Files
penguin-harness/packages/core/test/model-catalog.test.ts
T
Yaowei Zheng 45bfae6e94 Initialize repository with harness code and assets
Initial import of all source code, config, and README assets: the
packages workspace (cli, core, server, web, docs, landing, skills),
build scripts, tooling config, and CI workflows.

Includes the data-layout revision made on this branch: the local data
root defaults to ~/.penguin/data (PENGUIN_HOME still overrides; the
installer keeps its binaries in ~/.penguin), and every Agent lives
under <project>/agents/<agent>/ — path helpers, the three
agent-enumeration scans, the system prompt, built-in Skills, tests
and docs all follow the new layout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ihk8iQuo3kv2aPjAYEPuR
2026-07-19 14:06:53 +08:00

213 lines
9.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Built-in model catalog unit tests: unique ids, valid provider references, positive
* three-bucket pricing, lookups, and preset entry generation.
*/
import { describe, expect, it } from "vitest";
import {
MODEL_CATALOG,
MODEL_PROVIDERS,
catalogEntryFor,
inferProviderForUpstream,
presetModelEntries,
providerInfo,
resolveModelEnv,
} from "../src/state/index.js";
describe("model-catalog", () => {
it("model id 全局唯一;DeepSeek 排在最前(默认模型所属厂商)", () => {
const ids = MODEL_CATALOG.map((m) => m.modelId);
expect(new Set(ids).size).toBe(ids.length);
expect(MODEL_CATALOG[0]!.provider).toBe("deepseek");
// Group order: DeepSeek first, followed by the OpenRouter and SiliconFlow gateways,
// then Google Gemini before Anthropic, with custom last.
expect(MODEL_PROVIDERS.map((p) => p.id)).toEqual([
"deepseek",
"openrouter",
"siliconflow",
"google",
"anthropic",
"openai",
"zhipu",
"moonshot",
"custom",
]);
expect(providerInfo("siliconflow")!.label).toBe("SiliconFlow");
// The catalog no longer includes GLM-5-Turbo.
expect(ids).not.toContain("glm-5-turbo");
});
it("provider 均在 MODEL_PROVIDERS 内(custom 仅收拢自定义模型,目录不使用)", () => {
const providerIds = new Set(MODEL_PROVIDERS.map((p) => p.id));
for (const m of MODEL_CATALOG) {
expect(providerIds.has(m.provider)).toBe(true);
expect(m.provider).not.toBe("custom");
}
// Except for custom, every provider gives a console link to "get an API key" (shown in the
// frontend's group header) and a model list / docs link to "get a model id" (shown in the add-model dialog).
for (const p of MODEL_PROVIDERS) {
if (p.id === "custom") {
expect(p.apiKeyUrl).toBeUndefined();
expect(p.modelsUrl).toBeUndefined();
} else {
expect(p.apiKeyUrl).toMatch(/^https:\/\//);
expect(p.modelsUrl).toMatch(/^https:\/\//);
}
}
// Provider ids are also unique, and each provider has an API key / base URL env var name.
expect(new Set([...providerIds]).size).toBe(MODEL_PROVIDERS.length);
for (const p of MODEL_PROVIDERS) {
expect(p.envKey).toMatch(/_API_KEY$/);
expect(p.envBaseUrlKey).toMatch(/_BASE_URL$/);
}
});
it("三桶价格均为正数,context_window 为正整数", () => {
for (const m of MODEL_CATALOG) {
expect(m.pricing, m.modelId).toBeDefined();
expect(m.pricing!.unit).toBe("usd_per_mtok");
expect(m.pricing!.cache_read).toBeGreaterThan(0);
expect(m.pricing!.cache_write).toBeGreaterThan(0);
expect(m.pricing!.output).toBeGreaterThan(0);
expect(Number.isInteger(m.contextWindow)).toBe(true);
expect(m.contextWindow!).toBeGreaterThan(0);
}
});
it("providerInfo 按 id 命中,未知返回 undefined", () => {
expect(providerInfo("moonshot")?.envKey).toBe("MOONSHOT_API_KEY");
expect(providerInfo("nonexistent")).toBeUndefined();
});
it("成对匹配与分组推断:catalogEntryFor / inferProviderForUpstream", () => {
// catalogEntryFor is the sole catalog lookup entry point: it matches on (group, upstream id)
// pairs, so an identically named upstream id never matches across the wrong group.
expect(catalogEntryFor("anthropic", "claude-sonnet-4-6")?.displayName).toBe(
"Claude Sonnet 4.6",
);
expect(catalogEntryFor("openai", "claude-sonnet-4-6")).toBeUndefined();
// The upstream id itself may contain / (gateway models); it is never split apart.
expect(catalogEntryFor("openrouter", "xiaomi/mimo-v2.5")?.displayName).toBe("MiMo-V2.5");
expect(catalogEntryFor("custom", "my-own")).toBeUndefined();
// Inference for `model add` when --provider is omitted: a catalog hit yields its provider, otherwise custom.
expect(inferProviderForUpstream("deepseek-v4-pro")).toBe("deepseek");
expect(inferProviderForUpstream("xiaomi/mimo-v2.5")).toBe("openrouter");
expect(inferProviderForUpstream("my-own-model")).toBe("custom");
});
it("presetModelEntries:provider 与纯上游 model_id 分列;网关模型内联 base_url", () => {
const entries = presetModelEntries();
expect(entries).toHaveLength(MODEL_CATALOG.length);
for (const [i, entry] of entries.entries()) {
const cat = MODEL_CATALOG[i]!;
expect(entry.provider).toBe(cat.provider);
expect(entry.model_id).toBe(cat.modelId);
expect(entry.context_window).toBe(cat.contextWindow);
expect(entry.pricing).toEqual(cat.pricing);
expect(entry.vision).toBe(cat.supportsVision ? undefined : false);
// Models that AgentHub can auto-route leave client_type unset; OpenRouter gateway models set it to openai.
expect(entry.client_type).toBe(cat.clientType);
// Gateway models inline a preset base URL (no credentials); other models carry no credential at all.
expect(entry.base_url).toBe(cat.baseUrl);
expect(entry.api_key).toBeUndefined();
// The concatenated storage id and request_model_id have been removed and no longer appear.
expect(Object.hasOwn(entry, "request_model_id")).toBe(false);
}
});
it("网关模型(OpenRouter / SiliconFlow):openai 协议 + 预置 base URL;env 兜底为 OPENAI_API_KEY", () => {
const or = MODEL_CATALOG.filter((m) => m.provider === "openrouter");
expect(or.map((m) => m.modelId)).toEqual([
"xiaomi/mimo-v2.5",
"tencent/hy3",
"minimax/minimax-m3",
"stepfun/step-3.7-flash",
]);
for (const m of or) {
expect(m.clientType).toBe("openai");
expect(m.baseUrl).toBe("https://openrouter.ai/api/v1");
}
const sf = MODEL_CATALOG.filter((m) => m.provider === "siliconflow");
expect(sf.map((m) => m.modelId)).toEqual([
"zai-org/GLM-5.2",
"deepseek-ai/DeepSeek-V4-Pro",
"meituan-longcat/LongCat-2.0",
]);
for (const m of sf) {
expect(m.clientType).toBe("openai");
expect(m.baseUrl).toBe("https://api.siliconflow.cn/v1");
}
// Routed through AgentHub's OpenAI client -> when the credential is left blank it reads OPENAI_API_KEY (not the provider's own env var name).
for (const id of ["openrouter", "siliconflow", "custom"]) {
expect(providerInfo(id)!.envKey).toBe("OPENAI_API_KEY");
expect(providerInfo(id)!.envBaseUrlKey).toBe("OPENAI_BASE_URL");
}
// gatewayBaseUrl (prefilled by group in the frontend's "add model" dialog) is only carried by the two gateway providers.
expect(providerInfo("openrouter")!.gatewayBaseUrl).toBe("https://openrouter.ai/api/v1");
expect(providerInfo("siliconflow")!.gatewayBaseUrl).toBe("https://api.siliconflow.cn/v1");
for (const p of MODEL_PROVIDERS) {
if (p.id !== "openrouter" && p.id !== "siliconflow") {
expect(p.gatewayBaseUrl, p.id).toBeUndefined();
}
}
const gateway = [...or, ...sf];
// Pricing (USD): MiMo v2.5 and Hy3.
const mimo = MODEL_CATALOG.find((m) => m.modelId === "xiaomi/mimo-v2.5")!.pricing!;
expect([mimo.cache_read, mimo.cache_write, mimo.output]).toEqual([0.0028, 0.14, 0.28]);
const hy3 = MODEL_CATALOG.find((m) => m.modelId === "tencent/hy3")!.pricing!;
expect([hy3.cache_read, hy3.cache_write, hy3.output]).toEqual([0.035, 0.14, 0.58]);
// In preset entries, exactly the gateway models (and only them) inline base_url (no credentials).
const withBaseUrl = presetModelEntries().filter((e) => e.base_url !== undefined);
expect(withBaseUrl.map((e) => [e.provider, e.model_id]).sort()).toEqual(
gateway.map((m) => [m.provider, m.modelId]).sort(),
);
});
it("DeepSeek 与 Kimi 按官方人民币价初始化(存储美元,×7 还原官方原价)", () => {
const cnyOf = (usdV: number) => Math.round(usdV * 7 * 1000) / 1000;
const pro = MODEL_CATALOG.find((m) => m.modelId === "deepseek-v4-pro")!.pricing!;
expect([cnyOf(pro.cache_read), cnyOf(pro.cache_write), cnyOf(pro.output)]).toEqual([
0.025, 3, 6,
]);
const k26 = MODEL_CATALOG.find((m) => m.modelId === "kimi-k2.6")!.pricing!;
expect([cnyOf(k26.cache_read), cnyOf(k26.cache_write), cnyOf(k26.output)]).toEqual([
1.1, 6.5, 27,
]);
});
});
describe("resolveModelEnv(PRN-021:env 兜底按 AgentHub 路由规则解析)", () => {
it("一方厂商 id 按子串路由到厂商客户端的环境变量", () => {
expect(resolveModelEnv("deepseek-v4-pro")?.envKey).toBe("DEEPSEEK_API_KEY");
expect(resolveModelEnv("claude-opus-4-8")?.envKey).toBe("ANTHROPIC_API_KEY");
expect(resolveModelEnv("claude-sonnet-4-6")?.envKey).toBe("ANTHROPIC_API_KEY");
expect(resolveModelEnv("gemini-3.5-flash")?.envKey).toBe("GEMINI_API_KEY");
expect(resolveModelEnv("gpt-5.5-pro")?.envKey).toBe("OPENAI_API_KEY");
expect(resolveModelEnv("glm-5.2")?.envKey).toBe("ZAI_API_KEY");
expect(resolveModelEnv("kimi-k2.6")?.envBaseUrlKey).toBe("MOONSHOT_BASE_URL");
});
it("显式 client_type 优先于 id:openai 协议一律 OPENAI_*(与分组无关)", () => {
expect(resolveModelEnv("deepseek-v4-pro", "openai")?.envKey).toBe("OPENAI_API_KEY");
expect(resolveModelEnv("zai-org/GLM-5.2", "openai")?.envKey).toBe("OPENAI_API_KEY");
});
it("无法路由的 id 返回 undefined(AgentHub 会拒绝,需显式 client_type 或 OpenAI 协议分组)", () => {
expect(resolveModelEnv("totally-unknown-model")).toBeUndefined();
expect(resolveModelEnv("xiaomi/mimo-v2.5")).toBeUndefined();
});
it("目录不变式:无 client_type 的条目按 id 可路由且 envKey 与厂商一致;网关条目经 client_type 解析为 OPENAI_*", () => {
for (const m of MODEL_CATALOG) {
const env = resolveModelEnv(m.modelId, m.clientType);
expect(env, `${m.provider}/${m.modelId}`).toBeDefined();
if (m.clientType === undefined) {
expect(env!.envKey, m.modelId).toBe(providerInfo(m.provider)!.envKey);
} else {
expect(env!.envKey, m.modelId).toBe("OPENAI_API_KEY");
}
}
});
});