mirror of https://github.com/openclaw/openclaw.git
449 lines
13 KiB
TypeScript
449 lines
13 KiB
TypeScript
import type { StreamFn } from "@mariozechner/pi-agent-core";
|
|
import type { Context, Model, SimpleStreamOptions } from "@mariozechner/pi-ai";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import { buildOpenAICodexProviderPlugin } from "./openai-codex-provider.js";
|
|
import { buildOpenAIProvider } from "./openai-provider.js";
|
|
|
|
const refreshOpenAICodexTokenMock = vi.hoisted(() => vi.fn());
|
|
|
|
vi.mock("./openai-codex-provider.runtime.js", () => ({
|
|
refreshOpenAICodexToken: refreshOpenAICodexTokenMock,
|
|
}));
|
|
|
|
function runWrappedPayloadCase(params: {
|
|
wrap: NonNullable<ReturnType<typeof buildOpenAIProvider>["wrapStreamFn"]>;
|
|
provider: string;
|
|
modelId: string;
|
|
model:
|
|
| Model<"openai-responses">
|
|
| Model<"openai-codex-responses">
|
|
| Model<"azure-openai-responses">;
|
|
extraParams?: Record<string, unknown>;
|
|
cfg?: Record<string, unknown>;
|
|
payload?: Record<string, unknown>;
|
|
}) {
|
|
const payload = params.payload ?? { store: false };
|
|
let capturedOptions: (SimpleStreamOptions & { openaiWsWarmup?: boolean }) | undefined;
|
|
const baseStreamFn: StreamFn = (model, _context, options) => {
|
|
capturedOptions = options as (SimpleStreamOptions & { openaiWsWarmup?: boolean }) | undefined;
|
|
options?.onPayload?.(payload, model);
|
|
return {} as ReturnType<StreamFn>;
|
|
};
|
|
|
|
const streamFn = params.wrap({
|
|
provider: params.provider,
|
|
modelId: params.modelId,
|
|
extraParams: params.extraParams,
|
|
config: params.cfg as never,
|
|
agentDir: "/tmp/openai-provider-test",
|
|
streamFn: baseStreamFn,
|
|
} as never);
|
|
|
|
const context: Context = { messages: [] };
|
|
void streamFn?.(params.model, context, {});
|
|
|
|
return {
|
|
payload,
|
|
options: capturedOptions,
|
|
};
|
|
}
|
|
|
|
describe("buildOpenAIProvider", () => {
|
|
it("resolves gpt-5.4 mini and nano from GPT-5 small-model templates", () => {
|
|
const provider = buildOpenAIProvider();
|
|
const registry = {
|
|
find(providerId: string, id: string) {
|
|
if (providerId !== "openai") {
|
|
return null;
|
|
}
|
|
if (id === "gpt-5-mini") {
|
|
return {
|
|
id,
|
|
name: "GPT-5 mini",
|
|
provider: "openai",
|
|
api: "openai-responses",
|
|
baseUrl: "https://api.openai.com/v1",
|
|
reasoning: true,
|
|
input: ["text", "image"],
|
|
cost: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 },
|
|
contextWindow: 400_000,
|
|
maxTokens: 128_000,
|
|
};
|
|
}
|
|
if (id === "gpt-5-nano") {
|
|
return {
|
|
id,
|
|
name: "GPT-5 nano",
|
|
provider: "openai",
|
|
api: "openai-responses",
|
|
baseUrl: "https://api.openai.com/v1",
|
|
reasoning: true,
|
|
input: ["text", "image"],
|
|
cost: { input: 0.5, output: 1, cacheRead: 0, cacheWrite: 0 },
|
|
contextWindow: 200_000,
|
|
maxTokens: 64_000,
|
|
};
|
|
}
|
|
return null;
|
|
},
|
|
};
|
|
|
|
const mini = provider.resolveDynamicModel?.({
|
|
provider: "openai",
|
|
modelId: "gpt-5.4-mini",
|
|
modelRegistry: registry as never,
|
|
});
|
|
const nano = provider.resolveDynamicModel?.({
|
|
provider: "openai",
|
|
modelId: "gpt-5.4-nano",
|
|
modelRegistry: registry as never,
|
|
});
|
|
|
|
expect(mini).toMatchObject({
|
|
provider: "openai",
|
|
id: "gpt-5.4-mini",
|
|
api: "openai-responses",
|
|
baseUrl: "https://api.openai.com/v1",
|
|
contextWindow: 400_000,
|
|
maxTokens: 128_000,
|
|
});
|
|
expect(nano).toMatchObject({
|
|
provider: "openai",
|
|
id: "gpt-5.4-nano",
|
|
api: "openai-responses",
|
|
baseUrl: "https://api.openai.com/v1",
|
|
contextWindow: 400_000,
|
|
maxTokens: 128_000,
|
|
});
|
|
});
|
|
|
|
it("surfaces gpt-5.4 mini and nano in xhigh and augmented catalog metadata", () => {
|
|
const provider = buildOpenAIProvider();
|
|
|
|
expect(
|
|
provider.supportsXHighThinking?.({
|
|
provider: "openai",
|
|
modelId: "gpt-5.4-mini",
|
|
} as never),
|
|
).toBe(true);
|
|
expect(
|
|
provider.supportsXHighThinking?.({
|
|
provider: "openai",
|
|
modelId: "gpt-5.4-nano",
|
|
} as never),
|
|
).toBe(true);
|
|
|
|
const entries = provider.augmentModelCatalog?.({
|
|
env: process.env,
|
|
entries: [
|
|
{ provider: "openai", id: "gpt-5-mini", name: "GPT-5 mini" },
|
|
{ provider: "openai", id: "gpt-5-nano", name: "GPT-5 nano" },
|
|
],
|
|
} as never);
|
|
|
|
expect(entries).toContainEqual(
|
|
expect.objectContaining({
|
|
provider: "openai",
|
|
id: "gpt-5.4-mini",
|
|
name: "gpt-5.4-mini",
|
|
reasoning: true,
|
|
input: ["text", "image"],
|
|
contextWindow: 400_000,
|
|
}),
|
|
);
|
|
expect(entries).toContainEqual(
|
|
expect.objectContaining({
|
|
provider: "openai",
|
|
id: "gpt-5.4-nano",
|
|
name: "gpt-5.4-nano",
|
|
reasoning: true,
|
|
input: ["text", "image"],
|
|
contextWindow: 400_000,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("owns native reasoning output mode for OpenAI and Azure OpenAI responses", () => {
|
|
const provider = buildOpenAIProvider();
|
|
|
|
expect(
|
|
provider.resolveReasoningOutputMode?.({
|
|
provider: "openai",
|
|
modelApi: "openai-responses",
|
|
modelId: "gpt-5.4",
|
|
} as never),
|
|
).toBe("native");
|
|
expect(
|
|
provider.resolveReasoningOutputMode?.({
|
|
provider: "azure-openai-responses",
|
|
modelApi: "azure-openai-responses",
|
|
modelId: "gpt-5.4",
|
|
} as never),
|
|
).toBe("native");
|
|
});
|
|
|
|
it("keeps GPT-5.4 family metadata aligned with native OpenAI docs", () => {
|
|
const provider = buildOpenAIProvider();
|
|
const codexProvider = buildOpenAICodexProviderPlugin();
|
|
|
|
const openaiModel = provider.resolveDynamicModel?.({
|
|
provider: "openai",
|
|
modelId: "gpt-5.4",
|
|
modelRegistry: { find: () => null },
|
|
} as never);
|
|
const codexModel = codexProvider.resolveDynamicModel?.({
|
|
provider: "openai-codex",
|
|
modelId: "gpt-5.4",
|
|
modelRegistry: { find: () => null },
|
|
} as never);
|
|
|
|
expect(openaiModel).toMatchObject({
|
|
provider: "openai",
|
|
id: "gpt-5.4",
|
|
api: "openai-responses",
|
|
baseUrl: "https://api.openai.com/v1",
|
|
contextWindow: 1_050_000,
|
|
maxTokens: 128_000,
|
|
});
|
|
expect(codexModel).toMatchObject({
|
|
provider: "openai-codex",
|
|
id: "gpt-5.4",
|
|
api: "openai-codex-responses",
|
|
baseUrl: "https://chatgpt.com/backend-api",
|
|
contextWindow: 1_050_000,
|
|
maxTokens: 128_000,
|
|
});
|
|
});
|
|
|
|
it("keeps modern live selection on OpenAI 5.2+ and Codex 5.2+", () => {
|
|
const provider = buildOpenAIProvider();
|
|
const codexProvider = buildOpenAICodexProviderPlugin();
|
|
|
|
expect(
|
|
provider.isModernModelRef?.({
|
|
provider: "openai",
|
|
modelId: "gpt-5.0",
|
|
} as never),
|
|
).toBe(false);
|
|
expect(
|
|
provider.isModernModelRef?.({
|
|
provider: "openai",
|
|
modelId: "gpt-5.2",
|
|
} as never),
|
|
).toBe(true);
|
|
expect(
|
|
provider.isModernModelRef?.({
|
|
provider: "openai",
|
|
modelId: "gpt-5.4",
|
|
} as never),
|
|
).toBe(true);
|
|
|
|
expect(
|
|
codexProvider.isModernModelRef?.({
|
|
provider: "openai-codex",
|
|
modelId: "gpt-5.1-codex",
|
|
} as never),
|
|
).toBe(false);
|
|
expect(
|
|
codexProvider.isModernModelRef?.({
|
|
provider: "openai-codex",
|
|
modelId: "gpt-5.1-codex-max",
|
|
} as never),
|
|
).toBe(false);
|
|
expect(
|
|
codexProvider.isModernModelRef?.({
|
|
provider: "openai-codex",
|
|
modelId: "gpt-5.2-codex",
|
|
} as never),
|
|
).toBe(true);
|
|
expect(
|
|
codexProvider.isModernModelRef?.({
|
|
provider: "openai-codex",
|
|
modelId: "gpt-5.4",
|
|
} as never),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("owns replay policy for OpenAI and Codex transports", () => {
|
|
const provider = buildOpenAIProvider();
|
|
const codexProvider = buildOpenAICodexProviderPlugin();
|
|
|
|
expect(
|
|
provider.buildReplayPolicy?.({
|
|
provider: "openai",
|
|
modelApi: "openai",
|
|
modelId: "gpt-5.4",
|
|
} as never),
|
|
).toEqual({
|
|
sanitizeMode: "images-only",
|
|
applyAssistantFirstOrderingFix: false,
|
|
sanitizeToolCallIds: false,
|
|
validateGeminiTurns: false,
|
|
validateAnthropicTurns: false,
|
|
});
|
|
|
|
expect(
|
|
provider.buildReplayPolicy?.({
|
|
provider: "openai",
|
|
modelApi: "openai-completions",
|
|
modelId: "gpt-5.4",
|
|
} as never),
|
|
).toEqual({
|
|
sanitizeMode: "images-only",
|
|
applyAssistantFirstOrderingFix: false,
|
|
sanitizeToolCallIds: true,
|
|
toolCallIdMode: "strict",
|
|
validateGeminiTurns: false,
|
|
validateAnthropicTurns: false,
|
|
});
|
|
|
|
expect(
|
|
codexProvider.buildReplayPolicy?.({
|
|
provider: "openai-codex",
|
|
modelApi: "openai-codex-responses",
|
|
modelId: "gpt-5.4",
|
|
} as never),
|
|
).toEqual({
|
|
sanitizeMode: "images-only",
|
|
applyAssistantFirstOrderingFix: false,
|
|
sanitizeToolCallIds: false,
|
|
validateGeminiTurns: false,
|
|
validateAnthropicTurns: false,
|
|
});
|
|
});
|
|
|
|
it("owns direct OpenAI wrapper composition for responses payloads", () => {
|
|
const provider = buildOpenAIProvider();
|
|
const extraParams = provider.prepareExtraParams?.({
|
|
provider: "openai",
|
|
modelId: "gpt-5.4",
|
|
extraParams: {
|
|
fastMode: true,
|
|
serviceTier: "priority",
|
|
textVerbosity: "low",
|
|
},
|
|
} as never);
|
|
const result = runWrappedPayloadCase({
|
|
wrap: provider.wrapStreamFn as NonNullable<typeof provider.wrapStreamFn>,
|
|
provider: "openai",
|
|
modelId: "gpt-5.4",
|
|
extraParams: extraParams ?? undefined,
|
|
model: {
|
|
api: "openai-responses",
|
|
provider: "openai",
|
|
id: "gpt-5.4",
|
|
baseUrl: "https://api.openai.com/v1",
|
|
} as Model<"openai-responses">,
|
|
payload: {
|
|
reasoning: { effort: "none" },
|
|
},
|
|
});
|
|
|
|
expect(extraParams).toMatchObject({
|
|
transport: "auto",
|
|
openaiWsWarmup: true,
|
|
});
|
|
expect(result.payload.service_tier).toBe("priority");
|
|
expect(result.payload.text).toEqual({ verbosity: "low" });
|
|
expect(result.payload.reasoning).toEqual({ effort: "none" });
|
|
});
|
|
|
|
it("owns Azure OpenAI reasoning compatibility without forcing OpenAI transport defaults", () => {
|
|
const provider = buildOpenAIProvider();
|
|
const result = runWrappedPayloadCase({
|
|
wrap: provider.wrapStreamFn as NonNullable<typeof provider.wrapStreamFn>,
|
|
provider: "azure-openai-responses",
|
|
modelId: "gpt-5.4",
|
|
model: {
|
|
api: "azure-openai-responses",
|
|
provider: "azure-openai-responses",
|
|
id: "gpt-5.4",
|
|
baseUrl: "https://example.openai.azure.com/openai/v1",
|
|
} as Model<"azure-openai-responses">,
|
|
payload: {
|
|
reasoning: { effort: "none" },
|
|
},
|
|
});
|
|
|
|
expect(result.options?.transport).toBeUndefined();
|
|
expect(result.options?.openaiWsWarmup).toBeUndefined();
|
|
expect(result.payload.reasoning).toEqual({ effort: "none" });
|
|
});
|
|
|
|
it("owns Codex wrapper composition for responses payloads", () => {
|
|
const provider = buildOpenAICodexProviderPlugin();
|
|
const result = runWrappedPayloadCase({
|
|
wrap: provider.wrapStreamFn as NonNullable<typeof provider.wrapStreamFn>,
|
|
provider: "openai-codex",
|
|
modelId: "gpt-5.4",
|
|
extraParams: {
|
|
fastMode: true,
|
|
serviceTier: "priority",
|
|
text_verbosity: "high",
|
|
},
|
|
cfg: {
|
|
auth: {
|
|
profiles: {
|
|
"openai-codex:default": {
|
|
provider: "openai-codex",
|
|
mode: "oauth",
|
|
},
|
|
},
|
|
},
|
|
tools: {
|
|
web: {
|
|
search: {
|
|
enabled: true,
|
|
openaiCodex: {
|
|
enabled: true,
|
|
mode: "live",
|
|
allowedDomains: ["example.com"],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
model: {
|
|
api: "openai-codex-responses",
|
|
provider: "openai-codex",
|
|
id: "gpt-5.4",
|
|
baseUrl: "https://chatgpt.com/backend-api",
|
|
} as Model<"openai-codex-responses">,
|
|
payload: {
|
|
store: false,
|
|
text: { verbosity: "medium" },
|
|
tools: [{ type: "function", name: "read" }],
|
|
},
|
|
});
|
|
|
|
expect(result.payload.store).toBe(false);
|
|
expect(result.payload.service_tier).toBe("priority");
|
|
expect(result.payload.text).toEqual({ verbosity: "high" });
|
|
expect(result.payload.tools).toEqual([
|
|
{ type: "function", name: "read" },
|
|
{
|
|
type: "web_search",
|
|
external_web_access: true,
|
|
filters: { allowed_domains: ["example.com"] },
|
|
},
|
|
]);
|
|
});
|
|
it("falls back to cached codex oauth credentials on accountId extraction failures", async () => {
|
|
const provider = buildOpenAICodexProviderPlugin();
|
|
const credential = {
|
|
type: "oauth" as const,
|
|
provider: "openai-codex",
|
|
access: "cached-access-token",
|
|
refresh: "refresh-token",
|
|
expires: Date.now() - 60_000,
|
|
};
|
|
|
|
refreshOpenAICodexTokenMock.mockReset();
|
|
refreshOpenAICodexTokenMock.mockRejectedValueOnce(
|
|
new Error("Failed to extract accountId from token"),
|
|
);
|
|
|
|
await expect(provider.refreshOAuth?.(credential)).resolves.toEqual(credential);
|
|
});
|
|
});
|