dsh-qoder-connect 0.1.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/LICENSE +21 -0
- package/README.en.md +147 -0
- package/README.md +148 -0
- package/assets/1.png +0 -0
- package/assets/2.png +0 -0
- package/assets/3.png +0 -0
- package/assets/4.png +0 -0
- package/assets/5.png +0 -0
- package/assets/6.png +0 -0
- package/cordis.patch.yml +6 -0
- package/lib/bin.d.ts +6 -0
- package/lib/bin.js +291 -0
- package/lib/client.js +3545 -0
- package/lib/index.d.ts +1371 -0
- package/lib/index.js +1961 -0
- package/lib/variants-jbFhMKO8.js +4082 -0
- package/package.json +116 -0
|
@@ -0,0 +1,4082 @@
|
|
|
1
|
+
import crypto, { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { writeFileAtomic } from "@deepseek-ai/dsh-atomic-write";
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { resolveDshHome } from "@deepseek-ai/dsh-home-paths";
|
|
8
|
+
import { EMPTY_RESPONSE_CODE, LlmError, ProviderRequestId, ReasoningEffortId, ToolCallId, attributionHeaders, contentHasImage, createAssistantMessage, createToolResultMessage, createUserMessage } from "@deepseek-ai/dsh-llm";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { execFileSync } from "node:child_process";
|
|
11
|
+
//#region src/paths.ts
|
|
12
|
+
/**
|
|
13
|
+
* The plugin's data directory — the ONE place every file the plugin owns
|
|
14
|
+
* lives.
|
|
15
|
+
*
|
|
16
|
+
* Layout: `<profile>/.dsh-qoder-connect/state/` (the profile discovered
|
|
17
|
+
* the same way the credential store always did). Everything — PAT files,
|
|
18
|
+
* saved catalogs, probe records, the host heartbeat, and the machine-id
|
|
19
|
+
* seed — writes there, so a profile directory never collects loose
|
|
20
|
+
* `.qoder-*` files and the whole plugin's footprint is one folder.
|
|
21
|
+
*
|
|
22
|
+
* Fallbacks, in order: `DSH_QODER_DATA_DIR` env override → the discovered
|
|
23
|
+
* profile → the Harness home (a checkout running its own tests, or a host
|
|
24
|
+
* loading the plugin from outside any profile).
|
|
25
|
+
*
|
|
26
|
+
* Split out of `auth.ts` so catalog/probe/heartbeat stores can import the
|
|
27
|
+
* directory without pulling in the credential code (and its upstream
|
|
28
|
+
* dependency) — these modules stay leaf-light on purpose.
|
|
29
|
+
*
|
|
30
|
+
* @module dsh-qoder-connect/paths
|
|
31
|
+
*/
|
|
32
|
+
/** The per-profile directory the plugin's data folder lives under. */
|
|
33
|
+
const QODER_DATA_DIR_NAME = ".dsh-qoder-connect";
|
|
34
|
+
/** The directory inside the data dir where the rebuildable state files live. */
|
|
35
|
+
const QODER_STATE_DIR_NAME = "state";
|
|
36
|
+
/** Environment override for the whole data directory. */
|
|
37
|
+
const QODER_DATA_DIR_ENV = "DSH_QODER_DATA_DIR";
|
|
38
|
+
const PROFILES_DIR_NAME = "profiles";
|
|
39
|
+
/** The npm name of this package, as a profile's manifest declares it. */
|
|
40
|
+
const PLUGIN_PACKAGE_NAME = "dsh-qoder-connect";
|
|
41
|
+
function pluginPackageRoot() {
|
|
42
|
+
try {
|
|
43
|
+
return dirname(dirname(fileURLToPath(import.meta.url)));
|
|
44
|
+
} catch {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Whether a profile directory declares this plugin.
|
|
50
|
+
*
|
|
51
|
+
* Read from the profile's manifest rather than inferred from this module's own
|
|
52
|
+
* location, because DSH installs a plugin into a profile by *link*: the manifest
|
|
53
|
+
* carries `"dsh-qoder-connect": "link:/path/to/checkout"`, while Node
|
|
54
|
+
* resolves the module to that real path, which lies outside `$DSH_HOME` entirely.
|
|
55
|
+
* Walking up from the module would therefore miss the profile for exactly the
|
|
56
|
+
* install shape a developer uses.
|
|
57
|
+
*/
|
|
58
|
+
function profileDeclaresPlugin(profileDir) {
|
|
59
|
+
try {
|
|
60
|
+
const manifest = JSON.parse(readFileSync(join(profileDir, "package.json"), "utf8"));
|
|
61
|
+
return typeof manifest.dependencies?.[PLUGIN_PACKAGE_NAME] === "string" || typeof manifest.devDependencies?.[PLUGIN_PACKAGE_NAME] === "string";
|
|
62
|
+
} catch {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Whether a profile's installed copy of this plugin resolves to this package.
|
|
68
|
+
*
|
|
69
|
+
* This is what separates two profiles that both declare the plugin — a `web` and
|
|
70
|
+
* a `desktop` profile can each list it — so the data directory follows the
|
|
71
|
+
* profile whose copy is actually running rather than the first one found.
|
|
72
|
+
*/
|
|
73
|
+
function profileLinksToThisPackage(profileDir) {
|
|
74
|
+
const own = pluginPackageRoot();
|
|
75
|
+
if (own === void 0) return false;
|
|
76
|
+
try {
|
|
77
|
+
return realpathSync(join(profileDir, "node_modules", PLUGIN_PACKAGE_NAME)) === realpathSync(own);
|
|
78
|
+
} catch {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* The profile directory this plugin belongs to, or undefined when none can be
|
|
84
|
+
* determined.
|
|
85
|
+
*
|
|
86
|
+
* A single declaring profile is accepted without the link test, so a normal
|
|
87
|
+
* (non-linked) install still resolves.
|
|
88
|
+
*/
|
|
89
|
+
function discoverProfileDir() {
|
|
90
|
+
const profilesRoot = join(resolveDshHome(), PROFILES_DIR_NAME);
|
|
91
|
+
let entries;
|
|
92
|
+
try {
|
|
93
|
+
entries = readdirSync(profilesRoot, { withFileTypes: true });
|
|
94
|
+
} catch {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const candidates = [];
|
|
98
|
+
for (const entry of entries) {
|
|
99
|
+
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
|
|
100
|
+
const dir = join(profilesRoot, entry.name);
|
|
101
|
+
if (profileDeclaresPlugin(dir)) candidates.push(dir);
|
|
102
|
+
}
|
|
103
|
+
if (candidates.length === 0) return void 0;
|
|
104
|
+
if (candidates.length === 1) return candidates[0];
|
|
105
|
+
return candidates.find((candidate) => profileLinksToThisPackage(candidate));
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* The plugin's data directory: `<profile>/.dsh-qoder-connect`.
|
|
109
|
+
*
|
|
110
|
+
* Falls back to the Harness home when no profile can be discovered — a
|
|
111
|
+
* checkout running its own tests, or a host that loads the plugin from
|
|
112
|
+
* outside a profile — so the plugin always has somewhere to write, and
|
|
113
|
+
* `DSH_QODER_DATA_DIR` overrides either way.
|
|
114
|
+
*/
|
|
115
|
+
function qoderPluginDataDir() {
|
|
116
|
+
const override = process.env[QODER_DATA_DIR_ENV];
|
|
117
|
+
if (override !== void 0 && override.trim() !== "") return override;
|
|
118
|
+
const base = discoverProfileDir() ?? resolveDshHome();
|
|
119
|
+
return join(base, QODER_DATA_DIR_NAME);
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* The directory the rebuildable state files live in:
|
|
123
|
+
* `<data dir>/state/` (saved catalogs, probe records, the host heartbeat).
|
|
124
|
+
*/
|
|
125
|
+
function qoderStateDir() {
|
|
126
|
+
return join(qoderPluginDataDir(), QODER_STATE_DIR_NAME);
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* The plugin-owned seed for the Qoder transport's machine id.
|
|
130
|
+
*
|
|
131
|
+
* The transport's `getMachineId(paths)` helper reads a list of trusted
|
|
132
|
+
* locations and writes its created UUID into the last one; this is the
|
|
133
|
+
* DSH-side location the host wires into that chain, so the identifier stays
|
|
134
|
+
* inside the plugin's own folder instead of scattering files near `$HOME`.
|
|
135
|
+
*/
|
|
136
|
+
function qoderMachineIdPath() {
|
|
137
|
+
return join(qoderStateDir(), ".qoder-machine-id");
|
|
138
|
+
}
|
|
139
|
+
//#endregion
|
|
140
|
+
//#region src/auth.ts
|
|
141
|
+
/**
|
|
142
|
+
* Qoder credential storage — one Personal Access Token per variant.
|
|
143
|
+
*
|
|
144
|
+
* Every credential the plugin holds is a PAT the human pasted into the
|
|
145
|
+
* variant's card (or exported into the environment, or saved by the CLI).
|
|
146
|
+
* There is no device flow, no token refresh, and no expiry: a PAT is valid
|
|
147
|
+
* until the user revokes it, so this store only reads, writes, and deletes a
|
|
148
|
+
* small JSON document.
|
|
149
|
+
*
|
|
150
|
+
* One file per variant inside the plugin's data directory
|
|
151
|
+
* (`<.dsh-qoder-connect>/`). The on-disk schema is
|
|
152
|
+
* `{version: 2, pat, region, savedAt}` — deliberately narrower than the
|
|
153
|
+
* WorkBuddy-era document it replaces: anything this file does not name is
|
|
154
|
+
* not a fact the plugin knows.
|
|
155
|
+
*
|
|
156
|
+
* @module dsh-qoder-connect/auth
|
|
157
|
+
*/
|
|
158
|
+
/** The on-disk document version this writer produces. */
|
|
159
|
+
const AUTH_SCHEMA_VERSION = 2;
|
|
160
|
+
/** Environment fallback for the global arm when no file exists. */
|
|
161
|
+
const QODER_PAT_ENV_GLOBAL = "QODER_PERSONAL_ACCESS_TOKEN";
|
|
162
|
+
/** Environment fallback for the China arm when no file exists. */
|
|
163
|
+
const QODER_PAT_ENV_CN = "QODER_CN_PERSONAL_ACCESS_TOKEN";
|
|
164
|
+
/** Basename of the China variant's credential file inside the data directory. */
|
|
165
|
+
const QODER_AUTH_FILENAME = ".qoder-auth.json";
|
|
166
|
+
/** Last four characters of a token, for display. */
|
|
167
|
+
function patTail(pat) {
|
|
168
|
+
const trimmed = pat.trim();
|
|
169
|
+
return trimmed.length >= 4 ? trimmed.slice(-4) : void 0;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Stable identity key for a credential, used by probe records and saved
|
|
173
|
+
* catalogs.
|
|
174
|
+
*
|
|
175
|
+
* A PAT is a bearer secret, so the key is its one-way hash rather than any
|
|
176
|
+
* suffix of it: surfaces that show the key (cards, JSON diagnostics) leak
|
|
177
|
+
* nothing usable, while two reads of the same token still correlate locally.
|
|
178
|
+
*/
|
|
179
|
+
function qoderCredentialIdentity(credential) {
|
|
180
|
+
return `pat:${createHash("sha256").update(credential.pat).digest("hex").slice(0, 16)}`;
|
|
181
|
+
}
|
|
182
|
+
function isRecord$1(value) {
|
|
183
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
184
|
+
}
|
|
185
|
+
function isRegion(value) {
|
|
186
|
+
return value === "global" || value === "china";
|
|
187
|
+
}
|
|
188
|
+
var QoderCredentialStore = class {
|
|
189
|
+
variant;
|
|
190
|
+
ownPath;
|
|
191
|
+
logger;
|
|
192
|
+
/** The legacy-file warning is emitted once per process, not per read. */
|
|
193
|
+
legacyWarned = false;
|
|
194
|
+
constructor(options) {
|
|
195
|
+
this.variant = options.variant;
|
|
196
|
+
this.ownPath = options.ownPath ?? join(qoderPluginDataDir(), options.variant.ownFilename);
|
|
197
|
+
this.logger = options.logger;
|
|
198
|
+
}
|
|
199
|
+
/** Absolute path of this variant's credential file, for display and tests. */
|
|
200
|
+
ownAuthPath() {
|
|
201
|
+
return this.ownPath;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* The credential currently in effect, or `undefined` when there is none.
|
|
205
|
+
*
|
|
206
|
+
* Resolution order: the plugin-owned file, then the arm's environment
|
|
207
|
+
* variable (only when no file exists — a saved token always wins over a
|
|
208
|
+
* stray env), then nothing. There is no refresh and no expiry check: a PAT
|
|
209
|
+
* lives until revoked.
|
|
210
|
+
*/
|
|
211
|
+
async current() {
|
|
212
|
+
const stored = await this.readOwn();
|
|
213
|
+
if (stored !== void 0) return stored;
|
|
214
|
+
return this.fromEnvironment();
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* The credential, or a thrown `MISSING_CREDENTIAL` error.
|
|
218
|
+
*
|
|
219
|
+
* Callers that serve requests (the shim, the catalog sweep) use this so the
|
|
220
|
+
* absence of a token surfaces as the same classified failure the transport
|
|
221
|
+
* itself raises when a saved token turns out empty.
|
|
222
|
+
*/
|
|
223
|
+
async resolve() {
|
|
224
|
+
const credential = await this.current();
|
|
225
|
+
if (credential === void 0 || credential.pat.trim() === "") throw new Error("qoder: MISSING_CREDENTIAL — no Personal Access Token is configured. Add one in the Qoder settings card.");
|
|
226
|
+
return credential;
|
|
227
|
+
}
|
|
228
|
+
/** The token in effect as a bare string, rejecting when there is none. */
|
|
229
|
+
async patPromise() {
|
|
230
|
+
return (await this.resolve()).pat;
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Persist a token for this variant. The caller is responsible for having
|
|
234
|
+
* validated it against the region first (`validateApiKey` upstream).
|
|
235
|
+
*/
|
|
236
|
+
async save(pat, source = "card") {
|
|
237
|
+
const trimmed = pat.trim();
|
|
238
|
+
if (trimmed === "") throw new Error("qoder: cannot save an empty Personal Access Token");
|
|
239
|
+
const credential = {
|
|
240
|
+
pat: trimmed,
|
|
241
|
+
region: this.variant.region,
|
|
242
|
+
savedAtMs: Date.now(),
|
|
243
|
+
source
|
|
244
|
+
};
|
|
245
|
+
await this.writeOwn(credential);
|
|
246
|
+
return credential;
|
|
247
|
+
}
|
|
248
|
+
/** Remove the stored credential. A missing file is not an error. */
|
|
249
|
+
async clear() {
|
|
250
|
+
try {
|
|
251
|
+
await rm(this.ownPath, { force: true });
|
|
252
|
+
} catch {}
|
|
253
|
+
}
|
|
254
|
+
/** Alias kept for call-site readability (`logout` is just clearing). */
|
|
255
|
+
async logout() {
|
|
256
|
+
await this.clear();
|
|
257
|
+
}
|
|
258
|
+
/** Redacted summary for the card, the PAT route, and doctor output. */
|
|
259
|
+
async status() {
|
|
260
|
+
const base = {
|
|
261
|
+
filePath: this.ownPath,
|
|
262
|
+
region: this.variant.region
|
|
263
|
+
};
|
|
264
|
+
const credential = await this.current();
|
|
265
|
+
if (credential === void 0) return {
|
|
266
|
+
state: "missing",
|
|
267
|
+
...base,
|
|
268
|
+
...this.pendingLegacyReason === void 0 ? {} : { reason: this.pendingLegacyReason }
|
|
269
|
+
};
|
|
270
|
+
const tail = patTail(credential.pat);
|
|
271
|
+
return {
|
|
272
|
+
state: "configured",
|
|
273
|
+
...base,
|
|
274
|
+
pat: {
|
|
275
|
+
source: credential.source,
|
|
276
|
+
...credential.savedAtMs === void 0 ? {} : { savedAtMs: credential.savedAtMs },
|
|
277
|
+
...tail === void 0 ? {} : { patTail: tail }
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
/** Set when a read found a stale WorkBuddy-era file; consumed once. */
|
|
282
|
+
pendingLegacyReason;
|
|
283
|
+
async fromEnvironment() {
|
|
284
|
+
const name = this.variant.region === "china" ? QODER_PAT_ENV_CN : QODER_PAT_ENV_GLOBAL;
|
|
285
|
+
const value = process.env[name]?.trim();
|
|
286
|
+
if (value === void 0 || value === "") return void 0;
|
|
287
|
+
return {
|
|
288
|
+
pat: value,
|
|
289
|
+
region: this.variant.region,
|
|
290
|
+
source: "env"
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
async readOwn() {
|
|
294
|
+
let text;
|
|
295
|
+
try {
|
|
296
|
+
text = await readFile(this.ownPath, "utf8");
|
|
297
|
+
} catch (error) {
|
|
298
|
+
this.pendingLegacyReason = void 0;
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
let parsed;
|
|
302
|
+
try {
|
|
303
|
+
parsed = JSON.parse(text);
|
|
304
|
+
} catch {
|
|
305
|
+
this.pendingLegacyReason = void 0;
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
if (!isRecord$1(parsed)) {
|
|
309
|
+
this.pendingLegacyReason = void 0;
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
if (typeof parsed["accessToken"] === "string" && parsed["pat"] === void 0) {
|
|
313
|
+
this.pendingLegacyReason = "stale WorkBuddy credential file; save a Qoder Personal Access Token instead";
|
|
314
|
+
if (!this.legacyWarned) {
|
|
315
|
+
this.legacyWarned = true;
|
|
316
|
+
this.logger?.warn?.(`qoder: ${this.variant.displayName}: ${this.ownPath} holds a WorkBuddy-era credential; it is ignored. Save a Qoder Personal Access Token to replace it.`);
|
|
317
|
+
}
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
this.pendingLegacyReason = void 0;
|
|
321
|
+
const pat = typeof parsed["pat"] === "string" ? parsed["pat"].trim() : "";
|
|
322
|
+
if (pat === "") return void 0;
|
|
323
|
+
const region = isRegion(parsed["region"]) ? parsed["region"] : this.variant.region;
|
|
324
|
+
const savedAt = typeof parsed["savedAt"] === "number" && Number.isFinite(parsed["savedAt"]) ? parsed["savedAt"] : void 0;
|
|
325
|
+
return {
|
|
326
|
+
pat,
|
|
327
|
+
region,
|
|
328
|
+
...savedAt === void 0 ? {} : { savedAtMs: savedAt },
|
|
329
|
+
source: "card"
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
async writeOwn(credential) {
|
|
333
|
+
const document = {
|
|
334
|
+
version: AUTH_SCHEMA_VERSION,
|
|
335
|
+
pat: credential.pat,
|
|
336
|
+
region: credential.region,
|
|
337
|
+
savedAt: credential.savedAtMs ?? Date.now()
|
|
338
|
+
};
|
|
339
|
+
const directory = dirname(this.ownPath);
|
|
340
|
+
try {
|
|
341
|
+
await mkdir(directory, {
|
|
342
|
+
recursive: true,
|
|
343
|
+
mode: 448
|
|
344
|
+
});
|
|
345
|
+
} catch {}
|
|
346
|
+
await writeFileAtomic(this.ownPath, `${JSON.stringify(document, null, 2)}\n`, {
|
|
347
|
+
mode: 384,
|
|
348
|
+
dirMode: 448
|
|
349
|
+
});
|
|
350
|
+
this.pendingLegacyReason = void 0;
|
|
351
|
+
}
|
|
352
|
+
};
|
|
353
|
+
/**
|
|
354
|
+
* The plugin-owned credential path for one variant's default filename.
|
|
355
|
+
* Kept as a free function for the CLI, which builds no store per display line.
|
|
356
|
+
*/
|
|
357
|
+
function qoderOwnAuthPath(variant) {
|
|
358
|
+
return join(qoderPluginDataDir(), variant.ownFilename);
|
|
359
|
+
}
|
|
360
|
+
//#endregion
|
|
361
|
+
//#region src/status-paths.ts
|
|
362
|
+
/** Plugin-owned status endpoint consumed by its browser half. */
|
|
363
|
+
const QODER_STATUS_PATH = "/plugins/dsh-qoder-connect/status";
|
|
364
|
+
/**
|
|
365
|
+
* Plugin-owned probe control endpoint.
|
|
366
|
+
*
|
|
367
|
+
* Separate from the status route because it accepts writes: the status route's
|
|
368
|
+
* loopback Host/Origin guard protects against a DNS-rebinding *page*, which is
|
|
369
|
+
* not the same as authorizing a state-changing action. This route therefore
|
|
370
|
+
* also requires the in-process key the browser half receives with the status
|
|
371
|
+
* document.
|
|
372
|
+
*/
|
|
373
|
+
const QODER_PROBE_PATH = "/plugins/dsh-qoder-connect/probe";
|
|
374
|
+
/**
|
|
375
|
+
* Plugin-owned PAT endpoint, one per variant.
|
|
376
|
+
*
|
|
377
|
+
* A POST here saves (validates and persists) a Personal Access Token, or
|
|
378
|
+
* clears the stored one. Unlike the read-only status GET it is a write —
|
|
379
|
+
* it persists a credential — so it also requires the in-process key the
|
|
380
|
+
* browser half receives with the status document.
|
|
381
|
+
*/
|
|
382
|
+
const QODER_AUTH_PATH = "/plugins/dsh-qoder-connect/auth";
|
|
383
|
+
/**
|
|
384
|
+
* The international (Qoder Global) variant's own triple of routes.
|
|
385
|
+
*
|
|
386
|
+
* Kept as separate constants rather than a computed suffix so both halves
|
|
387
|
+
* reference literal strings: the browser bundle and the host bundle are built
|
|
388
|
+
* independently, and a shared expression is one build-config drift away from
|
|
389
|
+
* the desk asking a route the host never mounted.
|
|
390
|
+
*/
|
|
391
|
+
const QODER_GLOBAL_STATUS_PATH = "/plugins/dsh-qoder-connect/global/status";
|
|
392
|
+
const QODER_GLOBAL_PROBE_PATH = "/plugins/dsh-qoder-connect/global/probe";
|
|
393
|
+
const QODER_GLOBAL_AUTH_PATH = "/plugins/dsh-qoder-connect/global/auth";
|
|
394
|
+
//#endregion
|
|
395
|
+
//#region src/catalog.ts
|
|
396
|
+
/**
|
|
397
|
+
* The built-in roster, transcribed from the transport's own `defaultModels`
|
|
398
|
+
* (`src/qoder/catalog.ts`) — the Qoder model keys both regions start from
|
|
399
|
+
* before the first successful discovery: `cmodel`, `auto`, `ultimate`,
|
|
400
|
+
* `performance`, `efficient`, `lite`.
|
|
401
|
+
*
|
|
402
|
+
* It exists so the provider registers with a usable catalog while the first
|
|
403
|
+
* fetch is in flight or the endpoint is unreachable, and it is deliberately
|
|
404
|
+
* *not* a promise about the upstream's current state: the discovery answer
|
|
405
|
+
* replaces it at startup. No per-region roster is invented here — the
|
|
406
|
+
* transport ships one shared default set, and this mirror keeps the same
|
|
407
|
+
* discipline. Reasoning and rate metadata are absent because the defaults
|
|
408
|
+
* declare none; those rows offer no thinking control until the upstream says
|
|
409
|
+
* otherwise.
|
|
410
|
+
*/
|
|
411
|
+
const FALLBACK_QODER_MODELS = [
|
|
412
|
+
{
|
|
413
|
+
id: "cmodel",
|
|
414
|
+
name: "Cantus (Qoder)",
|
|
415
|
+
contextWindow: 1e6,
|
|
416
|
+
maxTokens: 32768,
|
|
417
|
+
supportsImages: true,
|
|
418
|
+
billing: {
|
|
419
|
+
free: false,
|
|
420
|
+
rateUnknown: true
|
|
421
|
+
},
|
|
422
|
+
source: "system"
|
|
423
|
+
},
|
|
424
|
+
{
|
|
425
|
+
id: "auto",
|
|
426
|
+
name: "Qoder Auto",
|
|
427
|
+
contextWindow: 18e4,
|
|
428
|
+
maxTokens: 32768,
|
|
429
|
+
supportsImages: true,
|
|
430
|
+
billing: {
|
|
431
|
+
free: false,
|
|
432
|
+
rateUnknown: true
|
|
433
|
+
},
|
|
434
|
+
source: "system"
|
|
435
|
+
},
|
|
436
|
+
{
|
|
437
|
+
id: "ultimate",
|
|
438
|
+
name: "Qoder Ultimate",
|
|
439
|
+
contextWindow: 1e6,
|
|
440
|
+
maxTokens: 32768,
|
|
441
|
+
supportsImages: true,
|
|
442
|
+
billing: {
|
|
443
|
+
free: false,
|
|
444
|
+
rateUnknown: true
|
|
445
|
+
},
|
|
446
|
+
source: "system"
|
|
447
|
+
},
|
|
448
|
+
{
|
|
449
|
+
id: "performance",
|
|
450
|
+
name: "Qoder Performance",
|
|
451
|
+
contextWindow: 1e6,
|
|
452
|
+
maxTokens: 32768,
|
|
453
|
+
supportsImages: true,
|
|
454
|
+
billing: {
|
|
455
|
+
free: false,
|
|
456
|
+
rateUnknown: true
|
|
457
|
+
},
|
|
458
|
+
source: "system"
|
|
459
|
+
},
|
|
460
|
+
{
|
|
461
|
+
id: "efficient",
|
|
462
|
+
name: "Qoder Efficient",
|
|
463
|
+
contextWindow: 18e4,
|
|
464
|
+
maxTokens: 32768,
|
|
465
|
+
supportsImages: true,
|
|
466
|
+
billing: {
|
|
467
|
+
free: false,
|
|
468
|
+
rateUnknown: true
|
|
469
|
+
},
|
|
470
|
+
source: "system"
|
|
471
|
+
},
|
|
472
|
+
{
|
|
473
|
+
id: "lite",
|
|
474
|
+
name: "Qoder Lite",
|
|
475
|
+
contextWindow: 18e4,
|
|
476
|
+
maxTokens: 32768,
|
|
477
|
+
supportsImages: false,
|
|
478
|
+
billing: {
|
|
479
|
+
free: false,
|
|
480
|
+
rateUnknown: true
|
|
481
|
+
},
|
|
482
|
+
source: "system"
|
|
483
|
+
}
|
|
484
|
+
];
|
|
485
|
+
/**
|
|
486
|
+
* Mutable catalog shared by the shim's `/v1/models` and the adapter.
|
|
487
|
+
*
|
|
488
|
+
* Visibility is separate from content. A variant with no usable credential
|
|
489
|
+
* must expose *no* models rather than a fallback roster: the DSH model picker
|
|
490
|
+
* drops an empty group, so an empty catalog is exactly how a provider hides
|
|
491
|
+
* without touching registration. Serving the fallback to a signed-out user
|
|
492
|
+
* instead offers models that can only fail (the transport throws
|
|
493
|
+
* `MISSING_CREDENTIAL` on the first message), which is worse than showing
|
|
494
|
+
* nothing.
|
|
495
|
+
*
|
|
496
|
+
* The flag defaults to visible so a directly-constructed catalog behaves as
|
|
497
|
+
* it always has; the plugin runtime applies the credential gate.
|
|
498
|
+
*/
|
|
499
|
+
var QoderCatalog = class {
|
|
500
|
+
models;
|
|
501
|
+
visible = true;
|
|
502
|
+
useMaximumContextWindow = false;
|
|
503
|
+
/** Per-model window overrides (model id → tokens); an override wins over the preference. */
|
|
504
|
+
modelContextWindows = {};
|
|
505
|
+
constructor(initial = FALLBACK_QODER_MODELS) {
|
|
506
|
+
this.models = initial;
|
|
507
|
+
}
|
|
508
|
+
/** Current entries; empty while the variant has no usable credential. */
|
|
509
|
+
current() {
|
|
510
|
+
if (!this.visible) return [];
|
|
511
|
+
return this.models.map((model) => {
|
|
512
|
+
const override = this.modelContextWindows[model.id];
|
|
513
|
+
if (override !== void 0 && override > 0) return {
|
|
514
|
+
...model,
|
|
515
|
+
defaultContextWindow: model.defaultContextWindow ?? model.contextWindow,
|
|
516
|
+
contextWindow: override
|
|
517
|
+
};
|
|
518
|
+
const maximum = model.supportedContextWindows === void 0 ? void 0 : Math.max(...model.supportedContextWindows);
|
|
519
|
+
return this.useMaximumContextWindow && maximum !== void 0 && maximum > model.contextWindow ? {
|
|
520
|
+
...model,
|
|
521
|
+
defaultContextWindow: model.defaultContextWindow ?? model.contextWindow,
|
|
522
|
+
contextWindow: maximum
|
|
523
|
+
} : model;
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
/** Replace the list; callers invalidate their adapter snapshot after this. */
|
|
527
|
+
set(models) {
|
|
528
|
+
this.models = [...models];
|
|
529
|
+
}
|
|
530
|
+
/** Whether this variant's models are exposed at all. */
|
|
531
|
+
isVisible() {
|
|
532
|
+
return this.visible;
|
|
533
|
+
}
|
|
534
|
+
/**
|
|
535
|
+
* Show or hide the whole catalog. Returns whether the value changed, so the
|
|
536
|
+
* caller can skip an invalidation that would re-render an identical list.
|
|
537
|
+
*/
|
|
538
|
+
setVisible(visible) {
|
|
539
|
+
if (this.visible === visible) return false;
|
|
540
|
+
this.visible = visible;
|
|
541
|
+
return true;
|
|
542
|
+
}
|
|
543
|
+
/** Select the largest declared window where the upstream offers one. */
|
|
544
|
+
setUseMaximumContextWindow(useMaximum) {
|
|
545
|
+
if (this.useMaximumContextWindow === useMaximum) return false;
|
|
546
|
+
this.useMaximumContextWindow = useMaximum;
|
|
547
|
+
return true;
|
|
548
|
+
}
|
|
549
|
+
/**
|
|
550
|
+
* Replace the per-model window overrides wholesale. Returns whether the map
|
|
551
|
+
* changed, so the caller can skip an invalidation over an identical write.
|
|
552
|
+
*/
|
|
553
|
+
setModelContextWindows(modelContextWindows) {
|
|
554
|
+
const next = { ...modelContextWindows };
|
|
555
|
+
if (JSON.stringify(next) === JSON.stringify(this.modelContextWindows)) return false;
|
|
556
|
+
this.modelContextWindows = next;
|
|
557
|
+
return true;
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* Models to fall back to when the upstream fetch fails; ignores
|
|
561
|
+
* visibility, because the caller asking for the fallback already knows the
|
|
562
|
+
* credential state.
|
|
563
|
+
*/
|
|
564
|
+
fallback() {
|
|
565
|
+
return this.models;
|
|
566
|
+
}
|
|
567
|
+
};
|
|
568
|
+
//#endregion
|
|
569
|
+
//#region src/catalog-store.ts
|
|
570
|
+
/**
|
|
571
|
+
* The last catalog that actually loaded, kept per variant and per account.
|
|
572
|
+
*
|
|
573
|
+
* Both the plan (§4 "降级顺序为同账号的最近成功目录 → 本版内置保守目录")
|
|
574
|
+
* and the README promise this fallback, and without it a restart always drops
|
|
575
|
+
* the user to the built-in roster even when a good catalog was fetched minutes
|
|
576
|
+
* earlier. The built-in roster is a snapshot taken once; a fetched catalog is
|
|
577
|
+
* what the upstream actually serves to this account.
|
|
578
|
+
*
|
|
579
|
+
* What it deliberately is *not*:
|
|
580
|
+
*
|
|
581
|
+
* - not a cache with a freshness policy — it never prevents a fetch, it only
|
|
582
|
+
* answers when a fetch cannot;
|
|
583
|
+
* - not shared across accounts (a different account can see a different roster),
|
|
584
|
+
* nor across variants (the two regions serve overlapping ids with different
|
|
585
|
+
* capacity and rate answers);
|
|
586
|
+
* - not a place for secrets: model metadata only, never a token. The account
|
|
587
|
+
* key is a one-way hash of the credential, not the credential itself.
|
|
588
|
+
*
|
|
589
|
+
* @module dsh-qoder-connect/catalog-store
|
|
590
|
+
*/
|
|
591
|
+
/** On-disk format this reader accepts; other versions are discarded. */
|
|
592
|
+
const CATALOG_FORMAT_VERSION = 1;
|
|
593
|
+
/** Plugin-owned saved-catalog path inside the plugin's state directory. */
|
|
594
|
+
function qoderCatalogPath(filename) {
|
|
595
|
+
return join(qoderStateDir(), filename);
|
|
596
|
+
}
|
|
597
|
+
/** Whether a parsed value is a model row worth keeping. */
|
|
598
|
+
function isModel(value) {
|
|
599
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
600
|
+
const row = value;
|
|
601
|
+
return typeof row["id"] === "string" && row["id"] !== "" && typeof row["name"] === "string" && typeof row["contextWindow"] === "number" && Number.isFinite(row["contextWindow"]) && typeof row["maxTokens"] === "number" && Number.isFinite(row["maxTokens"]) && typeof row["supportsImages"] === "boolean";
|
|
602
|
+
}
|
|
603
|
+
/** Whether a parsed value is a saved catalog this reader can trust. */
|
|
604
|
+
function isSaved(value) {
|
|
605
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
606
|
+
const entry = value;
|
|
607
|
+
if (typeof entry["account"] !== "string" || entry["account"] === "") return false;
|
|
608
|
+
if (typeof entry["source"] !== "string" || entry["source"] === "") return false;
|
|
609
|
+
if (typeof entry["fetchedAtMs"] !== "number" || !Number.isFinite(entry["fetchedAtMs"])) return false;
|
|
610
|
+
const models = entry["models"];
|
|
611
|
+
if (!Array.isArray(models) || models.length === 0) return false;
|
|
612
|
+
return models.every(isModel);
|
|
613
|
+
}
|
|
614
|
+
/**
|
|
615
|
+
* The last successful catalog per account, read once and written atomically.
|
|
616
|
+
*
|
|
617
|
+
* Malformed content reads as "nothing saved" rather than throwing: this file
|
|
618
|
+
* is an optimization for the offline and first-seconds cases, and a corrupt one
|
|
619
|
+
* must never be able to stop the plugin from serving models.
|
|
620
|
+
*/
|
|
621
|
+
var QoderCatalogStore = class {
|
|
622
|
+
path;
|
|
623
|
+
entries;
|
|
624
|
+
constructor(options = {}) {
|
|
625
|
+
this.path = typeof options === "string" ? options : options.path ?? qoderCatalogPath(".qoder-catalog.json");
|
|
626
|
+
}
|
|
627
|
+
/** Resolved state-file path, for the CLI and tests. */
|
|
628
|
+
filePath() {
|
|
629
|
+
return this.path;
|
|
630
|
+
}
|
|
631
|
+
load() {
|
|
632
|
+
if (this.entries !== void 0) return this.entries;
|
|
633
|
+
const entries = {};
|
|
634
|
+
if (existsSync(this.path)) try {
|
|
635
|
+
const parsed = JSON.parse(readFileSync(this.path, "utf8"));
|
|
636
|
+
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
|
|
637
|
+
const document = parsed;
|
|
638
|
+
const raw = document["version"] === CATALOG_FORMAT_VERSION ? document["entries"] : void 0;
|
|
639
|
+
if (typeof raw === "object" && raw !== null && !Array.isArray(raw)) {
|
|
640
|
+
for (const [key, value] of Object.entries(raw)) if (isSaved(value)) entries[key] = value;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
} catch {}
|
|
644
|
+
this.entries = entries;
|
|
645
|
+
return entries;
|
|
646
|
+
}
|
|
647
|
+
/** The saved catalog for one account, or `undefined` when there is none. */
|
|
648
|
+
get(account) {
|
|
649
|
+
const entry = this.load()[account];
|
|
650
|
+
return entry === void 0 ? void 0 : entry;
|
|
651
|
+
}
|
|
652
|
+
/**
|
|
653
|
+
* Remember a catalog for an account, replacing whatever was saved before.
|
|
654
|
+
*
|
|
655
|
+
* A failed write is swallowed: the plugin has already served these models,
|
|
656
|
+
* and losing the *memory* of them is not worth surfacing.
|
|
657
|
+
*/
|
|
658
|
+
set(account, catalog) {
|
|
659
|
+
const entries = this.load();
|
|
660
|
+
entries[account] = {
|
|
661
|
+
account,
|
|
662
|
+
...catalog
|
|
663
|
+
};
|
|
664
|
+
this.persist();
|
|
665
|
+
}
|
|
666
|
+
/** Forget one account's catalog — used when that account signs out. */
|
|
667
|
+
delete(account) {
|
|
668
|
+
const entries = this.load();
|
|
669
|
+
if (!(account in entries)) return;
|
|
670
|
+
delete entries[account];
|
|
671
|
+
this.persist();
|
|
672
|
+
}
|
|
673
|
+
persist() {
|
|
674
|
+
const directory = dirname(this.path);
|
|
675
|
+
try {
|
|
676
|
+
if (!existsSync(directory)) mkdirSync(directory, { recursive: true });
|
|
677
|
+
const document = {
|
|
678
|
+
version: CATALOG_FORMAT_VERSION,
|
|
679
|
+
entries: this.load()
|
|
680
|
+
};
|
|
681
|
+
const temporary = resolve(`${this.path}.tmp`);
|
|
682
|
+
writeFileSync(temporary, `${JSON.stringify(document, null, 2)}\n`, { mode: 384 });
|
|
683
|
+
renameSync(temporary, this.path);
|
|
684
|
+
} catch {}
|
|
685
|
+
}
|
|
686
|
+
};
|
|
687
|
+
//#endregion
|
|
688
|
+
//#region src/probe.ts
|
|
689
|
+
/**
|
|
690
|
+
* The reasoning-effort probe: decide whether a model's `reasoning_effort`
|
|
691
|
+
* parameter is actually validated, and if so which canonical values it accepts.
|
|
692
|
+
*
|
|
693
|
+
* Implements `docs/reasoning-effort-probe-plan.md` §4. The order matters and is
|
|
694
|
+
* not an optimization:
|
|
695
|
+
*
|
|
696
|
+
* 1. **Baseline** (no `reasoning_effort`) proves the model, credential, and
|
|
697
|
+
* request shape work at all, so a later rejection can be attributed.
|
|
698
|
+
* 2. **Sentinel** (a fresh random, impossible-to-collide value) answers the one
|
|
699
|
+
* question a per-level sweep cannot: does the upstream validate the field?
|
|
700
|
+
* A model that accepts the sentinel answers 200 to *everything*, so its
|
|
701
|
+
* per-level results would be uniformly false positives.
|
|
702
|
+
* 3. **Levels**, only after the sentinel was refused.
|
|
703
|
+
*
|
|
704
|
+
* The result is an observation, never a capability claim. Even a fully
|
|
705
|
+
* successful sweep means "the upstream accepted these spellings", not "these
|
|
706
|
+
* spellings change how the model thinks".
|
|
707
|
+
*
|
|
708
|
+
* @module dsh-qoder-connect/probe
|
|
709
|
+
*/
|
|
710
|
+
/**
|
|
711
|
+
* The canonical values a probe tests, in a fixed order.
|
|
712
|
+
*
|
|
713
|
+
* `minimal` is absent: it appears in no upstream vocabulary. `off` is absent
|
|
714
|
+
* by policy — disabling thinking is a separate capability the upstream must
|
|
715
|
+
* declare through `canDisableThinking`, never something probing may infer.
|
|
716
|
+
*/
|
|
717
|
+
const PROBE_EFFORT_CANDIDATES = [
|
|
718
|
+
"low",
|
|
719
|
+
"medium",
|
|
720
|
+
"high",
|
|
721
|
+
"xhigh",
|
|
722
|
+
"max"
|
|
723
|
+
];
|
|
724
|
+
/** Prompt body used by every probe request; carries nothing user-specific. */
|
|
725
|
+
const PROBE_PROMPT = "ping";
|
|
726
|
+
/** Default sentinel: unmistakably non-canonical, different on every call. */
|
|
727
|
+
function randomSentinel() {
|
|
728
|
+
return `probe_sentinel_${randomBytes(12).toString("hex")}`;
|
|
729
|
+
}
|
|
730
|
+
/**
|
|
731
|
+
* The transport's rejection code for an effort the model does not advertise
|
|
732
|
+
* (`src/qoder/transport/wire/serialize.ts`).
|
|
733
|
+
*
|
|
734
|
+
* With the Qoder upstream this rejection is raised *locally* against the
|
|
735
|
+
* discovery catalog before any network call: a probe therefore measures the
|
|
736
|
+
* catalog's declaration as much as the endpoint behind it. That is a weaker
|
|
737
|
+
* finding than the WorkBuddy-era one, and it is what the plan's Stage D will
|
|
738
|
+
* weigh; the sequence below still distinguishes "advertises and validates"
|
|
739
|
+
* (per-level refusals after an accepted baseline) from "accepts anything the
|
|
740
|
+
* catalog does not name" — the sentinel answers that question against the
|
|
741
|
+
* same vocabulary the request path will later enforce.
|
|
742
|
+
*/
|
|
743
|
+
const INVALID_EFFORT_CODE = "UNSUPPORTED_REASONING_EFFORT";
|
|
744
|
+
/** Whether an attempt is an attributable rejection of the effort value. */
|
|
745
|
+
function isEffortRejection(attempt) {
|
|
746
|
+
return attempt.status === 400 && attempt.errorCode === INVALID_EFFORT_CODE;
|
|
747
|
+
}
|
|
748
|
+
/** Whether an attempt shows the upstream accepted the request and streamed. */
|
|
749
|
+
function isAcceptance(attempt) {
|
|
750
|
+
return attempt.status === 200 && attempt.streamed;
|
|
751
|
+
}
|
|
752
|
+
/** Why an attempt ended in `unknown`, phrased for a log line. */
|
|
753
|
+
function unknownReason(stage, attempt) {
|
|
754
|
+
const code = attempt.errorCode === void 0 ? "" : ` (${attempt.errorCode})`;
|
|
755
|
+
const detail = attempt.detail === void 0 ? "" : `: ${attempt.detail}`;
|
|
756
|
+
return `${stage} status ${attempt.status}${code}${detail}`;
|
|
757
|
+
}
|
|
758
|
+
/**
|
|
759
|
+
* Probe one model.
|
|
760
|
+
*
|
|
761
|
+
* `options.candidates` exists so tests can shorten the sweep; production always
|
|
762
|
+
* uses {@link PROBE_EFFORT_CANDIDATES}.
|
|
763
|
+
*/
|
|
764
|
+
async function probeModel(options) {
|
|
765
|
+
const sentinel = options.sentinel ?? randomSentinel;
|
|
766
|
+
const candidates = options.candidates ?? PROBE_EFFORT_CANDIDATES;
|
|
767
|
+
const timeoutMs = options.timeoutMs ?? 3e4;
|
|
768
|
+
let requests = 0;
|
|
769
|
+
const attempt = async (effort) => {
|
|
770
|
+
requests += 1;
|
|
771
|
+
const controller = new AbortController();
|
|
772
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
773
|
+
try {
|
|
774
|
+
return await options.send(effort, controller.signal);
|
|
775
|
+
} catch (error) {
|
|
776
|
+
return {
|
|
777
|
+
status: 0,
|
|
778
|
+
streamed: false,
|
|
779
|
+
detail: `transport error: ${String(error)}`
|
|
780
|
+
};
|
|
781
|
+
} finally {
|
|
782
|
+
clearTimeout(timer);
|
|
783
|
+
}
|
|
784
|
+
};
|
|
785
|
+
const baseline = await attempt(void 0);
|
|
786
|
+
if (!isAcceptance(baseline)) return {
|
|
787
|
+
validation: "unknown",
|
|
788
|
+
efforts: [],
|
|
789
|
+
requests,
|
|
790
|
+
reason: unknownReason("baseline", baseline)
|
|
791
|
+
};
|
|
792
|
+
const sentinelAttempt = await attempt(sentinel());
|
|
793
|
+
if (isAcceptance(sentinelAttempt)) return {
|
|
794
|
+
validation: "non-validating",
|
|
795
|
+
efforts: [],
|
|
796
|
+
requests
|
|
797
|
+
};
|
|
798
|
+
if (!isEffortRejection(sentinelAttempt)) return {
|
|
799
|
+
validation: "unknown",
|
|
800
|
+
efforts: [],
|
|
801
|
+
requests,
|
|
802
|
+
reason: unknownReason("sentinel", sentinelAttempt)
|
|
803
|
+
};
|
|
804
|
+
const accepted = [];
|
|
805
|
+
for (const effort of candidates) {
|
|
806
|
+
const levelAttempt = await attempt(effort);
|
|
807
|
+
if (isAcceptance(levelAttempt)) {
|
|
808
|
+
accepted.push(effort);
|
|
809
|
+
continue;
|
|
810
|
+
}
|
|
811
|
+
if (isEffortRejection(levelAttempt)) continue;
|
|
812
|
+
return {
|
|
813
|
+
validation: "unknown",
|
|
814
|
+
efforts: [],
|
|
815
|
+
requests,
|
|
816
|
+
reason: unknownReason(`level ${effort}`, levelAttempt)
|
|
817
|
+
};
|
|
818
|
+
}
|
|
819
|
+
return {
|
|
820
|
+
validation: "validating",
|
|
821
|
+
efforts: accepted,
|
|
822
|
+
requests
|
|
823
|
+
};
|
|
824
|
+
}
|
|
825
|
+
//#endregion
|
|
826
|
+
//#region src/qoder/transport/endpoints.ts
|
|
827
|
+
const qoderRegionEndpoints = {
|
|
828
|
+
global: {
|
|
829
|
+
baseUrl: "https://api3.qoder.sh/",
|
|
830
|
+
openApiUrl: "https://openapi.qoder.sh",
|
|
831
|
+
centerUrl: "https://center.qoder.sh"
|
|
832
|
+
},
|
|
833
|
+
china: {
|
|
834
|
+
baseUrl: "https://gateway.qoder.com.cn/",
|
|
835
|
+
openApiUrl: "https://openapi.qoder.com.cn",
|
|
836
|
+
centerUrl: "https://gateway.qoder.com.cn"
|
|
837
|
+
}
|
|
838
|
+
};
|
|
839
|
+
/** Signed path of the center image upload route; it never carries an `/algo` prefix. */
|
|
840
|
+
const qoderImageUploadPath = "/api/v2/image/upload";
|
|
841
|
+
qoderRegionEndpoints.global.baseUrl;
|
|
842
|
+
qoderRegionEndpoints.global.openApiUrl;
|
|
843
|
+
function resolveQoderEndpoints(region = "global") {
|
|
844
|
+
return qoderRegionEndpoints[region] ?? qoderRegionEndpoints.global;
|
|
845
|
+
}
|
|
846
|
+
function getQoderChatUrl(region = "global") {
|
|
847
|
+
const { baseUrl } = resolveQoderEndpoints(region);
|
|
848
|
+
return `${baseUrl}algo/api/v2/service/pro/sse/agent_chat_generation?FetchKeys=llm_model_result&AgentId=agent_common&Encode=1`;
|
|
849
|
+
}
|
|
850
|
+
function getQoderModelListUrl(region = "global") {
|
|
851
|
+
const { baseUrl } = resolveQoderEndpoints(region);
|
|
852
|
+
return `${baseUrl}algo/api/v2/model/list?Encode=1`;
|
|
853
|
+
}
|
|
854
|
+
function getQoderImageUploadUrl(region = "global", requestId) {
|
|
855
|
+
const { centerUrl } = resolveQoderEndpoints(region);
|
|
856
|
+
const base = `${centerUrl.replace(/\/+$/u, "")}/algo${qoderImageUploadPath}`;
|
|
857
|
+
return requestId === void 0 ? base : `${base}?request_id=${encodeURIComponent(requestId)}`;
|
|
858
|
+
}
|
|
859
|
+
function getQoderExchangeUrl(region = "global") {
|
|
860
|
+
const { openApiUrl } = resolveQoderEndpoints(region);
|
|
861
|
+
return `${openApiUrl}/api/v1/jobToken/exchange`;
|
|
862
|
+
}
|
|
863
|
+
function getQoderUserInfoUrl(region = "global") {
|
|
864
|
+
const { openApiUrl } = resolveQoderEndpoints(region);
|
|
865
|
+
return `${openApiUrl}/api/v1/userinfo`;
|
|
866
|
+
}
|
|
867
|
+
function getQoderUsageUrl(region = "global") {
|
|
868
|
+
const { openApiUrl } = resolveQoderEndpoints(region);
|
|
869
|
+
return `${openApiUrl}/api/v2/quota/usage`;
|
|
870
|
+
}
|
|
871
|
+
function getQoderUserPlanUrl(region = "global") {
|
|
872
|
+
const { openApiUrl } = resolveQoderEndpoints(region);
|
|
873
|
+
return `${openApiUrl}/api/v2/user/plan`;
|
|
874
|
+
}
|
|
875
|
+
function getQoderUserStatusUrl(region = "global") {
|
|
876
|
+
const { openApiUrl } = resolveQoderEndpoints(region);
|
|
877
|
+
return `${openApiUrl}/api/v3/user/status`;
|
|
878
|
+
}
|
|
879
|
+
//#endregion
|
|
880
|
+
//#region src/qoder/errors.ts
|
|
881
|
+
/**
|
|
882
|
+
* DSH-compatible LLM error representation.
|
|
883
|
+
*
|
|
884
|
+
* @module dsh-provider-qoder/qoder/errors
|
|
885
|
+
*/
|
|
886
|
+
var QoderLlmError = class extends LlmError {
|
|
887
|
+
constructor(message, code = "UNKNOWN_ERROR", options) {
|
|
888
|
+
super(message, code, options);
|
|
889
|
+
}
|
|
890
|
+
};
|
|
891
|
+
function qoderHttpError(message, response) {
|
|
892
|
+
const { status } = response;
|
|
893
|
+
const code = status === 401 || status === 403 ? "AUTH" : status === 408 ? "TIMEOUT" : status === 429 ? "RATE_LIMIT" : status >= 500 && status <= 599 ? "SERVER" : status >= 400 && status <= 499 ? "INVALID_REQUEST" : "PROVIDER_ERROR";
|
|
894
|
+
const providerRetryAfterMs = retryAfterMs(response.headers?.get("retry-after") ?? null);
|
|
895
|
+
const requestId = qoderRequestId(response.headers);
|
|
896
|
+
return new QoderLlmError(message, code, {
|
|
897
|
+
status,
|
|
898
|
+
...providerRetryAfterMs === void 0 ? {} : { providerRetryAfterMs },
|
|
899
|
+
...requestId === void 0 ? {} : { requestId }
|
|
900
|
+
});
|
|
901
|
+
}
|
|
902
|
+
function qoderRequestId(headers) {
|
|
903
|
+
const normalized = (headers?.get("x-request-id") ?? headers?.get("request-id") ?? headers?.get("x-amzn-requestid"))?.trim();
|
|
904
|
+
return normalized ? ProviderRequestId(normalized) : void 0;
|
|
905
|
+
}
|
|
906
|
+
function retryAfterMs(value, nowMs = Date.now()) {
|
|
907
|
+
const normalized = value?.trim();
|
|
908
|
+
if (!normalized) return void 0;
|
|
909
|
+
if (/^\d+$/u.test(normalized)) {
|
|
910
|
+
const delayMs = Number(normalized) * 1e3;
|
|
911
|
+
return Number.isFinite(delayMs) && delayMs > 0 ? delayMs : void 0;
|
|
912
|
+
}
|
|
913
|
+
const retryAt = Date.parse(normalized);
|
|
914
|
+
if (Number.isNaN(retryAt)) return void 0;
|
|
915
|
+
const delayMs = retryAt - nowMs;
|
|
916
|
+
return delayMs > 0 ? delayMs : void 0;
|
|
917
|
+
}
|
|
918
|
+
//#endregion
|
|
919
|
+
//#region src/qoder/transport/machine-id.ts
|
|
920
|
+
function defaultMachineIdPaths() {
|
|
921
|
+
return [join(homedir(), ".qoder", ".auth", "machine_id"), join(homedir(), ".dsh", "qoder", "machine_id")];
|
|
922
|
+
}
|
|
923
|
+
/** Read Qoder's machine id or create the DSH-owned fallback. */
|
|
924
|
+
function getMachineId(paths = defaultMachineIdPaths()) {
|
|
925
|
+
for (const path of paths) {
|
|
926
|
+
if (!existsSync(path)) continue;
|
|
927
|
+
try {
|
|
928
|
+
const value = readFileSync(path, "utf8").trim();
|
|
929
|
+
if (value) return value;
|
|
930
|
+
} catch {}
|
|
931
|
+
}
|
|
932
|
+
const machineId = crypto.randomUUID();
|
|
933
|
+
const savePath = paths.at(-1);
|
|
934
|
+
if (savePath !== void 0) try {
|
|
935
|
+
mkdirSync(dirname(savePath), { recursive: true });
|
|
936
|
+
writeFileSync(savePath, machineId, {
|
|
937
|
+
encoding: "utf8",
|
|
938
|
+
flag: "wx"
|
|
939
|
+
});
|
|
940
|
+
} catch {
|
|
941
|
+
try {
|
|
942
|
+
const existing = readFileSync(savePath, "utf8").trim();
|
|
943
|
+
if (existing) return existing;
|
|
944
|
+
} catch {}
|
|
945
|
+
}
|
|
946
|
+
return machineId;
|
|
947
|
+
}
|
|
948
|
+
//#endregion
|
|
949
|
+
//#region src/qoder/transport/logging.ts
|
|
950
|
+
const sensitiveKey = /(?:authorization|credential|password|secret|token)/iu;
|
|
951
|
+
const identifierKey = /^(?:accountId|id|uid|userId)$/iu;
|
|
952
|
+
const tokenValue = /\b(?:jrt|jt|pt)-[\w.-]+\b/giu;
|
|
953
|
+
const bearerValue = /Bearer\s+[^\s,;]+/giu;
|
|
954
|
+
const maxDepth = 4;
|
|
955
|
+
const maxEntries = 50;
|
|
956
|
+
const maxStringLength = 500;
|
|
957
|
+
function maskIdentifier(value) {
|
|
958
|
+
const text = String(value);
|
|
959
|
+
return text.length <= 4 ? "[REDACTED]" : `…${text.slice(-4)}`;
|
|
960
|
+
}
|
|
961
|
+
function maskEmail(value) {
|
|
962
|
+
const text = String(value);
|
|
963
|
+
const at = text.indexOf("@");
|
|
964
|
+
if (at <= 0) return "[REDACTED]";
|
|
965
|
+
return `${text.slice(0, 1)}***${text.slice(at)}`;
|
|
966
|
+
}
|
|
967
|
+
function redactString(value) {
|
|
968
|
+
return (value.length > maxStringLength ? `${value.slice(0, maxStringLength)}…` : value).replace(tokenValue, "[REDACTED]").replace(bearerValue, "Bearer [REDACTED]");
|
|
969
|
+
}
|
|
970
|
+
/** Redact credentials and bound arbitrary provider values before logging them. */
|
|
971
|
+
function redactLogValue(value, depth = 0) {
|
|
972
|
+
if (typeof value === "string") return redactString(value);
|
|
973
|
+
if (value === null || typeof value !== "object") return value;
|
|
974
|
+
if (depth >= maxDepth) return "[TRUNCATED]";
|
|
975
|
+
if (value instanceof Error) return {
|
|
976
|
+
name: value.name,
|
|
977
|
+
message: redactString(value.message),
|
|
978
|
+
..."code" in value ? { code: redactLogValue(value.code, depth + 1) } : {}
|
|
979
|
+
};
|
|
980
|
+
if (Array.isArray(value)) return value.slice(0, maxEntries).map((item) => redactLogValue(item, depth + 1));
|
|
981
|
+
return Object.fromEntries(Object.entries(value).slice(0, maxEntries).map(([key, item]) => {
|
|
982
|
+
if (sensitiveKey.test(key)) return [key, "[REDACTED]"];
|
|
983
|
+
if (identifierKey.test(key)) return [key, maskIdentifier(item)];
|
|
984
|
+
if (key.toLowerCase() === "email") return [key, maskEmail(item)];
|
|
985
|
+
return [key, redactLogValue(item, depth + 1)];
|
|
986
|
+
}));
|
|
987
|
+
}
|
|
988
|
+
/** Parse JSON-shaped diagnostics when possible, then apply the same redaction boundary. */
|
|
989
|
+
function redactLogPayload(text) {
|
|
990
|
+
try {
|
|
991
|
+
return redactLogValue(JSON.parse(text));
|
|
992
|
+
} catch {
|
|
993
|
+
return redactLogValue(text);
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
/** Log one successfully parsed non-stream response through the shared redaction policy. */
|
|
997
|
+
function logParsedResponse(logger, operation, result) {
|
|
998
|
+
logger?.debug?.("[Qoder Response] Parsed", redactLogValue({
|
|
999
|
+
operation,
|
|
1000
|
+
result
|
|
1001
|
+
}));
|
|
1002
|
+
}
|
|
1003
|
+
//#endregion
|
|
1004
|
+
//#region src/qoder/transport/wire/cosy.ts
|
|
1005
|
+
/**
|
|
1006
|
+
* COSY authentication headers and signature algorithm for Qoder services.
|
|
1007
|
+
*
|
|
1008
|
+
* Implements RSA + AES + MD5 signature generation required by the upstream
|
|
1009
|
+
* Qoder gateway.
|
|
1010
|
+
*
|
|
1011
|
+
* @module dsh-provider-qoder/qoder/transport/wire/cosy
|
|
1012
|
+
*/
|
|
1013
|
+
const qoderRSAPublicKey = `-----BEGIN PUBLIC KEY-----
|
|
1014
|
+
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDA8iMH5c02LilrsERw9t6Pv5Nc
|
|
1015
|
+
4k6Pz1EaDicBMpdpxKduSZu5OANqUq8er4GM95omAGIOPOh+Nx0spthYA2BqGz+l
|
|
1016
|
+
6HRkPJ7S236FZz73In/KVuLnwI8JJ2CbuJap8kvheCCZpmAWpb/cPx/3Vr/J6I17
|
|
1017
|
+
XcW+ML9FoCI6AOvOzwIDAQAB
|
|
1018
|
+
-----END PUBLIC KEY-----`;
|
|
1019
|
+
const qoderIdeVersion = "1.1.47";
|
|
1020
|
+
const defaultUserAgent = `qoder/${qoderIdeVersion}`;
|
|
1021
|
+
const qoderDataPolicy = "disagree";
|
|
1022
|
+
const qoderLoginVersion = "v2";
|
|
1023
|
+
const qoderMachineOs = process.platform === "win32" ? process.arch === "arm64" ? "aarch64_windows" : "x86_64_windows" : process.arch === "arm64" ? "aarch64_linux" : "x86_64_linux";
|
|
1024
|
+
const qoderMachineTypeMagic = "5";
|
|
1025
|
+
function computeSigPath(urlStr) {
|
|
1026
|
+
let sigPath = new URL(urlStr).pathname;
|
|
1027
|
+
if (sigPath.startsWith("/algo")) sigPath = sigPath.substring(5);
|
|
1028
|
+
return sigPath;
|
|
1029
|
+
}
|
|
1030
|
+
function rsaEncryptBase64(data) {
|
|
1031
|
+
const key = {
|
|
1032
|
+
key: qoderRSAPublicKey,
|
|
1033
|
+
padding: crypto.constants.RSA_PKCS1_PADDING
|
|
1034
|
+
};
|
|
1035
|
+
return crypto.publicEncrypt(key, typeof data === "string" ? Buffer.from(data) : data).toString("base64");
|
|
1036
|
+
}
|
|
1037
|
+
function aesEncryptCBCBase64(plaintext, keyStr) {
|
|
1038
|
+
const cipher = crypto.createCipheriv("aes-128-cbc", Buffer.from(keyStr), Buffer.from(keyStr));
|
|
1039
|
+
let encrypted = cipher.update(plaintext, "utf8", "base64");
|
|
1040
|
+
encrypted += cipher.final("base64");
|
|
1041
|
+
return encrypted;
|
|
1042
|
+
}
|
|
1043
|
+
function buildAuthHeaders(body, requestURL, creds) {
|
|
1044
|
+
if (!creds.userID) throw new Error("cosy: user id is empty");
|
|
1045
|
+
if (!creds.authToken) throw new Error("cosy: auth token is empty");
|
|
1046
|
+
const aesKey = crypto.randomUUID().replace(/-/g, "").slice(0, 16);
|
|
1047
|
+
const userInfo = {
|
|
1048
|
+
uid: creds.userID,
|
|
1049
|
+
security_oauth_token: creds.authToken,
|
|
1050
|
+
name: creds.name || "",
|
|
1051
|
+
aid: "",
|
|
1052
|
+
email: creds.email || ""
|
|
1053
|
+
};
|
|
1054
|
+
const infoB64 = aesEncryptCBCBase64(JSON.stringify(userInfo), aesKey);
|
|
1055
|
+
const cosyKey = rsaEncryptBase64(aesKey);
|
|
1056
|
+
const timestamp = Math.floor(Date.now() / 1e3).toString();
|
|
1057
|
+
const cosyPayload = {
|
|
1058
|
+
version: "v1",
|
|
1059
|
+
requestId: crypto.randomUUID(),
|
|
1060
|
+
info: infoB64,
|
|
1061
|
+
cosyVersion: qoderIdeVersion,
|
|
1062
|
+
ideVersion: ""
|
|
1063
|
+
};
|
|
1064
|
+
const payloadB64 = Buffer.from(JSON.stringify(cosyPayload)).toString("base64");
|
|
1065
|
+
const sigPath = computeSigPath(requestURL);
|
|
1066
|
+
const sigInput = `${payloadB64}\n${cosyKey}\n${timestamp}\n${body ? Buffer.isBuffer(body) ? body.toString("utf8") : body : ""}\n${sigPath}`;
|
|
1067
|
+
const sig = crypto.createHash("md5").update(sigInput).digest("hex");
|
|
1068
|
+
const bodyHash = crypto.createHash("md5").update(body || "").digest("hex");
|
|
1069
|
+
const bodyLen = body ? (Buffer.isBuffer(body) ? body.length : Buffer.from(body).length).toString() : "0";
|
|
1070
|
+
const machineID = creds.machineID || getMachineId();
|
|
1071
|
+
return {
|
|
1072
|
+
Authorization: `Bearer COSY.${payloadB64}.${sig}`,
|
|
1073
|
+
"Cosy-Key": cosyKey,
|
|
1074
|
+
"Cosy-User": creds.userID,
|
|
1075
|
+
"Cosy-Date": timestamp,
|
|
1076
|
+
"Cosy-Version": qoderIdeVersion,
|
|
1077
|
+
"Cosy-Machineid": machineID,
|
|
1078
|
+
"Cosy-Machinetoken": machineID,
|
|
1079
|
+
"Cosy-Machinetype": qoderMachineTypeMagic,
|
|
1080
|
+
"Cosy-Machineos": qoderMachineOs,
|
|
1081
|
+
"Cosy-Clienttype": "5",
|
|
1082
|
+
"Cosy-Clientip": "127.0.0.1",
|
|
1083
|
+
"Cosy-Bodyhash": bodyHash,
|
|
1084
|
+
"Cosy-Bodylength": bodyLen,
|
|
1085
|
+
"Cosy-Sigpath": sigPath,
|
|
1086
|
+
"Cosy-Data-Policy": qoderDataPolicy,
|
|
1087
|
+
"Cosy-Organization-Id": "",
|
|
1088
|
+
"Cosy-Organization-Tags": "",
|
|
1089
|
+
"Login-Version": qoderLoginVersion,
|
|
1090
|
+
"X-Request-Id": crypto.randomUUID()
|
|
1091
|
+
};
|
|
1092
|
+
}
|
|
1093
|
+
const defaultMaxJsonBytes = 2097152;
|
|
1094
|
+
const defaultMaxErrorBytes = 16384;
|
|
1095
|
+
const metadataRetryBaseDelayMs = 200;
|
|
1096
|
+
const maxProviderRetryDelayMs = 1e4;
|
|
1097
|
+
function opaqueCredentialKey(value) {
|
|
1098
|
+
return createHash("sha256").update(value).digest("hex");
|
|
1099
|
+
}
|
|
1100
|
+
function retryableMetadataError(error) {
|
|
1101
|
+
return error instanceof QoderLlmError && [
|
|
1102
|
+
"RATE_LIMIT",
|
|
1103
|
+
"SERVER",
|
|
1104
|
+
"TIMEOUT",
|
|
1105
|
+
"TRANSPORT"
|
|
1106
|
+
].includes(error.code);
|
|
1107
|
+
}
|
|
1108
|
+
function abortableDelay(ms, signal) {
|
|
1109
|
+
if (signal.aborted) return Promise.reject(new DOMException("aborted", "AbortError"));
|
|
1110
|
+
return new Promise((resolve, reject) => {
|
|
1111
|
+
const timer = setTimeout(() => {
|
|
1112
|
+
signal.removeEventListener("abort", onAbort);
|
|
1113
|
+
resolve();
|
|
1114
|
+
}, ms);
|
|
1115
|
+
const onAbort = () => {
|
|
1116
|
+
clearTimeout(timer);
|
|
1117
|
+
reject(new DOMException("aborted", "AbortError"));
|
|
1118
|
+
};
|
|
1119
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
1120
|
+
});
|
|
1121
|
+
}
|
|
1122
|
+
async function retryMetadataRead(signal, operation) {
|
|
1123
|
+
try {
|
|
1124
|
+
return await operation();
|
|
1125
|
+
} catch (error) {
|
|
1126
|
+
if (!retryableMetadataError(error) || signal.aborted) throw error;
|
|
1127
|
+
const providerDelay = error.failure.providerRetryAfterMs;
|
|
1128
|
+
await abortableDelay(providerDelay === void 0 ? metadataRetryBaseDelayMs + Math.floor(Math.random() * 41) : Math.min(providerDelay, maxProviderRetryDelayMs), signal);
|
|
1129
|
+
return operation();
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
function withDeadline(signal, timeoutMs) {
|
|
1133
|
+
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
|
1134
|
+
return {
|
|
1135
|
+
timeoutSignal,
|
|
1136
|
+
signal: signal === void 0 ? timeoutSignal : AbortSignal.any([signal, timeoutSignal])
|
|
1137
|
+
};
|
|
1138
|
+
}
|
|
1139
|
+
async function readLimitedText(response, maxBytes, label) {
|
|
1140
|
+
const declaredLength = Number(response.headers.get("content-length"));
|
|
1141
|
+
if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
|
|
1142
|
+
await response.body?.cancel().catch(() => void 0);
|
|
1143
|
+
throw new QoderLlmError(`${label} exceeded its response size limit.`, "MALFORMED_RESPONSE");
|
|
1144
|
+
}
|
|
1145
|
+
if (!response.body) return "";
|
|
1146
|
+
const reader = response.body.getReader();
|
|
1147
|
+
const decoder = new TextDecoder();
|
|
1148
|
+
let bytes = 0;
|
|
1149
|
+
let text = "";
|
|
1150
|
+
try {
|
|
1151
|
+
while (true) {
|
|
1152
|
+
const { done, value } = await reader.read();
|
|
1153
|
+
if (done) break;
|
|
1154
|
+
bytes += value.byteLength;
|
|
1155
|
+
if (bytes > maxBytes) throw new QoderLlmError(`${label} exceeded its response size limit.`, "MALFORMED_RESPONSE");
|
|
1156
|
+
text += decoder.decode(value, { stream: true });
|
|
1157
|
+
}
|
|
1158
|
+
return text + decoder.decode();
|
|
1159
|
+
} finally {
|
|
1160
|
+
await reader.cancel().catch(() => void 0);
|
|
1161
|
+
reader.releaseLock();
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
var SingleFlight = class {
|
|
1165
|
+
flights = /* @__PURE__ */ new Map();
|
|
1166
|
+
run(key, signal, start, abortedError) {
|
|
1167
|
+
if (signal?.aborted) return Promise.reject(abortedError());
|
|
1168
|
+
let flight = this.flights.get(key);
|
|
1169
|
+
if (flight === void 0 || flight.controller.signal.aborted) {
|
|
1170
|
+
const controller = new AbortController();
|
|
1171
|
+
const created = {};
|
|
1172
|
+
created.controller = controller;
|
|
1173
|
+
created.settled = false;
|
|
1174
|
+
created.waiters = 0;
|
|
1175
|
+
created.promise = start(controller.signal).finally(() => {
|
|
1176
|
+
created.settled = true;
|
|
1177
|
+
if (this.flights.get(key) === created) this.flights.delete(key);
|
|
1178
|
+
});
|
|
1179
|
+
flight = created;
|
|
1180
|
+
this.flights.set(key, created);
|
|
1181
|
+
}
|
|
1182
|
+
flight.waiters++;
|
|
1183
|
+
return new Promise((resolve, reject) => {
|
|
1184
|
+
let finished = false;
|
|
1185
|
+
const finish = (callback) => {
|
|
1186
|
+
if (finished) return;
|
|
1187
|
+
finished = true;
|
|
1188
|
+
signal?.removeEventListener("abort", onAbort);
|
|
1189
|
+
flight.waiters--;
|
|
1190
|
+
if (flight.waiters === 0 && !flight.settled) {
|
|
1191
|
+
if (this.flights.get(key) === flight) this.flights.delete(key);
|
|
1192
|
+
flight.controller.abort("all callers aborted");
|
|
1193
|
+
}
|
|
1194
|
+
callback();
|
|
1195
|
+
};
|
|
1196
|
+
const onAbort = () => finish(() => reject(abortedError()));
|
|
1197
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
1198
|
+
flight.promise.then((value) => finish(() => resolve(value)), (error) => finish(() => reject(error)));
|
|
1199
|
+
});
|
|
1200
|
+
}
|
|
1201
|
+
};
|
|
1202
|
+
async function openApiJsonRequest(fetchImpl, options) {
|
|
1203
|
+
const method = options.method ?? (options.body !== void 0 ? "POST" : "GET");
|
|
1204
|
+
const timeoutMs = options.timeoutMs ?? 15e3;
|
|
1205
|
+
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
|
1206
|
+
const requestSignal = options.signal === void 0 ? timeoutSignal : AbortSignal.any([options.signal, timeoutSignal]);
|
|
1207
|
+
const startedAt = performance.now();
|
|
1208
|
+
options.logger?.debug?.(`[Qoder ${options.operation}] Requesting`, {
|
|
1209
|
+
url: options.url,
|
|
1210
|
+
method
|
|
1211
|
+
});
|
|
1212
|
+
try {
|
|
1213
|
+
const headers = {
|
|
1214
|
+
accept: "application/json",
|
|
1215
|
+
"user-agent": options.userAgent ?? defaultUserAgent,
|
|
1216
|
+
"cosy-version": "1.0.1",
|
|
1217
|
+
"cosy-clienttype": "5",
|
|
1218
|
+
...options.headers
|
|
1219
|
+
};
|
|
1220
|
+
if (options.token) headers.authorization = `Bearer ${options.token}`;
|
|
1221
|
+
if (options.machineId) {
|
|
1222
|
+
headers["Cosy-MachineToken"] = options.machineId;
|
|
1223
|
+
headers["Cosy-MachineType"] = "host";
|
|
1224
|
+
}
|
|
1225
|
+
let bodyText;
|
|
1226
|
+
if (options.body !== void 0) {
|
|
1227
|
+
headers["content-type"] = "application/json";
|
|
1228
|
+
bodyText = typeof options.body === "string" ? options.body : JSON.stringify(options.body);
|
|
1229
|
+
}
|
|
1230
|
+
const response = await fetchImpl(options.url, {
|
|
1231
|
+
method,
|
|
1232
|
+
headers,
|
|
1233
|
+
...bodyText === void 0 ? {} : { body: bodyText },
|
|
1234
|
+
signal: requestSignal
|
|
1235
|
+
});
|
|
1236
|
+
options.logger?.debug?.(`[Qoder ${options.operation}] Request completed`, {
|
|
1237
|
+
status: response.status,
|
|
1238
|
+
statusText: response.statusText,
|
|
1239
|
+
durationMs: Math.round(performance.now() - startedAt),
|
|
1240
|
+
...qoderRequestId(response.headers) === void 0 ? {} : { requestId: qoderRequestId(response.headers) }
|
|
1241
|
+
});
|
|
1242
|
+
const text = await readLimitedText(response, response.ok ? defaultMaxJsonBytes : defaultMaxErrorBytes, `Qoder ${options.operation} response`);
|
|
1243
|
+
if (!response.ok) {
|
|
1244
|
+
options.logger?.error?.(`[Qoder ${options.operation}] Request failed`, redactLogPayload(text));
|
|
1245
|
+
throw qoderHttpError(`Failed to execute Qoder ${options.operation} with status ${response.status}.`, response);
|
|
1246
|
+
}
|
|
1247
|
+
let data;
|
|
1248
|
+
try {
|
|
1249
|
+
data = JSON.parse(text);
|
|
1250
|
+
} catch {
|
|
1251
|
+
options.logger?.error?.(`[Qoder ${options.operation}] Invalid JSON response`, redactLogPayload(text));
|
|
1252
|
+
throw new QoderLlmError(`Failed to parse Qoder ${options.operation} JSON response`, "MALFORMED_RESPONSE");
|
|
1253
|
+
}
|
|
1254
|
+
if (options.logCategory) logParsedResponse(options.logger, options.logCategory, data);
|
|
1255
|
+
return data;
|
|
1256
|
+
} catch (error) {
|
|
1257
|
+
if (error instanceof QoderLlmError) throw error;
|
|
1258
|
+
if (options.signal?.aborted) throw new QoderLlmError(`Qoder ${options.operation} request was aborted.`, "ABORTED");
|
|
1259
|
+
if (timeoutSignal.aborted) throw new QoderLlmError(`Qoder ${options.operation} request timed out.`, "TIMEOUT");
|
|
1260
|
+
throw new QoderLlmError(`Qoder ${options.operation} network request failed.`, "TRANSPORT", { cause: error });
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
//#endregion
|
|
1264
|
+
//#region src/qoder/transport/auth.ts
|
|
1265
|
+
const expiryBufferMs = 3e5;
|
|
1266
|
+
const defaultExpiryMs = 864e5;
|
|
1267
|
+
const defaultAuthTimeoutMs = 15e3;
|
|
1268
|
+
function abortedError() {
|
|
1269
|
+
return new QoderLlmError("Qoder authentication was aborted.", "ABORTED");
|
|
1270
|
+
}
|
|
1271
|
+
async function waitForFlight(promise, signal) {
|
|
1272
|
+
if (signal === void 0) return promise;
|
|
1273
|
+
if (signal.aborted) throw abortedError();
|
|
1274
|
+
return new Promise((resolve, reject) => {
|
|
1275
|
+
const onAbort = () => reject(abortedError());
|
|
1276
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
1277
|
+
promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort));
|
|
1278
|
+
});
|
|
1279
|
+
}
|
|
1280
|
+
var QoderAuthService = class {
|
|
1281
|
+
cache = /* @__PURE__ */ new Map();
|
|
1282
|
+
inFlight = /* @__PURE__ */ new Map();
|
|
1283
|
+
fetchImpl;
|
|
1284
|
+
timeoutMs;
|
|
1285
|
+
resolveMachineId;
|
|
1286
|
+
region;
|
|
1287
|
+
logger;
|
|
1288
|
+
constructor(options = {}) {
|
|
1289
|
+
this.fetchImpl = options.fetch ?? globalThis.fetch;
|
|
1290
|
+
this.timeoutMs = options.timeoutMs ?? defaultAuthTimeoutMs;
|
|
1291
|
+
this.resolveMachineId = options.resolveMachineId ?? getMachineId;
|
|
1292
|
+
this.region = options.region ?? "global";
|
|
1293
|
+
this.logger = options.logger;
|
|
1294
|
+
}
|
|
1295
|
+
clear(pat) {
|
|
1296
|
+
if (pat) this.cache.delete(`${this.region}:${opaqueCredentialKey(pat)}`);
|
|
1297
|
+
else this.cache.clear();
|
|
1298
|
+
}
|
|
1299
|
+
async getCredentials(pat, signal) {
|
|
1300
|
+
if (!pat || typeof pat !== "string") throw new QoderLlmError("Qoder Personal Access Token is missing or invalid. Configure Qoder in the Qoder settings page.", "MISSING_CREDENTIAL");
|
|
1301
|
+
if (signal?.aborted) throw abortedError();
|
|
1302
|
+
const cacheKey = `${this.region}:${opaqueCredentialKey(pat)}`;
|
|
1303
|
+
const cached = this.cache.get(cacheKey);
|
|
1304
|
+
if (cached && cached.expiresAt > Date.now() + expiryBufferMs) return cached.creds;
|
|
1305
|
+
let entry = this.inFlight.get(cacheKey);
|
|
1306
|
+
if (entry === void 0 || entry.controller.signal.aborted) {
|
|
1307
|
+
const controller = new AbortController();
|
|
1308
|
+
const created = {};
|
|
1309
|
+
created.controller = controller;
|
|
1310
|
+
created.waiters = 0;
|
|
1311
|
+
created.settled = false;
|
|
1312
|
+
created.timeout = setTimeout(() => controller.abort("authentication timeout"), this.timeoutMs);
|
|
1313
|
+
created.promise = this.exchangeAndResolve(pat, controller.signal).finally(() => {
|
|
1314
|
+
created.settled = true;
|
|
1315
|
+
clearTimeout(created.timeout);
|
|
1316
|
+
if (this.inFlight.get(cacheKey) === created) this.inFlight.delete(cacheKey);
|
|
1317
|
+
});
|
|
1318
|
+
entry = created;
|
|
1319
|
+
this.inFlight.set(cacheKey, entry);
|
|
1320
|
+
}
|
|
1321
|
+
entry.waiters++;
|
|
1322
|
+
try {
|
|
1323
|
+
return await waitForFlight(entry.promise, signal);
|
|
1324
|
+
} finally {
|
|
1325
|
+
entry.waiters--;
|
|
1326
|
+
if (entry.waiters === 0 && !entry.settled) {
|
|
1327
|
+
if (this.inFlight.get(cacheKey) === entry) this.inFlight.delete(cacheKey);
|
|
1328
|
+
entry.controller.abort("all callers aborted");
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
async exchangeAndResolve(pat, signal) {
|
|
1333
|
+
let jobToken;
|
|
1334
|
+
let expiresAt = Date.now() + defaultExpiryMs;
|
|
1335
|
+
const data = await openApiJsonRequest(this.fetchImpl, {
|
|
1336
|
+
url: getQoderExchangeUrl(this.region),
|
|
1337
|
+
body: { personal_token: pat },
|
|
1338
|
+
signal,
|
|
1339
|
+
timeoutMs: this.timeoutMs,
|
|
1340
|
+
logger: this.logger,
|
|
1341
|
+
operation: "Auth",
|
|
1342
|
+
logCategory: "auth.exchange"
|
|
1343
|
+
});
|
|
1344
|
+
if (!data.token) throw new QoderLlmError("Qoder PAT exchange returned no job token.", "AUTH");
|
|
1345
|
+
jobToken = data.token;
|
|
1346
|
+
if (data.expires_at) {
|
|
1347
|
+
const parsed = Date.parse(data.expires_at);
|
|
1348
|
+
if (!Number.isNaN(parsed)) expiresAt = parsed;
|
|
1349
|
+
} else if (typeof data.expires_in === "number" && data.expires_in > 0) expiresAt = Date.now() + data.expires_in;
|
|
1350
|
+
const userInfo = await retryMetadataRead(signal, () => this.fetchUserInfo(jobToken, signal));
|
|
1351
|
+
const creds = {
|
|
1352
|
+
userID: userInfo.userID,
|
|
1353
|
+
authToken: jobToken,
|
|
1354
|
+
name: userInfo.name || "Qoder User",
|
|
1355
|
+
email: userInfo.email,
|
|
1356
|
+
machineID: this.resolveMachineId()
|
|
1357
|
+
};
|
|
1358
|
+
const cacheKey = `${this.region}:${opaqueCredentialKey(pat)}`;
|
|
1359
|
+
this.cache.set(cacheKey, {
|
|
1360
|
+
creds,
|
|
1361
|
+
expiresAt
|
|
1362
|
+
});
|
|
1363
|
+
return creds;
|
|
1364
|
+
}
|
|
1365
|
+
async fetchUserInfo(jobToken, signal) {
|
|
1366
|
+
const info = await openApiJsonRequest(this.fetchImpl, {
|
|
1367
|
+
url: getQoderUserInfoUrl(this.region),
|
|
1368
|
+
token: jobToken,
|
|
1369
|
+
signal,
|
|
1370
|
+
timeoutMs: this.timeoutMs,
|
|
1371
|
+
logger: this.logger,
|
|
1372
|
+
operation: "UserInfo",
|
|
1373
|
+
logCategory: "auth.user-info"
|
|
1374
|
+
});
|
|
1375
|
+
if (!info.id) throw new QoderLlmError("Qoder identity lookup returned no user id.", "AUTH");
|
|
1376
|
+
return {
|
|
1377
|
+
userID: info.id,
|
|
1378
|
+
email: info.email ?? "",
|
|
1379
|
+
name: info.name ?? info.username ?? ""
|
|
1380
|
+
};
|
|
1381
|
+
}
|
|
1382
|
+
};
|
|
1383
|
+
//#endregion
|
|
1384
|
+
//#region src/qoder/catalog.ts
|
|
1385
|
+
const reasoningEffortOrder = /* @__PURE__ */ new Map([
|
|
1386
|
+
["low", 0],
|
|
1387
|
+
["medium", 1],
|
|
1388
|
+
["high", 2],
|
|
1389
|
+
["xhigh", 3],
|
|
1390
|
+
["max", 4]
|
|
1391
|
+
]);
|
|
1392
|
+
function positiveNumber(value) {
|
|
1393
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : void 0;
|
|
1394
|
+
}
|
|
1395
|
+
function contextOptionsOf(value) {
|
|
1396
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
|
|
1397
|
+
const options = {};
|
|
1398
|
+
for (const [key, raw] of Object.entries(value)) {
|
|
1399
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) continue;
|
|
1400
|
+
const entry = raw;
|
|
1401
|
+
const tokenCount = positiveNumber(entry.token_count);
|
|
1402
|
+
const isDefault = typeof entry.is_default === "boolean" ? entry.is_default : void 0;
|
|
1403
|
+
if (tokenCount === void 0) continue;
|
|
1404
|
+
options[key] = {
|
|
1405
|
+
...tokenCount === void 0 ? {} : { tokenCount },
|
|
1406
|
+
...isDefault === void 0 ? {} : { isDefault }
|
|
1407
|
+
};
|
|
1408
|
+
}
|
|
1409
|
+
if (Object.keys(options).length === 0) return void 0;
|
|
1410
|
+
return options;
|
|
1411
|
+
}
|
|
1412
|
+
function reasoningEffortsOf(value) {
|
|
1413
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return {};
|
|
1414
|
+
const enabled = value.enabled;
|
|
1415
|
+
if (typeof enabled !== "object" || enabled === null || Array.isArray(enabled)) return {};
|
|
1416
|
+
const rawEfforts = enabled.efforts;
|
|
1417
|
+
if (typeof rawEfforts !== "object" || rawEfforts === null || Array.isArray(rawEfforts)) return {};
|
|
1418
|
+
const efforts = [];
|
|
1419
|
+
let defaultEffort;
|
|
1420
|
+
for (const [id, raw] of Object.entries(rawEfforts)) {
|
|
1421
|
+
if (!id.trim() || typeof raw !== "object" || raw === null || Array.isArray(raw)) continue;
|
|
1422
|
+
const entry = raw;
|
|
1423
|
+
efforts.push({
|
|
1424
|
+
id,
|
|
1425
|
+
name: id,
|
|
1426
|
+
...typeof entry.description === "string" && entry.description.trim() ? { description: entry.description.trim() } : {}
|
|
1427
|
+
});
|
|
1428
|
+
if (entry.is_default === true) defaultEffort = id;
|
|
1429
|
+
}
|
|
1430
|
+
return {
|
|
1431
|
+
...efforts.length === 0 ? {} : { efforts: efforts.sort((left, right) => (reasoningEffortOrder.get(left.id) ?? Number.MAX_SAFE_INTEGER) - (reasoningEffortOrder.get(right.id) ?? Number.MAX_SAFE_INTEGER)) },
|
|
1432
|
+
...defaultEffort === void 0 ? {} : { defaultEffort }
|
|
1433
|
+
};
|
|
1434
|
+
}
|
|
1435
|
+
function thinkingDefault(value, fallback, onConflict) {
|
|
1436
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return fallback;
|
|
1437
|
+
const config = value;
|
|
1438
|
+
const isDefault = (entry) => typeof entry === "object" && entry !== null && !Array.isArray(entry) && entry.is_default === true;
|
|
1439
|
+
const enabled = isDefault(config.enabled);
|
|
1440
|
+
const disabled = isDefault(config.disabled);
|
|
1441
|
+
if (enabled && disabled) onConflict?.("thinking-defaults");
|
|
1442
|
+
return enabled === disabled ? fallback : enabled;
|
|
1443
|
+
}
|
|
1444
|
+
function normalizeQoderModels(payload, onConflict) {
|
|
1445
|
+
if (typeof payload !== "object" || payload === null || !Array.isArray(payload.assistant)) return [];
|
|
1446
|
+
const models = [];
|
|
1447
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1448
|
+
for (const raw of payload.assistant) {
|
|
1449
|
+
if (typeof raw !== "object" || raw === null || raw.enable !== true) continue;
|
|
1450
|
+
const id = typeof raw.key === "string" ? raw.key.trim() : "";
|
|
1451
|
+
if (!id || seen.has(id)) continue;
|
|
1452
|
+
seen.add(id);
|
|
1453
|
+
const contextOptions = contextOptionsOf(raw.context_config);
|
|
1454
|
+
const defaultOptions = Object.values(contextOptions ?? {}).filter((option) => option.isDefault && option.tokenCount !== void 0);
|
|
1455
|
+
if (defaultOptions.length > 1) onConflict?.("context-defaults");
|
|
1456
|
+
const contextWindow = (defaultOptions.length === 1 ? defaultOptions[0]?.tokenCount : void 0) ?? positiveNumber(raw.max_input_tokens) ?? 18e4;
|
|
1457
|
+
const maxContextWindow = Math.max(positiveNumber(raw.max_input_tokens) ?? 0, contextWindow, ...Object.values(contextOptions ?? {}).map((option) => option.tokenCount ?? 0));
|
|
1458
|
+
const isReasoning = thinkingDefault(raw.thinking_config, raw.is_reasoning === true, onConflict);
|
|
1459
|
+
const priceFactor = typeof raw.price_factor === "number" && Number.isFinite(raw.price_factor) && raw.price_factor >= 0 ? raw.price_factor : void 0;
|
|
1460
|
+
const reasoning = reasoningEffortsOf(raw.thinking_config);
|
|
1461
|
+
models.push({
|
|
1462
|
+
id,
|
|
1463
|
+
name: typeof raw.display_name === "string" && raw.display_name.trim() ? raw.display_name.trim() : id,
|
|
1464
|
+
contextWindow,
|
|
1465
|
+
maxContextWindow,
|
|
1466
|
+
maxTokens: positiveNumber(raw.max_output_tokens) ?? 32768,
|
|
1467
|
+
source: typeof raw.source === "string" && raw.source.trim() ? raw.source.trim() : "system",
|
|
1468
|
+
isReasoning,
|
|
1469
|
+
supportsEffort: reasoning.efforts !== void 0,
|
|
1470
|
+
supportsImages: raw.is_vl === true,
|
|
1471
|
+
...reasoning.efforts === void 0 ? {} : { reasoningEfforts: reasoning.efforts },
|
|
1472
|
+
...reasoning.defaultEffort === void 0 ? {} : { defaultReasoningEffort: reasoning.defaultEffort },
|
|
1473
|
+
...priceFactor === void 0 ? {} : { priceFactor },
|
|
1474
|
+
...contextOptions === void 0 ? {} : { contextOptions }
|
|
1475
|
+
});
|
|
1476
|
+
}
|
|
1477
|
+
return models;
|
|
1478
|
+
}
|
|
1479
|
+
//#endregion
|
|
1480
|
+
//#region src/qoder/transport/catalog-reader.ts
|
|
1481
|
+
async function fetchQoderModels(credentials, options = {}) {
|
|
1482
|
+
const url = getQoderModelListUrl(options.region);
|
|
1483
|
+
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
1484
|
+
const startedAt = performance.now();
|
|
1485
|
+
const deadline = withDeadline(options.signal, options.timeoutMs ?? 15e3);
|
|
1486
|
+
options.logger?.debug?.("[Qoder Models] Requesting model catalog", { url });
|
|
1487
|
+
try {
|
|
1488
|
+
const response = await fetchImpl(url, {
|
|
1489
|
+
method: "GET",
|
|
1490
|
+
headers: {
|
|
1491
|
+
accept: "application/json",
|
|
1492
|
+
...buildAuthHeaders(null, url, credentials)
|
|
1493
|
+
},
|
|
1494
|
+
signal: deadline.signal
|
|
1495
|
+
});
|
|
1496
|
+
options.logger?.debug?.("[Qoder Models] Catalog request completed", {
|
|
1497
|
+
url,
|
|
1498
|
+
status: response.status,
|
|
1499
|
+
durationMs: Math.round(performance.now() - startedAt),
|
|
1500
|
+
...qoderRequestId(response.headers) === void 0 ? {} : { requestId: qoderRequestId(response.headers) }
|
|
1501
|
+
});
|
|
1502
|
+
const text = await readLimitedText(response, response.ok ? defaultMaxJsonBytes : defaultMaxErrorBytes, "Qoder model discovery response");
|
|
1503
|
+
if (!response.ok) {
|
|
1504
|
+
options.logger?.error?.("[Qoder Models] Catalog request failed", redactLogPayload(text));
|
|
1505
|
+
throw qoderHttpError(`Qoder model discovery failed with HTTP status ${response.status}.`, response);
|
|
1506
|
+
}
|
|
1507
|
+
let payload;
|
|
1508
|
+
try {
|
|
1509
|
+
payload = JSON.parse(text);
|
|
1510
|
+
} catch {
|
|
1511
|
+
throw new QoderLlmError("Qoder model discovery returned invalid JSON.", "MALFORMED_RESPONSE");
|
|
1512
|
+
}
|
|
1513
|
+
const models = normalizeQoderModels(payload, (conflict) => {
|
|
1514
|
+
options.logger?.warn?.("[Qoder Models] Conflicting catalog defaults; using fallback", { conflict });
|
|
1515
|
+
});
|
|
1516
|
+
if (models.length === 0) throw new QoderLlmError("Qoder model discovery returned no enabled models.", "EMPTY_RESPONSE");
|
|
1517
|
+
return models;
|
|
1518
|
+
} catch (error) {
|
|
1519
|
+
if (error instanceof QoderLlmError) throw error;
|
|
1520
|
+
if (options.signal?.aborted) throw new QoderLlmError("Qoder model discovery was aborted.", "ABORTED");
|
|
1521
|
+
if (deadline.timeoutSignal.aborted) throw new QoderLlmError("Qoder model discovery timed out.", "TIMEOUT");
|
|
1522
|
+
options.logger?.error?.("[Qoder Models] Catalog network request failed", redactLogValue(error));
|
|
1523
|
+
throw new QoderLlmError("Qoder model discovery network request failed.", "TRANSPORT", { cause: error });
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
//#endregion
|
|
1527
|
+
//#region src/qoder/transport/wire/translate.ts
|
|
1528
|
+
function unsupported(message) {
|
|
1529
|
+
return new QoderLlmError(message, "UNSUPPORTED_CONTENT");
|
|
1530
|
+
}
|
|
1531
|
+
function toolResultText(block) {
|
|
1532
|
+
let text = "";
|
|
1533
|
+
for (const nested of block.content) {
|
|
1534
|
+
if (nested.type === "image") continue;
|
|
1535
|
+
if (nested.type !== "text") throw unsupported(`Qoder tool results support text only; received nested ${String(nested.type)} content.`);
|
|
1536
|
+
text += nested.text;
|
|
1537
|
+
}
|
|
1538
|
+
return text;
|
|
1539
|
+
}
|
|
1540
|
+
/**
|
|
1541
|
+
* Check message shapes without performing any provider I/O.
|
|
1542
|
+
*
|
|
1543
|
+
* Callers run this before resolving credentials so an invalid request never
|
|
1544
|
+
* consumes a Qoder subscription.
|
|
1545
|
+
*/
|
|
1546
|
+
function validateMessageShapes(messages) {
|
|
1547
|
+
for (const message of messages) {
|
|
1548
|
+
const toolResults = message.content.filter((block) => block.type === "tool-result");
|
|
1549
|
+
if (toolResults.length > 0) {
|
|
1550
|
+
if (message.role !== "user" || toolResults.length !== message.content.length) throw unsupported("Qoder tool-result messages cannot contain sibling content or use a non-user role.");
|
|
1551
|
+
for (const result of toolResults) toolResultText(result);
|
|
1552
|
+
continue;
|
|
1553
|
+
}
|
|
1554
|
+
for (const block of message.content) {
|
|
1555
|
+
if (block.type === "text") continue;
|
|
1556
|
+
if (block.type === "image") {
|
|
1557
|
+
if (message.role !== "user") throw unsupported("Qoder image content is valid only in user messages.");
|
|
1558
|
+
continue;
|
|
1559
|
+
}
|
|
1560
|
+
if (block.type === "tool-call") {
|
|
1561
|
+
if (message.role !== "assistant") throw unsupported("Qoder tool calls are valid only in assistant messages.");
|
|
1562
|
+
continue;
|
|
1563
|
+
}
|
|
1564
|
+
if (block.type === "reasoning") continue;
|
|
1565
|
+
throw unsupported(`Qoder transport encountered unsupported block type: ${String(block.type)}`);
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
/** Reject a batch that exceeds the deployment image policy before any upload work starts. */
|
|
1570
|
+
function enforceImageLimits(images, attachments) {
|
|
1571
|
+
const limits = attachments.imageLimits;
|
|
1572
|
+
if (images.length > limits.maxImagesPerMessage) throw unsupported(`Qoder accepts at most ${limits.maxImagesPerMessage} images per message; received ${images.length}.`);
|
|
1573
|
+
let total = 0;
|
|
1574
|
+
for (const image of images) total += image.attachment.bytes;
|
|
1575
|
+
if (total > limits.maxMessageImageBytes) throw unsupported("Qoder message image content exceeds the configured total byte limit.");
|
|
1576
|
+
}
|
|
1577
|
+
async function resolveImagePart(block, context) {
|
|
1578
|
+
const { attachments, uploader, credentials, signal } = context;
|
|
1579
|
+
if (attachments === void 0) throw new QoderLlmError("Qoder image input requires the DSH attachment service.", "ATTACHMENT");
|
|
1580
|
+
let image;
|
|
1581
|
+
try {
|
|
1582
|
+
const limits = attachments.imageLimits;
|
|
1583
|
+
image = await attachments.readImageRequest(block.attachment, {
|
|
1584
|
+
maxPixels: limits.maxImagePixels,
|
|
1585
|
+
maxBytes: limits.maxImageBytes
|
|
1586
|
+
}, signal);
|
|
1587
|
+
} catch (error) {
|
|
1588
|
+
if (signal?.aborted) throw new QoderLlmError("Qoder image preparation was aborted.", "ABORTED", { cause: error });
|
|
1589
|
+
if (error instanceof QoderLlmError) throw error;
|
|
1590
|
+
throw new QoderLlmError("Qoder could not prepare an image attachment.", "ATTACHMENT", { cause: error });
|
|
1591
|
+
}
|
|
1592
|
+
if (uploader !== void 0 && credentials !== void 0) return {
|
|
1593
|
+
type: "image_url",
|
|
1594
|
+
image_url: { url: await uploader.resolveImageUrl(image, credentials, signal) }
|
|
1595
|
+
};
|
|
1596
|
+
return {
|
|
1597
|
+
type: "image_url",
|
|
1598
|
+
image_url: { url: `data:${image.mediaType};base64,${Buffer.from(image.data).toString("base64")}` }
|
|
1599
|
+
};
|
|
1600
|
+
}
|
|
1601
|
+
function translateTools(tools) {
|
|
1602
|
+
return (tools ?? []).map((tool) => ({
|
|
1603
|
+
type: "function",
|
|
1604
|
+
function: {
|
|
1605
|
+
name: tool.name,
|
|
1606
|
+
description: tool.description,
|
|
1607
|
+
parameters: tool.parameters
|
|
1608
|
+
}
|
|
1609
|
+
}));
|
|
1610
|
+
}
|
|
1611
|
+
async function validateAndTranslateMessages(messages, systemPrompt, attachments, signal, pipeline) {
|
|
1612
|
+
validateMessageShapes(messages);
|
|
1613
|
+
const context = {
|
|
1614
|
+
attachments,
|
|
1615
|
+
signal,
|
|
1616
|
+
uploader: pipeline?.uploader,
|
|
1617
|
+
credentials: pipeline?.credentials
|
|
1618
|
+
};
|
|
1619
|
+
const preserveThinking = pipeline?.preserveThinking ?? true;
|
|
1620
|
+
const output = [];
|
|
1621
|
+
if (typeof systemPrompt === "string" && systemPrompt.trim().length > 0) output.push({
|
|
1622
|
+
role: "system",
|
|
1623
|
+
content: systemPrompt
|
|
1624
|
+
});
|
|
1625
|
+
for (const message of messages) {
|
|
1626
|
+
const toolResults = message.content.filter((block) => block.type === "tool-result");
|
|
1627
|
+
if (toolResults.length > 0) {
|
|
1628
|
+
if (message.role !== "user" || toolResults.length !== message.content.length) throw unsupported("Qoder tool-result messages cannot contain sibling content or use a non-user role.");
|
|
1629
|
+
for (const result of toolResults) {
|
|
1630
|
+
output.push({
|
|
1631
|
+
role: "tool",
|
|
1632
|
+
tool_call_id: String(result.toolCallId),
|
|
1633
|
+
content: toolResultText(result)
|
|
1634
|
+
});
|
|
1635
|
+
const images = result.content.filter((block) => block.type === "image");
|
|
1636
|
+
if (images.length > 0) {
|
|
1637
|
+
if (attachments !== void 0) enforceImageLimits(images, attachments);
|
|
1638
|
+
output.push({
|
|
1639
|
+
role: "user",
|
|
1640
|
+
content: [{
|
|
1641
|
+
type: "text",
|
|
1642
|
+
text: `[${images.length} image${images.length === 1 ? "" : "s"} returned by the previous tool call]`
|
|
1643
|
+
}, ...await Promise.all(images.map((image) => resolveImagePart(image, context)))]
|
|
1644
|
+
});
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
continue;
|
|
1648
|
+
}
|
|
1649
|
+
let text = "";
|
|
1650
|
+
let reasoningText = "";
|
|
1651
|
+
const userContent = [];
|
|
1652
|
+
const pendingImages = [];
|
|
1653
|
+
let hasImage = false;
|
|
1654
|
+
const toolCalls = [];
|
|
1655
|
+
for (const block of message.content) {
|
|
1656
|
+
if (block.type === "text") {
|
|
1657
|
+
text += block.text;
|
|
1658
|
+
if (message.role === "user") userContent.push({
|
|
1659
|
+
type: "text",
|
|
1660
|
+
text: block.text
|
|
1661
|
+
});
|
|
1662
|
+
continue;
|
|
1663
|
+
}
|
|
1664
|
+
if (block.type === "image") {
|
|
1665
|
+
hasImage = true;
|
|
1666
|
+
pendingImages.push({
|
|
1667
|
+
slot: userContent.length,
|
|
1668
|
+
block
|
|
1669
|
+
});
|
|
1670
|
+
userContent.push(void 0);
|
|
1671
|
+
continue;
|
|
1672
|
+
}
|
|
1673
|
+
if (block.type === "tool-call") {
|
|
1674
|
+
toolCalls.push({
|
|
1675
|
+
id: String(block.id),
|
|
1676
|
+
type: "function",
|
|
1677
|
+
function: {
|
|
1678
|
+
name: block.name,
|
|
1679
|
+
arguments: block.arguments
|
|
1680
|
+
}
|
|
1681
|
+
});
|
|
1682
|
+
continue;
|
|
1683
|
+
}
|
|
1684
|
+
if (block.type === "reasoning") {
|
|
1685
|
+
if (message.role === "assistant") reasoningText += block.text;
|
|
1686
|
+
continue;
|
|
1687
|
+
}
|
|
1688
|
+
}
|
|
1689
|
+
if (message.role === "assistant") {
|
|
1690
|
+
const hasReasoning = preserveThinking && reasoningText.length > 0;
|
|
1691
|
+
if (!text && toolCalls.length === 0 && !hasReasoning) continue;
|
|
1692
|
+
output.push({
|
|
1693
|
+
role: "assistant",
|
|
1694
|
+
content: text || " ",
|
|
1695
|
+
...toolCalls.length === 0 ? {} : { tool_calls: toolCalls },
|
|
1696
|
+
...hasReasoning ? { reasoning_content: reasoningText } : {}
|
|
1697
|
+
});
|
|
1698
|
+
continue;
|
|
1699
|
+
}
|
|
1700
|
+
if (pendingImages.length > 0) {
|
|
1701
|
+
if (attachments !== void 0) enforceImageLimits(pendingImages.map((pending) => pending.block), attachments);
|
|
1702
|
+
await Promise.all(pendingImages.map(async (pending) => {
|
|
1703
|
+
userContent[pending.slot] = await resolveImagePart(pending.block, context);
|
|
1704
|
+
}));
|
|
1705
|
+
}
|
|
1706
|
+
output.push({
|
|
1707
|
+
role: message.role,
|
|
1708
|
+
content: hasImage ? userContent.filter((part) => part !== void 0) : text
|
|
1709
|
+
});
|
|
1710
|
+
}
|
|
1711
|
+
return output;
|
|
1712
|
+
}
|
|
1713
|
+
//#endregion
|
|
1714
|
+
//#region src/qoder/transport/wire/serialize.ts
|
|
1715
|
+
/** Build the minimum qodercli request envelope for a validated DSH request. */
|
|
1716
|
+
function stableHash(prefix, ...inputs) {
|
|
1717
|
+
const hash = crypto.createHash("sha256");
|
|
1718
|
+
hash.update(prefix);
|
|
1719
|
+
for (const input of inputs) {
|
|
1720
|
+
hash.update("\0");
|
|
1721
|
+
hash.update(input);
|
|
1722
|
+
}
|
|
1723
|
+
return hash.digest("hex").slice(0, 16);
|
|
1724
|
+
}
|
|
1725
|
+
function stableChatRecordId(model, messages, tools, maxTokens) {
|
|
1726
|
+
const hash = crypto.createHash("sha256");
|
|
1727
|
+
hash.update("qoder-record");
|
|
1728
|
+
hash.update("\0");
|
|
1729
|
+
hash.update(model);
|
|
1730
|
+
hash.update("\0");
|
|
1731
|
+
hash.update(JSON.stringify(messages));
|
|
1732
|
+
hash.update("\0");
|
|
1733
|
+
hash.update(JSON.stringify(tools));
|
|
1734
|
+
hash.update("\0");
|
|
1735
|
+
hash.update(`mt=${maxTokens}`);
|
|
1736
|
+
return hash.digest("hex").slice(0, 16);
|
|
1737
|
+
}
|
|
1738
|
+
/**
|
|
1739
|
+
* Reject an unusable request before any credential resolution or provider I/O.
|
|
1740
|
+
*
|
|
1741
|
+
* Image publication needs credentials, so message translation now runs after
|
|
1742
|
+
* authentication. This static pass preserves the guarantee that a request the
|
|
1743
|
+
* provider cannot serve never reaches the network.
|
|
1744
|
+
*/
|
|
1745
|
+
function validateQoderRequestShape(options, model) {
|
|
1746
|
+
if (options.reasoningEffort !== void 0) {
|
|
1747
|
+
const effort = String(options.reasoningEffort);
|
|
1748
|
+
if (!model?.reasoningEfforts?.some((candidate) => candidate.id === effort)) throw new QoderLlmError(`Qoder model "${options.model}" does not advertise reasoning effort "${effort}".`, "UNSUPPORTED_REASONING_EFFORT");
|
|
1749
|
+
}
|
|
1750
|
+
if (options.messages.some((message) => contentHasImage(message.content)) && model?.supportsImages !== true) throw new QoderLlmError(`Qoder model "${options.model}" does not advertise image input.`, "UNSUPPORTED_CONTENT");
|
|
1751
|
+
validateMessageShapes(options.messages);
|
|
1752
|
+
}
|
|
1753
|
+
/** Translate a request whose shape has already been validated. */
|
|
1754
|
+
function translateQoderMessages(options, attachments, pipeline) {
|
|
1755
|
+
return validateAndTranslateMessages(options.messages, options.system, attachments, options.signal, pipeline);
|
|
1756
|
+
}
|
|
1757
|
+
async function validateQoderRequest(options, model, attachments) {
|
|
1758
|
+
validateQoderRequestShape(options, model);
|
|
1759
|
+
return validateAndTranslateMessages(options.messages, options.system, attachments, options.signal);
|
|
1760
|
+
}
|
|
1761
|
+
async function buildQoderRequestBody(options, userId, translatedMessages, model, attachments) {
|
|
1762
|
+
if (!userId) throw new QoderLlmError("Qoder request identity is missing.", "AUTH");
|
|
1763
|
+
const modelKey = options.model || "cmodel";
|
|
1764
|
+
const messages = translatedMessages ?? await validateQoderRequest(options, model, attachments);
|
|
1765
|
+
const modelMaxTokens = model?.maxTokens ?? 32768;
|
|
1766
|
+
const maxTokens = Math.min(options.maxTokens ?? modelMaxTokens, modelMaxTokens);
|
|
1767
|
+
const isReasoning = options.reasoningEffort !== void 0 || (model?.isReasoning ?? false);
|
|
1768
|
+
const tools = translateTools(options.tools);
|
|
1769
|
+
const defaultContexts = Object.values(model?.contextOptions ?? {}).filter((option) => option.isDefault === true && typeof option.tokenCount === "number" && Number.isFinite(option.tokenCount) && option.tokenCount > 0);
|
|
1770
|
+
const contextConfig = model?.contextOptions === void 0 || defaultContexts.length !== 1 ? void 0 : Object.fromEntries(Object.entries(model.contextOptions).map(([key, value]) => [key, {
|
|
1771
|
+
...value.tokenCount === void 0 ? {} : { token_count: value.tokenCount },
|
|
1772
|
+
...value.isDefault === void 0 ? {} : { is_default: value.isDefault }
|
|
1773
|
+
}]));
|
|
1774
|
+
let lastUserText = "";
|
|
1775
|
+
for (let index = messages.length - 1; index >= 0; index--) {
|
|
1776
|
+
const message = messages[index];
|
|
1777
|
+
if (message === void 0) continue;
|
|
1778
|
+
if (message.role === "user") {
|
|
1779
|
+
const content = message.content;
|
|
1780
|
+
lastUserText = typeof content === "string" ? content : Array.isArray(content) ? content.filter((part) => part.type === "text").map((part) => part.text).join("") : "";
|
|
1781
|
+
break;
|
|
1782
|
+
}
|
|
1783
|
+
}
|
|
1784
|
+
const stablePart = stableHash("qoder-session", userId, modelKey);
|
|
1785
|
+
const sessionId = options.sessionId === void 0 ? `${stablePart}-${crypto.randomUUID()}` : `${stablePart}-${String(options.sessionId)}`;
|
|
1786
|
+
const recordId = stableChatRecordId(modelKey, messages, tools, maxTokens);
|
|
1787
|
+
return {
|
|
1788
|
+
request_id: crypto.randomUUID(),
|
|
1789
|
+
request_set_id: recordId,
|
|
1790
|
+
chat_record_id: recordId,
|
|
1791
|
+
session_id: sessionId,
|
|
1792
|
+
stream: true,
|
|
1793
|
+
chat_task: "FREE_INPUT",
|
|
1794
|
+
is_reply: true,
|
|
1795
|
+
is_retry: false,
|
|
1796
|
+
source: 1,
|
|
1797
|
+
version: "3",
|
|
1798
|
+
session_type: "qodercli",
|
|
1799
|
+
agent_id: "agent_common",
|
|
1800
|
+
task_id: "common",
|
|
1801
|
+
code_language: "",
|
|
1802
|
+
chat_prompt: "",
|
|
1803
|
+
image_urls: null,
|
|
1804
|
+
aliyun_user_type: "",
|
|
1805
|
+
system: "",
|
|
1806
|
+
messages,
|
|
1807
|
+
tools,
|
|
1808
|
+
parameters: {
|
|
1809
|
+
max_tokens: maxTokens,
|
|
1810
|
+
...options.reasoningEffort === void 0 ? {} : { reasoning_effort: String(options.reasoningEffort) }
|
|
1811
|
+
},
|
|
1812
|
+
chat_context: {
|
|
1813
|
+
chatPrompt: "",
|
|
1814
|
+
imageUrls: null,
|
|
1815
|
+
extra: {
|
|
1816
|
+
context: [],
|
|
1817
|
+
modelConfig: {
|
|
1818
|
+
key: modelKey,
|
|
1819
|
+
is_reasoning: isReasoning
|
|
1820
|
+
},
|
|
1821
|
+
originalContent: lastUserText
|
|
1822
|
+
},
|
|
1823
|
+
features: [],
|
|
1824
|
+
text: lastUserText
|
|
1825
|
+
},
|
|
1826
|
+
model_config: {
|
|
1827
|
+
key: modelKey,
|
|
1828
|
+
is_reasoning: isReasoning,
|
|
1829
|
+
max_output_tokens: maxTokens,
|
|
1830
|
+
source: model?.source || "system",
|
|
1831
|
+
...contextConfig === void 0 ? {} : { context_config: contextConfig }
|
|
1832
|
+
},
|
|
1833
|
+
business: {
|
|
1834
|
+
product: "cli",
|
|
1835
|
+
version: "1.0.0",
|
|
1836
|
+
type: "agent",
|
|
1837
|
+
stage: "start",
|
|
1838
|
+
id: crypto.randomUUID(),
|
|
1839
|
+
name: lastUserText.substring(0, 30),
|
|
1840
|
+
begin_at: Date.now()
|
|
1841
|
+
}
|
|
1842
|
+
};
|
|
1843
|
+
}
|
|
1844
|
+
const mediaTypeExtensions = {
|
|
1845
|
+
"image/png": "png",
|
|
1846
|
+
"image/jpeg": "jpg",
|
|
1847
|
+
"image/webp": "webp",
|
|
1848
|
+
"image/gif": "gif"
|
|
1849
|
+
};
|
|
1850
|
+
function aborted$2() {
|
|
1851
|
+
return new QoderLlmError("Qoder image upload was aborted.", "ABORTED");
|
|
1852
|
+
}
|
|
1853
|
+
/** Encode one image as a single-field multipart payload using the Qoder boundary shape. */
|
|
1854
|
+
function buildQoderImageMultipart(data, mediaType, boundaryId = crypto.randomUUID()) {
|
|
1855
|
+
const boundary = `----qodercli-${boundaryId}`;
|
|
1856
|
+
const extension = mediaTypeExtensions[mediaType] ?? "png";
|
|
1857
|
+
const header = Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="image.${extension}"\r\nContent-Type: ${mediaType}\r\n\r\n`, "utf8");
|
|
1858
|
+
const footer = Buffer.from(`\r\n--${boundary}--\r\n`, "utf8");
|
|
1859
|
+
return {
|
|
1860
|
+
body: Buffer.concat([
|
|
1861
|
+
header,
|
|
1862
|
+
Buffer.from(data),
|
|
1863
|
+
footer
|
|
1864
|
+
]),
|
|
1865
|
+
boundary
|
|
1866
|
+
};
|
|
1867
|
+
}
|
|
1868
|
+
/**
|
|
1869
|
+
* Extract the durable object URL from a center upload response.
|
|
1870
|
+
*
|
|
1871
|
+
* @param payload - parsed response body.
|
|
1872
|
+
* @returns the first usable absolute URL, or undefined when none is present.
|
|
1873
|
+
*/
|
|
1874
|
+
function readQoderImageUrl(payload) {
|
|
1875
|
+
if (payload === null || typeof payload !== "object") return void 0;
|
|
1876
|
+
const root = payload;
|
|
1877
|
+
const result = root.result;
|
|
1878
|
+
const data = root.data;
|
|
1879
|
+
const candidates = [
|
|
1880
|
+
root.url,
|
|
1881
|
+
result?.url,
|
|
1882
|
+
result?.oss_url,
|
|
1883
|
+
data?.url,
|
|
1884
|
+
data?.oss_url
|
|
1885
|
+
];
|
|
1886
|
+
for (const candidate of candidates) {
|
|
1887
|
+
if (typeof candidate !== "string") continue;
|
|
1888
|
+
const trimmed = candidate.trim();
|
|
1889
|
+
if (trimmed.length === 0) continue;
|
|
1890
|
+
if (!URL.canParse(trimmed)) continue;
|
|
1891
|
+
return trimmed;
|
|
1892
|
+
}
|
|
1893
|
+
}
|
|
1894
|
+
function dataUrl(image) {
|
|
1895
|
+
return `data:${image.mediaType};base64,${Buffer.from(image.data).toString("base64")}`;
|
|
1896
|
+
}
|
|
1897
|
+
/**
|
|
1898
|
+
* Publishes request images to the Qoder center service and remembers the result.
|
|
1899
|
+
*
|
|
1900
|
+
* Publication never fails a model request: any upstream problem degrades to an
|
|
1901
|
+
* inline data URL so the turn proceeds with the same content it would have
|
|
1902
|
+
* carried before the pipeline existed.
|
|
1903
|
+
*/
|
|
1904
|
+
var QoderImageUploader = class {
|
|
1905
|
+
cache = /* @__PURE__ */ new Map();
|
|
1906
|
+
flights = new SingleFlight();
|
|
1907
|
+
fetchImpl;
|
|
1908
|
+
region;
|
|
1909
|
+
logger;
|
|
1910
|
+
timeoutMs;
|
|
1911
|
+
cacheTtlMs;
|
|
1912
|
+
cacheCapacity;
|
|
1913
|
+
maxConcurrency;
|
|
1914
|
+
refreshCredentials;
|
|
1915
|
+
now;
|
|
1916
|
+
active = 0;
|
|
1917
|
+
queue = [];
|
|
1918
|
+
constructor(options = {}) {
|
|
1919
|
+
this.fetchImpl = options.fetch ?? globalThis.fetch;
|
|
1920
|
+
this.region = options.region ?? "global";
|
|
1921
|
+
this.logger = options.logger;
|
|
1922
|
+
this.timeoutMs = options.timeoutMs ?? 3e4;
|
|
1923
|
+
this.cacheTtlMs = options.cacheTtlMs ?? 18e5;
|
|
1924
|
+
this.cacheCapacity = options.cacheCapacity ?? 512;
|
|
1925
|
+
this.maxConcurrency = options.maxConcurrency ?? 4;
|
|
1926
|
+
this.refreshCredentials = options.refreshCredentials;
|
|
1927
|
+
this.now = options.now ?? Date.now;
|
|
1928
|
+
}
|
|
1929
|
+
/**
|
|
1930
|
+
* Resolve the wire URL for one request image.
|
|
1931
|
+
*
|
|
1932
|
+
* @param image - request-encoded image produced by the DSH attachment store.
|
|
1933
|
+
* @param credentials - credentials authorizing the center exchange.
|
|
1934
|
+
* @param signal - optional caller cancellation.
|
|
1935
|
+
* @returns a center object URL, or an inline data URL when publication fails.
|
|
1936
|
+
* @throws QoderLlmError with code `ABORTED` only when the caller cancels.
|
|
1937
|
+
*/
|
|
1938
|
+
async resolveImageUrl(image, credentials, signal) {
|
|
1939
|
+
if (signal?.aborted) throw aborted$2();
|
|
1940
|
+
const key = this.cacheKey(image, credentials);
|
|
1941
|
+
const cached = this.cache.get(key);
|
|
1942
|
+
if (cached !== void 0) {
|
|
1943
|
+
if (cached.expiresAt > this.now()) {
|
|
1944
|
+
this.cache.delete(key);
|
|
1945
|
+
this.cache.set(key, cached);
|
|
1946
|
+
this.logger?.debug?.("[image-upload] cache hit", {
|
|
1947
|
+
url: cached.url,
|
|
1948
|
+
region: this.region,
|
|
1949
|
+
mediaType: image.mediaType,
|
|
1950
|
+
bytes: image.data.byteLength
|
|
1951
|
+
});
|
|
1952
|
+
return cached.url;
|
|
1953
|
+
}
|
|
1954
|
+
this.cache.delete(key);
|
|
1955
|
+
}
|
|
1956
|
+
return this.flights.run(key, signal, async (sharedSignal) => {
|
|
1957
|
+
const url = await this.publish(image, credentials, sharedSignal);
|
|
1958
|
+
if (url !== void 0) {
|
|
1959
|
+
this.remember(key, url);
|
|
1960
|
+
return url;
|
|
1961
|
+
}
|
|
1962
|
+
return dataUrl(image);
|
|
1963
|
+
}, aborted$2);
|
|
1964
|
+
}
|
|
1965
|
+
/**
|
|
1966
|
+
* Derive the deduplication identity for one request image.
|
|
1967
|
+
*
|
|
1968
|
+
* `variantId` is the attachment store's deterministic cache and upload index
|
|
1969
|
+
* over the attachment, the request policy, and the encoder parameters, so it
|
|
1970
|
+
* identifies these exact bytes without rehashing them. Region and subscriber
|
|
1971
|
+
* are included because a center object is never shared across either.
|
|
1972
|
+
*/
|
|
1973
|
+
cacheKey(image, credentials) {
|
|
1974
|
+
const hash = crypto.createHash("sha256");
|
|
1975
|
+
hash.update(getQoderImageUploadUrl(this.region));
|
|
1976
|
+
hash.update("\0");
|
|
1977
|
+
hash.update(credentials.userID);
|
|
1978
|
+
hash.update("\0");
|
|
1979
|
+
hash.update(image.mediaType);
|
|
1980
|
+
hash.update("\0");
|
|
1981
|
+
const variantId = typeof image.variantId === "string" ? image.variantId : "";
|
|
1982
|
+
if (variantId.length > 0) hash.update(variantId);
|
|
1983
|
+
else hash.update(crypto.createHash("sha256").update(Buffer.from(image.data)).digest("hex"));
|
|
1984
|
+
return hash.digest("hex");
|
|
1985
|
+
}
|
|
1986
|
+
remember(key, url) {
|
|
1987
|
+
this.cache.delete(key);
|
|
1988
|
+
this.cache.set(key, {
|
|
1989
|
+
url,
|
|
1990
|
+
expiresAt: this.now() + this.cacheTtlMs
|
|
1991
|
+
});
|
|
1992
|
+
while (this.cache.size > this.cacheCapacity) {
|
|
1993
|
+
const oldest = this.cache.keys().next();
|
|
1994
|
+
if (oldest.done === true) break;
|
|
1995
|
+
this.cache.delete(oldest.value);
|
|
1996
|
+
}
|
|
1997
|
+
}
|
|
1998
|
+
async withSlot(signal, operation) {
|
|
1999
|
+
if (signal.aborted) throw aborted$2();
|
|
2000
|
+
if (this.active >= this.maxConcurrency) {
|
|
2001
|
+
this.logger?.debug?.("[image-upload] queued", {
|
|
2002
|
+
region: this.region,
|
|
2003
|
+
active: this.active,
|
|
2004
|
+
queued: this.queue.length + 1
|
|
2005
|
+
});
|
|
2006
|
+
await new Promise((resolve, reject) => {
|
|
2007
|
+
const onAbort = () => {
|
|
2008
|
+
const index = this.queue.indexOf(release);
|
|
2009
|
+
if (index !== -1) this.queue.splice(index, 1);
|
|
2010
|
+
reject(aborted$2());
|
|
2011
|
+
};
|
|
2012
|
+
const release = () => {
|
|
2013
|
+
signal.removeEventListener("abort", onAbort);
|
|
2014
|
+
resolve();
|
|
2015
|
+
};
|
|
2016
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
2017
|
+
this.queue.push(release);
|
|
2018
|
+
});
|
|
2019
|
+
} else this.active++;
|
|
2020
|
+
try {
|
|
2021
|
+
if (signal.aborted) throw aborted$2();
|
|
2022
|
+
return await operation();
|
|
2023
|
+
} finally {
|
|
2024
|
+
const next = this.queue.shift();
|
|
2025
|
+
if (next !== void 0) next();
|
|
2026
|
+
else this.active--;
|
|
2027
|
+
}
|
|
2028
|
+
}
|
|
2029
|
+
/** Attempt publication, returning undefined when the request must degrade. */
|
|
2030
|
+
async publish(image, credentials, signal) {
|
|
2031
|
+
return this.withSlot(signal, async () => {
|
|
2032
|
+
const first = await this.attempt(image, credentials, signal);
|
|
2033
|
+
if (first.url !== void 0) return first.url;
|
|
2034
|
+
if (!first.retryable || this.refreshCredentials === void 0) {
|
|
2035
|
+
this.warnDegraded(first.reason);
|
|
2036
|
+
return;
|
|
2037
|
+
}
|
|
2038
|
+
let refreshed;
|
|
2039
|
+
this.logger?.debug?.("[image-upload] refreshing credentials before retry", {
|
|
2040
|
+
region: this.region,
|
|
2041
|
+
reason: first.reason
|
|
2042
|
+
});
|
|
2043
|
+
try {
|
|
2044
|
+
refreshed = await this.refreshCredentials(signal);
|
|
2045
|
+
} catch (error) {
|
|
2046
|
+
if (signal.aborted) throw aborted$2();
|
|
2047
|
+
this.warnDegraded("credential refresh failed", error);
|
|
2048
|
+
return;
|
|
2049
|
+
}
|
|
2050
|
+
const second = await this.attempt(image, refreshed, signal);
|
|
2051
|
+
if (second.url !== void 0) return second.url;
|
|
2052
|
+
this.warnDegraded(second.reason);
|
|
2053
|
+
});
|
|
2054
|
+
}
|
|
2055
|
+
warnDegraded(reason, cause) {
|
|
2056
|
+
this.logger?.warn?.("[image-upload] upload failed, keeping base64 image", {
|
|
2057
|
+
region: this.region,
|
|
2058
|
+
reason
|
|
2059
|
+
}, ...cause === void 0 ? [] : [redactLogValue(cause)]);
|
|
2060
|
+
}
|
|
2061
|
+
async attempt(image, credentials, signal) {
|
|
2062
|
+
const requestId = crypto.randomUUID();
|
|
2063
|
+
const url = getQoderImageUploadUrl(this.region, requestId);
|
|
2064
|
+
const multipart = buildQoderImageMultipart(image.data, image.mediaType);
|
|
2065
|
+
const signedBody = Buffer.from(String(multipart.body.length), "utf8");
|
|
2066
|
+
const timeout = AbortSignal.timeout(this.timeoutMs);
|
|
2067
|
+
const requestSignal = AbortSignal.any([signal, timeout]);
|
|
2068
|
+
const startedAt = this.now();
|
|
2069
|
+
const details = {
|
|
2070
|
+
requestId,
|
|
2071
|
+
region: this.region,
|
|
2072
|
+
mediaType: image.mediaType,
|
|
2073
|
+
bytes: image.data.byteLength,
|
|
2074
|
+
timeoutMs: this.timeoutMs
|
|
2075
|
+
};
|
|
2076
|
+
this.logger?.debug?.("[image-upload] started", details);
|
|
2077
|
+
try {
|
|
2078
|
+
const response = await this.fetchImpl(url, {
|
|
2079
|
+
method: "PUT",
|
|
2080
|
+
headers: {
|
|
2081
|
+
...buildAuthHeaders(signedBody, url, credentials),
|
|
2082
|
+
"AI-CLIENT-TIMESTAMP": String(Math.floor(this.now() / 1e3)),
|
|
2083
|
+
"Content-Type": `multipart/form-data; boundary=${multipart.boundary}`,
|
|
2084
|
+
"Content-Length": String(multipart.body.length),
|
|
2085
|
+
"accept": "application/json"
|
|
2086
|
+
},
|
|
2087
|
+
body: new Uint8Array(multipart.body),
|
|
2088
|
+
signal: requestSignal
|
|
2089
|
+
});
|
|
2090
|
+
this.logger?.debug?.("[image-upload] response received", {
|
|
2091
|
+
requestId,
|
|
2092
|
+
status: response.status,
|
|
2093
|
+
elapsedMs: this.now() - startedAt
|
|
2094
|
+
});
|
|
2095
|
+
const text = await readLimitedText(response, defaultMaxJsonBytes, "Qoder image upload response");
|
|
2096
|
+
if (!response.ok) return {
|
|
2097
|
+
retryable: response.status === 401 || response.status === 403,
|
|
2098
|
+
reason: `HTTP ${response.status}`
|
|
2099
|
+
};
|
|
2100
|
+
let payload;
|
|
2101
|
+
try {
|
|
2102
|
+
payload = JSON.parse(text);
|
|
2103
|
+
} catch {
|
|
2104
|
+
return {
|
|
2105
|
+
retryable: false,
|
|
2106
|
+
reason: "invalid JSON response"
|
|
2107
|
+
};
|
|
2108
|
+
}
|
|
2109
|
+
const resolved = readQoderImageUrl(payload);
|
|
2110
|
+
if (resolved === void 0) return {
|
|
2111
|
+
retryable: false,
|
|
2112
|
+
reason: "response carried no image URL"
|
|
2113
|
+
};
|
|
2114
|
+
this.logger?.debug?.("[image-upload] succeeded", {
|
|
2115
|
+
...details,
|
|
2116
|
+
status: response.status,
|
|
2117
|
+
elapsedMs: this.now() - startedAt,
|
|
2118
|
+
url: resolved
|
|
2119
|
+
});
|
|
2120
|
+
return {
|
|
2121
|
+
url: resolved,
|
|
2122
|
+
retryable: false,
|
|
2123
|
+
reason: ""
|
|
2124
|
+
};
|
|
2125
|
+
} catch (error) {
|
|
2126
|
+
const failureDetails = {
|
|
2127
|
+
requestId,
|
|
2128
|
+
region: this.region,
|
|
2129
|
+
elapsedMs: this.now() - startedAt
|
|
2130
|
+
};
|
|
2131
|
+
if (signal.aborted) {
|
|
2132
|
+
this.logger?.debug?.("[image-upload] aborted", failureDetails);
|
|
2133
|
+
throw aborted$2();
|
|
2134
|
+
}
|
|
2135
|
+
if (timeout.aborted) {
|
|
2136
|
+
this.logger?.debug?.("[image-upload] timed out", failureDetails);
|
|
2137
|
+
return {
|
|
2138
|
+
retryable: false,
|
|
2139
|
+
reason: "upload timed out"
|
|
2140
|
+
};
|
|
2141
|
+
}
|
|
2142
|
+
this.logger?.debug?.("[image-upload] network failure", failureDetails, redactLogValue(error));
|
|
2143
|
+
return {
|
|
2144
|
+
retryable: false,
|
|
2145
|
+
reason: "network failure"
|
|
2146
|
+
};
|
|
2147
|
+
}
|
|
2148
|
+
}
|
|
2149
|
+
};
|
|
2150
|
+
//#endregion
|
|
2151
|
+
//#region src/qoder/transport/account-reader.ts
|
|
2152
|
+
const defaultUsageTtlMs = 6e4;
|
|
2153
|
+
const defaultUsageTimeoutMs = 15e3;
|
|
2154
|
+
function normalizeQuota(raw) {
|
|
2155
|
+
if (!raw || typeof raw !== "object") return void 0;
|
|
2156
|
+
const total = typeof raw.total === "number" && Number.isFinite(raw.total) ? raw.total : typeof raw.cap === "number" && Number.isFinite(raw.cap) ? raw.cap : typeof raw.remaining === "number" && typeof raw.used === "number" ? raw.used + raw.remaining : 0;
|
|
2157
|
+
const used = typeof raw.used === "number" && Number.isFinite(raw.used) ? raw.used : 0;
|
|
2158
|
+
const remaining = typeof raw.remaining === "number" && Number.isFinite(raw.remaining) ? raw.remaining : Math.max(0, total - used);
|
|
2159
|
+
let percentage;
|
|
2160
|
+
if (typeof raw.percentage === "number" && Number.isFinite(raw.percentage)) percentage = raw.percentage <= 1 && total > 1 ? raw.percentage * 100 : raw.percentage;
|
|
2161
|
+
else percentage = total > 0 ? used / total * 100 : 0;
|
|
2162
|
+
const unit = typeof raw.unit === "string" && raw.unit.length > 0 ? raw.unit : "credits";
|
|
2163
|
+
return {
|
|
2164
|
+
total,
|
|
2165
|
+
used,
|
|
2166
|
+
remaining,
|
|
2167
|
+
percentage,
|
|
2168
|
+
unit
|
|
2169
|
+
};
|
|
2170
|
+
}
|
|
2171
|
+
function normalizeExpiresAt(rawExpires) {
|
|
2172
|
+
if (rawExpires === void 0 || rawExpires === null) return void 0;
|
|
2173
|
+
if (typeof rawExpires === "number" && rawExpires > 0) return new Date(rawExpires).toISOString();
|
|
2174
|
+
if (typeof rawExpires === "string" && rawExpires.length > 0) {
|
|
2175
|
+
const parsed = Date.parse(rawExpires);
|
|
2176
|
+
if (!Number.isNaN(parsed) && parsed > 0) return new Date(parsed).toISOString();
|
|
2177
|
+
}
|
|
2178
|
+
}
|
|
2179
|
+
function asString(value) {
|
|
2180
|
+
return typeof value === "string" && value.trim().length > 0 ? value.trim() : void 0;
|
|
2181
|
+
}
|
|
2182
|
+
function asBoolean(value) {
|
|
2183
|
+
return typeof value === "boolean" ? value : void 0;
|
|
2184
|
+
}
|
|
2185
|
+
function asNumber(value) {
|
|
2186
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
2187
|
+
if (typeof value === "string" && value.trim().length > 0) {
|
|
2188
|
+
const num = Number(value);
|
|
2189
|
+
if (Number.isFinite(num)) return num;
|
|
2190
|
+
}
|
|
2191
|
+
}
|
|
2192
|
+
function normalizeOrganization(raw) {
|
|
2193
|
+
if (!raw || typeof raw !== "object") return void 0;
|
|
2194
|
+
const obj = raw;
|
|
2195
|
+
const orgId = asString(obj.org_id) ?? asString(obj.orgId) ?? asString(obj.id);
|
|
2196
|
+
const orgName = asString(obj.org_name) ?? asString(obj.orgName) ?? asString(obj.name);
|
|
2197
|
+
if (!orgId || !orgName) return void 0;
|
|
2198
|
+
return {
|
|
2199
|
+
orgId,
|
|
2200
|
+
orgName,
|
|
2201
|
+
...asString(obj.role_name) ?? asString(obj.roleName) !== void 0 ? { roleName: asString(obj.role_name) ?? asString(obj.roleName) } : {},
|
|
2202
|
+
isSuspended: asBoolean(obj.is_suspended) ?? asBoolean(obj.isSuspended) ?? false,
|
|
2203
|
+
canManageSubscriptions: asBoolean(obj.can_manage_subscriptions) ?? asBoolean(obj.canManageSubscriptions) ?? false,
|
|
2204
|
+
resourcePackageFeatureEnabled: asBoolean(obj.resource_package_feature_enabled) ?? asBoolean(obj.resourcePackageFeatureEnabled) ?? false
|
|
2205
|
+
};
|
|
2206
|
+
}
|
|
2207
|
+
function normalizeFeatureAllowed(raw) {
|
|
2208
|
+
if (!raw || typeof raw !== "object") return void 0;
|
|
2209
|
+
const obj = raw;
|
|
2210
|
+
return {
|
|
2211
|
+
quest: asBoolean(obj.quest) ?? false,
|
|
2212
|
+
wiki: asBoolean(obj.wiki) ?? false,
|
|
2213
|
+
codeReview: asBoolean(obj.code_review) ?? asBoolean(obj.codeReview) ?? false
|
|
2214
|
+
};
|
|
2215
|
+
}
|
|
2216
|
+
function normalizePlan(raw) {
|
|
2217
|
+
if (!raw || typeof raw !== "object") return void 0;
|
|
2218
|
+
const obj = raw;
|
|
2219
|
+
const userType = asString(obj.user_type) ?? asString(obj.userType);
|
|
2220
|
+
const planTierName = asString(obj.plan_tier_name) ?? asString(obj.planTierName) ?? asString(obj.plan_name) ?? asString(obj.planName);
|
|
2221
|
+
if (!userType || !planTierName) return void 0;
|
|
2222
|
+
const organization = normalizeOrganization(obj.organization);
|
|
2223
|
+
const isPersonalVersion = asBoolean(obj.is_personal_version) ?? asBoolean(obj.isPersonalVersion) ?? organization === void 0;
|
|
2224
|
+
const startDate = normalizeExpiresAt(obj.start_date ?? obj.startDate);
|
|
2225
|
+
const endDate = normalizeExpiresAt(obj.end_date ?? obj.endDate);
|
|
2226
|
+
const planTier = asString(obj.plan_tier) ?? asString(obj.planTier);
|
|
2227
|
+
const isHighestTier = asBoolean(obj.is_highest_tier) ?? asBoolean(obj.isHighestTier);
|
|
2228
|
+
const isRenewed = asBoolean(obj.is_renewed) ?? asBoolean(obj.isRenewed);
|
|
2229
|
+
const featureAllowed = normalizeFeatureAllowed(obj.feature_allowed ?? obj.featureAllowed);
|
|
2230
|
+
return {
|
|
2231
|
+
userType,
|
|
2232
|
+
planTierName,
|
|
2233
|
+
...planTier !== void 0 ? { planTier } : {},
|
|
2234
|
+
isPersonalVersion,
|
|
2235
|
+
...isHighestTier !== void 0 ? { isHighestTier } : {},
|
|
2236
|
+
...isRenewed !== void 0 ? { isRenewed } : {},
|
|
2237
|
+
...startDate !== void 0 ? { startDate } : {},
|
|
2238
|
+
...endDate !== void 0 ? { endDate } : {},
|
|
2239
|
+
...organization !== void 0 ? { organization } : {},
|
|
2240
|
+
...featureAllowed !== void 0 ? { featureAllowed } : {},
|
|
2241
|
+
raw
|
|
2242
|
+
};
|
|
2243
|
+
}
|
|
2244
|
+
function normalizeStatus(raw) {
|
|
2245
|
+
if (!raw || typeof raw !== "object") return void 0;
|
|
2246
|
+
const obj = raw;
|
|
2247
|
+
const featureSwitches = obj.featureSwitches ?? obj.feature_switches;
|
|
2248
|
+
const teamSwitches = obj.teamSwitches ?? obj.team_switches;
|
|
2249
|
+
const allowByok = asNumber(featureSwitches?.allow_byok ?? featureSwitches?.allowByok) ?? 0;
|
|
2250
|
+
const teamAllowByok = asNumber(teamSwitches?.allow_byok ?? teamSwitches?.allowByok);
|
|
2251
|
+
const isPrivacyPolicyModifiable = asBoolean(obj.isPrivacyPolicyModifiable ?? obj.is_data_policy_modifiable);
|
|
2252
|
+
return {
|
|
2253
|
+
allowByok,
|
|
2254
|
+
...teamAllowByok !== void 0 ? { teamAllowByok } : {},
|
|
2255
|
+
...isPrivacyPolicyModifiable !== void 0 ? { isPrivacyPolicyModifiable } : {},
|
|
2256
|
+
raw
|
|
2257
|
+
};
|
|
2258
|
+
}
|
|
2259
|
+
var QoderUsageReader = class {
|
|
2260
|
+
authService;
|
|
2261
|
+
fetchImpl;
|
|
2262
|
+
ttlMs;
|
|
2263
|
+
timeoutMs;
|
|
2264
|
+
region;
|
|
2265
|
+
logger;
|
|
2266
|
+
cache = /* @__PURE__ */ new Map();
|
|
2267
|
+
flights = new SingleFlight();
|
|
2268
|
+
constructor(options) {
|
|
2269
|
+
this.authService = options.authService;
|
|
2270
|
+
this.fetchImpl = options.fetch ?? globalThis.fetch;
|
|
2271
|
+
this.ttlMs = options.ttlMs ?? defaultUsageTtlMs;
|
|
2272
|
+
this.timeoutMs = options.timeoutMs ?? defaultUsageTimeoutMs;
|
|
2273
|
+
this.region = options.region ?? "global";
|
|
2274
|
+
this.logger = options.logger;
|
|
2275
|
+
}
|
|
2276
|
+
async readAccount(pat, options) {
|
|
2277
|
+
if (!pat || typeof pat !== "string") throw new QoderLlmError("Qoder Personal Access Token is missing or invalid.", "MISSING_CREDENTIAL");
|
|
2278
|
+
const cacheKey = `${this.region}:${opaqueCredentialKey(pat)}`;
|
|
2279
|
+
if (!options?.force) {
|
|
2280
|
+
const cached = this.cache.get(cacheKey);
|
|
2281
|
+
if (cached && cached.expiresAt > Date.now()) return cached.info;
|
|
2282
|
+
}
|
|
2283
|
+
return this.flights.run(cacheKey, options?.signal, (sharedSignal) => this.loadAccount(pat, sharedSignal, cacheKey), () => new QoderLlmError("Qoder account request was aborted.", "ABORTED"));
|
|
2284
|
+
}
|
|
2285
|
+
async loadAccount(pat, signal, cacheKey) {
|
|
2286
|
+
const creds = await this.authService.getCredentials(pat, signal);
|
|
2287
|
+
const profile = {
|
|
2288
|
+
id: creds.userID,
|
|
2289
|
+
name: creds.name || "Qoder User",
|
|
2290
|
+
email: creds.email || ""
|
|
2291
|
+
};
|
|
2292
|
+
const [usage, plan, status] = await Promise.all([
|
|
2293
|
+
retryMetadataRead(signal, () => this.fetchUsage(creds.authToken, signal)),
|
|
2294
|
+
this.safeFetchPlan(creds.authToken, signal),
|
|
2295
|
+
this.safeFetchStatus(creds.authToken, creds.machineID, signal)
|
|
2296
|
+
]);
|
|
2297
|
+
const accountInfo = {
|
|
2298
|
+
profile,
|
|
2299
|
+
usage,
|
|
2300
|
+
...plan !== void 0 ? { plan } : {},
|
|
2301
|
+
...status !== void 0 ? { status } : {},
|
|
2302
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2303
|
+
};
|
|
2304
|
+
this.cache.set(cacheKey, {
|
|
2305
|
+
info: accountInfo,
|
|
2306
|
+
expiresAt: Date.now() + this.ttlMs
|
|
2307
|
+
});
|
|
2308
|
+
return accountInfo;
|
|
2309
|
+
}
|
|
2310
|
+
clear(pat) {
|
|
2311
|
+
if (pat) this.cache.delete(`${this.region}:${opaqueCredentialKey(pat)}`);
|
|
2312
|
+
else this.cache.clear();
|
|
2313
|
+
}
|
|
2314
|
+
async fetchUsage(jobToken, signal) {
|
|
2315
|
+
const data = await openApiJsonRequest(this.fetchImpl, {
|
|
2316
|
+
url: getQoderUsageUrl(this.region),
|
|
2317
|
+
token: jobToken,
|
|
2318
|
+
signal,
|
|
2319
|
+
timeoutMs: this.timeoutMs,
|
|
2320
|
+
logger: this.logger,
|
|
2321
|
+
operation: "Usage",
|
|
2322
|
+
logCategory: "account.usage"
|
|
2323
|
+
});
|
|
2324
|
+
return {
|
|
2325
|
+
userQuota: normalizeQuota(data.userQuota),
|
|
2326
|
+
orgResourcePackage: normalizeQuota(data.orgResourcePackage),
|
|
2327
|
+
totalUsagePercentage: typeof data.totalUsagePercentage === "number" ? data.totalUsagePercentage : void 0,
|
|
2328
|
+
isQuotaExceeded: typeof data.isQuotaExceeded === "boolean" ? data.isQuotaExceeded : false,
|
|
2329
|
+
expiresAt: normalizeExpiresAt(data.expiresAt),
|
|
2330
|
+
raw: data
|
|
2331
|
+
};
|
|
2332
|
+
}
|
|
2333
|
+
async fetchPlan(jobToken, signal) {
|
|
2334
|
+
return normalizePlan(await openApiJsonRequest(this.fetchImpl, {
|
|
2335
|
+
url: getQoderUserPlanUrl(this.region),
|
|
2336
|
+
token: jobToken,
|
|
2337
|
+
signal,
|
|
2338
|
+
timeoutMs: this.timeoutMs,
|
|
2339
|
+
logger: this.logger,
|
|
2340
|
+
operation: "Plan",
|
|
2341
|
+
logCategory: "account.plan"
|
|
2342
|
+
}));
|
|
2343
|
+
}
|
|
2344
|
+
async safeFetchPlan(jobToken, signal) {
|
|
2345
|
+
try {
|
|
2346
|
+
return await this.fetchPlan(jobToken, signal);
|
|
2347
|
+
} catch (error) {
|
|
2348
|
+
if (signal.aborted) throw error;
|
|
2349
|
+
this.logger?.warn?.("[Qoder Plan] Failed to load user plan (degraded)", error instanceof Error ? error.message : error);
|
|
2350
|
+
return;
|
|
2351
|
+
}
|
|
2352
|
+
}
|
|
2353
|
+
async fetchStatus(jobToken, machineId, signal) {
|
|
2354
|
+
return normalizeStatus(await openApiJsonRequest(this.fetchImpl, {
|
|
2355
|
+
url: getQoderUserStatusUrl(this.region),
|
|
2356
|
+
token: jobToken,
|
|
2357
|
+
machineId,
|
|
2358
|
+
signal,
|
|
2359
|
+
timeoutMs: this.timeoutMs,
|
|
2360
|
+
logger: this.logger,
|
|
2361
|
+
operation: "Status",
|
|
2362
|
+
logCategory: "account.status"
|
|
2363
|
+
}));
|
|
2364
|
+
}
|
|
2365
|
+
async safeFetchStatus(jobToken, machineId, signal) {
|
|
2366
|
+
try {
|
|
2367
|
+
return await this.fetchStatus(jobToken, machineId, signal);
|
|
2368
|
+
} catch (error) {
|
|
2369
|
+
if (signal?.aborted) throw error;
|
|
2370
|
+
this.logger?.warn?.("[Qoder Status] Failed to load user status (degraded)", error instanceof Error ? error.message : error);
|
|
2371
|
+
return;
|
|
2372
|
+
}
|
|
2373
|
+
}
|
|
2374
|
+
};
|
|
2375
|
+
//#endregion
|
|
2376
|
+
//#region src/qoder/transport/wire/encoding.ts
|
|
2377
|
+
/**
|
|
2378
|
+
* Qoder WAF Body encoding algorithm.
|
|
2379
|
+
*
|
|
2380
|
+
* Re-arranges standard Base64 chunks and translates characters against
|
|
2381
|
+
* a custom alphabet to satisfy Qoder upstream WAF requirements.
|
|
2382
|
+
*
|
|
2383
|
+
* @module dsh-provider-qoder/qoder/transport/wire/encoding
|
|
2384
|
+
*/
|
|
2385
|
+
const qoderCustomAlphabet = "_doRTgHZBKcGVjlvpC,@aFSx#DPuNJme&i*MzLOEn)sUrthbf%Y^w.(kIQyXqWA!";
|
|
2386
|
+
const qoderStdAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
2387
|
+
function qoderEncodeBody(plaintext) {
|
|
2388
|
+
const std = Buffer.isBuffer(plaintext) ? plaintext.toString("base64") : Buffer.from(plaintext).toString("base64");
|
|
2389
|
+
const n = std.length;
|
|
2390
|
+
const a = Math.floor(n / 3);
|
|
2391
|
+
const rearranged = std.slice(n - a) + std.slice(a, n - a) + std.slice(0, a);
|
|
2392
|
+
let out = "";
|
|
2393
|
+
for (let i = 0; i < n; i++) {
|
|
2394
|
+
const c = rearranged[i];
|
|
2395
|
+
if (c === void 0) continue;
|
|
2396
|
+
if (c === "=") out += "$";
|
|
2397
|
+
else {
|
|
2398
|
+
const idx = qoderStdAlphabet.indexOf(c);
|
|
2399
|
+
if (idx >= 0) out += qoderCustomAlphabet[idx];
|
|
2400
|
+
else out += c;
|
|
2401
|
+
}
|
|
2402
|
+
}
|
|
2403
|
+
return out;
|
|
2404
|
+
}
|
|
2405
|
+
//#endregion
|
|
2406
|
+
//#region src/qoder/transport/wire/thinking.ts
|
|
2407
|
+
const thinkingTags = [{
|
|
2408
|
+
open: "<thinking>",
|
|
2409
|
+
close: "</thinking>"
|
|
2410
|
+
}, {
|
|
2411
|
+
open: "<think>",
|
|
2412
|
+
close: "</think>"
|
|
2413
|
+
}];
|
|
2414
|
+
const allTags = thinkingTags.flatMap((tag) => [tag.open, tag.close]);
|
|
2415
|
+
function trailingPrefixLength(text, candidates) {
|
|
2416
|
+
let best = 0;
|
|
2417
|
+
for (const candidate of candidates) {
|
|
2418
|
+
const limit = Math.min(text.length, candidate.length - 1);
|
|
2419
|
+
for (let length = limit; length > best; length--) if (text.endsWith(candidate.slice(0, length))) {
|
|
2420
|
+
best = length;
|
|
2421
|
+
break;
|
|
2422
|
+
}
|
|
2423
|
+
}
|
|
2424
|
+
return best;
|
|
2425
|
+
}
|
|
2426
|
+
function earliestTag(text, kind) {
|
|
2427
|
+
let found;
|
|
2428
|
+
for (const tag of thinkingTags) {
|
|
2429
|
+
const position = text.indexOf(tag[kind]);
|
|
2430
|
+
if (position !== -1 && (found === void 0 || position < found.position)) found = {
|
|
2431
|
+
position,
|
|
2432
|
+
tag
|
|
2433
|
+
};
|
|
2434
|
+
}
|
|
2435
|
+
return found;
|
|
2436
|
+
}
|
|
2437
|
+
function trimSeparator(text) {
|
|
2438
|
+
if (text.startsWith("\n\n")) return text.slice(2);
|
|
2439
|
+
if (text.startsWith("\n")) return text.slice(1);
|
|
2440
|
+
return text;
|
|
2441
|
+
}
|
|
2442
|
+
var NativeReasoningStripper = class {
|
|
2443
|
+
buffer = "";
|
|
2444
|
+
push(chunk) {
|
|
2445
|
+
this.buffer += chunk;
|
|
2446
|
+
let output = "";
|
|
2447
|
+
while (this.buffer) {
|
|
2448
|
+
let firstPosition = -1;
|
|
2449
|
+
let firstTag = "";
|
|
2450
|
+
for (const tag of allTags) {
|
|
2451
|
+
const position = this.buffer.indexOf(tag);
|
|
2452
|
+
if (position !== -1 && (firstPosition === -1 || position < firstPosition)) {
|
|
2453
|
+
firstPosition = position;
|
|
2454
|
+
firstTag = tag;
|
|
2455
|
+
}
|
|
2456
|
+
}
|
|
2457
|
+
if (firstPosition !== -1) {
|
|
2458
|
+
output += this.buffer.slice(0, firstPosition);
|
|
2459
|
+
this.buffer = this.buffer.slice(firstPosition + firstTag.length);
|
|
2460
|
+
continue;
|
|
2461
|
+
}
|
|
2462
|
+
const held = trailingPrefixLength(this.buffer, allTags);
|
|
2463
|
+
output += this.buffer.slice(0, this.buffer.length - held);
|
|
2464
|
+
this.buffer = this.buffer.slice(this.buffer.length - held);
|
|
2465
|
+
break;
|
|
2466
|
+
}
|
|
2467
|
+
return output;
|
|
2468
|
+
}
|
|
2469
|
+
finish() {
|
|
2470
|
+
const output = allTags.some((tag) => tag.startsWith(this.buffer)) ? "" : this.buffer;
|
|
2471
|
+
this.buffer = "";
|
|
2472
|
+
return output;
|
|
2473
|
+
}
|
|
2474
|
+
};
|
|
2475
|
+
var QoderThinkingParser = class {
|
|
2476
|
+
buffer = "";
|
|
2477
|
+
mode = "text";
|
|
2478
|
+
closingTag = thinkingTags[0].close;
|
|
2479
|
+
native = new NativeReasoningStripper();
|
|
2480
|
+
pushReasoning(chunk) {
|
|
2481
|
+
const text = this.native.push(chunk);
|
|
2482
|
+
return text ? [{
|
|
2483
|
+
type: "reasoning",
|
|
2484
|
+
text
|
|
2485
|
+
}] : [];
|
|
2486
|
+
}
|
|
2487
|
+
pushContent(chunk) {
|
|
2488
|
+
const output = [];
|
|
2489
|
+
const pendingReasoning = this.native.finish();
|
|
2490
|
+
if (pendingReasoning) output.push({
|
|
2491
|
+
type: "reasoning",
|
|
2492
|
+
text: pendingReasoning
|
|
2493
|
+
});
|
|
2494
|
+
this.buffer += chunk;
|
|
2495
|
+
while (this.buffer) {
|
|
2496
|
+
if (this.mode === "reasoning") {
|
|
2497
|
+
const closeAt = this.buffer.indexOf(this.closingTag);
|
|
2498
|
+
if (closeAt !== -1) {
|
|
2499
|
+
if (closeAt > 0) output.push({
|
|
2500
|
+
type: "reasoning",
|
|
2501
|
+
text: this.buffer.slice(0, closeAt)
|
|
2502
|
+
});
|
|
2503
|
+
this.buffer = trimSeparator(this.buffer.slice(closeAt + this.closingTag.length));
|
|
2504
|
+
this.mode = "text";
|
|
2505
|
+
continue;
|
|
2506
|
+
}
|
|
2507
|
+
const held = trailingPrefixLength(this.buffer, [this.closingTag]);
|
|
2508
|
+
const safeLength = this.buffer.length - held;
|
|
2509
|
+
if (safeLength > 0) output.push({
|
|
2510
|
+
type: "reasoning",
|
|
2511
|
+
text: this.buffer.slice(0, safeLength)
|
|
2512
|
+
});
|
|
2513
|
+
this.buffer = this.buffer.slice(safeLength);
|
|
2514
|
+
break;
|
|
2515
|
+
}
|
|
2516
|
+
const opener = earliestTag(this.buffer, "open");
|
|
2517
|
+
const closer = earliestTag(this.buffer, "close");
|
|
2518
|
+
if (opener !== void 0 && (closer === void 0 || opener.position < closer.position)) {
|
|
2519
|
+
if (opener.position > 0) output.push({
|
|
2520
|
+
type: "text",
|
|
2521
|
+
text: this.buffer.slice(0, opener.position)
|
|
2522
|
+
});
|
|
2523
|
+
this.buffer = this.buffer.slice(opener.position + opener.tag.open.length);
|
|
2524
|
+
this.closingTag = opener.tag.close;
|
|
2525
|
+
this.mode = "reasoning";
|
|
2526
|
+
continue;
|
|
2527
|
+
}
|
|
2528
|
+
if (closer !== void 0) {
|
|
2529
|
+
if (closer.position > 0) output.push({
|
|
2530
|
+
type: "text",
|
|
2531
|
+
text: this.buffer.slice(0, closer.position)
|
|
2532
|
+
});
|
|
2533
|
+
this.buffer = trimSeparator(this.buffer.slice(closer.position + closer.tag.close.length));
|
|
2534
|
+
continue;
|
|
2535
|
+
}
|
|
2536
|
+
const held = trailingPrefixLength(this.buffer, allTags);
|
|
2537
|
+
const safeLength = this.buffer.length - held;
|
|
2538
|
+
if (safeLength > 0) output.push({
|
|
2539
|
+
type: "text",
|
|
2540
|
+
text: this.buffer.slice(0, safeLength)
|
|
2541
|
+
});
|
|
2542
|
+
this.buffer = this.buffer.slice(safeLength);
|
|
2543
|
+
break;
|
|
2544
|
+
}
|
|
2545
|
+
return output;
|
|
2546
|
+
}
|
|
2547
|
+
finish() {
|
|
2548
|
+
const output = [];
|
|
2549
|
+
const pendingReasoning = this.native.finish();
|
|
2550
|
+
if (pendingReasoning) output.push({
|
|
2551
|
+
type: "reasoning",
|
|
2552
|
+
text: pendingReasoning
|
|
2553
|
+
});
|
|
2554
|
+
if (this.buffer) output.push({
|
|
2555
|
+
type: this.mode,
|
|
2556
|
+
text: this.buffer
|
|
2557
|
+
});
|
|
2558
|
+
this.buffer = "";
|
|
2559
|
+
return output;
|
|
2560
|
+
}
|
|
2561
|
+
};
|
|
2562
|
+
//#endregion
|
|
2563
|
+
//#region src/qoder/transport/wire/sse.ts
|
|
2564
|
+
/** Decode Qoder's outer SSE envelope and inner model chunks. */
|
|
2565
|
+
const doneMarker = "[DONE]";
|
|
2566
|
+
function malformed(message) {
|
|
2567
|
+
return new QoderLlmError(message, "MALFORMED_RESPONSE");
|
|
2568
|
+
}
|
|
2569
|
+
function parseEnvelope(rawData) {
|
|
2570
|
+
let value;
|
|
2571
|
+
try {
|
|
2572
|
+
value = JSON.parse(rawData);
|
|
2573
|
+
} catch {
|
|
2574
|
+
throw malformed("Malformed outer SSE JSON payload received from Qoder.");
|
|
2575
|
+
}
|
|
2576
|
+
if (value === null || typeof value !== "object") throw malformed("Malformed outer SSE envelope received from Qoder.");
|
|
2577
|
+
const envelope = value;
|
|
2578
|
+
if (envelope.statusCodeValue !== void 0 && typeof envelope.statusCodeValue !== "number") throw malformed("Qoder SSE envelope has an invalid status code.");
|
|
2579
|
+
if (envelope.statusCodeValue !== void 0 && (!Number.isInteger(envelope.statusCodeValue) || envelope.statusCodeValue < 100 || envelope.statusCodeValue > 599)) throw malformed("Qoder SSE envelope has an invalid status code.");
|
|
2580
|
+
if (envelope.statusCodeValue !== void 0 && envelope.statusCodeValue !== 200) throw qoderHttpError(`Qoder service returned upstream error status ${envelope.statusCodeValue}.`, { status: envelope.statusCodeValue });
|
|
2581
|
+
if (envelope.body !== void 0 && typeof envelope.body !== "string") throw malformed("Qoder SSE envelope has an invalid model body.");
|
|
2582
|
+
return envelope;
|
|
2583
|
+
}
|
|
2584
|
+
function parseInner(body) {
|
|
2585
|
+
try {
|
|
2586
|
+
const value = JSON.parse(body);
|
|
2587
|
+
if (value === null || typeof value !== "object") throw new Error("not an object");
|
|
2588
|
+
return value;
|
|
2589
|
+
} catch {
|
|
2590
|
+
throw malformed("Malformed inner model payload received from Qoder.");
|
|
2591
|
+
}
|
|
2592
|
+
}
|
|
2593
|
+
function tokenUsage(innerChunk) {
|
|
2594
|
+
if (!innerChunk.usage) return void 0;
|
|
2595
|
+
const usage = innerChunk.usage;
|
|
2596
|
+
const promptTokens = usage.prompt_tokens ?? 0;
|
|
2597
|
+
const cacheReadTokens = usage.prompt_tokens_details?.cached_tokens ?? 0;
|
|
2598
|
+
const cacheWriteTokens = usage.prompt_tokens_details?.cache_write_tokens ?? 0;
|
|
2599
|
+
const reasoningTokens = usage.completion_tokens_details?.reasoning_tokens;
|
|
2600
|
+
return {
|
|
2601
|
+
inputTokens: Math.max(0, promptTokens - cacheReadTokens - cacheWriteTokens),
|
|
2602
|
+
outputTokens: usage.completion_tokens ?? 0,
|
|
2603
|
+
...cacheReadTokens > 0 ? { cacheReadTokens } : {},
|
|
2604
|
+
...cacheWriteTokens > 0 ? { cacheWriteTokens } : {},
|
|
2605
|
+
...reasoningTokens === void 0 ? {} : { reasoningTokens }
|
|
2606
|
+
};
|
|
2607
|
+
}
|
|
2608
|
+
function finishKind(value) {
|
|
2609
|
+
if (value === void 0 || value === null || value === "") return void 0;
|
|
2610
|
+
if (value === "stop") return "stop";
|
|
2611
|
+
if (value === "length") return "max-tokens";
|
|
2612
|
+
if (value === "tool_calls" || value === "toolUse") return "tool-calls";
|
|
2613
|
+
if (value === "content_filter") throw new QoderLlmError("Qoder blocked the response through its content filter.", "PROVIDER_ERROR");
|
|
2614
|
+
throw malformed(`Qoder returned unknown finish reason "${value}".`);
|
|
2615
|
+
}
|
|
2616
|
+
async function* parseQoderSse(stream, options = {}) {
|
|
2617
|
+
const reader = stream.getReader();
|
|
2618
|
+
const decoder = new TextDecoder();
|
|
2619
|
+
const thinkingParser = new QoderThinkingParser();
|
|
2620
|
+
const toolCalls = /* @__PURE__ */ new Map();
|
|
2621
|
+
let buffer = "";
|
|
2622
|
+
let sourceEnded = false;
|
|
2623
|
+
let sawDone = false;
|
|
2624
|
+
let nextBlockIndex = 0;
|
|
2625
|
+
let activeTextual;
|
|
2626
|
+
let pendingUsage;
|
|
2627
|
+
let terminalKind = "stop";
|
|
2628
|
+
let sawContent = false;
|
|
2629
|
+
const maxBufferChars = options.maxBufferChars ?? 2097152;
|
|
2630
|
+
const closeTextual = () => {
|
|
2631
|
+
if (activeTextual === void 0) return [];
|
|
2632
|
+
const state = activeTextual;
|
|
2633
|
+
activeTextual = void 0;
|
|
2634
|
+
return [{
|
|
2635
|
+
type: "block-end",
|
|
2636
|
+
index: state.index,
|
|
2637
|
+
block: state.type === "text" ? {
|
|
2638
|
+
type: "text",
|
|
2639
|
+
text: state.text
|
|
2640
|
+
} : {
|
|
2641
|
+
type: "reasoning",
|
|
2642
|
+
text: state.text
|
|
2643
|
+
}
|
|
2644
|
+
}];
|
|
2645
|
+
};
|
|
2646
|
+
const appendSegment = (segment) => {
|
|
2647
|
+
if (!segment.text) return [];
|
|
2648
|
+
const chunks = [];
|
|
2649
|
+
sawContent = true;
|
|
2650
|
+
if (activeTextual?.type !== segment.type) {
|
|
2651
|
+
chunks.push(...closeTextual());
|
|
2652
|
+
activeTextual = {
|
|
2653
|
+
type: segment.type,
|
|
2654
|
+
index: nextBlockIndex++,
|
|
2655
|
+
text: ""
|
|
2656
|
+
};
|
|
2657
|
+
chunks.push({
|
|
2658
|
+
type: "block-start",
|
|
2659
|
+
index: activeTextual.index,
|
|
2660
|
+
blockType: segment.type
|
|
2661
|
+
});
|
|
2662
|
+
}
|
|
2663
|
+
activeTextual.text += segment.text;
|
|
2664
|
+
chunks.push(segment.type === "text" ? {
|
|
2665
|
+
type: "text-delta",
|
|
2666
|
+
index: activeTextual.index,
|
|
2667
|
+
text: segment.text
|
|
2668
|
+
} : {
|
|
2669
|
+
type: "reasoning-delta",
|
|
2670
|
+
index: activeTextual.index,
|
|
2671
|
+
text: segment.text
|
|
2672
|
+
});
|
|
2673
|
+
return chunks;
|
|
2674
|
+
};
|
|
2675
|
+
const openToolCall = (state) => {
|
|
2676
|
+
if (!state.id || state.blockIndex !== void 0) return [];
|
|
2677
|
+
const chunks = closeTextual();
|
|
2678
|
+
state.blockIndex = nextBlockIndex++;
|
|
2679
|
+
sawContent = true;
|
|
2680
|
+
chunks.push({
|
|
2681
|
+
type: "block-start",
|
|
2682
|
+
index: state.blockIndex,
|
|
2683
|
+
blockType: "tool-call"
|
|
2684
|
+
});
|
|
2685
|
+
const argumentsDelta = state.arguments.slice(state.emittedArguments);
|
|
2686
|
+
state.emittedArguments = state.arguments.length;
|
|
2687
|
+
chunks.push({
|
|
2688
|
+
type: "tool-call-delta",
|
|
2689
|
+
index: state.blockIndex,
|
|
2690
|
+
id: ToolCallId(state.id),
|
|
2691
|
+
...state.name ? { name: state.name } : {},
|
|
2692
|
+
argumentsDelta
|
|
2693
|
+
});
|
|
2694
|
+
return chunks;
|
|
2695
|
+
};
|
|
2696
|
+
try {
|
|
2697
|
+
while (!sourceEnded && !sawDone) {
|
|
2698
|
+
const { done, value } = await reader.read();
|
|
2699
|
+
if (done) {
|
|
2700
|
+
sourceEnded = true;
|
|
2701
|
+
buffer += decoder.decode();
|
|
2702
|
+
if (buffer.length > 0 && !buffer.endsWith("\n")) buffer += "\n";
|
|
2703
|
+
} else {
|
|
2704
|
+
options.onActivity?.();
|
|
2705
|
+
buffer += decoder.decode(value, { stream: true });
|
|
2706
|
+
if (buffer.length > maxBufferChars) throw malformed("Qoder SSE frame exceeded its size limit.");
|
|
2707
|
+
}
|
|
2708
|
+
while (!sawDone) {
|
|
2709
|
+
const lineEnd = buffer.indexOf("\n");
|
|
2710
|
+
if (lineEnd === -1) break;
|
|
2711
|
+
let line = buffer.substring(0, lineEnd);
|
|
2712
|
+
buffer = buffer.substring(lineEnd + 1);
|
|
2713
|
+
if (line.endsWith("\r")) line = line.slice(0, -1);
|
|
2714
|
+
line = line.trim();
|
|
2715
|
+
if (!line.startsWith("data:")) continue;
|
|
2716
|
+
const rawData = line.substring(5).trim();
|
|
2717
|
+
if (!rawData) continue;
|
|
2718
|
+
if (rawData === doneMarker) {
|
|
2719
|
+
sawDone = true;
|
|
2720
|
+
break;
|
|
2721
|
+
}
|
|
2722
|
+
const body = parseEnvelope(rawData).body?.trim();
|
|
2723
|
+
if (!body) continue;
|
|
2724
|
+
if (body === doneMarker) {
|
|
2725
|
+
sawDone = true;
|
|
2726
|
+
break;
|
|
2727
|
+
}
|
|
2728
|
+
const innerChunk = parseInner(body);
|
|
2729
|
+
pendingUsage = tokenUsage(innerChunk) ?? pendingUsage;
|
|
2730
|
+
for (const choice of innerChunk.choices ?? []) {
|
|
2731
|
+
terminalKind = finishKind(choice.finish_reason) ?? terminalKind;
|
|
2732
|
+
const delta = choice.delta;
|
|
2733
|
+
if (!delta) continue;
|
|
2734
|
+
if (typeof delta.reasoning_content === "string" && delta.reasoning_content) for (const segment of thinkingParser.pushReasoning(delta.reasoning_content)) for (const chunk of appendSegment(segment)) yield chunk;
|
|
2735
|
+
if (typeof delta.content === "string" && delta.content) for (const segment of thinkingParser.pushContent(delta.content)) for (const chunk of appendSegment(segment)) yield chunk;
|
|
2736
|
+
if (delta.tool_calls !== void 0) {
|
|
2737
|
+
if (!Array.isArray(delta.tool_calls)) throw malformed("Qoder tool-call delta is not an array.");
|
|
2738
|
+
for (const rawCall of delta.tool_calls) {
|
|
2739
|
+
if (typeof rawCall !== "object" || rawCall === null || Array.isArray(rawCall)) throw malformed("Qoder tool-call delta is not an object.");
|
|
2740
|
+
const upstreamIndex = rawCall.index ?? 0;
|
|
2741
|
+
if (!Number.isInteger(upstreamIndex) || upstreamIndex < 0) throw malformed("Qoder tool-call delta has an invalid index.");
|
|
2742
|
+
let state = toolCalls.get(upstreamIndex);
|
|
2743
|
+
if (state === void 0) {
|
|
2744
|
+
state = {
|
|
2745
|
+
id: "",
|
|
2746
|
+
name: "",
|
|
2747
|
+
arguments: "",
|
|
2748
|
+
emittedArguments: 0
|
|
2749
|
+
};
|
|
2750
|
+
toolCalls.set(upstreamIndex, state);
|
|
2751
|
+
}
|
|
2752
|
+
if (rawCall.id !== void 0) {
|
|
2753
|
+
if (rawCall.id === "" || rawCall.id === null) {} else if (typeof rawCall.id !== "string") throw malformed("Qoder tool call has an invalid id.");
|
|
2754
|
+
else {
|
|
2755
|
+
if (state.id && state.id !== rawCall.id) throw malformed("Qoder changed a streamed tool-call id.");
|
|
2756
|
+
state.id = rawCall.id;
|
|
2757
|
+
}
|
|
2758
|
+
}
|
|
2759
|
+
let hasNewName = false;
|
|
2760
|
+
if (rawCall.function?.name !== void 0) {
|
|
2761
|
+
const name = rawCall.function.name;
|
|
2762
|
+
if (name === "" || name === null) {} else if (typeof name !== "string") throw malformed("Qoder tool call has an invalid name.");
|
|
2763
|
+
else {
|
|
2764
|
+
if (state.name && state.name !== name) throw malformed("Qoder changed a streamed tool-call name.");
|
|
2765
|
+
if (state.name !== name) {
|
|
2766
|
+
state.name = name;
|
|
2767
|
+
hasNewName = true;
|
|
2768
|
+
}
|
|
2769
|
+
}
|
|
2770
|
+
}
|
|
2771
|
+
if (rawCall.function?.arguments !== void 0) {
|
|
2772
|
+
if (typeof rawCall.function.arguments !== "string") throw malformed("Qoder tool-call arguments delta is not a string.");
|
|
2773
|
+
state.arguments += rawCall.function.arguments;
|
|
2774
|
+
}
|
|
2775
|
+
const wasOpen = state.blockIndex !== void 0;
|
|
2776
|
+
for (const chunk of openToolCall(state)) yield chunk;
|
|
2777
|
+
if (wasOpen && state.blockIndex !== void 0 && state.emittedArguments < state.arguments.length) {
|
|
2778
|
+
const argumentsDelta = state.arguments.slice(state.emittedArguments);
|
|
2779
|
+
state.emittedArguments = state.arguments.length;
|
|
2780
|
+
yield {
|
|
2781
|
+
type: "tool-call-delta",
|
|
2782
|
+
index: state.blockIndex,
|
|
2783
|
+
id: ToolCallId(state.id),
|
|
2784
|
+
...state.name ? { name: state.name } : {},
|
|
2785
|
+
argumentsDelta
|
|
2786
|
+
};
|
|
2787
|
+
} else if (wasOpen && state.blockIndex !== void 0 && hasNewName) yield {
|
|
2788
|
+
type: "tool-call-delta",
|
|
2789
|
+
index: state.blockIndex,
|
|
2790
|
+
id: ToolCallId(state.id),
|
|
2791
|
+
name: state.name,
|
|
2792
|
+
argumentsDelta: ""
|
|
2793
|
+
};
|
|
2794
|
+
}
|
|
2795
|
+
}
|
|
2796
|
+
}
|
|
2797
|
+
}
|
|
2798
|
+
}
|
|
2799
|
+
} finally {
|
|
2800
|
+
await reader.cancel().catch(() => void 0);
|
|
2801
|
+
reader.releaseLock();
|
|
2802
|
+
}
|
|
2803
|
+
if (!sawDone) throw new QoderLlmError("SSE stream ended prematurely without [DONE].", "TRANSPORT");
|
|
2804
|
+
for (const segment of thinkingParser.finish()) for (const chunk of appendSegment(segment)) yield chunk;
|
|
2805
|
+
for (const chunk of closeTextual()) yield chunk;
|
|
2806
|
+
for (const [, state] of [...toolCalls.entries()].sort(([left], [right]) => left - right)) {
|
|
2807
|
+
if (!state.id || !state.name) throw malformed("Qoder completed an unidentifiable tool call.");
|
|
2808
|
+
const normalizedArguments = state.arguments.trim() ? state.arguments : "{}";
|
|
2809
|
+
try {
|
|
2810
|
+
JSON.parse(normalizedArguments);
|
|
2811
|
+
} catch {
|
|
2812
|
+
throw malformed(`Qoder completed tool call "${state.name}" with malformed JSON arguments.`);
|
|
2813
|
+
}
|
|
2814
|
+
for (const chunk of openToolCall(state)) yield chunk;
|
|
2815
|
+
if (state.blockIndex === void 0) throw malformed("Qoder failed to open a completed tool call.");
|
|
2816
|
+
if (state.emittedArguments === 0 && normalizedArguments === "{}") yield {
|
|
2817
|
+
type: "tool-call-delta",
|
|
2818
|
+
index: state.blockIndex,
|
|
2819
|
+
id: ToolCallId(state.id),
|
|
2820
|
+
name: state.name,
|
|
2821
|
+
argumentsDelta: "{}"
|
|
2822
|
+
};
|
|
2823
|
+
yield {
|
|
2824
|
+
type: "block-end",
|
|
2825
|
+
index: state.blockIndex,
|
|
2826
|
+
block: {
|
|
2827
|
+
type: "tool-call",
|
|
2828
|
+
id: ToolCallId(state.id),
|
|
2829
|
+
name: state.name,
|
|
2830
|
+
arguments: normalizedArguments
|
|
2831
|
+
}
|
|
2832
|
+
};
|
|
2833
|
+
}
|
|
2834
|
+
if (pendingUsage) yield {
|
|
2835
|
+
type: "usage",
|
|
2836
|
+
usage: pendingUsage
|
|
2837
|
+
};
|
|
2838
|
+
if (!sawContent) {
|
|
2839
|
+
yield {
|
|
2840
|
+
type: "finish",
|
|
2841
|
+
reason: {
|
|
2842
|
+
kind: "error",
|
|
2843
|
+
failure: {
|
|
2844
|
+
message: "Model returned a completed response with no content.",
|
|
2845
|
+
code: EMPTY_RESPONSE_CODE
|
|
2846
|
+
}
|
|
2847
|
+
}
|
|
2848
|
+
};
|
|
2849
|
+
return;
|
|
2850
|
+
}
|
|
2851
|
+
yield {
|
|
2852
|
+
type: "finish",
|
|
2853
|
+
reason: { kind: toolCalls.size > 0 ? "tool-calls" : terminalKind }
|
|
2854
|
+
};
|
|
2855
|
+
}
|
|
2856
|
+
//#endregion
|
|
2857
|
+
//#region src/qoder/transport/chat.ts
|
|
2858
|
+
/** Qoder chat request encoding, timeout lifecycle, and SSE streaming. */
|
|
2859
|
+
function aborted$1(message) {
|
|
2860
|
+
return new QoderLlmError(message, "ABORTED");
|
|
2861
|
+
}
|
|
2862
|
+
async function* streamQoderChat(options, model, credentials, messages, dependencies) {
|
|
2863
|
+
const request = await buildQoderRequestBody(options, credentials.userID, messages, model);
|
|
2864
|
+
const encodedBody = qoderEncodeBody(JSON.stringify(request));
|
|
2865
|
+
const encodedBytes = Buffer.from(encodedBody, "utf8");
|
|
2866
|
+
const chatUrl = getQoderChatUrl(dependencies.region);
|
|
2867
|
+
const requestController = new AbortController();
|
|
2868
|
+
let headerTimedOut = false;
|
|
2869
|
+
let idleTimedOut = false;
|
|
2870
|
+
let idleTimer;
|
|
2871
|
+
let chunkCount = 0;
|
|
2872
|
+
let reqId;
|
|
2873
|
+
const startedAt = performance.now();
|
|
2874
|
+
let lastActivityAt = startedAt;
|
|
2875
|
+
const onCallerAbort = () => requestController.abort(options.signal?.reason);
|
|
2876
|
+
options.signal?.addEventListener("abort", onCallerAbort, { once: true });
|
|
2877
|
+
const headerTimer = setTimeout(() => {
|
|
2878
|
+
headerTimedOut = true;
|
|
2879
|
+
requestController.abort("response header timeout");
|
|
2880
|
+
}, dependencies.responseHeaderTimeoutMs);
|
|
2881
|
+
const resetIdleTimer = () => {
|
|
2882
|
+
lastActivityAt = performance.now();
|
|
2883
|
+
if (idleTimer !== void 0) clearTimeout(idleTimer);
|
|
2884
|
+
idleTimer = setTimeout(() => {
|
|
2885
|
+
idleTimedOut = true;
|
|
2886
|
+
requestController.abort("stream idle timeout");
|
|
2887
|
+
}, dependencies.streamIdleTimeoutMs);
|
|
2888
|
+
};
|
|
2889
|
+
try {
|
|
2890
|
+
const response = await dependencies.fetch(chatUrl, {
|
|
2891
|
+
method: "POST",
|
|
2892
|
+
headers: {
|
|
2893
|
+
"content-type": "application/json",
|
|
2894
|
+
"accept": "text/event-stream",
|
|
2895
|
+
"cache-control": "no-cache",
|
|
2896
|
+
"accept-encoding": "identity",
|
|
2897
|
+
"x-model-key": options.model || "cmodel",
|
|
2898
|
+
"x-model-source": model?.source || "system",
|
|
2899
|
+
...attributionHeaders(),
|
|
2900
|
+
...buildAuthHeaders(encodedBytes, chatUrl, credentials)
|
|
2901
|
+
},
|
|
2902
|
+
body: encodedBytes,
|
|
2903
|
+
signal: requestController.signal
|
|
2904
|
+
});
|
|
2905
|
+
clearTimeout(headerTimer);
|
|
2906
|
+
reqId = qoderRequestId(response.headers);
|
|
2907
|
+
dependencies.logger?.debug?.("[Qoder Stream] Response headers received", {
|
|
2908
|
+
region: dependencies.region,
|
|
2909
|
+
status: response.status,
|
|
2910
|
+
durationMs: Math.round(performance.now() - startedAt),
|
|
2911
|
+
...reqId === void 0 ? {} : { requestId: reqId }
|
|
2912
|
+
});
|
|
2913
|
+
resetIdleTimer();
|
|
2914
|
+
if (!response.ok) throw qoderHttpError(`Qoder upstream service returned HTTP ${response.status}.`, response);
|
|
2915
|
+
if (!response.body) throw new QoderLlmError("Qoder response contains no readable body stream.", "EMPTY_RESPONSE");
|
|
2916
|
+
let firstChunkDurationMs;
|
|
2917
|
+
let tokenUsage;
|
|
2918
|
+
let finishReason;
|
|
2919
|
+
const streamStartedAt = performance.now();
|
|
2920
|
+
for await (const chunk of parseQoderSse(response.body, { onActivity: resetIdleTimer })) {
|
|
2921
|
+
chunkCount++;
|
|
2922
|
+
if (firstChunkDurationMs === void 0) {
|
|
2923
|
+
firstChunkDurationMs = Math.round(performance.now() - startedAt);
|
|
2924
|
+
dependencies.logger?.debug?.("[Qoder Stream] First chunk received", {
|
|
2925
|
+
durationMs: firstChunkDurationMs,
|
|
2926
|
+
...reqId === void 0 ? {} : { requestId: reqId }
|
|
2927
|
+
});
|
|
2928
|
+
}
|
|
2929
|
+
if (chunk.type === "usage") tokenUsage = chunk.usage;
|
|
2930
|
+
if (chunk.type === "finish") finishReason = chunk.reason.kind;
|
|
2931
|
+
yield chunk;
|
|
2932
|
+
}
|
|
2933
|
+
dependencies.logger?.debug?.("[Qoder Stream] Stream completed", {
|
|
2934
|
+
durationMs: Math.round(performance.now() - startedAt),
|
|
2935
|
+
streamDurationMs: Math.round(performance.now() - streamStartedAt),
|
|
2936
|
+
chunkCount,
|
|
2937
|
+
...finishReason === void 0 ? {} : { finishReason },
|
|
2938
|
+
...tokenUsage === void 0 ? {} : { usage: tokenUsage },
|
|
2939
|
+
...reqId === void 0 ? {} : { requestId: reqId }
|
|
2940
|
+
});
|
|
2941
|
+
} catch (error) {
|
|
2942
|
+
if (options.signal?.aborted) throw aborted$1("Request was aborted.");
|
|
2943
|
+
const elapsedMs = Math.round(performance.now() - startedAt);
|
|
2944
|
+
if (headerTimedOut) {
|
|
2945
|
+
const failure = new QoderLlmError("Qoder model request exceeded its response header timeout.", "TIMEOUT");
|
|
2946
|
+
dependencies.logger?.error?.("[Qoder Stream] Request failed", redactLogValue(failure), {
|
|
2947
|
+
phase: "header",
|
|
2948
|
+
elapsedMs,
|
|
2949
|
+
timeoutMs: dependencies.responseHeaderTimeoutMs
|
|
2950
|
+
});
|
|
2951
|
+
throw failure;
|
|
2952
|
+
}
|
|
2953
|
+
if (idleTimedOut) {
|
|
2954
|
+
const failure = new QoderLlmError("Qoder model stream exceeded its idle timeout.", "TIMEOUT");
|
|
2955
|
+
dependencies.logger?.error?.("[Qoder Stream] Request failed", redactLogValue(failure), {
|
|
2956
|
+
phase: "stream-idle",
|
|
2957
|
+
chunkCount,
|
|
2958
|
+
idleDurationMs: Math.round(performance.now() - lastActivityAt),
|
|
2959
|
+
elapsedMs,
|
|
2960
|
+
...reqId === void 0 ? {} : { requestId: reqId }
|
|
2961
|
+
});
|
|
2962
|
+
throw failure;
|
|
2963
|
+
}
|
|
2964
|
+
if (error instanceof QoderLlmError) {
|
|
2965
|
+
dependencies.logger?.error?.("[Qoder Stream] Request failed", redactLogValue(error), {
|
|
2966
|
+
chunkCount,
|
|
2967
|
+
elapsedMs,
|
|
2968
|
+
...reqId === void 0 ? {} : { requestId: reqId }
|
|
2969
|
+
});
|
|
2970
|
+
throw error;
|
|
2971
|
+
}
|
|
2972
|
+
const failure = new QoderLlmError("Qoder transport request failed.", "TRANSPORT", { cause: error });
|
|
2973
|
+
dependencies.logger?.error?.("[Qoder Stream] Request failed", redactLogValue(failure), {
|
|
2974
|
+
chunkCount,
|
|
2975
|
+
elapsedMs,
|
|
2976
|
+
...reqId === void 0 ? {} : { requestId: reqId },
|
|
2977
|
+
cause: redactLogValue(error)
|
|
2978
|
+
});
|
|
2979
|
+
throw failure;
|
|
2980
|
+
} finally {
|
|
2981
|
+
clearTimeout(headerTimer);
|
|
2982
|
+
if (idleTimer !== void 0) clearTimeout(idleTimer);
|
|
2983
|
+
options.signal?.removeEventListener("abort", onCallerAbort);
|
|
2984
|
+
requestController.abort("request complete");
|
|
2985
|
+
}
|
|
2986
|
+
}
|
|
2987
|
+
function aborted(message) {
|
|
2988
|
+
return new QoderLlmError(message, "ABORTED");
|
|
2989
|
+
}
|
|
2990
|
+
var DefaultQoderTransport = class {
|
|
2991
|
+
region;
|
|
2992
|
+
resolvePat;
|
|
2993
|
+
fetchImpl;
|
|
2994
|
+
logger;
|
|
2995
|
+
streamIdleTimeoutMs;
|
|
2996
|
+
responseHeaderTimeoutMs;
|
|
2997
|
+
metadataTimeoutMs;
|
|
2998
|
+
auth;
|
|
2999
|
+
usage;
|
|
3000
|
+
modelFlights = new SingleFlight();
|
|
3001
|
+
attachments;
|
|
3002
|
+
imageUploader;
|
|
3003
|
+
preserveThinking;
|
|
3004
|
+
constructor(options) {
|
|
3005
|
+
this.region = options.region;
|
|
3006
|
+
this.resolvePat = options.resolvePat;
|
|
3007
|
+
this.fetchImpl = options.fetch ?? globalThis.fetch;
|
|
3008
|
+
this.logger = options.logger;
|
|
3009
|
+
this.streamIdleTimeoutMs = options.streamIdleTimeoutMs ?? 3e5;
|
|
3010
|
+
this.responseHeaderTimeoutMs = options.responseHeaderTimeoutMs ?? 6e4;
|
|
3011
|
+
this.metadataTimeoutMs = options.metadataTimeoutMs;
|
|
3012
|
+
this.attachments = options.attachments;
|
|
3013
|
+
this.preserveThinking = options.preserveThinking;
|
|
3014
|
+
this.auth = new QoderAuthService({
|
|
3015
|
+
fetch: this.fetchImpl,
|
|
3016
|
+
logger: this.logger,
|
|
3017
|
+
region: this.region,
|
|
3018
|
+
resolveMachineId: options.resolveMachineId
|
|
3019
|
+
});
|
|
3020
|
+
this.imageUploader = new QoderImageUploader({
|
|
3021
|
+
fetch: this.fetchImpl,
|
|
3022
|
+
logger: this.logger,
|
|
3023
|
+
region: this.region,
|
|
3024
|
+
...options.imageUploadTimeoutMs === void 0 ? {} : { timeoutMs: options.imageUploadTimeoutMs },
|
|
3025
|
+
...options.imageUrlCacheTtlMs === void 0 ? {} : { cacheTtlMs: options.imageUrlCacheTtlMs },
|
|
3026
|
+
refreshCredentials: async (signal) => {
|
|
3027
|
+
const pat = await this.requirePat(signal);
|
|
3028
|
+
this.auth.clear(pat);
|
|
3029
|
+
return this.auth.getCredentials(pat, signal);
|
|
3030
|
+
}
|
|
3031
|
+
});
|
|
3032
|
+
this.usage = new QoderUsageReader({
|
|
3033
|
+
authService: this.auth,
|
|
3034
|
+
fetch: this.fetchImpl,
|
|
3035
|
+
logger: this.logger,
|
|
3036
|
+
region: this.region,
|
|
3037
|
+
timeoutMs: this.metadataTimeoutMs
|
|
3038
|
+
});
|
|
3039
|
+
}
|
|
3040
|
+
stream(options, model) {
|
|
3041
|
+
return this.generate(options, model);
|
|
3042
|
+
}
|
|
3043
|
+
async discoverModels(signal) {
|
|
3044
|
+
const pat = await this.requirePat(signal);
|
|
3045
|
+
const key = opaqueCredentialKey(pat);
|
|
3046
|
+
return this.modelFlights.run(key, signal, async (sharedSignal) => {
|
|
3047
|
+
const credentials = await this.auth.getCredentials(pat, sharedSignal);
|
|
3048
|
+
return retryMetadataRead(sharedSignal, () => fetchQoderModels(credentials, {
|
|
3049
|
+
fetch: this.fetchImpl,
|
|
3050
|
+
signal: sharedSignal,
|
|
3051
|
+
logger: this.logger,
|
|
3052
|
+
region: this.region,
|
|
3053
|
+
timeoutMs: this.metadataTimeoutMs
|
|
3054
|
+
}));
|
|
3055
|
+
}, () => aborted("Qoder model discovery was aborted."));
|
|
3056
|
+
}
|
|
3057
|
+
async readAccount(options) {
|
|
3058
|
+
const pat = await this.requirePat(options?.signal);
|
|
3059
|
+
return this.usage.readAccount(pat, {
|
|
3060
|
+
force: options?.force,
|
|
3061
|
+
signal: options?.signal
|
|
3062
|
+
});
|
|
3063
|
+
}
|
|
3064
|
+
async requirePat(signal) {
|
|
3065
|
+
if (signal?.aborted) throw aborted("Qoder request was aborted.");
|
|
3066
|
+
const pat = (await this.resolvePat()).trim();
|
|
3067
|
+
if (!pat) throw new QoderLlmError("Qoder Personal Access Token is missing. Configure Qoder in the Qoder settings page.", "MISSING_CREDENTIAL");
|
|
3068
|
+
if (signal?.aborted) throw aborted("Qoder request was aborted.");
|
|
3069
|
+
return pat;
|
|
3070
|
+
}
|
|
3071
|
+
async *generate(options, model) {
|
|
3072
|
+
if (options.signal?.aborted) throw aborted("Request was aborted prior to generation.");
|
|
3073
|
+
validateQoderRequestShape(options, model);
|
|
3074
|
+
const pat = await this.requirePat(options.signal);
|
|
3075
|
+
const credentials = await this.auth.getCredentials(pat, options.signal);
|
|
3076
|
+
const messages = await translateQoderMessages(options, this.attachments, {
|
|
3077
|
+
uploader: this.imageUploader,
|
|
3078
|
+
credentials,
|
|
3079
|
+
preserveThinking: this.preserveThinking
|
|
3080
|
+
});
|
|
3081
|
+
yield* streamQoderChat(options, model, await this.auth.getCredentials(pat, options.signal), messages, {
|
|
3082
|
+
fetch: this.fetchImpl,
|
|
3083
|
+
logger: this.logger,
|
|
3084
|
+
region: this.region,
|
|
3085
|
+
responseHeaderTimeoutMs: this.responseHeaderTimeoutMs,
|
|
3086
|
+
streamIdleTimeoutMs: this.streamIdleTimeoutMs
|
|
3087
|
+
});
|
|
3088
|
+
}
|
|
3089
|
+
};
|
|
3090
|
+
//#endregion
|
|
3091
|
+
//#region src/qoder/transport/index.ts
|
|
3092
|
+
function createQoderTransport(options) {
|
|
3093
|
+
return new DefaultQoderTransport(options);
|
|
3094
|
+
}
|
|
3095
|
+
//#endregion
|
|
3096
|
+
//#region src/upstream.ts
|
|
3097
|
+
/**
|
|
3098
|
+
* Qoder upstream client — the loopback shim's OpenAI-shaped surface, carried
|
|
3099
|
+
* over to the Qoder transport.
|
|
3100
|
+
*
|
|
3101
|
+
* The plugin's browser card, the CLI, and DSH's pi-ai adapter all reach Qoder
|
|
3102
|
+
* through this one module. The wire reality changed underneath the WorkBuddy
|
|
3103
|
+
* edition it replaces: there is no HTTP endpoint that accepts an OpenAI JSON
|
|
3104
|
+
* body any more. Instead this module
|
|
3105
|
+
*
|
|
3106
|
+
* - **translates** the narrow OpenAI chat-completions request the shim forwards
|
|
3107
|
+
* (model, messages, tools, `max_tokens`, `temperature`, `stop`,
|
|
3108
|
+
* `reasoning_effort`) into dsh-llm's `GenerateOptions`, and
|
|
3109
|
+
* - **re-serializes** the transport's `StreamChunk` vocabulary back into
|
|
3110
|
+
* `chat.completion.chunk` SSE frames, so the adapter side needs nothing
|
|
3111
|
+
* Qoder-specific, and
|
|
3112
|
+
* - maps catalog discovery (`transport.discoverModels`) into the plugin's
|
|
3113
|
+
* model rows, and the account read (`transport.readAccount`) into the
|
|
3114
|
+
* card's credit shape.
|
|
3115
|
+
*
|
|
3116
|
+
* Failure classification follows the transport's error taxonomy
|
|
3117
|
+
* (`QoderLlmError.failure.code` plus the HTTP status it carried); the
|
|
3118
|
+
* WorkBuddy-era session/credit marker lists are gone with the upstream they
|
|
3119
|
+
* described.
|
|
3120
|
+
*
|
|
3121
|
+
* @module dsh-qoder-connect/upstream
|
|
3122
|
+
*/
|
|
3123
|
+
/** HTTP status the shim answers each failure kind with. */
|
|
3124
|
+
const KIND_STATUS = {
|
|
3125
|
+
missing_credential: 401,
|
|
3126
|
+
auth: 401,
|
|
3127
|
+
soft_rate: 429,
|
|
3128
|
+
quota_exceeded: 402,
|
|
3129
|
+
server: 502,
|
|
3130
|
+
client: 400
|
|
3131
|
+
};
|
|
3132
|
+
/**
|
|
3133
|
+
* Normalize a billing rate label for display.
|
|
3134
|
+
*
|
|
3135
|
+
* Qoder reports a `priceFactor` number; the host spells it `x<n>` (and the
|
|
3136
|
+
* WorkBuddy-era saved files sometimes carried a trailing " credits"). The card
|
|
3137
|
+
* wants one shape: a trimmed multiplier without the suffix.
|
|
3138
|
+
*/
|
|
3139
|
+
function normalizeCredits(credits) {
|
|
3140
|
+
if (credits === void 0) return void 0;
|
|
3141
|
+
const trimmed = credits.trim().replace(/\s+credits?$/iu, "");
|
|
3142
|
+
return trimmed === "" ? void 0 : trimmed;
|
|
3143
|
+
}
|
|
3144
|
+
/**
|
|
3145
|
+
* Classify a raw HTTP upstream failure from status and a body excerpt.
|
|
3146
|
+
*
|
|
3147
|
+
* Status first, then phrases: a throttling body often also says "quota
|
|
3148
|
+
* exceeded", and reading that as an exhausted balance parks a healthy account
|
|
3149
|
+
* until the next billing day instead of retrying shortly.
|
|
3150
|
+
*/
|
|
3151
|
+
function classifyUpstreamError(status, body) {
|
|
3152
|
+
const lower = body.toLowerCase();
|
|
3153
|
+
if (status === 402) return "quota_exceeded";
|
|
3154
|
+
if (status === 429) return "soft_rate";
|
|
3155
|
+
if (status === 401 || status === 403) return "auth";
|
|
3156
|
+
for (const marker of [
|
|
3157
|
+
"quota exceeded",
|
|
3158
|
+
"insufficient quota",
|
|
3159
|
+
"quota_exhausted",
|
|
3160
|
+
"exhausted"
|
|
3161
|
+
]) if (lower.includes(marker)) return "quota_exceeded";
|
|
3162
|
+
for (const marker of [
|
|
3163
|
+
"rate limit",
|
|
3164
|
+
"too many requests",
|
|
3165
|
+
"throttl"
|
|
3166
|
+
]) if (lower.includes(marker)) return "soft_rate";
|
|
3167
|
+
for (const marker of [
|
|
3168
|
+
"invalid token",
|
|
3169
|
+
"invalid pat",
|
|
3170
|
+
"unauthorized",
|
|
3171
|
+
"forbidden",
|
|
3172
|
+
"not authenticated"
|
|
3173
|
+
]) if (lower.includes(marker)) return "auth";
|
|
3174
|
+
if (status === 0 || status >= 500) return "server";
|
|
3175
|
+
return "client";
|
|
3176
|
+
}
|
|
3177
|
+
/**
|
|
3178
|
+
* Map one structured transport failure onto the shim's error kind.
|
|
3179
|
+
*
|
|
3180
|
+
* The transport's taxonomy (`src/qoder/errors.ts`) codes an HTTP 402 as
|
|
3181
|
+
* `INVALID_REQUEST`, so the status gets the last say for the quota and auth
|
|
3182
|
+
* families before the code-based defaults apply.
|
|
3183
|
+
*/
|
|
3184
|
+
function kindFromQoderFailure(failure) {
|
|
3185
|
+
if (failure.code === "MISSING_CREDENTIAL") return "missing_credential";
|
|
3186
|
+
if (failure.code === "AUTH" || failure.status === 401 || failure.status === 403) return "auth";
|
|
3187
|
+
if (failure.code === "RATE_LIMIT" || failure.status === 429) return "soft_rate";
|
|
3188
|
+
if (failure.code === "QUOTA" || failure.status === 402) return "quota_exceeded";
|
|
3189
|
+
if (failure.code.startsWith("INVALID_") || failure.code.startsWith("UNSUPPORTED_") || failure.code === "ATTACHMENT") return "client";
|
|
3190
|
+
return "server";
|
|
3191
|
+
}
|
|
3192
|
+
/** Whether a Personal Access Token works against one region's endpoints. */
|
|
3193
|
+
async function validateApiKey(pat, region, options = {}) {
|
|
3194
|
+
const trimmed = pat.trim();
|
|
3195
|
+
if (trimmed === "") return false;
|
|
3196
|
+
try {
|
|
3197
|
+
await createQoderTransport({
|
|
3198
|
+
region,
|
|
3199
|
+
resolvePat: async () => trimmed,
|
|
3200
|
+
...options.fetch === void 0 ? {} : { fetch: options.fetch },
|
|
3201
|
+
metadataTimeoutMs: options.timeoutMs ?? 15e3
|
|
3202
|
+
}).discoverModels(AbortSignal.timeout(options.timeoutMs ?? 15e3));
|
|
3203
|
+
return true;
|
|
3204
|
+
} catch {
|
|
3205
|
+
return false;
|
|
3206
|
+
}
|
|
3207
|
+
}
|
|
3208
|
+
/** A request the OpenAI layer itself could not accept; the shim answers 400. */
|
|
3209
|
+
var UpstreamRequestError = class extends Error {};
|
|
3210
|
+
/** One `data:` frame of a `chat.completion.chunk` SSE body. */
|
|
3211
|
+
function sseFrame(payload) {
|
|
3212
|
+
return `data: ${JSON.stringify(payload)}\n\n`;
|
|
3213
|
+
}
|
|
3214
|
+
const SSE_DONE = "data: [DONE]\n\n";
|
|
3215
|
+
/** The media types the attachment service normalizes; anything else is refused. */
|
|
3216
|
+
const IMAGE_MEDIA_TYPES = [
|
|
3217
|
+
"image/png",
|
|
3218
|
+
"image/jpeg",
|
|
3219
|
+
"image/webp",
|
|
3220
|
+
"image/gif"
|
|
3221
|
+
];
|
|
3222
|
+
function isRecord(value) {
|
|
3223
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3224
|
+
}
|
|
3225
|
+
function requiredString(value, what) {
|
|
3226
|
+
if (typeof value !== "string" || value.trim() === "") throw new UpstreamRequestError(`${what} must be a non-empty string`);
|
|
3227
|
+
return value;
|
|
3228
|
+
}
|
|
3229
|
+
/** Decode one `data:image/...;base64,` URL into typed bytes. */
|
|
3230
|
+
function decodeDataImage(url) {
|
|
3231
|
+
const match = /^data:([a-z]+\/[a-z0-9.+-]+)(?:;[a-z0-9;=+.-]*)?,(.*)$/iu.exec(url);
|
|
3232
|
+
if (match === null) throw new UpstreamRequestError("image_url data URL is malformed");
|
|
3233
|
+
const mediaType = match[1].toLowerCase();
|
|
3234
|
+
if (!IMAGE_MEDIA_TYPES.includes(mediaType)) throw new UpstreamRequestError(`image media type "${mediaType}" is not supported (png, jpeg, webp, gif)`);
|
|
3235
|
+
let data;
|
|
3236
|
+
try {
|
|
3237
|
+
data = new Uint8Array(Buffer.from(match[2] ?? "", "base64"));
|
|
3238
|
+
} catch {
|
|
3239
|
+
throw new UpstreamRequestError("image_url base64 payload could not be decoded");
|
|
3240
|
+
}
|
|
3241
|
+
if (data.byteLength === 0) throw new UpstreamRequestError("image_url payload is empty");
|
|
3242
|
+
return {
|
|
3243
|
+
mediaType,
|
|
3244
|
+
data
|
|
3245
|
+
};
|
|
3246
|
+
}
|
|
3247
|
+
/**
|
|
3248
|
+
* Parse one OpenAI content value (string or parts array) into text.
|
|
3249
|
+
*
|
|
3250
|
+
* With `imagesAs`, image parts are offered for commit — and a request that
|
|
3251
|
+
* carries them without any commit path fails as a client error, because the
|
|
3252
|
+
* Qoder wire can only ever transport a durable attachment reference.
|
|
3253
|
+
*/
|
|
3254
|
+
async function openAiContentBlocks(content, role, imagesAs) {
|
|
3255
|
+
const blocks = [];
|
|
3256
|
+
const parts = typeof content === "string" || content === null || content === void 0 ? typeof content === "string" && content !== "" ? [{
|
|
3257
|
+
type: "text",
|
|
3258
|
+
text: content
|
|
3259
|
+
}] : [] : Array.isArray(content) ? content : (() => {
|
|
3260
|
+
throw new UpstreamRequestError(`${role} message content must be a string or a parts array`);
|
|
3261
|
+
})();
|
|
3262
|
+
for (const raw of parts) {
|
|
3263
|
+
if (!isRecord(raw) || typeof raw["type"] !== "string") throw new UpstreamRequestError(`${role} message content part is malformed`);
|
|
3264
|
+
switch (raw["type"]) {
|
|
3265
|
+
case "text":
|
|
3266
|
+
blocks.push({
|
|
3267
|
+
type: "text",
|
|
3268
|
+
text: typeof raw["text"] === "string" ? raw["text"] : ""
|
|
3269
|
+
});
|
|
3270
|
+
break;
|
|
3271
|
+
case "image_url": {
|
|
3272
|
+
if (role !== "user") throw new UpstreamRequestError("image parts are valid only in user messages");
|
|
3273
|
+
const imageUrl = raw["image_url"];
|
|
3274
|
+
const url = isRecord(imageUrl) && typeof imageUrl["url"] === "string" ? imageUrl["url"] : "";
|
|
3275
|
+
if (!url.toLowerCase().startsWith("data:")) throw new UpstreamRequestError("image_url must be an inline data: URL; remote image URLs are not fetched");
|
|
3276
|
+
if (imagesAs === void 0) throw new UpstreamRequestError("image input requires the DSH attachment service");
|
|
3277
|
+
const decoded = decodeDataImage(url);
|
|
3278
|
+
blocks.push(await imagesAs.commit(decoded));
|
|
3279
|
+
break;
|
|
3280
|
+
}
|
|
3281
|
+
default: throw new UpstreamRequestError(`unsupported content part type "${raw["type"]}"`);
|
|
3282
|
+
}
|
|
3283
|
+
}
|
|
3284
|
+
return blocks;
|
|
3285
|
+
}
|
|
3286
|
+
/**
|
|
3287
|
+
* Translate one OpenAI chat-completions body into a `GenerateOptions` request.
|
|
3288
|
+
*
|
|
3289
|
+
* The layer is deliberately narrow: it accepts exactly what pi-ai's
|
|
3290
|
+
* `openai-completions` API sends, and rejects anything else with an
|
|
3291
|
+
* attributable 400 rather than silently dropping a caller's intent. System
|
|
3292
|
+
* prompts ride `options.system` (the transport's own slot), not a leading
|
|
3293
|
+
* message; tool results become dedicated user-role messages the Qoder
|
|
3294
|
+
* serializer accepts.
|
|
3295
|
+
*/
|
|
3296
|
+
async function toGenerateOptions(bodyJson, context) {
|
|
3297
|
+
let parsed;
|
|
3298
|
+
try {
|
|
3299
|
+
parsed = JSON.parse(bodyJson);
|
|
3300
|
+
} catch {
|
|
3301
|
+
throw new UpstreamRequestError("request body is not valid JSON");
|
|
3302
|
+
}
|
|
3303
|
+
if (!isRecord(parsed)) throw new UpstreamRequestError("request body must be a JSON object");
|
|
3304
|
+
const model = requiredString(parsed["model"], "model");
|
|
3305
|
+
const rawMessages = parsed["messages"];
|
|
3306
|
+
if (!Array.isArray(rawMessages) || rawMessages.length === 0) throw new UpstreamRequestError("messages must be a non-empty array");
|
|
3307
|
+
const systemParts = [];
|
|
3308
|
+
const messages = [];
|
|
3309
|
+
for (const raw of rawMessages) {
|
|
3310
|
+
if (!isRecord(raw)) throw new UpstreamRequestError("each message must be an object");
|
|
3311
|
+
const role = raw["role"];
|
|
3312
|
+
switch (role) {
|
|
3313
|
+
case "system":
|
|
3314
|
+
case "developer":
|
|
3315
|
+
if (typeof raw["content"] !== "string") throw new UpstreamRequestError("system message content must be a string");
|
|
3316
|
+
if (raw["content"] !== "") systemParts.push(raw["content"]);
|
|
3317
|
+
break;
|
|
3318
|
+
case "user": {
|
|
3319
|
+
const content = await openAiContentBlocks(raw["content"], "user", context.attachments === void 0 ? void 0 : { commit: async (part) => ({
|
|
3320
|
+
type: "image",
|
|
3321
|
+
attachment: await context.attachments.saveImage({
|
|
3322
|
+
data: part.data,
|
|
3323
|
+
mediaType: part.mediaType
|
|
3324
|
+
})
|
|
3325
|
+
}) });
|
|
3326
|
+
messages.push(createUserMessage({
|
|
3327
|
+
content,
|
|
3328
|
+
source: { kind: "user" }
|
|
3329
|
+
}));
|
|
3330
|
+
break;
|
|
3331
|
+
}
|
|
3332
|
+
case "assistant": {
|
|
3333
|
+
const content = await openAiContentBlocks(raw["content"], "assistant", void 0);
|
|
3334
|
+
const rawCalls = raw["tool_calls"];
|
|
3335
|
+
if (Array.isArray(rawCalls)) for (const rawCall of rawCalls) {
|
|
3336
|
+
if (!isRecord(rawCall)) throw new UpstreamRequestError("each tool call must be an object");
|
|
3337
|
+
const fn = isRecord(rawCall["function"]) ? rawCall["function"] : rawCall;
|
|
3338
|
+
content.push({
|
|
3339
|
+
type: "tool-call",
|
|
3340
|
+
id: ToolCallId(requiredString(rawCall["id"], "tool call id")),
|
|
3341
|
+
name: requiredString(fn["name"], "tool call function name"),
|
|
3342
|
+
arguments: typeof fn["arguments"] === "string" ? fn["arguments"] : ""
|
|
3343
|
+
});
|
|
3344
|
+
}
|
|
3345
|
+
if (content.length === 0) break;
|
|
3346
|
+
messages.push(createAssistantMessage({
|
|
3347
|
+
content,
|
|
3348
|
+
source: {
|
|
3349
|
+
provider: context.providerId,
|
|
3350
|
+
model
|
|
3351
|
+
}
|
|
3352
|
+
}));
|
|
3353
|
+
break;
|
|
3354
|
+
}
|
|
3355
|
+
case "tool": {
|
|
3356
|
+
const callId = requiredString(raw["tool_call_id"], "tool_call_id");
|
|
3357
|
+
const content = raw["content"];
|
|
3358
|
+
let text;
|
|
3359
|
+
if (typeof content === "string") text = content;
|
|
3360
|
+
else if (Array.isArray(content)) {
|
|
3361
|
+
text = content.filter((part) => isRecord(part) && part["type"] === "text").map((part) => typeof part["text"] === "string" ? part["text"] : "").join("");
|
|
3362
|
+
if (content.some((part) => isRecord(part) && part["type"] !== "text")) throw new UpstreamRequestError("tool result content is limited to text");
|
|
3363
|
+
} else throw new UpstreamRequestError("tool message content must be a string or a text-parts array");
|
|
3364
|
+
messages.push(createToolResultMessage({
|
|
3365
|
+
callId: ToolCallId(callId),
|
|
3366
|
+
content: [{
|
|
3367
|
+
type: "text",
|
|
3368
|
+
text
|
|
3369
|
+
}],
|
|
3370
|
+
isError: false
|
|
3371
|
+
}));
|
|
3372
|
+
break;
|
|
3373
|
+
}
|
|
3374
|
+
default: throw new UpstreamRequestError(`unsupported message role "${String(role)}"`);
|
|
3375
|
+
}
|
|
3376
|
+
}
|
|
3377
|
+
if (messages.length === 0) throw new UpstreamRequestError("the request carries no model-facing message");
|
|
3378
|
+
const tools = Array.isArray(parsed["tools"]) ? parsed["tools"].map((raw) => {
|
|
3379
|
+
if (!isRecord(raw)) throw new UpstreamRequestError("each tool must be an object");
|
|
3380
|
+
const fn = isRecord(raw["function"]) ? raw["function"] : raw;
|
|
3381
|
+
if (!isRecord(fn)) throw new UpstreamRequestError("each tool must carry a function object");
|
|
3382
|
+
return {
|
|
3383
|
+
name: requiredString(fn["name"], "tool name"),
|
|
3384
|
+
description: typeof fn["description"] === "string" ? fn["description"] : "",
|
|
3385
|
+
parameters: isRecord(fn["parameters"]) ? fn["parameters"] : {}
|
|
3386
|
+
};
|
|
3387
|
+
}) : void 0;
|
|
3388
|
+
const effort = parsed["reasoning_effort"];
|
|
3389
|
+
const stop = parsed["stop"];
|
|
3390
|
+
const temperature = parsed["temperature"];
|
|
3391
|
+
const maxTokens = parsed["max_tokens"];
|
|
3392
|
+
return {
|
|
3393
|
+
provider: context.providerId,
|
|
3394
|
+
model,
|
|
3395
|
+
messages,
|
|
3396
|
+
...systemParts.length > 0 ? { system: systemParts.join("\n") } : {},
|
|
3397
|
+
...tools === void 0 || tools.length === 0 ? {} : { tools },
|
|
3398
|
+
...typeof temperature === "number" && Number.isFinite(temperature) ? { temperature } : {},
|
|
3399
|
+
...typeof maxTokens === "number" && Number.isFinite(maxTokens) && maxTokens > 0 ? { maxTokens: Math.floor(maxTokens) } : {},
|
|
3400
|
+
...typeof effort === "string" && effort.trim() !== "" ? { reasoningEffort: ReasoningEffortId(effort.trim()) } : {},
|
|
3401
|
+
...Array.isArray(stop) ? { stop: stop.filter((entry) => typeof entry === "string") } : typeof stop === "string" ? { stop: [stop] } : {},
|
|
3402
|
+
...context.signal === void 0 ? {} : { signal: context.signal }
|
|
3403
|
+
};
|
|
3404
|
+
}
|
|
3405
|
+
/**
|
|
3406
|
+
* Re-serialize one `StreamChunk` sequence into OpenAI chunk frames.
|
|
3407
|
+
*
|
|
3408
|
+
* One assistant choice, deltas keyed by a per-block tool-call index that
|
|
3409
|
+
* restarts at zero per block like OpenAI's own, reasoning streamed as
|
|
3410
|
+
* `delta.reasoning_content` (the compatible extension other gateways use),
|
|
3411
|
+
* usage as a choices-less frame before the terminal finish, and `data:
|
|
3412
|
+
* [DONE]` last. A failed or aborted finish mid-stream becomes an error JSON
|
|
3413
|
+
* frame and then `[DONE]`: the HTTP status is already sent and cannot carry
|
|
3414
|
+
* the failure.
|
|
3415
|
+
*/
|
|
3416
|
+
var ChunkEncoder = class {
|
|
3417
|
+
id;
|
|
3418
|
+
created;
|
|
3419
|
+
model;
|
|
3420
|
+
started = false;
|
|
3421
|
+
terminal = false;
|
|
3422
|
+
toolIndexByBlock = /* @__PURE__ */ new Map();
|
|
3423
|
+
announcedTools = /* @__PURE__ */ new Set();
|
|
3424
|
+
nextToolIndex = 0;
|
|
3425
|
+
constructor(model) {
|
|
3426
|
+
this.id = `chatcmpl-${randomBytes(12).toString("hex")}`;
|
|
3427
|
+
this.created = Math.floor(Date.now() / 1e3);
|
|
3428
|
+
this.model = model;
|
|
3429
|
+
}
|
|
3430
|
+
frame(extra) {
|
|
3431
|
+
return sseFrame({
|
|
3432
|
+
id: this.id,
|
|
3433
|
+
object: "chat.completion.chunk",
|
|
3434
|
+
created: this.created,
|
|
3435
|
+
model: this.model,
|
|
3436
|
+
...extra
|
|
3437
|
+
});
|
|
3438
|
+
}
|
|
3439
|
+
/** The role frame opens every stream, once. */
|
|
3440
|
+
start() {
|
|
3441
|
+
if (this.started) return [];
|
|
3442
|
+
this.started = true;
|
|
3443
|
+
return [this.frame({ choices: [{
|
|
3444
|
+
index: 0,
|
|
3445
|
+
delta: { role: "assistant" },
|
|
3446
|
+
finish_reason: null
|
|
3447
|
+
}] })];
|
|
3448
|
+
}
|
|
3449
|
+
/** Translate one transport chunk into zero or more SSE frames. */
|
|
3450
|
+
frames(chunk) {
|
|
3451
|
+
switch (chunk.type) {
|
|
3452
|
+
case "block-start":
|
|
3453
|
+
case "block-end": return [];
|
|
3454
|
+
case "text-delta": return [this.frame({ choices: [{
|
|
3455
|
+
index: 0,
|
|
3456
|
+
delta: { content: chunk.text },
|
|
3457
|
+
finish_reason: null
|
|
3458
|
+
}] })];
|
|
3459
|
+
case "reasoning-delta": return [this.frame({ choices: [{
|
|
3460
|
+
index: 0,
|
|
3461
|
+
delta: { reasoning_content: chunk.text },
|
|
3462
|
+
finish_reason: null
|
|
3463
|
+
}] })];
|
|
3464
|
+
case "tool-call-delta": {
|
|
3465
|
+
let toolIndex = this.toolIndexByBlock.get(chunk.index);
|
|
3466
|
+
if (toolIndex === void 0) {
|
|
3467
|
+
toolIndex = this.nextToolIndex++;
|
|
3468
|
+
this.toolIndexByBlock.set(chunk.index, toolIndex);
|
|
3469
|
+
}
|
|
3470
|
+
const first = !this.announcedTools.has(chunk.index);
|
|
3471
|
+
if (first) this.announcedTools.add(chunk.index);
|
|
3472
|
+
const call = {
|
|
3473
|
+
index: toolIndex,
|
|
3474
|
+
...first ? {
|
|
3475
|
+
id: String(chunk.id),
|
|
3476
|
+
type: "function",
|
|
3477
|
+
function: {
|
|
3478
|
+
name: chunk.name ?? "",
|
|
3479
|
+
arguments: chunk.argumentsDelta
|
|
3480
|
+
}
|
|
3481
|
+
} : { function: { arguments: chunk.argumentsDelta } }
|
|
3482
|
+
};
|
|
3483
|
+
return [this.frame({ choices: [{
|
|
3484
|
+
index: 0,
|
|
3485
|
+
delta: { tool_calls: [call] },
|
|
3486
|
+
finish_reason: null
|
|
3487
|
+
}] })];
|
|
3488
|
+
}
|
|
3489
|
+
case "usage": {
|
|
3490
|
+
const prompt = chunk.usage.inputTokens + (chunk.usage.cacheReadTokens ?? 0) + (chunk.usage.cacheWriteTokens ?? 0);
|
|
3491
|
+
const completion = chunk.usage.outputTokens;
|
|
3492
|
+
return [this.frame({
|
|
3493
|
+
choices: [],
|
|
3494
|
+
usage: {
|
|
3495
|
+
prompt_tokens: prompt,
|
|
3496
|
+
completion_tokens: completion,
|
|
3497
|
+
total_tokens: chunk.usage.totalTokens ?? prompt + completion
|
|
3498
|
+
}
|
|
3499
|
+
})];
|
|
3500
|
+
}
|
|
3501
|
+
case "finish": {
|
|
3502
|
+
this.terminal = true;
|
|
3503
|
+
const reason = chunk.reason;
|
|
3504
|
+
if (reason.kind === "error" || reason.kind === "aborted") return [sseFrame({ error: {
|
|
3505
|
+
message: reason.failure.message,
|
|
3506
|
+
type: "server_error",
|
|
3507
|
+
code: reason.failure.code
|
|
3508
|
+
} }), SSE_DONE];
|
|
3509
|
+
const finishReason = reason.kind === "max-tokens" ? "length" : reason.kind === "tool-calls" ? "tool_calls" : "stop";
|
|
3510
|
+
return [this.frame({ choices: [{
|
|
3511
|
+
index: 0,
|
|
3512
|
+
delta: {},
|
|
3513
|
+
finish_reason: finishReason
|
|
3514
|
+
}] }), SSE_DONE];
|
|
3515
|
+
}
|
|
3516
|
+
}
|
|
3517
|
+
}
|
|
3518
|
+
/** Fallback finish when the chunk source ended without a finish chunk. */
|
|
3519
|
+
finishFallback() {
|
|
3520
|
+
if (this.terminal) return [];
|
|
3521
|
+
this.terminal = true;
|
|
3522
|
+
return [this.frame({ choices: [{
|
|
3523
|
+
index: 0,
|
|
3524
|
+
delta: {},
|
|
3525
|
+
finish_reason: "stop"
|
|
3526
|
+
}] }), SSE_DONE];
|
|
3527
|
+
}
|
|
3528
|
+
/** A thrown mid-stream failure, phrased as one error frame and the end. */
|
|
3529
|
+
abortFrames(message, code) {
|
|
3530
|
+
this.terminal = true;
|
|
3531
|
+
return [sseFrame({ error: {
|
|
3532
|
+
message,
|
|
3533
|
+
type: "server_error",
|
|
3534
|
+
code
|
|
3535
|
+
} }), SSE_DONE];
|
|
3536
|
+
}
|
|
3537
|
+
};
|
|
3538
|
+
/** Turn any thrown failure into the shim's classified pre-stream result. */
|
|
3539
|
+
function failureResult(error) {
|
|
3540
|
+
if (error instanceof UpstreamRequestError) return {
|
|
3541
|
+
ok: false,
|
|
3542
|
+
status: 400,
|
|
3543
|
+
kind: "client",
|
|
3544
|
+
message: error.message
|
|
3545
|
+
};
|
|
3546
|
+
if (error instanceof LlmError) {
|
|
3547
|
+
const kind = kindFromQoderFailure(error.failure);
|
|
3548
|
+
return {
|
|
3549
|
+
ok: false,
|
|
3550
|
+
status: typeof error.failure.status === "number" && error.failure.status >= 100 && error.failure.status <= 599 ? error.failure.status : KIND_STATUS[kind],
|
|
3551
|
+
kind,
|
|
3552
|
+
message: error.failure.message
|
|
3553
|
+
};
|
|
3554
|
+
}
|
|
3555
|
+
return {
|
|
3556
|
+
ok: false,
|
|
3557
|
+
status: 502,
|
|
3558
|
+
kind: "server",
|
|
3559
|
+
message: String(error)
|
|
3560
|
+
};
|
|
3561
|
+
}
|
|
3562
|
+
var QoderUpstreamClient = class {
|
|
3563
|
+
region;
|
|
3564
|
+
providerId;
|
|
3565
|
+
getPat;
|
|
3566
|
+
transport;
|
|
3567
|
+
attachments;
|
|
3568
|
+
externalCatalog;
|
|
3569
|
+
/** The raw discovery rows of the last successful `fetchModels`, by id. */
|
|
3570
|
+
discovered = /* @__PURE__ */ new Map();
|
|
3571
|
+
/** Provenance of the last successful catalog fetch, for the status card. */
|
|
3572
|
+
lastCatalog;
|
|
3573
|
+
constructor(options) {
|
|
3574
|
+
this.region = options.region;
|
|
3575
|
+
this.providerId = options.providerId;
|
|
3576
|
+
this.getPat = options.getPat;
|
|
3577
|
+
this.transport = options.transport;
|
|
3578
|
+
this.attachments = options.attachments;
|
|
3579
|
+
this.externalCatalog = options.catalogProvider;
|
|
3580
|
+
}
|
|
3581
|
+
/** The region this client's transport serves. */
|
|
3582
|
+
get clientRegion() {
|
|
3583
|
+
return this.region;
|
|
3584
|
+
}
|
|
3585
|
+
/**
|
|
3586
|
+
* The catalog row for one model id: this client's own discovery answers
|
|
3587
|
+
* first (it is the same data the upstream catalog call produced), and an
|
|
3588
|
+
* externally supplied provider — the plugin's fallback roster, say — fills
|
|
3589
|
+
* ids the discovery never listed.
|
|
3590
|
+
*/
|
|
3591
|
+
catalogModelFor(id) {
|
|
3592
|
+
return this.discovered.get(id) ?? this.externalCatalog?.(id);
|
|
3593
|
+
}
|
|
3594
|
+
/**
|
|
3595
|
+
* Discover this variant's models and translate them into plugin rows.
|
|
3596
|
+
*
|
|
3597
|
+
* The raw rows stay addressable via {@link catalogModelFor}: a chat request
|
|
3598
|
+
* for a discovered model must hand the transport its row, because effort
|
|
3599
|
+
* support, image capability, and the output cap are all validated against
|
|
3600
|
+
* it. The last successful fetch's provenance lands in {@link lastCatalog}
|
|
3601
|
+
* for the status card.
|
|
3602
|
+
*/
|
|
3603
|
+
async fetchModels(signal) {
|
|
3604
|
+
const models = await this.transport.discoverModels(signal);
|
|
3605
|
+
this.discovered.clear();
|
|
3606
|
+
for (const model of models) this.discovered.set(model.id, model);
|
|
3607
|
+
this.lastCatalog = {
|
|
3608
|
+
fetchedAtMs: Date.now(),
|
|
3609
|
+
source: `${this.providerId}:discovery`
|
|
3610
|
+
};
|
|
3611
|
+
return models.map(modelInfoOf);
|
|
3612
|
+
}
|
|
3613
|
+
/**
|
|
3614
|
+
* The subscriber's quota answer, mapped onto the card's credit shape.
|
|
3615
|
+
*
|
|
3616
|
+
* Qoder reports one personal quota and an optional organization resource
|
|
3617
|
+
* package; each becomes a package row the card already knows how to draw.
|
|
3618
|
+
* An unbounded personal allowance (`total === 0`, not exceeded) is the
|
|
3619
|
+
* account's "unlimited" state.
|
|
3620
|
+
*/
|
|
3621
|
+
async fetchCredits(signal) {
|
|
3622
|
+
const usage = (await this.transport.readAccount({
|
|
3623
|
+
force: true,
|
|
3624
|
+
...signal === void 0 ? {} : { signal }
|
|
3625
|
+
})).usage ?? {};
|
|
3626
|
+
const accounts = [];
|
|
3627
|
+
const personal = usage.userQuota;
|
|
3628
|
+
if (personal !== void 0) accounts.push({
|
|
3629
|
+
packageName: "个人额度",
|
|
3630
|
+
remain: personal.remaining,
|
|
3631
|
+
size: personal.total,
|
|
3632
|
+
...usage.expiresAt === void 0 ? {} : { packageEndTime: usage.expiresAt }
|
|
3633
|
+
});
|
|
3634
|
+
const org = usage.orgResourcePackage;
|
|
3635
|
+
if (org !== void 0) accounts.push({
|
|
3636
|
+
packageName: "组织资源包",
|
|
3637
|
+
remain: org.remaining,
|
|
3638
|
+
size: org.total,
|
|
3639
|
+
...usage.expiresAt === void 0 ? {} : { packageEndTime: usage.expiresAt }
|
|
3640
|
+
});
|
|
3641
|
+
const unlimited = usage.isQuotaExceeded === false && personal !== void 0 && personal.total === 0;
|
|
3642
|
+
return {
|
|
3643
|
+
total: usage.totalUsagePercentage ?? personal?.percentage ?? 0,
|
|
3644
|
+
totalSize: accounts.reduce((sum, entry) => sum + entry.size, 0),
|
|
3645
|
+
accounts,
|
|
3646
|
+
...unlimited ? { unlimited: true } : {},
|
|
3647
|
+
...usage.expiresAt === void 0 ? {} : { cycleResetTime: usage.expiresAt }
|
|
3648
|
+
};
|
|
3649
|
+
}
|
|
3650
|
+
/**
|
|
3651
|
+
* Stream one translated chat-completions request.
|
|
3652
|
+
*
|
|
3653
|
+
* The first transport chunk is pulled before the response is constructed:
|
|
3654
|
+
* everything the transport can reject without touching the network — a
|
|
3655
|
+
* missing or empty credential, an effort the model does not advertise, an
|
|
3656
|
+
* image on a text-only model — must still be able to answer as a real HTTP
|
|
3657
|
+
* status, not as a 200 whose first frame is an error. Once streaming has
|
|
3658
|
+
* begun, failures degrade to the error-frame-then-`[DONE]` form the
|
|
3659
|
+
* {@link ChunkEncoder} documents.
|
|
3660
|
+
*/
|
|
3661
|
+
async chatStream(bodyJson, signal) {
|
|
3662
|
+
let options;
|
|
3663
|
+
try {
|
|
3664
|
+
options = await toGenerateOptions(bodyJson, {
|
|
3665
|
+
providerId: this.providerId,
|
|
3666
|
+
attachments: this.attachments,
|
|
3667
|
+
...signal === void 0 ? {} : { signal }
|
|
3668
|
+
});
|
|
3669
|
+
} catch (error) {
|
|
3670
|
+
return failureResult(error);
|
|
3671
|
+
}
|
|
3672
|
+
try {
|
|
3673
|
+
await this.getPat();
|
|
3674
|
+
} catch {
|
|
3675
|
+
return {
|
|
3676
|
+
ok: false,
|
|
3677
|
+
status: KIND_STATUS.missing_credential,
|
|
3678
|
+
kind: "missing_credential",
|
|
3679
|
+
message: "no Qoder Personal Access Token is configured"
|
|
3680
|
+
};
|
|
3681
|
+
}
|
|
3682
|
+
const encoder = new ChunkEncoder(options.model);
|
|
3683
|
+
let iterator;
|
|
3684
|
+
try {
|
|
3685
|
+
iterator = this.transport.stream(options, this.catalogModelFor(options.model))[Symbol.asyncIterator]();
|
|
3686
|
+
} catch (error) {
|
|
3687
|
+
return failureResult(error);
|
|
3688
|
+
}
|
|
3689
|
+
try {
|
|
3690
|
+
const first = await iterator.next();
|
|
3691
|
+
if (!first.done) {
|
|
3692
|
+
const earlyFailure = first.value.type === "finish" && (first.value.reason.kind === "error" || first.value.reason.kind === "aborted") ? first.value.reason.failure : void 0;
|
|
3693
|
+
if (earlyFailure !== void 0) {
|
|
3694
|
+
await iterator.return?.();
|
|
3695
|
+
const kind = kindFromQoderFailure(earlyFailure);
|
|
3696
|
+
return {
|
|
3697
|
+
ok: false,
|
|
3698
|
+
status: earlyFailure.status ?? KIND_STATUS[kind],
|
|
3699
|
+
kind,
|
|
3700
|
+
message: earlyFailure.message
|
|
3701
|
+
};
|
|
3702
|
+
}
|
|
3703
|
+
}
|
|
3704
|
+
const stream = this.bodyStream(iterator, first, encoder);
|
|
3705
|
+
return {
|
|
3706
|
+
ok: true,
|
|
3707
|
+
response: new Response(stream, {
|
|
3708
|
+
status: 200,
|
|
3709
|
+
headers: {
|
|
3710
|
+
"Content-Type": "text/event-stream; charset=utf-8",
|
|
3711
|
+
"Cache-Control": "no-cache",
|
|
3712
|
+
"Connection": "keep-alive"
|
|
3713
|
+
}
|
|
3714
|
+
})
|
|
3715
|
+
};
|
|
3716
|
+
} catch (error) {
|
|
3717
|
+
await iterator.return?.();
|
|
3718
|
+
return failureResult(error);
|
|
3719
|
+
}
|
|
3720
|
+
}
|
|
3721
|
+
/** Drive the SSE body from an already-started chunk iterator. */
|
|
3722
|
+
bodyStream(iterator, first, encoder) {
|
|
3723
|
+
const encoderRef = encoder;
|
|
3724
|
+
const toBytes = (() => {
|
|
3725
|
+
const text = new TextEncoder();
|
|
3726
|
+
return (frame) => text.encode(frame);
|
|
3727
|
+
})();
|
|
3728
|
+
let queue = [];
|
|
3729
|
+
let pending = first;
|
|
3730
|
+
return new ReadableStream({
|
|
3731
|
+
pull: async (controller) => {
|
|
3732
|
+
try {
|
|
3733
|
+
while (queue.length === 0 && !encoderRef.terminal) {
|
|
3734
|
+
if (pending === void 0) pending = await iterator.next();
|
|
3735
|
+
const next = pending;
|
|
3736
|
+
pending = void 0;
|
|
3737
|
+
if (next.done) {
|
|
3738
|
+
queue.push(...encoderRef.finishFallback());
|
|
3739
|
+
break;
|
|
3740
|
+
}
|
|
3741
|
+
queue.push(...encoderRef.start(), ...encoderRef.frames(next.value));
|
|
3742
|
+
}
|
|
3743
|
+
} catch (error) {
|
|
3744
|
+
const message = error instanceof LlmError ? error.failure.message : String(error);
|
|
3745
|
+
const code = error instanceof LlmError ? error.failure.code : "TRANSPORT";
|
|
3746
|
+
queue = encoderRef.abortFrames(message, code);
|
|
3747
|
+
}
|
|
3748
|
+
if (queue.length > 0) {
|
|
3749
|
+
controller.enqueue(toBytes(queue.shift()));
|
|
3750
|
+
if (encoderRef.terminal && queue.length === 0) controller.close();
|
|
3751
|
+
return;
|
|
3752
|
+
}
|
|
3753
|
+
controller.close();
|
|
3754
|
+
},
|
|
3755
|
+
cancel: async () => {
|
|
3756
|
+
await iterator.return?.();
|
|
3757
|
+
}
|
|
3758
|
+
});
|
|
3759
|
+
}
|
|
3760
|
+
/**
|
|
3761
|
+
* One minimal reasoning-effort request, as the probe sweep needs it.
|
|
3762
|
+
*
|
|
3763
|
+
* The transport validates `reasoningEffort` against the model's advertised
|
|
3764
|
+
* efforts **locally** before any network call: a rejection therefore reads
|
|
3765
|
+
* as an attributable 400 with code `UNSUPPORTED_REASONING_EFFORT`, and an
|
|
3766
|
+
* accepted value costs exactly one streamed request, which this method ends
|
|
3767
|
+
* after the first chunk arrives. A probe on this upstream measures the
|
|
3768
|
+
* discovery catalog as much as the endpoint behind it — see the module docs
|
|
3769
|
+
* in `probe.ts`.
|
|
3770
|
+
*/
|
|
3771
|
+
async probeEffort(model, effort, signal) {
|
|
3772
|
+
try {
|
|
3773
|
+
await this.getPat();
|
|
3774
|
+
} catch {
|
|
3775
|
+
return {
|
|
3776
|
+
status: 401,
|
|
3777
|
+
streamed: false,
|
|
3778
|
+
errorCode: "MISSING_CREDENTIAL",
|
|
3779
|
+
detail: "no Personal Access Token configured"
|
|
3780
|
+
};
|
|
3781
|
+
}
|
|
3782
|
+
const options = {
|
|
3783
|
+
provider: this.providerId,
|
|
3784
|
+
model,
|
|
3785
|
+
messages: [createUserMessage({
|
|
3786
|
+
content: [{
|
|
3787
|
+
type: "text",
|
|
3788
|
+
text: PROBE_PROMPT
|
|
3789
|
+
}],
|
|
3790
|
+
source: { kind: "user" }
|
|
3791
|
+
})],
|
|
3792
|
+
maxTokens: 1,
|
|
3793
|
+
...effort === void 0 ? {} : { reasoningEffort: ReasoningEffortId(effort) },
|
|
3794
|
+
signal
|
|
3795
|
+
};
|
|
3796
|
+
try {
|
|
3797
|
+
const iterator = this.transport.stream(options, this.catalogModelFor(model))[Symbol.asyncIterator]();
|
|
3798
|
+
try {
|
|
3799
|
+
const first = await iterator.next();
|
|
3800
|
+
return {
|
|
3801
|
+
status: first.done ? 502 : 200,
|
|
3802
|
+
streamed: !first.done
|
|
3803
|
+
};
|
|
3804
|
+
} finally {
|
|
3805
|
+
await iterator.return?.();
|
|
3806
|
+
}
|
|
3807
|
+
} catch (error) {
|
|
3808
|
+
if (error instanceof LlmError) return {
|
|
3809
|
+
status: error.failure.status ?? defaultStatusForProbe(error.failure.code),
|
|
3810
|
+
streamed: false,
|
|
3811
|
+
errorCode: error.failure.code,
|
|
3812
|
+
detail: error.failure.message.slice(0, 300)
|
|
3813
|
+
};
|
|
3814
|
+
return {
|
|
3815
|
+
status: 0,
|
|
3816
|
+
streamed: false,
|
|
3817
|
+
detail: `transport error: ${String(error)}`
|
|
3818
|
+
};
|
|
3819
|
+
}
|
|
3820
|
+
}
|
|
3821
|
+
};
|
|
3822
|
+
/** Statuses {@link probeEffort} reports for failures the transport raised pre-network. */
|
|
3823
|
+
function defaultStatusForProbe(code) {
|
|
3824
|
+
switch (code) {
|
|
3825
|
+
case "UNSUPPORTED_REASONING_EFFORT":
|
|
3826
|
+
case "UNSUPPORTED_CONTENT":
|
|
3827
|
+
case "INVALID_REQUEST": return 400;
|
|
3828
|
+
case "MISSING_CREDENTIAL":
|
|
3829
|
+
case "AUTH": return 401;
|
|
3830
|
+
case "RATE_LIMIT": return 429;
|
|
3831
|
+
case "QUOTA": return 402;
|
|
3832
|
+
default: return 502;
|
|
3833
|
+
}
|
|
3834
|
+
}
|
|
3835
|
+
/**
|
|
3836
|
+
* Translate one Qoder catalog row into the plugin's model-info shape.
|
|
3837
|
+
*
|
|
3838
|
+
* The Qoder vocabulary carries less than the WorkBuddy one did — no badges,
|
|
3839
|
+
* no promotions, no free/credits strings — so the billing section reports the
|
|
3840
|
+
* price factor only, spelled `x<n>`, and says so honestly when the upstream
|
|
3841
|
+
* reported nothing (`rateUnknown`). Context capacity comes from the effective
|
|
3842
|
+
* window, with the declared options kept for the card and the maximum-window
|
|
3843
|
+
* preference; a row with no capacity at all takes the transport's own default.
|
|
3844
|
+
*/
|
|
3845
|
+
function modelInfoOf(model) {
|
|
3846
|
+
const options = Object.values(model.contextOptions ?? {}).map((option) => option.tokenCount).filter((count) => typeof count === "number" && Number.isFinite(count) && count > 0);
|
|
3847
|
+
const supportedContextWindows = [...new Set(options)].sort((left, right) => left - right);
|
|
3848
|
+
const defaultOptions = Object.values(model.contextOptions ?? {}).filter((option) => option.isDefault === true);
|
|
3849
|
+
const contextWindow = model.contextWindow ?? (defaultOptions.length === 1 ? defaultOptions[0]?.tokenCount : void 0) ?? DEFAULT_CONTEXT_WINDOW;
|
|
3850
|
+
const reasoning = model.isReasoning === true || model.supportsEffort === true ? {
|
|
3851
|
+
supports: true,
|
|
3852
|
+
...model.reasoningEfforts === void 0 || model.reasoningEfforts.length === 0 ? {} : { supportedEfforts: model.reasoningEfforts.map((effort) => effort.id) },
|
|
3853
|
+
...model.defaultReasoningEffort === void 0 ? {} : { defaultEffort: model.defaultReasoningEffort },
|
|
3854
|
+
canDisableThinking: false
|
|
3855
|
+
} : void 0;
|
|
3856
|
+
const billing = model.priceFactor === void 0 ? {
|
|
3857
|
+
free: false,
|
|
3858
|
+
rateUnknown: true
|
|
3859
|
+
} : model.priceFactor === 0 ? {
|
|
3860
|
+
credits: "x0",
|
|
3861
|
+
free: true
|
|
3862
|
+
} : {
|
|
3863
|
+
credits: `x${model.priceFactor}`,
|
|
3864
|
+
free: false
|
|
3865
|
+
};
|
|
3866
|
+
return {
|
|
3867
|
+
id: model.id,
|
|
3868
|
+
name: model.name === "" ? model.id : model.name,
|
|
3869
|
+
contextWindow,
|
|
3870
|
+
...defaultOptions.length === 1 && defaultOptions[0]?.tokenCount !== void 0 ? { defaultContextWindow: defaultOptions[0].tokenCount } : {},
|
|
3871
|
+
...supportedContextWindows.length > 1 ? { supportedContextWindows } : {},
|
|
3872
|
+
maxTokens: model.maxTokens ?? DEFAULT_MAX_TOKENS,
|
|
3873
|
+
supportsImages: model.supportsImages === true,
|
|
3874
|
+
...reasoning === void 0 ? {} : { reasoning },
|
|
3875
|
+
billing,
|
|
3876
|
+
...model.source === void 0 ? {} : { source: model.source }
|
|
3877
|
+
};
|
|
3878
|
+
}
|
|
3879
|
+
/** The transport's own output cap for rows that declare none. */
|
|
3880
|
+
const DEFAULT_MAX_TOKENS = 32768;
|
|
3881
|
+
/** A conservative input budget for rows that declare none. */
|
|
3882
|
+
const DEFAULT_CONTEXT_WINDOW = 18e4;
|
|
3883
|
+
//#endregion
|
|
3884
|
+
//#region src/version.ts
|
|
3885
|
+
const QODER_CONNECT_VERSION = "0.1.0";
|
|
3886
|
+
//#endregion
|
|
3887
|
+
//#region src/host-heartbeat.ts
|
|
3888
|
+
/**
|
|
3889
|
+
* Host-side heartbeat: a small JSON file written into the plugin's data
|
|
3890
|
+
* directory once the `qoder` providers are registered. The status CLI reads it
|
|
3891
|
+
* to report whether the host bundle is alive, independent of the browser card.
|
|
3892
|
+
*
|
|
3893
|
+
* The browser (client) bundle cannot write files; its health is reported
|
|
3894
|
+
* only through `console.error` on failure (see `src/client/index.tsx`).
|
|
3895
|
+
* This asymmetry is intentional: the host is the load-bearing half, and
|
|
3896
|
+
* a missing heartbeat unambiguously means the host never started.
|
|
3897
|
+
*
|
|
3898
|
+
* @module dsh-qoder-connect/host-heartbeat
|
|
3899
|
+
*/
|
|
3900
|
+
/** Basename of the host heartbeat file inside the plugin's state directory. */
|
|
3901
|
+
const QODER_HOST_HEARTBEAT_FILENAME = ".qoder-host-heartbeat.json";
|
|
3902
|
+
/** Current on-disk heartbeat format; readers reject others. */
|
|
3903
|
+
const HEARTBEAT_FORMAT_VERSION = 1;
|
|
3904
|
+
/** Absolute path of the host heartbeat file. */
|
|
3905
|
+
function qoderHostHeartbeatPath() {
|
|
3906
|
+
return join(qoderStateDir(), QODER_HOST_HEARTBEAT_FILENAME);
|
|
3907
|
+
}
|
|
3908
|
+
/**
|
|
3909
|
+
* Write (or overwrite) the heartbeat after the host bundle registered the
|
|
3910
|
+
* provider. A failed write is non-fatal: the host is already running, and
|
|
3911
|
+
* the status CLI will simply report "heartbeat missing" rather than failing.
|
|
3912
|
+
*/
|
|
3913
|
+
async function writeHostHeartbeat() {
|
|
3914
|
+
const document = {
|
|
3915
|
+
version: HEARTBEAT_FORMAT_VERSION,
|
|
3916
|
+
package: "dsh-qoder-connect",
|
|
3917
|
+
pluginVersion: QODER_CONNECT_VERSION,
|
|
3918
|
+
registeredAt: Date.now(),
|
|
3919
|
+
pid: process.pid
|
|
3920
|
+
};
|
|
3921
|
+
try {
|
|
3922
|
+
await mkdir(qoderStateDir(), { recursive: true });
|
|
3923
|
+
await writeFile(qoderHostHeartbeatPath(), JSON.stringify(document), "utf8");
|
|
3924
|
+
} catch {}
|
|
3925
|
+
}
|
|
3926
|
+
/** Remove the heartbeat on plugin disposal so a stale file does not linger. */
|
|
3927
|
+
async function clearHostHeartbeat() {
|
|
3928
|
+
try {
|
|
3929
|
+
await rm(qoderHostHeartbeatPath(), { force: true });
|
|
3930
|
+
} catch {}
|
|
3931
|
+
}
|
|
3932
|
+
/** Read and validate the heartbeat; returns `undefined` when absent or malformed. */
|
|
3933
|
+
async function readHostHeartbeat() {
|
|
3934
|
+
let raw;
|
|
3935
|
+
try {
|
|
3936
|
+
raw = await readFile(qoderHostHeartbeatPath(), "utf8");
|
|
3937
|
+
} catch {
|
|
3938
|
+
return;
|
|
3939
|
+
}
|
|
3940
|
+
try {
|
|
3941
|
+
const parsed = JSON.parse(raw);
|
|
3942
|
+
if (parsed.version === HEARTBEAT_FORMAT_VERSION && parsed.package === "dsh-qoder-connect" && typeof parsed.registeredAt === "number" && typeof parsed.pid === "number") return {
|
|
3943
|
+
version: HEARTBEAT_FORMAT_VERSION,
|
|
3944
|
+
package: "dsh-qoder-connect",
|
|
3945
|
+
pluginVersion: typeof parsed.pluginVersion === "string" ? parsed.pluginVersion : "unknown",
|
|
3946
|
+
registeredAt: parsed.registeredAt,
|
|
3947
|
+
pid: parsed.pid
|
|
3948
|
+
};
|
|
3949
|
+
} catch {}
|
|
3950
|
+
}
|
|
3951
|
+
/**
|
|
3952
|
+
* Absolute start time (epoch ms) of the process holding `pid`, or `undefined`
|
|
3953
|
+
* when it cannot be determined (no such PID, platform lacks a readable source).
|
|
3954
|
+
*
|
|
3955
|
+
* - macOS / Linux: `ps -o lstart=` prints a local-time "EEE MMM DD HH:MM:SS YYYY";
|
|
3956
|
+
* `Date.parse` resolves it against the local clock, which matches how
|
|
3957
|
+
* `registeredAt` (a `Date.now()` absolute value) is expressed.
|
|
3958
|
+
* - Windows: WMI `CreationDate` is UTC (`YYYYMMDDHHMMSS.mmm+zzzz`); parsed with
|
|
3959
|
+
* `Date.UTC`, again comparable to `registeredAt`.
|
|
3960
|
+
*
|
|
3961
|
+
* Failures return `undefined` so callers can fall back to plain PID liveness
|
|
3962
|
+
* rather than mis-report a running host as dead.
|
|
3963
|
+
*/
|
|
3964
|
+
function processStartTimeMs(pid) {
|
|
3965
|
+
try {
|
|
3966
|
+
if (process.platform === "win32") {
|
|
3967
|
+
const m = execFileSync("wmic", [
|
|
3968
|
+
"process",
|
|
3969
|
+
"where",
|
|
3970
|
+
`processid=${pid}`,
|
|
3971
|
+
"get",
|
|
3972
|
+
"CreationDate"
|
|
3973
|
+
], {
|
|
3974
|
+
encoding: "utf8",
|
|
3975
|
+
windowsHide: true
|
|
3976
|
+
}).match(/(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})\.\d+([+-]\d{4})/);
|
|
3977
|
+
if (m === null) return void 0;
|
|
3978
|
+
const [, y, mo, d, h, mi, s] = m;
|
|
3979
|
+
const ms = Date.UTC(Number(y), Number(mo) - 1, Number(d), Number(h), Number(mi), Number(s));
|
|
3980
|
+
return Number.isFinite(ms) ? ms : void 0;
|
|
3981
|
+
}
|
|
3982
|
+
const out = execFileSync("ps", [
|
|
3983
|
+
"-o",
|
|
3984
|
+
"lstart=",
|
|
3985
|
+
"-p",
|
|
3986
|
+
String(pid)
|
|
3987
|
+
], {
|
|
3988
|
+
encoding: "utf8",
|
|
3989
|
+
env: {
|
|
3990
|
+
...process.env,
|
|
3991
|
+
LC_ALL: "C",
|
|
3992
|
+
LANG: "C"
|
|
3993
|
+
}
|
|
3994
|
+
}).trim();
|
|
3995
|
+
if (out === "") return void 0;
|
|
3996
|
+
const ms = Date.parse(out);
|
|
3997
|
+
return Number.isFinite(ms) ? ms : void 0;
|
|
3998
|
+
} catch {
|
|
3999
|
+
return;
|
|
4000
|
+
}
|
|
4001
|
+
}
|
|
4002
|
+
/**
|
|
4003
|
+
* Whether the heartbeat's PID is still alive *and* still the same process that
|
|
4004
|
+
* registered it. A stale heartbeat (host crashed without clearing the file)
|
|
4005
|
+
* is distinguished from a live host by two checks:
|
|
4006
|
+
*
|
|
4007
|
+
* 1. `process.kill(pid, 0)` — the PID exists (signal 0 tests existence).
|
|
4008
|
+
* 2. The process holding that PID started at or before `registeredAt`. A host
|
|
4009
|
+
* that registered the heartbeat must have been started before writing it,
|
|
4010
|
+
* so `start <= registeredAt`; a recycled PID belongs to an unrelated process
|
|
4011
|
+
* started after the host died, so `start > registeredAt` correctly reads dead.
|
|
4012
|
+
*
|
|
4013
|
+
* PID-only detection is not enough: after a crash the OS may hand the same PID
|
|
4014
|
+
* to an unrelated process, and the un-cleared stale heartbeat would otherwise
|
|
4015
|
+
* produce a false "Host running". When the process start time cannot be read
|
|
4016
|
+
* (e.g. unsupported platform) the check degrades to plain PID liveness.
|
|
4017
|
+
*/
|
|
4018
|
+
function isHeartbeatProcessAlive(heartbeat) {
|
|
4019
|
+
try {
|
|
4020
|
+
process.kill(heartbeat.pid, 0);
|
|
4021
|
+
} catch {
|
|
4022
|
+
return false;
|
|
4023
|
+
}
|
|
4024
|
+
const startAtMs = processStartTimeMs(heartbeat.pid);
|
|
4025
|
+
if (startAtMs === void 0) return true;
|
|
4026
|
+
return startAtMs <= heartbeat.registeredAt;
|
|
4027
|
+
}
|
|
4028
|
+
//#endregion
|
|
4029
|
+
//#region src/variants.ts
|
|
4030
|
+
/**
|
|
4031
|
+
* The two Qoder products this one plugin serves.
|
|
4032
|
+
*
|
|
4033
|
+
* Both are the same upstream in different regions, and they differ by route
|
|
4034
|
+
* (endpoints per `src/qoder/transport/endpoints.ts`), file identity, and display
|
|
4035
|
+
* identity. Everything that varies between them is collected here as one
|
|
4036
|
+
* descriptor, so no module has to carry its own `if (international)` branch and
|
|
4037
|
+
* a third variant would be a data change rather than a refactor.
|
|
4038
|
+
*
|
|
4039
|
+
* Each variant holds its own Personal Access Token: a token minted on
|
|
4040
|
+
* qoder.com does not work against the China deployment and vice versa, and the
|
|
4041
|
+
* two files, routes, and catalogs never cross.
|
|
4042
|
+
*
|
|
4043
|
+
* This module is host-side (it names files and routes). The browser half takes
|
|
4044
|
+
* the same ids and routes from the Node-free `status-paths.ts`, which stays the
|
|
4045
|
+
* single source shared by both halves.
|
|
4046
|
+
*
|
|
4047
|
+
* @module dsh-qoder-connect/variants
|
|
4048
|
+
*/
|
|
4049
|
+
/** China Qoder first: the no-suffix arm keeps the plugin's primary routes. */
|
|
4050
|
+
const QODER_VARIANTS = [{
|
|
4051
|
+
id: "qoder",
|
|
4052
|
+
displayName: "Qoder",
|
|
4053
|
+
appName: "Qoder",
|
|
4054
|
+
region: "china",
|
|
4055
|
+
ownFilename: ".qoder-auth.json",
|
|
4056
|
+
probeFilename: ".qoder-probe.json",
|
|
4057
|
+
catalogFilename: ".qoder-catalog.json",
|
|
4058
|
+
statusPath: QODER_STATUS_PATH,
|
|
4059
|
+
probePath: QODER_PROBE_PATH,
|
|
4060
|
+
authPath: QODER_AUTH_PATH
|
|
4061
|
+
}, {
|
|
4062
|
+
id: "qoder-global",
|
|
4063
|
+
displayName: "Qoder Global",
|
|
4064
|
+
appName: "Qoder Global",
|
|
4065
|
+
region: "global",
|
|
4066
|
+
ownFilename: ".qoder-global-auth.json",
|
|
4067
|
+
probeFilename: ".qoder-global-probe.json",
|
|
4068
|
+
catalogFilename: ".qoder-global-catalog.json",
|
|
4069
|
+
statusPath: QODER_GLOBAL_STATUS_PATH,
|
|
4070
|
+
probePath: QODER_GLOBAL_PROBE_PATH,
|
|
4071
|
+
authPath: QODER_GLOBAL_AUTH_PATH
|
|
4072
|
+
}];
|
|
4073
|
+
/** The China variant; the plugin's default and compatibility anchor. */
|
|
4074
|
+
const CHINA_VARIANT = QODER_VARIANTS[0];
|
|
4075
|
+
/** The international variant. */
|
|
4076
|
+
const GLOBAL_VARIANT = QODER_VARIANTS[1];
|
|
4077
|
+
/** Look up a variant by provider id. */
|
|
4078
|
+
function variantFor(id) {
|
|
4079
|
+
return QODER_VARIANTS.find((variant) => variant.id === id);
|
|
4080
|
+
}
|
|
4081
|
+
//#endregion
|
|
4082
|
+
export { QODER_GLOBAL_AUTH_PATH as A, qoderCredentialIdentity as B, probeModel as C, FALLBACK_QODER_MODELS as D, qoderCatalogPath as E, QODER_AUTH_FILENAME as F, qoderPluginDataDir as G, QODER_DATA_DIR_ENV as H, QODER_PAT_ENV_CN as I, qoderStateDir as K, QODER_PAT_ENV_GLOBAL as L, QODER_GLOBAL_STATUS_PATH as M, QODER_PROBE_PATH as N, QoderCatalog as O, QODER_STATUS_PATH as P, QoderCredentialStore as R, PROBE_EFFORT_CANDIDATES as S, QoderCatalogStore as T, QODER_DATA_DIR_NAME as U, qoderOwnAuthPath as V, qoderMachineIdPath as W, modelInfoOf as _, QODER_HOST_HEARTBEAT_FILENAME as a, createQoderTransport as b, processStartTimeMs as c, writeHostHeartbeat as d, QODER_CONNECT_VERSION as f, kindFromQoderFailure as g, classifyUpstreamError as h, variantFor as i, QODER_GLOBAL_PROBE_PATH as j, QODER_AUTH_PATH as k, qoderHostHeartbeatPath as l, QoderUpstreamClient as m, GLOBAL_VARIANT as n, clearHostHeartbeat as o, KIND_STATUS as p, QODER_VARIANTS as r, isHeartbeatProcessAlive as s, CHINA_VARIANT as t, readHostHeartbeat as u, normalizeCredits as v, randomSentinel as w, getMachineId as x, validateApiKey as y, patTail as z };
|