mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 14:41:37 +00:00
fix: provider dead exports no longer block changed checks (#108592)
* fix(ci): clean provider dead exports * test(extensions): satisfy provider contract types * refactor(openai): tighten provider runtime factory
This commit is contained in:
committed by
GitHub
parent
527711d27c
commit
e7cba0e4d5
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
import { stream as streamModel, type AssistantMessage, type Model } from "openclaw/plugin-sdk/llm";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveFirstGithubToken } from "./auth.js";
|
||||
import { buildCopilotDynamicHeaders } from "./stream.js";
|
||||
import { wrapCopilotOpenAIResponsesStream } from "./stream.js";
|
||||
import { wrapCopilotProviderStream } from "./stream.js";
|
||||
import { resolveCopilotApiToken } from "./token.js";
|
||||
|
||||
const LIVE =
|
||||
@@ -196,7 +195,7 @@ describeLive("github-copilot connection-bound Responses IDs live", () => {
|
||||
};
|
||||
let capturedPayload: Record<string, unknown> | undefined;
|
||||
|
||||
const wrappedStream = wrapCopilotOpenAIResponsesStream(streamModel as never);
|
||||
const wrappedStream = wrapCopilotProviderStream({ streamFn: streamModel } as never);
|
||||
if (!wrappedStream) {
|
||||
throw new Error("expected Copilot Responses stream wrapper");
|
||||
}
|
||||
@@ -205,10 +204,6 @@ describeLive("github-copilot connection-bound Responses IDs live", () => {
|
||||
context as never,
|
||||
{
|
||||
apiKey: token.token,
|
||||
headers: buildCopilotDynamicHeaders({
|
||||
messages: context.messages,
|
||||
hasImages: false,
|
||||
}),
|
||||
maxTokens: 32,
|
||||
onPayload: (payload: unknown) => {
|
||||
capturedPayload = payload as Record<string, unknown>;
|
||||
|
||||
@@ -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: [] },
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PUBLIC_GITHUB_COPILOT_DOMAIN, resolveGithubCopilotDomain } from "./domain.js";
|
||||
import {
|
||||
PUBLIC_GITHUB_COPILOT_DOMAIN,
|
||||
resolveGithubCopilotDomain,
|
||||
withGithubCopilotDomainConfig,
|
||||
} from "./domain.js";
|
||||
|
||||
describe("github-copilot domain resolution", () => {
|
||||
const withDomain = (githubDomain: string) =>
|
||||
@@ -36,3 +40,32 @@ describe("github-copilot domain resolution", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("withGithubCopilotDomainConfig", () => {
|
||||
const tenantConfig = {
|
||||
models: {
|
||||
providers: { "github-copilot": { params: { githubDomain: "acme.ghe.com" } } },
|
||||
},
|
||||
} as never;
|
||||
|
||||
it("persists the tenant domain when login minted a tenant token", () => {
|
||||
const next = withGithubCopilotDomainConfig({} as never, "acme.ghe.com");
|
||||
expect(
|
||||
(next as { models?: { providers?: Record<string, { params?: Record<string, unknown> }> } })
|
||||
.models?.providers?.["github-copilot"]?.params?.githubDomain,
|
||||
).toBe("acme.ghe.com");
|
||||
});
|
||||
|
||||
it("clears a stale tenant domain after public login", () => {
|
||||
const next = withGithubCopilotDomainConfig(tenantConfig, "github.com");
|
||||
const params = (
|
||||
next as { models?: { providers?: Record<string, { params?: Record<string, unknown> }> } }
|
||||
).models?.providers?.["github-copilot"]?.params;
|
||||
expect(params && "githubDomain" in params).toBe(false);
|
||||
});
|
||||
|
||||
it("leaves config untouched for public login without persisted domain", () => {
|
||||
const cfg = {} as never;
|
||||
expect(withGithubCopilotDomainConfig(cfg, "github.com")).toBe(cfg);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,3 +38,37 @@ export function resolveGithubCopilotDomain(params?: {
|
||||
}
|
||||
return normalizeGithubCopilotDomain(readConfiguredGithubCopilotDomain(params?.config));
|
||||
}
|
||||
|
||||
// Shortcut login must persist its token's tenant. A missing domain would route
|
||||
// the tenant token back to github.com after the environment override is removed.
|
||||
export function withGithubCopilotDomainConfig(cfg: OpenClawConfig, domain: string): OpenClawConfig {
|
||||
const models: NonNullable<OpenClawConfig["models"]> = cfg.models ?? {};
|
||||
const providers: NonNullable<typeof models.providers> = models.providers ?? {};
|
||||
const provider = providers["github-copilot"];
|
||||
const params = provider?.params;
|
||||
const isDefault = domain === PUBLIC_GITHUB_COPILOT_DOMAIN;
|
||||
if (isDefault && !(params && "githubDomain" in params)) {
|
||||
return cfg;
|
||||
}
|
||||
const nextParams: Record<string, unknown> = { ...params };
|
||||
if (isDefault) {
|
||||
delete nextParams.githubDomain;
|
||||
} else {
|
||||
nextParams.githubDomain = domain;
|
||||
}
|
||||
const nextProviders = { ...providers };
|
||||
if (provider) {
|
||||
nextProviders["github-copilot"] = { ...provider, params: nextParams };
|
||||
} else {
|
||||
// Source config accepts partial provider inputs; catalog materialization
|
||||
// supplies baseUrl/models before runtime consumption.
|
||||
Object.assign(nextProviders, { "github-copilot": { params: nextParams } });
|
||||
}
|
||||
return {
|
||||
...cfg,
|
||||
models: {
|
||||
...models,
|
||||
providers: nextProviders,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -17,16 +17,15 @@ import type {
|
||||
UnifiedModelCatalogEntry,
|
||||
} from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api";
|
||||
import type { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
runGitHubCopilotDeviceFlow,
|
||||
setGitHubCopilotDeviceFlowFetchGuardForTesting,
|
||||
} from "./login.js";
|
||||
import { runGitHubCopilotDeviceFlow } from "./login.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
githubCopilotLoginCommand: vi.fn(),
|
||||
fetchWithSsrFGuard: vi.fn(async (params: { url: string; init?: RequestInit }) => ({
|
||||
fetchWithSsrFGuard: vi.fn<typeof fetchWithSsrFGuard>(async (params) => ({
|
||||
response: await fetch(params.url, params.init),
|
||||
finalUrl: params.url,
|
||||
release: vi.fn(async () => {}),
|
||||
})),
|
||||
resolveCopilotApiToken: vi.fn(),
|
||||
@@ -85,7 +84,11 @@ type GithubCopilotTestModelCatalogProvider = {
|
||||
afterEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
setGitHubCopilotDeviceFlowFetchGuardForTesting(null);
|
||||
mocks.fetchWithSsrFGuard.mockImplementation(async (params) => ({
|
||||
response: await fetch(params.url, params.init),
|
||||
finalUrl: params.url,
|
||||
release: vi.fn(async () => {}),
|
||||
}));
|
||||
clearRuntimeAuthProfileStoreSnapshots();
|
||||
await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
|
||||
});
|
||||
@@ -730,7 +733,7 @@ describe("github-copilot plugin", () => {
|
||||
throw new Error(`unexpected fetch in github-copilot refresh test: ${target}`);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
setGitHubCopilotDeviceFlowFetchGuardForTesting(async (params) => ({
|
||||
mocks.fetchWithSsrFGuard.mockImplementation(async (params) => ({
|
||||
response: await fetchMock(params.url, params.init),
|
||||
finalUrl: params.url,
|
||||
release: async () => {},
|
||||
@@ -838,7 +841,7 @@ describe("github-copilot plugin", () => {
|
||||
writeExistingCopilotTokenProfile(agentDir);
|
||||
const fetchMock = buildDeviceFlowFetchMock("github.com", "public-fresh-token");
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
setGitHubCopilotDeviceFlowFetchGuardForTesting(async (params) => ({
|
||||
mocks.fetchWithSsrFGuard.mockImplementation(async (params) => ({
|
||||
response: await fetchMock(params.url, params.init),
|
||||
finalUrl: params.url,
|
||||
release: async () => {},
|
||||
@@ -898,7 +901,7 @@ describe("github-copilot plugin", () => {
|
||||
writeExistingCopilotTokenProfile(agentDir);
|
||||
const fetchMock = buildDeviceFlowFetchMock("acme.ghe.com", "tenant-fresh-token");
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
setGitHubCopilotDeviceFlowFetchGuardForTesting(async (params) => ({
|
||||
mocks.fetchWithSsrFGuard.mockImplementation(async (params) => ({
|
||||
response: await fetchMock(params.url, params.init),
|
||||
finalUrl: params.url,
|
||||
release: async () => {},
|
||||
@@ -956,7 +959,7 @@ describe("github-copilot plugin", () => {
|
||||
// the domain change is detected and the public token is not reused.
|
||||
const fetchMock = buildDeviceFlowFetchMock("acme.ghe.com", "tenant-fresh-token");
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
setGitHubCopilotDeviceFlowFetchGuardForTesting(async (params) => ({
|
||||
mocks.fetchWithSsrFGuard.mockImplementation(async (params) => ({
|
||||
response: await fetchMock(params.url, params.init),
|
||||
finalUrl: params.url,
|
||||
release: async () => {},
|
||||
@@ -1062,7 +1065,7 @@ describe("github-copilot plugin", () => {
|
||||
// prompt value instead, the fetch mock would throw on an unexpected host.
|
||||
const fetchMock = buildDeviceFlowFetchMock("env-tenant.ghe.com", "env-tenant-token");
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
setGitHubCopilotDeviceFlowFetchGuardForTesting(async (params) => ({
|
||||
mocks.fetchWithSsrFGuard.mockImplementation(async (params) => ({
|
||||
response: await fetchMock(params.url, params.init),
|
||||
finalUrl: params.url,
|
||||
release: async () => {},
|
||||
@@ -1112,7 +1115,7 @@ describe("github-copilot plugin", () => {
|
||||
|
||||
it("rejects unsafe GitHub device code lifetimes before polling", async () => {
|
||||
const release = vi.fn(async () => {});
|
||||
setGitHubCopilotDeviceFlowFetchGuardForTesting(async () => ({
|
||||
mocks.fetchWithSsrFGuard.mockImplementation(async () => ({
|
||||
response: new Response(
|
||||
'{"device_code":"device-code-stub","user_code":"ABCD-1234","verification_uri":"https://github.com/login/device","expires_in":1e309,"interval":0}',
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
@@ -1132,7 +1135,7 @@ describe("github-copilot plugin", () => {
|
||||
it("rejects GitHub device code expiries outside the Date timestamp range before polling", async () => {
|
||||
const release = vi.fn(async () => {});
|
||||
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(MAX_DATE_TIMESTAMP_MS);
|
||||
setGitHubCopilotDeviceFlowFetchGuardForTesting(async () => ({
|
||||
mocks.fetchWithSsrFGuard.mockImplementation(async () => ({
|
||||
response: new Response(
|
||||
'{"device_code":"device-code-stub","user_code":"ABCD-1234","verification_uri":"https://github.com/login/device","expires_in":1,"interval":0}',
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
@@ -1159,7 +1162,7 @@ describe("github-copilot plugin", () => {
|
||||
const release = vi.fn(async () => {});
|
||||
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
||||
let accessTokenPolls = 0;
|
||||
setGitHubCopilotDeviceFlowFetchGuardForTesting(async (params) => {
|
||||
mocks.fetchWithSsrFGuard.mockImplementation(async (params) => {
|
||||
if (params.url === "https://github.com/login/device/code") {
|
||||
return {
|
||||
response: new Response(
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
// Github Copilot tests cover device-flow login behavior.
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
runGitHubCopilotDeviceFlow,
|
||||
setGitHubCopilotDeviceFlowFetchGuardForTesting,
|
||||
withGithubCopilotDomainConfig,
|
||||
} from "./login.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
fetchWithSsrFGuard: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/ssrf-runtime", async () => {
|
||||
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/ssrf-runtime")>(
|
||||
"openclaw/plugin-sdk/ssrf-runtime",
|
||||
);
|
||||
return { ...actual, fetchWithSsrFGuard: mocks.fetchWithSsrFGuard };
|
||||
});
|
||||
|
||||
import { runGitHubCopilotDeviceFlow } from "./login.js";
|
||||
|
||||
const DEVICE_CODE_URL = "https://github.com/login/device/code";
|
||||
const ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token";
|
||||
@@ -30,7 +38,7 @@ function guardResponse(body: unknown, status = 200, url = DEVICE_CODE_URL) {
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
setGitHubCopilotDeviceFlowFetchGuardForTesting(null);
|
||||
mocks.fetchWithSsrFGuard.mockReset();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -39,7 +47,7 @@ describe("runGitHubCopilotDeviceFlow — normal flow", () => {
|
||||
let callIdx = 0;
|
||||
const requestTimeouts: Array<number | undefined> = [];
|
||||
const controller = new AbortController();
|
||||
setGitHubCopilotDeviceFlowFetchGuardForTesting(async (params) => {
|
||||
mocks.fetchWithSsrFGuard.mockImplementation(async (params) => {
|
||||
callIdx += 1;
|
||||
requestTimeouts.push(params.timeoutMs);
|
||||
expect(params.signal).toBe(controller.signal);
|
||||
@@ -70,7 +78,7 @@ describe("runGitHubCopilotDeviceFlow — normal flow", () => {
|
||||
|
||||
it("returns access_denied when GitHub rejects the authorization", async () => {
|
||||
let callIdx = 0;
|
||||
setGitHubCopilotDeviceFlowFetchGuardForTesting(async () => {
|
||||
mocks.fetchWithSsrFGuard.mockImplementation(async () => {
|
||||
callIdx += 1;
|
||||
if (callIdx === 1) {
|
||||
return guardResponse(VALID_DEVICE_CODE_BODY);
|
||||
@@ -86,7 +94,7 @@ describe("runGitHubCopilotDeviceFlow — normal flow", () => {
|
||||
|
||||
it("returns expired when GitHub reports expired_token", async () => {
|
||||
let callIdx = 0;
|
||||
setGitHubCopilotDeviceFlowFetchGuardForTesting(async () => {
|
||||
mocks.fetchWithSsrFGuard.mockImplementation(async () => {
|
||||
callIdx += 1;
|
||||
if (callIdx === 1) {
|
||||
return guardResponse(VALID_DEVICE_CODE_BODY);
|
||||
@@ -103,7 +111,7 @@ describe("runGitHubCopilotDeviceFlow — normal flow", () => {
|
||||
|
||||
describe("runGitHubCopilotDeviceFlow — HTTP error propagation", () => {
|
||||
it("throws with failureLabel on non-OK device code response", async () => {
|
||||
setGitHubCopilotDeviceFlowFetchGuardForTesting(async () => guardResponse({}, 401));
|
||||
mocks.fetchWithSsrFGuard.mockImplementation(async () => guardResponse({}, 401));
|
||||
|
||||
await expect(runGitHubCopilotDeviceFlow({ showCode: vi.fn() })).rejects.toThrow(
|
||||
"GitHub device code failed: HTTP 401",
|
||||
@@ -112,7 +120,7 @@ describe("runGitHubCopilotDeviceFlow — HTTP error propagation", () => {
|
||||
|
||||
it("throws with failureLabel on non-OK access token response", async () => {
|
||||
let callIdx = 0;
|
||||
setGitHubCopilotDeviceFlowFetchGuardForTesting(async () => {
|
||||
mocks.fetchWithSsrFGuard.mockImplementation(async () => {
|
||||
callIdx += 1;
|
||||
if (callIdx === 1) {
|
||||
return guardResponse(VALID_DEVICE_CODE_BODY);
|
||||
@@ -146,7 +154,7 @@ describe("postGitHubDeviceFlowForm — response size bound", () => {
|
||||
},
|
||||
});
|
||||
|
||||
setGitHubCopilotDeviceFlowFetchGuardForTesting(async () => ({
|
||||
mocks.fetchWithSsrFGuard.mockImplementation(async () => ({
|
||||
response: new Response(oversizedBody, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -170,7 +178,7 @@ describe("postGitHubDeviceFlowForm — response size bound", () => {
|
||||
let canceled = false;
|
||||
let callIdx = 0;
|
||||
|
||||
setGitHubCopilotDeviceFlowFetchGuardForTesting(async () => {
|
||||
mocks.fetchWithSsrFGuard.mockImplementation(async () => {
|
||||
callIdx += 1;
|
||||
if (callIdx === 1) {
|
||||
return guardResponse(VALID_DEVICE_CODE_BODY);
|
||||
@@ -219,7 +227,7 @@ describe("runGitHubCopilotDeviceFlow — data-residency GitHub Enterprise", () =
|
||||
|
||||
const urls: string[] = [];
|
||||
let callIdx = 0;
|
||||
setGitHubCopilotDeviceFlowFetchGuardForTesting(async (params) => {
|
||||
mocks.fetchWithSsrFGuard.mockImplementation(async (params) => {
|
||||
urls.push(params.url);
|
||||
callIdx += 1;
|
||||
if (callIdx === 1) {
|
||||
@@ -250,7 +258,7 @@ describe("runGitHubCopilotDeviceFlow — data-residency GitHub Enterprise", () =
|
||||
});
|
||||
|
||||
it("rejects a verification URL whose host does not match the configured domain", async () => {
|
||||
setGitHubCopilotDeviceFlowFetchGuardForTesting(async () =>
|
||||
mocks.fetchWithSsrFGuard.mockImplementation(async () =>
|
||||
guardResponse(
|
||||
{ ...VALID_DEVICE_CODE_BODY, verification_uri: "https://github.com/login/device" },
|
||||
200,
|
||||
@@ -263,32 +271,3 @@ describe("runGitHubCopilotDeviceFlow — data-residency GitHub Enterprise", () =
|
||||
).rejects.toThrow("unexpected verification URL");
|
||||
});
|
||||
});
|
||||
|
||||
describe("withGithubCopilotDomainConfig — shortcut login domain persistence", () => {
|
||||
const tenantConfig = {
|
||||
models: {
|
||||
providers: { "github-copilot": { params: { githubDomain: "acme.ghe.com" } } },
|
||||
},
|
||||
} as never;
|
||||
|
||||
it("persists the tenant domain when the shortcut minted a tenant token", () => {
|
||||
const next = withGithubCopilotDomainConfig({} as never, "acme.ghe.com");
|
||||
expect(
|
||||
(next as { models?: { providers?: Record<string, { params?: Record<string, unknown> }> } })
|
||||
.models?.providers?.["github-copilot"]?.params?.githubDomain,
|
||||
).toBe("acme.ghe.com");
|
||||
});
|
||||
|
||||
it("clears a stale tenant domain when the shortcut logged in against github.com", () => {
|
||||
const next = withGithubCopilotDomainConfig(tenantConfig, "github.com");
|
||||
const params = (
|
||||
next as { models?: { providers?: Record<string, { params?: Record<string, unknown> }> } }
|
||||
).models?.providers?.["github-copilot"]?.params;
|
||||
expect(params && "githubDomain" in params).toBe(false);
|
||||
});
|
||||
|
||||
it("leaves config untouched for a public login with no persisted domain", () => {
|
||||
const cfg = {} as never;
|
||||
expect(withGithubCopilotDomainConfig(cfg, "github.com")).toBe(cfg);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// Github Copilot plugin module implements login behavior.
|
||||
import { intro, note, outro, spinner } from "@clack/prompts";
|
||||
import { stylePromptTitle } from "openclaw/plugin-sdk/cli-runtime";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { logConfigUpdated, updateConfig } from "openclaw/plugin-sdk/config-mutation";
|
||||
import {
|
||||
resolveExpiresAtMsFromDurationMs,
|
||||
@@ -18,7 +17,11 @@ import {
|
||||
import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http";
|
||||
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime";
|
||||
import { fetchWithSsrFGuard, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { PUBLIC_GITHUB_COPILOT_DOMAIN, resolveGithubCopilotDomain } from "./domain.js";
|
||||
import {
|
||||
PUBLIC_GITHUB_COPILOT_DOMAIN,
|
||||
resolveGithubCopilotDomain,
|
||||
withGithubCopilotDomainConfig,
|
||||
} from "./domain.js";
|
||||
|
||||
const CLIENT_ID = "Iv1.b507a08c87ecfe98";
|
||||
const GITHUB_DEVICE_FLOW_REQUEST_TIMEOUT_MS = 30_000;
|
||||
@@ -68,14 +71,6 @@ class GitHubDeviceFlowError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
let githubDeviceFlowFetchGuard = fetchWithSsrFGuard;
|
||||
|
||||
export function setGitHubCopilotDeviceFlowFetchGuardForTesting(
|
||||
impl: typeof fetchWithSsrFGuard | null,
|
||||
): void {
|
||||
githubDeviceFlowFetchGuard = impl ?? fetchWithSsrFGuard;
|
||||
}
|
||||
|
||||
async function upsertAuthProfileWithLockOrThrow(params: UpsertAuthProfileParams): Promise<void> {
|
||||
const updated = await upsertAuthProfileWithLock(params);
|
||||
if (!updated) {
|
||||
@@ -142,7 +137,7 @@ async function postGitHubDeviceFlowForm(params: {
|
||||
domain: string;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<Record<string, unknown>> {
|
||||
const { response, release } = await githubDeviceFlowFetchGuard({
|
||||
const { response, release } = await fetchWithSsrFGuard({
|
||||
url: params.url,
|
||||
init: {
|
||||
method: "POST",
|
||||
@@ -357,46 +352,6 @@ export async function runGitHubCopilotDeviceFlow(
|
||||
}
|
||||
}
|
||||
|
||||
// The shortcut login mints its token against the resolved domain, so the same
|
||||
// domain must land in persisted config: a tenant token with no stored
|
||||
// githubDomain would silently route to github.com (and 401) once
|
||||
// COPILOT_GITHUB_DOMAIN is unset. Mirrors the enterprise auth method's
|
||||
// persist-on-tenant / clear-on-public behavior.
|
||||
export function withGithubCopilotDomainConfig(cfg: OpenClawConfig, domain: string): OpenClawConfig {
|
||||
// Normalize the optional layers to concrete objects before spreading:
|
||||
// spreading a possibly-undefined object widens every optional property to
|
||||
// `T | undefined`, which exactOptionalPropertyTypes rejects.
|
||||
const models: NonNullable<OpenClawConfig["models"]> = cfg.models ?? {};
|
||||
const providers: NonNullable<typeof models.providers> = models.providers ?? {};
|
||||
const provider = providers["github-copilot"];
|
||||
const params = provider?.params;
|
||||
const isDefault = domain === PUBLIC_GITHUB_COPILOT_DOMAIN;
|
||||
if (isDefault && !(params && "githubDomain" in params)) {
|
||||
return cfg;
|
||||
}
|
||||
const nextParams: Record<string, unknown> = { ...params };
|
||||
if (isDefault) {
|
||||
delete nextParams.githubDomain;
|
||||
} else {
|
||||
nextParams.githubDomain = domain;
|
||||
}
|
||||
const nextProviders = { ...providers };
|
||||
if (provider) {
|
||||
nextProviders["github-copilot"] = { ...provider, params: nextParams };
|
||||
} else {
|
||||
// Source config accepts partial provider inputs; catalog materialization
|
||||
// supplies baseUrl/models before runtime consumption.
|
||||
Object.assign(nextProviders, { "github-copilot": { params: nextParams } });
|
||||
}
|
||||
return {
|
||||
...cfg,
|
||||
models: {
|
||||
...models,
|
||||
providers: nextProviders,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function githubCopilotLoginCommand(
|
||||
opts: { profileId?: string; yes?: boolean; agentDir?: string },
|
||||
runtime: RuntimeEnv,
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
// Github Copilot tests cover stream plugin behavior.
|
||||
import type { Context } from "openclaw/plugin-sdk/llm";
|
||||
import { buildCopilotIdeHeaders, COPILOT_INTEGRATION_ID } from "openclaw/plugin-sdk/provider-auth";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { buildCopilotDynamicHeaders } from "./stream.js";
|
||||
import {
|
||||
wrapCopilotAnthropicStream,
|
||||
wrapCopilotOpenAICompletionsStream,
|
||||
wrapCopilotOpenAIResponsesStream,
|
||||
wrapCopilotProviderStream,
|
||||
} from "./stream.js";
|
||||
import { wrapCopilotAnthropicStream, wrapCopilotProviderStream } from "./stream.js";
|
||||
|
||||
function requireStreamFn(streamFn: ReturnType<typeof wrapCopilotProviderStream>) {
|
||||
expect(streamFn).toBeTypeOf("function");
|
||||
@@ -28,6 +24,19 @@ function requireFirstStreamOptions(mock: ReturnType<typeof vi.fn>, label: string
|
||||
return options as { headers?: Record<string, unknown>; onPayload?: unknown };
|
||||
}
|
||||
|
||||
function buildExpectedCopilotHeaders(
|
||||
initiator: "agent" | "user",
|
||||
hasImages: boolean,
|
||||
): Record<string, string> {
|
||||
return {
|
||||
...buildCopilotIdeHeaders(),
|
||||
"Copilot-Integration-Id": COPILOT_INTEGRATION_ID,
|
||||
"Openai-Organization": "github-copilot",
|
||||
"x-initiator": initiator,
|
||||
...(hasImages ? { "Copilot-Vision-Request": "true" } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
describe("wrapCopilotAnthropicStream", () => {
|
||||
it("adds Copilot headers, strips thinking replay, and marks cache for Claude payloads", () => {
|
||||
const payloads: Array<{
|
||||
@@ -63,12 +72,9 @@ describe("wrapCopilotAnthropicStream", () => {
|
||||
{ type: "image", image: "data:image/png;base64,abc" },
|
||||
],
|
||||
},
|
||||
] as Parameters<typeof buildCopilotDynamicHeaders>[0]["messages"];
|
||||
] as Context["messages"];
|
||||
const context = { messages };
|
||||
const expectedCopilotHeaders = buildCopilotDynamicHeaders({
|
||||
messages,
|
||||
hasImages: true,
|
||||
});
|
||||
const expectedCopilotHeaders = buildExpectedCopilotHeaders("user", true);
|
||||
expect(expectedCopilotHeaders["Accept-Encoding"]).toBe("identity");
|
||||
|
||||
void wrapped(
|
||||
@@ -192,7 +198,7 @@ describe("wrapCopilotAnthropicStream", () => {
|
||||
} as never;
|
||||
});
|
||||
|
||||
const wrapped = requireStreamFn(wrapCopilotOpenAIResponsesStream(baseStreamFn));
|
||||
const wrapped = requireStreamFn(wrapCopilotProviderStream({ streamFn: baseStreamFn } as never));
|
||||
const messages = [
|
||||
{
|
||||
role: "toolResult",
|
||||
@@ -201,11 +207,8 @@ describe("wrapCopilotAnthropicStream", () => {
|
||||
{ type: "image", image: "data:image/png;base64,abc" },
|
||||
],
|
||||
},
|
||||
] as Parameters<typeof buildCopilotDynamicHeaders>[0]["messages"];
|
||||
const expectedCopilotHeaders = buildCopilotDynamicHeaders({
|
||||
messages,
|
||||
hasImages: true,
|
||||
});
|
||||
] as Context["messages"];
|
||||
const expectedCopilotHeaders = buildExpectedCopilotHeaders("agent", true);
|
||||
|
||||
void wrapped(
|
||||
{
|
||||
@@ -249,7 +252,7 @@ describe("wrapCopilotAnthropicStream", () => {
|
||||
} as never;
|
||||
});
|
||||
|
||||
const wrapped = requireStreamFn(wrapCopilotOpenAIResponsesStream(baseStreamFn));
|
||||
const wrapped = requireStreamFn(wrapCopilotProviderStream({ streamFn: baseStreamFn } as never));
|
||||
|
||||
await wrapped(
|
||||
{
|
||||
@@ -270,7 +273,7 @@ describe("wrapCopilotAnthropicStream", () => {
|
||||
|
||||
it("adds Copilot headers for Chat Completions models", () => {
|
||||
const baseStreamFn = vi.fn(() => ({ async *[Symbol.asyncIterator]() {} }) as never);
|
||||
const wrapped = requireStreamFn(wrapCopilotOpenAICompletionsStream(baseStreamFn));
|
||||
const wrapped = requireStreamFn(wrapCopilotProviderStream({ streamFn: baseStreamFn } as never));
|
||||
const messages = [
|
||||
{
|
||||
role: "user",
|
||||
@@ -279,11 +282,8 @@ describe("wrapCopilotAnthropicStream", () => {
|
||||
{ type: "image", data: "abc", mimeType: "image/png" },
|
||||
],
|
||||
},
|
||||
] as Parameters<typeof buildCopilotDynamicHeaders>[0]["messages"];
|
||||
const expectedCopilotHeaders = buildCopilotDynamicHeaders({
|
||||
messages,
|
||||
hasImages: true,
|
||||
});
|
||||
] as Context["messages"];
|
||||
const expectedCopilotHeaders = buildExpectedCopilotHeaders("user", true);
|
||||
|
||||
void wrapped(
|
||||
{
|
||||
|
||||
@@ -46,7 +46,7 @@ function hasCopilotVisionInput(messages: Context["messages"]): boolean {
|
||||
});
|
||||
}
|
||||
|
||||
export function buildCopilotDynamicHeaders(params: {
|
||||
function buildCopilotDynamicHeaders(params: {
|
||||
messages: Context["messages"];
|
||||
hasImages: boolean;
|
||||
}): Record<string, string> {
|
||||
@@ -115,7 +115,7 @@ export function wrapCopilotAnthropicStream(
|
||||
};
|
||||
}
|
||||
|
||||
export function wrapCopilotOpenAIResponsesStream(
|
||||
function wrapCopilotOpenAIResponsesStream(
|
||||
baseStreamFn: StreamFn | undefined,
|
||||
): StreamFn | undefined {
|
||||
if (!baseStreamFn) {
|
||||
@@ -140,7 +140,7 @@ export function wrapCopilotOpenAIResponsesStream(
|
||||
};
|
||||
}
|
||||
|
||||
export function wrapCopilotOpenAICompletionsStream(
|
||||
function wrapCopilotOpenAICompletionsStream(
|
||||
baseStreamFn: StreamFn | undefined,
|
||||
): StreamFn | undefined {
|
||||
if (!baseStreamFn) {
|
||||
|
||||
@@ -19,9 +19,6 @@ import {
|
||||
resolveCopilotTokenCache,
|
||||
type CachedCopilotToken,
|
||||
} from "./token-cache.js";
|
||||
export { deriveCopilotApiBaseUrlFromToken } from "openclaw/plugin-sdk/provider-auth";
|
||||
export type { CachedCopilotToken } from "./token-cache.js";
|
||||
|
||||
export const DEFAULT_COPILOT_API_BASE_URL = "https://api.individual.githubcopilot.com";
|
||||
const COPILOT_TOKEN_EXCHANGE_TIMEOUT_MS = 30_000;
|
||||
let openConfiguredCacheStore: (() => PluginStateSyncKeyedStore<CachedCopilotToken>) | undefined;
|
||||
@@ -87,8 +84,6 @@ async function cancelUnreadResponseBody(response: Response): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
export type ResolveCopilotApiToken = typeof resolveCopilotApiToken;
|
||||
|
||||
export async function resolveCopilotApiToken(params: {
|
||||
githubToken: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Openai tests cover embedding batch plugin behavior.
|
||||
import { createServer } from "node:http";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { parseOpenAiBatchOutput, runOpenAiEmbeddingBatches } from "./embedding-batch.js";
|
||||
import { runOpenAiEmbeddingBatches } from "./embedding-batch.js";
|
||||
|
||||
const jsonlEncoder = new TextEncoder();
|
||||
|
||||
@@ -83,12 +83,6 @@ async function closeServer(server: ReturnType<typeof createServer>): Promise<voi
|
||||
}
|
||||
|
||||
describe("OpenAI embedding batch output", () => {
|
||||
it("wraps malformed JSONL output", () => {
|
||||
expect(() => parseOpenAiBatchOutput('{"custom_id":"ok"}\n{not json')).toThrow(
|
||||
"OpenAI embedding batch output contained malformed JSONL",
|
||||
);
|
||||
});
|
||||
|
||||
it("reads a completed error file before downloading successful output", async () => {
|
||||
let outputFetched = false;
|
||||
const fetchImpl = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
|
||||
@@ -174,7 +174,7 @@ function isOpenAiBatchUploadTooLargeError(error: unknown): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
export function parseOpenAiBatchOutput(text: string): OpenAiBatchOutputLine[] {
|
||||
function parseOpenAiBatchOutput(text: string): OpenAiBatchOutputLine[] {
|
||||
if (!text.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -5,16 +5,19 @@ import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api";
|
||||
import { requireRegisteredProvider } from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import * as providerAuth from "openclaw/plugin-sdk/provider-auth-runtime";
|
||||
import * as providerHttp from "openclaw/plugin-sdk/provider-http";
|
||||
import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import {
|
||||
GPT5_BEHAVIOR_CONTRACT,
|
||||
GPT5_FRIENDLY_CHAT_PROMPT_OVERLAY,
|
||||
GPT5_HEARTBEAT_PROMPT_OVERLAY,
|
||||
type ProviderPlugin,
|
||||
} from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { buildOpenAIImageGenerationProvider } from "./image-generation-provider.js";
|
||||
import plugin from "./index.js";
|
||||
import {
|
||||
OPENAI_FRIENDLY_PROMPT_OVERLAY,
|
||||
OPENAI_GPT5_BEHAVIOR_CONTRACT,
|
||||
OPENAI_HEARTBEAT_PROMPT_OVERLAY,
|
||||
shouldApplyOpenAIPromptOverlay,
|
||||
} from "./prompt-overlay.js";
|
||||
|
||||
const OPENAI_FRIENDLY_PROMPT_OVERLAY = GPT5_FRIENDLY_CHAT_PROMPT_OVERLAY;
|
||||
const OPENAI_GPT5_BEHAVIOR_CONTRACT = GPT5_BEHAVIOR_CONTRACT;
|
||||
const OPENAI_HEARTBEAT_PROMPT_OVERLAY = GPT5_HEARTBEAT_PROMPT_OVERLAY;
|
||||
|
||||
const runtimeMocks = vi.hoisted(() => ({
|
||||
ensureGlobalUndiciEnvProxyDispatcher: vi.fn(),
|
||||
@@ -35,7 +38,7 @@ vi.mock("./openai-chatgpt-oauth-flow.runtime.js", () => ({
|
||||
refreshOpenAICodexToken: runtimeMocks.refreshOpenAICodexToken,
|
||||
}));
|
||||
|
||||
import { createOpenAICodexProviderRuntime } from "./openai-chatgpt-provider.runtime.js";
|
||||
import { createOpenAICodexProviderRuntime } from "./openai-chatgpt-provider-runtime.factory.js";
|
||||
async function registerOpenAIPluginWithHook(params?: { pluginConfig?: Record<string, unknown> }) {
|
||||
const on = vi.fn();
|
||||
const providers: ProviderPlugin[] = [];
|
||||
@@ -299,7 +302,6 @@ describe("openai plugin", () => {
|
||||
runtimeMocks.refreshOpenAICodexToken.mockResolvedValue(refreshed);
|
||||
const runtime = createOpenAICodexProviderRuntime({
|
||||
ensureGlobalUndiciEnvProxyDispatcher: runtimeMocks.ensureGlobalUndiciEnvProxyDispatcher,
|
||||
getOAuthApiKey: vi.fn(),
|
||||
refreshOpenAICodexToken: runtimeMocks.refreshOpenAICodexToken,
|
||||
});
|
||||
|
||||
@@ -459,12 +461,6 @@ describe("openai plugin", () => {
|
||||
modelId: "gpt-image-1",
|
||||
}),
|
||||
).toBeUndefined();
|
||||
expect(shouldApplyOpenAIPromptOverlay({ modelProviderId: "openai", modelId: "gpt-4.1" })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
shouldApplyOpenAIPromptOverlay({ modelProviderId: "anthropic", modelId: "gpt-5.4" }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("includes the tagged GPT-5 behavior contract in the OpenAI prompt overlay", () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -63,9 +63,7 @@ function raiseMinimalReasoningForOpenAINativeWebSearch(payload: Record<string, u
|
||||
reasoning.effort = "low";
|
||||
}
|
||||
|
||||
export function patchOpenAINativeWebSearchPayload(
|
||||
payload: unknown,
|
||||
): OpenAINativeWebSearchPatchResult {
|
||||
function patchOpenAINativeWebSearchPayload(payload: unknown): OpenAINativeWebSearchPatchResult {
|
||||
if (!isRecord(payload)) {
|
||||
return "payload_not_object";
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// Openai plugin module implements openai chatgpt oauth abort behavior.
|
||||
export {
|
||||
buildOAuthRequestSignal,
|
||||
createOAuthLoginCancelledError,
|
||||
throwIfOAuthLoginAborted,
|
||||
withOAuthLoginAbort,
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
|
||||
import { generatePKCE } from "./openai-chatgpt-pkce.runtime.js";
|
||||
|
||||
const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
|
||||
const AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize";
|
||||
const CALLBACK_PORT = 1455;
|
||||
const CALLBACK_PATH = "/auth/callback";
|
||||
const DEFAULT_CALLBACK_HOST = "localhost";
|
||||
const LOOPBACK_CALLBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
|
||||
const SCOPE = "openid profile email offline_access";
|
||||
|
||||
const loadNodeCrypto = createLazyRuntimeModule(() =>
|
||||
import("node:crypto").then((cryptoModule) => cryptoModule.randomBytes),
|
||||
);
|
||||
|
||||
export function resolveOpenAICallbackHost(env: NodeJS.ProcessEnv = process.env): string {
|
||||
const host = env.OPENCLAW_OAUTH_CALLBACK_HOST?.trim() || DEFAULT_CALLBACK_HOST;
|
||||
if (!LOOPBACK_CALLBACK_HOSTS.has(host)) {
|
||||
throw new Error("OpenAI Codex OAuth callback host must be localhost, 127.0.0.1, or ::1");
|
||||
}
|
||||
return host;
|
||||
}
|
||||
|
||||
export function resolveOpenAIRedirectUri(host: string): string {
|
||||
const hostForUrl = host === "::1" ? "[::1]" : host;
|
||||
const url = new URL(`http://${hostForUrl}:${CALLBACK_PORT}`);
|
||||
url.pathname = CALLBACK_PATH;
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
export async function createOpenAIAuthorizationFlow(
|
||||
originator: string,
|
||||
redirectUri: string,
|
||||
): Promise<{ verifier: string; redirectUri: string; state: string; url: string }> {
|
||||
if (typeof process === "undefined" || (!process.versions?.node && !process.versions?.bun)) {
|
||||
throw new Error("OpenAI Codex OAuth is only available in Node.js environments");
|
||||
}
|
||||
const [{ verifier, challenge }, randomBytes] = await Promise.all([
|
||||
generatePKCE(),
|
||||
loadNodeCrypto(),
|
||||
]);
|
||||
const state = randomBytes(16).toString("hex");
|
||||
const url = new URL(AUTHORIZE_URL);
|
||||
url.searchParams.set("response_type", "code");
|
||||
url.searchParams.set("client_id", CLIENT_ID);
|
||||
url.searchParams.set("redirect_uri", redirectUri);
|
||||
url.searchParams.set("scope", SCOPE);
|
||||
url.searchParams.set("code_challenge", challenge);
|
||||
url.searchParams.set("code_challenge_method", "S256");
|
||||
url.searchParams.set("state", state);
|
||||
url.searchParams.set("id_token_add_organizations", "true");
|
||||
url.searchParams.set("codex_cli_simplified_flow", "true");
|
||||
url.searchParams.set("originator", originator);
|
||||
return { verifier, redirectUri, state, url: url.toString() };
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -6,64 +6,36 @@
|
||||
*/
|
||||
|
||||
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
|
||||
import {
|
||||
parseOAuthAuthorizationInput,
|
||||
resolveOAuthTokenExpiresAt,
|
||||
resolveOAuthTokenLifetimeMs,
|
||||
} from "openclaw/plugin-sdk/provider-oauth-runtime";
|
||||
import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
|
||||
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { parseOAuthAuthorizationInput } from "openclaw/plugin-sdk/provider-oauth-runtime";
|
||||
import { resolveCodexAuthIdentity } from "./openai-chatgpt-auth-identity.js";
|
||||
import {
|
||||
createOAuthLoginCancelledError,
|
||||
throwIfOAuthLoginAborted,
|
||||
withOAuthLoginAbort,
|
||||
} from "./openai-chatgpt-oauth-abort.runtime.js";
|
||||
import {
|
||||
createOpenAIAuthorizationFlow,
|
||||
resolveOpenAICallbackHost,
|
||||
resolveOpenAIRedirectUri,
|
||||
} from "./openai-chatgpt-oauth-authorization.runtime.js";
|
||||
import { oauthErrorHtml, oauthSuccessHtml } from "./openai-chatgpt-oauth-page.runtime.js";
|
||||
import type {
|
||||
OAuthCredentials,
|
||||
OAuthLoginCallbacks,
|
||||
OAuthPrompt,
|
||||
OAuthProviderInterface,
|
||||
} from "./openai-chatgpt-oauth-types.runtime.js";
|
||||
import { generatePKCE } from "./openai-chatgpt-pkce.runtime.js";
|
||||
import {
|
||||
exchangeOpenAIAuthorizationCode,
|
||||
refreshOpenAIAccessToken,
|
||||
} from "./openai-chatgpt-oauth-token.runtime.js";
|
||||
import type { OAuthCredentials, OAuthPrompt } from "./openai-chatgpt-oauth-types.runtime.js";
|
||||
|
||||
const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
|
||||
const AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize";
|
||||
const TOKEN_URL = "https://auth.openai.com/oauth/token";
|
||||
const CALLBACK_PORT = 1455;
|
||||
const CALLBACK_PATH = "/auth/callback";
|
||||
const DEFAULT_CALLBACK_HOST = "localhost";
|
||||
const LOOPBACK_CALLBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
|
||||
const CALLBACK_HOST = resolveCallbackHost();
|
||||
const REDIRECT_URI = resolveRedirectUri(CALLBACK_HOST);
|
||||
const CALLBACK_HOST = resolveOpenAICallbackHost();
|
||||
const REDIRECT_URI = resolveOpenAIRedirectUri(CALLBACK_HOST);
|
||||
const MANUAL_PROMPT_FALLBACK_MS = 15_000;
|
||||
const TOKEN_REQUEST_TIMEOUT_MS = 30_000;
|
||||
const SCOPE = "openid profile email offline_access";
|
||||
const OAUTH_TOKEN_RESPONSE_BODY_LIMIT_BYTES = 1 * 1024 * 1024;
|
||||
|
||||
type TokenSuccess = { type: "success"; access: string; refresh: string; expires: number };
|
||||
type TokenFailure = { type: "failed"; message: string; status?: number };
|
||||
type TokenResult = TokenSuccess | TokenFailure;
|
||||
type TokenResponseJson = {
|
||||
access_token?: string;
|
||||
refresh_token?: string;
|
||||
expires_in?: number;
|
||||
};
|
||||
type NodeOAuthRuntime = {
|
||||
randomBytes: typeof import("node:crypto").randomBytes;
|
||||
http: typeof import("node:http");
|
||||
};
|
||||
type TokenRequestOptions = {
|
||||
signal?: AbortSignal;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
const loadNodeOAuthModules = createLazyRuntimeModule(() =>
|
||||
Promise.all([import("node:crypto"), import("node:http")]).then(([cryptoModule, httpModule]) => ({
|
||||
randomBytes: cryptoModule.randomBytes,
|
||||
http: httpModule,
|
||||
})),
|
||||
import("node:http").then((http) => ({ http })),
|
||||
);
|
||||
|
||||
function loadNodeOAuthRuntime(): Promise<NodeOAuthRuntime> {
|
||||
@@ -75,25 +47,6 @@ function loadNodeOAuthRuntime(): Promise<NodeOAuthRuntime> {
|
||||
return loadNodeOAuthModules();
|
||||
}
|
||||
|
||||
function resolveCallbackHost(env: NodeJS.ProcessEnv = process.env): string {
|
||||
const host = env.OPENCLAW_OAUTH_CALLBACK_HOST?.trim() || DEFAULT_CALLBACK_HOST;
|
||||
if (!LOOPBACK_CALLBACK_HOSTS.has(host)) {
|
||||
throw new Error("OpenAI Codex OAuth callback host must be localhost, 127.0.0.1, or ::1");
|
||||
}
|
||||
return host;
|
||||
}
|
||||
|
||||
function resolveRedirectUri(host: string = CALLBACK_HOST): string {
|
||||
const hostForUrl = host === "::1" ? "[::1]" : host;
|
||||
const url = new URL(`http://${hostForUrl}:${CALLBACK_PORT}`);
|
||||
url.pathname = CALLBACK_PATH;
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function createState(randomBytes: typeof import("node:crypto").randomBytes): string {
|
||||
return randomBytes(16).toString("hex");
|
||||
}
|
||||
|
||||
function waitForManualPromptFallback(signal?: AbortSignal): Promise<null> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) {
|
||||
@@ -133,204 +86,6 @@ async function promptForAuthorizationCode(
|
||||
return parsed.code;
|
||||
}
|
||||
|
||||
function formatMissingTokenResponseFields(json: TokenResponseJson): string {
|
||||
const missing: string[] = [];
|
||||
if (!json.access_token) {
|
||||
missing.push("access_token");
|
||||
}
|
||||
if (!json.refresh_token) {
|
||||
missing.push("refresh_token");
|
||||
}
|
||||
if (resolveOAuthTokenLifetimeMs(json.expires_in) === undefined) {
|
||||
missing.push("expires_in");
|
||||
}
|
||||
return missing.join(", ");
|
||||
}
|
||||
|
||||
function formatTokenRequestError(
|
||||
operation: "exchange" | "refresh",
|
||||
error: unknown,
|
||||
timeoutMs: number,
|
||||
signal?: AbortSignal,
|
||||
): string {
|
||||
if (signal?.aborted) {
|
||||
return "Login cancelled";
|
||||
}
|
||||
if (error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError")) {
|
||||
return `OpenAI Codex token ${operation} timed out after ${timeoutMs}ms`;
|
||||
}
|
||||
return `OpenAI Codex token ${operation} error: ${error instanceof Error ? error.message : String(error)}`;
|
||||
}
|
||||
|
||||
async function postTokenForm(
|
||||
body: URLSearchParams,
|
||||
options: TokenRequestOptions = {},
|
||||
): Promise<Response> {
|
||||
const timeoutMs = options.timeoutMs ?? TOKEN_REQUEST_TIMEOUT_MS;
|
||||
throwIfOAuthLoginAborted(options.signal);
|
||||
const { response, release } = await fetchWithSsrFGuard({
|
||||
url: TOKEN_URL,
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body,
|
||||
},
|
||||
timeoutMs,
|
||||
signal: options.signal,
|
||||
auditContext: "openai-chatgpt-oauth-token",
|
||||
});
|
||||
try {
|
||||
const responseBody = await readResponseWithLimit(
|
||||
response,
|
||||
OAUTH_TOKEN_RESPONSE_BODY_LIMIT_BYTES,
|
||||
{
|
||||
onOverflow: ({ size, maxBytes }) =>
|
||||
new Error(
|
||||
`OpenAI Codex OAuth token response body too large: ${size} bytes (limit: ${maxBytes} bytes)`,
|
||||
),
|
||||
},
|
||||
);
|
||||
return new Response(new Uint8Array(responseBody), {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: response.headers,
|
||||
});
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
}
|
||||
|
||||
async function exchangeAuthorizationCode(
|
||||
code: string,
|
||||
verifier: string,
|
||||
redirectUri: string = REDIRECT_URI,
|
||||
options: TokenRequestOptions = {},
|
||||
): Promise<TokenResult> {
|
||||
const timeoutMs = options.timeoutMs ?? TOKEN_REQUEST_TIMEOUT_MS;
|
||||
let response: Response;
|
||||
try {
|
||||
response = await postTokenForm(
|
||||
new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
client_id: CLIENT_ID,
|
||||
code,
|
||||
code_verifier: verifier,
|
||||
redirect_uri: redirectUri,
|
||||
}),
|
||||
{ signal: options.signal, timeoutMs },
|
||||
);
|
||||
} catch (error) {
|
||||
return {
|
||||
type: "failed",
|
||||
message: formatTokenRequestError("exchange", error, timeoutMs, options.signal),
|
||||
};
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => "");
|
||||
return {
|
||||
type: "failed",
|
||||
status: response.status,
|
||||
message: `OpenAI Codex token exchange failed (${response.status}): ${text || response.statusText}`,
|
||||
};
|
||||
}
|
||||
|
||||
const json = (await response.json()) as TokenResponseJson;
|
||||
|
||||
const expires = resolveOAuthTokenExpiresAt(json.expires_in);
|
||||
if (!json.access_token || !json.refresh_token || expires === undefined) {
|
||||
return {
|
||||
type: "failed",
|
||||
message: `OpenAI Codex token exchange response missing fields: ${formatMissingTokenResponseFields(json)}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "success",
|
||||
access: json.access_token,
|
||||
refresh: json.refresh_token,
|
||||
expires,
|
||||
};
|
||||
}
|
||||
|
||||
async function refreshAccessToken(
|
||||
refreshToken: string,
|
||||
options: TokenRequestOptions = {},
|
||||
): Promise<TokenResult> {
|
||||
try {
|
||||
const timeoutMs = options.timeoutMs ?? TOKEN_REQUEST_TIMEOUT_MS;
|
||||
const response = await postTokenForm(
|
||||
new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken,
|
||||
client_id: CLIENT_ID,
|
||||
}),
|
||||
{ signal: options.signal, timeoutMs },
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => "");
|
||||
return {
|
||||
type: "failed",
|
||||
status: response.status,
|
||||
message: `OpenAI Codex token refresh failed (${response.status}): ${text || response.statusText}`,
|
||||
};
|
||||
}
|
||||
|
||||
const json = (await response.json()) as TokenResponseJson;
|
||||
|
||||
const expires = resolveOAuthTokenExpiresAt(json.expires_in);
|
||||
if (!json.access_token || !json.refresh_token || expires === undefined) {
|
||||
return {
|
||||
type: "failed",
|
||||
message: `OpenAI Codex token refresh response missing fields: ${formatMissingTokenResponseFields(json)}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "success",
|
||||
access: json.access_token,
|
||||
refresh: json.refresh_token,
|
||||
expires,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
type: "failed",
|
||||
message: formatTokenRequestError(
|
||||
"refresh",
|
||||
error,
|
||||
options.timeoutMs ?? TOKEN_REQUEST_TIMEOUT_MS,
|
||||
options.signal,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function createAuthorizationFlow(
|
||||
originator = "openclaw",
|
||||
): Promise<{ verifier: string; redirectUri: string; state: string; url: string }> {
|
||||
const [{ verifier, challenge }, runtime] = await Promise.all([
|
||||
generatePKCE(),
|
||||
loadNodeOAuthRuntime(),
|
||||
]);
|
||||
const state = createState(runtime.randomBytes);
|
||||
|
||||
const url = new URL(AUTHORIZE_URL);
|
||||
url.searchParams.set("response_type", "code");
|
||||
url.searchParams.set("client_id", CLIENT_ID);
|
||||
const redirectUri = REDIRECT_URI;
|
||||
url.searchParams.set("redirect_uri", redirectUri);
|
||||
url.searchParams.set("scope", SCOPE);
|
||||
url.searchParams.set("code_challenge", challenge);
|
||||
url.searchParams.set("code_challenge_method", "S256");
|
||||
url.searchParams.set("state", state);
|
||||
url.searchParams.set("id_token_add_organizations", "true");
|
||||
url.searchParams.set("codex_cli_simplified_flow", "true");
|
||||
url.searchParams.set("originator", originator);
|
||||
|
||||
return { verifier, redirectUri, state, url: url.toString() };
|
||||
}
|
||||
|
||||
type OAuthServerInfo = {
|
||||
close: () => void;
|
||||
cancelWait: () => void;
|
||||
@@ -437,7 +192,10 @@ export async function loginOpenAICodex(options: {
|
||||
signal?: AbortSignal;
|
||||
}): Promise<OAuthCredentials> {
|
||||
throwIfOAuthLoginAborted(options.signal);
|
||||
const { verifier, redirectUri, state, url } = await createAuthorizationFlow(options.originator);
|
||||
const { verifier, redirectUri, state, url } = await createOpenAIAuthorizationFlow(
|
||||
options.originator ?? "openclaw",
|
||||
REDIRECT_URI,
|
||||
);
|
||||
const server = await startLocalOAuthServer(state);
|
||||
|
||||
let code: string | undefined;
|
||||
@@ -538,7 +296,7 @@ export async function loginOpenAICodex(options: {
|
||||
throw new Error("Missing authorization code");
|
||||
}
|
||||
|
||||
const tokenResult = await exchangeAuthorizationCode(code, verifier, redirectUri, {
|
||||
const tokenResult = await exchangeOpenAIAuthorizationCode(code, verifier, redirectUri, {
|
||||
signal: options.signal,
|
||||
});
|
||||
if (tokenResult.type !== "success") {
|
||||
@@ -565,7 +323,7 @@ export async function loginOpenAICodex(options: {
|
||||
* Refresh OpenAI Codex OAuth token
|
||||
*/
|
||||
export async function refreshOpenAICodexToken(refreshToken: string): Promise<OAuthCredentials> {
|
||||
const result = await refreshAccessToken(refreshToken);
|
||||
const result = await refreshOpenAIAccessToken(refreshToken);
|
||||
if (result.type !== "success") {
|
||||
throw new Error(result.message);
|
||||
}
|
||||
@@ -583,40 +341,6 @@ export async function refreshOpenAICodexToken(refreshToken: string): Promise<OAu
|
||||
};
|
||||
}
|
||||
|
||||
export const openaiCodexOAuthProvider: OAuthProviderInterface = {
|
||||
id: "openai",
|
||||
name: "ChatGPT Plus/Pro (Codex Subscription)",
|
||||
usesCallbackServer: true,
|
||||
|
||||
async login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
|
||||
return loginOpenAICodex({
|
||||
onAuth: callbacks.onAuth,
|
||||
onPrompt: callbacks.onPrompt,
|
||||
onProgress: callbacks.onProgress,
|
||||
onManualCodeInput: callbacks.onManualCodeInput,
|
||||
signal: callbacks.signal,
|
||||
});
|
||||
},
|
||||
|
||||
async refreshToken(credentials: OAuthCredentials): Promise<OAuthCredentials> {
|
||||
return refreshOpenAICodexToken(credentials.refresh);
|
||||
},
|
||||
|
||||
getApiKey(credentials: OAuthCredentials): string {
|
||||
return credentials.access;
|
||||
},
|
||||
};
|
||||
|
||||
export const testing = {
|
||||
callbackHost: CALLBACK_HOST,
|
||||
createAuthorizationFlow,
|
||||
exchangeAuthorizationCode,
|
||||
loginOpenAICodex,
|
||||
refreshAccessToken,
|
||||
resolveCallbackHost,
|
||||
resolveRedirectUri,
|
||||
};
|
||||
|
||||
function toLintErrorObject(value: unknown, fallbackMessage: string): Error {
|
||||
if (value instanceof Error) {
|
||||
return value;
|
||||
|
||||
85
extensions/openai/openai-chatgpt-oauth-preflight.runtime.ts
Normal file
85
extensions/openai/openai-chatgpt-oauth-preflight.runtime.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
|
||||
|
||||
const OPENAI_AUTH_PROBE_URL =
|
||||
"https://auth.openai.com/oauth/authorize?response_type=code&client_id=openclaw-preflight&redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback&scope=openid+profile+email";
|
||||
const TLS_CERT_ERROR_CODES = new Set([
|
||||
"UNABLE_TO_GET_ISSUER_CERT_LOCALLY",
|
||||
"UNABLE_TO_VERIFY_LEAF_SIGNATURE",
|
||||
"CERT_HAS_EXPIRED",
|
||||
"DEPTH_ZERO_SELF_SIGNED_CERT",
|
||||
"SELF_SIGNED_CERT_IN_CHAIN",
|
||||
"ERR_TLS_CERT_ALTNAME_INVALID",
|
||||
]);
|
||||
const TLS_CERT_ERROR_PATTERNS = [
|
||||
/unable to get local issuer certificate/i,
|
||||
/unable to verify the first certificate/i,
|
||||
/self[- ]signed certificate/i,
|
||||
/certificate has expired/i,
|
||||
];
|
||||
|
||||
type PreflightFailureKind = "tls-cert" | "network";
|
||||
export type OpenAIOAuthTlsPreflightResult =
|
||||
| { ok: true }
|
||||
| {
|
||||
ok: false;
|
||||
kind: PreflightFailureKind;
|
||||
code?: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
function getErrorRecord(error: unknown): Record<string, unknown> | null {
|
||||
return error && typeof error === "object" ? (error as Record<string, unknown>) : null;
|
||||
}
|
||||
|
||||
function extractFailure(error: unknown): {
|
||||
code?: string;
|
||||
message: string;
|
||||
kind: PreflightFailureKind;
|
||||
} {
|
||||
const root = getErrorRecord(error);
|
||||
const rootCause = getErrorRecord(root?.cause);
|
||||
const code = typeof rootCause?.code === "string" ? rootCause.code : undefined;
|
||||
const message =
|
||||
typeof rootCause?.message === "string"
|
||||
? rootCause.message
|
||||
: typeof root?.message === "string"
|
||||
? root.message
|
||||
: String(error);
|
||||
const isTlsCertError =
|
||||
(code ? TLS_CERT_ERROR_CODES.has(code) : false) ||
|
||||
TLS_CERT_ERROR_PATTERNS.some((pattern) => pattern.test(message));
|
||||
return {
|
||||
code,
|
||||
message,
|
||||
kind: isTlsCertError ? "tls-cert" : "network",
|
||||
};
|
||||
}
|
||||
|
||||
export async function runOpenAIOAuthTlsPreflight(options?: {
|
||||
timeoutMs?: number;
|
||||
fetchImpl?: typeof fetch;
|
||||
}): Promise<OpenAIOAuthTlsPreflightResult> {
|
||||
const timeoutMs = resolveTimerTimeoutMs(options?.timeoutMs, 5000);
|
||||
const fetchImpl = options?.fetchImpl ?? fetch;
|
||||
let response: Response | undefined;
|
||||
try {
|
||||
response = await fetchImpl(OPENAI_AUTH_PROBE_URL, {
|
||||
method: "GET",
|
||||
redirect: "manual",
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
const failure = extractFailure(error);
|
||||
return {
|
||||
ok: false,
|
||||
kind: failure.kind,
|
||||
code: failure.code,
|
||||
message: failure.message,
|
||||
};
|
||||
} finally {
|
||||
if (response?.bodyUsed !== true) {
|
||||
await response?.body?.cancel().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
190
extensions/openai/openai-chatgpt-oauth-token.runtime.ts
Normal file
190
extensions/openai/openai-chatgpt-oauth-token.runtime.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import {
|
||||
resolveOAuthTokenExpiresAt,
|
||||
resolveOAuthTokenLifetimeMs,
|
||||
} from "openclaw/plugin-sdk/provider-oauth-runtime";
|
||||
import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
|
||||
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { throwIfOAuthLoginAborted } from "./openai-chatgpt-oauth-abort.runtime.js";
|
||||
|
||||
const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
|
||||
const TOKEN_URL = "https://auth.openai.com/oauth/token";
|
||||
const TOKEN_REQUEST_TIMEOUT_MS = 30_000;
|
||||
const OAUTH_TOKEN_RESPONSE_BODY_LIMIT_BYTES = 1 * 1024 * 1024;
|
||||
|
||||
type TokenSuccess = { type: "success"; access: string; refresh: string; expires: number };
|
||||
type TokenFailure = { type: "failed"; message: string; status?: number };
|
||||
type TokenResult = TokenSuccess | TokenFailure;
|
||||
type TokenResponseJson = {
|
||||
access_token?: string;
|
||||
refresh_token?: string;
|
||||
expires_in?: number;
|
||||
};
|
||||
type TokenRequestOptions = {
|
||||
signal?: AbortSignal;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
function formatMissingTokenResponseFields(json: TokenResponseJson): string {
|
||||
const missing: string[] = [];
|
||||
if (!json.access_token) {
|
||||
missing.push("access_token");
|
||||
}
|
||||
if (!json.refresh_token) {
|
||||
missing.push("refresh_token");
|
||||
}
|
||||
if (resolveOAuthTokenLifetimeMs(json.expires_in) === undefined) {
|
||||
missing.push("expires_in");
|
||||
}
|
||||
return missing.join(", ");
|
||||
}
|
||||
|
||||
function formatTokenRequestError(
|
||||
operation: "exchange" | "refresh",
|
||||
error: unknown,
|
||||
timeoutMs: number,
|
||||
signal?: AbortSignal,
|
||||
): string {
|
||||
if (signal?.aborted) {
|
||||
return "Login cancelled";
|
||||
}
|
||||
if (error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError")) {
|
||||
return `OpenAI Codex token ${operation} timed out after ${timeoutMs}ms`;
|
||||
}
|
||||
return `OpenAI Codex token ${operation} error: ${error instanceof Error ? error.message : String(error)}`;
|
||||
}
|
||||
|
||||
async function postTokenForm(
|
||||
body: URLSearchParams,
|
||||
options: TokenRequestOptions = {},
|
||||
): Promise<Response> {
|
||||
const timeoutMs = options.timeoutMs ?? TOKEN_REQUEST_TIMEOUT_MS;
|
||||
throwIfOAuthLoginAborted(options.signal);
|
||||
const { response, release } = await fetchWithSsrFGuard({
|
||||
url: TOKEN_URL,
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body,
|
||||
},
|
||||
timeoutMs,
|
||||
signal: options.signal,
|
||||
auditContext: "openai-chatgpt-oauth-token",
|
||||
});
|
||||
try {
|
||||
const responseBody = await readResponseWithLimit(
|
||||
response,
|
||||
OAUTH_TOKEN_RESPONSE_BODY_LIMIT_BYTES,
|
||||
{
|
||||
onOverflow: ({ size, maxBytes }) =>
|
||||
new Error(
|
||||
`OpenAI Codex OAuth token response body too large: ${size} bytes (limit: ${maxBytes} bytes)`,
|
||||
),
|
||||
},
|
||||
);
|
||||
return new Response(new Uint8Array(responseBody), {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: response.headers,
|
||||
});
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
}
|
||||
|
||||
export async function exchangeOpenAIAuthorizationCode(
|
||||
code: string,
|
||||
verifier: string,
|
||||
redirectUri: string,
|
||||
options: TokenRequestOptions = {},
|
||||
): Promise<TokenResult> {
|
||||
const timeoutMs = options.timeoutMs ?? TOKEN_REQUEST_TIMEOUT_MS;
|
||||
let response: Response;
|
||||
try {
|
||||
response = await postTokenForm(
|
||||
new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
client_id: CLIENT_ID,
|
||||
code,
|
||||
code_verifier: verifier,
|
||||
redirect_uri: redirectUri,
|
||||
}),
|
||||
{ signal: options.signal, timeoutMs },
|
||||
);
|
||||
} catch (error) {
|
||||
return {
|
||||
type: "failed",
|
||||
message: formatTokenRequestError("exchange", error, timeoutMs, options.signal),
|
||||
};
|
||||
}
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => "");
|
||||
return {
|
||||
type: "failed",
|
||||
status: response.status,
|
||||
message: `OpenAI Codex token exchange failed (${response.status}): ${text || response.statusText}`,
|
||||
};
|
||||
}
|
||||
const json = (await response.json()) as TokenResponseJson;
|
||||
const expires = resolveOAuthTokenExpiresAt(json.expires_in);
|
||||
if (!json.access_token || !json.refresh_token || expires === undefined) {
|
||||
return {
|
||||
type: "failed",
|
||||
message: `OpenAI Codex token exchange response missing fields: ${formatMissingTokenResponseFields(json)}`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: "success",
|
||||
access: json.access_token,
|
||||
refresh: json.refresh_token,
|
||||
expires,
|
||||
};
|
||||
}
|
||||
|
||||
export async function refreshOpenAIAccessToken(
|
||||
refreshToken: string,
|
||||
options: TokenRequestOptions = {},
|
||||
): Promise<TokenResult> {
|
||||
try {
|
||||
const timeoutMs = options.timeoutMs ?? TOKEN_REQUEST_TIMEOUT_MS;
|
||||
const response = await postTokenForm(
|
||||
new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken,
|
||||
client_id: CLIENT_ID,
|
||||
}),
|
||||
{ signal: options.signal, timeoutMs },
|
||||
);
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => "");
|
||||
return {
|
||||
type: "failed",
|
||||
status: response.status,
|
||||
message: `OpenAI Codex token refresh failed (${response.status}): ${text || response.statusText}`,
|
||||
};
|
||||
}
|
||||
const json = (await response.json()) as TokenResponseJson;
|
||||
const expires = resolveOAuthTokenExpiresAt(json.expires_in);
|
||||
if (!json.access_token || !json.refresh_token || expires === undefined) {
|
||||
return {
|
||||
type: "failed",
|
||||
message: `OpenAI Codex token refresh response missing fields: ${formatMissingTokenResponseFields(json)}`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: "success",
|
||||
access: json.access_token,
|
||||
refresh: json.refresh_token,
|
||||
expires,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
type: "failed",
|
||||
message: formatTokenRequestError(
|
||||
"refresh",
|
||||
error,
|
||||
options.timeoutMs ?? TOKEN_REQUEST_TIMEOUT_MS,
|
||||
options.signal,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -1,79 +1,25 @@
|
||||
// Openai plugin module implements openai chatgpt oauth behavior.
|
||||
import path from "node:path";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
|
||||
import type { ProviderAuthContext } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { ensureGlobalUndiciEnvProxyDispatcher } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { formatCliCommand } from "openclaw/plugin-sdk/setup-tools";
|
||||
import { loginOpenAICodex } from "./openai-chatgpt-oauth-flow.runtime.js";
|
||||
import {
|
||||
runOpenAIOAuthTlsPreflight,
|
||||
type OpenAIOAuthTlsPreflightResult,
|
||||
} from "./openai-chatgpt-oauth-preflight.runtime.js";
|
||||
import type { OAuthCredentials } from "./openai-chatgpt-oauth-types.runtime.js";
|
||||
|
||||
const manualInputPromptMessage = "Paste the authorization code (or full redirect URL):";
|
||||
const openAICodexOAuthOriginator = "openclaw";
|
||||
const localManualFallbackDelayMs = 15_000;
|
||||
const localManualFallbackGraceMs = 1_000;
|
||||
const openAIAuthProbeUrl =
|
||||
"https://auth.openai.com/oauth/authorize?response_type=code&client_id=openclaw-preflight&redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback&scope=openid+profile+email";
|
||||
|
||||
const tlsCertErrorCodes = new Set([
|
||||
"UNABLE_TO_GET_ISSUER_CERT_LOCALLY",
|
||||
"UNABLE_TO_VERIFY_LEAF_SIGNATURE",
|
||||
"CERT_HAS_EXPIRED",
|
||||
"DEPTH_ZERO_SELF_SIGNED_CERT",
|
||||
"SELF_SIGNED_CERT_IN_CHAIN",
|
||||
"ERR_TLS_CERT_ALTNAME_INVALID",
|
||||
]);
|
||||
|
||||
const tlsCertErrorPatterns = [
|
||||
/unable to get local issuer certificate/i,
|
||||
/unable to verify the first certificate/i,
|
||||
/self[- ]signed certificate/i,
|
||||
/certificate has expired/i,
|
||||
];
|
||||
|
||||
type OpenAICodexOAuthFailureCode =
|
||||
| "callback_timeout"
|
||||
| "callback_validation_failed"
|
||||
| "unsupported_region";
|
||||
|
||||
type PreflightFailureKind = "tls-cert" | "network";
|
||||
type OpenAIOAuthTlsPreflightResult =
|
||||
| { ok: true }
|
||||
| {
|
||||
ok: false;
|
||||
kind: PreflightFailureKind;
|
||||
code?: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
function getErrorRecord(error: unknown): Record<string, unknown> | null {
|
||||
return error && typeof error === "object" ? (error as Record<string, unknown>) : null;
|
||||
}
|
||||
|
||||
function extractFailure(error: unknown): {
|
||||
code?: string;
|
||||
message: string;
|
||||
kind: PreflightFailureKind;
|
||||
} {
|
||||
const root = getErrorRecord(error);
|
||||
const rootCause = getErrorRecord(root?.cause);
|
||||
const code = typeof rootCause?.code === "string" ? rootCause.code : undefined;
|
||||
const message =
|
||||
typeof rootCause?.message === "string"
|
||||
? rootCause.message
|
||||
: typeof root?.message === "string"
|
||||
? root.message
|
||||
: String(error);
|
||||
const isTlsCertError =
|
||||
(code ? tlsCertErrorCodes.has(code) : false) ||
|
||||
tlsCertErrorPatterns.some((pattern) => pattern.test(message));
|
||||
return {
|
||||
code,
|
||||
message,
|
||||
kind: isTlsCertError ? "tls-cert" : "network",
|
||||
};
|
||||
}
|
||||
|
||||
function resolveHomebrewPrefixFromExecPath(execPath: string): string | null {
|
||||
const marker = `${path.sep}Cellar${path.sep}`;
|
||||
const idx = execPath.indexOf(marker);
|
||||
@@ -88,38 +34,6 @@ function resolveCertBundlePath(): string | null {
|
||||
return prefix ? path.join(prefix, "etc", "openssl@3", "cert.pem") : null;
|
||||
}
|
||||
|
||||
async function runOpenAIOAuthTlsPreflight(options?: {
|
||||
timeoutMs?: number;
|
||||
fetchImpl?: typeof fetch;
|
||||
}): Promise<OpenAIOAuthTlsPreflightResult> {
|
||||
const timeoutMs = resolveTimerTimeoutMs(options?.timeoutMs, 5000);
|
||||
const fetchImpl = options?.fetchImpl ?? fetch;
|
||||
let response: Response | undefined;
|
||||
try {
|
||||
response = await fetchImpl(openAIAuthProbeUrl, {
|
||||
method: "GET",
|
||||
redirect: "manual",
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
const failure = extractFailure(error);
|
||||
return {
|
||||
ok: false,
|
||||
kind: failure.kind,
|
||||
code: failure.code,
|
||||
message: failure.message,
|
||||
};
|
||||
} finally {
|
||||
if (response?.bodyUsed !== true) {
|
||||
await response?.body?.cancel().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const testing = { runOpenAIOAuthTlsPreflight };
|
||||
export { testing as __testing };
|
||||
|
||||
function formatOpenAIOAuthTlsPreflightFix(
|
||||
result: Exclude<OpenAIOAuthTlsPreflightResult, { ok: true }>,
|
||||
): string {
|
||||
|
||||
@@ -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";
|
||||
|
||||
18
extensions/openai/openai-chatgpt-provider-runtime.factory.ts
Normal file
18
extensions/openai/openai-chatgpt-provider-runtime.factory.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { ensureGlobalUndiciEnvProxyDispatcher } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { refreshOpenAICodexToken as refreshOpenAICodexTokenFromFlow } from "./openai-chatgpt-oauth-flow.runtime.js";
|
||||
|
||||
type OpenAICodexProviderRuntimeDeps = {
|
||||
ensureGlobalUndiciEnvProxyDispatcher: typeof ensureGlobalUndiciEnvProxyDispatcher;
|
||||
refreshOpenAICodexToken: typeof refreshOpenAICodexTokenFromFlow;
|
||||
};
|
||||
|
||||
export function createOpenAICodexProviderRuntime(deps: OpenAICodexProviderRuntimeDeps): {
|
||||
refreshOpenAICodexToken: typeof refreshOpenAICodexTokenFromFlow;
|
||||
} {
|
||||
return {
|
||||
async refreshOpenAICodexToken(...args) {
|
||||
deps.ensureGlobalUndiciEnvProxyDispatcher();
|
||||
return await deps.refreshOpenAICodexToken(...args);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,61 +1,14 @@
|
||||
// Openai provider module implements model/runtime integration.
|
||||
import { ensureGlobalUndiciEnvProxyDispatcher } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { refreshOpenAICodexToken as refreshOpenAICodexTokenFromFlow } from "./openai-chatgpt-oauth-flow.runtime.js";
|
||||
import type { OAuthCredentials } from "./openai-chatgpt-oauth-types.runtime.js";
|
||||
|
||||
type OpenAICodexProviderRuntimeDeps = {
|
||||
ensureGlobalUndiciEnvProxyDispatcher: typeof ensureGlobalUndiciEnvProxyDispatcher;
|
||||
getOAuthApiKey: typeof getOpenAICodexOAuthApiKey;
|
||||
refreshOpenAICodexToken: typeof refreshOpenAICodexTokenFromFlow;
|
||||
};
|
||||
|
||||
export function createOpenAICodexProviderRuntime(deps: OpenAICodexProviderRuntimeDeps): {
|
||||
getOAuthApiKey: typeof getOAuthApiKey;
|
||||
refreshOpenAICodexToken: typeof refreshOpenAICodexToken;
|
||||
} {
|
||||
return {
|
||||
async getOAuthApiKey(...args) {
|
||||
deps.ensureGlobalUndiciEnvProxyDispatcher();
|
||||
return await deps.getOAuthApiKey(...args);
|
||||
},
|
||||
async refreshOpenAICodexToken(...args) {
|
||||
deps.ensureGlobalUndiciEnvProxyDispatcher();
|
||||
return await deps.refreshOpenAICodexToken(...args);
|
||||
},
|
||||
};
|
||||
}
|
||||
import { createOpenAICodexProviderRuntime } from "./openai-chatgpt-provider-runtime.factory.js";
|
||||
|
||||
const runtime = createOpenAICodexProviderRuntime({
|
||||
ensureGlobalUndiciEnvProxyDispatcher,
|
||||
getOAuthApiKey: getOpenAICodexOAuthApiKey,
|
||||
refreshOpenAICodexToken: refreshOpenAICodexTokenFromFlow,
|
||||
});
|
||||
|
||||
export async function getOAuthApiKey(
|
||||
...args: Parameters<typeof getOpenAICodexOAuthApiKey>
|
||||
): Promise<Awaited<ReturnType<typeof getOpenAICodexOAuthApiKey>>> {
|
||||
return await runtime.getOAuthApiKey(...args);
|
||||
}
|
||||
|
||||
export async function refreshOpenAICodexToken(
|
||||
...args: Parameters<typeof refreshOpenAICodexTokenFromFlow>
|
||||
): Promise<Awaited<ReturnType<typeof refreshOpenAICodexTokenFromFlow>>> {
|
||||
return await runtime.refreshOpenAICodexToken(...args);
|
||||
}
|
||||
|
||||
async function getOpenAICodexOAuthApiKey(
|
||||
providerId: string,
|
||||
credentials: Record<string, OAuthCredentials>,
|
||||
): Promise<{ newCredentials: OAuthCredentials; apiKey: string } | null> {
|
||||
if (providerId !== "openai") {
|
||||
throw new Error(`Unknown OAuth provider: ${providerId}`);
|
||||
}
|
||||
let creds = credentials[providerId];
|
||||
if (!creds) {
|
||||
return null;
|
||||
}
|
||||
if (Date.now() >= creds.expires) {
|
||||
creds = await refreshOpenAICodexTokenFromFlow(creds.refresh);
|
||||
}
|
||||
return { newCredentials: creds, apiKey: creds.access };
|
||||
}
|
||||
|
||||
@@ -5,14 +5,11 @@ import {
|
||||
clearLiveCatalogCacheForTests,
|
||||
type LiveModelCatalogFetchGuard,
|
||||
} from "openclaw/plugin-sdk/provider-catalog-live-runtime";
|
||||
import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { OPENAI_API_BASE_URL, OPENAI_CODEX_RESPONSES_BASE_URL } from "./base-url.js";
|
||||
import { OPENAI_CODEX_DEFAULT_MODEL, OPENAI_DEFAULT_MODEL } from "./default-models.js";
|
||||
import {
|
||||
buildOpenAICodexLiveProviderConfig,
|
||||
buildOpenAILiveProviderConfig,
|
||||
buildOpenAIProvider,
|
||||
} from "./openai-provider.js";
|
||||
import { buildOpenAIProvider } from "./openai-provider.js";
|
||||
import manifest from "./openclaw.plugin.json" with { type: "json" };
|
||||
import { resolveModelRoutes } from "./provider-policy-api.js";
|
||||
|
||||
@@ -23,6 +20,87 @@ const mocks = vi.hoisted(() => ({
|
||||
resolveProviderAuthProfileMetadata: vi.fn(),
|
||||
}));
|
||||
|
||||
async function runCatalogWithFetchGuard(params: {
|
||||
fetchGuard: LiveModelCatalogFetchGuard;
|
||||
auth: {
|
||||
mode: "api_key" | "oauth";
|
||||
apiKey: string;
|
||||
discoveryApiKey?: string;
|
||||
profileId?: string;
|
||||
source: string;
|
||||
};
|
||||
accountId?: string;
|
||||
baseUrl?: string;
|
||||
}): Promise<ModelProviderConfig> {
|
||||
if (params.auth.mode === "oauth") {
|
||||
mocks.resolveApiKeyForProvider.mockResolvedValue({
|
||||
...params.auth,
|
||||
source: params.auth.source,
|
||||
});
|
||||
mocks.resolveProviderAuthProfileMetadata.mockReturnValue({
|
||||
profileId: params.auth.profileId,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
}
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => {
|
||||
const guarded = await params.fetchGuard({
|
||||
url: typeof input === "string" ? input : input instanceof URL ? input.href : input.url,
|
||||
init,
|
||||
});
|
||||
await guarded.release();
|
||||
return guarded.response;
|
||||
});
|
||||
try {
|
||||
const result = await buildOpenAIProvider().catalog?.run({
|
||||
resolveProviderAuth: () => params.auth,
|
||||
resolveProviderApiKey: () => ({
|
||||
apiKey: params.auth.apiKey,
|
||||
discoveryApiKey: params.auth.discoveryApiKey,
|
||||
}),
|
||||
config: params.baseUrl
|
||||
? { models: { providers: { openai: { baseUrl: params.baseUrl, models: [] } } } }
|
||||
: { auth: { profiles: {} } },
|
||||
agentDir: "/tmp/openai-agent",
|
||||
workspaceDir: "/tmp/openai-workspace",
|
||||
} as never);
|
||||
if (!result || "provider" in result || !result.providers.openai) {
|
||||
throw new Error("expected OpenAI live provider catalog");
|
||||
}
|
||||
return result.providers.openai;
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
}
|
||||
}
|
||||
|
||||
async function buildOpenAILiveProviderConfig(params: {
|
||||
apiKey: string;
|
||||
baseUrl?: string;
|
||||
fetchGuard: LiveModelCatalogFetchGuard;
|
||||
}): Promise<ModelProviderConfig> {
|
||||
return await runCatalogWithFetchGuard({
|
||||
fetchGuard: params.fetchGuard,
|
||||
auth: { mode: "api_key", apiKey: params.apiKey, source: "profile" },
|
||||
baseUrl: params.baseUrl,
|
||||
});
|
||||
}
|
||||
|
||||
async function buildOpenAICodexLiveProviderConfig(params: {
|
||||
discoveryApiKey: string;
|
||||
accountId?: string;
|
||||
fetchGuard: LiveModelCatalogFetchGuard;
|
||||
}): Promise<ModelProviderConfig> {
|
||||
return await runCatalogWithFetchGuard({
|
||||
fetchGuard: params.fetchGuard,
|
||||
auth: {
|
||||
mode: "oauth",
|
||||
apiKey: params.discoveryApiKey,
|
||||
profileId: "openai:chatgpt",
|
||||
source: "profile",
|
||||
},
|
||||
accountId: params.accountId,
|
||||
});
|
||||
}
|
||||
|
||||
vi.mock("./openai-chatgpt-provider.runtime.js", () => ({
|
||||
refreshOpenAICodexToken: mocks.refreshOpenAICodexToken,
|
||||
}));
|
||||
@@ -1998,6 +2076,33 @@ describe("buildOpenAIProvider", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps one native OpenAI web search tool when the payload is already patched", () => {
|
||||
const provider = buildOpenAIProvider();
|
||||
const wrap = provider.wrapStreamFn;
|
||||
if (!wrap) {
|
||||
throw new Error("expected OpenAI wrapper");
|
||||
}
|
||||
|
||||
const result = runWrappedPayloadCase({
|
||||
wrap,
|
||||
provider: "openai",
|
||||
modelId: "gpt-5.4",
|
||||
model: {
|
||||
api: "openai-responses",
|
||||
provider: "openai",
|
||||
id: "gpt-5.4",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
} as Model<"openai-responses">,
|
||||
payload: {
|
||||
tools: [{ type: "web_search" }, { type: "function", name: "web_search" }],
|
||||
reasoning: { effort: "minimal" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.payload.tools).toEqual([{ type: "web_search" }]);
|
||||
expect(result.payload.reasoning).toEqual({ effort: "low" });
|
||||
});
|
||||
|
||||
it("keeps managed OpenAI web_search when agent policy denies native web search", () => {
|
||||
const provider = buildOpenAIProvider();
|
||||
const wrap = provider.wrapStreamFn;
|
||||
|
||||
@@ -183,7 +183,7 @@ function buildOpenAIManifestModelsForBaseUrl(baseUrl: string): ModelDefinitionCo
|
||||
);
|
||||
}
|
||||
|
||||
export async function buildOpenAILiveProviderConfig(
|
||||
async function buildOpenAILiveProviderConfig(
|
||||
params: BuildOpenAILiveProviderConfigParams,
|
||||
): Promise<ModelProviderConfig> {
|
||||
const baseUrl =
|
||||
@@ -446,7 +446,7 @@ function buildOpenAICodexStaticProviderConfig(): ModelProviderConfig {
|
||||
};
|
||||
}
|
||||
|
||||
export async function buildOpenAICodexLiveProviderConfig(params: {
|
||||
async function buildOpenAICodexLiveProviderConfig(params: {
|
||||
discoveryApiKey: string;
|
||||
accountId?: string;
|
||||
fetchGuard?: LiveModelCatalogFetchGuard;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -70,7 +70,7 @@ export function captureOpenAIRealtimeWsClose(params: {
|
||||
});
|
||||
}
|
||||
|
||||
export type OpenAIRealtimeClientSecretResult = {
|
||||
type OpenAIRealtimeClientSecretResult = {
|
||||
value: string;
|
||||
expiresAt?: number;
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -356,7 +356,7 @@ function aggregateHistory(params: {
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchOpenAIAdminUsage(params: {
|
||||
async function fetchOpenAIAdminUsage(params: {
|
||||
apiKey: string;
|
||||
projectId?: string;
|
||||
timeoutMs: number;
|
||||
|
||||
@@ -1,19 +1,43 @@
|
||||
// Opencode tests cover media understanding provider plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
opencodeMediaUnderstandingProvider,
|
||||
stripOpencodeDisabledResponsesReasoningPayload,
|
||||
} from "./media-understanding-provider.js";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
describeImageWithModelPayloadTransform:
|
||||
vi.fn<
|
||||
(request: unknown, onPayload: (payload: unknown) => unknown) => Promise<{ text: string }>
|
||||
>(),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/media-understanding", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("openclaw/plugin-sdk/media-understanding")>()),
|
||||
describeImageWithModelPayloadTransform: mocks.describeImageWithModelPayloadTransform,
|
||||
}));
|
||||
|
||||
import { opencodeMediaUnderstandingProvider } from "./media-understanding-provider.js";
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.describeImageWithModelPayloadTransform.mockReset();
|
||||
});
|
||||
|
||||
async function applyImagePayloadTransform(payload: Record<string, unknown>): Promise<void> {
|
||||
mocks.describeImageWithModelPayloadTransform.mockImplementationOnce(
|
||||
async (_request, onPayload) => {
|
||||
await onPayload(payload);
|
||||
return { text: "ok" };
|
||||
},
|
||||
);
|
||||
await opencodeMediaUnderstandingProvider.describeImage?.({} as never);
|
||||
}
|
||||
|
||||
describe("opencode media understanding provider", () => {
|
||||
it("strips disabled Responses reasoning payloads", () => {
|
||||
it("strips disabled Responses reasoning payloads", async () => {
|
||||
const payload = {
|
||||
reasoning: { effort: "none" },
|
||||
include: ["reasoning.encrypted_content"],
|
||||
store: false,
|
||||
};
|
||||
|
||||
stripOpencodeDisabledResponsesReasoningPayload(payload);
|
||||
await applyImagePayloadTransform(payload);
|
||||
|
||||
expect(payload).toEqual({
|
||||
include: ["reasoning.encrypted_content"],
|
||||
@@ -21,13 +45,13 @@ describe("opencode media understanding provider", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps supported Responses reasoning payloads", () => {
|
||||
it("keeps supported Responses reasoning payloads", async () => {
|
||||
const payload = {
|
||||
reasoning: { effort: "low" },
|
||||
store: false,
|
||||
};
|
||||
|
||||
stripOpencodeDisabledResponsesReasoningPayload(payload);
|
||||
await applyImagePayloadTransform(payload);
|
||||
|
||||
expect(payload).toEqual({
|
||||
reasoning: { effort: "low" },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -214,7 +214,7 @@ type OpencodeZenModelDefinition = ModelDefinitionConfig & {
|
||||
input: Array<"text" | "image">;
|
||||
};
|
||||
|
||||
export type FetchOpencodeZenLiveModelIdsParams = {
|
||||
type FetchOpencodeZenLiveModelIdsParams = {
|
||||
apiKey?: string;
|
||||
discoveryApiKey?: string;
|
||||
fetchGuard?: LiveModelCatalogFetchGuard;
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -32,15 +32,12 @@ vi.mock("openclaw/plugin-sdk/node-host", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
import { registerOpenCodeSessionCatalog } from "./session-catalog-plugin.js";
|
||||
import {
|
||||
createOpenCodeSessionNodeInvokePolicies,
|
||||
createOpenCodeSessionNodeHostCommands,
|
||||
isOpenCodeSessionCatalogEnabled,
|
||||
OPENCODE_SESSIONS_LIST_COMMAND,
|
||||
OPENCODE_SESSION_READ_COMMAND,
|
||||
OPENCODE_TERMINAL_RESUME_COMMAND,
|
||||
registerOpenCodeSessionCatalog,
|
||||
} from "./session-catalog-plugin.js";
|
||||
} from "./session-catalog-shared.js";
|
||||
import {
|
||||
listLocalOpenCodeSessionPage,
|
||||
readLocalOpenCodeTranscriptPage,
|
||||
@@ -50,6 +47,25 @@ const temporaryDirectories: string[] = [];
|
||||
const originalPath = process.env.PATH;
|
||||
const originalUnrelatedEnv = process.env.CATALOG_UNRELATED_ENV;
|
||||
|
||||
function captureOpenCodeSessionRegistrations(pluginConfig: unknown = {}) {
|
||||
const catalogs: Array<Parameters<OpenClawPluginApi["registerSessionCatalog"]>[0]> = [];
|
||||
const commands: Array<Parameters<OpenClawPluginApi["registerNodeHostCommand"]>[0]> = [];
|
||||
const policies: Array<Parameters<OpenClawPluginApi["registerNodeInvokePolicy"]>[0]> = [];
|
||||
registerOpenCodeSessionCatalog({
|
||||
pluginConfig,
|
||||
runtime: { nodes: { list: vi.fn().mockResolvedValue({ nodes: [] }) } },
|
||||
registerSessionCatalog: (catalog: Parameters<OpenClawPluginApi["registerSessionCatalog"]>[0]) =>
|
||||
catalogs.push(catalog),
|
||||
registerNodeHostCommand: (
|
||||
command: Parameters<OpenClawPluginApi["registerNodeHostCommand"]>[0],
|
||||
) => commands.push(command),
|
||||
registerNodeInvokePolicy: (
|
||||
policy: Parameters<OpenClawPluginApi["registerNodeInvokePolicy"]>[0],
|
||||
) => policies.push(policy),
|
||||
} as unknown as OpenClawPluginApi);
|
||||
return { catalogs, commands, policies };
|
||||
}
|
||||
|
||||
async function installFakeOpenCode(
|
||||
assistantText = "hi",
|
||||
sessionTitle = "Catalog session",
|
||||
@@ -221,7 +237,7 @@ describe("OpenCode session catalog", () => {
|
||||
"auto-detects the CLI and honors the node-local Web UI switch",
|
||||
async () => {
|
||||
const directory = await installFakeOpenCode();
|
||||
const commands = createOpenCodeSessionNodeHostCommands();
|
||||
const { commands } = captureOpenCodeSessionRegistrations();
|
||||
expect(commands.map((command) => command.command)).toEqual([
|
||||
OPENCODE_SESSIONS_LIST_COMMAND,
|
||||
OPENCODE_SESSION_READ_COMMAND,
|
||||
@@ -293,7 +309,8 @@ describe("OpenCode session catalog", () => {
|
||||
"runs only catalog-validated OpenCode sessions through the node PTY",
|
||||
async () => {
|
||||
await installFakeOpenCode();
|
||||
const terminal = createOpenCodeSessionNodeHostCommands().find(
|
||||
const { commands, policies } = captureOpenCodeSessionRegistrations();
|
||||
const terminal = commands.find(
|
||||
(command) => command.command === OPENCODE_TERMINAL_RESUME_COMMAND,
|
||||
);
|
||||
const io = {
|
||||
@@ -325,7 +342,7 @@ describe("OpenCode session catalog", () => {
|
||||
).rejects.toThrow("threadId is invalid");
|
||||
|
||||
const invokeNode = vi.fn(() => ({ ok: false as const, error: "unexpected" }));
|
||||
const policy = createOpenCodeSessionNodeInvokePolicies()[0]!;
|
||||
const policy = policies[0]!;
|
||||
expect(
|
||||
policy.handle({ command: OPENCODE_TERMINAL_RESUME_COMMAND, invokeNode } as never),
|
||||
).toEqual({ ok: true });
|
||||
@@ -407,14 +424,10 @@ describe("OpenCode session catalog", () => {
|
||||
});
|
||||
|
||||
it("does not register the catalog when explicitly disabled", () => {
|
||||
const registerSessionCatalog = vi.fn();
|
||||
const api = {
|
||||
pluginConfig: { sessionCatalog: { enabled: false } },
|
||||
registerSessionCatalog,
|
||||
} as unknown as OpenClawPluginApi;
|
||||
registerOpenCodeSessionCatalog(api);
|
||||
expect(isOpenCodeSessionCatalogEnabled(api.pluginConfig)).toBe(false);
|
||||
expect(registerSessionCatalog).not.toHaveBeenCalled();
|
||||
const registrations = captureOpenCodeSessionRegistrations({
|
||||
sessionCatalog: { enabled: false },
|
||||
});
|
||||
expect(registrations).toEqual({ catalogs: [], commands: [], policies: [] });
|
||||
});
|
||||
|
||||
it("bridges paired-node list and read requests without undefined transport fields", async () => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -136,7 +136,7 @@ function extractImagesFromPart(
|
||||
throwMalformedOpenRouterImageResponse(malformedResponseError);
|
||||
}
|
||||
|
||||
export function extractOpenRouterImagesFromResponse(
|
||||
function extractOpenRouterImagesFromResponse(
|
||||
body: unknown,
|
||||
options: { malformedResponseError?: string } = {},
|
||||
): GeneratedImageAsset[] {
|
||||
|
||||
@@ -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(() => ({
|
||||
|
||||
@@ -100,7 +100,7 @@ type OpenRouterSttResponse = {
|
||||
text?: string;
|
||||
};
|
||||
|
||||
export async function transcribeOpenRouterAudio(
|
||||
async function transcribeOpenRouterAudio(
|
||||
params: AudioTranscriptionRequest,
|
||||
): Promise<AudioTranscriptionResult> {
|
||||
const model = params.model?.trim() || DEFAULT_OPENROUTER_AUDIO_TRANSCRIPTION_MODEL;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
// Openrouter OAuth tests cover PKCE exchange and auth profile output.
|
||||
import type { ProviderAuthContext } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
buildOpenRouterOAuthAuthorizeUrl,
|
||||
buildOpenRouterOAuthRedirectUri,
|
||||
exchangeOpenRouterOAuthCode,
|
||||
loginOpenRouterOAuth,
|
||||
OPENROUTER_OAUTH_CALLBACK_PATH,
|
||||
OPENROUTER_OAUTH_CALLBACK_PORT,
|
||||
OPENROUTER_OAUTH_CHOICE_ID,
|
||||
OPENROUTER_OAUTH_CODE_CHALLENGE_METHOD,
|
||||
OPENROUTER_OAUTH_REDIRECT_URI,
|
||||
OPENROUTER_OAUTH_TOKEN_URL,
|
||||
parseOpenRouterOAuthCallbackInput,
|
||||
waitForOpenRouterOAuthCallback,
|
||||
} from "./oauth.js";
|
||||
import { createOpenRouterOAuthAuthMethod } from "./oauth.js";
|
||||
|
||||
const OPENROUTER_OAUTH_REDIRECT_URI = "http://localhost:3000/openrouter-oauth/callback";
|
||||
type OpenRouterOAuthLoginOptions = NonNullable<
|
||||
Parameters<typeof createOpenRouterOAuthAuthMethod>[0]
|
||||
>;
|
||||
|
||||
function loginOpenRouterOAuth(ctx: ProviderAuthContext, options: OpenRouterOAuthLoginOptions = {}) {
|
||||
return createOpenRouterOAuthAuthMethod(options).run(ctx);
|
||||
}
|
||||
|
||||
function jsonResponse(value: unknown, init?: ResponseInit): Response {
|
||||
return new Response(JSON.stringify(value), {
|
||||
@@ -101,11 +97,13 @@ function requestJsonBody(init: RequestInit | undefined): Record<string, unknown>
|
||||
|
||||
function createOpenRouterOAuthContext(params: {
|
||||
isRemote: boolean;
|
||||
onProgress?: (message: string) => void;
|
||||
redirectInput?: string;
|
||||
openUrl?: (url: string) => Promise<void>;
|
||||
signal?: AbortSignal;
|
||||
}) {
|
||||
const progress = {
|
||||
update: vi.fn(),
|
||||
update: vi.fn((message: string) => params.onProgress?.(message)),
|
||||
stop: vi.fn(),
|
||||
};
|
||||
const note = vi.fn<(message: string, title?: string) => Promise<void>>(async () => undefined);
|
||||
@@ -120,6 +118,7 @@ function createOpenRouterOAuthContext(params: {
|
||||
config: {},
|
||||
isRemote: params.isRemote,
|
||||
openUrl,
|
||||
signal: params.signal,
|
||||
prompter: {
|
||||
note,
|
||||
text,
|
||||
@@ -145,125 +144,158 @@ async function requestLocalOpenRouterOAuthCallback(
|
||||
const ready = new Promise<void>((resolve) => {
|
||||
markReady = resolve;
|
||||
});
|
||||
const callback = waitForOpenRouterOAuthCallback({
|
||||
expectedState: "state-1",
|
||||
timeoutMs: 1000,
|
||||
onProgress: markReady,
|
||||
const controller = new AbortController();
|
||||
const redirectInput = `${OPENROUTER_OAUTH_REDIRECT_URI}?${query}`;
|
||||
const { ctx } = createOpenRouterOAuthContext({
|
||||
isRemote: false,
|
||||
onProgress: (message) => {
|
||||
if (message.startsWith("Waiting for OpenRouter OAuth callback")) {
|
||||
markReady();
|
||||
}
|
||||
},
|
||||
redirectInput,
|
||||
signal: controller.signal,
|
||||
});
|
||||
const callback = loginOpenRouterOAuth(ctx, {
|
||||
createPkce: () => ({ verifier: "verifier-1", challenge: "challenge-1" }),
|
||||
createState: () => "state-1",
|
||||
fetchImpl: vi.fn(async () => jsonResponse({ key: "sk-or-v1-test" })),
|
||||
});
|
||||
callback.catch(() => undefined);
|
||||
await ready;
|
||||
await Promise.race([
|
||||
ready,
|
||||
callback.then(
|
||||
() => {
|
||||
throw new Error("OpenRouter OAuth completed before callback server started");
|
||||
},
|
||||
(error: unknown) => {
|
||||
throw error;
|
||||
},
|
||||
),
|
||||
]);
|
||||
|
||||
const response = await fetch(`${OPENROUTER_OAUTH_REDIRECT_URI}?${query}`, {
|
||||
headers: { Connection: "close" },
|
||||
try {
|
||||
const response = await fetch(redirectInput, { headers: { Connection: "close" } });
|
||||
return { callback, response, body: await response.text() };
|
||||
} catch (error) {
|
||||
controller.abort();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function runRemoteOpenRouterOAuthRedirect(redirectInput: string) {
|
||||
const { ctx } = createOpenRouterOAuthContext({ isRemote: true, redirectInput });
|
||||
return loginOpenRouterOAuth(ctx, {
|
||||
createPkce: () => ({ verifier: "verifier-1", challenge: "challenge-1" }),
|
||||
createState: () => "state-1",
|
||||
fetchImpl: vi.fn(async () => jsonResponse({ key: "sk-or-v1-test" })),
|
||||
});
|
||||
return { callback, response, body: await response.text() };
|
||||
}
|
||||
|
||||
describe("OpenRouter OAuth", () => {
|
||||
it("builds the documented PKCE authorize URL", () => {
|
||||
const url = new URL(
|
||||
buildOpenRouterOAuthAuthorizeUrl({ codeChallenge: "challenge-1", state: "state-1" }),
|
||||
);
|
||||
it("builds the documented PKCE authorize URL", async () => {
|
||||
const { ctx, openUrl } = createOpenRouterOAuthContext({ isRemote: true });
|
||||
await loginOpenRouterOAuth(ctx, {
|
||||
createPkce: () => ({ verifier: "verifier-1", challenge: "challenge-1" }),
|
||||
createState: () => "state-1",
|
||||
fetchImpl: vi.fn(async () => jsonResponse({ key: "sk-or-v1-test" })),
|
||||
});
|
||||
const openedUrl = (openUrl as ReturnType<typeof vi.fn>).mock.calls[0]?.[0];
|
||||
if (typeof openedUrl !== "string") {
|
||||
throw new Error("expected OpenRouter OAuth authorize URL");
|
||||
}
|
||||
const url = new URL(openedUrl);
|
||||
const callbackUrl = new URL(url.searchParams.get("callback_url") ?? "");
|
||||
|
||||
expect(url.origin + url.pathname).toBe("https://openrouter.ai/auth");
|
||||
expect(callbackUrl.origin + callbackUrl.pathname).toBe(OPENROUTER_OAUTH_REDIRECT_URI);
|
||||
expect(callbackUrl.searchParams.get("state")).toBe("state-1");
|
||||
expect(url.searchParams.get("code_challenge")).toBe("challenge-1");
|
||||
expect(url.searchParams.get("code_challenge_method")).toBe(
|
||||
OPENROUTER_OAUTH_CODE_CHALLENGE_METHOD,
|
||||
);
|
||||
expect(OPENROUTER_OAUTH_REDIRECT_URI).toContain(`:${OPENROUTER_OAUTH_CALLBACK_PORT}/`);
|
||||
expect(OPENROUTER_OAUTH_REDIRECT_URI).toContain(OPENROUTER_OAUTH_CALLBACK_PATH);
|
||||
expect(url.searchParams.get("code_challenge_method")).toBe("S256");
|
||||
});
|
||||
|
||||
it("parses state-bound OpenRouter redirect URLs and query strings", () => {
|
||||
expect(
|
||||
parseOpenRouterOAuthCallbackInput(
|
||||
it("parses state-bound OpenRouter redirect URLs and query strings", async () => {
|
||||
await expect(
|
||||
runRemoteOpenRouterOAuthRedirect(
|
||||
`${OPENROUTER_OAUTH_REDIRECT_URI}?state=state-1&code=AUTHCODE`,
|
||||
"state-1",
|
||||
),
|
||||
).toEqual({ code: "AUTHCODE", state: "state-1" });
|
||||
expect(parseOpenRouterOAuthCallbackInput("state=state-1&code=AUTHCODE", "state-1")).toEqual({
|
||||
code: "AUTHCODE",
|
||||
state: "state-1",
|
||||
});
|
||||
expect(() =>
|
||||
parseOpenRouterOAuthCallbackInput(
|
||||
).resolves.toMatchObject({ defaultModel: "openrouter/auto" });
|
||||
await expect(
|
||||
runRemoteOpenRouterOAuthRedirect("state=state-1&code=AUTHCODE"),
|
||||
).resolves.toMatchObject({ defaultModel: "openrouter/auto" });
|
||||
await expect(
|
||||
runRemoteOpenRouterOAuthRedirect(
|
||||
`${OPENROUTER_OAUTH_REDIRECT_URI}?state=state-1&error=access_denied&error_description=Denied`,
|
||||
"state-1",
|
||||
),
|
||||
).toThrow("OpenRouter OAuth error: access_denied: Denied");
|
||||
expect(() =>
|
||||
parseOpenRouterOAuthCallbackInput(
|
||||
).rejects.toThrow("OpenRouter OAuth error: access_denied: Denied");
|
||||
await expect(
|
||||
runRemoteOpenRouterOAuthRedirect(
|
||||
"state=state-1&error=access_denied&error_description=Denied",
|
||||
"state-1",
|
||||
),
|
||||
).toThrow("OpenRouter OAuth error: access_denied: Denied");
|
||||
expect(() =>
|
||||
parseOpenRouterOAuthCallbackInput(
|
||||
).rejects.toThrow("OpenRouter OAuth error: access_denied: Denied");
|
||||
await expect(
|
||||
runRemoteOpenRouterOAuthRedirect(
|
||||
`${OPENROUTER_OAUTH_REDIRECT_URI}?error=access_denied&error_description=Denied`,
|
||||
"state-1",
|
||||
),
|
||||
).toThrow("Missing OpenRouter OAuth state");
|
||||
expect(() =>
|
||||
parseOpenRouterOAuthCallbackInput(
|
||||
).rejects.toThrow("Missing OpenRouter OAuth state");
|
||||
await expect(
|
||||
runRemoteOpenRouterOAuthRedirect(
|
||||
`${OPENROUTER_OAUTH_REDIRECT_URI}?state=wrong&error=access_denied&error_description=Denied`,
|
||||
"state-1",
|
||||
),
|
||||
).toThrow("OpenRouter OAuth state mismatch");
|
||||
expect(buildOpenRouterOAuthRedirectUri({ state: "state-1" })).toBe(
|
||||
`${OPENROUTER_OAUTH_REDIRECT_URI}?state=state-1`,
|
||||
);
|
||||
expect(() =>
|
||||
parseOpenRouterOAuthCallbackInput(
|
||||
`${OPENROUTER_OAUTH_REDIRECT_URI}?code=AUTHCODE`,
|
||||
"state-1",
|
||||
),
|
||||
).toThrow("Missing OpenRouter OAuth state");
|
||||
expect(() =>
|
||||
parseOpenRouterOAuthCallbackInput(
|
||||
).rejects.toThrow("OpenRouter OAuth state mismatch");
|
||||
await expect(
|
||||
runRemoteOpenRouterOAuthRedirect(`${OPENROUTER_OAUTH_REDIRECT_URI}?code=AUTHCODE`),
|
||||
).rejects.toThrow("Missing OpenRouter OAuth state");
|
||||
await expect(
|
||||
runRemoteOpenRouterOAuthRedirect(
|
||||
`${OPENROUTER_OAUTH_REDIRECT_URI}?state=wrong&code=AUTHCODE`,
|
||||
"state-1",
|
||||
),
|
||||
).toThrow("OpenRouter OAuth state mismatch");
|
||||
expect(() => parseOpenRouterOAuthCallbackInput("AUTHCODE", "state-1")).toThrow(
|
||||
).rejects.toThrow("OpenRouter OAuth state mismatch");
|
||||
await expect(runRemoteOpenRouterOAuthRedirect("AUTHCODE")).rejects.toThrow(
|
||||
"Paste the full OpenRouter redirect URL",
|
||||
);
|
||||
});
|
||||
|
||||
it("exchanges an authorization code for the issued OpenRouter API key", async () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>(async (url, init) => {
|
||||
expect(requestUrl(url)).toBe(OPENROUTER_OAUTH_TOKEN_URL);
|
||||
expect(requestUrl(url)).toBe("https://openrouter.ai/api/v1/auth/keys");
|
||||
expect(init?.method).toBe("POST");
|
||||
expect(new Headers(init?.headers).get("content-type")).toBe("application/json");
|
||||
expect(requestJsonBody(init)).toEqual({
|
||||
code: "AUTHCODE",
|
||||
code_verifier: "verifier-1",
|
||||
code_challenge_method: OPENROUTER_OAUTH_CODE_CHALLENGE_METHOD,
|
||||
code_challenge_method: "S256",
|
||||
});
|
||||
return jsonResponse({ key: "sk-or-v1-test", user_id: "user-1" });
|
||||
});
|
||||
const { ctx } = createOpenRouterOAuthContext({ isRemote: true });
|
||||
|
||||
await expect(
|
||||
exchangeOpenRouterOAuthCode({
|
||||
code: "AUTHCODE",
|
||||
codeVerifier: "verifier-1",
|
||||
loginOpenRouterOAuth(ctx, {
|
||||
createPkce: () => ({ verifier: "verifier-1", challenge: "challenge-1" }),
|
||||
createState: () => "state-1",
|
||||
fetchImpl,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
key: "sk-or-v1-test",
|
||||
userId: "user-1",
|
||||
).resolves.toMatchObject({
|
||||
profiles: [
|
||||
{
|
||||
credential: {
|
||||
key: "sk-or-v1-test",
|
||||
metadata: { userId: "user-1" },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("bounds successful OpenRouter OAuth responses", async () => {
|
||||
const oversized = oversizedJsonResponse();
|
||||
const { ctx } = createOpenRouterOAuthContext({ isRemote: true });
|
||||
|
||||
await expect(
|
||||
exchangeOpenRouterOAuthCode({
|
||||
code: "AUTHCODE",
|
||||
codeVerifier: "verifier-1",
|
||||
loginOpenRouterOAuth(ctx, {
|
||||
createPkce: () => ({ verifier: "verifier-1", challenge: "challenge-1" }),
|
||||
createState: () => "state-1",
|
||||
fetchImpl: vi.fn(async () => oversized.response),
|
||||
}),
|
||||
).rejects.toThrow("OpenRouter OAuth key exchange: JSON response exceeds 16777216 bytes");
|
||||
@@ -279,18 +311,26 @@ describe("OpenRouter OAuth", () => {
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({ error: { message: "Invalid code", code: 400 } }, { status: 400 }),
|
||||
);
|
||||
const first = createOpenRouterOAuthContext({
|
||||
isRemote: true,
|
||||
redirectInput: `${OPENROUTER_OAUTH_REDIRECT_URI}?state=state-1&code=bad-code`,
|
||||
});
|
||||
const second = createOpenRouterOAuthContext({
|
||||
isRemote: true,
|
||||
redirectInput: `${OPENROUTER_OAUTH_REDIRECT_URI}?state=state-1&code=bad-code`,
|
||||
});
|
||||
|
||||
await expect(
|
||||
exchangeOpenRouterOAuthCode({
|
||||
code: "bad-code",
|
||||
codeVerifier: "bad-verifier",
|
||||
loginOpenRouterOAuth(first.ctx, {
|
||||
createPkce: () => ({ verifier: "bad-verifier", challenge: "challenge-1" }),
|
||||
createState: () => "state-1",
|
||||
fetchImpl,
|
||||
}),
|
||||
).rejects.toThrow("OpenRouter OAuth key exchange failed (403): Invalid code or code_verifier");
|
||||
await expect(
|
||||
exchangeOpenRouterOAuthCode({
|
||||
code: "bad-code",
|
||||
codeVerifier: "bad-verifier",
|
||||
loginOpenRouterOAuth(second.ctx, {
|
||||
createPkce: () => ({ verifier: "bad-verifier", challenge: "challenge-1" }),
|
||||
createState: () => "state-1",
|
||||
fetchImpl,
|
||||
}),
|
||||
).rejects.toThrow("OpenRouter OAuth key exchange failed (400): Invalid code");
|
||||
@@ -302,12 +342,16 @@ describe("OpenRouter OAuth", () => {
|
||||
502,
|
||||
);
|
||||
const fetchImpl = vi.fn<typeof fetch>(async () => errorResponse.response);
|
||||
const { ctx } = createOpenRouterOAuthContext({
|
||||
isRemote: true,
|
||||
redirectInput: `${OPENROUTER_OAUTH_REDIRECT_URI}?state=state-1&code=bad-code`,
|
||||
});
|
||||
|
||||
let error: unknown;
|
||||
try {
|
||||
await exchangeOpenRouterOAuthCode({
|
||||
code: "bad-code",
|
||||
codeVerifier: "bad-verifier",
|
||||
await loginOpenRouterOAuth(ctx, {
|
||||
createPkce: () => ({ verifier: "bad-verifier", challenge: "challenge-1" }),
|
||||
createState: () => "state-1",
|
||||
fetchImpl,
|
||||
});
|
||||
} catch (caught) {
|
||||
@@ -369,7 +413,7 @@ describe("OpenRouter OAuth", () => {
|
||||
|
||||
it("uses the local callback path before opening the browser locally", async () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>(async () => jsonResponse({ key: "sk-or-v1-test" }));
|
||||
const waitForCallback = vi.fn<typeof waitForOpenRouterOAuthCallback>(async () => ({
|
||||
const waitForCallback = vi.fn(async (_params: { expectedState: string }) => ({
|
||||
code: "AUTHCODE",
|
||||
state: "state-1",
|
||||
}));
|
||||
@@ -417,6 +461,6 @@ describe("OpenRouter OAuth", () => {
|
||||
});
|
||||
|
||||
it("exposes stable auth choice metadata", () => {
|
||||
expect(OPENROUTER_OAUTH_CHOICE_ID).toBe("openrouter-oauth");
|
||||
expect(createOpenRouterOAuthAuthMethod().wizard?.choiceId).toBe("openrouter-oauth");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,14 +17,14 @@ import { applyOpenrouterConfig, OPENROUTER_DEFAULT_MODEL_REF } from "./onboard.j
|
||||
|
||||
const PROVIDER_ID = "openrouter";
|
||||
const OPENROUTER_OAUTH_METHOD_ID = "oauth";
|
||||
export const OPENROUTER_OAUTH_CHOICE_ID = "openrouter-oauth";
|
||||
const OPENROUTER_OAUTH_CHOICE_ID = "openrouter-oauth";
|
||||
const OPENROUTER_OAUTH_AUTHORIZE_URL = "https://openrouter.ai/auth";
|
||||
export const OPENROUTER_OAUTH_TOKEN_URL = "https://openrouter.ai/api/v1/auth/keys";
|
||||
export const OPENROUTER_OAUTH_CALLBACK_HOST = "localhost";
|
||||
export const OPENROUTER_OAUTH_CALLBACK_PORT = 3000;
|
||||
export const OPENROUTER_OAUTH_CALLBACK_PATH = "/openrouter-oauth/callback";
|
||||
export const OPENROUTER_OAUTH_REDIRECT_URI = `http://${OPENROUTER_OAUTH_CALLBACK_HOST}:${OPENROUTER_OAUTH_CALLBACK_PORT}${OPENROUTER_OAUTH_CALLBACK_PATH}`;
|
||||
export const OPENROUTER_OAUTH_CODE_CHALLENGE_METHOD = "S256";
|
||||
const OPENROUTER_OAUTH_TOKEN_URL = "https://openrouter.ai/api/v1/auth/keys";
|
||||
const OPENROUTER_OAUTH_CALLBACK_HOST = "localhost";
|
||||
const OPENROUTER_OAUTH_CALLBACK_PORT = 3000;
|
||||
const OPENROUTER_OAUTH_CALLBACK_PATH = "/openrouter-oauth/callback";
|
||||
const OPENROUTER_OAUTH_REDIRECT_URI = `http://${OPENROUTER_OAUTH_CALLBACK_HOST}:${OPENROUTER_OAUTH_CALLBACK_PORT}${OPENROUTER_OAUTH_CALLBACK_PATH}`;
|
||||
const OPENROUTER_OAUTH_CODE_CHALLENGE_METHOD = "S256";
|
||||
|
||||
const OPENROUTER_OAUTH_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
const OPENROUTER_OAUTH_FETCH_TIMEOUT_MS = 30 * 1000;
|
||||
@@ -106,13 +106,13 @@ function parseOpenRouterKeyResponse(value: unknown): OpenRouterOAuthKeyResult {
|
||||
};
|
||||
}
|
||||
|
||||
export function buildOpenRouterOAuthRedirectUri(params: { state: string }): string {
|
||||
function buildOpenRouterOAuthRedirectUri(params: { state: string }): string {
|
||||
const url = new URL(OPENROUTER_OAUTH_REDIRECT_URI);
|
||||
url.searchParams.set("state", params.state);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
export function buildOpenRouterOAuthAuthorizeUrl(params: {
|
||||
function buildOpenRouterOAuthAuthorizeUrl(params: {
|
||||
codeChallenge: string;
|
||||
state: string;
|
||||
}): string {
|
||||
@@ -133,7 +133,7 @@ function requireOpenRouterOAuthState(state: string | undefined, expectedState: s
|
||||
return state;
|
||||
}
|
||||
|
||||
export function parseOpenRouterOAuthCallbackInput(
|
||||
function parseOpenRouterOAuthCallbackInput(
|
||||
input: string,
|
||||
expectedState: string,
|
||||
): OpenRouterOAuthCallbackResult {
|
||||
@@ -174,7 +174,7 @@ export function parseOpenRouterOAuthCallbackInput(
|
||||
}
|
||||
}
|
||||
|
||||
export async function exchangeOpenRouterOAuthCode(params: {
|
||||
async function exchangeOpenRouterOAuthCode(params: {
|
||||
code: string;
|
||||
codeVerifier: string;
|
||||
fetchImpl?: typeof fetch;
|
||||
@@ -206,7 +206,7 @@ export async function exchangeOpenRouterOAuthCode(params: {
|
||||
return parseOpenRouterKeyResponse(body);
|
||||
}
|
||||
|
||||
export async function waitForOpenRouterOAuthCallback(params: {
|
||||
async function waitForOpenRouterOAuthCallback(params: {
|
||||
expectedState: string;
|
||||
timeoutMs?: number;
|
||||
onProgress?: (message: string) => void;
|
||||
@@ -396,7 +396,7 @@ async function resolveOpenRouterOAuthCode(
|
||||
return (await callbackPromise).code;
|
||||
}
|
||||
|
||||
export async function loginOpenRouterOAuth(
|
||||
async function loginOpenRouterOAuth(
|
||||
ctx: ProviderAuthContext,
|
||||
options: OpenRouterOAuthLoginOptions = {},
|
||||
): Promise<ProviderAuthResult> {
|
||||
@@ -447,7 +447,9 @@ export async function loginOpenRouterOAuth(
|
||||
}
|
||||
}
|
||||
|
||||
export function createOpenRouterOAuthAuthMethod(): ProviderAuthMethod {
|
||||
export function createOpenRouterOAuthAuthMethod(
|
||||
options: OpenRouterOAuthLoginOptions = {},
|
||||
): ProviderAuthMethod {
|
||||
return {
|
||||
id: OPENROUTER_OAUTH_METHOD_ID,
|
||||
label: "OpenRouter OAuth",
|
||||
@@ -464,6 +466,6 @@ export function createOpenRouterOAuthAuthMethod(): ProviderAuthMethod {
|
||||
onboardingScopes: ["text-inference", "music-generation"],
|
||||
onboardingFeatured: true,
|
||||
},
|
||||
run: async (ctx) => await loginOpenRouterOAuth(ctx),
|
||||
run: async (ctx) => await loginOpenRouterOAuth(ctx, options),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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 \
|
||||
|
||||
Reference in New Issue
Block a user