From e7cba0e4d5ddfff47a3f5fffcc662779ceeec970 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 15 Jul 2026 22:33:54 -0700 Subject: [PATCH] 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 --- extensions/codex/harness.ts | 8 - .../codex/media-understanding-provider.ts | 2 +- extensions/codex/src/app-server/models.ts | 4 +- .../connection-bound-ids.live.test.ts | 9 +- .../connection-bound-ids.test.ts | 22 +- .../github-copilot/connection-bound-ids.ts | 6 +- extensions/github-copilot/domain.test.ts | 35 +- extensions/github-copilot/domain.ts | 34 ++ extensions/github-copilot/index.test.ts | 31 +- extensions/github-copilot/login.test.ts | 67 ++-- extensions/github-copilot/login.ts | 57 +--- extensions/github-copilot/models.test.ts | 5 +- extensions/github-copilot/stream.test.ts | 50 +-- extensions/github-copilot/stream.ts | 6 +- extensions/github-copilot/token.ts | 5 - extensions/openai/embedding-batch.test.ts | 8 +- extensions/openai/embedding-batch.ts | 2 +- extensions/openai/index.test.ts | 26 +- .../media-understanding-provider.test.ts | 13 +- .../openai/media-understanding-provider.ts | 2 +- .../openai/model-route-contract.test.ts | 30 +- extensions/openai/model-route-contract.ts | 8 +- extensions/openai/native-web-search.ts | 4 +- .../openai-chatgpt-oauth-abort.runtime.ts | 1 - ...nai-chatgpt-oauth-authorization.runtime.ts | 55 +++ .../openai-chatgpt-oauth-flow.runtime.test.ts | 47 ++- .../openai-chatgpt-oauth-flow.runtime.ts | 316 ++---------------- .../openai-chatgpt-oauth-preflight.runtime.ts | 85 +++++ .../openai-chatgpt-oauth-token.runtime.ts | 190 +++++++++++ .../openai-chatgpt-oauth-types.runtime.ts | 11 +- .../openai-chatgpt-oauth.runtime.test.ts | 6 +- .../openai/openai-chatgpt-oauth.runtime.ts | 94 +----- .../openai/openai-chatgpt-pkce.runtime.ts | 2 +- ...openai-chatgpt-provider-runtime.factory.ts | 18 + .../openai/openai-chatgpt-provider.runtime.ts | 49 +-- extensions/openai/openai-provider.test.ts | 115 ++++++- extensions/openai/openai-provider.ts | 4 +- extensions/openai/prompt-overlay.ts | 9 +- extensions/openai/realtime-provider-shared.ts | 2 +- extensions/openai/tts.test.ts | 27 -- extensions/openai/tts.ts | 2 +- extensions/openai/usage.test.ts | 39 ++- extensions/openai/usage.ts | 2 +- .../media-understanding-provider.test.ts | 42 ++- .../opencode/media-understanding-provider.ts | 2 +- extensions/opencode/provider-catalog.ts | 2 +- extensions/opencode/session-catalog-plugin.ts | 12 +- extensions/opencode/session-catalog.test.ts | 45 ++- .../image-generation-provider.test.ts | 75 +++-- .../openrouter/image-generation-provider.ts | 2 +- .../media-understanding-provider.test.ts | 10 +- .../media-understanding-provider.ts | 2 +- extensions/openrouter/models.test.ts | 23 +- extensions/openrouter/models.ts | 2 +- extensions/openrouter/oauth.test.ts | 230 +++++++------ extensions/openrouter/oauth.ts | 32 +- .../openai-web-search-minimal/assertions.mjs | 37 -- .../lib/openai-web-search-minimal/scenario.sh | 2 - 58 files changed, 1041 insertions(+), 985 deletions(-) create mode 100644 extensions/openai/openai-chatgpt-oauth-authorization.runtime.ts create mode 100644 extensions/openai/openai-chatgpt-oauth-preflight.runtime.ts create mode 100644 extensions/openai/openai-chatgpt-oauth-token.runtime.ts create mode 100644 extensions/openai/openai-chatgpt-provider-runtime.factory.ts diff --git a/extensions/codex/harness.ts b/extensions/codex/harness.ts index dc48773e90e2..0c5818fef09d 100644 --- a/extensions/codex/harness.ts +++ b/extensions/codex/harness.ts @@ -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, diff --git a/extensions/codex/media-understanding-provider.ts b/extensions/codex/media-understanding-provider.ts index 928afef6deeb..30fe6a1dfffd 100644 --- a/extensions/codex/media-understanding-provider.ts +++ b/extensions/codex/media-understanding-provider.ts @@ -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 diff --git a/extensions/codex/src/app-server/models.ts b/extensions/codex/src/app-server/models.ts index 2222494174b5..3cd55a1cdbb1 100644 --- a/extensions/codex/src/app-server/models.ts +++ b/extensions/codex/src/app-server/models.ts @@ -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; diff --git a/extensions/github-copilot/connection-bound-ids.live.test.ts b/extensions/github-copilot/connection-bound-ids.live.test.ts index 6efc611edba7..f7f8864ce546 100644 --- a/extensions/github-copilot/connection-bound-ids.live.test.ts +++ b/extensions/github-copilot/connection-bound-ids.live.test.ts @@ -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 | 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; diff --git a/extensions/github-copilot/connection-bound-ids.test.ts b/extensions/github-copilot/connection-bound-ids.test.ts index 21006491c194..7a0e821675c5 100644 --- a/extensions/github-copilot/connection-bound-ids.test.ts +++ b/extensions/github-copilot/connection-bound-ids.test.ts @@ -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: [] }, diff --git a/extensions/github-copilot/connection-bound-ids.ts b/extensions/github-copilot/connection-bound-ids.ts index f3229ec59b0b..e5023baac9af 100644 --- a/extensions/github-copilot/connection-bound-ids.ts +++ b/extensions/github-copilot/connection-bound-ids.ts @@ -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; diff --git a/extensions/github-copilot/domain.test.ts b/extensions/github-copilot/domain.test.ts index 4fd14bdfd61e..fcf173197c47 100644 --- a/extensions/github-copilot/domain.test.ts +++ b/extensions/github-copilot/domain.test.ts @@ -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 }> } }) + .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 }> } } + ).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); + }); +}); diff --git a/extensions/github-copilot/domain.ts b/extensions/github-copilot/domain.ts index 86cfaed8046c..fe5e87e3d9ac 100644 --- a/extensions/github-copilot/domain.ts +++ b/extensions/github-copilot/domain.ts @@ -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 = cfg.models ?? {}; + const providers: NonNullable = 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 = { ...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, + }, + }; +} diff --git a/extensions/github-copilot/index.test.ts b/extensions/github-copilot/index.test.ts index 5d4a920ef30a..976078b61a76 100644 --- a/extensions/github-copilot/index.test.ts +++ b/extensions/github-copilot/index.test.ts @@ -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(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( diff --git a/extensions/github-copilot/login.test.ts b/extensions/github-copilot/login.test.ts index 38207c3f6e11..ae5588d7663f 100644 --- a/extensions/github-copilot/login.test.ts +++ b/extensions/github-copilot/login.test.ts @@ -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( + "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 = []; 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 }> } }) - .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 }> } } - ).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); - }); -}); diff --git a/extensions/github-copilot/login.ts b/extensions/github-copilot/login.ts index ab140b9ed21d..00bc291b4f5b 100644 --- a/extensions/github-copilot/login.ts +++ b/extensions/github-copilot/login.ts @@ -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 { const updated = await upsertAuthProfileWithLock(params); if (!updated) { @@ -142,7 +137,7 @@ async function postGitHubDeviceFlowForm(params: { domain: string; signal?: AbortSignal; }): Promise> { - 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 = cfg.models ?? {}; - const providers: NonNullable = 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 = { ...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, diff --git a/extensions/github-copilot/models.test.ts b/extensions/github-copilot/models.test.ts index 01e080c11555..6d9b22244f19 100644 --- a/extensions/github-copilot/models.test.ts +++ b/extensions/github-copilot/models.test.ts @@ -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) => ({ diff --git a/extensions/github-copilot/stream.test.ts b/extensions/github-copilot/stream.test.ts index f58e24b4fb69..6a579150022d 100644 --- a/extensions/github-copilot/stream.test.ts +++ b/extensions/github-copilot/stream.test.ts @@ -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) { expect(streamFn).toBeTypeOf("function"); @@ -28,6 +24,19 @@ function requireFirstStreamOptions(mock: ReturnType, label: string return options as { headers?: Record; onPayload?: unknown }; } +function buildExpectedCopilotHeaders( + initiator: "agent" | "user", + hasImages: boolean, +): Record { + 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[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[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[0]["messages"]; - const expectedCopilotHeaders = buildCopilotDynamicHeaders({ - messages, - hasImages: true, - }); + ] as Context["messages"]; + const expectedCopilotHeaders = buildExpectedCopilotHeaders("user", true); void wrapped( { diff --git a/extensions/github-copilot/stream.ts b/extensions/github-copilot/stream.ts index 38d0cde65bdf..41962aaa4dfd 100644 --- a/extensions/github-copilot/stream.ts +++ b/extensions/github-copilot/stream.ts @@ -46,7 +46,7 @@ function hasCopilotVisionInput(messages: Context["messages"]): boolean { }); } -export function buildCopilotDynamicHeaders(params: { +function buildCopilotDynamicHeaders(params: { messages: Context["messages"]; hasImages: boolean; }): Record { @@ -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) { diff --git a/extensions/github-copilot/token.ts b/extensions/github-copilot/token.ts index 64fa40558ceb..55b14c37b1bc 100644 --- a/extensions/github-copilot/token.ts +++ b/extensions/github-copilot/token.ts @@ -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) | undefined; @@ -87,8 +84,6 @@ async function cancelUnreadResponseBody(response: Response): Promise { } } -export type ResolveCopilotApiToken = typeof resolveCopilotApiToken; - export async function resolveCopilotApiToken(params: { githubToken: string; env?: NodeJS.ProcessEnv; diff --git a/extensions/openai/embedding-batch.test.ts b/extensions/openai/embedding-batch.test.ts index c884e46e1b61..0f62b0621c08 100644 --- a/extensions/openai/embedding-batch.test.ts +++ b/extensions/openai/embedding-batch.test.ts @@ -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): Promise { - 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) => { diff --git a/extensions/openai/embedding-batch.ts b/extensions/openai/embedding-batch.ts index f2f0300ad965..c0b75f81e362 100644 --- a/extensions/openai/embedding-batch.ts +++ b/extensions/openai/embedding-batch.ts @@ -174,7 +174,7 @@ function isOpenAiBatchUploadTooLargeError(error: unknown): boolean { ); } -export function parseOpenAiBatchOutput(text: string): OpenAiBatchOutputLine[] { +function parseOpenAiBatchOutput(text: string): OpenAiBatchOutputLine[] { if (!text.trim()) { return []; } diff --git a/extensions/openai/index.test.ts b/extensions/openai/index.test.ts index 2a9394fc0148..56f397b68e11 100644 --- a/extensions/openai/index.test.ts +++ b/extensions/openai/index.test.ts @@ -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 }) { 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", () => { diff --git a/extensions/openai/media-understanding-provider.test.ts b/extensions/openai/media-understanding-provider.test.ts index d32ab77f4971..880bc56a3747 100644 --- a/extensions/openai/media-understanding-provider.test.ts +++ b/extensions/openai/media-understanding-provider.test.ts @@ -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", diff --git a/extensions/openai/media-understanding-provider.ts b/extensions/openai/media-understanding-provider.ts index 87f3e221fda9..03c6e0c1567b 100644 --- a/extensions/openai/media-understanding-provider.ts +++ b/extensions/openai/media-understanding-provider.ts @@ -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", diff --git a/extensions/openai/model-route-contract.test.ts b/extensions/openai/model-route-contract.test.ts index 9dffd596756e..87b8aa89bc7e 100644 --- a/extensions/openai/model-route-contract.test.ts +++ b/extensions/openai/model-route-contract.test.ts @@ -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", diff --git a/extensions/openai/model-route-contract.ts b/extensions/openai/model-route-contract.ts index 38b5c82e7347..10f238a9439e 100644 --- a/extensions/openai/model-route-contract.ts +++ b/extensions/openai/model-route-contract.ts @@ -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 = [ diff --git a/extensions/openai/native-web-search.ts b/extensions/openai/native-web-search.ts index 8cca2bf0e5bb..e1058d04caae 100644 --- a/extensions/openai/native-web-search.ts +++ b/extensions/openai/native-web-search.ts @@ -63,9 +63,7 @@ function raiseMinimalReasoningForOpenAINativeWebSearch(payload: Record + 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() }; +} diff --git a/extensions/openai/openai-chatgpt-oauth-flow.runtime.test.ts b/extensions/openai/openai-chatgpt-oauth-flow.runtime.test.ts index 89ab9ffeb8be..3322a340fdc5 100644 --- a/extensions/openai/openai-chatgpt-oauth-flow.runtime.test.ts +++ b/extensions/openai/openai-chatgpt-oauth-flow.runtime.test.ts @@ -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", diff --git a/extensions/openai/openai-chatgpt-oauth-flow.runtime.ts b/extensions/openai/openai-chatgpt-oauth-flow.runtime.ts index 7aebd3c8b787..ae749170092b 100644 --- a/extensions/openai/openai-chatgpt-oauth-flow.runtime.ts +++ b/extensions/openai/openai-chatgpt-oauth-flow.runtime.ts @@ -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 { @@ -75,25 +47,6 @@ function loadNodeOAuthRuntime(): Promise { 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 { 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 { - 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 { - 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 { - 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 { 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 { - 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 { - return loginOpenAICodex({ - onAuth: callbacks.onAuth, - onPrompt: callbacks.onPrompt, - onProgress: callbacks.onProgress, - onManualCodeInput: callbacks.onManualCodeInput, - signal: callbacks.signal, - }); - }, - - async refreshToken(credentials: OAuthCredentials): Promise { - 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; diff --git a/extensions/openai/openai-chatgpt-oauth-preflight.runtime.ts b/extensions/openai/openai-chatgpt-oauth-preflight.runtime.ts new file mode 100644 index 000000000000..11d7c65950b0 --- /dev/null +++ b/extensions/openai/openai-chatgpt-oauth-preflight.runtime.ts @@ -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 | null { + return error && typeof error === "object" ? (error as Record) : 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 { + 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); + } + } +} diff --git a/extensions/openai/openai-chatgpt-oauth-token.runtime.ts b/extensions/openai/openai-chatgpt-oauth-token.runtime.ts new file mode 100644 index 000000000000..f13aa432f58c --- /dev/null +++ b/extensions/openai/openai-chatgpt-oauth-token.runtime.ts @@ -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 { + 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 { + 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 { + 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, + ), + }; + } +} diff --git a/extensions/openai/openai-chatgpt-oauth-types.runtime.ts b/extensions/openai/openai-chatgpt-oauth-types.runtime.ts index 827ac12e8055..129f10b63553 100644 --- a/extensions/openai/openai-chatgpt-oauth-types.runtime.ts +++ b/extensions/openai/openai-chatgpt-oauth-types.runtime.ts @@ -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"; diff --git a/extensions/openai/openai-chatgpt-oauth.runtime.test.ts b/extensions/openai/openai-chatgpt-oauth.runtime.test.ts index 6ac2fd614e75..0a5df6bab0fe 100644 --- a/extensions/openai/openai-chatgpt-oauth.runtime.test.ts +++ b/extensions/openai/openai-chatgpt-oauth.runtime.test.ts @@ -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, }), diff --git a/extensions/openai/openai-chatgpt-oauth.runtime.ts b/extensions/openai/openai-chatgpt-oauth.runtime.ts index a2c91628b745..e9ff5d159f0a 100644 --- a/extensions/openai/openai-chatgpt-oauth.runtime.ts +++ b/extensions/openai/openai-chatgpt-oauth.runtime.ts @@ -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 | null { - return error && typeof error === "object" ? (error as Record) : 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 { - 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, ): string { diff --git a/extensions/openai/openai-chatgpt-pkce.runtime.ts b/extensions/openai/openai-chatgpt-pkce.runtime.ts index c34b8d6bcb8e..7be542e731b8 100644 --- a/extensions/openai/openai-chatgpt-pkce.runtime.ts +++ b/extensions/openai/openai-chatgpt-pkce.runtime.ts @@ -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"; diff --git a/extensions/openai/openai-chatgpt-provider-runtime.factory.ts b/extensions/openai/openai-chatgpt-provider-runtime.factory.ts new file mode 100644 index 000000000000..4eef8f16aaf5 --- /dev/null +++ b/extensions/openai/openai-chatgpt-provider-runtime.factory.ts @@ -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); + }, + }; +} diff --git a/extensions/openai/openai-chatgpt-provider.runtime.ts b/extensions/openai/openai-chatgpt-provider.runtime.ts index 24815fefce25..1a00ceae7aef 100644 --- a/extensions/openai/openai-chatgpt-provider.runtime.ts +++ b/extensions/openai/openai-chatgpt-provider.runtime.ts @@ -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 -): Promise>> { - return await runtime.getOAuthApiKey(...args); -} - export async function refreshOpenAICodexToken( ...args: Parameters ): Promise>> { return await runtime.refreshOpenAICodexToken(...args); } - -async function getOpenAICodexOAuthApiKey( - providerId: string, - credentials: Record, -): 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 }; -} diff --git a/extensions/openai/openai-provider.test.ts b/extensions/openai/openai-provider.test.ts index f867e2db81b0..4832c704a0f9 100644 --- a/extensions/openai/openai-provider.test.ts +++ b/extensions/openai/openai-provider.test.ts @@ -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 { + 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 { + 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 { + 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; diff --git a/extensions/openai/openai-provider.ts b/extensions/openai/openai-provider.ts index a02e5a72a5a7..2802e0fb376c 100644 --- a/extensions/openai/openai-provider.ts +++ b/extensions/openai/openai-provider.ts @@ -183,7 +183,7 @@ function buildOpenAIManifestModelsForBaseUrl(baseUrl: string): ModelDefinitionCo ); } -export async function buildOpenAILiveProviderConfig( +async function buildOpenAILiveProviderConfig( params: BuildOpenAILiveProviderConfigParams, ): Promise { const baseUrl = @@ -446,7 +446,7 @@ function buildOpenAICodexStaticProviderConfig(): ModelProviderConfig { }; } -export async function buildOpenAICodexLiveProviderConfig(params: { +async function buildOpenAICodexLiveProviderConfig(params: { discoveryApiKey: string; accountId?: string; fetchGuard?: LiveModelCatalogFetchGuard; diff --git a/extensions/openai/prompt-overlay.ts b/extensions/openai/prompt-overlay.ts index 1bc115762d22..084ec794aed4 100644 --- a/extensions/openai/prompt-overlay.ts +++ b/extensions/openai/prompt-overlay.ts @@ -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 { diff --git a/extensions/openai/realtime-provider-shared.ts b/extensions/openai/realtime-provider-shared.ts index 434c36c7b465..d464bf102ba8 100644 --- a/extensions/openai/realtime-provider-shared.ts +++ b/extensions/openai/realtime-provider-shared.ts @@ -70,7 +70,7 @@ export function captureOpenAIRealtimeWsClose(params: { }); } -export type OpenAIRealtimeClientSecretResult = { +type OpenAIRealtimeClientSecretResult = { value: string; expiresAt?: number; }; diff --git a/extensions/openai/tts.test.ts b/extensions/openai/tts.test.ts index f1ea2ba92131..14fc9690fd52 100644 --- a/extensions/openai/tts.test.ts +++ b/extensions/openai/tts.test.ts @@ -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"); diff --git a/extensions/openai/tts.ts b/extensions/openai/tts.ts index acfa82a8fc18..653458f2addb 100644 --- a/extensions/openai/tts.ts +++ b/extensions/openai/tts.ts @@ -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, diff --git a/extensions/openai/usage.test.ts b/extensions/openai/usage.test.ts index 66d6307a7d1c..6a47d539338e 100644 --- a/extensions/openai/usage.test.ts +++ b/extensions/openai/usage.test.ts @@ -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"); diff --git a/extensions/openai/usage.ts b/extensions/openai/usage.ts index c303a335c14d..993e144a9aed 100644 --- a/extensions/openai/usage.ts +++ b/extensions/openai/usage.ts @@ -356,7 +356,7 @@ function aggregateHistory(params: { }; } -export async function fetchOpenAIAdminUsage(params: { +async function fetchOpenAIAdminUsage(params: { apiKey: string; projectId?: string; timeoutMs: number; diff --git a/extensions/opencode/media-understanding-provider.test.ts b/extensions/opencode/media-understanding-provider.test.ts index 00e72403042b..c01034adcfcc 100644 --- a/extensions/opencode/media-understanding-provider.test.ts +++ b/extensions/opencode/media-understanding-provider.test.ts @@ -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()), + describeImageWithModelPayloadTransform: mocks.describeImageWithModelPayloadTransform, +})); + +import { opencodeMediaUnderstandingProvider } from "./media-understanding-provider.js"; + +beforeEach(() => { + mocks.describeImageWithModelPayloadTransform.mockReset(); +}); + +async function applyImagePayloadTransform(payload: Record): Promise { + 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" }, diff --git a/extensions/opencode/media-understanding-provider.ts b/extensions/opencode/media-understanding-provider.ts index d04db574bf6e..3235bf143350 100644 --- a/extensions/opencode/media-understanding-provider.ts +++ b/extensions/opencode/media-understanding-provider.ts @@ -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; } diff --git a/extensions/opencode/provider-catalog.ts b/extensions/opencode/provider-catalog.ts index 21012b744abb..f10eee6ed2a5 100644 --- a/extensions/opencode/provider-catalog.ts +++ b/extensions/opencode/provider-catalog.ts @@ -214,7 +214,7 @@ type OpencodeZenModelDefinition = ModelDefinitionConfig & { input: Array<"text" | "image">; }; -export type FetchOpencodeZenLiveModelIdsParams = { +type FetchOpencodeZenLiveModelIdsParams = { apiKey?: string; discoveryApiKey?: string; fetchGuard?: LiveModelCatalogFetchGuard; diff --git a/extensions/opencode/session-catalog-plugin.ts b/extensions/opencode/session-catalog-plugin.ts index 1cf4c633b7b8..cf2298ef9610 100644 --- a/extensions/opencode/session-catalog-plugin.ts +++ b/extensions/opencode/session-catalog-plugin.ts @@ -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: [ diff --git a/extensions/opencode/session-catalog.test.ts b/extensions/opencode/session-catalog.test.ts index 2883c27c008e..2e859103474c 100644 --- a/extensions/opencode/session-catalog.test.ts +++ b/extensions/opencode/session-catalog.test.ts @@ -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[0]> = []; + const commands: Array[0]> = []; + const policies: Array[0]> = []; + registerOpenCodeSessionCatalog({ + pluginConfig, + runtime: { nodes: { list: vi.fn().mockResolvedValue({ nodes: [] }) } }, + registerSessionCatalog: (catalog: Parameters[0]) => + catalogs.push(catalog), + registerNodeHostCommand: ( + command: Parameters[0], + ) => commands.push(command), + registerNodeInvokePolicy: ( + policy: Parameters[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 () => { diff --git a/extensions/openrouter/image-generation-provider.test.ts b/extensions/openrouter/image-generation-provider.test.ts index 63a16b0e234c..6daf92d24a3a 100644 --- a/extensions/openrouter/image-generation-provider.test.ts +++ b/extensions/openrouter/image-generation-provider.test.ts @@ -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"); }); }); diff --git a/extensions/openrouter/image-generation-provider.ts b/extensions/openrouter/image-generation-provider.ts index 7a8e4909b5ab..03e2145d8b1b 100644 --- a/extensions/openrouter/image-generation-provider.ts +++ b/extensions/openrouter/image-generation-provider.ts @@ -136,7 +136,7 @@ function extractImagesFromPart( throwMalformedOpenRouterImageResponse(malformedResponseError); } -export function extractOpenRouterImagesFromResponse( +function extractOpenRouterImagesFromResponse( body: unknown, options: { malformedResponseError?: string } = {}, ): GeneratedImageAsset[] { diff --git a/extensions/openrouter/media-understanding-provider.test.ts b/extensions/openrouter/media-understanding-provider.test.ts index 25a926b60408..b5b6295d9914 100644 --- a/extensions/openrouter/media-understanding-provider.test.ts +++ b/extensions/openrouter/media-understanding-provider.test.ts @@ -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(() => ({ diff --git a/extensions/openrouter/media-understanding-provider.ts b/extensions/openrouter/media-understanding-provider.ts index c57cab6f8e48..9ea128fe0750 100644 --- a/extensions/openrouter/media-understanding-provider.ts +++ b/extensions/openrouter/media-understanding-provider.ts @@ -100,7 +100,7 @@ type OpenRouterSttResponse = { text?: string; }; -export async function transcribeOpenRouterAudio( +async function transcribeOpenRouterAudio( params: AudioTranscriptionRequest, ): Promise { const model = params.model?.trim() || DEFAULT_OPENROUTER_AUDIO_TRANSCRIPTION_MODEL; diff --git a/extensions/openrouter/models.test.ts b/extensions/openrouter/models.test.ts index d6041fd28523..fc2d3b315d8c 100644 --- a/extensions/openrouter/models.test.ts +++ b/extensions/openrouter/models.test.ts @@ -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); }); }); diff --git a/extensions/openrouter/models.ts b/extensions/openrouter/models.ts index 6d465bdf7270..1c17d32bf338 100644 --- a/extensions/openrouter/models.ts +++ b/extensions/openrouter/models.ts @@ -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; } diff --git a/extensions/openrouter/oauth.test.ts b/extensions/openrouter/oauth.test.ts index ac5789e3cd15..98b095e830b2 100644 --- a/extensions/openrouter/oauth.test.ts +++ b/extensions/openrouter/oauth.test.ts @@ -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[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 function createOpenRouterOAuthContext(params: { isRemote: boolean; + onProgress?: (message: string) => void; redirectInput?: string; openUrl?: (url: string) => Promise; + 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>(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((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).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(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(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(async () => jsonResponse({ key: "sk-or-v1-test" })); - const waitForCallback = vi.fn(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"); }); }); diff --git a/extensions/openrouter/oauth.ts b/extensions/openrouter/oauth.ts index 2efcea06f2d8..efa3b379d947 100644 --- a/extensions/openrouter/oauth.ts +++ b/extensions/openrouter/oauth.ts @@ -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 { @@ -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), }; } diff --git a/scripts/e2e/lib/openai-web-search-minimal/assertions.mjs b/scripts/e2e/lib/openai-web-search-minimal/assertions.mjs index c142994f2e35..3da330949d14 100644 --- a/scripts/e2e/lib/openai-web-search-minimal/assertions.mjs +++ b/scripts/e2e/lib/openai-web-search-minimal/assertions.mjs @@ -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, }; diff --git a/scripts/e2e/lib/openai-web-search-minimal/scenario.sh b/scripts/e2e/lib/openai-web-search-minimal/scenario.sh index 8938b2ad2436..e97ae33f1aba 100644 --- a/scripts/e2e/lib/openai-web-search-minimal/scenario.sh +++ b/scripts/e2e/lib/openai-web-search-minimal/scenario.sh @@ -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 \