@hasna/terminal 2.3.2 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/dist/ai.js +78 -85
  2. package/dist/cache.js +3 -2
  3. package/dist/cli.js +1 -1
  4. package/dist/compression.js +8 -30
  5. package/dist/context-hints.js +20 -10
  6. package/dist/diff-cache.js +1 -1
  7. package/dist/discover.js +1 -1
  8. package/dist/economy.js +37 -5
  9. package/dist/expand-store.js +7 -1
  10. package/dist/mcp/server.js +44 -68
  11. package/dist/output-processor.js +10 -7
  12. package/dist/providers/anthropic.js +6 -2
  13. package/dist/providers/cerebras.js +6 -93
  14. package/dist/providers/groq.js +6 -93
  15. package/dist/providers/index.js +85 -36
  16. package/dist/providers/openai-compat.js +93 -0
  17. package/dist/providers/xai.js +6 -93
  18. package/dist/tokens.js +17 -0
  19. package/dist/tool-profiles.js +9 -2
  20. package/package.json +1 -1
  21. package/src/ai.ts +83 -94
  22. package/src/cache.ts +3 -2
  23. package/src/cli.tsx +1 -1
  24. package/src/compression.ts +8 -35
  25. package/src/context-hints.ts +20 -10
  26. package/src/diff-cache.ts +1 -1
  27. package/src/discover.ts +1 -1
  28. package/src/economy.ts +37 -5
  29. package/src/expand-store.ts +8 -1
  30. package/src/mcp/server.ts +45 -73
  31. package/src/output-processor.ts +11 -8
  32. package/src/providers/anthropic.ts +6 -2
  33. package/src/providers/base.ts +2 -0
  34. package/src/providers/cerebras.ts +6 -105
  35. package/src/providers/groq.ts +6 -105
  36. package/src/providers/index.ts +84 -33
  37. package/src/providers/openai-compat.ts +109 -0
  38. package/src/providers/xai.ts +6 -105
  39. package/src/tokens.ts +18 -0
  40. package/src/tool-profiles.ts +9 -2
  41. package/src/compression.test.ts +0 -49
  42. package/src/output-router.ts +0 -56
  43. package/src/parsers/base.ts +0 -72
  44. package/src/parsers/build.ts +0 -73
  45. package/src/parsers/errors.ts +0 -107
  46. package/src/parsers/files.ts +0 -91
  47. package/src/parsers/git.ts +0 -101
  48. package/src/parsers/index.ts +0 -66
  49. package/src/parsers/parsers.test.ts +0 -153
  50. package/src/parsers/tests.ts +0 -98
@@ -5,17 +5,16 @@ import { z } from "zod";
5
5
  import { spawn } from "child_process";
6
6
  import { compress, stripAnsi } from "../compression.js";
7
7
  import { stripNoise } from "../noise-filter.js";
8
- import { parseOutput, tokenSavings, estimateTokens } from "../parsers/index.js";
9
- import { summarizeOutput } from "../ai.js";
8
+ import { estimateTokens } from "../tokens.js";
9
+ import { processOutput } from "../output-processor.js";
10
10
  import { searchFiles, searchContent, semanticSearch } from "../search/index.js";
11
11
  import { listRecipes, listCollections, getRecipe, createRecipe } from "../recipes/storage.js";
12
12
  import { substituteVariables } from "../recipes/model.js";
13
13
  import { bgStart, bgStatus, bgStop, bgLogs, bgWaitPort } from "../supervisor.js";
14
14
  import { diffOutput } from "../diff-cache.js";
15
- import { processOutput } from "../output-processor.js";
16
15
  import { listSessions, getSessionInteractions, getSessionStats } from "../sessions-db.js";
17
16
  import { cachedRead } from "../file-cache.js";
18
- import { getBootContext } from "../session-boot.js";
17
+ import { getBootContext, invalidateBootCache } from "../session-boot.js";
19
18
  import { storeOutput, expandOutput } from "../expand-store.js";
20
19
  import { rewriteCommand } from "../command-rewriter.js";
21
20
  import { shouldBeLazy, toLazy } from "../lazy-executor.js";
@@ -46,6 +45,10 @@ function exec(command, cwd, timeout) {
46
45
  // Strip noise before returning (npm fund, progress bars, etc.)
47
46
  const cleanStdout = stripNoise(stdout).cleaned;
48
47
  const cleanStderr = stripNoise(stderr).cleaned;
48
+ // Invalidate boot cache after state-changing git commands
49
+ if (/\bgit\s+(commit|checkout|branch|merge|reset|push|pull|rebase|stash)\b/.test(actualCommand)) {
50
+ invalidateBootCache();
51
+ }
49
52
  resolve({ exitCode: code ?? 0, stdout: cleanStdout, stderr: cleanStderr, duration: Date.now() - start, rewritten: rw.changed ? rw.rewritten : undefined });
50
53
  });
51
54
  });
@@ -87,42 +90,20 @@ export function createServer() {
87
90
  }) }],
88
91
  };
89
92
  }
90
- // JSON mode structured parsing (only if it actually saves tokens)
91
- if (format === "json") {
92
- const parsed = parseOutput(command, output);
93
- if (parsed) {
94
- const savings = tokenSavings(output, parsed.data);
95
- if (savings.saved > 0) {
96
- return {
97
- content: [{ type: "text", text: JSON.stringify({
98
- exitCode: result.exitCode, parsed: parsed.data, parser: parsed.parser,
99
- duration: result.duration, tokensSaved: savings.saved, savingsPercent: savings.percent,
100
- }) }],
101
- };
102
- }
103
- // JSON was larger — fall through to compression
104
- }
105
- }
106
- // Compressed mode (also fallback for json when no parser matches)
107
- if (format === "compressed" || format === "json") {
108
- const compressed = compress(command, output, { maxTokens, format: "json" });
109
- return {
110
- content: [{ type: "text", text: JSON.stringify({
111
- exitCode: result.exitCode, output: compressed.content, format: compressed.format,
112
- duration: result.duration, tokensSaved: compressed.tokensSaved, savingsPercent: compressed.savingsPercent,
113
- }) }],
114
- };
115
- }
116
- // Summary mode — AI-powered
117
- if (format === "summary") {
93
+ // JSON and Summary modes both go through AI processing
94
+ if (format === "json" || format === "summary") {
118
95
  try {
119
- const summary = await summarizeOutput(command, output, maxTokens ?? 200);
120
- const rawTokens = estimateTokens(output);
121
- const summaryTokens = estimateTokens(summary);
96
+ const processed = await processOutput(command, output);
97
+ const detailKey = output.split("\n").length > 15 ? storeOutput(command, output) : undefined;
122
98
  return {
123
99
  content: [{ type: "text", text: JSON.stringify({
124
- exitCode: result.exitCode, summary, duration: result.duration,
125
- tokensSaved: rawTokens - summaryTokens,
100
+ exitCode: result.exitCode,
101
+ summary: processed.summary,
102
+ structured: processed.structured,
103
+ duration: result.duration,
104
+ tokensSaved: processed.tokensSaved,
105
+ aiProcessed: processed.aiProcessed,
106
+ ...(detailKey ? { detailKey, expandable: true } : {}),
126
107
  }) }],
127
108
  };
128
109
  }
@@ -136,6 +117,16 @@ export function createServer() {
136
117
  };
137
118
  }
138
119
  }
120
+ // Compressed mode — fast non-AI: strip + dedup + truncate
121
+ if (format === "compressed") {
122
+ const compressed = compress(command, output, { maxTokens });
123
+ return {
124
+ content: [{ type: "text", text: JSON.stringify({
125
+ exitCode: result.exitCode, output: compressed.content, duration: result.duration,
126
+ tokensSaved: compressed.tokensSaved, savingsPercent: compressed.savingsPercent,
127
+ }) }],
128
+ };
129
+ }
139
130
  return { content: [{ type: "text", text: output }] };
140
131
  });
141
132
  // ── execute_smart: AI-powered output processing ────────────────────────────
@@ -192,28 +183,21 @@ export function createServer() {
192
183
  command = includeHidden ? `ls -la "${target}"` : `ls -l "${target}"`;
193
184
  }
194
185
  const result = await exec(command);
195
- const parsed = parseOutput(command, result.stdout);
196
- if (parsed) {
197
- return {
198
- content: [{ type: "text", text: JSON.stringify({ cwd: target, ...parsed.data, parser: parsed.parser }) }],
199
- };
200
- }
201
186
  const files = result.stdout.split("\n").filter(l => l.trim());
202
- return { content: [{ type: "text", text: JSON.stringify({ cwd: target, files }) }] };
187
+ return { content: [{ type: "text", text: JSON.stringify({ cwd: target, files, count: files.length }) }] };
203
188
  });
204
189
  // ── explain_error: structured error diagnosis ─────────────────────────────
205
190
  server.tool("explain_error", "Parse error output and return structured diagnosis with root cause and fix suggestion.", {
206
191
  error: z.string().describe("Error output text"),
207
192
  command: z.string().optional().describe("The command that produced the error"),
208
193
  }, async ({ error, command }) => {
209
- const { errorParser } = await import("../parsers/errors.js");
210
- if (errorParser.detect(command ?? "", error)) {
211
- const info = errorParser.parse(command ?? "", error);
212
- return { content: [{ type: "text", text: JSON.stringify(info) }] };
213
- }
194
+ // AI processes the error no regex guessing
195
+ const processed = await processOutput(command ?? "unknown", error);
214
196
  return {
215
197
  content: [{ type: "text", text: JSON.stringify({
216
- type: "unknown", message: error.split("\n")[0]?.trim() ?? "Unknown error",
198
+ summary: processed.summary,
199
+ structured: processed.structured,
200
+ aiProcessed: processed.aiProcessed,
217
201
  }) }],
218
202
  };
219
203
  });
@@ -221,9 +205,8 @@ export function createServer() {
221
205
  server.tool("status", "Get open-terminal server status, capabilities, and available parsers.", async () => {
222
206
  return {
223
207
  content: [{ type: "text", text: JSON.stringify({
224
- name: "open-terminal", version: "0.2.0", cwd: process.cwd(),
225
- parsers: ["ls", "find", "test", "git-log", "git-status", "build", "npm-install", "error"],
226
- features: ["structured-output", "token-compression", "ai-summary", "error-diagnosis"],
208
+ name: "open-terminal", version: "0.3.0", cwd: process.cwd(),
209
+ features: ["ai-output-processing", "token-compression", "noise-filtering", "diff-caching", "lazy-execution", "progressive-disclosure"],
227
210
  }) }],
228
211
  };
229
212
  });
@@ -287,19 +270,12 @@ export function createServer() {
287
270
  const command = variables ? substituteVariables(recipe.command, variables) : recipe.command;
288
271
  const result = await exec(command, cwd, 30000);
289
272
  const output = (result.stdout + result.stderr).trim();
290
- if (format === "json") {
291
- const parsed = parseOutput(command, output);
292
- if (parsed) {
293
- return { content: [{ type: "text", text: JSON.stringify({
294
- recipe: name, exitCode: result.exitCode, parsed: parsed.data, duration: result.duration,
295
- }) }] };
296
- }
297
- }
298
- if (format === "compressed") {
299
- const compressed = compress(command, output, { format: "json" });
273
+ if (format === "json" || format === "compressed") {
274
+ const processed = await processOutput(command, output);
300
275
  return { content: [{ type: "text", text: JSON.stringify({
301
- recipe: name, exitCode: result.exitCode, output: compressed.content, duration: result.duration,
302
- tokensSaved: compressed.tokensSaved,
276
+ recipe: name, exitCode: result.exitCode, summary: processed.summary,
277
+ structured: processed.structured, duration: result.duration,
278
+ tokensSaved: processed.tokensSaved, aiProcessed: processed.aiProcessed,
303
279
  }) }] };
304
280
  }
305
281
  return { content: [{ type: "text", text: JSON.stringify({
@@ -384,10 +360,10 @@ export function createServer() {
384
360
  duration: result.duration, tokensSaved: diff.tokensSaved,
385
361
  }) }] };
386
362
  }
387
- // First run — return full output
388
- const compressed = compress(command, output, { format: "json" });
363
+ // First run — return full output (ANSI stripped)
364
+ const clean = stripAnsi(output);
389
365
  return { content: [{ type: "text", text: JSON.stringify({
390
- exitCode: result.exitCode, output: compressed.content,
366
+ exitCode: result.exitCode, output: clean,
391
367
  diffSummary: "first run", duration: result.duration,
392
368
  }) }] };
393
369
  });
@@ -1,12 +1,16 @@
1
1
  // AI-powered output processor — uses cheap AI to intelligently summarize any output
2
2
  // NOTHING is hardcoded. The AI decides what's important, what's noise, what to keep.
3
3
  import { getProvider } from "./providers/index.js";
4
- import { estimateTokens } from "./parsers/index.js";
4
+ import { estimateTokens } from "./tokens.js";
5
5
  import { recordSaving } from "./economy.js";
6
6
  import { discoverOutputHints } from "./context-hints.js";
7
7
  import { formatProfileHints } from "./tool-profiles.js";
8
+ import { stripAnsi } from "./compression.js";
9
+ import { stripNoise } from "./noise-filter.js";
8
10
  const MIN_LINES_TO_PROCESS = 15;
9
- const MAX_OUTPUT_FOR_AI = 8000; // chars to send to AI (truncate if longer)
11
+ // Reserve ~2000 chars for system prompt + hints + profile + overhead
12
+ const PROMPT_OVERHEAD_CHARS = 2000;
13
+ const MAX_OUTPUT_FOR_AI = 6000; // chars of output to send to AI (leaves room for prompt overhead)
10
14
  const SUMMARIZE_PROMPT = `You are an intelligent terminal assistant. Given a user's original question and the command output, ANSWER THE QUESTION directly.
11
15
 
12
16
  RULES:
@@ -39,8 +43,9 @@ export async function processOutput(command, output, originalPrompt) {
39
43
  netSavingsUsd: 0,
40
44
  };
41
45
  }
42
- // Truncate very long output before sending to AI
43
- let toSummarize = output;
46
+ // Clean output before AI processing — strip ANSI codes and noise
47
+ let toSummarize = stripAnsi(output);
48
+ toSummarize = stripNoise(toSummarize).cleaned;
44
49
  if (toSummarize.length > MAX_OUTPUT_FOR_AI) {
45
50
  const headChars = Math.floor(MAX_OUTPUT_FOR_AI * 0.6);
46
51
  const tailChars = Math.floor(MAX_OUTPUT_FOR_AI * 0.3);
@@ -61,13 +66,11 @@ export async function processOutput(command, output, originalPrompt) {
61
66
  const summary = await provider.complete(`${originalPrompt ? `User asked: ${originalPrompt}\n` : ""}Command: ${command}\nOutput (${lines.length} lines):\n${toSummarize}${hintsBlock}${profileHints}`, {
62
67
  system: SUMMARIZE_PROMPT,
63
68
  maxTokens: 300,
69
+ temperature: 0.2,
64
70
  });
65
71
  const originalTokens = estimateTokens(output);
66
72
  const summaryTokens = estimateTokens(summary);
67
73
  const saved = Math.max(0, originalTokens - summaryTokens);
68
- if (saved > 0) {
69
- recordSaving("compressed", saved);
70
- }
71
74
  // Try to extract structured JSON if the AI returned it
72
75
  let structured;
73
76
  try {
@@ -12,7 +12,9 @@ export class AnthropicProvider {
12
12
  const message = await this.client.messages.create({
13
13
  model: options.model ?? "claude-haiku-4-5-20251001",
14
14
  max_tokens: options.maxTokens ?? 256,
15
- system: options.system,
15
+ temperature: options.temperature ?? 0,
16
+ ...(options.stop ? { stop_sequences: options.stop } : {}),
17
+ system: [{ type: "text", text: options.system, cache_control: { type: "ephemeral" } }],
16
18
  messages: [{ role: "user", content: prompt }],
17
19
  });
18
20
  const block = message.content[0];
@@ -25,7 +27,9 @@ export class AnthropicProvider {
25
27
  const stream = await this.client.messages.stream({
26
28
  model: options.model ?? "claude-haiku-4-5-20251001",
27
29
  max_tokens: options.maxTokens ?? 256,
28
- system: options.system,
30
+ temperature: options.temperature ?? 0,
31
+ ...(options.stop ? { stop_sequences: options.stop } : {}),
32
+ system: [{ type: "text", text: options.system, cache_control: { type: "ephemeral" } }],
29
33
  messages: [{ role: "user", content: prompt }],
30
34
  });
31
35
  for await (const chunk of stream) {
@@ -1,95 +1,8 @@
1
- // Cerebras provider — uses OpenAI-compatible API
2
- // Default for open-source users. Fast inference on Llama models.
3
- const CEREBRAS_BASE_URL = "https://api.cerebras.ai/v1";
4
- const DEFAULT_MODEL = "qwen-3-235b-a22b-instruct-2507";
5
- export class CerebrasProvider {
1
+ // Cerebras provider — fast inference on Qwen/Llama models
2
+ import { OpenAICompatibleProvider } from "./openai-compat.js";
3
+ export class CerebrasProvider extends OpenAICompatibleProvider {
6
4
  name = "cerebras";
7
- apiKey;
8
- constructor() {
9
- this.apiKey = process.env.CEREBRAS_API_KEY ?? "";
10
- }
11
- isAvailable() {
12
- return !!process.env.CEREBRAS_API_KEY;
13
- }
14
- async complete(prompt, options) {
15
- const model = options.model ?? DEFAULT_MODEL;
16
- const res = await fetch(`${CEREBRAS_BASE_URL}/chat/completions`, {
17
- method: "POST",
18
- headers: {
19
- "Content-Type": "application/json",
20
- Authorization: `Bearer ${this.apiKey}`,
21
- },
22
- body: JSON.stringify({
23
- model,
24
- max_tokens: options.maxTokens ?? 256,
25
- messages: [
26
- { role: "system", content: options.system },
27
- { role: "user", content: prompt },
28
- ],
29
- }),
30
- });
31
- if (!res.ok) {
32
- const text = await res.text();
33
- throw new Error(`Cerebras API error ${res.status}: ${text}`);
34
- }
35
- const json = (await res.json());
36
- return (json.choices?.[0]?.message?.content ?? "").trim();
37
- }
38
- async stream(prompt, options, callbacks) {
39
- const model = options.model ?? DEFAULT_MODEL;
40
- const res = await fetch(`${CEREBRAS_BASE_URL}/chat/completions`, {
41
- method: "POST",
42
- headers: {
43
- "Content-Type": "application/json",
44
- Authorization: `Bearer ${this.apiKey}`,
45
- },
46
- body: JSON.stringify({
47
- model,
48
- max_tokens: options.maxTokens ?? 256,
49
- stream: true,
50
- messages: [
51
- { role: "system", content: options.system },
52
- { role: "user", content: prompt },
53
- ],
54
- }),
55
- });
56
- if (!res.ok) {
57
- const text = await res.text();
58
- throw new Error(`Cerebras API error ${res.status}: ${text}`);
59
- }
60
- let result = "";
61
- const reader = res.body?.getReader();
62
- if (!reader)
63
- throw new Error("No response body");
64
- const decoder = new TextDecoder();
65
- let buffer = "";
66
- while (true) {
67
- const { done, value } = await reader.read();
68
- if (done)
69
- break;
70
- buffer += decoder.decode(value, { stream: true });
71
- const lines = buffer.split("\n");
72
- buffer = lines.pop() ?? "";
73
- for (const line of lines) {
74
- const trimmed = line.trim();
75
- if (!trimmed.startsWith("data: "))
76
- continue;
77
- const data = trimmed.slice(6);
78
- if (data === "[DONE]")
79
- break;
80
- try {
81
- const parsed = JSON.parse(data);
82
- const delta = parsed.choices?.[0]?.delta?.content;
83
- if (delta) {
84
- result += delta;
85
- callbacks.onToken(result.trim());
86
- }
87
- }
88
- catch {
89
- // skip malformed chunks
90
- }
91
- }
92
- }
93
- return result.trim();
94
- }
5
+ baseUrl = "https://api.cerebras.ai/v1";
6
+ defaultModel = "qwen-3-235b-a22b-instruct-2507";
7
+ apiKeyEnvVar = "CEREBRAS_API_KEY";
95
8
  }
@@ -1,95 +1,8 @@
1
- // Groq provider — uses OpenAI-compatible API
2
- // Ultra-fast inference. Supports Llama, Qwen, Kimi models.
3
- const GROQ_BASE_URL = "https://api.groq.com/openai/v1";
4
- const DEFAULT_MODEL = "openai/gpt-oss-120b";
5
- export class GroqProvider {
1
+ // Groq provider — ultra-fast inference
2
+ import { OpenAICompatibleProvider } from "./openai-compat.js";
3
+ export class GroqProvider extends OpenAICompatibleProvider {
6
4
  name = "groq";
7
- apiKey;
8
- constructor() {
9
- this.apiKey = process.env.GROQ_API_KEY ?? "";
10
- }
11
- isAvailable() {
12
- return !!process.env.GROQ_API_KEY;
13
- }
14
- async complete(prompt, options) {
15
- const model = options.model ?? DEFAULT_MODEL;
16
- const res = await fetch(`${GROQ_BASE_URL}/chat/completions`, {
17
- method: "POST",
18
- headers: {
19
- "Content-Type": "application/json",
20
- Authorization: `Bearer ${this.apiKey}`,
21
- },
22
- body: JSON.stringify({
23
- model,
24
- max_tokens: options.maxTokens ?? 256,
25
- messages: [
26
- { role: "system", content: options.system },
27
- { role: "user", content: prompt },
28
- ],
29
- }),
30
- });
31
- if (!res.ok) {
32
- const text = await res.text();
33
- throw new Error(`Groq API error ${res.status}: ${text}`);
34
- }
35
- const json = (await res.json());
36
- return (json.choices?.[0]?.message?.content ?? "").trim();
37
- }
38
- async stream(prompt, options, callbacks) {
39
- const model = options.model ?? DEFAULT_MODEL;
40
- const res = await fetch(`${GROQ_BASE_URL}/chat/completions`, {
41
- method: "POST",
42
- headers: {
43
- "Content-Type": "application/json",
44
- Authorization: `Bearer ${this.apiKey}`,
45
- },
46
- body: JSON.stringify({
47
- model,
48
- max_tokens: options.maxTokens ?? 256,
49
- stream: true,
50
- messages: [
51
- { role: "system", content: options.system },
52
- { role: "user", content: prompt },
53
- ],
54
- }),
55
- });
56
- if (!res.ok) {
57
- const text = await res.text();
58
- throw new Error(`Groq API error ${res.status}: ${text}`);
59
- }
60
- let result = "";
61
- const reader = res.body?.getReader();
62
- if (!reader)
63
- throw new Error("No response body");
64
- const decoder = new TextDecoder();
65
- let buffer = "";
66
- while (true) {
67
- const { done, value } = await reader.read();
68
- if (done)
69
- break;
70
- buffer += decoder.decode(value, { stream: true });
71
- const lines = buffer.split("\n");
72
- buffer = lines.pop() ?? "";
73
- for (const line of lines) {
74
- const trimmed = line.trim();
75
- if (!trimmed.startsWith("data: "))
76
- continue;
77
- const data = trimmed.slice(6);
78
- if (data === "[DONE]")
79
- break;
80
- try {
81
- const parsed = JSON.parse(data);
82
- const delta = parsed.choices?.[0]?.delta?.content;
83
- if (delta) {
84
- result += delta;
85
- callbacks.onToken(result.trim());
86
- }
87
- }
88
- catch {
89
- // skip malformed chunks
90
- }
91
- }
92
- }
93
- return result.trim();
94
- }
5
+ baseUrl = "https://api.groq.com/openai/v1";
6
+ defaultModel = "openai/gpt-oss-120b";
7
+ apiKeyEnvVar = "GROQ_API_KEY";
95
8
  }
@@ -1,4 +1,4 @@
1
- // Provider auto-detection and management
1
+ // Provider auto-detection and management — with fallback on failure
2
2
  import { DEFAULT_PROVIDER_CONFIG } from "./base.js";
3
3
  import { AnthropicProvider } from "./anthropic.js";
4
4
  import { CerebrasProvider } from "./cerebras.js";
@@ -6,9 +6,10 @@ import { GroqProvider } from "./groq.js";
6
6
  import { XaiProvider } from "./xai.js";
7
7
  export { DEFAULT_PROVIDER_CONFIG } from "./base.js";
8
8
  let _provider = null;
9
+ let _failedProviders = new Set();
9
10
  /** Get the active LLM provider. Auto-detects based on available API keys. */
10
11
  export function getProvider(config) {
11
- if (_provider)
12
+ if (_provider && !_failedProviders.has(_provider.name))
12
13
  return _provider;
13
14
  const cfg = config ?? DEFAULT_PROVIDER_CONFIG;
14
15
  _provider = resolveProvider(cfg);
@@ -17,51 +18,99 @@ export function getProvider(config) {
17
18
  /** Reset the cached provider (useful when config changes). */
18
19
  export function resetProvider() {
19
20
  _provider = null;
21
+ _failedProviders.clear();
22
+ }
23
+ /** Get a fallback-wrapped provider that tries alternatives on failure */
24
+ export function getProviderWithFallback(config) {
25
+ const primary = getProvider(config);
26
+ return new FallbackProvider(primary);
20
27
  }
21
28
  function resolveProvider(config) {
22
- if (config.provider === "cerebras") {
23
- const p = new CerebrasProvider();
24
- if (!p.isAvailable())
25
- throw new Error("CEREBRAS_API_KEY not set. Run: export CEREBRAS_API_KEY=your-key");
26
- return p;
27
- }
28
- if (config.provider === "anthropic") {
29
- const p = new AnthropicProvider();
30
- if (!p.isAvailable())
31
- throw new Error("ANTHROPIC_API_KEY not set. Run: export ANTHROPIC_API_KEY=your-key");
32
- return p;
29
+ if (config.provider !== "auto") {
30
+ const providers = {
31
+ cerebras: () => new CerebrasProvider(),
32
+ anthropic: () => new AnthropicProvider(),
33
+ groq: () => new GroqProvider(),
34
+ xai: () => new XaiProvider(),
35
+ };
36
+ const factory = providers[config.provider];
37
+ if (factory) {
38
+ const p = factory();
39
+ if (!p.isAvailable())
40
+ throw new Error(`${config.provider.toUpperCase()}_API_KEY not set`);
41
+ return p;
42
+ }
33
43
  }
34
- if (config.provider === "groq") {
35
- const p = new GroqProvider();
36
- if (!p.isAvailable())
37
- throw new Error("GROQ_API_KEY not set. Run: export GROQ_API_KEY=your-key");
38
- return p;
44
+ // auto: prefer Cerebras, then xAI, then Groq, then Anthropic — skip failed
45
+ const candidates = [
46
+ new CerebrasProvider(),
47
+ new XaiProvider(),
48
+ new GroqProvider(),
49
+ new AnthropicProvider(),
50
+ ];
51
+ for (const p of candidates) {
52
+ if (p.isAvailable() && !_failedProviders.has(p.name))
53
+ return p;
39
54
  }
40
- if (config.provider === "xai") {
41
- const p = new XaiProvider();
42
- if (!p.isAvailable())
43
- throw new Error("XAI_API_KEY not set. Run: export XAI_API_KEY=your-key");
44
- return p;
55
+ // If all failed, clear failures and try again
56
+ if (_failedProviders.size > 0) {
57
+ _failedProviders.clear();
58
+ for (const p of candidates) {
59
+ if (p.isAvailable())
60
+ return p;
61
+ }
45
62
  }
46
- // auto: prefer Cerebras (qwen-235b, fast + accurate), then xAI, then Groq, then Anthropic
47
- const cerebras = new CerebrasProvider();
48
- if (cerebras.isAvailable())
49
- return cerebras;
50
- const xai = new XaiProvider();
51
- if (xai.isAvailable())
52
- return xai;
53
- const groq = new GroqProvider();
54
- if (groq.isAvailable())
55
- return groq;
56
- const anthropic = new AnthropicProvider();
57
- if (anthropic.isAvailable())
58
- return anthropic;
59
63
  throw new Error("No API key found. Set one of:\n" +
60
64
  " export CEREBRAS_API_KEY=your-key (free, open-source)\n" +
61
65
  " export GROQ_API_KEY=your-key (free, fast)\n" +
62
66
  " export XAI_API_KEY=your-key (Grok, code-optimized)\n" +
63
67
  " export ANTHROPIC_API_KEY=your-key (Claude)");
64
68
  }
69
+ /** Provider wrapper that falls back to alternatives on API errors */
70
+ class FallbackProvider {
71
+ name;
72
+ primary;
73
+ constructor(primary) {
74
+ this.primary = primary;
75
+ this.name = primary.name;
76
+ }
77
+ isAvailable() {
78
+ return this.primary.isAvailable();
79
+ }
80
+ async complete(prompt, options) {
81
+ try {
82
+ return await this.primary.complete(prompt, options);
83
+ }
84
+ catch (err) {
85
+ const fallback = this.getFallback();
86
+ if (fallback)
87
+ return fallback.complete(prompt, options);
88
+ throw err;
89
+ }
90
+ }
91
+ async stream(prompt, options, callbacks) {
92
+ try {
93
+ return await this.primary.stream(prompt, options, callbacks);
94
+ }
95
+ catch (err) {
96
+ const fallback = this.getFallback();
97
+ if (fallback)
98
+ return fallback.complete(prompt, options); // fallback doesn't stream
99
+ throw err;
100
+ }
101
+ }
102
+ getFallback() {
103
+ _failedProviders.add(this.primary.name);
104
+ _provider = null; // force re-resolve
105
+ try {
106
+ const next = getProvider();
107
+ if (next.name !== this.primary.name)
108
+ return next;
109
+ }
110
+ catch { }
111
+ return null;
112
+ }
113
+ }
65
114
  /** List available providers (for onboarding UI). */
66
115
  export function availableProviders() {
67
116
  return [