llms-py 3.0.0b2__py3-none-any.whl → 3.0.0b4__py3-none-any.whl

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 (51) hide show
  1. llms/__pycache__/main.cpython-314.pyc +0 -0
  2. llms/index.html +2 -1
  3. llms/llms.json +50 -17
  4. llms/main.py +484 -544
  5. llms/providers/__pycache__/anthropic.cpython-314.pyc +0 -0
  6. llms/providers/__pycache__/chutes.cpython-314.pyc +0 -0
  7. llms/providers/__pycache__/google.cpython-314.pyc +0 -0
  8. llms/providers/__pycache__/nvidia.cpython-314.pyc +0 -0
  9. llms/providers/__pycache__/openai.cpython-314.pyc +0 -0
  10. llms/providers/__pycache__/openrouter.cpython-314.pyc +0 -0
  11. llms/providers/anthropic.py +189 -0
  12. llms/providers/chutes.py +152 -0
  13. llms/providers/google.py +306 -0
  14. llms/providers/nvidia.py +107 -0
  15. llms/providers/openai.py +159 -0
  16. llms/providers/openrouter.py +70 -0
  17. llms/providers-extra.json +356 -0
  18. llms/providers.json +1 -1
  19. llms/ui/App.mjs +132 -60
  20. llms/ui/ai.mjs +76 -10
  21. llms/ui/app.css +65 -28
  22. llms/ui/ctx.mjs +196 -0
  23. llms/ui/index.mjs +75 -171
  24. llms/ui/lib/charts.mjs +9 -13
  25. llms/ui/markdown.mjs +6 -0
  26. llms/ui/{Analytics.mjs → modules/analytics.mjs} +76 -64
  27. llms/ui/{Main.mjs → modules/chat/ChatBody.mjs} +59 -135
  28. llms/ui/{SettingsDialog.mjs → modules/chat/SettingsDialog.mjs} +8 -8
  29. llms/ui/{ChatPrompt.mjs → modules/chat/index.mjs} +242 -46
  30. llms/ui/modules/layout.mjs +267 -0
  31. llms/ui/modules/model-selector.mjs +851 -0
  32. llms/ui/{Recents.mjs → modules/threads/Recents.mjs} +0 -2
  33. llms/ui/{Sidebar.mjs → modules/threads/index.mjs} +46 -44
  34. llms/ui/{threadStore.mjs → modules/threads/threadStore.mjs} +10 -7
  35. llms/ui/utils.mjs +82 -123
  36. {llms_py-3.0.0b2.dist-info → llms_py-3.0.0b4.dist-info}/METADATA +1 -1
  37. llms_py-3.0.0b4.dist-info/RECORD +65 -0
  38. llms/ui/Avatar.mjs +0 -86
  39. llms/ui/Brand.mjs +0 -52
  40. llms/ui/OAuthSignIn.mjs +0 -61
  41. llms/ui/ProviderIcon.mjs +0 -36
  42. llms/ui/ProviderStatus.mjs +0 -104
  43. llms/ui/SignIn.mjs +0 -65
  44. llms/ui/Welcome.mjs +0 -8
  45. llms/ui/model-selector.mjs +0 -686
  46. llms/ui.json +0 -1069
  47. llms_py-3.0.0b2.dist-info/RECORD +0 -58
  48. {llms_py-3.0.0b2.dist-info → llms_py-3.0.0b4.dist-info}/WHEEL +0 -0
  49. {llms_py-3.0.0b2.dist-info → llms_py-3.0.0b4.dist-info}/entry_points.txt +0 -0
  50. {llms_py-3.0.0b2.dist-info → llms_py-3.0.0b4.dist-info}/licenses/LICENSE +0 -0
  51. {llms_py-3.0.0b2.dist-info → llms_py-3.0.0b4.dist-info}/top_level.txt +0 -0
@@ -1,686 +0,0 @@
1
- import { ref, computed, watch, inject, onMounted } from "vue"
2
- import ProviderStatus from "./ProviderStatus.mjs"
3
- import ProviderIcon from "./ProviderIcon.mjs"
4
- import { storageObject } from "./utils.mjs"
5
-
6
- const SORT_OPTIONS = [
7
- { id: 'name', label: 'Name' },
8
- { id: 'knowledge', label: 'Knowledge Cutoff' },
9
- { id: 'release_date', label: 'Release Date' },
10
- { id: 'last_updated', label: 'Last Updated' },
11
- { id: 'cost_input', label: 'Cost (Input)' },
12
- { id: 'cost_output', label: 'Cost (Output)' },
13
- { id: 'context', label: 'Context Limit' },
14
- ]
15
-
16
- const I = x => `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${x}</svg>`
17
- const modalityIcons = {
18
- text: I(`<polyline points="4,7 4,4 20,4 20,7"></polyline><line x1="9" y1="20" x2="15" y2="20"></line><line x1="12" y1="4" x2="12" y2="20"></line>`),
19
- image: I(`<rect width="18" height="18" x="3" y="3" rx="2" ry="2"></rect><circle cx="9" cy="9" r="2"></circle><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"></path>`),
20
- audio: I(`<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon><path d="m19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07"></path>`),
21
- video: I(`<path d="m22 8-6 4 6 4V8Z"></path><rect width="14" height="12" x="2" y="6" rx="2" ry="2"></rect>`),
22
- pdf: I(`<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14,2 14,8 20,8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line><polyline points="10,9 9,9 8,9"></polyline>`),
23
- }
24
-
25
- const STORAGE_KEY = 'llms.modelSelector'
26
- const FAVORITES_KEY = 'llms.favorites'
27
-
28
- // Formatting helpers
29
- const numFmt = new Intl.NumberFormat()
30
- const currFmt = new Intl.NumberFormat(undefined, { style: 'currency', currency: 'USD', maximumFractionDigits: 2 })
31
-
32
- function formatCost(cost) {
33
- if (cost == null) return '-'
34
- const val = parseFloat(cost)
35
- if (val === 0) return 'Free'
36
- return currFmt.format(val)
37
- }
38
-
39
- function formatNumber(num) {
40
- if (num == null) return '-'
41
- return numFmt.format(num)
42
- }
43
-
44
- function formatShortNumber(num) {
45
- if (num == null) return '-'
46
- if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M'
47
- if (num >= 1000) return (num / 1000).toFixed(0) + 'K'
48
- return numFmt.format(num)
49
- }
50
-
51
- function getModelModalities(model) {
52
- const mods = new Set()
53
- const input = model.modalities?.input || []
54
- const output = model.modalities?.output || []
55
-
56
- // Collect all modalities
57
- input.forEach(m => mods.add(m))
58
- output.forEach(m => mods.add(m))
59
-
60
- // Filter out text and ensure we only show known icons
61
- const allowed = ['image', 'audio', 'video', 'pdf']
62
- return Array.from(mods).filter(m => m !== 'text' && allowed.includes(m)).sort()
63
- }
64
-
65
- const ModelSelectorModal = {
66
- components: {
67
- ProviderIcon,
68
- },
69
- template: `
70
- <!-- Dialog Overlay -->
71
- <div class="fixed inset-0 z-50 overflow-hidden" @keydown.escape="closeDialog">
72
- <!-- Backdrop -->
73
- <div class="fixed inset-0 bg-black/50 transition-opacity" @click="closeDialog"></div>
74
-
75
- <!-- Dialog -->
76
- <div class="fixed inset-4 md:inset-8 lg:inset-12 flex items-center justify-center">
77
- <div class="relative bg-white dark:bg-gray-800 rounded-xl shadow-2xl w-full h-full max-w-6xl max-h-[90vh] flex flex-col overflow-hidden">
78
- <!-- Header -->
79
- <div class="flex-shrink-0 px-6 py-4 border-b border-gray-200 dark:border-gray-700">
80
- <div class="flex items-center justify-between mb-4">
81
- <h2 class="text-xl font-semibold text-gray-900 dark:text-gray-100">Select Model</h2>
82
- <button type="button" @click="closeDialog" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors">
83
- <svg class="size-6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
84
- <path fill="currentColor" d="M19 6.41L17.59 5L12 10.59L6.41 5L5 6.41L10.59 12L5 17.59L6.41 19L12 13.41L17.59 19L19 17.59L13.41 12z"/>
85
- </svg>
86
- </button>
87
- </div>
88
-
89
- <!-- Search and Controls -->
90
- <div class="flex flex-col md:flex-row gap-3">
91
- <!-- Search -->
92
- <div class="flex-1 relative">
93
- <svg class="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-gray-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
94
- <path fill-rule="evenodd" d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z" clip-rule="evenodd" />
95
- </svg>
96
- <input type="text" v-model="searchQuery" ref="searchInput"
97
- placeholder="Search models..."
98
- class="w-full pl-10 pr-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm" />
99
- </div>
100
-
101
- <!-- Modality Filters -->
102
- <div class="flex items-center space-x-1">
103
- <button v-for="(icon, type) in modalityIcons" :key="type" type="button"
104
- @click="toggleModality(type)"
105
- :title="type"
106
- :class="[
107
- 'p-2 rounded-lg transition-colors border',
108
- selectedModalities.has(type)
109
- ? 'bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 border-blue-200 dark:border-blue-800'
110
- : 'bg-white dark:bg-gray-800 text-gray-400 border-transparent hover:bg-gray-50 dark:hover:bg-gray-700 hover:text-gray-600 dark:hover:text-gray-200'
111
- ]"
112
- v-html="icon">
113
- </button>
114
- </div>
115
-
116
- <!-- Sort -->
117
- <div class="flex items-center space-x-2">
118
- <label class="text-sm text-gray-600 dark:text-gray-400 whitespace-nowrap">Sort by:</label>
119
- <select v-model="sortBy"
120
- class="px-3 py-2 pr-8 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 min-w-[200px]">
121
- <option v-for="opt in sortOptions" :key="opt.id" :value="opt.id">{{ opt.label }}</option>
122
- </select>
123
- <button type="button" @click="toggleSortDirection"
124
- class="p-2 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
125
- :title="sortAsc ? 'Ascending' : 'Descending'">
126
- <svg v-if="sortAsc" class="size-5 text-gray-600 dark:text-gray-400" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
127
- <path fill="currentColor" d="M19 7h3l-4-4l-4 4h3v14h2M2 17h10v2H2M6 5v2H2V5m0 6h7v2H2z"/>
128
- </svg>
129
- <svg v-else class="size-5 text-gray-600 dark:text-gray-400" style="transform: scaleY(-1)" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
130
- <path fill="currentColor" d="M19 7h3l-4-4l-4 4h3v14h2M2 17h10v2H2M6 5v2H2V5m0 6h7v2H2z"/>
131
- </svg>
132
- </button>
133
- </div>
134
- </div>
135
-
136
- <!-- Provider Filter -->
137
- <div class="mt-3 flex flex-wrap gap-2">
138
- <button type="button"
139
- @click="activeTab = 'favorites'"
140
- :class="[
141
- 'flex items-center space-x-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors',
142
- activeTab === 'favorites'
143
- ? 'bg-yellow-500 text-white'
144
- : 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600'
145
- ]">
146
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-3.5">
147
- <path fill-rule="evenodd" d="M10.868 2.884c-.321-.772-1.415-.772-1.736 0l-1.83 4.401-4.753.381c-.833.067-1.171 1.107-.536 1.651l3.62 3.102-1.106 4.637c-.194.813.691 1.456 1.405 1.02L10 15.591l4.069 2.485c.713.436 1.598-.207 1.404-1.02l-1.106-4.637 3.62-3.102c.635-.544.297-1.584-.536-1.65l-4.752-.382-1.831-4.401z" clip-rule="evenodd" />
148
- </svg>
149
- <span>Favorites</span>
150
- <span v-if="favorites.length > 0" class="ml-1 opacity-75">({{ favorites.length }})</span>
151
- </button>
152
- <div class="w-px h-6 bg-gray-300 dark:bg-gray-600 mx-1 self-center"></div>
153
- <button type="button"
154
- @click="setActiveTab('browse', null)"
155
- :class="[
156
- 'px-3 py-1.5 rounded-lg text-xs font-medium transition-colors',
157
- activeTab === 'browse' && selectedProviders.size === 0
158
- ? 'bg-blue-600 text-white'
159
- : 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600'
160
- ]">
161
- All
162
- </button>
163
- <button v-for="provider in uniqueProviders" :key="provider"
164
- type="button"
165
- @click="setActiveTab('browse', provider)"
166
- :class="[
167
- 'flex items-center space-x-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors',
168
- activeTab === 'browse' && selectedProviders.has(provider)
169
- ? 'bg-blue-600 text-white'
170
- : 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600'
171
- ]">
172
- <ProviderIcon :provider="provider" class="size-4" />
173
- <span>{{ provider }}</span>
174
- <span class="opacity-60">({{ providerCounts[provider] }})</span>
175
- </button>
176
- </div>
177
- </div>
178
-
179
- <!-- Model List -->
180
- <div class="flex-1 overflow-y-auto p-4">
181
- <div v-if="filteredModels.length === 0 && !hasUnavailableFavorites" class="text-center py-12 text-gray-500 dark:text-gray-400">
182
- No models found matching your criteria.
183
- </div>
184
- <div v-else class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
185
- <button v-for="model in filteredModels" :key="model.id + '-' + model.provider"
186
- type="button"
187
- @click="selectModel(model)"
188
- :class="[
189
- 'relative text-left p-4 rounded-lg border transition-all group',
190
- modelValue === model.name
191
- ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/30 ring-2 ring-blue-500/50'
192
- : 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700/50'
193
- ]">
194
- <!-- Favorite Star -->
195
- <div @click.stop="toggleFavorite(model)"
196
- class="absolute top-2 right-2 p-1.5 rounded-full hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors z-10 cursor-pointer"
197
- :title="isFavorite(model) ? 'Remove from favorites' : 'Add to favorites'">
198
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"
199
- :class="['size-4 transition-colors', isFavorite(model) ? 'text-yellow-400' : 'text-gray-300 dark:text-gray-600 group-hover:text-gray-400 dark:group-hover:text-gray-500']">
200
- <path fill-rule="evenodd" d="M10.868 2.884c-.321-.772-1.415-.772-1.736 0l-1.83 4.401-4.753.381c-.833.067-1.171 1.107-.536 1.651l3.62 3.102-1.106 4.637c-.194.813.691 1.456 1.405 1.02L10 15.591l4.069 2.485c.713.436 1.598-.207 1.404-1.02l-1.106-4.637 3.62-3.102c.635-.544.297-1.584-.536-1.65l-4.752-.382-1.831-4.401z" clip-rule="evenodd" />
201
- </svg>
202
- </div>
203
-
204
- <div class="flex items-start justify-between mb-2 pr-6">
205
- <div class="flex items-center space-x-2 min-w-0">
206
- <ProviderIcon :provider="model.provider" class="size-5 flex-shrink-0" />
207
- <span class="font-medium text-gray-900 dark:text-gray-100 truncate">{{ model.name }}</span>
208
- </div>
209
- <div v-if="isFreeModel(model)" class="flex-shrink-0 ml-2">
210
- <span class="px-1.5 py-0.5 text-xs font-semibold rounded bg-green-100 dark:bg-green-900/50 text-green-700 dark:text-green-300">FREE</span>
211
- </div>
212
- </div>
213
-
214
- <div class="text-xs text-gray-500 dark:text-gray-400 mb-2 truncate" :title="model.id">{{ model.id }}</div>
215
-
216
- <div class="flex flex-wrap gap-x-4 gap-y-1 text-xs text-gray-600 dark:text-gray-400">
217
- <span v-if="model.cost && !isFreeModel(model)" :title="'Input: ' + model.cost.input + ' / Output: ' + model.cost.output + ' per 1M tokens'">
218
- 💰 {{ formatCost(model.cost.input) }} / {{ formatCost(model.cost.output) }}
219
- </span>
220
- <span v-if="model.limit?.context" :title="'Context window: ' + formatNumber(model.limit.context) + ' tokens'">
221
- 📏 {{ formatShortNumber(model.limit.context) }}
222
- </span>
223
- <span v-if="model.knowledge" :title="'Knowledge cutoff: ' + model.knowledge">
224
- 📅 {{ model.knowledge }}
225
- </span>
226
- </div>
227
-
228
- <div class="flex flex-wrap gap-1 mt-2">
229
- <span v-if="model.reasoning" class="px-1.5 py-0.5 text-xs rounded bg-purple-100 dark:bg-purple-900/50 text-purple-700 dark:text-purple-300">reasoning</span>
230
- <span v-if="model.tool_call" class="px-1.5 py-0.5 text-xs rounded bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300">tools</span>
231
-
232
- <!-- Modality Icons -->
233
- <span v-for="mod in getModelModalities(model)" :key="mod"
234
- class="inline-flex items-center justify-center p-0.5 text-gray-400 dark:text-gray-500"
235
- :title="mod"
236
- v-html="modalityIcons[mod]">
237
- </span>
238
- </div>
239
- </button>
240
- </div>
241
-
242
-
243
- <!-- Unavailable Favorites -->
244
- <div v-if="activeTab === 'favorites' && unavailableFavorites.length > 0" class="mt-6 pt-6 border-t border-gray-200 dark:border-gray-700">
245
- <div class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-3 ml-1">Unavailable</div>
246
- <div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3 opacity-60 grayscale">
247
- <div v-for="model in unavailableFavorites" :key="model.id"
248
- class="relative text-left p-4 rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 cursor-not-allowed">
249
-
250
- <!-- Remove from favorites button -->
251
- <div @click.stop="toggleFavorite(model)"
252
- class="absolute top-2 right-2 p-1.5 rounded-full hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors z-10 cursor-pointer"
253
- title="Remove from favorites">
254
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-4 text-yellow-400">
255
- <path fill-rule="evenodd" d="M10.868 2.884c-.321-.772-1.415-.772-1.736 0l-1.83 4.401-4.753.381c-.833.067-1.171 1.107-.536 1.651l3.62 3.102-1.106 4.637c-.194.813.691 1.456 1.405 1.02L10 15.591l4.069 2.485c.713.436 1.598-.207 1.404-1.02l-1.106-4.637 3.62-3.102c.635-.544.297-1.584-.536-1.65l-4.752-.382-1.831-4.401z" clip-rule="evenodd" />
256
- </svg>
257
- </div>
258
-
259
- <div class="flex items-start justify-between mb-2 pr-6">
260
- <div class="flex items-center space-x-2 min-w-0">
261
- <ProviderIcon v-if="model.provider" :provider="model.provider" class="size-5 flex-shrink-0" />
262
- <span class="font-medium text-gray-900 dark:text-gray-100 truncate">{{ model.name || model.id }}</span>
263
- </div>
264
- </div>
265
- <div class="text-xs text-gray-500 dark:text-gray-400 truncate">{{ model.id }}</div>
266
- <div class="mt-2 text-xs italic text-gray-400">Provider unavailable</div>
267
- </div>
268
- </div>
269
- </div>
270
- </div>
271
-
272
- <!-- Footer -->
273
- <div class="flex-shrink-0 px-6 py-3 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
274
- <div class="flex items-center justify-between">
275
- <span class="text-sm text-gray-600 dark:text-gray-400">
276
- {{ filteredModels.length }} of {{ models.length }} models
277
- </span>
278
- <button type="button" @click="closeDialog"
279
- class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 transition-colors">
280
- Close
281
- </button>
282
- </div>
283
- </div>
284
- </div>
285
- </div>
286
- </div>
287
- `,
288
- emits: ['done'],
289
- setup(props, { emit }) {
290
- const ctx = inject('ctx')
291
- const searchQuery = ref('')
292
- const searchInput = ref(null)
293
-
294
- // Load preferences
295
- const prefs = storageObject(STORAGE_KEY)
296
- const sortBy = ref(prefs.sortBy || 'name')
297
- const sortAsc = ref(prefs.sortAsc !== false)
298
- const selectedProviders = ref(new Set())
299
- const selectedModalities = ref(new Set())
300
- const models = computed(() => ctx.state.models || [])
301
-
302
- // Favorites State
303
- const favorites = ref([])
304
- try {
305
- favorites.value = JSON.parse(localStorage.getItem(FAVORITES_KEY)) || []
306
- } catch {
307
- favorites.value = []
308
- }
309
-
310
- const activeTab = ref(favorites.value.length > 0 ? 'favorites' : 'browse')
311
-
312
- const sortOptions = SORT_OPTIONS
313
-
314
- // Get unique providers
315
- const uniqueProviders = computed(() => {
316
- if (!models.value) return []
317
- const providers = [...new Set(models.value.map(m => m.provider))].filter(Boolean)
318
- return providers.sort()
319
- })
320
-
321
- // Provider counts
322
- const providerCounts = computed(() => {
323
- if (!models.value) return {}
324
- const counts = {}
325
- models.value.forEach(m => {
326
- if (m.provider) {
327
- counts[m.provider] = (counts[m.provider] || 0) + 1
328
- }
329
- })
330
- return counts
331
- })
332
-
333
- // Filter and sort helpers
334
- function getModelKey(model) {
335
- return `${model.provider}:${model.id}`
336
- }
337
-
338
- function isFavorite(model) {
339
- const key = getModelKey(model)
340
- return favorites.value.includes(key)
341
- }
342
-
343
- // Unavailable favorites (provider disabled or model removed)
344
- const unavailableFavorites = computed(() => {
345
- if (!models.value) return []
346
- const availableKeys = new Set(models.value.map(getModelKey))
347
- const missingKeys = favorites.value.filter(key => !availableKeys.has(key))
348
-
349
- return missingKeys.map(key => {
350
- const [provider, ...idParts] = key.split(':')
351
- const id = idParts.join(':')
352
- return {
353
- id,
354
- provider,
355
- name: id // Fallback
356
- }
357
- })
358
- })
359
-
360
- const hasUnavailableFavorites = computed(() => unavailableFavorites.value.length > 0)
361
-
362
- // Filter and sort models
363
- const filteredModels = computed(() => {
364
- if (!models.value) return []
365
-
366
- let result = [...models.value]
367
-
368
- // Filter by Tab
369
- if (activeTab.value === 'favorites') {
370
- result = result.filter(isFavorite)
371
- } else {
372
- // Browse Tab - Filter by provider
373
- if (selectedProviders.value.size > 0) {
374
- result = result.filter(m => selectedProviders.value.has(m.provider))
375
- }
376
- }
377
-
378
- // Filter by Modalities (AND logic)
379
- if (selectedModalities.value.size > 0) {
380
- result = result.filter(m => {
381
- const mods = m.modalities || {}
382
- const inputMods = mods.input || []
383
- const outputMods = mods.output || []
384
-
385
- for (const requiredMod of selectedModalities.value) {
386
- if (requiredMod === 'audio') {
387
- if (!inputMods.includes('audio') && !outputMods.includes('audio')) return false
388
- } else if (requiredMod === 'pdf') {
389
- if (!inputMods.includes('pdf')) return false
390
- } else {
391
- // text, image, video mostly in input (though video could be output too, but prompt implies input/output caps)
392
- // The user prompt says "input/output modalities", but usually we filter by what the model supports generally.
393
- // Let's check both for robust matching if strict input/output isn't specified for that type,
394
- // but based on typical llms.json:
395
- // text: input/output
396
- // image: input (vision) / output (generation)
397
- // video: input
398
- // let's check input for everything except where we know otherwise
399
-
400
- const inIn = inputMods.includes(requiredMod)
401
- const inOut = outputMods.includes(requiredMod)
402
- if (!inIn && !inOut) return false
403
- }
404
- }
405
- return true
406
- })
407
- }
408
-
409
- // Filter by search query
410
- if (searchQuery.value.trim()) {
411
- const query = searchQuery.value.toLowerCase()
412
- result = result.filter(m =>
413
- m.name?.toLowerCase().includes(query) ||
414
- m.id?.toLowerCase().includes(query) ||
415
- m.provider?.toLowerCase().includes(query)
416
- )
417
- }
418
-
419
- // Sort
420
- result.sort((a, b) => {
421
- let cmp = 0
422
- switch (sortBy.value) {
423
- case 'name':
424
- cmp = (a.name || '').localeCompare(b.name || '')
425
- break
426
- case 'knowledge':
427
- cmp = (a.knowledge || '').localeCompare(b.knowledge || '')
428
- break
429
- case 'release_date':
430
- cmp = (a.release_date || '').localeCompare(b.release_date || '')
431
- break
432
- case 'last_updated':
433
- cmp = (a.last_updated || '').localeCompare(b.last_updated || '')
434
- break
435
- case 'cost_input':
436
- cmp = (parseFloat(a.cost?.input) || 0) - (parseFloat(b.cost?.input) || 0)
437
- break
438
- case 'cost_output':
439
- cmp = (parseFloat(a.cost?.output) || 0) - (parseFloat(b.cost?.output) || 0)
440
- break
441
- case 'context':
442
- cmp = (a.limit?.context || 0) - (b.limit?.context || 0)
443
- break
444
- default:
445
- cmp = 0
446
- }
447
- return sortAsc.value ? cmp : -cmp
448
- })
449
-
450
- return result
451
- })
452
-
453
- function isFreeModel(model) {
454
- return model.cost && parseFloat(model.cost.input) === 0 && parseFloat(model.cost.output) === 0
455
- }
456
-
457
- function selectModel(model) {
458
- ctx.setState({ selectedModel: model.name })
459
- closeDialog()
460
- }
461
-
462
- function closeDialog() {
463
- emit('done')
464
- }
465
-
466
- function setActiveTab(tab, provider) {
467
- activeTab.value = tab
468
- if (tab === 'browse') {
469
- toggleProvider(provider)
470
- }
471
- }
472
-
473
- function toggleProvider(provider) {
474
- if (provider === null) {
475
- selectedProviders.value = new Set()
476
- } else {
477
- // Exclusive filter: if clicking a new provider, clear others.
478
- // If clicking the currently selected one, toggle it off.
479
- const newSet = new Set()
480
- if (!selectedProviders.value.has(provider)) {
481
- newSet.add(provider)
482
- }
483
- selectedProviders.value = newSet
484
- }
485
- }
486
-
487
- function toggleModality(modality) {
488
- const newSet = new Set(selectedModalities.value)
489
- if (newSet.has(modality)) {
490
- newSet.delete(modality)
491
- } else {
492
- newSet.add(modality)
493
- }
494
- selectedModalities.value = newSet
495
- }
496
-
497
- function toggleFavorite(model) {
498
- const key = getModelKey(model)
499
- const idx = favorites.value.indexOf(key)
500
- if (idx === -1) {
501
- favorites.value.push(key)
502
- } else {
503
- favorites.value.splice(idx, 1)
504
- }
505
- storageObject(FAVORITES_KEY, favorites.value)
506
- }
507
-
508
- function toggleSortDirection() {
509
- sortAsc.value = !sortAsc.value
510
- }
511
-
512
- // Save preferences when sort changes
513
- watch([sortBy, sortAsc], () => {
514
- storageObject(STORAGE_KEY, {
515
- sortBy: sortBy.value,
516
- sortAsc: sortAsc.value
517
- })
518
- })
519
-
520
- // Deep link logic with Vue Router
521
- onMounted(() => {
522
- searchQuery.value = ''
523
- setTimeout(() => {
524
- searchInput.value?.focus()
525
- }, 100)
526
- })
527
-
528
- return {
529
- models,
530
- searchQuery,
531
- searchInput,
532
- sortBy,
533
- sortAsc,
534
- sortOptions,
535
- selectedProviders,
536
- uniqueProviders,
537
- providerCounts,
538
- filteredModels,
539
- formatCost,
540
- formatNumber,
541
- formatShortNumber,
542
- isFreeModel,
543
-
544
- closeDialog,
545
- selectModel,
546
- toggleProvider,
547
- toggleSortDirection,
548
- favorites,
549
- activeTab,
550
- setActiveTab,
551
- toggleFavorite,
552
- isFavorite,
553
- unavailableFavorites,
554
- hasUnavailableFavorites,
555
- modalityIcons,
556
- selectedModalities,
557
- toggleModality,
558
- getModelModalities,
559
- }
560
- }
561
- }
562
-
563
- const ModelTooltip = {
564
- template: `
565
- <div v-if="model"
566
- class="absolute z-50 mt-10 ml-0 p-3 bg-white dark:bg-gray-800 rounded-lg shadow-xl border border-gray-200 dark:border-gray-700 text-sm w-72">
567
- <div class="font-semibold text-gray-900 dark:text-gray-100 mb-2">{{ model.name }}</div>
568
- <div class="text-xs text-gray-500 dark:text-gray-400 mb-2">{{ model.provider }}</div>
569
-
570
- <div v-if="model.cost" class="mb-2">
571
- <div class="text-xs font-medium text-gray-700 dark:text-gray-300">Cost per 1M tokens:</div>
572
- <div class="text-xs text-gray-600 dark:text-gray-400 ml-2">
573
- Input: {{ formatCost(model.cost.input) }} · Output: {{ formatCost(model.cost.output) }}
574
- </div>
575
- </div>
576
-
577
- <div v-if="model.limit" class="mb-2">
578
- <div class="text-xs font-medium text-gray-700 dark:text-gray-300">Limits:</div>
579
- <div class="text-xs text-gray-600 dark:text-gray-400 ml-2">
580
- Context: {{ formatNumber(model.limit.context) }} · Output: {{ formatNumber(model.limit.output) }}
581
- </div>
582
- </div>
583
-
584
- <div v-if="model.knowledge" class="text-xs text-gray-600 dark:text-gray-400">
585
- Knowledge: {{ model.knowledge }}
586
- </div>
587
-
588
- <div class="flex flex-wrap gap-1 mt-2">
589
- <span v-if="model.reasoning" class="px-1.5 py-0.5 text-xs rounded bg-purple-100 dark:bg-purple-900/50 text-purple-700 dark:text-purple-300">reasoning</span>
590
- <span v-if="model.tool_call" class="px-1.5 py-0.5 text-xs rounded bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300">tools</span>
591
-
592
- <!-- Modality Icons -->
593
- <span v-for="mod in getModelModalities(model)" :key="mod"
594
- class="inline-flex items-center justify-center p-0.5 text-gray-400 dark:text-gray-500"
595
- :title="mod"
596
- v-html="modalityIcons[mod]">
597
- </span>
598
- </div>
599
- </div>
600
- `,
601
- props: {
602
- model: Object,
603
- },
604
- setup(props) {
605
- return {
606
- formatCost,
607
- formatNumber,
608
- getModelModalities,
609
- modalityIcons,
610
- }
611
- }
612
- }
613
-
614
- const ModelSelector = {
615
- components: {
616
- ProviderStatus,
617
- ProviderIcon,
618
- ModelSelectorModal,
619
- ModelTooltip,
620
- },
621
- template: `
622
- <!-- Model Selector Button -->
623
- <div class="pl-1 flex space-x-2">
624
- <button type="button" @click="openDialog"
625
- class="flex items-center space-x-2 px-3 py-2 rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 hover:bg-gray-50 dark:hover:bg-gray-800 text-sm text-gray-700 dark:text-gray-300 transition-colors min-w-48 max-w-96"
626
- @mouseenter="showTooltip = true"
627
- @mouseleave="showTooltip = false">
628
- <ProviderIcon v-if="selectedModel?.provider" :provider="selectedModel.provider" class="size-5 flex-shrink-0" />
629
- <span class="truncate flex-1 text-left">{{ selectedModel?.name || 'Select Model...' }}</span>
630
- <svg class="size-4 flex-shrink-0 text-gray-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
631
- <path fill-rule="evenodd" d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z" clip-rule="evenodd" />
632
- </svg>
633
- </button>
634
-
635
- <!-- Info Tooltip (on hover) -->
636
- <ModelTooltip v-if="showTooltip" :model="selectedModel" />
637
-
638
- <ProviderStatus @updated="$emit('updated', $event)" />
639
- </div>
640
- `,
641
- emits: ['updated', 'update:modelValue'],
642
- props: {
643
- models: Array,
644
- modelValue: String,
645
- },
646
- setup(props, { emit }) {
647
- const ctx = inject('ctx')
648
- const showTooltip = ref(false)
649
-
650
- // Get selected model object
651
- const selectedModel = computed(() => {
652
- if (!props.modelValue || !props.models) return null
653
- return props.models.find(m => m.name === props.modelValue) || props.models.find(m => m.id === props.modelValue)
654
- })
655
-
656
- function openDialog() {
657
- ctx.state.models = props.models
658
- ctx.openModal('models')
659
- }
660
-
661
- watch(() => ctx.state.selectedModel, (newVal) => {
662
- emit('update:modelValue', newVal)
663
- })
664
-
665
- onMounted(() => {
666
- ctx.state.models = props.models
667
- })
668
-
669
- return {
670
- showTooltip,
671
- openDialog,
672
- selectedModel,
673
- }
674
- }
675
- }
676
-
677
- export default {
678
- install(ctx) {
679
- ctx.components({
680
- ModelSelector,
681
- })
682
- ctx.modals({
683
- 'models': ModelSelectorModal,
684
- })
685
- }
686
- }