fix: honor copilot config and profiles (#705) (thanks @TAGOOZ)

This commit is contained in:
Peter Steinberger
2026-01-12 16:55:47 +00:00
parent 3da1afed68
commit 8afdf75e2b
3 changed files with 219 additions and 19 deletions

View File

@@ -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

View File

@@ -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<string, { baseUrl?: string }>;
};
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<string, { baseUrl?: string }>;
};
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(() => {

View File

@@ -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<ClawdbotConfig["models"]>;
type ModelsProviderConfig = NonNullable<ModelsConfigShape["providers"]>[string];
type ProviderConfig = NonNullable<ModelsConfig["providers"]>[string];
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> {
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> {
try {
const raw = await fs.readFile(pathname, "utf8");
@@ -32,12 +74,45 @@ async function readJson(pathname: string): Promise<unknown> {
}
}
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<string, ProviderConfig> = {};
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<ModelsProviderConfig | null> {
}): Promise<ProviderConfig | null> {
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<string, ProviderConfig> = {
...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 {