import assert from "node:assert/strict"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import test from "node:test"; import { AuthStorage, ModelRegistry } from "@mariozechner/pi-coding-agent"; import { getDefaultConfigPath, loadConfig, loadBotAppConfig, resolveRuntimeModel } from "../src/config.js"; function createConfigFile(config: unknown): string { const tempDir = mkdtempSync(join(tmpdir(), "pi-bot-workspace-config-")); const workspaceDir = join(tempDir, "workspace"); const configPath = join(workspaceDir, "config.json"); mkdirSync(workspaceDir, { recursive: true }); writeFileSync(configPath, JSON.stringify(config, null, 2)); return configPath; } test("loadConfig resolves workspace directory and default model", () => { const configPath = createConfigFile({ agents: { defaults: { workspace: ".", thinkingLevel: "medium", model: { primary: "coze/glm-4-7-251222" } } }, models: { providers: { coze: { api: "openai-completions", baseUrl: "${COZE_BASE_URL}", apiKey: "${COZE_API_KEY}", models: [ { id: "glm-4-7-251222", name: "GLM 4.7", input: ["text"], reasoning: false, contextWindow: 200000, maxTokens: 8192, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } } ] } } } }); const loaded = loadConfig(configPath); const tempRoot = dirname(dirname(configPath)); rmSync(tempRoot, { recursive: true, force: true }); assert.equal(loaded.workspaceDir, dirname(configPath)); assert.equal(loaded.thinkingLevel, "medium"); assert.deepEqual(loaded.defaultModel, { provider: "coze", modelId: "glm-4-7-251222" }); }); test("loadBotAppConfig defaults agent mode to pi when env override is absent", () => { const configPath = createConfigFile({ agents: { defaults: { workspace: ".", model: { primary: "openai/gpt-5-mini" } } } }); const loaded = loadBotAppConfig({ configPath, env: {} }); const tempRoot = dirname(dirname(configPath)); rmSync(tempRoot, { recursive: true, force: true }); assert.equal(loaded.agent.mode, "pi"); }); test("resolveRuntimeModel builds a custom provider model from workspace/config.json", async () => { const configPath = createConfigFile({ agents: { defaults: { workspace: ".", model: { primary: "coze/glm-4-7-251222" } } }, models: { providers: { missing: { api: "openai-completions", baseUrl: "${MISSING_BASE_URL}", apiKey: "${MISSING_API_KEY}", models: [ { id: "unused-model", name: "Unused", input: ["text"], reasoning: false } ] }, coze: { api: "openai-completions", baseUrl: "${COZE_BASE_URL}", apiKey: "${COZE_API_KEY}", headers: { "X-Workspace": "${WORKSPACE_ID}" }, models: [ { id: "glm-4-7-251222", name: "GLM 4.7", input: ["text"], reasoning: false, contextWindow: 200000, maxTokens: 8192, headers: { "X-Trace": "${TRACE_ID}" }, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } } ] } } } }); const tempRoot = dirname(dirname(configPath)); const env = { COZE_BASE_URL: "https://coze.example.com/v1", COZE_API_KEY: "coze-secret", WORKSPACE_ID: "workspace-1", TRACE_ID: "trace-1" }; const resolved = resolveRuntimeModel({ configPath, env }); assert.ok(resolved); assert.equal(resolved.model.provider, "coze"); assert.equal(resolved.model.api, "openai-completions"); assert.equal(resolved.model.baseUrl, env.COZE_BASE_URL); assert.equal(resolved.apiKey, env.COZE_API_KEY); assert.deepEqual(resolved.model.headers, { "X-Workspace": env.WORKSPACE_ID, "X-Trace": env.TRACE_ID }); const authStorage = AuthStorage.inMemory(); const modelRegistry = new ModelRegistry(authStorage, join(tempRoot, "absent-models.json")); authStorage.setRuntimeApiKey(resolved.model.provider, resolved.apiKey!); const auth = await modelRegistry.getApiKeyAndHeaders(resolved.model); rmSync(tempRoot, { recursive: true, force: true }); assert.deepEqual(auth, { ok: true, apiKey: env.COZE_API_KEY, headers: { "X-Workspace": env.WORKSPACE_ID, "X-Trace": env.TRACE_ID } }); }); test("resolveRuntimeModel applies workspace overrides to built-in providers", () => { const configPath = createConfigFile({ agents: { defaults: { workspace: ".", model: { primary: "openai/gpt-5-mini" } } }, models: { providers: { openai: { baseUrl: "https://proxy.example.com/v1", headers: { "X-Proxy": "${PROXY_ID}" }, modelOverrides: { "gpt-5-mini": { maxTokens: 4096 } } } } } }); const tempRoot = dirname(dirname(configPath)); const resolved = resolveRuntimeModel({ provider: "openai", model: "gpt-5-mini", baseUrl: "https://forced.example.com/v1", configPath, env: { PROXY_ID: "proxy-1" } }); rmSync(tempRoot, { recursive: true, force: true }); assert.ok(resolved); assert.equal(resolved.model.provider, "openai"); assert.equal(resolved.model.id, "gpt-5-mini"); assert.equal(resolved.model.baseUrl, "https://forced.example.com/v1"); assert.equal(resolved.model.maxTokens, 4096); assert.equal(resolved.model.headers?.["X-Proxy"], "proxy-1"); }); test("loadBotAppConfig builds the app config from config.json plus env-backed channels", () => { const configPath = createConfigFile({ agents: { defaults: { workspace: ".", thinkingLevel: "high", model: { primary: "coze/auto" } } }, models: { providers: { coze: { api: "openai-completions", apiKey: "${COZE_API_KEY}", baseUrl: "${COZE_BASE_URL}", models: [ { id: "auto", name: "Auto", input: ["text"], reasoning: false } ] } } }, channels: { feishu: { enabled: true, appId: "${FEISHU_APP_ID}", appSecret: "${FEISHU_APP_SECRET}", domain: "${FEISHU_DOMAIN}", encryptKey: "${FEISHU_ENCRYPT_KEY}", verificationToken: "${FEISHU_VERIFICATION_TOKEN}", requireMention: false, thinkingReaction: { enabled: false, emojiType: "OneSecond" } } } }); const tempRoot = dirname(dirname(configPath)); const loaded = loadBotAppConfig({ configPath, env: { PI_BOT_AGENT_MODE: "pi", FEISHU_APP_ID: "app-id", FEISHU_APP_SECRET: "app-secret", FEISHU_DOMAIN: "feishu.example.com", FEISHU_ENCRYPT_KEY: "encrypt-key", FEISHU_VERIFICATION_TOKEN: "verify-token" } }); rmSync(tempRoot, { recursive: true, force: true }); assert.equal(getDefaultConfigPath(), resolve(process.cwd(), "<%= workspaceDir %>/config.json")); assert.equal(loaded.agent.mode, "pi"); assert.equal(loaded.agent.provider, "coze"); assert.equal(loaded.agent.model, "auto"); assert.equal(loaded.agent.configPath, configPath); assert.equal(loaded.agent.thinkingLevel, "high"); assert.equal(loaded.agent.cwd, dirname(configPath)); assert.equal( (((loaded.configRoot?.agents as { defaults?: { model?: { primary?: string } } })?.defaults?.model?.primary) ?? ""), "coze/auto" ); assert.equal(loaded.channels.feishu?.appId, "app-id"); assert.equal(loaded.channels.feishu?.verificationToken, "verify-token"); assert.equal(loaded.routing.feishuGroupRequireMention, false); assert.equal(loaded.channels.feishu?.thinkingReaction?.enabled, false); assert.equal(loaded.channels.feishu?.thinkingReaction?.emojiType, "OneSecond"); }); test("loadConfig falls back to a minimal mock config when config.json is missing", () => { const tempDir = mkdtempSync(join(tmpdir(), "pi-bot-missing-config-")); const configPath = join(tempDir, "workspace", "config.json"); const loaded = loadConfig(configPath); rmSync(tempDir, { recursive: true, force: true }); assert.equal(loaded.source, "fallback-mock"); assert.equal(loaded.path, configPath); assert.equal(loaded.workspaceDir, dirname(configPath)); assert.deepEqual(loaded.defaultModel, { provider: "openai", modelId: "gpt-5-mini" }); assert.equal(loaded.config.channels?.feishu?.enabled, false); assert.equal(loaded.config.channels?.wechat?.enabled, false); }); test("loadBotAppConfig falls back to mock mode and skips file config path when config.json is missing", () => { const tempDir = mkdtempSync(join(tmpdir(), "pi-bot-missing-config-")); const configPath = join(tempDir, "workspace", "config.json"); const loaded = loadBotAppConfig({ configPath, env: {} }); rmSync(tempDir, { recursive: true, force: true }); assert.equal(loaded.agent.mode, "mock"); assert.equal(loaded.agent.provider, "openai"); assert.equal(loaded.agent.model, "gpt-5-mini"); assert.equal(loaded.agent.configPath, undefined); assert.equal(loaded.agent.cwd, dirname(configPath)); assert.equal( (((loaded.configRoot?.agents as { defaults?: { model?: { primary?: string } } })?.defaults?.model?.primary) ?? ""), "openai/gpt-5-mini" ); }); test("loadBotAppConfig still allows env to override mode when config.json is missing", () => { const tempDir = mkdtempSync(join(tmpdir(), "pi-bot-missing-config-")); const configPath = join(tempDir, "workspace", "config.json"); const loaded = loadBotAppConfig({ configPath, env: { PI_BOT_AGENT_MODE: "pi" } }); rmSync(tempDir, { recursive: true, force: true }); assert.equal(loaded.agent.mode, "pi"); assert.equal(loaded.agent.configPath, undefined); });