ldrouter 1.16.5 → 1.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/README.md +17 -0
- package/dist/server/db/repositories/codex-accounts.js +15 -3
- package/dist/server/db/repositories/qoder-accounts.js +167 -0
- package/dist/server/db/schema.js +33 -1
- package/dist/server/gateway/runner.js +73 -4
- package/dist/server/providers/codex-refresh.js +5 -2
- package/dist/server/providers/codex.js +53 -19
- package/dist/server/providers/index.js +4 -0
- package/dist/server/providers/pooled.js +84 -0
- package/dist/server/providers/qoder/catalog.js +138 -0
- package/dist/server/providers/qoder/client.js +248 -0
- package/dist/server/providers/qoder/constants.js +34 -0
- package/dist/server/providers/qoder/cosy.js +81 -0
- package/dist/server/providers/qoder/credentials.js +125 -0
- package/dist/server/providers/qoder/encode.js +28 -0
- package/dist/server/providers/qoder/qoder-import.js +109 -0
- package/dist/server/providers/qoder/rsa.js +7 -0
- package/dist/server/providers/qoder/sse.js +211 -0
- package/dist/server/providers/qoder/tier.js +105 -0
- package/dist/server/routes/admin/backup.js +7 -2
- package/dist/server/routes/admin/models.js +14 -0
- package/dist/server/routes/admin/providers.js +53 -34
- package/dist/server/routes/admin/qoder.js +271 -0
- package/dist/server/routes/admin.js +8 -1
- package/dist/server/routing/capabilities.js +1 -0
- package/dist/server/routing/combo.js +22 -3
- package/dist/server/upstream/client.js +14 -0
- package/dist/web/assets/index-DdtyoPpS.js +391 -0
- package/dist/web/index.html +1 -1
- package/migrations/0007_qoder_accounts.sql +217 -0
- package/package.json +1 -1
- package/dist/web/assets/index-CkDJqDcS.js +0 -391
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// Qoder credential seam: turns an account row plus its PAT into a short-lived job token the
|
|
2
|
+
// chat client can sign with, and classifies attempt failures back onto account health.
|
|
3
|
+
//
|
|
4
|
+
// A PAT (`pt-…`) cannot be used directly — it is exchanged at openapi for a job token (`jt-…`).
|
|
5
|
+
// The exchange is coalesced per account so a burst of requests does not stampede the upstream,
|
|
6
|
+
// exactly like codex-refresh.ts's flight map.
|
|
7
|
+
import { GatewayError } from '../../errors.js';
|
|
8
|
+
import { upstreamHttpError } from '../../upstream/client.js';
|
|
9
|
+
import { getQoderAccountDetailById, getQoderAccountRefreshState, getQoderCredentials, persistQoderJobToken, readQoderCatalog, setQoderAccountHealth, } from '../../db/repositories/qoder-accounts.js';
|
|
10
|
+
import { deserializeCatalog, exchangeQoderPat, fetchQoderCatalog, serializeCatalog } from './catalog.js';
|
|
11
|
+
/** Refresh this far ahead of expiry so an in-flight request never crosses the boundary. */
|
|
12
|
+
const REFRESH_LEAD_MS = 5 * 60 * 1000;
|
|
13
|
+
const EXCHANGE_TIMEOUT_MS = 15_000;
|
|
14
|
+
const flights = new Map();
|
|
15
|
+
function needsRefresh(jobTokenExpiresAt, now) {
|
|
16
|
+
const expires = Date.parse(jobTokenExpiresAt);
|
|
17
|
+
return !Number.isFinite(expires) || expires - now.getTime() <= REFRESH_LEAD_MS;
|
|
18
|
+
}
|
|
19
|
+
function readCatalog(accountRecordId) {
|
|
20
|
+
const row = readQoderCatalog(accountRecordId);
|
|
21
|
+
return row?.catalogJson ? deserializeCatalog(row.catalogJson) : null;
|
|
22
|
+
}
|
|
23
|
+
async function resolveCredentials(accountRecordId, force, now) {
|
|
24
|
+
let state;
|
|
25
|
+
let credentials;
|
|
26
|
+
let detail;
|
|
27
|
+
try {
|
|
28
|
+
state = getQoderAccountRefreshState(accountRecordId);
|
|
29
|
+
detail = getQoderAccountDetailById(accountRecordId);
|
|
30
|
+
credentials = state ? getQoderCredentials(accountRecordId) : null;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
// Never surface the underlying error: a decryption failure message can echo ciphertext.
|
|
34
|
+
return { ok: false, error: 'credential_unavailable' };
|
|
35
|
+
}
|
|
36
|
+
if (!state || !credentials || !detail)
|
|
37
|
+
return { ok: false, error: 'account_not_found' };
|
|
38
|
+
const base = { qoderUserId: detail.qoderUserId, machineId: detail.machineId, email: detail.email, label: detail.label };
|
|
39
|
+
if (!force && !needsRefresh(state.jobTokenExpiresAt, now)) {
|
|
40
|
+
return { ok: true, config: { ...base, jobToken: credentials.jobToken, catalog: readCatalog(accountRecordId) } };
|
|
41
|
+
}
|
|
42
|
+
// The PAT is the durable credential; the job token is derived and disposable. A fresh catalog
|
|
43
|
+
// is fetched on the same exchange because the model list is served to the same token.
|
|
44
|
+
const controller = new AbortController();
|
|
45
|
+
const timer = setTimeout(() => controller.abort(), EXCHANGE_TIMEOUT_MS);
|
|
46
|
+
try {
|
|
47
|
+
const exchanged = await exchangeQoderPat(credentials.personalToken, { signal: controller.signal });
|
|
48
|
+
const catalog = await fetchQoderCatalog({ jobToken: exchanged.jobToken, userId: exchanged.qoderUserId || detail.qoderUserId, machineId: detail.machineId }, { signal: controller.signal }).catch(() => null);
|
|
49
|
+
persistQoderJobToken(accountRecordId, {
|
|
50
|
+
jobToken: exchanged.jobToken,
|
|
51
|
+
expiresAt: exchanged.jobTokenExpiresAt,
|
|
52
|
+
...(catalog ? { catalogJson: serializeCatalog(catalog), catalogFetchedAt: catalog.fetchedAt } : {}),
|
|
53
|
+
});
|
|
54
|
+
return {
|
|
55
|
+
ok: true,
|
|
56
|
+
config: {
|
|
57
|
+
qoderUserId: exchanged.qoderUserId || base.qoderUserId,
|
|
58
|
+
machineId: base.machineId,
|
|
59
|
+
jobToken: exchanged.jobToken,
|
|
60
|
+
catalog: catalog ?? readCatalog(accountRecordId),
|
|
61
|
+
email: exchanged.email ?? base.email,
|
|
62
|
+
label: exchanged.label ?? base.label,
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
// A rejected PAT is durable: the operator must replace it, so stop routing to this account.
|
|
68
|
+
setQoderAccountHealth(accountRecordId, 'down', 'personal access token rejected — replace it');
|
|
69
|
+
return { ok: false, error: 'exchange_failed' };
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
clearTimeout(timer);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function toResult(result) {
|
|
76
|
+
if (result.ok)
|
|
77
|
+
return { config: result.config };
|
|
78
|
+
if (result.error === 'account_not_found')
|
|
79
|
+
throw new GatewayError('invalid_request_error', 'Qoder account not found', { status: 404 });
|
|
80
|
+
throw new GatewayError('authentication_error', 'Qoder credentials could not be refreshed — replace the personal access token', { status: 401 });
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Resolve a usable job token for an account, refreshing when it is inside the lead window or when
|
|
84
|
+
* `force` is set. Concurrent callers share one exchange.
|
|
85
|
+
*/
|
|
86
|
+
export async function qoderCredentialsFor(accountRecordId, options = {}) {
|
|
87
|
+
const force = options.force === true;
|
|
88
|
+
const now = options.now ?? new Date();
|
|
89
|
+
const key = `${accountRecordId}:${force ? 'force' : 'soft'}`;
|
|
90
|
+
const existing = flights.get(key);
|
|
91
|
+
if (existing)
|
|
92
|
+
return toResult(await existing);
|
|
93
|
+
const flight = resolveCredentials(accountRecordId, force, now).finally(() => flights.delete(key));
|
|
94
|
+
flights.set(key, flight);
|
|
95
|
+
return toResult(await flight);
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Classify a Qoder attempt failure. A rejected or expired PAT marks the account down so routing
|
|
99
|
+
* skips it; a billing wall only degrades it. Both keep the credential out of the error, and both
|
|
100
|
+
* fail the attempt so combo fallback proceeds.
|
|
101
|
+
*/
|
|
102
|
+
export async function qoderAttemptFailure(accountRecordId, error) {
|
|
103
|
+
const status = error.status ?? 502;
|
|
104
|
+
const billing = Boolean(error.billing);
|
|
105
|
+
if (status === 401 || status === 403) {
|
|
106
|
+
// The PAT may still be valid — the job token may merely have expired mid-request. Force one
|
|
107
|
+
// exchange before condemning the account; a second rejection is the PAT's fault.
|
|
108
|
+
const retried = await qoderCredentialsFor(accountRecordId, { force: true }).catch(() => null);
|
|
109
|
+
if (!retried) {
|
|
110
|
+
setQoderAccountHealth(accountRecordId, 'down', 'personal access token rejected — replace it');
|
|
111
|
+
throw new GatewayError('upstream_auth_error', 'Qoder account credentials were rejected — replace the personal access token', { status: 502, code: 'qoder_auth_failed' });
|
|
112
|
+
}
|
|
113
|
+
throw new GatewayError('upstream_auth_error', 'Qoder job token was rejected after a refresh', { status: 502, code: 'qoder_auth_failed' });
|
|
114
|
+
}
|
|
115
|
+
if (billing) {
|
|
116
|
+
setQoderAccountHealth(accountRecordId, 'degraded', 'upstream reported a quota or billing block');
|
|
117
|
+
throw new GatewayError('upstream_error', 'Qoder account is out of quota', { status: 502, code: 'qoder_billing_block' });
|
|
118
|
+
}
|
|
119
|
+
throw upstreamHttpError(status, '', error);
|
|
120
|
+
}
|
|
121
|
+
/** Probe/discover share this: a catalog is the only proof the PAT works end to end. */
|
|
122
|
+
export async function withQoderCredentials(accountRecordId, fn) {
|
|
123
|
+
const { config } = await qoderCredentialsFor(accountRecordId);
|
|
124
|
+
return fn(config);
|
|
125
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Qoder request-body obfuscation for `&Encode=1`: standard base64, split into thirds
|
|
2
|
+
// and reordered [tail][mid][head], then mapped through a custom 64-char alphabet.
|
|
3
|
+
// Second-order WAF evasion, not security: the transform is public and reversible.
|
|
4
|
+
// Pure and log-free — the plaintext is request content, so never echo it.
|
|
5
|
+
const STD_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
|
6
|
+
const CUSTOM_ALPHABET = '_doRTgHZBKcGVjlvpC,@aFSx#DPuNJme&i*MzLOEn)sUrthbf%Y^w.(kIQyXqWA!';
|
|
7
|
+
const S2C = (() => {
|
|
8
|
+
const table = new Int16Array(128).fill(-1);
|
|
9
|
+
for (let i = 0; i < 64; i += 1)
|
|
10
|
+
table[STD_ALPHABET.charCodeAt(i)] = CUSTOM_ALPHABET.charCodeAt(i);
|
|
11
|
+
table['='.charCodeAt(0)] = '$'.charCodeAt(0);
|
|
12
|
+
return table;
|
|
13
|
+
})();
|
|
14
|
+
/** Encode a request body so the upstream can decode it with `&Encode=1`. */
|
|
15
|
+
export function encodeQoderBody(plaintext) {
|
|
16
|
+
const buf = Buffer.isBuffer(plaintext) ? plaintext : typeof plaintext === 'string' ? Buffer.from(plaintext, 'utf8') : Buffer.from(plaintext);
|
|
17
|
+
const standard = buf.toString('base64');
|
|
18
|
+
const n = standard.length;
|
|
19
|
+
const a = Math.floor(n / 3);
|
|
20
|
+
const rearranged = standard.slice(n - a) + standard.slice(a, n - a) + standard.slice(0, a);
|
|
21
|
+
const out = Buffer.alloc(n);
|
|
22
|
+
for (let i = 0; i < n; i += 1) {
|
|
23
|
+
const code = rearranged.charCodeAt(i);
|
|
24
|
+
const mapped = code < 128 ? S2C[code] ?? -1 : -1;
|
|
25
|
+
out[i] = mapped >= 0 ? mapped : code;
|
|
26
|
+
}
|
|
27
|
+
return out.toString('latin1');
|
|
28
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// Qoder credential import normalizer. PAT-only: a `pt-…` personal access token is the single
|
|
2
|
+
// importable credential, because it is the only one the gateway can renew itself. Device tokens
|
|
3
|
+
// (`dt-…`) expire after ~30 days with no refresh and job tokens (`jt-…`) are derived and
|
|
4
|
+
// short-lived, so both are rejected here with a reason instead of failing upstream later.
|
|
5
|
+
import { createHash } from 'node:crypto';
|
|
6
|
+
const MAX_INPUT_BYTES = 2_000_000;
|
|
7
|
+
const MAX_RECORDS = 500;
|
|
8
|
+
const MAX_TOKEN_LENGTH = 200_000;
|
|
9
|
+
const PAT_PREFIX = 'pt-';
|
|
10
|
+
const stringValue = (value) => {
|
|
11
|
+
if (typeof value !== 'string')
|
|
12
|
+
return null;
|
|
13
|
+
const result = value.replace(/^\uFEFF/, '').trim();
|
|
14
|
+
return result || null;
|
|
15
|
+
};
|
|
16
|
+
const firstString = (...values) => {
|
|
17
|
+
for (const value of values) {
|
|
18
|
+
const result = stringValue(value);
|
|
19
|
+
if (result)
|
|
20
|
+
return result;
|
|
21
|
+
}
|
|
22
|
+
return null;
|
|
23
|
+
};
|
|
24
|
+
function objectValue(value) {
|
|
25
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : null;
|
|
26
|
+
}
|
|
27
|
+
function mask(value) {
|
|
28
|
+
if (!value)
|
|
29
|
+
return null;
|
|
30
|
+
return value.length <= 8 ? `${value.slice(0, 2)}…${value.slice(-2)}` : `${value.slice(0, 4)}…${value.slice(-4)}`;
|
|
31
|
+
}
|
|
32
|
+
/** Never derived from the token itself in any recoverable way — one-way, like Codex's identity. */
|
|
33
|
+
export function qoderTokenFingerprint(personalToken) {
|
|
34
|
+
return `pat:${createHash('sha256').update(personalToken).digest('hex').slice(0, 32)}`;
|
|
35
|
+
}
|
|
36
|
+
export function isQoderPatImportable(token) {
|
|
37
|
+
return typeof token === 'string' && token.trim().startsWith(PAT_PREFIX);
|
|
38
|
+
}
|
|
39
|
+
export function normalizeQoderToken(input, index, source) {
|
|
40
|
+
const raw = objectValue(input);
|
|
41
|
+
// A bare string is the token; an object is matched against the aliases the Codex importer
|
|
42
|
+
// accepts so one export file works for either provider.
|
|
43
|
+
const token = stringValue(input) ?? (raw
|
|
44
|
+
? firstString(raw.token, raw.pt_token, raw.personal_token, raw.personalToken, raw.access_token, raw.accessToken)
|
|
45
|
+
: null);
|
|
46
|
+
if (!token)
|
|
47
|
+
return { index, source, error: 'Missing personal access token' };
|
|
48
|
+
if (token.length > MAX_TOKEN_LENGTH)
|
|
49
|
+
return { index, source, error: 'Token exceeds maximum length' };
|
|
50
|
+
if (!token.startsWith(PAT_PREFIX)) {
|
|
51
|
+
// A dt- device token or a jt- job token is not importable; saying so beats an opaque 403 later.
|
|
52
|
+
return { index, source, error: 'Not a Qoder personal access token (must start with pt-)' };
|
|
53
|
+
}
|
|
54
|
+
const label = raw ? firstString(raw.label, raw.name, raw.email) : null;
|
|
55
|
+
return { index, personalToken: token, label, source };
|
|
56
|
+
}
|
|
57
|
+
function expandRoot(value) {
|
|
58
|
+
const root = objectValue(value);
|
|
59
|
+
if (Array.isArray(value))
|
|
60
|
+
return value;
|
|
61
|
+
if (root && Array.isArray(root.accounts))
|
|
62
|
+
return root.accounts;
|
|
63
|
+
return [value];
|
|
64
|
+
}
|
|
65
|
+
export function parseQoderImportText(text, source) {
|
|
66
|
+
if (Buffer.byteLength(text, 'utf8') > MAX_INPUT_BYTES)
|
|
67
|
+
return [{ index: 0, source, error: 'Input exceeds maximum size' }];
|
|
68
|
+
const cleaned = text.replace(/^\uFEFF/, '').trim();
|
|
69
|
+
if (!cleaned)
|
|
70
|
+
return [{ index: 0, source, error: 'Input is empty' }];
|
|
71
|
+
let roots;
|
|
72
|
+
try {
|
|
73
|
+
roots = expandRoot(JSON.parse(cleaned));
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
// Plain text / JSONL: one token (or JSON object) per line. A single unparseable line is
|
|
77
|
+
// still passed through as a bare string so the pt- check reports the real reason.
|
|
78
|
+
const lines = cleaned.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
79
|
+
roots = lines.map((line) => {
|
|
80
|
+
try {
|
|
81
|
+
return JSON.parse(line);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return line;
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
const results = [];
|
|
89
|
+
for (const root of roots) {
|
|
90
|
+
for (const item of expandRoot(root)) {
|
|
91
|
+
if (results.length >= MAX_RECORDS) {
|
|
92
|
+
results.push({ index: results.length, source, error: 'Input exceeds maximum record count' });
|
|
93
|
+
return results;
|
|
94
|
+
}
|
|
95
|
+
results.push(normalizeQoderToken(item, results.length, source));
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return results;
|
|
99
|
+
}
|
|
100
|
+
export function toQoderPreview(record, duplicateOf = null) {
|
|
101
|
+
return {
|
|
102
|
+
index: record.index, source: record.source, valid: true,
|
|
103
|
+
personalTokenMasked: mask(record.personalToken) ?? '', label: record.label,
|
|
104
|
+
fingerprint: qoderTokenFingerprint(record.personalToken), duplicateOf,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
export function toQoderImportResult(records, failures = []) {
|
|
108
|
+
return { records: records.map((record) => toQoderPreview(record)), failures };
|
|
109
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** RSA public key used to wrap the per-request AES key. Not a secret. */
|
|
2
|
+
export const QODER_RSA_PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
|
|
3
|
+
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDA8iMH5c02LilrsERw9t6Pv5Nc
|
|
4
|
+
4k6Pz1EaDicBMpdpxKduSZu5OANqUq8er4GM95omAGIOPOh+Nx0spthYA2BqGz+l
|
|
5
|
+
6HRkPJ7S236FZz73In/KVuLnwI8JJ2CbuJap8kvheCCZpmAWpb/cPx/3Vr/J6I17
|
|
6
|
+
XcW+ML9FoCI6AOvOzwIDAQAB
|
|
7
|
+
-----END PUBLIC KEY-----`;
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
const BILLING_CODES = /"code"\s*:\s*"(112|10605)"/;
|
|
2
|
+
function isBilling(message) {
|
|
3
|
+
return BILLING_CODES.test(message) || message.toLowerCase().includes('pricingurl');
|
|
4
|
+
}
|
|
5
|
+
function numberOr(value, fallback) {
|
|
6
|
+
const parsed = Number(value);
|
|
7
|
+
return Number.isFinite(parsed) ? parsed : fallback;
|
|
8
|
+
}
|
|
9
|
+
export function qoderUsageOf(value) {
|
|
10
|
+
const usage = (value && typeof value === 'object' ? value : {});
|
|
11
|
+
const details = (usage.prompt_tokens_details && typeof usage.prompt_tokens_details === 'object' ? usage.prompt_tokens_details : {});
|
|
12
|
+
const input = numberOr(usage.prompt_tokens ?? usage.input_tokens, 0);
|
|
13
|
+
const output = numberOr(usage.completion_tokens ?? usage.output_tokens, 0);
|
|
14
|
+
return {
|
|
15
|
+
input, output,
|
|
16
|
+
total: numberOr(usage.total_tokens, input + output),
|
|
17
|
+
cacheRead: numberOr(details.cached_tokens ?? usage.cached_tokens ?? usage.cache_read_input_tokens, 0),
|
|
18
|
+
cacheWrite: numberOr(details.cache_creation_tokens ?? usage.cache_creation_input_tokens, 0),
|
|
19
|
+
reasoning: numberOr(usage.reasoning_tokens ?? usage.completion_tokens_details?.reasoning_tokens, 0),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
export class QoderEnvelopeReader {
|
|
23
|
+
options;
|
|
24
|
+
chunks = [];
|
|
25
|
+
toolCalls = [];
|
|
26
|
+
text = '';
|
|
27
|
+
finishReason = null;
|
|
28
|
+
usage = null;
|
|
29
|
+
error = null;
|
|
30
|
+
buffer = '';
|
|
31
|
+
ended = false;
|
|
32
|
+
pendingFinish = null;
|
|
33
|
+
pendingUsage = null;
|
|
34
|
+
finishForwarded = false;
|
|
35
|
+
meta;
|
|
36
|
+
constructor(options) {
|
|
37
|
+
this.options = options;
|
|
38
|
+
this.meta = { id: null, created: null };
|
|
39
|
+
}
|
|
40
|
+
terminal() { return this.ended; }
|
|
41
|
+
errorEnvelope() { return this.error; }
|
|
42
|
+
push(text) {
|
|
43
|
+
if (this.ended)
|
|
44
|
+
return;
|
|
45
|
+
this.buffer += text;
|
|
46
|
+
let index;
|
|
47
|
+
while ((index = this.buffer.indexOf('\n')) !== -1) {
|
|
48
|
+
const line = this.buffer.slice(0, index);
|
|
49
|
+
this.buffer = this.buffer.slice(index + 1);
|
|
50
|
+
this.line(line);
|
|
51
|
+
if (this.ended)
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/** Flush a final line that arrived without a terminating newline. */
|
|
56
|
+
finish() {
|
|
57
|
+
if (!this.ended && this.buffer.length > 0) {
|
|
58
|
+
const line = this.buffer;
|
|
59
|
+
this.buffer = '';
|
|
60
|
+
this.line(line);
|
|
61
|
+
}
|
|
62
|
+
this.flushPending();
|
|
63
|
+
}
|
|
64
|
+
cancel() { this.ended = true; this.buffer = ''; }
|
|
65
|
+
line(raw) {
|
|
66
|
+
const trimmed = raw.replace(/\r$/, '').trim();
|
|
67
|
+
if (!trimmed.startsWith('data:'))
|
|
68
|
+
return;
|
|
69
|
+
const data = trimmed.slice(5).trimStart();
|
|
70
|
+
if (data === '[DONE]') {
|
|
71
|
+
this.flushPending();
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
let envelope;
|
|
75
|
+
try {
|
|
76
|
+
envelope = JSON.parse(data);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const statusValue = numberOr(envelope.statusCodeValue, 200);
|
|
82
|
+
let inner;
|
|
83
|
+
if (typeof envelope.body === 'string') {
|
|
84
|
+
inner = envelope.body;
|
|
85
|
+
// Upstream may send `body` as a JSON-encoded string literal; unwrap that one layer.
|
|
86
|
+
if (inner.startsWith('"')) {
|
|
87
|
+
try {
|
|
88
|
+
const parsed = JSON.parse(inner);
|
|
89
|
+
if (typeof parsed === 'string')
|
|
90
|
+
inner = parsed;
|
|
91
|
+
}
|
|
92
|
+
catch { /* keep raw */ }
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
inner = envelope.body == null ? '' : JSON.stringify(envelope.body);
|
|
97
|
+
}
|
|
98
|
+
if (statusValue !== 200) {
|
|
99
|
+
this.error = { statusValue, message: inner || `upstream status ${statusValue}`, billing: isBilling(inner) };
|
|
100
|
+
this.ended = true;
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (!inner || inner === '[DONE]') {
|
|
104
|
+
this.flushPending();
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
let chunk;
|
|
108
|
+
try {
|
|
109
|
+
chunk = JSON.parse(inner);
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (typeof chunk.id === 'string' && chunk.id)
|
|
115
|
+
this.meta.id = chunk.id;
|
|
116
|
+
if (typeof chunk.created === 'number')
|
|
117
|
+
this.meta.created = chunk.created;
|
|
118
|
+
if (chunk.usage)
|
|
119
|
+
this.pendingUsage = qoderUsageOf(chunk.usage);
|
|
120
|
+
const choice = (Array.isArray(chunk.choices) ? chunk.choices[0] : null);
|
|
121
|
+
const delta = (choice?.delta ?? {});
|
|
122
|
+
const finish = (choice?.finish_reason ?? delta.finish_reason);
|
|
123
|
+
const content = typeof delta.content === 'string' ? delta.content : '';
|
|
124
|
+
const reasoning = typeof delta.reasoning_content === 'string' ? delta.reasoning_content : '';
|
|
125
|
+
const toolCalls = Array.isArray(delta.tool_calls) ? delta.tool_calls : [];
|
|
126
|
+
if (content || reasoning || toolCalls.length > 0) {
|
|
127
|
+
if (content)
|
|
128
|
+
this.text += content;
|
|
129
|
+
for (const call of toolCalls) {
|
|
130
|
+
let input = {};
|
|
131
|
+
if (typeof call.function?.arguments === 'string' && call.function.arguments) {
|
|
132
|
+
try {
|
|
133
|
+
input = JSON.parse(call.function.arguments);
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
input = {};
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
this.toolCalls.push({ id: call.id ?? `call-${this.toolCalls.length}`, name: call.function?.name ?? 'unknown', input });
|
|
140
|
+
}
|
|
141
|
+
// Pass the upstream chunk through verbatim so client-side shape is preserved.
|
|
142
|
+
this.emit(chunk);
|
|
143
|
+
// Forward the upstream finish reason verbatim; a `tool_calls` finish must survive
|
|
144
|
+
// the coalesce, so never coerce it to 'stop' here.
|
|
145
|
+
if (finish) {
|
|
146
|
+
this.finishForwarded = true;
|
|
147
|
+
this.pendingFinish = finish;
|
|
148
|
+
this.finishReason = finish;
|
|
149
|
+
}
|
|
150
|
+
this.endOnUsage();
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (finish) {
|
|
154
|
+
this.pendingFinish = finish;
|
|
155
|
+
this.finishReason = finish;
|
|
156
|
+
}
|
|
157
|
+
this.endOnUsage();
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* A usage frame terminates the stream even when no finish_reason was ever seen: usage
|
|
161
|
+
* arriving is itself the end-of-stream signal. Shared by both entry paths so they cannot
|
|
162
|
+
* drift apart. When both are present the coalesce still happens in emitTerminal().
|
|
163
|
+
*/
|
|
164
|
+
endOnUsage() {
|
|
165
|
+
if (!this.pendingUsage)
|
|
166
|
+
return;
|
|
167
|
+
if (!this.pendingFinish)
|
|
168
|
+
this.pendingFinish = 'stop';
|
|
169
|
+
this.emitTerminal();
|
|
170
|
+
}
|
|
171
|
+
emit(chunk) {
|
|
172
|
+
this.chunks.push(JSON.stringify(chunk));
|
|
173
|
+
}
|
|
174
|
+
emitTerminal() {
|
|
175
|
+
if (this.pendingFinish || this.pendingUsage) {
|
|
176
|
+
// We only reach here with finishReason still null when we synthesized 'stop' ourselves
|
|
177
|
+
// (usage-only termination). The accessor must report what went on the wire; a real
|
|
178
|
+
// upstream reason is always recorded alongside pendingFinish and is never overwritten.
|
|
179
|
+
if (!this.finishReason)
|
|
180
|
+
this.finishReason = 'stop';
|
|
181
|
+
this.usage = this.pendingUsage ?? this.usage;
|
|
182
|
+
this.chunks.push(JSON.stringify({
|
|
183
|
+
id: this.meta.id ?? `qoder-${Date.now()}`,
|
|
184
|
+
object: 'chat.completion.chunk',
|
|
185
|
+
created: this.meta.created ?? Math.floor(Date.now() / 1000),
|
|
186
|
+
model: this.options.model,
|
|
187
|
+
choices: [{ index: 0, delta: {}, finish_reason: this.pendingFinish ?? 'stop' }],
|
|
188
|
+
...(this.pendingUsage ? { usage: this.toOpenAIUsage(this.pendingUsage) } : {}),
|
|
189
|
+
}));
|
|
190
|
+
this.pendingFinish = null;
|
|
191
|
+
this.pendingUsage = null;
|
|
192
|
+
}
|
|
193
|
+
this.ended = true;
|
|
194
|
+
}
|
|
195
|
+
flushPending() {
|
|
196
|
+
if (this.ended)
|
|
197
|
+
return;
|
|
198
|
+
if (this.pendingUsage || (this.pendingFinish && !this.finishForwarded))
|
|
199
|
+
this.emitTerminal();
|
|
200
|
+
this.ended = true;
|
|
201
|
+
}
|
|
202
|
+
toOpenAIUsage(usage) {
|
|
203
|
+
return {
|
|
204
|
+
prompt_tokens: usage.input,
|
|
205
|
+
completion_tokens: usage.output,
|
|
206
|
+
total_tokens: usage.total,
|
|
207
|
+
...(usage.cacheRead ? { prompt_tokens_details: { cached_tokens: usage.cacheRead } } : {}),
|
|
208
|
+
...(usage.reasoning ? { completion_tokens_details: { reasoning_tokens: usage.reasoning } } : {}),
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Qoder context-window tiers. A model_config advertises context_config tiers
|
|
2
|
+
// (200K/400K/1M) while max_input_tokens carries only the IDE-selected one. The IDE
|
|
3
|
+
// lets the user switch; a CLI-style client has no picker, so a long session is
|
|
4
|
+
// rejected upstream even though the model supports more. Emulate the picker:
|
|
5
|
+
// estimate the prompt, choose the smallest tier that fits, mirror it into the same
|
|
6
|
+
// three fields the IDE writes. Pure — no I/O.
|
|
7
|
+
import { QODER_CONTEXT_TIER_ENV, QODER_CONTEXT_TIER_HEADROOM } from './constants.js';
|
|
8
|
+
const UNIT = { K: 1_000, M: 1_000_000 };
|
|
9
|
+
export function parseTierTokenCount(value) {
|
|
10
|
+
if (typeof value === 'number')
|
|
11
|
+
return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
|
|
12
|
+
if (typeof value !== 'string')
|
|
13
|
+
return 0;
|
|
14
|
+
const match = value.trim().toUpperCase().match(/^(\d+(?:\.\d+)?)\s*([KM])?$/);
|
|
15
|
+
if (!match)
|
|
16
|
+
return 0;
|
|
17
|
+
const count = Number(match[1]) * (UNIT[match[2] ?? ''] ?? 1);
|
|
18
|
+
return Number.isFinite(count) && count > 0 ? Math.floor(count) : 0;
|
|
19
|
+
}
|
|
20
|
+
function tierName(entry, tokenCount) {
|
|
21
|
+
const raw = entry.name ?? entry.label ?? entry.display_name ?? entry.displayName;
|
|
22
|
+
if (typeof raw === 'string' && raw.trim())
|
|
23
|
+
return raw.trim();
|
|
24
|
+
return String(tokenCount);
|
|
25
|
+
}
|
|
26
|
+
export function getQoderContextTiers(modelConfig) {
|
|
27
|
+
const list = modelConfig?.context_config ?? modelConfig?.contextConfig;
|
|
28
|
+
if (!Array.isArray(list))
|
|
29
|
+
return [];
|
|
30
|
+
const byCount = new Map();
|
|
31
|
+
for (const item of list) {
|
|
32
|
+
if (!item || typeof item !== 'object')
|
|
33
|
+
continue;
|
|
34
|
+
const entry = item;
|
|
35
|
+
const tokenCount = parseTierTokenCount(entry.tokenCount ?? entry.token_count ?? entry.max_input_tokens ?? entry.maxInputTokens);
|
|
36
|
+
if (!tokenCount)
|
|
37
|
+
continue;
|
|
38
|
+
const isDefault = entry.isDefault === true || entry.is_default === true || entry.default === true;
|
|
39
|
+
const previous = byCount.get(tokenCount);
|
|
40
|
+
byCount.set(tokenCount, { name: tierName(entry, tokenCount), tokenCount, isDefault: (previous?.isDefault ?? false) || isDefault });
|
|
41
|
+
}
|
|
42
|
+
return [...byCount.values()].sort((a, b) => a.tokenCount - b.tokenCount);
|
|
43
|
+
}
|
|
44
|
+
const CJK_RE = /[\u1100-\u11ff\u2e80-\u9fff\uac00-\ud7af\uf900-\ufaff\uff00-\uffef]/g;
|
|
45
|
+
/** CJK counts ~1 token per char; everything else ~4 chars per token. */
|
|
46
|
+
export function estimateQoderPromptTokens(prompt = {}) {
|
|
47
|
+
let text;
|
|
48
|
+
try {
|
|
49
|
+
text = JSON.stringify({ system: prompt.system ?? '', messages: prompt.messages ?? [], tools: prompt.tools ?? [] }) ?? '';
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return 0;
|
|
53
|
+
}
|
|
54
|
+
const cjk = (text.match(CJK_RE) ?? []).length;
|
|
55
|
+
return Math.ceil(cjk + (text.length - cjk) / 4);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* null means "send the payload as-is": the model has no tiers, or the default already fits.
|
|
59
|
+
* Pure function of its inputs plus the QODER_CONTEXT_TIER env var.
|
|
60
|
+
*/
|
|
61
|
+
export function resolveQoderContextTier(modelConfig, prompt, options = {}) {
|
|
62
|
+
const tiers = getQoderContextTiers(modelConfig);
|
|
63
|
+
if (tiers.length === 0)
|
|
64
|
+
return null;
|
|
65
|
+
const preference = String(options.preference ?? process.env[QODER_CONTEXT_TIER_ENV] ?? '').trim().toLowerCase() || 'auto';
|
|
66
|
+
const largest = tiers[tiers.length - 1];
|
|
67
|
+
const defaultTier = tiers.find((tier) => tier.isDefault) ?? tiers[0];
|
|
68
|
+
const estimatedTokens = estimateQoderPromptTokens(prompt);
|
|
69
|
+
const headroom = typeof options.headroom === 'number' ? options.headroom : QODER_CONTEXT_TIER_HEADROOM;
|
|
70
|
+
const needed = Math.ceil(estimatedTokens * (1 + headroom));
|
|
71
|
+
if (preference === 'max')
|
|
72
|
+
return { tier: largest, estimatedTokens, reason: 'forced:max' };
|
|
73
|
+
if (preference === 'default')
|
|
74
|
+
return { tier: defaultTier, estimatedTokens, reason: 'forced:default' };
|
|
75
|
+
if (preference !== 'auto') {
|
|
76
|
+
const wanted = preference.replace(/\s+/g, '');
|
|
77
|
+
const asCount = parseTierTokenCount(wanted);
|
|
78
|
+
const named = tiers.find((tier) => tier.name.replace(/\s+/g, '').toLowerCase() === wanted || (asCount > 0 && tier.tokenCount === asCount));
|
|
79
|
+
if (named)
|
|
80
|
+
return { tier: named, estimatedTokens, reason: `forced:${named.name}` };
|
|
81
|
+
}
|
|
82
|
+
const currentLimit = parseTierTokenCount(modelConfig?.max_input_tokens ?? modelConfig?.maxInputTokens) || defaultTier.tokenCount;
|
|
83
|
+
if (needed <= currentLimit)
|
|
84
|
+
return null;
|
|
85
|
+
const fits = tiers.find((tier) => tier.tokenCount >= needed && tier.tokenCount > currentLimit);
|
|
86
|
+
const tier = fits ?? largest;
|
|
87
|
+
if (tier.tokenCount <= currentLimit)
|
|
88
|
+
return null;
|
|
89
|
+
return { tier, estimatedTokens, reason: fits ? 'auto:fits' : 'auto:largest' };
|
|
90
|
+
}
|
|
91
|
+
/** Mutates and returns the payload, mirroring the IDE's three writes. */
|
|
92
|
+
export function applyQoderContextTier(payload, tier) {
|
|
93
|
+
if (!payload || !tier?.tokenCount)
|
|
94
|
+
return payload;
|
|
95
|
+
const target = payload;
|
|
96
|
+
target.parameters = { ...(target.parameters ?? {}), context_length: tier.tokenCount };
|
|
97
|
+
const chatContext = target.chat_context ?? {};
|
|
98
|
+
const extra = chatContext.extra ?? {};
|
|
99
|
+
chatContext.extra = { ...extra, ideModelConfigOverride: { max_input_tokens: tier.tokenCount } };
|
|
100
|
+
target.chat_context = chatContext;
|
|
101
|
+
if (target.model_config && typeof target.model_config === 'object') {
|
|
102
|
+
target.model_config = { ...target.model_config, max_input_tokens: tier.tokenCount };
|
|
103
|
+
}
|
|
104
|
+
return payload;
|
|
105
|
+
}
|
|
@@ -4,7 +4,7 @@ import path from 'node:path';
|
|
|
4
4
|
import zlib from 'node:zlib';
|
|
5
5
|
import crypto from 'node:crypto';
|
|
6
6
|
import { getDb, closeDb, openDb, schema } from '../../db/index.js';
|
|
7
|
-
import { requireAdminAuth } from '../../auth/middleware.js';
|
|
7
|
+
import { requireAdminAuth, requireAdminCsrf } from '../../auth/middleware.js';
|
|
8
8
|
import { sha256Hex } from '../../auth/ids.js';
|
|
9
9
|
import { recordAudit } from '../../db/repositories/audit.js';
|
|
10
10
|
import { loadConfig, setConfigMasterKey } from '../../config/index.js';
|
|
@@ -54,10 +54,15 @@ function assertDatabaseUsable(expectedSchemaVersion) {
|
|
|
54
54
|
}
|
|
55
55
|
}
|
|
56
56
|
export async function registerBackupRoutes(app) {
|
|
57
|
+
// This module is registered in its own scope (routes/admin.ts) precisely so this is the only
|
|
58
|
+
// auth hook on the backup routes. A preHandler cannot cancel a later one, so the first-run
|
|
59
|
+
// import exemption must not sit alongside another admin module's requireAdminAuth.
|
|
57
60
|
app.addHook('preHandler', async (req, reply) => {
|
|
58
61
|
if (req.url === '/api/admin/backup/restore' && !getSettings().setupComplete)
|
|
59
62
|
return;
|
|
60
|
-
|
|
63
|
+
await requireAdminAuth(req, reply);
|
|
64
|
+
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method))
|
|
65
|
+
await requireAdminCsrf(req);
|
|
61
66
|
});
|
|
62
67
|
app.post('/api/admin/backup/create', async (req, reply) => {
|
|
63
68
|
const passphrase = req.body?.passphrase ?? '';
|
|
@@ -88,6 +88,9 @@ export async function registerModelRoutes(app) {
|
|
|
88
88
|
const { codexModels } = await import('../../providers/codex.js');
|
|
89
89
|
const { getCodexAccountById, listCodexAccountSummaries } = await import('../../db/repositories/codex-accounts.js');
|
|
90
90
|
const { withCodexCredentials } = await import('../../providers/codex-refresh.js');
|
|
91
|
+
const { withQoderCredentials } = await import('../../providers/qoder/credentials.js');
|
|
92
|
+
const { qoderModels } = await import('../../providers/qoder/client.js');
|
|
93
|
+
const { listQoderAccountSummaries } = await import('../../db/repositories/qoder-accounts.js');
|
|
91
94
|
let discovered;
|
|
92
95
|
try {
|
|
93
96
|
if (provider.type === 'codex') {
|
|
@@ -100,6 +103,17 @@ export async function registerModelRoutes(app) {
|
|
|
100
103
|
accountRecordId: account.id, customHeaders: {}, totalTimeoutMs: Math.min(provider.totalTimeoutMs, 30000),
|
|
101
104
|
}));
|
|
102
105
|
}
|
|
106
|
+
else if (provider.type === 'qoder') {
|
|
107
|
+
// The catalog is fetched live from the first eligible account; no manual model list.
|
|
108
|
+
const summary = listQoderAccountSummaries(provider.id).find((candidate) => candidate.enabled && candidate.healthState !== 'down');
|
|
109
|
+
if (!summary)
|
|
110
|
+
throw new GatewayError('authentication_error', 'No eligible Qoder account is configured', { status: 503 });
|
|
111
|
+
discovered = await withQoderCredentials(summary.id, (config) => {
|
|
112
|
+
if (!config.catalog)
|
|
113
|
+
throw new GatewayError('invalid_request_error', 'Qoder model catalog is empty — replace the personal access token', { status: 400, code: 'model_config_not_cached' });
|
|
114
|
+
return Promise.resolve(qoderModels(config.catalog));
|
|
115
|
+
});
|
|
116
|
+
}
|
|
103
117
|
else {
|
|
104
118
|
if (!provider.encryptedApiKey || !provider.apiKeyNonce)
|
|
105
119
|
throw new GatewayError('invalid_request_error', 'Provider credentials are missing', { status: 501 });
|