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