From af3b4bbcdbfb4cb58f357199c4b917afe805c406 Mon Sep 17 00:00:00 2001 From: Ben Nasraoui Date: Sat, 18 Apr 2026 17:05:38 +1000 Subject: [PATCH] fix: rewrite cached system prompt model identity to match requested model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bridge injects a cached Claude Code system prompt on every Anthropic request so traffic is classified as Claude Code (plan quota) rather than third-party OAuth (extra usage). The prompt is captured via the validator script with whatever model was used at capture time — typically claude-sonnet-4-6. The prompt contains a self-identity line: "You are powered by the model named Sonnet 4.6. The exact model ID is claude-sonnet-4-6." Without this fix, every request — regardless of the selected model — receives that line, so Opus 4.7 / Haiku 4.5 / any future model is told it is Sonnet 4.6. This can bias behavior: response length, tool-use preferences, and confidence calibration that are tuned per model family. Fix rewrites just the identity line in-place before injection, using the `parsed.model` value from the outbound request body. The display name is derived from the model ID (`claude-opus-4-7` → `Opus 4.7`) instead of a hardcoded map, so new models don't need plugin updates. Falls back to the raw model ID when the `claude-{family}-{major}-{minor}` convention does not match, surfacing naming changes truthfully rather than silently. Tests cover: - ID parsing (normal, dated variant, unknown convention) - Identity rewrite (match, no model, dated variant, non-matching blocks) --- src/index.test.ts | 61 +++++++++++++++++++++++++++++++++++++++++++++++ src/index.ts | 52 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 112 insertions(+), 1 deletion(-) diff --git a/src/index.test.ts b/src/index.test.ts index dd3e03a..a3622a5 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -19,6 +19,10 @@ import { } from "./claude-tools.js"; import { extractOAuthErrorDetail } from "./oauth.js"; import { createSseProcessor, parseSseEvent, buildSseEvent } from "./stream.js"; +import { + deriveModelDisplayName, + rewriteSystemBlocksForModel, +} from "./index.js"; // ── Helpers (extracted / reimplemented from index.ts for unit testing) ──────── @@ -926,3 +930,60 @@ describe("parseSseEvent / buildSseEvent round trip", () => { assert.deepEqual(parsed, { event: null, data: '{"ok":true}' }); }); }); + +describe("deriveModelDisplayName", () => { + it("parses claude-opus-4-7 → Opus 4.7", () => { + assert.equal(deriveModelDisplayName("claude-opus-4-7"), "Opus 4.7"); + }); + + it("parses claude-sonnet-4-6 → Sonnet 4.6", () => { + assert.equal(deriveModelDisplayName("claude-sonnet-4-6"), "Sonnet 4.6"); + }); + + it("ignores a trailing date suffix (claude-haiku-4-5-20251001 → Haiku 4.5)", () => { + assert.equal(deriveModelDisplayName("claude-haiku-4-5-20251001"), "Haiku 4.5"); + }); + + it("falls back to the raw id when the convention doesn't match", () => { + assert.equal(deriveModelDisplayName("weird-custom-model"), "weird-custom-model"); + assert.equal(deriveModelDisplayName("claude-3-5-sonnet-20241022"), "claude-3-5-sonnet-20241022"); + }); +}); + +describe("rewriteSystemBlocksForModel", () => { + const sourceBlock = { + type: "text", + text: + "You are a Claude agent, built on Anthropic's Claude Agent SDK.\n" + + " - You are powered by the model named Sonnet 4.6. The exact model ID is claude-sonnet-4-6.\n" + + " - Assistant knowledge cutoff is August 2025.", + }; + + it("rewrites the identity line to match the requested model", () => { + const [out] = rewriteSystemBlocksForModel([sourceBlock], "claude-opus-4-7"); + assert.ok(out.text?.includes("named Opus 4.7")); + assert.ok(out.text?.includes("claude-opus-4-7")); + assert.ok(!out.text?.includes("Sonnet 4.6")); + assert.ok(!out.text?.includes("claude-sonnet-4-6")); + }); + + it("leaves blocks untouched when no model id is provided", () => { + const [out] = rewriteSystemBlocksForModel([sourceBlock], undefined); + assert.equal(out, sourceBlock); + }); + + it("handles a dated variant without re-including the date suffix in the display name", () => { + const [out] = rewriteSystemBlocksForModel([sourceBlock], "claude-haiku-4-5-20251001"); + assert.ok(out.text?.includes("named Haiku 4.5")); + assert.ok(out.text?.includes("The exact model ID is claude-haiku-4-5-20251001.")); + }); + + it("preserves non-text blocks and non-matching text", () => { + const nonText = { type: "other", text: "unchanged" }; + const noMatch = { type: "text", text: "no identity line here" }; + const blocks = [nonText, noMatch]; + const out = rewriteSystemBlocksForModel(blocks, "claude-opus-4-7"); + assert.deepEqual(out[0], nonText); + assert.equal(out[1].text, "no identity line here"); + }); +}); diff --git a/src/index.ts b/src/index.ts index 889e0ff..e704342 100644 --- a/src/index.ts +++ b/src/index.ts @@ -159,6 +159,52 @@ type PluginClient = { const CLAUDE_PREFIX = "You are a Claude agent, built on Anthropic's Claude Agent SDK."; +/** + * Derive a human-readable display name from a Claude model ID. + * + * Matches `claude-{family}-{major}-{minor}[-{date}]` (the convention used + * for every Claude model family to date) and renders e.g. + * claude-opus-4-7 -> "Opus 4.7" + * claude-haiku-4-5-20251001 -> "Haiku 4.5" + * claude-sonnet-4-6 -> "Sonnet 4.6" + * + * Falls back to the raw model ID if the convention doesn't match, so future + * naming changes are surfaced truthfully instead of silently wrong. + */ +export function deriveModelDisplayName(modelId: string): string { + const m = modelId.match(/^claude-([a-z]+)-(\d+)-(\d+)(?:-\d+)?$/i); + if (!m) return modelId; + const [, family, major, minor] = m; + const capitalized = family.charAt(0).toUpperCase() + family.slice(1).toLowerCase(); + return `${capitalized} ${major}.${minor}`; +} + +/** + * Rewrite model-identity lines inside the cached Claude Code system prompt + * so they reflect the model actually being requested. Without this, every + * request (regardless of selected model) gets a system prompt claiming + * "You are powered by the model named Sonnet 4.6", which can bias behavior. + */ +export function rewriteSystemBlocksForModel( + blocks: Array<{ type?: string; text?: string }>, + modelId: string | undefined, +): Array<{ type?: string; text?: string }> { + if (!modelId) return blocks; + const display = deriveModelDisplayName(modelId); + + return blocks.map((block) => { + if (block?.type !== "text" || typeof block.text !== "string") return block; + let text = block.text; + + text = text.replace( + /You are powered by the model named [^\n]+? The exact model ID is [a-z0-9.-]+\./g, + `You are powered by the model named ${display}. The exact model ID is ${modelId}.`, + ); + + return { ...block, text }; + }); +} + const oauthProfileCache = new Map>(); const SYSTEM_PROMPT_CACHE_PATH = process.env.ANTHROPIC_SYSTEM_PROMPT_PATH || join(process.env.HOME || "", ".cache", "opencode-claude-bridge", "claude-system-prompt.json"); @@ -648,9 +694,13 @@ const OpenCodeClaudeBridge = async ({ client }: { client: PluginClient }) => { // If we have a locally captured Claude Code system prompt, prefer it. if (cachedClaudeSystem) { + const rewritten = rewriteSystemBlocksForModel( + cachedClaudeSystem, + typeof parsed.model === "string" ? parsed.model : undefined, + ); parsed.system = [ { type: "text", text: billingHeader }, - ...cachedClaudeSystem, + ...rewritten, ]; } else if (parsed.system && Array.isArray(parsed.system)) { // Otherwise keep the existing prompt but shape it closer to Claude Code.