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