orquesta-cli 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core/slash-command-handler.d.ts.map +1 -1
- package/dist/core/slash-command-handler.js +82 -0
- package/dist/core/slash-command-handler.js.map +1 -1
- package/dist/ui/components/LLMSetupWizard.d.ts.map +1 -1
- package/dist/ui/components/LLMSetupWizard.js +151 -13
- package/dist/ui/components/LLMSetupWizard.js.map +1 -1
- package/dist/ui/components/OpenRouterModelBrowser.d.ts +13 -0
- package/dist/ui/components/OpenRouterModelBrowser.d.ts.map +1 -0
- package/dist/ui/components/OpenRouterModelBrowser.js +221 -0
- package/dist/ui/components/OpenRouterModelBrowser.js.map +1 -0
- package/package.json +1 -1
- package/src/core/slash-command-handler.ts +88 -0
- package/src/ui/components/LLMSetupWizard.tsx +213 -23
- package/src/ui/components/OpenRouterModelBrowser.tsx +365 -0
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenRouter Model Browser Component
|
|
3
|
+
*
|
|
4
|
+
* Fetches and displays all available OpenRouter models with:
|
|
5
|
+
* - Search/filter functionality
|
|
6
|
+
* - Free/Paid badges
|
|
7
|
+
* - Manual model ID input fallback
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import React, { useState, useEffect, useMemo } from 'react';
|
|
11
|
+
import { Box, Text, useInput } from 'ink';
|
|
12
|
+
import TextInput from 'ink-text-input';
|
|
13
|
+
import Spinner from 'ink-spinner';
|
|
14
|
+
|
|
15
|
+
interface OpenRouterModel {
|
|
16
|
+
id: string;
|
|
17
|
+
name: string;
|
|
18
|
+
description?: string;
|
|
19
|
+
pricing: {
|
|
20
|
+
prompt: string;
|
|
21
|
+
completion: string;
|
|
22
|
+
};
|
|
23
|
+
context_length: number;
|
|
24
|
+
top_provider?: {
|
|
25
|
+
is_moderated: boolean;
|
|
26
|
+
};
|
|
27
|
+
architecture?: {
|
|
28
|
+
modality: string;
|
|
29
|
+
instruct_type?: string;
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface OpenRouterModelBrowserProps {
|
|
34
|
+
onSelect: (model: { id: string; name: string; contextLength: number; isFree: boolean }) => void;
|
|
35
|
+
onCancel: () => void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
type BrowseMode = 'list' | 'search' | 'manual';
|
|
39
|
+
|
|
40
|
+
export const OpenRouterModelBrowser: React.FC<OpenRouterModelBrowserProps> = ({
|
|
41
|
+
onSelect,
|
|
42
|
+
onCancel,
|
|
43
|
+
}) => {
|
|
44
|
+
const [mode, setMode] = useState<BrowseMode>('list');
|
|
45
|
+
const [models, setModels] = useState<OpenRouterModel[]>([]);
|
|
46
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
47
|
+
const [error, setError] = useState<string | null>(null);
|
|
48
|
+
const [searchQuery, setSearchQuery] = useState('');
|
|
49
|
+
const [manualModelId, setManualModelId] = useState('');
|
|
50
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
51
|
+
const [scrollOffset, setScrollOffset] = useState(0);
|
|
52
|
+
|
|
53
|
+
// Max visible items in the list
|
|
54
|
+
const MAX_VISIBLE = 10;
|
|
55
|
+
|
|
56
|
+
// Fetch models from OpenRouter API
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
const fetchModels = async () => {
|
|
59
|
+
try {
|
|
60
|
+
const response = await fetch('https://openrouter.ai/api/v1/models');
|
|
61
|
+
if (!response.ok) {
|
|
62
|
+
throw new Error(`Failed to fetch models: ${response.status}`);
|
|
63
|
+
}
|
|
64
|
+
const data = await response.json() as { data?: OpenRouterModel[] };
|
|
65
|
+
|
|
66
|
+
// Sort: free models first, then by name
|
|
67
|
+
const sortedModels = (data.data || []).sort((a: OpenRouterModel, b: OpenRouterModel) => {
|
|
68
|
+
const aFree = parseFloat(a.pricing.prompt) === 0 && parseFloat(a.pricing.completion) === 0;
|
|
69
|
+
const bFree = parseFloat(b.pricing.prompt) === 0 && parseFloat(b.pricing.completion) === 0;
|
|
70
|
+
if (aFree && !bFree) return -1;
|
|
71
|
+
if (!aFree && bFree) return 1;
|
|
72
|
+
return a.name.localeCompare(b.name);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
setModels(sortedModels);
|
|
76
|
+
setIsLoading(false);
|
|
77
|
+
} catch (err) {
|
|
78
|
+
setError(err instanceof Error ? err.message : 'Failed to fetch models');
|
|
79
|
+
setIsLoading(false);
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
fetchModels();
|
|
84
|
+
}, []);
|
|
85
|
+
|
|
86
|
+
// Filter models based on search query
|
|
87
|
+
const filteredModels = useMemo(() => {
|
|
88
|
+
if (!searchQuery.trim()) return models;
|
|
89
|
+
|
|
90
|
+
const query = searchQuery.toLowerCase();
|
|
91
|
+
return models.filter(model =>
|
|
92
|
+
model.id.toLowerCase().includes(query) ||
|
|
93
|
+
model.name.toLowerCase().includes(query) ||
|
|
94
|
+
model.description?.toLowerCase().includes(query)
|
|
95
|
+
);
|
|
96
|
+
}, [models, searchQuery]);
|
|
97
|
+
|
|
98
|
+
// Check if model is free
|
|
99
|
+
const isFreeModel = (model: OpenRouterModel): boolean => {
|
|
100
|
+
return parseFloat(model.pricing.prompt) === 0 && parseFloat(model.pricing.completion) === 0;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
// Format pricing for display
|
|
104
|
+
const formatPricing = (model: OpenRouterModel): string => {
|
|
105
|
+
const promptPrice = parseFloat(model.pricing.prompt);
|
|
106
|
+
const completionPrice = parseFloat(model.pricing.completion);
|
|
107
|
+
|
|
108
|
+
if (promptPrice === 0 && completionPrice === 0) {
|
|
109
|
+
return 'Free';
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Price per million tokens
|
|
113
|
+
const promptPer1M = (promptPrice * 1000000).toFixed(2);
|
|
114
|
+
return `$${promptPer1M}/1M`;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
// Handle model selection
|
|
118
|
+
const handleSelect = () => {
|
|
119
|
+
if (mode === 'manual') {
|
|
120
|
+
if (manualModelId.trim()) {
|
|
121
|
+
onSelect({
|
|
122
|
+
id: manualModelId.trim(),
|
|
123
|
+
name: manualModelId.trim(),
|
|
124
|
+
contextLength: 128000,
|
|
125
|
+
isFree: false,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
} else {
|
|
129
|
+
const model = filteredModels[selectedIndex];
|
|
130
|
+
if (model) {
|
|
131
|
+
onSelect({
|
|
132
|
+
id: model.id,
|
|
133
|
+
name: model.name,
|
|
134
|
+
contextLength: model.context_length,
|
|
135
|
+
isFree: isFreeModel(model),
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
// Keyboard handling
|
|
142
|
+
useInput((_input, key) => {
|
|
143
|
+
if (key.escape) {
|
|
144
|
+
if (mode === 'search' || mode === 'manual') {
|
|
145
|
+
setMode('list');
|
|
146
|
+
setSearchQuery('');
|
|
147
|
+
setManualModelId('');
|
|
148
|
+
} else {
|
|
149
|
+
onCancel();
|
|
150
|
+
}
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (mode === 'list') {
|
|
155
|
+
if (key.upArrow) {
|
|
156
|
+
setSelectedIndex((prev) => {
|
|
157
|
+
const newIndex = prev > 0 ? prev - 1 : filteredModels.length - 1;
|
|
158
|
+
// Adjust scroll offset
|
|
159
|
+
if (newIndex < scrollOffset) {
|
|
160
|
+
setScrollOffset(newIndex);
|
|
161
|
+
}
|
|
162
|
+
return newIndex;
|
|
163
|
+
});
|
|
164
|
+
} else if (key.downArrow) {
|
|
165
|
+
setSelectedIndex((prev) => {
|
|
166
|
+
const newIndex = prev < filteredModels.length - 1 ? prev + 1 : 0;
|
|
167
|
+
// Adjust scroll offset
|
|
168
|
+
if (newIndex >= scrollOffset + MAX_VISIBLE) {
|
|
169
|
+
setScrollOffset(newIndex - MAX_VISIBLE + 1);
|
|
170
|
+
} else if (newIndex < scrollOffset) {
|
|
171
|
+
setScrollOffset(0);
|
|
172
|
+
}
|
|
173
|
+
return newIndex;
|
|
174
|
+
});
|
|
175
|
+
} else if (key.return) {
|
|
176
|
+
handleSelect();
|
|
177
|
+
} else if (_input === '/' || _input === 's') {
|
|
178
|
+
setMode('search');
|
|
179
|
+
setSelectedIndex(0);
|
|
180
|
+
setScrollOffset(0);
|
|
181
|
+
} else if (_input === 'm') {
|
|
182
|
+
setMode('manual');
|
|
183
|
+
}
|
|
184
|
+
} else if (mode === 'search') {
|
|
185
|
+
if (key.return) {
|
|
186
|
+
// Apply search and go back to list
|
|
187
|
+
setMode('list');
|
|
188
|
+
setSelectedIndex(0);
|
|
189
|
+
setScrollOffset(0);
|
|
190
|
+
}
|
|
191
|
+
} else if (mode === 'manual') {
|
|
192
|
+
if (key.return) {
|
|
193
|
+
handleSelect();
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// Update selection when filtered list changes
|
|
199
|
+
useEffect(() => {
|
|
200
|
+
if (selectedIndex >= filteredModels.length) {
|
|
201
|
+
setSelectedIndex(Math.max(0, filteredModels.length - 1));
|
|
202
|
+
}
|
|
203
|
+
}, [filteredModels.length, selectedIndex]);
|
|
204
|
+
|
|
205
|
+
// Loading state
|
|
206
|
+
if (isLoading) {
|
|
207
|
+
return (
|
|
208
|
+
<Box flexDirection="column" paddingY={1}>
|
|
209
|
+
<Text color="cyan">
|
|
210
|
+
<Spinner type="dots" /> Loading OpenRouter models...
|
|
211
|
+
</Text>
|
|
212
|
+
</Box>
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Error state
|
|
217
|
+
if (error) {
|
|
218
|
+
return (
|
|
219
|
+
<Box flexDirection="column" paddingY={1}>
|
|
220
|
+
<Text color="red">Error: {error}</Text>
|
|
221
|
+
<Text color="gray">Press 'm' to enter model ID manually, or Esc to go back</Text>
|
|
222
|
+
</Box>
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Calculate visible models
|
|
227
|
+
const visibleModels = filteredModels.slice(scrollOffset, scrollOffset + MAX_VISIBLE);
|
|
228
|
+
|
|
229
|
+
return (
|
|
230
|
+
<Box flexDirection="column">
|
|
231
|
+
{/* Header */}
|
|
232
|
+
<Box borderStyle="round" borderColor="magenta" paddingX={2} marginBottom={1}>
|
|
233
|
+
<Text color="magenta" bold>
|
|
234
|
+
OpenRouter Models ({filteredModels.length} available)
|
|
235
|
+
</Text>
|
|
236
|
+
</Box>
|
|
237
|
+
|
|
238
|
+
{/* Search Mode */}
|
|
239
|
+
{mode === 'search' && (
|
|
240
|
+
<Box paddingX={1} marginBottom={1}>
|
|
241
|
+
<Text color="yellow">Search: </Text>
|
|
242
|
+
<TextInput
|
|
243
|
+
value={searchQuery}
|
|
244
|
+
onChange={setSearchQuery}
|
|
245
|
+
placeholder="Type to filter models..."
|
|
246
|
+
/>
|
|
247
|
+
</Box>
|
|
248
|
+
)}
|
|
249
|
+
|
|
250
|
+
{/* Manual Mode */}
|
|
251
|
+
{mode === 'manual' && (
|
|
252
|
+
<Box flexDirection="column" paddingX={1}>
|
|
253
|
+
<Text color="yellow" bold>Enter Model ID:</Text>
|
|
254
|
+
<Box marginY={1}>
|
|
255
|
+
<Text color="gray">ID: </Text>
|
|
256
|
+
<TextInput
|
|
257
|
+
value={manualModelId}
|
|
258
|
+
onChange={setManualModelId}
|
|
259
|
+
placeholder="provider/model-name"
|
|
260
|
+
/>
|
|
261
|
+
</Box>
|
|
262
|
+
<Text color="gray" dimColor>
|
|
263
|
+
Example: anthropic/claude-3.5-sonnet, openai/gpt-4-turbo
|
|
264
|
+
</Text>
|
|
265
|
+
<Box marginTop={1}>
|
|
266
|
+
<Text dimColor>Enter: confirm | Esc: back to list</Text>
|
|
267
|
+
</Box>
|
|
268
|
+
</Box>
|
|
269
|
+
)}
|
|
270
|
+
|
|
271
|
+
{/* Model List */}
|
|
272
|
+
{mode !== 'manual' && (
|
|
273
|
+
<>
|
|
274
|
+
{/* Current search filter */}
|
|
275
|
+
{searchQuery && mode === 'list' && (
|
|
276
|
+
<Box paddingX={1} marginBottom={1}>
|
|
277
|
+
<Text color="gray">Filtered by: "{searchQuery}" </Text>
|
|
278
|
+
<Text color="cyan">(press / to search again)</Text>
|
|
279
|
+
</Box>
|
|
280
|
+
)}
|
|
281
|
+
|
|
282
|
+
{/* Scroll indicator - top */}
|
|
283
|
+
{scrollOffset > 0 && (
|
|
284
|
+
<Box paddingX={1}>
|
|
285
|
+
<Text color="gray">↑ {scrollOffset} more above</Text>
|
|
286
|
+
</Box>
|
|
287
|
+
)}
|
|
288
|
+
|
|
289
|
+
{/* Model List */}
|
|
290
|
+
<Box flexDirection="column" paddingX={1}>
|
|
291
|
+
{visibleModels.map((model, index) => {
|
|
292
|
+
const actualIndex = scrollOffset + index;
|
|
293
|
+
const isSelected = actualIndex === selectedIndex;
|
|
294
|
+
const isFree = isFreeModel(model);
|
|
295
|
+
|
|
296
|
+
return (
|
|
297
|
+
<Box key={model.id} flexDirection="row">
|
|
298
|
+
{/* Selection indicator */}
|
|
299
|
+
<Text color={isSelected ? 'cyan' : undefined}>
|
|
300
|
+
{isSelected ? '> ' : ' '}
|
|
301
|
+
</Text>
|
|
302
|
+
|
|
303
|
+
{/* Free/Paid Badge */}
|
|
304
|
+
<Text
|
|
305
|
+
color={isFree ? 'green' : 'yellow'}
|
|
306
|
+
bold
|
|
307
|
+
>
|
|
308
|
+
{isFree ? '[FREE] ' : '[PAID] '}
|
|
309
|
+
</Text>
|
|
310
|
+
|
|
311
|
+
{/* Model name */}
|
|
312
|
+
<Text
|
|
313
|
+
color={isSelected ? 'cyan' : 'white'}
|
|
314
|
+
bold={isSelected}
|
|
315
|
+
>
|
|
316
|
+
{model.id}
|
|
317
|
+
</Text>
|
|
318
|
+
|
|
319
|
+
{/* Context length */}
|
|
320
|
+
<Text color="gray">
|
|
321
|
+
{' '}({Math.round(model.context_length / 1000)}k ctx)
|
|
322
|
+
</Text>
|
|
323
|
+
|
|
324
|
+
{/* Price */}
|
|
325
|
+
{!isFree && (
|
|
326
|
+
<Text color="gray" dimColor>
|
|
327
|
+
{' '}{formatPricing(model)}
|
|
328
|
+
</Text>
|
|
329
|
+
)}
|
|
330
|
+
</Box>
|
|
331
|
+
);
|
|
332
|
+
})}
|
|
333
|
+
</Box>
|
|
334
|
+
|
|
335
|
+
{/* Scroll indicator - bottom */}
|
|
336
|
+
{scrollOffset + MAX_VISIBLE < filteredModels.length && (
|
|
337
|
+
<Box paddingX={1}>
|
|
338
|
+
<Text color="gray">↓ {filteredModels.length - scrollOffset - MAX_VISIBLE} more below</Text>
|
|
339
|
+
</Box>
|
|
340
|
+
)}
|
|
341
|
+
|
|
342
|
+
{/* No results */}
|
|
343
|
+
{filteredModels.length === 0 && (
|
|
344
|
+
<Box paddingX={1}>
|
|
345
|
+
<Text color="yellow">No models match your search.</Text>
|
|
346
|
+
</Box>
|
|
347
|
+
)}
|
|
348
|
+
</>
|
|
349
|
+
)}
|
|
350
|
+
|
|
351
|
+
{/* Footer / Controls */}
|
|
352
|
+
{mode === 'list' && (
|
|
353
|
+
<Box marginTop={1} flexDirection="column">
|
|
354
|
+
<Text dimColor>↑↓: navigate | Enter: select | /: search | m: manual entry | Esc: cancel</Text>
|
|
355
|
+
</Box>
|
|
356
|
+
)}
|
|
357
|
+
|
|
358
|
+
{mode === 'search' && (
|
|
359
|
+
<Box marginTop={1}>
|
|
360
|
+
<Text dimColor>Type to filter | Enter: apply | Esc: clear search</Text>
|
|
361
|
+
</Box>
|
|
362
|
+
)}
|
|
363
|
+
</Box>
|
|
364
|
+
);
|
|
365
|
+
};
|