Skip to content
Open
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
2 changes: 2 additions & 0 deletions docs/IMPLEMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,8 @@ This release changes no settings, OAuth-store, or session persistence format and

**OpenCode Go multi-protocol.** Selectable ids come from live `GET /zen/go/v1/models` (process-cached; packaged seed when cold or the fetch fails). Byte and model-count caps fail closed as malformed — never a truncated prefix. Protocol routing stays on the local map; unknown live ids use Chat Completions. `buildGoSource` / `resolveGoEndpoint` pick the adapter and base URL per model (not a single provider-wide OpenAI route). When Go is the active provider, subscription usage is fetched for the status bar and omitted on auth/network failure.

**ChatGPT model catalog.** Each ChatGPT (Codex OAuth) profile lists the models its account serves: after startup the TUI fetches `GET /codex/models` with the profile's refreshed token and account id, keeps entries whose `visibility` is `list`, orders them by `priority`, and the first becomes the profile's default (`src/provider/codex-model-catalog.ts`). Connecting an account fetches the same list with the just-issued token for the connect model step, which preselects its first model unless the operator has moved to another one; `/connect` keeps that list for the saved profile, then refreshes every profile's list for the picker. The list is cached per sign-in for the process (profile name and when it signed in, which a token refresh keeps and a reconnect changes), and an older fetch never replaces a newer list. Confirming the connect model step while the fetch is still in flight waits for it, so the saved model is picked from the account's list. Fleet workers spawned from the TUI read it through the live catalog. Until a fetch succeeds the profile uses `CODEX_DEFAULT_MODELS`; a later failed fetch keeps the last live list. Headless runs never fetch, so they and their workers always use `CODEX_DEFAULT_MODELS`. The backend filters the catalog by the request's `client_version`: an old `CODEX_CLIENT_VERSION` returns fewer models or none. Only an empty list logs a warning ("listed no models"); a shortened list is silent, so when a model the Codex CLI offers is missing here, bump `CODEX_CLIENT_VERSION` to a current Codex CLI release.

### CLI Verbs and Flags

Printed by `corbits --help` / `-h` from `CLI_HELP_TEXT` in `src/config/index.ts`
Expand Down
29 changes: 17 additions & 12 deletions src/auth/codex/constants.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { callbackTargetFor } from "@corbits/oauth-core";
import { codexOAuthConfig } from "@corbits/codex-provider";
import { CODEX_BASE_URL, codexOAuthConfig } from "@corbits/codex-provider";

export {
CODEX_BASE_URL,
Expand All @@ -12,22 +12,27 @@ export const CODEX_CALLBACK_HOST = callbackTarget.host;
export const CODEX_CALLBACK_PORT = callbackTarget.port;
export const CODEX_CALLBACK_PATH = callbackTarget.path;

// The account's available model catalog. The models endpoint requires a
// client_version query param.
export const CODEX_MODELS_PATH = "/codex/models";
export const CODEX_CLIENT_VERSION = "0.50.0";
const CODEX_MODELS_PATH = "/codex/models";
// The backend filters /codex/models by client_version: an older version gets
// fewer models or none, and the per-model minimal_client_version does not
// predict which. Track a released Codex CLI version.
export const CODEX_CLIENT_VERSION = "0.165.0";
// The account's available model catalog, read by both the sign-in scope
// check and the live model catalog so the two cannot drift.
export const CODEX_MODELS_URL = `${CODEX_BASE_URL}${CODEX_MODELS_PATH}?client_version=${encodeURIComponent(CODEX_CLIENT_VERSION)}`;

// Client identity the Codex backend expects on usage/model requests, matching
// the public Codex CLI originator.
export const CODEX_ORIGINATOR = "codex_cli_rs";

// The models a ChatGPT sign-in offers, in the order GET /codex/models listed
// them for a ChatGPT Plus account (client_version 0.165.0, 2026-11-01); the
// first entry is the ChatGPT default. Availability varies by plan and rollout,
// so other accounts may be served a different set. gpt-5.4, gpt-5.4-mini and
// gpt-5.5 are left out because OpenAI retired them from Codex with ChatGPT
// sign-in. API-key sessions are not affected, so the API-key path keeps its
// own list and default.
// The models a ChatGPT sign-in offers when its live catalog (GET
// /codex/models) is unavailable, in the order that catalog listed them for a
// ChatGPT Plus account (client_version 0.165.0, 2026-11-01); the first entry
// is the ChatGPT default. Availability varies by plan and rollout, so other
// accounts may be served a different set. gpt-5.4, gpt-5.4-mini and gpt-5.5
// are left out because OpenAI retired them from Codex with ChatGPT sign-in.
// API-key sessions are not affected, so the API-key path keeps its own list
// and default.
export const CODEX_DEFAULT_MODELS = [
"gpt-6.1-sol",
"gpt-6-astra",
Expand Down
14 changes: 5 additions & 9 deletions src/auth/oauth-scope-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,7 @@

import { isOAuthTokenEndpointError } from "./token-session-boundary.js";

import {
CODEX_BASE_URL,
CODEX_MODELS_PATH,
CODEX_CLIENT_VERSION,
} from "./codex/constants.js";
import { CODEX_MODELS_URL } from "./codex/constants.js";
import { refreshStagedCodexTokens } from "./codex/session.js";
import type { CodexTokens } from "./codex/store.js";
import { codexAuthHeadersForToken } from "./codex/auth-headers.js";
Expand Down Expand Up @@ -151,10 +147,10 @@ async function checkCodexScope(
commandName,
),
(headers) =>
fetch(
`${CODEX_BASE_URL}${CODEX_MODELS_PATH}?client_version=${encodeURIComponent(CODEX_CLIENT_VERSION)}`,
{ headers, signal: AbortSignal.timeout(SCOPE_CHECK_TIMEOUT_MS) },
),
fetch(CODEX_MODELS_URL, {
headers,
signal: AbortSignal.timeout(SCOPE_CHECK_TIMEOUT_MS),
}),
);
}

Expand Down
8 changes: 3 additions & 5 deletions src/config/codex-providers.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import {
CODEX_BASE_URL,
CODEX_DEFAULT_MODELS,
} from "../auth/codex/constants.js";
import { CODEX_BASE_URL } from "../auth/codex/constants.js";
import type { CodexProfile } from "../auth/codex/store.js";
import { selectableCodexModelIds } from "../provider/codex-model-catalog.js";
import { createOAuthProviderProjection } from "./oauth-providers.js";

export const CODEX_PROVIDER_PREFIX = "codex/";
Expand All @@ -13,7 +11,7 @@ export const CODEX_PROVIDER_PREFIX = "codex/";
const projection = createOAuthProviderProjection<CodexProfile>({
prefix: CODEX_PROVIDER_PREFIX,
baseURL: CODEX_BASE_URL,
models: () => CODEX_DEFAULT_MODELS,
models: selectableCodexModelIds,
catalogExtras: (profile) => ({
codexProfile: profile.name,
...(profile.tokens.accountId !== undefined
Expand Down
73 changes: 42 additions & 31 deletions src/provider/bounded-model-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,43 @@ type DiscoveredModels = Extract<
{ status: "models" }
>["models"];

/**
* Log why a live catalog fetch kept `keeping` instead of a fresh list. An
* unreachable catalog is routine (offline, transient outage), so it stays at
* debug; an empty or malformed answer means the endpoint's contract moved,
* which only shows up here, so it warns.
*/
export function logCatalogFallback(
catalogLabel: string,
state: Exclude<CatalogDiscoveryState, { status: "models" }>,
keeping: string,
): void {
// HTTP and size failures already name the catalog; transport and parse
// errors do not, so the catalog is named exactly once either way.
const reason = (message: string): string =>
message.startsWith(catalogLabel) ? message : `${catalogLabel}: ${message}`;
switch (state.status) {
case "unavailable":
catalogLogger.debug(
"Model catalog unavailable ({reason}); keeping {fallback}",
{ reason: reason(state.message), fallback: keeping },
);
return;
case "empty":
catalogLogger.warn(
"Model catalog listed no models ({catalog}); keeping {fallback}",
{ catalog: catalogLabel, fallback: keeping },
);
return;
case "malformed":
catalogLogger.warn(
"Model catalog response rejected ({reason}); keeping {fallback}",
{ reason: reason(state.message), fallback: keeping },
);
return;
}
}

export function createBoundedModelCatalog<
Seed extends readonly string[],
>(args: {
Expand Down Expand Up @@ -198,40 +235,14 @@ export function createBoundedModelCatalog<
return selectableModelIds();
}

// An unreachable catalog is routine (offline, transient outage), so it
// stays at debug. An empty or malformed answer means the endpoint's
// contract moved, which only shows up here, so it warns.
function logFallback(
state: Exclude<CatalogDiscoveryState, { status: "models" }>,
): void {
const fallback =
snapshot === undefined ? "the built-in list" : "the last live list";
// HTTP and size failures already name the catalog; transport and parse
// errors do not, so the catalog is named exactly once either way.
const reason = (message: string): string =>
message.startsWith(catalogLabel)
? message
: `${catalogLabel}: ${message}`;
switch (state.status) {
case "unavailable":
catalogLogger.debug(
"Model catalog unavailable ({reason}); keeping {fallback}",
{ reason: reason(state.message), fallback },
);
return;
case "empty":
catalogLogger.warn(
"Model catalog listed no models ({catalog}); keeping {fallback}",
{ catalog: catalogLabel, fallback },
);
return;
case "malformed":
catalogLogger.warn(
"Model catalog response rejected ({reason}); keeping {fallback}",
{ reason: reason(state.message), fallback },
);
return;
}
logCatalogFallback(
catalogLabel,
state,
snapshot === undefined ? "the built-in list" : "the last live list",
);
}

function prefetchModels(): Promise<Seed | DiscoveredModels> {
Expand Down
Loading
Loading