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:
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user