Skip to content
Open
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
16 changes: 16 additions & 0 deletions src/components/settings/llm-presets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,22 @@ export const LLM_PRESETS: LlmPreset[] = [
],
suggestedContextSize: 256000,
},
{
id: "kimi-coding-plan",
label: "Kimi (Coding Plan)",
hint: "api.kimi.com",
provider: "custom",
baseUrl: "https://api.kimi.com/coding/",
defaultModel: "kimi-for-coding",
apiMode: "chat_completions",
// Kimi Coding Plan is a separate subscription service from the
// Moonshot open platform. It supports both OpenAI-compatible
// (chat_completions) and Anthropic-compatible (anthropic_messages)
// wires on the same base URL. The Anthropic wire requires Bearer
// auth (see requiresBearerAuth in llm-providers.ts).
suggestedModels: ["kimi-for-coding"],
suggestedContextSize: 256000,
},
{
id: "zhipu",
label: "智谱 GLM (Zhipu)",
Expand Down
38 changes: 37 additions & 1 deletion src/lib/__tests__/llm-providers.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest"
import { buildAnthropicUrl, parseGoogleLine, getProviderConfig } from "../llm-providers"
import { buildAnthropicUrl, parseGoogleLine, parseAnthropicLine, getProviderConfig } from "../llm-providers"
import type { LlmConfig as RealLlmConfig } from "@/stores/wiki-store"

// Inline minimal types to avoid store/zustand dependencies in unit tests
Expand Down Expand Up @@ -194,6 +194,42 @@ describe("parseGoogleLine — Gemini SSE parsing", () => {
})
})

describe("parseAnthropicLine — Anthropic SSE parsing", () => {
it("extracts text from a standard text_delta event (with space)", () => {
const line = 'data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}'
expect(parseAnthropicLine(line)).toBe("Hello")
})

it("extracts text when delta.type is omitted (no-space SSE)", () => {
// Some third-party Anthropic-compatible gateways (e.g. Kimi Coding Plan)
// emit content_block_delta with a bare `text` field and no `type` inside
// delta, and sometimes omit the space after `data:`.
const line = 'data:{"type":"content_block_delta","index":0,"delta":{"text":"world"}}'
expect(parseAnthropicLine(line)).toBe("world")
})

it("extracts text from a complete message event (single-shot SSE)", () => {
const line =
'data: {"type":"message","id":"msg_01","role":"assistant","content":[{"type":"text","text":"Hello world"}]}'
expect(parseAnthropicLine(line)).toBe("Hello world")
})

it("falls back to OpenAI-shaped delta.content when present", () => {
const line = 'data: {"choices":[{"delta":{"content":"fallback"}}]}'
expect(parseAnthropicLine(line)).toBe("fallback")
})

it("returns null for non-content-block-delta events without extractable text", () => {
const line = 'data: {"type":"message_start","message":{"id":"msg_01"}}'
expect(parseAnthropicLine(line)).toBeNull()
})

it("returns null for non-data lines", () => {
expect(parseAnthropicLine("event: start")).toBeNull()
expect(parseAnthropicLine("")).toBeNull()
})
})

describe("Claude Code CLI provider — not reachable via getProviderConfig", () => {
it("throws, because the subprocess transport dispatches one layer up in streamChat", () => {
// If this ever stops throwing, someone wired claude-code into the
Expand Down
33 changes: 33 additions & 0 deletions src/lib/llm-providers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,39 @@ describe("reasoning controls", () => {
expect(provider.url).toBe("https://token-plan-cn.xiaomimimo.com/anthropic/v1/messages")
expect(provider.headers.Authorization).toBe("Bearer sk-mimo")
expect(provider.headers["x-api-key"]).toBeUndefined()
expect(provider.headers["anthropic-version"]).toBe("2023-06-01")
})

it("uses Bearer auth for Kimi Coding Plan Anthropic wire", () => {
const cfg = mkConfig({
provider: "custom",
apiKey: "sk-kimi-test",
model: "kimi-for-coding",
customEndpoint: "https://api.kimi.com/coding/",
apiMode: "anthropic_messages",
})
const provider = getProviderConfig(cfg)

expect(provider.url).toBe("https://api.kimi.com/coding/v1/messages")
expect(provider.headers.Authorization).toBe("Bearer sk-kimi-test")
expect(provider.headers["x-api-key"]).toBeUndefined()
expect(provider.headers["anthropic-version"]).toBe("2023-06-01")
})

it("uses Bearer auth for Moonshot Anthropic wire", () => {
const cfg = mkConfig({
provider: "custom",
apiKey: "sk-moonshot",
model: "kimi-k2.6",
customEndpoint: "https://api.moonshot.ai/anthropic",
apiMode: "anthropic_messages",
})
const provider = getProviderConfig(cfg)

expect(provider.url).toBe("https://api.moonshot.ai/anthropic/v1/messages")
expect(provider.headers.Authorization).toBe("Bearer sk-moonshot")
expect(provider.headers["x-api-key"]).toBeUndefined()
expect(provider.headers["anthropic-version"]).toBe("2023-06-01")
})

it("disables Qwen3 thinking on OpenAI-compatible local endpoints", () => {
Expand Down
61 changes: 46 additions & 15 deletions src/lib/llm-providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,8 +119,8 @@ function localLlmOriginHeader(): Record<string, string> {
}

function parseOpenAiLine(line: string): string | null {
if (!line.startsWith("data: ")) return null
const data = line.slice(6).trim()
if (!line.startsWith("data:")) return null
const data = line.slice(5).trim()
if (data === "[DONE]") return null
try {
const parsed = JSON.parse(data) as {
Expand All @@ -132,29 +132,51 @@ function parseOpenAiLine(line: string): string | null {
}
}

function parseAnthropicLine(line: string): string | null {
if (!line.startsWith("data: ")) return null
const data = line.slice(6).trim()
export function parseAnthropicLine(line: string): string | null {
if (!line.startsWith("data:")) return null
const data = line.slice(5).trim()
if (data === "[DONE]") return null
try {
const parsed = JSON.parse(data) as {
type: string
delta?: { type: string; text?: string }
}
const parsed = JSON.parse(data) as Record<string, unknown>

// Standard Anthropic streaming: content_block_delta with text_delta
const delta = parsed.delta as Record<string, unknown> | undefined
if (
parsed.type === "content_block_delta" &&
parsed.delta?.type === "text_delta"
(delta?.type === "text_delta" || typeof delta?.text === "string")
) {
return parsed.delta.text ?? null
return (delta.text as string) ?? null
}

// Some third-party Anthropic-compatible gateways (e.g. Kimi/Moonshot)
// emit the complete assistant message as a single SSE event instead
// of incremental content_block_delta chunks.
if (
parsed.type === "message" &&
Array.isArray(parsed.content) &&
parsed.content.length > 0 &&
typeof (parsed.content as Array<Record<string, unknown>>)[0].text === "string"
) {
return (parsed.content as Array<Record<string, unknown>>)[0].text as string
}

// Fallback: misconfigured proxies occasionally return OpenAI-shaped
// chunks on an Anthropic wire. Extract delta.content when present.
const choices = parsed.choices as Array<Record<string, unknown>> | undefined
if (choices && choices[0]) {
const choiceDelta = choices[0].delta as Record<string, unknown> | undefined
if (typeof choiceDelta?.content === "string") return choiceDelta.content
}

return null
} catch {
return null
}
}

export function parseGoogleLine(line: string): string | null {
if (!line.startsWith("data: ")) return null
const data = line.slice(6).trim()
if (!line.startsWith("data:")) return null
const data = line.slice(5).trim()
try {
const parsed = JSON.parse(data) as {
candidates: Array<{
Expand Down Expand Up @@ -489,7 +511,16 @@ function requiresBearerAuth(url: string): boolean {
normalized.startsWith("https://coding.dashscope.aliyuncs.com/apps/anthropic") ||
// Xiaomi MiMo Token Plan Anthropic gateway authenticates with
// Authorization Bearer, matching its OpenAI-compatible gateway.
/(^https:\/\/|^)token-plan-cn\.xiaomimimo\.com\/anthropic(?:\/|$)/i.test(normalized)
/(^https:\/\/|^)token-plan-cn\.xiaomimimo\.com\/anthropic(?:\/|$)/i.test(normalized) ||
// Kimi Coding Plan — uses Authorization: Bearer, not x-api-key.
// The coding endpoint (api.kimi.com/coding) is separate from the
// Moonshot open platform (api.moonshot.ai) and expects Bearer auth
// on both its OpenAI- and Anthropic-compatible wires.
normalized.startsWith("https://api.kimi.com/coding") ||
// Moonshot open platform Anthropic-compatible wires (global + CN)
// also authenticate with Bearer tokens, matching their OpenAI wire.
normalized.startsWith("https://api.moonshot.ai/anthropic") ||
normalized.startsWith("https://api.moonshot.cn/anthropic")
)
}

Expand All @@ -516,12 +547,12 @@ export function buildAnthropicUrl(base: string): string {
function buildAnthropicHeaders(apiKey: string, url: string): Record<string, string> {
const base: Record<string, string> = {
"Content-Type": JSON_CONTENT_TYPE,
"anthropic-version": "2023-06-01",
}
if (requiresBearerAuth(url)) {
base.Authorization = `Bearer ${apiKey}`
} else {
base["x-api-key"] = apiKey
base["anthropic-version"] = "2023-06-01"
base["anthropic-dangerous-direct-browser-access"] = "true"
}
return base
Expand Down