feat: add GitHub Copilot provider

Copilot device login + onboarding option; model list auth detection.
This commit is contained in:
Mustafa Tag Eldeen
2026-01-11 05:19:07 +02:00
committed by Peter Steinberger
parent 717a259056
commit 3da1afed68
19 changed files with 926 additions and 1122 deletions

View File

@@ -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<string, { baseUrl?: string; models?: unknown[] }>;
};
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(() => {

View File

@@ -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<ClawdbotConfig["models"]>;
type ProviderConfig = NonNullable<ModelsConfig["providers"]>[string];
type ModelsProviderConfig = NonNullable<ModelsConfigShape["providers"]>[string];
const DEFAULT_MODE: NonNullable<ModelsConfig["mode"]> = "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<string, unknown> {
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<string, ProviderConfig> = {};
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<unknown> {
try {
const raw = await fs.readFile(pathname, "utf8");
@@ -67,37 +32,62 @@ async function readJson(pathname: string): Promise<unknown> {
}
}
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<string, ProviderConfig> = {};
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<ModelsProviderConfig | null> {
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 {

View File

@@ -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<typeof import("@mariozechner/pi-ai")>(
"@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);
});
});

View File

@@ -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<void> => {
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;
};

View File

@@ -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<typeof import("../commands/models.js")>(
"../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),
);
});
});

View File

@@ -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 <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");

View File

@@ -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({

View File

@@ -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" });

View File

@@ -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<string, { key?: string }>;
};
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<string, { key?: string }>;
};
expect(parsed.profiles?.["openrouter:default"]?.key).toBe(
"sk-openrouter-test",
);
delete process.env.OPENROUTER_API_KEY;
});
});

View File

@@ -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;
}

View File

@@ -1,3 +1,4 @@
export { githubCopilotLoginCommand } from "../providers/github-copilot-auth.js";
export {
modelsAliasesAddCommand,
modelsAliasesListCommand,

View File

@@ -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) {

View File

@@ -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";

View File

@@ -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;

View File

@@ -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

View File

@@ -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<T>(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<DeviceCodeResponse> {
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<DeviceCodeResponse>(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<string> {
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<DeviceTokenResponse>(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");
}

View File

@@ -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,
};
}

View File

@@ -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);
});
});

View File

@@ -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<string, unknown>;
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,
};
}