pi-free 2.2.3 → 2.2.4
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 +16 -49
- package/README.md +41 -532
- package/banner.svg +23 -20
- package/config.ts +82 -10
- package/constants.ts +11 -1
- package/index.ts +15 -1
- package/lib/model-detection.ts +296 -296
- package/lib/model-metadata.ts +10 -3
- package/lib/telemetry.ts +36 -44
- package/package.json +3 -2
- 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/encoding.ts +48 -0
- package/providers/qoder/models.ts +321 -0
- package/providers/qoder/qoder.ts +154 -0
- package/providers/qoder/stream.ts +677 -0
- package/providers/qoder/thinking-parser.ts +251 -0
- package/providers/qoder/transform.ts +189 -0
- package/providers/tokenrouter/tokenrouter.ts +3 -6
|
@@ -0,0 +1,677 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Qoder custom streaming handler.
|
|
3
|
+
*
|
|
4
|
+
* Qoder's API is NOT OpenAI-compatible — it uses a proprietary protocol at
|
|
5
|
+
* `api3.qoder.sh/algo/api/v2/service/pro/sse/agent_chat_generation` with
|
|
6
|
+
* COSY-signed headers, WAF-encoded bodies, and a custom SSE event format.
|
|
7
|
+
*
|
|
8
|
+
* This module implements the full `streamSimple` interface that Pi expects,
|
|
9
|
+
* bridging Qoder's proprietary streaming to Pi's `AssistantMessageEventStream`.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import crypto from "node:crypto";
|
|
13
|
+
import type {
|
|
14
|
+
Api,
|
|
15
|
+
AssistantMessage,
|
|
16
|
+
AssistantMessageEventStream,
|
|
17
|
+
Context,
|
|
18
|
+
Model,
|
|
19
|
+
SimpleStreamOptions,
|
|
20
|
+
TextContent,
|
|
21
|
+
ThinkingContent,
|
|
22
|
+
ToolCall,
|
|
23
|
+
} from "@earendil-works/pi-ai";
|
|
24
|
+
import * as PiAi from "@earendil-works/pi-ai";
|
|
25
|
+
import { buildAuthHeaders, getMachineId } from "./cosy.ts";
|
|
26
|
+
import { getCachedModelConfig } from "./models.ts";
|
|
27
|
+
import { getCachedCredentials } from "./auth.ts";
|
|
28
|
+
import { qoderEncodeBody } from "./encoding.ts";
|
|
29
|
+
import { ThinkingTagParser } from "./thinking-parser.ts";
|
|
30
|
+
import { transformMessagesForQoder, transformTools } from "./transform.ts";
|
|
31
|
+
|
|
32
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
interface ToolCallState {
|
|
35
|
+
arguments: string;
|
|
36
|
+
id: string;
|
|
37
|
+
name: string;
|
|
38
|
+
emittedStart?: boolean;
|
|
39
|
+
emittedEnd?: boolean;
|
|
40
|
+
contentIndex: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface StreamState {
|
|
44
|
+
output: AssistantMessage;
|
|
45
|
+
stream: AssistantMessageEventStream;
|
|
46
|
+
contentBlockIndex: number;
|
|
47
|
+
thinkingBlockIndex: number;
|
|
48
|
+
toolCallsState: ToolCallState[];
|
|
49
|
+
thinkingParser: ThinkingTagParser | null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function stableHash(prefix: string, ...inputs: string[]): string {
|
|
53
|
+
const hash = crypto.createHash("sha256");
|
|
54
|
+
hash.update(prefix);
|
|
55
|
+
for (const input of inputs) {
|
|
56
|
+
hash.update("\0");
|
|
57
|
+
hash.update(input);
|
|
58
|
+
}
|
|
59
|
+
return hash.digest("hex").slice(0, 16);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function stableChatRecordID(
|
|
63
|
+
model: string,
|
|
64
|
+
messages: Array<{ role?: string; content?: unknown }>,
|
|
65
|
+
tools: unknown,
|
|
66
|
+
maxTokens: number,
|
|
67
|
+
): string {
|
|
68
|
+
const hash = crypto.createHash("sha256");
|
|
69
|
+
hash.update("qoder-record");
|
|
70
|
+
hash.update("\0");
|
|
71
|
+
hash.update(model);
|
|
72
|
+
for (const msg of messages) {
|
|
73
|
+
if (msg?.role) {
|
|
74
|
+
hash.update("\0");
|
|
75
|
+
hash.update(msg.role);
|
|
76
|
+
}
|
|
77
|
+
if (msg?.content) {
|
|
78
|
+
hash.update("\0");
|
|
79
|
+
hash.update(
|
|
80
|
+
typeof msg.content === "string"
|
|
81
|
+
? msg.content
|
|
82
|
+
: JSON.stringify(msg.content),
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (tools) {
|
|
87
|
+
hash.update("\0");
|
|
88
|
+
hash.update(JSON.stringify(tools));
|
|
89
|
+
}
|
|
90
|
+
hash.update("\0");
|
|
91
|
+
hash.update(`mt=${maxTokens}`);
|
|
92
|
+
return hash.digest("hex").slice(0, 16);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ─── Delta processing helpers ────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
function processReasoningDelta(
|
|
98
|
+
state: StreamState,
|
|
99
|
+
reasoningContent: string,
|
|
100
|
+
): void {
|
|
101
|
+
if (state.thinkingBlockIndex === -1) {
|
|
102
|
+
state.thinkingBlockIndex = state.output.content.length;
|
|
103
|
+
state.output.content.push({ type: "thinking", thinking: "" });
|
|
104
|
+
state.stream.push({
|
|
105
|
+
type: "thinking_start",
|
|
106
|
+
contentIndex: state.thinkingBlockIndex,
|
|
107
|
+
partial: state.output,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
const block = state.output.content[
|
|
111
|
+
state.thinkingBlockIndex
|
|
112
|
+
] as ThinkingContent;
|
|
113
|
+
block.thinking += reasoningContent;
|
|
114
|
+
state.stream.push({
|
|
115
|
+
type: "thinking_delta",
|
|
116
|
+
contentIndex: state.thinkingBlockIndex,
|
|
117
|
+
delta: reasoningContent,
|
|
118
|
+
partial: state.output,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function closeThinkingBlock(state: StreamState): void {
|
|
123
|
+
if (state.thinkingBlockIndex === -1) return;
|
|
124
|
+
const block = state.output.content[
|
|
125
|
+
state.thinkingBlockIndex
|
|
126
|
+
] as ThinkingContent;
|
|
127
|
+
state.stream.push({
|
|
128
|
+
type: "thinking_end",
|
|
129
|
+
contentIndex: state.thinkingBlockIndex,
|
|
130
|
+
content: block.thinking,
|
|
131
|
+
partial: state.output,
|
|
132
|
+
});
|
|
133
|
+
state.thinkingBlockIndex = -1;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function processTextDelta(state: StreamState, text: string): void {
|
|
137
|
+
if (state.thinkingParser) {
|
|
138
|
+
state.thinkingParser.processChunk(text);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (state.contentBlockIndex === -1) {
|
|
142
|
+
state.contentBlockIndex = state.output.content.length;
|
|
143
|
+
state.output.content.push({ type: "text", text: "" });
|
|
144
|
+
state.stream.push({
|
|
145
|
+
type: "text_start",
|
|
146
|
+
contentIndex: state.contentBlockIndex,
|
|
147
|
+
partial: state.output,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
const block = state.output.content[state.contentBlockIndex] as TextContent;
|
|
151
|
+
block.text += text;
|
|
152
|
+
state.stream.push({
|
|
153
|
+
type: "text_delta",
|
|
154
|
+
contentIndex: state.contentBlockIndex,
|
|
155
|
+
delta: text,
|
|
156
|
+
partial: state.output,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function processToolCallDelta(
|
|
161
|
+
state: StreamState,
|
|
162
|
+
tc: {
|
|
163
|
+
index?: number;
|
|
164
|
+
id?: string;
|
|
165
|
+
function?: { name?: string; arguments?: string };
|
|
166
|
+
},
|
|
167
|
+
): void {
|
|
168
|
+
const idx = tc.index ?? 0;
|
|
169
|
+
if (!state.toolCallsState[idx]) {
|
|
170
|
+
state.toolCallsState[idx] = {
|
|
171
|
+
arguments: "",
|
|
172
|
+
id: "",
|
|
173
|
+
name: "",
|
|
174
|
+
contentIndex: 0,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
const toolState = state.toolCallsState[idx];
|
|
178
|
+
if (tc.id) toolState.id = tc.id;
|
|
179
|
+
if (tc.function?.name) toolState.name = tc.function.name;
|
|
180
|
+
if (tc.function?.arguments) {
|
|
181
|
+
const argDelta = tc.function.arguments;
|
|
182
|
+
toolState.arguments += argDelta;
|
|
183
|
+
|
|
184
|
+
if (toolState.emittedStart === undefined) {
|
|
185
|
+
toolState.emittedStart = true;
|
|
186
|
+
toolState.contentIndex = state.output.content.length;
|
|
187
|
+
const block: ToolCall = {
|
|
188
|
+
type: "toolCall",
|
|
189
|
+
id: toolState.id,
|
|
190
|
+
name: toolState.name,
|
|
191
|
+
arguments: {},
|
|
192
|
+
};
|
|
193
|
+
state.output.content.push(block);
|
|
194
|
+
state.stream.push({
|
|
195
|
+
type: "toolcall_start",
|
|
196
|
+
contentIndex: toolState.contentIndex,
|
|
197
|
+
partial: state.output,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
state.stream.push({
|
|
201
|
+
type: "toolcall_delta",
|
|
202
|
+
contentIndex: toolState.contentIndex,
|
|
203
|
+
delta: argDelta,
|
|
204
|
+
partial: state.output,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function processDelta(
|
|
210
|
+
state: StreamState,
|
|
211
|
+
delta: Record<string, unknown>,
|
|
212
|
+
): void {
|
|
213
|
+
// 1. Reasoning content (API-native)
|
|
214
|
+
if (delta.reasoning_content) {
|
|
215
|
+
processReasoningDelta(state, delta.reasoning_content as string);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// 2. Text content
|
|
219
|
+
if (delta.content) {
|
|
220
|
+
closeThinkingBlock(state);
|
|
221
|
+
processTextDelta(state, delta.content as string);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// 3. Tool calls
|
|
225
|
+
if (delta.tool_calls && Array.isArray(delta.tool_calls)) {
|
|
226
|
+
for (const tc of delta.tool_calls) {
|
|
227
|
+
processToolCallDelta(state, tc);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function finalizeToolCalls(state: StreamState): void {
|
|
233
|
+
for (const toolState of state.toolCallsState) {
|
|
234
|
+
if (toolState?.emittedStart && !toolState.emittedEnd) {
|
|
235
|
+
toolState.emittedEnd = true;
|
|
236
|
+
let args = {};
|
|
237
|
+
try {
|
|
238
|
+
args = JSON.parse(toolState.arguments || "{}");
|
|
239
|
+
} catch {
|
|
240
|
+
// Invalid JSON args — use empty object
|
|
241
|
+
}
|
|
242
|
+
const block = state.output.content[toolState.contentIndex] as ToolCall;
|
|
243
|
+
block.arguments = args;
|
|
244
|
+
state.stream.push({
|
|
245
|
+
type: "toolcall_end",
|
|
246
|
+
contentIndex: toolState.contentIndex,
|
|
247
|
+
toolCall: {
|
|
248
|
+
type: "toolCall",
|
|
249
|
+
id: toolState.id,
|
|
250
|
+
name: toolState.name,
|
|
251
|
+
arguments: args,
|
|
252
|
+
},
|
|
253
|
+
partial: state.output,
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ─── SSE parsing ─────────────────────────────────────────────────────────────
|
|
260
|
+
|
|
261
|
+
function handleSSELine(
|
|
262
|
+
state: StreamState,
|
|
263
|
+
line: string,
|
|
264
|
+
): boolean {
|
|
265
|
+
if (!line.startsWith("data:")) return false;
|
|
266
|
+
|
|
267
|
+
const dataStr = line.slice(5).trim();
|
|
268
|
+
if (dataStr === "[DONE]") return true;
|
|
269
|
+
|
|
270
|
+
try {
|
|
271
|
+
const envelope = JSON.parse(dataStr);
|
|
272
|
+
if (envelope.statusCodeValue && envelope.statusCodeValue !== 200) {
|
|
273
|
+
throw new Error(
|
|
274
|
+
`Upstream status ${envelope.statusCodeValue}: ${envelope.body}`,
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const innerStr = envelope.body;
|
|
279
|
+
if (!innerStr || innerStr === "[DONE]") return false;
|
|
280
|
+
|
|
281
|
+
const inner = JSON.parse(innerStr);
|
|
282
|
+
if (inner.choices && inner.choices.length > 0) {
|
|
283
|
+
const choice = inner.choices[0];
|
|
284
|
+
if (choice.delta) {
|
|
285
|
+
processDelta(state, choice.delta);
|
|
286
|
+
}
|
|
287
|
+
if (choice.finish_reason) {
|
|
288
|
+
state.output.stopReason = choice.finish_reason;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
} catch {
|
|
292
|
+
// Skip unparseable SSE lines
|
|
293
|
+
}
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async function consumeSSEStream(
|
|
298
|
+
state: StreamState,
|
|
299
|
+
reader: ReadableStreamDefaultReader<Uint8Array>,
|
|
300
|
+
): Promise<void> {
|
|
301
|
+
const decoder = new TextDecoder();
|
|
302
|
+
let buffer = "";
|
|
303
|
+
|
|
304
|
+
while (true) {
|
|
305
|
+
const { done, value } = await reader.read();
|
|
306
|
+
if (done) break;
|
|
307
|
+
|
|
308
|
+
buffer += decoder.decode(value, { stream: true });
|
|
309
|
+
|
|
310
|
+
while (true) {
|
|
311
|
+
const lineEnd = buffer.indexOf("\n");
|
|
312
|
+
if (lineEnd === -1) break;
|
|
313
|
+
|
|
314
|
+
const line = buffer.substring(0, lineEnd).trim();
|
|
315
|
+
buffer = buffer.substring(lineEnd + 1);
|
|
316
|
+
|
|
317
|
+
const done = handleSSELine(state, line);
|
|
318
|
+
if (done) break;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// ─── Request builder ─────────────────────────────────────────────────────────
|
|
324
|
+
|
|
325
|
+
async function fetchQoderStream(
|
|
326
|
+
setup: StreamSetup,
|
|
327
|
+
signal?: AbortSignal,
|
|
328
|
+
): Promise<ReadableStream<Uint8Array>> {
|
|
329
|
+
const {
|
|
330
|
+
accessToken,
|
|
331
|
+
qoderModel,
|
|
332
|
+
modelConfig,
|
|
333
|
+
normalizedMessages,
|
|
334
|
+
lastUserText,
|
|
335
|
+
systemText,
|
|
336
|
+
maxTokens,
|
|
337
|
+
toolsRaw,
|
|
338
|
+
recordID,
|
|
339
|
+
userID,
|
|
340
|
+
name,
|
|
341
|
+
email,
|
|
342
|
+
machineID,
|
|
343
|
+
} = setup;
|
|
344
|
+
const sessionID = stableHash("qoder-session", userID, qoderModel);
|
|
345
|
+
|
|
346
|
+
const isReasoning = Boolean(modelConfig.is_reasoning);
|
|
347
|
+
|
|
348
|
+
const reqBody: Record<string, unknown> = {
|
|
349
|
+
request_id: crypto.randomUUID(),
|
|
350
|
+
request_set_id: recordID,
|
|
351
|
+
chat_record_id: recordID,
|
|
352
|
+
session_id: sessionID,
|
|
353
|
+
stream: true,
|
|
354
|
+
chat_task: "FREE_INPUT",
|
|
355
|
+
is_reply: true,
|
|
356
|
+
is_retry: false,
|
|
357
|
+
source: 1,
|
|
358
|
+
version: "3",
|
|
359
|
+
session_type: "qodercli",
|
|
360
|
+
agent_id: "agent_common",
|
|
361
|
+
task_id: "common",
|
|
362
|
+
code_language: "",
|
|
363
|
+
chat_prompt: "",
|
|
364
|
+
image_urls: null,
|
|
365
|
+
aliyun_user_type: "",
|
|
366
|
+
system: systemText,
|
|
367
|
+
messages: normalizedMessages,
|
|
368
|
+
tools: toolsRaw || [],
|
|
369
|
+
parameters: { max_tokens: maxTokens },
|
|
370
|
+
chat_context: {
|
|
371
|
+
chatPrompt: "",
|
|
372
|
+
imageUrls: null,
|
|
373
|
+
extra: {
|
|
374
|
+
context: [],
|
|
375
|
+
modelConfig: {
|
|
376
|
+
key: qoderModel,
|
|
377
|
+
is_reasoning: isReasoning,
|
|
378
|
+
},
|
|
379
|
+
originalContent: lastUserText,
|
|
380
|
+
},
|
|
381
|
+
features: [],
|
|
382
|
+
text: lastUserText,
|
|
383
|
+
},
|
|
384
|
+
model_config: modelConfig,
|
|
385
|
+
business: {
|
|
386
|
+
product: "cli",
|
|
387
|
+
version: "1.0.0",
|
|
388
|
+
type: "agent",
|
|
389
|
+
stage: "start",
|
|
390
|
+
id: crypto.randomUUID(),
|
|
391
|
+
name: lastUserText.substring(0, 30),
|
|
392
|
+
begin_at: Date.now(),
|
|
393
|
+
},
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
const bodyBytes = Buffer.from(JSON.stringify(reqBody));
|
|
397
|
+
const encodedBody = qoderEncodeBody(bodyBytes);
|
|
398
|
+
const encodedBytes = Buffer.from(encodedBody, "utf8");
|
|
399
|
+
|
|
400
|
+
const chatURL =
|
|
401
|
+
"https://api3.qoder.sh/algo/api/v2/service/pro/sse/agent_chat_generation?FetchKeys=llm_model_result&AgentId=agent_common&Encode=1";
|
|
402
|
+
|
|
403
|
+
const headers = buildAuthHeaders(encodedBytes, chatURL, {
|
|
404
|
+
userID,
|
|
405
|
+
authToken: accessToken,
|
|
406
|
+
name,
|
|
407
|
+
email,
|
|
408
|
+
machineID,
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
const modelSource = modelConfig.source || "system";
|
|
412
|
+
|
|
413
|
+
const response = await fetch(chatURL, {
|
|
414
|
+
method: "POST",
|
|
415
|
+
headers: {
|
|
416
|
+
"Content-Type": "application/json",
|
|
417
|
+
Accept: "text/event-stream",
|
|
418
|
+
"Cache-Control": "no-cache",
|
|
419
|
+
"Accept-Encoding": "identity",
|
|
420
|
+
"X-Model-Key": qoderModel,
|
|
421
|
+
"X-Model-Source": modelSource as string,
|
|
422
|
+
...headers,
|
|
423
|
+
},
|
|
424
|
+
body: encodedBytes,
|
|
425
|
+
signal,
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
if (!response.ok) {
|
|
429
|
+
const errText = await response.text();
|
|
430
|
+
throw new Error(
|
|
431
|
+
`Qoder API request failed: ${response.status} ${response.statusText}. Response: ${errText}`,
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const body = response.body;
|
|
436
|
+
if (!body) throw new Error("No response body");
|
|
437
|
+
return body;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// ─── Stream handler ──────────────────────────────────────────────────────────
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Main streaming handler for Qoder API requests.
|
|
444
|
+
* This is passed as the `streamSimple` option in `pi.registerProvider`.
|
|
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
|
+
interface StreamSetup {
|
|
489
|
+
accessToken: string;
|
|
490
|
+
qoderModel: string;
|
|
491
|
+
modelConfig: Record<string, unknown>;
|
|
492
|
+
normalizedMessages: unknown[];
|
|
493
|
+
lastUserText: string;
|
|
494
|
+
systemText: string;
|
|
495
|
+
maxTokens: number;
|
|
496
|
+
toolsRaw: unknown;
|
|
497
|
+
recordID: string;
|
|
498
|
+
userID: string;
|
|
499
|
+
name: string;
|
|
500
|
+
email: string;
|
|
501
|
+
machineID: string;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function buildStreamSetup(
|
|
505
|
+
model: Model<Api>,
|
|
506
|
+
context: Context,
|
|
507
|
+
options: SimpleStreamOptions | undefined,
|
|
508
|
+
): StreamSetup {
|
|
509
|
+
const accessToken = options?.apiKey;
|
|
510
|
+
if (!accessToken) {
|
|
511
|
+
throw new Error(
|
|
512
|
+
"Qoder credentials not set. Run /login qoder or set QODER_PERSONAL_ACCESS_TOKEN.",
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const cachedCreds = getCachedCredentials();
|
|
517
|
+
const userID = cachedCreds?.userID || "qoder-user";
|
|
518
|
+
const name = cachedCreds?.name || "Qoder User";
|
|
519
|
+
const email = cachedCreds?.email || "user@qoder.com";
|
|
520
|
+
const machineID = cachedCreds?.machineID || getMachineId();
|
|
521
|
+
|
|
522
|
+
const qoderModel = model.id;
|
|
523
|
+
const modelConfig = getCachedModelConfig(qoderModel) || {
|
|
524
|
+
key: qoderModel,
|
|
525
|
+
is_reasoning: isReasoningModel(qoderModel),
|
|
526
|
+
max_output_tokens: 32768,
|
|
527
|
+
source: "system",
|
|
528
|
+
};
|
|
529
|
+
modelConfig.key = qoderModel;
|
|
530
|
+
|
|
531
|
+
const maxOutputTokens = modelConfig.max_output_tokens || 32768;
|
|
532
|
+
|
|
533
|
+
const normalizedMessages = transformMessagesForQoder(context.messages);
|
|
534
|
+
const systemText = context.systemPrompt || "";
|
|
535
|
+
const lastUserText = extractLastUserText(normalizedMessages);
|
|
536
|
+
|
|
537
|
+
const maxTokens = resolveMaxTokens(maxOutputTokens, options?.maxTokens);
|
|
538
|
+
|
|
539
|
+
const toolsRaw =
|
|
540
|
+
context.tools && context.tools.length > 0
|
|
541
|
+
? transformTools(context.tools)
|
|
542
|
+
: undefined;
|
|
543
|
+
const recordID = stableChatRecordID(
|
|
544
|
+
qoderModel,
|
|
545
|
+
normalizedMessages,
|
|
546
|
+
toolsRaw,
|
|
547
|
+
maxTokens,
|
|
548
|
+
);
|
|
549
|
+
|
|
550
|
+
return {
|
|
551
|
+
accessToken,
|
|
552
|
+
qoderModel,
|
|
553
|
+
modelConfig,
|
|
554
|
+
normalizedMessages,
|
|
555
|
+
lastUserText,
|
|
556
|
+
systemText,
|
|
557
|
+
maxTokens,
|
|
558
|
+
toolsRaw,
|
|
559
|
+
recordID,
|
|
560
|
+
userID,
|
|
561
|
+
name,
|
|
562
|
+
email,
|
|
563
|
+
machineID,
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
async function runStream(
|
|
568
|
+
output: AssistantMessage,
|
|
569
|
+
stream: AssistantMessageEventStream,
|
|
570
|
+
model: Model<Api>,
|
|
571
|
+
context: Context,
|
|
572
|
+
options: SimpleStreamOptions | undefined,
|
|
573
|
+
): Promise<void> {
|
|
574
|
+
try {
|
|
575
|
+
const setup = buildStreamSetup(model, context, options);
|
|
576
|
+
|
|
577
|
+
const thinkingEnabled = isThinkingEnabled(options?.reasoning);
|
|
578
|
+
const thinkingParser = thinkingEnabled
|
|
579
|
+
? new ThinkingTagParser(output, stream)
|
|
580
|
+
: null;
|
|
581
|
+
|
|
582
|
+
const state: StreamState = {
|
|
583
|
+
output,
|
|
584
|
+
stream,
|
|
585
|
+
contentBlockIndex: -1,
|
|
586
|
+
thinkingBlockIndex: -1,
|
|
587
|
+
toolCallsState: [],
|
|
588
|
+
thinkingParser,
|
|
589
|
+
};
|
|
590
|
+
|
|
591
|
+
stream.push({ type: "start", partial: output });
|
|
592
|
+
|
|
593
|
+
const reader = await fetchQoderStream(setup, options?.signal).then(
|
|
594
|
+
(s) => s.getReader(),
|
|
595
|
+
);
|
|
596
|
+
|
|
597
|
+
await consumeSSEStream(state, reader);
|
|
598
|
+
|
|
599
|
+
// Finalize
|
|
600
|
+
if (thinkingParser) {
|
|
601
|
+
thinkingParser.finalize();
|
|
602
|
+
}
|
|
603
|
+
closeThinkingBlock(state);
|
|
604
|
+
finalizeToolCalls(state);
|
|
605
|
+
|
|
606
|
+
if (state.toolCallsState.length > 0) {
|
|
607
|
+
output.stopReason = "toolUse";
|
|
608
|
+
} else if (!output.stopReason || output.stopReason === "stop") {
|
|
609
|
+
output.stopReason = "stop";
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
stream.push({
|
|
613
|
+
type: "done",
|
|
614
|
+
reason: output.stopReason as "stop" | "toolUse",
|
|
615
|
+
message: output,
|
|
616
|
+
});
|
|
617
|
+
stream.end();
|
|
618
|
+
} catch (e: unknown) {
|
|
619
|
+
const logger = (await import("../../lib/logger.ts")).createLogger("qoder");
|
|
620
|
+
logger.error("stream error", {
|
|
621
|
+
error: e instanceof Error ? e.message : String(e),
|
|
622
|
+
});
|
|
623
|
+
output.stopReason = options?.signal?.aborted ? "aborted" : "error";
|
|
624
|
+
output.errorMessage = e instanceof Error ? e.message : String(e);
|
|
625
|
+
stream.push({
|
|
626
|
+
type: "error",
|
|
627
|
+
reason: output.stopReason,
|
|
628
|
+
error: output,
|
|
629
|
+
});
|
|
630
|
+
try {
|
|
631
|
+
stream.end();
|
|
632
|
+
} catch {
|
|
633
|
+
// Stream may already be ended
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// ─── Small pure helpers ──────────────────────────────────────────────────────
|
|
639
|
+
|
|
640
|
+
function isReasoningModel(modelId: string): boolean {
|
|
641
|
+
return (
|
|
642
|
+
modelId === "ultimate" ||
|
|
643
|
+
modelId === "performance" ||
|
|
644
|
+
modelId.includes("dmodel") ||
|
|
645
|
+
modelId.includes("dfmodel")
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function extractLastUserText(
|
|
650
|
+
messages: Array<{ role?: string; content?: unknown }>,
|
|
651
|
+
): string {
|
|
652
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
653
|
+
const msg = messages[i];
|
|
654
|
+
if (msg?.role !== "user") continue;
|
|
655
|
+
const content = msg.content;
|
|
656
|
+
if (typeof content === "string") return content;
|
|
657
|
+
if (Array.isArray(content)) {
|
|
658
|
+
return content.map((c) => ("text" in c ? c.text : "")).join("");
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
return "";
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function resolveMaxTokens(maxOutputTokens: number, requested?: number): number {
|
|
665
|
+
let maxTokens = 32768;
|
|
666
|
+
if (maxOutputTokens > 0) {
|
|
667
|
+
maxTokens = maxOutputTokens;
|
|
668
|
+
}
|
|
669
|
+
if (requested && requested < maxTokens) {
|
|
670
|
+
maxTokens = requested;
|
|
671
|
+
}
|
|
672
|
+
return maxTokens;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
function isThinkingEnabled(reasoning: unknown): boolean {
|
|
676
|
+
return reasoning !== false && reasoning !== "off";
|
|
677
|
+
}
|