From 8afdf75e2b4cba05926996613e7bac5add6885cd Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 12 Jan 2026 16:55:47 +0000 Subject: [PATCH] fix: honor copilot config and profiles (#705) (thanks @TAGOOZ) --- CHANGELOG.md | 1 + src/agents/models-config.test.ts | 121 +++++++++++++++++++++++++++++++ src/agents/models-config.ts | 116 ++++++++++++++++++++++++----- 3 files changed, 219 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 56e09eb6c..0da79dd4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ ### Fixes - Auto-reply: inline `/status` now honors allowlists (authorized stripped + replied inline; unauthorized leaves text for the agent) to match command gating tests. - Telegram: show typing indicator in General forum topics. (#779) — thanks @azade-c. +- Models: keep explicit GitHub Copilot provider config and honor agent-dir auth profiles for auto-injection. (#705) — thanks @TAGOOZ. ## 2026.1.11 diff --git a/src/agents/models-config.test.ts b/src/agents/models-config.test.ts index 9780b8f08..46d175fcd 100644 --- a/src/agents/models-config.test.ts +++ b/src/agents/models-config.test.ts @@ -76,6 +76,127 @@ describe("models config", () => { } }); }); + + it("does not override explicit github-copilot provider config", 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: { + "github-copilot": { + baseUrl: "https://copilot.local", + api: "openai-responses", + models: [], + }, + }, + }, + }); + + const agentDir = resolveClawdbotAgentDir(); + const raw = await fs.readFile( + path.join(agentDir, "models.json"), + "utf8", + ); + const parsed = JSON.parse(raw) as { + providers: Record; + }; + + expect(parsed.providers["github-copilot"]?.baseUrl).toBe( + "https://copilot.local", + ); + } finally { + process.env.COPILOT_GITHUB_TOKEN = previous; + } + }); + }); + + it("uses agentDir override auth profiles for copilot injection", async () => { + await withTempHome(async (home) => { + const previous = process.env.COPILOT_GITHUB_TOKEN; + const previousGh = process.env.GH_TOKEN; + const previousGithub = process.env.GITHUB_TOKEN; + delete process.env.COPILOT_GITHUB_TOKEN; + delete process.env.GH_TOKEN; + delete process.env.GITHUB_TOKEN; + + try { + vi.resetModules(); + + const agentDir = path.join(home, "agent-override"); + await fs.mkdir(agentDir, { recursive: true }); + await fs.writeFile( + path.join(agentDir, "auth-profiles.json"), + JSON.stringify( + { + version: 1, + profiles: { + "github-copilot:github": { + type: "token", + provider: "github-copilot", + token: "gh-profile-token", + }, + }, + }, + null, + 2, + ), + ); + + 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"); + + await ensureClawdbotModelsJson({ models: { providers: {} } }, agentDir); + + const raw = await fs.readFile( + path.join(agentDir, "models.json"), + "utf8", + ); + const parsed = JSON.parse(raw) as { + providers: Record; + }; + + expect(parsed.providers["github-copilot"]?.baseUrl).toBe( + "https://api.copilot.example", + ); + } finally { + if (previous === undefined) delete process.env.COPILOT_GITHUB_TOKEN; + else process.env.COPILOT_GITHUB_TOKEN = previous; + if (previousGh === undefined) delete process.env.GH_TOKEN; + else process.env.GH_TOKEN = previousGh; + if (previousGithub === undefined) delete process.env.GITHUB_TOKEN; + else process.env.GITHUB_TOKEN = previousGithub; + } + }); + }); let previousHome: string | undefined; beforeEach(() => { diff --git a/src/agents/models-config.ts b/src/agents/models-config.ts index 7039dfe87..f148dfc83 100644 --- a/src/agents/models-config.ts +++ b/src/agents/models-config.ts @@ -2,7 +2,6 @@ import fs from "node:fs/promises"; import path from "node:path"; import { type ClawdbotConfig, loadConfig } from "../config/config.js"; -import type { ModelsConfig as ModelsConfigShape } from "../config/types.js"; import { DEFAULT_COPILOT_API_BASE_URL, resolveCopilotApiToken, @@ -12,17 +11,60 @@ import { ensureAuthProfileStore, listProfilesForProvider, } from "./auth-profiles.js"; +import { resolveEnvApiKey } from "./model-auth.js"; type ModelsConfig = NonNullable; - -type ModelsProviderConfig = NonNullable[string]; +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 normalizeGoogleProvider(provider: ProviderConfig): ProviderConfig { + let mutated = false; + const models = provider.models.map((model) => { + const nextId = normalizeGoogleModelId(model.id); + if (nextId === model.id) return model; + mutated = true; + return { ...model, id: nextId }; + }); + return mutated ? { ...provider, models } : provider; +} + +function normalizeProviders( + providers: ModelsConfig["providers"], +): ModelsConfig["providers"] { + if (!providers) return providers; + let mutated = false; + const next: Record = {}; + for (const [key, provider] of Object.entries(providers)) { + const normalized = + key === "google" ? normalizeGoogleProvider(provider) : provider; + if (normalized !== provider) mutated = true; + next[key] = normalized; + } + return mutated ? next : providers; +} + async function readJson(pathname: string): Promise { try { const raw = await fs.readFile(pathname, "utf8"); @@ -32,12 +74,45 @@ async function readJson(pathname: string): Promise { } } -async function maybeBuildCopilotProvider(params: { +function buildMinimaxApiProvider(): ProviderConfig { + return { + baseUrl: MINIMAX_API_BASE_URL, + api: "anthropic-messages", + models: [ + { + id: MINIMAX_DEFAULT_MODEL_ID, + name: "MiniMax M2.1", + reasoning: false, + input: ["text"], + cost: MINIMAX_API_COST, + contextWindow: MINIMAX_DEFAULT_CONTEXT_WINDOW, + maxTokens: MINIMAX_DEFAULT_MAX_TOKENS, + }, + ], + }; +} + +function resolveImplicitProviders(params: { cfg: ClawdbotConfig; + agentDir: string; +}): ModelsConfig["providers"] { + const providers: Record = {}; + const minimaxEnv = resolveEnvApiKey("minimax"); + const authStore = ensureAuthProfileStore(params.agentDir); + const hasMinimaxProfile = + listProfilesForProvider(authStore, "minimax").length > 0; + if (minimaxEnv || hasMinimaxProfile) { + providers.minimax = buildMinimaxApiProvider(); + } + return providers; +} + +async function maybeBuildCopilotProvider(params: { + agentDir: string; env?: NodeJS.ProcessEnv; -}): Promise { +}): Promise { const env = params.env ?? process.env; - const authStore = ensureAuthProfileStore(); + const authStore = ensureAuthProfileStore(params.agentDir); const hasProfile = listProfilesForProvider(authStore, "github-copilot").length > 0; const envToken = env.COPILOT_GITHUB_TOKEN ?? env.GH_TOKEN ?? env.GITHUB_TOKEN; @@ -87,7 +162,7 @@ async function maybeBuildCopilotProvider(params: { return { baseUrl, models: [], - } satisfies ModelsProviderConfig; + } satisfies ProviderConfig; } export async function ensureClawdbotModelsJson( @@ -95,24 +170,26 @@ export async function ensureClawdbotModelsJson( agentDirOverride?: string, ): Promise<{ agentDir: string; wrote: boolean }> { const cfg = config ?? loadConfig(); + const agentDir = agentDirOverride?.trim() + ? agentDirOverride.trim() + : resolveClawdbotAgentDir(); const explicitProviders = cfg.models?.providers ?? {}; - const implicitCopilot = await maybeBuildCopilotProvider({ cfg }); - const providers = implicitCopilot - ? { ...explicitProviders, "github-copilot": implicitCopilot } - : explicitProviders; + const implicitProviders = resolveImplicitProviders({ cfg, agentDir }); + const providers: Record = { + ...implicitProviders, + ...explicitProviders, + }; + const implicitCopilot = await maybeBuildCopilotProvider({ agentDir }); + if (implicitCopilot && !providers["github-copilot"]) { + providers["github-copilot"] = implicitCopilot; + } - if (!providers || Object.keys(providers).length === 0) { - const agentDir = agentDirOverride?.trim() - ? agentDirOverride.trim() - : resolveClawdbotAgentDir(); + if (Object.keys(providers).length === 0) { return { agentDir, wrote: false }; } const mode = cfg.models?.mode ?? DEFAULT_MODE; - const agentDir = agentDirOverride?.trim() - ? agentDirOverride.trim() - : resolveClawdbotAgentDir(); const targetPath = path.join(agentDir, "models.json"); let mergedProviders = providers; @@ -128,7 +205,8 @@ export async function ensureClawdbotModelsJson( } } - const next = `${JSON.stringify({ providers: mergedProviders }, null, 2)}\n`; + const normalizedProviders = normalizeProviders(mergedProviders); + const next = `${JSON.stringify({ providers: normalizedProviders }, null, 2)}\n`; try { existingRaw = await fs.readFile(targetPath, "utf8"); } catch {