diff --git a/README.md b/README.md index b9d6713..33c9cb7 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,29 @@ pnpm build # production frontend build pnpm tauri build # packaged app (bundles the engine sidecar) ``` +## Provider catalog + +Settings uses the vendored snapshot at `src/lib/providers/catalog.json`. It is a +small, agent-capable subset of [models.dev](https://models.dev), with OpenRouter +featured for users who want one key across several model vendors. Catalog +providers use the same local BYOK path as manually configured providers; no +proxy service is bundled. + +Refresh the snapshot when provider endpoints or model lists change: + +```sh +pnpm catalog:refresh +``` + +The refresh command reads `https://models.dev/api.json` and therefore needs +network access. It can also read an already downloaded API response: +`node scripts/refresh-provider-catalog.mjs ./api.json`. Tests parse only the +vendored file and never call models.dev. + +models.dev is MIT licensed. The generated JSON starts with source and copyright +attribution; the upstream license is kept in +`src/lib/providers/models.dev.LICENSE`. + ## Configuration (env vars) - `JUCODE_BIN` — path to the `jucode` binary (overrides auto-resolution). diff --git a/package.json b/package.json index 08a3b54..e650b10 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "preview": "vite preview", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "catalog:refresh": "node scripts/refresh-provider-catalog.mjs", "tauri": "tauri", "test": "vitest run", "test:watch": "vitest" diff --git a/scripts/refresh-provider-catalog.mjs b/scripts/refresh-provider-catalog.mjs new file mode 100644 index 0000000..9e72450 --- /dev/null +++ b/scripts/refresh-provider-catalog.mjs @@ -0,0 +1,155 @@ +#!/usr/bin/env node + +import { readFile, writeFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; + +const SOURCE_URL = 'https://models.dev/api.json'; +const OUTPUT = fileURLToPath(new URL('../src/lib/providers/catalog.json', import.meta.url)); + +const selections = [ + { + id: 'openrouter', + name: 'OpenRouter', + description: 'Use one key for models from OpenAI, Anthropic, Google, DeepSeek, Qwen, xAI and more.', + base_url: 'https://openrouter.ai/api/v1', + protocol: 'chat', + featured: true, + models: [ + 'openai/gpt-5.6-sol', + 'anthropic/claude-opus-5', + 'google/gemini-3.7-flash', + 'deepseek/deepseek-v4-pro', + 'qwen/qwen3.8-max', + 'z-ai/glm-5.3', + 'x-ai/grok-4.6' + ] + }, + { + id: 'openai', + name: 'OpenAI', + description: 'Connect directly to OpenAI with the Responses API.', + base_url: 'https://api.openai.com/v1', + protocol: 'responses', + models: ['gpt-5.6-sol', 'gpt-5.6', 'gpt-5.5', 'gpt-5.4-mini', 'gpt-5.4'] + }, + { + id: 'anthropic', + name: 'Anthropic', + description: 'Connect directly to the Anthropic Messages API.', + base_url: 'https://api.anthropic.com/v1', + protocol: 'anthropic', + models: [ + 'claude-opus-5', + 'claude-sonnet-5', + 'claude-fable-5', + 'claude-opus-4-8', + 'claude-sonnet-4-6' + ] + }, + { + id: 'deepseek', + name: 'DeepSeek', + description: 'Use DeepSeek models through its Anthropic-compatible endpoint.', + base_url: 'https://api.deepseek.com/anthropic', + protocol: 'anthropic', + models: ['deepseek-v4-pro', 'deepseek-v4-flash', 'deepseek-v4-flash-vision-exp'] + }, + { + id: 'groq', + name: 'Groq', + description: 'Run supported open-weight models on Groq infrastructure.', + base_url: 'https://api.groq.com/openai/v1', + protocol: 'chat', + models: [ + 'qwen/qwen3.6-27b', + 'openai/gpt-oss-120b', + 'openai/gpt-oss-20b', + 'llama-3.3-70b-versatile' + ] + }, + { + id: 'xai', + name: 'xAI', + description: 'Connect directly to the xAI API for Grok models.', + base_url: 'https://api.x.ai/v1', + protocol: 'chat', + models: ['grok-4.6', 'grok-4.5', 'grok-4.3', 'grok-4.20-0309-reasoning'] + }, + { + id: 'mistral', + name: 'Mistral', + description: 'Connect directly to Mistral for its general and coding models.', + base_url: 'https://api.mistral.ai/v1', + protocol: 'chat', + models: [ + 'zai-glm-5-2', + 'mistral-medium-latest', + 'mistral-small-latest', + 'magistral-medium-latest', + 'mistral-large-2411' + ] + } +]; + +async function loadSource(source) { + if (/^https?:\/\//.test(source)) { + const response = await fetch(source); + if (!response.ok) throw new Error(`models.dev returned HTTP ${response.status}`); + return response.json(); + } + return JSON.parse(await readFile(source, 'utf8')); +} + +function reasoningEfforts(model) { + const option = model.reasoning_options?.find( + (item) => item?.type === 'effort' && Array.isArray(item.values) + ); + return option?.values?.length ? option.values : undefined; +} + +function mapModel(providerId, modelId, model) { + if (!model) throw new Error(`models.dev no longer contains ${providerId}/${modelId}`); + if (!model.modalities?.output?.includes('text')) { + throw new Error(`${providerId}/${modelId} does not produce text`); + } + if (model.status === 'deprecated') throw new Error(`${providerId}/${modelId} is deprecated`); + return { + name: model.id ?? modelId, + context_window: model.limit?.context || undefined, + max_output_tokens: model.limit?.output || undefined, + reasoning_efforts: reasoningEfforts(model) + }; +} + +const source = process.argv[2] ?? process.env.MODELS_DEV_SOURCE ?? SOURCE_URL; +const data = await loadSource(source); +const providers = selections.map((selection) => { + const upstream = data[selection.id]; + if (!upstream?.models) throw new Error(`models.dev no longer contains provider ${selection.id}`); + return { + id: selection.id, + name: upstream.name ?? selection.name, + description: selection.description, + base_url: selection.base_url, + protocol: selection.protocol, + docs_url: upstream.doc, + featured: selection.featured === true, + models: selection.models.map((id) => mapModel(selection.id, id, upstream.models[id])) + }; +}); + +const snapshot = { + _attribution: { + notice: 'Curated and transformed from models.dev. See models.dev.LICENSE.', + source: SOURCE_URL, + repository: 'https://github.com/anomalyco/models.dev', + license: 'MIT', + copyright: 'Copyright (c) 2025 models.dev', + license_file: 'models.dev.LICENSE', + snapshot_date: process.env.CATALOG_SNAPSHOT_DATE ?? new Date().toISOString().slice(0, 10) + }, + providers +}; + +await writeFile(OUTPUT, `${JSON.stringify(snapshot, null, 2)}\n`); +console.log(`Wrote ${providers.length} providers to ${OUTPUT}`); diff --git a/src/lib/Composer.svelte b/src/lib/Composer.svelte index 969ba70..2d9434f 100644 --- a/src/lib/Composer.svelte +++ b/src/lib/Composer.svelte @@ -28,6 +28,7 @@ active: boolean; command: string; depth: number | undefined; + group?: string; }; let { diff --git a/src/lib/Settings.svelte b/src/lib/Settings.svelte index fd63084..3dd440e 100644 --- a/src/lib/Settings.svelte +++ b/src/lib/Settings.svelte @@ -12,6 +12,7 @@ import Vendor from '$lib/Vendor.svelte'; import OverviewPanel from '$lib/OverviewPanel.svelte'; import ProviderAccountCard from '$lib/settings/ProviderAccountCard.svelte'; + import ProviderCatalogPicker from '$lib/settings/ProviderCatalogPicker.svelte'; import CustomProviderForm from '$lib/settings/CustomProviderForm.svelte'; import McpSection from '$lib/settings/McpSection.svelte'; import UpdateCard from '$lib/settings/UpdateCard.svelte'; @@ -24,6 +25,11 @@ import { t, setLocale, getLocale, LOCALES, LOCALE_LABELS } from '$lib/i18n'; import { ASR_PROVIDERS, asrProvider, resolveAsrSettings, type AsrSettings } from '$lib/audio'; import { PLUGINS, loadPluginSettings, setPluginEnabled } from '$lib/plugins/registry'; + import { + PROVIDER_CATALOG, + providerFormPrefill, + type CatalogProvider + } from '$lib/providers/catalog'; let { sessionId, @@ -52,15 +58,18 @@ } interface Provider { id: string; + name?: string; base_url: string; format: string; models: ModelCfg[]; builtin: boolean; + source?: 'catalog' | 'custom'; } const CUSTOM_KEY = 'jucode-custom-providers'; const FORMATS = [ { value: 'responses', label: 'Responses' }, - { value: 'anthropic', label: 'Anthropic' } + { value: 'anthropic', label: 'Anthropic' }, + { value: 'chat', label: 'Chat Completions' } ]; let cfg = $state>({}); @@ -75,9 +84,10 @@ let section = $state<'overview' | 'account' | 'behavior' | 'extensions'>(untrack(() => initialSection)); // inline editor state - let editing = $state(null); // provider id, or '__new__' + let editing = $state(null); // provider id, '__catalog__', or '__new__' let keyInput = $state(''); let form = $state<{ id: string; base_url: string; format: string; key: string; models: ModelCfg[] }>({ id: '', base_url: '', format: 'responses', key: '', models: [] }); + let selectedCatalog = $state(null); let mName = $state(''); let mCtx = $state(); @@ -98,6 +108,9 @@ ...builtin.map((b) => ({ id: b.id, base_url: b.base_url, models: b.models, format: b.protocol, builtin: true })), ...custom ]); + const catalogProviders = $derived( + PROVIDER_CATALOG.providers.filter((entry) => !allProviders.some((provider) => provider.id === entry.id)) + ); const modelOpts = $derived(models.map((m) => ({ value: m.name, label: m.name, ...m }))); const effortOpts = $derived(efforts.map((e) => ({ value: e, label: cap(e) }))); // All providers' models in one list (provider-qualified), so the default-model @@ -108,6 +121,7 @@ value: `${p.id}::${m.name}`, label: m.name, provider: p.id, + group: p.id === 'jucode' ? t('settings.behavior.groupJucode') : t('settings.behavior.groupByok'), context_window: m.context_window, authed: keyed.includes(p.id) })) @@ -161,11 +175,23 @@ keyInput = ''; } function openCreate() { + editing = '__catalog__'; + selectedCatalog = null; + } + function openCustom() { editing = '__new__'; + selectedCatalog = null; form = { id: '', base_url: '', format: 'responses', key: '', models: [] }; mName = ''; mCtx = undefined; } + function selectCatalogProvider(provider: CatalogProvider) { + selectedCatalog = provider; + form = providerFormPrefill(provider); + editing = '__new__'; + mName = ''; + mCtx = undefined; + } function persistCustom() { localStorage.setItem(CUSTOM_KEY, JSON.stringify(custom)); } @@ -193,8 +219,19 @@ } async function createProvider() { const id = form.id.trim(); - if (!id || !form.base_url.trim() || form.models.length === 0) return; - custom = [...custom.filter((c) => c.id !== id), { id, base_url: form.base_url.trim(), format: form.format, models: form.models, builtin: false }]; + if (!id || !form.base_url.trim() || form.models.length === 0 || (selectedCatalog && !form.key.trim())) return; + custom = [ + ...custom.filter((c) => c.id !== id), + { + id, + name: selectedCatalog?.name, + base_url: form.base_url.trim(), + format: form.format, + models: form.models, + builtin: false, + source: selectedCatalog ? 'catalog' : 'custom' + } + ]; persistCustom(); if (form.key.trim()) { await setAuthKey(id, form.key.trim()); @@ -202,6 +239,7 @@ onAuthChange?.(); } editing = null; + selectedCatalog = null; } function deleteProvider(id: string) { custom = custom.filter((c) => c.id !== id); @@ -358,16 +396,26 @@ {/each} - {#if editing === '__new__'} + {#if editing === '__catalog__'} + (editing = null)} + /> + {:else if editing === '__new__'} (editing = null)} + onCancel={() => (editing = selectedCatalog ? '__catalog__' : null)} /> {:else} diff --git a/src/lib/i18n/messages/settings.ts b/src/lib/i18n/messages/settings.ts index 3d41050..b9ba026 100644 --- a/src/lib/i18n/messages/settings.ts +++ b/src/lib/i18n/messages/settings.ts @@ -28,6 +28,7 @@ const settings = { hint: '各 Provider 独立登录、可同时使用。点卡片登录或查看详情;展开后可设为新会话默认。', default: '默认', custom: '自定义', + byok: 'BYOK', authorizing: '授权中…', loggedIn: '已登录', keyed: '已配密钥', @@ -70,7 +71,23 @@ const settings = { modelName: '模型名', contextWindow: '窗口', create: '创建', - add: '添加自定义 Provider' + add: '添加 Provider' + }, + catalog: { + title: '选择 Provider', + hint: '选择后会填好端点、协议和模型列表。你只需粘贴 API key。', + search: '搜索 Provider 或模型…', + featured: '推荐', + modelCount: '{count} 个模型', + noMatch: '没有匹配的 Provider', + custom: '手动配置', + connect: '连接 {provider}', + addProvider: '添加 Provider', + protocol: { + responses: 'Responses', + anthropic: 'Anthropic Messages', + chat: 'Chat Completions' + } }, backend: { groupLabel: '引擎后端', @@ -99,6 +116,8 @@ const settings = { selectModel: '选择模型', noModels: '暂无模型 · 先在「账户」登录或配置 Provider。', notConfigured: '未配置', + groupJucode: 'JuCode 内置', + groupByok: '自定义 / BYOK', htmlOpen: '对话中点击 HTML 链接时', htmlOpenBrowser: '内置浏览器', htmlOpenEditor: '编辑器', @@ -280,6 +299,7 @@ const settings = { hint: 'Each provider logs in independently and can be used at the same time. Click a card to log in or view details; expand it to set as the default for new sessions.', default: 'Default', custom: 'Custom', + byok: 'BYOK', authorizing: 'Authorizing…', loggedIn: 'Logged in', keyed: 'Key set', @@ -322,7 +342,23 @@ const settings = { modelName: 'Model name', contextWindow: 'Window', create: 'Create', - add: 'Add custom provider' + add: 'Add provider' + }, + catalog: { + title: 'Choose a provider', + hint: 'The endpoint, protocol and model list are filled in for you. Just paste your API key.', + search: 'Search providers or models…', + featured: 'Featured', + modelCount: '{count} models', + noMatch: 'No matching providers', + custom: 'Set up manually', + connect: 'Connect {provider}', + addProvider: 'Add provider', + protocol: { + responses: 'Responses', + anthropic: 'Anthropic Messages', + chat: 'Chat Completions' + } }, backend: { groupLabel: 'Engine backends', @@ -350,6 +386,8 @@ const settings = { defaultModel: 'Default model', selectModel: 'Select a model', noModels: 'No models yet · log in or configure a provider under Account first.', + groupJucode: 'JuCode built-in', + groupByok: 'Custom / BYOK', htmlOpen: 'Clicking an HTML link in chat', htmlOpenBrowser: 'Built-in browser', htmlOpenEditor: 'Editor', diff --git a/src/lib/i18n/messages/shell.ts b/src/lib/i18n/messages/shell.ts index a5b5718..fc68e18 100644 --- a/src/lib/i18n/messages/shell.ts +++ b/src/lib/i18n/messages/shell.ts @@ -22,6 +22,12 @@ const shell = { resume: '恢复历史会话', checkpoint: '回退到历史回合' }, + modelGroup: { + codex: 'Codex 官方', + claude: 'Claude 官方', + jucode: 'JuCode 内置', + byok: '自定义 / BYOK' + }, notConfigured: '未配置', pickerSearchPlaceholder: '筛选…', empty: '(empty)', @@ -238,6 +244,12 @@ const shell = { resume: 'Resume a session', checkpoint: 'Rewind to a turn' }, + modelGroup: { + codex: 'Codex official', + claude: 'Claude official', + jucode: 'JuCode built-in', + byok: 'Custom / BYOK' + }, notConfigured: 'not configured', pickerSearchPlaceholder: 'Filter…', empty: '(empty)', diff --git a/src/lib/providers/catalog.json b/src/lib/providers/catalog.json new file mode 100644 index 0000000..a2a9e71 --- /dev/null +++ b/src/lib/providers/catalog.json @@ -0,0 +1,425 @@ +{ + "_attribution": { + "notice": "Curated and transformed from models.dev. See models.dev.LICENSE.", + "source": "https://models.dev/api.json", + "repository": "https://github.com/anomalyco/models.dev", + "license": "MIT", + "copyright": "Copyright (c) 2025 models.dev", + "license_file": "models.dev.LICENSE", + "snapshot_date": "2026-08-29" + }, + "providers": [ + { + "id": "openrouter", + "name": "OpenRouter", + "description": "Use one key for models from OpenAI, Anthropic, Google, DeepSeek, Qwen, xAI and more.", + "base_url": "https://openrouter.ai/api/v1", + "protocol": "chat", + "docs_url": "https://openrouter.ai/models", + "featured": true, + "models": [ + { + "name": "openai/gpt-5.6-sol", + "context_window": 1050000, + "max_output_tokens": 128000, + "reasoning_efforts": [ + "none", + "low", + "medium", + "high", + "xhigh", + "max" + ] + }, + { + "name": "anthropic/claude-opus-5", + "context_window": 1000000, + "max_output_tokens": 128000, + "reasoning_efforts": [ + "low", + "medium", + "high", + "xhigh", + "max" + ] + }, + { + "name": "google/gemini-3.7-flash", + "context_window": 1048576, + "max_output_tokens": 65536, + "reasoning_efforts": [ + "low", + "medium", + "high" + ] + }, + { + "name": "deepseek/deepseek-v4-pro", + "context_window": 1048576, + "max_output_tokens": 384000, + "reasoning_efforts": [ + "high", + "xhigh" + ] + }, + { + "name": "qwen/qwen3.8-max", + "context_window": 1000000, + "max_output_tokens": 131072, + "reasoning_efforts": [ + "minimal", + "low", + "medium", + "high", + "xhigh" + ] + }, + { + "name": "z-ai/glm-5.3", + "context_window": 1310720, + "max_output_tokens": 131072, + "reasoning_efforts": [ + "low", + "high", + "max" + ] + }, + { + "name": "x-ai/grok-4.6", + "context_window": 500000, + "max_output_tokens": 450000, + "reasoning_efforts": [ + "low", + "medium", + "high", + "xhigh" + ] + } + ] + }, + { + "id": "openai", + "name": "OpenAI", + "description": "Connect directly to OpenAI with the Responses API.", + "base_url": "https://api.openai.com/v1", + "protocol": "responses", + "docs_url": "https://platform.openai.com/docs/models", + "featured": false, + "models": [ + { + "name": "gpt-5.6-sol", + "context_window": 1050000, + "max_output_tokens": 128000, + "reasoning_efforts": [ + "none", + "low", + "medium", + "high", + "xhigh", + "max" + ] + }, + { + "name": "gpt-5.6", + "context_window": 1050000, + "max_output_tokens": 128000, + "reasoning_efforts": [ + "none", + "low", + "medium", + "high", + "xhigh", + "max" + ] + }, + { + "name": "gpt-5.5", + "context_window": 1050000, + "max_output_tokens": 128000, + "reasoning_efforts": [ + "none", + "low", + "medium", + "high", + "xhigh" + ] + }, + { + "name": "gpt-5.4-mini", + "context_window": 400000, + "max_output_tokens": 128000, + "reasoning_efforts": [ + "none", + "low", + "medium", + "high", + "xhigh" + ] + }, + { + "name": "gpt-5.4", + "context_window": 1050000, + "max_output_tokens": 128000, + "reasoning_efforts": [ + "none", + "low", + "medium", + "high", + "xhigh" + ] + } + ] + }, + { + "id": "anthropic", + "name": "Anthropic", + "description": "Connect directly to the Anthropic Messages API.", + "base_url": "https://api.anthropic.com/v1", + "protocol": "anthropic", + "docs_url": "https://docs.anthropic.com/en/docs/about-claude/models", + "featured": false, + "models": [ + { + "name": "claude-opus-5", + "context_window": 1000000, + "max_output_tokens": 128000, + "reasoning_efforts": [ + "low", + "medium", + "high", + "xhigh", + "max" + ] + }, + { + "name": "claude-sonnet-5", + "context_window": 1000000, + "max_output_tokens": 128000, + "reasoning_efforts": [ + "low", + "medium", + "high", + "xhigh", + "max" + ] + }, + { + "name": "claude-fable-5", + "context_window": 1000000, + "max_output_tokens": 128000, + "reasoning_efforts": [ + "low", + "medium", + "high", + "xhigh", + "max" + ] + }, + { + "name": "claude-opus-4-8", + "context_window": 1000000, + "max_output_tokens": 128000, + "reasoning_efforts": [ + "low", + "medium", + "high", + "xhigh", + "max" + ] + }, + { + "name": "claude-sonnet-4-6", + "context_window": 1000000, + "max_output_tokens": 128000, + "reasoning_efforts": [ + "low", + "medium", + "high", + "max" + ] + } + ] + }, + { + "id": "deepseek", + "name": "DeepSeek", + "description": "Use DeepSeek models through its Anthropic-compatible endpoint.", + "base_url": "https://api.deepseek.com/anthropic", + "protocol": "anthropic", + "docs_url": "https://api-docs.deepseek.com/quick_start/pricing", + "featured": false, + "models": [ + { + "name": "deepseek-v4-pro", + "context_window": 1000000, + "max_output_tokens": 384000, + "reasoning_efforts": [ + "high", + "max" + ] + }, + { + "name": "deepseek-v4-flash", + "context_window": 1000000, + "max_output_tokens": 384000, + "reasoning_efforts": [ + "low", + "high", + "max" + ] + }, + { + "name": "deepseek-v4-flash-vision-exp", + "context_window": 1000000, + "max_output_tokens": 384000, + "reasoning_efforts": [ + "low", + "high", + "max" + ] + } + ] + }, + { + "id": "groq", + "name": "Groq", + "description": "Run supported open-weight models on Groq infrastructure.", + "base_url": "https://api.groq.com/openai/v1", + "protocol": "chat", + "docs_url": "https://console.groq.com/docs/models", + "featured": false, + "models": [ + { + "name": "qwen/qwen3.6-27b", + "context_window": 131072, + "max_output_tokens": 16384, + "reasoning_efforts": [ + "none", + "default" + ] + }, + { + "name": "openai/gpt-oss-120b", + "context_window": 131072, + "max_output_tokens": 65536, + "reasoning_efforts": [ + "low", + "medium", + "high" + ] + }, + { + "name": "openai/gpt-oss-20b", + "context_window": 131072, + "max_output_tokens": 65536, + "reasoning_efforts": [ + "low", + "medium", + "high" + ] + }, + { + "name": "llama-3.3-70b-versatile", + "context_window": 131072, + "max_output_tokens": 32768 + } + ] + }, + { + "id": "xai", + "name": "xAI", + "description": "Connect directly to the xAI API for Grok models.", + "base_url": "https://api.x.ai/v1", + "protocol": "chat", + "docs_url": "https://docs.x.ai/docs/models", + "featured": false, + "models": [ + { + "name": "grok-4.6", + "context_window": 500000, + "max_output_tokens": 500000, + "reasoning_efforts": [ + "low", + "medium", + "high", + "xhigh" + ] + }, + { + "name": "grok-4.5", + "context_window": 500000, + "max_output_tokens": 500000, + "reasoning_efforts": [ + "low", + "medium", + "high" + ] + }, + { + "name": "grok-4.3", + "context_window": 1000000, + "max_output_tokens": 30000, + "reasoning_efforts": [ + "none", + "low", + "medium", + "high" + ] + }, + { + "name": "grok-4.20-0309-reasoning", + "context_window": 1000000, + "max_output_tokens": 30000 + } + ] + }, + { + "id": "mistral", + "name": "Mistral", + "description": "Connect directly to Mistral for its general and coding models.", + "base_url": "https://api.mistral.ai/v1", + "protocol": "chat", + "docs_url": "https://docs.mistral.ai/getting-started/models/", + "featured": false, + "models": [ + { + "name": "zai-glm-5-2", + "context_window": 1000000, + "max_output_tokens": 131072, + "reasoning_efforts": [ + "high", + "max" + ] + }, + { + "name": "mistral-medium-latest", + "context_window": 262144, + "max_output_tokens": 262144, + "reasoning_efforts": [ + "none", + "high" + ] + }, + { + "name": "mistral-small-latest", + "context_window": 256000, + "max_output_tokens": 256000, + "reasoning_efforts": [ + "none", + "high" + ] + }, + { + "name": "magistral-medium-latest", + "context_window": 128000, + "max_output_tokens": 16384 + }, + { + "name": "mistral-large-2411", + "context_window": 131072, + "max_output_tokens": 16384 + } + ] + } + ] +} diff --git a/src/lib/providers/catalog.test.ts b/src/lib/providers/catalog.test.ts new file mode 100644 index 0000000..47b22ce --- /dev/null +++ b/src/lib/providers/catalog.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest'; +import snapshot from './catalog.json'; +import { + PROVIDER_CATALOG, + parseProviderCatalog, + providerFormPrefill +} from './catalog'; + +describe('provider catalog snapshot', () => { + it('parses the vendored snapshot with its license attribution', () => { + const catalog = parseProviderCatalog(snapshot); + expect(catalog.attribution).toMatchObject({ + source: 'https://models.dev/api.json', + license: 'MIT', + copyright: 'Copyright (c) 2025 models.dev', + license_file: 'models.dev.LICENSE' + }); + expect(catalog.providers.length).toBeGreaterThan(1); + expect(new Set(catalog.providers.map((provider) => provider.id)).size).toBe( + catalog.providers.length + ); + }); + + it('keeps OpenRouter first and featured with models from several vendors', () => { + const openRouter = PROVIDER_CATALOG.providers[0]; + expect(openRouter).toMatchObject({ + id: 'openrouter', + featured: true, + base_url: 'https://openrouter.ai/api/v1', + protocol: 'chat' + }); + expect(openRouter.models.map((model) => model.name)).toEqual( + expect.arrayContaining([ + expect.stringMatching(/^openai\//), + expect.stringMatching(/^anthropic\//), + expect.stringMatching(/^google\//) + ]) + ); + }); + + it('rejects protocols the desktop does not understand', () => { + const invalid = structuredClone(snapshot) as Record; + invalid.providers[0].protocol = 'litellm'; + expect(() => parseProviderCatalog(invalid)).toThrow('Invalid protocol'); + }); +}); + +describe('catalog provider form prefill', () => { + it('copies the endpoint, protocol and full model list while leaving the key blank', () => { + const anthropic = PROVIDER_CATALOG.providers.find((provider) => provider.id === 'anthropic')!; + const form = providerFormPrefill(anthropic); + expect(form).toMatchObject({ + id: 'anthropic', + base_url: 'https://api.anthropic.com/v1', + format: 'anthropic', + key: '' + }); + expect(form.models).toEqual(anthropic.models); + }); + + it('returns model copies that can be edited without changing the catalog', () => { + const provider = PROVIDER_CATALOG.providers[0]; + const form = providerFormPrefill(provider); + form.models[0].name = 'changed'; + form.models[0].reasoning_efforts?.push('custom'); + expect(provider.models[0].name).not.toBe('changed'); + expect(provider.models[0].reasoning_efforts).not.toContain('custom'); + }); +}); diff --git a/src/lib/providers/catalog.ts b/src/lib/providers/catalog.ts new file mode 100644 index 0000000..495b846 --- /dev/null +++ b/src/lib/providers/catalog.ts @@ -0,0 +1,149 @@ +import snapshot from './catalog.json'; + +export type ProviderProtocol = 'responses' | 'anthropic' | 'chat'; + +export interface ProviderModel { + name: string; + context_window?: number; + max_output_tokens?: number; + reasoning_efforts?: string[]; +} + +export interface CatalogProvider { + id: string; + name: string; + description: string; + base_url: string; + protocol: ProviderProtocol; + models: ProviderModel[]; + docs_url?: string; + featured: boolean; +} + +export interface ProviderCatalog { + attribution: { + source: string; + repository: string; + license: 'MIT'; + copyright: string; + license_file: string; + snapshot_date: string; + }; + providers: CatalogProvider[]; +} + +export interface ProviderFormPrefill { + id: string; + base_url: string; + format: ProviderProtocol; + key: string; + models: ProviderModel[]; +} + +const protocols = new Set(['responses', 'anthropic', 'chat']); +const text = (value: unknown, field: string): string => { + if (typeof value !== 'string' || !value.trim()) throw new Error(`Invalid provider catalog ${field}`); + return value.trim(); +}; +const positiveInt = (value: unknown, field: string): number | undefined => { + if (value == null) return undefined; + if (!Number.isInteger(value) || Number(value) <= 0) throw new Error(`Invalid provider catalog ${field}`); + return Number(value); +}; + +export function parseProviderCatalog(value: unknown): ProviderCatalog { + if (!value || typeof value !== 'object') throw new Error('Invalid provider catalog root'); + const root = value as Record; + const rawAttribution = root._attribution; + if (!rawAttribution || typeof rawAttribution !== 'object') { + throw new Error('Provider catalog attribution is missing'); + } + const attributionValue = rawAttribution as Record; + const license = text(attributionValue.license, 'license'); + if (license !== 'MIT') throw new Error(`Unsupported provider catalog license: ${license}`); + const attribution: ProviderCatalog['attribution'] = { + source: text(attributionValue.source, 'source'), + repository: text(attributionValue.repository, 'repository'), + license, + copyright: text(attributionValue.copyright, 'copyright'), + license_file: text(attributionValue.license_file, 'license_file'), + snapshot_date: text(attributionValue.snapshot_date, 'snapshot_date') + }; + if (!Array.isArray(root.providers) || root.providers.length === 0) { + throw new Error('Provider catalog has no providers'); + } + + const providerIds = new Set(); + const providers = root.providers.map((raw, providerIndex): CatalogProvider => { + if (!raw || typeof raw !== 'object') throw new Error(`Invalid provider at index ${providerIndex}`); + const item = raw as Record; + const id = text(item.id, 'provider id'); + if (!/^[a-z0-9][a-z0-9_-]*$/.test(id) || providerIds.has(id)) { + throw new Error(`Invalid or duplicate provider id: ${id}`); + } + providerIds.add(id); + const protocol = text(item.protocol, `${id} protocol`) as ProviderProtocol; + if (!protocols.has(protocol)) throw new Error(`Invalid protocol for ${id}: ${protocol}`); + const base_url = text(item.base_url, `${id} base_url`).replace(/\/+$/, ''); + try { + const url = new URL(base_url); + if (url.protocol !== 'https:' && url.protocol !== 'http:') throw new Error(); + } catch { + throw new Error(`Invalid base URL for ${id}`); + } + if (!Array.isArray(item.models) || item.models.length === 0) { + throw new Error(`Provider ${id} has no models`); + } + const modelNames = new Set(); + const models = item.models.map((rawModel, modelIndex): ProviderModel => { + if (!rawModel || typeof rawModel !== 'object') { + throw new Error(`Invalid model at ${id}[${modelIndex}]`); + } + const model = rawModel as Record; + const name = text(model.name, `${id} model name`); + if (modelNames.has(name)) throw new Error(`Duplicate model ${id}/${name}`); + modelNames.add(name); + const efforts = + model.reasoning_efforts == null + ? undefined + : Array.isArray(model.reasoning_efforts) + ? model.reasoning_efforts.map((effort) => text(effort, `${id}/${name} effort`)) + : (() => { + throw new Error(`Invalid reasoning efforts for ${id}/${name}`); + })(); + return { + name, + context_window: positiveInt(model.context_window, `${id}/${name} context_window`), + max_output_tokens: positiveInt(model.max_output_tokens, `${id}/${name} max_output_tokens`), + reasoning_efforts: efforts + }; + }); + return { + id, + name: text(item.name, `${id} name`), + description: text(item.description, `${id} description`), + base_url, + protocol, + models, + docs_url: item.docs_url == null ? undefined : text(item.docs_url, `${id} docs_url`), + featured: item.featured === true + }; + }); + + return { attribution, providers }; +} + +export function providerFormPrefill(provider: CatalogProvider): ProviderFormPrefill { + return { + id: provider.id, + base_url: provider.base_url, + format: provider.protocol, + key: '', + models: provider.models.map((model) => ({ + ...model, + reasoning_efforts: model.reasoning_efforts ? [...model.reasoning_efforts] : undefined + })) + }; +} + +export const PROVIDER_CATALOG = parseProviderCatalog(snapshot); diff --git a/src/lib/providers/models.dev.LICENSE b/src/lib/providers/models.dev.LICENSE new file mode 100644 index 0000000..9ef0008 --- /dev/null +++ b/src/lib/providers/models.dev.LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 models.dev + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/lib/settings/CustomProviderForm.svelte b/src/lib/settings/CustomProviderForm.svelte index 6c8eb05..bdc81e1 100644 --- a/src/lib/settings/CustomProviderForm.svelte +++ b/src/lib/settings/CustomProviderForm.svelte @@ -26,6 +26,9 @@ mCtx = $bindable(), formats, fmt, + title, + submitLabel, + createDisabled = false, onAddModel, onCreate, onCancel @@ -35,6 +38,9 @@ mCtx: number | undefined; formats: { value: string; label: string }[]; fmt: (n?: number) => string; + title?: string; + submitLabel?: string; + createDisabled?: boolean; onAddModel: () => void; onCreate: () => void; onCancel: () => void; @@ -42,7 +48,7 @@
-
{t('settings.custom.title')}
+
{title ?? t('settings.custom.title')}
@@ -62,7 +68,7 @@
- +
diff --git a/src/lib/settings/ProviderAccountCard.svelte b/src/lib/settings/ProviderAccountCard.svelte index a3acb5c..aa4ad2c 100644 --- a/src/lib/settings/ProviderAccountCard.svelte +++ b/src/lib/settings/ProviderAccountCard.svelte @@ -16,10 +16,12 @@ } interface Provider { id: string; + name?: string; base_url: string; format: string; models: ModelCfg[]; builtin: boolean; + source?: 'catalog' | 'custom'; } let { @@ -63,11 +65,11 @@ + {/each} + {#if matches.length === 0}
{t('settings.catalog.noMatch')}
{/if} + +
+ + +
+ + + diff --git a/src/lib/shell/Picker.svelte b/src/lib/shell/Picker.svelte index f768465..87a80ab 100644 --- a/src/lib/shell/Picker.svelte +++ b/src/lib/shell/Picker.svelte @@ -14,6 +14,7 @@ active: boolean; command: string; depth: number | undefined; + group?: string; }; let { @@ -67,6 +68,9 @@ {/if}
{#each rows as row, i (row.id)} + {#if row.group && (i === 0 || rows[i - 1]?.group !== row.group)} +
{row.group}
+ {/if}