/** * 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/" 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 "../shared/qoder/encoding.js"; import { buildCosyHeaders } from "../shared/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 { SSE_DONE } from "../utils/sseConstants.js"; import { FETCH_CONNECT_TIMEOUT_MS } from "../config/runtimeConfig.js"; import { resolveProviderTimeoutMs } from "../services/providerTimeout.js"; import { QODER_CHAT_URL_ENCODED, QODER_JOB_TOKEN_EXCHANGE_URL, QODER_USERINFO_URL, QODER_MODEL_MAP, QODER_IDE_VERSION, QODER_CLIENT_TYPE, } from "../shared/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: \n\n`. Errors become a synthetic OpenAI error * chunk + [DONE]. * * Critical: Qoder's SSE often keeps the socket open after the terminal * [DONE]/error frame (agent keepalive). Non-streaming clients drain via * response.text() which hangs until the socket closes — so on terminal * events we cancel the upstream reader and close our stream immediately. */ function wrapQoderSSE(response, model) { if (!response.ok || !response.body) return response; const decoder = new TextDecoder(); const encoder = new TextEncoder(); let buffer = ""; let doneEmitted = false; const reader = response.body.getReader(); // Process one already-extracted SSE line (no trailing newline). const processLine = (line, controller) => { const trimmed = line.replace(/\r$/, "").trim(); if (!trimmed) return; if (!trimmed.startsWith("data:")) return; if (doneEmitted) return; const data = trimmed.slice(5).trimStart(); if (data === "[DONE]") { controller.enqueue(encoder.encode(SSE_DONE)); 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(SSE_DONE)); doneEmitted = true; return; } if (!inner) return; if (inner === "[DONE]") { controller.enqueue(encoder.encode(SSE_DONE)); doneEmitted = true; return; } // Strip embedded newlines so the SSE frame stays a single event. const sanitized = inner.replace(/\r?\n/g, ""); controller.enqueue(encoder.encode(`data: ${sanitized}\n\n`)); }; const stream = new ReadableStream({ // Use start()+loop (not pull): a pull that buffers a partial line without // enqueueing would never be re-invoked, hanging consumers like .text(). async start(controller) { try { while (!doneEmitted) { const { done, value } = await reader.read(); if (done) { buffer += decoder.decode(); if (buffer.length > 0) { processLine(buffer, controller); buffer = ""; } break; } buffer += decoder.decode(value, { stream: true }); let nl; while ((nl = buffer.indexOf("\n")) !== -1) { const line = buffer.slice(0, nl); buffer = buffer.slice(nl + 1); processLine(line, controller); if (doneEmitted) { // Terminal frame received — drop upstream keepalive and end. await reader.cancel().catch(() => {}); controller.close(); return; } } } } catch { // fall through to terminal [DONE] + close } finally { if (!doneEmitted) { try { controller.enqueue(encoder.encode(SSE_DONE)); doneEmitted = true; } catch { /* already closed */ } } try { controller.close(); } catch { /* already closed */ } await reader.cancel().catch(() => {}); } }, cancel() { return reader.cancel().catch(() => {}); }, }); return new Response(stream, { status: response.status, statusText: response.statusText, headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", }, }); } // ── PAT (Personal Access Token) → job-token exchange ─────────────────────── // PATs (pt-...) cannot sign COSY requests directly. Exchange them for a // short-lived job token (jt-...) via /api/v1/jobToken/exchange (plain JSON, // not COSY-signed), then resolve the userId from userinfo. Mirrors the // official qodercli flow. Cached per-PAT until near-expiry. const PAT_PREFIX = "pt-"; const PAT_REFRESH_BUFFER_MS = 5 * 60 * 1000; const patJobCache = new Map(); export function isQoderPat(token) { return typeof token === "string" && token.startsWith(PAT_PREFIX); } async function exchangeJobToken(pat, proxyOptions = null, signal = null) { const res = await proxyAwareFetch( QODER_JOB_TOKEN_EXCHANGE_URL, { method: "POST", headers: { "Content-Type": "application/json", Accept: "application/json", "User-Agent": "qodercli/1.0.0", "Cosy-Version": QODER_IDE_VERSION, "Cosy-ClientType": QODER_CLIENT_TYPE, }, body: JSON.stringify({ personal_token: pat }), signal, }, proxyOptions, ); if (!res.ok) { const text = await res.text().catch(() => ""); throw new Error(`qoder PAT exchange failed: ${res.status} ${text.slice(0, 200)}`); } const data = await res.json(); if (!data.token) throw new Error("qoder PAT exchange returned no job token"); let expiresAt = Date.now() + 24 * 60 * 60 * 1000; if (data.expires_at) { const parsed = Date.parse(data.expires_at); if (!Number.isNaN(parsed)) expiresAt = parsed; } else if (typeof data.expires_in === "number" && data.expires_in > 0) { expiresAt = Date.now() + data.expires_in; } return { jobToken: data.token, jobRefreshToken: data.refresh_token || "", expiresAt }; } async function fetchUserIdForJobToken(jobToken, proxyOptions = null, signal = null) { try { const res = await proxyAwareFetch( QODER_USERINFO_URL, { method: "GET", headers: { Authorization: `Bearer ${jobToken}`, Accept: "application/json", "User-Agent": "qodercli/1.0.0", }, signal, }, proxyOptions, ); if (!res.ok) return ""; const info = await res.json().catch(() => ({})); return info.id || info.userId || info.user_id || ""; } catch { return ""; } } /** * Exchange a PAT for a job token + userId, caching until near-expiry so repeat * chat requests don't re-exchange. Returns { accessToken, userId }. */ async function resolvePatCredential(pat, proxyOptions = null, signal = null) { const cached = patJobCache.get(pat); if (cached && cached.expiresAt - Date.now() > PAT_REFRESH_BUFFER_MS) { return cached; } const { jobToken, expiresAt } = await exchangeJobToken(pat, proxyOptions, signal); const userId = await fetchUserIdForJobToken(jobToken, proxyOptions, signal); const entry = { accessToken: jobToken, userId, expiresAt }; patJobCache.set(pat, entry); return entry; } 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(); // PAT (pt-...) → exchange for short-lived job token + resolve userId so // downstream COSY signing + catalog fetch work. Device tokens (dt-...) and // job tokens (jt-...) skip this and are used directly. const rawToken = credentials?.apiKey || credentials?.accessToken; if (isQoderPat(rawToken)) { try { const resolved = await resolvePatCredential(rawToken, proxyOptions, signal); credentials = { ...credentials, accessToken: resolved.accessToken, apiKey: undefined, providerSpecificData: { authMethod: "pat", ...(credentials?.providerSpecificData || {}), userId: resolved.userId || credentials?.providerSpecificData?.userId || "", machineId: credentials?.providerSpecificData?.machineId || "", }, }; } catch (err) { log?.error?.("QODER", `PAT exchange failed: ${err.message}`); const fakeResp = new Response( JSON.stringify({ error: { message: `qoder PAT exchange failed: ${err.message}` } }), { status: 401, headers: { "Content-Type": "application/json" } }, ); return { response: fakeResp, url, headers: {}, transformedBody: body }; } } 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 = await resolveProviderTimeoutMs(this.provider, this.config?.timeoutMs, FETCH_CONNECT_TIMEOUT_MS); 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, isQoderPat, resolvePatCredential, };