@kevin5251984/guild 0.2.18 → 0.2.20
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/package.json +2 -2
- package/src/catalog/subagents.ts +2 -1
- package/src/chat-parts.ts +6 -1
- package/src/cli.ts +2 -5
- package/src/compact.ts +27 -0
- package/src/db.ts +236 -68
- package/src/generate.ts +59 -28
- package/src/handlers.ts +355 -84
- package/src/harness.ts +39 -10
- package/src/host-browse.ts +149 -4
- package/src/image-gen.ts +2 -1
- package/src/llm.ts +435 -71
- package/src/mcp.ts +32 -5
- package/src/memory.ts +69 -0
- package/src/mention.ts +189 -14
- package/src/oauth.ts +204 -27
- package/src/opencode-free.ts +248 -0
- package/src/public/buddy.js +432 -0
- package/src/public/chat.css +174 -72
- package/src/public/chat.html +354 -69
- package/src/public/i18n.js +44 -6
- package/src/public/index.html +1 -46
- package/src/public/mobile.css +597 -0
- package/src/public/mobile.html +954 -0
- package/src/public/settings.html +239 -35
- package/src/public/skills-add.html +8 -0
- package/src/public/studio.html +171 -117
- package/src/public/style.css +220 -26
- package/src/public/subagents-add.html +8 -0
- package/src/reasoning-catalog.ts +346 -0
- package/src/router.ts +184 -13
- package/src/store.ts +170 -5
- package/src/subagent.ts +239 -7
- package/src/tools.ts +132 -21
- package/src/trajectory.ts +72 -9
- package/src/usage.ts +10 -0
- package/src/version.ts +25 -0
- package/vendor/protocol/src/index.ts +27 -2
package/src/llm.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import type {
|
|
4
4
|
AuxRole,
|
|
@@ -15,12 +15,16 @@ import {
|
|
|
15
15
|
formatOAuthError,
|
|
16
16
|
listSubscriptions,
|
|
17
17
|
OAUTH_PICKER_IDS,
|
|
18
|
+
STREAM_IDLE_TIMEOUT_MS,
|
|
18
19
|
storedAccessToken,
|
|
19
20
|
subscriptionByPicker,
|
|
21
|
+
withTransientRetries,
|
|
20
22
|
} from "./oauth.ts";
|
|
21
23
|
import {
|
|
24
|
+
emitProgress,
|
|
22
25
|
openaiTools,
|
|
23
26
|
roundSignal,
|
|
27
|
+
throwIfAborted,
|
|
24
28
|
TOOL_LOOP_WRAP,
|
|
25
29
|
type SkillRef,
|
|
26
30
|
type ToolContext,
|
|
@@ -35,24 +39,49 @@ import {
|
|
|
35
39
|
fromOpenAiUsage,
|
|
36
40
|
withDuration,
|
|
37
41
|
} from "./usage.ts";
|
|
42
|
+
import {
|
|
43
|
+
attachReasoning,
|
|
44
|
+
clampEffort,
|
|
45
|
+
reasoningFor,
|
|
46
|
+
reasoningPayload,
|
|
47
|
+
refreshReasoningCatalog,
|
|
48
|
+
sanitizeEffort,
|
|
49
|
+
} from "./reasoning-catalog.ts";
|
|
50
|
+
|
|
51
|
+
export { refreshReasoningCatalog };
|
|
52
|
+
import {
|
|
53
|
+
OPENCODE_FREE_DEFAULT_MODEL,
|
|
54
|
+
OPENCODE_FREE_PROVIDER_ID,
|
|
55
|
+
fetchOpenCodeFreeModels,
|
|
56
|
+
isKeylessProvider,
|
|
57
|
+
llmRequestHeaders,
|
|
58
|
+
openCodeFreeModels,
|
|
59
|
+
openCodeFreeProvider,
|
|
60
|
+
probeOpenCodeFreeModels,
|
|
61
|
+
selectOpenCodeFreeIds,
|
|
62
|
+
usesZenResponses,
|
|
63
|
+
type OpenCodeFreeProbe,
|
|
64
|
+
} from "./opencode-free.ts";
|
|
38
65
|
|
|
39
66
|
export const AUX_ROLES: { id: AuxRole; name: string; hint: string }[] = [
|
|
40
67
|
{ id: "vision", name: "Vision", hint: "Image analysis" },
|
|
41
68
|
{ id: "web", name: "Web extract", hint: "Page summarization" },
|
|
42
|
-
{ id: "
|
|
43
|
-
{ id: "skills", name: "Skills hub", hint: "Skill search" },
|
|
44
|
-
{ id: "approval", name: "Approval", hint: "Command risk scoring" },
|
|
45
|
-
{ id: "title", name: "Title", hint: "Session titles" },
|
|
46
|
-
{ id: "generate", name: "Generate", hint: "Soul / Agent / Skill markdown" },
|
|
69
|
+
{ id: "spawn", name: "SubAgent", hint: "explorer / worker / reviewer" },
|
|
47
70
|
];
|
|
48
71
|
|
|
72
|
+
const CONFIGURABLE_AUX = new Set(AUX_ROLES.map((role) => role.id));
|
|
73
|
+
|
|
49
74
|
export const DEFAULT_MODELS: ModelsFile = {
|
|
50
|
-
default:
|
|
75
|
+
default: {
|
|
76
|
+
provider: OPENCODE_FREE_PROVIDER_ID,
|
|
77
|
+
model: OPENCODE_FREE_DEFAULT_MODEL,
|
|
78
|
+
},
|
|
51
79
|
reasoning: "medium",
|
|
52
80
|
fast: false,
|
|
53
81
|
aux: {},
|
|
54
82
|
recent: [],
|
|
55
83
|
providers: {
|
|
84
|
+
[OPENCODE_FREE_PROVIDER_ID]: openCodeFreeProvider(),
|
|
56
85
|
openai: {
|
|
57
86
|
name: "OpenAI",
|
|
58
87
|
baseUrl: "https://api.openai.com/v1",
|
|
@@ -119,15 +148,48 @@ export function readModelsFile(dataDir: string): ModelsFile {
|
|
|
119
148
|
if (!parsed || typeof parsed !== "object" || !parsed.providers) {
|
|
120
149
|
return structuredClone(DEFAULT_MODELS);
|
|
121
150
|
}
|
|
122
|
-
return parsed;
|
|
151
|
+
if (Object.keys(parsed.providers).length === 0) return parsed;
|
|
152
|
+
return withOpenCodeFree(parsed);
|
|
123
153
|
} catch {
|
|
124
154
|
return structuredClone(DEFAULT_MODELS);
|
|
125
155
|
}
|
|
126
156
|
}
|
|
127
157
|
|
|
158
|
+
function pinOpenCodeFree(file: ModelsFile): ModelsFile {
|
|
159
|
+
const current = file.providers[OPENCODE_FREE_PROVIDER_ID];
|
|
160
|
+
if (!current) return file;
|
|
161
|
+
const keys = Object.keys(file.providers);
|
|
162
|
+
if (keys[0] === OPENCODE_FREE_PROVIDER_ID) return file;
|
|
163
|
+
const rest: ModelsFile["providers"] = {};
|
|
164
|
+
for (const [id, provider] of Object.entries(file.providers)) {
|
|
165
|
+
if (id === OPENCODE_FREE_PROVIDER_ID) continue;
|
|
166
|
+
rest[id] = provider;
|
|
167
|
+
}
|
|
168
|
+
return {
|
|
169
|
+
...file,
|
|
170
|
+
providers: {
|
|
171
|
+
[OPENCODE_FREE_PROVIDER_ID]: current,
|
|
172
|
+
...rest,
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function withOpenCodeFree(file: ModelsFile): ModelsFile {
|
|
178
|
+
if (file.providers[OPENCODE_FREE_PROVIDER_ID]) return pinOpenCodeFree(file);
|
|
179
|
+
return pinOpenCodeFree({
|
|
180
|
+
...file,
|
|
181
|
+
providers: {
|
|
182
|
+
[OPENCODE_FREE_PROVIDER_ID]: openCodeFreeProvider(),
|
|
183
|
+
...file.providers,
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
128
188
|
export function writeModelsFile(dataDir: string, file: ModelsFile): ModelsFile {
|
|
129
|
-
const cleaned = sanitizeModels(file);
|
|
130
|
-
|
|
189
|
+
const cleaned = pinOpenCodeFree(sanitizeModels(file));
|
|
190
|
+
const path = modelsPath(dataDir);
|
|
191
|
+
writeFileSync(path, `${JSON.stringify(cleaned, null, 2)}\n`, { mode: 0o600 });
|
|
192
|
+
chmodSync(path, 0o600);
|
|
131
193
|
return cleaned;
|
|
132
194
|
}
|
|
133
195
|
|
|
@@ -162,7 +224,7 @@ export function mergeModelsFile(
|
|
|
162
224
|
providersIn[id] = { ...provider, apiKey };
|
|
163
225
|
}
|
|
164
226
|
next.providers = providersIn;
|
|
165
|
-
return writeModelsFile(dataDir, next);
|
|
227
|
+
return writeModelsFile(dataDir, withOpenCodeFree(next));
|
|
166
228
|
}
|
|
167
229
|
|
|
168
230
|
function pushRecent(list: ModelRef[] | undefined, ref: ModelRef): ModelRef[] {
|
|
@@ -187,8 +249,51 @@ export function maskApiKey(value: string): string {
|
|
|
187
249
|
return key.slice(0, 5) + "…" + key.slice(-5);
|
|
188
250
|
}
|
|
189
251
|
|
|
190
|
-
export function
|
|
252
|
+
export async function refreshOpenCodeFreeCatalog(
|
|
253
|
+
dataDir: string,
|
|
254
|
+
force = false,
|
|
255
|
+
): Promise<{
|
|
256
|
+
models: { id: string; name?: string }[];
|
|
257
|
+
updated: boolean;
|
|
258
|
+
probe?: OpenCodeFreeProbe[];
|
|
259
|
+
}> {
|
|
191
260
|
const file = readModelsFile(dataDir);
|
|
261
|
+
const prev = file.providers[OPENCODE_FREE_PROVIDER_ID] ?? openCodeFreeProvider();
|
|
262
|
+
if (!force) {
|
|
263
|
+
return { models: prev.models, updated: false };
|
|
264
|
+
}
|
|
265
|
+
const live = await fetchOpenCodeFreeModels(4_000, true);
|
|
266
|
+
if (!live?.length) {
|
|
267
|
+
throw new StoreError(502, "couldn't sync OpenCode Free");
|
|
268
|
+
}
|
|
269
|
+
const probe = await probeOpenCodeFreeModels(live);
|
|
270
|
+
const keepId =
|
|
271
|
+
file.default?.provider === OPENCODE_FREE_PROVIDER_ID
|
|
272
|
+
? file.default.model
|
|
273
|
+
: undefined;
|
|
274
|
+
const usable = selectOpenCodeFreeIds(live, probe, keepId);
|
|
275
|
+
if (!usable.length) {
|
|
276
|
+
return { models: prev.models, updated: false, probe };
|
|
277
|
+
}
|
|
278
|
+
const ids = usable;
|
|
279
|
+
const models = openCodeFreeModels(ids);
|
|
280
|
+
const same =
|
|
281
|
+
models.length === prev.models.length &&
|
|
282
|
+
models.every((row, i) => row.id === prev.models[i]?.id);
|
|
283
|
+
if (!same) {
|
|
284
|
+
writeModelsFile(dataDir, {
|
|
285
|
+
...file,
|
|
286
|
+
providers: {
|
|
287
|
+
...file.providers,
|
|
288
|
+
[OPENCODE_FREE_PROVIDER_ID]: { ...prev, models },
|
|
289
|
+
},
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
return { models, updated: !same, probe };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export function publicModels(dataDir: string, env: NodeJS.ProcessEnv = process.env) {
|
|
296
|
+
const file = withOpenCodeFree(readModelsFile(dataDir));
|
|
192
297
|
const providers: PublicProvider[] = Object.entries(file.providers).map(
|
|
193
298
|
([id, provider]) => {
|
|
194
299
|
const key = provider.apiKey ?? "";
|
|
@@ -196,6 +301,7 @@ export function publicModels(dataDir: string, env: NodeJS.ProcessEnv = process.e
|
|
|
196
301
|
return {
|
|
197
302
|
id,
|
|
198
303
|
...provider,
|
|
304
|
+
models: attachReasoning(id, provider.models),
|
|
199
305
|
apiKey: stored === "literal" ? "" : key,
|
|
200
306
|
apiKeyPreview: key ? maskApiKey(key) : "",
|
|
201
307
|
stored,
|
|
@@ -208,21 +314,23 @@ export function publicModels(dataDir: string, env: NodeJS.ProcessEnv = process.e
|
|
|
208
314
|
...providers.map((p) => ({
|
|
209
315
|
id: p.id,
|
|
210
316
|
name: p.name || p.id,
|
|
211
|
-
kind: "key" as
|
|
212
|
-
ready:
|
|
213
|
-
|
|
317
|
+
kind: (isKeylessProvider(p.id) ? "keyless" : "key") as "key" | "keyless",
|
|
318
|
+
ready:
|
|
319
|
+
isKeylessProvider(p.id) ||
|
|
320
|
+
Boolean(resolveApiKey(p.apiKey, env) || p.stored === "literal"),
|
|
321
|
+
models: attachReasoning(p.id, p.models),
|
|
214
322
|
})),
|
|
215
323
|
...subscriptions.map((s) => ({
|
|
216
324
|
id: s.pickerId,
|
|
217
325
|
name: s.name,
|
|
218
326
|
kind: "oauth" as const,
|
|
219
327
|
ready: s.ready,
|
|
220
|
-
models: s.models ?? [],
|
|
328
|
+
models: attachReasoning(s.pickerId, s.models ?? []),
|
|
221
329
|
})),
|
|
222
330
|
];
|
|
223
331
|
return {
|
|
224
332
|
default: file.default ?? null,
|
|
225
|
-
reasoning: file.reasoning ?? "
|
|
333
|
+
reasoning: file.reasoning ?? "",
|
|
226
334
|
fast: Boolean(file.fast),
|
|
227
335
|
aux: file.aux ?? {},
|
|
228
336
|
auxRoles: AUX_ROLES,
|
|
@@ -292,18 +400,21 @@ export function resolveLlm(
|
|
|
292
400
|
const file = readModelsFile(dataDir);
|
|
293
401
|
const ref: ModelRef | null | undefined =
|
|
294
402
|
prefer ??
|
|
295
|
-
(role &&
|
|
403
|
+
(role && CONFIGURABLE_AUX.has(role as AuxRole)
|
|
404
|
+
? file.aux?.[role as AuxRole]
|
|
405
|
+
: file.default);
|
|
296
406
|
const tryProvider = (id: string, modelId?: string): LlmTarget | null => {
|
|
297
407
|
const oauth = oauthTarget(dataDir, id, modelId);
|
|
298
408
|
if (oauth) return oauth;
|
|
299
|
-
const
|
|
409
|
+
const providerId = isKeylessProvider(id) ? OPENCODE_FREE_PROVIDER_ID : id;
|
|
410
|
+
const provider = file.providers[providerId];
|
|
300
411
|
if (!provider) return null;
|
|
301
412
|
const apiKey = resolveApiKey(provider.apiKey, env);
|
|
302
|
-
if (!apiKey) return null;
|
|
413
|
+
if (!apiKey && !isKeylessProvider(providerId)) return null;
|
|
303
414
|
const model = modelId || provider.models[0]?.id || "";
|
|
304
415
|
if (!model) return null;
|
|
305
416
|
return {
|
|
306
|
-
providerId
|
|
417
|
+
providerId,
|
|
307
418
|
model,
|
|
308
419
|
baseUrl: provider.baseUrl.replace(/\/+$/, ""),
|
|
309
420
|
apiKey,
|
|
@@ -383,9 +494,14 @@ export async function llmComplete(input: {
|
|
|
383
494
|
spawnDepth: 0,
|
|
384
495
|
allowWrite: true,
|
|
385
496
|
};
|
|
497
|
+
const file = readModelsFile(input.dataDir);
|
|
498
|
+
const effort = clampEffort(
|
|
499
|
+
file.fast ? "low" : file.reasoning,
|
|
500
|
+
reasoningFor(target.providerId, target.model),
|
|
501
|
+
Boolean(file.fast),
|
|
502
|
+
);
|
|
386
503
|
if (OAUTH_PICKER_IDS.has(target.providerId)) {
|
|
387
504
|
try {
|
|
388
|
-
const file = readModelsFile(input.dataDir);
|
|
389
505
|
return await completeOAuth({
|
|
390
506
|
dataDir: input.dataDir,
|
|
391
507
|
pickerId: target.providerId,
|
|
@@ -393,7 +509,7 @@ export async function llmComplete(input: {
|
|
|
393
509
|
system: input.system,
|
|
394
510
|
messages: input.messages,
|
|
395
511
|
temperature: input.temperature ?? 0.4,
|
|
396
|
-
reasoning:
|
|
512
|
+
reasoning: effort,
|
|
397
513
|
tools: useTools,
|
|
398
514
|
skills: input.skills,
|
|
399
515
|
toolCtx,
|
|
@@ -421,6 +537,7 @@ export async function llmComplete(input: {
|
|
|
421
537
|
input.temperature ?? 0.4,
|
|
422
538
|
useTools,
|
|
423
539
|
toolCtx,
|
|
540
|
+
effort,
|
|
424
541
|
);
|
|
425
542
|
if (!done) return null;
|
|
426
543
|
return {
|
|
@@ -454,7 +571,13 @@ async function dispatchComplete(
|
|
|
454
571
|
temperature: number,
|
|
455
572
|
tools: boolean,
|
|
456
573
|
ctx: ToolContext,
|
|
574
|
+
effort?: string,
|
|
457
575
|
): Promise<DispatchResult | null> {
|
|
576
|
+
if (isKeylessProvider(target.providerId) && usesZenResponses(target.model)) {
|
|
577
|
+
return tools
|
|
578
|
+
? completeZenResponsesTools(target, system, messages, ctx, effort)
|
|
579
|
+
: wrapText(await completeZenResponses(target, system, messages, effort));
|
|
580
|
+
}
|
|
458
581
|
if (target.api === "openai-responses") {
|
|
459
582
|
const text = await completeCodex(target, system, messages);
|
|
460
583
|
return text ? { text, traces: [], thinking: "" } : null;
|
|
@@ -465,8 +588,8 @@ async function dispatchComplete(
|
|
|
465
588
|
: wrapText(await completeAnthropic(target, system, messages));
|
|
466
589
|
}
|
|
467
590
|
return tools
|
|
468
|
-
? completeOpenAiTools(target, system, messages, temperature, ctx)
|
|
469
|
-
: wrapText(await completeOpenAi(target, system, messages, temperature));
|
|
591
|
+
? completeOpenAiTools(target, system, messages, temperature, ctx, effort)
|
|
592
|
+
: wrapText(await completeOpenAi(target, system, messages, temperature, effort));
|
|
470
593
|
}
|
|
471
594
|
|
|
472
595
|
function wrapText(text: string | null): DispatchResult | null {
|
|
@@ -479,6 +602,7 @@ async function completeOpenAiTools(
|
|
|
479
602
|
messages: { role: "user" | "assistant"; content: string }[],
|
|
480
603
|
temperature: number,
|
|
481
604
|
ctx: ToolContext,
|
|
605
|
+
effort?: string,
|
|
482
606
|
): Promise<DispatchResult | null> {
|
|
483
607
|
type ChatMsg = {
|
|
484
608
|
role: string;
|
|
@@ -518,29 +642,49 @@ async function completeOpenAiTools(
|
|
|
518
642
|
if (fitted.length < msgs.length) {
|
|
519
643
|
msgs.splice(0, msgs.length, ...fitted);
|
|
520
644
|
}
|
|
521
|
-
const response = await
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
645
|
+
const response = await withTransientRetries(
|
|
646
|
+
async () => {
|
|
647
|
+
throwIfAborted(ctx);
|
|
648
|
+
const res = await fetch(`${target.baseUrl}/chat/completions`, {
|
|
649
|
+
method: "POST",
|
|
650
|
+
headers: llmRequestHeaders(target),
|
|
651
|
+
body: JSON.stringify({
|
|
652
|
+
model: target.model,
|
|
653
|
+
temperature,
|
|
654
|
+
messages: msgs,
|
|
655
|
+
tools: catalog,
|
|
656
|
+
tool_choice: "auto",
|
|
657
|
+
...reasoningPayload(target.providerId, target.baseUrl, effort),
|
|
658
|
+
}),
|
|
659
|
+
signal: roundSignal(ctx),
|
|
660
|
+
});
|
|
661
|
+
if (res.ok) return res;
|
|
662
|
+
const err = new Error(`HTTP ${res.status}`);
|
|
663
|
+
if (res.status === 429 || res.status >= 500) throw err;
|
|
664
|
+
return res;
|
|
527
665
|
},
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
666
|
+
{
|
|
667
|
+
signal: ctx.signal,
|
|
668
|
+
onRetry: () => {
|
|
669
|
+
emitProgress(ctx, traces, "連線中斷,重試中…");
|
|
670
|
+
},
|
|
671
|
+
},
|
|
672
|
+
);
|
|
673
|
+
if (!response.ok) {
|
|
674
|
+
return {
|
|
675
|
+
calls: [],
|
|
676
|
+
text: `模型請求失敗:HTTP ${response.status}`,
|
|
677
|
+
thinking: "",
|
|
678
|
+
};
|
|
679
|
+
}
|
|
538
680
|
const data = (await response.json()) as {
|
|
539
681
|
choices?: { message?: ChatMsg; finish_reason?: string }[];
|
|
540
682
|
usage?: {
|
|
541
683
|
prompt_tokens?: number;
|
|
542
684
|
completion_tokens?: number;
|
|
543
685
|
total_tokens?: number;
|
|
686
|
+
prompt_tokens_details?: { cached_tokens?: number };
|
|
687
|
+
input_tokens_details?: { cached_tokens?: number };
|
|
544
688
|
};
|
|
545
689
|
};
|
|
546
690
|
const message = data.choices?.[0]?.message;
|
|
@@ -629,26 +773,52 @@ async function completeAnthropicTools(
|
|
|
629
773
|
if (fitted.length < msgs.length) {
|
|
630
774
|
msgs.splice(0, msgs.length, ...fitted);
|
|
631
775
|
}
|
|
632
|
-
const response = await
|
|
633
|
-
|
|
776
|
+
const response = await withTransientRetries(
|
|
777
|
+
async () => {
|
|
778
|
+
throwIfAborted(ctx);
|
|
779
|
+
const res = await fetch(
|
|
780
|
+
`${target.baseUrl.replace(/\/v1$/, "")}/v1/messages`,
|
|
781
|
+
{
|
|
782
|
+
method: "POST",
|
|
783
|
+
headers,
|
|
784
|
+
body: JSON.stringify({
|
|
785
|
+
model: target.model,
|
|
786
|
+
max_tokens: 2048,
|
|
787
|
+
system,
|
|
788
|
+
messages: msgs,
|
|
789
|
+
tools,
|
|
790
|
+
}),
|
|
791
|
+
signal: roundSignal(ctx),
|
|
792
|
+
},
|
|
793
|
+
);
|
|
794
|
+
if (res.ok) return res;
|
|
795
|
+
const err = new Error(`HTTP ${res.status}`);
|
|
796
|
+
if (res.status === 429 || res.status >= 500) throw err;
|
|
797
|
+
return res;
|
|
798
|
+
},
|
|
634
799
|
{
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
max_tokens: 2048,
|
|
640
|
-
system,
|
|
641
|
-
messages: msgs,
|
|
642
|
-
tools,
|
|
643
|
-
}),
|
|
644
|
-
signal: roundSignal(ctx),
|
|
800
|
+
signal: ctx.signal,
|
|
801
|
+
onRetry: () => {
|
|
802
|
+
emitProgress(ctx, traces, "連線中斷,重試中…");
|
|
803
|
+
},
|
|
645
804
|
},
|
|
646
805
|
);
|
|
647
|
-
if (!response.ok)
|
|
806
|
+
if (!response.ok) {
|
|
807
|
+
return {
|
|
808
|
+
calls: [],
|
|
809
|
+
text: `模型請求失敗:HTTP ${response.status}`,
|
|
810
|
+
thinking: "",
|
|
811
|
+
};
|
|
812
|
+
}
|
|
648
813
|
const data = (await response.json()) as {
|
|
649
814
|
stop_reason?: string;
|
|
650
815
|
content?: Part[];
|
|
651
|
-
usage?: {
|
|
816
|
+
usage?: {
|
|
817
|
+
input_tokens?: number;
|
|
818
|
+
output_tokens?: number;
|
|
819
|
+
cache_read_input_tokens?: number;
|
|
820
|
+
cache_creation_input_tokens?: number;
|
|
821
|
+
};
|
|
652
822
|
};
|
|
653
823
|
const parts = data.content ?? [];
|
|
654
824
|
lastParts = parts;
|
|
@@ -718,27 +888,225 @@ function anthropicHeaders(target: LlmTarget): Record<string, string> {
|
|
|
718
888
|
return headers;
|
|
719
889
|
}
|
|
720
890
|
|
|
891
|
+
type ZenInput =
|
|
892
|
+
| { role: "user" | "assistant"; content: string }
|
|
893
|
+
| { type: "function_call"; call_id: string; name: string; arguments: string }
|
|
894
|
+
| { type: "function_call_output"; call_id: string; output: string };
|
|
895
|
+
|
|
896
|
+
function zenResponsesTools(
|
|
897
|
+
catalog: ReturnType<typeof openaiTools>,
|
|
898
|
+
): { type: "function"; name: string; description: string; parameters: unknown }[] {
|
|
899
|
+
return catalog.map((tool) => ({
|
|
900
|
+
type: "function" as const,
|
|
901
|
+
name: tool.function.name,
|
|
902
|
+
description: tool.function.description,
|
|
903
|
+
parameters: tool.function.parameters,
|
|
904
|
+
}));
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
async function postZenResponses(
|
|
908
|
+
target: LlmTarget,
|
|
909
|
+
body: Record<string, unknown>,
|
|
910
|
+
signal: AbortSignal,
|
|
911
|
+
): Promise<Response> {
|
|
912
|
+
return fetch(`${target.baseUrl.replace(/\/+$/, "")}/responses`, {
|
|
913
|
+
method: "POST",
|
|
914
|
+
headers: llmRequestHeaders(target),
|
|
915
|
+
body: JSON.stringify(body),
|
|
916
|
+
signal,
|
|
917
|
+
});
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
async function completeZenResponses(
|
|
921
|
+
target: LlmTarget,
|
|
922
|
+
system: string,
|
|
923
|
+
messages: { role: "user" | "assistant"; content: string }[],
|
|
924
|
+
effort?: string,
|
|
925
|
+
): Promise<string | null> {
|
|
926
|
+
const response = await postZenResponses(
|
|
927
|
+
target,
|
|
928
|
+
{
|
|
929
|
+
model: target.model,
|
|
930
|
+
instructions: system,
|
|
931
|
+
input: messages,
|
|
932
|
+
...reasoningPayload(target.providerId, target.baseUrl, effort),
|
|
933
|
+
},
|
|
934
|
+
AbortSignal.timeout(STREAM_IDLE_TIMEOUT_MS),
|
|
935
|
+
);
|
|
936
|
+
if (!response.ok) return null;
|
|
937
|
+
const data = (await response.json()) as Record<string, unknown>;
|
|
938
|
+
return extractResponsesText(data);
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
async function completeZenResponsesTools(
|
|
942
|
+
target: LlmTarget,
|
|
943
|
+
system: string,
|
|
944
|
+
messages: { role: "user" | "assistant"; content: string }[],
|
|
945
|
+
ctx: ToolContext,
|
|
946
|
+
effort?: string,
|
|
947
|
+
): Promise<DispatchResult | null> {
|
|
948
|
+
const input: ZenInput[] = messages.map((item) => ({
|
|
949
|
+
role: item.role,
|
|
950
|
+
content: item.content,
|
|
951
|
+
}));
|
|
952
|
+
const traces: ToolTrace[] = [];
|
|
953
|
+
const thinkingChunks: string[] = [];
|
|
954
|
+
const catalog = openaiTools(ctx.skills ?? [], ctx);
|
|
955
|
+
const tools = zenResponsesTools(catalog);
|
|
956
|
+
const usage = blankUsage();
|
|
957
|
+
const started = Date.now();
|
|
958
|
+
let lastCalls: Extract<ZenInput, { type: "function_call" }>[] = [];
|
|
959
|
+
const looped = await runAgentLoop({
|
|
960
|
+
toolCtx: ctx,
|
|
961
|
+
traces,
|
|
962
|
+
thinkingChunks,
|
|
963
|
+
nullIfNoTraces: true,
|
|
964
|
+
ask: async ({ wrap, steer }) => {
|
|
965
|
+
if (wrap) input.push({ role: "user", content: TOOL_LOOP_WRAP });
|
|
966
|
+
if (steer) input.push({ role: "user", content: steer });
|
|
967
|
+
const extra =
|
|
968
|
+
estimateSendTokens(system) +
|
|
969
|
+
estimateSendTokens(JSON.stringify(tools)) +
|
|
970
|
+
2048;
|
|
971
|
+
const fitted = trimSendMessages(input, extra);
|
|
972
|
+
if (fitted.length < input.length) {
|
|
973
|
+
input.splice(0, input.length, ...fitted);
|
|
974
|
+
}
|
|
975
|
+
const response = await withTransientRetries(
|
|
976
|
+
async () => {
|
|
977
|
+
throwIfAborted(ctx);
|
|
978
|
+
const res = await postZenResponses(
|
|
979
|
+
target,
|
|
980
|
+
{
|
|
981
|
+
model: target.model,
|
|
982
|
+
instructions: system,
|
|
983
|
+
input,
|
|
984
|
+
tools,
|
|
985
|
+
tool_choice: "auto",
|
|
986
|
+
...reasoningPayload(target.providerId, target.baseUrl, effort),
|
|
987
|
+
},
|
|
988
|
+
roundSignal(ctx),
|
|
989
|
+
);
|
|
990
|
+
if (res.ok) return res;
|
|
991
|
+
const err = new Error(`HTTP ${res.status}`);
|
|
992
|
+
if (res.status === 429 || res.status >= 500) throw err;
|
|
993
|
+
return res;
|
|
994
|
+
},
|
|
995
|
+
{
|
|
996
|
+
signal: ctx.signal,
|
|
997
|
+
onRetry: () => {
|
|
998
|
+
emitProgress(ctx, traces, "連線中斷,重試中…");
|
|
999
|
+
},
|
|
1000
|
+
},
|
|
1001
|
+
);
|
|
1002
|
+
if (!response.ok) {
|
|
1003
|
+
return {
|
|
1004
|
+
calls: [],
|
|
1005
|
+
text: `模型請求失敗:HTTP ${response.status}`,
|
|
1006
|
+
thinking: "",
|
|
1007
|
+
};
|
|
1008
|
+
}
|
|
1009
|
+
const data = (await response.json()) as {
|
|
1010
|
+
output?: Record<string, unknown>[];
|
|
1011
|
+
usage?: {
|
|
1012
|
+
input_tokens?: number;
|
|
1013
|
+
output_tokens?: number;
|
|
1014
|
+
total_tokens?: number;
|
|
1015
|
+
input_tokens_details?: { cached_tokens?: number };
|
|
1016
|
+
};
|
|
1017
|
+
};
|
|
1018
|
+
addUsage(
|
|
1019
|
+
usage,
|
|
1020
|
+
fromOpenAiUsage({
|
|
1021
|
+
prompt_tokens: data.usage?.input_tokens,
|
|
1022
|
+
completion_tokens: data.usage?.output_tokens,
|
|
1023
|
+
total_tokens: data.usage?.total_tokens,
|
|
1024
|
+
prompt_tokens_details: {
|
|
1025
|
+
cached_tokens: data.usage?.input_tokens_details?.cached_tokens,
|
|
1026
|
+
},
|
|
1027
|
+
}),
|
|
1028
|
+
);
|
|
1029
|
+
lastCalls = [];
|
|
1030
|
+
const calls: { id: string; name: string; args: Record<string, unknown> }[] =
|
|
1031
|
+
[];
|
|
1032
|
+
for (const item of data.output ?? []) {
|
|
1033
|
+
if (item?.type !== "function_call") continue;
|
|
1034
|
+
const callId = String(item.call_id || item.id || "");
|
|
1035
|
+
const name = String(item.name || "");
|
|
1036
|
+
if (!callId || !name) continue;
|
|
1037
|
+
lastCalls.push({
|
|
1038
|
+
type: "function_call",
|
|
1039
|
+
call_id: callId,
|
|
1040
|
+
name,
|
|
1041
|
+
arguments: String(item.arguments || "{}"),
|
|
1042
|
+
});
|
|
1043
|
+
let args: Record<string, unknown> = {};
|
|
1044
|
+
try {
|
|
1045
|
+
args = JSON.parse(String(item.arguments || "{}")) as Record<
|
|
1046
|
+
string,
|
|
1047
|
+
unknown
|
|
1048
|
+
>;
|
|
1049
|
+
} catch {
|
|
1050
|
+
args = {};
|
|
1051
|
+
}
|
|
1052
|
+
calls.push({ id: callId, name, args });
|
|
1053
|
+
}
|
|
1054
|
+
return {
|
|
1055
|
+
calls,
|
|
1056
|
+
text: extractResponsesText(data as Record<string, unknown>) ?? "",
|
|
1057
|
+
thinking: "",
|
|
1058
|
+
};
|
|
1059
|
+
},
|
|
1060
|
+
onRetry: (late) => {
|
|
1061
|
+
input.push({ role: "user", content: late });
|
|
1062
|
+
},
|
|
1063
|
+
onTools: (calls, outcomes) => {
|
|
1064
|
+
for (let i = 0; i < calls.length; i++) {
|
|
1065
|
+
const raw = lastCalls[i];
|
|
1066
|
+
if (raw) input.push(raw);
|
|
1067
|
+
else {
|
|
1068
|
+
input.push({
|
|
1069
|
+
type: "function_call",
|
|
1070
|
+
call_id: calls[i].id,
|
|
1071
|
+
name: calls[i].name,
|
|
1072
|
+
arguments: JSON.stringify(calls[i].args ?? {}),
|
|
1073
|
+
});
|
|
1074
|
+
}
|
|
1075
|
+
input.push({
|
|
1076
|
+
type: "function_call_output",
|
|
1077
|
+
call_id: calls[i].id,
|
|
1078
|
+
output: outcomes[i]?.text ?? "",
|
|
1079
|
+
});
|
|
1080
|
+
}
|
|
1081
|
+
},
|
|
1082
|
+
});
|
|
1083
|
+
if (!looped) return null;
|
|
1084
|
+
return {
|
|
1085
|
+
text: looped.text,
|
|
1086
|
+
traces: looped.traces,
|
|
1087
|
+
thinking: looped.thinking,
|
|
1088
|
+
usage: withDuration(usage, started),
|
|
1089
|
+
};
|
|
1090
|
+
}
|
|
1091
|
+
|
|
721
1092
|
async function completeOpenAi(
|
|
722
1093
|
target: LlmTarget,
|
|
723
1094
|
system: string,
|
|
724
1095
|
messages: { role: "user" | "assistant"; content: string }[],
|
|
725
1096
|
temperature: number,
|
|
1097
|
+
effort?: string,
|
|
726
1098
|
): Promise<string | null> {
|
|
727
1099
|
const url = `${target.baseUrl}/chat/completions`;
|
|
728
|
-
const headers: Record<string, string> = {
|
|
729
|
-
authorization: `Bearer ${target.apiKey}`,
|
|
730
|
-
"content-type": "application/json",
|
|
731
|
-
...(target.headers ?? {}),
|
|
732
|
-
};
|
|
733
1100
|
const response = await fetch(url, {
|
|
734
1101
|
method: "POST",
|
|
735
|
-
headers,
|
|
1102
|
+
headers: llmRequestHeaders(target),
|
|
736
1103
|
body: JSON.stringify({
|
|
737
1104
|
model: target.model,
|
|
738
1105
|
temperature,
|
|
739
1106
|
messages: [{ role: "system", content: system }, ...messages],
|
|
1107
|
+
...reasoningPayload(target.providerId, target.baseUrl, effort),
|
|
740
1108
|
}),
|
|
741
|
-
signal: AbortSignal.timeout(
|
|
1109
|
+
signal: AbortSignal.timeout(STREAM_IDLE_TIMEOUT_MS),
|
|
742
1110
|
});
|
|
743
1111
|
if (!response.ok) return null;
|
|
744
1112
|
const data = (await response.json()) as {
|
|
@@ -776,7 +1144,7 @@ async function completeAnthropic(
|
|
|
776
1144
|
system,
|
|
777
1145
|
messages,
|
|
778
1146
|
}),
|
|
779
|
-
signal: AbortSignal.timeout(
|
|
1147
|
+
signal: AbortSignal.timeout(STREAM_IDLE_TIMEOUT_MS),
|
|
780
1148
|
});
|
|
781
1149
|
if (!response.ok) return null;
|
|
782
1150
|
const data = (await response.json()) as {
|
|
@@ -818,7 +1186,7 @@ async function completeCodex(
|
|
|
818
1186
|
method: "POST",
|
|
819
1187
|
headers,
|
|
820
1188
|
body: JSON.stringify(body),
|
|
821
|
-
signal: AbortSignal.timeout(
|
|
1189
|
+
signal: AbortSignal.timeout(STREAM_IDLE_TIMEOUT_MS),
|
|
822
1190
|
});
|
|
823
1191
|
if (response.ok) {
|
|
824
1192
|
const data = (await response.json()) as Record<string, unknown>;
|
|
@@ -829,7 +1197,7 @@ async function completeCodex(
|
|
|
829
1197
|
method: "POST",
|
|
830
1198
|
headers,
|
|
831
1199
|
body: JSON.stringify({ ...body, stream: true }),
|
|
832
|
-
signal: AbortSignal.timeout(
|
|
1200
|
+
signal: AbortSignal.timeout(STREAM_IDLE_TIMEOUT_MS),
|
|
833
1201
|
});
|
|
834
1202
|
if (!streamed.ok || !streamed.body) return null;
|
|
835
1203
|
return readSseText(streamed);
|
|
@@ -936,6 +1304,7 @@ function sanitizeModels(file: ModelsFile): ModelsFile {
|
|
|
936
1304
|
.map((model) => ({
|
|
937
1305
|
id: model.id.trim(),
|
|
938
1306
|
name: model.name?.trim() || undefined,
|
|
1307
|
+
...(model.reasoning ? { reasoning: model.reasoning } : {}),
|
|
939
1308
|
}));
|
|
940
1309
|
if (models.length === 0) {
|
|
941
1310
|
throw new StoreError(400, `provider ${id} needs at least one model`);
|
|
@@ -965,12 +1334,7 @@ function sanitizeModels(file: ModelsFile): ModelsFile {
|
|
|
965
1334
|
for (const [role, ref] of Object.entries(file.aux ?? {})) {
|
|
966
1335
|
aux[role as AuxRole] = validRef(ref as ModelRef | null);
|
|
967
1336
|
}
|
|
968
|
-
const reasoning =
|
|
969
|
-
file.reasoning === "minimal" ||
|
|
970
|
-
file.reasoning === "low" ||
|
|
971
|
-
file.reasoning === "high"
|
|
972
|
-
? file.reasoning
|
|
973
|
-
: "medium";
|
|
1337
|
+
const reasoning = sanitizeEffort(file.reasoning);
|
|
974
1338
|
const recent = (file.recent ?? [])
|
|
975
1339
|
.map((ref) => validRef(ref))
|
|
976
1340
|
.filter((ref): ref is ModelRef => Boolean(ref))
|