ldrouter 1.16.5 → 1.17.1
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 +25 -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/combos.js +4 -1
- 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-DAzlm3Y6.js +391 -0
- package/dist/web/assets/index-Xccks9E5.css +1 -0
- package/dist/web/index.html +2 -2
- package/migrations/0007_qoder_accounts.sql +217 -0
- package/package.json +1 -1
- package/dist/web/assets/index-CkDJqDcS.js +0 -391
- package/dist/web/assets/index-Drmqi8MO.css +0 -1
|
@@ -27,6 +27,8 @@ export async function probeProvider(cfg) {
|
|
|
27
27
|
const start = Date.now();
|
|
28
28
|
if (cfg.type === 'codex')
|
|
29
29
|
return { ok: false, detail: 'Codex providers require the Codex account adapter', latencyMs: 0 };
|
|
30
|
+
if (cfg.type === 'qoder')
|
|
31
|
+
return { ok: false, detail: 'Qoder providers require the Qoder account adapter', latencyMs: 0 };
|
|
30
32
|
try {
|
|
31
33
|
const url = cfg.type === 'openai' ? `${stripSlash(cfg.baseUrl)}/v1/models` : `${stripSlash(cfg.baseUrl)}/v1/models`;
|
|
32
34
|
const res = await fetchWithTimeout(url, { method: 'GET', headers: buildHeaders(cfg) }, cfg.totalTimeoutMs);
|
|
@@ -45,6 +47,8 @@ export async function probeProvider(cfg) {
|
|
|
45
47
|
export async function discoverProviderModels(cfg) {
|
|
46
48
|
if (cfg.type === 'codex')
|
|
47
49
|
throw new Error('Codex providers require the Codex account adapter');
|
|
50
|
+
if (cfg.type === 'qoder')
|
|
51
|
+
throw new Error('Qoder providers require the Qoder account adapter');
|
|
48
52
|
if (cfg.type === 'openai')
|
|
49
53
|
return discoverOpenAI(cfg);
|
|
50
54
|
return discoverAnthropic(cfg);
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Account-pool providers: the upstream credential is a pool of accounts stored in a
|
|
2
|
+
// dedicated table, not one API key on the provider row. The registry keeps the
|
|
3
|
+
// per-type probe/discover/account branches out of the provider CRUD route.
|
|
4
|
+
import { GatewayError } from '../errors.js';
|
|
5
|
+
import { listCodexAccountSummaries, chatgptAccountIdOf } from '../db/repositories/codex-accounts.js';
|
|
6
|
+
import { listQoderAccountSummaries } from '../db/repositories/qoder-accounts.js';
|
|
7
|
+
import { probeCodex, codexModels, CODEX_BASE_URL } from './codex.js';
|
|
8
|
+
import { withCodexCredentials, codexCredentialError } from './codex-refresh.js';
|
|
9
|
+
import { probeQoder, qoderModels } from './qoder/client.js';
|
|
10
|
+
import { withQoderCredentials } from './qoder/credentials.js';
|
|
11
|
+
import { QODER_INFERENCE_BASE } from './qoder/constants.js';
|
|
12
|
+
export const POOLED_PROVIDER_TYPES = ['codex', 'qoder'];
|
|
13
|
+
function firstEligible(accounts) {
|
|
14
|
+
const account = accounts.find((candidate) => candidate.enabled && candidate.healthState !== 'down');
|
|
15
|
+
if (!account)
|
|
16
|
+
throw new GatewayError('authentication_error', 'No eligible account is configured for this provider', { status: 503 });
|
|
17
|
+
return account;
|
|
18
|
+
}
|
|
19
|
+
const codexStrategy = {
|
|
20
|
+
defaults: { name: 'Codex', slug: 'codex', baseUrl: CODEX_BASE_URL },
|
|
21
|
+
accountsTable: 'codex_accounts',
|
|
22
|
+
listAccounts: (providerId) => listCodexAccountSummaries(providerId),
|
|
23
|
+
// `firstEligible` is the single eligibility gate (matching the route it replaces); the account id
|
|
24
|
+
// is a plain lookup, not a second gate — an imported account without a chatgpt_account_id is still
|
|
25
|
+
// probed, and a degraded or token-expired account is still refreshable, so the upstream answers
|
|
26
|
+
// honestly instead of the route short-circuiting to 503 (which would also skip the audit write).
|
|
27
|
+
probe: async (provider) => {
|
|
28
|
+
const account = firstEligible(codexStrategy.listAccounts(provider.id));
|
|
29
|
+
const accountId = chatgptAccountIdOf(account.id) ?? '';
|
|
30
|
+
return withCodexCredentials(account.id, (credentials) => probeCodex({
|
|
31
|
+
baseUrl: provider.baseUrl, accountId, accessToken: credentials.accessToken,
|
|
32
|
+
accountRecordId: account.id, customHeaders: {}, totalTimeoutMs: Math.min(provider.totalTimeoutMs, 20_000),
|
|
33
|
+
})).catch((error) => { throw codexCredentialError(error); });
|
|
34
|
+
},
|
|
35
|
+
discover: async (provider) => {
|
|
36
|
+
const account = firstEligible(codexStrategy.listAccounts(provider.id));
|
|
37
|
+
const accountId = chatgptAccountIdOf(account.id) ?? '';
|
|
38
|
+
return withCodexCredentials(account.id, (credentials) => codexModels({
|
|
39
|
+
baseUrl: provider.baseUrl, accountId, accessToken: credentials.accessToken,
|
|
40
|
+
accountRecordId: account.id, customHeaders: {}, totalTimeoutMs: Math.min(provider.totalTimeoutMs, 30_000),
|
|
41
|
+
})).catch((error) => { throw codexCredentialError(error); });
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
const qoderStrategy = {
|
|
45
|
+
defaults: { name: 'Qoder', slug: 'qoder', baseUrl: QODER_INFERENCE_BASE },
|
|
46
|
+
accountsTable: 'qoder_accounts',
|
|
47
|
+
listAccounts: (providerId) => listQoderAccountSummaries(providerId),
|
|
48
|
+
// Same contract as Codex: `firstEligible` is the single eligibility gate, and the probe goes
|
|
49
|
+
// through the credential seam so a stale job token is refreshed before the upstream is asked.
|
|
50
|
+
probe: async (provider) => {
|
|
51
|
+
const account = firstEligible(qoderStrategy.listAccounts(provider.id));
|
|
52
|
+
return withQoderCredentials(account.id, (config) => probeQoder({ ...config, accountRecordId: account.id, totalTimeoutMs: Math.min(provider.totalTimeoutMs, 20_000) }))
|
|
53
|
+
.catch((error) => { throw qoderCredentialError(error); });
|
|
54
|
+
},
|
|
55
|
+
discover: async (provider) => {
|
|
56
|
+
const account = firstEligible(qoderStrategy.listAccounts(provider.id));
|
|
57
|
+
return withQoderCredentials(account.id, (config) => {
|
|
58
|
+
if (!config.catalog)
|
|
59
|
+
throw new GatewayError('invalid_request_error', 'Qoder model catalog is empty — the account has no usable models', { status: 400, code: 'model_config_not_cached' });
|
|
60
|
+
return Promise.resolve(qoderModels(config.catalog));
|
|
61
|
+
}).catch((error) => { throw qoderCredentialError(error); });
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
/** Qoder fixes credential failures by replacing the PAT, not by re-saving a provider API key. */
|
|
65
|
+
function qoderCredentialError(error) {
|
|
66
|
+
if (error instanceof GatewayError)
|
|
67
|
+
return error;
|
|
68
|
+
const code = error instanceof Error ? error.message : '';
|
|
69
|
+
if (code === 'account_not_found')
|
|
70
|
+
return new GatewayError('invalid_request_error', 'Qoder account not found', { status: 404 });
|
|
71
|
+
return error;
|
|
72
|
+
}
|
|
73
|
+
const STRATEGIES = { codex: codexStrategy, qoder: qoderStrategy };
|
|
74
|
+
/** null for types the registry does not own (openai / anthropic-compatible keep the generic path). */
|
|
75
|
+
export function getPooledProvider(type) {
|
|
76
|
+
return STRATEGIES[type] ?? null;
|
|
77
|
+
}
|
|
78
|
+
/** Throws for a non-pool type so callers cannot read undefined defaults. */
|
|
79
|
+
export function pooledProviderDefaults(type) {
|
|
80
|
+
const strategy = getPooledProvider(type);
|
|
81
|
+
if (!strategy)
|
|
82
|
+
throw new GatewayError('invalid_request_error', `Provider type '${type}' is not an account-pool provider`, { status: 400 });
|
|
83
|
+
return strategy.defaults;
|
|
84
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// Qoder credential exchange and live model catalog.
|
|
2
|
+
// A PAT (pt-) cannot sign COSY requests, so it is exchanged for a short-lived job
|
|
3
|
+
// token (jt-) over plain JSON, then userinfo supplies the stable user id that signing
|
|
4
|
+
// requires. The inference host is api2 for job tokens; api3 rejects them.
|
|
5
|
+
import { buildCosyHeaders } from './cosy.js';
|
|
6
|
+
import { QODER_JOB_TOKEN_EXCHANGE_URL, QODER_MODEL_LIST_URL, QODER_USERINFO_URL } from './constants.js';
|
|
7
|
+
const FETCH_TIMEOUT_MS = 15_000;
|
|
8
|
+
const PAT_PREFIX = 'pt-';
|
|
9
|
+
export function isQoderPat(token) {
|
|
10
|
+
return typeof token === 'string' && token.startsWith(PAT_PREFIX);
|
|
11
|
+
}
|
|
12
|
+
async function timedFetch(url, init, deps = {}) {
|
|
13
|
+
const controller = new AbortController();
|
|
14
|
+
const timer = setTimeout(() => controller.abort(), deps.timeoutMs ?? FETCH_TIMEOUT_MS);
|
|
15
|
+
try {
|
|
16
|
+
return await (deps.fetchImpl ?? fetch)(url, { ...init, signal: init.signal ?? controller.signal });
|
|
17
|
+
}
|
|
18
|
+
finally {
|
|
19
|
+
clearTimeout(timer);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
const numberOr = (value, fallback) => {
|
|
23
|
+
const parsed = Number(value);
|
|
24
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
25
|
+
};
|
|
26
|
+
export function parseCatalog(body, fetchedAt) {
|
|
27
|
+
const entries = new Map();
|
|
28
|
+
const chat = body && typeof body === 'object' ? body.chat : null;
|
|
29
|
+
if (!Array.isArray(chat))
|
|
30
|
+
return { entries, fetchedAt };
|
|
31
|
+
for (const item of chat) {
|
|
32
|
+
if (!item || typeof item !== 'object')
|
|
33
|
+
continue;
|
|
34
|
+
const raw = item;
|
|
35
|
+
const key = typeof raw.key === 'string' ? raw.key : '';
|
|
36
|
+
if (!key)
|
|
37
|
+
continue;
|
|
38
|
+
entries.set(key, {
|
|
39
|
+
key,
|
|
40
|
+
displayName: typeof raw.display_name === 'string' && raw.display_name ? raw.display_name : key,
|
|
41
|
+
// A disabled entry stays routable: the admin may still select it, and chat sends
|
|
42
|
+
// the live model_config regardless.
|
|
43
|
+
enabled: raw.enable !== false,
|
|
44
|
+
isReasoning: raw.is_reasoning === true,
|
|
45
|
+
isVl: raw.is_vl === true,
|
|
46
|
+
maxInputTokens: numberOr(raw.max_input_tokens, 0),
|
|
47
|
+
maxOutputTokens: numberOr(raw.max_output_tokens, 0),
|
|
48
|
+
raw,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
return { entries, fetchedAt };
|
|
52
|
+
}
|
|
53
|
+
// The serialized form deliberately keeps each entry's `raw` config: chat must send the
|
|
54
|
+
// live model_config and never re-derive it from the parsed fields.
|
|
55
|
+
export function serializeCatalog(catalog) {
|
|
56
|
+
return JSON.stringify({
|
|
57
|
+
fetchedAt: catalog.fetchedAt,
|
|
58
|
+
entries: [...catalog.entries.values()].map((entry) => ({ ...entry })),
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
export function deserializeCatalog(raw) {
|
|
62
|
+
if (!raw)
|
|
63
|
+
return null;
|
|
64
|
+
try {
|
|
65
|
+
const parsed = JSON.parse(raw);
|
|
66
|
+
const entries = new Map();
|
|
67
|
+
if (Array.isArray(parsed.entries)) {
|
|
68
|
+
for (const entry of parsed.entries)
|
|
69
|
+
if (entry?.key)
|
|
70
|
+
entries.set(entry.key, entry);
|
|
71
|
+
}
|
|
72
|
+
return { entries, fetchedAt: typeof parsed.fetchedAt === 'string' ? parsed.fetchedAt : '' };
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
export async function exchangeQoderPat(personalToken, deps = {}) {
|
|
79
|
+
if (!isQoderPat(personalToken))
|
|
80
|
+
throw new Error('not a Qoder personal access token (expected a pt- value)');
|
|
81
|
+
const response = await timedFetch(QODER_JOB_TOKEN_EXCHANGE_URL, {
|
|
82
|
+
method: 'POST',
|
|
83
|
+
headers: { 'content-type': 'application/json', accept: 'application/json', 'user-agent': 'qodercli/1.0.0' },
|
|
84
|
+
body: JSON.stringify({ personal_token: personalToken }),
|
|
85
|
+
}, deps);
|
|
86
|
+
if (!response.ok)
|
|
87
|
+
throw new Error(`Qoder personal access token exchange failed: HTTP ${response.status}`);
|
|
88
|
+
const body = (await response.json());
|
|
89
|
+
const jobToken = typeof body.token === 'string' ? body.token : '';
|
|
90
|
+
if (!jobToken)
|
|
91
|
+
throw new Error('Qoder personal access token exchange returned no job token');
|
|
92
|
+
const explicit = typeof body.expires_at === 'string' ? Date.parse(body.expires_at) : NaN;
|
|
93
|
+
const relative = typeof body.expires_in === 'number' && body.expires_in > 0 ? Date.now() + body.expires_in * 1000 : NaN;
|
|
94
|
+
// The upstream usually omits both; a job token is short-lived, so default to 24h and
|
|
95
|
+
// let a 401 at request time trigger an early re-exchange.
|
|
96
|
+
const expiresAt = new Date(Number.isFinite(explicit) ? explicit : Number.isFinite(relative) ? relative : Date.now() + 24 * 60 * 60 * 1000).toISOString();
|
|
97
|
+
const info = await fetchQoderUserInfo(jobToken, deps);
|
|
98
|
+
return { jobToken, jobTokenExpiresAt: expiresAt, qoderUserId: info.qoderUserId, email: info.email, label: info.label };
|
|
99
|
+
}
|
|
100
|
+
export async function fetchQoderUserInfo(jobToken, deps = {}) {
|
|
101
|
+
try {
|
|
102
|
+
const response = await timedFetch(QODER_USERINFO_URL, { method: 'GET', headers: { authorization: `Bearer ${jobToken}`, accept: 'application/json', 'user-agent': 'qodercli/1.0.0' } }, deps);
|
|
103
|
+
if (!response.ok)
|
|
104
|
+
return { qoderUserId: '', email: null, label: null };
|
|
105
|
+
const body = (await response.json());
|
|
106
|
+
const pick = (...values) => {
|
|
107
|
+
for (const value of values)
|
|
108
|
+
if (typeof value === 'string' && value.trim())
|
|
109
|
+
return value.trim();
|
|
110
|
+
return null;
|
|
111
|
+
};
|
|
112
|
+
return {
|
|
113
|
+
qoderUserId: pick(body.id, body.userId, body.user_id) ?? '',
|
|
114
|
+
email: pick(body.email),
|
|
115
|
+
label: pick(body.name, body.username),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return { qoderUserId: '', email: null, label: null };
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/** null on any failure: the caller decides whether that is fatal (it is, for cached model_config). */
|
|
123
|
+
export async function fetchQoderCatalog(creds, deps = {}) {
|
|
124
|
+
const headers = {
|
|
125
|
+
accept: 'application/json',
|
|
126
|
+
'accept-encoding': 'identity',
|
|
127
|
+
...buildCosyHeaders(Buffer.alloc(0), QODER_MODEL_LIST_URL, { userId: creds.userId, authToken: creds.jobToken, machineId: creds.machineId }),
|
|
128
|
+
};
|
|
129
|
+
try {
|
|
130
|
+
const response = await timedFetch(QODER_MODEL_LIST_URL, { method: 'GET', headers }, deps);
|
|
131
|
+
if (!response.ok)
|
|
132
|
+
return null;
|
|
133
|
+
return parseCatalog(await response.json(), new Date().toISOString());
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
// Qoder chat adapter: canonical request -> Qoder payload -> obfuscated body -> COSY-signed
|
|
2
|
+
// fetch -> OpenAI-shaped chunks -> canonical aggregate.
|
|
3
|
+
//
|
|
4
|
+
// Two behaviours the runner depends on:
|
|
5
|
+
// - Streaming emits OpenAI chunks and STOPS at the terminal frame. Qoder keeps the
|
|
6
|
+
// socket open after [DONE]; a client that drains would hang.
|
|
7
|
+
// - A missing live model_config is a hard error. Sending the wrong block makes the
|
|
8
|
+
// upstream silently serve a different model.
|
|
9
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
10
|
+
import { GatewayError } from '../../errors.js';
|
|
11
|
+
import { redactString } from '../../security/redact.js';
|
|
12
|
+
import { QODER_CHAT_URL, QODER_CHAT_SIG_PATH } from './constants.js';
|
|
13
|
+
import { buildCosyHeaders } from './cosy.js';
|
|
14
|
+
import { encodeQoderBody } from './encode.js';
|
|
15
|
+
import { QoderEnvelopeReader } from './sse.js';
|
|
16
|
+
import { applyQoderContextTier, resolveQoderContextTier } from './tier.js';
|
|
17
|
+
export class QoderModelConfigMissingError extends Error {
|
|
18
|
+
status = 400;
|
|
19
|
+
constructor(key) {
|
|
20
|
+
super(`Qoder model_config for "${key}" is not cached — discover or refresh this account's models first`);
|
|
21
|
+
this.name = 'QoderModelConfigMissingError';
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
const emptyUsage = () => ({ input: 0, output: 0, total: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 });
|
|
25
|
+
function blocksToParts(content) {
|
|
26
|
+
const parts = [];
|
|
27
|
+
let text = '';
|
|
28
|
+
for (const block of content) {
|
|
29
|
+
if (block.type === 'text' && block.text) {
|
|
30
|
+
parts.push({ type: 'text', text: block.text });
|
|
31
|
+
text += block.text;
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
const image = block.type === 'image' ? block.image : undefined;
|
|
35
|
+
const url = image?.url ?? (image?.base64 ? `data:${image.mimeType ?? 'image/png'};base64,${image.base64}` : undefined);
|
|
36
|
+
if (url)
|
|
37
|
+
parts.push({ type: 'image_url', image_url: { url } });
|
|
38
|
+
}
|
|
39
|
+
return { text, parts };
|
|
40
|
+
}
|
|
41
|
+
function normalizeMessage(message) {
|
|
42
|
+
const { text, parts } = blocksToParts(message.content);
|
|
43
|
+
const hasImage = parts.some((part) => part.type === 'image_url');
|
|
44
|
+
// Qoder reads documents through its own file API; a 30MB inline document is not a request.
|
|
45
|
+
const toolCalls = message.content.filter((block) => block.type === 'tool_use').map((block) => ({ id: block.toolUse?.id ?? '', type: 'function', function: { name: block.toolUse?.name ?? '', arguments: JSON.stringify(block.toolUse?.input ?? {}) } }));
|
|
46
|
+
return {
|
|
47
|
+
role: message.role,
|
|
48
|
+
content: hasImage ? parts : text,
|
|
49
|
+
...(toolCalls.length > 0 ? { tool_calls: toolCalls } : {}),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
function stableHash(prefix, ...parts) {
|
|
53
|
+
const hash = createHash('sha256').update(prefix);
|
|
54
|
+
for (const part of parts)
|
|
55
|
+
hash.update('\0').update(String(part ?? ''));
|
|
56
|
+
return hash.digest('hex').slice(0, 24);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Build the exact payload Qoder expects. Throws QoderModelConfigMissingError when the
|
|
60
|
+
* account has no cached model_config for this key — never guesses.
|
|
61
|
+
*/
|
|
62
|
+
export function qoderRequestPayload(req, modelKey, catalog, options = {}) {
|
|
63
|
+
const entry = catalog?.entries.get(modelKey);
|
|
64
|
+
if (!entry)
|
|
65
|
+
throw new QoderModelConfigMissingError(modelKey);
|
|
66
|
+
const modelConfig = { ...entry.raw, key: modelKey };
|
|
67
|
+
const isReasoning = entry.isReasoning;
|
|
68
|
+
const messages = req.messages.map(normalizeMessage);
|
|
69
|
+
const lastUser = [...messages].reverse().find((message) => message.role === 'user');
|
|
70
|
+
const lastUserText = typeof lastUser?.content === 'string'
|
|
71
|
+
? lastUser.content
|
|
72
|
+
: Array.isArray(lastUser?.content) ? lastUser.content.map((part) => part.text ?? '').join('\n') : '';
|
|
73
|
+
const declaredMax = entry.maxOutputTokens > 0 ? entry.maxOutputTokens : 32_768;
|
|
74
|
+
const requestedMax = req.maxOutputTokens ?? 0;
|
|
75
|
+
const maxTokens = requestedMax > 0 ? Math.min(requestedMax, declaredMax) : declaredMax;
|
|
76
|
+
const tools = (req.tools ?? []).map((tool) => ({ type: 'function', function: { name: tool.name, description: tool.description, parameters: tool.inputSchema } }));
|
|
77
|
+
const recordId = stableHash('qoder-record', modelKey, JSON.stringify(messages), JSON.stringify(tools), maxTokens);
|
|
78
|
+
const payload = {
|
|
79
|
+
request_id: randomUUID(),
|
|
80
|
+
request_set_id: recordId,
|
|
81
|
+
chat_record_id: recordId,
|
|
82
|
+
session_id: stableHash('qoder-session', modelKey),
|
|
83
|
+
stream: true,
|
|
84
|
+
chat_task: 'FREE_INPUT',
|
|
85
|
+
is_reply: true,
|
|
86
|
+
is_retry: false,
|
|
87
|
+
source: 1,
|
|
88
|
+
version: '3',
|
|
89
|
+
session_type: 'qodercli',
|
|
90
|
+
agent_id: 'agent_common',
|
|
91
|
+
task_id: 'common',
|
|
92
|
+
code_language: '',
|
|
93
|
+
chat_prompt: '',
|
|
94
|
+
image_urls: null,
|
|
95
|
+
aliyun_user_type: '',
|
|
96
|
+
system: req.system ?? '',
|
|
97
|
+
messages,
|
|
98
|
+
tools,
|
|
99
|
+
parameters: { max_tokens: maxTokens },
|
|
100
|
+
chat_context: {
|
|
101
|
+
chatPrompt: '',
|
|
102
|
+
imageUrls: null,
|
|
103
|
+
extra: { context: [], modelConfig: { key: modelKey, is_reasoning: isReasoning }, originalContent: lastUserText },
|
|
104
|
+
features: [],
|
|
105
|
+
text: lastUserText,
|
|
106
|
+
},
|
|
107
|
+
model_config: modelConfig,
|
|
108
|
+
business: {
|
|
109
|
+
product: 'cli', version: '1.0.0', type: 'agent', stage: 'start',
|
|
110
|
+
id: randomUUID(), name: lastUserText.slice(0, 30), begin_at: Date.now(),
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
const tier = resolveQoderContextTier(modelConfig, { system: req.system, messages, tools }, { preference: options.contextPreference });
|
|
114
|
+
if (tier)
|
|
115
|
+
applyQoderContextTier(payload, tier.tier);
|
|
116
|
+
return { payload, modelConfig };
|
|
117
|
+
}
|
|
118
|
+
/** Thrown before any byte is written downstream, so the runner can still fail over. */
|
|
119
|
+
export class QoderUpstreamError extends Error {
|
|
120
|
+
status;
|
|
121
|
+
billing;
|
|
122
|
+
constructor(status, message, billing = false) {
|
|
123
|
+
super(message);
|
|
124
|
+
this.status = status;
|
|
125
|
+
this.billing = billing;
|
|
126
|
+
this.name = 'QoderUpstreamError';
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
function signedInit(cfg, payload, modelKey) {
|
|
130
|
+
// encodeQoderBody returns latin1-mapped text; the COSY signature hashes exactly these
|
|
131
|
+
// bytes, so the body must be reconstructed with the same encoding.
|
|
132
|
+
const encoded = Buffer.from(encodeQoderBody(Buffer.from(JSON.stringify(payload), 'utf8')), 'latin1');
|
|
133
|
+
return {
|
|
134
|
+
init: {
|
|
135
|
+
method: 'POST',
|
|
136
|
+
headers: {
|
|
137
|
+
'content-type': 'application/json',
|
|
138
|
+
accept: 'text/event-stream',
|
|
139
|
+
'cache-control': 'no-cache',
|
|
140
|
+
'accept-encoding': 'identity',
|
|
141
|
+
'x-model-key': modelKey,
|
|
142
|
+
'x-model-source': String(payload.model_config?.source ?? 'system'),
|
|
143
|
+
...buildCosyHeaders(encoded, QODER_CHAT_URL, { userId: cfg.qoderUserId, authToken: cfg.jobToken, name: cfg.name, email: cfg.email ?? undefined, machineId: cfg.machineId }),
|
|
144
|
+
},
|
|
145
|
+
body: encoded,
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
async function openSignedStream(cfg, req, deps) {
|
|
150
|
+
// Accept both a public id (`qoder/qmodel_38max`) and the bare upstream key the runner passes.
|
|
151
|
+
// Qoder model keys never contain a slash, so the last segment is the key either way — taking it
|
|
152
|
+
// unconditionally is what makes this idempotent instead of stripping a prefix that may be gone.
|
|
153
|
+
const modelKey = req.model.split('/').pop() ?? req.model;
|
|
154
|
+
const { payload } = qoderRequestPayload(req, modelKey, cfg.catalog);
|
|
155
|
+
const { init } = signedInit(cfg, payload, modelKey);
|
|
156
|
+
const controller = new AbortController();
|
|
157
|
+
const timer = setTimeout(() => controller.abort(), cfg.totalTimeoutMs);
|
|
158
|
+
let response;
|
|
159
|
+
try {
|
|
160
|
+
response = await (deps.fetchImpl ?? fetch)(QODER_CHAT_URL, { ...init, signal: controller.signal });
|
|
161
|
+
}
|
|
162
|
+
catch (error) {
|
|
163
|
+
clearTimeout(timer);
|
|
164
|
+
throw new GatewayError('timeout_error', 'Qoder upstream request failed', { status: 504, cause: error });
|
|
165
|
+
}
|
|
166
|
+
// Headers are in: the total timeout no longer applies (Qoder streams can be long).
|
|
167
|
+
clearTimeout(timer);
|
|
168
|
+
if (!response.ok || !response.body) {
|
|
169
|
+
throw new QoderUpstreamError(response.status, `Qoder upstream HTTP ${response.status}`);
|
|
170
|
+
}
|
|
171
|
+
return {
|
|
172
|
+
response,
|
|
173
|
+
reader: new QoderEnvelopeReader({ model: `qoder/${modelKey}` }),
|
|
174
|
+
status: response.status,
|
|
175
|
+
upstreamRequestId: response.headers.get('x-request-id'),
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
async function drainEnvelope(reader, response, onChunk) {
|
|
179
|
+
const upstream = response.body.getReader();
|
|
180
|
+
const decoder = new TextDecoder();
|
|
181
|
+
let emitted = 0;
|
|
182
|
+
const drain = () => {
|
|
183
|
+
while (emitted < reader.chunks.length) {
|
|
184
|
+
onChunk?.({ data: reader.chunks[emitted] });
|
|
185
|
+
emitted += 1;
|
|
186
|
+
}
|
|
187
|
+
return reader.terminal();
|
|
188
|
+
};
|
|
189
|
+
try {
|
|
190
|
+
for (;;) {
|
|
191
|
+
const part = await upstream.read();
|
|
192
|
+
const done = part.done;
|
|
193
|
+
reader.push(decoder.decode(part.value ?? new Uint8Array(), { stream: !done }));
|
|
194
|
+
if (drain())
|
|
195
|
+
break;
|
|
196
|
+
if (done) {
|
|
197
|
+
reader.finish();
|
|
198
|
+
drain();
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
const error = reader.errorEnvelope();
|
|
203
|
+
if (error)
|
|
204
|
+
throw new QoderUpstreamError(error.statusValue, redactString(error.message.slice(0, 300)), error.billing);
|
|
205
|
+
}
|
|
206
|
+
finally {
|
|
207
|
+
// Qoder holds the socket open after the terminal frame; leaving it un-cancelled leaks
|
|
208
|
+
// the connection until the upstream keepalive gives up.
|
|
209
|
+
await upstream.cancel().catch(() => { });
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
/** Streaming: emit OpenAI-shaped chunks as they arrive, stop at the terminal frame. */
|
|
213
|
+
export async function callQoderStreaming(cfg, req, onChunk, deps = {}) {
|
|
214
|
+
const { response, reader, status, upstreamRequestId } = await openSignedStream(cfg, { ...req, stream: true }, deps);
|
|
215
|
+
await drainEnvelope(reader, response, onChunk);
|
|
216
|
+
return { status, upstreamRequestId, text: reader.text, toolCalls: reader.toolCalls, finishReason: reader.finishReason, usage: reader.usage ?? emptyUsage() };
|
|
217
|
+
}
|
|
218
|
+
/** Qoder only speaks SSE; the non-streaming shape is assembled from the same reader. */
|
|
219
|
+
export async function callQoderNonStreaming(cfg, req, deps = {}) {
|
|
220
|
+
const { response, reader, status, upstreamRequestId } = await openSignedStream(cfg, { ...req, stream: false }, deps);
|
|
221
|
+
await drainEnvelope(reader, response);
|
|
222
|
+
return { status, upstreamRequestId, text: reader.text, toolCalls: reader.toolCalls, finishReason: reader.finishReason, usage: reader.usage ?? emptyUsage() };
|
|
223
|
+
}
|
|
224
|
+
export async function probeQoder(cfg) {
|
|
225
|
+
const started = Date.now();
|
|
226
|
+
const catalogue = cfg.catalog;
|
|
227
|
+
if (!catalogue || catalogue.entries.size === 0) {
|
|
228
|
+
return { ok: false, detail: 'Model catalog is empty — check the personal access token', latencyMs: Date.now() - started };
|
|
229
|
+
}
|
|
230
|
+
return { ok: true, detail: 'Connected (catalog loaded)', latencyMs: Date.now() - started, modelCount: catalogue.entries.size };
|
|
231
|
+
}
|
|
232
|
+
/** Catalog entries become discovered models; hidden (enable:false) keys stay routable. */
|
|
233
|
+
export function qoderModels(catalog) {
|
|
234
|
+
return [...catalog.entries.values()].map((entry) => ({
|
|
235
|
+
upstreamId: entry.key,
|
|
236
|
+
displayName: entry.displayName,
|
|
237
|
+
capabilities: {
|
|
238
|
+
chat: true,
|
|
239
|
+
streaming: true,
|
|
240
|
+
tools: true,
|
|
241
|
+
reasoning: entry.isReasoning || undefined,
|
|
242
|
+
image_input: entry.isVl || undefined,
|
|
243
|
+
max_context_tokens: entry.maxInputTokens || null,
|
|
244
|
+
max_output_tokens: entry.maxOutputTokens || null,
|
|
245
|
+
},
|
|
246
|
+
}));
|
|
247
|
+
}
|
|
248
|
+
export { QODER_CHAT_SIG_PATH };
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Qoder endpoint map. PAT-only: every credential is a personal access token (pt-...)
|
|
2
|
+
// that is exchanged for a short-lived job token (jt-...), and job tokens are served
|
|
3
|
+
// by api2 (api3 answers 403 "Login expired" for jt-).
|
|
4
|
+
// ponytail: single inference host because there is exactly one credential kind.
|
|
5
|
+
// If device tokens (dt-, api3) are ever supported, restore a token-prefix branch here.
|
|
6
|
+
import { QODER_RSA_PUBLIC_KEY as RSA_KEY } from './rsa.js';
|
|
7
|
+
export const QODER_OPENAPI_BASE = 'https://openapi.qoder.sh';
|
|
8
|
+
export const QODER_INFERENCE_BASE = 'https://api2.qoder.sh';
|
|
9
|
+
export const QODER_LOGIN_URL = 'https://qoder.com/account/integrations';
|
|
10
|
+
export const QODER_JOB_TOKEN_EXCHANGE_URL = `${QODER_OPENAPI_BASE}/api/v1/jobToken/exchange`;
|
|
11
|
+
export const QODER_USERINFO_URL = `${QODER_OPENAPI_BASE}/api/v1/userinfo`;
|
|
12
|
+
export const QODER_CHAT_SIG_PATH = '/api/v2/service/pro/sse/agent_chat_generation';
|
|
13
|
+
export const QODER_CHAT_URL = `${QODER_INFERENCE_BASE}/algo${QODER_CHAT_SIG_PATH}?FetchKeys=llm_model_result&AgentId=agent_common&Encode=1`;
|
|
14
|
+
export const QODER_MODEL_LIST_URL = `${QODER_INFERENCE_BASE}/algo/api/v2/model/list`;
|
|
15
|
+
// COSY client fingerprint. The upstream validates the signature against the values
|
|
16
|
+
// used at signing time, so these are not free-form.
|
|
17
|
+
export const QODER_IDE_VERSION = '1.0.0';
|
|
18
|
+
export const QODER_CLIENT_TYPE = '5';
|
|
19
|
+
export const QODER_DATA_POLICY = 'disagree';
|
|
20
|
+
export const QODER_LOGIN_VERSION = 'v2';
|
|
21
|
+
export const QODER_MACHINE_OS = 'x86_64_windows';
|
|
22
|
+
export const QODER_MACHINE_TYPE = '5';
|
|
23
|
+
// Static fallback catalog used only when the live model list cannot be fetched at
|
|
24
|
+
// add time, so the admin sees something to select. Chat never trusts this list —
|
|
25
|
+
// it always sends the live model_config.
|
|
26
|
+
export const QODER_MODEL_KEYS = [
|
|
27
|
+
'auto', 'ultimate', 'performance', 'efficient', 'lite',
|
|
28
|
+
'qmodel_38max', 'qmodel_latest', 'qmodel', 'qfmodel',
|
|
29
|
+
'kmodel_latest', 'kmodel', 'gmodel', 'gfmodel',
|
|
30
|
+
'dmodel', 'dfmodel', 'mmodel',
|
|
31
|
+
];
|
|
32
|
+
export const QODER_RSA_PUBLIC_KEY = RSA_KEY;
|
|
33
|
+
export const QODER_CONTEXT_TIER_ENV = 'QODER_CONTEXT_TIER';
|
|
34
|
+
export const QODER_CONTEXT_TIER_HEADROOM = 0.15;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Qoder COSY request signing: AES-128-CBC user blob + RSA-wrapped key + MD5 signature
|
|
2
|
+
// over payload || cosyKey || timestamp || body || sigPath, plus the Cosy-* header set.
|
|
3
|
+
// Pure: reads no env, touches no DB, logs nothing. The signed material is a credential —
|
|
4
|
+
// callers must never log the returned headers.
|
|
5
|
+
import { createCipheriv, createHash, publicEncrypt, randomUUID, constants as cryptoConstants } from 'node:crypto';
|
|
6
|
+
import { QODER_CLIENT_TYPE, QODER_DATA_POLICY, QODER_IDE_VERSION, QODER_LOGIN_VERSION, QODER_MACHINE_OS, QODER_MACHINE_TYPE, QODER_RSA_PUBLIC_KEY, } from './constants.js';
|
|
7
|
+
export function generateMachineId() {
|
|
8
|
+
return randomUUID();
|
|
9
|
+
}
|
|
10
|
+
/** Strip the leading `/algo`; matches the client convention the upstream validates against. */
|
|
11
|
+
export function computeSigPath(requestUrl) {
|
|
12
|
+
let pathname;
|
|
13
|
+
try {
|
|
14
|
+
pathname = new URL(requestUrl).pathname;
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return '';
|
|
18
|
+
}
|
|
19
|
+
return pathname.startsWith('/algo') ? pathname.slice('/algo'.length) : pathname;
|
|
20
|
+
}
|
|
21
|
+
function pkcs7Pad(data, blockSize) {
|
|
22
|
+
const padding = blockSize - (data.length % blockSize);
|
|
23
|
+
const padded = Buffer.alloc(data.length + padding, padding);
|
|
24
|
+
data.copy(padded, 0);
|
|
25
|
+
return padded;
|
|
26
|
+
}
|
|
27
|
+
function aesEncryptCbcBase64(plaintext, key) {
|
|
28
|
+
const keyBytes = Buffer.from(key, 'utf8');
|
|
29
|
+
if (keyBytes.length !== 16)
|
|
30
|
+
throw new Error(`qoder cosy: aes key must be 16 bytes, got ${keyBytes.length}`);
|
|
31
|
+
const cipher = createCipheriv('aes-128-cbc', keyBytes, keyBytes);
|
|
32
|
+
cipher.setAutoPadding(false);
|
|
33
|
+
return Buffer.concat([cipher.update(pkcs7Pad(Buffer.from(plaintext, 'utf8'), 16)), cipher.final()]).toString('base64');
|
|
34
|
+
}
|
|
35
|
+
const md5 = (input) => createHash('md5').update(input).digest('hex');
|
|
36
|
+
/**
|
|
37
|
+
* Build the full Cosy-* header map for one request.
|
|
38
|
+
* `body` must be the exact bytes that will be sent (the obfuscated body for chat,
|
|
39
|
+
* an empty buffer for GETs).
|
|
40
|
+
*/
|
|
41
|
+
export function buildCosyHeaders(body, requestUrl, creds) {
|
|
42
|
+
if (!creds?.userId)
|
|
43
|
+
throw new Error('qoder cosy: user id is empty');
|
|
44
|
+
if (!creds?.authToken)
|
|
45
|
+
throw new Error('qoder cosy: auth token is empty');
|
|
46
|
+
const bodyBuf = Buffer.isBuffer(body) ? body : typeof body === 'string' ? Buffer.from(body, 'latin1') : Buffer.from(body ?? []);
|
|
47
|
+
// Fresh AES key per request: first 16 chars of a uuid4, which also becomes the IV.
|
|
48
|
+
const aesKey = randomUUID().slice(0, 16);
|
|
49
|
+
const info = aesEncryptCbcBase64(JSON.stringify({
|
|
50
|
+
uid: creds.userId, security_oauth_token: creds.authToken, name: creds.name ?? '', aid: '', email: creds.email ?? '',
|
|
51
|
+
}), aesKey);
|
|
52
|
+
const cosyKey = publicEncrypt({ key: QODER_RSA_PUBLIC_KEY, padding: cryptoConstants.RSA_PKCS1_PADDING }, Buffer.from(aesKey, 'utf8')).toString('base64');
|
|
53
|
+
const timestamp = String(Math.floor(Date.now() / 1000));
|
|
54
|
+
const payloadB64 = Buffer.from(JSON.stringify({
|
|
55
|
+
version: 'v1', requestId: randomUUID(), info, cosyVersion: QODER_IDE_VERSION, ideVersion: '',
|
|
56
|
+
}), 'utf8').toString('base64');
|
|
57
|
+
const sigPath = computeSigPath(requestUrl);
|
|
58
|
+
const sig = md5(Buffer.from(`${payloadB64}\n${cosyKey}\n${timestamp}\n${bodyBuf.toString('latin1')}\n${sigPath}`, 'latin1'));
|
|
59
|
+
const machineId = creds.machineId || generateMachineId();
|
|
60
|
+
return {
|
|
61
|
+
Authorization: `Bearer COSY.${payloadB64}.${sig}`,
|
|
62
|
+
'Cosy-Key': cosyKey,
|
|
63
|
+
'Cosy-User': creds.userId,
|
|
64
|
+
'Cosy-Date': timestamp,
|
|
65
|
+
'Cosy-Version': QODER_IDE_VERSION,
|
|
66
|
+
'Cosy-Machineid': machineId,
|
|
67
|
+
'Cosy-Machinetoken': machineId,
|
|
68
|
+
'Cosy-Machinetype': QODER_MACHINE_TYPE,
|
|
69
|
+
'Cosy-Machineos': QODER_MACHINE_OS,
|
|
70
|
+
'Cosy-Clienttype': QODER_CLIENT_TYPE,
|
|
71
|
+
'Cosy-Clientip': '127.0.0.1',
|
|
72
|
+
'Cosy-Bodyhash': md5(bodyBuf),
|
|
73
|
+
'Cosy-Bodylength': String(bodyBuf.length),
|
|
74
|
+
'Cosy-Sigpath': sigPath,
|
|
75
|
+
'Cosy-Data-Policy': QODER_DATA_POLICY,
|
|
76
|
+
'Cosy-Organization-Id': '',
|
|
77
|
+
'Cosy-Organization-Tags': '',
|
|
78
|
+
'Login-Version': QODER_LOGIN_VERSION,
|
|
79
|
+
'X-Request-Id': randomUUID(),
|
|
80
|
+
};
|
|
81
|
+
}
|