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
20 changes: 14 additions & 6 deletions docs-site/src/content/docs/guides/codex-app-models.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,17 @@ the bare or API-key model list. The row is matched on the field shape a real cat
which filters malformed entries — it does not prove the id came from an upstream response, since
the cache is a user-owned file. See [Exact Codex account selectors](/reference/configuration/routing/#exact-codex-account-selectors).

`gpt-daybreak-blue-latest` follows that observation-only rule for account-qualified rows and is not
added to the bare native allowlist. A separate, explicit `customModels` entry can expose the same
wire id as `openai/gpt-daybreak-blue-latest` through the canonical Codex-login forward provider:
`gpt-daybreak-blue-latest` is account-gated. opencodex checks each authenticated ChatGPT account's
own Codex model roster before advertising or routing it. In Pool mode, the bare row exists only when
at least one eligible Pool account reports the slug. In Direct mode, the bare row follows the main
account used by the local catalog, and each request also checks the forwarded caller credential (or
the stored main credential when an OpenCodex admission bearer is substituted). A
`<selector>/gpt-daybreak-blue-latest` row exists only when that selector's mapped account reports it.
Pool routing excludes unentitled accounts. If no roster can be confirmed, the gated row fails closed
instead of spending a prompt on an upstream 400.

A separate, explicit `customModels` entry can expose the same wire id as
`openai/gpt-daybreak-blue-latest` through the canonical Codex-login forward provider:

```json
{
Expand All @@ -41,8 +49,8 @@ wire id as `openai/gpt-daybreak-blue-latest` through the canonical Codex-login f

Only that exact provider, endpoint, and model id receive the pinned Sol capability snapshot:
922,000 context, 829,800 automatic compaction, the native reasoning ladder, and native Codex tool
metadata. The request still sends `gpt-daybreak-blue-latest`; opencodex does not rewrite it to Sol,
does not create a bare row, and does not grant account entitlement. The separately billed
metadata. The request still sends `gpt-daybreak-blue-latest`; opencodex does not rewrite it to Sol
or grant account entitlement. The separately billed
`openai-apikey/daybreak-blue-latest` API row is a different route and its 1,050,000 / 922,000 limits
are never copied into the Codex-login row.

Expand All @@ -68,7 +76,7 @@ gpt-5.6-sol # bare Codex-login route via Pool or Direct
<selector>/gpt-5.6-sol # stored Codex account mapped by that selector
openai-apikey/gpt-5.6-sol # API key
openai/gpt-daybreak-blue-latest # explicit Codex-forward custom row (922,000)
<selector>/gpt-daybreak-blue-latest # observed account-qualified native id, when available
<selector>/gpt-daybreak-blue-latest # account-qualified native id, only when that account reports it
openai-apikey/daybreak-blue-latest # separate API-key route (1,050,000 / 922,000)
```

Expand Down
3 changes: 3 additions & 0 deletions src/codex/account-usability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,16 @@ export interface CodexAccountUsabilityOptions {
nativeMainSelectionOnly?: boolean;
/** Test seam for proving whether routing attempted a physical native-token read. */
isMainAccountTokenLive?: typeof isMainAccountTokenLive;
/** Confirmed account ids for an account-gated model; omitted for ordinary native models. */
modelEligibleAccountIds?: ReadonlySet<string>;
}

export function isCodexAccountUsable(
config: OcxConfig,
accountId: string,
options: CodexAccountUsabilityOptions = {},
): boolean {
if (options.modelEligibleAccountIds && !options.modelEligibleAccountIds.has(accountId)) return false;
if (accountId === MAIN_CODEX_ACCOUNT_ID) {
// Startup recovery owns the physical auth/vault boundary. Never parse or select
// native __main__ while an encrypted switch journal is pending or inconclusive.
Expand Down
57 changes: 55 additions & 2 deletions src/codex/auth-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,13 @@ import {
pickAlternateCodexAccount,
resolveCodexAccountForThreadDetailed,
} from "./routing";
import {
entitledCodexAccountIdsForModel,
isDirectCallerEntitledToCodexModel,
resolveCodexModelEntitlements,
type CodexModelEntitlementSnapshot,
} from "./model-entitlements";
import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS } from "./catalog/native-models";
import type { CodexCooldownSource, CodexQuotaScope } from "./routing";
import { maskAccountId } from "../lib/privacy";
import { formatErrorResponse } from "../bridge";
Expand Down Expand Up @@ -289,6 +296,14 @@ export interface ResolveCodexAuthContextOptions {
isMainAccountTokenLive?: () => boolean;
getMainAccountToken?: typeof getMainAccountToken;
primeCodexPoolQuotas?: (config: OcxConfig, reason: string) => Promise<void>;
/** Test seam for account-gated native model discovery. */
resolveCodexModelEntitlements?: (
config: Pick<OcxConfig, "codexAccounts">,
) => Promise<CodexModelEntitlementSnapshot>;
/** Direct requests admitted with a proxy bearer substitute the stored native-main credential. */
substituteMainCredentialForDirect?: boolean;
/** Test seam for a Direct request's own forwarded ChatGPT credential. */
isDirectCallerEntitledToCodexModel?: (headers: Headers, modelId: string) => Promise<boolean>;
}

export interface CodexAccountSelectionAdmission {
Expand All @@ -312,8 +327,28 @@ export async function resolveCodexAuthContext(
// selected stored credential even while the canonical OpenAI provider is globally Direct.
if (mode === "direct" && fixedAccountId === undefined) {
if (!hasCallerCodexBearer(headers)) throw new CodexDirectAuthenticationError();
if (options.modelId && ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(options.modelId)) {
const entitled = options.substituteMainCredentialForDirect
? entitledCodexAccountIdsForModel(
await (options.resolveCodexModelEntitlements ?? resolveCodexModelEntitlements)(config),
options.modelId,
)?.has(MAIN_CODEX_ACCOUNT_ID) === true
: await (options.isDirectCallerEntitledToCodexModel ?? isDirectCallerEntitledToCodexModel)(
headers,
options.modelId,
);
if (!entitled) {
throw new CodexPoolAuthenticationError("The selected ChatGPT account does not support this model");
}
}
return { kind: "main", accountId: null };
}
const entitlementSnapshot = options.modelId && ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(options.modelId)
? await (options.resolveCodexModelEntitlements ?? resolveCodexModelEntitlements)(config)
: undefined;
const modelEligibleAccountIds = entitlementSnapshot
? entitledCodexAccountIdsForModel(entitlementSnapshot, options.modelId)
: undefined;
// Retained startup recovery makes the physical main identity ineligible. Routing
// can still preserve service by selecting a healthy configured pool account.
const nativeMainTrafficBlocked = isNativeMainTrafficBlocked();
Expand All @@ -325,6 +360,7 @@ export async function resolveCodexAuthContext(
nativeMainSelectionOnly: !nativeMainTrafficBlocked
&& selectionAdmission?.mainProfileDraining === true,
isMainAccountTokenLive: options.isMainAccountTokenLive,
modelEligibleAccountIds,
};
let accountId: string;
const quotaScope = codexQuotaScopeForModel(options.modelId);
Expand Down Expand Up @@ -354,7 +390,11 @@ export async function resolveCodexAuthContext(
const selected = resolution.status === "selected" ? resolution.accountId : null;
if (!selected) {
if (fixedAccountId !== undefined) {
throw new CodexPoolAuthenticationError("Selected Codex account is unavailable");
throw new CodexPoolAuthenticationError(
modelEligibleAccountIds && !modelEligibleAccountIds.has(fixedAccountId)
? "Selected Codex account does not support this model"
: "Selected Codex account is unavailable",
);
}
// Recovery deliberately makes physical main ineligible. If no healthy
// pool route is configured and main is the intended route, report the
Expand All @@ -364,7 +404,9 @@ export async function resolveCodexAuthContext(
if (nativeMainTrafficBlocked && !options.excludeAccountId) {
throw new CodexMainProfileDrainingError();
}
throw new CodexPoolAuthenticationError();
throw new CodexPoolAuthenticationError(
modelEligibleAccountIds ? "No eligible Codex account supports this model" : undefined,
);
}
accountId = selected;
if (accountId === MAIN_CODEX_ACCOUNT_ID && nativeMainTrafficBlocked) {
Expand All @@ -377,6 +419,17 @@ export async function resolveCodexAuthContext(
) {
throw new CodexMainProfileDrainingError();
}
// Some legacy Pool fallbacks preserve a configured active account even when it is not
// currently selectable, so token/cooldown code can produce the historical actionable error.
// Model entitlement is different: sending the request would spend a turn on an account whose
// authenticated roster already denied the model. Reassert this boundary after every selector.
if (modelEligibleAccountIds && !modelEligibleAccountIds.has(accountId)) {
throw new CodexPoolAuthenticationError(
fixedAccountId !== undefined
? "Selected Codex account does not support this model"
: "No eligible Codex account supports this model",
);
}
if (fixedAccountId !== undefined) {
if (isCodexAccountPaused(config, accountId)) {
throw new CodexPoolAuthenticationError("Selected Codex account is unavailable");
Expand Down
20 changes: 17 additions & 3 deletions src/codex/catalog/metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { modelInList } from "../../types";
import { CODEX_REASONING_LEVELS, codexEffortRank, configuredReasoningEfforts, modelRecordValue, sanitizeCodexReasoningEfforts } from "../../reasoning-effort";
import { getModelMetadata, getModelMetadataCaseInsensitive, listModelMetadata, resolveMetadataProvider } from "../../generated/model-metadata";
import { enrichProviderFromRegistry, shouldCaseFoldMetadataModelId } from "../../providers/derive";
import { getProviderRegistryEntry } from "../../providers/registry";
import { getProviderRegistryEntry, providerCodexAccountMode } from "../../providers/registry";
import { applyProviderContextCap, providerContextCap } from "../../providers/context-cap";
import { routedSlug, slugEquals, slugsEquivalent } from "../../providers/slug-codec";
import { identifyRoutedModel } from "../../adapters/identity";
Expand All @@ -38,13 +38,16 @@ import { readCurrentCatalogOrCache, readCurrentCodexCatalog, readCurrentCodexMod
import { trustedAccountBoundNativeCatalogSlug, visibleCodexAccountSelectors } from "./account-models";
import { CODEX_NATIVE_ALIAS_CATALOG_KIND } from "./kinds";
import {
ACCOUNT_GATED_NATIVE_OPENAI_MODELS,
NATIVE_DAYBREAK_BLUE_MODEL,
NATIVE_OPENAI_CAPABILITY_ALIAS_MODELS,
NATIVE_OPENAI_MODELS,
SUPPORTED_NATIVE_OPENAI_SLUGS,
isNativeOpenAiCapabilityAliasModel,
nativeOpenAiCapabilitySourceSlug,
} from "./native-models";
import { cachedAvailableAccountGatedNativeModels } from "../model-entitlements";
import { MAIN_CODEX_ACCOUNT_ID } from "../main-account";
export { CODEX_NATIVE_ALIAS_CATALOG_KIND } from "./kinds";
export {
NATIVE_DAYBREAK_BLUE_MODEL,
Expand Down Expand Up @@ -390,7 +393,14 @@ export function nativeModelRows(config: Pick<OcxConfig, "disabledModels" | "comb
// Both user levers, not just the cap: a per-model window set from the dashboard has to show
// up on the row the dashboard itself renders.
const limits = nativeContextLimits(config);
return NATIVE_OPENAI_MODELS.filter(slug => !shadowed.has(slug)).map(slug => {
const bareEligibleAccountIds = providerCodexAccountMode(
OPENAI_CODEX_PROVIDER_ID,
config.providers?.[OPENAI_CODEX_PROVIDER_ID],
) === "direct" ? new Set([MAIN_CODEX_ACCOUNT_ID]) : undefined;
const availableGated = cachedAvailableAccountGatedNativeModels(Date.now(), bareEligibleAccountIds);
return NATIVE_OPENAI_MODELS
.filter(slug => !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug) || availableGated.has(slug))
.filter(slug => !shadowed.has(slug)).map(slug => {
const contextWindow = nativeOpenAiContextWindow(slug, limits);
const maxInputTokens = nativeOpenAiMaxInputTokens(slug, limits);
return {
Expand Down Expand Up @@ -475,7 +485,11 @@ export function shouldUpgradeToUpstreamEntry(entry: RawEntry): boolean {

export function nativeOpenAiSlugs(): string[] {
const live = catalogNativeSlugs();
return live.length > 0 ? unique([...live, ...DOCUMENTED_NATIVE_OPENAI_ADDITIONS]) : NATIVE_OPENAI_MODELS;
const availableGated = cachedAvailableAccountGatedNativeModels();
const candidates = live.length > 0 ? unique([...live, ...DOCUMENTED_NATIVE_OPENAI_ADDITIONS]) : NATIVE_OPENAI_MODELS;
return candidates.filter(slug => (
!ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug) || availableGated.has(slug)
));
}

const ACCOUNT_BOUND_OPENAI_NATIVE_PREFIX = /^(?:gpt-|o1-|o3-|o4-)/;
Expand Down
36 changes: 22 additions & 14 deletions src/codex/catalog/native-models.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,22 @@
/** ChatGPT/Codex wire id observed for the account-native Daybreak Blue surface. */
export const NATIVE_DAYBREAK_BLUE_MODEL = "gpt-daybreak-blue-latest";

/** Native ChatGPT/Codex ids whose availability is proven per authenticated account. */
export const ACCOUNT_GATED_NATIVE_OPENAI_MODELS: ReadonlySet<string> = new Set([
NATIVE_DAYBREAK_BLUE_MODEL,
]);

/**
* Account-native aliases whose Codex capabilities track another pinned native row.
*
* This is catalog metadata inheritance only. Routing always preserves the requested
* wire id, so the ChatGPT/Codex `gpt-daybreak-*` surface never collapses into the
* separately billed API-key `daybreak-*-latest` surface or into `gpt-5.6-sol`.
* wire id for the separately billed API-key `daybreak-*-latest` surface, so the two never
* collapse into each other.
*
* The ChatGPT/Codex surface is different: an account-gated request IS rewritten to its
* canonical wire model before it leaves the process (`applyCodexAccountGatedWireNormalization`
* in src/server/responses/core.ts), because the authenticated backend rejects the gated slug
* on shards that do not carry it. The catalog keeps the product identity; only the wire moves.
*/
const NATIVE_OPENAI_CAPABILITY_SOURCES: Readonly<Record<string, string>> = Object.freeze({
[NATIVE_DAYBREAK_BLUE_MODEL]: "gpt-5.6-sol",
Expand All @@ -16,14 +26,14 @@ const NATIVE_OPENAI_CAPABILITY_SOURCES: Readonly<Record<string, string>> = Objec
* Native ids whose capability metadata is inherited from another pinned native row.
*
* Membership here is about METADATA INHERITANCE only, and is independent of whether the
* slug is also globally allowlisted in `NATIVE_OPENAI_MODELS`. `gpt-daybreak-blue-latest`
* is now in BOTH: it inherits Sol's capability shape AND ships as a globally supported
* native row (owner decision, devlog 260816_codexrs_multiagent_v2_and_history_perf/011).
* slug is also present in `NATIVE_OPENAI_MODELS`. `gpt-daybreak-blue-latest` is now in BOTH:
* it inherits Sol's capability shape AND is a supported account-gated native id (owner decision,
* devlog 260816_codexrs_multiagent_v2_and_history_perf/011).
*
* The maps that consume the union of these two lists (`PINNED_NATIVE_CAPABILITY_ENTRIES`,
* `UPSTREAM_NATIVE_ENTRIES`) are keyed by slug, so an overlapping id collapses to one
* entry. Catalog row generation iterates `NATIVE_OPENAI_MODELS` alone, so it still emits
* exactly one bare row and one row per account selector.
* entry. Catalog row generation iterates `NATIVE_OPENAI_MODELS`, then entitlement evidence limits
* it to at most one bare row and one row per entitled account selector.
*/
export const NATIVE_OPENAI_CAPABILITY_ALIAS_MODELS = Object.freeze(
Object.keys(NATIVE_OPENAI_CAPABILITY_SOURCES),
Expand All @@ -42,16 +52,14 @@ export function nativeOpenAiCapabilitySourceSlug(slug: string): string {
*
* `gpt-daybreak-blue-latest` is entitlement-gated upstream: it is absent from codex-rs's
* bundled catalog and reaches a client only through an authenticated `/models` response.
* It is listed here by explicit owner decision so the row exists without waiting for an
* observation, because opencodex injects `model_catalog_json` and codex-rs therefore builds
* It is listed here by explicit owner decision so the capability template exists without waiting
* for an observation, because opencodex injects `model_catalog_json` and codex-rs therefore builds
* a `StaticModelsManager` whose refresh is a no-op — an entitled account had no way to
* discover it on a clean install.
*
* Accepted tradeoff: an UNENTITLED account also sees the row. Catalog sync still succeeds;
* selecting the model reaches the canonical OpenAI provider and the backend answers 400
* "model not supported for this account", which is relayed (a bare pooled route may first
* retry one alternate account on that exact body; a selector-qualified route is fixed and
* relays immediately). `disabledModels` hides the row but is NOT a runtime routing denial.
* Availability is not static: catalog sync and Pool routing require the account's authenticated
* `/models` roster to contain the slug. An unconfirmed or unentitled account never receives the
* request. `disabledModels` remains the independent user visibility control.
*
* Devlog: 260816_codexrs_multiagent_v2_and_history_perf/011 §4-bis.
*/
Expand Down
Loading
Loading