diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index 791fbcd35..250aff7d3 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -18,7 +18,7 @@ import { ANTHROPIC_OAUTH_BETA, CLAUDE_CODE_SYSTEM_INSTRUCTION, applyClaudeToolPr import { parseDataUrl } from "./image"; import { enforceAnthropicImageLimits } from "./anthropic-image-guard"; import { normalizeAnthropicImages } from "./anthropic-image-normalize"; -import { neutralizeIdentity } from "./identity"; +import { identifyRoutedModel } from "./identity"; import { CLAUDE_CODE_HEADERS, claudeCodeSessionId } from "./client-fingerprint"; import { buildNonOpenAIToolCatalogNudgeForTools } from "./tool-catalog-nudge"; import { decodeServerSentEvents } from "../lib/sse-decoder"; @@ -441,7 +441,7 @@ function messagesToAnthropicFormat( ); const systemParts = [...(parsed.context.systemPrompt ?? []), ...(toolCatalogNudge ? [toolCatalogNudge] : [])]; const system = systemParts.length - ? neutralizeIdentity(systemParts.join("\n\n")) || undefined + ? identifyRoutedModel(systemParts.join("\n\n"), parsed.modelId) || undefined : undefined; const messages: unknown[] = []; diff --git a/src/adapters/google.ts b/src/adapters/google.ts index 872c3732a..c9d7aedfc 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -20,7 +20,7 @@ import { safeAntigravityHttpErrorMessage, safeVertexHttpErrorMessage } from "./g import { isVertexTruncationReason, vertexTruncationErrorMessage } from "./google-truncation"; import { ANTIGRAVITY_REQUEST_UA, antigravitySessionId, isLikelyRealThoughtSignature, sanitizeAntigravityClaudeSignatures } from "./google-antigravity-wire"; import { compileGoogleWireBody } from "./google-wire-compiler"; -import { neutralizeIdentity } from "./identity"; +import { identifyRoutedModel } from "./identity"; import { antigravityUsesReplayCache, applyAntigravityReplay, clearAntigravityReplay, observeAntigravityReplay } from "./google-antigravity-replay"; import { resolveAntigravityEffortWireModel } from "../providers/antigravity-models"; import { @@ -122,15 +122,18 @@ function geminiToolResultText(content: string | OcxContentPart[]): string { return hasContent ? contentPartsToText(content) : GEMINI_EMPTY_TOOL_OUTPUT_PLACEHOLDER; } -function messagesToGeminiFormat(parsed: OcxParsedRequest): { systemInstruction?: unknown; contents: unknown[] } { +function messagesToGeminiFormat( + parsed: OcxParsedRequest, + routedModelId = parsed.modelId, +): { systemInstruction?: unknown; contents: unknown[] } { // Neutralize Codex's GPT-5 identity line (Gemini/Antigravity share this path) so a routed model // never misreports as GPT-5/OpenAI, and never leaks the proxy identity upstream. const toolCatalogNudge = buildNonOpenAIToolCatalogNudgeForTools(parsed.context.tools, parsed.options.toolChoice); - const systemText = neutralizeIdentity([ + const systemText = identifyRoutedModel([ ...(parsed.context.systemPrompt ?? []), ...(toolCatalogNudge ? [toolCatalogNudge] : []), GOOGLE_BREVITY_INSTRUCTION, - ].join("\n\n")); + ].join("\n\n"), routedModelId); const systemInstruction = { parts: [{ text: systemText }] }; const contents: unknown[] = []; @@ -292,7 +295,13 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte : {}), async buildRequest(parsed: OcxParsedRequest) { - const { systemInstruction, contents } = messagesToGeminiFormat(parsed); + const routedModelId = provider.googleMode === "cloud-code-assist" + ? resolveAntigravityEffortWireModel( + parsed.modelId, + mapReasoningEffort(provider, parsed.modelId, parsed.options.reasoning), + ).wireModelId + : parsed.modelId; + const { systemInstruction, contents } = messagesToGeminiFormat(parsed, routedModelId); const tools = toolsToGeminiFormat(parsed); const body: Record = { contents }; diff --git a/src/adapters/identity.ts b/src/adapters/identity.ts index 655b6a28b..d09741067 100644 --- a/src/adapters/identity.ts +++ b/src/adapters/identity.ts @@ -1,5 +1,5 @@ /** - * Central identity neutralization. + * Central routed-model identity repair. * * Codex sends the SAME GPT-5 identity line to EVERY model at request time (the per-model catalog * `base_instructions` is ignored on the wire). For routed, non-OpenAI providers that line is both @@ -8,10 +8,11 @@ * into the upstream payload — a signature no first-party client (Claude Code, Gemini CLI, Kiro) ever * sends, and a likely ToS trigger. * - * The neutral replacement keeps ONLY the necessary instruction (don't misreport as GPT-5/OpenAI) - * and names no proxy. Provider-native identity blocks (e.g. the anthropic OAuth "You are a Claude - * agent..." prefix) are layered on TOP of this by the individual adapters; this module never claims - * to be a specific first-party client. + * The replacement keeps the necessary instruction (don't misreport as GPT-5/OpenAI), names the + * model id that is actually sent on the wire when it is safe to interpolate, and names no proxy. + * Provider-native identity blocks (e.g. the anthropic OAuth "You are a Claude agent..." prefix) + * are layered on TOP of this by the individual adapters; this module never claims to be a specific + * first-party client. */ /** Historical exact identity line Codex injected for every model. */ @@ -37,7 +38,39 @@ export const NEUTRAL_IDENTITY_LINE = "You are a coding agent. Do not claim to be * the leak can't reappear in one adapter while being fixed in another. */ export function neutralizeIdentity(systemText: string): string { - return systemText.replace(CODEX_GPT5_IDENTITY_RE, NEUTRAL_IDENTITY_LINE); + // A callback avoids `$&`, `$'`, and other replacement-string substitutions if this constant ever + // becomes configurable. Keep the same safe form in identifyRoutedModel below. + return systemText.replace(CODEX_GPT5_IDENTITY_RE, () => NEUTRAL_IDENTITY_LINE); +} + +function safeRoutedModelIdentity(modelName: string): string | null { + // Callers pass the model id after adapter-specific wire normalization. Brackets remain valid for + // providers that intentionally send a suffix such as `[1m]`; the OpenAI-chat adapter strips that + // suffix before calling us only when modelSuffixBracketStrip is enabled. + const trimmed = modelName.trim(); + if (trimmed.length === 0 || trimmed.length > 128) return null; + const allowedPunctuation = "._/@:+-[]~"; + for (const char of trimmed) { + const code = char.charCodeAt(0); + const isAsciiAlphaNumeric = (code >= 48 && code <= 57) + || (code >= 65 && code <= 90) + || (code >= 97 && code <= 122); + if (!isAsciiAlphaNumeric && !allowedPunctuation.includes(char)) return null; + } + return trimmed; +} + +/** + * Identity for a routed model. Callers pass the concrete model id that will be sent upstream, so + * identity questions can name it instead of falling back to Codex/GPT identity inherited from the + * native template. + */ +export function identifyRoutedModel(systemText: string, modelName: string): string { + const identity = safeRoutedModelIdentity(modelName); + const replacement = identity + ? `You are a coding agent powered by the ${identity}. If asked which model you are, identify as ${identity}. Do not claim to be a different model or to have a different creator.` + : "You are a coding agent powered by the configured model. If asked which model you are, identify as configured model. Do not claim to be GPT-5 or made by OpenAI."; + return systemText.replace(CODEX_GPT5_IDENTITY_RE, () => replacement); } /** The catalog (static, on-disk) replacement for `base_instructions`. Same neutral wording. */ diff --git a/src/adapters/kiro.ts b/src/adapters/kiro.ts index 5e291e8b1..7cac67259 100644 --- a/src/adapters/kiro.ts +++ b/src/adapters/kiro.ts @@ -39,7 +39,7 @@ import { extractKiroImages, normalizeKiroImages, type KiroImage } from "./kiro-i import { sniffImageDimensions } from "./anthropic-image-guard"; import { fetchKiroWithRetry, noteKiroTransientThrottle } from "./kiro-retry"; import { convertKiroToolContext } from "./kiro-tools"; -import { neutralizeIdentity } from "./identity"; +import { identifyRoutedModel } from "./identity"; import { buildNonOpenAIToolCatalogNudgeFromNames } from "./tool-catalog-nudge"; import { KIRO_COMPLETION_INSTRUCTIONS, @@ -431,9 +431,10 @@ export function buildKiroPayload( const nameMap = toolContext.nameMap; const systemParts: string[] = []; const injectedChars = { value: 0 }; - // Neutralize Codex's GPT-5 identity line so a routed Kiro model never misreports as GPT-5/OpenAI - // and the proxy identity never leaks upstream. - if (parsed.context.systemPrompt?.length) systemParts.push(neutralizeIdentity(parsed.context.systemPrompt.join("\n\n"))); + // Name the Kiro model id actually sent on the wire without leaking the proxy identity upstream. + if (parsed.context.systemPrompt?.length) { + systemParts.push(identifyRoutedModel(parsed.context.systemPrompt.join("\n\n"), modelId)); + } for (const addition of toolContext.systemAdditions) { const boundedAddition = boundedInjectedInstruction(addition, injectedChars); if (boundedAddition) systemParts.push(boundedAddition); diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index 1abd9743a..284879010 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -7,7 +7,7 @@ import { isDebugEnabled } from "../lib/debug-settings"; import { isCyberPolicyCode } from "../lib/errors"; import { redactSecretString } from "../lib/redact"; import { contentPartsToText } from "./image"; -import { neutralizeIdentity } from "./identity"; +import { identifyRoutedModel } from "./identity"; import { buildNonOpenAIToolCatalogNudgeForTools, shouldInjectNonOpenAIToolCatalogNudge } from "./tool-catalog-nudge"; import { openRouterProviderPayload, resolveOpenRouterRouting } from "../providers/openrouter-routing"; import { @@ -147,7 +147,10 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon // base_instructions is ignored at request time). Neutralize that one identity line // so routed, non-OpenAI models don't misreport themselves as GPT-5 / OpenAI — without // leaking the proxy identity into the payload. - const sys = neutralizeIdentity(systemParts.join("\n\n")); + const wireModelId = provider.modelSuffixBracketStrip + ? stripBracketedModelSuffix(parsed.modelId) + : parsed.modelId; + const sys = identifyRoutedModel(systemParts.join("\n\n"), wireModelId); out.push({ role: "system", content: sys }); } diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index d55c833ab..187094d12 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -14,7 +14,7 @@ import { enrichProviderFromRegistry, shouldCaseFoldMetadataModelId } from "../.. import { getProviderRegistryEntry } from "../../providers/registry"; import { applyProviderContextCap, providerContextCap } from "../../providers/context-cap"; import { routedSlug, slugEquals, slugsEquivalent } from "../../providers/slug-codec"; -import { CODEX_GPT5_IDENTITY_LINE } from "../../adapters/identity"; +import { identifyRoutedModel } from "../../adapters/identity"; import { filterCursorConfiguredModelsByLiveDiscovery } from "../../adapters/cursor/discovery"; import { fetchCursorUsableModels } from "../../adapters/cursor/live-models"; import { isCanonicalOpenAiForwardProvider, OPENAI_API_PROVIDER_ID, OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers"; @@ -194,10 +194,7 @@ export function deriveEntry( if (typeof e.base_instructions === "string") { // Proxy-neutral: keep the GPT-5/OpenAI disclaimer but never advertise the opencodex proxy // (leaking that into base_instructions is a non-first-party signature → ToS risk). - e.base_instructions = e.base_instructions.replace( - CODEX_GPT5_IDENTITY_LINE, - `You are a coding agent powered by the ${modelName} model. Do not claim to be GPT-5 or made by OpenAI.`, - ); + e.base_instructions = identifyRoutedModel(e.base_instructions, modelName); } applyReasoningLevels(e, model?.reasoningEfforts, model?.defaultReasoningEffort, preserveExact); normalizeRoutedCatalogEntry(e, model?.parallelToolCalls === true); diff --git a/tests/codex-catalog-golden.test.ts b/tests/codex-catalog-golden.test.ts index 31357b3fb..eacc50003 100644 --- a/tests/codex-catalog-golden.test.ts +++ b/tests/codex-catalog-golden.test.ts @@ -13,8 +13,8 @@ function template(): Record { description: "Native GPT model", priority: 1, visibility: "list", - base_instructions: "You are Codex, a coding agent based on GPT-5.\nUse tools carefully.", - model_messages: { instructions_template: "You are Codex, a coding agent based on GPT-5." }, + base_instructions: "You are Codex, an agent based on GPT-5.\nUse tools carefully.", + model_messages: { instructions_template: "You are Codex, an agent based on GPT-5." }, tool_mode: "code", use_responses_lite: true, supports_websockets: true, diff --git a/tests/identity-neutralize.test.ts b/tests/identity-neutralize.test.ts index 2ea559022..559b51f5f 100644 --- a/tests/identity-neutralize.test.ts +++ b/tests/identity-neutralize.test.ts @@ -5,10 +5,12 @@ import { join } from "node:path"; import { CODEX_GPT5_IDENTITY_LINE, CODEX_GPT5_IDENTITY_LINE_AGENT, + identifyRoutedModel, NEUTRAL_IDENTITY_LINE, neutralizeIdentity, } from "../src/adapters/identity"; import { createGoogleAdapter } from "../src/adapters/google"; +import { createAnthropicAdapter } from "../src/adapters/anthropic"; import { createKiroAdapter } from "../src/adapters/kiro"; import { createOpenAIChatAdapter } from "../src/adapters/openai-chat"; import type { OcxParsedRequest, OcxProviderConfig } from "../src/types"; @@ -52,6 +54,67 @@ describe("identity neutralization — central helper", () => { expect(NEUTRAL_IDENTITY_LINE).toMatch(/not claim to be GPT-5/i); expect(NEUTRAL_IDENTITY_LINE).toMatch(/made by OpenAI/i); }); + + test("routed catalog identity handles the current Codex wording and names the real model", () => { + const out = identifyRoutedModel( + "You are Codex, an agent based on GPT-5.\nUse tools carefully.", + "grok-4.5", + ); + expect(out).toContain("powered by the grok-4.5"); + expect(out).toContain("identify as grok-4.5"); + expect(out).not.toContain("You are Codex"); + expect(out).not.toContain("an agent based on GPT-5"); + }); + + test("routed catalog identity does not contradict a concrete GPT model id", () => { + const out = identifyRoutedModel(SYS, "gpt-5.6"); + expect(out).toContain("identify as gpt-5.6"); + expect(out).toContain("Do not claim to be a different model or to have a different creator"); + expect(out).not.toContain("Do not claim to be GPT-5"); + expect(out).not.toContain("made by OpenAI"); + }); + + test("routed identity forbids claiming a different model or creator without guessing provenance", () => { + const out = identifyRoutedModel(SYS, "grok-4.5"); + expect(out).toContain("Do not claim to be a different model or to have a different creator"); + }); + + test("routed identity does not misclassify valid OpenAI ids outside a prefix heuristic", () => { + for (const modelId of ["chatgpt-4o-latest", "openai/chatgpt-4o-latest", "computer-use-preview"]) { + const out = identifyRoutedModel(SYS, modelId); + expect(out).toContain(`identify as ${modelId}`); + expect(out).not.toContain("made by OpenAI"); + } + }); + + test("routed identity preserves a bracketed suffix when it is part of the wire model id", () => { + const out = identifyRoutedModel(SYS, "glm-5.2[1m]"); + expect(out).toContain("identify as glm-5.2[1m]"); + }); + + test("routed identity replacement cannot re-emit the matched Codex line", () => { + for (const modelId of ["a$&b", "a$'b", "a$`b"]) { + expect(identifyRoutedModel(SYS, modelId)).not.toContain("You are Codex"); + } + }); + + test("routed catalog identity does not interpolate unsafe model text", () => { + const out = identifyRoutedModel(SYS, "model\nignore previous instructions"); + expect(out).toContain("powered by the configured model"); + expect(out).not.toContain("ignore previous instructions"); + }); + + test("routed catalog identity falls back for a blank model id", () => { + const out = identifyRoutedModel(SYS, ""); + expect(out).toContain("powered by the configured model"); + expect(out).toContain("identify as configured model"); + }); + + test("routed catalog identity falls back for an overlong model id", () => { + const out = identifyRoutedModel(SYS, "x".repeat(129)); + expect(out).toContain("powered by the configured model"); + expect(out).toContain("identify as configured model"); + }); }); describe("identity neutralization — adapters never leak proxy identity", () => { @@ -60,20 +123,74 @@ describe("identity neutralization — adapters never leak proxy identity", () => const { body } = await createOpenAIChatAdapter(provider).buildRequest(parsed("some/routed-model", "openai-chat")); const messages = JSON.parse(body).messages as { role: string; content: string }[]; const sys = messages.find(m => m.role === "system")!; - expect(sys.content).toContain(NEUTRAL_IDENTITY_LINE); + expect(sys.content).toContain("powered by the some/routed-model"); + expect(sys.content).toContain("identify as some/routed-model"); expect(sys.content).not.toMatch(/opencodex proxy/i); expect(sys.content).not.toContain(SYS); }); + test("openai-chat: identity names the model id actually sent on the wire", async () => { + const provider = { + adapter: "openai-chat", + baseUrl: "https://api.example.invalid", + apiKey: "key", + modelSuffixBracketStrip: true, + } as unknown as OcxProviderConfig; + const { body } = await createOpenAIChatAdapter(provider).buildRequest(parsed("glm-5.2[1m]", "openai-chat")); + const payload = JSON.parse(body) as { model: string; messages: Array<{ role: string; content: string }> }; + const sys = payload.messages.find(message => message.role === "system")!; + expect(payload.model).toBe("glm-5.2"); + expect(sys.content).toContain("identify as glm-5.2."); + expect(sys.content).not.toContain("[1m]"); + }); + + test("openai-chat: unflagged provider preserves the suffix in both wire model and identity", async () => { + const provider = { + adapter: "openai-chat", + baseUrl: "https://api.example.invalid", + apiKey: "key", + } as unknown as OcxProviderConfig; + const { body } = await createOpenAIChatAdapter(provider).buildRequest(parsed("k3[1m]", "openai-chat")); + const payload = JSON.parse(body) as { model: string; messages: Array<{ role: string; content: string }> }; + const sys = payload.messages.find(message => message.role === "system")!; + expect(payload.model).toBe("k3[1m]"); + expect(sys.content).toContain("identify as k3[1m]"); + }); + + test("openai-chat: OpenRouter latest alias matches in the wire model and identity", async () => { + const provider = { + adapter: "openai-chat", + baseUrl: "https://api.example.invalid", + apiKey: "key", + } as unknown as OcxProviderConfig; + const { body } = await createOpenAIChatAdapter(provider).buildRequest(parsed("~x-ai/grok-latest", "openai-chat")); + const payload = JSON.parse(body) as { model: string; messages: Array<{ role: string; content: string }> }; + const sys = payload.messages.find(message => message.role === "system")!; + expect(payload.model).toBe("~x-ai/grok-latest"); + expect(sys.content).toContain("identify as ~x-ai/grok-latest"); + }); + test("google/antigravity: systemInstruction is neutralized, no proxy mention", async () => { const provider = { adapter: "google", baseUrl: "https://generativelanguage.googleapis.com", apiKey: "key" }; const { body } = await createGoogleAdapter(provider).buildRequest(parsed("gemini-3-pro", "google")); const sysText = JSON.parse(body).systemInstruction.parts.map((p: { text: string }) => p.text).join(""); - expect(sysText).toContain(NEUTRAL_IDENTITY_LINE); + expect(sysText).toContain("identify as gemini-3-pro"); expect(sysText).not.toMatch(/opencodex proxy/i); expect(sysText).not.toContain(SYS); }); + test("anthropic: system block names the routed model", async () => { + const provider = { + adapter: "anthropic", + baseUrl: "https://api.anthropic.com", + apiKey: "key", + authMode: "key", + } as unknown as OcxProviderConfig; + const { body } = await createAnthropicAdapter(provider).buildRequest(parsed("claude-sonnet-5", "anthropic")); + const payload = JSON.parse(body) as { system: Array<{ text: string }> }; + expect(payload.system.map(part => part.text).join("\n")).toContain("identify as claude-sonnet-5"); + }); + describe("kiro", () => { const origHome = process.env.HOME; const origRegion = process.env.KIRO_REGION; @@ -95,6 +212,7 @@ describe("identity neutralization — adapters never leak proxy identity", () => const serialized = typeof body === "string" ? body : JSON.stringify(body); expect(serialized).not.toMatch(/opencodex proxy/i); expect(serialized).not.toContain(SYS); + expect(serialized).toContain("identify as claude-sonnet-4.5"); }); }); });