Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 118 additions & 0 deletions src/providers/anthropic-compatible.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
private errorPrefix: string;

constructor(
name: string,
apiKey: string,
model: string,
maxTokens: number,
baseUrl: string,
extraHeaders: Record<string, string> = {},
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<string> {
return this.call(systemPrompt, userPrompt);
}

async summarize(systemPrompt: string, userPrompt: string): Promise<string> {
return this.call(systemPrompt, userPrompt);
}

async describeImage(
imageData: string,
mimeType: string,
prompt: string,
): Promise<string> {
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<string> {
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 ?? "";
}
}
12 changes: 11 additions & 1 deletion src/providers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,18 @@ 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";
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);
Expand Down Expand Up @@ -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":
Expand Down
25 changes: 25 additions & 0 deletions src/providers/kimi-for-coding.ts
Original file line number Diff line number Diff line change
@@ -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",
);
}
}
65 changes: 13 additions & 52 deletions src/providers/minimax.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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<string> {
return this.call(systemPrompt, userPrompt)
}

async summarize(systemPrompt: string, userPrompt: string): Promise<string> {
return this.call(systemPrompt, userPrompt)
}

private async call(systemPrompt: string, userPrompt: string): Promise<string> {
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",
);
}
}
2 changes: 1 addition & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
87 changes: 87 additions & 0 deletions test/kimi-for-coding-e2e.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
body: unknown;
} | null = null;

const originalFetch = globalThis.fetch;
globalThis.fetch = async (
url: string | URL | Request,
init?: RequestInit,
) => {
const requestUrl = url.toString();
const headers: Record<string, string> = {};
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<string, unknown>;
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;
}
});
});
30 changes: 30 additions & 0 deletions test/kimi-for-coding-provider.test.ts
Original file line number Diff line number Diff line change
@@ -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",
);
});
});