diff --git a/src/codex/catalog.ts b/src/codex/catalog.ts index 3ee398ecf3..5f899b0c8a 100644 --- a/src/codex/catalog.ts +++ b/src/codex/catalog.ts @@ -8,6 +8,6 @@ export { nativeEffortClamp, shouldApplyNativeEffortClamp, catalogModelEfforts, c export { applyProviderConfigHints, isDatedVariantId, filterCatalogVisibleModels, gatherRoutedModels, clearGatherRoutedModelsInflight, augmentRoutedModelsWithRegistryOpenAiApiRows, augmentRoutedModelsWithJawcodeMetadata } from "./catalog/provider-fetch"; export { deriveComboCatalogModel, exactComboCatalogSlugs, getLastComboCatalogOmissions, resetOpenAiApiCatalogWarningStateForTests, uniqueCatalogModelsForPublicList, uniqueCatalogModelsForRawPublicList, buildComboCatalogOmission, comboCatalogOmissionReason, summarizeComboCatalogOmissions } from "./catalog/aggregation"; export type { ComboCatalogOmission, ComboCatalogOmissionReason } from "./catalog/aggregation"; -export { MAX_SPAWN_AGENT_MODEL_OVERRIDES, effectiveSubagentRoster, buildCatalogEntries, resetCatalogRuntimeStateForTests, orderForSubagents, mergeCatalogEntriesForSync, syncCatalogModels, restoreCodexCatalog, invalidateCodexModelsCache } from "./catalog/sync"; +export { ANTHROPIC_RESPONSE_MODEL_ALIAS_CATALOG_KIND, MAX_SPAWN_AGENT_MODEL_OVERRIDES, effectiveSubagentRoster, buildCatalogEntries, resetCatalogRuntimeStateForTests, orderForSubagents, mergeCatalogEntriesForSync, syncCatalogModels, restoreCodexCatalog, invalidateCodexModelsCache } from "./catalog/sync"; export type { SpawnAgentSurface, SubagentRosterExclusionReason, EffectiveSubagentModel, SubagentRosterExclusion, EffectiveSubagentRoster } from "./catalog/sync"; export { accountBoundNativeDisplayName, accountBoundNativeModelSlugs, CODEX_ACCOUNT_BOUND_CATALOG_KIND, trustedAccountBoundNativeCatalogSlug, visibleCodexAccountSelectors } from "./catalog/account-models"; diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index 79113d42f4..7c808125b4 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -56,6 +56,39 @@ import { codexRuntimeStatePath } from "../runtime"; import { accountBoundNativeDisplayName, CODEX_ACCOUNT_BOUND_CATALOG_KIND, trustedAccountBoundNativeCatalogSlug, visibleCodexAccountSelectors } from "./account-models"; export const MAX_SPAWN_AGENT_MODEL_OVERRIDES = 5; +/** Marker for hidden bare Anthropic rows that heal legacy response-model identities. */ +export const ANTHROPIC_RESPONSE_MODEL_ALIAS_CATALOG_KIND = "anthropic-response-model-alias-v1"; + +function isAnthropicResponseModelAlias(entry: RawEntry): boolean { + return entry.opencodex_catalog_kind === ANTHROPIC_RESPONSE_MODEL_ALIAS_CATALOG_KIND; +} + +/** + * Clone canonical Anthropic catalog rows under hidden bare slugs. Older Codex + * sessions can still request the bare id, while new responses always identify + * the canonical provider-qualified row. The marker lets sync and restore remove + * these generated aliases without treating them as user-authored native rows. + */ +function appendAnthropicResponseModelAliases( + entries: RawEntry[], + models: readonly CatalogModel[], +): RawEntry[] { + const result = [...entries]; + for (const model of models) { + if (model.provider !== "anthropic" || model.id.includes("/")) continue; + const canonicalSlug = routedSlug(model.provider, model.id); + const canonical = entries.find(entry => entry.slug === canonicalSlug); + if (!canonical) continue; + if (result.some(entry => entry.slug === model.id)) continue; + result.push({ + ...JSON.parse(JSON.stringify(canonical)) as RawEntry, + slug: model.id, + visibility: "hide", + opencodex_catalog_kind: ANTHROPIC_RESPONSE_MODEL_ALIAS_CATALOG_KIND, + }); + } + return result; +} export type SpawnAgentSurface = "v1" | "v2"; @@ -396,7 +429,10 @@ export function buildCatalogEntries( delete entry.prefer_websockets; } } - return applyMultiAgentMode(out, multiAgentMode, isMultiAgentV2Enabled()); + return appendAnthropicResponseModelAliases( + applyMultiAgentMode(out, multiAgentMode, isMultiAgentV2Enabled()), + goModels, + ); } export function resetCatalogRuntimeStateForTests(): void { @@ -466,6 +502,7 @@ export function mergeCatalogEntriesForSync( ? catalogModels .filter(m => typeof m.slug === "string" && !(m.slug as string).includes("/") + && !isAnthropicResponseModelAlias(m) && m.owned_by !== COMBO_NAMESPACE && !goIds.has(m.slug as string) && !isUnsupportedOpenAiNativeSlug(m.slug as string)) @@ -996,11 +1033,16 @@ export function restoreCodexCatalogWithPermit( const replacementVisibility = visibleAccountReplacementNatives(catalog.models, disabledModels); const backup = readCatalogBackup(catalogPath); if (backup && Array.isArray(backup.models)) { - const removed = (catalog.models ?? []).filter(m => typeof m.slug === "string" && m.slug.includes("/")).length; + const removed = (catalog.models ?? []).filter(m => + (typeof m.slug === "string" && m.slug.includes("/")) || isAnthropicResponseModelAlias(m) + ).length; const backupSlugs = new Set(backup.models.flatMap(m => typeof m.slug === "string" ? [m.slug] : [])); const userNativeAdditions = restoreAccountHiddenBareNatives( (catalog.models ?? []).filter(m => - typeof m.slug === "string" && !m.slug.includes("/") && !backupSlugs.has(m.slug) + typeof m.slug === "string" + && !m.slug.includes("/") + && !backupSlugs.has(m.slug) + && !isAnthropicResponseModelAlias(m) ), replacementVisibility, disabledModels, @@ -1017,7 +1059,10 @@ export function restoreCodexCatalogWithPermit( } const before = catalog.models.length; const native = restoreAccountHiddenBareNatives( - catalog.models.filter(m => !(typeof m.slug === "string" && m.slug.includes("/"))), + catalog.models.filter(m => + !(typeof m.slug === "string" && m.slug.includes("/")) + && !isAnthropicResponseModelAlias(m) + ), replacementVisibility, disabledModels, ); diff --git a/src/images/loop.ts b/src/images/loop.ts index e1eb47f089..da32837a55 100644 --- a/src/images/loop.ts +++ b/src/images/loop.ts @@ -225,6 +225,8 @@ class LoopError extends Error { */ export interface ImageBridgeDeps { parsed: OcxParsedRequest; + /** Client-facing Responses model; upstream adapters continue to read parsed.modelId. */ + responseModel?: string; adapter: ProviderAdapter; incomingMeta: IncomingMeta; plan?: ImageBridgePlan; @@ -900,7 +902,7 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise { + produce(), deps.responseModel ?? parsed.modelId, toolNsMap, freeform, toolSearch, () => { internalAbort.abort("client closed responses stream"); }, 2_000, { diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 2d00ab0c43..9b9931eb85 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -98,7 +98,7 @@ import { ForwardAdmissionCredentialError, validateForwardAdmissionCredential } f import { createTranslatorBudget, isTranslatorBudgetExceededError, type TranslatorBudget } from "../../lib/translator-budget"; import { listOpenAiForwardSidecarCandidates, resolveFirstUsableOpenAiSidecar, type ResolvedOpenAiForwardSidecar } from "../../providers/openai-sidecar"; import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers"; -import { slugsEquivalent } from "../../providers/slug-codec"; +import { routedSlug, slugsEquivalent } from "../../providers/slug-codec"; import { applyOpenAiVirtualModel, resolveOpenAiCompactModel } from "../../providers/openai-virtual-models"; import { isUsageDebugEnabled } from "../../usage/debug"; import { readJsonRequestBody, DecompressedBodyTooLargeError, UnsupportedContentEncodingError } from "../request-decompress"; @@ -1529,6 +1529,7 @@ async function handleResponsesInner( // upstream for reliability (#875); the answer must then be reframed to SSE // for streaming clients. const clientRequestedStream = parsed.stream; + const finalSelectedModelId = route.modelId; await applyFinalRouteRequestNormalization({ parsed, route, @@ -1538,6 +1539,15 @@ async function handleResponsesInner( inboundWire, inboundTransport: options.inboundTransport, }); + // Anthropic routes strip their provider namespace for the upstream Messages + // request. Keep the canonical Codex catalog selector separately so every + // client-facing Responses surface resolves back to the same metadata row. + // Legacy bare selectors are intentionally healed to the namespaced row. + // Other adapters retain their post-normalization identity (including virtual + // model rewrites), exactly as before this repair. + const responseModel = route.provider.adapter === "anthropic" + ? routedSlug(route.providerName, finalSelectedModelId) + : route.modelId; // Attribute local auth/cooldown failures to the public selector too; exact auth may fail before // the normal post-resolution provider label is assigned. if (route.codexAccountNamespace) { @@ -2388,6 +2398,7 @@ async function handleResponsesInner( } const imgResponse = await runWithImageBridge({ parsed, adapter, + responseModel, incomingMeta: { headers: selectedForwardHeaders, abortSignal: options.abortSignal, translatorBudget }, ...(imgPlan ? { plan: imgPlan } : {}), ...(vidPlan ? { videoPlan: vidPlan } : {}), @@ -2463,6 +2474,7 @@ async function handleResponsesInner( parsed.context.tools = [...(parsed.context.tools ?? []), buildWebSearchTool()]; const wsResponse = await runWithWebSearch({ parsed, adapter, + responseModel, incomingMeta: { headers: selectedForwardHeaders, abortSignal: options.abortSignal, translatorBudget }, backend: wsPlan.backend, forwardProvider: wsPlan.forwardSidecar?.provider, @@ -2560,7 +2572,7 @@ async function handleResponsesInner( eventSource = preflight.stream; } const sseStream = bridgeToResponsesSSE( - eventSource, parsed.modelId, toolNsMap, freeformToolNames, toolSearchToolNames, + eventSource, responseModel, toolNsMap, freeformToolNames, toolSearchToolNames, () => { runTurnAbort.abort(); queue.close(); @@ -2612,7 +2624,7 @@ async function handleResponsesInner( } } let providerState: OcxProviderContinuationState | undefined; - const json = buildResponseJSON(events, parsed.modelId, { + const json = buildResponseJSON(events, responseModel, { translatorBudget, replayCacheScope: parsed._clientThreadId ?? "global", hideThinkingSummary: parsed.options.hideThinkingSummary, @@ -3254,7 +3266,7 @@ async function handleResponsesInner( : initialEventStream; const { toolNsMap, freeformToolNames, toolSearchToolNames } = toolBridgeMaps; const sseStream = bridgeToResponsesSSE( - eventStream, parsed.modelId, toolNsMap, freeformToolNames, toolSearchToolNames, + eventStream, responseModel, toolNsMap, freeformToolNames, toolSearchToolNames, () => upstream.abort(), 2_000, { translatorBudget, @@ -3314,7 +3326,7 @@ async function handleResponsesInner( } const { toolNsMap, freeformToolNames, toolSearchToolNames } = toolBridgeMaps; let providerState: OcxProviderContinuationState | undefined; - const json = buildResponseJSON(events, parsed.modelId, { + const json = buildResponseJSON(events, responseModel, { translatorBudget, replayCacheScope: parsed._clientThreadId ?? "global", hideThinkingSummary: parsed.options.hideThinkingSummary, diff --git a/src/web-search/loop.ts b/src/web-search/loop.ts index ce4e4eb45a..62eb1d6bbd 100644 --- a/src/web-search/loop.ts +++ b/src/web-search/loop.ts @@ -242,6 +242,8 @@ class LoopError extends Error { */ export interface WebSearchLoopDeps { parsed: OcxParsedRequest; + /** Client-facing Responses model; upstream adapters continue to read parsed.modelId. */ + responseModel?: string; adapter: ProviderAdapter; incomingMeta: IncomingMeta; /** Which executor runs searches. Defaults to "openai" so existing callers keep the ChatGPT path (audit F4). */ @@ -770,7 +772,7 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise { + produce(), deps.responseModel ?? parsed.modelId, toolNsMap, freeform, toolSearch, () => { const elapsed = Date.now() - loopT0; if (executedSearchCount > 0 || searchesExecuted > 0) { console.warn(`[web-search-loop] cancelled — ${executedSearchCount} real searches, ${searchesExecuted - executedSearchCount} placeholders, ${elapsed}ms`); diff --git a/structure/03_catalog-and-subagents.md b/structure/03_catalog-and-subagents.md index fc7739f41c..c2ef2540a7 100644 --- a/structure/03_catalog-and-subagents.md +++ b/structure/03_catalog-and-subagents.md @@ -121,6 +121,35 @@ The `multi_agent_v2` feature flag and the logical maximum thread count are separ `multiAgentMode` (`src/codex/features.ts`): the mode decides which surface Codex advertises, while the flag and thread count decide what the native runtime allows. +## Routed response model identity + +Anthropic requests use a provider-qualified Codex selector (for example +`anthropic/claude-sonnet-5`) but the upstream Messages request uses the bare model id. The response +bridge preserves those as separate identities: upstream adapters read the bare `parsed.modelId`, +while every Codex-facing Responses path emits the canonical provider-qualified selector. This +includes the ordinary stream/JSON bridge and the image and web-search loops. + +Legacy sessions may still send a bare Anthropic selector. Catalog sync therefore clones each +canonical Anthropic row into a hidden bare compatibility row with identical context, compaction, +modality, and reasoning metadata. The clone carries +`opencodex_catalog_kind = "anthropic-response-model-alias-v1"`; sync drops stale marked rows and +restore removes them without touching user-authored bare catalog entries. + +[Decision Log] +- 목적과 의도: keep `response.model` resolvable against the exact Codex catalog metadata row while + preserving Anthropic's bare upstream wire model. +- 기존 구현 및 제약 조건: route normalization overwrites `parsed.modelId` before every response + bridge, and old sessions can resume with a bare selector that has no catalog row. +- 검토한 주요 대안: stop stripping the upstream model, rewrite only the terminal event, or retain a + separate client-facing identity and marker-owned hidden compatibility row. +- 선택한 방식: retain the separate identity and thread it through every bridge; generate only the + built-in Anthropic compatibility aliases and mark them for deterministic cleanup. +- 다른 대안 대신 이 방식을 선택한 이유: upstream behavior remains byte-compatible, all response + events agree on one identity, and restore can distinguish generated aliases from user data. +- 장점, 단점 및 영향: long-context and auto-compaction metadata resolve correctly for new and + resumed sessions; the catalog gains hidden rows, but they never appear in the picker and are + removed with OpenCodex-owned routing state. + ## Ultra reasoning level Ultra is always advertised in the catalog regardless of the `multi_agent_v2` toggle. The v2 toggle diff --git a/tests/anthropic-response-model.test.ts b/tests/anthropic-response-model.test.ts new file mode 100644 index 0000000000..9eebc71d44 --- /dev/null +++ b/tests/anthropic-response-model.test.ts @@ -0,0 +1,103 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { saveCredential } from "../src/oauth/store"; +import { handleResponses } from "../src/server/responses"; +import type { OcxConfig } from "../src/types"; + +const config = { + port: 0, + defaultProvider: "anthropic", + providers: { + anthropic: { + adapter: "anthropic", + baseUrl: "https://api.anthropic.com", + authMode: "oauth", + models: ["claude-sonnet-5"], + }, + }, +} as unknown as OcxConfig; + +const streamingMessage = [ + 'event: message_start\ndata: {"type":"message_start","message":{"id":"msg_test","type":"message","role":"assistant","content":[],"model":"claude-sonnet-5","stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":3,"output_tokens":0}}}\n\n', + 'event: content_block_start\ndata: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}\n\n', + 'event: content_block_delta\ndata: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"OK"}}\n\n', + 'event: content_block_stop\ndata: {"type":"content_block_stop","index":0}\n\n', + 'event: message_delta\ndata: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":1}}\n\n', + 'event: message_stop\ndata: {"type":"message_stop"}\n\n', +].join(""); + +describe("Anthropic Codex-facing response model", () => { + const originalHome = process.env.OPENCODEX_HOME; + let originalFetch: typeof fetch; + let home: string; + let upstreamModels: string[]; + + beforeEach(async () => { + home = mkdtempSync(join(tmpdir(), "ocx-anthropic-response-model-")); + process.env.OPENCODEX_HOME = home; + await saveCredential("anthropic", { + access: "anthropic-access-test", + refresh: "anthropic-refresh-test", + expires: Date.now() + 3_600_000, + accountId: `response-model-${Date.now()}`, + }); + originalFetch = globalThis.fetch; + upstreamModels = []; + globalThis.fetch = (async (_input, init) => { + const body = JSON.parse(String(init?.body ?? "{}")) as { model?: string; stream?: boolean }; + upstreamModels.push(body.model ?? ""); + if (body.stream) { + return new Response(streamingMessage, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); + } + return Response.json({ + id: "msg_test", + type: "message", + role: "assistant", + content: [{ type: "text", text: "OK" }], + model: "claude-sonnet-5", + stop_reason: "end_turn", + stop_sequence: null, + usage: { input_tokens: 3, output_tokens: 1 }, + }); + }) as typeof fetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + if (originalHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = originalHome; + rmSync(home, { recursive: true, force: true }); + }); + + test("keeps a provider-qualified selector in streaming Responses output", async () => { + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "anthropic/claude-sonnet-5", input: "reply OK", stream: true }), + }), config, { model: "", provider: "" }); + + const text = await response.text(); + expect(response.status).toBe(200); + expect(upstreamModels).toEqual(["claude-sonnet-5"]); + expect(text).toContain('"model":"anthropic/claude-sonnet-5"'); + expect(text).not.toContain('"model":"claude-sonnet-5"'); + }); + + test("heals a legacy bare selector in non-streaming Responses output", async () => { + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "claude-sonnet-5", input: "reply OK", stream: false }), + }), config, { model: "", provider: "" }); + + const json = await response.json() as { model?: string }; + expect(response.status).toBe(200); + expect(upstreamModels).toEqual(["claude-sonnet-5"]); + expect(json.model).toBe("anthropic/claude-sonnet-5"); + }); +}); diff --git a/tests/codex-catalog-restore.test.ts b/tests/codex-catalog-restore.test.ts index f689d80eb2..799a14755b 100644 --- a/tests/codex-catalog-restore.test.ts +++ b/tests/codex-catalog-restore.test.ts @@ -47,6 +47,7 @@ describe("Codex catalog restore", () => { models: [ { slug: "gpt-5.5" }, { slug: "opencode-go/deepseek-v4-pro" }, + { slug: "claude-sonnet-5", opencodex_catalog_kind: "anthropic-response-model-alias-v1" }, { slug: "user-native" }, ], }, null, 2) + "\n"); @@ -58,7 +59,7 @@ describe("Codex catalog restore", () => { `); expect(r.status).toBe(0); - expect(JSON.parse(r.stdout)).toMatchObject({ removed: 1, kept: 2 }); + expect(JSON.parse(r.stdout)).toMatchObject({ removed: 2, kept: 2 }); const slugs = JSON.parse(readFileSync(catalogPath, "utf8")).models.map((m: { slug: string }) => m.slug); expect(slugs).toEqual(["gpt-5.5", "user-native"]); }, { timeout: 15_000 }); @@ -212,6 +213,7 @@ describe("Codex catalog restore", () => { { slug: "gpt-5.5", priority: 0, supports_websockets: true }, { slug: "codex-mini", priority: 60, supports_websockets: true }, { slug: "umans/umans-kimi-k2.7" }, + { slug: "claude-sonnet-5", opencodex_catalog_kind: "anthropic-response-model-alias-v1" }, { slug: "user-native", priority: 10 }, ], }, null, 2) + "\n"); @@ -223,7 +225,7 @@ describe("Codex catalog restore", () => { `); expect(r.status).toBe(0); - expect(JSON.parse(r.stdout)).toMatchObject({ removed: 1, kept: 3 }); + expect(JSON.parse(r.stdout)).toMatchObject({ removed: 2, kept: 3 }); const restored = JSON.parse(readFileSync(catalogPath, "utf8")).models as Array>; expect(restored).toEqual([ { slug: "gpt-5.5", priority: 50 }, diff --git a/tests/codex-catalog.test.ts b/tests/codex-catalog.test.ts index 1f6896f8a2..0ceab5bc5d 100644 --- a/tests/codex-catalog.test.ts +++ b/tests/codex-catalog.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, spyOn, test } from "bun:test"; import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { augmentRoutedModelsWithJawcodeMetadata, augmentRoutedModelsWithRegistryOpenAiApiRows, buildCatalogEntries, buildComboCatalogOmission, catalogModelSlug, clampCatalogModelsToCodexSupport, clampEntryToCodexSupportedEfforts, clampedDefaultEffort, comboCatalogOmissionReason, deriveComboCatalogModel, exactComboCatalogSlugs, filterCatalogVisibleModels, filterSupportedNativeSlugs, gatherRoutedModels as gatherRoutedModelsDirect, isDatedVariantId, isMediaGenerationModelId, loadBundledCodexCatalog, materializeBundledCodexCatalog, mergeCatalogEntriesForSync, NATIVE_OPENAI_MODELS, normalizeRoutedCatalogEntry, resetCatalogRuntimeStateForTests, resetOpenAiApiCatalogWarningStateForTests, shouldExposeRoutedModel } from "../src/codex/catalog"; +import { ANTHROPIC_RESPONSE_MODEL_ALIAS_CATALOG_KIND, augmentRoutedModelsWithJawcodeMetadata, augmentRoutedModelsWithRegistryOpenAiApiRows, buildCatalogEntries, buildComboCatalogOmission, catalogModelSlug, clampCatalogModelsToCodexSupport, clampEntryToCodexSupportedEfforts, clampedDefaultEffort, comboCatalogOmissionReason, deriveComboCatalogModel, exactComboCatalogSlugs, filterCatalogVisibleModels, filterSupportedNativeSlugs, gatherRoutedModels as gatherRoutedModelsDirect, isDatedVariantId, isMediaGenerationModelId, loadBundledCodexCatalog, materializeBundledCodexCatalog, mergeCatalogEntriesForSync, NATIVE_OPENAI_MODELS, normalizeRoutedCatalogEntry, resetCatalogRuntimeStateForTests, resetOpenAiApiCatalogWarningStateForTests, shouldExposeRoutedModel } from "../src/codex/catalog"; import { withStubbedProviderFetch } from "./helpers/catalog-provider-fetch"; import { CURSOR_STATIC_MODELS, @@ -1311,6 +1311,35 @@ describe("Codex catalog routed normalization", () => { expect(routed?.supports_search_tool).toBe(true); }); + test("Anthropic catalog rows include a hidden metadata-identical bare compatibility alias", () => { + const entries = buildCatalogEntries(null, [], [{ + provider: "anthropic", + id: "claude-sonnet-5", + contextWindow: 1_000_000, + maxInputTokens: 950_000, + reasoningEfforts: ["low", "medium", "high"], + defaultReasoningEffort: "high", + inputModalities: ["text", "image"], + }]); + const canonical = entries.find(entry => entry.slug === "anthropic/claude-sonnet-5"); + const alias = entries.find(entry => entry.slug === "claude-sonnet-5"); + + expect(canonical).toBeDefined(); + expect(alias).toMatchObject({ + slug: "claude-sonnet-5", + visibility: "hide", + opencodex_catalog_kind: ANTHROPIC_RESPONSE_MODEL_ALIAS_CATALOG_KIND, + context_window: canonical?.context_window, + max_context_window: canonical?.max_context_window, + auto_compact_token_limit: canonical?.auto_compact_token_limit, + input_modalities: canonical?.input_modalities, + supported_reasoning_levels: canonical?.supported_reasoning_levels, + }); + + const custom = buildCatalogEntries(null, [], [{ provider: "anthropic-compatible", id: "claude-sonnet-5" }]); + expect(custom.some(entry => entry.slug === "claude-sonnet-5")).toBe(false); + }); + test("liveModels false uses configured provider models without fetching", async () => { clearModelCache("static-provider"); const originalFetch = globalThis.fetch; diff --git a/tests/images/loop.test.ts b/tests/images/loop.test.ts index 8310b49bdf..3cb7f9b5cd 100644 --- a/tests/images/loop.test.ts +++ b/tests/images/loop.test.ts @@ -126,6 +126,20 @@ describe("runWithImageBridge", () => { expect(sse).toContain("hello world"); }); + test("uses the client-facing response model without changing the upstream model", async () => { + const parsed = makeParsed(); + streamQueue = [[{ type: "text_delta", text: "hello" }, { type: "done" }]]; + const response = await runWithImageBridge({ + parsed, + responseModel: "anthropic/claude-sonnet-5", + adapter: mockAdapter, + plan, + }); + const sse = await response.text(); + expect(parsed.modelId).toBe("test-model"); + expect(sse).toContain('"model":"anthropic/claude-sonnet-5"'); + }); + test("single image call → fulfilled, second iteration yields text", async () => { const sse = await runAndGetSSE( [imageCallEvents, [{ type: "text_delta", text: "Here is your image" }, { type: "done" }]], diff --git a/tests/web-search.test.ts b/tests/web-search.test.ts index 60ded4b156..d4a5e02eed 100644 --- a/tests/web-search.test.ts +++ b/tests/web-search.test.ts @@ -106,6 +106,24 @@ describe("issue #1001 — forced-answer passes must produce usable output", () = expect(frames.some(frame => frame.event === "response.failed")).toBe(false); }); + test("uses the client-facing response model without changing the upstream model", async () => { + const parsed = parseRequest({ model: "claude-sonnet-5", input: "hi", stream: true, tools: [{ type: "web_search" }] }); + const response = await runWithWebSearch({ + parsed, + responseModel: "anthropic/claude-sonnet-5", + adapter: twoPassAdapter([{ type: "text_delta", text: "final answer" }, { type: "done" }]), + forwardProvider, + hostedTool: { type: "web_search" }, + selectedForwardHeaders: new Headers({ authorization: "Bearer token" }), + settings: { model: "gpt-5.6-luna", reasoning: "low", timeoutMs: 30_000 }, + maxSearches: 1, + }); + const frames = await collectSse(response.body!); + const completed = frames.find(frame => frame.event === "response.completed")?.data.response as Record; + expect(parsed.modelId).toBe("claude-sonnet-5"); + expect(completed.model).toBe("anthropic/claude-sonnet-5"); + }); + test("a valid closed non-web tool call without text is allowed to complete", async () => { const frames = await drive([ { type: "tool_call_start", id: "call_1", name: "shell" },