From 4b529f92438251e296523aa58cf858480ccfcf6c Mon Sep 17 00:00:00 2001 From: Menci Date: Sat, 8 Aug 2026 17:50:44 +0800 Subject: [PATCH 1/5] feat(gateway): answer Claude Code's one-token probes at the gateway MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switching models in Claude Code failed against any model whose candidate translates Messages to Responses: API error: 400 {"error":{"message":"Invalid 'max_output_tokens': integer below minimum value. Expected a value >= 16, but got 1 instead.", "code":"invalid_request_body"}} Claude Code validates a model by generating one token against it. `/model ` issues a non-streaming `POST /v1/messages?beta=true` with `max_tokens: 1` and a fixed throwaway prompt, and calls the model unusable if the request throws. Decompiled from the 2.1.226 binary, the CLI's `model_validation` side query sends a single user turn holding one ephemeral `Hi` text block; the same helper serves the CLI's other one-token probes with their own fixed prompts (`quota` preflight, `test` credential verification, `.` reachability). None of them wants generated text — the caller reads `usage.input_tokens` / `usage.output_tokens` and treats "did not throw" as the verdict. A one-token cap is not portable: OpenAI's Responses API floors `max_output_tokens` at 16 and rejects anything lower outright, so the probe fails and the CLI reports the model missing. Add an unconditional Messages interceptor that recognizes those probes and answers them itself with a turn that stopped at the one-token cap — no content blocks, `stop_reason: max_tokens`, and a zero `usage` block, which Claude Code dereferences unconditionally. Registered ahead of the rest of the chain, since it replaces the turn rather than shaping it. Interception sits after model resolution, so an id no upstream serves still fails at the serve layer with a 404 and the CLI still reports it not found; only the pointless generation behind the probe is suppressed. The turn makes no upstream call, records a usage row at zero, and contributes no latency sample. Detection requires the `claude-cli` User-Agent, `max_tokens: 1`, no tools, and a single user turn holding one of the CLI's fixed probe prompts. That is narrower than CLIProxyAPI, which short-circuits on `max_tokens === 1` alone after reverting a tighter predicate; the extra conditions fail safe toward today's behavior, at the cost of tracking a Claude Code build detail. --- .../data-plane/chat/messages/http_test.ts | 38 ++++++ .../answer-claude-code-probe_test.ts | 116 ++++++++++++++++++ .../interceptors/answer-claude-code-probe.ts | 116 ++++++++++++++++++ .../chat/messages/interceptors/index.ts | 14 ++- 4 files changed, 281 insertions(+), 3 deletions(-) create mode 100644 packages/gateway/__tests__/data-plane/chat/messages/interceptors/answer-claude-code-probe_test.ts create mode 100644 packages/gateway/src/data-plane/chat/messages/interceptors/answer-claude-code-probe.ts diff --git a/packages/gateway/__tests__/data-plane/chat/messages/http_test.ts b/packages/gateway/__tests__/data-plane/chat/messages/http_test.ts index 9c1a639144..d34606f0e2 100644 --- a/packages/gateway/__tests__/data-plane/chat/messages/http_test.ts +++ b/packages/gateway/__tests__/data-plane/chat/messages/http_test.ts @@ -5,6 +5,7 @@ import type { AuthVars } from '../../../../src/middleware/auth.ts'; import { initRepo } from '../../../../src/repo/index.ts'; import type { ApiKey, User } from '../../../../src/repo/types.ts'; import { InMemoryRepo } from '../../../repo/memory.ts'; +import { flushBackground } from '../../../test-utils/background-tracker.ts'; import { doneFrame, eventFrame, type ModelEndpoints, type ProtocolFrame } from '@floway-dev/protocols/common'; import type { MessagesStreamEvent } from '@floway-dev/protocols/messages'; import { type ModelCandidate, directFetcher, type ProviderCallResult, type ProviderStreamResult, type UpstreamCallOptions } from '@floway-dev/provider'; @@ -161,6 +162,43 @@ test('POST /v1/messages returns a single JSON body when stream is omitted', asyn assertEquals(body.role, 'assistant'); }); +test('POST /v1/messages answers the Claude Code model-validation probe without calling the upstream', async () => { + const repo = installRepo(); + const callMessages = vi.fn((): Promise> => { + throw new Error('the probe reached the upstream'); + }); + queueCandidates([makeCandidate({ callMessages })]); + + const response = await makeApp().request('/v1/messages', { + method: 'POST', + headers: new Headers({ 'content-type': 'application/json', 'user-agent': 'claude-cli/2.1.226 (external, cli)' }), + body: JSON.stringify({ + model: 'test-model', + max_tokens: 1, + system: [{ type: 'text', text: "You are Claude Code, Anthropic's official CLI for Claude." }], + messages: [{ role: 'user', content: [{ type: 'text', text: 'Hi', cache_control: { type: 'ephemeral' } }] }], + metadata: { user_id: 'user_0_account__session_0' }, + }), + }); + + assertEquals(response.status, 200); + const body = await response.json() as { model: string; stop_reason: string; usage: { input_tokens: number; output_tokens: number } }; + assertEquals(body.model, 'test-model'); + assertEquals(body.stop_reason, 'max_tokens'); + assertEquals(body.usage.input_tokens, 0); + assertEquals(body.usage.output_tokens, 0); + assertEquals(callMessages.mock.calls.length, 0); + + // The turn is recorded as served, at zero cost, and contributes no latency + // sample — there was no upstream call to measure. + await flushBackground(); + const usage = await repo.usage.listAll(); + assertEquals(usage.length, 1); + assertEquals(usage[0]?.requests, 1); + assertEquals(usage[0]?.metrics, []); + assertEquals(await repo.performance.listAll(), []); +}); + test('POST /v1/messages rejects body anthropic_beta with a 400 before routing', async () => { installRepo(); // No candidates queued — the http entry rejects before reaching the serve. diff --git a/packages/gateway/__tests__/data-plane/chat/messages/interceptors/answer-claude-code-probe_test.ts b/packages/gateway/__tests__/data-plane/chat/messages/interceptors/answer-claude-code-probe_test.ts new file mode 100644 index 0000000000..9a51c99e0c --- /dev/null +++ b/packages/gateway/__tests__/data-plane/chat/messages/interceptors/answer-claude-code-probe_test.ts @@ -0,0 +1,116 @@ +import { test, vi } from 'vitest'; + +import { answerClaudeCodeProbe, isClaudeCodeProbe } from '../../../../../src/data-plane/chat/messages/interceptors/answer-claude-code-probe.ts'; +import type { MessagesInvocation } from '../../../../../src/data-plane/chat/messages/interceptors/types.ts'; +import { mockChatGatewayCtx } from '../../../../test-utils/gateway-ctx.ts'; +import type { ProtocolFrame } from '@floway-dev/protocols/common'; +import { collectMessagesProtocolEventsToResult, type MessagesPayload, type MessagesStreamEvent } from '@floway-dev/protocols/messages'; +import { type ExecuteResult, eventResult } from '@floway-dev/provider'; +import { assert, assertEquals, stubModelCandidate, testTelemetryModelIdentity } from '@floway-dev/test-utils'; + +const stubCtx = mockChatGatewayCtx(); + +// The 2.1.226 `/model` probe, byte for byte: one user turn, one ephemeral text +// block, `max_tokens: 1`, no tools, and the SDK's `claude-cli` User-Agent. +const PROBE_USER_AGENT = 'claude-cli/2.1.226 (external, cli)'; + +const probePayload = (overrides: Partial = {}): MessagesPayload => ({ + model: 'test-model', + max_tokens: 1, + system: [{ type: 'text', text: "You are Claude Code, Anthropic's official CLI for Claude." }], + messages: [{ role: 'user', content: [{ type: 'text', text: 'Hi', cache_control: { type: 'ephemeral' } }] }], + metadata: { user_id: 'user_0_account__session_0' }, + ...overrides, +}); + +const invocation = (payload: MessagesPayload, userAgent: string | null = PROBE_USER_AGENT): MessagesInvocation => ({ + payload, + candidate: stubModelCandidate({ model: { endpoints: { responses: {} } } }), + targetApi: 'responses', + headers: new Headers(userAgent === null ? {} : { 'user-agent': userAgent }), +}); + +const passthrough = async (): Promise>> => + eventResult((async function* (): AsyncGenerator> {})(), testTelemetryModelIdentity); + +const runProbe = async (input: MessagesInvocation) => { + const run = vi.fn(passthrough); + const result = await answerClaudeCodeProbe(input, stubCtx, run); + return { result, run }; +}; + +test('answers the /model validation probe without dialing the upstream', async () => { + const { result, run } = await runProbe(invocation(probePayload())); + + assertEquals(run.mock.calls.length, 0); + assert(result.type === 'events'); + const message = await collectMessagesProtocolEventsToResult(result.events); + assertEquals(message.type, 'message'); + assertEquals(message.role, 'assistant'); + assertEquals(message.model, 'test-model'); + assertEquals(message.content, []); + assertEquals(message.stop_reason, 'max_tokens'); + assertEquals(message.usage.input_tokens, 0); + assertEquals(message.usage.output_tokens, 0); + assert(message.id.startsWith('msg_')); +}); + +test('reports no performance context so the turn contributes no latency sample', async () => { + const { result } = await runProbe(invocation(probePayload())); + + assert(result.type === 'events'); + assertEquals(result.performance, undefined); + assertEquals(result.finalMetadata, undefined); +}); + +test('answers every fixed side-query prompt, in block and bare-string form', async () => { + for (const prompt of ['Hi', 'hello', 'quota', 'test', '.']) { + assert(isClaudeCodeProbe(probePayload({ messages: [{ role: 'user', content: prompt }] }), new Headers({ 'user-agent': PROBE_USER_AGENT })), `bare string: ${prompt}`); + assert(isClaudeCodeProbe(probePayload({ messages: [{ role: 'user', content: [{ type: 'text', text: prompt }] }] }), new Headers({ 'user-agent': PROBE_USER_AGENT })), `text block: ${prompt}`); + } +}); + +test('forwards a one-token request from a client that is not Claude Code', async () => { + const { run } = await runProbe(invocation(probePayload(), 'python-httpx/0.28.1')); + assertEquals(run.mock.calls.length, 1); + + const { run: unidentified } = await runProbe(invocation(probePayload(), null)); + assertEquals(unidentified.mock.calls.length, 1); +}); + +test('forwards a Claude Code turn whose output cap is not one token', async () => { + const { run } = await runProbe(invocation(probePayload({ max_tokens: 32_000 }))); + assertEquals(run.mock.calls.length, 1); +}); + +test('forwards a Claude Code turn that carries the session tools', async () => { + const { run } = await runProbe(invocation(probePayload({ + tools: [{ name: 'Bash', input_schema: { type: 'object' } }], + }))); + assertEquals(run.mock.calls.length, 1); +}); + +test('forwards a conversation whose sole turn is not one of the fixed prompts', async () => { + const { run } = await runProbe(invocation(probePayload({ + messages: [{ role: 'user', content: 'Hi, can you explain this file?' }], + }))); + assertEquals(run.mock.calls.length, 1); +}); + +test('forwards a multi-turn conversation that happens to end on a probe prompt', async () => { + const { run } = await runProbe(invocation(probePayload({ + messages: [ + { role: 'user', content: 'Hi' }, + { role: 'assistant', content: 'Hello!' }, + { role: 'user', content: 'Hi' }, + ], + }))); + assertEquals(run.mock.calls.length, 1); +}); + +test('forwards a turn whose sole block is not text', async () => { + const { run } = await runProbe(invocation(probePayload({ + messages: [{ role: 'user', content: [{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'AA==' } }] }], + }))); + assertEquals(run.mock.calls.length, 1); +}); diff --git a/packages/gateway/src/data-plane/chat/messages/interceptors/answer-claude-code-probe.ts b/packages/gateway/src/data-plane/chat/messages/interceptors/answer-claude-code-probe.ts new file mode 100644 index 0000000000..08609b9f52 --- /dev/null +++ b/packages/gateway/src/data-plane/chat/messages/interceptors/answer-claude-code-probe.ts @@ -0,0 +1,116 @@ +import type { MessagesInterceptor } from './types.ts'; +import { telemetryModelIdentity } from '../../../shared/telemetry/attribution.ts'; +import { doneFrame, eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; +import type { MessagesPayload, MessagesStreamEvent } from '@floway-dev/protocols/messages'; +import { eventResult, providerModelOf, type ExecuteResult, type ModelCandidate } from '@floway-dev/provider'; + +// Claude Code answers "is this model usable?" by generating one token against +// it. `/model ` runs the CLI's `model_validation` side query — a +// non-streaming `POST /v1/messages?beta=true` with `max_tokens: 1` and a +// throwaway one-word prompt — and reports the model unusable if that call +// throws. Decompiled from the 2.1.226 binary: +// +// await eie({model:r, max_tokens:1, maxRetries:0, querySource:"model_validation", +// messages:[{role:"user",content:[{type:"text",text:"Hi", +// cache_control:{type:"ephemeral"}}]}]}), lra.set(r,!0), {valid:!0} +// +// The same helper serves the CLI's other one-token probes, each with its own +// fixed prompt: `quota` for the rate-limit preflight, `test` for credential +// verification, `.` for the Bedrock/Vertex reachability checks. None of them +// wants generated text — the caller reads `usage.input_tokens` / +// `usage.output_tokens` for its own telemetry and treats "did not throw" as +// the verdict. +// +// A one-token cap is not portable. OpenAI's Responses API floors +// `max_output_tokens` at 16 and rejects anything lower with a hard 400, so +// every Messages→Responses candidate fails the probe and Claude Code +// concludes the model does not exist — the CLI surfaces the upstream envelope +// verbatim: `API error: 400 {"error":{"message":"Invalid 'max_output_tokens': +// integer below minimum value. Expected a value >= 16, but got 1 instead.", +// "code":"invalid_request_body"}}`. +// +// The gateway answers the probe itself instead. That is honest for what the +// probe actually asks: by the time this interceptor runs, model resolution has +// already picked a real candidate for the requested id, so an id no upstream +// serves still fails at the serve layer with a 404 and the CLI still reports +// it not found. What we suppress is only the pointless one-token generation +// behind it — no upstream call, and no tokens to bill. +// +// The predicate is deliberately narrower than the one gateway with production +// experience here — CLIProxyAPI short-circuits on `max_tokens === 1` alone +// (https://github.com/router-for-me/CLIProxyAPI/pull/3571), having reverted a +// tighter predicate that missed probe shapes. We keep the client and prompt +// conditions because a mismatch here fails safe in the direction of the +// current behavior: an unrecognized probe reaches the upstream exactly as it +// does today. The cost is that the literals below are a Claude Code build +// detail — a report against v2.1.220 records `hello` where 2.1.226 sends `Hi` +// (https://github.com/BerriAI/litellm/issues/35061) — so a release that renames +// one re-exposes the 400 until the new literal is added here. +const CLAUDE_CODE_USER_AGENT = /^claude-cli\/\d+\.\d+\.\d+/i; + +const PROBE_PROMPTS: ReadonlySet = new Set(['hi', 'hello', 'quota', 'test', '.']); + +// The whole conversation of a probe: one user turn carrying one text block (or +// the bare-string form older clients send). Anything longer is a real turn. +const soleUserPromptOf = (payload: MessagesPayload): string | null => { + if (payload.messages.length !== 1) return null; + const message = payload.messages[0]!; + if (message.role !== 'user') return null; + const { content } = message; + if (typeof content === 'string') return content; + if (content.length !== 1) return null; + const block = content[0]!; + return block.type === 'text' ? block.text : null; +}; + +export const isClaudeCodeProbe = (payload: MessagesPayload, headers: Headers): boolean => { + if (payload.max_tokens !== 1) return false; + // Every real Claude Code turn ships the session's tools; a side query never + // does. + if (payload.tools !== undefined) return false; + const userAgent = headers.get('user-agent'); + if (userAgent === null || !CLAUDE_CODE_USER_AGENT.test(userAgent)) return false; + const prompt = soleUserPromptOf(payload); + return prompt !== null && PROBE_PROMPTS.has(prompt.trim().toLowerCase()); +}; + +// Anthropic-shaped opaque message id (`msg_` + 24 base62 chars) for a body no +// upstream produced, mirroring the synthetic `request_id` the Messages error +// envelopes mint. 24 chars off crypto.randomUUID is ~96 bits of entropy. +const mintMessageId = (): string => `msg_${crypto.randomUUID().replace(/-/g, '').slice(0, 24)}`; + +// A turn that stopped at the caller's one-token cap before emitting anything: +// no content blocks, `stop_reason: 'max_tokens'`, and a zero usage block. The +// usage block is load-bearing — Claude Code dereferences +// `usage.input_tokens` / `usage.output_tokens` unconditionally, and a missing +// `usage` throws inside the CLI and reads as a failed probe. +const probeFrames = async function* (model: string): AsyncGenerator> { + yield eventFrame({ + type: 'message_start', + message: { + id: mintMessageId(), + type: 'message', + role: 'assistant', + model, + content: [], + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 0, output_tokens: 0 }, + }, + }); + yield eventFrame({ type: 'message_delta', delta: { stop_reason: 'max_tokens', stop_sequence: null }, usage: { output_tokens: 0 } }); + yield eventFrame({ type: 'message_stop' }); + yield doneFrame(); +}; + +// No `performance` context on the result: `settle` reads it to decide whether +// the turn contributes a latency sample, and a turn that never dialed the +// upstream has no latency to report. The usage row still lands, at zero, so +// the request itself stays visible in the dashboard. +const probeResult = (candidate: ModelCandidate, model: string): ExecuteResult> => + eventResult(probeFrames(model), telemetryModelIdentity(candidate, providerModelOf(candidate).id)); + +export const answerClaudeCodeProbe: MessagesInterceptor = async (ctx, _gatewayCtx, run) => + isClaudeCodeProbe(ctx.payload, ctx.headers) + ? probeResult(ctx.candidate, ctx.payload.model) + : await run(); diff --git a/packages/gateway/src/data-plane/chat/messages/interceptors/index.ts b/packages/gateway/src/data-plane/chat/messages/interceptors/index.ts index 3ce6010815..3646f80850 100644 --- a/packages/gateway/src/data-plane/chat/messages/interceptors/index.ts +++ b/packages/gateway/src/data-plane/chat/messages/interceptors/index.ts @@ -1,3 +1,4 @@ +import { answerClaudeCodeProbe } from './answer-claude-code-probe.ts'; import { withRoleCompatibilityApplied } from './apply-role-compatibility.ts'; import { withReasoningDisabledOnForcedToolChoice } from './disable-reasoning-on-forced-tool-choice.ts'; import { stripBillingAttribution } from './strip-billing-attribution.ts'; @@ -5,13 +6,19 @@ import type { MessagesCountTokensInterceptor, MessagesInterceptor, MessagesPaylo import { withMessagesWebSearchRequestPrepared, withMessagesWebSearchShim } from './web-search-shim.ts'; // Unified Messages generation chain. All entries are attached to every -// candidate; each interceptor decides whether to act from the candidate's -// model flags and selected target. +// candidate; each interceptor decides whether to act — from the candidate's +// model flags and selected target, or, for the client-compatibility entry, +// from the inbound request itself. // // Translated requests re-enter the selected target protocol's chain. The role // compatibility entry therefore acts only when Messages is the final target. // -// - withMessagesWebSearchShim: registered first so its request preparation, +// - answerClaudeCodeProbe: registered ahead of everything else because it +// replaces the turn rather than shaping it — an intercepted probe must not +// reach the web-search shim's intercept loop, and no payload transform +// downstream of it can matter to a turn that never dials. Unconditional: +// the probe is a property of the inbound request, not of the candidate. +// - withMessagesWebSearchShim: registered next so its request preparation, // replay rewrite, and intercept loop wrap the rest of the generation chain. // Unconditional for translated targets (Responses / Chat Completions cannot // carry Anthropic server tools); gated by `messages-web-search-shim` for @@ -41,6 +48,7 @@ const messagesPayloadInterceptors: readonly MessagesPayloadInterceptor[] = [ ]; export const messagesInterceptors: readonly MessagesInterceptor[] = [ + answerClaudeCodeProbe, withMessagesWebSearchShim, ...messagesPayloadInterceptors, ]; From d61e6797f13cf03c3c94866c39b4a0a190059d3e Mon Sep 17 00:00:00 2001 From: Menci Date: Sat, 8 Aug 2026 18:44:46 +0800 Subject: [PATCH 2/5] refactor(gateway): share the Anthropic synthetic id minter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Messages module mints Anthropic-shaped opaque ids in two places — the `request_id` on a gateway-synthesized error envelope, and the message id on a gateway-answered turn — from the same expression. Give the module one minter parameterized by prefix. The rationale comment it carried was also wrong about its own output: `crypto.randomUUID().replace(/-/g, '').slice(0, 24)` yields 24 hex characters, not base62, and the first 24 hex characters of a UUIDv4 carry ~90 bits rather than ~96 because the version nibble is fixed and the variant nibble constrained. --- .../src/data-plane/chat/messages/anthropic-id.ts | 9 +++++++++ .../gateway/src/data-plane/chat/messages/errors.ts | 11 ++--------- 2 files changed, 11 insertions(+), 9 deletions(-) create mode 100644 packages/gateway/src/data-plane/chat/messages/anthropic-id.ts diff --git a/packages/gateway/src/data-plane/chat/messages/anthropic-id.ts b/packages/gateway/src/data-plane/chat/messages/anthropic-id.ts new file mode 100644 index 0000000000..b7a081d068 --- /dev/null +++ b/packages/gateway/src/data-plane/chat/messages/anthropic-id.ts @@ -0,0 +1,9 @@ +// Anthropic-shaped opaque ids (`msg_` / `req_` + 24 hex chars) for bodies no +// upstream produced: the synthetic `request_id` on a gateway-synthesized error +// envelope, and the message id on a gateway-answered turn. 24 chars off +// crypto.randomUUID carry ~90 bits of entropy (the UUIDv4 version nibble is +// fixed and the variant nibble constrained), plenty for an opaque per-request +// id. We never bridge these to an upstream id — by construction they name +// something that never reached one. +export const mintAnthropicId = (prefix: 'msg' | 'req'): string => + `${prefix}_${crypto.randomUUID().replace(/-/g, '').slice(0, 24)}`; diff --git a/packages/gateway/src/data-plane/chat/messages/errors.ts b/packages/gateway/src/data-plane/chat/messages/errors.ts index 852c3109ad..250a293f5e 100644 --- a/packages/gateway/src/data-plane/chat/messages/errors.ts +++ b/packages/gateway/src/data-plane/chat/messages/errors.ts @@ -1,3 +1,4 @@ +import { mintAnthropicId } from './anthropic-id.ts'; import { appendFailedUpstreams } from '../../shared/failed-upstreams.ts'; import type { ChatServeFailure } from '../shared/errors.ts'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; @@ -5,14 +6,6 @@ import type { MessagesStreamEvent } from '@floway-dev/protocols/messages'; import type { ExecuteResult, PerformanceTelemetryContext } from '@floway-dev/provider'; import type { TranslatorInputError } from '@floway-dev/translate'; -// Mint an Anthropic-shaped synthetic request id (`req_` + 24 base62 chars) -// so a gateway-synthesized 4xx body carries the same top-level `request_id` -// field every real Anthropic response carries. The value is opaque to the -// caller; we never bridge it to an upstream id (these envelopes never -// reached an upstream). 24 chars from crypto.randomUUID yields ~96 bits of -// entropy, plenty for an opaque per-error id. -const mintAnthropicRequestId = (): string => `req_${crypto.randomUUID().replace(/-/g, '').slice(0, 24)}`; - // Anthropic Messages error envelope used to render pre-stream // `ChatServeFailure`s. These are gateway-synthesized rather than received // from any upstream — `source: 'gateway'` so the dump labels them as such. @@ -32,7 +25,7 @@ const anthropicErrorResult = ( body: new TextEncoder().encode(JSON.stringify({ type: 'error', error: { type, message }, - request_id: mintAnthropicRequestId(), + request_id: mintAnthropicId('req'), })), ...(performance ? { performance } : {}), }); From d49be5b1acd2383b4a55547fd1ca98753484b669 Mon Sep 17 00:00:00 2001 From: Menci Date: Sat, 8 Aug 2026 18:45:29 +0800 Subject: [PATCH 3/5] fix(gateway): stop answering the Claude Code probes we cannot answer truthfully MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of the probe interceptor found the accepted-prompt set too wide and its research notes wrong on three points. `quota` leaves the set. It is the CLI's rate-limit preflight, and its caller consumes the `anthropic-ratelimit-unified-*` response headers rather than the body — a synthesized turn cannot carry them, so answering it would replace a working quota reading, on every upstream that serves the probe today, with a silent blank. `.` leaves too: it is issued by the Bedrock / Vertex / Mantle SDK clients, which route through their own base URLs and never reach an `ANTHROPIC_BASE_URL` gateway. That leaves the `/model` validation probe, which is the reported failure, and the credential check, which reads nothing off the response at all. Prompt matching becomes exact. Every literal is read off a binary rather than inferred, so lowercasing the set and normalizing the input widened the predicate to shapes no evidence attributes to the CLI, in a predicate the file argues at length is deliberately narrow. An unobserved shape should reach the upstream rather than be answered from a guess. Corrections to the research notes, each verified against the 2.1.226 binary or the GitHub API: - Only the `/model` validation probe goes through the `eie` side-query helper; `quota`, `test`, and `.` are independent call sites. The "reads usage and treats no-throw as the verdict" claim holds for the validation path only. - CLIProxyAPI has no `max_tokens === 1` short-circuit; PR 3571 is an open proposal to add one, and the revert it describes is internal to its own branch history. It is not shipped behavior and not a baseline. - The bare-string content form is what the current client sends for the credential check, not a legacy shape. Labeling it as one read as a compatibility shim for old clients, which it is not. - `eie` accepts a `tools` argument; it is the one-token probes specifically, not side queries in general, that never carry tools. - Ordering the entry first needs no web-search-shim hazard to justify it; the shim is inactive for a request with no tools regardless. `isClaudeCodeProbe` goes back to module-private, matching its siblings, and the prompt matrix now drives the interceptor rather than the predicate. Two shape guards — a sole turn with more than one block, and a sole turn that is not a user turn — had no test pinning them; both are now covered. --- .../answer-claude-code-probe_test.ts | 80 +++++++++++++------ .../interceptors/answer-claude-code-probe.ts | 74 +++++++++-------- .../chat/messages/interceptors/index.ts | 8 +- 3 files changed, 98 insertions(+), 64 deletions(-) diff --git a/packages/gateway/__tests__/data-plane/chat/messages/interceptors/answer-claude-code-probe_test.ts b/packages/gateway/__tests__/data-plane/chat/messages/interceptors/answer-claude-code-probe_test.ts index 9a51c99e0c..69c4f5254c 100644 --- a/packages/gateway/__tests__/data-plane/chat/messages/interceptors/answer-claude-code-probe_test.ts +++ b/packages/gateway/__tests__/data-plane/chat/messages/interceptors/answer-claude-code-probe_test.ts @@ -1,6 +1,6 @@ import { test, vi } from 'vitest'; -import { answerClaudeCodeProbe, isClaudeCodeProbe } from '../../../../../src/data-plane/chat/messages/interceptors/answer-claude-code-probe.ts'; +import { answerClaudeCodeProbe } from '../../../../../src/data-plane/chat/messages/interceptors/answer-claude-code-probe.ts'; import type { MessagesInvocation } from '../../../../../src/data-plane/chat/messages/interceptors/types.ts'; import { mockChatGatewayCtx } from '../../../../test-utils/gateway-ctx.ts'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; @@ -10,10 +10,13 @@ import { assert, assertEquals, stubModelCandidate, testTelemetryModelIdentity } const stubCtx = mockChatGatewayCtx(); -// The 2.1.226 `/model` probe, byte for byte: one user turn, one ephemeral text -// block, `max_tokens: 1`, no tools, and the SDK's `claude-cli` User-Agent. +// The SDK's User-Agent on every Claude Code request. const PROBE_USER_AGENT = 'claude-cli/2.1.226 (external, cli)'; +// The shape of the 2.1.226 `/model` validation probe: one user turn holding +// one ephemeral text block, `max_tokens: 1`, no tools. The real body also +// carries the CLI's system-prompt array and a `betas` list, neither of which +// the predicate looks at. const probePayload = (overrides: Partial = {}): MessagesPayload => ({ model: 'test-model', max_tokens: 1, @@ -39,6 +42,16 @@ const runProbe = async (input: MessagesInvocation) => { return { result, run }; }; +const assertForwarded = async (input: MessagesInvocation, message: string) => { + const { run } = await runProbe(input); + assertEquals(run.mock.calls.length, 1, message); +}; + +const assertAnswered = async (input: MessagesInvocation, message: string) => { + const { run } = await runProbe(input); + assertEquals(run.mock.calls.length, 0, message); +}; + test('answers the /model validation probe without dialing the upstream', async () => { const { result, run } = await runProbe(invocation(probePayload())); @@ -63,54 +76,69 @@ test('reports no performance context so the turn contributes no latency sample', assertEquals(result.finalMetadata, undefined); }); -test('answers every fixed side-query prompt, in block and bare-string form', async () => { - for (const prompt of ['Hi', 'hello', 'quota', 'test', '.']) { - assert(isClaudeCodeProbe(probePayload({ messages: [{ role: 'user', content: prompt }] }), new Headers({ 'user-agent': PROBE_USER_AGENT })), `bare string: ${prompt}`); - assert(isClaudeCodeProbe(probePayload({ messages: [{ role: 'user', content: [{ type: 'text', text: prompt }] }] }), new Headers({ 'user-agent': PROBE_USER_AGENT })), `text block: ${prompt}`); +test('answers every fixed probe prompt, in block and bare-string form', async () => { + for (const prompt of ['Hi', 'hello', 'test']) { + await assertAnswered(invocation(probePayload({ messages: [{ role: 'user', content: prompt }] })), `bare string: ${prompt}`); + await assertAnswered(invocation(probePayload({ messages: [{ role: 'user', content: [{ type: 'text', text: prompt }] }] })), `text block: ${prompt}`); } }); -test('forwards a one-token request from a client that is not Claude Code', async () => { - const { run } = await runProbe(invocation(probePayload(), 'python-httpx/0.28.1')); - assertEquals(run.mock.calls.length, 1); +test('forwards the probe prompts the gateway cannot answer truthfully', async () => { + // `quota` reads rate-limit response headers a synthesized turn cannot carry; + // `.` only ever reaches a Bedrock / Vertex / Mantle base URL. + for (const prompt of ['quota', '.']) { + await assertForwarded(invocation(probePayload({ messages: [{ role: 'user', content: prompt }] })), `prompt: ${prompt}`); + } +}); - const { run: unidentified } = await runProbe(invocation(probePayload(), null)); - assertEquals(unidentified.mock.calls.length, 1); +test('forwards a probe prompt whose casing the CLI has never been observed to send', async () => { + await assertForwarded(invocation(probePayload({ messages: [{ role: 'user', content: 'HI' }] })), 'uppercased'); +}); + +test('forwards a one-token request from a client that is not Claude Code', async () => { + await assertForwarded(invocation(probePayload(), 'python-httpx/0.28.1'), 'other client'); + await assertForwarded(invocation(probePayload(), null), 'no user-agent'); }); test('forwards a Claude Code turn whose output cap is not one token', async () => { - const { run } = await runProbe(invocation(probePayload({ max_tokens: 32_000 }))); - assertEquals(run.mock.calls.length, 1); + await assertForwarded(invocation(probePayload({ max_tokens: 32_000 })), 'max_tokens 32000'); }); test('forwards a Claude Code turn that carries the session tools', async () => { - const { run } = await runProbe(invocation(probePayload({ + await assertForwarded(invocation(probePayload({ tools: [{ name: 'Bash', input_schema: { type: 'object' } }], - }))); - assertEquals(run.mock.calls.length, 1); + })), 'tools present'); }); test('forwards a conversation whose sole turn is not one of the fixed prompts', async () => { - const { run } = await runProbe(invocation(probePayload({ + await assertForwarded(invocation(probePayload({ messages: [{ role: 'user', content: 'Hi, can you explain this file?' }], - }))); - assertEquals(run.mock.calls.length, 1); + })), 'real question'); }); test('forwards a multi-turn conversation that happens to end on a probe prompt', async () => { - const { run } = await runProbe(invocation(probePayload({ + await assertForwarded(invocation(probePayload({ messages: [ { role: 'user', content: 'Hi' }, { role: 'assistant', content: 'Hello!' }, { role: 'user', content: 'Hi' }, ], - }))); - assertEquals(run.mock.calls.length, 1); + })), 'three turns'); +}); + +test('forwards a sole turn that carries more than one block', async () => { + await assertForwarded(invocation(probePayload({ + messages: [{ role: 'user', content: [{ type: 'text', text: 'Hi' }, { type: 'text', text: 'and explain this file' }] }], + })), 'two text blocks'); +}); + +test('forwards a sole turn that is not a user turn', async () => { + await assertForwarded(invocation(probePayload({ messages: [{ role: 'assistant', content: 'Hi' }] })), 'assistant role'); + await assertForwarded(invocation(probePayload({ messages: [{ role: 'system', content: 'Hi' }] })), 'system role'); }); test('forwards a turn whose sole block is not text', async () => { - const { run } = await runProbe(invocation(probePayload({ + await assertForwarded(invocation(probePayload({ messages: [{ role: 'user', content: [{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'AA==' } }] }], - }))); - assertEquals(run.mock.calls.length, 1); + })), 'image block'); }); diff --git a/packages/gateway/src/data-plane/chat/messages/interceptors/answer-claude-code-probe.ts b/packages/gateway/src/data-plane/chat/messages/interceptors/answer-claude-code-probe.ts index 08609b9f52..514eb64470 100644 --- a/packages/gateway/src/data-plane/chat/messages/interceptors/answer-claude-code-probe.ts +++ b/packages/gateway/src/data-plane/chat/messages/interceptors/answer-claude-code-probe.ts @@ -1,25 +1,27 @@ import type { MessagesInterceptor } from './types.ts'; import { telemetryModelIdentity } from '../../../shared/telemetry/attribution.ts'; +import { mintAnthropicId } from '../anthropic-id.ts'; import { doneFrame, eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; import type { MessagesPayload, MessagesStreamEvent } from '@floway-dev/protocols/messages'; import { eventResult, providerModelOf, type ExecuteResult, type ModelCandidate } from '@floway-dev/provider'; // Claude Code answers "is this model usable?" by generating one token against // it. `/model ` runs the CLI's `model_validation` side query — a -// non-streaming `POST /v1/messages?beta=true` with `max_tokens: 1` and a -// throwaway one-word prompt — and reports the model unusable if that call -// throws. Decompiled from the 2.1.226 binary: +// non-streaming `POST /v1/messages?beta=true` with `max_tokens: 1` and a fixed +// throwaway prompt — and reports the model unusable if that call throws. +// Decompiled from the 2.1.226 binary: // // await eie({model:r, max_tokens:1, maxRetries:0, querySource:"model_validation", // messages:[{role:"user",content:[{type:"text",text:"Hi", // cache_control:{type:"ephemeral"}}]}]}), lra.set(r,!0), {valid:!0} // -// The same helper serves the CLI's other one-token probes, each with its own -// fixed prompt: `quota` for the rate-limit preflight, `test` for credential -// verification, `.` for the Bedrock/Vertex reachability checks. None of them -// wants generated text — the caller reads `usage.input_tokens` / -// `usage.output_tokens` for its own telemetry and treats "did not throw" as -// the verdict. +// The verdict is "did not throw": the only response fields this path reads are +// `usage.input_tokens` / `usage.output_tokens` and `stop_reason`, and only to +// populate the CLI's own telemetry event. The CLI runs several other one-token +// probes with their own fixed prompts, each from an independent call site +// rather than through `eie` — `quota` for the rate-limit preflight, `test` for +// credential verification, `.` for the Bedrock / Vertex / Mantle reachability +// checks. // // A one-token cap is not portable. OpenAI's Responses API floors // `max_output_tokens` at 16 and rejects anything lower with a hard 400, so @@ -35,23 +37,32 @@ import { eventResult, providerModelOf, type ExecuteResult, type ModelCandidate } // serves still fails at the serve layer with a 404 and the CLI still reports // it not found. What we suppress is only the pointless one-token generation // behind it — no upstream call, and no tokens to bill. -// -// The predicate is deliberately narrower than the one gateway with production -// experience here — CLIProxyAPI short-circuits on `max_tokens === 1` alone -// (https://github.com/router-for-me/CLIProxyAPI/pull/3571), having reverted a -// tighter predicate that missed probe shapes. We keep the client and prompt -// conditions because a mismatch here fails safe in the direction of the -// current behavior: an unrecognized probe reaches the upstream exactly as it -// does today. The cost is that the literals below are a Claude Code build -// detail — a report against v2.1.220 records `hello` where 2.1.226 sends `Hi` -// (https://github.com/BerriAI/litellm/issues/35061) — so a release that renames -// one re-exposes the 400 until the new literal is added here. const CLAUDE_CODE_USER_AGENT = /^claude-cli\/\d+\.\d+\.\d+/i; -const PROBE_PROMPTS: ReadonlySet = new Set(['hi', 'hello', 'quota', 'test', '.']); +// The probes we can answer truthfully, written exactly as the CLI sends them. +// `Hi` is the `/model` validation probe in 2.1.226; `hello` is the same probe +// as captured against v2.1.220 in +// https://github.com/BerriAI/litellm/issues/35061; `test` is the credential +// check, which reads nothing at all off the response. +// +// Two recorded prompts are deliberately absent. `quota` is the rate-limit +// preflight, and its caller consumes the `anthropic-ratelimit-unified-*` +// response headers rather than the body — a synthesized turn cannot carry +// them, so answering it here would replace a working quota reading, on every +// upstream that serves the probe today, with a silent blank. `.` is issued by +// the Bedrock / Vertex / Mantle SDK clients, which route through their own +// base URLs and never reach an `ANTHROPIC_BASE_URL` gateway. +// +// Matching is exact: every literal here is read off a binary rather than +// inferred, and a probe shape we have not observed should reach the upstream +// rather than be answered from a guess. The cost of keying on the prompt at +// all is that these literals are a Claude Code build detail — a release that +// renames one re-exposes the 400 until the new literal is added here. +const PROBE_PROMPTS: ReadonlySet = new Set(['Hi', 'hello', 'test']); -// The whole conversation of a probe: one user turn carrying one text block (or -// the bare-string form older clients send). Anything longer is a real turn. +// The whole conversation of a probe. `model_validation` sends its prompt as a +// single ephemeral text block and the credential check sends the bare-string +// form; both shapes are current. Anything longer is a real turn. const soleUserPromptOf = (payload: MessagesPayload): string | null => { if (payload.messages.length !== 1) return null; const message = payload.messages[0]!; @@ -63,32 +74,27 @@ const soleUserPromptOf = (payload: MessagesPayload): string | null => { return block.type === 'text' ? block.text : null; }; -export const isClaudeCodeProbe = (payload: MessagesPayload, headers: Headers): boolean => { +const isClaudeCodeProbe = (payload: MessagesPayload, headers: Headers): boolean => { if (payload.max_tokens !== 1) return false; - // Every real Claude Code turn ships the session's tools; a side query never - // does. + // Every real Claude Code turn ships the session's tools; the one-token + // probes never do. if (payload.tools !== undefined) return false; const userAgent = headers.get('user-agent'); if (userAgent === null || !CLAUDE_CODE_USER_AGENT.test(userAgent)) return false; const prompt = soleUserPromptOf(payload); - return prompt !== null && PROBE_PROMPTS.has(prompt.trim().toLowerCase()); + return prompt !== null && PROBE_PROMPTS.has(prompt); }; -// Anthropic-shaped opaque message id (`msg_` + 24 base62 chars) for a body no -// upstream produced, mirroring the synthetic `request_id` the Messages error -// envelopes mint. 24 chars off crypto.randomUUID is ~96 bits of entropy. -const mintMessageId = (): string => `msg_${crypto.randomUUID().replace(/-/g, '').slice(0, 24)}`; - // A turn that stopped at the caller's one-token cap before emitting anything: // no content blocks, `stop_reason: 'max_tokens'`, and a zero usage block. The -// usage block is load-bearing — Claude Code dereferences +// usage block is load-bearing — the validation path dereferences // `usage.input_tokens` / `usage.output_tokens` unconditionally, and a missing // `usage` throws inside the CLI and reads as a failed probe. const probeFrames = async function* (model: string): AsyncGenerator> { yield eventFrame({ type: 'message_start', message: { - id: mintMessageId(), + id: mintAnthropicId('msg'), type: 'message', role: 'assistant', model, diff --git a/packages/gateway/src/data-plane/chat/messages/interceptors/index.ts b/packages/gateway/src/data-plane/chat/messages/interceptors/index.ts index 3646f80850..30d754d9b3 100644 --- a/packages/gateway/src/data-plane/chat/messages/interceptors/index.ts +++ b/packages/gateway/src/data-plane/chat/messages/interceptors/index.ts @@ -14,10 +14,10 @@ import { withMessagesWebSearchRequestPrepared, withMessagesWebSearchShim } from // compatibility entry therefore acts only when Messages is the final target. // // - answerClaudeCodeProbe: registered ahead of everything else because it -// replaces the turn rather than shaping it — an intercepted probe must not -// reach the web-search shim's intercept loop, and no payload transform -// downstream of it can matter to a turn that never dials. Unconditional: -// the probe is a property of the inbound request, not of the candidate. +// replaces the turn rather than shaping it — no payload transform below it +// can matter to a turn that never dials. Unconditional: whether a request +// is one of Claude Code's one-token probes is a property of the request, +// not of the candidate. // - withMessagesWebSearchShim: registered next so its request preparation, // replay rewrite, and intercept loop wrap the rest of the generation chain. // Unconditional for translated targets (Responses / Chat Completions cannot From 1c016691b3d0b75d9a92e51832cb325606aa6431 Mon Sep 17 00:00:00 2001 From: Menci Date: Sat, 8 Aug 2026 19:25:52 +0800 Subject: [PATCH 4/5] fix(gateway): drop the probe prompt no Claude Code build sends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-2 review caught the accepted-prompt set asserting an evidence standard one of its members does not meet. `hello` came from a third-party bug report against v2.1.220, and re-reading that report shows its request body is a hand-written reproduction rather than a capture. No build on disk contains it: 2.1.221 through 2.1.226 all send `Hi` in ephemeral-block form at the `model_validation` call site, and `hello` appears in those binaries only in computer-use tool-schema examples and an IPC handshake frame. Matching became exact precisely so that an unobserved shape reaches the upstream instead of being answered from a guess, so the guessed entry goes. The litellm report stays as the reference for what it does support — that the literal is a build detail worth re-reading per release. Two comment corrections alongside it. The response-field inventory said `usage.input_tokens` / `usage.output_tokens` and `stop_reason` were the only fields read; the telemetry event also reads `_request_id` and the two cache counters, all behind `??`, so the load-bearing property is the unguarded dereference rather than the size of the inventory. And the test fixture's User-Agent note credited the header to the Anthropic SDK and presented one fixed string: Claude Code composes it itself as `claude-cli/ (external, …)` with up to three optional trailing segments, which is why the predicate anchors on the prefix alone. --- .../answer-claude-code-probe_test.ts | 12 ++++--- .../interceptors/answer-claude-code-probe.ts | 35 ++++++++++--------- 2 files changed, 26 insertions(+), 21 deletions(-) diff --git a/packages/gateway/__tests__/data-plane/chat/messages/interceptors/answer-claude-code-probe_test.ts b/packages/gateway/__tests__/data-plane/chat/messages/interceptors/answer-claude-code-probe_test.ts index 69c4f5254c..81bc54ce6b 100644 --- a/packages/gateway/__tests__/data-plane/chat/messages/interceptors/answer-claude-code-probe_test.ts +++ b/packages/gateway/__tests__/data-plane/chat/messages/interceptors/answer-claude-code-probe_test.ts @@ -10,7 +10,10 @@ import { assert, assertEquals, stubModelCandidate, testTelemetryModelIdentity } const stubCtx = mockChatGatewayCtx(); -// The SDK's User-Agent on every Claude Code request. +// The CLI emits `claude-cli/ (external, …)`, where the +// entrypoint substitutes for `cli` and up to three optional segments append — +// which is why the predicate anchors on the `claude-cli/` prefix only. +// This is the default entrypoint form. const PROBE_USER_AGENT = 'claude-cli/2.1.226 (external, cli)'; // The shape of the 2.1.226 `/model` validation probe: one user turn holding @@ -77,7 +80,7 @@ test('reports no performance context so the turn contributes no latency sample', }); test('answers every fixed probe prompt, in block and bare-string form', async () => { - for (const prompt of ['Hi', 'hello', 'test']) { + for (const prompt of ['Hi', 'test']) { await assertAnswered(invocation(probePayload({ messages: [{ role: 'user', content: prompt }] })), `bare string: ${prompt}`); await assertAnswered(invocation(probePayload({ messages: [{ role: 'user', content: [{ type: 'text', text: prompt }] }] })), `text block: ${prompt}`); } @@ -85,8 +88,9 @@ test('answers every fixed probe prompt, in block and bare-string form', async () test('forwards the probe prompts the gateway cannot answer truthfully', async () => { // `quota` reads rate-limit response headers a synthesized turn cannot carry; - // `.` only ever reaches a Bedrock / Vertex / Mantle base URL. - for (const prompt of ['quota', '.']) { + // `.` only ever reaches a Bedrock / Vertex / Mantle base URL; `hello` is a + // third-party report's reconstruction that no observed build sends. + for (const prompt of ['quota', '.', 'hello']) { await assertForwarded(invocation(probePayload({ messages: [{ role: 'user', content: prompt }] })), `prompt: ${prompt}`); } }); diff --git a/packages/gateway/src/data-plane/chat/messages/interceptors/answer-claude-code-probe.ts b/packages/gateway/src/data-plane/chat/messages/interceptors/answer-claude-code-probe.ts index 514eb64470..7a2f7217bb 100644 --- a/packages/gateway/src/data-plane/chat/messages/interceptors/answer-claude-code-probe.ts +++ b/packages/gateway/src/data-plane/chat/messages/interceptors/answer-claude-code-probe.ts @@ -15,13 +15,13 @@ import { eventResult, providerModelOf, type ExecuteResult, type ModelCandidate } // messages:[{role:"user",content:[{type:"text",text:"Hi", // cache_control:{type:"ephemeral"}}]}]}), lra.set(r,!0), {valid:!0} // -// The verdict is "did not throw": the only response fields this path reads are -// `usage.input_tokens` / `usage.output_tokens` and `stop_reason`, and only to -// populate the CLI's own telemetry event. The CLI runs several other one-token -// probes with their own fixed prompts, each from an independent call site -// rather than through `eie` — `quota` for the rate-limit preflight, `test` for -// credential verification, `.` for the Bedrock / Vertex / Mantle reachability -// checks. +// The verdict is "did not throw". The validation path dereferences +// `usage.input_tokens` / `usage.output_tokens` unguarded to populate the CLI's +// own telemetry event, reads `stop_reason` and the cache counters behind `??`, +// and looks at nothing else. The CLI runs several other one-token probes with +// their own fixed prompts, each from an independent call site rather than +// through `eie` — `quota` for the rate-limit preflight, `test` for credential +// verification, `.` for the Bedrock / Vertex / Mantle reachability checks. // // A one-token cap is not portable. OpenAI's Responses API floors // `max_output_tokens` at 16 and rejects anything lower with a hard 400, so @@ -39,11 +39,9 @@ import { eventResult, providerModelOf, type ExecuteResult, type ModelCandidate } // behind it — no upstream call, and no tokens to bill. const CLAUDE_CODE_USER_AGENT = /^claude-cli\/\d+\.\d+\.\d+/i; -// The probes we can answer truthfully, written exactly as the CLI sends them. -// `Hi` is the `/model` validation probe in 2.1.226; `hello` is the same probe -// as captured against v2.1.220 in -// https://github.com/BerriAI/litellm/issues/35061; `test` is the credential -// check, which reads nothing at all off the response. +// The probes we can answer truthfully, written exactly as 2.1.226 sends them: +// `Hi` is the `/model` validation probe, `test` the credential check, which +// reads nothing at all off the response. // // Two recorded prompts are deliberately absent. `quota` is the rate-limit // preflight, and its caller consumes the `anthropic-ratelimit-unified-*` @@ -53,12 +51,15 @@ const CLAUDE_CODE_USER_AGENT = /^claude-cli\/\d+\.\d+\.\d+/i; // the Bedrock / Vertex / Mantle SDK clients, which route through their own // base URLs and never reach an `ANTHROPIC_BASE_URL` gateway. // -// Matching is exact: every literal here is read off a binary rather than -// inferred, and a probe shape we have not observed should reach the upstream +// Matching is exact, and every literal is read off a binary rather than +// inferred: a probe shape we have not observed should reach the upstream // rather than be answered from a guess. The cost of keying on the prompt at -// all is that these literals are a Claude Code build detail — a release that -// renames one re-exposes the 400 until the new literal is added here. -const PROBE_PROMPTS: ReadonlySet = new Set(['Hi', 'hello', 'test']); +// all is that these literals are a Claude Code build detail — a report against +// v2.1.220 records the validation prompt as `hello` +// (https://github.com/BerriAI/litellm/issues/35061), a spelling absent from +// 2.1.226 — so a release that renames one re-exposes the 400 until the new +// literal is read off that build and added here. +const PROBE_PROMPTS: ReadonlySet = new Set(['Hi', 'test']); // The whole conversation of a probe. `model_validation` sends its prompt as a // single ephemeral text block and the credential check sends the bare-string From 7cca741728cec4814c3c131fe0bb9334d0b96971 Mon Sep 17 00:00:00 2001 From: Menci Date: Sat, 8 Aug 2026 23:13:10 +0800 Subject: [PATCH 5/5] refactor(protocols): own Anthropic id generation at the protocol layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shape of an Anthropic id — `msg_` for a message, `req_` for a request, followed by an opaque 24-character token — is a property of the Messages wire contract, not of the gateway that happens to synthesize bodies carrying one. Move the generator into `@floway-dev/protocols/messages` alongside `createRandomResponsesItemId`, its exact counterpart on the Responses side, and name it `generateAnthropicId` to match. Generating from `crypto.getRandomValues` rather than by slicing a UUID drops the version and variant nibbles the slice inherited, so the token carries 96 bits of entropy across its 24 characters instead of 90, and the comment no longer needs the caveat. The wire shape is unchanged. --- .../data-plane/chat/messages/errors_test.ts | 2 +- .../src/data-plane/chat/messages/anthropic-id.ts | 9 --------- .../src/data-plane/chat/messages/errors.ts | 5 ++--- .../interceptors/answer-claude-code-probe.ts | 5 ++--- packages/protocols/src/messages/id.ts | 15 +++++++++++++++ packages/protocols/src/messages/index.ts | 1 + 6 files changed, 21 insertions(+), 16 deletions(-) delete mode 100644 packages/gateway/src/data-plane/chat/messages/anthropic-id.ts create mode 100644 packages/protocols/src/messages/id.ts diff --git a/packages/gateway/__tests__/data-plane/chat/messages/errors_test.ts b/packages/gateway/__tests__/data-plane/chat/messages/errors_test.ts index c3ecb6a3df..1ec9368328 100644 --- a/packages/gateway/__tests__/data-plane/chat/messages/errors_test.ts +++ b/packages/gateway/__tests__/data-plane/chat/messages/errors_test.ts @@ -26,7 +26,7 @@ test('translatorInputErrorResult renders an Anthropic 400 invalid_request_error type: 'invalid_request_error', message: "Invalid 'image_url' content part in system or developer message. Only 'text' content parts are supported in system messages on this model.", }); - assert(typeof body.request_id === 'string' && /^req_[A-Za-z0-9]{24}$/.test(body.request_id), `request_id ${String(body.request_id)} must match Anthropic-shape req_<24-base62>`); + assert(typeof body.request_id === 'string' && /^req_[A-Za-z0-9]{24}$/.test(body.request_id), `request_id ${String(body.request_id)} must match Anthropic-shape req_<24 opaque chars>`); }); test('translatorInputErrorResult preserves Anthropic key order: type, error, request_id', () => { diff --git a/packages/gateway/src/data-plane/chat/messages/anthropic-id.ts b/packages/gateway/src/data-plane/chat/messages/anthropic-id.ts deleted file mode 100644 index b7a081d068..0000000000 --- a/packages/gateway/src/data-plane/chat/messages/anthropic-id.ts +++ /dev/null @@ -1,9 +0,0 @@ -// Anthropic-shaped opaque ids (`msg_` / `req_` + 24 hex chars) for bodies no -// upstream produced: the synthetic `request_id` on a gateway-synthesized error -// envelope, and the message id on a gateway-answered turn. 24 chars off -// crypto.randomUUID carry ~90 bits of entropy (the UUIDv4 version nibble is -// fixed and the variant nibble constrained), plenty for an opaque per-request -// id. We never bridge these to an upstream id — by construction they name -// something that never reached one. -export const mintAnthropicId = (prefix: 'msg' | 'req'): string => - `${prefix}_${crypto.randomUUID().replace(/-/g, '').slice(0, 24)}`; diff --git a/packages/gateway/src/data-plane/chat/messages/errors.ts b/packages/gateway/src/data-plane/chat/messages/errors.ts index 250a293f5e..9aa147e0a3 100644 --- a/packages/gateway/src/data-plane/chat/messages/errors.ts +++ b/packages/gateway/src/data-plane/chat/messages/errors.ts @@ -1,8 +1,7 @@ -import { mintAnthropicId } from './anthropic-id.ts'; import { appendFailedUpstreams } from '../../shared/failed-upstreams.ts'; import type { ChatServeFailure } from '../shared/errors.ts'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; -import type { MessagesStreamEvent } from '@floway-dev/protocols/messages'; +import { generateAnthropicId, type MessagesStreamEvent } from '@floway-dev/protocols/messages'; import type { ExecuteResult, PerformanceTelemetryContext } from '@floway-dev/provider'; import type { TranslatorInputError } from '@floway-dev/translate'; @@ -25,7 +24,7 @@ const anthropicErrorResult = ( body: new TextEncoder().encode(JSON.stringify({ type: 'error', error: { type, message }, - request_id: mintAnthropicId('req'), + request_id: generateAnthropicId('req'), })), ...(performance ? { performance } : {}), }); diff --git a/packages/gateway/src/data-plane/chat/messages/interceptors/answer-claude-code-probe.ts b/packages/gateway/src/data-plane/chat/messages/interceptors/answer-claude-code-probe.ts index 7a2f7217bb..928cec19b0 100644 --- a/packages/gateway/src/data-plane/chat/messages/interceptors/answer-claude-code-probe.ts +++ b/packages/gateway/src/data-plane/chat/messages/interceptors/answer-claude-code-probe.ts @@ -1,8 +1,7 @@ import type { MessagesInterceptor } from './types.ts'; import { telemetryModelIdentity } from '../../../shared/telemetry/attribution.ts'; -import { mintAnthropicId } from '../anthropic-id.ts'; import { doneFrame, eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; -import type { MessagesPayload, MessagesStreamEvent } from '@floway-dev/protocols/messages'; +import { generateAnthropicId, type MessagesPayload, type MessagesStreamEvent } from '@floway-dev/protocols/messages'; import { eventResult, providerModelOf, type ExecuteResult, type ModelCandidate } from '@floway-dev/provider'; // Claude Code answers "is this model usable?" by generating one token against @@ -95,7 +94,7 @@ const probeFrames = async function* (model: string): AsyncGenerator { + const bytes = new Uint8Array(12); + crypto.getRandomValues(bytes); + return `${prefix}_${encodeHex(bytes)}`; +}; diff --git a/packages/protocols/src/messages/index.ts b/packages/protocols/src/messages/index.ts index 707cf24383..1eeb2ba1bf 100644 --- a/packages/protocols/src/messages/index.ts +++ b/packages/protocols/src/messages/index.ts @@ -408,6 +408,7 @@ export const parseAnthropicBetaHeader = (raw: string | null | undefined): readon raw ? raw.split(',').map(part => part.trim()).filter(part => part.length > 0) : []; export { MESSAGES_MISSING_TERMINAL_MESSAGE, collectMessagesProtocolEventsToResult } from './to-result.ts'; +export { generateAnthropicId } from './id.ts'; export { reassembleMessagesEvents } from './reassemble.ts'; export { messagesProtocolFrameToSSEFrame } from './to-sse.ts'; export { PROMPT_TOO_LONG_MESSAGE, buildPromptTooLongBody } from './context-window-error.ts';