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.
@@ -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
+ };