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

* fix(ci): clean provider dead exports

* test(extensions): satisfy provider contract types

* refactor(openai): tighten provider runtime factory
This commit is contained in:
Peter Steinberger
2026-07-15 22:33:54 -07:00
committed by GitHub
parent 527711d27c
commit e7cba0e4d5
58 changed files with 1041 additions and 985 deletions
@@ -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;
+34 -1
View File
@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest";
import { PUBLIC_GITHUB_COPILOT_DOMAIN, resolveGithubCopilotDomain } from "./domain.js";
import {
PUBLIC_GITHUB_COPILOT_DOMAIN,
resolveGithubCopilotDomain,
withGithubCopilotDomainConfig,
} from "./domain.js";
describe("github-copilot domain resolution", () => {
const withDomain = (githubDomain: string) =>
@@ -36,3 +40,32 @@ describe("github-copilot domain resolution", () => {
);
});
});
describe("withGithubCopilotDomainConfig", () => {
const tenantConfig = {
models: {
providers: { "github-copilot": { params: { githubDomain: "acme.ghe.com" } } },
},
} as never;
it("persists the tenant domain when login minted a tenant token", () => {
const next = withGithubCopilotDomainConfig({} as never, "acme.ghe.com");
expect(
(next as { models?: { providers?: Record<string, { params?: Record<string, unknown> }> } })
.models?.providers?.["github-copilot"]?.params?.githubDomain,
).toBe("acme.ghe.com");
});
it("clears a stale tenant domain after public login", () => {
const next = withGithubCopilotDomainConfig(tenantConfig, "github.com");
const params = (
next as { models?: { providers?: Record<string, { params?: Record<string, unknown> }> } }
).models?.providers?.["github-copilot"]?.params;
expect(params && "githubDomain" in params).toBe(false);
});
it("leaves config untouched for public login without persisted domain", () => {
const cfg = {} as never;
expect(withGithubCopilotDomainConfig(cfg, "github.com")).toBe(cfg);
});
});
+34
View File
@@ -38,3 +38,37 @@ export function resolveGithubCopilotDomain(params?: {
}
return normalizeGithubCopilotDomain(readConfiguredGithubCopilotDomain(params?.config));
}
// Shortcut login must persist its token's tenant. A missing domain would route
// the tenant token back to github.com after the environment override is removed.
export function withGithubCopilotDomainConfig(cfg: OpenClawConfig, domain: string): OpenClawConfig {
const models: NonNullable<OpenClawConfig["models"]> = cfg.models ?? {};
const providers: NonNullable<typeof models.providers> = models.providers ?? {};
const provider = providers["github-copilot"];
const params = provider?.params;
const isDefault = domain === PUBLIC_GITHUB_COPILOT_DOMAIN;
if (isDefault && !(params && "githubDomain" in params)) {
return cfg;
}
const nextParams: Record<string, unknown> = { ...params };
if (isDefault) {
delete nextParams.githubDomain;
} else {
nextParams.githubDomain = domain;
}
const nextProviders = { ...providers };
if (provider) {
nextProviders["github-copilot"] = { ...provider, params: nextParams };
} else {
// Source config accepts partial provider inputs; catalog materialization
// supplies baseUrl/models before runtime consumption.
Object.assign(nextProviders, { "github-copilot": { params: nextParams } });
}
return {
...cfg,
models: {
...models,
providers: nextProviders,
},
};
}
+17 -14
View File
@@ -17,16 +17,15 @@ import type {
UnifiedModelCatalogEntry,
} from "openclaw/plugin-sdk/plugin-entry";
import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api";
import type { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
import {
runGitHubCopilotDeviceFlow,
setGitHubCopilotDeviceFlowFetchGuardForTesting,
} from "./login.js";
import { runGitHubCopilotDeviceFlow } from "./login.js";
const mocks = vi.hoisted(() => ({
githubCopilotLoginCommand: vi.fn(),
fetchWithSsrFGuard: vi.fn(async (params: { url: string; init?: RequestInit }) => ({
fetchWithSsrFGuard: vi.fn<typeof fetchWithSsrFGuard>(async (params) => ({
response: await fetch(params.url, params.init),
finalUrl: params.url,
release: vi.fn(async () => {}),
})),
resolveCopilotApiToken: vi.fn(),
@@ -85,7 +84,11 @@ type GithubCopilotTestModelCatalogProvider = {
afterEach(async () => {
vi.clearAllMocks();
vi.unstubAllGlobals();
setGitHubCopilotDeviceFlowFetchGuardForTesting(null);
mocks.fetchWithSsrFGuard.mockImplementation(async (params) => ({
response: await fetch(params.url, params.init),
finalUrl: params.url,
release: vi.fn(async () => {}),
}));
clearRuntimeAuthProfileStoreSnapshots();
await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
});
@@ -730,7 +733,7 @@ describe("github-copilot plugin", () => {
throw new Error(`unexpected fetch in github-copilot refresh test: ${target}`);
});
vi.stubGlobal("fetch", fetchMock);
setGitHubCopilotDeviceFlowFetchGuardForTesting(async (params) => ({
mocks.fetchWithSsrFGuard.mockImplementation(async (params) => ({
response: await fetchMock(params.url, params.init),
finalUrl: params.url,
release: async () => {},
@@ -838,7 +841,7 @@ describe("github-copilot plugin", () => {
writeExistingCopilotTokenProfile(agentDir);
const fetchMock = buildDeviceFlowFetchMock("github.com", "public-fresh-token");
vi.stubGlobal("fetch", fetchMock);
setGitHubCopilotDeviceFlowFetchGuardForTesting(async (params) => ({
mocks.fetchWithSsrFGuard.mockImplementation(async (params) => ({
response: await fetchMock(params.url, params.init),
finalUrl: params.url,
release: async () => {},
@@ -898,7 +901,7 @@ describe("github-copilot plugin", () => {
writeExistingCopilotTokenProfile(agentDir);
const fetchMock = buildDeviceFlowFetchMock("acme.ghe.com", "tenant-fresh-token");
vi.stubGlobal("fetch", fetchMock);
setGitHubCopilotDeviceFlowFetchGuardForTesting(async (params) => ({
mocks.fetchWithSsrFGuard.mockImplementation(async (params) => ({
response: await fetchMock(params.url, params.init),
finalUrl: params.url,
release: async () => {},
@@ -956,7 +959,7 @@ describe("github-copilot plugin", () => {
// the domain change is detected and the public token is not reused.
const fetchMock = buildDeviceFlowFetchMock("acme.ghe.com", "tenant-fresh-token");
vi.stubGlobal("fetch", fetchMock);
setGitHubCopilotDeviceFlowFetchGuardForTesting(async (params) => ({
mocks.fetchWithSsrFGuard.mockImplementation(async (params) => ({
response: await fetchMock(params.url, params.init),
finalUrl: params.url,
release: async () => {},
@@ -1062,7 +1065,7 @@ describe("github-copilot plugin", () => {
// prompt value instead, the fetch mock would throw on an unexpected host.
const fetchMock = buildDeviceFlowFetchMock("env-tenant.ghe.com", "env-tenant-token");
vi.stubGlobal("fetch", fetchMock);
setGitHubCopilotDeviceFlowFetchGuardForTesting(async (params) => ({
mocks.fetchWithSsrFGuard.mockImplementation(async (params) => ({
response: await fetchMock(params.url, params.init),
finalUrl: params.url,
release: async () => {},
@@ -1112,7 +1115,7 @@ describe("github-copilot plugin", () => {
it("rejects unsafe GitHub device code lifetimes before polling", async () => {
const release = vi.fn(async () => {});
setGitHubCopilotDeviceFlowFetchGuardForTesting(async () => ({
mocks.fetchWithSsrFGuard.mockImplementation(async () => ({
response: new Response(
'{"device_code":"device-code-stub","user_code":"ABCD-1234","verification_uri":"https://github.com/login/device","expires_in":1e309,"interval":0}',
{ status: 200, headers: { "Content-Type": "application/json" } },
@@ -1132,7 +1135,7 @@ describe("github-copilot plugin", () => {
it("rejects GitHub device code expiries outside the Date timestamp range before polling", async () => {
const release = vi.fn(async () => {});
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(MAX_DATE_TIMESTAMP_MS);
setGitHubCopilotDeviceFlowFetchGuardForTesting(async () => ({
mocks.fetchWithSsrFGuard.mockImplementation(async () => ({
response: new Response(
'{"device_code":"device-code-stub","user_code":"ABCD-1234","verification_uri":"https://github.com/login/device","expires_in":1,"interval":0}',
{ status: 200, headers: { "Content-Type": "application/json" } },
@@ -1159,7 +1162,7 @@ describe("github-copilot plugin", () => {
const release = vi.fn(async () => {});
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
let accessTokenPolls = 0;
setGitHubCopilotDeviceFlowFetchGuardForTesting(async (params) => {
mocks.fetchWithSsrFGuard.mockImplementation(async (params) => {
if (params.url === "https://github.com/login/device/code") {
return {
response: new Response(
+23 -44
View File
@@ -1,10 +1,18 @@
// Github Copilot tests cover device-flow login behavior.
import { afterEach, describe, expect, it, vi } from "vitest";
import {
runGitHubCopilotDeviceFlow,
setGitHubCopilotDeviceFlowFetchGuardForTesting,
withGithubCopilotDomainConfig,
} from "./login.js";
const mocks = vi.hoisted(() => ({
fetchWithSsrFGuard: vi.fn(),
}));
vi.mock("openclaw/plugin-sdk/ssrf-runtime", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/ssrf-runtime")>(
"openclaw/plugin-sdk/ssrf-runtime",
);
return { ...actual, fetchWithSsrFGuard: mocks.fetchWithSsrFGuard };
});
import { runGitHubCopilotDeviceFlow } from "./login.js";
const DEVICE_CODE_URL = "https://github.com/login/device/code";
const ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token";
@@ -30,7 +38,7 @@ function guardResponse(body: unknown, status = 200, url = DEVICE_CODE_URL) {
}
afterEach(() => {
setGitHubCopilotDeviceFlowFetchGuardForTesting(null);
mocks.fetchWithSsrFGuard.mockReset();
vi.restoreAllMocks();
});
@@ -39,7 +47,7 @@ describe("runGitHubCopilotDeviceFlow — normal flow", () => {
let callIdx = 0;
const requestTimeouts: Array<number | undefined> = [];
const controller = new AbortController();
setGitHubCopilotDeviceFlowFetchGuardForTesting(async (params) => {
mocks.fetchWithSsrFGuard.mockImplementation(async (params) => {
callIdx += 1;
requestTimeouts.push(params.timeoutMs);
expect(params.signal).toBe(controller.signal);
@@ -70,7 +78,7 @@ describe("runGitHubCopilotDeviceFlow — normal flow", () => {
it("returns access_denied when GitHub rejects the authorization", async () => {
let callIdx = 0;
setGitHubCopilotDeviceFlowFetchGuardForTesting(async () => {
mocks.fetchWithSsrFGuard.mockImplementation(async () => {
callIdx += 1;
if (callIdx === 1) {
return guardResponse(VALID_DEVICE_CODE_BODY);
@@ -86,7 +94,7 @@ describe("runGitHubCopilotDeviceFlow — normal flow", () => {
it("returns expired when GitHub reports expired_token", async () => {
let callIdx = 0;
setGitHubCopilotDeviceFlowFetchGuardForTesting(async () => {
mocks.fetchWithSsrFGuard.mockImplementation(async () => {
callIdx += 1;
if (callIdx === 1) {
return guardResponse(VALID_DEVICE_CODE_BODY);
@@ -103,7 +111,7 @@ describe("runGitHubCopilotDeviceFlow — normal flow", () => {
describe("runGitHubCopilotDeviceFlow — HTTP error propagation", () => {
it("throws with failureLabel on non-OK device code response", async () => {
setGitHubCopilotDeviceFlowFetchGuardForTesting(async () => guardResponse({}, 401));
mocks.fetchWithSsrFGuard.mockImplementation(async () => guardResponse({}, 401));
await expect(runGitHubCopilotDeviceFlow({ showCode: vi.fn() })).rejects.toThrow(
"GitHub device code failed: HTTP 401",
@@ -112,7 +120,7 @@ describe("runGitHubCopilotDeviceFlow — HTTP error propagation", () => {
it("throws with failureLabel on non-OK access token response", async () => {
let callIdx = 0;
setGitHubCopilotDeviceFlowFetchGuardForTesting(async () => {
mocks.fetchWithSsrFGuard.mockImplementation(async () => {
callIdx += 1;
if (callIdx === 1) {
return guardResponse(VALID_DEVICE_CODE_BODY);
@@ -146,7 +154,7 @@ describe("postGitHubDeviceFlowForm — response size bound", () => {
},
});
setGitHubCopilotDeviceFlowFetchGuardForTesting(async () => ({
mocks.fetchWithSsrFGuard.mockImplementation(async () => ({
response: new Response(oversizedBody, {
status: 200,
headers: { "Content-Type": "application/json" },
@@ -170,7 +178,7 @@ describe("postGitHubDeviceFlowForm — response size bound", () => {
let canceled = false;
let callIdx = 0;
setGitHubCopilotDeviceFlowFetchGuardForTesting(async () => {
mocks.fetchWithSsrFGuard.mockImplementation(async () => {
callIdx += 1;
if (callIdx === 1) {
return guardResponse(VALID_DEVICE_CODE_BODY);
@@ -219,7 +227,7 @@ describe("runGitHubCopilotDeviceFlow — data-residency GitHub Enterprise", () =
const urls: string[] = [];
let callIdx = 0;
setGitHubCopilotDeviceFlowFetchGuardForTesting(async (params) => {
mocks.fetchWithSsrFGuard.mockImplementation(async (params) => {
urls.push(params.url);
callIdx += 1;
if (callIdx === 1) {
@@ -250,7 +258,7 @@ describe("runGitHubCopilotDeviceFlow — data-residency GitHub Enterprise", () =
});
it("rejects a verification URL whose host does not match the configured domain", async () => {
setGitHubCopilotDeviceFlowFetchGuardForTesting(async () =>
mocks.fetchWithSsrFGuard.mockImplementation(async () =>
guardResponse(
{ ...VALID_DEVICE_CODE_BODY, verification_uri: "https://github.com/login/device" },
200,
@@ -263,32 +271,3 @@ describe("runGitHubCopilotDeviceFlow — data-residency GitHub Enterprise", () =
).rejects.toThrow("unexpected verification URL");
});
});
describe("withGithubCopilotDomainConfig — shortcut login domain persistence", () => {
const tenantConfig = {
models: {
providers: { "github-copilot": { params: { githubDomain: "acme.ghe.com" } } },
},
} as never;
it("persists the tenant domain when the shortcut minted a tenant token", () => {
const next = withGithubCopilotDomainConfig({} as never, "acme.ghe.com");
expect(
(next as { models?: { providers?: Record<string, { params?: Record<string, unknown> }> } })
.models?.providers?.["github-copilot"]?.params?.githubDomain,
).toBe("acme.ghe.com");
});
it("clears a stale tenant domain when the shortcut logged in against github.com", () => {
const next = withGithubCopilotDomainConfig(tenantConfig, "github.com");
const params = (
next as { models?: { providers?: Record<string, { params?: Record<string, unknown> }> } }
).models?.providers?.["github-copilot"]?.params;
expect(params && "githubDomain" in params).toBe(false);
});
it("leaves config untouched for a public login with no persisted domain", () => {
const cfg = {} as never;
expect(withGithubCopilotDomainConfig(cfg, "github.com")).toBe(cfg);
});
});
+6 -51
View File
@@ -1,7 +1,6 @@
// Github Copilot plugin module implements login behavior.
import { intro, note, outro, spinner } from "@clack/prompts";
import { stylePromptTitle } from "openclaw/plugin-sdk/cli-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { logConfigUpdated, updateConfig } from "openclaw/plugin-sdk/config-mutation";
import {
resolveExpiresAtMsFromDurationMs,
@@ -18,7 +17,11 @@ import {
import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime";
import { fetchWithSsrFGuard, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
import { PUBLIC_GITHUB_COPILOT_DOMAIN, resolveGithubCopilotDomain } from "./domain.js";
import {
PUBLIC_GITHUB_COPILOT_DOMAIN,
resolveGithubCopilotDomain,
withGithubCopilotDomainConfig,
} from "./domain.js";
const CLIENT_ID = "Iv1.b507a08c87ecfe98";
const GITHUB_DEVICE_FLOW_REQUEST_TIMEOUT_MS = 30_000;
@@ -68,14 +71,6 @@ class GitHubDeviceFlowError extends Error {
}
}
let githubDeviceFlowFetchGuard = fetchWithSsrFGuard;
export function setGitHubCopilotDeviceFlowFetchGuardForTesting(
impl: typeof fetchWithSsrFGuard | null,
): void {
githubDeviceFlowFetchGuard = impl ?? fetchWithSsrFGuard;
}
async function upsertAuthProfileWithLockOrThrow(params: UpsertAuthProfileParams): Promise<void> {
const updated = await upsertAuthProfileWithLock(params);
if (!updated) {
@@ -142,7 +137,7 @@ async function postGitHubDeviceFlowForm(params: {
domain: string;
signal?: AbortSignal;
}): Promise<Record<string, unknown>> {
const { response, release } = await githubDeviceFlowFetchGuard({
const { response, release } = await fetchWithSsrFGuard({
url: params.url,
init: {
method: "POST",
@@ -357,46 +352,6 @@ export async function runGitHubCopilotDeviceFlow(
}
}
// The shortcut login mints its token against the resolved domain, so the same
// domain must land in persisted config: a tenant token with no stored
// githubDomain would silently route to github.com (and 401) once
// COPILOT_GITHUB_DOMAIN is unset. Mirrors the enterprise auth method's
// persist-on-tenant / clear-on-public behavior.
export function withGithubCopilotDomainConfig(cfg: OpenClawConfig, domain: string): OpenClawConfig {
// Normalize the optional layers to concrete objects before spreading:
// spreading a possibly-undefined object widens every optional property to
// `T | undefined`, which exactOptionalPropertyTypes rejects.
const models: NonNullable<OpenClawConfig["models"]> = cfg.models ?? {};
const providers: NonNullable<typeof models.providers> = models.providers ?? {};
const provider = providers["github-copilot"];
const params = provider?.params;
const isDefault = domain === PUBLIC_GITHUB_COPILOT_DOMAIN;
if (isDefault && !(params && "githubDomain" in params)) {
return cfg;
}
const nextParams: Record<string, unknown> = { ...params };
if (isDefault) {
delete nextParams.githubDomain;
} else {
nextParams.githubDomain = domain;
}
const nextProviders = { ...providers };
if (provider) {
nextProviders["github-copilot"] = { ...provider, params: nextParams };
} else {
// Source config accepts partial provider inputs; catalog materialization
// supplies baseUrl/models before runtime consumption.
Object.assign(nextProviders, { "github-copilot": { params: nextParams } });
}
return {
...cfg,
models: {
...models,
providers: nextProviders,
},
};
}
export async function githubCopilotLoginCommand(
opts: { profileId?: string; yes?: boolean; agentDir?: string },
runtime: RuntimeEnv,
+3 -2
View File
@@ -2,10 +2,11 @@
import { createHash } from "node:crypto";
import { expectDefined } from "@openclaw/normalization-core";
import type { PluginStateSyncKeyedStore } from "openclaw/plugin-sdk/plugin-state-runtime";
import { deriveCopilotApiBaseUrlFromToken } from "openclaw/plugin-sdk/provider-auth";
import { createProviderUsageFetch, makeResponse } from "openclaw/plugin-sdk/test-env";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { CachedCopilotToken } from "./token.js";
import { deriveCopilotApiBaseUrlFromToken, resolveCopilotApiToken } from "./token.js";
import type { CachedCopilotToken } from "./token-cache.js";
import { resolveCopilotApiToken } from "./token.js";
import { fetchCopilotUsage } from "./usage.js";
vi.mock("openclaw/plugin-sdk/provider-model-shared", async (importOriginal) => ({
+25 -25
View File
@@ -1,12 +1,8 @@
// Github Copilot tests cover stream plugin behavior.
import type { Context } from "openclaw/plugin-sdk/llm";
import { buildCopilotIdeHeaders, COPILOT_INTEGRATION_ID } from "openclaw/plugin-sdk/provider-auth";
import { describe, expect, it, vi } from "vitest";
import { buildCopilotDynamicHeaders } from "./stream.js";
import {
wrapCopilotAnthropicStream,
wrapCopilotOpenAICompletionsStream,
wrapCopilotOpenAIResponsesStream,
wrapCopilotProviderStream,
} from "./stream.js";
import { wrapCopilotAnthropicStream, wrapCopilotProviderStream } from "./stream.js";
function requireStreamFn(streamFn: ReturnType<typeof wrapCopilotProviderStream>) {
expect(streamFn).toBeTypeOf("function");
@@ -28,6 +24,19 @@ function requireFirstStreamOptions(mock: ReturnType<typeof vi.fn>, label: string
return options as { headers?: Record<string, unknown>; onPayload?: unknown };
}
function buildExpectedCopilotHeaders(
initiator: "agent" | "user",
hasImages: boolean,
): Record<string, string> {
return {
...buildCopilotIdeHeaders(),
"Copilot-Integration-Id": COPILOT_INTEGRATION_ID,
"Openai-Organization": "github-copilot",
"x-initiator": initiator,
...(hasImages ? { "Copilot-Vision-Request": "true" } : {}),
};
}
describe("wrapCopilotAnthropicStream", () => {
it("adds Copilot headers, strips thinking replay, and marks cache for Claude payloads", () => {
const payloads: Array<{
@@ -63,12 +72,9 @@ describe("wrapCopilotAnthropicStream", () => {
{ type: "image", image: "data:image/png;base64,abc" },
],
},
] as Parameters<typeof buildCopilotDynamicHeaders>[0]["messages"];
] as Context["messages"];
const context = { messages };
const expectedCopilotHeaders = buildCopilotDynamicHeaders({
messages,
hasImages: true,
});
const expectedCopilotHeaders = buildExpectedCopilotHeaders("user", true);
expect(expectedCopilotHeaders["Accept-Encoding"]).toBe("identity");
void wrapped(
@@ -192,7 +198,7 @@ describe("wrapCopilotAnthropicStream", () => {
} as never;
});
const wrapped = requireStreamFn(wrapCopilotOpenAIResponsesStream(baseStreamFn));
const wrapped = requireStreamFn(wrapCopilotProviderStream({ streamFn: baseStreamFn } as never));
const messages = [
{
role: "toolResult",
@@ -201,11 +207,8 @@ describe("wrapCopilotAnthropicStream", () => {
{ type: "image", image: "data:image/png;base64,abc" },
],
},
] as Parameters<typeof buildCopilotDynamicHeaders>[0]["messages"];
const expectedCopilotHeaders = buildCopilotDynamicHeaders({
messages,
hasImages: true,
});
] as Context["messages"];
const expectedCopilotHeaders = buildExpectedCopilotHeaders("agent", true);
void wrapped(
{
@@ -249,7 +252,7 @@ describe("wrapCopilotAnthropicStream", () => {
} as never;
});
const wrapped = requireStreamFn(wrapCopilotOpenAIResponsesStream(baseStreamFn));
const wrapped = requireStreamFn(wrapCopilotProviderStream({ streamFn: baseStreamFn } as never));
await wrapped(
{
@@ -270,7 +273,7 @@ describe("wrapCopilotAnthropicStream", () => {
it("adds Copilot headers for Chat Completions models", () => {
const baseStreamFn = vi.fn(() => ({ async *[Symbol.asyncIterator]() {} }) as never);
const wrapped = requireStreamFn(wrapCopilotOpenAICompletionsStream(baseStreamFn));
const wrapped = requireStreamFn(wrapCopilotProviderStream({ streamFn: baseStreamFn } as never));
const messages = [
{
role: "user",
@@ -279,11 +282,8 @@ describe("wrapCopilotAnthropicStream", () => {
{ type: "image", data: "abc", mimeType: "image/png" },
],
},
] as Parameters<typeof buildCopilotDynamicHeaders>[0]["messages"];
const expectedCopilotHeaders = buildCopilotDynamicHeaders({
messages,
hasImages: true,
});
] as Context["messages"];
const expectedCopilotHeaders = buildExpectedCopilotHeaders("user", true);
void wrapped(
{
+3 -3
View File
@@ -46,7 +46,7 @@ function hasCopilotVisionInput(messages: Context["messages"]): boolean {
});
}
export function buildCopilotDynamicHeaders(params: {
function buildCopilotDynamicHeaders(params: {
messages: Context["messages"];
hasImages: boolean;
}): Record<string, string> {
@@ -115,7 +115,7 @@ export function wrapCopilotAnthropicStream(
};
}
export function wrapCopilotOpenAIResponsesStream(
function wrapCopilotOpenAIResponsesStream(
baseStreamFn: StreamFn | undefined,
): StreamFn | undefined {
if (!baseStreamFn) {
@@ -140,7 +140,7 @@ export function wrapCopilotOpenAIResponsesStream(
};
}
export function wrapCopilotOpenAICompletionsStream(
function wrapCopilotOpenAICompletionsStream(
baseStreamFn: StreamFn | undefined,
): StreamFn | undefined {
if (!baseStreamFn) {
-5
View File
@@ -19,9 +19,6 @@ import {
resolveCopilotTokenCache,
type CachedCopilotToken,
} from "./token-cache.js";
export { deriveCopilotApiBaseUrlFromToken } from "openclaw/plugin-sdk/provider-auth";
export type { CachedCopilotToken } from "./token-cache.js";
export const DEFAULT_COPILOT_API_BASE_URL = "https://api.individual.githubcopilot.com";
const COPILOT_TOKEN_EXCHANGE_TIMEOUT_MS = 30_000;
let openConfiguredCacheStore: (() => PluginStateSyncKeyedStore<CachedCopilotToken>) | undefined;
@@ -87,8 +84,6 @@ async function cancelUnreadResponseBody(response: Response): Promise<void> {
}
}
export type ResolveCopilotApiToken = typeof resolveCopilotApiToken;
export async function resolveCopilotApiToken(params: {
githubToken: string;
env?: NodeJS.ProcessEnv;