Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
155 changes: 155 additions & 0 deletions scripts/refresh-provider-catalog.mjs
Original file line number Diff line number Diff line change
@@ -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}`);
1 change: 1 addition & 0 deletions src/lib/Composer.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
active: boolean;
command: string;
depth: number | undefined;
group?: string;
};

let {
Expand Down
60 changes: 54 additions & 6 deletions src/lib/Settings.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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,
Expand Down Expand Up @@ -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<Record<string, any>>({});
Expand All @@ -75,9 +84,10 @@
let section = $state<'overview' | 'account' | 'behavior' | 'extensions'>(untrack(() => initialSection));

// inline editor state
let editing = $state<string | null>(null); // provider id, or '__new__'
let editing = $state<string | null>(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<CatalogProvider | null>(null);
let mName = $state('');
let mCtx = $state<number | undefined>();

Expand All @@ -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
Expand All @@ -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)
}))
Expand Down Expand Up @@ -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));
}
Expand Down Expand Up @@ -193,15 +219,27 @@
}
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());
keyed = await readAuthProviders();
onAuthChange?.();
}
editing = null;
selectedCatalog = null;
}
function deleteProvider(id: string) {
custom = custom.filter((c) => c.id !== id);
Expand Down Expand Up @@ -358,16 +396,26 @@
{/each}
</div>

{#if editing === '__new__'}
{#if editing === '__catalog__'}
<ProviderCatalogPicker
providers={catalogProviders}
onSelect={selectCatalogProvider}
onCustom={openCustom}
onCancel={() => (editing = null)}
/>
{:else if editing === '__new__'}
<CustomProviderForm
bind:form
bind:mName
bind:mCtx
formats={FORMATS}
{fmt}
title={selectedCatalog ? t('settings.catalog.connect', { provider: selectedCatalog.name }) : undefined}
submitLabel={selectedCatalog ? t('settings.catalog.addProvider') : undefined}
createDisabled={!!selectedCatalog && !form.key.trim()}
onAddModel={addFormModel}
onCreate={createProvider}
onCancel={() => (editing = null)}
onCancel={() => (editing = selectedCatalog ? '__catalog__' : null)}
/>
{:else}
<button class="addprov" onclick={openCreate}><Plus size={15} /> {t('settings.custom.add')}</button>
Expand Down
Loading
Loading