@hemansubedi/aether-ai 1.0.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 (90) hide show
  1. package/.gitattributes +3 -0
  2. package/.github/workflows/live-stats.yml +42 -0
  3. package/.github/workflows/publish.yml +34 -0
  4. package/.github/workflows/update-preview.yml +41 -0
  5. package/INSTALL.md +59 -0
  6. package/LICENSE +21 -0
  7. package/README.md +397 -0
  8. package/assets/aether-arena.svg +72 -0
  9. package/assets/aether-banner.svg +62 -0
  10. package/assets/aether-router.svg +129 -0
  11. package/dist/agent.js +125 -0
  12. package/dist/arena.js +486 -0
  13. package/dist/checkpoint.js +105 -0
  14. package/dist/client.js +95 -0
  15. package/dist/combos.js +176 -0
  16. package/dist/commands.js +483 -0
  17. package/dist/config.js +104 -0
  18. package/dist/cost.js +176 -0
  19. package/dist/git.js +52 -0
  20. package/dist/health.js +81 -0
  21. package/dist/index.js +272 -0
  22. package/dist/keys.js +128 -0
  23. package/dist/memory.js +98 -0
  24. package/dist/modes.js +68 -0
  25. package/dist/providers/index.js +32 -0
  26. package/dist/providers/ollama.js +206 -0
  27. package/dist/providers/openai-compat.js +181 -0
  28. package/dist/providers/openrouter.js +189 -0
  29. package/dist/providers/registry.js +211 -0
  30. package/dist/router-engine.js +200 -0
  31. package/dist/router.js +171 -0
  32. package/dist/server.js +210 -0
  33. package/dist/session.js +97 -0
  34. package/dist/settings.js +97 -0
  35. package/dist/skills.js +100 -0
  36. package/dist/tokensaver.js +50 -0
  37. package/dist/tools/filesystem.js +243 -0
  38. package/dist/tools/git.js +53 -0
  39. package/dist/tools/glob.js +175 -0
  40. package/dist/tools/grep.js +193 -0
  41. package/dist/tools/registry.js +39 -0
  42. package/dist/tools/vision.js +140 -0
  43. package/dist/tools/websearch.js +118 -0
  44. package/dist/tui.js +562 -0
  45. package/dist/types.js +8 -0
  46. package/docs/preview.txt +51 -0
  47. package/docs/screenshots.md +110 -0
  48. package/docs/stats.md +5 -0
  49. package/install.ps1 +170 -0
  50. package/install.sh +196 -0
  51. package/package.json +34 -0
  52. package/scripts/generate-stats-card.ts +62 -0
  53. package/scripts/patch_index.ps1 +17 -0
  54. package/scripts/release.sh +7 -0
  55. package/src/agent.ts +146 -0
  56. package/src/arena.ts +584 -0
  57. package/src/checkpoint.ts +111 -0
  58. package/src/client.ts +172 -0
  59. package/src/combos.ts +199 -0
  60. package/src/commands.ts +973 -0
  61. package/src/config.ts +122 -0
  62. package/src/cost.ts +206 -0
  63. package/src/git.ts +68 -0
  64. package/src/health.ts +90 -0
  65. package/src/index.ts +281 -0
  66. package/src/keys.ts +135 -0
  67. package/src/memory.ts +101 -0
  68. package/src/modes.ts +84 -0
  69. package/src/providers/index.ts +59 -0
  70. package/src/providers/ollama.ts +222 -0
  71. package/src/providers/openai-compat.ts +188 -0
  72. package/src/providers/openrouter.ts +198 -0
  73. package/src/providers/registry.ts +223 -0
  74. package/src/router-engine.ts +214 -0
  75. package/src/router.ts +195 -0
  76. package/src/server.ts +242 -0
  77. package/src/session.ts +111 -0
  78. package/src/settings.ts +125 -0
  79. package/src/skills.ts +106 -0
  80. package/src/tokensaver.ts +57 -0
  81. package/src/tools/filesystem.ts +258 -0
  82. package/src/tools/git.ts +53 -0
  83. package/src/tools/glob.ts +180 -0
  84. package/src/tools/grep.ts +192 -0
  85. package/src/tools/registry.ts +54 -0
  86. package/src/tools/vision.ts +152 -0
  87. package/src/tools/websearch.ts +130 -0
  88. package/src/tui.ts +664 -0
  89. package/src/types.ts +77 -0
  90. package/tsconfig.json +16 -0
@@ -0,0 +1,223 @@
1
+ export interface FreeProvider {
2
+ name: string;
3
+ baseURL: string;
4
+ type: "ollama" | "openrouter" | "openai-compatible";
5
+ models: string[];
6
+ priority: number;
7
+ enabled: boolean;
8
+ maxRetries: number;
9
+ timeoutMs: number;
10
+ }
11
+
12
+ export const PROVIDER_REGISTRY: FreeProvider[] = [
13
+ {
14
+ name: "ollama-local",
15
+ baseURL: "http://localhost:11434",
16
+ type: "ollama",
17
+ models: ["goekdenizguelmez/JOSIEFIED-Qwen3:8b", "hf.co/OBLITERATUS/Qwen3.8-27B-OBLITERATED:Q4_K_M", "llama3.1:8b", "qwen2.5:7b", "mistral:7b", "phi3:medium"],
18
+ priority: 1,
19
+ enabled: true,
20
+ maxRetries: 2,
21
+ timeoutMs: 120000,
22
+ },
23
+ {
24
+ name: "openrouter-free",
25
+ baseURL: "https://openrouter.ai/api/v1",
26
+ type: "openrouter",
27
+ models: [
28
+ "openrouter/auto",
29
+ "meta-llama/llama-3.1-8b-instruct:free",
30
+ "google/gemma-2-9b-it:free",
31
+ "mistralai/mistral-7b-instruct:free",
32
+ "nvidia/nemotron-70b-instruct:free",
33
+ ],
34
+ priority: 2,
35
+ enabled: true,
36
+ maxRetries: 3,
37
+ timeoutMs: 120000,
38
+ },
39
+ {
40
+ name: "openai-compatible",
41
+ baseURL: "",
42
+ type: "openai-compatible",
43
+ models: ["gpt-4o-mini"],
44
+ priority: 99,
45
+ enabled: false,
46
+ maxRetries: 3,
47
+ timeoutMs: 120000,
48
+ },
49
+ {
50
+ name: "groq",
51
+ baseURL: "https://api.groq.com/openai/v1",
52
+ type: "openai-compatible",
53
+ models: ["llama-3.1-8b-instant", "llama-3.1-70b-versatile", "llama-3.2-1b-preview", "llama-3.2-11b-vision-preview", "mixtral-8x7b-32768"],
54
+ priority: 3,
55
+ enabled: true,
56
+ maxRetries: 3,
57
+ timeoutMs: 120000,
58
+ },
59
+ {
60
+ name: "mistral",
61
+ baseURL: "https://api.mistral.com/v1",
62
+ type: "openai-compatible",
63
+ models: ["mistral-small-latest", "mistral-medium-latest", "mistral-large-latest", "mixtral-8x7b-latest"],
64
+ priority: 4,
65
+ enabled: true,
66
+ maxRetries: 3,
67
+ timeoutMs: 120000,
68
+ },
69
+ {
70
+ name: "cohere",
71
+ baseURL: "https://api.cohere.com/v1",
72
+ type: "openai-compatible",
73
+ models: ["command-r", "command-r-plus", "command-light"],
74
+ priority: 5,
75
+ enabled: true,
76
+ maxRetries: 3,
77
+ timeoutMs: 120000,
78
+ },
79
+ {
80
+ name: "huggingface",
81
+ baseURL: "https://api-inference.huggingface.co",
82
+ type: "openai-compatible",
83
+ models: ["HuggingFaceH4/zephyr-7b-beta", "HuggingFaceH4/mistral-7b-it", "bigcode/starcoder2-15b"],
84
+ priority: 6,
85
+ enabled: true,
86
+ maxRetries: 3,
87
+ timeoutMs: 120000,
88
+ },
89
+ {
90
+ name: "fireworks",
91
+ baseURL: "https://api.fireworks.ai/inference/v1",
92
+ type: "openai-compatible",
93
+ models: ["accounts/fireworks/models/llama-v3-8b-instruct", "accounts/fireworks/models/llama-v3-70b-instruct", "accounts/fireworks/models/mixtral-8x7b-instruct"],
94
+ priority: 7,
95
+ enabled: true,
96
+ maxRetries: 3,
97
+ timeoutMs: 120000,
98
+ },
99
+ {
100
+ name: "together",
101
+ baseURL: "https://api.together.xyz/v1",
102
+ type: "openai-compatible",
103
+ models: ["meta-llama/Llama-3-8b-hf-chat", "meta-llama/Llama-3-70b-hf-chat", "mistralai/Mixtral-8x7B-Instruct-v0.1", "Qwen/Qwen1.5-7B-Chat"],
104
+ priority: 8,
105
+ enabled: true,
106
+ maxRetries: 3,
107
+ timeoutMs: 120000,
108
+ },
109
+ {
110
+ name: "deepseek",
111
+ baseURL: "https://api.deepseek.com/v1",
112
+ type: "openai-compatible",
113
+ models: ["deepseek-chat", "deepseek-coder", "deepseek-reasoner"],
114
+ priority: 9,
115
+ enabled: true,
116
+ maxRetries: 3,
117
+ timeoutMs: 120000,
118
+ },
119
+ {
120
+ name: "gemini",
121
+ baseURL: "https://generativelanguage.googleapis.com/v1/openai",
122
+ type: "openai-compatible",
123
+ models: ["gemini-1.5-flash", "gemini-1.5-pro", "gemini-2.0-flash-exp"],
124
+ priority: 10,
125
+ enabled: true,
126
+ maxRetries: 3,
127
+ timeoutMs: 120000,
128
+ },
129
+ {
130
+ name: "xai",
131
+ baseURL: "https://api.x.ai/v1",
132
+ type: "openai-compatible",
133
+ models: ["grok-2", "grok-2-mini", "grok-beta", "grok-mini"],
134
+ priority: 11,
135
+ enabled: true,
136
+ maxRetries: 3,
137
+ timeoutMs: 120000,
138
+ },
139
+ {
140
+ name: "perplexity",
141
+ baseURL: "https://api.perplexity.ai",
142
+ type: "openai-compatible",
143
+ models: ["sonar", "sonar-pro", "sonar-reasoning", "sonar-deep-research"],
144
+ priority: 12,
145
+ enabled: true,
146
+ maxRetries: 3,
147
+ timeoutMs: 120000,
148
+ },
149
+ {
150
+ name: "cerebras",
151
+ baseURL: "https://api.cerebras.ai/v1",
152
+ type: "openai-compatible",
153
+ models: ["llama3.1-8b", "llama3.1-70b", "llama3.2-1b", "llama3.2-11b-vision", "qwen-32b"],
154
+ priority: 13,
155
+ enabled: true,
156
+ maxRetries: 3,
157
+ timeoutMs: 120000,
158
+ },
159
+ {
160
+ name: "cloudflare",
161
+ baseURL: "",
162
+ type: "openai-compatible",
163
+ models: ["@cf/meta-llama/llama-3.1-8b-instruct", "@cf/qwen/qwen2.5-7b-instruct", "@cf/mistral/mistral-7b-instruct"],
164
+ priority: 99,
165
+ enabled: false,
166
+ maxRetries: 3,
167
+ timeoutMs: 120000,
168
+ },
169
+ {
170
+ name: "nvidia",
171
+ baseURL: "https://integrate.api.nvidia.com/v1",
172
+ type: "openai-compatible",
173
+ models: ["meta/llama-3.1-8b-instruct", "meta/llama-3.1-70b-instruct", "mistralai/mistral-7b-instruct", "nvidia/nemotron-4-340b-instruct"],
174
+ priority: 14,
175
+ enabled: true,
176
+ maxRetries: 3,
177
+ timeoutMs: 120000,
178
+ },
179
+ {
180
+ name: "voyage",
181
+ baseURL: "",
182
+ type: "openai-compatible",
183
+ models: ["voyage-3-large", "voyage-3", "voyage-code-3"],
184
+ priority: 99,
185
+ enabled: false,
186
+ maxRetries: 3,
187
+ timeoutMs: 120000,
188
+ },
189
+ {
190
+ name: "jina",
191
+ baseURL: "https://api.jina.ai/v1",
192
+ type: "openai-compatible",
193
+ models: ["jina-embeddings-v3", "jina-clip-v2"],
194
+ priority: 15,
195
+ enabled: true,
196
+ maxRetries: 3,
197
+ timeoutMs: 120000,
198
+ },
199
+ {
200
+ name: "parasail",
201
+ baseURL: "https://api.parasail.ai/v1",
202
+ type: "openai-compatible",
203
+ models: ["parasail-dqn-turbo-70b", "parasail-mixtral-8x7b", "parasail-llama-3-8b"],
204
+ priority: 16,
205
+ enabled: true,
206
+ maxRetries: 3,
207
+ timeoutMs: 120000,
208
+ },
209
+ {
210
+ name: "featherless",
211
+ baseURL: "https://api.featherless.ai/v1",
212
+ type: "openai-compatible",
213
+ models: ["featherless/qwen-2.5-7b-instruct", "featherless/llama-3.1-8b-instruct", "featherless/mistral-7b-instruct"],
214
+ priority: 17,
215
+ enabled: true,
216
+ maxRetries: 3,
217
+ timeoutMs: 120000,
218
+ },
219
+ ];
220
+
221
+ export function getProvider(name: string): FreeProvider | undefined {
222
+ return PROVIDER_REGISTRY.find((p) => p.name === name);
223
+ }
@@ -0,0 +1,214 @@
1
+ import { HealthTracker } from "./health.js";
2
+ import { createProvider, type Provider, type ProviderConfig } from "./providers/index.js";
3
+ import { PROVIDER_REGISTRY, type FreeProvider } from "./providers/registry.js";
4
+ import { KeyManager, ENV_MAP } from "./keys.js";
5
+ import { type Combo } from "./combos.js";
6
+
7
+ export interface RouteAttempt {
8
+ provider: string;
9
+ model: string;
10
+ ok: boolean;
11
+ error?: string;
12
+ latencyMs: number;
13
+ }
14
+
15
+ export interface RouteResult {
16
+ text: string;
17
+ toolCalls: any[];
18
+ usage?: { input_tokens: number; output_tokens: number };
19
+ attempts: RouteAttempt[];
20
+ provider: string;
21
+ model: string;
22
+ }
23
+
24
+ export class RouterEngine {
25
+ private health: HealthTracker;
26
+ private cache = new Map<string, Provider>();
27
+ private configs: ProviderConfig[];
28
+ private keyManager: KeyManager;
29
+ activeModel?: string;
30
+
31
+ constructor(providers?: FreeProvider[], health?: HealthTracker, keyManager?: KeyManager) {
32
+ this.health = health ?? new HealthTracker();
33
+ this.keyManager = keyManager ?? KeyManager.instance();
34
+ this.configs = (providers ?? PROVIDER_REGISTRY).map((p) => ({
35
+ name: p.name,
36
+ type: p.type,
37
+ baseURL: p.baseURL,
38
+ apiKey: this.keyManager.get(p.name) ?? process.env[ENV_MAP[p.name] ?? ""],
39
+ models: p.models,
40
+ priority: p.priority,
41
+ enabled: p.enabled,
42
+ maxRetries: p.maxRetries,
43
+ timeoutMs: p.timeoutMs,
44
+ }));
45
+ }
46
+
47
+ get configs_(): ProviderConfig[] { return this.configs; }
48
+ get healthTracker(): HealthTracker { return this.health; }
49
+ get keys(): KeyManager { return this.keyManager; }
50
+
51
+ /** Update the key for a provider and invalidate the cached provider so the
52
+ * new credential takes effect on the next request. */
53
+ setKey(name: string, key: string): void {
54
+ this.keyManager.set(name, key);
55
+ const cfg = this.configs.find((c) => c.name === name);
56
+ if (cfg) cfg.apiKey = key.trim() || undefined;
57
+ this.cache.delete(name);
58
+ }
59
+
60
+ /** Activate a combo: rebuild the router's active provider list from the
61
+ * combo's providers (in order), set the active model to the combo's first
62
+ * model, and invalidate the provider cache so the new providers load. */
63
+ applyCombo(combo: Combo): string {
64
+ this.configs = (combo.providers ?? [])
65
+ .map((name) => {
66
+ const p = PROVIDER_REGISTRY.find((x: FreeProvider) => x.name === name);
67
+ if (!p) return undefined;
68
+ return {
69
+ name: p.name,
70
+ type: p.type,
71
+ baseURL: p.baseURL,
72
+ apiKey: this.keyManager.get(p.name) ?? process.env[ENV_MAP[p.name] ?? ""],
73
+ models: p.models,
74
+ priority: p.priority,
75
+ enabled: p.enabled,
76
+ maxRetries: p.maxRetries,
77
+ timeoutMs: p.timeoutMs,
78
+ } as ProviderConfig;
79
+ })
80
+ .filter((c): c is ProviderConfig => Boolean(c));
81
+ this.cache.clear();
82
+ this.activeModel = combo.models?.[0];
83
+ const provs = this.configs.map((c) => c.name).join(", ") || "(none)";
84
+ return `Combo "${combo.name}" active: ${this.configs.length} provider(s) [${provs}], model: ${this.activeModel ?? "(none)"}`;
85
+ }
86
+
87
+ private async getProvider(cfg: ProviderConfig): Promise<Provider> {
88
+ const cached = this.cache.get(cfg.name);
89
+ if (cached) return cached;
90
+ const p = await createProvider(cfg);
91
+ this.cache.set(cfg.name, p);
92
+ return p;
93
+ }
94
+
95
+ private sorted(): ProviderConfig[] {
96
+ return [...this.configs].filter((p) => p.enabled).sort((a, b) => a.priority - b.priority);
97
+ }
98
+
99
+ async *chatStream(
100
+ messages: any[],
101
+ tools: any[],
102
+ opts?: { temperature?: number; maxTokens?: number; signal?: AbortSignal; excludeProviders?: string[] }
103
+ ): AsyncGenerator<any> {
104
+ const order = this.sorted().filter((p) => !opts?.excludeProviders?.includes(p.name));
105
+ if (order.length === 0) {
106
+ yield { type: "error", error: "No providers configured" };
107
+ return;
108
+ }
109
+ let lastError = "";
110
+ for (const cfg of order) {
111
+ if (!this.health.isAvailable(cfg.name)) continue;
112
+ const provider = await this.getProvider(cfg);
113
+ const model = cfg.models[0] ?? "";
114
+ const start = Date.now();
115
+ try {
116
+ let usage: any;
117
+ for await (const chunk of provider.chat(messages, tools, {
118
+ signal: opts?.signal,
119
+ temperature: opts?.temperature,
120
+ maxTokens: opts?.maxTokens,
121
+ })) {
122
+ if (chunk.type === "done") usage = chunk.usage;
123
+ if (chunk.type === "error" && chunk.error) throw new Error(chunk.error);
124
+ yield chunk;
125
+ }
126
+ this.health.recordSuccess(cfg.name);
127
+ if (!usage) yield { type: "done", usage };
128
+ return;
129
+ } catch (err) {
130
+ const msg = (err as Error).message;
131
+ lastError = msg;
132
+ this.health.recordFailure(cfg.name, msg);
133
+ if (opts?.signal?.aborted) {
134
+ yield { type: "error", error: `Aborted: ${msg}` };
135
+ return;
136
+ }
137
+ yield {
138
+ type: "error",
139
+ error: `Provider ${cfg.name} failed (${msg}); failing over...`,
140
+ };
141
+ }
142
+ }
143
+ yield {
144
+ type: "error",
145
+ error: `All ${order.length} providers failed. Last: ${lastError || "unknown"}`,
146
+ };
147
+ }
148
+
149
+ async chat(messages: any[], tools: any[], opts?: { temperature?: number; maxTokens?: number; signal?: AbortSignal; excludeProviders?: string[] }): Promise<RouteResult> {
150
+ const order = this.sorted().filter((p) => !opts?.excludeProviders?.includes(p.name));
151
+ const attempts: RouteAttempt[] = [];
152
+ let lastError = "";
153
+ for (const cfg of order) {
154
+ if (!this.health.isAvailable(cfg.name)) continue;
155
+ const provider = await this.getProvider(cfg);
156
+ const model = cfg.models[0] ?? "";
157
+ const start = Date.now();
158
+ try {
159
+ let text = "", toolCalls: any[] = [], usage: any;
160
+ for await (const chunk of provider.chat(messages, tools, { signal: opts?.signal, temperature: opts?.temperature, maxTokens: opts?.maxTokens })) {
161
+ if (chunk.type === "text" && chunk.text) text += chunk.text;
162
+ if (chunk.type === "tool_call" && chunk.tool_call) toolCalls.push(chunk.tool_call);
163
+ if (chunk.type === "done") usage = chunk.usage;
164
+ if (chunk.type === "error" && chunk.error) throw new Error(chunk.error);
165
+ }
166
+ this.health.recordSuccess(cfg.name);
167
+ attempts.push({ provider: cfg.name, model, ok: true, latencyMs: Date.now() - start });
168
+ return { text, toolCalls, usage, attempts, provider: cfg.name, model };
169
+ } catch (err) {
170
+ const msg = (err as Error).message;
171
+ lastError = msg;
172
+ this.health.recordFailure(cfg.name, msg);
173
+ attempts.push({ provider: cfg.name, model, ok: false, error: msg, latencyMs: Date.now() - start });
174
+ if (opts?.signal?.aborted) break;
175
+ }
176
+ }
177
+ throw new Error(`All ${order.length} providers failed. Last: ${lastError}. Attempts: ${JSON.stringify(attempts)}`);
178
+ }
179
+
180
+ async listFreeModels(): Promise<Record<string, string[]>> {
181
+ const out: Record<string, string[]> = {};
182
+ for (const cfg of this.sorted()) {
183
+ try {
184
+ const p = await this.getProvider(cfg);
185
+ out[cfg.name] = await p.listModels();
186
+ } catch { out[cfg.name] = cfg.models; }
187
+ }
188
+ return out;
189
+ }
190
+
191
+ async healthAll() {
192
+ const out: any[] = [];
193
+ for (const cfg of this.configs) {
194
+ if (!cfg.enabled) continue;
195
+ try {
196
+ const provider = await this.getProvider(cfg);
197
+ const status = await provider.health();
198
+ out.push({ provider: cfg.name, ...status });
199
+ } catch {
200
+ out.push({
201
+ provider: cfg.name,
202
+ healthy: false,
203
+ failures: 0,
204
+ lastCheck: 0,
205
+ circuitOpen: false,
206
+ cooldownUntil: 0,
207
+ });
208
+ }
209
+ }
210
+ return out;
211
+ }
212
+
213
+ resetHealth() { this.health.resetAll(); }
214
+ }
package/src/router.ts ADDED
@@ -0,0 +1,195 @@
1
+ import type {
2
+ ChatChunk,
3
+ Message,
4
+ ProviderConfig,
5
+ RouteDecision,
6
+ ToolDef,
7
+ HealthStatus,
8
+ } from "./types.js";
9
+ import { HealthTracker } from "./health.js";
10
+ import { createProvider, type Provider } from "./providers/index.js";
11
+
12
+ export class Router {
13
+ readonly configs: ProviderConfig[];
14
+ private health: HealthTracker;
15
+ private cache = new Map<string, Provider>();
16
+ private modelCache = new Map<string, Provider>();
17
+
18
+ activeProvider?: string;
19
+ activeModel?: string;
20
+
21
+ constructor(providers: ProviderConfig[], health: HealthTracker) {
22
+ this.configs = providers;
23
+ this.health = health;
24
+ }
25
+
26
+ private async getProvider(config: ProviderConfig): Promise<Provider> {
27
+ const cached = this.cache.get(config.name);
28
+ if (cached) return cached;
29
+ const p = await createProvider(config);
30
+ this.cache.set(config.name, p);
31
+ return p;
32
+ }
33
+
34
+ private sortedConfigs(): ProviderConfig[] {
35
+ return [...this.configs]
36
+ .filter((p) => p.enabled)
37
+ .sort((a, b) => a.priority - b.priority);
38
+ }
39
+
40
+ private findConfig(name?: string): ProviderConfig | undefined {
41
+ if (!name) return undefined;
42
+ return this.configs.find((c) => c.name === name);
43
+ }
44
+
45
+ resolveModel(modelId: string): { provider: ProviderConfig; model: string } | null {
46
+ const exact = this.configs.find((c) => c.models.includes(modelId));
47
+ if (exact) return { provider: exact, model: modelId };
48
+ const first = this.sortedConfigs()[0];
49
+ return first ? { provider: first, model: modelId } : null;
50
+ }
51
+
52
+ async getModelProvider(modelId: string): Promise<Provider> {
53
+ const cached = this.modelCache.get(modelId);
54
+ if (cached) return cached;
55
+ const cfg = this.configs.find((c) => c.models.includes(modelId)) || this.sortedConfigs()[0];
56
+ if (!cfg) throw new Error(`No provider configured to serve model "${modelId}"`);
57
+ const override: ProviderConfig = { ...cfg, models: [modelId] };
58
+ const p = await createProvider(override);
59
+ this.modelCache.set(modelId, p);
60
+ return p;
61
+ }
62
+
63
+ select(): RouteDecision | null {
64
+ const cfg = this.findConfig(this.activeProvider) || this.sortedConfigs()[0];
65
+ if (cfg && this.health.isAvailable(cfg.name)) {
66
+ return {
67
+ provider: cfg.name,
68
+ model: this.activeModel || cfg.models[0] || "",
69
+ reason: this.activeProvider ? `selected (active: ${this.activeProvider})` : `selected by priority ${cfg.priority}`,
70
+ };
71
+ }
72
+ for (const c of this.sortedConfigs()) {
73
+ if (this.health.isAvailable(c.name)) {
74
+ return { provider: c.name, model: this.activeModel || c.models[0] || "", reason: "fallback" };
75
+ }
76
+ }
77
+ return null;
78
+ }
79
+
80
+ setActiveProvider(name?: string): void {
81
+ this.activeProvider = name;
82
+ }
83
+
84
+ setActiveModel(model: string): void {
85
+ this.activeModel = model;
86
+ const cfg = this.findConfig(this.activeProvider) || this.sortedConfigs()[0];
87
+ if (cfg) {
88
+ cfg.models = [model];
89
+ }
90
+ }
91
+
92
+ getActiveProvider(): string | undefined {
93
+ return this.activeProvider;
94
+ }
95
+
96
+ getActiveModel(): string | undefined {
97
+ return this.activeModel;
98
+ }
99
+
100
+ getProviderNames(): string[] {
101
+ return this.configs.filter((c) => c.enabled).map((c) => c.name);
102
+ }
103
+
104
+ getModelsFor(name?: string): string[] {
105
+ const cfg = name
106
+ ? this.configs.find((c) => c.name === name)
107
+ : this.activeProvider
108
+ ? this.configs.find((c) => c.name === this.activeProvider)
109
+ : this.sortedConfigs()[0];
110
+ return cfg?.models ?? [];
111
+ }
112
+
113
+ async *chat(
114
+ messages: Message[],
115
+ tools: ToolDef[],
116
+ opts?: { temperature?: number; maxTokens?: number; signal?: AbortSignal }
117
+ ): AsyncIterable<ChatChunk> {
118
+ const order = this.sortedConfigs();
119
+ if (order.length === 0) {
120
+ yield { type: "error", error: "No providers configured" };
121
+ return;
122
+ }
123
+
124
+ let lastError: Error | null = null;
125
+ for (const cfg of order) {
126
+ if (!this.health.isAvailable(cfg.name)) continue;
127
+ const provider = await this.getProvider(cfg);
128
+ try {
129
+ for await (const chunk of provider.chat(messages, tools, {
130
+ signal: opts?.signal,
131
+ temperature: opts?.temperature,
132
+ maxTokens: opts?.maxTokens,
133
+ })) {
134
+ if (chunk.type === "error" && chunk.error) {
135
+ throw new Error(chunk.error);
136
+ }
137
+ yield chunk;
138
+ }
139
+ this.health.recordSuccess(cfg.name);
140
+ return;
141
+ } catch (err) {
142
+ lastError = err as Error;
143
+ this.health.recordFailure(cfg.name, lastError?.message);
144
+ if (opts?.signal?.aborted) {
145
+ yield { type: "error", error: `Aborted: ${lastError.message}` };
146
+ return;
147
+ }
148
+ yield {
149
+ type: "error",
150
+ error: `Provider ${cfg.name} failed (${lastError.message}); failing over...`,
151
+ };
152
+ continue;
153
+ }
154
+ }
155
+
156
+ yield {
157
+ type: "error",
158
+ error: `All providers failed. Last error: ${lastError?.message ?? "unknown"}`,
159
+ };
160
+ }
161
+
162
+ async listAllModels(): Promise<Record<string, string[]>> {
163
+ const out: Record<string, string[]> = {};
164
+ for (const cfg of this.sortedConfigs()) {
165
+ try {
166
+ const provider = await this.getProvider(cfg);
167
+ out[cfg.name] = await provider.listModels();
168
+ } catch {
169
+ out[cfg.name] = [];
170
+ }
171
+ }
172
+ return out;
173
+ }
174
+
175
+ async healthAll(): Promise<HealthStatus[]> {
176
+ const out: HealthStatus[] = [];
177
+ for (const cfg of this.configs) {
178
+ try {
179
+ const provider = await this.getProvider(cfg);
180
+ const status = await provider.health();
181
+ out.push({ provider: cfg.name, ...status });
182
+ } catch {
183
+ out.push({
184
+ provider: cfg.name,
185
+ healthy: false,
186
+ failures: 0,
187
+ lastCheck: 0,
188
+ circuitOpen: false,
189
+ cooldownUntil: 0,
190
+ });
191
+ }
192
+ }
193
+ return out;
194
+ }
195
+ }