@hanzo/ui 8.27.60 → 8.27.63

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 (55) hide show
  1. package/dist/auth/SignIn.cjs +2 -78
  2. package/dist/auth/SignIn.d.ts +1 -9
  3. package/dist/auth/SignIn.d.ts.map +1 -1
  4. package/dist/auth/SignIn.js +2 -78
  5. package/dist/auth/SignIn.js.map +1 -1
  6. package/dist/chat/Composer.cjs +1 -1
  7. package/dist/chat/Composer.d.ts +1 -1
  8. package/dist/chat/Composer.js +1 -1
  9. package/dist/consent/Consent.cjs +8 -23
  10. package/dist/consent/Consent.d.ts.map +1 -1
  11. package/dist/consent/Consent.js +11 -26
  12. package/dist/consent/Consent.js.map +1 -1
  13. package/dist/gallery.cjs +4 -4
  14. package/dist/gallery.d.ts.map +1 -1
  15. package/dist/gallery.html +4 -5
  16. package/dist/gallery.js +5 -5
  17. package/dist/gallery.js.map +1 -1
  18. package/dist/index.cjs +1 -1
  19. package/dist/index.js +1 -1
  20. package/dist/index.node.cjs +1 -1
  21. package/dist/index.node.js +1 -1
  22. package/dist/marketing/Footer.cjs +1 -1
  23. package/dist/marketing/Footer.js +1 -1
  24. package/dist/models/ModelPicker.cjs +191 -0
  25. package/dist/models/ModelPicker.d.ts +27 -0
  26. package/dist/models/ModelPicker.d.ts.map +1 -0
  27. package/dist/models/ModelPicker.js +189 -0
  28. package/dist/models/ModelPicker.js.map +1 -0
  29. package/dist/models/catalog.cjs +331 -181
  30. package/dist/models/catalog.d.ts +110 -38
  31. package/dist/models/catalog.d.ts.map +1 -1
  32. package/dist/models/catalog.js +312 -176
  33. package/dist/models/catalog.js.map +1 -1
  34. package/dist/models/index.cjs +33 -12
  35. package/dist/models/index.d.ts +7 -4
  36. package/dist/models/index.d.ts.map +1 -1
  37. package/dist/models/index.js +7 -6
  38. package/dist/models/index.js.map +1 -1
  39. package/dist/models/picker.logic.cjs +62 -0
  40. package/dist/models/picker.logic.d.ts +41 -0
  41. package/dist/models/picker.logic.d.ts.map +1 -0
  42. package/dist/models/picker.logic.js +54 -0
  43. package/dist/models/picker.logic.js.map +1 -0
  44. package/dist/product/limits.cjs +14 -13
  45. package/dist/product/limits.d.ts +9 -10
  46. package/dist/product/limits.d.ts.map +1 -1
  47. package/dist/product/limits.js +14 -12
  48. package/dist/product/limits.js.map +1 -1
  49. package/dist/styles.css +1 -2
  50. package/package.json +2 -2
  51. package/dist/models/ModelSelector.cjs +0 -80
  52. package/dist/models/ModelSelector.d.ts +0 -44
  53. package/dist/models/ModelSelector.d.ts.map +0 -1
  54. package/dist/models/ModelSelector.js +0 -78
  55. package/dist/models/ModelSelector.js.map +0 -1
@@ -1,220 +1,370 @@
1
1
  "use strict";
2
- /**
3
- * @hanzo/ui/models — Model catalog data layer
4
- *
5
- * Pure, SSR-safe helpers shared by the unified ModelSelector. No React,
6
- * no browser globals — usable on the server, in edge runtimes, or the client.
7
- *
8
- * ModelCatalogEntry is the gateway/OpenAI-shaped record ({ id, owned_by, ... })
9
- * returned by `${baseUrl}/models`. It is intentionally distinct from the
10
- * registry-shaped ZenModelLike in ./types (which drives the marketing cards).
11
- */
12
2
  Object.defineProperty(exports, "__esModule", { value: true });
13
- exports.RESEARCH = void 0;
14
- exports.familyOf = familyOf;
15
- exports.groupModelsByFamily = groupModelsByFamily;
16
- exports.isChatModel = isChatModel;
17
- exports.filterChatModels = filterChatModels;
3
+ exports.ENSO = exports.CAPABILITY_NAMES = exports.CLASS_NAMES = exports.FAMILIES = exports.RESEARCH = void 0;
4
+ exports.withResearch = withResearch;
5
+ exports.modelOf = modelOf;
6
+ exports.parseModels = parseModels;
18
7
  exports.fetchModelCatalog = fetchModelCatalog;
8
+ exports.makerOf = makerOf;
9
+ exports.makerName = makerName;
10
+ exports.modelName = modelName;
11
+ exports.groupKey = groupKey;
12
+ exports.groupLabel = groupLabel;
13
+ exports.groupModels = groupModels;
14
+ exports.capabilitiesOf = capabilitiesOf;
15
+ exports.can = can;
16
+ exports.matchesPaused = matchesPaused;
17
+ exports.isPaused = isPaused;
18
+ exports.matchesModel = matchesModel;
19
+ exports.sortModels = sortModels;
20
+ exports.defaultModel = defaultModel;
21
+ exports.formatContext = formatContext;
22
+ exports.formatPrice = formatPrice;
19
23
  /**
20
24
  * The models that exist and cannot be called, said once for every list of
21
25
  * models: a picker draws each one disabled, a catalogue page draws it muted.
22
- *
23
- * No gateway serves them, and that is the point: `/v1/models` never answers for
24
- * one, so nothing can route to it. A host that lists them appends these to what
25
- * the gateway said.
26
+ * `/v1/models` never answers for one, so nothing can route to it.
26
27
  */
27
28
  exports.RESEARCH = [
28
29
  {
29
30
  id: 'zen7',
30
31
  owned_by: 'zenlm',
31
- label: 'Zen 7',
32
+ family: 'zen',
33
+ name: 'Zen 7',
32
34
  access: 'research',
33
35
  request: 'https://hanzo.ai/research-access',
34
36
  description: 'The next open-weight generation after Zen 6, in research preview.',
35
37
  },
36
38
  ];
39
+ /** The catalog with the research models it does not already list, appended. */
40
+ function withResearch(models) {
41
+ const ids = new Set(models.map((m) => m.id));
42
+ return [...models, ...exports.RESEARCH.filter((m) => !ids.has(m.id))];
43
+ }
44
+ // ── reading the wire ─────────────────────────────────────────────────────────
45
+ const CLASSES = ['premium', 'ours', 'free'];
46
+ const FAMILY_IDS = ['enso', 'zen', 'kai', 'jev', 'zoo'];
47
+ const str = (v) => (typeof v === 'string' && v.trim() ? v : undefined);
48
+ const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : undefined);
49
+ const pos = (v) => {
50
+ const n = num(v);
51
+ return n !== undefined && n > 0 ? n : undefined;
52
+ };
53
+ const yes = (v) => (v === true ? true : undefined);
54
+ const strs = (v) => {
55
+ if (!Array.isArray(v))
56
+ return undefined;
57
+ const out = v.filter((x) => typeof x === 'string' && x.trim() !== '').map((x) => x.toLowerCase());
58
+ return out.length ? out : undefined;
59
+ };
60
+ const oneOf = (set, v) => typeof v === 'string' && set.includes(v) ? v : undefined;
61
+ function pricingOf(v) {
62
+ if (!v || typeof v !== 'object')
63
+ return undefined;
64
+ const p = v;
65
+ const input = num(p.input_per_million);
66
+ const output = num(p.output_per_million);
67
+ if (input === undefined && output === undefined)
68
+ return undefined;
69
+ return {
70
+ ...(input !== undefined && input >= 0 ? { input_per_million: input } : {}),
71
+ ...(output !== undefined && output >= 0 ? { output_per_million: output } : {}),
72
+ };
73
+ }
74
+ /** One row, checked field by field: it is a network response. Null without an id. */
75
+ function modelOf(v) {
76
+ if (!v || typeof v !== 'object')
77
+ return null;
78
+ const r = v;
79
+ const id = str(r.id);
80
+ if (!id)
81
+ return null;
82
+ const m = { id };
83
+ const set = (k, val) => {
84
+ if (val !== undefined)
85
+ m[k] = val;
86
+ };
87
+ set('owned_by', str(r.owned_by));
88
+ set('name', str(r.name));
89
+ set('description', str(r.description));
90
+ set('class', oneOf(CLASSES, r.class));
91
+ set('family', oneOf(FAMILY_IDS, r.family));
92
+ set('premium', typeof r.premium === 'boolean' ? r.premium : undefined);
93
+ set('created', pos(r.created));
94
+ set('context_window', pos(r.context_window));
95
+ set('max_output_tokens', pos(r.max_output_tokens));
96
+ set('inputs', strs(r.inputs));
97
+ set('outputs', strs(r.outputs));
98
+ set('supports_vision', yes(r.supports_vision));
99
+ set('supports_tools', yes(r.supports_tools));
100
+ set('supports_reasoning', yes(r.supports_reasoning));
101
+ set('pricing', pricingOf(r.pricing));
102
+ return m;
103
+ }
37
104
  /**
38
- * owned_by → display family.
39
- *
40
- * There is no `hanzo` entry, and its absence is the point. It used to say
41
- * `hanzo: 'Zen'`, which was wrong twice: Zoo Labs Foundation makes Zen, and the
42
- * gateway does not report Zen that way — it answers `owned_by: 'zenlm'`. What
43
- * `owned_by: 'hanzo'` actually names is the dozen models Hanzo serves under its
44
- * own namespace, so that entry filed Whisper, Kokoro, GLM, Kimi, Qwen3.5 and
45
- * MiniMax under "Zen" as well. Without it each falls to its id prefix and lands
46
- * where it belongs, and what is genuinely Hanzo's capitalizes to "Hanzo".
105
+ * The catalog from a `/v1/models` body (`{data: [...]}`) or its `data` array.
106
+ * Rows without an id are dropped; a repeated id keeps its first row.
47
107
  */
48
- const OWNER_FAMILY = {
49
- zenlm: 'Zen',
108
+ function parseModels(body) {
109
+ const rows = Array.isArray(body)
110
+ ? body
111
+ : body && typeof body === 'object' && Array.isArray(body.data)
112
+ ? (body.data)
113
+ : [];
114
+ const seen = new Set();
115
+ const out = [];
116
+ for (const row of rows) {
117
+ const m = modelOf(row);
118
+ if (!m || seen.has(m.id))
119
+ continue;
120
+ seen.add(m.id);
121
+ out.push(m);
122
+ }
123
+ return out;
124
+ }
125
+ /**
126
+ * Fetch the catalog from an OpenAI-shaped `/models` endpoint. No caching, no
127
+ * state: the host owns both. Throws on a non-2xx answer.
128
+ */
129
+ async function fetchModelCatalog(baseUrl, token) {
130
+ const base = (baseUrl ?? 'https://api.hanzo.ai/v1').replace(/\/+$/, '');
131
+ const headers = { Accept: 'application/json' };
132
+ if (token)
133
+ headers.Authorization = `Bearer ${token}`;
134
+ const res = await fetch(`${base}/models`, { headers });
135
+ if (!res.ok)
136
+ throw new Error(`fetchModelCatalog: ${res.status} ${res.statusText}`);
137
+ return parseModels(await res.json());
138
+ }
139
+ // ── what a model is ──────────────────────────────────────────────────────────
140
+ /** The families, in the order every surface leads with them. */
141
+ exports.FAMILIES = [
142
+ { id: 'enso', label: 'Enso' },
143
+ { id: 'zen', label: 'Zen' },
144
+ { id: 'kai', label: 'Kai' },
145
+ { id: 'jev', label: 'Jev' },
146
+ { id: 'zoo', label: 'Zoo' },
147
+ ];
148
+ exports.CLASS_NAMES = {
149
+ premium: 'Premium',
150
+ ours: 'Hanzo',
151
+ free: 'Free',
152
+ };
153
+ /** The makers whose name is not their id capitalized. */
154
+ const MAKERS = {
155
+ 'aion-labs': 'Aion Labs',
156
+ 'arcee-ai': 'Arcee AI',
157
+ 'bytedance-seed': 'ByteDance Seed',
158
+ 'dots-studio': 'dots.studio',
159
+ 'ibm-granite': 'IBM Granite',
160
+ 'inference-net': 'Inference.net',
161
+ 'meta-llama': 'Meta Llama',
162
+ 'nex-agi': 'Nex AGI',
163
+ 'prism-ml': 'Prism ML',
164
+ 'x-ai': 'xAI',
165
+ 'z-ai': 'Z.ai',
166
+ ai21: 'AI21',
50
167
  anthropic: 'Anthropic',
51
- openai: 'OpenAI',
168
+ bytedance: 'ByteDance',
169
+ cognitivecomputations: 'Cognitive Computations',
52
170
  deepseek: 'DeepSeek',
53
- alibaba: 'Qwen',
54
- qwen: 'Qwen',
55
- meta: 'Meta Llama',
56
- mistralai: 'Mistral',
57
- mistral: 'Mistral',
58
- google: 'Google Gemma',
171
+ inclusionai: 'inclusionAI',
172
+ minimax: 'MiniMax',
173
+ mistralai: 'Mistral AI',
174
+ moonshotai: 'Moonshot AI',
175
+ nousresearch: 'Nous Research',
59
176
  nvidia: 'NVIDIA',
60
- moonshot: 'Kimi',
177
+ openai: 'OpenAI',
178
+ openrouter: 'OpenRouter',
179
+ rekaai: 'Reka AI',
180
+ thedrummer: 'TheDrummer',
181
+ thinkingmachines: 'Thinking Machines',
182
+ typesafe: 'TypeSafe',
183
+ zenlm: 'Zen LM',
61
184
  };
62
- /** What the picker leads with; everything else sorts alphabetically after these. */
63
- const FAMILY_ORDER = ['Enso', 'Zen', 'Anthropic', 'OpenAI'];
64
- function capitalize(s) {
65
- return s ? s.charAt(0).toUpperCase() + s.slice(1) : s;
66
- }
67
- /** Infer a family from an id prefix. Returns undefined when nothing matches. */
68
- function familyFromId(id) {
69
- if (id.startsWith('enso'))
70
- return 'Enso';
71
- if (id.startsWith('zen'))
72
- return 'Zen';
73
- if (id.startsWith('claude'))
74
- return 'Anthropic';
75
- if (id.startsWith('gpt') || id === 'o1' || id.startsWith('o1-') || id.startsWith('o3'))
76
- return 'OpenAI';
77
- if (id.startsWith('qwen'))
78
- return 'Qwen';
79
- if (id.startsWith('llama'))
80
- return 'Meta Llama';
81
- if (id.startsWith('glm'))
82
- return 'GLM';
83
- if (id.startsWith('kimi'))
84
- return 'Kimi';
85
- if (id.startsWith('deepseek'))
86
- return 'DeepSeek';
87
- if (id.startsWith('mistral'))
88
- return 'Mistral';
89
- if (id.startsWith('gemma'))
90
- return 'Google Gemma';
91
- if (id.startsWith('nemotron'))
92
- return 'NVIDIA';
93
- if (id.startsWith('minimax'))
94
- return 'MiniMax';
95
- if (id.startsWith('mimo'))
96
- return 'MiMo';
97
- return undefined;
185
+ /** Who made a model, as the catalog keys it: `owned_by`, lowercased, without OpenRouter's `~` alias mark. */
186
+ function makerOf(m) {
187
+ return (m.owned_by ?? '').trim().replace(/^~/, '').toLowerCase() || 'other';
188
+ }
189
+ /** A maker's display name. */
190
+ function makerName(maker) {
191
+ return (MAKERS[maker] ??
192
+ maker
193
+ .split(/[-_]/)
194
+ .filter(Boolean)
195
+ .map((w) => w[0].toUpperCase() + w.slice(1))
196
+ .join(' '));
197
+ }
198
+ /** The name a model is shown by: the catalog's name, else its id. */
199
+ function modelName(m) {
200
+ return m.name ?? m.id;
201
+ }
202
+ function groupKey(m) {
203
+ return m.family ? `family:${m.family}` : `maker:${makerOf(m)}`;
204
+ }
205
+ function groupLabel(m) {
206
+ const f = m.family && exports.FAMILIES.find((x) => x.id === m.family);
207
+ return f ? f.label : makerName(makerOf(m));
98
208
  }
99
209
  /**
100
- * Resolve the display family of a model.
101
- * Precedence: explicit `family` → enso id (house split, beats owned_by) →
102
- * owned_by map → llama id under any owner → capitalized owned_by → id prefix → "Other".
210
+ * Models in groups: Hanzo's families first, in FAMILIES order, then every maker
211
+ * by name. Models keep the order they came in, so the caller sorts first.
103
212
  */
104
- function familyOf(m) {
105
- const explicit = m.family?.trim();
106
- if (explicit)
107
- return explicit;
108
- const id = (m.id ?? '').toLowerCase();
109
- // Enso ids form their own house family regardless of owned_by.
110
- if (id.startsWith('enso'))
111
- return 'Enso';
112
- const owned = m.owned_by?.toLowerCase().trim();
113
- if (owned) {
114
- const byOwner = OWNER_FAMILY[owned];
115
- if (byOwner)
116
- return byOwner;
117
- // llama* ids resolve to Meta Llama even under a different owner.
118
- const byId = familyFromId(id);
119
- if (byId)
120
- return byId;
121
- return capitalize(owned);
122
- }
123
- return familyFromId(id) ?? 'Other';
124
- }
125
- function orderFamilies(a, b) {
126
- const ia = FAMILY_ORDER.indexOf(a);
127
- const ib = FAMILY_ORDER.indexOf(b);
128
- if (ia !== -1 || ib !== -1) {
129
- if (ia === -1)
130
- return 1;
131
- if (ib === -1)
132
- return -1;
133
- return ia - ib;
213
+ function groupModels(models) {
214
+ const groups = new Map();
215
+ for (const m of models) {
216
+ const key = groupKey(m);
217
+ let g = groups.get(key);
218
+ if (!g) {
219
+ g = { key, label: groupLabel(m), ...(m.family ? { family: m.family } : {}), models: [] };
220
+ groups.set(key, g);
221
+ }
222
+ g.models.push(m);
134
223
  }
135
- return a.localeCompare(b);
224
+ const rank = (g) => (g.family ? exports.FAMILIES.findIndex((f) => f.id === g.family) : exports.FAMILIES.length);
225
+ return [...groups.values()].sort((a, b) => rank(a) - rank(b) || a.label.localeCompare(b.label));
136
226
  }
227
+ const has = (list, ...want) => !!list && want.some((w) => list.includes(w));
137
228
  /**
138
- * Group models by family, ordered house-then-marquee-then-alphabetical.
139
- * Model order within each family is the input order (stable).
229
+ * What a model can do, read from what it publishes. A model that names no
230
+ * outputs is read as a text model, the way the gateway serves it.
140
231
  */
141
- function groupModelsByFamily(models) {
142
- const byFamily = new Map();
143
- for (const m of models) {
144
- const fam = familyOf(m);
145
- const arr = byFamily.get(fam);
146
- if (arr)
147
- arr.push(m);
148
- else
149
- byFamily.set(fam, [m]);
232
+ function capabilitiesOf(m) {
233
+ const out = [];
234
+ const embeddings = has(m.outputs, 'embeddings', 'embedding');
235
+ const rerank = has(m.outputs, 'rerank');
236
+ const decision = has(m.outputs, 'decision');
237
+ const text = !m.outputs || has(m.outputs, 'text');
238
+ if (text && !embeddings && !rerank && !decision)
239
+ out.push('chat');
240
+ if (m.supports_vision || has(m.inputs, 'image'))
241
+ out.push('vision');
242
+ if (embeddings)
243
+ out.push('embeddings');
244
+ if (rerank)
245
+ out.push('rerank');
246
+ if (has(m.outputs, 'image'))
247
+ out.push('image');
248
+ if (has(m.outputs, 'video'))
249
+ out.push('video');
250
+ if (has(m.outputs, 'audio', 'transcript') || has(m.inputs, 'audio'))
251
+ out.push('audio');
252
+ if (decision)
253
+ out.push('decision');
254
+ if (m.supports_tools)
255
+ out.push('tools');
256
+ if (m.supports_reasoning)
257
+ out.push('reasoning');
258
+ return out;
259
+ }
260
+ function can(m, c) {
261
+ return capabilitiesOf(m).includes(c);
262
+ }
263
+ exports.CAPABILITY_NAMES = {
264
+ chat: 'Chat',
265
+ vision: 'Vision',
266
+ embeddings: 'Embeddings',
267
+ rerank: 'Rerank',
268
+ image: 'Image',
269
+ video: 'Video',
270
+ audio: 'Audio',
271
+ decision: 'Decision',
272
+ tools: 'Tools',
273
+ reasoning: 'Reasoning',
274
+ };
275
+ /** Whether `id` matches a paused entry: the same id, or a glob whose `*` stands for any run of characters. */
276
+ function matchesPaused(id, pattern) {
277
+ if (!pattern.includes('*'))
278
+ return id === pattern;
279
+ const parts = pattern.split('*');
280
+ if (!id.startsWith(parts[0]) || !id.endsWith(parts.at(-1)))
281
+ return false;
282
+ let at = parts[0].length;
283
+ const end = id.length - parts.at(-1).length;
284
+ for (const part of parts.slice(1, -1)) {
285
+ const i = id.indexOf(part, at);
286
+ if (i < 0 || i + part.length > end)
287
+ return false;
288
+ at = i + part.length;
150
289
  }
151
- return Array.from(byFamily.keys())
152
- .sort(orderFamilies)
153
- .map((family) => ({ family, models: byFamily.get(family) }));
154
- }
155
- /** Modality/id keywords that mark a model as NOT chat-capable. */
156
- const NON_CHAT = [
157
- 'embedding',
158
- 'embed',
159
- 'image',
160
- 'video',
161
- 'music',
162
- 'voice',
163
- 'tts',
164
- 'rerank',
165
- 'guard',
166
- 'foley',
167
- 'moderation',
168
- 'router',
169
- ];
290
+ return at <= end;
291
+ }
170
292
  /**
171
- * True when a model can be used for chat. Excludes embedding/image/video/
172
- * music/voice/tts/rerank/guard/foley/moderation/router by explicit modality
173
- * or by an id-segment heuristic.
293
+ * Whether the payer's plan has paused this model: its whole class is limited,
294
+ * or an entry in `paused` names it. It is still listed and still picked; the
295
+ * gateway refuses it or answers from Enso.
174
296
  */
175
- function isChatModel(m) {
176
- const modality = (m.modality ?? '').toLowerCase().trim();
177
- if (modality && NON_CHAT.some((k) => modality.includes(k)))
297
+ function isPaused(m, limits) {
298
+ if (!limits)
178
299
  return false;
179
- const id = (m.id ?? '').toLowerCase();
180
- if (NON_CHAT.some((k) => id.includes(k)))
181
- return false;
182
- return true;
300
+ if (m.class && limits.classes[m.class]?.state === 'limited')
301
+ return true;
302
+ return !!limits.paused?.some((p) => matchesPaused(m.id, p.model));
303
+ }
304
+ /** Case-insensitive search: every word of the query appears in the model's id, name, maker, group or description. */
305
+ function matchesModel(m, query) {
306
+ const words = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
307
+ if (!words.length)
308
+ return true;
309
+ const hay = [m.id, m.name, makerOf(m), makerName(makerOf(m)), groupLabel(m), m.description]
310
+ .filter(Boolean)
311
+ .join(' ')
312
+ .toLowerCase();
313
+ return words.every((w) => hay.includes(w));
183
314
  }
184
- /** Keep only chat-capable models. */
185
- function filterChatModels(models) {
186
- return models.filter(isChatModel);
315
+ /** A sorted copy. Context and price put models without the figure last. */
316
+ function sortModels(models, by = 'name') {
317
+ const name = (a, b) => modelName(a).localeCompare(modelName(b));
318
+ const last = (v, dir) => (v === undefined ? Infinity : dir * v);
319
+ return [...models].sort((a, b) => {
320
+ switch (by) {
321
+ case 'newest':
322
+ return (b.created ?? 0) - (a.created ?? 0) || name(a, b);
323
+ case 'context':
324
+ return last(a.context_window, -1) - last(b.context_window, -1) || name(a, b);
325
+ case 'price':
326
+ return last(a.pricing?.input_per_million, 1) - last(b.pricing?.input_per_million, 1) || name(a, b);
327
+ default:
328
+ return name(a, b);
329
+ }
330
+ });
187
331
  }
332
+ // ── where a conversation starts ──────────────────────────────────────────────
333
+ /** The house router: what a conversation is answered by until a model is picked. */
334
+ exports.ENSO = 'enso-auto';
188
335
  /**
189
- * Fetch a model catalog from an OpenAI-shaped `/models` endpoint.
190
- * SSR-safe: uses global fetch, no browser globals, no caching/state —
191
- * consumers own caching. Throws on a non-2xx response.
336
+ * The model a new conversation, run or playground tab starts on: Enso for a
337
+ * conversation, else the first of Hanzo's own models that does what the scope
338
+ * asks, else the first free or Hanzo model that does. Never a premium model,
339
+ * and never a research preview: a person picks those. '' when nothing qualifies.
192
340
  */
193
- async function fetchModelCatalog(baseUrl, token) {
194
- const base = (baseUrl ?? 'https://api.hanzo.ai/v1').replace(/\/+$/, '');
195
- const headers = { Accept: 'application/json' };
196
- if (token)
197
- headers.Authorization = `Bearer ${token}`;
198
- const res = await fetch(`${base}/models`, { headers });
199
- if (!res.ok) {
200
- throw new Error(`fetchModelCatalog: ${res.status} ${res.statusText}`);
341
+ function defaultModel(models, scope = 'chat') {
342
+ const offered = models.filter((m) => m.access !== 'research' && m.class !== 'premium' && can(m, scope));
343
+ if (scope === 'chat' && offered.some((m) => m.id === exports.ENSO))
344
+ return exports.ENSO;
345
+ for (const f of exports.FAMILIES) {
346
+ const ours = sortModels(offered.filter((m) => m.family === f.id));
347
+ if (ours.length)
348
+ return ours[0].id;
201
349
  }
202
- const json = await res.json();
203
- const rows = Array.isArray(json)
204
- ? json
205
- : Array.isArray(json?.data)
206
- ? json.data
207
- : [];
208
- return rows
209
- .map((d) => ({
210
- id: String(d.id),
211
- owned_by: d.owned_by ?? d.ownedBy ?? undefined,
212
- family: d.family ?? undefined,
213
- label: d.label ?? d.name ?? undefined,
214
- premium: d.premium ?? undefined,
215
- modality: d.modality ?? undefined,
216
- context_window: d.context_window ?? d.contextWindow ?? d.context ?? undefined,
217
- description: d.description ?? undefined,
218
- }))
219
- .filter((m) => m.id && m.id !== 'undefined');
350
+ return sortModels(offered)[0]?.id ?? '';
351
+ }
352
+ // ── how a figure reads ───────────────────────────────────────────────────────
353
+ /** "200K", "1M", "1.5M"; "" when the catalog states no window. */
354
+ function formatContext(n) {
355
+ if (!n)
356
+ return '';
357
+ if (n >= 1_000_000)
358
+ return `${+(n / 1_000_000).toFixed(1)}M`;
359
+ if (n >= 1000)
360
+ return `${Math.round(n / 1000)}K`;
361
+ return String(n);
362
+ }
363
+ /** "$3.00" per 1M tokens, "Free" at zero, "" when the catalog lists no price. */
364
+ function formatPrice(n) {
365
+ if (n === undefined)
366
+ return '';
367
+ if (n === 0)
368
+ return 'Free';
369
+ return n < 0.01 ? `$${+n.toPrecision(2)}` : `$${n.toFixed(2)}`;
220
370
  }