From 9c9dd7b19163ec9d9c8ba7b18a92128167f8147e Mon Sep 17 00:00:00 2001 From: decolua Date: Wed, 29 Jul 2026 18:09:57 +0700 Subject: [PATCH] feat(qoder): support PAT auth and refresh model list Exchange Personal Access Tokens for short-lived job tokens, close the SSE stream on terminal frames so non-streaming clients do not hang, re-enable OAuth plus API-key auth modes, and replace the model catalog with the current Qoder aliases. Co-Authored-By: Claude Fable 5 --- open-sse/executors/qoder.js | 213 +++++++++++++++---- open-sse/providers/registry/qoder.js | 36 ++-- open-sse/shared/qoder/constants.js | 5 + src/app/api/providers/[id]/test/testUtils.js | 20 ++ 4 files changed, 217 insertions(+), 57 deletions(-) diff --git a/open-sse/executors/qoder.js b/open-sse/executors/qoder.js index 2a7714b3..d062b139 100644 --- a/open-sse/executors/qoder.js +++ b/open-sse/executors/qoder.js @@ -32,7 +32,11 @@ import { SSE_DONE } from "../utils/sseConstants.js"; import { FETCH_CONNECT_TIMEOUT_MS } from "../config/runtimeConfig.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"; @@ -220,8 +224,13 @@ async function buildQoderRequestBody({ model, body, credentials, log, proxyOptio * 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 `data: [DONE]\n\n` plus - * a synthetic OpenAI error chunk. + * 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; @@ -230,15 +239,14 @@ function wrapQoderSSE(response, model) { const encoder = new TextEncoder(); let buffer = ""; let doneEmitted = false; + const reader = response.body.getReader(); - // 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]. + // 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; // never forward chunks past stream end + if (doneEmitted) return; const data = trimmed.slice(5).trimStart(); if (data === "[DONE]") { @@ -271,47 +279,60 @@ function wrapQoderSSE(response, model) { 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). + // 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 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); + 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(() => {}); } }, - 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(SSE_DONE)); - doneEmitted = true; - } + cancel() { + return reader.cancel().catch(() => {}); }, }); - 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, { + return new Response(stream, { status: response.status, statusText: response.statusText, headers: { @@ -321,6 +342,92 @@ function wrapQoderSSE(response, model) { }); } +// ── 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); @@ -338,6 +445,34 @@ export class QoderExecutor extends BaseExecutor { 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 @@ -455,4 +590,6 @@ export const __test__ = { normalizeMessages, wrapQoderSSE, buildQoderRequestBody, + isQoderPat, + resolvePatCredential, }; diff --git a/open-sse/providers/registry/qoder.js b/open-sse/providers/registry/qoder.js index abcc873b..2b6c93ed 100644 --- a/open-sse/providers/registry/qoder.js +++ b/open-sse/providers/registry/qoder.js @@ -11,10 +11,11 @@ export default { notice: { signupUrl: "https://qoder.com", }, - deprecated: true, - deprecationNotice: "RISK_NOTICE", }, - category: "free", + category: "oauth", + authModes: ["oauth", "apikey"], + hasOAuth: true, + authHint: "Personal Access Token (pt-...) từ https://qoder.com/account/integrations", transport: { baseUrl: "https://api3.qoder.sh/algo/api/v2/service/pro/sse/agent_chat_generation", headers: {}, @@ -25,22 +26,19 @@ export default { }, }, models: [ - { id: "qoder-rome-30ba3b", name: "Qoder ROME" }, - { id: "glm-5.2", name: "GLM-5.2" }, - { id: "minimax-m3", name: "MiniMax M3" }, - { id: "qwen3-coder-plus", name: "Qwen3 Coder Plus" }, - { id: "qwen3-max", name: "Qwen3 Max" }, - { id: "qwen3-vl-plus", name: "Qwen3 Vision Plus" }, - { id: "kimi-k2-0905", name: "Kimi K2 0905" }, - { id: "qwen3-max-preview", name: "Qwen3 Max Preview" }, - { id: "kimi-k2", name: "Kimi K2" }, - { id: "deepseek-v3.2", name: "DeepSeek-V3.2-Exp" }, - { id: "deepseek-r1", name: "DeepSeek R1" }, - { id: "deepseek-v3", name: "DeepSeek V3" }, - { id: "qwen3-32b", name: "Qwen3 32B" }, - { id: "qwen3-235b-a22b-thinking-2507", name: "Qwen3 235B A22B Thinking 2507" }, - { id: "qwen3-235b-a22b-instruct", name: "Qwen3 235B A22B Instruct" }, - { id: "qwen3-235b", name: "Qwen3 235B" }, + { id: "ultimate", name: "Ultimate" }, + { id: "auto", name: "Auto" }, + { id: "performance", name: "Performance" }, + { id: "efficient", name: "Efficient" }, + { id: "qmodel_preview", name: "Qwen3.8-Max-Preview" }, + { id: "qmodel_latest", name: "Qwen3.7-Max" }, + { id: "qmodel", name: "Qwen3.7-Plus" }, + { id: "kmodel_latest", name: "Kimi-K3" }, + { id: "kmodel", name: "Kimi-K2.7-Code" }, + { id: "gm51model", name: "GLM-5.2" }, + { id: "dmodel", name: "DeepSeek-V4-Pro" }, + { id: "dfmodel", name: "DeepSeek-V4-Flash" }, + { id: "mmodel", name: "MiniMax-M3" }, ], oauth: { openApiBaseUrl: "https://openapi.qoder.sh", diff --git a/open-sse/shared/qoder/constants.js b/open-sse/shared/qoder/constants.js index 1d9ce303..184c35d6 100644 --- a/open-sse/shared/qoder/constants.js +++ b/open-sse/shared/qoder/constants.js @@ -20,6 +20,11 @@ export const QODER_USERINFO_URL = `${QODER_OPENAPI_BASE}/api/v1/userinfo`; export const QODER_QUOTA_USAGE_URL = `${QODER_OPENAPI_BASE}/api/v2/quota/usage`; export const QODER_REFRESH_TOKEN_URL = `${QODER_CENTER_BASE}/algo/api/v3/user/refresh_token`; +// PAT (Personal Access Token, pt-...) → short-lived job token (jt-...) exchange. +// PATs cannot sign COSY requests directly — they must be exchanged first. +// This endpoint is NOT COSY-signed (plain JSON POST). +export const QODER_JOB_TOKEN_EXCHANGE_URL = `${QODER_OPENAPI_BASE}/api/v1/jobToken/exchange`; + // Inference endpoints (under /algo on api3.qoder.sh, all COSY-signed) export const QODER_CHAT_SIG_PATH = "/api/v2/service/pro/sse/agent_chat_generation"; export const QODER_CHAT_URL = `${QODER_CHAT_BASE}/algo${QODER_CHAT_SIG_PATH}?FetchKeys=llm_model_result&AgentId=agent_common`; diff --git a/src/app/api/providers/[id]/test/testUtils.js b/src/app/api/providers/[id]/test/testUtils.js index d69191b5..8ee8c496 100644 --- a/src/app/api/providers/[id]/test/testUtils.js +++ b/src/app/api/providers/[id]/test/testUtils.js @@ -785,6 +785,26 @@ async function testApiKeyConnection(connection, effectiveProxy = null) { }, effectiveProxy); return { valid: res.ok, error: res.ok ? null : "Invalid API key" }; } + case "qoder": { + // PAT (pt-...) exchange → job token. A successful exchange proves the PAT. + const raw = connection.apiKey || ""; + const pat = raw.startsWith("pt-") ? raw : `pt-${raw}`; + const exRes = await fetchWithConnectionProxy( + "https://openapi.qoder.sh/api/v1/jobToken/exchange", + { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + "Cosy-Version": "1.0.1", + "Cosy-ClientType": "5", + }, + body: JSON.stringify({ personal_token: pat }), + }, + effectiveProxy, + ); + return { valid: exRes.ok, error: exRes.ok ? null : "Invalid Personal Access Token" }; + } default: return { valid: false, error: "Provider test not supported" }; }