opencode-qoder 0.1.0

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,539 @@
1
+ import crypto from "node:crypto";
2
+ import { resolveQoderCredentials } from "./auth.js";
3
+ import { buildAuthHeaders } from "./cosy.js";
4
+ import { getModelDefinition, QODER_CHAT_URL, USER_AGENT } from "./constants.js";
5
+ import { qoderEncodeBody } from "./encoding.js";
6
+ import { transformPrompt, transformTools } from "./transform.js";
7
+ const THINKING_TAG_VARIANTS = [
8
+ { open: "<thinking>", close: "</thinking>" },
9
+ { open: "<think>", close: "</think>" },
10
+ { open: "<reasoning>", close: "</reasoning>" },
11
+ { open: "<thought>", close: "</thought>" },
12
+ ];
13
+ function stableHash(prefix, ...inputs) {
14
+ const hash = crypto.createHash("sha256");
15
+ hash.update(prefix);
16
+ for (const input of inputs) {
17
+ hash.update("\0");
18
+ hash.update(input);
19
+ }
20
+ return hash.digest("hex").slice(0, 16);
21
+ }
22
+ function stableChatRecordID(model, messages, tools, maxTokens) {
23
+ const hash = crypto.createHash("sha256");
24
+ hash.update("qoder-record");
25
+ hash.update("\0");
26
+ hash.update(model);
27
+ for (const message of messages) {
28
+ hash.update("\0");
29
+ hash.update(message.role);
30
+ if (message.content)
31
+ hash.update(typeof message.content === "string" ? message.content : JSON.stringify(message.content));
32
+ if (message.tool_calls)
33
+ hash.update(JSON.stringify(message.tool_calls));
34
+ }
35
+ if (tools.length) {
36
+ hash.update("\0");
37
+ hash.update(JSON.stringify(tools));
38
+ }
39
+ hash.update("\0");
40
+ hash.update(`mt=${maxTokens}`);
41
+ return hash.digest("hex").slice(0, 16);
42
+ }
43
+ function mapFinishReason(raw, hasToolCalls) {
44
+ if (hasToolCalls || raw === "tool_calls" || raw === "function_call")
45
+ return { unified: "tool-calls", raw };
46
+ if (raw === "length")
47
+ return { unified: "length", raw };
48
+ if (raw === "content_filter")
49
+ return { unified: "content-filter", raw };
50
+ if (!raw || raw === "stop")
51
+ return { unified: "stop", raw };
52
+ return { unified: "other", raw };
53
+ }
54
+ function usageFromQoder(raw) {
55
+ const promptTokens = raw?.prompt_tokens;
56
+ const cachedTokens = raw?.prompt_tokens_details?.cached_tokens;
57
+ return {
58
+ inputTokens: {
59
+ total: promptTokens,
60
+ noCache: promptTokens !== undefined && cachedTokens !== undefined ? promptTokens - cachedTokens : undefined,
61
+ cacheRead: cachedTokens,
62
+ cacheWrite: undefined,
63
+ },
64
+ outputTokens: {
65
+ total: raw?.completion_tokens,
66
+ text: undefined,
67
+ reasoning: raw?.completion_tokens_details?.reasoning_tokens,
68
+ },
69
+ raw: raw,
70
+ };
71
+ }
72
+ function isParsableJson(input) {
73
+ try {
74
+ JSON.parse(input);
75
+ return true;
76
+ }
77
+ catch {
78
+ return false;
79
+ }
80
+ }
81
+ function getTrailingPossibleTagPrefixLength(text, tag) {
82
+ const maxPrefixLength = Math.min(text.length, tag.length - 1);
83
+ for (let len = maxPrefixLength; len > 0; len--) {
84
+ if (text.endsWith(tag.slice(0, len)))
85
+ return len;
86
+ }
87
+ return 0;
88
+ }
89
+ function getMaxTrailingPossibleTagPrefixLength(text, tags) {
90
+ let maxLength = 0;
91
+ for (const tag of tags)
92
+ maxLength = Math.max(maxLength, getTrailingPossibleTagPrefixLength(text, tag));
93
+ return maxLength;
94
+ }
95
+ class StreamEmitter {
96
+ controller;
97
+ textID;
98
+ reasoningID;
99
+ textIndex = 0;
100
+ constructor(controller) {
101
+ this.controller = controller;
102
+ }
103
+ text(delta) {
104
+ if (!delta)
105
+ return;
106
+ this.endReasoning();
107
+ if (!this.textID) {
108
+ this.textID = `txt-${this.textIndex++}`;
109
+ this.controller.enqueue({ type: "text-start", id: this.textID });
110
+ }
111
+ this.controller.enqueue({ type: "text-delta", id: this.textID, delta });
112
+ }
113
+ endText() {
114
+ if (!this.textID)
115
+ return;
116
+ this.controller.enqueue({ type: "text-end", id: this.textID });
117
+ this.textID = undefined;
118
+ }
119
+ reasoning(delta) {
120
+ if (!delta)
121
+ return;
122
+ if (!this.reasoningID) {
123
+ this.endText();
124
+ this.reasoningID = "reasoning-0";
125
+ this.controller.enqueue({ type: "reasoning-start", id: this.reasoningID });
126
+ }
127
+ this.controller.enqueue({ type: "reasoning-delta", id: this.reasoningID, delta });
128
+ }
129
+ endReasoning() {
130
+ if (!this.reasoningID)
131
+ return;
132
+ this.controller.enqueue({ type: "reasoning-end", id: this.reasoningID });
133
+ this.reasoningID = undefined;
134
+ }
135
+ closeOpenBlocks() {
136
+ this.endReasoning();
137
+ this.endText();
138
+ }
139
+ }
140
+ class ThinkingTagParser {
141
+ emitter;
142
+ textBuffer = "";
143
+ inThinking = false;
144
+ thinkingExtracted = false;
145
+ activeEndTag = THINKING_TAG_VARIANTS[0].close;
146
+ constructor(emitter) {
147
+ this.emitter = emitter;
148
+ }
149
+ process(chunk) {
150
+ this.textBuffer += chunk;
151
+ while (this.textBuffer.length > 0) {
152
+ const prevLength = this.textBuffer.length;
153
+ if (!this.inThinking && !this.thinkingExtracted) {
154
+ this.processBeforeThinking();
155
+ if (this.textBuffer.length === 0)
156
+ break;
157
+ }
158
+ if (this.inThinking) {
159
+ this.processInsideThinking();
160
+ if (this.textBuffer.length === 0)
161
+ break;
162
+ }
163
+ if (this.thinkingExtracted) {
164
+ this.emitter.text(this.textBuffer);
165
+ this.textBuffer = "";
166
+ break;
167
+ }
168
+ if (this.textBuffer.length >= prevLength)
169
+ break;
170
+ }
171
+ }
172
+ finalize() {
173
+ if (!this.textBuffer)
174
+ return;
175
+ if (this.inThinking) {
176
+ this.emitter.reasoning(this.textBuffer);
177
+ this.emitter.endReasoning();
178
+ }
179
+ else {
180
+ this.emitter.text(this.textBuffer);
181
+ }
182
+ this.textBuffer = "";
183
+ }
184
+ processBeforeThinking() {
185
+ let bestPos = -1;
186
+ let bestVariant;
187
+ for (const variant of THINKING_TAG_VARIANTS) {
188
+ const pos = this.textBuffer.indexOf(variant.open);
189
+ if (pos !== -1 && (bestPos === -1 || pos < bestPos)) {
190
+ bestPos = pos;
191
+ bestVariant = variant;
192
+ }
193
+ }
194
+ if (bestPos !== -1 && bestVariant) {
195
+ if (bestPos > 0)
196
+ this.emitter.text(this.textBuffer.slice(0, bestPos));
197
+ this.textBuffer = this.textBuffer.slice(bestPos + bestVariant.open.length);
198
+ this.activeEndTag = bestVariant.close;
199
+ this.inThinking = true;
200
+ return;
201
+ }
202
+ const trailingPrefixLength = getMaxTrailingPossibleTagPrefixLength(this.textBuffer, THINKING_TAG_VARIANTS.map((variant) => variant.open));
203
+ const safeLen = this.textBuffer.length - trailingPrefixLength;
204
+ if (safeLen > 0) {
205
+ this.emitter.text(this.textBuffer.slice(0, safeLen));
206
+ this.textBuffer = this.textBuffer.slice(safeLen);
207
+ }
208
+ }
209
+ processInsideThinking() {
210
+ const endPos = this.textBuffer.indexOf(this.activeEndTag);
211
+ if (endPos !== -1) {
212
+ if (endPos > 0)
213
+ this.emitter.reasoning(this.textBuffer.slice(0, endPos));
214
+ this.emitter.endReasoning();
215
+ this.textBuffer = this.textBuffer.slice(endPos + this.activeEndTag.length);
216
+ this.inThinking = false;
217
+ this.thinkingExtracted = true;
218
+ if (this.textBuffer.startsWith("\n\n"))
219
+ this.textBuffer = this.textBuffer.slice(2);
220
+ return;
221
+ }
222
+ const trailingPrefixLength = getTrailingPossibleTagPrefixLength(this.textBuffer, this.activeEndTag);
223
+ const safeLen = this.textBuffer.length - trailingPrefixLength;
224
+ if (safeLen > 0) {
225
+ this.emitter.reasoning(this.textBuffer.slice(0, safeLen));
226
+ this.textBuffer = this.textBuffer.slice(safeLen);
227
+ }
228
+ }
229
+ }
230
+ function buildRequestBody(modelID, options, userID) {
231
+ const model = getModelDefinition(modelID);
232
+ const transformed = transformPrompt(options.prompt);
233
+ const { tools, ignoredTools } = transformTools(options.tools);
234
+ const warnings = [];
235
+ if (ignoredTools > 0)
236
+ warnings.push({ type: "unsupported", feature: "provider-defined tools" });
237
+ if (options.stopSequences?.length)
238
+ warnings.push({ type: "unsupported", feature: "stop sequences" });
239
+ if (options.responseFormat?.type === "json")
240
+ warnings.push({ type: "unsupported", feature: "JSON response format" });
241
+ let maxTokens = model.maxTokens;
242
+ if (options.maxOutputTokens && options.maxOutputTokens < maxTokens)
243
+ maxTokens = options.maxOutputTokens;
244
+ const recordID = stableChatRecordID(modelID, transformed.messages, tools, maxTokens);
245
+ const sessionID = stableHash("qoder-session", userID, modelID);
246
+ const parameters = { max_tokens: maxTokens };
247
+ if (typeof options.temperature === "number")
248
+ parameters.temperature = options.temperature;
249
+ if (typeof options.topP === "number")
250
+ parameters.top_p = options.topP;
251
+ return {
252
+ warnings,
253
+ body: {
254
+ request_id: crypto.randomUUID(),
255
+ request_set_id: recordID,
256
+ chat_record_id: recordID,
257
+ session_id: sessionID,
258
+ stream: true,
259
+ chat_task: "FREE_INPUT",
260
+ is_reply: true,
261
+ is_retry: false,
262
+ source: 1,
263
+ version: "3",
264
+ session_type: "qodercli",
265
+ agent_id: "agent_common",
266
+ task_id: "common",
267
+ code_language: "",
268
+ chat_prompt: "",
269
+ image_urls: null,
270
+ aliyun_user_type: "",
271
+ system: transformed.system,
272
+ messages: transformed.messages,
273
+ tools,
274
+ parameters,
275
+ chat_context: {
276
+ chatPrompt: "",
277
+ imageUrls: null,
278
+ extra: {
279
+ context: [],
280
+ modelConfig: {
281
+ key: modelID,
282
+ is_reasoning: model.reasoning,
283
+ },
284
+ originalContent: transformed.lastUserText,
285
+ },
286
+ features: [],
287
+ text: transformed.lastUserText,
288
+ },
289
+ model_config: {
290
+ key: modelID,
291
+ is_reasoning: model.reasoning,
292
+ max_output_tokens: model.maxTokens,
293
+ source: "system",
294
+ },
295
+ business: {
296
+ product: "cli",
297
+ version: "1.0.0",
298
+ type: "agent",
299
+ stage: "start",
300
+ id: crypto.randomUUID(),
301
+ name: transformed.lastUserText.substring(0, 30),
302
+ begin_at: Date.now(),
303
+ },
304
+ },
305
+ };
306
+ }
307
+ function parseSSELine(line) {
308
+ if (!line.startsWith("data:"))
309
+ return undefined;
310
+ const dataStr = line.substring(5).trim();
311
+ if (!dataStr || dataStr === "[DONE]")
312
+ return undefined;
313
+ const envelope = JSON.parse(dataStr);
314
+ if (envelope.statusCodeValue && envelope.statusCodeValue !== 200) {
315
+ throw new Error(`Upstream status ${envelope.statusCodeValue}: ${envelope.body}`);
316
+ }
317
+ if (!envelope.body || envelope.body === "[DONE]")
318
+ return undefined;
319
+ return JSON.parse(envelope.body);
320
+ }
321
+ export class QoderLanguageModel {
322
+ modelId;
323
+ providerOptions;
324
+ specificationVersion = "v3";
325
+ provider = "qoder";
326
+ supportedUrls = { "image/*": [/^data:/, /^https?:/] };
327
+ constructor(modelId, providerOptions = {}) {
328
+ this.modelId = modelId;
329
+ this.providerOptions = providerOptions;
330
+ }
331
+ async doGenerate(options) {
332
+ const result = await this.doStream(options);
333
+ const content = [];
334
+ const textByID = new Map();
335
+ const reasoningByID = new Map();
336
+ let finishReason = { unified: "stop", raw: undefined };
337
+ let usage = usageFromQoder();
338
+ const warnings = [];
339
+ for await (const part of result.stream) {
340
+ if (part.type === "stream-start")
341
+ warnings.push(...part.warnings);
342
+ if (part.type === "text-start") {
343
+ const block = { type: "text", text: "" };
344
+ textByID.set(part.id, block);
345
+ content.push(block);
346
+ }
347
+ if (part.type === "text-delta")
348
+ textByID.get(part.id).text += part.delta;
349
+ if (part.type === "reasoning-start") {
350
+ const block = { type: "reasoning", text: "" };
351
+ reasoningByID.set(part.id, block);
352
+ content.push(block);
353
+ }
354
+ if (part.type === "reasoning-delta")
355
+ reasoningByID.get(part.id).text += part.delta;
356
+ if (part.type === "tool-call") {
357
+ content.push({ type: "tool-call", toolCallId: part.toolCallId, toolName: part.toolName, input: part.input });
358
+ }
359
+ if (part.type === "finish") {
360
+ finishReason = part.finishReason;
361
+ usage = part.usage;
362
+ }
363
+ if (part.type === "error")
364
+ throw part.error instanceof Error ? part.error : new Error(String(part.error));
365
+ }
366
+ return { content, finishReason, usage, warnings, request: result.request, response: result.response };
367
+ }
368
+ async doStream(options) {
369
+ const credentials = await resolveQoderCredentials(this.providerOptions);
370
+ const { body, warnings } = buildRequestBody(this.modelId, options, credentials.userID);
371
+ const bodyBytes = Buffer.from(JSON.stringify(body));
372
+ const encodedBody = qoderEncodeBody(bodyBytes);
373
+ const encodedBytes = Buffer.from(encodedBody, "utf8");
374
+ const headers = buildAuthHeaders(encodedBytes, QODER_CHAT_URL, {
375
+ userID: credentials.userID,
376
+ authToken: credentials.access,
377
+ name: credentials.name,
378
+ email: credentials.email,
379
+ machineID: credentials.machineID,
380
+ });
381
+ const abortController = new AbortController();
382
+ const abort = () => abortController.abort(options.abortSignal?.reason);
383
+ if (options.abortSignal?.aborted)
384
+ abort();
385
+ else
386
+ options.abortSignal?.addEventListener("abort", abort, { once: true });
387
+ const response = await fetch(QODER_CHAT_URL, {
388
+ method: "POST",
389
+ headers: {
390
+ "Content-Type": "application/json",
391
+ Accept: "text/event-stream",
392
+ "Cache-Control": "no-cache",
393
+ "Accept-Encoding": "identity",
394
+ "User-Agent": USER_AGENT,
395
+ "X-Model-Key": this.modelId,
396
+ "X-Model-Source": "system",
397
+ ...headers,
398
+ },
399
+ body: encodedBytes,
400
+ signal: abortController.signal,
401
+ });
402
+ if (!response.ok) {
403
+ const errText = await response.text().catch(() => "");
404
+ throw new Error(`Qoder API request failed: ${response.status} ${response.statusText}. Response: ${errText}`);
405
+ }
406
+ const stream = this.responseToStream(response, warnings);
407
+ return { stream, request: { body }, response: { headers: Object.fromEntries(response.headers.entries()) } };
408
+ }
409
+ responseToStream(response, warnings) {
410
+ const modelID = this.modelId;
411
+ return new ReadableStream({
412
+ async start(controller) {
413
+ controller.enqueue({ type: "stream-start", warnings });
414
+ const reader = response.body?.getReader();
415
+ if (!reader)
416
+ throw new Error("Qoder response body is empty");
417
+ const decoder = new TextDecoder();
418
+ const emitter = new StreamEmitter(controller);
419
+ const tagParser = new ThinkingTagParser(emitter);
420
+ const toolCalls = [];
421
+ let buffer = "";
422
+ let rawFinishReason;
423
+ let rawUsage;
424
+ let sawToolCall = false;
425
+ const finishToolCall = (state) => {
426
+ if (!state.started || state.finished)
427
+ return;
428
+ state.finished = true;
429
+ controller.enqueue({ type: "tool-input-end", id: state.id });
430
+ controller.enqueue({ type: "tool-call", toolCallId: state.id, toolName: state.name, input: state.arguments });
431
+ sawToolCall = true;
432
+ };
433
+ try {
434
+ while (true) {
435
+ const { done, value } = await reader.read();
436
+ if (done)
437
+ break;
438
+ buffer += decoder.decode(value, { stream: true });
439
+ while (true) {
440
+ const lineEnd = buffer.indexOf("\n");
441
+ if (lineEnd === -1)
442
+ break;
443
+ const line = buffer.substring(0, lineEnd).trim();
444
+ buffer = buffer.substring(lineEnd + 1);
445
+ if (!line)
446
+ continue;
447
+ const chunk = parseSSELine(line);
448
+ if (!chunk)
449
+ continue;
450
+ if (chunk.id || chunk.model || chunk.created) {
451
+ controller.enqueue({
452
+ type: "response-metadata",
453
+ id: chunk.id,
454
+ modelId: chunk.model || modelID,
455
+ timestamp: chunk.created ? new Date(chunk.created * 1000) : undefined,
456
+ });
457
+ }
458
+ if (chunk.usage)
459
+ rawUsage = chunk.usage;
460
+ const choice = chunk.choices?.[0];
461
+ if (!choice)
462
+ continue;
463
+ if (choice.finish_reason)
464
+ rawFinishReason = choice.finish_reason;
465
+ const delta = choice.delta;
466
+ if (!delta)
467
+ continue;
468
+ if (delta.reasoning_content)
469
+ emitter.reasoning(delta.reasoning_content);
470
+ if (delta.content)
471
+ tagParser.process(delta.content);
472
+ if (delta.tool_calls) {
473
+ tagParser.finalize();
474
+ emitter.endReasoning();
475
+ for (const toolCallDelta of delta.tool_calls) {
476
+ const index = toolCallDelta.index ?? 0;
477
+ const state = toolCalls[index] ??
478
+ (toolCalls[index] = {
479
+ id: toolCallDelta.id || crypto.randomUUID(),
480
+ name: toolCallDelta.function?.name || "",
481
+ arguments: "",
482
+ started: false,
483
+ finished: false,
484
+ });
485
+ if (toolCallDelta.id)
486
+ state.id = toolCallDelta.id;
487
+ if (toolCallDelta.function?.name)
488
+ state.name = toolCallDelta.function.name;
489
+ if (!state.started && state.name) {
490
+ state.started = true;
491
+ controller.enqueue({ type: "tool-input-start", id: state.id, toolName: state.name });
492
+ }
493
+ const argDelta = toolCallDelta.function?.arguments || "";
494
+ if (argDelta) {
495
+ state.arguments += argDelta;
496
+ controller.enqueue({ type: "tool-input-delta", id: state.id, delta: argDelta });
497
+ }
498
+ if (state.started && isParsableJson(state.arguments))
499
+ finishToolCall(state);
500
+ }
501
+ }
502
+ }
503
+ }
504
+ tagParser.finalize();
505
+ emitter.closeOpenBlocks();
506
+ for (const state of toolCalls)
507
+ finishToolCall(state);
508
+ controller.enqueue({
509
+ type: "finish",
510
+ finishReason: mapFinishReason(rawFinishReason, sawToolCall),
511
+ usage: usageFromQoder(rawUsage),
512
+ providerMetadata: { qoder: {} },
513
+ });
514
+ controller.close();
515
+ }
516
+ catch (error) {
517
+ controller.enqueue({ type: "error", error });
518
+ controller.enqueue({
519
+ type: "finish",
520
+ finishReason: { unified: "error", raw: undefined },
521
+ usage: usageFromQoder(rawUsage),
522
+ providerMetadata: { qoder: {} },
523
+ });
524
+ controller.close();
525
+ }
526
+ finally {
527
+ await reader.cancel().catch(() => { });
528
+ }
529
+ },
530
+ });
531
+ }
532
+ }
533
+ export function createQoder(options = {}) {
534
+ return {
535
+ languageModel(modelID) {
536
+ return new QoderLanguageModel(modelID, options);
537
+ },
538
+ };
539
+ }
@@ -0,0 +1,45 @@
1
+ import type { LanguageModelV3CallOptions, LanguageModelV3Prompt } from "@ai-sdk/provider";
2
+ export interface QoderTool {
3
+ type: "function";
4
+ function: {
5
+ name: string;
6
+ description?: string;
7
+ parameters?: unknown;
8
+ };
9
+ }
10
+ interface QoderToolCall {
11
+ id?: string;
12
+ type: "function";
13
+ function: {
14
+ name?: string;
15
+ arguments: string;
16
+ };
17
+ }
18
+ type QoderTextPart = {
19
+ type: "text";
20
+ text: string;
21
+ };
22
+ type QoderImagePart = {
23
+ type: "image_url";
24
+ image_url: {
25
+ url: string;
26
+ };
27
+ };
28
+ type QoderContent = string | Array<QoderTextPart | QoderImagePart>;
29
+ export interface QoderMessage {
30
+ role: "user" | "assistant" | "tool";
31
+ content: QoderContent | null;
32
+ tool_calls?: QoderToolCall[];
33
+ tool_call_id?: string;
34
+ }
35
+ export interface TransformedPrompt {
36
+ system: string;
37
+ messages: QoderMessage[];
38
+ lastUserText: string;
39
+ }
40
+ export declare function transformPrompt(prompt: LanguageModelV3Prompt): TransformedPrompt;
41
+ export declare function transformTools(tools: LanguageModelV3CallOptions["tools"]): {
42
+ tools: QoderTool[];
43
+ ignoredTools: number;
44
+ };
45
+ export {};