feat: AgentHub 0.4.1 and a model-catalog refresh across every provider group (#43)

Co-authored-by: Alice <alice@prismshadow.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-07-23 02:02:54 +08:00
1 parent d01d0faf7a
commit 0f6828e57b
8 files changed
+334 -38

No files matched your search

+115 -4
View File
@@ -69,11 +69,19 @@ describe("model-catalog", () => {
});
it("price buckets are positive (preview models without a list price omit pricing); context_window is a positive integer", () => {
// Models with no obtainable published price. qwen3.8-max-preview: the plan runs a
// quota-multiplier promotion instead of a per-token list price. The three SiliconFlow
// entries: AgentHub's registry publishes no pricing for them and SiliconFlow's price list
// is only reachable with an authenticated token, so no number can be sourced. All of them
// carry no pricing and their costs read as 0, same as unpriced user models.
const UNPRICED = new Set([
"qwen-token-plan\0qwen3.8-max-preview",
"siliconflow\0Pro/moonshotai/Kimi-K2.6",
"siliconflow\0Pro/zai-org/GLM-5.1",
"siliconflow\0Qwen/Qwen3.6-35B-A3B",
]);
for (const m of MODEL_CATALOG) {
if (m.provider === "qwen-token-plan" && m.modelId === "qwen3.8-max-preview") {
// Preview-only model: the plan runs a quota-multiplier promotion and publishes no
// per-token list price, so the entry carries none and costs read as 0 (same as
// unpriced user models).
if (UNPRICED.has(`${m.provider}\0${m.modelId}`)) {
expect(m.pricing, m.modelId).toBeUndefined();
} else if (m.modelId.endsWith(":free")) {
// Free-tier gateway model: a genuine $0 price (not "unknown"), so costs compute to 0.
@@ -149,18 +157,23 @@ describe("model-catalog", () => {
"anthropic/claude-sonnet-5",
"deepseek/deepseek-v4-flash",
"deepseek/deepseek-v4-pro",
"google/gemini-3.6-flash",
"google/gemini-3.5-flash",
"google/gemini-3.5-flash-lite",
"minimax/minimax-m3",
"moonshotai/kimi-k3",
"moonshotai/kimi-k2.6",
"nvidia/nemotron-3-ultra-550b-a55b:free",
"openai/gpt-5.6-sol",
"openai/gpt-5.6-terra",
"openai/gpt-5.5",
"qwen/qwen3.6-35b-a3b",
"stepfun/step-3.7-flash",
"tencent/hy3",
"x-ai/grok-4.5",
"xiaomi/mimo-v2.5",
"z-ai/glm-5.2",
"z-ai/glm-5.1",
]);
for (const m of or) {
expect(m.clientType).toBe("openai");
@@ -179,11 +192,16 @@ describe("model-catalog", () => {
expect(m.baseUrl).toBe("https://api.fireworks.ai/inference/v1");
}
const sf = MODEL_CATALOG.filter((m) => m.provider === "siliconflow");
// Dictionary order is case-insensitive (as in qwen-pay-as-you-go, where ZHIPU/GLM-5.2
// sorts last): Pro/ and Qwen/ fall between moonshotai/ and zai-org/.
expect(sf.map((m) => m.modelId)).toEqual([
"deepseek-ai/DeepSeek-V4-Flash",
"deepseek-ai/DeepSeek-V4-Pro",
"meituan-longcat/LongCat-2.0",
"moonshotai/Kimi-K2.7-Code",
"Pro/moonshotai/Kimi-K2.6",
"Pro/zai-org/GLM-5.1",
"Qwen/Qwen3.6-35B-A3B",
"zai-org/GLM-5.2",
]);
for (const m of sf) {
@@ -261,6 +279,25 @@ describe("model-catalog", () => {
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]);
// Gemini 3.6 Flash and 3.5 Flash Lite: upstream publishes a cache-hit price, so cache_read
// stores the real discounted price (not the input price) — cache_read is its own billing
// bucket in the cost center. cache_write repeats input (no per-token cache-write fee).
const g36 = catalogEntryFor("openrouter", "google/gemini-3.6-flash")!;
expect([g36.contextWindow, g36.supportsVision]).toEqual([1048576, true]);
expect([g36.pricing!.cache_read, g36.pricing!.cache_write, g36.pricing!.output]).toEqual([
0.15, 1.5, 7.5,
]);
const g35lite = catalogEntryFor("openrouter", "google/gemini-3.5-flash-lite")!;
expect([g35lite.contextWindow, g35lite.supportsVision]).toEqual([1048576, true]);
expect([
g35lite.pricing!.cache_read,
g35lite.pricing!.cache_write,
g35lite.pricing!.output,
]).toEqual([0.03, 0.3, 2.5]);
// The gateway row for gemini-3.5-flash reports the same context window as the
// direct-vendor row for that model (and as AgentHub's registry): 1048576, not 1000000.
expect(catalogEntryFor("openrouter", "google/gemini-3.5-flash")!.contextWindow).toBe(1048576);
expect(catalogEntryFor("google", "gemini-3.5-flash")!.contextWindow).toBe(1048576);
// In preset entries, exactly the gateway models (and only them) inline base_url (no credentials).
const withBaseUrl = presetModelEntries().filter((e) => e.base_url !== undefined);
@@ -269,12 +306,78 @@ describe("model-catalog", () => {
);
});
it("direct-vendor groups: auto-routed (no client_type / base_url), newest series first", () => {
// These groups' ids are auto-routed by AgentHub, so they carry neither client_type nor a
// preset base URL — the opposite of the gateway groups above.
for (const id of ["google", "anthropic", "moonshot"]) {
for (const m of MODEL_CATALOG.filter((e) => e.provider === id)) {
expect(m.clientType, m.modelId).toBeUndefined();
expect(m.baseUrl, m.modelId).toBeUndefined();
}
}
// Dictionary order by tier with newer versions of a tier first (same rule the OpenRouter
// block follows for the identical Claude line-up).
expect(MODEL_CATALOG.filter((m) => m.provider === "google").map((m) => m.modelId)).toEqual([
"gemini-3.6-flash",
"gemini-3.5-flash",
"gemini-3.5-flash-lite",
"gemini-3.1-flash-lite",
"gemini-3.1-pro-preview",
"gemini-3-flash-preview",
]);
expect(MODEL_CATALOG.filter((m) => m.provider === "anthropic").map((m) => m.modelId)).toEqual([
"claude-fable-5",
"claude-opus-4-8",
"claude-opus-4-7",
"claude-sonnet-5",
"claude-sonnet-4-6",
]);
expect(MODEL_CATALOG.filter((m) => m.provider === "moonshot").map((m) => m.modelId)).toEqual([
"kimi-k3",
"kimi-k2.6",
"kimi-k2.5",
]);
// Anthropic keeps its cache_write = 1.25 x input convention for the Claude 5 line too
// (registry input 10 and 2 -> 12.5 and 2.5), unlike every other group where cache_write
// repeats the input price.
const fable = catalogEntryFor("anthropic", "claude-fable-5")!;
expect([fable.pricing!.cache_read, fable.pricing!.cache_write, fable.pricing!.output]).toEqual([
1, 12.5, 50,
]);
const sonnet5 = catalogEntryFor("anthropic", "claude-sonnet-5")!;
expect([
sonnet5.pricing!.cache_read,
sonnet5.pricing!.cache_write,
sonnet5.pricing!.output,
]).toEqual([0.2, 2.5, 10]);
// The same model resold by a gateway keeps one display name across groups.
for (const [directProvider, directId, gatewayProvider, gatewayId] of [
["anthropic", "claude-fable-5", "openrouter", "anthropic/claude-fable-5"],
["anthropic", "claude-sonnet-5", "openrouter", "anthropic/claude-sonnet-5"],
["google", "gemini-3.5-flash-lite", "openrouter", "google/gemini-3.5-flash-lite"],
["moonshot", "kimi-k3", "openrouter", "moonshotai/kimi-k3"],
["moonshot", "kimi-k2.6", "openrouter", "moonshotai/kimi-k2.6"],
["moonshot", "kimi-k2.6", "siliconflow", "Pro/moonshotai/Kimi-K2.6"],
["zhipu", "glm-5.1", "openrouter", "z-ai/glm-5.1"],
["zhipu", "glm-5.1", "siliconflow", "Pro/zai-org/GLM-5.1"],
] as const) {
expect(
catalogEntryFor(gatewayProvider, gatewayId)!.displayName,
`${gatewayProvider}/${gatewayId}`,
).toBe(catalogEntryFor(directProvider, directId)!.displayName);
}
});
it("DeepSeek and Kimi are initialized from official CNY prices (stored in USD; x7 recovers the official price)", () => {
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 k3 = MODEL_CATALOG.find(
(m) => m.provider === "moonshot" && m.modelId === "kimi-k3",
)!.pricing!;
expect([cnyOf(k3.cache_read), cnyOf(k3.cache_write), cnyOf(k3.output)]).toEqual([2, 20, 100]);
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,
@@ -291,6 +394,14 @@ describe("resolveModelEnv (PRN-021: env fallback resolved by AgentHub routing ru
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");
// agenthub 0.4.1 routes these to their own clients; both read the same env pair as the
// family they belong to, so the id must still resolve (kimi-k3 matches no k2.x substring).
expect(resolveModelEnv("kimi-k3")?.envKey).toBe("MOONSHOT_API_KEY");
expect(resolveModelEnv("kimi-k3")?.envBaseUrlKey).toBe("MOONSHOT_BASE_URL");
expect(resolveModelEnv("gemini-3.6-flash")?.envKey).toBe("GEMINI_API_KEY");
expect(resolveModelEnv("gemini-3.5-flash-lite")?.envKey).toBe("GEMINI_API_KEY");
expect(resolveModelEnv("claude-fable-5")?.envKey).toBe("ANTHROPIC_API_KEY");
expect(resolveModelEnv("claude-sonnet-5")?.envKey).toBe("ANTHROPIC_API_KEY");
});
it("explicit client_type beats id: the openai protocol always uses OPENAI_* (independent of grouping)", () => {