pi-provider-kimi-code 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/stream.ts ADDED
@@ -0,0 +1,299 @@
1
+ // Stream wrapper + empty-response filter. Reads every side-effect source
2
+ // (process.env, options, apiKey) at the top and hands a plain
3
+ // KimiPayloadContext to applyKimiPayloadMutations. The actual logic lives in
4
+ // the pure units in payload.ts; this file just wires SDK streaming + filter +
5
+ // error fallback together.
6
+
7
+ import type {
8
+ Api,
9
+ AssistantMessageEvent,
10
+ AssistantMessageEventStream,
11
+ Context,
12
+ Model,
13
+ SimpleStreamOptions,
14
+ } from "@earendil-works/pi-ai";
15
+ import {
16
+ createAssistantMessageEventStream,
17
+ streamSimpleAnthropic,
18
+ streamSimpleOpenAICompletions,
19
+ } from "@earendil-works/pi-ai";
20
+
21
+ import { IS_OPENAI_PROTOCOL, PROTOCOL } from "./constants.ts";
22
+ import { isKimiAuthErrorMessage, refreshKimiAuthToken } from "./oauth.ts";
23
+ import {
24
+ type Uploader,
25
+ applyKimiPayloadMutations,
26
+ isRecord,
27
+ readEnvOverrides,
28
+ resolveCacheRetention,
29
+ uploadKimiFile,
30
+ } from "./payload.ts";
31
+
32
+ // =============================================================================
33
+ // Event stream filter: suppress Kimi "(Empty response: ...)" text blocks
34
+ // =============================================================================
35
+ // The Kimi API wraps thinking-only responses (no text content) into a text
36
+ // block like: (Empty response: {'content': [{'type': 'thinking', ...}]}).
37
+ // This leaks internal state to the user. We buffer text_start/text_delta
38
+ // events and drop the whole block if text_end starts with the marker.
39
+ // Pure async generator — no closure dependencies, testable with synthetic
40
+ // event arrays.
41
+
42
+ const EMPTY_RESPONSE_PREFIX = "(Empty response:";
43
+
44
+ export async function* filterEmptyResponseStream(
45
+ upstream: AsyncIterable<AssistantMessageEvent>,
46
+ ): AsyncIterable<AssistantMessageEvent> {
47
+ const suppressedIndices = new Set<number>();
48
+ let textBuffer: AssistantMessageEvent[] = [];
49
+ let bufferingIndex: number | null = null;
50
+
51
+ for await (const event of upstream) {
52
+ // Start buffering when a new text block begins.
53
+ if (event.type === "text_start") {
54
+ bufferingIndex = event.contentIndex;
55
+ textBuffer = [event];
56
+ continue;
57
+ }
58
+
59
+ // Accumulate text deltas + detect the empty-response marker on text_end.
60
+ if (
61
+ bufferingIndex !== null &&
62
+ "contentIndex" in event &&
63
+ event.contentIndex === bufferingIndex
64
+ ) {
65
+ if (event.type === "text_delta") {
66
+ textBuffer.push(event);
67
+ continue;
68
+ }
69
+ if (event.type === "text_end") {
70
+ if (event.content.startsWith(EMPTY_RESPONSE_PREFIX)) {
71
+ // Suppress entire text block. Do NOT splice the message content
72
+ // array: it is a shared reference into session state, and mutating
73
+ // it would shift subsequent contentIndex values, corrupting the
74
+ // stream.
75
+ suppressedIndices.add(bufferingIndex);
76
+ } else {
77
+ // Legitimate text block — flush buffered events + end event.
78
+ for (const buffered of textBuffer) yield buffered;
79
+ yield event;
80
+ }
81
+ textBuffer = [];
82
+ bufferingIndex = null;
83
+ continue;
84
+ }
85
+ }
86
+
87
+ // Skip any event targeting an already-suppressed content index.
88
+ if ("contentIndex" in event && suppressedIndices.has(event.contentIndex)) {
89
+ continue;
90
+ }
91
+
92
+ // Clean suppressed blocks out of the final message.
93
+ if (event.type === "done" && suppressedIndices.size > 0) {
94
+ event.message.content = event.message.content.filter(
95
+ (block) =>
96
+ !(
97
+ block.type === "text" &&
98
+ typeof block.text === "string" &&
99
+ block.text.startsWith(EMPTY_RESPONSE_PREFIX)
100
+ ),
101
+ );
102
+ }
103
+
104
+ yield event;
105
+ }
106
+ }
107
+
108
+ // =============================================================================
109
+ // Stream wrapper: orchestrates payload mutation + event filter
110
+ // =============================================================================
111
+
112
+ export function streamSimpleKimi(
113
+ model: Model<Api>,
114
+ context: Context,
115
+ options?: SimpleStreamOptions,
116
+ ): AssistantMessageEventStream {
117
+ const filtered = createAssistantMessageEventStream();
118
+ const initialKey = options?.apiKey || process.env.KIMI_API_KEY || "";
119
+
120
+ const cacheKeyOverride = (
121
+ options as (SimpleStreamOptions & { prompt_cache_key?: unknown }) | undefined
122
+ )?.prompt_cache_key;
123
+ const cacheKey = (typeof cacheKeyOverride === "string" && cacheKeyOverride) || options?.sessionId;
124
+ const cacheRetention = resolveCacheRetention(options?.cacheRetention);
125
+ const envOverrides = readEnvOverrides();
126
+ const thinkingKeep = process.env.KIMI_MODEL_THINKING_KEEP;
127
+ const originalOnPayload = options?.onPayload;
128
+ // The pi-side model id ("kimi-for-coding") is what users select via /model
129
+ // and what gets persisted into sessions. The wire model id discovered at
130
+ // OAuth login (e.g. a versioned alias the server exposes) gets carried on
131
+ // the model object via modifyModels and rewritten into the request payload
132
+ // here so /v1/chat/completions and /v1/messages see the real wire id.
133
+ const wireModelId = (model as Model<Api> & { wireModelId?: unknown }).wireModelId;
134
+
135
+ const buildPatchedOptions = (apiKey: string): SimpleStreamOptions => {
136
+ const upload: Uploader | undefined = apiKey
137
+ ? (mimeType, data) => uploadKimiFile(apiKey, mimeType, data)
138
+ : undefined;
139
+ // Only forward apiKey if we actually have one — never override the
140
+ // caller's credential (e.g. Claude Code OAuth token) with an empty string.
141
+ const apiKeyOverride = apiKey ? { apiKey } : {};
142
+ return {
143
+ ...options,
144
+ ...apiKeyOverride,
145
+ onPayload: async (payload, modelData) => {
146
+ let nextPayload: unknown = payload;
147
+
148
+ if (isRecord(nextPayload)) {
149
+ await applyKimiPayloadMutations(nextPayload, {
150
+ api: PROTOCOL,
151
+ upload,
152
+ cacheKey,
153
+ cacheRetention,
154
+ reasoning: options?.reasoning,
155
+ thinkingKeep,
156
+ envOverrides,
157
+ });
158
+ if (
159
+ typeof wireModelId === "string" &&
160
+ wireModelId &&
161
+ nextPayload.model === "kimi-for-coding"
162
+ ) {
163
+ nextPayload.model = wireModelId;
164
+ }
165
+ }
166
+
167
+ if (originalOnPayload) {
168
+ const res = await originalOnPayload(nextPayload, modelData);
169
+ if (res !== undefined) nextPayload = res;
170
+ }
171
+
172
+ return nextPayload;
173
+ },
174
+ };
175
+ };
176
+
177
+ void (async () => {
178
+ let attempt = 0;
179
+ let currentKey = initialKey;
180
+
181
+ while (true) {
182
+ const patchedOptions = buildPatchedOptions(currentKey);
183
+ // Route by the module-level protocol flag, not model.api, since we
184
+ // register with a custom api type (kimi-openai-completions /
185
+ // kimi-anthropic-messages) to avoid overriding the built-in
186
+ // Anthropic/OpenAI stream handlers.
187
+ const upstream = IS_OPENAI_PROTOCOL
188
+ ? streamSimpleOpenAICompletions(
189
+ model as Model<"openai-completions">,
190
+ context,
191
+ patchedOptions,
192
+ )
193
+ : streamSimpleAnthropic(model as Model<"anthropic-messages">, context, patchedOptions);
194
+
195
+ let shouldRetry = false;
196
+ let prefixBuffer: AssistantMessageEvent[] = [];
197
+
198
+ try {
199
+ for await (const event of filterEmptyResponseStream(upstream)) {
200
+ // streamAnthropic emits a synthetic "start" event synchronously,
201
+ // before the for-await loop begins iterating and therefore before
202
+ // the HTTP request is actually made. If the request 401s, the loop
203
+ // throws and the catch block emits "error". Without buffering, the
204
+ // "start" event (which carries an empty AssistantMessage) leaks into
205
+ // the session history and the TUI, leaving a phantom empty assistant
206
+ // bubble. We buffer "start" events and only flush them once we see
207
+ // a non-error event that proves the stream is alive.
208
+ if (event.type === "start") {
209
+ prefixBuffer.push(event);
210
+ continue;
211
+ }
212
+
213
+ // Speculative OAuth refresh on the first auth error. We retry once
214
+ // so short-lived Kimi tokens invalidated before the local expires
215
+ // timestamp lapses don't surface as raw 401s to the user.
216
+ if (
217
+ attempt === 0 &&
218
+ event.type === "error" &&
219
+ isKimiAuthErrorMessage(event.error?.errorMessage)
220
+ ) {
221
+ console.error(
222
+ `[kimi-coding] upstream error on first event, attempting refresh: ${event.error?.errorMessage?.slice(0, 200)}`,
223
+ );
224
+ const refreshed = await refreshKimiAuthToken(currentKey);
225
+ if (refreshed && refreshed !== currentKey) {
226
+ console.error("[kimi-coding] retrying stream with refreshed token");
227
+ currentKey = refreshed;
228
+ shouldRetry = true;
229
+ break; // discard prefixBuffer — don't leak the stale start
230
+ }
231
+ console.error(
232
+ "[kimi-coding] refresh did not yield a new token, forwarding original error",
233
+ );
234
+ }
235
+
236
+ // First non-start, non-retry event: flush buffered prefix, then
237
+ // stream normally.
238
+ for (const e of prefixBuffer) filtered.push(e);
239
+ prefixBuffer = [];
240
+ filtered.push(event);
241
+ }
242
+
243
+ // Stream ended normally: flush any remaining buffered starts.
244
+ if (!shouldRetry) {
245
+ for (const e of prefixBuffer) filtered.push(e);
246
+ }
247
+ } catch (err) {
248
+ // Upstream threw rather than emitting a stream `error` event. This can
249
+ // be the same stale-token 401 surfaced as an exception (depending on
250
+ // the SDK path / network layer), so mirror the in-stream refresh
251
+ // branch: on attempt 0, try one OAuth refresh + retry. Either way,
252
+ // discard `prefixBuffer` — we never confirmed the stream actually
253
+ // started, and flushing the buffered `start` would resurrect the
254
+ // phantom empty assistant message this PR set out to fix.
255
+ console.error("[kimi-coding] stream error:", err);
256
+ if (attempt === 0 && isKimiAuthErrorMessage(err instanceof Error ? err.message : err)) {
257
+ const refreshed = await refreshKimiAuthToken(currentKey);
258
+ if (refreshed && refreshed !== currentKey) {
259
+ console.error("[kimi-coding] retrying stream after thrown error with refreshed token");
260
+ currentKey = refreshed;
261
+ shouldRetry = true;
262
+ }
263
+ }
264
+ if (!shouldRetry) {
265
+ filtered.push({
266
+ type: "error",
267
+ reason: "error",
268
+ error: {
269
+ role: "assistant",
270
+ api: model.api,
271
+ provider: model.provider,
272
+ model: model.id,
273
+ content: [],
274
+ stopReason: "error",
275
+ errorMessage: err instanceof Error ? err.message : String(err),
276
+ usage: {
277
+ input: 0,
278
+ output: 0,
279
+ cacheRead: 0,
280
+ cacheWrite: 0,
281
+ totalTokens: 0,
282
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
283
+ },
284
+ timestamp: Date.now(),
285
+ },
286
+ });
287
+ }
288
+ }
289
+
290
+ if (shouldRetry) {
291
+ attempt++;
292
+ continue;
293
+ }
294
+ break;
295
+ }
296
+ })();
297
+
298
+ return filtered;
299
+ }
@@ -0,0 +1,316 @@
1
+ import { AuthStorage, defineTool, type AgentToolResult } from "@earendil-works/pi-coding-agent";
2
+ import { Type, type Static } from "@earendil-works/pi-ai";
3
+
4
+ import { PROVIDER_ID } from "../constants.ts";
5
+ import { getCommonHeaders } from "../device.ts";
6
+
7
+ const MOONSHOT_BASE_V1 = "https://api.kimi.com/coding/v1";
8
+ const MOONSHOT_TIMEOUT_MS = 180_000;
9
+
10
+ export const moonshotSearchSchema = Type.Object({
11
+ query: Type.String({ description: "The query text to search for." }),
12
+ limit: Type.Optional(
13
+ Type.Number({
14
+ description: "The number of results to return.",
15
+ default: 5,
16
+ minimum: 1,
17
+ maximum: 20,
18
+ }),
19
+ ),
20
+ include_content: Type.Optional(
21
+ Type.Boolean({
22
+ description: "Whether to include fetched page content in the search results.",
23
+ default: false,
24
+ }),
25
+ ),
26
+ });
27
+
28
+ export const moonshotFetchSchema = Type.Object({
29
+ url: Type.String({ description: "The URL to fetch content from." }),
30
+ });
31
+
32
+ export type MoonshotSearchParams = Static<typeof moonshotSearchSchema>;
33
+ export type MoonshotFetchParams = Static<typeof moonshotFetchSchema>;
34
+
35
+ export interface MoonshotSearchResult {
36
+ url: string;
37
+ title: string;
38
+ snippet: string;
39
+ content?: string;
40
+ }
41
+
42
+ export interface MoonshotFetchResult {
43
+ url: string;
44
+ content: string;
45
+ }
46
+
47
+ interface MoonshotToolDeps {
48
+ fetch: typeof fetch;
49
+ getAccessToken: () => string | null;
50
+ }
51
+
52
+ export interface BuildMoonshotToolOptions {
53
+ deps?: Partial<MoonshotToolDeps>;
54
+ defaultCollapsed?: boolean;
55
+ }
56
+
57
+ interface MoonshotSearchResponse {
58
+ search_results?: unknown;
59
+ }
60
+
61
+ function defaultGetAccessToken(): string | null {
62
+ const credential = AuthStorage.create().get(PROVIDER_ID);
63
+ if (!credential || credential.type !== "oauth") return null;
64
+ return credential.access;
65
+ }
66
+
67
+ function buildDeps(options: BuildMoonshotToolOptions = {}): MoonshotToolDeps {
68
+ return {
69
+ fetch: options.deps?.fetch ?? fetch,
70
+ getAccessToken: options.deps?.getAccessToken ?? defaultGetAccessToken,
71
+ };
72
+ }
73
+
74
+ function clampLimit(value: number | undefined): number {
75
+ if (typeof value !== "number" || !Number.isFinite(value)) return 5;
76
+ return Math.max(1, Math.min(20, Math.trunc(value)));
77
+ }
78
+
79
+ function errorResult<T>(message: string): AgentToolResult<T> {
80
+ return {
81
+ content: [{ type: "text", text: message }],
82
+ details: undefined as T,
83
+ };
84
+ }
85
+
86
+ function buildTimeoutSignal(signal: AbortSignal | undefined): {
87
+ signal: AbortSignal;
88
+ cleanup: () => void;
89
+ } {
90
+ const controller = new AbortController();
91
+ const timeout = setTimeout(() => controller.abort(), MOONSHOT_TIMEOUT_MS);
92
+ const abort = () => controller.abort();
93
+ if (signal?.aborted) {
94
+ controller.abort();
95
+ } else {
96
+ signal?.addEventListener("abort", abort, { once: true });
97
+ }
98
+
99
+ return {
100
+ signal: controller.signal,
101
+ cleanup: () => {
102
+ clearTimeout(timeout);
103
+ signal?.removeEventListener("abort", abort);
104
+ },
105
+ };
106
+ }
107
+
108
+ function buildHeaders(accessToken: string, toolCallId: string): Record<string, string> {
109
+ return {
110
+ ...getCommonHeaders(),
111
+ Authorization: `Bearer ${accessToken}`,
112
+ "Content-Type": "application/json",
113
+ "X-Msh-Tool-Call-Id": toolCallId,
114
+ };
115
+ }
116
+
117
+ function stringField(record: Record<string, unknown>, key: string): string {
118
+ const value = record[key];
119
+ return typeof value === "string" ? value : "";
120
+ }
121
+
122
+ function mapSearchResults(value: unknown, includeContent: boolean): MoonshotSearchResult[] {
123
+ if (!Array.isArray(value)) return [];
124
+ return value
125
+ .filter((item): item is Record<string, unknown> => typeof item === "object" && item !== null)
126
+ .map((item) => {
127
+ const result: MoonshotSearchResult = {
128
+ url: stringField(item, "url"),
129
+ title: stringField(item, "title"),
130
+ snippet: stringField(item, "snippet"),
131
+ };
132
+ if (includeContent) {
133
+ result.content = stringField(item, "content");
134
+ }
135
+ return result;
136
+ });
137
+ }
138
+
139
+ function searchResultsText(results: MoonshotSearchResult[]): string {
140
+ if (results.length === 0) return "[]";
141
+ return JSON.stringify(results, null, 2);
142
+ }
143
+
144
+ function textComponent(text: string) {
145
+ return {
146
+ render: () => text.split("\n"),
147
+ invalidate: () => {},
148
+ };
149
+ }
150
+
151
+ function firstText(result: AgentToolResult<unknown>): string {
152
+ const first = result.content[0];
153
+ return first?.type === "text" ? first.text : "";
154
+ }
155
+
156
+ function shouldCollapse(defaultCollapsed: boolean, expanded: boolean | undefined): boolean {
157
+ return defaultCollapsed && expanded !== true;
158
+ }
159
+
160
+ function renderSearchResult(
161
+ result: AgentToolResult<MoonshotSearchResult[]>,
162
+ expanded: boolean | undefined,
163
+ defaultCollapsed: boolean,
164
+ ) {
165
+ if (!Array.isArray(result.details)) {
166
+ return textComponent(firstText(result));
167
+ }
168
+ if (!shouldCollapse(defaultCollapsed, expanded)) {
169
+ return textComponent(searchResultsText(result.details));
170
+ }
171
+
172
+ const count = result.details.length;
173
+ const first = result.details[0];
174
+ const suffix = first ? `; first: ${first.title || first.url}` : "";
175
+ return textComponent(`moonshot_search returned ${count} result(s)${suffix}`);
176
+ }
177
+
178
+ function renderFetchResult(
179
+ result: AgentToolResult<MoonshotFetchResult>,
180
+ expanded: boolean | undefined,
181
+ defaultCollapsed: boolean,
182
+ ) {
183
+ if (!result.details) {
184
+ return textComponent(firstText(result));
185
+ }
186
+ if (!shouldCollapse(defaultCollapsed, expanded)) {
187
+ return textComponent(result.details.content);
188
+ }
189
+
190
+ return textComponent(
191
+ `moonshot_fetch fetched ${result.details.url} (${result.details.content.length} chars)`,
192
+ );
193
+ }
194
+
195
+ async function readErrorBody(response: Response): Promise<string> {
196
+ return response.text().catch(() => "");
197
+ }
198
+
199
+ export function buildMoonshotSearchTool(options: BuildMoonshotToolOptions = {}) {
200
+ const deps = buildDeps(options);
201
+ const defaultCollapsed = options.defaultCollapsed ?? true;
202
+
203
+ return defineTool({
204
+ name: "moonshot_search",
205
+ label: "Moonshot Search",
206
+ description: "Search the web through Kimi Coding's server-side Moonshot search service.",
207
+ promptSnippet: "Search the web with Kimi Coding's Moonshot search service",
208
+ parameters: moonshotSearchSchema,
209
+
210
+ async execute(toolCallId, params, signal) {
211
+ const accessToken = deps.getAccessToken();
212
+ if (!accessToken) {
213
+ return errorResult<MoonshotSearchResult[]>(
214
+ "Missing Kimi Code OAuth credentials. Run /login kimi-coding first.",
215
+ );
216
+ }
217
+
218
+ const limit = clampLimit(params.limit);
219
+ const includeContent = params.include_content === true;
220
+ const timeout = buildTimeoutSignal(signal);
221
+ try {
222
+ const response = await deps.fetch(`${MOONSHOT_BASE_V1}/search`, {
223
+ method: "POST",
224
+ headers: buildHeaders(accessToken, toolCallId),
225
+ body: JSON.stringify({
226
+ text_query: params.query,
227
+ limit,
228
+ enable_page_crawling: includeContent,
229
+ timeout_seconds: 30,
230
+ }),
231
+ signal: timeout.signal,
232
+ });
233
+
234
+ if (!response.ok) {
235
+ const body = await readErrorBody(response);
236
+ return errorResult<MoonshotSearchResult[]>(
237
+ `Moonshot search failed: ${response.status}${body ? ` ${body}` : ""}`,
238
+ );
239
+ }
240
+
241
+ const data = (await response.json()) as MoonshotSearchResponse;
242
+ const results = mapSearchResults(data.search_results, includeContent);
243
+ return {
244
+ content: [{ type: "text", text: searchResultsText(results) }],
245
+ details: results,
246
+ };
247
+ } catch (error) {
248
+ const message = error instanceof Error ? error.message : String(error);
249
+ return errorResult<MoonshotSearchResult[]>(`Moonshot search failed: ${message}`);
250
+ } finally {
251
+ timeout.cleanup();
252
+ }
253
+ },
254
+
255
+ renderResult(result, renderOptions) {
256
+ return renderSearchResult(result, renderOptions.expanded, defaultCollapsed);
257
+ },
258
+ });
259
+ }
260
+
261
+ export function buildMoonshotFetchTool(options: BuildMoonshotToolOptions = {}) {
262
+ const deps = buildDeps(options);
263
+ const defaultCollapsed = options.defaultCollapsed ?? true;
264
+
265
+ return defineTool({
266
+ name: "moonshot_fetch",
267
+ label: "Moonshot Fetch",
268
+ description: "Fetch a web page through Kimi Coding's server-side Moonshot fetch service.",
269
+ promptSnippet: "Fetch a web page with Kimi Coding's Moonshot fetch service",
270
+ parameters: moonshotFetchSchema,
271
+
272
+ async execute(toolCallId, params, signal) {
273
+ const accessToken = deps.getAccessToken();
274
+ if (!accessToken) {
275
+ return errorResult<MoonshotFetchResult>(
276
+ "Missing Kimi Code OAuth credentials. Run /login kimi-coding first.",
277
+ );
278
+ }
279
+
280
+ const timeout = buildTimeoutSignal(signal);
281
+ try {
282
+ const response = await deps.fetch(`${MOONSHOT_BASE_V1}/fetch`, {
283
+ method: "POST",
284
+ headers: {
285
+ ...buildHeaders(accessToken, toolCallId),
286
+ Accept: "text/markdown",
287
+ },
288
+ body: JSON.stringify({ url: params.url }),
289
+ signal: timeout.signal,
290
+ });
291
+
292
+ if (!response.ok) {
293
+ const body = await readErrorBody(response);
294
+ return errorResult<MoonshotFetchResult>(
295
+ `Moonshot fetch failed: ${response.status}${body ? ` ${body}` : ""}`,
296
+ );
297
+ }
298
+
299
+ const content = await response.text();
300
+ return {
301
+ content: [{ type: "text", text: content }],
302
+ details: { url: params.url, content },
303
+ };
304
+ } catch (error) {
305
+ const message = error instanceof Error ? error.message : String(error);
306
+ return errorResult<MoonshotFetchResult>(`Moonshot fetch failed: ${message}`);
307
+ } finally {
308
+ timeout.cleanup();
309
+ }
310
+ },
311
+
312
+ renderResult(result, renderOptions) {
313
+ return renderFetchResult(result, renderOptions.expanded, defaultCollapsed);
314
+ },
315
+ });
316
+ }