merge: sync upstream v0.5.81 into MIBP fork
# Conflicts: # .gitignore # Dockerfile # open-sse/handlers/chatCore.js # open-sse/providers/registry/cline.js # open-sse/providers/registry/index.js # open-sse/services/usage.js # open-sse/utils/streamHandler.js # package.json # src/app/(dashboard)/dashboard/profile/page.js # src/app/(dashboard)/dashboard/providers/[id]/page.js
This commit is contained in:
@@ -10,7 +10,14 @@ export function getClineAccessToken(token) {
|
||||
if (typeof token !== "string") return "";
|
||||
const trimmed = token.trim();
|
||||
if (!trimmed) return "";
|
||||
return trimmed.startsWith("workos:") ? trimmed : `workos:${trimmed}`;
|
||||
if (trimmed.toLowerCase().startsWith("workos:")) return trimmed;
|
||||
// Cline OAuth access tokens are WorkOS JWTs (base64url `eyJ…` header).
|
||||
// ClinePass API keys (category "apikey", e.g. `clp_…`) are NOT JWTs and must
|
||||
// be sent verbatim — prefixing them with `workos:` makes the Cline API reject
|
||||
// the request with HTTP 401 ("Please make sure you're using the latest
|
||||
// version of Cline and re-authenticate your Cline account.").
|
||||
const isWorkOsJwt = /^eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/.test(trimmed);
|
||||
return isWorkOsJwt ? `workos:${trimmed}` : trimmed;
|
||||
}
|
||||
|
||||
export function getClineAuthorizationHeader(token) {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { PROVIDERS } from "../providers/index.js";
|
||||
|
||||
/**
|
||||
* Unwrap Cline's non-stream envelope: {"success":true,"data":{...choices...}}.
|
||||
*
|
||||
* Scoped to providers opting in via `transport.quirks.clineEnvelope` so no other
|
||||
* provider's body is ever rewritten. The error envelope ({"success":false,...})
|
||||
* never matches and passes through untouched.
|
||||
*
|
||||
* @param {object} body - Parsed upstream response body
|
||||
* @param {string} provider - Provider id or alias
|
||||
* @returns {object} The inner `data` object, or `body` unchanged
|
||||
*/
|
||||
export function unwrapClineEnvelope(body, provider) {
|
||||
if (!provider || !PROVIDERS[provider]?.quirks?.clineEnvelope) return body;
|
||||
const { success, data } = body || {};
|
||||
if (success !== true || !data || typeof data !== "object" || Array.isArray(data)) return body;
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import crypto from "node:crypto";
|
||||
import { proxyAwareFetch } from "../utils/proxyFetch.js";
|
||||
|
||||
/**
|
||||
* Xiaomi MiMo account-session helpers (used for weekly quota).
|
||||
*
|
||||
* The weekly quota endpoint lives on the account service domain and is authorized
|
||||
* by an account session cookie, NOT the sk- API key. Acquiring that cookie mirrors
|
||||
* MiMo Desktop: a passToken (persisted in Desktop's cookie store) is exchanged via
|
||||
* the passportapi SSO, then authorized for the `mimopc` service, and finally stamped
|
||||
* by the mimo-server /api/sts callback into a `serviceToken` cookie.
|
||||
*
|
||||
* Flow (verified against MiMo Desktop traffic):
|
||||
* 1. GET {api}/api/user/xiaomi/me -> 302 to account SSO (sid=mimopc)
|
||||
* 2. GET account /pass/serviceLogin?sid=passportapi&_json=true -> nonce/ssecurity
|
||||
* 3. GET {location}&clientSign=... -> account-level serviceToken
|
||||
* 4. GET account /pass/serviceLogin?sid=mimopc&callback=<sts>&_json=true
|
||||
* 5. GET {api}/api/sts?...&ticket... -> Set-Cookie: serviceToken (mimopc scope)
|
||||
*/
|
||||
|
||||
const API_BASE = "https://mimo-server-cn.xiaomimimo.com";
|
||||
const ACCOUNT_HOST = "account.xiaomi.com";
|
||||
const API_UA =
|
||||
"miNative PC/Normal Windows_NT/10.0.19045 SDKV/1.0.0 DEVT/PC DEVS/Windows APP/miaccount_desktop APPV/0.1.0";
|
||||
const SSO_UA = "MiClaw/1.0";
|
||||
const COOKIE_TTL_MS = 30 * 60 * 1000;
|
||||
|
||||
// Per-account session caches (keyed by passToken hash) so multiple Xiaomi
|
||||
// accounts / connections can rotate without clobbering each other.
|
||||
const _cache = new Map(); // key -> { cookie, at }
|
||||
const _inflight = new Map(); // key -> Promise<cookie|null>
|
||||
|
||||
function desktopCookiePath() {
|
||||
const home = os.homedir();
|
||||
if (process.platform === "win32") {
|
||||
return path.join(home, "AppData", "Roaming", "Xiaomi MiMo", "Partitions", "xiaomi-account", "Network", "Cookies");
|
||||
}
|
||||
if (process.platform === "darwin") {
|
||||
return path.join(home, "Library", "Application Support", "Xiaomi MiMo", "Partitions", "xiaomi-account", "Network", "Cookies");
|
||||
}
|
||||
return path.join(home, ".config", "Xiaomi MiMo", "Partitions", "xiaomi-account", "Network", "Cookies");
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the persisted Xiaomi account cookies from MiMo Desktop's Electron profile.
|
||||
* The Chromium cookie DB is held with an exclusive lock while Desktop runs, so we
|
||||
* copy it first and bail (return null) if that fails.
|
||||
* @returns {Promise<Record<string,string>|null>}
|
||||
*/
|
||||
async function readDesktopAccountCookies() {
|
||||
const src = desktopCookiePath();
|
||||
if (!fs.existsSync(src)) return null;
|
||||
const tmp = path.join(os.tmpdir(), `9r-mimo-cookies-${process.pid}-${crypto.randomBytes(4).toString("hex")}.db`);
|
||||
try {
|
||||
fs.copyFileSync(src, tmp);
|
||||
} catch {
|
||||
return null; // locked by a running Desktop
|
||||
}
|
||||
try {
|
||||
const { DatabaseSync } = await import("node:sqlite");
|
||||
const db = new DatabaseSync(tmp, { readOnly: true });
|
||||
const rows = db.prepare("SELECT name, value FROM cookies WHERE host_key = ?").all("." + ACCOUNT_HOST);
|
||||
db.close();
|
||||
const jar = Object.fromEntries(rows.map((r) => [r.name, r.value]));
|
||||
return jar.passToken ? jar : null;
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
try {
|
||||
fs.unlinkSync(tmp);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read just the passToken + identity cookies from Desktop's profile.
|
||||
* Exported so the connect flow can persist a per-account passToken into the
|
||||
* connection's providerSpecificData — this is what enables multi-account rotation.
|
||||
* @returns {Promise<{passToken:string, userId:string|null, cUserId:string|null}|null>}
|
||||
*/
|
||||
export async function readDesktopPassToken() {
|
||||
try {
|
||||
const jar = await readDesktopAccountCookies();
|
||||
if (!jar?.passToken) return null;
|
||||
return { passToken: jar.passToken, userId: jar.userId || null, cUserId: jar.cUserId || null };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function signatureClientSign(nonce, ssecurity) {
|
||||
const input = `nonce=${nonce}` + (ssecurity && ssecurity.trim() ? `&${ssecurity}` : "");
|
||||
return encodeURIComponent(crypto.createHash("sha1").update(input).digest("base64"));
|
||||
}
|
||||
|
||||
function absorbSetCookie(jar, res) {
|
||||
for (const c of res.headers.getSetCookie?.() || []) {
|
||||
const m = /^([^=]+)=([^;]*)/.exec(c.trim());
|
||||
if (m && m[2]) jar[m[1]] = m[2];
|
||||
}
|
||||
}
|
||||
|
||||
function cookieHeader(jar) {
|
||||
return Object.entries(jar)
|
||||
.filter(([, v]) => v)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join("; ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange a passToken for a mimo-server service session cookie.
|
||||
* @returns {Promise<string|null>} Cookie header value, or null on failure.
|
||||
*/
|
||||
async function acquireServiceCookie(passJar, proxyOptions) {
|
||||
const jar = { ...passJar };
|
||||
const ck = () => cookieHeader(jar);
|
||||
|
||||
// 1. Unauthenticated API call -> 302 carrying the sts callback (sid=mimopc)
|
||||
const r1 = await proxyAwareFetch(
|
||||
`${API_BASE}/api/user/xiaomi/me`,
|
||||
{ redirect: "manual", headers: { "User-Agent": API_UA, Cookie: ck() } },
|
||||
proxyOptions,
|
||||
);
|
||||
const redirect = r1.headers.get("location");
|
||||
if (!redirect) return null;
|
||||
const stsCallback = new URL(redirect).searchParams.get("callback");
|
||||
if (!stsCallback) return null;
|
||||
|
||||
// 2. passportapi SSO phase 1 -> nonce + ssecurity
|
||||
const sso1 = await proxyAwareFetch(
|
||||
`https://${ACCOUNT_HOST}/pass/serviceLogin?sid=passportapi&_json=true`,
|
||||
{ headers: { Cookie: ck(), "User-Agent": SSO_UA, Accept: "application/json" } },
|
||||
proxyOptions,
|
||||
);
|
||||
const j1 = JSON.parse((await sso1.text()).replace(/^&&&START&&&/, ""));
|
||||
const nonce = j1.nonce || (j1.location ? new URL(j1.location).searchParams.get("nonce") : null);
|
||||
if (!nonce || !j1.location) return null;
|
||||
|
||||
// 3. passportapi SSO phase 2 -> account-level serviceToken
|
||||
const sso2 = await proxyAwareFetch(
|
||||
`${j1.location}&clientSign=${signatureClientSign(nonce, j1.ssecurity)}`,
|
||||
{ redirect: "manual", headers: { Cookie: ck(), "User-Agent": SSO_UA } },
|
||||
proxyOptions,
|
||||
);
|
||||
absorbSetCookie(jar, sso2);
|
||||
|
||||
// 4. mimopc SSO -> sts callback carrying a ticket
|
||||
const sso3 = await proxyAwareFetch(
|
||||
`https://${ACCOUNT_HOST}/pass/serviceLogin?sid=mimopc&callback=${encodeURIComponent(stsCallback)}&_json=true`,
|
||||
{ headers: { Cookie: ck(), "User-Agent": SSO_UA, Accept: "application/json" } },
|
||||
proxyOptions,
|
||||
);
|
||||
const j3 = JSON.parse((await sso3.text()).replace(/^&&&START&&&/, ""));
|
||||
absorbSetCookie(jar, sso3);
|
||||
if (!j3?.location || !/\/api\/sts/.test(j3.location)) return null;
|
||||
|
||||
// 5. sts callback -> Set-Cookie: serviceToken (mimopc scope)
|
||||
const sts = await proxyAwareFetch(
|
||||
j3.location,
|
||||
{ redirect: "manual", headers: { "User-Agent": API_UA, Cookie: ck() } },
|
||||
proxyOptions,
|
||||
);
|
||||
absorbSetCookie(jar, sts);
|
||||
|
||||
const needed = ["serviceToken", "mimopc_ph", "mimopc_slh", "userId"];
|
||||
if (!jar.serviceToken) return null;
|
||||
const out = {};
|
||||
for (const k of needed) if (jar[k]) out[k] = jar[k];
|
||||
return cookieHeader(out);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get (and cache) the mimo-server account cookie.
|
||||
* @param {object|null} providerSpecificData - may carry `mimoPassToken` override
|
||||
*/
|
||||
async function getServiceCookie(providerSpecificData, proxyOptions) {
|
||||
const passJar = providerSpecificData?.mimoPassToken
|
||||
? { passToken: providerSpecificData.mimoPassToken, userId: providerSpecificData.mimoUserId, cUserId: providerSpecificData.mimoCUserId }
|
||||
: await readDesktopAccountCookies();
|
||||
if (!passJar) return { cookie: null, reason: "no-pass-token" };
|
||||
|
||||
// One cached session per passToken — accounts/connections rotate independently.
|
||||
const key = crypto.createHash("sha256").update(passJar.passToken).digest("hex");
|
||||
|
||||
const cached = _cache.get(key);
|
||||
if (cached && Date.now() - cached.at < COOKIE_TTL_MS) {
|
||||
return { cookie: cached.cookie };
|
||||
}
|
||||
|
||||
// De-dupe concurrent handshakes for the same account: a burst of requests must
|
||||
// not each run the full 5-step SSO chain.
|
||||
const inflight = _inflight.get(key);
|
||||
if (inflight) {
|
||||
const cookie = await inflight;
|
||||
return cookie ? { cookie } : { cookie: null, reason: "sso-failed" };
|
||||
}
|
||||
|
||||
const promise = (async () => {
|
||||
try {
|
||||
return await acquireServiceCookie(passJar, proxyOptions);
|
||||
} catch {
|
||||
return null; // network/parse failure — callers degrade, never throw
|
||||
} finally {
|
||||
_inflight.delete(key);
|
||||
}
|
||||
})();
|
||||
_inflight.set(key, promise);
|
||||
|
||||
const cookie = await promise;
|
||||
if (!cookie) return { cookie: null, reason: "sso-failed" };
|
||||
_cache.set(key, { cookie, at: Date.now() });
|
||||
return { cookie };
|
||||
}
|
||||
|
||||
/** Drop cached sessions so the next call re-runs the handshake (e.g. after a 401). */
|
||||
export function invalidateMimoAccountCookieCache() {
|
||||
_cache.clear();
|
||||
}
|
||||
|
||||
/** mimo-server account API base + the User-Agent its backend expects. */
|
||||
export const MIMO_API_BASE = API_BASE;
|
||||
export const MIMO_API_UA = API_UA;
|
||||
|
||||
/**
|
||||
* Resolve the mimo-server account-session cookie, for upstream /api/route/* calls.
|
||||
* @returns {Promise<string|null>} Cookie header value, or null when unavailable.
|
||||
*/
|
||||
export async function getMimoAccountCookie(providerSpecificData = null, proxyOptions = null) {
|
||||
try {
|
||||
const { cookie } = await getServiceCookie(providerSpecificData, proxyOptions);
|
||||
return cookie;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the weekly quota from the account service.
|
||||
* @returns {Promise<{percent?:number, resetDate?:string, resetAt?:number, error?:string}>}
|
||||
*/
|
||||
export async function getMimoAccountUsage(providerSpecificData = null, proxyOptions = null) {
|
||||
const { cookie, reason } = await getServiceCookie(providerSpecificData, proxyOptions);
|
||||
if (!cookie) {
|
||||
return { error: reason === "no-pass-token" ? "no-session" : "session-failed" };
|
||||
}
|
||||
try {
|
||||
const res = await proxyAwareFetch(
|
||||
`${API_BASE}/api/user/usage`,
|
||||
{ headers: { "User-Agent": API_UA, Cookie: cookie, Accept: "application/json" }, signal: AbortSignal.timeout(10000) },
|
||||
proxyOptions,
|
||||
);
|
||||
if (!res.ok) return { error: `http-${res.status}` };
|
||||
const data = await res.json().catch(() => null);
|
||||
if (!data || data.code !== 0 || !data.data) return { error: "bad-response" };
|
||||
return { percent: data.data.percent, resetDate: data.data.resetDate, resetAt: data.data.resetAt };
|
||||
} catch (e) {
|
||||
return { error: e.message };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
/**
|
||||
* Native qodercli does NOT stuff image/PDF bytes into agent_chat_generation.
|
||||
* It PUTs them to /algo/api/v2/image/upload (COSY-signed multipart) and then
|
||||
* sends the returned OSS URL. Agents like Claude Code send OpenAI/Claude
|
||||
* data-URIs instead, which 9router previously forwarded verbatim — 10MB
|
||||
* images become 30MB+ JSON and upstream 413s even though the model window
|
||||
* is ~200k tokens.
|
||||
*
|
||||
* This module:
|
||||
* 1. Uploads inlined images to Qoder's file API (cached by sha256).
|
||||
* 2. Replaces huge non-image file blocks with a short stub.
|
||||
* 3. Caps leftover data-URIs so the chat JSON stays small.
|
||||
*/
|
||||
|
||||
import { createHash } from "crypto";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import { proxyAwareFetch } from "../../utils/proxyFetch.js";
|
||||
import { parseDataUri } from "../../translator/concerns/image.js";
|
||||
import { OPENAI_BLOCK, CLAUDE_BLOCK } from "../../translator/schema/blocks.js";
|
||||
import { MAX_IMAGE_BYTES } from "../../config/mediaConfig.js";
|
||||
import { buildCosyHeaders } from "./cosy.js";
|
||||
import {
|
||||
QODER_IMAGE_UPLOAD_SIG_PATH,
|
||||
QODER_INLINE_FALLBACK_MAX_BYTES,
|
||||
QODER_MAX_PAYLOAD_BYTES,
|
||||
qoderInferenceBase,
|
||||
} from "./constants.js";
|
||||
|
||||
const IMAGE_MIME_RE = /^image\//i;
|
||||
const DATA_URI_RE = /data:[^;]+;base64,[A-Za-z0-9+/=\s]+/g;
|
||||
|
||||
function mimeExt(mime) {
|
||||
const m = String(mime || "").toLowerCase();
|
||||
if (m.includes("png")) return "png";
|
||||
if (m.includes("jpeg") || m.includes("jpg")) return "jpg";
|
||||
if (m.includes("gif")) return "gif";
|
||||
if (m.includes("webp")) return "webp";
|
||||
if (m.includes("bmp")) return "bmp";
|
||||
if (m.includes("pdf")) return "pdf";
|
||||
return "bin";
|
||||
}
|
||||
|
||||
function decodedBytes(b64) {
|
||||
if (typeof b64 !== "string" || !b64) return 0;
|
||||
const compact = b64.replace(/\s/g, "");
|
||||
return Math.floor(compact.length * 3 / 4);
|
||||
}
|
||||
|
||||
function stubText({ name, mime, bytes, reason }) {
|
||||
const label = name || mime || "attachment";
|
||||
const size = bytes ? `, ${bytes} bytes` : "";
|
||||
return `[file omitted: ${label}${size} — ${reason}]`;
|
||||
}
|
||||
|
||||
export function buildMultipartFile(buffer, { fieldName = "file", fileName, mediaType } = {}) {
|
||||
const boundary = `----9routerQoder${Date.now().toString(16)}${Math.random().toString(16).slice(2)}`;
|
||||
const filename = fileName || `upload.${mimeExt(mediaType)}`;
|
||||
const head = Buffer.from(
|
||||
`--${boundary}\r\nContent-Disposition: form-data; name="${fieldName}"; filename="${filename}"\r\nContent-Type: ${mediaType || "application/octet-stream"}\r\n\r\n`,
|
||||
);
|
||||
const tail = Buffer.from(`\r\n--${boundary}--\r\n`);
|
||||
const body = Buffer.concat([head, buffer, tail]);
|
||||
return { boundary, body };
|
||||
}
|
||||
|
||||
function extractUrlFromUploadResponse(json) {
|
||||
if (!json || typeof json !== "object") return null;
|
||||
const result = json.result && typeof json.result === "object" ? json.result : json;
|
||||
const arrays = [result.imageUrls, result.image_urls, json.imageUrls, json.image_urls];
|
||||
for (const arr of arrays) {
|
||||
if (Array.isArray(arr) && typeof arr[0] === "string" && arr[0]) return arr[0];
|
||||
}
|
||||
const keys = ["imageUrl", "image_url", "url", "ossUrl", "oss_url", "originalUrl", "originUrl", "link", "image"];
|
||||
for (const key of keys) {
|
||||
const v = result[key] ?? json[key];
|
||||
if (typeof v === "string" && v) return v;
|
||||
}
|
||||
if (typeof json.body === "string") {
|
||||
try { return extractUrlFromUploadResponse(JSON.parse(json.body)); } catch { /* ignore */ }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function defaultUploadImage({ buffer, mediaType, credentials, proxyOptions, signal }) {
|
||||
const requestId = uuidv4();
|
||||
const url = `${qoderInferenceBase(credentials)}${`/algo${QODER_IMAGE_UPLOAD_SIG_PATH}`}?request_id=${requestId}`;
|
||||
const { boundary, body } = buildMultipartFile(buffer, {
|
||||
fileName: `image.${mimeExt(mediaType)}`,
|
||||
mediaType: mediaType || "application/octet-stream",
|
||||
});
|
||||
const psd = credentials?.providerSpecificData || {};
|
||||
const cosyHeaders = buildCosyHeaders(body, url, {
|
||||
userId: psd.userId,
|
||||
authToken: credentials.accessToken,
|
||||
name: credentials.displayName || "",
|
||||
email: credentials.email || "",
|
||||
machineId: psd.machineId || "",
|
||||
});
|
||||
const headers = {
|
||||
...cosyHeaders,
|
||||
Accept: "application/json",
|
||||
"Content-Type": `multipart/form-data; boundary=${boundary}`,
|
||||
"Content-Length": String(body.length),
|
||||
"AI-CLIENT-TIMESTAMP": String(Math.floor(Date.now() / 1000)),
|
||||
"Accept-Encoding": "identity",
|
||||
};
|
||||
const res = await proxyAwareFetch(
|
||||
url,
|
||||
{ method: "PUT", headers, body, signal },
|
||||
proxyOptions,
|
||||
);
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`HTTP ${res.status}${text ? `: ${text.slice(0, 180)}` : ""}`);
|
||||
}
|
||||
const json = await res.json().catch(() => null);
|
||||
const uploaded = extractUrlFromUploadResponse(json);
|
||||
if (!uploaded) throw new Error("upload response missing url");
|
||||
return uploaded;
|
||||
}
|
||||
|
||||
async function uploadImageData({ base64, mediaType, credentials, proxyOptions, signal, log, uploadFn, cache }) {
|
||||
const compact = String(base64 || "").replace(/\s/g, "");
|
||||
if (!compact) return null;
|
||||
const bytes = decodedBytes(compact);
|
||||
if (bytes > MAX_IMAGE_BYTES) {
|
||||
log?.warn?.("QODER", `image ${bytes} bytes exceeds upload cap, stubbing`);
|
||||
return { stub: true, bytes, mime: mediaType };
|
||||
}
|
||||
let buffer;
|
||||
try {
|
||||
buffer = Buffer.from(compact, "base64");
|
||||
} catch {
|
||||
return { stub: true, bytes, mime: mediaType };
|
||||
}
|
||||
const digest = createHash("sha256").update(buffer).digest("hex");
|
||||
if (cache?.has(digest)) return { url: cache.get(digest), bytes, mime: mediaType };
|
||||
|
||||
const doUpload = uploadFn || defaultUploadImage;
|
||||
try {
|
||||
const url = await doUpload({ buffer, mediaType, credentials, proxyOptions, signal });
|
||||
if (typeof url === "string" && url) {
|
||||
cache?.set(digest, url);
|
||||
return { url, bytes, mime: mediaType };
|
||||
}
|
||||
} catch (err) {
|
||||
log?.warn?.("QODER", `image upload failed (${err.message}); ${bytes <= QODER_INLINE_FALLBACK_MAX_BYTES ? "keeping inline" : "stubbing"}`);
|
||||
}
|
||||
if (bytes <= QODER_INLINE_FALLBACK_MAX_BYTES) return { keep: true, bytes, mime: mediaType };
|
||||
return { stub: true, bytes, mime: mediaType };
|
||||
}
|
||||
|
||||
function imageUrlBlock(url) {
|
||||
return { type: OPENAI_BLOCK.IMAGE_URL, image_url: { url } };
|
||||
}
|
||||
|
||||
async function rewriteBlock(block, ctx) {
|
||||
if (!block || typeof block !== "object") return block;
|
||||
|
||||
if (block.type === OPENAI_BLOCK.IMAGE_URL) {
|
||||
const raw = typeof block.image_url === "string" ? block.image_url : block.image_url?.url;
|
||||
if (typeof raw !== "string" || !raw) return null;
|
||||
if (raw.startsWith("http://") || raw.startsWith("https://")) return imageUrlBlock(raw);
|
||||
const parsed = parseDataUri(raw);
|
||||
if (!parsed) return { type: OPENAI_BLOCK.TEXT, text: stubText({ name: "attachment", reason: "unreadable data URI" }) };
|
||||
if (!IMAGE_MIME_RE.test(parsed.mimeType)) {
|
||||
return { type: OPENAI_BLOCK.TEXT, text: stubText({ name: "file", mime: parsed.mimeType, bytes: decodedBytes(parsed.base64), reason: "non-image bytes are not inlined into Qoder context" }) };
|
||||
}
|
||||
const up = await uploadImageData({ ...ctx, base64: parsed.base64, mediaType: parsed.mimeType });
|
||||
if (up?.url) return imageUrlBlock(up.url);
|
||||
if (up?.keep) return imageUrlBlock(raw);
|
||||
return { type: OPENAI_BLOCK.TEXT, text: stubText({ name: "image", mime: parsed.mimeType, bytes: up?.bytes, reason: "upload failed; not inlined" }) };
|
||||
}
|
||||
|
||||
if (block.type === OPENAI_BLOCK.IMAGE || block.type === CLAUDE_BLOCK.IMAGE) {
|
||||
const src = block.source || {};
|
||||
if (src.type === "url" && typeof src.url === "string") return imageUrlBlock(src.url);
|
||||
if (src.type === "base64" && src.data) {
|
||||
const mime = src.media_type || "image/png";
|
||||
const up = await uploadImageData({ ...ctx, base64: src.data, mediaType: mime });
|
||||
if (up?.url) return imageUrlBlock(up.url);
|
||||
if (up?.keep) return imageUrlBlock(`data:${mime};base64,${src.data}`);
|
||||
return { type: OPENAI_BLOCK.TEXT, text: stubText({ name: "image", mime, bytes: up?.bytes, reason: "upload failed; not inlined" }) };
|
||||
}
|
||||
}
|
||||
|
||||
if (block.type === OPENAI_BLOCK.FILE && block.file) {
|
||||
const file = block.file;
|
||||
const name = file.filename || file.name || "file";
|
||||
const dataUri = typeof file.file_data === "string" ? file.file_data : null;
|
||||
const parsed = dataUri ? parseDataUri(dataUri) : null;
|
||||
const b64 = parsed?.base64 || (typeof file.file_data === "string" && !file.file_data.startsWith("data:") ? file.file_data : null);
|
||||
const mime = parsed?.mimeType || file.format || "application/octet-stream";
|
||||
if (b64 && IMAGE_MIME_RE.test(mime)) {
|
||||
const up = await uploadImageData({ ...ctx, base64: b64, mediaType: mime });
|
||||
if (up?.url) return imageUrlBlock(up.url);
|
||||
}
|
||||
return { type: OPENAI_BLOCK.TEXT, text: stubText({ name, mime, bytes: decodedBytes(b64 || ""), reason: "Qoder reads documents via its file API, not inlined bytes" }) };
|
||||
}
|
||||
|
||||
if (block.type === CLAUDE_BLOCK.DOCUMENT && block.source) {
|
||||
const src = block.source;
|
||||
const name = block.title || "document";
|
||||
if (src.type === "base64" && src.data) {
|
||||
const mime = src.media_type || "application/pdf";
|
||||
if (IMAGE_MIME_RE.test(mime)) {
|
||||
const up = await uploadImageData({ ...ctx, base64: src.data, mediaType: mime });
|
||||
if (up?.url) return imageUrlBlock(up.url);
|
||||
}
|
||||
return { type: OPENAI_BLOCK.TEXT, text: stubText({ name, mime, bytes: decodedBytes(src.data), reason: "Qoder reads documents via its file API, not inlined bytes" }) };
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof block.text === "string" && block.text.includes("data:") && block.text.length > 8192) {
|
||||
const next = block.text.replace(DATA_URI_RE, (m) => {
|
||||
const parsed = parseDataUri(m.trim());
|
||||
const bytes = parsed ? decodedBytes(parsed.base64) : m.length;
|
||||
if (bytes <= QODER_INLINE_FALLBACK_MAX_BYTES) return m;
|
||||
return stubText({ mime: parsed?.mimeType, bytes, reason: "inlined data URI stripped from Qoder context" });
|
||||
});
|
||||
return { ...block, text: next };
|
||||
}
|
||||
|
||||
return block;
|
||||
}
|
||||
|
||||
async function rewriteContent(content, ctx) {
|
||||
if (typeof content === "string") {
|
||||
if (content.includes("data:") && content.length > 8192) {
|
||||
return content.replace(DATA_URI_RE, (m) => {
|
||||
const parsed = parseDataUri(m.trim());
|
||||
const bytes = parsed ? decodedBytes(parsed.base64) : m.length;
|
||||
if (bytes <= QODER_INLINE_FALLBACK_MAX_BYTES) return m;
|
||||
return stubText({ mime: parsed?.mimeType, bytes, reason: "inlined data URI stripped from Qoder context" });
|
||||
});
|
||||
}
|
||||
return content;
|
||||
}
|
||||
if (!Array.isArray(content)) return content;
|
||||
const out = [];
|
||||
for (const block of content) {
|
||||
const next = await rewriteBlock(block, ctx);
|
||||
if (next == null) continue;
|
||||
out.push(next);
|
||||
}
|
||||
return out.length ? out : "";
|
||||
}
|
||||
|
||||
function payloadBytes(messages) {
|
||||
try {
|
||||
return Buffer.byteLength(JSON.stringify(messages), "utf8");
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function stripRemainingDataUris(messages) {
|
||||
for (const msg of messages || []) {
|
||||
if (typeof msg?.content === "string" && msg.content.includes("data:")) {
|
||||
msg.content = msg.content.replace(DATA_URI_RE, (m) =>
|
||||
stubText({ bytes: m.length, reason: "payload over Qoder size budget" }),
|
||||
);
|
||||
} else if (Array.isArray(msg?.content)) {
|
||||
msg.content = msg.content.map((block) => {
|
||||
if (block?.type === OPENAI_BLOCK.IMAGE_URL) {
|
||||
const raw = typeof block.image_url === "string" ? block.image_url : block.image_url?.url;
|
||||
if (typeof raw === "string" && raw.startsWith("data:")) {
|
||||
return { type: OPENAI_BLOCK.TEXT, text: stubText({ name: "image", reason: "payload over Qoder size budget" }) };
|
||||
}
|
||||
}
|
||||
if (typeof block?.text === "string" && block.text.includes("data:")) {
|
||||
return { ...block, text: block.text.replace(DATA_URI_RE, (m) =>
|
||||
stubText({ bytes: m.length, reason: "payload over Qoder size budget" }),
|
||||
) };
|
||||
}
|
||||
return block;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite OpenAI-shaped messages in place: upload images, stub huge files.
|
||||
* @returns {Promise<{imageUrls: string[], uploaded: number, stubbed: number}>}
|
||||
*/
|
||||
export async function rewriteQoderMessageAttachments(messages, {
|
||||
credentials,
|
||||
log,
|
||||
proxyOptions = null,
|
||||
signal = null,
|
||||
uploadFn = null,
|
||||
} = {}) {
|
||||
const stats = { imageUrls: [], uploaded: 0, stubbed: 0 };
|
||||
if (!Array.isArray(messages) || messages.length === 0) return stats;
|
||||
|
||||
const ctx = { credentials, log, proxyOptions, signal, uploadFn, cache: new Map() };
|
||||
|
||||
for (const msg of messages) {
|
||||
if (!msg || typeof msg !== "object") continue;
|
||||
if (Array.isArray(msg.images)) {
|
||||
// Ollama-style sidecar; fold into content so normalizeMessages can see them.
|
||||
const extras = msg.images.map((url) => imageUrlBlock(String(url)));
|
||||
msg.content = Array.isArray(msg.content)
|
||||
? [...msg.content, ...extras]
|
||||
: [{ type: OPENAI_BLOCK.TEXT, text: typeof msg.content === "string" ? msg.content : "" }, ...extras];
|
||||
delete msg.images;
|
||||
}
|
||||
msg.content = await rewriteContent(msg.content, ctx);
|
||||
}
|
||||
|
||||
// Collect surviving http(s) image URLs for callers that want image_urls.
|
||||
for (const msg of messages) {
|
||||
if (!Array.isArray(msg?.content)) continue;
|
||||
for (const block of msg.content) {
|
||||
const url = block?.type === OPENAI_BLOCK.IMAGE_URL
|
||||
? (typeof block.image_url === "string" ? block.image_url : block.image_url?.url)
|
||||
: null;
|
||||
if (typeof url === "string" && /^https?:\/\//i.test(url)) stats.imageUrls.push(url);
|
||||
if (block?.type === OPENAI_BLOCK.TEXT && typeof block.text === "string" && block.text.startsWith("[file omitted:")) stats.stubbed += 1;
|
||||
}
|
||||
}
|
||||
stats.uploaded = stats.imageUrls.length;
|
||||
|
||||
if (payloadBytes(messages) > QODER_MAX_PAYLOAD_BYTES) {
|
||||
log?.warn?.("QODER", `request still ${payloadBytes(messages)} bytes after rewrite; stripping leftover data URIs`);
|
||||
stripRemainingDataUris(messages);
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
/** Test helper kept for callers; upload memo is now per-request. */
|
||||
export function clearQoderUploadCache() {}
|
||||
|
||||
export const __test__ = {
|
||||
extractUrlFromUploadResponse,
|
||||
decodedBytes,
|
||||
stubText,
|
||||
payloadBytes,
|
||||
};
|
||||
@@ -33,6 +33,39 @@ export const QODER_CHAT_SIG_PATH = "/api/v2/service/pro/sse/agent_chat_generatio
|
||||
export const QODER_CHAT_URL = `${QODER_CHAT_BASE}/algo${QODER_CHAT_SIG_PATH}?FetchKeys=llm_model_result&AgentId=agent_common`;
|
||||
export const QODER_CHAT_URL_ENCODED = `${QODER_CHAT_URL}&Encode=1`;
|
||||
export const QODER_MODEL_LIST_URL = `${QODER_CHAT_BASE}/algo/api/v2/model/list`;
|
||||
// Official qodercli uploads images here (COSY-signed PUT multipart, field "file")
|
||||
// instead of inlining base64 into agent_chat_generation.
|
||||
export const QODER_IMAGE_UPLOAD_SIG_PATH = "/api/v2/image/upload";
|
||||
|
||||
// Drop remaining inlined binaries if the Qoder JSON body would still exceed this.
|
||||
// 30MB+ payloads are what blow past Claude-Code's ~200k context on the wire.
|
||||
export const QODER_MAX_PAYLOAD_BYTES = 6 * 1024 * 1024;
|
||||
// If OSS upload fails, keep tiny data-URIs; anything larger is stubbed.
|
||||
export const QODER_INLINE_FALLBACK_MAX_BYTES = 512 * 1024;
|
||||
|
||||
// Context-window tier selection (see shared/qoder/contextTier.js). The IDE exposes the
|
||||
// model's context_config tiers (200K/400K/1M); we auto-escalate when the estimated prompt
|
||||
// (+ headroom, tokenizer variance) no longer fits the current max_input_tokens.
|
||||
export const QODER_CONTEXT_TIER_HEADROOM = 0.15;
|
||||
export const QODER_CONTEXT_TIER_ENV = "QODER_CONTEXT_TIER";
|
||||
export const QODER_CONTEXT_TIER_MODES = Object.freeze({ AUTO: "auto", MAX: "max", DEFAULT: "default" });
|
||||
|
||||
/**
|
||||
* Job-token (jt-...) traffic must hit api2.qoder.sh — api3 rejects jt- with
|
||||
* "Login expired" (403). Device tokens (dt-...) stay on api3. PATs (pt-...)
|
||||
* are exchanged for jt- before this is consulted.
|
||||
*/
|
||||
export function qoderInferenceBase(credentials) {
|
||||
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;
|
||||
}
|
||||
return QODER_CHAT_BASE;
|
||||
}
|
||||
|
||||
// COSY header constants. These are not arbitrary — the upstream signature
|
||||
// validation matches them against the values used at signing time.
|
||||
@@ -54,10 +87,13 @@ export const QODER_MODEL_MAP = {
|
||||
lite: "lite",
|
||||
// Frontier models
|
||||
qmodel: "qmodel",
|
||||
qfmodel: "qfmodel",
|
||||
qmodel_latest: "qmodel_latest",
|
||||
qmodel_38max: "qmodel_38max",
|
||||
dmodel: "dmodel",
|
||||
dfmodel: "dfmodel",
|
||||
gm51model: "gm51model",
|
||||
gmodel: "gmodel",
|
||||
gfmodel: "gfmodel",
|
||||
kmodel: "kmodel",
|
||||
mmodel: "mmodel",
|
||||
};
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Qoder context-window tiers.
|
||||
*
|
||||
* Each Qoder model_config ships a `context_config` list (e.g. 200K / 400K / 1M for
|
||||
* qmodel_38max) while `max_input_tokens` only carries the tier the IDE currently has
|
||||
* selected (~180K by default). The Qoder IDE lets the user switch tiers from the model
|
||||
* picker; a qodercli-style client (which is what 9router impersonates) has no picker,
|
||||
* so a long Claude-Code / Codex session that grew past the default tier is rejected
|
||||
* upstream even though the model itself supports 1M.
|
||||
*
|
||||
* This module emulates the IDE: estimate the prompt size, pick the smallest advertised
|
||||
* tier that fits (never below the model's current default), and mirror the choice into
|
||||
* the same three places the IDE writes:
|
||||
* parameters.context_length
|
||||
* chat_context.extra.ideModelConfigOverride.max_input_tokens
|
||||
* model_config.max_input_tokens
|
||||
*
|
||||
* Override with QODER_CONTEXT_TIER = auto (default) | max | default | <tier name, e.g. 1M>.
|
||||
* Pure functions, no I/O — the executor wires them into buildQoderRequestBody.
|
||||
*/
|
||||
|
||||
import { QODER_CONTEXT_TIER_HEADROOM, QODER_CONTEXT_TIER_MODES } from "./constants.js";
|
||||
|
||||
const UNIT = { K: 1_000, M: 1_000_000 };
|
||||
|
||||
/** "200K" | "1M" | "204800" | 204800 → integer token count (0 when unparseable). */
|
||||
export function parseTierTokenCount(value) {
|
||||
if (typeof value === "number") return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
|
||||
if (typeof value !== "string") return 0;
|
||||
const m = value.trim().toUpperCase().match(/^(\d+(?:\.\d+)?)\s*([KM])?$/);
|
||||
if (!m) return 0;
|
||||
const n = Number(m[1]) * (UNIT[m[2]] || 1);
|
||||
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0;
|
||||
}
|
||||
|
||||
function tierName(entry, tokenCount) {
|
||||
const raw = entry.name ?? entry.label ?? entry.display_name ?? entry.displayName ?? entry.key ?? entry.id;
|
||||
if (typeof raw === "string" && raw.trim()) return raw.trim();
|
||||
if (tokenCount >= UNIT.M && tokenCount % UNIT.M === 0) return `${tokenCount / UNIT.M}M`;
|
||||
if (tokenCount >= UNIT.K && tokenCount % UNIT.K === 0) return `${tokenCount / UNIT.K}K`;
|
||||
return String(tokenCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a model_config into sorted tiers: [{ name, tokenCount, isDefault }] ascending.
|
||||
* Accepts snake_case and camelCase shapes; returns [] when the model has no tiers.
|
||||
*/
|
||||
export function getQoderContextTiers(modelConfig) {
|
||||
const list = modelConfig?.context_config ?? modelConfig?.contextConfig;
|
||||
if (!Array.isArray(list)) return [];
|
||||
const byCount = new Map();
|
||||
for (const entry of list) {
|
||||
if (!entry || typeof entry !== "object") continue;
|
||||
const tokenCount = parseTierTokenCount(
|
||||
entry.tokenCount ?? entry.token_count ?? entry.max_input_tokens ?? entry.maxInputTokens ?? entry.contextLength ?? entry.context_length,
|
||||
);
|
||||
if (!tokenCount) continue;
|
||||
const isDefault = entry.isDefault === true || entry.is_default === true || entry.default === true;
|
||||
const prev = byCount.get(tokenCount);
|
||||
byCount.set(tokenCount, {
|
||||
name: tierName(entry, tokenCount),
|
||||
tokenCount,
|
||||
isDefault: (prev?.isDefault || false) || isDefault,
|
||||
});
|
||||
}
|
||||
return [...byCount.values()].sort((a, b) => a.tokenCount - b.tokenCount);
|
||||
}
|
||||
|
||||
const CJK_RE = /[\u1100-\u11ff\u2e80-\u9fff\uac00-\ud7af\uf900-\ufaff\uff00-\uffef]/g;
|
||||
|
||||
/**
|
||||
* Rough prompt-size estimate in tokens. CJK characters count ~1 token each, everything
|
||||
* else ~4 chars/token — the plain chars/4 rule underestimates Chinese/Japanese by up to
|
||||
* 4x, which is exactly when a tier decision matters.
|
||||
*/
|
||||
export function estimateQoderPromptTokens({ system, messages, tools } = {}) {
|
||||
let text = "";
|
||||
try {
|
||||
text = JSON.stringify({ system: system || "", messages: messages || [], tools: tools || [] }) || "";
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
const cjk = (text.match(CJK_RE) || []).length;
|
||||
return Math.ceil(cjk + (text.length - cjk) / 4);
|
||||
}
|
||||
|
||||
function normalizeMode(preference) {
|
||||
const p = String(preference ?? "").trim();
|
||||
return p ? p : QODER_CONTEXT_TIER_MODES.AUTO;
|
||||
}
|
||||
|
||||
function findNamedTier(tiers, name) {
|
||||
const wanted = name.replace(/\s+/g, "").toUpperCase();
|
||||
const asCount = parseTierTokenCount(wanted);
|
||||
return tiers.find((t) => t.name.replace(/\s+/g, "").toUpperCase() === wanted || (asCount && t.tokenCount === asCount)) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide which tier a request should run under.
|
||||
*
|
||||
* @param {object} modelConfig raw Qoder model_config (has context_config + max_input_tokens)
|
||||
* @param {{system?: string, messages?: any[], tools?: any[]}} prompt what will be sent
|
||||
* @param {{preference?: string, headroom?: number}} [options]
|
||||
* @returns {{ tier: {name, tokenCount, isDefault}, estimatedTokens: number, reason: string } | null}
|
||||
* null → leave the payload exactly as before (no tiers, or the default already fits).
|
||||
*/
|
||||
export function resolveQoderContextTier(modelConfig, prompt, options = {}) {
|
||||
const tiers = getQoderContextTiers(modelConfig);
|
||||
if (!tiers.length) return null;
|
||||
|
||||
const mode = normalizeMode(options.preference);
|
||||
const largest = tiers[tiers.length - 1];
|
||||
const defaultTier = tiers.find((t) => t.isDefault) || tiers[0];
|
||||
const estimatedTokens = estimateQoderPromptTokens(prompt);
|
||||
const headroom = typeof options.headroom === "number" ? options.headroom : QODER_CONTEXT_TIER_HEADROOM;
|
||||
const need = Math.ceil(estimatedTokens * (1 + headroom));
|
||||
|
||||
if (mode.toLowerCase() === QODER_CONTEXT_TIER_MODES.MAX) {
|
||||
return { tier: largest, estimatedTokens, reason: "forced:max" };
|
||||
}
|
||||
if (mode.toLowerCase() === QODER_CONTEXT_TIER_MODES.DEFAULT) {
|
||||
return { tier: defaultTier, estimatedTokens, reason: "forced:default" };
|
||||
}
|
||||
if (mode.toLowerCase() !== QODER_CONTEXT_TIER_MODES.AUTO) {
|
||||
const named = findNamedTier(tiers, mode);
|
||||
if (named) return { tier: named, estimatedTokens, reason: `forced:${named.name}` };
|
||||
// Unknown tier name → fall through to auto rather than silently breaking requests.
|
||||
}
|
||||
|
||||
// auto: keep the upstream default (current behaviour) while the prompt fits in it.
|
||||
const currentMax = parseTierTokenCount(modelConfig?.max_input_tokens ?? modelConfig?.maxInputTokens);
|
||||
const currentLimit = currentMax || defaultTier.tokenCount;
|
||||
if (need <= currentLimit) return null;
|
||||
|
||||
const fits = tiers.find((t) => t.tokenCount >= need && t.tokenCount > currentLimit);
|
||||
const tier = fits || largest;
|
||||
if (tier.tokenCount <= currentLimit) return null; // nothing bigger to escalate to
|
||||
return { tier, estimatedTokens, reason: fits ? "auto:fits" : "auto:largest" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the chosen tier into a Qoder chat payload (mutates + returns it).
|
||||
* Mirrors the IDE: parameters.context_length, ideModelConfigOverride, model_config.
|
||||
*/
|
||||
export function applyQoderContextTier(payload, tier) {
|
||||
if (!payload || !tier?.tokenCount) return payload;
|
||||
payload.parameters = { ...(payload.parameters || {}), context_length: tier.tokenCount };
|
||||
payload.chat_context = payload.chat_context || {};
|
||||
payload.chat_context.extra = {
|
||||
...(payload.chat_context.extra || {}),
|
||||
ideModelConfigOverride: {
|
||||
...(payload.chat_context.extra?.ideModelConfigOverride || {}),
|
||||
max_input_tokens: tier.tokenCount,
|
||||
},
|
||||
};
|
||||
if (payload.model_config && typeof payload.model_config === "object") {
|
||||
payload.model_config = { ...payload.model_config, max_input_tokens: tier.tokenCount };
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* Qoder SSE is OpenAI-shaped inside `{statusCodeValue, body}` envelopes, but
|
||||
* usage arrives on a later `choices: []` frame — after finish_reason, which
|
||||
* itself often lives on `delta.finish_reason` rather than the choice.
|
||||
*
|
||||
* Downstream (Claude translator, OpenAI clients, Claude Code) look for usage
|
||||
* on the finish chunk or drop `choices: []` entirely. 9router's own dashboard
|
||||
* still sees tokens because extractUsage runs on every forwarded frame.
|
||||
*
|
||||
* Coalesce: hold empty finish + usage-only frames, then emit one OpenAI
|
||||
* include_usage-style chunk: `{choices:[{delta:{}, finish_reason}], usage}`.
|
||||
*/
|
||||
|
||||
function num(v) {
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize Qoder/OpenAI usage into the shape stream.js + Claude translation
|
||||
* already understand (prompt_tokens + prompt_tokens_details.cached_tokens).
|
||||
*/
|
||||
export function canonicalizeQoderUsage(usage) {
|
||||
if (!usage || typeof usage !== "object" || Array.isArray(usage)) return null;
|
||||
|
||||
const prompt = num(usage.prompt_tokens ?? usage.input_tokens);
|
||||
const completion = num(usage.completion_tokens ?? usage.output_tokens);
|
||||
if (prompt == null && completion == null) return null;
|
||||
|
||||
const details = (usage.prompt_tokens_details && typeof usage.prompt_tokens_details === "object")
|
||||
? { ...usage.prompt_tokens_details }
|
||||
: {};
|
||||
const cached = num(
|
||||
details.cached_tokens ??
|
||||
usage.cached_tokens ??
|
||||
usage.prompt_cache_hit_tokens ??
|
||||
usage.cache_read_input_tokens,
|
||||
);
|
||||
const cacheCreation = num(
|
||||
details.cache_creation_tokens ??
|
||||
usage.cache_creation_input_tokens,
|
||||
);
|
||||
|
||||
const promptTokens = prompt || 0;
|
||||
const completionTokens = completion || 0;
|
||||
const out = {
|
||||
prompt_tokens: promptTokens,
|
||||
completion_tokens: completionTokens,
|
||||
total_tokens: num(usage.total_tokens) ?? (promptTokens + completionTokens),
|
||||
};
|
||||
|
||||
if (cached != null) {
|
||||
out.cached_tokens = cached;
|
||||
details.cached_tokens = cached;
|
||||
}
|
||||
if (cacheCreation != null) {
|
||||
details.cache_creation_tokens = cacheCreation;
|
||||
}
|
||||
if (Object.keys(details).length) out.prompt_tokens_details = details;
|
||||
|
||||
if (usage.completion_tokens_details && typeof usage.completion_tokens_details === "object") {
|
||||
out.completion_tokens_details = usage.completion_tokens_details;
|
||||
}
|
||||
const reasoning = num(usage.reasoning_tokens ?? usage.completion_tokens_details?.reasoning_tokens);
|
||||
if (reasoning != null) out.reasoning_tokens = reasoning;
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function finishReasonOf(parsed) {
|
||||
const choice = parsed?.choices?.[0];
|
||||
return choice?.finish_reason || choice?.delta?.finish_reason || parsed?.finish_reason || null;
|
||||
}
|
||||
|
||||
function hasValuableDelta(parsed) {
|
||||
const delta = parsed?.choices?.[0]?.delta;
|
||||
if (!delta || typeof delta !== "object") return false;
|
||||
if (typeof delta.content === "string" && delta.content.length > 0) return true;
|
||||
if (typeof delta.reasoning_content === "string" && delta.reasoning_content.length > 0) return true;
|
||||
if (Array.isArray(delta.tool_calls) && delta.tool_calls.length > 0) return true;
|
||||
if (delta.role) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function parseInner(inner) {
|
||||
if (inner == null || inner === "") return { raw: false, parsed: null };
|
||||
if (inner === "[DONE]") return { done: true };
|
||||
if (typeof inner !== "string") {
|
||||
if (typeof inner === "object") return { parsed: inner };
|
||||
return { raw: true, text: String(inner) };
|
||||
}
|
||||
try {
|
||||
return { parsed: JSON.parse(inner) };
|
||||
} catch {
|
||||
return { raw: true, text: inner };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} opts
|
||||
* @param {string} opts.model
|
||||
* @param {TextEncoder} opts.encoder
|
||||
* @param {string} opts.sseDone "data: [DONE]\\n\\n"
|
||||
*/
|
||||
export function createQoderSseCoalescer({ model, encoder, sseDone }) {
|
||||
let pendingFinish = null;
|
||||
let pendingUsage = null;
|
||||
let lastMeta = { id: null, created: null, model };
|
||||
let doneEmitted = false;
|
||||
let finishAlreadyForwarded = false;
|
||||
|
||||
const emitJson = (controller, obj) => {
|
||||
const sanitized = JSON.stringify(obj).replace(/\r?\n/g, "");
|
||||
controller.enqueue(encoder.encode(`data: ${sanitized}\n\n`));
|
||||
};
|
||||
|
||||
const emitRaw = (controller, text) => {
|
||||
controller.enqueue(encoder.encode(`data: ${String(text).replace(/\r?\n/g, "")}\n\n`));
|
||||
};
|
||||
|
||||
const emitDone = (controller) => {
|
||||
if (doneEmitted) return;
|
||||
controller.enqueue(encoder.encode(sseDone));
|
||||
doneEmitted = true;
|
||||
};
|
||||
|
||||
const emitTerminal = (controller) => {
|
||||
if (!pendingFinish && !pendingUsage) return;
|
||||
emitJson(controller, {
|
||||
id: lastMeta.id || `qoder-${Date.now()}`,
|
||||
object: "chat.completion.chunk",
|
||||
created: lastMeta.created || Math.floor(Date.now() / 1000),
|
||||
model: lastMeta.model || model,
|
||||
choices: [{ index: 0, delta: {}, finish_reason: pendingFinish || "stop" }],
|
||||
...(pendingUsage ? { usage: pendingUsage } : {}),
|
||||
});
|
||||
pendingFinish = null;
|
||||
pendingUsage = null;
|
||||
};
|
||||
|
||||
const flush = (controller) => {
|
||||
if (doneEmitted) return;
|
||||
if (pendingUsage || (pendingFinish && !finishAlreadyForwarded)) {
|
||||
emitTerminal(controller);
|
||||
}
|
||||
emitDone(controller);
|
||||
};
|
||||
|
||||
const handleInner = (inner, controller) => {
|
||||
if (doneEmitted) return { terminal: true };
|
||||
|
||||
const parsedInner = parseInner(inner);
|
||||
if (parsedInner.done) {
|
||||
flush(controller);
|
||||
return { terminal: true };
|
||||
}
|
||||
if (parsedInner.raw) {
|
||||
emitRaw(controller, parsedInner.text);
|
||||
return {};
|
||||
}
|
||||
const parsed = parsedInner.parsed;
|
||||
if (!parsed || typeof parsed !== "object") return {};
|
||||
|
||||
if (typeof parsed.id === "string" && parsed.id) lastMeta.id = parsed.id;
|
||||
if (typeof parsed.created === "number") lastMeta.created = parsed.created;
|
||||
if (typeof parsed.model === "string" && parsed.model) lastMeta.model = parsed.model;
|
||||
|
||||
const usage = canonicalizeQoderUsage(parsed.usage);
|
||||
if (usage) pendingUsage = usage;
|
||||
|
||||
const finish = finishReasonOf(parsed);
|
||||
if (hasValuableDelta(parsed)) {
|
||||
// Stream content as-is (preserves upstream JSON for tests/clients).
|
||||
emitRaw(controller, typeof inner === "string" ? inner : JSON.stringify(parsed));
|
||||
if (finish) {
|
||||
finishAlreadyForwarded = true;
|
||||
// Keep finish around only if we still need a usage trailer.
|
||||
pendingFinish = pendingUsage ? finish : null;
|
||||
}
|
||||
if (pendingFinish && pendingUsage) {
|
||||
emitTerminal(controller);
|
||||
emitDone(controller);
|
||||
return { terminal: true };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
if (finish) pendingFinish = finish;
|
||||
|
||||
// Empty finish and/or usage-only: emit as soon as we have both (Qoder
|
||||
// order is finish then usage). Don't wait for the later [DONE]/keepalive.
|
||||
if ((pendingFinish || finishAlreadyForwarded) && pendingUsage) {
|
||||
if (!pendingFinish) pendingFinish = "stop";
|
||||
emitTerminal(controller);
|
||||
emitDone(controller);
|
||||
return { terminal: true };
|
||||
}
|
||||
return {};
|
||||
};
|
||||
|
||||
return {
|
||||
handleInner,
|
||||
flush,
|
||||
get doneEmitted() {
|
||||
return doneEmitted;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -112,7 +112,14 @@ export function parseZedCallbackPayload(input) {
|
||||
url = new URL(raw);
|
||||
} catch {
|
||||
try {
|
||||
url = new URL(`http://127.0.0.1/?${raw.replace(/^\?/, "")}`);
|
||||
// Accept pathname+query (what the local proxy forwards, e.g.
|
||||
// "/?user_id=..&access_token=.." or "/callback?.."), a bare query,
|
||||
// or a lone query string. Only the query part is parsed — a leading
|
||||
// path must never become part of the first parameter name.
|
||||
const query = raw.includes("?")
|
||||
? raw.slice(raw.indexOf("?") + 1)
|
||||
: raw.replace(/^\?/, "");
|
||||
url = new URL(`http://127.0.0.1/?${query}`);
|
||||
} catch {
|
||||
throw new Error("Invalid Zed callback URL");
|
||||
}
|
||||
@@ -134,6 +141,10 @@ export function parseZedCallbackPayload(input) {
|
||||
export function decryptZedAccessToken(encryptedAccessToken, privateKeyVerifier) {
|
||||
const privateKey = decodeZedPrivateKeyVerifier(privateKeyVerifier);
|
||||
const encrypted = Buffer.from(String(encryptedAccessToken), "base64url");
|
||||
const fail = (oaepError) => {
|
||||
const message = oaepError instanceof Error ? oaepError.message : String(oaepError);
|
||||
throw new Error(`Failed to decrypt Zed access token: ${message}`);
|
||||
};
|
||||
try {
|
||||
return crypto
|
||||
.privateDecrypt(
|
||||
@@ -143,15 +154,21 @@ export function decryptZedAccessToken(encryptedAccessToken, privateKeyVerifier)
|
||||
.toString("utf8");
|
||||
} catch (oaepError) {
|
||||
try {
|
||||
return crypto
|
||||
const text = crypto
|
||||
.privateDecrypt(
|
||||
{ key: privateKey, padding: crypto.constants.RSA_PKCS1_PADDING },
|
||||
encrypted,
|
||||
)
|
||||
.toString("utf8");
|
||||
} catch {
|
||||
const message = oaepError instanceof Error ? oaepError.message : String(oaepError);
|
||||
throw new Error(`Failed to decrypt Zed access token: ${message}`);
|
||||
// PKCS#1 v1.5 unpadding is not integrity-checked: a wrong-key decrypt
|
||||
// can "succeed" with garbage bytes instead of throwing. Replacement
|
||||
// characters prove the output is not the real UTF-8 token — fail loudly
|
||||
// rather than storing garbage as a credential.
|
||||
if (text.includes("�")) fail(oaepError);
|
||||
return text;
|
||||
} catch (err) {
|
||||
if (err.message.startsWith("Failed to decrypt Zed access token")) throw err;
|
||||
fail(oaepError);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -280,6 +297,7 @@ export async function fetchZedLlmToken(credentials, options = {}) {
|
||||
body: JSON.stringify({ organization_id: organizationId }),
|
||||
signal: options.signal ?? undefined,
|
||||
},
|
||||
options.proxyOptions ?? null,
|
||||
);
|
||||
const token =
|
||||
typeof data?.token === "string" ? data.token : data?.token?.[0] || data?.token?.value;
|
||||
|
||||
Reference in New Issue
Block a user