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` then refreshes every profile's list for the picker. The list is cached per profile and account for the process, and 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
23 changes: 14 additions & 9 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,19 +12,24 @@ 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 can select, in the Codex backend's priority
// order (GET /codex/models); the first entry is the ChatGPT default. OpenAI
// retires models for ChatGPT sign-in on its own schedule, independently of the
// OpenAI API, so this list and its default do not follow the API-key path.
// The models a ChatGPT sign-in can select when its live catalog (GET
// /codex/models) is unavailable, in the backend's priority order; the first
// entry is the ChatGPT default. OpenAI retires models for ChatGPT sign-in on
// its own schedule, independently of the OpenAI API, so this list and its
// default do not follow the API-key path.
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