/** * KimiWebExecutor — Moonshot AI Chat via www.kimi.com (international) * * Routes requests through Kimi's consumer chat API on the international domain. * Originally this executor targeted `kimi.moonshot.cn` (mainland-CN consumer * chat). That domain now redirects every visitor outside CN to * `https://www.kimi.com/`, which speaks a completely different API surface: * * - Endpoint: POST /apiv2/kimi.gateway.chat.v1.ChatService/Chat * - Protocol: Connect-RPC (unary envelope framing — 5-byte header + JSON) * - Auth: `Authorization: Bearer ` + `Cookie: kimi-auth=` * - Body: Connect-framed `{scenario, message:{role,blocks:[{text:{content}}]}, * options:{thinking,enable_plugin}}` * - Response: Connect-framed stream of events carrying deltas with one of * `mask: "block.text.content"` (answer) or * `mask: "block.think.content"` (reasoning), emitted via * `op: "set"` (initial) and `op: "append"` (incremental). * * Cookie handling: the user pastes their full Cookie header from www.kimi.com. * We extract the `kimi-auth` JWT from it (it is the only cookie the upstream * actually consults) and use it both as the Bearer token and as the Cookie we * send back, so we don't leak the user's analytics cookies (Ga, CF, HM, ...). * * The `x-msh-*` / `x-traffic-id` / `x-msh-shield-data` headers the SPA sends * are NOT required — verified by stripping them one at a time against a live * session; the upstream returns the same response either way. */ import { BaseExecutor, type ExecuteInput } from "./base.ts"; import { makeExecutorErrorResult as makeErrorResult, sanitizeErrorMessage } from "../utils/error.ts"; import { extractKimiJwt } from "@/lib/providers/webCookieAuth"; export { extractKimiJwt }; const BASE_URL = "https://www.kimi.com"; const CHAT_URL = `${BASE_URL}/apiv2/kimi.gateway.chat.v1.ChatService/Chat`; const USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36"; /** * Map a Kimi model id (the `key` field from `GetAvailableModels`) to the * request shape the upstream expects. Today only the chat-tier `k2d6` family * is supported — the agent variants (`k2d6-agent`, `k2d6-agent-ultra`) need * a different scenario (`SCENARIO_OK_COMPUTER`) plus `kimiPlusId` / * `agentMode` fields that this executor does not shape; users who need * agentic Kimi should use the `kimi-coding` (api.kimi.com) provider. */ export interface KimiModelConfig { scenario: string; thinking: boolean; } export function resolveModelConfig(modelId: string): KimiModelConfig { if (modelId === "k2d6-thinking") return { scenario: "SCENARIO_K2D5", thinking: true }; // `k2d6` (Instant) and any unknown id fall back to the default chat scenario. return { scenario: "SCENARIO_K2D5", thinking: false }; } /** Wrap a JSON message in the 5-byte Connect streaming envelope (flags + length). */ export function frameConnectMessage(json: string): Uint8Array { const payload = new TextEncoder().encode(json); const framed = new Uint8Array(5 + payload.length); framed[0] = 0; // flags: 0 = uncompressed const len = payload.length; framed[1] = (len >>> 24) & 0xff; framed[2] = (len >>> 16) & 0xff; framed[3] = (len >>> 8) & 0xff; framed[4] = len & 0xff; framed.set(payload, 5); return framed; } interface ConnectFrame { flags: number; message: Record | null; } /** * ponytail: cap a single Connect frame at 8 MiB. Kimi's largest legitimate * event is well under 1 KiB (a delta or stage transition); anything bigger * means the upstream is misbehaving or an attacker controls the response and * is trying to OOM the proxy by sending a header claiming a huge length. * The non-streaming accumulator would otherwise grow unbounded. If you ever * see this tripping in production, raise the ceiling and add a regression * test — but never remove it. */ const MAX_FRAME_LEN = 8 * 1024 * 1024; /** * Decode one Connect frame from a stream buffer. * Returns: * - `consumed: 0` if there isn't enough data yet (need more bytes) * - `consumed: -1` if the frame header claims a length above MAX_FRAME_LEN * (caller must treat this as a stream-fatal protocol error) * - `consumed: N` + the parsed frame otherwise */ export function decodeConnectFrame(buf: Uint8Array, byteOffset: number): { consumed: number; frame: ConnectFrame | null } { if (byteOffset + 5 > buf.length) return { consumed: 0, frame: null }; const flags = buf[byteOffset]; const len = (buf[byteOffset + 1] << 24) | (buf[byteOffset + 2] << 16) | (buf[byteOffset + 3] << 8) | buf[byteOffset + 4]; // Sign-extend the high bit back to negative when len was read as signed. const msgLen = len < 0 ? len + 0x100000000 : len; if (msgLen > MAX_FRAME_LEN) return { consumed: -1, frame: null }; if (byteOffset + 5 + msgLen > buf.length) return { consumed: 0, frame: null }; const payload = buf.subarray(byteOffset + 5, byteOffset + 5 + msgLen); let message: Record | null = null; if (msgLen > 0) { try { message = JSON.parse(new TextDecoder().decode(payload)); } catch { message = null; } } return { consumed: 5 + msgLen, frame: { flags, message } }; } type DeltaKind = "text" | "think" | null; /** * Extract a content delta + kind from a Connect frame message. * * The chat stream uses two ops against two masks: * - `op: "set"` on `block.text` / `block.think` → first chunk * - `op: "append"` on `block.text.content` / `block.think.content` → subsequent chunks * * Anything else (heartbeats, chat/message metadata, stage transitions) is * suppressed; we only surface text to the client. */ export function extractDelta(msg: Record | null): { kind: DeltaKind; text: string } | null { if (!msg) return null; const op = String(msg.op ?? ""); const mask = String(msg.mask ?? ""); const block = (msg.block ?? {}) as Record; // `op: append` carries a delta string under `block..content`. if (op === "append") { if (mask === "block.text.content") { const text = String(((block.text ?? {}) as Record).content ?? ""); return text ? { kind: "text", text } : null; } if (mask === "block.think.content") { const text = String(((block.think ?? {}) as Record).content ?? ""); return text ? { kind: "think", text } : null; } return null; } // `op: set` on `block.text` / `block.think` carries the initial content. if (op === "set") { if (mask === "block.text") { const text = String(((block.text ?? {}) as Record).content ?? ""); return text ? { kind: "text", text } : null; } if (mask === "block.think") { const text = String(((block.think ?? {}) as Record).content ?? ""); return text ? { kind: "think", text } : null; } } return null; } export function isEndOfStream(msg: Record | null): boolean { if (!msg) return false; // Assistant message flipped to COMPLETED. const message = (msg.message ?? null) as Record | null; if (message && String(message.status ?? "") === "MESSAGE_STATUS_COMPLETED" && String(message.role ?? "") === "assistant") { return true; } return false; } /** * Fold a multi-turn OpenAI `messages` array into a single Kimi user turn. * * Limitations (kimi-web is a single-turn consumer chat, not an agentic API): * - `tool` and `function` role messages are silently dropped — Kimi's web * chat has no concept of tool results, so agentic flows should use the * `kimi-coding` (api.kimi.com) provider instead. * - Assistant `tool_calls` and image content parts are stringified into * text, which loses structure. Acceptable for free-text continuation, * unacceptable for tool-round-trip — same workaround: use kimi-coding. */ export function foldMessages(messages: Array<{ role: string; content: unknown }>): string { let system = ""; let user = ""; for (const m of messages) { const text = typeof m.content === "string" ? m.content : JSON.stringify(m.content ?? ""); if (m.role === "system") { system += (system ? "\n\n" : "") + text; } else if (m.role === "user") { // Kimi's web chat is single-turn; keep only the latest user content but // preserve prior assistant text for continuity when present. user = user ? `${user}\n\n${text}` : text; } else if (m.role === "assistant") { user = user ? `${user}\n\nAssistant: ${text}` : `Assistant: ${text}`; } } return system ? `${system}\n\n${user}` : user; } export class KimiWebExecutor extends BaseExecutor { constructor() { super("kimi-web", { id: "kimi-web", baseUrl: BASE_URL }); } private buildKimiHeaders(jwt: string): Record { const headers: Record = { "Content-Type": "application/connect+json", Accept: "*/*", "User-Agent": USER_AGENT, Origin: BASE_URL, Referer: `${BASE_URL}/`, "connect-protocol-version": "1", }; if (jwt) { headers["Authorization"] = `Bearer ${jwt}`; headers["Cookie"] = `kimi-auth=${jwt}`; } return headers; } private buildRequestBody(prompt: string, wantThinking: boolean, scenario: string): string { return JSON.stringify({ scenario, tools: [{ type: "TOOL_TYPE_SEARCH", search: {} }, { type: "TOOL_TYPE_CRON_JOB" }], message: { role: "user", blocks: [{ message_id: "", text: { content: prompt } }], scenario, }, options: { thinking: wantThinking, enable_plugin: true }, }); } async execute(input: ExecuteInput) { const { body, credentials, signal, stream: wantStream } = input; const bodyObj = (body || {}) as Record; const rawCredential = String(credentials?.apiKey ?? "").trim(); const jwt = extractKimiJwt(rawCredential); if (!jwt) { return makeErrorResult( 400, "Missing Kimi session — paste the full Cookie header from www.kimi.com (must contain kimi-auth=) or just the JWT itself.", body, CHAT_URL ); } const messages = (bodyObj.messages as Array<{ role: string; content: unknown }>) || []; const modelId = (bodyObj.model as string) || "kimi-default"; // Resolve scenario + default thinking flag from the model id (catalog truth), // then honour an explicit `reasoning_effort: "none"` override from the caller. const modelConfig = resolveModelConfig(modelId); const wantThinking = bodyObj.reasoning_effort === "none" ? false : modelConfig.thinking; const prompt = foldMessages(messages); const reqBody = this.buildRequestBody(prompt, wantThinking, modelConfig.scenario); const reqHeaders = this.buildKimiHeaders(jwt); // Connect framing wraps the JSON body in a 5-byte envelope. Without it the // upstream returns `invalid_argument` for every request. const framedBody = frameConnectMessage(reqBody); let upstream: Response; try { upstream = await fetch(CHAT_URL, { method: "POST", headers: reqHeaders, body: new Uint8Array(framedBody), signal, }); } catch (err) { return makeErrorResult( 502, `Kimi fetch failed: ${err instanceof Error ? err.message : "unknown"}`, body, CHAT_URL ); } if (!upstream.ok) { const errText = await upstream.text().catch(() => ""); return makeErrorResult(upstream.status, `Kimi error: ${sanitizeErrorMessage(errText)}`, body, CHAT_URL); } const encoder = new TextEncoder(); const id = `chatcmpl-kimi-${Date.now()}`; const created = Math.floor(Date.now() / 1000); const emitChunk = ( controller: ReadableStreamDefaultController, delta: Record, finish: string | null = null ) => { const chunk = { id, object: "chat.completion.chunk", created, model: modelId, choices: [{ index: 0, delta, finish_reason: finish }], }; controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`)); }; // The upstream is a Connect-framed stream regardless of whether the // client asked for SSE — Kimi always streams. For non-streaming clients // we buffer the full response below. const sourceStream = upstream.body ?? new ReadableStream({ start: (c) => c.close() }); if (wantStream) { const outStream = new ReadableStream({ async start(controller) { const reader = sourceStream.getReader(); let buffer = new Uint8Array(0); let emittedRole = false; try { while (true) { const { done, value } = await reader.read(); if (done) break; if (value) { const merged = new Uint8Array(buffer.length + value.length); merged.set(buffer, 0); merged.set(value, buffer.length); buffer = merged; let offset = 0; while (offset < buffer.length) { const { consumed, frame } = decodeConnectFrame(buffer, offset); if (consumed === -1) { // Frame header claims a length above MAX_FRAME_LEN — stream-fatal. controller.error(new Error("Kimi Connect frame exceeded MAX_FRAME_LEN")); return; } if (consumed === 0) break; // need more bytes offset += consumed; if (!frame?.message) continue; const delta = extractDelta(frame.message); if (delta) { if (!emittedRole) { emittedRole = true; emitChunk(controller, { role: "assistant", content: "" }); } if (delta.kind === "think") { emitChunk(controller, { reasoning_content: delta.text }); } else { emitChunk(controller, { content: delta.text }); } } if (isEndOfStream(frame.message)) { emitChunk(controller, {}, "stop"); controller.enqueue(encoder.encode("data: [DONE]\n\n")); controller.close(); return; } } // Compact the buffer. buffer = buffer.subarray(offset); } } // Stream ended without an explicit COMPLETED marker — flush a stop. if (!emittedRole) { emitChunk(controller, { role: "assistant", content: "" }); } emitChunk(controller, {}, "stop"); controller.enqueue(encoder.encode("data: [DONE]\n\n")); controller.close(); } catch (err) { if (!signal?.aborted) { try { controller.error(err); } catch { /* controller already closed */ } } } }, }); return { response: new Response(outStream, { headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive", }, }), url: CHAT_URL, headers: reqHeaders, transformedBody: JSON.parse(reqBody), }; } // Non-streaming: collect all deltas into a single chat.completion JSON. let answer = ""; let reasoning = ""; const reader = sourceStream.getReader(); let buffer = new Uint8Array(0); try { while (true) { const { done, value } = await reader.read(); if (done) break; if (!value) continue; const merged = new Uint8Array(buffer.length + value.length); merged.set(buffer, 0); merged.set(value, buffer.length); buffer = merged; let offset = 0; while (offset < buffer.length) { const { consumed, frame } = decodeConnectFrame(buffer, offset); if (consumed === -1) break; // oversized frame — abort, return what we have if (consumed === 0) break; offset += consumed; if (!frame?.message) continue; const delta = extractDelta(frame.message); if (delta) { if (delta.kind === "think") reasoning += delta.text; else answer += delta.text; } if (isEndOfStream(frame.message)) { offset = buffer.length; // drain break; } } buffer = buffer.subarray(offset); } } catch { /* best-effort — return what we have */ } const message: Record = { role: "assistant", content: answer }; if (reasoning) message.reasoning_content = reasoning; const completion = { id, object: "chat.completion", created, model: modelId, choices: [{ index: 0, message, finish_reason: "stop" }], }; return { response: new Response(JSON.stringify(completion), { headers: { "Content-Type": "application/json" }, }), url: CHAT_URL, headers: reqHeaders, transformedBody: JSON.parse(reqBody), }; } }