merge: integrate origin/master (v0.5.50) into gitea/new_feature

- Resolve conflicts in chatCore handlers: keep apiKey/streamErrorPatterns
  from the details-filters feature, adopt origin's stripContinuityFields,
  customToolNames, cache-inclusive usage accounting, and Responses-API
  SSE→JSON conversion
- Adopt origin's provider usage handlers (codebuddy-intl, qoder creds)
  and modality detection (audio/video inputs)
- Keep requestDetails apiKey column (schema v2) + masked key persistence

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
This commit is contained in:
2026-08-06 09:54:31 +07:00
180 changed files with 8622 additions and 6233 deletions

View File

@@ -33,13 +33,11 @@ import { FETCH_CONNECT_TIMEOUT_MS } from "../config/runtimeConfig.js";
import { resolveProviderTimeoutMs } from "../services/providerTimeout.js";
import {
QODER_CHAT_URL_ENCODED,
QODER_JOB_TOKEN_EXCHANGE_URL,
QODER_USERINFO_URL,
QODER_CHAT_BASE_ALT,
QODER_CHAT_SIG_PATH,
QODER_MODEL_MAP,
QODER_IDE_VERSION,
QODER_CLIENT_TYPE,
} from "../shared/qoder/constants.js";
import { getQoderModelConfig, resolveQoderModels } from "../services/qoderModels.js";
import { getQoderModelConfig, resolveQoderModels, isQoderPat, resolveQoderCredentials } from "../services/qoderModels.js";
/**
* Hoist role:"system" messages out of the messages array (Qoder rejects
@@ -343,98 +341,18 @@ 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);
}
buildUrl() {
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;
}
@@ -444,36 +362,24 @@ export class QoderExecutor extends BaseExecutor {
// - COSY headers built from the *encoded* body bytes
// - response stream re-wrapped from {statusCodeValue, body} to OpenAI SSE
async execute({ model, body, stream, credentials, signal, log, proxyOptions = null }) {
const url = this.buildUrl();
// PAT (pt-...) → exchange for short-lived job token + resolve userId so
// downstream COSY signing + catalog fetch work. Device tokens (dt-...) and
// job tokens (jt-...) skip this and are used directly.
const rawToken = credentials?.apiKey || credentials?.accessToken;
if (isQoderPat(rawToken)) {
try {
const resolved = await resolvePatCredential(rawToken, proxyOptions, signal);
credentials = {
...credentials,
accessToken: resolved.accessToken,
apiKey: undefined,
providerSpecificData: {
authMethod: "pat",
...(credentials?.providerSpecificData || {}),
userId: resolved.userId || credentials?.providerSpecificData?.userId || "",
machineId: credentials?.providerSpecificData?.machineId || "",
},
};
credentials = await resolveQoderCredentials(credentials, proxyOptions, signal);
} 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 };
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
@@ -591,6 +497,4 @@ export const __test__ = {
normalizeMessages,
wrapQoderSSE,
buildQoderRequestBody,
isQoderPat,
resolvePatCredential,
};