mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-22 01:24:52 +02:00
[eric] model picker: groups render subscriptions first, API keys second, routers last with tier separators, never interleaved
This commit is contained in:
@@ -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|\.|-)/,
|
||||
|
||||
Reference in New Issue
Block a user