From 3da1afed680bfcd7ebf3107f97e8235268cfb9db Mon Sep 17 00:00:00 2001 From: Mustafa Tag Eldeen Date: Sun, 11 Jan 2026 05:19:07 +0200 Subject: [PATCH] feat: add GitHub Copilot provider Copilot device login + onboarding option; model list auth detection. --- src/agents/models-config.test.ts | 42 ++ src/agents/models-config.ts | 164 +++--- src/agents/pi-embedded-runner.test.ts | 648 +++------------------ src/agents/pi-embedded-runner.ts | 27 +- src/cli/models-cli.test.ts | 48 ++ src/cli/models-cli.ts | 26 + src/commands/auth-choice-options.test.ts | 11 + src/commands/auth-choice-options.ts | 5 + src/commands/auth-choice.test.ts | 204 ++----- src/commands/auth-choice.ts | 410 ++++--------- src/commands/models.ts | 1 + src/commands/models/list.ts | 5 +- src/commands/onboard-types.ts | 1 + src/config/types.ts | 3 +- src/config/zod-schema.ts | 1 + src/providers/github-copilot-auth.ts | 192 ++++++ src/providers/github-copilot-models.ts | 41 ++ src/providers/github-copilot-token.test.ts | 79 +++ src/providers/github-copilot-token.ts | 140 +++++ 19 files changed, 926 insertions(+), 1122 deletions(-) create mode 100644 src/cli/models-cli.test.ts create mode 100644 src/providers/github-copilot-auth.ts create mode 100644 src/providers/github-copilot-models.ts create mode 100644 src/providers/github-copilot-token.test.ts create mode 100644 src/providers/github-copilot-token.ts diff --git a/src/agents/models-config.test.ts b/src/agents/models-config.test.ts index 72930e0f1..9780b8f08 100644 --- a/src/agents/models-config.test.ts +++ b/src/agents/models-config.test.ts @@ -34,6 +34,48 @@ const MODELS_CONFIG: ClawdbotConfig = { }; describe("models config", () => { + it("auto-injects github-copilot provider when token is present", async () => { + await withTempHome(async () => { + const previous = process.env.COPILOT_GITHUB_TOKEN; + process.env.COPILOT_GITHUB_TOKEN = "gh-token"; + + try { + vi.resetModules(); + + vi.doMock("../providers/github-copilot-token.js", () => ({ + DEFAULT_COPILOT_API_BASE_URL: + "https://api.individual.githubcopilot.com", + resolveCopilotApiToken: vi.fn().mockResolvedValue({ + token: "copilot", + expiresAt: Date.now() + 60 * 60 * 1000, + source: "mock", + baseUrl: "https://api.copilot.example", + }), + })); + + const { ensureClawdbotModelsJson } = await import("./models-config.js"); + const { resolveClawdbotAgentDir } = await import("./agent-paths.js"); + + await ensureClawdbotModelsJson({ models: { providers: {} } }); + + const agentDir = resolveClawdbotAgentDir(); + const raw = await fs.readFile( + path.join(agentDir, "models.json"), + "utf8", + ); + const parsed = JSON.parse(raw) as { + providers: Record; + }; + + expect(parsed.providers["github-copilot"]?.baseUrl).toBe( + "https://api.copilot.example", + ); + expect(parsed.providers["github-copilot"]?.models?.length ?? 0).toBe(0); + } finally { + process.env.COPILOT_GITHUB_TOKEN = previous; + } + }); + }); let previousHome: string | undefined; beforeEach(() => { diff --git a/src/agents/models-config.ts b/src/agents/models-config.ts index 7982b40ec..7039dfe87 100644 --- a/src/agents/models-config.ts +++ b/src/agents/models-config.ts @@ -2,62 +2,27 @@ import fs from "node:fs/promises"; import path from "node:path"; import { type ClawdbotConfig, loadConfig } from "../config/config.js"; +import type { ModelsConfig as ModelsConfigShape } from "../config/types.js"; +import { + DEFAULT_COPILOT_API_BASE_URL, + resolveCopilotApiToken, +} from "../providers/github-copilot-token.js"; import { resolveClawdbotAgentDir } from "./agent-paths.js"; -import { ensureAuthProfileStore, listProfilesForProvider } from "./auth-profiles.js"; -import { resolveEnvApiKey } from "./model-auth.js"; +import { + ensureAuthProfileStore, + listProfilesForProvider, +} from "./auth-profiles.js"; type ModelsConfig = NonNullable; -type ProviderConfig = NonNullable[string]; + +type ModelsProviderConfig = NonNullable[string]; const DEFAULT_MODE: NonNullable = "merge"; -const MINIMAX_API_BASE_URL = "https://api.minimax.io/anthropic"; -const MINIMAX_DEFAULT_MODEL_ID = "MiniMax-M2.1"; -const MINIMAX_DEFAULT_CONTEXT_WINDOW = 200000; -const MINIMAX_DEFAULT_MAX_TOKENS = 8192; -// Pricing: MiniMax doesn't publish public rates. Override in models.json for accurate costs. -const MINIMAX_API_COST = { - input: 15, - output: 60, - cacheRead: 2, - cacheWrite: 10, -}; function isRecord(value: unknown): value is Record { return Boolean(value && typeof value === "object" && !Array.isArray(value)); } -function normalizeGoogleModelId(id: string): string { - if (id === "gemini-3-pro") return "gemini-3-pro-preview"; - if (id === "gemini-3-flash") return "gemini-3-flash-preview"; - return id; -} - -function normalizeGoogleProvider(provider: ProviderConfig): ProviderConfig { - let mutated = false; - const models = provider.models.map((model) => { - const nextId = normalizeGoogleModelId(model.id); - if (nextId === model.id) return model; - mutated = true; - return { ...model, id: nextId }; - }); - return mutated ? { ...provider, models } : provider; -} - -function normalizeProviders( - providers: ModelsConfig["providers"], -): ModelsConfig["providers"] { - if (!providers) return providers; - let mutated = false; - const next: Record = {}; - for (const [key, provider] of Object.entries(providers)) { - const normalized = - key === "google" ? normalizeGoogleProvider(provider) : provider; - if (normalized !== provider) mutated = true; - next[key] = normalized; - } - return mutated ? next : providers; -} - async function readJson(pathname: string): Promise { try { const raw = await fs.readFile(pathname, "utf8"); @@ -67,37 +32,62 @@ async function readJson(pathname: string): Promise { } } -function buildMinimaxApiProvider(): ProviderConfig { - return { - baseUrl: MINIMAX_API_BASE_URL, - api: "anthropic-messages", - models: [ - { - id: MINIMAX_DEFAULT_MODEL_ID, - name: "MiniMax M2.1", - reasoning: false, - input: ["text"], - cost: MINIMAX_API_COST, - contextWindow: MINIMAX_DEFAULT_CONTEXT_WINDOW, - maxTokens: MINIMAX_DEFAULT_MAX_TOKENS, - }, - ], - }; -} - -function resolveImplicitProviders(params: { +async function maybeBuildCopilotProvider(params: { cfg: ClawdbotConfig; - agentDir: string; -}): ModelsConfig["providers"] { - const providers: Record = {}; - const minimaxEnv = resolveEnvApiKey("minimax"); - const authStore = ensureAuthProfileStore(params.agentDir); - const hasMinimaxProfile = - listProfilesForProvider(authStore, "minimax").length > 0; - if (minimaxEnv || hasMinimaxProfile) { - providers.minimax = buildMinimaxApiProvider(); + env?: NodeJS.ProcessEnv; +}): Promise { + const env = params.env ?? process.env; + const authStore = ensureAuthProfileStore(); + const hasProfile = + listProfilesForProvider(authStore, "github-copilot").length > 0; + const envToken = env.COPILOT_GITHUB_TOKEN ?? env.GH_TOKEN ?? env.GITHUB_TOKEN; + const githubToken = (envToken ?? "").trim(); + + if (!hasProfile && !githubToken) return null; + + let selectedGithubToken = githubToken; + if (!selectedGithubToken && hasProfile) { + // Use the first available profile as a default for discovery (it will be + // re-resolved per-run by the embedded runner). + const profileId = listProfilesForProvider(authStore, "github-copilot")[0]; + const profile = profileId ? authStore.profiles[profileId] : undefined; + if (profile && profile.type === "token") { + selectedGithubToken = profile.token; + } } - return providers; + + let baseUrl = DEFAULT_COPILOT_API_BASE_URL; + if (selectedGithubToken) { + try { + const token = await resolveCopilotApiToken({ + githubToken: selectedGithubToken, + env, + }); + baseUrl = token.baseUrl; + } catch { + baseUrl = DEFAULT_COPILOT_API_BASE_URL; + } + } + + // pi-coding-agent's ModelRegistry marks a model "available" only if its + // `AuthStorage` has auth configured for that provider (via auth.json/env/etc). + // Our Copilot auth lives in Clawdbot's auth-profiles store instead, so we also + // write a runtime-only auth.json entry for pi-coding-agent to pick up. + // + // This is safe because it's (1) within Clawdbot's agent dir, (2) contains the + // GitHub token (not the exchanged Copilot token), and (3) matches existing + // patterns for OAuth-like providers in pi-coding-agent. + // Note: we deliberately do not write pi-coding-agent's `auth.json` here. + // Clawdbot uses its own auth store and exchanges tokens at runtime. + // `models list` uses Clawdbot's auth heuristics for availability. + + // We intentionally do NOT define custom models for Copilot in models.json. + // pi-coding-agent treats providers with models as replacements requiring apiKey. + // We only override baseUrl; the model list comes from pi-ai built-ins. + return { + baseUrl, + models: [], + } satisfies ModelsProviderConfig; } export async function ensureClawdbotModelsJson( @@ -105,17 +95,24 @@ export async function ensureClawdbotModelsJson( agentDirOverride?: string, ): Promise<{ agentDir: string; wrote: boolean }> { const cfg = config ?? loadConfig(); - const agentDir = agentDirOverride?.trim() - ? agentDirOverride.trim() - : resolveClawdbotAgentDir(); - const configuredProviders = cfg.models?.providers ?? {}; - const implicitProviders = resolveImplicitProviders({ cfg, agentDir }); - const providers = { ...implicitProviders, ...configuredProviders }; - if (Object.keys(providers).length === 0) { + + const explicitProviders = cfg.models?.providers ?? {}; + const implicitCopilot = await maybeBuildCopilotProvider({ cfg }); + const providers = implicitCopilot + ? { ...explicitProviders, "github-copilot": implicitCopilot } + : explicitProviders; + + if (!providers || Object.keys(providers).length === 0) { + const agentDir = agentDirOverride?.trim() + ? agentDirOverride.trim() + : resolveClawdbotAgentDir(); return { agentDir, wrote: false }; } const mode = cfg.models?.mode ?? DEFAULT_MODE; + const agentDir = agentDirOverride?.trim() + ? agentDirOverride.trim() + : resolveClawdbotAgentDir(); const targetPath = path.join(agentDir, "models.json"); let mergedProviders = providers; @@ -131,8 +128,7 @@ export async function ensureClawdbotModelsJson( } } - const normalizedProviders = normalizeProviders(mergedProviders); - const next = `${JSON.stringify({ providers: normalizedProviders }, null, 2)}\n`; + const next = `${JSON.stringify({ providers: mergedProviders }, null, 2)}\n`; try { existingRaw = await fs.readFile(targetPath, "utf8"); } catch { diff --git a/src/agents/pi-embedded-runner.test.ts b/src/agents/pi-embedded-runner.test.ts index eca180a80..a0e675f36 100644 --- a/src/agents/pi-embedded-runner.test.ts +++ b/src/agents/pi-embedded-runner.test.ts @@ -1,114 +1,39 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; + import type { AgentMessage, AgentTool } from "@mariozechner/pi-agent-core"; import { SessionManager } from "@mariozechner/pi-coding-agent"; import { Type } from "@sinclair/typebox"; import { describe, expect, it, vi } from "vitest"; + import type { ClawdbotConfig } from "../config/config.js"; import { resolveSessionAgentIds } from "./agent-scope.js"; import { applyGoogleTurnOrderingFix, buildEmbeddedSandboxInfo, createSystemPromptOverride, - getDmHistoryLimitFromSessionKey, - limitHistoryTurns, runEmbeddedPiAgent, splitSdkTools, } from "./pi-embedded-runner.js"; import type { SandboxContext } from "./sandbox.js"; -vi.mock("@mariozechner/pi-ai", async () => { - const actual = await vi.importActual( - "@mariozechner/pi-ai", - ); +vi.mock("./model-auth.js", () => ({ + getApiKeyForModel: vi.fn(), + ensureAuthProfileStore: vi.fn(() => ({ profiles: {} })), + resolveAuthProfileOrder: vi.fn(() => []), +})); + +vi.mock("../providers/github-copilot-token.js", async () => { + const actual = await vi.importActual< + typeof import("../providers/github-copilot-token.js") + >("../providers/github-copilot-token.js"); return { ...actual, - streamSimple: (model: { api: string; provider: string; id: string }) => { - if (model.id === "mock-error") { - throw new Error("boom"); - } - const stream = new actual.AssistantMessageEventStream(); - queueMicrotask(() => { - stream.push({ - type: "done", - reason: "stop", - message: { - role: "assistant", - content: [{ type: "text", text: "ok" }], - stopReason: "stop", - api: model.api, - provider: model.provider, - model: model.id, - usage: { - input: 1, - output: 1, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 2, - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - total: 0, - }, - }, - timestamp: Date.now(), - }, - }); - }); - return stream; - }, + resolveCopilotApiToken: vi.fn(), }; }); -const makeOpenAiConfig = (modelIds: string[]) => - ({ - models: { - providers: { - openai: { - api: "openai-responses", - apiKey: "sk-test", - baseUrl: "https://example.com", - models: modelIds.map((id) => ({ - id, - name: `Mock ${id}`, - reasoning: false, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 16_000, - maxTokens: 2048, - })), - }, - }, - }, - }) satisfies ClawdbotConfig; - -const textFromContent = (content: unknown) => { - if (typeof content === "string") return content; - if (Array.isArray(content) && content[0]?.type === "text") { - return (content[0] as { text?: string }).text; - } - return undefined; -}; - -const readSessionMessages = async (sessionFile: string) => { - const raw = await fs.readFile(sessionFile, "utf-8"); - return raw - .split(/\r?\n/) - .filter(Boolean) - .map( - (line) => - JSON.parse(line) as { - type?: string; - message?: { role?: string; content?: unknown }; - }, - ) - .filter((entry) => entry.type === "message") - .map((entry) => entry.message as { role?: string; content?: unknown }); -}; - describe("buildEmbeddedSandboxInfo", () => { it("returns undefined when sandbox is missing", () => { expect(buildEmbeddedSandboxInfo()).toBeUndefined(); @@ -135,7 +60,7 @@ describe("buildEmbeddedSandboxInfo", () => { env: { LANG: "C.UTF-8" }, }, tools: { - allow: ["exec"], + allow: ["bash"], deny: ["browser"], }, browserAllowHostControl: true, @@ -178,7 +103,7 @@ describe("buildEmbeddedSandboxInfo", () => { env: { LANG: "C.UTF-8" }, }, tools: { - allow: ["exec"], + allow: ["bash"], deny: ["browser"], }, browserAllowHostControl: false, @@ -262,7 +187,7 @@ function createStubTool(name: string): AgentTool { describe("splitSdkTools", () => { const tools = [ createStubTool("read"), - createStubTool("exec"), + createStubTool("bash"), createStubTool("edit"), createStubTool("write"), createStubTool("browser"), @@ -276,7 +201,7 @@ describe("splitSdkTools", () => { expect(builtInTools).toEqual([]); expect(customTools.map((tool) => tool.name)).toEqual([ "read", - "exec", + "bash", "edit", "write", "browser", @@ -291,7 +216,7 @@ describe("splitSdkTools", () => { expect(builtInTools).toEqual([]); expect(customTools.map((tool) => tool.name)).toEqual([ "read", - "exec", + "bash", "edit", "write", "browser", @@ -317,7 +242,7 @@ describe("applyGoogleTurnOrderingFix", () => { { role: "assistant", content: [ - { type: "toolCall", id: "call_1", name: "exec", arguments: {} }, + { type: "toolCall", id: "call_1", name: "bash", arguments: {} }, ], }, ] satisfies AgentMessage[]; @@ -372,281 +297,50 @@ describe("applyGoogleTurnOrderingFix", () => { }); }); -describe("limitHistoryTurns", () => { - const makeMessages = (roles: ("user" | "assistant")[]): AgentMessage[] => - roles.map((role, i) => ({ - role, - content: [{ type: "text", text: `message ${i}` }], - })); - - it("returns all messages when limit is undefined", () => { - const messages = makeMessages(["user", "assistant", "user", "assistant"]); - expect(limitHistoryTurns(messages, undefined)).toBe(messages); - }); - - it("returns all messages when limit is 0", () => { - const messages = makeMessages(["user", "assistant", "user", "assistant"]); - expect(limitHistoryTurns(messages, 0)).toBe(messages); - }); - - it("returns all messages when limit is negative", () => { - const messages = makeMessages(["user", "assistant", "user", "assistant"]); - expect(limitHistoryTurns(messages, -1)).toBe(messages); - }); - - it("returns empty array when messages is empty", () => { - expect(limitHistoryTurns([], 5)).toEqual([]); - }); - - it("keeps all messages when fewer user turns than limit", () => { - const messages = makeMessages(["user", "assistant", "user", "assistant"]); - expect(limitHistoryTurns(messages, 10)).toBe(messages); - }); - - it("limits to last N user turns", () => { - const messages = makeMessages([ - "user", - "assistant", - "user", - "assistant", - "user", - "assistant", - ]); - const limited = limitHistoryTurns(messages, 2); - expect(limited.length).toBe(4); - expect(limited[0].content).toEqual([{ type: "text", text: "message 2" }]); - }); - - it("handles single user turn limit", () => { - const messages = makeMessages([ - "user", - "assistant", - "user", - "assistant", - "user", - "assistant", - ]); - const limited = limitHistoryTurns(messages, 1); - expect(limited.length).toBe(2); - expect(limited[0].content).toEqual([{ type: "text", text: "message 4" }]); - expect(limited[1].content).toEqual([{ type: "text", text: "message 5" }]); - }); - - it("handles messages with multiple assistant responses per user turn", () => { - const messages = makeMessages([ - "user", - "assistant", - "assistant", - "user", - "assistant", - ]); - const limited = limitHistoryTurns(messages, 1); - expect(limited.length).toBe(2); - expect(limited[0].role).toBe("user"); - expect(limited[1].role).toBe("assistant"); - }); - - it("preserves message content integrity", () => { - const messages: AgentMessage[] = [ - { role: "user", content: [{ type: "text", text: "first" }] }, - { - role: "assistant", - content: [{ type: "toolCall", id: "1", name: "exec", arguments: {} }], - }, - { role: "user", content: [{ type: "text", text: "second" }] }, - { role: "assistant", content: [{ type: "text", text: "response" }] }, - ]; - const limited = limitHistoryTurns(messages, 1); - expect(limited[0].content).toEqual([{ type: "text", text: "second" }]); - expect(limited[1].content).toEqual([{ type: "text", text: "response" }]); - }); -}); - -describe("getDmHistoryLimitFromSessionKey", () => { - it("returns undefined when sessionKey is undefined", () => { - expect(getDmHistoryLimitFromSessionKey(undefined, {})).toBeUndefined(); - }); - - it("returns undefined when config is undefined", () => { - expect( - getDmHistoryLimitFromSessionKey("telegram:dm:123", undefined), - ).toBeUndefined(); - }); - - it("returns dmHistoryLimit for telegram provider", () => { - const config = { telegram: { dmHistoryLimit: 15 } } as ClawdbotConfig; - expect(getDmHistoryLimitFromSessionKey("telegram:dm:123", config)).toBe(15); - }); - - it("returns dmHistoryLimit for whatsapp provider", () => { - const config = { whatsapp: { dmHistoryLimit: 20 } } as ClawdbotConfig; - expect(getDmHistoryLimitFromSessionKey("whatsapp:dm:123", config)).toBe(20); - }); - - it("returns dmHistoryLimit for agent-prefixed session keys", () => { - const config = { telegram: { dmHistoryLimit: 10 } } as ClawdbotConfig; - expect( - getDmHistoryLimitFromSessionKey("agent:main:telegram:dm:123", config), - ).toBe(10); - }); - - it("returns undefined for non-dm session kinds", () => { - const config = { - slack: { dmHistoryLimit: 10 }, - telegram: { dmHistoryLimit: 15 }, - } as ClawdbotConfig; - expect( - getDmHistoryLimitFromSessionKey("agent:beta:slack:channel:C1", config), - ).toBeUndefined(); - expect( - getDmHistoryLimitFromSessionKey("telegram:slash:123", config), - ).toBeUndefined(); - }); - - it("returns undefined for unknown provider", () => { - const config = { telegram: { dmHistoryLimit: 15 } } as ClawdbotConfig; - expect( - getDmHistoryLimitFromSessionKey("unknown:dm:123", config), - ).toBeUndefined(); - }); - - it("returns undefined when provider config has no dmHistoryLimit", () => { - const config = { telegram: {} } as ClawdbotConfig; - expect( - getDmHistoryLimitFromSessionKey("telegram:dm:123", config), - ).toBeUndefined(); - }); - - it("handles all supported providers", () => { - const providers = [ - "telegram", - "whatsapp", - "discord", - "slack", - "signal", - "imessage", - "msteams", - ] as const; - - for (const provider of providers) { - const config = { [provider]: { dmHistoryLimit: 5 } } as ClawdbotConfig; - expect( - getDmHistoryLimitFromSessionKey(`${provider}:dm:123`, config), - ).toBe(5); - } - }); - - it("handles per-DM overrides for all supported providers", () => { - const providers = [ - "telegram", - "whatsapp", - "discord", - "slack", - "signal", - "imessage", - "msteams", - ] as const; - - for (const provider of providers) { - // Test per-DM override takes precedence - const configWithOverride = { - [provider]: { - dmHistoryLimit: 20, - dms: { user123: { historyLimit: 7 } }, - }, - } as ClawdbotConfig; - expect( - getDmHistoryLimitFromSessionKey( - `${provider}:dm:user123`, - configWithOverride, - ), - ).toBe(7); - - // Test fallback to provider default when user not in dms - expect( - getDmHistoryLimitFromSessionKey( - `${provider}:dm:otheruser`, - configWithOverride, - ), - ).toBe(20); - - // Test with agent-prefixed key - expect( - getDmHistoryLimitFromSessionKey( - `agent:main:${provider}:dm:user123`, - configWithOverride, - ), - ).toBe(7); - } - }); - - it("returns per-DM override when set", () => { - const config = { - telegram: { - dmHistoryLimit: 15, - dms: { "123": { historyLimit: 5 } }, - }, - } as ClawdbotConfig; - expect(getDmHistoryLimitFromSessionKey("telegram:dm:123", config)).toBe(5); - }); - - it("falls back to provider default when per-DM not set", () => { - const config = { - telegram: { - dmHistoryLimit: 15, - dms: { "456": { historyLimit: 5 } }, - }, - } as ClawdbotConfig; - expect(getDmHistoryLimitFromSessionKey("telegram:dm:123", config)).toBe(15); - }); - - it("returns per-DM override for agent-prefixed keys", () => { - const config = { - telegram: { - dmHistoryLimit: 20, - dms: { "789": { historyLimit: 3 } }, - }, - } as ClawdbotConfig; - expect( - getDmHistoryLimitFromSessionKey("agent:main:telegram:dm:789", config), - ).toBe(3); - }); - - it("handles userId with colons (e.g., email)", () => { - const config = { - msteams: { - dmHistoryLimit: 10, - dms: { "user@example.com": { historyLimit: 7 } }, - }, - } as ClawdbotConfig; - expect( - getDmHistoryLimitFromSessionKey("msteams:dm:user@example.com", config), - ).toBe(7); - }); - - it("returns undefined when per-DM historyLimit is not set", () => { - const config = { - telegram: { - dms: { "123": {} }, - }, - } as ClawdbotConfig; - expect( - getDmHistoryLimitFromSessionKey("telegram:dm:123", config), - ).toBeUndefined(); - }); - - it("returns 0 when per-DM historyLimit is explicitly 0 (unlimited)", () => { - const config = { - telegram: { - dmHistoryLimit: 15, - dms: { "123": { historyLimit: 0 } }, - }, - } as ClawdbotConfig; - expect(getDmHistoryLimitFromSessionKey("telegram:dm:123", config)).toBe(0); - }); -}); - describe("runEmbeddedPiAgent", () => { + it("exchanges github token for copilot token", async () => { + const { getApiKeyForModel } = await import("./model-auth.js"); + const { resolveCopilotApiToken } = await import( + "../providers/github-copilot-token.js" + ); + + vi.mocked(getApiKeyForModel).mockResolvedValue({ + apiKey: "gh-token", + source: "test", + }); + vi.mocked(resolveCopilotApiToken).mockResolvedValue({ + token: "copilot-token", + expiresAt: Date.now() + 60_000, + source: "test", + }); + + const agentDir = await fs.mkdtemp( + path.join(os.tmpdir(), "clawdbot-agent-copilot-"), + ); + const workspaceDir = await fs.mkdtemp( + path.join(os.tmpdir(), "clawdbot-workspace-copilot-"), + ); + const sessionFile = path.join(workspaceDir, "session.jsonl"); + + await expect( + runEmbeddedPiAgent({ + sessionId: "session:test", + sessionKey: "agent:dev:test", + sessionFile, + workspaceDir, + prompt: "hi", + provider: "github-copilot", + model: "gpt-4o", + timeoutMs: 1, + agentDir, + }), + ).rejects.toThrow(); + + expect(resolveCopilotApiToken).toHaveBeenCalledWith({ + githubToken: "gh-token", + }); + }); + it("writes models.json into the provided agentDir", async () => { const agentDir = await fs.mkdtemp( path.join(os.tmpdir(), "clawdbot-agent-"), @@ -660,12 +354,12 @@ describe("runEmbeddedPiAgent", () => { models: { providers: { minimax: { - baseUrl: "https://api.minimax.io/anthropic", - api: "anthropic-messages", + baseUrl: "https://api.minimax.io/v1", + api: "openai-completions", apiKey: "sk-minimax-test", models: [ { - id: "MiniMax-M2.1", + id: "minimax-m2.1", name: "MiniMax M2.1", reasoning: false, input: ["text"], @@ -698,216 +392,4 @@ describe("runEmbeddedPiAgent", () => { fs.stat(path.join(agentDir, "models.json")), ).resolves.toBeTruthy(); }); - - it("persists the first user message before assistant output", async () => { - const agentDir = await fs.mkdtemp( - path.join(os.tmpdir(), "clawdbot-agent-"), - ); - const workspaceDir = await fs.mkdtemp( - path.join(os.tmpdir(), "clawdbot-workspace-"), - ); - const sessionFile = path.join(workspaceDir, "session.jsonl"); - - const cfg = makeOpenAiConfig(["mock-1"]); - - await runEmbeddedPiAgent({ - sessionId: "session:test", - sessionKey: "agent:main:main", - sessionFile, - workspaceDir, - config: cfg, - prompt: "hello", - provider: "openai", - model: "mock-1", - timeoutMs: 5_000, - agentDir, - }); - - const messages = await readSessionMessages(sessionFile); - const firstUserIndex = messages.findIndex( - (message) => - message?.role === "user" && - textFromContent(message.content) === "hello", - ); - const firstAssistantIndex = messages.findIndex( - (message) => message?.role === "assistant", - ); - expect(firstUserIndex).toBeGreaterThanOrEqual(0); - if (firstAssistantIndex !== -1) { - expect(firstUserIndex).toBeLessThan(firstAssistantIndex); - } - }); - - it("persists the user message when prompt fails before assistant output", async () => { - const agentDir = await fs.mkdtemp( - path.join(os.tmpdir(), "clawdbot-agent-"), - ); - const workspaceDir = await fs.mkdtemp( - path.join(os.tmpdir(), "clawdbot-workspace-"), - ); - const sessionFile = path.join(workspaceDir, "session.jsonl"); - - const cfg = makeOpenAiConfig(["mock-error"]); - - const result = await runEmbeddedPiAgent({ - sessionId: "session:test", - sessionKey: "agent:main:main", - sessionFile, - workspaceDir, - config: cfg, - prompt: "boom", - provider: "openai", - model: "mock-error", - timeoutMs: 5_000, - agentDir, - }); - expect(result.payloads[0]?.isError).toBe(true); - - const messages = await readSessionMessages(sessionFile); - const userIndex = messages.findIndex( - (message) => - message?.role === "user" && textFromContent(message.content) === "boom", - ); - expect(userIndex).toBeGreaterThanOrEqual(0); - }); - - it("appends new user + assistant after existing transcript entries", async () => { - const agentDir = await fs.mkdtemp( - path.join(os.tmpdir(), "clawdbot-agent-"), - ); - const workspaceDir = await fs.mkdtemp( - path.join(os.tmpdir(), "clawdbot-workspace-"), - ); - const sessionFile = path.join(workspaceDir, "session.jsonl"); - - const sessionManager = SessionManager.open(sessionFile); - sessionManager.appendMessage({ - role: "user", - content: [{ type: "text", text: "seed user" }], - }); - sessionManager.appendMessage({ - role: "assistant", - content: [{ type: "text", text: "seed assistant" }], - stopReason: "stop", - api: "openai-responses", - provider: "openai", - model: "mock-1", - usage: { - input: 1, - output: 1, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 2, - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - total: 0, - }, - }, - timestamp: Date.now(), - }); - - const cfg = makeOpenAiConfig(["mock-1"]); - - await runEmbeddedPiAgent({ - sessionId: "session:test", - sessionKey: "agent:main:main", - sessionFile, - workspaceDir, - config: cfg, - prompt: "hello", - provider: "openai", - model: "mock-1", - timeoutMs: 5_000, - agentDir, - }); - - const messages = await readSessionMessages(sessionFile); - const seedUserIndex = messages.findIndex( - (message) => - message?.role === "user" && - textFromContent(message.content) === "seed user", - ); - const seedAssistantIndex = messages.findIndex( - (message) => - message?.role === "assistant" && - textFromContent(message.content) === "seed assistant", - ); - const newUserIndex = messages.findIndex( - (message) => - message?.role === "user" && - textFromContent(message.content) === "hello", - ); - const newAssistantIndex = messages.findIndex( - (message, index) => index > newUserIndex && message?.role === "assistant", - ); - expect(seedUserIndex).toBeGreaterThanOrEqual(0); - expect(seedAssistantIndex).toBeGreaterThan(seedUserIndex); - expect(newUserIndex).toBeGreaterThan(seedAssistantIndex); - expect(newAssistantIndex).toBeGreaterThan(newUserIndex); - }); - - it("persists multi-turn user/assistant ordering across runs", async () => { - const agentDir = await fs.mkdtemp( - path.join(os.tmpdir(), "clawdbot-agent-"), - ); - const workspaceDir = await fs.mkdtemp( - path.join(os.tmpdir(), "clawdbot-workspace-"), - ); - const sessionFile = path.join(workspaceDir, "session.jsonl"); - - const cfg = makeOpenAiConfig(["mock-1"]); - - await runEmbeddedPiAgent({ - sessionId: "session:test", - sessionKey: "agent:main:main", - sessionFile, - workspaceDir, - config: cfg, - prompt: "first", - provider: "openai", - model: "mock-1", - timeoutMs: 5_000, - agentDir, - }); - - await runEmbeddedPiAgent({ - sessionId: "session:test", - sessionKey: "agent:main:main", - sessionFile, - workspaceDir, - config: cfg, - prompt: "second", - provider: "openai", - model: "mock-1", - timeoutMs: 5_000, - agentDir, - }); - - const messages = await readSessionMessages(sessionFile); - const firstUserIndex = messages.findIndex( - (message) => - message?.role === "user" && - textFromContent(message.content) === "first", - ); - const firstAssistantIndex = messages.findIndex( - (message, index) => - index > firstUserIndex && message?.role === "assistant", - ); - const secondUserIndex = messages.findIndex( - (message) => - message?.role === "user" && - textFromContent(message.content) === "second", - ); - const secondAssistantIndex = messages.findIndex( - (message, index) => - index > secondUserIndex && message?.role === "assistant", - ); - expect(firstUserIndex).toBeGreaterThanOrEqual(0); - expect(firstAssistantIndex).toBeGreaterThan(firstUserIndex); - expect(secondUserIndex).toBeGreaterThan(firstAssistantIndex); - expect(secondAssistantIndex).toBeGreaterThan(secondUserIndex); - }); }); diff --git a/src/agents/pi-embedded-runner.ts b/src/agents/pi-embedded-runner.ts index d985b2595..0e7809c86 100644 --- a/src/agents/pi-embedded-runner.ts +++ b/src/agents/pi-embedded-runner.ts @@ -1042,7 +1042,18 @@ export async function compactEmbeddedPiSession(params: { model, cfg: params.config, }); - authStorage.setRuntimeApiKey(model.provider, apiKeyInfo.apiKey); + + if (model.provider === "github-copilot") { + const { resolveCopilotApiToken } = await import( + "../providers/github-copilot-token.js" + ); + const copilotToken = await resolveCopilotApiToken({ + githubToken: apiKeyInfo.apiKey, + }); + authStorage.setRuntimeApiKey(model.provider, copilotToken.token); + } else { + authStorage.setRuntimeApiKey(model.provider, apiKeyInfo.apiKey); + } } catch (err) { return { ok: false, @@ -1432,7 +1443,19 @@ export async function runEmbeddedPiAgent(params: { const applyApiKeyInfo = async (candidate?: string): Promise => { apiKeyInfo = await resolveApiKeyForCandidate(candidate); - authStorage.setRuntimeApiKey(model.provider, apiKeyInfo.apiKey); + + if (model.provider === "github-copilot") { + const { resolveCopilotApiToken } = await import( + "../providers/github-copilot-token.js" + ); + const copilotToken = await resolveCopilotApiToken({ + githubToken: apiKeyInfo.apiKey, + }); + authStorage.setRuntimeApiKey(model.provider, copilotToken.token); + } else { + authStorage.setRuntimeApiKey(model.provider, apiKeyInfo.apiKey); + } + lastProfileId = apiKeyInfo.profileId; }; diff --git a/src/cli/models-cli.test.ts b/src/cli/models-cli.test.ts new file mode 100644 index 000000000..81cd36ed8 --- /dev/null +++ b/src/cli/models-cli.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, vi } from "vitest"; + +const githubCopilotLoginCommand = vi.fn(); + +vi.mock("../commands/models.js", async () => { + const actual = (await vi.importActual( + "../commands/models.js", + )) as typeof import("../commands/models.js"); + + return { + ...actual, + githubCopilotLoginCommand, + }; +}); + +describe("models cli", () => { + it("registers github-copilot login command", async () => { + const { Command } = await import("commander"); + const { registerModelsCli } = await import("./models-cli.js"); + + const program = new Command(); + registerModelsCli(program); + + const models = program.commands.find((cmd) => cmd.name() === "models"); + expect(models).toBeTruthy(); + + const auth = models?.commands.find((cmd) => cmd.name() === "auth"); + expect(auth).toBeTruthy(); + + const login = auth?.commands.find( + (cmd) => cmd.name() === "login-github-copilot", + ); + expect(login).toBeTruthy(); + + await program.parseAsync( + ["models", "auth", "login-github-copilot", "--yes"], + { + from: "user", + }, + ); + + expect(githubCopilotLoginCommand).toHaveBeenCalledTimes(1); + expect(githubCopilotLoginCommand).toHaveBeenCalledWith( + expect.objectContaining({ yes: true }), + expect.any(Object), + ); + }); +}); diff --git a/src/cli/models-cli.ts b/src/cli/models-cli.ts index 98a57efa0..5e3e8279f 100644 --- a/src/cli/models-cli.ts +++ b/src/cli/models-cli.ts @@ -1,6 +1,7 @@ import type { Command } from "commander"; import { + githubCopilotLoginCommand, modelsAliasesAddCommand, modelsAliasesListCommand, modelsAliasesRemoveCommand, @@ -374,6 +375,31 @@ export function registerModelsCli(program: Command) { } }); + auth + .command("login-github-copilot") + .description( + "Login to GitHub Copilot via GitHub device flow (TTY required)", + ) + .option( + "--profile-id ", + "Auth profile id (default: github-copilot:github)", + ) + .option("--yes", "Overwrite existing profile without prompting", false) + .action(async (opts) => { + try { + await githubCopilotLoginCommand( + { + profileId: opts.profileId as string | undefined, + yes: Boolean(opts.yes), + }, + defaultRuntime, + ); + } catch (err) { + defaultRuntime.error(String(err)); + defaultRuntime.exit(1); + } + }); + const order = auth .command("order") .description("Manage per-agent auth profile order overrides"); diff --git a/src/commands/auth-choice-options.test.ts b/src/commands/auth-choice-options.test.ts index 3071904da..ae928c051 100644 --- a/src/commands/auth-choice-options.test.ts +++ b/src/commands/auth-choice-options.test.ts @@ -7,6 +7,17 @@ import { import { buildAuthChoiceOptions } from "./auth-choice-options.js"; describe("buildAuthChoiceOptions", () => { + it("includes GitHub Copilot", () => { + const store: AuthProfileStore = { version: 1, profiles: {} }; + const options = buildAuthChoiceOptions({ + store, + includeSkip: false, + includeClaudeCliIfMissing: false, + platform: "linux", + }); + + expect(options.find((opt) => opt.value === "github-copilot")).toBeDefined(); + }); it("includes Claude CLI option on macOS even when missing", () => { const store: AuthProfileStore = { version: 1, profiles: {} }; const options = buildAuthChoiceOptions({ diff --git a/src/commands/auth-choice-options.ts b/src/commands/auth-choice-options.ts index 806472a64..062d78a4f 100644 --- a/src/commands/auth-choice-options.ts +++ b/src/commands/auth-choice-options.ts @@ -171,6 +171,11 @@ export function buildAuthChoiceOptions(params: { value: "antigravity", label: "Google Antigravity (Claude Opus 4.5, Gemini 3, etc.)", }); + options.push({ + value: "github-copilot", + label: "GitHub Copilot (GitHub device login)", + hint: "Uses GitHub device flow", + }); options.push({ value: "gemini-api-key", label: "Google Gemini API key" }); options.push({ value: "zai-api-key", label: "Z.AI (GLM 4.7) API key" }); options.push({ value: "apiKey", label: "Anthropic API key" }); diff --git a/src/commands/auth-choice.test.ts b/src/commands/auth-choice.test.ts index 4d0bf524b..626b3a2f3 100644 --- a/src/commands/auth-choice.test.ts +++ b/src/commands/auth-choice.test.ts @@ -8,6 +8,10 @@ import type { RuntimeEnv } from "../runtime.js"; import type { WizardPrompter } from "../wizard/prompts.js"; import { applyAuthChoice } from "./auth-choice.js"; +vi.mock("../providers/github-copilot-auth.js", () => ({ + githubCopilotLoginCommand: vi.fn(async () => {}), +})); + const noopAsync = async () => {}; const noop = () => {}; @@ -15,7 +19,6 @@ describe("applyAuthChoice", () => { const previousStateDir = process.env.CLAWDBOT_STATE_DIR; const previousAgentDir = process.env.CLAWDBOT_AGENT_DIR; const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR; - const previousOpenrouterKey = process.env.OPENROUTER_API_KEY; let tempStateDir: string | null = null; afterEach(async () => { @@ -38,11 +41,6 @@ describe("applyAuthChoice", () => { } else { process.env.PI_CODING_AGENT_DIR = previousPiAgentDir; } - if (previousOpenrouterKey === undefined) { - delete process.env.OPENROUTER_API_KEY; - } else { - process.env.OPENROUTER_API_KEY = previousOpenrouterKey; - } }); it("prompts and writes MiniMax API key when selecting minimax-api", async () => { @@ -51,74 +49,6 @@ describe("applyAuthChoice", () => { process.env.CLAWDBOT_AGENT_DIR = path.join(tempStateDir, "agent"); process.env.PI_CODING_AGENT_DIR = process.env.CLAWDBOT_AGENT_DIR; - const text = vi - .fn() - .mockResolvedValue('export MINIMAX_API_KEY="sk-minimax-test"'); - const select: WizardPrompter["select"] = vi.fn( - async (params) => params.options[0]?.value as never, - ); - const multiselect: WizardPrompter["multiselect"] = vi.fn(async () => []); - const prompter: WizardPrompter = { - intro: vi.fn(noopAsync), - outro: vi.fn(noopAsync), - note: vi.fn(noopAsync), - select, - multiselect, - text, - confirm: vi.fn(async () => false), - progress: vi.fn(() => ({ update: noop, stop: noop })), - }; - const runtime: RuntimeEnv = { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn((code: number) => { - throw new Error(`exit:${code}`); - }), - }; - - const result = await applyAuthChoice({ - authChoice: "minimax-api", - config: {}, - prompter, - runtime, - setDefaultModel: true, - }); - - expect(text).toHaveBeenCalledWith( - expect.objectContaining({ message: "Enter MiniMax API key" }), - ); - expect(result.config.models?.providers?.minimax).toMatchObject({ - baseUrl: "https://api.minimax.io/anthropic", - api: "anthropic-messages", - }); - expect(result.config.agents?.defaults?.model).toMatchObject({ - primary: "minimax/MiniMax-M2.1", - }); - expect(result.config.auth?.profiles?.["minimax:default"]).toMatchObject({ - provider: "minimax", - mode: "api_key", - }); - - const authProfilePath = path.join( - tempStateDir, - "agents", - "main", - "agent", - "auth-profiles.json", - ); - const raw = await fs.readFile(authProfilePath, "utf8"); - const parsed = JSON.parse(raw) as { - profiles?: Record; - }; - expect(parsed.profiles?.["minimax:default"]?.key).toBe("sk-minimax-test"); - }); - - it("configures MiniMax M2.1 via the Anthropic-compatible endpoint", async () => { - tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-auth-")); - process.env.CLAWDBOT_STATE_DIR = tempStateDir; - process.env.CLAWDBOT_AGENT_DIR = path.join(tempStateDir, "agent"); - process.env.PI_CODING_AGENT_DIR = process.env.CLAWDBOT_AGENT_DIR; - const text = vi.fn().mockResolvedValue("sk-minimax-test"); const select: WizardPrompter["select"] = vi.fn( async (params) => params.options[0]?.value as never, @@ -153,12 +83,9 @@ describe("applyAuthChoice", () => { expect(text).toHaveBeenCalledWith( expect.objectContaining({ message: "Enter MiniMax API key" }), ); - expect(result.config.models?.providers?.minimax).toMatchObject({ - baseUrl: "https://api.minimax.io/anthropic", - api: "anthropic-messages", - }); - expect(result.config.agents?.defaults?.model).toMatchObject({ - primary: "minimax/MiniMax-M2.1", + expect(result.config.auth?.profiles?.["minimax:default"]).toMatchObject({ + provider: "minimax", + mode: "api_key", }); const authProfilePath = path.join( @@ -174,6 +101,52 @@ describe("applyAuthChoice", () => { }; expect(parsed.profiles?.["minimax:default"]?.key).toBe("sk-minimax-test"); }); + + it("sets default model when selecting github-copilot", async () => { + tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-auth-")); + process.env.CLAWDBOT_STATE_DIR = tempStateDir; + process.env.CLAWDBOT_AGENT_DIR = path.join(tempStateDir, "agent"); + process.env.PI_CODING_AGENT_DIR = process.env.CLAWDBOT_AGENT_DIR; + + const prompter: WizardPrompter = { + intro: vi.fn(noopAsync), + outro: vi.fn(noopAsync), + note: vi.fn(noopAsync), + select: vi.fn(async () => "" as never), + multiselect: vi.fn(async () => []), + text: vi.fn(async () => ""), + confirm: vi.fn(async () => false), + progress: vi.fn(() => ({ update: noop, stop: noop })), + }; + const runtime: RuntimeEnv = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn((code: number) => { + throw new Error(`exit:${code}`); + }), + }; + + const previousTty = process.stdin.isTTY; + const stdin = process.stdin as unknown as { isTTY?: boolean }; + stdin.isTTY = true; + + try { + const result = await applyAuthChoice({ + authChoice: "github-copilot", + config: {}, + prompter, + runtime, + setDefaultModel: true, + }); + + expect(result.config.agents?.defaults?.model?.primary).toBe( + "github-copilot/gpt-4o", + ); + } finally { + stdin.isTTY = previousTty; + } + }); + it("does not override the default model when selecting opencode-zen without setDefaultModel", async () => { tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-auth-")); process.env.CLAWDBOT_STATE_DIR = tempStateDir; @@ -226,75 +199,4 @@ describe("applyAuthChoice", () => { expect(result.config.models?.providers?.["opencode-zen"]).toBeUndefined(); expect(result.agentModelOverride).toBe("opencode/claude-opus-4-5"); }); - - it("uses existing OPENROUTER_API_KEY when selecting openrouter-api-key", async () => { - tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-auth-")); - process.env.CLAWDBOT_STATE_DIR = tempStateDir; - process.env.CLAWDBOT_AGENT_DIR = path.join(tempStateDir, "agent"); - process.env.PI_CODING_AGENT_DIR = process.env.CLAWDBOT_AGENT_DIR; - process.env.OPENROUTER_API_KEY = "sk-openrouter-test"; - - const text = vi.fn(); - const select: WizardPrompter["select"] = vi.fn( - async (params) => params.options[0]?.value as never, - ); - const multiselect: WizardPrompter["multiselect"] = vi.fn(async () => []); - const confirm = vi.fn(async () => true); - const prompter: WizardPrompter = { - intro: vi.fn(noopAsync), - outro: vi.fn(noopAsync), - note: vi.fn(noopAsync), - select, - multiselect, - text, - confirm, - progress: vi.fn(() => ({ update: noop, stop: noop })), - }; - const runtime: RuntimeEnv = { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn((code: number) => { - throw new Error(`exit:${code}`); - }), - }; - - const result = await applyAuthChoice({ - authChoice: "openrouter-api-key", - config: {}, - prompter, - runtime, - setDefaultModel: true, - }); - - expect(confirm).toHaveBeenCalledWith( - expect.objectContaining({ - message: expect.stringContaining("OPENROUTER_API_KEY"), - }), - ); - expect(text).not.toHaveBeenCalled(); - expect(result.config.auth?.profiles?.["openrouter:default"]).toMatchObject({ - provider: "openrouter", - mode: "api_key", - }); - expect(result.config.agents?.defaults?.model?.primary).toBe( - "openrouter/auto", - ); - - const authProfilePath = path.join( - tempStateDir, - "agents", - "main", - "agent", - "auth-profiles.json", - ); - const raw = await fs.readFile(authProfilePath, "utf8"); - const parsed = JSON.parse(raw) as { - profiles?: Record; - }; - expect(parsed.profiles?.["openrouter:default"]?.key).toBe( - "sk-openrouter-test", - ); - - delete process.env.OPENROUTER_API_KEY; - }); }); diff --git a/src/commands/auth-choice.ts b/src/commands/auth-choice.ts index d59ceda85..08a042e7d 100644 --- a/src/commands/auth-choice.ts +++ b/src/commands/auth-choice.ts @@ -9,7 +9,6 @@ import { CODEX_CLI_PROFILE_ID, ensureAuthProfileStore, listProfilesForProvider, - resolveAuthProfileOrder, upsertAuthProfile, } from "../agents/auth-profiles.js"; import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js"; @@ -21,6 +20,7 @@ import { loadModelCatalog } from "../agents/model-catalog.js"; import { resolveConfiguredModelRef } from "../agents/model-selection.js"; import type { ClawdbotConfig } from "../config/config.js"; import { upsertSharedEnvVar } from "../infra/env-file.js"; +import { githubCopilotLoginCommand } from "../providers/github-copilot-auth.js"; import type { RuntimeEnv } from "../runtime.js"; import type { WizardPrompter } from "../wizard/prompts.js"; import { @@ -40,22 +40,17 @@ import { applyMinimaxApiConfig, applyMinimaxApiProviderConfig, applyMinimaxConfig, + applyMinimaxHostedConfig, + applyMinimaxHostedProviderConfig, applyMinimaxProviderConfig, - applyMoonshotConfig, - applyMoonshotProviderConfig, applyOpencodeZenConfig, applyOpencodeZenProviderConfig, - applyOpenrouterConfig, - applyOpenrouterProviderConfig, applyZaiConfig, - MOONSHOT_DEFAULT_MODEL_REF, - OPENROUTER_DEFAULT_MODEL_REF, + MINIMAX_HOSTED_MODEL_REF, setAnthropicApiKey, setGeminiApiKey, setMinimaxApiKey, - setMoonshotApiKey, setOpencodeZenApiKey, - setOpenrouterApiKey, setZaiApiKey, writeOAuthCredentials, ZAI_DEFAULT_MODEL_REF, @@ -68,55 +63,6 @@ import { } from "./openai-codex-model-default.js"; import { OPENCODE_ZEN_DEFAULT_MODEL } from "./opencode-zen-model-default.js"; -const DEFAULT_KEY_PREVIEW = { head: 4, tail: 4 }; - -function normalizeApiKeyInput(raw: string): string { - const trimmed = String(raw ?? "").trim(); - if (!trimmed) return ""; - - // Handle shell-style assignments: export KEY="value" or KEY=value - const assignmentMatch = trimmed.match( - /^(?:export\s+)?[A-Za-z_][A-Za-z0-9_]*\s*=\s*(.+)$/, - ); - const valuePart = assignmentMatch ? assignmentMatch[1].trim() : trimmed; - - const unquoted = - valuePart.length >= 2 && - ((valuePart.startsWith('"') && valuePart.endsWith('"')) || - (valuePart.startsWith("'") && valuePart.endsWith("'")) || - (valuePart.startsWith("`") && valuePart.endsWith("`"))) - ? valuePart.slice(1, -1) - : valuePart; - - const withoutSemicolon = unquoted.endsWith(";") - ? unquoted.slice(0, -1) - : unquoted; - - return withoutSemicolon.trim(); -} - -const validateApiKeyInput = (value: unknown) => - normalizeApiKeyInput(String(value ?? "")).length > 0 ? undefined : "Required"; - -function formatApiKeyPreview( - raw: string, - opts: { head?: number; tail?: number } = {}, -): string { - const trimmed = raw.trim(); - if (!trimmed) return "…"; - const head = opts.head ?? DEFAULT_KEY_PREVIEW.head; - const tail = opts.tail ?? DEFAULT_KEY_PREVIEW.tail; - if (trimmed.length <= head + tail) { - const shortHead = Math.min(2, trimmed.length); - const shortTail = Math.min(2, trimmed.length - shortHead); - if (shortTail <= 0) { - return `${trimmed.slice(0, shortHead)}…`; - } - return `${trimmed.slice(0, shortHead)}…${trimmed.slice(-shortTail)}`; - } - return `${trimmed.slice(0, head)}…${trimmed.slice(-tail)}`; -} - export async function warnIfModelConfigLooksOff( config: ClawdbotConfig, prompter: WizardPrompter, @@ -388,7 +334,7 @@ export async function applyAuthChoice(params: { const envKey = resolveEnvApiKey("openai"); if (envKey) { const useExisting = await params.prompter.confirm({ - message: `Use existing OPENAI_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`, + message: `Use existing OPENAI_API_KEY (${envKey.source})?`, initialValue: true, }); if (useExisting) { @@ -409,9 +355,9 @@ export async function applyAuthChoice(params: { const key = await params.prompter.text({ message: "Enter OpenAI API key", - validate: validateApiKeyInput, + validate: (value) => (value?.trim() ? undefined : "Required"), }); - const trimmed = normalizeApiKeyInput(String(key)); + const trimmed = String(key).trim(); const result = upsertSharedEnvVar({ key: "OPENAI_API_KEY", value: trimmed, @@ -421,115 +367,6 @@ export async function applyAuthChoice(params: { `Saved OPENAI_API_KEY to ${result.path} for launchd compatibility.`, "OpenAI API key", ); - } else if (params.authChoice === "openrouter-api-key") { - const store = ensureAuthProfileStore(params.agentDir, { - allowKeychainPrompt: false, - }); - const profileOrder = resolveAuthProfileOrder({ - cfg: nextConfig, - store, - provider: "openrouter", - }); - const existingProfileId = profileOrder.find((profileId) => - Boolean(store.profiles[profileId]), - ); - const existingCred = existingProfileId - ? store.profiles[existingProfileId] - : undefined; - let profileId = "openrouter:default"; - let mode: "api_key" | "oauth" | "token" = "api_key"; - let hasCredential = false; - - if (existingProfileId && existingCred?.type) { - profileId = existingProfileId; - mode = - existingCred.type === "oauth" - ? "oauth" - : existingCred.type === "token" - ? "token" - : "api_key"; - hasCredential = true; - } - - if (!hasCredential) { - const envKey = resolveEnvApiKey("openrouter"); - if (envKey) { - const useExisting = await params.prompter.confirm({ - message: `Use existing OPENROUTER_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`, - initialValue: true, - }); - if (useExisting) { - await setOpenrouterApiKey(envKey.apiKey, params.agentDir); - hasCredential = true; - } - } - } - - if (!hasCredential) { - const key = await params.prompter.text({ - message: "Enter OpenRouter API key", - validate: validateApiKeyInput, - }); - await setOpenrouterApiKey( - normalizeApiKeyInput(String(key)), - params.agentDir, - ); - hasCredential = true; - } - - if (hasCredential) { - nextConfig = applyAuthProfileConfig(nextConfig, { - profileId, - provider: "openrouter", - mode, - }); - } - if (params.setDefaultModel) { - nextConfig = applyOpenrouterConfig(nextConfig); - await params.prompter.note( - `Default model set to ${OPENROUTER_DEFAULT_MODEL_REF}`, - "Model configured", - ); - } else { - nextConfig = applyOpenrouterProviderConfig(nextConfig); - agentModelOverride = OPENROUTER_DEFAULT_MODEL_REF; - await noteAgentModel(OPENROUTER_DEFAULT_MODEL_REF); - } - } else if (params.authChoice === "moonshot-api-key") { - let hasCredential = false; - const envKey = resolveEnvApiKey("moonshot"); - if (envKey) { - const useExisting = await params.prompter.confirm({ - message: `Use existing MOONSHOT_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`, - initialValue: true, - }); - if (useExisting) { - await setMoonshotApiKey(envKey.apiKey, params.agentDir); - hasCredential = true; - } - } - if (!hasCredential) { - const key = await params.prompter.text({ - message: "Enter Moonshot API key", - validate: validateApiKeyInput, - }); - await setMoonshotApiKey( - normalizeApiKeyInput(String(key)), - params.agentDir, - ); - } - nextConfig = applyAuthProfileConfig(nextConfig, { - profileId: "moonshot:default", - provider: "moonshot", - mode: "api_key", - }); - if (params.setDefaultModel) { - nextConfig = applyMoonshotConfig(nextConfig); - } else { - nextConfig = applyMoonshotProviderConfig(nextConfig); - agentModelOverride = MOONSHOT_DEFAULT_MODEL_REF; - await noteAgentModel(MOONSHOT_DEFAULT_MODEL_REF); - } } else if (params.authChoice === "openai-codex") { const isRemote = isRemoteEnvironment(); await params.prompter.note( @@ -742,28 +579,11 @@ export async function applyAuthChoice(params: { ); } } else if (params.authChoice === "gemini-api-key") { - let hasCredential = false; - const envKey = resolveEnvApiKey("google"); - if (envKey) { - const useExisting = await params.prompter.confirm({ - message: `Use existing GEMINI_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`, - initialValue: true, - }); - if (useExisting) { - await setGeminiApiKey(envKey.apiKey, params.agentDir); - hasCredential = true; - } - } - if (!hasCredential) { - const key = await params.prompter.text({ - message: "Enter Gemini API key", - validate: validateApiKeyInput, - }); - await setGeminiApiKey( - normalizeApiKeyInput(String(key)), - params.agentDir, - ); - } + const key = await params.prompter.text({ + message: "Enter Gemini API key", + validate: (value) => (value?.trim() ? undefined : "Required"), + }); + await setGeminiApiKey(String(key).trim(), params.agentDir); nextConfig = applyAuthProfileConfig(nextConfig, { profileId: "google:default", provider: "google", @@ -783,25 +603,11 @@ export async function applyAuthChoice(params: { await noteAgentModel(GOOGLE_GEMINI_DEFAULT_MODEL); } } else if (params.authChoice === "zai-api-key") { - let hasCredential = false; - const envKey = resolveEnvApiKey("zai"); - if (envKey) { - const useExisting = await params.prompter.confirm({ - message: `Use existing ZAI_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`, - initialValue: true, - }); - if (useExisting) { - await setZaiApiKey(envKey.apiKey, params.agentDir); - hasCredential = true; - } - } - if (!hasCredential) { - const key = await params.prompter.text({ - message: "Enter Z.AI API key", - validate: validateApiKeyInput, - }); - await setZaiApiKey(normalizeApiKeyInput(String(key)), params.agentDir); - } + const key = await params.prompter.text({ + message: "Enter Z.AI API key", + validate: (value) => (value?.trim() ? undefined : "Required"), + }); + await setZaiApiKey(String(key).trim(), params.agentDir); nextConfig = applyAuthProfileConfig(nextConfig, { profileId: "zai:default", provider: "zai", @@ -836,76 +642,33 @@ export async function applyAuthChoice(params: { await noteAgentModel(ZAI_DEFAULT_MODEL_REF); } } else if (params.authChoice === "apiKey") { - let hasCredential = false; - const envKey = process.env.ANTHROPIC_API_KEY?.trim(); - if (envKey) { - const useExisting = await params.prompter.confirm({ - message: `Use existing ANTHROPIC_API_KEY (env, ${formatApiKeyPreview(envKey)})?`, - initialValue: true, - }); - if (useExisting) { - await setAnthropicApiKey(envKey, params.agentDir); - hasCredential = true; - } - } - if (!hasCredential) { - const key = await params.prompter.text({ - message: "Enter Anthropic API key", - validate: validateApiKeyInput, - }); - await setAnthropicApiKey( - normalizeApiKeyInput(String(key)), - params.agentDir, - ); - } + const key = await params.prompter.text({ + message: "Enter Anthropic API key", + validate: (value) => (value?.trim() ? undefined : "Required"), + }); + await setAnthropicApiKey(String(key).trim(), params.agentDir); nextConfig = applyAuthProfileConfig(nextConfig, { profileId: "anthropic:default", provider: "anthropic", mode: "api_key", }); - } else if ( - params.authChoice === "minimax-cloud" || - params.authChoice === "minimax-api" || - params.authChoice === "minimax-api-lightning" - ) { - const modelId = - params.authChoice === "minimax-api-lightning" - ? "MiniMax-M2.1-lightning" - : "MiniMax-M2.1"; - let hasCredential = false; - const envKey = resolveEnvApiKey("minimax"); - if (envKey) { - const useExisting = await params.prompter.confirm({ - message: `Use existing MINIMAX_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`, - initialValue: true, - }); - if (useExisting) { - await setMinimaxApiKey(envKey.apiKey, params.agentDir); - hasCredential = true; - } - } - if (!hasCredential) { - const key = await params.prompter.text({ - message: "Enter MiniMax API key", - validate: validateApiKeyInput, - }); - await setMinimaxApiKey( - normalizeApiKeyInput(String(key)), - params.agentDir, - ); - } + } else if (params.authChoice === "minimax-cloud") { + const key = await params.prompter.text({ + message: "Enter MiniMax API key", + validate: (value) => (value?.trim() ? undefined : "Required"), + }); + await setMinimaxApiKey(String(key).trim(), params.agentDir); nextConfig = applyAuthProfileConfig(nextConfig, { profileId: "minimax:default", provider: "minimax", mode: "api_key", }); if (params.setDefaultModel) { - nextConfig = applyMinimaxApiConfig(nextConfig, modelId); + nextConfig = applyMinimaxHostedConfig(nextConfig); } else { - const modelRef = `minimax/${modelId}`; - nextConfig = applyMinimaxApiProviderConfig(nextConfig, modelId); - agentModelOverride = modelRef; - await noteAgentModel(modelRef); + nextConfig = applyMinimaxHostedProviderConfig(nextConfig); + agentModelOverride = MINIMAX_HOSTED_MODEL_REF; + await noteAgentModel(MINIMAX_HOSTED_MODEL_REF); } } else if (params.authChoice === "minimax") { if (params.setDefaultModel) { @@ -915,6 +678,79 @@ export async function applyAuthChoice(params: { agentModelOverride = "lmstudio/minimax-m2.1-gs32"; await noteAgentModel("lmstudio/minimax-m2.1-gs32"); } + } else if (params.authChoice === "minimax-api") { + const key = await params.prompter.text({ + message: "Enter MiniMax API key", + validate: (value) => (value?.trim() ? undefined : "Required"), + }); + await setMinimaxApiKey(String(key).trim(), params.agentDir); + nextConfig = applyAuthProfileConfig(nextConfig, { + profileId: "minimax:default", + provider: "minimax", + mode: "api_key", + }); + if (params.setDefaultModel) { + nextConfig = applyMinimaxApiConfig(nextConfig); + } else { + nextConfig = applyMinimaxApiProviderConfig(nextConfig); + agentModelOverride = "minimax/MiniMax-M2.1"; + await noteAgentModel("minimax/MiniMax-M2.1"); + } + } else if (params.authChoice === "github-copilot") { + await params.prompter.note( + [ + "This will open a GitHub device login to authorize Copilot.", + "Requires an active GitHub Copilot subscription.", + ].join("\n"), + "GitHub Copilot", + ); + + if (!process.stdin.isTTY) { + await params.prompter.note( + "GitHub Copilot login requires an interactive TTY.", + "GitHub Copilot", + ); + return { config: nextConfig, agentModelOverride }; + } + + try { + await githubCopilotLoginCommand({ yes: true }, params.runtime); + } catch (err) { + await params.prompter.note( + `GitHub Copilot login failed: ${String(err)}`, + "GitHub Copilot", + ); + return { config: nextConfig, agentModelOverride }; + } + + nextConfig = applyAuthProfileConfig(nextConfig, { + profileId: "github-copilot:github", + provider: "github-copilot", + mode: "token", + }); + + if (params.setDefaultModel) { + const model = "github-copilot/gpt-4o"; + nextConfig = { + ...nextConfig, + agents: { + ...nextConfig.agents, + defaults: { + ...nextConfig.agents?.defaults, + model: { + ...(typeof nextConfig.agents?.defaults?.model === "object" + ? nextConfig.agents.defaults.model + : undefined), + primary: model, + }, + }, + }, + }; + await params.prompter.note( + `Default model set to ${model}`, + "Model configured", + ); + } } else if (params.authChoice === "opencode-zen") { await params.prompter.note( [ @@ -924,28 +760,11 @@ export async function applyAuthChoice(params: { ].join("\n"), "OpenCode Zen", ); - let hasCredential = false; - const envKey = resolveEnvApiKey("opencode"); - if (envKey) { - const useExisting = await params.prompter.confirm({ - message: `Use existing OPENCODE_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`, - initialValue: true, - }); - if (useExisting) { - await setOpencodeZenApiKey(envKey.apiKey, params.agentDir); - hasCredential = true; - } - } - if (!hasCredential) { - const key = await params.prompter.text({ - message: "Enter OpenCode Zen API key", - validate: validateApiKeyInput, - }); - await setOpencodeZenApiKey( - normalizeApiKeyInput(String(key)), - params.agentDir, - ); - } + const key = await params.prompter.text({ + message: "Enter OpenCode Zen API key", + validate: (value) => (value?.trim() ? undefined : "Required"), + }); + await setOpencodeZenApiKey(String(key).trim(), params.agentDir); nextConfig = applyAuthProfileConfig(nextConfig, { profileId: "opencode:default", provider: "opencode", @@ -982,24 +801,19 @@ export function resolvePreferredProviderForAuthChoice( return "openai-codex"; case "openai-api-key": return "openai"; - case "openrouter-api-key": - return "openrouter"; - case "moonshot-api-key": - return "moonshot"; case "gemini-api-key": return "google"; - case "zai-api-key": - return "zai"; case "antigravity": return "google-antigravity"; case "minimax-cloud": case "minimax-api": - case "minimax-api-lightning": return "minimax"; case "minimax": return "lmstudio"; case "opencode-zen": return "opencode"; + case "github-copilot": + return "github-copilot"; default: return undefined; } diff --git a/src/commands/models.ts b/src/commands/models.ts index 90664838c..2003644a0 100644 --- a/src/commands/models.ts +++ b/src/commands/models.ts @@ -1,3 +1,4 @@ +export { githubCopilotLoginCommand } from "../providers/github-copilot-auth.js"; export { modelsAliasesAddCommand, modelsAliasesListCommand, diff --git a/src/commands/models/list.ts b/src/commands/models/list.ts index 613cc42c4..a2be81aef 100644 --- a/src/commands/models/list.ts +++ b/src/commands/models/list.ts @@ -424,10 +424,9 @@ function toModelRow(params: { const input = model.input.join("+") || "text"; const local = isLocalBaseUrl(model.baseUrl); const available = - availableKeys?.has(modelKey(model.provider, model.id)) || - (cfg && authStore + cfg && authStore ? hasAuthForProvider(model.provider, cfg, authStore) - : false); + : (availableKeys?.has(modelKey(model.provider, model.id)) ?? false); const aliasTags = aliases.length > 0 ? [`alias:${aliases.join(",")}`] : []; const mergedTags = new Set(tags); if (aliasTags.length > 0) { diff --git a/src/commands/onboard-types.ts b/src/commands/onboard-types.ts index e28a271c5..6b5caa7be 100644 --- a/src/commands/onboard-types.ts +++ b/src/commands/onboard-types.ts @@ -22,6 +22,7 @@ export type AuthChoice = | "minimax-api" | "minimax-api-lightning" | "opencode-zen" + | "github-copilot" | "skip"; export type GatewayAuthChoice = "off" | "token" | "password"; export type ResetScope = "config" | "config+creds+sessions" | "full"; diff --git a/src/config/types.ts b/src/config/types.ts index f9d91ab28..1c723122e 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -1407,7 +1407,8 @@ export type ModelApi = | "openai-completions" | "openai-responses" | "anthropic-messages" - | "google-generative-ai"; + | "google-generative-ai" + | "github-copilot"; export type ModelCompatConfig = { supportsStore?: boolean; diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index caadbf74b..af8fd547a 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -8,6 +8,7 @@ const ModelApiSchema = z.union([ z.literal("openai-responses"), z.literal("anthropic-messages"), z.literal("google-generative-ai"), + z.literal("github-copilot"), ]); const ModelCompatSchema = z diff --git a/src/providers/github-copilot-auth.ts b/src/providers/github-copilot-auth.ts new file mode 100644 index 000000000..e963c5870 --- /dev/null +++ b/src/providers/github-copilot-auth.ts @@ -0,0 +1,192 @@ +import { intro, note, outro, spinner } from "@clack/prompts"; + +import { + ensureAuthProfileStore, + upsertAuthProfile, +} from "../agents/auth-profiles.js"; +import { updateConfig } from "../commands/models/shared.js"; +import { applyAuthProfileConfig } from "../commands/onboard-auth.js"; +import { CONFIG_PATH_CLAWDBOT } from "../config/config.js"; +import type { RuntimeEnv } from "../runtime.js"; +import { stylePromptTitle } from "../terminal/prompt-style.js"; + +const CLIENT_ID = "Iv1.b507a08c87ecfe98"; +const DEVICE_CODE_URL = "https://github.com/login/device/code"; +const ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token"; + +type DeviceCodeResponse = { + device_code: string; + user_code: string; + verification_uri: string; + expires_in: number; + interval: number; +}; + +type DeviceTokenResponse = + | { + access_token: string; + token_type: string; + scope?: string; + } + | { + error: string; + error_description?: string; + error_uri?: string; + }; + +function parseJsonResponse(value: unknown): T { + if (!value || typeof value !== "object") { + throw new Error("Unexpected response from GitHub"); + } + return value as T; +} + +async function requestDeviceCode(params: { + scope: string; +}): Promise { + const body = new URLSearchParams({ + client_id: CLIENT_ID, + scope: params.scope, + }); + + const res = await fetch(DEVICE_CODE_URL, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/x-www-form-urlencoded", + }, + body, + }); + + if (!res.ok) { + throw new Error(`GitHub device code failed: HTTP ${res.status}`); + } + + const json = parseJsonResponse(await res.json()); + if (!json.device_code || !json.user_code || !json.verification_uri) { + throw new Error("GitHub device code response missing fields"); + } + return json; +} + +async function pollForAccessToken(params: { + deviceCode: string; + intervalMs: number; + expiresAt: number; +}): Promise { + const bodyBase = new URLSearchParams({ + client_id: CLIENT_ID, + device_code: params.deviceCode, + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + }); + + while (Date.now() < params.expiresAt) { + const res = await fetch(ACCESS_TOKEN_URL, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/x-www-form-urlencoded", + }, + body: bodyBase, + }); + + if (!res.ok) { + throw new Error(`GitHub device token failed: HTTP ${res.status}`); + } + + const json = parseJsonResponse(await res.json()); + if ("access_token" in json && typeof json.access_token === "string") { + return json.access_token; + } + + const err = "error" in json ? json.error : "unknown"; + if (err === "authorization_pending") { + await new Promise((r) => setTimeout(r, params.intervalMs)); + continue; + } + if (err === "slow_down") { + await new Promise((r) => setTimeout(r, params.intervalMs + 2000)); + continue; + } + if (err === "expired_token") { + throw new Error("GitHub device code expired; run login again"); + } + if (err === "access_denied") { + throw new Error("GitHub login cancelled"); + } + throw new Error(`GitHub device flow error: ${err}`); + } + + throw new Error("GitHub device code expired; run login again"); +} + +export async function githubCopilotLoginCommand( + opts: { profileId?: string; yes?: boolean }, + runtime: RuntimeEnv, +) { + if (!process.stdin.isTTY) { + throw new Error("github-copilot login requires an interactive TTY."); + } + + intro(stylePromptTitle("GitHub Copilot login")); + + const profileId = opts.profileId?.trim() || "github-copilot:github"; + const store = ensureAuthProfileStore(undefined, { + allowKeychainPrompt: false, + }); + + if (store.profiles[profileId] && !opts.yes) { + note( + `Auth profile already exists: ${profileId}\nRe-running will overwrite it.`, + stylePromptTitle("Existing credentials"), + ); + } + + const spin = spinner(); + spin.start("Requesting device code from GitHub..."); + const device = await requestDeviceCode({ scope: "read:user" }); + spin.stop("Device code ready"); + + note( + [`Visit: ${device.verification_uri}`, `Code: ${device.user_code}`].join( + "\n", + ), + stylePromptTitle("Authorize"), + ); + + const expiresAt = Date.now() + device.expires_in * 1000; + const intervalMs = Math.max(1000, device.interval * 1000); + + const polling = spinner(); + polling.start("Waiting for GitHub authorization..."); + const accessToken = await pollForAccessToken({ + deviceCode: device.device_code, + intervalMs, + expiresAt, + }); + polling.stop("GitHub access token acquired"); + + upsertAuthProfile({ + profileId, + credential: { + type: "token", + provider: "github-copilot", + token: accessToken, + // GitHub device flow token doesn't reliably include expiry here. + // Leave expires unset; we'll exchange into Copilot token plus expiry later. + }, + }); + + await updateConfig((cfg) => + applyAuthProfileConfig(cfg, { + provider: "github-copilot", + profileId, + mode: "token", + }), + ); + + runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`); + runtime.log(`Auth profile: ${profileId} (github-copilot/token)`); + + outro("Done"); +} diff --git a/src/providers/github-copilot-models.ts b/src/providers/github-copilot-models.ts new file mode 100644 index 000000000..f532bc4be --- /dev/null +++ b/src/providers/github-copilot-models.ts @@ -0,0 +1,41 @@ +import type { ModelDefinitionConfig } from "../config/types.js"; + +const DEFAULT_CONTEXT_WINDOW = 128_000; +const DEFAULT_MAX_TOKENS = 8192; + +// Copilot model ids vary by plan/org and can change. +// We keep this list intentionally broad; if a model isn't available Copilot will +// return an error and users can remove it from their config. +const DEFAULT_MODEL_IDS = [ + "gpt-4o", + "gpt-4.1", + "gpt-4.1-mini", + "gpt-4.1-nano", + "o1", + "o1-mini", + "o3-mini", +] as const; + +export function getDefaultCopilotModelIds(): string[] { + return [...DEFAULT_MODEL_IDS]; +} + +export function buildCopilotModelDefinition( + modelId: string, +): ModelDefinitionConfig { + const id = modelId.trim(); + if (!id) throw new Error("Model id required"); + return { + id, + name: id, + // pi-coding-agent's registry schema doesn't know about a "github-copilot" API. + // We use OpenAI-compatible responses API, while keeping the provider id as + // "github-copilot" (pi-ai uses that to attach Copilot-specific headers). + api: "openai-responses", + reasoning: false, + input: ["text", "image"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: DEFAULT_CONTEXT_WINDOW, + maxTokens: DEFAULT_MAX_TOKENS, + }; +} diff --git a/src/providers/github-copilot-token.test.ts b/src/providers/github-copilot-token.test.ts new file mode 100644 index 000000000..d7b4b8efc --- /dev/null +++ b/src/providers/github-copilot-token.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it, vi } from "vitest"; + +const loadJsonFile = vi.fn(); +const saveJsonFile = vi.fn(); +const resolveStateDir = vi.fn().mockReturnValue("/tmp/clawdbot-state"); + +vi.mock("../infra/json-file.js", () => ({ + loadJsonFile, + saveJsonFile, +})); + +vi.mock("../config/paths.js", () => ({ + resolveStateDir, +})); + +describe("github-copilot token", () => { + it("derives baseUrl from token", async () => { + const { deriveCopilotApiBaseUrlFromToken } = await import( + "./github-copilot-token.js" + ); + + expect( + deriveCopilotApiBaseUrlFromToken("token;proxy-ep=proxy.example.com;"), + ).toBe("https://api.example.com"); + expect( + deriveCopilotApiBaseUrlFromToken("token;proxy-ep=https://proxy.foo.bar;"), + ).toBe("https://api.foo.bar"); + }); + + it("uses cache when token is still valid", async () => { + const now = Date.now(); + loadJsonFile.mockReturnValue({ + token: "cached;proxy-ep=proxy.example.com;", + expiresAt: now + 60 * 60 * 1000, + updatedAt: now, + }); + + const { resolveCopilotApiToken } = await import( + "./github-copilot-token.js" + ); + + const fetchImpl = vi.fn(); + const res = await resolveCopilotApiToken({ + githubToken: "gh", + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + + expect(res.token).toBe("cached;proxy-ep=proxy.example.com;"); + expect(res.baseUrl).toBe("https://api.example.com"); + expect(String(res.source)).toContain("cache:"); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it("fetches and stores token when cache is missing", async () => { + loadJsonFile.mockReturnValue(undefined); + + const fetchImpl = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + token: "fresh;proxy-ep=https://proxy.contoso.test;", + expires_at: Math.floor(Date.now() / 1000) + 3600, + }), + }); + + const { resolveCopilotApiToken } = await import( + "./github-copilot-token.js" + ); + + const res = await resolveCopilotApiToken({ + githubToken: "gh", + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + + expect(res.token).toBe("fresh;proxy-ep=https://proxy.contoso.test;"); + expect(res.baseUrl).toBe("https://api.contoso.test"); + expect(saveJsonFile).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/providers/github-copilot-token.ts b/src/providers/github-copilot-token.ts new file mode 100644 index 000000000..9342609f4 --- /dev/null +++ b/src/providers/github-copilot-token.ts @@ -0,0 +1,140 @@ +import path from "node:path"; + +import { resolveStateDir } from "../config/paths.js"; +import { loadJsonFile, saveJsonFile } from "../infra/json-file.js"; + +const COPILOT_TOKEN_URL = "https://api.github.com/copilot_internal/v2/token"; + +export type CachedCopilotToken = { + token: string; + /** milliseconds since epoch */ + expiresAt: number; + /** milliseconds since epoch */ + updatedAt: number; +}; + +function resolveCopilotTokenCachePath(env: NodeJS.ProcessEnv = process.env) { + return path.join( + resolveStateDir(env), + "credentials", + "github-copilot.token.json", + ); +} + +function isTokenUsable(cache: CachedCopilotToken, now = Date.now()): boolean { + // Keep a small safety margin when checking expiry. + return cache.expiresAt - now > 5 * 60 * 1000; +} + +function parseCopilotTokenResponse(value: unknown): { + token: string; + expiresAt: number; +} { + if (!value || typeof value !== "object") { + throw new Error("Unexpected response from GitHub Copilot token endpoint"); + } + const asRecord = value as Record; + const token = asRecord.token; + const expiresAt = asRecord.expires_at; + if (typeof token !== "string" || token.trim().length === 0) { + throw new Error("Copilot token response missing token"); + } + + // GitHub returns a unix timestamp (seconds), but we defensively accept ms too. + let expiresAtMs: number; + if (typeof expiresAt === "number" && Number.isFinite(expiresAt)) { + expiresAtMs = expiresAt > 10_000_000_000 ? expiresAt : expiresAt * 1000; + } else if (typeof expiresAt === "string" && expiresAt.trim().length > 0) { + const parsed = Number.parseInt(expiresAt, 10); + if (!Number.isFinite(parsed)) { + throw new Error("Copilot token response has invalid expires_at"); + } + expiresAtMs = parsed > 10_000_000_000 ? parsed : parsed * 1000; + } else { + throw new Error("Copilot token response missing expires_at"); + } + + return { token, expiresAt: expiresAtMs }; +} + +export const DEFAULT_COPILOT_API_BASE_URL = + "https://api.individual.githubcopilot.com"; + +export function deriveCopilotApiBaseUrlFromToken(token: string): string | null { + const trimmed = token.trim(); + if (!trimmed) return null; + + // The token returned from the Copilot token endpoint is a semicolon-delimited + // set of key/value pairs. One of them is `proxy-ep=...`. + const match = trimmed.match(/(?:^|;)\s*proxy-ep=([^;\s]+)/i); + const proxyEp = match?.[1]?.trim(); + if (!proxyEp) return null; + + // pi-ai expects converting proxy.* -> api.* + // (see upstream getGitHubCopilotBaseUrl). + const host = proxyEp.replace(/^https?:\/\//, "").replace(/^proxy\./i, "api."); + if (!host) return null; + + return `https://${host}`; +} + +export async function resolveCopilotApiToken(params: { + githubToken: string; + env?: NodeJS.ProcessEnv; + fetchImpl?: typeof fetch; +}): Promise<{ + token: string; + expiresAt: number; + source: string; + baseUrl: string; +}> { + const env = params.env ?? process.env; + const cachePath = resolveCopilotTokenCachePath(env); + const cached = loadJsonFile(cachePath) as CachedCopilotToken | undefined; + if ( + cached && + typeof cached.token === "string" && + typeof cached.expiresAt === "number" + ) { + if (isTokenUsable(cached)) { + return { + token: cached.token, + expiresAt: cached.expiresAt, + source: `cache:${cachePath}`, + baseUrl: + deriveCopilotApiBaseUrlFromToken(cached.token) ?? + DEFAULT_COPILOT_API_BASE_URL, + }; + } + } + + const fetchImpl = params.fetchImpl ?? fetch; + const res = await fetchImpl(COPILOT_TOKEN_URL, { + method: "GET", + headers: { + Accept: "application/json", + Authorization: `Bearer ${params.githubToken}`, + }, + }); + + if (!res.ok) { + throw new Error(`Copilot token exchange failed: HTTP ${res.status}`); + } + + const json = parseCopilotTokenResponse(await res.json()); + const payload: CachedCopilotToken = { + token: json.token, + expiresAt: json.expiresAt, + updatedAt: Date.now(), + }; + saveJsonFile(cachePath, payload); + + return { + token: payload.token, + expiresAt: payload.expiresAt, + source: `fetched:${COPILOT_TOKEN_URL}`, + baseUrl: + deriveCopilotApiBaseUrlFromToken(payload.token) ?? + DEFAULT_COPILOT_API_BASE_URL, + }; +}