[eric] model picker: groups render subscriptions first, API keys second, routers last with tier separators, never interleaved

This commit is contained in:
ciregenz
2026-08-09 17:01:41 -07:00
parent 97d0c7a8ef
commit 0d98fd6bc7
3 changed files with 73 additions and 3 deletions
@@ -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<Props> = ({
</Box>
)}
{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 ? (
<Box key={`tier-${tier}`} sx={{ px: 1.5, pt: lastTier === null ? 0.5 : 1, pb: 0.5, mt: lastTier === null ? 0 : 0.5, borderTop: lastTier === null ? 'none' : `1px solid ${c.border.subtle}` }}>
<Typography sx={{ fontSize: '0.625rem', fontWeight: 700, letterSpacing: '0.1em', textTransform: 'uppercase', color: c.text.ghost }}>
{TIER_LABELS[tier]}
</Typography>
</Box>
) : 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<Props> = ({
};
return [
tierHeader,
<MenuItem
key={`header-${prov}`}
onClick={(e) => {
@@ -233,7 +246,8 @@ export const ModelPickerList: React.FC<Props> = ({
})}
</Collapse>,
];
}).flat()}
}).flat().filter(Boolean);
})()}
</>
);
};
@@ -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');
});
@@ -112,6 +112,35 @@ export function sortModelsForPicker<T extends { label: string }>(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<ModelTier, string> = {
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<string, Array<{ billing_kind?: string }>>,
): Array<[string, Array<any>, 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|\.|-)/,