minimal-agent 0.18.13 → 0.18.14

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.
Files changed (174) hide show
  1. package/package.json +1 -3
  2. package/plugins/workflow-runner/src/runtime/context.js +1 -1
  3. package/plugins/workflow-runner/src/runtime/context.ts +1 -1
  4. package/plugins/workflow-runner/test/embeddedExecution.test.ts +2 -5
  5. package/plugins/workflow-runner/test/execLifetime.test.ts +15 -2
  6. package/skills/ssh-remote-dev/SKILL.md +61 -0
  7. package/skills/ssh-remote-dev/agents/openai.yaml +4 -0
  8. package/src/advisor/README.md +62 -24
  9. package/src/advisor/advisorNudge.js +199 -0
  10. package/src/advisor/advisorNudge.ts +232 -0
  11. package/src/advisor/deliveryReview.js +35 -12
  12. package/src/advisor/deliveryReview.ts +35 -11
  13. package/src/advisor/presentation.js +7 -10
  14. package/src/advisor/presentation.ts +10 -13
  15. package/src/advisor/reviewContext.js +22 -58
  16. package/src/advisor/reviewContext.ts +24 -67
  17. package/src/cli/configMenu.js +17 -128
  18. package/src/cli/configMenu.tsx +17 -193
  19. package/src/cli/print.js +2 -0
  20. package/src/cli/print.ts +2 -0
  21. package/src/cli/streamJson.js +2 -0
  22. package/src/cli/streamJson.ts +2 -0
  23. package/src/cli/wizards/advisorWizard.js +9 -7
  24. package/src/cli/wizards/advisorWizard.tsx +9 -7
  25. package/src/cli/wizards/contextAdvisorWizard.js +9 -7
  26. package/src/cli/wizards/contextAdvisorWizard.tsx +9 -7
  27. package/src/cli/wizards/providerPresets.js +27 -1
  28. package/src/cli/wizards/providerPresets.ts +44 -2
  29. package/src/cli/wizards/providerWizard.js +51 -10
  30. package/src/cli/wizards/providerWizard.tsx +59 -9
  31. package/src/config/configDefaults.js +1 -1
  32. package/src/config/configDefaults.ts +1 -1
  33. package/src/config/configDiagnostics.js +1 -21
  34. package/src/config/configDiagnostics.ts +3 -23
  35. package/src/config/configFile.js +11 -4
  36. package/src/config/configFile.ts +27 -7
  37. package/src/config/configManagement.js +1 -0
  38. package/src/config/configManagement.ts +2 -0
  39. package/src/config/configService.js +12 -8
  40. package/src/config/configService.ts +16 -11
  41. package/src/config/numericLimits.js +0 -4
  42. package/src/config/numericLimits.ts +0 -4
  43. package/src/config/reasoningEffort.js +16 -0
  44. package/src/config/reasoningEffort.ts +18 -0
  45. package/src/context/budgetPlan.js +1 -1
  46. package/src/context/budgetPlan.ts +1 -1
  47. package/src/context/optimizeConversation.js +1 -1
  48. package/src/context/optimizeConversation.ts +1 -1
  49. package/src/harness/stallGuard.js +8 -13
  50. package/src/harness/stallGuard.ts +8 -12
  51. package/src/llm/client.js +64 -17
  52. package/src/llm/client.ts +62 -7
  53. package/src/llm/providers/openaiFormat.js +14 -3
  54. package/src/llm/providers/openaiFormat.ts +17 -3
  55. package/src/llm/providers/responsesFormat.js +275 -0
  56. package/src/llm/providers/responsesFormat.ts +306 -0
  57. package/src/loop/README.md +18 -3
  58. package/src/loop/liveDiagramGate.js +63 -0
  59. package/src/loop/liveDiagramGate.ts +61 -0
  60. package/src/loop/messages.js +12 -0
  61. package/src/loop/messages.ts +13 -0
  62. package/src/loop/options.ts +7 -2
  63. package/src/loop/toolBatch.js +0 -1
  64. package/src/loop/toolBatch.ts +0 -1
  65. package/src/loop/toolExecutor.js +11 -46
  66. package/src/loop/toolExecutor.ts +12 -55
  67. package/src/loop/toolPresentation.js +2 -13
  68. package/src/loop/toolPresentation.ts +2 -13
  69. package/src/loop/toolSemantics.js +3 -4
  70. package/src/loop/toolSemantics.ts +4 -6
  71. package/src/loop.js +222 -214
  72. package/src/loop.ts +226 -232
  73. package/src/memory/dependencies.js +11 -3
  74. package/src/memory/dependencies.ts +11 -3
  75. package/src/memory/episodicMemory.js +59 -18
  76. package/src/memory/episodicMemory.ts +58 -18
  77. package/src/memory/experienceStore.js +4 -3
  78. package/src/memory/experienceStore.ts +4 -3
  79. package/src/memory/factCandidates.js +53 -1
  80. package/src/memory/factCandidates.ts +54 -1
  81. package/src/memory/factLearning.js +2 -2
  82. package/src/memory/factLearning.ts +2 -2
  83. package/src/memory/jobPolicy.js +24 -5
  84. package/src/memory/jobPolicy.ts +25 -5
  85. package/src/memory/learning.js +2 -2
  86. package/src/memory/learning.ts +2 -2
  87. package/src/memory/learningModel.js +7 -0
  88. package/src/memory/learningModel.ts +7 -0
  89. package/src/memory/managementService.js +8 -7
  90. package/src/memory/managementService.ts +16 -8
  91. package/src/memory/memoryService.js +104 -33
  92. package/src/memory/memoryService.ts +132 -28
  93. package/src/memory/projectKnowledge.js +39 -3
  94. package/src/memory/projectKnowledge.ts +45 -3
  95. package/src/memory/recallQuery.js +56 -0
  96. package/src/memory/recallQuery.ts +58 -0
  97. package/src/memory/retrieval.js +39 -10
  98. package/src/memory/retrieval.ts +46 -11
  99. package/src/memory/turnJournal.js +75 -10
  100. package/src/memory/turnJournal.ts +66 -10
  101. package/src/memory/types.ts +7 -1
  102. package/src/prompts/delegation.js +2 -1
  103. package/src/prompts/delegation.ts +3 -1
  104. package/src/prompts/policyRegistry.js +9 -12
  105. package/src/prompts/policyRegistry.ts +9 -12
  106. package/src/prompts/promptAssembly.js +6 -5
  107. package/src/prompts/promptAssembly.ts +8 -6
  108. package/src/prompts/visualPresentation.js +39 -10
  109. package/src/prompts/visualPresentation.ts +38 -9
  110. package/src/runtime/answerPreparation.js +1 -1
  111. package/src/runtime/answerPreparation.ts +1 -1
  112. package/src/runtime/fileLock.js +19 -3
  113. package/src/runtime/fileLock.ts +19 -3
  114. package/src/runtime/invocationPolicy.js +1 -1
  115. package/src/runtime/invocationPolicy.ts +1 -1
  116. package/src/runtime/outputContract.js +0 -4
  117. package/src/runtime/outputContract.ts +0 -7
  118. package/src/session/runAgentTurn.js +3 -0
  119. package/src/session/runAgentTurn.ts +3 -0
  120. package/src/team/onlineToolContract.js +0 -1
  121. package/src/team/onlineToolContract.ts +0 -1
  122. package/src/tools/advisor/advisor.js +207 -365
  123. package/src/tools/advisor/advisor.ts +211 -383
  124. package/src/tools/agent/agentTypes.js +0 -1
  125. package/src/tools/agent/agentTypes.ts +0 -1
  126. package/src/tools/agent/coordinator.js +4 -1
  127. package/src/tools/agent/coordinator.ts +4 -1
  128. package/src/tools/bash/semantics.js +4 -5
  129. package/src/tools/bash/semantics.ts +4 -5
  130. package/src/tools/capabilities.js +2 -3
  131. package/src/tools/capabilities.ts +2 -4
  132. package/src/tools/index.js +0 -16
  133. package/src/tools/index.ts +0 -16
  134. package/src/tools/memory/maintenance.js +8 -9
  135. package/src/tools/memory/maintenance.ts +13 -11
  136. package/src/tools/memory/memory.js +5 -1
  137. package/src/tools/memory/memory.ts +5 -0
  138. package/src/tools/optimizeConversation/README.md +4 -6
  139. package/src/tools/toolPromptGuides.js +2 -9
  140. package/src/tools/toolPromptGuides.ts +2 -9
  141. package/src/types.ts +28 -105
  142. package/src/ui/App.js +2 -0
  143. package/src/ui/App.tsx +1 -0
  144. package/src/ui/MessageList.js +8 -44
  145. package/src/ui/MessageList.tsx +3 -81
  146. package/src/ui/hooks/useChat.js +2 -0
  147. package/src/ui/hooks/useChat.ts +2 -0
  148. package/src/ui/toolProgress.js +1 -1
  149. package/src/ui/toolProgress.ts +1 -1
  150. package/src/web/protocol.ts +10 -3
  151. package/src/advisor/advisorCoordinator.js +0 -734
  152. package/src/advisor/advisorCoordinator.ts +0 -825
  153. package/src/cli/wizards/sshHostWizard.js +0 -156
  154. package/src/cli/wizards/sshHostWizard.tsx +0 -214
  155. package/src/context/sshHosts.js +0 -290
  156. package/src/context/sshHosts.ts +0 -312
  157. package/src/loop/publication.js +0 -94
  158. package/src/loop/publication.ts +0 -124
  159. package/src/runtime/finalAnswer.js +0 -9
  160. package/src/runtime/finalAnswer.ts +0 -13
  161. package/src/tools/advisor/advisorDecision.js +0 -79
  162. package/src/tools/advisor/advisorDecision.ts +0 -92
  163. package/src/tools/ssh/ssh.js +0 -152
  164. package/src/tools/ssh/ssh.ts +0 -167
  165. package/src/tools/ssh/sshAvailability.js +0 -35
  166. package/src/tools/ssh/sshAvailability.ts +0 -39
  167. package/src/tools/ssh/sshClient.js +0 -177
  168. package/src/tools/ssh/sshClient.ts +0 -189
  169. package/src/tools/ssh/sshDownload.js +0 -77
  170. package/src/tools/ssh/sshDownload.ts +0 -92
  171. package/src/tools/ssh/sshHostsTool.js +0 -36
  172. package/src/tools/ssh/sshHostsTool.ts +0 -48
  173. package/src/tools/ssh/sshUpload.js +0 -72
  174. package/src/tools/ssh/sshUpload.ts +0 -85
package/src/llm/client.js CHANGED
@@ -26,6 +26,7 @@
26
26
  import { dlog } from '../utils/debugLog.js';
27
27
  import { buildOpenAIRequest, createOpenAISSEParser, isImageInputRejected, isMaxTokensRejected, isPromptCacheKeyRejected, isResponseFormatUnsupported, } from './providers/openaiFormat.js';
28
28
  import { promptMaterialHash, shortCacheScope } from './promptCache.js';
29
+ import { buildResponsesRequest, createResponsesSSEParser, isReasoningEffortRejected, } from './providers/responsesFormat.js';
29
30
  import { buildAnthropicRequest, createAnthropicSSEParser, } from './providers/anthropicFormat.js';
30
31
  import { chatGateway } from './gatewayClient.js';
31
32
  import { prepareMessagesForProvider } from './messageProvenance.js';
@@ -42,6 +43,11 @@ const rfUnsupported = new Set();
42
43
  */
43
44
  const maxTokensUnsupported = new Set();
44
45
  const promptCacheKeyUnsupported = new Set();
46
+ /**
47
+ * 记住"这个 baseURL::model 不认我们配的推理强度"。强度档位因模型而异(有的没有 none/minimal,
48
+ * 非推理模型根本没有 reasoning 参数),拒绝时去掉该参数重发一次,而不是让整个对话 400。
49
+ */
50
+ const reasoningEffortUnsupported = new Set();
45
51
  /** 调试日志用的请求自增号(vote 并发多 voter 时靠它在日志里区分是哪一路)。 */
46
52
  let reqCounter = 0;
47
53
  // ============================================================
@@ -278,7 +284,11 @@ async function* streamLocal(args) {
278
284
  const maxAttempts = args.maxAttempts ?? DEFAULT_RETRY.maxAttempts;
279
285
  if (!Number.isSafeInteger(maxAttempts) || maxAttempts < 1)
280
286
  throw new Error('Invalid maxAttempts');
281
- const format = provider.apiFormat === 'anthropic' ? 'anthropic' : 'openai';
287
+ const format = provider.apiFormat === 'anthropic'
288
+ ? 'anthropic'
289
+ : provider.apiFormat === 'openai-responses'
290
+ ? 'openai-responses'
291
+ : 'openai';
282
292
  if (format === 'anthropic' && responseFormat) {
283
293
  throw new Error('output_schema 结构化输出目前不支持 Anthropic Messages 格式的 provider' +
284
294
  '(Anthropic 的强制工具输出模式与 OpenAI 的 response_format.json_schema 形状不同,尚未实现映射)。' +
@@ -292,15 +302,18 @@ async function* streamLocal(args) {
292
302
  const rfKey = `${provider.baseURL}::${provider.model}`;
293
303
  // OpenAI 分支专属的「就地退化」状态;Anthropic 无此概念,两个 flag 恒为 false,
294
304
  // 下面的退化 if 分支天然不会进入(不需要额外的 format 判断散落各处)。
295
- let wantResponseFormat = format === 'openai' &&
305
+ let wantResponseFormat = format !== 'anthropic' &&
296
306
  responseFormat !== undefined &&
297
307
  !rfUnsupported.has(rfKey);
298
- let wantMaxTokens = format === 'openai' &&
308
+ let wantMaxTokens = format !== 'anthropic' &&
299
309
  typeof provider.maxTokens === 'number' &&
300
310
  provider.maxTokens > 0 &&
301
311
  !maxTokensUnsupported.has(rfKey);
302
312
  let imagesStripped = false;
303
- let wantPromptCacheKey = format === 'openai' &&
313
+ let wantReasoningEffort = format !== 'anthropic' &&
314
+ !!provider.reasoningEffort &&
315
+ !reasoningEffortUnsupported.has(rfKey);
316
+ let wantPromptCacheKey = format !== 'anthropic' &&
304
317
  provider.promptCache?.mode === 'session-key' &&
305
318
  !!promptCache?.scopeKey &&
306
319
  !promptCacheKeyUnsupported.has(rfKey);
@@ -330,18 +343,31 @@ async function* streamLocal(args) {
330
343
  tools,
331
344
  ...(cacheEdits ? { cacheEdits } : {}),
332
345
  })
333
- : buildOpenAIRequest({
334
- provider,
335
- cleanedMessages,
336
- tools,
337
- withResponseFormat: wantResponseFormat,
338
- withMaxTokens: wantMaxTokens,
339
- includeUsage,
340
- promptCache,
341
- withPromptCacheKey: wantPromptCacheKey,
342
- responseFormat,
343
- reasoning: provider.reasoning,
344
- });
346
+ : format === 'openai-responses'
347
+ ? buildResponsesRequest({
348
+ provider,
349
+ cleanedMessages,
350
+ tools,
351
+ withMaxTokens: wantMaxTokens,
352
+ withReasoningEffort: wantReasoningEffort,
353
+ withPromptCacheKey: wantPromptCacheKey,
354
+ promptCache,
355
+ responseFormat,
356
+ withResponseFormat: wantResponseFormat,
357
+ })
358
+ : buildOpenAIRequest({
359
+ provider,
360
+ cleanedMessages,
361
+ tools,
362
+ withResponseFormat: wantResponseFormat,
363
+ withMaxTokens: wantMaxTokens,
364
+ includeUsage,
365
+ promptCache,
366
+ withPromptCacheKey: wantPromptCacheKey,
367
+ responseFormat,
368
+ reasoning: provider.reasoning,
369
+ withReasoningEffort: wantReasoningEffort,
370
+ });
345
371
  return fetch(req.url, {
346
372
  method: 'POST',
347
373
  headers: req.headers,
@@ -440,6 +466,25 @@ async function* streamLocal(args) {
440
466
  break;
441
467
  errText = await resp.text().catch(() => '');
442
468
  }
469
+ // 推理强度不被该模型接受 → 去掉 reasoning 参数重发一次(沿用服务端默认强度),并告警。
470
+ if (wantReasoningEffort && isReasoningEffortRejected(resp.status, errText)) {
471
+ reasoningEffortUnsupported.add(rfKey);
472
+ wantReasoningEffort = false;
473
+ console.warn(`[minimal-agent] provider model "${provider.model}" 不接受推理强度 "${provider.reasoningEffort}",` +
474
+ `已去掉该参数重发(沿用服务端默认强度)。可在 /config 里改选该模型支持的档位。`);
475
+ try {
476
+ resp = await postChat();
477
+ watchdog.kick();
478
+ }
479
+ catch (e) {
480
+ if ((await handleNetworkError(e, attempt, 'reasoning-effort fallback fetch failed')) === 'break')
481
+ break;
482
+ continue;
483
+ }
484
+ if (resp.ok)
485
+ break;
486
+ errText = await resp.text().catch(() => '');
487
+ }
443
488
  // 退化①:response_format.json_schema 不支持
444
489
  if (wantResponseFormat &&
445
490
  isResponseFormatUnsupported(resp.status, errText)) {
@@ -554,7 +599,9 @@ async function* streamLocal(args) {
554
599
  let buffer = '';
555
600
  const parser = format === 'anthropic'
556
601
  ? createAnthropicSSEParser()
557
- : createOpenAISSEParser();
602
+ : format === 'openai-responses'
603
+ ? createResponsesSSEParser()
604
+ : createOpenAISSEParser();
558
605
  try {
559
606
  while (true) {
560
607
  // 每次 read 前检查 abort:用户按 ESC/Ctrl+C 时 signal.aborted 为 true。
package/src/llm/client.ts CHANGED
@@ -42,6 +42,11 @@ import {
42
42
  isResponseFormatUnsupported,
43
43
  } from './providers/openaiFormat.js';
44
44
  import { promptMaterialHash, shortCacheScope } from './promptCache.js';
45
+ import {
46
+ buildResponsesRequest,
47
+ createResponsesSSEParser,
48
+ isReasoningEffortRejected,
49
+ } from './providers/responsesFormat.js';
45
50
  import {
46
51
  buildAnthropicRequest,
47
52
  createAnthropicSSEParser,
@@ -64,6 +69,11 @@ const rfUnsupported = new Set<string>();
64
69
  */
65
70
  const maxTokensUnsupported = new Set<string>();
66
71
  const promptCacheKeyUnsupported = new Set<string>();
72
+ /**
73
+ * 记住"这个 baseURL::model 不认我们配的推理强度"。强度档位因模型而异(有的没有 none/minimal,
74
+ * 非推理模型根本没有 reasoning 参数),拒绝时去掉该参数重发一次,而不是让整个对话 400。
75
+ */
76
+ const reasoningEffortUnsupported = new Set<string>();
67
77
 
68
78
  /** 调试日志用的请求自增号(vote 并发多 voter 时靠它在日志里区分是哪一路)。 */
69
79
  let reqCounter = 0;
@@ -385,8 +395,12 @@ async function* streamLocal(args: {
385
395
  } = args;
386
396
  const maxAttempts = args.maxAttempts ?? DEFAULT_RETRY.maxAttempts;
387
397
  if (!Number.isSafeInteger(maxAttempts) || maxAttempts < 1) throw new Error('Invalid maxAttempts');
388
- const format: 'openai' | 'anthropic' =
389
- provider.apiFormat === 'anthropic' ? 'anthropic' : 'openai';
398
+ const format: 'openai' | 'openai-responses' | 'anthropic' =
399
+ provider.apiFormat === 'anthropic'
400
+ ? 'anthropic'
401
+ : provider.apiFormat === 'openai-responses'
402
+ ? 'openai-responses'
403
+ : 'openai';
390
404
 
391
405
  if (format === 'anthropic' && responseFormat) {
392
406
  throw new Error(
@@ -410,17 +424,21 @@ async function* streamLocal(args: {
410
424
  // OpenAI 分支专属的「就地退化」状态;Anthropic 无此概念,两个 flag 恒为 false,
411
425
  // 下面的退化 if 分支天然不会进入(不需要额外的 format 判断散落各处)。
412
426
  let wantResponseFormat =
413
- format === 'openai' &&
427
+ format !== 'anthropic' &&
414
428
  responseFormat !== undefined &&
415
429
  !rfUnsupported.has(rfKey);
416
430
  let wantMaxTokens =
417
- format === 'openai' &&
431
+ format !== 'anthropic' &&
418
432
  typeof provider.maxTokens === 'number' &&
419
433
  provider.maxTokens > 0 &&
420
434
  !maxTokensUnsupported.has(rfKey);
421
435
  let imagesStripped = false;
436
+ let wantReasoningEffort =
437
+ format !== 'anthropic' &&
438
+ !!provider.reasoningEffort &&
439
+ !reasoningEffortUnsupported.has(rfKey);
422
440
  let wantPromptCacheKey =
423
- format === 'openai' &&
441
+ format !== 'anthropic' &&
424
442
  provider.promptCache?.mode === 'session-key' &&
425
443
  !!promptCache?.scopeKey &&
426
444
  !promptCacheKeyUnsupported.has(rfKey);
@@ -455,7 +473,19 @@ async function* streamLocal(args: {
455
473
  tools,
456
474
  ...(cacheEdits ? { cacheEdits } : {}),
457
475
  })
458
- : buildOpenAIRequest({
476
+ : format === 'openai-responses'
477
+ ? buildResponsesRequest({
478
+ provider,
479
+ cleanedMessages,
480
+ tools,
481
+ withMaxTokens: wantMaxTokens,
482
+ withReasoningEffort: wantReasoningEffort,
483
+ withPromptCacheKey: wantPromptCacheKey,
484
+ promptCache,
485
+ responseFormat,
486
+ withResponseFormat: wantResponseFormat,
487
+ })
488
+ : buildOpenAIRequest({
459
489
  provider,
460
490
  cleanedMessages,
461
491
  tools,
@@ -466,6 +496,7 @@ async function* streamLocal(args: {
466
496
  withPromptCacheKey: wantPromptCacheKey,
467
497
  responseFormat,
468
498
  reasoning: provider.reasoning,
499
+ withReasoningEffort: wantReasoningEffort,
469
500
  });
470
501
  return fetch(req.url, {
471
502
  method: 'POST',
@@ -590,6 +621,28 @@ async function* streamLocal(args: {
590
621
  errText = await resp.text().catch(() => '');
591
622
  }
592
623
 
624
+ // 推理强度不被该模型接受 → 去掉 reasoning 参数重发一次(沿用服务端默认强度),并告警。
625
+ if (wantReasoningEffort && isReasoningEffortRejected(resp.status, errText)) {
626
+ reasoningEffortUnsupported.add(rfKey);
627
+ wantReasoningEffort = false;
628
+ console.warn(
629
+ `[minimal-agent] provider model "${provider.model}" 不接受推理强度 "${provider.reasoningEffort}",` +
630
+ `已去掉该参数重发(沿用服务端默认强度)。可在 /config 里改选该模型支持的档位。`,
631
+ );
632
+ try {
633
+ resp = await postChat();
634
+ watchdog.kick();
635
+ } catch (e) {
636
+ if (
637
+ (await handleNetworkError(e, attempt, 'reasoning-effort fallback fetch failed')) === 'break'
638
+ )
639
+ break;
640
+ continue;
641
+ }
642
+ if (resp.ok) break;
643
+ errText = await resp.text().catch(() => '');
644
+ }
645
+
593
646
  // 退化①:response_format.json_schema 不支持
594
647
  if (
595
648
  wantResponseFormat &&
@@ -730,7 +783,9 @@ async function* streamLocal(args: {
730
783
  const parser =
731
784
  format === 'anthropic'
732
785
  ? createAnthropicSSEParser()
733
- : createOpenAISSEParser();
786
+ : format === 'openai-responses'
787
+ ? createResponsesSSEParser()
788
+ : createOpenAISSEParser();
734
789
 
735
790
  try {
736
791
  while (true) {
@@ -15,7 +15,8 @@ export function isResponseFormatUnsupported(status, errText) {
15
15
  if (status !== 400)
16
16
  return false;
17
17
  const t = errText.toLowerCase();
18
- if (!t.includes('response_format'))
18
+ // Responses API 把它叫 text.format。
19
+ if (!t.includes('response_format') && !t.includes('text.format'))
19
20
  return false;
20
21
  return (t.includes('not support') ||
21
22
  t.includes('unsupported') ||
@@ -31,7 +32,7 @@ export function isMaxTokensRejected(status, errText) {
31
32
  if (status !== 400)
32
33
  return false;
33
34
  const t = errText.toLowerCase();
34
- if (!t.includes('max_tokens') && !t.includes('max tokens'))
35
+ if (!t.includes('max_tokens') && !t.includes('max tokens') && !t.includes('max_output_tokens'))
35
36
  return false;
36
37
  return (t.includes('not support') ||
37
38
  t.includes('unsupported') ||
@@ -189,6 +190,9 @@ export function buildOpenAIUrl(provider) {
189
190
  }
190
191
  return url;
191
192
  }
193
+ function isOpenRouter(baseURL) {
194
+ return /(?:^|\/\/|\.)openrouter\.ai(?:[/:]|$)/i.test(baseURL);
195
+ }
192
196
  export function buildOpenAIRequest(args) {
193
197
  const { provider, cleanedMessages, tools, withResponseFormat, withMaxTokens, includeUsage, responseFormat, promptCache, withPromptCacheKey, } = args;
194
198
  // 转换 image_url.detail: 'default' → 'auto',兼容所有 OpenAI 兼容 provider
@@ -224,7 +228,14 @@ export function buildOpenAIRequest(args) {
224
228
  // OpenRouter and other compatible providers accept this optional extension.
225
229
  // Omit it unless explicitly enabled so ordinary OpenAI-compatible endpoints
226
230
  // retain their default reasoning behavior.
227
- reasoning: args.reasoning ? { enabled: true } : undefined,
231
+ // reasoningEffort 优先:OpenRouter 认 reasoning.effort,OpenAI 官方 Chat Completions
232
+ // 认顶层 reasoning_effort,二者按端点分开发,避免把 OpenRouter 形状发给官方端点。
233
+ reasoning: args.withReasoningEffort && provider.reasoningEffort && isOpenRouter(provider.baseURL)
234
+ ? { effort: provider.reasoningEffort }
235
+ : args.reasoning ? { enabled: true } : undefined,
236
+ reasoning_effort: args.withReasoningEffort && provider.reasoningEffort && !isOpenRouter(provider.baseURL)
237
+ ? provider.reasoningEffort
238
+ : undefined,
228
239
  // ADR-05: structured output;缺省 undefined 不出现在 body(JSON.stringify 自动剥离)
229
240
  response_format: withResponseFormat ? responseFormat : undefined,
230
241
  ...(withPromptCacheKey && promptCache?.scopeKey
@@ -35,7 +35,8 @@ interface OpenAIToolSpec {
35
35
  export function isResponseFormatUnsupported(status: number, errText: string): boolean {
36
36
  if (status !== 400) return false;
37
37
  const t = errText.toLowerCase();
38
- if (!t.includes('response_format')) return false;
38
+ // Responses API 把它叫 text.format。
39
+ if (!t.includes('response_format') && !t.includes('text.format')) return false;
39
40
  return (
40
41
  t.includes('not support') ||
41
42
  t.includes('unsupported') ||
@@ -52,7 +53,7 @@ export function isResponseFormatUnsupported(status: number, errText: string): bo
52
53
  export function isMaxTokensRejected(status: number, errText: string): boolean {
53
54
  if (status !== 400) return false;
54
55
  const t = errText.toLowerCase();
55
- if (!t.includes('max_tokens') && !t.includes('max tokens')) return false;
56
+ if (!t.includes('max_tokens') && !t.includes('max tokens') && !t.includes('max_output_tokens')) return false;
56
57
  return (
57
58
  t.includes('not support') ||
58
59
  t.includes('unsupported') ||
@@ -121,6 +122,8 @@ export interface OpenAIRequestArgs {
121
122
  promptCache?: PromptCacheContext;
122
123
  withPromptCacheKey?: boolean;
123
124
  reasoning?: boolean;
125
+ /** 发送 provider.reasoningEffort(被服务端拒绝过的模型由 client.ts 关掉)。 */
126
+ withReasoningEffort?: boolean;
124
127
  responseFormat?: {
125
128
  type: 'json_schema';
126
129
  json_schema: { name: string; schema: Record<string, unknown>; strict?: boolean };
@@ -236,6 +239,10 @@ export function buildOpenAIUrl(provider: LocalProvider): string {
236
239
  return url;
237
240
  }
238
241
 
242
+ function isOpenRouter(baseURL: string): boolean {
243
+ return /(?:^|\/\/|\.)openrouter\.ai(?:[/:]|$)/i.test(baseURL);
244
+ }
245
+
239
246
  export function buildOpenAIRequest(args: OpenAIRequestArgs): { url: string; headers: Record<string, string>; body: string } {
240
247
  const {
241
248
  provider,
@@ -281,7 +288,14 @@ export function buildOpenAIRequest(args: OpenAIRequestArgs): { url: string; head
281
288
  // OpenRouter and other compatible providers accept this optional extension.
282
289
  // Omit it unless explicitly enabled so ordinary OpenAI-compatible endpoints
283
290
  // retain their default reasoning behavior.
284
- reasoning: args.reasoning ? { enabled: true } : undefined,
291
+ // reasoningEffort 优先:OpenRouter 认 reasoning.effort,OpenAI 官方 Chat Completions
292
+ // 认顶层 reasoning_effort,二者按端点分开发,避免把 OpenRouter 形状发给官方端点。
293
+ reasoning: args.withReasoningEffort && provider.reasoningEffort && isOpenRouter(provider.baseURL)
294
+ ? { effort: provider.reasoningEffort }
295
+ : args.reasoning ? { enabled: true } : undefined,
296
+ reasoning_effort: args.withReasoningEffort && provider.reasoningEffort && !isOpenRouter(provider.baseURL)
297
+ ? provider.reasoningEffort
298
+ : undefined,
285
299
  // ADR-05: structured output;缺省 undefined 不出现在 body(JSON.stringify 自动剥离)
286
300
  response_format: withResponseFormat ? responseFormat : undefined,
287
301
  ...(withPromptCacheKey && promptCache?.scopeKey
@@ -0,0 +1,275 @@
1
+ /**
2
+ * ============================================================
3
+ * src/llm/providers/responsesFormat.ts —— OpenAI Responses API 协议
4
+ * ------------------------------------------------------------
5
+ * ⚠️ 未经真实端点验证:本机没有可用于付费调用的 OpenAI key,请求体与 SSE 事件字段
6
+ * 照官方文档(POST /v1/responses,stream:true)实现,配了手写的 SSE 录像单测,
7
+ * 没有做过真实 round-trip。拿到 key 后先跑一次真实对话冒烟(含一次工具调用)。
8
+ *
9
+ * 与 openaiFormat.ts / anthropicFormat.ts 同一分工:这里只做「内部 Message[]/Tool[]
10
+ * ⇄ Responses 协议形状」的翻译 + SSE 逐帧解析;重试/退避/空闲看门狗/就地退化
11
+ * 留在 client.ts。
12
+ *
13
+ * 设计取舍(都是为了让它在不依赖服务端会话状态的前提下正确):
14
+ * - 无状态:`store:false`,每次请求重发完整历史,不用 previous_response_id。
15
+ * 与本项目「history 是唯一事实源、压缩/快照在本地」的前提一致。
16
+ * - 工具 `strict:false`:Responses 的函数工具默认 strict:true,要求
17
+ * additionalProperties:false 且全字段 required;本项目的工具 schema 不满足,
18
+ * 不显式关掉就会整批 400。
19
+ * - 回传的 function_call 只带 call_id/name/arguments,**不带 id**:带上 fc_ id
20
+ * 而缺少配对的 reasoning 项,服务端会报错;无状态重发不保存 reasoning 项,
21
+ * 所以只能不带。代价是推理模型在多轮工具调用间丢失上一轮的推理上下文
22
+ * (质量略降,不影响正确性)。
23
+ * - 不请求 reasoning.summary:部分组织需要先做身份验证才能拿推理摘要,
24
+ * 请求了反而 400。服务端若主动发摘要增量,照常显示为思维链。
25
+ * ============================================================
26
+ */
27
+ function userContent(content) {
28
+ if (typeof content === 'string')
29
+ return content;
30
+ return content.map((part) => {
31
+ if (part.type === 'text')
32
+ return { type: 'input_text', text: part.text };
33
+ if (part.type === 'image_url') {
34
+ const detail = part.image_url.detail;
35
+ return {
36
+ type: 'input_image',
37
+ image_url: part.image_url.url,
38
+ // 'default' 是本项目内部取值,Responses 只认 auto/low/high。
39
+ detail: detail === 'low' || detail === 'high' ? detail : 'auto',
40
+ };
41
+ }
42
+ return { type: 'input_text', text: '[视频内容未发送:Responses 格式的 provider 暂不支持视频输入]' };
43
+ });
44
+ }
45
+ /** 内部 Message[](OpenAI Chat 形状)→ Responses 的 input 数组。 */
46
+ export function toResponsesInput(messages) {
47
+ const input = [];
48
+ for (const message of messages) {
49
+ if (message.role === 'system') {
50
+ input.push({ role: 'system', content: message.content });
51
+ }
52
+ else if (message.role === 'user') {
53
+ input.push({ role: 'user', content: userContent(message.content) });
54
+ }
55
+ else if (message.role === 'assistant') {
56
+ if (message.content) {
57
+ input.push({ role: 'assistant', content: [{ type: 'output_text', text: message.content }] });
58
+ }
59
+ for (const call of message.tool_calls ?? []) {
60
+ input.push({
61
+ type: 'function_call',
62
+ call_id: call.id,
63
+ name: call.function.name,
64
+ arguments: call.function.arguments || '{}',
65
+ });
66
+ }
67
+ }
68
+ else {
69
+ input.push({ type: 'function_call_output', call_id: message.tool_call_id, output: message.content });
70
+ }
71
+ }
72
+ return input;
73
+ }
74
+ function looksLikeVersionedBase(url) {
75
+ try {
76
+ const path = new URL(url).pathname.replace(/\/+$/, '');
77
+ if (path === '' || path === '/')
78
+ return true;
79
+ return /^v\d+/.test(path.split('/').at(-1));
80
+ }
81
+ catch {
82
+ return false;
83
+ }
84
+ }
85
+ /** baseURL 是完整端点(以 /responses 结尾);只有「版本化基础路径」才自动补后缀。 */
86
+ export function buildResponsesUrl(provider) {
87
+ const url = provider.baseURL.trim().replace(/\/+$/, '');
88
+ if (!url || url.endsWith('/responses'))
89
+ return url;
90
+ return looksLikeVersionedBase(url) ? `${url}/responses` : url;
91
+ }
92
+ export function buildResponsesRequest(args) {
93
+ const { provider, cleanedMessages, tools } = args;
94
+ const functionTools = tools.map((tool) => ({
95
+ type: 'function',
96
+ name: tool.name,
97
+ description: typeof tool.description === 'function' ? tool.description() : tool.description,
98
+ parameters: tool.parameters,
99
+ strict: false,
100
+ }));
101
+ const body = {
102
+ model: provider.model,
103
+ input: toResponsesInput(cleanedMessages),
104
+ stream: true,
105
+ store: false,
106
+ };
107
+ if (functionTools.length > 0) {
108
+ body.tools = functionTools;
109
+ body.tool_choice = 'auto';
110
+ }
111
+ if (args.withMaxTokens && typeof provider.maxTokens === 'number' && provider.maxTokens > 0) {
112
+ body.max_output_tokens = provider.maxTokens;
113
+ }
114
+ if (args.withReasoningEffort && provider.reasoningEffort) {
115
+ body.reasoning = { effort: provider.reasoningEffort };
116
+ }
117
+ if (args.withResponseFormat && args.responseFormat) {
118
+ const schema = args.responseFormat.json_schema;
119
+ body.text = {
120
+ format: {
121
+ type: 'json_schema',
122
+ name: schema.name,
123
+ schema: schema.schema,
124
+ ...(schema.strict !== undefined ? { strict: schema.strict } : {}),
125
+ },
126
+ };
127
+ }
128
+ if (args.withPromptCacheKey && args.promptCache?.scopeKey) {
129
+ body.prompt_cache_key = args.promptCache.scopeKey;
130
+ }
131
+ return {
132
+ url: buildResponsesUrl(provider),
133
+ headers: {
134
+ 'Content-Type': 'application/json',
135
+ Authorization: `Bearer ${provider.apiKey}`,
136
+ },
137
+ body: JSON.stringify(body),
138
+ };
139
+ }
140
+ /** 400 是否在抱怨 reasoning / effort 取值(模型不支持该档位,或根本不是推理模型)。 */
141
+ export function isReasoningEffortRejected(status, errText) {
142
+ if (status !== 400 && status !== 422)
143
+ return false;
144
+ const text = errText.toLowerCase();
145
+ if (!text.includes('reasoning'))
146
+ return false;
147
+ return (text.includes('not support') ||
148
+ text.includes('unsupported') ||
149
+ text.includes('invalid') ||
150
+ text.includes('not valid') ||
151
+ text.includes('unknown') ||
152
+ text.includes('unrecognized') ||
153
+ text.includes('unexpected') ||
154
+ text.includes('not allowed') ||
155
+ text.includes('supported values'));
156
+ }
157
+ function finiteNonNegative(value) {
158
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined;
159
+ }
160
+ /** 每个请求一个新实例;把 Responses 的 SSE `data:` 载荷翻成 LlmStreamEvent。 */
161
+ export function createResponsesSSEParser() {
162
+ /** output_index → 本轮第几个工具调用(loop 按 index 合并分片)。 */
163
+ const toolIndex = new Map();
164
+ const streamedArguments = new Set();
165
+ let stopReason = 'unknown';
166
+ let sawToolCall = false;
167
+ let promptTokens;
168
+ let outputTokens;
169
+ let cachedTokens;
170
+ let promptCacheUsage = { reported: false };
171
+ const readUsage = (usage) => {
172
+ if (!usage || typeof usage !== 'object')
173
+ return;
174
+ const u = usage;
175
+ // input_tokens 已含命中缓存的部分(与 Chat Completions 的 prompt_tokens 同语义)。
176
+ promptTokens = finiteNonNegative(u.input_tokens) ?? promptTokens;
177
+ outputTokens = finiteNonNegative(u.output_tokens) ?? outputTokens;
178
+ const details = u.input_tokens_details;
179
+ if (details && typeof details === 'object') {
180
+ cachedTokens = finiteNonNegative(details.cached_tokens) ?? cachedTokens;
181
+ }
182
+ promptCacheUsage = {
183
+ reported: cachedTokens !== undefined,
184
+ ...(promptTokens !== undefined ? { promptTokens } : {}),
185
+ ...(cachedTokens !== undefined ? { cacheReadTokens: cachedTokens } : {}),
186
+ };
187
+ };
188
+ const done = () => ({
189
+ type: 'done',
190
+ stopReason,
191
+ promptTokens,
192
+ outputTokens,
193
+ cachedPromptTokens: cachedTokens,
194
+ promptCacheUsage,
195
+ });
196
+ const indexFor = (outputIndex) => {
197
+ let index = toolIndex.get(outputIndex);
198
+ if (index === undefined) {
199
+ index = toolIndex.size;
200
+ toolIndex.set(outputIndex, index);
201
+ }
202
+ return index;
203
+ };
204
+ return {
205
+ buildFallbackDone: done,
206
+ handleData(dataStr) {
207
+ if (dataStr === '[DONE]')
208
+ return [done()];
209
+ let data;
210
+ try {
211
+ data = JSON.parse(dataStr);
212
+ }
213
+ catch {
214
+ return [];
215
+ }
216
+ switch (data.type) {
217
+ case 'response.output_text.delta':
218
+ return typeof data.delta === 'string' && data.delta
219
+ ? [{ type: 'text_delta', delta: data.delta }]
220
+ : [];
221
+ case 'response.reasoning_summary_text.delta':
222
+ case 'response.reasoning_text.delta':
223
+ return typeof data.delta === 'string' && data.delta
224
+ ? [{ type: 'reasoning_delta', field: 'reasoning', delta: data.delta }]
225
+ : [];
226
+ case 'response.output_item.added': {
227
+ const item = data.item;
228
+ if (item?.type !== 'function_call')
229
+ return [];
230
+ sawToolCall = true;
231
+ // id 用 call_id(回传 function_call_output 时必须对得上),不是 fc_ 的 item id。
232
+ return [{ type: 'tool_call_delta', index: indexFor(data.output_index ?? 0), id: item.call_id, name: item.name }];
233
+ }
234
+ case 'response.function_call_arguments.delta': {
235
+ const outputIndex = data.output_index ?? 0;
236
+ if (typeof data.delta !== 'string' || !data.delta)
237
+ return [];
238
+ streamedArguments.add(outputIndex);
239
+ return [{ type: 'tool_call_delta', index: indexFor(outputIndex), argumentsDelta: data.delta }];
240
+ }
241
+ case 'response.output_item.done': {
242
+ // 兜底:有的网关不发 arguments.delta,只在条目完成时给完整 arguments。
243
+ const item = data.item;
244
+ const outputIndex = data.output_index ?? 0;
245
+ if (item?.type !== 'function_call' || streamedArguments.has(outputIndex))
246
+ return [];
247
+ sawToolCall = true;
248
+ const arguments_ = typeof item.arguments === 'string' ? item.arguments : '';
249
+ return arguments_
250
+ ? [{ type: 'tool_call_delta', index: indexFor(outputIndex), id: item.call_id, name: item.name, argumentsDelta: arguments_ }]
251
+ : [];
252
+ }
253
+ case 'response.completed':
254
+ readUsage(data.response?.usage);
255
+ stopReason = sawToolCall ? 'tool_calls' : 'stop';
256
+ return [done()];
257
+ case 'response.incomplete': {
258
+ readUsage(data.response?.usage);
259
+ const reason = data.response?.incomplete_details?.reason;
260
+ stopReason = reason === 'max_output_tokens' ? 'length' : sawToolCall ? 'tool_calls' : 'unknown';
261
+ return [done()];
262
+ }
263
+ case 'response.failed': {
264
+ const error = data.response?.error;
265
+ throw new Error(`OpenAI Responses 流式响应失败:${JSON.stringify(error ?? data.response ?? data)}`);
266
+ }
267
+ case 'error':
268
+ throw new Error(`OpenAI Responses 流式响应报错:${JSON.stringify(data.error ?? data)}`);
269
+ default:
270
+ // response.created / in_progress / content_part.* / output_text.done 等:已知安全的 no-op。
271
+ return [];
272
+ }
273
+ },
274
+ };
275
+ }