Skip to content

Commit df7fd90

Browse files
authored
Merge pull request #29 from gitcommit90/release-v0.5.15
Release ReRouted 0.5.15
2 parents e200e45 + a143d0c commit df7fd90

12 files changed

Lines changed: 553 additions & 57 deletions

‎CHANGELOG.md‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,20 @@ Release tags use the form `vX.Y.Z` and match `package.json`. GitHub Releases car
99

1010
## [Unreleased]
1111

12+
## [0.5.15] - 2026-09-23
13+
14+
### Changed
15+
16+
- 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.
17+
- Claude OAuth requests now use the current Claude Code 2.1.280 client fingerprint.
18+
- Usage normalization now reports cache reads, cache writes, uncached input, and logical input consistently across provider accounting conventions.
19+
20+
### Fixed
21+
22+
- Manual provider model validation is bounded instead of hanging indefinitely when an upstream accepts a connection but never answers.
23+
- Claude cache prefixes remain reusable across human turns when volatile invocation context changes.
24+
- 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.
25+
1226
## [0.5.12] - 2026-08-12
1327

1428
### Fixed

‎package-lock.json‎

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@gitcommit90/rerouted",
33
"productName": "ReRouted",
4-
"version": "0.5.14",
4+
"version": "0.5.15",
55
"description": "A local AI router for connected accounts, models, named routes, and automatic fallback.",
66
"author": "gitcommit90",
77
"license": "MIT",

‎src/lib/model-test.js‎

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
const { redactString } = require("./logger");
44

55
const MAX_ERROR_BODY_LENGTH = 4096;
6+
const DEFAULT_MODEL_TEST_TIMEOUT_MS = 60_000;
67

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

88-
async function runProviderModelTest({ adapter, provider, model, onTokenRefresh, logger } = {}) {
89+
async function runProviderModelTest({ adapter, provider, model, onTokenRefresh, logger, timeoutMs = DEFAULT_MODEL_TEST_TIMEOUT_MS } = {}) {
8990
const label = `${provider?.name || provider?.type || "provider"}/${model}`;
91+
const controller = new AbortController();
92+
const boundedTimeoutMs = Math.max(1, Number(timeoutMs) || DEFAULT_MODEL_TEST_TIMEOUT_MS);
93+
const timeoutError = new Error(`model test timed out after ${boundedTimeoutMs}ms`);
94+
timeoutError.name = "TimeoutError";
95+
timeoutError.code = "ETIMEDOUT";
96+
let timer;
97+
const timeout = new Promise((_resolve, reject) => {
98+
timer = setTimeout(() => {
99+
controller.abort(timeoutError);
100+
reject(timeoutError);
101+
}, boundedTimeoutMs);
102+
});
90103
try {
91-
const result = await adapter.chat(
104+
const request = adapter.chat(
92105
{ ...provider },
93106
{
94107
model,
@@ -99,9 +112,11 @@ async function runProviderModelTest({ adapter, provider, model, onTokenRefresh,
99112
stream: false,
100113
},
101114
stream: false,
115+
signal: controller.signal,
102116
onTokenRefresh,
103117
}
104118
);
119+
const result = await Promise.race([request, timeout]);
105120
const response = result && result.response ? result.response : result;
106121
const inspection = await inspectModelTestResponse(response);
107122
if (!inspection.ok) {
@@ -116,10 +131,13 @@ async function runProviderModelTest({ adapter, provider, model, onTokenRefresh,
116131
const message = safeErrorBody(error?.message || String(error));
117132
logFailure(logger, label, error?.status || null, message);
118133
return { ok: false, error: `Model test failed: ${message}` };
134+
} finally {
135+
clearTimeout(timer);
119136
}
120137
}
121138

122139
module.exports = {
140+
DEFAULT_MODEL_TEST_TIMEOUT_MS,
123141
MAX_ERROR_BODY_LENGTH,
124142
bodyHasUpstreamError,
125143
inspectModelTestResponse,

‎src/lib/providers/chatgpt.js‎

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,11 +130,30 @@ function toolArguments(value) {
130130
function toResponsesInput(messages, model, reasoningScope) {
131131
const input = [];
132132
const instructions = [];
133+
const deferredDynamicContext = [];
133134

134135
for (const message of messages || []) {
135136
if (!message || typeof message !== "object") continue;
136137
if (message.role === "system") {
137-
instructions.push(textFromOpenAiContent(message.content));
138+
const cacheScope = message.extra_content?.openai?.cache_scope;
139+
const text = textFromOpenAiContent(message.content);
140+
if (cacheScope === "dynamic_context") {
141+
deferredDynamicContext.push({
142+
type: "message",
143+
role: "developer",
144+
content: toResponsesContent(message.content, "developer"),
145+
});
146+
} else if (cacheScope === "inline_context") {
147+
input.push({
148+
type: "message",
149+
role: "developer",
150+
content: toResponsesContent(message.content, "developer"),
151+
});
152+
} else {
153+
// Unmarked clients retain the historical behavior. 1Helm explicitly
154+
// marks only its durable identity/capability blocks as instructions.
155+
instructions.push(text);
156+
}
138157
continue;
139158
}
140159
if (message.role === "tool") {
@@ -195,6 +214,21 @@ function toResponsesInput(messages, model, reasoningScope) {
195214
}
196215
}
197216

217+
if (deferredDynamicContext.length) {
218+
// 1Helm's volatile time, recalled memory, session state, and invocation
219+
// evidence belong immediately before the current user turn. Keeping them
220+
// out of `instructions` leaves the durable instructions + append-only
221+
// conversation as an exact provider-cache prefix across turns.
222+
let insertionIndex = input.length;
223+
for (let index = input.length - 1; index >= 0; index--) {
224+
if (input[index]?.type === "message" && input[index]?.role === "user") {
225+
insertionIndex = index;
226+
break;
227+
}
228+
}
229+
input.splice(insertionIndex, 0, ...deferredDynamicContext);
230+
}
231+
198232
return { input, instructions };
199233
}
200234

@@ -252,6 +286,9 @@ function toResponsesBody(body, model, stream, { reasoningScope } = {}) {
252286
if (body.parallel_tool_calls !== undefined) {
253287
out.parallel_tool_calls = body.parallel_tool_calls;
254288
}
289+
if (body.prompt_cache_key !== undefined) {
290+
out.prompt_cache_key = body.prompt_cache_key;
291+
}
255292
const include = Array.isArray(body.include) ? [...body.include] : [];
256293
if (!include.includes("reasoning.encrypted_content")) {
257294
include.push("reasoning.encrypted_content");

0 commit comments

Comments
 (0)