feat: add web executor model configuration

This commit is contained in:
liujing
2026-07-20 16:59:24 +08:00
parent 4026f04b93
commit 64b3ef6c60
8 changed files with 1507 additions and 17 deletions
+207
View File
@@ -0,0 +1,207 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { extractModelIds, discoverExecutorModels } from "./workflow-model-discovery.js";
import { saveGlobalExecutorModels, loadGlobalWorkflowConfig } from "./workflow-config.js";
describe("workflow-model-discovery helpers", () => {
it("extracts model ids from data[] shape", () => {
expect(extractModelIds({
data: [
{ id: "claude-sonnet-4" },
{ id: "claude-opus-4" },
{ name: "ignored" },
],
})).toEqual(["claude-sonnet-4", "claude-opus-4"]);
});
it("extracts model ids from models[] shape", () => {
expect(extractModelIds({
models: [
{ id: "model-a" },
"model-b",
],
})).toEqual(["model-a", "model-b"]);
});
});
describe("saveGlobalExecutorModels", () => {
let tmpHome: string;
let originalHome: string | undefined;
beforeEach(() => {
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "agent-workflow-global-config-"));
originalHome = process.env.HOME;
process.env.HOME = tmpHome;
});
afterEach(() => {
if (originalHome === undefined) delete process.env.HOME;
else process.env.HOME = originalHome;
fs.rmSync(tmpHome, { recursive: true, force: true });
});
it("atomically updates only executor models and preserves unrelated fields", () => {
const configDir = path.join(tmpHome, ".agent-workflow");
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(path.join(configDir, "config.json"), JSON.stringify({
version: "1",
defaultExecutor: "reasonix",
maxCycles: 7,
executors: {
reasonix: { binary: "/usr/local/bin/reasonix", model: "old-model", profile: "fast" },
claude: { binary: "claude" },
},
}, null, 2));
saveGlobalExecutorModels({
reasonix: "new-reasonix",
claude: "claude-sonnet-4",
agy: null,
});
const loaded = loadGlobalWorkflowConfig()!;
expect(loaded.defaultExecutor).toBe("reasonix");
expect(loaded.maxCycles).toBe(7);
expect(loaded.executors?.reasonix).toEqual({
binary: "/usr/local/bin/reasonix",
model: "new-reasonix",
profile: "fast",
});
expect(loaded.executors?.claude).toEqual({
binary: "claude",
model: "claude-sonnet-4",
});
expect(loaded.executors?.agy).toBeUndefined();
});
it("null clears a model override without dropping other executor fields", () => {
const configDir = path.join(tmpHome, ".agent-workflow");
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(path.join(configDir, "config.json"), JSON.stringify({
version: "1",
executors: {
agy: { binary: "agy", model: "to-remove", agent: "code-assistant" },
},
}));
saveGlobalExecutorModels({ agy: null });
const loaded = loadGlobalWorkflowConfig()!;
expect(loaded.executors?.agy).toEqual({ binary: "agy", agent: "code-assistant" });
});
});
describe("discoverExecutorModels isolation", () => {
let tmpHome: string;
let originalHome: string | undefined;
let originalFetch: typeof globalThis.fetch | undefined;
const ANTHROPIC_VARS = [
"ANTHROPIC_API_KEY",
"ANTHROPIC_AUTH_TOKEN",
"ANTHROPIC_BASE_URL",
"ANTHROPIC_DEFAULT_SONNET_MODEL",
"ANTHROPIC_DEFAULT_OPUS_MODEL",
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
] as const;
const savedVars: Record<string, string | undefined> = {};
beforeEach(() => {
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "agent-workflow-discovery-"));
originalHome = process.env.HOME;
process.env.HOME = tmpHome;
originalFetch = globalThis.fetch;
for (const key of ANTHROPIC_VARS) {
savedVars[key] = process.env[key];
delete process.env[key];
}
});
afterEach(() => {
if (originalHome === undefined) delete process.env.HOME;
else process.env.HOME = originalHome;
for (const key of ANTHROPIC_VARS) {
if (savedVars[key] === undefined) delete process.env[key];
else process.env[key] = savedVars[key];
}
if (originalFetch) globalThis.fetch = originalFetch;
else delete (globalThis as { fetch?: typeof fetch }).fetch;
fs.rmSync(tmpHome, { recursive: true, force: true });
vi.restoreAllMocks();
});
function writeClaudeSettings(env: Record<string, string>): void {
const claudeDir = path.join(tmpHome, ".claude");
fs.mkdirSync(claudeDir, { recursive: true });
fs.writeFileSync(path.join(claudeDir, "settings.json"), JSON.stringify({ env }));
}
it("keeps Claude discovery isolated and never returns credentials", async () => {
process.env.ANTHROPIC_API_KEY = "secret-key-value";
process.env.ANTHROPIC_BASE_URL = "https://user:pass@api.example.com/v1?token=abc#frag";
globalThis.fetch = vi.fn(async () => {
throw new Error("network down secret-key-value");
}) as unknown as typeof fetch;
const result = await discoverExecutorModels();
expect(result.claude.status).toBe("error");
expect(result.claude.error).toBeTruthy();
expect(JSON.stringify(result)).not.toContain("secret-key-value");
expect(JSON.stringify(result)).not.toContain("user:pass");
expect(result.agy.status === "ok" || result.agy.status === "error").toBe(true);
expect(result.reasonix.status === "ok" || result.reasonix.status === "error").toBe(true);
});
it("resolves Claude settings from ~/.claude/settings.json when process env is unset", async () => {
// Process env is fully cleared in beforeEach; settings.json is the only source.
writeClaudeSettings({
ANTHROPIC_BASE_URL: "https://gateway.internal.example",
ANTHROPIC_AUTH_TOKEN: "token-from-settings",
ANTHROPIC_DEFAULT_SONNET_MODEL: "gateway-sonnet",
ANTHROPIC_DEFAULT_OPUS_MODEL: "gateway-opus",
});
let capturedUrl = "";
let capturedAuth: string | undefined;
let capturedApiKey: string | undefined;
globalThis.fetch = vi.fn(async (input: any, init: any) => {
capturedUrl = String(input);
const headers = new Headers(init?.headers);
capturedAuth = headers.get("authorization") ?? undefined;
capturedApiKey = headers.get("x-api-key") ?? undefined;
return {
ok: true,
status: 200,
json: async () => ({ data: [{ id: "gateway-model-1" }, { id: "gateway-model-2" }] }),
};
}) as unknown as typeof fetch;
const result = await discoverExecutorModels();
expect(result.claude.status).toBe("ok");
expect(capturedUrl.startsWith("https://gateway.internal.example")).toBe(true);
// Auth token must use the Authorization: Bearer header, not x-api-key.
expect(capturedAuth).toBe("Bearer token-from-settings");
expect(capturedApiKey == null).toBe(true);
expect(result.claude.models).toContain("gateway-model-1");
// Configured default model candidates are surfaced.
expect(result.claude.defaults).toContain("gateway-sonnet");
expect(result.claude.defaults).toContain("gateway-opus");
// The resolved token must never appear anywhere in the browser-visible result.
expect(JSON.stringify(result)).not.toContain("token-from-settings");
});
it("redacts a settings.json auth token from browser-visible errors", async () => {
writeClaudeSettings({
ANTHROPIC_BASE_URL: "https://gateway.internal.example",
ANTHROPIC_AUTH_TOKEN: "token-from-settings",
});
globalThis.fetch = vi.fn(async () => {
throw new Error("upstream failed for token-from-settings");
}) as unknown as typeof fetch;
const result = await discoverExecutorModels();
expect(result.claude.status).toBe("error");
expect(result.claude.error).toBeTruthy();
expect(JSON.stringify(result)).not.toContain("token-from-settings");
});
});