Skip to content

Commit 33c0cd1

Browse files
committed
fix: improve codex model discovery
1 parent 81666e5 commit 33c0cd1

17 files changed

Lines changed: 343 additions & 40 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ Docs: https://docs.openclaw.ai
3131

3232
- Voice-call/Telnyx: preserve inbound/outbound callback metadata and read transcription text from Telnyx's current `transcription_data` payload.
3333
- Codex harness: send verbose tool progress to chat channels for native app-server runs, matching the Pi harness `/verbose on` and `/verbose full` behavior. (#70966) Thanks @jalehman.
34+
- Codex models: fetch paginated Codex app-server model catalogs, mark truncated `/codex models` output, and keep ChatGPT OAuth defaults on the `openai-codex/gpt-5.5` route instead of the OpenAI API-key route.
3435
- Codex harness: route native `request_user_input` prompts back to the originating chat, preserve queued follow-up answers, and honor newer app-server command approval amendment decisions.
3536
- Codex status: report Codex CLI OAuth as `oauth (codex-cli)` for native `codex/*` sessions instead of showing unknown auth. Fixes #70688. Thanks @jb510.
3637
- Codex harness/context-engine: redact context-engine assembly failures before logging, so fallback warnings do not serialize raw error objects. (#70809) Thanks @jalehman.

extensions/codex/provider.test.ts

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,66 @@ describe("codex provider", () => {
137137
});
138138
});
139139

140+
it("pages through live discovery before building the provider catalog", async () => {
141+
const listModels = vi
142+
.fn()
143+
.mockResolvedValueOnce({
144+
models: [
145+
{
146+
id: "gpt-5.4",
147+
model: "gpt-5.4",
148+
hidden: false,
149+
inputModalities: ["text", "image"],
150+
supportedReasoningEfforts: ["medium"],
151+
},
152+
],
153+
nextCursor: "page-2",
154+
})
155+
.mockResolvedValueOnce({
156+
models: [
157+
{
158+
id: "gpt-5.2",
159+
model: "gpt-5.2",
160+
hidden: false,
161+
inputModalities: ["text"],
162+
supportedReasoningEfforts: [],
163+
},
164+
],
165+
});
166+
167+
const result = await buildCodexProviderCatalog({
168+
env: {},
169+
listModels,
170+
});
171+
172+
expect(listModels).toHaveBeenNthCalledWith(
173+
1,
174+
expect.objectContaining({ cursor: undefined, limit: 100, sharedClient: false }),
175+
);
176+
expect(listModels).toHaveBeenNthCalledWith(
177+
2,
178+
expect.objectContaining({ cursor: "page-2", limit: 100, sharedClient: false }),
179+
);
180+
expect(result.provider.models.map((model) => model.id)).toEqual(["gpt-5.4", "gpt-5.2"]);
181+
});
182+
183+
it("reports discovery failures before using the fallback catalog", async () => {
184+
const error = new Error("app-server down");
185+
const onDiscoveryFailure = vi.fn();
186+
const listModels = vi.fn(async () => {
187+
throw error;
188+
});
189+
190+
const result = await buildCodexProviderCatalog({
191+
env: {},
192+
listModels,
193+
onDiscoveryFailure,
194+
});
195+
196+
expect(onDiscoveryFailure).toHaveBeenCalledWith(error);
197+
expectStaticFallbackCatalog(result);
198+
});
199+
140200
it("keeps a static fallback catalog when live discovery is explicitly disabled by env", async () => {
141201
const listModels = vi.fn();
142202

@@ -176,7 +236,7 @@ describe("codex provider", () => {
176236
expect(discoveryClient.close).toHaveBeenCalledTimes(1);
177237
});
178238

179-
it("resolves arbitrary Codex app-server model ids through the codex provider", () => {
239+
it("resolves arbitrary Codex app-server model ids as text-only until discovered", () => {
180240
const provider = buildCodexProvider();
181241

182242
const model = provider.resolveDynamicModel?.({
@@ -190,6 +250,21 @@ describe("codex provider", () => {
190250
provider: "codex",
191251
api: "openai-codex-responses",
192252
baseUrl: "https://chatgpt.com/backend-api",
253+
input: ["text"],
254+
});
255+
});
256+
257+
it("keeps fallback Codex app-server models image-capable", () => {
258+
const provider = buildCodexProvider();
259+
260+
const model = provider.resolveDynamicModel?.({
261+
provider: "codex",
262+
modelId: "gpt-5.5",
263+
modelRegistry: { find: () => null },
264+
} as never);
265+
266+
expect(model).toMatchObject({
267+
id: "gpt-5.5",
193268
input: ["text", "image"],
194269
});
195270
});

extensions/codex/provider.ts

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { resolvePluginConfigObject } from "openclaw/plugin-sdk/config-runtime";
2+
import { createSubsystemLogger } from "openclaw/plugin-sdk/core";
23
import type { ProviderRuntimeModel } from "openclaw/plugin-sdk/plugin-entry";
34
import {
45
normalizeModelCompat,
@@ -26,10 +27,13 @@ import type {
2627

2728
const DEFAULT_DISCOVERY_TIMEOUT_MS = 2500;
2829
const LIVE_DISCOVERY_ENV = "OPENCLAW_CODEX_DISCOVERY_LIVE";
30+
const MODEL_DISCOVERY_PAGE_LIMIT = 100;
31+
const codexCatalogLog = createSubsystemLogger("codex/catalog");
2932

3033
type CodexModelLister = (options: {
3134
timeoutMs: number;
3235
limit?: number;
36+
cursor?: string;
3337
startOptions?: CodexAppServerStartOptions;
3438
sharedClient?: boolean;
3539
}) => Promise<CodexAppServerModelListResult>;
@@ -43,6 +47,7 @@ type BuildCatalogOptions = {
4347
env?: NodeJS.ProcessEnv;
4448
pluginConfig?: unknown;
4549
listModels?: CodexModelLister;
50+
onDiscoveryFailure?: (error: unknown) => void;
4651
};
4752

4853
export function buildCodexProvider(options: BuildCodexProviderOptions = {}): ProviderPlugin {
@@ -103,6 +108,7 @@ export async function buildCodexProviderCatalog(
103108
listModels: options.listModels ?? listCodexAppServerModelsLazy,
104109
timeoutMs,
105110
startOptions: appServer.start,
111+
onDiscoveryFailure: options.onDiscoveryFailure,
106112
});
107113
}
108114
return {
@@ -115,12 +121,15 @@ function resolveCodexDynamicModel(modelId: string) {
115121
if (!id) {
116122
return undefined;
117123
}
124+
const fallbackModel = FALLBACK_CODEX_MODELS.find((model) => model.id === id);
118125
return normalizeModelCompat({
119126
...buildCodexModelDefinition({
120127
id,
121128
model: id,
122-
inputModalities: ["text", "image"],
123-
supportedReasoningEfforts: shouldDefaultToReasoningModel(id) ? ["medium"] : [],
129+
inputModalities: fallbackModel?.inputModalities ?? ["text"],
130+
supportedReasoningEfforts:
131+
fallbackModel?.supportedReasoningEfforts ??
132+
(shouldDefaultToReasoningModel(id) ? ["medium"] : []),
124133
}),
125134
provider: CODEX_PROVIDER_ID,
126135
baseUrl: CODEX_BASE_URL,
@@ -131,23 +140,36 @@ async function listModelsBestEffort(params: {
131140
listModels: CodexModelLister;
132141
timeoutMs: number;
133142
startOptions: CodexAppServerStartOptions;
143+
onDiscoveryFailure?: (error: unknown) => void;
134144
}): Promise<CodexAppServerModel[]> {
135145
try {
136-
const result = await params.listModels({
137-
timeoutMs: params.timeoutMs,
138-
limit: 100,
139-
startOptions: params.startOptions,
140-
sharedClient: false,
146+
const models: CodexAppServerModel[] = [];
147+
let cursor: string | undefined;
148+
do {
149+
const result = await params.listModels({
150+
timeoutMs: params.timeoutMs,
151+
limit: MODEL_DISCOVERY_PAGE_LIMIT,
152+
cursor,
153+
startOptions: params.startOptions,
154+
sharedClient: false,
155+
});
156+
models.push(...result.models.filter((model) => !model.hidden));
157+
cursor = result.nextCursor;
158+
} while (cursor);
159+
return models;
160+
} catch (error) {
161+
params.onDiscoveryFailure?.(error);
162+
codexCatalogLog.debug("codex model discovery failed; using fallback catalog", {
163+
error: error instanceof Error ? error.message : String(error),
141164
});
142-
return result.models.filter((model) => !model.hidden);
143-
} catch {
144165
return [];
145166
}
146167
}
147168

148169
async function listCodexAppServerModelsLazy(options: {
149170
timeoutMs: number;
150171
limit?: number;
172+
cursor?: string;
151173
startOptions?: CodexAppServerStartOptions;
152174
sharedClient?: boolean;
153175
}): Promise<CodexAppServerModelListResult> {

extensions/codex/src/app-server/models.test.ts

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,13 @@ vi.mock("openclaw/plugin-sdk/provider-auth", () => ({
2121
}));
2222

2323
let listCodexAppServerModels: typeof import("./models.js").listCodexAppServerModels;
24+
let listAllCodexAppServerModels: typeof import("./models.js").listAllCodexAppServerModels;
2425
let resetSharedCodexAppServerClientForTests: typeof import("./shared-client.js").resetSharedCodexAppServerClientForTests;
2526

2627
describe("listCodexAppServerModels", () => {
2728
beforeAll(async () => {
2829
({ listCodexAppServerModels } = await import("./models.js"));
30+
({ listAllCodexAppServerModels } = await import("./models.js"));
2931
({ resetSharedCodexAppServerClientForTests } = await import("./shared-client.js"));
3032
});
3133

@@ -97,4 +99,132 @@ describe("listCodexAppServerModels", () => {
9799
harness.client.close();
98100
startSpy.mockRestore();
99101
});
102+
103+
it("lists all app-server model pages through one client", async () => {
104+
const harness = createClientHarness();
105+
const startSpy = vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);
106+
107+
const listPromise = listAllCodexAppServerModels({ limit: 1, timeoutMs: 1000 });
108+
await vi.waitFor(() => expect(harness.writes.length).toBeGreaterThanOrEqual(1));
109+
const initialize = JSON.parse(harness.writes[0] ?? "{}") as { id?: number };
110+
harness.send({
111+
id: initialize.id,
112+
result: { userAgent: "openclaw/0.118.0 (macOS; test)" },
113+
});
114+
await vi.waitFor(() => expect(harness.writes.length).toBeGreaterThanOrEqual(3));
115+
const firstList = JSON.parse(harness.writes[2] ?? "{}") as {
116+
id?: number;
117+
params?: { cursor?: string | null };
118+
};
119+
expect(firstList.params?.cursor).toBeNull();
120+
121+
harness.send({
122+
id: firstList.id,
123+
result: {
124+
data: [
125+
{
126+
id: "gpt-5.4",
127+
model: "gpt-5.4",
128+
upgrade: null,
129+
upgradeInfo: null,
130+
availabilityNux: null,
131+
displayName: "gpt-5.4",
132+
description: "GPT-5.4",
133+
hidden: false,
134+
inputModalities: ["text"],
135+
supportedReasoningEfforts: [],
136+
defaultReasoningEffort: "medium",
137+
supportsPersonality: false,
138+
additionalSpeedTiers: [],
139+
isDefault: false,
140+
},
141+
],
142+
nextCursor: "page-2",
143+
},
144+
});
145+
await vi.waitFor(() => expect(harness.writes.length).toBeGreaterThanOrEqual(4));
146+
const secondList = JSON.parse(harness.writes[3] ?? "{}") as {
147+
id?: number;
148+
params?: { cursor?: string | null };
149+
};
150+
expect(secondList.params?.cursor).toBe("page-2");
151+
152+
harness.send({
153+
id: secondList.id,
154+
result: {
155+
data: [
156+
{
157+
id: "gpt-5.2",
158+
model: "gpt-5.2",
159+
upgrade: null,
160+
upgradeInfo: null,
161+
availabilityNux: null,
162+
displayName: "gpt-5.2",
163+
description: "GPT-5.2",
164+
hidden: false,
165+
inputModalities: ["text", "image"],
166+
supportedReasoningEfforts: [],
167+
defaultReasoningEffort: "medium",
168+
supportsPersonality: false,
169+
additionalSpeedTiers: [],
170+
isDefault: false,
171+
},
172+
],
173+
nextCursor: null,
174+
},
175+
});
176+
177+
await expect(listPromise).resolves.toMatchObject({
178+
models: [{ id: "gpt-5.4" }, { id: "gpt-5.2" }],
179+
});
180+
harness.client.close();
181+
startSpy.mockRestore();
182+
});
183+
184+
it("marks all-model listing truncated after the page cap", async () => {
185+
const harness = createClientHarness();
186+
const startSpy = vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);
187+
188+
const listPromise = listAllCodexAppServerModels({ limit: 1, timeoutMs: 1000, maxPages: 1 });
189+
await vi.waitFor(() => expect(harness.writes.length).toBeGreaterThanOrEqual(1));
190+
const initialize = JSON.parse(harness.writes[0] ?? "{}") as { id?: number };
191+
harness.send({
192+
id: initialize.id,
193+
result: { userAgent: "openclaw/0.118.0 (macOS; test)" },
194+
});
195+
await vi.waitFor(() => expect(harness.writes.length).toBeGreaterThanOrEqual(3));
196+
const firstList = JSON.parse(harness.writes[2] ?? "{}") as { id?: number };
197+
harness.send({
198+
id: firstList.id,
199+
result: {
200+
data: [
201+
{
202+
id: "gpt-5.4",
203+
model: "gpt-5.4",
204+
upgrade: null,
205+
upgradeInfo: null,
206+
availabilityNux: null,
207+
displayName: "gpt-5.4",
208+
description: "GPT-5.4",
209+
hidden: false,
210+
inputModalities: ["text"],
211+
supportedReasoningEfforts: [],
212+
defaultReasoningEffort: "medium",
213+
supportsPersonality: false,
214+
additionalSpeedTiers: [],
215+
isDefault: false,
216+
},
217+
],
218+
nextCursor: "page-2",
219+
},
220+
});
221+
222+
await expect(listPromise).resolves.toMatchObject({
223+
models: [{ id: "gpt-5.4" }],
224+
nextCursor: "page-2",
225+
truncated: true,
226+
});
227+
harness.client.close();
228+
startSpy.mockRestore();
229+
});
100230
});

0 commit comments

Comments
 (0)