[eric] fix API key priority over 9Router, fix OAuth callback 500, polish Settings UI

This commit is contained in:
ciregenz
2026-03-27 13:06:17 -07:00
parent 9024f1b5ea
commit 97622f87e0
9 changed files with 171 additions and 39 deletions
+2
View File
@@ -5,6 +5,8 @@ const nextConfig = {
unoptimized: true
},
env: {},
// Allow builds with both Turbopack (Next 16 default) and webpack
turbopack: {},
webpack: (config, { isServer }) => {
// Ignore fs/path modules in browser bundle
if (!isServer) {
+4 -4
View File
@@ -862,17 +862,17 @@ class AgentManager:
"disallowed_tools": effective_disallowed,
"include_partial_messages": True,
}
# Priority: 9Router subscription → API key
# Priority: API key → 9Router subscription
from backend.apps.nine_router import is_running as _9r_running
if _9r_running():
if global_settings.anthropic_api_key:
options_kwargs["env"] = {"ANTHROPIC_API_KEY": global_settings.anthropic_api_key}
elif _9r_running():
options_kwargs["env"] = {
"ANTHROPIC_API_KEY": "9router",
"ANTHROPIC_BASE_URL": "http://localhost:20128",
}
# --bare skips CLI's own OAuth/keychain auth, uses only ANTHROPIC_API_KEY
options_kwargs["extra_args"] = {"bare": None}
elif global_settings.anthropic_api_key:
options_kwargs["env"] = {"ANTHROPIC_API_KEY": global_settings.anthropic_api_key}
else:
raise ValueError("No AI provider configured. Set an API key or connect a subscription.")
if mcp_servers:
@@ -114,6 +114,7 @@ def create_provider(
auth_token=getattr(settings, "openswarm_auth_token", None),
base_url=getattr(settings, "openswarm_proxy_url", None) or "https://api.openswarm.ai",
)
# Priority: API key → 9Router subscription
if settings.anthropic_api_key:
return AnthropicProvider(api_key=settings.anthropic_api_key)
# No API key — try 9Router as fallback
+1 -1
View File
@@ -15,7 +15,7 @@ from backend.apps.analytics.collector import init as init_collector, shutdown as
logger = logging.getLogger(__name__)
APP_VERSION = "1.0.16"
APP_VERSION = "1.0.17"
_heartbeat_task: asyncio.Task | None = None
+25 -4
View File
@@ -76,11 +76,32 @@ def _find_node() -> str | None:
async def ensure_running():
"""Start 9Router if not already running."""
global _process
if is_running():
logger.info("9Router already running on port %d", NINE_ROUTER_PORT)
return
_is_packaged = os.environ.get("OPENSWARM_PACKAGED") == "1"
if is_running():
# In dev mode, kill stale standalone servers (from previous builds)
# so we can start `next dev` which always uses latest source code
if not _is_packaged:
import subprocess as _sp
try:
result = _sp.run(
["pgrep", "-f", "next-server"],
capture_output=True, text=True, timeout=3,
)
if result.stdout.strip():
logger.info("Dev mode: killing stale standalone 9Router to use next dev instead")
_sp.run(["pkill", "-f", "next-server"], timeout=5)
import asyncio
await asyncio.sleep(2)
else:
logger.info("9Router already running on port %d", NINE_ROUTER_PORT)
return
except Exception:
logger.info("9Router already running on port %d", NINE_ROUTER_PORT)
return
else:
logger.info("9Router already running on port %d", NINE_ROUTER_PORT)
return
_9router_dir = _find_9router_dir()
if _is_packaged and _9router_dir:
+11 -10
View File
@@ -77,9 +77,10 @@ def validate_credentials(settings: AppSettings, provider: str = "anthropic") ->
def get_provider_credentials(settings: AppSettings, provider: str) -> dict[str, str]:
"""Return credential dict for a specific provider."""
p = provider.lower().strip()
validate_credentials(settings, provider)
if provider == "anthropic":
if p in ("anthropic", "claude"):
if getattr(settings, "connection_mode", "own_key") == "managed":
return {
"auth_token": getattr(settings, "openswarm_auth_token", "") or "",
@@ -87,18 +88,18 @@ def get_provider_credentials(settings: AppSettings, provider: str) -> dict[str,
}
return {"api_key": settings.anthropic_api_key or ""}
if provider == "openai":
if p in ("openai", "codex"):
return {"api_key": settings.openai_api_key or ""}
if provider == "gemini":
if p in ("gemini", "google", "gemini-cli"):
return {"api_key": getattr(settings, "google_api_key", "") or ""}
if provider == "openrouter":
if p == "openrouter":
return {"api_key": getattr(settings, "openrouter_api_key", "") or ""}
# Custom provider
for cp in getattr(settings, "custom_providers", []):
if cp.name == provider:
if cp.name.lower() == p:
return {"api_key": cp.api_key, "base_url": cp.base_url}
raise ValueError(f"No credentials for provider: {provider}")
@@ -139,15 +140,15 @@ def get_anthropic_client(settings: AppSettings) -> anthropic.AsyncAnthropic:
base_url=proxy_url,
)
# Prefer 9Router subscription (free for users with Claude/ChatGPT/Gemini subscriptions)
# Prefer API key when set
if settings.anthropic_api_key:
return anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
# Fall back to 9Router subscription (free for users with Claude/ChatGPT/Gemini subscriptions)
if _check_9router():
return anthropic.AsyncAnthropic(
api_key="9router",
base_url="http://localhost:20128",
)
# Fall back to API key
if settings.anthropic_api_key:
return anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
raise ValueError("No AI provider configured. Set an API key or connect a subscription.")
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "openswarm",
"version": "1.0.15",
"version": "1.0.16",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openswarm",
"version": "1.0.15",
"version": "1.0.16",
"hasInstallScript": true,
"dependencies": {
"electron-updater": "^6.3.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "openswarm",
"version": "1.0.16",
"version": "1.0.17",
"description": "OpenSwarm — AI Agent Orchestrator",
"main": "main.js",
"scripts": {
+124 -17
View File
@@ -164,17 +164,36 @@ const SUBSCRIPTION_PROVIDERS = [
{ id: 'github', name: 'GitHub Copilot', desc: 'Claude + GPT models via your Copilot subscription', color: '#8B949E', preview: true },
];
const SubscriptionCard: React.FC<{ provider: typeof SUBSCRIPTION_PROVIDERS[0]; connected: boolean; onConnect: () => void; onDisconnect: () => void; connecting: boolean; userCode?: string }> = ({ provider, connected, onConnect, onDisconnect, connecting, userCode }) => {
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 }) => {
const c = useClaudeTokens();
const isPreview = (provider as any).preview;
return (
<Box sx={{ p: 1.5, borderRadius: `${c.radius.md}px`, border: `1px solid ${connected ? c.status.success + '30' : c.border.subtle}`, bgcolor: connected ? `${c.status.success}04` : 'transparent', opacity: isPreview ? 0.5 : 1 }}>
<Box sx={{
p: 1.5, borderRadius: `${c.radius.md}px`,
border: `1px solid ${connected ? c.status.success + '30' : connecting ? c.accent.primary + '30' : c.border.subtle}`,
bgcolor: connected ? `${c.status.success}04` : connecting ? `${c.accent.primary}04` : 'transparent',
opacity: isPreview ? 0.5 : 1,
transition: 'all 0.3s ease',
}}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: connected ? c.status.success : c.border.medium, flexShrink: 0 }} />
<Box sx={{
width: 8, height: 8, borderRadius: '50%', flexShrink: 0,
bgcolor: connected ? c.status.success : connecting ? c.accent.primary : c.border.medium,
transition: 'background-color 0.3s ease',
...(connecting ? {
animation: 'pulse-dot 1.5s ease-in-out infinite',
'@keyframes pulse-dot': {
'0%, 100%': { opacity: 1, transform: 'scale(1)' },
'50%': { opacity: 0.4, transform: 'scale(0.8)' },
},
} : {}),
}} />
<Box>
<Typography sx={{ fontSize: '0.78rem', fontWeight: 600, color: c.text.primary }}>{provider.name}</Typography>
<Typography sx={{ fontSize: '0.65rem', color: c.text.muted }}>{provider.desc}</Typography>
<Typography sx={{ fontSize: '0.65rem', color: connecting ? c.accent.primary : c.text.muted, transition: 'color 0.3s ease' }}>
{connecting ? 'Waiting for authorization...' : provider.desc}
</Typography>
</Box>
</Box>
{isPreview ? (
@@ -182,17 +201,26 @@ const SubscriptionCard: React.FC<{ provider: typeof SUBSCRIPTION_PROVIDERS[0]; c
Coming soon
</Typography>
) : connected ? (
<Typography onClick={onDisconnect} sx={{ fontSize: '0.68rem', color: c.text.tertiary, cursor: 'pointer', '&:hover': { color: c.status.error } }}>
Disconnect
</Typography>
disconnecting ? (
<CircularProgress size={14} sx={{ color: c.text.ghost }} />
) : (
<Typography onClick={onDisconnect} sx={{ fontSize: '0.68rem', color: c.text.tertiary, cursor: 'pointer', '&:hover': { color: c.status.error }, transition: 'color 0.2s ease' }}>
Disconnect
</Typography>
)
) : connecting && userCode ? (
<Box sx={{ textAlign: 'right' }}>
<Typography sx={{ fontSize: '0.68rem', color: c.text.muted }}>Enter code:</Typography>
<Typography sx={{ fontSize: '0.85rem', fontWeight: 700, color: c.accent.primary, fontFamily: 'monospace', letterSpacing: '0.1em' }}>{userCode}</Typography>
</Box>
) : connecting ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.8 }}>
<CircularProgress size={14} sx={{ color: c.accent.primary }} />
<Typography sx={{ fontSize: '0.68rem', color: c.accent.primary }}>Connecting...</Typography>
</Box>
) : (
<Button onClick={onConnect} disabled={connecting} variant="outlined" size="small" sx={{ textTransform: 'none', fontSize: '0.7rem', color: c.text.primary, borderColor: c.border.medium, minWidth: 70, '&:hover': { borderColor: c.accent.primary } }}>
{connecting ? 'Waiting...' : 'Connect'}
<Button onClick={onConnect} variant="outlined" size="small" sx={{ textTransform: 'none', fontSize: '0.7rem', color: c.text.primary, borderColor: c.border.medium, minWidth: 70, '&:hover': { borderColor: c.accent.primary }, transition: 'all 0.2s ease' }}>
Connect
</Button>
)}
</Box>
@@ -204,6 +232,7 @@ const SubscriptionCards: React.FC = () => {
const c = useClaudeTokens();
const [status, setStatus] = useState<any>(null);
const [connecting, setConnecting] = useState<string | null>(null);
const [disconnecting, setDisconnecting] = useState<string | null>(null);
const [userCode, setUserCode] = useState('');
const [pollTimer, setPollTimer] = useState<any>(null);
@@ -323,6 +352,7 @@ const SubscriptionCards: React.FC = () => {
};
const handleDisconnect = async (providerId: string) => {
setDisconnecting(providerId);
try {
await fetch(`${API_BASE}/agents/subscriptions/disconnect`, {
method: 'POST',
@@ -331,13 +361,36 @@ const SubscriptionCards: React.FC = () => {
});
} catch {}
// Wait briefly for 9Router to process, then refresh
setTimeout(fetchStatus, 500);
setTimeout(() => { fetchStatus(); setDisconnecting(null); }, 500);
};
if (!status) {
// Initial loading — show skeleton cards
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{SUBSCRIPTION_PROVIDERS.map(p => (
<Box key={p.id} sx={{
p: 1.5, borderRadius: `${c.radius.md}px`, border: `1px solid ${c.border.subtle}`,
display: 'flex', alignItems: 'center', gap: 1,
animation: 'skeleton-pulse 1.5s ease-in-out infinite',
'@keyframes skeleton-pulse': { '0%, 100%': { opacity: 0.5 }, '50%': { opacity: 0.25 } },
}}>
<Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: c.border.medium, flexShrink: 0 }} />
<Box sx={{ flex: 1 }}>
<Box sx={{ width: 100, height: 12, bgcolor: c.border.subtle, borderRadius: 1, mb: 0.5 }} />
<Box sx={{ width: 180, height: 10, bgcolor: c.border.subtle, borderRadius: 1 }} />
</Box>
</Box>
))}
</Box>
);
}
if (!status?.running) {
return (
<Box sx={{ p: 2, borderRadius: `${c.radius.md}px`, border: `1px solid ${c.border.subtle}`, textAlign: 'center' }}>
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted, mb: 1 }}>
<CircularProgress size={18} sx={{ color: c.text.ghost, mb: 1 }} />
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted, mb: 0.5 }}>
Starting subscription service...
</Typography>
<Typography sx={{ fontSize: '0.65rem', color: c.text.ghost }}>
@@ -357,6 +410,7 @@ const SubscriptionCards: React.FC = () => {
onConnect={() => handleConnect(p.id)}
onDisconnect={() => handleDisconnect(p.id)}
connecting={connecting === p.id}
disconnecting={disconnecting === p.id}
userCode={connecting === p.id ? userCode : undefined}
/>
))}
@@ -401,7 +455,59 @@ const UsageStats: React.FC = () => {
.catch(() => {});
}, []);
if (!stats) return null;
if (!stats) {
// Skeleton loading state
const skeletonPulse = {
animation: 'skeleton-pulse 1.5s ease-in-out infinite',
'@keyframes skeleton-pulse': { '0%, 100%': { opacity: 0.5 }, '50%': { opacity: 0.25 } },
};
const skeletonCard = {
p: 1.5, borderRadius: `${c.radius.md}px`, bgcolor: c.bg.elevated,
border: `1px solid ${c.border.subtle}`, ...skeletonPulse,
};
return (
<Box sx={{ mb: 2.5 }}>
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 1, mb: 1 }}>
{Array.from({ length: 4 }, (_, i) => (
<Box key={i} sx={skeletonCard}>
<Box sx={{ width: 60, height: 8, bgcolor: c.border.subtle, borderRadius: 1, mb: 1 }} />
<Box sx={{ width: 50, height: 18, bgcolor: c.border.subtle, borderRadius: 1, mb: 0.5 }} />
<Box sx={{ width: 90, height: 8, bgcolor: c.border.subtle, borderRadius: 1 }} />
</Box>
))}
</Box>
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 1, mb: 1.5 }}>
{Array.from({ length: 4 }, (_, i) => (
<Box key={i} sx={skeletonCard}>
<Box sx={{ width: 70, height: 8, bgcolor: c.border.subtle, borderRadius: 1, mb: 1 }} />
<Box sx={{ width: 45, height: 18, bgcolor: c.border.subtle, borderRadius: 1, mb: 0.5 }} />
<Box sx={{ width: 80, height: 8, bgcolor: c.border.subtle, borderRadius: 1 }} />
</Box>
))}
</Box>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1.5 }}>
{Array.from({ length: 2 }, (_, i) => (
<Box key={i} sx={{ ...skeletonCard, p: 2 }}>
<Box sx={{ width: 80, height: 8, bgcolor: c.border.subtle, borderRadius: 1, mb: 2 }} />
{Array.from({ length: 3 }, (_, j) => (
<Box key={j} sx={{ mb: 1.5 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
<Box sx={{ width: 60 + j * 15, height: 10, bgcolor: c.border.subtle, borderRadius: 1 }} />
<Box sx={{ width: 35, height: 10, bgcolor: c.border.subtle, borderRadius: 1 }} />
</Box>
<Box sx={{ display: 'flex', gap: '1px' }}>
{Array.from({ length: 16 }, (_, k) => (
<Box key={k} sx={{ width: 5, height: 5, bgcolor: c.border.subtle, opacity: k < 8 - j * 2 ? 0.6 : 0.2 }} />
))}
</Box>
</Box>
))}
</Box>
))}
</Box>
</Box>
);
}
const formatCost = (v: number) => {
if (v === 0) return '$0.00';
@@ -757,11 +863,12 @@ const Settings: React.FC = () => {
PaperProps={{
sx: {
width: 780,
maxHeight: '85vh',
height: '85vh',
bgcolor: c.bg.page,
borderRadius: 2,
border: `1px solid ${c.border.subtle}`,
boxShadow: c.shadow.md,
transition: 'none',
},
}}
>
@@ -814,7 +921,7 @@ const Settings: React.FC = () => {
scrollbarColor: `${c.border.medium} transparent`,
}}>
{activeTab === 'general' ? (
<Box sx={{ display: 'flex', flexDirection: 'column', pt: 2.5, pb: 1 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', pt: 2.5, pb: 1, animation: 'fadeIn 0.2s ease', '@keyframes fadeIn': { from: { opacity: 0 }, to: { opacity: 1 } } }}>
{/* ── Agent Defaults ── */}
<Typography sx={sectionSx}>Agent Defaults</Typography>
@@ -1283,7 +1390,7 @@ const Settings: React.FC = () => {
</Box>
) : activeTab === 'models' ? (
<Box sx={{ display: 'flex', flexDirection: 'column', pt: 2.5, pb: 1, gap: 2.5 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', pt: 2.5, pb: 1, gap: 2.5, animation: 'fadeIn 0.2s ease', '@keyframes fadeIn': { from: { opacity: 0 }, to: { opacity: 1 } } }}>
{/* ── USE EXISTING SUBSCRIPTIONS ── */}
<Typography sx={{ fontSize: '0.7rem', color: c.text.ghost, textTransform: 'uppercase', letterSpacing: '0.05em', fontWeight: 600 }}>
@@ -1347,11 +1454,11 @@ const Settings: React.FC = () => {
</Box>
) : activeTab === 'usage' ? (
<Box sx={{ display: 'flex', flexDirection: 'column', pt: 2.5, pb: 1 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', pt: 2.5, pb: 1, animation: 'fadeIn 0.2s ease', '@keyframes fadeIn': { from: { opacity: 0 }, to: { opacity: 1 } } }}>
<UsageStats />
</Box>
) : (
<Box sx={{ pt: 2.5, pb: 1 }}>
<Box sx={{ pt: 2.5, pb: 1, animation: 'fadeIn 0.2s ease', '@keyframes fadeIn': { from: { opacity: 0 }, to: { opacity: 1 } } }}>
<CommandsContent />
</Box>
)}