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.
Files changed (107) hide show
  1. package/dist/cli/commands.js +162 -38
  2. package/dist/cli/completer.js +5 -5
  3. package/dist/cli/main.js +42 -54
  4. package/dist/cli/repl-commands.js +138 -38
  5. package/dist/cli/repl.js +175 -89
  6. package/dist/cli/run-result.js +11 -0
  7. package/dist/cli/security-commands.js +6 -6
  8. package/dist/cli/setup.js +21 -15
  9. package/dist/config/config.js +54 -27
  10. package/dist/config/defaults.js +17 -0
  11. package/dist/config/domains.js +179 -0
  12. package/dist/config/index.js +2 -1
  13. package/dist/config/security.js +28 -8
  14. package/dist/core/agent.js +162 -30
  15. package/dist/core/bootstrap.js +94 -17
  16. package/dist/core/crash-handler.js +51 -0
  17. package/dist/core/environment.js +199 -0
  18. package/dist/core/session-logger.js +60 -6
  19. package/dist/core/version.js +2 -0
  20. package/dist/i18n/en.json +120 -39
  21. package/dist/i18n/ru.json +91 -10
  22. package/dist/llm/openai-compat.js +191 -53
  23. package/dist/llm/orchestrator.js +5 -3
  24. package/dist/logger/app-logger.js +50 -4
  25. package/dist/main.js +1288 -635
  26. package/dist/modules/browser/session.js +4 -0
  27. package/dist/modules/certification/cli.js +58 -19
  28. package/dist/modules/certification/loader.js +2 -1
  29. package/dist/modules/certification/manifest.js +22 -14
  30. package/dist/modules/certification/runner.js +91 -5
  31. package/dist/modules/certification/scenarios.js +290 -7
  32. package/dist/modules/context/fact-extractor.js +6 -0
  33. package/dist/modules/context/manager.js +19 -2
  34. package/dist/modules/execution/audit-runners.js +61 -7
  35. package/dist/modules/execution/execution-plugin.js +219 -60
  36. package/dist/modules/execution/module.js +207 -18
  37. package/dist/modules/execution/moe-executor.js +33 -20
  38. package/dist/modules/execution/plan-store.js +39 -0
  39. package/dist/modules/execution/plan-tool.js +188 -19
  40. package/dist/modules/execution/planner.js +27 -23
  41. package/dist/modules/execution/stuck-detector.js +244 -8
  42. package/dist/modules/execution/tracker.js +8 -6
  43. package/dist/modules/execution/verifier.js +15 -2
  44. package/dist/modules/hallucination/detector.js +4 -0
  45. package/dist/modules/hallucination/factual.js +45 -5
  46. package/dist/modules/indexer/module.js +1 -0
  47. package/dist/modules/lsp/client.js +123 -12
  48. package/dist/modules/lsp/index.js +1 -1
  49. package/dist/modules/lsp/module.js +30 -2
  50. package/dist/modules/lsp/probe.js +11 -1
  51. package/dist/modules/lsp/startup-check.js +5 -2
  52. package/dist/modules/plugins/builtin/lint-on-write.js +144 -41
  53. package/dist/modules/plugins/manager.js +57 -13
  54. package/dist/modules/pricing/index.js +61 -0
  55. package/dist/modules/pricing/prices.js +129 -0
  56. package/dist/modules/providers/create.js +22 -0
  57. package/dist/modules/providers/fallback.js +79 -0
  58. package/dist/modules/providers/health.js +46 -0
  59. package/dist/modules/providers/index.js +5 -0
  60. package/dist/modules/providers/manager.js +161 -0
  61. package/dist/modules/providers/presets.js +128 -0
  62. package/dist/modules/providers/registry.js +22 -0
  63. package/dist/modules/providers/types.js +1 -0
  64. package/dist/modules/registry.js +1 -0
  65. package/dist/modules/security/command-validator.js +14 -0
  66. package/dist/modules/security/encryption.js +6 -6
  67. package/dist/modules/security/network-validator.js +17 -0
  68. package/dist/modules/security/path-validator.js +22 -26
  69. package/dist/modules/session/store.js +10 -10
  70. package/dist/tools/approve.js +1 -0
  71. package/dist/tools/attach-image.js +12 -0
  72. package/dist/tools/bash.js +27 -4
  73. package/dist/tools/browser.js +1 -0
  74. package/dist/tools/chunk-query.js +1 -0
  75. package/dist/tools/create-dir.js +1 -0
  76. package/dist/tools/delete-file.js +1 -0
  77. package/dist/tools/download-file.js +1 -0
  78. package/dist/tools/edit-file.js +2 -1
  79. package/dist/tools/enable-tools.js +1 -0
  80. package/dist/tools/executor.js +17 -7
  81. package/dist/tools/file-info.js +1 -0
  82. package/dist/tools/glob-tool.js +1 -0
  83. package/dist/tools/grep-tool.js +54 -13
  84. package/dist/tools/list-dir.js +1 -0
  85. package/dist/tools/load-skill.js +1 -0
  86. package/dist/tools/mcp-call.js +1 -0
  87. package/dist/tools/move-file.js +1 -0
  88. package/dist/tools/path-utils.js +51 -1
  89. package/dist/tools/pipeline-run.js +1 -0
  90. package/dist/tools/process-kill.js +11 -0
  91. package/dist/tools/process-list.js +1 -0
  92. package/dist/tools/process-log.js +9 -0
  93. package/dist/tools/question.js +1 -0
  94. package/dist/tools/read-file.js +94 -6
  95. package/dist/tools/recall.js +1 -0
  96. package/dist/tools/remember.js +1 -0
  97. package/dist/tools/scope-check.js +7 -5
  98. package/dist/tools/search-history.js +1 -0
  99. package/dist/tools/subagent.js +4 -4
  100. package/dist/tools/web-browse.js +1 -0
  101. package/dist/tools/web-fetch.js +27 -6
  102. package/dist/tools/web-search.js +70 -43
  103. package/dist/tools/write-file.js +1 -0
  104. package/dist/ui/line-editor.js +142 -23
  105. package/dist/ui/line-math.js +8 -4
  106. package/dist/ui/renderer.js +57 -7
  107. 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(ctx, call) {
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(ctx, call, result) {
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(ctx, error) {
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(ctx) {
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(ctx) {
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(ctx, response) {
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(ctx) {
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(ctx) {
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(ctx, phase) {
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(ctx, call) {
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(ctx, call, result, durationMs) {
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(ctx, chunk) {
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(ctx, chunk) {
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
+ }