Merge PR #5497: fix(catalog): prune unsupported Codex account models (@roboomp)

# Conflicts:
#	packages/catalog/src/discovery/codex.ts
#	packages/catalog/src/models.json
#	packages/catalog/test/codex-discovery.test.ts
This commit is contained in:
can1357
2026-07-18 21:02:46 +02:00
8 changed files with 129 additions and 9 deletions
+4
View File
@@ -78,6 +78,10 @@
- Fixed reasoning effort mapping for Z.ai GLM-5.2 on the Anthropic messages endpoint to correctly use the two-tier scale (high, max) and emit output_config.effort.
- Fixed an issue where stale cached model limits would override updated static catalog limits after a catalog fingerprint mismatch.
- Fixed Cursor discovery to correctly preserve GetUsableModels max-mode metadata for premium models and invalidate stale cache entries.
### Fixed
- Fixed OpenAI Codex discovery to replace stale bundled models with the authenticated account catalog, preventing unsupported models from remaining selectable. ([#5364](https://github.com/can1357/oh-my-pi/issues/5364))
- Fixed OpenAI Codex discovery ignoring the caller-supplied `fetch`, so it always hit the global network instead of the configured (proxy/extra-CA/test) fetch. ([#5364](https://github.com/can1357/oh-my-pi/issues/5364))
## [16.4.3] - 2026-07-11
+9 -2
View File
@@ -548,19 +548,25 @@ async function generateModels() {
allModels.push(...buildFireworksFastSeed());
const specialDiscoverySources = [
{ label: "Antigravity", fetch: fetchAntigravityModels },
{ label: "Codex", fetch: fetchCodexDiscoveryModels },
{ label: "Antigravity", providerId: "google-antigravity", authoritative: false, fetch: fetchAntigravityModels },
{ label: "Codex", providerId: "openai-codex", authoritative: true, fetch: fetchCodexDiscoveryModels },
] as const;
const specialDiscoveries = await Promise.all(
specialDiscoverySources.map(async source => ({
label: source.label,
providerId: source.providerId,
authoritative: source.authoritative,
models: await source.fetch(),
})),
);
const authoritativeSpecialDiscoveryProviders = new Set<string>();
for (const discovery of specialDiscoveries) {
if (discovery.models.length > 0) {
console.log(`Added ${discovery.models.length} models from ${discovery.label} discovery`);
allModels.push(...discovery.models);
if (discovery.authoritative) {
authoritativeSpecialDiscoveryProviders.add(discovery.providerId);
}
}
}
@@ -587,6 +593,7 @@ async function generateModels() {
!DISCOVERY_ONLY_PROVIDERS.has(model.provider) &&
!RETIRED_PROVIDERS.has(model.provider) &&
!authoritativeCatalogProviders.has(model.provider) &&
!authoritativeSpecialDiscoveryProviders.has(model.provider) &&
!modelsDevSnapshotExcludedProviders.has(model.provider)
) {
allModels.push(model);
+2 -2
View File
@@ -1,6 +1,6 @@
import { type } from "arktype";
import { parseKnownModel, semverEqual } from "../identity/classify";
import type { ModelSpec } from "../types";
import type { FetchImpl, ModelSpec } from "../types";
import { discoveryFetch } from "../utils";
import { CODEX_BASE_URL, CODEX_CLIENT_VERSION, OPENAI_HEADER_VALUES, OPENAI_HEADERS } from "../wire/codex";
@@ -69,7 +69,7 @@ export interface CodexModelDiscoveryOptions {
/** Abort signal for network request cancellation. */
signal?: AbortSignal;
/** Optional fetch implementation override for tests. */
fetchFn?: typeof fetch;
fetchFn?: FetchImpl;
}
/**
@@ -13,18 +13,20 @@ export interface OpenAICodexModelManagerConfig {
accessToken?: string;
accountId?: string;
clientVersion?: string;
fetch?: FetchImpl;
}
export function openaiCodexModelManagerOptions(
config: OpenAICodexModelManagerConfig = {},
): ModelManagerOptions<"openai-codex-responses"> {
const { accessToken, accountId, clientVersion } = config;
const { accessToken, accountId, clientVersion, fetch } = config;
return {
providerId: "openai-codex",
dynamicModelsAuthoritative: true,
...(accessToken
? {
fetchDynamicModels: async () => {
const result = await fetchCodexModels({ accessToken, accountId, clientVersion });
const result = await fetchCodexModels({ accessToken, accountId, clientVersion, fetchFn: fetch });
return result?.models ?? null;
},
}
@@ -7,6 +7,7 @@ import { buildModel } from "@oh-my-pi/pi-catalog/build";
import { fetchCodexModels } from "@oh-my-pi/pi-catalog/discovery/codex";
import { writeModelCache } from "@oh-my-pi/pi-catalog/model-cache";
import { resolveProviderModels } from "@oh-my-pi/pi-catalog/model-manager";
import { openaiCodexModelManagerOptions } from "@oh-my-pi/pi-catalog/provider-models/special";
import type { ModelSpec } from "@oh-my-pi/pi-catalog/types";
describe("Codex model discovery", () => {
@@ -140,6 +141,42 @@ describe("Codex model discovery", () => {
expect(legacy?.contextWindow).toBe(272_000);
});
it("uses the discovered account catalog as authoritative", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "pi-catalog-codex-authoritative-"));
const staticOnlyModel: ModelSpec<"openai-codex-responses"> = {
id: "unsupported-static",
name: "Unsupported static model",
api: "openai-codex-responses",
provider: "openai-codex",
baseUrl: "https://chatgpt.com/backend-api",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 272_000,
maxTokens: 128_000,
};
const discoveredModel: ModelSpec<"openai-codex-responses"> = {
...staticOnlyModel,
id: "account-supported",
name: "Account-supported model",
};
try {
const result = await resolveProviderModels(
{
...openaiCodexModelManagerOptions(),
staticModels: [staticOnlyModel],
cacheDbPath: path.join(tempDir, "models.db"),
fetchDynamicModels: async () => [discoveredModel],
},
"online",
);
expect(result.models.map(model => model.id)).toEqual(["account-supported"]);
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
it("ignores pre-V2 Codex discovery cache rows", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "pi-catalog-codex-v7-cache-"));
const dbPath = path.join(tempDir, "models.db");
+1
View File
@@ -259,6 +259,7 @@
- Fixed launch tool rendering stacking a stale pending header over a bare `✓ Launch` line and raw text: the tool now uses a merged registry renderer with one per-op status header (op, target, `state · pid · uptime` meta), stripped log cursor suffixes, capped collapsed log/list previews, and a launch tool glyph
- Fixed confusing launch start/wait results when readiness timed out with the log pattern already matched (readiness needs log AND port): the result printed a contradictory `Ready: <match>` next to `Readiness timed out` without naming the failing condition. Daemon snapshots now carry the unmet conditions (`readyPending`), and start/wait results state exactly what never happened (e.g. `port 3100 on 127.0.0.1 never accepted connections`); the TUI shows a `waiting on port` badge on starting daemons
- Fixed the in-process `stat` builtin mangling BSD-style invocations like `stat -f "%Sm %N" file` (macOS muscle memory): GNU `-f` means `--file-system`, so the format string was treated as a file operand — printing filesystem info for the real operands and erroring with `cannot read file system information for '%Sm %N'`. A `-f` whose format value contains `%` is now detected as BSD syntax and translated to the GNU equivalent (`%Sm`→`%y`, `%N`→`%n`, `%z`→`%s`, epoch/`S`-form times, owner/group/permission and `H`/`L` sub-field directives, `-L`/`-n`/`-q`/`-F` flag clusters, with `%n`/`%t` as literal newline/tab); directives with no GNU counterpart fail with a clear `unsupported BSD format directive` error
- Fixed authoritative providers (e.g. `openai-codex`) keeping unsupported bundled models selectable when a fresh model cache and an expired OAuth token coincided: built-in discovery now forces the OAuth refresh so the provider's model manager is constructed and prunes stale bundled entries (e.g. `gpt-5.4-nano`) instead of waiting out the cache TTL. ([#5364](https://github.com/can1357/oh-my-pi/issues/5364))
- Fixed the remaining GNU-flavored shell builtins that broke under macOS/BSD muscle memory, using the same unambiguous-detection approach as the `stat` fix (only invocations that are invalid or nonsensical under GNU semantics are reinterpreted; unsupported BSD forms fail loudly instead of producing wrong output): `date -r <epoch>` formats the epoch when no such file exists (GNU `-r FILE` mtime preserved), signed `date -v±N<unit>` adjustments translate to `-d` relative dates and `-j` is accepted (`-j -f` strptime parse mode and field-set `-v` error clearly); `sed -i '' 's/…/…/' file` drops the BSD empty backup-suffix token instead of treating it as the script; `mktemp -t prefix` without X's creates `$TMPDIR/prefix.XXXXXXXXXX` (the GNU `too few X's` error path); `tail -r` reverses input by delegating to `tac` (with `-n`/`-c`/`-f` combinations erroring clearly); `find -E` maps to `-regextype posix-extended` ahead of the expression; `base64 -D` decodes as an alias of `-d`; and `ln -sfh` works via a `-h` alias of `--no-dereference` (clap's `-h` help short is dropped to match real GNU/BSD ln; `--help` unchanged)
- Fixed the browser tool crashing the whole process (parent session and every subagent) when a CDP world re-acquire failed mid-navigation: the stealth `puppeteer-core` patch called the bare `debugError` logger, which is `undefined` while the `puppeteer:error` debug channel is disabled (the default), turning a transient acquire failure into a fatal `TypeError` unhandled rejection. The patched `FrameManager`/`WebWorker` acquire paths now use `debugCatchError` ([#5296](https://github.com/can1357/oh-my-pi/issues/5296))
- Fixed a role with a `:high` thinking suffix resolving to a longer sibling model whose id embeds the tier name (e.g. `kimi-for-coding:high` → `kimi-for-coding-highspeed`). The thinking suffix is now stripped before any fuzzy match, so `provider/model:high` keeps the exact model at high effort ([#5151](https://github.com/can1357/oh-my-pi/issues/5151)).
@@ -1641,6 +1641,7 @@ export class ModelRegistry {
providerId: string,
strategy: ModelRefreshStrategy,
cacheProviderId: string,
authoritative: boolean,
): Promise<string | undefined> {
const peekedKey = await this.#peekApiKeyForProvider(providerId);
if (isAuthenticated(peekedKey) || strategy === "offline") {
@@ -1650,7 +1651,13 @@ export class ModelRegistry {
if (oauthCredentials.length === 0) {
return peekedKey;
}
if (strategy === "online-if-uncached") {
// Authoritative providers prune bundled models only when their manager is
// actually constructed, which needs an authenticated key. A fresh cache does
// not let us skip the refresh here: with an expired OAuth token peekedKey is
// undefined, the manager is never added, and stale bundled models survive the
// full cache TTL. So only take the no-refresh shortcut for non-authoritative
// providers, whose bundled models stay visible regardless.
if (strategy === "online-if-uncached" && !authoritative) {
// Mirror shouldFetchRemoteSources: built-in managers use the catalog's
// default TTL, so only refresh when the manager will actually fetch.
const cache = readModelCache<Api>(
@@ -1682,11 +1689,13 @@ export class ModelRegistry {
): Promise<ModelManagerOptions<Api>[]> {
const specialProviderDescriptors: Array<{
providerId: string;
authoritative: boolean;
resolveKey: (value: string | undefined) => string | undefined;
createOptions: (key: string) => ModelManagerOptions<Api>;
}> = [
{
providerId: "google-antigravity",
authoritative: false,
resolveKey: extractGoogleOAuthToken,
createOptions: oauthToken =>
googleAntigravityModelManagerOptions({
@@ -1697,6 +1706,7 @@ export class ModelRegistry {
},
{
providerId: "google-gemini-cli",
authoritative: false,
resolveKey: extractGoogleOAuthToken,
createOptions: oauthToken =>
googleGeminiCliModelManagerOptions({
@@ -1707,12 +1717,14 @@ export class ModelRegistry {
},
{
providerId: "openai-codex",
authoritative: true,
resolveKey: value => value,
createOptions: accessToken => {
const accountId = resolveOAuthAccountIdForAccessToken(this.authStorage, "openai-codex", accessToken);
return openaiCodexModelManagerOptions({
accessToken,
accountId,
fetch: this.#fetch,
});
},
},
@@ -1737,12 +1749,22 @@ export class ModelRegistry {
const cacheProviderId =
descriptor.createModelManagerOptions({ baseUrl: discoveryBaseUrl, fetch: this.#fetch })
.cacheProviderId ?? descriptor.providerId;
return this.#resolveBuiltInDiscoveryApiKey(descriptor.providerId, strategy, cacheProviderId);
return this.#resolveBuiltInDiscoveryApiKey(
descriptor.providerId,
strategy,
cacheProviderId,
descriptor.dynamicModelsAuthoritative ?? false,
);
}),
);
const specialKeys = await Promise.all(
enabledSpecialProviderDescriptors.map(descriptor =>
this.#resolveBuiltInDiscoveryApiKey(descriptor.providerId, strategy, descriptor.providerId),
this.#resolveBuiltInDiscoveryApiKey(
descriptor.providerId,
strategy,
descriptor.providerId,
descriptor.authoritative,
),
),
);
const options: ModelManagerOptions<Api>[] = [];
@@ -280,6 +280,53 @@ describe("ModelRegistry runtime discovery", () => {
expect(authStorage.getOAuthCredential("anthropic")?.access).toBe("sk-ant-oat-expired-anthropic");
});
test("online-if-uncached refreshes expired OAuth for authoritative providers even when the cache is fresh", async () => {
// Regression for #5364: openai-codex is authoritative, so its bundled
// models are pruned only when the manager is actually constructed — which
// needs an authenticated key. With an expired OAuth token peekApiKey
// returns undefined; the fresh-cache shortcut must NOT skip the refresh, or
// the manager is never added and unsupported bundled ids (gpt-5.4-nano)
// remain selectable for the whole cache TTL.
const { refreshCalls } = await useAuthStorageWithRefreshTracker();
await authStorage.set("openai-codex", {
type: "oauth",
access: "expired-openai-codex",
refresh: "refresh-openai-codex",
expires: Date.now() - 60_000,
});
// Fresh + authoritative, but written against no static fingerprint so the
// constructed manager still performs the account-scoped fetch.
writeModelCache("openai-codex", Date.now() - 60_000, [], true, "", cacheDbPath);
let modelListCalls = 0;
const fetchMock: FetchImpl = async (input, init) => {
const url = String(input);
if (url.startsWith("https://chatgpt.com/backend-api") && url.includes("/models")) {
modelListCalls++;
expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer fresh-openai-codex");
return Response.json({
models: [
{
slug: "gpt-5.6-terra",
display_name: "GPT-5.6 Terra",
context_window: 372_000,
supported_in_api: true,
input_modalities: ["text", "image"],
},
],
});
}
throw new Error(`Unexpected URL: ${url}`);
};
const registry = new ModelRegistry(authStorage, modelsJsonPath, { fetch: fetchMock });
await registry.refreshProvider("openai-codex", "online-if-uncached");
expect(refreshCalls).toEqual(["openai-codex"]);
expect(modelListCalls).toBe(1);
expect(registry.find("openai-codex", "gpt-5.6-terra")).toBeDefined();
expect(registry.find("openai-codex", "gpt-5.4-nano")).toBeUndefined();
});
test("configured discovery suppresses built-in special OAuth discovery", async () => {
await authStorage.set("google-gemini-cli", {
type: "oauth",