mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 13:38:31 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
f17a68aaee
commit
9c9dd7b191
+175
-38
@@ -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: <inner>\n\n`. Errors become `data: [DONE]\n\n` plus
|
||||
* a synthetic OpenAI error chunk.
|
||||
* 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;
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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`;
|
||||
|
||||
Reference in New Issue
Block a user