diff --git a/src/codex/catalog/effort.ts b/src/codex/catalog/effort.ts index 13fd33bbab..0f36c65aa5 100644 --- a/src/codex/catalog/effort.ts +++ b/src/codex/catalog/effort.ts @@ -31,7 +31,7 @@ import { redactSecretString, redactUserPath } from "../../lib/redact"; import upstreamModelsSnapshot from "../data/upstream-models.json"; -import { readCatalog, readCodexCatalogPath } from "./parsing"; +import { COMBO_CATALOG_KIND, readCatalog, readCodexCatalogPath } from "./parsing"; import type { CatalogModel, RawEntry } from "./parsing"; import { UPSTREAM_NATIVE_ENTRIES } from "./metadata"; import { loadBundledCodexCatalog } from "./bundled"; @@ -111,9 +111,11 @@ export const ROUTED_REASONING_LEVELS = [...CODEX_REASONING_LEVELS]; export function applyCatalogModelMetadata(entry: RawEntry, model?: CatalogModel): void { if (!model) return; - // This marker survives strict catalog normalization and lets sync distinguish a stale - // bare combo alias from a genuine native model row. - if (model.provider === COMBO_NAMESPACE) entry.owned_by = model.owned_by ?? COMBO_NAMESPACE; + if (model.provider === COMBO_NAMESPACE) entry.opencodex_catalog_kind = COMBO_CATALOG_KIND; + // Preserve upstream/provider ownership as semantic catalog metadata. Generated lifecycle + // markers must use opencodex_catalog_kind instead of overloading this field. + if (model.owned_by) entry.owned_by = model.owned_by; + else if (model.provider === COMBO_NAMESPACE) entry.owned_by = COMBO_NAMESPACE; // displayName is DISPLAY-ONLY: it relabels the picker row but never touches the routing // slug, alias, or provider. deriveEntry already stamped the slug as display_name; a // configured displayName overrides just the label. The `/` separator is rejected at every diff --git a/src/codex/catalog/parsing.ts b/src/codex/catalog/parsing.ts index 41251cbbb1..b5f17fd317 100644 --- a/src/codex/catalog/parsing.ts +++ b/src/codex/catalog/parsing.ts @@ -88,6 +88,8 @@ export function isDefaultCatalogPath(path: string): boolean { export interface CatalogModel { id: string; provider: string; + /** Final provider adapter identity used to derive adapter-specific catalog rows. */ + adapter?: OcxProviderConfig["adapter"]; /** Public Codex-facing slug override (used by combo aliases). */ alias?: string; /** @@ -117,6 +119,23 @@ export interface CatalogModel { export type RawEntry = Record; +export const COMBO_CATALOG_KIND = "combo-v1"; +export const ROUTED_CONTEXT_COMPAT_CATALOG_KIND = "routed-context-compat-v1"; + +export function isComboCatalogEntry(entry: RawEntry): boolean { + return entry.opencodex_catalog_kind === COMBO_CATALOG_KIND; +} + +export function isRoutedContextCompatEntry(entry: RawEntry): boolean { + return entry.opencodex_catalog_kind === ROUTED_CONTEXT_COMPAT_CATALOG_KIND; +} + +export function routedContextCompatTarget(entry: RawEntry): string | undefined { + return isRoutedContextCompatEntry(entry) && typeof entry.opencodex_routed_slug === "string" + ? entry.opencodex_routed_slug + : undefined; +} + export type RawCatalog = { models?: RawEntry[]; [k: string]: unknown }; export const JAWCODE_CATALOG_AUGMENT_PROVIDERS = new Set(["opencode-go"]); diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index 545a387c35..2e1aa15826 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -25,17 +25,18 @@ import { type OAuthActiveTokenObservation, } from "../../oauth"; import type { OcxConfig, OcxProviderConfig } from "../../types"; -import { modelInList } from "../../types"; +import { MODEL_ADAPTER_OVERRIDE_ALLOWED, modelInList } from "../../types"; import { CODEX_REASONING_LEVELS, codexEffortRank, configuredReasoningEfforts, modelRecordValue, sanitizeCodexReasoningEfforts } from "../../reasoning-effort"; import { getJawcodeModelMetadata, getJawcodeModelMetadataCaseInsensitive, listJawcodeModelMetadata, resolveJawcodeProvider } from "../../generated/jawcode-model-metadata"; import { enrichProviderFromRegistry, shouldCaseFoldMetadataModelId } from "../../providers/derive"; -import { getProviderRegistryEntry, providerMatchesRegistryTransport } from "../../providers/registry"; +import { getProviderRegistryEntry, providerMatchesRegistryTransport, providerModelWireDefault } 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 { 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"; +import { resolveWireProtocolOverride } from "../../server/adapter-resolve"; import { COMBO_NAMESPACE, comboModelId, @@ -131,9 +132,33 @@ interface CapturedProviderGather { readonly discovery: ResolvedProviderModelDiscovery; readonly policy: CatalogProviderDiscoveryPolicySnapshot; readonly request: CapturedModelsRequest; + readonly registryWireDefaults: Readonly>; readonly observedAuth?: ModelsAuthResolution; } +function captureRegistryWireDefaults( + name: string, + provider: OcxProviderConfig, +): Readonly> { + const declared = getProviderRegistryEntry(name)?.modelWireDefaults ?? {}; + const captured: Record = {}; + for (const modelId of Object.keys(declared)) { + const wire = providerModelWireDefault( + name, + provider, + modelId, + MODEL_ADAPTER_OVERRIDE_ALLOWED, + "responses", + ); + if (wire) captured[modelId.trim().toLowerCase()] = wire; + } + return Object.freeze(captured); +} + +function capturedRegistryWireDefault(captured: CapturedProviderGather, modelId: string): string | null { + return captured.registryWireDefaults[modelId.trim().toLowerCase()] ?? null; +} + interface GatherFlightCapture { readonly discoveryPolicyIdentity: string; readonly authIdentity: string; @@ -378,6 +403,7 @@ function captureProviderGather( maxModels: resolved.maxModels, }); const registryTransportMatch = providerMatchesRegistryTransport(name, provider); + const registryWireDefaults = captureRegistryWireDefaults(name, provider); const trustedOpenAiApi = captureTrustedOpenAiApiPolicy(name, registryTransportMatch); const policy = detachedFrozen({ provider: name, @@ -401,6 +427,7 @@ function captureProviderGather( discovery, policy, request, + registryWireDefaults, ...(observedAuth ? { observedAuth: Object.freeze({ ...observedAuth }) } : {}), }); } @@ -440,6 +467,7 @@ function captureGatherFlight( // It is the one member of a provider row that is legitimately a function, // so it is dropped here rather than allowed to break every encode. provider: omitProviderTransportExecutor(provider.provider), + registryWireDefaults: provider.registryWireDefaults, }))), discoveryPolicySnapshots, providers: Object.freeze(providers), @@ -547,8 +575,13 @@ function configuredReasoningSummarySupport(prov: OcxProviderConfig | undefined, return modelRecordValue(prov.modelReasoningSummaryDelivery, id) !== undefined ? true : undefined; } -export function applyProviderConfigHints(name: string, prov: OcxProviderConfig, model: CatalogModel, providerCap?: number): CatalogModel { - void name; +export function applyProviderConfigHints( + name: string, + prov: OcxProviderConfig, + model: CatalogModel, + providerCap?: number, + capturedWireDefault?: string | null, +): CatalogModel { const configuredCap = configuredContextWindow(prov, model.id); const configuredMaxInput = configuredMaxInputTokens(prov, model.id); let inputModalities = configuredInputModalities(prov, model.id); @@ -565,6 +598,7 @@ export function applyProviderConfigHints(name: string, prov: OcxProviderConfig, const supportsReasoningSummaries = configuredReasoningSummarySupport(prov, model.id); const hinted = { ...model, + adapter: resolveWireProtocolOverride(name, model.id, prov, "responses", capturedWireDefault).adapter, ...(configuredCap !== undefined ? { contextWindow: typeof model.contextWindow === "number" && model.contextWindow > 0 @@ -597,14 +631,32 @@ export function applyProviderConfigHints(name: string, prov: OcxProviderConfig, return providerCap !== undefined ? { ...hinted, contextCap: providerCap, contextCapped: false } : hinted; } -export function catalogHintsFromProviderConfig(name: string, prov: OcxProviderConfig, id: string, contextCap?: number): Partial { - const hinted = applyProviderConfigHints(name, prov, { id, provider: name }, contextCap); +export function catalogHintsFromProviderConfig( + name: string, + prov: OcxProviderConfig, + id: string, + contextCap?: number, + capturedWireDefault?: string | null, +): Partial { + const hinted = applyProviderConfigHints(name, prov, { id, provider: name }, contextCap, capturedWireDefault); const { provider: _provider, id: _id, ...hints } = hinted; return hints; } -export function applyConfigHintsToCachedModels(name: string, prov: OcxProviderConfig, models: CatalogModel[], contextCap?: number): CatalogModel[] { - return models.map(model => applyProviderConfigHints(name, prov, model, contextCap)); +export function applyConfigHintsToCachedModels( + name: string, + prov: OcxProviderConfig, + models: CatalogModel[], + contextCap?: number, + registryWireDefaults?: Readonly>, +): CatalogModel[] { + return models.map(model => applyProviderConfigHints( + name, + prov, + model, + contextCap, + registryWireDefaults ? registryWireDefaults[model.id.trim().toLowerCase()] ?? null : undefined, + )); } export function isDatedVariantId(liveId: string, configuredId: string): boolean { @@ -838,7 +890,7 @@ async function fetchProviderModelsWithAuth( const configured: CatalogModel[] = configuredIds.map(id => ({ id, provider: name, - ...catalogHintsFromProviderConfig(name, prov, id, contextCap), + ...catalogHintsFromProviderConfig(name, prov, id, contextCap, capturedRegistryWireDefault(captured, id)), })); // Static catalogs never need an OAuth refresh or an upstream model request. Clear any // discovery failure left by an older live configuration even when the account is logged out. @@ -859,7 +911,7 @@ async function fetchProviderModelsWithAuth( : [{ id: prov.defaultModel, provider: name, - ...catalogHintsFromProviderConfig(name, prov, prov.defaultModel, contextCap), + ...catalogHintsFromProviderConfig(name, prov, prov.defaultModel, contextCap, capturedRegistryWireDefault(captured, prov.defaultModel)), }]; const vertexDefaultSeed = seedVertexDefault ? configured[0] : undefined; const withVertexDefaultSeed = (models: CatalogModel[]): CatalogModel[] => ( @@ -874,10 +926,10 @@ async function fetchProviderModelsWithAuth( // suffix) but filter the static seed to the bases the account actually has — so models not on the // plan (e.g. claude-fable-5) drop out instead of failing ERROR_BAD_MODEL_NAME. Fall back to the seed. const cachedCursor = getFreshCached(name, ttlMs); - if (cachedCursor) return applyConfigHintsToCachedModels(name, prov, cachedCursor); + if (cachedCursor) return applyConfigHintsToCachedModels(name, prov, cachedCursor, undefined, captured.registryWireDefaults); if (isModelsFetchCoolingDown(name)) { const cooling = getStaleCached(name); - return cooling ? applyConfigHintsToCachedModels(name, prov, cooling) : configured; + return cooling ? applyConfigHintsToCachedModels(name, prov, cooling, undefined, captured.registryWireDefaults) : configured; } const liveResult = await fetchCursorUsableModels({ apiKey, baseUrl: prov.baseUrl }); if (liveResult.ok) { @@ -894,7 +946,7 @@ async function fetchProviderModelsWithAuth( `[opencodex] Cursor model discovery for "${name}" failed [${liveResult.error}]${liveResult.detail ? `: ${liveResult.detail}` : ""}; using stale/static catalog degradation.`, ); const staleCursor = getStaleCached(name); - return staleCursor ? applyConfigHintsToCachedModels(name, prov, staleCursor) : configured; + return staleCursor ? applyConfigHintsToCachedModels(name, prov, staleCursor, undefined, captured.registryWireDefaults) : configured; } if (prov.authMode === "oauth" && !apiKey) { // No usable token (logged out, or account marked needsReauth). Still surface the @@ -903,12 +955,12 @@ async function fetchProviderModelsWithAuth( return configured; } const fresh = getFreshCached(name, ttlMs); - if (fresh) return withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, fresh, contextCap)); // dedups Codex's frequent /v1/models polling within the TTL + if (fresh) return withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, fresh, contextCap, captured.registryWireDefaults)); // dedups Codex's frequent /v1/models polling within the TTL if (isModelsFetchCoolingDown(name)) { // A recently-failed provider (unreachable API, missing proxy, bad key) must not re-pay the // fetch timeout on every catalog poll — the dashboard polls this path per page load. const stale = getStaleCached(name); - return stale ? withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, stale, contextCap)) : failedDiscoveryConfigured; + return stale ? withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, stale, contextCap, captured.registryWireDefaults)) : failedDiscoveryConfigured; } const url = request.url; const headers = materializeCapturedHeaders(request, apiKey); @@ -927,7 +979,7 @@ async function fetchProviderModelsWithAuth( const stale = getStaleCached(name); return { models: stale - ? withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, stale, contextCap)) + ? withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, stale, contextCap, captured.registryWireDefaults)) : failedDiscoveryConfigured, fallback: stale ? "stale" : "configured", shouldLog, @@ -1000,7 +1052,7 @@ async function fetchProviderModelsWithAuth( provider: name, ...(ownedBy ? { owned_by: ownedBy } : {}), ...catalogHintsFromModelsApiItem(name, m), - }, contextCap); + }, contextCap, capturedRegistryWireDefault(captured, m.id)); }) .filter(m => shouldExposeProviderModel(name, m.id)); // Capture the count BEFORE the alias/configured augmentation below pushes extra rows into @@ -1018,7 +1070,13 @@ async function fetchProviderModelsWithAuth( const dated = live.find(l => isDatedVariantId(l.id, m.id)); if (dated) { // Reapply config hints so alias-keyed overrides (modelContextWindows etc.) win. - live.push(applyProviderConfigHints(name, prov, { ...dated, id: m.id }, contextCap)); + live.push(applyProviderConfigHints( + name, + prov, + { ...dated, id: m.id }, + contextCap, + capturedRegistryWireDefault(captured, m.id), + )); } else if (seedVertexDefault || shouldRetainConfiguredProviderModel(name, m.id)) { live.push(m); } else { @@ -1293,10 +1351,14 @@ async function gatherRoutedModelsUncached( const replacedByRoutedSlug = new Map(all.map(model => [routedSlug(model.provider, model.id), model])); const customModels = (config.customModels ?? []).map(cm => { const rawProvider = config.providers[cm.provider]; + const enrichedProvider = enrichedByName.get(cm.provider) ?? rawProvider; const supportsReasoningSummaries = configuredReasoningSummarySupport(rawProvider, cm.modelId); const base: CatalogModel = { id: cm.modelId, provider: cm.provider, + ...(enrichedProvider?.adapter + ? { adapter: resolveWireProtocolOverride(cm.provider, cm.modelId, enrichedProvider).adapter } + : {}), // Display-only label: never feeds routing (customModels are keyed by routedSlug below). ...(cm.displayName ? { displayName: cm.displayName } : {}), ...(cm.contextWindow ? { contextWindow: cm.contextWindow } : {}), @@ -1327,7 +1389,6 @@ async function gatherRoutedModelsUncached( // (#349/#344). Deliberately NOT the full applyProviderConfigHints pass — custom rows are a // user override, so their explicit contextWindow / inputModalities / reasoning fields must be // preserved verbatim (the hint pass would cap context and overwrite modalities from registry). - const enrichedProvider = enrichedByName.get(cm.provider) ?? rawProvider; if (enrichedProvider && modelInList(enrichedProvider.noVisionModels, merged.id)) { const current = merged.inputModalities ?? ["text"]; if (!current.includes("image")) { diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index 8ad5becfad..3e183813b4 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -30,7 +30,7 @@ import { redactSecretString } from "../../lib/redact"; import upstreamModelsSnapshot from "../data/upstream-models.json"; -import { activeCodexModelsCachePath, applyJawcodeCatalogMetadata, applyMultiAgentMode, applyNativeOpenAiContextOverride, catalogBackupPathFor, catalogHasRoutedEntries, catalogModelSlug, ensureStrictCatalogFields, findNativeTemplate, isDefaultCatalogPath, isRoutedModelCompatibilityExcluded, legacyCatalogBackupPath, normalizeRoutedCatalogEntry, normalizeServiceTiers, readCatalog, readCatalogBackup, readCodexCatalogPath, readNativeBaseline } from "./parsing"; +import { activeCodexModelsCachePath, applyJawcodeCatalogMetadata, applyMultiAgentMode, applyNativeOpenAiContextOverride, catalogBackupPathFor, catalogHasRoutedEntries, catalogModelSlug, ensureStrictCatalogFields, findNativeTemplate, isComboCatalogEntry, isDefaultCatalogPath, isRoutedContextCompatEntry, isRoutedModelCompatibilityExcluded, legacyCatalogBackupPath, normalizeRoutedCatalogEntry, normalizeServiceTiers, readCatalog, readCatalogBackup, readCodexCatalogPath, readNativeBaseline, ROUTED_CONTEXT_COMPAT_CATALOG_KIND, routedContextCompatTarget } from "./parsing"; import type { CatalogModel, MultiAgentMode, RawCatalog, RawEntry } from "./parsing"; import { applyNativeVisibility, disabledNativeSlugs, isUnsupportedOpenAiNativeSlug, nativeOpenAiSlugs, NATIVE_OPENAI_MODELS, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi, shouldUpgradeToUpstreamEntry, SUPPORTED_NATIVE_OPENAI_SLUGS, upstreamNativeEntry } from "./metadata"; import { @@ -246,6 +246,10 @@ export function deriveEntry( e.visibility = "list"; if ("upgrade" in e) e.upgrade = null; delete e.availability_nux; // don't replay another model's "now available" NUX + // Generated-row lifecycle markers belong only to their source row. A template selected + // from a converged catalog must never stamp compatibility/account metadata onto new rows. + delete e.opencodex_catalog_kind; + delete e.opencodex_routed_slug; // Routed (namespaced) models inherit the gpt template — correct its OpenAI/GPT identity // and advertise the reasoning ladder Codex accepts. if (isRouted) { @@ -334,6 +338,17 @@ export function buildCatalogEntries( const comboPublicSlugs = new Set(goModels .filter(model => model.provider === COMBO_NAMESPACE) .map(catalogModelSlug)); + const routedContextCompatWinnerByBareId = new Map(); + for (const model of goModels) { + if (model.adapter !== "anthropic" || model.id.includes("/")) continue; + if (comboPublicSlugs.has(model.id)) continue; + const slug = catalogModelSlug(model); + if (slug === model.id) continue; + const current = routedContextCompatWinnerByBareId.get(model.id); + if (!current || slug.localeCompare(catalogModelSlug(current)) < 0) { + routedContextCompatWinnerByBareId.set(model.id, model); + } + } for (const slug of gptSlugs) { const e = deriveEntry(template, slug, "OpenAI native model (Codex OAuth passthrough).", 9); if (rank.has(slug)) e.priority = rank.get(slug)!; @@ -384,6 +399,18 @@ export function buildCatalogEntries( e.priority = 1_000 + (typeof e.priority === "number" ? e.priority : 5); } out.push(e); + // Codex Desktop can persist Anthropic selections as bare ids. Preserve the + // routed context/compaction metadata in a picker-hidden compatibility row. + if (routedContextCompatWinnerByBareId.get(m.id) === m) { + const compat = JSON.parse(JSON.stringify(e)) as RawEntry; + compat.slug = m.id; + compat.display_name = m.id; + compat.description = `Hidden routed metadata alias for ${slug}.`; + compat.visibility = "hide"; + compat.opencodex_catalog_kind = ROUTED_CONTEXT_COMPAT_CATALOG_KIND; + compat.opencodex_routed_slug = slug; + out.push(compat); + } } // Central capability override (phase 120.4): the advertised flag must match the implemented WS // endpoint. Overrides both the routed strip (normalizeRoutedCatalogEntry) and any native template @@ -442,6 +469,13 @@ function isOcxAuthoredRoutedEntry(entry: RawEntry): boolean { return slug.includes("/") && desc.startsWith("Routed via opencodex → "); } +/** Current marker plus the pre-marker description signature needed to retire legacy combo rows. */ +function isComboLifecycleEntry(entry: RawEntry): boolean { + if (isComboCatalogEntry(entry)) return true; + const desc = typeof entry.description === "string" ? entry.description : ""; + return desc.startsWith(`Routed via opencodex → ${COMBO_NAMESPACE} (`); +} + export function mergeCatalogEntriesForSync( catalogModels: RawEntry[], routedEntries: RawEntry[], @@ -467,7 +501,8 @@ export function mergeCatalogEntriesForSync( ? catalogModels .filter(m => typeof m.slug === "string" && !(m.slug as string).includes("/") - && m.owned_by !== COMBO_NAMESPACE + && !isComboLifecycleEntry(m) + && !isRoutedContextCompatEntry(m) && !goIds.has(m.slug as string) && !isUnsupportedOpenAiNativeSlug(m.slug as string)) .map(m => { @@ -555,6 +590,27 @@ export function mergeCatalogEntriesForSync( const provider = (m.slug as string).slice(0, (m.slug as string).indexOf("/")); return !(isOcxAuthoredRoutedEntry(m) && !gatheredProviderNames.has(provider)); }); + const retainedRoutedSlugs = new Set(finalRoutedEntries.flatMap(entry => + typeof entry.slug === "string" ? [entry.slug] : [] + )); + const retainedCompatTargets = new Set(); + const retainedCompatSlugs = new Set(); + const retainedCompatEntries = catalogModels.flatMap(entry => { + const target = routedContextCompatTarget(entry); + return target !== undefined && retainedRoutedSlugs.has(target) && typeof entry.slug === "string" + ? [entry] : []; + }).sort((a, b) => { + const targetOrder = routedContextCompatTarget(a)!.localeCompare(routedContextCompatTarget(b)!); + return targetOrder !== 0 ? targetOrder : (a.slug as string).localeCompare(b.slug as string); + }).filter(entry => { + const target = routedContextCompatTarget(entry)!; + const slug = entry.slug as string; + if (retainedCompatTargets.has(target) || retainedCompatSlugs.has(slug)) return false; + retainedCompatTargets.add(target); + retainedCompatSlugs.add(slug); + return true; + }); + finalRoutedEntries = [...finalRoutedEntries, ...retainedCompatEntries]; } else { const preservedForeignRouted = catalogModels.filter(m => { if (typeof m.slug !== "string" || !m.slug.includes("/")) return false; @@ -570,7 +626,7 @@ export function mergeCatalogEntriesForSync( if (!hasPhysicalComboProvider) { finalRoutedEntries = finalRoutedEntries.filter(entry => { const slug = typeof entry.slug === "string" ? entry.slug : ""; - const comboOwned = slug.startsWith(`${COMBO_NAMESPACE}/`) || entry.owned_by === COMBO_NAMESPACE; + const comboOwned = slug.startsWith(`${COMBO_NAMESPACE}/`) || isComboLifecycleEntry(entry); return !comboOwned || freshSlugs.has(slug); }); } @@ -629,11 +685,15 @@ export function mergeCatalogEntriesForSync( // Native enable/disable runs as the LAST pass so the upstream-upgrade branch above can never // clobber a hide flag back to list. Bare ids disable every account clone; qualified ids disable // only their generated account row. - return applyMultiAgentMode( + const finalized = applyMultiAgentMode( applyNativeVisibility(mergedEntries, disabledModels, alignedAccountBoundEntries.length > 0), multiAgentMode, isMultiAgentV2Enabled(), ); + for (const entry of finalized) { + if (isRoutedContextCompatEntry(entry)) entry.visibility = "hide"; + } + return finalized; } interface RetainedCatalogSyncRead { @@ -1013,11 +1073,17 @@ 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("/")) + || isRoutedContextCompatEntry(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("/") + && !isRoutedContextCompatEntry(m) + && !backupSlugs.has(m.slug) ), replacementVisibility, disabledModels, @@ -1034,7 +1100,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("/")) + && !isRoutedContextCompatEntry(m) + ), replacementVisibility, disabledModels, ); diff --git a/src/codex/convergence.ts b/src/codex/convergence.ts index 5194967eb2..6637341252 100644 --- a/src/codex/convergence.ts +++ b/src/codex/convergence.ts @@ -31,8 +31,10 @@ import { catalogBackupPathFor, catalogHasRoutedEntries, findNativeTemplate, + isRoutedContextCompatEntry, legacyCatalogBackupPath, parseCatalogJson, + routedContextCompatTarget, type RawCatalog, } from "./catalog/parsing"; import { @@ -180,6 +182,7 @@ function prepareCatalog( const nativeSlugs = includeNativeOpenAi ? [...new Set((active?.models ?? catalog.models ?? []).flatMap(entry => ( typeof entry.slug === "string" && !entry.slug.includes("/") && !disabledNative.has(entry.slug) + && !isRoutedContextCompatEntry(entry) ? [entry.slug] : [] )))] : []; @@ -189,13 +192,42 @@ function prepareCatalog( ); if (entries.length === nativeSlugs.length) { const configuredProviders = new Set(enabledProviders.map(([name]) => name)); - const preserved = (active?.models ?? []).filter(entry => { + const activeEntries = active?.models ?? []; + const preserved = activeEntries.filter(entry => { + if (isRoutedContextCompatEntry(entry)) return false; if (typeof entry.slug !== "string" || !entry.slug.includes("/")) return false; const provider = entry.slug.slice(0, entry.slug.indexOf("/")); const description = typeof entry.description === "string" ? entry.description : ""; return configuredProviders.has(provider) || !description.startsWith("Routed via opencodex → "); }); - entries.push(...preserved); + const preservedTargets = new Set(preserved.flatMap(entry => + typeof entry.slug === "string" ? [entry.slug] : [] + )); + const preservedAliasSlugs = new Set(); + const preservedCompatTargets = new Set(); + const preservedCompat = activeEntries.flatMap(entry => { + const target = routedContextCompatTarget(entry); + return target !== undefined && preservedTargets.has(target) && typeof entry.slug === "string" + ? [entry] : []; + }).sort((a, b) => { + const aTarget = routedContextCompatTarget(a)!; + const bTarget = routedContextCompatTarget(b)!; + const targetOrder = aTarget.localeCompare(bTarget); + if (targetOrder !== 0) return targetOrder; + const targetModelId = aTarget.slice(aTarget.indexOf("/") + 1); + const aCanonical = a.slug === targetModelId; + const bCanonical = b.slug === targetModelId; + if (aCanonical !== bCanonical) return aCanonical ? -1 : 1; + return (a.slug as string).localeCompare(b.slug as string); + }).filter(entry => { + const target = routedContextCompatTarget(entry)!; + const slug = entry.slug as string; + if (preservedAliasSlugs.has(slug) || preservedCompatTargets.has(target)) return false; + preservedAliasSlugs.add(slug); + preservedCompatTargets.add(target); + return true; + }); + entries.push(...preserved, ...preservedCompat); } if (!hasPhysicalComboProvider) { const exact = exactComboSlugs; diff --git a/src/images/loop.ts b/src/images/loop.ts index e1eb47f089..5a2efd715f 100644 --- a/src/images/loop.ts +++ b/src/images/loop.ts @@ -900,7 +900,7 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise { + produce(), parsed._responseModelId ?? parsed.modelId, toolNsMap, freeform, toolSearch, () => { internalAbort.abort("client closed responses stream"); }, 2_000, { diff --git a/src/server/adapter-resolve.ts b/src/server/adapter-resolve.ts index 2edf7e3ee9..8ab38ec6d9 100644 --- a/src/server/adapter-resolve.ts +++ b/src/server/adapter-resolve.ts @@ -24,12 +24,15 @@ import { type InboundWire, providerModelWireDefault } from "../providers/registr * because the Chat and Anthropic surfaces translate into a Responses-shaped body and * replay through `handleResponses`; those two callers pass their real inbound so a * scoped registry default cannot fire for a client that never asked for that wire. + * `capturedRegistryDefault` freezes catalog-gather authority across async discovery: + * `null` is an authoritative absence, while `undefined` keeps the ordinary live lookup. */ export function resolveWireProtocolOverride( providerName: string, modelId: string, providerConfig: OcxProviderConfig, inbound: InboundWire = "responses", + capturedRegistryDefault?: string | null, ): OcxProviderConfig { const pinned = pinnedWireAdapter(providerName, modelId); if (pinned && providerConfig.adapter !== pinned) { @@ -42,7 +45,9 @@ export function resolveWireProtocolOverride( // opt-out from a registry default). Invalid hand-edited values fall through to the default. const requested = configured && MODEL_ADAPTER_OVERRIDE_ALLOWED.has(configured) ? configured - : providerModelWireDefault(providerName, providerConfig, modelId, MODEL_ADAPTER_OVERRIDE_ALLOWED, inbound); + : capturedRegistryDefault === undefined + ? providerModelWireDefault(providerName, providerConfig, modelId, MODEL_ADAPTER_OVERRIDE_ALLOWED, inbound) + : capturedRegistryDefault ?? undefined; if (requested && MODEL_ADAPTER_OVERRIDE_ALLOWED.has(requested) && requested !== providerConfig.adapter diff --git a/src/server/index.ts b/src/server/index.ts index b25f4ce67d..2944aac194 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -339,6 +339,7 @@ function attachLiveSidebandUpstream( // Source invariant for tests/passthrough-abort.test.ts after the pure module split: // if (isEventStream && upstreamResponse.body) { // const repairConfig = route.provider.responsesItemIdRepair; +// const responseModelRewrite = parsed._responseModelId !== route.modelId // const needsClientRewrite = imageGenCallAliases.size > 0 // #314 gated shape: win32 always uses the terminal-aware eager relay so a keep-alive // upstream cannot hold Codex open after response.completed; darwin no-rewrite traffic diff --git a/src/server/request-log.ts b/src/server/request-log.ts index 4e317967b6..7f9137fa93 100644 --- a/src/server/request-log.ts +++ b/src/server/request-log.ts @@ -68,6 +68,8 @@ export interface RequestLogContext { modelSupportsServiceTier?: boolean; responseServiceTier?: string; resolvedModel?: string; + /** Internal: a bridge emitted a client-facing selector; keep the physical routed model in logs. */ + preserveResolvedModelFromRoute?: boolean; usage?: OcxUsage; usageLogInputTokens?: number; attempts?: PersistedUsageAttempt[]; @@ -512,7 +514,11 @@ export function applyResponseLogMetadata(logCtx: RequestLogContext, payload: unk : payload; if (!source || typeof source !== "object") return; const model = (source as { model?: unknown }).model; - if (typeof model === "string" && model.trim()) logCtx.resolvedModel = model; + if ( + !logCtx.preserveResolvedModelFromRoute + && typeof model === "string" + && model.trim() + ) logCtx.resolvedModel = model; const serviceTier = (source as { service_tier?: unknown }).service_tier; if (typeof serviceTier === "string" && serviceTier.trim()) logCtx.responseServiceTier = serviceTier; const usage = usageFromResponsesPayload((source as { usage?: unknown }).usage); diff --git a/src/server/responses-model-rewrite.ts b/src/server/responses-model-rewrite.ts new file mode 100644 index 0000000000..b0ed8a3e6c --- /dev/null +++ b/src/server/responses-model-rewrite.ts @@ -0,0 +1,29 @@ +import type { SsePayloadRewrite } from "./sse-payload-rewrite"; + +function rewriteResponseObjectModel(value: unknown, responseModelId: string): boolean { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const response = value as Record; + if (typeof response.model !== "string" || response.model === responseModelId) return false; + response.model = responseModelId; + return true; +} + +/** Rewrite only existing Responses model metadata; unrelated and malformed payloads stay byte-identical. */ +export function rewriteResponsesModelJson(json: string, responseModelId: string): string { + let parsed: unknown; + try { + parsed = JSON.parse(json); + } catch { + return json; + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return json; + const record = parsed as Record; + const rootChanged = rewriteResponseObjectModel(record, responseModelId); + const nestedChanged = rewriteResponseObjectModel(record.response, responseModelId); + const changed = rootChanged || nestedChanged; + return changed ? JSON.stringify(record) : json; +} + +export function createResponsesModelPayloadRewrite(responseModelId: string): SsePayloadRewrite { + return payload => rewriteResponsesModelJson(payload, responseModelId); +} diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 2d00ab0c43..5581a72aa7 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -16,7 +16,7 @@ import { previousResponseReplayFailure, rememberResponseState, } from "../../responses/state"; -import { comboRouteDecisionTrace, NoEligiblePolicyCandidateError, routeModel, type RouteResult } from "../../router"; +import { comboRouteDecisionTrace, knownModelIdsForProvider, NoEligiblePolicyCandidateError, routeModel, type RouteResult } from "../../router"; import { evidenceFromBody } from "../../routing/request-evidence"; import { advanceComboAfterFailure, @@ -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 { decodeRoutedModelId, encodeRoutedModelId, 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"; @@ -172,6 +172,7 @@ import { restoreImageGenCallsInJson, } from "../responses-image-gen-repair"; import { composeSsePayloadRewrites, relaySseWithPayloadRewrite } from "../sse-payload-rewrite"; +import { createResponsesModelPayloadRewrite, rewriteResponsesModelJson } from "../responses-model-rewrite"; import type { EffectiveSubagentRoster, SpawnAgentSurface } from "../../codex/catalog"; import { buildToolBridgeMaps, collabSurface, injectDeveloperMessage, multiAgentGuidanceText } from "./collaboration"; @@ -861,8 +862,25 @@ async function applyFinalRouteRequestNormalization(args: { logCtx: RequestLogContext; inboundWire: InboundWire; inboundTransport?: "websocket"; + comboAttempt?: boolean; }): Promise { - const { parsed, route, config, req, logCtx, inboundWire, inboundTransport } = args; + const { parsed, route, config, req, logCtx, inboundWire, inboundTransport, comboAttempt } = args; + + // Preserve the final Codex-facing selector before upstream route normalization. + // Bare routed response ids make Codex miss catalog context/compaction metadata. + const canonicalRoutedModelId = routedSlug(route.providerName, route.modelId); + const encodedModelIdIsReversible = !route.modelId.includes("/") + || decodeRoutedModelId( + encodeRoutedModelId(route.modelId), + knownModelIdsForProvider(route.providerName, route.provider), + ) === route.modelId; + parsed._responseModelId = comboAttempt + ? route.modelId + : route.providerName === "openai" && isCanonicalOpenAiForwardProvider(route.provider) + ? (route.codexAccountNamespace ? parsed.modelId : route.modelId) + : encodedModelIdIsReversible + ? canonicalRoutedModelId + : `${route.providerName}/${route.modelId}`; // Apply the routed model id upstream: routing may strip a "/" namespace. if (route.modelId !== parsed.modelId) { @@ -899,6 +917,13 @@ async function applyFinalRouteRequestNormalization(args: { // Virtual model rewriting: Pro aliases → base model + reasoning.mode="pro". applyOpenAiVirtualModel(parsed, route, logCtx); + // Combo child requests are internal concrete-target dispatches. Preserve their historical + // physical response identity; the parent request log retains the logical combo selector. + if (comboAttempt) parsed._responseModelId = route.modelId; + if (parsed._responseModelId !== route.modelId) { + logCtx.resolvedModel = route.modelId; + logCtx.preserveResolvedModelFromRoute = true; + } // Fast mode override for OpenAI-routed models, only where the provider's Responses // route documents `service_tier` support (capability gate below strips everywhere else). @@ -1537,6 +1562,7 @@ async function handleResponsesInner( logCtx, inboundWire, inboundTransport: options.inboundTransport, + comboAttempt: options.comboAttempt, }); // Attribute local auth/cooldown failures to the public selector too; exact auth may fail before // the normal post-resolution provider label is assigned. @@ -2052,13 +2078,20 @@ async function handleResponsesInner( if (isEventStream && upstreamResponse.body) { const repairConfig = route.provider.responsesItemIdRepair; const snapshotRepairEnabled = hasResponsesSnapshotRepair(route.provider.responsesSnapshotRepair); - const needsClientRewrite = imageGenCallAliases.size > 0 || hasResponsesItemIdRepair(repairConfig) || snapshotRepairEnabled; + const responseModelRewrite = parsed._responseModelId !== route.modelId + ? createResponsesModelPayloadRewrite(parsed._responseModelId!) + : undefined; + const needsClientRewrite = imageGenCallAliases.size > 0 + || hasResponsesItemIdRepair(repairConfig) + || snapshotRepairEnabled + || responseModelRewrite !== undefined; // Compose opt-in payload rewrites into one parse/stringify pass (image-gen restore first). const payloadRewrites = [ createImageGenCallRestoreRewrite(imageGenCallAliases), hasResponsesItemIdRepair(repairConfig) ? createResponsesItemIdPayloadRewrite(repairConfig!, translatorBudget) : undefined, + responseModelRewrite, ].filter((rewrite): rewrite is NonNullable => rewrite !== undefined); // #893: sparse-snapshot gateways get field backfills AND lifecycle event // injection at the block level, after payload rewrites. Defaults come @@ -2250,14 +2283,19 @@ async function handleResponsesInner( } const clientJson = (() => { const restored = restoreImageGenCallsInJson(text, imageGenCallAliases); - if (!hasResponsesSnapshotRepair(route.provider.responsesSnapshotRepair)) return restored; - let outbound: unknown; - try { - outbound = JSON.parse(request.body); - } catch { - outbound = undefined; - } - return repairResponsesSnapshotJson(restored, outbound); + const repaired = (() => { + if (!hasResponsesSnapshotRepair(route.provider.responsesSnapshotRepair)) return restored; + let outbound: unknown; + try { + outbound = JSON.parse(request.body); + } catch { + outbound = undefined; + } + return repairResponsesSnapshotJson(restored, outbound); + })(); + return parsed._responseModelId !== route.modelId + ? rewriteResponsesModelJson(repaired, parsed._responseModelId!) + : repaired; })(); // #875: the transport-neutral reliability policy forced a bounded JSON // upstream for a client that asked for SSE. Reframe the completed JSON @@ -2560,7 +2598,7 @@ async function handleResponsesInner( eventSource = preflight.stream; } const sseStream = bridgeToResponsesSSE( - eventSource, parsed.modelId, toolNsMap, freeformToolNames, toolSearchToolNames, + eventSource, parsed._responseModelId ?? parsed.modelId, toolNsMap, freeformToolNames, toolSearchToolNames, () => { runTurnAbort.abort(); queue.close(); @@ -2612,7 +2650,7 @@ async function handleResponsesInner( } } let providerState: OcxProviderContinuationState | undefined; - const json = buildResponseJSON(events, parsed.modelId, { + const json = buildResponseJSON(events, parsed._responseModelId ?? parsed.modelId, { translatorBudget, replayCacheScope: parsed._clientThreadId ?? "global", hideThinkingSummary: parsed.options.hideThinkingSummary, @@ -3254,7 +3292,7 @@ async function handleResponsesInner( : initialEventStream; const { toolNsMap, freeformToolNames, toolSearchToolNames } = toolBridgeMaps; const sseStream = bridgeToResponsesSSE( - eventStream, parsed.modelId, toolNsMap, freeformToolNames, toolSearchToolNames, + eventStream, parsed._responseModelId ?? parsed.modelId, toolNsMap, freeformToolNames, toolSearchToolNames, () => upstream.abort(), 2_000, { translatorBudget, @@ -3314,7 +3352,7 @@ async function handleResponsesInner( } const { toolNsMap, freeformToolNames, toolSearchToolNames } = toolBridgeMaps; let providerState: OcxProviderContinuationState | undefined; - const json = buildResponseJSON(events, parsed.modelId, { + const json = buildResponseJSON(events, parsed._responseModelId ?? parsed.modelId, { translatorBudget, replayCacheScope: parsed._clientThreadId ?? "global", hideThinkingSummary: parsed.options.hideThinkingSummary, diff --git a/src/types.ts b/src/types.ts index 8d877b66b0..ba978ec91f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2,6 +2,8 @@ import type { KiroOAuthMetadata } from "./oauth/types"; export interface OcxParsedRequest { modelId: string; + /** Final Codex-facing routed selector retained after upstream model normalization. */ + _responseModelId?: string; /** Selected OpenAI API virtual-model id retained after it rewrites the upstream wire model. */ _openAiVirtualSelectedModelId?: string; previousResponseId?: string; diff --git a/src/web-search/loop.ts b/src/web-search/loop.ts index ce4e4eb45a..5349b78638 100644 --- a/src/web-search/loop.ts +++ b/src/web-search/loop.ts @@ -770,7 +770,7 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise { + produce(), parsed._responseModelId ?? 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..7f446ba37f 100644 --- a/structure/03_catalog-and-subagents.md +++ b/structure/03_catalog-and-subagents.md @@ -27,12 +27,34 @@ the rows being merged. This split is required because empty or partial provider preserve routed entries and genuine user-native rows from the file that will be overwritten; a bundled catalog never contains those rows. +Anthropic-wire providers also emit one picker-hidden bare compatibility row for each bare model id +so a selector persisted by Codex Desktop still resolves the routed row's context and compaction +metadata. This row is derived from the effective per-model adapter identity (including configured +model overrides), not a provider-name convention. When providers share a bare model id, the +lexicographically smallest canonical routed slug owns the single compatibility row. +If a combo reserves that bare public slug, the combo row wins and no hidden compatibility duplicate +is emitted. +It preserves the canonical row's `owned_by` value and is identified only by the nonsemantic +`opencodex_catalog_kind = "routed-context-compat-v1"` marker plus its +`opencodex_routed_slug` target. Empty-discovery protection in both sync and convergence retains the +alias only while that target routed row survives; catalog convergence never promotes it into the +native slug set; restore removes it with the routed rows. Repeated sync/convergence must therefore +leave exactly one hidden alias and no visible native duplicate. + +Generated combo rows use the independent `opencodex_catalog_kind = "combo-v1"` lifecycle marker. +`owned_by` remains provider-supplied semantic metadata and must never decide combo cleanup; sync +removes stale combo rows by the marker, the canonical `combo/` namespace, or the legacy generated +description signature, so a normal provider is free to report `owned_by: "combo"` without losing +its catalog row. + Codex App model picker visibility comes from this shared catalog, not from patching the App. Provider live-model lists are cached with a configured TTL (`src/codex/model-cache.ts`). Adding, deleting, or editing a provider's shape clears that per-provider cache; a disabled-only change deliberately does not, because a disabled provider is already excluded from the catalog gather -instead. Codex's own `models_cache.json` is a different cache, invalidated by catalog refresh. +instead. A gather flight captures registry-only per-model wire defaults before any async discovery, +includes them in its authority identity, and never rereads mutable registry state when mapping the +response. Codex's own `models_cache.json` is a different cache, invalidated by catalog refresh. ## Startup readiness diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index ed401de198..2ae48b4917 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -22,6 +22,19 @@ executor contract. Main-request migration must not treat that branch as fixed-tr provider, lets the selected adapter speak the upstream protocol, then bridges adapter events back to Responses-compatible streaming output. +Model identity has two deliberately separate forms. Upstream requests and request logs use the +final physical route model id. Client-facing Responses metadata uses the canonical public selector: +native canonical OpenAI traffic normalizes an unscoped `openai/` selector to the selected +bare id (account-qualified selectors remain qualified), while every routed provider uses the +one-slash `routedSlug(provider, model)` codec when that alias uniquely decodes to the selected +native id. A slash-containing id with an unknown or colliding encoding keeps its raw qualified +selector to prevent a later turn from switching to a different native-exact model. The client +identity is applied consistently to bridged JSON/SSE, direct `openai-responses` passthrough +JSON/SSE, image-loop output, web-search-loop output, and virtual-model responses. Internal combo +child dispatches retain their historical physical response model while +the parent log retains the logical combo selector. Client rewrites never feed request-log +inspection; log finalization is locked to the physical route identity when the two forms differ. + The option-aware `openai` provider uses `openai-responses` with `authMode: "forward"`. Pool mode resolves main plus added accounts through affinity/quota/cooldown ownership; Direct forwards only the allowed Codex/OpenAI auth/session headers from the current request and short-circuits pool @@ -48,8 +61,8 @@ Native passthrough SSE has TWO shapes, selected per request in selected by `selectEagerPath` in `src/lib/bun-stream-caps.ts`; the latter keeps `legacy-tee` and known-bad-runtime `auto` on tee as documented. When selected, `response.completed` closes the client stream even if upstream keeps HTTP/SSE - alive. Darwin uses it for no-client-rewrite traffic only (neither image-gen - aliases nor item-id repair) and is explicit-only: `auto` stays tee even after + alive. Darwin uses it for no-client-rewrite traffic only (no image-gen aliases, item-id repair, + snapshot repair, or public-model identity rewrite) and is explicit-only: `auto` stays tee even after a future threshold bump. One eager reader + byte-bounded client queue + post-cancel bounded discard-drain replaces the tee and goes directly to the response without a JS rewrite wrapper, preserving the full diff --git a/tests/codex-catalog-restore.test.ts b/tests/codex-catalog-restore.test.ts index f689d80eb2..3d37f54063 100644 --- a/tests/codex-catalog-restore.test.ts +++ b/tests/codex-catalog-restore.test.ts @@ -47,6 +47,12 @@ describe("Codex catalog restore", () => { models: [ { slug: "gpt-5.5" }, { slug: "opencode-go/deepseek-v4-pro" }, + { + slug: "claude-sonnet-5", + owned_by: "anthropic", + opencodex_catalog_kind: "routed-context-compat-v1", + opencodex_routed_slug: "anthropic/claude-sonnet-5", + }, { slug: "user-native" }, ], }, null, 2) + "\n"); @@ -58,7 +64,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 }); diff --git a/tests/codex-catalog.test.ts b/tests/codex-catalog.test.ts index 1f6896f8a2..32dbfaeae2 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 { applyProviderConfigHints, 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, @@ -271,6 +271,7 @@ describe("combo catalog capability intersection", () => { .toEqual(["low", "medium"]); expect(row?.input_modalities).toEqual(["text"]); expect(row?.owned_by).toBe("combo"); + expect(row?.opencodex_catalog_kind).toBe("combo-v1"); }); test("treats bare and slashed combo aliases as routed catalog rows", () => { @@ -307,6 +308,34 @@ describe("combo catalog capability intersection", () => { expect(row.input_modalities).toEqual(["text"]); }); + test("does not emit a hidden Anthropic compatibility row when a combo reserves the bare slug", () => { + const alias = "claude-sonnet-5"; + const combo = deriveComboCatalogModel( + "mixed", + normalizedCombo({ alias }), + [memberA, memberB], + )!; + const anthropic = { + provider: "anthropic-compatible", + adapter: "anthropic" as const, + id: alias, + owned_by: "anthropic", + }; + const rows = buildCatalogEntries( + nativeTemplate(), + [], + [anthropic, combo], + undefined, + false, + "default", + new Set([alias]), + ); + + expect(rows.filter(row => row.slug === alias)).toHaveLength(1); + expect(rows.find(row => row.slug === alias)?.opencodex_catalog_kind).toBe("combo-v1"); + expect(rows.some(row => row.opencodex_catalog_kind === "routed-context-compat-v1")).toBe(false); + }); + test("restores a non-OpenAI catalog row after its shadowing combo alias is renamed or deleted", () => { const alias = "deepseek/deepseek-chat"; const provider = { provider: "deepseek", id: "deepseek-chat", owned_by: "deepseek" }; @@ -359,6 +388,7 @@ describe("combo catalog capability intersection", () => { const stale = { slug, owned_by: "combo", + opencodex_catalog_kind: "combo-v1", input_modalities: ["text"], supported_reasoning_levels: [{ effort: "low" }], }; @@ -368,6 +398,33 @@ describe("combo catalog capability intersection", () => { ); expect(merged.some(entry => entry.slug === slug)).toBe(false); } + + const legacy = { + slug: "legacy-combo-alias", + owned_by: "combo", + description: "Routed via opencodex → combo (combo).", + }; + const merged = mergeCatalogEntriesForSync( + [legacy], [], new Map(), [], false, new Set(), null, new Set(), new Set(), + "default", new Set(), false, + ); + expect(merged.some(entry => entry.slug === legacy.slug)).toBe(false); + }); + + test("keeps semantic owned_by metadata out of combo lifecycle cleanup", () => { + const semanticOwnerRows = [ + { slug: "third-party-native", owned_by: "combo" }, + { slug: "vendor/model", owned_by: "combo", description: "Vendor catalog model." }, + ]; + const merged = mergeCatalogEntriesForSync( + semanticOwnerRows, [], new Map(), [], false, new Set(), null, new Set(), new Set(), + "default", new Set(), false, + ); + + expect(merged.map(entry => entry.slug)).toEqual(expect.arrayContaining([ + "third-party-native", + "vendor/model", + ])); }); test("filters aliased combos by public or canonical disabled model ids", () => { @@ -747,6 +804,136 @@ describe("configured CatalogModel displayName -> catalog display_name", () => { expect(row?.slug).toBe("anthropic/claude-sonnet-4-6"); }); + test("adds a hidden bare Anthropic alias with routed context metadata", () => { + const entries = buildCatalogEntries(nativeTemplate(), [], [ + { + provider: "anthropic", + adapter: "anthropic", + id: "claude-sonnet-5", + owned_by: "anthropic", + contextWindow: 1_000_000, + maxInputTokens: 900_000, + }, + ]); + const routed = entries.find(e => e.slug === "anthropic/claude-sonnet-5"); + const compat = entries.find(e => e.slug === "claude-sonnet-5"); + + expect(routed).toBeDefined(); + expect(compat).toMatchObject({ + slug: "claude-sonnet-5", + display_name: "claude-sonnet-5", + visibility: "hide", + owned_by: "anthropic", + opencodex_catalog_kind: "routed-context-compat-v1", + opencodex_routed_slug: "anthropic/claude-sonnet-5", + context_window: 1_000_000, + max_context_window: 1_000_000, + auto_compact_token_limit: 900_000, + }); + }); + + test("derives the hidden alias from adapter identity for a custom provider name", () => { + const entries = buildCatalogEntries(nativeTemplate(), [], [{ + provider: "anthropic-compatible-default", + adapter: "anthropic", + id: "claude-sonnet-5", + owned_by: "custom-owner", + }]); + + expect(entries.find(entry => entry.slug === "claude-sonnet-5")).toMatchObject({ + visibility: "hide", + owned_by: "custom-owner", + opencodex_catalog_kind: "routed-context-compat-v1", + opencodex_routed_slug: "anthropic-compatible-default/claude-sonnet-5", + }); + }); + + test("uses the effective per-model adapter for discovered and custom catalog rows", async () => { + const provider = { + adapter: "anthropic" as const, + baseUrl: "https://mixed.example.test/v1", + authMode: "key" as const, + apiKey: "test-key", + liveModels: false, + models: [], + modelAdapters: { "claude-sonnet-5": "openai-chat" as const }, + }; + expect(applyProviderConfigHints("mixed-wire", provider, { + provider: "mixed-wire", + id: "claude-sonnet-5", + }).adapter).toBe("openai-chat"); + + const models = await gatherRoutedModels({ + port: 10100, + defaultProvider: "mixed-wire", + providers: { "mixed-wire": provider }, + customModels: [{ provider: "mixed-wire", modelId: "claude-sonnet-5" }], + }); + expect(models.find(model => model.id === "claude-sonnet-5")?.adapter).toBe("openai-chat"); + expect(buildCatalogEntries(nativeTemplate(), [], models) + .find(entry => entry.slug === "claude-sonnet-5")) + .toBeUndefined(); + }); + + test("emits one deterministic bare compatibility alias when providers share a model id", () => { + const entries = buildCatalogEntries(nativeTemplate(), [], [ + { provider: "z-anthropic", adapter: "anthropic", id: "claude-sonnet-5", owned_by: "z-owner" }, + { provider: "a-anthropic", adapter: "anthropic", id: "claude-sonnet-5", owned_by: "a-owner" }, + ]); + const aliases = entries.filter(entry => entry.slug === "claude-sonnet-5"); + + expect(aliases).toHaveLength(1); + expect(aliases[0]).toMatchObject({ + visibility: "hide", + owned_by: "a-owner", + opencodex_catalog_kind: "routed-context-compat-v1", + opencodex_routed_slug: "a-anthropic/claude-sonnet-5", + }); + expect(entries.filter(entry => typeof entry.slug === "string" && entry.slug.endsWith("/claude-sonnet-5"))) + .toHaveLength(2); + }); + + test("transient empty discovery preserves only aliases whose routed row survives", () => { + const existing = buildCatalogEntries(nativeTemplate(), [], [ + { provider: "z-anthropic", adapter: "anthropic", id: "claude-sonnet-5", owned_by: "z-owner" }, + { provider: "a-anthropic", adapter: "anthropic", id: "claude-sonnet-5", owned_by: "a-owner" }, + ]); + const duplicate = { + ...existing.find(entry => entry.slug === "claude-sonnet-5")!, + owned_by: "z-owner", + opencodex_routed_slug: "z-anthropic/claude-sonnet-5", + }; + const orphan = { + ...existing.find(entry => entry.slug === "claude-sonnet-5")!, + slug: "claude-orphan-5", + opencodex_routed_slug: "a-anthropic/claude-orphan-5", + }; + const merged = mergeCatalogEntriesForSync( + [...existing, duplicate, orphan], + [], + new Map(), + [], + false, + new Set(), + null, + new Set(), + new Set(["a-anthropic", "z-anthropic"]), + "default", + new Set(), + false, + false, + ); + + const aliases = merged.filter(entry => entry.slug === "claude-sonnet-5"); + expect(aliases).toHaveLength(1); + expect(aliases[0]).toMatchObject({ + visibility: "hide", + owned_by: "a-owner", + opencodex_routed_slug: "a-anthropic/claude-sonnet-5", + }); + expect(merged.some(entry => entry.slug === "claude-orphan-5")).toBe(false); + }); + test("Command Code routed models relabel the picker row with distinguishable slugs", () => { const entries = buildCatalogEntries(nativeTemplate(), [], [ { provider: "command-code", id: "deepseek/deepseek-v4-flash", owned_by: "command-code" }, @@ -1399,6 +1586,13 @@ describe("Codex catalog routed normalization", () => { expect(models.map(model => `${model.provider}/${model.id}`)).toEqual([ "anthropic-compatible-default/claude-sonnet-5", ]); + expect(models[0]?.adapter).toBe("anthropic"); + expect(buildCatalogEntries(nativeTemplate(), [], models) + .find(entry => entry.slug === "claude-sonnet-5")) + .toMatchObject({ + visibility: "hide", + opencodex_routed_slug: "anthropic-compatible-default/claude-sonnet-5", + }); } finally { globalThis.fetch = originalFetch; clearModelCache("anthropic-compatible-default"); diff --git a/tests/codex-convergence-contract.test.ts b/tests/codex-convergence-contract.test.ts index 74af96030a..013a9b2be7 100644 --- a/tests/codex-convergence-contract.test.ts +++ b/tests/codex-convergence-contract.test.ts @@ -144,6 +144,89 @@ test("commit is fixed-order, receipt-exact, and a consumed candidate cannot be r expect(manifest(root)).toEqual(after); }); +test("repeated convergence keeps one hidden routed-context alias and no native duplicate", async () => { + const routed: OcxConfig = { + port: 10100, + defaultProvider: "anthropic-compatible-default", + providers: { + "anthropic-compatible-default": { + adapter: "anthropic", + baseUrl: "https://anthropic.example.test/v1", + authMode: "key", + apiKey: "test-key", + liveModels: false, + models: ["claude-sonnet-5"], + }, + }, + }; + saveConfig(routed); + writeFileSync(join(codexHome, "opencodex-catalog.json"), `${JSON.stringify({ + models: [ + JSON.parse(sourceCatalog()).models[0], + { + slug: "anthropic-compatible-default/claude-sonnet-5", + description: "Routed via opencodex → anthropic-compatible-default (anthropic).", + }, + { + slug: "claude-sonnet-5", + visibility: "hide", + owned_by: "anthropic", + opencodex_catalog_kind: "routed-context-compat-v1", + opencodex_routed_slug: "anthropic-compatible-default/claude-sonnet-5", + }, + ], + }, null, 2)}\n`); + + for (let pass = 0; pass < 2; pass++) { + const gathered = await gatherCodexCatalogCandidate(captureCatalogAdmissionSnapshot(routed)); + expect(gathered.kind).toBe("candidate"); + const committed = await commitCodexCatalogCandidate( + (gathered as Extract).candidate, + 1_000, + ); + expect(committed.kind).toBe("committed"); + } + + const catalogPath = join(codexHome, "opencodex-catalog.json"); + const beforeEmptyGather = JSON.parse(readFileSync(catalogPath, "utf8")) as { + models: Array>; + }; + const compatibility = beforeEmptyGather.models.find(entry => entry.slug === "claude-sonnet-5")!; + beforeEmptyGather.models.push({ ...compatibility, slug: "claude-legacy-duplicate" }); + writeFileSync(catalogPath, `${JSON.stringify(beforeEmptyGather, null, 2)}\n`); + + const emptyGather = structuredClone(routed); + emptyGather.providers["anthropic-compatible-default"]!.models = []; + saveConfig(emptyGather); + const gathered = await gatherCodexCatalogCandidate(captureCatalogAdmissionSnapshot(emptyGather)); + expect(gathered.kind).toBe("candidate"); + const committed = await commitCodexCatalogCandidate( + (gathered as Extract).candidate, + 1_000, + ); + expect(committed.kind).toBe("committed"); + + const finalModels = (JSON.parse(readFileSync(catalogPath, "utf8")) as { + models: Array>; + }).models; + const rows = finalModels.filter(entry => entry.slug === "claude-sonnet-5"); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + visibility: "hide", + opencodex_catalog_kind: "routed-context-compat-v1", + opencodex_routed_slug: "anthropic-compatible-default/claude-sonnet-5", + }); + expect(finalModels + .filter(entry => entry.opencodex_catalog_kind === "routed-context-compat-v1") + .map(entry => ({ slug: entry.slug, target: entry.opencodex_routed_slug }))) + .toEqual([{ + slug: "claude-sonnet-5", + target: "anthropic-compatible-default/claude-sonnet-5", + }]); + expect(finalModels.find(entry => entry.slug === "anthropic-compatible-default/claude-sonnet-5")) + .not.toHaveProperty("opencodex_catalog_kind"); +}); + test("generation drift rejects before every catalog target write", async () => { const gathered = await candidate(); const before = manifest(codexHome); diff --git a/tests/images/loop.test.ts b/tests/images/loop.test.ts index 8310b49bdf..f434a2a164 100644 --- a/tests/images/loop.test.ts +++ b/tests/images/loop.test.ts @@ -126,6 +126,35 @@ describe("runWithImageBridge", () => { expect(sse).toContain("hello world"); }); + test("image-loop SSE snapshots preserve the canonical client selector", async () => { + const parsed = makeParsed(); + parsed.modelId = "claude-sonnet-5"; + parsed._responseModelId = "fixture-anthropic/claude-sonnet-5"; + let upstreamModel = ""; + streamQueue = [[{ type: "text_delta", text: "hello" }, { type: "done" }]]; + const response = await runWithImageBridge({ + parsed, + adapter: { + ...mockAdapter, + buildRequest: async request => { + upstreamModel = request.modelId; + return { url: "https://test/v1/chat", method: "POST", headers: {}, body: "{}" }; + }, + }, + plan, + }); + const models = (await response.text()).split("\n\n").flatMap(block => { + const data = block.split("\n").find(line => line.startsWith("data: "))?.slice(6); + if (!data || data === "[DONE]") return []; + const payload = JSON.parse(data) as { response?: { model?: unknown } }; + return typeof payload.response?.model === "string" ? [payload.response.model] : []; + }); + + expect(upstreamModel).toBe("claude-sonnet-5"); + expect(models.length).toBeGreaterThan(0); + expect(new Set(models)).toEqual(new Set(["fixture-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/openai-api-virtual-models.test.ts b/tests/openai-api-virtual-models.test.ts index b148463871..971a2d5a30 100644 --- a/tests/openai-api-virtual-models.test.ts +++ b/tests/openai-api-virtual-models.test.ts @@ -367,7 +367,7 @@ describe("OpenAI API compact transport", () => { }); describe("OpenAI API Pro transport identities", () => { - test("HTTP JSON, HTTP SSE, and real WebSocket keep base wire/client identity and virtual logs", async () => { + test("HTTP JSON, HTTP SSE, and real WebSocket keep public client identity, base wire identity, and virtual logs", async () => { const originalFetch = globalThis.fetch; const home = mkdtempSync(join(tmpdir(), "ocx-openai-api-pro-")); process.env.OPENCODEX_HOME = home; @@ -466,7 +466,7 @@ describe("OpenAI API Pro transport identities", () => { const beforeJsonUsage = readUsage().length; const json = await request(selected, false); expect(json.status).toBe(200); - expect(await json.json()).toMatchObject({ model: base }); + expect(await json.json()).toMatchObject({ model: selected }); await expectOnePersisted(beforeJsonLogs, beforeJsonUsage, selected, virtual, base); const beforeSseLogs = (await readLogs()).length; @@ -474,15 +474,15 @@ describe("OpenAI API Pro transport identities", () => { const sse = await request(selected, true); expect(sse.status).toBe(200); const sseText = await sse.text(); - expect(sseText).toContain(`\"model\":\"${base}\"`); - expect(sseText).not.toContain(`\"model\":\"${virtual}\"`); + expect(sseText).toContain(`\"model\":\"${selected}\"`); + expect(sseText).not.toContain(`\"model\":\"${base}\"`); await expectOnePersisted(beforeSseLogs, beforeSseUsage, selected, virtual, base); const beforeWsLogs = (await readLogs()).length; const beforeWsUsage = readUsage().length; const wsText = await wsTurn(selected); - expect(wsText).toContain(`\"model\":\"${base}\"`); - expect(wsText).not.toContain(`\"model\":\"${virtual}\"`); + expect(wsText).toContain(`\"model\":\"${selected}\"`); + expect(wsText).not.toContain(`\"model\":\"${base}\"`); await expectOnePersisted(beforeWsLogs, beforeWsUsage, selected, virtual, base); for (const capture of captures.slice(-3)) { @@ -495,9 +495,9 @@ describe("OpenAI API Pro transport identities", () => { const prototypeHttp = await request("openai-apikey/constructor", false); expect(prototypeHttp.status).toBe(200); - expect(await prototypeHttp.json()).toMatchObject({ model: "constructor" }); + expect(await prototypeHttp.json()).toMatchObject({ model: "openai-apikey/constructor" }); const prototypeWs = await wsTurn("openai-apikey/toString"); - expect(prototypeWs).toContain('"model":"toString"'); + expect(prototypeWs).toContain('"model":"openai-apikey/toString"'); for (const invalidReasoning of ["high", ["high"]]) { const before = captures.length; diff --git a/tests/openai-provider-option-e2e.test.ts b/tests/openai-provider-option-e2e.test.ts index 243e43dbab..762696c0d4 100644 --- a/tests/openai-provider-option-e2e.test.ts +++ b/tests/openai-provider-option-e2e.test.ts @@ -433,7 +433,7 @@ describe("OpenAI provider-option integration spine", () => { reasoning: { effort: "high" }, }, { authorization: "Bearer fixture-caller-main" }); expect(response.status).toBe(200); - expect(await response.json()).toMatchObject({ model: row.wire }); + expect(await response.json()).toMatchObject({ model: row.selected }); const capture = captures.at(-1)!; expect(capture).toMatchObject({ url: "https://api.openai.com/v1/responses", diff --git a/tests/passthrough-abort.test.ts b/tests/passthrough-abort.test.ts index 5c782b6072..1712a108eb 100644 --- a/tests/passthrough-abort.test.ts +++ b/tests/passthrough-abort.test.ts @@ -50,6 +50,7 @@ describe("passthrough relayWithAbort (RC2, passthrough path)", () => { // Windows no-rewrite traffic must honor the stream-mode/runtime gate so // legacy-tee remains a safety escape hatch for Bun#32111. expect(sseBranch).toContain("const repairConfig = route.provider.responsesItemIdRepair;"); + expect(sseBranch).toContain("const responseModelRewrite = parsed._responseModelId !== route.modelId"); expect(sseBranch).toContain("const needsClientRewrite = imageGenCallAliases.size > 0"); expect(sseBranch).toContain("new Response(eagerBody"); expect(sseBranch).toContain("const rewrittenBody = clientBlockRewrite !== undefined || payloadRewrites.length > 0"); diff --git a/tests/request-log.test.ts b/tests/request-log.test.ts index 50042794d7..f668b460cf 100644 --- a/tests/request-log.test.ts +++ b/tests/request-log.test.ts @@ -619,6 +619,29 @@ describe("request log metadata", () => { }); }); + test("client-facing response selectors do not replace the physical routed model", async () => { + const entries: RequestLogEntry[] = []; + const logCtx: RequestLogContext = { + model: "claude-sonnet-5", + provider: "anthropic", + resolvedModel: "claude-sonnet-5", + preserveResolvedModelFromRoute: true, + }; + const response = responseWithDeferredRequestLog( + new Response(JSON.stringify({ + model: "anthropic/claude-sonnet-5", + status: "completed", + }), { status: 200, headers: { "content-type": "application/json" } }), + "ocx-test-routed-model", + Date.now(), + logCtx, + entry => entries.push(entry), + ); + + expect(await response.json()).toMatchObject({ model: "anthropic/claude-sonnet-5" }); + expect(entries[0]?.resolvedModel).toBe("claude-sonnet-5"); + }); + test("deferred JSON logging captures reported usage", async () => { const entries: RequestLogEntry[] = []; const response = responseWithDeferredRequestLog( diff --git a/tests/response-model-identity.test.ts b/tests/response-model-identity.test.ts new file mode 100644 index 0000000000..1a5f33edf0 --- /dev/null +++ b/tests/response-model-identity.test.ts @@ -0,0 +1,260 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { handleResponses } from "../src/server/responses/core"; +import type { RequestLogContext } from "../src/server/request-log"; +import type { OcxConfig, OcxProviderConfig } from "../src/types"; + +const originalFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +function routedConfig( + providerName: string, + adapter: OcxProviderConfig["adapter"], + models?: string[], + providerOverrides: Partial = {}, +): OcxConfig { + return { + port: 0, + defaultProvider: providerName, + providers: { + [providerName]: { + adapter, + baseUrl: "https://provider.example.test/v1", + authMode: "key", + apiKey: "test-key", + ...(models ? { models } : {}), + ...providerOverrides, + }, + }, + } as OcxConfig; +} + +function responseSnapshot(model: unknown): Record { + return { + id: "resp_fixture", + object: "response", + created_at: 1, + status: "completed", + model, + output: [], + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + }; +} + +async function post(args: { + model: string; + providerName?: string; + adapter?: OcxProviderConfig["adapter"]; + models?: string[]; + stream?: boolean; + providerOverrides?: Partial; + requestHeaders?: Record; +}): Promise<{ response: Response; upstreamModel: unknown; logCtx: RequestLogContext }> { + const providerName = args.providerName ?? "fixture-anthropic"; + const adapter = args.adapter ?? "openai-chat"; + const stream = args.stream ?? false; + let upstreamModel: unknown; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const request = input instanceof Request ? input : new Request(input, init); + upstreamModel = (await request.clone().json() as Record).model; + if (adapter === "openai-responses") { + const snapshot = responseSnapshot(upstreamModel); + if (stream) { + const created = JSON.stringify({ type: "response.created", response: { ...snapshot, status: "in_progress" } }); + const completed = JSON.stringify({ type: "response.completed", response: snapshot }); + return new Response( + `event: response.created\ndata: ${created}\n\nevent: response.completed\ndata: ${completed}\n\ndata: [DONE]\n\n`, + { status: 200, headers: { "content-type": "text/event-stream" } }, + ); + } + return new Response(JSON.stringify(snapshot), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + if (stream) { + return new Response( + 'data: {"choices":[{"delta":{"content":"ok"}}]}\n\ndata: {"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1}}\n\ndata: [DONE]\n\n', + { status: 200, headers: { "content-type": "text/event-stream" } }, + ); + } + return new Response(JSON.stringify({ + choices: [{ message: { role: "assistant", content: "ok" }, finish_reason: "stop" }], + usage: { prompt_tokens: 1, completion_tokens: 1 }, + }), { status: 200, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + + const logCtx = { model: "", provider: "" } as RequestLogContext; + const response = await handleResponses( + new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json", ...args.requestHeaders }, + body: JSON.stringify({ model: args.model, input: "ping", stream }), + }), + routedConfig(providerName, adapter, args.models, args.providerOverrides), + logCtx, + {}, + ); + + return { response, upstreamModel, logCtx }; +} + +function responseModelsFromSse(text: string): string[] { + return text.split(/\r?\n\r?\n/).flatMap(block => { + const payload = block.split(/\r?\n/).find(line => line.startsWith("data: "))?.slice(6); + if (!payload || payload === "[DONE]") return []; + const value = JSON.parse(payload) as { model?: unknown; response?: { model?: unknown } }; + const model = value.response?.model ?? value.model; + return typeof model === "string" ? [model] : []; + }); +} + +describe("routed response model identity", () => { + test("preserves a canonical provider-qualified selector in JSON", async () => { + const result = await post({ model: "fixture-anthropic/claude-sonnet-5" }); + + expect(result.upstreamModel).toBe("claude-sonnet-5"); + expect((await result.response.json() as Record).model) + .toBe("fixture-anthropic/claude-sonnet-5"); + }); + + test("heals a bare routed selector in JSON", async () => { + const result = await post({ model: "claude-sonnet-5" }); + + expect(result.upstreamModel).toBe("claude-sonnet-5"); + expect((await result.response.json() as Record).model) + .toBe("fixture-anthropic/claude-sonnet-5"); + }); + + test("canonicalizes a legacy full-slash selector with the routed slug codec", async () => { + const result = await post({ + model: "openrouter/anthropic/claude-sonnet-5", + providerName: "openrouter", + models: ["anthropic/claude-sonnet-5"], + }); + + expect(result.upstreamModel).toBe("anthropic/claude-sonnet-5"); + expect((await result.response.json() as Record).model) + .toBe("openrouter/anthropic-claude-sonnet-5"); + }); + + test("preserves a raw qualified selector when slash encoding collides with a native id", async () => { + const result = await post({ + model: "collision/a/b", + providerName: "collision", + models: ["a/b", "a-b"], + }); + + expect(result.upstreamModel).toBe("a/b"); + expect((await result.response.json() as Record).model) + .toBe("collision/a/b"); + expect(result.logCtx.resolvedModel).toBe("a/b"); + }); + + test("all bridged SSE response snapshots use the canonical routed selector", async () => { + const result = await post({ model: "claude-sonnet-5", stream: true }); + const models = responseModelsFromSse(await result.response.text()); + + expect(result.upstreamModel).toBe("claude-sonnet-5"); + expect(models.length).toBeGreaterThan(0); + expect(new Set(models)).toEqual(new Set(["fixture-anthropic/claude-sonnet-5"])); + }); + + test("Responses passthrough rewrites JSON for the client and keeps the physical log model", async () => { + const result = await post({ + model: "claude-sonnet-5", + adapter: "openai-responses", + }); + + expect(result.upstreamModel).toBe("claude-sonnet-5"); + expect((await result.response.json() as Record).model) + .toBe("fixture-anthropic/claude-sonnet-5"); + expect(result.logCtx.resolvedModel).toBe("claude-sonnet-5"); + }); + + test("canonical OpenAI forward responses normalize a qualified selector to the native slug", async () => { + const result = await post({ + model: "openai/gpt-5.6-sol", + providerName: "openai", + adapter: "openai-responses", + providerOverrides: { + authMode: "forward", + baseUrl: "https://chatgpt.com/backend-api/codex", + codexAccountMode: "direct", + }, + requestHeaders: { authorization: "Bearer test-forward-token" }, + }); + + expect(result.upstreamModel).toBe("gpt-5.6-sol"); + expect((await result.response.json() as Record).model).toBe("gpt-5.6-sol"); + expect(result.logCtx.resolvedModel).toBe("gpt-5.6-sol"); + }); + + test("Responses passthrough rewrites every SSE snapshot and keeps the upstream selector bare", async () => { + const result = await post({ + model: "claude-sonnet-5", + adapter: "openai-responses", + stream: true, + }); + const models = responseModelsFromSse(await result.response.text()); + + expect(result.upstreamModel).toBe("claude-sonnet-5"); + expect(models).toEqual([ + "fixture-anthropic/claude-sonnet-5", + "fixture-anthropic/claude-sonnet-5", + ]); + expect(result.logCtx.resolvedModel).toBe("claude-sonnet-5"); + }); + + test("virtual OpenAI API models keep the public selector while using and logging the base wire model", async () => { + const result = await post({ + model: "openai-apikey/gpt-5.6-sol-pro", + providerName: "openai-apikey", + adapter: "openai-responses", + }); + + expect(result.upstreamModel).toBe("gpt-5.6-sol"); + expect((await result.response.json() as Record).model) + .toBe("openai-apikey/gpt-5.6-sol-pro"); + expect(result.logCtx.resolvedModel).toBe("gpt-5.6-sol"); + }); + + test("combo responses keep the physical target while the logical log keeps the combo selector", async () => { + let upstreamModel: unknown; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const request = input instanceof Request ? input : new Request(input, init); + upstreamModel = (await request.clone().json() as Record).model; + return new Response(JSON.stringify({ + choices: [{ message: { role: "assistant", content: "ok" }, finish_reason: "stop" }], + usage: { prompt_tokens: 1, completion_tokens: 1 }, + }), { status: 200, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const config: OcxConfig = { + port: 0, + defaultProvider: "member", + providers: { + member: { + adapter: "openai-chat", + baseUrl: "https://member.example.test/v1", + authMode: "key", + apiKey: "test-key", + }, + }, + combos: { identity: { targets: [{ provider: "member", model: "claude-sonnet-5" }] } }, + }; + const logCtx = { model: "", provider: "" } as RequestLogContext; + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "combo/identity", input: "ping", stream: false }), + }), config, logCtx, {}); + + expect(upstreamModel).toBe("claude-sonnet-5"); + expect((await response.json() as Record).model).toBe("claude-sonnet-5"); + expect(logCtx.model).toBe("combo/identity"); + expect(logCtx.provider).toBe("combo"); + expect(logCtx.resolvedModel).toBe("claude-sonnet-5"); + }); +}); diff --git a/tests/web-search.test.ts b/tests/web-search.test.ts index 60ded4b156..8f6946308d 100644 --- a/tests/web-search.test.ts +++ b/tests/web-search.test.ts @@ -411,6 +411,47 @@ function scriptedAdapter(firstPass: AdapterEvent[]): ProviderAdapter { } describe("BUG-R86 routed web-search timeout semantics", () => { + test("web-search-loop SSE snapshots preserve the canonical client selector", async () => { + const parsed = parseRequest({ + model: "claude-sonnet-5", + input: "hi", + stream: true, + tools: [{ type: "web_search" }], + }); + parsed._responseModelId = "fixture-anthropic/claude-sonnet-5"; + let upstreamModel = ""; + const adapter: ProviderAdapter = { + name: "identity", + buildRequest: request => { + upstreamModel = request.modelId; + return { url: "https://routed.test/v1", method: "POST", headers: {}, body: "{}" }; + }, + fetchResponse: async () => new Response("wire", { status: 200 }), + async *parseStream() { + yield { type: "text_delta", text: "answer" }; + yield { type: "done" }; + }, + }; + const response = await runWithWebSearch({ + parsed, + adapter, + 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 models = frames.flatMap(frame => { + const responseModel = (frame.data.response as { model?: unknown } | undefined)?.model; + return typeof responseModel === "string" ? [responseModel] : []; + }); + + expect(upstreamModel).toBe("claude-sonnet-5"); + expect(models.length).toBeGreaterThan(0); + expect(new Set(models)).toEqual(new Set(["fixture-anthropic/claude-sonnet-5"])); + }); + test("translator overflow remains typed through the sidecar loop and bridge", async () => { const adapter: ProviderAdapter = { name: "overflow",