fix: strip the [1m] context marker Claude Code appends to the model name
With the 1M-context beta enabled, Claude Code sends model: "claude-opus-5[1m]".
The marker is a client-side annotation — it matches no combo name, no alias and
no provider/model pair — so the request dies at model resolution with
"Invalid model format" and the client reports "There's an issue with the
selected model". Every request from that session fails until the beta is
switched off.
New open-sse/utils/modelMarkers.js exporting stripModelContextMarker(modelStr)
-> { model, contextMarker }. handleChat strips the marker before resolution and
normalizes body.model so downstream logging and translation see the real name.
Only a trailing marker is stripped, so a model whose name genuinely contains
brackets is left alone.
The capability itself travels in anthropic-beta: context-1m-2025-08-07, which
the default executor already forwards untouched — only the routing key needed
cleaning.
Fixes #3690.
Tests: tests/unit/model-context-marker.test.js (6 cases).
This commit is contained in:
20
open-sse/utils/modelMarkers.js
Normal file
20
open-sse/utils/modelMarkers.js
Normal file
@@ -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() };
|
||||
}
|
||||
@@ -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 `<model>[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)
|
||||
|
||||
|
||||
54
tests/unit/model-context-marker.test.js
Normal file
54
tests/unit/model-context-marker.test.js
Normal file
@@ -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 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user