diff --git a/open-sse/utils/modelMarkers.js b/open-sse/utils/modelMarkers.js new file mode 100644 index 00000000..6429fca0 --- /dev/null +++ b/open-sse/utils/modelMarkers.js @@ -0,0 +1,20 @@ +// Claude Code appends a bracketed context marker to the model name when the +// 1M-context beta is toggled on: `claude-opus-5` becomes `claude-opus-5[1m]`. +// The marker is a client-side annotation, not part of any model id: it never +// matches a combo name, an alias or a `provider/model` pair, so a request that +// carries it dies at model resolution with "Invalid model format". +// +// The capability itself travels in the `anthropic-beta: context-1m-2025-08-07` +// header, which is forwarded untouched — stripping the marker is enough to let +// the request route normally and still reach the upstream as a 1M request. + +const CONTEXT_MARKER = /\[1m\]$/i; + +// Returns { model, contextMarker } — contextMarker is null when there is none. +export function stripModelContextMarker(modelStr) { + if (typeof modelStr !== "string") return { model: modelStr, contextMarker: null }; + const trimmed = modelStr.trim(); + const match = trimmed.match(CONTEXT_MARKER); + if (!match) return { model: modelStr, contextMarker: null }; + return { model: trimmed.slice(0, -match[0].length), contextMarker: match[0].slice(1, -1).toLowerCase() }; +} diff --git a/src/sse/handlers/chat.js b/src/sse/handlers/chat.js index 8ae048e4..19dc8167 100644 --- a/src/sse/handlers/chat.js +++ b/src/sse/handlers/chat.js @@ -23,6 +23,7 @@ import { detectFormatByEndpoint } from "open-sse/translator/formats.js"; import * as log from "../utils/logger.js"; import { updateProviderCredentials, checkAndRefreshToken } from "../services/tokenRefresh.js"; import { getProjectIdForConnection } from "open-sse/services/projectId.js"; +import { stripModelContextMarker } from "open-sse/utils/modelMarkers.js"; /** * Handle chat completion request @@ -47,7 +48,11 @@ export async function handleChat(request, clientRawRequest = null) { headers: Object.fromEntries(request.headers.entries()) }; } - const modelStr = body.model; + // Claude Code marks a 1M-context request as `[1m]`; the marker matches + // no combo, alias or provider/model pair, so it must not reach resolution. + // The capability travels in the anthropic-beta header, forwarded as-is. + const { model: modelStr, contextMarker } = stripModelContextMarker(body.model); + if (contextMarker) body.model = modelStr; // Request summary is emitted as the unified "▶" line in chatCore (has fmt/thinking/account) diff --git a/tests/unit/model-context-marker.test.js b/tests/unit/model-context-marker.test.js new file mode 100644 index 00000000..a2fcf314 --- /dev/null +++ b/tests/unit/model-context-marker.test.js @@ -0,0 +1,54 @@ +/** + * Regression: Claude Code appends `[1m]` to the model name when the + * 1M-context beta is on, so `/v1/messages` arrives with + * `model: "claude-opus-5[1m]"`. Nothing in 9router knows about the marker: + * it matches no combo, no alias and no `provider/model` pair, so the request + * is rejected at model resolution and the client reports + * + * There's an issue with the selected model (claude-opus-5[1m]). + * It may not exist or you may not have access to it. + * + * The capability itself rides in the `anthropic-beta` header, which is + * forwarded untouched, so stripping the marker is all that is needed. + */ + +import { describe, it, expect } from "vitest"; +import { stripModelContextMarker } from "../../open-sse/utils/modelMarkers.js"; + +describe("model context marker", () => { + it("strips the [1m] marker and reports it", () => { + expect(stripModelContextMarker("claude-opus-5[1m]")).toEqual({ + model: "claude-opus-5", + contextMarker: "1m", + }); + }); + + it("strips it from a provider-prefixed model too", () => { + expect(stripModelContextMarker("cc/claude-sonnet-4.5[1m]")).toEqual({ + model: "cc/claude-sonnet-4.5", + contextMarker: "1m", + }); + }); + + it("is case insensitive", () => { + expect(stripModelContextMarker("claude-opus-5[1M]").model).toBe("claude-opus-5"); + }); + + it("leaves a plain model untouched", () => { + expect(stripModelContextMarker("claude-opus-5")).toEqual({ + model: "claude-opus-5", + contextMarker: null, + }); + }); + + it("only strips a trailing marker, never one inside the name", () => { + expect(stripModelContextMarker("weird[1m]name")).toEqual({ + model: "weird[1m]name", + contextMarker: null, + }); + }); + + it("tolerates a non-string model", () => { + expect(stripModelContextMarker(undefined)).toEqual({ model: undefined, contextMarker: null }); + }); +});