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
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@
},
"dependencies": {
"@blockrun/llm": "^3.19.1",
"@blockrun/model-catalog": "https://codeload.github.com/BlockRunAI/model-catalog/tar.gz/b95efe2f025c9968742e6479bc9ec71b6a40c0f4",
"@blockrun/model-catalog": "https://codeload.github.com/BlockRunAI/model-catalog/tar.gz/aecf20b4d5ee51af4a38a69a1b9c81e3e64b955c",
"@blockrun/router-core": "https://codeload.github.com/BlockRunAI/router-core/tar.gz/fd6d03d035ff676642ba1683c333d929939f8a00",
"@colbymchenry/codegraph": "^1.5.0",
"@modelcontextprotocol/sdk": "^1.29.0",
Expand Down
2 changes: 2 additions & 0 deletions src/gateway-models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ export interface GatewayModel {
name: string;
description?: string;
owned_by?: string;
available?: boolean;
release_date?: string;
billing_mode: BillingMode;
categories: string[];
context_window?: number;
Expand Down
43 changes: 43 additions & 0 deletions src/serve/model-list.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { sortModelsNewestFirst } from '@blockrun/model-catalog';
import type { GatewayModel } from '../gateway-models.js';

// Keep company sections in the established picker order. Recency within each
// section belongs to Model Core, shared with the expanded terminal picker.
const PROVIDER_LABEL: Record<string, string> = {
anthropic: 'Anthropic', openai: 'OpenAI', azure: 'OpenAI', google: 'Google', 'google-vertex': 'Google',
xai: 'xAI', deepseek: 'DeepSeek', meta: 'Meta', 'meta-llama': 'Meta', nvidia: 'NVIDIA',
moonshot: 'Moonshot', moonshotai: 'Moonshot', qwen: 'Qwen', alibaba: 'Qwen', mistral: 'Mistral',
mistralai: 'Mistral', minimax: 'MiniMax', zhipu: 'Zhipu', bytedance: 'ByteDance', cohere: 'Cohere',
perplexity: 'Perplexity', amazon: 'Amazon', microsoft: 'Microsoft', '01-ai': 'Yi', ai21: 'AI21',
};
const PROVIDER_ORDER = ['Anthropic', 'OpenAI', 'Google', 'xAI', 'DeepSeek', 'Qwen', 'Moonshot', 'Meta', 'Mistral', 'MiniMax', 'NVIDIA'];

function providerLabel(id: string, ownedBy?: string): string {
const p = (id.split('/')[0] || ownedBy || '').toLowerCase();
return PROVIDER_LABEL[p] || (p ? p.charAt(0).toUpperCase() + p.slice(1) : 'Other');
}

/** The models.list payload used by both Desktop and the browser UI. */
export function buildModelList(models: GatewayModel[]) {
const groups = new Map<string, GatewayModel[]>();
for (const model of models) {
if (model.available === false || !model.categories.includes('chat')) continue;
const provider = providerLabel(model.id, model.owned_by);
const group = groups.get(provider) ?? [];
group.push(model);
groups.set(provider, group);
}
const rank = (provider: string) => {
const index = PROVIDER_ORDER.indexOf(provider);
return index < 0 ? PROVIDER_ORDER.length : index;
};
return [...groups].sort(([a], [b]) => rank(a) - rank(b) || a.localeCompare(b))
.flatMap(([provider, rows]) => sortModelsNewestFirst(rows).map(model => ({
id: model.id,
label: model.name,
free: model.billing_mode === 'free',
group: provider,
provider,
contextWindow: model.context_window,
})));
}
39 changes: 2 additions & 37 deletions src/serve/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { assembleInstructions } from '../agent/context.js';
import { interactiveSession } from '../agent/loop.js';
import { allCapabilities, createSubAgentCapability } from '../tools/index.js';
import { getModelsByCategory } from '../gateway-models.js';
import { buildModelList } from './model-list.js';
import { listSessions, loadSessionHistory } from '../session/storage.js';
import { loadSdkSettlements } from '../stats/cost-log.js';
import { readSwaps } from '../stats/swap-log.js';
Expand Down Expand Up @@ -140,26 +141,11 @@ function labelFor(name: string): string {
return TOOL_LABELS[name] ?? name;
}

// Model list grouping — by provider (company), like OpenRouter/Together. The
// provider is the id's vendor prefix (e.g. "anthropic/claude-…"); PROVIDER_ORDER
// puts the most-wanted vendors first, the rest fall in alphabetically.
const PROVIDER_LABEL: Record<string, string> = {
anthropic: 'Anthropic', openai: 'OpenAI', azure: 'OpenAI', google: 'Google', 'google-vertex': 'Google',
xai: 'xAI', deepseek: 'DeepSeek', meta: 'Meta', 'meta-llama': 'Meta', nvidia: 'NVIDIA',
moonshot: 'Moonshot', moonshotai: 'Moonshot', qwen: 'Qwen', alibaba: 'Qwen', mistral: 'Mistral',
mistralai: 'Mistral', minimax: 'MiniMax', zhipu: 'Zhipu', bytedance: 'ByteDance', cohere: 'Cohere',
perplexity: 'Perplexity', amazon: 'Amazon', microsoft: 'Microsoft', '01-ai': 'Yi', ai21: 'AI21',
};
const PROVIDER_ORDER = ['Anthropic', 'OpenAI', 'Google', 'xAI', 'DeepSeek', 'Qwen', 'Moonshot', 'Meta', 'Mistral', 'MiniMax', 'NVIDIA'];
const MEDIA_MIME: Record<string, string> = {
mp3: 'audio/mpeg', wav: 'audio/wav', m4a: 'audio/mp4', ogg: 'audio/ogg', flac: 'audio/flac',
mp4: 'video/mp4', webm: 'video/webm',
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp', gif: 'image/gif',
};
function providerLabel(id: string, ownedBy?: string): string {
const p = (id.split('/')[0] || ownedBy || '').toLowerCase();
return PROVIDER_LABEL[p] || (p ? p.charAt(0).toUpperCase() + p.slice(1) : 'Other');
}

// ─── Browser-attack surface gate ────────────────────────────────────────────
// Loopback binding alone is NOT an auth boundary: any web page the user has
Expand Down Expand Up @@ -839,28 +825,7 @@ export async function startServer(opts: ServerOptions): Promise<void> {
case 'models.list': {
try {
const models = await getModelsByCategory('chat');
const mapped = models.map((m) => {
const provider = providerLabel(m.id, m.owned_by);
return {
id: m.id,
label: m.name,
free: m.billing_mode === 'free',
group: provider,
provider,
contextWindow: m.context_window,
};
});
// Group by provider (PROVIDER_ORDER first, then alphabetical); free
// models surface first within each provider. The picker renders
// consecutive same-`group` items as one section, so sort accordingly.
const rank = (g: string) => { const i = PROVIDER_ORDER.indexOf(g); return i < 0 ? 999 : i; };
mapped.sort((a, b) =>
rank(a.group) - rank(b.group) ||
a.group.localeCompare(b.group) ||
(a.free === b.free ? 0 : a.free ? -1 : 1) ||
a.label.localeCompare(b.label),
);
send(ws, id, 'response', { models: mapped });
send(ws, id, 'response', { models: buildModelList(models) });
} catch (err) {
send(ws, id, 'error', { message: err instanceof Error ? err.message : 'models error' });
}
Expand Down
111 changes: 10 additions & 101 deletions src/ui/model-picker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import readline from 'node:readline';
import chalk from 'chalk';
import { sortModelsNewestFirst } from '@blockrun/model-catalog';
import { getGatewayModels, type GatewayModel } from '../gateway-models.js';
import { FREE_DEFAULT_MODEL } from '../free-models.js';
import { getModelCatalog, onModelCatalogChange } from '../model-catalog.js';
Expand Down Expand Up @@ -490,94 +491,6 @@ function modelSuffix(id: string): string {
return id.includes('/') ? id.slice(id.indexOf('/') + 1) : id;
}

function versionScoreFrom(match: RegExpMatchArray | null): number {
if (!match) return 0;
const parts = match[1].split('.').map(part => Number.parseInt(part, 10) || 0);
return parts.slice(0, 3).reduce((score, part, index) => score + part * [1_000_000, 100_000, 1_000][index], 0);
}

function providerVersionScore(provider: string, suffix: string): number {
switch (provider) {
case 'anthropic':
return versionScoreFrom(suffix.match(/claude-(?:fable|opus|sonnet|haiku)-(\d+(?:\.\d+)*)/));
case 'openai':
if (suffix.startsWith('gpt-4o')) return versionScoreFrom(['', '4.0'] as RegExpMatchArray);
return versionScoreFrom(suffix.match(/(?:gpt-|o)(\d+(?:\.\d+)*)/));
case 'google':
return versionScoreFrom(suffix.match(/gemini-(\d+(?:\.\d+)*)/));
case 'xai':
return versionScoreFrom(suffix.match(/grok-(\d+(?:\.\d+)*)/));
case 'zai':
return versionScoreFrom(suffix.match(/glm-(\d+(?:\.\d+)*)/));
case 'moonshot':
return versionScoreFrom(suffix.match(/kimi-k(\d+(?:\.\d+)*)/));
case 'minimax':
return versionScoreFrom(suffix.match(/minimax-m(\d+(?:\.\d+)*)/));
case 'deepseek':
return versionScoreFrom(suffix.match(/deepseek-v(\d+(?:\.\d+)*)/));
case 'nvidia':
return versionScoreFrom(
suffix.match(/deepseek-v(\d+(?:\.\d+)*)/) ??
suffix.match(/qwen(\d+(?:\.\d+)*)/) ??
suffix.match(/step-(\d+(?:\.\d+)*)/) ??
suffix.match(/nemotron-(\d+(?:\.\d+)*)/) ??
suffix.match(/mistral-large-(\d+(?:\.\d+)*)/) ??
suffix.match(/v(\d+(?:\.\d+)*)/),
);
default:
return versionScoreFrom(suffix.match(/(?:^|[^\d])(\d+(?:\.\d+)*)(?:[^\d]|$)/));
}
}

function expandedModelRank(model: GatewayModel): number {
const suffix = modelSuffix(model.id).toLowerCase();
const provider = providerOf(model.id);
let score = providerVersionScore(provider, suffix);

switch (provider) {
case 'anthropic':
score += suffix.includes('fable') ? 90_000 : suffix.includes('opus') ? 80_000 : suffix.includes('sonnet') ? 70_000 : suffix.includes('haiku') ? 40_000 : 0;
break;
case 'openai':
score += suffix.startsWith('gpt-') ? 80_000 : suffix.startsWith('o') ? 65_000 : 0;
if (suffix.includes('codex')) score += 8_000;
if (suffix.includes('pro') || suffix.includes('sol')) score += 3_000;
if (suffix.includes('terra')) score += 2_000;
if (suffix.includes('luna')) score += 1_000;
if (suffix.includes('mini')) score -= 8_000;
if (suffix.includes('nano')) score -= 12_000;
break;
case 'google':
score += suffix.includes('gemini') ? 80_000 : 0;
if (suffix.includes('pro')) score += 5_000;
if (suffix.includes('flash')) score -= 5_000;
break;
case 'xai':
score += suffix.includes('grok') ? 80_000 : 0;
if (suffix.includes('fast')) score -= 4_000;
if (suffix.includes('build')) score -= 8_000;
break;
case 'zai':
score += suffix.includes('glm') ? 80_000 : 0;
if (suffix.includes('turbo')) score -= 5_000;
break;
case 'moonshot':
score += suffix.includes('kimi') ? 80_000 : 0;
break;
case 'minimax':
score += suffix.includes('minimax') ? 80_000 : 0;
break;
case 'deepseek':
score += suffix.includes('v4-pro') ? 90_000 : suffix.includes('reasoner') ? 75_000 : suffix.includes('chat') ? 70_000 : 0;
break;
case 'nvidia':
score += suffix.includes('qwen3-next') ? 80_000 : suffix.includes('qwen') ? 70_000 : suffix.includes('nemotron') ? 60_000 : 0;
break;
}

return score;
}

function cleanGatewayLabel(model: GatewayModel): string {
return (model.name || modelSuffix(model.id))
.replace(/\s*\((?:free|paid)\)\s*$/i, '')
Expand Down Expand Up @@ -698,28 +611,24 @@ export async function getExpandedPickerCategories(): Promise<HydratedPicker> {

export function reconcileExpandedPicker(catalog: GatewayModel[]): HydratedPicker {
const categories: ModelCategory[] = [{ ...PICKER_CATEGORIES[0], models: [...PICKER_CATEGORIES[0].models] }];
const curated = new Set<string>();
const curatedRows = new Map<string, ModelEntry>();

for (const cat of PICKER_CATEGORIES) {
for (const row of cat.models) {
curated.add(row.id);
if (!isSyntheticId(row.id)) curatedRows.set(row.id, row);
}
}

const shortcutById = buildShortcutById();
const chatModels = catalog
.filter(m => m.categories?.includes('chat'))
.sort((a, b) => {
const providerDelta = providerRank(providerOf(a.id)) - providerRank(providerOf(b.id));
if (providerDelta !== 0) return providerDelta;
const rankDelta = expandedModelRank(b) - expandedModelRank(a);
if (rankDelta !== 0) return rankDelta;
const curatedDelta = Number(!curated.has(a.id)) - Number(!curated.has(b.id));
if (curatedDelta !== 0) return curatedDelta;
return modelSuffix(a.id).localeCompare(modelSuffix(b.id));
});
const byProviderModels = new Map<string, GatewayModel[]>();
for (const model of catalog) {
if (model.available === false || !model.categories?.includes('chat')) continue;
const provider = providerOf(model.id);
const rows = byProviderModels.get(provider) ?? [];
rows.push(model);
byProviderModels.set(provider, rows);
}
const chatModels = [...byProviderModels.values()].flatMap(rows => sortModelsNewestFirst(rows));

const byProvider = new Map<string, ModelEntry[]>();
for (const model of chatModels) {
Expand Down
10 changes: 5 additions & 5 deletions test/local.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7687,7 +7687,7 @@ test('reconcileExpandedPicker: live catalog is grouped by provider after Auto',
);
});

test('reconcileExpandedPicker: provider groups rank newest and strongest models first', async () => {
test('reconcileExpandedPicker: provider groups use shared recency and preserve same-generation order', async () => {
const { reconcileExpandedPicker } = await import('../dist/ui/model-picker.js');
const catalog = [
gwModel('anthropic/claude-haiku-4.5', { input: 1, output: 2 }),
Expand Down Expand Up @@ -7732,13 +7732,13 @@ test('reconcileExpandedPicker: provider groups rank newest and strongest models
'anthropic/claude-haiku-4.5',
]);
assert.deepEqual(idsFor('OpenAI / GPT'), [
'openai/gpt-5.6-luna',
'openai/gpt-5.6-sol',
'openai/gpt-5.6-terra',
'openai/gpt-5.6-luna',
'openai/gpt-5.5',
'openai/gpt-5.4-pro',
'openai/gpt-5.4-mini',
'openai/gpt-5.4-nano',
'openai/gpt-5.4-pro',
'openai/gpt-5.3-codex',
'openai/gpt-5.3',
'openai/gpt-5.2',
Expand All @@ -7747,8 +7747,8 @@ test('reconcileExpandedPicker: provider groups rank newest and strongest models
]);
assert.deepEqual(idsFor('Google / Gemini'), [
'google/gemini-3.5-flash',
'google/gemini-3.1-pro',
'google/gemini-3.1-flash-lite',
'google/gemini-3.1-pro',
'google/gemini-3-flash-preview',
'google/gemini-2.5-flash',
]);
Expand All @@ -7760,8 +7760,8 @@ test('reconcileExpandedPicker: provider groups rank newest and strongest models
assert.deepEqual(idsFor('Z.AI / GLM'), [
'zai/glm-5.2',
'zai/glm-5.1',
'zai/glm-5',
'zai/glm-5-turbo',
'zai/glm-5',
]);
assert.deepEqual(idsFor('MiniMax'), [
'minimax/minimax-m3',
Expand Down
42 changes: 42 additions & 0 deletions test/model-catalog.local.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,48 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import snapshot from '@blockrun/model-catalog/snapshot' with { type: 'json' };
import { buildModelList } from '../dist/serve/model-list.js';

test('models.list groups every available chat model with the latest generations first', () => {
const row = (id, extra = {}) => ({ id, name: id, categories: ['chat'], billing_mode: 'paid', pricing: { input: 1, output: 2 }, ...extra });
const rows = [
row('google/gemini-2.5-pro'),
row('openai/gpt-4.1'),
row('anthropic/claude-haiku-4.5', { billing_mode: 'free' }),
row('google/gemini-3.1-pro'),
row('anthropic/claude-fable-5.1'),
row('openai/gpt-6.1-sol', { context_window: 1_000_000 }),
row('google/gemini-4-argon'),
row('openai/gpt-6-astra'),
row('google/gemini-3.8-flash'),
row('test/future-chat'),
row('google/image-model', { categories: ['image'] }),
row('openai/gpt-99', { available: false }),
];
const list = buildModelList(rows);
assert.deepEqual(list.map(m => m.id), [
'anthropic/claude-fable-5.1', 'anthropic/claude-haiku-4.5',
'openai/gpt-6.1-sol', 'openai/gpt-6-astra', 'openai/gpt-4.1',
'google/gemini-4-argon', 'google/gemini-3.8-flash', 'google/gemini-3.1-pro', 'google/gemini-2.5-pro',
'test/future-chat',
]);
assert.equal(list.find(m => m.id === 'anthropic/claude-haiku-4.5').free, true);
assert.equal(list.find(m => m.id === 'openai/gpt-6.1-sol').contextWindow, 1_000_000);
assert.deepEqual([...new Set(list.map(m => m.group))], ['Anthropic', 'OpenAI', 'Google', 'Test']);
assert.equal(rows[0].id, 'google/gemini-2.5-pro', 'input order is not mutated');
});

test('Desktop and expanded terminal pickers share verified release-date ordering', async () => {
const { reconcileExpandedPicker } = await import('../dist/ui/model-picker.js');
const rows = [
{ id: 'google/gemini-4-argon', name: 'Gemini 4 Argon', release_date: '2026-09-01' },
{ id: 'google/gemini-3.8-flash', name: 'Gemini 3.8 Flash', release_date: '2026-10-01' },
].map(m => ({ ...m, categories: ['chat'], billing_mode: 'paid', pricing: { input: 1, output: 2 } }));
const expected = ['google/gemini-3.8-flash', 'google/gemini-4-argon'];
assert.deepEqual(buildModelList(rows).map(m => m.id), expected);
const expanded = reconcileExpandedPicker(rows).categories.find(c => c.category === 'Google / Gemini');
assert.deepEqual(expanded.models.map(m => m.id), expected);
});

process.env.RUNCODE_CHAIN = 'solana';
delete process.env.FRANKLIN_CATALOG_OFFLINE;
Expand Down
Loading