From 0d98fd6bc7b29a6c338a73c56ce3b7f7b48a03f1 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sun, 9 Aug 2026 17:01:41 -0700 Subject: [PATCH] [eric] model picker: groups render subscriptions first, API keys second, routers last with tier separators, never interleaved --- .../model-picker/ModelPickerList.tsx | 20 +++++++++++-- .../model-picker/modelPicker.test.ts | 27 +++++++++++++++++ .../ChatInput/model-picker/modelPicker.ts | 29 +++++++++++++++++++ 3 files changed, 73 insertions(+), 3 deletions(-) create mode 100644 frontend/src/app/pages/AgentChat/ChatInput/model-picker/modelPicker.test.ts diff --git a/frontend/src/app/pages/AgentChat/ChatInput/model-picker/ModelPickerList.tsx b/frontend/src/app/pages/AgentChat/ChatInput/model-picker/ModelPickerList.tsx index 3d0dd8b1..fbd0c089 100644 --- a/frontend/src/app/pages/AgentChat/ChatInput/model-picker/ModelPickerList.tsx +++ b/frontend/src/app/pages/AgentChat/ChatInput/model-picker/ModelPickerList.tsx @@ -7,7 +7,7 @@ import Tooltip from '@mui/material/Tooltip'; import Collapse from '@mui/material/Collapse'; import KeyboardArrowRightIcon from '@mui/icons-material/KeyboardArrowRight'; import { ClaudeTokens } from '@/shared/styles/claudeTokens'; -import { PROVIDER_COLORS, OR_AUTO_COLLAPSE_THRESHOLD } from './modelPicker'; +import { PROVIDER_COLORS, OR_AUTO_COLLAPSE_THRESHOLD, orderGroupsByTier, TIER_LABELS } from './modelPicker'; import { formatTokenCount } from '../helpers'; import { ModelPickerRecents } from './ModelPickerRecents'; @@ -73,7 +73,19 @@ export const ModelPickerList: React.FC = ({ )} - {Object.entries(filteredModelGroups).map(([prov, models]) => { + {(() => { + const ordered = orderGroupsByTier(filteredModelGroups); + let lastTier: string | null = null; + return ordered.map(([prov, models, tier]) => { + // A hard separator + tier label the first time each tier appears, so subs/API/routers never blur together. + const tierHeader = tier !== lastTier ? ( + + + {TIER_LABELS[tier]} + + + ) : null; + lastTier = tier; const isOpenSwarmPro = prov === 'OpenSwarm Pro'; const isOR = prov.startsWith('OpenRouter'); const ms = models as any[]; @@ -105,6 +117,7 @@ export const ModelPickerList: React.FC = ({ }; return [ + tierHeader, { @@ -233,7 +246,8 @@ export const ModelPickerList: React.FC = ({ })} , ]; - }).flat()} + }).flat().filter(Boolean); + })()} ); }; diff --git a/frontend/src/app/pages/AgentChat/ChatInput/model-picker/modelPicker.test.ts b/frontend/src/app/pages/AgentChat/ChatInput/model-picker/modelPicker.test.ts new file mode 100644 index 00000000..b477bd12 --- /dev/null +++ b/frontend/src/app/pages/AgentChat/ChatInput/model-picker/modelPicker.test.ts @@ -0,0 +1,27 @@ +// Run: npx tsx --test frontend/src/app/pages/AgentChat/ChatInput/model-picker/modelPicker.test.ts +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { orderGroupsByTier, groupTier } from './modelPicker.ts'; + +test('groups order subscriptions, then API keys, then routers, never interleaved', () => { + const grouped = { + 'OpenRouter · DeepSeek': [{ billing_kind: 'router' }], + 'OpenAI': [{ billing_kind: 'api_key' }], + 'Anthropic': [{ billing_kind: 'subscription' }], + 'Google': [{ billing_kind: 'subscription' }, { billing_kind: 'api_key' }], + }; + const ordered = orderGroupsByTier(grouped).map(([prov, , tier]) => `${prov}:${tier}`); + assert.deepEqual(ordered, [ + 'Anthropic:subscription', + 'Google:subscription', + 'OpenAI:api_key', + 'OpenRouter · DeepSeek:router', + ]); +}); + +test('a mixed sub+api group sorts as a subscription; router prefixes and dot-separators are routers', () => { + assert.equal(groupTier('Google', [{ billing_kind: 'api_key' }, { billing_kind: 'subscription' }]), 'subscription'); + assert.equal(groupTier('OpenRouter · Meta', [{ billing_kind: 'router' }]), 'router'); + assert.equal(groupTier('Some · Vendor', [{ billing_kind: 'api_key' }]), 'router'); + assert.equal(groupTier('OpenAI', [{ billing_kind: 'api_key' }]), 'api_key'); +}); diff --git a/frontend/src/app/pages/AgentChat/ChatInput/model-picker/modelPicker.ts b/frontend/src/app/pages/AgentChat/ChatInput/model-picker/modelPicker.ts index 2f6b19e7..8057f8f9 100644 --- a/frontend/src/app/pages/AgentChat/ChatInput/model-picker/modelPicker.ts +++ b/frontend/src/app/pages/AgentChat/ChatInput/model-picker/modelPicker.ts @@ -112,6 +112,35 @@ export function sortModelsForPicker(models: T[]): T }); } +// The three billing tiers the picker groups by, in the order they render (Eric 2026-08-09): your +// own subscriptions first, your own API keys second, pass-through routers last, never interleaved. +export type ModelTier = 'subscription' | 'api_key' | 'router'; +export const TIER_ORDER: ModelTier[] = ['subscription', 'api_key', 'router']; +export const TIER_LABELS: Record = { + subscription: 'Subscriptions', + api_key: 'API keys', + router: 'Routers', +}; + +/** A provider group's tier: OpenRouter/router prefixes are routers; otherwise the group's dominant + * billing_kind (subscription rows win ties so a mixed group sorts with the subs). */ +export function groupTier(prov: string, models: Array<{ billing_kind?: string }>): ModelTier { + if (/^(openrouter|router)\b/i.test(prov) || prov.includes('·')) return 'router'; + if (models.some((m) => m.billing_kind === 'subscription' || m.billing_kind === 'free')) return 'subscription'; + return 'api_key'; +} + +/** Reorder group entries into subscription -> api_key -> router, keeping each provider's existing + * order within its tier. The one place tier order is decided, so headers and list can't drift. */ +export function orderGroupsByTier( + grouped: Record>, +): Array<[string, Array, ModelTier]> { + const entries = Object.entries(grouped).map( + ([prov, models]) => [prov, models, groupTier(prov, models as any[])] as [string, any[], ModelTier], + ); + return entries.sort((a, b) => TIER_ORDER.indexOf(a[2]) - TIER_ORDER.indexOf(b[2])); +} + // Superseded generations we no longer surface in the picker; the ids still work if saved as a default. const DEPRECATED_PATTERNS: RegExp[] = [ /\bgpt[-_ ]?[34](\b|o|\.|-)/,