@chatcode/cco-llm-chatcode-config 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.md +124 -0
- package/README.zh.md +133 -0
- package/cordis.patch.yml +4 -0
- package/cordis.web.patch.yml +12 -0
- package/docs/chatcode-login.md +88 -0
- package/docs/chatcode-login.zh.md +179 -0
- package/docs/chatcode-models.md +29 -0
- package/docs/chatcode-models.zh.md +29 -0
- package/docs/chatcode-reporting.md +96 -0
- package/docs/chatcode-reporting.zh.md +96 -0
- package/docs/decisions/2026-08-31-chatcode-model-source.md +39 -0
- package/docs/decisions/2026-08-31-chatcode-model-source.zh.md +39 -0
- package/docs/decisions/2026-09-16-actual-model-adapter-routing.md +31 -0
- package/docs/decisions/2026-09-16-actual-model-adapter-routing.zh.md +31 -0
- package/lib/client.js +469 -0
- package/lib/index.d.ts +263 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +4873 -0
- package/lib/index.js.map +1 -0
- package/lib/startup-gate-BaCbWaKH.js +164 -0
- package/lib/startup-gate-BaCbWaKH.js.map +1 -0
- package/lib/web-startup.d.ts +9 -0
- package/lib/web-startup.d.ts.map +1 -0
- package/lib/web-startup.js +20 -0
- package/lib/web-startup.js.map +1 -0
- package/package.json +121 -0
- package/vendor/README.md +7 -0
- package/vendor/dsh-llm-pi-ai/LICENSE +21 -0
- package/vendor/dsh-llm-pi-ai/README.i18n.yaml +6 -0
- package/vendor/dsh-llm-pi-ai/README.md +238 -0
- package/vendor/dsh-llm-pi-ai/README.zh.md +238 -0
- package/vendor/dsh-llm-pi-ai/package.json +65 -0
- package/vendor/dsh-llm-pi-ai/src/adapter.ts +434 -0
- package/vendor/dsh-llm-pi-ai/src/auth.ts +241 -0
- package/vendor/dsh-llm-pi-ai/src/catalog.ts +908 -0
- package/vendor/dsh-llm-pi-ai/src/config.ts +478 -0
- package/vendor/dsh-llm-pi-ai/src/context.ts +349 -0
- package/vendor/dsh-llm-pi-ai/src/discovery.ts +284 -0
- package/vendor/dsh-llm-pi-ai/src/index.ts +336 -0
- package/vendor/dsh-llm-pi-ai/src/invariant.ts +30 -0
- package/vendor/dsh-llm-pi-ai/src/login.ts +161 -0
- package/vendor/dsh-llm-pi-ai/src/provider.ts +192 -0
- package/vendor/dsh-llm-pi-ai/src/replay.ts +249 -0
- package/vendor/dsh-llm-pi-ai/src/stream.ts +232 -0
- package/vendor/dsh-llm-pi-ai/tests/adapter.e2e.ts +168 -0
- package/vendor/dsh-llm-pi-ai/tests/adapter.spec.ts +1034 -0
- package/vendor/dsh-llm-pi-ai/tests/assemble.ts +32 -0
- package/vendor/dsh-llm-pi-ai/tests/auth-double.ts +39 -0
- package/vendor/dsh-llm-pi-ai/tests/auth.spec.ts +221 -0
- package/vendor/dsh-llm-pi-ai/tests/catalog.spec.ts +1220 -0
- package/vendor/dsh-llm-pi-ai/tests/config.spec.ts +111 -0
- package/vendor/dsh-llm-pi-ai/tests/context.spec.ts +474 -0
- package/vendor/dsh-llm-pi-ai/tests/convert.spec.ts +922 -0
- package/vendor/dsh-llm-pi-ai/tests/discovery.spec.ts +374 -0
- package/vendor/dsh-llm-pi-ai/tests/dynamic-config.spec.ts +241 -0
- package/vendor/dsh-llm-pi-ai/tests/fixtures/qr-code.png +0 -0
- package/vendor/dsh-llm-pi-ai/tests/loader-composition.spec.ts +244 -0
- package/vendor/dsh-llm-pi-ai/tests/login.spec.ts +198 -0
- package/vendor/dsh-llm-pi-ai/tests/mock-server.ts +82 -0
- package/vendor/dsh-llm-pi-ai/tests/provider-apis.e2e.ts +266 -0
- package/vendor/dsh-llm-pi-ai/tests/sdk-options.spec.ts +106 -0
- package/vendor/dsh-llm-pi-ai/tsconfig.json +4 -0
- package/vendor/dsh-llm-pi-ai/tsconfig.upstream.json +51 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,4873 @@
|
|
|
1
|
+
import { n as checkChatCodeStartupGate, r as startupGateFailureMessage, t as ChatCodeStartupGateService } from "./startup-gate-BaCbWaKH.js";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { Service } from "@deepseek-ai/cordis";
|
|
4
|
+
import { CONTEXT_WINDOW_EXCEEDED_CODE, EMPTY_RESPONSE_CODE, IMAGE_OFFLOAD_REQUIRED_CODE, LlmAdapter, LlmError, QUOTA_EXCEEDED_CODE, ReasoningEffortId, RetryPolicySchema, assertUsableApiKey, attributionHeaders, contentHasImage, isContextWindowExceededError, isQuotaExceededError, offloadedImageText, projectOffloadedImages, requestImageHandleText, requiredImageOffload, resolveRetryPolicy } from "@deepseek-ai/dsh-llm";
|
|
5
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
6
|
+
import { emitKeypressEvents } from "node:readline";
|
|
7
|
+
import { stdin, stdout } from "node:process";
|
|
8
|
+
import { homedir, userInfo } from "node:os";
|
|
9
|
+
import { mkdir, open, readFile, readdir, rename, unlink } from "node:fs/promises";
|
|
10
|
+
import { basename, extname, isAbsolute, join, resolve } from "node:path";
|
|
11
|
+
import { InMemoryCredentialStore, createModels, createProvider, defaultProviderAuthContext, getSupportedThinkingLevels, isContextOverflow } from "@earendil-works/pi-ai";
|
|
12
|
+
import { credentialKey, credentialRef } from "@deepseek-ai/dsh-credentials";
|
|
13
|
+
import { launchEnvironmentOf } from "@deepseek-ai/dsh-launch-environment";
|
|
14
|
+
import { getOrCreateAnonymousUserId } from "@deepseek-ai/dsh-anonymous-user-id";
|
|
15
|
+
import { DeepSeekAdapter, resolveAdapterOptions } from "@deepseek-ai/dsh-llm-deepseek";
|
|
16
|
+
import { MAX_TIMER_DELAY_MS, idleWatchdog, timeoutOf } from "@deepseek-ai/dsh-timeout";
|
|
17
|
+
import { brandString } from "@deepseek-ai/dsh-brand";
|
|
18
|
+
import { requestImageDimensions } from "@deepseek-ai/dsh-attachment";
|
|
19
|
+
import z from "@deepseek-ai/schemastery";
|
|
20
|
+
import { builtinProviders, getBuiltinModels } from "@earendil-works/pi-ai/providers/all";
|
|
21
|
+
import { anthropicMessagesApi } from "@earendil-works/pi-ai/api/anthropic-messages.lazy";
|
|
22
|
+
import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
|
|
23
|
+
import { openAIResponsesApi } from "@earendil-works/pi-ai/api/openai-responses.lazy";
|
|
24
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
25
|
+
import { setTimeout as setTimeout$1 } from "node:timers/promises";
|
|
26
|
+
import { dshHomePath } from "@deepseek-ai/dsh-home-paths";
|
|
27
|
+
//#region src/update.ts
|
|
28
|
+
/** Self-update for the global ChatCode CLI installation. @module dsh-llm-chatcode-config/update */
|
|
29
|
+
/**
|
|
30
|
+
* The public ChatCode CLI package owns both browser and terminal surfaces.
|
|
31
|
+
*/
|
|
32
|
+
const CHATCODE_CLI_PACKAGES = ["@chatcode/chatcode-cli"];
|
|
33
|
+
/** Bound one npm call so a stalled registry cannot hang the command forever. */
|
|
34
|
+
const VIEW_TIMEOUT_MS = 2e4;
|
|
35
|
+
const LIST_TIMEOUT_MS = 15e3;
|
|
36
|
+
const INSTALL_TIMEOUT_MS = 12e4;
|
|
37
|
+
/** Print one progress line to the terminal while an update runs. */
|
|
38
|
+
function log(message) {
|
|
39
|
+
process.stdout.write(`[update] ${message}\n`);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Run one npm invocation and capture its exit code and combined output.
|
|
43
|
+
* Registry, auth, and proxy resolution are npm's own business: no registry is
|
|
44
|
+
* forced, so a project's `.npmrc` (mirror, scoped registry, token) applies.
|
|
45
|
+
* @param args - npm arguments after the `npm` command word.
|
|
46
|
+
* @param timeoutMs - bound on the whole invocation.
|
|
47
|
+
*/
|
|
48
|
+
function runNpm(args, timeoutMs, signal) {
|
|
49
|
+
return new Promise((resolve) => {
|
|
50
|
+
const win = process.platform === "win32";
|
|
51
|
+
const stdio = [
|
|
52
|
+
"ignore",
|
|
53
|
+
"pipe",
|
|
54
|
+
"pipe"
|
|
55
|
+
];
|
|
56
|
+
let child;
|
|
57
|
+
if (win) {
|
|
58
|
+
const spec = args.map((argument) => /\s/.test(argument) ? JSON.stringify(argument) : argument).join(" ");
|
|
59
|
+
child = spawn(`npm ${spec}`, {
|
|
60
|
+
shell: true,
|
|
61
|
+
stdio
|
|
62
|
+
});
|
|
63
|
+
} else child = spawn("npm", args, { stdio });
|
|
64
|
+
let output = "";
|
|
65
|
+
let done = false;
|
|
66
|
+
const settle = (code) => {
|
|
67
|
+
if (done) return;
|
|
68
|
+
done = true;
|
|
69
|
+
clearTimeout(timer);
|
|
70
|
+
if (signal !== void 0) signal.removeEventListener("abort", onAbort);
|
|
71
|
+
resolve({
|
|
72
|
+
code,
|
|
73
|
+
output
|
|
74
|
+
});
|
|
75
|
+
};
|
|
76
|
+
const timer = setTimeout(() => {
|
|
77
|
+
child.kill();
|
|
78
|
+
}, timeoutMs);
|
|
79
|
+
const onAbort = () => {
|
|
80
|
+
clearTimeout(timer);
|
|
81
|
+
child.kill();
|
|
82
|
+
};
|
|
83
|
+
if (signal !== void 0) {
|
|
84
|
+
if (signal.aborted) onAbort();
|
|
85
|
+
else signal.addEventListener("abort", onAbort, { once: true });
|
|
86
|
+
}
|
|
87
|
+
const collect = (chunk) => {
|
|
88
|
+
output += chunk.toString();
|
|
89
|
+
};
|
|
90
|
+
child.stdout?.on("data", collect);
|
|
91
|
+
child.stderr?.on("data", collect);
|
|
92
|
+
child.on("error", (error) => {
|
|
93
|
+
output += String(error);
|
|
94
|
+
settle(-1);
|
|
95
|
+
});
|
|
96
|
+
child.on("close", (code) => {
|
|
97
|
+
settle(code ?? -1);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
/** Strip npm's advisory `npm warn` lines from captured combined output. */
|
|
102
|
+
function cleanNpmOutput(output) {
|
|
103
|
+
return output.split(/\r?\n/).map((line) => line.trim()).filter((line) => line !== "" && !/^npm warn\b/i.test(line)).join("\n");
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Turn npm's noisy combined output into one readable failure line. `--json`
|
|
107
|
+
* errors print `{ "error": { "code", "summary", "detail" } }` on stderr,
|
|
108
|
+
* possibly surrounded by `npm error` prose; lift the first brace block and read
|
|
109
|
+
* the summary from it, else fall back to the cleaned raw text.
|
|
110
|
+
*/
|
|
111
|
+
function describeNpmFailure(output, packageName) {
|
|
112
|
+
const start = output.indexOf("{");
|
|
113
|
+
const end = output.lastIndexOf("}");
|
|
114
|
+
if (start !== -1 && end > start) try {
|
|
115
|
+
const parsed = JSON.parse(output.slice(start, end + 1));
|
|
116
|
+
const code = typeof parsed.error?.code === "string" ? parsed.error.code : void 0;
|
|
117
|
+
const summary = typeof parsed.error?.summary === "string" ? parsed.error.summary : void 0;
|
|
118
|
+
if (code !== void 0 || summary !== void 0) return `${summary ?? code}(${packageName})`;
|
|
119
|
+
} catch {}
|
|
120
|
+
return `${cleanNpmOutput(output) || "npm view 失败"}(${packageName})`;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Fetch the latest published version of one package through `npm view`, so the
|
|
124
|
+
* same registry, scoped-registry override, and auth token the installer uses
|
|
125
|
+
* also decide what "latest" means.
|
|
126
|
+
*/
|
|
127
|
+
async function fetchLatestVersion(packageName, signal) {
|
|
128
|
+
const result = await runNpm([
|
|
129
|
+
"view",
|
|
130
|
+
packageName,
|
|
131
|
+
"version",
|
|
132
|
+
"--json"
|
|
133
|
+
], VIEW_TIMEOUT_MS, signal);
|
|
134
|
+
if (result.code !== 0 || result.output.trim() === "") throw new Error(describeNpmFailure(result.output, packageName));
|
|
135
|
+
const stdout = cleanNpmOutput(result.output);
|
|
136
|
+
try {
|
|
137
|
+
const parsed = JSON.parse(stdout);
|
|
138
|
+
if (typeof parsed === "string" && parsed !== "") return parsed;
|
|
139
|
+
} catch {}
|
|
140
|
+
throw new Error(`无法从 registry 解析最新版本(${packageName})`);
|
|
141
|
+
}
|
|
142
|
+
/** Best-effort read of one installed package version from a co-located install. */
|
|
143
|
+
function resolveCoLocatedVersion(packageName) {
|
|
144
|
+
try {
|
|
145
|
+
const manifest = createRequire(import.meta.url)(`${packageName}/package.json`);
|
|
146
|
+
return typeof manifest.version === "string" ? manifest.version : void 0;
|
|
147
|
+
} catch {
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Extract the `--json` result from npm's combined output. npm can prefix
|
|
153
|
+
* advisory `npm warn` lines on stderr (e.g. from a pnpm-injected env config),
|
|
154
|
+
* so lift the first brace block and parse just that, not the whole stream.
|
|
155
|
+
*/
|
|
156
|
+
function parseNpmJson(output) {
|
|
157
|
+
const start = output.indexOf("{");
|
|
158
|
+
const end = output.lastIndexOf("}");
|
|
159
|
+
if (start === -1 || end <= start) return void 0;
|
|
160
|
+
try {
|
|
161
|
+
return JSON.parse(output.slice(start, end + 1));
|
|
162
|
+
} catch {
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
/** Resolve one installed global package version through npm's own store. */
|
|
167
|
+
async function resolveGlobalVersion(packageName, signal) {
|
|
168
|
+
const result = await runNpm([
|
|
169
|
+
"list",
|
|
170
|
+
"-g",
|
|
171
|
+
packageName,
|
|
172
|
+
"--json",
|
|
173
|
+
"--depth=0"
|
|
174
|
+
], LIST_TIMEOUT_MS, signal);
|
|
175
|
+
if (result.output.trim() === "") return void 0;
|
|
176
|
+
const version = parseNpmJson(result.output)?.dependencies?.[packageName]?.version;
|
|
177
|
+
return typeof version === "string" ? version : void 0;
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Best-effort read of one installed package version; `undefined` when it cannot
|
|
181
|
+
* be resolved. The co-located probe first targets a dev tree, then falls back
|
|
182
|
+
* to npm's view of the global install.
|
|
183
|
+
*/
|
|
184
|
+
async function resolveCurrentVersion(packageName, signal) {
|
|
185
|
+
return resolveCoLocatedVersion(packageName) ?? await resolveGlobalVersion(packageName, signal);
|
|
186
|
+
}
|
|
187
|
+
/** Run `npm install -g <specs...>`, letting npm pick the registry it would install from. */
|
|
188
|
+
function runNpmInstall(packageSpecs, signal) {
|
|
189
|
+
return runNpm([
|
|
190
|
+
"install",
|
|
191
|
+
"-g",
|
|
192
|
+
...packageSpecs
|
|
193
|
+
], INSTALL_TIMEOUT_MS, signal);
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Check the latest version and, when newer than the running one, install the
|
|
197
|
+
* public ChatCode CLI package. Progress lines go to `options.onProgress` when given (the
|
|
198
|
+
* interactive surface renders them in a panel), else to stdout — so the
|
|
199
|
+
* `chatcode-cli --update` sync entry point keeps printing as before. Every awaited step
|
|
200
|
+
* honors `options.signal`: an abort kills the in-flight npm child and settles
|
|
201
|
+
* with the `aborted` status.
|
|
202
|
+
*/
|
|
203
|
+
async function runUpdate(options = {}) {
|
|
204
|
+
const { signal, onProgress } = options;
|
|
205
|
+
const progress = (line) => {
|
|
206
|
+
if (onProgress !== void 0) onProgress(line);
|
|
207
|
+
else log(line);
|
|
208
|
+
};
|
|
209
|
+
const aborted = () => signal?.aborted === true ? {
|
|
210
|
+
status: "aborted",
|
|
211
|
+
message: "更新已中止"
|
|
212
|
+
} : void 0;
|
|
213
|
+
progress(`开始检查更新:${CHATCODE_CLI_PACKAGES.join("、")}`);
|
|
214
|
+
const updates = [];
|
|
215
|
+
for (const name of CHATCODE_CLI_PACKAGES) {
|
|
216
|
+
if (aborted() !== void 0) return aborted();
|
|
217
|
+
const current = await resolveCurrentVersion(name, signal);
|
|
218
|
+
if (aborted() !== void 0) return aborted();
|
|
219
|
+
progress(`当前版本 ${name}:${current ?? "(未安装或未解析)"}`);
|
|
220
|
+
let latest;
|
|
221
|
+
try {
|
|
222
|
+
latest = await fetchLatestVersion(name, signal);
|
|
223
|
+
} catch (error) {
|
|
224
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
225
|
+
progress(`检查 ${name} 最新版本失败:${detail}`);
|
|
226
|
+
return {
|
|
227
|
+
status: "error",
|
|
228
|
+
message: `检查更新失败:${detail}`
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
if (aborted() !== void 0) return aborted();
|
|
232
|
+
progress(`最新版本 ${name}:${latest}`);
|
|
233
|
+
updates.push({
|
|
234
|
+
name,
|
|
235
|
+
current,
|
|
236
|
+
latest
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
if (!updates.some((update) => update.current !== update.latest)) {
|
|
240
|
+
const summary = updates.map((update) => `${update.name} ${update.latest}`).join(",");
|
|
241
|
+
progress("ChatCode CLI 已是最新版本,无需更新");
|
|
242
|
+
return {
|
|
243
|
+
status: "up-to-date",
|
|
244
|
+
message: `ChatCode CLI 已是最新版本(${summary})`
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
const packageSpecs = updates.map((update) => `${update.name}@${update.latest}`);
|
|
248
|
+
progress(`检测到新版本,开始安装:${packageSpecs.join(" ")}`);
|
|
249
|
+
const result = await runNpmInstall(packageSpecs, signal);
|
|
250
|
+
if (aborted() !== void 0) return aborted();
|
|
251
|
+
if (result.code !== 0) {
|
|
252
|
+
const detail = cleanNpmOutput(result.output) || "未知错误";
|
|
253
|
+
progress(`安装失败(exit ${result.code})`);
|
|
254
|
+
return {
|
|
255
|
+
status: "error",
|
|
256
|
+
message: `更新失败:${detail}\n请手动执行:npm install -g ${packageSpecs.join(" ")}`
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
progress("安装完成");
|
|
260
|
+
return {
|
|
261
|
+
status: "updated",
|
|
262
|
+
message: `已更新:${updates.map((update) => `${update.name} ${update.current ?? "(未知)"} → ${update.latest}`).join(",")},请重启 ChatCode CLI 以生效`
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
/** Run one npm invocation synchronously and capture its exit code and output. */
|
|
266
|
+
function syncRunNpm(args, timeoutMs) {
|
|
267
|
+
const win = process.platform === "win32";
|
|
268
|
+
const spec = args.map((argument) => /\s/.test(argument) ? JSON.stringify(argument) : argument).join(" ");
|
|
269
|
+
const result = win ? spawnSync(`npm ${spec}`, {
|
|
270
|
+
shell: true,
|
|
271
|
+
encoding: "utf8",
|
|
272
|
+
timeout: timeoutMs,
|
|
273
|
+
stdio: [
|
|
274
|
+
"ignore",
|
|
275
|
+
"pipe",
|
|
276
|
+
"pipe"
|
|
277
|
+
]
|
|
278
|
+
}) : spawnSync("npm", args, {
|
|
279
|
+
encoding: "utf8",
|
|
280
|
+
timeout: timeoutMs,
|
|
281
|
+
stdio: [
|
|
282
|
+
"ignore",
|
|
283
|
+
"pipe",
|
|
284
|
+
"pipe"
|
|
285
|
+
]
|
|
286
|
+
});
|
|
287
|
+
if (result.error !== void 0) return {
|
|
288
|
+
code: -1,
|
|
289
|
+
output: String(result.error)
|
|
290
|
+
};
|
|
291
|
+
return {
|
|
292
|
+
code: result.status ?? -1,
|
|
293
|
+
output: `${result.stdout ?? ""}${result.stderr ?? ""}`
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Print one progress line without yielding the event loop. On a Windows
|
|
298
|
+
* console `process.stdout.write` still issues the console write immediately
|
|
299
|
+
* (libuv calls WriteConsoleW synchronously), so the bytes are on screen before
|
|
300
|
+
* the caller's hard exit — and because we never yield, the concurrently booting
|
|
301
|
+
* profile stays frozen and cannot print its own logs.
|
|
302
|
+
*/
|
|
303
|
+
function syncLog(message) {
|
|
304
|
+
process.stdout.write(`[update] ${message}\n`);
|
|
305
|
+
}
|
|
306
|
+
/** Resolve one installed global package version synchronously through npm's store. */
|
|
307
|
+
function resolveGlobalVersionSync(packageName) {
|
|
308
|
+
const result = syncRunNpm([
|
|
309
|
+
"list",
|
|
310
|
+
"-g",
|
|
311
|
+
packageName,
|
|
312
|
+
"--json",
|
|
313
|
+
"--depth=0"
|
|
314
|
+
], LIST_TIMEOUT_MS);
|
|
315
|
+
if (result.output.trim() === "") return void 0;
|
|
316
|
+
const version = parseNpmJson(result.output)?.dependencies?.[packageName]?.version;
|
|
317
|
+
return typeof version === "string" ? version : void 0;
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Synchronous twin of {@link runUpdate} for `chatcode-cli --update`. Blocking the event
|
|
321
|
+
* loop on purpose: the profile's other plugins boot concurrently with this one,
|
|
322
|
+
* and `chatcode-cli --update` must finish — and exit — before any of them can print.
|
|
323
|
+
* The final message is returned, not logged, so the caller writes it once and
|
|
324
|
+
* picks the exit code.
|
|
325
|
+
*/
|
|
326
|
+
function runUpdateSync() {
|
|
327
|
+
syncLog(`开始检查更新:${CHATCODE_CLI_PACKAGES.join("、")}`);
|
|
328
|
+
const updates = [];
|
|
329
|
+
for (const name of CHATCODE_CLI_PACKAGES) {
|
|
330
|
+
const current = resolveCoLocatedVersion(name) ?? resolveGlobalVersionSync(name);
|
|
331
|
+
syncLog(`当前版本 ${name}:${current ?? "(未安装或未解析)"}`);
|
|
332
|
+
const view = syncRunNpm([
|
|
333
|
+
"view",
|
|
334
|
+
name,
|
|
335
|
+
"version",
|
|
336
|
+
"--json"
|
|
337
|
+
], VIEW_TIMEOUT_MS);
|
|
338
|
+
let latest;
|
|
339
|
+
if (view.code !== 0 || view.output.trim() === "") {
|
|
340
|
+
const detail = describeNpmFailure(view.output, name);
|
|
341
|
+
syncLog(`检查 ${name} 最新版本失败:${detail}`);
|
|
342
|
+
return {
|
|
343
|
+
status: "error",
|
|
344
|
+
message: `检查更新失败:${detail}`
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
try {
|
|
348
|
+
const parsed = JSON.parse(cleanNpmOutput(view.output));
|
|
349
|
+
if (typeof parsed !== "string" || parsed === "") throw new Error("invalid version payload");
|
|
350
|
+
latest = parsed;
|
|
351
|
+
} catch {
|
|
352
|
+
const detail = `无法从 registry 解析最新版本(${name})`;
|
|
353
|
+
syncLog(`检查 ${name} 最新版本失败:${detail}`);
|
|
354
|
+
return {
|
|
355
|
+
status: "error",
|
|
356
|
+
message: `检查更新失败:${detail}`
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
syncLog(`最新版本 ${name}:${latest}`);
|
|
360
|
+
updates.push({
|
|
361
|
+
name,
|
|
362
|
+
current,
|
|
363
|
+
latest
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
if (!updates.some((update) => update.current !== update.latest)) {
|
|
367
|
+
const summary = updates.map((update) => `${update.name} ${update.latest}`).join(",");
|
|
368
|
+
syncLog("ChatCode CLI 已是最新版本,无需更新");
|
|
369
|
+
return {
|
|
370
|
+
status: "up-to-date",
|
|
371
|
+
message: `ChatCode CLI 已是最新版本(${summary})`
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
const packageSpecs = updates.map((update) => `${update.name}@${update.latest}`);
|
|
375
|
+
syncLog(`检测到新版本,开始安装:${packageSpecs.join(" ")}`);
|
|
376
|
+
const install = syncRunNpm([
|
|
377
|
+
"install",
|
|
378
|
+
"-g",
|
|
379
|
+
...packageSpecs
|
|
380
|
+
], INSTALL_TIMEOUT_MS);
|
|
381
|
+
if (install.code !== 0) {
|
|
382
|
+
const detail = cleanNpmOutput(install.output) || "未知错误";
|
|
383
|
+
syncLog(`安装失败(exit ${install.code})`);
|
|
384
|
+
return {
|
|
385
|
+
status: "error",
|
|
386
|
+
message: `更新失败:${detail}\n请手动执行:npm install -g ${packageSpecs.join(" ")}`
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
syncLog("安装完成");
|
|
390
|
+
return {
|
|
391
|
+
status: "updated",
|
|
392
|
+
message: `已更新:${updates.map((update) => `${update.name} ${update.current ?? "(未知)"} → ${update.latest}`).join(",")},请重启 ChatCode CLI 以生效`
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
//#endregion
|
|
396
|
+
//#region src/version-check.ts
|
|
397
|
+
/**
|
|
398
|
+
* Startup version admission against the CVP version-validate endpoint.
|
|
399
|
+
*
|
|
400
|
+
* Mirrors the `yuanjing-wanma-cli` startup flow: after launch, ask CVP whether
|
|
401
|
+
* the installed ChatCode CLI package is still usable. When the server reports a newer
|
|
402
|
+
* enabled version (status 1) or a disabled current version with a rollback
|
|
403
|
+
* (status -1), present a keyboard dialog and either install the server-selected
|
|
404
|
+
* version or leave the program before the interactive surface mounts.
|
|
405
|
+
* @module dsh-llm-chatcode-config/version-check
|
|
406
|
+
*/
|
|
407
|
+
const ERROR_CODE = 500;
|
|
408
|
+
function validateUrl(cvpChatCodeApiUrl) {
|
|
409
|
+
const base = cvpChatCodeApiUrl.replace(/\/+$/u, "");
|
|
410
|
+
return new URL(`${base}/chatcode/api/v1/cli/version/validate`);
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* Interpret one 200 JSON body. Returns nothing when the server said "valid",
|
|
414
|
+
* "unknown package/version", or errored — every such case leaves startup alone,
|
|
415
|
+
* matching the reference's fail-open behavior.
|
|
416
|
+
*/
|
|
417
|
+
function decide(body) {
|
|
418
|
+
if (body.code === ERROR_CODE) return void 0;
|
|
419
|
+
const data = body.data;
|
|
420
|
+
const payload = data !== null && typeof data === "object" ? data : body;
|
|
421
|
+
if (typeof payload.version === "string" && payload.version !== "") {
|
|
422
|
+
if (payload.status === 1) return {
|
|
423
|
+
action: "upgrade",
|
|
424
|
+
version: payload.version
|
|
425
|
+
};
|
|
426
|
+
if (payload.status === -1) return {
|
|
427
|
+
action: "rollback",
|
|
428
|
+
version: payload.version
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
async function validatePackage(config, packageName, options) {
|
|
433
|
+
const resolveVersion = options.resolveVersion ?? resolveCurrentVersion;
|
|
434
|
+
let versionNum;
|
|
435
|
+
try {
|
|
436
|
+
versionNum = await resolveVersion(packageName);
|
|
437
|
+
} catch {
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
if (versionNum === void 0 || versionNum === "") return void 0;
|
|
441
|
+
const url = validateUrl(config.cvpChatCodeApiUrl);
|
|
442
|
+
url.searchParams.set("packageName", packageName);
|
|
443
|
+
url.searchParams.set("versionNum", versionNum);
|
|
444
|
+
console.log(`[version-check] 开始校验 ${packageName}@${versionNum}`);
|
|
445
|
+
const request = options.request ?? fetch;
|
|
446
|
+
let response;
|
|
447
|
+
try {
|
|
448
|
+
response = await request(url, { signal: AbortSignal.timeout(options.timeoutMs ?? 1e4) });
|
|
449
|
+
} catch (err) {
|
|
450
|
+
console.log(`[version-check] 请求失败 (${packageName}): ${err?.message ?? err}`);
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
if (!response.ok) return void 0;
|
|
454
|
+
const body = await response.json().catch(() => void 0);
|
|
455
|
+
if (body === null || typeof body !== "object") return void 0;
|
|
456
|
+
const wrapped = decide(body);
|
|
457
|
+
return wrapped === void 0 ? void 0 : {
|
|
458
|
+
...wrapped,
|
|
459
|
+
currentVersion: versionNum
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* Validate the ChatCode CLI package and collapse results into one decision.
|
|
464
|
+
* A disabled version wins over an available upgrade: an unusable install must
|
|
465
|
+
* be replaced before any forward update matters.
|
|
466
|
+
*/
|
|
467
|
+
async function checkVersions(config, options = {}) {
|
|
468
|
+
const outcomes = (await Promise.all(CHATCODE_CLI_PACKAGES.map(async (name) => {
|
|
469
|
+
const outcome = await validatePackage(config, name, options);
|
|
470
|
+
return outcome === void 0 ? void 0 : {
|
|
471
|
+
name,
|
|
472
|
+
action: outcome.action,
|
|
473
|
+
currentVersion: outcome.currentVersion,
|
|
474
|
+
targetVersion: outcome.version
|
|
475
|
+
};
|
|
476
|
+
}))).filter((entry) => entry !== void 0);
|
|
477
|
+
const rollbacks = outcomes.filter((entry) => entry.action === "rollback");
|
|
478
|
+
if (rollbacks.length > 0) return {
|
|
479
|
+
action: "rollback",
|
|
480
|
+
packages: rollbacks.map(({ name, currentVersion, targetVersion }) => ({
|
|
481
|
+
name,
|
|
482
|
+
currentVersion,
|
|
483
|
+
targetVersion
|
|
484
|
+
}))
|
|
485
|
+
};
|
|
486
|
+
const upgrades = outcomes.filter((entry) => entry.action === "upgrade");
|
|
487
|
+
if (upgrades.length > 0) return {
|
|
488
|
+
action: "upgrade",
|
|
489
|
+
packages: upgrades.map(({ name, currentVersion, targetVersion }) => ({
|
|
490
|
+
name,
|
|
491
|
+
currentVersion,
|
|
492
|
+
targetVersion
|
|
493
|
+
}))
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
/** Install the server-selected versions for one decision. */
|
|
497
|
+
async function runDecisionInstall(decision) {
|
|
498
|
+
const specs = decision.packages.map((pkg) => `${pkg.name}@${pkg.targetVersion}`);
|
|
499
|
+
const result = await runNpmInstall(specs);
|
|
500
|
+
if (result.code !== 0) return {
|
|
501
|
+
ok: false,
|
|
502
|
+
message: `安装失败:${result.output.trim() || "未知错误"}\n请手动执行:npm install -g ${specs.join(" ")}`
|
|
503
|
+
};
|
|
504
|
+
const summary = decision.packages.map((pkg) => `${pkg.name} ${pkg.targetVersion}`).join("、");
|
|
505
|
+
return {
|
|
506
|
+
ok: true,
|
|
507
|
+
message: `已${decision.action === "upgrade" ? "升级" : "更换"}到 ${summary},请重启 ChatCode CLI 以生效`
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
/** Localize one decision into the terminal dialog copy. */
|
|
511
|
+
function decisionPrompt(decision) {
|
|
512
|
+
const targets = decision.packages.map((pkg) => `${pkg.name} ${pkg.targetVersion}`).join("、");
|
|
513
|
+
if (decision.action === "upgrade") return {
|
|
514
|
+
title: "版本升级提醒",
|
|
515
|
+
message: `新版本已发布(${targets}),请升级后再使用。`,
|
|
516
|
+
performLabel: "升级",
|
|
517
|
+
cancelLabel: "不升级(退出程序)"
|
|
518
|
+
};
|
|
519
|
+
return {
|
|
520
|
+
title: "版本禁用提醒",
|
|
521
|
+
message: `当前版本已禁用(${decision.packages.map((pkg) => `${pkg.name} ${pkg.currentVersion}`).join("、")}),请更换到(${targets})后再使用。`,
|
|
522
|
+
performLabel: "更换",
|
|
523
|
+
cancelLabel: "不更换(退出程序)"
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* Show a two-option keyboard dialog: up/down arrows move the cursor, Enter
|
|
528
|
+
* chooses. Selecting the first option returns `perform`; the second option or
|
|
529
|
+
* Ctrl+C returns `exit`. Without a TTY there is no menu to read, so it fails
|
|
530
|
+
* open to `exit` and the caller leaves startup untouched.
|
|
531
|
+
*/
|
|
532
|
+
function promptVersionAction(prompt) {
|
|
533
|
+
if (stdin.isTTY !== true) return Promise.resolve("exit");
|
|
534
|
+
const labels = [prompt.performLabel, prompt.cancelLabel];
|
|
535
|
+
return new Promise((resolve) => {
|
|
536
|
+
let selected = 0;
|
|
537
|
+
let drawn = 0;
|
|
538
|
+
const computeDrawn = (lines) => {
|
|
539
|
+
const cols = stdout.columns || 80;
|
|
540
|
+
return lines.reduce((total, ln) => {
|
|
541
|
+
if (ln === "") return total + 1;
|
|
542
|
+
const width = [...ln].reduce((sum, ch) => sum + (/^[\u1100-\u11ff\u2e80-\ua4cf\uf900-\ufaff\uff00-\uffef]/u.test(ch) ? 2 : 1), 0);
|
|
543
|
+
return total + Math.max(1, Math.ceil(width / cols));
|
|
544
|
+
}, 0);
|
|
545
|
+
};
|
|
546
|
+
const render = () => {
|
|
547
|
+
const lines = [
|
|
548
|
+
prompt.title,
|
|
549
|
+
prompt.message,
|
|
550
|
+
"",
|
|
551
|
+
...labels.map((label, index) => index === selected ? `> ${label}` : ` ${label}`)
|
|
552
|
+
];
|
|
553
|
+
if (drawn > 0) stdout.write(`\x1b[${drawn}A\x1b[0J`);
|
|
554
|
+
stdout.write(`${lines.join("\n")}\n`);
|
|
555
|
+
drawn = computeDrawn(lines);
|
|
556
|
+
};
|
|
557
|
+
const finish = (value) => {
|
|
558
|
+
if (stdin.isTTY === true) stdin.setRawMode(false);
|
|
559
|
+
stdin.pause();
|
|
560
|
+
stdin.off("keypress", onKeypress);
|
|
561
|
+
stdout.write("\n");
|
|
562
|
+
resolve(value);
|
|
563
|
+
};
|
|
564
|
+
const onKeypress = (_chunk, key) => {
|
|
565
|
+
if (key.ctrl && key.name === "c") {
|
|
566
|
+
finish("exit");
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
if (key.name === "up") {
|
|
570
|
+
selected = selected === 0 ? labels.length - 1 : selected - 1;
|
|
571
|
+
render();
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
if (key.name === "down") {
|
|
575
|
+
selected = selected === labels.length - 1 ? 0 : selected + 1;
|
|
576
|
+
render();
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
if (key.name === "return") finish(selected === 0 ? "perform" : "exit");
|
|
580
|
+
};
|
|
581
|
+
emitKeypressEvents(stdin);
|
|
582
|
+
stdin.setRawMode(true);
|
|
583
|
+
stdin.resume();
|
|
584
|
+
stdin.on("keypress", onKeypress);
|
|
585
|
+
render();
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
//#endregion
|
|
589
|
+
//#region vendor/dsh-llm-pi-ai/src/auth.ts
|
|
590
|
+
/**
|
|
591
|
+
* Create private auth storage for adapters whose explicit source owns every credential.
|
|
592
|
+
* @returns an empty in-memory store and provider auth context, independent of ChatCode CLI login records.
|
|
593
|
+
*/
|
|
594
|
+
function isolatedPiAiAuth() {
|
|
595
|
+
return {
|
|
596
|
+
credentials: new InMemoryCredentialStore(),
|
|
597
|
+
authContext: defaultProviderAuthContext()
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
//#endregion
|
|
601
|
+
//#region vendor/dsh-llm-pi-ai/src/replay.ts
|
|
602
|
+
/**
|
|
603
|
+
* Durable pi-ai replay metadata and assistant-history reconstruction.
|
|
604
|
+
*
|
|
605
|
+
* ChatCode CLI content remains the durable source for text and tool calls. This
|
|
606
|
+
* module stores only the provider-native metadata needed to reconstruct a
|
|
607
|
+
* pi-ai assistant message on a later request.
|
|
608
|
+
*
|
|
609
|
+
* @module dsh-llm-pi-ai/replay
|
|
610
|
+
*/
|
|
611
|
+
/** Parse tool-call argument JSON; tolerate model malformations with {}. */
|
|
612
|
+
function parseArguments(raw) {
|
|
613
|
+
try {
|
|
614
|
+
const parsed = JSON.parse(raw);
|
|
615
|
+
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) return parsed;
|
|
616
|
+
} catch {}
|
|
617
|
+
return {};
|
|
618
|
+
}
|
|
619
|
+
/** Construct the zero usage value required by historical pi-ai messages. */
|
|
620
|
+
function emptyPiUsage() {
|
|
621
|
+
return {
|
|
622
|
+
input: 0,
|
|
623
|
+
output: 0,
|
|
624
|
+
cacheRead: 0,
|
|
625
|
+
cacheWrite: 0,
|
|
626
|
+
totalTokens: 0,
|
|
627
|
+
cost: {
|
|
628
|
+
input: 0,
|
|
629
|
+
output: 0,
|
|
630
|
+
cacheRead: 0,
|
|
631
|
+
cacheWrite: 0,
|
|
632
|
+
total: 0
|
|
633
|
+
}
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
/**
|
|
637
|
+
* Project a successful pi-ai response into the minimal durable replay state.
|
|
638
|
+
* The per-block half is index-aligned with the streamed blocks (pi-ai content
|
|
639
|
+
* order), so `BlockAssembler` prunes an entry with its block whenever assembly
|
|
640
|
+
* removes one.
|
|
641
|
+
* @param message - completed native pi-ai assistant response.
|
|
642
|
+
* @returns the versioned lossless-JSON replay projection.
|
|
643
|
+
*/
|
|
644
|
+
function toPiReplayState(message) {
|
|
645
|
+
return {
|
|
646
|
+
response: {
|
|
647
|
+
kind: "pi-ai",
|
|
648
|
+
version: 2,
|
|
649
|
+
api: message.api,
|
|
650
|
+
provider: message.provider,
|
|
651
|
+
model: message.model,
|
|
652
|
+
...message.responseModel === void 0 ? {} : { responseModel: message.responseModel },
|
|
653
|
+
...message.responseId === void 0 ? {} : { responseId: message.responseId },
|
|
654
|
+
stopReason: message.stopReason
|
|
655
|
+
},
|
|
656
|
+
blocks: message.content.map((block) => {
|
|
657
|
+
switch (block.type) {
|
|
658
|
+
case "text": return {
|
|
659
|
+
type: "text",
|
|
660
|
+
...block.textSignature === void 0 ? {} : { textSignature: block.textSignature }
|
|
661
|
+
};
|
|
662
|
+
case "thinking": return {
|
|
663
|
+
type: "reasoning",
|
|
664
|
+
...block.thinkingSignature === void 0 ? {} : { thinkingSignature: block.thinkingSignature },
|
|
665
|
+
...block.redacted === void 0 ? {} : { redacted: block.redacted }
|
|
666
|
+
};
|
|
667
|
+
case "toolCall": return {
|
|
668
|
+
type: "tool-call",
|
|
669
|
+
...block.thoughtSignature === void 0 ? {} : { thoughtSignature: block.thoughtSignature }
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
})
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
function invalidReplay(message) {
|
|
676
|
+
throw new LlmError(`invalid pi-ai replay state: ${message}`, "INVALID_REPLAY_STATE");
|
|
677
|
+
}
|
|
678
|
+
/** Validate the durable adapter-private envelope before it reaches pi-ai. */
|
|
679
|
+
function readReplayState(value) {
|
|
680
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return invalidReplay("expected a replay envelope");
|
|
681
|
+
const envelope = value;
|
|
682
|
+
const rawResponse = envelope["response"];
|
|
683
|
+
if (typeof rawResponse !== "object" || rawResponse === null || Array.isArray(rawResponse)) return invalidReplay("expected a response object");
|
|
684
|
+
const response = rawResponse;
|
|
685
|
+
if (response["kind"] !== "pi-ai") return invalidReplay("unknown state kind");
|
|
686
|
+
if (response["version"] !== 2) return invalidReplay(`unsupported version ${String(response["version"])}`);
|
|
687
|
+
for (const key of [
|
|
688
|
+
"api",
|
|
689
|
+
"provider",
|
|
690
|
+
"model"
|
|
691
|
+
]) if (typeof response[key] !== "string" || response[key].length === 0) return invalidReplay(`${key} must be a non-empty string`);
|
|
692
|
+
if (![
|
|
693
|
+
"stop",
|
|
694
|
+
"length",
|
|
695
|
+
"toolUse",
|
|
696
|
+
"error",
|
|
697
|
+
"aborted"
|
|
698
|
+
].includes(String(response["stopReason"]))) return invalidReplay("unknown stopReason");
|
|
699
|
+
if (response["responseModel"] !== void 0 && typeof response["responseModel"] !== "string") return invalidReplay("responseModel must be a string");
|
|
700
|
+
if (response["responseId"] !== void 0 && typeof response["responseId"] !== "string") return invalidReplay("responseId must be a string");
|
|
701
|
+
const blocks = envelope["blocks"];
|
|
702
|
+
if (!Array.isArray(blocks)) return invalidReplay("blocks must be an array");
|
|
703
|
+
for (const [index, value] of blocks.entries()) {
|
|
704
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return invalidReplay(`block ${index} must be an object`);
|
|
705
|
+
const block = value;
|
|
706
|
+
if (![
|
|
707
|
+
"text",
|
|
708
|
+
"reasoning",
|
|
709
|
+
"tool-call"
|
|
710
|
+
].includes(String(block["type"]))) return invalidReplay(`block ${index} has an unknown type`);
|
|
711
|
+
for (const signature of [
|
|
712
|
+
"textSignature",
|
|
713
|
+
"thinkingSignature",
|
|
714
|
+
"thoughtSignature"
|
|
715
|
+
]) if (block[signature] !== void 0 && typeof block[signature] !== "string") return invalidReplay(`block ${index} ${signature} must be a string`);
|
|
716
|
+
if (block["redacted"] !== void 0 && typeof block["redacted"] !== "boolean") return invalidReplay(`block ${index} redacted must be boolean`);
|
|
717
|
+
}
|
|
718
|
+
return {
|
|
719
|
+
response,
|
|
720
|
+
blocks
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
/** Convert provider-neutral blocks without trusting them as same-model replay. */
|
|
724
|
+
function foreignAssistant(message) {
|
|
725
|
+
const source = message.source.kind === "model" ? message.source : void 0;
|
|
726
|
+
const content = [];
|
|
727
|
+
for (const block of message.content) switch (block.type) {
|
|
728
|
+
case "text":
|
|
729
|
+
content.push({
|
|
730
|
+
type: "text",
|
|
731
|
+
text: block.text
|
|
732
|
+
});
|
|
733
|
+
break;
|
|
734
|
+
case "reasoning":
|
|
735
|
+
content.push({
|
|
736
|
+
type: "thinking",
|
|
737
|
+
thinking: block.text
|
|
738
|
+
});
|
|
739
|
+
break;
|
|
740
|
+
case "tool-call":
|
|
741
|
+
content.push({
|
|
742
|
+
type: "toolCall",
|
|
743
|
+
id: block.id,
|
|
744
|
+
name: block.name,
|
|
745
|
+
arguments: parseArguments(block.arguments)
|
|
746
|
+
});
|
|
747
|
+
break;
|
|
748
|
+
case "image": throw new LlmError("pi-ai chat history cannot represent structured assistant image output", "UNSUPPORTED_CONTENT");
|
|
749
|
+
}
|
|
750
|
+
return {
|
|
751
|
+
role: "assistant",
|
|
752
|
+
content,
|
|
753
|
+
api: "dsh-foreign",
|
|
754
|
+
provider: source?.provider ?? "dsh-foreign",
|
|
755
|
+
model: source?.model ?? "dsh-foreign",
|
|
756
|
+
usage: emptyPiUsage(),
|
|
757
|
+
stopReason: content.some((piece) => piece.type === "toolCall") ? "toolUse" : "stop",
|
|
758
|
+
timestamp: 0
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
/** Recombine durable ChatCode CLI content with validated pi-ai replay metadata. */
|
|
762
|
+
function replayedAssistant(message, source, rawState) {
|
|
763
|
+
const state = readReplayState(rawState);
|
|
764
|
+
if (state.response.provider !== source.provider) return invalidReplay("provider does not match assistant source");
|
|
765
|
+
if (state.response.model !== source.model) return invalidReplay("model does not match assistant source");
|
|
766
|
+
if (state.blocks.length !== message.content.length) return invalidReplay("block count does not match assistant content");
|
|
767
|
+
return {
|
|
768
|
+
role: "assistant",
|
|
769
|
+
content: message.content.map((block, index) => {
|
|
770
|
+
const replay = state.blocks[index];
|
|
771
|
+
if (replay === void 0 || replay.type !== block.type) return invalidReplay(`block ${index} does not match assistant content`);
|
|
772
|
+
switch (block.type) {
|
|
773
|
+
case "text": return {
|
|
774
|
+
type: "text",
|
|
775
|
+
text: block.text,
|
|
776
|
+
...replay.type === "text" && replay.textSignature !== void 0 ? { textSignature: replay.textSignature } : {}
|
|
777
|
+
};
|
|
778
|
+
case "reasoning": return {
|
|
779
|
+
type: "thinking",
|
|
780
|
+
thinking: block.text,
|
|
781
|
+
...replay.type === "reasoning" && replay.thinkingSignature !== void 0 ? { thinkingSignature: replay.thinkingSignature } : {},
|
|
782
|
+
...replay.type === "reasoning" && replay.redacted !== void 0 ? { redacted: replay.redacted } : {}
|
|
783
|
+
};
|
|
784
|
+
case "tool-call": return {
|
|
785
|
+
type: "toolCall",
|
|
786
|
+
id: block.id,
|
|
787
|
+
name: block.name,
|
|
788
|
+
arguments: parseArguments(block.arguments),
|
|
789
|
+
...replay.type === "tool-call" && replay.thoughtSignature !== void 0 ? { thoughtSignature: replay.thoughtSignature } : {}
|
|
790
|
+
};
|
|
791
|
+
/* v8 ignore next -- readReplayState rejects unknown replay tags, so an equal plugin-added ChatCode CLI tag cannot reach this switch */
|
|
792
|
+
default: return invalidReplay(`block ${index} has an unsupported ChatCode CLI type`);
|
|
793
|
+
}
|
|
794
|
+
}),
|
|
795
|
+
api: state.response.api,
|
|
796
|
+
provider: state.response.provider,
|
|
797
|
+
model: state.response.model,
|
|
798
|
+
...state.response.responseModel === void 0 ? {} : { responseModel: state.response.responseModel },
|
|
799
|
+
...state.response.responseId === void 0 ? {} : { responseId: state.response.responseId },
|
|
800
|
+
usage: emptyPiUsage(),
|
|
801
|
+
stopReason: state.response.stopReason,
|
|
802
|
+
timestamp: 0
|
|
803
|
+
};
|
|
804
|
+
}
|
|
805
|
+
/**
|
|
806
|
+
* Convert one durable ChatCode CLI assistant message into pi-ai history.
|
|
807
|
+
*
|
|
808
|
+
* Durable content is the authoritative record; replay metadata only restores
|
|
809
|
+
* native fidelity (ids, signatures). A replay state this build cannot use —
|
|
810
|
+
* another adapter's kind, another version, a malformed value, or metadata that
|
|
811
|
+
* no longer matches the content — therefore degrades the one message to
|
|
812
|
+
* provider-neutral history instead of failing the request.
|
|
813
|
+
* @param message - assistant content with required source and optional adapter-owned replay metadata.
|
|
814
|
+
* @param onDegrade - called with the diagnostic reason when an unusable replay
|
|
815
|
+
* state falls back to provider-neutral conversion.
|
|
816
|
+
* @returns a native pi-ai assistant message reconstructed from durable content.
|
|
817
|
+
*/
|
|
818
|
+
function toPiAssistant(message, onDegrade) {
|
|
819
|
+
const source = message.source;
|
|
820
|
+
if (source.kind !== "model" || source.replayState === void 0) return foreignAssistant(message);
|
|
821
|
+
try {
|
|
822
|
+
return replayedAssistant(message, source, source.replayState);
|
|
823
|
+
} catch (error) {
|
|
824
|
+
/* v8 ignore next -- replayedAssistant throws only INVALID_REPLAY_STATE LlmErrors; the
|
|
825
|
+
guard keeps a future non-replay failure loud instead of silently degrading it */
|
|
826
|
+
if (!(error instanceof LlmError) || error.code !== "INVALID_REPLAY_STATE") throw error;
|
|
827
|
+
onDegrade?.(error.message);
|
|
828
|
+
return foreignAssistant(message);
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
//#endregion
|
|
832
|
+
//#region vendor/dsh-llm-pi-ai/src/catalog.ts
|
|
833
|
+
/**
|
|
834
|
+
* Materialization of one provider route's model catalog. The installed pi-ai
|
|
835
|
+
* catalog supplies defaults keyed by model id, and a profile's own model
|
|
836
|
+
* entries override them field by field, so a route naming a catalog provider
|
|
837
|
+
* stays configuration-free while a route pi-ai has never heard of is fully
|
|
838
|
+
* describable from `settings.yaml`.
|
|
839
|
+
*
|
|
840
|
+
* Every pi-ai `Model` field the harness cannot default is required here rather
|
|
841
|
+
* than at request time: an unserviceable route fails while its configuration is
|
|
842
|
+
* being resolved, which is the earliest point that can name the offending key.
|
|
843
|
+
*
|
|
844
|
+
* @module dsh-llm-pi-ai/catalog
|
|
845
|
+
*/
|
|
846
|
+
/**
|
|
847
|
+
* Pricing for a model the installed catalog does not describe. The harness
|
|
848
|
+
* never reads pi-ai's cost metadata — `replay.ts` zeroes it and no consumer
|
|
849
|
+
* reports spend — so this is the absence of a fact, not a configurable rate.
|
|
850
|
+
*/
|
|
851
|
+
const NO_COST = {
|
|
852
|
+
input: 0,
|
|
853
|
+
output: 0,
|
|
854
|
+
cacheRead: 0,
|
|
855
|
+
cacheWrite: 0
|
|
856
|
+
};
|
|
857
|
+
/** Every request modality a profile may declare. */
|
|
858
|
+
const MODALITIES = Object.keys({
|
|
859
|
+
text: true,
|
|
860
|
+
image: true
|
|
861
|
+
});
|
|
862
|
+
/**
|
|
863
|
+
* One entry's modality list, or `undefined` when it states no answer. Absent
|
|
864
|
+
* and empty mean the same thing — `[]` describes a model that accepts nothing
|
|
865
|
+
* and could serve no request — which is what makes an entry naming a catalog
|
|
866
|
+
* model without declaring modalities keep the catalog's, since the config
|
|
867
|
+
* schema materializes `[]` for an absent array.
|
|
868
|
+
* @param configured - the list a `models` or `modelOverrides` entry supplied.
|
|
869
|
+
* @returns the declared modalities, or `undefined` to ask the next level.
|
|
870
|
+
*/
|
|
871
|
+
function declaredInput(configured) {
|
|
872
|
+
return configured === void 0 || configured.length === 0 ? void 0 : [...configured];
|
|
873
|
+
}
|
|
874
|
+
/** Every pi-ai thinking level a profile may declare, in escalation order. */
|
|
875
|
+
const THINKING_LEVELS = Object.keys({
|
|
876
|
+
off: true,
|
|
877
|
+
minimal: true,
|
|
878
|
+
low: true,
|
|
879
|
+
medium: true,
|
|
880
|
+
high: true,
|
|
881
|
+
xhigh: true,
|
|
882
|
+
max: true
|
|
883
|
+
});
|
|
884
|
+
/** Reasoning-dispatch wire formats a profile may name, most-reached first. */
|
|
885
|
+
const SUPPORTED_THINKING_FORMATS = Object.keys({
|
|
886
|
+
"openai": true,
|
|
887
|
+
"deepseek": true,
|
|
888
|
+
"openrouter": true,
|
|
889
|
+
"together": true,
|
|
890
|
+
"baseten": true,
|
|
891
|
+
"zai": true,
|
|
892
|
+
"qwen": true,
|
|
893
|
+
"chat-template": true,
|
|
894
|
+
"qwen-chat-template": true,
|
|
895
|
+
"string-thinking": true,
|
|
896
|
+
"ant-ling": true
|
|
897
|
+
});
|
|
898
|
+
/** The output-cap field spellings a profile may name. */
|
|
899
|
+
const MAX_TOKENS_FIELDS = Object.keys({
|
|
900
|
+
max_completion_tokens: true,
|
|
901
|
+
max_tokens: true
|
|
902
|
+
});
|
|
903
|
+
/** The prompt-cache marker conventions a profile may name. */
|
|
904
|
+
const CACHE_CONTROL_FORMATS = Object.keys({ anthropic: true });
|
|
905
|
+
/** The request-state placeholders a profile may name. */
|
|
906
|
+
const CHAT_TEMPLATE_VARS = Object.keys({
|
|
907
|
+
"thinking.enabled": true,
|
|
908
|
+
"thinking.effort": true
|
|
909
|
+
});
|
|
910
|
+
let providerIndex;
|
|
911
|
+
/**
|
|
912
|
+
* Installed catalog providers by id, constructed once. Each entry owns the API
|
|
913
|
+
* implementations for its own models, which is why a catalog route reuses this
|
|
914
|
+
* provider instead of being rebuilt from parts.
|
|
915
|
+
* @returns the catalog provider index.
|
|
916
|
+
*/
|
|
917
|
+
function catalogProviders() {
|
|
918
|
+
providerIndex ??= new Map(builtinProviders().map((provider) => [provider.id, provider]));
|
|
919
|
+
return providerIndex;
|
|
920
|
+
}
|
|
921
|
+
/**
|
|
922
|
+
* The installed catalog provider for one route, when pi-ai ships one.
|
|
923
|
+
* @param provider - provider route key.
|
|
924
|
+
* @returns the catalog provider, or `undefined` for a route pi-ai does not ship.
|
|
925
|
+
*/
|
|
926
|
+
function catalogProvider(provider) {
|
|
927
|
+
return catalogProviders().get(provider);
|
|
928
|
+
}
|
|
929
|
+
/**
|
|
930
|
+
* The installed catalog models for one route, indexed by model id.
|
|
931
|
+
* @param provider - provider route key.
|
|
932
|
+
* @returns catalog models by id; empty for a route pi-ai does not ship.
|
|
933
|
+
*/
|
|
934
|
+
function catalogModels(provider) {
|
|
935
|
+
if (!catalogProviders().has(provider)) return /* @__PURE__ */ new Map();
|
|
936
|
+
const models = getBuiltinModels(provider);
|
|
937
|
+
return new Map(models.map((model) => [model.id, model]));
|
|
938
|
+
}
|
|
939
|
+
/**
|
|
940
|
+
* Disposition of every `OpenAICompletionsCompat` field. The `Record` key type
|
|
941
|
+
* is a drift gate: a pi-ai upgrade that adds a field fails compilation here
|
|
942
|
+
* until it is classified, so the offer never silently lags the upstream set.
|
|
943
|
+
*/
|
|
944
|
+
const COMPLETIONS_COMPAT_GATE = {
|
|
945
|
+
supportsStore: "offer",
|
|
946
|
+
supportsDeveloperRole: "offer",
|
|
947
|
+
supportsReasoningEffort: "offer",
|
|
948
|
+
supportsUsageInStreaming: "offer",
|
|
949
|
+
supportsFinishReason: "offer",
|
|
950
|
+
maxTokensField: "offer",
|
|
951
|
+
requiresToolResultName: "offer",
|
|
952
|
+
requiresAssistantAfterToolResult: "offer",
|
|
953
|
+
requiresThinkingAsText: "offer",
|
|
954
|
+
requiresReasoningContentOnAssistantMessages: "offer",
|
|
955
|
+
thinkingFormat: "offer",
|
|
956
|
+
chatTemplateKwargs: "offer",
|
|
957
|
+
chatTemplateArgs: "offer",
|
|
958
|
+
supportsThinkingTokenBudget: "offer",
|
|
959
|
+
supportsStrictMode: "offer",
|
|
960
|
+
cacheControlFormat: "offer",
|
|
961
|
+
supportsLongCacheRetention: "offer",
|
|
962
|
+
openRouterRouting: "withhold",
|
|
963
|
+
vercelGatewayRouting: "withhold",
|
|
964
|
+
zaiToolStream: "withhold",
|
|
965
|
+
supportsOpenAIGrammarTools: "withhold",
|
|
966
|
+
sendSessionAffinityHeaders: "withhold",
|
|
967
|
+
deferredToolsMode: "withhold",
|
|
968
|
+
sessionAffinityFormat: "withhold"
|
|
969
|
+
};
|
|
970
|
+
/** Disposition of every `OpenAIResponsesCompat` field; a drift gate like the one above. */
|
|
971
|
+
const RESPONSES_COMPAT_GATE = {
|
|
972
|
+
supportsDeveloperRole: "offer",
|
|
973
|
+
supportsStrictMode: "offer",
|
|
974
|
+
supportsLongCacheRetention: "offer",
|
|
975
|
+
sessionAffinityFormat: "withhold",
|
|
976
|
+
supportsOpenAIGrammarTools: "withhold",
|
|
977
|
+
supportsAdditionalTools: "withhold",
|
|
978
|
+
supportsToolSearch: "withhold",
|
|
979
|
+
supportsExplicitPromptCacheMode: "withhold"
|
|
980
|
+
};
|
|
981
|
+
/**
|
|
982
|
+
* The compat gate of every wire protocol a profile may configure.
|
|
983
|
+
*
|
|
984
|
+
* Keyed by protocol, but grouped by pi-ai's compat *type*: the three Responses
|
|
985
|
+
* protocols share `OpenAIResponsesCompat`, so a switch settable on one is
|
|
986
|
+
* settable on all three. Keying by protocol alone would refuse
|
|
987
|
+
* `azure-openai-responses` and `openai-codex-responses` the fields their own
|
|
988
|
+
* models declare.
|
|
989
|
+
*/
|
|
990
|
+
const COMPAT_GATES = {
|
|
991
|
+
"openai-completions": COMPLETIONS_COMPAT_GATE,
|
|
992
|
+
"openai-responses": RESPONSES_COMPAT_GATE,
|
|
993
|
+
"azure-openai-responses": RESPONSES_COMPAT_GATE,
|
|
994
|
+
"openai-codex-responses": RESPONSES_COMPAT_GATE,
|
|
995
|
+
"anthropic-messages": {
|
|
996
|
+
supportsEagerToolInputStreaming: "offer",
|
|
997
|
+
supportsLongCacheRetention: "offer",
|
|
998
|
+
supportsCacheControlOnTools: "offer",
|
|
999
|
+
supportsTemperature: "offer",
|
|
1000
|
+
forceAdaptiveThinking: "offer",
|
|
1001
|
+
allowEmptySignature: "offer",
|
|
1002
|
+
supportsStrictTools: "offer",
|
|
1003
|
+
sendSessionAffinityHeaders: "withhold",
|
|
1004
|
+
supportsToolReferences: "withhold"
|
|
1005
|
+
},
|
|
1006
|
+
"bedrock-converse-stream": { supportsStrictMode: "offer" }
|
|
1007
|
+
};
|
|
1008
|
+
/**
|
|
1009
|
+
* The compat gate of one resolved protocol. A `string` lookup rather than a
|
|
1010
|
+
* keyed read: a route's `api` is configuration, so it may name a protocol
|
|
1011
|
+
* pi-ai gives no compat type — or none at all.
|
|
1012
|
+
* @param api - resolved wire protocol.
|
|
1013
|
+
* @returns that protocol's field gate, or `undefined` when it takes no compat.
|
|
1014
|
+
*/
|
|
1015
|
+
function compatGate(api) {
|
|
1016
|
+
return COMPAT_GATES[api];
|
|
1017
|
+
}
|
|
1018
|
+
/**
|
|
1019
|
+
* The compat entries a profile actually set.
|
|
1020
|
+
*
|
|
1021
|
+
* schemastery materializes an absent dict as `{}` — the behavior
|
|
1022
|
+
* `reasoningEfforts` works around with a union — so every parsed profile
|
|
1023
|
+
* carries both template-argument keys whether or not anyone wrote them. An
|
|
1024
|
+
* empty one states nothing here: it would send no arguments, which is exactly
|
|
1025
|
+
* what leaving the field out does, so absent and empty are the same request
|
|
1026
|
+
* and neither may make a route look like it configured a switch. A valueless
|
|
1027
|
+
* scalar is the other thing schemastery lets through, and it is refused by
|
|
1028
|
+
* {@link assertOfferedCompatFields} before this runs rather than filtered.
|
|
1029
|
+
* @param compat - the configured switches, when any.
|
|
1030
|
+
* @returns the entries carrying a value, in declaration order.
|
|
1031
|
+
*/
|
|
1032
|
+
function configuredCompatEntries(compat) {
|
|
1033
|
+
return Object.entries(compat ?? {}).flatMap(([field, value]) => {
|
|
1034
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) && Object.keys(value).length === 0 ? [] : [[field, value]];
|
|
1035
|
+
});
|
|
1036
|
+
}
|
|
1037
|
+
/**
|
|
1038
|
+
* The protocols offering one compat field, in {@link COMPAT_GATES} order.
|
|
1039
|
+
* @param field - configured compat field name.
|
|
1040
|
+
* @returns the protocols whose compat takes it; empty when none does, which
|
|
1041
|
+
* is either a withheld field or a name no upstream compat type declares.
|
|
1042
|
+
*/
|
|
1043
|
+
function compatProtocols(field) {
|
|
1044
|
+
return Object.entries(COMPAT_GATES).flatMap(([api, gate]) => gate[field] === "offer" ? [api] : []);
|
|
1045
|
+
}
|
|
1046
|
+
/**
|
|
1047
|
+
* The compat fields one protocol offers, for a diagnostic that has to show
|
|
1048
|
+
* what was available instead of the name that missed.
|
|
1049
|
+
* @param api - wire protocol.
|
|
1050
|
+
* @returns the offered field names, or an empty list for a protocol taking no compat.
|
|
1051
|
+
*/
|
|
1052
|
+
function offeredCompatFields(api) {
|
|
1053
|
+
return Object.entries(compatGate(api) ?? {}).flatMap(([field, disposition]) => disposition === "offer" ? [field] : []);
|
|
1054
|
+
}
|
|
1055
|
+
/**
|
|
1056
|
+
* Every offered field name, deduplicated, for the one diagnostic that cannot
|
|
1057
|
+
* narrow by protocol: the vocabulary check runs before any protocol resolves,
|
|
1058
|
+
* which is what lets it refuse a misspelling on a route whose models would
|
|
1059
|
+
* never have reached the protocol that declares the intended field.
|
|
1060
|
+
* @returns the offered field names across every protocol, in gate order.
|
|
1061
|
+
*/
|
|
1062
|
+
function allOfferedCompatFields() {
|
|
1063
|
+
const fields = /* @__PURE__ */ new Set();
|
|
1064
|
+
for (const api of Object.keys(COMPAT_GATES)) for (const field of offeredCompatFields(api)) fields.add(field);
|
|
1065
|
+
return [...fields];
|
|
1066
|
+
}
|
|
1067
|
+
/**
|
|
1068
|
+
* Reject a compat key no protocol offers. Runs before any protocol is
|
|
1069
|
+
* resolved, so a withheld field or a misspelling fails even on a route whose
|
|
1070
|
+
* models never reach the protocol that would have taken it — the alternative
|
|
1071
|
+
* being the silent drop that let an unreadable switch look applied.
|
|
1072
|
+
* @param provider - provider route key, for diagnostics.
|
|
1073
|
+
* @param site - the configuration site, for diagnostics.
|
|
1074
|
+
* @param compat - the configured switches, when any.
|
|
1075
|
+
* @throws Error naming the offending key.
|
|
1076
|
+
*/
|
|
1077
|
+
function assertOfferedCompatFields(provider, site, compat) {
|
|
1078
|
+
for (const [field, value] of Object.entries(compat ?? {})) {
|
|
1079
|
+
if (compatProtocols(field).length === 0) {
|
|
1080
|
+
if (Object.values(COMPAT_GATES).some((gate) => gate[field] !== void 0)) invalid$1(provider, `${site} sets compat "${field}", which is not configurable here: pi-ai's installed catalog sets it for the vendors that need it, so name that provider as the route instead`);
|
|
1081
|
+
invalid$1(provider, `${site} sets compat "${field}", which no wire protocol declares; the configurable switches are ${allOfferedCompatFields().join(", ")}`);
|
|
1082
|
+
}
|
|
1083
|
+
if (value == null) invalid$1(provider, `${site} sets compat "${field}" with no value; give it one, or remove the key to leave the field to the next layer — the installed catalog entry, then pi-ai's own detection`);
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
/** Report a route the deployment cannot serve, naming the settings key at fault. */
|
|
1087
|
+
function invalid$1(provider, detail) {
|
|
1088
|
+
throw new Error(`llm-pi-ai: provider "${provider}" ${detail}`);
|
|
1089
|
+
}
|
|
1090
|
+
/**
|
|
1091
|
+
* The one wire protocol a catalog route's shipped models agree on. This is what
|
|
1092
|
+
* lets a deployment add a model the installed catalog has not caught up with —
|
|
1093
|
+
* a provider's newest release — without restating the protocol its siblings
|
|
1094
|
+
* already use. A route whose shipped models disagree (an OpenAI-style catalog
|
|
1095
|
+
* spanning Responses and Chat Completions) has no such answer, so a model it
|
|
1096
|
+
* does not describe must name its protocol at the route.
|
|
1097
|
+
*/
|
|
1098
|
+
function sharedCatalogApi(defaults) {
|
|
1099
|
+
const apis = /* @__PURE__ */ new Set();
|
|
1100
|
+
for (const model of defaults.values()) apis.add(model.api);
|
|
1101
|
+
return apis.size === 1 ? [...apis][0] : void 0;
|
|
1102
|
+
}
|
|
1103
|
+
/**
|
|
1104
|
+
* Resolve one model's reasoning capability from its declared efforts.
|
|
1105
|
+
*
|
|
1106
|
+
* A declared dict translates to pi-ai's `thinkingLevelMap` with every level
|
|
1107
|
+
* decided explicitly: declared levels carry their wire spelling, undeclared
|
|
1108
|
+
* levels are pinned to `null` (unsupported). Pinning matters because pi-ai's
|
|
1109
|
+
* own defaulting is asymmetric — an absent key means "supported" for the five
|
|
1110
|
+
* base levels but "unsupported" for `xhigh`/`max` — and a profile author
|
|
1111
|
+
* should not need to know that. A declared `off` with no value is the one
|
|
1112
|
+
* exception: it stays absent from the map, which pi-ai reads as "supported,
|
|
1113
|
+
* send nothing" — the correct dispatch where not thinking is the parameter's
|
|
1114
|
+
* absence — while `off` with a value sends that value.
|
|
1115
|
+
* @param provider - provider route key, for diagnostics.
|
|
1116
|
+
* @param entry - the configured model entry.
|
|
1117
|
+
* @param base - the installed catalog entry of the same id, when one exists.
|
|
1118
|
+
* @returns the reasoning fields the materialized model carries.
|
|
1119
|
+
*/
|
|
1120
|
+
function resolveModelReasoning(provider, entry, base) {
|
|
1121
|
+
const efforts = entry.reasoningEfforts;
|
|
1122
|
+
if (efforts === void 0) return { reasoning: base?.reasoning ?? false };
|
|
1123
|
+
if (efforts === false) return { reasoning: false };
|
|
1124
|
+
if (efforts === null || Object.keys(efforts).length === 0) invalid$1(provider, `model "${entry.id}" has an empty reasoningEfforts; declare the offered levels, set false for a non-reasoning model, or omit the field to keep the installed catalog's capability`);
|
|
1125
|
+
const declared = THINKING_LEVELS.flatMap((level) => {
|
|
1126
|
+
const wire = efforts[level];
|
|
1127
|
+
return wire === void 0 ? [] : [[level, wire]];
|
|
1128
|
+
});
|
|
1129
|
+
for (const [level, wire] of declared) if (wire === null) {
|
|
1130
|
+
if (level !== "off") invalid$1(provider, `model "${entry.id}" reasoningEfforts.${level} needs the wire value dispatch should send; only "off" may leave it empty`);
|
|
1131
|
+
} else if (wire.length === 0) invalid$1(provider, `model "${entry.id}" reasoningEfforts.${level} must not be an empty string`);
|
|
1132
|
+
if (!declared.some(([level]) => level !== "off")) invalid$1(provider, `model "${entry.id}" reasoningEfforts offers no level beyond "off"; declare a thinking level, or set reasoningEfforts to false for a non-reasoning model`);
|
|
1133
|
+
const map = {};
|
|
1134
|
+
for (const level of THINKING_LEVELS) {
|
|
1135
|
+
const wire = efforts[level];
|
|
1136
|
+
if (wire === void 0) map[level] = null;
|
|
1137
|
+
else if (wire !== null) map[level] = wire;
|
|
1138
|
+
}
|
|
1139
|
+
return {
|
|
1140
|
+
reasoning: true,
|
|
1141
|
+
thinkingLevelMap: map
|
|
1142
|
+
};
|
|
1143
|
+
}
|
|
1144
|
+
/**
|
|
1145
|
+
* Resolve one model's compat block from the profile's switches.
|
|
1146
|
+
*
|
|
1147
|
+
* A model switch wins over the route switch field by field; whatever neither
|
|
1148
|
+
* sets keeps the installed entry's value, and a field no layer decides falls
|
|
1149
|
+
* through to pi-ai's own detection. A model-level switch its protocol does not
|
|
1150
|
+
* take fails resolution — about one named model it can only be a mistake —
|
|
1151
|
+
* while a route-level one skips past such models, since a route default must
|
|
1152
|
+
* stay settable on a route whose models do not all speak one protocol. Every
|
|
1153
|
+
* field reaching here is offered by some protocol; {@link
|
|
1154
|
+
* assertOfferedCompatFields} has already refused the rest.
|
|
1155
|
+
* @param provider - provider route key, for diagnostics.
|
|
1156
|
+
* @param entry - the configured model entry.
|
|
1157
|
+
* @param route - the route-level switches, when any.
|
|
1158
|
+
* @param base - the installed catalog entry of the same id, when one exists.
|
|
1159
|
+
* @param api - the model's resolved wire protocol.
|
|
1160
|
+
* @returns a `compat` field to spread into the model, or nothing.
|
|
1161
|
+
*/
|
|
1162
|
+
function resolveModelCompat(provider, entry, route, base, api) {
|
|
1163
|
+
const gate = compatGate(api);
|
|
1164
|
+
const configured = {};
|
|
1165
|
+
for (const [field, value] of configuredCompatEntries(route)) {
|
|
1166
|
+
if (gate?.[field] !== "offer") continue;
|
|
1167
|
+
configured[field] = value;
|
|
1168
|
+
}
|
|
1169
|
+
for (const [field, value] of configuredCompatEntries(entry.compat)) {
|
|
1170
|
+
if (gate?.[field] !== "offer") {
|
|
1171
|
+
const offered = offeredCompatFields(api);
|
|
1172
|
+
invalid$1(provider, `model "${entry.id}" sets compat "${field}", but its api is "${api}", which does not take it; that switch exists on ${compatProtocols(field).join(", ")}, and "${api}" offers ${offered.length === 0 ? "no configurable compat" : offered.join(", ")}`);
|
|
1173
|
+
}
|
|
1174
|
+
configured[field] = value;
|
|
1175
|
+
}
|
|
1176
|
+
if (Object.keys(configured).length === 0) return {};
|
|
1177
|
+
return { compat: {
|
|
1178
|
+
...base?.api === api ? base.compat : void 0,
|
|
1179
|
+
...configured
|
|
1180
|
+
} };
|
|
1181
|
+
}
|
|
1182
|
+
/**
|
|
1183
|
+
* Materialize one route's catalog by merging the installed catalog defaults
|
|
1184
|
+
* under the configured entries. A route with no configured `models` serves the
|
|
1185
|
+
* installed catalog unchanged, which is what keeps an existing
|
|
1186
|
+
* `providers: { deepseek: { apiKeyEnv: … } }` profile working untouched.
|
|
1187
|
+
* @param request - the route-level catalog facts.
|
|
1188
|
+
* @returns the materialized models and the explicitly configured request caps.
|
|
1189
|
+
*/
|
|
1190
|
+
function resolveRouteModels(request) {
|
|
1191
|
+
const { provider } = request;
|
|
1192
|
+
const defaults = catalogModels(provider);
|
|
1193
|
+
const providerBaseUrl = catalogProvider(provider)?.baseUrl;
|
|
1194
|
+
const configured = request.models ?? [];
|
|
1195
|
+
const overrides = request.modelOverrides ?? {};
|
|
1196
|
+
for (const [id, override] of Object.entries(overrides)) {
|
|
1197
|
+
if (id.length === 0) invalid$1(provider, "has a modelOverrides entry with an empty model id");
|
|
1198
|
+
if (defaults.size === 0) invalid$1(provider, `sets modelOverrides for "${id}", but the installed catalog does not describe this route; a declared route spells every model out in its models list`);
|
|
1199
|
+
if (configured.length > 0) invalid$1(provider, `sets modelOverrides for "${id}" beside a models list; models already replaces the served catalog, so declare the fields on its entries`);
|
|
1200
|
+
if (!defaults.has(id)) invalid$1(provider, `modelOverrides names "${id}", which the installed catalog does not describe`);
|
|
1201
|
+
if ("id" in override) invalid$1(provider, `modelOverrides entry "${id}" sets "id", which is the dict key`);
|
|
1202
|
+
}
|
|
1203
|
+
const entries = configured.length > 0 ? configured : [...defaults.values()].map((model) => ({
|
|
1204
|
+
id: model.id,
|
|
1205
|
+
...overrides[model.id]
|
|
1206
|
+
}));
|
|
1207
|
+
if (entries.length === 0) invalid$1(provider, "resolves no models; the installed catalog does not describe this route, so its models must be listed in configuration");
|
|
1208
|
+
const routeApi = sharedCatalogApi(defaults);
|
|
1209
|
+
assertOfferedCompatFields(provider, "route", request.compat);
|
|
1210
|
+
for (const entry of entries) assertOfferedCompatFields(provider, `model "${entry.id}"`, entry.compat);
|
|
1211
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1212
|
+
const configuredMaxTokens = /* @__PURE__ */ new Map();
|
|
1213
|
+
const models = entries.map((entry) => {
|
|
1214
|
+
if (entry.id.length === 0) invalid$1(provider, "has a model with an empty id");
|
|
1215
|
+
if (seen.has(entry.id)) invalid$1(provider, `lists model "${entry.id}" more than once`);
|
|
1216
|
+
seen.add(entry.id);
|
|
1217
|
+
const base = defaults.get(entry.id);
|
|
1218
|
+
const api = request.api ?? base?.api ?? routeApi;
|
|
1219
|
+
if (api === void 0) invalid$1(provider, `model "${entry.id}" needs an api; the installed catalog does not describe it, so set the route's api to the wire protocol its endpoint speaks`);
|
|
1220
|
+
const baseUrl = request.baseURL ?? base?.baseUrl ?? providerBaseUrl;
|
|
1221
|
+
if (baseUrl === void 0) invalid$1(provider, `model "${entry.id}" needs a baseURL; the installed catalog does not describe this route`);
|
|
1222
|
+
const contextWindow = entry.contextWindow ?? base?.contextWindow ?? request.defaultContextWindow;
|
|
1223
|
+
if (!Number.isInteger(contextWindow) || contextWindow <= 0) invalid$1(provider, `model "${entry.id}" contextWindow must be a positive integer`);
|
|
1224
|
+
const maxTokens = entry.maxTokens ?? base?.maxTokens ?? request.defaultMaxTokens;
|
|
1225
|
+
if (!Number.isInteger(maxTokens) || maxTokens <= 0) invalid$1(provider, `model "${entry.id}" maxTokens must be a positive integer`);
|
|
1226
|
+
if (entry.maxTokens !== void 0) configuredMaxTokens.set(entry.id, entry.maxTokens);
|
|
1227
|
+
return {
|
|
1228
|
+
...base,
|
|
1229
|
+
id: entry.id,
|
|
1230
|
+
name: entry.name ?? base?.name ?? entry.id,
|
|
1231
|
+
api,
|
|
1232
|
+
provider,
|
|
1233
|
+
baseUrl,
|
|
1234
|
+
input: declaredInput(entry.input) ?? base?.input ?? [...request.defaultInput],
|
|
1235
|
+
cost: base?.cost ?? NO_COST,
|
|
1236
|
+
contextWindow,
|
|
1237
|
+
maxTokens,
|
|
1238
|
+
...resolveModelReasoning(provider, entry, base),
|
|
1239
|
+
...resolveModelCompat(provider, entry, request.compat, base, api)
|
|
1240
|
+
};
|
|
1241
|
+
});
|
|
1242
|
+
for (const [field] of configuredCompatEntries(request.compat)) {
|
|
1243
|
+
const takers = compatProtocols(field);
|
|
1244
|
+
if (models.some((model) => takers.includes(model.api))) continue;
|
|
1245
|
+
invalid$1(provider, `sets compat "${field}", but no model on the route speaks a protocol that takes it; it exists on ${takers.join(", ")}`);
|
|
1246
|
+
}
|
|
1247
|
+
return {
|
|
1248
|
+
models,
|
|
1249
|
+
configuredMaxTokens
|
|
1250
|
+
};
|
|
1251
|
+
}
|
|
1252
|
+
//#endregion
|
|
1253
|
+
//#region vendor/dsh-llm-pi-ai/src/provider.ts
|
|
1254
|
+
/**
|
|
1255
|
+
* Construction of the pi-ai `Provider` that one configured route registers into
|
|
1256
|
+
* the adapter's `Models` collection.
|
|
1257
|
+
*
|
|
1258
|
+
* Two constructions, one decision: a route the installed catalog ships, whose
|
|
1259
|
+
* profile does not override the wire protocol, **reuses that catalog provider**
|
|
1260
|
+
* with its models replaced — the catalog provider owns API implementations this
|
|
1261
|
+
* package cannot reconstruct (Bedrock loads its Smithy module through a
|
|
1262
|
+
* separate entry point), so rebuilding it from parts would silently narrow
|
|
1263
|
+
* which providers work. Every other route — one pi-ai has never heard of, or a
|
|
1264
|
+
* catalog route pointed at a different protocol — is built by `createProvider`
|
|
1265
|
+
* over the protocol table below.
|
|
1266
|
+
*
|
|
1267
|
+
* Credentials never reach this module's storage: the harness resolves a route's
|
|
1268
|
+
* key through `ctx.credentials` before the request enters pi-ai and hands it
|
|
1269
|
+
* over as a stream option, which `Models` presents to `resolve()` as the
|
|
1270
|
+
* credential key.
|
|
1271
|
+
*
|
|
1272
|
+
* @module dsh-llm-pi-ai/provider
|
|
1273
|
+
*/
|
|
1274
|
+
/**
|
|
1275
|
+
* Wire protocols a configured route may name, mapped to pi-ai's lazily loaded
|
|
1276
|
+
* implementations. Each entry is the factory that pi-ai's matching provider
|
|
1277
|
+
* factory uses, so a hand-declared route reaches exactly the implementation a
|
|
1278
|
+
* catalog route would.
|
|
1279
|
+
*
|
|
1280
|
+
* The table is deliberately narrow: the protocols a hand-declared route
|
|
1281
|
+
* actually reads, each completely describable with a key, an
|
|
1282
|
+
* endpoint, and headers. Bedrock signs with SigV4 over AWS credentials and a
|
|
1283
|
+
* region, Vertex needs a project, a location, and application-default
|
|
1284
|
+
* credentials, Azure needs provider environment plus an api-version, and Codex
|
|
1285
|
+
* authenticates through OAuth — none of which this configuration shape can
|
|
1286
|
+
* express, so offering them would hand back a provider that cannot
|
|
1287
|
+
* authenticate. The remainder are absent for want of a consumer rather than a
|
|
1288
|
+
* blocker: each is one line here once a deployment needs it. Catalog routes
|
|
1289
|
+
* still reach every protocol through their own provider; only an explicit
|
|
1290
|
+
* override is refused.
|
|
1291
|
+
*/
|
|
1292
|
+
const PROTOCOLS = {
|
|
1293
|
+
"openai-completions": openAICompletionsApi,
|
|
1294
|
+
"openai-responses": openAIResponsesApi,
|
|
1295
|
+
"anthropic-messages": anthropicMessagesApi
|
|
1296
|
+
};
|
|
1297
|
+
/**
|
|
1298
|
+
* Every wire protocol a configured route may name, most-reached first. The
|
|
1299
|
+
* order is the table's and therefore stable; a configuration surface offering
|
|
1300
|
+
* a choice presents the first as its default, which is why the protocol a
|
|
1301
|
+
* hand-declared gateway most often speaks — and the one endpoint interrogation
|
|
1302
|
+
* can read — leads.
|
|
1303
|
+
* @returns the supported protocol identifiers.
|
|
1304
|
+
*/
|
|
1305
|
+
function supportedProtocols() {
|
|
1306
|
+
return Object.keys(PROTOCOLS);
|
|
1307
|
+
}
|
|
1308
|
+
/**
|
|
1309
|
+
* Api-key auth for a route the harness authenticates itself. `Models` calls
|
|
1310
|
+
* this after the adapter has already resolved the route's credential, so a
|
|
1311
|
+
* missing key here is not this layer's failure: a named-but-unresolvable
|
|
1312
|
+
* reference has already failed the request with `MISSING_CREDENTIAL`, and a
|
|
1313
|
+
* route naming no credential at all is deliberately unauthenticated. Reporting
|
|
1314
|
+
* it as configured hands the decision to the protocol, which is where the
|
|
1315
|
+
* requirement actually lives — pi-ai's OpenAI-compatible implementation, for
|
|
1316
|
+
* one, still insists on a key or an `Authorization` header of its own.
|
|
1317
|
+
* @param name - display name used as the resolution's status label.
|
|
1318
|
+
* @returns the api-key auth for a harness-authenticated route.
|
|
1319
|
+
*/
|
|
1320
|
+
function harnessApiKeyAuth(name) {
|
|
1321
|
+
return {
|
|
1322
|
+
name,
|
|
1323
|
+
resolve: ({ credential }) => Promise.resolve({
|
|
1324
|
+
auth: credential?.key === void 0 ? {} : { apiKey: credential.key },
|
|
1325
|
+
source: name
|
|
1326
|
+
})
|
|
1327
|
+
};
|
|
1328
|
+
}
|
|
1329
|
+
/**
|
|
1330
|
+
* The auth one route resolves its credential through.
|
|
1331
|
+
*
|
|
1332
|
+
* A catalog route keeps the installed provider's own auth, which is what
|
|
1333
|
+
* preserves provider-native ambient discovery for a profile naming no
|
|
1334
|
+
* credential. That holds even when the profile repoints the protocol: which
|
|
1335
|
+
* environment a provider reads is a property of the provider, not of the wire
|
|
1336
|
+
* format its models speak.
|
|
1337
|
+
*
|
|
1338
|
+
* The single addition covers a catalog provider that offers no api-key method
|
|
1339
|
+
* at all. pi-ai resolves a request's `apiKey` override only when the provider
|
|
1340
|
+
* declares one (`resolveProviderAuth` checks `provider.auth.apiKey` before
|
|
1341
|
+
* honouring the override), so an OAuth-only provider — `openai-codex` is the
|
|
1342
|
+
* one the installed catalog ships — would refuse a profile's explicit key with
|
|
1343
|
+
* `Provider is not configured` before any request went out. Adding the harness
|
|
1344
|
+
* method beside the provider's own restores that route. A keyless profile adds
|
|
1345
|
+
* nothing and still reports the honest refusal, because this adapter resolves
|
|
1346
|
+
* credentials through its own seam and holds no OAuth store to fall back on.
|
|
1347
|
+
* @param spec - the resolved route facts.
|
|
1348
|
+
* @param catalog - the installed catalog provider, when pi-ai ships one.
|
|
1349
|
+
* @returns the auth to construct this route's provider with.
|
|
1350
|
+
*/
|
|
1351
|
+
function routeAuth(spec, catalog) {
|
|
1352
|
+
if (catalog === void 0) return { apiKey: harnessApiKeyAuth(spec.displayName) };
|
|
1353
|
+
if (catalog.auth.apiKey !== void 0 || !spec.namesCredential) return catalog.auth;
|
|
1354
|
+
return {
|
|
1355
|
+
...catalog.auth,
|
|
1356
|
+
apiKey: harnessApiKeyAuth(spec.displayName)
|
|
1357
|
+
};
|
|
1358
|
+
}
|
|
1359
|
+
/**
|
|
1360
|
+
* Reuse an installed catalog provider with this route's models and identity.
|
|
1361
|
+
* Model dispatch stays with the catalog provider, so its API implementations,
|
|
1362
|
+
* compatibility quirks, and ambient credential discovery are preserved exactly.
|
|
1363
|
+
* Catalog-owned dynamic refresh is dropped: this route's catalog is the
|
|
1364
|
+
* settings document, and a background refresh would contradict it.
|
|
1365
|
+
*/
|
|
1366
|
+
function reuseCatalogProvider(base, spec) {
|
|
1367
|
+
const baseUrl = spec.baseURL ?? base.baseUrl;
|
|
1368
|
+
return {
|
|
1369
|
+
id: spec.provider,
|
|
1370
|
+
name: spec.displayName,
|
|
1371
|
+
...baseUrl === void 0 ? {} : { baseUrl },
|
|
1372
|
+
auth: routeAuth(spec, base),
|
|
1373
|
+
getModels: () => spec.models,
|
|
1374
|
+
stream: (model, context, options) => base.stream(model, context, options),
|
|
1375
|
+
streamSimple: (model, context, options) => base.streamSimple(model, context, options)
|
|
1376
|
+
};
|
|
1377
|
+
}
|
|
1378
|
+
/**
|
|
1379
|
+
* Build the pi-ai provider for one resolved route.
|
|
1380
|
+
* @param spec - the resolved route facts.
|
|
1381
|
+
* @returns the provider to register in the adapter's `Models` collection.
|
|
1382
|
+
* @throws Error when the route names a wire protocol this build cannot serve.
|
|
1383
|
+
*/
|
|
1384
|
+
function buildProvider(spec) {
|
|
1385
|
+
const catalog = catalogProvider(spec.provider);
|
|
1386
|
+
if (catalog !== void 0 && spec.api === void 0) return reuseCatalogProvider(catalog, spec);
|
|
1387
|
+
const factory = spec.api === void 0 ? void 0 : PROTOCOLS[spec.api];
|
|
1388
|
+
if (factory === void 0) throw new Error(`llm-pi-ai: provider "${spec.provider}" names api "${spec.api}", which this build cannot serve; supported protocols are ${supportedProtocols().join(", ")}`);
|
|
1389
|
+
return createProvider({
|
|
1390
|
+
id: spec.provider,
|
|
1391
|
+
name: spec.displayName,
|
|
1392
|
+
...spec.baseURL === void 0 ? {} : { baseUrl: spec.baseURL },
|
|
1393
|
+
auth: routeAuth(spec, catalog),
|
|
1394
|
+
models: spec.models,
|
|
1395
|
+
api: factory()
|
|
1396
|
+
});
|
|
1397
|
+
}
|
|
1398
|
+
//#endregion
|
|
1399
|
+
//#region vendor/dsh-llm-pi-ai/src/config.ts
|
|
1400
|
+
/** Default maximum idle interval while an adapter stream read is outstanding. */
|
|
1401
|
+
const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 3e5;
|
|
1402
|
+
/**
|
|
1403
|
+
* Default request-level bound on base64-encoded image payload. Every image in
|
|
1404
|
+
* history is re-encoded into every request body, so an unbounded conversation
|
|
1405
|
+
* eventually exceeds a provider or gateway request-size cap and the session
|
|
1406
|
+
* can never complete another request. The 20MiB default admits fifteen 1MiB
|
|
1407
|
+
* request versions after base64 expansion and reserves request capacity for
|
|
1408
|
+
* system prompts, history, tools, and JSON.
|
|
1409
|
+
* Deployments behind stricter gateways lower it per route.
|
|
1410
|
+
*/
|
|
1411
|
+
const DEFAULT_MAX_REQUEST_IMAGE_BYTES = 20971520;
|
|
1412
|
+
/** Default total-pixel budget preserves the complete 2048px normalized attachment. */
|
|
1413
|
+
const DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET = 4194304;
|
|
1414
|
+
/** Default raw encoded-byte target before inline base64 expansion; the smallest quality-ladder output is used when no quality fits. */
|
|
1415
|
+
const DEFAULT_REQUEST_IMAGE_MAX_BYTES = 1048576;
|
|
1416
|
+
/** Context capacity assumed for a model neither configuration nor the catalog sizes. */
|
|
1417
|
+
const DEFAULT_CONTEXT_WINDOW = 262144;
|
|
1418
|
+
/** Output capability assumed for a model neither configuration nor the catalog sizes. */
|
|
1419
|
+
const DEFAULT_MAX_TOKENS = 32768;
|
|
1420
|
+
/**
|
|
1421
|
+
* Modalities assumed for a model neither configuration nor the catalog
|
|
1422
|
+
* declares. Text is the floor every supported protocol certainly carries, so
|
|
1423
|
+
* this is the absence of a declaration rather than a guess at the endpoint:
|
|
1424
|
+
* nothing can interrogate a gateway for its modalities, and the two wrong
|
|
1425
|
+
* answers do not cost the same. Under-claiming refuses the image before it is
|
|
1426
|
+
* attached, naming the model. Over-claiming admits one the provider then
|
|
1427
|
+
* rejects mid-turn, after the message is durable, leaving the session
|
|
1428
|
+
* repeating a request that cannot succeed.
|
|
1429
|
+
*/
|
|
1430
|
+
const DEFAULT_INPUT = ["text"];
|
|
1431
|
+
const thinkingBudgets = z.object({
|
|
1432
|
+
minimal: z.number(),
|
|
1433
|
+
low: z.number(),
|
|
1434
|
+
medium: z.number(),
|
|
1435
|
+
high: z.number()
|
|
1436
|
+
});
|
|
1437
|
+
/**
|
|
1438
|
+
* One `chat_template_kwargs` or `chat_template_args` value. The `$var` member
|
|
1439
|
+
* is pi-ai's placeholder for a value dispatch fills from the request's
|
|
1440
|
+
* thinking state, which makes a template-driven gateway configurable without
|
|
1441
|
+
* restating its template.
|
|
1442
|
+
*/
|
|
1443
|
+
const chatTemplateKwarg = z.union([
|
|
1444
|
+
z.string(),
|
|
1445
|
+
z.number(),
|
|
1446
|
+
z.boolean(),
|
|
1447
|
+
z.const(null),
|
|
1448
|
+
z.object({
|
|
1449
|
+
$var: z.union(CHAT_TEMPLATE_VARS).required(),
|
|
1450
|
+
omitWhenOff: z.boolean()
|
|
1451
|
+
})
|
|
1452
|
+
]);
|
|
1453
|
+
const compatProfile = z.object({
|
|
1454
|
+
supportsStore: z.boolean(),
|
|
1455
|
+
supportsDeveloperRole: z.boolean(),
|
|
1456
|
+
supportsReasoningEffort: z.boolean(),
|
|
1457
|
+
supportsUsageInStreaming: z.boolean(),
|
|
1458
|
+
supportsFinishReason: z.boolean(),
|
|
1459
|
+
maxTokensField: z.union(MAX_TOKENS_FIELDS),
|
|
1460
|
+
requiresToolResultName: z.boolean(),
|
|
1461
|
+
requiresAssistantAfterToolResult: z.boolean(),
|
|
1462
|
+
requiresThinkingAsText: z.boolean(),
|
|
1463
|
+
requiresReasoningContentOnAssistantMessages: z.boolean(),
|
|
1464
|
+
thinkingFormat: z.union(SUPPORTED_THINKING_FORMATS),
|
|
1465
|
+
chatTemplateKwargs: z.dict(chatTemplateKwarg),
|
|
1466
|
+
chatTemplateArgs: z.dict(chatTemplateKwarg),
|
|
1467
|
+
supportsThinkingTokenBudget: z.boolean(),
|
|
1468
|
+
supportsStrictMode: z.boolean(),
|
|
1469
|
+
cacheControlFormat: z.union(CACHE_CONTROL_FORMATS),
|
|
1470
|
+
supportsLongCacheRetention: z.boolean(),
|
|
1471
|
+
supportsEagerToolInputStreaming: z.boolean(),
|
|
1472
|
+
supportsCacheControlOnTools: z.boolean(),
|
|
1473
|
+
supportsTemperature: z.boolean(),
|
|
1474
|
+
forceAdaptiveThinking: z.boolean(),
|
|
1475
|
+
allowEmptySignature: z.boolean(),
|
|
1476
|
+
supportsStrictTools: z.boolean()
|
|
1477
|
+
});
|
|
1478
|
+
/**
|
|
1479
|
+
* Keys are the offered levels, values their wire spellings. A valueless key
|
|
1480
|
+
* (`off:`) survives validation because schemastery passes nullable data
|
|
1481
|
+
* through before any member schema runs — `z.const(null)` only controls the
|
|
1482
|
+
* error for non-null wrong values and what a configuration UI renders.
|
|
1483
|
+
* Only resolution decides which levels may leave the value empty, so the
|
|
1484
|
+
* diagnostic can name the route and model. The assertion narrows
|
|
1485
|
+
* schemastery's `Dict`, which types every literal key as required; dict
|
|
1486
|
+
* validation checks only present keys, so the runtime value is a partial record.
|
|
1487
|
+
*/
|
|
1488
|
+
const reasoningEfforts = z.dict(z.union([z.string(), z.const(null)]), z.union(THINKING_LEVELS));
|
|
1489
|
+
/** The fields a `models` entry and a `modelOverrides` value share; only the id's home differs. */
|
|
1490
|
+
const modelFields = {
|
|
1491
|
+
name: z.string(),
|
|
1492
|
+
contextWindow: z.number().step(1).min(1),
|
|
1493
|
+
maxTokens: z.number().step(1).min(1),
|
|
1494
|
+
input: z.array(z.union(MODALITIES)),
|
|
1495
|
+
reasoningEfforts: z.union([z.const(false), reasoningEfforts]),
|
|
1496
|
+
compat: compatProfile
|
|
1497
|
+
};
|
|
1498
|
+
const modelProfile = z.object({
|
|
1499
|
+
id: z.string().required(),
|
|
1500
|
+
...modelFields
|
|
1501
|
+
});
|
|
1502
|
+
/** A {@link modelProfile} whose id lives in the `modelOverrides` dict key. */
|
|
1503
|
+
const modelOverride = z.object(modelFields);
|
|
1504
|
+
const profile = z.object({
|
|
1505
|
+
apiKeyEnv: z.string().role("credential-ref"),
|
|
1506
|
+
displayName: z.string(),
|
|
1507
|
+
api: z.union(supportedProtocols()),
|
|
1508
|
+
baseURL: z.string(),
|
|
1509
|
+
models: z.array(modelProfile),
|
|
1510
|
+
modelOverrides: z.dict(modelOverride),
|
|
1511
|
+
compat: compatProfile,
|
|
1512
|
+
defaultContextWindow: z.number().step(1).min(1).default(DEFAULT_CONTEXT_WINDOW),
|
|
1513
|
+
defaultMaxTokens: z.number().step(1).min(1).default(DEFAULT_MAX_TOKENS),
|
|
1514
|
+
defaultInput: z.array(z.union(MODALITIES)).default([...DEFAULT_INPUT]),
|
|
1515
|
+
headers: z.dict(z.string()),
|
|
1516
|
+
reasoning: z.union(THINKING_LEVELS),
|
|
1517
|
+
reasoningSplit: z.boolean(),
|
|
1518
|
+
thinkingBudgets,
|
|
1519
|
+
cacheRetention: z.union([
|
|
1520
|
+
"none",
|
|
1521
|
+
"short",
|
|
1522
|
+
"long"
|
|
1523
|
+
]),
|
|
1524
|
+
transport: z.union([
|
|
1525
|
+
"sse",
|
|
1526
|
+
"websocket",
|
|
1527
|
+
"websocket-cached",
|
|
1528
|
+
"auto"
|
|
1529
|
+
]),
|
|
1530
|
+
timeoutMs: z.natural(),
|
|
1531
|
+
websocketConnectTimeoutMs: z.natural(),
|
|
1532
|
+
streamIdleTimeoutMs: z.number().min(Number.MIN_VALUE).max(MAX_TIMER_DELAY_MS).default(DEFAULT_STREAM_IDLE_TIMEOUT_MS),
|
|
1533
|
+
maxRequestImageBytes: z.number().step(1).min(1).default(DEFAULT_MAX_REQUEST_IMAGE_BYTES),
|
|
1534
|
+
requestImagePixelBudget: z.number().step(1).min(1).default(DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET),
|
|
1535
|
+
requestImageMaxBytes: z.number().step(1).min(1).default(DEFAULT_REQUEST_IMAGE_MAX_BYTES),
|
|
1536
|
+
retryPolicy: RetryPolicySchema
|
|
1537
|
+
});
|
|
1538
|
+
z.object({ providers: z.dict(profile).default({}) });
|
|
1539
|
+
/** Reject removed pre-release profile fields and name their replacements. */
|
|
1540
|
+
function rejectRemovedFields(provider, source) {
|
|
1541
|
+
const legacy = source;
|
|
1542
|
+
if ("provider" in legacy) throw new Error(`llm-pi-ai: provider "${provider}" sets "provider", which moved to the providers dict key`);
|
|
1543
|
+
if ("maxRetries" in legacy || "maxRetryDelayMs" in legacy) throw new Error(`llm-pi-ai: provider "${provider}" sets maxRetries or maxRetryDelayMs, which were removed; compose agent recovery with dsh-llm-retry`);
|
|
1544
|
+
}
|
|
1545
|
+
/**
|
|
1546
|
+
* Validate profiles and return a detached route-keyed map suitable for
|
|
1547
|
+
* per-request reads. This is the one explicit resolve step, so an omitted dict
|
|
1548
|
+
* resolves to the empty (dormant) route set here rather than through a hidden
|
|
1549
|
+
* fallback, and each route's models and pi-ai provider are materialized once.
|
|
1550
|
+
* @param providers - configured provider profiles keyed by route.
|
|
1551
|
+
* @returns validated profiles in configuration order.
|
|
1552
|
+
*/
|
|
1553
|
+
function resolveProfiles(providers) {
|
|
1554
|
+
if (Array.isArray(providers)) throw new Error("llm-pi-ai: providers is now a dict keyed by provider route, not an array of profiles");
|
|
1555
|
+
const entries = Object.entries(providers ?? {});
|
|
1556
|
+
const resolved = /* @__PURE__ */ new Map();
|
|
1557
|
+
for (const [provider, source] of entries) {
|
|
1558
|
+
rejectRemovedFields(provider, source);
|
|
1559
|
+
if (provider.length === 0) throw new Error("llm-pi-ai: provider names must be non-empty");
|
|
1560
|
+
if (source.baseURL !== void 0 && source.baseURL.length === 0) throw new Error(`llm-pi-ai: provider "${provider}" has an empty baseURL`);
|
|
1561
|
+
if (source.displayName !== void 0 && source.displayName.length === 0) throw new Error(`llm-pi-ai: provider "${provider}" has an empty displayName`);
|
|
1562
|
+
const streamIdleTimeoutMs = source.streamIdleTimeoutMs ?? 3e5;
|
|
1563
|
+
if (!Number.isFinite(streamIdleTimeoutMs) || streamIdleTimeoutMs <= 0 || streamIdleTimeoutMs > MAX_TIMER_DELAY_MS) throw new Error(`llm-pi-ai: provider "${provider}" streamIdleTimeoutMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`);
|
|
1564
|
+
const maxRequestImageBytes = source.maxRequestImageBytes ?? 20971520;
|
|
1565
|
+
if (!Number.isInteger(maxRequestImageBytes) || maxRequestImageBytes <= 0) throw new Error(`llm-pi-ai: provider "${provider}" maxRequestImageBytes must be a positive integer`);
|
|
1566
|
+
const requestImagePixelBudget = source.requestImagePixelBudget ?? 4194304;
|
|
1567
|
+
if (!Number.isSafeInteger(requestImagePixelBudget) || requestImagePixelBudget <= 0) throw new Error(`llm-pi-ai: provider "${provider}" requestImagePixelBudget must be a positive safe integer`);
|
|
1568
|
+
const requestImageMaxBytes = source.requestImageMaxBytes ?? 1048576;
|
|
1569
|
+
if (!Number.isSafeInteger(requestImageMaxBytes) || requestImageMaxBytes <= 0) throw new Error(`llm-pi-ai: provider "${provider}" requestImageMaxBytes must be a positive safe integer`);
|
|
1570
|
+
const defaultInput = [...source.defaultInput ?? DEFAULT_INPUT];
|
|
1571
|
+
if (defaultInput.length === 0) throw new Error(`llm-pi-ai: provider "${provider}" defaultInput must name at least one modality`);
|
|
1572
|
+
const displayName = source.displayName ?? provider;
|
|
1573
|
+
const catalog = resolveRouteModels({
|
|
1574
|
+
provider,
|
|
1575
|
+
...source.api === void 0 ? {} : { api: source.api },
|
|
1576
|
+
...source.baseURL === void 0 ? {} : { baseURL: source.baseURL },
|
|
1577
|
+
...source.models === void 0 ? {} : { models: source.models },
|
|
1578
|
+
...source.modelOverrides === void 0 ? {} : { modelOverrides: source.modelOverrides },
|
|
1579
|
+
...source.compat === void 0 ? {} : { compat: source.compat },
|
|
1580
|
+
defaultInput,
|
|
1581
|
+
defaultContextWindow: source.defaultContextWindow ?? 262144,
|
|
1582
|
+
defaultMaxTokens: source.defaultMaxTokens ?? 32768
|
|
1583
|
+
});
|
|
1584
|
+
if (source.reasoningSplit !== void 0 && catalog.models.some((model) => model.api !== "openai-completions")) throw new Error(`llm-pi-ai: provider "${provider}" reasoningSplit requires every model to use openai-completions`);
|
|
1585
|
+
const { apiKeyEnv, retryPolicy, models: _models, displayName: _displayName, ...rest } = source;
|
|
1586
|
+
resolved.set(provider, {
|
|
1587
|
+
...rest,
|
|
1588
|
+
provider,
|
|
1589
|
+
displayName,
|
|
1590
|
+
...apiKeyEnv === void 0 ? {} : { apiKeyEnv: credentialRef(apiKeyEnv) },
|
|
1591
|
+
streamIdleTimeoutMs,
|
|
1592
|
+
maxRequestImageBytes,
|
|
1593
|
+
requestImagePixelBudget,
|
|
1594
|
+
requestImageMaxBytes,
|
|
1595
|
+
retryPolicy: resolveRetryPolicy(retryPolicy, `llm-pi-ai: provider "${provider}" retryPolicy`),
|
|
1596
|
+
...rest.headers === void 0 ? {} : { headers: { ...rest.headers } },
|
|
1597
|
+
...rest.thinkingBudgets === void 0 ? {} : { thinkingBudgets: { ...rest.thinkingBudgets } },
|
|
1598
|
+
configuredMaxTokens: catalog.configuredMaxTokens,
|
|
1599
|
+
piProvider: buildProvider({
|
|
1600
|
+
provider,
|
|
1601
|
+
displayName,
|
|
1602
|
+
...source.api === void 0 ? {} : { api: source.api },
|
|
1603
|
+
...source.baseURL === void 0 ? {} : { baseURL: source.baseURL },
|
|
1604
|
+
models: catalog.models,
|
|
1605
|
+
namesCredential: apiKeyEnv !== void 0
|
|
1606
|
+
})
|
|
1607
|
+
});
|
|
1608
|
+
}
|
|
1609
|
+
return resolved;
|
|
1610
|
+
}
|
|
1611
|
+
//#endregion
|
|
1612
|
+
//#region vendor/dsh-llm-pi-ai/src/context.ts
|
|
1613
|
+
/**
|
|
1614
|
+
* ChatCode CLI request-history conversion into pi-ai's Context vocabulary.
|
|
1615
|
+
*
|
|
1616
|
+
* @module dsh-llm-pi-ai/context
|
|
1617
|
+
*/
|
|
1618
|
+
/** Join the text blocks of a harness message. */
|
|
1619
|
+
function flattenText(message) {
|
|
1620
|
+
return message.content.filter((block) => block.type === "text").map((block) => block.text).join("");
|
|
1621
|
+
}
|
|
1622
|
+
/** Flatten text recursively inside one tool result. */
|
|
1623
|
+
function toolResultText(blocks) {
|
|
1624
|
+
return blocks.map((block) => block.type === "text" ? block.text : block.type === "tool-result" ? toolResultText(block.content) : "").join("");
|
|
1625
|
+
}
|
|
1626
|
+
/** Reject image roles that pi-ai cannot replay before request-size offloading can replace them. */
|
|
1627
|
+
function assertSupportedImageRoles(messages) {
|
|
1628
|
+
for (const message of messages) if (message.role !== "user" && contentHasImage(message.content)) throw new LlmError(`pi-ai cannot represent an image in an in-history ${message.role} message`, "UNSUPPORTED_CONTENT");
|
|
1629
|
+
}
|
|
1630
|
+
async function userContent(blocks, requestImages, resolveImageAccess) {
|
|
1631
|
+
const content = [];
|
|
1632
|
+
for (const block of blocks) switch (block.type) {
|
|
1633
|
+
case "text":
|
|
1634
|
+
if (block.text.length > 0) content.push({
|
|
1635
|
+
type: "text",
|
|
1636
|
+
text: block.text
|
|
1637
|
+
});
|
|
1638
|
+
break;
|
|
1639
|
+
case "image": {
|
|
1640
|
+
const version = requestImages.get(block.attachment.attachmentId);
|
|
1641
|
+
content.push({
|
|
1642
|
+
type: "text",
|
|
1643
|
+
text: requestImageHandleText(block.attachment, version, resolveImageAccess(block.attachment))
|
|
1644
|
+
});
|
|
1645
|
+
content.push({
|
|
1646
|
+
type: "image",
|
|
1647
|
+
data: Buffer.from(version.data).toString("base64"),
|
|
1648
|
+
mimeType: version.mediaType
|
|
1649
|
+
});
|
|
1650
|
+
break;
|
|
1651
|
+
}
|
|
1652
|
+
case "tool-result": {
|
|
1653
|
+
const nested = await userContent(block.content, requestImages, resolveImageAccess);
|
|
1654
|
+
if (typeof nested === "string") {
|
|
1655
|
+
if (nested.length > 0) content.push({
|
|
1656
|
+
type: "text",
|
|
1657
|
+
text: nested
|
|
1658
|
+
});
|
|
1659
|
+
} else content.push(...nested);
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
if (content.every((block) => block.type === "text")) return content.map((block) => block.text).join("");
|
|
1663
|
+
return content;
|
|
1664
|
+
}
|
|
1665
|
+
function collectImageRefs(blocks, refs) {
|
|
1666
|
+
for (const block of blocks) if (block.type === "image") {
|
|
1667
|
+
if (block.offloaded !== true) refs.set(block.attachment.attachmentId, block.attachment);
|
|
1668
|
+
} else if (block.type === "tool-result") collectImageRefs(block.content, refs);
|
|
1669
|
+
}
|
|
1670
|
+
async function prepareRequestImages(messages, attachments, budget, signal) {
|
|
1671
|
+
const refs = /* @__PURE__ */ new Map();
|
|
1672
|
+
for (const message of messages) collectImageRefs(message.content, refs);
|
|
1673
|
+
const orderedRefs = [...refs.values()];
|
|
1674
|
+
const prepared = await Promise.all(orderedRefs.map((ref) => attachments.readImageRequest(ref, requestImageTarget(ref, budget), signal)));
|
|
1675
|
+
const versions = /* @__PURE__ */ new Map();
|
|
1676
|
+
for (const [index, ref] of orderedRefs.entries()) versions.set(ref.attachmentId, prepared[index]);
|
|
1677
|
+
return versions;
|
|
1678
|
+
}
|
|
1679
|
+
function toolsOf(options) {
|
|
1680
|
+
return options.tools?.map((tool) => ({
|
|
1681
|
+
name: tool.name,
|
|
1682
|
+
description: tool.description,
|
|
1683
|
+
parameters: tool.parameters
|
|
1684
|
+
}));
|
|
1685
|
+
}
|
|
1686
|
+
/** Select the pi-ai system prompt source shared by both conversion paths. */
|
|
1687
|
+
function splitSystemPrompt(options) {
|
|
1688
|
+
if (options.system !== void 0) return {
|
|
1689
|
+
systemPrompt: options.system,
|
|
1690
|
+
messages: options.messages
|
|
1691
|
+
};
|
|
1692
|
+
const [first, ...rest] = options.messages;
|
|
1693
|
+
if (first?.role !== "system") return {
|
|
1694
|
+
systemPrompt: void 0,
|
|
1695
|
+
messages: options.messages
|
|
1696
|
+
};
|
|
1697
|
+
const text = flattenText(first);
|
|
1698
|
+
return {
|
|
1699
|
+
systemPrompt: text.length > 0 ? text : void 0,
|
|
1700
|
+
messages: rest
|
|
1701
|
+
};
|
|
1702
|
+
}
|
|
1703
|
+
/** Assemble the request-level pi-ai context envelope shared by both conversion paths. */
|
|
1704
|
+
function piContext(systemPrompt, options, messages) {
|
|
1705
|
+
const tools = toolsOf(options);
|
|
1706
|
+
return {
|
|
1707
|
+
...systemPrompt !== void 0 ? { systemPrompt } : {},
|
|
1708
|
+
messages,
|
|
1709
|
+
...tools !== void 0 && tools.length > 0 ? { tools } : {}
|
|
1710
|
+
};
|
|
1711
|
+
}
|
|
1712
|
+
function appendAssistant(message, messages, toolNames, onReplayDegrade) {
|
|
1713
|
+
const assistant = toPiAssistant(message, onReplayDegrade);
|
|
1714
|
+
for (const block of assistant.content) if (block.type === "toolCall") toolNames.set(brandString(block.id), block.name);
|
|
1715
|
+
messages.push(assistant);
|
|
1716
|
+
}
|
|
1717
|
+
function textOnlyContext(options, onReplayDegrade) {
|
|
1718
|
+
assertSupportedImageRoles(options.messages);
|
|
1719
|
+
const split = splitSystemPrompt(options);
|
|
1720
|
+
const toolNames = /* @__PURE__ */ new Map();
|
|
1721
|
+
const messages = [];
|
|
1722
|
+
for (const message of split.messages) {
|
|
1723
|
+
if (contentHasImage(message.content)) throw new LlmError("pi-ai image conversion requires the durable attachment service", "UNSUPPORTED_CONTENT");
|
|
1724
|
+
if (message.role === "system") {
|
|
1725
|
+
messages.push({
|
|
1726
|
+
role: "user",
|
|
1727
|
+
content: flattenText(message),
|
|
1728
|
+
timestamp: 0
|
|
1729
|
+
});
|
|
1730
|
+
continue;
|
|
1731
|
+
}
|
|
1732
|
+
if (message.role === "assistant") {
|
|
1733
|
+
appendAssistant(message, messages, toolNames, onReplayDegrade);
|
|
1734
|
+
continue;
|
|
1735
|
+
}
|
|
1736
|
+
const text = flattenText(message);
|
|
1737
|
+
const results = message.content.filter((block) => block.type === "tool-result");
|
|
1738
|
+
if (text.length > 0 || results.length === 0) messages.push({
|
|
1739
|
+
role: "user",
|
|
1740
|
+
content: text,
|
|
1741
|
+
timestamp: 0
|
|
1742
|
+
});
|
|
1743
|
+
for (const result of results) messages.push({
|
|
1744
|
+
role: "toolResult",
|
|
1745
|
+
toolCallId: result.toolCallId,
|
|
1746
|
+
toolName: toolNames.get(result.toolCallId) ?? "unknown",
|
|
1747
|
+
content: [{
|
|
1748
|
+
type: "text",
|
|
1749
|
+
text: toolResultText(result.content) || "(no output)"
|
|
1750
|
+
}],
|
|
1751
|
+
isError: result.isError ?? false,
|
|
1752
|
+
timestamp: 0
|
|
1753
|
+
});
|
|
1754
|
+
}
|
|
1755
|
+
return piContext(split.systemPrompt, options, messages);
|
|
1756
|
+
}
|
|
1757
|
+
/** Deterministic request target for one source under the route budgets. */
|
|
1758
|
+
function requestImageTarget(ref, budget) {
|
|
1759
|
+
return {
|
|
1760
|
+
...requestImageDimensions(ref.width, ref.height, budget.maxPixels),
|
|
1761
|
+
maxBytes: budget.maxBytes
|
|
1762
|
+
};
|
|
1763
|
+
}
|
|
1764
|
+
function toPiContext(options, images, onReplayDegrade) {
|
|
1765
|
+
return images === void 0 ? textOnlyContext(options, onReplayDegrade) : toPiContextWithImages(options, images, onReplayDegrade);
|
|
1766
|
+
}
|
|
1767
|
+
async function toPiContextWithImages(options, images, onReplayDegrade) {
|
|
1768
|
+
const { attachments, resolveImageAccess, maxRequestImageBytes } = images;
|
|
1769
|
+
const requestImagePolicy = images.requestImagePolicy ?? {
|
|
1770
|
+
maxPixels: 4194304,
|
|
1771
|
+
maxBytes: 1048576
|
|
1772
|
+
};
|
|
1773
|
+
assertSupportedImageRoles(options.messages);
|
|
1774
|
+
const split = splitSystemPrompt(options);
|
|
1775
|
+
const requestImages = await prepareRequestImages(split.messages, attachments, requestImagePolicy, options.signal);
|
|
1776
|
+
if (maxRequestImageBytes !== void 0) {
|
|
1777
|
+
const offloadImages = requiredImageOffload(split.messages, {
|
|
1778
|
+
representation: "base64",
|
|
1779
|
+
maxBytes: maxRequestImageBytes
|
|
1780
|
+
}, (block) => requestImages.get(block.attachment.attachmentId).bytes);
|
|
1781
|
+
if (offloadImages > 0) throw new LlmError(`pi-ai request images exceed the ${maxRequestImageBytes}-byte base64 bound; ${offloadImages} more oldest occurrence(s) must be offloaded.`, IMAGE_OFFLOAD_REQUIRED_CODE, { offloadImages });
|
|
1782
|
+
}
|
|
1783
|
+
const exactMessages = projectOffloadedImages(split.messages, (ref) => offloadedImageText(ref, resolveImageAccess(ref)));
|
|
1784
|
+
const toolNames = /* @__PURE__ */ new Map();
|
|
1785
|
+
const messages = [];
|
|
1786
|
+
for (const message of exactMessages) {
|
|
1787
|
+
if (message.role === "system") {
|
|
1788
|
+
messages.push({
|
|
1789
|
+
role: "user",
|
|
1790
|
+
content: flattenText(message),
|
|
1791
|
+
timestamp: 0
|
|
1792
|
+
});
|
|
1793
|
+
continue;
|
|
1794
|
+
}
|
|
1795
|
+
if (message.role === "assistant") {
|
|
1796
|
+
appendAssistant(message, messages, toolNames, onReplayDegrade);
|
|
1797
|
+
continue;
|
|
1798
|
+
}
|
|
1799
|
+
const content = await userContent(message.content.filter((block) => block.type !== "tool-result"), requestImages, resolveImageAccess);
|
|
1800
|
+
const results = message.content.filter((block) => block.type === "tool-result");
|
|
1801
|
+
if (content.length > 0 || results.length === 0) messages.push({
|
|
1802
|
+
role: "user",
|
|
1803
|
+
content,
|
|
1804
|
+
timestamp: 0
|
|
1805
|
+
});
|
|
1806
|
+
for (const result of results) {
|
|
1807
|
+
const resultContent = await userContent(result.content, requestImages, resolveImageAccess);
|
|
1808
|
+
messages.push({
|
|
1809
|
+
role: "toolResult",
|
|
1810
|
+
toolCallId: result.toolCallId,
|
|
1811
|
+
toolName: toolNames.get(result.toolCallId) ?? "unknown",
|
|
1812
|
+
content: typeof resultContent === "string" ? [{
|
|
1813
|
+
type: "text",
|
|
1814
|
+
text: resultContent || "(no output)"
|
|
1815
|
+
}] : resultContent,
|
|
1816
|
+
isError: result.isError ?? false,
|
|
1817
|
+
timestamp: 0
|
|
1818
|
+
});
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1821
|
+
return piContext(split.systemPrompt, options, messages);
|
|
1822
|
+
}
|
|
1823
|
+
//#endregion
|
|
1824
|
+
//#region vendor/dsh-llm-pi-ai/src/stream.ts
|
|
1825
|
+
/**
|
|
1826
|
+
* pi-ai assistant event translation into the ChatCode CLI streaming protocol.
|
|
1827
|
+
*
|
|
1828
|
+
* pi-ai tool-call arguments are parsed objects while ChatCode CLI keeps their
|
|
1829
|
+
* raw JSON representation. pi-ai also reports failures as terminal stream
|
|
1830
|
+
* events, which this module maps into ChatCode CLI finish chunks.
|
|
1831
|
+
*
|
|
1832
|
+
* @module dsh-llm-pi-ai/stream
|
|
1833
|
+
*/
|
|
1834
|
+
/**
|
|
1835
|
+
* Map pi-ai usage (reasoning folded into output by pi-ai).
|
|
1836
|
+
* @param usage - cumulative usage from the terminal pi-ai event.
|
|
1837
|
+
* @returns harness counts with pi-ai's exact total; cache fields appear only
|
|
1838
|
+
* when non-zero (pi-ai reports zeros, not absence).
|
|
1839
|
+
*/
|
|
1840
|
+
function mapUsage(usage) {
|
|
1841
|
+
return {
|
|
1842
|
+
inputTokens: usage.input,
|
|
1843
|
+
outputTokens: usage.output,
|
|
1844
|
+
totalTokens: usage.totalTokens,
|
|
1845
|
+
...usage.cacheRead > 0 ? { cacheReadTokens: usage.cacheRead } : {},
|
|
1846
|
+
...usage.cacheWrite > 0 ? { cacheWriteTokens: usage.cacheWrite } : {}
|
|
1847
|
+
};
|
|
1848
|
+
}
|
|
1849
|
+
function classifyPiAiError(message) {
|
|
1850
|
+
if (/\b(?:401|403)\b/.test(message)) return "AUTH";
|
|
1851
|
+
if (isQuotaExceededError(message)) return QUOTA_EXCEEDED_CODE;
|
|
1852
|
+
if (/\b429\b|rate.?limit/i.test(message)) return "RATE_LIMIT";
|
|
1853
|
+
if (/\b413\b|failed to buffer the request body:\s*length limit exceeded|payload too large|request body too large/i.test(message)) return "INVALID_REQUEST";
|
|
1854
|
+
if (/\b400\b|invalid.?request/i.test(message)) return "INVALID_REQUEST";
|
|
1855
|
+
if (/\b5\d\d\b/.test(message)) return "SERVER";
|
|
1856
|
+
if (/\btime(?:d)?\s*out\b|timeout/i.test(message)) return "TIMEOUT";
|
|
1857
|
+
if (/stream ended (?:before|without)\b/i.test(message)) return "TRANSPORT";
|
|
1858
|
+
if (/\b(?:network|connection|socket|fetch)\b|\bECONN[A-Z]+\b/i.test(message) || /\b(?:other side closed|HTTP2 request did not get a response|WebSocket closed unexpectedly)\b/i.test(message) || /\bterminated\b|premature close/i.test(message)) return "TRANSPORT";
|
|
1859
|
+
return "PI_AI_ERROR";
|
|
1860
|
+
}
|
|
1861
|
+
/**
|
|
1862
|
+
* Map a terminal pi-ai event to the harness finish reason.
|
|
1863
|
+
* @param message - the assistant message carried by the `done` or `error` event.
|
|
1864
|
+
* @param contextWindow - resolved catalog capacity for usage-based overflow detection.
|
|
1865
|
+
* @returns the mapped harness reason. Recognized error text, `stop` usage above
|
|
1866
|
+
* `contextWindow`, and zero-output `length` usage that fills the window map
|
|
1867
|
+
* to `CONTEXT_WINDOW_EXCEEDED`; a `stop` with no content blocks maps to an
|
|
1868
|
+
* `EMPTY_RESPONSE` error, while terminal `pending` and `deferred` states map
|
|
1869
|
+
* to non-retryable `PI_AI_ERROR` failures.
|
|
1870
|
+
*/
|
|
1871
|
+
function mapStopReason(message, contextWindow) {
|
|
1872
|
+
const piAiOverflow = isContextOverflow(message, contextWindow);
|
|
1873
|
+
const harnessOverflow = message.stopReason === "error" && message.errorMessage !== void 0 && isContextWindowExceededError(message.errorMessage);
|
|
1874
|
+
if (piAiOverflow || harnessOverflow) return {
|
|
1875
|
+
kind: "error",
|
|
1876
|
+
failure: {
|
|
1877
|
+
message: message.errorMessage ?? `pi-ai detected context overflow for model "${message.model}"`,
|
|
1878
|
+
code: CONTEXT_WINDOW_EXCEEDED_CODE
|
|
1879
|
+
}
|
|
1880
|
+
};
|
|
1881
|
+
switch (message.stopReason) {
|
|
1882
|
+
case "stop":
|
|
1883
|
+
if (message.content.length === 0) return {
|
|
1884
|
+
kind: "error",
|
|
1885
|
+
failure: {
|
|
1886
|
+
message: `model "${message.model}" returned a completed response with no content`,
|
|
1887
|
+
code: EMPTY_RESPONSE_CODE
|
|
1888
|
+
}
|
|
1889
|
+
};
|
|
1890
|
+
return { kind: "stop" };
|
|
1891
|
+
case "length": return { kind: "max-tokens" };
|
|
1892
|
+
case "toolUse": return { kind: "tool-calls" };
|
|
1893
|
+
case "pending": return {
|
|
1894
|
+
kind: "error",
|
|
1895
|
+
failure: {
|
|
1896
|
+
message: `pi-ai stream for model "${message.model}" ended pending`,
|
|
1897
|
+
code: "PI_AI_ERROR"
|
|
1898
|
+
}
|
|
1899
|
+
};
|
|
1900
|
+
case "deferred": return {
|
|
1901
|
+
kind: "error",
|
|
1902
|
+
failure: {
|
|
1903
|
+
message: `pi-ai deferred response for model "${message.model}" is not supported`,
|
|
1904
|
+
code: "PI_AI_ERROR"
|
|
1905
|
+
}
|
|
1906
|
+
};
|
|
1907
|
+
case "aborted": return {
|
|
1908
|
+
kind: "aborted",
|
|
1909
|
+
failure: {
|
|
1910
|
+
message: message.errorMessage ?? "pi-ai stream aborted",
|
|
1911
|
+
code: "ABORTED"
|
|
1912
|
+
}
|
|
1913
|
+
};
|
|
1914
|
+
case "error": {
|
|
1915
|
+
const text = message.errorMessage ?? "pi-ai stream error";
|
|
1916
|
+
return {
|
|
1917
|
+
kind: "error",
|
|
1918
|
+
failure: {
|
|
1919
|
+
message: text,
|
|
1920
|
+
code: classifyPiAiError(text)
|
|
1921
|
+
}
|
|
1922
|
+
};
|
|
1923
|
+
}
|
|
1924
|
+
}
|
|
1925
|
+
}
|
|
1926
|
+
/**
|
|
1927
|
+
* Translate the pi-ai event stream into StreamChunks. pi-ai never throws
|
|
1928
|
+
* mid-stream — failures arrive as `error` events, which become error/aborted
|
|
1929
|
+
* `finish` chunks (the harness protocol's other error-delivery style).
|
|
1930
|
+
* @param events - one assistant turn's pi-ai event stream.
|
|
1931
|
+
* @param contextWindow - resolved catalog capacity for usage-based overflow detection.
|
|
1932
|
+
* @param callerSignal - caller cancellation state; an aborted caller makes any
|
|
1933
|
+
* in-band terminal error an aborted finish.
|
|
1934
|
+
* @returns the harness chunks, ending with `usage` then `finish`; throws
|
|
1935
|
+
* `LlmError` (`STREAM_CLOSED`) if the source ends without a terminal event.
|
|
1936
|
+
*/
|
|
1937
|
+
async function* toStreamChunks(events, contextWindow, callerSignal) {
|
|
1938
|
+
const toolIds = /* @__PURE__ */ new Map();
|
|
1939
|
+
for await (const event of events) switch (event.type) {
|
|
1940
|
+
case "start": break;
|
|
1941
|
+
case "text_start":
|
|
1942
|
+
yield {
|
|
1943
|
+
type: "block-start",
|
|
1944
|
+
index: event.contentIndex,
|
|
1945
|
+
blockType: "text"
|
|
1946
|
+
};
|
|
1947
|
+
break;
|
|
1948
|
+
case "text_delta":
|
|
1949
|
+
yield {
|
|
1950
|
+
type: "text-delta",
|
|
1951
|
+
index: event.contentIndex,
|
|
1952
|
+
text: event.delta
|
|
1953
|
+
};
|
|
1954
|
+
break;
|
|
1955
|
+
case "text_end":
|
|
1956
|
+
yield {
|
|
1957
|
+
type: "block-end",
|
|
1958
|
+
index: event.contentIndex,
|
|
1959
|
+
block: {
|
|
1960
|
+
type: "text",
|
|
1961
|
+
text: event.content
|
|
1962
|
+
}
|
|
1963
|
+
};
|
|
1964
|
+
break;
|
|
1965
|
+
case "thinking_start":
|
|
1966
|
+
yield {
|
|
1967
|
+
type: "block-start",
|
|
1968
|
+
index: event.contentIndex,
|
|
1969
|
+
blockType: "reasoning"
|
|
1970
|
+
};
|
|
1971
|
+
break;
|
|
1972
|
+
case "thinking_delta":
|
|
1973
|
+
yield {
|
|
1974
|
+
type: "reasoning-delta",
|
|
1975
|
+
index: event.contentIndex,
|
|
1976
|
+
text: event.delta
|
|
1977
|
+
};
|
|
1978
|
+
break;
|
|
1979
|
+
case "thinking_end":
|
|
1980
|
+
yield {
|
|
1981
|
+
type: "block-end",
|
|
1982
|
+
index: event.contentIndex,
|
|
1983
|
+
block: {
|
|
1984
|
+
type: "reasoning",
|
|
1985
|
+
text: event.content
|
|
1986
|
+
}
|
|
1987
|
+
};
|
|
1988
|
+
break;
|
|
1989
|
+
case "toolcall_start": {
|
|
1990
|
+
const partial = event.partial.content[event.contentIndex];
|
|
1991
|
+
const id = partial?.type === "toolCall" ? partial.id : "";
|
|
1992
|
+
const name = partial?.type === "toolCall" ? partial.name : "";
|
|
1993
|
+
toolIds.set(event.contentIndex, {
|
|
1994
|
+
id,
|
|
1995
|
+
name
|
|
1996
|
+
});
|
|
1997
|
+
yield {
|
|
1998
|
+
type: "block-start",
|
|
1999
|
+
index: event.contentIndex,
|
|
2000
|
+
blockType: "tool-call"
|
|
2001
|
+
};
|
|
2002
|
+
break;
|
|
2003
|
+
}
|
|
2004
|
+
case "toolcall_delta": {
|
|
2005
|
+
const known = toolIds.get(event.contentIndex);
|
|
2006
|
+
yield {
|
|
2007
|
+
type: "tool-call-delta",
|
|
2008
|
+
index: event.contentIndex,
|
|
2009
|
+
id: brandString(known?.id ?? ""),
|
|
2010
|
+
...known?.name !== void 0 && known.name.length > 0 ? { name: known.name } : {},
|
|
2011
|
+
argumentsDelta: event.delta
|
|
2012
|
+
};
|
|
2013
|
+
break;
|
|
2014
|
+
}
|
|
2015
|
+
case "toolcall_end":
|
|
2016
|
+
yield {
|
|
2017
|
+
type: "block-end",
|
|
2018
|
+
index: event.contentIndex,
|
|
2019
|
+
block: {
|
|
2020
|
+
type: "tool-call",
|
|
2021
|
+
id: brandString(event.toolCall.id),
|
|
2022
|
+
name: event.toolCall.name,
|
|
2023
|
+
arguments: JSON.stringify(event.toolCall.arguments)
|
|
2024
|
+
}
|
|
2025
|
+
};
|
|
2026
|
+
break;
|
|
2027
|
+
case "done":
|
|
2028
|
+
yield {
|
|
2029
|
+
type: "usage",
|
|
2030
|
+
usage: mapUsage(event.message.usage)
|
|
2031
|
+
};
|
|
2032
|
+
yield {
|
|
2033
|
+
type: "finish",
|
|
2034
|
+
reason: mapStopReason(event.message, contextWindow),
|
|
2035
|
+
replayState: toPiReplayState(event.message)
|
|
2036
|
+
};
|
|
2037
|
+
return;
|
|
2038
|
+
case "error":
|
|
2039
|
+
yield {
|
|
2040
|
+
type: "usage",
|
|
2041
|
+
usage: mapUsage(event.error.usage)
|
|
2042
|
+
};
|
|
2043
|
+
yield {
|
|
2044
|
+
type: "finish",
|
|
2045
|
+
reason: mapStopReason(callerSignal?.aborted ? {
|
|
2046
|
+
...event.error,
|
|
2047
|
+
stopReason: "aborted"
|
|
2048
|
+
} : event.error, contextWindow)
|
|
2049
|
+
};
|
|
2050
|
+
return;
|
|
2051
|
+
}
|
|
2052
|
+
throw new LlmError("pi-ai event stream ended without done/error", "STREAM_CLOSED");
|
|
2053
|
+
}
|
|
2054
|
+
//#endregion
|
|
2055
|
+
//#region \0@oxc-project+runtime@0.147.0/helpers/esm/usingCtx.js
|
|
2056
|
+
function _usingCtx() {
|
|
2057
|
+
var r = "function" == typeof SuppressedError ? SuppressedError : function(r, e) {
|
|
2058
|
+
var n = Error();
|
|
2059
|
+
return n.name = "SuppressedError", n.error = r, n.suppressed = e, n;
|
|
2060
|
+
}, e = {}, n = [];
|
|
2061
|
+
function using(r, e) {
|
|
2062
|
+
if (null != e) {
|
|
2063
|
+
if (Object(e) !== e) throw new TypeError("using declarations can only be used with objects, functions, null, or undefined.");
|
|
2064
|
+
if (r) var o = e[Symbol.asyncDispose || Symbol["for"]("Symbol.asyncDispose")];
|
|
2065
|
+
if (void 0 === o && (o = e[Symbol.dispose || Symbol["for"]("Symbol.dispose")], r)) var t = o;
|
|
2066
|
+
if ("function" != typeof o) throw new TypeError("Object is not disposable.");
|
|
2067
|
+
t && (o = function o() {
|
|
2068
|
+
try {
|
|
2069
|
+
t.call(e);
|
|
2070
|
+
} catch (r) {
|
|
2071
|
+
return Promise.reject(r);
|
|
2072
|
+
}
|
|
2073
|
+
}), n.push({
|
|
2074
|
+
v: e,
|
|
2075
|
+
d: o,
|
|
2076
|
+
a: r
|
|
2077
|
+
});
|
|
2078
|
+
} else r && n.push({
|
|
2079
|
+
d: e,
|
|
2080
|
+
a: r
|
|
2081
|
+
});
|
|
2082
|
+
return e;
|
|
2083
|
+
}
|
|
2084
|
+
return {
|
|
2085
|
+
e,
|
|
2086
|
+
u: using.bind(null, !1),
|
|
2087
|
+
a: using.bind(null, !0),
|
|
2088
|
+
d: function d() {
|
|
2089
|
+
var o, t = this.e, s = 0;
|
|
2090
|
+
function next() {
|
|
2091
|
+
for (; o = n.pop();) try {
|
|
2092
|
+
if (!o.a && 1 === s) return s = 0, n.push(o), Promise.resolve().then(next);
|
|
2093
|
+
if (o.d) {
|
|
2094
|
+
var r = o.d.call(o.v);
|
|
2095
|
+
if (o.a) return s |= 2, Promise.resolve(r).then(next, err);
|
|
2096
|
+
} else s |= 1;
|
|
2097
|
+
} catch (r) {
|
|
2098
|
+
return err(r);
|
|
2099
|
+
}
|
|
2100
|
+
if (1 === s) return t !== e ? Promise.reject(t) : Promise.resolve();
|
|
2101
|
+
if (t !== e) throw t;
|
|
2102
|
+
}
|
|
2103
|
+
function err(n) {
|
|
2104
|
+
return t = t !== e ? new r(n, t) : n, next();
|
|
2105
|
+
}
|
|
2106
|
+
return next();
|
|
2107
|
+
}
|
|
2108
|
+
};
|
|
2109
|
+
}
|
|
2110
|
+
//#endregion
|
|
2111
|
+
//#region vendor/dsh-llm-pi-ai/src/adapter.ts
|
|
2112
|
+
/**
|
|
2113
|
+
* Generic pi-ai-backed implementation of the ChatCode CLI LLM seam.
|
|
2114
|
+
*
|
|
2115
|
+
* Each resolution produces one **immutable** snapshot — the profiles plus a
|
|
2116
|
+
* `Models` collection holding the `Provider` each route built — and an
|
|
2117
|
+
* operation captures a whole snapshot before its first `await`. A
|
|
2118
|
+
* configuration change builds a *new* collection rather than mutating the one
|
|
2119
|
+
* in use, because `Models.streamSimple()` is lazy: it resolves the provider
|
|
2120
|
+
* when the stream is first consumed, which is after the credential await, so a
|
|
2121
|
+
* mutated collection would let a request that started under one configuration
|
|
2122
|
+
* finish under another — or fail with a provider that no longer exists. This is
|
|
2123
|
+
* what makes the seam's per-step call freeze (`llm.prepareCall()`) hold all the
|
|
2124
|
+
* way down: switching models mid-reply takes effect on the next step, never
|
|
2125
|
+
* inside the one in flight.
|
|
2126
|
+
*
|
|
2127
|
+
* A route naming a credential reference still resolves it through the harness
|
|
2128
|
+
* seam and passes it as the request's `apiKey` option, which pi-ai treats as
|
|
2129
|
+
* the highest-priority auth override — that is what keeps the fail-loud
|
|
2130
|
+
* reference semantics. Everything that override does not cover reaches pi-ai
|
|
2131
|
+
* through the collection's own auth: the credential store holds the records a
|
|
2132
|
+
* login wrote and a refresh rotates, and the auth context answers the ambient
|
|
2133
|
+
* questions a provider asks while resolving. Both are stable across snapshots,
|
|
2134
|
+
* so a configuration change rebuilds the collection without forgetting who is
|
|
2135
|
+
* signed in.
|
|
2136
|
+
*
|
|
2137
|
+
* @module dsh-llm-pi-ai/adapter
|
|
2138
|
+
*/
|
|
2139
|
+
/** Copy profile stream knobs into pi-ai's common option vocabulary. */
|
|
2140
|
+
function profileOptions(profile, reasoning, apiKey) {
|
|
2141
|
+
const enabledReasoning = reasoning === "off" ? void 0 : reasoning;
|
|
2142
|
+
return {
|
|
2143
|
+
...apiKey === void 0 ? {} : { apiKey },
|
|
2144
|
+
...enabledReasoning === void 0 ? {} : { reasoning: enabledReasoning },
|
|
2145
|
+
...profile.reasoningSplit === void 0 ? {} : { samplingParams: { reasoning_split: profile.reasoningSplit } },
|
|
2146
|
+
...profile.thinkingBudgets === void 0 ? {} : { thinkingBudgets: profile.thinkingBudgets },
|
|
2147
|
+
...profile.cacheRetention === void 0 ? {} : { cacheRetention: profile.cacheRetention },
|
|
2148
|
+
...profile.transport === void 0 ? {} : { transport: profile.transport },
|
|
2149
|
+
...profile.timeoutMs === void 0 ? {} : { timeoutMs: profile.timeoutMs },
|
|
2150
|
+
...profile.websocketConnectTimeoutMs === void 0 ? {} : { websocketConnectTimeoutMs: profile.websocketConnectTimeoutMs },
|
|
2151
|
+
maxRetries: 0
|
|
2152
|
+
};
|
|
2153
|
+
}
|
|
2154
|
+
/**
|
|
2155
|
+
* The profile default this exact model can actually take, for DESCRIBING it.
|
|
2156
|
+
* A configured level the model does not support yields none rather than
|
|
2157
|
+
* throwing: `resolveModel` builds the model catalog, and a catalog that fails
|
|
2158
|
+
* takes its whole provider out of every picker — so one mis-set profile field
|
|
2159
|
+
* would hide every model on the route, including the ones that support the
|
|
2160
|
+
* level. The request path still refuses, which is where a bad configuration
|
|
2161
|
+
* belongs: describing what a model can do must not fail because a deployment
|
|
2162
|
+
* asked it for something it cannot.
|
|
2163
|
+
* @param model - the resolved model descriptor.
|
|
2164
|
+
* @param effort - the profile's configured level, if any.
|
|
2165
|
+
* @returns the level when this model supports it, otherwise undefined.
|
|
2166
|
+
*/
|
|
2167
|
+
function describableReasoningLevel(model, effort) {
|
|
2168
|
+
if (effort === void 0) return void 0;
|
|
2169
|
+
return getSupportedThinkingLevels(model).some((level) => level === effort) ? effort : void 0;
|
|
2170
|
+
}
|
|
2171
|
+
/** Validate an explicit ChatCode CLI profile effort without invoking pi-ai's clamp. */
|
|
2172
|
+
function resolveReasoningLevel(model, effort) {
|
|
2173
|
+
if (effort === void 0) return void 0;
|
|
2174
|
+
if (getSupportedThinkingLevels(model).some((level) => level === effort)) return effort;
|
|
2175
|
+
throw new LlmError(`pi-ai provider "${model.provider}" model "${model.id}" does not support reasoning effort "${effort}"`, "UNSUPPORTED_REASONING_EFFORT");
|
|
2176
|
+
}
|
|
2177
|
+
/**
|
|
2178
|
+
* Selectable reasoning efforts for one model, or nothing at all.
|
|
2179
|
+
*
|
|
2180
|
+
* A model that carries no reasoning metadata — every hand-declared one, and
|
|
2181
|
+
* every catalog model pi-ai marks as non-reasoning — is reported by pi-ai as
|
|
2182
|
+
* supporting the single level `off`. Passing that through would offer a control
|
|
2183
|
+
* that cannot do what it says: `off` is translated to *omitting* the reasoning
|
|
2184
|
+
* option, which for such a model is byte-for-byte the same request as naming no
|
|
2185
|
+
* effort — so a provider whose own default is to think would keep thinking with
|
|
2186
|
+
* `off` selected. Omitting `reasoning` entirely is the seam's way of saying the
|
|
2187
|
+
* capability is unavailable, which leaves the surface offering only the
|
|
2188
|
+
* provider's default.
|
|
2189
|
+
* @param model - the resolved model descriptor.
|
|
2190
|
+
* @param defaultLevel - the profile's configured effort, already validated.
|
|
2191
|
+
* @returns the `reasoning` field, or an empty object when none can be offered.
|
|
2192
|
+
*/
|
|
2193
|
+
function reasoningInfo(model, defaultLevel) {
|
|
2194
|
+
if (!model.reasoning) return {};
|
|
2195
|
+
return { reasoning: {
|
|
2196
|
+
efforts: getSupportedThinkingLevels(model).map((level) => ({
|
|
2197
|
+
id: ReasoningEffortId(level),
|
|
2198
|
+
name: `${level.charAt(0).toUpperCase()}${level.slice(1)}`
|
|
2199
|
+
})),
|
|
2200
|
+
...defaultLevel === void 0 ? {} : { defaultEffort: ReasoningEffortId(defaultLevel) }
|
|
2201
|
+
} };
|
|
2202
|
+
}
|
|
2203
|
+
/** Merge deployment headers while removing case-insensitive attribution collisions. */
|
|
2204
|
+
function requestHeaders(headers, auth) {
|
|
2205
|
+
const attribution = attributionHeaders();
|
|
2206
|
+
const reserved = new Set([...Object.keys(attribution), ...Object.keys(auth ?? {})].map((name) => name.toLowerCase()));
|
|
2207
|
+
return {
|
|
2208
|
+
...Object.fromEntries(Object.entries(headers ?? {}).filter(([name]) => !reserved.has(name.toLowerCase()))),
|
|
2209
|
+
...Object.fromEntries(Object.entries(auth ?? {}).filter(([name]) => !Object.keys(attribution).some((reservedName) => reservedName.toLowerCase() === name.toLowerCase()))),
|
|
2210
|
+
...attribution
|
|
2211
|
+
};
|
|
2212
|
+
}
|
|
2213
|
+
/**
|
|
2214
|
+
* pi-ai-backed multi-provider adapter. Each operation reads the current
|
|
2215
|
+
* profiles, so a configuration change reaches the next request without a
|
|
2216
|
+
* restart; model descriptors come from the collection those profiles built.
|
|
2217
|
+
*/
|
|
2218
|
+
var PiAiAdapter = class extends LlmAdapter {
|
|
2219
|
+
config;
|
|
2220
|
+
snapshot;
|
|
2221
|
+
constructor(config) {
|
|
2222
|
+
super();
|
|
2223
|
+
this.config = config;
|
|
2224
|
+
}
|
|
2225
|
+
/**
|
|
2226
|
+
* The snapshot for the current profiles. Resolution memoizes its result, so
|
|
2227
|
+
* an unchanged configuration is recognized by identity; a changed one gets a
|
|
2228
|
+
* brand-new collection, leaving any snapshot an operation already captured
|
|
2229
|
+
* untouched for as long as that operation holds it.
|
|
2230
|
+
*/
|
|
2231
|
+
current() {
|
|
2232
|
+
const profiles = this.config.profiles();
|
|
2233
|
+
if (this.snapshot?.profiles === profiles) return this.snapshot;
|
|
2234
|
+
const models = createModels(this.config.auth);
|
|
2235
|
+
for (const profile of profiles.values()) models.setProvider(profile.piProvider);
|
|
2236
|
+
this.snapshot = {
|
|
2237
|
+
profiles,
|
|
2238
|
+
models
|
|
2239
|
+
};
|
|
2240
|
+
return this.snapshot;
|
|
2241
|
+
}
|
|
2242
|
+
/** The profile for one route within one snapshot, or the not-owned failure. */
|
|
2243
|
+
profileOf(snapshot, provider) {
|
|
2244
|
+
const profile = snapshot.profiles.get(provider);
|
|
2245
|
+
if (profile === void 0) throw new LlmError(`pi-ai adapter does not own provider "${provider}"`, "NO_ADAPTER");
|
|
2246
|
+
return profile;
|
|
2247
|
+
}
|
|
2248
|
+
/** The configured descriptor for one exact route/model pair within one snapshot. */
|
|
2249
|
+
modelOf(snapshot, provider, model) {
|
|
2250
|
+
this.profileOf(snapshot, provider);
|
|
2251
|
+
const resolved = snapshot.models.getModel(provider, model);
|
|
2252
|
+
if (resolved === void 0) throw new LlmError(`pi-ai provider "${provider}" has no configured model "${model}"`, "UNKNOWN_MODEL");
|
|
2253
|
+
return resolved;
|
|
2254
|
+
}
|
|
2255
|
+
providerInfo(provider) {
|
|
2256
|
+
return {
|
|
2257
|
+
id: provider,
|
|
2258
|
+
name: this.current().profiles.get(provider)?.displayName ?? provider
|
|
2259
|
+
};
|
|
2260
|
+
}
|
|
2261
|
+
providerRetryPolicy(provider) {
|
|
2262
|
+
return this.current().profiles.get(provider)?.retryPolicy;
|
|
2263
|
+
}
|
|
2264
|
+
listModels(provider) {
|
|
2265
|
+
return Promise.resolve().then(() => {
|
|
2266
|
+
const snapshot = this.current();
|
|
2267
|
+
this.profileOf(snapshot, provider);
|
|
2268
|
+
return snapshot.models.getModels(provider).map((model) => ({
|
|
2269
|
+
provider,
|
|
2270
|
+
id: model.id,
|
|
2271
|
+
name: model.name,
|
|
2272
|
+
inputModalities: [...model.input]
|
|
2273
|
+
}));
|
|
2274
|
+
});
|
|
2275
|
+
}
|
|
2276
|
+
resolveModel(provider, model, _signal) {
|
|
2277
|
+
return Promise.resolve().then(() => {
|
|
2278
|
+
const snapshot = this.current();
|
|
2279
|
+
return this.modelInfo(snapshot, provider, model);
|
|
2280
|
+
});
|
|
2281
|
+
}
|
|
2282
|
+
modelInfo(snapshot, provider, model) {
|
|
2283
|
+
const profile = this.profileOf(snapshot, provider);
|
|
2284
|
+
const resolvedModel = this.modelOf(snapshot, provider, model);
|
|
2285
|
+
const defaultLevel = describableReasoningLevel(resolvedModel, profile.reasoning);
|
|
2286
|
+
const configuredMaxTokens = profile.configuredMaxTokens.get(model);
|
|
2287
|
+
return {
|
|
2288
|
+
provider,
|
|
2289
|
+
id: model,
|
|
2290
|
+
name: resolvedModel.name,
|
|
2291
|
+
inputModalities: [...resolvedModel.input],
|
|
2292
|
+
context: { contextWindow: resolvedModel.contextWindow },
|
|
2293
|
+
...configuredMaxTokens === void 0 ? {} : { defaultMaxTokens: configuredMaxTokens },
|
|
2294
|
+
...reasoningInfo(resolvedModel, defaultLevel)
|
|
2295
|
+
};
|
|
2296
|
+
}
|
|
2297
|
+
prepareCall(provider, model, _signal) {
|
|
2298
|
+
const snapshot = this.current();
|
|
2299
|
+
return Promise.resolve({
|
|
2300
|
+
model: this.modelInfo(snapshot, provider, model),
|
|
2301
|
+
stream: (options) => this.streamWithSnapshot(options, snapshot)
|
|
2302
|
+
});
|
|
2303
|
+
}
|
|
2304
|
+
stream(options) {
|
|
2305
|
+
return this.streamWithSnapshot(options, this.current());
|
|
2306
|
+
}
|
|
2307
|
+
async *streamWithSnapshot(options, snapshot) {
|
|
2308
|
+
try {
|
|
2309
|
+
var _usingCtx$1 = _usingCtx();
|
|
2310
|
+
if (options.stop !== void 0) throw new LlmError("llm-pi-ai does not support GenerateOptions.stop", "UNSUPPORTED_OPTION");
|
|
2311
|
+
const profile = this.profileOf(snapshot, options.provider);
|
|
2312
|
+
const model = this.modelOf(snapshot, options.provider, options.model);
|
|
2313
|
+
const reasoning = resolveReasoningLevel(model, options.reasoningEffort ?? profile.reasoning);
|
|
2314
|
+
const auth = await this.config.resolveAuth(options.provider, profile);
|
|
2315
|
+
const consumer = new AbortController();
|
|
2316
|
+
const upstream = options.signal === void 0 ? consumer.signal : AbortSignal.any([options.signal, consumer.signal]);
|
|
2317
|
+
const streamIdleTimeoutMs = profile.streamIdleTimeoutMs;
|
|
2318
|
+
const watchdog = _usingCtx$1.u(idleWatchdog(upstream, streamIdleTimeoutMs, "LLM_STREAM_IDLE_TIMEOUT"));
|
|
2319
|
+
try {
|
|
2320
|
+
const containsImage = options.messages.some((message) => contentHasImage(message.content));
|
|
2321
|
+
if (containsImage && !model.input.includes("image")) throw new LlmError(`pi-ai model "${model.id}" does not support image input`, "UNSUPPORTED_CONTENT");
|
|
2322
|
+
const attachments = containsImage ? this.config.resolveAttachments?.() : void 0;
|
|
2323
|
+
if (containsImage && attachments === void 0) throw new LlmError("pi-ai image input requires the durable attachment service", "UNSUPPORTED_CONTENT");
|
|
2324
|
+
const onReplayDegrade = (reason) => {
|
|
2325
|
+
this.config.onReplayDegrade?.({
|
|
2326
|
+
provider: options.provider,
|
|
2327
|
+
model: options.model,
|
|
2328
|
+
reason
|
|
2329
|
+
});
|
|
2330
|
+
};
|
|
2331
|
+
const context = attachments === void 0 ? toPiContext(options, void 0, onReplayDegrade) : await toPiContext({
|
|
2332
|
+
...options,
|
|
2333
|
+
signal: watchdog.signal
|
|
2334
|
+
}, {
|
|
2335
|
+
attachments,
|
|
2336
|
+
resolveImageAccess: (ref) => this.config.resolveImageAccess?.(attachments, ref),
|
|
2337
|
+
maxRequestImageBytes: profile.maxRequestImageBytes,
|
|
2338
|
+
requestImagePolicy: {
|
|
2339
|
+
maxPixels: profile.requestImagePixelBudget,
|
|
2340
|
+
maxBytes: profile.requestImageMaxBytes
|
|
2341
|
+
}
|
|
2342
|
+
}, onReplayDegrade);
|
|
2343
|
+
const iterator = toStreamChunks(snapshot.models.streamSimple(model, context, {
|
|
2344
|
+
...profileOptions(profile, reasoning, auth.apiKey),
|
|
2345
|
+
...options.temperature === void 0 ? {} : { temperature: options.temperature },
|
|
2346
|
+
...options.maxTokens === void 0 ? {} : { maxTokens: options.maxTokens },
|
|
2347
|
+
...options.sessionId === void 0 ? {} : { sessionId: String(options.sessionId) },
|
|
2348
|
+
signal: watchdog.signal,
|
|
2349
|
+
headers: requestHeaders(profile.headers, auth.headers)
|
|
2350
|
+
}), model.contextWindow, options.signal)[Symbol.asyncIterator]();
|
|
2351
|
+
let exhausted = false;
|
|
2352
|
+
try {
|
|
2353
|
+
while (true) {
|
|
2354
|
+
const result = await watchdog.next(iterator);
|
|
2355
|
+
const timeout = timeoutOf(watchdog.signal, "LLM_STREAM_IDLE_TIMEOUT");
|
|
2356
|
+
if (timeout !== void 0) throw timeout;
|
|
2357
|
+
if (result.done) {
|
|
2358
|
+
exhausted = true;
|
|
2359
|
+
return;
|
|
2360
|
+
}
|
|
2361
|
+
yield result.value;
|
|
2362
|
+
}
|
|
2363
|
+
} finally {
|
|
2364
|
+
if (!exhausted) {
|
|
2365
|
+
consumer.abort("pi-ai stream consumer stopped");
|
|
2366
|
+
try {
|
|
2367
|
+
await iterator.return(void 0);
|
|
2368
|
+
} catch (_abortedSdkTeardown) {}
|
|
2369
|
+
}
|
|
2370
|
+
}
|
|
2371
|
+
} catch (error) {
|
|
2372
|
+
if (timeoutOf(watchdog.signal, "LLM_STREAM_IDLE_TIMEOUT") !== void 0) throw new LlmError(`pi-ai stream idle timeout after ${streamIdleTimeoutMs}ms`, "TIMEOUT", { cause: error });
|
|
2373
|
+
if (options.signal?.aborted) throw new LlmError("pi-ai request aborted by caller", "ABORTED", { cause: error });
|
|
2374
|
+
throw error;
|
|
2375
|
+
} finally {
|
|
2376
|
+
consumer.abort("pi-ai stream consumer stopped");
|
|
2377
|
+
}
|
|
2378
|
+
} catch (_) {
|
|
2379
|
+
_usingCtx$1.e = _;
|
|
2380
|
+
} finally {
|
|
2381
|
+
_usingCtx$1.d();
|
|
2382
|
+
}
|
|
2383
|
+
}
|
|
2384
|
+
};
|
|
2385
|
+
//#endregion
|
|
2386
|
+
//#region src/adapter.ts
|
|
2387
|
+
/** Aggregate imported models while dispatching each private route through its selected adapter. @module dsh-llm-chatcode-config/adapter */
|
|
2388
|
+
/** One public provider route containing every imported ChatCode custom model. */
|
|
2389
|
+
const CHATCODE_PROVIDER = "chatcode-custom";
|
|
2390
|
+
/** Display name for the unified imported-model group. */
|
|
2391
|
+
const CHATCODE_PROVIDER_NAME = "自定义模型";
|
|
2392
|
+
/** Provider route and selector group for centrally managed Coding Plan models. */
|
|
2393
|
+
const CODING_PLAN_PROVIDER = "chatcode-codingplan";
|
|
2394
|
+
const CODING_PLAN_PROVIDER_NAME = "内置模型";
|
|
2395
|
+
/** Provider route and selector group for opt-in MAAS models. */
|
|
2396
|
+
const MAAS_PROVIDER = "chatcode-maas";
|
|
2397
|
+
const MAAS_PROVIDER_NAME = "Maas平台";
|
|
2398
|
+
const DEEPSEEK_MODEL = /deepseek/i;
|
|
2399
|
+
function privateModelKey(route, model) {
|
|
2400
|
+
return `${route}\u0000${model}`;
|
|
2401
|
+
}
|
|
2402
|
+
function createDeepSeekAdapter(route, profile, model, apiKey) {
|
|
2403
|
+
const protocol = profile.api === "anthropic-messages" ? "messages" : profile.api === "openai-completions" ? "chat-completions" : void 0;
|
|
2404
|
+
if (protocol === void 0 || profile.baseURL === void 0) throw new LlmError(`chatcode-config: DeepSeek model route "${route}" has no supported protocol or base URL`, "INVALID_CHATCODE_CONFIG");
|
|
2405
|
+
const connection = {
|
|
2406
|
+
...resolveAdapterOptions({
|
|
2407
|
+
protocol,
|
|
2408
|
+
baseURL: profile.baseURL,
|
|
2409
|
+
defaultContextWindow: model.contextWindow,
|
|
2410
|
+
maxTokens: model.maxTokens,
|
|
2411
|
+
models: [{
|
|
2412
|
+
id: model.id,
|
|
2413
|
+
name: model.name,
|
|
2414
|
+
contextWindow: model.contextWindow,
|
|
2415
|
+
maxTokens: model.maxTokens,
|
|
2416
|
+
inputModalities: [...model.input]
|
|
2417
|
+
}],
|
|
2418
|
+
streamIdleTimeoutMs: profile.streamIdleTimeoutMs
|
|
2419
|
+
}),
|
|
2420
|
+
retryPolicy: profile.retryPolicy
|
|
2421
|
+
};
|
|
2422
|
+
return new DeepSeekAdapter({
|
|
2423
|
+
options: () => connection,
|
|
2424
|
+
resolveApiKey: () => {
|
|
2425
|
+
if (apiKey === void 0) throw new LlmError(`chatcode-config: no API key for DeepSeek model "${model.id}"`, "MISSING_CREDENTIAL");
|
|
2426
|
+
return Promise.resolve(apiKey);
|
|
2427
|
+
},
|
|
2428
|
+
resolveUserId: () => getOrCreateAnonymousUserId(),
|
|
2429
|
+
prepareExtensions: () => Promise.resolve({
|
|
2430
|
+
fields: {},
|
|
2431
|
+
accept: () => Promise.resolve()
|
|
2432
|
+
})
|
|
2433
|
+
});
|
|
2434
|
+
}
|
|
2435
|
+
/** A static unified ChatCode catalog with request adapters selected from actual model ids. */
|
|
2436
|
+
var ChatCodeAdapter = class extends LlmAdapter {
|
|
2437
|
+
profiles;
|
|
2438
|
+
provider;
|
|
2439
|
+
providerName;
|
|
2440
|
+
piAdapter;
|
|
2441
|
+
deepSeekAdapters = /* @__PURE__ */ new Map();
|
|
2442
|
+
routes = /* @__PURE__ */ new Map();
|
|
2443
|
+
publicModels = /* @__PURE__ */ new Map();
|
|
2444
|
+
constructor(options, profiles, provider = CHATCODE_PROVIDER, providerName = CHATCODE_PROVIDER_NAME, selections, apiKeys = /* @__PURE__ */ new Map()) {
|
|
2445
|
+
super();
|
|
2446
|
+
this.profiles = profiles;
|
|
2447
|
+
this.provider = provider;
|
|
2448
|
+
this.providerName = providerName;
|
|
2449
|
+
this.piAdapter = new PiAiAdapter(options);
|
|
2450
|
+
for (const [route, profile] of profiles) for (const model of profile.piProvider.getModels()) {
|
|
2451
|
+
const selection = [...selections ?? /* @__PURE__ */ new Map()].find(([, target]) => target.route === route && target.model === model.id)?.[0] ?? model.id;
|
|
2452
|
+
if (this.routes.has(selection)) throw new LlmError(`chatcode-config provider has duplicate public model "${selection}"`, "INVALID_CHATCODE_CONFIG");
|
|
2453
|
+
this.routes.set(selection, {
|
|
2454
|
+
selection,
|
|
2455
|
+
route,
|
|
2456
|
+
model: model.id
|
|
2457
|
+
});
|
|
2458
|
+
this.publicModels.set(privateModelKey(route, model.id), selection);
|
|
2459
|
+
if (DEEPSEEK_MODEL.test(model.id)) this.deepSeekAdapters.set(privateModelKey(route, model.id), createDeepSeekAdapter(route, profile, model, apiKeys.get(route)));
|
|
2460
|
+
}
|
|
2461
|
+
}
|
|
2462
|
+
requestAdapter(target) {
|
|
2463
|
+
return this.deepSeekAdapters.get(privateModelKey(target.route, target.model)) ?? this.piAdapter;
|
|
2464
|
+
}
|
|
2465
|
+
/** Reject routes outside the one public provider registered by this adapter. */
|
|
2466
|
+
assertProvider(provider) {
|
|
2467
|
+
if (provider !== this.provider) throw new LlmError(`chatcode-config adapter does not own provider "${provider}"`, "NO_ADAPTER");
|
|
2468
|
+
}
|
|
2469
|
+
/** Resolve the private route for one publicly selected model. */
|
|
2470
|
+
routeOf(provider, model) {
|
|
2471
|
+
this.assertProvider(provider);
|
|
2472
|
+
const target = this.routes.get(model);
|
|
2473
|
+
if (target === void 0) throw new LlmError(`chatcode-config provider has no configured model "${model}"`, "UNKNOWN_MODEL");
|
|
2474
|
+
return target;
|
|
2475
|
+
}
|
|
2476
|
+
validateOutput(options, target) {
|
|
2477
|
+
const ceiling = this.profiles.get(target.route)?.configuredMaxTokens.get(target.model);
|
|
2478
|
+
if (ceiling !== void 0 && options.maxTokens !== void 0 && options.maxTokens > ceiling) throw new LlmError("chatcode-config: maxTokens exceeds the configured model output limit", "UNSUPPORTED_OPTION");
|
|
2479
|
+
}
|
|
2480
|
+
providerInfo(provider) {
|
|
2481
|
+
this.assertProvider(provider);
|
|
2482
|
+
return {
|
|
2483
|
+
id: provider,
|
|
2484
|
+
name: this.providerName
|
|
2485
|
+
};
|
|
2486
|
+
}
|
|
2487
|
+
providerRetryPolicy(_provider) {
|
|
2488
|
+
return this.profiles.values().next().value?.retryPolicy;
|
|
2489
|
+
}
|
|
2490
|
+
async listModels(provider) {
|
|
2491
|
+
this.assertProvider(provider);
|
|
2492
|
+
return (await Promise.all([...this.profiles.keys()].map(async (route) => ({
|
|
2493
|
+
route,
|
|
2494
|
+
models: await this.piAdapter.listModels(route)
|
|
2495
|
+
})))).flatMap(({ route, models }) => models.map((model) => ({
|
|
2496
|
+
...model,
|
|
2497
|
+
id: this.publicModels.get(privateModelKey(route, model.id)) ?? model.id,
|
|
2498
|
+
provider: this.provider
|
|
2499
|
+
})));
|
|
2500
|
+
}
|
|
2501
|
+
async resolveModel(provider, model, signal) {
|
|
2502
|
+
const target = this.routeOf(provider, model);
|
|
2503
|
+
return {
|
|
2504
|
+
...await this.requestAdapter(target).resolveModel(target.route, target.model, signal),
|
|
2505
|
+
id: target.selection,
|
|
2506
|
+
provider: this.provider
|
|
2507
|
+
};
|
|
2508
|
+
}
|
|
2509
|
+
async prepareCall(provider, model, signal) {
|
|
2510
|
+
const target = this.routeOf(provider, model);
|
|
2511
|
+
const prepared = await this.requestAdapter(target).prepareCall(target.route, target.model, signal);
|
|
2512
|
+
return {
|
|
2513
|
+
model: {
|
|
2514
|
+
...prepared.model,
|
|
2515
|
+
id: target.selection,
|
|
2516
|
+
provider: this.provider
|
|
2517
|
+
},
|
|
2518
|
+
stream: (options) => {
|
|
2519
|
+
const preparedTarget = this.routeOf(options.provider, options.model);
|
|
2520
|
+
this.validateOutput(options, preparedTarget);
|
|
2521
|
+
return prepared.stream({
|
|
2522
|
+
...options,
|
|
2523
|
+
provider: preparedTarget.route,
|
|
2524
|
+
model: preparedTarget.model
|
|
2525
|
+
});
|
|
2526
|
+
}
|
|
2527
|
+
};
|
|
2528
|
+
}
|
|
2529
|
+
stream(options) {
|
|
2530
|
+
const target = this.routeOf(options.provider, options.model);
|
|
2531
|
+
this.validateOutput(options, target);
|
|
2532
|
+
return this.requestAdapter(target).stream({
|
|
2533
|
+
...options,
|
|
2534
|
+
provider: target.route,
|
|
2535
|
+
model: target.model
|
|
2536
|
+
});
|
|
2537
|
+
}
|
|
2538
|
+
};
|
|
2539
|
+
/** Live custom-model adapter whose prepared calls retain their starting settings snapshot. */
|
|
2540
|
+
var LiveChatCodeAdapter = class extends LlmAdapter {
|
|
2541
|
+
source;
|
|
2542
|
+
provider;
|
|
2543
|
+
providerName;
|
|
2544
|
+
snapshot;
|
|
2545
|
+
constructor(source, provider = CHATCODE_PROVIDER, providerName = CHATCODE_PROVIDER_NAME) {
|
|
2546
|
+
super();
|
|
2547
|
+
this.source = source;
|
|
2548
|
+
this.provider = provider;
|
|
2549
|
+
this.providerName = providerName;
|
|
2550
|
+
}
|
|
2551
|
+
current() {
|
|
2552
|
+
const source = this.source();
|
|
2553
|
+
if (this.snapshot?.source === source) return this.snapshot.adapter;
|
|
2554
|
+
const adapter = new ChatCodeAdapter({
|
|
2555
|
+
profiles: () => source.profiles,
|
|
2556
|
+
resolveAuth: (route) => {
|
|
2557
|
+
const auth = source.auth.get(route);
|
|
2558
|
+
if (auth === void 0) throw new LlmError("chatcode-config: missing auth for resolved profile", "INVARIANT");
|
|
2559
|
+
return Promise.resolve(auth);
|
|
2560
|
+
},
|
|
2561
|
+
auth: isolatedPiAiAuth()
|
|
2562
|
+
}, source.profiles, this.provider, this.providerName, source.selections, source.apiKeys);
|
|
2563
|
+
this.snapshot = {
|
|
2564
|
+
source,
|
|
2565
|
+
adapter
|
|
2566
|
+
};
|
|
2567
|
+
return adapter;
|
|
2568
|
+
}
|
|
2569
|
+
providerInfo(provider) {
|
|
2570
|
+
return this.current().providerInfo(provider);
|
|
2571
|
+
}
|
|
2572
|
+
providerRetryPolicy(provider) {
|
|
2573
|
+
return this.current().providerRetryPolicy(provider);
|
|
2574
|
+
}
|
|
2575
|
+
listModels(provider) {
|
|
2576
|
+
return this.current().listModels(provider);
|
|
2577
|
+
}
|
|
2578
|
+
resolveModel(provider, model, signal) {
|
|
2579
|
+
return this.current().resolveModel(provider, model, signal);
|
|
2580
|
+
}
|
|
2581
|
+
prepareCall(provider, model, signal) {
|
|
2582
|
+
return this.current().prepareCall(provider, model, signal);
|
|
2583
|
+
}
|
|
2584
|
+
stream(options) {
|
|
2585
|
+
return this.current().stream(options);
|
|
2586
|
+
}
|
|
2587
|
+
};
|
|
2588
|
+
//#endregion
|
|
2589
|
+
//#region src/chatcode-auth.ts
|
|
2590
|
+
/** ChatCode session login and its shared, host-only credential record. */
|
|
2591
|
+
/** Keep the address stable for grants written by the initial implementation. */
|
|
2592
|
+
const CHATCODE_CREDENTIAL_KEY = credentialKey("chatcode-auth", "default");
|
|
2593
|
+
/** Process-scoped credential accepted from immutable desktop/terminal launchers. */
|
|
2594
|
+
const CHATCODE_CLI_OAUTH_TOKEN = "CHATCODE_CLI_OAUTH_TOKEN";
|
|
2595
|
+
const objectOf = (value) => value !== null && typeof value === "object" ? value : {};
|
|
2596
|
+
const stringOf = (value) => typeof value === "string" && value.trim() !== "" ? value.trim() : void 0;
|
|
2597
|
+
const finiteTime = (value) => typeof value === "number" && Number.isFinite(value) && value > 0 && value <= 864e13;
|
|
2598
|
+
function endpoint(value) {
|
|
2599
|
+
const url = new URL(value);
|
|
2600
|
+
if (url.username || url.password || url.protocol !== "https:" && !(url.protocol === "http:" && [
|
|
2601
|
+
"localhost",
|
|
2602
|
+
"127.0.0.1",
|
|
2603
|
+
"[::1]"
|
|
2604
|
+
].includes(url.hostname))) throw new Error("ChatCode endpoints require HTTPS (HTTP is allowed only on loopback).");
|
|
2605
|
+
return url;
|
|
2606
|
+
}
|
|
2607
|
+
/** Preserve the configured hash-router query while adding this attempt's UUID. */
|
|
2608
|
+
function loginUrlOf(loginUrl, sessionId) {
|
|
2609
|
+
const url = endpoint(loginUrl);
|
|
2610
|
+
if (!url.hash) url.searchParams.set("sessionId", sessionId);
|
|
2611
|
+
else {
|
|
2612
|
+
const hash = url.hash.slice(1);
|
|
2613
|
+
const split = hash.indexOf("?");
|
|
2614
|
+
const route = split < 0 ? hash : hash.slice(0, split);
|
|
2615
|
+
const params = new URLSearchParams(split < 0 ? "" : hash.slice(split + 1));
|
|
2616
|
+
params.set("sessionId", sessionId);
|
|
2617
|
+
url.hash = `${route}?${params.toString()}`;
|
|
2618
|
+
}
|
|
2619
|
+
return url.href;
|
|
2620
|
+
}
|
|
2621
|
+
/** Decode both the original payload and the concurrency-safe envelope. */
|
|
2622
|
+
function grantOf(value) {
|
|
2623
|
+
const envelope = objectOf(value);
|
|
2624
|
+
const candidate = envelope.version === 2 ? objectOf(envelope.grant) : envelope;
|
|
2625
|
+
if (candidate.version !== 1 || !stringOf(candidate.accessToken)) return void 0;
|
|
2626
|
+
for (const field of [
|
|
2627
|
+
"refreshToken",
|
|
2628
|
+
"longToken",
|
|
2629
|
+
"userName",
|
|
2630
|
+
"emailAddress"
|
|
2631
|
+
]) if (candidate[field] !== void 0 && typeof candidate[field] !== "string") return void 0;
|
|
2632
|
+
if (candidate.expiresAtMs !== void 0 && !finiteTime(candidate.expiresAtMs)) return void 0;
|
|
2633
|
+
return {
|
|
2634
|
+
version: 1,
|
|
2635
|
+
accessToken: stringOf(candidate.accessToken),
|
|
2636
|
+
...candidate.refreshToken === void 0 ? {} : { refreshToken: candidate.refreshToken },
|
|
2637
|
+
...candidate.longToken === void 0 ? {} : { longToken: candidate.longToken },
|
|
2638
|
+
...candidate.userName === void 0 ? {} : { userName: candidate.userName },
|
|
2639
|
+
...candidate.emailAddress === void 0 ? {} : { emailAddress: candidate.emailAddress },
|
|
2640
|
+
...candidate.expiresAtMs === void 0 ? {} : { expiresAtMs: candidate.expiresAtMs }
|
|
2641
|
+
};
|
|
2642
|
+
}
|
|
2643
|
+
function stateOf(record) {
|
|
2644
|
+
const payload = objectOf(record?.kind === "grant" ? record.payload : void 0);
|
|
2645
|
+
const grant = grantOf(payload);
|
|
2646
|
+
const state = {
|
|
2647
|
+
version: 2,
|
|
2648
|
+
...grant ? { grant } : {}
|
|
2649
|
+
};
|
|
2650
|
+
if (payload.version !== 2) return state;
|
|
2651
|
+
const login = objectOf(payload.login);
|
|
2652
|
+
if (typeof login.sessionId === "string" && finiteTime(login.expiresAtMs) && [
|
|
2653
|
+
"pending",
|
|
2654
|
+
"succeeded",
|
|
2655
|
+
"failed",
|
|
2656
|
+
"timed-out",
|
|
2657
|
+
"cancelled"
|
|
2658
|
+
].includes(String(login.state))) state.login = {
|
|
2659
|
+
sessionId: login.sessionId,
|
|
2660
|
+
state: login.state,
|
|
2661
|
+
expiresAtMs: login.expiresAtMs
|
|
2662
|
+
};
|
|
2663
|
+
if ([
|
|
2664
|
+
"valid",
|
|
2665
|
+
"invalid",
|
|
2666
|
+
"unavailable"
|
|
2667
|
+
].includes(String(payload.validation)) && finiteTime(payload.checkedAtMs)) {
|
|
2668
|
+
state.validation = payload.validation;
|
|
2669
|
+
state.checkedAtMs = payload.checkedAtMs;
|
|
2670
|
+
}
|
|
2671
|
+
return state;
|
|
2672
|
+
}
|
|
2673
|
+
function recordOf(state) {
|
|
2674
|
+
return {
|
|
2675
|
+
kind: "grant",
|
|
2676
|
+
payload: { ...state }
|
|
2677
|
+
};
|
|
2678
|
+
}
|
|
2679
|
+
function emailOf(value) {
|
|
2680
|
+
const root = objectOf(value);
|
|
2681
|
+
const data = objectOf(root.data);
|
|
2682
|
+
for (const source of [
|
|
2683
|
+
data,
|
|
2684
|
+
objectOf(data.account),
|
|
2685
|
+
root
|
|
2686
|
+
]) for (const key of [
|
|
2687
|
+
"email",
|
|
2688
|
+
"emailAddress",
|
|
2689
|
+
"userEmail",
|
|
2690
|
+
"mail"
|
|
2691
|
+
]) {
|
|
2692
|
+
const email = stringOf(source[key]);
|
|
2693
|
+
if (email && email.length <= 320 && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return email;
|
|
2694
|
+
}
|
|
2695
|
+
}
|
|
2696
|
+
function responseGrant(body, previous) {
|
|
2697
|
+
const data = objectOf(objectOf(body).data);
|
|
2698
|
+
const accessToken = stringOf(data.access_token);
|
|
2699
|
+
if (!accessToken) return void 0;
|
|
2700
|
+
const expiresAtMs = typeof data.expires_in === "number" && data.expires_in > 0 ? Date.now() + data.expires_in * 1e3 : void 0;
|
|
2701
|
+
const grant = {
|
|
2702
|
+
version: 1,
|
|
2703
|
+
accessToken
|
|
2704
|
+
};
|
|
2705
|
+
for (const [target, source] of [
|
|
2706
|
+
["refreshToken", "refresh_token"],
|
|
2707
|
+
["longToken", "longToken"],
|
|
2708
|
+
["userName", "userName"]
|
|
2709
|
+
]) {
|
|
2710
|
+
const value = stringOf(data[source]) ?? previous?.[target];
|
|
2711
|
+
if (value) grant[target] = value;
|
|
2712
|
+
}
|
|
2713
|
+
if (finiteTime(expiresAtMs)) grant.expiresAtMs = expiresAtMs;
|
|
2714
|
+
if (previous?.emailAddress) grant.emailAddress = previous.emailAddress;
|
|
2715
|
+
return grant;
|
|
2716
|
+
}
|
|
2717
|
+
function stale(grant) {
|
|
2718
|
+
return grant.expiresAtMs !== void 0 && grant.expiresAtMs <= Date.now() + 3e4;
|
|
2719
|
+
}
|
|
2720
|
+
/**
|
|
2721
|
+
* Snapshot the desktop grant only from the inherited process environment.
|
|
2722
|
+
* Project/user .env files are deliberately excluded: this credential is an
|
|
2723
|
+
* explicit property of one host launch, not durable ChatCode CLI configuration.
|
|
2724
|
+
*/
|
|
2725
|
+
function chatCodeEnvironmentToken(ctx) {
|
|
2726
|
+
return stringOf(launchEnvironmentOf(ctx).getFrom(CHATCODE_CLI_OAUTH_TOKEN, ["process"])?.value);
|
|
2727
|
+
}
|
|
2728
|
+
/** One protocol owner, shared across Web and TUI through credentials-local's file lock. */
|
|
2729
|
+
var ChatCodeAuthService = class extends Service {
|
|
2730
|
+
credentials;
|
|
2731
|
+
options;
|
|
2732
|
+
lifetime = new AbortController();
|
|
2733
|
+
active = /* @__PURE__ */ new Map();
|
|
2734
|
+
failures = /* @__PURE__ */ new Map();
|
|
2735
|
+
tasks = /* @__PURE__ */ new Set();
|
|
2736
|
+
/** A new host process must contact ChatCode once before trusting persisted validation metadata. */
|
|
2737
|
+
startupValidationPending = true;
|
|
2738
|
+
environmentAccessToken;
|
|
2739
|
+
constructor(ctx, credentials, options, environmentAccessToken) {
|
|
2740
|
+
super(ctx, "chatcodeAuth");
|
|
2741
|
+
this.credentials = credentials;
|
|
2742
|
+
this.options = options;
|
|
2743
|
+
this.environmentAccessToken = stringOf(environmentAccessToken);
|
|
2744
|
+
endpoint(options.loginUrl);
|
|
2745
|
+
endpoint(options.apiBaseUrl);
|
|
2746
|
+
ctx.effect(() => async () => {
|
|
2747
|
+
this.lifetime.abort();
|
|
2748
|
+
await Promise.allSettled(this.tasks);
|
|
2749
|
+
}, "chatcode-auth: stop pending requests");
|
|
2750
|
+
}
|
|
2751
|
+
async mutate(fn) {
|
|
2752
|
+
try {
|
|
2753
|
+
return stateOf(await this.credentials.modifyRecord(CHATCODE_CREDENTIAL_KEY, async (record) => {
|
|
2754
|
+
const next = await fn(stateOf(record));
|
|
2755
|
+
return next === void 0 ? void 0 : recordOf(next);
|
|
2756
|
+
}));
|
|
2757
|
+
} catch {
|
|
2758
|
+
throw new Error("ChatCode credential operation failed; check the ChatCode CLI credential store permissions and availability.");
|
|
2759
|
+
}
|
|
2760
|
+
}
|
|
2761
|
+
async ensure(signal, force = false) {
|
|
2762
|
+
const combined = AbortSignal.any([
|
|
2763
|
+
this.lifetime.signal,
|
|
2764
|
+
...signal ? [signal] : [],
|
|
2765
|
+
AbortSignal.timeout(Math.min(2e4, this.options.requestTimeoutMs * 3))
|
|
2766
|
+
]);
|
|
2767
|
+
combined.throwIfAborted();
|
|
2768
|
+
return await this.mutate(async (current) => {
|
|
2769
|
+
combined.throwIfAborted();
|
|
2770
|
+
const grant = current.grant;
|
|
2771
|
+
if (!grant) {
|
|
2772
|
+
this.startupValidationPending = false;
|
|
2773
|
+
return;
|
|
2774
|
+
}
|
|
2775
|
+
if (!force && !this.startupValidationPending) return void 0;
|
|
2776
|
+
let next = grant;
|
|
2777
|
+
let result;
|
|
2778
|
+
try {
|
|
2779
|
+
if (stale(grant)) {
|
|
2780
|
+
const refreshed = await this.refresh(grant, combined);
|
|
2781
|
+
if (refreshed.grant) next = refreshed.grant;
|
|
2782
|
+
result = refreshed.grant ? await this.account(next, combined) : { validation: refreshed.validation };
|
|
2783
|
+
} else {
|
|
2784
|
+
result = await this.account(grant, combined);
|
|
2785
|
+
if (result.validation === "invalid") {
|
|
2786
|
+
const refreshed = await this.refresh(grant, combined);
|
|
2787
|
+
if (refreshed.grant) {
|
|
2788
|
+
next = refreshed.grant;
|
|
2789
|
+
result = await this.account(next, combined);
|
|
2790
|
+
} else result = { validation: refreshed.validation };
|
|
2791
|
+
}
|
|
2792
|
+
}
|
|
2793
|
+
} finally {
|
|
2794
|
+
this.startupValidationPending = false;
|
|
2795
|
+
}
|
|
2796
|
+
if (result.emailAddress) next = {
|
|
2797
|
+
...next,
|
|
2798
|
+
emailAddress: result.emailAddress
|
|
2799
|
+
};
|
|
2800
|
+
signal?.throwIfAborted();
|
|
2801
|
+
this.lifetime.signal.throwIfAborted();
|
|
2802
|
+
return {
|
|
2803
|
+
...current,
|
|
2804
|
+
grant: next,
|
|
2805
|
+
validation: result.validation,
|
|
2806
|
+
checkedAtMs: Date.now()
|
|
2807
|
+
};
|
|
2808
|
+
});
|
|
2809
|
+
}
|
|
2810
|
+
async status(options = {}) {
|
|
2811
|
+
if (this.environmentAccessToken !== void 0) return {
|
|
2812
|
+
required: this.options.requireLogin !== false,
|
|
2813
|
+
configured: true,
|
|
2814
|
+
expired: false,
|
|
2815
|
+
validation: "valid"
|
|
2816
|
+
};
|
|
2817
|
+
const state = await this.ensure(void 0, options.force === true);
|
|
2818
|
+
const grant = state.grant;
|
|
2819
|
+
const login = state.login?.state === "pending" ? this.failures.get(state.login.sessionId) ?? state.login : state.login;
|
|
2820
|
+
return {
|
|
2821
|
+
required: this.options.requireLogin !== false,
|
|
2822
|
+
configured: grant !== void 0,
|
|
2823
|
+
expired: grant?.expiresAtMs !== void 0 && grant.expiresAtMs <= Date.now(),
|
|
2824
|
+
validation: grant ? state.validation ?? "unavailable" : "none",
|
|
2825
|
+
...grant?.expiresAtMs === void 0 ? {} : { expiresAtMs: grant.expiresAtMs },
|
|
2826
|
+
...grant?.userName ? { userName: grant.userName } : {},
|
|
2827
|
+
...grant?.emailAddress ? { emailAddress: grant.emailAddress } : {},
|
|
2828
|
+
...login ? { login: login.state === "pending" && login.expiresAtMs <= Date.now() ? {
|
|
2829
|
+
...login,
|
|
2830
|
+
state: "timed-out"
|
|
2831
|
+
} : login } : {}
|
|
2832
|
+
};
|
|
2833
|
+
}
|
|
2834
|
+
async accessToken(signal) {
|
|
2835
|
+
signal?.throwIfAborted();
|
|
2836
|
+
if (this.environmentAccessToken !== void 0) return this.environmentAccessToken;
|
|
2837
|
+
const state = await this.ensure(signal);
|
|
2838
|
+
signal?.throwIfAborted();
|
|
2839
|
+
return state.validation === "valid" && state.grant && (state.grant.expiresAtMs === void 0 || state.grant.expiresAtMs > Date.now()) ? state.grant.accessToken : void 0;
|
|
2840
|
+
}
|
|
2841
|
+
environmentTokenActive() {
|
|
2842
|
+
return this.environmentAccessToken !== void 0;
|
|
2843
|
+
}
|
|
2844
|
+
async startLogin() {
|
|
2845
|
+
if (this.environmentAccessToken !== void 0) throw new Error("ChatCode authentication is provided by the launch environment.");
|
|
2846
|
+
this.lifetime.signal.throwIfAborted();
|
|
2847
|
+
const sessionId = randomUUID();
|
|
2848
|
+
const url = loginUrlOf(this.options.loginUrl, sessionId);
|
|
2849
|
+
const login = {
|
|
2850
|
+
sessionId,
|
|
2851
|
+
state: "pending",
|
|
2852
|
+
expiresAtMs: Date.now() + this.options.pollTimeoutMs
|
|
2853
|
+
};
|
|
2854
|
+
await this.mutate(async (current) => {
|
|
2855
|
+
this.lifetime.signal.throwIfAborted();
|
|
2856
|
+
for (const controller of this.active.values()) controller.abort();
|
|
2857
|
+
return {
|
|
2858
|
+
...current,
|
|
2859
|
+
login
|
|
2860
|
+
};
|
|
2861
|
+
});
|
|
2862
|
+
this.failures.clear();
|
|
2863
|
+
const controller = new AbortController();
|
|
2864
|
+
this.active.set(sessionId, controller);
|
|
2865
|
+
const signal = AbortSignal.any([
|
|
2866
|
+
controller.signal,
|
|
2867
|
+
this.lifetime.signal,
|
|
2868
|
+
AbortSignal.timeout(this.options.pollTimeoutMs)
|
|
2869
|
+
]);
|
|
2870
|
+
const task = this.pollAndCommit(login, signal).catch(async () => {
|
|
2871
|
+
const state = Date.now() >= login.expiresAtMs ? "timed-out" : signal.aborted ? "cancelled" : "failed";
|
|
2872
|
+
const failed = {
|
|
2873
|
+
...login,
|
|
2874
|
+
state
|
|
2875
|
+
};
|
|
2876
|
+
try {
|
|
2877
|
+
await this.finish(failed);
|
|
2878
|
+
} catch {
|
|
2879
|
+
this.failures.set(sessionId, failed);
|
|
2880
|
+
}
|
|
2881
|
+
}).finally(() => {
|
|
2882
|
+
this.active.delete(sessionId);
|
|
2883
|
+
this.tasks.delete(task);
|
|
2884
|
+
});
|
|
2885
|
+
this.tasks.add(task);
|
|
2886
|
+
return {
|
|
2887
|
+
sessionId,
|
|
2888
|
+
url
|
|
2889
|
+
};
|
|
2890
|
+
}
|
|
2891
|
+
async logout() {
|
|
2892
|
+
if (this.environmentAccessToken !== void 0) return;
|
|
2893
|
+
for (const controller of this.active.values()) controller.abort();
|
|
2894
|
+
await this.mutate(async (current) => ({
|
|
2895
|
+
version: 2,
|
|
2896
|
+
...current.login ? { login: {
|
|
2897
|
+
...current.login,
|
|
2898
|
+
state: "cancelled"
|
|
2899
|
+
} } : {}
|
|
2900
|
+
}));
|
|
2901
|
+
this.failures.clear();
|
|
2902
|
+
}
|
|
2903
|
+
async cancelLogin(sessionId) {
|
|
2904
|
+
this.active.get(sessionId)?.abort();
|
|
2905
|
+
await this.mutate(async (current) => current.login?.sessionId === sessionId && current.login.state === "pending" ? {
|
|
2906
|
+
...current,
|
|
2907
|
+
login: {
|
|
2908
|
+
...current.login,
|
|
2909
|
+
state: "cancelled"
|
|
2910
|
+
}
|
|
2911
|
+
} : void 0);
|
|
2912
|
+
}
|
|
2913
|
+
async waitForLogin(sessionId, signal) {
|
|
2914
|
+
const combined = AbortSignal.any([this.lifetime.signal, ...signal ? [signal] : []]);
|
|
2915
|
+
while (true) {
|
|
2916
|
+
combined.throwIfAborted();
|
|
2917
|
+
const status = await this.status();
|
|
2918
|
+
if (status.login?.sessionId !== sessionId) return "cancelled";
|
|
2919
|
+
if (status.login.state !== "pending") return status.login.state;
|
|
2920
|
+
await setTimeout$1(this.options.pollIntervalMs, void 0, { signal: combined });
|
|
2921
|
+
}
|
|
2922
|
+
}
|
|
2923
|
+
async finish(login) {
|
|
2924
|
+
await this.mutate(async (current) => current.login?.sessionId === login.sessionId && current.login.state === "pending" ? {
|
|
2925
|
+
...current,
|
|
2926
|
+
login
|
|
2927
|
+
} : void 0);
|
|
2928
|
+
}
|
|
2929
|
+
async pollAndCommit(login, signal) {
|
|
2930
|
+
let grant;
|
|
2931
|
+
while (Date.now() < login.expiresAtMs) {
|
|
2932
|
+
signal.throwIfAborted();
|
|
2933
|
+
const current = await this.mutate(async () => void 0);
|
|
2934
|
+
if (current.login?.sessionId !== login.sessionId || current.login.state !== "pending") return;
|
|
2935
|
+
if (!grant) {
|
|
2936
|
+
const response = await this.request(`/caassist-api-lt/caassist/api/account/session/login?${new URLSearchParams({ sessionId: login.sessionId })}`, {
|
|
2937
|
+
method: "GET",
|
|
2938
|
+
signal
|
|
2939
|
+
});
|
|
2940
|
+
if (response?.ok) grant = responseGrant(response.body);
|
|
2941
|
+
}
|
|
2942
|
+
if (grant) {
|
|
2943
|
+
const account = await this.account(grant, signal);
|
|
2944
|
+
if (account.validation === "invalid") throw new Error("ChatCode rejected the login grant.");
|
|
2945
|
+
if (account.validation === "valid") {
|
|
2946
|
+
const validated = {
|
|
2947
|
+
...grant,
|
|
2948
|
+
...account.emailAddress ? { emailAddress: account.emailAddress } : {}
|
|
2949
|
+
};
|
|
2950
|
+
await this.mutate(async (latest) => {
|
|
2951
|
+
signal.throwIfAborted();
|
|
2952
|
+
if (latest.login?.sessionId !== login.sessionId || latest.login.state !== "pending") return void 0;
|
|
2953
|
+
return {
|
|
2954
|
+
version: 2,
|
|
2955
|
+
grant: validated,
|
|
2956
|
+
validation: "valid",
|
|
2957
|
+
checkedAtMs: Date.now(),
|
|
2958
|
+
login: {
|
|
2959
|
+
...login,
|
|
2960
|
+
state: "succeeded"
|
|
2961
|
+
}
|
|
2962
|
+
};
|
|
2963
|
+
});
|
|
2964
|
+
return;
|
|
2965
|
+
}
|
|
2966
|
+
}
|
|
2967
|
+
await setTimeout$1(Math.min(this.options.pollIntervalMs, Math.max(1, login.expiresAtMs - Date.now())), void 0, { signal });
|
|
2968
|
+
}
|
|
2969
|
+
await this.finish({
|
|
2970
|
+
...login,
|
|
2971
|
+
state: "timed-out"
|
|
2972
|
+
});
|
|
2973
|
+
}
|
|
2974
|
+
async account(grant, signal) {
|
|
2975
|
+
const response = await this.request("/caassist-api-lt/caassist/api/account/info", {
|
|
2976
|
+
method: "POST",
|
|
2977
|
+
headers: {
|
|
2978
|
+
authorization: `Bearer ${grant.accessToken}`,
|
|
2979
|
+
"content-type": "application/json"
|
|
2980
|
+
},
|
|
2981
|
+
body: "{}",
|
|
2982
|
+
signal
|
|
2983
|
+
});
|
|
2984
|
+
const body = objectOf(response?.body);
|
|
2985
|
+
if (response?.status === 401 || response?.status === 403 || body.code === "76021501") return { validation: "invalid" };
|
|
2986
|
+
if (!response?.ok || !(body.success === true || body.code === "00000000")) return { validation: "unavailable" };
|
|
2987
|
+
const emailAddress = emailOf(body);
|
|
2988
|
+
return {
|
|
2989
|
+
validation: "valid",
|
|
2990
|
+
...emailAddress ? { emailAddress } : {}
|
|
2991
|
+
};
|
|
2992
|
+
}
|
|
2993
|
+
async refresh(current, signal) {
|
|
2994
|
+
const username = current.userName || current.emailAddress;
|
|
2995
|
+
if (!username || !current.longToken) return { validation: "invalid" };
|
|
2996
|
+
const response = await this.request("/caassist-api-lt/caassist/api/account/long/login", {
|
|
2997
|
+
method: "POST",
|
|
2998
|
+
headers: { "content-type": "application/json" },
|
|
2999
|
+
body: JSON.stringify({
|
|
3000
|
+
username,
|
|
3001
|
+
longToken: current.longToken
|
|
3002
|
+
}),
|
|
3003
|
+
signal
|
|
3004
|
+
});
|
|
3005
|
+
const body = objectOf(response?.body);
|
|
3006
|
+
const grant = response?.ok ? responseGrant(body, current) : void 0;
|
|
3007
|
+
if (grant) return {
|
|
3008
|
+
grant,
|
|
3009
|
+
validation: "unavailable"
|
|
3010
|
+
};
|
|
3011
|
+
return { validation: response?.status === 401 || response?.status === 403 || body.code === "76021501" || body.success === false ? "invalid" : "unavailable" };
|
|
3012
|
+
}
|
|
3013
|
+
async request(path, init) {
|
|
3014
|
+
const signal = AbortSignal.any([
|
|
3015
|
+
this.lifetime.signal,
|
|
3016
|
+
...init.signal ? [init.signal] : [],
|
|
3017
|
+
AbortSignal.timeout(this.options.requestTimeoutMs)
|
|
3018
|
+
]);
|
|
3019
|
+
try {
|
|
3020
|
+
const response = await fetch(new URL(path, this.options.apiBaseUrl), {
|
|
3021
|
+
...init,
|
|
3022
|
+
signal,
|
|
3023
|
+
redirect: "error"
|
|
3024
|
+
});
|
|
3025
|
+
return {
|
|
3026
|
+
ok: response.ok,
|
|
3027
|
+
status: response.status,
|
|
3028
|
+
body: await response.json().catch(() => void 0)
|
|
3029
|
+
};
|
|
3030
|
+
} catch {
|
|
3031
|
+
return;
|
|
3032
|
+
}
|
|
3033
|
+
}
|
|
3034
|
+
};
|
|
3035
|
+
//#endregion
|
|
3036
|
+
//#region src/config.ts
|
|
3037
|
+
/** Mount and settings configuration for ChatCode model sources. @module dsh-llm-chatcode-config/config */
|
|
3038
|
+
/** Validate mounting options without exposing control-plane credentials. */
|
|
3039
|
+
const Config = z.object({
|
|
3040
|
+
settingsPath: z.string(),
|
|
3041
|
+
customModels: z.array(z.object({
|
|
3042
|
+
model: z.string().required(),
|
|
3043
|
+
apiKey: z.string().required().role("secret"),
|
|
3044
|
+
baseUrl: z.string().required(),
|
|
3045
|
+
description: z.string(),
|
|
3046
|
+
provider: z.union([z.string(), z.const(null)]),
|
|
3047
|
+
protocol: z.string(),
|
|
3048
|
+
contextWindow: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER),
|
|
3049
|
+
maxTokens: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER),
|
|
3050
|
+
maxInputTokens: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER)
|
|
3051
|
+
})).default([]),
|
|
3052
|
+
defaultContextWindow: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER).default(262144),
|
|
3053
|
+
defaultMaxTokens: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER).default(4096),
|
|
3054
|
+
retryPolicy: RetryPolicySchema,
|
|
3055
|
+
auth: z.object({
|
|
3056
|
+
requireLogin: z.boolean().default(true),
|
|
3057
|
+
loginUrl: z.string().default("https://chatcode.chinaunicom.cn/unicode/#/login"),
|
|
3058
|
+
apiBaseUrl: z.string().default("https://chatcode.chinaunicom.cn"),
|
|
3059
|
+
pollIntervalMs: z.number().step(1).min(250).max(6e4).default(1e3),
|
|
3060
|
+
pollTimeoutMs: z.number().step(1).min(1e3).max(72e5).default(36e5),
|
|
3061
|
+
requestTimeoutMs: z.number().step(1).min(1e3).max(6e4).default(5e3)
|
|
3062
|
+
}).default({
|
|
3063
|
+
requireLogin: true,
|
|
3064
|
+
loginUrl: "https://chatcode.chinaunicom.cn/unicode/#/login",
|
|
3065
|
+
apiBaseUrl: "https://chatcode.chinaunicom.cn",
|
|
3066
|
+
pollIntervalMs: 1e3,
|
|
3067
|
+
pollTimeoutMs: 36e5,
|
|
3068
|
+
requestTimeoutMs: 5e3
|
|
3069
|
+
}),
|
|
3070
|
+
cvpChatCodeApiUrl: z.string().default("https://chatcode.chinaunicom.cn/cvp"),
|
|
3071
|
+
startupGate: z.object({
|
|
3072
|
+
token: z.string().role("secret").default(""),
|
|
3073
|
+
timeoutMs: z.number().step(1).min(1e3).max(6e4).default(5e3)
|
|
3074
|
+
}).default({
|
|
3075
|
+
token: "",
|
|
3076
|
+
timeoutMs: 5e3
|
|
3077
|
+
}),
|
|
3078
|
+
reporting: z.object({
|
|
3079
|
+
enabled: z.boolean().default(true),
|
|
3080
|
+
codeSave: z.boolean().default(true),
|
|
3081
|
+
conversationSync: z.boolean().default(true),
|
|
3082
|
+
chatCodeSession: z.boolean().default(true),
|
|
3083
|
+
codeBatchItems: z.number().step(1).min(1).max(500).default(50),
|
|
3084
|
+
codeBatchChars: z.number().step(1).min(1024).max(5242880).default(524288),
|
|
3085
|
+
codeRetryDelayMs: z.number().step(1).min(1e3).max(3e5).default(5e3),
|
|
3086
|
+
codeOutboxDir: z.string().default(""),
|
|
3087
|
+
includeSubagentConversationSync: z.boolean().default(false),
|
|
3088
|
+
modelKindRules: z.array(z.object({
|
|
3089
|
+
provider: z.string(),
|
|
3090
|
+
model: z.string(),
|
|
3091
|
+
kind: z.number().step(1).min(0).max(3).required()
|
|
3092
|
+
})).default([])
|
|
3093
|
+
}).default({
|
|
3094
|
+
enabled: true,
|
|
3095
|
+
codeSave: true,
|
|
3096
|
+
conversationSync: true,
|
|
3097
|
+
chatCodeSession: true,
|
|
3098
|
+
codeBatchItems: 50,
|
|
3099
|
+
codeBatchChars: 524288,
|
|
3100
|
+
codeRetryDelayMs: 5e3,
|
|
3101
|
+
codeOutboxDir: "",
|
|
3102
|
+
includeSubagentConversationSync: false,
|
|
3103
|
+
modelKindRules: []
|
|
3104
|
+
}),
|
|
3105
|
+
codingPlanEndpoint: z.string().default("https://chatcode.chinaunicom.cn/cvp/api/cli/v1/model-runtime-configs"),
|
|
3106
|
+
enableMaas: z.boolean().default(false),
|
|
3107
|
+
maasEndpoint: z.string().default("https://chatcode.chinaunicom.cn/cvp/wanma/api/v1/cli/maas-models"),
|
|
3108
|
+
catalogTimeoutMs: z.number().step(1).min(1).max(6e4).default(1e4)
|
|
3109
|
+
});
|
|
3110
|
+
//#endregion
|
|
3111
|
+
//#region src/managed.ts
|
|
3112
|
+
/** Read centrally managed model catalogs without exposing their credentials. @module dsh-llm-chatcode-config/managed */
|
|
3113
|
+
/** Resolve the OS account that launched the current DSH host. */
|
|
3114
|
+
function currentUserName() {
|
|
3115
|
+
const environmentName = process.env.USERNAME?.trim() || process.env.USER?.trim();
|
|
3116
|
+
if (environmentName !== void 0 && environmentName !== "") return environmentName;
|
|
3117
|
+
try {
|
|
3118
|
+
return userInfo().username.trim();
|
|
3119
|
+
} catch {
|
|
3120
|
+
return "";
|
|
3121
|
+
}
|
|
3122
|
+
}
|
|
3123
|
+
/** Query fields required by the CodingPlan runtime catalogue. */
|
|
3124
|
+
function runtimeQuery(userName = currentUserName()) {
|
|
3125
|
+
return { userEmail: userName };
|
|
3126
|
+
}
|
|
3127
|
+
function record(value) {
|
|
3128
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
|
|
3129
|
+
}
|
|
3130
|
+
function string(value) {
|
|
3131
|
+
return typeof value === "string" ? value : void 0;
|
|
3132
|
+
}
|
|
3133
|
+
function positive(value) {
|
|
3134
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : void 0;
|
|
3135
|
+
}
|
|
3136
|
+
const DIAGNOSTIC_BODY_LIMIT = 16e3;
|
|
3137
|
+
const SENSITIVE_FIELD = /(?:api[-_]?key|access[-_]?token|refresh[-_]?token|long[-_]?token|authorization|password|secret)/i;
|
|
3138
|
+
/** Render enough of a JSON response to diagnose its shape without logging credentials. */
|
|
3139
|
+
function diagnosticBody(value) {
|
|
3140
|
+
try {
|
|
3141
|
+
const rendered = JSON.stringify(value, (key, item) => SENSITIVE_FIELD.test(key) ? "[REDACTED]" : item);
|
|
3142
|
+
if (rendered === void 0) return "<empty>";
|
|
3143
|
+
return rendered.length <= DIAGNOSTIC_BODY_LIMIT ? rendered : `${rendered.slice(0, DIAGNOSTIC_BODY_LIMIT)}...<truncated>`;
|
|
3144
|
+
} catch {
|
|
3145
|
+
return "<unserializable JSON>";
|
|
3146
|
+
}
|
|
3147
|
+
}
|
|
3148
|
+
function catalogHeaders(authorization, noCache = false) {
|
|
3149
|
+
const accessToken = authorization?.accessToken.trim();
|
|
3150
|
+
if (accessToken !== void 0 && accessToken !== "" && !/[\r\n]/.test(accessToken)) return {
|
|
3151
|
+
Accept: "application/json",
|
|
3152
|
+
...noCache ? { "Cache-Control": "no-cache" } : {},
|
|
3153
|
+
Authorization: `Bearer ${accessToken}`,
|
|
3154
|
+
accessToken
|
|
3155
|
+
};
|
|
3156
|
+
return {
|
|
3157
|
+
Accept: "application/json",
|
|
3158
|
+
...noCache ? { "Cache-Control": "no-cache" } : {}
|
|
3159
|
+
};
|
|
3160
|
+
}
|
|
3161
|
+
function protocolOf$1(model) {
|
|
3162
|
+
switch (model.protocol?.trim().toLowerCase()) {
|
|
3163
|
+
case "openai": return "openai-completions";
|
|
3164
|
+
case "anthropic": return "anthropic-messages";
|
|
3165
|
+
default: return;
|
|
3166
|
+
}
|
|
3167
|
+
}
|
|
3168
|
+
/** Accept gateway roots and a complete OpenAI chat-completions URL from the control plane. */
|
|
3169
|
+
function endpointOf$1(value) {
|
|
3170
|
+
let url;
|
|
3171
|
+
try {
|
|
3172
|
+
url = new URL(value);
|
|
3173
|
+
} catch {
|
|
3174
|
+
return;
|
|
3175
|
+
}
|
|
3176
|
+
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.search || url.hash) return void 0;
|
|
3177
|
+
url.pathname = url.pathname.replace(/\/chat\/completions\/?$/i, "") || "/";
|
|
3178
|
+
return url.href.replace(/\/+$/, "");
|
|
3179
|
+
}
|
|
3180
|
+
function requestApiKey(apiKey) {
|
|
3181
|
+
if (apiKey === void 0 || apiKey.trim() === "" || /^\*+$/.test(apiKey.trim()) || /[\r\n]/.test(apiKey)) return void 0;
|
|
3182
|
+
return apiKey;
|
|
3183
|
+
}
|
|
3184
|
+
function requestAuth(protocol, apiKey) {
|
|
3185
|
+
if (apiKey === void 0) return {};
|
|
3186
|
+
return protocol === "anthropic-messages" ? { headers: { Authorization: `Bearer ${apiKey}` } } : { apiKey };
|
|
3187
|
+
}
|
|
3188
|
+
/** Fetch the supported control-plane response shape and report only generic, non-secret errors. */
|
|
3189
|
+
async function fetchRuntimeModels(endpoint, timeoutMs, authorization, diagnostic) {
|
|
3190
|
+
let url;
|
|
3191
|
+
try {
|
|
3192
|
+
url = new URL(endpoint);
|
|
3193
|
+
} catch {
|
|
3194
|
+
throw new LlmError("chatcode-config: managed model endpoint is invalid", "INVALID_CHATCODE_CONFIG");
|
|
3195
|
+
}
|
|
3196
|
+
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password) throw new LlmError("chatcode-config: managed model endpoint must be HTTP(S) without credentials", "INVALID_CHATCODE_CONFIG");
|
|
3197
|
+
for (const [key, value] of Object.entries(runtimeQuery())) url.searchParams.set(key, value);
|
|
3198
|
+
diagnostic?.(`chatcode-config: CodingPlan request GET ${url.href}`);
|
|
3199
|
+
let response;
|
|
3200
|
+
try {
|
|
3201
|
+
response = await fetch(url, {
|
|
3202
|
+
headers: catalogHeaders(authorization),
|
|
3203
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
3204
|
+
});
|
|
3205
|
+
} catch (error) {
|
|
3206
|
+
diagnostic?.(`chatcode-config: CodingPlan request failed before a response (${error instanceof Error ? error.message : String(error)})`);
|
|
3207
|
+
throw new LlmError("chatcode-config: managed model catalog is unavailable", "MANAGED_MODEL_CATALOG_UNAVAILABLE");
|
|
3208
|
+
}
|
|
3209
|
+
let body;
|
|
3210
|
+
try {
|
|
3211
|
+
body = await response.json();
|
|
3212
|
+
diagnostic?.(`chatcode-config: CodingPlan response status=${response.status} body=${diagnosticBody(body)}`);
|
|
3213
|
+
} catch {
|
|
3214
|
+
diagnostic?.(`chatcode-config: CodingPlan response status=${response.status} body=<invalid JSON>`);
|
|
3215
|
+
throw new LlmError("chatcode-config: managed model catalog returned invalid JSON", "MANAGED_MODEL_CATALOG_INVALID");
|
|
3216
|
+
}
|
|
3217
|
+
if (!response.ok) throw new LlmError("chatcode-config: managed model catalog request failed", "MANAGED_MODEL_CATALOG_UNAVAILABLE");
|
|
3218
|
+
const top = record(body);
|
|
3219
|
+
const data = top === void 0 ? void 0 : record(top.data);
|
|
3220
|
+
const rows = data === void 0 ? void 0 : data.models;
|
|
3221
|
+
if (top?.code !== "00000000" || !Array.isArray(rows)) throw new LlmError("chatcode-config: managed model catalog returned an invalid response", "MANAGED_MODEL_CATALOG_INVALID");
|
|
3222
|
+
const models = rows.flatMap((row) => {
|
|
3223
|
+
const item = record(row);
|
|
3224
|
+
if (item === void 0) return [];
|
|
3225
|
+
const logicalModelId = string(item.logicalModelId);
|
|
3226
|
+
if (logicalModelId === void 0 || logicalModelId.trim() === "") return [];
|
|
3227
|
+
const parsed = {
|
|
3228
|
+
logicalModelId,
|
|
3229
|
+
displayName: string(item.displayName) ?? logicalModelId
|
|
3230
|
+
};
|
|
3231
|
+
const description = string(item.description);
|
|
3232
|
+
const protocol = string(item.protocol);
|
|
3233
|
+
const provider = string(item.provider);
|
|
3234
|
+
const baseUrl = string(item.baseUrl);
|
|
3235
|
+
const providerModelId = string(item.providerModelId);
|
|
3236
|
+
const apiKey = string(item.apiKey);
|
|
3237
|
+
const maxToken = positive(item.maxToken);
|
|
3238
|
+
const contextWindow = positive(item.contextWindow);
|
|
3239
|
+
if (description !== void 0) parsed.description = description;
|
|
3240
|
+
if (protocol !== void 0) parsed.protocol = protocol;
|
|
3241
|
+
if (provider !== void 0) parsed.provider = provider;
|
|
3242
|
+
if (baseUrl !== void 0) parsed.baseUrl = baseUrl;
|
|
3243
|
+
if (providerModelId !== void 0) parsed.providerModelId = providerModelId;
|
|
3244
|
+
if (apiKey !== void 0) parsed.apiKey = apiKey;
|
|
3245
|
+
if (maxToken !== void 0) parsed.maxToken = maxToken;
|
|
3246
|
+
if (contextWindow !== void 0) parsed.contextWindow = contextWindow;
|
|
3247
|
+
return [parsed];
|
|
3248
|
+
});
|
|
3249
|
+
diagnostic?.(`chatcode-config: CodingPlan parsed ${String(rows.length)} response rows into ${String(models.length)} model records`);
|
|
3250
|
+
return models;
|
|
3251
|
+
}
|
|
3252
|
+
/** Fetch the public MAAS catalog. It intentionally contains no endpoint or credential fields. */
|
|
3253
|
+
async function fetchMaasCatalog(endpoint, timeoutMs, authorization) {
|
|
3254
|
+
let url;
|
|
3255
|
+
try {
|
|
3256
|
+
url = new URL(endpoint);
|
|
3257
|
+
} catch {
|
|
3258
|
+
throw new LlmError("chatcode-config: MAAS model endpoint is invalid", "INVALID_CHATCODE_CONFIG");
|
|
3259
|
+
}
|
|
3260
|
+
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password) throw new LlmError("chatcode-config: MAAS model endpoint must be HTTP(S) without credentials", "INVALID_CHATCODE_CONFIG");
|
|
3261
|
+
let response;
|
|
3262
|
+
try {
|
|
3263
|
+
response = await fetch(url, {
|
|
3264
|
+
headers: catalogHeaders(authorization, true),
|
|
3265
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
3266
|
+
});
|
|
3267
|
+
} catch {
|
|
3268
|
+
throw new LlmError("chatcode-config: MAAS model catalog is unavailable", "MANAGED_MODEL_CATALOG_UNAVAILABLE");
|
|
3269
|
+
}
|
|
3270
|
+
if (!response.ok) throw new LlmError("chatcode-config: MAAS model catalog request failed", "MANAGED_MODEL_CATALOG_UNAVAILABLE");
|
|
3271
|
+
let body;
|
|
3272
|
+
try {
|
|
3273
|
+
body = await response.json();
|
|
3274
|
+
} catch {
|
|
3275
|
+
throw new LlmError("chatcode-config: MAAS model catalog returned invalid JSON", "MANAGED_MODEL_CATALOG_INVALID");
|
|
3276
|
+
}
|
|
3277
|
+
const top = record(body);
|
|
3278
|
+
if (top?.code !== 200 || !Array.isArray(top.data)) throw new LlmError("chatcode-config: MAAS model catalog returned an invalid response", "MANAGED_MODEL_CATALOG_INVALID");
|
|
3279
|
+
return top.data.flatMap((row) => {
|
|
3280
|
+
const item = record(row);
|
|
3281
|
+
const id = positive(item?.id);
|
|
3282
|
+
const logicalModelId = string(item?.logicalModelId)?.trim();
|
|
3283
|
+
if (id === void 0 || logicalModelId === void 0 || logicalModelId === "") return [];
|
|
3284
|
+
const parsed = {
|
|
3285
|
+
id,
|
|
3286
|
+
logicalModelId,
|
|
3287
|
+
displayName: string(item?.displayName)?.trim() || logicalModelId
|
|
3288
|
+
};
|
|
3289
|
+
const description = string(item?.description);
|
|
3290
|
+
const protocol = string(item?.protocol);
|
|
3291
|
+
const model = string(item?.model);
|
|
3292
|
+
const maxTokens = positive(item?.maxTokens);
|
|
3293
|
+
const contextWindow = positive(item?.contextWindow);
|
|
3294
|
+
if (description !== void 0) parsed.description = description;
|
|
3295
|
+
if (protocol !== void 0) parsed.protocol = protocol;
|
|
3296
|
+
if (model !== void 0) parsed.model = model;
|
|
3297
|
+
if (maxTokens !== void 0) parsed.maxTokens = maxTokens;
|
|
3298
|
+
if (contextWindow !== void 0) parsed.contextWindow = contextWindow;
|
|
3299
|
+
return [parsed];
|
|
3300
|
+
});
|
|
3301
|
+
}
|
|
3302
|
+
function maasRuntimeUrl(catalogEndpoint, id) {
|
|
3303
|
+
let url;
|
|
3304
|
+
try {
|
|
3305
|
+
url = new URL(catalogEndpoint);
|
|
3306
|
+
} catch {
|
|
3307
|
+
throw new LlmError("chatcode-config: MAAS model endpoint is invalid", "INVALID_CHATCODE_CONFIG");
|
|
3308
|
+
}
|
|
3309
|
+
const path = url.pathname.replace(/\/+$/, "");
|
|
3310
|
+
if (!path.endsWith("/maas-models")) throw new LlmError("chatcode-config: MAAS endpoint must end with /maas-models", "INVALID_CHATCODE_CONFIG");
|
|
3311
|
+
url.pathname = `${path}/${String(id)}/runtime-config`;
|
|
3312
|
+
url.search = "";
|
|
3313
|
+
return url;
|
|
3314
|
+
}
|
|
3315
|
+
/** Fetch one selected MAAS model's private runtime configuration. */
|
|
3316
|
+
async function fetchMaasRuntime(endpoint, catalog, timeoutMs, authorization) {
|
|
3317
|
+
const url = maasRuntimeUrl(endpoint, catalog.id);
|
|
3318
|
+
let response;
|
|
3319
|
+
try {
|
|
3320
|
+
response = await fetch(url, {
|
|
3321
|
+
headers: catalogHeaders(authorization, true),
|
|
3322
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
3323
|
+
});
|
|
3324
|
+
} catch {
|
|
3325
|
+
throw new LlmError("chatcode-config: MAAS runtime configuration is unavailable", "MANAGED_MODEL_CATALOG_UNAVAILABLE");
|
|
3326
|
+
}
|
|
3327
|
+
if (!response.ok) throw new LlmError("chatcode-config: MAAS runtime configuration request failed", "MANAGED_MODEL_CATALOG_UNAVAILABLE");
|
|
3328
|
+
let body;
|
|
3329
|
+
try {
|
|
3330
|
+
body = await response.json();
|
|
3331
|
+
} catch {
|
|
3332
|
+
throw new LlmError("chatcode-config: MAAS runtime configuration returned invalid JSON", "MANAGED_MODEL_CATALOG_INVALID");
|
|
3333
|
+
}
|
|
3334
|
+
const top = record(body);
|
|
3335
|
+
const data = top === void 0 ? void 0 : record(top.data);
|
|
3336
|
+
const runtimeId = positive(data?.id);
|
|
3337
|
+
if (top?.code !== 200 || data === void 0 || runtimeId !== void 0 && runtimeId !== catalog.id) throw new LlmError("chatcode-config: MAAS runtime configuration returned an invalid response", "MANAGED_MODEL_CATALOG_INVALID");
|
|
3338
|
+
const logicalModelId = string(data.logicalModelId)?.trim() || catalog.logicalModelId;
|
|
3339
|
+
const providerModelId = string(data.model)?.trim() || catalog.model?.trim();
|
|
3340
|
+
const baseUrl = string(data.baseUrl);
|
|
3341
|
+
if (logicalModelId === void 0 || logicalModelId === "" || providerModelId === void 0 || providerModelId === "" || baseUrl === void 0) return void 0;
|
|
3342
|
+
const parsed = {
|
|
3343
|
+
logicalModelId,
|
|
3344
|
+
displayName: string(data.displayName)?.trim() || catalog.displayName,
|
|
3345
|
+
baseUrl,
|
|
3346
|
+
providerModelId
|
|
3347
|
+
};
|
|
3348
|
+
const description = string(data.description) ?? catalog.description;
|
|
3349
|
+
const protocol = string(data.protocol) ?? catalog.protocol;
|
|
3350
|
+
const apiKey = string(data.apiKey);
|
|
3351
|
+
const maxToken = positive(data.maxTokens) ?? catalog.maxTokens;
|
|
3352
|
+
const contextWindow = positive(data.contextWindow) ?? catalog.contextWindow;
|
|
3353
|
+
if (description !== void 0) parsed.description = description;
|
|
3354
|
+
if (protocol !== void 0) parsed.protocol = protocol;
|
|
3355
|
+
if (apiKey !== void 0) parsed.apiKey = apiKey;
|
|
3356
|
+
if (maxToken !== void 0) parsed.maxToken = maxToken;
|
|
3357
|
+
if (contextWindow !== void 0) parsed.contextWindow = contextWindow;
|
|
3358
|
+
return parsed;
|
|
3359
|
+
}
|
|
3360
|
+
/** Resolve every public MAAS entry through its private runtime endpoint before publishing it as selectable. */
|
|
3361
|
+
async function fetchMaasRuntimeModels(endpoint, timeoutMs, authorization) {
|
|
3362
|
+
const catalog = await fetchMaasCatalog(endpoint, timeoutMs, authorization);
|
|
3363
|
+
return (await Promise.all(catalog.map(async (item) => {
|
|
3364
|
+
try {
|
|
3365
|
+
return await fetchMaasRuntime(endpoint, item, timeoutMs, authorization);
|
|
3366
|
+
} catch (error) {
|
|
3367
|
+
if (error instanceof LlmError) return void 0;
|
|
3368
|
+
throw error;
|
|
3369
|
+
}
|
|
3370
|
+
}))).flatMap((model) => model === void 0 ? [] : [model]);
|
|
3371
|
+
}
|
|
3372
|
+
/** Translate runnable managed entries into detached pi-ai routes. Incomplete public metadata is deliberately not selectable. */
|
|
3373
|
+
function resolveManagedSource(models, group, config) {
|
|
3374
|
+
const profiles = {};
|
|
3375
|
+
const auth = /* @__PURE__ */ new Map();
|
|
3376
|
+
const apiKeys = /* @__PURE__ */ new Map();
|
|
3377
|
+
const selections = /* @__PURE__ */ new Map();
|
|
3378
|
+
for (const entry of models) {
|
|
3379
|
+
const protocol = protocolOf$1(entry);
|
|
3380
|
+
const baseURL = entry.baseUrl === void 0 ? void 0 : endpointOf$1(entry.baseUrl);
|
|
3381
|
+
const providerModelId = entry.providerModelId?.trim();
|
|
3382
|
+
if (protocol === void 0 || baseURL === void 0 || providerModelId === void 0 || providerModelId === "") continue;
|
|
3383
|
+
const selection = entry.logicalModelId.trim();
|
|
3384
|
+
if (selection === "" || selections.has(selection)) continue;
|
|
3385
|
+
const route = `managed-${group}-${createHash("sha256").update(JSON.stringify([
|
|
3386
|
+
protocol,
|
|
3387
|
+
baseURL,
|
|
3388
|
+
providerModelId,
|
|
3389
|
+
selection
|
|
3390
|
+
])).digest("hex").slice(0, 20)}`;
|
|
3391
|
+
profiles[route] = {
|
|
3392
|
+
displayName: entry.displayName.trim() || selection,
|
|
3393
|
+
api: protocol,
|
|
3394
|
+
baseURL,
|
|
3395
|
+
models: [{
|
|
3396
|
+
id: providerModelId,
|
|
3397
|
+
name: entry.displayName.trim() || selection,
|
|
3398
|
+
contextWindow: entry.contextWindow ?? entry.maxToken ?? config.defaultContextWindow,
|
|
3399
|
+
maxTokens: entry.maxToken ?? config.defaultMaxTokens
|
|
3400
|
+
}],
|
|
3401
|
+
...protocol === "openai-completions" ? {
|
|
3402
|
+
compat: {
|
|
3403
|
+
maxTokensField: "max_tokens",
|
|
3404
|
+
supportsDeveloperRole: false
|
|
3405
|
+
},
|
|
3406
|
+
headers: { Accept: "text/event-stream" }
|
|
3407
|
+
} : {},
|
|
3408
|
+
...config.retryPolicy === void 0 ? {} : { retryPolicy: config.retryPolicy }
|
|
3409
|
+
};
|
|
3410
|
+
const apiKey = requestApiKey(entry.apiKey);
|
|
3411
|
+
auth.set(route, requestAuth(protocol, apiKey));
|
|
3412
|
+
if (apiKey !== void 0) apiKeys.set(route, apiKey);
|
|
3413
|
+
selections.set(selection, {
|
|
3414
|
+
route,
|
|
3415
|
+
model: providerModelId
|
|
3416
|
+
});
|
|
3417
|
+
}
|
|
3418
|
+
return {
|
|
3419
|
+
profiles: resolveProfiles(profiles),
|
|
3420
|
+
auth,
|
|
3421
|
+
apiKeys,
|
|
3422
|
+
selections
|
|
3423
|
+
};
|
|
3424
|
+
}
|
|
3425
|
+
//#endregion
|
|
3426
|
+
//#region src/reporting/outbox.ts
|
|
3427
|
+
/** Durable file-per-record outbox for generated-code statistics. */
|
|
3428
|
+
/** Persist generated code until a backend acknowledgement permits deletion. */
|
|
3429
|
+
var CodeOutbox = class {
|
|
3430
|
+
directory;
|
|
3431
|
+
constructor(directory) {
|
|
3432
|
+
this.directory = directory;
|
|
3433
|
+
}
|
|
3434
|
+
/** Atomically append non-empty code strings to the outbox. */
|
|
3435
|
+
async enqueue(codes) {
|
|
3436
|
+
const kept = codes.map((code) => code.trim()).filter(Boolean);
|
|
3437
|
+
if (kept.length === 0) return;
|
|
3438
|
+
await mkdir(this.directory, {
|
|
3439
|
+
recursive: true,
|
|
3440
|
+
mode: 448
|
|
3441
|
+
});
|
|
3442
|
+
for (const code of kept) await this.writeRecord(code);
|
|
3443
|
+
}
|
|
3444
|
+
async writeRecord(code) {
|
|
3445
|
+
const id = randomUUID();
|
|
3446
|
+
const record = {
|
|
3447
|
+
version: 1,
|
|
3448
|
+
id,
|
|
3449
|
+
createdAt: Date.now(),
|
|
3450
|
+
codes: [code]
|
|
3451
|
+
};
|
|
3452
|
+
const target = join(this.directory, `${String(record.createdAt).padStart(13, "0")}-${id}.json`);
|
|
3453
|
+
const temporary = `${target}.${randomUUID()}.tmp`;
|
|
3454
|
+
const handle = await open(temporary, "wx", 384);
|
|
3455
|
+
try {
|
|
3456
|
+
await handle.writeFile(`${JSON.stringify(record)}\n`, "utf8");
|
|
3457
|
+
await handle.sync();
|
|
3458
|
+
} finally {
|
|
3459
|
+
await handle.close();
|
|
3460
|
+
}
|
|
3461
|
+
await rename(temporary, target);
|
|
3462
|
+
}
|
|
3463
|
+
/** Read the oldest complete records within both configured batch limits. */
|
|
3464
|
+
async readBatch(maxItems, maxChars) {
|
|
3465
|
+
await mkdir(this.directory, {
|
|
3466
|
+
recursive: true,
|
|
3467
|
+
mode: 448
|
|
3468
|
+
});
|
|
3469
|
+
const entries = (await readdir(this.directory, { withFileTypes: true })).filter((entry) => entry.isFile() && entry.name.endsWith(".json")).map((entry) => entry.name).sort();
|
|
3470
|
+
const batch = {
|
|
3471
|
+
files: [],
|
|
3472
|
+
codes: []
|
|
3473
|
+
};
|
|
3474
|
+
let chars = 0;
|
|
3475
|
+
for (const name of entries) {
|
|
3476
|
+
const path = join(this.directory, name);
|
|
3477
|
+
const record = await this.readRecord(path);
|
|
3478
|
+
if (!record) continue;
|
|
3479
|
+
const nextChars = record.codes.reduce((sum, code) => sum + code.length, 0);
|
|
3480
|
+
if (batch.codes.length > 0 && (batch.codes.length + record.codes.length > maxItems || chars + nextChars > maxChars)) break;
|
|
3481
|
+
batch.files.push(path);
|
|
3482
|
+
batch.codes.push(...record.codes);
|
|
3483
|
+
chars += nextChars;
|
|
3484
|
+
if (batch.codes.length >= maxItems || chars >= maxChars) break;
|
|
3485
|
+
}
|
|
3486
|
+
return batch;
|
|
3487
|
+
}
|
|
3488
|
+
/** Delete only records included in a backend-acknowledged batch. */
|
|
3489
|
+
async acknowledge(files) {
|
|
3490
|
+
for (const file of files) try {
|
|
3491
|
+
await unlink(file);
|
|
3492
|
+
} catch (error) {
|
|
3493
|
+
if (error.code !== "ENOENT") throw error;
|
|
3494
|
+
}
|
|
3495
|
+
}
|
|
3496
|
+
async readRecord(path) {
|
|
3497
|
+
try {
|
|
3498
|
+
const value = JSON.parse(await readFile(path, "utf8"));
|
|
3499
|
+
if (isRecord$2(value) && value.version === 1 && typeof value.id === "string" && Number.isSafeInteger(value.createdAt) && Array.isArray(value.codes) && value.codes.length > 0 && value.codes.every((code) => typeof code === "string" && code.trim().length > 0)) return value;
|
|
3500
|
+
} catch (error) {
|
|
3501
|
+
if (error.code === "ENOENT") return void 0;
|
|
3502
|
+
}
|
|
3503
|
+
const invalidDirectory = join(this.directory, "invalid");
|
|
3504
|
+
await mkdir(invalidDirectory, {
|
|
3505
|
+
recursive: true,
|
|
3506
|
+
mode: 448
|
|
3507
|
+
});
|
|
3508
|
+
await rename(path, join(invalidDirectory, `${basename(path)}.${randomUUID()}.invalid`));
|
|
3509
|
+
}
|
|
3510
|
+
};
|
|
3511
|
+
function isRecord$2(value) {
|
|
3512
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3513
|
+
}
|
|
3514
|
+
//#endregion
|
|
3515
|
+
//#region src/reporting/code.ts
|
|
3516
|
+
/** Pure extraction of code that is safe to count as AI-generated output. */
|
|
3517
|
+
const EXCLUDED_FENCE_LABELS = /* @__PURE__ */ new Set([
|
|
3518
|
+
"text",
|
|
3519
|
+
"plaintext",
|
|
3520
|
+
"plain",
|
|
3521
|
+
"output",
|
|
3522
|
+
"result",
|
|
3523
|
+
"log",
|
|
3524
|
+
"example",
|
|
3525
|
+
"sample",
|
|
3526
|
+
"demo",
|
|
3527
|
+
"note",
|
|
3528
|
+
"notice",
|
|
3529
|
+
"warning",
|
|
3530
|
+
"error",
|
|
3531
|
+
"exception",
|
|
3532
|
+
"stacktrace",
|
|
3533
|
+
"trace"
|
|
3534
|
+
]);
|
|
3535
|
+
/** Extract reportable fenced code blocks from assistant-visible Markdown. */
|
|
3536
|
+
function extractMarkdownCode(markdown) {
|
|
3537
|
+
const result = [];
|
|
3538
|
+
for (const match of markdown.matchAll(/(?:^|\n)(`{3,}|~{3,})[ \t]*([^\r\n]*)\r?\n([\s\S]*?)\r?\n?\1(?=\r?\n|$)/g)) {
|
|
3539
|
+
const label = match[2]?.trim().split(/[ \t]/, 1)[0]?.toLowerCase() ?? "";
|
|
3540
|
+
const code = match[3]?.trim();
|
|
3541
|
+
if (!code || EXCLUDED_FENCE_LABELS.has(label) || !label && looksLikeConsoleResult(code)) continue;
|
|
3542
|
+
result.push({
|
|
3543
|
+
code,
|
|
3544
|
+
...label ? { language: label } : {}
|
|
3545
|
+
});
|
|
3546
|
+
}
|
|
3547
|
+
return result;
|
|
3548
|
+
}
|
|
3549
|
+
/** Extract code from one successful first-party mutation request. */
|
|
3550
|
+
function extractMutationCode(name, args) {
|
|
3551
|
+
if (!isRecord$1(args)) return void 0;
|
|
3552
|
+
if (name === "write") return codeAt(args, "file_path", "content");
|
|
3553
|
+
if (name === "edit") {
|
|
3554
|
+
if (typeof args.old_string !== "string" || args.old_string.length === 0) return void 0;
|
|
3555
|
+
return codeAt(args, "file_path", "new_string");
|
|
3556
|
+
}
|
|
3557
|
+
if (name !== "str_replace_editor") return void 0;
|
|
3558
|
+
if (args.command === "create") return codeAt(args, "path", "file_text");
|
|
3559
|
+
if (args.command === "str_replace") {
|
|
3560
|
+
if (typeof args.old_str !== "string" || args.old_str.length === 0) return void 0;
|
|
3561
|
+
return codeAt(args, "path", "new_str");
|
|
3562
|
+
}
|
|
3563
|
+
if (args.command === "insert" && Number.isInteger(args.insert_line)) return codeAt(args, "path", "new_str");
|
|
3564
|
+
}
|
|
3565
|
+
/** Parse model-produced JSON arguments before mutation extraction. */
|
|
3566
|
+
function extractMutationCodeFromJson(name, raw) {
|
|
3567
|
+
try {
|
|
3568
|
+
return extractMutationCode(name, JSON.parse(raw));
|
|
3569
|
+
} catch {
|
|
3570
|
+
return;
|
|
3571
|
+
}
|
|
3572
|
+
}
|
|
3573
|
+
/** Infer a stable Markdown language name from a file path. */
|
|
3574
|
+
function inferLanguage(filePath) {
|
|
3575
|
+
const extension = extname(filePath).slice(1).toLowerCase();
|
|
3576
|
+
if (!extension) return void 0;
|
|
3577
|
+
return {
|
|
3578
|
+
cjs: "javascript",
|
|
3579
|
+
htm: "html",
|
|
3580
|
+
js: "javascript",
|
|
3581
|
+
mjs: "javascript",
|
|
3582
|
+
py: "python",
|
|
3583
|
+
ps1: "powershell",
|
|
3584
|
+
sh: "bash",
|
|
3585
|
+
ts: "typescript",
|
|
3586
|
+
yml: "yaml"
|
|
3587
|
+
}[extension] ?? extension;
|
|
3588
|
+
}
|
|
3589
|
+
function codeAt(args, pathKey, codeKey) {
|
|
3590
|
+
const code = args[codeKey];
|
|
3591
|
+
if (typeof code !== "string" || code.trim().length === 0) return void 0;
|
|
3592
|
+
const filePath = args[pathKey];
|
|
3593
|
+
if (typeof filePath !== "string" || filePath.trim().length === 0) return { code: code.trim() };
|
|
3594
|
+
const language = inferLanguage(filePath);
|
|
3595
|
+
return {
|
|
3596
|
+
code: code.trim(),
|
|
3597
|
+
filePath,
|
|
3598
|
+
...language ? { language } : {}
|
|
3599
|
+
};
|
|
3600
|
+
}
|
|
3601
|
+
function looksLikeConsoleResult(code) {
|
|
3602
|
+
const lines = code.split("\n");
|
|
3603
|
+
if (lines.length <= 3) return false;
|
|
3604
|
+
return lines.filter((line) => /^[A-Za-z_]\w*\(\d+\)\s*=/.test(line.trim())).length > lines.length * .5;
|
|
3605
|
+
}
|
|
3606
|
+
function isRecord$1(value) {
|
|
3607
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3608
|
+
}
|
|
3609
|
+
//#endregion
|
|
3610
|
+
//#region src/reporting/payloads.ts
|
|
3611
|
+
/** Return the first explicit model-kind override, if one matches. */
|
|
3612
|
+
function matchModelKindRule(provider, model, rules) {
|
|
3613
|
+
return rules.find((rule) => (rule.provider === void 0 || rule.provider === provider) && (rule.model === void 0 || rule.model === model))?.kind;
|
|
3614
|
+
}
|
|
3615
|
+
/** Build the immutable conversation fields for one Session. */
|
|
3616
|
+
function conversationMeta(session, userEmail, title, modelName) {
|
|
3617
|
+
return {
|
|
3618
|
+
sessionId: String(session.id),
|
|
3619
|
+
userEmail,
|
|
3620
|
+
projectPath: session.header.cwd ?? "",
|
|
3621
|
+
...title ? { title } : {},
|
|
3622
|
+
modelName,
|
|
3623
|
+
startTime: new Date(session.header.createdAt).toISOString()
|
|
3624
|
+
};
|
|
3625
|
+
}
|
|
3626
|
+
/** Project one human or assistant message into a single backend row. */
|
|
3627
|
+
function conversationMessage(sessionId, message, timestamp, turn, usage) {
|
|
3628
|
+
const projected = projectBlocks(message.content);
|
|
3629
|
+
if (!projected) return void 0;
|
|
3630
|
+
const model = message.source.kind === "model" ? message.source.model : void 0;
|
|
3631
|
+
return {
|
|
3632
|
+
uuid: String(message.id),
|
|
3633
|
+
sessionId,
|
|
3634
|
+
role: message.role === "assistant" ? "assistant" : message.role === "system" ? "system" : "user",
|
|
3635
|
+
contentType: projected.contentType,
|
|
3636
|
+
content: projected.content,
|
|
3637
|
+
...model ? { modelName: model } : {},
|
|
3638
|
+
...usage ? { tokenUsage: {
|
|
3639
|
+
inputTokens: usage.inputTokens,
|
|
3640
|
+
outputTokens: usage.outputTokens,
|
|
3641
|
+
cacheReadTokens: usage.cacheReadTokens ?? 0,
|
|
3642
|
+
cacheCreationTokens: usage.cacheWriteTokens ?? 0
|
|
3643
|
+
} } : {},
|
|
3644
|
+
usageTime: new Date(timestamp).toISOString(),
|
|
3645
|
+
timestamp: new Date(timestamp).toISOString(),
|
|
3646
|
+
turnIndex: turn
|
|
3647
|
+
};
|
|
3648
|
+
}
|
|
3649
|
+
/** Project one durable tool call into a stable conversation row. */
|
|
3650
|
+
function toolCallMessage(sessionId, callId, name, args, timestamp, turn) {
|
|
3651
|
+
return {
|
|
3652
|
+
uuid: callId,
|
|
3653
|
+
sessionId,
|
|
3654
|
+
role: "assistant",
|
|
3655
|
+
contentType: "tool_use",
|
|
3656
|
+
content: name,
|
|
3657
|
+
toolName: name,
|
|
3658
|
+
toolInput: args,
|
|
3659
|
+
usageTime: new Date(timestamp).toISOString(),
|
|
3660
|
+
timestamp: new Date(timestamp).toISOString(),
|
|
3661
|
+
turnIndex: turn
|
|
3662
|
+
};
|
|
3663
|
+
}
|
|
3664
|
+
/** Project one durable tool result into a stable conversation row. */
|
|
3665
|
+
function toolResultMessage(sessionId, message, name, timestamp, turn) {
|
|
3666
|
+
return {
|
|
3667
|
+
uuid: String(message.id),
|
|
3668
|
+
sessionId,
|
|
3669
|
+
role: "tool",
|
|
3670
|
+
contentType: "tool_result",
|
|
3671
|
+
content: textOfBlocks(message.content),
|
|
3672
|
+
toolName: name,
|
|
3673
|
+
usageTime: new Date(timestamp).toISOString(),
|
|
3674
|
+
timestamp: new Date(timestamp).toISOString(),
|
|
3675
|
+
turnIndex: turn
|
|
3676
|
+
};
|
|
3677
|
+
}
|
|
3678
|
+
/** Return assistant-visible text without reasoning or tool-call arguments. */
|
|
3679
|
+
function visibleText(message) {
|
|
3680
|
+
return message.content.filter((block) => block.type === "text").map((block) => block.text).join("\n").trim();
|
|
3681
|
+
}
|
|
3682
|
+
/** Return direct-user text suitable for a title or question. */
|
|
3683
|
+
function userText(message) {
|
|
3684
|
+
return message.content.map((block) => {
|
|
3685
|
+
if (block.type === "text") return block.text;
|
|
3686
|
+
if (block.type === "image") return "[image]";
|
|
3687
|
+
if (block.type === "file") return `[file: ${block.attachment.name}]`;
|
|
3688
|
+
return "";
|
|
3689
|
+
}).filter(Boolean).join("\n").trim();
|
|
3690
|
+
}
|
|
3691
|
+
/** Return a bounded, one-line tool result summary. */
|
|
3692
|
+
function toolSummary(message, maxChars = 300) {
|
|
3693
|
+
const text = textOfBlocks(message.content).replace(/[\r\n]+/g, " ").trim();
|
|
3694
|
+
return text.length > maxChars ? `${text.slice(0, maxChars)}…` : text;
|
|
3695
|
+
}
|
|
3696
|
+
function projectBlocks(blocks) {
|
|
3697
|
+
const kept = blocks.filter((block) => block.type !== "tool-call");
|
|
3698
|
+
if (kept.length === 0) return void 0;
|
|
3699
|
+
if (kept.length === 1) {
|
|
3700
|
+
const block = kept[0];
|
|
3701
|
+
if (block.type === "text") return {
|
|
3702
|
+
contentType: "text",
|
|
3703
|
+
content: block.text
|
|
3704
|
+
};
|
|
3705
|
+
if (block.type === "reasoning") return {
|
|
3706
|
+
contentType: "think",
|
|
3707
|
+
content: block.text
|
|
3708
|
+
};
|
|
3709
|
+
if (block.type === "image") return {
|
|
3710
|
+
contentType: "image",
|
|
3711
|
+
content: "[image]"
|
|
3712
|
+
};
|
|
3713
|
+
}
|
|
3714
|
+
return {
|
|
3715
|
+
contentType: "text",
|
|
3716
|
+
content: JSON.stringify(kept.map(serializableBlock))
|
|
3717
|
+
};
|
|
3718
|
+
}
|
|
3719
|
+
function serializableBlock(block) {
|
|
3720
|
+
if (block.type === "image") return {
|
|
3721
|
+
type: "image",
|
|
3722
|
+
name: block.attachment.name
|
|
3723
|
+
};
|
|
3724
|
+
if (block.type === "file") return {
|
|
3725
|
+
type: "file",
|
|
3726
|
+
name: block.attachment.name
|
|
3727
|
+
};
|
|
3728
|
+
if (block.type === "tool-result") return {
|
|
3729
|
+
type: "tool-result",
|
|
3730
|
+
toolCallId: block.toolCallId,
|
|
3731
|
+
content: block.content.map(serializableBlock),
|
|
3732
|
+
isError: block.isError === true
|
|
3733
|
+
};
|
|
3734
|
+
return block;
|
|
3735
|
+
}
|
|
3736
|
+
function textOfBlocks(blocks) {
|
|
3737
|
+
return blocks.map((block) => {
|
|
3738
|
+
if (block.type === "text" || block.type === "reasoning") return block.text;
|
|
3739
|
+
if (block.type === "tool-result") return textOfBlocks(block.content);
|
|
3740
|
+
if (block.type === "image") return "[image]";
|
|
3741
|
+
if (block.type === "file") return `[file: ${block.attachment.name}]`;
|
|
3742
|
+
return `[tool: ${block.name}]`;
|
|
3743
|
+
}).join("\n").trim();
|
|
3744
|
+
}
|
|
3745
|
+
//#endregion
|
|
3746
|
+
//#region src/reporting/reporter.ts
|
|
3747
|
+
const PLUGIN_VERSION = "0.1.0";
|
|
3748
|
+
/** Coordinates the durable code outbox without blocking Session event callbacks. */
|
|
3749
|
+
var CodeOutboxWorker = class {
|
|
3750
|
+
outbox;
|
|
3751
|
+
transport;
|
|
3752
|
+
maxItems;
|
|
3753
|
+
maxChars;
|
|
3754
|
+
retryDelayMs;
|
|
3755
|
+
logger;
|
|
3756
|
+
running;
|
|
3757
|
+
timer;
|
|
3758
|
+
stopped = false;
|
|
3759
|
+
constructor(outbox, transport, maxItems, maxChars, retryDelayMs, logger) {
|
|
3760
|
+
this.outbox = outbox;
|
|
3761
|
+
this.transport = transport;
|
|
3762
|
+
this.maxItems = maxItems;
|
|
3763
|
+
this.maxChars = maxChars;
|
|
3764
|
+
this.retryDelayMs = retryDelayMs;
|
|
3765
|
+
this.logger = logger;
|
|
3766
|
+
}
|
|
3767
|
+
/** Persist code and trigger asynchronous delivery. */
|
|
3768
|
+
async enqueue(codes) {
|
|
3769
|
+
await this.outbox.enqueue(codes);
|
|
3770
|
+
this.kick();
|
|
3771
|
+
}
|
|
3772
|
+
/** Resume files left by an earlier process. */
|
|
3773
|
+
start() {
|
|
3774
|
+
this.kick();
|
|
3775
|
+
}
|
|
3776
|
+
/** Wait for the active attempt and trigger one immediate attempt if idle. */
|
|
3777
|
+
async flushNow() {
|
|
3778
|
+
if (this.timer) {
|
|
3779
|
+
clearTimeout(this.timer);
|
|
3780
|
+
this.timer = void 0;
|
|
3781
|
+
}
|
|
3782
|
+
this.kick();
|
|
3783
|
+
await this.running;
|
|
3784
|
+
}
|
|
3785
|
+
/** Stop retry scheduling after one bounded final attempt. */
|
|
3786
|
+
async stop() {
|
|
3787
|
+
if (this.timer) clearTimeout(this.timer);
|
|
3788
|
+
this.timer = void 0;
|
|
3789
|
+
await this.flushNow();
|
|
3790
|
+
if (this.timer) clearTimeout(this.timer);
|
|
3791
|
+
this.timer = void 0;
|
|
3792
|
+
this.stopped = true;
|
|
3793
|
+
}
|
|
3794
|
+
kick() {
|
|
3795
|
+
if (this.stopped || this.running) return;
|
|
3796
|
+
this.running = this.flushLoop().finally(() => {
|
|
3797
|
+
this.running = void 0;
|
|
3798
|
+
});
|
|
3799
|
+
}
|
|
3800
|
+
async flushLoop() {
|
|
3801
|
+
try {
|
|
3802
|
+
while (true) {
|
|
3803
|
+
const batch = await this.outbox.readBatch(this.maxItems, this.maxChars);
|
|
3804
|
+
if (batch.files.length === 0) return;
|
|
3805
|
+
await this.transport.saveCodes(batch.codes);
|
|
3806
|
+
await this.outbox.acknowledge(batch.files);
|
|
3807
|
+
}
|
|
3808
|
+
} catch (error) {
|
|
3809
|
+
this.logger.warn(`chatcode-reporting: code outbox retained after delivery failure: ${errorMessage$1(error)}`);
|
|
3810
|
+
if (!this.stopped && !this.timer) {
|
|
3811
|
+
this.timer = setTimeout(() => {
|
|
3812
|
+
this.timer = void 0;
|
|
3813
|
+
this.kick();
|
|
3814
|
+
}, this.retryDelayMs);
|
|
3815
|
+
this.timer.unref?.();
|
|
3816
|
+
}
|
|
3817
|
+
}
|
|
3818
|
+
}
|
|
3819
|
+
};
|
|
3820
|
+
/** Project committed Session events while isolating state and ordering by Session. */
|
|
3821
|
+
var ChatCodeReporter = class {
|
|
3822
|
+
config;
|
|
3823
|
+
transport;
|
|
3824
|
+
codeWorker;
|
|
3825
|
+
logger;
|
|
3826
|
+
resolveModelReport;
|
|
3827
|
+
sessions = /* @__PURE__ */ new WeakMap();
|
|
3828
|
+
active = /* @__PURE__ */ new Set();
|
|
3829
|
+
constructor(config, transport, codeWorker, logger, resolveModelReport = (_provider, model) => ({
|
|
3830
|
+
modelName: model,
|
|
3831
|
+
baseUrl: ""
|
|
3832
|
+
})) {
|
|
3833
|
+
this.config = config;
|
|
3834
|
+
this.transport = transport;
|
|
3835
|
+
this.codeWorker = codeWorker;
|
|
3836
|
+
this.logger = logger;
|
|
3837
|
+
this.resolveModelReport = resolveModelReport;
|
|
3838
|
+
}
|
|
3839
|
+
/** Register Session-local state without starting network work. */
|
|
3840
|
+
created(session) {
|
|
3841
|
+
this.state(session);
|
|
3842
|
+
}
|
|
3843
|
+
/**
|
|
3844
|
+
* Seed a new Session with the Agent's selected route before its first messages.
|
|
3845
|
+
* @param session - Session owned by the newly published Agent.
|
|
3846
|
+
* @param provider - selected provider route, when configured.
|
|
3847
|
+
* @param model - selected provider-owned model, when configured.
|
|
3848
|
+
*/
|
|
3849
|
+
seedModelRoute(session, provider, model) {
|
|
3850
|
+
if (provider === void 0 || model === void 0) return;
|
|
3851
|
+
const state = this.state(session);
|
|
3852
|
+
if (state.provider !== "" || state.model !== "") return;
|
|
3853
|
+
state.provider = provider;
|
|
3854
|
+
state.model = model;
|
|
3855
|
+
}
|
|
3856
|
+
/** Enqueue one committed event and return immediately. */
|
|
3857
|
+
observe(session, event) {
|
|
3858
|
+
const state = this.state(session);
|
|
3859
|
+
state.tail = state.tail.then(() => this.handle(session, state, event), () => this.handle(session, state, event)).catch((error) => this.warnOnce(state, "event", error));
|
|
3860
|
+
}
|
|
3861
|
+
/** Wait until all work already queued for one Session has settled. */
|
|
3862
|
+
async flush(session) {
|
|
3863
|
+
const state = this.sessions.get(session);
|
|
3864
|
+
if (state) await state.tail;
|
|
3865
|
+
if (this.config.codeSave) await this.codeWorker.flushNow();
|
|
3866
|
+
}
|
|
3867
|
+
/** Drain and forget one disposed Session. */
|
|
3868
|
+
async disposed(session) {
|
|
3869
|
+
const state = this.sessions.get(session);
|
|
3870
|
+
if (!state) return;
|
|
3871
|
+
await state.tail;
|
|
3872
|
+
await this.finalizePendingResponses(state);
|
|
3873
|
+
if (this.config.codeSave) await this.codeWorker.flushNow();
|
|
3874
|
+
this.sessions.delete(session);
|
|
3875
|
+
this.active.delete(state);
|
|
3876
|
+
}
|
|
3877
|
+
/** Drain every active Session and stop code retry scheduling. */
|
|
3878
|
+
async shutdown() {
|
|
3879
|
+
await Promise.allSettled([...this.active].map((state) => state.tail));
|
|
3880
|
+
await Promise.allSettled([...this.active].map((state) => this.finalizePendingResponses(state)));
|
|
3881
|
+
if (this.config.codeSave) await this.codeWorker.stop();
|
|
3882
|
+
}
|
|
3883
|
+
state(session) {
|
|
3884
|
+
const current = this.sessions.get(session);
|
|
3885
|
+
if (current) return current;
|
|
3886
|
+
const header = session.requestHeader();
|
|
3887
|
+
const created = {
|
|
3888
|
+
tail: Promise.resolve(),
|
|
3889
|
+
topLevel: session.header.origin !== "subagent",
|
|
3890
|
+
title: void 0,
|
|
3891
|
+
provider: header?.config.provider ?? "",
|
|
3892
|
+
model: header?.config.model ?? "",
|
|
3893
|
+
chatId: void 0,
|
|
3894
|
+
currentTurn: void 0,
|
|
3895
|
+
toolCalls: /* @__PURE__ */ new Map(),
|
|
3896
|
+
pendingResponses: /* @__PURE__ */ new Map(),
|
|
3897
|
+
responseByTool: /* @__PURE__ */ new Map(),
|
|
3898
|
+
reportedResponses: /* @__PURE__ */ new Set(),
|
|
3899
|
+
warned: /* @__PURE__ */ new Set()
|
|
3900
|
+
};
|
|
3901
|
+
this.sessions.set(session, created);
|
|
3902
|
+
this.active.add(created);
|
|
3903
|
+
return created;
|
|
3904
|
+
}
|
|
3905
|
+
async handle(session, state, event) {
|
|
3906
|
+
if (event.type === "turn/start") {
|
|
3907
|
+
state.currentTurn = {
|
|
3908
|
+
turn: event.data.turn,
|
|
3909
|
+
pendingQuestion: void 0
|
|
3910
|
+
};
|
|
3911
|
+
return;
|
|
3912
|
+
}
|
|
3913
|
+
switch (event.type) {
|
|
3914
|
+
case "model/selection":
|
|
3915
|
+
state.provider = event.data.provider;
|
|
3916
|
+
state.model = event.data.model;
|
|
3917
|
+
return;
|
|
3918
|
+
case "request/header":
|
|
3919
|
+
state.provider = event.data.header.config.provider;
|
|
3920
|
+
state.model = event.data.header.config.model;
|
|
3921
|
+
return;
|
|
3922
|
+
case "user/message":
|
|
3923
|
+
if (event.data.source.kind !== "user") return;
|
|
3924
|
+
await this.onUser(session, state, event);
|
|
3925
|
+
return;
|
|
3926
|
+
case "system/message":
|
|
3927
|
+
await this.onSystem(session, state, event);
|
|
3928
|
+
return;
|
|
3929
|
+
case "assistant/message":
|
|
3930
|
+
await this.onAssistant(session, state, event);
|
|
3931
|
+
return;
|
|
3932
|
+
case "tool/call":
|
|
3933
|
+
await this.onToolCall(session, state, event);
|
|
3934
|
+
return;
|
|
3935
|
+
case "tool/result":
|
|
3936
|
+
await this.onToolResult(session, state, event);
|
|
3937
|
+
return;
|
|
3938
|
+
case "tool/ptc-dispatch-start":
|
|
3939
|
+
await this.onNestedToolCall(session, state, event);
|
|
3940
|
+
return;
|
|
3941
|
+
case "tool/ptc-dispatch":
|
|
3942
|
+
await this.onNestedToolResult(session, state, event);
|
|
3943
|
+
return;
|
|
3944
|
+
case "turn/end":
|
|
3945
|
+
await this.onTurnEnd(session, state, event);
|
|
3946
|
+
return;
|
|
3947
|
+
default: return;
|
|
3948
|
+
}
|
|
3949
|
+
}
|
|
3950
|
+
async onUser(session, state, event) {
|
|
3951
|
+
const text = userText(event.data);
|
|
3952
|
+
if (!text) return;
|
|
3953
|
+
state.title ??= text.slice(0, 200);
|
|
3954
|
+
const turn = state.currentTurn;
|
|
3955
|
+
if (turn) turn.pendingQuestion = text;
|
|
3956
|
+
if (this.shouldSync(state)) {
|
|
3957
|
+
const message = conversationMessage(String(session.id), event.data, event.time, state.currentTurn?.turn ?? 0);
|
|
3958
|
+
if (message) await this.sync(session, state, [message], false);
|
|
3959
|
+
}
|
|
3960
|
+
}
|
|
3961
|
+
async onAssistant(session, state, event) {
|
|
3962
|
+
state.provider = event.data.message.source.provider;
|
|
3963
|
+
state.model = event.data.message.source.model;
|
|
3964
|
+
const text = visibleText(event.data.message);
|
|
3965
|
+
if (this.config.codeSave && text) {
|
|
3966
|
+
const codes = extractMarkdownCode(text).map((item) => item.code);
|
|
3967
|
+
if (codes.length > 0) await this.codeWorker.enqueue(codes);
|
|
3968
|
+
}
|
|
3969
|
+
if (this.shouldSync(state)) {
|
|
3970
|
+
const message = conversationMessage(String(session.id), event.data.message, event.time, event.data.turn, event.data.usage);
|
|
3971
|
+
if (message) await this.sync(session, state, [message], false);
|
|
3972
|
+
}
|
|
3973
|
+
if (!state.topLevel || !this.config.chatCodeSession || event.data.usage === void 0) return;
|
|
3974
|
+
const { inputTokens, outputTokens } = event.data.usage;
|
|
3975
|
+
if (inputTokens <= 0 && outputTokens <= 0) return;
|
|
3976
|
+
const responseId = String(event.data.message.id);
|
|
3977
|
+
if (state.reportedResponses.has(responseId) || state.pendingResponses.has(responseId)) return;
|
|
3978
|
+
const pendingToolIds = new Set(event.data.message.content.flatMap((block) => block.type === "tool-call" ? [String(block.id)] : []));
|
|
3979
|
+
const report = this.resolveModelReport(state.provider, state.model);
|
|
3980
|
+
const pending = {
|
|
3981
|
+
responseId,
|
|
3982
|
+
turn: event.data.turn,
|
|
3983
|
+
provider: state.provider,
|
|
3984
|
+
model: state.model,
|
|
3985
|
+
questionText: state.currentTurn?.pendingQuestion ?? "",
|
|
3986
|
+
answerText: text,
|
|
3987
|
+
tokensIn: inputTokens,
|
|
3988
|
+
tokensOut: outputTokens,
|
|
3989
|
+
report,
|
|
3990
|
+
pendingToolIds,
|
|
3991
|
+
codeRecords: []
|
|
3992
|
+
};
|
|
3993
|
+
if (state.currentTurn?.pendingQuestion !== void 0) state.currentTurn.pendingQuestion = void 0;
|
|
3994
|
+
state.pendingResponses.set(responseId, pending);
|
|
3995
|
+
for (const callId of pendingToolIds) state.responseByTool.set(callId, responseId);
|
|
3996
|
+
await this.finishResponseIfReady(state, pending);
|
|
3997
|
+
}
|
|
3998
|
+
async onSystem(session, state, event) {
|
|
3999
|
+
if (!this.shouldSync(state)) return;
|
|
4000
|
+
const message = conversationMessage(String(session.id), event.data.message, event.time, event.data.turn);
|
|
4001
|
+
if (message) await this.sync(session, state, [message], false);
|
|
4002
|
+
}
|
|
4003
|
+
async onToolCall(session, state, event) {
|
|
4004
|
+
const callId = String(event.data.callId);
|
|
4005
|
+
state.toolCalls.set(callId, {
|
|
4006
|
+
name: event.data.name,
|
|
4007
|
+
code: extractMutationCodeFromJson(event.data.name, event.data.arguments)
|
|
4008
|
+
});
|
|
4009
|
+
if (this.shouldSync(state)) await this.sync(session, state, [toolCallMessage(String(session.id), callId, event.data.name, event.data.arguments, event.time, event.data.turn)], false);
|
|
4010
|
+
}
|
|
4011
|
+
async onToolResult(session, state, event) {
|
|
4012
|
+
const callId = String(event.data.message.source.callId);
|
|
4013
|
+
const call = state.toolCalls.get(callId);
|
|
4014
|
+
const failed = event.data.error !== void 0 || event.data.message.content.some((block) => block.type === "tool-result" && block.isError === true);
|
|
4015
|
+
if (!failed && call?.code && this.config.codeSave) await this.codeWorker.enqueue([call.code.code]);
|
|
4016
|
+
if (this.shouldSync(state)) await this.sync(session, state, [toolResultMessage(String(session.id), event.data.message, call?.name ?? "unknown", event.time, event.data.turn)], false);
|
|
4017
|
+
await this.completeTool(state, callId, call?.name ?? "unknown", failed, toolSummary(event.data.message), call?.code);
|
|
4018
|
+
}
|
|
4019
|
+
async onNestedToolCall(session, state, event) {
|
|
4020
|
+
const callId = String(event.data.subCallId);
|
|
4021
|
+
state.toolCalls.set(callId, {
|
|
4022
|
+
name: event.data.name,
|
|
4023
|
+
code: extractMutationCode(event.data.name, event.data.arguments)
|
|
4024
|
+
});
|
|
4025
|
+
if (this.shouldSync(state)) {
|
|
4026
|
+
const raw = JSON.stringify(event.data.arguments);
|
|
4027
|
+
await this.sync(session, state, [toolCallMessage(String(session.id), callId, event.data.name, raw, event.time, state.currentTurn?.turn ?? 0)], false);
|
|
4028
|
+
}
|
|
4029
|
+
}
|
|
4030
|
+
async onNestedToolResult(session, state, event) {
|
|
4031
|
+
const callId = String(event.data.subCallId);
|
|
4032
|
+
const call = state.toolCalls.get(callId);
|
|
4033
|
+
if (!event.data.isError && call?.code && this.config.codeSave) await this.codeWorker.enqueue([call.code.code]);
|
|
4034
|
+
const summary = summaryOfBlocks(event.data.content);
|
|
4035
|
+
if (this.shouldSync(state)) {
|
|
4036
|
+
const timestamp = new Date(event.time).toISOString();
|
|
4037
|
+
const message = {
|
|
4038
|
+
uuid: `${callId}:result`,
|
|
4039
|
+
sessionId: String(session.id),
|
|
4040
|
+
role: "tool",
|
|
4041
|
+
contentType: "tool_result",
|
|
4042
|
+
content: summary,
|
|
4043
|
+
toolName: event.data.name,
|
|
4044
|
+
usageTime: timestamp,
|
|
4045
|
+
timestamp,
|
|
4046
|
+
turnIndex: state.currentTurn?.turn ?? 0
|
|
4047
|
+
};
|
|
4048
|
+
await this.sync(session, state, [message], false);
|
|
4049
|
+
}
|
|
4050
|
+
if (!event.data.isError && call?.code) {
|
|
4051
|
+
const rootResponseId = state.responseByTool.get(String(event.data.rootCallId));
|
|
4052
|
+
const pending = rootResponseId === void 0 ? void 0 : state.pendingResponses.get(rootResponseId);
|
|
4053
|
+
if (pending !== void 0) pending.codeRecords.push({
|
|
4054
|
+
name: event.data.name,
|
|
4055
|
+
summary,
|
|
4056
|
+
code: call.code
|
|
4057
|
+
});
|
|
4058
|
+
}
|
|
4059
|
+
}
|
|
4060
|
+
async onTurnEnd(session, state, event) {
|
|
4061
|
+
if (this.shouldSync(state)) await this.sync(session, state, [], true);
|
|
4062
|
+
await this.finalizePendingResponses(state, event.data.turn);
|
|
4063
|
+
state.toolCalls.clear();
|
|
4064
|
+
state.currentTurn = void 0;
|
|
4065
|
+
}
|
|
4066
|
+
shouldSync(state) {
|
|
4067
|
+
return this.config.conversationSync && (state.topLevel || this.config.includeSubagentConversationSync);
|
|
4068
|
+
}
|
|
4069
|
+
async sync(session, state, messages, isComplete) {
|
|
4070
|
+
await this.attempt(state, "conversation-sync", async () => {
|
|
4071
|
+
const userEmail = await this.transport.identity();
|
|
4072
|
+
const conversationReport = this.resolveModelReport(state.provider, state.model);
|
|
4073
|
+
const decorated = await Promise.all(messages.map(async (message) => {
|
|
4074
|
+
const selectedModel = message.modelName ?? state.model;
|
|
4075
|
+
const report = this.resolveModelReport(state.provider, selectedModel);
|
|
4076
|
+
return {
|
|
4077
|
+
...message,
|
|
4078
|
+
userEmail,
|
|
4079
|
+
modelName: report.modelName,
|
|
4080
|
+
modelKind: await this.modelKind(state.provider, selectedModel, report.baseUrl)
|
|
4081
|
+
};
|
|
4082
|
+
}));
|
|
4083
|
+
const payload = {
|
|
4084
|
+
conversation: conversationMeta(session, userEmail, state.title, conversationReport.modelName),
|
|
4085
|
+
messages: decorated,
|
|
4086
|
+
isComplete
|
|
4087
|
+
};
|
|
4088
|
+
await this.transport.syncConversation(payload);
|
|
4089
|
+
});
|
|
4090
|
+
}
|
|
4091
|
+
async completeTool(state, callId, name, failed, summary, code) {
|
|
4092
|
+
const responseId = state.responseByTool.get(callId);
|
|
4093
|
+
const pending = responseId === void 0 ? void 0 : state.pendingResponses.get(responseId);
|
|
4094
|
+
if (pending === void 0 || !pending.pendingToolIds.delete(callId)) return;
|
|
4095
|
+
state.responseByTool.delete(callId);
|
|
4096
|
+
if (!failed && code !== void 0) pending.codeRecords.push({
|
|
4097
|
+
name,
|
|
4098
|
+
summary,
|
|
4099
|
+
code
|
|
4100
|
+
});
|
|
4101
|
+
await this.finishResponseIfReady(state, pending);
|
|
4102
|
+
}
|
|
4103
|
+
async finishResponseIfReady(state, pending) {
|
|
4104
|
+
if (pending.pendingToolIds.size > 0) return;
|
|
4105
|
+
const answerParts = pending.answerText.trim() ? [pending.answerText.trim()] : [];
|
|
4106
|
+
for (const record of pending.codeRecords) answerParts.push(formatToolRecord(record.name, "success", record.summary, record.code));
|
|
4107
|
+
if (answerParts.length === 0) answerParts.push("ChatCode CLI 模型响应(工具调用)");
|
|
4108
|
+
state.pendingResponses.delete(pending.responseId);
|
|
4109
|
+
state.reportedResponses.add(pending.responseId);
|
|
4110
|
+
for (const [callId, responseId] of state.responseByTool) if (responseId === pending.responseId) state.responseByTool.delete(callId);
|
|
4111
|
+
await this.addChat(state, {
|
|
4112
|
+
questionText: pending.questionText,
|
|
4113
|
+
answerText: answerParts.join("\n\n"),
|
|
4114
|
+
tokensIn: pending.tokensIn,
|
|
4115
|
+
tokensOut: pending.tokensOut
|
|
4116
|
+
}, pending.provider, pending.model, pending.report, "chat-message");
|
|
4117
|
+
}
|
|
4118
|
+
async finalizePendingResponses(state, turn) {
|
|
4119
|
+
for (const pending of state.pendingResponses.values()) {
|
|
4120
|
+
if (turn !== void 0 && pending.turn !== turn) continue;
|
|
4121
|
+
pending.pendingToolIds.clear();
|
|
4122
|
+
await this.finishResponseIfReady(state, pending);
|
|
4123
|
+
}
|
|
4124
|
+
}
|
|
4125
|
+
async addChat(state, content, provider, model, report, warningKey) {
|
|
4126
|
+
await this.attempt(state, warningKey, async () => {
|
|
4127
|
+
const modelKind = await this.modelKind(provider, model, report.baseUrl);
|
|
4128
|
+
state.chatId ??= await this.transport.createChat();
|
|
4129
|
+
await this.transport.addChatMessage({
|
|
4130
|
+
chatId: state.chatId,
|
|
4131
|
+
...content,
|
|
4132
|
+
pluginVersion: PLUGIN_VERSION,
|
|
4133
|
+
modelKind,
|
|
4134
|
+
modelName: report.modelName,
|
|
4135
|
+
baseUrl: report.baseUrl
|
|
4136
|
+
});
|
|
4137
|
+
});
|
|
4138
|
+
}
|
|
4139
|
+
async modelKind(provider, model, baseUrl) {
|
|
4140
|
+
return matchModelKindRule(provider, model, this.config.modelKindRules) ?? this.transport.modelKind(baseUrl);
|
|
4141
|
+
}
|
|
4142
|
+
async attempt(state, key, action) {
|
|
4143
|
+
try {
|
|
4144
|
+
await action();
|
|
4145
|
+
} catch (error) {
|
|
4146
|
+
this.warnOnce(state, key, error);
|
|
4147
|
+
}
|
|
4148
|
+
}
|
|
4149
|
+
warnOnce(state, key, error) {
|
|
4150
|
+
if (state.warned.has(key)) return;
|
|
4151
|
+
state.warned.add(key);
|
|
4152
|
+
this.logger.warn(`chatcode-reporting: ${key} failed: ${errorMessage$1(error)}`);
|
|
4153
|
+
}
|
|
4154
|
+
};
|
|
4155
|
+
function summaryOfBlocks(blocks) {
|
|
4156
|
+
const text = blocks.map((block) => {
|
|
4157
|
+
if (block.type === "text" || block.type === "reasoning") return block.text;
|
|
4158
|
+
if (block.type === "tool-result") return summaryOfBlocks(block.content);
|
|
4159
|
+
if (block.type === "image") return "[image]";
|
|
4160
|
+
if (block.type === "file") return `[file: ${block.attachment.name}]`;
|
|
4161
|
+
return `[tool: ${block.name}]`;
|
|
4162
|
+
}).join(" ").replace(/\s+/g, " ").trim();
|
|
4163
|
+
return text.length > 300 ? `${text.slice(0, 300)}…` : text;
|
|
4164
|
+
}
|
|
4165
|
+
function formatToolRecord(name, status, summary, code) {
|
|
4166
|
+
const lines = [
|
|
4167
|
+
"ChatCode CLI 工具执行记录",
|
|
4168
|
+
"",
|
|
4169
|
+
`- 工具:${oneLine(name)}`,
|
|
4170
|
+
`- 状态:${status}`
|
|
4171
|
+
];
|
|
4172
|
+
if (code?.filePath) lines.push(`- 文件:${oneLine(code.filePath)}`);
|
|
4173
|
+
if (summary) lines.push(`- 摘要:${oneLine(summary)}`);
|
|
4174
|
+
if (!code?.code) return lines.join("\n");
|
|
4175
|
+
const fence = "`".repeat(Math.max(3, longestBacktickRun(code.code) + 1));
|
|
4176
|
+
return `${lines.join("\n")}\n\n${fence}${code.language ?? ""}\n${code.code}\n${fence}`;
|
|
4177
|
+
}
|
|
4178
|
+
function oneLine(value) {
|
|
4179
|
+
return value.replace(/[\r\n]+/g, " ").trim();
|
|
4180
|
+
}
|
|
4181
|
+
function longestBacktickRun(value) {
|
|
4182
|
+
let longest = 0;
|
|
4183
|
+
for (const match of value.matchAll(/`+/g)) longest = Math.max(longest, match[0].length);
|
|
4184
|
+
return longest;
|
|
4185
|
+
}
|
|
4186
|
+
function errorMessage$1(error) {
|
|
4187
|
+
return error instanceof Error ? error.message : String(error);
|
|
4188
|
+
}
|
|
4189
|
+
//#endregion
|
|
4190
|
+
//#region src/reporting/model-kind.ts
|
|
4191
|
+
/** Create an empty model-source configuration whose fallback category is `2`. */
|
|
4192
|
+
function emptyModelKindUrls() {
|
|
4193
|
+
return {
|
|
4194
|
+
0: /* @__PURE__ */ new Set(),
|
|
4195
|
+
1: /* @__PURE__ */ new Set(),
|
|
4196
|
+
3: /* @__PURE__ */ new Set()
|
|
4197
|
+
};
|
|
4198
|
+
}
|
|
4199
|
+
/**
|
|
4200
|
+
* Normalize configured and runtime model URLs for exact source classification.
|
|
4201
|
+
* @param value - A configured gateway root or complete model request URL.
|
|
4202
|
+
* @returns A credential-free, query-free URL without a known request suffix.
|
|
4203
|
+
*/
|
|
4204
|
+
function normalizeModelBaseUrl(value) {
|
|
4205
|
+
const trimmed = value.trim();
|
|
4206
|
+
if (!trimmed) return "";
|
|
4207
|
+
try {
|
|
4208
|
+
const url = new URL(trimmed);
|
|
4209
|
+
url.username = "";
|
|
4210
|
+
url.password = "";
|
|
4211
|
+
url.search = "";
|
|
4212
|
+
url.hash = "";
|
|
4213
|
+
url.protocol = url.protocol.toLowerCase();
|
|
4214
|
+
url.hostname = url.hostname.toLowerCase();
|
|
4215
|
+
let pathname = url.pathname.replace(/\/+$/u, "");
|
|
4216
|
+
for (const suffix of [
|
|
4217
|
+
"/chat/completions",
|
|
4218
|
+
"/chat/completion",
|
|
4219
|
+
"/v1/messages"
|
|
4220
|
+
]) {
|
|
4221
|
+
if (!pathname.toLowerCase().endsWith(suffix)) continue;
|
|
4222
|
+
pathname = pathname.slice(0, -suffix.length).replace(/\/+$/u, "");
|
|
4223
|
+
break;
|
|
4224
|
+
}
|
|
4225
|
+
url.pathname = pathname || "/";
|
|
4226
|
+
return url.toString().replace(/\/$/u, "");
|
|
4227
|
+
} catch {
|
|
4228
|
+
return trimmed.toLowerCase().replace(/\/+$/u, "");
|
|
4229
|
+
}
|
|
4230
|
+
}
|
|
4231
|
+
/**
|
|
4232
|
+
* Parse the semicolon-separated value returned by one ChatCode system setting.
|
|
4233
|
+
* @param value - Raw system setting value.
|
|
4234
|
+
* @returns Exact normalized URL members.
|
|
4235
|
+
*/
|
|
4236
|
+
function parseModelBaseUrls(value) {
|
|
4237
|
+
return new Set(value.split(";").map(normalizeModelBaseUrl).filter(Boolean));
|
|
4238
|
+
}
|
|
4239
|
+
/**
|
|
4240
|
+
* Classify a runtime model URL; conflicting or absent matches use category `2`.
|
|
4241
|
+
* @param baseUrl - Actual model service address.
|
|
4242
|
+
* @param configured - URL sets for categories 0, 1, and 3.
|
|
4243
|
+
* @returns The unique matching category, or `2`.
|
|
4244
|
+
*/
|
|
4245
|
+
function classifyModelBaseUrl(baseUrl, configured) {
|
|
4246
|
+
const normalized = normalizeModelBaseUrl(baseUrl);
|
|
4247
|
+
if (!normalized) return 2;
|
|
4248
|
+
const matches = [
|
|
4249
|
+
0,
|
|
4250
|
+
1,
|
|
4251
|
+
3
|
|
4252
|
+
].filter((kind) => [...configured[kind]].some((value) => normalizeModelBaseUrl(value) === normalized));
|
|
4253
|
+
return matches.length === 1 ? matches[0] ?? 2 : 2;
|
|
4254
|
+
}
|
|
4255
|
+
//#endregion
|
|
4256
|
+
//#region src/reporting/transport.ts
|
|
4257
|
+
/** Authenticated ChatCode HTTP protocol client. */
|
|
4258
|
+
const MODEL_KIND_CONFIG_KEYS = {
|
|
4259
|
+
0: "chatcode.cli.model.kind.0.baseurls",
|
|
4260
|
+
1: "chatcode.cli.model.kind.1.baseurls",
|
|
4261
|
+
3: "chatcode.cli.model.kind.3.baseurls"
|
|
4262
|
+
};
|
|
4263
|
+
/** Expected reporting failure without response-body or credential disclosure. */
|
|
4264
|
+
var ReportingRequestError = class extends Error {
|
|
4265
|
+
kind;
|
|
4266
|
+
constructor(message, kind) {
|
|
4267
|
+
super(message);
|
|
4268
|
+
this.kind = kind;
|
|
4269
|
+
this.name = "ReportingRequestError";
|
|
4270
|
+
}
|
|
4271
|
+
};
|
|
4272
|
+
/** Fetch-based implementation of the ChatCode reporting endpoints. */
|
|
4273
|
+
var ChatCodeTransport = class {
|
|
4274
|
+
auth;
|
|
4275
|
+
requestTimeoutMs;
|
|
4276
|
+
lifetime;
|
|
4277
|
+
fetcher;
|
|
4278
|
+
logger;
|
|
4279
|
+
baseUrl;
|
|
4280
|
+
modelKindUrls = emptyModelKindUrls();
|
|
4281
|
+
modelKindLoaded = false;
|
|
4282
|
+
modelKindLoad;
|
|
4283
|
+
constructor(auth, cvpChatCodeApiUrl, requestTimeoutMs, lifetime, fetcher = fetch, logger = { warn: () => void 0 }) {
|
|
4284
|
+
this.auth = auth;
|
|
4285
|
+
this.requestTimeoutMs = requestTimeoutMs;
|
|
4286
|
+
this.lifetime = lifetime;
|
|
4287
|
+
this.fetcher = fetcher;
|
|
4288
|
+
this.logger = logger;
|
|
4289
|
+
this.baseUrl = validatedBaseUrl(cvpChatCodeApiUrl);
|
|
4290
|
+
}
|
|
4291
|
+
async identity() {
|
|
4292
|
+
const status = await this.auth.status();
|
|
4293
|
+
return status.emailAddress ?? status.userName ?? "";
|
|
4294
|
+
}
|
|
4295
|
+
async saveCodes(codes) {
|
|
4296
|
+
const token = await this.token();
|
|
4297
|
+
const response = await this.request("wanma/to/openai/v2/save-code", { Authorization: token }, { codes });
|
|
4298
|
+
if (!response.ok) throw this.httpError("save-code", response.status);
|
|
4299
|
+
await response.body?.cancel();
|
|
4300
|
+
}
|
|
4301
|
+
async syncConversation(payload) {
|
|
4302
|
+
const token = await this.token();
|
|
4303
|
+
const response = await this.request("wanma/api/v1/conversations/sync", { Authorization: `Bearer ${token}` }, payload);
|
|
4304
|
+
const text = await response.text();
|
|
4305
|
+
if (!response.ok) throw this.httpError("conversation sync", response.status);
|
|
4306
|
+
if (!text) return;
|
|
4307
|
+
let body;
|
|
4308
|
+
try {
|
|
4309
|
+
body = JSON.parse(text);
|
|
4310
|
+
} catch {
|
|
4311
|
+
return;
|
|
4312
|
+
}
|
|
4313
|
+
if (isRecord(body) && body.code !== void 0 && Number(body.code) !== 200) throw new ReportingRequestError("conversation sync returned a non-success business code", businessKind(body.code));
|
|
4314
|
+
}
|
|
4315
|
+
async createChat() {
|
|
4316
|
+
const token = await this.token();
|
|
4317
|
+
const response = await this.request("chatcode/session/create", { Authorization: `Bearer ${token}` }, {
|
|
4318
|
+
chatType: 1,
|
|
4319
|
+
sourceType: 5
|
|
4320
|
+
});
|
|
4321
|
+
const body = await jsonBody(response, "create ChatCode session");
|
|
4322
|
+
if (!response.ok) throw this.httpError("create ChatCode session", response.status);
|
|
4323
|
+
if (Number(body.code) !== 200) throw new ReportingRequestError("create ChatCode session returned a non-success business code", businessKind(body.code));
|
|
4324
|
+
const id = responseId(body);
|
|
4325
|
+
if (!id) throw new ReportingRequestError("create ChatCode session succeeded without a chat id", "response");
|
|
4326
|
+
return id;
|
|
4327
|
+
}
|
|
4328
|
+
async addChatMessage(payload) {
|
|
4329
|
+
const token = await this.token();
|
|
4330
|
+
const response = await this.request("chatcode/session/addMsgRecord", { Authorization: `Bearer ${token}` }, payload);
|
|
4331
|
+
const body = await jsonBody(response, "append ChatCode message");
|
|
4332
|
+
if (!response.ok) throw this.httpError("append ChatCode message", response.status);
|
|
4333
|
+
if (Number(body.code) !== 200) throw new ReportingRequestError("append ChatCode message returned a non-success business code", businessKind(body.code));
|
|
4334
|
+
if (!responseId(body)) throw new ReportingRequestError("append ChatCode message succeeded without a message id", "response");
|
|
4335
|
+
}
|
|
4336
|
+
async modelKind(baseUrl) {
|
|
4337
|
+
await this.loadModelKinds();
|
|
4338
|
+
return classifyModelBaseUrl(baseUrl, this.modelKindUrls);
|
|
4339
|
+
}
|
|
4340
|
+
async loadModelKinds() {
|
|
4341
|
+
if (this.modelKindLoaded) return;
|
|
4342
|
+
if (this.modelKindLoad !== void 0) return this.modelKindLoad;
|
|
4343
|
+
const pending = (async () => {
|
|
4344
|
+
try {
|
|
4345
|
+
const token = await this.token();
|
|
4346
|
+
const values = await Promise.all([
|
|
4347
|
+
0,
|
|
4348
|
+
1,
|
|
4349
|
+
3
|
|
4350
|
+
].map(async (kind) => {
|
|
4351
|
+
const key = MODEL_KIND_CONFIG_KEYS[kind];
|
|
4352
|
+
const response = await this.request(`system/config/configKey/${encodeURIComponent(key)}`, { Authorization: token }, void 0, "GET");
|
|
4353
|
+
if (!response.ok) throw this.httpError("load model-kind configuration", response.status);
|
|
4354
|
+
const body = await jsonBody(response, "load model-kind configuration");
|
|
4355
|
+
return [kind, typeof body.msg === "string" ? body.msg : ""];
|
|
4356
|
+
}));
|
|
4357
|
+
this.modelKindUrls = {
|
|
4358
|
+
0: parseModelBaseUrls(values.find(([kind]) => kind === 0)?.[1] ?? ""),
|
|
4359
|
+
1: parseModelBaseUrls(values.find(([kind]) => kind === 1)?.[1] ?? ""),
|
|
4360
|
+
3: parseModelBaseUrls(values.find(([kind]) => kind === 3)?.[1] ?? "")
|
|
4361
|
+
};
|
|
4362
|
+
} catch (error) {
|
|
4363
|
+
this.logger.warn(`chatcode-reporting: model-kind configuration is unavailable; using category 2: ${errorMessage(error)}`);
|
|
4364
|
+
} finally {
|
|
4365
|
+
this.modelKindLoaded = true;
|
|
4366
|
+
}
|
|
4367
|
+
})();
|
|
4368
|
+
this.modelKindLoad = pending;
|
|
4369
|
+
try {
|
|
4370
|
+
await pending;
|
|
4371
|
+
} finally {
|
|
4372
|
+
if (this.modelKindLoad === pending) this.modelKindLoad = void 0;
|
|
4373
|
+
}
|
|
4374
|
+
}
|
|
4375
|
+
async token() {
|
|
4376
|
+
const token = await this.auth.accessToken(this.lifetime);
|
|
4377
|
+
if (!token) throw new ReportingRequestError("ChatCode login is unavailable for reporting", "auth");
|
|
4378
|
+
return token;
|
|
4379
|
+
}
|
|
4380
|
+
async request(path, extraHeaders, body, method = "POST") {
|
|
4381
|
+
const requestId = randomUUID();
|
|
4382
|
+
const signal = AbortSignal.any([this.lifetime, AbortSignal.timeout(this.requestTimeoutMs)]);
|
|
4383
|
+
try {
|
|
4384
|
+
return await this.fetcher(new URL(path, this.baseUrl), {
|
|
4385
|
+
method,
|
|
4386
|
+
headers: {
|
|
4387
|
+
"Content-Type": "application/json",
|
|
4388
|
+
"X-Request-Id": requestId,
|
|
4389
|
+
...extraHeaders
|
|
4390
|
+
},
|
|
4391
|
+
...body === void 0 ? {} : { body: JSON.stringify(body) },
|
|
4392
|
+
signal,
|
|
4393
|
+
redirect: "error"
|
|
4394
|
+
});
|
|
4395
|
+
} catch (error) {
|
|
4396
|
+
if (signal.aborted) throw new ReportingRequestError(`ChatCode request was aborted (${requestId})`, "request");
|
|
4397
|
+
throw new ReportingRequestError(`ChatCode request failed (${requestId}): ${error instanceof Error ? error.message : String(error)}`, "request");
|
|
4398
|
+
}
|
|
4399
|
+
}
|
|
4400
|
+
httpError(operation, status) {
|
|
4401
|
+
return new ReportingRequestError(`${operation} returned HTTP ${status}`, status === 401 || status === 403 ? "auth" : "request");
|
|
4402
|
+
}
|
|
4403
|
+
};
|
|
4404
|
+
function errorMessage(error) {
|
|
4405
|
+
return error instanceof Error ? error.message : String(error);
|
|
4406
|
+
}
|
|
4407
|
+
function validatedBaseUrl(value) {
|
|
4408
|
+
const url = new URL(value);
|
|
4409
|
+
const loopback = [
|
|
4410
|
+
"localhost",
|
|
4411
|
+
"127.0.0.1",
|
|
4412
|
+
"[::1]"
|
|
4413
|
+
].includes(url.hostname);
|
|
4414
|
+
if (url.username || url.password || url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) throw new Error("ChatCode reporting requires HTTPS; HTTP is allowed only on loopback.");
|
|
4415
|
+
url.pathname = `${url.pathname.replace(/\/+$/u, "")}/`;
|
|
4416
|
+
return url;
|
|
4417
|
+
}
|
|
4418
|
+
async function jsonBody(response, operation) {
|
|
4419
|
+
try {
|
|
4420
|
+
const body = await response.json();
|
|
4421
|
+
if (isRecord(body)) return body;
|
|
4422
|
+
} catch {}
|
|
4423
|
+
throw new ReportingRequestError(`${operation} returned invalid JSON`, "response");
|
|
4424
|
+
}
|
|
4425
|
+
function responseId(body) {
|
|
4426
|
+
const data = typeof body.data === "string" ? body.data.trim() : "";
|
|
4427
|
+
if (data) return data;
|
|
4428
|
+
const message = typeof body.msg === "string" ? body.msg.trim() : "";
|
|
4429
|
+
return message && message !== "操作成功" && message.toLowerCase() !== "success" ? message : "";
|
|
4430
|
+
}
|
|
4431
|
+
function businessKind(code) {
|
|
4432
|
+
const numeric = Number(code);
|
|
4433
|
+
return numeric === 401 || numeric === 403 ? "auth" : "request";
|
|
4434
|
+
}
|
|
4435
|
+
function isRecord(value) {
|
|
4436
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
4437
|
+
}
|
|
4438
|
+
//#endregion
|
|
4439
|
+
//#region src/reporting/index.ts
|
|
4440
|
+
/** Register the reporter for all Sessions visible to this Cordis scope. */
|
|
4441
|
+
function installChatCodeReporting(ctx, auth, config, resolveModelReport) {
|
|
4442
|
+
const lifetime = new AbortController();
|
|
4443
|
+
const transport = new ChatCodeTransport(auth, config.cvpChatCodeApiUrl, config.requestTimeoutMs, lifetime.signal, fetch, ctx.logger);
|
|
4444
|
+
if (config.codeOutboxDir !== "" && !isAbsolute(config.codeOutboxDir)) throw new Error("ChatCode reporting codeOutboxDir must be an absolute path.");
|
|
4445
|
+
const codeWorker = new CodeOutboxWorker(new CodeOutbox(config.codeOutboxDir || dshHomePath("chatcode-reporting", "code-save-outbox")), transport, config.codeBatchItems, config.codeBatchChars, config.codeRetryDelayMs, ctx.logger);
|
|
4446
|
+
const reporter = new ChatCodeReporter(config, transport, codeWorker, ctx.logger, resolveModelReport);
|
|
4447
|
+
if (config.codeSave) codeWorker.start();
|
|
4448
|
+
ctx.on("session/created", (session) => {
|
|
4449
|
+
reporter.created(session);
|
|
4450
|
+
});
|
|
4451
|
+
ctx.on("agent/created", ({ agent }) => {
|
|
4452
|
+
reporter.seedModelRoute(agent.session, agent.options.provider, agent.options.model);
|
|
4453
|
+
});
|
|
4454
|
+
ctx.on("session/event", (session, event) => {
|
|
4455
|
+
reporter.observe(session, event);
|
|
4456
|
+
});
|
|
4457
|
+
ctx.on("session/disposed", (session) => reporter.disposed(session));
|
|
4458
|
+
ctx.effect(() => async () => {
|
|
4459
|
+
try {
|
|
4460
|
+
await reporter.shutdown();
|
|
4461
|
+
} finally {
|
|
4462
|
+
lifetime.abort();
|
|
4463
|
+
}
|
|
4464
|
+
}, "chatcode-reporting: drain queued reports");
|
|
4465
|
+
}
|
|
4466
|
+
//#endregion
|
|
4467
|
+
//#region src/source.ts
|
|
4468
|
+
/** Resolve custom-model settings and parse the legacy ChatCode JSON file. @module dsh-llm-chatcode-config/source */
|
|
4469
|
+
const positiveInteger = z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER);
|
|
4470
|
+
const documentSchema = z.object({ customModels: z.array(z.object({
|
|
4471
|
+
model: z.string().required(),
|
|
4472
|
+
apiKey: z.string().required(),
|
|
4473
|
+
baseUrl: z.string().required(),
|
|
4474
|
+
description: z.string(),
|
|
4475
|
+
provider: z.union([z.string(), z.const(null)]),
|
|
4476
|
+
protocol: z.string(),
|
|
4477
|
+
contextWindow: positiveInteger,
|
|
4478
|
+
maxTokens: positiveInteger,
|
|
4479
|
+
maxInputTokens: positiveInteger
|
|
4480
|
+
})).required() });
|
|
4481
|
+
/**
|
|
4482
|
+
* Resolve the actual provider model and service URL behind a public selection.
|
|
4483
|
+
* @param source - Current custom or managed model snapshot.
|
|
4484
|
+
* @param selection - Public model id selected through the aggregate provider.
|
|
4485
|
+
* @returns Reporting fields, or `undefined` when the selection is absent.
|
|
4486
|
+
*/
|
|
4487
|
+
function reportModelFromSource(source, selection) {
|
|
4488
|
+
const target = source.selections.get(selection);
|
|
4489
|
+
if (target === void 0) return void 0;
|
|
4490
|
+
return {
|
|
4491
|
+
modelName: target.model,
|
|
4492
|
+
baseUrl: source.profiles.get(target.route)?.baseURL ?? ""
|
|
4493
|
+
};
|
|
4494
|
+
}
|
|
4495
|
+
/** Report only a field location: JSON/schema diagnostics may quote a credential. */
|
|
4496
|
+
function invalid(location) {
|
|
4497
|
+
throw new LlmError(`chatcode-config: invalid ${location}`, "INVALID_CHATCODE_CONFIG");
|
|
4498
|
+
}
|
|
4499
|
+
/** Normalize the two legacy wire dialects, retaining the provider=anthropic fallback. */
|
|
4500
|
+
function protocolOf(entry, location) {
|
|
4501
|
+
switch (entry.protocol?.trim().toLowerCase()) {
|
|
4502
|
+
case void 0:
|
|
4503
|
+
case "": return entry.provider?.trim().toLowerCase() === "anthropic" ? "anthropic-messages" : "openai-completions";
|
|
4504
|
+
case "openai": return "openai-completions";
|
|
4505
|
+
case "anthropic": return "anthropic-messages";
|
|
4506
|
+
default: return invalid(`${location}.protocol (expected OpenAI or Anthropic)`);
|
|
4507
|
+
}
|
|
4508
|
+
}
|
|
4509
|
+
/** Validate a complete base URL without discarding the gateway's path prefix. */
|
|
4510
|
+
function endpointOf(raw, location) {
|
|
4511
|
+
let url;
|
|
4512
|
+
try {
|
|
4513
|
+
url = new URL(raw);
|
|
4514
|
+
} catch {
|
|
4515
|
+
return invalid(`${location}.baseUrl`);
|
|
4516
|
+
}
|
|
4517
|
+
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.search || url.hash) return invalid(`${location}.baseUrl (expected HTTP(S) without credentials, query, or fragment)`);
|
|
4518
|
+
return url.href.replace(/\/+$/, "");
|
|
4519
|
+
}
|
|
4520
|
+
/**
|
|
4521
|
+
* Validate customModels and resolve one detached activation snapshot.
|
|
4522
|
+
* @param data - parsed JSON from the external file, never a typed plugin configuration.
|
|
4523
|
+
* @param config - validated mount defaults.
|
|
4524
|
+
* @returns profiles and private credentials for exactly the declared model entries.
|
|
4525
|
+
*/
|
|
4526
|
+
function resolveSource(data, config) {
|
|
4527
|
+
let entries;
|
|
4528
|
+
try {
|
|
4529
|
+
entries = documentSchema(data).customModels;
|
|
4530
|
+
} catch {
|
|
4531
|
+
return invalid("customModels (expected an array of models with model, baseUrl, and apiKey)");
|
|
4532
|
+
}
|
|
4533
|
+
const profiles = {};
|
|
4534
|
+
const auth = /* @__PURE__ */ new Map();
|
|
4535
|
+
const apiKeys = /* @__PURE__ */ new Map();
|
|
4536
|
+
const selections = /* @__PURE__ */ new Map();
|
|
4537
|
+
const modelIds = /* @__PURE__ */ new Set();
|
|
4538
|
+
for (const [index, entry] of entries.entries()) {
|
|
4539
|
+
const location = `customModels[${index}]`;
|
|
4540
|
+
if (entry.model.trim() === "") invalid(`${location}.model`);
|
|
4541
|
+
if (modelIds.has(entry.model)) invalid(`${location}.model (duplicate model id in unified catalog)`);
|
|
4542
|
+
modelIds.add(entry.model);
|
|
4543
|
+
const api = protocolOf(entry, location);
|
|
4544
|
+
const baseURL = endpointOf(entry.baseUrl, location);
|
|
4545
|
+
const provider = `chatcode-${createHash("sha256").update(JSON.stringify([
|
|
4546
|
+
api,
|
|
4547
|
+
baseURL,
|
|
4548
|
+
entry.model
|
|
4549
|
+
])).digest("hex").slice(0, 20)}`;
|
|
4550
|
+
const apiKey = assertUsableApiKey(entry.apiKey, "chatcode-config", `${location}.apiKey`);
|
|
4551
|
+
profiles[provider] = {
|
|
4552
|
+
displayName: entry.description?.trim() || entry.model,
|
|
4553
|
+
api,
|
|
4554
|
+
baseURL,
|
|
4555
|
+
models: [{
|
|
4556
|
+
id: entry.model,
|
|
4557
|
+
name: entry.description?.trim() || entry.model,
|
|
4558
|
+
contextWindow: entry.contextWindow ?? entry.maxTokens ?? entry.maxInputTokens ?? config.defaultContextWindow,
|
|
4559
|
+
maxTokens: entry.maxTokens ?? config.defaultMaxTokens
|
|
4560
|
+
}],
|
|
4561
|
+
...api === "openai-completions" ? { compat: {
|
|
4562
|
+
maxTokensField: "max_tokens",
|
|
4563
|
+
supportsDeveloperRole: false
|
|
4564
|
+
} } : {},
|
|
4565
|
+
...api === "openai-completions" && /^minimax-m2(?:[.-]|$)/i.test(entry.model) ? { reasoningSplit: true } : {},
|
|
4566
|
+
...config.retryPolicy === void 0 ? {} : { retryPolicy: config.retryPolicy }
|
|
4567
|
+
};
|
|
4568
|
+
auth.set(provider, api === "anthropic-messages" ? { headers: { Authorization: `Bearer ${apiKey}` } } : { apiKey });
|
|
4569
|
+
apiKeys.set(provider, apiKey);
|
|
4570
|
+
selections.set(entry.model, {
|
|
4571
|
+
route: provider,
|
|
4572
|
+
model: entry.model
|
|
4573
|
+
});
|
|
4574
|
+
}
|
|
4575
|
+
return {
|
|
4576
|
+
profiles: resolveProfiles(profiles),
|
|
4577
|
+
auth,
|
|
4578
|
+
apiKeys,
|
|
4579
|
+
selections,
|
|
4580
|
+
entries: entries.map((entry) => ({ ...entry }))
|
|
4581
|
+
};
|
|
4582
|
+
}
|
|
4583
|
+
/**
|
|
4584
|
+
* Resolve the user-defined models in one validated settings snapshot.
|
|
4585
|
+
* @param config - Current resolved plugin settings.
|
|
4586
|
+
* @returns One immutable model and authentication snapshot.
|
|
4587
|
+
*/
|
|
4588
|
+
function resolveConfiguredSource(config) {
|
|
4589
|
+
return resolveSource({ customModels: config.customModels }, config);
|
|
4590
|
+
}
|
|
4591
|
+
/**
|
|
4592
|
+
* Read the configured Host file once; never use the agent's execution filesystem.
|
|
4593
|
+
* @param config - validated mount defaults and optional Host path.
|
|
4594
|
+
* @returns the complete validated snapshot; malformed JSON never appears in diagnostics.
|
|
4595
|
+
*/
|
|
4596
|
+
async function readSource(config) {
|
|
4597
|
+
const filename = resolve(config.settingsPath ?? join(homedir(), ".chatcode-cli", "settings.json"));
|
|
4598
|
+
let text;
|
|
4599
|
+
try {
|
|
4600
|
+
text = await readFile(filename, "utf8");
|
|
4601
|
+
} catch {
|
|
4602
|
+
throw new LlmError("chatcode-config: cannot read settingsPath", "CHATCODE_CONFIG_READ_FAILED");
|
|
4603
|
+
}
|
|
4604
|
+
let data;
|
|
4605
|
+
try {
|
|
4606
|
+
data = JSON.parse(text);
|
|
4607
|
+
} catch {
|
|
4608
|
+
throw new LlmError("chatcode-config: settingsPath is not valid JSON", "INVALID_CHATCODE_CONFIG");
|
|
4609
|
+
}
|
|
4610
|
+
return resolveSource(data, config);
|
|
4611
|
+
}
|
|
4612
|
+
//#endregion
|
|
4613
|
+
//#region src/index.ts
|
|
4614
|
+
/** Publish ChatCode-authenticated CodingPlan and MAAS catalogs into the LLM registry. @module dsh-llm-chatcode-config */
|
|
4615
|
+
const name = "llm-chatcode-config";
|
|
4616
|
+
const inject = ["llm"];
|
|
4617
|
+
const SETTINGS_NAMESPACE = "llm-chatcode-config";
|
|
4618
|
+
/**
|
|
4619
|
+
* Keeps catalog I/O in the host bundle: clients ask for a registry refresh but
|
|
4620
|
+
* never receive endpoint or credential-bearing runtime configuration.
|
|
4621
|
+
*/
|
|
4622
|
+
var ChatCodeModelCatalogService = class extends Service {
|
|
4623
|
+
reload;
|
|
4624
|
+
readMaas;
|
|
4625
|
+
writeMaas;
|
|
4626
|
+
constructor(ctx, reload, readMaas) {
|
|
4627
|
+
super(ctx, "chatcodeModelCatalog");
|
|
4628
|
+
this.reload = reload;
|
|
4629
|
+
this.readMaas = readMaas;
|
|
4630
|
+
}
|
|
4631
|
+
refresh() {
|
|
4632
|
+
return this.reload();
|
|
4633
|
+
}
|
|
4634
|
+
maasEnabled() {
|
|
4635
|
+
return this.readMaas();
|
|
4636
|
+
}
|
|
4637
|
+
async setMaasEnabled(enabled) {
|
|
4638
|
+
if (this.writeMaas === void 0) throw new Error("ChatCode MAAS settings are not ready; retry after the Host finishes starting.");
|
|
4639
|
+
await this.writeMaas(enabled);
|
|
4640
|
+
await this.reload();
|
|
4641
|
+
}
|
|
4642
|
+
/** Bind the durable settings writer once the optional settings service mounts. */
|
|
4643
|
+
bindMaasSettings(write) {
|
|
4644
|
+
this.writeMaas = write;
|
|
4645
|
+
}
|
|
4646
|
+
};
|
|
4647
|
+
/** Register ChatCode model sources, account access, and operations reporting. */
|
|
4648
|
+
async function apply(ctx, config) {
|
|
4649
|
+
const environmentAccessToken = chatCodeEnvironmentToken(ctx);
|
|
4650
|
+
const catalogAuthorization = environmentAccessToken === void 0 ? void 0 : { accessToken: environmentAccessToken };
|
|
4651
|
+
if ((ctx.get("cmdlineArgs")?.get() ?? []).includes("--update")) {
|
|
4652
|
+
const result = runUpdateSync();
|
|
4653
|
+
process.stdout.write(`${result.message}\n`);
|
|
4654
|
+
process.exit(result.status === "error" ? 1 : 0);
|
|
4655
|
+
}
|
|
4656
|
+
new ChatCodeStartupGateService(ctx, config);
|
|
4657
|
+
if (ctx.get("cmdlineArgs") !== void 0) {
|
|
4658
|
+
const decision = await checkVersions(config);
|
|
4659
|
+
if (decision !== void 0) {
|
|
4660
|
+
if (await promptVersionAction(decisionPrompt(decision)) !== "perform") {
|
|
4661
|
+
ctx.get("appExit")?.(0);
|
|
4662
|
+
return;
|
|
4663
|
+
}
|
|
4664
|
+
const verb = decision.action === "upgrade" ? "升级" : "更换";
|
|
4665
|
+
process.stdout.write(`正在${verb}中...\n`);
|
|
4666
|
+
const result = await runDecisionInstall(decision);
|
|
4667
|
+
process.stdout.write(`${result.message}\n`);
|
|
4668
|
+
ctx.get("appExit")?.(result.ok ? 0 : 1);
|
|
4669
|
+
return;
|
|
4670
|
+
}
|
|
4671
|
+
}
|
|
4672
|
+
ctx.inject(["commands"], (commandsCtx) => {
|
|
4673
|
+
commandsCtx.commands.register({
|
|
4674
|
+
name: "update",
|
|
4675
|
+
description: "更新 ChatCode CLI 到最新版本",
|
|
4676
|
+
handler: async (invocation) => {
|
|
4677
|
+
const result = await runUpdate({
|
|
4678
|
+
signal: invocation.signal,
|
|
4679
|
+
onProgress: (line) => {
|
|
4680
|
+
ctx.emit("llm-chatcode-config/update-progress", line);
|
|
4681
|
+
}
|
|
4682
|
+
});
|
|
4683
|
+
return result.status === "error" ? {
|
|
4684
|
+
kind: "error",
|
|
4685
|
+
text: result.message
|
|
4686
|
+
} : {
|
|
4687
|
+
kind: "success",
|
|
4688
|
+
text: result.message
|
|
4689
|
+
};
|
|
4690
|
+
}
|
|
4691
|
+
});
|
|
4692
|
+
});
|
|
4693
|
+
const register = (provider, providerName, source) => {
|
|
4694
|
+
if (source.profiles.size === 0) return void 0;
|
|
4695
|
+
const adapter = new ChatCodeAdapter({
|
|
4696
|
+
profiles: () => source.profiles,
|
|
4697
|
+
resolveAuth: (route) => Promise.resolve(source.auth.get(route)),
|
|
4698
|
+
auth: isolatedPiAiAuth()
|
|
4699
|
+
}, source.profiles, provider, providerName, source.selections, source.apiKeys);
|
|
4700
|
+
return ctx.llm.registerAdapter([provider], adapter);
|
|
4701
|
+
};
|
|
4702
|
+
let current = () => config;
|
|
4703
|
+
let lastCustomConfig;
|
|
4704
|
+
let customSourceSnapshot;
|
|
4705
|
+
const customSource = () => {
|
|
4706
|
+
const resolved = current();
|
|
4707
|
+
if (resolved === lastCustomConfig && customSourceSnapshot !== void 0) return customSourceSnapshot;
|
|
4708
|
+
const source = resolveConfiguredSource(resolved);
|
|
4709
|
+
lastCustomConfig = resolved;
|
|
4710
|
+
customSourceSnapshot = source;
|
|
4711
|
+
return source;
|
|
4712
|
+
};
|
|
4713
|
+
const customAdapter = new LiveChatCodeAdapter(customSource);
|
|
4714
|
+
let customRegistration;
|
|
4715
|
+
const refreshCustom = () => {
|
|
4716
|
+
const routes = customSource().profiles.size === 0 ? [] : [CHATCODE_PROVIDER];
|
|
4717
|
+
if (customRegistration === void 0) {
|
|
4718
|
+
if (routes.length === 0) return;
|
|
4719
|
+
customRegistration = ctx.llm.registerAdapter(routes, customAdapter);
|
|
4720
|
+
return;
|
|
4721
|
+
}
|
|
4722
|
+
customRegistration.replace(routes);
|
|
4723
|
+
};
|
|
4724
|
+
const managedRegistrations = /* @__PURE__ */ new Map();
|
|
4725
|
+
const managedSources = /* @__PURE__ */ new Map();
|
|
4726
|
+
const resolveReportingModel = (provider, model) => {
|
|
4727
|
+
const source = provider === "chatcode-custom" ? customSource() : managedSources.get(provider);
|
|
4728
|
+
return source === void 0 ? {
|
|
4729
|
+
modelName: model,
|
|
4730
|
+
baseUrl: ""
|
|
4731
|
+
} : reportModelFromSource(source, model) ?? {
|
|
4732
|
+
modelName: model,
|
|
4733
|
+
baseUrl: ""
|
|
4734
|
+
};
|
|
4735
|
+
};
|
|
4736
|
+
ctx.inject(["credentials"], (authCtx) => {
|
|
4737
|
+
const auth = new ChatCodeAuthService(authCtx, authCtx.get("credentials"), config.auth, environmentAccessToken);
|
|
4738
|
+
if (config.reporting.enabled) authCtx.inject(["sessions"], (reportingCtx) => {
|
|
4739
|
+
installChatCodeReporting(reportingCtx, auth, {
|
|
4740
|
+
...config.reporting,
|
|
4741
|
+
cvpChatCodeApiUrl: config.cvpChatCodeApiUrl,
|
|
4742
|
+
requestTimeoutMs: config.auth.requestTimeoutMs
|
|
4743
|
+
}, resolveReportingModel);
|
|
4744
|
+
});
|
|
4745
|
+
});
|
|
4746
|
+
let refreshQueued = false;
|
|
4747
|
+
let refreshInFlight;
|
|
4748
|
+
const clearManaged = () => {
|
|
4749
|
+
for (const registration of managedRegistrations.values()) registration();
|
|
4750
|
+
managedRegistrations.clear();
|
|
4751
|
+
managedSources.clear();
|
|
4752
|
+
};
|
|
4753
|
+
const refreshOnce = async () => {
|
|
4754
|
+
clearManaged();
|
|
4755
|
+
const resolved = current();
|
|
4756
|
+
const catalogs = [{
|
|
4757
|
+
provider: CODING_PLAN_PROVIDER,
|
|
4758
|
+
name: CODING_PLAN_PROVIDER_NAME,
|
|
4759
|
+
endpoint: resolved.codingPlanEndpoint,
|
|
4760
|
+
group: "codingplan"
|
|
4761
|
+
}];
|
|
4762
|
+
if (resolved.enableMaas) {
|
|
4763
|
+
if (resolved.maasEndpoint.trim() === "") ctx.logger.warn("chatcode-config: MAAS is enabled but no MAAS catalog endpoint is configured");
|
|
4764
|
+
else catalogs.push({
|
|
4765
|
+
provider: MAAS_PROVIDER,
|
|
4766
|
+
name: MAAS_PROVIDER_NAME,
|
|
4767
|
+
endpoint: resolved.maasEndpoint,
|
|
4768
|
+
group: "maas"
|
|
4769
|
+
});
|
|
4770
|
+
}
|
|
4771
|
+
const loaded = await Promise.all(catalogs.map(async (catalog) => {
|
|
4772
|
+
try {
|
|
4773
|
+
const models = catalog.group === "maas" ? await fetchMaasRuntimeModels(catalog.endpoint, resolved.catalogTimeoutMs, catalogAuthorization) : await fetchRuntimeModels(catalog.endpoint, resolved.catalogTimeoutMs, catalogAuthorization, (message) => ctx.logger.info(message));
|
|
4774
|
+
const source = resolveManagedSource(models, catalog.group, resolved);
|
|
4775
|
+
if (catalog.group === "codingplan") ctx.logger.info(`chatcode-config: CodingPlan registered ${String(source.selections.size)} runnable models from ${String(models.length)} parsed records`);
|
|
4776
|
+
return {
|
|
4777
|
+
catalog,
|
|
4778
|
+
source
|
|
4779
|
+
};
|
|
4780
|
+
} catch (error) {
|
|
4781
|
+
if (error instanceof LlmError) {
|
|
4782
|
+
ctx.logger.warn(`chatcode-config: ${catalog.name} models were not registered (${error.code})`);
|
|
4783
|
+
return;
|
|
4784
|
+
}
|
|
4785
|
+
throw error;
|
|
4786
|
+
}
|
|
4787
|
+
}));
|
|
4788
|
+
for (const entry of loaded) {
|
|
4789
|
+
if (entry === void 0) continue;
|
|
4790
|
+
const registration = register(entry.catalog.provider, entry.catalog.name, entry.source);
|
|
4791
|
+
if (registration !== void 0) {
|
|
4792
|
+
managedRegistrations.set(entry.catalog.provider, registration);
|
|
4793
|
+
managedSources.set(entry.catalog.provider, entry.source);
|
|
4794
|
+
}
|
|
4795
|
+
}
|
|
4796
|
+
};
|
|
4797
|
+
const refreshManaged = () => {
|
|
4798
|
+
if (refreshInFlight !== void 0) return refreshInFlight;
|
|
4799
|
+
const tracked = (async () => {
|
|
4800
|
+
do {
|
|
4801
|
+
refreshQueued = false;
|
|
4802
|
+
await refreshOnce();
|
|
4803
|
+
} while (refreshQueued);
|
|
4804
|
+
})().finally(() => {
|
|
4805
|
+
if (refreshInFlight === tracked) refreshInFlight = void 0;
|
|
4806
|
+
});
|
|
4807
|
+
refreshInFlight = tracked;
|
|
4808
|
+
return tracked;
|
|
4809
|
+
};
|
|
4810
|
+
const refreshForSettingsChange = () => {
|
|
4811
|
+
refreshCustom();
|
|
4812
|
+
if (refreshInFlight !== void 0) {
|
|
4813
|
+
refreshQueued = true;
|
|
4814
|
+
return;
|
|
4815
|
+
}
|
|
4816
|
+
refreshManaged();
|
|
4817
|
+
};
|
|
4818
|
+
const modelCatalogService = new ChatCodeModelCatalogService(ctx, refreshManaged, () => current().enableMaas);
|
|
4819
|
+
ctx.logger.info(`chatcode-config: waiting for settings service to register namespace "${SETTINGS_NAMESPACE}"`);
|
|
4820
|
+
ctx.inject(["settings"], async (settingsCtx) => {
|
|
4821
|
+
ctx.logger.info(`chatcode-config: settings service available; registering namespace "${SETTINGS_NAMESPACE}"`);
|
|
4822
|
+
try {
|
|
4823
|
+
settingsCtx.settings.installSection(ctx, SETTINGS_NAMESPACE, Config, config, {
|
|
4824
|
+
setSource: (source) => {
|
|
4825
|
+
current = source;
|
|
4826
|
+
},
|
|
4827
|
+
onChange: refreshForSettingsChange,
|
|
4828
|
+
validate: (value) => {
|
|
4829
|
+
resolveConfiguredSource(value);
|
|
4830
|
+
}
|
|
4831
|
+
});
|
|
4832
|
+
} catch (error) {
|
|
4833
|
+
ctx.logger.warn(`chatcode-config: failed to register settings namespace "${SETTINGS_NAMESPACE}" (${error instanceof Error ? error.message : String(error)})`);
|
|
4834
|
+
throw error;
|
|
4835
|
+
}
|
|
4836
|
+
modelCatalogService.bindMaasSettings(async (enabled) => {
|
|
4837
|
+
ctx.logger.info(`chatcode-config: persisting MAAS setting enableMaas=${String(enabled)}`);
|
|
4838
|
+
const ops = [{
|
|
4839
|
+
op: "set",
|
|
4840
|
+
path: ["enableMaas"],
|
|
4841
|
+
value: enabled
|
|
4842
|
+
}];
|
|
4843
|
+
const revision = () => settingsCtx.settings.describe().find((entry) => entry.ns === SETTINGS_NAMESPACE)?.revision;
|
|
4844
|
+
try {
|
|
4845
|
+
await settingsCtx.settings.mutate(SETTINGS_NAMESPACE, ops, revision());
|
|
4846
|
+
} catch (error) {
|
|
4847
|
+
if (error?.code !== "SETTINGS_CONFLICT") throw error;
|
|
4848
|
+
await settingsCtx.settings.mutate(SETTINGS_NAMESPACE, ops, revision());
|
|
4849
|
+
}
|
|
4850
|
+
ctx.logger.info(`chatcode-config: persisted MAAS setting enableMaas=${String(enabled)}`);
|
|
4851
|
+
});
|
|
4852
|
+
const descriptor = settingsCtx.settings.describe().find((entry) => entry.ns === SETTINGS_NAMESPACE);
|
|
4853
|
+
ctx.logger.info(`chatcode-config: settings namespace "${SETTINGS_NAMESPACE}" registered=${String(descriptor !== void 0)} revision=${String(descriptor?.revision ?? "missing")} enableMaas=${String(current().enableMaas)}`);
|
|
4854
|
+
if (descriptor?.user !== void 0) {
|
|
4855
|
+
ctx.logger.info(`chatcode-config: settings namespace "${SETTINGS_NAMESPACE}" loaded an existing user section`);
|
|
4856
|
+
return;
|
|
4857
|
+
}
|
|
4858
|
+
try {
|
|
4859
|
+
const imported = await readSource(config);
|
|
4860
|
+
await settingsCtx.settings.replace(SETTINGS_NAMESPACE, { customModels: imported.entries ?? [] });
|
|
4861
|
+
ctx.logger.info(`chatcode-config: settings namespace "${SETTINGS_NAMESPACE}" initialized from the legacy source`);
|
|
4862
|
+
} catch (error) {
|
|
4863
|
+
if (!(error instanceof LlmError)) throw error;
|
|
4864
|
+
ctx.logger.warn("chatcode-config: legacy settingsPath is unavailable or invalid; custom models were not imported and import will retry when the Host starts again");
|
|
4865
|
+
}
|
|
4866
|
+
});
|
|
4867
|
+
refreshCustom();
|
|
4868
|
+
await refreshManaged();
|
|
4869
|
+
}
|
|
4870
|
+
//#endregion
|
|
4871
|
+
export { ChatCodeAuthService, ChatCodeModelCatalogService, ChatCodeStartupGateService, Config, SETTINGS_NAMESPACE, apply, checkChatCodeStartupGate, inject, name, startupGateFailureMessage };
|
|
4872
|
+
|
|
4873
|
+
//# sourceMappingURL=index.js.map
|