Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions docs-site/src/content/docs/guides/codex-app-models.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,13 +73,15 @@ the resulting list; otherwise the native default is used when present, then the
choice. Stored custom configuration is unchanged, and repeated syncs do not add `max` back to a
narrow custom list.

This requires the exact provider, destination, and capability-backed model identity. An arbitrary
gateway such as `YYLJ/gpt-6-astra` does not inherit native capabilities from its name. Its explicit
custom ladder continues to override discovered provider metadata under the normal routed rules.
The same catalog bound applies when the custom model id has pinned native capability metadata,
including an arbitrary gateway such as `YYLJ/gpt-6-astra`. Desktop validates the model id, so
`none` and `minimal` are stripped from that catalog row. Full native identity still requires the
exact provider, destination, and capability-backed model identity; a gateway does not inherit
Responses Lite, multi-agent, or native windows from its name.
Codex's native Astra `ultra` choice is retained: it is a client delegation mode converted to a
supported wire effort, distinct from the [API model's effort list](https://developers.openai.com/api/docs/models/gpt-6-astra).
Catalog normalization does not rewrite existing thread settings or establish support for a
particular installed Desktop version.
Catalog normalization does not rewrite existing thread settings. Request-time native effort
clamps remain canonical-forward only.

When the `codexAccountNamespaces` map is empty, account-qualified picker rows are off. If
`codexAccountPickerEnabled` is omitted with a non-empty map, they are treated as enabled for
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -231,11 +231,12 @@ provider request is sent. Changing away and back also ends that continuation. St
use the new selection. Selection changes before the first provider send retain normal reselection.

Custom-model `reasoningEfforts` normally override discovered provider metadata. The bounded
exception is an explicit Astra or Daybreak custom row on the canonical `openai` Codex-forward
destination: its advertised list is intersected with that model's pinned native capabilities.
An explicit empty list remains empty with no default; a nonempty incompatible list falls back
to the native default as a single choice. Defaults must belong to the final list. This changes
the catalog projection, not stored configuration or arbitrary gateway models sharing a GPT name.
exception is an explicit custom row whose model id has pinned native Codex capabilities,
including Astra or Daybreak on an arbitrary gateway: its advertised list is intersected with
that model's pinned native capabilities. Full native identity still requires the canonical
`openai` Codex-forward destination. An explicit empty list remains empty with no default; a
nonempty incompatible list falls back to the native default as a single choice. Defaults must
belong to the final list. This changes the catalog projection, not stored configuration.
See [custom native catalog examples](/guides/codex-app-models/).

### Operator-pinned reasoning effort
Expand Down
18 changes: 13 additions & 5 deletions src/codex/catalog/provider-fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2321,7 +2321,7 @@ async function gatherRoutedModelsWithAuth(
return models;
}

/** Bound a proven Codex-forward custom row without changing its stored configuration. */
/** Bound a custom row whose model id has pinned native Codex metadata, without changing stored configuration. */
function boundCustomNativeReasoning(
model: CatalogModel,
allowed: readonly string[],
Expand Down Expand Up @@ -2625,8 +2625,8 @@ async function gatherRoutedModelsUncached(
: {}),
// Explicit custom-row ladder wins over the inherited provider row below: the merge only
// gap-fills, so a stored `[]` (explicit "no reasoning") or a declared ladder is kept
// instead of being replaced by that row's metadata. Only proven native aliases are
// bounded against their own capability source after the merge.
// instead of being replaced by that row's metadata. Capability-backed native model ids
// are bounded against their own pinned ladder after the merge, including gateways.
...(Array.isArray(cm.reasoningEfforts) ? { reasoningEfforts: [...cm.reasoningEfforts] } : {}),
...(cm.defaultReasoningEffort ? { defaultReasoningEffort: cm.defaultReasoningEffort } : {}),
...(typeof supportsServiceTier === "boolean" ? { supportsServiceTier } : {}),
Expand Down Expand Up @@ -2679,8 +2679,16 @@ async function gatherRoutedModelsUncached(
...(base.codexToolMode === undefined && replaced.codexToolMode !== undefined ? { codexToolMode: replaced.codexToolMode } : {}),
...(base.capabilities === undefined && replaced.capabilities !== undefined ? { capabilities: replaced.capabilities } : {}),
} : base;
const reasoningBounded = codexForwardNativeCapabilityAlias
? boundCustomNativeReasoning(merged, nativeReasoningEfforts(cm.modelId), nativeAliasDefaultEffort)
// Catalog-advertised efforts are bounded whenever the model id is a pinned native
// slug. Desktop validates that id, so a gateway such as YYLJ/gpt-6-astra still cannot
// advertise none/minimal. Full native identity stays behind the alias predicate.
const nativeEffortSource = hasNativeOpenAiCapabilityMetadata(cm.modelId);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Limit native bounding to explicitly declared custom ladders

When a gpt-6-astra custom-model entry omits reasoningEfforts, line 2672 first inherits the provider-derived ladder, but this unconditional native-id check then treats that inherited array as an explicit custom ladder. For example, an openai-apikey custom override that only changes the display name inherits the registry's lowmax ladder and now loses the normally synthesized ultra rung because preservePinnedNativeCustomReasoning subsequently preserves the bounded list exactly. This violates the existing omitted-key contract (“leaves the provider-derived ladder in charge”); gate this bound on cm.reasoningEfforts being explicitly present rather than on the merged model alone.

AGENTS.md reference: src/AGENTS.md:L10-L10

Useful? React with 👍 / 👎.

const reasoningBounded = nativeEffortSource
? boundCustomNativeReasoning(
merged,
nativeReasoningEfforts(cm.modelId),
nativeAliasDefaultEffort ?? nativeDefaultReasoningEffort(cm.modelId),
)
: merged;
// Vision-sidecar coverage only: when the enriched provider's shared predicate matches
// noVisionModels or text-without-image modelInputModalities, advertise image input so the
Expand Down
15 changes: 12 additions & 3 deletions src/codex/catalog/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ import { codexAccountLogLabel, fallbackCodexAccountLogLabel } from "../account-l

import { CODEX_CUSTOM_MODEL_CATALOG_KIND, CODEX_PROVIDER_MODEL_CATALOG_KIND, activeCodexModelsCachePath, applyCatalogMetadata, applyMultiAgentMode, applyNativeOpenAiContextOverride, applyRoutedCodexToolMode, catalogBackupPathFor, catalogHasRoutedEntries, catalogModelSlug, ensureStrictCatalogFields, findNativeTemplate, findSupportedNativeTemplate, isDefaultCatalogPath, isRoutedModelCompatibilityExcluded, legacyCatalogBackupPath, normalizeRoutedCatalogEntry, normalizeServiceTiers, readCatalog, readCatalogBackup, readCodexCatalogPath, readCodexCatalogPathForHome, readConfiguredAutoReviewModel, readNativeBaseline } from "./parsing";
import type { CatalogModel, MultiAgentMode, RawCatalog, RawEntry } from "./parsing";
import { accountBoundNativeOpenAiSlugs, accountBoundNativeOpenAiSlugsBySelector, applyNativeVisibility, CODEX_NATIVE_ALIAS_CATALOG_KIND, desktopAllowlistSuppressedNativeSlugs, disabledNativeSlugs, isNativeAliasCatalogEntry, isUnsupportedOpenAiNativeSlug, NATIVE_OPENAI_MODELS, RETIRED_NATIVE_OPENAI_MODELS, nativeContextLimits, observedAccountBoundNativeEntries, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi, shouldUpgradeToUpstreamEntry, SUPPORTED_NATIVE_OPENAI_SLUGS, upstreamNativeEntry, type NativeContextLimitsInput } from "./metadata";
import { accountBoundNativeOpenAiSlugs, accountBoundNativeOpenAiSlugsBySelector, applyNativeVisibility, CODEX_NATIVE_ALIAS_CATALOG_KIND, desktopAllowlistSuppressedNativeSlugs, disabledNativeSlugs, hasNativeOpenAiCapabilityMetadata, isNativeAliasCatalogEntry, isUnsupportedOpenAiNativeSlug, NATIVE_OPENAI_MODELS, RETIRED_NATIVE_OPENAI_MODELS, nativeContextLimits, observedAccountBoundNativeEntries, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi, shouldUpgradeToUpstreamEntry, SUPPORTED_NATIVE_OPENAI_SLUGS, upstreamNativeEntry, type NativeContextLimitsInput } from "./metadata";
import {
bundledCatalogCacheState,
loadBundledCodexCatalog,
Expand Down Expand Up @@ -316,6 +316,13 @@ function routedDisplayName(slug: string, model?: CatalogModel, config?: Pick<Ocx
return slug;
}

function preservePinnedNativeCustomReasoning(model?: CatalogModel): boolean {
return model !== undefined
&& model.catalogKind === CODEX_CUSTOM_MODEL_CATALOG_KIND
&& hasNativeOpenAiCapabilityMetadata(model.id)
&& Array.isArray(model.reasoningEfforts);
}

/**
* Cria uma entrada nativa ou roteada a partir do snapshot upstream, de um clone
* do template ou de campos mínimos. Aplica os metadados e limites pertinentes
Expand Down Expand Up @@ -380,7 +387,9 @@ export function deriveEntry(
e,
model?.reasoningEfforts,
model?.defaultReasoningEffort,
preserveExactReasoning || codexForwardNativeCapabilityAlias !== null,
preserveExactReasoning
|| codexForwardNativeCapabilityAlias !== null
|| preservePinnedNativeCustomReasoning(model),
);
// This exact provider/model pair is the ChatGPT/Codex forward surface. Keep the pinned
// native tool/search/responses-lite contract while preserving the routed slug and wire id.
Expand Down Expand Up @@ -429,7 +438,7 @@ export function deriveEntry(
};
if (isRouted) {
applyRoutedCodexToolMode(entry, model?.codexToolMode);
applyReasoningLevels(entry, model?.reasoningEfforts, model?.defaultReasoningEffort, preserveExactReasoning);
applyReasoningLevels(entry, model?.reasoningEfforts, model?.defaultReasoningEffort, preserveExactReasoning || preservePinnedNativeCustomReasoning(model));
}
else {
applyReasoningLevels(entry, isGpt56NativeSlug(slug) ? undefined : ["low", "medium", "high", "xhigh"]);
Expand Down
21 changes: 11 additions & 10 deletions structure/catalog.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,16 @@ custom catalog remains the native metadata/template authority even when a bundle
warm. Both paths may use an admitted matching bundled memo only as installed-runtime capability
evidence to remove unsupported reasoning efforts; convergence never probes Codex itself.

Custom Astra and Daybreak rows acquire native reasoning capability only through the existing
canonical `openai` forward destination and explicit capability-source predicate. The shared
custom-row producer bounds their merged effort lists against pinned per-model Codex metadata,
preserves an explicit empty list without a default, and recovers an incompatible nonempty list
to the native default singleton. A default must belong to the projected list. Other custom rows
keep their declaration precedence; a GPT model name, display alias, or arbitrary gateway is not
native provenance. Stored configuration and native capability maps are unchanged.
Custom Astra and Daybreak rows acquire native identity -- Responses Lite, multi-agent, context
windows, display names -- only through the canonical `openai` forward destination and explicit
capability-source predicate. Catalog-advertised reasoning lists are a narrower bound: when a
custom row's model id has pinned native capability metadata, the shared producer intersects an
explicit declared ladder with that pinned list even on an arbitrary gateway such as
`YYLJ/gpt-6-astra`. Desktop validates the model id, so `none` and `minimal` must not survive on
those catalog rows. An explicit empty list remains empty; a nonempty incompatible list falls back
to the native default singleton. A default must belong to the projected list. Full native identity
is still not inferred from a GPT name. Stored configuration and native capability maps are
unchanged. Request-time native effort clamps remain canonical-forward only.

The observed-state merge tracks the current invocation's freshly generated custom row objects
after detaching its inputs. Those rows already own their complete reasoning projection, so the
Expand All @@ -60,9 +63,7 @@ ordinary retained provider rows still receive the existing mock-tier policy. A p
marker alone never grants this exemption. Both gather entry points, retained sync, management
convergence and direct Codex model discovery use the same producer. The legacy runtime effort
union clamp remains separate; it is not a per-model or per-client-version grammar oracle.
Existing thread settings and the reported Desktop 0.153.4 gateway rejection require separate
runtime evidence. Codex's native `ultra` mode is preserved and is not a literal API wire promise.

Codex's native `ultra` mode is preserved and is not a literal API wire promise.
When account selectors are enabled, the sync path may also observe exact, visible, API-supported
OpenAI-family ids from Codex's user-owned catalog/cache. Only rows with native catalog provenance
are trusted; unknown ids are carried through startup cache invalidation as hidden observations and
Expand Down
6 changes: 3 additions & 3 deletions tests/claude-integration/claude-models-discovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ test("per-surface id style: ?ids= wins, claude-code UA gets readable, unknown UA
}
});

test("Codex discovery bounds proven custom Astra before any disk sync and preserves a gateway namesake", async () => {
test("Codex discovery bounds proven custom Astra before any disk sync including a gateway namesake", async () => {
const config = configWithStaticModels();
config.providers.openai = {
adapter: "openai-responses",
Expand All @@ -164,8 +164,8 @@ test("Codex discovery bounds proven custom Astra before any disk sync and preser
expect(canonical?.supported_reasoning_levels.map(level => level.effort)).toEqual(["low"]);
expect(canonical?.default_reasoning_level).toBe("low");
const gateway = catalog.models.find(row => row.slug === "YYLJ/gpt-6-astra");
expect(gateway?.supported_reasoning_levels.map(level => level.effort)).toEqual(["none", "minimal", "low", "max", "ultra"]);
expect(gateway?.default_reasoning_level).toBe("minimal");
expect(gateway?.supported_reasoning_levels.map(level => level.effort)).toEqual(["low"]);
expect(gateway?.default_reasoning_level).toBe("low");
} finally {
await server.stop(true);
}
Expand Down
51 changes: 39 additions & 12 deletions tests/codex-integration/codex-catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4062,14 +4062,14 @@ describe("Codex catalog routed normalization", () => {
});

test.each([
{ name: "YYLJ", adapter: "openai-responses", baseUrl: "https://gateway.example.test/v1", authMode: "key", modelId: "gpt-6-astra" },
{ name: "openai", adapter: "openai-responses", baseUrl: "https://gateway.example.test/v1", authMode: "forward", modelId: "gpt-6-astra" },
{ name: "openai", adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "key", modelId: "gpt-6-astra" },
{ name: "openai", adapter: "openai-chat", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "key", modelId: "gpt-6-astra" },
{ name: "openai-apikey", adapter: "openai-responses", baseUrl: "https://api.openai.com/v1", authMode: "key", modelId: "gpt-6-astra" },
{ name: "openai", adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", modelId: "gpt-unproven" },
] satisfies Array<{ name: string; adapter: OcxProviderConfig["adapter"]; baseUrl: string; authMode: OcxProviderConfig["authMode"]; modelId: string }>)(
"custom $name/$modelId does not infer native effort capability from $baseUrl / $authMode / $adapter",
{ name: "YYLJ", adapter: "openai-responses", baseUrl: "https://gateway.example.test/v1", authMode: "key", modelId: "gpt-6-astra", efforts: ["low"], defaultEffort: "low", catalogEfforts: ["low"] },
{ name: "openai", adapter: "openai-responses", baseUrl: "https://gateway.example.test/v1", authMode: "forward", modelId: "gpt-6-astra", efforts: ["low"], defaultEffort: "low", catalogEfforts: ["low"] },
{ name: "openai", adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "key", modelId: "gpt-6-astra", efforts: ["low"], defaultEffort: "low", catalogEfforts: ["low"] },
{ name: "openai", adapter: "openai-chat", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "key", modelId: "gpt-6-astra", efforts: ["low"], defaultEffort: "low", catalogEfforts: ["low"] },
{ name: "openai-apikey", adapter: "openai-responses", baseUrl: "https://api.openai.com/v1", authMode: "key", modelId: "gpt-6-astra", efforts: ["low"], defaultEffort: "low", catalogEfforts: ["low"] },
{ name: "openai", adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", modelId: "gpt-unproven", efforts: ["none", "minimal", "low"], defaultEffort: "minimal", catalogEfforts: ["none", "minimal", "low", "max", "ultra"] },
] satisfies Array<{ name: string; adapter: OcxProviderConfig["adapter"]; baseUrl: string; authMode: OcxProviderConfig["authMode"]; modelId: string; efforts: string[]; defaultEffort: string; catalogEfforts: string[] }>)(
"custom $name/$modelId does not inherit native identity from $baseUrl / $authMode / $adapter",
async fixture => {
const models = await gatherRoutedModels({
port: 10100,
Expand All @@ -4079,15 +4079,42 @@ describe("Codex catalog routed normalization", () => {
});
const custom = models.find(row => row.provider === fixture.name && row.id === fixture.modelId);
expect(custom?.codexForwardNativeCapabilityAlias).toBeUndefined();
expect(custom?.reasoningEfforts).toEqual(["none", "minimal", "low"]);
expect(custom?.defaultReasoningEffort).toBe("minimal");
expect(custom?.reasoningEfforts).toEqual(fixture.efforts);
expect(custom?.defaultReasoningEffort).toBe(fixture.defaultEffort);
const entries = buildCatalogEntries(nativeTemplate(), [], models);
const row = entries.find(entry => entry.slug === `${fixture.name}/${fixture.modelId}`);
expect(row ? catalogEntryEfforts(row) : undefined)
.toEqual(["none", "minimal", "low", "max", "ultra"]);
expect(row ? catalogEntryEfforts(row) : undefined).toEqual(fixture.catalogEfforts);
expect(row?.use_responses_lite).toBeUndefined();
expect(row?.multi_agent_version).toBeUndefined();
},
);

test("gateway custom Astra bounds catalog efforts without native identity (#3775)", async () => {
const config = {
port: 10100,
defaultProvider: "YYLJ",
providers: { YYLJ: { adapter: "openai-responses" as const, baseUrl: "https://gateway.example.test/v1", authMode: "key" as const, liveModels: false, models: ["gpt-6-astra"] } },
customModels: [{
id: "yylj-astra",
provider: "YYLJ",
modelId: "gpt-6-astra",
reasoningEfforts: ["none", "minimal", "low", "medium", "high", "xhigh", "max"],
defaultReasoningEffort: "minimal",
}],
};
const beforeConfig = JSON.stringify(config);
const models = await gatherRoutedModels(config);
const custom = models.find(row => row.provider === "YYLJ" && row.id === "gpt-6-astra");
expect(custom?.codexForwardNativeCapabilityAlias).toBeUndefined();
expect(custom?.reasoningEfforts).toEqual(["low", "medium", "high", "xhigh", "max"]);
expect(custom?.defaultReasoningEffort).toBe("low");
const row = buildCatalogEntries(nativeTemplate(), [], models).find(entry => entry.slug === "YYLJ/gpt-6-astra");
expect(row ? catalogEntryEfforts(row) : undefined).toEqual(["low", "medium", "high", "xhigh", "max"]);
expect(row?.default_reasoning_level).toBe("low");
expect(row?.use_responses_lite).toBeUndefined();
expect(JSON.stringify(config)).toBe(beforeConfig);
});

test("fresh none-only custom rows keep their ladder while retained provider rows still gain max", async () => {
const models = await gatherRoutedModels({
port: 10100,
Expand Down
Loading