pi-free 2.2.3 → 2.2.5
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/CHANGELOG.md +23 -48
- package/README.md +41 -532
- package/banner.svg +23 -20
- package/config.ts +779 -702
- package/constants.ts +11 -1
- package/index.ts +15 -1
- package/lib/built-in-toggle.ts +427 -259
- package/lib/model-detection.ts +296 -296
- package/lib/model-metadata.ts +10 -3
- package/lib/telemetry.ts +36 -44
- package/package.json +74 -73
- package/provider-failover/benchmark-lookup.ts +30 -15
- package/provider-helper.ts +27 -8
- package/providers/bai/bai.ts +2 -7
- package/providers/cline/cline-xml-bridge.ts +31 -25
- package/providers/cline/cline.ts +17 -8
- package/providers/kilo/kilo.ts +11 -6
- package/providers/model-fetcher.ts +1 -1
- package/providers/opencode-session.ts +2 -2
- package/providers/openmodel/openmodel.ts +525 -0
- package/providers/qoder/auth.ts +548 -0
- package/providers/qoder/cosy.ts +236 -0
- package/providers/qoder/models.ts +209 -0
- package/providers/qoder/qoder.ts +119 -0
- package/providers/qoder/stream.ts +585 -0
- package/providers/qoder/thinking-parser.ts +251 -0
- package/providers/qoder/transform.ts +192 -0
- package/providers/tokenrouter/tokenrouter.ts +3 -6
|
@@ -0,0 +1,585 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Qoder custom streaming handler.
|
|
3
|
+
*
|
|
4
|
+
* Qoder's current production API is OpenAI-compatible and lives at
|
|
5
|
+
* `api2-v2.qoder.sh/model/v1/chat/completions` with standard Bearer auth.
|
|
6
|
+
* The legacy proprietary SSE/protocol endpoint at api3.qoder.sh has been
|
|
7
|
+
* decommissioned and returns 500 Internal Server Error.
|
|
8
|
+
*
|
|
9
|
+
* This module implements the `streamSimple` interface that Pi expects,
|
|
10
|
+
* translating Qoder's response format to Pi's `AssistantMessageEventStream`.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type {
|
|
14
|
+
Api,
|
|
15
|
+
AssistantMessage,
|
|
16
|
+
AssistantMessageEventStream,
|
|
17
|
+
Context,
|
|
18
|
+
Model,
|
|
19
|
+
SimpleStreamOptions,
|
|
20
|
+
StopReason,
|
|
21
|
+
TextContent,
|
|
22
|
+
ThinkingContent,
|
|
23
|
+
ToolCall,
|
|
24
|
+
} from "@earendil-works/pi-ai";
|
|
25
|
+
import * as PiAi from "@earendil-works/pi-ai";
|
|
26
|
+
import { BASE_URL_QODER } from "../../constants.ts";
|
|
27
|
+
import { createLogger } from "../../lib/logger.ts";
|
|
28
|
+
import { getCachedModelConfig, staticModels } from "./models.ts";
|
|
29
|
+
import { ThinkingTagParser } from "./thinking-parser.ts";
|
|
30
|
+
import { transformMessagesForQoder, transformTools } from "./transform.ts";
|
|
31
|
+
|
|
32
|
+
// =============================================================================
|
|
33
|
+
// Helpers / State
|
|
34
|
+
// =============================================================================
|
|
35
|
+
|
|
36
|
+
interface ToolCallState {
|
|
37
|
+
arguments: string;
|
|
38
|
+
id: string;
|
|
39
|
+
name: string;
|
|
40
|
+
emittedStart?: boolean;
|
|
41
|
+
emittedEnd?: boolean;
|
|
42
|
+
contentIndex: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface StreamState {
|
|
46
|
+
output: AssistantMessage;
|
|
47
|
+
stream: AssistantMessageEventStream;
|
|
48
|
+
contentBlockIndex: number;
|
|
49
|
+
thinkingBlockIndex: number;
|
|
50
|
+
toolCallsState: ToolCallState[];
|
|
51
|
+
thinkingParser: ThinkingTagParser | null;
|
|
52
|
+
hasReasoningContent: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// =============================================================================
|
|
56
|
+
// Delta processing
|
|
57
|
+
// =============================================================================
|
|
58
|
+
|
|
59
|
+
function processReasoningDelta(
|
|
60
|
+
state: StreamState,
|
|
61
|
+
reasoningContent: string,
|
|
62
|
+
): void {
|
|
63
|
+
if (state.thinkingBlockIndex === -1) {
|
|
64
|
+
state.thinkingBlockIndex = state.output.content.length;
|
|
65
|
+
state.output.content.push({ type: "thinking", thinking: "" });
|
|
66
|
+
state.stream.push({
|
|
67
|
+
type: "thinking_start",
|
|
68
|
+
contentIndex: state.thinkingBlockIndex,
|
|
69
|
+
partial: state.output,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
const block = state.output.content[
|
|
73
|
+
state.thinkingBlockIndex
|
|
74
|
+
] as ThinkingContent;
|
|
75
|
+
block.thinking += reasoningContent;
|
|
76
|
+
state.stream.push({
|
|
77
|
+
type: "thinking_delta",
|
|
78
|
+
contentIndex: state.thinkingBlockIndex,
|
|
79
|
+
delta: reasoningContent,
|
|
80
|
+
partial: state.output,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function closeThinkingBlock(state: StreamState): void {
|
|
85
|
+
if (state.thinkingBlockIndex === -1) return;
|
|
86
|
+
const block = state.output.content[
|
|
87
|
+
state.thinkingBlockIndex
|
|
88
|
+
] as ThinkingContent;
|
|
89
|
+
state.stream.push({
|
|
90
|
+
type: "thinking_end",
|
|
91
|
+
contentIndex: state.thinkingBlockIndex,
|
|
92
|
+
content: block.thinking,
|
|
93
|
+
partial: state.output,
|
|
94
|
+
});
|
|
95
|
+
state.thinkingBlockIndex = -1;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function processTextDelta(state: StreamState, text: string): void {
|
|
99
|
+
if (state.thinkingParser && !state.hasReasoningContent) {
|
|
100
|
+
state.thinkingParser.processChunk(text);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (state.contentBlockIndex === -1) {
|
|
104
|
+
state.contentBlockIndex = state.output.content.length;
|
|
105
|
+
state.output.content.push({ type: "text", text: "" });
|
|
106
|
+
state.stream.push({
|
|
107
|
+
type: "text_start",
|
|
108
|
+
contentIndex: state.contentBlockIndex,
|
|
109
|
+
partial: state.output,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
const block = state.output.content[state.contentBlockIndex] as TextContent;
|
|
113
|
+
block.text += text;
|
|
114
|
+
state.stream.push({
|
|
115
|
+
type: "text_delta",
|
|
116
|
+
contentIndex: state.contentBlockIndex,
|
|
117
|
+
delta: text,
|
|
118
|
+
partial: state.output,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function processToolCallDelta(
|
|
123
|
+
state: StreamState,
|
|
124
|
+
tc: {
|
|
125
|
+
index?: number;
|
|
126
|
+
id?: string;
|
|
127
|
+
function?: { name?: string; arguments?: string };
|
|
128
|
+
},
|
|
129
|
+
): void {
|
|
130
|
+
const idx = tc.index ?? 0;
|
|
131
|
+
if (!state.toolCallsState[idx]) {
|
|
132
|
+
state.toolCallsState[idx] = {
|
|
133
|
+
arguments: "",
|
|
134
|
+
id: "",
|
|
135
|
+
name: "",
|
|
136
|
+
contentIndex: 0,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
const toolState = state.toolCallsState[idx];
|
|
140
|
+
if (tc.id) toolState.id = tc.id;
|
|
141
|
+
if (tc.function?.name) toolState.name = tc.function.name;
|
|
142
|
+
if (tc.function?.arguments) {
|
|
143
|
+
const argDelta = tc.function.arguments;
|
|
144
|
+
toolState.arguments += argDelta;
|
|
145
|
+
|
|
146
|
+
if (toolState.emittedStart === undefined) {
|
|
147
|
+
toolState.emittedStart = true;
|
|
148
|
+
toolState.contentIndex = state.output.content.length;
|
|
149
|
+
const block: ToolCall = {
|
|
150
|
+
type: "toolCall",
|
|
151
|
+
id: toolState.id,
|
|
152
|
+
name: toolState.name,
|
|
153
|
+
arguments: {},
|
|
154
|
+
};
|
|
155
|
+
state.output.content.push(block);
|
|
156
|
+
state.stream.push({
|
|
157
|
+
type: "toolcall_start",
|
|
158
|
+
contentIndex: toolState.contentIndex,
|
|
159
|
+
partial: state.output,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
state.stream.push({
|
|
163
|
+
type: "toolcall_delta",
|
|
164
|
+
contentIndex: toolState.contentIndex,
|
|
165
|
+
delta: argDelta,
|
|
166
|
+
partial: state.output,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function processDelta(
|
|
172
|
+
state: StreamState,
|
|
173
|
+
delta: Record<string, unknown>,
|
|
174
|
+
): void {
|
|
175
|
+
// 1. Reasoning content (API-native OpenAI-compatible extension)
|
|
176
|
+
if (delta.reasoning_content) {
|
|
177
|
+
state.hasReasoningContent = true;
|
|
178
|
+
processReasoningDelta(state, delta.reasoning_content as string);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// 2. Text content
|
|
182
|
+
if (delta.content) {
|
|
183
|
+
closeThinkingBlock(state);
|
|
184
|
+
processTextDelta(state, delta.content as string);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// 3. Tool calls
|
|
188
|
+
if (delta.tool_calls && Array.isArray(delta.tool_calls)) {
|
|
189
|
+
for (const tc of delta.tool_calls) {
|
|
190
|
+
processToolCallDelta(state, tc);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function finalizeToolCalls(state: StreamState): void {
|
|
196
|
+
for (const toolState of state.toolCallsState) {
|
|
197
|
+
if (toolState?.emittedStart && !toolState.emittedEnd) {
|
|
198
|
+
toolState.emittedEnd = true;
|
|
199
|
+
let args = {};
|
|
200
|
+
try {
|
|
201
|
+
args = JSON.parse(toolState.arguments || "{}");
|
|
202
|
+
} catch {
|
|
203
|
+
// Invalid JSON args — use empty object
|
|
204
|
+
}
|
|
205
|
+
const block = state.output.content[toolState.contentIndex] as ToolCall;
|
|
206
|
+
block.arguments = args;
|
|
207
|
+
state.stream.push({
|
|
208
|
+
type: "toolcall_end",
|
|
209
|
+
contentIndex: toolState.contentIndex,
|
|
210
|
+
toolCall: {
|
|
211
|
+
type: "toolCall",
|
|
212
|
+
id: toolState.id,
|
|
213
|
+
name: toolState.name,
|
|
214
|
+
arguments: args,
|
|
215
|
+
},
|
|
216
|
+
partial: state.output,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// =============================================================================
|
|
223
|
+
// SSE parsing (OpenAI-compatible stream)
|
|
224
|
+
// =============================================================================
|
|
225
|
+
|
|
226
|
+
function handleSSELine(state: StreamState, line: string): boolean {
|
|
227
|
+
if (!line.startsWith("data:")) return false;
|
|
228
|
+
|
|
229
|
+
const dataStr = line.slice(5).trim();
|
|
230
|
+
if (dataStr === "[DONE]") return true;
|
|
231
|
+
|
|
232
|
+
let parsed: Record<string, unknown>;
|
|
233
|
+
try {
|
|
234
|
+
parsed = JSON.parse(dataStr);
|
|
235
|
+
} catch {
|
|
236
|
+
// Skip unparseable SSE lines
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (parsed.error) {
|
|
241
|
+
throw new Error(
|
|
242
|
+
`Qoder SSE error: ${(parsed.error as { message?: string }).message || JSON.stringify(parsed.error)}`,
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (
|
|
247
|
+
parsed.choices &&
|
|
248
|
+
Array.isArray(parsed.choices) &&
|
|
249
|
+
parsed.choices.length > 0
|
|
250
|
+
) {
|
|
251
|
+
const choice = parsed.choices[0] as Record<string, unknown>;
|
|
252
|
+
if (choice.delta) {
|
|
253
|
+
processDelta(state, choice.delta as Record<string, unknown>);
|
|
254
|
+
}
|
|
255
|
+
if (choice.finish_reason) {
|
|
256
|
+
state.output.stopReason = choice.finish_reason as StopReason;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async function consumeSSEStream(
|
|
263
|
+
state: StreamState,
|
|
264
|
+
reader: ReadableStreamDefaultReader<Uint8Array>,
|
|
265
|
+
): Promise<void> {
|
|
266
|
+
const decoder = new TextDecoder();
|
|
267
|
+
let buffer = "";
|
|
268
|
+
|
|
269
|
+
while (true) {
|
|
270
|
+
const { done, value } = await reader.read();
|
|
271
|
+
if (done) break;
|
|
272
|
+
|
|
273
|
+
buffer += decoder.decode(value, { stream: true });
|
|
274
|
+
|
|
275
|
+
if (buffer.length > MAX_SSE_BUFFER_BYTES) {
|
|
276
|
+
throw new Error(
|
|
277
|
+
`Qoder SSE buffer exceeded ${MAX_SSE_BUFFER_BYTES} bytes without a newline; stream appears malformed.`,
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
while (true) {
|
|
282
|
+
const lineEnd = buffer.indexOf("\n");
|
|
283
|
+
if (lineEnd === -1) break;
|
|
284
|
+
|
|
285
|
+
const line = buffer.substring(0, lineEnd).trim();
|
|
286
|
+
buffer = buffer.substring(lineEnd + 1);
|
|
287
|
+
|
|
288
|
+
const done = handleSSELine(state, line);
|
|
289
|
+
if (done) break;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// =============================================================================
|
|
295
|
+
// Request builder (OpenAI-compatible)
|
|
296
|
+
// =============================================================================
|
|
297
|
+
|
|
298
|
+
const QODER_CHAT_URL = `${BASE_URL_QODER}/model/v1/chat/completions`;
|
|
299
|
+
|
|
300
|
+
const logger = createLogger("qoder");
|
|
301
|
+
|
|
302
|
+
/** Max SSE line buffer size before we treat the stream as malformed. */
|
|
303
|
+
const MAX_SSE_BUFFER_BYTES = 1024 * 1024; // 1 MB
|
|
304
|
+
|
|
305
|
+
/** Redact a bearer token so it never leaks into logs or error messages. */
|
|
306
|
+
function redactToken(token: string | undefined): string {
|
|
307
|
+
if (!token) return "(none)";
|
|
308
|
+
if (token.length <= 8) return "***";
|
|
309
|
+
return `${token.slice(0, 3)}...${token.slice(-3)}`;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
interface StreamSetup {
|
|
313
|
+
accessToken: string;
|
|
314
|
+
qoderModel: string;
|
|
315
|
+
modelConfig: Record<string, unknown>;
|
|
316
|
+
normalizedMessages: unknown[];
|
|
317
|
+
systemText: string;
|
|
318
|
+
maxTokens: number;
|
|
319
|
+
toolsRaw: unknown;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function buildStreamSetup(
|
|
323
|
+
model: Model<Api>,
|
|
324
|
+
context: Context,
|
|
325
|
+
options: SimpleStreamOptions | undefined,
|
|
326
|
+
): StreamSetup {
|
|
327
|
+
const accessToken = options?.apiKey;
|
|
328
|
+
if (!accessToken) {
|
|
329
|
+
throw new Error(
|
|
330
|
+
"Qoder credentials not set. Run /login qoder or set QODER_PERSONAL_ACCESS_TOKEN.",
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Log for diagnostics (visible in pi output)
|
|
335
|
+
const isDebug = process.env.QODER_DEBUG === "1";
|
|
336
|
+
|
|
337
|
+
const qoderModel = model.id;
|
|
338
|
+
const modelConfig = getCachedModelConfig(qoderModel) || {
|
|
339
|
+
key: qoderModel,
|
|
340
|
+
is_reasoning: isReasoningModel(qoderModel),
|
|
341
|
+
max_output_tokens: 32768,
|
|
342
|
+
source: "system",
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
const maxOutputTokens = (modelConfig.max_output_tokens as number) || 32768;
|
|
346
|
+
|
|
347
|
+
let normalizedMessages = transformMessagesForQoder(context.messages);
|
|
348
|
+
const systemText = context.systemPrompt || "";
|
|
349
|
+
|
|
350
|
+
// Prepend system prompt as a system message if present.
|
|
351
|
+
if (systemText) {
|
|
352
|
+
normalizedMessages = [
|
|
353
|
+
{ role: "system", content: systemText },
|
|
354
|
+
...normalizedMessages,
|
|
355
|
+
];
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const maxTokens = resolveMaxTokens(maxOutputTokens, options?.maxTokens);
|
|
359
|
+
|
|
360
|
+
const toolsRaw =
|
|
361
|
+
context.tools && context.tools.length > 0
|
|
362
|
+
? transformTools(context.tools)
|
|
363
|
+
: undefined;
|
|
364
|
+
|
|
365
|
+
if (isDebug) {
|
|
366
|
+
logger.info("[QODER] streaming request", {
|
|
367
|
+
endpoint: QODER_CHAT_URL,
|
|
368
|
+
model: qoderModel,
|
|
369
|
+
messages: normalizedMessages.length,
|
|
370
|
+
token: redactToken(accessToken),
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
return {
|
|
375
|
+
accessToken,
|
|
376
|
+
qoderModel,
|
|
377
|
+
modelConfig,
|
|
378
|
+
normalizedMessages,
|
|
379
|
+
systemText,
|
|
380
|
+
maxTokens,
|
|
381
|
+
toolsRaw,
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
async function fetchQoderStream(
|
|
386
|
+
setup: StreamSetup,
|
|
387
|
+
signal?: AbortSignal,
|
|
388
|
+
): Promise<ReadableStream<Uint8Array>> {
|
|
389
|
+
const { accessToken, qoderModel, normalizedMessages, maxTokens, toolsRaw } =
|
|
390
|
+
setup;
|
|
391
|
+
|
|
392
|
+
const reqBody: Record<string, unknown> = {
|
|
393
|
+
model: qoderModel,
|
|
394
|
+
messages: normalizedMessages,
|
|
395
|
+
stream: true,
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
if (toolsRaw && Array.isArray(toolsRaw) && toolsRaw.length > 0) {
|
|
399
|
+
reqBody.tools = toolsRaw;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
if (maxTokens > 0) {
|
|
403
|
+
reqBody.max_tokens = maxTokens;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const headers: Record<string, string> = {
|
|
407
|
+
"Content-Type": "application/json",
|
|
408
|
+
Accept: "text/event-stream",
|
|
409
|
+
Authorization: `Bearer ${accessToken}`,
|
|
410
|
+
"User-Agent": "pi-free-providers",
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
const response = await fetch(QODER_CHAT_URL, {
|
|
414
|
+
method: "POST",
|
|
415
|
+
headers,
|
|
416
|
+
body: Buffer.from(JSON.stringify(reqBody)),
|
|
417
|
+
signal,
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
if (!response.ok) {
|
|
421
|
+
const errText = await response.text();
|
|
422
|
+
const truncated =
|
|
423
|
+
errText.length > 500 ? `${errText.slice(0, 500)}...` : errText;
|
|
424
|
+
logger.error("[QODER] API request failed", {
|
|
425
|
+
status: response.status,
|
|
426
|
+
statusText: response.statusText,
|
|
427
|
+
model: qoderModel,
|
|
428
|
+
responseLength: errText.length,
|
|
429
|
+
response: truncated,
|
|
430
|
+
token: redactToken(accessToken),
|
|
431
|
+
});
|
|
432
|
+
throw new Error(
|
|
433
|
+
`Qoder API request failed: ${response.status} ${response.statusText} at ${QODER_CHAT_URL}. Model: ${qoderModel}.`,
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const body = response.body;
|
|
438
|
+
if (!body) throw new Error("No response body");
|
|
439
|
+
return body;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// =============================================================================
|
|
443
|
+
// Stream handler (Pi `streamSimple` implementation)
|
|
444
|
+
// =============================================================================
|
|
445
|
+
|
|
446
|
+
export function streamQoder(
|
|
447
|
+
model: Model<Api>,
|
|
448
|
+
context: Context,
|
|
449
|
+
options?: SimpleStreamOptions,
|
|
450
|
+
): AssistantMessageEventStream {
|
|
451
|
+
const StreamCtor = (
|
|
452
|
+
PiAi as unknown as {
|
|
453
|
+
AssistantMessageEventStream: new () => AssistantMessageEventStream;
|
|
454
|
+
}
|
|
455
|
+
).AssistantMessageEventStream;
|
|
456
|
+
const stream = new StreamCtor();
|
|
457
|
+
|
|
458
|
+
const output: AssistantMessage = {
|
|
459
|
+
role: "assistant",
|
|
460
|
+
content: [],
|
|
461
|
+
api: model.api,
|
|
462
|
+
provider: model.provider,
|
|
463
|
+
model: model.id,
|
|
464
|
+
usage: {
|
|
465
|
+
input: 0,
|
|
466
|
+
output: 0,
|
|
467
|
+
cacheRead: 0,
|
|
468
|
+
cacheWrite: 0,
|
|
469
|
+
totalTokens: 0,
|
|
470
|
+
cost: {
|
|
471
|
+
input: 0,
|
|
472
|
+
output: 0,
|
|
473
|
+
cacheRead: 0,
|
|
474
|
+
cacheWrite: 0,
|
|
475
|
+
total: 0,
|
|
476
|
+
},
|
|
477
|
+
},
|
|
478
|
+
stopReason: "stop",
|
|
479
|
+
timestamp: Date.now(),
|
|
480
|
+
};
|
|
481
|
+
|
|
482
|
+
// Run async — AssistantMessageEventStream is a push-based pull stream
|
|
483
|
+
runStream(output, stream, model, context, options);
|
|
484
|
+
|
|
485
|
+
return stream;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
async function runStream(
|
|
489
|
+
output: AssistantMessage,
|
|
490
|
+
stream: AssistantMessageEventStream,
|
|
491
|
+
model: Model<Api>,
|
|
492
|
+
context: Context,
|
|
493
|
+
options: SimpleStreamOptions | undefined,
|
|
494
|
+
): Promise<void> {
|
|
495
|
+
try {
|
|
496
|
+
const setup = buildStreamSetup(model, context, options);
|
|
497
|
+
|
|
498
|
+
const thinkingEnabled = isThinkingEnabled(options?.reasoning);
|
|
499
|
+
const thinkingParser = thinkingEnabled
|
|
500
|
+
? new ThinkingTagParser(output, stream)
|
|
501
|
+
: null;
|
|
502
|
+
|
|
503
|
+
const state: StreamState = {
|
|
504
|
+
output,
|
|
505
|
+
stream,
|
|
506
|
+
contentBlockIndex: -1,
|
|
507
|
+
thinkingBlockIndex: -1,
|
|
508
|
+
toolCallsState: [],
|
|
509
|
+
thinkingParser,
|
|
510
|
+
hasReasoningContent: false,
|
|
511
|
+
};
|
|
512
|
+
|
|
513
|
+
stream.push({ type: "start", partial: output });
|
|
514
|
+
|
|
515
|
+
const reader = await fetchQoderStream(setup, options?.signal).then((s) =>
|
|
516
|
+
s.getReader(),
|
|
517
|
+
);
|
|
518
|
+
|
|
519
|
+
await consumeSSEStream(state, reader);
|
|
520
|
+
|
|
521
|
+
// Finalize
|
|
522
|
+
if (thinkingParser) {
|
|
523
|
+
thinkingParser.finalize();
|
|
524
|
+
}
|
|
525
|
+
closeThinkingBlock(state);
|
|
526
|
+
finalizeToolCalls(state);
|
|
527
|
+
|
|
528
|
+
if (state.toolCallsState.length > 0) {
|
|
529
|
+
output.stopReason = "toolUse";
|
|
530
|
+
} else if (!output.stopReason || output.stopReason === "stop") {
|
|
531
|
+
output.stopReason = "stop";
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
stream.push({
|
|
535
|
+
type: "done",
|
|
536
|
+
reason: output.stopReason as "stop" | "toolUse",
|
|
537
|
+
message: output,
|
|
538
|
+
});
|
|
539
|
+
stream.end();
|
|
540
|
+
} catch (e: unknown) {
|
|
541
|
+
const logger = (await import("../../lib/logger.ts")).createLogger("qoder");
|
|
542
|
+
logger.error("stream error", {
|
|
543
|
+
error: e instanceof Error ? e.message : String(e),
|
|
544
|
+
});
|
|
545
|
+
output.stopReason = options?.signal?.aborted ? "aborted" : "error";
|
|
546
|
+
output.errorMessage = e instanceof Error ? e.message : String(e);
|
|
547
|
+
stream.push({
|
|
548
|
+
type: "error",
|
|
549
|
+
reason: output.stopReason,
|
|
550
|
+
error: output,
|
|
551
|
+
});
|
|
552
|
+
try {
|
|
553
|
+
stream.end();
|
|
554
|
+
} catch {
|
|
555
|
+
// Stream may already be ended
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// =============================================================================
|
|
561
|
+
// Small pure helpers
|
|
562
|
+
// =============================================================================
|
|
563
|
+
|
|
564
|
+
const REASONING_MODEL_IDS = new Set(
|
|
565
|
+
staticModels.filter((m) => m.reasoning).map((m) => m.id),
|
|
566
|
+
);
|
|
567
|
+
|
|
568
|
+
function isReasoningModel(modelId: string): boolean {
|
|
569
|
+
return REASONING_MODEL_IDS.has(modelId);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function resolveMaxTokens(maxOutputTokens: number, requested?: number): number {
|
|
573
|
+
let maxTokens = 32768;
|
|
574
|
+
if (maxOutputTokens > 0) {
|
|
575
|
+
maxTokens = maxOutputTokens;
|
|
576
|
+
}
|
|
577
|
+
if (requested && requested < maxTokens) {
|
|
578
|
+
maxTokens = requested;
|
|
579
|
+
}
|
|
580
|
+
return maxTokens;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function isThinkingEnabled(reasoning: unknown): boolean {
|
|
584
|
+
return reasoning !== false && reasoning !== "off";
|
|
585
|
+
}
|