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
61 changes: 61 additions & 0 deletions src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) ────────

Expand Down Expand Up @@ -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");
});
});
52 changes: 51 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Promise<OAuthProfile | null>>();
const SYSTEM_PROMPT_CACHE_PATH = process.env.ANTHROPIC_SYSTEM_PROMPT_PATH
|| join(process.env.HOME || "", ".cache", "opencode-claude-bridge", "claude-system-prompt.json");
Expand Down Expand Up @@ -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.
Expand Down