fix(github-copilot): authenticate fine-grained tokens

This commit is contained in:
joshavant
2026-07-26 23:25:40 -05:00
committed by Josh Avant
parent f229356689
commit fbd6b842f9
28 changed files with 450 additions and 595 deletions
@@ -9,8 +9,8 @@ import {
import { Type } from "typebox";
import { describe, expect, it } from "vitest";
import { resolveFirstGithubToken } from "./auth.js";
import { resolveCopilotRuntimeAuth } from "./runtime-auth.js";
import { wrapCopilotProviderStream } from "./stream.js";
import { resolveCopilotApiToken } from "./token.js";
const LIVE =
process.env.OPENCLAW_LIVE_TEST === "1" ||
@@ -26,9 +26,8 @@ const LIVE_MODEL_ID = process.env.OPENCLAW_LIVE_GITHUB_COPILOT_MODEL?.trim() ||
const describeLive = LIVE ? describe : describe.skip;
const TOOL_ARGUMENT_MARKER = `copilot-stream-arguments-${"x".repeat(128)}`;
type CopilotApiToken = {
token: string;
expiresAt: number;
type CopilotRuntimeAuth = {
apiKey: string;
source: string;
baseUrl: string;
};
@@ -145,31 +144,29 @@ describeLive("github-copilot connection-bound Responses IDs live", () => {
return;
}
let token: CopilotApiToken | undefined;
let token: CopilotRuntimeAuth | undefined;
const failures: string[] = [];
for (const candidate of candidates) {
try {
logProgress(`exchanging ${candidate.source} GitHub token for Copilot token`);
logProgress(`validating ${candidate.source} GitHub token for Copilot`);
token = await withTimeout(
"Copilot token exchange",
resolveCopilotApiToken({
"Copilot authentication",
resolveCopilotRuntimeAuth({
githubToken: candidate.token,
fetchImpl: fetchWithTimeout,
}),
15_000,
);
logProgress(
`token ok via ${candidate.source} (${token.source.startsWith("cache:") ? "cache" : "fetched"})`,
);
logProgress(`token ok via ${candidate.source}`);
break;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
failures.push(`${candidate.source}: ${message}`);
logProgress(`token exchange failed via ${candidate.source} (${message})`);
logProgress(`token validation failed via ${candidate.source} (${message})`);
}
}
if (!token) {
throw new Error(`Copilot token exchange failed for all candidates: ${failures.join("; ")}`);
throw new Error(`Copilot authentication failed for all candidates: ${failures.join("; ")}`);
}
const model = buildModel(token.baseUrl);
@@ -206,7 +203,7 @@ describeLive("github-copilot connection-bound Responses IDs live", () => {
model as never,
context as never,
{
apiKey: token.token,
apiKey: token.apiKey,
maxTokens: 256,
onPayload: (payload: unknown) => {
capturedPayload = {
+4 -4
View File
@@ -51,7 +51,7 @@ export function createGithubCopilotDynamicModelHooks(params: {
if (!params.discoveryEnabled(ctx.config)) {
return null;
}
const { DEFAULT_COPILOT_API_BASE_URL, resolveCopilotApiToken } =
const { DEFAULT_COPILOT_API_BASE_URL, resolveCopilotRuntimeAuth } =
await loadGithubCopilotRuntime();
const { githubToken, hasProfile } = await resolveFirstGithubToken({
agentDir: ctx.agentDir,
@@ -67,13 +67,13 @@ export function createGithubCopilotDynamicModelHooks(params: {
let copilotApiToken: string | undefined;
if (githubToken) {
try {
const token = await resolveCopilotApiToken({
const auth = await resolveCopilotRuntimeAuth({
githubToken,
env: ctx.env,
githubDomain: resolveGithubCopilotDomain({ env: ctx.env, config: ctx.config }),
});
baseUrl = token.baseUrl;
copilotApiToken = token.token;
baseUrl = auth.baseUrl;
copilotApiToken = auth.apiKey;
} catch {
baseUrl = DEFAULT_COPILOT_API_BASE_URL;
}
+37 -23
View File
@@ -1,9 +1,9 @@
// Github Copilot tests cover embeddings plugin behavior.
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CopilotTokenExchangeError } from "./token-exchange-error.js";
import { CopilotRuntimeAuthError } from "./runtime-auth-error.js";
const resolveFirstGithubTokenMock = vi.hoisted(() => vi.fn());
const resolveCopilotApiTokenMock = vi.hoisted(() => vi.fn());
const resolveCopilotRuntimeAuthMock = vi.hoisted(() => vi.fn());
const resolveConfiguredSecretInputStringMock = vi.hoisted(() => vi.fn());
const fetchWithSsrFGuardMock = vi.hoisted(() => vi.fn());
@@ -15,9 +15,9 @@ vi.mock("openclaw/plugin-sdk/secret-input-runtime", () => ({
resolveConfiguredSecretInputString: resolveConfiguredSecretInputStringMock,
}));
vi.mock("./token.js", () => ({
vi.mock("./runtime-auth.js", () => ({
DEFAULT_COPILOT_API_BASE_URL: "https://example.test",
resolveCopilotApiToken: resolveCopilotApiTokenMock,
resolveCopilotRuntimeAuth: resolveCopilotRuntimeAuthMock,
}));
vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
@@ -29,7 +29,7 @@ import { githubCopilotMemoryEmbeddingProviderAdapter } from "./embeddings.js";
afterAll(() => {
vi.doUnmock("./auth.js");
vi.doUnmock("openclaw/plugin-sdk/secret-input-runtime");
vi.doUnmock("./token.js");
vi.doUnmock("./runtime-auth.js");
vi.doUnmock("openclaw/plugin-sdk/ssrf-runtime");
vi.resetModules();
});
@@ -98,14 +98,14 @@ function defaultCreateOptions() {
};
}
function firstCopilotApiTokenRequest() {
const [call] = resolveCopilotApiTokenMock.mock.calls;
function firstCopilotRuntimeAuthRequest() {
const [call] = resolveCopilotRuntimeAuthMock.mock.calls;
if (!call) {
throw new Error("expected resolveCopilotApiToken call");
throw new Error("expected resolveCopilotRuntimeAuth call");
}
const [request] = call;
if (!request || typeof request !== "object") {
throw new Error("expected resolveCopilotApiToken request");
throw new Error("expected resolveCopilotRuntimeAuth request");
}
return request as { env?: typeof process.env; githubToken?: string };
}
@@ -132,9 +132,8 @@ describe("githubCopilotMemoryEmbeddingProviderAdapter", () => {
githubToken: "test-token-placeholder",
hasProfile: false,
});
resolveCopilotApiTokenMock.mockResolvedValue({
token: "test-token-placeholder",
expiresAt: Date.now() + 3_600_000,
resolveCopilotRuntimeAuthMock.mockResolvedValue({
apiKey: "test-token-placeholder",
source: "test",
baseUrl: TEST_BASE_URL,
});
@@ -145,7 +144,7 @@ describe("githubCopilotMemoryEmbeddingProviderAdapter", () => {
vi.unstubAllGlobals();
resolveConfiguredSecretInputStringMock.mockReset();
resolveFirstGithubTokenMock.mockReset();
resolveCopilotApiTokenMock.mockReset();
resolveCopilotRuntimeAuthMock.mockReset();
fetchWithSsrFGuardMock.mockReset();
});
@@ -169,7 +168,7 @@ describe("githubCopilotMemoryEmbeddingProviderAdapter", () => {
const result = await githubCopilotMemoryEmbeddingProviderAdapter.create(defaultCreateOptions());
expect(result.provider?.model).toBe("text-embedding-3-small");
expect(firstCopilotApiTokenRequest().githubToken).toBe("test-token-placeholder");
expect(firstCopilotRuntimeAuthRequest().githubToken).toBe("test-token-placeholder");
});
it("matches embedding-capable models when supported_endpoints is missing or malformed", async () => {
@@ -268,6 +267,7 @@ describe("githubCopilotMemoryEmbeddingProviderAdapter", () => {
expect(caught?.message.length).toBeLessThan(8_300);
expect(tracked.wasCanceled()).toBe(true);
expect(textSpy).not.toHaveBeenCalled();
expect(resolveCopilotRuntimeAuthMock).toHaveBeenCalledTimes(1);
});
it("bounds embeddings error bodies", async () => {
@@ -299,6 +299,7 @@ describe("githubCopilotMemoryEmbeddingProviderAdapter", () => {
expect(caught?.message.length).toBeLessThan(8_300);
expect(tracked.wasCanceled()).toBe(true);
expect(textSpy).not.toHaveBeenCalled();
expect(resolveCopilotRuntimeAuthMock).toHaveBeenCalledTimes(1);
});
it("honors remote overrides when creating the provider", async () => {
@@ -320,15 +321,28 @@ describe("githubCopilotMemoryEmbeddingProviderAdapter", () => {
expect(resolveFirstGithubTokenMock).not.toHaveBeenCalled();
expect(resolveConfiguredSecretInputStringMock).not.toHaveBeenCalled();
expect(firstCopilotApiTokenRequest().env).toBe(process.env);
expect(firstCopilotApiTokenRequest().githubToken).toBe("test-token-placeholder");
expect(resolveCopilotRuntimeAuthMock).not.toHaveBeenCalled();
const discoveryCall = firstDiscoveryRequest();
expect(discoveryCall.url).toBe("https://proxy.example/v1/models");
expect(discoveryCall.init.headers["Accept-Encoding"]).toBe("identity");
expect(discoveryCall.init.headers["Copilot-Integration-Id"]).toBe("copilot-developer-cli");
expect(discoveryCall.init.headers["X-Proxy-Token"]).toBe("test-token-placeholder");
});
it("does not forward a stored GitHub token to a custom remote endpoint", async () => {
await expect(
githubCopilotMemoryEmbeddingProviderAdapter.create({
...defaultCreateOptions(),
remote: { baseUrl: "https://proxy.example/v1" },
} as never),
).rejects.toThrow("custom baseUrl requires an explicit memory.search.remote.apiKey");
expect(resolveFirstGithubTokenMock).not.toHaveBeenCalled();
expect(resolveCopilotRuntimeAuthMock).not.toHaveBeenCalled();
expect(fetchWithSsrFGuardMock).not.toHaveBeenCalled();
});
it("rejects an unresolved remote ref without falling back to another profile", async () => {
await expect(
githubCopilotMemoryEmbeddingProviderAdapter.create({
@@ -342,7 +356,7 @@ describe("githubCopilotMemoryEmbeddingProviderAdapter", () => {
path: "memory.search.remote.apiKey",
});
expect(resolveFirstGithubTokenMock).not.toHaveBeenCalled();
expect(resolveCopilotApiTokenMock).not.toHaveBeenCalled();
expect(resolveCopilotRuntimeAuthMock).not.toHaveBeenCalled();
expect(resolveConfiguredSecretInputStringMock).not.toHaveBeenCalled();
});
@@ -366,13 +380,13 @@ describe("githubCopilotMemoryEmbeddingProviderAdapter", () => {
});
});
it("treats token parsing and discovery failures as auto-fallback errors", () => {
expect(shouldContinueAutoSelection(new Error("Copilot token response missing token"))).toBe(
true,
);
it("treats authentication and discovery failures as auto-fallback errors", () => {
expect(
shouldContinueAutoSelection(new Error("Copilot user response missing endpoints.api")),
).toBe(true);
expect(
shouldContinueAutoSelection(
new Error("Unexpected response from GitHub Copilot token endpoint"),
new Error("Unexpected response from GitHub Copilot user endpoint"),
),
).toBe(true);
expect(
@@ -382,7 +396,7 @@ describe("githubCopilotMemoryEmbeddingProviderAdapter", () => {
).toBe(true);
expect(
shouldContinueAutoSelection(
new CopilotTokenExchangeError({ reason: "timeout", timeoutMs: 30_000 }),
new CopilotRuntimeAuthError({ reason: "timeout", timeoutMs: 30_000 }),
),
).toBe(true);
expect(shouldContinueAutoSelection(new Error("Network timeout"))).toBe(false);
@@ -10,15 +10,15 @@ import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const resolveFirstGithubTokenMock = vi.hoisted(() => vi.fn());
const resolveCopilotApiTokenMock = vi.hoisted(() => vi.fn());
const resolveCopilotRuntimeAuthMock = vi.hoisted(() => vi.fn());
vi.mock("./auth.js", () => ({
resolveFirstGithubToken: resolveFirstGithubTokenMock,
}));
vi.mock("./token.js", () => ({
vi.mock("./runtime-auth.js", () => ({
DEFAULT_COPILOT_API_BASE_URL: "https://api.githubcopilot.test",
resolveCopilotApiToken: resolveCopilotApiTokenMock,
resolveCopilotRuntimeAuth: resolveCopilotRuntimeAuthMock,
}));
// Intentionally NOT mocked: openclaw/plugin-sdk/ssrf-runtime, global fetch, and
@@ -76,9 +76,8 @@ async function startCopilotServer(handle: {
}
function pointTokenAt(baseUrl: string): void {
resolveCopilotApiTokenMock.mockResolvedValue({
token: "copilot_test_token_abc",
expiresAt: Date.now() + 3_600_000,
resolveCopilotRuntimeAuthMock.mockResolvedValue({
apiKey: "copilot_test_token_abc",
source: "test",
baseUrl,
});
@@ -123,7 +122,7 @@ describe("githubCopilotMemoryEmbeddingProviderAdapter real transport", () => {
const pending = servers.splice(0);
await Promise.all(pending.map((server) => server.close()));
resolveFirstGithubTokenMock.mockReset();
resolveCopilotApiTokenMock.mockReset();
resolveCopilotRuntimeAuthMock.mockReset();
});
it("redacts credential-shaped text in model discovery errors over real transport", async () => {
+45 -26
View File
@@ -16,8 +16,9 @@ import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-i
import { fetchWithSsrFGuard, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
import { resolveFirstGithubToken } from "./auth.js";
import { resolveGithubCopilotDomain } from "./domain.js";
import { CopilotTokenExchangeError } from "./token-exchange-error.js";
import { DEFAULT_COPILOT_API_BASE_URL, resolveCopilotApiToken } from "./token.js";
import { CopilotRuntimeAuthError } from "./runtime-auth-error.js";
import { DEFAULT_COPILOT_API_BASE_URL, resolveCopilotRuntimeAuth } from "./runtime-auth.js";
import { COPILOT_RUNTIME_INTEGRATION_ID } from "./runtime-identity.js";
const COPILOT_EMBEDDING_PROVIDER_ID = "github-copilot";
@@ -33,6 +34,7 @@ const PREFERRED_MODELS = [
const COPILOT_HEADERS_STATIC: Record<string, string> = {
"Content-Type": "application/json",
...buildCopilotIdeHeaders(),
"Copilot-Integration-Id": COPILOT_RUNTIME_INTEGRATION_ID,
};
const COPILOT_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
const COPILOT_EMBEDDINGS_RESPONSE_MAX_BYTES = 64 * 1024 * 1024;
@@ -57,6 +59,7 @@ type CopilotModelEntry = {
type GitHubCopilotEmbeddingClient = {
githubToken: string;
model: string;
runtimeAuth?: { apiKey: string; baseUrl: string };
baseUrl?: string;
headers?: Record<string, string>;
env?: NodeJS.ProcessEnv;
@@ -65,7 +68,7 @@ type GitHubCopilotEmbeddingClient = {
};
function isCopilotSetupError(err: unknown): boolean {
if (err instanceof CopilotTokenExchangeError) {
if (err instanceof CopilotRuntimeAuthError) {
return true;
}
if (!(err instanceof Error)) {
@@ -73,16 +76,16 @@ function isCopilotSetupError(err: unknown): boolean {
}
// All Copilot-specific setup failures should allow auto-selection to
// fall through to the next provider (e.g. OpenAI). This covers: missing
// GitHub token, token exchange failures, no embedding models on the plan,
// GitHub token, authentication failures, no embedding models on the plan,
// model discovery errors, and user-pinned model not available on Copilot.
return (
err.message.includes("No GitHub token available") ||
err.message.includes("Copilot token response") ||
err.message.includes("Copilot user response") ||
err.message.includes("No embedding models available") ||
err.message.includes("GitHub Copilot model discovery") ||
err.message.includes("github-copilot.model-discovery") ||
err.message.includes("GitHub Copilot embedding model") ||
err.message.includes("Unexpected response from GitHub Copilot token endpoint")
err.message.includes("Unexpected response from GitHub Copilot user endpoint")
);
}
@@ -213,19 +216,21 @@ async function resolveGitHubCopilotEmbeddingSession(client: GitHubCopilotEmbeddi
baseUrl: string;
headers: Record<string, string>;
}> {
const token = await resolveCopilotApiToken({
githubToken: client.githubToken,
env: client.env,
fetchImpl: client.fetchImpl,
githubDomain: client.githubDomain,
});
const baseUrl = client.baseUrl?.trim() || token.baseUrl || DEFAULT_COPILOT_API_BASE_URL;
const auth =
client.runtimeAuth ??
(await resolveCopilotRuntimeAuth({
githubToken: client.githubToken,
env: client.env,
fetchImpl: client.fetchImpl,
githubDomain: client.githubDomain,
}));
const baseUrl = client.baseUrl?.trim() || auth.baseUrl || DEFAULT_COPILOT_API_BASE_URL;
return {
baseUrl,
headers: {
...COPILOT_HEADERS_STATIC,
...client.headers,
Authorization: `Bearer ${token.token}`,
Authorization: `Bearer ${auth.apiKey}`,
},
};
}
@@ -240,16 +245,15 @@ async function createGitHubCopilotEmbeddingProvider(
return [];
}
const session = await resolveGitHubCopilotEmbeddingSession(client);
const url = `${session.baseUrl.replace(/\/$/, "")}/embeddings`;
const url = `${initialSession.baseUrl.replace(/\/$/, "")}/embeddings`;
return await withRemoteHttpResponse({
url,
fetchImpl: client.fetchImpl,
ssrfPolicy: buildRemoteBaseUrlPolicy(session.baseUrl),
ssrfPolicy: buildRemoteBaseUrlPolicy(initialSession.baseUrl),
signal,
init: {
method: "POST",
headers: session.headers,
headers: initialSession.headers,
body: JSON.stringify({ model: client.model, input }),
},
onResponse: async (response) => {
@@ -297,6 +301,17 @@ export const githubCopilotMemoryEmbeddingProviderAdapter: MemoryEmbeddingProvide
value: options.remote?.apiKey,
path: "memory.search.remote.apiKey",
});
const customBaseUrl = options.remote?.baseUrl?.trim();
const customRuntimeAuth = customBaseUrl
? (() => {
if (!explicitValue) {
throw new Error(
"GitHub Copilot memory custom baseUrl requires an explicit memory.search.remote.apiKey",
);
}
return { apiKey: explicitValue, baseUrl: customBaseUrl };
})()
: undefined;
const value = explicitValue
? explicitValue
: (
@@ -314,13 +329,16 @@ export const githubCopilotMemoryEmbeddingProviderAdapter: MemoryEmbeddingProvide
env: process.env,
config: options.config,
});
const { token: copilotToken, baseUrl: resolvedBaseUrl } = await resolveCopilotApiToken({
githubToken: value,
env: process.env,
githubDomain,
});
const baseUrl =
options.remote?.baseUrl?.trim() || resolvedBaseUrl || DEFAULT_COPILOT_API_BASE_URL;
// A custom endpoint owns its own explicit credential. Never resolve a
// durable GitHub token and then forward it to an operator-supplied host.
const runtimeAuth =
customRuntimeAuth ??
(await resolveCopilotRuntimeAuth({
githubToken: value,
env: process.env,
githubDomain,
}));
const baseUrl = runtimeAuth.baseUrl || DEFAULT_COPILOT_API_BASE_URL;
const ssrfPolicy = buildSsrfPolicy(baseUrl);
// Always discover models even when the user pins one: this validates
@@ -328,7 +346,7 @@ export const githubCopilotMemoryEmbeddingProviderAdapter: MemoryEmbeddingProvide
// we attempt any embedding requests.
const availableModels = await discoverEmbeddingModels({
baseUrl,
copilotToken,
copilotToken: runtimeAuth.apiKey,
headers: options.remote?.headers,
ssrfPolicy,
});
@@ -341,6 +359,7 @@ export const githubCopilotMemoryEmbeddingProviderAdapter: MemoryEmbeddingProvide
env: process.env,
fetchImpl: fetch,
githubToken: value,
runtimeAuth,
githubDomain,
headers: options.remote?.headers,
model,
+44 -48
View File
@@ -28,8 +28,7 @@ const mocks = vi.hoisted(() => ({
finalUrl: params.url,
release: vi.fn(async () => {}),
})),
resolveCopilotApiToken: vi.fn(),
configureCopilotTokenCacheStore: vi.fn<(openStore: () => unknown) => void>(),
resolveCopilotRuntimeAuth: vi.fn(),
}));
function requireAuthMethod<T>(methods: readonly T[], index: number): T {
@@ -48,15 +47,11 @@ vi.mock("openclaw/plugin-sdk/ssrf-runtime", async () => {
vi.mock("./register.runtime.js", () => ({
DEFAULT_COPILOT_API_BASE_URL: "https://api.githubcopilot.test",
resolveCopilotApiToken: mocks.resolveCopilotApiToken,
resolveCopilotRuntimeAuth: mocks.resolveCopilotRuntimeAuth,
githubCopilotLoginCommand: mocks.githubCopilotLoginCommand,
fetchCopilotUsage: vi.fn(),
}));
vi.mock("./token.js", () => ({
configureCopilotTokenCacheStore: mocks.configureCopilotTokenCacheStore,
}));
import plugin from "./index.js";
const tempDirs: string[] = [];
@@ -75,6 +70,7 @@ type GithubCopilotTestProvider = RegisteredProvider & {
prepareDynamicModel: NonNullable<RegisteredProvider["prepareDynamicModel"]>;
resolveDynamicModel: NonNullable<RegisteredProvider["resolveDynamicModel"]>;
preferRuntimeResolvedModel: NonNullable<RegisteredProvider["preferRuntimeResolvedModel"]>;
prepareRuntimeAuth: NonNullable<RegisteredProvider["prepareRuntimeAuth"]>;
resolveThinkingProfile: NonNullable<RegisteredProvider["resolveThinkingProfile"]>;
};
type GithubCopilotTestModelCatalogProvider = {
@@ -178,31 +174,31 @@ function registerProviderWithPluginConfig(pluginConfig: Record<string, unknown>)
}
describe("github-copilot plugin", () => {
it("binds a lazy provider-scoped token store", () => {
const store = {};
const openSyncKeyedStore = vi.fn(() => store);
plugin.register(
createTestPluginApi({
id: "github-copilot",
name: "GitHub Copilot",
source: "test",
config: {},
runtime: { state: { openSyncKeyedStore } } as never,
}),
);
it("preserves the source token supplied by the auth layer for runtime auth", async () => {
mocks.resolveCopilotRuntimeAuth.mockResolvedValueOnce({
apiKey: "github-source-token",
baseUrl: "https://api.individual.githubcopilot.com",
});
const provider = registerProviderWithPluginConfig({});
const openStore = requireFirstMockArg<() => unknown>(
mocks.configureCopilotTokenCacheStore,
"token cache store binding",
);
expect(openSyncKeyedStore).not.toHaveBeenCalled();
expect(openStore()).toBe(store);
expect(openStore()).toBe(store);
expect(openSyncKeyedStore).toHaveBeenCalledOnce();
expect(openSyncKeyedStore).toHaveBeenCalledWith({
namespace: "token",
maxEntries: 8,
overflowPolicy: "evict-oldest",
const prepared = await provider.prepareRuntimeAuth({
config: {},
env: {},
provider: "github-copilot",
modelId: "gpt-5-mini",
model: { id: "gpt-5-mini", provider: "github-copilot" },
apiKey: "github-source-token",
authMode: "oauth",
} as never);
expect(mocks.resolveCopilotRuntimeAuth).toHaveBeenCalledWith({
githubToken: "github-source-token",
env: {},
githubDomain: "github.com",
});
expect(prepared).toEqual({
apiKey: "github-source-token",
baseUrl: "https://api.individual.githubcopilot.com",
});
});
@@ -287,7 +283,7 @@ describe("github-copilot plugin", () => {
} as never);
expect(result).toBeNull();
expect(mocks.resolveCopilotApiToken).not.toHaveBeenCalled();
expect(mocks.resolveCopilotRuntimeAuth).not.toHaveBeenCalled();
});
it("exposes xhigh thinking for catalog-supported Copilot reasoning efforts", () => {
@@ -358,8 +354,8 @@ describe("github-copilot plugin", () => {
});
it("uses live plugin config to re-enable discovery after startup disable", async () => {
mocks.resolveCopilotApiToken.mockResolvedValueOnce({
token: "copilot_api_token",
mocks.resolveCopilotRuntimeAuth.mockResolvedValueOnce({
apiKey: "gh_test_token",
baseUrl: "https://api.githubcopilot.live",
});
const provider = registerProviderWithPluginConfig({ discovery: { enabled: false } });
@@ -381,7 +377,7 @@ describe("github-copilot plugin", () => {
resolveProviderApiKey: () => ({ apiKey: "gh_test_token" }),
} as never);
expect(mocks.resolveCopilotApiToken).toHaveBeenCalledWith({
expect(mocks.resolveCopilotRuntimeAuth).toHaveBeenCalledWith({
githubToken: "gh_test_token",
env: { GH_TOKEN: "gh_test_token" },
githubDomain: "github.com",
@@ -395,8 +391,8 @@ describe("github-copilot plugin", () => {
});
it("dual-publishes unified live catalog rows with existing discovery semantics", async () => {
mocks.resolveCopilotApiToken.mockResolvedValueOnce({
token: "copilot_api_token",
mocks.resolveCopilotRuntimeAuth.mockResolvedValueOnce({
apiKey: "gh_test_token",
baseUrl: "https://api.githubcopilot.live",
});
const { modelCatalogProvider } = registerProviderAndCatalogWithPluginConfig({
@@ -425,7 +421,7 @@ describe("github-copilot plugin", () => {
}),
} as never);
expect(mocks.resolveCopilotApiToken).toHaveBeenCalledWith({
expect(mocks.resolveCopilotRuntimeAuth).toHaveBeenCalledWith({
githubToken: "gh_test_token",
env: { GH_TOKEN: "gh_test_token" },
githubDomain: "github.com",
@@ -500,13 +496,13 @@ describe("github-copilot plugin", () => {
agentDir,
{ filterExternalAuthProfiles: false, syncExternalCli: false },
);
mocks.resolveCopilotApiToken
mocks.resolveCopilotRuntimeAuth
.mockResolvedValueOnce({
token: "alpha",
apiKey: "chosen",
baseUrl: "https://api.githubcopilot.live",
})
.mockResolvedValueOnce({
token: "beta",
apiKey: "first",
baseUrl: "https://api.githubcopilot.first",
});
const catalogResponse = (contextWindow: number, promptTokens: number) =>
@@ -560,12 +556,12 @@ describe("github-copilot plugin", () => {
await provider.prepareDynamicModel(selectedContext);
await provider.prepareDynamicModel(firstContext);
expect(mocks.resolveCopilotApiToken).toHaveBeenNthCalledWith(1, {
expect(mocks.resolveCopilotRuntimeAuth).toHaveBeenNthCalledWith(1, {
githubToken: "chosen",
env: process.env,
githubDomain: "github.com",
});
expect(mocks.resolveCopilotApiToken).toHaveBeenNthCalledWith(2, {
expect(mocks.resolveCopilotRuntimeAuth).toHaveBeenNthCalledWith(2, {
githubToken: "first",
env: process.env,
githubDomain: "github.com",
@@ -605,13 +601,13 @@ describe("github-copilot plugin", () => {
agentDir,
{ filterExternalAuthProfiles: false, syncExternalCli: false },
);
mocks.resolveCopilotApiToken
mocks.resolveCopilotRuntimeAuth
.mockResolvedValueOnce({
token: "test-auth-token",
apiKey: "test-auth-token",
baseUrl: "https://api.githubcopilot.profile",
})
.mockResolvedValueOnce({
token: "test-token-placeholder",
apiKey: "test-token-placeholder",
baseUrl: "https://api.githubcopilot.direct",
});
const catalogResponse = (contextWindow: number, promptTokens: number) =>
@@ -675,12 +671,12 @@ describe("github-copilot plugin", () => {
await provider.prepareDynamicModel(profileContext);
await provider.prepareDynamicModel(directContext);
expect(mocks.resolveCopilotApiToken).toHaveBeenNthCalledWith(1, {
expect(mocks.resolveCopilotRuntimeAuth).toHaveBeenNthCalledWith(1, {
githubToken: "test-auth-token",
env: process.env,
githubDomain: "github.com",
});
expect(mocks.resolveCopilotApiToken).toHaveBeenNthCalledWith(2, {
expect(mocks.resolveCopilotRuntimeAuth).toHaveBeenNthCalledWith(2, {
githubToken: "test-token-placeholder",
env: process.env,
githubDomain: "github.com",
+4 -23
View File
@@ -9,7 +9,6 @@ import {
type UnifiedModelCatalogEntry,
type UnifiedModelCatalogProviderContext,
} from "openclaw/plugin-sdk/plugin-entry";
import type { PluginStateSyncKeyedStore } from "openclaw/plugin-sdk/plugin-state-runtime";
import {
applyAuthProfileConfig,
coerceSecretRef,
@@ -30,12 +29,6 @@ import {
sanitizeGithubCopilotReplayHistory,
} from "./replay-policy.js";
import { wrapCopilotProviderStream } from "./stream.js";
import {
COPILOT_TOKEN_CACHE_MAX_ENTRIES,
COPILOT_TOKEN_CACHE_NAMESPACE,
type CachedCopilotToken,
} from "./token-cache.js";
import { configureCopilotTokenCacheStore } from "./token.js";
const COPILOT_ENV_VARS: [string, string, string] = [
"COPILOT_GITHUB_TOKEN",
@@ -340,17 +333,6 @@ export default definePluginEntry({
description: "Bundled GitHub Copilot provider plugin",
register(api) {
const startupPluginConfig = (api.pluginConfig ?? {}) as GithubCopilotPluginConfig;
let tokenCacheStore: PluginStateSyncKeyedStore<CachedCopilotToken> | undefined;
const openTokenCacheStore = () => {
tokenCacheStore ??= api.runtime.state.openSyncKeyedStore<CachedCopilotToken>({
namespace: COPILOT_TOKEN_CACHE_NAMESPACE,
maxEntries: COPILOT_TOKEN_CACHE_MAX_ENTRIES,
overflowPolicy: "evict-oldest",
});
return tokenCacheStore;
};
configureCopilotTokenCacheStore(openTokenCacheStore);
function resolveCurrentPluginConfig(config?: OpenClawConfig): GithubCopilotPluginConfig {
const runtimePluginConfig = resolvePluginConfigObject(config, "github-copilot");
if (runtimePluginConfig) {
@@ -635,16 +617,15 @@ export default definePluginEntry({
};
},
prepareRuntimeAuth: async (ctx) => {
const { resolveCopilotApiToken } = await loadGithubCopilotRuntime();
const token = await resolveCopilotApiToken({
const { resolveCopilotRuntimeAuth } = await loadGithubCopilotRuntime();
const auth = await resolveCopilotRuntimeAuth({
githubToken: ctx.apiKey,
env: ctx.env,
githubDomain: resolveGithubCopilotDomain({ env: ctx.env, config: ctx.config }),
});
return {
apiKey: token.token,
baseUrl: token.baseUrl,
expiresAt: token.expiresAt,
apiKey: auth.apiKey,
baseUrl: auth.baseUrl,
};
},
resolveUsageAuth: async (ctx) => await ctx.resolveOAuthToken(),
+2 -2
View File
@@ -27,7 +27,7 @@ const CLIENT_ID = "Iv1.b507a08c87ecfe98";
const GITHUB_DEVICE_FLOW_REQUEST_TIMEOUT_MS = 30_000;
const GITHUB_DEVICE_FLOW_DEFAULT_INTERVAL_MS = 5_000;
const GITHUB_DEVICE_FLOW_SLOW_DOWN_INCREMENT_MS = 5_000;
// Data-residency GitHub Enterprise support: the device flow, token exchange, and
// Data-residency GitHub Enterprise support: the device flow, runtime auth, and
// completions endpoints all live under the tenant host (e.g. "acme.ghe.com")
// instead of github.com. The host is threaded in from the selected auth flow so
// the SSRF allowlist and every request target stay consistent for one login.
@@ -391,7 +391,7 @@ export async function githubCopilotLoginCommand(
}
// Mint against the same host the runtime will route to. resolveGithubCopilotDomain
// is env-authoritative (COPILOT_GITHUB_DOMAIN wins), and runtime token exchange
// is env-authoritative (COPILOT_GITHUB_DOMAIN wins), and runtime authentication
// uses the same resolver, so honoring it here keeps the minted token and the
// runtime endpoint on the same tenant instead of minting a public token that
// then 401s against api.<tenant>.
+118 -136
View File
@@ -1,13 +1,9 @@
// Github Copilot tests cover models plugin behavior.
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-cache.js";
import { CopilotTokenExchangeError } from "./token-exchange-error.js";
import { resolveCopilotApiToken } from "./token.js";
import { describe, expect, it, vi } from "vitest";
import { CopilotRuntimeAuthError } from "./runtime-auth-error.js";
import { resolveCopilotRuntimeAuth } from "./runtime-auth.js";
import { fetchCopilotUsage } from "./usage.js";
vi.mock("openclaw/plugin-sdk/provider-model-shared", async (importOriginal) => ({
@@ -20,16 +16,6 @@ vi.mock("openclaw/plugin-sdk/provider-model-shared", async (importOriginal) => (
}),
}));
const jsonStoreMocks = vi.hoisted(() => ({
loadJsonFile: vi.fn(),
saveJsonFile: vi.fn(),
}));
vi.mock("openclaw/plugin-sdk/json-store", () => ({
loadJsonFile: jsonStoreMocks.loadJsonFile,
saveJsonFile: jsonStoreMocks.saveJsonFile,
}));
vi.mock("openclaw/plugin-sdk/state-paths", () => ({
resolveStateDir: () => "/tmp/openclaw-state",
}));
@@ -59,10 +45,6 @@ function requireResolvedModel(ctx: ProviderResolveDynamicModelContext) {
return result;
}
function copilotCredentialFixture(proxyHost: string): string {
return `test-token-placeholder;proxy-ep=${proxyHost};`;
}
describe("resolveCopilotForwardCompatModel", () => {
it("returns undefined for empty modelId", () => {
expect(resolveCopilotForwardCompatModel(createMockCtx(""))).toBeUndefined();
@@ -359,144 +341,144 @@ describe("fetchCopilotUsage", () => {
});
});
describe("github-copilot token", () => {
const cachePath = "/tmp/openclaw-state/credentials/github-copilot.token.json";
describe("github-copilot runtime auth", () => {
it("validates and preserves the source token while resolving the account API endpoint", async () => {
const fetchImpl = vi
.fn()
.mockResolvedValue(
new Response(
JSON.stringify({ endpoints: { api: "https://api.individual.githubcopilot.com/" } }),
{ status: 200, headers: { "content-type": "application/json" } },
),
);
beforeEach(() => {
jsonStoreMocks.loadJsonFile.mockReset();
jsonStoreMocks.saveJsonFile.mockReset();
});
it("derives baseUrl only from trusted Copilot token hosts", () => {
expect(deriveCopilotApiBaseUrlFromToken("token;proxy-ep=proxy.example.com;")).toBeNull();
expect(deriveCopilotApiBaseUrlFromToken("token;proxy-ep=https://proxy.foo.bar;")).toBeNull();
expect(
deriveCopilotApiBaseUrlFromToken(
copilotCredentialFixture("proxy.individual.githubcopilot.com"),
),
).toBe("https://api.individual.githubcopilot.com");
});
it("uses cache when token is still valid", async () => {
const now = Date.now();
jsonStoreMocks.loadJsonFile.mockReturnValue({
token: "cached;proxy-ep=proxy.example.com;",
expiresAt: now + 60 * 60 * 1000,
updatedAt: now,
integrationId: "vscode-chat",
sourceCredentialFingerprint: createHash("sha256").update("gh").digest("hex"),
domain: "github.com",
const auth = await resolveCopilotRuntimeAuth({
githubToken: "github-source-token",
fetchImpl: fetchImpl as typeof fetch,
});
const fetchImpl = vi.fn();
const res = await resolveCopilotApiToken({
githubToken: "gh",
cachePath,
loadJsonFileImpl: jsonStoreMocks.loadJsonFile,
saveJsonFileImpl: jsonStoreMocks.saveJsonFile,
fetchImpl: fetchImpl as unknown as typeof fetch,
expect(auth).toEqual({
apiKey: "github-source-token",
baseUrl: "https://api.individual.githubcopilot.com",
source: "validated:https://api.github.com/copilot_internal/user",
});
expect(res.token).toBe("cached;proxy-ep=proxy.example.com;");
expect(res.baseUrl).toBe("https://api.individual.githubcopilot.com");
expect(res.source).toContain("cache:");
expect(fetchImpl).not.toHaveBeenCalled();
});
it("fetches and stores token when cache is missing", async () => {
jsonStoreMocks.loadJsonFile.mockReturnValue(undefined);
const fetchImpl = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
token: "fresh;proxy-ep=https://proxy.contoso.test;",
expires_at: Math.floor(Date.now() / 1000) + 3600,
}),
{
status: 200,
headers: { "content-type": "application/json" },
expect(fetchImpl).toHaveBeenCalledWith(
"https://api.github.com/copilot_internal/user",
expect.objectContaining({
method: "GET",
headers: {
Accept: "application/json",
Authorization: "Bearer github-source-token",
},
),
}),
);
const res = await resolveCopilotApiToken({
githubToken: "gh",
cachePath,
loadJsonFileImpl: jsonStoreMocks.loadJsonFile,
saveJsonFileImpl: jsonStoreMocks.saveJsonFile,
fetchImpl: fetchImpl as unknown as typeof fetch,
});
expect(res.token).toBe("fresh;proxy-ep=https://proxy.contoso.test;");
expect(res.baseUrl).toBe("https://api.individual.githubcopilot.com");
const [, calledInit] = fetchImpl.mock.calls[0] ?? [];
expect(((calledInit as RequestInit).headers as Record<string, string>)["Accept-Encoding"]).toBe(
"identity",
);
expect(jsonStoreMocks.saveJsonFile).toHaveBeenCalledTimes(1);
});
it("explains how to recover from a forbidden token exchange", async () => {
jsonStoreMocks.loadJsonFile.mockReturnValue(undefined);
const fetchImpl = vi.fn().mockResolvedValue(new Response(null, { status: 403 }));
it("accepts an account endpoint under the configured data-residency tenant", async () => {
const fetchImpl = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ endpoints: { api: "https://copilot-api.acme.ghe.com" } }), {
status: 200,
headers: { "content-type": "application/json" },
}),
);
const rejection = resolveCopilotApiToken({
githubToken: "gh",
cachePath,
loadJsonFileImpl: jsonStoreMocks.loadJsonFile,
saveJsonFileImpl: jsonStoreMocks.saveJsonFile,
fetchImpl: fetchImpl as unknown as typeof fetch,
const auth = await resolveCopilotRuntimeAuth({
githubToken: "tenant-source-token",
githubDomain: "acme.ghe.com",
fetchImpl: fetchImpl as typeof fetch,
});
await expect(rejection).rejects.toBeInstanceOf(CopilotTokenExchangeError);
expect(auth.baseUrl).toBe("https://copilot-api.acme.ghe.com");
expect(fetchImpl.mock.calls[0]?.[0]).toBe("https://api.acme.ghe.com/copilot_internal/user");
});
it("uses a domain-safe fallback when account metadata omits the API endpoint", async () => {
const fetchImpl = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ copilot_plan: "individual" }), {
status: 200,
headers: { "content-type": "application/json" },
}),
);
await expect(
resolveCopilotRuntimeAuth({
githubToken: "github-source-token",
fetchImpl: fetchImpl as typeof fetch,
}),
).resolves.toMatchObject({
apiKey: "github-source-token",
baseUrl: "https://api.individual.githubcopilot.com",
});
});
it.each([
"http://api.individual.githubcopilot.com",
"https://api.individual.githubcopilot.com.attacker.test",
"https://user@api.individual.githubcopilot.com",
])("rejects an untrusted account endpoint: %s", async (api) => {
const fetchImpl = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ endpoints: { api } }), {
status: 200,
headers: { "content-type": "application/json" },
}),
);
await expect(
resolveCopilotRuntimeAuth({
githubToken: "github-source-token",
fetchImpl: fetchImpl as typeof fetch,
}),
).rejects.toThrow("untrusted endpoints.api URL");
});
it("explains how to recover from forbidden authentication", async () => {
const fetchImpl = vi.fn().mockResolvedValue(new Response(null, { status: 403 }));
const rejection = resolveCopilotRuntimeAuth({
githubToken: "github-source-token",
fetchImpl: fetchImpl as typeof fetch,
});
await expect(rejection).rejects.toBeInstanceOf(CopilotRuntimeAuthError);
await expect(rejection).rejects.toMatchObject({
code: "github_copilot_token_exchange_failed",
code: "github_copilot_auth_failed",
reason: "http_error",
status: 403,
message: expect.stringContaining("login-github-copilot"),
});
});
it("keeps exchanges per source credential in plugin state", async () => {
const values = new Map<string, CachedCopilotToken>();
const register = vi.fn((key: string, value: CachedCopilotToken) => {
values.set(key, value);
});
const store = {
lookup: vi.fn((key: string) => values.get(key)),
register,
} as unknown as PluginStateSyncKeyedStore<CachedCopilotToken>;
const fetchImpl = vi.fn(async (_url: string, init?: RequestInit) => {
const source = new Headers(init?.headers).get("authorization")?.replace(/^Bearer\s+/u, "");
const exchange = `exchange-${source};proxy-ep=proxy.individual.githubcopilot.com;`;
it("maps a stalled response body to a runtime-auth timeout", async () => {
const controller = new AbortController();
const timeoutSpy = vi.spyOn(AbortSignal, "timeout").mockImplementation(() => controller.signal);
const fetchImpl = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
const signal = init?.signal;
return new Response(
JSON.stringify(
Object.fromEntries([
["token", exchange],
["expires_at", 2_000_000_000],
]),
),
new ReadableStream<Uint8Array>({
start(streamController) {
signal?.addEventListener("abort", () => streamController.error(signal.reason), {
once: true,
});
queueMicrotask(() => controller.abort(new DOMException("timed out", "TimeoutError")));
},
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
});
const resolve = (githubToken: string) =>
resolveCopilotApiToken({
githubToken,
fetchImpl: fetchImpl as typeof fetch,
openCacheStore: () => store,
try {
await expect(
resolveCopilotRuntimeAuth({
githubToken: "github-source-token",
fetchImpl: fetchImpl as typeof fetch,
}),
).rejects.toMatchObject({
name: "CopilotRuntimeAuthError",
reason: "timeout",
timeoutMs: 30_000,
});
const firstA = await resolve("source-a");
const firstB = await resolve("source-b");
const secondA = await resolve("source-a");
expect(firstA.token).toContain("exchange-source-a");
expect(firstB.token).toContain("exchange-source-b");
expect(secondA.token).toBe(firstA.token);
expect(secondA.source).toBe("cache:plugin-state");
expect(fetchImpl).toHaveBeenCalledTimes(2);
expect(register).toHaveBeenCalledTimes(2);
expect(values.size).toBe(2);
} finally {
timeoutSpy.mockRestore();
}
});
});
+4 -3
View File
@@ -3,7 +3,7 @@ import type {
ProviderResolveDynamicModelContext,
ProviderRuntimeModel,
} from "openclaw/plugin-sdk/core";
import { buildCopilotIdeHeaders, COPILOT_INTEGRATION_ID } from "openclaw/plugin-sdk/provider-auth";
import { buildCopilotIdeHeaders } from "openclaw/plugin-sdk/provider-auth";
import { readProviderJsonArrayFieldResponse } from "openclaw/plugin-sdk/provider-http";
import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-model-shared";
import {
@@ -19,6 +19,7 @@ import {
resolveCopilotTransportApi,
resolveStaticCopilotModelOverride,
} from "./model-metadata.js";
import { COPILOT_RUNTIME_INTEGRATION_ID } from "./runtime-identity.js";
export const PROVIDER_ID = "github-copilot";
@@ -231,7 +232,7 @@ function asCopilotApiModelEntry(value: unknown): CopilotApiModelEntry {
}
type FetchCopilotModelCatalogParams = {
/** Short-lived Copilot API token (from `resolveCopilotApiToken`). */
/** GitHub source token accepted by the account's Copilot API endpoint. */
copilotApiToken: string;
/** Resolved baseUrl from the same token-exchange response. */
baseUrl: string;
@@ -274,7 +275,7 @@ export async function fetchCopilotModelCatalog(
Accept: "application/json",
Authorization: `Bearer ${params.copilotApiToken}`,
...buildCopilotIdeHeaders(),
"Copilot-Integration-Id": COPILOT_INTEGRATION_ID,
"Copilot-Integration-Id": COPILOT_RUNTIME_INTEGRATION_ID,
},
signal: params.signal ?? controller?.signal,
});
@@ -6,8 +6,8 @@ import {
} from "openclaw/plugin-sdk/provider-auth";
import { githubCopilotLoginCommand } from "./login.js";
import { PROVIDER_ID, resolveCopilotForwardCompatModel } from "./models.js";
import { DEFAULT_COPILOT_API_BASE_URL, resolveCopilotRuntimeAuth } from "./runtime-auth.js";
import { wrapCopilotAnthropicStream, wrapCopilotProviderStream } from "./stream.js";
import { DEFAULT_COPILOT_API_BASE_URL, resolveCopilotApiToken } from "./token.js";
import { fetchCopilotUsage } from "./usage.js";
export {
@@ -18,7 +18,7 @@ export {
githubCopilotLoginCommand,
listProfilesForProvider,
PROVIDER_ID,
resolveCopilotApiToken,
resolveCopilotRuntimeAuth,
resolveCopilotForwardCompatModel,
wrapCopilotAnthropicStream,
wrapCopilotProviderStream,
@@ -1,13 +1,13 @@
// GitHub Copilot token exchange errors shared by runtime and fallback policy.
type CopilotTokenExchangeFailure =
// GitHub Copilot runtime-auth errors shared by provider setup and fallback policy.
type CopilotRuntimeAuthFailure =
| { reason: "http_error"; status: number }
| { reason: "timeout"; timeoutMs: number; cause?: unknown };
function buildCopilotTokenExchangeMessage(failure: CopilotTokenExchangeFailure): string {
function buildCopilotRuntimeAuthMessage(failure: CopilotRuntimeAuthFailure): string {
if (failure.reason === "timeout") {
return `Copilot token exchange failed: timed out after ${failure.timeoutMs}ms`;
return `Copilot authentication failed: timed out after ${failure.timeoutMs}ms`;
}
const message = `Copilot token exchange failed: HTTP ${failure.status}`;
const message = `Copilot authentication failed: HTTP ${failure.status}`;
if (failure.status !== 403) {
return message;
}
@@ -18,18 +18,18 @@ function buildCopilotTokenExchangeMessage(failure: CopilotTokenExchangeFailure):
);
}
export class CopilotTokenExchangeError extends Error {
readonly code = "github_copilot_token_exchange_failed";
readonly reason: CopilotTokenExchangeFailure["reason"];
export class CopilotRuntimeAuthError extends Error {
readonly code = "github_copilot_auth_failed";
readonly reason: CopilotRuntimeAuthFailure["reason"];
readonly status?: number;
readonly timeoutMs?: number;
constructor(failure: CopilotTokenExchangeFailure) {
constructor(failure: CopilotRuntimeAuthFailure) {
super(
buildCopilotTokenExchangeMessage(failure),
buildCopilotRuntimeAuthMessage(failure),
failure.reason === "timeout" ? { cause: failure.cause } : undefined,
);
this.name = "CopilotTokenExchangeError";
this.name = "CopilotRuntimeAuthError";
this.reason = failure.reason;
if (failure.reason === "http_error") {
this.status = failure.status;
+123
View File
@@ -0,0 +1,123 @@
// GitHub Copilot source-token validation and account endpoint resolution.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http";
import { PUBLIC_GITHUB_COPILOT_DOMAIN, resolveGithubCopilotDomain } from "./domain.js";
import { CopilotRuntimeAuthError } from "./runtime-auth-error.js";
export const DEFAULT_COPILOT_API_BASE_URL = "https://api.individual.githubcopilot.com";
const COPILOT_RUNTIME_AUTH_TIMEOUT_MS = 30_000;
function copilotUserUrl(domain: string): string {
return `https://api.${domain}/copilot_internal/user`;
}
function copilotApiBaseFallback(domain: string): string {
return domain === PUBLIC_GITHUB_COPILOT_DOMAIN
? DEFAULT_COPILOT_API_BASE_URL
: `https://copilot-api.${domain}`;
}
function isTrustedCopilotApiHost(host: string, domain: string): boolean {
if (host === "copilot-proxy.githubusercontent.com" || host.endsWith(".githubcopilot.com")) {
return true;
}
return (
domain !== PUBLIC_GITHUB_COPILOT_DOMAIN && (host === domain || host.endsWith(`.${domain}`))
);
}
function parseCopilotApiBaseUrl(value: unknown, domain: string): string {
if (!value || typeof value !== "object") {
throw new Error("Unexpected response from GitHub Copilot user endpoint");
}
const endpoints = (value as { endpoints?: unknown }).endpoints;
const api =
endpoints && typeof endpoints === "object" ? (endpoints as { api?: unknown }).api : undefined;
if (api === undefined || api === null || api === "") {
return copilotApiBaseFallback(domain);
}
if (typeof api !== "string" || !api.trim()) {
throw new Error("GitHub Copilot user response has an invalid endpoints.api URL");
}
let url: URL;
try {
url = new URL(api);
} catch {
throw new Error("GitHub Copilot user response has an invalid endpoints.api URL");
}
const host = url.hostname.toLowerCase();
if (
url.protocol !== "https:" ||
url.username ||
url.password ||
url.search ||
url.hash ||
!isTrustedCopilotApiHost(host, domain)
) {
throw new Error("GitHub Copilot user response has an untrusted endpoints.api URL");
}
return url.href.replace(/\/+$/, "");
}
async function cancelUnreadResponseBody(response: Response): Promise<void> {
if (!response.bodyUsed) {
await response.body?.cancel().catch(() => undefined);
}
}
export async function resolveCopilotRuntimeAuth(params: {
githubToken: string;
env?: NodeJS.ProcessEnv;
fetchImpl?: typeof fetch;
githubDomain?: string;
config?: OpenClawConfig;
}): Promise<{
apiKey: string;
source: string;
baseUrl: string;
}> {
const env = params.env ?? process.env;
const domain = resolveGithubCopilotDomain({
env,
explicit: params.githubDomain,
config: params.config,
});
const userUrl = copilotUserUrl(domain);
const fetchImpl = params.fetchImpl ?? fetch;
const signal = AbortSignal.timeout(COPILOT_RUNTIME_AUTH_TIMEOUT_MS);
try {
const response = await fetchImpl(userUrl, {
method: "GET",
headers: {
Accept: "application/json",
Authorization: `Bearer ${params.githubToken}`,
},
signal,
});
if (!response.ok) {
await cancelUnreadResponseBody(response);
throw new CopilotRuntimeAuthError({ reason: "http_error", status: response.status });
}
const baseUrl = parseCopilotApiBaseUrl(
await readProviderJsonResponse(response, "github-copilot.user"),
domain,
);
// The current Copilot CLI/SDK resolves account metadata through `/user`,
// then sends this original GitHub token to CAPI. The retired `/v2/token`
// exchange rejects supported fine-grained PATs before inference.
return {
apiKey: params.githubToken,
source: `validated:${userUrl}`,
baseUrl,
};
} catch (error) {
if (signal.aborted) {
throw new CopilotRuntimeAuthError({
reason: "timeout",
timeoutMs: COPILOT_RUNTIME_AUTH_TIMEOUT_MS,
cause: error,
});
}
throw error;
}
}
@@ -0,0 +1,3 @@
// GitHub's current fine-grained PAT contract is the Copilot CLI identity.
// Keep this provider-owned instead of changing the legacy public SDK constant.
export const COPILOT_RUNTIME_INTEGRATION_ID = "copilot-developer-cli";
+3 -2
View File
@@ -1,7 +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 { buildCopilotIdeHeaders } from "openclaw/plugin-sdk/provider-auth";
import { describe, expect, it, vi } from "vitest";
import { COPILOT_RUNTIME_INTEGRATION_ID } from "./runtime-identity.js";
import { wrapCopilotAnthropicStream, wrapCopilotProviderStream } from "./stream.js";
function requireStreamFn(streamFn: ReturnType<typeof wrapCopilotProviderStream>) {
@@ -30,7 +31,7 @@ function buildExpectedCopilotHeaders(
): Record<string, string> {
return {
...buildCopilotIdeHeaders(),
"Copilot-Integration-Id": COPILOT_INTEGRATION_ID,
"Copilot-Integration-Id": COPILOT_RUNTIME_INTEGRATION_ID,
"Openai-Organization": "github-copilot",
"x-initiator": initiator,
...(hasImages ? { "Copilot-Vision-Request": "true" } : {}),
+3 -2
View File
@@ -2,13 +2,14 @@
import type { StreamFn } from "openclaw/plugin-sdk/agent-core";
import type { Context } from "openclaw/plugin-sdk/llm";
import type { ProviderWrapStreamFnContext } from "openclaw/plugin-sdk/plugin-entry";
import { buildCopilotIdeHeaders, COPILOT_INTEGRATION_ID } from "openclaw/plugin-sdk/provider-auth";
import { buildCopilotIdeHeaders } from "openclaw/plugin-sdk/provider-auth";
import {
applyAnthropicEphemeralCacheControlMarkers,
streamWithPayloadPatch,
} from "openclaw/plugin-sdk/provider-stream-shared";
import { sanitizeCopilotReplayResponsePayload } from "./connection-bound-ids.js";
import { stripCopilotAssistantThinkingMessages } from "./replay-policy.js";
import { COPILOT_RUNTIME_INTEGRATION_ID } from "./runtime-identity.js";
type StreamOptions = Parameters<StreamFn>[2];
@@ -52,7 +53,7 @@ function buildCopilotDynamicHeaders(params: {
}): Record<string, string> {
return {
...buildCopilotIdeHeaders(),
"Copilot-Integration-Id": COPILOT_INTEGRATION_ID,
"Copilot-Integration-Id": COPILOT_RUNTIME_INTEGRATION_ID,
"Openai-Organization": "github-copilot",
"x-initiator": inferCopilotInitiator(params.messages),
...(params.hasImages ? { "Copilot-Vision-Request": "true" } : {}),
-91
View File
@@ -1,91 +0,0 @@
import { createHash } from "node:crypto";
import { asDateTimestampMs } from "openclaw/plugin-sdk/number-runtime";
import type { PluginStateSyncKeyedStore } from "openclaw/plugin-sdk/plugin-state-runtime";
import { COPILOT_INTEGRATION_ID } from "openclaw/plugin-sdk/provider-auth";
import { PUBLIC_GITHUB_COPILOT_DOMAIN } from "./domain.js";
export const COPILOT_TOKEN_CACHE_NAMESPACE = "token";
export const COPILOT_TOKEN_CACHE_MAX_ENTRIES = 8;
export type CachedCopilotToken = {
token: string;
expiresAt: number;
updatedAt: number;
integrationId?: string;
sourceCredentialFingerprint?: string;
domain?: string;
};
export function fingerprintCopilotSourceCredential(githubToken: string): string {
return createHash("sha256").update(githubToken).digest("hex");
}
export function isCopilotTokenUsable(params: {
cache: CachedCopilotToken;
domain: string;
sourceCredentialFingerprint: string;
now?: number;
}): boolean {
const expiresAt = asDateTimestampMs(params.cache.expiresAt);
// Pre-domain cache entries were public-only. Keep public upgrades warm while
// forcing tenant requests to exchange a tenant-scoped token.
const cacheDomain = params.cache.domain ?? PUBLIC_GITHUB_COPILOT_DOMAIN;
return (
params.cache.integrationId === COPILOT_INTEGRATION_ID &&
cacheDomain === params.domain &&
params.cache.sourceCredentialFingerprint === params.sourceCredentialFingerprint &&
expiresAt !== undefined &&
expiresAt - (params.now ?? Date.now()) > 5 * 60 * 1000
);
}
type CopilotTokenCache = {
path: string;
load(): CachedCopilotToken | undefined;
save(value: CachedCopilotToken): void;
};
export function resolveCopilotTokenCache(params: {
domain: string;
sourceCredentialFingerprint: string;
openCacheStore?: () => PluginStateSyncKeyedStore<CachedCopilotToken>;
cachePath?: string;
loadJsonFileImpl?: (path: string) => unknown;
saveJsonFileImpl?: (path: string, value: CachedCopilotToken) => void;
}): CopilotTokenCache {
const usesExplicitCacheAdapter =
params.cachePath !== undefined ||
params.loadJsonFileImpl !== undefined ||
params.saveJsonFileImpl !== undefined;
if (usesExplicitCacheAdapter) {
// Explicit file adapters are test/compat seams only. Runtime state is SQLite.
const cachePath = params.cachePath?.trim() || "explicit-cache";
const loadJsonFileFn = params.loadJsonFileImpl ?? (() => undefined);
const saveJsonFileFn = params.saveJsonFileImpl ?? (() => undefined);
return {
path: cachePath,
load: () => loadJsonFileFn(cachePath) as CachedCopilotToken | undefined,
save: (value) => saveJsonFileFn(cachePath, value),
};
}
const store = params.openCacheStore?.();
if (!store) {
// Direct live/tests may call the provider helper without plugin registration.
// They exchange normally but do not persist runtime state outside SQLite.
return {
path: "uncached",
load: () => undefined,
save: () => undefined,
};
}
const key = `${params.domain}:${params.sourceCredentialFingerprint}`;
return {
path: "plugin-state",
load: () => store.lookup(key),
save: (value) =>
store.register(key, value, {
ttlMs: Math.max(1, value.expiresAt - Date.now()),
}),
};
}
-187
View File
@@ -1,187 +0,0 @@
// GitHub Copilot credential exchange and cache policy.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import {
asDateTimestampMs,
parseStrictNonNegativeInteger,
resolveExpiresAtMsFromEpochSeconds,
} from "openclaw/plugin-sdk/number-runtime";
import type { PluginStateSyncKeyedStore } from "openclaw/plugin-sdk/plugin-state-runtime";
import {
buildCopilotIdeHeaders,
COPILOT_INTEGRATION_ID,
deriveCopilotApiBaseUrlFromToken,
} from "openclaw/plugin-sdk/provider-auth";
import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http";
import { PUBLIC_GITHUB_COPILOT_DOMAIN, resolveGithubCopilotDomain } from "./domain.js";
import {
fingerprintCopilotSourceCredential,
isCopilotTokenUsable,
resolveCopilotTokenCache,
type CachedCopilotToken,
} from "./token-cache.js";
import { CopilotTokenExchangeError } from "./token-exchange-error.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;
/** Bind provider-scoped SQLite state when the bundled plugin registers. */
export function configureCopilotTokenCacheStore(
openCacheStore: () => PluginStateSyncKeyedStore<CachedCopilotToken>,
): void {
openConfiguredCacheStore = openCacheStore;
}
function copilotTokenUrl(domain: string): string {
return `https://api.${domain}/copilot_internal/v2/token`;
}
function copilotApiBaseFallback(domain: string): string {
return domain === PUBLIC_GITHUB_COPILOT_DOMAIN
? DEFAULT_COPILOT_API_BASE_URL
: `https://copilot-api.${domain}`;
}
function resolveCopilotTokenExpiresAtMs(expiresAt: unknown): number | undefined {
const parsed =
typeof expiresAt === "number" && Number.isFinite(expiresAt)
? expiresAt
: typeof expiresAt === "string" && expiresAt.trim().length > 0
? parseStrictNonNegativeInteger(expiresAt)
: undefined;
if (parsed === undefined) {
return undefined;
}
return parsed < 100_000_000_000
? resolveExpiresAtMsFromEpochSeconds(parsed)
: asDateTimestampMs(parsed);
}
function parseCopilotTokenResponse(value: unknown): { token: string; expiresAt: number } {
if (!value || typeof value !== "object") {
throw new Error("Unexpected response from GitHub Copilot token endpoint");
}
const record = value as Record<string, unknown>;
const { token: credential, expires_at: expiresAt } = record;
if (typeof credential !== "string" || credential.trim().length === 0) {
throw new Error("Copilot token response missing token");
}
if (
expiresAt === undefined ||
expiresAt === null ||
(typeof expiresAt === "string" && expiresAt.trim().length === 0)
) {
throw new Error("Copilot token response missing expires_at");
}
const expiresAtMs = resolveCopilotTokenExpiresAtMs(expiresAt);
if (expiresAtMs === undefined) {
throw new Error("Copilot token response has invalid expires_at");
}
return { token: credential, expiresAt: expiresAtMs };
}
async function cancelUnreadResponseBody(response: Response): Promise<void> {
if (!response.bodyUsed) {
await response.body?.cancel().catch(() => undefined);
}
}
export async function resolveCopilotApiToken(params: {
githubToken: string;
env?: NodeJS.ProcessEnv;
fetchImpl?: typeof fetch;
cachePath?: string;
loadJsonFileImpl?: (path: string) => unknown;
saveJsonFileImpl?: (path: string, value: CachedCopilotToken) => void;
openCacheStore?: () => PluginStateSyncKeyedStore<CachedCopilotToken>;
githubDomain?: string;
config?: OpenClawConfig;
}): Promise<{
token: string;
expiresAt: number;
source: string;
baseUrl: string;
}> {
const env = params.env ?? process.env;
const domain = resolveGithubCopilotDomain({
env,
explicit: params.githubDomain,
config: params.config,
});
const tokenUrl = copilotTokenUrl(domain);
const apiBaseFallback = copilotApiBaseFallback(domain);
const sourceCredentialFingerprint = fingerprintCopilotSourceCredential(params.githubToken);
const cache = resolveCopilotTokenCache({
domain,
sourceCredentialFingerprint,
...(params.openCacheStore || openConfiguredCacheStore
? { openCacheStore: params.openCacheStore ?? openConfiguredCacheStore }
: {}),
...(params.cachePath !== undefined ? { cachePath: params.cachePath } : {}),
...(params.loadJsonFileImpl ? { loadJsonFileImpl: params.loadJsonFileImpl } : {}),
...(params.saveJsonFileImpl ? { saveJsonFileImpl: params.saveJsonFileImpl } : {}),
});
const cached = cache.load();
if (
cached &&
typeof cached.token === "string" &&
typeof cached.expiresAt === "number" &&
isCopilotTokenUsable({ cache: cached, domain, sourceCredentialFingerprint })
) {
const { token: credential } = cached;
return {
token: credential,
expiresAt: cached.expiresAt,
source: `cache:${cache.path}`,
baseUrl: deriveCopilotApiBaseUrlFromToken(cached.token) ?? apiBaseFallback,
};
}
const fetchImpl = params.fetchImpl ?? fetch;
const signal = AbortSignal.timeout(COPILOT_TOKEN_EXCHANGE_TIMEOUT_MS);
let payload: ReturnType<typeof parseCopilotTokenResponse>;
try {
const response = await fetchImpl(tokenUrl, {
method: "GET",
headers: {
Accept: "application/json",
Authorization: `Bearer ${params.githubToken}`,
"Copilot-Integration-Id": COPILOT_INTEGRATION_ID,
...buildCopilotIdeHeaders({ includeApiVersion: true }),
},
signal,
});
if (!response.ok) {
await cancelUnreadResponseBody(response);
throw new CopilotTokenExchangeError({ reason: "http_error", status: response.status });
}
payload = parseCopilotTokenResponse(
await readProviderJsonResponse(response, "github-copilot.token"),
);
} catch (error) {
if (signal.aborted && error === signal.reason) {
throw new CopilotTokenExchangeError({
reason: "timeout",
timeoutMs: COPILOT_TOKEN_EXCHANGE_TIMEOUT_MS,
cause: error,
});
}
throw error;
}
const cachedPayload: CachedCopilotToken = {
token: payload.token,
expiresAt: payload.expiresAt,
updatedAt: Date.now(),
integrationId: COPILOT_INTEGRATION_ID,
sourceCredentialFingerprint,
domain,
};
cache.save(cachedPayload);
const { token: credential } = cachedPayload;
return {
token: credential,
expiresAt: cachedPayload.expiresAt,
source: `fetched:${tokenUrl}`,
baseUrl: deriveCopilotApiBaseUrlFromToken(cachedPayload.token) ?? apiBaseFallback,
};
}
@@ -1695,7 +1695,7 @@ describe("compaction-safeguard recent-turn preservation", () => {
const summaryCall = latestMockCallArg(mockSummarizeInStages) as {
headers?: Record<string, string>;
};
expect(summaryCall.headers?.["Copilot-Integration-Id"]).toBe("vscode-chat");
expect(summaryCall.headers?.["Copilot-Integration-Id"]).toBe("copilot-developer-cli");
expect(summaryCall.headers?.["Editor-Plugin-Version"]).toBe("copilot-chat/0.35.0");
expect(summaryCall.headers?.["Openai-Organization"]).toBe("github-copilot");
expect(summaryCall.headers?.["User-Agent"]).toBe("GitHubCopilotChat/0.35.0");
+4 -1
View File
@@ -13,6 +13,9 @@ export const COPILOT_EDITOR_PLUGIN_VERSION = "copilot-chat/0.35.0";
export const COPILOT_GITHUB_API_VERSION = "2025-04-01";
/** @deprecated GitHub Copilot provider-owned helper; do not use from third-party plugins. */
export const COPILOT_INTEGRATION_ID = "vscode-chat";
// Current GitHub fine-grained PATs are accepted by CAPI under the Copilot CLI
// identity. Keep this private from the plugin SDK's legacy VS Code contract.
export const COPILOT_RUNTIME_INTEGRATION_ID = "copilot-developer-cli";
/** @deprecated GitHub Copilot provider-owned helper; do not use from third-party plugins. */
export function buildCopilotIdeHeaders(
@@ -71,7 +74,7 @@ export function buildCopilotDynamicHeaders(params: {
}): Record<string, string> {
return {
...buildCopilotIdeHeaders(),
"Copilot-Integration-Id": COPILOT_INTEGRATION_ID,
"Copilot-Integration-Id": COPILOT_RUNTIME_INTEGRATION_ID,
"Openai-Organization": "github-copilot",
"x-initiator": inferCopilotInitiator(params.messages),
...(params.hasImages ? { "Copilot-Vision-Request": "true" } : {}),
@@ -229,7 +229,10 @@ vi.mock("./openrouter-model-capabilities.js", () => ({
}));
import type { OpenClawConfig, OpenClawConfigInput } from "../../config/config.js";
import { COPILOT_INTEGRATION_ID, buildCopilotIdeHeaders } from "../copilot-dynamic-headers.js";
import {
buildCopilotIdeHeaders,
COPILOT_RUNTIME_INTEGRATION_ID,
} from "../copilot-dynamic-headers.js";
import { getModelProviderLocalService } from "../provider-local-service.js";
import { getModelProviderRequestTransport } from "../provider-request-config.js";
import { buildForwardCompatTemplate } from "./model.forward-compat.test-support.js";
@@ -2440,7 +2443,7 @@ describe("resolveModel", () => {
expect(model.headers).toEqual({
...buildCopilotIdeHeaders(),
"Copilot-Integration-Id": COPILOT_INTEGRATION_ID,
"Copilot-Integration-Id": COPILOT_RUNTIME_INTEGRATION_ID,
"Openai-Organization": "github-copilot",
});
});
@@ -2463,7 +2466,7 @@ describe("resolveModel", () => {
expect(model.headers).toEqual({
...buildCopilotIdeHeaders(),
"Copilot-Integration-Id": COPILOT_INTEGRATION_ID,
"Copilot-Integration-Id": COPILOT_RUNTIME_INTEGRATION_ID,
"Openai-Organization": "github-copilot",
});
});
+5 -2
View File
@@ -12,7 +12,10 @@ import type {
import { assertSecretInputResolved } from "../config/types.secrets.js";
import type { PinnedDispatcherPolicy } from "../infra/net/ssrf.js";
import type { Api } from "../llm/types.js";
import { COPILOT_INTEGRATION_ID, buildCopilotIdeHeaders } from "./copilot-dynamic-headers.js";
import {
buildCopilotIdeHeaders,
COPILOT_RUNTIME_INTEGRATION_ID,
} from "./copilot-dynamic-headers.js";
import type {
ProviderRequestCapabilities,
ProviderRequestCapability,
@@ -427,7 +430,7 @@ function resolveProviderDefaultRequestHeaders(
}
return {
...buildCopilotIdeHeaders(),
"Copilot-Integration-Id": COPILOT_INTEGRATION_ID,
"Copilot-Integration-Id": COPILOT_RUNTIME_INTEGRATION_ID,
"Openai-Organization": "github-copilot",
};
}
@@ -295,6 +295,10 @@ describe("GitHub Copilot OAuth model routing", () => {
} as OAuthCredentials;
}
it("exposes the durable GitHub token to provider runtime auth", () => {
expect(githubCopilotOAuthProvider.getApiKey(credential({}))).toBe("refresh-token");
});
it("drops github-copilot models for an unsupported persisted enterprise domain", () => {
const result = githubCopilotOAuthProvider.modifyModels?.(
models,
+3 -1
View File
@@ -573,7 +573,9 @@ export const githubCopilotOAuthProvider: OAuthProviderInterface = {
},
getApiKey(credentials: OAuthCredentials): string {
return credentials.access;
// The provider runtime now authenticates directly with the durable GitHub
// credential; the short-lived Copilot access token is legacy profile state.
return credentials.refresh;
},
modifyModels(models: Model[], credentials: OAuthCredentials): Model[] {
@@ -170,7 +170,7 @@ vi.mock("../plugin-sdk/provider-auth.js", () => ({
"Editor-Version": "vscode/1.107.0",
"User-Agent": "GitHubCopilotChat/0.35.0",
}),
COPILOT_INTEGRATION_ID: "vscode-chat",
COPILOT_INTEGRATION_ID: "copilot-developer-cli",
}));
const imageTestFetchWithSsrFGuardMock = vi.hoisted(() => vi.fn());
@@ -502,7 +502,7 @@ describe("describeImageWithModel", () => {
expect(completionModel.baseUrl).toBe("https://api.githubcopilot.com");
expect(options.apiKey).toBe(storedValue);
expect(options.headers).toMatchObject({
"Copilot-Integration-Id": "vscode-chat",
"Copilot-Integration-Id": "copilot-developer-cli",
"Copilot-Vision-Request": "true",
"Editor-Version": "vscode/1.107.0",
"User-Agent": "GitHubCopilotChat/0.35.0",
@@ -159,7 +159,7 @@ vi.mock("../plugin-sdk/provider-auth.js", () => ({
"Editor-Version": "vscode/1.107.0",
"User-Agent": "GitHubCopilotChat/0.35.0",
}),
COPILOT_INTEGRATION_ID: "vscode-chat",
COPILOT_INTEGRATION_ID: "copilot-developer-cli",
}));
const imageTestFetchWithSsrFGuardMock = vi.hoisted(() => vi.fn());
+1 -1
View File
@@ -173,7 +173,7 @@ vi.mock("../plugin-sdk/provider-auth.js", () => ({
"Editor-Version": "vscode/1.107.0",
"User-Agent": "GitHubCopilotChat/0.35.0",
}),
COPILOT_INTEGRATION_ID: "vscode-chat",
COPILOT_INTEGRATION_ID: "copilot-developer-cli",
}));
const imageTestFetchWithSsrFGuardMock = vi.hoisted(() => vi.fn());
+3 -2
View File
@@ -2,6 +2,7 @@
// provider hook.
import { clampPositiveTimerTimeoutMs } from "@openclaw/normalization-core/number-coercion";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { COPILOT_RUNTIME_INTEGRATION_ID } from "../agents/copilot-dynamic-headers.js";
import { isMinimaxVlmModel, minimaxUnderstandImage } from "../agents/minimax-vlm.js";
import { requireApiKey, resolveApiKeyForProvider } from "../agents/model-auth.js";
import { resolveProviderRequestCapabilities } from "../agents/provider-attribution.js";
@@ -18,7 +19,7 @@ import {
import { isSecretRef } from "../config/types.secrets.js";
import { complete } from "../llm/stream.js";
import type { AssistantMessage, Context, Model, ProviderStreamOptions } from "../llm/types.js";
import { buildCopilotIdeHeaders, COPILOT_INTEGRATION_ID } from "../plugin-sdk/provider-auth.js";
import { buildCopilotIdeHeaders } from "../plugin-sdk/provider-auth.js";
import { getResolvedImageRuntimeContext, resolveImageRuntime } from "./image-model-runtime.js";
import { normalizeMediaProviderId } from "./provider-id.js";
import type {
@@ -177,7 +178,7 @@ function buildImageRequestHeaders(model: Model): Record<string, string> | undefi
}
return {
...buildCopilotIdeHeaders(),
"Copilot-Integration-Id": COPILOT_INTEGRATION_ID,
"Copilot-Integration-Id": COPILOT_RUNTIME_INTEGRATION_ID,
"Openai-Organization": "github-copilot",
"x-initiator": "user",
"Copilot-Vision-Request": "true",