@orbit-intelligence/orbit-agent 0.3.12

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 (80) hide show
  1. package/LICENSE +16 -0
  2. package/README.md +23 -0
  3. package/bin/orbit +26 -0
  4. package/dist/prompts/system.js +80 -0
  5. package/dist/src/cli/args.js +145 -0
  6. package/dist/src/cli/orchestrate.js +100 -0
  7. package/dist/src/cli/run.js +393 -0
  8. package/dist/src/config/config-schema.js +151 -0
  9. package/dist/src/config/index.js +57 -0
  10. package/dist/src/core/agent/agent-loop.js +402 -0
  11. package/dist/src/core/agents/delegate.js +120 -0
  12. package/dist/src/core/agents/orchestrator.js +58 -0
  13. package/dist/src/core/agents/prompts.js +82 -0
  14. package/dist/src/core/agents/types.js +1 -0
  15. package/dist/src/core/context/context-manager.js +167 -0
  16. package/dist/src/core/events.js +23 -0
  17. package/dist/src/core/llm/http.js +207 -0
  18. package/dist/src/core/llm/index.js +93 -0
  19. package/dist/src/core/llm/models.js +228 -0
  20. package/dist/src/core/llm/providers/gemini.js +211 -0
  21. package/dist/src/core/llm/providers/openai-compat.js +31 -0
  22. package/dist/src/core/llm/router.js +125 -0
  23. package/dist/src/core/llm/secrets.js +121 -0
  24. package/dist/src/core/llm/types.js +10 -0
  25. package/dist/src/core/orchestration/dispatcher.js +74 -0
  26. package/dist/src/core/orchestration/messenger.js +139 -0
  27. package/dist/src/core/orchestration/roles.js +129 -0
  28. package/dist/src/core/orchestration/runtime.js +122 -0
  29. package/dist/src/core/orchestration/session.js +204 -0
  30. package/dist/src/core/orchestration/shared-context.js +88 -0
  31. package/dist/src/core/orchestration/tools.js +187 -0
  32. package/dist/src/core/orchestration/types.js +3 -0
  33. package/dist/src/core/permissions/index.js +58 -0
  34. package/dist/src/core/project-context.js +115 -0
  35. package/dist/src/core/skill-loader.js +31 -0
  36. package/dist/src/core/tools/edit.js +142 -0
  37. package/dist/src/core/tools/filesystem.js +203 -0
  38. package/dist/src/core/tools/git.js +138 -0
  39. package/dist/src/core/tools/registry.js +73 -0
  40. package/dist/src/core/tools/search.js +90 -0
  41. package/dist/src/core/tools/shell.js +65 -0
  42. package/dist/src/core/tools/types.js +6 -0
  43. package/dist/src/core/types.js +3 -0
  44. package/dist/src/index.js +11 -0
  45. package/dist/src/session/event-log.js +55 -0
  46. package/dist/src/session/store.js +76 -0
  47. package/dist/src/setup/wizard.js +401 -0
  48. package/dist/src/tui/InkApp.js +67 -0
  49. package/dist/src/tui/ansi.js +142 -0
  50. package/dist/src/tui/app.js +768 -0
  51. package/dist/src/tui/colors.js +13 -0
  52. package/dist/src/tui/components/AgentDock.js +46 -0
  53. package/dist/src/tui/components/Composer.js +35 -0
  54. package/dist/src/tui/components/Header.js +23 -0
  55. package/dist/src/tui/components/ModelPicker.js +23 -0
  56. package/dist/src/tui/components/PermissionModal.js +29 -0
  57. package/dist/src/tui/components/SlashMenu.js +15 -0
  58. package/dist/src/tui/components/StatusLine.js +27 -0
  59. package/dist/src/tui/components/Transcript.js +31 -0
  60. package/dist/src/tui/components/WorkingStatus.js +29 -0
  61. package/dist/src/tui/components/input.js +246 -0
  62. package/dist/src/tui/components/markdown.js +384 -0
  63. package/dist/src/tui/components/message.js +105 -0
  64. package/dist/src/tui/context.js +8 -0
  65. package/dist/src/tui/geometry.js +40 -0
  66. package/dist/src/tui/renderer.js +116 -0
  67. package/dist/src/tui/rows.js +247 -0
  68. package/dist/src/tui/scheduler.js +32 -0
  69. package/dist/src/tui/store.js +127 -0
  70. package/dist/src/tui/style.js +151 -0
  71. package/dist/src/tui/term.js +309 -0
  72. package/dist/src/tui/text.js +104 -0
  73. package/dist/src/tui/themes/index.js +15 -0
  74. package/dist/src/tui/themes/palettes.js +137 -0
  75. package/dist/src/tui/themes/types.js +1 -0
  76. package/dist/src/utils/diff.js +161 -0
  77. package/dist/src/utils/platform.js +71 -0
  78. package/dist/src/utils/signals.js +26 -0
  79. package/dist/src/version.js +4 -0
  80. package/package.json +71 -0
@@ -0,0 +1,228 @@
1
+ export const modelsOf = (spec) => spec.models.map((m) => m.id);
2
+ function r(mode, efforts, def, extra) {
3
+ return { mode, efforts, default: def, ...extra };
4
+ }
5
+ export const GROQ_CATALOG = [
6
+ {
7
+ id: 'openai/gpt-oss-120b',
8
+ label: 'OpenAI GPT-OSS 120B',
9
+ reasoning: r('effort', ['low', 'medium', 'high'], 'medium'),
10
+ },
11
+ {
12
+ id: 'openai/gpt-oss-20b',
13
+ label: 'OpenAI GPT-OSS 20B',
14
+ reasoning: r('effort', ['low', 'medium', 'high'], 'medium'),
15
+ },
16
+ {
17
+ id: 'qwen/qwen3-32b',
18
+ label: 'Qwen3 32B',
19
+ reasoning: r('effort', ['none', 'low', 'medium', 'high'], 'none', {
20
+ paramValue: { none: 'none', low: 'default', medium: 'default', high: 'default' },
21
+ }),
22
+ },
23
+ {
24
+ id: 'qwen/qwen3.6-27b',
25
+ label: 'Qwen3.6 27B',
26
+ reasoning: r('effort', ['none', 'low', 'medium', 'high'], 'none', {
27
+ paramValue: { none: 'none', low: 'default', medium: 'default', high: 'default' },
28
+ }),
29
+ },
30
+ { id: 'meta-llama/llama-4-maverick-17b-128e-instruct', label: 'Llama 4 Maverick 17B' },
31
+ { id: 'meta-llama/llama-4-scout-17b-16e-instruct', label: 'Llama 4 Scout 17B' },
32
+ { id: 'meta-llama/llama-3.3-70b-versatile', label: 'Llama 3.3 70B' },
33
+ { id: 'meta-llama/llama-3.1-8b-instant', label: 'Llama 3.1 8B' },
34
+ { id: 'moonshotai/kimi-k2-instruct-0905', label: 'Kimi K2 Instruct' },
35
+ ];
36
+ export const GEMINI_CATALOG = [
37
+ {
38
+ id: 'gemini-3.8-flash',
39
+ label: 'Gemini 3.8 Flash',
40
+ reasoning: r('level', ['low', 'medium', 'high'], 'medium'),
41
+ },
42
+ {
43
+ id: 'gemini-3.7-flash',
44
+ label: 'Gemini 3.7 Flash',
45
+ reasoning: r('level', ['low', 'medium', 'high'], 'medium'),
46
+ },
47
+ {
48
+ id: 'gemini-3.6-flash',
49
+ label: 'Gemini 3.6 Flash',
50
+ reasoning: r('level', ['minimal', 'low', 'medium', 'high'], 'medium'),
51
+ },
52
+ {
53
+ id: 'gemini-3.5-flash',
54
+ label: 'Gemini 3.5 Flash',
55
+ reasoning: r('level', ['minimal', 'low', 'medium', 'high'], 'medium'),
56
+ },
57
+ {
58
+ id: 'gemini-3.1-flash-lite',
59
+ label: 'Gemini 3.1 Flash-Lite',
60
+ reasoning: r('level', ['minimal', 'low', 'medium', 'high'], 'minimal'),
61
+ },
62
+ {
63
+ id: 'gemini-3-pro-preview',
64
+ label: 'Gemini 3 Pro',
65
+ reasoning: r('level', ['low', 'high'], 'high'),
66
+ },
67
+ {
68
+ id: 'gemini-3.1-pro-preview',
69
+ label: 'Gemini 3.1 Pro',
70
+ reasoning: r('level', ['low', 'medium', 'high'], 'medium'),
71
+ },
72
+ {
73
+ id: 'gemini-2.5-pro',
74
+ label: 'Gemini 2.5 Pro',
75
+ reasoning: r('budget', ['minimal', 'low', 'medium', 'high'], 'medium', {
76
+ switchable: false,
77
+ }),
78
+ },
79
+ {
80
+ id: 'gemini-2.5-flash',
81
+ label: 'Gemini 2.5 Flash',
82
+ reasoning: r('budget', ['minimal', 'low', 'medium', 'high'], 'medium', {
83
+ switchable: false,
84
+ }),
85
+ },
86
+ {
87
+ id: 'gemini-2.5-flash-lite',
88
+ label: 'Gemini 2.5 Flash-Lite',
89
+ reasoning: r('budget', ['none', 'minimal', 'low', 'medium', 'high'], 'none', {
90
+ switchable: true,
91
+ }),
92
+ },
93
+ ];
94
+ export const OPENROUTER_CATALOG = [
95
+ // Paid
96
+ {
97
+ id: 'anthropic/claude-opus-4.8',
98
+ label: 'Claude Opus 4.8',
99
+ reasoning: r('none', ['low', 'medium', 'high'], 'medium', { switchable: false }),
100
+ },
101
+ { id: 'anthropic/claude-sonnet-4.7', label: 'Claude Sonnet 4.7' },
102
+ { id: 'anthropic/claude-haiku-4.5', label: 'Claude Haiku 4.5' },
103
+ {
104
+ id: 'openai/o3',
105
+ label: 'OpenAI o3',
106
+ reasoning: r('effort', ['low', 'medium', 'high'], 'medium'),
107
+ },
108
+ {
109
+ id: 'openai/o4-mini',
110
+ label: 'OpenAI o4-mini',
111
+ reasoning: r('effort', ['low', 'medium', 'high'], 'medium'),
112
+ },
113
+ {
114
+ id: 'openai/gpt-oss-120b',
115
+ label: 'OpenAI GPT-OSS 120B',
116
+ reasoning: r('effort', ['low', 'medium', 'high'], 'medium'),
117
+ },
118
+ {
119
+ id: 'openai/gpt-oss-20b',
120
+ label: 'OpenAI GPT-OSS 20B',
121
+ reasoning: r('effort', ['low', 'medium', 'high'], 'medium'),
122
+ },
123
+ { id: 'google/gemini-3.5-flash', label: 'Gemini 3.5 Flash' },
124
+ { id: 'google/gemini-3-pro-preview', label: 'Gemini 3 Pro' },
125
+ { id: 'google/gemini-2.5-pro', label: 'Gemini 2.5 Pro' },
126
+ {
127
+ id: 'deepseek/deepseek-v4-pro',
128
+ label: 'DeepSeek V4 Pro',
129
+ reasoning: r('effort', ['low', 'medium', 'high'], 'medium'),
130
+ },
131
+ { id: 'deepseek/deepseek-v4-flash', label: 'DeepSeek V4 Flash' },
132
+ {
133
+ id: 'deepseek/deepseek-r1',
134
+ label: 'DeepSeek R1',
135
+ reasoning: r('effort', ['low', 'medium', 'high'], 'high'),
136
+ },
137
+ {
138
+ id: 'qwen/qwen3.7-max',
139
+ label: 'Qwen3.7 Max',
140
+ reasoning: r('effort', ['low', 'medium', 'high'], 'medium'),
141
+ },
142
+ {
143
+ id: 'x-ai/grok-build-0.1',
144
+ label: 'Grok Build 0.1',
145
+ reasoning: r('effort', ['low', 'medium', 'high'], 'medium'),
146
+ },
147
+ { id: 'x-ai/grok-4.3', label: 'Grok 4.3' },
148
+ { id: 'minimax/minimax-m3', label: 'MiniMax M3' },
149
+ { id: 'nvidia/nemotron-3-ultra', label: 'NVIDIA Nemotron 3 Ultra' },
150
+ { id: 'moonshotai/kimi-k2.7-code', label: 'Kimi K2.7 Code' },
151
+ { id: 'mistralai/mistral-medium-3-5', label: 'Mistral Medium 3.5' },
152
+ // Free (`:free` tiers)
153
+ { id: 'openrouter/free', label: 'Auto — free router', free: true },
154
+ {
155
+ id: 'thinkingmachines/inkling:free',
156
+ label: 'Inkling (free)',
157
+ free: true,
158
+ reasoning: r('effort', ['low', 'medium', 'high'], 'medium'),
159
+ },
160
+ {
161
+ id: 'thinkingmachines/inkling-small:free',
162
+ label: 'Inkling Small (free)',
163
+ free: true,
164
+ reasoning: r('effort', ['low', 'medium', 'high'], 'medium'),
165
+ },
166
+ { id: 'minimax/minimax-m3:free', label: 'MiniMax M3 (free)', free: true },
167
+ {
168
+ id: 'deepseek/deepseek-r1:free',
169
+ label: 'DeepSeek R1 (free)',
170
+ free: true,
171
+ reasoning: r('effort', ['low', 'medium', 'high'], 'high'),
172
+ },
173
+ { id: 'openai/gpt-oss-120b:free', label: 'GPT-OSS 120B (free)', free: true },
174
+ { id: 'openai/gpt-oss-20b:free', label: 'GPT-OSS 20B (free)', free: true },
175
+ { id: 'nvidia/nemotron-3-ultra:free', label: 'Nemotron 3 Ultra (free)', free: true },
176
+ { id: 'nvidia/nemotron-3-super:free', label: 'Nemotron 3 Super (free)', free: true },
177
+ { id: 'nvidia/nemotron-3.5-lightning:free', label: 'Nemotron 3.5 Light (free)', free: true },
178
+ { id: 'google/gemma-4-31b:free', label: 'Gemma 4 31B (free)', free: true },
179
+ { id: 'google/gemma-4-26b-a4b:free', label: 'Gemma 4 26B (free)', free: true },
180
+ { id: 'cohere/north-mini-code:free', label: 'North Mini Code (free)', free: true },
181
+ { id: 'meta-llama/llama-3.3-70b-instruct:free', label: 'Llama 3.3 70B (free)', free: true },
182
+ { id: 'meta-llama/llama-3.1-8b-instruct:free', label: 'Llama 3.1 8B (free)', free: true },
183
+ ];
184
+ export const ORBITX_CATALOG = [
185
+ { id: 'auto', label: 'auto (backend routes across providers)' },
186
+ ];
187
+ /**
188
+ * Models the Orbit X gateway can route (`orbitx/<bare>`). Mirrors the
189
+ * server-side MODEL_ROUTES table so /model's picker only offers ids that the
190
+ * backend will actually serve. Order is presentation order.
191
+ */
192
+ export const ORBITX_SERVE = [
193
+ 'auto',
194
+ 'gemini-3.8-flash',
195
+ 'gemini-3.6-flash',
196
+ 'gemini-3.1-pro-preview',
197
+ 'gemini-2.5-flash',
198
+ 'meta-llama/llama-4-maverick-17b-128e-instruct',
199
+ 'qwen/qwen3-32b',
200
+ 'meta-llama/llama-3.3-70b-versatile',
201
+ 'meta-llama/llama-3.1-8b-instant',
202
+ 'qwen/qwen-2.5-coder-32b-instruct',
203
+ 'qwen/qwen3.7-max',
204
+ 'deepseek/deepseek-v4-flash',
205
+ 'moonshotai/kimi-k2.7-code',
206
+ 'meta-llama/llama-3.3-70b-instruct',
207
+ ];
208
+ export const PROVIDER_CATALOGS = [
209
+ { id: 'orbitx', label: 'Orbit X', requiresSecret: true, supportsCustom: true, models: ORBITX_CATALOG },
210
+ { id: 'groq', label: 'Groq', requiresSecret: true, supportsCustom: false, models: GROQ_CATALOG },
211
+ { id: 'gemini', label: 'Google Gemini', requiresSecret: true, supportsCustom: false, models: GEMINI_CATALOG },
212
+ { id: 'openrouter', label: 'OpenRouter', requiresSecret: true, supportsCustom: true, models: OPENROUTER_CATALOG },
213
+ ];
214
+ /** Find a model entry by bare id across every provider catalog. */
215
+ export function findModel(provider, modelBare) {
216
+ for (const cat of PROVIDER_CATALOGS) {
217
+ if (cat.id !== provider)
218
+ continue;
219
+ const m = cat.models.find((x) => x.id === modelBare);
220
+ if (m)
221
+ return { ...m, provider };
222
+ }
223
+ return null;
224
+ }
225
+ /** True if the given entry advertises a reasoning/effort picker. */
226
+ export function modelSupportsReasoning(m) {
227
+ return !!m.reasoning;
228
+ }
@@ -0,0 +1,211 @@
1
+ import { GEMINI_CATALOG } from '../models.js';
2
+ /**
3
+ * Gemini (Google) native streaming provider.
4
+ * Uses v1beta generateContent streamGenerateContent with alt=sse, key auth.
5
+ */
6
+ const GEMINI_ENDPOINT = 'https://generativelanguage.googleapis.com/v1beta/models';
7
+ const GEMINI_MODELS = GEMINI_CATALOG.map((m) => m.id);
8
+ export function createGeminiProvider(apiKey) {
9
+ return {
10
+ id: 'gemini',
11
+ modelCount: GEMINI_MODELS.length,
12
+ listModels() {
13
+ return GEMINI_MODELS.map((m) => `gemini/${m}`);
14
+ },
15
+ async *stream(llmOpts) {
16
+ let model = llmOpts.model;
17
+ if (model.startsWith('gemini/'))
18
+ model = model.slice(7);
19
+ if (!model)
20
+ model = 'gemini-2.5-flash';
21
+ const contents = toGeminiContents(llmOpts.messages);
22
+ const body = { contents };
23
+ const sys = llmOpts.messages.find((m) => m.role === 'system');
24
+ if (sys)
25
+ body.systemInstruction = { parts: [{ text: sys.content }] };
26
+ if (llmOpts.tools?.length) {
27
+ body.tools = [{
28
+ functionDeclarations: llmOpts.tools.map((t) => ({
29
+ name: t.function.name,
30
+ description: t.function.description,
31
+ parameters: t.function.parameters,
32
+ })),
33
+ }];
34
+ }
35
+ const thinkingConfig = geminiThinkingConfig(model, llmOpts.reasoning);
36
+ const genConfig = {
37
+ temperature: llmOpts.temperature,
38
+ maxOutputTokens: llmOpts.maxTokens,
39
+ };
40
+ if (thinkingConfig)
41
+ genConfig.thinkingConfig = thinkingConfig;
42
+ body.generationConfig = genConfig;
43
+ const url = `${GEMINI_ENDPOINT}/${model}:streamGenerateContent?alt=sse&key=${encodeURIComponent(apiKey)}`;
44
+ const res = await fetch(url, {
45
+ method: 'POST',
46
+ headers: { 'Content-Type': 'application/json' },
47
+ body: JSON.stringify(body),
48
+ signal: llmOpts.signal,
49
+ });
50
+ if (!res.ok) {
51
+ const text = await res.text().catch(() => 'unknown');
52
+ throw new Error(`Gemini HTTP ${res.status}: ${text.slice(0, 300)}`);
53
+ }
54
+ const reader = res.body?.getReader();
55
+ if (!reader)
56
+ throw new Error('No readable body from Gemini');
57
+ const decoder = new TextDecoder();
58
+ let buffer = '';
59
+ let pendingArgs = '';
60
+ let pendingTool = null;
61
+ try {
62
+ while (true) {
63
+ const { done, value } = await reader.read();
64
+ if (done)
65
+ break;
66
+ buffer += decoder.decode(value, { stream: true });
67
+ while (true) {
68
+ const nl = buffer.indexOf('\n\n');
69
+ if (nl === -1)
70
+ break;
71
+ const raw = buffer.slice(0, nl);
72
+ buffer = buffer.slice(nl + 2);
73
+ for (const line of raw.split('\n')) {
74
+ if (!line.startsWith('data:'))
75
+ continue;
76
+ const data = line.slice(5).trim();
77
+ if (!data)
78
+ continue;
79
+ const parsed = jsonSafe(data);
80
+ if (!parsed)
81
+ continue;
82
+ const cand = parsed.candidates?.[0];
83
+ if (!cand)
84
+ continue;
85
+ for (const part of cand.content?.parts ?? []) {
86
+ if (typeof part.text === 'string') {
87
+ if (part.thought)
88
+ yield { type: 'reasoning', text: part.text };
89
+ else if (part.text.length > 0)
90
+ yield { type: 'token', text: part.text };
91
+ }
92
+ if (part.functionCall) {
93
+ if (pendingTool) {
94
+ yield { type: 'tool_call_args', id: pendingTool.id, args: pendingArgs };
95
+ yield { type: 'tool_call_end', id: pendingTool.id, name: pendingTool.name, args: pendingArgs };
96
+ }
97
+ const name = part.functionCall.name ?? '';
98
+ const argsStr = JSON.stringify(part.functionCall.args ?? {});
99
+ pendingTool = { id: `gemini_${Date.now()}_${name}`, name };
100
+ pendingArgs = argsStr;
101
+ yield { type: 'tool_call_start', id: pendingTool.id, name };
102
+ }
103
+ }
104
+ const usage = parsed.usageMetadata;
105
+ const reason = cand.finishReason;
106
+ if (reason === 'STOP' || reason === 'MAX_TOKENS') {
107
+ if (pendingTool) {
108
+ yield { type: 'tool_call_args', id: pendingTool.id, args: pendingArgs };
109
+ yield { type: 'tool_call_end', id: pendingTool.id, name: pendingTool.name, args: pendingArgs };
110
+ pendingTool = null;
111
+ }
112
+ yield { type: 'done', usage: usage ? {
113
+ inputTokens: usage.promptTokenCount,
114
+ outputTokens: usage.candidatesTokenCount,
115
+ } : undefined };
116
+ return;
117
+ }
118
+ if (parsed.promptFeedback?.blockReason) {
119
+ throw new Error(`Gemini blocked: ${parsed.promptFeedback.blockReason}`);
120
+ }
121
+ }
122
+ }
123
+ }
124
+ }
125
+ finally {
126
+ reader.releaseLock();
127
+ }
128
+ if (pendingTool) {
129
+ yield { type: 'tool_call_args', id: pendingTool.id, args: pendingArgs };
130
+ yield { type: 'tool_call_end', id: pendingTool.id, name: pendingTool.name, args: pendingArgs };
131
+ }
132
+ yield { type: 'done' };
133
+ },
134
+ };
135
+ }
136
+ function toGeminiContents(messages) {
137
+ const contents = [];
138
+ for (const m of messages) {
139
+ const parts = [];
140
+ if (m.role === 'system')
141
+ continue;
142
+ if (m.role === 'assistant') {
143
+ if (m.content)
144
+ parts.push({ text: m.content });
145
+ for (const tc of m.toolCalls ?? []) {
146
+ let args = {};
147
+ try {
148
+ args = JSON.parse(tc.args);
149
+ }
150
+ catch { /* keep {} */ }
151
+ parts.push({ functionCall: { name: tc.name, args } });
152
+ }
153
+ contents.push({ role: 'model', parts });
154
+ continue;
155
+ }
156
+ if (m.toolResults && m.toolResults.length > 0) {
157
+ for (const r of m.toolResults) {
158
+ let result;
159
+ try {
160
+ result = JSON.parse(r.content);
161
+ }
162
+ catch {
163
+ result = { output: r.content };
164
+ }
165
+ parts.push({ functionResponse: { name: r.name, response: result } });
166
+ }
167
+ contents.push({ role: 'user', parts });
168
+ continue;
169
+ }
170
+ if (m.role === 'user') {
171
+ parts.push({ text: m.content });
172
+ contents.push({ role: 'user', parts });
173
+ }
174
+ }
175
+ return contents;
176
+ }
177
+ function jsonSafe(s) {
178
+ try {
179
+ return JSON.parse(s);
180
+ }
181
+ catch {
182
+ return null;
183
+ }
184
+ }
185
+ /**
186
+ * Translate our reasoning config into Gemini's thinkingConfig.
187
+ * - gemini-3.x: `thinkingLevel` (MINIMAL/LOW/MEDIUM/HIGH); thinking is
188
+ * mandatory, so "off" degrades to minimal.
189
+ * - gemini-2.5: `thinkingBudget` token counts; lite defaults to off, so
190
+ * disabling just omits the config.
191
+ */
192
+ function geminiThinkingConfig(model, reasoning) {
193
+ if (!reasoning)
194
+ return undefined;
195
+ const effort = reasoning.effort ?? 'medium';
196
+ const disabled = reasoning.enabled === false;
197
+ if (model.startsWith('gemini-3')) {
198
+ const level = disabled || effort === 'none' ? 'MINIMAL'
199
+ : effort === 'high' ? 'HIGH'
200
+ : effort === 'low' ? 'LOW'
201
+ : 'MEDIUM';
202
+ return { thinkingLevel: level };
203
+ }
204
+ if (model.startsWith('gemini-2.5')) {
205
+ if (disabled || effort === 'none')
206
+ return undefined; // lite stays off; pro/flash use defaults
207
+ const budget = effort === 'high' ? 32_768 : effort === 'medium' ? 8_192 : effort === 'low' ? 2_048 : 1_024;
208
+ return { thinkingBudget: budget };
209
+ }
210
+ return undefined;
211
+ }
@@ -0,0 +1,31 @@
1
+ import { streamOpenAi } from '../http.js';
2
+ export function createOpenAiProvider(opts) {
3
+ const [firstModel] = opts.models;
4
+ return {
5
+ id: opts.id,
6
+ modelCount: opts.models.length,
7
+ listModels() {
8
+ return opts.models.map((m) => `${opts.id}/${m}`);
9
+ },
10
+ async *stream(llmOpts) {
11
+ // Resolve model: strip provider prefix if present
12
+ let model = llmOpts.model;
13
+ if (model.startsWith(`${opts.id}/`))
14
+ model = model.slice(opts.id.length + 1);
15
+ if (!model)
16
+ model = firstModel ?? '';
17
+ yield* streamOpenAi({
18
+ baseUrl: opts.baseUrl,
19
+ apiKey: opts.apiKey,
20
+ model,
21
+ messages: llmOpts.messages,
22
+ tools: llmOpts.tools,
23
+ temperature: llmOpts.temperature,
24
+ maxTokens: llmOpts.maxTokens,
25
+ signal: llmOpts.signal,
26
+ reasoning: llmOpts.reasoning,
27
+ reasoningStyle: opts.reasoningStyle,
28
+ });
29
+ },
30
+ };
31
+ }
@@ -0,0 +1,125 @@
1
+ import { splitModelId } from './types.js';
2
+ const COOLDOWN_MS = 60_000;
3
+ const MAX_FAILURES = 2;
4
+ export class AutoRouter {
5
+ providers;
6
+ strategy;
7
+ candidates;
8
+ health = new Map();
9
+ rrIndex = 0;
10
+ constructor(providers, candidates, strategy) {
11
+ this.providers = new Map(Object.entries(providers));
12
+ this.candidates = candidates.filter(Boolean);
13
+ this.strategy = strategy;
14
+ }
15
+ get candidateList() {
16
+ return this.candidates;
17
+ }
18
+ providerFor(providerId) {
19
+ return this.providers.get(providerId);
20
+ }
21
+ /** Ordered list of candidate model ids for this turn. */
22
+ order() {
23
+ if (this.strategy === 'pinned')
24
+ return this.candidates.slice(0, 1);
25
+ if (this.strategy === 'round-robin') {
26
+ const sorted = [...this.candidates];
27
+ if (sorted.length === 0)
28
+ return sorted;
29
+ const rotated = sorted.slice(this.rrIndex % sorted.length).concat(sorted.slice(0, this.rrIndex % sorted.length));
30
+ this.rrIndex = (this.rrIndex + 1) % sorted.length;
31
+ return rotated;
32
+ }
33
+ // auto / failover: prioritize healthy over cooling-down
34
+ const now = Date.now();
35
+ const healthy = this.candidates.filter((c) => !this.isCooling(c, now));
36
+ const cooling = this.candidates.filter((c) => this.isCooling(c, now));
37
+ return [...healthy, ...cooling];
38
+ }
39
+ isCooling(modelId, now) {
40
+ const h = this.health.get(modelId);
41
+ return !!h && h.failures >= MAX_FAILURES && now - h.lastFailureAt < COOLDOWN_MS;
42
+ }
43
+ recordFailure(modelId) {
44
+ const h = this.health.get(modelId) ?? { model: modelId, failures: 0, lastFailureAt: 0 };
45
+ h.failures++;
46
+ h.lastFailureAt = Date.now();
47
+ this.health.set(modelId, h);
48
+ }
49
+ recordSuccess(modelId, latencyMs) {
50
+ const h = this.health.get(modelId) ?? { model: modelId, failures: 0, lastFailureAt: 0 };
51
+ if (h.failures > 0)
52
+ h.failures = 0;
53
+ h.lastLatencyMs = latencyMs;
54
+ this.health.set(modelId, h);
55
+ }
56
+ /** Stream through the router with buffered failover. */
57
+ async *stream(opts) {
58
+ const order = this.order();
59
+ const maxAttempts = Math.max(1, opts.retries ?? 1);
60
+ const retryDelay = opts.retryDelayMs ?? 500;
61
+ let lastError;
62
+ for (const modelId of order) {
63
+ const { provider, model } = splitModelId(modelId);
64
+ const prov = this.providerFor(provider);
65
+ if (!prov)
66
+ continue;
67
+ // rr/failover: only one pass over order; but allow multiple attempts per candidate
68
+ const attempts = this.strategy === 'pinned' ? maxAttempts : 1;
69
+ for (let attempt = 0; attempt < attempts; attempt++) {
70
+ const started = Date.now();
71
+ let firstEventSeen = false;
72
+ try {
73
+ const gen = prov.stream({ ...opts, model });
74
+ for await (const ev of gen) {
75
+ if (ev.type === 'done') {
76
+ this.recordSuccess(modelId, Date.now() - started);
77
+ yield ev;
78
+ return;
79
+ }
80
+ // Commit on the FIRST event so every following word streams to
81
+ // the user immediately (no token/ms thresholds). Events are only
82
+ // withheld until the first one arrives so a hard pre-stream failure
83
+ // can still fail over to the next candidate without showing text.
84
+ firstEventSeen = true;
85
+ yield ev;
86
+ }
87
+ // stream ended without a `done` event
88
+ this.recordSuccess(modelId, Date.now() - started);
89
+ return;
90
+ }
91
+ catch (err) {
92
+ if (err instanceof Error)
93
+ lastError = err;
94
+ else
95
+ lastError = new Error(String(err));
96
+ if (firstEventSeen) {
97
+ throw err;
98
+ }
99
+ this.recordFailure(modelId);
100
+ if (attempt < attempts - 1) {
101
+ await delay(retryDelay);
102
+ continue;
103
+ }
104
+ // move to next candidate
105
+ break;
106
+ }
107
+ }
108
+ }
109
+ throw new Error(lastError
110
+ ? `All LLM routes failed: ${lastError.message}`
111
+ : 'All LLM routes failed — check provider keys and connectivity.');
112
+ }
113
+ /** Current best route summary for the status bar. */
114
+ peek() {
115
+ const first = this.order()[0];
116
+ if (!first)
117
+ return null;
118
+ const { provider, model } = splitModelId(first);
119
+ const h = this.health.get(first);
120
+ return { provider, model, strategy: this.strategy, latencyMs: h?.lastLatencyMs };
121
+ }
122
+ }
123
+ function delay(ms) {
124
+ return new Promise((r) => setTimeout(r, ms));
125
+ }