From bd55bf2c7b0b9844d8b2acfedd0284795776eac5 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 6 May 2026 03:02:25 -0700 Subject: [PATCH] feat(openai): support chat-latest override --- .../openai/openai-provider.live.test.ts | 24 ++++++- extensions/openai/openai-provider.test.ts | 68 ++++++++++++++++++- extensions/openai/openai-provider.ts | 24 ++++++- 3 files changed, 110 insertions(+), 6 deletions(-) diff --git a/extensions/openai/openai-provider.live.test.ts b/extensions/openai/openai-provider.live.test.ts index 2779a3c09ad..8974da7e146 100644 --- a/extensions/openai/openai-provider.live.test.ts +++ b/extensions/openai/openai-provider.live.test.ts @@ -5,7 +5,7 @@ import { describe, expect, it } from "vitest"; import { buildOpenAIProvider } from "./openai-provider.js"; const OPENAI_API_KEY = process.env.OPENAI_API_KEY ?? ""; -const DEFAULT_LIVE_MODEL_IDS = ["gpt-5.5", "gpt-5.4-mini", "gpt-5.4-nano"] as const; +const DEFAULT_LIVE_MODEL_IDS = ["chat-latest", "gpt-5.5", "gpt-5.4-mini", "gpt-5.4-nano"] as const; const liveEnabled = OPENAI_API_KEY.trim().length > 0 && process.env.OPENCLAW_LIVE_TEST === "1"; const describeLive = liveEnabled ? describe : describe.skip; @@ -16,6 +16,7 @@ type LiveModelCase = { cost: { input: number; output: number; cacheRead: number; cacheWrite: number }; contextWindow: number; maxTokens: number; + reasoning: boolean; }; function findOpenAIModel(modelId: string): Model | null { @@ -24,6 +25,16 @@ function findOpenAIModel(modelId: string): Model | null { function resolveLiveModelCase(modelId: string): LiveModelCase { switch (modelId) { + case "chat-latest": + return { + modelId, + templateId: "gpt-5.5", + templateName: "GPT-5.5", + cost: { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 0 }, + contextWindow: 400_000, + maxTokens: 128_000, + reasoning: false, + }; case "gpt-5.5": return { modelId, @@ -32,6 +43,7 @@ function resolveLiveModelCase(modelId: string): LiveModelCase { cost: { input: 5, output: 30, cacheRead: 0, cacheWrite: 0 }, contextWindow: 1_000_000, maxTokens: 128_000, + reasoning: true, }; case "gpt-5.5-pro": return { @@ -41,6 +53,7 @@ function resolveLiveModelCase(modelId: string): LiveModelCase { cost: { input: 30, output: 180, cacheRead: 0, cacheWrite: 0 }, contextWindow: 1_000_000, maxTokens: 128_000, + reasoning: true, }; case "gpt-5.4": return { @@ -50,6 +63,7 @@ function resolveLiveModelCase(modelId: string): LiveModelCase { cost: { input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 0 }, contextWindow: 400_000, maxTokens: 128_000, + reasoning: true, }; case "gpt-5.4-pro": return { @@ -59,6 +73,7 @@ function resolveLiveModelCase(modelId: string): LiveModelCase { cost: { input: 21, output: 168, cacheRead: 0, cacheWrite: 0 }, contextWindow: 400_000, maxTokens: 128_000, + reasoning: true, }; case "gpt-5.4-mini": return { @@ -68,6 +83,7 @@ function resolveLiveModelCase(modelId: string): LiveModelCase { cost: { input: 0.25, output: 2, cacheRead: 0.025, cacheWrite: 0 }, contextWindow: 400_000, maxTokens: 128_000, + reasoning: true, }; case "gpt-5.4-nano": return { @@ -77,6 +93,7 @@ function resolveLiveModelCase(modelId: string): LiveModelCase { cost: { input: 0.05, output: 0.4, cacheRead: 0.005, cacheWrite: 0 }, contextWindow: 400_000, maxTokens: 128_000, + reasoning: true, }; default: throw new Error(`Unsupported live OpenAI model: ${modelId}`); @@ -113,7 +130,7 @@ describeLive("buildOpenAIProvider live", () => { provider: "openai", api: "openai-completions", baseUrl: "https://api.openai.com/v1", - reasoning: true, + reasoning: liveCase.reasoning, input: ["text", "image"], cost: liveCase.cost, contextWindow: liveCase.contextWindow, @@ -146,6 +163,7 @@ describeLive("buildOpenAIProvider live", () => { id: liveCase.modelId, api: "openai-responses", baseUrl: "https://api.openai.com/v1", + reasoning: liveCase.reasoning, }); const client = new OpenAI({ @@ -158,7 +176,7 @@ describeLive("buildOpenAIProvider live", () => { instructions: "Return exactly OK and no other text.", input: "Return exactly OK.", max_output_tokens: 64, - reasoning: { effort: "none" }, + ...(liveCase.reasoning ? { reasoning: { effort: "none" as const } } : {}), text: { verbosity: "low" }, }); diff --git a/extensions/openai/openai-provider.test.ts b/extensions/openai/openai-provider.test.ts index 828e6fc1844..060f0513b27 100644 --- a/extensions/openai/openai-provider.test.ts +++ b/extensions/openai/openai-provider.test.ts @@ -282,6 +282,60 @@ describe("buildOpenAIProvider", () => { }); }); + it("resolves chat-latest as an explicit direct API model override", () => { + const provider = buildOpenAIProvider(); + + const model = provider.resolveDynamicModel?.({ + provider: "openai", + modelId: "chat-latest", + modelRegistry: { + find: (_provider: string, id: string) => + id === "gpt-5.5" + ? { + id, + name: "GPT-5.5", + provider: "openai", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + input: ["text", "image"], + cost: { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 0 }, + contextWindow: 1_050_000, + maxTokens: 128_000, + } + : null, + } as never, + }); + + expect(model).toMatchObject({ + provider: "openai", + id: "chat-latest", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + reasoning: false, + input: ["text", "image"], + contextWindow: 400_000, + maxTokens: 128_000, + cost: { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 0 }, + }); + + const fallback = provider.resolveDynamicModel?.({ + provider: "openai", + modelId: "chat-latest", + modelRegistry: { find: () => null }, + } as never); + + expect(fallback).toMatchObject({ + provider: "openai", + id: "chat-latest", + api: "openai-responses", + reasoning: false, + contextWindow: 400_000, + maxTokens: 128_000, + cost: { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 0 }, + }); + }); + it("leaves gpt-5.5 to Pi and resolves gpt-5.5-pro locally", () => { const provider = buildOpenAIProvider(); @@ -340,7 +394,7 @@ describe("buildOpenAIProvider", () => { }); }); - it("surfaces gpt-5.5 in xhigh without synthetic catalog metadata", () => { + it("keeps chat-latest and gpt-5.5 out of synthetic catalog metadata", () => { const provider = buildOpenAIProvider(); expect( @@ -363,6 +417,12 @@ describe("buildOpenAIProvider", () => { id: "gpt-5.5", }), ); + expect(entries).not.toContainEqual( + expect.objectContaining({ + provider: "openai", + id: "chat-latest", + }), + ); }); it("keeps modern live selection on OpenAI 5.2+ and current Codex models", () => { @@ -387,6 +447,12 @@ describe("buildOpenAIProvider", () => { modelId: "gpt-5.4", } as never), ).toBe(true); + expect( + provider.isModernModelRef?.({ + provider: "openai", + modelId: "chat-latest", + } as never), + ).toBe(true); expect( provider.isModernModelRef?.({ provider: "openai", diff --git a/extensions/openai/openai-provider.ts b/extensions/openai/openai-provider.ts index 76503bb293d..f5e073ef533 100644 --- a/extensions/openai/openai-provider.ts +++ b/extensions/openai/openai-provider.ts @@ -23,6 +23,7 @@ import { import { resolveOpenAIThinkingProfile } from "./thinking-policy.js"; const PROVIDER_ID = "openai"; +const OPENAI_CHAT_LATEST_MODEL_ID = "chat-latest"; const OPENAI_GPT_55_MODEL_ID = "gpt-5.5"; const OPENAI_GPT_55_PRO_MODEL_ID = "gpt-5.5-pro"; const OPENAI_GPT_54_MODEL_ID = "gpt-5.4"; @@ -35,6 +36,7 @@ const OPENAI_GPT_54_PRO_CONTEXT_TOKENS = 1_050_000; const OPENAI_GPT_54_MINI_CONTEXT_TOKENS = 400_000; const OPENAI_GPT_54_NANO_CONTEXT_TOKENS = 400_000; const OPENAI_GPT_54_MAX_TOKENS = 128_000; +const OPENAI_CHAT_LATEST_COST = { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 0 } as const; const OPENAI_GPT_55_PRO_COST = { input: 30, output: 180, cacheRead: 0, cacheWrite: 0 } as const; const OPENAI_GPT_54_COST = { input: 2.5, output: 15, cacheRead: 0.25, cacheWrite: 0 } as const; const OPENAI_GPT_54_PRO_COST = { input: 30, output: 180, cacheRead: 0, cacheWrite: 0 } as const; @@ -60,7 +62,13 @@ const OPENAI_GPT_54_TEMPLATE_MODEL_IDS = ["gpt-5.2"] as const; const OPENAI_GPT_54_PRO_TEMPLATE_MODEL_IDS = ["gpt-5.2-pro", "gpt-5.2"] as const; const OPENAI_GPT_54_MINI_TEMPLATE_MODEL_IDS = ["gpt-5-mini"] as const; const OPENAI_GPT_54_NANO_TEMPLATE_MODEL_IDS = ["gpt-5-nano", "gpt-5-mini"] as const; +const OPENAI_CHAT_LATEST_TEMPLATE_MODEL_IDS = [ + OPENAI_GPT_55_MODEL_ID, + OPENAI_GPT_54_MODEL_ID, + "gpt-5.2", +] as const; const OPENAI_MODERN_MODEL_IDS = [ + OPENAI_CHAT_LATEST_MODEL_ID, OPENAI_GPT_55_MODEL_ID, OPENAI_GPT_55_PRO_MODEL_ID, OPENAI_GPT_54_MODEL_ID, @@ -106,7 +114,19 @@ function resolveOpenAIGptForwardCompatModel(ctx: ProviderResolveDynamicModelCont const lower = normalizeLowercaseStringOrEmpty(trimmedModelId); let templateIds: readonly string[]; let patch: Partial; - if (lower === OPENAI_GPT_55_PRO_MODEL_ID) { + if (lower === OPENAI_CHAT_LATEST_MODEL_ID) { + templateIds = OPENAI_CHAT_LATEST_TEMPLATE_MODEL_IDS; + patch = { + api: "openai-responses", + provider: PROVIDER_ID, + baseUrl: "https://api.openai.com/v1", + reasoning: false, + input: ["text", "image"], + cost: OPENAI_CHAT_LATEST_COST, + contextWindow: 400_000, + maxTokens: OPENAI_GPT_54_MAX_TOKENS, + }; + } else if (lower === OPENAI_GPT_55_PRO_MODEL_ID) { templateIds = OPENAI_GPT_55_PRO_TEMPLATE_MODEL_IDS; patch = { api: "openai-responses", @@ -182,7 +202,7 @@ function resolveOpenAIGptForwardCompatModel(ctx: ProviderResolveDynamicModelCont id: trimmedModelId, name: trimmedModelId, ...patch, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + cost: patch.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: patch.contextWindow ?? DEFAULT_CONTEXT_TOKENS, maxTokens: patch.maxTokens ?? DEFAULT_CONTEXT_TOKENS, } as ProviderRuntimeModel)