feat: add GitHub Copilot provider
Copilot device login + onboarding option; model list auth detection.
This commit is contained in:
committed by
Peter Steinberger
parent
717a259056
commit
3da1afed68
@@ -34,6 +34,48 @@ const MODELS_CONFIG: ClawdbotConfig = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe("models config", () => {
|
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;
|
let previousHome: string | undefined;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|||||||
@@ -2,62 +2,27 @@ import fs from "node:fs/promises";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
import { type ClawdbotConfig, loadConfig } from "../config/config.js";
|
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 { resolveClawdbotAgentDir } from "./agent-paths.js";
|
||||||
import { ensureAuthProfileStore, listProfilesForProvider } from "./auth-profiles.js";
|
import {
|
||||||
import { resolveEnvApiKey } from "./model-auth.js";
|
ensureAuthProfileStore,
|
||||||
|
listProfilesForProvider,
|
||||||
|
} from "./auth-profiles.js";
|
||||||
|
|
||||||
type ModelsConfig = NonNullable<ClawdbotConfig["models"]>;
|
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 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> {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
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> {
|
async function readJson(pathname: string): Promise<unknown> {
|
||||||
try {
|
try {
|
||||||
const raw = await fs.readFile(pathname, "utf8");
|
const raw = await fs.readFile(pathname, "utf8");
|
||||||
@@ -67,37 +32,62 @@ async function readJson(pathname: string): Promise<unknown> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildMinimaxApiProvider(): ProviderConfig {
|
async function maybeBuildCopilotProvider(params: {
|
||||||
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: {
|
|
||||||
cfg: ClawdbotConfig;
|
cfg: ClawdbotConfig;
|
||||||
agentDir: string;
|
env?: NodeJS.ProcessEnv;
|
||||||
}): ModelsConfig["providers"] {
|
}): Promise<ModelsProviderConfig | null> {
|
||||||
const providers: Record<string, ProviderConfig> = {};
|
const env = params.env ?? process.env;
|
||||||
const minimaxEnv = resolveEnvApiKey("minimax");
|
const authStore = ensureAuthProfileStore();
|
||||||
const authStore = ensureAuthProfileStore(params.agentDir);
|
const hasProfile =
|
||||||
const hasMinimaxProfile =
|
listProfilesForProvider(authStore, "github-copilot").length > 0;
|
||||||
listProfilesForProvider(authStore, "minimax").length > 0;
|
const envToken = env.COPILOT_GITHUB_TOKEN ?? env.GH_TOKEN ?? env.GITHUB_TOKEN;
|
||||||
if (minimaxEnv || hasMinimaxProfile) {
|
const githubToken = (envToken ?? "").trim();
|
||||||
providers.minimax = buildMinimaxApiProvider();
|
|
||||||
|
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(
|
export async function ensureClawdbotModelsJson(
|
||||||
@@ -105,17 +95,24 @@ export async function ensureClawdbotModelsJson(
|
|||||||
agentDirOverride?: string,
|
agentDirOverride?: string,
|
||||||
): Promise<{ agentDir: string; wrote: boolean }> {
|
): Promise<{ agentDir: string; wrote: boolean }> {
|
||||||
const cfg = config ?? loadConfig();
|
const cfg = config ?? loadConfig();
|
||||||
const agentDir = agentDirOverride?.trim()
|
|
||||||
? agentDirOverride.trim()
|
const explicitProviders = cfg.models?.providers ?? {};
|
||||||
: resolveClawdbotAgentDir();
|
const implicitCopilot = await maybeBuildCopilotProvider({ cfg });
|
||||||
const configuredProviders = cfg.models?.providers ?? {};
|
const providers = implicitCopilot
|
||||||
const implicitProviders = resolveImplicitProviders({ cfg, agentDir });
|
? { ...explicitProviders, "github-copilot": implicitCopilot }
|
||||||
const providers = { ...implicitProviders, ...configuredProviders };
|
: explicitProviders;
|
||||||
if (Object.keys(providers).length === 0) {
|
|
||||||
|
if (!providers || Object.keys(providers).length === 0) {
|
||||||
|
const agentDir = agentDirOverride?.trim()
|
||||||
|
? agentDirOverride.trim()
|
||||||
|
: resolveClawdbotAgentDir();
|
||||||
return { agentDir, wrote: false };
|
return { agentDir, wrote: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
const mode = cfg.models?.mode ?? DEFAULT_MODE;
|
const mode = cfg.models?.mode ?? DEFAULT_MODE;
|
||||||
|
const agentDir = agentDirOverride?.trim()
|
||||||
|
? agentDirOverride.trim()
|
||||||
|
: resolveClawdbotAgentDir();
|
||||||
const targetPath = path.join(agentDir, "models.json");
|
const targetPath = path.join(agentDir, "models.json");
|
||||||
|
|
||||||
let mergedProviders = providers;
|
let mergedProviders = providers;
|
||||||
@@ -131,8 +128,7 @@ export async function ensureClawdbotModelsJson(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedProviders = normalizeProviders(mergedProviders);
|
const next = `${JSON.stringify({ providers: mergedProviders }, null, 2)}\n`;
|
||||||
const next = `${JSON.stringify({ providers: normalizedProviders }, null, 2)}\n`;
|
|
||||||
try {
|
try {
|
||||||
existingRaw = await fs.readFile(targetPath, "utf8");
|
existingRaw = await fs.readFile(targetPath, "utf8");
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -1,114 +1,39 @@
|
|||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
import type { AgentMessage, AgentTool } from "@mariozechner/pi-agent-core";
|
import type { AgentMessage, AgentTool } from "@mariozechner/pi-agent-core";
|
||||||
import { SessionManager } from "@mariozechner/pi-coding-agent";
|
import { SessionManager } from "@mariozechner/pi-coding-agent";
|
||||||
import { Type } from "@sinclair/typebox";
|
import { Type } from "@sinclair/typebox";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import type { ClawdbotConfig } from "../config/config.js";
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
import { resolveSessionAgentIds } from "./agent-scope.js";
|
import { resolveSessionAgentIds } from "./agent-scope.js";
|
||||||
import {
|
import {
|
||||||
applyGoogleTurnOrderingFix,
|
applyGoogleTurnOrderingFix,
|
||||||
buildEmbeddedSandboxInfo,
|
buildEmbeddedSandboxInfo,
|
||||||
createSystemPromptOverride,
|
createSystemPromptOverride,
|
||||||
getDmHistoryLimitFromSessionKey,
|
|
||||||
limitHistoryTurns,
|
|
||||||
runEmbeddedPiAgent,
|
runEmbeddedPiAgent,
|
||||||
splitSdkTools,
|
splitSdkTools,
|
||||||
} from "./pi-embedded-runner.js";
|
} from "./pi-embedded-runner.js";
|
||||||
import type { SandboxContext } from "./sandbox.js";
|
import type { SandboxContext } from "./sandbox.js";
|
||||||
|
|
||||||
vi.mock("@mariozechner/pi-ai", async () => {
|
vi.mock("./model-auth.js", () => ({
|
||||||
const actual = await vi.importActual<typeof import("@mariozechner/pi-ai")>(
|
getApiKeyForModel: vi.fn(),
|
||||||
"@mariozechner/pi-ai",
|
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 {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
streamSimple: (model: { api: string; provider: string; id: string }) => {
|
resolveCopilotApiToken: vi.fn(),
|
||||||
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;
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
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", () => {
|
describe("buildEmbeddedSandboxInfo", () => {
|
||||||
it("returns undefined when sandbox is missing", () => {
|
it("returns undefined when sandbox is missing", () => {
|
||||||
expect(buildEmbeddedSandboxInfo()).toBeUndefined();
|
expect(buildEmbeddedSandboxInfo()).toBeUndefined();
|
||||||
@@ -135,7 +60,7 @@ describe("buildEmbeddedSandboxInfo", () => {
|
|||||||
env: { LANG: "C.UTF-8" },
|
env: { LANG: "C.UTF-8" },
|
||||||
},
|
},
|
||||||
tools: {
|
tools: {
|
||||||
allow: ["exec"],
|
allow: ["bash"],
|
||||||
deny: ["browser"],
|
deny: ["browser"],
|
||||||
},
|
},
|
||||||
browserAllowHostControl: true,
|
browserAllowHostControl: true,
|
||||||
@@ -178,7 +103,7 @@ describe("buildEmbeddedSandboxInfo", () => {
|
|||||||
env: { LANG: "C.UTF-8" },
|
env: { LANG: "C.UTF-8" },
|
||||||
},
|
},
|
||||||
tools: {
|
tools: {
|
||||||
allow: ["exec"],
|
allow: ["bash"],
|
||||||
deny: ["browser"],
|
deny: ["browser"],
|
||||||
},
|
},
|
||||||
browserAllowHostControl: false,
|
browserAllowHostControl: false,
|
||||||
@@ -262,7 +187,7 @@ function createStubTool(name: string): AgentTool {
|
|||||||
describe("splitSdkTools", () => {
|
describe("splitSdkTools", () => {
|
||||||
const tools = [
|
const tools = [
|
||||||
createStubTool("read"),
|
createStubTool("read"),
|
||||||
createStubTool("exec"),
|
createStubTool("bash"),
|
||||||
createStubTool("edit"),
|
createStubTool("edit"),
|
||||||
createStubTool("write"),
|
createStubTool("write"),
|
||||||
createStubTool("browser"),
|
createStubTool("browser"),
|
||||||
@@ -276,7 +201,7 @@ describe("splitSdkTools", () => {
|
|||||||
expect(builtInTools).toEqual([]);
|
expect(builtInTools).toEqual([]);
|
||||||
expect(customTools.map((tool) => tool.name)).toEqual([
|
expect(customTools.map((tool) => tool.name)).toEqual([
|
||||||
"read",
|
"read",
|
||||||
"exec",
|
"bash",
|
||||||
"edit",
|
"edit",
|
||||||
"write",
|
"write",
|
||||||
"browser",
|
"browser",
|
||||||
@@ -291,7 +216,7 @@ describe("splitSdkTools", () => {
|
|||||||
expect(builtInTools).toEqual([]);
|
expect(builtInTools).toEqual([]);
|
||||||
expect(customTools.map((tool) => tool.name)).toEqual([
|
expect(customTools.map((tool) => tool.name)).toEqual([
|
||||||
"read",
|
"read",
|
||||||
"exec",
|
"bash",
|
||||||
"edit",
|
"edit",
|
||||||
"write",
|
"write",
|
||||||
"browser",
|
"browser",
|
||||||
@@ -317,7 +242,7 @@ describe("applyGoogleTurnOrderingFix", () => {
|
|||||||
{
|
{
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
content: [
|
content: [
|
||||||
{ type: "toolCall", id: "call_1", name: "exec", arguments: {} },
|
{ type: "toolCall", id: "call_1", name: "bash", arguments: {} },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
] satisfies AgentMessage[];
|
] 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", () => {
|
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 () => {
|
it("writes models.json into the provided agentDir", async () => {
|
||||||
const agentDir = await fs.mkdtemp(
|
const agentDir = await fs.mkdtemp(
|
||||||
path.join(os.tmpdir(), "clawdbot-agent-"),
|
path.join(os.tmpdir(), "clawdbot-agent-"),
|
||||||
@@ -660,12 +354,12 @@ describe("runEmbeddedPiAgent", () => {
|
|||||||
models: {
|
models: {
|
||||||
providers: {
|
providers: {
|
||||||
minimax: {
|
minimax: {
|
||||||
baseUrl: "https://api.minimax.io/anthropic",
|
baseUrl: "https://api.minimax.io/v1",
|
||||||
api: "anthropic-messages",
|
api: "openai-completions",
|
||||||
apiKey: "sk-minimax-test",
|
apiKey: "sk-minimax-test",
|
||||||
models: [
|
models: [
|
||||||
{
|
{
|
||||||
id: "MiniMax-M2.1",
|
id: "minimax-m2.1",
|
||||||
name: "MiniMax M2.1",
|
name: "MiniMax M2.1",
|
||||||
reasoning: false,
|
reasoning: false,
|
||||||
input: ["text"],
|
input: ["text"],
|
||||||
@@ -698,216 +392,4 @@ describe("runEmbeddedPiAgent", () => {
|
|||||||
fs.stat(path.join(agentDir, "models.json")),
|
fs.stat(path.join(agentDir, "models.json")),
|
||||||
).resolves.toBeTruthy();
|
).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);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1042,7 +1042,18 @@ export async function compactEmbeddedPiSession(params: {
|
|||||||
model,
|
model,
|
||||||
cfg: params.config,
|
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) {
|
} catch (err) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
@@ -1432,7 +1443,19 @@ export async function runEmbeddedPiAgent(params: {
|
|||||||
|
|
||||||
const applyApiKeyInfo = async (candidate?: string): Promise<void> => {
|
const applyApiKeyInfo = async (candidate?: string): Promise<void> => {
|
||||||
apiKeyInfo = await resolveApiKeyForCandidate(candidate);
|
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;
|
lastProfileId = apiKeyInfo.profileId;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
48
src/cli/models-cli.test.ts
Normal file
48
src/cli/models-cli.test.ts
Normal 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),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Command } from "commander";
|
import type { Command } from "commander";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
githubCopilotLoginCommand,
|
||||||
modelsAliasesAddCommand,
|
modelsAliasesAddCommand,
|
||||||
modelsAliasesListCommand,
|
modelsAliasesListCommand,
|
||||||
modelsAliasesRemoveCommand,
|
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
|
const order = auth
|
||||||
.command("order")
|
.command("order")
|
||||||
.description("Manage per-agent auth profile order overrides");
|
.description("Manage per-agent auth profile order overrides");
|
||||||
|
|||||||
@@ -7,6 +7,17 @@ import {
|
|||||||
import { buildAuthChoiceOptions } from "./auth-choice-options.js";
|
import { buildAuthChoiceOptions } from "./auth-choice-options.js";
|
||||||
|
|
||||||
describe("buildAuthChoiceOptions", () => {
|
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", () => {
|
it("includes Claude CLI option on macOS even when missing", () => {
|
||||||
const store: AuthProfileStore = { version: 1, profiles: {} };
|
const store: AuthProfileStore = { version: 1, profiles: {} };
|
||||||
const options = buildAuthChoiceOptions({
|
const options = buildAuthChoiceOptions({
|
||||||
|
|||||||
@@ -171,6 +171,11 @@ export function buildAuthChoiceOptions(params: {
|
|||||||
value: "antigravity",
|
value: "antigravity",
|
||||||
label: "Google Antigravity (Claude Opus 4.5, Gemini 3, etc.)",
|
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: "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: "zai-api-key", label: "Z.AI (GLM 4.7) API key" });
|
||||||
options.push({ value: "apiKey", label: "Anthropic API key" });
|
options.push({ value: "apiKey", label: "Anthropic API key" });
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ import type { RuntimeEnv } from "../runtime.js";
|
|||||||
import type { WizardPrompter } from "../wizard/prompts.js";
|
import type { WizardPrompter } from "../wizard/prompts.js";
|
||||||
import { applyAuthChoice } from "./auth-choice.js";
|
import { applyAuthChoice } from "./auth-choice.js";
|
||||||
|
|
||||||
|
vi.mock("../providers/github-copilot-auth.js", () => ({
|
||||||
|
githubCopilotLoginCommand: vi.fn(async () => {}),
|
||||||
|
}));
|
||||||
|
|
||||||
const noopAsync = async () => {};
|
const noopAsync = async () => {};
|
||||||
const noop = () => {};
|
const noop = () => {};
|
||||||
|
|
||||||
@@ -15,7 +19,6 @@ describe("applyAuthChoice", () => {
|
|||||||
const previousStateDir = process.env.CLAWDBOT_STATE_DIR;
|
const previousStateDir = process.env.CLAWDBOT_STATE_DIR;
|
||||||
const previousAgentDir = process.env.CLAWDBOT_AGENT_DIR;
|
const previousAgentDir = process.env.CLAWDBOT_AGENT_DIR;
|
||||||
const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR;
|
const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR;
|
||||||
const previousOpenrouterKey = process.env.OPENROUTER_API_KEY;
|
|
||||||
let tempStateDir: string | null = null;
|
let tempStateDir: string | null = null;
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
@@ -38,11 +41,6 @@ describe("applyAuthChoice", () => {
|
|||||||
} else {
|
} else {
|
||||||
process.env.PI_CODING_AGENT_DIR = previousPiAgentDir;
|
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 () => {
|
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.CLAWDBOT_AGENT_DIR = path.join(tempStateDir, "agent");
|
||||||
process.env.PI_CODING_AGENT_DIR = process.env.CLAWDBOT_AGENT_DIR;
|
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 text = vi.fn().mockResolvedValue("sk-minimax-test");
|
||||||
const select: WizardPrompter["select"] = vi.fn(
|
const select: WizardPrompter["select"] = vi.fn(
|
||||||
async (params) => params.options[0]?.value as never,
|
async (params) => params.options[0]?.value as never,
|
||||||
@@ -153,12 +83,9 @@ describe("applyAuthChoice", () => {
|
|||||||
expect(text).toHaveBeenCalledWith(
|
expect(text).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ message: "Enter MiniMax API key" }),
|
expect.objectContaining({ message: "Enter MiniMax API key" }),
|
||||||
);
|
);
|
||||||
expect(result.config.models?.providers?.minimax).toMatchObject({
|
expect(result.config.auth?.profiles?.["minimax:default"]).toMatchObject({
|
||||||
baseUrl: "https://api.minimax.io/anthropic",
|
provider: "minimax",
|
||||||
api: "anthropic-messages",
|
mode: "api_key",
|
||||||
});
|
|
||||||
expect(result.config.agents?.defaults?.model).toMatchObject({
|
|
||||||
primary: "minimax/MiniMax-M2.1",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const authProfilePath = path.join(
|
const authProfilePath = path.join(
|
||||||
@@ -174,6 +101,52 @@ describe("applyAuthChoice", () => {
|
|||||||
};
|
};
|
||||||
expect(parsed.profiles?.["minimax:default"]?.key).toBe("sk-minimax-test");
|
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 () => {
|
it("does not override the default model when selecting opencode-zen without setDefaultModel", async () => {
|
||||||
tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-auth-"));
|
tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-auth-"));
|
||||||
process.env.CLAWDBOT_STATE_DIR = tempStateDir;
|
process.env.CLAWDBOT_STATE_DIR = tempStateDir;
|
||||||
@@ -226,75 +199,4 @@ describe("applyAuthChoice", () => {
|
|||||||
expect(result.config.models?.providers?.["opencode-zen"]).toBeUndefined();
|
expect(result.config.models?.providers?.["opencode-zen"]).toBeUndefined();
|
||||||
expect(result.agentModelOverride).toBe("opencode/claude-opus-4-5");
|
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;
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
CODEX_CLI_PROFILE_ID,
|
CODEX_CLI_PROFILE_ID,
|
||||||
ensureAuthProfileStore,
|
ensureAuthProfileStore,
|
||||||
listProfilesForProvider,
|
listProfilesForProvider,
|
||||||
resolveAuthProfileOrder,
|
|
||||||
upsertAuthProfile,
|
upsertAuthProfile,
|
||||||
} from "../agents/auth-profiles.js";
|
} from "../agents/auth-profiles.js";
|
||||||
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.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 { resolveConfiguredModelRef } from "../agents/model-selection.js";
|
||||||
import type { ClawdbotConfig } from "../config/config.js";
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
import { upsertSharedEnvVar } from "../infra/env-file.js";
|
import { upsertSharedEnvVar } from "../infra/env-file.js";
|
||||||
|
import { githubCopilotLoginCommand } from "../providers/github-copilot-auth.js";
|
||||||
import type { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
import type { WizardPrompter } from "../wizard/prompts.js";
|
import type { WizardPrompter } from "../wizard/prompts.js";
|
||||||
import {
|
import {
|
||||||
@@ -40,22 +40,17 @@ import {
|
|||||||
applyMinimaxApiConfig,
|
applyMinimaxApiConfig,
|
||||||
applyMinimaxApiProviderConfig,
|
applyMinimaxApiProviderConfig,
|
||||||
applyMinimaxConfig,
|
applyMinimaxConfig,
|
||||||
|
applyMinimaxHostedConfig,
|
||||||
|
applyMinimaxHostedProviderConfig,
|
||||||
applyMinimaxProviderConfig,
|
applyMinimaxProviderConfig,
|
||||||
applyMoonshotConfig,
|
|
||||||
applyMoonshotProviderConfig,
|
|
||||||
applyOpencodeZenConfig,
|
applyOpencodeZenConfig,
|
||||||
applyOpencodeZenProviderConfig,
|
applyOpencodeZenProviderConfig,
|
||||||
applyOpenrouterConfig,
|
|
||||||
applyOpenrouterProviderConfig,
|
|
||||||
applyZaiConfig,
|
applyZaiConfig,
|
||||||
MOONSHOT_DEFAULT_MODEL_REF,
|
MINIMAX_HOSTED_MODEL_REF,
|
||||||
OPENROUTER_DEFAULT_MODEL_REF,
|
|
||||||
setAnthropicApiKey,
|
setAnthropicApiKey,
|
||||||
setGeminiApiKey,
|
setGeminiApiKey,
|
||||||
setMinimaxApiKey,
|
setMinimaxApiKey,
|
||||||
setMoonshotApiKey,
|
|
||||||
setOpencodeZenApiKey,
|
setOpencodeZenApiKey,
|
||||||
setOpenrouterApiKey,
|
|
||||||
setZaiApiKey,
|
setZaiApiKey,
|
||||||
writeOAuthCredentials,
|
writeOAuthCredentials,
|
||||||
ZAI_DEFAULT_MODEL_REF,
|
ZAI_DEFAULT_MODEL_REF,
|
||||||
@@ -68,55 +63,6 @@ import {
|
|||||||
} from "./openai-codex-model-default.js";
|
} from "./openai-codex-model-default.js";
|
||||||
import { OPENCODE_ZEN_DEFAULT_MODEL } from "./opencode-zen-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(
|
export async function warnIfModelConfigLooksOff(
|
||||||
config: ClawdbotConfig,
|
config: ClawdbotConfig,
|
||||||
prompter: WizardPrompter,
|
prompter: WizardPrompter,
|
||||||
@@ -388,7 +334,7 @@ export async function applyAuthChoice(params: {
|
|||||||
const envKey = resolveEnvApiKey("openai");
|
const envKey = resolveEnvApiKey("openai");
|
||||||
if (envKey) {
|
if (envKey) {
|
||||||
const useExisting = await params.prompter.confirm({
|
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,
|
initialValue: true,
|
||||||
});
|
});
|
||||||
if (useExisting) {
|
if (useExisting) {
|
||||||
@@ -409,9 +355,9 @@ export async function applyAuthChoice(params: {
|
|||||||
|
|
||||||
const key = await params.prompter.text({
|
const key = await params.prompter.text({
|
||||||
message: "Enter OpenAI API key",
|
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({
|
const result = upsertSharedEnvVar({
|
||||||
key: "OPENAI_API_KEY",
|
key: "OPENAI_API_KEY",
|
||||||
value: trimmed,
|
value: trimmed,
|
||||||
@@ -421,115 +367,6 @@ export async function applyAuthChoice(params: {
|
|||||||
`Saved OPENAI_API_KEY to ${result.path} for launchd compatibility.`,
|
`Saved OPENAI_API_KEY to ${result.path} for launchd compatibility.`,
|
||||||
"OpenAI API key",
|
"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") {
|
} else if (params.authChoice === "openai-codex") {
|
||||||
const isRemote = isRemoteEnvironment();
|
const isRemote = isRemoteEnvironment();
|
||||||
await params.prompter.note(
|
await params.prompter.note(
|
||||||
@@ -742,28 +579,11 @@ export async function applyAuthChoice(params: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else if (params.authChoice === "gemini-api-key") {
|
} else if (params.authChoice === "gemini-api-key") {
|
||||||
let hasCredential = false;
|
const key = await params.prompter.text({
|
||||||
const envKey = resolveEnvApiKey("google");
|
message: "Enter Gemini API key",
|
||||||
if (envKey) {
|
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||||
const useExisting = await params.prompter.confirm({
|
});
|
||||||
message: `Use existing GEMINI_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`,
|
await setGeminiApiKey(String(key).trim(), params.agentDir);
|
||||||
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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||||
profileId: "google:default",
|
profileId: "google:default",
|
||||||
provider: "google",
|
provider: "google",
|
||||||
@@ -783,25 +603,11 @@ export async function applyAuthChoice(params: {
|
|||||||
await noteAgentModel(GOOGLE_GEMINI_DEFAULT_MODEL);
|
await noteAgentModel(GOOGLE_GEMINI_DEFAULT_MODEL);
|
||||||
}
|
}
|
||||||
} else if (params.authChoice === "zai-api-key") {
|
} else if (params.authChoice === "zai-api-key") {
|
||||||
let hasCredential = false;
|
const key = await params.prompter.text({
|
||||||
const envKey = resolveEnvApiKey("zai");
|
message: "Enter Z.AI API key",
|
||||||
if (envKey) {
|
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||||
const useExisting = await params.prompter.confirm({
|
});
|
||||||
message: `Use existing ZAI_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`,
|
await setZaiApiKey(String(key).trim(), params.agentDir);
|
||||||
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);
|
|
||||||
}
|
|
||||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||||
profileId: "zai:default",
|
profileId: "zai:default",
|
||||||
provider: "zai",
|
provider: "zai",
|
||||||
@@ -836,76 +642,33 @@ export async function applyAuthChoice(params: {
|
|||||||
await noteAgentModel(ZAI_DEFAULT_MODEL_REF);
|
await noteAgentModel(ZAI_DEFAULT_MODEL_REF);
|
||||||
}
|
}
|
||||||
} else if (params.authChoice === "apiKey") {
|
} else if (params.authChoice === "apiKey") {
|
||||||
let hasCredential = false;
|
const key = await params.prompter.text({
|
||||||
const envKey = process.env.ANTHROPIC_API_KEY?.trim();
|
message: "Enter Anthropic API key",
|
||||||
if (envKey) {
|
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||||
const useExisting = await params.prompter.confirm({
|
});
|
||||||
message: `Use existing ANTHROPIC_API_KEY (env, ${formatApiKeyPreview(envKey)})?`,
|
await setAnthropicApiKey(String(key).trim(), params.agentDir);
|
||||||
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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||||
profileId: "anthropic:default",
|
profileId: "anthropic:default",
|
||||||
provider: "anthropic",
|
provider: "anthropic",
|
||||||
mode: "api_key",
|
mode: "api_key",
|
||||||
});
|
});
|
||||||
} else if (
|
} else if (params.authChoice === "minimax-cloud") {
|
||||||
params.authChoice === "minimax-cloud" ||
|
const key = await params.prompter.text({
|
||||||
params.authChoice === "minimax-api" ||
|
message: "Enter MiniMax API key",
|
||||||
params.authChoice === "minimax-api-lightning"
|
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||||
) {
|
});
|
||||||
const modelId =
|
await setMinimaxApiKey(String(key).trim(), params.agentDir);
|
||||||
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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||||
profileId: "minimax:default",
|
profileId: "minimax:default",
|
||||||
provider: "minimax",
|
provider: "minimax",
|
||||||
mode: "api_key",
|
mode: "api_key",
|
||||||
});
|
});
|
||||||
if (params.setDefaultModel) {
|
if (params.setDefaultModel) {
|
||||||
nextConfig = applyMinimaxApiConfig(nextConfig, modelId);
|
nextConfig = applyMinimaxHostedConfig(nextConfig);
|
||||||
} else {
|
} else {
|
||||||
const modelRef = `minimax/${modelId}`;
|
nextConfig = applyMinimaxHostedProviderConfig(nextConfig);
|
||||||
nextConfig = applyMinimaxApiProviderConfig(nextConfig, modelId);
|
agentModelOverride = MINIMAX_HOSTED_MODEL_REF;
|
||||||
agentModelOverride = modelRef;
|
await noteAgentModel(MINIMAX_HOSTED_MODEL_REF);
|
||||||
await noteAgentModel(modelRef);
|
|
||||||
}
|
}
|
||||||
} else if (params.authChoice === "minimax") {
|
} else if (params.authChoice === "minimax") {
|
||||||
if (params.setDefaultModel) {
|
if (params.setDefaultModel) {
|
||||||
@@ -915,6 +678,79 @@ export async function applyAuthChoice(params: {
|
|||||||
agentModelOverride = "lmstudio/minimax-m2.1-gs32";
|
agentModelOverride = "lmstudio/minimax-m2.1-gs32";
|
||||||
await noteAgentModel("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") {
|
} else if (params.authChoice === "opencode-zen") {
|
||||||
await params.prompter.note(
|
await params.prompter.note(
|
||||||
[
|
[
|
||||||
@@ -924,28 +760,11 @@ export async function applyAuthChoice(params: {
|
|||||||
].join("\n"),
|
].join("\n"),
|
||||||
"OpenCode Zen",
|
"OpenCode Zen",
|
||||||
);
|
);
|
||||||
let hasCredential = false;
|
const key = await params.prompter.text({
|
||||||
const envKey = resolveEnvApiKey("opencode");
|
message: "Enter OpenCode Zen API key",
|
||||||
if (envKey) {
|
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||||
const useExisting = await params.prompter.confirm({
|
});
|
||||||
message: `Use existing OPENCODE_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`,
|
await setOpencodeZenApiKey(String(key).trim(), params.agentDir);
|
||||||
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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||||
profileId: "opencode:default",
|
profileId: "opencode:default",
|
||||||
provider: "opencode",
|
provider: "opencode",
|
||||||
@@ -982,24 +801,19 @@ export function resolvePreferredProviderForAuthChoice(
|
|||||||
return "openai-codex";
|
return "openai-codex";
|
||||||
case "openai-api-key":
|
case "openai-api-key":
|
||||||
return "openai";
|
return "openai";
|
||||||
case "openrouter-api-key":
|
|
||||||
return "openrouter";
|
|
||||||
case "moonshot-api-key":
|
|
||||||
return "moonshot";
|
|
||||||
case "gemini-api-key":
|
case "gemini-api-key":
|
||||||
return "google";
|
return "google";
|
||||||
case "zai-api-key":
|
|
||||||
return "zai";
|
|
||||||
case "antigravity":
|
case "antigravity":
|
||||||
return "google-antigravity";
|
return "google-antigravity";
|
||||||
case "minimax-cloud":
|
case "minimax-cloud":
|
||||||
case "minimax-api":
|
case "minimax-api":
|
||||||
case "minimax-api-lightning":
|
|
||||||
return "minimax";
|
return "minimax";
|
||||||
case "minimax":
|
case "minimax":
|
||||||
return "lmstudio";
|
return "lmstudio";
|
||||||
case "opencode-zen":
|
case "opencode-zen":
|
||||||
return "opencode";
|
return "opencode";
|
||||||
|
case "github-copilot":
|
||||||
|
return "github-copilot";
|
||||||
default:
|
default:
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
export { githubCopilotLoginCommand } from "../providers/github-copilot-auth.js";
|
||||||
export {
|
export {
|
||||||
modelsAliasesAddCommand,
|
modelsAliasesAddCommand,
|
||||||
modelsAliasesListCommand,
|
modelsAliasesListCommand,
|
||||||
|
|||||||
@@ -424,10 +424,9 @@ function toModelRow(params: {
|
|||||||
const input = model.input.join("+") || "text";
|
const input = model.input.join("+") || "text";
|
||||||
const local = isLocalBaseUrl(model.baseUrl);
|
const local = isLocalBaseUrl(model.baseUrl);
|
||||||
const available =
|
const available =
|
||||||
availableKeys?.has(modelKey(model.provider, model.id)) ||
|
cfg && authStore
|
||||||
(cfg && authStore
|
|
||||||
? hasAuthForProvider(model.provider, 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 aliasTags = aliases.length > 0 ? [`alias:${aliases.join(",")}`] : [];
|
||||||
const mergedTags = new Set(tags);
|
const mergedTags = new Set(tags);
|
||||||
if (aliasTags.length > 0) {
|
if (aliasTags.length > 0) {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export type AuthChoice =
|
|||||||
| "minimax-api"
|
| "minimax-api"
|
||||||
| "minimax-api-lightning"
|
| "minimax-api-lightning"
|
||||||
| "opencode-zen"
|
| "opencode-zen"
|
||||||
|
| "github-copilot"
|
||||||
| "skip";
|
| "skip";
|
||||||
export type GatewayAuthChoice = "off" | "token" | "password";
|
export type GatewayAuthChoice = "off" | "token" | "password";
|
||||||
export type ResetScope = "config" | "config+creds+sessions" | "full";
|
export type ResetScope = "config" | "config+creds+sessions" | "full";
|
||||||
|
|||||||
@@ -1407,7 +1407,8 @@ export type ModelApi =
|
|||||||
| "openai-completions"
|
| "openai-completions"
|
||||||
| "openai-responses"
|
| "openai-responses"
|
||||||
| "anthropic-messages"
|
| "anthropic-messages"
|
||||||
| "google-generative-ai";
|
| "google-generative-ai"
|
||||||
|
| "github-copilot";
|
||||||
|
|
||||||
export type ModelCompatConfig = {
|
export type ModelCompatConfig = {
|
||||||
supportsStore?: boolean;
|
supportsStore?: boolean;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const ModelApiSchema = z.union([
|
|||||||
z.literal("openai-responses"),
|
z.literal("openai-responses"),
|
||||||
z.literal("anthropic-messages"),
|
z.literal("anthropic-messages"),
|
||||||
z.literal("google-generative-ai"),
|
z.literal("google-generative-ai"),
|
||||||
|
z.literal("github-copilot"),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const ModelCompatSchema = z
|
const ModelCompatSchema = z
|
||||||
|
|||||||
192
src/providers/github-copilot-auth.ts
Normal file
192
src/providers/github-copilot-auth.ts
Normal 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");
|
||||||
|
}
|
||||||
41
src/providers/github-copilot-models.ts
Normal file
41
src/providers/github-copilot-models.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
79
src/providers/github-copilot-token.test.ts
Normal file
79
src/providers/github-copilot-token.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
140
src/providers/github-copilot-token.ts
Normal file
140
src/providers/github-copilot-token.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user