@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/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: "compression", name: "Compression", hint: "Context compaction" },
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: null,
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
- writeFileSync(modelsPath(dataDir), `${JSON.stringify(cleaned, null, 2)}\n`);
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 publicModels(dataDir: string, env: NodeJS.ProcessEnv = process.env) {
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 const,
212
- ready: Boolean(resolveApiKey(p.apiKey, env) || p.stored === "literal"),
213
- models: p.models,
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 ?? "medium",
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 && role !== "chat" ? file.aux?.[role] : file.default);
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 provider = file.providers[id];
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: id,
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: file.fast ? "low" : file.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 fetch(`${target.baseUrl}/chat/completions`, {
522
- method: "POST",
523
- headers: {
524
- authorization: `Bearer ${target.apiKey}`,
525
- "content-type": "application/json",
526
- ...(target.headers ?? {}),
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
- body: JSON.stringify({
529
- model: target.model,
530
- temperature,
531
- messages: msgs,
532
- tools: catalog,
533
- tool_choice: "auto",
534
- }),
535
- signal: roundSignal(ctx),
536
- });
537
- if (!response.ok) return null;
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 fetch(
633
- `${target.baseUrl.replace(/\/v1$/, "")}/v1/messages`,
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
- method: "POST",
636
- headers,
637
- body: JSON.stringify({
638
- model: target.model,
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) return null;
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?: { input_tokens?: number; output_tokens?: number };
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(25_000),
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(25_000),
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(40_000),
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(40_000),
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))