mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-26 06:22:22 +02:00
[eric] multi-model polish: updated model registry, Gemini thought-signature fix, collapsible model picker, UI fixes
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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 }} />
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user