fix(openai): make onboarding models account-aware (#114258)

This commit is contained in:
Peter Steinberger
2026-07-26 23:33:57 -04:00
committed by GitHub
parent 24786f7219
commit 04bb2b7ee9
5 changed files with 372 additions and 9 deletions
+34 -9
View File
@@ -5,7 +5,13 @@ 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 = ["chat-latest", "gpt-5.5", "gpt-5.4-mini", "gpt-5.4-nano"] as const;
const DEFAULT_LIVE_MODEL_IDS = [
"gpt-5.6",
"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;
@@ -22,6 +28,25 @@ type LiveModelCase = {
function resolveLiveModelCase(modelId: string): LiveModelCase {
switch (modelId) {
case "gpt-5.6":
case "gpt-5.6-sol":
case "gpt-5.6-terra":
case "gpt-5.6-luna":
return {
modelId,
templateId: "gpt-5.5",
templateName: "GPT-5.5",
cost:
modelId === "gpt-5.6-terra"
? { input: 2.5, output: 15, cacheRead: 0.25, cacheWrite: 3.125 }
: modelId === "gpt-5.6-luna"
? { input: 1, output: 6, cacheRead: 0.1, cacheWrite: 1.25 }
: { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 6.25 },
contextWindow: 1_050_000,
maxTokens: 128_000,
reasoning: true,
textVerbosity: "low",
};
case "chat-latest":
return {
modelId,
@@ -39,7 +64,7 @@ function resolveLiveModelCase(modelId: string): LiveModelCase {
templateId: "gpt-5.5",
templateName: "GPT-5.5",
cost: { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 0 },
contextWindow: 1_000_000,
contextWindow: 1_050_000,
maxTokens: 128_000,
reasoning: true,
textVerbosity: "low",
@@ -50,7 +75,7 @@ function resolveLiveModelCase(modelId: string): LiveModelCase {
templateId: "gpt-5.4-pro",
templateName: "GPT-5.4 Pro",
cost: { input: 30, output: 180, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1_000_000,
contextWindow: 1_050_000,
maxTokens: 128_000,
reasoning: true,
textVerbosity: "low",
@@ -60,8 +85,8 @@ function resolveLiveModelCase(modelId: string): LiveModelCase {
modelId,
templateId: "gpt-5.2",
templateName: "GPT-5.2",
cost: { input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 0 },
contextWindow: 400_000,
cost: { input: 2.5, output: 15, cacheRead: 0.25, cacheWrite: 0 },
contextWindow: 1_050_000,
maxTokens: 128_000,
reasoning: true,
textVerbosity: "low",
@@ -71,8 +96,8 @@ function resolveLiveModelCase(modelId: string): LiveModelCase {
modelId,
templateId: "gpt-5.2-pro",
templateName: "GPT-5.2 Pro",
cost: { input: 21, output: 168, cacheRead: 0, cacheWrite: 0 },
contextWindow: 400_000,
cost: { input: 30, output: 180, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1_050_000,
maxTokens: 128_000,
reasoning: true,
textVerbosity: "low",
@@ -82,7 +107,7 @@ function resolveLiveModelCase(modelId: string): LiveModelCase {
modelId,
templateId: "gpt-5-mini",
templateName: "GPT-5 mini",
cost: { input: 0.25, output: 2, cacheRead: 0.025, cacheWrite: 0 },
cost: { input: 0.75, output: 4.5, cacheRead: 0.075, cacheWrite: 0 },
contextWindow: 400_000,
maxTokens: 128_000,
reasoning: true,
@@ -93,7 +118,7 @@ function resolveLiveModelCase(modelId: string): LiveModelCase {
modelId,
templateId: "gpt-5-nano",
templateName: "GPT-5 nano",
cost: { input: 0.05, output: 0.4, cacheRead: 0.005, cacheWrite: 0 },
cost: { input: 0.2, output: 1.25, cacheRead: 0.02, cacheWrite: 0 },
contextWindow: 400_000,
maxTokens: 128_000,
reasoning: true,
+79
View File
@@ -493,6 +493,12 @@ describe("buildOpenAIProvider", () => {
data: [
{ id: "gpt-5.6", object: "model" },
{ id: "gpt-5.5", object: "model" },
{ id: "chat-latest", object: "model" },
{ id: "gpt-5.4", object: "model" },
{ id: "gpt-5.4-pro", object: "model" },
{ id: "gpt-5.4-mini", object: "model" },
{ id: "gpt-5.4-nano", object: "model" },
{ id: "gpt-5.3-codex-spark", object: "model" },
{ id: "not-in-manifest", object: "model" },
],
}),
@@ -508,6 +514,47 @@ describe("buildOpenAIProvider", () => {
expect(provider.apiKey).toBe("sk-openai");
expect(provider.models.map((model) => model.id)).toContain("gpt-5.6");
expect(provider.models.map((model) => model.id)).toContain("gpt-5.5");
expect(provider.models.map((model) => model.id)).toEqual(
expect.arrayContaining([
"chat-latest",
"gpt-5.4",
"gpt-5.4-pro",
"gpt-5.4-mini",
"gpt-5.4-nano",
]),
);
expect(provider.models.find((model) => model.id === "chat-latest")).toMatchObject({
api: "openai-responses",
baseUrl: "https://api.openai.com/v1",
contextWindow: 400_000,
maxTokens: 128_000,
reasoning: false,
cost: { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 0 },
});
expect(provider.models.find((model) => model.id === "gpt-5.4-pro")).toMatchObject({
api: "openai-responses",
baseUrl: "https://api.openai.com/v1",
contextWindow: 1_050_000,
maxTokens: 128_000,
reasoning: true,
input: ["text"],
cost: { input: 30, output: 180, cacheRead: 0, cacheWrite: 0 },
});
expect(provider.models.find((model) => model.id === "gpt-5.4-mini")).toMatchObject({
api: "openai-responses",
baseUrl: "https://api.openai.com/v1",
contextWindow: 400_000,
maxTokens: 128_000,
cost: { input: 0.75, output: 4.5, cacheRead: 0.075, cacheWrite: 0 },
});
expect(provider.models.find((model) => model.id === "gpt-5.4-nano")).toMatchObject({
api: "openai-responses",
baseUrl: "https://api.openai.com/v1",
contextWindow: 400_000,
maxTokens: 128_000,
cost: { input: 0.2, output: 1.25, cacheRead: 0.02, cacheWrite: 0 },
});
expect(provider.models.map((model) => model.id)).not.toContain("gpt-5.3-codex-spark");
expect(provider.models.map((model) => model.id)).not.toContain("not-in-manifest");
const fetchParams = vi.mocked(fetchGuard).mock.calls[0]?.[0];
expect(fetchParams?.url).toBe("https://api.openai.com/v1/models");
@@ -521,6 +568,38 @@ describe("buildOpenAIProvider", () => {
expect(release).toHaveBeenCalledOnce();
});
it("does not surface platform models omitted by the account's live catalog", async () => {
const fetchGuard: LiveModelCatalogFetchGuard = vi.fn(async () => ({
response: Response.json({ data: [{ id: "gpt-5.5", object: "model" }] }),
finalUrl: "https://api.openai.com/v1/models",
release: async () => undefined,
}));
const provider = await buildOpenAILiveProviderConfig({
apiKey: "sk-openai",
fetchGuard,
});
expect(provider.models.map((model) => model.id)).toEqual(["gpt-5.5"]);
});
it("keeps only manifest fallback models when OpenAI discovery is unavailable", async () => {
const fetchGuard: LiveModelCatalogFetchGuard = vi.fn(async () => ({
response: new Response("temporarily unavailable", { status: 503 }),
finalUrl: "https://api.openai.com/v1/models",
release: async () => undefined,
}));
const provider = await buildOpenAILiveProviderConfig({
apiKey: "sk-openai",
fetchGuard,
});
expect(provider.models.map((model) => model.id)).toEqual(
manifest.modelCatalog.providers.openai.models.map((model) => model.id),
);
});
it("skips OpenAI live discovery for custom OpenAI-compatible base URLs", async () => {
const customBaseUrl = "https://example-proxy.invalid/v1";
const fetchGuard: LiveModelCatalogFetchGuard = vi.fn(async () => {
+81
View File
@@ -185,6 +185,60 @@ function buildOpenAIManifestModelsForBaseUrl(baseUrl: string): ModelDefinitionCo
);
}
function buildOpenAIDiscoverablePlatformModels(baseUrl: string): ModelDefinitionConfig[] {
const models = [
{
id: OPENAI_CHAT_LATEST_MODEL_ID,
name: "Chat Latest",
reasoning: false,
cost: OPENAI_CHAT_LATEST_COST,
contextWindow: 400_000,
},
{
id: OPENAI_GPT_54_MODEL_ID,
name: "GPT-5.4",
reasoning: true,
cost: OPENAI_GPT_54_COST,
contextWindow: OPENAI_GPT_54_CONTEXT_TOKENS,
},
{
id: OPENAI_GPT_54_PRO_MODEL_ID,
name: "GPT-5.4 Pro",
reasoning: true,
cost: OPENAI_GPT_54_PRO_COST,
contextWindow: OPENAI_GPT_54_PRO_CONTEXT_TOKENS,
},
{
id: OPENAI_GPT_54_MINI_MODEL_ID,
name: "GPT-5.4 Mini",
reasoning: true,
cost: OPENAI_GPT_54_MINI_COST,
contextWindow: OPENAI_GPT_54_MINI_CONTEXT_TOKENS,
},
{
id: OPENAI_GPT_54_NANO_MODEL_ID,
name: "GPT-5.4 Nano",
reasoning: true,
cost: OPENAI_GPT_54_NANO_COST,
contextWindow: OPENAI_GPT_54_NANO_CONTEXT_TOKENS,
},
] as const;
// First-party discovery must retain provider-owned costs and capabilities;
// generic projection would otherwise surface valid models as zero-cost.
return models.map(({ id, name, reasoning, cost, contextWindow }) => ({
id,
name,
reasoning,
cost,
contextWindow,
api: "openai-responses",
baseUrl,
input: id === OPENAI_GPT_54_PRO_MODEL_ID ? ["text"] : ["text", "image"],
maxTokens: OPENAI_GPT_54_MAX_TOKENS,
}));
}
async function buildOpenAILiveProviderConfig(
params: BuildOpenAILiveProviderConfigParams,
): Promise<ModelProviderConfig> {
@@ -207,6 +261,33 @@ async function buildOpenAILiveProviderConfig(
api: "openai-responses",
},
models,
projectRows: (rows, fallback) => {
const discoveredIds = new Set(
rows.flatMap((row) => {
if (!row || typeof row !== "object" || Array.isArray(row)) {
return [];
}
const candidate = row as { id?: unknown; object?: unknown };
if (candidate.object !== undefined && candidate.object !== "model") {
return [];
}
const modelId = typeof candidate.id === "string" ? candidate.id.trim() : "";
return modelId ? [modelId] : [];
}),
);
const selectedIds = new Set<string>();
// Discovery alone confirms account access; leave the manifest as the
// advisory fallback when OpenAI cannot return an authenticated catalog.
return [...fallback.models, ...buildOpenAIDiscoverablePlatformModels(baseUrl)].filter(
(model) => {
if (!discoveredIds.has(model.id) || selectedIds.has(model.id)) {
return false;
}
selectedIds.add(model.id);
return true;
},
);
},
apiKey: params.apiKey,
discoveryApiKey: params.discoveryApiKey,
fetchGuard: params.fetchGuard,
+177
View File
@@ -0,0 +1,177 @@
// Real-key onboarding must persist an env reference and complete the default first turn.
import { execFile } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
import { describe, expect, it } from "vitest";
import { extractAgentReplyTexts } from "../scripts/e2e/lib/agent-turn-output.mjs";
import { readPersistedAuthProfileStoreRaw } from "../src/agents/auth-profiles/sqlite.js";
import { isLiveTestEnabled } from "../src/agents/live-test-helpers.js";
import { createOpenClawTestState } from "../src/test-utils/openclaw-test-state.js";
const execFileAsync = promisify(execFile);
const openAiApiKey = process.env.OPENAI_API_KEY?.trim() ?? "";
const describeLive = isLiveTestEnabled() && openAiApiKey.length > 0 ? describe : describe.skip;
const replyMarker = "OPENCLAW_OPENAI_ONBOARDING_OK";
async function runOpenClaw(args: string[], env: NodeJS.ProcessEnv): Promise<string> {
try {
const result = await execFileAsync(process.execPath, ["scripts/run-node.mjs", ...args], {
cwd: path.resolve(import.meta.dirname, ".."),
env,
maxBuffer: 2 * 1024 * 1024,
timeout: 180_000,
});
expect(result.stdout.includes(openAiApiKey)).toBe(false);
expect(result.stderr.includes(openAiApiKey)).toBe(false);
return result.stdout;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(message.replaceAll(openAiApiKey, "[REDACTED]"));
}
}
function assertOpenAiEnvProfile(agentDir: string): void {
const store = readPersistedAuthProfileStoreRaw(agentDir) as {
profiles?: Record<string, Record<string, unknown>>;
} | null;
expect(store?.profiles).toBeDefined();
// Assert on booleans before inspecting the profile so a broken inline-key
// migration can never echo a real live credential in Vitest diagnostics.
expect(JSON.stringify(store).includes(openAiApiKey)).toBe(false);
const profile = Object.values(store?.profiles ?? {}).find(
(candidate) => candidate.type === "api_key" && candidate.provider === "openai",
);
const keyRef = profile?.keyRef as
| { source?: unknown; provider?: unknown; id?: unknown }
| undefined;
expect(profile !== undefined).toBe(true);
expect(profile?.type === "api_key").toBe(true);
expect(profile?.provider === "openai").toBe(true);
expect(keyRef?.source === "env").toBe(true);
expect(keyRef?.provider === "default").toBe(true);
expect(keyRef?.id === "OPENAI_API_KEY").toBe(true);
expect(Object.hasOwn(profile ?? {}, "key")).toBe(false);
}
function summarizeAgentOutput(stdout: string): string {
const trimmed = stdout.trim();
const jsonStart = trimmed.lastIndexOf("\n{");
const rawJson = jsonStart >= 0 ? trimmed.slice(jsonStart + 1) : trimmed;
try {
const payload = JSON.parse(rawJson) as {
status?: string;
error?: unknown;
payloads?: Array<{ isError?: boolean; text?: string }>;
meta?: { provider?: string; model?: string; stopReason?: string; error?: unknown };
result?: {
status?: string;
payloads?: Array<{ isError?: boolean; text?: string }>;
meta?: { provider?: string; model?: string; stopReason?: string; error?: unknown };
};
};
const meta = payload.result?.meta ?? payload.meta;
const payloads = payload.result?.payloads ?? payload.payloads ?? [];
return JSON.stringify({
status: payload.result?.status ?? payload.status,
provider: meta?.provider,
model: meta?.model,
stopReason: meta?.stopReason,
hasError: payload.error !== undefined || meta?.error !== undefined,
payloadCount: payloads.length,
errorPayloadCount: payloads.filter((entry) => entry.isError === true).length,
outputBytes: Buffer.byteLength(stdout),
});
} catch {
return JSON.stringify({ outputBytes: Buffer.byteLength(stdout), validJson: false });
}
}
describeLive("fresh OpenAI onboarding live", () => {
it("keeps repeated onboarding secret-safe and runs the actual default model", async () => {
const state = await createOpenClawTestState({
label: "openai-onboarding-live",
layout: "state-only",
scenario: "empty",
applyEnv: false,
// CLI children must take the production path, not inherit Vitest-only
// provider discovery and runtime shortcuts from the live-test worker.
env: {
NODE_ENV: undefined,
VITEST: undefined,
VITEST_POOL_ID: undefined,
VITEST_WORKER_ID: undefined,
OPENCLAW_TEST_FAST: undefined,
OPENCLAW_TEST_HOME: undefined,
OPENCLAW_TEST_MINIMAL_GATEWAY: undefined,
OPENCLAW_TEST_TRUST_BUNDLED_PLUGINS_DIR: undefined,
OPENCLAW_BUNDLED_PLUGINS_DIR: undefined,
OPENCLAW_DISABLE_BUNDLED_PLUGINS: undefined,
OPENCLAW_PLUGIN_CATALOG_PATHS: undefined,
OPENCLAW_PLUGINS_PATHS: undefined,
},
});
try {
await expect(fs.access(state.configPath)).rejects.toThrow();
const onboardArgs = [
"onboard",
"--non-interactive",
"--accept-risk",
"--mode",
"local",
"--auth-choice",
"openai-api-key",
"--secret-input-mode",
"ref",
"--gateway-bind",
"loopback",
"--skip-daemon",
"--skip-ui",
"--skip-skills",
"--skip-health",
"--suppress-gateway-token-output",
"--json",
];
for (let attempt = 0; attempt < 2; attempt += 1) {
await runOpenClaw(onboardArgs, state.env);
const rawConfig = await fs.readFile(state.configPath, "utf8");
expect(rawConfig.includes(openAiApiKey)).toBe(false);
const config = JSON.parse(rawConfig) as {
agents?: { defaults?: { model?: { primary?: string } } };
gateway?: { mode?: string };
};
expect(config.agents?.defaults?.model?.primary).toBe("openai/gpt-5.6");
expect(config.gateway?.mode).toBe("local");
assertOpenAiEnvProfile(state.agentDir());
}
await expect(fs.access(path.join(state.agentDir(), "auth-profiles.json"))).rejects.toThrow();
const stdout = await runOpenClaw(
[
"agent",
"--local",
"--agent",
"main",
"--session-id",
"openai-onboarding-live-default",
"--message",
`Return exactly ${replyMarker} and no other text.`,
"--thinking",
"off",
"--json",
],
state.env,
);
expect(
extractAgentReplyTexts(stdout).some((reply) => reply.includes(replyMarker)),
`default OpenAI agent turn returned ${summarizeAgentOutput(stdout)}`,
).toBe(true);
assertOpenAiEnvProfile(state.agentDir());
} finally {
await state.cleanup();
}
}, 300_000);
});
+1
View File
@@ -116,6 +116,7 @@ describe("scripts/test-live-shard", () => {
expect(selectLiveShardFiles("native-live-test", allFiles)).toEqual([
"test/image-generation.infer-cli.live.test.ts",
"test/image-generation.runtime.live.test.ts",
"test/openai-onboarding.live.test.ts",
]);
expect(selectLiveShardFiles("native-live-extensions-media", allFiles)).toEqual([
"extensions/minimax/minimax.live.test.ts",