From ec2e1dca1808a50a99f7f18e6d92dc5b66dfc898 Mon Sep 17 00:00:00 2001 From: yangyunchao Date: Fri, 15 May 2026 20:51:17 +0800 Subject: [PATCH] feat(provider): add kimi-for-coding provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add support for Kimi for Coding (Moonshot AI Coding Plan) via their Anthropic-compatible endpoint at https://api.kimi.com/coding. Key points: - Requires whitelisted User-Agent (KimiCLI/1.5) to avoid HTTP 429 "engine overloaded" errors. - Extracts shared AnthropicCompatibleProvider base class to eliminate ~60 lines of duplication with MinimaxProvider. - Adds describeImage support that MinimaxProvider was missing. Env vars: KIMI_API_KEY (required) KIMI_BASE_URL (optional, default: https://api.kimi.com/coding) KIMI_MODEL (optional, default: kimi-k2) Tests: - test/kimi-for-coding-provider.test.ts — base URL resolution - test/kimi-for-coding-e2e.test.ts — request shape + error handling Signed-off-by: yangyunchao --- src/providers/anthropic-compatible.ts | 118 ++++++++++++++++++++++++++ src/providers/index.ts | 12 ++- src/providers/kimi-for-coding.ts | 25 ++++++ src/providers/minimax.ts | 65 +++----------- src/types.ts | 2 +- test/kimi-for-coding-e2e.test.ts | 87 +++++++++++++++++++ test/kimi-for-coding-provider.test.ts | 30 +++++++ 7 files changed, 285 insertions(+), 54 deletions(-) create mode 100644 src/providers/anthropic-compatible.ts create mode 100644 src/providers/kimi-for-coding.ts create mode 100644 test/kimi-for-coding-e2e.test.ts create mode 100644 test/kimi-for-coding-provider.test.ts diff --git a/src/providers/anthropic-compatible.ts b/src/providers/anthropic-compatible.ts new file mode 100644 index 00000000..444f7f87 --- /dev/null +++ b/src/providers/anthropic-compatible.ts @@ -0,0 +1,118 @@ +import type { MemoryProvider } from "../types.js"; + +interface AnthropicMessageResponse { + content?: Array<{ type: string; text?: string }>; +} + +export class AnthropicCompatibleProvider implements MemoryProvider { + name: string; + private apiKey: string; + private model: string; + private maxTokens: number; + private baseUrl: string; + private extraHeaders: Record; + private errorPrefix: string; + + constructor( + name: string, + apiKey: string, + model: string, + maxTokens: number, + baseUrl: string, + extraHeaders: Record = {}, + errorPrefix: string = "API", + ) { + this.name = name; + this.apiKey = apiKey; + this.model = model; + this.maxTokens = maxTokens; + this.baseUrl = baseUrl; + this.extraHeaders = extraHeaders; + this.errorPrefix = errorPrefix; + } + + async compress(systemPrompt: string, userPrompt: string): Promise { + return this.call(systemPrompt, userPrompt); + } + + async summarize(systemPrompt: string, userPrompt: string): Promise { + return this.call(systemPrompt, userPrompt); + } + + async describeImage( + imageData: string, + mimeType: string, + prompt: string, + ): Promise { + const url = `${this.baseUrl}/v1/messages`; + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": this.apiKey, + "anthropic-version": "2023-06-01", + ...this.extraHeaders, + }, + body: JSON.stringify({ + model: this.model, + max_tokens: this.maxTokens, + messages: [ + { + role: "user", + content: [ + { + type: "image", + source: { + type: "base64", + media_type: mimeType, + data: imageData, + }, + }, + { type: "text", text: prompt }, + ], + }, + ], + }), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`${this.errorPrefix} error ${response.status}: ${text}`); + } + + const data = (await response.json()) as AnthropicMessageResponse; + const textBlock = data.content?.find((b) => b.type === "text"); + return textBlock?.text ?? ""; + } + + private async call( + systemPrompt: string, + userPrompt: string, + ): Promise { + const url = `${this.baseUrl}/v1/messages`; + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": this.apiKey, + "anthropic-version": "2023-06-01", + ...this.extraHeaders, + }, + body: JSON.stringify({ + model: this.model, + max_tokens: this.maxTokens, + system: systemPrompt, + messages: [{ role: "user", content: userPrompt }], + }), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`${this.errorPrefix} error ${response.status}: ${text}`); + } + + const data = (await response.json()) as AnthropicMessageResponse; + const textBlock = data.content?.find((b) => b.type === "text"); + return textBlock?.text ?? ""; + } +} diff --git a/src/providers/index.ts b/src/providers/index.ts index b22907bc..7515acff 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -5,6 +5,7 @@ import type { } from "../types.js"; import { AgentSDKProvider } from "./agent-sdk.js"; import { AnthropicProvider } from "./anthropic.js"; +import { KimiForCodingProvider } from "./kimi-for-coding.js"; import { MinimaxProvider } from "./minimax.js"; import { NoopProvider } from "./noop.js"; import { OpenRouterProvider } from "./openrouter.js"; @@ -12,7 +13,10 @@ import { ResilientProvider } from "./resilient.js"; import { FallbackChainProvider } from "./fallback-chain.js"; import { getEnvVar } from "../config.js"; -export { createEmbeddingProvider, createImageEmbeddingProvider } from "./embedding/index.js"; +export { + createEmbeddingProvider, + createImageEmbeddingProvider, +} from "./embedding/index.js"; function requireEnvVar(key: string): string { const value = getEnvVar(key); @@ -94,6 +98,12 @@ function createBaseProvider(config: ProviderConfig): MemoryProvider { config.maxTokens, "https://openrouter.ai/api/v1/chat/completions", ); + case "kimi-for-coding": + return new KimiForCodingProvider( + requireEnvVar("KIMI_API_KEY"), + config.model, + config.maxTokens, + ); case "noop": return new NoopProvider(); case "agent-sdk": diff --git a/src/providers/kimi-for-coding.ts b/src/providers/kimi-for-coding.ts new file mode 100644 index 00000000..208affbf --- /dev/null +++ b/src/providers/kimi-for-coding.ts @@ -0,0 +1,25 @@ +import { AnthropicCompatibleProvider } from "./anthropic-compatible.js"; +import { getEnvVar } from "../config.js"; + +/** + * Kimi for Coding provider. + * + * Kimi's Coding Plan endpoint rejects the default Anthropic SDK User-Agent + * with HTTP 429 "engine overloaded". We pass a whitelisted User-Agent header. + * + * Required env var: KIMI_API_KEY + * Optional env vars: KIMI_BASE_URL (default: https://api.kimi.com/coding) + */ +export class KimiForCodingProvider extends AnthropicCompatibleProvider { + constructor(apiKey: string, model: string, maxTokens: number) { + super( + "kimi-for-coding", + apiKey, + model, + maxTokens, + getEnvVar("KIMI_BASE_URL") || "https://api.kimi.com/coding", + { "User-Agent": "KimiCLI/1.5" }, + "Kimi for Coding", + ); + } +} diff --git a/src/providers/minimax.ts b/src/providers/minimax.ts index e912fabc..4bb3b6dd 100644 --- a/src/providers/minimax.ts +++ b/src/providers/minimax.ts @@ -1,8 +1,8 @@ -import type { MemoryProvider } from '../types.js' -import { getEnvVar } from '../config.js' +import { AnthropicCompatibleProvider } from "./anthropic-compatible.js"; +import { getEnvVar } from "../config.js"; /** - * MiniMax provider using raw fetch to call MiniMax's Anthropic-compatible API. + * MiniMax provider using Anthropic-compatible API. * * The Anthropic SDK automatically injects `x-stainless-*` headers that MiniMax * rejects with 403. This provider bypasses the SDK and calls the API directly. @@ -15,55 +15,16 @@ import { getEnvVar } from '../config.js' * Optional: * MINIMAX_BASE_URL — base URL without path (default: https://api.minimax.io/anthropic) */ -export class MinimaxProvider implements MemoryProvider { - name = 'minimax' - private apiKey: string - private model: string - private maxTokens: number - private baseUrl: string - +export class MinimaxProvider extends AnthropicCompatibleProvider { constructor(apiKey: string, model: string, maxTokens: number) { - this.apiKey = apiKey - this.model = model - this.maxTokens = maxTokens - this.baseUrl = - getEnvVar('MINIMAX_BASE_URL') || 'https://api.minimax.io/anthropic' - } - - async compress(systemPrompt: string, userPrompt: string): Promise { - return this.call(systemPrompt, userPrompt) - } - - async summarize(systemPrompt: string, userPrompt: string): Promise { - return this.call(systemPrompt, userPrompt) - } - - private async call(systemPrompt: string, userPrompt: string): Promise { - const url = `${this.baseUrl}/v1/messages` - const response = await fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': this.apiKey, - 'anthropic-version': '2023-06-01', - }, - body: JSON.stringify({ - model: this.model, - max_tokens: this.maxTokens, - system: systemPrompt, - messages: [{ role: 'user', content: userPrompt }], - }), - }) - - if (!response.ok) { - const text = await response.text() - throw new Error(`MiniMax API error ${response.status}: ${text}`) - } - - const data = (await response.json()) as { - content?: Array<{ type: string; text?: string }> - } - const textBlock = data.content?.find((b) => b.type === 'text') - return textBlock?.text ?? '' + super( + "minimax", + apiKey, + model, + maxTokens, + getEnvVar("MINIMAX_BASE_URL") || "https://api.minimax.io/anthropic", + {}, + "MiniMax", + ); } } diff --git a/src/types.ts b/src/types.ts index 86a2c971..1275d7e5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -129,7 +129,7 @@ export interface ProviderConfig { baseURL?: string; } -export type ProviderType = "agent-sdk" | "anthropic" | "gemini" | "openrouter" | "minimax" | "noop"; +export type ProviderType = "agent-sdk" | "anthropic" | "gemini" | "openrouter" | "minimax" | "kimi-for-coding" | "noop"; export interface MemoryProvider { name: string; diff --git a/test/kimi-for-coding-e2e.test.ts b/test/kimi-for-coding-e2e.test.ts new file mode 100644 index 00000000..bcf58e93 --- /dev/null +++ b/test/kimi-for-coding-e2e.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; +import { KimiForCodingProvider } from "../src/providers/kimi-for-coding.js"; + +describe("KimiForCodingProvider — request shape", () => { + it("sends correct headers and body to Anthropic-compatible endpoint", async () => { + let capturedRequest: { + url: string; + headers: Record; + body: unknown; + } | null = null; + + const originalFetch = globalThis.fetch; + globalThis.fetch = async ( + url: string | URL | Request, + init?: RequestInit, + ) => { + const requestUrl = url.toString(); + const headers: Record = {}; + if (init?.headers) { + const h = init.headers; + if (h instanceof Headers) { + h.forEach((v, k) => { + headers[k] = v; + }); + } else if (Array.isArray(h)) { + for (const [k, v] of h) headers[k] = v; + } else { + Object.assign(headers, h); + } + } + capturedRequest = { + url: requestUrl, + headers, + body: init?.body ? JSON.parse(init.body as string) : null, + }; + return new Response( + JSON.stringify({ + content: [{ type: "text", text: "mocked response" }], + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + }; + + try { + const provider = new KimiForCodingProvider("sk-test-key", "kimi-k2", 512); + const result = await provider.compress("Be concise", "Summarize this"); + + expect(result).toBe("mocked response"); + expect(capturedRequest).not.toBeNull(); + expect(capturedRequest!.url).toBe( + "https://api.kimi.com/coding/v1/messages", + ); + expect(capturedRequest!.headers["x-api-key"]).toBe("sk-test-key"); + expect(capturedRequest!.headers["anthropic-version"]).toBe("2023-06-01"); + expect(capturedRequest!.headers["User-Agent"]).toBe("KimiCLI/1.5"); + expect(capturedRequest!.headers["Content-Type"]).toBe("application/json"); + + const body = capturedRequest!.body as Record; + expect(body.model).toBe("kimi-k2"); + expect(body.max_tokens).toBe(512); + expect(body.system).toBe("Be concise"); + expect(body.messages).toEqual([ + { role: "user", content: "Summarize this" }, + ]); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it("throws on API error with status and body", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => + new Response("engine overloaded", { + status: 429, + statusText: "Too Many Requests", + }); + + try { + const provider = new KimiForCodingProvider("sk-test", "kimi-k2", 100); + await expect(provider.summarize("sys", "user")).rejects.toThrow( + "Kimi for Coding error 429: engine overloaded", + ); + } finally { + globalThis.fetch = originalFetch; + } + }); +}); diff --git a/test/kimi-for-coding-provider.test.ts b/test/kimi-for-coding-provider.test.ts new file mode 100644 index 00000000..4ab50029 --- /dev/null +++ b/test/kimi-for-coding-provider.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it, afterEach } from "vitest"; +import { KimiForCodingProvider } from "../src/providers/kimi-for-coding.js"; + +describe("KimiForCodingProvider — base URL and header resolution", () => { + const originalEnv = process.env["KIMI_BASE_URL"]; + + afterEach(() => { + if (originalEnv === undefined) { + delete process.env["KIMI_BASE_URL"]; + } else { + process.env["KIMI_BASE_URL"] = originalEnv; + } + }); + + it("defaults to https://api.kimi.com/coding", () => { + delete process.env["KIMI_BASE_URL"]; + const provider = new KimiForCodingProvider("test-key", "kimi-k2", 4096); + expect((provider as unknown as { baseUrl: string }).baseUrl).toBe( + "https://api.kimi.com/coding", + ); + }); + + it("honors KIMI_BASE_URL via getEnvVar", () => { + process.env["KIMI_BASE_URL"] = "https://custom.kimi.example.com/coding"; + const provider = new KimiForCodingProvider("test-key", "kimi-k2", 4096); + expect((provider as unknown as { baseUrl: string }).baseUrl).toBe( + "https://custom.kimi.example.com/coding", + ); + }); +});