diff --git a/docs/providers/github-copilot.md b/docs/providers/github-copilot.md index f854460449a3..ccb091282035 100644 --- a/docs/providers/github-copilot.md +++ b/docs/providers/github-copilot.md @@ -333,6 +333,11 @@ configured default model is never replaced. resolves the account-specific API endpoint, and uses the stored GitHub token for Copilot requests. You do not need to manage runtime authentication manually. + + Usage checks also use the selected profile's GitHub token. For OAuth profiles + that carry a tenant domain, usage follows that domain before the provider's + configured domain. `COPILOT_GITHUB_DOMAIN` still takes precedence. + diff --git a/extensions/github-copilot/index.ts b/extensions/github-copilot/index.ts index 063e465fb6c0..3f5b6306568d 100644 --- a/extensions/github-copilot/index.ts +++ b/extensions/github-copilot/index.ts @@ -725,12 +725,17 @@ export default definePluginEntry({ }, resolveUsageAuth: async (ctx) => await ctx.resolveOAuthToken(), fetchUsageSnapshot: async (ctx) => { + const source = parseGithubCopilotApiKey(ctx.token); const { fetchCopilotUsage } = await loadGithubCopilotRuntime(); return await fetchCopilotUsage( - ctx.token, + source.githubToken, ctx.timeoutMs, ctx.fetchFn, - resolveGithubCopilotDomain({ env: ctx.env, config: ctx.config }), + resolveGithubCopilotDomain({ + env: ctx.env, + explicit: source.githubDomain, + config: ctx.config, + }), ); }, }); diff --git a/extensions/github-copilot/usage.test.ts b/extensions/github-copilot/usage.test.ts new file mode 100644 index 000000000000..f47e4134c05b --- /dev/null +++ b/extensions/github-copilot/usage.test.ts @@ -0,0 +1,129 @@ +import { expectDefined } from "@openclaw/normalization-core"; +import type { OpenClawConfig, OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry"; +import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api"; +import { describe, expect, it, vi } from "vitest"; +import plugin from "./index.js"; + +function registerProvider() { + const registerProviderMock = vi.fn(); + plugin.register(createTestPluginApi({ registerProvider: registerProviderMock })); + return expectDefined(registerProviderMock.mock.calls[0]?.[0], "Copilot provider registration"); +} + +describe("GitHub Copilot usage credential routing", () => { + it.each([ + { label: "plain public token", expectedDomain: "github.com" }, + { + label: "plain token with configured tenant", + configuredDomain: "config.ghe.com", + expectedDomain: "config.ghe.com", + }, + { + label: "public OAuth metadata", + credentialDomain: "github.com", + expectedDomain: "github.com", + }, + { + label: "OAuth tenant without provider config", + credentialDomain: "account.ghe.com", + expectedDomain: "account.ghe.com", + }, + { + label: "OAuth tenant before provider config", + credentialDomain: "account.ghe.com", + configuredDomain: "config.ghe.com", + expectedDomain: "account.ghe.com", + }, + { + label: "public OAuth before provider config", + credentialDomain: "github.com", + configuredDomain: "config.ghe.com", + expectedDomain: "github.com", + }, + { + label: "environment override before OAuth and config", + credentialDomain: "account.ghe.com", + configuredDomain: "config.ghe.com", + envDomain: "override.ghe.com", + expectedDomain: "override.ghe.com", + }, + ])("uses the raw token and correct host for $label", async (testCase) => { + const provider = registerProvider(); + const token = testCase.credentialDomain + ? expectDefined( + provider.formatApiKey, + "Copilot OAuth formatter", + )({ + type: "oauth", + provider: "github-copilot", + refresh: "durable-token", + access: "old-access", + expires: 0, + enterpriseUrl: testCase.credentialDomain, + }) + : "durable-token"; + const config: OpenClawConfig = testCase.configuredDomain + ? { + models: { + providers: { + "github-copilot": { + baseUrl: "https://api.githubcopilot.com", + models: [], + params: { githubDomain: testCase.configuredDomain }, + }, + }, + }, + } + : {}; + const fetchFn = vi.fn(async (input, init) => { + const request = new Request(input, init); + expect(request.url).toBe(`https://api.${testCase.expectedDomain}/copilot_internal/user`); + expect(request.headers.get("authorization")).toBe("token durable-token"); + return Response.json({ + copilot_plan: "business", + quota_snapshots: { premium_interactions: { percent_remaining: 75 } }, + }); + }); + + const result = await expectDefined( + provider.fetchUsageSnapshot, + "Copilot usage hook", + )({ + config, + env: testCase.envDomain ? { COPILOT_GITHUB_DOMAIN: testCase.envDomain } : {}, + provider: "github-copilot", + token, + timeoutMs: 5000, + fetchFn, + }); + + expect(fetchFn).toHaveBeenCalledOnce(); + expect(result).toMatchObject({ + provider: "github-copilot", + plan: "business", + windows: [{ label: "Premium", usedPercent: 25 }], + }); + }); + + it.each([ + "openclaw-github-copilot-oauth:v1:invalid-json", + 'openclaw-github-copilot-oauth:v1:{"token":"durable-token","githubDomain":"attacker.example"}', + ])("rejects invalid credential metadata before sending a request", async (token) => { + const provider = registerProvider(); + const fetchFn = vi.fn(); + await expect( + expectDefined( + provider.fetchUsageSnapshot, + "Copilot usage hook", + )({ + config: {}, + env: {}, + provider: "github-copilot", + token, + timeoutMs: 5000, + fetchFn, + }), + ).rejects.toThrow("Invalid GitHub Copilot legacy OAuth credential metadata"); + expect(fetchFn).not.toHaveBeenCalled(); + }); +});