FN-8180: add Kimi K3 catalog and pricing support

Expose Kimi K3 throughout the native pi catalog and token-cost surfaces.

- Add K3 pricing and normalized lookup coverage for Dashboard cost calculations.
- Verify native K3 registry, API model route, and repeat-stop behavior end to end.
- Add a minor release changeset for Kimi K3 model selection support.

Files changed:
 .changeset/fn-8180-pi-sdk-kimi-k3.md               |  7 +++
 packages/core/src/__tests__/model-pricing.test.ts  |  7 ++-
 packages/core/src/model-pricing.ts                 | 17 +++++-
 ...ister-model-routes-kimi-k3-supplemental.test.ts | 70 ++++++++++++++++++++++
 .../pi-prompt-session-and-check-recursion.test.ts  | 15 +++++
 .../src/__tests__/provider-registration.test.ts    | 17 ++++++
 6 files changed, 130 insertions(+), 3 deletions(-)

Fusion-Task-Id: FN-8180
Fusion-Task-Lineage: 07598d73-c866-4329-b5cf-3aebc1c0a180
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-16 19:46:25 -07:00
parent 136958fc60
commit 26cb0cc2e1
6 changed files with 130 additions and 3 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Add Kimi K3 model selection and token-cost support.
category: feature
dev: Aligns pi SDK consumers to 0.80.10 and verifies native `kimi-coding:k3` catalog availability.

View File

@@ -113,11 +113,13 @@ describe("model-pricing", () => {
expect(result.usd).toBeCloseTo(4.875, 3);
});
it("prices GLM-5.2, MiniMax-M3, and Kimi K2.6 instead of reporting unavailable", () => {
it("prices GLM-5.2, MiniMax-M3, Kimi K2.6, and Kimi K3 instead of reporting unavailable", () => {
const cases = [
{ provider: "zai", model: "glm-5.2", expectedUsd: 2.28 },
{ provider: "minimax", model: "MiniMax-M3", expectedUsd: 0.54 },
{ provider: "kimi-coding", model: "kimi-k2.6-preview", expectedUsd: 1.75 },
// K3: $3/M cache-miss input + $15/M output; 1M input + 200k output = $6.
{ provider: "kimi-coding", model: "k3", expectedUsd: 6 },
] as const;
for (const { provider, model, expectedUsd } of cases) {
@@ -271,6 +273,9 @@ describe("model-pricing", () => {
expect(lookupPricing({ model: "kimi-k2.6-preview" })).toBe(
MODEL_PRICING["kimi-coding:kimi-k2.6-preview"],
);
expect(lookupPricing({ provider: " KIMI-CODING ", model: " K3 " })).toBe(
MODEL_PRICING["kimi-coding:k3"],
);
});
it("returns undefined for empty / unknown input", () => {

View File

@@ -28,7 +28,7 @@
* The date the rates in {@link MODEL_PRICING} were last verified, ISO-8601.
* Bump this whenever you edit a rate. Surfaced in the UI as "prices as of".
*/
export const pricingAsOf = "2026-07-11";
export const pricingAsOf = "2026-07-16";
/**
* Pricing entries older than this (relative to a caller-supplied `now`) are
@@ -387,7 +387,12 @@ export const MODEL_PRICING: Readonly<Record<string, ModelPricing>> = {
/*
* FNXC:ModelCatalog 2026-07-11-23:02:
* The LiteLLM pricing refresh only maps OpenAI, Anthropic, and Google providers, so Z.ai, MiniMax, and Kimi Coding runs need static rows to keep Dashboard token-cost surfaces from rendering `—`. Rates are verified from https://docs.z.ai/guides/overview/pricing.md, https://platform.minimax.io/docs/guides/pricing-paygo.md, and https://platform.kimi.ai/docs/pricing/chat-k26.md; MiniMax uses the Standard ≤512k pay-as-you-go tier because MODEL_PRICING has no request-tier dimension.
* The LiteLLM pricing refresh only maps OpenAI, Anthropic, and Google providers, so Z.ai, MiniMax, and Kimi Coding runs need static rows to keep Dashboard token-cost surfaces from rendering `—`. Rates are verified from https://docs.z.ai/guides/overview/pricing.md, https://platform.minimax.io/docs/guides/pricing-paygo.md, https://platform.kimi.ai/docs/pricing/chat-k26.md, and https://platform.kimi.ai/docs/pricing/chat-k3.md; MiniMax uses the Standard ≤512k pay-as-you-go tier because MODEL_PRICING has no request-tier dimension.
* FNXC:ModelCatalog 2026-07-16-19:05:
* FN-8180 upgrades pi to 0.80.10, whose native kimi-coding catalog exposes `k3`.
* Keep the Dashboard's independent cost derivation aligned with Moonshot's published
* K3 rates rather than pi's intentionally zero-valued catalog cost placeholders.
*/
// ── Zhipu AI (Z.ai) ─────────────────────────────────────────────────
// No distinct cache-write token charge → cacheWrite = input rate.
@@ -418,6 +423,14 @@ export const MODEL_PRICING: Readonly<Record<string, ModelPricing>> = {
cacheWritePer1M: 0.95,
source: "platform.kimi.ai/docs/pricing/chat-k26.md",
},
// Cache miss is the input rate; K3 has no separately published cache-write rate.
"kimi-coding:k3": {
inputPer1M: 3,
outputPer1M: 15,
cacheReadPer1M: 0.3,
cacheWritePer1M: 3,
source: "platform.kimi.ai/docs/pricing/chat-k3.md",
},
};
/** Reference to a model, by provider + id (either may be unset). */

View File

@@ -0,0 +1,70 @@
import type { Router } from "express";
import { describe, expect, it, vi } from "vitest";
import { ModelRegistry, ModelRuntime } from "@earendil-works/pi-coding-agent";
import { registerModelRoutes } from "../routes/register-model-routes.js";
/*
FNXC:ModelCatalog 2026-07-16-19:05:
FN-8180 requires Kimi K3 to reach both model-catalog consumers. This route-level
coverage uses pi 0.80.10's actual built-in ModelRuntime catalog after refresh, so an
SDK catalog regression cannot leave the Dashboard dropdown missing K3 while the engine
registry test remains green.
*/
async function createNativeKimiRegistry(): Promise<ModelRegistry> {
const runtime = await ModelRuntime.create({
credentials: {
read: async (providerId) => providerId === "kimi-coding"
? { type: "api_key", key: "test-kimi-key" }
: undefined,
list: async () => [{ providerId: "kimi-coding", type: "api_key" }],
modify: async (_providerId, fn) => fn(undefined),
delete: async () => undefined,
},
modelsPath: null,
allowModelNetwork: false,
});
return new ModelRegistry(runtime);
}
function createModelsHandler(modelRegistry: ModelRegistry) {
const handlers = new Map<string, (req: unknown, res: { json: (body: unknown) => void }) => Promise<void>>();
const router = {
get: vi.fn((path: string, handler: (req: unknown, res: { json: (body: unknown) => void }) => Promise<void>) => {
handlers.set(path, handler);
}),
} as unknown as Router;
const authStorage = {
reload: vi.fn(),
getOAuthProviders: vi.fn(() => []),
getApiKeyProviders: vi.fn(() => [{ id: "kimi-coding", name: "Kimi" }]),
get: vi.fn(() => ({ type: "api_key", key: "test-kimi-key" })),
hasApiKey: vi.fn((providerId: string) => providerId === "kimi-coding"),
hasAuth: vi.fn((providerId: string) => providerId === "kimi-coding"),
};
registerModelRoutes({
router,
store: {
getGlobalSettingsStore: () => ({ getSettings: vi.fn().mockResolvedValue({}) }),
getSettingsFast: vi.fn().mockResolvedValue({}),
} as never,
runtimeLogger: { child: vi.fn(() => ({ warn: vi.fn() })) } as never,
options: { modelRegistry, authStorage } as never,
} as never);
return handlers.get("/models")!;
}
describe("FN-8180: Kimi K3 /api/models catalog", () => {
it("surfaces the native K3 model once for a configured Kimi provider", async () => {
const handler = createModelsHandler(await createNativeKimiRegistry());
const json = vi.fn();
await handler({}, { json });
const response = json.mock.calls[0][0] as { models: Array<{ provider: string; id: string; name: string; reasoning: boolean; contextWindow: number }> };
const k3Rows = response.models.filter((model) => model.provider === "kimi-coding" && model.id === "k3");
expect(k3Rows).toEqual([{ provider: "kimi-coding", id: "k3", name: "Kimi K3", reasoning: true, contextWindow: 1_048_576 }]);
});
});

View File

@@ -57,6 +57,21 @@ describe("promptSessionAndCheck recursion guard (FN-4930)", () => {
expect(warnMock).not.toHaveBeenCalledWith(expect.stringContaining("failed to inspect transcript"));
});
it("treats Kimi's finish_reason repeat as a soft stop", async () => {
const { promptSessionAndCheck } = await import("../pi.js");
const state = { errorMessage: "", messages: [] };
const session = {
model: { provider: "kimi-coding", id: "k3" },
prompt: vi.fn(async () => {
state.errorMessage = "Provider finish_reason: repeat";
}),
state,
} as any;
await expect(promptSessionAndCheck(session, "hello")).resolves.toBeUndefined();
expect(state.errorMessage).toBeUndefined();
});
it("annotates model-auth-tier incompatibility errors with model identity and actionable hint", async () => {
const { promptSessionAndCheck } = await import("../pi.js");

View File

@@ -127,6 +127,23 @@ describe("seedDashboardProviders", () => {
expect(providerIds).toEqual(expect.arrayContaining(["zai", "openrouter", "kimi-coding", "grok-cli"]));
});
it("keeps native Kimi K3 available through the installed pi model registry", async () => {
// FNXC:ModelCatalog 2026-07-16-19:05: FN-8180 requires catalog coverage to
// exercise pi's real 0.80.10 built-in registry, not a hand-written Kimi fixture.
const modelRegistry = await createInMemoryModelRegistry();
await modelRegistry.refresh();
expect(modelRegistry.find("kimi-coding", "k3")).toMatchObject({
provider: "kimi-coding",
id: "k3",
name: "Kimi K3",
api: "anthropic-messages",
baseUrl: "https://api.kimi.com/coding",
contextWindow: 1_048_576,
maxTokens: 131_072,
});
});
it("registers one custom provider alongside built-ins", async () => {
const store = makeStore([customProvider()]);
const authStorage = makeAuthStorage();