open-model-selector 0.1.0

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/README.md ADDED
@@ -0,0 +1,824 @@
1
+ # open-model-selector
2
+
3
+ [![npm version](https://img.shields.io/npm/v/open-model-selector)](https://www.npmjs.com/package/open-model-selector) [![npm bundle size](https://img.shields.io/bundlephobia/minzip/open-model-selector)](https://bundlephobia.com/package/open-model-selector) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![TypeScript](https://img.shields.io/badge/TypeScript-5.9-blue.svg)](https://www.typescriptlang.org/)
4
+
5
+ ### An accessible, themeable React model-selector combobox for any OpenAI-compatible API — with first-class [Venice.ai](https://venice.ai) support.
6
+
7
+
8
+ <p align="center">
9
+ <img src="https://raw.githubusercontent.com/sethbang/open-model-selector/main/screenshots/main.png" alt="open-model-selector screenshot showing auto-discovery, code snippet, and the model selector popover" width="50%" />
10
+ </p>
11
+
12
+ > Drop it into your app to let users search, filter, and pick from Venice's frontier model catalog (GPT-5.2, Claude Opus 4.6, Gemini 3 Pro, GLM 4.7, Qwen 3 Coder 480B, and more) or any other `/v1/models` endpoint.
13
+ <br />
14
+
15
+ ## Table of Contents
16
+
17
+ - [Features](#features)
18
+ - [Installation](#installation)
19
+ - [Quick Start](#quick-start)
20
+ - [API Reference](#api-reference)
21
+ - [TypeScript](#typescript)
22
+ - [Customization](#customization)
23
+ - [Framework Integration](#framework-integration)
24
+ - [Development](#development)
25
+ - [License](#license)
26
+ - [Links](#links)
27
+
28
+ ## Features
29
+
30
+ - **First-class [Venice.ai](https://venice.ai) support** — full normalizer for Venice's rich `model_spec` format including capabilities, privacy levels, traits, and per-type pricing across 60+ models
31
+ - **Any OpenAI-compatible endpoint** — also works out of the box with OpenAI, OpenRouter, and more
32
+ - **Accessible combobox** built on [cmdk](https://cmdk.paco.me/) + [Radix Popover](https://www.radix-ui.com/primitives/docs/components/popover)
33
+ - **Managed or controlled mode** — auto-fetch models from an API, or pass them in directly
34
+ - **Fuzzy search** by name, provider, ID, and description
35
+ - **Favorites** with `localStorage` persistence or external state control
36
+ - **Sorting** by name (A–Z) or newest
37
+ - **"Use System Default"** sentinel option for fallback behavior
38
+ - **8 model types**: text, image, video, inpaint, embedding, TTS, ASR, upscale
39
+ - **Specialized selectors**: `<TextModelSelector>`, `<ImageModelSelector>`, `<VideoModelSelector>`
40
+ - **Built-in normalizers** for Venice.ai, OpenAI, and OpenRouter response shapes
41
+ - **Scoped CSS** with `--oms-` custom property prefix — never pollutes host styles
42
+ - **Dark mode** via `prefers-color-scheme` and `.dark` class
43
+ - **Full TypeScript types** exported
44
+ - **Dual CJS/ESM output** with sourcemaps
45
+ - **React 18 and 19** support
46
+
47
+ ## Installation
48
+
49
+ ### Install the package
50
+
51
+ ```bash
52
+ npm install open-model-selector
53
+ ```
54
+
55
+ ```bash
56
+ yarn add open-model-selector
57
+ ```
58
+
59
+ ```bash
60
+ pnpm add open-model-selector
61
+ ```
62
+
63
+ ### Peer dependencies
64
+
65
+ The following peer dependencies are required and must be installed separately:
66
+
67
+ | Package | Version |
68
+ | --- | --- |
69
+ | `react` | `^18.0.0 \|\| ^19.0.0` |
70
+ | `react-dom` | `^18.0.0 \|\| ^19.0.0` |
71
+ | `@radix-ui/react-popover` | `^1.0.0` |
72
+ | `cmdk` | `^1.0.0` |
73
+
74
+ Install the non-React peer dependencies (React and React DOM are typically already in your project):
75
+
76
+ ```bash
77
+ # npm
78
+ npm install @radix-ui/react-popover cmdk
79
+ # yarn
80
+ yarn add @radix-ui/react-popover cmdk
81
+ # pnpm
82
+ pnpm add @radix-ui/react-popover cmdk
83
+ ```
84
+
85
+ > **React 18 users:** React 18 does not bundle its own TypeScript types. If you're using TypeScript with React 18, ensure `@types/react` and `@types/react-dom` are installed in your project.
86
+
87
+ ### CSS import
88
+
89
+ You must import the stylesheet for the component to render correctly:
90
+
91
+ ```tsx
92
+ import "open-model-selector/styles.css";
93
+ ```
94
+
95
+ > Import this once in your app's entry point or layout component.
96
+
97
+ ### Node.js requirement
98
+
99
+ Node.js **>=18.0.0** is required.
100
+
101
+ ---
102
+
103
+ ## Quick Start
104
+
105
+ ### Managed Mode (API Fetch)
106
+
107
+ The simplest way to use the component — point it at [Venice.ai](https://venice.ai) (or any OpenAI-compatible endpoint) and it handles the rest:
108
+
109
+ ```tsx
110
+ import { useState } from "react"
111
+ import { ModelSelector } from "open-model-selector"
112
+ import type { AnyModel } from "open-model-selector"
113
+ import "open-model-selector/styles.css"
114
+
115
+ function App() {
116
+ const [modelId, setModelId] = useState<string>("")
117
+ const [selectedModel, setSelectedModel] = useState<AnyModel | null>(null)
118
+
119
+ return (
120
+ <ModelSelector
121
+ baseUrl="https://api.venice.ai/api/v1"
122
+ apiKey="your-api-key"
123
+ value={modelId}
124
+ onChange={(id, model) => {
125
+ setModelId(id)
126
+ setSelectedModel(model) // full model object, or null for system default
127
+ }}
128
+ />
129
+ )
130
+ }
131
+ ```
132
+
133
+ > The `onChange` callback receives the model ID **and** the full model object (or `null` when "Use System Default" is selected). This eliminates the need for a secondary lookup. The component fetches from `{baseUrl}/models` and normalizes responses automatically. Venice.ai's rich `model_spec` format is fully supported — capabilities, privacy levels, traits, pricing, and deprecation info are all extracted out of the box.
134
+
135
+ ### Controlled Mode (Static Models)
136
+
137
+ Pass models directly when you already have them or need full control over the list. Here's an example using Venice.ai's frontier models:
138
+
139
+ ```tsx
140
+ import { useState } from "react"
141
+ import { ModelSelector } from "open-model-selector"
142
+ import type { TextModel } from "open-model-selector"
143
+ import "open-model-selector/styles.css"
144
+
145
+ const models: TextModel[] = [
146
+ {
147
+ id: "zai-org-glm-4.7",
148
+ name: "GLM 4.7",
149
+ provider: "venice.ai",
150
+ type: "text",
151
+ created: 1766534400,
152
+ is_favorite: false,
153
+ context_length: 198000,
154
+ capabilities: {
155
+ supportsFunctionCalling: true,
156
+ supportsReasoning: true,
157
+ supportsWebSearch: true,
158
+ },
159
+ pricing: { prompt: 0.00000055, completion: 0.00000265 },
160
+ },
161
+ {
162
+ id: "qwen3-coder-480b-a35b-instruct",
163
+ name: "Qwen 3 Coder 480B",
164
+ provider: "venice.ai",
165
+ type: "text",
166
+ created: 1745903059,
167
+ is_favorite: false,
168
+ context_length: 256000,
169
+ capabilities: {
170
+ optimizedForCode: true,
171
+ supportsFunctionCalling: true,
172
+ supportsWebSearch: true,
173
+ },
174
+ pricing: { prompt: 0.00000075, completion: 0.000003 },
175
+ },
176
+ {
177
+ id: "claude-opus-4-6",
178
+ name: "Claude Opus 4.6",
179
+ provider: "venice.ai",
180
+ type: "text",
181
+ created: 1770249600,
182
+ is_favorite: false,
183
+ context_length: 1000000,
184
+ capabilities: {
185
+ optimizedForCode: true,
186
+ supportsFunctionCalling: true,
187
+ supportsReasoning: true,
188
+ supportsVision: true,
189
+ },
190
+ pricing: { prompt: 0.000006, completion: 0.00003 },
191
+ },
192
+ ]
193
+
194
+ function App() {
195
+ const [modelId, setModelId] = useState<string>("zai-org-glm-4.7")
196
+
197
+ return (
198
+ <ModelSelector
199
+ models={models}
200
+ value={modelId}
201
+ onChange={(id) => setModelId(id)}
202
+ placeholder="Choose a model..."
203
+ />
204
+ )
205
+ }
206
+ ```
207
+
208
+ > When a non-empty `models` array is provided, the internal API fetch is disabled. Venice.ai offers 60+ models across text, image, video, embedding, TTS, ASR, and more — all accessible via a single API key at [venice.ai](https://venice.ai).
209
+
210
+ ### Specialized Selectors
211
+
212
+ Type-filtered convenience components for common model categories:
213
+
214
+ ```tsx
215
+ import { useState } from "react"
216
+ import { TextModelSelector, ImageModelSelector, VideoModelSelector } from "open-model-selector"
217
+ import "open-model-selector/styles.css" // Required — same stylesheet as <ModelSelector>
218
+
219
+ function App() {
220
+ const [model, setModel] = useState("")
221
+
222
+ return (
223
+ <>
224
+ {/* These are pre-filtered wrappers around <ModelSelector type="..."> */}
225
+ <TextModelSelector baseUrl="https://api.venice.ai/api/v1" value={model} onChange={(id) => setModel(id)} />
226
+ <ImageModelSelector baseUrl="https://api.venice.ai/api/v1" value={model} onChange={(id) => setModel(id)} />
227
+ <VideoModelSelector baseUrl="https://api.venice.ai/api/v1" value={model} onChange={(id) => setModel(id)} />
228
+ </>
229
+ )
230
+ }
231
+ ```
232
+
233
+ > These are convenience wrappers that pass `type="text"`, `type="image"`, or `type="video"` respectively. All three forward `ref` to the root `<div>`, just like `<ModelSelector>`.
234
+
235
+ ---
236
+
237
+ ## API Reference
238
+
239
+ ### `<ModelSelector>`
240
+
241
+ The primary component. Renders an accessible combobox popover for searching and selecting models.
242
+
243
+ The component forwards `ref` to the root `<div>`.
244
+
245
+ | Prop | Type | Default | Description |
246
+ |------|------|---------|-------------|
247
+ | `models` | `AnyModel[]` | `[]` | Static list of models. When non-empty, disables internal API fetch. |
248
+ | `baseUrl` | `string` | — | Base URL for the OpenAI-compatible API (e.g., `"https://api.venice.ai/api/v1"`). |
249
+ | `apiKey` | `string` | — | API key for authentication. ⚠️ Visible in browser DevTools — use a backend proxy in production. |
250
+ | `type` | `ModelType` | — | Filter to a specific model type (`"text"`, `"image"`, `"video"`, etc.). |
251
+ | `queryParams` | `Record<string, string>` | `{}` | Query parameters appended to the `/models` URL as a query string. See [Query Parameters](#query-parameters) below. |
252
+ | `fetcher` | `(url: string, init?: RequestInit) => Promise<Response>` | `fetch` | Custom fetch function for SSR, proxies, or testing. |
253
+ | `responseExtractor` | `ResponseExtractor` | `defaultResponseExtractor` | Custom function to extract the model array from the API response. |
254
+ | `normalizer` | `ModelNormalizer` | `defaultModelNormalizer` | Custom function to normalize each raw model object into an `AnyModel`. |
255
+ | `value` | `string` | — | Currently selected model ID (controlled). |
256
+ | `onChange` | `(modelId: string, model: AnyModel \| null) => void` | — | Callback when a model is selected. Receives the model ID and the full model object (or `null` for the system-default sentinel). If omitted, a dev-mode warning is logged. |
257
+ | `onToggleFavorite` | `(modelId: string) => void` | — | Callback for favorite toggle. If omitted, favorites use localStorage. |
258
+ | `placeholder` | `string` | `"Select model..."` | Placeholder text when no model is selected. |
259
+ | `sortOrder` | `"name" \| "created"` | — | Controlled sort order. If omitted, internal state is used. |
260
+ | `onSortChange` | `(order: "name" \| "created") => void` | — | Callback when sort changes. |
261
+ | `side` | `"top" \| "bottom" \| "left" \| "right"` | `"bottom"` | Popover placement relative to the trigger. |
262
+ | `className` | `string` | — | Additional CSS class(es) for the root element. |
263
+ | `storageKey` | `string` | `"open-model-selector-favorites"` | localStorage key for persisting favorites (uncontrolled mode). |
264
+ | `showSystemDefault` | `boolean` | `true` | Whether to show the "Use System Default" option. |
265
+ | `showDeprecated` | `boolean` | `true` | Whether to show deprecated models. When `false`, past-date deprecated models are hidden. |
266
+ | `disabled` | `boolean` | `false` | When true, prevents opening the selector and dims the trigger button. |
267
+
268
+ The library exports a sentinel constant for the system default option:
269
+
270
+ ```ts
271
+ import { SYSTEM_DEFAULT_VALUE } from "open-model-selector"
272
+ // SYSTEM_DEFAULT_VALUE === "system_default"
273
+ ```
274
+
275
+ ### `useModels` Hook
276
+
277
+ Fetches and normalizes models from an OpenAI-compatible API endpoint. Used internally by `<ModelSelector>` but available for building custom UIs on top of Venice.ai or any other provider.
278
+
279
+ ```tsx
280
+ import { useModels } from "open-model-selector"
281
+
282
+ const { models, loading, error } = useModels({
283
+ baseUrl: "https://api.venice.ai/api/v1",
284
+ apiKey: "your-key",
285
+ type: "text",
286
+ })
287
+ ```
288
+
289
+ #### Props (`UseModelsProps`)
290
+
291
+ | Prop | Type | Default | Description |
292
+ |------|------|---------|-------------|
293
+ | `baseUrl` | `string` | — | Base URL for the API. If omitted, no fetch occurs. |
294
+ | `apiKey` | `string` | — | Bearer token for authentication. |
295
+ | `type` | `ModelType` | — | Client-side filter by model type. |
296
+ | `queryParams` | `Record<string, string>` | `{}` | Query parameters appended to the `/models` URL. See [Query Parameters](#query-parameters) below. |
297
+ | `fetcher` | `(url: string, init?: RequestInit) => Promise<Response>` | `fetch` | Custom fetch function. |
298
+ | `responseExtractor` | `ResponseExtractor` | `defaultResponseExtractor` | Extracts model array from response JSON. |
299
+ | `normalizer` | `ModelNormalizer` | `defaultModelNormalizer` | Normalizes each raw model into `AnyModel`. |
300
+
301
+ #### Return Value (`UseModelsResult`)
302
+
303
+ | Field | Type | Description |
304
+ |-------|------|-------------|
305
+ | `models` | `AnyModel[]` | Normalized model array. |
306
+ | `loading` | `boolean` | `true` while fetching. |
307
+ | `error` | `Error \| null` | Fetch or normalization error, if any. |
308
+
309
+ > **Notes:**
310
+ > - Automatically cleans up with `AbortController` on unmount
311
+ > - Re-fetches when `baseUrl`, `apiKey`, or `queryParams` change
312
+ > - `fetcher`, `responseExtractor`, and `normalizer` are stored in refs (no memoization needed)
313
+
314
+ > **⚠️ Stability:** `apiKey` and `baseUrl` are used as React effect dependencies. Ensure these are stable values (string constants, state variables, or `useMemo`'d values). Passing an unstable reference (e.g., `apiKey={computeKey()}`) will cause infinite re-fetching.
315
+
316
+ ### Query Parameters
317
+
318
+ The `queryParams` prop is appended to the `/models` endpoint URL as query string parameters (e.g., `queryParams={{ type: 'text' }}` fetches from `/models?type=text`).
319
+
320
+ **Venice.ai users:** Venice's `/models` endpoint supports a `type` parameter to filter models server-side. Pass the model type you need:
321
+
322
+ ```tsx
323
+ // Fetch only text models from Venice
324
+ <ModelSelector
325
+ baseUrl="https://api.venice.ai/api/v1"
326
+ queryParams={{ type: 'text' }}
327
+ value={model}
328
+ onChange={(id) => setModel(id)}
329
+ />
330
+
331
+ // Fetch all model types from Venice
332
+ <ModelSelector
333
+ baseUrl="https://api.venice.ai/api/v1"
334
+ queryParams={{ type: 'all' }}
335
+ value={model}
336
+ onChange={(id) => setModel(id)}
337
+ />
338
+ ```
339
+
340
+ > Venice supports `type` values: `"text"`, `"image"`, `"video"`, `"embedding"`, `"tts"`, `"asr"`, `"upscale"`, `"inpaint"`, and `"all"`.
341
+
342
+ **Other APIs:** Pass whatever query parameters your endpoint expects, or omit `queryParams` entirely if none are needed.
343
+
344
+ > **Performance tip:** The `type` prop (e.g., `type="text"`) filters **client-side** — all models are fetched first, then filtered in the browser. For large catalogs, use `queryParams` to filter **server-side** at the API level, reducing payload size and parse time. You can combine both: `queryParams` for server-side pre-filtering and `type` as a client-side safety net.
345
+
346
+ ### Model Types
347
+
348
+ The library supports 8 model types, each with its own TypeScript interface extending `BaseModel`:
349
+
350
+ | Type | Interface | Key Fields |
351
+ |------|-----------|------------|
352
+ | `"text"` | `TextModel` | `pricing.prompt`, `pricing.completion`, `pricing.cache_input`, `pricing.cache_write`, `context_length`, `capabilities`, `constraints.temperature`, `constraints.top_p` |
353
+ | `"image"` | `ImageModel` | `pricing.generation`, `pricing.resolutions`, `constraints.aspectRatios`, `constraints.resolutions`, `supportsWebSearch` |
354
+ | `"video"` | `VideoModel` | `constraints.resolutions`, `constraints.durations`, `constraints.aspect_ratios`, `model_sets` |
355
+ | `"inpaint"` | `InpaintModel` | `pricing.generation`, `constraints.aspectRatios`, `constraints.combineImages` |
356
+ | `"embedding"` | `EmbeddingModel` | `pricing.input`, `pricing.output` |
357
+ | `"tts"` | `TtsModel` | `pricing.input`, `voices` |
358
+ | `"asr"` | `AsrModel` | `pricing.per_audio_second` |
359
+ | `"upscale"` | `UpscaleModel` | `pricing.generation` |
360
+
361
+ The union type `AnyModel` represents any of the above.
362
+
363
+ The `BaseModel` interface includes these fields shared by all types:
364
+
365
+ - `id: string`
366
+ - `name: string`
367
+ - `provider: string`
368
+ - `created: number` (Unix timestamp)
369
+ - `type: ModelType`
370
+ - `description?: string`
371
+ - `privacy?: "private" | "anonymized"`
372
+ - `is_favorite?: boolean`
373
+ - `offline?: boolean`
374
+ - `betaModel?: boolean`
375
+ - `modelSource?: string`
376
+ - `traits?: string[]`
377
+ - `deprecation?: { date: string }`
378
+
379
+ ```tsx
380
+ import type { TextModel, ImageModel, AnyModel, ModelType } from "open-model-selector"
381
+ ```
382
+
383
+ The library also exports prop types for each specialized selector:
384
+
385
+ ```tsx
386
+ import type {
387
+ ModelSelectorProps,
388
+ TextModelSelectorProps,
389
+ ImageModelSelectorProps,
390
+ VideoModelSelectorProps,
391
+ } from "open-model-selector"
392
+ ```
393
+
394
+ > `TextModelSelectorProps`, `ImageModelSelectorProps`, and `VideoModelSelectorProps` are each `Omit<ModelSelectorProps, 'type'>` — identical to `ModelSelectorProps` with the `type` prop removed.
395
+
396
+ ---
397
+
398
+ ## TypeScript
399
+
400
+ Full `.d.ts` type declarations are included in the package — no separate `@types/` install is needed.
401
+
402
+ ### All Exported Types
403
+
404
+ ```ts
405
+ // Component props
406
+ import type {
407
+ ModelSelectorProps,
408
+ TextModelSelectorProps,
409
+ ImageModelSelectorProps,
410
+ VideoModelSelectorProps,
411
+ } from "open-model-selector"
412
+
413
+ // Hook types
414
+ import type { UseModelsProps, UseModelsResult, FetchFn } from "open-model-selector"
415
+
416
+ // Base model types
417
+ import type { ModelType, BaseModel, Deprecation, AnyModel } from "open-model-selector"
418
+
419
+ // Text model types
420
+ import type { TextModel, TextPricing, TextCapabilities, TextConstraints } from "open-model-selector"
421
+
422
+ // Image model types
423
+ import type { ImageModel, ImagePricing, ImageConstraints } from "open-model-selector"
424
+
425
+ // Video model types
426
+ import type { VideoModel, VideoConstraints } from "open-model-selector"
427
+
428
+ // Other model types
429
+ import type { InpaintModel, InpaintPricing, InpaintConstraints } from "open-model-selector"
430
+ import type { EmbeddingModel, EmbeddingPricing } from "open-model-selector"
431
+ import type { TtsModel, TtsPricing } from "open-model-selector"
432
+ import type { AsrModel, AsrPricing } from "open-model-selector"
433
+ import type { UpscaleModel, UpscalePricing } from "open-model-selector"
434
+
435
+ // Normalizer types (also available from "open-model-selector/utils")
436
+ import type { ModelNormalizer, ResponseExtractor } from "open-model-selector"
437
+ ```
438
+
439
+ > **Tip:** `import type` statements are erased at compile time and are always safe in React Server Components, regardless of `"use client"` directives.
440
+
441
+ ---
442
+
443
+ ## Customization
444
+
445
+ ### Custom Normalizer
446
+
447
+ The built-in `defaultModelNormalizer` handles Venice.ai, OpenAI, and OpenRouter response shapes automatically — including Venice's nested `model_spec` format with capabilities, privacy, traits, and deprecation info. If your API returns a different shape, provide a custom normalizer:
448
+
449
+ ```tsx
450
+ import { ModelSelector } from "open-model-selector"
451
+ import type { ModelNormalizer, AnyModel } from "open-model-selector"
452
+
453
+ const myNormalizer: ModelNormalizer = (raw): AnyModel => ({
454
+ id: raw.model_id as string,
455
+ name: raw.display_name as string,
456
+ provider: raw.org as string,
457
+ type: "text",
458
+ created: Date.now() / 1000,
459
+ is_favorite: false,
460
+ context_length: Number(raw.max_tokens) || 128000,
461
+ pricing: {
462
+ prompt: Number(raw.cost_per_input_token),
463
+ completion: Number(raw.cost_per_output_token),
464
+ },
465
+ })
466
+
467
+ <ModelSelector
468
+ baseUrl="https://my-api.com/v1"
469
+ normalizer={myNormalizer}
470
+ value={model}
471
+ onChange={(id) => setModel(id)}
472
+ />
473
+ ```
474
+
475
+ > You can also compose with the built-in `extractBaseFields` helper for shared fields. The normalizer is stored in a ref — no `useCallback` wrapper needed.
476
+
477
+ ### Custom Response Extractor
478
+
479
+ The built-in `defaultResponseExtractor` handles `{ data: [...] }`, `{ models: [...] }`, and top-level arrays. For non-standard response shapes, provide a custom extractor:
480
+
481
+ ```tsx
482
+ import type { ResponseExtractor } from "open-model-selector"
483
+
484
+ const myExtractor: ResponseExtractor = (body) => {
485
+ // Your API returns { results: { items: [...] } }
486
+ const results = (body as any).results
487
+ return results?.items ?? []
488
+ }
489
+
490
+ <ModelSelector
491
+ baseUrl="https://my-api.com/v1"
492
+ responseExtractor={myExtractor}
493
+ value={model}
494
+ onChange={(id) => setModel(id)}
495
+ />
496
+ ```
497
+
498
+ ### Styling and Theming
499
+
500
+ All CSS variables are scoped under `.oms-reset` and use the `--oms-` prefix — they never pollute `:root` or the host app.
501
+
502
+ **Overriding variables:**
503
+
504
+ ```css
505
+ .oms-reset {
506
+ --oms-primary: 210 100% 50%;
507
+ --oms-radius: 0.75rem;
508
+ --oms-popover-width: 400px;
509
+ }
510
+ ```
511
+
512
+ **Key CSS variables:**
513
+
514
+ | Variable | Default (Light) | Purpose |
515
+ |----------|----------------|---------|
516
+ | `--oms-background` | `0 0% 100%` | Background color |
517
+ | `--oms-foreground` | `222.2 84% 4.9%` | Text color |
518
+ | `--oms-primary` | `222.2 47.4% 11.2%` | Primary/action color |
519
+ | `--oms-accent` | `210 40% 96.1%` | Hover/selection highlight |
520
+ | `--oms-muted-foreground` | `215.4 16.3% 46.9%` | Subdued text |
521
+ | `--oms-border` | `214.3 31.8% 91.4%` | Border color |
522
+ | `--oms-destructive` | `0 84.2% 60.2%` | Error/destructive color |
523
+ | `--oms-radius` | `0.5rem` | Border radius |
524
+ | `--oms-popover-width` | `300px` | Popover width |
525
+
526
+ > **Important:** CSS variable values must be space-separated HSL triplets (e.g., `220 14% 96%`), **not** hex, rgb, or named colors. The component uses these values inside `hsl()` wrappers internally, following the Shadcn/ui convention. This allows alpha composition: `hsl(var(--oms-accent) / 0.5)`.
527
+ >
528
+ > ```css
529
+ > /* ✅ Correct */
530
+ > .my-theme .oms-popover-content {
531
+ > --oms-background: 220 14% 96%;
532
+ > }
533
+ >
534
+ > /* ❌ Incorrect — will break styling */
535
+ > .my-theme .oms-popover-content {
536
+ > --oms-background: #f0f0f0;
537
+ > --oms-background: rgb(240, 240, 240);
538
+ > --oms-background: white;
539
+ > }
540
+ > ```
541
+
542
+ **Dark mode:**
543
+
544
+ The component supports two dark mode strategies:
545
+
546
+ 1. **Automatic** — `@media (prefers-color-scheme: dark)` works out of the box with no configuration.
547
+ 2. **Class-based** — Add a `.dark` class to any ancestor element. Compatible with Tailwind's `darkMode: "class"`.
548
+
549
+ ```html
550
+ <!-- Automatic: just works with OS dark mode -->
551
+ <ModelSelector ... />
552
+
553
+ <!-- Manual: add .dark class to any ancestor -->
554
+ <div class="dark">
555
+ <ModelSelector ... />
556
+ </div>
557
+ ```
558
+
559
+ > Portal-safe — the dark theme is also applied to popover and tooltip content that renders outside the component tree via Radix portals.
560
+
561
+ ### Format Utilities
562
+
563
+ The `open-model-selector/utils` entry point exports format utilities without the `"use client"` directive, making them safe for React Server Components:
564
+
565
+ ```tsx
566
+ import {
567
+ formatPrice,
568
+ formatContextLength,
569
+ formatFlatPrice,
570
+ formatAudioPrice,
571
+ formatDuration,
572
+ formatResolutions,
573
+ formatAspectRatios,
574
+ } from "open-model-selector/utils"
575
+ ```
576
+
577
+ Usage examples:
578
+
579
+ ```ts
580
+ formatPrice("0.000003") // "$3.00" — per-token price × 1,000,000
581
+ formatPrice(0.00003) // "$30.00" — works with numbers too
582
+ formatPrice(0) // "Free"
583
+ formatPrice(undefined) // "—"
584
+ formatContextLength(128000) // "128k"
585
+ formatFlatPrice(0.04) // "$0.04"
586
+ formatAudioPrice(0.006) // "$0.0060 / sec"
587
+ formatDuration(["5", "10"]) // "5s – 10s"
588
+ formatResolutions(["720p", "1080p", "4K"]) // "720p, 1080p, 4K"
589
+ formatAspectRatios(["1:1", "16:9", "4:3"]) // "1:1, 16:9, 4:3"
590
+ ```
591
+
592
+ > **Note:** `formatPrice` expects the raw **per-token** price (as returned by OpenAI, Venice.ai, OpenRouter, etc.) and converts it to a **per-million-token** display value by multiplying by 1,000,000. For example, a per-token cost of `0.000003` becomes `$3.00` per million tokens. Very small values (< $0.01/M) use 6 decimal places to preserve precision.
593
+
594
+ ### Normalizer Utilities
595
+
596
+ In addition to `defaultModelNormalizer` and `defaultResponseExtractor`, the library exports the individual per-type normalizers, type inference helpers, and low-level building blocks. These are available from both `"open-model-selector"` and `"open-model-selector/utils"`.
597
+
598
+ #### Per-Type Normalizers
599
+
600
+ Each model type has a dedicated normalizer that converts a raw API response object into the corresponding typed model:
601
+
602
+ ```ts
603
+ import {
604
+ normalizeTextModel,
605
+ normalizeImageModel,
606
+ normalizeVideoModel,
607
+ normalizeInpaintModel,
608
+ normalizeEmbeddingModel,
609
+ normalizeTtsModel,
610
+ normalizeAsrModel,
611
+ normalizeUpscaleModel,
612
+ } from "open-model-selector/utils"
613
+
614
+ // Each accepts a raw object and returns the typed model
615
+ const textModel = normalizeTextModel(rawApiObject) // → TextModel
616
+ const imageModel = normalizeImageModel(rawApiObject) // → ImageModel
617
+ ```
618
+
619
+ > These are the same functions used internally by `defaultModelNormalizer`. Use them directly when you need to normalize models of a known type without the dispatching logic.
620
+
621
+ | Function | Returns |
622
+ |----------|---------|
623
+ | `normalizeTextModel(raw)` | `TextModel` |
624
+ | `normalizeImageModel(raw)` | `ImageModel` |
625
+ | `normalizeVideoModel(raw)` | `VideoModel` |
626
+ | `normalizeInpaintModel(raw)` | `InpaintModel` |
627
+ | `normalizeEmbeddingModel(raw)` | `EmbeddingModel` |
628
+ | `normalizeTtsModel(raw)` | `TtsModel` |
629
+ | `normalizeAsrModel(raw)` | `AsrModel` |
630
+ | `normalizeUpscaleModel(raw)` | `UpscaleModel` |
631
+
632
+ #### Type Inference
633
+
634
+ `inferTypeFromId` uses heuristic pattern matching on a model's ID string to determine its type. This is how `defaultModelNormalizer` classifies models from providers that don't include an explicit `type` field (e.g., OpenAI, OpenRouter):
635
+
636
+ ```ts
637
+ import { inferTypeFromId, MODEL_ID_TYPE_PATTERNS } from "open-model-selector/utils"
638
+
639
+ inferTypeFromId("dall-e-3") // "image"
640
+ inferTypeFromId("whisper-large-v3") // "asr"
641
+ inferTypeFromId("gpt-4o") // undefined (no match → caller defaults to "text")
642
+ ```
643
+
644
+ `MODEL_ID_TYPE_PATTERNS` is the `Array<[RegExp, ModelType]>` used internally. It's exported so you can inspect the built-in rules or extend them for custom providers.
645
+
646
+ #### Low-Level Helpers
647
+
648
+ | Function | Signature | Description |
649
+ |----------|-----------|-------------|
650
+ | `extractBaseFields` | `(raw, type) → Omit<BaseModel, 'type'>` | Extracts shared `BaseModel` fields from a raw API object. Handles both Venice's nested `model_spec` format and flat top-level fields. Useful when writing custom per-type normalizers. |
651
+ | `toNum` | `(v: unknown) → number \| undefined` | Safely coerces an unknown value to a number. Returns `undefined` for `undefined`, `null`, empty strings, and `NaN`. Used internally by all normalizers. |
652
+
653
+ ### Helper Utilities
654
+
655
+ These are exported from `"open-model-selector/utils"` (and also re-exported from the main entry):
656
+
657
+ ```ts
658
+ import { isDeprecated } from "open-model-selector/utils"
659
+ // or equivalently:
660
+ import { isDeprecated } from "open-model-selector"
661
+ ```
662
+
663
+ | Function | Signature | Description |
664
+ |----------|-----------|-------------|
665
+ | `isDeprecated` | `(dateStr: string) → boolean` | Returns `true` if the given ISO 8601 date string is in the past. Handles date-only strings (`"2025-01-15"`) by normalizing to UTC. Returns `false` for invalid dates. |
666
+
667
+ ---
668
+
669
+ ## Framework Integration
670
+
671
+ ### `"use client"` & React Server Components
672
+
673
+ All component and hook modules include a `"use client"` directive — they are safe to import in Next.js App Router, Remix, and other RSC-aware frameworks without extra wrappers.
674
+
675
+ The **`open-model-selector/utils`** sub-path does **not** include `"use client"` and is safe to import directly in Server Components:
676
+
677
+ ```tsx
678
+ // ✅ Server Component — works fine
679
+ import { formatPrice, formatContextLength } from "open-model-selector/utils"
680
+
681
+ // ✅ Server Component — type-only imports are always safe
682
+ import type { TextModel, AnyModel } from "open-model-selector"
683
+ ```
684
+
685
+ If you need the components or `useModels` hook, import them in a Client Component (any file with `"use client"` at the top, or rendered inside one).
686
+
687
+ ### SSR Compatibility
688
+
689
+ The library is SSR-safe:
690
+
691
+ - An isomorphic `useLayoutEffect` pattern is used internally, so there are no React warnings during server-side rendering.
692
+ - Works with **Next.js** (App Router and Pages Router), **Remix**, **Gatsby**, and any other SSR framework.
693
+ - Tooltips render via a `createPortal` call — they require a DOM environment, but this is handled automatically since the portal only mounts after hydration.
694
+
695
+ ### Security
696
+
697
+ - **API keys** are sent as an `Authorization: Bearer` header, never as URL query parameters.
698
+ - **Error messages** from failed requests are constructed from `response.status` and `response.statusText` only — user-supplied content is not interpolated into the DOM.
699
+ - **Client-side key exposure:** Any `apiKey` passed as a prop is visible in browser DevTools. For production apps, use one of:
700
+ - A server-side proxy that injects the key (pass a relative `baseUrl` and a custom `fetcher`)
701
+ - Environment variables scoped to the server (e.g., `VENICE_API_KEY`) with a thin API route
702
+ - A scoped/read-only API key with minimal permissions
703
+
704
+ #### Recommended: Backend Proxy Pattern
705
+
706
+ Instead of passing `apiKey` directly, use a server-side proxy and the `fetcher` prop:
707
+
708
+ ```tsx
709
+ // Next.js API Route: app/api/models/route.ts
710
+ import { NextResponse } from 'next/server'
711
+
712
+ export async function GET() {
713
+ const res = await fetch('https://api.venice.ai/api/v1/models', {
714
+ headers: { Authorization: `Bearer ${process.env.VENICE_API_KEY}` },
715
+ })
716
+ return NextResponse.json(await res.json())
717
+ }
718
+ ```
719
+
720
+ ```tsx
721
+ // Client component
722
+ <ModelSelector
723
+ baseUrl="/api"
724
+ fetcher={(url, init) => fetch(url, init)}
725
+ onChange={(id) => console.log(id)}
726
+ />
727
+ ```
728
+
729
+ This keeps your API key server-side and never exposes it to the browser.
730
+
731
+ ### Accessibility
732
+
733
+ The component implements the [ARIA combobox pattern](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/) and is designed to work well with screen readers and keyboard-only navigation.
734
+
735
+ **Combobox & Popover:**
736
+ - Trigger button has `role="combobox"` with `aria-expanded`, `aria-haspopup="listbox"`, and `aria-controls` linking to the listbox
737
+ - Dynamic `aria-label` reflects the current selection state
738
+ - Search input is labeled (`aria-label="Search models"`)
739
+ - Loading state uses `role="status"` with `aria-live="polite"`; errors use `role="alert"`
740
+
741
+ **Keyboard Navigation** (provided by [cmdk](https://cmdk.paco.me/) and [Radix Popover](https://www.radix-ui.com/primitives/docs/components/popover)):
742
+ - `↑` / `↓` — navigate the model list
743
+ - `Enter` — select the highlighted model
744
+ - `Escape` — close the popover and return focus to the trigger
745
+ - Type-ahead filtering via the search input (auto-focused on open)
746
+
747
+ **Tooltips:**
748
+ - Model detail tooltips use `role="tooltip"` with a unique `id` linked via `aria-describedby` on the trigger
749
+ - Tooltips appear on both hover and keyboard focus (`onFocus` / `onBlur`), and dismiss with `Escape`
750
+
751
+ ---
752
+
753
+ ## Development
754
+
755
+ ### Scripts
756
+
757
+ | Command | Description |
758
+ |---------|-------------|
759
+ | `npm run build` | Build with tsup (CJS + ESM + types + sourcemaps). Cleans `dist/` first via `prebuild`. |
760
+ | `npm run dev` | Build in watch mode for development. |
761
+ | `npm run typecheck` | Run TypeScript type checking (`tsc`). |
762
+ | `npm test` | Run tests with Vitest. |
763
+ | `npm run test:watch` | Run tests in watch mode. |
764
+ | `npm run storybook` | Start Storybook dev server on port 6006. |
765
+ | `npm run build-storybook` | Build static Storybook site. |
766
+
767
+ > `npm run prepublishOnly` runs typecheck → test → build automatically before publishing.
768
+
769
+ ### Testing
770
+
771
+ The project uses a layered testing strategy:
772
+
773
+ - **Unit tests**: Vitest with jsdom environment
774
+ - **Component tests**: `@testing-library/react` + `@testing-library/user-event`
775
+ - **Browser tests**: Storybook + Playwright via `@storybook/addon-vitest` and `@vitest/browser-playwright`
776
+
777
+ Test files:
778
+
779
+ - [`src/components/model-selector.test.tsx`](src/components/model-selector.test.tsx) — Component tests
780
+ - [`src/hooks/use-models.test.tsx`](src/hooks/use-models.test.tsx) — Hook tests
781
+ - [`src/utils/format.test.ts`](src/utils/format.test.ts) — Format utility tests
782
+ - [`src/utils/normalizers/normalizers.test.ts`](src/utils/normalizers/normalizers.test.ts) — Normalizer tests
783
+
784
+ ```bash
785
+ # Run all tests
786
+ npm test
787
+
788
+ # Run tests in watch mode
789
+ npm run test:watch
790
+ ```
791
+
792
+ ### Storybook
793
+
794
+ The project includes comprehensive Storybook stories covering multiple scenarios: Default, PreselectedModel, SystemDefault, CustomPlaceholder, SortByNewest, ControlledFavorites, EmptyState, LoadingState, ErrorState, PopoverTop, WideContainer, DarkMode, MinimalModels, and VeniceLive.
795
+
796
+ ```bash
797
+ npm run storybook
798
+ # Opens at http://localhost:6006
799
+ ```
800
+
801
+ ### Contributing
802
+
803
+ 1. Fork the repository
804
+ 2. Create a feature branch (`git checkout -b feature/my-feature`)
805
+ 3. Make your changes
806
+ 4. Run `npm run typecheck && npm test` to verify
807
+ 5. Commit and push
808
+ 6. Open a Pull Request
809
+
810
+ ---
811
+
812
+ ## License
813
+
814
+ MIT — see [LICENSE](LICENSE) for details.
815
+
816
+ ---
817
+
818
+ ## Links
819
+
820
+ - [npm](https://www.npmjs.com/package/open-model-selector)
821
+ - [GitHub](https://github.com/sethbang/open-model-selector)
822
+ - [Changelog](CHANGELOG.md)
823
+ - [Issues](https://github.com/sethbang/open-model-selector/issues)
824
+ - [Venice.ai](https://venice.ai) — recommended OpenAI-compatible API provider, offering both frontier models and open source models.