pi-provider-kimi-code 0.3.1 → 0.3.2

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
+ }