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 CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ Env, read at proxy startup (canonical annotated template: `.env.sidecar.example`
`/v1/messages` is the **native** ingress; `POST /v1/chat/completions` is a **translated** one — converted to Anthropic shape, run through the **same** `getHandlerForRequest` → `ComposedHandler.handle` pipeline, then translated back. An OpenAI client (sk-agent, any `AsyncOpenAI` consumer) therefore inherits the routing cascade, budget failover, accounting and leak policy for free, by changing `base_url` alone.

- **Never-hang holds here too**: a malformed stream degrades to a single terminal chunk + `data: [DONE]`. `thinking` blocks surface as `reasoning_content`; `stop_reason` maps to `finish_reason`.
- **Request-side thinking controls are mapped (#435)** — `enable_thinking` (top level or under `chat_template_kwargs`) and `reasoning_effort` land on the house `thinking` object: `false` → `{type:"disabled"}`, `true` → enabled with a 2048 default budget, effort → the inverse of `openai-api-format`'s budget ladder (2000/8000/24000/32000). An enabled block is always legal (≥1024, `< max_tokens`): against a client-sent `max_tokens` it clamps; against our own 4096 placeholder it *raises* the placeholder — clamping "high" to 4095 would invert the ask to "minimal" downstream. Same fix closes the Qwen openai-wire passthrough, which mapped *any* `originalRequest.thinking` to `enable_thinking: true` — the exact inversion of a disabled ask. Measured end-to-end on gc@glm-5.3 via the hub (2026-10-11): 36→3 completion tokens, reasoning gone.
- **A failover notice never rides the content here (#229)** — it arrives in the `x-claudish-failover-notice` response header (base64 of the UTF-8 text), on both the streaming and JSON responses, and the relay passes it through. `/v1/messages` keeps the in-content block 0 for the Claude Code agent loop. The gate is the route (`noticePolicyForIngress`), never a user-agent: a programmatic consumer reading a notice as its answer is how an empty model output passed a code review on 2026-09-23.
- **The relay is path-aware** — a sidecar forwards to the *same* route the client hit, so an OpenAI request reaches the hub's `/v1/chat/completions` in NOMINAL mode and the resilience model is preserved. The deep liveness probe stays on `/v1/messages`.
- Out of scope for now: `/v1/models` discovery, OpenAI web-search interception, per-role thinking policy on this path. Detail: `docs/reference/openai-ingress.md`.
Expand Down
41 changes: 41 additions & 0 deletions docs/reference/openai-ingress.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,49 @@ changing `base_url` alone.
| `tool` role | `tool_result` |
| function tools | `input_schema` |
| `tool_choice` | mapped |
| `enable_thinking` / `chat_template_kwargs.enable_thinking` / `reasoning_effort` | `thinking` object — see #435 below |
| *(absent)* `max_tokens` | defaulted — Anthropic requires it, OpenAI does not |

## Request-side thinking controls (#435)

vLLM-style `enable_thinking` (top level, or under `chat_template_kwargs`) and OpenAI's
`reasoning_effort` were **silently dropped** by the converter until #435: the reasoning decision
fell to the served dialect's policy (`CLAUDISH_GLM_THINKING` passthrough by default = GLM thinks;
`CLAUDISH_QWEN_THINKING` disabled) and the client's ask never reached `prepareRequest`. They now
map onto the house `thinking` object — the seam every dialect already consults
(`originalRequest.thinking`):

| Client field | Mapped shape |
|---|---|
| `enable_thinking: false` | `{type: "disabled"}` |
| `enable_thinking: true` | `{type: "enabled", budget_tokens: 2048}` |
| `reasoning_effort: minimal/low/medium/high` | `{type: "enabled", budget_tokens: 2000/8000/24000/32000}` |

- Top-level `enable_thinking` wins over the `chat_template_kwargs` spelling; an unknown
`reasoning_effort` value is ignored (no partial mapping); no controls → no `thinking` field
(pre-#435 behavior).
- The effort ladder is the exact inverse of `openai-api-format.ts`'s budget→effort map, so an
o1/o3 round trip (`reasoning_effort` in, budget back out) is stable.
- **Budget legality**: an enabled block always carries `budget_tokens ≥ 1024` (native Anthropic
floor) and `< max_tokens` (Qwen requirement). Against a **client-sent** `max_tokens` the budget
clamps into it; against **our 4096 placeholder** it instead *raises* the placeholder to
`budget + 1` — clamping "high" (32000) to 4095 would invert the ask to "minimal" downstream.
No room for a legal block (tiny `max_tokens`) → the mapping is skipped; dialect policy decides.

**Downstream fates** (dialect `prepareRequest`): GLM openai wire forwards `{type:"disabled"}`
verbatim and reduces an enabled block to its binary `{type:"enabled"}`; Qwen maps to
`enable_thinking` + `thinking_budget` (openai wire) or keeps the native object (anthropic wire);
o1/o3 remaps budget → `reasoning_effort`. `#435` also fixed the Qwen openai-wire passthrough,
which mapped *any* `originalRequest.thinking` to `enable_thinking: true` — inverting an explicit
disabled ask.

**Measured on the real lane (2026-10-11)**, probe proxy → hub `/v1/messages` → sonnet nominal
`gc@glm-5.3`, prompt `"Reponds exactement: ok"`, `max_tokens: 200`: no controls → **36 completion
tokens, 131 chars of `reasoning_content`**; `enable_thinking: false` → **3 completion tokens,
reasoning absent** — the 37→3 shape of the 2026-08-20 probe of record, end to end through the
ingress. Pins: `openai-request-to-anthropic.test.ts` (mapping matrix),
`qwen-model-dialect.test.ts` + `glm-model-dialect.test.ts` (dialect ends).

## Response translators

`handlers/shared/anthropic-to-openai.ts`:
Expand Down
60 changes: 60 additions & 0 deletions packages/cli/src/adapters/glm-model-dialect.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/**
* GLMModelDialect — #435 ingress-interaction pins.
*
* The OpenAI ingress now maps the client's `enable_thinking` /
* `chat_template_kwargs.enable_thinking` / `reasoning_effort` onto the house
* `thinking` object (openai-request-to-anthropic.ts). These pins hold the GLM
* end of that contract on the OpenAI wire (the `gc@` Coding Plan lane the
* issue was reported on): what the dialect does with each mapped shape.
*
* Probe of record (2026-08-20, gc@ glm-5.3, "Reponds exactement: ok"):
* no field → 37 out tokens / 131 reasoning chars (GLM thinks by default)
* {type:"disabled"} → 3 out tokens / 0 reasoning
* The default policy is `passthrough` — no field set unless someone asked.
*/

import { describe, it, expect, afterEach } from "bun:test";
import { GLMModelDialect } from "./glm-model-dialect.js";

const ORIGINAL = process.env.CLAUDISH_GLM_THINKING;

afterEach(() => {
if (ORIGINAL === undefined) delete process.env.CLAUDISH_GLM_THINKING;
else process.env.CLAUDISH_GLM_THINKING = ORIGINAL;
});

function dialect() {
return new GLMModelDialect("glm-5.3");
}

const OPENAI = { wireFormat: "openai-sse" as const };

describe("GLMModelDialect — #435 ingress-mapped thinking (OpenAI wire)", () => {
it("ingress {type:'disabled'} → wire {type:'disabled'} — the 37→3 probe shape", () => {
delete process.env.CLAUDISH_GLM_THINKING; // passthrough default
const payload: any = {};
dialect().prepareRequest(payload, { thinking: { type: "disabled" } }, OPENAI);
expect(payload.thinking).toEqual({ type: "disabled" });
});

it("ingress enabled block → binary {type:'enabled'} (budget dropped — GLM has no budget knob)", () => {
delete process.env.CLAUDISH_GLM_THINKING;
const payload: any = {};
dialect().prepareRequest(payload, { thinking: { type: "enabled", budget_tokens: 8000 } }, OPENAI);
expect(payload.thinking).toEqual({ type: "enabled" });
});

it("no thinking controls sent → no field on the wire (GLM thinks by default — documented passthrough)", () => {
delete process.env.CLAUDISH_GLM_THINKING;
const payload: any = {};
dialect().prepareRequest(payload, {}, OPENAI);
expect(payload.thinking).toBeUndefined();
});

it("CLAUDISH_GLM_THINKING=disabled overrides even an ingress enabled ask", () => {
process.env.CLAUDISH_GLM_THINKING = "disabled";
const payload: any = {};
dialect().prepareRequest(payload, { thinking: { type: "enabled", budget_tokens: 32000 } }, OPENAI);
expect(payload.thinking).toEqual({ type: "disabled" });
});
});
35 changes: 35 additions & 0 deletions packages/cli/src/adapters/qwen-model-dialect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,41 @@ describe("QwenModelDialect — OpenAI-compatible wire", () => {
});
});

describe("QwenModelDialect — #435 ingress-mapped thinking", () => {
it("passthrough + ingress enabled block → enable_thinking + thinking_budget on the OpenAI wire", () => {
process.env.CLAUDISH_QWEN_THINKING = "passthrough";
const payload: any = {};
dialect().prepareRequest(payload, { thinking: { type: "enabled", budget_tokens: 8000 } }, OPENAI);
expect(payload.enable_thinking).toBe(true);
expect(payload.thinking_budget).toBe(8000);
expect(payload.thinking).toBeUndefined();
});

it("passthrough + ingress disabled block stays disabled on the anthropic wire", () => {
process.env.CLAUDISH_QWEN_THINKING = "passthrough";
const payload: any = {};
dialect().prepareRequest(payload, { thinking: { type: "disabled" } }, ANTHROPIC);
expect(payload.thinking).toEqual({ type: "disabled" });
});

it("#435 passthrough + ingress DISABLED block → enable_thinking:false (never the inverted true)", () => {
process.env.CLAUDISH_QWEN_THINKING = "passthrough";
const payload: any = {};
dialect().prepareRequest(payload, { thinking: { type: "disabled" } }, OPENAI);
expect(payload.enable_thinking).toBe(false);
expect(payload.thinking_budget).toBeUndefined();
expect(payload.thinking).toBeUndefined();
});

it("the default disabled policy overrides an ingress enabled ask (budget-lane contract, unchanged by #435)", () => {
delete process.env.CLAUDISH_QWEN_THINKING;
const payload: any = {};
dialect().prepareRequest(payload, { thinking: { type: "enabled", budget_tokens: 8000 } }, OPENAI);
expect(payload.enable_thinking).toBe(false);
expect(payload.thinking_budget).toBeUndefined();
});
});

describe("QwenModelDialect — policy parsing", () => {
it("falls back to 'disabled' on an unrecognized value", () => {
process.env.CLAUDISH_QWEN_THINKING = "yes-please";
Expand Down
11 changes: 11 additions & 0 deletions packages/cli/src/adapters/qwen-model-dialect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,17 @@ export class QwenModelDialect extends BaseAPIFormat {
}

if (originalRequest.thinking) {
// #435: the OpenAI ingress can now carry an explicit DISABLED block (a
// client's `enable_thinking:false`). The historical mapping assumed
// thinking-present = enabled-ask and would have sent `enable_thinking:
// true` for it — the exact inversion of the client's ask.
if ((originalRequest.thinking as { type?: string }).type === "disabled") {
request.enable_thinking = false;
delete request.thinking_budget;
delete request.thinking;
log("[QwenModelDialect] openai wire: enable_thinking=false (client asked)");
return request;
}
const { budget_tokens } = originalRequest.thinking;

// Qwen specific parameters
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -251,3 +251,72 @@ describe("convertOpenAIRequestToAnthropic", () => {
expect(out.max_tokens).toBe(4096);
});
});

describe("#435 — request-side thinking controls reach the Anthropic `thinking` object", () => {
const base = { model: "glm-5.3", messages: [{ role: "user", content: "hi" }] };

test("enable_thinking:false → {type:'disabled'} — the shape GLM forwards verbatim (37→3 probe)", () => {
const out = convertOpenAIRequestToAnthropic({ ...base, enable_thinking: false });
expect(out.thinking).toEqual({ type: "disabled" });
});

test("chat_template_kwargs.enable_thinking:false → same disabled block (vLLM spelling)", () => {
const out = convertOpenAIRequestToAnthropic({ ...base, chat_template_kwargs: { enable_thinking: false } });
expect(out.thinking).toEqual({ type: "disabled" });
});

test("top-level enable_thinking wins over chat_template_kwargs when both present", () => {
const out = convertOpenAIRequestToAnthropic({
...base,
enable_thinking: false,
chat_template_kwargs: { enable_thinking: true },
});
expect(out.thinking).toEqual({ type: "disabled" });
});

test("enable_thinking:true → enabled block with a legal default budget (native lane needs ≥1024)", () => {
const out = convertOpenAIRequestToAnthropic({ ...base, enable_thinking: true });
expect(out.thinking).toEqual({ type: "enabled", budget_tokens: 2048 });
});

test("reasoning_effort maps onto budget_tokens — the exact inverse of openai-api-format's ladder", () => {
for (const [effort, budget] of [["minimal", 2000], ["low", 8000], ["medium", 24000], ["high", 32000]] as const) {
const out = convertOpenAIRequestToAnthropic({ ...base, reasoning_effort: effort });
expect(out.thinking).toEqual({ type: "enabled", budget_tokens: budget });
}
});

test("a defaulted max_tokens rises to fit the effort budget — our 4096 placeholder must not invert 'high' into 'minimal'", () => {
const out = convertOpenAIRequestToAnthropic({ ...base, reasoning_effort: "high" });
expect(out.thinking).toEqual({ type: "enabled", budget_tokens: 32000 });
expect(out.max_tokens).toBe(32001); // budget < max_tokens holds on every wire
// A small effort still inside the placeholder leaves it untouched.
const small = convertOpenAIRequestToAnthropic({ ...base, reasoning_effort: "minimal" });
expect(small.max_tokens).toBe(4096);
});

test("enable_thinking:true + reasoning_effort — the effort sizes the budget", () => {
const out = convertOpenAIRequestToAnthropic({ ...base, enable_thinking: true, reasoning_effort: "low" });
expect(out.thinking).toEqual({ type: "enabled", budget_tokens: 8000 });
});

test("budget clamps to max_tokens - 1 (Qwen requires budget < max_tokens)", () => {
const out = convertOpenAIRequestToAnthropic({ ...base, reasoning_effort: "high", max_tokens: 2000 });
expect(out.thinking).toEqual({ type: "enabled", budget_tokens: 1999 });
});

test("no room for a legal enabled block (max_tokens ≤ 1024) → thinking left unset, dialect policy decides", () => {
const out = convertOpenAIRequestToAnthropic({ ...base, enable_thinking: true, max_tokens: 500 });
expect(out.thinking).toBeUndefined();
});

test("unknown reasoning_effort value is ignored — no partial mapping", () => {
const out = convertOpenAIRequestToAnthropic({ ...base, reasoning_effort: "ultra" });
expect(out.thinking).toBeUndefined();
});

test("no thinking controls → no thinking field (pre-#435 behavior unchanged)", () => {
const out = convertOpenAIRequestToAnthropic({ ...base });
expect(out.thinking).toBeUndefined();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -215,13 +215,15 @@ export function convertOpenAIRequestToAnthropic(openai: any): any {
out.messages = transformed;

// Sampling / stop params.
let maxTokensDefaulted = false;
if (typeof src.max_tokens === "number") {
out.max_tokens = src.max_tokens;
} else if (typeof src.max_completion_tokens === "number") {
// o1+/reasoning models use max_completion_tokens.
out.max_tokens = src.max_completion_tokens;
} else {
out.max_tokens = 4096; // Anthropic requires max_tokens; OpenAI doesn't send it.
maxTokensDefaulted = true;
}
if (typeof src.temperature === "number") out.temperature = src.temperature;
if (typeof src.top_p === "number") out.top_p = src.top_p;
Expand All @@ -230,6 +232,67 @@ export function convertOpenAIRequestToAnthropic(openai: any): any {
}
if (src.user) out.metadata = { ...(out.metadata ?? {}), user_id: src.user };

// #435 — request-side thinking controls. vLLM-style `enable_thinking` (top
// level or under `chat_template_kwargs`) and OpenAI's `reasoning_effort` were
// silently DROPPED here, so the reasoning decision fell to the served
// dialect's policy (CLAUDISH_GLM_THINKING passthrough by default = GLM thinks;
// CLAUDISH_QWEN_THINKING disabled) — the client's ask never reached
// `prepareRequest`. Mapping them onto the house `thinking` object is the seam
// every dialect already consults (`originalRequest.thinking`).
//
// Shapes and their downstream fate (measured/probed conventions):
// - {type:"disabled"} — GLM openai wire forwards it verbatim (the 37→3 token
// probe shape, 2026-08-20); Qwen anthropic wire accepts it natively.
// - {type:"enabled", budget_tokens} — GLM reduces to its binary
// {type:"enabled"}; Qwen maps to enable_thinking + thinking_budget under
// `passthrough`; o1/o3 converts back to reasoning_effort (the effort ladder
// below is the exact inverse of openai-api-format's budget→effort map, so
// the round trip is stable).
// - The enabled block always carries a budget ≥ 1024 and < max_tokens: the
// native Anthropic lane requires budget_tokens ≥ 1024, Qwen requires
// budget < max_tokens, and the ingress defaults max_tokens to 4096. When
// there is no room for a legal block (tiny max_tokens), the mapping is
// skipped entirely — a logged-none no-op beats an invalid body or silently
// inverting the client's ask.
const enableThinking =
typeof src.enable_thinking === "boolean"
? src.enable_thinking
: typeof src.chat_template_kwargs?.enable_thinking === "boolean"
? src.chat_template_kwargs.enable_thinking
: undefined;
const effortBudget: Record<string, number> = {
minimal: 2_000,
low: 8_000,
medium: 24_000,
high: 32_000,
};
const fromEffort =
typeof src.reasoning_effort === "string"
? effortBudget[src.reasoning_effort.toLowerCase()]
: undefined;
if (enableThinking !== undefined || fromEffort !== undefined) {
if (enableThinking === false) {
out.thinking = { type: "disabled" };
} else {
let budget = fromEffort ?? 2_048;
if (maxTokensDefaulted) {
// The 4096 ceiling is OUR placeholder, not the client's — clamping an
// explicit "high" (32000) to 4095 would invert the ask downstream
// (o1/o3 remap 4095 back to "minimal"). Raise the placeholder to fit.
if (budget >= out.max_tokens) out.max_tokens = budget + 1;
} else {
// A CLIENT-sent max_tokens is their ceiling: clamp into it (Qwen
// requires budget < max_tokens), or drop the mapping when no legal
// block fits — never fail the body, never silently invert.
budget = Math.min(budget, out.max_tokens - 1);
if (budget < 1_024) budget = 0;
}
if (budget >= 1_024) {
out.thinking = { type: "enabled", budget_tokens: budget };
}
}
}

// Tools + tool_choice.
const tools = mapTools(src.tools);
if (tools) out.tools = tools;
Expand Down
Loading