From ea5597b483796d28a9c68ad86cce53993bf8f922 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 13 Jan 2026 04:04:11 +0000 Subject: [PATCH] fix: restore implicit providers + copilot auth choice --- src/agents/models-config.ts | 230 ++++++++++++++++++++---------------- src/commands/auth-choice.ts | 58 +++++++++ 2 files changed, 185 insertions(+), 103 deletions(-) diff --git a/src/agents/models-config.ts b/src/agents/models-config.ts index 247c24321..28fc73723 100644 --- a/src/agents/models-config.ts +++ b/src/agents/models-config.ts @@ -2,64 +2,78 @@ import fs from "node:fs/promises"; import path from "node:path"; import { type ClawdbotConfig, loadConfig } from "../config/config.js"; +import { + DEFAULT_COPILOT_API_BASE_URL, + resolveCopilotApiToken, +} from "../providers/github-copilot-token.js"; import { resolveClawdbotAgentDir } from "./agent-paths.js"; import { - type AuthProfileStore, ensureAuthProfileStore, listProfilesForProvider, } from "./auth-profiles.js"; -import { resolveEnvApiKey } from "./model-auth.js"; +import { + normalizeProviders, + type ProviderConfig, + resolveImplicitProviders, +} from "./models-config.providers.js"; type ModelsConfig = NonNullable; -type ProviderConfig = NonNullable[string]; const DEFAULT_MODE: NonNullable = "merge"; -const MINIMAX_API_BASE_URL = "https://api.minimax.io/anthropic"; -const MINIMAX_DEFAULT_MODEL_ID = "MiniMax-M2.1"; -const MINIMAX_DEFAULT_CONTEXT_WINDOW = 200000; -const MINIMAX_DEFAULT_MAX_TOKENS = 8192; -// Pricing: MiniMax doesn't publish public rates. Override in models.json for accurate costs. -const MINIMAX_API_COST = { - input: 15, - output: 60, - cacheRead: 2, - cacheWrite: 10, -}; function isRecord(value: unknown): value is Record { return Boolean(value && typeof value === "object" && !Array.isArray(value)); } -function normalizeGoogleModelId(id: string): string { - if (id === "gemini-3-pro") return "gemini-3-pro-preview"; - if (id === "gemini-3-flash") return "gemini-3-flash-preview"; - return id; +function mergeProviderModels( + implicit: ProviderConfig, + explicit: ProviderConfig, +): ProviderConfig { + const implicitModels = Array.isArray(implicit.models) ? implicit.models : []; + const explicitModels = Array.isArray(explicit.models) ? explicit.models : []; + if (implicitModels.length === 0) return { ...implicit, ...explicit }; + + const getId = (model: unknown): string => { + if (!model || typeof model !== "object") return ""; + const id = (model as { id?: unknown }).id; + return typeof id === "string" ? id.trim() : ""; + }; + const seen = new Set(explicitModels.map(getId).filter(Boolean)); + + const mergedModels = [ + ...explicitModels, + ...implicitModels.filter((model) => { + const id = getId(model); + if (!id) return false; + if (seen.has(id)) return false; + seen.add(id); + return true; + }), + ]; + + return { + ...implicit, + ...explicit, + models: mergedModels, + }; } -function normalizeGoogleProvider(provider: ProviderConfig): ProviderConfig { - let mutated = false; - const models = provider.models.map((model) => { - const nextId = normalizeGoogleModelId(model.id); - if (nextId === model.id) return model; - mutated = true; - return { ...model, id: nextId }; - }); - return mutated ? { ...provider, models } : provider; -} - -function normalizeProviders( - providers: ModelsConfig["providers"], -): ModelsConfig["providers"] { - if (!providers) return providers; - let mutated = false; - const next: Record = {}; - for (const [key, provider] of Object.entries(providers)) { - const normalized = - key === "google" ? normalizeGoogleProvider(provider) : provider; - if (normalized !== provider) mutated = true; - next[key] = normalized; +function mergeProviders(params: { + implicit?: Record | null; + explicit?: Record | null; +}): Record { + const out: Record = params.implicit + ? { ...params.implicit } + : {}; + for (const [key, explicit] of Object.entries(params.explicit ?? {})) { + const providerKey = key.trim(); + if (!providerKey) continue; + const implicit = out[providerKey]; + out[providerKey] = implicit + ? mergeProviderModels(implicit, explicit) + : explicit; } - return mutated ? next : providers; + return out; } async function readJson(pathname: string): Promise { @@ -71,67 +85,62 @@ async function readJson(pathname: string): Promise { } } -function buildMinimaxApiProvider(apiKey?: string): ProviderConfig { - return { - baseUrl: MINIMAX_API_BASE_URL, - ...(apiKey ? { apiKey } : {}), - 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 resolveMinimaxApiKeyFromStore( - store: AuthProfileStore, -): string | undefined { - const profileIds = listProfilesForProvider(store, "minimax"); - for (const profileId of profileIds) { - const cred = store.profiles[profileId]; - if (!cred) continue; - if (cred.type === "api_key") { - const key = cred.key?.trim(); - if (key) return key; - continue; - } - if (cred.type === "token") { - const token = cred.token?.trim(); - if (!token) continue; - if ( - typeof cred.expires === "number" && - Number.isFinite(cred.expires) && - cred.expires > 0 && - Date.now() >= cred.expires - ) { - continue; - } - return token; - } - } - return undefined; -} - -function resolveImplicitProviders(params: { - cfg: ClawdbotConfig; +async function maybeBuildCopilotProvider(params: { agentDir: string; -}): ModelsConfig["providers"] { - const providers: Record = {}; - const minimaxEnv = resolveEnvApiKey("minimax"); + env?: NodeJS.ProcessEnv; +}): Promise { + const env = params.env ?? process.env; const authStore = ensureAuthProfileStore(params.agentDir); - const minimaxKey = - minimaxEnv?.apiKey ?? resolveMinimaxApiKeyFromStore(authStore); - if (minimaxKey) { - providers.minimax = buildMinimaxApiProvider(minimaxKey); + const hasProfile = + listProfilesForProvider(authStore, "github-copilot").length > 0; + const envToken = env.COPILOT_GITHUB_TOKEN ?? env.GH_TOKEN ?? env.GITHUB_TOKEN; + const githubToken = (envToken ?? "").trim(); + + if (!hasProfile && !githubToken) return null; + + let selectedGithubToken = githubToken; + if (!selectedGithubToken && hasProfile) { + // Use the first available profile as a default for discovery (it will be + // re-resolved per-run by the embedded runner). + const profileId = listProfilesForProvider(authStore, "github-copilot")[0]; + const profile = profileId ? authStore.profiles[profileId] : undefined; + if (profile && profile.type === "token") { + selectedGithubToken = profile.token; + } } - return providers; + + let baseUrl = DEFAULT_COPILOT_API_BASE_URL; + if (selectedGithubToken) { + try { + const token = await resolveCopilotApiToken({ + githubToken: selectedGithubToken, + env, + }); + baseUrl = token.baseUrl; + } catch { + baseUrl = DEFAULT_COPILOT_API_BASE_URL; + } + } + + // pi-coding-agent's ModelRegistry marks a model "available" only if its + // `AuthStorage` has auth configured for that provider (via auth.json/env/etc). + // Our Copilot auth lives in Clawdbot's auth-profiles store instead, so we also + // write a runtime-only auth.json entry for pi-coding-agent to pick up. + // + // This is safe because it's (1) within Clawdbot's agent dir, (2) contains the + // GitHub token (not the exchanged Copilot token), and (3) matches existing + // patterns for OAuth-like providers in pi-coding-agent. + // Note: we deliberately do not write pi-coding-agent's `auth.json` here. + // Clawdbot uses its own auth store and exchanges tokens at runtime. + // `models list` uses Clawdbot's auth heuristics for availability. + + // We intentionally do NOT define custom models for Copilot in models.json. + // pi-coding-agent treats providers with models as replacements requiring apiKey. + // We only override baseUrl; the model list comes from pi-ai built-ins. + return { + baseUrl, + models: [], + } satisfies ProviderConfig; } export async function ensureClawdbotModelsJson( @@ -142,9 +151,21 @@ export async function ensureClawdbotModelsJson( const agentDir = agentDirOverride?.trim() ? agentDirOverride.trim() : resolveClawdbotAgentDir(); - const configuredProviders = cfg.models?.providers ?? {}; - const implicitProviders = resolveImplicitProviders({ cfg, agentDir }); - const providers = { ...implicitProviders, ...configuredProviders }; + + const explicitProviders = (cfg.models?.providers ?? {}) as Record< + string, + ProviderConfig + >; + const implicitProviders = resolveImplicitProviders({ agentDir }); + const providers: Record = mergeProviders({ + implicit: implicitProviders, + explicit: explicitProviders, + }); + const implicitCopilot = await maybeBuildCopilotProvider({ agentDir }); + if (implicitCopilot && !providers["github-copilot"]) { + providers["github-copilot"] = implicitCopilot; + } + if (Object.keys(providers).length === 0) { return { agentDir, wrote: false }; } @@ -165,7 +186,10 @@ export async function ensureClawdbotModelsJson( } } - const normalizedProviders = normalizeProviders(mergedProviders); + const normalizedProviders = normalizeProviders({ + providers: mergedProviders, + agentDir, + }); const next = `${JSON.stringify({ providers: normalizedProviders }, null, 2)}\n`; try { existingRaw = await fs.readFile(targetPath, "utf8"); diff --git a/src/commands/auth-choice.ts b/src/commands/auth-choice.ts index 11cb5ef06..be224c39d 100644 --- a/src/commands/auth-choice.ts +++ b/src/commands/auth-choice.ts @@ -21,6 +21,7 @@ import { loadModelCatalog } from "../agents/model-catalog.js"; import { resolveConfiguredModelRef } from "../agents/model-selection.js"; import type { ClawdbotConfig } from "../config/config.js"; import { upsertSharedEnvVar } from "../infra/env-file.js"; +import { githubCopilotLoginCommand } from "../providers/github-copilot-auth.js"; import type { RuntimeEnv } from "../runtime.js"; import type { WizardPrompter } from "../wizard/prompts.js"; import { @@ -931,6 +932,61 @@ export async function applyAuthChoice(params: { agentModelOverride = modelRef; await noteAgentModel(modelRef); } + } 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 === "minimax") { if (params.setDefaultModel) { nextConfig = applyMinimaxConfig(nextConfig); @@ -1018,6 +1074,8 @@ export function resolvePreferredProviderForAuthChoice( return "google-antigravity"; case "synthetic-api-key": return "synthetic"; + case "github-copilot": + return "github-copilot"; case "minimax-cloud": case "minimax-api": case "minimax-api-lightning":