fix: provider dead exports no longer block changed checks (#108592)

* fix(ci): clean provider dead exports

* test(extensions): satisfy provider contract types

* refactor(openai): tighten provider runtime factory
This commit is contained in:
Peter Steinberger
2026-07-15 22:33:54 -07:00
committed by GitHub
parent 527711d27c
commit e7cba0e4d5
58 changed files with 1041 additions and 985 deletions

View File

@@ -8,11 +8,6 @@ import type {
ContextEngineHostCapability,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import type {
CodexAppServerListModelsOptions,
CodexAppServerModel,
CodexAppServerModelListResult,
} from "./src/app-server/models.js";
import type { CodexAppServerBindingStore } from "./src/app-server/session-binding.js";
// `codex` is legacy input only until Part 2 doctor migration rewrites stored refs.
@@ -29,9 +24,6 @@ const CODEX_APP_SERVER_CONTEXT_ENGINE_HOST_CAPABILITIES = [
"thread-bootstrap-projection",
] as const satisfies readonly ContextEngineHostCapability[];
/** Public model-listing types exposed for Codex app-server catalog callers. */
export type { CodexAppServerListModelsOptions, CodexAppServerModel, CodexAppServerModelListResult };
type CodexAppServerAgentHarness = AgentHarness & {
compactAfterContextEngine?(
params: AgentHarnessCompactParams,

View File

@@ -23,7 +23,7 @@ const CODEX_MEDIA_PROVIDER_ID = "codex";
const DEFAULT_CODEX_IMAGE_MODEL = "gpt-5.6-sol";
const DEFAULT_CODEX_IMAGE_PROMPT = "Describe the image.";
export type CodexMediaUnderstandingProviderOptions = CodexBoundedTurnOptions;
type CodexMediaUnderstandingProviderOptions = CodexBoundedTurnOptions;
/**
* Builds the media-understanding provider that delegates image tasks to an

View File

@@ -13,7 +13,7 @@ import { readCodexModelListResponse } from "./protocol-validators.js";
import type { CodexModel, CodexReasoningEffortOption } from "./protocol.js";
/** Normalized model metadata returned by the Codex app-server model listing helper. */
export type CodexAppServerModel = {
type CodexAppServerModel = {
id: string;
model: string;
displayName?: string;
@@ -33,7 +33,7 @@ export type CodexAppServerModelListResult = {
};
/** Options for querying Codex app-server models through a shared or isolated client. */
export type CodexAppServerListModelsOptions = {
type CodexAppServerListModelsOptions = {
limit?: number;
cursor?: string;
includeHidden?: boolean;

View File

@@ -2,8 +2,7 @@
import { stream as streamModel, type AssistantMessage, type Model } from "openclaw/plugin-sdk/llm";
import { describe, expect, it } from "vitest";
import { resolveFirstGithubToken } from "./auth.js";
import { buildCopilotDynamicHeaders } from "./stream.js";
import { wrapCopilotOpenAIResponsesStream } from "./stream.js";
import { wrapCopilotProviderStream } from "./stream.js";
import { resolveCopilotApiToken } from "./token.js";
const LIVE =
@@ -196,7 +195,7 @@ describeLive("github-copilot connection-bound Responses IDs live", () => {
};
let capturedPayload: Record<string, unknown> | undefined;
const wrappedStream = wrapCopilotOpenAIResponsesStream(streamModel as never);
const wrappedStream = wrapCopilotProviderStream({ streamFn: streamModel } as never);
if (!wrappedStream) {
throw new Error("expected Copilot Responses stream wrapper");
}
@@ -205,10 +204,6 @@ describeLive("github-copilot connection-bound Responses IDs live", () => {
context as never,
{
apiKey: token.token,
headers: buildCopilotDynamicHeaders({
messages: context.messages,
hasImages: false,
}),
maxTokens: 32,
onPayload: (payload: unknown) => {
capturedPayload = payload as Record<string, unknown>;

View File

@@ -1,10 +1,10 @@
// Github Copilot tests cover connection bound ids plugin behavior.
import { describe, expect, it } from "vitest";
import {
rewriteCopilotConnectionBoundResponseIds,
rewriteCopilotResponsePayloadConnectionBoundIds,
sanitizeCopilotReplayResponseIds,
} from "./connection-bound-ids.js";
import { rewriteCopilotResponsePayloadConnectionBoundIds } from "./connection-bound-ids.js";
function rewriteInputIds(input: unknown): boolean {
return rewriteCopilotResponsePayloadConnectionBoundIds({ input });
}
describe("github-copilot connection-bound response IDs", () => {
it("rewrites opaque message response item IDs deterministically", () => {
@@ -12,8 +12,8 @@ describe("github-copilot connection-bound response IDs", () => {
const first = [{ id: originalId, type: "message" }];
const second = [{ id: originalId, type: "message" }];
expect(rewriteCopilotConnectionBoundResponseIds(first)).toBe(true);
expect(rewriteCopilotConnectionBoundResponseIds(second)).toBe(true);
expect(rewriteInputIds(first)).toBe(true);
expect(rewriteInputIds(second)).toBe(true);
expect(first[0]?.id).toMatch(/^msg_[a-f0-9]{16}$/);
expect(first[0]?.id).toBe(second[0]?.id);
});
@@ -29,7 +29,7 @@ describe("github-copilot connection-bound response IDs", () => {
{ id: messageId, type: "message" },
];
expect(rewriteCopilotConnectionBoundResponseIds(input)).toBe(true);
expect(rewriteInputIds(input)).toBe(true);
expect(input[0]?.id).toBe("rs_existing");
expect(input[1]?.id).toBe("msg_existing");
expect(input[2]?.id).toBe("fc_existing");
@@ -47,7 +47,7 @@ describe("github-copilot connection-bound response IDs", () => {
{ id: withoutField, type: "reasoning" },
];
expect(rewriteCopilotConnectionBoundResponseIds(input)).toBe(false);
expect(rewriteInputIds(input)).toBe(false);
expect(input[0]?.id).toBe(withEncrypted);
expect(input[1]?.id).toBe(withNull);
expect(input[2]?.id).toBe(withoutField);
@@ -61,7 +61,7 @@ describe("github-copilot connection-bound response IDs", () => {
{ id: withoutEncrypted, type: "reasoning" },
];
expect(sanitizeCopilotReplayResponseIds(input)).toBe(false);
expect(rewriteInputIds(input)).toBe(false);
expect(input.map((item) => item.id)).toEqual([withEncrypted, withoutEncrypted]);
});
@@ -79,7 +79,7 @@ describe("github-copilot connection-bound response IDs", () => {
{ id: "rs_valid", type: "reasoning", encrypted_content: "valid", summary: [] },
];
expect(sanitizeCopilotReplayResponseIds(input)).toBe(true);
expect(rewriteInputIds(input)).toBe(true);
expect(input).toEqual([
{ type: "reasoning", encrypted_content: "missing-id", summary: [] },
{ id: "rs_valid", type: "reasoning", encrypted_content: "valid", summary: [] },

View File

@@ -34,7 +34,7 @@ function isValidReasoningReplayId(id: unknown): id is string {
return typeof id === "string" && id.length > 0 && id.length <= 64;
}
export function sanitizeCopilotReplayResponseIds(input: unknown): boolean {
function sanitizeCopilotReplayResponseIds(input: unknown): boolean {
if (!Array.isArray(input)) {
return false;
}
@@ -66,10 +66,6 @@ export function sanitizeCopilotReplayResponseIds(input: unknown): boolean {
return rewrote;
}
export function rewriteCopilotConnectionBoundResponseIds(input: unknown): boolean {
return sanitizeCopilotReplayResponseIds(input);
}
function sanitizeCopilotReplayResponsePayloadIds(payload: unknown): boolean {
if (!payload || typeof payload !== "object") {
return false;

View File

@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest";
import { PUBLIC_GITHUB_COPILOT_DOMAIN, resolveGithubCopilotDomain } from "./domain.js";
import {
PUBLIC_GITHUB_COPILOT_DOMAIN,
resolveGithubCopilotDomain,
withGithubCopilotDomainConfig,
} from "./domain.js";
describe("github-copilot domain resolution", () => {
const withDomain = (githubDomain: string) =>
@@ -36,3 +40,32 @@ describe("github-copilot domain resolution", () => {
);
});
});
describe("withGithubCopilotDomainConfig", () => {
const tenantConfig = {
models: {
providers: { "github-copilot": { params: { githubDomain: "acme.ghe.com" } } },
},
} as never;
it("persists the tenant domain when login minted a tenant token", () => {
const next = withGithubCopilotDomainConfig({} as never, "acme.ghe.com");
expect(
(next as { models?: { providers?: Record<string, { params?: Record<string, unknown> }> } })
.models?.providers?.["github-copilot"]?.params?.githubDomain,
).toBe("acme.ghe.com");
});
it("clears a stale tenant domain after public login", () => {
const next = withGithubCopilotDomainConfig(tenantConfig, "github.com");
const params = (
next as { models?: { providers?: Record<string, { params?: Record<string, unknown> }> } }
).models?.providers?.["github-copilot"]?.params;
expect(params && "githubDomain" in params).toBe(false);
});
it("leaves config untouched for public login without persisted domain", () => {
const cfg = {} as never;
expect(withGithubCopilotDomainConfig(cfg, "github.com")).toBe(cfg);
});
});

View File

@@ -38,3 +38,37 @@ export function resolveGithubCopilotDomain(params?: {
}
return normalizeGithubCopilotDomain(readConfiguredGithubCopilotDomain(params?.config));
}
// Shortcut login must persist its token's tenant. A missing domain would route
// the tenant token back to github.com after the environment override is removed.
export function withGithubCopilotDomainConfig(cfg: OpenClawConfig, domain: string): OpenClawConfig {
const models: NonNullable<OpenClawConfig["models"]> = cfg.models ?? {};
const providers: NonNullable<typeof models.providers> = models.providers ?? {};
const provider = providers["github-copilot"];
const params = provider?.params;
const isDefault = domain === PUBLIC_GITHUB_COPILOT_DOMAIN;
if (isDefault && !(params && "githubDomain" in params)) {
return cfg;
}
const nextParams: Record<string, unknown> = { ...params };
if (isDefault) {
delete nextParams.githubDomain;
} else {
nextParams.githubDomain = domain;
}
const nextProviders = { ...providers };
if (provider) {
nextProviders["github-copilot"] = { ...provider, params: nextParams };
} else {
// Source config accepts partial provider inputs; catalog materialization
// supplies baseUrl/models before runtime consumption.
Object.assign(nextProviders, { "github-copilot": { params: nextParams } });
}
return {
...cfg,
models: {
...models,
providers: nextProviders,
},
};
}

View File

@@ -17,16 +17,15 @@ import type {
UnifiedModelCatalogEntry,
} from "openclaw/plugin-sdk/plugin-entry";
import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api";
import type { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
import {
runGitHubCopilotDeviceFlow,
setGitHubCopilotDeviceFlowFetchGuardForTesting,
} from "./login.js";
import { runGitHubCopilotDeviceFlow } from "./login.js";
const mocks = vi.hoisted(() => ({
githubCopilotLoginCommand: vi.fn(),
fetchWithSsrFGuard: vi.fn(async (params: { url: string; init?: RequestInit }) => ({
fetchWithSsrFGuard: vi.fn<typeof fetchWithSsrFGuard>(async (params) => ({
response: await fetch(params.url, params.init),
finalUrl: params.url,
release: vi.fn(async () => {}),
})),
resolveCopilotApiToken: vi.fn(),
@@ -85,7 +84,11 @@ type GithubCopilotTestModelCatalogProvider = {
afterEach(async () => {
vi.clearAllMocks();
vi.unstubAllGlobals();
setGitHubCopilotDeviceFlowFetchGuardForTesting(null);
mocks.fetchWithSsrFGuard.mockImplementation(async (params) => ({
response: await fetch(params.url, params.init),
finalUrl: params.url,
release: vi.fn(async () => {}),
}));
clearRuntimeAuthProfileStoreSnapshots();
await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
});
@@ -730,7 +733,7 @@ describe("github-copilot plugin", () => {
throw new Error(`unexpected fetch in github-copilot refresh test: ${target}`);
});
vi.stubGlobal("fetch", fetchMock);
setGitHubCopilotDeviceFlowFetchGuardForTesting(async (params) => ({
mocks.fetchWithSsrFGuard.mockImplementation(async (params) => ({
response: await fetchMock(params.url, params.init),
finalUrl: params.url,
release: async () => {},
@@ -838,7 +841,7 @@ describe("github-copilot plugin", () => {
writeExistingCopilotTokenProfile(agentDir);
const fetchMock = buildDeviceFlowFetchMock("github.com", "public-fresh-token");
vi.stubGlobal("fetch", fetchMock);
setGitHubCopilotDeviceFlowFetchGuardForTesting(async (params) => ({
mocks.fetchWithSsrFGuard.mockImplementation(async (params) => ({
response: await fetchMock(params.url, params.init),
finalUrl: params.url,
release: async () => {},
@@ -898,7 +901,7 @@ describe("github-copilot plugin", () => {
writeExistingCopilotTokenProfile(agentDir);
const fetchMock = buildDeviceFlowFetchMock("acme.ghe.com", "tenant-fresh-token");
vi.stubGlobal("fetch", fetchMock);
setGitHubCopilotDeviceFlowFetchGuardForTesting(async (params) => ({
mocks.fetchWithSsrFGuard.mockImplementation(async (params) => ({
response: await fetchMock(params.url, params.init),
finalUrl: params.url,
release: async () => {},
@@ -956,7 +959,7 @@ describe("github-copilot plugin", () => {
// the domain change is detected and the public token is not reused.
const fetchMock = buildDeviceFlowFetchMock("acme.ghe.com", "tenant-fresh-token");
vi.stubGlobal("fetch", fetchMock);
setGitHubCopilotDeviceFlowFetchGuardForTesting(async (params) => ({
mocks.fetchWithSsrFGuard.mockImplementation(async (params) => ({
response: await fetchMock(params.url, params.init),
finalUrl: params.url,
release: async () => {},
@@ -1062,7 +1065,7 @@ describe("github-copilot plugin", () => {
// prompt value instead, the fetch mock would throw on an unexpected host.
const fetchMock = buildDeviceFlowFetchMock("env-tenant.ghe.com", "env-tenant-token");
vi.stubGlobal("fetch", fetchMock);
setGitHubCopilotDeviceFlowFetchGuardForTesting(async (params) => ({
mocks.fetchWithSsrFGuard.mockImplementation(async (params) => ({
response: await fetchMock(params.url, params.init),
finalUrl: params.url,
release: async () => {},
@@ -1112,7 +1115,7 @@ describe("github-copilot plugin", () => {
it("rejects unsafe GitHub device code lifetimes before polling", async () => {
const release = vi.fn(async () => {});
setGitHubCopilotDeviceFlowFetchGuardForTesting(async () => ({
mocks.fetchWithSsrFGuard.mockImplementation(async () => ({
response: new Response(
'{"device_code":"device-code-stub","user_code":"ABCD-1234","verification_uri":"https://github.com/login/device","expires_in":1e309,"interval":0}',
{ status: 200, headers: { "Content-Type": "application/json" } },
@@ -1132,7 +1135,7 @@ describe("github-copilot plugin", () => {
it("rejects GitHub device code expiries outside the Date timestamp range before polling", async () => {
const release = vi.fn(async () => {});
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(MAX_DATE_TIMESTAMP_MS);
setGitHubCopilotDeviceFlowFetchGuardForTesting(async () => ({
mocks.fetchWithSsrFGuard.mockImplementation(async () => ({
response: new Response(
'{"device_code":"device-code-stub","user_code":"ABCD-1234","verification_uri":"https://github.com/login/device","expires_in":1,"interval":0}',
{ status: 200, headers: { "Content-Type": "application/json" } },
@@ -1159,7 +1162,7 @@ describe("github-copilot plugin", () => {
const release = vi.fn(async () => {});
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
let accessTokenPolls = 0;
setGitHubCopilotDeviceFlowFetchGuardForTesting(async (params) => {
mocks.fetchWithSsrFGuard.mockImplementation(async (params) => {
if (params.url === "https://github.com/login/device/code") {
return {
response: new Response(

View File

@@ -1,10 +1,18 @@
// Github Copilot tests cover device-flow login behavior.
import { afterEach, describe, expect, it, vi } from "vitest";
import {
runGitHubCopilotDeviceFlow,
setGitHubCopilotDeviceFlowFetchGuardForTesting,
withGithubCopilotDomainConfig,
} from "./login.js";
const mocks = vi.hoisted(() => ({
fetchWithSsrFGuard: vi.fn(),
}));
vi.mock("openclaw/plugin-sdk/ssrf-runtime", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/ssrf-runtime")>(
"openclaw/plugin-sdk/ssrf-runtime",
);
return { ...actual, fetchWithSsrFGuard: mocks.fetchWithSsrFGuard };
});
import { runGitHubCopilotDeviceFlow } from "./login.js";
const DEVICE_CODE_URL = "https://github.com/login/device/code";
const ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token";
@@ -30,7 +38,7 @@ function guardResponse(body: unknown, status = 200, url = DEVICE_CODE_URL) {
}
afterEach(() => {
setGitHubCopilotDeviceFlowFetchGuardForTesting(null);
mocks.fetchWithSsrFGuard.mockReset();
vi.restoreAllMocks();
});
@@ -39,7 +47,7 @@ describe("runGitHubCopilotDeviceFlow — normal flow", () => {
let callIdx = 0;
const requestTimeouts: Array<number | undefined> = [];
const controller = new AbortController();
setGitHubCopilotDeviceFlowFetchGuardForTesting(async (params) => {
mocks.fetchWithSsrFGuard.mockImplementation(async (params) => {
callIdx += 1;
requestTimeouts.push(params.timeoutMs);
expect(params.signal).toBe(controller.signal);
@@ -70,7 +78,7 @@ describe("runGitHubCopilotDeviceFlow — normal flow", () => {
it("returns access_denied when GitHub rejects the authorization", async () => {
let callIdx = 0;
setGitHubCopilotDeviceFlowFetchGuardForTesting(async () => {
mocks.fetchWithSsrFGuard.mockImplementation(async () => {
callIdx += 1;
if (callIdx === 1) {
return guardResponse(VALID_DEVICE_CODE_BODY);
@@ -86,7 +94,7 @@ describe("runGitHubCopilotDeviceFlow — normal flow", () => {
it("returns expired when GitHub reports expired_token", async () => {
let callIdx = 0;
setGitHubCopilotDeviceFlowFetchGuardForTesting(async () => {
mocks.fetchWithSsrFGuard.mockImplementation(async () => {
callIdx += 1;
if (callIdx === 1) {
return guardResponse(VALID_DEVICE_CODE_BODY);
@@ -103,7 +111,7 @@ describe("runGitHubCopilotDeviceFlow — normal flow", () => {
describe("runGitHubCopilotDeviceFlow — HTTP error propagation", () => {
it("throws with failureLabel on non-OK device code response", async () => {
setGitHubCopilotDeviceFlowFetchGuardForTesting(async () => guardResponse({}, 401));
mocks.fetchWithSsrFGuard.mockImplementation(async () => guardResponse({}, 401));
await expect(runGitHubCopilotDeviceFlow({ showCode: vi.fn() })).rejects.toThrow(
"GitHub device code failed: HTTP 401",
@@ -112,7 +120,7 @@ describe("runGitHubCopilotDeviceFlow — HTTP error propagation", () => {
it("throws with failureLabel on non-OK access token response", async () => {
let callIdx = 0;
setGitHubCopilotDeviceFlowFetchGuardForTesting(async () => {
mocks.fetchWithSsrFGuard.mockImplementation(async () => {
callIdx += 1;
if (callIdx === 1) {
return guardResponse(VALID_DEVICE_CODE_BODY);
@@ -146,7 +154,7 @@ describe("postGitHubDeviceFlowForm — response size bound", () => {
},
});
setGitHubCopilotDeviceFlowFetchGuardForTesting(async () => ({
mocks.fetchWithSsrFGuard.mockImplementation(async () => ({
response: new Response(oversizedBody, {
status: 200,
headers: { "Content-Type": "application/json" },
@@ -170,7 +178,7 @@ describe("postGitHubDeviceFlowForm — response size bound", () => {
let canceled = false;
let callIdx = 0;
setGitHubCopilotDeviceFlowFetchGuardForTesting(async () => {
mocks.fetchWithSsrFGuard.mockImplementation(async () => {
callIdx += 1;
if (callIdx === 1) {
return guardResponse(VALID_DEVICE_CODE_BODY);
@@ -219,7 +227,7 @@ describe("runGitHubCopilotDeviceFlow — data-residency GitHub Enterprise", () =
const urls: string[] = [];
let callIdx = 0;
setGitHubCopilotDeviceFlowFetchGuardForTesting(async (params) => {
mocks.fetchWithSsrFGuard.mockImplementation(async (params) => {
urls.push(params.url);
callIdx += 1;
if (callIdx === 1) {
@@ -250,7 +258,7 @@ describe("runGitHubCopilotDeviceFlow — data-residency GitHub Enterprise", () =
});
it("rejects a verification URL whose host does not match the configured domain", async () => {
setGitHubCopilotDeviceFlowFetchGuardForTesting(async () =>
mocks.fetchWithSsrFGuard.mockImplementation(async () =>
guardResponse(
{ ...VALID_DEVICE_CODE_BODY, verification_uri: "https://github.com/login/device" },
200,
@@ -263,32 +271,3 @@ describe("runGitHubCopilotDeviceFlow — data-residency GitHub Enterprise", () =
).rejects.toThrow("unexpected verification URL");
});
});
describe("withGithubCopilotDomainConfig — shortcut login domain persistence", () => {
const tenantConfig = {
models: {
providers: { "github-copilot": { params: { githubDomain: "acme.ghe.com" } } },
},
} as never;
it("persists the tenant domain when the shortcut minted a tenant token", () => {
const next = withGithubCopilotDomainConfig({} as never, "acme.ghe.com");
expect(
(next as { models?: { providers?: Record<string, { params?: Record<string, unknown> }> } })
.models?.providers?.["github-copilot"]?.params?.githubDomain,
).toBe("acme.ghe.com");
});
it("clears a stale tenant domain when the shortcut logged in against github.com", () => {
const next = withGithubCopilotDomainConfig(tenantConfig, "github.com");
const params = (
next as { models?: { providers?: Record<string, { params?: Record<string, unknown> }> } }
).models?.providers?.["github-copilot"]?.params;
expect(params && "githubDomain" in params).toBe(false);
});
it("leaves config untouched for a public login with no persisted domain", () => {
const cfg = {} as never;
expect(withGithubCopilotDomainConfig(cfg, "github.com")).toBe(cfg);
});
});

View File

@@ -1,7 +1,6 @@
// Github Copilot plugin module implements login behavior.
import { intro, note, outro, spinner } from "@clack/prompts";
import { stylePromptTitle } from "openclaw/plugin-sdk/cli-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { logConfigUpdated, updateConfig } from "openclaw/plugin-sdk/config-mutation";
import {
resolveExpiresAtMsFromDurationMs,
@@ -18,7 +17,11 @@ import {
import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime";
import { fetchWithSsrFGuard, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
import { PUBLIC_GITHUB_COPILOT_DOMAIN, resolveGithubCopilotDomain } from "./domain.js";
import {
PUBLIC_GITHUB_COPILOT_DOMAIN,
resolveGithubCopilotDomain,
withGithubCopilotDomainConfig,
} from "./domain.js";
const CLIENT_ID = "Iv1.b507a08c87ecfe98";
const GITHUB_DEVICE_FLOW_REQUEST_TIMEOUT_MS = 30_000;
@@ -68,14 +71,6 @@ class GitHubDeviceFlowError extends Error {
}
}
let githubDeviceFlowFetchGuard = fetchWithSsrFGuard;
export function setGitHubCopilotDeviceFlowFetchGuardForTesting(
impl: typeof fetchWithSsrFGuard | null,
): void {
githubDeviceFlowFetchGuard = impl ?? fetchWithSsrFGuard;
}
async function upsertAuthProfileWithLockOrThrow(params: UpsertAuthProfileParams): Promise<void> {
const updated = await upsertAuthProfileWithLock(params);
if (!updated) {
@@ -142,7 +137,7 @@ async function postGitHubDeviceFlowForm(params: {
domain: string;
signal?: AbortSignal;
}): Promise<Record<string, unknown>> {
const { response, release } = await githubDeviceFlowFetchGuard({
const { response, release } = await fetchWithSsrFGuard({
url: params.url,
init: {
method: "POST",
@@ -357,46 +352,6 @@ export async function runGitHubCopilotDeviceFlow(
}
}
// The shortcut login mints its token against the resolved domain, so the same
// domain must land in persisted config: a tenant token with no stored
// githubDomain would silently route to github.com (and 401) once
// COPILOT_GITHUB_DOMAIN is unset. Mirrors the enterprise auth method's
// persist-on-tenant / clear-on-public behavior.
export function withGithubCopilotDomainConfig(cfg: OpenClawConfig, domain: string): OpenClawConfig {
// Normalize the optional layers to concrete objects before spreading:
// spreading a possibly-undefined object widens every optional property to
// `T | undefined`, which exactOptionalPropertyTypes rejects.
const models: NonNullable<OpenClawConfig["models"]> = cfg.models ?? {};
const providers: NonNullable<typeof models.providers> = models.providers ?? {};
const provider = providers["github-copilot"];
const params = provider?.params;
const isDefault = domain === PUBLIC_GITHUB_COPILOT_DOMAIN;
if (isDefault && !(params && "githubDomain" in params)) {
return cfg;
}
const nextParams: Record<string, unknown> = { ...params };
if (isDefault) {
delete nextParams.githubDomain;
} else {
nextParams.githubDomain = domain;
}
const nextProviders = { ...providers };
if (provider) {
nextProviders["github-copilot"] = { ...provider, params: nextParams };
} else {
// Source config accepts partial provider inputs; catalog materialization
// supplies baseUrl/models before runtime consumption.
Object.assign(nextProviders, { "github-copilot": { params: nextParams } });
}
return {
...cfg,
models: {
...models,
providers: nextProviders,
},
};
}
export async function githubCopilotLoginCommand(
opts: { profileId?: string; yes?: boolean; agentDir?: string },
runtime: RuntimeEnv,

View File

@@ -2,10 +2,11 @@
import { createHash } from "node:crypto";
import { expectDefined } from "@openclaw/normalization-core";
import type { PluginStateSyncKeyedStore } from "openclaw/plugin-sdk/plugin-state-runtime";
import { deriveCopilotApiBaseUrlFromToken } from "openclaw/plugin-sdk/provider-auth";
import { createProviderUsageFetch, makeResponse } from "openclaw/plugin-sdk/test-env";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { CachedCopilotToken } from "./token.js";
import { deriveCopilotApiBaseUrlFromToken, resolveCopilotApiToken } from "./token.js";
import type { CachedCopilotToken } from "./token-cache.js";
import { resolveCopilotApiToken } from "./token.js";
import { fetchCopilotUsage } from "./usage.js";
vi.mock("openclaw/plugin-sdk/provider-model-shared", async (importOriginal) => ({

View File

@@ -1,12 +1,8 @@
// Github Copilot tests cover stream plugin behavior.
import type { Context } from "openclaw/plugin-sdk/llm";
import { buildCopilotIdeHeaders, COPILOT_INTEGRATION_ID } from "openclaw/plugin-sdk/provider-auth";
import { describe, expect, it, vi } from "vitest";
import { buildCopilotDynamicHeaders } from "./stream.js";
import {
wrapCopilotAnthropicStream,
wrapCopilotOpenAICompletionsStream,
wrapCopilotOpenAIResponsesStream,
wrapCopilotProviderStream,
} from "./stream.js";
import { wrapCopilotAnthropicStream, wrapCopilotProviderStream } from "./stream.js";
function requireStreamFn(streamFn: ReturnType<typeof wrapCopilotProviderStream>) {
expect(streamFn).toBeTypeOf("function");
@@ -28,6 +24,19 @@ function requireFirstStreamOptions(mock: ReturnType<typeof vi.fn>, label: string
return options as { headers?: Record<string, unknown>; onPayload?: unknown };
}
function buildExpectedCopilotHeaders(
initiator: "agent" | "user",
hasImages: boolean,
): Record<string, string> {
return {
...buildCopilotIdeHeaders(),
"Copilot-Integration-Id": COPILOT_INTEGRATION_ID,
"Openai-Organization": "github-copilot",
"x-initiator": initiator,
...(hasImages ? { "Copilot-Vision-Request": "true" } : {}),
};
}
describe("wrapCopilotAnthropicStream", () => {
it("adds Copilot headers, strips thinking replay, and marks cache for Claude payloads", () => {
const payloads: Array<{
@@ -63,12 +72,9 @@ describe("wrapCopilotAnthropicStream", () => {
{ type: "image", image: "data:image/png;base64,abc" },
],
},
] as Parameters<typeof buildCopilotDynamicHeaders>[0]["messages"];
] as Context["messages"];
const context = { messages };
const expectedCopilotHeaders = buildCopilotDynamicHeaders({
messages,
hasImages: true,
});
const expectedCopilotHeaders = buildExpectedCopilotHeaders("user", true);
expect(expectedCopilotHeaders["Accept-Encoding"]).toBe("identity");
void wrapped(
@@ -192,7 +198,7 @@ describe("wrapCopilotAnthropicStream", () => {
} as never;
});
const wrapped = requireStreamFn(wrapCopilotOpenAIResponsesStream(baseStreamFn));
const wrapped = requireStreamFn(wrapCopilotProviderStream({ streamFn: baseStreamFn } as never));
const messages = [
{
role: "toolResult",
@@ -201,11 +207,8 @@ describe("wrapCopilotAnthropicStream", () => {
{ type: "image", image: "data:image/png;base64,abc" },
],
},
] as Parameters<typeof buildCopilotDynamicHeaders>[0]["messages"];
const expectedCopilotHeaders = buildCopilotDynamicHeaders({
messages,
hasImages: true,
});
] as Context["messages"];
const expectedCopilotHeaders = buildExpectedCopilotHeaders("agent", true);
void wrapped(
{
@@ -249,7 +252,7 @@ describe("wrapCopilotAnthropicStream", () => {
} as never;
});
const wrapped = requireStreamFn(wrapCopilotOpenAIResponsesStream(baseStreamFn));
const wrapped = requireStreamFn(wrapCopilotProviderStream({ streamFn: baseStreamFn } as never));
await wrapped(
{
@@ -270,7 +273,7 @@ describe("wrapCopilotAnthropicStream", () => {
it("adds Copilot headers for Chat Completions models", () => {
const baseStreamFn = vi.fn(() => ({ async *[Symbol.asyncIterator]() {} }) as never);
const wrapped = requireStreamFn(wrapCopilotOpenAICompletionsStream(baseStreamFn));
const wrapped = requireStreamFn(wrapCopilotProviderStream({ streamFn: baseStreamFn } as never));
const messages = [
{
role: "user",
@@ -279,11 +282,8 @@ describe("wrapCopilotAnthropicStream", () => {
{ type: "image", data: "abc", mimeType: "image/png" },
],
},
] as Parameters<typeof buildCopilotDynamicHeaders>[0]["messages"];
const expectedCopilotHeaders = buildCopilotDynamicHeaders({
messages,
hasImages: true,
});
] as Context["messages"];
const expectedCopilotHeaders = buildExpectedCopilotHeaders("user", true);
void wrapped(
{

View File

@@ -46,7 +46,7 @@ function hasCopilotVisionInput(messages: Context["messages"]): boolean {
});
}
export function buildCopilotDynamicHeaders(params: {
function buildCopilotDynamicHeaders(params: {
messages: Context["messages"];
hasImages: boolean;
}): Record<string, string> {
@@ -115,7 +115,7 @@ export function wrapCopilotAnthropicStream(
};
}
export function wrapCopilotOpenAIResponsesStream(
function wrapCopilotOpenAIResponsesStream(
baseStreamFn: StreamFn | undefined,
): StreamFn | undefined {
if (!baseStreamFn) {
@@ -140,7 +140,7 @@ export function wrapCopilotOpenAIResponsesStream(
};
}
export function wrapCopilotOpenAICompletionsStream(
function wrapCopilotOpenAICompletionsStream(
baseStreamFn: StreamFn | undefined,
): StreamFn | undefined {
if (!baseStreamFn) {

View File

@@ -19,9 +19,6 @@ import {
resolveCopilotTokenCache,
type CachedCopilotToken,
} from "./token-cache.js";
export { deriveCopilotApiBaseUrlFromToken } from "openclaw/plugin-sdk/provider-auth";
export type { CachedCopilotToken } from "./token-cache.js";
export const DEFAULT_COPILOT_API_BASE_URL = "https://api.individual.githubcopilot.com";
const COPILOT_TOKEN_EXCHANGE_TIMEOUT_MS = 30_000;
let openConfiguredCacheStore: (() => PluginStateSyncKeyedStore<CachedCopilotToken>) | undefined;
@@ -87,8 +84,6 @@ async function cancelUnreadResponseBody(response: Response): Promise<void> {
}
}
export type ResolveCopilotApiToken = typeof resolveCopilotApiToken;
export async function resolveCopilotApiToken(params: {
githubToken: string;
env?: NodeJS.ProcessEnv;

View File

@@ -1,7 +1,7 @@
// Openai tests cover embedding batch plugin behavior.
import { createServer } from "node:http";
import { describe, expect, it, vi } from "vitest";
import { parseOpenAiBatchOutput, runOpenAiEmbeddingBatches } from "./embedding-batch.js";
import { runOpenAiEmbeddingBatches } from "./embedding-batch.js";
const jsonlEncoder = new TextEncoder();
@@ -83,12 +83,6 @@ async function closeServer(server: ReturnType<typeof createServer>): Promise<voi
}
describe("OpenAI embedding batch output", () => {
it("wraps malformed JSONL output", () => {
expect(() => parseOpenAiBatchOutput('{"custom_id":"ok"}\n{not json')).toThrow(
"OpenAI embedding batch output contained malformed JSONL",
);
});
it("reads a completed error file before downloading successful output", async () => {
let outputFetched = false;
const fetchImpl = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {

View File

@@ -174,7 +174,7 @@ function isOpenAiBatchUploadTooLargeError(error: unknown): boolean {
);
}
export function parseOpenAiBatchOutput(text: string): OpenAiBatchOutputLine[] {
function parseOpenAiBatchOutput(text: string): OpenAiBatchOutputLine[] {
if (!text.trim()) {
return [];
}

View File

@@ -5,16 +5,19 @@ import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api";
import { requireRegisteredProvider } from "openclaw/plugin-sdk/plugin-test-runtime";
import * as providerAuth from "openclaw/plugin-sdk/provider-auth-runtime";
import * as providerHttp from "openclaw/plugin-sdk/provider-http";
import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared";
import {
GPT5_BEHAVIOR_CONTRACT,
GPT5_FRIENDLY_CHAT_PROMPT_OVERLAY,
GPT5_HEARTBEAT_PROMPT_OVERLAY,
type ProviderPlugin,
} from "openclaw/plugin-sdk/provider-model-shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { buildOpenAIImageGenerationProvider } from "./image-generation-provider.js";
import plugin from "./index.js";
import {
OPENAI_FRIENDLY_PROMPT_OVERLAY,
OPENAI_GPT5_BEHAVIOR_CONTRACT,
OPENAI_HEARTBEAT_PROMPT_OVERLAY,
shouldApplyOpenAIPromptOverlay,
} from "./prompt-overlay.js";
const OPENAI_FRIENDLY_PROMPT_OVERLAY = GPT5_FRIENDLY_CHAT_PROMPT_OVERLAY;
const OPENAI_GPT5_BEHAVIOR_CONTRACT = GPT5_BEHAVIOR_CONTRACT;
const OPENAI_HEARTBEAT_PROMPT_OVERLAY = GPT5_HEARTBEAT_PROMPT_OVERLAY;
const runtimeMocks = vi.hoisted(() => ({
ensureGlobalUndiciEnvProxyDispatcher: vi.fn(),
@@ -35,7 +38,7 @@ vi.mock("./openai-chatgpt-oauth-flow.runtime.js", () => ({
refreshOpenAICodexToken: runtimeMocks.refreshOpenAICodexToken,
}));
import { createOpenAICodexProviderRuntime } from "./openai-chatgpt-provider.runtime.js";
import { createOpenAICodexProviderRuntime } from "./openai-chatgpt-provider-runtime.factory.js";
async function registerOpenAIPluginWithHook(params?: { pluginConfig?: Record<string, unknown> }) {
const on = vi.fn();
const providers: ProviderPlugin[] = [];
@@ -299,7 +302,6 @@ describe("openai plugin", () => {
runtimeMocks.refreshOpenAICodexToken.mockResolvedValue(refreshed);
const runtime = createOpenAICodexProviderRuntime({
ensureGlobalUndiciEnvProxyDispatcher: runtimeMocks.ensureGlobalUndiciEnvProxyDispatcher,
getOAuthApiKey: vi.fn(),
refreshOpenAICodexToken: runtimeMocks.refreshOpenAICodexToken,
});
@@ -459,12 +461,6 @@ describe("openai plugin", () => {
modelId: "gpt-image-1",
}),
).toBeUndefined();
expect(shouldApplyOpenAIPromptOverlay({ modelProviderId: "openai", modelId: "gpt-4.1" })).toBe(
false,
);
expect(
shouldApplyOpenAIPromptOverlay({ modelProviderId: "anthropic", modelId: "gpt-5.4" }),
).toBe(false);
});
it("includes the tagged GPT-5 behavior contract in the OpenAI prompt overlay", () => {

View File

@@ -5,10 +5,7 @@ import {
installPinnedHostnameTestHooks,
} from "openclaw/plugin-sdk/test-env";
import { describe, expect, it } from "vitest";
import {
openaiMediaUnderstandingProvider,
transcribeOpenAiAudio,
} from "./media-understanding-provider.js";
import { openaiMediaUnderstandingProvider } from "./media-understanding-provider.js";
installPinnedHostnameTestHooks();
@@ -20,7 +17,7 @@ describe("openaiMediaUnderstandingProvider", () => {
audio: "gpt-4o-transcribe",
});
expect(openaiMediaUnderstandingProvider.autoPriority).toEqual({ image: 20, audio: 20 });
expect(openaiMediaUnderstandingProvider.transcribeAudio).toBe(transcribeOpenAiAudio);
expect(openaiMediaUnderstandingProvider.transcribeAudio).toBeTypeOf("function");
});
});
@@ -28,7 +25,7 @@ describe("transcribeOpenAiAudio", () => {
it("respects lowercase authorization header overrides", async () => {
const { fetchFn, getAuthHeader } = createAuthCaptureJsonFetch({ text: "ok" });
const result = await transcribeOpenAiAudio({
const result = await openaiMediaUnderstandingProvider.transcribeAudio!({
buffer: Buffer.from("audio"),
fileName: "note.mp3",
apiKey: "test-key",
@@ -44,7 +41,7 @@ describe("transcribeOpenAiAudio", () => {
it("builds the expected request payload", async () => {
const { fetchFn, getRequest } = createRequestCaptureJsonFetch({ text: "hello" });
const result = await transcribeOpenAiAudio({
const result = await openaiMediaUnderstandingProvider.transcribeAudio!({
buffer: Buffer.from("audio-bytes"),
fileName: "voice.wav",
apiKey: "test-key",
@@ -88,7 +85,7 @@ describe("transcribeOpenAiAudio", () => {
const { fetchFn } = createRequestCaptureJsonFetch({});
await expect(
transcribeOpenAiAudio({
openaiMediaUnderstandingProvider.transcribeAudio!({
buffer: Buffer.from("audio-bytes"),
fileName: "voice.wav",
apiKey: "test-key",

View File

@@ -10,7 +10,7 @@ import { OPENAI_DEFAULT_AUDIO_TRANSCRIPTION_MODEL } from "./default-models.js";
const DEFAULT_OPENAI_AUDIO_BASE_URL = "https://api.openai.com/v1";
export async function transcribeOpenAiAudio(params: AudioTranscriptionRequest) {
async function transcribeOpenAiAudio(params: AudioTranscriptionRequest) {
return await transcribeOpenAiCompatibleAudio({
...params,
provider: "openai",

View File

@@ -1,10 +1,7 @@
import { describe, expect, it } from "vitest";
import {
OPENAI_CHATGPT_MODERN_MODEL_IDS,
OPENAI_DUAL_ROUTE_MODEL_IDS,
OPENAI_PLATFORM_ONLY_ROUTE_MODEL_IDS,
OPENAI_PROVIDER_MODERN_MODEL_IDS,
OPENAI_SUBSCRIPTION_ONLY_ROUTE_MODEL_IDS,
isOpenAIDualRouteModelId,
isOpenAIPlatformOnlyRouteModelId,
isOpenAISubscriptionOnlyRouteModelId,
@@ -37,12 +34,25 @@ describe("OpenAI model route contract", () => {
const provider = buildOpenAIProvider();
const chatGPTHooks = buildOpenAICodexProviderHooks();
const routeModelIds = [
...OPENAI_DUAL_ROUTE_MODEL_IDS,
...OPENAI_PLATFORM_ONLY_ROUTE_MODEL_IDS,
...OPENAI_SUBSCRIPTION_ONLY_ROUTE_MODEL_IDS,
...new Set([...OPENAI_PROVIDER_MODERN_MODEL_IDS, ...OPENAI_CHATGPT_MODERN_MODEL_IDS]),
];
const dualRouteModelIds = routeModelIds.filter(isOpenAIDualRouteModelId);
const platformOnlyRouteModelIds = routeModelIds.filter(isOpenAIPlatformOnlyRouteModelId);
const subscriptionOnlyRouteModelIds = routeModelIds.filter(
isOpenAISubscriptionOnlyRouteModelId,
);
expect(new Set(routeModelIds).size).toBe(routeModelIds.length);
expect(
new Set([
...dualRouteModelIds,
...platformOnlyRouteModelIds,
...subscriptionOnlyRouteModelIds,
]).size,
).toBe(
dualRouteModelIds.length +
platformOnlyRouteModelIds.length +
subscriptionOnlyRouteModelIds.length,
);
for (const modelId of OPENAI_PROVIDER_MODERN_MODEL_IDS) {
expect(provider.isModernModelRef?.({ provider: "openai", modelId })).toBe(true);
@@ -51,20 +61,20 @@ describe("OpenAI model route contract", () => {
expect(chatGPTHooks.isModernModelRef?.({ provider: "openai", modelId })).toBe(true);
}
for (const modelId of OPENAI_DUAL_ROUTE_MODEL_IDS) {
for (const modelId of dualRouteModelIds) {
const resolution = resolveUnconfiguredModel(modelId);
expect(
resolution.kind === "routes" ? resolution.routes.map((route) => route.api) : [],
).toEqual(["openai-responses", "openai-chatgpt-responses"]);
}
for (const modelId of OPENAI_PLATFORM_ONLY_ROUTE_MODEL_IDS) {
for (const modelId of platformOnlyRouteModelIds) {
expect(resolveUnconfiguredModel(modelId)).toMatchObject({
kind: "routes",
defaultRuntimeId: "codex",
routes: [{ api: "openai-responses", authRequirement: "api-key" }],
});
}
for (const modelId of OPENAI_SUBSCRIPTION_ONLY_ROUTE_MODEL_IDS) {
for (const modelId of subscriptionOnlyRouteModelIds) {
expect(resolveUnconfiguredModel(modelId)).toMatchObject({
kind: "routes",
defaultRuntimeId: "codex",

View File

@@ -21,7 +21,7 @@ export const OPENAI_GPT_56_VARIANT_MODEL_IDS = [
] as const;
/** Models with known first-party Platform and ChatGPT transports. */
export const OPENAI_DUAL_ROUTE_MODEL_IDS = [
const OPENAI_DUAL_ROUTE_MODEL_IDS = [
...OPENAI_GPT_56_VARIANT_MODEL_IDS,
OPENAI_GPT_55_MODEL_ID,
OPENAI_GPT_55_PRO_MODEL_ID,
@@ -31,14 +31,12 @@ export const OPENAI_DUAL_ROUTE_MODEL_IDS = [
] as const;
/** Direct aliases excluded from the ChatGPT catalog. */
export const OPENAI_PLATFORM_ONLY_ROUTE_MODEL_IDS = [
const OPENAI_PLATFORM_ONLY_ROUTE_MODEL_IDS = [
OPENAI_CHAT_LATEST_MODEL_ID,
OPENAI_GPT_56_MODEL_ID,
] as const;
export const OPENAI_SUBSCRIPTION_ONLY_ROUTE_MODEL_IDS = [
OPENAI_GPT_53_CODEX_SPARK_MODEL_ID,
] as const;
const OPENAI_SUBSCRIPTION_ONLY_ROUTE_MODEL_IDS = [OPENAI_GPT_53_CODEX_SPARK_MODEL_ID] as const;
/** Modern model refs recognized by the unified OpenAI provider surface. */
export const OPENAI_PROVIDER_MODERN_MODEL_IDS = [

View File

@@ -63,9 +63,7 @@ function raiseMinimalReasoningForOpenAINativeWebSearch(payload: Record<string, u
reasoning.effort = "low";
}
export function patchOpenAINativeWebSearchPayload(
payload: unknown,
): OpenAINativeWebSearchPatchResult {
function patchOpenAINativeWebSearchPayload(payload: unknown): OpenAINativeWebSearchPatchResult {
if (!isRecord(payload)) {
return "payload_not_object";
}

View File

@@ -1,6 +1,5 @@
// Openai plugin module implements openai chatgpt oauth abort behavior.
export {
buildOAuthRequestSignal,
createOAuthLoginCancelledError,
throwIfOAuthLoginAborted,
withOAuthLoginAbort,

View File

@@ -0,0 +1,55 @@
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
import { generatePKCE } from "./openai-chatgpt-pkce.runtime.js";
const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
const AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize";
const CALLBACK_PORT = 1455;
const CALLBACK_PATH = "/auth/callback";
const DEFAULT_CALLBACK_HOST = "localhost";
const LOOPBACK_CALLBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
const SCOPE = "openid profile email offline_access";
const loadNodeCrypto = createLazyRuntimeModule(() =>
import("node:crypto").then((cryptoModule) => cryptoModule.randomBytes),
);
export function resolveOpenAICallbackHost(env: NodeJS.ProcessEnv = process.env): string {
const host = env.OPENCLAW_OAUTH_CALLBACK_HOST?.trim() || DEFAULT_CALLBACK_HOST;
if (!LOOPBACK_CALLBACK_HOSTS.has(host)) {
throw new Error("OpenAI Codex OAuth callback host must be localhost, 127.0.0.1, or ::1");
}
return host;
}
export function resolveOpenAIRedirectUri(host: string): string {
const hostForUrl = host === "::1" ? "[::1]" : host;
const url = new URL(`http://${hostForUrl}:${CALLBACK_PORT}`);
url.pathname = CALLBACK_PATH;
return url.toString();
}
export async function createOpenAIAuthorizationFlow(
originator: string,
redirectUri: string,
): Promise<{ verifier: string; redirectUri: string; state: string; url: string }> {
if (typeof process === "undefined" || (!process.versions?.node && !process.versions?.bun)) {
throw new Error("OpenAI Codex OAuth is only available in Node.js environments");
}
const [{ verifier, challenge }, randomBytes] = await Promise.all([
generatePKCE(),
loadNodeCrypto(),
]);
const state = randomBytes(16).toString("hex");
const url = new URL(AUTHORIZE_URL);
url.searchParams.set("response_type", "code");
url.searchParams.set("client_id", CLIENT_ID);
url.searchParams.set("redirect_uri", redirectUri);
url.searchParams.set("scope", SCOPE);
url.searchParams.set("code_challenge", challenge);
url.searchParams.set("code_challenge_method", "S256");
url.searchParams.set("state", state);
url.searchParams.set("id_token_add_organizations", "true");
url.searchParams.set("codex_cli_simplified_flow", "true");
url.searchParams.set("originator", originator);
return { verifier, redirectUri, state, url: url.toString() };
}

View File

@@ -10,7 +10,16 @@ vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
fetchWithSsrFGuard: ssrfMocks.fetchWithSsrFGuard,
}));
import { openaiCodexOAuthProvider, testing } from "./openai-chatgpt-oauth-flow.runtime.js";
import {
createOpenAIAuthorizationFlow,
resolveOpenAICallbackHost,
resolveOpenAIRedirectUri,
} from "./openai-chatgpt-oauth-authorization.runtime.js";
import { loginOpenAICodex } from "./openai-chatgpt-oauth-flow.runtime.js";
import {
exchangeOpenAIAuthorizationCode,
refreshOpenAIAccessToken,
} from "./openai-chatgpt-oauth-token.runtime.js";
function timeoutError(): Error {
return new DOMException("timed out", "TimeoutError");
@@ -40,7 +49,7 @@ describe("OpenAI Codex OAuth flow", () => {
controller.abort();
await expect(
openaiCodexOAuthProvider.login({
loginOpenAICodex({
onAuth: vi.fn(),
onPrompt: vi.fn(async () => "unused-code"),
signal: controller.signal,
@@ -51,7 +60,7 @@ describe("OpenAI Codex OAuth flow", () => {
it("does not open the OAuth flow after cancellation during setup", async () => {
const controller = new AbortController();
const onAuth = vi.fn();
const loginPromise = openaiCodexOAuthProvider.login({
const loginPromise = loginOpenAICodex({
onAuth,
onPrompt: vi.fn(async () => "unused-code"),
signal: controller.signal,
@@ -64,7 +73,11 @@ describe("OpenAI Codex OAuth flow", () => {
});
it("waits for Node OAuth runtime before creating an authorization flow", async () => {
const flow = await testing.createAuthorizationFlow("openclaw-test");
const callbackHost = resolveOpenAICallbackHost();
const flow = await createOpenAIAuthorizationFlow(
"openclaw-test",
resolveOpenAIRedirectUri(callbackHost),
);
const url = new URL(flow.url);
expect(flow.state).toMatch(/^[a-f0-9]{32}$/u);
@@ -73,15 +86,15 @@ describe("OpenAI Codex OAuth flow", () => {
const redirectUri = url.searchParams.get("redirect_uri");
expect(redirectUri).toBeTruthy();
expect(flow.redirectUri).toBe(redirectUri);
expect(testing.callbackHost).toBe(new URL(redirectUri ?? "").hostname);
expect(callbackHost).toBe(new URL(redirectUri ?? "").hostname);
});
it("builds callback redirect URIs from the configured loopback host", () => {
expect(testing.resolveRedirectUri("127.0.0.1")).toBe("http://127.0.0.1:1455/auth/callback");
expect(resolveOpenAIRedirectUri("127.0.0.1")).toBe("http://127.0.0.1:1455/auth/callback");
});
it("rejects non-loopback callback bind hosts", () => {
expect(() => testing.resolveCallbackHost({ OPENCLAW_OAUTH_CALLBACK_HOST: "0.0.0.0" })).toThrow(
expect(() => resolveOpenAICallbackHost({ OPENCLAW_OAUTH_CALLBACK_HOST: "0.0.0.0" })).toThrow(
"callback host must be localhost, 127.0.0.1, or ::1",
);
});
@@ -89,10 +102,10 @@ describe("OpenAI Codex OAuth flow", () => {
it("times out token exchange requests", async () => {
ssrfMocks.fetchWithSsrFGuard.mockRejectedValueOnce(timeoutError());
const result = await testing.exchangeAuthorizationCode(
const result = await exchangeOpenAIAuthorizationCode(
"code",
"verifier",
testing.resolveRedirectUri("localhost"),
resolveOpenAIRedirectUri("localhost"),
{ timeoutMs: 5 },
);
@@ -112,10 +125,10 @@ describe("OpenAI Codex OAuth flow", () => {
const controller = new AbortController();
controller.abort();
const result = await testing.exchangeAuthorizationCode(
const result = await exchangeOpenAIAuthorizationCode(
"code",
"verifier",
testing.resolveRedirectUri("localhost"),
resolveOpenAIRedirectUri("localhost"),
{ signal: controller.signal, timeoutMs: 5 },
);
@@ -131,10 +144,10 @@ describe("OpenAI Codex OAuth flow", () => {
'{"access_token":"access-token","refresh_token":"refresh-token","expires_in":1e309}',
);
const result = await testing.exchangeAuthorizationCode(
const result = await exchangeOpenAIAuthorizationCode(
"code",
"verifier",
testing.resolveRedirectUri("localhost"),
resolveOpenAIRedirectUri("localhost"),
{ timeoutMs: 5 },
);
@@ -147,7 +160,7 @@ describe("OpenAI Codex OAuth flow", () => {
it("times out token refresh requests", async () => {
ssrfMocks.fetchWithSsrFGuard.mockRejectedValueOnce(timeoutError());
const result = await testing.refreshAccessToken("old-refresh-token", { timeoutMs: 5 });
const result = await refreshOpenAIAccessToken("old-refresh-token", { timeoutMs: 5 });
expect(ssrfMocks.fetchWithSsrFGuard).toHaveBeenCalledWith(
expect.objectContaining({
@@ -168,7 +181,7 @@ describe("OpenAI Codex OAuth flow", () => {
expires_in: 0,
});
const result = await testing.refreshAccessToken("old-refresh-token", { timeoutMs: 5 });
const result = await refreshOpenAIAccessToken("old-refresh-token", { timeoutMs: 5 });
expect(result).toEqual({
type: "failed",
@@ -221,7 +234,7 @@ describe("OpenAI Codex OAuth bounded token response reads", () => {
return { response, release };
});
const result = await testing.exchangeAuthorizationCode(
const result = await exchangeOpenAIAuthorizationCode(
"code-loopback",
"verifier-loopback",
"http://localhost:1455/auth/callback",
@@ -260,7 +273,7 @@ describe("OpenAI Codex OAuth bounded token response reads", () => {
return { response, release };
});
const result = await testing.exchangeAuthorizationCode(
const result = await exchangeOpenAIAuthorizationCode(
"code-loopback",
"verifier-loopback",
"http://localhost:1455/auth/callback",

View File

@@ -6,64 +6,36 @@
*/
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
import {
parseOAuthAuthorizationInput,
resolveOAuthTokenExpiresAt,
resolveOAuthTokenLifetimeMs,
} from "openclaw/plugin-sdk/provider-oauth-runtime";
import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import { parseOAuthAuthorizationInput } from "openclaw/plugin-sdk/provider-oauth-runtime";
import { resolveCodexAuthIdentity } from "./openai-chatgpt-auth-identity.js";
import {
createOAuthLoginCancelledError,
throwIfOAuthLoginAborted,
withOAuthLoginAbort,
} from "./openai-chatgpt-oauth-abort.runtime.js";
import {
createOpenAIAuthorizationFlow,
resolveOpenAICallbackHost,
resolveOpenAIRedirectUri,
} from "./openai-chatgpt-oauth-authorization.runtime.js";
import { oauthErrorHtml, oauthSuccessHtml } from "./openai-chatgpt-oauth-page.runtime.js";
import type {
OAuthCredentials,
OAuthLoginCallbacks,
OAuthPrompt,
OAuthProviderInterface,
} from "./openai-chatgpt-oauth-types.runtime.js";
import { generatePKCE } from "./openai-chatgpt-pkce.runtime.js";
import {
exchangeOpenAIAuthorizationCode,
refreshOpenAIAccessToken,
} from "./openai-chatgpt-oauth-token.runtime.js";
import type { OAuthCredentials, OAuthPrompt } from "./openai-chatgpt-oauth-types.runtime.js";
const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
const AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize";
const TOKEN_URL = "https://auth.openai.com/oauth/token";
const CALLBACK_PORT = 1455;
const CALLBACK_PATH = "/auth/callback";
const DEFAULT_CALLBACK_HOST = "localhost";
const LOOPBACK_CALLBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
const CALLBACK_HOST = resolveCallbackHost();
const REDIRECT_URI = resolveRedirectUri(CALLBACK_HOST);
const CALLBACK_HOST = resolveOpenAICallbackHost();
const REDIRECT_URI = resolveOpenAIRedirectUri(CALLBACK_HOST);
const MANUAL_PROMPT_FALLBACK_MS = 15_000;
const TOKEN_REQUEST_TIMEOUT_MS = 30_000;
const SCOPE = "openid profile email offline_access";
const OAUTH_TOKEN_RESPONSE_BODY_LIMIT_BYTES = 1 * 1024 * 1024;
type TokenSuccess = { type: "success"; access: string; refresh: string; expires: number };
type TokenFailure = { type: "failed"; message: string; status?: number };
type TokenResult = TokenSuccess | TokenFailure;
type TokenResponseJson = {
access_token?: string;
refresh_token?: string;
expires_in?: number;
};
type NodeOAuthRuntime = {
randomBytes: typeof import("node:crypto").randomBytes;
http: typeof import("node:http");
};
type TokenRequestOptions = {
signal?: AbortSignal;
timeoutMs?: number;
};
const loadNodeOAuthModules = createLazyRuntimeModule(() =>
Promise.all([import("node:crypto"), import("node:http")]).then(([cryptoModule, httpModule]) => ({
randomBytes: cryptoModule.randomBytes,
http: httpModule,
})),
import("node:http").then((http) => ({ http })),
);
function loadNodeOAuthRuntime(): Promise<NodeOAuthRuntime> {
@@ -75,25 +47,6 @@ function loadNodeOAuthRuntime(): Promise<NodeOAuthRuntime> {
return loadNodeOAuthModules();
}
function resolveCallbackHost(env: NodeJS.ProcessEnv = process.env): string {
const host = env.OPENCLAW_OAUTH_CALLBACK_HOST?.trim() || DEFAULT_CALLBACK_HOST;
if (!LOOPBACK_CALLBACK_HOSTS.has(host)) {
throw new Error("OpenAI Codex OAuth callback host must be localhost, 127.0.0.1, or ::1");
}
return host;
}
function resolveRedirectUri(host: string = CALLBACK_HOST): string {
const hostForUrl = host === "::1" ? "[::1]" : host;
const url = new URL(`http://${hostForUrl}:${CALLBACK_PORT}`);
url.pathname = CALLBACK_PATH;
return url.toString();
}
function createState(randomBytes: typeof import("node:crypto").randomBytes): string {
return randomBytes(16).toString("hex");
}
function waitForManualPromptFallback(signal?: AbortSignal): Promise<null> {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
@@ -133,204 +86,6 @@ async function promptForAuthorizationCode(
return parsed.code;
}
function formatMissingTokenResponseFields(json: TokenResponseJson): string {
const missing: string[] = [];
if (!json.access_token) {
missing.push("access_token");
}
if (!json.refresh_token) {
missing.push("refresh_token");
}
if (resolveOAuthTokenLifetimeMs(json.expires_in) === undefined) {
missing.push("expires_in");
}
return missing.join(", ");
}
function formatTokenRequestError(
operation: "exchange" | "refresh",
error: unknown,
timeoutMs: number,
signal?: AbortSignal,
): string {
if (signal?.aborted) {
return "Login cancelled";
}
if (error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError")) {
return `OpenAI Codex token ${operation} timed out after ${timeoutMs}ms`;
}
return `OpenAI Codex token ${operation} error: ${error instanceof Error ? error.message : String(error)}`;
}
async function postTokenForm(
body: URLSearchParams,
options: TokenRequestOptions = {},
): Promise<Response> {
const timeoutMs = options.timeoutMs ?? TOKEN_REQUEST_TIMEOUT_MS;
throwIfOAuthLoginAborted(options.signal);
const { response, release } = await fetchWithSsrFGuard({
url: TOKEN_URL,
init: {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body,
},
timeoutMs,
signal: options.signal,
auditContext: "openai-chatgpt-oauth-token",
});
try {
const responseBody = await readResponseWithLimit(
response,
OAUTH_TOKEN_RESPONSE_BODY_LIMIT_BYTES,
{
onOverflow: ({ size, maxBytes }) =>
new Error(
`OpenAI Codex OAuth token response body too large: ${size} bytes (limit: ${maxBytes} bytes)`,
),
},
);
return new Response(new Uint8Array(responseBody), {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
} finally {
await release();
}
}
async function exchangeAuthorizationCode(
code: string,
verifier: string,
redirectUri: string = REDIRECT_URI,
options: TokenRequestOptions = {},
): Promise<TokenResult> {
const timeoutMs = options.timeoutMs ?? TOKEN_REQUEST_TIMEOUT_MS;
let response: Response;
try {
response = await postTokenForm(
new URLSearchParams({
grant_type: "authorization_code",
client_id: CLIENT_ID,
code,
code_verifier: verifier,
redirect_uri: redirectUri,
}),
{ signal: options.signal, timeoutMs },
);
} catch (error) {
return {
type: "failed",
message: formatTokenRequestError("exchange", error, timeoutMs, options.signal),
};
}
if (!response.ok) {
const text = await response.text().catch(() => "");
return {
type: "failed",
status: response.status,
message: `OpenAI Codex token exchange failed (${response.status}): ${text || response.statusText}`,
};
}
const json = (await response.json()) as TokenResponseJson;
const expires = resolveOAuthTokenExpiresAt(json.expires_in);
if (!json.access_token || !json.refresh_token || expires === undefined) {
return {
type: "failed",
message: `OpenAI Codex token exchange response missing fields: ${formatMissingTokenResponseFields(json)}`,
};
}
return {
type: "success",
access: json.access_token,
refresh: json.refresh_token,
expires,
};
}
async function refreshAccessToken(
refreshToken: string,
options: TokenRequestOptions = {},
): Promise<TokenResult> {
try {
const timeoutMs = options.timeoutMs ?? TOKEN_REQUEST_TIMEOUT_MS;
const response = await postTokenForm(
new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: CLIENT_ID,
}),
{ signal: options.signal, timeoutMs },
);
if (!response.ok) {
const text = await response.text().catch(() => "");
return {
type: "failed",
status: response.status,
message: `OpenAI Codex token refresh failed (${response.status}): ${text || response.statusText}`,
};
}
const json = (await response.json()) as TokenResponseJson;
const expires = resolveOAuthTokenExpiresAt(json.expires_in);
if (!json.access_token || !json.refresh_token || expires === undefined) {
return {
type: "failed",
message: `OpenAI Codex token refresh response missing fields: ${formatMissingTokenResponseFields(json)}`,
};
}
return {
type: "success",
access: json.access_token,
refresh: json.refresh_token,
expires,
};
} catch (error) {
return {
type: "failed",
message: formatTokenRequestError(
"refresh",
error,
options.timeoutMs ?? TOKEN_REQUEST_TIMEOUT_MS,
options.signal,
),
};
}
}
async function createAuthorizationFlow(
originator = "openclaw",
): Promise<{ verifier: string; redirectUri: string; state: string; url: string }> {
const [{ verifier, challenge }, runtime] = await Promise.all([
generatePKCE(),
loadNodeOAuthRuntime(),
]);
const state = createState(runtime.randomBytes);
const url = new URL(AUTHORIZE_URL);
url.searchParams.set("response_type", "code");
url.searchParams.set("client_id", CLIENT_ID);
const redirectUri = REDIRECT_URI;
url.searchParams.set("redirect_uri", redirectUri);
url.searchParams.set("scope", SCOPE);
url.searchParams.set("code_challenge", challenge);
url.searchParams.set("code_challenge_method", "S256");
url.searchParams.set("state", state);
url.searchParams.set("id_token_add_organizations", "true");
url.searchParams.set("codex_cli_simplified_flow", "true");
url.searchParams.set("originator", originator);
return { verifier, redirectUri, state, url: url.toString() };
}
type OAuthServerInfo = {
close: () => void;
cancelWait: () => void;
@@ -437,7 +192,10 @@ export async function loginOpenAICodex(options: {
signal?: AbortSignal;
}): Promise<OAuthCredentials> {
throwIfOAuthLoginAborted(options.signal);
const { verifier, redirectUri, state, url } = await createAuthorizationFlow(options.originator);
const { verifier, redirectUri, state, url } = await createOpenAIAuthorizationFlow(
options.originator ?? "openclaw",
REDIRECT_URI,
);
const server = await startLocalOAuthServer(state);
let code: string | undefined;
@@ -538,7 +296,7 @@ export async function loginOpenAICodex(options: {
throw new Error("Missing authorization code");
}
const tokenResult = await exchangeAuthorizationCode(code, verifier, redirectUri, {
const tokenResult = await exchangeOpenAIAuthorizationCode(code, verifier, redirectUri, {
signal: options.signal,
});
if (tokenResult.type !== "success") {
@@ -565,7 +323,7 @@ export async function loginOpenAICodex(options: {
* Refresh OpenAI Codex OAuth token
*/
export async function refreshOpenAICodexToken(refreshToken: string): Promise<OAuthCredentials> {
const result = await refreshAccessToken(refreshToken);
const result = await refreshOpenAIAccessToken(refreshToken);
if (result.type !== "success") {
throw new Error(result.message);
}
@@ -583,40 +341,6 @@ export async function refreshOpenAICodexToken(refreshToken: string): Promise<OAu
};
}
export const openaiCodexOAuthProvider: OAuthProviderInterface = {
id: "openai",
name: "ChatGPT Plus/Pro (Codex Subscription)",
usesCallbackServer: true,
async login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
return loginOpenAICodex({
onAuth: callbacks.onAuth,
onPrompt: callbacks.onPrompt,
onProgress: callbacks.onProgress,
onManualCodeInput: callbacks.onManualCodeInput,
signal: callbacks.signal,
});
},
async refreshToken(credentials: OAuthCredentials): Promise<OAuthCredentials> {
return refreshOpenAICodexToken(credentials.refresh);
},
getApiKey(credentials: OAuthCredentials): string {
return credentials.access;
},
};
export const testing = {
callbackHost: CALLBACK_HOST,
createAuthorizationFlow,
exchangeAuthorizationCode,
loginOpenAICodex,
refreshAccessToken,
resolveCallbackHost,
resolveRedirectUri,
};
function toLintErrorObject(value: unknown, fallbackMessage: string): Error {
if (value instanceof Error) {
return value;

View File

@@ -0,0 +1,85 @@
import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
const OPENAI_AUTH_PROBE_URL =
"https://auth.openai.com/oauth/authorize?response_type=code&client_id=openclaw-preflight&redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback&scope=openid+profile+email";
const TLS_CERT_ERROR_CODES = new Set([
"UNABLE_TO_GET_ISSUER_CERT_LOCALLY",
"UNABLE_TO_VERIFY_LEAF_SIGNATURE",
"CERT_HAS_EXPIRED",
"DEPTH_ZERO_SELF_SIGNED_CERT",
"SELF_SIGNED_CERT_IN_CHAIN",
"ERR_TLS_CERT_ALTNAME_INVALID",
]);
const TLS_CERT_ERROR_PATTERNS = [
/unable to get local issuer certificate/i,
/unable to verify the first certificate/i,
/self[- ]signed certificate/i,
/certificate has expired/i,
];
type PreflightFailureKind = "tls-cert" | "network";
export type OpenAIOAuthTlsPreflightResult =
| { ok: true }
| {
ok: false;
kind: PreflightFailureKind;
code?: string;
message: string;
};
function getErrorRecord(error: unknown): Record<string, unknown> | null {
return error && typeof error === "object" ? (error as Record<string, unknown>) : null;
}
function extractFailure(error: unknown): {
code?: string;
message: string;
kind: PreflightFailureKind;
} {
const root = getErrorRecord(error);
const rootCause = getErrorRecord(root?.cause);
const code = typeof rootCause?.code === "string" ? rootCause.code : undefined;
const message =
typeof rootCause?.message === "string"
? rootCause.message
: typeof root?.message === "string"
? root.message
: String(error);
const isTlsCertError =
(code ? TLS_CERT_ERROR_CODES.has(code) : false) ||
TLS_CERT_ERROR_PATTERNS.some((pattern) => pattern.test(message));
return {
code,
message,
kind: isTlsCertError ? "tls-cert" : "network",
};
}
export async function runOpenAIOAuthTlsPreflight(options?: {
timeoutMs?: number;
fetchImpl?: typeof fetch;
}): Promise<OpenAIOAuthTlsPreflightResult> {
const timeoutMs = resolveTimerTimeoutMs(options?.timeoutMs, 5000);
const fetchImpl = options?.fetchImpl ?? fetch;
let response: Response | undefined;
try {
response = await fetchImpl(OPENAI_AUTH_PROBE_URL, {
method: "GET",
redirect: "manual",
signal: AbortSignal.timeout(timeoutMs),
});
return { ok: true };
} catch (error) {
const failure = extractFailure(error);
return {
ok: false,
kind: failure.kind,
code: failure.code,
message: failure.message,
};
} finally {
if (response?.bodyUsed !== true) {
await response?.body?.cancel().catch(() => undefined);
}
}
}

View File

@@ -0,0 +1,190 @@
import {
resolveOAuthTokenExpiresAt,
resolveOAuthTokenLifetimeMs,
} from "openclaw/plugin-sdk/provider-oauth-runtime";
import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import { throwIfOAuthLoginAborted } from "./openai-chatgpt-oauth-abort.runtime.js";
const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
const TOKEN_URL = "https://auth.openai.com/oauth/token";
const TOKEN_REQUEST_TIMEOUT_MS = 30_000;
const OAUTH_TOKEN_RESPONSE_BODY_LIMIT_BYTES = 1 * 1024 * 1024;
type TokenSuccess = { type: "success"; access: string; refresh: string; expires: number };
type TokenFailure = { type: "failed"; message: string; status?: number };
type TokenResult = TokenSuccess | TokenFailure;
type TokenResponseJson = {
access_token?: string;
refresh_token?: string;
expires_in?: number;
};
type TokenRequestOptions = {
signal?: AbortSignal;
timeoutMs?: number;
};
function formatMissingTokenResponseFields(json: TokenResponseJson): string {
const missing: string[] = [];
if (!json.access_token) {
missing.push("access_token");
}
if (!json.refresh_token) {
missing.push("refresh_token");
}
if (resolveOAuthTokenLifetimeMs(json.expires_in) === undefined) {
missing.push("expires_in");
}
return missing.join(", ");
}
function formatTokenRequestError(
operation: "exchange" | "refresh",
error: unknown,
timeoutMs: number,
signal?: AbortSignal,
): string {
if (signal?.aborted) {
return "Login cancelled";
}
if (error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError")) {
return `OpenAI Codex token ${operation} timed out after ${timeoutMs}ms`;
}
return `OpenAI Codex token ${operation} error: ${error instanceof Error ? error.message : String(error)}`;
}
async function postTokenForm(
body: URLSearchParams,
options: TokenRequestOptions = {},
): Promise<Response> {
const timeoutMs = options.timeoutMs ?? TOKEN_REQUEST_TIMEOUT_MS;
throwIfOAuthLoginAborted(options.signal);
const { response, release } = await fetchWithSsrFGuard({
url: TOKEN_URL,
init: {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body,
},
timeoutMs,
signal: options.signal,
auditContext: "openai-chatgpt-oauth-token",
});
try {
const responseBody = await readResponseWithLimit(
response,
OAUTH_TOKEN_RESPONSE_BODY_LIMIT_BYTES,
{
onOverflow: ({ size, maxBytes }) =>
new Error(
`OpenAI Codex OAuth token response body too large: ${size} bytes (limit: ${maxBytes} bytes)`,
),
},
);
return new Response(new Uint8Array(responseBody), {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
} finally {
await release();
}
}
export async function exchangeOpenAIAuthorizationCode(
code: string,
verifier: string,
redirectUri: string,
options: TokenRequestOptions = {},
): Promise<TokenResult> {
const timeoutMs = options.timeoutMs ?? TOKEN_REQUEST_TIMEOUT_MS;
let response: Response;
try {
response = await postTokenForm(
new URLSearchParams({
grant_type: "authorization_code",
client_id: CLIENT_ID,
code,
code_verifier: verifier,
redirect_uri: redirectUri,
}),
{ signal: options.signal, timeoutMs },
);
} catch (error) {
return {
type: "failed",
message: formatTokenRequestError("exchange", error, timeoutMs, options.signal),
};
}
if (!response.ok) {
const text = await response.text().catch(() => "");
return {
type: "failed",
status: response.status,
message: `OpenAI Codex token exchange failed (${response.status}): ${text || response.statusText}`,
};
}
const json = (await response.json()) as TokenResponseJson;
const expires = resolveOAuthTokenExpiresAt(json.expires_in);
if (!json.access_token || !json.refresh_token || expires === undefined) {
return {
type: "failed",
message: `OpenAI Codex token exchange response missing fields: ${formatMissingTokenResponseFields(json)}`,
};
}
return {
type: "success",
access: json.access_token,
refresh: json.refresh_token,
expires,
};
}
export async function refreshOpenAIAccessToken(
refreshToken: string,
options: TokenRequestOptions = {},
): Promise<TokenResult> {
try {
const timeoutMs = options.timeoutMs ?? TOKEN_REQUEST_TIMEOUT_MS;
const response = await postTokenForm(
new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: CLIENT_ID,
}),
{ signal: options.signal, timeoutMs },
);
if (!response.ok) {
const text = await response.text().catch(() => "");
return {
type: "failed",
status: response.status,
message: `OpenAI Codex token refresh failed (${response.status}): ${text || response.statusText}`,
};
}
const json = (await response.json()) as TokenResponseJson;
const expires = resolveOAuthTokenExpiresAt(json.expires_in);
if (!json.access_token || !json.refresh_token || expires === undefined) {
return {
type: "failed",
message: `OpenAI Codex token refresh response missing fields: ${formatMissingTokenResponseFields(json)}`,
};
}
return {
type: "success",
access: json.access_token,
refresh: json.refresh_token,
expires,
};
} catch (error) {
return {
type: "failed",
message: formatTokenRequestError(
"refresh",
error,
options.timeoutMs ?? TOKEN_REQUEST_TIMEOUT_MS,
options.signal,
),
};
}
}

View File

@@ -1,11 +1,2 @@
// Openai plugin module implements openai chatgpt oauth types behavior.
export type {
OAuthAuthInfo,
OAuthCredentials,
OAuthLoginCallbacks,
OAuthPrompt,
OAuthProviderId,
OAuthProviderInterface,
OAuthSelectOption,
OAuthSelectPrompt,
} from "openclaw/plugin-sdk/provider-oauth-runtime";
export type { OAuthCredentials, OAuthPrompt } from "openclaw/plugin-sdk/provider-oauth-runtime";

View File

@@ -1,7 +1,7 @@
// Openai tests cover openai chatgpt oauth plugin behavior.
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
import { afterEach, describe, expect, it, vi } from "vitest";
import { testing } from "./openai-chatgpt-oauth.runtime.js";
import { runOpenAIOAuthTlsPreflight } from "./openai-chatgpt-oauth-preflight.runtime.js";
describe("OpenAI Codex OAuth runtime", () => {
afterEach(() => {
@@ -13,7 +13,7 @@ describe("OpenAI Codex OAuth runtime", () => {
const fetchImpl = vi.fn(async () => new Response(null, { status: 302 }));
await expect(
testing.runOpenAIOAuthTlsPreflight({
runOpenAIOAuthTlsPreflight({
timeoutMs: Number.MAX_SAFE_INTEGER,
fetchImpl,
}),
@@ -29,7 +29,7 @@ describe("OpenAI Codex OAuth runtime", () => {
const fetchImpl = vi.fn(async () => response);
await expect(
testing.runOpenAIOAuthTlsPreflight({
runOpenAIOAuthTlsPreflight({
timeoutMs: 20,
fetchImpl,
}),

View File

@@ -1,79 +1,25 @@
// Openai plugin module implements openai chatgpt oauth behavior.
import path from "node:path";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
import type { ProviderAuthContext } from "openclaw/plugin-sdk/plugin-entry";
import { ensureGlobalUndiciEnvProxyDispatcher } from "openclaw/plugin-sdk/runtime-env";
import { formatCliCommand } from "openclaw/plugin-sdk/setup-tools";
import { loginOpenAICodex } from "./openai-chatgpt-oauth-flow.runtime.js";
import {
runOpenAIOAuthTlsPreflight,
type OpenAIOAuthTlsPreflightResult,
} from "./openai-chatgpt-oauth-preflight.runtime.js";
import type { OAuthCredentials } from "./openai-chatgpt-oauth-types.runtime.js";
const manualInputPromptMessage = "Paste the authorization code (or full redirect URL):";
const openAICodexOAuthOriginator = "openclaw";
const localManualFallbackDelayMs = 15_000;
const localManualFallbackGraceMs = 1_000;
const openAIAuthProbeUrl =
"https://auth.openai.com/oauth/authorize?response_type=code&client_id=openclaw-preflight&redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback&scope=openid+profile+email";
const tlsCertErrorCodes = new Set([
"UNABLE_TO_GET_ISSUER_CERT_LOCALLY",
"UNABLE_TO_VERIFY_LEAF_SIGNATURE",
"CERT_HAS_EXPIRED",
"DEPTH_ZERO_SELF_SIGNED_CERT",
"SELF_SIGNED_CERT_IN_CHAIN",
"ERR_TLS_CERT_ALTNAME_INVALID",
]);
const tlsCertErrorPatterns = [
/unable to get local issuer certificate/i,
/unable to verify the first certificate/i,
/self[- ]signed certificate/i,
/certificate has expired/i,
];
type OpenAICodexOAuthFailureCode =
| "callback_timeout"
| "callback_validation_failed"
| "unsupported_region";
type PreflightFailureKind = "tls-cert" | "network";
type OpenAIOAuthTlsPreflightResult =
| { ok: true }
| {
ok: false;
kind: PreflightFailureKind;
code?: string;
message: string;
};
function getErrorRecord(error: unknown): Record<string, unknown> | null {
return error && typeof error === "object" ? (error as Record<string, unknown>) : null;
}
function extractFailure(error: unknown): {
code?: string;
message: string;
kind: PreflightFailureKind;
} {
const root = getErrorRecord(error);
const rootCause = getErrorRecord(root?.cause);
const code = typeof rootCause?.code === "string" ? rootCause.code : undefined;
const message =
typeof rootCause?.message === "string"
? rootCause.message
: typeof root?.message === "string"
? root.message
: String(error);
const isTlsCertError =
(code ? tlsCertErrorCodes.has(code) : false) ||
tlsCertErrorPatterns.some((pattern) => pattern.test(message));
return {
code,
message,
kind: isTlsCertError ? "tls-cert" : "network",
};
}
function resolveHomebrewPrefixFromExecPath(execPath: string): string | null {
const marker = `${path.sep}Cellar${path.sep}`;
const idx = execPath.indexOf(marker);
@@ -88,38 +34,6 @@ function resolveCertBundlePath(): string | null {
return prefix ? path.join(prefix, "etc", "openssl@3", "cert.pem") : null;
}
async function runOpenAIOAuthTlsPreflight(options?: {
timeoutMs?: number;
fetchImpl?: typeof fetch;
}): Promise<OpenAIOAuthTlsPreflightResult> {
const timeoutMs = resolveTimerTimeoutMs(options?.timeoutMs, 5000);
const fetchImpl = options?.fetchImpl ?? fetch;
let response: Response | undefined;
try {
response = await fetchImpl(openAIAuthProbeUrl, {
method: "GET",
redirect: "manual",
signal: AbortSignal.timeout(timeoutMs),
});
return { ok: true };
} catch (error) {
const failure = extractFailure(error);
return {
ok: false,
kind: failure.kind,
code: failure.code,
message: failure.message,
};
} finally {
if (response?.bodyUsed !== true) {
await response?.body?.cancel().catch(() => undefined);
}
}
}
export const testing = { runOpenAIOAuthTlsPreflight };
export { testing as __testing };
function formatOpenAIOAuthTlsPreflightFix(
result: Exclude<OpenAIOAuthTlsPreflightResult, { ok: true }>,
): string {

View File

@@ -1,2 +1,2 @@
// Openai plugin module implements openai chatgpt pkce behavior.
export { generateOAuthState, generatePKCE } from "openclaw/plugin-sdk/provider-oauth-runtime";
export { generatePKCE } from "openclaw/plugin-sdk/provider-oauth-runtime";

View File

@@ -0,0 +1,18 @@
import { ensureGlobalUndiciEnvProxyDispatcher } from "openclaw/plugin-sdk/runtime-env";
import { refreshOpenAICodexToken as refreshOpenAICodexTokenFromFlow } from "./openai-chatgpt-oauth-flow.runtime.js";
type OpenAICodexProviderRuntimeDeps = {
ensureGlobalUndiciEnvProxyDispatcher: typeof ensureGlobalUndiciEnvProxyDispatcher;
refreshOpenAICodexToken: typeof refreshOpenAICodexTokenFromFlow;
};
export function createOpenAICodexProviderRuntime(deps: OpenAICodexProviderRuntimeDeps): {
refreshOpenAICodexToken: typeof refreshOpenAICodexTokenFromFlow;
} {
return {
async refreshOpenAICodexToken(...args) {
deps.ensureGlobalUndiciEnvProxyDispatcher();
return await deps.refreshOpenAICodexToken(...args);
},
};
}

View File

@@ -1,61 +1,14 @@
// Openai provider module implements model/runtime integration.
import { ensureGlobalUndiciEnvProxyDispatcher } from "openclaw/plugin-sdk/runtime-env";
import { refreshOpenAICodexToken as refreshOpenAICodexTokenFromFlow } from "./openai-chatgpt-oauth-flow.runtime.js";
import type { OAuthCredentials } from "./openai-chatgpt-oauth-types.runtime.js";
type OpenAICodexProviderRuntimeDeps = {
ensureGlobalUndiciEnvProxyDispatcher: typeof ensureGlobalUndiciEnvProxyDispatcher;
getOAuthApiKey: typeof getOpenAICodexOAuthApiKey;
refreshOpenAICodexToken: typeof refreshOpenAICodexTokenFromFlow;
};
export function createOpenAICodexProviderRuntime(deps: OpenAICodexProviderRuntimeDeps): {
getOAuthApiKey: typeof getOAuthApiKey;
refreshOpenAICodexToken: typeof refreshOpenAICodexToken;
} {
return {
async getOAuthApiKey(...args) {
deps.ensureGlobalUndiciEnvProxyDispatcher();
return await deps.getOAuthApiKey(...args);
},
async refreshOpenAICodexToken(...args) {
deps.ensureGlobalUndiciEnvProxyDispatcher();
return await deps.refreshOpenAICodexToken(...args);
},
};
}
import { createOpenAICodexProviderRuntime } from "./openai-chatgpt-provider-runtime.factory.js";
const runtime = createOpenAICodexProviderRuntime({
ensureGlobalUndiciEnvProxyDispatcher,
getOAuthApiKey: getOpenAICodexOAuthApiKey,
refreshOpenAICodexToken: refreshOpenAICodexTokenFromFlow,
});
export async function getOAuthApiKey(
...args: Parameters<typeof getOpenAICodexOAuthApiKey>
): Promise<Awaited<ReturnType<typeof getOpenAICodexOAuthApiKey>>> {
return await runtime.getOAuthApiKey(...args);
}
export async function refreshOpenAICodexToken(
...args: Parameters<typeof refreshOpenAICodexTokenFromFlow>
): Promise<Awaited<ReturnType<typeof refreshOpenAICodexTokenFromFlow>>> {
return await runtime.refreshOpenAICodexToken(...args);
}
async function getOpenAICodexOAuthApiKey(
providerId: string,
credentials: Record<string, OAuthCredentials>,
): Promise<{ newCredentials: OAuthCredentials; apiKey: string } | null> {
if (providerId !== "openai") {
throw new Error(`Unknown OAuth provider: ${providerId}`);
}
let creds = credentials[providerId];
if (!creds) {
return null;
}
if (Date.now() >= creds.expires) {
creds = await refreshOpenAICodexTokenFromFlow(creds.refresh);
}
return { newCredentials: creds, apiKey: creds.access };
}

View File

@@ -5,14 +5,11 @@ import {
clearLiveCatalogCacheForTests,
type LiveModelCatalogFetchGuard,
} from "openclaw/plugin-sdk/provider-catalog-live-runtime";
import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { OPENAI_API_BASE_URL, OPENAI_CODEX_RESPONSES_BASE_URL } from "./base-url.js";
import { OPENAI_CODEX_DEFAULT_MODEL, OPENAI_DEFAULT_MODEL } from "./default-models.js";
import {
buildOpenAICodexLiveProviderConfig,
buildOpenAILiveProviderConfig,
buildOpenAIProvider,
} from "./openai-provider.js";
import { buildOpenAIProvider } from "./openai-provider.js";
import manifest from "./openclaw.plugin.json" with { type: "json" };
import { resolveModelRoutes } from "./provider-policy-api.js";
@@ -23,6 +20,87 @@ const mocks = vi.hoisted(() => ({
resolveProviderAuthProfileMetadata: vi.fn(),
}));
async function runCatalogWithFetchGuard(params: {
fetchGuard: LiveModelCatalogFetchGuard;
auth: {
mode: "api_key" | "oauth";
apiKey: string;
discoveryApiKey?: string;
profileId?: string;
source: string;
};
accountId?: string;
baseUrl?: string;
}): Promise<ModelProviderConfig> {
if (params.auth.mode === "oauth") {
mocks.resolveApiKeyForProvider.mockResolvedValue({
...params.auth,
source: params.auth.source,
});
mocks.resolveProviderAuthProfileMetadata.mockReturnValue({
profileId: params.auth.profileId,
accountId: params.accountId,
});
}
const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => {
const guarded = await params.fetchGuard({
url: typeof input === "string" ? input : input instanceof URL ? input.href : input.url,
init,
});
await guarded.release();
return guarded.response;
});
try {
const result = await buildOpenAIProvider().catalog?.run({
resolveProviderAuth: () => params.auth,
resolveProviderApiKey: () => ({
apiKey: params.auth.apiKey,
discoveryApiKey: params.auth.discoveryApiKey,
}),
config: params.baseUrl
? { models: { providers: { openai: { baseUrl: params.baseUrl, models: [] } } } }
: { auth: { profiles: {} } },
agentDir: "/tmp/openai-agent",
workspaceDir: "/tmp/openai-workspace",
} as never);
if (!result || "provider" in result || !result.providers.openai) {
throw new Error("expected OpenAI live provider catalog");
}
return result.providers.openai;
} finally {
fetchSpy.mockRestore();
}
}
async function buildOpenAILiveProviderConfig(params: {
apiKey: string;
baseUrl?: string;
fetchGuard: LiveModelCatalogFetchGuard;
}): Promise<ModelProviderConfig> {
return await runCatalogWithFetchGuard({
fetchGuard: params.fetchGuard,
auth: { mode: "api_key", apiKey: params.apiKey, source: "profile" },
baseUrl: params.baseUrl,
});
}
async function buildOpenAICodexLiveProviderConfig(params: {
discoveryApiKey: string;
accountId?: string;
fetchGuard: LiveModelCatalogFetchGuard;
}): Promise<ModelProviderConfig> {
return await runCatalogWithFetchGuard({
fetchGuard: params.fetchGuard,
auth: {
mode: "oauth",
apiKey: params.discoveryApiKey,
profileId: "openai:chatgpt",
source: "profile",
},
accountId: params.accountId,
});
}
vi.mock("./openai-chatgpt-provider.runtime.js", () => ({
refreshOpenAICodexToken: mocks.refreshOpenAICodexToken,
}));
@@ -1998,6 +2076,33 @@ describe("buildOpenAIProvider", () => {
]);
});
it("keeps one native OpenAI web search tool when the payload is already patched", () => {
const provider = buildOpenAIProvider();
const wrap = provider.wrapStreamFn;
if (!wrap) {
throw new Error("expected OpenAI wrapper");
}
const result = runWrappedPayloadCase({
wrap,
provider: "openai",
modelId: "gpt-5.4",
model: {
api: "openai-responses",
provider: "openai",
id: "gpt-5.4",
baseUrl: "https://api.openai.com/v1",
} as Model<"openai-responses">,
payload: {
tools: [{ type: "web_search" }, { type: "function", name: "web_search" }],
reasoning: { effort: "minimal" },
},
});
expect(result.payload.tools).toEqual([{ type: "web_search" }]);
expect(result.payload.reasoning).toEqual({ effort: "low" });
});
it("keeps managed OpenAI web_search when agent policy denies native web search", () => {
const provider = buildOpenAIProvider();
const wrap = provider.wrapStreamFn;

View File

@@ -183,7 +183,7 @@ function buildOpenAIManifestModelsForBaseUrl(baseUrl: string): ModelDefinitionCo
);
}
export async function buildOpenAILiveProviderConfig(
async function buildOpenAILiveProviderConfig(
params: BuildOpenAILiveProviderConfigParams,
): Promise<ModelProviderConfig> {
const baseUrl =
@@ -446,7 +446,7 @@ function buildOpenAICodexStaticProviderConfig(): ModelProviderConfig {
};
}
export async function buildOpenAICodexLiveProviderConfig(params: {
async function buildOpenAICodexLiveProviderConfig(params: {
discoveryApiKey: string;
accountId?: string;
fetchGuard?: LiveModelCatalogFetchGuard;

View File

@@ -1,8 +1,5 @@
// Openai plugin module implements prompt overlay behavior.
import {
GPT5_BEHAVIOR_CONTRACT,
GPT5_FRIENDLY_CHAT_PROMPT_OVERLAY,
GPT5_HEARTBEAT_PROMPT_OVERLAY,
isGpt5ModelId,
resolveGpt5PromptOverlayMode,
resolveGpt5SystemPromptContribution,
@@ -11,10 +8,6 @@ import {
const OPENAI_PROVIDER_IDS = new Set(["openai"]);
export const OPENAI_FRIENDLY_PROMPT_OVERLAY = GPT5_FRIENDLY_CHAT_PROMPT_OVERLAY;
export const OPENAI_HEARTBEAT_PROMPT_OVERLAY = GPT5_HEARTBEAT_PROMPT_OVERLAY;
export const OPENAI_GPT5_BEHAVIOR_CONTRACT = GPT5_BEHAVIOR_CONTRACT;
type OpenAIPromptOverlayMode = Gpt5PromptOverlayMode;
export function resolveOpenAIPromptOverlayMode(
@@ -23,7 +16,7 @@ export function resolveOpenAIPromptOverlayMode(
return resolveGpt5PromptOverlayMode(undefined, pluginConfig);
}
export function shouldApplyOpenAIPromptOverlay(params: {
function shouldApplyOpenAIPromptOverlay(params: {
modelProviderId?: string;
modelId?: string;
}): boolean {

View File

@@ -70,7 +70,7 @@ export function captureOpenAIRealtimeWsClose(params: {
});
}
export type OpenAIRealtimeClientSecretResult = {
type OpenAIRealtimeClientSecretResult = {
value: string;
expiresAt?: number;
};

View File

@@ -16,7 +16,6 @@ import {
OPENAI_TTS_MODELS,
OPENAI_TTS_VOICES,
openaiTTS,
resolveOpenAITtsInstructions,
} from "./tts.js";
vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
@@ -120,32 +119,6 @@ describe("openai tts", () => {
);
});
describe("resolveOpenAITtsInstructions", () => {
it("keeps instructions only for gpt-4o-mini-tts variants", () => {
expect(resolveOpenAITtsInstructions("gpt-4o-mini-tts", " Speak warmly ")).toBe(
"Speak warmly",
);
expect(resolveOpenAITtsInstructions("gpt-4o-mini-tts-2025-12-15", "Speak warmly")).toBe(
"Speak warmly",
);
expect(resolveOpenAITtsInstructions("tts-1", "Speak warmly")).toBeUndefined();
expect(resolveOpenAITtsInstructions("tts-1-hd", "Speak warmly")).toBeUndefined();
expect(resolveOpenAITtsInstructions("gpt-4o-mini-tts", " ")).toBeUndefined();
});
it("preserves instructions for custom OpenAI-compatible TTS endpoints", () => {
expect(
resolveOpenAITtsInstructions("tts-1", " Speak warmly ", "https://tts.example.com/v1"),
).toBe("Speak warmly");
expect(
resolveOpenAITtsInstructions("tts-1", " Speak warmly ", "https://api.openai.com/v1/"),
).toBeUndefined();
expect(
resolveOpenAITtsInstructions("tts-1", " ", "https://tts.example.com/v1"),
).toBeUndefined();
});
});
describe("openaiTTS diagnostics", () => {
it("adds OpenClaw attribution headers to native OpenAI speech requests", async () => {
vi.stubEnv("OPENCLAW_VERSION", "2026.3.22");

View File

@@ -66,7 +66,7 @@ export function isValidOpenAIVoice(voice: string, baseUrl?: string): voice is Op
return OPENAI_TTS_VOICES.includes(voice as OpenAiTtsVoice);
}
export function resolveOpenAITtsInstructions(
function resolveOpenAITtsInstructions(
model: string,
instructions?: string,
baseUrl?: string,

View File

@@ -1,10 +1,35 @@
import { describe, expect, it, vi } from "vitest";
import { fetchOpenAIAdminUsage, fetchOpenAIUsage, resolveOpenAIUsageAuth } from "./usage.js";
import { fetchOpenAIUsage, resolveOpenAIUsageAuth } from "./usage.js";
function requestUrl(input: string | URL | Request): URL {
return new URL(input instanceof Request ? input.url : input);
}
async function fetchAdminUsage(params: {
apiKey: string;
projectId?: string;
fetchFn: typeof fetch;
}) {
const auth = await resolveOpenAIUsageAuth({
config: {},
env: { OPENAI_ADMIN_KEY: params.apiKey },
provider: "openai",
resolveApiKeyFromConfigAndStore: () => undefined,
resolveOAuthToken: async () => null,
});
if (!("token" in auth) || !auth.token) {
throw new Error("expected encoded OpenAI admin token");
}
return await fetchOpenAIUsage({
config: {},
env: params.projectId ? { OPENAI_PROJECT_ID: params.projectId } : {},
provider: "openai",
token: auth.token,
timeoutMs: 5_000,
fetchFn: params.fetchFn,
});
}
describe("OpenAI provider usage", () => {
it("aggregates provider-reported costs, tokens, models, and categories", async () => {
const fetchFn = vi.fn(async (input: string | URL | Request, _init?: RequestInit) => {
@@ -47,22 +72,19 @@ describe("OpenAI provider usage", () => {
);
});
const result = await fetchOpenAIAdminUsage({
const result = await fetchAdminUsage({
apiKey: "sk-admin-test",
projectId: "proj_test",
timeoutMs: 5_000,
fetchFn: fetchFn as typeof fetch,
now: Date.parse("2026-07-06T12:00:00Z"),
periodDays: 2,
});
expect(result).toMatchObject({
provider: "openai",
plan: "Admin API · proj_test",
billing: [{ type: "spend", amount: 12.34, unit: "USD", period: "2d" }],
billing: [{ type: "spend", amount: 12.34, unit: "USD", period: "30d" }],
costHistory: {
unit: "USD",
periodDays: 2,
periodDays: 30,
scope: "Project proj_test",
daily: [
{
@@ -99,9 +121,8 @@ describe("OpenAI provider usage", () => {
});
it("reports when organization usage rejects a non-admin key", async () => {
const result = await fetchOpenAIAdminUsage({
const result = await fetchAdminUsage({
apiKey: "sk-proj-test",
timeoutMs: 5_000,
fetchFn: vi.fn(async () => new Response("", { status: 403 })) as typeof fetch,
});
expect(result.error).toBe("Admin API key required");

View File

@@ -356,7 +356,7 @@ function aggregateHistory(params: {
};
}
export async function fetchOpenAIAdminUsage(params: {
async function fetchOpenAIAdminUsage(params: {
apiKey: string;
projectId?: string;
timeoutMs: number;

View File

@@ -1,19 +1,43 @@
// Opencode tests cover media understanding provider plugin behavior.
import { describe, expect, it } from "vitest";
import {
opencodeMediaUnderstandingProvider,
stripOpencodeDisabledResponsesReasoningPayload,
} from "./media-understanding-provider.js";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
describeImageWithModelPayloadTransform:
vi.fn<
(request: unknown, onPayload: (payload: unknown) => unknown) => Promise<{ text: string }>
>(),
}));
vi.mock("openclaw/plugin-sdk/media-understanding", async (importOriginal) => ({
...(await importOriginal<typeof import("openclaw/plugin-sdk/media-understanding")>()),
describeImageWithModelPayloadTransform: mocks.describeImageWithModelPayloadTransform,
}));
import { opencodeMediaUnderstandingProvider } from "./media-understanding-provider.js";
beforeEach(() => {
mocks.describeImageWithModelPayloadTransform.mockReset();
});
async function applyImagePayloadTransform(payload: Record<string, unknown>): Promise<void> {
mocks.describeImageWithModelPayloadTransform.mockImplementationOnce(
async (_request, onPayload) => {
await onPayload(payload);
return { text: "ok" };
},
);
await opencodeMediaUnderstandingProvider.describeImage?.({} as never);
}
describe("opencode media understanding provider", () => {
it("strips disabled Responses reasoning payloads", () => {
it("strips disabled Responses reasoning payloads", async () => {
const payload = {
reasoning: { effort: "none" },
include: ["reasoning.encrypted_content"],
store: false,
};
stripOpencodeDisabledResponsesReasoningPayload(payload);
await applyImagePayloadTransform(payload);
expect(payload).toEqual({
include: ["reasoning.encrypted_content"],
@@ -21,13 +45,13 @@ describe("opencode media understanding provider", () => {
});
});
it("keeps supported Responses reasoning payloads", () => {
it("keeps supported Responses reasoning payloads", async () => {
const payload = {
reasoning: { effort: "low" },
store: false,
};
stripOpencodeDisabledResponsesReasoningPayload(payload);
await applyImagePayloadTransform(payload);
expect(payload).toEqual({
reasoning: { effort: "low" },

View File

@@ -7,7 +7,7 @@ import {
} from "openclaw/plugin-sdk/media-understanding";
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
export function stripOpencodeDisabledResponsesReasoningPayload(payload: unknown): void {
function stripOpencodeDisabledResponsesReasoningPayload(payload: unknown): void {
if (!isRecord(payload)) {
return;
}

View File

@@ -214,7 +214,7 @@ type OpencodeZenModelDefinition = ModelDefinitionConfig & {
input: Array<"text" | "image">;
};
export type FetchOpencodeZenLiveModelIdsParams = {
type FetchOpencodeZenLiveModelIdsParams = {
apiKey?: string;
discoveryApiKey?: string;
fetchGuard?: LiveModelCatalogFetchGuard;

View File

@@ -37,12 +37,6 @@ import {
type OpenCodeSessionPage,
} from "./session-catalog.js";
export {
OPENCODE_SESSIONS_LIST_COMMAND,
OPENCODE_SESSION_READ_COMMAND,
OPENCODE_TERMINAL_RESUME_COMMAND,
} from "./session-catalog-shared.js";
const MAX_HOSTS = 100;
const MAX_CURSOR_LENGTH = 128;
const TRANSCRIPT_ITEM_TYPES = new Set([
@@ -147,7 +141,7 @@ function fullConfigCatalogEnabled(config: unknown): boolean {
return entry.config.sessionCatalog.enabled !== false;
}
export function isOpenCodeSessionCatalogEnabled(pluginConfig: unknown): boolean {
function isOpenCodeSessionCatalogEnabled(pluginConfig: unknown): boolean {
return (
!isRecord(pluginConfig) ||
!isRecord(pluginConfig.sessionCatalog) ||
@@ -155,7 +149,7 @@ export function isOpenCodeSessionCatalogEnabled(pluginConfig: unknown): boolean
);
}
export function createOpenCodeSessionNodeHostCommands(): OpenClawPluginNodeHostCommand[] {
function createOpenCodeSessionNodeHostCommands(): OpenClawPluginNodeHostCommand[] {
const available = ({ config, env }: { config: unknown; env: NodeJS.ProcessEnv }) =>
fullConfigCatalogEnabled(config) && executableOnPath("opencode", env);
return [
@@ -179,7 +173,7 @@ export function createOpenCodeSessionNodeHostCommands(): OpenClawPluginNodeHostC
];
}
export function createOpenCodeSessionNodeInvokePolicies(): OpenClawPluginNodeInvokePolicy[] {
function createOpenCodeSessionNodeInvokePolicies(): OpenClawPluginNodeInvokePolicy[] {
return [
{
commands: [

View File

@@ -32,15 +32,12 @@ vi.mock("openclaw/plugin-sdk/node-host", async (importOriginal) => {
};
});
import { registerOpenCodeSessionCatalog } from "./session-catalog-plugin.js";
import {
createOpenCodeSessionNodeInvokePolicies,
createOpenCodeSessionNodeHostCommands,
isOpenCodeSessionCatalogEnabled,
OPENCODE_SESSIONS_LIST_COMMAND,
OPENCODE_SESSION_READ_COMMAND,
OPENCODE_TERMINAL_RESUME_COMMAND,
registerOpenCodeSessionCatalog,
} from "./session-catalog-plugin.js";
} from "./session-catalog-shared.js";
import {
listLocalOpenCodeSessionPage,
readLocalOpenCodeTranscriptPage,
@@ -50,6 +47,25 @@ const temporaryDirectories: string[] = [];
const originalPath = process.env.PATH;
const originalUnrelatedEnv = process.env.CATALOG_UNRELATED_ENV;
function captureOpenCodeSessionRegistrations(pluginConfig: unknown = {}) {
const catalogs: Array<Parameters<OpenClawPluginApi["registerSessionCatalog"]>[0]> = [];
const commands: Array<Parameters<OpenClawPluginApi["registerNodeHostCommand"]>[0]> = [];
const policies: Array<Parameters<OpenClawPluginApi["registerNodeInvokePolicy"]>[0]> = [];
registerOpenCodeSessionCatalog({
pluginConfig,
runtime: { nodes: { list: vi.fn().mockResolvedValue({ nodes: [] }) } },
registerSessionCatalog: (catalog: Parameters<OpenClawPluginApi["registerSessionCatalog"]>[0]) =>
catalogs.push(catalog),
registerNodeHostCommand: (
command: Parameters<OpenClawPluginApi["registerNodeHostCommand"]>[0],
) => commands.push(command),
registerNodeInvokePolicy: (
policy: Parameters<OpenClawPluginApi["registerNodeInvokePolicy"]>[0],
) => policies.push(policy),
} as unknown as OpenClawPluginApi);
return { catalogs, commands, policies };
}
async function installFakeOpenCode(
assistantText = "hi",
sessionTitle = "Catalog session",
@@ -221,7 +237,7 @@ describe("OpenCode session catalog", () => {
"auto-detects the CLI and honors the node-local Web UI switch",
async () => {
const directory = await installFakeOpenCode();
const commands = createOpenCodeSessionNodeHostCommands();
const { commands } = captureOpenCodeSessionRegistrations();
expect(commands.map((command) => command.command)).toEqual([
OPENCODE_SESSIONS_LIST_COMMAND,
OPENCODE_SESSION_READ_COMMAND,
@@ -293,7 +309,8 @@ describe("OpenCode session catalog", () => {
"runs only catalog-validated OpenCode sessions through the node PTY",
async () => {
await installFakeOpenCode();
const terminal = createOpenCodeSessionNodeHostCommands().find(
const { commands, policies } = captureOpenCodeSessionRegistrations();
const terminal = commands.find(
(command) => command.command === OPENCODE_TERMINAL_RESUME_COMMAND,
);
const io = {
@@ -325,7 +342,7 @@ describe("OpenCode session catalog", () => {
).rejects.toThrow("threadId is invalid");
const invokeNode = vi.fn(() => ({ ok: false as const, error: "unexpected" }));
const policy = createOpenCodeSessionNodeInvokePolicies()[0]!;
const policy = policies[0]!;
expect(
policy.handle({ command: OPENCODE_TERMINAL_RESUME_COMMAND, invokeNode } as never),
).toEqual({ ok: true });
@@ -407,14 +424,10 @@ describe("OpenCode session catalog", () => {
});
it("does not register the catalog when explicitly disabled", () => {
const registerSessionCatalog = vi.fn();
const api = {
pluginConfig: { sessionCatalog: { enabled: false } },
registerSessionCatalog,
} as unknown as OpenClawPluginApi;
registerOpenCodeSessionCatalog(api);
expect(isOpenCodeSessionCatalogEnabled(api.pluginConfig)).toBe(false);
expect(registerSessionCatalog).not.toHaveBeenCalled();
const registrations = captureOpenCodeSessionRegistrations({
sessionCatalog: { enabled: false },
});
expect(registrations).toEqual({ catalogs: [], commands: [], policies: [] });
});
it("bridges paired-node list and read requests without undefined transport fields", async () => {

View File

@@ -1,9 +1,6 @@
// Openrouter tests cover image generation provider plugin behavior.
import { afterEach, describe, expect, it, vi } from "vitest";
import {
buildOpenRouterImageGenerationProvider,
extractOpenRouterImagesFromResponse,
} from "./image-generation-provider.js";
import { buildOpenRouterImageGenerationProvider } from "./image-generation-provider.js";
const {
assertOkOrThrowHttpErrorMock,
@@ -324,31 +321,46 @@ describe("openrouter image generation provider", () => {
).rejects.toThrow("OpenRouter image generation response malformed");
});
it("extracts image fallbacks from string content and raw b64 parts", () => {
it("extracts image fallbacks from string content and raw b64 parts", async () => {
const png = Buffer.from("png-inline").toString("base64");
const raw = Buffer.from("raw-inline").toString("base64");
const images = extractOpenRouterImagesFromResponse({
choices: [
{
message: {
content: `done data:image/png;base64,${png}`,
},
},
{
message: {
content: [{ b64_json: raw }],
},
},
],
postJsonRequestMock.mockResolvedValue({
response: {
json: async () => ({
choices: [
{
message: {
content: `done data:image/png;base64,${png}`,
},
},
{
message: {
content: [{ b64_json: raw }],
},
},
],
}),
},
release: vi.fn(async () => {}),
});
expect(images.map((image) => image.buffer.toString())).toEqual(["png-inline", "raw-inline"]);
const result = await buildOpenRouterImageGenerationProvider().generateImage({
provider: "openrouter",
model: "google/gemini-3.1-flash-image-preview",
prompt: "draw image fallbacks",
cfg: {},
});
expect(result.images.map((image) => image.buffer.toString())).toEqual([
"png-inline",
"raw-inline",
]);
});
it("rejects invalid raw image parts in strict extraction mode", () => {
expect(() =>
extractOpenRouterImagesFromResponse(
{
it("rejects invalid raw image parts in strict extraction mode", async () => {
postJsonRequestMock.mockResolvedValue({
response: {
json: async () => ({
choices: [
{
message: {
@@ -356,9 +368,18 @@ describe("openrouter image generation provider", () => {
},
},
],
},
{ malformedResponseError: "OpenRouter image generation response malformed" },
),
).toThrow("OpenRouter image generation response malformed");
}),
},
release: vi.fn(async () => {}),
});
await expect(
buildOpenRouterImageGenerationProvider().generateImage({
provider: "openrouter",
model: "google/gemini-3.1-flash-image-preview",
prompt: "draw invalid fallback",
cfg: {},
}),
).rejects.toThrow("OpenRouter image generation response malformed");
});
});

View File

@@ -136,7 +136,7 @@ function extractImagesFromPart(
throwMalformedOpenRouterImageResponse(malformedResponseError);
}
export function extractOpenRouterImagesFromResponse(
function extractOpenRouterImagesFromResponse(
body: unknown,
options: { malformedResponseError?: string } = {},
): GeneratedImageAsset[] {

View File

@@ -4,10 +4,12 @@ import {
describeImagesWithModel,
} from "openclaw/plugin-sdk/media-understanding";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
openrouterMediaUnderstandingProvider,
transcribeOpenRouterAudio,
} from "./media-understanding-provider.js";
import { openrouterMediaUnderstandingProvider } from "./media-understanding-provider.js";
const transcribeOpenRouterAudio = openrouterMediaUnderstandingProvider.transcribeAudio;
if (!transcribeOpenRouterAudio) {
throw new Error("expected OpenRouter audio transcription provider");
}
const { assertOkOrThrowHttpErrorMock, postJsonRequestMock, resolveProviderHttpRequestConfigMock } =
vi.hoisted(() => ({

View File

@@ -100,7 +100,7 @@ type OpenRouterSttResponse = {
text?: string;
};
export async function transcribeOpenRouterAudio(
async function transcribeOpenRouterAudio(
params: AudioTranscriptionRequest,
): Promise<AudioTranscriptionResult> {
const model = params.model?.trim() || DEFAULT_OPENROUTER_AUDIO_TRANSCRIPTION_MODEL;

View File

@@ -3,27 +3,8 @@ import {
isOpenRouterDeepSeekV4ModelId,
isOpenRouterMistralModelId,
normalizeOpenRouterApiModelId,
normalizeOpenRouterModelId,
} from "./models.js";
describe("normalizeOpenRouterModelId", () => {
it("strips the openrouter/ provider qualifier", () => {
expect(normalizeOpenRouterModelId("openrouter/deepseek/deepseek-v4-flash")).toBe(
"deepseek/deepseek-v4-flash",
);
});
it("leaves non-openrouter refs unchanged", () => {
expect(normalizeOpenRouterModelId("deepseek/deepseek-v4-flash")).toBe(
"deepseek/deepseek-v4-flash",
);
});
it("returns undefined for non-strings", () => {
expect(normalizeOpenRouterModelId(null)).toBeUndefined();
});
});
describe("normalizeOpenRouterApiModelId", () => {
it.each([
["openrouter/deepseek-v4-flash", "deepseek/deepseek-v4-flash"],
@@ -58,7 +39,9 @@ describe("isOpenRouterDeepSeekV4ModelId", () => {
it("matches namespaced DeepSeek V4 refs", () => {
expect(isOpenRouterDeepSeekV4ModelId("openrouter/deepseek/deepseek-v4-flash")).toBe(true);
expect(isOpenRouterDeepSeekV4ModelId("openrouter/deepseek/deepseek-v4-pro")).toBe(true);
expect(isOpenRouterDeepSeekV4ModelId("deepseek/deepseek-v4-flash")).toBe(true);
expect(isOpenRouterDeepSeekV4ModelId("openrouter/anthropic/claude-sonnet-4-6")).toBe(false);
expect(isOpenRouterDeepSeekV4ModelId(null)).toBe(false);
});
});
@@ -66,5 +49,7 @@ describe("isOpenRouterMistralModelId", () => {
it("matches Mistral-prefixed refs", () => {
expect(isOpenRouterMistralModelId("openrouter/mistral/ministral-8b")).toBe(true);
expect(isOpenRouterMistralModelId("openrouter/codestral-22b")).toBe(true);
expect(isOpenRouterMistralModelId("mistral/ministral-8b")).toBe(true);
expect(isOpenRouterMistralModelId(null)).toBe(false);
});
});

View File

@@ -21,7 +21,7 @@ const OPENROUTER_SHORT_TO_API_MODEL_ID = new Map([
["deepseek-v4-pro", "deepseek/deepseek-v4-pro"],
]);
export function normalizeOpenRouterModelId(modelId: unknown): string | undefined {
function normalizeOpenRouterModelId(modelId: unknown): string | undefined {
if (typeof modelId !== "string") {
return undefined;
}

View File

@@ -1,20 +1,16 @@
// Openrouter OAuth tests cover PKCE exchange and auth profile output.
import type { ProviderAuthContext } from "openclaw/plugin-sdk/plugin-entry";
import { describe, expect, it, vi } from "vitest";
import {
buildOpenRouterOAuthAuthorizeUrl,
buildOpenRouterOAuthRedirectUri,
exchangeOpenRouterOAuthCode,
loginOpenRouterOAuth,
OPENROUTER_OAUTH_CALLBACK_PATH,
OPENROUTER_OAUTH_CALLBACK_PORT,
OPENROUTER_OAUTH_CHOICE_ID,
OPENROUTER_OAUTH_CODE_CHALLENGE_METHOD,
OPENROUTER_OAUTH_REDIRECT_URI,
OPENROUTER_OAUTH_TOKEN_URL,
parseOpenRouterOAuthCallbackInput,
waitForOpenRouterOAuthCallback,
} from "./oauth.js";
import { createOpenRouterOAuthAuthMethod } from "./oauth.js";
const OPENROUTER_OAUTH_REDIRECT_URI = "http://localhost:3000/openrouter-oauth/callback";
type OpenRouterOAuthLoginOptions = NonNullable<
Parameters<typeof createOpenRouterOAuthAuthMethod>[0]
>;
function loginOpenRouterOAuth(ctx: ProviderAuthContext, options: OpenRouterOAuthLoginOptions = {}) {
return createOpenRouterOAuthAuthMethod(options).run(ctx);
}
function jsonResponse(value: unknown, init?: ResponseInit): Response {
return new Response(JSON.stringify(value), {
@@ -101,11 +97,13 @@ function requestJsonBody(init: RequestInit | undefined): Record<string, unknown>
function createOpenRouterOAuthContext(params: {
isRemote: boolean;
onProgress?: (message: string) => void;
redirectInput?: string;
openUrl?: (url: string) => Promise<void>;
signal?: AbortSignal;
}) {
const progress = {
update: vi.fn(),
update: vi.fn((message: string) => params.onProgress?.(message)),
stop: vi.fn(),
};
const note = vi.fn<(message: string, title?: string) => Promise<void>>(async () => undefined);
@@ -120,6 +118,7 @@ function createOpenRouterOAuthContext(params: {
config: {},
isRemote: params.isRemote,
openUrl,
signal: params.signal,
prompter: {
note,
text,
@@ -145,125 +144,158 @@ async function requestLocalOpenRouterOAuthCallback(
const ready = new Promise<void>((resolve) => {
markReady = resolve;
});
const callback = waitForOpenRouterOAuthCallback({
expectedState: "state-1",
timeoutMs: 1000,
onProgress: markReady,
const controller = new AbortController();
const redirectInput = `${OPENROUTER_OAUTH_REDIRECT_URI}?${query}`;
const { ctx } = createOpenRouterOAuthContext({
isRemote: false,
onProgress: (message) => {
if (message.startsWith("Waiting for OpenRouter OAuth callback")) {
markReady();
}
},
redirectInput,
signal: controller.signal,
});
const callback = loginOpenRouterOAuth(ctx, {
createPkce: () => ({ verifier: "verifier-1", challenge: "challenge-1" }),
createState: () => "state-1",
fetchImpl: vi.fn(async () => jsonResponse({ key: "sk-or-v1-test" })),
});
callback.catch(() => undefined);
await ready;
await Promise.race([
ready,
callback.then(
() => {
throw new Error("OpenRouter OAuth completed before callback server started");
},
(error: unknown) => {
throw error;
},
),
]);
const response = await fetch(`${OPENROUTER_OAUTH_REDIRECT_URI}?${query}`, {
headers: { Connection: "close" },
try {
const response = await fetch(redirectInput, { headers: { Connection: "close" } });
return { callback, response, body: await response.text() };
} catch (error) {
controller.abort();
throw error;
}
}
function runRemoteOpenRouterOAuthRedirect(redirectInput: string) {
const { ctx } = createOpenRouterOAuthContext({ isRemote: true, redirectInput });
return loginOpenRouterOAuth(ctx, {
createPkce: () => ({ verifier: "verifier-1", challenge: "challenge-1" }),
createState: () => "state-1",
fetchImpl: vi.fn(async () => jsonResponse({ key: "sk-or-v1-test" })),
});
return { callback, response, body: await response.text() };
}
describe("OpenRouter OAuth", () => {
it("builds the documented PKCE authorize URL", () => {
const url = new URL(
buildOpenRouterOAuthAuthorizeUrl({ codeChallenge: "challenge-1", state: "state-1" }),
);
it("builds the documented PKCE authorize URL", async () => {
const { ctx, openUrl } = createOpenRouterOAuthContext({ isRemote: true });
await loginOpenRouterOAuth(ctx, {
createPkce: () => ({ verifier: "verifier-1", challenge: "challenge-1" }),
createState: () => "state-1",
fetchImpl: vi.fn(async () => jsonResponse({ key: "sk-or-v1-test" })),
});
const openedUrl = (openUrl as ReturnType<typeof vi.fn>).mock.calls[0]?.[0];
if (typeof openedUrl !== "string") {
throw new Error("expected OpenRouter OAuth authorize URL");
}
const url = new URL(openedUrl);
const callbackUrl = new URL(url.searchParams.get("callback_url") ?? "");
expect(url.origin + url.pathname).toBe("https://openrouter.ai/auth");
expect(callbackUrl.origin + callbackUrl.pathname).toBe(OPENROUTER_OAUTH_REDIRECT_URI);
expect(callbackUrl.searchParams.get("state")).toBe("state-1");
expect(url.searchParams.get("code_challenge")).toBe("challenge-1");
expect(url.searchParams.get("code_challenge_method")).toBe(
OPENROUTER_OAUTH_CODE_CHALLENGE_METHOD,
);
expect(OPENROUTER_OAUTH_REDIRECT_URI).toContain(`:${OPENROUTER_OAUTH_CALLBACK_PORT}/`);
expect(OPENROUTER_OAUTH_REDIRECT_URI).toContain(OPENROUTER_OAUTH_CALLBACK_PATH);
expect(url.searchParams.get("code_challenge_method")).toBe("S256");
});
it("parses state-bound OpenRouter redirect URLs and query strings", () => {
expect(
parseOpenRouterOAuthCallbackInput(
it("parses state-bound OpenRouter redirect URLs and query strings", async () => {
await expect(
runRemoteOpenRouterOAuthRedirect(
`${OPENROUTER_OAUTH_REDIRECT_URI}?state=state-1&code=AUTHCODE`,
"state-1",
),
).toEqual({ code: "AUTHCODE", state: "state-1" });
expect(parseOpenRouterOAuthCallbackInput("state=state-1&code=AUTHCODE", "state-1")).toEqual({
code: "AUTHCODE",
state: "state-1",
});
expect(() =>
parseOpenRouterOAuthCallbackInput(
).resolves.toMatchObject({ defaultModel: "openrouter/auto" });
await expect(
runRemoteOpenRouterOAuthRedirect("state=state-1&code=AUTHCODE"),
).resolves.toMatchObject({ defaultModel: "openrouter/auto" });
await expect(
runRemoteOpenRouterOAuthRedirect(
`${OPENROUTER_OAUTH_REDIRECT_URI}?state=state-1&error=access_denied&error_description=Denied`,
"state-1",
),
).toThrow("OpenRouter OAuth error: access_denied: Denied");
expect(() =>
parseOpenRouterOAuthCallbackInput(
).rejects.toThrow("OpenRouter OAuth error: access_denied: Denied");
await expect(
runRemoteOpenRouterOAuthRedirect(
"state=state-1&error=access_denied&error_description=Denied",
"state-1",
),
).toThrow("OpenRouter OAuth error: access_denied: Denied");
expect(() =>
parseOpenRouterOAuthCallbackInput(
).rejects.toThrow("OpenRouter OAuth error: access_denied: Denied");
await expect(
runRemoteOpenRouterOAuthRedirect(
`${OPENROUTER_OAUTH_REDIRECT_URI}?error=access_denied&error_description=Denied`,
"state-1",
),
).toThrow("Missing OpenRouter OAuth state");
expect(() =>
parseOpenRouterOAuthCallbackInput(
).rejects.toThrow("Missing OpenRouter OAuth state");
await expect(
runRemoteOpenRouterOAuthRedirect(
`${OPENROUTER_OAUTH_REDIRECT_URI}?state=wrong&error=access_denied&error_description=Denied`,
"state-1",
),
).toThrow("OpenRouter OAuth state mismatch");
expect(buildOpenRouterOAuthRedirectUri({ state: "state-1" })).toBe(
`${OPENROUTER_OAUTH_REDIRECT_URI}?state=state-1`,
);
expect(() =>
parseOpenRouterOAuthCallbackInput(
`${OPENROUTER_OAUTH_REDIRECT_URI}?code=AUTHCODE`,
"state-1",
),
).toThrow("Missing OpenRouter OAuth state");
expect(() =>
parseOpenRouterOAuthCallbackInput(
).rejects.toThrow("OpenRouter OAuth state mismatch");
await expect(
runRemoteOpenRouterOAuthRedirect(`${OPENROUTER_OAUTH_REDIRECT_URI}?code=AUTHCODE`),
).rejects.toThrow("Missing OpenRouter OAuth state");
await expect(
runRemoteOpenRouterOAuthRedirect(
`${OPENROUTER_OAUTH_REDIRECT_URI}?state=wrong&code=AUTHCODE`,
"state-1",
),
).toThrow("OpenRouter OAuth state mismatch");
expect(() => parseOpenRouterOAuthCallbackInput("AUTHCODE", "state-1")).toThrow(
).rejects.toThrow("OpenRouter OAuth state mismatch");
await expect(runRemoteOpenRouterOAuthRedirect("AUTHCODE")).rejects.toThrow(
"Paste the full OpenRouter redirect URL",
);
});
it("exchanges an authorization code for the issued OpenRouter API key", async () => {
const fetchImpl = vi.fn<typeof fetch>(async (url, init) => {
expect(requestUrl(url)).toBe(OPENROUTER_OAUTH_TOKEN_URL);
expect(requestUrl(url)).toBe("https://openrouter.ai/api/v1/auth/keys");
expect(init?.method).toBe("POST");
expect(new Headers(init?.headers).get("content-type")).toBe("application/json");
expect(requestJsonBody(init)).toEqual({
code: "AUTHCODE",
code_verifier: "verifier-1",
code_challenge_method: OPENROUTER_OAUTH_CODE_CHALLENGE_METHOD,
code_challenge_method: "S256",
});
return jsonResponse({ key: "sk-or-v1-test", user_id: "user-1" });
});
const { ctx } = createOpenRouterOAuthContext({ isRemote: true });
await expect(
exchangeOpenRouterOAuthCode({
code: "AUTHCODE",
codeVerifier: "verifier-1",
loginOpenRouterOAuth(ctx, {
createPkce: () => ({ verifier: "verifier-1", challenge: "challenge-1" }),
createState: () => "state-1",
fetchImpl,
}),
).resolves.toEqual({
key: "sk-or-v1-test",
userId: "user-1",
).resolves.toMatchObject({
profiles: [
{
credential: {
key: "sk-or-v1-test",
metadata: { userId: "user-1" },
},
},
],
});
});
it("bounds successful OpenRouter OAuth responses", async () => {
const oversized = oversizedJsonResponse();
const { ctx } = createOpenRouterOAuthContext({ isRemote: true });
await expect(
exchangeOpenRouterOAuthCode({
code: "AUTHCODE",
codeVerifier: "verifier-1",
loginOpenRouterOAuth(ctx, {
createPkce: () => ({ verifier: "verifier-1", challenge: "challenge-1" }),
createState: () => "state-1",
fetchImpl: vi.fn(async () => oversized.response),
}),
).rejects.toThrow("OpenRouter OAuth key exchange: JSON response exceeds 16777216 bytes");
@@ -279,18 +311,26 @@ describe("OpenRouter OAuth", () => {
.mockResolvedValueOnce(
jsonResponse({ error: { message: "Invalid code", code: 400 } }, { status: 400 }),
);
const first = createOpenRouterOAuthContext({
isRemote: true,
redirectInput: `${OPENROUTER_OAUTH_REDIRECT_URI}?state=state-1&code=bad-code`,
});
const second = createOpenRouterOAuthContext({
isRemote: true,
redirectInput: `${OPENROUTER_OAUTH_REDIRECT_URI}?state=state-1&code=bad-code`,
});
await expect(
exchangeOpenRouterOAuthCode({
code: "bad-code",
codeVerifier: "bad-verifier",
loginOpenRouterOAuth(first.ctx, {
createPkce: () => ({ verifier: "bad-verifier", challenge: "challenge-1" }),
createState: () => "state-1",
fetchImpl,
}),
).rejects.toThrow("OpenRouter OAuth key exchange failed (403): Invalid code or code_verifier");
await expect(
exchangeOpenRouterOAuthCode({
code: "bad-code",
codeVerifier: "bad-verifier",
loginOpenRouterOAuth(second.ctx, {
createPkce: () => ({ verifier: "bad-verifier", challenge: "challenge-1" }),
createState: () => "state-1",
fetchImpl,
}),
).rejects.toThrow("OpenRouter OAuth key exchange failed (400): Invalid code");
@@ -302,12 +342,16 @@ describe("OpenRouter OAuth", () => {
502,
);
const fetchImpl = vi.fn<typeof fetch>(async () => errorResponse.response);
const { ctx } = createOpenRouterOAuthContext({
isRemote: true,
redirectInput: `${OPENROUTER_OAUTH_REDIRECT_URI}?state=state-1&code=bad-code`,
});
let error: unknown;
try {
await exchangeOpenRouterOAuthCode({
code: "bad-code",
codeVerifier: "bad-verifier",
await loginOpenRouterOAuth(ctx, {
createPkce: () => ({ verifier: "bad-verifier", challenge: "challenge-1" }),
createState: () => "state-1",
fetchImpl,
});
} catch (caught) {
@@ -369,7 +413,7 @@ describe("OpenRouter OAuth", () => {
it("uses the local callback path before opening the browser locally", async () => {
const fetchImpl = vi.fn<typeof fetch>(async () => jsonResponse({ key: "sk-or-v1-test" }));
const waitForCallback = vi.fn<typeof waitForOpenRouterOAuthCallback>(async () => ({
const waitForCallback = vi.fn(async (_params: { expectedState: string }) => ({
code: "AUTHCODE",
state: "state-1",
}));
@@ -417,6 +461,6 @@ describe("OpenRouter OAuth", () => {
});
it("exposes stable auth choice metadata", () => {
expect(OPENROUTER_OAUTH_CHOICE_ID).toBe("openrouter-oauth");
expect(createOpenRouterOAuthAuthMethod().wizard?.choiceId).toBe("openrouter-oauth");
});
});

View File

@@ -17,14 +17,14 @@ import { applyOpenrouterConfig, OPENROUTER_DEFAULT_MODEL_REF } from "./onboard.j
const PROVIDER_ID = "openrouter";
const OPENROUTER_OAUTH_METHOD_ID = "oauth";
export const OPENROUTER_OAUTH_CHOICE_ID = "openrouter-oauth";
const OPENROUTER_OAUTH_CHOICE_ID = "openrouter-oauth";
const OPENROUTER_OAUTH_AUTHORIZE_URL = "https://openrouter.ai/auth";
export const OPENROUTER_OAUTH_TOKEN_URL = "https://openrouter.ai/api/v1/auth/keys";
export const OPENROUTER_OAUTH_CALLBACK_HOST = "localhost";
export const OPENROUTER_OAUTH_CALLBACK_PORT = 3000;
export const OPENROUTER_OAUTH_CALLBACK_PATH = "/openrouter-oauth/callback";
export const OPENROUTER_OAUTH_REDIRECT_URI = `http://${OPENROUTER_OAUTH_CALLBACK_HOST}:${OPENROUTER_OAUTH_CALLBACK_PORT}${OPENROUTER_OAUTH_CALLBACK_PATH}`;
export const OPENROUTER_OAUTH_CODE_CHALLENGE_METHOD = "S256";
const OPENROUTER_OAUTH_TOKEN_URL = "https://openrouter.ai/api/v1/auth/keys";
const OPENROUTER_OAUTH_CALLBACK_HOST = "localhost";
const OPENROUTER_OAUTH_CALLBACK_PORT = 3000;
const OPENROUTER_OAUTH_CALLBACK_PATH = "/openrouter-oauth/callback";
const OPENROUTER_OAUTH_REDIRECT_URI = `http://${OPENROUTER_OAUTH_CALLBACK_HOST}:${OPENROUTER_OAUTH_CALLBACK_PORT}${OPENROUTER_OAUTH_CALLBACK_PATH}`;
const OPENROUTER_OAUTH_CODE_CHALLENGE_METHOD = "S256";
const OPENROUTER_OAUTH_TIMEOUT_MS = 5 * 60 * 1000;
const OPENROUTER_OAUTH_FETCH_TIMEOUT_MS = 30 * 1000;
@@ -106,13 +106,13 @@ function parseOpenRouterKeyResponse(value: unknown): OpenRouterOAuthKeyResult {
};
}
export function buildOpenRouterOAuthRedirectUri(params: { state: string }): string {
function buildOpenRouterOAuthRedirectUri(params: { state: string }): string {
const url = new URL(OPENROUTER_OAUTH_REDIRECT_URI);
url.searchParams.set("state", params.state);
return url.toString();
}
export function buildOpenRouterOAuthAuthorizeUrl(params: {
function buildOpenRouterOAuthAuthorizeUrl(params: {
codeChallenge: string;
state: string;
}): string {
@@ -133,7 +133,7 @@ function requireOpenRouterOAuthState(state: string | undefined, expectedState: s
return state;
}
export function parseOpenRouterOAuthCallbackInput(
function parseOpenRouterOAuthCallbackInput(
input: string,
expectedState: string,
): OpenRouterOAuthCallbackResult {
@@ -174,7 +174,7 @@ export function parseOpenRouterOAuthCallbackInput(
}
}
export async function exchangeOpenRouterOAuthCode(params: {
async function exchangeOpenRouterOAuthCode(params: {
code: string;
codeVerifier: string;
fetchImpl?: typeof fetch;
@@ -206,7 +206,7 @@ export async function exchangeOpenRouterOAuthCode(params: {
return parseOpenRouterKeyResponse(body);
}
export async function waitForOpenRouterOAuthCallback(params: {
async function waitForOpenRouterOAuthCallback(params: {
expectedState: string;
timeoutMs?: number;
onProgress?: (message: string) => void;
@@ -396,7 +396,7 @@ async function resolveOpenRouterOAuthCode(
return (await callbackPromise).code;
}
export async function loginOpenRouterOAuth(
async function loginOpenRouterOAuth(
ctx: ProviderAuthContext,
options: OpenRouterOAuthLoginOptions = {},
): Promise<ProviderAuthResult> {
@@ -447,7 +447,9 @@ export async function loginOpenRouterOAuth(
}
}
export function createOpenRouterOAuthAuthMethod(): ProviderAuthMethod {
export function createOpenRouterOAuthAuthMethod(
options: OpenRouterOAuthLoginOptions = {},
): ProviderAuthMethod {
return {
id: OPENROUTER_OAUTH_METHOD_ID,
label: "OpenRouter OAuth",
@@ -464,6 +466,6 @@ export function createOpenRouterOAuthAuthMethod(): ProviderAuthMethod {
onboardingScopes: ["text-inference", "music-generation"],
onboardingFeatured: true,
},
run: async (ctx) => await loginOpenRouterOAuth(ctx),
run: async (ctx) => await loginOpenRouterOAuth(ctx, options),
};
}

View File

@@ -65,42 +65,6 @@ function scanSuccessRequest(logPath) {
return { responseCount, success, recentResponses };
}
function assertPatchBehavior() {
return import("../../../../dist/extensions/openai/native-web-search.js").then(
({ patchOpenAINativeWebSearchPayload }) => {
const injectedPayload = {
reasoning: { effort: "minimal", summary: "auto" },
};
const injectedResult = patchOpenAINativeWebSearchPayload(injectedPayload);
if (injectedResult !== "injected") {
throw new Error(`expected native web_search injection, got ${injectedResult}`);
}
if (injectedPayload.reasoning.effort !== "low") {
throw new Error(
`expected injected native web_search to raise minimal reasoning to low, got ${JSON.stringify(injectedPayload.reasoning)}`,
);
}
if (!injectedPayload.tools?.some((tool) => tool?.type === "web_search")) {
throw new Error(`native web_search was not injected: ${JSON.stringify(injectedPayload)}`);
}
const existingNativePayload = {
tools: [{ type: "web_search" }],
reasoning: { effort: "minimal" },
};
const existingResult = patchOpenAINativeWebSearchPayload(existingNativePayload);
if (existingResult !== "native_tool_already_present") {
throw new Error(`expected existing native web_search, got ${existingResult}`);
}
if (existingNativePayload.reasoning.effort !== "low") {
throw new Error(
`expected existing native web_search to raise minimal reasoning to low, got ${JSON.stringify(existingNativePayload.reasoning)}`,
);
}
},
);
}
function assertSuccessRequest() {
const logPath = process.argv[3];
const { responseCount, success, recentResponses } = scanSuccessRequest(logPath);
@@ -129,7 +93,6 @@ function assertSuccessRequest() {
}
const commands = {
"assert-patch-behavior": assertPatchBehavior,
"assert-success-request": assertSuccessRequest,
};

View File

@@ -60,8 +60,6 @@ trap 'status=$?; dump_debug_logs "$status"; exit "$status"' ERR
entry="$(openclaw_e2e_resolve_entrypoint)"
mkdir -p "$OPENCLAW_STATE_DIR" "$TLS_DIR"
node scripts/e2e/lib/openai-web-search-minimal/assertions.mjs assert-patch-behavior
node scripts/e2e/lib/fixture.mjs openai-web-search-minimal-config
openssl req -x509 -newkey rsa:2048 -nodes -sha256 -days 1 \