Adds pt-... token auth as an alternative to OAuth device flow. A PAT can't sign COSY requests directly, so it's exchanged for a short-lived job token (jt-...) plus userId via openapi.qoder.sh, then used for signing. Also fixes job-token traffic (jt-...) being rejected by api3.qoder.sh with 403 "Login expired" — the official qodercli serves jt- traffic from api2.qoder.sh instead, so buildUrl/model-list routing now branches on it. Quota usage and the dashboard add-key modal are updated to resolve PAT credentials and label the field correctly, and bulk-add now validates each key so it gets a real testStatus instead of a hardcoded "unknown".
603 lines
21 KiB
JavaScript
603 lines
21 KiB
JavaScript
/**
|
|
* 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 "../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 {
|
|
QODER_CHAT_URL_ENCODED,
|
|
QODER_CHAT_BASE_ALT,
|
|
QODER_CHAT_SIG_PATH,
|
|
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: <inner>\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(credentials) {
|
|
// Job-token (jt-...) traffic must hit api2.qoder.sh — api3 rejects jt-
|
|
// with "Login expired" (403). Device tokens (dt-...) stay on api3.
|
|
const raw = credentials?.apiKey || credentials?.accessToken;
|
|
if (typeof raw === "string" && !raw.startsWith("pt-") && (raw.startsWith("jt-") || (credentials?.accessToken || "").startsWith("jt-"))) {
|
|
return `${QODER_CHAT_BASE_ALT}/algo${QODER_CHAT_SIG_PATH}?FetchKeys=llm_model_result&AgentId=agent_common&Encode=1`;
|
|
}
|
|
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 }) {
|
|
// 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: this.buildUrl(credentials), headers: {}, transformedBody: body };
|
|
}
|
|
}
|
|
|
|
const url = this.buildUrl(credentials);
|
|
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 || 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,
|
|
};
|