Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,20 @@ Release tags use the form `vX.Y.Z` and match `package.json`. GitHub Releases car

## [Unreleased]

## [0.5.15] - 2026-09-23

### Changed

- Provider-specific cache shaping now runs after route selection, so Claude receives stable and rolling cache breakpoints while ChatGPT and xAI receive stable request cache keys without leaking internal metadata to arbitrary OpenAI-compatible providers.
- Claude OAuth requests now use the current Claude Code 2.1.280 client fingerprint.
- Usage normalization now reports cache reads, cache writes, uncached input, and logical input consistently across provider accounting conventions.

### Fixed

- Manual provider model validation is bounded instead of hanging indefinitely when an upstream accepts a connection but never answers.
- Claude cache prefixes remain reusable across human turns when volatile invocation context changes.
- Parallel Claude tool results, including supplemental image blocks, are serialized into one immediately following user message with all `tool_result` blocks first, preventing Anthropic request-order failures.

## [0.5.12] - 2026-08-12

### Fixed
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@gitcommit90/rerouted",
"productName": "ReRouted",
"version": "0.5.14",
"version": "0.5.15",
"description": "A local AI router for connected accounts, models, named routes, and automatic fallback.",
"author": "gitcommit90",
"license": "MIT",
Expand Down
22 changes: 20 additions & 2 deletions src/lib/model-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
const { redactString } = require("./logger");

const MAX_ERROR_BODY_LENGTH = 4096;
const DEFAULT_MODEL_TEST_TIMEOUT_MS = 60_000;

function safeErrorBody(value, maxLength = MAX_ERROR_BODY_LENGTH) {
const body = redactString(value);
Expand Down Expand Up @@ -85,10 +86,22 @@ function logFailure(logger, label, status, body) {
logger?.error?.(`Model test failed for ${label}`, { status, body: safeErrorBody(body) });
}

async function runProviderModelTest({ adapter, provider, model, onTokenRefresh, logger } = {}) {
async function runProviderModelTest({ adapter, provider, model, onTokenRefresh, logger, timeoutMs = DEFAULT_MODEL_TEST_TIMEOUT_MS } = {}) {
const label = `${provider?.name || provider?.type || "provider"}/${model}`;
const controller = new AbortController();
const boundedTimeoutMs = Math.max(1, Number(timeoutMs) || DEFAULT_MODEL_TEST_TIMEOUT_MS);
const timeoutError = new Error(`model test timed out after ${boundedTimeoutMs}ms`);
timeoutError.name = "TimeoutError";
timeoutError.code = "ETIMEDOUT";
let timer;
const timeout = new Promise((_resolve, reject) => {
timer = setTimeout(() => {
controller.abort(timeoutError);
reject(timeoutError);
}, boundedTimeoutMs);
});
try {
const result = await adapter.chat(
const request = adapter.chat(
{ ...provider },
{
model,
Expand All @@ -99,9 +112,11 @@ async function runProviderModelTest({ adapter, provider, model, onTokenRefresh,
stream: false,
},
stream: false,
signal: controller.signal,
onTokenRefresh,
}
);
const result = await Promise.race([request, timeout]);
const response = result && result.response ? result.response : result;
const inspection = await inspectModelTestResponse(response);
if (!inspection.ok) {
Expand All @@ -116,10 +131,13 @@ async function runProviderModelTest({ adapter, provider, model, onTokenRefresh,
const message = safeErrorBody(error?.message || String(error));
logFailure(logger, label, error?.status || null, message);
return { ok: false, error: `Model test failed: ${message}` };
} finally {
clearTimeout(timer);
}
}

module.exports = {
DEFAULT_MODEL_TEST_TIMEOUT_MS,
MAX_ERROR_BODY_LENGTH,
bodyHasUpstreamError,
inspectModelTestResponse,
Expand Down
39 changes: 38 additions & 1 deletion src/lib/providers/chatgpt.js
Original file line number Diff line number Diff line change
Expand Up @@ -130,11 +130,30 @@ function toolArguments(value) {
function toResponsesInput(messages, model, reasoningScope) {
const input = [];
const instructions = [];
const deferredDynamicContext = [];

for (const message of messages || []) {
if (!message || typeof message !== "object") continue;
if (message.role === "system") {
instructions.push(textFromOpenAiContent(message.content));
const cacheScope = message.extra_content?.openai?.cache_scope;
const text = textFromOpenAiContent(message.content);
if (cacheScope === "dynamic_context") {
deferredDynamicContext.push({
type: "message",
role: "developer",
content: toResponsesContent(message.content, "developer"),
});
} else if (cacheScope === "inline_context") {
input.push({
type: "message",
role: "developer",
content: toResponsesContent(message.content, "developer"),
});
} else {
// Unmarked clients retain the historical behavior. 1Helm explicitly
// marks only its durable identity/capability blocks as instructions.
instructions.push(text);
}
continue;
}
if (message.role === "tool") {
Expand Down Expand Up @@ -195,6 +214,21 @@ function toResponsesInput(messages, model, reasoningScope) {
}
}

if (deferredDynamicContext.length) {
// 1Helm's volatile time, recalled memory, session state, and invocation
// evidence belong immediately before the current user turn. Keeping them
// out of `instructions` leaves the durable instructions + append-only
// conversation as an exact provider-cache prefix across turns.
let insertionIndex = input.length;
for (let index = input.length - 1; index >= 0; index--) {
if (input[index]?.type === "message" && input[index]?.role === "user") {
insertionIndex = index;
break;
}
}
input.splice(insertionIndex, 0, ...deferredDynamicContext);
}

return { input, instructions };
}

Expand Down Expand Up @@ -252,6 +286,9 @@ function toResponsesBody(body, model, stream, { reasoningScope } = {}) {
if (body.parallel_tool_calls !== undefined) {
out.parallel_tool_calls = body.parallel_tool_calls;
}
if (body.prompt_cache_key !== undefined) {
out.prompt_cache_key = body.prompt_cache_key;
}
const include = Array.isArray(body.include) ? [...body.include] : [];
if (!include.includes("reasoning.encrypted_content")) {
include.push("reasoning.encrypted_content");
Expand Down
Loading
Loading