From 021300b44caa25610bddfd10e0ebc69edc4469fb Mon Sep 17 00:00:00 2001 From: chrisae9 Date: Mon, 3 Aug 2026 11:22:34 -0400 Subject: [PATCH 01/13] feat(codex): add account-qualified catalog entries --- src/codex/catalog.ts | 3 +- src/codex/catalog/account-models.ts | 66 ++++++++ src/codex/catalog/metadata.ts | 26 +++- src/codex/catalog/parsing.ts | 3 +- src/codex/catalog/sync.ts | 169 +++++++++++++++++---- src/server/index.ts | 30 +++- src/server/responses/collaboration.ts | 39 ++++- src/server/responses/core.ts | 1 + tests/claude-models-discovery.test.ts | 68 +++++++++ tests/codex-catalog-sync-hardening.test.ts | 97 ++++++++++++ tests/multi-agent-compat.test.ts | 114 +++++++++++++- tests/native-model-toggle.test.ts | 80 ++++++++++ 12 files changed, 643 insertions(+), 53 deletions(-) create mode 100644 src/codex/catalog/account-models.ts diff --git a/src/codex/catalog.ts b/src/codex/catalog.ts index a410b59b0..5294e16b7 100644 --- a/src/codex/catalog.ts +++ b/src/codex/catalog.ts @@ -2,7 +2,7 @@ // Public surface preserved exactly; importers keep using "src/codex/catalog". export { isMediaGenerationModelId, shouldExposeRoutedModel, readCodexCatalogPath, readCatalog, normalizeRoutedCatalogEntry, catalogModelSlug, filterSupportedNativeSlugs, catalogModelSupportsReasoningSummaries } from "./catalog/parsing"; export type { CatalogModel, MultiAgentMode } from "./catalog/parsing"; -export { NATIVE_OPENAI_MODELS, nativeOpenAiContextWindow, disabledNativeSlugs, visibleNativeSlugs, desktopVisibleNativeSlugs, nativeModelRows, applyNativeVisibility, upstreamNativeEntry, nativeOpenAiSlugs, listCatalogNativeSlugs, nativeReasoningEfforts, nativeDefaultReasoningEffort } from "./catalog/metadata"; +export { NATIVE_OPENAI_MODELS, nativeOpenAiContextWindow, disabledNativeSlugs, visibleNativeSlugs, desktopVisibleNativeSlugs, nativeModelRows, applyNativeVisibility, upstreamNativeEntry, nativeOpenAiSlugs, listCatalogNativeSlugs, nativeReasoningEfforts, nativeDefaultReasoningEffort, shouldIncludeNativeOpenAi } from "./catalog/metadata"; export { isSpawnableCodexCandidate, codexExecInvocation, loadBundledCodexCatalog, materializeBundledCodexCatalog, loadCatalogTemplate } from "./catalog/bundled"; export { nativeEffortClamp, shouldApplyNativeEffortClamp, catalogModelEfforts, codexSupportedReasoningEfforts, clampedDefaultEffort, clampEntryToCodexSupportedEfforts, clampCatalogModelsToCodexSupport } from "./catalog/effort"; export { applyProviderConfigHints, isDatedVariantId, filterCatalogVisibleModels, gatherRoutedModels, clearGatherRoutedModelsInflight, augmentRoutedModelsWithRegistryOpenAiApiRows, augmentRoutedModelsWithJawcodeMetadata } from "./catalog/provider-fetch"; @@ -10,3 +10,4 @@ export { deriveComboCatalogModel, exactComboCatalogSlugs, getLastComboCatalogOmi 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 type { SpawnAgentSurface, SubagentRosterExclusionReason, EffectiveSubagentModel, SubagentRosterExclusion, EffectiveSubagentRoster } from "./catalog/sync"; +export { accountBoundNativeDisplayName, accountBoundNativeModelSlugs, CODEX_ACCOUNT_BOUND_CATALOG_DESCRIPTION, CODEX_ACCOUNT_BOUND_CATALOG_KIND, trustedAccountBoundNativeCatalogSlug, visibleCodexAccountSelectors } from "./catalog/account-models"; diff --git a/src/codex/catalog/account-models.ts b/src/codex/catalog/account-models.ts new file mode 100644 index 000000000..45cd7d076 --- /dev/null +++ b/src/codex/catalog/account-models.ts @@ -0,0 +1,66 @@ +import type { OcxConfig } from "../../types"; +import { isMainCodexAccountTarget } from "../account-namespaces"; +import type { RawEntry } from "./parsing"; + +/** Stable marker used to distinguish generated account rows from provider-owned catalog rows. */ +export const CODEX_ACCOUNT_BOUND_CATALOG_DESCRIPTION = + "OpenAI native model bound to a Codex account namespace."; +export const CODEX_ACCOUNT_BOUND_CATALOG_KIND = "account-selector-v1"; + +/** + * Public selectors whose configured account row still exists. + * + * Stale mappings stay in config so exact routing keeps failing closed, but they must not advertise + * a deleted account. Credential health, pause, and reauthentication state intentionally do not + * churn catalog identity. Only selector keys leave this boundary; private account ids do not. + */ +export function visibleCodexAccountSelectors( + config: Pick, +): string[] { + const storedPoolAccounts = new Set( + (config.codexAccounts ?? []) + .filter(account => !account.isMain) + .map(account => account.id), + ); + return Object.entries(config.codexAccountNamespaces ?? {}) + .filter(([, accountId]) => + isMainCodexAccountTarget(accountId) || storedPoolAccounts.has(accountId) + ) + .map(([selector]) => selector); +} + +export function accountBoundNativeDisplayName(selector: string, native: RawEntry): string { + const rawModel = typeof native.display_name === "string" + ? native.display_name + : String(native.slug ?? ""); + const model = rawModel + .replace(/^gpt-/i, "") + .split("-") + .filter(Boolean) + .map(part => /^[a-z]/.test(part) ? part.charAt(0).toUpperCase() + part.slice(1) : part) + .join(" "); + return `${selector} / ${model}`; +} + +function accountBoundNativeCatalogSlug(entry: RawEntry): string | undefined { + if (entry.description !== CODEX_ACCOUNT_BOUND_CATALOG_DESCRIPTION + || typeof entry.slug !== "string") return undefined; + const slash = entry.slug.indexOf("/"); + return slash > 0 ? entry.slug.slice(slash + 1) : undefined; +} + +/** Identify current generated rows without changing Codex's semantic model fields. */ +export function trustedAccountBoundNativeCatalogSlug(entry: RawEntry): string | undefined { + return entry.opencodex_catalog_kind === CODEX_ACCOUNT_BOUND_CATALOG_KIND + ? accountBoundNativeCatalogSlug(entry) + : undefined; +} + +export function accountBoundNativeModelSlugs( + config: Pick, + nativeSlugs: Iterable, +): string[] { + const natives = [...nativeSlugs]; + return visibleCodexAccountSelectors(config) + .flatMap(selector => natives.map(slug => `${selector}/${slug}`)); +} diff --git a/src/codex/catalog/metadata.ts b/src/codex/catalog/metadata.ts index 8f3bad494..b29e5826a 100644 --- a/src/codex/catalog/metadata.ts +++ b/src/codex/catalog/metadata.ts @@ -34,6 +34,7 @@ import upstreamModelsSnapshot from "../data/upstream-models.json"; import { filterSupportedNativeSlugs } from "./parsing"; import type { RawEntry } from "./parsing"; import { readCurrentCatalogOrCache, unique } from "./bundled"; +import { trustedAccountBoundNativeCatalogSlug } from "./account-models"; export const NATIVE_OPENAI_MODELS = [ "gpt-5.5", "gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex-spark", @@ -121,6 +122,15 @@ export function visibleNativeSlugs(config: Pick): s return nativeOpenAiSlugs().filter(slug => !disabled.has(slug)); } +/** Whether native ChatGPT/Codex routes are valid for this provider configuration. */ +export function shouldIncludeNativeOpenAi(config: Pick): boolean { + const enabledProviders = Object.entries(config.providers) + .filter(([, provider]) => provider.disabled !== true); + return enabledProviders.length === 0 || enabledProviders.some(([name, provider]) => + name === OPENAI_CODEX_PROVIDER_ID && isCanonicalOpenAiForwardProvider(provider) + ); +} + /** Native slugs exposed to Claude Desktop show/export/apply (opt-out via claudeCode.desktopNativeModels). */ export function desktopVisibleNativeSlugs(config: Pick): string[] { if (config.claudeCode?.desktopNativeModels === false) return []; @@ -135,11 +145,21 @@ export function nativeModelRows(config: Pick): Arra }); } -export function applyNativeVisibility(entries: RawEntry[], disabledNative: Set): RawEntry[] { +export function applyNativeVisibility( + entries: RawEntry[], + disabledNative: Set, + hideBareNative = false, +): RawEntry[] { for (const entry of entries) { const slug = typeof entry.slug === "string" ? entry.slug : ""; - if (!slug || slug.includes("/") || !SUPPORTED_NATIVE_OPENAI_SLUGS.has(slug)) continue; - entry.visibility = disabledNative.has(slug) ? "hide" : "list"; + const accountBoundSlug = trustedAccountBoundNativeCatalogSlug(entry); + const nativeSlug = accountBoundSlug ?? slug; + if (!nativeSlug + || (!accountBoundSlug && slug.includes("/")) + || !SUPPORTED_NATIVE_OPENAI_SLUGS.has(nativeSlug)) continue; + entry.visibility = disabledNative.has(nativeSlug) || (!accountBoundSlug && hideBareNative) + ? "hide" + : "list"; } return entries; } diff --git a/src/codex/catalog/parsing.ts b/src/codex/catalog/parsing.ts index 0c248ca53..777018156 100644 --- a/src/codex/catalog/parsing.ts +++ b/src/codex/catalog/parsing.ts @@ -32,6 +32,7 @@ import upstreamModelsSnapshot from "../data/upstream-models.json"; import { NATIVE_OPENAI_CONTEXT_OVERRIDES, SUPPORTED_NATIVE_OPENAI_SLUGS, UPSTREAM_NATIVE_ENTRIES } from "./metadata"; +import { trustedAccountBoundNativeCatalogSlug } from "./account-models"; export function legacyCatalogBackupPath(): string { return join(getConfigDir(), "catalog-backup.json"); @@ -317,7 +318,7 @@ export function applyMultiAgentMode(entries: RawEntry[], mode: MultiAgentMode, v // re-apply upstream pins from the snapshot for native entries that have one. for (const entry of entries) { const slug = typeof entry.slug === "string" ? entry.slug : ""; - const upstream = UPSTREAM_NATIVE_ENTRIES.get(slug); + const upstream = UPSTREAM_NATIVE_ENTRIES.get(trustedAccountBoundNativeCatalogSlug(entry) ?? slug); const upstreamPin = upstream?.multi_agent_version; if (typeof upstreamPin === "string") { entry.multi_agent_version = upstreamPin; diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index 187094d12..5e8ff4223 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -17,7 +17,6 @@ import { routedSlug, slugEquals, slugsEquivalent } from "../../providers/slug-co import { identifyRoutedModel } from "../../adapters/identity"; import { filterCursorConfiguredModelsByLiveDiscovery } from "../../adapters/cursor/discovery"; import { fetchCursorUsableModels } from "../../adapters/cursor/live-models"; -import { isCanonicalOpenAiForwardProvider, OPENAI_API_PROVIDER_ID, OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers"; import { COMBO_NAMESPACE, comboModelId, @@ -33,13 +32,14 @@ import upstreamModelsSnapshot from "../data/upstream-models.json"; import { activeCodexModelsCachePath, applyJawcodeCatalogMetadata, applyMultiAgentMode, applyNativeOpenAiContextOverride, catalogModelSlug, ensureCatalogBackup, ensureStrictCatalogFields, findNativeTemplate, isRoutedModelCompatibilityExcluded, normalizeRoutedCatalogEntry, normalizeServiceTiers, readCatalog, readCatalogBackup, readCodexCatalogPath, readNativeBaseline } from "./parsing"; import type { CatalogModel, MultiAgentMode, RawEntry } from "./parsing"; -import { applyNativeVisibility, disabledNativeSlugs, isUnsupportedOpenAiNativeSlug, nativeOpenAiSlugs, shouldUpgradeToUpstreamEntry, upstreamNativeEntry } from "./metadata"; +import { applyNativeVisibility, disabledNativeSlugs, isUnsupportedOpenAiNativeSlug, nativeOpenAiSlugs, shouldIncludeNativeOpenAi, shouldUpgradeToUpstreamEntry, SUPPORTED_NATIVE_OPENAI_SLUGS, upstreamNativeEntry } from "./metadata"; import { loadCatalogForSync, resetBundledCatalogCacheForTests } from "./bundled"; import { isMultiAgentV2Enabled } from "../features"; import { applyCatalogModelMetadata, applyReasoningLevels, catalogEntryEfforts, clampCatalogModelsToCodexSupport, ensureGpt56ReasoningLevels, ensureUltraReasoningLevel, isGpt56NativeSlug } from "./effort"; import { clearGatherRoutedModelsInflight, filterCatalogVisibleModels, gatherRoutedModels, lastDropWarnSignature } from "./provider-fetch"; import { clearLastComboCatalogOmissions, comboCatalogWarningSignatures, comboMasqueradeCollisionWarnings, exactComboCatalogSlugs, openAiApiCollisionWarnings, resolveSlugAliasCollisions, slugAliasCollisionWarnings, warnComboMasqueradeCollisionOnce } from "./aggregation"; import type { ComboCatalogOmission } from "./aggregation"; +import { accountBoundNativeDisplayName, CODEX_ACCOUNT_BOUND_CATALOG_DESCRIPTION, CODEX_ACCOUNT_BOUND_CATALOG_KIND, trustedAccountBoundNativeCatalogSlug, visibleCodexAccountSelectors } from "./account-models"; export const MAX_SPAWN_AGENT_MODEL_OVERRIDES = 5; @@ -93,6 +93,16 @@ export function configuredCatalogEntry(entries: RawEntry[], configured: string): ?? entries.find(entry => typeof entry.slug === "string" && slugsEquivalent(configured, entry.slug)); } +function configuredSubagentModelMatchesEntry(configured: string, entry: RawEntry): boolean { + if (typeof entry.slug !== "string") return false; + if (slugsEquivalent(configured, entry.slug)) return true; + const nativeSlug = trustedAccountBoundNativeCatalogSlug(entry); + return !configured.includes("/") + && nativeSlug !== undefined + && SUPPORTED_NATIVE_OPENAI_SLUGS.has(nativeSlug) + && slugsEquivalent(configured, nativeSlug); +} + export function effectiveSubagentRoster( configuredModels: readonly string[], surface: SpawnAgentSurface, @@ -121,23 +131,37 @@ export function effectiveSubagentRoster( model: entry.slug as string, efforts: catalogEntryEfforts(entry), })); - const advertised = candidates.filter(candidate => - configured.some(model => slugsEquivalent(model, candidate.model)) - ); + const advertised = ordered + .filter(({ entry }) => configured.some(model => configuredSubagentModelMatchesEntry(model, entry))) + .map(({ entry }) => ({ + model: entry.slug as string, + efforts: catalogEntryEfforts(entry), + })); const excluded = configured.flatMap((model): SubagentRosterExclusion[] => { - const entry = configuredCatalogEntry(entries, model); - if (!entry) return [{ configured: model, reason: "missing_catalog_entry" }]; - const catalogModel = entry.slug as string; - if (entry.visibility !== "list") { - return [{ configured: model, catalogModel, reason: "picker_hidden" }]; - } - if (surface === "v2" && !isEligibleV2SubagentEntry(entry)) { - return [{ configured: model, catalogModel, reason: "surface_incompatible" }]; + if (ordered.some(({ entry }) => configuredSubagentModelMatchesEntry(model, entry))) return []; + const matchingEntries = entries.filter(entry => configuredSubagentModelMatchesEntry(model, entry)); + if (matchingEntries.length === 0) return [{ configured: model, reason: "missing_catalog_entry" }]; + const visibleCompatible = matchingEntries.find(entry => + entry.visibility === "list" + && (surface !== "v2" || isEligibleV2SubagentEntry(entry)) + ); + if (visibleCompatible) { + return [{ + configured: model, + catalogModel: visibleCompatible.slug as string, + reason: "outside_display_limit", + }]; } - if (!candidates.some(candidate => candidate.model === catalogModel)) { - return [{ configured: model, catalogModel, reason: "outside_display_limit" }]; + const visible = matchingEntries.find(entry => entry.visibility === "list"); + if (visible) { + return [{ + configured: model, + catalogModel: visible.slug as string, + reason: "surface_incompatible", + }]; } - return []; + const hidden = configuredCatalogEntry(entries, model) ?? matchingEntries[0]!; + return [{ configured: model, catalogModel: hidden.slug as string, reason: "picker_hidden" }]; }); return { candidates, advertised, excluded }; } @@ -252,6 +276,7 @@ export function buildCatalogEntries( wsEnabled = false, multiAgentMode: MultiAgentMode = "default", exactComboSlugs: ReadonlySet = new Set(), + accountSelectors: readonly string[] = [], ): RawEntry[] { // Codex's models-manager sorts by `priority` ASC and advertises the first 5 picker-visible // models to spawn_agent (sort_by_key(priority) + MAX_MODEL_OVERRIDES_IN_SPAWN_AGENT=5). Catalog @@ -259,6 +284,7 @@ export function buildCatalogEntries( // it sorts to the front. This works for native gpt slugs AND routed slugs alike. const rank = new Map((featured ?? []).map((slug, i) => [slug, i] as const)); const out: RawEntry[] = []; + const nativeEntries: RawEntry[] = []; const collisionSkipped = resolveSlugAliasCollisions(goModels); const comboPublicSlugs = new Set(goModels .filter(model => model.provider === COMBO_NAMESPACE) @@ -267,6 +293,27 @@ export function buildCatalogEntries( const e = deriveEntry(template, slug, "OpenAI native model (Codex OAuth passthrough).", 9); if (rank.has(slug)) e.priority = rank.get(slug)!; out.push(e); + nativeEntries.push(e); + } + for (const [selectorIndex, selector] of accountSelectors.entries()) { + for (const [nativeIndex, native] of nativeEntries.entries()) { + const e = JSON.parse(JSON.stringify(native)) as RawEntry; + const nativeSlug = String(native.slug); + const catalogSlug = `${selector}/${nativeSlug}`; + e.slug = catalogSlug; + e.display_name = accountBoundNativeDisplayName(selector, native); + e.description = CODEX_ACCOUNT_BOUND_CATALOG_DESCRIPTION; + // Codex ignores this OpenCodex extension; preserve the native comp_hash unchanged. + e.opencodex_catalog_kind = CODEX_ACCOUNT_BOUND_CATALOG_KIND; + const exactRank = rank.get(catalogSlug); + const inheritedRank = rank.get(nativeSlug); + const featuredRank = exactRank ?? inheritedRank; + e.priority = featuredRank !== undefined + ? featuredRank * accountSelectors.length + selectorIndex + : ((featured?.length ?? 0) + nativeIndex) * accountSelectors.length + selectorIndex; + e.visibility = "list"; + out.push(e); + } } for (const m of goModels) { if (collisionSkipped.has(m)) continue; @@ -288,6 +335,10 @@ export function buildCatalogEntries( // Featured picks may be stored raw (legacy) or encoded — honor both. const rankHit = rank.get(slug) ?? rank.get(`${m.provider}/${m.id}`); if (rankHit !== undefined) e.priority = rankHit; + else if (accountSelectors.length > 0) { + // Keep the generated account rows together in Codex's priority-sorted flat picker. + e.priority = 1_000 + (typeof e.priority === "number" ? e.priority : 5); + } out.push(e); } // Central capability override (phase 120.4): the advertised flag must match the implemented WS @@ -364,6 +415,7 @@ export function mergeCatalogEntriesForSync( exactComboSlugs: ReadonlySet = new Set(), hasPhysicalComboProvider = false, includeNativeOpenAi = true, + accountBoundEntries: readonly RawEntry[] = [], ): RawEntry[] { const rank = new Map(featured.map((slug, i) => [slug, i] as const)); const native = includeNativeOpenAi @@ -427,20 +479,25 @@ export function mergeCatalogEntriesForSync( routedEntries.flatMap(entry => typeof entry.slug === "string" ? [entry.slug] : []), ); let finalRoutedEntries = routedEntries; + const existingRoutedEntries = catalogModels.filter(m => + typeof m.slug === "string" + && m.slug.includes("/") + && trustedAccountBoundNativeCatalogSlug(m) === undefined + ); const preservingExistingRouted = routedEntries.length === 0 - && catalogModels.some(m => typeof m.slug === "string" && (m.slug as string).includes("/")); + && existingRoutedEntries.length > 0; if (preservingExistingRouted) { // #855: transient-fetch protection keeps existing rows, but rows OpenCodex // itself authored for a provider that is no longer configured are ghosts, // not protected foreign entries. - finalRoutedEntries = catalogModels.filter(m => { - if (typeof m.slug !== "string" || !(m.slug as string).includes("/")) return false; + finalRoutedEntries = existingRoutedEntries.filter(m => { const provider = (m.slug as string).slice(0, (m.slug as string).indexOf("/")); return !(isOcxAuthoredRoutedEntry(m) && !gatheredProviderNames.has(provider)); }); } else { const preservedForeignRouted = catalogModels.filter(m => { if (typeof m.slug !== "string" || !m.slug.includes("/")) return false; + if (trustedAccountBoundNativeCatalogSlug(m) !== undefined) return false; const provider = m.slug.slice(0, m.slug.indexOf("/")); if (gatheredProviderNames.has(provider) || freshSlugs.has(m.slug)) return false; // #855: an OpenCodex-authored row whose provider was deleted is a ghost; @@ -466,17 +523,24 @@ export function mergeCatalogEntriesForSync( finalRoutedEntries = finalRoutedEntries.filter(entry => typeof entry.slug !== "string" || !isRoutedModelCompatibilityExcluded(entry.slug) ); + const accountBoundSlugs = new Set(accountBoundEntries.flatMap(entry => + typeof entry.slug === "string" ? [entry.slug] : [] + )); + finalRoutedEntries = finalRoutedEntries.filter(entry => + typeof entry.slug !== "string" || !accountBoundSlugs.has(entry.slug) + ); if (preservingExistingRouted) { console.warn(`[opencodex] catalog sync: routed model fetch returned empty; preserving ${finalRoutedEntries.length} existing routed entr${finalRoutedEntries.length === 1 ? "y" : "ies"} on disk.`); } - const mergedEntries = [...native, ...finalRoutedEntries].map(m => { + const managedEntries = [...finalRoutedEntries, ...accountBoundEntries]; + const mergedEntries = [...native, ...managedEntries].map(m => { const normalized = normalizeServiceTiers(m); applyNativeOpenAiContextOverride(normalized); const exactCombo = typeof m.slug === "string" && exactComboSlugs.has(m.slug); const e = ensureStrictCatalogFields(normalized, { preserveExactInputModalities: exactCombo, - isRouted: finalRoutedEntries.includes(m), + isRouted: managedEntries.includes(m), }); // Mock-max universality (260709): preserved routed entries from disk may predate // the max rung — ensure it here so subagent max spawns validate on every @@ -501,7 +565,11 @@ export function mergeCatalogEntriesForSync( }); // Native enable/disable (single choke point: bare slugs in `disabledModels`). Runs as the // LAST pass so the upstream-upgrade branch above can never clobber a hide flag back to list. - return applyMultiAgentMode(applyNativeVisibility(mergedEntries, disabledNative), multiAgentMode, isMultiAgentV2Enabled()); + return applyMultiAgentMode( + applyNativeVisibility(mergedEntries, disabledNative, accountBoundEntries.length > 0), + multiAgentMode, + isMultiAgentV2Enabled(), + ); } export async function syncCatalogModels(config: OcxConfig): Promise<{ @@ -538,7 +606,19 @@ export async function syncCatalogModels(config: OcxConfig): Promise<{ const multiAgentMode: MultiAgentMode = config.multiAgentMode === "v1" || config.multiAgentMode === "v2" ? config.multiAgentMode : "default"; const exactComboSlugs = exactComboCatalogSlugs(config); const hasPhysicalComboProvider = Object.hasOwn(config.providers, COMBO_NAMESPACE); - const goEntries = buildCatalogEntries(template ? JSON.parse(JSON.stringify(template)) : null, [], orderedGoModels, featured, websocketsEnabled(config), multiAgentMode, exactComboSlugs); + const includeNativeOpenAi = shouldIncludeNativeOpenAi(config); + const accountSelectors = includeNativeOpenAi ? visibleCodexAccountSelectors(config) : []; + const wsEnabled = websocketsEnabled(config); + const goEntries = buildCatalogEntries( + template ? JSON.parse(JSON.stringify(template)) : null, + [], + orderedGoModels, + featured, + wsEnabled, + multiAgentMode, + exactComboSlugs, + accountSelectors, + ); // Keep genuine native entries (gpt-*, codex-*) with their real per-model fields and append // routed providers as namespaced slugs. Cursor and other adopted providers can expose model ids // like `gpt-5.5`; those must not delete the native OpenAI/Codex base row. @@ -552,21 +632,46 @@ export async function syncCatalogModels(config: OcxConfig): Promise<{ // Central WS capability override on the FINAL on-disk catalog (the file Codex reads). Applies to // native AND routed so the advertised flag matches the implemented endpoint (phase 120.4) and a // native template can never leak supports_websockets while the flag is off. - const wsEnabled = websocketsEnabled(config); - const enabledProviders = Object.entries(config.providers ?? {}) - .filter(([, prov]) => prov.disabled !== true); - const hasCanonicalOpenai = enabledProviders.some(([name, prov]) => - name === "openai" && isCanonicalOpenAiForwardProvider(prov), - ); // #636: when the user only configured non-OpenAI providers (e.g. kimi), do not advertise // bare gpt-* rows that hard-404 via NoEnabledOpenAiProviderError. Keep natives when no // providers are configured yet (fresh install / catalog bootstrap tests). - const includeNativeOpenAi = enabledProviders.length === 0 || hasCanonicalOpenai; - catalog.models = mergeCatalogEntriesForSync(catalogModelsForMerge, goEntries, baseline, featured, wsEnabled, goIds, template, disabledNativeSlugs(config), gatheredProviderNames, multiAgentMode, exactComboSlugs, hasPhysicalComboProvider, includeNativeOpenAi); + const accountBoundEntries = includeNativeOpenAi && accountSelectors.length > 0 + ? buildCatalogEntries( + template ? JSON.parse(JSON.stringify(template)) : null, + nativeOpenAiSlugs(), + [], + featured, + wsEnabled, + multiAgentMode, + exactComboSlugs, + accountSelectors, + ).filter(entry => trustedAccountBoundNativeCatalogSlug(entry) !== undefined) + : []; + catalog.models = mergeCatalogEntriesForSync( + catalogModelsForMerge, + goEntries, + baseline, + featured, + wsEnabled, + goIds, + template, + disabledNativeSlugs(config), + gatheredProviderNames, + multiAgentMode, + exactComboSlugs, + hasPhysicalComboProvider, + includeNativeOpenAi, + accountBoundEntries, + ); clampCatalogModelsToCodexSupport(catalog.models); atomicWriteFile(catalogPath, JSON.stringify(catalog, null, 2) + "\n"); - return { added: goEntries.length, path: catalogPath, catalogWritten: true, comboOmissions }; + return { + added: goEntries.length + accountBoundEntries.length, + path: catalogPath, + catalogWritten: true, + comboOmissions, + }; } export function restoreCodexCatalog(): { removed: number; kept: number; path: string } { diff --git a/src/server/index.ts b/src/server/index.ts index 57e6f6c9f..53b26257b 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -570,8 +570,10 @@ export function startServer(port?: number, deps: StartServerDeps = {}) { } throw error; } - const { applyNativeVisibility, buildCatalogEntries, disabledNativeSlugs, exactComboCatalogSlugs, loadCatalogTemplate, nativeOpenAiSlugs, nativeReasoningEfforts, nativeDefaultReasoningEffort, orderForSubagents, filterCatalogVisibleModels, uniqueCatalogModelsForRawPublicList, visibleNativeSlugs, desktopVisibleNativeSlugs } = await import("../codex/catalog"); + const { accountBoundNativeModelSlugs, applyNativeVisibility, buildCatalogEntries, disabledNativeSlugs, exactComboCatalogSlugs, loadCatalogTemplate, nativeOpenAiSlugs, nativeReasoningEfforts, nativeDefaultReasoningEffort, orderForSubagents, filterCatalogVisibleModels, shouldIncludeNativeOpenAi, uniqueCatalogModelsForRawPublicList, visibleCodexAccountSelectors, visibleNativeSlugs, desktopVisibleNativeSlugs } = await import("../codex/catalog"); const nativeSlugs = nativeOpenAiSlugs(); + const includeAccountRows = shouldIncludeNativeOpenAi(config); + const accountSelectors = includeAccountRows ? visibleCodexAccountSelectors(config) : []; const goEnabled = filterCatalogVisibleModels(goModels, config); const goOrdered = orderForSubagents(goEnabled, config.subagentModels); // Claude Code / Claude Desktop gateway model discovery (GET /v1/models with @@ -615,8 +617,14 @@ export function startServer(port?: number, deps: StartServerDeps = {}) { // Disabled natives stay in the catalog shape with visibility "hide" (mirrors the // on-disk sync; codex-rs keeps them out of the picker itself). const maMode = config.multiAgentMode === "v1" || config.multiAgentMode === "v2" ? config.multiAgentMode : "default"; - const entries = buildCatalogEntries(loadCatalogTemplate(), nativeSlugs, goOrdered, config.subagentModels, websocketsEnabled(config), maMode as "v1" | "default" | "v2", exactComboCatalogSlugs(config)); - return jsonResponse({ models: applyNativeVisibility(entries, disabledNativeSlugs(config)) }, 200, req, config); + const entries = buildCatalogEntries(loadCatalogTemplate(), nativeSlugs, goOrdered, config.subagentModels, websocketsEnabled(config), maMode as "v1" | "default" | "v2", exactComboCatalogSlugs(config), accountSelectors); + return jsonResponse({ + models: applyNativeVisibility( + entries, + disabledNativeSlugs(config), + accountSelectors.length > 0, + ), + }, 200, req, config); } // OpenAI list shape: native gpt bare + routed models namespaced "/" // (pure availability list — disabled natives are omitted entirely). @@ -644,14 +652,22 @@ export function startServer(port?: number, deps: StartServerDeps = {}) { reasoning_efforts: efforts.map(effort => grokEffortOption(effort, effort === defaultEffort)), }; }; - const data = [ - ...visibleNativeSlugs(config).map(id => ({ + const nativeModelRow = (id: string, metadataId = id) => ({ id, object: "model", created: 0, owned_by: "openai", - ...grokEffortFields(nativeReasoningEfforts(id), nativeDefaultReasoningEffort(id)), - })), + ...grokEffortFields( + nativeReasoningEfforts(metadataId), + nativeDefaultReasoningEffort(metadataId), + ), + }); + const visibleNatives = visibleNativeSlugs(config); + const data = [ + ...visibleNatives.map(id => nativeModelRow(id)), + ...(includeAccountRows ? accountBoundNativeModelSlugs(config, visibleNatives) : []).map(id => + nativeModelRow(id, id.slice(id.indexOf("/") + 1)) + ), ...uniqueCatalogModelsForRawPublicList(goOrdered).map(m => ({ id: m.alias ?? `${m.provider}/${m.id}`, object: "model", diff --git a/src/server/responses/collaboration.ts b/src/server/responses/collaboration.ts index 8e07930fb..00f12d5e2 100644 --- a/src/server/responses/collaboration.ts +++ b/src/server/responses/collaboration.ts @@ -60,7 +60,6 @@ import { fetchWithResetRetry, fetchWithTransientRetry, applyUpstreamRecoveryInit import { ForwardAdmissionCredentialError, validateForwardAdmissionCredential } from "../auth-cors"; import { listOpenAiForwardSidecarCandidates, resolveFirstUsableOpenAiSidecar, type ResolvedOpenAiForwardSidecar } from "../../providers/openai-sidecar"; import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers"; -import { slugsEquivalent } from "../../providers/slug-codec"; import { subagentFallbackGuidanceText } from "../../codex/subagent-model-fallback"; import { applyOpenAiVirtualModel, resolveOpenAiCompactModel } from "../../providers/openai-virtual-models"; import { isUsageDebugEnabled } from "../../usage/debug"; @@ -96,7 +95,7 @@ import { sanitizePassthroughHeaders, } from "../relay"; import { hasResponsesItemIdRepair, relaySseWithResponsesItemIdRepair } from "../responses-item-id-repair"; -import type { EffectiveSubagentRoster, SpawnAgentSurface } from "../../codex/catalog"; +import type { EffectiveSubagentModel, EffectiveSubagentRoster, SpawnAgentSurface } from "../../codex/catalog"; import type { TranslatorBudget } from "../../lib/translator-budget"; @@ -169,6 +168,7 @@ export function collabSurface(parsed: OcxParsedRequest): "v1" | "v2" | null { export interface MultiAgentGuidanceOptions { multiAgentGuidanceEnabled?: boolean; + codexAccountNamespace?: string; injectionModel?: string; injectionEffort?: string; subagentModels?: string[]; @@ -218,6 +218,7 @@ export async function multiAgentGuidanceText( const { injectionModel, injectionEffort, + codexAccountNamespace, subagentModels, subagentModelFallback, injectionPrompt, @@ -244,13 +245,35 @@ export async function multiAgentGuidanceText( ]; const resolveRoster = deps.resolveEffectiveSubagentRoster ?? resolveEffectiveSubagentRoster; const effective = await resolveRoster(configuredForGuidance, "v2"); - const rosterModels = effective.advertised.filter(candidate => - (subagentModels ?? []).some(model => slugsEquivalent(model, candidate.model)) - ); - const roster = subagentRosterText(rosterModels); - const preferred = injectionModel - ? effective.candidates.find(candidate => slugsEquivalent(injectionModel, candidate.model)) + // Resolve the roster and preferred roles independently so a bare native can project onto its + // generated account rows without making an unrelated provider/gpt-* row look equivalent. + // The intersection keeps both projections inside Codex's one global five-model window. + const candidateModels = new Set(effective.candidates.map(candidate => candidate.model)); + const withinCandidateWindow = (candidate: EffectiveSubagentModel): boolean => + candidateModels.has(candidate.model); + const configuredSubagents = subagentModels ?? []; + const subagentEffective = configuredSubagents.length > 0 + ? injectionModel + ? await resolveRoster(configuredSubagents, "v2") + : effective + : undefined; + const preferredEffective = injectionModel + ? configuredSubagents.length > 0 + ? await resolveRoster([injectionModel], "v2") + : effective : undefined; + const rosterModels = (subagentEffective?.advertised ?? []).filter(withinCandidateWindow); + const roster = subagentRosterText(rosterModels); + const preferredCandidates = (preferredEffective?.advertised ?? []).filter(withinCandidateWindow); + const preferred = injectionModel?.includes("/") + ? preferredCandidates[0] + : codexAccountNamespace + ? preferredCandidates.find(candidate => + candidate.model.startsWith(`${codexAccountNamespace}/`) + ) + : preferredCandidates.length === 1 && !preferredCandidates[0]!.model.includes("/") + ? preferredCandidates[0] + : undefined; if (isInjectionDebugEnabled() && effective.excluded.length > 0) { injectionDebugLog(`[opencodex] multi-agent guidance excluded: ${effective.excluded diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 6225e8f9b..619a2e7f0 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -882,6 +882,7 @@ async function applyFinalRouteRequestNormalization(args: { { const guidance = await multiAgentGuidanceText(parsed, { multiAgentGuidanceEnabled: config.multiAgentGuidanceEnabled, + codexAccountNamespace: route.codexAccountNamespace, injectionModel: config.injectionModel, injectionEffort: config.injectionEffort, subagentModels: config.subagentModels, diff --git a/tests/claude-models-discovery.test.ts b/tests/claude-models-discovery.test.ts index 7bb0cc26b..d38f339a2 100644 --- a/tests/claude-models-discovery.test.ts +++ b/tests/claude-models-discovery.test.ts @@ -155,3 +155,71 @@ test("OpenAI list shape and Codex catalog shape stay unchanged", async () => { await server.stop(true); } }); + +test("configured account selectors appear in OpenAI and Codex discovery without private ids", async () => { + const config = configWithStaticModels(); + config.providers.openai = { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + codexAccountMode: "pool", + }; + config.codexAccounts = [{ + id: "stored-side-account", + email: "private@example.test", + alias: "Private Display Name", + isMain: false, + }]; + config.codexAccountNamespaces = { + desktop: "@main", + team: "stored-side-account", + removed: "missing-account", + }; + saveConfig(config); + const server = startServer(0); + try { + const plain = await fetch(new URL("/v1/models", server.url)).then(response => response.json()) as { + data: Array<{ id: string; reasoning_efforts?: unknown[] }>; + }; + const plainIds = plain.data.map(model => model.id); + expect(plainIds).toContain("gpt-5.5"); + expect(plainIds).toContain("desktop/gpt-5.5"); + expect(plainIds).toContain("team/gpt-5.5"); + expect(plainIds.some(id => id.startsWith("removed/"))).toBe(false); + expect(plain.data.find(model => model.id === "team/gpt-5.5")?.reasoning_efforts) + .toEqual(plain.data.find(model => model.id === "gpt-5.5")?.reasoning_efforts); + + const catalog = await fetch(new URL("/v1/models?client_version=1.0.0", server.url)) + .then(response => response.json()) as { + models: Array<{ slug: string; display_name?: string; visibility?: string; priority?: number }>; + }; + expect(catalog.models.find(model => model.slug === "gpt-5.5")?.visibility).toBe("hide"); + expect(catalog.models.find(model => model.slug === "desktop/gpt-5.5")) + .toMatchObject({ display_name: "desktop / 5.5", visibility: "list" }); + expect(catalog.models.find(model => model.slug === "team/gpt-5.5")?.visibility).toBe("list"); + expect(catalog.models.some(model => model.slug.startsWith("removed/"))).toBe(false); + expect(JSON.stringify(catalog)).not.toContain("stored-side-account"); + expect(JSON.stringify(catalog)).not.toContain("private@example.test"); + } finally { + server.stop(true); + } +}); + +test("account selectors stay out of discovery when no canonical OpenAI provider is enabled", async () => { + const config = configWithStaticModels(); + config.codexAccountNamespaces = { desktop: "@main" }; + saveConfig(config); + const server = startServer(0); + try { + const plain = await fetch(new URL("/v1/models", server.url)).then(response => response.json()) as { + data: Array<{ id: string }>; + }; + expect(plain.data.some(model => model.id.startsWith("desktop/"))).toBe(false); + + const catalog = await fetch(new URL("/v1/models?client_version=1.0.0", server.url)) + .then(response => response.json()) as { models: Array<{ slug: string }> }; + expect(catalog.models.some(model => model.slug.startsWith("desktop/"))).toBe(false); + } finally { + server.stop(true); + } +}); diff --git a/tests/codex-catalog-sync-hardening.test.ts b/tests/codex-catalog-sync-hardening.test.ts index a8d951b0d..4e3ba4a49 100644 --- a/tests/codex-catalog-sync-hardening.test.ts +++ b/tests/codex-catalog-sync-hardening.test.ts @@ -163,6 +163,103 @@ describe("Codex catalog sync hardening", () => { expect(slugs).toContain("gpt-5.5"); }); + test("account rows reconcile independently from provider-outage preservation", () => { + const catalogPath = join(codexHome, "catalog.json"); + writeFileSync(join(codexHome, "config.toml"), 'model_catalog_json = "catalog.json"\n', "utf8"); + const accountDescription = "OpenAI native model bound to a Codex account namespace."; + const accountMarker = "account-selector-v1"; + writeFileSync(catalogPath, JSON.stringify({ + models: [ + { ...nativeEntry("gpt-5.5", 0), comp_hash: "native-compaction-hash" }, + routedEntry("vendor/stable-model", 5), + { ...routedEntry("foreign/gpt-5.5", 6), description: accountDescription }, + { + ...routedEntry("team/gpt-5.5", 7), + display_name: "Stale provider row with a colliding slug", + }, + { + ...nativeEntry("removed/gpt-5.5", 8), + description: accountDescription, + opencodex_catalog_kind: accountMarker, + }, + ], + }, null, 2) + "\n"); + + const r = runScript(codexHome, opencodexHome, ` + const { syncCatalogModels } = require("./src/codex/catalog"); + syncCatalogModels({ + providers: {}, + codexAccounts: [{ + id: "stored-team-account", + email: "private@example.test", + alias: "Private Display Name", + isMain: false + }], + codexAccountNamespaces: { + desktop: "@main", + team: "stored-team-account", + removed: "missing-account" + } + }).then(res => console.log(JSON.stringify(res))); + `); + expect(r.status).toBe(0); + expect(r.stderr).toContain("routed model fetch returned empty; preserving 2 existing routed entries"); + + const rows = JSON.parse(readFileSync(catalogPath, "utf8")).models as Array<{ + slug: string; + display_name?: string; + description?: string; + visibility?: string; + comp_hash?: string; + opencodex_catalog_kind?: string; + }>; + expect(rows.some(row => row.slug === "vendor/stable-model")).toBe(true); + expect(rows.some(row => row.slug === "foreign/gpt-5.5")).toBe(true); + expect(rows.some(row => row.slug === "removed/gpt-5.5")).toBe(false); + expect(rows.find(row => row.slug === "gpt-5.5")?.visibility).toBe("hide"); + expect(rows.find(row => row.slug === "desktop/gpt-5.5")?.visibility).toBe("list"); + expect(rows.find(row => row.slug === "team/gpt-5.5")).toMatchObject({ + display_name: "team / 5.5", + description: accountDescription, + opencodex_catalog_kind: accountMarker, + comp_hash: "native-compaction-hash", + visibility: "list", + }); + expect(rows.filter(row => row.slug === "team/gpt-5.5")).toHaveLength(1); + expect(JSON.stringify(rows)).not.toContain("stored-team-account"); + expect(JSON.stringify(rows)).not.toContain("private@example.test"); + }); + + test("non-OpenAI-only sync omits account rows without reprioritizing routed models", () => { + const catalogPath = join(codexHome, "catalog.json"); + writeFileSync(join(codexHome, "config.toml"), 'model_catalog_json = "catalog.json"\n', "utf8"); + writeFileSync(catalogPath, JSON.stringify({ models: [nativeEntry("gpt-5.5", 0)] }, null, 2) + "\n"); + + const r = runScript(codexHome, opencodexHome, ` + const { syncCatalogModels } = require("./src/codex/catalog"); + syncCatalogModels({ + providers: { + mock: { + adapter: "openai-chat", + baseUrl: "https://api.example.test/v1", + liveModels: false, + models: ["static-model"] + } + }, + codexAccountNamespaces: { desktop: "@main" } + }).then(res => console.log(JSON.stringify(res))); + `); + expect(r.status).toBe(0); + + const rows = JSON.parse(readFileSync(catalogPath, "utf8")).models as Array<{ + slug: string; + priority?: number; + }>; + expect(rows.find(row => row.slug === "mock/static-model")?.priority).toBe(5); + expect(rows.some(row => row.slug === "gpt-5.5")).toBe(false); + expect(rows.some(row => row.slug === "desktop/gpt-5.5")).toBe(false); + }); + test("default catalog path merges from disk instead of replacing it with bundled rows", () => { const catalogPath = join(codexHome, "opencodex-catalog.json"); writeFileSync(join(codexHome, "config.toml"), 'openai_base_url = "http://127.0.0.1:10100/v1"\n', "utf8"); diff --git a/tests/multi-agent-compat.test.ts b/tests/multi-agent-compat.test.ts index b76d1fe93..8a66cc9f4 100644 --- a/tests/multi-agent-compat.test.ts +++ b/tests/multi-agent-compat.test.ts @@ -10,7 +10,7 @@ import { join } from "node:path"; import { injectDeveloperMessage, multiAgentGuidanceText, sanitizeEncryptedContentInPlace } from "../src/server/responses"; import { parseRequest } from "../src/responses/parser"; import type { OcxParsedRequest } from "../src/types"; -import { effectiveSubagentRoster } from "../src/codex/catalog"; +import { CODEX_ACCOUNT_BOUND_CATALOG_DESCRIPTION, CODEX_ACCOUNT_BOUND_CATALOG_KIND, effectiveSubagentRoster } from "../src/codex/catalog"; import { clearDebugSettings, setDebugSettings } from "../src/lib/debug-settings"; import { getInjectionDebugLogEntries, @@ -51,6 +51,8 @@ type CatalogFixtureModel = { visibility?: "list" | "hide"; priority?: number; multiAgentVersion?: "v1" | "v2" | null; + description?: string; + accountBound?: boolean; }; /** Write an injected-catalog fixture into the active CODEX_HOME. */ @@ -65,6 +67,8 @@ function catalogFixture(dir: string, models: CatalogFixtureModel[]): void { // written (normalizeRoutedCatalogEntry deletes it). The production absent-key // path cannot be tested if the fixture rewrites it to "v2". ...(model.multiAgentVersion === undefined ? {} : { multi_agent_version: model.multiAgentVersion }), + ...(model.description ? { description: model.description } : {}), + ...(model.accountBound ? { opencodex_catalog_kind: CODEX_ACCOUNT_BOUND_CATALOG_KIND } : {}), supported_reasoning_levels: (model.efforts ?? []) .map(effort => ({ effort, description: effort })), })), @@ -200,6 +204,114 @@ describe("multiAgentGuidanceText", () => { } }); + test("bare native roles project onto account rows without matching arbitrary provider rows", async () => { + const dir = codexHomeFixture(V2_ON); + catalogFixture(dir, [ + { slug: "gpt-5.6-sol", visibility: "hide", priority: 0 }, + { + slug: "vendor/gpt-5.6-sol", + priority: 1, + description: CODEX_ACCOUNT_BOUND_CATALOG_DESCRIPTION, + }, + { + slug: "desktop/gpt-5.6-sol", + efforts: ["high", "max"], + priority: 2, + description: CODEX_ACCOUNT_BOUND_CATALOG_DESCRIPTION, + accountBound: true, + }, + { + slug: "team/gpt-5.6-sol", + efforts: ["high", "max"], + priority: 3, + description: CODEX_ACCOUNT_BOUND_CATALOG_DESCRIPTION, + accountBound: true, + }, + ]); + + const projected = effectiveSubagentRoster(["gpt-5.6-sol"], "v2"); + expect(projected.advertised.map(model => model.model)).toEqual([ + "desktop/gpt-5.6-sol", + "team/gpt-5.6-sol", + ]); + expect(effectiveSubagentRoster(["team/gpt-5.6-sol"], "v2").advertised.map(m => m.model)) + .toEqual(["team/gpt-5.6-sol"]); + + const text = await multiAgentGuidanceText( + parsedFixture({ tools: [{ name: "spawn_agent" }] }), + { + injectionModel: "gpt-5.6-sol", + codexAccountNamespace: "team", + subagentModels: ["gpt-5.6-sol"], + subagentModelFallback: ["kimi/k3"], + }, + ); + expect(text).toContain('Preferred sub-agent: model "team/gpt-5.6-sol"'); + expect(text).toContain('"desktop/gpt-5.6-sol", "team/gpt-5.6-sol"'); + expect(text).not.toContain('"vendor/gpt-5.6-sol"'); + expect(text).toContain("kimi/k3"); + + const ambiguous = await multiAgentGuidanceText( + parsedFixture({ tools: [{ name: "spawn_agent" }] }), + { injectionModel: "gpt-5.6-sol" }, + ); + expect(ambiguous).toBeNull(); + }); + + test("account projection never widens the five-model spawn candidate window", () => { + const dir = codexHomeFixture(V2_ON); + catalogFixture(dir, [ + ...Array.from({ length: 5 }, (_, index) => ({ + slug: `filler-${index}`, + priority: index, + })), + { slug: "gpt-5.6-sol", visibility: "hide", priority: 5 }, + { + slug: "desktop/gpt-5.6-sol", + priority: 6, + description: CODEX_ACCOUNT_BOUND_CATALOG_DESCRIPTION, + accountBound: true, + }, + ]); + + const effective = effectiveSubagentRoster(["gpt-5.6-sol"], "v2"); + expect(effective.advertised).toEqual([]); + expect(effective.excluded).toEqual([{ + configured: "gpt-5.6-sol", + catalogModel: "desktop/gpt-5.6-sol", + reason: "outside_display_limit", + }]); + }); + + test("bare preference never chooses an exact account from a truncated projection", async () => { + const dir = codexHomeFixture(V2_ON); + catalogFixture(dir, [ + ...Array.from({ length: 4 }, (_, index) => ({ + slug: `filler-${index}`, + priority: index, + })), + { + slug: "desktop/gpt-5.6-sol", + priority: 4, + description: CODEX_ACCOUNT_BOUND_CATALOG_DESCRIPTION, + accountBound: true, + }, + { + slug: "team/gpt-5.6-sol", + priority: 5, + description: CODEX_ACCOUNT_BOUND_CATALOG_DESCRIPTION, + accountBound: true, + }, + ]); + + expect(effectiveSubagentRoster(["gpt-5.6-sol"], "v2").advertised.map(m => m.model)) + .toEqual(["desktop/gpt-5.6-sol"]); + expect(await multiAgentGuidanceText( + parsedFixture({ tools: [{ name: "spawn_agent" }] }), + { injectionModel: "gpt-5.6-sol" }, + )).toBeNull(); + }); + test("effective roster applies alias, visibility, v2 compatibility, stable priority, cap, and diagnostics", async () => { const dir = codexHomeFixture(V2_ON); catalogFixture(dir, [ diff --git a/tests/native-model-toggle.test.ts b/tests/native-model-toggle.test.ts index 1d1066aa0..4872252ac 100644 --- a/tests/native-model-toggle.test.ts +++ b/tests/native-model-toggle.test.ts @@ -1,10 +1,16 @@ import { describe, expect, test } from "bun:test"; import { + accountBoundNativeDisplayName, + accountBoundNativeModelSlugs, applyNativeVisibility, + buildCatalogEntries, + CODEX_ACCOUNT_BOUND_CATALOG_DESCRIPTION, + CODEX_ACCOUNT_BOUND_CATALOG_KIND, disabledNativeSlugs, mergeCatalogEntriesForSync, NATIVE_OPENAI_MODELS, nativeModelRows, + visibleCodexAccountSelectors, visibleNativeSlugs, } from "../src/codex/catalog"; import { handleManagementAPI } from "../src/server/management-api"; @@ -56,6 +62,69 @@ describe("native GPT model toggles (bare slugs in disabledModels)", () => { expect(rows.find(r => r.slug === "gpt-5.6-sol")?.contextWindow).toBe(372_000); }); + test("configured public selectors replace bare picker rows with account-qualified native clones", () => { + const template = nativeTemplate(); + template.comp_hash = "native-compaction-hash"; + const entries = buildCatalogEntries( + template, + ["gpt-5.5"], + [{ provider: "litellm-local", id: "qwen3.6" }], + ["gpt-5.5"], + false, + "default", + new Set(), + ["main-account", "side.account"], + ); + applyNativeVisibility(entries, new Set(), true); + + const bare = entries.find(entry => entry.slug === "gpt-5.5"); + const main = entries.find(entry => entry.slug === "main-account/gpt-5.5"); + const side = entries.find(entry => entry.slug === "side.account/gpt-5.5"); + const routed = entries.find(entry => entry.slug === "litellm-local/qwen3.6"); + expect(bare?.visibility).toBe("hide"); + expect(main).toMatchObject({ + display_name: "main-account / 5.5", + description: CODEX_ACCOUNT_BOUND_CATALOG_DESCRIPTION, + opencodex_catalog_kind: CODEX_ACCOUNT_BOUND_CATALOG_KIND, + comp_hash: "native-compaction-hash", + visibility: "list", + priority: 0, + }); + expect(side?.display_name).toBe("side.account / 5.5"); + expect(side?.priority).toBe(1); + expect(side?.model_messages).toEqual(bare?.model_messages); + expect(routed?.priority).toBeGreaterThan(side?.priority as number); + expect(entries.every(entry => Number.isInteger(entry.priority))).toBe(true); + }); + + test("case-distinct routing selectors remain distinguishable in picker labels", () => { + expect(accountBoundNativeDisplayName("work", nativeTemplate())).toBe("work / 5.5"); + expect(accountBoundNativeDisplayName("Work", nativeTemplate())).toBe("Work / 5.5"); + }); + + test("catalog discovery uses public selectors only and drops mappings to missing accounts", () => { + const config = { + codexAccounts: [{ + id: "stored-side-account", + email: "private@example.test", + alias: "Private Display Name", + isMain: false, + }], + codexAccountNamespaces: { + desktop: "@main", + team: "stored-side-account", + removed: "missing-account", + }, + }; + expect(visibleCodexAccountSelectors(config)).toEqual(["desktop", "team"]); + expect(accountBoundNativeModelSlugs(config, ["gpt-5.5"])).toEqual([ + "desktop/gpt-5.5", + "team/gpt-5.5", + ]); + expect(JSON.stringify(accountBoundNativeModelSlugs(config, ["gpt-5.5"]))) + .not.toContain("stored-side-account"); + }); + test("catalog sync flips supported natives to visibility hide and restores list on re-enable", () => { const native = nativeTemplate(); const disabledOnce = mergeCatalogEntriesForSync( @@ -108,6 +177,17 @@ describe("native GPT model toggles (bare slugs in disabledModels)", () => { expect(entries[1].visibility).toBe("list"); }); + test("disabled native state is mirrored onto its account-qualified clones", () => { + const entries = [{ + slug: "side/gpt-5.6-sol", + description: CODEX_ACCOUNT_BOUND_CATALOG_DESCRIPTION, + opencodex_catalog_kind: CODEX_ACCOUNT_BOUND_CATALOG_KIND, + visibility: "list", + }]; + applyNativeVisibility(entries, new Set(["gpt-5.6-sol"]), true); + expect(entries[0].visibility).toBe("hide"); + }); + test("management API surfaces: /api/models leads with native rows; subagent available drops disabled bare slugs", async () => { const config = makeConfig({ disabledModels: ["gpt-5.6-sol"] }); From 55c4cc1eda700b5de8c699cc9c73d60ccc3886a6 Mon Sep 17 00:00:00 2001 From: chrisae9 Date: Mon, 3 Aug 2026 14:54:47 -0400 Subject: [PATCH 02/13] fix(codex): harden account catalog projection --- src/codex/catalog.ts | 2 +- src/codex/catalog/account-models.ts | 21 ++---- src/codex/catalog/metadata.ts | 20 +++-- src/codex/catalog/parsing.ts | 6 +- src/codex/catalog/sync.ts | 32 ++++++-- src/server/responses/collaboration.ts | 14 +++- tests/claude-models-discovery.test.ts | 3 +- tests/codex-catalog-sync-hardening.test.ts | 87 +++++++++++++++++++--- tests/multi-agent-compat.test.ts | 28 ++++--- tests/native-model-toggle.test.ts | 68 ++++++++++++++++- 10 files changed, 225 insertions(+), 56 deletions(-) diff --git a/src/codex/catalog.ts b/src/codex/catalog.ts index 5294e16b7..e207b7329 100644 --- a/src/codex/catalog.ts +++ b/src/codex/catalog.ts @@ -10,4 +10,4 @@ export { deriveComboCatalogModel, exactComboCatalogSlugs, getLastComboCatalogOmi 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 type { SpawnAgentSurface, SubagentRosterExclusionReason, EffectiveSubagentModel, SubagentRosterExclusion, EffectiveSubagentRoster } from "./catalog/sync"; -export { accountBoundNativeDisplayName, accountBoundNativeModelSlugs, CODEX_ACCOUNT_BOUND_CATALOG_DESCRIPTION, CODEX_ACCOUNT_BOUND_CATALOG_KIND, trustedAccountBoundNativeCatalogSlug, visibleCodexAccountSelectors } from "./catalog/account-models"; +export { accountBoundNativeDisplayName, accountBoundNativeModelSlugs, CODEX_ACCOUNT_BOUND_CATALOG_KIND, trustedAccountBoundNativeCatalogSlug, visibleCodexAccountSelectors } from "./catalog/account-models"; diff --git a/src/codex/catalog/account-models.ts b/src/codex/catalog/account-models.ts index 45cd7d076..24afec4cf 100644 --- a/src/codex/catalog/account-models.ts +++ b/src/codex/catalog/account-models.ts @@ -2,9 +2,7 @@ import type { OcxConfig } from "../../types"; import { isMainCodexAccountTarget } from "../account-namespaces"; import type { RawEntry } from "./parsing"; -/** Stable marker used to distinguish generated account rows from provider-owned catalog rows. */ -export const CODEX_ACCOUNT_BOUND_CATALOG_DESCRIPTION = - "OpenAI native model bound to a Codex account namespace."; +/** Stable nonsemantic marker used to distinguish generated rows from provider-owned rows. */ export const CODEX_ACCOUNT_BOUND_CATALOG_KIND = "account-selector-v1"; /** @@ -42,18 +40,15 @@ export function accountBoundNativeDisplayName(selector: string, native: RawEntry return `${selector} / ${model}`; } -function accountBoundNativeCatalogSlug(entry: RawEntry): string | undefined { - if (entry.description !== CODEX_ACCOUNT_BOUND_CATALOG_DESCRIPTION - || typeof entry.slug !== "string") return undefined; - const slash = entry.slug.indexOf("/"); - return slash > 0 ? entry.slug.slice(slash + 1) : undefined; -} - /** Identify current generated rows without changing Codex's semantic model fields. */ export function trustedAccountBoundNativeCatalogSlug(entry: RawEntry): string | undefined { - return entry.opencodex_catalog_kind === CODEX_ACCOUNT_BOUND_CATALOG_KIND - ? accountBoundNativeCatalogSlug(entry) - : undefined; + if (entry.opencodex_catalog_kind !== CODEX_ACCOUNT_BOUND_CATALOG_KIND + || typeof entry.slug !== "string") return undefined; + const slash = entry.slug.indexOf("/"); + if (slash <= 0 || slash !== entry.slug.lastIndexOf("/") || slash === entry.slug.length - 1) { + return undefined; + } + return entry.slug.slice(slash + 1); } export function accountBoundNativeModelSlugs( diff --git a/src/codex/catalog/metadata.ts b/src/codex/catalog/metadata.ts index b29e5826a..244158c38 100644 --- a/src/codex/catalog/metadata.ts +++ b/src/codex/catalog/metadata.ts @@ -126,9 +126,14 @@ export function visibleNativeSlugs(config: Pick): s export function shouldIncludeNativeOpenAi(config: Pick): boolean { const enabledProviders = Object.entries(config.providers) .filter(([, provider]) => provider.disabled !== true); - return enabledProviders.length === 0 || enabledProviders.some(([name, provider]) => - name === OPENAI_CODEX_PROVIDER_ID && isCanonicalOpenAiForwardProvider(provider) - ); + return enabledProviders.length === 0 || enabledProviders.some(([name, provider]) => { + if (name !== OPENAI_CODEX_PROVIDER_ID) return false; + // Registry routing defaults an omitted authMode on the built-in OpenAI row to forward. + const canonical = provider.authMode === undefined + ? { ...provider, authMode: "forward" as const } + : provider; + return isCanonicalOpenAiForwardProvider(canonical); + }); } /** Native slugs exposed to Claude Desktop show/export/apply (opt-out via claudeCode.desktopNativeModels). */ @@ -193,8 +198,13 @@ export function nativeOpenAiSlugs(): string[] { export function listCatalogNativeSlugs(): string[] { const cat = readCurrentCatalogOrCache(); - const live = filterSupportedNativeSlugs(cat?.models ?? []); + const models = cat?.models ?? []; + const live = filterSupportedNativeSlugs(models); + const accountBound = models.flatMap(entry => { + const slug = trustedAccountBoundNativeCatalogSlug(entry); + return slug !== undefined && SUPPORTED_NATIVE_OPENAI_SLUGS.has(slug) ? [slug] : []; + }); // Ensure documented additions (e.g. gpt-5.3-codex-spark) appear even when the bundled catalog // predates the slug — mirrors nativeOpenAiSlugs() which already merges them for /v1/models. - return unique([...live, ...DOCUMENTED_NATIVE_OPENAI_ADDITIONS]); + return unique([...live, ...accountBound, ...DOCUMENTED_NATIVE_OPENAI_ADDITIONS]); } diff --git a/src/codex/catalog/parsing.ts b/src/codex/catalog/parsing.ts index 777018156..41251cbbb 100644 --- a/src/codex/catalog/parsing.ts +++ b/src/codex/catalog/parsing.ts @@ -245,8 +245,10 @@ export function isNativeOpenAiEntry(entry: RawEntry): boolean { } export function applyNativeOpenAiContextOverride(entry: RawEntry): void { - if (!isNativeOpenAiEntry(entry)) return; - const override = NATIVE_OPENAI_CONTEXT_OVERRIDES[entry.slug as string]; + const nativeSlug = trustedAccountBoundNativeCatalogSlug(entry) + ?? (isNativeOpenAiEntry(entry) ? entry.slug as string : undefined); + if (!nativeSlug) return; + const override = NATIVE_OPENAI_CONTEXT_OVERRIDES[nativeSlug]; if (!override) return; if (typeof override.contextWindow === "number") { entry.context_window = override.contextWindow; diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index 5e8ff4223..aab30008a 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -39,7 +39,7 @@ import { applyCatalogModelMetadata, applyReasoningLevels, catalogEntryEfforts, c import { clearGatherRoutedModelsInflight, filterCatalogVisibleModels, gatherRoutedModels, lastDropWarnSignature } from "./provider-fetch"; import { clearLastComboCatalogOmissions, comboCatalogWarningSignatures, comboMasqueradeCollisionWarnings, exactComboCatalogSlugs, openAiApiCollisionWarnings, resolveSlugAliasCollisions, slugAliasCollisionWarnings, warnComboMasqueradeCollisionOnce } from "./aggregation"; import type { ComboCatalogOmission } from "./aggregation"; -import { accountBoundNativeDisplayName, CODEX_ACCOUNT_BOUND_CATALOG_DESCRIPTION, CODEX_ACCOUNT_BOUND_CATALOG_KIND, trustedAccountBoundNativeCatalogSlug, visibleCodexAccountSelectors } from "./account-models"; +import { accountBoundNativeDisplayName, CODEX_ACCOUNT_BOUND_CATALOG_KIND, trustedAccountBoundNativeCatalogSlug, visibleCodexAccountSelectors } from "./account-models"; export const MAX_SPAWN_AGENT_MODEL_OVERRIDES = 5; @@ -283,6 +283,7 @@ export function buildCatalogEntries( // ARRAY order is discarded — so "featuring" a model = giving it the LOWEST priority (0..N-1) so // it sorts to the front. This works for native gpt slugs AND routed slugs alike. const rank = new Map((featured ?? []).map((slug, i) => [slug, i] as const)); + const priorityStride = Math.max(accountSelectors.length, 1); const out: RawEntry[] = []; const nativeEntries: RawEntry[] = []; const collisionSkipped = resolveSlugAliasCollisions(goModels); @@ -302,14 +303,13 @@ export function buildCatalogEntries( const catalogSlug = `${selector}/${nativeSlug}`; e.slug = catalogSlug; e.display_name = accountBoundNativeDisplayName(selector, native); - e.description = CODEX_ACCOUNT_BOUND_CATALOG_DESCRIPTION; // Codex ignores this OpenCodex extension; preserve the native comp_hash unchanged. e.opencodex_catalog_kind = CODEX_ACCOUNT_BOUND_CATALOG_KIND; const exactRank = rank.get(catalogSlug); const inheritedRank = rank.get(nativeSlug); const featuredRank = exactRank ?? inheritedRank; e.priority = featuredRank !== undefined - ? featuredRank * accountSelectors.length + selectorIndex + ? featuredRank * priorityStride + selectorIndex : ((featured?.length ?? 0) + nativeIndex) * accountSelectors.length + selectorIndex; e.visibility = "list"; out.push(e); @@ -334,7 +334,7 @@ export function buildCatalogEntries( ); // Featured picks may be stored raw (legacy) or encoded — honor both. const rankHit = rank.get(slug) ?? rank.get(`${m.provider}/${m.id}`); - if (rankHit !== undefined) e.priority = rankHit; + if (rankHit !== undefined) e.priority = rankHit * priorityStride; else if (accountSelectors.length > 0) { // Keep the generated account rows together in Codex's priority-sorted flat picker. e.priority = 1_000 + (typeof e.priority === "number" ? e.priority : 5); @@ -475,6 +475,22 @@ export function mergeCatalogEntriesForSync( } } + const nativeBySlug = new Map(native.flatMap(entry => + typeof entry.slug === "string" ? [[entry.slug, entry] as const] : [] + )); + const alignedAccountBoundEntries = accountBoundEntries.map(entry => { + const nativeSlug = trustedAccountBoundNativeCatalogSlug(entry); + const source = nativeSlug === undefined ? undefined : nativeBySlug.get(nativeSlug); + if (!source) return entry; + const aligned = JSON.parse(JSON.stringify(source)) as RawEntry; + aligned.slug = entry.slug; + aligned.display_name = entry.display_name; + aligned.priority = entry.priority; + aligned.visibility = "list"; + aligned.opencodex_catalog_kind = CODEX_ACCOUNT_BOUND_CATALOG_KIND; + return aligned; + }); + const freshSlugs = new Set( routedEntries.flatMap(entry => typeof entry.slug === "string" ? [entry.slug] : []), ); @@ -523,7 +539,7 @@ export function mergeCatalogEntriesForSync( finalRoutedEntries = finalRoutedEntries.filter(entry => typeof entry.slug !== "string" || !isRoutedModelCompatibilityExcluded(entry.slug) ); - const accountBoundSlugs = new Set(accountBoundEntries.flatMap(entry => + const accountBoundSlugs = new Set(alignedAccountBoundEntries.flatMap(entry => typeof entry.slug === "string" ? [entry.slug] : [] )); finalRoutedEntries = finalRoutedEntries.filter(entry => @@ -533,14 +549,14 @@ export function mergeCatalogEntriesForSync( console.warn(`[opencodex] catalog sync: routed model fetch returned empty; preserving ${finalRoutedEntries.length} existing routed entr${finalRoutedEntries.length === 1 ? "y" : "ies"} on disk.`); } - const managedEntries = [...finalRoutedEntries, ...accountBoundEntries]; + const managedEntries = [...finalRoutedEntries, ...alignedAccountBoundEntries]; const mergedEntries = [...native, ...managedEntries].map(m => { const normalized = normalizeServiceTiers(m); applyNativeOpenAiContextOverride(normalized); const exactCombo = typeof m.slug === "string" && exactComboSlugs.has(m.slug); const e = ensureStrictCatalogFields(normalized, { preserveExactInputModalities: exactCombo, - isRouted: managedEntries.includes(m), + isRouted: finalRoutedEntries.includes(m), }); // Mock-max universality (260709): preserved routed entries from disk may predate // the max rung — ensure it here so subagent max spawns validate on every @@ -566,7 +582,7 @@ export function mergeCatalogEntriesForSync( // Native enable/disable (single choke point: bare slugs in `disabledModels`). Runs as the // LAST pass so the upstream-upgrade branch above can never clobber a hide flag back to list. return applyMultiAgentMode( - applyNativeVisibility(mergedEntries, disabledNative, accountBoundEntries.length > 0), + applyNativeVisibility(mergedEntries, disabledNative, alignedAccountBoundEntries.length > 0), multiAgentMode, isMultiAgentV2Enabled(), ); diff --git a/src/server/responses/collaboration.ts b/src/server/responses/collaboration.ts index 00f12d5e2..7d4277307 100644 --- a/src/server/responses/collaboration.ts +++ b/src/server/responses/collaboration.ts @@ -60,6 +60,7 @@ import { fetchWithResetRetry, fetchWithTransientRetry, applyUpstreamRecoveryInit import { ForwardAdmissionCredentialError, validateForwardAdmissionCredential } from "../auth-cors"; import { listOpenAiForwardSidecarCandidates, resolveFirstUsableOpenAiSidecar, type ResolvedOpenAiForwardSidecar } from "../../providers/openai-sidecar"; import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers"; +import { slugsEquivalent } from "../../providers/slug-codec"; import { subagentFallbackGuidanceText } from "../../codex/subagent-model-fallback"; import { applyOpenAiVirtualModel, resolveOpenAiCompactModel } from "../../providers/openai-virtual-models"; import { isUsageDebugEnabled } from "../../usage/debug"; @@ -262,7 +263,18 @@ export async function multiAgentGuidanceText( ? await resolveRoster([injectionModel], "v2") : effective : undefined; - const rosterModels = (subagentEffective?.advertised ?? []).filter(withinCandidateWindow); + const explicitlyConfigured = (candidate: EffectiveSubagentModel): boolean => + configuredSubagents.some(model => + model.includes("/") && slugsEquivalent(model, candidate.model) + ); + const allowedForCurrentRoute = (candidate: EffectiveSubagentModel): boolean => + explicitlyConfigured(candidate) + || !candidate.model.includes("/") + || (codexAccountNamespace !== undefined + && candidate.model.startsWith(`${codexAccountNamespace}/`)); + const rosterModels = (subagentEffective?.advertised ?? []) + .filter(withinCandidateWindow) + .filter(allowedForCurrentRoute); const roster = subagentRosterText(rosterModels); const preferredCandidates = (preferredEffective?.advertised ?? []).filter(withinCandidateWindow); const preferred = injectionModel?.includes("/") diff --git a/tests/claude-models-discovery.test.ts b/tests/claude-models-discovery.test.ts index d38f339a2..83a1e056b 100644 --- a/tests/claude-models-discovery.test.ts +++ b/tests/claude-models-discovery.test.ts @@ -161,8 +161,7 @@ test("configured account selectors appear in OpenAI and Codex discovery without config.providers.openai = { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", - authMode: "forward", - codexAccountMode: "pool", + liveModels: false, }; config.codexAccounts = [{ id: "stored-side-account", diff --git a/tests/codex-catalog-sync-hardening.test.ts b/tests/codex-catalog-sync-hardening.test.ts index 4e3ba4a49..2f6ce6bda 100644 --- a/tests/codex-catalog-sync-hardening.test.ts +++ b/tests/codex-catalog-sync-hardening.test.ts @@ -163,32 +163,56 @@ describe("Codex catalog sync hardening", () => { expect(slugs).toContain("gpt-5.5"); }); - test("account rows reconcile independently from provider-outage preservation", () => { + test("account rows reconcile idempotently and independently from provider outages", () => { const catalogPath = join(codexHome, "catalog.json"); writeFileSync(join(codexHome, "config.toml"), 'model_catalog_json = "catalog.json"\n', "utf8"); - const accountDescription = "OpenAI native model bound to a Codex account namespace."; const accountMarker = "account-selector-v1"; writeFileSync(catalogPath, JSON.stringify({ models: [ - { ...nativeEntry("gpt-5.5", 0), comp_hash: "native-compaction-hash" }, + { + ...nativeEntry("gpt-5.5", 0), + comp_hash: "native-5.5-hash", + base_instructions: "Native 5.5 instructions", + model_messages: { instructions_template: "Native 5.5 instructions" }, + tool_mode: null, + context_window: 128_000, + max_context_window: 128_000, + auto_compact_token_limit: 115_200, + }, + { + ...nativeEntry("gpt-5.4", 1), + comp_hash: "native-5.4-hash", + base_instructions: "Native 5.4 instructions", + model_messages: { instructions_template: "Native 5.4 instructions" }, + tool_mode: "code_mode_only", + }, + nativeEntry("gpt-5.4-mini", 2), routedEntry("vendor/stable-model", 5), - { ...routedEntry("foreign/gpt-5.5", 6), description: accountDescription }, + { ...routedEntry("foreign/gpt-5.5", 6), description: "Foreign provider description" }, { ...routedEntry("team/gpt-5.5", 7), display_name: "Stale provider row with a colliding slug", }, { ...nativeEntry("removed/gpt-5.5", 8), - description: accountDescription, + description: "Retired generated row", opencodex_catalog_kind: accountMarker, }, ], }, null, 2) + "\n"); const r = runScript(codexHome, opencodexHome, ` + const { readFileSync } = require("node:fs"); const { syncCatalogModels } = require("./src/codex/catalog"); - syncCatalogModels({ - providers: {}, + const catalogPath = ${JSON.stringify(catalogPath)}; + const config = { + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + liveModels: false + } + }, codexAccounts: [{ id: "stored-team-account", email: "private@example.test", @@ -200,7 +224,13 @@ describe("Codex catalog sync hardening", () => { team: "stored-team-account", removed: "missing-account" } - }).then(res => console.log(JSON.stringify(res))); + }; + syncCatalogModels(config) + .then(() => { + const firstRows = JSON.parse(readFileSync(catalogPath, "utf8")).models; + return syncCatalogModels(config).then(res => ({ firstRows, res })); + }) + .then(output => console.log(JSON.stringify(output))); `); expect(r.status).toBe(0); expect(r.stderr).toContain("routed model fetch returned empty; preserving 2 existing routed entries"); @@ -212,20 +242,55 @@ describe("Codex catalog sync hardening", () => { visibility?: string; comp_hash?: string; opencodex_catalog_kind?: string; + base_instructions?: string; + model_messages?: { instructions_template?: string }; + tool_mode?: string | null; + context_window?: number; + max_context_window?: number; + auto_compact_token_limit?: number; }>; + const firstRows = (JSON.parse(r.stdout) as { firstRows: typeof rows }).firstRows; + const firstBare = firstRows.find(row => row.slug === "gpt-5.5"); + const firstTeam = firstRows.find(row => row.slug === "team/gpt-5.5"); + expect(firstBare).toMatchObject({ + context_window: 272_000, + max_context_window: 272_000, + auto_compact_token_limit: 244_800, + }); + expect(firstTeam).toMatchObject({ + context_window: firstBare?.context_window, + max_context_window: firstBare?.max_context_window, + auto_compact_token_limit: firstBare?.auto_compact_token_limit, + }); expect(rows.some(row => row.slug === "vendor/stable-model")).toBe(true); expect(rows.some(row => row.slug === "foreign/gpt-5.5")).toBe(true); expect(rows.some(row => row.slug === "removed/gpt-5.5")).toBe(false); expect(rows.find(row => row.slug === "gpt-5.5")?.visibility).toBe("hide"); expect(rows.find(row => row.slug === "desktop/gpt-5.5")?.visibility).toBe("list"); - expect(rows.find(row => row.slug === "team/gpt-5.5")).toMatchObject({ + const bare = rows.find(row => row.slug === "gpt-5.5"); + const team = rows.find(row => row.slug === "team/gpt-5.5"); + expect(team).toMatchObject({ display_name: "team / 5.5", - description: accountDescription, opencodex_catalog_kind: accountMarker, - comp_hash: "native-compaction-hash", + comp_hash: "native-5.5-hash", visibility: "list", }); + expect(team?.description).toBe(bare?.description); expect(rows.filter(row => row.slug === "team/gpt-5.5")).toHaveLength(1); + for (const selector of ["desktop", "team"]) { + expect(rows.some(row => row.slug === `${selector}/gpt-5.4`)).toBe(true); + expect(rows.some(row => row.slug === `${selector}/gpt-5.4-mini`)).toBe(true); + } + for (const nativeSlug of ["gpt-5.5", "gpt-5.4"]) { + const native = rows.find(row => row.slug === nativeSlug); + const qualified = rows.find(row => row.slug === `team/${nativeSlug}`); + expect(qualified).toMatchObject({ + comp_hash: native?.comp_hash, + base_instructions: native?.base_instructions, + model_messages: native?.model_messages, + tool_mode: native?.tool_mode, + }); + } expect(JSON.stringify(rows)).not.toContain("stored-team-account"); expect(JSON.stringify(rows)).not.toContain("private@example.test"); }); diff --git a/tests/multi-agent-compat.test.ts b/tests/multi-agent-compat.test.ts index 8a66cc9f4..4f1f699bb 100644 --- a/tests/multi-agent-compat.test.ts +++ b/tests/multi-agent-compat.test.ts @@ -10,7 +10,7 @@ import { join } from "node:path"; import { injectDeveloperMessage, multiAgentGuidanceText, sanitizeEncryptedContentInPlace } from "../src/server/responses"; import { parseRequest } from "../src/responses/parser"; import type { OcxParsedRequest } from "../src/types"; -import { CODEX_ACCOUNT_BOUND_CATALOG_DESCRIPTION, CODEX_ACCOUNT_BOUND_CATALOG_KIND, effectiveSubagentRoster } from "../src/codex/catalog"; +import { CODEX_ACCOUNT_BOUND_CATALOG_KIND, effectiveSubagentRoster } from "../src/codex/catalog"; import { clearDebugSettings, setDebugSettings } from "../src/lib/debug-settings"; import { getInjectionDebugLogEntries, @@ -51,7 +51,6 @@ type CatalogFixtureModel = { visibility?: "list" | "hide"; priority?: number; multiAgentVersion?: "v1" | "v2" | null; - description?: string; accountBound?: boolean; }; @@ -67,7 +66,6 @@ function catalogFixture(dir: string, models: CatalogFixtureModel[]): void { // written (normalizeRoutedCatalogEntry deletes it). The production absent-key // path cannot be tested if the fixture rewrites it to "v2". ...(model.multiAgentVersion === undefined ? {} : { multi_agent_version: model.multiAgentVersion }), - ...(model.description ? { description: model.description } : {}), ...(model.accountBound ? { opencodex_catalog_kind: CODEX_ACCOUNT_BOUND_CATALOG_KIND } : {}), supported_reasoning_levels: (model.efforts ?? []) .map(effort => ({ effort, description: effort })), @@ -211,20 +209,17 @@ describe("multiAgentGuidanceText", () => { { slug: "vendor/gpt-5.6-sol", priority: 1, - description: CODEX_ACCOUNT_BOUND_CATALOG_DESCRIPTION, }, { slug: "desktop/gpt-5.6-sol", efforts: ["high", "max"], priority: 2, - description: CODEX_ACCOUNT_BOUND_CATALOG_DESCRIPTION, accountBound: true, }, { slug: "team/gpt-5.6-sol", efforts: ["high", "max"], priority: 3, - description: CODEX_ACCOUNT_BOUND_CATALOG_DESCRIPTION, accountBound: true, }, ]); @@ -247,10 +242,26 @@ describe("multiAgentGuidanceText", () => { }, ); expect(text).toContain('Preferred sub-agent: model "team/gpt-5.6-sol"'); - expect(text).toContain('"desktop/gpt-5.6-sol", "team/gpt-5.6-sol"'); + expect(text).toContain('"team/gpt-5.6-sol"'); + expect(text).not.toContain('"desktop/gpt-5.6-sol"'); expect(text).not.toContain('"vendor/gpt-5.6-sol"'); expect(text).toContain("kimi/k3"); + const bareParent = await multiAgentGuidanceText( + parsedFixture({ tools: [{ name: "spawn_agent" }] }), + { subagentModels: ["gpt-5.6-sol"] }, + ); + expect(bareParent).toBeNull(); + + const explicitCrossAccount = await multiAgentGuidanceText( + parsedFixture({ tools: [{ name: "spawn_agent" }] }), + { + codexAccountNamespace: "team", + subagentModels: ["desktop/gpt-5.6-sol"], + }, + ); + expect(explicitCrossAccount).toContain('"desktop/gpt-5.6-sol"'); + const ambiguous = await multiAgentGuidanceText( parsedFixture({ tools: [{ name: "spawn_agent" }] }), { injectionModel: "gpt-5.6-sol" }, @@ -269,7 +280,6 @@ describe("multiAgentGuidanceText", () => { { slug: "desktop/gpt-5.6-sol", priority: 6, - description: CODEX_ACCOUNT_BOUND_CATALOG_DESCRIPTION, accountBound: true, }, ]); @@ -293,13 +303,11 @@ describe("multiAgentGuidanceText", () => { { slug: "desktop/gpt-5.6-sol", priority: 4, - description: CODEX_ACCOUNT_BOUND_CATALOG_DESCRIPTION, accountBound: true, }, { slug: "team/gpt-5.6-sol", priority: 5, - description: CODEX_ACCOUNT_BOUND_CATALOG_DESCRIPTION, accountBound: true, }, ]); diff --git a/tests/native-model-toggle.test.ts b/tests/native-model-toggle.test.ts index 4872252ac..f3161f5be 100644 --- a/tests/native-model-toggle.test.ts +++ b/tests/native-model-toggle.test.ts @@ -4,12 +4,13 @@ import { accountBoundNativeModelSlugs, applyNativeVisibility, buildCatalogEntries, - CODEX_ACCOUNT_BOUND_CATALOG_DESCRIPTION, CODEX_ACCOUNT_BOUND_CATALOG_KIND, disabledNativeSlugs, mergeCatalogEntriesForSync, NATIVE_OPENAI_MODELS, nativeModelRows, + shouldIncludeNativeOpenAi, + trustedAccountBoundNativeCatalogSlug, visibleCodexAccountSelectors, visibleNativeSlugs, } from "../src/codex/catalog"; @@ -84,12 +85,12 @@ describe("native GPT model toggles (bare slugs in disabledModels)", () => { expect(bare?.visibility).toBe("hide"); expect(main).toMatchObject({ display_name: "main-account / 5.5", - description: CODEX_ACCOUNT_BOUND_CATALOG_DESCRIPTION, opencodex_catalog_kind: CODEX_ACCOUNT_BOUND_CATALOG_KIND, comp_hash: "native-compaction-hash", visibility: "list", priority: 0, }); + expect(main?.description).toBe(bare?.description); expect(side?.display_name).toBe("side.account / 5.5"); expect(side?.priority).toBe(1); expect(side?.model_messages).toEqual(bare?.model_messages); @@ -97,6 +98,68 @@ describe("native GPT model toggles (bare slugs in disabledModels)", () => { expect(entries.every(entry => Number.isInteger(entry.priority))).toBe(true); }); + test("featured routed rows follow complete account-qualified priority groups", () => { + const entries = buildCatalogEntries( + nativeTemplate(), + ["gpt-5.5"], + [{ provider: "vendor", id: "model" }], + ["gpt-5.5", "vendor/model"], + false, + "default", + new Set(), + ["one", "two", "three"], + ); + applyNativeVisibility(entries, new Set(), true); + + const visible = entries + .filter(entry => entry.visibility === "list") + .sort((left, right) => Number(left.priority) - Number(right.priority)); + expect(visible.slice(0, 4).map(entry => entry.slug)).toEqual([ + "one/gpt-5.5", + "two/gpt-5.5", + "three/gpt-5.5", + "vendor/model", + ]); + expect(visible.slice(0, 4).map(entry => entry.priority)).toEqual([0, 1, 2, 3]); + }); + + test("generated-row ownership uses only the nonsemantic marker and qualified slug shape", () => { + expect(trustedAccountBoundNativeCatalogSlug({ + slug: "side/gpt-5.6-sol", + opencodex_catalog_kind: CODEX_ACCOUNT_BOUND_CATALOG_KIND, + })).toBe("gpt-5.6-sol"); + expect(trustedAccountBoundNativeCatalogSlug({ slug: "side/gpt-5.6-sol" })).toBeUndefined(); + expect(trustedAccountBoundNativeCatalogSlug({ + slug: "gpt-5.6-sol", + opencodex_catalog_kind: CODEX_ACCOUNT_BOUND_CATALOG_KIND, + })).toBeUndefined(); + expect(trustedAccountBoundNativeCatalogSlug({ + slug: "side/nested/gpt-5.6-sol", + opencodex_catalog_kind: CODEX_ACCOUNT_BOUND_CATALOG_KIND, + })).toBeUndefined(); + }); + + test("native availability mirrors the built-in OpenAI auth-mode default", () => { + const canonical = { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + } as const; + expect(shouldIncludeNativeOpenAi({ providers: {} })).toBe(true); + expect(shouldIncludeNativeOpenAi({ providers: { openai: canonical } })).toBe(true); + expect(shouldIncludeNativeOpenAi({ + providers: { openai: { ...canonical, authMode: "forward" } }, + })).toBe(true); + expect(shouldIncludeNativeOpenAi({ + providers: { openai: { ...canonical, authMode: "key" } }, + })).toBe(false); + expect(shouldIncludeNativeOpenAi({ + providers: { openai: { ...canonical, baseUrl: "https://api.example.test/v1" } }, + })).toBe(false); + expect(shouldIncludeNativeOpenAi({ + providers: { openai: { ...canonical, disabled: true } }, + })).toBe(true); + }); + test("case-distinct routing selectors remain distinguishable in picker labels", () => { expect(accountBoundNativeDisplayName("work", nativeTemplate())).toBe("work / 5.5"); expect(accountBoundNativeDisplayName("Work", nativeTemplate())).toBe("Work / 5.5"); @@ -180,7 +243,6 @@ describe("native GPT model toggles (bare slugs in disabledModels)", () => { test("disabled native state is mirrored onto its account-qualified clones", () => { const entries = [{ slug: "side/gpt-5.6-sol", - description: CODEX_ACCOUNT_BOUND_CATALOG_DESCRIPTION, opencodex_catalog_kind: CODEX_ACCOUNT_BOUND_CATALOG_KIND, visibility: "list", }]; From f11a3d1f05ac1cfc5a15e0c201c06c71d4c55abe Mon Sep 17 00:00:00 2001 From: chrisae9 Date: Mon, 3 Aug 2026 15:33:46 -0400 Subject: [PATCH 03/13] fix(codex): address account catalog edge cases --- src/codex/catalog/account-models.ts | 3 +- src/codex/catalog/aggregation.ts | 15 +++++- src/codex/catalog/sync.ts | 16 ++++--- src/server/index.ts | 10 ++-- src/server/responses/collaboration.ts | 24 ++++++++-- tests/claude-models-discovery.test.ts | 8 +++- tests/codex-catalog-sync-hardening.test.ts | 49 ++++++++++++++++++++ tests/grok-models-effort-list.test.ts | 8 +++- tests/multi-agent-compat.test.ts | 16 +++++++ tests/native-model-toggle.test.ts | 53 ++++++++++++++++++++++ 10 files changed, 181 insertions(+), 21 deletions(-) diff --git a/src/codex/catalog/account-models.ts b/src/codex/catalog/account-models.ts index 24afec4cf..300000293 100644 --- a/src/codex/catalog/account-models.ts +++ b/src/codex/catalog/account-models.ts @@ -10,7 +10,8 @@ export const CODEX_ACCOUNT_BOUND_CATALOG_KIND = "account-selector-v1"; * * Stale mappings stay in config so exact routing keeps failing closed, but they must not advertise * a deleted account. Credential health, pause, and reauthentication state intentionally do not - * churn catalog identity. Only selector keys leave this boundary; private account ids do not. + * churn catalog identity. Selector keys have already passed config's nonempty, single-segment + * namespace validation. Only those public keys leave this boundary; private account ids do not. */ export function visibleCodexAccountSelectors( config: Pick, diff --git a/src/codex/catalog/aggregation.ts b/src/codex/catalog/aggregation.ts index 88bfa5eba..03b0e17c9 100644 --- a/src/codex/catalog/aggregation.ts +++ b/src/codex/catalog/aggregation.ts @@ -281,6 +281,8 @@ export function resetOpenAiApiCatalogWarningStateForTests(): void { export const slugAliasCollisionWarnings = new Set(); export const comboMasqueradeCollisionWarnings = new Set(); + +export const accountSelectorShadowCollisionWarnings = new Set(); let lastWarningReconciledGeneration = 0; export function reconcileCatalogWarningMemos(generation: number): number { @@ -288,11 +290,13 @@ export function reconcileCatalogWarningMemos(generation: number): number { const removed = openAiApiCollisionWarnings.size + comboCatalogWarningSignatures.size + slugAliasCollisionWarnings.size - + comboMasqueradeCollisionWarnings.size; + + comboMasqueradeCollisionWarnings.size + + accountSelectorShadowCollisionWarnings.size; openAiApiCollisionWarnings.clear(); comboCatalogWarningSignatures.clear(); slugAliasCollisionWarnings.clear(); comboMasqueradeCollisionWarnings.clear(); + accountSelectorShadowCollisionWarnings.clear(); lastWarningReconciledGeneration = generation; return removed; } @@ -305,6 +309,15 @@ export function warnComboMasqueradeCollisionOnce(slug: string): void { ); } +/** Warn once when a live provider row loses its reserved slug to an account selector. */ +export function warnAccountSelectorShadowedProviderOnce(slug: string): void { + if (accountSelectorShadowCollisionWarnings.has(slug)) return; + accountSelectorShadowCollisionWarnings.add(slug); + console.warn( + `[opencodex] account selector collision on "${safeCatalogWarningLabel(slug)}": the account-bound native model wins and the shadowed provider model is omitted from the catalog. Rename the provider or account selector.`, + ); +} + export function resolveSlugAliasCollisions(goModels: CatalogModel[]): Set { const skipped = new Set(); const winnerByAlias = new Map(); diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index aab30008a..0ae47eeec 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -37,7 +37,7 @@ import { loadCatalogForSync, resetBundledCatalogCacheForTests } from "./bundled" import { isMultiAgentV2Enabled } from "../features"; import { applyCatalogModelMetadata, applyReasoningLevels, catalogEntryEfforts, clampCatalogModelsToCodexSupport, ensureGpt56ReasoningLevels, ensureUltraReasoningLevel, isGpt56NativeSlug } from "./effort"; import { clearGatherRoutedModelsInflight, filterCatalogVisibleModels, gatherRoutedModels, lastDropWarnSignature } from "./provider-fetch"; -import { clearLastComboCatalogOmissions, comboCatalogWarningSignatures, comboMasqueradeCollisionWarnings, exactComboCatalogSlugs, openAiApiCollisionWarnings, resolveSlugAliasCollisions, slugAliasCollisionWarnings, warnComboMasqueradeCollisionOnce } from "./aggregation"; +import { accountSelectorShadowCollisionWarnings, clearLastComboCatalogOmissions, comboCatalogWarningSignatures, comboMasqueradeCollisionWarnings, exactComboCatalogSlugs, openAiApiCollisionWarnings, resolveSlugAliasCollisions, slugAliasCollisionWarnings, warnAccountSelectorShadowedProviderOnce, warnComboMasqueradeCollisionOnce } from "./aggregation"; import type { ComboCatalogOmission } from "./aggregation"; import { accountBoundNativeDisplayName, CODEX_ACCOUNT_BOUND_CATALOG_KIND, trustedAccountBoundNativeCatalogSlug, visibleCodexAccountSelectors } from "./account-models"; @@ -88,7 +88,7 @@ export interface EffectiveSubagentRoster { excluded: SubagentRosterExclusion[]; } -export function configuredCatalogEntry(entries: RawEntry[], configured: string): RawEntry | undefined { +export function configuredCatalogEntry(entries: readonly RawEntry[], configured: string): RawEntry | undefined { return entries.find(entry => entry.slug === configured) ?? entries.find(entry => typeof entry.slug === "string" && slugsEquivalent(configured, entry.slug)); } @@ -106,13 +106,14 @@ function configuredSubagentModelMatchesEntry(configured: string, entry: RawEntry export function effectiveSubagentRoster( configuredModels: readonly string[], surface: SpawnAgentSurface, + catalogEntries?: readonly RawEntry[], ): EffectiveSubagentRoster { const configured = configuredModels .filter(model => model.trim().length > 0) .filter((model, index, all) => !all.slice(0, index).some(previous => slugsEquivalent(previous, model)) ); - const entries = readCatalog(readCodexCatalogPath())?.models ?? []; + const entries = catalogEntries ?? readCatalog(readCodexCatalogPath())?.models ?? []; const ordered = entries .map((entry, index) => ({ entry, index })) .filter(({ entry }) => typeof entry.slug === "string") @@ -363,6 +364,7 @@ export function resetCatalogRuntimeStateForTests(): void { comboCatalogWarningSignatures.clear(); slugAliasCollisionWarnings.clear(); comboMasqueradeCollisionWarnings.clear(); + accountSelectorShadowCollisionWarnings.clear(); clearLastComboCatalogOmissions(); clearModelCache(); clearGatherRoutedModelsInflight(); @@ -542,9 +544,11 @@ export function mergeCatalogEntriesForSync( const accountBoundSlugs = new Set(alignedAccountBoundEntries.flatMap(entry => typeof entry.slug === "string" ? [entry.slug] : [] )); - finalRoutedEntries = finalRoutedEntries.filter(entry => - typeof entry.slug !== "string" || !accountBoundSlugs.has(entry.slug) - ); + finalRoutedEntries = finalRoutedEntries.filter(entry => { + if (typeof entry.slug !== "string" || !accountBoundSlugs.has(entry.slug)) return true; + if (freshSlugs.has(entry.slug)) warnAccountSelectorShadowedProviderOnce(entry.slug); + return false; + }); if (preservingExistingRouted) { console.warn(`[opencodex] catalog sync: routed model fetch returned empty; preserving ${finalRoutedEntries.length} existing routed entr${finalRoutedEntries.length === 1 ? "y" : "ies"} on disk.`); } diff --git a/src/server/index.ts b/src/server/index.ts index 53b26257b..9f1aad776 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -571,9 +571,9 @@ export function startServer(port?: number, deps: StartServerDeps = {}) { throw error; } const { accountBoundNativeModelSlugs, applyNativeVisibility, buildCatalogEntries, disabledNativeSlugs, exactComboCatalogSlugs, loadCatalogTemplate, nativeOpenAiSlugs, nativeReasoningEfforts, nativeDefaultReasoningEffort, orderForSubagents, filterCatalogVisibleModels, shouldIncludeNativeOpenAi, uniqueCatalogModelsForRawPublicList, visibleCodexAccountSelectors, visibleNativeSlugs, desktopVisibleNativeSlugs } = await import("../codex/catalog"); - const nativeSlugs = nativeOpenAiSlugs(); - const includeAccountRows = shouldIncludeNativeOpenAi(config); - const accountSelectors = includeAccountRows ? visibleCodexAccountSelectors(config) : []; + const includeNativeOpenAi = shouldIncludeNativeOpenAi(config); + const nativeSlugs = includeNativeOpenAi ? nativeOpenAiSlugs() : []; + const accountSelectors = includeNativeOpenAi ? visibleCodexAccountSelectors(config) : []; const goEnabled = filterCatalogVisibleModels(goModels, config); const goOrdered = orderForSubagents(goEnabled, config.subagentModels); // Claude Code / Claude Desktop gateway model discovery (GET /v1/models with @@ -662,10 +662,10 @@ export function startServer(port?: number, deps: StartServerDeps = {}) { nativeDefaultReasoningEffort(metadataId), ), }); - const visibleNatives = visibleNativeSlugs(config); + const visibleNatives = includeNativeOpenAi ? visibleNativeSlugs(config) : []; const data = [ ...visibleNatives.map(id => nativeModelRow(id)), - ...(includeAccountRows ? accountBoundNativeModelSlugs(config, visibleNatives) : []).map(id => + ...(includeNativeOpenAi ? accountBoundNativeModelSlugs(config, visibleNatives) : []).map(id => nativeModelRow(id, id.slice(id.indexOf("/") + 1)) ), ...uniqueCatalogModelsForRawPublicList(goOrdered).map(m => ({ diff --git a/src/server/responses/collaboration.ts b/src/server/responses/collaboration.ts index 7d4277307..a6fa5cdd1 100644 --- a/src/server/responses/collaboration.ts +++ b/src/server/responses/collaboration.ts @@ -208,6 +208,16 @@ export async function resolveEffectiveSubagentRoster( return effectiveSubagentRoster(configuredModels, surface); } +/** Reuse one parsed catalog snapshot across every roster projection for this request. */ +async function createRequestScopedSubagentRosterResolver(): Promise> { + const { effectiveSubagentRoster, readCatalog, readCodexCatalogPath } = await import("../../codex/catalog"); + const catalogEntries = readCatalog(readCodexCatalogPath())?.models ?? []; + return (configuredModels, surface) => + effectiveSubagentRoster(configuredModels, surface, catalogEntries); +} + export async function multiAgentGuidanceText( @@ -224,6 +234,9 @@ export async function multiAgentGuidanceText( subagentModelFallback, injectionPrompt, } = options; + const activeAccountNamespace = codexAccountNamespace?.length + ? codexAccountNamespace + : undefined; const surface = collabSurface(parsed); if (surface === null) return null; @@ -244,7 +257,8 @@ export async function multiAgentGuidanceText( ...(subagentModels ?? []), ...(injectionModel ? [injectionModel] : []), ]; - const resolveRoster = deps.resolveEffectiveSubagentRoster ?? resolveEffectiveSubagentRoster; + const resolveRoster = deps.resolveEffectiveSubagentRoster + ?? await createRequestScopedSubagentRosterResolver(); const effective = await resolveRoster(configuredForGuidance, "v2"); // Resolve the roster and preferred roles independently so a bare native can project onto its // generated account rows without making an unrelated provider/gpt-* row look equivalent. @@ -270,8 +284,8 @@ export async function multiAgentGuidanceText( const allowedForCurrentRoute = (candidate: EffectiveSubagentModel): boolean => explicitlyConfigured(candidate) || !candidate.model.includes("/") - || (codexAccountNamespace !== undefined - && candidate.model.startsWith(`${codexAccountNamespace}/`)); + || (activeAccountNamespace !== undefined + && candidate.model.startsWith(`${activeAccountNamespace}/`)); const rosterModels = (subagentEffective?.advertised ?? []) .filter(withinCandidateWindow) .filter(allowedForCurrentRoute); @@ -279,9 +293,9 @@ export async function multiAgentGuidanceText( const preferredCandidates = (preferredEffective?.advertised ?? []).filter(withinCandidateWindow); const preferred = injectionModel?.includes("/") ? preferredCandidates[0] - : codexAccountNamespace + : activeAccountNamespace ? preferredCandidates.find(candidate => - candidate.model.startsWith(`${codexAccountNamespace}/`) + candidate.model.startsWith(`${activeAccountNamespace}/`) ) : preferredCandidates.length === 1 && !preferredCandidates[0]!.model.includes("/") ? preferredCandidates[0] diff --git a/tests/claude-models-discovery.test.ts b/tests/claude-models-discovery.test.ts index 83a1e056b..bced3cc1c 100644 --- a/tests/claude-models-discovery.test.ts +++ b/tests/claude-models-discovery.test.ts @@ -197,8 +197,10 @@ test("configured account selectors appear in OpenAI and Codex discovery without .toMatchObject({ display_name: "desktop / 5.5", visibility: "list" }); expect(catalog.models.find(model => model.slug === "team/gpt-5.5")?.visibility).toBe("list"); expect(catalog.models.some(model => model.slug.startsWith("removed/"))).toBe(false); - expect(JSON.stringify(catalog)).not.toContain("stored-side-account"); - expect(JSON.stringify(catalog)).not.toContain("private@example.test"); + for (const privateValue of ["stored-side-account", "private@example.test", "Private Display Name"]) { + expect(JSON.stringify(catalog)).not.toContain(privateValue); + expect(JSON.stringify(plain)).not.toContain(privateValue); + } } finally { server.stop(true); } @@ -214,10 +216,12 @@ test("account selectors stay out of discovery when no canonical OpenAI provider data: Array<{ id: string }>; }; expect(plain.data.some(model => model.id.startsWith("desktop/"))).toBe(false); + expect(plain.data.some(model => model.id.startsWith("gpt-"))).toBe(false); const catalog = await fetch(new URL("/v1/models?client_version=1.0.0", server.url)) .then(response => response.json()) as { models: Array<{ slug: string }> }; expect(catalog.models.some(model => model.slug.startsWith("desktop/"))).toBe(false); + expect(catalog.models.some(model => model.slug.startsWith("gpt-"))).toBe(false); } finally { server.stop(true); } diff --git a/tests/codex-catalog-sync-hardening.test.ts b/tests/codex-catalog-sync-hardening.test.ts index 2f6ce6bda..a5a7e9cdf 100644 --- a/tests/codex-catalog-sync-hardening.test.ts +++ b/tests/codex-catalog-sync-hardening.test.ts @@ -234,6 +234,7 @@ describe("Codex catalog sync hardening", () => { `); expect(r.status).toBe(0); expect(r.stderr).toContain("routed model fetch returned empty; preserving 2 existing routed entries"); + expect(r.stderr).not.toContain("account selector collision"); const rows = JSON.parse(readFileSync(catalogPath, "utf8")).models as Array<{ slug: string; @@ -250,6 +251,7 @@ describe("Codex catalog sync hardening", () => { auto_compact_token_limit?: number; }>; const firstRows = (JSON.parse(r.stdout) as { firstRows: typeof rows }).firstRows; + expect(rows).toEqual(firstRows); const firstBare = firstRows.find(row => row.slug === "gpt-5.5"); const firstTeam = firstRows.find(row => row.slug === "team/gpt-5.5"); expect(firstBare).toMatchObject({ @@ -293,6 +295,53 @@ describe("Codex catalog sync hardening", () => { } expect(JSON.stringify(rows)).not.toContain("stored-team-account"); expect(JSON.stringify(rows)).not.toContain("private@example.test"); + expect(JSON.stringify(rows)).not.toContain("Private Display Name"); + }); + + test("a live provider row shadowed by an account selector warns once per runtime generation", () => { + const catalogPath = join(codexHome, "catalog.json"); + writeFileSync(join(codexHome, "config.toml"), 'model_catalog_json = "catalog.json"\n', "utf8"); + writeFileSync(catalogPath, JSON.stringify({ + models: [nativeEntry("gpt-5.5", 0)], + }, null, 2) + "\n"); + + const r = runScript(codexHome, opencodexHome, ` + const { resetCatalogRuntimeStateForTests, syncCatalogModels } = require("./src/codex/catalog"); + const config = { + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + liveModels: false + }, + team: { + adapter: "openai-chat", + baseUrl: "https://api.example.test/v1", + liveModels: false, + models: ["gpt-5.5"] + } + }, + codexAccounts: [{ id: "stored-team-account", isMain: false }], + codexAccountNamespaces: { team: "stored-team-account" } + }; + syncCatalogModels(config) + .then(() => syncCatalogModels(config)) + .then(() => { + resetCatalogRuntimeStateForTests(); + return syncCatalogModels(config); + }) + .then(res => console.log(JSON.stringify(res))); + `); + expect(r.status).toBe(0); + expect((r.stderr.match(/account selector collision on "team\/gpt-5\.5"/g) ?? []).length).toBe(2); + + const rows = JSON.parse(readFileSync(catalogPath, "utf8")).models as Array<{ + slug: string; + opencodex_catalog_kind?: string; + }>; + expect(rows.filter(row => row.slug === "team/gpt-5.5")).toEqual([ + expect.objectContaining({ opencodex_catalog_kind: "account-selector-v1" }), + ]); }); test("non-OpenAI-only sync omits account rows without reprioritizing routed models", () => { diff --git a/tests/grok-models-effort-list.test.ts b/tests/grok-models-effort-list.test.ts index 5c24e93e5..ed4046fb3 100644 --- a/tests/grok-models-effort-list.test.ts +++ b/tests/grok-models-effort-list.test.ts @@ -43,7 +43,13 @@ afterEach(() => { describe("raw /v1/models list reasoning-effort advertisement (Grok Build discovery)", () => { test("routed models with configured tiers advertise the Grok reasoning catalog shape", async () => { - saveConfig(effortConfig()); + const config = effortConfig(); + config.providers.openai = { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + liveModels: false, + }; + saveConfig(config); const server = startServer(0); try { const res = await fetch(new URL("/v1/models", server.url)); diff --git a/tests/multi-agent-compat.test.ts b/tests/multi-agent-compat.test.ts index 4f1f699bb..cb829aeab 100644 --- a/tests/multi-agent-compat.test.ts +++ b/tests/multi-agent-compat.test.ts @@ -253,6 +253,22 @@ describe("multiAgentGuidanceText", () => { ); expect(bareParent).toBeNull(); + const emptyNamespace = await multiAgentGuidanceText( + parsedFixture({ tools: [{ name: "spawn_agent" }] }), + { + codexAccountNamespace: "", + subagentModels: ["gpt-5.6-sol"], + }, + { + resolveEffectiveSubagentRoster: () => ({ + candidates: [{ model: "/gpt-5.6-sol", efforts: ["high"] }], + advertised: [{ model: "/gpt-5.6-sol", efforts: ["high"] }], + excluded: [], + }), + }, + ); + expect(emptyNamespace).toBeNull(); + const explicitCrossAccount = await multiAgentGuidanceText( parsedFixture({ tools: [{ name: "spawn_agent" }] }), { diff --git a/tests/native-model-toggle.test.ts b/tests/native-model-toggle.test.ts index f3161f5be..be97c09d6 100644 --- a/tests/native-model-toggle.test.ts +++ b/tests/native-model-toggle.test.ts @@ -15,6 +15,7 @@ import { visibleNativeSlugs, } from "../src/codex/catalog"; import { handleManagementAPI } from "../src/server/management-api"; +import { applyMultiAgentMode, applyNativeOpenAiContextOverride } from "../src/codex/catalog/parsing"; import type { OcxConfig } from "../src/types"; function makeConfig(overrides: Partial = {}): OcxConfig { @@ -129,6 +130,14 @@ describe("native GPT model toggles (bare slugs in disabledModels)", () => { opencodex_catalog_kind: CODEX_ACCOUNT_BOUND_CATALOG_KIND, })).toBe("gpt-5.6-sol"); expect(trustedAccountBoundNativeCatalogSlug({ slug: "side/gpt-5.6-sol" })).toBeUndefined(); + expect(trustedAccountBoundNativeCatalogSlug({ + slug: "/gpt-5.6-sol", + opencodex_catalog_kind: CODEX_ACCOUNT_BOUND_CATALOG_KIND, + })).toBeUndefined(); + expect(trustedAccountBoundNativeCatalogSlug({ + slug: "side/", + opencodex_catalog_kind: CODEX_ACCOUNT_BOUND_CATALOG_KIND, + })).toBeUndefined(); expect(trustedAccountBoundNativeCatalogSlug({ slug: "gpt-5.6-sol", opencodex_catalog_kind: CODEX_ACCOUNT_BOUND_CATALOG_KIND, @@ -139,6 +148,50 @@ describe("native GPT model toggles (bare slugs in disabledModels)", () => { })).toBeUndefined(); }); + test("native metadata helpers trust only marked, well-shaped account rows", () => { + const trusted = { + slug: "side/gpt-5.6-luna", + opencodex_catalog_kind: CODEX_ACCOUNT_BOUND_CATALOG_KIND, + context_window: 128_000, + max_context_window: 128_000, + auto_compact_token_limit: 115_200, + multi_agent_version: "v2", + }; + const malformed = { + ...trusted, + slug: "side/nested/gpt-5.6-luna", + }; + const unmarked = { + ...trusted, + slug: "provider/gpt-5.6-luna", + opencodex_catalog_kind: undefined, + }; + + applyNativeOpenAiContextOverride(trusted); + applyNativeOpenAiContextOverride(malformed); + applyNativeOpenAiContextOverride(unmarked); + expect(trusted).toMatchObject({ + context_window: 372_000, + max_context_window: 372_000, + auto_compact_token_limit: 334_800, + }); + expect(malformed).toMatchObject({ + context_window: 128_000, + max_context_window: 128_000, + auto_compact_token_limit: 115_200, + }); + expect(unmarked).toMatchObject({ + context_window: 128_000, + max_context_window: 128_000, + auto_compact_token_limit: 115_200, + }); + + applyMultiAgentMode([trusted, malformed, unmarked], "default"); + expect(trusted.multi_agent_version).toBe("v1"); + expect(malformed.multi_agent_version).toBeUndefined(); + expect(unmarked.multi_agent_version).toBeUndefined(); + }); + test("native availability mirrors the built-in OpenAI auth-mode default", () => { const canonical = { adapter: "openai-responses", From aa60d7ff2c940c5b4c8980e439fca30846ffc0a0 Mon Sep 17 00:00:00 2001 From: chrisae9 Date: Mon, 3 Aug 2026 15:53:19 -0400 Subject: [PATCH 04/13] fix(codex): address account catalog review --- src/codex/catalog.ts | 2 +- src/codex/catalog/metadata.ts | 39 ++++-- src/codex/catalog/sync.ts | 74 +++++++++-- src/codex/subagent-model-fallback.ts | 33 ++++- src/server/index.ts | 38 ++++-- src/server/responses/collaboration.ts | 12 +- tests/claude-models-discovery.test.ts | 136 ++++++++++++++++++++- tests/codex-catalog-restore.test.ts | 128 +++++++++++++++++++ tests/codex-catalog-sync-hardening.test.ts | 73 +++++++++++ tests/multi-agent-compat.test.ts | 20 +++ tests/native-model-toggle.test.ts | 9 ++ tests/subagent-model-fallback.test.ts | 48 +++++++- 12 files changed, 568 insertions(+), 44 deletions(-) diff --git a/src/codex/catalog.ts b/src/codex/catalog.ts index e207b7329..3ee398ecf 100644 --- a/src/codex/catalog.ts +++ b/src/codex/catalog.ts @@ -2,7 +2,7 @@ // Public surface preserved exactly; importers keep using "src/codex/catalog". export { isMediaGenerationModelId, shouldExposeRoutedModel, readCodexCatalogPath, readCatalog, normalizeRoutedCatalogEntry, catalogModelSlug, filterSupportedNativeSlugs, catalogModelSupportsReasoningSummaries } from "./catalog/parsing"; export type { CatalogModel, MultiAgentMode } from "./catalog/parsing"; -export { NATIVE_OPENAI_MODELS, nativeOpenAiContextWindow, disabledNativeSlugs, visibleNativeSlugs, desktopVisibleNativeSlugs, nativeModelRows, applyNativeVisibility, upstreamNativeEntry, nativeOpenAiSlugs, listCatalogNativeSlugs, nativeReasoningEfforts, nativeDefaultReasoningEffort, shouldIncludeNativeOpenAi } from "./catalog/metadata"; +export { NATIVE_OPENAI_MODELS, nativeOpenAiContextWindow, disabledNativeSlugs, visibleNativeSlugs, desktopVisibleNativeSlugs, nativeModelRows, applyNativeVisibility, upstreamNativeEntry, nativeOpenAiSlugs, listCatalogNativeSlugs, nativeReasoningEfforts, nativeDefaultReasoningEffort, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi } from "./catalog/metadata"; export { isSpawnableCodexCandidate, codexExecInvocation, loadBundledCodexCatalog, materializeBundledCodexCatalog, loadCatalogTemplate } from "./catalog/bundled"; export { nativeEffortClamp, shouldApplyNativeEffortClamp, catalogModelEfforts, codexSupportedReasoningEfforts, clampedDefaultEffort, clampEntryToCodexSupportedEfforts, clampCatalogModelsToCodexSupport } from "./catalog/effort"; export { applyProviderConfigHints, isDatedVariantId, filterCatalogVisibleModels, gatherRoutedModels, clearGatherRoutedModelsInflight, augmentRoutedModelsWithRegistryOpenAiApiRows, augmentRoutedModelsWithJawcodeMetadata } from "./catalog/provider-fetch"; diff --git a/src/codex/catalog/metadata.ts b/src/codex/catalog/metadata.ts index 244158c38..13acd081e 100644 --- a/src/codex/catalog/metadata.ts +++ b/src/codex/catalog/metadata.ts @@ -31,7 +31,6 @@ import { redactSecretString } from "../../lib/redact"; import upstreamModelsSnapshot from "../data/upstream-models.json"; -import { filterSupportedNativeSlugs } from "./parsing"; import type { RawEntry } from "./parsing"; import { readCurrentCatalogOrCache, unique } from "./bundled"; import { trustedAccountBoundNativeCatalogSlug } from "./account-models"; @@ -122,18 +121,26 @@ export function visibleNativeSlugs(config: Pick): s return nativeOpenAiSlugs().filter(slug => !disabled.has(slug)); } -/** Whether native ChatGPT/Codex routes are valid for this provider configuration. */ +/** Whether an enabled canonical OpenAI provider can serve exact account-qualified routes. */ +export function shouldIncludeAccountBoundNativeOpenAi( + config: Pick, +): boolean { + const provider = config.providers[OPENAI_CODEX_PROVIDER_ID]; + if (!provider || provider.disabled === true) return false; + // Registry routing defaults an omitted authMode on the built-in OpenAI row to forward. + const canonical = provider.authMode === undefined + ? { ...provider, authMode: "forward" as const } + : provider; + return isCanonicalOpenAiForwardProvider(canonical); +} + +/** Whether native ChatGPT/Codex rows belong in this provider configuration. */ export function shouldIncludeNativeOpenAi(config: Pick): boolean { - const enabledProviders = Object.entries(config.providers) - .filter(([, provider]) => provider.disabled !== true); - return enabledProviders.length === 0 || enabledProviders.some(([name, provider]) => { - if (name !== OPENAI_CODEX_PROVIDER_ID) return false; - // Registry routing defaults an omitted authMode on the built-in OpenAI row to forward. - const canonical = provider.authMode === undefined - ? { ...provider, authMode: "forward" as const } - : provider; - return isCanonicalOpenAiForwardProvider(canonical); - }); + const hasEnabledProvider = Object.values(config.providers) + .some(provider => provider.disabled !== true); + // Preserve the existing no-enabled-provider catalog bootstrap, but do not use that bootstrap + // exception for account-qualified rows: exact-account routing requires a live OpenAI provider. + return !hasEnabledProvider || shouldIncludeAccountBoundNativeOpenAi(config); } /** Native slugs exposed to Claude Desktop show/export/apply (opt-out via claudeCode.desktopNativeModels). */ @@ -199,11 +206,17 @@ export function nativeOpenAiSlugs(): string[] { export function listCatalogNativeSlugs(): string[] { const cat = readCurrentCatalogOrCache(); const models = cat?.models ?? []; - const live = filterSupportedNativeSlugs(models); + const live = models.flatMap(entry => { + const slug = typeof entry.slug === "string" ? entry.slug : ""; + return !slug.includes("/") && SUPPORTED_NATIVE_OPENAI_SLUGS.has(slug) ? [slug] : []; + }); const accountBound = models.flatMap(entry => { const slug = trustedAccountBoundNativeCatalogSlug(entry); return slug !== undefined && SUPPORTED_NATIVE_OPENAI_SLUGS.has(slug) ? [slug] : []; }); + // Deliberately ignore `visibility`: it is a rendered projection of disabledModels and account + // selectors, so treating it as fresh availability would shrink the supported set between syncs. + // visibleNativeSlugs applies the current disabledModels source of truth for public consumers. // Ensure documented additions (e.g. gpt-5.3-codex-spark) appear even when the bundled catalog // predates the slug — mirrors nativeOpenAiSlugs() which already merges them for /v1/models. return unique([...live, ...accountBound, ...DOCUMENTED_NATIVE_OPENAI_ADDITIONS]); diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index 0ae47eeec..d312de694 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -2,7 +2,7 @@ import { execFileSync } from "node:child_process"; import { createHash } from "node:crypto"; import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync } from "node:fs"; import { delimiter, dirname, join, resolve } from "node:path"; -import { atomicWriteFile, expandUserPath, getConfigDir, websocketsEnabled } from "../../config"; +import { atomicWriteFile, expandUserPath, getConfigDir, readConfigDiagnostics, websocketsEnabled } from "../../config"; import { CODEX_CONFIG_PATH, CODEX_MODELS_CACHE_PATH, DEFAULT_CATALOG_PATH, readRootTomlString, resolveCodexConfigPath } from "../paths"; import { clearModelCache, DEFAULT_MODEL_CACHE_TTL_MS, getFreshCached, getStaleCached, isModelsFetchCoolingDown, markModelsFetchFailure, setCached } from "../model-cache"; import { buildModelsRequest, resolveModelsAuthToken } from "../../oauth"; @@ -32,7 +32,7 @@ import upstreamModelsSnapshot from "../data/upstream-models.json"; import { activeCodexModelsCachePath, applyJawcodeCatalogMetadata, applyMultiAgentMode, applyNativeOpenAiContextOverride, catalogModelSlug, ensureCatalogBackup, ensureStrictCatalogFields, findNativeTemplate, isRoutedModelCompatibilityExcluded, normalizeRoutedCatalogEntry, normalizeServiceTiers, readCatalog, readCatalogBackup, readCodexCatalogPath, readNativeBaseline } from "./parsing"; import type { CatalogModel, MultiAgentMode, RawEntry } from "./parsing"; -import { applyNativeVisibility, disabledNativeSlugs, isUnsupportedOpenAiNativeSlug, nativeOpenAiSlugs, shouldIncludeNativeOpenAi, shouldUpgradeToUpstreamEntry, SUPPORTED_NATIVE_OPENAI_SLUGS, upstreamNativeEntry } from "./metadata"; +import { applyNativeVisibility, disabledNativeSlugs, isUnsupportedOpenAiNativeSlug, NATIVE_OPENAI_MODELS, nativeOpenAiSlugs, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi, shouldUpgradeToUpstreamEntry, SUPPORTED_NATIVE_OPENAI_SLUGS, upstreamNativeEntry } from "./metadata"; import { loadCatalogForSync, resetBundledCatalogCacheForTests } from "./bundled"; import { isMultiAgentV2Enabled } from "../features"; import { applyCatalogModelMetadata, applyReasoningLevels, catalogEntryEfforts, clampCatalogModelsToCodexSupport, ensureGpt56ReasoningLevels, ensureUltraReasoningLevel, isGpt56NativeSlug } from "./effort"; @@ -127,6 +127,7 @@ export function effectiveSubagentRoster( return leftPriority - rightPriority || left.index - right.index; }) .slice(0, MAX_SPAWN_AGENT_MODEL_OVERRIDES); + const orderedEntries = new Set(ordered.map(({ entry }) => entry)); const candidates = ordered.map(({ entry }) => ({ model: entry.slug as string, @@ -139,8 +140,8 @@ export function effectiveSubagentRoster( efforts: catalogEntryEfforts(entry), })); const excluded = configured.flatMap((model): SubagentRosterExclusion[] => { - if (ordered.some(({ entry }) => configuredSubagentModelMatchesEntry(model, entry))) return []; const matchingEntries = entries.filter(entry => configuredSubagentModelMatchesEntry(model, entry)); + if (matchingEntries.some(entry => orderedEntries.has(entry))) return []; if (matchingEntries.length === 0) return [{ configured: model, reason: "missing_catalog_entry" }]; const visibleCompatible = matchingEntries.find(entry => entry.visibility === "list" @@ -627,7 +628,10 @@ export async function syncCatalogModels(config: OcxConfig): Promise<{ const exactComboSlugs = exactComboCatalogSlugs(config); const hasPhysicalComboProvider = Object.hasOwn(config.providers, COMBO_NAMESPACE); const includeNativeOpenAi = shouldIncludeNativeOpenAi(config); - const accountSelectors = includeNativeOpenAi ? visibleCodexAccountSelectors(config) : []; + const includeAccountBoundNativeOpenAi = shouldIncludeAccountBoundNativeOpenAi(config); + const accountSelectors = includeAccountBoundNativeOpenAi + ? visibleCodexAccountSelectors(config) + : []; const wsEnabled = websocketsEnabled(config); const goEntries = buildCatalogEntries( template ? JSON.parse(JSON.stringify(template)) : null, @@ -655,10 +659,10 @@ export async function syncCatalogModels(config: OcxConfig): Promise<{ // #636: when the user only configured non-OpenAI providers (e.g. kimi), do not advertise // bare gpt-* rows that hard-404 via NoEnabledOpenAiProviderError. Keep natives when no // providers are configured yet (fresh install / catalog bootstrap tests). - const accountBoundEntries = includeNativeOpenAi && accountSelectors.length > 0 + const accountBoundEntries = includeAccountBoundNativeOpenAi && accountSelectors.length > 0 ? buildCatalogEntries( template ? JSON.parse(JSON.stringify(template)) : null, - nativeOpenAiSlugs(), + NATIVE_OPENAI_MODELS, [], featured, wsEnabled, @@ -694,16 +698,64 @@ export async function syncCatalogModels(config: OcxConfig): Promise<{ }; } +function visibleAccountReplacementNatives(models: readonly RawEntry[]): Map { + const replacements = new Map(); + for (const entry of models) { + const nativeSlug = trustedAccountBoundNativeCatalogSlug(entry); + if (nativeSlug === undefined || !SUPPORTED_NATIVE_OPENAI_SLUGS.has(nativeSlug)) continue; + const visible = entry.visibility === "list"; + replacements.set(nativeSlug, (replacements.get(nativeSlug) ?? true) && visible); + } + return replacements; +} + +function restoreAccountHiddenBareNatives( + entries: readonly RawEntry[], + replacementVisibility: ReadonlyMap, + disabledNative: ReadonlySet | null, +): RawEntry[] { + return entries.map(entry => { + const slug = typeof entry.slug === "string" ? entry.slug : ""; + if ( + entry.visibility !== "hide" + || !SUPPORTED_NATIVE_OPENAI_SLUGS.has(slug) + || replacementVisibility.get(slug) !== true + || disabledNative === null + || disabledNative.has(slug) + ) { + return entry; + } + return { ...entry, visibility: "list" }; + }); +} + +function currentDisabledNativeSlugsForRestore(): Set | null { + try { + const diagnostics = readConfigDiagnostics(); + if (diagnostics.source === "fallback" || diagnostics.error !== null) return null; + return disabledNativeSlugs(diagnostics.config); + } catch { + // An unreadable config cannot safely authorize a visibility change during restore. + return null; + } +} + export function restoreCodexCatalog(): { removed: number; kept: number; path: string } { const catalogPath = readCodexCatalogPath(); const catalog = readCatalog(catalogPath); if (!catalog || !Array.isArray(catalog.models)) return { removed: 0, kept: 0, path: catalogPath }; + const replacementVisibility = visibleAccountReplacementNatives(catalog.models); + const disabledNative = currentDisabledNativeSlugsForRestore(); 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 backupSlugs = new Set(backup.models.flatMap(m => typeof m.slug === "string" ? [m.slug] : [])); - const userNativeAdditions = (catalog.models ?? []).filter(m => - typeof m.slug === "string" && !m.slug.includes("/") && !backupSlugs.has(m.slug) + const userNativeAdditions = restoreAccountHiddenBareNatives( + (catalog.models ?? []).filter(m => + typeof m.slug === "string" && !m.slug.includes("/") && !backupSlugs.has(m.slug) + ), + replacementVisibility, + disabledNative, ); const restored = { ...backup, @@ -713,7 +765,11 @@ export function restoreCodexCatalog(): { removed: number; kept: number; path: st return { removed, kept: restored.models.length, path: catalogPath }; } const before = catalog.models.length; - const native = catalog.models.filter(m => !(typeof m.slug === "string" && m.slug.includes("/"))); + const native = restoreAccountHiddenBareNatives( + catalog.models.filter(m => !(typeof m.slug === "string" && m.slug.includes("/"))), + replacementVisibility, + disabledNative, + ); const removed = before - native.length; if (removed > 0) { catalog.models = native; diff --git a/src/codex/subagent-model-fallback.ts b/src/codex/subagent-model-fallback.ts index 160c7d360..e312ad0a4 100644 --- a/src/codex/subagent-model-fallback.ts +++ b/src/codex/subagent-model-fallback.ts @@ -30,6 +30,7 @@ import { PROVIDER_REGISTRY } from "../providers/registry"; import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers"; import { routeModel, type RouteResult } from "../router"; import { sweepExpiredOnWrite } from "../lib/state-store-sweeper"; +import { codexAccountNamespaceForModel } from "./account-namespace-match"; export const DEFAULT_SUBAGENT_MODEL_FALLBACK_POLL_MS = 60_000; type SubagentQuotaPrimeFn = (config: OcxConfig, reason: string) => Promise; @@ -71,6 +72,13 @@ function isDisabledFallbackModel(model: string, config: OcxConfig): boolean { const slash = model.indexOf("/"); const provider = model.slice(0, slash); const modelId = model.slice(slash + 1); + if (codexAccountNamespaceForModel(config.codexAccountNamespaces, model)) { + return disabled.some(stored => + stored === model + || stored === modelId + || slugEquals(stored, "openai", modelId) + ); + } return disabled.some(stored => stored === model || slugEquals(stored, provider, modelId)); } @@ -130,9 +138,18 @@ function resolvePoolFallbackAccountId( return activeCodexAccountId(config); } +function resolveRouteFallbackAccountId( + route: RouteResult | null, + config: OcxConfig, + accountId?: string | null, +): string | null { + return route?.codexAccountId ?? resolvePoolFallbackAccountId(config, accountId); +} + function isRoutableFallbackModel(model: string, config: OcxConfig): boolean { const slash = model.indexOf("/"); if (slash > 0) { + if (codexAccountNamespaceForModel(config.codexAccountNamespaces, model)) return true; const providerName = model.slice(0, slash); if (!hasOwnProvider(config.providers, providerName)) { // Allow well-known "vendor/model" ids (e.g. anthropic/claude-*) to flow as @@ -153,7 +170,7 @@ export function isNativeModelQuotaExhausted( ): boolean { const route = tryRouteFallbackModel(config, model); if (!route || !isPoolCodexRoute(route)) return false; - const resolvedAccountId = resolvePoolFallbackAccountId(config, accountId); + const resolvedAccountId = resolveRouteFallbackAccountId(route, config, accountId); if (!resolvedAccountId) return false; const quota = getAccountQuota(resolvedAccountId); const usage = computeCodexUsageScore(quota, getPoolAccountPlan(config, resolvedAccountId)); @@ -170,7 +187,11 @@ export function isModelHealthBlocked( const route = tryRouteFallbackModel(config, model); const poolScoped = !!route && isPoolCodexRoute(route); const health = modelHealth.get( - healthKey(model, resolvePoolFallbackAccountId(config, accountId), poolScoped), + healthKey( + model, + resolveRouteFallbackAccountId(route, config, accountId), + poolScoped, + ), ); return !!health && health.unavailableUntil > now; } @@ -191,7 +212,7 @@ export function isSubagentModelUnavailable( // Pool candidates need a usable account. Derive requirement from the resolved // route (canonical openai defaults to pool even when codexAccountMode is omitted). - const resolvedAccountId = resolvePoolFallbackAccountId(config, accountId); + const resolvedAccountId = resolveRouteFallbackAccountId(route, config, accountId); if (!resolvedAccountId) return true; if (isCodexAccountPaused(config, resolvedAccountId)) return true; if (!isCodexAccountUsable(config, resolvedAccountId, accountUsabilityOptions)) return true; @@ -245,7 +266,11 @@ export function noteSubagentModelFailure( const route = tryRouteFallbackModel(config, model); const poolScoped = !!route && isPoolCodexRoute(route); modelHealth.set( - healthKey(model, resolvePoolFallbackAccountId(config, accountId), poolScoped), + healthKey( + model, + resolveRouteFallbackAccountId(route, config, accountId), + poolScoped, + ), { unavailableUntil: now + interval, reason: "quota_exhausted", diff --git a/src/server/index.ts b/src/server/index.ts index 9f1aad776..001f8ea64 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -570,10 +570,14 @@ export function startServer(port?: number, deps: StartServerDeps = {}) { } throw error; } - const { accountBoundNativeModelSlugs, applyNativeVisibility, buildCatalogEntries, disabledNativeSlugs, exactComboCatalogSlugs, loadCatalogTemplate, nativeOpenAiSlugs, nativeReasoningEfforts, nativeDefaultReasoningEffort, orderForSubagents, filterCatalogVisibleModels, shouldIncludeNativeOpenAi, uniqueCatalogModelsForRawPublicList, visibleCodexAccountSelectors, visibleNativeSlugs, desktopVisibleNativeSlugs } = await import("../codex/catalog"); + const { applyNativeVisibility, buildCatalogEntries, disabledNativeSlugs, exactComboCatalogSlugs, loadCatalogTemplate, NATIVE_OPENAI_MODELS, nativeOpenAiSlugs, nativeReasoningEfforts, nativeDefaultReasoningEffort, orderForSubagents, filterCatalogVisibleModels, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi, uniqueCatalogModelsForRawPublicList, visibleCodexAccountSelectors, visibleNativeSlugs, desktopVisibleNativeSlugs } = await import("../codex/catalog"); const includeNativeOpenAi = shouldIncludeNativeOpenAi(config); + const includeAccountBoundNativeOpenAi = shouldIncludeAccountBoundNativeOpenAi(config); const nativeSlugs = includeNativeOpenAi ? nativeOpenAiSlugs() : []; - const accountSelectors = includeNativeOpenAi ? visibleCodexAccountSelectors(config) : []; + const disabledNatives = disabledNativeSlugs(config); + const accountSelectors = includeAccountBoundNativeOpenAi + ? visibleCodexAccountSelectors(config) + : []; const goEnabled = filterCatalogVisibleModels(goModels, config); const goOrdered = orderForSubagents(goEnabled, config.subagentModels); // Claude Code / Claude Desktop gateway model discovery (GET /v1/models with @@ -617,11 +621,17 @@ export function startServer(port?: number, deps: StartServerDeps = {}) { // Disabled natives stay in the catalog shape with visibility "hide" (mirrors the // on-disk sync; codex-rs keeps them out of the picker itself). const maMode = config.multiAgentMode === "v1" || config.multiAgentMode === "v2" ? config.multiAgentMode : "default"; - const entries = buildCatalogEntries(loadCatalogTemplate(), nativeSlugs, goOrdered, config.subagentModels, websocketsEnabled(config), maMode as "v1" | "default" | "v2", exactComboCatalogSlugs(config), accountSelectors); + // Account rows use the same hidden-inclusive supported set as on-disk sync. This lets a + // newly re-enabled native reappear under each selector before the next sync, while the + // no-selector path keeps nativeOpenAiSlugs()'s existing visibility-sensitive behavior. + const catalogNativeSlugs = accountSelectors.length > 0 + ? NATIVE_OPENAI_MODELS + : nativeSlugs; + const entries = buildCatalogEntries(loadCatalogTemplate(), catalogNativeSlugs, goOrdered, config.subagentModels, websocketsEnabled(config), maMode as "v1" | "default" | "v2", exactComboCatalogSlugs(config), accountSelectors); return jsonResponse({ models: applyNativeVisibility( entries, - disabledNativeSlugs(config), + disabledNatives, accountSelectors.length > 0, ), }, 200, req, config); @@ -662,12 +672,24 @@ export function startServer(port?: number, deps: StartServerDeps = {}) { nativeDefaultReasoningEffort(metadataId), ), }); - const visibleNatives = includeNativeOpenAi ? visibleNativeSlugs(config) : []; + // Selector-active discovery follows the same complete supported set as the Codex catalog + // for both bare and qualified rows. Without selectors, the live catalog continues to own + // bare availability. + const selectorNativeSlugs = accountSelectors.length > 0 + ? NATIVE_OPENAI_MODELS.filter(slug => !disabledNatives.has(slug)) + : []; + const visibleNatives = includeNativeOpenAi + ? accountSelectors.length > 0 ? selectorNativeSlugs : visibleNativeSlugs(config) + : []; + const visibleAccountNatives = accountSelectors.flatMap(selector => + selectorNativeSlugs.map(metadataId => ({ + id: `${selector}/${metadataId}`, + metadataId, + })) + ); const data = [ ...visibleNatives.map(id => nativeModelRow(id)), - ...(includeNativeOpenAi ? accountBoundNativeModelSlugs(config, visibleNatives) : []).map(id => - nativeModelRow(id, id.slice(id.indexOf("/") + 1)) - ), + ...visibleAccountNatives.map(({ id, metadataId }) => nativeModelRow(id, metadataId)), ...uniqueCatalogModelsForRawPublicList(goOrdered).map(m => ({ id: m.alias ?? `${m.provider}/${m.id}`, object: "model", diff --git a/src/server/responses/collaboration.ts b/src/server/responses/collaboration.ts index a6fa5cdd1..f0b5da48e 100644 --- a/src/server/responses/collaboration.ts +++ b/src/server/responses/collaboration.ts @@ -291,15 +291,17 @@ export async function multiAgentGuidanceText( .filter(allowedForCurrentRoute); const roster = subagentRosterText(rosterModels); const preferredCandidates = (preferredEffective?.advertised ?? []).filter(withinCandidateWindow); + const soleBarePreferred = preferredCandidates.length === 1 + && !preferredCandidates[0]!.model.includes("/") + ? preferredCandidates[0] + : undefined; const preferred = injectionModel?.includes("/") ? preferredCandidates[0] : activeAccountNamespace ? preferredCandidates.find(candidate => candidate.model.startsWith(`${activeAccountNamespace}/`) - ) - : preferredCandidates.length === 1 && !preferredCandidates[0]!.model.includes("/") - ? preferredCandidates[0] - : undefined; + ) ?? soleBarePreferred + : soleBarePreferred; if (isInjectionDebugEnabled() && effective.excluded.length > 0) { injectionDebugLog(`[opencodex] multi-agent guidance excluded: ${effective.excluded @@ -309,7 +311,7 @@ export async function multiAgentGuidanceText( const fallbackGuidance = subagentFallbackGuidanceText({ subagentModelFallback } as OcxConfig); if (!injectionModel && roster === "" && fallbackGuidance === "") return null; if (injectionPrompt) { - return `${applyInjectionPlaceholders(injectionPrompt, injectionModel, injectionEffort, roster, fallbackGuidance)}`; + return `${applyInjectionPlaceholders(injectionPrompt, preferred?.model ?? injectionModel, injectionEffort, roster, fallbackGuidance)}`; } if (!preferred && roster === "" && fallbackGuidance === "") return null; let text = "When the active spawn_agent tool supports optional \"model\" or \"reasoning_effort\" overrides, " diff --git a/tests/claude-models-discovery.test.ts b/tests/claude-models-discovery.test.ts index bced3cc1c..9afc8fb4b 100644 --- a/tests/claude-models-discovery.test.ts +++ b/tests/claude-models-discovery.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, expect, setDefaultTimeout, test } from "bun:test"; -import { mkdtempSync, rmSync } from "node:fs"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { saveConfig } from "../src/config"; @@ -202,7 +202,103 @@ test("configured account selectors appear in OpenAI and Codex discovery without expect(JSON.stringify(plain)).not.toContain(privateValue); } } finally { - server.stop(true); + await server.stop(true); + } +}); + +test("Codex discovery restores account rows for supported natives hidden on disk", async () => { + const config = configWithStaticModels(); + config.providers.openai = { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + liveModels: false, + }; + saveConfig(config); + + const catalogPath = join(isolatedCodexHome!.path, "hidden-native-catalog.json"); + writeFileSync( + join(isolatedCodexHome!.path, "config.toml"), + 'model_catalog_json = "hidden-native-catalog.json"\n', + "utf8", + ); + writeFileSync(catalogPath, JSON.stringify({ + models: [ + { slug: "gpt-5.5", visibility: "hide" }, + { slug: "gpt-5.4", visibility: "list" }, + { slug: "gpt-99-internal", visibility: "hide" }, + { slug: "provider/gpt-5.5", visibility: "hide" }, + ], + }), "utf8"); + const { + listCatalogNativeSlugs, + resetCatalogRuntimeStateForTests, + visibleNativeSlugs, + } = await import("../src/codex/catalog"); + resetCatalogRuntimeStateForTests(); + expect(listCatalogNativeSlugs()).toContain("gpt-5.5"); + expect(listCatalogNativeSlugs()).not.toContain("gpt-99-internal"); + expect(listCatalogNativeSlugs()).not.toContain("provider/gpt-5.5"); + expect(visibleNativeSlugs(config)).toContain("gpt-5.5"); + expect(visibleNativeSlugs({ ...config, disabledModels: ["gpt-5.5"] })).not.toContain("gpt-5.5"); + + let server = startServer(0); + try { + const plain = await fetch(new URL("/v1/models", server.url)) + .then(response => response.json()) as { data: Array<{ id: string }> }; + expect(plain.data.some(model => model.id === "gpt-5.4-mini")).toBe(false); + + const catalog = await fetch(new URL("/v1/models?client_version=1.0.0", server.url)) + .then(response => response.json()) as { + models: Array<{ slug: string; visibility?: string }>; + }; + expect(catalog.models.find(model => model.slug === "gpt-5.5")?.visibility).toBe("list"); + } finally { + await server.stop(true); + } + + config.codexAccountNamespaces = { team: "@main" }; + config.disabledModels = ["gpt-5.4"]; + saveConfig(config); + resetCatalogRuntimeStateForTests(); + expect(visibleNativeSlugs(config)).toContain("gpt-5.5"); + expect(visibleNativeSlugs(config)).not.toContain("gpt-5.4"); + server = startServer(0); + try { + const plain = await fetch(new URL("/v1/models", server.url)) + .then(response => response.json()) as { + data: Array<{ id: string; reasoning_efforts?: unknown[] }>; + }; + expect(plain.data.find(model => model.id === "gpt-5.5")?.reasoning_efforts).toBeArray(); + expect(plain.data.find(model => model.id === "team/gpt-5.5")?.reasoning_efforts) + .toEqual(plain.data.find(model => model.id === "gpt-5.5")?.reasoning_efforts); + expect(plain.data.some(model => model.id === "gpt-5.4")).toBe(false); + expect(plain.data.some(model => model.id === "team/gpt-5.4")).toBe(false); + // Activating account selectors makes both bare and qualified discovery mirror the complete + // enabled supported set, even when a partial custom catalog omitted this native. + expect(plain.data.find(model => model.id === "gpt-5.4-mini")?.reasoning_efforts) + .toBeArray(); + expect(plain.data.find(model => model.id === "team/gpt-5.4-mini")?.reasoning_efforts) + .toEqual(plain.data.find(model => model.id === "gpt-5.4-mini")?.reasoning_efforts); + + const catalog = await fetch(new URL("/v1/models?client_version=1.0.0", server.url)) + .then(response => response.json()) as { + models: Array<{ + slug: string; + visibility?: string; + opencodex_catalog_kind?: string; + }>; + }; + expect(catalog.models.find(model => model.slug === "gpt-5.5")?.visibility).toBe("hide"); + expect(catalog.models.find(model => model.slug === "team/gpt-5.5")).toMatchObject({ + visibility: "list", + opencodex_catalog_kind: "account-selector-v1", + }); + expect(catalog.models.find(model => model.slug === "team/gpt-5.4")?.visibility) + .toBe("hide"); + expect(catalog.models.find(model => model.slug === "team/gpt-5.4-mini")?.visibility) + .toBe("list"); + } finally { + await server.stop(true); } }); @@ -223,6 +319,40 @@ test("account selectors stay out of discovery when no canonical OpenAI provider expect(catalog.models.some(model => model.slug.startsWith("desktop/"))).toBe(false); expect(catalog.models.some(model => model.slug.startsWith("gpt-"))).toBe(false); } finally { - server.stop(true); + await server.stop(true); + } +}); + +test("disabled canonical OpenAI preserves bare bootstrap rows without advertising account routes", async () => { + const config = { + port: 0, + defaultProvider: "openai", + openaiProviderTierVersion: 2, + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + disabled: true, + liveModels: false, + }, + }, + codexAccounts: [{ id: "stored-side-account", isMain: false }], + codexAccountNamespaces: { team: "stored-side-account" }, + } as OcxConfig; + saveConfig(config); + const server = startServer(0); + try { + const plain = await fetch(new URL("/v1/models", server.url)).then(response => response.json()) as { + data: Array<{ id: string }>; + }; + expect(plain.data.some(model => model.id.startsWith("gpt-"))).toBe(true); + expect(plain.data.some(model => model.id.startsWith("team/"))).toBe(false); + + const catalog = await fetch(new URL("/v1/models?client_version=1.0.0", server.url)) + .then(response => response.json()) as { models: Array<{ slug: string }> }; + expect(catalog.models.some(model => model.slug.startsWith("gpt-"))).toBe(true); + expect(catalog.models.some(model => model.slug.startsWith("team/"))).toBe(false); + } finally { + await server.stop(true); } }); diff --git a/tests/codex-catalog-restore.test.ts b/tests/codex-catalog-restore.test.ts index 8fce54ef6..7b7f0a698 100644 --- a/tests/codex-catalog-restore.test.ts +++ b/tests/codex-catalog-restore.test.ts @@ -63,6 +63,134 @@ describe("Codex catalog restore", () => { expect(slugs).toEqual(["gpt-5.5", "user-native"]); }, { timeout: 15_000 }); + test("fallback restore repairs only enabled natives with unanimously visible account clones", () => { + const catalogPath = join(codexHome, "catalog.json"); + writeFileSync(join(codexHome, "config.toml"), 'model_catalog_json = "catalog.json"\n', "utf8"); + writeFileSync(join(opencodexHome, "config.json"), JSON.stringify({ + disabledModels: ["gpt-5.4"], + }), "utf8"); + writeFileSync(catalogPath, JSON.stringify({ + models: [ + { slug: "gpt-5.5", visibility: "hide", priority: 7 }, + { slug: "gpt-5.4", visibility: "hide" }, + { slug: "gpt-5.3-codex-spark", visibility: "hide" }, + { slug: "user-native", visibility: "hide" }, + { + slug: "team/gpt-5.5", + visibility: "list", + opencodex_catalog_kind: "account-selector-v1", + }, + { + slug: "team/gpt-5.4", + visibility: "list", + opencodex_catalog_kind: "account-selector-v1", + }, + { slug: "provider/gpt-5.3-codex-spark", visibility: "list" }, + ], + }, null, 2) + "\n"); + + const r = runScript(codexHome, opencodexHome, ` + const { restoreCodexCatalog } = require("./src/codex/catalog"); + const first = restoreCodexCatalog(); + const second = restoreCodexCatalog(); + console.log(JSON.stringify({ first, second })); + `); + + expect(r.status).toBe(0); + expect(JSON.parse(r.stdout)).toEqual({ + first: { removed: 3, kept: 4, path: catalogPath }, + second: { removed: 0, kept: 4, path: catalogPath }, + }); + const restored = JSON.parse(readFileSync(catalogPath, "utf8")).models as Array>; + expect(restored.find(model => model.slug === "gpt-5.5")).toMatchObject({ + visibility: "list", + priority: 7, + }); + expect(restored.find(model => model.slug === "gpt-5.4")?.visibility).toBe("hide"); + expect(restored.find(model => model.slug === "gpt-5.3-codex-spark")?.visibility).toBe("hide"); + expect(restored.find(model => model.slug === "user-native")?.visibility).toBe("hide"); + expect(restored.some(model => String(model.slug).includes("/"))).toBe(false); + }, { timeout: 15_000 }); + + test("fallback restore leaves hidden natives untouched when current config is unreadable", () => { + const catalogPath = join(codexHome, "catalog.json"); + const configPath = join(opencodexHome, "config.json"); + writeFileSync(join(codexHome, "config.toml"), 'model_catalog_json = "catalog.json"\n', "utf8"); + writeFileSync(configPath, "{", "utf8"); + writeFileSync(catalogPath, JSON.stringify({ + models: [ + { slug: "gpt-5.5", visibility: "hide" }, + { + slug: "team/gpt-5.5", + visibility: "list", + opencodex_catalog_kind: "account-selector-v1", + }, + ], + }, null, 2) + "\n"); + + const r = runScript(codexHome, opencodexHome, ` + const { restoreCodexCatalog } = require("./src/codex/catalog"); + console.log(JSON.stringify(restoreCodexCatalog())); + `); + + expect(r.status).toBe(0); + expect(JSON.parse(r.stdout)).toMatchObject({ removed: 1, kept: 1 }); + expect(JSON.parse(readFileSync(catalogPath, "utf8")).models).toEqual([ + { slug: "gpt-5.5", visibility: "hide" }, + ]); + expect(readFileSync(configPath, "utf8")).toBe("{"); + }, { timeout: 15_000 }); + + test("backup restore repairs only later native additions with trusted visible clones", () => { + const catalogPath = join(codexHome, "catalog.json"); + const backupPath = backupPathForTestCatalog(codexHome, opencodexHome, "catalog.json"); + writeFileSync(join(codexHome, "config.toml"), 'model_catalog_json = "catalog.json"\n', "utf8"); + writeFileSync(backupPath, JSON.stringify({ + models: [{ slug: "gpt-5.4", visibility: "hide", priority: 50 }], + }, null, 2) + "\n"); + writeFileSync(catalogPath, JSON.stringify({ + models: [ + { slug: "gpt-5.4", visibility: "hide", priority: 0 }, + { slug: "gpt-5.5", visibility: "hide", priority: 7 }, + { slug: "gpt-5.3-codex-spark", visibility: "hide" }, + { + slug: "team/gpt-5.4", + visibility: "list", + opencodex_catalog_kind: "account-selector-v1", + }, + { + slug: "team/gpt-5.5", + visibility: "list", + opencodex_catalog_kind: "account-selector-v1", + }, + { + slug: "team/gpt-5.3-codex-spark", + visibility: "list", + opencodex_catalog_kind: "account-selector-v1", + }, + { + slug: "desktop/gpt-5.3-codex-spark", + visibility: "hide", + opencodex_catalog_kind: "account-selector-v1", + }, + ], + }, null, 2) + "\n"); + + const r = runScript(codexHome, opencodexHome, ` + const { restoreCodexCatalog } = require("./src/codex/catalog"); + console.log(JSON.stringify(restoreCodexCatalog())); + `); + + expect(r.status).toBe(0); + expect(JSON.parse(r.stdout)).toMatchObject({ removed: 4, kept: 3 }); + const restored = JSON.parse(readFileSync(catalogPath, "utf8")).models as Array>; + expect(restored).toEqual([ + { slug: "gpt-5.4", visibility: "hide", priority: 50 }, + { slug: "gpt-5.5", visibility: "list", priority: 7 }, + { slug: "gpt-5.3-codex-spark", visibility: "hide" }, + ]); + }, { timeout: 15_000 }); + test("uses pristine backup while preserving native entries added after sync", () => { const catalogPath = join(codexHome, "catalog.json"); const backupPath = backupPathForTestCatalog(codexHome, opencodexHome, "catalog.json"); diff --git a/tests/codex-catalog-sync-hardening.test.ts b/tests/codex-catalog-sync-hardening.test.ts index a5a7e9cdf..63bf01493 100644 --- a/tests/codex-catalog-sync-hardening.test.ts +++ b/tests/codex-catalog-sync-hardening.test.ts @@ -374,6 +374,79 @@ describe("Codex catalog sync hardening", () => { expect(rows.some(row => row.slug === "desktop/gpt-5.5")).toBe(false); }); + test("disabled canonical OpenAI keeps bare bootstrap rows but omits unrouteable account rows", () => { + const catalogPath = join(codexHome, "catalog.json"); + writeFileSync(join(codexHome, "config.toml"), 'model_catalog_json = "catalog.json"\n', "utf8"); + writeFileSync(catalogPath, JSON.stringify({ + models: [nativeEntry("gpt-5.5", 0)], + }, null, 2) + "\n"); + + const r = runScript(codexHome, opencodexHome, ` + const { syncCatalogModels } = require("./src/codex/catalog"); + syncCatalogModels({ + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + disabled: true, + liveModels: false + } + }, + codexAccounts: [{ id: "stored-side-account", isMain: false }], + codexAccountNamespaces: { team: "stored-side-account" } + }).then(res => console.log(JSON.stringify(res))); + `); + expect(r.status).toBe(0); + + const rows = JSON.parse(readFileSync(catalogPath, "utf8")).models as Array<{ + slug: string; + visibility?: string; + }>; + expect(rows.find(row => row.slug === "gpt-5.5")?.visibility).toBe("list"); + expect(rows.some(row => row.slug.startsWith("team/"))).toBe(false); + }); + + test("account sync recovers supported natives that were hidden before selectors existed", () => { + const catalogPath = join(codexHome, "catalog.json"); + writeFileSync(join(codexHome, "config.toml"), 'model_catalog_json = "catalog.json"\n', "utf8"); + writeFileSync(catalogPath, JSON.stringify({ + models: [ + { ...nativeEntry("gpt-5.5", 0), visibility: "hide" }, + nativeEntry("gpt-5.4", 1), + ], + }, null, 2) + "\n"); + + const r = runScript(codexHome, opencodexHome, ` + const { syncCatalogModels } = require("./src/codex/catalog"); + syncCatalogModels({ + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + liveModels: false + } + }, + disabledModels: ["gpt-5.4"], + codexAccounts: [{ id: "stored-side-account", isMain: false }], + codexAccountNamespaces: { team: "stored-side-account" } + }).then(res => console.log(JSON.stringify(res))); + `); + expect(r.status).toBe(0); + + const rows = JSON.parse(readFileSync(catalogPath, "utf8")).models as Array<{ + slug: string; + visibility?: string; + opencodex_catalog_kind?: string; + }>; + expect(rows.find(row => row.slug === "gpt-5.5")?.visibility).toBe("hide"); + // Generated rows recover from stale bare visibility, but still honor explicit native disables. + expect(rows.find(row => row.slug === "team/gpt-5.5")).toMatchObject({ + visibility: "list", + opencodex_catalog_kind: "account-selector-v1", + }); + expect(rows.find(row => row.slug === "team/gpt-5.4")?.visibility).toBe("hide"); + }); + test("default catalog path merges from disk instead of replacing it with bundled rows", () => { const catalogPath = join(codexHome, "opencodex-catalog.json"); writeFileSync(join(codexHome, "config.toml"), 'openai_base_url = "http://127.0.0.1:10100/v1"\n', "utf8"); diff --git a/tests/multi-agent-compat.test.ts b/tests/multi-agent-compat.test.ts index cb829aeab..952261dcf 100644 --- a/tests/multi-agent-compat.test.ts +++ b/tests/multi-agent-compat.test.ts @@ -222,6 +222,7 @@ describe("multiAgentGuidanceText", () => { priority: 3, accountBound: true, }, + { slug: "local-fast", efforts: ["high"], priority: 4 }, ]); const projected = effectiveSubagentRoster(["gpt-5.6-sol"], "v2"); @@ -247,6 +248,25 @@ describe("multiAgentGuidanceText", () => { expect(text).not.toContain('"vendor/gpt-5.6-sol"'); expect(text).toContain("kimi/k3"); + const custom = await multiAgentGuidanceText( + parsedFixture({ tools: [{ name: "spawn_agent" }] }), + { + injectionModel: "gpt-5.6-sol", + codexAccountNamespace: "team", + injectionPrompt: "Use {{model}}.", + }, + ); + expect(custom).toBe('Use team/gpt-5.6-sol.'); + + const exactBare = await multiAgentGuidanceText( + parsedFixture({ tools: [{ name: "spawn_agent" }] }), + { + injectionModel: "local-fast", + codexAccountNamespace: "team", + }, + ); + expect(exactBare).toContain('Preferred sub-agent: model "local-fast"'); + const bareParent = await multiAgentGuidanceText( parsedFixture({ tools: [{ name: "spawn_agent" }] }), { subagentModels: ["gpt-5.6-sol"] }, diff --git a/tests/native-model-toggle.test.ts b/tests/native-model-toggle.test.ts index be97c09d6..4ee8c5023 100644 --- a/tests/native-model-toggle.test.ts +++ b/tests/native-model-toggle.test.ts @@ -9,6 +9,7 @@ import { mergeCatalogEntriesForSync, NATIVE_OPENAI_MODELS, nativeModelRows, + shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi, trustedAccountBoundNativeCatalogSlug, visibleCodexAccountSelectors, @@ -211,6 +212,14 @@ describe("native GPT model toggles (bare slugs in disabledModels)", () => { expect(shouldIncludeNativeOpenAi({ providers: { openai: { ...canonical, disabled: true } }, })).toBe(true); + expect(shouldIncludeAccountBoundNativeOpenAi({ providers: {} })).toBe(false); + expect(shouldIncludeAccountBoundNativeOpenAi({ providers: { openai: canonical } })).toBe(true); + expect(shouldIncludeAccountBoundNativeOpenAi({ + providers: { openai: { ...canonical, disabled: true } }, + })).toBe(false); + expect(shouldIncludeAccountBoundNativeOpenAi({ + providers: { openai: { ...canonical, authMode: "key" } }, + })).toBe(false); }); test("case-distinct routing selectors remain distinguishable in picker labels", () => { diff --git a/tests/subagent-model-fallback.test.ts b/tests/subagent-model-fallback.test.ts index 3c2726746..1c6445159 100644 --- a/tests/subagent-model-fallback.test.ts +++ b/tests/subagent-model-fallback.test.ts @@ -45,7 +45,10 @@ function cfg(overrides: Partial = {}): OcxConfig { port: 10100, providers: { // Omitted codexAccountMode — canonical openai defaults to pool via routeModel. - openai: { adapter: "openai-responses" }, + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + }, "alibaba-token-plan": { adapter: "openai-chat", apiKey: "test", baseUrl: "https://example.invalid" }, kimi: { adapter: "openai-chat", apiKey: "test", baseUrl: "https://example.invalid" }, xai: { adapter: "openai-chat", apiKey: "test", baseUrl: "https://api.x.ai/v1" }, @@ -169,6 +172,49 @@ describe("subagent model fallback chain", () => { }); }); + test("selectAvailableSubagentModel admits account selectors and checks their fixed account", () => { + resetSubagentModelFallbackStateForTests(); + updateAccountQuota("pool-a", 95, undefined, 20); + updateAccountQuota("account-a", 10, undefined, 20); + const config = cfg({ + codexAccountNamespaces: { team: "account-a" }, + subagentModelFallback: ["team/gpt-5.5", "kimi/k3"], + }); + + expect(isSubagentModelUnavailable("team/gpt-5.5", config, "pool-a")).toBe(false); + expect(selectAvailableSubagentModel("gpt-5.6-sol", config, [], "pool-a")).toEqual({ + model: "team/gpt-5.5", + rewritten: true, + skipped: ["gpt-5.6-sol"], + }); + }); + + test("account selector fallbacks still reject invalid or disabled native models", () => { + resetSubagentModelFallbackStateForTests(); + updateAccountQuota("pool-a", 95, undefined, 20); + updateAccountQuota("account-a", 10, undefined, 20); + const config = cfg({ + codexAccountNamespaces: { team: "account-a" }, + subagentModelFallback: ["team/claude-opus-4-6", "kimi/k3"], + }); + + expect(selectAvailableSubagentModel("gpt-5.6-sol", config, [], "pool-a")).toEqual({ + model: "kimi/k3", + rewritten: true, + skipped: ["gpt-5.6-sol", "team/claude-opus-4-6"], + }); + expect(isSubagentModelUnavailable( + "team/gpt-5.5", + { ...config, disabledModels: ["gpt-5.5"] }, + "pool-a", + )).toBe(true); + expect(isSubagentModelUnavailable( + "team/gpt-5.5", + { ...config, disabledModels: ["openai/gpt-5.5"] }, + "pool-a", + )).toBe(true); + }); + test("noteSubagentModelFailure treats numeric 429 as quota-like", () => { resetSubagentModelFallbackStateForTests(); noteSubagentModelFailure("kimi/k3", "429", cfg()); From c2a5a93d1b1ebc52c65f82c7d80a3ef66f446d54 Mon Sep 17 00:00:00 2001 From: chrisae9 Date: Mon, 3 Aug 2026 22:07:38 -0400 Subject: [PATCH 05/13] fix(codex): address account catalog follow-up --- .../ja/reference/configuration/providers.md | 7 +- .../ja/reference/configuration/routing.md | 8 +- .../ko/reference/configuration/providers.md | 7 +- .../ko/reference/configuration/routing.md | 9 +- .../docs/reference/configuration/providers.md | 7 +- .../docs/reference/configuration/routing.md | 8 +- .../ru/reference/configuration/providers.md | 7 +- .../ru/reference/configuration/routing.md | 8 +- .../reference/configuration/providers.md | 7 +- .../zh-cn/reference/configuration/routing.md | 4 +- src/codex/catalog/metadata.ts | 6 +- src/codex/catalog/sync.ts | 41 +++++---- src/codex/subagent-model-fallback.ts | 10 ++- src/server/index.ts | 11 +-- src/types.ts | 8 +- structure/03_catalog-and-subagents.md | 9 +- tests/claude-models-discovery.test.ts | 9 +- tests/codex-catalog-restore.test.ts | 9 +- tests/codex-catalog-sync-hardening.test.ts | 8 +- tests/native-model-toggle.test.ts | 22 +++++ tests/subagent-model-fallback.test.ts | 88 ++++++++++++++++++- 21 files changed, 225 insertions(+), 68 deletions(-) diff --git a/docs-site/src/content/docs/ja/reference/configuration/providers.md b/docs-site/src/content/docs/ja/reference/configuration/providers.md index 0285abddb..17cecb3d1 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -11,12 +11,12 @@ description: プロバイダー エントリ、認証、エンドポイント、 | --- | --- | --- | --- | | `providers` | `Record` | — |プロバイダー名からプロバイダー設定へのマップ。 | | `openaiProviderTierVersion?` | `2` |移行によって設定される |単一のオプション対応 OpenAI プロジェクションを完了としてマークします。 | -| `disabledModels?` | `string[]` | — |モデルは Codex のカタログおよび `/v1/models` からは隠されていますが、直接のプロキシ呼び出しからはブロックされません。ルーティングされた ID はリストから削除されます。裸のネイティブ GPT ID は `visibility: "hide"` を取得します。 | +| `disabledModels?` | `string[]` | — | Codex catalog と `/v1/models` から非表示にする model。直接の proxy 呼び出しはブロックしません。routed id は一覧から削除されます。account-qualified native id は該当する selector row だけを非表示にし、bare native GPT id は bare row とその model の全 account-selector row を非表示にします。 | | `providerContextCaps?` | `Record` | `{}` |プロバイダーごとの Codex に表示されるコンテキストの上限。キャップは既知のコンテキスト ウィンドウを下げるだけです。 | | `contextCapValue?` | `number` | `350000` |ダッシュボードのコンテキストキャップ コントロールで使用される値。これを変更すると、有効になっているすべての `providerContextCaps` エントリが更新されます。 | | `codexAccounts?` | `CodexAccount[]` | `[]` | ChatGPT/Codex プール アカウントのメタデータは Codex Auth によって管理されます。秘密は`codex-accounts.json`に別に住んでいます。 | | `pausedCodexAccountIds?` | `string[]` | `[]` |再開するまでプールの選択から除外されるアカウント (一時停止時のメイン `__main__` アカウントを含む)。 | -| `codexAccountNamespaces?` | `Record` | — | 公開 model selector namespace から保存済み Codex アカウント target への任意 map。`/` は対応するアカウントだけに routing され、この設定自体は model picker row を追加しません。 | +| `codexAccountNamespaces?` | `Record` | — | 任意の公開 model selector を保存済み Codex アカウント target に対応付ける任意の map。target が存在する各 selector は Codex picker に個別の `/` row を追加し、各 row はそのアカウントだけを使用します。selector が 1 つでも有効な場合、bare native row は picker で非表示になりますが、明示的に無効化されない限り id は引き続き routing でき、raw `/v1/models` にも表示されます。 | | `activeCodexAccountId?` | `string` | — |次のリクエスト用に手動で選択されたプール アカウント。選択するとスレッドのアフィニティがクリアされます。実行中のリクエストでは、取得された資格情報が保持されます。 | | `autoSwitchThreshold?` | `number` | `80` | 使用量ベースのプロアクティブ切り替えしきい値。`quota` は紐付け済み/未紐付けタスクの次のリクエストを再評価でき、`fill-first` は未紐付け割り当ての使い切り基準としてのみ使用し、通常の `round-robin` 選択は使用しません。既知の 5 時間、週次、30 日 quota window の最大スコアを使います。`0` は使用量ベースの切り替えだけを無効にし、未紐付け割り当てや障害回復は無効にしません。 | | `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | 新規/未紐付け Codex リクエストの割り当て戦略。live な `(parent thread id, quota scope)` affinity がなければ未紐付けで、プロキシ再起動や affinity リセット後は既存の表示タスクも未紐付けになり得ます。`quota` はアクティブアカウントがなければ既知 usage 最小の適格アカウントを選び、適格なアクティブアカウントが `autoSwitchThreshold` 未満なら維持します。しきい値到達後は、未紐付けリクエストまたは紐付け済みタスクの次のリクエストを usage の低い適格アカウントへ移せます。`round-robin` は未紐付けリクエストを均等分散し、`fill-first` は cooldown、使用不可、または drain threshold までアクティブアカウントへ割り当てます。 | @@ -26,7 +26,8 @@ description: プロバイダー エントリ、認証、エンドポイント、 | `cacheRetention?` | `"none" \| "short" \| "long"` | `"short"` | Anthropic プロンプト キャッシュ ポリシー: 無効、5 分間の一時的、または 1 時間の延長。 | | `tokenGuardian?` | `OcxTokenGuardianConfig` |オフ |オプションのプロアクティブな OAuth 更新および Codex アカウントのウォームアップ ポリシー。 | -`codexAccountNamespaces` のキーは公開 selector です。長さは 1〜64 文字、先頭と末尾は ASCII +selector 名はユーザーが選ぶ公開 label であり、opencodex はアカウント role の意味を付与しません。 +`codexAccountNamespaces` のキーは長さ 1〜64 文字、先頭と末尾は ASCII 英数字、内部には英数字、`.`、`_`、`-` を使用でき、予約済み JavaScript object 名は拒否されます。 値は有効な pool account id(内部 `__main__` は不可)、または Codex Desktop アカウントを示す `"@main"` です。provider と予約済み `openai` / `combo` との衝突は大文字小文字を区別せず検査され、 diff --git a/docs-site/src/content/docs/ja/reference/configuration/routing.md b/docs-site/src/content/docs/ja/reference/configuration/routing.md index ee23d0990..2c4c32092 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/routing.md +++ b/docs-site/src/content/docs/ja/reference/configuration/routing.md @@ -37,9 +37,11 @@ selector の後には bare native OpenAI-family id だけを指定できます 明示的な選択は Pool assignment strategy と通常の thread affinity を迂回します。対応する account が 存在しない、一時停止中、cooldown 中、利用不能、または再認証が必要な場合、request は別の account -へ切り替えず fail closed し、active Pool account も変更しません。bare native model id は通常の -Pool / Direct routing を維持します。namespace map 自体は model picker row を作成しません。 -selector の検証、衝突規則、privacy guidance は +へ切り替えず fail closed し、active Pool account も変更しません。適格な selector が 1 つ以上 +設定されると、Codex catalog は bare native picker row を非表示にし、selector ごとに個別の +`/` row を表示します。bare native model id は明示的に無効化されない +限り通常の Pool / Direct routing を維持し、raw `/v1/models` にも残ります。対応する保存済み account +が存在しない selector は表示されません。selector の検証、衝突規則、privacy guidance は [プロバイダーの構成](/reference/configuration/providers/)を参照してください。 ## コンボ (`config.combos`) diff --git a/docs-site/src/content/docs/ko/reference/configuration/providers.md b/docs-site/src/content/docs/ko/reference/configuration/providers.md index 38a499c6c..a75469e25 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -11,12 +11,12 @@ description: 공급자 항목, 인증, 엔드포인트, 모델 카탈로그, 할 | --- | --- | --- | --- | | `providers` | `Record` | — | 공급자 이름을 공급자 설정에 매핑합니다. | | `openaiProviderTierVersion?` | `2` | 마이그레이션으로 설정됨 | 옵션을 인식하는 단일 OpenAI 투영이 완료되었음을 표시합니다. | -| `disabledModels?` | `string[]` | — | Codex의 카탈로그와 `/v1/models`에서는 숨기지만, 직접 프록시 호출은 차단하지 않습니다. 라우팅된 id는 목록에서 제거되며, 순수 네이티브 GPT id에는 `visibility: "hide"`가 적용됩니다. | +| `disabledModels?` | `string[]` | — | Codex catalog와 `/v1/models`에서는 숨기지만 직접 proxy 호출은 차단하지 않습니다. routed id는 목록에서 제거됩니다. account-qualified native id는 해당 selector row만 숨기고, bare native GPT id는 bare row와 그 model의 모든 account-selector row를 숨깁니다. | | `providerContextCaps?` | `Record` | `{}` | 공급자별 Codex 표시 컨텍스트 상한입니다. 상한은 이미 알려진 컨텍스트 윈도만 낮춥니다. | | `contextCapValue?` | `number` | `350000` | 대시보드의 컨텍스트 상한 컨트롤이 사용하는 값입니다. 이 값을 바꾸면 활성화된 모든 `providerContextCaps` 항목이 함께 갱신됩니다. | | `codexAccounts?` | `CodexAccount[]` | `[]` | Codex Auth가 관리하는 ChatGPT/Codex 풀 계정 메타데이터입니다. 비밀 정보는 `codex-accounts.json`에 따로 저장됩니다. | | `pausedCodexAccountIds?` | `string[]` | `[]` | 일시 중지된 `__main__` 계정을 포함해, 재개될 때까지 Pool 선택에서 제외되는 계정입니다. | -| `codexAccountNamespaces?` | `Record` | — | 공개 model selector namespace에서 저장된 Codex 계정 target으로 연결하는 선택적 map입니다. `/`은 매핑된 계정으로만 routing되며, 이 설정 자체는 model picker row를 추가하지 않습니다. | +| `codexAccountNamespaces?` | `Record` | — | 임의의 공개 model selector를 저장된 Codex 계정 target에 연결하는 선택적 map입니다. target이 존재하는 각 selector는 Codex picker에 별도의 `/` row를 추가하며, 각 row는 해당 계정만 사용합니다. selector가 하나라도 활성화되면 bare native row는 picker에서 숨겨지지만, 명시적으로 비활성화하지 않는 한 해당 id는 계속 routing 가능하고 raw `/v1/models`에 표시됩니다. | | `activeCodexAccountId?` | `string` | — | 다음 요청에 수동으로 선택한 Pool 계정입니다. 선택하면 thread 결속이 해제되며, 진행 중인 요청은 캡처한 자격 증명을 유지합니다. | | `autoSwitchThreshold?` | `number` | `80` | 사용량 기반 선제 전환 임계값입니다. `quota`는 바인딩된 작업과 바인딩 없는 작업의 다음 요청을 모두 재평가할 수 있고, `fill-first`는 바인딩 없는 작업 배정의 소진 기준으로만 사용하며, 기본 `round-robin` 선택은 이 값을 사용하지 않습니다. 알려진 5시간, 주간, 30일 quota window 중 가장 높은 점수를 씁니다. `0`은 사용량 기반 전환만 끄며 바인딩 없는 작업 배정이나 실패 복구는 끄지 않습니다. | | `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | 새 작업/바인딩 없는 Codex 요청의 계정 배정 전략입니다. `(parent thread id, quota scope)`의 live affinity가 없으면 바인딩 없는 요청이며, 프록시 재시작이나 affinity 초기화 뒤에는 기존에 보이던 작업도 바인딩이 없어질 수 있습니다. `quota`는 활성 계정이 없을 때 알려진 usage가 가장 낮은 적격 계정을 선택하고, 적격 활성 계정이 `autoSwitchThreshold` 미만이면 유지합니다. 임계값 도달 뒤에는 바인딩 없는 요청이나 바인딩된 작업의 다음 요청을 usage가 더 낮은 적격 계정으로 옮길 수 있습니다. `round-robin`은 바인딩 없는 요청을 균등 분배하고, `fill-first`는 cooldown, 사용 불가 또는 drain threshold까지 활성 계정에 배정합니다. | @@ -26,7 +26,8 @@ description: 공급자 항목, 인증, 엔드포인트, 모델 카탈로그, 할 | `cacheRetention?` | `"none" \| "short" \| "long"` | `"short"` | Anthropic 프롬프트 캐시 정책입니다. 비활성, 5분짜리 임시, 1시간짜리 확장 중 하나입니다. | | `tokenGuardian?` | `OcxTokenGuardianConfig` | 꺼짐 | 선택적 선제 OAuth 갱신과 Codex 계정 워밍업 정책입니다. | -`codexAccountNamespaces` 키는 공개 selector입니다. 길이는 1~64자이고 시작과 끝은 ASCII 영숫자여야 +selector 이름은 사용자가 정하는 공개 label이며, opencodex는 여기에 계정 역할 의미를 부여하지 않습니다. +`codexAccountNamespaces` 키는 길이가 1~64자이고 시작과 끝은 ASCII 영숫자여야 하며, 내부에는 영숫자, `.`, `_`, `-`를 사용할 수 있습니다. 예약된 JavaScript object 이름은 거부됩니다. 값은 유효한 pool account id(내부 `__main__` 제외)이거나 Codex Desktop 계정을 나타내는 `"@main"`입니다. provider 및 예약된 `openai` / `combo` 충돌은 대소문자를 구분하지 않고 검사하며, namespace가 있는 diff --git a/docs-site/src/content/docs/ko/reference/configuration/routing.md b/docs-site/src/content/docs/ko/reference/configuration/routing.md index 17bd27560..0ae010707 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/routing.md +++ b/docs-site/src/content/docs/ko/reference/configuration/routing.md @@ -36,9 +36,12 @@ id만 사용할 수 있습니다. 명시적 선택은 Pool assignment strategy와 일반 thread affinity를 우회합니다. 매핑된 account가 없거나, 일시 중지되었거나, cooldown 중이거나, 사용할 수 없거나, 재인증이 필요하면 다른 account로 전환하지 -않고 fail closed하며 active Pool account도 변경하지 않습니다. bare native model id는 기존 Pool / -Direct routing을 유지합니다. namespace map 자체는 model picker row를 만들지 않습니다. selector 검증, -충돌 규칙, privacy guidance는 [공급자 설정](/reference/configuration/providers/)을 참고하십시오. +않고 fail closed하며 active Pool account도 변경하지 않습니다. 적격 selector가 하나 이상 설정되면 +Codex catalog는 bare native picker row를 숨기고 각 selector마다 별도의 +`/` row를 표시합니다. bare native model id는 명시적으로 비활성화하지 +않는 한 기존 Pool / Direct routing을 유지하고 raw `/v1/models`에도 남습니다. 매핑된 저장 계정이 없는 +selector는 표시되지 않습니다. selector 검증, 충돌 규칙, privacy guidance는 +[공급자 설정](/reference/configuration/providers/)을 참고하십시오. ## Combos (`config.combos`) diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 7e27d048b..388710f2d 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -12,12 +12,12 @@ authenticated. | --- | --- | --- | --- | | `providers` | `Record` | — | Map of provider name to provider config. | | `openaiProviderTierVersion?` | `2` | set by migration | Marks the single option-aware OpenAI projection as complete. | -| `disabledModels?` | `string[]` | — | Models hidden from Codex's catalog and `/v1/models`, but not blocked from direct proxy calls. Routed ids are removed from listings; bare native GPT ids get `visibility: "hide"`. | +| `disabledModels?` | `string[]` | — | Models hidden from Codex's catalog and `/v1/models`, but not blocked from direct proxy calls. Routed ids are removed from listings. An account-qualified native id hides only that selector row; a bare native GPT id hides the bare row and every account-selector row for that model. | | `providerContextCaps?` | `Record` | `{}` | Per-provider Codex-visible context caps. A cap only lowers a known context window. | | `contextCapValue?` | `number` | `350000` | Value used by the dashboard context-cap controls; changing it updates every enabled `providerContextCaps` entry. | | `codexAccounts?` | `CodexAccount[]` | `[]` | ChatGPT/Codex pool account metadata managed by Codex Auth. Secrets live separately in `codex-accounts.json`. | | `pausedCodexAccountIds?` | `string[]` | `[]` | Accounts excluded from Pool selection until resumed, including the main `__main__` account when paused. | -| `codexAccountNamespaces?` | `Record` | — | Optional public model-selector namespace to stored Codex account target. `/` routes using exactly the mapped account; this setting does not add model-picker rows. | +| `codexAccountNamespaces?` | `Record` | — | Optional map from an arbitrary public model selector to a stored Codex account target. Each selector whose target is present adds separate `/` rows to the Codex picker; each row uses only that account. With any selector active, bare native rows are hidden in the picker, but their ids remain routable and listed by raw `/v1/models` unless explicitly disabled. | | `activeCodexAccountId?` | `string` | — | Manually selected Pool account for the next request. Selection clears thread affinity; in-flight requests keep captured credentials. | | `autoSwitchThreshold?` | `number` | `80` | Usage threshold for proactive switching. `quota` can re-evaluate both bound and unbound tasks on their next request; `fill-first` uses it only as the drain point for unbound assignment; normal `round-robin` selection does not use it. The score uses the hottest known 5h, weekly, or 30d quota window. `0` disables usage-based proactive switching only, not unbound assignment or failure recovery. | | `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | Assignment strategy for new/unbound Codex requests. A request is unbound when it has no live (parent thread id, quota scope) affinity; a visible existing task can become unbound after proxy restart or affinity reset. `quota` picks the lowest-usage eligible account when no active account exists, keeps an eligible active account below `autoSwitchThreshold`, and after the threshold may move an unbound request or proactively rebind a bound task to a lower-usage eligible account. `round-robin` distributes unbound requests evenly; `fill-first` keeps assigning unbound requests to the active account until cooldown, unavailability, or the configured drain threshold. | @@ -27,7 +27,8 @@ authenticated. | `cacheRetention?` | `"none" \| "short" \| "long"` | `"short"` | Anthropic prompt-cache policy: disabled, 5-minute ephemeral, or 1-hour extended. | | `tokenGuardian?` | `OcxTokenGuardianConfig` | off | Optional proactive OAuth refresh and Codex-account warmup policy. | -`codexAccountNamespaces` keys are public selectors: 1–64 characters, starting and ending with an +Selector names are user-chosen public labels; opencodex assigns no account-role semantics to them. +`codexAccountNamespaces` keys are 1–64 characters, starting and ending with an ASCII letter or number, with letters, numbers, `.`, `_`, or `-` inside. Reserved JavaScript object names are rejected. Each value is a valid pool-account id (never internal `__main__`) or `"@main"` for the Codex Desktop account. Provider and reserved `openai` / `combo` collisions are checked diff --git a/docs-site/src/content/docs/reference/configuration/routing.md b/docs-site/src/content/docs/reference/configuration/routing.md index fe2256114..85a5c8917 100644 --- a/docs-site/src/content/docs/reference/configuration/routing.md +++ b/docs-site/src/content/docs/reference/configuration/routing.md @@ -40,9 +40,11 @@ ids are valid after the selector. Exact selection bypasses Pool assignment strategy and ordinary thread affinity. If the mapped account is missing, paused, cooling down, unusable, or requires reauthentication, the request fails -closed instead of switching accounts and does not change the active Pool account. Bare native model -ids retain normal Pool/Direct routing. The namespace map itself does not create model-picker rows. -Selector validation, collision rules, and privacy guidance are documented in +closed instead of switching accounts and does not change the active Pool account. When at least one +eligible selector is configured, Codex catalogs hide bare native picker rows and list a separate +`/` row for each selector. Bare native ids retain normal Pool/Direct +routing and remain in raw `/v1/models` discovery unless explicitly disabled. Selectors whose mapped +stored account is missing are not advertised. Selector validation, collision rules, and privacy guidance are documented in [Provider Configuration](/reference/configuration/providers/). ## Combos (`config.combos`) diff --git a/docs-site/src/content/docs/ru/reference/configuration/providers.md b/docs-site/src/content/docs/ru/reference/configuration/providers.md index 4003682d2..79a611694 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -12,12 +12,12 @@ description: Записи провайдеров, аутентификация, | --- | --- | --- | --- | | `providers` | `Record` | — | Map вида provider name → provider config. | | `openaiProviderTierVersion?` | `2` | set by migration | Отмечает, что единая projection OpenAI с учётом режима уже завершена. | -| `disabledModels?` | `string[]` | — | Модели, скрытые из каталога Codex и `/v1/models`, но не заблокированные для прямых вызовов прокси. Routed-id удаляются из списков; bare native GPT-id получают `visibility: "hide"`. | +| `disabledModels?` | `string[]` | — | Модели, скрытые из каталога Codex и `/v1/models`, но не заблокированные для прямых вызовов прокси. Routed-id удаляются из списков. Account-qualified native-id скрывает только строку этого селектора; bare native GPT-id скрывает bare-строку и строки всех селекторов аккаунтов для этой модели. | | `providerContextCaps?` | `Record` | `{}` | Context cap'ы, видимые Codex, по каждому провайдеру. Cap может только понижать известное context window. | | `contextCapValue?` | `number` | `350000` | Значение, используемое элементами управления context-cap в дашборде; его изменение обновляет все включённые записи `providerContextCaps`. | | `codexAccounts?` | `CodexAccount[]` | `[]` | Метаданные аккаунтов пула ChatGPT/Codex, которыми управляет Codex Auth. Секреты живут отдельно в `codex-accounts.json`. | | `pausedCodexAccountIds?` | `string[]` | `[]` | Аккаунты, исключённые из выбора Pool до снятия паузы, включая основной аккаунт `__main__`, если он поставлен на паузу. | -| `codexAccountNamespaces?` | `Record` | — | Необязательное сопоставление публичного namespace селектора модели с сохранённым аккаунтом Codex. `/` направляется только в сопоставленный аккаунт; сама настройка не добавляет строки в model picker. | +| `codexAccountNamespaces?` | `Record` | — | Необязательное сопоставление произвольного публичного селектора модели с сохранённым аккаунтом Codex. Каждый селектор с существующей целью добавляет в model picker Codex отдельные строки `/`; каждая строка использует только этот аккаунт. Если активен хотя бы один селектор, bare native-строки скрываются в picker, но их id остаются маршрутизируемыми и перечисляются raw `/v1/models`, если они не отключены явно. | | `activeCodexAccountId?` | `string` | — | Вручную выбранный аккаунт Pool для следующего запроса. Выбор очищает thread affinity; in-flight-запросы сохраняют уже захваченные credential'ы. | | `autoSwitchThreshold?` | `number` | `80` | Порог проактивного переключения по использованию. `quota` может повторно оценить следующий запрос как привязанной, так и непривязанной задачи; `fill-first` использует его только как точку исчерпания для непривязанных назначений; обычный `round-robin` его не использует. Оценка берёт самое горячее из окон 5 часов, недели и 30 дней. `0` отключает только переключение по использованию, но не назначение непривязанных задач и не восстановление после сбоев. | | `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | Стратегия назначения для новых/непривязанных запросов Codex. Запрос непривязан, если у него нет live affinity `(parent thread id, quota scope)`; видимая существующая задача может стать непривязанной после перезапуска прокси или сброса affinity. `quota` выбирает подходящий аккаунт с наименьшим известным usage, когда активного аккаунта нет, сохраняет подходящий активный аккаунт ниже `autoSwitchThreshold`, а после порога может перевести непривязанный запрос или следующий запрос привязанной задачи на подходящий аккаунт с меньшим usage. `round-robin` равномерно распределяет непривязанные запросы; `fill-first` назначает их активному аккаунту до cooldown, недоступности или порога исчерпания. | @@ -27,7 +27,8 @@ description: Записи провайдеров, аутентификация, | `cacheRetention?` | `"none" \| "short" \| "long"` | `"short"` | Политика prompt-cache Anthropic: отключено, 5-минутный ephemeral или 1-часовой extended. | | `tokenGuardian?` | `OcxTokenGuardianConfig` | off | Необязательная политика proactive OAuth refresh и warmup'а аккаунтов Codex. | -Ключи `codexAccountNamespaces` — публичные селекторы длиной 1–64 символа. Они должны начинаться и +Имена селекторов — выбранные пользователем публичные метки; opencodex не придаёт им семантики ролей +аккаунтов. Ключи `codexAccountNamespaces` имеют длину 1–64 символа. Они должны начинаться и заканчиваться ASCII-буквой или цифрой; внутри разрешены буквы, цифры, `.`, `_` и `-`. Зарезервированные имена объектов JavaScript запрещены. Значение — допустимый id аккаунта пула (кроме внутреннего `__main__`) либо `"@main"` для аккаунта Codex Desktop. Коллизии с provider и зарезервированными `openai` / `combo` diff --git a/docs-site/src/content/docs/ru/reference/configuration/routing.md b/docs-site/src/content/docs/ru/reference/configuration/routing.md index c5a21208b..e7deed4ae 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/routing.md +++ b/docs-site/src/content/docs/ru/reference/configuration/routing.md @@ -42,9 +42,11 @@ opencodex разрешает запрошенную модель в следую Точный выбор обходит стратегию назначения Pool и обычную thread affinity. Если сопоставленный аккаунт отсутствует, приостановлен, находится в cooldown, непригоден или требует повторной аутентификации, запрос -завершается ошибкой без переключения на другой аккаунт и без изменения active Pool account. Голые id -нативных моделей сохраняют обычную маршрутизацию Pool / Direct. Сам namespace map не создаёт строки -model picker. Проверка selector, правила коллизий и рекомендации по privacy описаны в разделе +завершается ошибкой без переключения на другой аккаунт и без изменения active Pool account. Если +настроен хотя бы один допустимый селектор, каталоги Codex скрывают bare native-строки picker и добавляют +отдельную строку `/` для каждого селектора. Bare native-id сохраняют +обычную маршрутизацию Pool / Direct и остаются в raw `/v1/models`, если не отключены явно. Селекторы, +чей сохранённый аккаунт отсутствует, не рекламируются. Проверка selector, правила коллизий и рекомендации по privacy описаны в разделе [Конфигурация провайдеров](/reference/configuration/providers/). ## Combo (`config.combos`) diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md index 4d672e90e..526326ff8 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md @@ -11,12 +11,12 @@ description: 提供者条目、身份验证、端点、模型目录、配额、 | --- | --- | --- | --- | | `providers` | `Record` | — | 提供者名称到提供者配置的映射。 | | `openaiProviderTierVersion?` | `2` | 由迁移设置 | 标记单一、可感知选项的 OpenAI 投影已完成。 | -| `disabledModels?` | `string[]` | — | 从 Codex 目录和 `/v1/models` 中隐藏,但不会阻止直接代理调用的模型。路由后的 id 会从列表中移除;裸原生 GPT id 会被设为 `visibility: "hide"`。 | +| `disabledModels?` | `string[]` | — | 从 Codex catalog 和 `/v1/models` 中隐藏、但不阻止直接 proxy 调用的 model。routed id 会从列表中移除。account-qualified native id 只隐藏对应 selector row;bare native GPT id 会隐藏 bare row 以及该 model 的所有 account-selector row。 | | `providerContextCaps?` | `Record` | `{}` | 按提供者设置、对 Codex 可见的上下文上限。上限只会降低已知的上下文窗口。 | | `contextCapValue?` | `number` | `350000` | 仪表板上下文上限控件使用的值;修改它会更新所有已启用的 `providerContextCaps` 条目。 | | `codexAccounts?` | `CodexAccount[]` | `[]` | 由 Codex Auth 管理的 ChatGPT/Codex 池账户元数据。密钥单独存放在 `codex-accounts.json` 中。 | | `pausedCodexAccountIds?` | `string[]` | `[]` | 在恢复之前从 Pool 选择中排除的账户,包括被暂停时的主 `__main__` 账户。 | -| `codexAccountNamespaces?` | `Record` | — | 可选的公开 model selector namespace 到已保存 Codex account target 的映射。`/` 只会路由到映射账号;此设置本身不会添加 model picker row。 | +| `codexAccountNamespaces?` | `Record` | — | 将任意公开 model selector 映射到已保存 Codex account target 的可选配置。target 存在的每个 selector 都会在 Codex picker 中添加独立的 `/` row,且每个 row 只使用对应账户。只要有 selector 生效,bare native row 就会在 picker 中隐藏;但除非显式禁用,其 id 仍可路由,并继续列在 raw `/v1/models` 中。 | | `activeCodexAccountId?` | `string` | — | 为下一次请求手动选定的 Pool 账户。选择会清除线程亲和性;进行中的请求会保留捕获到的凭据。 | | `autoSwitchThreshold?` | `number` | `80` | 基于用量的主动切换阈值。`quota` 可在下一次请求中重新评估已绑定和未绑定任务;`fill-first` 仅把它用作未绑定分配的耗尽点;正常 `round-robin` 不使用它。分数取已知 5 小时、周或 30 天 quota window 的最高值。`0` 只关闭基于用量的主动切换,不关闭未绑定任务分配或故障恢复。 | | `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | 新建/未绑定 Codex 请求的分配策略。没有 live `(parent thread id, quota scope)` affinity 的请求属于未绑定;代理重启或 affinity 重置后,已有可见任务也可能未绑定。`quota` 在没有活跃账号时选择已知 usage 最低的合格账号;活跃账号合格且低于 `autoSwitchThreshold` 时继续使用;达到阈值后,可把未绑定请求或已绑定任务的下一次请求切换到 usage 更低的合格账号。`round-robin` 均匀分配未绑定请求;`fill-first` 在 cooldown、不可用或耗尽阈值前持续分配给活跃账号。 | @@ -26,7 +26,8 @@ description: 提供者条目、身份验证、端点、模型目录、配额、 | `cacheRetention?` | `"none" \| "short" \| "long"` | `"short"` | Anthropic 提示缓存策略:禁用、5 分钟临时缓存,或 1 小时扩展缓存。 | | `tokenGuardian?` | `OcxTokenGuardianConfig` | 关闭 | 可选的主动 OAuth 刷新与 Codex 账户预热策略。 | -`codexAccountNamespaces` 的 key 是公开 selector:长度为 1–64 个字符,首尾必须是 ASCII 字母或数字, +selector 名称是用户自定的公开 label;opencodex 不会为其赋予账户角色语义。 +`codexAccountNamespaces` 的 key 长度为 1–64 个字符,首尾必须是 ASCII 字母或数字, 中间可使用字母、数字、`.`、`_` 或 `-`;保留的 JavaScript object 名称会被拒绝。value 必须是有效的 pool account id(不能是内部 `__main__`),或用 `"@main"` 表示 Codex Desktop 账号。与 provider 及 保留的 `openai` / `combo` 冲突时不区分大小写;带 namespace 的 combo alias 不能把 selector 复用为 diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/routing.md b/docs-site/src/content/docs/zh-cn/reference/configuration/routing.md index b4bf5c8f5..91de85c76 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/routing.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/routing.md @@ -39,7 +39,9 @@ upstream 发送裸 `gpt-5.6-sol` model id。selector 后只能使用裸原生 Op 精确选择会绕过 Pool 分配策略和普通 thread affinity。若映射账户不存在、已暂停、处于 cooldown、 不可用或需要重新认证,请求会 fail closed,不会切换到其他账户,也不会改变 active Pool account。 -裸原生 model id 保持正常 Pool / Direct routing。namespace map 本身不会创建 model picker row。 +配置至少一个合格 selector 后,Codex catalog 会隐藏 bare native picker row,并为每个 selector 显示 +独立的 `/` row。除非显式禁用,bare native model id 仍保持正常的 Pool / +Direct routing,并继续出现在 raw `/v1/models` 中。映射到缺失已保存账户的 selector 不会被展示。 selector 校验、冲突规则和隐私说明见[提供方配置](/reference/configuration/providers/)。 ## Combos (`config.combos`) diff --git a/src/codex/catalog/metadata.ts b/src/codex/catalog/metadata.ts index 13acd081e..73a63bd63 100644 --- a/src/codex/catalog/metadata.ts +++ b/src/codex/catalog/metadata.ts @@ -159,7 +159,7 @@ export function nativeModelRows(config: Pick): Arra export function applyNativeVisibility( entries: RawEntry[], - disabledNative: Set, + disabledModels: ReadonlySet, hideBareNative = false, ): RawEntry[] { for (const entry of entries) { @@ -169,7 +169,9 @@ export function applyNativeVisibility( if (!nativeSlug || (!accountBoundSlug && slug.includes("/")) || !SUPPORTED_NATIVE_OPENAI_SLUGS.has(nativeSlug)) continue; - entry.visibility = disabledNative.has(nativeSlug) || (!accountBoundSlug && hideBareNative) + const disabled = disabledModels.has(nativeSlug) + || (accountBoundSlug !== undefined && disabledModels.has(slug)); + entry.visibility = disabled || (!accountBoundSlug && hideBareNative) ? "hide" : "list"; } diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index d312de694..3659a0a67 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -32,7 +32,7 @@ import upstreamModelsSnapshot from "../data/upstream-models.json"; import { activeCodexModelsCachePath, applyJawcodeCatalogMetadata, applyMultiAgentMode, applyNativeOpenAiContextOverride, catalogModelSlug, ensureCatalogBackup, ensureStrictCatalogFields, findNativeTemplate, isRoutedModelCompatibilityExcluded, normalizeRoutedCatalogEntry, normalizeServiceTiers, readCatalog, readCatalogBackup, readCodexCatalogPath, readNativeBaseline } from "./parsing"; import type { CatalogModel, MultiAgentMode, RawEntry } from "./parsing"; -import { applyNativeVisibility, disabledNativeSlugs, isUnsupportedOpenAiNativeSlug, NATIVE_OPENAI_MODELS, nativeOpenAiSlugs, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi, shouldUpgradeToUpstreamEntry, SUPPORTED_NATIVE_OPENAI_SLUGS, upstreamNativeEntry } from "./metadata"; +import { applyNativeVisibility, isUnsupportedOpenAiNativeSlug, NATIVE_OPENAI_MODELS, nativeOpenAiSlugs, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi, shouldUpgradeToUpstreamEntry, SUPPORTED_NATIVE_OPENAI_SLUGS, upstreamNativeEntry } from "./metadata"; import { loadCatalogForSync, resetBundledCatalogCacheForTests } from "./bundled"; import { isMultiAgentV2Enabled } from "../features"; import { applyCatalogModelMetadata, applyReasoningLevels, catalogEntryEfforts, clampCatalogModelsToCodexSupport, ensureGpt56ReasoningLevels, ensureUltraReasoningLevel, isGpt56NativeSlug } from "./effort"; @@ -408,7 +408,7 @@ export function mergeCatalogEntriesForSync( wsEnabled: boolean, goIds: Set = new Set(), template: RawEntry | null = null, - disabledNative: Set = new Set(), + disabledModels: ReadonlySet = new Set(), gatheredProviderNames: Set = new Set(routedEntries.flatMap(entry => { const slug = typeof entry.slug === "string" ? entry.slug : ""; const slash = slug.indexOf("/"); @@ -584,10 +584,11 @@ export function mergeCatalogEntriesForSync( } return e; }); - // Native enable/disable (single choke point: bare slugs in `disabledModels`). Runs as the - // LAST pass so the upstream-upgrade branch above can never clobber a hide flag back to list. + // 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( - applyNativeVisibility(mergedEntries, disabledNative, alignedAccountBoundEntries.length > 0), + applyNativeVisibility(mergedEntries, disabledModels, alignedAccountBoundEntries.length > 0), multiAgentMode, isMultiAgentV2Enabled(), ); @@ -679,7 +680,7 @@ export async function syncCatalogModels(config: OcxConfig): Promise<{ wsEnabled, goIds, template, - disabledNativeSlugs(config), + new Set(config.disabledModels ?? []), gatheredProviderNames, multiAgentMode, exactComboSlugs, @@ -698,12 +699,18 @@ export async function syncCatalogModels(config: OcxConfig): Promise<{ }; } -function visibleAccountReplacementNatives(models: readonly RawEntry[]): Map { +function visibleAccountReplacementNatives( + models: readonly RawEntry[], + disabledModels: ReadonlySet | null, +): Map { const replacements = new Map(); for (const entry of models) { const nativeSlug = trustedAccountBoundNativeCatalogSlug(entry); if (nativeSlug === undefined || !SUPPORTED_NATIVE_OPENAI_SLUGS.has(nativeSlug)) continue; - const visible = entry.visibility === "list"; + const exactSlug = typeof entry.slug === "string" ? entry.slug : ""; + const visible = entry.visibility === "list" + || (disabledModels !== null + && (disabledModels.has(nativeSlug) || disabledModels.has(exactSlug))); replacements.set(nativeSlug, (replacements.get(nativeSlug) ?? true) && visible); } return replacements; @@ -712,7 +719,7 @@ function visibleAccountReplacementNatives(models: readonly RawEntry[]): Map, - disabledNative: ReadonlySet | null, + disabledModels: ReadonlySet | null, ): RawEntry[] { return entries.map(entry => { const slug = typeof entry.slug === "string" ? entry.slug : ""; @@ -720,8 +727,8 @@ function restoreAccountHiddenBareNatives( entry.visibility !== "hide" || !SUPPORTED_NATIVE_OPENAI_SLUGS.has(slug) || replacementVisibility.get(slug) !== true - || disabledNative === null - || disabledNative.has(slug) + || disabledModels === null + || disabledModels.has(slug) ) { return entry; } @@ -729,11 +736,11 @@ function restoreAccountHiddenBareNatives( }); } -function currentDisabledNativeSlugsForRestore(): Set | null { +function currentDisabledModelsForRestore(): Set | null { try { const diagnostics = readConfigDiagnostics(); if (diagnostics.source === "fallback" || diagnostics.error !== null) return null; - return disabledNativeSlugs(diagnostics.config); + return new Set(diagnostics.config.disabledModels ?? []); } catch { // An unreadable config cannot safely authorize a visibility change during restore. return null; @@ -744,8 +751,8 @@ export function restoreCodexCatalog(): { removed: number; kept: number; path: st const catalogPath = readCodexCatalogPath(); const catalog = readCatalog(catalogPath); if (!catalog || !Array.isArray(catalog.models)) return { removed: 0, kept: 0, path: catalogPath }; - const replacementVisibility = visibleAccountReplacementNatives(catalog.models); - const disabledNative = currentDisabledNativeSlugsForRestore(); + const disabledModels = currentDisabledModelsForRestore(); + 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; @@ -755,7 +762,7 @@ export function restoreCodexCatalog(): { removed: number; kept: number; path: st typeof m.slug === "string" && !m.slug.includes("/") && !backupSlugs.has(m.slug) ), replacementVisibility, - disabledNative, + disabledModels, ); const restored = { ...backup, @@ -768,7 +775,7 @@ export function restoreCodexCatalog(): { removed: number; kept: number; path: st const native = restoreAccountHiddenBareNatives( catalog.models.filter(m => !(typeof m.slug === "string" && m.slug.includes("/"))), replacementVisibility, - disabledNative, + disabledModels, ); const removed = before - native.length; if (removed > 0) { diff --git a/src/codex/subagent-model-fallback.ts b/src/codex/subagent-model-fallback.ts index e312ad0a4..dd06564ac 100644 --- a/src/codex/subagent-model-fallback.ts +++ b/src/codex/subagent-model-fallback.ts @@ -15,7 +15,9 @@ import { CODEX_HOME, getCodexHome } from "./paths"; import { CODEX_UNKNOWN_USAGE_SCORE, getAccountQuota } from "./quota"; import { canAcquireCodexQuotaProbeLease, + codexQuotaScopeForModel, computeCodexUsageScore, + getCodexQuotaHealthSnapshot, getPoolAccountPlan, isCodexAccountInCooldown, } from "./routing"; @@ -216,7 +218,13 @@ export function isSubagentModelUnavailable( if (!resolvedAccountId) return true; if (isCodexAccountPaused(config, resolvedAccountId)) return true; if (!isCodexAccountUsable(config, resolvedAccountId, accountUsabilityOptions)) return true; - if ( + if (route.codexAccountId !== undefined) { + // An account-qualified route is pinned and cannot consume Pool's recovery-probe + // escape hatch. Honor both account-wide and model-scoped cooldowns so fallback + // advances instead of selecting a candidate that exact auth will reject. + const quotaScope = codexQuotaScopeForModel(route.modelId); + if (getCodexQuotaHealthSnapshot(resolvedAccountId, quotaScope, now) !== null) return true; + } else if ( isCodexAccountInCooldown(resolvedAccountId, now) && !canAcquireCodexQuotaProbeLease(resolvedAccountId, now) ) { diff --git a/src/server/index.ts b/src/server/index.ts index 001f8ea64..674573c8e 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -575,6 +575,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}) { const includeAccountBoundNativeOpenAi = shouldIncludeAccountBoundNativeOpenAi(config); const nativeSlugs = includeNativeOpenAi ? nativeOpenAiSlugs() : []; const disabledNatives = disabledNativeSlugs(config); + const disabledModels = new Set(config.disabledModels ?? []); const accountSelectors = includeAccountBoundNativeOpenAi ? visibleCodexAccountSelectors(config) : []; @@ -631,7 +632,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}) { return jsonResponse({ models: applyNativeVisibility( entries, - disabledNatives, + disabledModels, accountSelectors.length > 0, ), }, 200, req, config); @@ -682,10 +683,10 @@ export function startServer(port?: number, deps: StartServerDeps = {}) { ? accountSelectors.length > 0 ? selectorNativeSlugs : visibleNativeSlugs(config) : []; const visibleAccountNatives = accountSelectors.flatMap(selector => - selectorNativeSlugs.map(metadataId => ({ - id: `${selector}/${metadataId}`, - metadataId, - })) + selectorNativeSlugs.flatMap(metadataId => { + const id = `${selector}/${metadataId}`; + return disabledModels.has(id) ? [] : [{ id, metadataId }]; + }) ); const data = [ ...visibleNatives.map(id => nativeModelRow(id)), diff --git a/src/types.ts b/src/types.ts index 02d81668a..dc0713eb6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -644,10 +644,10 @@ export interface OcxConfig { */ subagentEffortCap?: string; /** - * Models hidden from Codex. Routed ids are namespaced ("/") and are excluded - * from the catalog + /v1/models entirely. BARE ids (no "/") are native GPT passthrough slugs: - * their catalog entries flip to visibility "hide" (entry preserved, picker-hidden) and they - * are omitted from the bare /v1/models list. + * Models hidden from Codex discovery without blocking direct proxy calls. Routed provider ids + * are excluded from the catalog + /v1/models entirely. Account-qualified native ids hide only + * their generated selector row and are omitted from raw /v1/models. BARE native GPT ids hide + * the bare row plus every generated selector row and omit that model family from raw discovery. */ disabledModels?: string[]; /** 사용자가 대시보드에서 직접 추가한 커스텀 모델 목록. */ diff --git a/structure/03_catalog-and-subagents.md b/structure/03_catalog-and-subagents.md index 6cb83d69d..ed37fd4fa 100644 --- a/structure/03_catalog-and-subagents.md +++ b/structure/03_catalog-and-subagents.md @@ -9,8 +9,9 @@ (`src/codex/data/upstream-models.json` — exact per-slug ladders: luna has no ultra); - clones a native template for routed `provider/model` entries; - forces strict Codex catalog fields required by the current parser; -- hides `disabledModels` (routed namespaced ids are excluded; BARE native slugs flip the - catalog entry to `visibility: "hide"` and drop from the bare `/v1/models` list); +- hides `disabledModels` without blocking direct routing (routed provider ids are excluded; + account-qualified native ids hide only that selector row; BARE native slugs hide the bare row + and all account-selector clones and drop that model family from raw `/v1/models`); - applies exact provider/model compatibility exclusions after live discovery and metadata augmentation, so upstream-advertised but uncallable rows never enter dashboard or Codex pickers; - strips native-only service tier and WebSocket metadata unless explicitly enabled; @@ -64,6 +65,10 @@ Pool mode routes across main plus added Codex credentials. Key rules: which maps to the config-only sentinel `@main`; the sentinel deliberately sits outside the pool-account id grammar. Selectors must not collide with provider or combo ids (`src/codex/account-namespaces.ts`, `src/codex/account-namespace-match.ts`). +- **Selector labels carry no account-role semantics.** When at least one selector is advertisable, + the Codex catalog clones each supported native row per selector and hides the bare picker rows; + bare ids remain routable and stay in raw `/v1/models` unless explicitly disabled. Missing stored + account targets are not advertised, and private account ids never become catalog labels. - **Rotation is sticky.** A conversation stays on its selected account while that account is usable; failure moves it, success does not (`src/codex/pool-rotation.ts`). - **The credential store is generation-guarded.** A refresh takes a lock and persists only if the diff --git a/tests/claude-models-discovery.test.ts b/tests/claude-models-discovery.test.ts index 9afc8fb4b..f5046fffd 100644 --- a/tests/claude-models-discovery.test.ts +++ b/tests/claude-models-discovery.test.ts @@ -156,7 +156,7 @@ test("OpenAI list shape and Codex catalog shape stay unchanged", async () => { } }); -test("configured account selectors appear in OpenAI and Codex discovery without private ids", async () => { +test("exact account disables affect only the matching OpenAI and Codex discovery row", async () => { const config = configWithStaticModels(); config.providers.openai = { adapter: "openai-responses", @@ -174,6 +174,7 @@ test("configured account selectors appear in OpenAI and Codex discovery without team: "stored-side-account", removed: "missing-account", }; + config.disabledModels = ["team/gpt-5.5"]; saveConfig(config); const server = startServer(0); try { @@ -183,9 +184,9 @@ test("configured account selectors appear in OpenAI and Codex discovery without const plainIds = plain.data.map(model => model.id); expect(plainIds).toContain("gpt-5.5"); expect(plainIds).toContain("desktop/gpt-5.5"); - expect(plainIds).toContain("team/gpt-5.5"); + expect(plainIds).not.toContain("team/gpt-5.5"); expect(plainIds.some(id => id.startsWith("removed/"))).toBe(false); - expect(plain.data.find(model => model.id === "team/gpt-5.5")?.reasoning_efforts) + expect(plain.data.find(model => model.id === "desktop/gpt-5.5")?.reasoning_efforts) .toEqual(plain.data.find(model => model.id === "gpt-5.5")?.reasoning_efforts); const catalog = await fetch(new URL("/v1/models?client_version=1.0.0", server.url)) @@ -195,7 +196,7 @@ test("configured account selectors appear in OpenAI and Codex discovery without expect(catalog.models.find(model => model.slug === "gpt-5.5")?.visibility).toBe("hide"); expect(catalog.models.find(model => model.slug === "desktop/gpt-5.5")) .toMatchObject({ display_name: "desktop / 5.5", visibility: "list" }); - expect(catalog.models.find(model => model.slug === "team/gpt-5.5")?.visibility).toBe("list"); + expect(catalog.models.find(model => model.slug === "team/gpt-5.5")?.visibility).toBe("hide"); expect(catalog.models.some(model => model.slug.startsWith("removed/"))).toBe(false); for (const privateValue of ["stored-side-account", "private@example.test", "Private Display Name"]) { expect(JSON.stringify(catalog)).not.toContain(privateValue); diff --git a/tests/codex-catalog-restore.test.ts b/tests/codex-catalog-restore.test.ts index 7b7f0a698..4c726dd88 100644 --- a/tests/codex-catalog-restore.test.ts +++ b/tests/codex-catalog-restore.test.ts @@ -67,7 +67,7 @@ describe("Codex catalog restore", () => { const catalogPath = join(codexHome, "catalog.json"); writeFileSync(join(codexHome, "config.toml"), 'model_catalog_json = "catalog.json"\n', "utf8"); writeFileSync(join(opencodexHome, "config.json"), JSON.stringify({ - disabledModels: ["gpt-5.4"], + disabledModels: ["gpt-5.4", "desktop/gpt-5.5"], }), "utf8"); writeFileSync(catalogPath, JSON.stringify({ models: [ @@ -80,6 +80,11 @@ describe("Codex catalog restore", () => { visibility: "list", opencodex_catalog_kind: "account-selector-v1", }, + { + slug: "desktop/gpt-5.5", + visibility: "hide", + opencodex_catalog_kind: "account-selector-v1", + }, { slug: "team/gpt-5.4", visibility: "list", @@ -98,7 +103,7 @@ describe("Codex catalog restore", () => { expect(r.status).toBe(0); expect(JSON.parse(r.stdout)).toEqual({ - first: { removed: 3, kept: 4, path: catalogPath }, + first: { removed: 4, kept: 4, path: catalogPath }, second: { removed: 0, kept: 4, path: catalogPath }, }); const restored = JSON.parse(readFileSync(catalogPath, "utf8")).models as Array>; diff --git a/tests/codex-catalog-sync-hardening.test.ts b/tests/codex-catalog-sync-hardening.test.ts index 63bf01493..6f261eb8a 100644 --- a/tests/codex-catalog-sync-hardening.test.ts +++ b/tests/codex-catalog-sync-hardening.test.ts @@ -426,9 +426,9 @@ describe("Codex catalog sync hardening", () => { liveModels: false } }, - disabledModels: ["gpt-5.4"], + disabledModels: ["gpt-5.4", "team/gpt-5.5"], codexAccounts: [{ id: "stored-side-account", isMain: false }], - codexAccountNamespaces: { team: "stored-side-account" } + codexAccountNamespaces: { desktop: "@main", team: "stored-side-account" } }).then(res => console.log(JSON.stringify(res))); `); expect(r.status).toBe(0); @@ -441,6 +441,10 @@ describe("Codex catalog sync hardening", () => { expect(rows.find(row => row.slug === "gpt-5.5")?.visibility).toBe("hide"); // Generated rows recover from stale bare visibility, but still honor explicit native disables. expect(rows.find(row => row.slug === "team/gpt-5.5")).toMatchObject({ + visibility: "hide", + opencodex_catalog_kind: "account-selector-v1", + }); + expect(rows.find(row => row.slug === "desktop/gpt-5.5")).toMatchObject({ visibility: "list", opencodex_catalog_kind: "account-selector-v1", }); diff --git a/tests/native-model-toggle.test.ts b/tests/native-model-toggle.test.ts index 4ee8c5023..d2f9615ab 100644 --- a/tests/native-model-toggle.test.ts +++ b/tests/native-model-toggle.test.ts @@ -100,6 +100,28 @@ describe("native GPT model toggles (bare slugs in disabledModels)", () => { expect(entries.every(entry => Number.isInteger(entry.priority))).toBe(true); }); + test("exact account disables hide only the matching generated picker row", () => { + const entries = buildCatalogEntries( + nativeTemplate(), + ["gpt-5.5"], + [], + [], + false, + "default", + new Set(), + ["desktop", "team"], + ); + applyNativeVisibility(entries, new Set(["team/gpt-5.5"]), true); + + expect(entries.find(entry => entry.slug === "gpt-5.5")?.visibility).toBe("hide"); + expect(entries.find(entry => entry.slug === "desktop/gpt-5.5")?.visibility).toBe("list"); + expect(entries.find(entry => entry.slug === "team/gpt-5.5")?.visibility).toBe("hide"); + + const untrusted = [{ slug: "team/gpt-5.5", visibility: "list" }]; + applyNativeVisibility(untrusted, new Set(["team/gpt-5.5"]), true); + expect(untrusted[0]?.visibility).toBe("list"); + }); + test("featured routed rows follow complete account-qualified priority groups", () => { const entries = buildCatalogEntries( nativeTemplate(), diff --git a/tests/subagent-model-fallback.test.ts b/tests/subagent-model-fallback.test.ts index 1c6445159..76599e2ad 100644 --- a/tests/subagent-model-fallback.test.ts +++ b/tests/subagent-model-fallback.test.ts @@ -20,6 +20,12 @@ import { import { saveCodexAccountCredential } from "../src/codex/account-store"; import { clearAccountNeedsReauth, markAccountNeedsReauth } from "../src/codex/account-runtime-state"; import { clearAccountQuota, updateAccountQuota } from "../src/codex/quota"; +import { + canAcquireCodexQuotaProbeLease, + clearCodexUpstreamHealthForAccount, + CODEX_QUOTA_PROBE_INTERVAL_MS, + recordCodexUpstreamOutcome, +} from "../src/codex/routing"; import type { OcxConfig } from "../src/types"; // beforeEach writes three Codex credentials (NTFS ACL harden on Windows). Under @@ -104,6 +110,9 @@ afterEach(() => { clearAccountNeedsReauth("account-a"); clearAccountNeedsReauth("account-b"); clearAccountNeedsReauth("main"); + clearCodexUpstreamHealthForAccount("pool-a"); + clearCodexUpstreamHealthForAccount("account-a"); + clearCodexUpstreamHealthForAccount("account-b"); rmSync(testDir, { recursive: true, force: true }); }, { timeout: 30_000 }); @@ -189,12 +198,79 @@ describe("subagent model fallback chain", () => { }); }); + test("account selector fallback skips a model-scoped cooldown on its fixed account", () => { + const now = 1_800_000_000_000; + updateAccountQuota("pool-a", 95, undefined, 20); + updateAccountQuota("account-a", 10, undefined, 20); + const config = cfg({ + codexAccountNamespaces: { team: "account-a" }, + subagentModelFallback: ["team/gpt-5.5", "kimi/k3"], + }); + recordCodexUpstreamOutcome(config, "account-a", 429, { + fixedAccount: true, + modelId: "gpt-5.5", + now, + resetAt: Math.floor((now + 60 * 60_000) / 1_000), + }); + + expect(selectAvailableSubagentModel("gpt-5.6-sol", config, [], "pool-a", now + 1)).toEqual({ + model: "kimi/k3", + rewritten: true, + skipped: ["gpt-5.6-sol", "team/gpt-5.5"], + }); + }); + + test("account selector fallback never uses Pool's account-wide cooldown probe", () => { + const now = 1_800_000_000_000; + const probeAt = now + CODEX_QUOTA_PROBE_INTERVAL_MS + 1; + updateAccountQuota("pool-a", 95, undefined, 20); + updateAccountQuota("account-a", 10, undefined, 20); + const config = cfg({ + codexAccountNamespaces: { team: "account-a" }, + subagentModelFallback: ["team/gpt-5.5", "kimi/k3"], + }); + recordCodexUpstreamOutcome(config, "account-a", 429, { + fixedAccount: true, + now, + resetAt: Math.floor((now + 60 * 60_000) / 1_000), + }); + + expect(canAcquireCodexQuotaProbeLease("account-a", probeAt)).toBe(true); + expect(selectAvailableSubagentModel("gpt-5.6-sol", config, [], "pool-a", probeAt)).toEqual({ + model: "kimi/k3", + rewritten: true, + skipped: ["gpt-5.6-sol", "team/gpt-5.5"], + }); + }); + + test("account selector fallback ignores cooldowns for an unrelated quota scope", () => { + const now = 1_800_000_000_000; + updateAccountQuota("pool-a", 95, undefined, 20); + updateAccountQuota("account-a", 10, undefined, 20); + const config = cfg({ + codexAccountNamespaces: { team: "account-a" }, + subagentModelFallback: ["team/gpt-5.5", "kimi/k3"], + }); + recordCodexUpstreamOutcome(config, "account-a", 429, { + fixedAccount: true, + modelId: "gpt-5.3-codex-spark", + now, + resetAt: Math.floor((now + 60 * 60_000) / 1_000), + }); + + expect(selectAvailableSubagentModel("gpt-5.6-sol", config, [], "pool-a", now + 1)).toEqual({ + model: "team/gpt-5.5", + rewritten: true, + skipped: ["gpt-5.6-sol"], + }); + }); + test("account selector fallbacks still reject invalid or disabled native models", () => { resetSubagentModelFallbackStateForTests(); updateAccountQuota("pool-a", 95, undefined, 20); updateAccountQuota("account-a", 10, undefined, 20); const config = cfg({ - codexAccountNamespaces: { team: "account-a" }, + codexAccountNamespaces: { team: "account-a", other: "account-b" }, subagentModelFallback: ["team/claude-opus-4-6", "kimi/k3"], }); @@ -213,6 +289,16 @@ describe("subagent model fallback chain", () => { { ...config, disabledModels: ["openai/gpt-5.5"] }, "pool-a", )).toBe(true); + expect(isSubagentModelUnavailable( + "team/gpt-5.5", + { ...config, disabledModels: ["team/gpt-5.5"] }, + "pool-a", + )).toBe(true); + expect(isSubagentModelUnavailable( + "other/gpt-5.5", + { ...config, disabledModels: ["team/gpt-5.5"] }, + "pool-a", + )).toBe(false); }); test("noteSubagentModelFailure treats numeric 429 as quota-like", () => { From cc0d81778a9a14082c05cc7ee35b1e8d14b08fb0 Mon Sep 17 00:00:00 2001 From: chrisae9 Date: Mon, 3 Aug 2026 23:26:47 -0400 Subject: [PATCH 06/13] docs(codex): clarify projected prompt model --- docs-site/src/content/docs/guides/sub-agent-surface.md | 2 +- docs-site/src/content/docs/ja/guides/sub-agent-surface.md | 2 +- docs-site/src/content/docs/ko/guides/sub-agent-surface.md | 2 +- docs-site/src/content/docs/ru/guides/sub-agent-surface.md | 2 +- .../src/content/docs/zh-cn/guides/sub-agent-surface.md | 2 +- src/types.ts | 8 +++++--- 6 files changed, 10 insertions(+), 8 deletions(-) diff --git a/docs-site/src/content/docs/guides/sub-agent-surface.md b/docs-site/src/content/docs/guides/sub-agent-surface.md index 442b9a97a..800430dc8 100644 --- a/docs-site/src/content/docs/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/guides/sub-agent-surface.md @@ -60,7 +60,7 @@ Custom `injectionPrompt` text can use all four placeholders: | Placeholder | Replaced with | | --- | --- | -| `{{model}}` | The configured `injectionModel`, or an empty string | +| `{{model}}` | The effective preferred model for this request. A bare native `injectionModel` is account-qualified when possible; if no preferred model resolves, the configured `injectionModel` is used, or an empty string when it is unset | | `{{effort}}` | The configured `injectionEffort`, or an empty string | | `{{roster}}` | The resolved picker-visible, surface-compatible roster | | `{{fallback}}` | The configured global fallback guidance | diff --git a/docs-site/src/content/docs/ja/guides/sub-agent-surface.md b/docs-site/src/content/docs/ja/guides/sub-agent-surface.md index fe70fda6f..058286405 100644 --- a/docs-site/src/content/docs/ja/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/ja/guides/sub-agent-surface.md @@ -49,7 +49,7 @@ v2 ロスターの場合、適格性には 3 つの状態があります。`"v2" |プレースホルダー | | に置き換えられます。 | --- | --- | -| `{{model}}` |設定された `injectionModel`、または空の文字列 | +| `{{model}}` |このリクエストに対する実効的な優先モデル。セレクターのないネイティブ `injectionModel` は、可能な場合はアカウントセレクターで修飾されます。優先モデルを解決できない場合は設定済みの `injectionModel` が使用され、未設定の場合は空文字列になります | | `{{effort}}` |設定された `injectionEffort`、または空の文字列 | | `{{roster}}` |解決されたピッカー表示、サーフェス互換のロスター | | `{{fallback}}` |設定されたグローバル フォールバック ガイダンス | diff --git a/docs-site/src/content/docs/ko/guides/sub-agent-surface.md b/docs-site/src/content/docs/ko/guides/sub-agent-surface.md index e64b8bcfb..c11216c58 100644 --- a/docs-site/src/content/docs/ko/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/ko/guides/sub-agent-surface.md @@ -49,7 +49,7 @@ v2 로스터의 경우 적합성은 세 가지 상태로 나뉩니다. `"v2"`로 | 플레이스홀더 | 대체되는 값 | | --- | --- | -| `{{model}}` | 설정된 `injectionModel`, 또는 빈 문자열 | +| `{{model}}` | 이 요청에 적용되는 선호 모델. 선택기가 없는 네이티브 `injectionModel`에는 가능한 경우 계정 선택기가 붙습니다. 선호 모델을 결정할 수 없으면 설정된 `injectionModel`을 사용하고, 설정되지 않았으면 빈 문자열을 사용합니다 | | `{{effort}}` | 설정된 `injectionEffort`, 또는 빈 문자열 | | `{{roster}}` | 해석된 선택기 표시 가능, 서피스 호환 로스터 | | `{{fallback}}` | 설정된 전역 폴백 가이드 | diff --git a/docs-site/src/content/docs/ru/guides/sub-agent-surface.md b/docs-site/src/content/docs/ru/guides/sub-agent-surface.md index 8aa118101..ab874005c 100644 --- a/docs-site/src/content/docs/ru/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/ru/guides/sub-agent-surface.md @@ -67,7 +67,7 @@ guidance-сообщений, которые opencodex пишет сам, на о | Плейсхолдер | Подставляемое значение | | --- | --- | -| `{{model}}` | Настроенный `injectionModel` либо пустая строка | +| `{{model}}` | Эффективная предпочитаемая модель для текущего запроса. К нативной модели без селектора, указанной в `injectionModel`, по возможности добавляется селектор аккаунта; если предпочитаемую модель определить не удалось, используется настроенное значение `injectionModel`, а если оно не задано — пустая строка | | `{{effort}}` | Настроенный `injectionEffort` либо пустая строка | | `{{roster}}` | Разрешённый ростер, видимый в picker и совместимый с поверхностью | | `{{fallback}}` | Настроенное глобальное fallback guidance | diff --git a/docs-site/src/content/docs/zh-cn/guides/sub-agent-surface.md b/docs-site/src/content/docs/zh-cn/guides/sub-agent-surface.md index 8cd75dc31..2d2e861f0 100644 --- a/docs-site/src/content/docs/zh-cn/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/zh-cn/guides/sub-agent-surface.md @@ -49,7 +49,7 @@ Dashboard 上的 **Sub-agent delegation** 控件管理三个相关设置: | 占位符 | 替换为 | | --- | --- | -| `{{model}}` | 配置的 `injectionModel`,或空字符串 | +| `{{model}}` | 当前请求中生效的首选模型。未带选择器的原生 `injectionModel` 会在可能时加上账户选择器;如果无法解析出首选模型,则使用已配置的 `injectionModel`,未配置时为空字符串 | | `{{effort}}` | 配置的 `injectionEffort`,或空字符串 | | `{{roster}}` | 解析后的、对 picker 可见且与界面兼容的 roster | | `{{fallback}}` | 配置的全局 fallback 指引 | diff --git a/src/types.ts b/src/types.ts index dc0713eb6..14716e04c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -619,9 +619,11 @@ export interface OcxConfig { * Custom override for the injected multi-agent guidance body (the text inside the * tags). When set, it replaces the built-in prompt on whichever * collab surface would have fired; firing gates are unchanged. Placeholders: - * `{{model}}` -> injectionModel, `{{effort}}` -> injectionEffort, `{{roster}}` -> - * the resolved sub-agent roster block ("" when nothing resolves), `{{fallback}}` -> - * the configured subagent model fallback guidance block ("" when unset). + * `{{model}}` -> the effective preferred model for the request (a bare native + * model is account-qualified when possible; otherwise injectionModel or ""), + * `{{effort}}` -> injectionEffort, `{{roster}}` -> the resolved sub-agent roster + * block ("" when nothing resolves), `{{fallback}}` -> the configured subagent + * model fallback guidance block ("" when unset). */ injectionPrompt?: string; /** From 2915b4b5ef1570808adf98e29a5bb6a38213b015 Mon Sep 17 00:00:00 2001 From: chrisae9 Date: Mon, 3 Aug 2026 23:56:51 -0400 Subject: [PATCH 07/13] docs(codex): document account selector picker rows --- .../content/docs/guides/codex-app-models.md | 37 ++++++++++------ .../docs/ja/guides/codex-app-models.md | 22 ++++++---- .../docs/ko/guides/codex-app-models.md | 38 ++++++++++------ .../docs/ru/guides/codex-app-models.md | 43 ++++++++++++------- .../docs/zh-cn/guides/codex-app-models.md | 21 ++++++--- 5 files changed, 105 insertions(+), 56 deletions(-) diff --git a/docs-site/src/content/docs/guides/codex-app-models.md b/docs-site/src/content/docs/guides/codex-app-models.md index 363f75491..8576a6f8a 100644 --- a/docs-site/src/content/docs/guides/codex-app-models.md +++ b/docs-site/src/content/docs/guides/codex-app-models.md @@ -7,9 +7,15 @@ opencodex does not patch Codex App. It writes the same Codex configuration and m Codex CLI/TUI already use. Because Codex App reads that shared state, routed models can appear in the App's model picker as normal Codex catalog entries. -OpenAI entries have two stable identities: one bare native `openai` group whose Pool(default) or -Direct account selection is controlled by `codexAccountMode`, and namespaced -`openai-apikey/` API-key transport. Changing the account mode does not change picker ids. +OpenAI entries use two credential routes: native Codex login and the namespaced +`openai-apikey/` API-key transport. Changing `codexAccountMode` between Pool and Direct by +itself does not change picker ids. When `codexAccountNamespaces` has eligible selectors whose +mapped accounts still exist, however, +opencodex adds separate `/` rows for the mapped accounts and hides +the bare native rows from the Codex picker. Selector labels are user-chosen public names with no +built-in account-role meaning. Selecting a qualified row uses only its mapped account, does not +change the active Pool account, and fails closed instead of switching accounts when the target is +unavailable. See [Exact Codex account selectors](/reference/configuration/routing/#exact-codex-account-selectors). API GPT-5.6 entries use 1,050,000 context / 922,000 max input, and `*-pro` picker ids resolve to the base wire model with `reasoning.mode: "pro"` while logs, usage, and picker state keep the virtual id. @@ -17,10 +23,12 @@ The API catalog is fixed to exactly eight ids: `gpt-5.5`, `gpt-5.6`, Sol/Terra/L three Pro virtual ids; there is no generic `gpt-5.6-pro` alias. Compact requests keep the selected tier but send the base model without a reasoning object. -Select a credential route explicitly; change Pool/Direct on the Providers page: +Select the credential route represented by the picker id. Change Pool/Direct on the Providers page; +`` below is a user-chosen public label mapped through `codexAccountNamespaces`: ```text -gpt-5.6-sol # openai (Pool or Direct option) +gpt-5.6-sol # bare Codex-login route via Pool or Direct +/gpt-5.6-sol # stored Codex account mapped by that selector openai-apikey/gpt-5.6-sol # API key ``` @@ -64,7 +72,8 @@ metadata instead of an older-template approximation. | Route | Picker ids and catalog metadata | | --- | --- | -| Codex login (Pool or Direct) | `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna` (372,000-token catalog window) | +| Codex login (no eligible account selectors) | Bare native ids such as `gpt-5.6-sol`, `gpt-5.6-terra`, and `gpt-5.6-luna`; Pool or Direct is selected through `codexAccountMode`. GPT-5.6 rows use a 372,000-token catalog window. | +| Codex login (eligible account selectors) | One `/` row per eligible selector and supported native model; each row uses only its mapped account, and bare native rows are hidden from the picker. Native metadata and context windows are preserved. | | OpenAI (API key) | Exactly eight namespaced rows: `gpt-5.5`, `gpt-5.6`, Sol/Terra/Luna, and the three `*-pro` virtual ids (1,050,000 context; 922,000 max input for all eight) | | OpenRouter | `openrouter/openai/gpt-5.6-sol`, `openrouter/openai/gpt-5.6-terra`, `openrouter/openai/gpt-5.6-luna` (1,050,000) | | Cursor | Static fallback includes `cursor/gpt-5.6-sol`, `cursor/gpt-5.6-terra`, and `cursor/gpt-5.6-luna` (1,000,000), plus `cursor/grok-4.5` and `cursor/grok-4.5-fast` (500,000); live account discovery decides which remain visible. | @@ -82,9 +91,11 @@ The dashboard Models page uses `disabledModels` for both model families: - Routed ids are namespaced (`provider/model`). Disabling one excludes it from the synced catalog and `/v1/models`. -- Native GPT ids are bare slugs. Disabling one keeps its catalog entry but changes - `visibility` to `hide`, preserving the exact entry for a later re-enable; the bare OpenAI list - shape omits it while disabled. +- Account-qualified native ids use `/`. Disabling one hides only that + selector row. +- Native GPT ids are bare slugs. Disabling one keeps its catalog entry but changes `visibility` to + `hide`, preserving the exact entry for a later re-enable; it hides the bare row and every + selector-qualified clone for that model from discovery. - Native rows come from the supported static set, so a disabled native model stays visible in the dashboard and can be turned back on. @@ -129,9 +140,11 @@ never advertised where it cannot be honored, and custom gateways can opt in expl ## Subagent selection Codex sorts picker-visible catalog entries by ascending `priority` and advertises the first five as -`spawn_agent` model overrides. Pick up to five bare native ids or namespaced `provider/model` ids -through `subagentModels` or the dashboard Subagents page; opencodex gives those entries priorities -0-4 in the chosen order. Other models remain callable by exact id. +`spawn_agent` model overrides. Pick up to five bare native ids, account-qualified +`/` ids, or routed `provider/model` ids through `subagentModels` or +the dashboard Subagents page. opencodex assigns low catalog priorities in the selected order; when +account selectors are active, bare native selections expand into selector-qualified groups. Other +models remain callable by exact id. The featured-model list is separate from the Dashboard's **Sub-agent delegation** selection. It controls which overrides Codex offers first; it does not select a model or trigger delegation by diff --git a/docs-site/src/content/docs/ja/guides/codex-app-models.md b/docs-site/src/content/docs/ja/guides/codex-app-models.md index 24ea8291f..9f48fde27 100644 --- a/docs-site/src/content/docs/ja/guides/codex-app-models.md +++ b/docs-site/src/content/docs/ja/guides/codex-app-models.md @@ -5,12 +5,13 @@ description: opencodex モデルが、共有 Codex カタログを通じて Code opencodex は Codex アプリにパッチを適用しません。 Codex CLI/TUI が既に使用しているのと同じ Codex 設定とモデル カタログを書き込みます。 Codex アプリはその共有状態を読み取るため、ルーティングされたモデルは通常の Codex カタログ エントリとしてアプリのモデル ピッカーに表示されます。 -OpenAI エントリには 2 つの安定した ID があります。1 つはプール (デフォルト) または直接アカウントの選択が `codexAccountMode` によって制御されるベア ネイティブ `openai` グループ、もう 1 つは名前空間付きの `openai-apikey/` API キー トランスポートです。アカウント モードを変更してもピッカー ID は変更されません。 API GPT-5.6 エントリは 1,050,000 コンテキスト / 922,000 最大入力を使用し、`*-pro` ピッカー ID は `reasoning.mode: "pro"` のベース ワイヤ モデルに解決されますが、ログ、使用状況、およびピッカー状態は仮想 ID を保持します。 API カタログは、`gpt-5.5`、`gpt-5.6`、Sol/Terra/Luna、およびそれらの 3 つの Pro 仮想 ID の 8 つの ID に固定されています。汎用の `gpt-5.6-pro` エイリアスはありません。コンパクト リクエストは、選択された層を保持しますが、推論オブジェクトなしで基本モデルを送信します。 +OpenAI エントリには、ネイティブ Codex ログインと、名前空間付きの `openai-apikey/` API キーという 2 つの資格情報ルートがあります。`codexAccountMode` だけを Pool と Direct の間で変更しても、ピッカー ID は変わりません。ただし、`codexAccountNamespaces` に対象アカウントが存在する selector がある場合、opencodex は対応するアカウントごとに `/` 行を追加し、ピッカーでは bare native 行を非表示にします。Selector 名はユーザーが決める公開ラベルであり、組み込みのアカウント role の意味はありません。`selector` 付きの行を選択すると、対応付けられたアカウントだけが使用され、アクティブな Pool アカウントは変更されません。対象を利用できない場合、別のアカウントへ切り替えずにリクエストが失敗します。詳しくは [Codex アカウントの明示的な selector](/reference/configuration/routing/#exact-codex-account-selectors) を参照してください。API GPT-5.6 エントリは 1,050,000 コンテキスト / 922,000 最大入力を使用し、`*-pro` ピッカー ID は `reasoning.mode: "pro"` のベース ワイヤ モデルに解決されますが、ログ、使用状況、およびピッカー状態は仮想 ID を保持します。 API カタログは、`gpt-5.5`、`gpt-5.6`、Sol/Terra/Luna、およびそれらの 3 つの Pro 仮想 ID の 8 つの ID に固定されています。汎用の `gpt-5.6-pro` エイリアスはありません。コンパクト リクエストは、選択された層を保持しますが、推論オブジェクトなしで基本モデルを送信します。 -資格情報ルートを明示的に選択します。 [プロバイダー] ページで [プール/ダイレクト] を変更します。 +ピッカー ID で資格情報ルートを明示的に選択します。Pool/Direct は Providers ページで変更します。以下の `` は、`codexAccountNamespaces` で対応付けたユーザー定義の公開ラベルです。 ```text -gpt-5.6-sol # openai (Pool or Direct option) +gpt-5.6-sol # Pool または Direct による bare Codex ログイン ルート +/gpt-5.6-sol # その selector に対応付けられた保存済み Codex アカウント openai-apikey/gpt-5.6-sol # API key ``` @@ -44,7 +45,8 @@ visibility = "list" |ルート |ピッカー ID とカタログのメタデータ | | --- | --- | -| Codex ログイン (プールまたは直接) | `gpt-5.6-sol`、`gpt-5.6-terra`、`gpt-5.6-luna` (372,000 トークンのカタログ ウィンドウ) | +| Codex ログイン (有効な account selector なし) | `gpt-5.6-sol`、`gpt-5.6-terra`、`gpt-5.6-luna` などの bare native id を表示し、`codexAccountMode` に従って Pool または Direct を使用します。GPT-5.6 行のカタログ ウィンドウは 372,000 トークンです。 | +| Codex ログイン (有効な account selector あり) | 有効な selector とサポート対象 native model の各組み合わせに `/` 行を表示します。各行は対応付けられたアカウントだけを使用し、bare native 行はピッカーで非表示になります。Native metadata と context window は保持されます。 | | OpenAI (API キー) |正確に 8 つの名前空間行: `gpt-5.5`、`gpt-5.6`、Sol/Terra/Luna、および 3 つの `*-pro` 仮想 ID (コンテキスト 1,050,000、8 つすべての最大入力 922,000) | |オープンルーター | `openrouter/openai/gpt-5.6-sol`、`openrouter/openai/gpt-5.6-terra`、`openrouter/openai/gpt-5.6-luna` (1,050,000) | |カーソル |静的フォールバックには、`cursor/gpt-5.6-sol`、`cursor/gpt-5.6-terra`、および `cursor/gpt-5.6-luna` (1,000,000)、さらに `cursor/grok-4.5` および `cursor/grok-4.5-fast` (500,000) が含まれます。ライブアカウントの検出により、どれが表示されたままになるかが決まります。 | @@ -56,10 +58,12 @@ visibility = "list" ダッシュボードの「モデル」ページでは、両方のモデル ファミリで `disabledModels` を使用します。 -- ルーティング ID は名前空間 (`provider/model`) です。いずれかを無効にすると、同期されたカタログから除外されます -そして`/v1/models`。 -- ネイティブ GPT ID は裸のナメクジです。無効にすると、カタログ エントリは保持されますが、変更されます。 -`visibility` から `hide` へ。後で再度有効にするために正確なエントリを保存します。裸の OpenAI リスト形状は、無効になっている間は省略されます。 +- Routed provider id は名前空間付き (`provider/model`) です。無効にすると、同期済みカタログと + `/v1/models` から除外されます。 +- Account-qualified native id は `/` 形式です。無効にすると、 + その selector 行だけが非表示になります。 +- Bare native GPT id は bare slug です。無効にすると、後で再び有効化できるようカタログ + エントリを保持したまま、bare 行とそのモデルの全 account-selector 複製行を非表示にします。 - ネイティブ行はサポートされている静的セットから取得されるため、無効になったネイティブ モデルは引き続き表示されます。 ダッシュボードに戻り、再びオンにすることができます。 @@ -90,7 +94,7 @@ fast_mode = true ## サブエージェントの選択 -Codex は、ピッカーに表示されるカタログ エントリを `priority` の昇順で並べ替え、最初の 5 つを `spawn_agent` モデル オーバーライドとしてアドバタイズします。 `subagentModels` またはダッシュボードのサブエージェント ページを通じて、最大 5 つのベア ネイティブ ID または名前空間付き `provider/model` ID を選択します。 opencodex は、これらのエントリに選択された順序で優先度 0 ~ 4 を与えます。他のモデルは引き続き正確な ID で呼び出すことができます。 +Codex は、ピッカーに表示されるカタログ エントリを `priority` の昇順で並べ替え、最初の 5 つを `spawn_agent` モデル オーバーライドとしてアドバタイズします。`subagentModels` またはダッシュボードの Subagents ページで、bare native id、account-qualified `/` id、または routed `provider/model` id を最大 5 つ選択します。opencodex は選択順に低いカタログ priority を割り当てます。account selector が有効な場合、bare native の選択は selector-qualified グループに展開されます。他のモデルは引き続き正確な ID で呼び出すことができます。 注目モデルのリストは、ダッシュボードの **サブエージェント委任** の選択とは別のものです。 Codex が提供するものを最初にオーバーライドするものを制御します。モデルを選択したり、委任をトリガーしたりすることはありません。 diff --git a/docs-site/src/content/docs/ko/guides/codex-app-models.md b/docs-site/src/content/docs/ko/guides/codex-app-models.md index caaf1f382..2ef3acc80 100644 --- a/docs-site/src/content/docs/ko/guides/codex-app-models.md +++ b/docs-site/src/content/docs/ko/guides/codex-app-models.md @@ -7,18 +7,25 @@ opencodex는 Codex App을 직접 고치지 않습니다. Codex CLI/TUI가 이미 같은 위치에 씁니다. Codex App도 이 공유 상태를 읽기 때문에, 라우팅된 모델이 일반 Codex 카탈로그 항목처럼 App의 모델 선택기에 나타날 수 있습니다. -OpenAI 항목에는 두 가지 고정된 정체성이 있습니다. 하나는 `codexAccountMode`가 Pool(기본) 또는 Direct -계정 선택을 정하는 네이티브 `openai` 그룹이고, 다른 하나는 `openai-apikey/` API key 전송 경로입니다. -계정 모드를 바꿔도 picker id는 바뀌지 않습니다. API GPT-5.6 항목은 context 1,050,000 / max input 922,000을 +OpenAI 항목에는 네이티브 Codex 로그인과 네임스페이스가 붙은 `openai-apikey/` API key +경로라는 두 가지 credential 경로가 있습니다. `codexAccountMode`만 Pool과 Direct 사이에서 바꾸는 것은 +선택기 id를 바꾸지 않습니다. 하지만 `codexAccountNamespaces`에 대상 계정이 존재하는 selector가 있으면, +opencodex는 매핑된 계정별로 `/` 행을 추가하고 선택기에서 bare native 행을 +숨깁니다. Selector 이름은 사용자가 정하는 공개 label이며 내장된 계정 역할 의미가 없습니다. `selector`가 +붙은 행을 선택하면 매핑된 계정만 사용하고 활성 Pool 계정은 바뀌지 않습니다. 대상 계정을 사용할 수 없으면 +다른 계정으로 전환하지 않고 요청이 실패합니다. 자세한 내용은 [명시적 Codex 계정 selector](/reference/configuration/routing/#exact-codex-account-selectors)를 +참고하세요. API GPT-5.6 항목은 context 1,050,000 / max input 922,000을 쓰고, `*-pro` picker id는 로그, 사용량, picker 상태에는 가상 id를 유지한 채 wire에서는 base model과 `reasoning.mode: "pro"`로 풀립니다. API 카탈로그는 `gpt-5.5`, `gpt-5.6`, Sol/Terra/Luna, 그리고 세 개의 Pro 가상 id까지 정확히 여덟 개로 고정되어 있으며, 일반적인 `gpt-5.6-pro` 별칭은 없습니다. Compact 요청은 선택한 tier를 유지하되 reasoning 객체 없이 base model만 보냅니다. -사용할 인증 경로를 명시하세요. Providers 페이지에서 Pool/Direct를 바꾸세요: +선택기 id로 credential 경로를 명시적으로 선택하세요. Pool/Direct는 Providers 페이지에서 바꾸며, +아래 ``는 `codexAccountNamespaces`로 매핑한 사용자 지정 공개 label입니다: ```text -gpt-5.6-sol # openai (Pool or Direct option) +gpt-5.6-sol # Pool 또는 Direct를 통한 bare Codex 로그인 경로 +/gpt-5.6-sol # 해당 selector에 매핑된 저장된 Codex 계정 openai-apikey/gpt-5.6-sol # API key ``` @@ -63,7 +70,8 @@ GPT-5.6에만 사용합니다. 오래된 템플릿으로 근사하지 않고 모 | 경로 | 선택기 id와 카탈로그 메타데이터 | | --- | --- | -| Codex 로그인(Pool 또는 Direct) | `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna` (372,000토큰 카탈로그 창) | +| Codex 로그인(유효한 account selector 없음) | `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna` 같은 bare native id를 표시하고 `codexAccountMode`에 따라 Pool 또는 Direct를 사용합니다. GPT-5.6 행의 카탈로그 창은 372,000토큰입니다. | +| Codex 로그인(유효한 account selector 있음) | 유효한 selector와 지원되는 native model의 각 조합마다 `/` 행을 표시합니다. 각 행은 매핑된 계정만 사용하며 bare native 행은 선택기에서 숨깁니다. Native metadata와 context window는 보존됩니다. | | OpenAI(API key) | 정확히 여덟 개의 네임스페이스 행: `gpt-5.5`, `gpt-5.6`, Sol/Terra/Luna, 그리고 세 개의 `*-pro` 가상 id (모두 컨텍스트 1,050,000; 최대 입력 922,000) | | OpenRouter | `openrouter/openai/gpt-5.6-sol`, `openrouter/openai/gpt-5.6-terra`, `openrouter/openai/gpt-5.6-luna` (1,050,000) | | Cursor | 정적 폴백에는 `cursor/gpt-5.6-sol`, `cursor/gpt-5.6-terra`, `cursor/gpt-5.6-luna` (1,000,000)와 `cursor/grok-4.5`, `cursor/grok-4.5-fast` (500,000)가 들어갑니다. 실시간 계정 탐색이 어떤 항목을 계속 보일지 정합니다. | @@ -79,10 +87,12 @@ Luna는 `max`에서 멈춥니다. Sol의 기본값은 `low`이고, Terra와 Luna 대시보드 Models 페이지는 두 모델 계열 모두 `disabledModels`를 사용합니다. -- 라우팅 id는 `provider/model` 형식입니다. 하나를 끄면 동기화된 카탈로그와 `/v1/models`에서 제외됩니다. -- 네이티브 GPT id는 bare slug입니다. 하나를 끄더라도 카탈로그 항목은 유지하고 `visibility`만 `hide`로 - 바꿉니다. 나중에 다시 켤 때 같은 항목을 그대로 살리기 위해서입니다. 비활성 상태에서는 bare OpenAI - 목록 형식에서 빠집니다. +- Routed provider id는 네임스페이스 형식(`provider/model`)입니다. 비활성화하면 동기화된 카탈로그와 + `/v1/models`에서 제외됩니다. +- Account-qualified native id는 `/` 형식입니다. 비활성화하면 해당 + selector 행만 숨깁니다. +- Bare native GPT id는 bare slug입니다. 비활성화하면 나중에 다시 켤 수 있도록 카탈로그 항목은 + 유지하면서 bare 행과 해당 모델의 모든 account-selector 복제 행을 숨깁니다. - 네이티브 행은 지원되는 정적 집합에서 오므로, 비활성화한 네이티브 모델은 대시보드에 계속 보이고 다시 켤 수 있습니다. @@ -126,9 +136,11 @@ fast_mode = true ## 서브에이전트 선택 Codex는 선택기에 보이는 카탈로그 항목을 `priority` 오름차순으로 정렬한 뒤 처음 다섯 개를 `spawn_agent` -model override로 노출합니다. `subagentModels`나 대시보드 Subagents 페이지에서 네이티브 id 또는 -`provider/model` id를 최대 다섯 개 고르면 opencodex가 선택한 순서대로 이 항목들에 priority 0-4를 -부여합니다. 다른 모델도 정확한 id로 직접 호출할 수 있습니다. +model override로 노출합니다. `subagentModels`나 대시보드 Subagents 페이지에서 bare native id, +account-qualified `/` id, 또는 routed `provider/model` id를 최대 다섯 개 +선택하세요. opencodex는 선택한 순서대로 낮은 카탈로그 priority를 부여합니다. account selector가 활성화되어 +있으면 bare native 선택은 selector-qualified 그룹으로 확장됩니다. 다른 모델도 정확한 id로 직접 호출할 수 +있습니다. featured-model 목록은 Dashboard의 **Sub-agent delegation** 선택과 별개입니다. Codex가 먼저 보여 줄 override를 정할 뿐, 모델을 고르거나 delegation을 시작하지는 않습니다. diff --git a/docs-site/src/content/docs/ru/guides/codex-app-models.md b/docs-site/src/content/docs/ru/guides/codex-app-models.md index 4aae48f46..7922f08d7 100644 --- a/docs-site/src/content/docs/ru/guides/codex-app-models.md +++ b/docs-site/src/content/docs/ru/guides/codex-app-models.md @@ -7,19 +7,29 @@ opencodex не патчит Codex App. Он записывает ту же ко которыми уже пользуются Codex CLI/TUI. Поскольку Codex App читает это общее состояние, маршрутизируемые модели могут появляться в picker'е App как обычные записи каталога Codex. -У записей OpenAI есть две стабильные идентичности: одна «голая» нативная группа `openai`, где -выбор аккаунта Pool(default) или Direct управляется `codexAccountMode`, и namespaced-транспорт -API-ключа `openai-apikey/`. Переключение account mode не меняет id в picker'е. У строк API +Записи OpenAI используют два credential-транспорта: нативный вход Codex и namespaced-транспорт +API-ключа `openai-apikey/`. Само по себе переключение `codexAccountMode` между Pool и Direct +не меняет id в picker'е. Однако если в `codexAccountNamespaces` есть подходящие селекторы, +opencodex добавляет для сопоставленных аккаунтов отдельные строки +`/` и скрывает bare native-строки из picker'а. Имена селекторов — +это публичные метки, которые выбирает пользователь; встроенного смысла роли аккаунта у них нет. +Выбор строки с селектором использует только сопоставленный аккаунт, не меняет активный аккаунт Pool +и при недоступности цели завершается ошибкой без переключения на другой аккаунт. Подробнее см. +в разделе [Точные селекторы аккаунтов Codex](/reference/configuration/routing/#exact-codex-account-selectors). +У строк API GPT-5.6 — контекст 1,050,000 и максимум входа 922,000; id picker'а вида `*-pro` разрешаются в базовую wire-модель с `reasoning.mode: "pro"`, а логи, usage и picker state сохраняют виртуальный id. Каталог API жёстко ограничен ровно восемью id: `gpt-5.5`, `gpt-5.6`, Sol/Terra/Luna и тремя виртуальными Pro-id; обобщённого alias `gpt-5.6-pro` не существует. Compact-запросы сохраняют выбранный tier, но отправляют базовую модель без объекта reasoning. -Явно выбирайте маршрут credential; Pool/Direct переключается на странице Providers: +Выбирайте credential-маршрут по id в picker'е. Pool/Direct переключается на странице Providers; +`` ниже — выбранная пользователем публичная метка, сопоставленная через +`codexAccountNamespaces`: ```text -gpt-5.6-sol # openai (Pool or Direct option) +gpt-5.6-sol # bare-маршрут входа Codex через Pool или Direct +/gpt-5.6-sol # сохранённый аккаунт Codex, сопоставленный с этим селектором openai-apikey/gpt-5.6-sol # API key ``` @@ -65,7 +75,8 @@ per-model identity и метаданные вместо приближения | Маршрут | Id в селекторе и метаданные каталога | | --- | --- | -| Вход Codex (Pool или Direct) | `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna` (окно каталога 372 000 токенов) | +| Вход Codex (без подходящих селекторов аккаунтов) | Bare native-id, например `gpt-5.6-sol`, `gpt-5.6-terra` и `gpt-5.6-luna`; Pool или Direct выбирается через `codexAccountMode`. У строк GPT-5.6 окно каталога 372 000 токенов. | +| Вход Codex (с подходящими селекторами аккаунтов) | По одной строке `/` для каждой пары подходящего селектора и поддерживаемой нативной модели; каждая строка использует только сопоставленный аккаунт, а bare native-строки скрыты из picker'а. Нативные метаданные и окна контекста сохраняются. | | OpenAI (API key) | Ровно восемь namespaced-строк: `gpt-5.5`, `gpt-5.6`, Sol/Terra/Luna и три виртуальных id `*-pro` (контекст 1,050,000; максимум входа 922,000 у всех восьми) | | OpenRouter | `openrouter/openai/gpt-5.6-sol`, `openrouter/openai/gpt-5.6-terra`, `openrouter/openai/gpt-5.6-luna` (1,050,000) | | Cursor | Статический fallback включает `cursor/gpt-5.6-sol`, `cursor/gpt-5.6-terra` и `cursor/gpt-5.6-luna` (1,000,000), а также `cursor/grok-4.5` и `cursor/grok-4.5-fast` (500,000); какие из них останутся видимыми, решает live-discovery аккаунта. | @@ -82,11 +93,12 @@ delegation и на backend он уходит как `max`. Наличие зап Страница Models в дашборде использует `disabledModels` для обоих семейств моделей: -- Routed-id имеют пространство имён (`provider/model`). Если отключить такую модель, она - исключается из синхронизируемого каталога и из `/v1/models`. -- Нативные GPT-id — это bare slug. Их отключение сохраняет запись каталога, но переводит - `visibility` в `hide`, чтобы потом можно было вернуть её в точности; bare OpenAI-list shape - при этом опускает модель из выдачи. +- Routed provider-id имеют namespace (`provider/model`). Отключение исключает такую модель из + синхронизируемого каталога и `/v1/models`. +- Account-qualified native-id имеют вид `/`. Отключение скрывает + только строку этого селектора. +- Bare native GPT-id — это голые slug. Отключение скрывает bare-строку и все account-selector-клоны + этой модели, сохраняя записи каталога для последующего включения. - Нативные строки берутся из поддерживаемого статического набора, поэтому отключённая нативная модель остаётся видимой в дашборде и её можно включить снова. @@ -131,10 +143,11 @@ capability-гейтом — `service_tier` удаляется только ко ## Выбор подагентов Codex сортирует видимые в picker'е записи каталога по возрастанию `priority` и рекламирует первые -пять как model-override для `spawn_agent`. Выберите до пяти bare native-id или namespaced-id -`provider/model` через `subagentModels` или страницу Subagents дашборда; opencodex присвоит этим -записям приоритеты 0-4 в выбранном порядке. Остальные модели всё равно можно вызывать по -точному id. +пять как model-override для `spawn_agent`. Выберите до пяти bare native-id, account-qualified id +`/` или routed provider-id `provider/model` через `subagentModels` +либо страницу Subagents дашборда. opencodex назначает им низкие приоритеты каталога в выбранном +порядке; при активных селекторах аккаунтов bare native-выбор разворачивается в группы +selector-qualified строк. Остальные модели всё равно можно вызывать по точному id. Список featured-моделей отделён от выбора **Sub-agent delegation** в дашборде. Он только определяет, какие override Codex показывает первыми; он не выбирает модель и не инициирует diff --git a/docs-site/src/content/docs/zh-cn/guides/codex-app-models.md b/docs-site/src/content/docs/zh-cn/guides/codex-app-models.md index 64b1473c7..9b5d66f2b 100644 --- a/docs-site/src/content/docs/zh-cn/guides/codex-app-models.md +++ b/docs-site/src/content/docs/zh-cn/guides/codex-app-models.md @@ -5,12 +5,14 @@ description: opencodex 中的模型如何通过共享 Codex 目录出现在 Code opencodex 不会修改 Codex App。它会写入 Codex CLI/TUI 已经使用的同一套 Codex 配置和模型目录。因为 Codex App 读取的是这份共享状态,路由模型可以像普通 Codex 目录条目一样出现在 App 的模型选择器中。 -OpenAI 条目有两种稳定身份:一个是不带命名空间的原生 `openai` 组,由 `codexAccountMode` 控制使用 Pool(默认)还是 Direct 的账户选择;另一个是命名空间化的 `openai-apikey/` API key 通道。切换账户模式不会改变选择器 id。API GPT-5.6 条目使用 1,050,000 context / 922,000 max input,而 `*-pro` 选择器 id 会解析到基础线协议模型,并在日志、用量和选择器状态中保留虚拟 id,同时带上 `reasoning.mode: "pro"`。API 目录固定为恰好八个 id:`gpt-5.5`、`gpt-5.6`、Sol/Terra/Luna,以及它们三个 Pro 虚拟 id;不存在通用的 `gpt-5.6-pro` 别名。Compact 请求会保留所选 tier,但发送基础模型且不带 reasoning 对象。 +OpenAI 条目有两种凭据通道:原生 Codex 登录,以及命名空间化的 `openai-apikey/` API key 通道。仅在 Pool 与 Direct 之间切换 `codexAccountMode` 不会改变选择器 id。但当 `codexAccountNamespaces` 中有目标账户存在的 selector 时,opencodex 会为映射账户添加独立的 `/` 行,并在选择器中隐藏裸原生行。Selector 名称是用户自定义的公开标签,没有内置的账户角色含义。选择带 `selector` 的行只会使用映射账户,不会更改当前 Pool 账户;目标不可用时,请求会直接失败,不会切换到其他账户。详情请参阅[精确 Codex 账户选择器](/reference/configuration/routing/#exact-codex-account-selectors)。API GPT-5.6 条目使用 1,050,000 context / 922,000 max input,而 `*-pro` 选择器 id 会解析到基础线协议模型,并在日志、用量和选择器状态中保留虚拟 id,同时带上 `reasoning.mode: "pro"`。API 目录固定为恰好八个 id:`gpt-5.5`、`gpt-5.6`、Sol/Terra/Luna,以及它们三个 Pro 虚拟 id;不存在通用的 `gpt-5.6-pro` 别名。Compact 请求会保留所选 tier,但发送基础模型且不带 reasoning 对象。 -请显式选择凭据路径;在 Providers 页面切换 Pool/Direct: +请通过选择器 id 显式选择凭据路径。在 Providers 页面切换 Pool/Direct;下面的 `` 是 +用户自定义、通过 `codexAccountNamespaces` 映射的公开标签: ```text -gpt-5.6-sol # openai (Pool or Direct option) +gpt-5.6-sol # 通过 Pool 或 Direct 使用 bare Codex 登录路由 +/gpt-5.6-sol # 映射到该 selector 的已保存 Codex 账户 openai-apikey/gpt-5.6-sol # API key ``` @@ -44,7 +46,8 @@ visibility = "list" | 路由 | 选择器 id 与目录元数据 | | --- | --- | -| Codex 登录(Pool 或 Direct) | `gpt-5.6-sol`、`gpt-5.6-terra`、`gpt-5.6-luna`(372,000-token 目录窗口) | +| Codex 登录(没有有效账户 selector) | 显示 `gpt-5.6-sol`、`gpt-5.6-terra`、`gpt-5.6-luna` 等裸原生 id,并按 `codexAccountMode` 使用 Pool 或 Direct。GPT-5.6 行使用 372,000-token 目录窗口。 | +| Codex 登录(有有效账户 selector) | 为每个有效 selector 与受支持原生模型的组合显示 `/` 行。每行只使用映射账户,裸原生行会从选择器中隐藏。原生 metadata 与 context window 会保留。 | | OpenAI(API key) | 恰好八个命名空间行:`gpt-5.5`、`gpt-5.6`、Sol/Terra/Luna,以及三个 `*-pro` 虚拟 id(八个条目均为 1,050,000 context / 922,000 max input) | | OpenRouter | `openrouter/openai/gpt-5.6-sol`、`openrouter/openai/gpt-5.6-terra`、`openrouter/openai/gpt-5.6-luna`(1,050,000) | | Cursor | 静态回退包含 `cursor/gpt-5.6-sol`、`cursor/gpt-5.6-terra`、`cursor/gpt-5.6-luna`(1,000,000),以及 `cursor/grok-4.5` 和 `cursor/grok-4.5-fast`(500,000);实时账户发现会决定最终哪些条目仍然可见。 | @@ -56,8 +59,12 @@ visibility = "list" 仪表盘 Models 页面对两类模型都使用 `disabledModels`: -- 路由 id 使用命名空间形式(`provider/model`)。禁用其中一个会把它从同步目录和 `/v1/models` 中移除。 -- 原生 GPT id 是裸 slug。禁用其中一个会保留它的目录条目,但把 `visibility` 改成 `hide`,以便之后重新启用时精确保留该条目;禁用期间,裸 OpenAI 列表形态会把它省略。 +- 路由 provider id 使用命名空间形式(`provider/model`)。禁用后会从同步目录和 + `/v1/models` 中移除。 +- 账户限定的原生 id 使用 `/` 形式。禁用后只隐藏对应的 + selector 行。 +- 裸原生 GPT id 是裸 slug。禁用后会隐藏裸行以及该模型的所有 account-selector 克隆行, + 同时保留目录条目以便之后重新启用。 - 原生行来自受支持的静态集合,因此被禁用的原生模型仍会在仪表盘中可见,并且可以重新打开。 可见性处理会在快照升级之后运行;每次切换后,管理 API 都会刷新目录,并强制让 Codex 的模型缓存失效。 @@ -87,7 +94,7 @@ fast_mode = true ## 子代理选择 -Codex 会按 `priority` 升序对选择器可见的目录条目排序,并把前五个作为 `spawn_agent` 模型 override 暴露出来。你可以通过 `subagentModels` 或仪表盘的 Subagents 页面,选择最多五个裸原生 id 或命名空间化的 `provider/model` id;opencodex 会按所选顺序给这些条目分配 0-4 的 priority。其他模型仍然可以通过精确 id 调用。 +Codex 会按 `priority` 升序对选择器可见的目录条目排序,并把前五个作为 `spawn_agent` 模型 override 暴露出来。你可以通过 `subagentModels` 或仪表盘的 Subagents 页面,选择最多五个裸原生 id、账户限定的 `/` id,或路由 `provider/model` id。opencodex 会按所选顺序分配较低的目录 priority;启用账户 selector 时,裸原生选择会展开为 selector-qualified 分组。其他模型仍然可以通过精确 id 调用。 精选模型列表与 Dashboard 的 **Sub-agent delegation** 选择彼此独立。它只决定 Codex 先提供哪些 override;它不会自己选择模型,也不会触发委派。 From 49091dabb0ec8ba367cd025a37c3651fc5629fc4 Mon Sep 17 00:00:00 2001 From: chrisae9 Date: Tue, 4 Aug 2026 01:00:30 -0400 Subject: [PATCH 08/13] docs(codex): clarify selector dashboard boundaries --- .../content/docs/guides/codex-app-models.md | 20 +++++++----- .../src/content/docs/guides/model-ordering.md | 31 +++++++++++++------ .../docs/ja/guides/codex-app-models.md | 11 ++++--- .../content/docs/ja/guides/model-ordering.md | 29 ++++++++++++----- .../docs/ja/reference/configuration/agents.md | 2 +- .../ja/reference/configuration/providers.md | 2 +- .../docs/ko/guides/codex-app-models.md | 23 ++++++++------ .../content/docs/ko/guides/model-ordering.md | 27 +++++++++++----- .../docs/ko/reference/configuration/agents.md | 2 +- .../ko/reference/configuration/providers.md | 2 +- .../docs/reference/configuration/agents.md | 2 +- .../docs/reference/configuration/providers.md | 2 +- .../docs/ru/guides/codex-app-models.md | 21 ++++++++----- .../content/docs/ru/guides/model-ordering.md | 30 +++++++++++++----- .../docs/ru/reference/configuration/agents.md | 2 +- .../ru/reference/configuration/providers.md | 2 +- .../docs/zh-cn/guides/codex-app-models.md | 10 +++--- .../docs/zh-cn/guides/model-ordering.md | 24 +++++++++----- .../zh-cn/reference/configuration/agents.md | 2 +- .../reference/configuration/providers.md | 2 +- src/types.ts | 6 ++-- structure/03_catalog-and-subagents.md | 13 ++++++-- 22 files changed, 176 insertions(+), 89 deletions(-) diff --git a/docs-site/src/content/docs/guides/codex-app-models.md b/docs-site/src/content/docs/guides/codex-app-models.md index 8576a6f8a..534f97e27 100644 --- a/docs-site/src/content/docs/guides/codex-app-models.md +++ b/docs-site/src/content/docs/guides/codex-app-models.md @@ -87,12 +87,15 @@ must still be entitled to use that model. ## Native and routed model toggles -The dashboard Models page uses `disabledModels` for both model families: +The dashboard Models page exposes `disabledModels` toggles for bare native ids and routed +`provider/model` ids. Account-qualified `/` ids are also supported by +`disabledModels`, but the dashboard does not list or toggle those exact selector rows; add them to +the configuration manually: - Routed ids are namespaced (`provider/model`). Disabling one excludes it from the synced catalog and `/v1/models`. -- Account-qualified native ids use `/`. Disabling one hides only that - selector row. +- Account-qualified native ids use `/`. Adding one to + `disabledModels` hides only that selector row. - Native GPT ids are bare slugs. Disabling one keeps its catalog entry but changes `visibility` to `hide`, preserving the exact entry for a later re-enable; it hides the bare row and every selector-qualified clone for that model from discovery. @@ -140,11 +143,12 @@ never advertised where it cannot be honored, and custom gateways can opt in expl ## Subagent selection Codex sorts picker-visible catalog entries by ascending `priority` and advertises the first five as -`spawn_agent` model overrides. Pick up to five bare native ids, account-qualified -`/` ids, or routed `provider/model` ids through `subagentModels` or -the dashboard Subagents page. opencodex assigns low catalog priorities in the selected order; when -account selectors are active, bare native selections expand into selector-qualified groups. Other -models remain callable by exact id. +`spawn_agent` model overrides. The dashboard Subagents page can select and save up to five bare +native ids or routed `provider/model` ids. Manually configured `subagentModels` also accepts +account-qualified `/` ids, but the dashboard does not offer those +exact ids; saving the page replaces the list with dashboard-visible choices. opencodex assigns low +catalog priorities in the selected order; when account selectors are active, bare native selections +expand into selector-qualified groups. Other models remain callable by exact id. The featured-model list is separate from the Dashboard's **Sub-agent delegation** selection. It controls which overrides Codex offers first; it does not select a model or trigger delegation by diff --git a/docs-site/src/content/docs/guides/model-ordering.md b/docs-site/src/content/docs/guides/model-ordering.md index 4c89e05d3..87d209dee 100644 --- a/docs-site/src/content/docs/guides/model-ordering.md +++ b/docs-site/src/content/docs/guides/model-ordering.md @@ -15,7 +15,15 @@ it earlier in the picker. The implementation records this constraint directly in `src/codex/catalog/sync.ts`. opencodex therefore controls featured placement by assigning lower priorities, not by relying on -array position. The relevant priorities are: +array position. Unless noted otherwise, the fixed priorities and worked example below describe a +catalog with no eligible Codex account selectors. With `N` eligible selectors, featured priorities +use `N` as a stride: a bare native choice at configured rank `i` expands to selector rows at +priorities `i * N + j`, where `j` is the selector's zero-based position; a routed choice uses +`i * N`; and an exact selector-qualified choice uses `i * N + j` for its selector. Unselected routed +rows are moved outside those selector groups. Codex still advertises only the first five +picker-visible rows. + +The relevant no-selector priorities are: | Catalog entry | Priority | Source | | --- | ---: | --- | @@ -57,7 +65,7 @@ only change whether a model is included. ## Effective picker pattern -With a non-empty featured list, the resulting order is: +With no eligible account selectors and a non-empty featured list, the resulting order is: 1. Models in the exact configured `subagentModels` order, with priorities `0` through `4`. 2. All remaining routed models, ordered alphabetically by provider and then model id, at priority `5`. @@ -95,16 +103,19 @@ The picker begins as follows: | After routed models | Remaining native models | `featured.length + 100` or higher | Unselected natives are moved below the featured block | The first five entries are the overrides advertised to `spawn_agent`; the rest continue in the -normal picker order. +normal picker order. With account selectors, the five-entry limit applies after bare native choices +have expanded into selector-qualified groups. ## Changing the order -The only supported way to customize leading model order is to reorder `subagentModels`. You can do -that on the dashboard's **Sub-agents** page or in the opencodex configuration. The list accepts at -most five models, and its order is significant. +The supported way to customize leading model order is to reorder `subagentModels`. The dashboard's +**Sub-agents** page can reorder bare native and routed ids. Use `ocx agent subagents set` or edit the +opencodex configuration for exact `/` choices; the dashboard does not +list those choices and omits them if it saves the roster. Use at most five configured ids. With +account selectors, one bare native choice can expand into multiple selector-qualified catalog rows, +so configured choices and advertised rows are not necessarily one-to-one. There is currently no general `modelOrder`, `providerOrder`, or priority-map setting in `OcxConfig`. -The supported ordering field is `subagentModels` (`src/types.ts:238-246`); `disabledModels` and each -provider's `selectedModels` are visibility fields (`src/types.ts:276-282` and -`src/types.ts:439-446`). To change the rest of the picker order would require a code-level behavior -change rather than a configuration edit. +The supported ordering field is `subagentModels`; `disabledModels` and each provider's +`selectedModels` are visibility fields. Changing the remaining picker order would require a +code-level behavior change rather than a configuration edit. diff --git a/docs-site/src/content/docs/ja/guides/codex-app-models.md b/docs-site/src/content/docs/ja/guides/codex-app-models.md index 9f48fde27..77a93e9fb 100644 --- a/docs-site/src/content/docs/ja/guides/codex-app-models.md +++ b/docs-site/src/content/docs/ja/guides/codex-app-models.md @@ -56,12 +56,15 @@ visibility = "list" ## ネイティブモデルとルーティングモデルの切り替え -ダッシュボードの「モデル」ページでは、両方のモデル ファミリで `disabledModels` を使用します。 +ダッシュボードの Models ページでは、bare native id と routed `provider/model` id の +`disabledModels` を切り替えられます。Account-qualified +`/` id も `disabledModels` でサポートされますが、ダッシュボードには +exact selector 行が表示されず、切り替えることもできません。この id は設定に直接追加してください。 - Routed provider id は名前空間付き (`provider/model`) です。無効にすると、同期済みカタログと `/v1/models` から除外されます。 -- Account-qualified native id は `/` 形式です。無効にすると、 - その selector 行だけが非表示になります。 +- Account-qualified native id は `/` 形式です。この id を + `disabledModels` に設定すると、その selector 行だけが非表示になります。 - Bare native GPT id は bare slug です。無効にすると、後で再び有効化できるようカタログ エントリを保持したまま、bare 行とそのモデルの全 account-selector 複製行を非表示にします。 - ネイティブ行はサポートされている静的セットから取得されるため、無効になったネイティブ モデルは引き続き表示されます。 @@ -94,7 +97,7 @@ fast_mode = true ## サブエージェントの選択 -Codex は、ピッカーに表示されるカタログ エントリを `priority` の昇順で並べ替え、最初の 5 つを `spawn_agent` モデル オーバーライドとしてアドバタイズします。`subagentModels` またはダッシュボードの Subagents ページで、bare native id、account-qualified `/` id、または routed `provider/model` id を最大 5 つ選択します。opencodex は選択順に低いカタログ priority を割り当てます。account selector が有効な場合、bare native の選択は selector-qualified グループに展開されます。他のモデルは引き続き正確な ID で呼び出すことができます。 +Codex は、ピッカーに表示されるカタログ エントリを `priority` の昇順で並べ替え、最初の 5 つを `spawn_agent` モデル オーバーライドとしてアドバタイズします。ダッシュボードの Subagents ページでは、bare native id または routed `provider/model` id を最大 5 つ選択して保存できます。手動で設定した `subagentModels` は account-qualified `/` id も受け付けますが、ダッシュボードにはこれらの exact id が表示されません。ページを保存すると、リストはダッシュボードに表示される選択肢で置き換えられます。opencodex は選択順に低いカタログ priority を割り当てます。account selector が有効な場合、bare native の選択は selector-qualified グループに展開されます。他のモデルは引き続き正確な ID で呼び出すことができます。 注目モデルのリストは、ダッシュボードの **サブエージェント委任** の選択とは別のものです。 Codex が提供するものを最初にオーバーライドするものを制御します。モデルを選択したり、委任をトリガーしたりすることはありません。 diff --git a/docs-site/src/content/docs/ja/guides/model-ordering.md b/docs-site/src/content/docs/ja/guides/model-ordering.md index e40eb62b7..6108c0877 100644 --- a/docs-site/src/content/docs/ja/guides/model-ordering.md +++ b/docs-site/src/content/docs/ja/guides/model-ordering.md @@ -13,7 +13,14 @@ Codex の models-manager はピッカーに表示されるカタログ項目を カタログ配列順は捨てるため、生成された JSON 配列で項目を前に動かしてもピッカーでは前に移動しません。この制約は `src/codex/catalog/sync.ts` に直接記録されています。 そのため opencodex は配列位置ではなくより低い priority を付与してフィーチャー位置を制御します。 -関連 priority は次のとおりです。 +この表の固定値と以下の例は、有効な account selector がない構成を説明します。`N` 個の selector が +ある場合、設定 rank `i` の featured bare native は priority `i * N + j` の selector 行へ展開され、 +`j` は 0 から始まる selector の位置です。featured routed 行には `i * N`、exact +account-qualified native id にはその selector の `i * N + j` が使用されます。Codex が公開するのは +引き続きピッカーに表示される最初の 5 行だけです。選択されていない routed 行は、これらの selector +グループの外へ移動されます。 + +selector がない場合の priority は次のとおりです。 | カタログ項目 | Priority | 根拠 | --- | ---: | --- | @@ -53,7 +60,7 @@ Codex の priority ソートでもこの先頭順序は保存されます。 ## 最終ピッカーパターン -featured リストが空でないときの最終順序は次のとおりです。 +有効な account selector がなく、featured リストが空でない場合の最終順序は次のとおりです。 1. 設定された `subagentModels` 順どおりに、priority `0` から `4` を受けたモデル 2. 残りのすべてのルーティングモデル、プロバイダー順とモデル ID 順アルファベットソート、priority `5` @@ -92,13 +99,19 @@ subagentModels = [ 最初の 5 項目は `spawn_agent` に広告されるオーバーライドで、残りは通常のピッカー順序に続きます。 +account selector がある場合、5 項目の制限は bare native の選択が selector-qualified グループへ +展開された後に適用されます。 + ## 順序を変える方法 -先頭モデルの順序をユーザーが変えられる唯一のサポート手段は `subagentModels` を並び替えることです。 -ダッシュボードの **Sub-agents** ページまたは opencodex 設定で変更できます。一覧は最大 5 モデルを -受け付け、配列順序に意味があります。 +先頭モデルの順序を変更するサポート手段は、`subagentModels` を並べ替えることです。 +ダッシュボードの **Sub-agents** ページでは bare native id と routed id を並べ替えられます。 +設定と `ocx agent subagents set` は exact account-qualified +`/` id も受け付けますが、ダッシュボードはこれらの id を表示せず、 +リストの保存時にも保持しません。設定する id は最大 5 つにしてください。account selector がある +場合は 1 つの bare native が複数の selector-qualified 行に展開されるため、設定した選択肢と公開 +される行は必ずしも一対一ではありません。 現在 `OcxConfig` には一般 `modelOrder`、`providerOrder`、priority map 設定はありません。サポートされるソート -フィールドは `subagentModels` です(`src/types.ts:238-246`)。`disabledModels` と各プロバイダーの -`selectedModels` は公開フィールドです(`src/types.ts:276-282`、`src/types.ts:439-446`)。そのため残りの -ピッカー順序を変えるには設定変更ではなくコード動作の変更が必要です。 +フィールドは `subagentModels` です。`disabledModels` と各プロバイダーの `selectedModels` は公開 +フィールドです。そのため残りのピッカー順序を変えるには設定変更ではなくコード動作の変更が必要です。 diff --git a/docs-site/src/content/docs/ja/reference/configuration/agents.md b/docs-site/src/content/docs/ja/reference/configuration/agents.md index de361056b..7852bdb69 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/agents.md +++ b/docs-site/src/content/docs/ja/reference/configuration/agents.md @@ -10,7 +10,7 @@ description: マルチエージェント サーフェス、委任ガイダンス |フィールド |タイプ |デフォルト |意味 | | --- | --- | --- | --- | | `multiAgentMode?` | `"v1" \| "default" \| "v2"` | `"default"` | `v1` はすべてのカタログ モデルを v1 としてスタンプします。 `v2` はすべてのモデルを v2 としてスタンプします。 `default` はアップストリーム ピン (Sol/Terra v2、Luna v1) を復元し、それ以外の場合はネイティブの `multi_agent_v2` フラグに従います。新しいセッションに適用されます。 | -| `subagentModels?` | `string[]` | `gpt-5.5`、`gpt-5.6-sol`、`gpt-5.6-terra`、`gpt-5.6-luna`、`gpt-5.4-mini` |最大 5 つのネイティブ ID またはルーティング ID がサブエージェント ピッカーで最初に表示されます。明示的な空のリストは保存されます。 | +| `subagentModels?` | `string[]` | `gpt-5.5`、`gpt-5.6-sol`、`gpt-5.6-terra`、`gpt-5.6-luna`、`gpt-5.4-mini` |最大 5 つの bare native id、account-qualified `/` id、または routed `provider/model` id をサブエージェント ピッカーで優先表示します。Subagents ページで選べるのは bare native id と routed id だけで、保存時には exact account-qualified の選択が除外されます。exact の選択には `ocx agent subagents set` を使用するか、設定を直接編集してください。明示的な空リストも保持されます。 | | `injectionModel?` | `string` | — |プロキシ作成の v2 委任ガイダンスで使用される、優先されるネイティブまたはルーティングされたサブエージェント モデル。 | | `injectionEffort?` | `string` | — |優先努力 (`low` ~ `ultra`)。`injectionModel` でのみ意味があります。 | | `injectionPrompt?` | `string` | — |内蔵ガイドボディを交換します。 `{{model}}`、`{{effort}}`、`{{roster}}`、`{{fallback}}`をサポートします。ファイアリングゲートは変更されません。 | diff --git a/docs-site/src/content/docs/ja/reference/configuration/providers.md b/docs-site/src/content/docs/ja/reference/configuration/providers.md index 17cecb3d1..7c3ab0288 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -11,7 +11,7 @@ description: プロバイダー エントリ、認証、エンドポイント、 | --- | --- | --- | --- | | `providers` | `Record` | — |プロバイダー名からプロバイダー設定へのマップ。 | | `openaiProviderTierVersion?` | `2` |移行によって設定される |単一のオプション対応 OpenAI プロジェクションを完了としてマークします。 | -| `disabledModels?` | `string[]` | — | Codex catalog と `/v1/models` から非表示にする model。直接の proxy 呼び出しはブロックしません。routed id は一覧から削除されます。account-qualified native id は該当する selector row だけを非表示にし、bare native GPT id は bare row とその model の全 account-selector row を非表示にします。 | +| `disabledModels?` | `string[]` | — | Codex catalog と `/v1/models` から非表示にする model。直接の proxy 呼び出しはブロックしません。routed id は一覧から削除されます。account-qualified native id は該当する selector row だけを非表示にし、bare native GPT id は bare row とその model の全 account-selector row を非表示にします。Models ページに表示されるのは bare native 行と routed 行だけです。selector-qualified 行を 1 つだけ非表示にするには、この設定フィールドを直接編集してください。 | | `providerContextCaps?` | `Record` | `{}` |プロバイダーごとの Codex に表示されるコンテキストの上限。キャップは既知のコンテキスト ウィンドウを下げるだけです。 | | `contextCapValue?` | `number` | `350000` |ダッシュボードのコンテキストキャップ コントロールで使用される値。これを変更すると、有効になっているすべての `providerContextCaps` エントリが更新されます。 | | `codexAccounts?` | `CodexAccount[]` | `[]` | ChatGPT/Codex プール アカウントのメタデータは Codex Auth によって管理されます。秘密は`codex-accounts.json`に別に住んでいます。 | diff --git a/docs-site/src/content/docs/ko/guides/codex-app-models.md b/docs-site/src/content/docs/ko/guides/codex-app-models.md index 2ef3acc80..f6c454c5e 100644 --- a/docs-site/src/content/docs/ko/guides/codex-app-models.md +++ b/docs-site/src/content/docs/ko/guides/codex-app-models.md @@ -85,12 +85,15 @@ Luna는 `max`에서 멈춥니다. Sol의 기본값은 `low`이고, Terra와 Luna ## 네이티브 및 라우팅 모델 토글 -대시보드 Models 페이지는 두 모델 계열 모두 `disabledModels`를 사용합니다. +대시보드 Models 페이지는 bare native id와 routed `provider/model` id에 대한 `disabledModels` 토글을 +제공합니다. Account-qualified `/` id도 `disabledModels`에서 +지원하지만 대시보드는 exact selector 행을 표시하거나 토글하지 않습니다. 이 id는 구성에 직접 +추가해야 합니다. - Routed provider id는 네임스페이스 형식(`provider/model`)입니다. 비활성화하면 동기화된 카탈로그와 `/v1/models`에서 제외됩니다. -- Account-qualified native id는 `/` 형식입니다. 비활성화하면 해당 - selector 행만 숨깁니다. +- Account-qualified native id는 `/` 형식입니다. 이 id를 + `disabledModels`에 추가하면 해당 selector 행만 숨깁니다. - Bare native GPT id는 bare slug입니다. 비활성화하면 나중에 다시 켤 수 있도록 카탈로그 항목은 유지하면서 bare 행과 해당 모델의 모든 account-selector 복제 행을 숨깁니다. - 네이티브 행은 지원되는 정적 집합에서 오므로, 비활성화한 네이티브 모델은 대시보드에 계속 보이고 다시 @@ -135,12 +138,14 @@ fast_mode = true ## 서브에이전트 선택 -Codex는 선택기에 보이는 카탈로그 항목을 `priority` 오름차순으로 정렬한 뒤 처음 다섯 개를 `spawn_agent` -model override로 노출합니다. `subagentModels`나 대시보드 Subagents 페이지에서 bare native id, -account-qualified `/` id, 또는 routed `provider/model` id를 최대 다섯 개 -선택하세요. opencodex는 선택한 순서대로 낮은 카탈로그 priority를 부여합니다. account selector가 활성화되어 -있으면 bare native 선택은 selector-qualified 그룹으로 확장됩니다. 다른 모델도 정확한 id로 직접 호출할 수 -있습니다. +Codex는 선택기에 보이는 카탈로그 항목을 `priority` 오름차순으로 정렬한 뒤 처음 다섯 개를 +`spawn_agent` model override로 노출합니다. 대시보드 Subagents 페이지에서는 bare native id 또는 +routed `provider/model` id를 최대 다섯 개 선택하고 저장할 수 있습니다. 수동으로 설정한 +`subagentModels`는 account-qualified `/` id도 지원하지만, +대시보드는 이러한 exact id를 제공하지 않으며 페이지를 저장하면 목록이 대시보드에 표시되는 선택 항목으로 +교체됩니다. opencodex는 선택한 순서대로 낮은 카탈로그 priority를 부여합니다. account selector가 +활성화되어 있으면 bare native 선택은 selector-qualified 그룹으로 확장됩니다. 다른 모델도 정확한 id로 +직접 호출할 수 있습니다. featured-model 목록은 Dashboard의 **Sub-agent delegation** 선택과 별개입니다. Codex가 먼저 보여 줄 override를 정할 뿐, 모델을 고르거나 delegation을 시작하지는 않습니다. diff --git a/docs-site/src/content/docs/ko/guides/model-ordering.md b/docs-site/src/content/docs/ko/guides/model-ordering.md index 45c7b6e77..3c960b184 100644 --- a/docs-site/src/content/docs/ko/guides/model-ordering.md +++ b/docs-site/src/content/docs/ko/guides/model-ordering.md @@ -14,7 +14,13 @@ Codex의 models-manager는 선택기에 표시되는 카탈로그 항목을 `pri 이동하지 않습니다. 이 제약은 `src/codex/catalog/sync.ts`에 직접 기록되어 있습니다. 따라서 opencodex는 배열 위치가 아니라 더 낮은 priority를 부여해 featured 위치를 제어합니다. -관련 priority는 다음과 같습니다. +이 표의 고정값과 아래 예시는 유효한 account selector가 없는 설정을 설명합니다. `N`개의 selector가 +있으면 설정 rank `i`의 featured bare native는 priority `i * N + j`인 selector 행으로 확장되며, +`j`는 0부터 시작하는 selector 위치입니다. featured routed 행은 `i * N`, exact account-qualified +native id는 해당 selector의 `i * N + j`를 사용합니다. Codex는 계속 선택기에 표시되는 처음 다섯 +행만 노출합니다. 선택되지 않은 routed 행은 이러한 selector 그룹 밖으로 이동합니다. + +selector가 없을 때의 priority는 다음과 같습니다. | 카탈로그 항목 | Priority | 근거 | | --- | ---: | --- | @@ -54,7 +60,7 @@ Codex의 priority 정렬에서도 이 선두 순서가 보존됩니다. ## 최종 선택기 패턴 -featured 목록이 비어 있지 않을 때 최종 순서는 다음과 같습니다. +유효한 account selector가 없고 featured 목록이 비어 있지 않을 때 최종 순서는 다음과 같습니다. 1. 설정된 `subagentModels` 순서 그대로, priority `0`부터 `4`를 받은 모델 2. 나머지 모든 라우팅 모델, 프로바이더순과 모델 id순 알파벳 정렬, priority `5` @@ -93,13 +99,18 @@ subagentModels = [ 처음 5개 항목은 `spawn_agent`에 광고되는 override이며, 나머지는 일반 선택기 순서로 이어집니다. +account selector가 있으면 bare native 선택이 selector-qualified 그룹으로 확장된 뒤에 5개 제한이 +적용됩니다. + ## 순서를 바꾸는 방법 -선두 모델 순서를 사용자가 바꿀 수 있는 유일한 지원 수단은 `subagentModels`를 재정렬하는 것입니다. -대시보드의 **Sub-agents** 페이지 또는 opencodex 설정에서 바꿀 수 있습니다. 목록은 최대 5개 모델을 -받으며 배열 순서에 의미가 있습니다. +선두 모델 순서를 바꾸는 지원 수단은 `subagentModels`를 재정렬하는 것입니다. 대시보드의 +**Sub-agents** 페이지에서는 bare native와 routed id의 순서를 바꿀 수 있습니다. 설정과 +`ocx agent subagents set`은 exact account-qualified `/` id도 +지원하지만, 대시보드는 이러한 id를 제공하지 않으며 목록을 저장할 때도 보존하지 않습니다. 설정 id는 +최대 5개만 사용하세요. account selector가 있으면 bare native 하나가 여러 selector-qualified 행으로 +확장될 수 있으므로 설정 항목과 노출 행이 항상 일대일로 대응하지는 않습니다. 현재 `OcxConfig`에는 일반 `modelOrder`, `providerOrder`, priority map 설정이 없습니다. 지원되는 정렬 -필드는 `subagentModels`입니다(`src/types.ts:238-246`). `disabledModels`와 각 프로바이더의 -`selectedModels`는 노출 필드입니다(`src/types.ts:276-282`, `src/types.ts:439-446`). 따라서 나머지 -선택기 순서를 바꾸려면 설정 수정이 아니라 코드 동작 변경이 필요합니다. +필드는 `subagentModels`입니다. `disabledModels`와 각 프로바이더의 `selectedModels`는 노출 +필드입니다. 따라서 나머지 선택기 순서를 바꾸려면 설정 수정이 아니라 코드 동작 변경이 필요합니다. diff --git a/docs-site/src/content/docs/ko/reference/configuration/agents.md b/docs-site/src/content/docs/ko/reference/configuration/agents.md index 210a032d3..706b9768a 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/agents.md +++ b/docs-site/src/content/docs/ko/reference/configuration/agents.md @@ -10,7 +10,7 @@ description: 멀티 에이전트 표면, 위임 안내, 선호 모델, 대체 | 필드 | 형식 | 기본값 | 의미 | | --- | --- | --- | --- | | `multiAgentMode?` | `"v1" \| "default" \| "v2"` | `"default"` | `v1`은 카탈로그의 모든 모델에 v1을 표시하고, `v2`는 모든 모델에 v2를 표시합니다. `default`는 상위 고정값(Sol/Terra는 v2, Luna는 v1)을 복원하고, 그 외에는 네이티브 `multi_agent_v2` 플래그를 따릅니다. 새 세션에 적용됩니다. | -| `subagentModels?` | `string[]` | `gpt-5.5`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`, `gpt-5.4-mini` | 최대 다섯 개의 네이티브 또는 라우팅된 id를 서브에이전트 선택기에서 우선 표시합니다. 명시적으로 빈 목록을 넣어도 그대로 보존합니다. | +| `subagentModels?` | `string[]` | `gpt-5.5`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`, `gpt-5.4-mini` | 최대 다섯 개의 bare native id, account-qualified `/` id 또는 routed `provider/model` id를 서브에이전트 선택기에서 우선 표시합니다. Subagents 페이지는 bare native와 routed id만 제공하며 저장할 때 exact account-qualified 선택을 제외합니다. exact 선택은 `ocx agent subagents set`을 사용하거나 설정을 직접 편집하세요. 명시적인 빈 목록도 그대로 보존됩니다. | | `injectionModel?` | `string` | — | 프록시가 작성한 v2 위임 안내에서 사용하는 선호 네이티브 또는 라우팅된 서브에이전트 모델입니다. | | `injectionEffort?` | `string` | — | 선호 노력(`low`부터 `ultra`까지)입니다. `injectionModel`이 있을 때만 의미가 있습니다. | | `injectionPrompt?` | `string` | — | 내장 안내 본문을 대체합니다. `{{model}}`, `{{effort}}`, `{{roster}}`, `{{fallback}}`를 지원합니다. 발동 조건은 그대로입니다. | diff --git a/docs-site/src/content/docs/ko/reference/configuration/providers.md b/docs-site/src/content/docs/ko/reference/configuration/providers.md index a75469e25..a389a79fc 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -11,7 +11,7 @@ description: 공급자 항목, 인증, 엔드포인트, 모델 카탈로그, 할 | --- | --- | --- | --- | | `providers` | `Record` | — | 공급자 이름을 공급자 설정에 매핑합니다. | | `openaiProviderTierVersion?` | `2` | 마이그레이션으로 설정됨 | 옵션을 인식하는 단일 OpenAI 투영이 완료되었음을 표시합니다. | -| `disabledModels?` | `string[]` | — | Codex catalog와 `/v1/models`에서는 숨기지만 직접 proxy 호출은 차단하지 않습니다. routed id는 목록에서 제거됩니다. account-qualified native id는 해당 selector row만 숨기고, bare native GPT id는 bare row와 그 model의 모든 account-selector row를 숨깁니다. | +| `disabledModels?` | `string[]` | — | Codex catalog와 `/v1/models`에서는 숨기지만 직접 proxy 호출은 차단하지 않습니다. routed id는 목록에서 제거됩니다. account-qualified native id는 해당 selector row만 숨기고, bare native GPT id는 bare row와 그 model의 모든 account-selector row를 숨깁니다. Models 페이지에는 bare native 행과 routed 행만 표시됩니다. selector-qualified 행 하나만 숨기려면 이 설정 필드에 직접 추가하세요. | | `providerContextCaps?` | `Record` | `{}` | 공급자별 Codex 표시 컨텍스트 상한입니다. 상한은 이미 알려진 컨텍스트 윈도만 낮춥니다. | | `contextCapValue?` | `number` | `350000` | 대시보드의 컨텍스트 상한 컨트롤이 사용하는 값입니다. 이 값을 바꾸면 활성화된 모든 `providerContextCaps` 항목이 함께 갱신됩니다. | | `codexAccounts?` | `CodexAccount[]` | `[]` | Codex Auth가 관리하는 ChatGPT/Codex 풀 계정 메타데이터입니다. 비밀 정보는 `codex-accounts.json`에 따로 저장됩니다. | diff --git a/docs-site/src/content/docs/reference/configuration/agents.md b/docs-site/src/content/docs/reference/configuration/agents.md index 8ef27e59d..7cc30e40d 100644 --- a/docs-site/src/content/docs/reference/configuration/agents.md +++ b/docs-site/src/content/docs/reference/configuration/agents.md @@ -11,7 +11,7 @@ routes, and limits delegated work. | Field | Type | Default | Meaning | | --- | --- | --- | --- | | `multiAgentMode?` | `"v1" \| "default" \| "v2"` | `"default"` | `v1` stamps every catalog model as v1; `v2` stamps every model as v2. `default` restores upstream pins (Sol/Terra v2, Luna v1) and otherwise follows the native `multi_agent_v2` flag. Applies to new sessions. | -| `subagentModels?` | `string[]` | `gpt-5.5`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`, `gpt-5.4-mini` | Up to five native or routed ids featured first in the sub-agent picker. An explicit empty list is preserved. | +| `subagentModels?` | `string[]` | `gpt-5.5`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`, `gpt-5.4-mini` | Up to five bare native, account-qualified `/`, or routed `provider/model` ids featured first in the sub-agent picker. The dashboard offers only bare native and routed ids and omits exact account-qualified choices when it saves; use `ocx agent subagents set` or edit the configuration for exact choices. An explicit empty list is preserved. | | `injectionModel?` | `string` | — | Preferred native or routed sub-agent model used in proxy-authored v2 delegation guidance. | | `injectionEffort?` | `string` | — | Preferred effort (`low` through `ultra`), meaningful only with `injectionModel`. | | `injectionPrompt?` | `string` | — | Replaces the built-in guidance body. Supports `{{model}}`, `{{effort}}`, `{{roster}}`, and `{{fallback}}`. Firing gates remain unchanged. | diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 388710f2d..a9603702b 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -12,7 +12,7 @@ authenticated. | --- | --- | --- | --- | | `providers` | `Record` | — | Map of provider name to provider config. | | `openaiProviderTierVersion?` | `2` | set by migration | Marks the single option-aware OpenAI projection as complete. | -| `disabledModels?` | `string[]` | — | Models hidden from Codex's catalog and `/v1/models`, but not blocked from direct proxy calls. Routed ids are removed from listings. An account-qualified native id hides only that selector row; a bare native GPT id hides the bare row and every account-selector row for that model. | +| `disabledModels?` | `string[]` | — | Models hidden from Codex's catalog and `/v1/models`, but not blocked from direct proxy calls. A routed id is removed from listings. An account-qualified native id hides only that selector row; a bare native GPT id hides the bare row and every account-selector row for that model. The dashboard Models page exposes only routed and bare native rows; use this configuration field directly to hide one selector-qualified row. | | `providerContextCaps?` | `Record` | `{}` | Per-provider Codex-visible context caps. A cap only lowers a known context window. | | `contextCapValue?` | `number` | `350000` | Value used by the dashboard context-cap controls; changing it updates every enabled `providerContextCaps` entry. | | `codexAccounts?` | `CodexAccount[]` | `[]` | ChatGPT/Codex pool account metadata managed by Codex Auth. Secrets live separately in `codex-accounts.json`. | diff --git a/docs-site/src/content/docs/ru/guides/codex-app-models.md b/docs-site/src/content/docs/ru/guides/codex-app-models.md index 7922f08d7..1fad62d41 100644 --- a/docs-site/src/content/docs/ru/guides/codex-app-models.md +++ b/docs-site/src/content/docs/ru/guides/codex-app-models.md @@ -91,12 +91,15 @@ delegation и на backend он уходит как `max`. Наличие зап ## Переключатели нативных и маршрутизируемых моделей -Страница Models в дашборде использует `disabledModels` для обоих семейств моделей: +Страница Models в дашборде предоставляет переключатели `disabledModels` для bare native-id и +routed provider-id `provider/model`. Account-qualified id `/` тоже +поддерживаются в `disabledModels`, но дашборд не перечисляет и не переключает точные строки +селекторов; добавляйте их в конфигурацию вручную: - Routed provider-id имеют namespace (`provider/model`). Отключение исключает такую модель из синхронизируемого каталога и `/v1/models`. -- Account-qualified native-id имеют вид `/`. Отключение скрывает - только строку этого селектора. +- Account-qualified native-id имеют вид `/`. Если добавить такой id + в `disabledModels`, скрывается только строка этого селектора. - Bare native GPT-id — это голые slug. Отключение скрывает bare-строку и все account-selector-клоны этой модели, сохраняя записи каталога для последующего включения. - Нативные строки берутся из поддерживаемого статического набора, поэтому отключённая нативная @@ -143,11 +146,13 @@ capability-гейтом — `service_tier` удаляется только ко ## Выбор подагентов Codex сортирует видимые в picker'е записи каталога по возрастанию `priority` и рекламирует первые -пять как model-override для `spawn_agent`. Выберите до пяти bare native-id, account-qualified id -`/` или routed provider-id `provider/model` через `subagentModels` -либо страницу Subagents дашборда. opencodex назначает им низкие приоритеты каталога в выбранном -порядке; при активных селекторах аккаунтов bare native-выбор разворачивается в группы -selector-qualified строк. Остальные модели всё равно можно вызывать по точному id. +пять как model-override для `spawn_agent`. Страница Subagents в дашборде позволяет выбрать и +сохранить до пяти bare native-id или routed provider-id `provider/model`. Настроенный вручную +`subagentModels` также принимает account-qualified id `/`, но +дашборд не предлагает эти точные id; сохранение страницы заменяет список вариантами, доступными в +дашборде. opencodex назначает им низкие приоритеты каталога в выбранном порядке; при активных +селекторах аккаунтов bare native-выбор разворачивается в группы selector-qualified строк. Остальные +модели всё равно можно вызывать по точному id. Список featured-моделей отделён от выбора **Sub-agent delegation** в дашборде. Он только определяет, какие override Codex показывает первыми; он не выбирает модель и не инициирует diff --git a/docs-site/src/content/docs/ru/guides/model-ordering.md b/docs-site/src/content/docs/ru/guides/model-ordering.md index d7dbae4ae..d5a368383 100644 --- a/docs-site/src/content/docs/ru/guides/model-ordering.md +++ b/docs-site/src/content/docs/ru/guides/model-ordering.md @@ -15,7 +15,15 @@ description: Как opencodex определяет порядок моделей зафиксировано прямо в реализации, в `src/codex/catalog/sync.ts`. Поэтому opencodex управляет размещением избранных моделей, назначая более низкие приоритеты, а -не полагаясь на позицию в массиве. Задействованные приоритеты: +не полагаясь на позицию в массиве. Фиксированные значения в этой таблице и пример ниже относятся +к конфигурации без подходящих селекторов аккаунтов. При наличии `N` селекторов каждая выбранная +bare native-модель с настроенным рангом `i` разворачивается в строки с приоритетами `i * N + j`, +где `j` — позиция селектора с нуля. Выбранная routed-строка получает `i * N`, а точный +account-qualified native-id — `i * N + j` для своего селектора. Codex по-прежнему объявляет только +первые пять видимых строк picker'а. Невыбранные routed-строки сдвигаются за пределы этих +selector-групп. + +Приоритеты без селекторов: | Запись каталога | Priority | Источник | | --- | ---: | --- | @@ -59,7 +67,7 @@ API управления ограничивает `subagentModels` пятью з ## Итоговая схема селектора -При непустом списке избранных итоговый порядок таков: +Без подходящих селекторов аккаунтов и при непустом списке избранных итоговый порядок таков: 1. Модели точно в настроенном порядке `subagentModels`, с приоритетами от `0` до `4`. 2. Все остальные маршрутизируемые модели, упорядоченные по алфавиту сначала по провайдеру, затем @@ -100,14 +108,20 @@ subagentModels = [ Первые пять записей — это переопределения, объявляемые `spawn_agent`; остальные продолжаются в обычном порядке селектора. +При наличии селекторов аккаунтов лимит в пять записей применяется после разворачивания bare +native-выбора в selector-qualified группы. + ## Изменение порядка -Единственный поддерживаемый способ настроить порядок ведущих моделей — переставить элементы -`subagentModels`. Это можно сделать на странице **Sub-agents** дашборда или в конфигурации -opencodex. Список принимает не более пяти моделей, и его порядок имеет значение. +Поддерживаемый способ настроить порядок ведущих моделей — переставить элементы `subagentModels`. +Страница **Sub-agents** в дашборде позволяет менять порядок bare native- и routed-id. Конфигурация +и `ocx agent subagents set` также принимают точные account-qualified id +`/`, но дашборд не предлагает и не сохраняет их при записи списка. +Используйте не более пяти настроенных id. При активных селекторах одна bare native-модель может +развернуться в несколько selector-qualified строк, поэтому число настроенных вариантов и +объявляемых строк не обязательно совпадает. Общих настроек `modelOrder`, `providerOrder` или карты приоритетов в `OcxConfig` сейчас нет. -Поддерживаемое поле порядка — `subagentModels` (`src/types.ts:238-246`); `disabledModels` и -`selectedModels` каждого провайдера — поля видимости (`src/types.ts:276-282` и -`src/types.ts:439-446`). Изменение остальной части порядка селектора потребовало бы изменения +Поддерживаемое поле порядка — `subagentModels`; `disabledModels` и `selectedModels` каждого +провайдера — поля видимости. Изменение остальной части порядка селектора потребовало бы изменения поведения на уровне кода, а не правки конфигурации. diff --git a/docs-site/src/content/docs/ru/reference/configuration/agents.md b/docs-site/src/content/docs/ru/reference/configuration/agents.md index fd29bbeae..acff1b653 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/agents.md +++ b/docs-site/src/content/docs/ru/reference/configuration/agents.md @@ -11,7 +11,7 @@ description: Multi-agent surface, guidance при делегировании, pr | Поле | Тип | По умолчанию | Значение | | --- | --- | --- | --- | | `multiAgentMode?` | `"v1" \| "default" \| "v2"` | `"default"` | `v1` штампует все модели как v1; `v2` штампует все модели как v2. `default` восстанавливает upstream pin'ы (Sol/Terra — v2, Luna — v1) и для остальных следует native flag `multi_agent_v2`. Применяется к новым сессиям. | -| `subagentModels?` | `string[]` | `gpt-5.5`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`, `gpt-5.4-mini` | До пяти native- или routed-id, которые показываются первыми в picker'е подагентов. Явный пустой список сохраняется. | +| `subagentModels?` | `string[]` | `gpt-5.5`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`, `gpt-5.4-mini` | До пяти bare native-id, account-qualified id `/` или routed-id `provider/model`, которые показываются первыми в picker'е подагентов. Страница Subagents предлагает только bare native- и routed-id и при сохранении исключает точные account-qualified варианты; для точного выбора используйте `ocx agent subagents set` или отредактируйте конфигурацию. Явный пустой список сохраняется. | | `injectionModel?` | `string` | — | Предпочитаемая native- или routed-модель подагента, которую proxy использует в собственном guidance v2. | | `injectionEffort?` | `string` | — | Предпочитаемый effort (`low`–`ultra`), имеющий смысл только вместе с `injectionModel`. | | `injectionPrompt?` | `string` | — | Заменяет встроенное тело guidance. Поддерживает `{{model}}`, `{{effort}}`, `{{roster}}` и `{{fallback}}`. Условия срабатывания при этом не меняются. | diff --git a/docs-site/src/content/docs/ru/reference/configuration/providers.md b/docs-site/src/content/docs/ru/reference/configuration/providers.md index 79a611694..d2bb183b1 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -12,7 +12,7 @@ description: Записи провайдеров, аутентификация, | --- | --- | --- | --- | | `providers` | `Record` | — | Map вида provider name → provider config. | | `openaiProviderTierVersion?` | `2` | set by migration | Отмечает, что единая projection OpenAI с учётом режима уже завершена. | -| `disabledModels?` | `string[]` | — | Модели, скрытые из каталога Codex и `/v1/models`, но не заблокированные для прямых вызовов прокси. Routed-id удаляются из списков. Account-qualified native-id скрывает только строку этого селектора; bare native GPT-id скрывает bare-строку и строки всех селекторов аккаунтов для этой модели. | +| `disabledModels?` | `string[]` | — | Модели, скрытые из каталога Codex и `/v1/models`, но не заблокированные для прямых вызовов прокси. Routed-id удаляются из списков. Account-qualified native-id скрывает только строку этого селектора; bare native GPT-id скрывает bare-строку и строки всех селекторов аккаунтов для этой модели. Страница Models показывает только bare native- и routed-строки; чтобы скрыть одну selector-qualified строку, задайте это поле конфигурации напрямую. | | `providerContextCaps?` | `Record` | `{}` | Context cap'ы, видимые Codex, по каждому провайдеру. Cap может только понижать известное context window. | | `contextCapValue?` | `number` | `350000` | Значение, используемое элементами управления context-cap в дашборде; его изменение обновляет все включённые записи `providerContextCaps`. | | `codexAccounts?` | `CodexAccount[]` | `[]` | Метаданные аккаунтов пула ChatGPT/Codex, которыми управляет Codex Auth. Секреты живут отдельно в `codex-accounts.json`. | diff --git a/docs-site/src/content/docs/zh-cn/guides/codex-app-models.md b/docs-site/src/content/docs/zh-cn/guides/codex-app-models.md index 9b5d66f2b..2e9eaad16 100644 --- a/docs-site/src/content/docs/zh-cn/guides/codex-app-models.md +++ b/docs-site/src/content/docs/zh-cn/guides/codex-app-models.md @@ -57,12 +57,14 @@ visibility = "list" ## 原生与路由模型开关 -仪表盘 Models 页面对两类模型都使用 `disabledModels`: +仪表盘 Models 页面为裸原生 id 和路由 `provider/model` id 提供 `disabledModels` 开关。 +`disabledModels` 也支持账户限定的 `/` id,但仪表盘不会列出或切换这些 +精确 selector 行;请把它们直接添加到配置中: - 路由 provider id 使用命名空间形式(`provider/model`)。禁用后会从同步目录和 `/v1/models` 中移除。 -- 账户限定的原生 id 使用 `/` 形式。禁用后只隐藏对应的 - selector 行。 +- 账户限定的原生 id 使用 `/` 形式。把该 id 写入 + `disabledModels` 只会隐藏对应的 selector 行。 - 裸原生 GPT id 是裸 slug。禁用后会隐藏裸行以及该模型的所有 account-selector 克隆行, 同时保留目录条目以便之后重新启用。 - 原生行来自受支持的静态集合,因此被禁用的原生模型仍会在仪表盘中可见,并且可以重新打开。 @@ -94,7 +96,7 @@ fast_mode = true ## 子代理选择 -Codex 会按 `priority` 升序对选择器可见的目录条目排序,并把前五个作为 `spawn_agent` 模型 override 暴露出来。你可以通过 `subagentModels` 或仪表盘的 Subagents 页面,选择最多五个裸原生 id、账户限定的 `/` id,或路由 `provider/model` id。opencodex 会按所选顺序分配较低的目录 priority;启用账户 selector 时,裸原生选择会展开为 selector-qualified 分组。其他模型仍然可以通过精确 id 调用。 +Codex 会按 `priority` 升序对选择器可见的目录条目排序,并把前五个作为 `spawn_agent` 模型 override 暴露出来。仪表盘 Subagents 页面最多可以选择并保存五个裸原生 id 或路由 `provider/model` id。手动设置的 `subagentModels` 也支持账户限定的 `/` id,但仪表盘不会提供这些精确 id;保存该页面会用仪表盘中可见的选项替换整个列表。opencodex 会按所选顺序分配较低的目录 priority;启用账户 selector 时,裸原生选择会展开为 selector-qualified 分组。其他模型仍然可以通过精确 id 调用。 精选模型列表与 Dashboard 的 **Sub-agent delegation** 选择彼此独立。它只决定 Codex 先提供哪些 override;它不会自己选择模型,也不会触发委派。 diff --git a/docs-site/src/content/docs/zh-cn/guides/model-ordering.md b/docs-site/src/content/docs/zh-cn/guides/model-ordering.md index a35884e37..2f229176c 100644 --- a/docs-site/src/content/docs/zh-cn/guides/model-ordering.md +++ b/docs-site/src/content/docs/zh-cn/guides/model-ordering.md @@ -12,7 +12,13 @@ Codex 的 models-manager 按 `priority` 升序排列选择器中可见的目录 丢弃,因此在生成的 JSON 数组中把某个条目前移,并不会让它在选择器中前移。该约束直接记录在 `src/codex/catalog/sync.ts` 中。 -因此,opencodex 通过分配更低的 priority 控制置顶位置,而不依赖数组位置。相关 priority 如下: +因此,opencodex 通过分配更低的 priority 控制置顶位置,而不依赖数组位置。本表中的固定值及下例适用于 +没有有效账户 selector 的配置。存在 `N` 个 selector 时,配置 rank 为 `i` 的置顶裸原生模型会展开为 +priority 为 `i * N + j` 的 selector 行,其中 `j` 是从 0 开始的 selector 位置。置顶的路由行使用 +`i * N`,精确的账户限定原生 id 使用其 selector 对应的 `i * N + j`。Codex 仍只公布选择器中可见的 +前五行。未选中的路由行会移到这些 selector 分组之外。 + +没有 selector 时的相关 priority 如下: | 目录条目 | Priority | 来源 | | --- | ---: | --- | @@ -52,7 +58,7 @@ priority,因此 Codex 的 priority 排序会保留这个开头序列。 ## 最终选择器顺序 -featured 列表非空时,最终顺序为: +没有有效账户 selector 且 featured 列表非空时,最终顺序为: 1. 严格按照配置的 `subagentModels` 顺序排列,priority 为 `0` 至 `4`; 2. 所有剩余路由模型,先按 provider、再按模型 id 的字母顺序排列,priority 为 `5`; @@ -90,12 +96,16 @@ subagentModels = [ 前五个条目是向 `spawn_agent` 公布的 override,其余模型继续按普通选择器顺序排列。 +存在账户 selector 时,五项限制会在裸原生选择展开为 selector-qualified 分组之后应用。 + ## 更改顺序 -自定义开头模型顺序的唯一受支持方式是重新排列 `subagentModels`。你可以在仪表盘的 -**Sub-agents** 页面或 opencodex 配置中修改它。该列表最多接受五个模型,其数组顺序有实际意义。 +自定义开头模型顺序的受支持方式是重新排列 `subagentModels`。仪表盘的 **Sub-agents** 页面可以调整 +裸原生和路由 id 的顺序。配置和 `ocx agent subagents set` 也接受精确的账户限定 +`/` id,但仪表盘不会提供这些 id,保存列表时也不会保留它们。配置的 +id 请勿超过五个。存在账户 selector 时,一个裸原生选项可能展开为多个 selector-qualified 行,因此 +已配置的选项与公布的行不一定一一对应。 目前 `OcxConfig` 中没有通用的 `modelOrder`、`providerOrder` 或 priority map 设置。受支持的排序 -字段是 `subagentModels`(`src/types.ts:238-246`);`disabledModels` 和各 provider 的 -`selectedModels` 都是可见性字段(`src/types.ts:276-282`、`src/types.ts:439-446`)。因此,要更改 -选择器其余部分的顺序,需要修改代码行为,而不是调整配置。 +字段是 `subagentModels`;`disabledModels` 和各 provider 的 `selectedModels` 都是可见性字段。 +因此,要更改选择器其余部分的顺序,需要修改代码行为,而不是调整配置。 diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/agents.md b/docs-site/src/content/docs/zh-cn/reference/configuration/agents.md index 12d6a3adf..ef2e059fa 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/agents.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/agents.md @@ -10,7 +10,7 @@ description: 多代理界面、委派引导、首选模型、回退链、原生 | 字段 | 类型 | 默认值 | 含义 | | --- | --- | --- | --- | | `multiAgentMode?` | `"v1" \| "default" \| "v2"` | `"default"` | `v1` 会把目录中的每个模型都标记为 v1;`v2` 会把每个模型都标记为 v2。`default` 会恢复上游固定值(Sol/Terra 为 v2,Luna 为 v1),否则遵循原生 `multi_agent_v2` 标志。适用于新会话。 | -| `subagentModels?` | `string[]` | `gpt-5.5`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`, `gpt-5.4-mini` | 最多五个原生或路由后的 id,会优先显示在子代理选择器中。显式空列表会被保留。 | +| `subagentModels?` | `string[]` | `gpt-5.5`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`, `gpt-5.4-mini` | 最多五个裸原生 id、账户限定的 `/` id 或路由 `provider/model` id 会优先显示在子代理选择器中。Subagents 页面只提供裸原生和路由 id,保存时会省略精确的账户限定选项;如需精确选择,请使用 `ocx agent subagents set` 或直接编辑配置。显式空列表会被保留。 | | `injectionModel?` | `string` | — | 在代理生成的 v2 委派引导中使用的首选原生或路由后的子代理模型。 | | `injectionEffort?` | `string` | — | 首选 effort(`low` 到 `ultra`),只有在 `injectionModel` 存在时才有意义。 | | `injectionPrompt?` | `string` | — | 替换内置引导正文。支持 `{{model}}`、`{{effort}}`、`{{roster}}` 和 `{{fallback}}`。触发条件保持不变。 | diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md index 526326ff8..b400ffbd4 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md @@ -11,7 +11,7 @@ description: 提供者条目、身份验证、端点、模型目录、配额、 | --- | --- | --- | --- | | `providers` | `Record` | — | 提供者名称到提供者配置的映射。 | | `openaiProviderTierVersion?` | `2` | 由迁移设置 | 标记单一、可感知选项的 OpenAI 投影已完成。 | -| `disabledModels?` | `string[]` | — | 从 Codex catalog 和 `/v1/models` 中隐藏、但不阻止直接 proxy 调用的 model。routed id 会从列表中移除。account-qualified native id 只隐藏对应 selector row;bare native GPT id 会隐藏 bare row 以及该 model 的所有 account-selector row。 | +| `disabledModels?` | `string[]` | — | 从 Codex catalog 和 `/v1/models` 中隐藏、但不阻止直接 proxy 调用的 model。routed id 会从列表中移除。account-qualified native id 只隐藏对应 selector row;bare native GPT id 会隐藏 bare row 以及该 model 的所有 account-selector row。Models 页面只显示裸原生行和路由行;若只隐藏一个 selector-qualified 行,请直接设置此配置字段。 | | `providerContextCaps?` | `Record` | `{}` | 按提供者设置、对 Codex 可见的上下文上限。上限只会降低已知的上下文窗口。 | | `contextCapValue?` | `number` | `350000` | 仪表板上下文上限控件使用的值;修改它会更新所有已启用的 `providerContextCaps` 条目。 | | `codexAccounts?` | `CodexAccount[]` | `[]` | 由 Codex Auth 管理的 ChatGPT/Codex 池账户元数据。密钥单独存放在 `codex-accounts.json` 中。 | diff --git a/src/types.ts b/src/types.ts index 14716e04c..5d428138d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -552,8 +552,10 @@ export interface OcxConfig { /** Claude Code inbound + launcher settings. */ claudeCode?: OcxClaudeCodeConfig; /** - * Up to 5 routed model ids ("/") to feature FIRST in the injected Codex catalog. - * Codex's spawn_agent only advertises the first 5 routed models, so this picks which 5 appear. + * Up to 5 Codex-facing catalog ids to feature first. Values may be bare catalog ids, + * exact account-qualified "/" ids, or routed + * "/" ids. With account selectors, one bare native choice can expand + * into a selector-qualified group; Codex still advertises only the first 5 visible rows. */ subagentModels?: string[]; /** diff --git a/structure/03_catalog-and-subagents.md b/structure/03_catalog-and-subagents.md index ed37fd4fa..ea34fa164 100644 --- a/structure/03_catalog-and-subagents.md +++ b/structure/03_catalog-and-subagents.md @@ -130,9 +130,16 @@ the request, and they never raise it. ## Subagents -Codex `spawn_agent` advertises only the highest-priority first five catalog models. `subagentModels` -is capped at five ids and may contain routed `provider/model` slugs or native model slugs. Startup -seeds native GPT defaults only when the field is unset; an explicit empty list persists. +Codex `spawn_agent` advertises only the highest-priority first five picker-visible catalog rows. +Use at most five configured `subagentModels` ids; they may contain bare catalog ids, routed +`provider/model` ids, or exact account-qualified `/` ids. The +dashboard offers bare native and routed choices; exact account-qualified choices are configured +through `ocx agent subagents set` or the opencodex configuration. + +When account selectors are active, one featured bare native id expands into a complete selector row +group. Catalog priorities use the selector count as a stride so each group stays together without +widening Codex's five-row advertisement window. Startup seeds bare native GPT defaults only when +`subagentModels` is unset; an explicit empty list persists. Quota-aware fallback walks a configured chain when the featured model is exhausted, probing availability on a bounded interval (default 60 s, `src/codex/subagent-model-fallback.ts`). It rewrites From 465594305ff6e9e164a446ca4c4ae3866e2e6e5c Mon Sep 17 00:00:00 2001 From: chrisae9 Date: Tue, 4 Aug 2026 01:40:26 -0400 Subject: [PATCH 09/13] fix(codex): avoid ambiguous prompt model ids --- .../content/docs/guides/sub-agent-surface.md | 9 +++--- .../docs/ja/guides/sub-agent-surface.md | 4 +-- .../docs/ja/reference/configuration/agents.md | 2 +- .../docs/ko/guides/sub-agent-surface.md | 4 +-- .../docs/ko/reference/configuration/agents.md | 2 +- .../docs/reference/configuration/agents.md | 2 +- .../docs/ru/guides/sub-agent-surface.md | 9 +++--- .../docs/ru/reference/configuration/agents.md | 2 +- .../docs/zh-cn/guides/sub-agent-surface.md | 4 +-- .../zh-cn/reference/configuration/agents.md | 2 +- src/server/responses/collaboration.ts | 6 +++- src/types.ts | 13 +++++---- tests/multi-agent-compat.test.ts | 29 ++++++++++++++++++- 13 files changed, 62 insertions(+), 26 deletions(-) diff --git a/docs-site/src/content/docs/guides/sub-agent-surface.md b/docs-site/src/content/docs/guides/sub-agent-surface.md index 800430dc8..7d61b2dbd 100644 --- a/docs-site/src/content/docs/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/guides/sub-agent-surface.md @@ -60,15 +60,16 @@ Custom `injectionPrompt` text can use all four placeholders: | Placeholder | Replaced with | | --- | --- | -| `{{model}}` | The effective preferred model for this request. A bare native `injectionModel` is account-qualified when possible; if no preferred model resolves, the configured `injectionModel` is used, or an empty string when it is unset | +| `{{model}}` | The effective preferred model for this request. A bare native `injectionModel` is account-qualified only when the request itself targets an explicit account selector. An unresolved or ambiguous bare value becomes an empty string; an unresolved explicit account-qualified or routed id remains unchanged | | `{{effort}}` | The configured `injectionEffort`, or an empty string | | `{{roster}}` | The resolved picker-visible, surface-compatible roster | | `{{fallback}}` | The configured global fallback guidance | The built-in v2 guidance has a 700-character budget. If it would exceed the budget, opencodex drops -the roster first rather than truncating the core spawn instructions. Guidance fires only when a -preferred model, eligible roster, or fallback chain resolves. A custom prompt does not bypass that -gate. +the roster first rather than truncating the core spawn instructions. Built-in guidance fires only +when a preferred model, eligible roster, or fallback chain resolves. A configured `injectionModel` +is sufficient to render a custom prompt; if a bare value cannot resolve uniquely, `{{model}}` +expands to an empty string. On v1, opencodex injects only the upstream-style proactive delegation guidance at `max` or `ultra` effort. It does not add a preferred model, roster, fallback list, or custom prompt on v1. diff --git a/docs-site/src/content/docs/ja/guides/sub-agent-surface.md b/docs-site/src/content/docs/ja/guides/sub-agent-surface.md index 058286405..611d75f6c 100644 --- a/docs-site/src/content/docs/ja/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/ja/guides/sub-agent-surface.md @@ -49,12 +49,12 @@ v2 ロスターの場合、適格性には 3 つの状態があります。`"v2" |プレースホルダー | | に置き換えられます。 | --- | --- | -| `{{model}}` |このリクエストに対する実効的な優先モデル。セレクターのないネイティブ `injectionModel` は、可能な場合はアカウントセレクターで修飾されます。優先モデルを解決できない場合は設定済みの `injectionModel` が使用され、未設定の場合は空文字列になります | +| `{{model}}` | このリクエストに対する実効的な優先モデル。セレクターなしのネイティブ `injectionModel` は、リクエスト自体が明示的なアカウントセレクターを指定している場合に限り、そのアカウントセレクターで修飾されます。セレクターなしの値が未解決または曖昧な場合は空文字列になり、明示的にアカウント修飾された ID またはルーティング済み ID は未解決でもそのまま保持されます | | `{{effort}}` |設定された `injectionEffort`、または空の文字列 | | `{{roster}}` |解決されたピッカー表示、サーフェス互換のロスター | | `{{fallback}}` |設定されたグローバル フォールバック ガイダンス | -組み込みの v2 ガイダンスの予算は 700 文字です。予算を超える場合、opencodex はコア スポーン命令を切り捨てるのではなく、まずロスターを削除します。ガイダンスは、優先モデル、適格なロスター、またはフォールバック チェーンが解決された場合にのみ起動されます。カスタム プロンプトはそのゲートをバイパスしません。 +組み込みの v2 ガイダンスの予算は 700 文字です。予算を超える場合、opencodex はコア スポーン命令を切り捨てるのではなく、まずロスターを削除します。組み込みガイダンスは、優先モデル、適格なロスター、またはフォールバック チェーンが解決された場合にのみ起動されます。カスタムプロンプトは `injectionModel` が設定されていれば生成され、セレクターなしの値を一意に解決できない場合は `{{model}}` が空文字列になります。 v1 では、opencodex は、`max` または `ultra` の取り組みでアップストリーム スタイルのプロアクティブな委任ガイダンスのみを挿入します。 v1 では、優先モデル、ロスター、フォールバック リスト、カスタム プロンプトは追加されません。 diff --git a/docs-site/src/content/docs/ja/reference/configuration/agents.md b/docs-site/src/content/docs/ja/reference/configuration/agents.md index 7852bdb69..74d90dc9b 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/agents.md +++ b/docs-site/src/content/docs/ja/reference/configuration/agents.md @@ -13,7 +13,7 @@ description: マルチエージェント サーフェス、委任ガイダンス | `subagentModels?` | `string[]` | `gpt-5.5`、`gpt-5.6-sol`、`gpt-5.6-terra`、`gpt-5.6-luna`、`gpt-5.4-mini` |最大 5 つの bare native id、account-qualified `/` id、または routed `provider/model` id をサブエージェント ピッカーで優先表示します。Subagents ページで選べるのは bare native id と routed id だけで、保存時には exact account-qualified の選択が除外されます。exact の選択には `ocx agent subagents set` を使用するか、設定を直接編集してください。明示的な空リストも保持されます。 | | `injectionModel?` | `string` | — |プロキシ作成の v2 委任ガイダンスで使用される、優先されるネイティブまたはルーティングされたサブエージェント モデル。 | | `injectionEffort?` | `string` | — |優先努力 (`low` ~ `ultra`)。`injectionModel` でのみ意味があります。 | -| `injectionPrompt?` | `string` | — |内蔵ガイドボディを交換します。 `{{model}}`、`{{effort}}`、`{{roster}}`、`{{fallback}}`をサポートします。ファイアリングゲートは変更されません。 | +| `injectionPrompt?` | `string` | — | 組み込みの v2 ガイダンス本文を置き換えます。`{{model}}`、`{{effort}}`、`{{roster}}`、`{{fallback}}`をサポートします。`injectionModel` が設定されていればカスタムプロンプトが生成されます。 | | `multiAgentGuidanceEnabled?` | `boolean` | `true` | opencodex が作成した v1/v2 開発者ガイダンスのみを制御します。ネイティブ エージェントのデフォルト、ツール、ルーティング、ロスター、またはエフォート キャップは変更されません。 | | `syncCodexSubagentDefaults?` | `boolean` | `false` |同期/再起動中に、Codex のネイティブ デフォルトとして `injectionModel` およびオプションの `injectionEffort` を書き込むようにオプトインします。 `injectionModel`が必要です。 | | `subagentModelFallback?` | `string[]` | `[]` |生成された子ターンの優先順位付きグローバル フォールバック モデル。 | diff --git a/docs-site/src/content/docs/ko/guides/sub-agent-surface.md b/docs-site/src/content/docs/ko/guides/sub-agent-surface.md index c11216c58..9678ee0ca 100644 --- a/docs-site/src/content/docs/ko/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/ko/guides/sub-agent-surface.md @@ -49,12 +49,12 @@ v2 로스터의 경우 적합성은 세 가지 상태로 나뉩니다. `"v2"`로 | 플레이스홀더 | 대체되는 값 | | --- | --- | -| `{{model}}` | 이 요청에 적용되는 선호 모델. 선택기가 없는 네이티브 `injectionModel`에는 가능한 경우 계정 선택기가 붙습니다. 선호 모델을 결정할 수 없으면 설정된 `injectionModel`을 사용하고, 설정되지 않았으면 빈 문자열을 사용합니다 | +| `{{model}}` | 이 요청에 적용되는 선호 모델. 선택자가 없는 네이티브 `injectionModel`은 요청 자체가 명시적 계정 선택자를 대상으로 할 때만 해당 계정으로 한정됩니다. 선택자가 없는 값을 해석할 수 없거나 결과가 모호하면 빈 문자열이 되며, 명시적으로 계정이 한정되었거나 라우팅된 ID는 해석되지 않아도 원래 값을 유지합니다 | | `{{effort}}` | 설정된 `injectionEffort`, 또는 빈 문자열 | | `{{roster}}` | 해석된 선택기 표시 가능, 서피스 호환 로스터 | | `{{fallback}}` | 설정된 전역 폴백 가이드 | -내장 v2 가이드는 700자 예산을 가집니다. 이 한도를 넘기면 opencodex는 핵심 스폰 지시를 자르는 대신 로스터를 먼저 제거합니다. 가이드는 선호 모델, 적합한 로스터, 폴백 체인이 해석될 때만 발화합니다. 사용자 정의 프롬프트라고 해서 이 조건을 우회할 수는 없습니다. +내장 v2 가이드는 700자 예산을 가집니다. 이 한도를 넘기면 opencodex는 핵심 스폰 지시를 자르는 대신 로스터를 먼저 제거합니다. 내장 가이드는 선호 모델, 적합한 로스터 또는 폴백 체인이 해석될 때만 발화합니다. 사용자 정의 프롬프트는 `injectionModel`만 설정되어 있어도 발화하며, 선택자가 없는 값을 하나로 해석할 수 없으면 `{{model}}`은 빈 문자열로 치환됩니다. v1에서는 opencodex가 `max` 또는 `ultra` 추론 강도에서만 업스트림 스타일의 능동 위임 가이드만 주입합니다. v1에는 선호 모델, 로스터, 폴백 목록, 사용자 정의 프롬프트를 추가하지 않습니다. diff --git a/docs-site/src/content/docs/ko/reference/configuration/agents.md b/docs-site/src/content/docs/ko/reference/configuration/agents.md index 706b9768a..579386afe 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/agents.md +++ b/docs-site/src/content/docs/ko/reference/configuration/agents.md @@ -13,7 +13,7 @@ description: 멀티 에이전트 표면, 위임 안내, 선호 모델, 대체 | `subagentModels?` | `string[]` | `gpt-5.5`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`, `gpt-5.4-mini` | 최대 다섯 개의 bare native id, account-qualified `/` id 또는 routed `provider/model` id를 서브에이전트 선택기에서 우선 표시합니다. Subagents 페이지는 bare native와 routed id만 제공하며 저장할 때 exact account-qualified 선택을 제외합니다. exact 선택은 `ocx agent subagents set`을 사용하거나 설정을 직접 편집하세요. 명시적인 빈 목록도 그대로 보존됩니다. | | `injectionModel?` | `string` | — | 프록시가 작성한 v2 위임 안내에서 사용하는 선호 네이티브 또는 라우팅된 서브에이전트 모델입니다. | | `injectionEffort?` | `string` | — | 선호 노력(`low`부터 `ultra`까지)입니다. `injectionModel`이 있을 때만 의미가 있습니다. | -| `injectionPrompt?` | `string` | — | 내장 안내 본문을 대체합니다. `{{model}}`, `{{effort}}`, `{{roster}}`, `{{fallback}}`를 지원합니다. 발동 조건은 그대로입니다. | +| `injectionPrompt?` | `string` | — | 내장 v2 안내 본문을 대체합니다. `{{model}}`, `{{effort}}`, `{{roster}}`, `{{fallback}}`를 지원합니다. `injectionModel`만 설정되어 있어도 사용자 정의 프롬프트가 발동합니다. | | `multiAgentGuidanceEnabled?` | `boolean` | `true` | opencodex가 작성하는 v1/v2 개발자 안내만 제어합니다. 네이티브 에이전트 기본값, 도구, 라우팅, 로스터, 노력 상한은 바꾸지 않습니다. | | `syncCodexSubagentDefaults?` | `boolean` | `false` | 동기화 또는 재시작 시 `injectionModel`과 선택적 `injectionEffort`를 Codex의 네이티브 기본값으로 기록하도록 선택합니다. `injectionModel`이 필요합니다. | | `subagentModelFallback?` | `string[]` | `[]` | 생성된 하위 턴에 적용되는 전역 대체 모델 우선순위 목록입니다. | diff --git a/docs-site/src/content/docs/reference/configuration/agents.md b/docs-site/src/content/docs/reference/configuration/agents.md index 7cc30e40d..948c9350a 100644 --- a/docs-site/src/content/docs/reference/configuration/agents.md +++ b/docs-site/src/content/docs/reference/configuration/agents.md @@ -14,7 +14,7 @@ routes, and limits delegated work. | `subagentModels?` | `string[]` | `gpt-5.5`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`, `gpt-5.4-mini` | Up to five bare native, account-qualified `/`, or routed `provider/model` ids featured first in the sub-agent picker. The dashboard offers only bare native and routed ids and omits exact account-qualified choices when it saves; use `ocx agent subagents set` or edit the configuration for exact choices. An explicit empty list is preserved. | | `injectionModel?` | `string` | — | Preferred native or routed sub-agent model used in proxy-authored v2 delegation guidance. | | `injectionEffort?` | `string` | — | Preferred effort (`low` through `ultra`), meaningful only with `injectionModel`. | -| `injectionPrompt?` | `string` | — | Replaces the built-in guidance body. Supports `{{model}}`, `{{effort}}`, `{{roster}}`, and `{{fallback}}`. Firing gates remain unchanged. | +| `injectionPrompt?` | `string` | — | Replaces the built-in v2 guidance body. Supports `{{model}}`, `{{effort}}`, `{{roster}}`, and `{{fallback}}`. A configured `injectionModel` is sufficient to render the custom prompt. | | `multiAgentGuidanceEnabled?` | `boolean` | `true` | Controls only opencodex-authored v1/v2 developer guidance; it does not change native agent defaults, tools, routing, rosters, or effort caps. | | `syncCodexSubagentDefaults?` | `boolean` | `false` | Opt into writing `injectionModel` and optional `injectionEffort` as Codex's native defaults during sync/restart. Requires `injectionModel`. | | `subagentModelFallback?` | `string[]` | `[]` | Priority-ordered global fallback models for spawned child turns. | diff --git a/docs-site/src/content/docs/ru/guides/sub-agent-surface.md b/docs-site/src/content/docs/ru/guides/sub-agent-surface.md index ab874005c..74cc0041f 100644 --- a/docs-site/src/content/docs/ru/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/ru/guides/sub-agent-surface.md @@ -67,15 +67,16 @@ guidance-сообщений, которые opencodex пишет сам, на о | Плейсхолдер | Подставляемое значение | | --- | --- | -| `{{model}}` | Эффективная предпочитаемая модель для текущего запроса. К нативной модели без селектора, указанной в `injectionModel`, по возможности добавляется селектор аккаунта; если предпочитаемую модель определить не удалось, используется настроенное значение `injectionModel`, а если оно не задано — пустая строка | +| `{{model}}` | Эффективная предпочитаемая модель для текущего запроса. Нативный `injectionModel` без селектора получает квалификатор аккаунта только тогда, когда сам запрос явно указывает селектор аккаунта. Неразрешённое или неоднозначное значение без селектора заменяется пустой строкой; неразрешённый явно квалифицированный по аккаунту или маршрутизируемый ID остаётся без изменений | | `{{effort}}` | Настроенный `injectionEffort` либо пустая строка | | `{{roster}}` | Разрешённый ростер, видимый в picker и совместимый с поверхностью | | `{{fallback}}` | Настроенное глобальное fallback guidance | Встроенное guidance для v2 ограничено 700 символами. Если оно не укладывается, opencodex сначала -убирает ростер, а не обрезает ядро инструкций для spawn. Guidance включается только тогда, когда -разрешается предпочитаемая модель, допустимый ростер или fallback chain. Пользовательский prompt -не обходит это условие. +убирает ростер, а не обрезает ядро инструкций для spawn. Встроенное guidance включается только +тогда, когда разрешается предпочитаемая модель, допустимый ростер или fallback chain. Настроенного +`injectionModel` достаточно, чтобы отобразить пользовательский prompt; если значение без селектора +нельзя разрешить однозначно, `{{model}}` заменяется пустой строкой. На v1 opencodex внедряет только upstream-style proactive guidance о делегировании на уровнях effort `max` или `ultra`. Предпочитаемую модель, ростер, fallback list и custom prompt на v1 он diff --git a/docs-site/src/content/docs/ru/reference/configuration/agents.md b/docs-site/src/content/docs/ru/reference/configuration/agents.md index acff1b653..062750e2a 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/agents.md +++ b/docs-site/src/content/docs/ru/reference/configuration/agents.md @@ -14,7 +14,7 @@ description: Multi-agent surface, guidance при делегировании, pr | `subagentModels?` | `string[]` | `gpt-5.5`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`, `gpt-5.4-mini` | До пяти bare native-id, account-qualified id `/` или routed-id `provider/model`, которые показываются первыми в picker'е подагентов. Страница Subagents предлагает только bare native- и routed-id и при сохранении исключает точные account-qualified варианты; для точного выбора используйте `ocx agent subagents set` или отредактируйте конфигурацию. Явный пустой список сохраняется. | | `injectionModel?` | `string` | — | Предпочитаемая native- или routed-модель подагента, которую proxy использует в собственном guidance v2. | | `injectionEffort?` | `string` | — | Предпочитаемый effort (`low`–`ultra`), имеющий смысл только вместе с `injectionModel`. | -| `injectionPrompt?` | `string` | — | Заменяет встроенное тело guidance. Поддерживает `{{model}}`, `{{effort}}`, `{{roster}}` и `{{fallback}}`. Условия срабатывания при этом не меняются. | +| `injectionPrompt?` | `string` | — | Заменяет встроенное тело guidance для v2. Поддерживает `{{model}}`, `{{effort}}`, `{{roster}}` и `{{fallback}}`. Настроенного `injectionModel` достаточно, чтобы отобразить пользовательский prompt. | | `multiAgentGuidanceEnabled?` | `boolean` | `true` | Управляет только developer-guidance, написанным самим opencodex, для v1/v2; не меняет native default'ы агентов, tools, routing, roster'ы и effort cap'ы. | | `syncCodexSubagentDefaults?` | `boolean` | `false` | Разрешает записывать `injectionModel` и, при наличии, `injectionEffort` как native default'ы Codex при sync/restart. Требует `injectionModel`. | | `subagentModelFallback?` | `string[]` | `[]` | Глобальные fallback-модели для порождённых child-turn'ов в порядке приоритета. | diff --git a/docs-site/src/content/docs/zh-cn/guides/sub-agent-surface.md b/docs-site/src/content/docs/zh-cn/guides/sub-agent-surface.md index 2d2e861f0..bec2d70da 100644 --- a/docs-site/src/content/docs/zh-cn/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/zh-cn/guides/sub-agent-surface.md @@ -49,12 +49,12 @@ Dashboard 上的 **Sub-agent delegation** 控件管理三个相关设置: | 占位符 | 替换为 | | --- | --- | -| `{{model}}` | 当前请求中生效的首选模型。未带选择器的原生 `injectionModel` 会在可能时加上账户选择器;如果无法解析出首选模型,则使用已配置的 `injectionModel`,未配置时为空字符串 | +| `{{model}}` | 当前请求中生效的首选模型。未带选择器的原生 `injectionModel` 只有在请求本身明确指定账户选择器时,才会加上该账户限定。无法解析或存在歧义的未限定值会替换为空字符串;显式限定到账户或提供商路由的 ID 即使无法解析也保持原值 | | `{{effort}}` | 配置的 `injectionEffort`,或空字符串 | | `{{roster}}` | 解析后的、对 picker 可见且与界面兼容的 roster | | `{{fallback}}` | 配置的全局 fallback 指引 | -内置的 v2 指引有 700 字符预算。如果会超出预算,opencodex 会优先删除 roster,而不是截断核心 spawn 指令。只有当首选模型、可用 roster 或 fallback chain 解析成功时,指引才会触发。自定义提示词不会绕过这个门槛。 +内置的 v2 指引有 700 字符预算。如果会超出预算,opencodex 会优先删除 roster,而不是截断核心 spawn 指令。内置指引仅在首选模型、可用 roster 或 fallback chain 解析成功时触发。只要配置了 `injectionModel`,自定义提示词就会触发;如果未限定的值无法唯一解析,`{{model}}` 会替换为空字符串。 在 v1 上,opencodex 只会在 `max` 或 `ultra` effort 下注入上游风格的主动委派指引。它不会在 v1 上额外添加首选模型、roster、fallback list 或自定义提示词。 diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/agents.md b/docs-site/src/content/docs/zh-cn/reference/configuration/agents.md index ef2e059fa..473c15d59 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/agents.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/agents.md @@ -13,7 +13,7 @@ description: 多代理界面、委派引导、首选模型、回退链、原生 | `subagentModels?` | `string[]` | `gpt-5.5`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`, `gpt-5.4-mini` | 最多五个裸原生 id、账户限定的 `/` id 或路由 `provider/model` id 会优先显示在子代理选择器中。Subagents 页面只提供裸原生和路由 id,保存时会省略精确的账户限定选项;如需精确选择,请使用 `ocx agent subagents set` 或直接编辑配置。显式空列表会被保留。 | | `injectionModel?` | `string` | — | 在代理生成的 v2 委派引导中使用的首选原生或路由后的子代理模型。 | | `injectionEffort?` | `string` | — | 首选 effort(`low` 到 `ultra`),只有在 `injectionModel` 存在时才有意义。 | -| `injectionPrompt?` | `string` | — | 替换内置引导正文。支持 `{{model}}`、`{{effort}}`、`{{roster}}` 和 `{{fallback}}`。触发条件保持不变。 | +| `injectionPrompt?` | `string` | — | 替换内置 v2 指引正文。支持 `{{model}}`、`{{effort}}`、`{{roster}}` 和 `{{fallback}}`。只要配置了 `injectionModel`,自定义提示词就会触发。 | | `multiAgentGuidanceEnabled?` | `boolean` | `true` | 只控制 opencodex 生成的 v1/v2 开发者引导;不会改变原生代理默认值、工具、路由、名单或 effort 上限。 | | `syncCodexSubagentDefaults?` | `boolean` | `false` | 允许在同步或重启时,将 `injectionModel` 以及可选的 `injectionEffort` 写入为 Codex 的原生默认值。需要 `injectionModel`。 | | `subagentModelFallback?` | `string[]` | `[]` | 按优先级排序的全局回退模型,用于派生的子轮次。 | diff --git a/src/server/responses/collaboration.ts b/src/server/responses/collaboration.ts index f0b5da48e..459da3973 100644 --- a/src/server/responses/collaboration.ts +++ b/src/server/responses/collaboration.ts @@ -311,7 +311,11 @@ export async function multiAgentGuidanceText( const fallbackGuidance = subagentFallbackGuidanceText({ subagentModelFallback } as OcxConfig); if (!injectionModel && roster === "" && fallbackGuidance === "") return null; if (injectionPrompt) { - return `${applyInjectionPlaceholders(injectionPrompt, preferred?.model ?? injectionModel, injectionEffort, roster, fallbackGuidance)}`; + // Bare ids must resolve to a unique/current-route candidate. Preserve the legacy raw + // fallback only for explicit routed/account-qualified ids. + const promptModel = preferred?.model + ?? (injectionModel?.includes("/") ? injectionModel : undefined); + return `${applyInjectionPlaceholders(injectionPrompt, promptModel, injectionEffort, roster, fallbackGuidance)}`; } if (!preferred && roster === "" && fallbackGuidance === "") return null; let text = "When the active spawn_agent tool supports optional \"model\" or \"reasoning_effort\" overrides, " diff --git a/src/types.ts b/src/types.ts index 5d428138d..ae84aa674 100644 --- a/src/types.ts +++ b/src/types.ts @@ -618,11 +618,14 @@ export interface OcxConfig { */ streamMode?: "auto" | "legacy-tee" | "eager-relay"; /** - * Custom override for the injected multi-agent guidance body (the text inside the - * tags). When set, it replaces the built-in prompt on whichever - * collab surface would have fired; firing gates are unchanged. Placeholders: - * `{{model}}` -> the effective preferred model for the request (a bare native - * model is account-qualified when possible; otherwise injectionModel or ""), + * Custom override for the injected v2 multi-agent guidance body (the text inside + * the tags). After guidance is enabled and the v2 surface and + * catalog-state gates pass, a configured injectionModel is sufficient to render it; + * otherwise an eligible roster or fallback is required. Placeholders: `{{model}}` -> the + * effective preferred model for the request (a bare native model is account-qualified + * only when the request targets an explicit account selector; unresolved or ambiguous + * bare values become "", while unresolved explicit routed or account-qualified values + * remain unchanged), * `{{effort}}` -> injectionEffort, `{{roster}}` -> the resolved sub-agent roster * block ("" when nothing resolves), `{{fallback}}` -> the configured subagent * model fallback guidance block ("" when unset). diff --git a/tests/multi-agent-compat.test.ts b/tests/multi-agent-compat.test.ts index 952261dcf..8e9740cb1 100644 --- a/tests/multi-agent-compat.test.ts +++ b/tests/multi-agent-compat.test.ts @@ -267,6 +267,16 @@ describe("multiAgentGuidanceText", () => { ); expect(exactBare).toContain('Preferred sub-agent: model "local-fast"'); + const exactBareCustom = await multiAgentGuidanceText( + parsedFixture({ tools: [{ name: "spawn_agent" }] }), + { + injectionModel: "local-fast", + codexAccountNamespace: "team", + injectionPrompt: "Use {{model}}.", + }, + ); + expect(exactBareCustom).toBe("Use local-fast."); + const bareParent = await multiAgentGuidanceText( parsedFixture({ tools: [{ name: "spawn_agent" }] }), { subagentModels: ["gpt-5.6-sol"] }, @@ -303,6 +313,16 @@ describe("multiAgentGuidanceText", () => { { injectionModel: "gpt-5.6-sol" }, ); expect(ambiguous).toBeNull(); + + const ambiguousCustom = await multiAgentGuidanceText( + parsedFixture({ tools: [{ name: "spawn_agent" }] }), + { + injectionModel: "gpt-5.6-sol", + injectionPrompt: "Use {{model}}.", + }, + ); + expect(ambiguousCustom).toBe("Use ."); + expect(ambiguousCustom).not.toContain("gpt-5.6-sol"); }); test("account projection never widens the five-model spawn candidate window", () => { @@ -354,6 +374,13 @@ describe("multiAgentGuidanceText", () => { parsedFixture({ tools: [{ name: "spawn_agent" }] }), { injectionModel: "gpt-5.6-sol" }, )).toBeNull(); + expect(await multiAgentGuidanceText( + parsedFixture({ tools: [{ name: "spawn_agent" }] }), + { + injectionModel: "gpt-5.6-sol", + injectionPrompt: "Use {{model}}.", + }, + )).toBe("Use ."); }); test("effective roster applies alias, visibility, v2 compatibility, stable priority, cap, and diagnostics", async () => { @@ -561,7 +588,7 @@ describe("multiAgentGuidanceText", () => { expect(text).toContain('Preferred sub-agent: model "opencode-go/glm-5.2", reasoning_effort "xhigh"'); }); - test("injectionPrompt preserves raw model and substitutes only the effective roster", async () => { + test("injectionPrompt preserves an unresolved explicit model and substitutes only the effective roster", async () => { const dir = codexHomeFixture(V2_ON); catalogFixture(dir, [ { slug: "gpt-5.6-terra", efforts: ["high", "max"], priority: 0, multiAgentVersion: "v2" }, From 9d1ad9d89be55fd181ac3f19c9b35e394521bcee Mon Sep 17 00:00:00 2001 From: chrisae9 Date: Tue, 4 Aug 2026 04:22:57 -0400 Subject: [PATCH 10/13] test(codex): stabilize catalog integration checks --- tests/codex-catalog-restore.test.ts | 5 +++-- tests/codex-catalog-sync-hardening.test.ts | 15 +++++++-------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/tests/codex-catalog-restore.test.ts b/tests/codex-catalog-restore.test.ts index 4c726dd88..f689d80eb 100644 --- a/tests/codex-catalog-restore.test.ts +++ b/tests/codex-catalog-restore.test.ts @@ -102,9 +102,10 @@ describe("Codex catalog restore", () => { `); expect(r.status).toBe(0); + const resolvedCatalogPath = join(realpathSync.native(codexHome), "catalog.json"); expect(JSON.parse(r.stdout)).toEqual({ - first: { removed: 4, kept: 4, path: catalogPath }, - second: { removed: 0, kept: 4, path: catalogPath }, + first: { removed: 4, kept: 4, path: resolvedCatalogPath }, + second: { removed: 0, kept: 4, path: resolvedCatalogPath }, }); const restored = JSON.parse(readFileSync(catalogPath, "utf8")).models as Array>; expect(restored.find(model => model.slug === "gpt-5.5")).toMatchObject({ diff --git a/tests/codex-catalog-sync-hardening.test.ts b/tests/codex-catalog-sync-hardening.test.ts index 6f261eb8a..7dfa2ace4 100644 --- a/tests/codex-catalog-sync-hardening.test.ts +++ b/tests/codex-catalog-sync-hardening.test.ts @@ -165,6 +165,7 @@ describe("Codex catalog sync hardening", () => { test("account rows reconcile idempotently and independently from provider outages", () => { const catalogPath = join(codexHome, "catalog.json"); + const firstCatalogPath = join(opencodexHome, "first-catalog.json"); writeFileSync(join(codexHome, "config.toml"), 'model_catalog_json = "catalog.json"\n', "utf8"); const accountMarker = "account-selector-v1"; writeFileSync(catalogPath, JSON.stringify({ @@ -202,9 +203,10 @@ describe("Codex catalog sync hardening", () => { }, null, 2) + "\n"); const r = runScript(codexHome, opencodexHome, ` - const { readFileSync } = require("node:fs"); + const { copyFileSync } = require("node:fs"); const { syncCatalogModels } = require("./src/codex/catalog"); const catalogPath = ${JSON.stringify(catalogPath)}; + const firstCatalogPath = ${JSON.stringify(firstCatalogPath)}; const config = { providers: { openai: { @@ -225,12 +227,9 @@ describe("Codex catalog sync hardening", () => { removed: "missing-account" } }; - syncCatalogModels(config) - .then(() => { - const firstRows = JSON.parse(readFileSync(catalogPath, "utf8")).models; - return syncCatalogModels(config).then(res => ({ firstRows, res })); - }) - .then(output => console.log(JSON.stringify(output))); + await syncCatalogModels(config); + copyFileSync(catalogPath, firstCatalogPath); + await syncCatalogModels(config); `); expect(r.status).toBe(0); expect(r.stderr).toContain("routed model fetch returned empty; preserving 2 existing routed entries"); @@ -250,7 +249,7 @@ describe("Codex catalog sync hardening", () => { max_context_window?: number; auto_compact_token_limit?: number; }>; - const firstRows = (JSON.parse(r.stdout) as { firstRows: typeof rows }).firstRows; + const firstRows = JSON.parse(readFileSync(firstCatalogPath, "utf8")).models as typeof rows; expect(rows).toEqual(firstRows); const firstBare = firstRows.find(row => row.slug === "gpt-5.5"); const firstTeam = firstRows.find(row => row.slug === "team/gpt-5.5"); From 79df0080a4cc692961bffa592967647dfaed9c17 Mon Sep 17 00:00:00 2001 From: chrisae9 Date: Tue, 4 Aug 2026 04:33:15 -0400 Subject: [PATCH 11/13] fix(codex): preserve case-distinct account fallbacks --- src/codex/subagent-model-fallback.ts | 11 +++++++++- tests/subagent-model-fallback.test.ts | 30 +++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/codex/subagent-model-fallback.ts b/src/codex/subagent-model-fallback.ts index dd06564ac..383412308 100644 --- a/src/codex/subagent-model-fallback.ts +++ b/src/codex/subagent-model-fallback.ts @@ -92,13 +92,22 @@ function pollIntervalMs(config: OcxConfig): number { return configured; } +function fallbackChainKey(model: string, config: OcxConfig): string { + const selector = codexAccountNamespaceForModel(config.codexAccountNamespaces, model); + if (!selector) return JSON.stringify(["model", model.toLowerCase()]); + const slash = model.indexOf("/"); + // Selector keys are exact-case account boundaries. Keep that segment distinct while + // retaining legacy case-insensitive de-duplication for the native model suffix. + return JSON.stringify(["account", selector, model.slice(slash + 1).toLowerCase()]); +} + function normalizedChain(primary: string, config: OcxConfig, extra: readonly string[] = []): string[] { const chain: string[] = []; const seen = new Set(); const push = (model: string | undefined) => { if (!model || model.trim() === "") return; const trimmed = model.trim(); - const key = trimmed.toLowerCase(); + const key = fallbackChainKey(trimmed, config); if (seen.has(key)) return; seen.add(key); chain.push(trimmed); diff --git a/tests/subagent-model-fallback.test.ts b/tests/subagent-model-fallback.test.ts index 76599e2ad..1904c7c22 100644 --- a/tests/subagent-model-fallback.test.ts +++ b/tests/subagent-model-fallback.test.ts @@ -128,6 +128,21 @@ describe("subagent model fallback chain", () => { "gpt-5.6-sol", "alibaba-token-plan/qwen3.8-max", ]); + expect(buildSubagentModelChain("work/gpt-5.5", cfg({ + codexAccountNamespaces: { work: "account-a", Work: "account-b" }, + subagentModelFallback: ["Work/gpt-5.5"], + }))).toEqual(["work/gpt-5.5", "Work/gpt-5.5"]); + expect(buildSubagentModelChain("work/gpt-5.5", cfg({ + codexAccountNamespaces: { work: "account-a" }, + subagentModelFallback: ["Work/gpt-5.5"], + }))).toEqual(["work/gpt-5.5", "Work/gpt-5.5"]); + expect(buildSubagentModelChain("work/gpt-5.5", cfg({ + codexAccountNamespaces: { work: "account-a" }, + subagentModelFallback: ["work/GPT-5.5"], + }))).toEqual(["work/gpt-5.5"]); + expect(buildSubagentModelChain("kimi/k3", cfg({ + subagentModelFallback: ["KIMI/K3"], + }))).toEqual(["kimi/k3"]); }); test("selectAvailableSubagentModel skips quota-exhausted native models", () => { @@ -198,6 +213,21 @@ describe("subagent model fallback chain", () => { }); }); + test("case-distinct account selector fallbacks remain independent", () => { + updateAccountQuota("pool-a", 95, undefined, 20); + const config = cfg({ + codexAccountNamespaces: { work: "account-a", Work: "account-b" }, + pausedCodexAccountIds: ["account-a"], + subagentModelFallback: ["work/gpt-5.5", "Work/gpt-5.5", "kimi/k3"], + }); + + expect(selectAvailableSubagentModel("gpt-5.6-sol", config, [], "pool-a")).toEqual({ + model: "Work/gpt-5.5", + rewritten: true, + skipped: ["gpt-5.6-sol", "work/gpt-5.5"], + }); + }); + test("account selector fallback skips a model-scoped cooldown on its fixed account", () => { const now = 1_800_000_000_000; updateAccountQuota("pool-a", 95, undefined, 20); From acb5ad84fda7034d268d1ce2ba8afcb8b1ccc083 Mon Sep 17 00:00:00 2001 From: chrisae9 Date: Tue, 4 Aug 2026 05:27:23 -0400 Subject: [PATCH 12/13] docs(codex): document account selector precedence --- .../src/content/docs/guides/model-routing.md | 59 +++++++++++------ .../content/docs/ja/guides/model-routing.md | 59 +++++++++++------ .../content/docs/ko/guides/model-routing.md | 56 ++++++++++++----- .../content/docs/ru/guides/model-routing.md | 63 +++++++++++++------ .../docs/zh-cn/guides/model-routing.md | 55 +++++++++++----- 5 files changed, 203 insertions(+), 89 deletions(-) diff --git a/docs-site/src/content/docs/guides/model-routing.md b/docs-site/src/content/docs/guides/model-routing.md index 8b37525af..c1616e9e5 100644 --- a/docs-site/src/content/docs/guides/model-routing.md +++ b/docs-site/src/content/docs/guides/model-routing.md @@ -6,14 +6,31 @@ description: How opencodex decides which provider serves a given model id. When Codex asks for a model, `router.ts` resolves it to exactly one configured provider. The rules are checked **in order**; the first match wins. -For OpenAI, bare `gpt-*` ids select one `openai` provider. Its `codexAccountMode` chooses -Pool(default, main plus added accounts) or Direct(current caller/main bearer) without changing the -model id. `openai-apikey/` explicitly selects API-key transport. The two credential routes -do not fall through to one another. +For OpenAI, a configured `/gpt-*` id maps through `codexAccountNamespaces` to exactly one +stored Codex account before combo or provider namespaces are considered. Bare `gpt-*` ids select +the canonical `openai` provider instead. Its `codexAccountMode` chooses Pool (default, main plus +added accounts) or Direct (current caller/main bearer) without changing the model id. +`openai-apikey/` explicitly selects API-key transport. These credential routes do not fall +through to one another. ## Precedence -1. **Explicit `provider/model`** — if the id contains `/` and the part before it is the name of a +1. **Exact Codex account selector** — if the id is + `/` and the selector is configured in `codexAccountNamespaces`, + the request uses only the mapped stored account and sends the bare native model upstream. + Unavailable exact targets fail closed instead of continuing through Pool, Direct, or provider + routing. + + ```text + side/gpt-5.6-sol → provider "openai", model "gpt-5.6-sol", account selector "side" + ``` + +2. **Combo id or alias** — while at least one combo is configured, a canonical `combo/` or + configured combo alias selects its concrete target before provider namespaces are checked. With + no configured combos, a legacy physical provider literally named `combo` remains a normal + provider namespace. See [Combos](/guides/combos/) for target selection and failover behavior. + +3. **Explicit `provider/model`** — if the id contains `/` and the part before it is the name of a configured provider, that provider is used and the id is stripped to the part after the slash. ```text @@ -22,29 +39,32 @@ do not fall through to one another. openrouter/openai/gpt-5.6-sol → provider "openrouter", model "openai/gpt-5.6-sol" ``` - This is the unambiguous form, and the one Codex's model picker uses for routed models. - If the named provider is disabled, this explicit form throws instead of routing. + This is the explicit routed-provider form, and the one Codex's model picker uses for routed + models. If the same public id is a configured combo alias, rule 2 wins. If the named provider is + disabled, this explicit form throws instead of routing. + +4. **Bare native OpenAI-family id** — an id such as `gpt-*`, `o1-*`, `o3-*`, or `o4-*` uses the + canonical enabled `openai` provider and its configured Pool or Direct account mode. -2. **A provider's `defaultModel`** — if any provider's `defaultModel` equals the id, that provider +5. **A provider's `defaultModel`** — if any provider's `defaultModel` equals the id, that provider is used (id passed through unchanged). -3. **Built-in prefix patterns** — the id is matched against known model-family prefixes, then routed +6. **Built-in prefix patterns** — the id is matched against known model-family prefixes, then routed to a configured provider of that name (or name-prefix): | Prefixes | Provider | | --- | --- | | `claude-`, `claude-sonnet-`, `claude-opus-`, `claude-haiku-` | `anthropic` | - | `gpt-`, `o1-`, `o3-`, `o4-` | bare ids use the configured `openai` account mode; use `openai-apikey/` for API-key transport | | `llama-`, `mixtral-`, `gemma-` | `groq` | This matcher is name-based and, unlike the `defaultModel` / `models[]` scans, currently does not filter a matching provider whose `disabled` flag is true. -4. **A provider's `models[]`** — if no prefix rule won and an active provider lists the id in its - `models[]`, that provider is used. This order matters: with an OpenAI-named provider configured, - a bare `gpt-*` id reaches it before another provider's `models[]` claim. +7. **A provider's `models[]`** — if no prefix rule won and an active provider lists the id in its + `models[]`, that provider is used. Rule 4 already sends a bare `gpt-*` id to the canonical enabled + `openai` provider before another provider's `models[]` claim can match. -5. **Default provider** — if nothing matched, the id is sent to `config.defaultProvider` unchanged. +8. **Default provider** — if nothing matched, the id is sent to `config.defaultProvider` unchanged. (If no default provider is configured, or it is disabled, routing throws.) ## API keys and environment variables @@ -81,11 +101,14 @@ Routing and catalog visibility are separate controls: ## Tips -- **Be explicit for routed models.** Prefer `provider/model` (rule 1) — it's unambiguous and - matches what Codex shows in its picker after a catalog sync. -- **Seed `models[]` or `defaultModel`** on a provider so short ids (rule 2/4) resolve without the +- **Target a Codex account explicitly** with `/` (rule 1). That route + is exact and fails closed; it never silently switches to another account. +- **Be explicit for routed models.** Prefer `provider/model` (rule 3) when that exact public id is + not a combo alias. It directly names the provider and matches what Codex shows in its picker after + a catalog sync. +- **Seed `models[]` or `defaultModel`** on a provider so short ids (rules 5/7) resolve without the `provider/` prefix. - **Prefix patterns are a convenience**, not a guarantee: they only resolve if a provider with that - name (e.g. `anthropic`, `openai`, `groq`) is actually configured. + name (e.g. `anthropic` or `groq`) is actually configured. See [Configuration](/reference/configuration/) for the provider fields these rules read. diff --git a/docs-site/src/content/docs/ja/guides/model-routing.md b/docs-site/src/content/docs/ja/guides/model-routing.md index 038c0722b..1268afc17 100644 --- a/docs-site/src/content/docs/ja/guides/model-routing.md +++ b/docs-site/src/content/docs/ja/guides/model-routing.md @@ -6,13 +6,31 @@ description: opencodex が与えられたモデル ID をどのプロバイダ Codex がモデルを要求すると `router.ts` がこれを正確に一つの設定されたプロバイダーに解釈します。ルールは **順番に**検査され、最初に一致したものが適用されます。 -OpenAI の bare `gpt-*` は単一の `openai` プロバイダーを選択します。`codexAccountMode` が -Pool(デフォルト、メイン + 追加アカウント)または Direct(現在の caller/メイン bearer)を決め、モデル ID はそのままです。 -`openai-apikey/` は API キートランスポートを明示的に選択し、2 つの認証情報経路はフォールバックしません。 +OpenAI では、設定済みの `/gpt-*` ID は、combo や provider の namespace より先に +`codexAccountNamespaces` を通じて 1 つの保存済み Codex アカウントに対応付けられます。bare +`gpt-*` ID は代わりに canonical `openai` provider を選択します。その `codexAccountMode` が +Pool(デフォルト、メイン + 追加アカウント)または Direct(現在の caller/メイン bearer)を決め、 +model ID は変更しません。`openai-apikey/` は API key transport を明示的に選択します。 +これらの credential route は相互にフォールバックしません。 ## 優先順位 -1. **明示的 `provider/model`** — ID に `/` が含まれ、その前部が設定されたプロバイダー名なら、 +1. **Codex account の exact selector** — ID が `/` で、その + selector が `codexAccountNamespaces` に設定されている場合、request は対応する保存済み account + だけを使用し、bare native model を upstream に送ります。exact target が利用不能なら、Pool、 + Direct、provider routing の次の規則へ進まず fail closed します。 + + ```text + side/gpt-5.6-sol → provider "openai", model "gpt-5.6-sol", account selector "side" + ``` + +2. **Combo ID または alias** — 1 つ以上の combo が設定されている間は、canonical `combo/` + または設定済み combo alias が provider namespace より先に concrete target を選択します。 + combo が 1 つも設定されていない場合、文字どおり `combo` という名前の legacy physical provider + は通常の provider namespace として残ります。target selection と failover の動作は + [Combos](/ja/guides/combos/)を参照してください。 + +3. **明示的 `provider/model`** — ID に `/` が含まれ、その前部が設定されたプロバイダー名なら、 該当プロバイダーが使われ、ID はスラッシュの後部に切り詰められます。 ```text @@ -21,30 +39,32 @@ Pool(デフォルト、メイン + 追加アカウント)または Direct(現在 openrouter/openai/gpt-5.6-sol → provider "openrouter", model "openai/gpt-5.6-sol" ``` - これは明確な形式で、Codex のモデルピッカーがルーティングモデルに使う形式です。指定したプロバイダーが - 無効の場合はルーティングせずエラーになります。 + これは routed provider を明示する形式で、Codex のモデルピッカーがルーティングモデルに使う + 形式です。同じ public ID が設定済み combo alias でもある場合はルール 2 が優先されます。指定した + provider が無効の場合はルーティングせずエラーになります。 + +4. **Bare native OpenAI-family ID** — `gpt-*`、`o1-*`、`o3-*`、`o4-*` などの ID は、canonical + かつ有効な `openai` provider と、その Pool または Direct account mode を使用します。 -2. **プロバイダーの `defaultModel`** — いずれかのプロバイダーの `defaultModel` が ID と一致すればそのプロバイダーが +5. **プロバイダーの `defaultModel`** — いずれかのプロバイダーの `defaultModel` が ID と一致すればそのプロバイダーが 使われます(ID は変更なくそのまま渡されます)。 -3. **組み込みプレフィックスパターン** — ID を既知のモデルファミリプレフィックスと照合し、該当名(または名前 +6. **組み込みプレフィックスパターン** — ID を既知のモデルファミリプレフィックスと照合し、該当名(または名前 プレフィックス)の設定されたプロバイダーにルーティングします: | プレフィックス | プロバイダー | --- | --- | | `claude-`、`claude-sonnet-`、`claude-opus-`、`claude-haiku-` | `anthropic` | - | `gpt-`、`o1-`、`o3-`、`o4-` | bare ID は設定された `openai` アカウントモード、API キーは `openai-apikey/` を明示 | | `llama-`、`mixtral-`、`gemma-` | `groq` | この検査は名前のみを見ます。`defaultModel` / `models[]` 検査と異なり、現在は名前が一致したプロバイダーの `disabled` 値が true でもスキップしません。 -4. **プロバイダーの `models[]`** — プレフィックスルールに一致せず、有効なプロバイダーの `models[]` に ID が - あればそのプロバイダーを使います。順序に注意してください。OpenAI 名のプロバイダーが設定されていると - 名前空間なしの - `gpt-*` ID は他のプロバイダーの `models[]` より先に OpenAI 側に行きます。 +7. **プロバイダーの `models[]`** — プレフィックスルールに一致せず、有効なプロバイダーの `models[]` に ID が + あればそのプロバイダーを使います。ルール 4 により、bare `gpt-*` ID は別の provider の + `models[]` が一致する前に canonical かつ有効な `openai` provider に送られます。 -5. **デフォルトプロバイダー** — いずれも一致しなければ ID は変更なく `config.defaultProvider` に送信されます。 +8. **デフォルトプロバイダー** — いずれも一致しなければ ID は変更なく `config.defaultProvider` に送信されます。 (デフォルトプロバイダーがない、または無効の場合はエラーになります。) ## API キーと環境変数 @@ -82,11 +102,14 @@ Pool(デフォルト、メイン + 追加アカウント)または Direct(現在 ## ヒント -- **ルーティングモデルは明示的に書いてください。** `provider/model`(ルール 1)を推奨 — 明確でカタログ - 同期後に Codex がピッカーに表示するものと一致します。 -- プロバイダーに **`models[]` または `defaultModel` を事前入力しておくと**、短い ID(ルール 2/4)が `provider/` +- **Codex account を明示的に指定するには** `/`(ルール 1)を + 使用します。この route は exact かつ fail closed であり、別の account に暗黙に切り替わりません。 +- **ルーティングモデルは明示的に書いてください。** exact public ID が combo alias でない場合は + `provider/model`(ルール 3)を推奨します。provider を直接指定し、catalog 同期後に Codex が + picker に表示するものと一致します。 +- プロバイダーに **`models[]` または `defaultModel` を事前入力しておくと**、短い ID(ルール 5/7)が `provider/` プレフィックスなしで解釈されます。 -- **プレフィックスパターンは便利機能**であり保証ではありません: 該当名(例: `anthropic`、`openai`、`groq`)の +- **プレフィックスパターンは便利機能**であり保証ではありません: 該当名(例: `anthropic`、`groq`)の プロバイダーが実際に設定されているときのみ解釈されます。 これらのルールが読むプロバイダーフィールドは[設定](/ja/reference/configuration/)を参照してください。 diff --git a/docs-site/src/content/docs/ko/guides/model-routing.md b/docs-site/src/content/docs/ko/guides/model-routing.md index cff25b0aa..187c40a20 100644 --- a/docs-site/src/content/docs/ko/guides/model-routing.md +++ b/docs-site/src/content/docs/ko/guides/model-routing.md @@ -6,13 +6,30 @@ description: opencodex가 주어진 모델 id를 어느 프로바이더가 처 Codex가 모델을 요청하면 `router.ts`가 이를 정확히 하나의 설정된 프로바이더로 해석합니다. 규칙은 **순서대로** 검사되며, 첫 번째로 일치하는 것이 적용됩니다. -OpenAI bare `gpt-*`는 단일 `openai` 프로바이더를 선택합니다. `codexAccountMode`가 -Pool(기본, 메인+추가 계정) 또는 Direct(현재 caller/메인 bearer)를 정하며 모델 id는 그대로입니다. -`openai-apikey/`은 API key transport를 명시적으로 선택하고 두 자격증명 경로는 fallback하지 않습니다. +OpenAI에서는 설정된 `/gpt-*` id가 combo 또는 provider 네임스페이스보다 먼저 +`codexAccountNamespaces`를 통해 정확히 하나의 저장된 Codex 계정에 매핑됩니다. bare `gpt-*` id는 +대신 canonical `openai` provider를 선택합니다. 해당 provider의 `codexAccountMode`가 Pool(기본, +메인+추가 계정) 또는 Direct(현재 caller/메인 bearer)를 정하며 model id는 그대로입니다. +`openai-apikey/`은 API key transport를 명시적으로 선택합니다. 이 credential route들은 서로 +fallback하지 않습니다. ## 우선순위 -1. **명시적 `provider/model`** — id에 `/`가 포함되어 있고 그 앞부분이 설정된 프로바이더의 이름이면, +1. **정확한 Codex account selector** — id가 `/`이고 selector가 + `codexAccountNamespaces`에 설정되어 있으면 요청은 매핑된 저장 account만 사용하고 bare native + model을 upstream으로 보냅니다. exact target을 사용할 수 없으면 Pool, Direct 또는 provider routing의 + 다음 규칙으로 넘어가지 않고 fail closed합니다. + + ```text + side/gpt-5.6-sol → provider "openai", model "gpt-5.6-sol", account selector "side" + ``` + +2. **Combo id 또는 alias** — combo가 하나 이상 설정되어 있는 동안에는 canonical `combo/` 또는 + 설정된 combo alias가 provider namespace보다 먼저 concrete target을 선택합니다. 설정된 combo가 + 하나도 없으면 이름이 정확히 `combo`인 legacy physical provider는 일반 provider namespace로 + 유지됩니다. target selection과 failover 동작은 [Combos](/ko/guides/combos/)를 참고하십시오. + +3. **명시적 `provider/model`** — id에 `/`가 포함되어 있고 그 앞부분이 설정된 프로바이더의 이름이면, 해당 프로바이더가 사용되며 id는 슬래시 뒷부분으로 잘립니다. ```text @@ -21,30 +38,32 @@ Pool(기본, 메인+추가 계정) 또는 Direct(현재 caller/메인 bearer)를 openrouter/openai/gpt-5.6-sol → provider "openrouter", model "openai/gpt-5.6-sol" ``` - 이는 명확한 형식이며, Codex의 모델 선택기가 라우팅된 모델에 사용하는 형식입니다. 지정한 프로바이더가 + 이는 routed provider를 명시하는 형식이며 Codex model picker가 라우팅된 모델에 사용하는 형식입니다. + 같은 public id가 설정된 combo alias이기도 하면 규칙 2가 먼저 적용됩니다. 지정한 provider가 비활성화돼 있으면 라우팅하지 않고 오류를 냅니다. -2. **프로바이더의 `defaultModel`** — 어떤 프로바이더의 `defaultModel`이 id와 일치하면 해당 프로바이더가 +4. **Bare native OpenAI-family id** — `gpt-*`, `o1-*`, `o3-*`, `o4-*` 같은 id는 canonical active + `openai` provider와 설정된 Pool 또는 Direct account mode를 사용합니다. + +5. **프로바이더의 `defaultModel`** — 어떤 프로바이더의 `defaultModel`이 id와 일치하면 해당 프로바이더가 사용됩니다(id는 변경 없이 그대로 전달됩니다). -3. **빌트인 프리픽스 패턴** — id를 알려진 모델 제품군 프리픽스와 대조한 뒤, 해당 이름(또는 이름 +6. **빌트인 프리픽스 패턴** — id를 알려진 모델 제품군 프리픽스와 대조한 뒤, 해당 이름(또는 이름 프리픽스)의 설정된 프로바이더로 라우팅합니다: | 프리픽스 | 프로바이더 | | --- | --- | | `claude-`, `claude-sonnet-`, `claude-opus-`, `claude-haiku-` | `anthropic` | - | `gpt-`, `o1-`, `o3-`, `o4-` | bare id는 설정된 `openai` 계정 모드, API key는 `openai-apikey/`를 명시 | | `llama-`, `mixtral-`, `gemma-` | `groq` | 이 검사는 이름만 봅니다. `defaultModel` / `models[]` 검사와 달리, 현재는 이름이 일치한 프로바이더의 `disabled` 값이 true여도 건너뛰지 않습니다. -4. **프로바이더의 `models[]`** — 프리픽스 규칙과 일치하지 않고 활성 프로바이더의 `models[]`에 id가 - 있으면 그 프로바이더를 사용합니다. 순서에 주의하세요. OpenAI 이름의 프로바이더가 설정돼 있으면 - 네임스페이스 없는 - `gpt-*` id는 다른 프로바이더의 `models[]`보다 먼저 OpenAI 쪽으로 갑니다. +7. **프로바이더의 `models[]`** — 프리픽스 규칙과 일치하지 않고 활성 프로바이더의 `models[]`에 id가 + 있으면 그 프로바이더를 사용합니다. 규칙 4가 bare `gpt-*` id를 다른 provider의 `models[]`가 + 일치하기 전에 canonical active `openai` provider로 보냅니다. -5. **기본 프로바이더** — 어느 것도 일치하지 않으면 id는 변경 없이 `config.defaultProvider`로 전송됩니다. +8. **기본 프로바이더** — 어느 것도 일치하지 않으면 id는 변경 없이 `config.defaultProvider`로 전송됩니다. (기본 프로바이더가 없거나 비활성화돼 있으면 오류를 냅니다.) ## API 키와 환경 변수 @@ -82,11 +101,14 @@ Pool(기본, 메인+추가 계정) 또는 Direct(현재 caller/메인 bearer)를 ## 팁 -- **라우팅된 모델에는 명시적으로 작성하세요.** `provider/model`(규칙 1)을 선호하세요 — 명확하고 카탈로그 - 동기화 후 Codex가 선택기에 표시하는 것과 일치합니다. -- 프로바이더에 **`models[]` 또는 `defaultModel`을 미리 채워두면** 짧은 id(규칙 2/4)가 `provider/` +- **Codex account를 명시적으로 지정하려면** `/`(규칙 1)을 사용하십시오. + 이 route는 exact하고 fail closed하므로 다른 account로 조용히 전환하지 않습니다. +- **라우팅된 모델에는 명시적으로 작성하세요.** exact public id가 combo alias가 아닐 때 + `provider/model`(규칙 3)을 선호하십시오. provider를 직접 지정하며 catalog 동기화 후 Codex가 + picker에 표시하는 것과 일치합니다. +- 프로바이더에 **`models[]` 또는 `defaultModel`을 미리 채워두면** 짧은 id(규칙 5/7)가 `provider/` 프리픽스 없이 해석됩니다. -- **프리픽스 패턴은 편의 기능**일 뿐 보장이 아닙니다: 해당 이름(예: `anthropic`, `openai`, `groq`)의 +- **프리픽스 패턴은 편의 기능**일 뿐 보장이 아닙니다: 해당 이름(예: `anthropic`, `groq`)의 프로바이더가 실제로 설정되어 있을 때만 해석됩니다. 이 규칙들이 읽는 프로바이더 필드는 [설정](/ko/reference/configuration/)을 참고하세요. diff --git a/docs-site/src/content/docs/ru/guides/model-routing.md b/docs-site/src/content/docs/ru/guides/model-routing.md index 021b253d4..6aef7ebe3 100644 --- a/docs-site/src/content/docs/ru/guides/model-routing.md +++ b/docs-site/src/content/docs/ru/guides/model-routing.md @@ -6,14 +6,32 @@ description: Как opencodex решает, какой провайдер буд Когда Codex запрашивает модель, `router.ts` разрешает её ровно в одного настроенного провайдера. Правила проверяются **по порядку**; побеждает первое совпадение. -Для OpenAI id `gpt-*` без префикса выбирают один провайдер `openai`. Его `codexAccountMode` -определяет Pool (по умолчанию; основной плюс добавленные аккаунты) или Direct (bearer текущего -вызывающего/основного аккаунта), не меняя id модели. `openai-apikey/` явно выбирает -транспорт по API-ключу. Эти два маршрута учётных данных не откатываются друг на друга. +Для OpenAI настроенный id `/gpt-*` через `codexAccountNamespaces` сопоставляется ровно +с одним сохранённым аккаунтом Codex до проверки пространств имён combo или провайдеров. Id +`gpt-*` без префикса вместо этого выбирают канонический провайдер `openai`. Его +`codexAccountMode` определяет Pool (по умолчанию; основной плюс добавленные аккаунты) или Direct +(bearer текущего вызывающего/основного аккаунта), не меняя id модели. `openai-apikey/` явно +выбирает транспорт по API-ключу. Эти маршруты учётных данных не откатываются друг на друга. ## Приоритет правил -1. **Явный `provider/model`** — если id содержит `/` и часть до него совпадает с именем +1. **Точный селектор аккаунта Codex** — если id имеет вид + `/`, а селектор настроен в `codexAccountNamespaces`, запрос + использует только сопоставленный сохранённый аккаунт и отправляет upstream голый id нативной + модели. Если точный целевой аккаунт недоступен, запрос завершается с отказом (fail closed), не + переходя к Pool, Direct или маршрутизации провайдера. + + ```text + side/gpt-5.6-sol → provider "openai", model "gpt-5.6-sol", account selector "side" + ``` + +2. **Id или алиас combo** — пока настроен хотя бы один combo, канонический `combo/` или + настроенный алиас combo выбирает конкретную цель до проверки пространств имён провайдеров. + Если combo не настроены, legacy physical provider с буквальным именем `combo` остаётся обычным + пространством имён провайдера. Выбор целей и поведение failover описаны в разделе + [Combo](/ru/guides/combos/). + +3. **Явный `provider/model`** — если id содержит `/` и часть до него совпадает с именем настроенного провайдера, используется этот провайдер, а id усекается до части после косой черты. ```text @@ -22,32 +40,35 @@ description: Как opencodex решает, какой провайдер буд openrouter/openai/gpt-5.6-sol → provider "openrouter", model "openai/gpt-5.6-sol" ``` - Это однозначная форма, и именно её селектор модели Codex использует для маршрутизируемых - моделей. Если указанный провайдер отключён, эта явная форма выбрасывает ошибку вместо + Это явная форма маршрутизируемого провайдера, и именно её селектор модели Codex использует для + маршрутизируемых моделей. Если тот же публичный id настроен как алиас combo, первым срабатывает + правило 2. Если указанный провайдер отключён, эта явная форма выбрасывает ошибку вместо маршрутизации. -2. **`defaultModel` провайдера** — если `defaultModel` какого-либо провайдера равен id, +4. **Голый id нативного семейства OpenAI** — id вида `gpt-*`, `o1-*`, `o3-*` или `o4-*` + использует канонический включённый провайдер `openai` и его настроенный режим Pool или Direct. + +5. **`defaultModel` провайдера** — если `defaultModel` какого-либо провайдера равен id, используется этот провайдер (id передаётся без изменений). -3. **Встроенные шаблоны префиксов** — id сопоставляется с известными префиксами семейств +6. **Встроенные шаблоны префиксов** — id сопоставляется с известными префиксами семейств моделей и направляется настроенному провайдеру с таким именем (или префиксом имени): | Префиксы | Провайдер | | --- | --- | | `claude-`, `claude-sonnet-`, `claude-opus-`, `claude-haiku-` | `anthropic` | - | `gpt-`, `o1-`, `o3-`, `o4-` | id без префикса используют настроенный режим аккаунта `openai`; для транспорта по API-ключу используйте `openai-apikey/` | | `llama-`, `mixtral-`, `gemma-` | `groq` | Этот сопоставитель работает только по имени и, в отличие от проверок `defaultModel` / `models[]`, в настоящее время не отфильтровывает совпавшего провайдера, у которого флаг `disabled` равен true. -4. **`models[]` провайдера** — если ни одно правило префикса не сработало, а активный провайдер - перечисляет id в своём `models[]`, используется этот провайдер. Порядок здесь важен: когда - настроен провайдер с именем OpenAI, id `gpt-*` без префикса попадёт к нему раньше, чем - сработает заявка через `models[]` другого провайдера. +7. **`models[]` провайдера** — если ни одно правило префикса не сработало, а активный провайдер + перечисляет id в своём `models[]`, используется этот провайдер. По правилу 4 id `gpt-*` без + префикса отправляется каноническому включённому провайдеру `openai` до того, как сможет + сработать заявка через `models[]` другого провайдера. -5. **Провайдер по умолчанию** — если ничего не совпало, id отправляется в +8. **Провайдер по умолчанию** — если ничего не совпало, id отправляется в `config.defaultProvider` без изменений. (Если провайдер по умолчанию не настроен или отключён, маршрутизация выбрасывает ошибку.) @@ -87,13 +108,15 @@ description: Как opencodex решает, какой провайдер буд ## Советы -- **Для маршрутизируемых моделей указывайте маршрут явно.** Предпочитайте `provider/model` - (правило 1) — эта форма однозначна и совпадает с тем, что Codex показывает в селекторе после - синхронизации каталога. -- **Заполните `models[]` или `defaultModel`** у провайдера, чтобы короткие id (правила 2/4) +- **Чтобы явно выбрать аккаунт Codex,** используйте `/` (правило 1). + Этот маршрут точный и fail closed: он никогда незаметно не переключается на другой аккаунт. +- **Для маршрутизируемых моделей указывайте маршрут явно.** Если точный публичный id не является + алиасом combo, предпочитайте `provider/model` (правило 3). Эта форма прямо называет провайдера и + совпадает с тем, что Codex показывает в селекторе после синхронизации каталога. +- **Заполните `models[]` или `defaultModel`** у провайдера, чтобы короткие id (правила 5/7) разрешались без префикса `provider/`. - **Шаблоны префиксов — это удобство**, а не гарантия: они срабатывают, только если провайдер с - таким именем (например, `anthropic`, `openai`, `groq`) действительно настроен. + таким именем (например, `anthropic` или `groq`) действительно настроен. Поля провайдера, которые читают эти правила, описаны в разделе [Конфигурация](/ru/reference/configuration/). diff --git a/docs-site/src/content/docs/zh-cn/guides/model-routing.md b/docs-site/src/content/docs/zh-cn/guides/model-routing.md index bcfa3fc68..b3105376a 100644 --- a/docs-site/src/content/docs/zh-cn/guides/model-routing.md +++ b/docs-site/src/content/docs/zh-cn/guides/model-routing.md @@ -5,13 +5,29 @@ description: opencodex 如何决定由哪个提供商来服务给定的模型 id 当 Codex 请求某个模型时,`router.ts` 会将其解析为唯一一个已配置的提供商。规则**按顺序**检查;第一个匹配者胜出。 -OpenAI 的 bare `gpt-*` 使用单一 `openai` provider。`codexAccountMode` 在 Pool(默认,主账户加 -添加账户)和 Direct(当前 caller/主登录 bearer)之间选择,模型 id 不变。 -`openai-apikey/` 显式使用 API key transport;两条凭证路径互不 fallback。 +对于 OpenAI,已配置的 `/gpt-*` id 会先通过 `codexAccountNamespaces` 精确映射到一个 +已存储 Codex 账户,然后才检查 combo 或 provider 命名空间。bare `gpt-*` id 则选择规范的 +`openai` provider。其 `codexAccountMode` 在 Pool(默认,主账户加添加账户)和 Direct(当前 +caller/主登录 bearer)之间选择,model id 保持不变。`openai-apikey/` 显式使用 API key +transport;这些凭证路径互不 fallback。 ## 优先级 -1. **显式 `provider/model`** —— 如果 id 包含 `/`,且斜杠前的部分是某个已配置提供商的名称,则使用该提供商,并将 id 截取为斜杠之后的部分。 +1. **精确 Codex 账户 selector** —— 如果 id 是 `/`,且该 selector + 已在 `codexAccountNamespaces` 中配置,请求只使用映射的已存储账户,并向 upstream 发送 bare + native model。若精确目标不可用,请求会 fail closed,不会继续尝试 Pool、Direct 或 provider + routing。 + + ```text + side/gpt-5.6-sol → provider "openai", model "gpt-5.6-sol", account selector "side" + ``` + +2. **Combo id 或 alias** —— 配置了至少一个 combo 时,规范的 `combo/` 或已配置 combo alias + 会先选择具体目标,然后才检查 provider 命名空间。没有配置 combo 时,名称恰好为 `combo` 的 legacy + physical provider 仍作为普通 provider 命名空间。目标选择与 failover 行为见 + [Combos](/zh-cn/guides/combos/)。 + +3. **显式 `provider/model`** —— 如果 id 包含 `/`,且斜杠前的部分是某个已配置提供商的名称,则使用该提供商,并将 id 截取为斜杠之后的部分。 ```text anthropic/claude-opus-5 → provider "anthropic", model "claude-opus-5" @@ -19,27 +35,30 @@ OpenAI 的 bare `gpt-*` 使用单一 `openai` provider。`codexAccountMode` 在 openrouter/openai/gpt-5.6-sol → provider "openrouter", model "openai/gpt-5.6-sol" ``` - 这是无歧义的写法,也是 Codex 的模型选择器对路由模型所使用的写法。如果指定的提供商已禁用, - 这种显式写法会直接抛出错误。 + 这是显式指定 routed provider 的写法,也是 Codex 模型选择器对路由模型所使用的写法。如果同一 + public id 也是已配置的 combo alias,则规则 2 优先。如果指定的 provider 已禁用,这种显式写法会 + 直接抛出错误。 + +4. **Bare native OpenAI-family id** —— `gpt-*`、`o1-*`、`o3-*` 或 `o4-*` 等 id 使用规范且已启用的 + `openai` provider,以及其已配置的 Pool 或 Direct account mode。 -2. **某个提供商的 `defaultModel`** —— 如果任一提供商的 `defaultModel` 等于该 id,则使用该提供商(id 原样传递)。 +5. **某个提供商的 `defaultModel`** —— 如果任一提供商的 `defaultModel` 等于该 id,则使用该提供商(id 原样传递)。 -3. **内置前缀模式** —— 将 id 与已知的模型系列前缀进行匹配,然后路由到名称(或名称前缀)与之相符的已配置提供商: +6. **内置前缀模式** —— 将 id 与已知的模型系列前缀进行匹配,然后路由到名称(或名称前缀)与之相符的已配置提供商: | 前缀 | 提供商 | | --- | --- | | `claude-`、`claude-sonnet-`、`claude-opus-`、`claude-haiku-` | `anthropic` | - | `gpt-`、`o1-`、`o3-`、`o4-` | bare id 使用已配置的 `openai` 账户模式;API key 显式使用 `openai-apikey/` | | `llama-`、`mixtral-`、`gemma-` | `groq` | 该匹配器只检查名称。与 `defaultModel` / `models[]` 扫描不同,目前即使匹配提供商的 `disabled` 为 true,它也不会跳过该提供商。 -4. **某个提供商的 `models[]`** —— 如果前缀规则没有命中,而某个启用的提供商在 `models[]` 中列出 - 该 id,则使用该提供商。这个顺序很重要:只要配置了 OpenAI 名称的提供商,裸 `gpt-*` id 就会在 - 其他提供商的 `models[]` 声明之前路由到 OpenAI。 +7. **某个提供商的 `models[]`** —— 如果前缀规则没有命中,而某个启用的提供商在 `models[]` 中列出 + 该 id,则使用该提供商。规则 4 已经会在其他 provider 的 `models[]` 声明匹配前,把 bare `gpt-*` + id 发送到规范且已启用的 `openai` provider。 -5. **默认提供商** —— 如果没有任何匹配,id 将原样发送给 `config.defaultProvider`。(如果未配置默认提供商,或默认提供商已禁用,路由会抛出异常。) +8. **默认提供商** —— 如果没有任何匹配,id 将原样发送给 `config.defaultProvider`。(如果未配置默认提供商,或默认提供商已禁用,路由会抛出异常。) ## API 密钥与环境变量 @@ -71,8 +90,12 @@ OpenAI 的 bare `gpt-*` 使用单一 `openai` provider。`codexAccountMode` 在 ## 提示 -- **对路由模型使用显式写法。** 优先使用 `provider/model`(规则 1)——它无歧义,并且与目录同步后 Codex 在其选择器中显示的内容一致。 -- **为提供商预置 `models[]` 或 `defaultModel`**,这样短 id(规则 2/4)无需 `provider/` 前缀即可解析。 -- **前缀模式只是一种便利**,而非保证:只有当确实配置了同名(例如 `anthropic`、`openai`、`groq`)的提供商时,它们才会解析成功。 +- **要显式指定 Codex 账户,** 请使用 `/`(规则 1)。该路由是精确且 + fail closed 的,绝不会静默切换到其他账户。 +- **对路由模型使用显式写法。** 当 exact public id 不是 combo alias 时,优先使用 + `provider/model`(规则 3)。它会直接指定 provider,并与 catalog 同步后 Codex 在 picker 中显示的 + 内容一致。 +- **为提供商预置 `models[]` 或 `defaultModel`**,这样短 id(规则 5/7)无需 `provider/` 前缀即可解析。 +- **前缀模式只是一种便利**,而非保证:只有当确实配置了同名(例如 `anthropic` 或 `groq`)的提供商时,它们才会解析成功。 这些规则读取的提供商字段请参见 [配置](/zh-cn/reference/configuration/)。 From e8df9782c3c0636ba31eb149eca90d96ac32af78 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Tue, 4 Aug 2026 12:24:27 +0200 Subject: [PATCH 13/13] fix(codex): preserve case-distinct role fallbacks Role-TOML model_fallback lists now reuse the selector-aware de-dupe key so work/gpt-5.5 and Work/gpt-5.5 stay independent when both selectors are configured. --- src/codex/subagent-model-fallback.ts | 17 ++++++++++++----- tests/subagent-model-fallback.test.ts | 27 +++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/src/codex/subagent-model-fallback.ts b/src/codex/subagent-model-fallback.ts index 383412308..9d3782900 100644 --- a/src/codex/subagent-model-fallback.ts +++ b/src/codex/subagent-model-fallback.ts @@ -92,8 +92,8 @@ function pollIntervalMs(config: OcxConfig): number { return configured; } -function fallbackChainKey(model: string, config: OcxConfig): string { - const selector = codexAccountNamespaceForModel(config.codexAccountNamespaces, model); +function fallbackChainKey(model: string, namespaces: unknown): string { + const selector = codexAccountNamespaceForModel(namespaces, model); if (!selector) return JSON.stringify(["model", model.toLowerCase()]); const slash = model.indexOf("/"); // Selector keys are exact-case account boundaries. Keep that segment distinct while @@ -107,7 +107,7 @@ function normalizedChain(primary: string, config: OcxConfig, extra: readonly str const push = (model: string | undefined) => { if (!model || model.trim() === "") return; const trimmed = model.trim(); - const key = fallbackChainKey(trimmed, config); + const key = fallbackChainKey(trimmed, config.codexAccountNamespaces); if (seen.has(key)) return; seen.add(key); chain.push(trimmed); @@ -371,13 +371,16 @@ export function readCodexAgentModel(role: string, codexHome = CODEX_HOME): strin export function resolveAgentModelFallbackForPrimary( primary: string, codexHome = CODEX_HOME, + namespaces?: unknown, ): string[] { const merged: string[] = []; const seen = new Set(); const push = (model: string | null | undefined) => { if (!model || model.trim() === "") return; const trimmed = model.trim(); - const key = trimmed.toLowerCase(); + // Match global-chain de-dupe: keep account-selector prefixes case-sensitive when + // those selectors are configured, while ordinary provider/model ids stay case-insensitive. + const key = fallbackChainKey(trimmed, namespaces); if (seen.has(key)) return; seen.add(key); merged.push(trimmed); @@ -446,7 +449,11 @@ export function applySubagentModelFallback( accountUsabilityOptions?: CodexAccountUsabilityOptions, ): { from?: string; to?: string; skipped?: string[] } | null { if (!isThreadSpawnRequest(headers)) return null; - const roleFallback = resolveAgentModelFallbackForPrimary(parsed.modelId, getCodexHome()); + const roleFallback = resolveAgentModelFallbackForPrimary( + parsed.modelId, + getCodexHome(), + config.codexAccountNamespaces, + ); const globalFallback = config.subagentModelFallback ?? []; if (globalFallback.length === 0 && roleFallback.length === 0) return null; const selection = selectAvailableSubagentModel( diff --git a/tests/subagent-model-fallback.test.ts b/tests/subagent-model-fallback.test.ts index 1904c7c22..6eb69b217 100644 --- a/tests/subagent-model-fallback.test.ts +++ b/tests/subagent-model-fallback.test.ts @@ -864,6 +864,33 @@ describe("subagent model fallback chain", () => { expect(parsed.modelId).toBe("gpt-5.6-sol"); }); + test("role model_fallback preserves case-distinct account selectors", () => { + const dir = codexHomeFixture(); + writeFileSync(join(dir, "agents", "executor.toml"), [ + "name = \"executor\"", + "model = \"gpt-5.6-sol\"", + "model_fallback = [", + " \"work/gpt-5.5\",", + " \"Work/gpt-5.5\",", + " \"work/GPT-5.5\",", + " \"kimi/k3\",", + "]", + "", + ].join("\n"), "utf8"); + const namespaces = { work: "account-a", Work: "account-b" }; + expect(resolveAgentModelFallbackForPrimary("gpt-5.6-sol", dir, namespaces)).toEqual([ + "work/gpt-5.5", + "Work/gpt-5.5", + "kimi/k3", + ]); + // Without configured namespaces the slash prefix is treated like a provider id and + // remains case-insensitive, matching ordinary provider/model de-duplication. + expect(resolveAgentModelFallbackForPrimary("gpt-5.6-sol", dir)).toEqual([ + "work/gpt-5.5", + "kimi/k3", + ]); + }); + test("applySubagentModelFallback can use per-agent model_fallback without global config", () => { const dir = codexHomeFixture(); writeFileSync(join(dir, "agents", "executor.toml"), [