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 {
DEFAULT_COPILOT_API_BASE_URL,
resolveCopilotApiToken,
} from "../providers/github-copilot-token.js";
import {
ensureAuthProfileStore,
listProfilesForProvider,
@@ -224,3 +228,61 @@ export function resolveImplicitProviders(params: {
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 { 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 {
ensureAuthProfileStore,
listProfilesForProvider,
} from "./auth-profiles.js";
import {
normalizeProviders,
type ProviderConfig,
resolveImplicitCopilotProvider,
resolveImplicitProviders,
} 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(
config?: ClawdbotConfig,
agentDirOverride?: string,
@@ -161,7 +96,7 @@ export async function ensureClawdbotModelsJson(
implicit: implicitProviders,
explicit: explicitProviders,
});
const implicitCopilot = await maybeBuildCopilotProvider({ agentDir });
const implicitCopilot = await resolveImplicitCopilotProvider({ agentDir });
if (implicitCopilot && !providers["github-copilot"]) {
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 { 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", () => ({
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)}`;
}
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(
config: ClawdbotConfig,
prompter: WizardPrompter,
@@ -487,16 +513,19 @@ export async function applyAuthChoice(params: {
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);
{
const applied = await applyDefaultModelChoice({
config: nextConfig,
setDefaultModel: params.setDefaultModel,
defaultModel: OPENROUTER_DEFAULT_MODEL_REF,
applyDefaultConfig: applyOpenrouterConfig,
applyProviderConfig: applyOpenrouterProviderConfig,
noteDefault: OPENROUTER_DEFAULT_MODEL_REF,
noteAgentModel,
prompter: params.prompter,
});
nextConfig = applied.config;
agentModelOverride = applied.agentModelOverride ?? agentModelOverride;
}
} else if (params.authChoice === "moonshot-api-key") {
let hasCredential = false;
@@ -526,12 +555,18 @@ export async function applyAuthChoice(params: {
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);
{
const applied = await applyDefaultModelChoice({
config: nextConfig,
setDefaultModel: params.setDefaultModel,
defaultModel: MOONSHOT_DEFAULT_MODEL_REF,
applyDefaultConfig: applyMoonshotConfig,
applyProviderConfig: applyMoonshotProviderConfig,
noteAgentModel,
prompter: params.prompter,
});
nextConfig = applied.config;
agentModelOverride = applied.agentModelOverride ?? agentModelOverride;
}
} else if (params.authChoice === "chutes") {
const isRemote = isRemoteEnvironment();
@@ -867,33 +902,36 @@ export async function applyAuthChoice(params: {
provider: "zai",
mode: "api_key",
});
if (params.setDefaultModel) {
nextConfig = applyZaiConfig(nextConfig);
await params.prompter.note(
`Default model set to ${ZAI_DEFAULT_MODEL_REF}`,
"Model configured",
);
} else {
nextConfig = {
...nextConfig,
agents: {
...nextConfig.agents,
defaults: {
...nextConfig.agents?.defaults,
models: {
...nextConfig.agents?.defaults?.models,
[ZAI_DEFAULT_MODEL_REF]: {
...nextConfig.agents?.defaults?.models?.[ZAI_DEFAULT_MODEL_REF],
alias:
nextConfig.agents?.defaults?.models?.[ZAI_DEFAULT_MODEL_REF]
?.alias ?? "GLM",
{
const applied = await applyDefaultModelChoice({
config: nextConfig,
setDefaultModel: params.setDefaultModel,
defaultModel: ZAI_DEFAULT_MODEL_REF,
applyDefaultConfig: applyZaiConfig,
applyProviderConfig: (config) => ({
...config,
agents: {
...config.agents,
defaults: {
...config.agents?.defaults,
models: {
...config.agents?.defaults?.models,
[ZAI_DEFAULT_MODEL_REF]: {
...config.agents?.defaults?.models?.[ZAI_DEFAULT_MODEL_REF],
alias:
config.agents?.defaults?.models?.[ZAI_DEFAULT_MODEL_REF]
?.alias ?? "GLM",
},
},
},
},
},
};
agentModelOverride = ZAI_DEFAULT_MODEL_REF;
await noteAgentModel(ZAI_DEFAULT_MODEL_REF);
}),
noteDefault: ZAI_DEFAULT_MODEL_REF,
noteAgentModel,
prompter: params.prompter,
});
nextConfig = applied.config;
agentModelOverride = applied.agentModelOverride ?? agentModelOverride;
}
} else if (params.authChoice === "synthetic-api-key") {
const key = await params.prompter.text({
@@ -906,16 +944,19 @@ export async function applyAuthChoice(params: {
provider: "synthetic",
mode: "api_key",
});
if (params.setDefaultModel) {
nextConfig = applySyntheticConfig(nextConfig);
await params.prompter.note(
`Default model set to ${SYNTHETIC_DEFAULT_MODEL_REF}`,
"Model configured",
);
} else {
nextConfig = applySyntheticProviderConfig(nextConfig);
agentModelOverride = SYNTHETIC_DEFAULT_MODEL_REF;
await noteAgentModel(SYNTHETIC_DEFAULT_MODEL_REF);
{
const applied = await applyDefaultModelChoice({
config: nextConfig,
setDefaultModel: params.setDefaultModel,
defaultModel: SYNTHETIC_DEFAULT_MODEL_REF,
applyDefaultConfig: applySyntheticConfig,
applyProviderConfig: applySyntheticProviderConfig,
noteDefault: SYNTHETIC_DEFAULT_MODEL_REF,
noteAgentModel,
prompter: params.prompter,
});
nextConfig = applied.config;
agentModelOverride = applied.agentModelOverride ?? agentModelOverride;
}
} else if (params.authChoice === "apiKey") {
let hasCredential = false;
@@ -981,13 +1022,20 @@ export async function applyAuthChoice(params: {
provider: "minimax",
mode: "api_key",
});
if (params.setDefaultModel) {
nextConfig = applyMinimaxApiConfig(nextConfig, modelId);
} else {
{
const modelRef = `minimax/${modelId}`;
nextConfig = applyMinimaxApiProviderConfig(nextConfig, modelId);
agentModelOverride = modelRef;
await noteAgentModel(modelRef);
const applied = await applyDefaultModelChoice({
config: nextConfig,
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") {
await params.prompter.note(
@@ -1045,12 +1093,18 @@ export async function applyAuthChoice(params: {
);
}
} else if (params.authChoice === "minimax") {
if (params.setDefaultModel) {
nextConfig = applyMinimaxConfig(nextConfig);
} else {
nextConfig = applyMinimaxProviderConfig(nextConfig);
agentModelOverride = "lmstudio/minimax-m2.1-gs32";
await noteAgentModel("lmstudio/minimax-m2.1-gs32");
{
const applied = await applyDefaultModelChoice({
config: nextConfig,
setDefaultModel: params.setDefaultModel,
defaultModel: "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") {
await params.prompter.note(
@@ -1088,16 +1142,19 @@ export async function applyAuthChoice(params: {
provider: "opencode",
mode: "api_key",
});
if (params.setDefaultModel) {
nextConfig = applyOpencodeZenConfig(nextConfig);
await params.prompter.note(
`Default model set to ${OPENCODE_ZEN_DEFAULT_MODEL}`,
"Model configured",
);
} else {
nextConfig = applyOpencodeZenProviderConfig(nextConfig);
agentModelOverride = OPENCODE_ZEN_DEFAULT_MODEL;
await noteAgentModel(OPENCODE_ZEN_DEFAULT_MODEL);
{
const applied = await applyDefaultModelChoice({
config: nextConfig,
setDefaultModel: params.setDefaultModel,
defaultModel: OPENCODE_ZEN_DEFAULT_MODEL,
applyDefaultConfig: applyOpencodeZenConfig,
applyProviderConfig: applyOpencodeZenProviderConfig,
noteDefault: OPENCODE_ZEN_DEFAULT_MODEL,
noteAgentModel,
prompter: params.prompter,
});
nextConfig = applied.config;
agentModelOverride = applied.agentModelOverride ?? agentModelOverride;
}
}
@@ -1107,43 +1164,29 @@ export async function applyAuthChoice(params: {
export function resolvePreferredProviderForAuthChoice(
choice: AuthChoice,
): string | undefined {
switch (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;
}
return PREFERRED_PROVIDER_BY_AUTH_CHOICE[choice];
}
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: {
defaults: {
model: "anthropic/claude-opus-4-5",
humanDelay: { mode: "off" },
workspace: "/tmp/clawd",
},
},