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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

- Exact-model and account-wide model-lock timestamps are now validated independently, so an expired exact lock cannot mask an active account-wide lock. Ports decolua/9router#3516. Closes #578.
- Thinking effort now reaches Ollama as `think`, Z.AI as both scalar and `reasoning.effort`, OpenCode free models as its gateway enum, and Grok CLI 4.6 virtual models through `xhigh`.
- Claude-format requests now normalize trailing assistant prefills into a continuation user turn (or matching error `tool_result`), unless `X-9Router-Assistant-Prefill: preserve` is set; falsy Claude `tools[].type` values now default to `custom`. Ports decolua/9router#3506 and #3484. Closes #580.

- Qoder refreshes its fallback catalog with `lite`, Qwen3.8-Max, and GLM-5.3; opaque Qoder model IDs now expose correct reasoning, vision, context, and output capabilities, and vision models retain OpenAI URL/data-URI and Claude base64 image blocks in outgoing chat payloads. Ports decolua/9router#3555. Closes #583.
- Groq replaces decommissioned seed model IDs with its current production catalog, discovers live models through the authenticated OpenAI-compatible `/models` endpoint, and accepts future model IDs via passthrough. Closes #582.
Expand Down
6 changes: 6 additions & 0 deletions docs/reference/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,12 @@ curl http://localhost:20128/v1/messages \

This route is useful for Claude-compatible clients and translation testing.

Claude-format requests normally end on a user turn. DurinDoor converts a trailing assistant text prefill into a continuation user turn and pairs trailing `tool_use` blocks with error `tool_result` blocks so strict Messages endpoints do not reject the request. Clients that require native assistant-prefill behavior can opt out per request:

```http
X-9Router-Assistant-Prefill: preserve
```

## Count Tokens

```bash
Expand Down
3 changes: 2 additions & 1 deletion open-sse/executors/github.js
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,8 @@ export class GithubExecutor extends BaseExecutor {
* the "github" branch KEEPS unvalidated blocks (see normalizeClaudePassthrough),
* so we force the validating path to strip them for this route.
*/
normalizeClaudePassthrough(transformedBody, model, "claude", requestContext?.modelCapabilities?.maxOutput ?? null);
/** Preserve the caller's assistant-prefill policy through the native GitHub Messages cleanup pass. */
normalizeClaudePassthrough(transformedBody, model, "claude", requestContext?.modelCapabilities?.maxOutput ?? null, { rawHeaders: requestContext?.clientHeaders });

/**
* Parallel-tool-use guard (Codex #291 P2): translateRequest converts OpenAI
Expand Down
4 changes: 2 additions & 2 deletions open-sse/handlers/chatCore.js
Original file line number Diff line number Diff line change
Expand Up @@ -537,7 +537,7 @@ export async function handleChatCore({ body, modelInfo, credentials: rawCredenti
});
}
// Normalize newer Cowork/CC beta shapes (adaptive thinking, mid-conversation system) the API rejects
if (clientTool === "claude") normalizeClaudePassthrough(translatedBody, translatedBody.model, provider, modelCapabilities?.maxOutput ?? null, { foldSystemTurns: true });
if (clientTool === "claude") normalizeClaudePassthrough(translatedBody, translatedBody.model, provider, modelCapabilities?.maxOutput ?? null, { foldSystemTurns: true, rawHeaders: clientRawRequest?.headers });
/**
* Native OpenAI Responses and Claude Messages bodies carry top-level
* `stream`; Gemini-family native bodies do not and reject an injected key.
Expand Down Expand Up @@ -652,7 +652,7 @@ export async function handleChatCore({ body, modelInfo, credentials: rawCredenti
// Claude tool schema requires `type` to be explicitly set; strict gateways (e.g., MiniMax)
// reject legacy payloads that omit it with HTTP 400. Default to "custom" when missing.
if (finalFormat === "claude" && Array.isArray(translatedBody.tools)) {
translatedBody.tools = translatedBody.tools.map((tool) => tool.type ? tool : { type: "custom", ...tool });
translatedBody.tools = translatedBody.tools.map((tool) => tool.type ? tool : { ...tool, type: "custom" });
}

// Token-saver summary parts, printed as one "⚙" line at the end (only active ones)
Expand Down
63 changes: 63 additions & 0 deletions open-sse/translator/concerns/assistantPrefillPolicy.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { isFunction, isObject, isString } from "../../../src/shared/utils/typeChecks.js";
import { CLAUDE_BLOCK, ROLE } from "../schema/index.js";

const ASSISTANT_CONTINUATION_PROMPT = "Continue from the assistant response above without repeating it.";
const INCOMPLETE_TOOL_RESULT = "Tool execution was not completed before this request continued.";
const PRESERVE_HEADER = "x-9router-assistant-prefill";

function getHeader(headers, name) {
if (!headers) return null;
if (isFunction(headers.get)) return headers.get(name);
if (!isObject(headers)) return null;

const key = Object.keys(headers).find((candidate) => candidate.toLowerCase() === name);
const value = key === undefined ? null : headers[key];
return Array.isArray(value) ? value[0] : value;
}

function hasText(content) {
if (isString(content)) return Boolean(content.trim());
return Array.isArray(content) && content.some((block) =>
block?.type === CLAUDE_BLOCK.TEXT && isString(block.text) && Boolean(block.text.trim())
);
}

/**
* Make Claude-format conversations end on a user turn unless a client explicitly
* requests native assistant-prefill semantics through the compatibility header.
* Mutates and returns `body` to match existing translator normalization helpers.
*/
export function applyAssistantPrefillPolicy(body, rawHeaders = null) {
if (!Array.isArray(body?.messages)) return body;
if (String(getHeader(rawHeaders, PRESERVE_HEADER) || "").toLowerCase() === "preserve") return body;

const trailingAssistant = body.messages.at(-1);
if (trailingAssistant?.role !== ROLE.ASSISTANT) return body;

const toolUses = Array.isArray(trailingAssistant.content)
? trailingAssistant.content.filter((block) => block?.type === CLAUDE_BLOCK.TOOL_USE && block.id)
: [];
if (toolUses.length > 0) {
body.messages.push({
role: ROLE.USER,
content: toolUses.map((toolUse) => ({
type: CLAUDE_BLOCK.TOOL_RESULT,
tool_use_id: toolUse.id,
is_error: true,
content: INCOMPLETE_TOOL_RESULT,
})),
});
return body;
}

if (!hasText(trailingAssistant.content)) {
body.messages.pop();
return body;
}

body.messages.push({
role: ROLE.USER,
content: [{ type: CLAUDE_BLOCK.TEXT, text: ASSISTANT_CONTINUATION_PROMPT }],
});
return body;
}
13 changes: 9 additions & 4 deletions open-sse/translator/formats/claude.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { PROVIDERS } from "../../providers/index.js";
import { getCapabilitiesForModel } from "../../providers/capabilities.js";
import { DEFAULT_MAX_TOKENS } from "../../config/runtimeConfig.js";
import { isObject, isString } from "../../../src/shared/utils/typeChecks.js";
import { applyAssistantPrefillPolicy } from "../concerns/assistantPrefillPolicy.js";

const CACHE_CONTROL_5M = { type: "ephemeral" };
const CACHE_CONTROL_1H = { type: "ephemeral", ttl: "1h" };
Expand Down Expand Up @@ -391,7 +392,9 @@ export function normalizeClaudePassthrough(body, model = "", provider = "claude"
}
}

return reconcileClaudeThinkingBudget(body, provider, customMaxOutput);
reconcileClaudeThinkingBudget(body, provider, customMaxOutput);
applyAssistantPrefillPolicy(body, options?.rawHeaders);
return body;
}

// Prepare request for Claude format endpoints
Expand Down Expand Up @@ -440,7 +443,7 @@ export function prepareClaudeRequest(body, provider = null, apiKey = null, conne
}

// Keep final assistant even if empty, otherwise check valid content
const isFinalAssistant = i === len - 1 && msg.role === "assistant";
const isFinalAssistant = i === len - 1 && msg.role === ROLE.ASSISTANT;
if (isFinalAssistant || hasValidContent(msg)) {
filtered.push(msg);
}
Expand All @@ -451,18 +454,20 @@ export function prepareClaudeRequest(body, provider = null, apiKey = null, conne
filtered = fixToolUseOrdering(filtered);

body.messages = filtered;
applyAssistantPrefillPolicy(body, rawHeaders);
filtered = body.messages;

// Check if thinking is enabled AND last message is from user
const lastMessage = filtered[filtered.length - 1];
const lastMessageIsUser = lastMessage?.role === "user";
const lastMessageIsUser = lastMessage?.role === ROLE.USER;
const thinkingEnabled = body.thinking?.type === "enabled" && lastMessageIsUser;

// Pass 2 (reverse): add cache_control to last assistant + handle thinking for Anthropic
let lastAssistantProcessed = false;
for (let i = filtered.length - 1; i >= 0; i--) {
const msg = filtered[i];

if (msg.role === "assistant" && Array.isArray(msg.content)) {
if (msg.role === ROLE.ASSISTANT && Array.isArray(msg.content)) {
// Add cache_control to last non-thinking block of first (from end) assistant with content
// thinking/redacted_thinking blocks do not support cache_control
if (allowCacheControl && !lastAssistantProcessed && msg.content.length > 0) {
Expand Down
9 changes: 5 additions & 4 deletions open-sse/translator/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -216,10 +216,11 @@ export function translateRequest(sourceFormat, targetFormat, model, body, stream
provider === "ollama" ||
provider === "ollama-local";
if (normalizesNativeClaudeTransport) {
// Ollama implements the Messages wire contract but not Anthropic's
// model-specific beta matrix. Normalize system turns without applying
// Claude-family model downgrades to the upstream Ollama model id.
result = normalizeClaudePassthrough(result, "", provider);
/**
* Ollama implements the Messages wire contract but not Anthropic's model beta matrix.
* Normalize against its model id while preserving the caller's assistant-prefill policy.
*/
result = normalizeClaudePassthrough(result, "", provider, null, { rawHeaders: credentials?.rawHeaders });
}
const apiKey = credentials?.accessToken || credentials?.apiKey || null;
const customMaxOutput = resolvedTranslationContext.modelCapabilities?.maxOutput ?? null;
Expand Down
83 changes: 83 additions & 0 deletions tests/translator/assistant-prefill-policy.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { describe, expect, it } from "vitest";
import {
normalizeClaudePassthrough,
prepareClaudeRequest,
} from "../../open-sse/translator/formats/claude.js";

const continuationPattern = /continue.*without repeating/i;

function translated(messages, headers = null) {
return prepareClaudeRequest({
model: "claude-sonnet-4-6",
messages: structuredClone(messages),
}, "anthropic", null, null, headers);
}

function passthrough(messages, headers = null) {
return normalizeClaudePassthrough({
model: "claude-sonnet-4-6",
messages: structuredClone(messages),
}, "claude-sonnet-4-6", "claude", null, { rawHeaders: headers });
}

describe.each([
["translated Claude target", translated],
["native Claude passthrough", passthrough],
])("assistant prefill policy — %s", (_name, run) => {
it("turns trailing assistant text into a continuation turn", () => {
const out = run([
{ role: "user", content: "Start" },
{ role: "assistant", content: [{ type: "text", text: "Partial answer" }] },
]);

expect(out.messages.map((message) => message.role)).toEqual(["user", "assistant", "user"]);
expect(JSON.stringify(out.messages.at(-1).content)).toMatch(continuationPattern);
});

it("drops an empty trailing assistant", () => {
const out = run([
{ role: "user", content: "Start" },
{ role: "assistant", content: [] },
]);

expect(out.messages).toHaveLength(1);
expect(out.messages[0].role).toBe("user");
});

it("completes every id-bearing trailing tool_use with an error tool_result", () => {
const out = run([
{ role: "user", content: "Start" },
{ role: "assistant", content: [
{ type: "tool_use", id: "tool-1", name: "lookup", input: {} },
{ type: "tool_use", name: "missing-id", input: {} },
] },
]);

expect(out.messages.at(-1)).toEqual({
role: "user",
content: [{
type: "tool_result",
tool_use_id: "tool-1",
is_error: true,
content: expect.stringMatching(/not completed/i),
}],
});
});

it("keeps a final user turn unchanged", () => {
const out = run([{ role: "user", content: "Start" }]);

expect(out.messages).toHaveLength(1);
expect(out.messages[0].role).toBe("user");
});

it("preserves assistant prefill when explicitly requested", () => {
const out = run([
{ role: "user", content: "Start" },
{ role: "assistant", content: "Partial answer" },
], new Headers({ "X-9Router-Assistant-Prefill": "preserve" }));

expect(out.messages).toHaveLength(2);
expect(JSON.stringify(out.messages.at(-1))).toContain("Partial answer");
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,12 @@ describe("#2401 reasoning/thinking bridge (request)", () => {
expect(assistant).toBeTruthy();
expect(assistant.reasoning_content ?? "").not.toContain("opaque-encrypted-payload-CCC");

const final = translateRequest(FORMATS.OPENAI, FORMATS.CLAUDE, MODEL, mid, true, {}, "anthropic");
// This case isolates the in-process redacted-thinking carrier. Preserve the
// terminal assistant deliberately; default continuation behavior is covered
// by the translated/native assistant-prefill-policy regression suite.
const final = translateRequest(FORMATS.OPENAI, FORMATS.CLAUDE, MODEL, mid, true, {
rawHeaders: { "x-9router-assistant-prefill": "preserve" },
}, "anthropic");
const back = final.messages.find((m) => m.role === "assistant");
expect(back).toBeTruthy();
expect(back.content).toEqual([{ type: "redacted_thinking", data: "opaque-encrypted-payload-CCC" }]);
Expand Down
17 changes: 11 additions & 6 deletions tests/unit/claude-cache-control.test.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { describe, expect, it } from "vitest";
import { anchorClaudeCache, normalizeClaudePassthrough } from "../../open-sse/translator/formats/claude.js";

// These cases isolate system-turn folding/hoisting and intentionally assert the
// pre-policy assistant shape. Opt out explicitly; default trailing-assistant
// normalization is covered for both paths in assistant-prefill-policy.test.js.
const PRESERVE_ASSISTANT_PREFILL = { rawHeaders: { "x-9router-assistant-prefill": "preserve" } };

describe("Claude passthrough cache-control anchoring", () => {
it("re-anchors markers after preparation reshapes messages", () => {
const body = {
Expand Down Expand Up @@ -104,7 +109,7 @@ describe("normalizeClaudePassthrough default behavior (foldSystemTurns off)", ()
],
};

normalizeClaudePassthrough(body);
normalizeClaudePassthrough(body, "", "claude", null, PRESERVE_ASSISTANT_PREFILL);

expect(body.system).toEqual([{ type: "text", text: "volatile reminder" }]);
expect(body.messages).toEqual([
Expand Down Expand Up @@ -165,7 +170,7 @@ describe("normalizeClaudePassthrough foldSystemTurns: true (native chatCore pass
],
};

normalizeClaudePassthrough(body, "", "claude", null, { foldSystemTurns: true });
normalizeClaudePassthrough(body, "", "claude", null, { ...PRESERVE_ASSISTANT_PREFILL, foldSystemTurns: true });

// Top-level system must NOT pick up the volatile system message.
expect(body.system).toEqual([{ type: "text", text: "stable" }]);
Expand Down Expand Up @@ -197,7 +202,7 @@ describe("normalizeClaudePassthrough foldSystemTurns: true (native chatCore pass
],
};

normalizeClaudePassthrough(body, "", "claude", null, { foldSystemTurns: true });
normalizeClaudePassthrough(body, "", "claude", null, { ...PRESERVE_ASSISTANT_PREFILL, foldSystemTurns: true });

// System reminder must attach to the FOLLOWING user turn, not the tool_result one.
expect(body.messages).toEqual([
Expand Down Expand Up @@ -236,7 +241,7 @@ describe("normalizeClaudePassthrough foldSystemTurns: true (native chatCore pass
],
};

normalizeClaudePassthrough(body, "", "claude", null, { foldSystemTurns: true });
normalizeClaudePassthrough(body, "", "claude", null, { ...PRESERVE_ASSISTANT_PREFILL, foldSystemTurns: true });

// No synthesized standalone user turn; the next existing user turn absorbs everything.
expect(body.messages).toHaveLength(3);
Expand Down Expand Up @@ -265,7 +270,7 @@ describe("normalizeClaudePassthrough foldSystemTurns: true (native chatCore pass
],
};

normalizeClaudePassthrough(body, "", "claude", null, { foldSystemTurns: true });
normalizeClaudePassthrough(body, "", "claude", null, { ...PRESERVE_ASSISTANT_PREFILL, foldSystemTurns: true });

expect(body.messages.every((m) => m.role !== "system")).toBe(true);
expect(body.messages).toEqual([
Expand Down Expand Up @@ -340,7 +345,7 @@ describe("normalizeClaudePassthrough foldSystemTurns: true (native chatCore pass
],
};

normalizeClaudePassthrough(body, "", "claude", null, { foldSystemTurns: true });
normalizeClaudePassthrough(body, "", "claude", null, { ...PRESERVE_ASSISTANT_PREFILL, foldSystemTurns: true });

expect(body.messages).toEqual([
{ role: "user", content: "ask" },
Expand Down
Loading
Loading