[eric] multi-model polish: updated model registry, Gemini thought-signature fix, collapsible model picker, UI fixes

This commit is contained in:
ciregenz
2026-04-12 20:49:33 -07:00
parent 4778cea80e
commit bc2ca1f33a
7 changed files with 183 additions and 108 deletions
@@ -110,6 +110,8 @@ export async function POST(request, { params }) {
return NextResponse.json({ error: "Missing device code" }, { status: 400 });
}
console.log(`[oauth-poll] ${provider}: polling with deviceCode=${deviceCode?.slice(0, 8)}...`);
// Providers that don't use PKCE for device code
const noPkceProviders = ["github", "kimi-coding", "kilocode"];
let result;
@@ -126,6 +128,8 @@ export async function POST(request, { params }) {
result = await pollForToken(provider, deviceCode, codeVerifier);
}
console.log(`[oauth-poll] ${provider}: result=`, JSON.stringify({ success: result.success, error: result.error, pending: result.pending, hasTokens: !!result.tokens }));
if (result.success) {
// Save to database
const connection = await createProviderConnection({
+3
View File
@@ -499,6 +499,7 @@ const PROVIDERS = {
return await response.json();
},
pollToken: async (config, deviceCode) => {
console.log(`[github-poll] Polling ${config.tokenUrl} with client_id=${config.clientId}, device_code=${deviceCode?.slice(0, 8)}...`);
const response = await fetch(config.tokenUrl, {
method: "POST",
headers: {
@@ -522,6 +523,8 @@ const PROVIDERS = {
data = { error: "invalid_response", error_description: text };
}
console.log(`[github-poll] Response: status=${response.status}, ok=${response.ok}, data=`, JSON.stringify(data));
return {
ok: response.ok,
data: data,
+12 -3
View File
@@ -308,7 +308,18 @@ async def list_models():
try:
providers_data = await _9r_providers()
conns = providers_data.get("connections", []) if isinstance(providers_data, dict) else []
connected = {c.get("provider", "") for c in conns if c.get("isActive")}
raw_providers = {c.get("provider", "") for c in conns if c.get("isActive") or c.get("testStatus") == "active"}
# Map 9Router's provider names to our BUILTIN_MODELS api field names.
# 9Router stores "github" but our models use api="github-copilot",
# 9Router stores "codex" but our models use api="codex" (matches),
# 9Router stores "claude" but our models use api="anthropic", etc.
_9R_TO_API = {
"github": "github-copilot",
"claude": "anthropic",
"codex": "codex",
"gemini-cli": "gemini-cli",
}
connected = raw_providers | {_9R_TO_API.get(p, p) for p in raw_providers}
except Exception as e:
logger.debug(f"Failed to fetch 9Router providers: {e}")
@@ -318,11 +329,9 @@ async def list_models():
for m in models:
api = m.get("api", "")
if m.get("subscription_only"):
# Subscription-only models need that provider live in 9Router
if not nine_router_up or api not in connected:
continue
elif api == "anthropic":
# Anthropic visible if API key set OR claude subscription connected
has_key = bool(getattr(settings, "anthropic_api_key", None))
if not has_key and "claude" not in connected:
continue
+46 -20
View File
@@ -75,13 +75,13 @@ BUILTIN_MODELS: dict[str, list[dict[str, Any]]] = {
# stronger reasoning, tool use, and agentic workflows.
# See: https://developers.openai.com/codex/models
"OpenAI": [
{"value": "gpt-5.4", "label": "GPT-5.4 (ChatGPT Plus)",
{"value": "gpt-5.4", "label": "GPT-5.4",
"context_window": 1_000_000, "router_model_id": "cx/gpt-5.4",
"api": "codex", "subscription_only": True, "reasoning": True},
{"value": "gpt-5.4-mini", "label": "GPT-5.4 Mini (ChatGPT Plus)",
{"value": "gpt-5.4-mini", "label": "GPT-5.4 Mini",
"context_window": 400_000, "router_model_id": "cx/gpt-5.4-mini",
"api": "codex", "subscription_only": True, "reasoning": True},
{"value": "gpt-5.3-codex", "label": "GPT-5.3 Codex (ChatGPT Plus)",
{"value": "gpt-5.3-codex", "label": "GPT-5.3 Codex",
"context_window": 400_000, "router_model_id": "cx/gpt-5.3-codex",
"api": "codex", "subscription_only": True, "reasoning": True},
],
@@ -108,25 +108,46 @@ BUILTIN_MODELS: dict[str, list[dict[str, Any]]] = {
"context_window": 1_000_000, "router_model_id": "gc/gemini-2.5-flash",
"api": "gemini-cli", "subscription_only": True},
],
# Copilot gives access to everyone's current-gen flagships under one
# subscription. Note the dot-notation (4.6 not 4-6) — Copilot's model
# catalog is separate from Anthropic's API naming.
"GitHub Copilot": [
{"value": "copilot-sonnet-4.6", "label": "Claude Sonnet 4.6 (Copilot)",
"context_window": 200_000, "router_model_id": "gh/claude-sonnet-4.6",
# GitHub Copilot — all plans (Free/Pro/Pro+) have access to the SAME
# models, just with different premium request quotas (50/300/1500).
# Model IDs MUST match 9Router's `gh:` pricing catalog at
# 9router/src/shared/constants/pricing.js — NOT the Codex CLI catalog.
# Copilot uses its own model IDs (dot-notation for Claude versions,
# different names from Codex CLI for some GPT models).
# See: https://github.com/features/copilot/plans
"OpenSwarm": [
# --- Free-tier friendly (low premium request cost) ---
{"value": "gpt-5-mini", "label": "GPT-5 Mini",
"context_window": 200_000, "router_model_id": "gh/gpt-5-mini",
"api": "github-copilot", "subscription_only": True},
{"value": "copilot-opus-4.6", "label": "Claude Opus 4.6 (Copilot)",
"context_window": 200_000, "router_model_id": "gh/claude-opus-4.6",
"api": "github-copilot", "subscription_only": True},
{"value": "copilot-haiku-4.5", "label": "Claude Haiku 4.5 (Copilot)",
{"value": "claude-haiku-4.5", "label": "Claude Haiku 4.5",
"context_window": 200_000, "router_model_id": "gh/claude-haiku-4.5",
"api": "github-copilot", "subscription_only": True},
{"value": "copilot-gpt-5.3-codex", "label": "GPT-5.3 Codex (Copilot)",
{"value": "grok-code-fast-1", "label": "Grok Code Fast 1",
"context_window": 128_000, "router_model_id": "gh/grok-code-fast-1",
"api": "github-copilot", "subscription_only": True},
{"value": "gpt-4.1", "label": "GPT-4.1",
"context_window": 128_000, "router_model_id": "gh/gpt-4.1",
"api": "github-copilot", "subscription_only": True},
# --- Premium models (consume more premium requests) ---
{"value": "claude-sonnet-4.6", "label": "Claude Sonnet 4.6",
"context_window": 200_000, "router_model_id": "gh/claude-sonnet-4.6",
"api": "github-copilot", "subscription_only": True},
{"value": "claude-opus-4.6", "label": "Claude Opus 4.6",
"context_window": 200_000, "router_model_id": "gh/claude-opus-4.6",
"api": "github-copilot", "subscription_only": True},
{"value": "gpt-5.3-codex", "label": "GPT-5.3 Codex",
"context_window": 400_000, "router_model_id": "gh/gpt-5.3-codex",
"api": "github-copilot", "subscription_only": True, "reasoning": True},
{"value": "copilot-gemini-3-pro", "label": "Gemini 3 Pro (Copilot)",
{"value": "gemini-3-pro", "label": "Gemini 3 Pro",
"context_window": 1_000_000, "router_model_id": "gh/gemini-3-pro-preview",
"api": "github-copilot", "subscription_only": True, "reasoning": True},
{"value": "gemini-3-flash", "label": "Gemini 3 Flash",
"context_window": 1_000_000, "router_model_id": "gh/gemini-3-flash-preview",
"api": "github-copilot", "subscription_only": True, "reasoning": True},
{"value": "gemini-2.5-pro", "label": "Gemini 2.5 Pro",
"context_window": 1_000_000, "router_model_id": "gh/gemini-2.5-pro",
"api": "github-copilot", "subscription_only": True},
],
}
@@ -490,11 +511,16 @@ COST_PER_1M_TOKENS: dict[tuple[str, str], tuple[float, float]] = {
("Qwen", "qwen/qwen3-235b-a22b"): (0.20, 0.70),
("Cohere", "cohere/command-a-03-2025"): (2.50, 10.0),
# GitHub Copilot (subscription-routed; no per-token cost)
("GitHub Copilot", "copilot-sonnet-4.6"): (0.0, 0.0),
("GitHub Copilot", "copilot-opus-4.6"): (0.0, 0.0),
("GitHub Copilot", "copilot-haiku-4.5"): (0.0, 0.0),
("GitHub Copilot", "copilot-gpt-5.3-codex"): (0.0, 0.0),
("GitHub Copilot", "copilot-gemini-3-pro"): (0.0, 0.0),
("OpenSwarm", "claude-sonnet-4.6"): (0.0, 0.0),
("OpenSwarm", "claude-opus-4.6"): (0.0, 0.0),
("OpenSwarm", "claude-haiku-4.5"): (0.0, 0.0),
("OpenSwarm", "gpt-5.3-codex"): (0.0, 0.0),
("OpenSwarm", "gpt-5-mini"): (0.0, 0.0),
("OpenSwarm", "gpt-4.1"): (0.0, 0.0),
("OpenSwarm", "grok-code-fast-1"): (0.0, 0.0),
("OpenSwarm", "gemini-3-pro"): (0.0, 0.0),
("OpenSwarm", "gemini-3-flash"): (0.0, 0.0),
("OpenSwarm", "gemini-2.5-pro"): (0.0, 0.0),
}
+76 -48
View File
@@ -227,6 +227,12 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
if (modesArr.length === 0) dispatch(fetchModes());
}, [dispatch, modesArr.length]);
// Collapsible provider groups in the model picker. The group containing
// the currently selected model is always expanded; others start collapsed
// when there are 3+ groups to keep the dropdown manageable.
const [collapsedGroups, setCollapsedGroups] = useState<Record<string, boolean>>({});
const toggleGroup = (prov: string) => setCollapsedGroups(prev => ({ ...prev, [prov]: !prev[prov] }));
const [images, setImages] = useState<AttachedImage[]>([]);
const [lightboxSrc, setLightboxSrc] = useState<string | null>(null);
const [isDragOver, setIsDragOver] = useState(false);
@@ -1060,56 +1066,78 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
transformOrigin={{ vertical: 'bottom', horizontal: 'left' }}
slotProps={{ paper: menuPaperProps }}
>
{Object.entries(allModelOptions.grouped).map(([prov, models]) => [
<MenuItem key={`header-${prov}`} disabled sx={{ opacity: '0.7 !important', py: 0.5, px: 1.5, minHeight: 'auto' }}>
<Typography sx={{ fontSize: '0.65rem', fontWeight: 700, letterSpacing: '0.06em', textTransform: 'uppercase', color: c.text.tertiary }}>
{prov}
</Typography>
</MenuItem>,
...models.map((opt) => (
{Object.entries(allModelOptions.grouped).map(([prov, models]) => {
// Default: group with selected model starts expanded, others
// collapsed when 3+ groups. But user can manually toggle any
// group including the active one.
const groupCount = Object.keys(allModelOptions.grouped).length;
const hasSelectedModel = models.some(m => m.value === model);
const defaultCollapsed = hasSelectedModel ? false : (groupCount >= 3);
const isCollapsed = collapsedGroups[prov] ?? defaultCollapsed;
const modelCount = models.length;
return [
// Clickable group header with expand/collapse arrow
<MenuItem
key={opt.value}
selected={model === opt.value}
onClick={() => {
onModelChange(opt.value);
if (onProviderChange) {
// Derive API-level provider key from the display group name
const provLower = prov.toLowerCase();
const providerMap: Record<string, string> = {
anthropic: 'anthropic',
openai: 'openai',
google: 'gemini',
// OpenRouter-backed providers
xai: 'openrouter',
meta: 'openrouter',
deepseek: 'openrouter',
mistral: 'openrouter',
qwen: 'openrouter',
cohere: 'openrouter',
};
onProviderChange(providerMap[provLower] || provLower);
}
// Warn (once) when switching to a non-Claude model with
// many MCP tools enabled. Non-Claude models don't have
// access to the deferred-tool pool and will receive every
// tool schema upfront, potentially exhausting context.
if (prov.toLowerCase() !== 'anthropic' && enabledMcpToolCount > MCP_WARNING_THRESHOLD) {
try {
if (typeof window !== 'undefined' && !window.localStorage.getItem(MCP_WARNING_LS_KEY)) {
setMcpWarningOpen(true);
}
} catch { /* ignore localStorage errors */ }
}
setModelAnchor(null);
}}
key={`header-${prov}`}
onClick={(e) => { e.stopPropagation(); toggleGroup(prov); }}
sx={{ py: 0.5, px: 1.5, minHeight: 'auto', cursor: 'pointer', '&:hover': { bgcolor: `${c.bg.secondary}80` } }}
>
<ListItemText
primary={opt.label}
slotProps={{ primary: { sx: { fontSize: '0.8rem', color: model === opt.value ? c.text.primary : c.text.muted } } }}
/>
</MenuItem>
)),
]).flat()}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, width: '100%', justifyContent: 'space-between' }}>
<Typography sx={{ fontSize: '0.65rem', fontWeight: 700, letterSpacing: '0.06em', textTransform: 'uppercase', color: c.text.tertiary }}>
{prov}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
{isCollapsed && <Typography sx={{ fontSize: '0.58rem', color: c.text.ghost }}>{modelCount}</Typography>}
<KeyboardArrowDownIcon sx={{
fontSize: 12,
color: c.text.ghost,
transform: isCollapsed ? 'rotate(-90deg)' : 'rotate(0deg)',
transition: 'transform 0.15s ease',
}} />
</Box>
</Box>
</MenuItem>,
// Models (hidden when collapsed)
...(!isCollapsed ? models.map((opt) => (
<MenuItem
key={opt.value}
selected={model === opt.value}
onClick={() => {
onModelChange(opt.value);
if (onProviderChange) {
const provLower = prov.toLowerCase();
const providerMap: Record<string, string> = {
anthropic: 'anthropic',
openai: 'openai',
google: 'gemini',
xai: 'openrouter',
meta: 'openrouter',
deepseek: 'openrouter',
mistral: 'openrouter',
qwen: 'openrouter',
cohere: 'openrouter',
};
onProviderChange(providerMap[provLower] || provLower);
}
if (prov.toLowerCase() !== 'anthropic' && enabledMcpToolCount > MCP_WARNING_THRESHOLD) {
try {
if (typeof window !== 'undefined' && !window.localStorage.getItem(MCP_WARNING_LS_KEY)) {
setMcpWarningOpen(true);
}
} catch {}
}
setModelAnchor(null);
}}
>
<ListItemText
primary={opt.label}
slotProps={{ primary: { sx: { fontSize: '0.8rem', color: model === opt.value ? c.text.primary : c.text.muted } } }}
/>
</MenuItem>
)) : []),
];
}).flat()}
</Menu>
<Box sx={{ flex: 1 }} />
+41 -36
View File
@@ -59,7 +59,7 @@ const SUBSCRIPTION_PROVIDERS = [
{ id: 'claude', name: 'Claude Pro / Max', desc: 'Sonnet 4.6, Opus 4.6, Haiku 4.5', color: '#E8927A', preview: false },
{ id: 'gemini-cli', name: 'Gemini Advanced', desc: 'Gemini 3 Pro, 3 Flash, 2.5 Pro, 2.5 Flash', color: '#4285F4', preview: false },
{ id: 'codex', name: 'ChatGPT Plus / Pro', desc: 'GPT-5.4, GPT-5.4 Mini, GPT-5.3 Codex', color: '#74AA9C', preview: false },
{ id: 'github', name: 'GitHub Copilot', desc: 'Claude, GPT, Gemini, and more', color: '#8B949E', preview: true },
{ id: 'github', name: 'GitHub Copilot', desc: 'Claude, GPT, Gemini, and more', color: '#8B949E', preview: false },
];
const SubscriptionCard: React.FC<{ provider: typeof SUBSCRIPTION_PROVIDERS[0]; connected: boolean; onConnect: () => void; onDisconnect: () => void; connecting: boolean; userCode?: string; disconnecting?: boolean }> = ({ provider, connected, onConnect, onDisconnect, connecting, userCode, disconnecting }) => {
@@ -257,48 +257,53 @@ const SubscriptionCards: React.FC = () => {
setPollTimer(devicePollTimer);
// Detect when the popup is closed (user may close it after seeing
// GitHub's "Congratulations" page). Give 9Router 3 seconds to
// process the token exchange, then do a final status check. If
// the connection still isn't found, reset the card so it doesn't
// stay stuck on "Waiting for authorization" forever — the root
// cause is a 9Router-side issue where the GitHub device-code poll
// sometimes fails to detect the token exchange completion.
const popupCloseCheck = setInterval(() => {
if (stopped) { clearInterval(popupCloseCheck); return; }
if (devicePopup && devicePopup.closed) {
clearInterval(popupCloseCheck);
setTimeout(async () => {
if (stopped) return;
// One last status check before giving up
try {
const sr = await fetch(`${API_BASE}/agents/subscriptions/status`);
const sd = await sr.json();
const connections = sd.providers?.connections || [];
if (connections.some((p: any) => p.provider === providerId && (p.isActive || p.testStatus === 'active'))) {
onDeviceSuccess();
return;
}
} catch {}
// Connection not found — reset card instead of staying stuck
stopped = true;
clearInterval(devicePollTimer);
clearInterval(statusPollTimer);
setPollTimer(null);
setConnecting(null);
setUserCode('');
fetchStatus();
}, 3000);
}
}, 1000);
// Detect when the user returns to the main window after
// interacting with the popup. In Electron, `popup.closed` is
// unreliable (the WindowProxy may not update when the child
// BrowserWindow is destroyed). Listening for `focus` on the
// main window is more robust — it fires when the user closes
// the popup, switches tabs, or clicks back on the app.
let focusCheckDone = false;
const onFocus = async () => {
if (stopped || focusCheckDone) return;
focusCheckDone = true;
window.removeEventListener('focus', onFocus);
// Give 9Router 3 seconds to process the token exchange
await new Promise(r => setTimeout(r, 3000));
if (stopped) return;
// Final status check
try {
const sr = await fetch(`${API_BASE}/agents/subscriptions/status`);
const sd = await sr.json();
const connections = sd.providers?.connections || [];
if (connections.some((p: any) => p.provider === providerId && (p.isActive || p.testStatus === 'active'))) {
onDeviceSuccess();
return;
}
} catch {}
// Connection not found — reset card
stopped = true;
clearInterval(devicePollTimer);
clearInterval(statusPollTimer);
setPollTimer(null);
setConnecting(null);
setUserCode('');
fetchStatus();
};
// Delay registering the focus listener so the initial popup
// open doesn't immediately trigger it (opening a popup blurs
// then refocuses the parent in some cases).
setTimeout(() => {
if (!stopped) window.addEventListener('focus', onFocus);
}, 2000);
// 5-minute hard timeout — clean up everything.
setTimeout(() => {
if (stopped) return;
stopped = true;
window.removeEventListener('focus', onFocus);
clearInterval(devicePollTimer);
clearInterval(statusPollTimer);
clearInterval(popupCloseCheck);
setPollTimer(null);
setConnecting(null);
setUserCode('');
File diff suppressed because one or more lines are too long