refactor: centralize auth-choice model defaults

This commit is contained in:
Peter Steinberger
2026-01-13 05:24:41 +00:00
parent 0321d5ed74
commit 61b7398cb7
5 changed files with 238 additions and 179 deletions

View File

@@ -1,4 +1,8 @@
import type { ClawdbotConfig } from "../config/config.js"; import type { ClawdbotConfig } from "../config/config.js";
import {
DEFAULT_COPILOT_API_BASE_URL,
resolveCopilotApiToken,
} from "../providers/github-copilot-token.js";
import { import {
ensureAuthProfileStore, ensureAuthProfileStore,
listProfilesForProvider, listProfilesForProvider,
@@ -224,3 +228,61 @@ export function resolveImplicitProviders(params: {
return providers; return providers;
} }
export async function resolveImplicitCopilotProvider(params: {
agentDir: string;
env?: NodeJS.ProcessEnv;
}): Promise<ProviderConfig | null> {
const env = params.env ?? process.env;
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;
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;
}
}
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;
}

View File

@@ -2,18 +2,11 @@ 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 {
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 {
normalizeProviders, normalizeProviders,
type ProviderConfig, type ProviderConfig,
resolveImplicitCopilotProvider,
resolveImplicitProviders, resolveImplicitProviders,
} from "./models-config.providers.js"; } from "./models-config.providers.js";
@@ -85,64 +78,6 @@ async function readJson(pathname: string): Promise<unknown> {
} }
} }
async function maybeBuildCopilotProvider(params: {
agentDir: string;
env?: NodeJS.ProcessEnv;
}): Promise<ProviderConfig | null> {
const env = params.env ?? process.env;
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;
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;
}
}
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( export async function ensureClawdbotModelsJson(
config?: ClawdbotConfig, config?: ClawdbotConfig,
agentDirOverride?: string, agentDirOverride?: string,
@@ -161,7 +96,7 @@ export async function ensureClawdbotModelsJson(
implicit: implicitProviders, implicit: implicitProviders,
explicit: explicitProviders, explicit: explicitProviders,
}); });
const implicitCopilot = await maybeBuildCopilotProvider({ agentDir }); const implicitCopilot = await resolveImplicitCopilotProvider({ agentDir });
if (implicitCopilot && !providers["github-copilot"]) { if (implicitCopilot && !providers["github-copilot"]) {
providers["github-copilot"] = implicitCopilot; providers["github-copilot"] = implicitCopilot;
} }

View File

@@ -6,7 +6,11 @@ import { afterEach, describe, expect, it, vi } from "vitest";
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 { applyAuthChoice } from "./auth-choice.js"; import {
applyAuthChoice,
resolvePreferredProviderForAuthChoice,
} from "./auth-choice.js";
import type { AuthChoice } from "./onboard-types.js";
vi.mock("../providers/github-copilot-auth.js", () => ({ vi.mock("../providers/github-copilot-auth.js", () => ({
githubCopilotLoginCommand: vi.fn(async () => {}), githubCopilotLoginCommand: vi.fn(async () => {}),
@@ -444,3 +448,17 @@ describe("applyAuthChoice", () => {
}); });
}); });
}); });
describe("resolvePreferredProviderForAuthChoice", () => {
it("maps github-copilot to the provider", () => {
expect(resolvePreferredProviderForAuthChoice("github-copilot")).toBe(
"github-copilot",
);
});
it("returns undefined for unknown choices", () => {
expect(
resolvePreferredProviderForAuthChoice("unknown" as AuthChoice),
).toBeUndefined();
});
});

View File

@@ -120,6 +120,32 @@ function formatApiKeyPreview(
return `${trimmed.slice(0, head)}${trimmed.slice(-tail)}`; return `${trimmed.slice(0, head)}${trimmed.slice(-tail)}`;
} }
async function applyDefaultModelChoice(params: {
config: ClawdbotConfig;
setDefaultModel: boolean;
defaultModel: string;
applyDefaultConfig: (config: ClawdbotConfig) => ClawdbotConfig;
applyProviderConfig: (config: ClawdbotConfig) => ClawdbotConfig;
noteDefault?: string;
noteAgentModel: (model: string) => Promise<void>;
prompter: WizardPrompter;
}): Promise<{ config: ClawdbotConfig; agentModelOverride?: string }> {
if (params.setDefaultModel) {
const next = params.applyDefaultConfig(params.config);
if (params.noteDefault) {
await params.prompter.note(
`Default model set to ${params.noteDefault}`,
"Model configured",
);
}
return { config: next };
}
const next = params.applyProviderConfig(params.config);
await params.noteAgentModel(params.defaultModel);
return { config: next, agentModelOverride: params.defaultModel };
}
export async function warnIfModelConfigLooksOff( export async function warnIfModelConfigLooksOff(
config: ClawdbotConfig, config: ClawdbotConfig,
prompter: WizardPrompter, prompter: WizardPrompter,
@@ -487,16 +513,19 @@ export async function applyAuthChoice(params: {
mode, mode,
}); });
} }
if (params.setDefaultModel) { {
nextConfig = applyOpenrouterConfig(nextConfig); const applied = await applyDefaultModelChoice({
await params.prompter.note( config: nextConfig,
`Default model set to ${OPENROUTER_DEFAULT_MODEL_REF}`, setDefaultModel: params.setDefaultModel,
"Model configured", defaultModel: OPENROUTER_DEFAULT_MODEL_REF,
); applyDefaultConfig: applyOpenrouterConfig,
} else { applyProviderConfig: applyOpenrouterProviderConfig,
nextConfig = applyOpenrouterProviderConfig(nextConfig); noteDefault: OPENROUTER_DEFAULT_MODEL_REF,
agentModelOverride = OPENROUTER_DEFAULT_MODEL_REF; noteAgentModel,
await noteAgentModel(OPENROUTER_DEFAULT_MODEL_REF); prompter: params.prompter,
});
nextConfig = applied.config;
agentModelOverride = applied.agentModelOverride ?? agentModelOverride;
} }
} else if (params.authChoice === "moonshot-api-key") { } else if (params.authChoice === "moonshot-api-key") {
let hasCredential = false; let hasCredential = false;
@@ -526,12 +555,18 @@ export async function applyAuthChoice(params: {
provider: "moonshot", provider: "moonshot",
mode: "api_key", mode: "api_key",
}); });
if (params.setDefaultModel) { {
nextConfig = applyMoonshotConfig(nextConfig); const applied = await applyDefaultModelChoice({
} else { config: nextConfig,
nextConfig = applyMoonshotProviderConfig(nextConfig); setDefaultModel: params.setDefaultModel,
agentModelOverride = MOONSHOT_DEFAULT_MODEL_REF; defaultModel: MOONSHOT_DEFAULT_MODEL_REF,
await noteAgentModel(MOONSHOT_DEFAULT_MODEL_REF); applyDefaultConfig: applyMoonshotConfig,
applyProviderConfig: applyMoonshotProviderConfig,
noteAgentModel,
prompter: params.prompter,
});
nextConfig = applied.config;
agentModelOverride = applied.agentModelOverride ?? agentModelOverride;
} }
} else if (params.authChoice === "chutes") { } else if (params.authChoice === "chutes") {
const isRemote = isRemoteEnvironment(); const isRemote = isRemoteEnvironment();
@@ -867,33 +902,36 @@ export async function applyAuthChoice(params: {
provider: "zai", provider: "zai",
mode: "api_key", mode: "api_key",
}); });
if (params.setDefaultModel) { {
nextConfig = applyZaiConfig(nextConfig); const applied = await applyDefaultModelChoice({
await params.prompter.note( config: nextConfig,
`Default model set to ${ZAI_DEFAULT_MODEL_REF}`, setDefaultModel: params.setDefaultModel,
"Model configured", defaultModel: ZAI_DEFAULT_MODEL_REF,
); applyDefaultConfig: applyZaiConfig,
} else { applyProviderConfig: (config) => ({
nextConfig = { ...config,
...nextConfig, agents: {
agents: { ...config.agents,
...nextConfig.agents, defaults: {
defaults: { ...config.agents?.defaults,
...nextConfig.agents?.defaults, models: {
models: { ...config.agents?.defaults?.models,
...nextConfig.agents?.defaults?.models, [ZAI_DEFAULT_MODEL_REF]: {
[ZAI_DEFAULT_MODEL_REF]: { ...config.agents?.defaults?.models?.[ZAI_DEFAULT_MODEL_REF],
...nextConfig.agents?.defaults?.models?.[ZAI_DEFAULT_MODEL_REF], alias:
alias: config.agents?.defaults?.models?.[ZAI_DEFAULT_MODEL_REF]
nextConfig.agents?.defaults?.models?.[ZAI_DEFAULT_MODEL_REF] ?.alias ?? "GLM",
?.alias ?? "GLM", },
}, },
}, },
}, },
}, }),
}; noteDefault: ZAI_DEFAULT_MODEL_REF,
agentModelOverride = ZAI_DEFAULT_MODEL_REF; noteAgentModel,
await noteAgentModel(ZAI_DEFAULT_MODEL_REF); prompter: params.prompter,
});
nextConfig = applied.config;
agentModelOverride = applied.agentModelOverride ?? agentModelOverride;
} }
} else if (params.authChoice === "synthetic-api-key") { } else if (params.authChoice === "synthetic-api-key") {
const key = await params.prompter.text({ const key = await params.prompter.text({
@@ -906,16 +944,19 @@ export async function applyAuthChoice(params: {
provider: "synthetic", provider: "synthetic",
mode: "api_key", mode: "api_key",
}); });
if (params.setDefaultModel) { {
nextConfig = applySyntheticConfig(nextConfig); const applied = await applyDefaultModelChoice({
await params.prompter.note( config: nextConfig,
`Default model set to ${SYNTHETIC_DEFAULT_MODEL_REF}`, setDefaultModel: params.setDefaultModel,
"Model configured", defaultModel: SYNTHETIC_DEFAULT_MODEL_REF,
); applyDefaultConfig: applySyntheticConfig,
} else { applyProviderConfig: applySyntheticProviderConfig,
nextConfig = applySyntheticProviderConfig(nextConfig); noteDefault: SYNTHETIC_DEFAULT_MODEL_REF,
agentModelOverride = SYNTHETIC_DEFAULT_MODEL_REF; noteAgentModel,
await noteAgentModel(SYNTHETIC_DEFAULT_MODEL_REF); prompter: params.prompter,
});
nextConfig = applied.config;
agentModelOverride = applied.agentModelOverride ?? agentModelOverride;
} }
} else if (params.authChoice === "apiKey") { } else if (params.authChoice === "apiKey") {
let hasCredential = false; let hasCredential = false;
@@ -981,13 +1022,20 @@ export async function applyAuthChoice(params: {
provider: "minimax", provider: "minimax",
mode: "api_key", mode: "api_key",
}); });
if (params.setDefaultModel) { {
nextConfig = applyMinimaxApiConfig(nextConfig, modelId);
} else {
const modelRef = `minimax/${modelId}`; const modelRef = `minimax/${modelId}`;
nextConfig = applyMinimaxApiProviderConfig(nextConfig, modelId); const applied = await applyDefaultModelChoice({
agentModelOverride = modelRef; config: nextConfig,
await noteAgentModel(modelRef); setDefaultModel: params.setDefaultModel,
defaultModel: modelRef,
applyDefaultConfig: (config) => applyMinimaxApiConfig(config, modelId),
applyProviderConfig: (config) =>
applyMinimaxApiProviderConfig(config, modelId),
noteAgentModel,
prompter: params.prompter,
});
nextConfig = applied.config;
agentModelOverride = applied.agentModelOverride ?? agentModelOverride;
} }
} else if (params.authChoice === "github-copilot") { } else if (params.authChoice === "github-copilot") {
await params.prompter.note( await params.prompter.note(
@@ -1045,12 +1093,18 @@ export async function applyAuthChoice(params: {
); );
} }
} else if (params.authChoice === "minimax") { } else if (params.authChoice === "minimax") {
if (params.setDefaultModel) { {
nextConfig = applyMinimaxConfig(nextConfig); const applied = await applyDefaultModelChoice({
} else { config: nextConfig,
nextConfig = applyMinimaxProviderConfig(nextConfig); setDefaultModel: params.setDefaultModel,
agentModelOverride = "lmstudio/minimax-m2.1-gs32"; defaultModel: "lmstudio/minimax-m2.1-gs32",
await noteAgentModel("lmstudio/minimax-m2.1-gs32"); applyDefaultConfig: applyMinimaxConfig,
applyProviderConfig: applyMinimaxProviderConfig,
noteAgentModel,
prompter: params.prompter,
});
nextConfig = applied.config;
agentModelOverride = applied.agentModelOverride ?? agentModelOverride;
} }
} else if (params.authChoice === "opencode-zen") { } else if (params.authChoice === "opencode-zen") {
await params.prompter.note( await params.prompter.note(
@@ -1088,16 +1142,19 @@ export async function applyAuthChoice(params: {
provider: "opencode", provider: "opencode",
mode: "api_key", mode: "api_key",
}); });
if (params.setDefaultModel) { {
nextConfig = applyOpencodeZenConfig(nextConfig); const applied = await applyDefaultModelChoice({
await params.prompter.note( config: nextConfig,
`Default model set to ${OPENCODE_ZEN_DEFAULT_MODEL}`, setDefaultModel: params.setDefaultModel,
"Model configured", defaultModel: OPENCODE_ZEN_DEFAULT_MODEL,
); applyDefaultConfig: applyOpencodeZenConfig,
} else { applyProviderConfig: applyOpencodeZenProviderConfig,
nextConfig = applyOpencodeZenProviderConfig(nextConfig); noteDefault: OPENCODE_ZEN_DEFAULT_MODEL,
agentModelOverride = OPENCODE_ZEN_DEFAULT_MODEL; noteAgentModel,
await noteAgentModel(OPENCODE_ZEN_DEFAULT_MODEL); prompter: params.prompter,
});
nextConfig = applied.config;
agentModelOverride = applied.agentModelOverride ?? agentModelOverride;
} }
} }
@@ -1107,43 +1164,29 @@ export async function applyAuthChoice(params: {
export function resolvePreferredProviderForAuthChoice( export function resolvePreferredProviderForAuthChoice(
choice: AuthChoice, choice: AuthChoice,
): string | undefined { ): string | undefined {
switch (choice) { return PREFERRED_PROVIDER_BY_AUTH_CHOICE[choice];
case "oauth":
case "setup-token":
case "claude-cli":
case "token":
case "apiKey":
return "anthropic";
case "openai-codex":
case "codex-cli":
return "openai-codex";
case "chutes":
return "chutes";
case "openai-api-key":
return "openai";
case "openrouter-api-key":
return "openrouter";
case "moonshot-api-key":
return "moonshot";
case "gemini-api-key":
return "google";
case "zai-api-key":
return "zai";
case "antigravity":
return "google-antigravity";
case "synthetic-api-key":
return "synthetic";
case "github-copilot":
return "github-copilot";
case "minimax-cloud":
case "minimax-api":
case "minimax-api-lightning":
return "minimax";
case "minimax":
return "lmstudio";
case "opencode-zen":
return "opencode";
default:
return undefined;
}
} }
const PREFERRED_PROVIDER_BY_AUTH_CHOICE: Partial<Record<AuthChoice, string>> = {
oauth: "anthropic",
"setup-token": "anthropic",
"claude-cli": "anthropic",
token: "anthropic",
apiKey: "anthropic",
"openai-codex": "openai-codex",
"codex-cli": "openai-codex",
chutes: "chutes",
"openai-api-key": "openai",
"openrouter-api-key": "openrouter",
"moonshot-api-key": "moonshot",
"gemini-api-key": "google",
"zai-api-key": "zai",
antigravity: "google-antigravity",
"synthetic-api-key": "synthetic",
"github-copilot": "github-copilot",
"minimax-cloud": "minimax",
"minimax-api": "minimax",
"minimax-api-lightning": "minimax",
minimax: "lmstudio",
"opencode-zen": "opencode",
};

View File

@@ -40,6 +40,7 @@ describe("discord native commands", () => {
agents: { agents: {
defaults: { defaults: {
model: "anthropic/claude-opus-4-5", model: "anthropic/claude-opus-4-5",
humanDelay: { mode: "off" },
workspace: "/tmp/clawd", workspace: "/tmp/clawd",
}, },
}, },