micro-models-agent 0.51.1 → 0.52.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/dist/cli/commands.js +162 -38
- package/dist/cli/completer.js +5 -5
- package/dist/cli/main.js +42 -54
- package/dist/cli/repl-commands.js +138 -38
- package/dist/cli/repl.js +175 -89
- package/dist/cli/run-result.js +11 -0
- package/dist/cli/security-commands.js +6 -6
- package/dist/cli/setup.js +21 -15
- package/dist/config/config.js +54 -27
- package/dist/config/defaults.js +17 -0
- package/dist/config/domains.js +179 -0
- package/dist/config/index.js +2 -1
- package/dist/config/security.js +28 -8
- package/dist/core/agent.js +162 -30
- package/dist/core/bootstrap.js +94 -17
- package/dist/core/crash-handler.js +51 -0
- package/dist/core/environment.js +199 -0
- package/dist/core/session-logger.js +60 -6
- package/dist/core/version.js +2 -0
- package/dist/i18n/en.json +120 -39
- package/dist/i18n/ru.json +91 -10
- package/dist/llm/openai-compat.js +191 -53
- package/dist/llm/orchestrator.js +5 -3
- package/dist/logger/app-logger.js +50 -4
- package/dist/main.js +1288 -635
- package/dist/modules/browser/session.js +4 -0
- package/dist/modules/certification/cli.js +58 -19
- package/dist/modules/certification/loader.js +2 -1
- package/dist/modules/certification/manifest.js +22 -14
- package/dist/modules/certification/runner.js +91 -5
- package/dist/modules/certification/scenarios.js +290 -7
- package/dist/modules/context/fact-extractor.js +6 -0
- package/dist/modules/context/manager.js +19 -2
- package/dist/modules/execution/audit-runners.js +61 -7
- package/dist/modules/execution/execution-plugin.js +219 -60
- package/dist/modules/execution/module.js +207 -18
- package/dist/modules/execution/moe-executor.js +33 -20
- package/dist/modules/execution/plan-store.js +39 -0
- package/dist/modules/execution/plan-tool.js +188 -19
- package/dist/modules/execution/planner.js +27 -23
- package/dist/modules/execution/stuck-detector.js +244 -8
- package/dist/modules/execution/tracker.js +8 -6
- package/dist/modules/execution/verifier.js +15 -2
- package/dist/modules/hallucination/detector.js +4 -0
- package/dist/modules/hallucination/factual.js +45 -5
- package/dist/modules/indexer/module.js +1 -0
- package/dist/modules/lsp/client.js +123 -12
- package/dist/modules/lsp/index.js +1 -1
- package/dist/modules/lsp/module.js +30 -2
- package/dist/modules/lsp/probe.js +11 -1
- package/dist/modules/lsp/startup-check.js +5 -2
- package/dist/modules/plugins/builtin/lint-on-write.js +144 -41
- package/dist/modules/plugins/manager.js +57 -13
- package/dist/modules/pricing/index.js +61 -0
- package/dist/modules/pricing/prices.js +129 -0
- package/dist/modules/providers/create.js +22 -0
- package/dist/modules/providers/fallback.js +79 -0
- package/dist/modules/providers/health.js +46 -0
- package/dist/modules/providers/index.js +5 -0
- package/dist/modules/providers/manager.js +161 -0
- package/dist/modules/providers/presets.js +128 -0
- package/dist/modules/providers/registry.js +22 -0
- package/dist/modules/providers/types.js +1 -0
- package/dist/modules/registry.js +1 -0
- package/dist/modules/security/command-validator.js +14 -0
- package/dist/modules/security/encryption.js +6 -6
- package/dist/modules/security/network-validator.js +17 -0
- package/dist/modules/security/path-validator.js +22 -26
- package/dist/modules/session/store.js +10 -10
- package/dist/tools/approve.js +1 -0
- package/dist/tools/attach-image.js +12 -0
- package/dist/tools/bash.js +27 -4
- package/dist/tools/browser.js +1 -0
- package/dist/tools/chunk-query.js +1 -0
- package/dist/tools/create-dir.js +1 -0
- package/dist/tools/delete-file.js +1 -0
- package/dist/tools/download-file.js +1 -0
- package/dist/tools/edit-file.js +2 -1
- package/dist/tools/enable-tools.js +1 -0
- package/dist/tools/executor.js +17 -7
- package/dist/tools/file-info.js +1 -0
- package/dist/tools/glob-tool.js +1 -0
- package/dist/tools/grep-tool.js +54 -13
- package/dist/tools/list-dir.js +1 -0
- package/dist/tools/load-skill.js +1 -0
- package/dist/tools/mcp-call.js +1 -0
- package/dist/tools/move-file.js +1 -0
- package/dist/tools/path-utils.js +51 -1
- package/dist/tools/pipeline-run.js +1 -0
- package/dist/tools/process-kill.js +11 -0
- package/dist/tools/process-list.js +1 -0
- package/dist/tools/process-log.js +9 -0
- package/dist/tools/question.js +1 -0
- package/dist/tools/read-file.js +94 -6
- package/dist/tools/recall.js +1 -0
- package/dist/tools/remember.js +1 -0
- package/dist/tools/scope-check.js +7 -5
- package/dist/tools/search-history.js +1 -0
- package/dist/tools/subagent.js +4 -4
- package/dist/tools/web-browse.js +1 -0
- package/dist/tools/web-fetch.js +27 -6
- package/dist/tools/web-search.js +70 -43
- package/dist/tools/write-file.js +1 -0
- package/dist/ui/line-editor.js +142 -23
- package/dist/ui/line-math.js +8 -4
- package/dist/ui/renderer.js +57 -7
- package/package.json +50 -48
|
@@ -1,6 +1,23 @@
|
|
|
1
1
|
import { semverGt } from "../updater/checker";
|
|
2
2
|
export class PluginManager {
|
|
3
3
|
plugins = [];
|
|
4
|
+
hostBridge = null;
|
|
5
|
+
/**
|
|
6
|
+
* Register the host bridge (set once by the REPL). It is merged into every
|
|
7
|
+
* hook context as `ctx.hostBridge` unless the caller already provided one.
|
|
8
|
+
*/
|
|
9
|
+
setHostBridge(bridge) {
|
|
10
|
+
this.hostBridge = bridge;
|
|
11
|
+
}
|
|
12
|
+
/** Merge the host bridge into a hook context (caller-provided value wins). */
|
|
13
|
+
withHost(ctx) {
|
|
14
|
+
if (!ctx || typeof ctx !== "object")
|
|
15
|
+
return ctx;
|
|
16
|
+
if (!this.hostBridge || ctx.hostBridge !== undefined) {
|
|
17
|
+
return ctx;
|
|
18
|
+
}
|
|
19
|
+
return { ...ctx, hostBridge: this.hostBridge };
|
|
20
|
+
}
|
|
4
21
|
/**
|
|
5
22
|
* Register a plugin. Plugins with the same `name` are deduplicated — the
|
|
6
23
|
* higher `version` wins (unversioned plugins count as "0.0.0"). The loser is
|
|
@@ -47,7 +64,8 @@ export class PluginManager {
|
|
|
47
64
|
}
|
|
48
65
|
});
|
|
49
66
|
}
|
|
50
|
-
async runOnBeforeTool(
|
|
67
|
+
async runOnBeforeTool(rawCtx, call) {
|
|
68
|
+
const ctx = this.withHost(rawCtx);
|
|
51
69
|
for (const plugin of this.plugins) {
|
|
52
70
|
if (plugin.onBeforeTool) {
|
|
53
71
|
try {
|
|
@@ -62,7 +80,8 @@ export class PluginManager {
|
|
|
62
80
|
}
|
|
63
81
|
return true;
|
|
64
82
|
}
|
|
65
|
-
async runOnAfterTool(
|
|
83
|
+
async runOnAfterTool(rawCtx, call, result) {
|
|
84
|
+
const ctx = this.withHost(rawCtx);
|
|
66
85
|
for (const plugin of this.plugins) {
|
|
67
86
|
if (plugin.onAfterTool) {
|
|
68
87
|
try {
|
|
@@ -74,7 +93,8 @@ export class PluginManager {
|
|
|
74
93
|
}
|
|
75
94
|
}
|
|
76
95
|
}
|
|
77
|
-
async runOnError(
|
|
96
|
+
async runOnError(rawCtx, error) {
|
|
97
|
+
const ctx = this.withHost(rawCtx);
|
|
78
98
|
for (const plugin of this.plugins) {
|
|
79
99
|
if (plugin.onError) {
|
|
80
100
|
try {
|
|
@@ -86,7 +106,8 @@ export class PluginManager {
|
|
|
86
106
|
}
|
|
87
107
|
}
|
|
88
108
|
}
|
|
89
|
-
runOnSessionStart(
|
|
109
|
+
runOnSessionStart(rawCtx) {
|
|
110
|
+
const ctx = this.withHost(rawCtx);
|
|
90
111
|
for (const plugin of this.plugins) {
|
|
91
112
|
if (plugin.onSessionStart) {
|
|
92
113
|
try {
|
|
@@ -98,7 +119,8 @@ export class PluginManager {
|
|
|
98
119
|
}
|
|
99
120
|
}
|
|
100
121
|
}
|
|
101
|
-
runOnBeforeThink(
|
|
122
|
+
runOnBeforeThink(rawCtx) {
|
|
123
|
+
const ctx = this.withHost(rawCtx);
|
|
102
124
|
for (const plugin of this.plugins) {
|
|
103
125
|
if (plugin.onBeforeThink) {
|
|
104
126
|
try {
|
|
@@ -110,7 +132,8 @@ export class PluginManager {
|
|
|
110
132
|
}
|
|
111
133
|
}
|
|
112
134
|
}
|
|
113
|
-
runOnAfterThink(
|
|
135
|
+
runOnAfterThink(rawCtx, response) {
|
|
136
|
+
const ctx = this.withHost(rawCtx);
|
|
114
137
|
for (const plugin of this.plugins) {
|
|
115
138
|
if (plugin.onAfterThink) {
|
|
116
139
|
try {
|
|
@@ -122,7 +145,8 @@ export class PluginManager {
|
|
|
122
145
|
}
|
|
123
146
|
}
|
|
124
147
|
}
|
|
125
|
-
runOnSessionEnd(
|
|
148
|
+
runOnSessionEnd(rawCtx) {
|
|
149
|
+
const ctx = this.withHost(rawCtx);
|
|
126
150
|
for (const plugin of this.plugins) {
|
|
127
151
|
if (plugin.onSessionEnd) {
|
|
128
152
|
try {
|
|
@@ -134,7 +158,8 @@ export class PluginManager {
|
|
|
134
158
|
}
|
|
135
159
|
}
|
|
136
160
|
}
|
|
137
|
-
runOnToolCall(
|
|
161
|
+
runOnToolCall(rawCtx) {
|
|
162
|
+
const ctx = this.withHost(rawCtx);
|
|
138
163
|
for (const plugin of this.plugins) {
|
|
139
164
|
if (plugin.onToolCall) {
|
|
140
165
|
try {
|
|
@@ -146,7 +171,8 @@ export class PluginManager {
|
|
|
146
171
|
}
|
|
147
172
|
}
|
|
148
173
|
}
|
|
149
|
-
runOnPhase(
|
|
174
|
+
runOnPhase(rawCtx, phase) {
|
|
175
|
+
const ctx = this.withHost(rawCtx);
|
|
150
176
|
for (const plugin of this.plugins) {
|
|
151
177
|
if (plugin.onPhase) {
|
|
152
178
|
try {
|
|
@@ -158,7 +184,8 @@ export class PluginManager {
|
|
|
158
184
|
}
|
|
159
185
|
}
|
|
160
186
|
}
|
|
161
|
-
runOnToolStart(
|
|
187
|
+
runOnToolStart(rawCtx, call) {
|
|
188
|
+
const ctx = this.withHost(rawCtx);
|
|
162
189
|
for (const plugin of this.plugins) {
|
|
163
190
|
if (plugin.onToolStart) {
|
|
164
191
|
try {
|
|
@@ -170,7 +197,8 @@ export class PluginManager {
|
|
|
170
197
|
}
|
|
171
198
|
}
|
|
172
199
|
}
|
|
173
|
-
runOnToolEnd(
|
|
200
|
+
runOnToolEnd(rawCtx, call, result, durationMs) {
|
|
201
|
+
const ctx = this.withHost(rawCtx);
|
|
174
202
|
for (const plugin of this.plugins) {
|
|
175
203
|
if (plugin.onToolEnd) {
|
|
176
204
|
try {
|
|
@@ -183,7 +211,8 @@ export class PluginManager {
|
|
|
183
211
|
}
|
|
184
212
|
}
|
|
185
213
|
/** Pipe a text chunk through plugin transforms; returns the (possibly replaced) chunk. */
|
|
186
|
-
runOnText(
|
|
214
|
+
runOnText(rawCtx, chunk) {
|
|
215
|
+
const ctx = this.withHost(rawCtx);
|
|
187
216
|
for (const plugin of this.plugins) {
|
|
188
217
|
if (plugin.onText) {
|
|
189
218
|
try {
|
|
@@ -199,7 +228,8 @@ export class PluginManager {
|
|
|
199
228
|
return chunk;
|
|
200
229
|
}
|
|
201
230
|
/** Pipe a meta chunk through plugin transforms; returns the (possibly replaced) chunk. */
|
|
202
|
-
runOnMeta(
|
|
231
|
+
runOnMeta(rawCtx, chunk) {
|
|
232
|
+
const ctx = this.withHost(rawCtx);
|
|
203
233
|
for (const plugin of this.plugins) {
|
|
204
234
|
if (plugin.onMeta) {
|
|
205
235
|
try {
|
|
@@ -214,4 +244,18 @@ export class PluginManager {
|
|
|
214
244
|
}
|
|
215
245
|
return chunk;
|
|
216
246
|
}
|
|
247
|
+
/** Dispatch a completed-run summary to all plugins (once per Agent.run()). */
|
|
248
|
+
runOnTurnEnd(rawCtx, summary) {
|
|
249
|
+
const ctx = this.withHost(rawCtx);
|
|
250
|
+
for (const plugin of this.plugins) {
|
|
251
|
+
if (plugin.onTurnEnd) {
|
|
252
|
+
try {
|
|
253
|
+
plugin.onTurnEnd(ctx, summary);
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
/* error isolation */
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
217
261
|
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { calculateCost, formatCost } from "./prices";
|
|
2
|
+
/**
|
|
3
|
+
* Accumulates per-call LLM cost for a session, attributed to the provider
|
|
4
|
+
* that served the call. Call `record()` after each LLM call with the
|
|
5
|
+
* (estimated or API) token usage and the provider name; read `total` for the
|
|
6
|
+
* running total, `totalFor(provider)` / `breakdown()` for per-provider
|
|
7
|
+
* attribution. When the model has no known price, `record` is a no-op.
|
|
8
|
+
*/
|
|
9
|
+
export class CostTracker {
|
|
10
|
+
config;
|
|
11
|
+
model;
|
|
12
|
+
_total = 0;
|
|
13
|
+
_known = false;
|
|
14
|
+
byProvider = new Map();
|
|
15
|
+
constructor(model, config) {
|
|
16
|
+
this.model = model;
|
|
17
|
+
this.config = config;
|
|
18
|
+
}
|
|
19
|
+
/** Update the model (on /model switch). Later records use the new price. */
|
|
20
|
+
setModel(model) {
|
|
21
|
+
this.model = model;
|
|
22
|
+
}
|
|
23
|
+
/** Record a single LLM call's token usage, attributed to `provider`. */
|
|
24
|
+
record(promptTokens, completionTokens, provider) {
|
|
25
|
+
const key = provider || "unknown";
|
|
26
|
+
const cost = calculateCost(this.model, promptTokens, completionTokens, this.config);
|
|
27
|
+
if (cost === undefined)
|
|
28
|
+
return;
|
|
29
|
+
this._known = true;
|
|
30
|
+
this._total += cost;
|
|
31
|
+
let entry = this.byProvider.get(key);
|
|
32
|
+
if (!entry) {
|
|
33
|
+
entry = { total: 0, known: false };
|
|
34
|
+
this.byProvider.set(key, entry);
|
|
35
|
+
}
|
|
36
|
+
entry.known = true;
|
|
37
|
+
entry.total += cost;
|
|
38
|
+
}
|
|
39
|
+
/** Running total cost in USD, or undefined if no model price is known. */
|
|
40
|
+
get total() {
|
|
41
|
+
return this._known ? this._total : undefined;
|
|
42
|
+
}
|
|
43
|
+
/** Total for one provider, or undefined when nothing was priced for it. */
|
|
44
|
+
totalFor(provider) {
|
|
45
|
+
const entry = this.byProvider.get(provider);
|
|
46
|
+
return entry && entry.known ? entry.total : undefined;
|
|
47
|
+
}
|
|
48
|
+
/** Per-provider totals sorted by cost descending (priced providers only). */
|
|
49
|
+
breakdown() {
|
|
50
|
+
const entries = [];
|
|
51
|
+
for (const [provider, entry] of this.byProvider) {
|
|
52
|
+
if (entry.known)
|
|
53
|
+
entries.push({ provider, cost: entry.total });
|
|
54
|
+
}
|
|
55
|
+
return entries.sort((a, b) => b.cost - a.cost);
|
|
56
|
+
}
|
|
57
|
+
/** Human-readable total, or undefined when pricing is unavailable. */
|
|
58
|
+
get formatted() {
|
|
59
|
+
return this._known ? formatCost(this._total) : undefined;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in price table (USD per 1M tokens) for OpenCode Zen / Go models.
|
|
3
|
+
* Values from https://opencode.ai/docs/zen/ and /docs/go/ (August 2026).
|
|
4
|
+
* Model ids are stored WITHOUT the `opencode/` or `opencode-go/` prefix so a
|
|
5
|
+
* single table serves both providers; lookup strips the prefix.
|
|
6
|
+
*/
|
|
7
|
+
const ZEN_PRICES = {
|
|
8
|
+
// Free models
|
|
9
|
+
"big-pickle": { input: 0, output: 0 },
|
|
10
|
+
"mimo-v2.5-free": { input: 0, output: 0 },
|
|
11
|
+
"hy3-free": { input: 0, output: 0 },
|
|
12
|
+
"nemotron-3-ultra-free": { input: 0, output: 0 },
|
|
13
|
+
"nemotron-3.5-lightning-free": { input: 0, output: 0 },
|
|
14
|
+
"muse-spark-1.2-contributor-free": { input: 0, output: 0 },
|
|
15
|
+
// MiniMax
|
|
16
|
+
"minimax-m3": { input: 0.3, output: 1.2 },
|
|
17
|
+
"minimax-m2.7": { input: 0.3, output: 1.2 },
|
|
18
|
+
"minimax-m2.5": { input: 0.3, output: 1.2 },
|
|
19
|
+
// GLM
|
|
20
|
+
"glm-5.2": { input: 1.4, output: 4.4 },
|
|
21
|
+
"glm-5.1": { input: 1.4, output: 4.4 },
|
|
22
|
+
"glm-5": { input: 1.0, output: 3.2 },
|
|
23
|
+
"glm-5.3": { input: 1.4, output: 4.4 },
|
|
24
|
+
// Kimi
|
|
25
|
+
"kimi-k2.7-code": { input: 0.95, output: 4.0 },
|
|
26
|
+
"kimi-k3": { input: 3.0, output: 15.0 },
|
|
27
|
+
"kimi-k2.6": { input: 0.95, output: 4.0 },
|
|
28
|
+
"kimi-k2.5": { input: 0.6, output: 3.0 },
|
|
29
|
+
// Qwen
|
|
30
|
+
"qwen3.7-max": { input: 2.5, output: 7.5 },
|
|
31
|
+
"qwen3.7-plus": { input: 0.4, output: 1.6 },
|
|
32
|
+
"qwen3.6-plus": { input: 0.5, output: 3.0 },
|
|
33
|
+
"qwen3.5-plus": { input: 0.2, output: 1.2 },
|
|
34
|
+
"qwen3.8-max": { input: 2.5, output: 7.5 },
|
|
35
|
+
// DeepSeek (off-peak)
|
|
36
|
+
"deepseek-v4-pro": { input: 0.66, output: 1.98 },
|
|
37
|
+
"deepseek-v4-flash": { input: 0.22, output: 0.66 },
|
|
38
|
+
// Claude
|
|
39
|
+
"claude-fable-5": { input: 10, output: 50 },
|
|
40
|
+
"claude-opus-5": { input: 5, output: 25 },
|
|
41
|
+
"claude-opus-4-8": { input: 5, output: 25 },
|
|
42
|
+
"claude-opus-4-7": { input: 5, output: 25 },
|
|
43
|
+
"claude-opus-4-6": { input: 5, output: 25 },
|
|
44
|
+
"claude-opus-4-5": { input: 5, output: 25 },
|
|
45
|
+
"claude-sonnet-5": { input: 2, output: 10 },
|
|
46
|
+
"claude-sonnet-4-6": { input: 3, output: 15 },
|
|
47
|
+
"claude-sonnet-4-5": { input: 3, output: 15 },
|
|
48
|
+
"claude-haiku-4-5": { input: 1, output: 5 },
|
|
49
|
+
// Gemini
|
|
50
|
+
"gemini-3.7-flash": { input: 1.5, output: 7.5 },
|
|
51
|
+
"gemini-3.6-flash": { input: 1.5, output: 7.5 },
|
|
52
|
+
"gemini-3.5-flash": { input: 1.5, output: 9.0 },
|
|
53
|
+
"gemini-3.5-flash-lite": { input: 0.3, output: 2.5 },
|
|
54
|
+
"gemini-3.1-pro": { input: 2, output: 12 },
|
|
55
|
+
"gemini-3-flash": { input: 0.5, output: 3.0 },
|
|
56
|
+
// Grok
|
|
57
|
+
"grok-4.6": { input: 2, output: 6 },
|
|
58
|
+
"grok-4.5": { input: 2, output: 6 },
|
|
59
|
+
"grok-build-0.1": { input: 1, output: 2 },
|
|
60
|
+
// Muse Spark
|
|
61
|
+
"muse-spark-1.2": { input: 1.25, output: 4.25 },
|
|
62
|
+
// GPT-5.x (Zen pricing; Go may differ)
|
|
63
|
+
"gpt-5.6-sol": { input: 5, output: 30 },
|
|
64
|
+
"gpt-5.6-terra": { input: 2, output: 12 },
|
|
65
|
+
"gpt-5.6-luna": { input: 0.2, output: 1.2 },
|
|
66
|
+
"gpt-5.5": { input: 5, output: 30 },
|
|
67
|
+
"gpt-5.5-pro": { input: 30, output: 180 },
|
|
68
|
+
"gpt-5.4": { input: 2.5, output: 15 },
|
|
69
|
+
"gpt-5.4-pro": { input: 30, output: 180 },
|
|
70
|
+
"gpt-5.4-mini": { input: 0.75, output: 4.5 },
|
|
71
|
+
"gpt-5.4-nano": { input: 0.2, output: 1.25 },
|
|
72
|
+
"gpt-5.3-codex": { input: 1.75, output: 14 },
|
|
73
|
+
"gpt-5.3-codex-spark": { input: 1.75, output: 14 },
|
|
74
|
+
"gpt-5.2": { input: 1.75, output: 14 },
|
|
75
|
+
"gpt-5.2-codex": { input: 1.75, output: 14 },
|
|
76
|
+
"gpt-5.1": { input: 1.07, output: 8.5 },
|
|
77
|
+
"gpt-5.1-codex": { input: 1.07, output: 8.5 },
|
|
78
|
+
"gpt-5.1-codex-max": { input: 1.25, output: 10 },
|
|
79
|
+
"gpt-5.1-codex-mini": { input: 0.25, output: 2 },
|
|
80
|
+
"gpt-5": { input: 1.07, output: 8.5 },
|
|
81
|
+
"gpt-5-codex": { input: 1.07, output: 8.5 },
|
|
82
|
+
"gpt-5-nano": { input: 0.05, output: 0.4 },
|
|
83
|
+
};
|
|
84
|
+
/** Provider prefixes stripped before a price lookup. */
|
|
85
|
+
const PROVIDER_PREFIXES = ["opencode-go/", "opencode/", "openai/", "anthropic/"];
|
|
86
|
+
/** Normalize a model id to its bare form for the price table lookup. */
|
|
87
|
+
export function normalizeModelId(model) {
|
|
88
|
+
let id = model.trim();
|
|
89
|
+
for (const prefix of PROVIDER_PREFIXES) {
|
|
90
|
+
if (id.startsWith(prefix)) {
|
|
91
|
+
id = id.slice(prefix.length);
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
// Strip a vendor path like qwen/qwen3.5-9b -> qwen3.5-9b (keeps bare ids).
|
|
96
|
+
const parts = id.split("/");
|
|
97
|
+
return parts[parts.length - 1];
|
|
98
|
+
}
|
|
99
|
+
/** Resolve the price for a model id. Overrides win, then the builtin table. */
|
|
100
|
+
export function resolvePrice(model, config) {
|
|
101
|
+
if (!config?.enabled)
|
|
102
|
+
return undefined;
|
|
103
|
+
const bare = normalizeModelId(model);
|
|
104
|
+
const overrides = config.overrides ?? {};
|
|
105
|
+
const direct = overrides[model] ?? overrides[bare];
|
|
106
|
+
if (direct)
|
|
107
|
+
return direct;
|
|
108
|
+
return ZEN_PRICES[bare];
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Compute the cost in USD of a single call given its token usage and the
|
|
112
|
+
* model price. Returns undefined when the model has no known price.
|
|
113
|
+
*/
|
|
114
|
+
export function calculateCost(model, promptTokens, completionTokens, config) {
|
|
115
|
+
const price = resolvePrice(model, config);
|
|
116
|
+
if (!price)
|
|
117
|
+
return undefined;
|
|
118
|
+
return (promptTokens / 1_000_000) * price.input + (completionTokens / 1_000_000) * price.output;
|
|
119
|
+
}
|
|
120
|
+
/** Format a USD cost for display. Zero and very small amounts stay readable. */
|
|
121
|
+
export function formatCost(cost) {
|
|
122
|
+
if (cost === 0)
|
|
123
|
+
return "$0.00";
|
|
124
|
+
if (cost >= 0.01)
|
|
125
|
+
return `$${cost.toFixed(2)}`;
|
|
126
|
+
if (cost >= 0.0001)
|
|
127
|
+
return `$${cost.toFixed(4)}`;
|
|
128
|
+
return `$${cost.toFixed(6)}`;
|
|
129
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { BUILTIN_PROVIDERS } from "./presets";
|
|
2
|
+
import { ProviderRegistry as Registry } from "./registry";
|
|
3
|
+
/**
|
|
4
|
+
* The single entry point for building a provider. Unknown types fall back to
|
|
5
|
+
* the OpenAI-compatible provider (backward compatible with legacy configs that
|
|
6
|
+
* use a custom `type` + baseUrl).
|
|
7
|
+
*/
|
|
8
|
+
export function createProvider(type, opts, registry = defaultRegistry()) {
|
|
9
|
+
const spec = registry.get(type);
|
|
10
|
+
if (!spec) {
|
|
11
|
+
return BUILTIN_PROVIDERS[0].create(opts);
|
|
12
|
+
}
|
|
13
|
+
return spec.create(opts);
|
|
14
|
+
}
|
|
15
|
+
/** Registry preloaded with all builtin provider presets. */
|
|
16
|
+
export function defaultRegistry() {
|
|
17
|
+
const registry = new Registry();
|
|
18
|
+
for (const spec of BUILTIN_PROVIDERS) {
|
|
19
|
+
registry.register(spec);
|
|
20
|
+
}
|
|
21
|
+
return registry;
|
|
22
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/** True when the error is worth failing over to another provider. */
|
|
2
|
+
export function isFailoverError(err) {
|
|
3
|
+
const e = err;
|
|
4
|
+
if (e?.name === "AbortError")
|
|
5
|
+
return false;
|
|
6
|
+
// HTTP status attached by openai-compat: retryable statuses only.
|
|
7
|
+
if (typeof e?.llmStatus === "number")
|
|
8
|
+
return e.llmStatus === 429 || e.llmStatus >= 500;
|
|
9
|
+
// Network-level failures by errno-style code (Bun: "ConnectionRefused",
|
|
10
|
+
// Node: "ECONNREFUSED", DNS: "ENOTFOUND"/"EAI_AGAIN", etc).
|
|
11
|
+
const normCode = (e?.code ?? "").replace(/[-_]/g, "").toLowerCase();
|
|
12
|
+
if (/^abort/.test(normCode))
|
|
13
|
+
return false;
|
|
14
|
+
if (/conn(refused|reset|timedout)|enotfound|eai_again|namenotresolved/.test(normCode)) {
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
// Network-level failures by message (fetch failed, timeouts, socket resets).
|
|
18
|
+
return /fetch failed|unable to connect|network|econnrefused|enotfound|etimedout|socket hang up|connection refused|connection reset/i.test(e?.message ?? "");
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Wraps a FallbackSource so that retryable failures (429/5xx/network) on the
|
|
22
|
+
* active provider transparently switch to the next configured entry and retry
|
|
23
|
+
* there. Failover happens ONLY before any chunk was emitted — mid-stream
|
|
24
|
+
* failures rethrow to avoid duplicated output. The last provider that served
|
|
25
|
+
* a successful call becomes the new active entry (sticky).
|
|
26
|
+
*/
|
|
27
|
+
export class FallbackProvider {
|
|
28
|
+
source;
|
|
29
|
+
onFailover;
|
|
30
|
+
constructor(source, onFailover) {
|
|
31
|
+
this.source = source;
|
|
32
|
+
this.onFailover = onFailover;
|
|
33
|
+
}
|
|
34
|
+
get model() {
|
|
35
|
+
return this.source.active.model;
|
|
36
|
+
}
|
|
37
|
+
get contextWindow() {
|
|
38
|
+
return this.source.active.contextWindow;
|
|
39
|
+
}
|
|
40
|
+
async *chat(messages, tools, signal, options) {
|
|
41
|
+
const names = this.source.listNames();
|
|
42
|
+
if (names.length <= 1) {
|
|
43
|
+
yield* this.source.active.chat(messages, tools, signal, options);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
let emitted = false;
|
|
47
|
+
let idx = Math.max(0, names.indexOf(this.source.activeName));
|
|
48
|
+
let lastError;
|
|
49
|
+
for (let attempt = 0; attempt < names.length; attempt++) {
|
|
50
|
+
const name = names[idx];
|
|
51
|
+
const provider = name === this.source.activeName
|
|
52
|
+
? this.source.active
|
|
53
|
+
: this.source.switch(name);
|
|
54
|
+
try {
|
|
55
|
+
for await (const chunk of provider.chat(messages, tools, signal, options)) {
|
|
56
|
+
emitted = true;
|
|
57
|
+
yield chunk;
|
|
58
|
+
}
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
catch (err) {
|
|
62
|
+
// Mid-stream failure after content was delivered: a restart would
|
|
63
|
+
// duplicate chunks — surface the error instead of failing over.
|
|
64
|
+
if (emitted || !isFailoverError(err))
|
|
65
|
+
throw err;
|
|
66
|
+
lastError = err;
|
|
67
|
+
idx = (idx + 1) % names.length;
|
|
68
|
+
this.onFailover?.(name, names[idx], err);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
throw lastError;
|
|
72
|
+
}
|
|
73
|
+
countTokens(text) {
|
|
74
|
+
return this.source.active.countTokens(text);
|
|
75
|
+
}
|
|
76
|
+
async listModels() {
|
|
77
|
+
return this.source.active.listModels();
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Probe providers concurrently (bounded by the caller passing few entries)
|
|
3
|
+
* and report ✓/✗ per entry. Uses `listModels()` — a free, cheap call — never
|
|
4
|
+
* a billed chat completion. Input order is preserved in the output.
|
|
5
|
+
*/
|
|
6
|
+
export async function probeProviders(targets, opts) {
|
|
7
|
+
const timeoutMs = opts?.timeoutMs ?? 5000;
|
|
8
|
+
const results = await Promise.all(targets.map((t) => probeOne(t, timeoutMs)));
|
|
9
|
+
return results;
|
|
10
|
+
}
|
|
11
|
+
async function probeOne(target, timeoutMs) {
|
|
12
|
+
const start = Date.now();
|
|
13
|
+
try {
|
|
14
|
+
const models = await withTimeout(target.listModels(), timeoutMs);
|
|
15
|
+
return { name: target.name, ok: true, ms: Date.now() - start, models: models.length };
|
|
16
|
+
}
|
|
17
|
+
catch (err) {
|
|
18
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
19
|
+
return {
|
|
20
|
+
name: target.name,
|
|
21
|
+
ok: false,
|
|
22
|
+
ms: Date.now() - start,
|
|
23
|
+
models: 0,
|
|
24
|
+
error: message,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function withTimeout(promise, ms) {
|
|
29
|
+
return new Promise((resolve, reject) => {
|
|
30
|
+
const timer = setTimeout(() => reject(new Error(`timeout after ${ms}ms`)), ms);
|
|
31
|
+
promise.then((v) => {
|
|
32
|
+
clearTimeout(timer);
|
|
33
|
+
resolve(v);
|
|
34
|
+
}, (e) => {
|
|
35
|
+
clearTimeout(timer);
|
|
36
|
+
reject(e);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
/** Build probe targets from a name→provider mapping (priority order). */
|
|
41
|
+
export function targetsFromProviders(entries) {
|
|
42
|
+
return entries.map(({ name, provider }) => ({
|
|
43
|
+
name,
|
|
44
|
+
listModels: () => provider.listModels(),
|
|
45
|
+
}));
|
|
46
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { ProviderRegistry } from "./registry";
|
|
2
|
+
export { createProvider, defaultRegistry } from "./create";
|
|
3
|
+
export { ProviderManager, presetOf } from "./manager";
|
|
4
|
+
export { probeProviders, targetsFromProviders, } from "./health";
|
|
5
|
+
export { BUILTIN_PROVIDERS, HOSTED_BASE_URLS } from "./presets";
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { createProvider, defaultRegistry } from "./create";
|
|
2
|
+
import { BUILTIN_PROVIDERS } from "./presets";
|
|
3
|
+
/**
|
|
4
|
+
* Manages the active provider + model at runtime. Supports several configured
|
|
5
|
+
* providers (`ProviderConfig.entries`) with hot-swap (`switch`, `setModel`).
|
|
6
|
+
* Legacy single-provider configs (`{type, baseUrl, apiKey}`) are normalized
|
|
7
|
+
* into a single-entry list, so existing configs keep working.
|
|
8
|
+
*/
|
|
9
|
+
export class ProviderManager {
|
|
10
|
+
entries;
|
|
11
|
+
activeProviderName;
|
|
12
|
+
model;
|
|
13
|
+
opts;
|
|
14
|
+
registry;
|
|
15
|
+
cache = new Map();
|
|
16
|
+
constructor(config, opts) {
|
|
17
|
+
this.opts = opts;
|
|
18
|
+
this.registry = opts.registry ?? defaultRegistry();
|
|
19
|
+
this.entries = this.normalizeEntries(config);
|
|
20
|
+
this.activeProviderName = this.resolveActive(config);
|
|
21
|
+
this.model = "";
|
|
22
|
+
}
|
|
23
|
+
/** The model the active provider is built with. */
|
|
24
|
+
get activeModel() {
|
|
25
|
+
return this.model;
|
|
26
|
+
}
|
|
27
|
+
/** Name/label of the active provider. */
|
|
28
|
+
get activeName() {
|
|
29
|
+
return this.activeProviderName;
|
|
30
|
+
}
|
|
31
|
+
get active() {
|
|
32
|
+
return this.build(this.activeProviderName);
|
|
33
|
+
}
|
|
34
|
+
get activeEntry() {
|
|
35
|
+
const entry = this.entryByName(this.activeProviderName);
|
|
36
|
+
if (!entry)
|
|
37
|
+
throw new Error(`Unknown active provider "${this.activeProviderName}"`);
|
|
38
|
+
return entry;
|
|
39
|
+
}
|
|
40
|
+
/** All configured entries (resolved with preset metadata), priority-ordered. */
|
|
41
|
+
list() {
|
|
42
|
+
return this.entries
|
|
43
|
+
.map((e) => {
|
|
44
|
+
const preset = this.registry.get(e.type);
|
|
45
|
+
return { ...e, label: e.label || preset?.label || e.type };
|
|
46
|
+
})
|
|
47
|
+
.map((e, i) => ({ e, i, p: e.priority ?? Number.MAX_SAFE_INTEGER }))
|
|
48
|
+
.sort((a, b) => a.p - b.p || a.i - b.i)
|
|
49
|
+
.map(({ e }) => e);
|
|
50
|
+
}
|
|
51
|
+
listNames() {
|
|
52
|
+
return this.list().map((e) => e.label);
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* CODE-ONLY capability routing (Phase 4.4): pick the first entry (priority
|
|
56
|
+
* order) whose preset capabilities and context window satisfy `needs`.
|
|
57
|
+
* Never exposed to the LLM — the model must not choose providers.
|
|
58
|
+
*/
|
|
59
|
+
pickFor(needs) {
|
|
60
|
+
const globalWindow = this.opts.contextWindow;
|
|
61
|
+
// No constraints → keep the current active provider.
|
|
62
|
+
if (!needs.vision && !needs.tools && needs.minContext === undefined) {
|
|
63
|
+
return this.activeProviderName;
|
|
64
|
+
}
|
|
65
|
+
for (const e of this.list()) {
|
|
66
|
+
const caps = this.registry.get(e.type)?.capabilities;
|
|
67
|
+
if (needs.vision && !caps?.vision)
|
|
68
|
+
continue;
|
|
69
|
+
if (needs.tools && !caps?.tools)
|
|
70
|
+
continue;
|
|
71
|
+
if (needs.minContext !== undefined &&
|
|
72
|
+
(e.contextWindow ?? globalWindow) < needs.minContext) {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
return e.label;
|
|
76
|
+
}
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
/** Hot-swap to a named provider; rebuild the active provider. */
|
|
80
|
+
switch(name, model) {
|
|
81
|
+
if (!this.entryByName(name)) {
|
|
82
|
+
throw new Error(`Unknown provider "${name}"`);
|
|
83
|
+
}
|
|
84
|
+
this.activeProviderName = name;
|
|
85
|
+
if (model !== undefined)
|
|
86
|
+
this.model = model;
|
|
87
|
+
return this.active;
|
|
88
|
+
}
|
|
89
|
+
/** Rebuild the active provider with a new model. */
|
|
90
|
+
setModel(model) {
|
|
91
|
+
this.model = model;
|
|
92
|
+
return this.active;
|
|
93
|
+
}
|
|
94
|
+
/** Serialize back to ProviderConfig (keeps active/entries in sync). */
|
|
95
|
+
toConfig() {
|
|
96
|
+
const active = this.activeEntry;
|
|
97
|
+
return {
|
|
98
|
+
active: this.activeProviderName,
|
|
99
|
+
entries: this.entries,
|
|
100
|
+
fallback: false,
|
|
101
|
+
type: active.type,
|
|
102
|
+
baseUrl: active.baseUrl,
|
|
103
|
+
apiKey: active.apiKey,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
normalizeEntries(config) {
|
|
107
|
+
if (Array.isArray(config.entries) && config.entries.length > 0) {
|
|
108
|
+
return config.entries.map((e) => ({ ...e, label: e.label || e.type }));
|
|
109
|
+
}
|
|
110
|
+
// Legacy single-provider form (or empty config) → one default entry.
|
|
111
|
+
return [
|
|
112
|
+
{
|
|
113
|
+
type: config.type ?? "openai-compat",
|
|
114
|
+
label: config.type ?? "openai-compat",
|
|
115
|
+
baseUrl: config.baseUrl ?? "",
|
|
116
|
+
apiKey: config.apiKey,
|
|
117
|
+
maxCompletionTokens: config.maxCompletionTokens,
|
|
118
|
+
},
|
|
119
|
+
];
|
|
120
|
+
}
|
|
121
|
+
resolveActive(config) {
|
|
122
|
+
if (config.active && this.entryByName(config.active))
|
|
123
|
+
return config.active;
|
|
124
|
+
const first = this.entries[0];
|
|
125
|
+
return first ? (first.label ?? "openai-compat") : "openai-compat";
|
|
126
|
+
}
|
|
127
|
+
entryByName(name) {
|
|
128
|
+
return this.entries.find((e) => e.label === name || e.type === name);
|
|
129
|
+
}
|
|
130
|
+
build(name) {
|
|
131
|
+
const entry = this.entryByName(name);
|
|
132
|
+
if (!entry)
|
|
133
|
+
throw new Error(`Unknown provider "${name}"`);
|
|
134
|
+
const key = `${name}|${this.model}`;
|
|
135
|
+
const cached = this.cache.get(key);
|
|
136
|
+
if (cached)
|
|
137
|
+
return cached;
|
|
138
|
+
const provider = createProvider(entry.type, {
|
|
139
|
+
model: this.model,
|
|
140
|
+
baseUrl: entry.baseUrl,
|
|
141
|
+
apiKey: entry.apiKey,
|
|
142
|
+
contextWindow: entry.contextWindow ?? this.opts.contextWindow,
|
|
143
|
+
retry: entry.retry ?? this.opts.retry,
|
|
144
|
+
rateLimits: entry.rateLimits ?? this.opts.rateLimits,
|
|
145
|
+
maxCompletionTokens: entry.maxCompletionTokens,
|
|
146
|
+
}, this.registry);
|
|
147
|
+
this.cache.set(key, provider);
|
|
148
|
+
return provider;
|
|
149
|
+
}
|
|
150
|
+
/** Clear the build cache (on config change). */
|
|
151
|
+
resetCache() {
|
|
152
|
+
this.cache.clear();
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/** Export preset metadata for the CLI (label/capabilities/baseUrl). */
|
|
156
|
+
export function presetOf(type) {
|
|
157
|
+
const spec = BUILTIN_PROVIDERS.find((p) => p.type === type);
|
|
158
|
+
if (!spec)
|
|
159
|
+
return undefined;
|
|
160
|
+
return { label: spec.label, baseUrl: spec.baseUrl };
|
|
161
|
+
}
|