File size: 16,856 Bytes
88c4c60 2dd17bb 88c4c60 2dd17bb 88c4c60 2dd17bb 88c4c60 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 | /**
* QoderExecutor β sends OpenAI-format chat requests to Qoder's COSY-signed
* inference endpoint at api3.qoder.sh, then unwraps Qoder's `{statusCodeValue,
* body}` SSE envelope back into plain OpenAI SSE for the rest of the pipeline.
*
* Differences vs the previous placeholder:
* - URL is api3.qoder.sh/algo/api/v2/service/pro/sse/agent_chat_generation
* with `&Encode=1` so we can ship the body through the WAF-bypass
* encoder.
* - Authentication is COSY (RSA + AES + MD5 + ~17 Cosy-* headers), not
* a static HMAC.
* - The request shape Qoder expects is non-trivial (chat_context with
* mirrored modelConfig, business block with stable IDs, system text
* hoisted out of the messages array). All ported from the reference.
* - Model identifier is one of the canonical Qoder keys (auto / ultimate /
* performance / efficient / lite + frontier "*model" ids); the
* translator layer feeds us "qoder/<key>" so we strip the prefix.
* - Per-model `model_config` is fetched live from /algo/api/v2/model/list
* and cached. Sending the wrong block silently downgrades to a
* different model upstream, so a missing entry is a hard error.
*/
import { qoderEncodeBody } from "@/lib/qoder/encoding.js";
import { buildCosyHeaders } from "@/lib/qoder/cosy.js";
import { v4 as uuidv4 } from "uuid";
import { createHash } from "crypto";
import { BaseExecutor } from "./base.js";
import { PROVIDERS } from "../config/providers.js";
import { proxyAwareFetch } from "../utils/proxyFetch.js";
import { FETCH_CONNECT_TIMEOUT_MS } from "../config/runtimeConfig.js";
import { getSettings } from "@/lib/localDb";
import {
QODER_CHAT_URL_ENCODED,
QODER_MODEL_MAP,
} from "@/lib/qoder/constants.js";
import { getQoderModelConfig, resolveQoderModels } from "../services/qoderModels.js";
/**
* Hoist role:"system" messages out of the messages array (Qoder rejects
* system in messages) and flatten any multipart content arrays.
*/
function normalizeMessages(messages) {
if (!Array.isArray(messages) || messages.length === 0) {
return { messages: [], systemText: "" };
}
const systemParts = [];
const out = [];
for (const msg of messages) {
if (!msg || typeof msg !== "object") continue;
const text = extractText(msg.content);
if (msg.role === "system") {
if (text) systemParts.push(text);
continue;
}
const cloned = { ...msg };
cloned.content = text;
out.push(cloned);
}
return { messages: out, systemText: systemParts.join("\n\n") };
}
function extractText(content) {
if (typeof content === "string") return content;
if (content == null) return "";
if (Array.isArray(content)) {
const parts = [];
for (const item of content) {
if (item && typeof item === "object") {
if (item.type === "text" && typeof item.text === "string") {
parts.push(item.text);
} else if (typeof item.text === "string") {
parts.push(item.text);
}
}
}
return parts.join("\n");
}
return String(content);
}
function lastUserText(messages) {
for (let i = messages.length - 1; i >= 0; i--) {
const m = messages[i];
if (m?.role === "user" && typeof m.content === "string") {
return m.content;
}
}
return "";
}
function stableHash(prefix, ...parts) {
const h = createHash("sha256");
h.update(prefix);
for (const p of parts) {
h.update("\0");
h.update(String(p ?? ""));
}
return h.digest("hex").slice(0, 16);
}
function stableChatRecordId(model, messages, tools, maxTokens) {
const h = createHash("sha256");
h.update("qoder-record\0");
h.update(String(model));
for (const m of messages) {
if (!m || typeof m !== "object") continue;
if (m.role) { h.update("\0"); h.update(m.role); }
if (typeof m.content === "string" && m.content) {
h.update("\0"); h.update(m.content);
}
}
if (tools) {
h.update("\0");
try { h.update(JSON.stringify(tools)); } catch {}
}
h.update(`\0mt=${maxTokens}`);
return h.digest("hex").slice(0, 16);
}
function truncate(s, n) {
return s && s.length > n ? `${s.slice(0, n)}...` : s || "";
}
/**
* Map the OpenAI-style request body into the exact shape Qoder expects.
*/
async function buildQoderRequestBody({ model, body, credentials, log, proxyOptions, signal }) {
const qoderKey = String(model || "").replace(/^qoder\//, "");
// Fetch model config from dynamic API instead of relying on static QODER_MODEL_MAP.
// This allows support for new Qoder models (e.g., qmodel_latest) without code changes.
let modelConfig = await getQoderModelConfig(credentials, qoderKey, { log, proxyOptions, signal });
if (!modelConfig) {
// Try a forced refresh once before giving up β the cache may simply
// not be populated yet on first ever call for this credential.
const refreshed = await resolveQoderModels(credentials, { forceRefresh: true, log, proxyOptions, signal });
const retried = refreshed?.rawConfigs.get(qoderKey);
if (!retried) {
throw new Error(
`qoder: model_config for "${qoderKey}" not yet known (run a model list fetch or check upstream connectivity)`,
);
}
modelConfig = { ...retried, key: qoderKey };
}
const { messages, systemText } = normalizeMessages(body.messages || []);
const tools = body.tools;
const isReasoning = !!modelConfig.is_reasoning;
const maxOutputTokens = Number(modelConfig.max_output_tokens) || 0;
let maxTokens = 32_768;
if (maxOutputTokens > 0) maxTokens = maxOutputTokens;
if (typeof body.max_tokens === "number" && body.max_tokens > 0 && body.max_tokens < maxTokens) {
maxTokens = body.max_tokens;
}
if (typeof body.max_completion_tokens === "number" && body.max_completion_tokens > 0 && body.max_completion_tokens < maxTokens) {
maxTokens = body.max_completion_tokens;
}
const lastUser = lastUserText(messages);
const psd = credentials.providerSpecificData || {};
const sessionId = stableHash("qoder-session", psd.userId, qoderKey);
const recordId = stableChatRecordId(qoderKey, messages, tools, maxTokens);
return {
qoderKey,
payload: {
request_id: uuidv4(),
request_set_id: recordId,
chat_record_id: recordId,
session_id: sessionId,
stream: true,
chat_task: "FREE_INPUT",
is_reply: true,
is_retry: false,
source: 1,
version: "3",
session_type: "qodercli",
agent_id: "agent_common",
task_id: "common",
code_language: "",
chat_prompt: "",
image_urls: null,
aliyun_user_type: "",
system: systemText,
messages,
tools: Array.isArray(tools) ? tools : [],
parameters: { max_tokens: maxTokens },
chat_context: {
chatPrompt: "",
imageUrls: null,
extra: {
context: [],
modelConfig: { key: qoderKey, is_reasoning: isReasoning },
originalContent: lastUser,
},
features: [],
text: lastUser,
},
model_config: modelConfig,
business: {
product: "cli",
version: "1.0.0",
type: "agent",
stage: "start",
id: uuidv4(),
name: truncate(lastUser, 30),
begin_at: Date.now(),
},
},
modelConfig,
};
}
/**
* Wrap the upstream's `{statusCodeValue, body}` SSE envelope into plain
* OpenAI SSE chunks the rest of the chatCore pipeline understands.
*
* Each upstream line looks like:
* data: {"statusCodeValue":200,"body":"{\"choices\":[{\"delta\":{...}}]}"}
* The inner body is an OpenAI streaming chunk (or "[DONE]"). We unwrap it
* and re-emit as `data: <inner>\n\n`. Errors become `data: [DONE]\n\n` plus
* a synthetic OpenAI error chunk.
*/
function wrapQoderSSE(response, model) {
if (!response.ok || !response.body) return response;
const decoder = new TextDecoder();
const encoder = new TextEncoder();
let buffer = "";
let doneEmitted = false;
// Process one already-extracted SSE line (no trailing newline). Returns
// false when the line indicated end-of-stream so the caller can stop
// forwarding any remaining chunks after [DONE].
const processLine = (line, controller) => {
const trimmed = line.replace(/\r$/, "").trim();
if (!trimmed) return;
if (!trimmed.startsWith("data:")) return;
if (doneEmitted) return; // never forward chunks past stream end
const data = trimmed.slice(5).trimStart();
if (data === "[DONE]") {
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
doneEmitted = true;
return;
}
let envelope;
try { envelope = JSON.parse(data); } catch { return; }
const statusVal = typeof envelope.statusCodeValue === "number" ? envelope.statusCodeValue : 200;
const inner = typeof envelope.body === "string" ? envelope.body : "";
if (statusVal !== 200) {
const msg = inner || `upstream status ${statusVal}`;
const errChunk = JSON.stringify({
id: `qoder-error-${Date.now()}`,
object: "chat.completion.chunk",
created: Math.floor(Date.now() / 1000),
model,
choices: [{ index: 0, delta: { content: `\n[qoder error ${statusVal}: ${truncate(msg, 200)}]` }, finish_reason: "stop" }],
});
controller.enqueue(encoder.encode(`data: ${errChunk}\n\n`));
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
doneEmitted = true;
return;
}
if (!inner) return;
if (inner === "[DONE]") {
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
doneEmitted = true;
return;
}
// Inner is an OpenAI-shaped chunk. Strip any embedded newlines so the
// SSE frame stays a single event (a literal "\n" inside `inner` would
// otherwise split the frame across multiple data: lines and downstream
// parsers would reassemble them as separate events).
const sanitized = inner.replace(/\r?\n/g, "");
controller.enqueue(encoder.encode(`data: ${sanitized}\n\n`));
};
const transform = new TransformStream({
transform(chunk, controller) {
buffer += decoder.decode(chunk, { stream: true });
let nl;
while ((nl = buffer.indexOf("\n")) !== -1) {
const line = buffer.slice(0, nl);
buffer = buffer.slice(nl + 1);
processLine(line, controller);
}
},
flush(controller) {
// Finalize the decoder so any pending multi-byte sequence is
// released into `buffer` instead of being silently dropped.
buffer += decoder.decode();
// Drain any trailing line that arrived without a terminating newline
// (e.g. upstream closed the socket immediately after the last write,
// or a CDN stripped the final CRLF). Without this, the chunk that
// carries finish_reason is silently lost.
if (buffer.length > 0) {
processLine(buffer, controller);
buffer = "";
}
if (!doneEmitted) {
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
doneEmitted = true;
}
},
});
const transformed = response.body.pipeThrough(transform);
// Build a Response with passable headers; the streaming handler reads
// `.body` as a ReadableStream regardless of Content-Type.
return new Response(transformed, {
status: response.status,
statusText: response.statusText,
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
},
});
}
export class QoderExecutor extends BaseExecutor {
constructor() {
super("qoder", PROVIDERS.qoder);
}
buildUrl() {
return QODER_CHAT_URL_ENCODED;
}
// Override execute entirely β Qoder needs:
// - body built from translated chat completion payload
// - body encoded with QoderEncodeBody before signing
// - COSY headers built from the *encoded* body bytes
// - response stream re-wrapped from {statusCodeValue, body} to OpenAI SSE
async execute({ model, body, stream, credentials, signal, log, proxyOptions = null }) {
const url = this.buildUrl();
// Resolve connect timeout: settings DB > env var > hardcoded default
let connectTimeoutMs = FETCH_CONNECT_TIMEOUT_MS;
try {
const settings = await getSettings();
if (settings.fetchConnectTimeoutMs > 0) {
connectTimeoutMs = settings.fetchConnectTimeoutMs * 1000;
}
} catch (_) { /* fallback to env/hardcoded */ }
const psd = credentials?.providerSpecificData || {};
if (!psd.userId) {
// No user id β no way to sign. Surface a 401 so the dashboard nudges
// the user back to OAuth.
const fakeResp = new Response(
JSON.stringify({ error: { message: "qoder credential is missing userId; reconnect the account" } }),
{ status: 401, headers: { "Content-Type": "application/json" } },
);
return { response: fakeResp, url, headers: {}, transformedBody: body };
}
if (!credentials?.accessToken) {
// Same shape as the userId guard β clean 401 so chatCore reports
// "reconnect" rather than bubbling cosy.js's synchronous throw as 500.
const fakeResp = new Response(
JSON.stringify({ error: { message: "qoder credential is missing accessToken; reconnect the account" } }),
{ status: 401, headers: { "Content-Type": "application/json" } },
);
return { response: fakeResp, url, headers: {}, transformedBody: body };
}
let qoderKey;
let payload;
try {
({ qoderKey, payload } = await buildQoderRequestBody({ model, body, credentials, log, proxyOptions, signal }));
} catch (err) {
const fakeResp = new Response(
JSON.stringify({ error: { message: err.message } }),
{ status: 400, headers: { "Content-Type": "application/json" } },
);
return { response: fakeResp, url, headers: {}, transformedBody: body };
}
const plainBody = Buffer.from(JSON.stringify(payload), "utf8");
const encodedBodyStr = qoderEncodeBody(plainBody);
const encodedBodyBuf = Buffer.from(encodedBodyStr, "latin1");
let cosyHeaders;
try {
cosyHeaders = buildCosyHeaders(
encodedBodyBuf,
url,
{
userId: psd.userId,
authToken: credentials.accessToken,
name: credentials.displayName || "",
email: credentials.email || "",
machineId: psd.machineId || "",
},
);
} catch (err) {
// cosy.js throws synchronously on missing userId/authToken β surface
// as 401 so chatCore prompts re-auth instead of returning a 500.
const fakeResp = new Response(
JSON.stringify({ error: { message: `qoder cosy signing failed: ${err.message}` } }),
{ status: 401, headers: { "Content-Type": "application/json" } },
);
return { response: fakeResp, url, headers: {}, transformedBody: body };
}
const modelSource = (payload.model_config && payload.model_config.source) || "system";
const headers = {
"Content-Type": "application/json",
Accept: "text/event-stream",
"Cache-Control": "no-cache",
"X-Model-Key": qoderKey,
"X-Model-Source": modelSource,
// gzip triggers signature validation on Qoder's CDN; force identity.
"Accept-Encoding": "identity",
...cosyHeaders,
};
// Abort if upstream doesn't return response headers within connect timeout.
const timeoutMs = this.config?.timeoutMs || connectTimeoutMs;
const connectCtrl = new AbortController();
const connectTimer = setTimeout(() => connectCtrl.abort(new Error("fetch connect timeout")), timeoutMs);
const mergedSignal = signal ? AbortSignal.any([signal, connectCtrl.signal]) : connectCtrl.signal;
let response;
try {
response = await proxyAwareFetch(
url,
{ method: "POST", headers, body: encodedBodyBuf, signal: mergedSignal },
proxyOptions,
);
} finally {
clearTimeout(connectTimer);
}
if (!response.ok) {
// Pass error response through unchanged so chatCore can capture it.
return { response, url, headers, transformedBody: payload };
}
const wrapped = wrapQoderSSE(response, `qoder/${qoderKey}`);
return { response: wrapped, url, headers, transformedBody: payload };
}
// Qoder device tokens don't refresh through OAuth β the upstream returns
// 403 for our flow. Surfacing failure via 401-on-chat is enough; the
// dashboard tells users to re-login when their token expires (~30 days).
async refreshCredentials() {
return null;
}
needsRefresh() {
return false;
}
}
export default QoderExecutor;
// Internals exposed for unit tests. Not part of the public API β callers
// should import QoderExecutor and use its public methods.
export const __test__ = {
normalizeMessages,
wrapQoderSSE,
buildQoderRequestBody,
};
|