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/README.md +99 -51
- package/index.ts +41 -944
- package/package.json +10 -6
- package/src/constants.ts +48 -0
- package/src/device.ts +164 -0
- package/src/models.ts +129 -0
- package/src/oauth.ts +397 -0
- package/src/payload.ts +446 -0
- package/src/stream.ts +299 -0
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
|
+
}
|