micro-models-agent 0.63.3 → 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.
- package/CHANGELOG.md +148 -1
- package/dist/cli/cache-line.js +30 -0
- package/dist/cli/command-suggest.js +38 -0
- package/dist/cli/commands.js +285 -60
- package/dist/cli/completer.js +16 -16
- package/dist/cli/json-payload.js +32 -0
- package/dist/cli/main.js +165 -77
- package/dist/cli/plugin-commands.js +5 -4
- package/dist/cli/relaunch.js +37 -0
- package/dist/cli/repl-commands.js +441 -307
- package/dist/cli/repl.js +360 -83
- package/dist/cli/run-result.js +12 -6
- package/dist/cli/security-commands.js +64 -60
- package/dist/cli/setup-order.js +57 -0
- package/dist/cli/setup-prompt.js +49 -0
- package/dist/cli/setup.js +52 -48
- package/dist/config/budget.js +48 -0
- package/dist/config/config.js +132 -70
- package/dist/config/defaults.js +37 -11
- package/dist/config/domains.js +9 -50
- package/dist/config/utils.js +56 -0
- package/dist/core/agent/audit-gate.js +49 -0
- package/dist/core/agent/compaction.js +89 -0
- package/dist/core/agent/constants.js +61 -0
- package/dist/core/agent/context-renderer.js +40 -0
- package/dist/core/agent/hallucination-gate.js +87 -0
- package/dist/core/agent/loop-state.js +53 -0
- package/dist/core/agent/prefix-monitor.js +101 -0
- package/dist/core/agent/reasoning-resolver.js +56 -0
- package/dist/core/agent/token-tracker.js +96 -0
- package/dist/core/agent/tool-batch.js +237 -0
- package/dist/core/agent/tool-output.js +62 -0
- package/dist/core/agent-moe.js +214 -69
- package/dist/core/agent.js +506 -546
- package/dist/core/bootstrap.js +297 -98
- package/dist/core/crash-handler.js +2 -1
- package/dist/core/prompt-builder.js +3 -0
- package/dist/core/prompt-overflow.js +307 -0
- package/dist/core/session-logger.js +34 -2
- package/dist/i18n/en.json +7 -4
- package/dist/i18n/ru.json +7 -4
- package/dist/index.js +5 -1
- package/dist/llm/cache-usage.js +76 -0
- package/dist/llm/image-utils.js +20 -16
- package/dist/llm/llm-errors.js +41 -0
- package/dist/llm/model-loader.js +30 -0
- package/dist/llm/openai-compat.js +287 -101
- package/dist/llm/orchestrator.js +140 -68
- package/dist/llm/provider-budget.js +68 -0
- package/dist/llm/provider.js +0 -1
- package/dist/llm/stream-state.js +26 -0
- package/dist/llm/token-counter.js +28 -0
- package/dist/logger/app-logger.js +12 -15
- package/dist/main.js +1606 -800
- package/dist/migration/detect.js +3 -1
- package/dist/modules/browser/actions.js +0 -3
- package/dist/modules/browser/bridge-client.js +2 -0
- package/dist/modules/browser/driver.js +46 -4
- package/dist/modules/certification/cli.js +85 -42
- package/dist/modules/certification/loader.js +15 -1
- package/dist/modules/certification/manifest.js +126 -15
- package/dist/modules/certification/runner.js +4 -26
- package/dist/modules/certification/scenarios.js +184 -5
- package/dist/modules/certification/syntax-scenarios.js +51 -0
- package/dist/modules/context/chunk-query.js +25 -5
- package/dist/modules/context/fact-extractor.js +6 -2
- package/dist/modules/context/manager.js +23 -7
- package/dist/modules/execution/audit-runners.js +7 -1
- package/dist/modules/execution/auditor.js +3 -3
- package/dist/modules/execution/execution-plugin.js +22 -15
- package/dist/modules/execution/input-from.js +46 -0
- package/dist/modules/execution/module.js +107 -18
- package/dist/modules/execution/moe-executor.js +166 -54
- package/dist/modules/execution/plan-actions.js +524 -0
- package/dist/modules/execution/plan-steps.js +23 -0
- package/dist/modules/execution/plan-store.js +15 -3
- package/dist/modules/execution/plan-tool.js +6 -488
- package/dist/modules/execution/plan-validator.js +24 -0
- package/dist/modules/execution/stuck-detector.js +3 -18
- package/dist/modules/execution/tracker.js +14 -5
- package/dist/modules/execution/transient-error.js +30 -0
- package/dist/modules/execution/verifier.js +94 -7
- package/dist/modules/execution/windows-commands.js +11 -0
- package/dist/modules/hallucination/confidence.js +36 -23
- package/dist/modules/hallucination/consistency.js +3 -0
- package/dist/modules/hallucination/detector.js +8 -3
- package/dist/modules/hallucination/factual.js +26 -7
- package/dist/modules/hallucination/llm-judge.js +12 -2
- package/dist/modules/indexer/map-command.js +35 -0
- package/dist/modules/indexer/map-select.js +87 -0
- package/dist/modules/indexer/module.js +34 -22
- package/dist/modules/indexer/symbols.js +189 -0
- package/dist/modules/indexer/walker.js +96 -42
- package/dist/modules/lsp/check-tool.js +2 -1
- package/dist/modules/lsp/client.js +49 -32
- package/dist/modules/lsp/config.js +55 -2
- package/dist/modules/lsp/module.js +38 -5
- package/dist/modules/lsp/probe.js +4 -3
- package/dist/modules/lsp/project-root.js +41 -1
- package/dist/modules/lsp/startup-check.js +12 -4
- package/dist/modules/mcp/client.js +153 -104
- package/dist/modules/mcp/module.js +165 -41
- package/dist/modules/memory/module.js +4 -3
- package/dist/modules/plugins/builtin/lint-on-write.js +36 -6
- package/dist/modules/plugins/manager.js +47 -84
- package/dist/modules/pricing/index.js +17 -7
- package/dist/modules/pricing/prices.js +30 -12
- package/dist/modules/processes/index.js +1 -0
- package/dist/modules/processes/kill-tree.js +56 -0
- package/dist/modules/processes/registry.js +2 -54
- package/dist/modules/providers/cache.js +23 -0
- package/dist/modules/providers/factory.js +28 -0
- package/dist/modules/providers/fallback.js +7 -5
- package/dist/modules/providers/health.js +2 -1
- package/dist/modules/providers/index.js +1 -0
- package/dist/modules/providers/manager.js +17 -2
- package/dist/modules/providers/presets.js +79 -6
- package/dist/modules/reasoning/policy.js +40 -0
- package/dist/modules/reasoning/probe.js +111 -0
- package/dist/modules/security/audit-notifier.js +42 -27
- package/dist/modules/security/command-validator.js +25 -20
- package/dist/modules/security/encryption.js +6 -12
- package/dist/modules/security/network-validator.js +76 -5
- package/dist/modules/security/path-validator.js +77 -34
- package/dist/modules/security/rate-limiter.js +11 -0
- package/dist/modules/security/security-policies.js +1 -1
- package/dist/modules/security/session-encryption.js +13 -2
- package/dist/modules/security/session-isolation.js +2 -9
- package/dist/modules/session/manager.js +11 -0
- package/dist/modules/session/module.js +11 -3
- package/dist/modules/session/store.js +41 -5
- package/dist/modules/skills/loader.js +7 -1
- package/dist/modules/skills/module.js +2 -1
- package/dist/modules/updater/changelog-reader.js +94 -0
- package/dist/modules/updater/dev-detect.js +17 -0
- package/dist/modules/updater/index.js +1 -0
- package/dist/modules/updater/module.js +14 -3
- package/dist/output/bus.js +32 -0
- package/dist/output/channel.js +233 -0
- package/dist/output/format.js +14 -0
- package/dist/output/index.js +7 -0
- package/dist/output/json-sink.js +22 -0
- package/dist/output/machine.js +8 -0
- package/dist/output/session-sink.js +27 -0
- package/dist/output/types.js +1 -0
- package/dist/tools/approve.js +6 -2
- package/dist/tools/attach-image.js +11 -11
- package/dist/tools/auto-fixer.js +198 -0
- package/dist/tools/bash.js +142 -89
- package/dist/tools/chunk-query.js +10 -6
- package/dist/tools/download-file.js +1 -1
- package/dist/tools/edit-file.js +20 -2
- package/dist/tools/executor.js +54 -9
- package/dist/tools/glob-tool.js +7 -0
- package/dist/tools/grep-tool.js +15 -1
- package/dist/tools/index.js +3 -1
- package/dist/tools/list-dir.js +3 -1
- package/dist/tools/load-skill.js +2 -1
- package/dist/tools/mcp-call.js +1 -1
- package/dist/tools/move-file.js +5 -4
- package/dist/tools/path-utils.js +7 -0
- package/dist/tools/pipeline-run.js +1 -1
- package/dist/tools/prompt-io.js +28 -0
- package/dist/tools/question.js +12 -12
- package/dist/tools/scope-request.js +91 -0
- package/dist/tools/session-info.js +44 -0
- package/dist/tools/set-thinking.js +71 -0
- package/dist/tools/subagent.js +50 -9
- package/dist/tools/syntax-validator.js +177 -0
- package/dist/tools/user-input.js +16 -9
- package/dist/tools/write-file.js +17 -1
- package/dist/ui/diff.js +10 -0
- package/dist/ui/line-editor.js +179 -26
- package/dist/ui/line-math.js +20 -3
- package/dist/ui/md-formatter.js +100 -10
- package/dist/ui/output.js +5 -4
- package/dist/ui/plan-view.js +2 -7
- package/dist/ui/renderer.js +89 -85
- package/dist/ui/spinner.js +14 -4
- package/dist/utils/error.js +4 -0
- package/dist/utils/index.js +4 -0
- package/dist/utils/retry.js +17 -0
- package/dist/utils/sleep.js +23 -0
- package/dist/utils/truncate.js +9 -0
- 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.
|
|
14
|
-
body.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
101
|
-
|
|
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
|
|
132
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
154
|
-
|
|
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
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
363
|
-
|
|
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
|
-
:
|
|
460
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|