Files
2026-07-13 13:39:12 +08:00

456 lines
17 KiB
TypeScript

/**
* 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 <JWT>` + `Cookie: kimi-auth=<JWT>`
* - 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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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<string, unknown>;
// `op: append` carries a delta string under `block.<text|think>.content`.
if (op === "append") {
if (mask === "block.text.content") {
const text = String(((block.text ?? {}) as Record<string, unknown>).content ?? "");
return text ? { kind: "text", text } : null;
}
if (mask === "block.think.content") {
const text = String(((block.think ?? {}) as Record<string, unknown>).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<string, unknown>).content ?? "");
return text ? { kind: "text", text } : null;
}
if (mask === "block.think") {
const text = String(((block.think ?? {}) as Record<string, unknown>).content ?? "");
return text ? { kind: "think", text } : null;
}
}
return null;
}
export function isEndOfStream(msg: Record<string, unknown> | null): boolean {
if (!msg) return false;
// Assistant message flipped to COMPLETED.
const message = (msg.message ?? null) as Record<string, unknown> | 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<string, string> {
const headers: Record<string, string> = {
"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<string, unknown>;
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=<JWT>) 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<string, unknown>,
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<string, unknown> = { 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),
};
}
}