micro-models-agent 0.63.0 → 1.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.
Files changed (186) hide show
  1. package/CHANGELOG.md +174 -0
  2. package/dist/cli/cache-line.js +30 -0
  3. package/dist/cli/command-suggest.js +38 -0
  4. package/dist/cli/commands.js +285 -60
  5. package/dist/cli/completer.js +16 -16
  6. package/dist/cli/json-payload.js +32 -0
  7. package/dist/cli/main.js +165 -77
  8. package/dist/cli/plugin-commands.js +5 -4
  9. package/dist/cli/relaunch.js +37 -0
  10. package/dist/cli/repl-commands.js +441 -307
  11. package/dist/cli/repl.js +360 -83
  12. package/dist/cli/run-result.js +12 -6
  13. package/dist/cli/security-commands.js +64 -60
  14. package/dist/cli/setup-order.js +57 -0
  15. package/dist/cli/setup-prompt.js +49 -0
  16. package/dist/cli/setup.js +52 -48
  17. package/dist/config/budget.js +48 -0
  18. package/dist/config/config.js +132 -70
  19. package/dist/config/defaults.js +37 -11
  20. package/dist/config/domains.js +9 -50
  21. package/dist/config/utils.js +56 -0
  22. package/dist/core/agent/audit-gate.js +49 -0
  23. package/dist/core/agent/compaction.js +89 -0
  24. package/dist/core/agent/constants.js +61 -0
  25. package/dist/core/agent/context-renderer.js +40 -0
  26. package/dist/core/agent/hallucination-gate.js +87 -0
  27. package/dist/core/agent/loop-state.js +53 -0
  28. package/dist/core/agent/prefix-monitor.js +101 -0
  29. package/dist/core/agent/reasoning-resolver.js +56 -0
  30. package/dist/core/agent/token-tracker.js +96 -0
  31. package/dist/core/agent/tool-batch.js +237 -0
  32. package/dist/core/agent/tool-output.js +62 -0
  33. package/dist/core/agent-moe.js +214 -69
  34. package/dist/core/agent.js +506 -546
  35. package/dist/core/bootstrap.js +297 -98
  36. package/dist/core/crash-handler.js +2 -1
  37. package/dist/core/prompt-builder.js +3 -0
  38. package/dist/core/prompt-overflow.js +307 -0
  39. package/dist/core/session-logger.js +34 -2
  40. package/dist/i18n/en.json +8 -4
  41. package/dist/i18n/ru.json +8 -4
  42. package/dist/index.js +5 -1
  43. package/dist/llm/cache-usage.js +76 -0
  44. package/dist/llm/image-utils.js +20 -16
  45. package/dist/llm/llm-errors.js +41 -0
  46. package/dist/llm/model-loader.js +30 -0
  47. package/dist/llm/openai-compat.js +287 -101
  48. package/dist/llm/orchestrator.js +140 -68
  49. package/dist/llm/provider-budget.js +68 -0
  50. package/dist/llm/provider.js +0 -1
  51. package/dist/llm/stream-state.js +26 -0
  52. package/dist/llm/token-counter.js +28 -0
  53. package/dist/logger/app-logger.js +12 -15
  54. package/dist/main.js +1755 -841
  55. package/dist/migration/detect.js +3 -1
  56. package/dist/modules/browser/actions.js +0 -3
  57. package/dist/modules/browser/bridge-client.js +2 -0
  58. package/dist/modules/browser/bridge-server.mjs +37 -4
  59. package/dist/modules/browser/driver.js +46 -4
  60. package/dist/modules/certification/cli.js +85 -42
  61. package/dist/modules/certification/loader.js +15 -1
  62. package/dist/modules/certification/manifest.js +126 -15
  63. package/dist/modules/certification/runner.js +4 -26
  64. package/dist/modules/certification/scenarios.js +184 -5
  65. package/dist/modules/certification/syntax-scenarios.js +51 -0
  66. package/dist/modules/context/chunk-query.js +25 -5
  67. package/dist/modules/context/fact-extractor.js +6 -2
  68. package/dist/modules/context/manager.js +23 -7
  69. package/dist/modules/execution/audit-runners.js +7 -1
  70. package/dist/modules/execution/auditor.js +3 -3
  71. package/dist/modules/execution/execution-plugin.js +22 -15
  72. package/dist/modules/execution/input-from.js +46 -0
  73. package/dist/modules/execution/module.js +107 -18
  74. package/dist/modules/execution/moe-executor.js +166 -54
  75. package/dist/modules/execution/plan-actions.js +524 -0
  76. package/dist/modules/execution/plan-steps.js +23 -0
  77. package/dist/modules/execution/plan-store.js +15 -3
  78. package/dist/modules/execution/plan-tool.js +6 -488
  79. package/dist/modules/execution/plan-validator.js +24 -0
  80. package/dist/modules/execution/stuck-detector.js +3 -18
  81. package/dist/modules/execution/tracker.js +14 -5
  82. package/dist/modules/execution/transient-error.js +30 -0
  83. package/dist/modules/execution/verifier.js +94 -7
  84. package/dist/modules/execution/windows-commands.js +11 -0
  85. package/dist/modules/hallucination/confidence.js +36 -23
  86. package/dist/modules/hallucination/consistency.js +3 -0
  87. package/dist/modules/hallucination/detector.js +8 -3
  88. package/dist/modules/hallucination/factual.js +26 -7
  89. package/dist/modules/hallucination/llm-judge.js +12 -2
  90. package/dist/modules/indexer/map-command.js +35 -0
  91. package/dist/modules/indexer/map-select.js +87 -0
  92. package/dist/modules/indexer/module.js +34 -22
  93. package/dist/modules/indexer/symbols.js +189 -0
  94. package/dist/modules/indexer/walker.js +96 -42
  95. package/dist/modules/lsp/check-tool.js +2 -1
  96. package/dist/modules/lsp/client.js +49 -32
  97. package/dist/modules/lsp/config.js +55 -2
  98. package/dist/modules/lsp/module.js +38 -5
  99. package/dist/modules/lsp/probe.js +4 -3
  100. package/dist/modules/lsp/project-root.js +41 -1
  101. package/dist/modules/lsp/startup-check.js +12 -4
  102. package/dist/modules/mcp/client.js +153 -104
  103. package/dist/modules/mcp/module.js +165 -41
  104. package/dist/modules/memory/module.js +4 -3
  105. package/dist/modules/plugins/builtin/lint-on-write.js +36 -6
  106. package/dist/modules/plugins/manager.js +47 -84
  107. package/dist/modules/pricing/index.js +17 -7
  108. package/dist/modules/pricing/prices.js +30 -12
  109. package/dist/modules/processes/index.js +1 -0
  110. package/dist/modules/processes/kill-tree.js +56 -0
  111. package/dist/modules/processes/registry.js +2 -54
  112. package/dist/modules/providers/cache.js +23 -0
  113. package/dist/modules/providers/factory.js +28 -0
  114. package/dist/modules/providers/fallback.js +7 -5
  115. package/dist/modules/providers/health.js +2 -1
  116. package/dist/modules/providers/index.js +1 -0
  117. package/dist/modules/providers/manager.js +17 -2
  118. package/dist/modules/providers/presets.js +79 -6
  119. package/dist/modules/reasoning/policy.js +40 -0
  120. package/dist/modules/reasoning/probe.js +111 -0
  121. package/dist/modules/security/audit-notifier.js +42 -27
  122. package/dist/modules/security/command-validator.js +25 -20
  123. package/dist/modules/security/encryption.js +6 -12
  124. package/dist/modules/security/network-validator.js +76 -5
  125. package/dist/modules/security/path-validator.js +77 -34
  126. package/dist/modules/security/rate-limiter.js +11 -0
  127. package/dist/modules/security/security-policies.js +1 -1
  128. package/dist/modules/security/session-encryption.js +13 -2
  129. package/dist/modules/security/session-isolation.js +2 -9
  130. package/dist/modules/session/manager.js +11 -0
  131. package/dist/modules/session/module.js +11 -3
  132. package/dist/modules/session/store.js +41 -5
  133. package/dist/modules/skills/loader.js +7 -1
  134. package/dist/modules/skills/module.js +2 -1
  135. package/dist/modules/updater/changelog-reader.js +94 -0
  136. package/dist/modules/updater/dev-detect.js +17 -0
  137. package/dist/modules/updater/index.js +1 -0
  138. package/dist/modules/updater/module.js +14 -3
  139. package/dist/output/bus.js +32 -0
  140. package/dist/output/channel.js +233 -0
  141. package/dist/output/format.js +14 -0
  142. package/dist/output/index.js +7 -0
  143. package/dist/output/json-sink.js +22 -0
  144. package/dist/output/machine.js +8 -0
  145. package/dist/output/session-sink.js +27 -0
  146. package/dist/output/types.js +1 -0
  147. package/dist/tools/approve.js +6 -2
  148. package/dist/tools/attach-image.js +11 -11
  149. package/dist/tools/auto-fixer.js +198 -0
  150. package/dist/tools/bash.js +142 -89
  151. package/dist/tools/chunk-query.js +10 -6
  152. package/dist/tools/download-file.js +1 -1
  153. package/dist/tools/edit-file.js +20 -2
  154. package/dist/tools/executor.js +54 -9
  155. package/dist/tools/glob-tool.js +7 -0
  156. package/dist/tools/grep-tool.js +15 -1
  157. package/dist/tools/index.js +3 -1
  158. package/dist/tools/list-dir.js +3 -1
  159. package/dist/tools/load-skill.js +2 -1
  160. package/dist/tools/mcp-call.js +1 -1
  161. package/dist/tools/move-file.js +5 -4
  162. package/dist/tools/path-utils.js +7 -0
  163. package/dist/tools/pipeline-run.js +1 -1
  164. package/dist/tools/prompt-io.js +28 -0
  165. package/dist/tools/question.js +12 -12
  166. package/dist/tools/scope-request.js +91 -0
  167. package/dist/tools/session-info.js +44 -0
  168. package/dist/tools/set-thinking.js +71 -0
  169. package/dist/tools/subagent.js +50 -9
  170. package/dist/tools/syntax-validator.js +177 -0
  171. package/dist/tools/user-input.js +16 -9
  172. package/dist/tools/write-file.js +17 -1
  173. package/dist/ui/diff.js +10 -0
  174. package/dist/ui/line-editor.js +179 -26
  175. package/dist/ui/line-math.js +20 -3
  176. package/dist/ui/md-formatter.js +100 -10
  177. package/dist/ui/output.js +5 -4
  178. package/dist/ui/plan-view.js +2 -7
  179. package/dist/ui/renderer.js +89 -85
  180. package/dist/ui/spinner.js +14 -4
  181. package/dist/utils/error.js +4 -0
  182. package/dist/utils/index.js +4 -0
  183. package/dist/utils/retry.js +17 -0
  184. package/dist/utils/sleep.js +23 -0
  185. package/dist/utils/truncate.js +9 -0
  186. package/package.json +1 -1
@@ -1,7 +1,26 @@
1
1
  import { TokenCounter } from "./token-counter";
2
2
  import { t } from "../i18n/index";
3
+ import { readMmaVersion } from "../core/version";
3
4
  import { createRateLimiter } from "../modules/security/rate-limiter";
5
+ import { LlmError } from "./llm-errors";
6
+ import { backoffDelay } from "../utils/retry";
7
+ import { StreamState } from "./stream-state";
8
+ import { parseCacheUsage } from "./cache-usage";
4
9
  const REQUEST_TIMEOUT_MS = 120000;
10
+ /** Max wait (ms) for a self-inflicted rate-limit slot before giving up. */
11
+ const MAX_RATE_WAIT_MS = 30_000;
12
+ /**
13
+ * Client identity header. OpenCode Go asks clients to identify themselves with
14
+ * their own user agent (not a generic SDK/HTTP-library name) so requests route
15
+ * to coding-agent traffic. `readMmaVersion()` is memoized — every provider
16
+ * construction would otherwise re-read package.json.
17
+ */
18
+ let cachedUserAgent = null;
19
+ function defaultUserAgent() {
20
+ if (cachedUserAgent === null)
21
+ cachedUserAgent = `micro-models-agent/${readMmaVersion()}`;
22
+ return cachedUserAgent;
23
+ }
5
24
  function buildRequestBody(opts) {
6
25
  const body = {
7
26
  model: opts.model,
@@ -10,9 +29,29 @@ function buildRequestBody(opts) {
10
29
  };
11
30
  if (opts.maxTokens !== undefined)
12
31
  body.max_tokens = opts.maxTokens;
13
- if (opts.reasoningEffort) {
14
- body.reasoning_effort = opts.reasoningEffort;
32
+ if (opts.cachePrompt)
33
+ body.cache_prompt = true;
34
+ if (opts.promptCacheKey)
35
+ body.prompt_cache_key = opts.promptCacheKey;
36
+ if (opts.sessionId)
37
+ body.session_id = opts.sessionId;
38
+ if (opts.stream && opts.streamUsage)
39
+ body.stream_options = { include_usage: true };
40
+ // Apply reasoning effort via the provider's strategy
41
+ const strategy = opts.reasoningStrategy ?? "openai-effort";
42
+ const level = opts.reasoningEffort;
43
+ if (strategy === "openai-effort" && level && level !== "default") {
44
+ body.reasoning_effort = level;
45
+ }
46
+ else if (strategy === "template-kwarg") {
47
+ body.chat_template_kwargs = { enable_thinking: level !== "none" };
48
+ }
49
+ else if (strategy === "prompt-tag" && level && level !== "default") {
50
+ // Send reasoning_effort in body (models that respect it via API) AND
51
+ // add /no_think tag to message (models that only respect prompt signals).
52
+ body.reasoning_effort = level;
15
53
  }
54
+ // "none" = unsupported, skip
16
55
  if (opts.tools && opts.tools.length > 0) {
17
56
  body.tools = opts.tools.map((t) => ({
18
57
  type: "function",
@@ -33,10 +72,19 @@ export class OpenAICompatProvider {
33
72
  tokenCounter;
34
73
  retryConfig;
35
74
  rateLimiter;
75
+ debug;
76
+ getSessionId;
77
+ userAgent;
78
+ cache;
79
+ cacheReport;
36
80
  constructor(config) {
37
81
  this.config = config;
38
82
  this.model = config.model;
39
83
  this.contextWindow = config.contextWindow ?? 32768;
84
+ this.cache = config.cache;
85
+ // Без capability всё равно читаем OpenAI-совместимые поля: это не меняет
86
+ // запрос и работает для локальных/кастомных серверов.
87
+ this.cacheReport = config.cache?.report ?? "openai";
40
88
  this.tokenCounter = new TokenCounter();
41
89
  this.retryConfig = config.retry ?? {
42
90
  maxRetries: 3,
@@ -46,31 +94,80 @@ export class OpenAICompatProvider {
46
94
  noDataTimeoutMs: 180000,
47
95
  };
48
96
  this.rateLimiter = createRateLimiter(config.rateLimits);
97
+ this.getSessionId = config.getSessionId;
98
+ this.userAgent = config.userAgent ?? defaultUserAgent();
99
+ this.debug = config.logger ? config.logger.debug.bind(config.logger) : null;
49
100
  }
50
101
  async *chat(messages, tools, signal, options) {
51
- // Check rate limit before making request
102
+ // Check rate limit before making request. A self-inflicted limit should
103
+ // wait for a slot (bounded) instead of aborting a healthy turn; only if
104
+ // no slot frees within the window do we surface a recoverable error the
105
+ // agent loop can feed back (rather than dying).
52
106
  if (!this.rateLimiter.canMakeRequest()) {
53
- throw new Error(`Rate limit exceeded: ${this.rateLimiter.getConfig().maxRequestsPerMinute} requests per minute`);
107
+ const waitMs = Math.min(this.rateLimiter.msUntilRequest(), MAX_RATE_WAIT_MS);
108
+ if (waitMs > 0) {
109
+ await this.sleep(waitMs, signal);
110
+ }
111
+ if (!this.rateLimiter.canMakeRequest()) {
112
+ throw new LlmError(`Rate limit exceeded: ${this.rateLimiter.getConfig().maxRequestsPerMinute} requests per minute`, { recoverable: true });
113
+ }
54
114
  }
55
115
  // Record this request
56
116
  this.rateLimiter.recordRequest();
57
- const streamResult = this.doStream(messages, tools, signal, options);
117
+ // Apply prompt-tag strategy: append /no_think to last user message (outgoing copy only)
118
+ let effectiveMessages = messages;
119
+ if (options?.reasoningStrategy === "prompt-tag" && options?.reasoningEffort === "none") {
120
+ effectiveMessages = this.applyPromptTag(messages, "/no_think");
121
+ }
122
+ const streamResult = this.doStream(effectiveMessages, tools, signal, options);
58
123
  let hasToolCall = false;
59
124
  let hasText = false;
125
+ let hasReasoning = false;
60
126
  for await (const chunk of streamResult) {
61
127
  if (chunk.type === "tool_call")
62
128
  hasToolCall = true;
63
129
  if (chunk.type === "text")
64
130
  hasText = true;
131
+ if (chunk.type === "reasoning")
132
+ hasReasoning = true;
65
133
  yield chunk;
66
134
  }
135
+ // A stream that ended with reasoning but no text/tool_call means the model
136
+ // THOUGHT but never produced an answer (observed: qwen3.5-9b in LM Studio
137
+ // emits reasoning_content then stops). The streamed reasoning was already
138
+ // delivered; fetch the missing answer via non-streaming. The fallback's
139
+ // own reasoning chunk is dropped so thinking is not shown twice — no text
140
+ // was emitted, so there is no duplicate-answer risk.
67
141
  if (!hasToolCall && !hasText) {
68
- const fallback = await this.doNonStreaming(messages, tools, signal, options);
142
+ this.debug?.("LLM streaming produced no text/tool_call falling back to non-streaming");
143
+ const fallback = await this.doNonStreaming(effectiveMessages, tools, signal, options);
144
+ this.debug?.("LLM non-streaming fallback result", { chunks: fallback.length });
69
145
  for (const chunk of fallback) {
146
+ if (hasReasoning && chunk.type === "reasoning")
147
+ continue;
70
148
  yield chunk;
71
149
  }
72
150
  }
73
151
  }
152
+ /** Append a tag to the last user message (outgoing copy, never stored in context). */
153
+ applyPromptTag(messages, tag) {
154
+ const copy = [...messages];
155
+ for (let i = copy.length - 1; i >= 0; i--) {
156
+ if (copy[i].role === "user") {
157
+ const msg = { ...copy[i] };
158
+ if (typeof msg.content === "string") {
159
+ msg.content = msg.content + "\n" + tag;
160
+ }
161
+ else {
162
+ // Array of parts — append a text part
163
+ msg.content = [...msg.content, { type: "text", text: tag }];
164
+ }
165
+ copy[i] = msg;
166
+ break;
167
+ }
168
+ }
169
+ return copy;
170
+ }
74
171
  async *doStream(messages, tools, signal, options) {
75
172
  const { baseDelay, maxDelay, maxStreamRetries, noDataTimeoutMs } = this.retryConfig;
76
173
  const streamRetries = maxStreamRetries ?? 2;
@@ -90,16 +187,15 @@ export class OpenAICompatProvider {
90
187
  return;
91
188
  }
92
189
  catch (err) {
93
- if (err?.name === "AbortError" || err?.llmTerminal || signal?.aborted)
190
+ if (err?.name === "AbortError" || (err instanceof LlmError && err.terminal) || signal?.aborted)
94
191
  throw err;
95
192
  // Content was already delivered — a re-stream would duplicate chunks.
96
193
  if (emitted || attempt >= streamRetries)
97
194
  throw err;
98
195
  // Connection dropped before any content → retry the whole request.
99
196
  }
100
- const delay = Math.min(baseDelay * Math.pow(2, attempt), maxDelay);
101
- const jitter = Math.random() * baseDelay * 0.1;
102
- await this.sleep(delay + jitter, signal);
197
+ // Экспоненциальный backoff с джиттером (общий хелпер из src/utils).
198
+ await this.sleep(backoffDelay(attempt, baseDelay, maxDelay, 0.1), signal);
103
199
  }
104
200
  }
105
201
  async *streamOnce(messages, tools, signal, options, onEmit, idleTimeoutMs) {
@@ -111,6 +207,18 @@ export class OpenAICompatProvider {
111
207
  stream: true,
112
208
  maxTokens,
113
209
  reasoningEffort: options?.reasoningEffort,
210
+ reasoningStrategy: options?.reasoningStrategy,
211
+ ...this.cacheHints(),
212
+ });
213
+ this.debug?.("LLM stream request", {
214
+ baseUrl: this.config.baseUrl,
215
+ model: this.model,
216
+ stream: true,
217
+ maxTokens,
218
+ toolsCount: tools?.length ?? 0,
219
+ reasoningEffort: options?.reasoningEffort,
220
+ reasoningStrategy: options?.reasoningStrategy,
221
+ messagesCount: messages.length,
114
222
  });
115
223
  const { headers, abortSignal, cleanup, isTimeout, flagTimeout, controller } = this.buildRequestSetup(signal);
116
224
  let response;
@@ -128,20 +236,26 @@ export class OpenAICompatProvider {
128
236
  if (err?.name === "AbortError")
129
237
  throw err;
130
238
  const wrapped = err instanceof Error ? err : new Error(String(err));
131
- wrapped.llmTerminal = true;
132
- throw wrapped;
239
+ if (wrapped instanceof LlmError) {
240
+ wrapped.markTerminal();
241
+ throw wrapped;
242
+ }
243
+ throw new LlmError(wrapped.message).markTerminal();
133
244
  }
134
245
  if (!response.ok) {
135
246
  cleanup();
136
247
  const errorText = await response.text();
137
- const err = new Error(t("error.llm_api", {
248
+ throw new LlmError(t("error.llm_api", {
138
249
  status: response.status,
139
250
  statusText: response.statusText,
140
251
  errorText,
141
- }));
142
- err.llmTerminal = true;
143
- throw err;
252
+ }), { terminal: true });
144
253
  }
254
+ const contentType = response.headers?.get?.("content-type") ?? "";
255
+ this.debug?.("LLM stream response headers", {
256
+ status: response.status,
257
+ contentType,
258
+ });
145
259
  const reader = response.body?.getReader();
146
260
  if (!reader) {
147
261
  cleanup();
@@ -150,14 +264,8 @@ export class OpenAICompatProvider {
150
264
  const decoder = new TextDecoder();
151
265
  let buffer = "";
152
266
  const toolCallAccs = new Map();
153
- // A tool_call started accumulating the server committed to a response.
154
- // If the stream stalls after this, the provider most likely buffers SSE
155
- // instead of streaming argument deltas (seen with LM Studio).
156
- let sawToolCallStart = false;
157
- let usage;
158
- let sawDone = false;
159
- let lastFinishReason;
160
- let sawText = false;
267
+ // Единый контейнер состояния стрима (вместо 9 разрозненных флагов).
268
+ const st = new StreamState();
161
269
  const readIdle = () => new Promise((resolve, reject) => {
162
270
  const idleTimer = setTimeout(() => {
163
271
  flagTimeout();
@@ -166,13 +274,13 @@ export class OpenAICompatProvider {
166
274
  reader.read().then((result) => {
167
275
  clearTimeout(idleTimer);
168
276
  if (isTimeout())
169
- reject(new Error(t(sawToolCallStart ? "error.llm_stream_idle_toolcall" : "error.llm_stream_idle", { timeout: idleTimeoutMs })));
277
+ reject(new Error(t(st.sawToolCallStart ? "error.llm_stream_idle_toolcall" : "error.llm_stream_idle", { timeout: idleTimeoutMs })));
170
278
  else
171
279
  resolve(result);
172
280
  }, (err) => {
173
281
  clearTimeout(idleTimer);
174
282
  if (isTimeout())
175
- reject(new Error(t(sawToolCallStart ? "error.llm_stream_idle_toolcall" : "error.llm_stream_idle", { timeout: idleTimeoutMs })));
283
+ reject(new Error(t(st.sawToolCallStart ? "error.llm_stream_idle_toolcall" : "error.llm_stream_idle", { timeout: idleTimeoutMs })));
176
284
  else
177
285
  reject(err);
178
286
  });
@@ -187,116 +295,180 @@ export class OpenAICompatProvider {
187
295
  buffer = lines.pop() || "";
188
296
  for (const line of lines) {
189
297
  const trimmed = line.trim();
190
- if (!trimmed || !trimmed.startsWith("data: "))
191
- continue;
192
- const data = trimmed.slice(6);
193
- if (data === "[DONE]") {
194
- sawDone = true;
298
+ if (!trimmed)
195
299
  continue;
300
+ let data;
301
+ if (trimmed.startsWith("data: ")) {
302
+ data = trimmed.slice(6);
303
+ if (data === "[DONE]") {
304
+ st.sawDone = true;
305
+ continue;
306
+ }
307
+ }
308
+ else {
309
+ // Not SSE-framed — Ollama (and some other backends) may stream
310
+ // NDJSON or send mid-stream errors as bare JSON. Try to parse the
311
+ // raw line; unparseable noise (SSE comments, keep-alives) is
312
+ // counted and skipped.
313
+ st.nonDataLines++;
314
+ if (st.nonDataSamples.length < 5)
315
+ st.nonDataSamples.push(trimmed.slice(0, 200));
316
+ data = trimmed;
196
317
  }
197
318
  try {
198
319
  const parsed = JSON.parse(data);
320
+ st.parsedChunks++;
199
321
  const choice = parsed.choices?.[0];
200
322
  if (!choice) {
201
323
  // Usage comes in the last chunk with empty choices
202
324
  if (parsed.usage) {
203
- usage = {
325
+ st.usage = {
204
326
  promptTokens: parsed.usage.prompt_tokens ?? 0,
205
327
  completionTokens: parsed.usage.completion_tokens ?? 0,
206
328
  totalTokens: parsed.usage.total_tokens ?? 0,
329
+ cache: parseCacheUsage(parsed.usage, this.cacheReport),
207
330
  };
331
+ this.debug?.("LLM stream usage", { ...st.usage });
208
332
  }
209
- continue;
210
- }
211
- const delta = choice.delta || {};
212
- const finishReason = choice.finish_reason;
213
- if (finishReason)
214
- lastFinishReason = finishReason;
215
- if (delta.reasoning_content) {
216
- onEmit();
217
- yield { type: "reasoning", content: delta.reasoning_content };
333
+ else if (parsed.error) {
334
+ // Mid-stream provider error (Ollama sends {"error": "..."} as
335
+ // bare JSON once generation already started). Surface it —
336
+ // swallowing it makes the response look silently empty.
337
+ // Thrown OUTSIDE the JSON-parse try/catch below.
338
+ st.midStreamError = String(parsed.error);
339
+ }
340
+ // NOTE: no `continue` here — it would skip the midStreamError
341
+ // throw below (which lives outside this try/catch).
218
342
  }
219
- if (delta.tool_calls) {
220
- sawToolCallStart = true;
221
- for (const tc of delta.tool_calls) {
222
- const idx = tc.index ?? 0;
223
- if (!toolCallAccs.has(idx)) {
224
- toolCallAccs.set(idx, { id: "", name: "", arguments: "" });
225
- }
226
- const acc = toolCallAccs.get(idx);
227
- if (tc.id)
228
- acc.id = tc.id;
229
- if (tc.function?.name)
230
- acc.name = tc.function.name;
231
- if (tc.function?.arguments) {
232
- acc.arguments += tc.function.arguments;
343
+ else {
344
+ if (st.parsedChunks <= 3 || st.parsedChunks % 50 === 0) {
345
+ const d = choice.delta ?? {};
346
+ this.debug?.("LLM stream chunk", {
347
+ n: st.parsedChunks,
348
+ finishReason: choice.finish_reason ?? null,
349
+ deltaKeys: Object.keys(d),
350
+ });
351
+ }
352
+ const delta = choice.delta || {};
353
+ const finishReason = choice.finish_reason;
354
+ if (finishReason)
355
+ st.lastFinishReason = finishReason;
356
+ // `reasoning` is the Ollama alias for OpenAI's `reasoning_content`.
357
+ const reasoningDelta = delta.reasoning_content ?? delta.reasoning;
358
+ if (reasoningDelta) {
359
+ onEmit();
360
+ yield { type: "reasoning", content: reasoningDelta };
361
+ }
362
+ if (delta.tool_calls) {
363
+ st.sawToolCallStart = true;
364
+ for (const tc of delta.tool_calls) {
365
+ const idx = tc.index ?? 0;
366
+ if (!toolCallAccs.has(idx)) {
367
+ toolCallAccs.set(idx, { id: "", name: "", arguments: "" });
368
+ }
369
+ const acc = toolCallAccs.get(idx);
370
+ if (tc.id)
371
+ acc.id = tc.id;
372
+ if (tc.function?.name)
373
+ acc.name = tc.function.name;
374
+ if (tc.function?.arguments) {
375
+ acc.arguments += tc.function.arguments;
376
+ }
233
377
  }
234
378
  }
235
- }
236
- if (delta.content) {
237
- onEmit();
238
- sawText = true;
239
- yield { type: "text", content: delta.content };
240
- }
241
- if (finishReason === "tool_calls" && toolCallAccs.size > 0) {
242
- for (const [, acc] of toolCallAccs) {
243
- if (acc.name) {
244
- onEmit();
245
- yield {
246
- type: "tool_call",
247
- toolCall: {
248
- id: acc.id,
249
- name: acc.name,
250
- arguments: acc.arguments || "{}",
251
- },
252
- };
379
+ if (delta.content) {
380
+ onEmit();
381
+ st.sawText = true;
382
+ yield { type: "text", content: delta.content };
383
+ }
384
+ if (finishReason === "tool_calls" && toolCallAccs.size > 0) {
385
+ for (const [, acc] of toolCallAccs) {
386
+ if (acc.name) {
387
+ onEmit();
388
+ yield {
389
+ type: "tool_call",
390
+ toolCall: {
391
+ id: acc.id,
392
+ name: acc.name,
393
+ arguments: acc.arguments || "{}",
394
+ },
395
+ };
396
+ }
253
397
  }
398
+ toolCallAccs.clear();
254
399
  }
255
- toolCallAccs.clear();
256
400
  }
257
401
  }
258
402
  catch {
259
403
  // Skip malformed JSON lines
260
404
  }
405
+ if (st.midStreamError) {
406
+ throw new Error(t("error.llm_provider_stream_error", { error: st.midStreamError.slice(0, 300) }));
407
+ }
261
408
  }
262
409
  }
263
- if (usage) {
410
+ if (st.usage) {
264
411
  onEmit();
265
- yield { type: "done", usage };
412
+ yield { type: "done", usage: st.usage };
266
413
  }
267
414
  // finish_reason "length": the completion hit the token limit. An
268
415
  // unfinished tool_call would otherwise be silently swallowed (tool_calls
269
416
  // are only yielded on finish_reason "tool_calls") and surface as an
270
417
  // empty response → blind hallucination retries. Surface the real cause.
271
- if (lastFinishReason === "length") {
272
- const truncatedToolCall = sawToolCallStart && toolCallAccs.size > 0;
273
- if (truncatedToolCall || !sawText) {
274
- const err = new Error(t(truncatedToolCall ? "error.llm_truncated_toolcall" : "error.llm_truncated", {
418
+ if (st.lastFinishReason === "length") {
419
+ const truncatedToolCall = st.sawToolCallStart && toolCallAccs.size > 0;
420
+ if (truncatedToolCall || !st.sawText) {
421
+ // Провайдер в порядке модель просто превысила completion-лимит.
422
+ // terminal: стрим не ретраим (он завершился корректно); recoverable:
423
+ // агентный цикл вернёт причину модели, чтобы она адаптировалась
424
+ // (разбила вывод) вместо смерти сессии.
425
+ throw new LlmError(t(truncatedToolCall ? "error.llm_truncated_toolcall" : "error.llm_truncated", {
275
426
  tokens: maxTokens,
276
- }));
277
- err.llmTerminal = true;
278
- // The provider is fine — the model just overran the completion
279
- // limit. The agent loop feeds this back so the model can adapt
280
- // (split the output) instead of the session dying.
281
- err.recoverableLlm = true;
282
- throw err;
427
+ }), { terminal: true, recoverable: true });
283
428
  }
429
+ // Текст уже устримлен и не завершён — не глушим ответ, но и не
430
+ // принимаем обрезок молча: отдаём видимое предупреждение.
431
+ yield {
432
+ type: "warning",
433
+ content: t("error.llm_output_truncated", { tokens: maxTokens }),
434
+ };
284
435
  }
285
436
  }
286
437
  finally {
287
438
  cleanup();
288
439
  reader.releaseLock();
440
+ this.debug?.("LLM stream finished", st.snapshot());
289
441
  }
290
- return sawDone;
442
+ return st.sawDone;
443
+ }
444
+ /**
445
+ * Вычисляет запросные подсказки кеша из возможностей провайдера и текущего
446
+ * session id. Без capability возвращает «ничего», сохраняя прежнее тело.
447
+ */
448
+ cacheHints() {
449
+ const sessionId = this.getSessionId?.();
450
+ return {
451
+ cachePrompt: this.cache?.requestCachePrompt === true,
452
+ promptCacheKey: this.cache?.requestPromptCacheKey ? sessionId : undefined,
453
+ sessionId: this.cache?.requestSessionId ? sessionId : undefined,
454
+ streamUsage: this.cache?.requestStreamUsage === true,
455
+ };
291
456
  }
292
457
  /** Shared request setup: auth headers + timeout/abort controller wiring. */
293
458
  buildRequestSetup(signal) {
294
459
  const headers = {
295
460
  "Content-Type": "application/json",
461
+ "User-Agent": this.userAgent,
296
462
  };
297
463
  if (this.config.apiKey && this.config.apiKey !== "not-needed") {
298
464
  headers["Authorization"] = `Bearer ${this.config.apiKey}`;
299
465
  }
466
+ const sessionId = this.getSessionId?.();
467
+ // Когда capability задана — шлём заголовок только тем, кому он нужен
468
+ // (Zen/Go). Без capability сохраняем легаси-поведение.
469
+ const sendSessionHeader = this.cache ? this.cache.sessionHeader : true;
470
+ if (sessionId && sendSessionHeader)
471
+ headers["x-opencode-session"] = sessionId;
300
472
  const controller = new AbortController();
301
473
  let timedOut = false;
302
474
  const timeoutId = setTimeout(() => {
@@ -328,13 +500,16 @@ export class OpenAICompatProvider {
328
500
  };
329
501
  }
330
502
  async doNonStreaming(messages, tools, signal, options) {
503
+ const maxTokens = options?.maxTokens ?? this.config.maxCompletionTokens ?? 4096;
331
504
  const body = buildRequestBody({
332
505
  model: this.model,
333
506
  messages,
334
507
  tools,
335
508
  stream: false,
336
- maxTokens: options?.maxTokens ?? this.config.maxCompletionTokens ?? 4096,
509
+ maxTokens,
337
510
  reasoningEffort: options?.reasoningEffort,
511
+ reasoningStrategy: options?.reasoningStrategy,
512
+ ...this.cacheHints(),
338
513
  });
339
514
  const { headers, abortSignal, cleanup, isTimeout } = this.buildRequestSetup(signal);
340
515
  try {
@@ -353,14 +528,19 @@ export class OpenAICompatProvider {
353
528
  }));
354
529
  }
355
530
  const data = await response.json();
531
+ // Some backends (e.g. Ollama) return HTTP 200 with {"error": "..."}.
532
+ if (data.error && !data.choices) {
533
+ throw new Error(t("error.llm_provider_stream_error", { error: String(data.error).slice(0, 300) }));
534
+ }
356
535
  const choice = data.choices?.[0];
357
536
  if (!choice) {
358
537
  return [];
359
538
  }
360
539
  const msg = choice.message || {};
361
540
  const chunks = [];
362
- if (msg.reasoning_content) {
363
- chunks.push({ type: "reasoning", content: msg.reasoning_content });
541
+ const reasoning = msg.reasoning_content ?? msg.reasoning;
542
+ if (reasoning) {
543
+ chunks.push({ type: "reasoning", content: reasoning });
364
544
  }
365
545
  if (msg.content) {
366
546
  chunks.push({ type: "text", content: msg.content });
@@ -377,6 +557,14 @@ export class OpenAICompatProvider {
377
557
  });
378
558
  }
379
559
  }
560
+ // Completed text but hit the cap: surface a visible warning instead of
561
+ // silently accepting a cut-off answer (see the streaming path).
562
+ if (choice.finish_reason === "length" && msg.content && !msg.tool_calls) {
563
+ chunks.push({
564
+ type: "warning",
565
+ content: t("error.llm_output_truncated", { tokens: maxTokens }),
566
+ });
567
+ }
380
568
  // Append usage from API response
381
569
  if (data.usage) {
382
570
  chunks.push({
@@ -385,6 +573,7 @@ export class OpenAICompatProvider {
385
573
  promptTokens: data.usage.prompt_tokens ?? 0,
386
574
  completionTokens: data.usage.completion_tokens ?? 0,
387
575
  totalTokens: data.usage.total_tokens ?? 0,
576
+ cache: parseCacheUsage(data.usage, this.cacheReport),
388
577
  },
389
578
  });
390
579
  }
@@ -408,6 +597,7 @@ export class OpenAICompatProvider {
408
597
  const url = `${this.config.baseUrl.replace(/\/+$/, "")}/models`;
409
598
  const headers = {
410
599
  "Content-Type": "application/json",
600
+ "User-Agent": this.userAgent,
411
601
  };
412
602
  if (this.config.apiKey && this.config.apiKey !== "not-needed") {
413
603
  headers["Authorization"] = `Bearer ${this.config.apiKey}`;
@@ -436,6 +626,7 @@ export class OpenAICompatProvider {
436
626
  let lastStatus = 0;
437
627
  let lastRetryAfter = 0;
438
628
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
629
+ lastRetryAfter = 0;
439
630
  try {
440
631
  const response = await fetch(url, init);
441
632
  if (!this.isRetryable(response.status))
@@ -456,22 +647,17 @@ export class OpenAICompatProvider {
456
647
  // otherwise exponential backoff. 429s from free tiers need a real wait.
457
648
  const delay = lastRetryAfter > 0
458
649
  ? Math.min(lastRetryAfter, maxDelay)
459
- : Math.min(baseDelay * Math.pow(2, attempt), maxDelay);
460
- const jitter = Math.random() * baseDelay * 0.1;
461
- await this.sleep(delay + jitter, init.signal ?? undefined);
650
+ : backoffDelay(attempt, baseDelay, maxDelay, 0.1);
651
+ await this.sleep(delay, init.signal ?? undefined);
462
652
  }
463
653
  }
464
654
  if (lastStatus === 429) {
465
- const e429 = new Error(t("error.llm_429", {
655
+ throw new LlmError(t("error.llm_429", {
466
656
  model: this.model,
467
657
  baseUrl: this.config.baseUrl,
468
- }));
469
- e429.llmStatus = lastStatus;
470
- throw e429;
658
+ }), { llmStatus: lastStatus });
471
659
  }
472
- const eRetry = new Error(t("error.llm_retries"));
473
- eRetry.llmStatus = lastStatus;
474
- throw eRetry;
660
+ throw new LlmError(t("error.llm_retries"), { llmStatus: lastStatus });
475
661
  }
476
662
  isRetryable(status) {
477
663
  return status === 429 || status >= 500;