[eric] multi-model subscription support: connect ChatGPT Plus, Gemini Advanced, and GitHub Copilot subscriptions via 9Router

- Add BUILTIN_MODELS for OpenAI (GPT-5.4/Mini/5.3-Codex), Google (Gemini 3 Pro/Flash, 2.5 Pro/Flash), and GitHub Copilot
  - Add resolve_model_id_for_sdk() and resolve_aux_model() to route prefixed model IDs (cx/, gc/, gh/) through 9Router translator
  - Add GET /agents/models endpoint returning available models based on live 9Router connection state
  - Enable ENABLE_TOOL_SEARCH=auto for all providers so non-Claude models get full tool access (23 built-in + all MCPs)
  - Add thinking block streaming support (ThinkingBlock in stream handler + AssistantMessage) for reasoning models
  - Fix Gemini 3 thought-signature errors: use skip_thought_signature_validator per Google docs
  - Fix WebSearch blocked_domains/allowed_domains empty-list rejection on Anthropic API
  - Fix browser_agent text_parts UnboundLocalError on non-Claude models
  - Fix auxiliary LLM calls (title gen, group meta, dashboard naming, view builder, browser agent) via resolve_aux_model
  - Add Codex OAuth callback listener on port 1455 for ChatGPT Plus subscription connect
  - Route Gemini OAuth through system browser since Google blocks embedded webviews
  - Override Electron popup user-agent for OAuth
  - Fix duplicate OAuth callback with idempotent completed_oauth tracking
  - Fix Settings modal tab routing and stale warning banner
  - Add API key inputs for OpenAI, Google, and OpenRouter
  - Add MCP warning banner when selecting non-Claude model with many tools
  - Force session fork on cross-provider model switch to prevent transcript corruption
  - Disable GitHub Copilot subscription card (9Router poll issue, marked preview)
  - Delete dead CopilotProvider import and unused CopilotAuthButton component
This commit is contained in:
ciregenz
2026-04-12 12:06:36 -07:00
parent 8f28efd668
commit 4778cea80e
16 changed files with 1286 additions and 267 deletions
@@ -173,6 +173,23 @@ export function prepareClaudeRequest(body, provider = null, apiKey = null) {
body.tools = body.tools.filter(tool => !tool.type || tool.type === "function");
}
// Fix: Anthropic's API rejects empty domain lists on the web_search
// tool with "Empty list of domains is ambiguous". The Claude Code CLI
// sends both `blocked_domains: []` and `allowed_domains: []` meaning
// "no restrictions", but the API wants the fields omitted entirely
// when empty. Clean up here so the fix applies to both direct-API
// and 9Router-subscription paths.
for (const tool of body.tools) {
if (tool.type && tool.type.startsWith("web_search")) {
if (Array.isArray(tool.blocked_domains) && tool.blocked_domains.length === 0) {
delete tool.blocked_domains;
}
if (Array.isArray(tool.allowed_domains) && tool.allowed_domains.length === 0) {
delete tool.allowed_domains;
}
}
}
body.tools = body.tools.map((tool, i) => {
const { cache_control, ...rest } = tool;
if (i === body.tools.length - 1) {
@@ -87,14 +87,27 @@ function openaiToGeminiBase(model, body, stream) {
} else if (role === "assistant") {
const parts = [];
// Thinking/reasoning → thought part with signature
// Gemini 3 models require a valid per-session `thoughtSignature`
// on thinking blocks and function calls. Real signatures are lost
// during the Gemini→OpenAI→Claude→OpenAI→Gemini translation
// round-trip (no intermediate format has a field for them).
//
// Google's official workaround for proxy/translation layers:
// set thoughtSignature to the literal "skip_thought_signature_validator"
// which bypasses validation. Per Google's docs this is a "last
// resort" that "negatively impacts model performance" because the
// model can't build on its prior reasoning across turns — but it
// lets multi-turn tool use work through any translation layer.
// See: https://docs.cloud.google.com/vertex-ai/generative-ai/docs/thought-signatures
const SKIP_SIG = "skip_thought_signature_validator";
if (msg.reasoning_content) {
parts.push({
thought: true,
text: msg.reasoning_content
});
parts.push({
thoughtSignature: DEFAULT_THINKING_GEMINI_SIGNATURE,
thoughtSignature: SKIP_SIG,
text: ""
});
}
@@ -113,7 +126,7 @@ function openaiToGeminiBase(model, body, stream) {
const args = tryParseJSON(tc.function?.arguments || "{}");
parts.push({
thoughtSignature: DEFAULT_THINKING_GEMINI_SIGNATURE,
thoughtSignature: SKIP_SIG,
functionCall: {
id: tc.id,
name: tc.function.name,
@@ -213,21 +226,26 @@ export function openaiToGeminiCLIRequest(model, body, stream) {
const gemini = openaiToGeminiBase(model, body, stream);
const isClaude = model.toLowerCase().includes("claude");
// Add thinking config for CLI
// Pass through thinking config from the incoming request. Gemini 3
// models have thinking always-on (can't be disabled per Google's docs).
// The thought-signature round-trip issue is handled by using
// "skip_thought_signature_validator" on all function call and thinking
// parts in the conversation history (see assistant message handling
// above at lines ~87-125). This lets thinking work with the trade-off
// that the model can't build on prior reasoning across turns.
if (body.reasoning_effort) {
const budgetMap = { low: 1024, medium: 8192, high: 32768 };
const budget = budgetMap[body.reasoning_effort] || 8192;
gemini.generationConfig.thinkingConfig = {
thinkingBudget: budget,
include_thoughts: true
includeThoughts: true
};
}
// Thinking config from Claude format
if (body.thinking?.type === "enabled" && body.thinking.budget_tokens) {
gemini.generationConfig.thinkingConfig = {
thinkingBudget: body.thinking.budget_tokens,
include_thoughts: true
includeThoughts: true
};
}
+170 -34
View File
@@ -596,7 +596,7 @@ class AgentManager:
)
from claude_agent_sdk.types import (
HookMatcher, PermissionResultAllow, PermissionResultDeny,
TextBlock, ToolUseBlock, StreamEvent,
TextBlock, ToolUseBlock, ThinkingBlock, StreamEvent,
SystemMessage,
)
except ImportError:
@@ -605,7 +605,19 @@ class AgentManager:
return
session.status = "running"
# Resolve the model id now so every closure (approval hook, tool.executed
# event, etc.) can tag analytics events with both the short name and
# the 9Router-prefixed id. This lets downstream dashboards correlate
# session-level stats (`session.model` = short name) with 9Router's
# per-model usage stats (keyed by the router_model_id).
from backend.apps.agents.providers.registry import (
resolve_model_id_for_sdk as _resolve_model_id_early,
get_api_type as _get_api_type_early,
)
_router_model_id = _resolve_model_id_early(session.model, load_settings())
_api_type_for_session = _get_api_type_early(session.model)
_builtin_perms = load_builtin_permissions()
def _get_effective_policy(tool_name: str) -> str:
@@ -650,6 +662,8 @@ class AgentManager:
"tool_name": tool_name,
"is_first_approval_in_session": len(session.pending_approvals) == 1,
"model": session.model,
"router_model_id": _router_model_id,
"api_type": _api_type_for_session,
}, session_id=session_id, dashboard_id=session.dashboard_id)
await ws_manager.send_to_session(session_id, "agent:status", {
@@ -668,6 +682,8 @@ class AgentManager:
"latency_ms": approval_latency_ms,
"input_was_modified": decision.get("updated_input") is not None,
"model": session.model,
"router_model_id": _router_model_id,
"api_type": _api_type_for_session,
}, session_id=session_id, dashboard_id=session.dashboard_id)
session.pending_approvals = [
@@ -777,6 +793,8 @@ class AgentManager:
"success": _tool_success,
"model": session.model,
"provider": session.provider,
"router_model_id": _router_model_id,
"api_type": _api_type_for_session,
}, session_id=session_id, dashboard_id=session.dashboard_id)
if isinstance(raw_response, list) and raw_response:
@@ -1002,8 +1020,16 @@ class AgentManager:
if effective_disallowed:
logger.info(f"[MCP-DEBUG] effective_disallowed: {effective_disallowed}")
# `_router_model_id` and `_api_type_for_session` were resolved
# at the top of _run_agent_loop (before any closures were
# defined) so analytics closures could tag events with them.
# Reuse those values here and keep session.provider in sync.
resolved_model = _router_model_id
api_type = _api_type_for_session
session.provider = api_type
options_kwargs = {
"model": session.model,
"model": resolved_model,
"max_buffer_size": 5 * 1024 * 1024,
"permission_mode": "default",
"can_use_tool": can_use_tool,
@@ -1015,37 +1041,66 @@ class AgentManager:
"disallowed_tools": effective_disallowed,
"include_partial_messages": True,
}
# Priority: API key → 9Router subscription
# Priority: Anthropic API key (Anthropic models only) → 9Router.
# Non-Anthropic api_types always route through 9Router regardless
# of whether an Anthropic API key is set.
from backend.apps.nine_router import is_running as _9r_running
if global_settings.anthropic_api_key:
if api_type == "anthropic" and global_settings.anthropic_api_key:
options_kwargs["env"] = {"ANTHROPIC_API_KEY": global_settings.anthropic_api_key}
logger.info("[MCP-DEBUG] Using direct API key")
logger.info("[MCP-DEBUG] Using direct Anthropic API key")
elif _9r_running():
options_kwargs["env"] = {
env = {
"ANTHROPIC_API_KEY": "9router",
"ANTHROPIC_BASE_URL": "http://localhost:20128",
# The bundled CLI auto-disables tool search when
# ANTHROPIC_BASE_URL isn't a first-party Anthropic host.
# Without tool search, the entire deferred-tool pool
# (WebSearch, NotebookEdit, TodoWrite, EnterPlanMode,
# Cron*, Task*, etc.) becomes unreachable. Force-enable
# in `auto` mode so the CLI surfaces them through the
# ToolSearch loader. 9Router is a transparent SSE proxy
# so tool_reference content blocks pass through intact.
#
# NOTE on context bloat: in `auto` mode, MCPs and
# deferred builtins are still loaded eagerly when the
# deferred-tool tokens are below ~10% of the model's
# context window. Setting this to "true" instead would
# force-enable tool search but the CLI's internal
# `tengu_defer_all_bn4` Statsig flag (defaults to true
# outside Anthropic's first-party network) then defers
# ALL non-core tools including Read/Edit/Bash, leaving
# the model with effectively zero tools. Until we have
# a way to override that Statsig flag from outside the
# binary, "auto" is the only working setting.
"ENABLE_TOOL_SEARCH": "auto",
}
# ENABLE_TOOL_SEARCH=auto is Claude-specific. It keeps the
# deferred-tool pool (WebSearch, NotebookEdit, TodoWrite,
# EnterPlanMode, Cron*, Task*, etc.) reachable via the
# ToolSearch loader when the CLI is pointed at a non-first-
# party host — otherwise the CLI auto-disables tool search.
#
# For non-Claude models the same flag is actively dangerous:
# the CLI would still inject a ToolSearch reference block
# into the system prompt, and GPT/Gemini may (a) call
# ToolSearch with hallucinated arguments, (b) ignore it and
# lose the base tool set, or (c) loop on failed calls. Drop
# the flag for non-Anthropic so the CLI eagerly loads the
# base Read/Edit/Bash/WebSearch set into the system prompt
# instead of deferring it.
#
# NOTE on context bloat (Claude path): in `auto` mode MCPs
# and deferred builtins are loaded eagerly when the
# deferred-tool tokens are below ~10% of the model's context
# window. Setting this to "true" would force-enable tool
# search but the CLI's internal `tengu_defer_all_bn4`
# Statsig flag (defaults to true outside Anthropic's first-
# party network) then defers ALL non-core tools including
# Read/Edit/Bash, leaving the model with effectively zero
# tools. Until we have a way to override that Statsig flag
# from outside the binary, "auto" is the only working
# setting for Claude.
# Enable ToolSearch for ALL providers, not just Anthropic.
# Without this flag the CLI's internal `tengu_defer_all_bn4`
# Statsig flag (default ON outside Anthropic's network) defers
# all non-core tools (WebSearch, WebFetch, TodoWrite,
# NotebookEdit, EnterPlanMode, Task*, Cron*, Agent, etc.)
# with no way to load them — making 16 tools completely
# inaccessible on non-Anthropic models.
#
# With "auto", the CLI eagerly loads tools when the schema
# budget fits within ~10% of context, and defers the rest
# behind ToolSearch. Frontier models (GPT-5.3 Codex,
# Gemini 3 Pro) can follow the ToolSearch instructions in
# the system prompt to load deferred tools on demand.
# OpenClaw (open-source Claude Code alternative) validates
# this approach — they load ALL tools upfront for every
# provider with no deferral at all.
#
# Original concern was hallucinated ToolSearch calls from
# non-Claude models, but in practice frontier models handle
# structured tool-call instructions reliably.
env["ENABLE_TOOL_SEARCH"] = "auto"
options_kwargs["env"] = env
# NOTE: do NOT pass `--bare`. It internally sets
# CLAUDE_CODE_SIMPLE=1, which short-circuits the default
# Claude Code system prompt to a `"You are Claude Code"`
@@ -1054,9 +1109,29 @@ class AgentManager:
# from env first (before OAuth/keychain), so the original
# goal of bare mode (skip OAuth/keychain) is preserved as
# long as ANTHROPIC_API_KEY is set above — which it is.
logger.info("[MCP-DEBUG] Using 9Router")
logger.info(f"[MCP-DEBUG] Using 9Router (api_type={api_type})")
else:
raise ValueError("No AI provider configured. Set an API key or connect a subscription.")
# 9Router is not up yet. For non-Anthropic api_types there
# is no API-key fallback, so wait for 9Router to start
# before giving up. ensure_running has its own 30s timeout.
if api_type != "anthropic":
from backend.apps.nine_router import ensure_running as _9r_ensure
logger.info(f"[MCP-DEBUG] 9Router not running for non-Anthropic model {session.model}; waiting for startup")
await _9r_ensure()
if _9r_running():
options_kwargs["env"] = {
"ANTHROPIC_API_KEY": "9router",
"ANTHROPIC_BASE_URL": "http://localhost:20128",
}
logger.info(f"[MCP-DEBUG] 9Router started; routing {session.model} via 9Router")
else:
raise ValueError(
f"9Router is not running; cannot use {session.model}. "
"Install Node.js and restart the app, or switch to a model "
"with a direct API key."
)
else:
raise ValueError("No AI provider configured. Set an API key or connect a subscription.")
if mcp_servers:
options_kwargs["mcp_servers"] = mcp_servers
mcp_json_len = len(json.dumps({"mcpServers": mcp_servers}))
@@ -1105,7 +1180,7 @@ class AgentManager:
elif isinstance(prompt_content, list):
prompt_content.insert(0, {"type": "text", "text": history})
logger.info(f"[MCP-DEBUG] Creating ClaudeAgentOptions with model={session.model}")
logger.info(f"[MCP-DEBUG] Creating ClaudeAgentOptions short={session.model} resolved={resolved_model} api_type={api_type}")
options = ClaudeAgentOptions(**options_kwargs)
logger.info(f"[MCP-DEBUG] ClaudeAgentOptions created. Starting query...")
@@ -1153,6 +1228,22 @@ class AgentManager:
})
stream_block_index_map[index] = stream_text_msg_id
elif block_type == "thinking":
# Reasoning trace from thinking-capable models
# (GPT-5.3 Codex, Gemini 3 Pro/Flash, Claude
# with extended thinking). Rendered as a
# collapsible "thinking" message in the UI via
# the existing stream infrastructure — the
# frontend already handles role="thinking" for
# the DynamicIsland/agent card rendering.
thinking_msg_id = uuid4().hex
stream_block_index_map[index] = thinking_msg_id
await ws_manager.send_to_session(session_id, "agent:stream_start", {
"session_id": session_id,
"message_id": thinking_msg_id,
"role": "thinking",
})
elif block_type == "tool_use":
tool_msg_id = uuid4().hex
stream_tool_msg_ids_ordered.append(tool_msg_id)
@@ -1176,6 +1267,14 @@ class AgentManager:
"message_id": msg_id,
"delta": delta.get("text", ""),
})
elif msg_id and delta_type == "thinking_delta":
# Thinking content streams as thinking_delta
# with a "thinking" field (not "text")
await ws_manager.send_to_session(session_id, "agent:stream_delta", {
"session_id": session_id,
"message_id": msg_id,
"delta": delta.get("thinking", ""),
})
elif msg_id and delta_type == "input_json_delta":
await ws_manager.send_to_session(session_id, "agent:stream_delta", {
"session_id": session_id,
@@ -1201,9 +1300,14 @@ class AgentManager:
elif isinstance(message, AssistantMessage):
content_parts = []
thinking_parts = []
tool_uses = []
for block in message.content:
if isinstance(block, TextBlock):
if isinstance(block, ThinkingBlock):
thinking_text = getattr(block, "thinking", None) or getattr(block, "text", None) or ""
if thinking_text:
thinking_parts.append(thinking_text)
elif isinstance(block, TextBlock):
content_parts.append(block.text)
elif isinstance(block, ToolUseBlock):
tool_uses.append({
@@ -1212,6 +1316,21 @@ class AgentManager:
"input": block.input,
})
# Emit thinking trace as a separate message so the
# frontend can render it as a collapsible reasoning
# bubble (GPT-5.3 Codex, Gemini 3 Pro/Flash).
if thinking_parts:
thinking_msg = Message(
role="thinking",
content="\n".join(thinking_parts),
branch_id=session.active_branch_id,
)
session.messages.append(thinking_msg)
await ws_manager.send_to_session(session_id, "agent:message", {
"session_id": session_id,
"message": thinking_msg.model_dump(mode="json"),
})
if content_parts:
asst_msg = Message(
id=stream_text_msg_id or uuid4().hex,
@@ -1469,6 +1588,19 @@ class AgentManager:
session_changed = False
if model and model != session.model:
# Cross-provider model switches force a session fork. The CLI's
# resume transcript stores Anthropic-format content blocks with
# Anthropic tool_use_ids; replaying them on a non-Anthropic
# provider via 9Router's claude→openai translator corrupts
# history silently (fixMissingToolResponses stubs missing tool
# responses with placeholder text). Forking starts a new CLI
# session so history is re-sent fresh in whichever format the
# new provider expects.
from backend.apps.agents.providers.registry import get_api_type as _get_api_type_for_model
if _get_api_type_for_model(session.model) != _get_api_type_for_model(model):
session.needs_fork = True
logger.info(f"[MCP-DEBUG] Forking session: api_type changed {session.model}{model}")
_analytics("model.switched", {
"from_model": session.model,
"to_model": model,
@@ -1706,7 +1838,9 @@ class AgentManager:
title = first_prompt[:40].strip()
try:
from backend.apps.settings.credentials import get_anthropic_client
from backend.apps.agents.providers.registry import resolve_aux_model
global_settings = load_settings()
aux_model, _aux_base = await resolve_aux_model(global_settings, preferred_tier="haiku")
client = get_anthropic_client(global_settings)
system_prompt = (
"You label user messages with a 2-4 word topic title. "
@@ -1727,7 +1861,7 @@ class AgentManager:
f"<message>\n{first_prompt}\n</message>"
)
resp = await client.messages.create(
model="claude-haiku-4-5-20251001",
model=aux_model,
max_tokens=20,
system=system_prompt,
messages=[{"role": "user", "content": user_turn}],
@@ -1767,7 +1901,9 @@ class AgentManager:
try:
import json as _json
from backend.apps.settings.credentials import get_anthropic_client
from backend.apps.agents.providers.registry import resolve_aux_model
global_settings = load_settings()
aux_model, _aux_base = await resolve_aux_model(global_settings, preferred_tier="sonnet")
client = get_anthropic_client(global_settings)
tool_desc = "\n".join(
@@ -1801,7 +1937,7 @@ class AgentManager:
)
resp = await client.messages.create(
model="claude-sonnet-4-20250514",
model=aux_model,
max_tokens=300,
system=system,
messages=[{"role": "user", "content": user_content}],
+57
View File
@@ -280,6 +280,63 @@ async def subscriptions_models():
return {"models": models}
@agents.router.get("/models")
async def list_models():
"""Return the chat-picker model list grouped by provider.
Intersects BUILTIN_MODELS with runtime availability:
- Anthropic models are visible if an API key is set OR 9Router has the
`claude` subscription connected.
- Subscription-only models (OpenAI/Google/Copilot routed via 9Router's
cx/gc/gh prefixes) are visible only when 9Router is up AND that
provider has an active connection.
The frontend already calls this endpoint from
frontend/src/shared/state/modelsSlice.ts:24 and falls back to hardcoded
Claude entries on failure, so the response shape is
`{"models": {"provider_name": [{value, label, context_window}, ...]}}`.
"""
from backend.apps.agents.providers.registry import BUILTIN_MODELS
from backend.apps.nine_router import is_running as _9r_running, get_providers as _9r_providers
from backend.apps.settings.settings import load_settings
settings = load_settings()
nine_router_up = _9r_running()
connected: set[str] = set()
if nine_router_up:
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")}
except Exception as e:
logger.debug(f"Failed to fetch 9Router providers: {e}")
result: dict[str, list[dict]] = {}
for provider_name, models in BUILTIN_MODELS.items():
visible = []
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
visible.append({
"value": m["value"],
"label": m["label"],
"context_window": m.get("context_window", 128_000),
})
if visible:
result[provider_name] = visible
return {"models": result}
@agents.router.post("/subscriptions/disconnect")
async def subscriptions_disconnect(body: dict):
"""Disconnect a subscription provider via 9Router."""
+53 -2
View File
@@ -877,10 +877,55 @@ async def run_browser_agent(
)
logger.info(f"Browser agent {session_id}: navigated to {initial_url}: {nav_result.get('text', nav_result.get('error', ''))}")
api_model = MODEL_MAP.get(model, model)
from backend.apps.settings.settings import load_settings
from backend.apps.settings.credentials import get_anthropic_client
client = get_anthropic_client(load_settings())
from backend.apps.agents.providers.registry import (
_find_builtin_model,
resolve_model_id_for_sdk,
resolve_aux_model,
)
browser_settings = load_settings()
# Resolve the model string to whatever the SDK / 9Router expects.
# When the parent session is running on a non-Claude model (e.g. gpt-5.4),
# the browser agent inherits it and we route through 9Router's prefix.
# Tool-use fidelity for browser-specific tools (BrowserNavigate, click,
# type, etc.) through 9Router's claude→openai translator is UNVERIFIED —
# if translation is poor, the user should manually switch this session
# back to Claude in the model picker.
if _find_builtin_model(model) is not None:
api_model = resolve_model_id_for_sdk(model, browser_settings)
else:
# Unknown model string — fall back to whatever aux model is available
try:
api_model, _ = await resolve_aux_model(browser_settings, preferred_tier="haiku")
except ValueError:
# Nothing connected at all — surface a clear error so the caller
# (parent agent) sees it in the tool result instead of crashing
# on a 400 from 9Router.
session.status = "error"
error_text = (
"Browser agent requires an active LLM subscription. "
"Connect Claude, Codex, or Gemini in Settings."
)
err_msg = Message(role="system", content=f"Error: {error_text}")
session.messages.append(err_msg)
await ws_manager.send_to_session(session_id, "agent:message", {
"session_id": session_id,
"message": err_msg.model_dump(mode="json"),
})
await ws_manager.send_to_session(session_id, "agent:status", {
"session_id": session_id,
"status": "error",
"session": session.model_dump(mode="json"),
})
return {
"session_id": session_id,
"browser_id": browser_id,
"summary": f"Error: {error_text}",
"action_log": [],
"final_screenshot": None,
}
client = get_anthropic_client(browser_settings)
# Resume prior conversation on this browser if we have one cached. This
# lets the sub-agent skip the "take a screenshot to figure out where I am"
@@ -923,6 +968,7 @@ async def run_browser_agent(
return None
return task.result()
text_parts = [] # initialized before loop so post-loop summary (line ~1294) has a default
try:
for turn in range(MAX_TURNS):
if cancel_event.is_set():
@@ -937,6 +983,11 @@ async def run_browser_agent(
))
if response is None:
break
# Guard against empty content (e.g. upstream API error from
# 9Router that the SDK parsed into a partial response object).
if not response.content:
logger.warning(f"Browser agent {session_id}: empty response content from {api_model}")
break
# Track token usage from browser agent API calls
if hasattr(response, 'usage') and response.usage:
+1 -1
View File
@@ -29,7 +29,7 @@ class ApprovalResponse(BaseModel):
class Message(BaseModel):
id: str = Field(default_factory=lambda: uuid4().hex)
role: Literal["user", "assistant", "tool_call", "tool_result", "system"]
role: Literal["user", "assistant", "tool_call", "tool_result", "system", "thinking"]
content: Any # str or list of content blocks
timestamp: datetime = Field(default_factory=datetime.now)
branch_id: str = "main"
+231 -42
View File
@@ -1,9 +1,14 @@
"""Provider factory and model registry.
"""Provider registry and model catalog.
Two-tier system:
1. Built-in providers (Anthropic, OpenAI, Gemini) with curated model lists
2. User-configured custom providers (any OpenAI-compatible endpoint)
- Includes built-in OpenRouter integration for 300+ models
NOTE: `create_provider`, `BaseProvider`, `AnthropicProvider`, `OpenAICompatProvider`,
and the native `AgentLoop` are currently unused. The live agent path is
`claude_agent_sdk` via `agent_manager._run_agent_loop`. Kept as a foundation
for a potential future native multi-provider loop.
Multi-model subscription support routes non-Anthropic models through 9Router's
`/v1/messages` endpoint by passing prefixed model IDs (e.g. `cx/gpt-5.4`,
`gc/gemini-2.5-pro`, `gh/claude-sonnet-4`). 9Router's translator converts the
Anthropic-format request into the provider's native format transparently.
"""
from __future__ import annotations
@@ -21,12 +26,107 @@ logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Tier 1: Built-in models (curated, we know their quirks)
# ---------------------------------------------------------------------------
#
# Fields:
# value — short internal name stored on AgentSession.model
# label — display name in the model picker
# context_window — tokens
# model_id — bare model string for direct API calls (Anthropic key path)
# router_model_id — prefixed string for 9Router routing (cc/, cx/, gc/, gh/)
# api — "anthropic" | "codex" | "gemini-cli" | "github-copilot"
# subscription_only— True means hidden from picker unless 9Router has that
# provider actively connected
# reasoning — True for models that emit Anthropic `thinking` content
# blocks via 9Router's translator. OpenSwarm's stream
# handler at agent_manager.py:1141-1165 does not yet
# render these blocks — final text still appears but
# the reasoning trace is silently dropped. Tracked as
# a follow-up; add a `thinking` case to the handler
# to surface the trace.
#
# Model IDs match 9Router's internal routing catalog at
# 9router/src/shared/constants/pricing.js. Each provider has a distinct
# model-name convention:
# - cc/ (Claude Code subscription) uses dash-notation: claude-sonnet-4-6
# - cx/ (OpenAI Codex subscription) uses dot-notation with -codex suffix.
# Note: `gpt-5.4` is NOT available on this path — it's API-key-only.
# The Codex subscription's flagship is gpt-5.3-codex.
# - gc/ (Gemini CLI subscription) uses gemini-3-pro-preview / 3-flash-preview
# (thinking-capable) and gemini-2.5-pro / 2.5-flash (stable).
# Gemini 3 thought signatures handled via skip_thought_signature_validator.
# - gh/ (GitHub Copilot) uses dot-notation (claude-sonnet-4.6 not
# claude-sonnet-4-6) because Copilot has its own model catalog
# independent from Anthropic's API naming.
BUILTIN_MODELS: dict[str, list[dict[str, Any]]] = {
# Anthropic: current-gen trio. Sonnet 4.6 (Feb 17 2026), Opus 4.6
# (Feb 5 2026), Haiku 4.5 (Oct 2025). All three are the current
# production flagships in their respective size tiers.
"Anthropic": [
{"value": "sonnet", "label": "Claude Sonnet 4.6", "context_window": 1_000_000, "model_id": "claude-sonnet-4-6", "api": "anthropic"},
{"value": "opus", "label": "Claude Opus 4.6", "context_window": 1_000_000, "model_id": "claude-opus-4-6", "api": "anthropic"},
{"value": "haiku", "label": "Claude Haiku 4.5", "context_window": 200_000, "model_id": "claude-haiku-4-5", "api": "anthropic"},
{"value": "sonnet", "label": "Claude Sonnet 4.6", "context_window": 1_000_000,
"model_id": "claude-sonnet-4-6", "router_model_id": "cc/claude-sonnet-4-6", "api": "anthropic"},
{"value": "opus", "label": "Claude Opus 4.6", "context_window": 1_000_000,
"model_id": "claude-opus-4-6", "router_model_id": "cc/claude-opus-4-6", "api": "anthropic"},
{"value": "haiku", "label": "Claude Haiku 4.5", "context_window": 200_000,
"model_id": "claude-haiku-4-5", "router_model_id": "cc/claude-haiku-4-5-20251001", "api": "anthropic"},
],
# OpenAI: ChatGPT Plus/Pro (Codex) subscription. gpt-5.4 is the
# current flagship — combines GPT-5.3 Codex coding capabilities with
# 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)",
"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)",
"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)",
"context_window": 400_000, "router_model_id": "cx/gpt-5.3-codex",
"api": "codex", "subscription_only": True, "reasoning": True},
],
# Google: Gemini via Gemini CLI subscription. Both 3.x (thinking-
# capable) and 2.5 (stable) are offered. Gemini 3 models have
# always-on thinking with per-session thought signatures that are
# lost during the format translation round-trip. We use Google's
# official workaround: `skip_thought_signature_validator` on all
# historical function call and thinking parts (see 9router
# openai-to-gemini.js). This bypasses signature validation at the
# cost of the model not being able to build on prior reasoning
# across turns — but all tools work and thinking is visible.
"Google": [
{"value": "gemini-3-pro", "label": "Gemini 3 Pro",
"context_window": 1_000_000, "router_model_id": "gc/gemini-3-pro-preview",
"api": "gemini-cli", "subscription_only": True, "reasoning": True},
{"value": "gemini-3-flash", "label": "Gemini 3 Flash",
"context_window": 1_000_000, "router_model_id": "gc/gemini-3-flash-preview",
"api": "gemini-cli", "subscription_only": True, "reasoning": True},
{"value": "gemini-2.5-pro", "label": "Gemini 2.5 Pro",
"context_window": 1_000_000, "router_model_id": "gc/gemini-2.5-pro",
"api": "gemini-cli", "subscription_only": True},
{"value": "gemini-2.5-flash", "label": "Gemini 2.5 Flash",
"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",
"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)",
"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)",
"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)",
"context_window": 1_000_000, "router_model_id": "gh/gemini-3-pro-preview",
"api": "github-copilot", "subscription_only": True, "reasoning": True},
],
}
@@ -56,6 +156,101 @@ def _is_9router_available() -> bool:
return available
# ---------------------------------------------------------------------------
# Model resolution (used by the live claude_agent_sdk path)
# ---------------------------------------------------------------------------
def _find_builtin_model(short_name: str) -> dict | None:
"""Look up a model entry by its short `value`."""
for models in BUILTIN_MODELS.values():
for m in models:
if m.get("value") == short_name:
return m
return None
def get_api_type(short_name: str) -> str:
"""Return the api type for a short model name.
Returns one of: "anthropic", "codex", "gemini-cli", "github-copilot".
Defaults to "anthropic" for unknown names so existing behavior is preserved.
"""
entry = _find_builtin_model(short_name)
return (entry or {}).get("api", "anthropic")
def resolve_model_id_for_sdk(short_name: str, settings: AppSettings) -> str:
"""Resolve a short model name into the id string passed to ClaudeAgentOptions.
Priority:
- Anthropic model with an API key set → bare `model_id` (real Anthropic API)
- Everything else → `router_model_id` (9Router with cc/ cx/ gc/ gh/ prefix)
- Unknown names pass through unchanged
"""
entry = _find_builtin_model(short_name)
if entry is None:
return short_name
if entry.get("api") == "anthropic" and getattr(settings, "anthropic_api_key", None):
return entry.get("model_id", short_name)
return entry.get("router_model_id", entry.get("model_id", short_name))
async def resolve_aux_model(settings: AppSettings, preferred_tier: str = "haiku") -> tuple[str, str | None]:
"""Pick the cheapest/most-available model for auxiliary LLM calls.
Used by title generation, group meta, dashboard naming, outputs/view
builder, and browser_agent — wherever we need a quick one-shot LLM call
that is NOT the user's selected chat model.
Returns (model_id, base_url).
- If base_url is None, caller should use the default Anthropic client.
- If base_url is set, caller should route through 9Router.
Priority:
1. Anthropic API key set → bare haiku/sonnet on real Anthropic API
2. 9Router + Claude subscription connected → cc/<model>
3. 9Router + Codex connected → cx/gpt-5.4-mini
4. 9Router + Gemini connected → gc/gemini-2.5-flash
5. 9Router + Copilot connected → gh/gpt-5
6. Nothing available → raise ValueError
"""
haiku_bare = "claude-haiku-4-5-20251001"
sonnet_bare = "claude-sonnet-4-20250514"
bare = haiku_bare if preferred_tier == "haiku" else sonnet_bare
# Direct API key wins
if getattr(settings, "anthropic_api_key", None):
return (bare, None)
# Fall back to 9Router
from backend.apps.nine_router import is_running as _9r_running, get_providers as _9r_providers
if not _9r_running():
raise ValueError(
"No AI provider configured for auxiliary LLM call. "
"Set an Anthropic API key or connect a subscription."
)
providers_data = await _9r_providers()
connections = providers_data.get("connections", []) if isinstance(providers_data, dict) else []
connected = {c.get("provider") for c in connections if c.get("isActive")}
base_url = "http://localhost:20128"
if "claude" in connected:
return (f"cc/{haiku_bare}" if preferred_tier == "haiku" else f"cc/{sonnet_bare}", base_url)
if "codex" in connected:
return ("cx/gpt-5.4-mini", base_url)
if "gemini-cli" in connected:
return ("gc/gemini-2.5-flash", base_url)
if "github" in connected:
return ("gh/gpt-5", base_url)
raise ValueError(
"No AI provider connected for auxiliary LLM call. "
"Connect at least one subscription in Settings."
)
# ---------------------------------------------------------------------------
# Provider factory
# ---------------------------------------------------------------------------
@@ -81,31 +276,12 @@ def create_provider(
from backend.apps.agents.providers.openai_compat import OpenAICompatProvider
return OpenAICompatProvider(api_key="9router", base_url="http://localhost:20128/v1")
# Check for GitHub Copilot
if provider_name in ("GitHub Copilot", "copilot"):
from backend.apps.agents.providers.copilot import CopilotProvider
copilot_token = getattr(settings, "copilot_token", None)
if not copilot_token:
raise ValueError("GitHub Copilot not connected. Sign in via Settings → Models.")
# Auto-refresh if expired
import time as _time
expires = getattr(settings, "copilot_token_expires", None)
if expires and _time.time() > expires - 120:
github_token = getattr(settings, "copilot_github_token", None)
if github_token:
import asyncio
from backend.apps.agents.copilot_auth import exchange_for_copilot_token
try:
loop = asyncio.get_event_loop()
result = loop.run_until_complete(exchange_for_copilot_token(github_token))
copilot_token = result["token"]
settings.copilot_token = copilot_token
settings.copilot_token_expires = result["expires_at"]
from backend.apps.settings.settings import _save_settings
_save_settings(settings)
except Exception as e:
logger.warning(f"Copilot token refresh failed: {e}")
return CopilotProvider(copilot_token=copilot_token)
# NOTE: GitHub Copilot previously branched to a CopilotProvider imported
# from backend.apps.agents.providers.copilot, but that module does not
# exist (the file was never checked in). The branch was unreachable dead
# code. Copilot subscription support now routes through 9Router's `gh/`
# prefix via the main claude_agent_sdk path — see BUILTIN_MODELS and
# resolve_model_id_for_sdk above.
if api_type == "anthropic":
from backend.apps.agents.providers.anthropic import AnthropicProvider
@@ -283,18 +459,25 @@ def get_context_window(provider: str, model: str, settings: AppSettings | None =
COST_PER_1M_TOKENS: dict[tuple[str, str], tuple[float, float]] = {
# (provider, model): (input_cost_per_1M, output_cost_per_1M)
# Anthropic
# NOTE: `calculate_cost` is currently unused in the live path — real
# cost tracking comes from 9Router's usage stats (analytics.py:270+).
# These entries are kept so the table matches BUILTIN_MODELS and can
# be used by any future native-loop path. Subscription-routed models
# are zero-cost to the user, but API rates are recorded here for
# reference where they exist.
# Anthropic (direct API rates)
("Anthropic", "sonnet"): (3.0, 15.0),
("Anthropic", "opus"): (5.0, 25.0),
("Anthropic", "haiku"): (1.0, 5.0),
# OpenAI
("OpenAI", "gpt-5.4"): (2.50, 15.0),
("OpenAI", "gpt-5.4-mini"): (0.75, 3.0),
("OpenAI", "o3"): (2.0, 8.0),
("OpenAI", "o4-mini"): (1.10, 4.40),
# Google
("Google", "gemini-2.5-flash"): (0.15, 0.60),
("Google", "gemini-2.5-pro"): (1.25, 10.0),
# OpenAI — Codex subscription path, user pays nothing per token
("OpenAI", "gpt-5.4"): (0.0, 0.0),
("OpenAI", "gpt-5.4-mini"): (0.0, 0.0),
("OpenAI", "gpt-5.3-codex"): (0.0, 0.0),
# Google — Gemini CLI subscription path, user pays nothing per token
("Google", "gemini-3-pro"): (0.0, 0.0),
("Google", "gemini-3-flash"): (0.0, 0.0),
("Google", "gemini-2.5-pro"): (0.0, 0.0),
("Google", "gemini-2.5-flash"): (0.0, 0.0),
# OpenRouter-backed (approximate)
("xAI", "x-ai/grok-4-0214"): (3.0, 15.0),
("Meta", "meta-llama/llama-4-maverick"): (0.50, 0.70),
@@ -306,6 +489,12 @@ COST_PER_1M_TOKENS: dict[tuple[str, str], tuple[float, float]] = {
("Qwen", "qwen/qwen3-coder"): (0.0, 0.0),
("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),
}
+3 -1
View File
@@ -222,7 +222,9 @@ async def generate_name(dashboard_id: str):
try:
from backend.apps.settings.settings import load_settings
from backend.apps.settings.credentials import get_anthropic_client
from backend.apps.agents.providers.registry import resolve_aux_model
global_settings = load_settings()
aux_model, _aux_base = await resolve_aux_model(global_settings, preferred_tier="haiku")
client = get_anthropic_client(global_settings)
if len(prompts) == 1:
@@ -241,7 +243,7 @@ async def generate_name(dashboard_id: str):
user_content = "\n".join(f"- {p}" for p in prompts)
resp = await client.messages.create(
model="claude-haiku-4-5-20251001",
model=aux_model,
max_tokens=20,
system=system,
messages=[{"role": "user", "content": user_content}],
+226 -3
View File
@@ -244,6 +244,221 @@ async def get_providers() -> list[dict]:
return []
# ---------------------------------------------------------------------------
# Per-provider OAuth redirect URIs
# ---------------------------------------------------------------------------
#
# Each upstream OAuth client is registered with the identity provider against
# a specific redirect URI. Anthropic's Claude Code client is lenient — any
# `http://localhost:*/callback` works — so we can use 9Router's built-in
# callback page at port 20128 for it. OpenAI's Codex client is NOT: it's
# registered with `http://localhost:1455/auth/callback` and OpenAI rejects
# any other redirect_uri with `unknown_error` at the auth page. Google's
# Gemini CLI client accepts arbitrary localhost URIs so we keep 20128 there.
#
# For Codex specifically we spawn a one-shot HTTP listener on port 1455
# below that serves a callback page mirroring 9Router's callback page —
# postMessage to window.opener, BroadcastChannel fan-out, then close. This
# lets the frontend reuse its existing Claude/Anthropic flow unchanged
# (window.open popup + postMessage handler in Settings.tsx).
_CODEX_CALLBACK_PORT = 1455
_CODEX_CALLBACK_PATH = "/auth/callback"
# Minimal callback page inlined as bytes. Mirrors 9router/src/app/callback/
# page.js:27-55 — posts the OAuth data to window.opener via postMessage,
# BroadcastChannel, and localStorage so whatever detection path the caller
# is using will fire. Served to the Electron popup that OAuth redirects to.
_CODEX_CALLBACK_HTML = b"""<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>Authorization Complete</title>
<style>body{font-family:-apple-system,system-ui,sans-serif;background:#111;color:#eee;
text-align:center;padding:60px 20px;margin:0}h1{font-weight:600;margin:0 0 12px}
p{color:#888;margin:0}</style></head><body>
<h1>Authorization Successful</h1>
<p>This window will close automatically...</p>
<script>
(function() {
var params = new URLSearchParams(window.location.search);
var data = {
code: params.get('code'),
state: params.get('state'),
error: params.get('error'),
errorDescription: params.get('error_description'),
fullUrl: window.location.href
};
// Method 1: postMessage to opener (popup mode -- primary path used by
// Settings.tsx:316 msgHandler)
if (window.opener) {
try { window.opener.postMessage({ type: 'oauth_callback', data: data }, '*'); }
catch (e) { console.log('postMessage failed:', e); }
}
// Method 2: BroadcastChannel (secondary relay for any same-origin listener)
try { var ch = new BroadcastChannel('oauth_callback'); ch.postMessage(data); ch.close(); }
catch (e) {}
// Method 3: localStorage flag (last-resort handoff)
try { localStorage.setItem('oauth_callback', JSON.stringify(Object.assign({}, data, { timestamp: Date.now() }))); }
catch (e) {}
setTimeout(function() { try { window.close(); } catch (e) {} }, 1500);
})();
</script>
</body></html>"""
async def _start_codex_callback_listener(timeout: float = 300.0) -> asyncio.base_events.Server | None:
"""Spawn a one-shot HTTP listener on 127.0.0.1:1455 for the Codex OAuth callback.
Serves GET /auth/callback with _CODEX_CALLBACK_HTML. After serving the
callback (or after `timeout` seconds with no callback) the listener
closes itself in a background task. Safe to call even if 1455 is busy —
logs the collision and returns None so start_oauth can still proceed and
surface whatever error OpenAI returns.
"""
callback_served = asyncio.Event()
async def _handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
try:
# Read the request line ("GET /auth/callback?... HTTP/1.1\r\n")
raw_request_line = await asyncio.wait_for(reader.readline(), timeout=5.0)
request_line = raw_request_line.decode("latin-1", errors="replace").strip()
# Drain headers so the browser's request is fully consumed
while True:
line = await asyncio.wait_for(reader.readline(), timeout=5.0)
if not line or line in (b"\r\n", b"\n"):
break
# Only respond to the OAuth callback path. Chrome preflights and
# favicon fetches get a 404 so they don't trigger the served-event.
parts = request_line.split(" ")
path = parts[1] if len(parts) >= 2 else ""
method = parts[0] if parts else ""
if method == "GET" and path.startswith(_CODEX_CALLBACK_PATH):
body = _CODEX_CALLBACK_HTML
response = (
b"HTTP/1.1 200 OK\r\n"
b"Content-Type: text/html; charset=utf-8\r\n"
b"Content-Length: " + str(len(body)).encode("ascii") + b"\r\n"
b"Cache-Control: no-store\r\n"
b"Connection: close\r\n\r\n"
+ body
)
writer.write(response)
await writer.drain()
callback_served.set()
else:
# Unrelated request (favicon, preflight) — 404 and move on
writer.write(
b"HTTP/1.1 404 Not Found\r\n"
b"Content-Length: 0\r\n"
b"Connection: close\r\n\r\n"
)
await writer.drain()
except Exception as e:
logger.debug(f"Codex callback listener handler error: {e}")
finally:
try:
writer.close()
await writer.wait_closed()
except Exception:
pass
try:
server = await asyncio.start_server(_handle, "127.0.0.1", _CODEX_CALLBACK_PORT)
except OSError as e:
# Port already in use — probably another Codex connect attempt still
# running, or an actual Codex CLI process holding 1455. Log and bail.
logger.warning(
f"Could not start Codex callback listener on port {_CODEX_CALLBACK_PORT}: {e}. "
"If another connection attempt is in progress, wait for it to finish or time out."
)
return None
async def _lifecycle():
try:
await asyncio.wait_for(callback_served.wait(), timeout=timeout)
# Give the served HTML a moment to run its JS (postMessage +
# window.close) before we close the socket. Chromium closes
# the tab on window.close() but the JS needs to run first.
await asyncio.sleep(2.0)
except asyncio.TimeoutError:
logger.info(f"Codex callback listener timed out after {timeout}s")
except Exception as e:
logger.debug(f"Codex callback listener lifecycle error: {e}")
finally:
try:
server.close()
await server.wait_closed()
except Exception:
pass
asyncio.create_task(_lifecycle())
logger.info(f"Started Codex callback listener on http://localhost:{_CODEX_CALLBACK_PORT}{_CODEX_CALLBACK_PATH}")
return server
# Providers that cannot use the in-Electron `window.open` popup flow and
# must be opened in the user's system browser instead.
#
# Google enforces an "Embedded WebView Restrictions" policy on its OAuth
# consent pages that uses JS-based fingerprinting, not just user-agent
# sniffing. We tried defeating it with a combination of Chrome UA spoof +
# sandboxed webPreferences + fresh session partition + a preload script
# that patches navigator.webdriver/plugins/mimeTypes/languages/chrome and
# overrides navigator.permissions.query — it was still rejected. Google's
# detection is a moving target and actively adversarial. The supported
# workaround (and what Google recommends for Desktop app OAuth) is to run
# the flow in the user's real browser via shell.openExternal.
#
# When a provider is in this set the frontend calls
# window.openswarm.openExternal (shell.openExternal) instead of
# window.open, and the callback lands on OpenSwarm's own
# /api/subscriptions/callback endpoint (backend/main.py:138) which
# exchanges the code and serves a "Connected!" page. Detection on the
# OpenSwarm side happens via the existing status poller on the
# Settings page.
_EXTERNAL_BROWSER_PROVIDERS: set[str] = {"gemini-cli"}
def _should_use_external_browser(provider: str) -> bool:
return provider in _EXTERNAL_BROWSER_PROVIDERS
def _backend_port() -> int:
"""Best-effort lookup of the OpenSwarm backend HTTP port.
Falls back to 8324 (the default in backend/main.py) if OPENSWARM_PORT
hasn't been set yet. backend/main.py:239 sets this env var at startup
before any request handler runs, so `start_oauth` will always see the
correct value.
"""
try:
return int(os.environ.get("OPENSWARM_PORT", "8324"))
except (TypeError, ValueError):
return 8324
def _callback_uri_for_provider(provider: str) -> str:
"""Return the redirect URI to pass to 9Router's authorize endpoint.
Most providers accept 9Router's built-in callback page at port 20128.
Two special cases:
- Codex/OpenAI's OAuth client is bound to a fixed
http://localhost:1455/auth/callback URI — handled by
_start_codex_callback_listener above.
- Gemini/Google's OAuth consent page rejects embedded browsers, so we
route the callback through OpenSwarm's backend endpoint at
/api/subscriptions/callback (backend/main.py:138) which runs the
exchange itself. This is the only provider where the callback lands
on OpenSwarm's port rather than 9Router's.
"""
if provider == "codex":
return f"http://localhost:{_CODEX_CALLBACK_PORT}{_CODEX_CALLBACK_PATH}"
if provider in _EXTERNAL_BROWSER_PROVIDERS:
return f"http://localhost:{_backend_port()}/api/subscriptions/callback"
return f"http://localhost:{NINE_ROUTER_PORT}/callback"
async def start_oauth(provider: str) -> dict:
"""Start OAuth flow for a provider.
@@ -267,9 +482,16 @@ async def start_oauth(provider: str) -> dict:
except Exception:
pass
# Authorization code flow — redirect to 9Router's own callback page
# (Anthropic only accepts redirect URIs registered with 9Router's client ID)
callback_url = f"http://localhost:{NINE_ROUTER_PORT}/callback"
# Authorization code flow. Most providers accept 9Router's own
# callback page at port 20128, but Codex's OAuth client is bound
# to a fixed http://localhost:1455/auth/callback URI — spawn an
# in-process listener on that port before returning the auth URL,
# so the popup can redirect there after login and relay the code
# back to the frontend via postMessage (same flow as Claude).
callback_url = _callback_uri_for_provider(provider)
if provider == "codex":
await _start_codex_callback_listener()
r = await client.get(
f"{NINE_ROUTER_API}/oauth/{provider}/authorize",
params={"redirect_uri": callback_url},
@@ -282,6 +504,7 @@ async def start_oauth(provider: str) -> dict:
"code_verifier": data.get("codeVerifier", ""),
"state": data.get("state", ""),
"redirect_uri": callback_url,
"use_external_browser": _should_use_external_browser(provider),
}
+29 -2
View File
@@ -377,10 +377,20 @@ async def vibe_code(body: VibeCodeRequest):
if context_parts:
user_message = "\n\n".join(context_parts) + "\n\nUser request: " + body.prompt
from backend.apps.agents.providers.registry import resolve_aux_model
try:
aux_model, _aux_base = await resolve_aux_model(load_settings(), preferred_tier="sonnet")
except ValueError as e:
return {
"message": f"Error: {str(e)}",
"frontend_code": body.current_frontend_code,
"backend_code": body.current_backend_code,
"input_schema": body.current_schema,
}
client = _get_anthropic_client()
try:
resp = await client.messages.create(
model="claude-sonnet-4-20250514",
model=aux_model,
max_tokens=8000,
system=VIBE_CODE_SYSTEM_PROMPT,
messages=[{"role": "user", "content": user_message}],
@@ -438,7 +448,24 @@ async def auto_run_output(body: AutoRunRequest):
schema_str = json.dumps(body.input_schema, indent=2)
user_message = f"Schema:\n```json\n{schema_str}\n```\n\nGenerate data for: {body.prompt}"
api_model = _resolve_model(body.model)
# Resolve body.model via the registry so non-Anthropic selections are
# routed through 9Router with the correct prefix (cx/, gc/, gh/).
# If body.model is unset or unknown, fall back to whichever aux model
# is available (prefers Claude, else any connected subscription).
from backend.apps.agents.providers.registry import (
_find_builtin_model,
resolve_model_id_for_sdk,
resolve_aux_model,
)
settings = load_settings()
if body.model and _find_builtin_model(body.model) is not None:
api_model = resolve_model_id_for_sdk(body.model, settings)
else:
try:
api_model, _ = await resolve_aux_model(settings, preferred_tier="haiku")
except ValueError as e:
return {"error": str(e), "input_data": None, "backend_result": None}
client = _get_anthropic_client()
try:
resp = await client.messages.create(
+49 -11
View File
@@ -9,6 +9,22 @@ from fastapi import Request
# In-memory store for pending OAuth flows (state -> {provider, code_verifier, redirect_uri})
_pending_oauth: dict[str, dict] = {}
# Recently-completed OAuth states so the /api/subscriptions/callback handler
# can distinguish a legitimate duplicate callback (browser prefetch, refresh,
# or Google redirect retry after a slow first response) from a truly stale
# request. Bounded FIFO — drops the oldest entries once it grows past
# _MAX_COMPLETED_OAUTH so it can't leak memory.
_completed_oauth: list[str] = []
_MAX_COMPLETED_OAUTH = 64
def _mark_oauth_completed(state: str) -> None:
if state in _completed_oauth:
return
_completed_oauth.append(state)
# Trim head if we've outgrown the bound
while len(_completed_oauth) > _MAX_COMPLETED_OAUTH:
_completed_oauth.pop(0)
from backend.config.Apps import MainApp
from backend.apps.health.health import health
from backend.apps.agents.agents import agents
@@ -135,9 +151,30 @@ async def subscriptions_pending(state: str):
}, headers={"Access-Control-Allow-Origin": "*"})
_SUCCESS_HTML = (
'<html><body style="background:#1a1a1a;color:#fff;display:flex;align-items:center;justify-content:center;height:100vh;font-family:sans-serif">'
'<div style="text-align:center">'
'<div style="width:64px;height:64px;border-radius:50%;background:#22c55e20;display:flex;align-items:center;justify-content:center;margin:0 auto 16px;font-size:32px">&#10003;</div>'
'<h2 style="margin:0 0 8px">Connected!</h2>'
'<p style="color:#888;margin:0">You can close this window</p>'
'</div>'
'<script>setTimeout(()=>window.close(),1500)</script>'
'</body></html>'
)
@app.get("/api/subscriptions/callback")
async def subscriptions_callback(request: Request):
"""Catch OAuth redirect from provider, exchange code via 9Router, close window."""
"""Catch OAuth redirect from provider, exchange code via 9Router, close window.
Must be idempotent: the browser can legitimately hit this URL more than
once (Chrome prefetch, user refresh, Google retrying a slow first
redirect). The first call consumes `_pending_oauth[state]`, so a second
call would otherwise render a misleading "Session expired" even though
the connection is already saved. To handle that, we track recently-
completed state values in `_completed_oauth` and return the success
page whenever we see a duplicate.
"""
code = request.query_params.get("code", "")
state = request.query_params.get("state", "")
error = request.query_params.get("error", "")
@@ -148,24 +185,25 @@ async def subscriptions_callback(request: Request):
pending = _pending_oauth.pop(state, None)
if not pending:
# Either a duplicate callback for a state we've already exchanged,
# or a truly stale state. Duplicates are the expected case —
# Chrome's prefetcher and some extensions speculatively GET URLs.
if state and state in _completed_oauth:
logger.info(f"Duplicate OAuth callback for state {state[:8]}... (already completed)")
return HTMLResponse(_SUCCESS_HTML)
logger.warning(f"OAuth callback with unknown state {state[:8] if state else '(empty)'}...")
return HTMLResponse('<html><body style="background:#1a1a1a;color:#fff;display:flex;align-items:center;justify-content:center;height:100vh;font-family:sans-serif"><div style="text-align:center"><h2>Session expired</h2><p style="color:#888">Please try connecting again.</p></div></body></html>')
from backend.apps.nine_router import exchange_oauth
try:
await exchange_oauth(pending["provider"], code, pending["redirect_uri"], pending["code_verifier"], state)
except Exception as e:
logger.warning(f"OAuth exchange failed for provider={pending.get('provider')}: {e}")
return HTMLResponse(f'<html><body style="background:#1a1a1a;color:#fff;display:flex;align-items:center;justify-content:center;height:100vh;font-family:sans-serif"><div style="text-align:center"><h2>Connection failed</h2><p style="color:#888">{e}</p></div></body></html>')
return HTMLResponse(
'<html><body style="background:#1a1a1a;color:#fff;display:flex;align-items:center;justify-content:center;height:100vh;font-family:sans-serif">'
'<div style="text-align:center">'
'<div style="width:64px;height:64px;border-radius:50%;background:#22c55e20;display:flex;align-items:center;justify-content:center;margin:0 auto 16px;font-size:32px">&#10003;</div>'
'<h2 style="margin:0 0 8px">Connected!</h2>'
'<p style="color:#888;margin:0">You can close this window</p>'
'</div>'
'<script>setTimeout(()=>window.close(),1500)</script>'
'</body></html>'
)
_mark_oauth_completed(state)
logger.info(f"OAuth exchange succeeded for provider={pending.get('provider')}")
return HTMLResponse(_SUCCESS_HTML)
@app.post("/api/browser-agent/run")
+37
View File
@@ -408,6 +408,32 @@ app.whenReady().then(async () => {
});
app.on('web-contents-created', (_event, contents) => {
// Override the user-agent on popup BrowserWindows (i.e. anything created
// via window.open from the renderer, which includes the OAuth popup for
// subscription connect flows). Electron's default UA includes an
// `Electron/X.Y.Z` token that accounts.google.com blacklists with a
// "browser not supported" page — and auth.openai.com is similarly picky.
// Spoofing a current Chrome UA makes those identity providers treat the
// popup like a real browser without changing the flow OpenSwarm uses to
// capture the callback (window.open + postMessage).
//
// This check runs synchronously during `new BrowserWindow()` construction.
// On the very first invocation (for mainWindow itself), `mainWindow` is
// still null because assignment happens after the constructor returns,
// so the `mainWindow &&` short-circuits and we leave the main window's
// UA alone. Webview tags report `getType() === 'webview'` and are also
// skipped — they render user-visited sites and must advertise the real UA.
if (
contents.getType() === 'window' &&
mainWindow &&
contents !== mainWindow.webContents
) {
const OAUTH_POPUP_UA =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 ' +
'(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';
contents.setUserAgent(OAUTH_POPUP_UA);
}
contents.setWindowOpenHandler(({ url, disposition }) => {
if (disposition === 'foreground-tab' || disposition === 'background-tab') {
if (mainWindow && !mainWindow.isDestroyed()) {
@@ -416,6 +442,17 @@ app.on('web-contents-created', (_event, contents) => {
return { action: 'deny' };
}
// Note on Google OAuth: we tried running the Gemini flow inside this
// popup BrowserWindow with a spoofed Chrome UA, fresh session partition,
// sandboxed webPreferences, and a preload script that patched
// navigator.webdriver/plugins/chrome/permissions. Google's consent page
// still rejected with "browser not supported". Their detection is
// actively adversarial and Google explicitly prohibits embedded browser
// OAuth. Gemini now routes through shell.openExternal instead (see
// _EXTERNAL_BROWSER_PROVIDERS in backend/apps/nine_router.py). Anthropic
// and OpenAI/Codex don't fingerprint, so they still use this popup path
// with the generic Chrome UA override set above.
return {
action: 'allow',
overrideBrowserWindowOptions: {
+14 -30
View File
@@ -36,7 +36,6 @@ import Dashboard from '@/app/pages/Dashboard/Dashboard';
import DashboardHost from '@/app/components/Layout/DashboardHost';
import { useLastDashboardId } from '@/shared/hooks/useLastDashboardId';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { API_BASE } from '@/shared/config';
import { fetchDashboards, createDashboard, renameDashboard } from '@/shared/state/dashboardsSlice';
import { addBrowserCard, addBrowserTab } from '@/shared/state/dashboardLayoutSlice';
import { setPendingBrowserUrl } from '@/shared/state/tempStateSlice';
@@ -120,13 +119,6 @@ const AppShell: React.FC = () => {
// ---- Warning banner: no internet / no model connected ----
const [isOnline, setIsOnline] = useState(navigator.onLine);
const [hasActiveSubscription, setHasActiveSubscription] = useState<boolean | null>(null); // null = still checking
// Narrow selectors — only re-render when an API key field actually changes,
// not on every settings update (theme toggle, system prompt edit, etc.).
const anthropicKey = useAppSelector((s) => s.settings.data.anthropic_api_key);
const openaiKey = useAppSelector((s) => s.settings.data.openai_api_key);
const googleKey = useAppSelector((s) => s.settings.data.google_api_key);
const openrouterKey = useAppSelector((s) => s.settings.data.openrouter_api_key);
useEffect(() => {
const goOnline = () => setIsOnline(true);
@@ -139,28 +131,20 @@ const AppShell: React.FC = () => {
};
}, []);
// Check subscription status once on mount (and re-check when an API key changes)
useEffect(() => {
let cancelled = false;
fetch(`${API_BASE}/agents/subscriptions/status`)
.then((r) => r.json())
.then((data) => {
if (cancelled) return;
const connections = data.providers?.connections || [];
setHasActiveSubscription(
data.running && connections.some((p: any) => p.isActive),
);
})
.catch(() => {
if (!cancelled) setHasActiveSubscription(false);
});
return () => { cancelled = true; };
}, [anthropicKey]);
const hasAnyApiKey = !!(anthropicKey || openaiKey || googleKey || openrouterKey);
const hasModelConnected = hasAnyApiKey || hasActiveSubscription === true;
// Don't flash the banner while the subscription check is in flight
const showWarningBanner = !isOnline || (hasActiveSubscription !== null && !hasModelConnected);
// Derive "any model connected" from the /agents/models response (already
// fetched into Redux at app start via Main.tsx and re-fetched by
// Settings.tsx after every subscription connect/disconnect). That endpoint
// intersects BUILTIN_MODELS with both the user's API keys AND 9Router's
// live connection state, so a non-empty byProvider means there's at least
// one usable model — regardless of whether it came from a typed API key
// or an OAuth subscription flow. This replaces the previous approach of
// polling /agents/subscriptions/status in an effect keyed to anthropicKey,
// which didn't refresh when a non-Anthropic subscription was connected.
const modelsByProvider = useAppSelector((s) => s.models.byProvider);
const modelsLoaded = useAppSelector((s) => s.models.loaded);
const hasModelConnected = Object.keys(modelsByProvider).length > 0;
// Don't flash the banner while the initial /agents/models fetch is in flight
const showWarningBanner = !isOnline || (modelsLoaded && !hasModelConnected);
const bannerDismissedForVersion = availableVersion != null && dismissedVersion === availableVersion;
const isUpdateActionable = updateStatus === 'available' || updateStatus === 'downloaded' || updateStatus === 'downloading';
@@ -8,6 +8,8 @@ import Typography from '@mui/material/Typography';
import IconButton from '@mui/material/IconButton';
import Tooltip from '@mui/material/Tooltip';
import Chip from '@mui/material/Chip';
import Snackbar from '@mui/material/Snackbar';
import Alert from '@mui/material/Alert';
import MicNoneOutlinedIcon from '@mui/icons-material/MicNoneOutlined';
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
import StopIcon from '@mui/icons-material/Stop';
@@ -181,6 +183,29 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
const modesArr = useMemo(() => Object.values(modesMap), [modesMap]);
const modelsByProvider = useAppSelector((state) => state.models.byProvider);
const modelsLoaded = useAppSelector((state) => state.models.loaded);
const toolItems = useAppSelector((state) => state.tools.items);
// Count the total number of enabled MCP tool permissions (non-deny) across
// all enabled MCP servers. Used to warn users before they switch to a
// non-Claude model that can't leverage the deferred-tool pool — those
// models get every schema upfront and may exhaust context fast.
const enabledMcpToolCount = useMemo(() => {
let count = 0;
for (const id in toolItems) {
const t = toolItems[id];
if (t.enabled && t.mcp_config && t.tool_permissions) {
for (const name in t.tool_permissions) {
if (t.tool_permissions[name] !== 'deny') count++;
}
}
}
return count;
}, [toolItems]);
// One-time dismissible warning when picking a non-Claude model with many MCPs.
const [mcpWarningOpen, setMcpWarningOpen] = useState(false);
const MCP_WARNING_LS_KEY = 'openswarm:nonClaudeMcpWarningDismissed';
const MCP_WARNING_THRESHOLD = 20;
// Build flat model list with provider grouping
const allModelOptions = useMemo(() => {
@@ -1064,6 +1089,17 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
};
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);
}}
>
@@ -1268,6 +1304,40 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
</Box>
</Modal>
<Snackbar
open={mcpWarningOpen}
autoHideDuration={12000}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
onClose={(_, reason) => {
if (reason === 'clickaway') return;
setMcpWarningOpen(false);
try {
if (typeof window !== 'undefined') {
window.localStorage.setItem(MCP_WARNING_LS_KEY, '1');
}
} catch { /* ignore */ }
}}
>
<Alert
severity="warning"
variant="filled"
onClose={() => {
setMcpWarningOpen(false);
try {
if (typeof window !== 'undefined') {
window.localStorage.setItem(MCP_WARNING_LS_KEY, '1');
}
} catch { /* ignore */ }
}}
sx={{ fontSize: '0.78rem', maxWidth: 520 }}
>
Non-Claude models don't support the deferred tool loader — all
{' '}{enabledMcpToolCount} MCP tool schemas will be sent upfront,
which may exhaust context on long sessions. Disable MCPs you
don't need in Settings Tools.
</Alert>
</Snackbar>
</Box>
);
});
+303 -133
View File
@@ -48,120 +48,18 @@ import DirectoryBrowser from '@/app/components/DirectoryBrowser';
import { CommandsContent } from '@/app/pages/Commands/Commands';
import { API_BASE } from '@/shared/config';
// ── Copilot Auth Button ──
const CopilotAuthButton: React.FC = () => {
const c = useClaudeTokens();
const [status, setStatus] = useState<'idle' | 'waiting' | 'connected' | 'error'>('idle');
const [userCode, setUserCode] = useState('');
const [username, setUsername] = useState('');
const [error, setError] = useState('');
// Check if already connected
useEffect(() => {
fetch(`${API_BASE}/agents/copilot/models`)
.then(r => r.json())
.then(d => {
if (d.models && d.models.length > 0) setStatus('connected');
})
.catch(() => {});
}, []);
const startAuth = async () => {
setStatus('waiting');
setError('');
try {
const resp = await fetch(`${API_BASE}/agents/copilot/start-auth`, { method: 'POST' });
const data = await resp.json();
setUserCode(data.user_code);
window.open(data.verification_uri, '_blank');
// Poll for completion
const deviceCode = data.device_code;
const poll = setInterval(async () => {
try {
const r = await fetch(`${API_BASE}/agents/copilot/poll-auth`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ device_code: deviceCode }),
});
const d = await r.json();
if (d.status === 'connected') {
clearInterval(poll);
setStatus('connected');
setUsername(d.username || '');
}
} catch {}
}, 5000);
// Timeout after 5 minutes
setTimeout(() => { clearInterval(poll); if (status === 'waiting') { setStatus('error'); setError('Auth timed out'); } }, 300000);
} catch (e: any) {
setStatus('error');
setError(e.message || 'Failed to start auth');
}
};
const disconnect = async () => {
await fetch(`${API_BASE}/agents/copilot/disconnect`, { method: 'POST' });
setStatus('idle');
setUsername('');
};
if (status === 'connected') {
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: c.status.success, flexShrink: 0 }} />
<Typography sx={{ fontSize: '0.78rem', color: c.text.primary }}>
Connected{username ? ` as @${username}` : ''}
</Typography>
<Typography
onClick={disconnect}
sx={{ fontSize: '0.72rem', color: c.text.tertiary, cursor: 'pointer', ml: 'auto', '&:hover': { color: c.status.error } }}
>
Disconnect
</Typography>
</Box>
);
}
if (status === 'waiting') {
return (
<Box>
<Typography sx={{ fontSize: '0.78rem', color: c.text.primary, mb: 0.5 }}>
Enter code <strong style={{ fontFamily: 'monospace', fontSize: '0.9rem', letterSpacing: '0.1em' }}>{userCode}</strong> at github.com/login/device
</Typography>
<Typography sx={{ fontSize: '0.68rem', color: c.text.tertiary }}>Waiting for authorization...</Typography>
</Box>
);
}
return (
<Box>
<Button
onClick={startAuth}
variant="outlined"
size="small"
sx={{
textTransform: 'none',
fontSize: '0.78rem',
color: c.text.primary,
borderColor: c.border.medium,
'&:hover': { borderColor: c.accent.primary, color: c.accent.primary },
}}
>
Sign in with GitHub
</Button>
{error && <Typography sx={{ fontSize: '0.7rem', color: c.status.error, mt: 0.5 }}>{error}</Typography>}
</Box>
);
};
// NOTE: a standalone CopilotAuthButton component used to live here, but it
// referenced `/agents/copilot/{models,start-auth,poll-auth,disconnect}`
// endpoints that never existed on the backend. GitHub Copilot now flows
// through 9Router's `github` OAuth under the generic SubscriptionCard path
// below, so the dead component was removed.
// ── Subscription Provider Card ──
const SUBSCRIPTION_PROVIDERS = [
{ id: 'claude', name: 'Claude Pro / Max', desc: 'Sonnet, Opus, Haiku — use your Anthropic subscription', color: '#E8927A', preview: false },
{ id: 'gemini-cli', name: 'Gemini Advanced', desc: 'Gemini 2.5 Pro and Flash — use your Google subscription', color: '#4285F4', preview: true },
{ id: 'codex', name: 'ChatGPT Plus / Pro', desc: 'GPT-5.4, o3, o4-mini — use your OpenAI subscription', color: '#74AA9C', preview: true },
{ id: 'github', name: 'GitHub Copilot', desc: 'Claude + GPT models via your Copilot subscription', color: '#8B949E', preview: true },
{ 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 },
];
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 }) => {
@@ -230,6 +128,7 @@ const SubscriptionCard: React.FC<{ provider: typeof SUBSCRIPTION_PROVIDERS[0]; c
const SubscriptionCards: React.FC = () => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const [status, setStatus] = useState<any>(null);
const [connecting, setConnecting] = useState<string | null>(null);
const [disconnecting, setDisconnecting] = useState<string | null>(null);
@@ -243,12 +142,18 @@ const SubscriptionCards: React.FC = () => {
.catch(() => setStatus({ running: false, providers: [], models: [] }));
};
// Refresh the chat model picker whenever subscription connection state
// changes — GET /agents/models intersects BUILTIN_MODELS with 9Router's
// live connected-provider set, so newly-connected subscriptions surface
// their models in the dropdown immediately.
const refreshPickerModels = () => { dispatch(fetchModels()); };
useEffect(() => { fetchStatus(); }, []);
const isConnected = (providerId: string) => {
if (!status?.providers) return false;
const connections = status.providers?.connections || (Array.isArray(status.providers) ? status.providers : []);
return connections.some((p: any) => p.provider === providerId && p.isActive);
return connections.some((p: any) => p.provider === providerId && (p.isActive || p.testStatus === 'active'));
};
const handleConnect = async (providerId: string) => {
@@ -271,48 +176,177 @@ const SubscriptionCards: React.FC = () => {
if (data.flow === 'device_code') {
const code = data.user_code || '';
setUserCode(code);
if (data.verification_uri) window.open(data.verification_uri, '_blank');
// Use a named window with features (not `_blank`) so Electron's
// setWindowOpenHandler sees `new-window` disposition and spawns a
// BrowserWindow popup — matching the Anthropic/Codex flow. With
// `_blank` the disposition becomes `foreground-tab` and our main.js
// handler routes it into the dashboard as a webview tab, which is
// what we saw for GitHub before this change.
//
// Keep a reference to the popup so we can auto-close it when the
// backend poll detects success, instead of leaving the user to
// dismiss the "Congratulations, you're all set" page manually.
let devicePopup: Window | null = null;
if (data.verification_uri) {
devicePopup = window.open(data.verification_uri, 'oauth_connect', 'width=600,height=720');
}
const timer = setInterval(async () => {
// Shared cleanup — whichever detection path fires first calls this.
let stopped = false;
const onDeviceSuccess = () => {
if (stopped) return;
stopped = true;
clearInterval(devicePollTimer);
clearInterval(statusPollTimer);
setPollTimer(null);
setConnecting(null);
setUserCode('');
fetchStatus();
refreshPickerModels();
// Auto-close popup 2s after success so user briefly sees the
// "Congratulations" page then it goes away automatically.
setTimeout(() => {
if (devicePopup && !devicePopup.closed) {
try { devicePopup.close(); } catch {}
}
}, 2000);
};
// Path 1: device-code poll — asks backend to poll the provider's
// token endpoint via 9Router. Primary path when it works.
const pollOnce = async () => {
if (stopped) return;
try {
const pr = await fetch(`${API_BASE}/agents/subscriptions/poll`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ provider: providerId, device_code: data.device_code, code_verifier: data.code_verifier, extra_data: data.extra_data }),
});
if (!pr.ok) {
console.warn(`[subscription-poll] ${providerId}: HTTP ${pr.status}`);
return;
}
const pd = await pr.json();
if (pd.success) {
clearInterval(timer);
onDeviceSuccess();
} else if (!pd.pending) {
console.warn(`[subscription-poll] ${providerId}: not success, not pending:`, pd);
}
} catch (e) {
console.warn(`[subscription-poll] ${providerId}: error:`, e);
}
};
pollOnce(); // immediate first attempt
const devicePollTimer = setInterval(pollOnce, 5000);
// Path 2: status poller — checks 9Router's connection list
// directly every 2s. Catches the connection even if the
// device-code poll silently errors (e.g. 9Router 500 from
// postExchange or createProviderConnection). Same pattern
// the authorization_code flow already uses.
const statusPollTimer = setInterval(async () => {
if (stopped) return;
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();
}
} catch {}
}, 2000);
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();
}
} catch {}
}, 5000);
setPollTimer(timer);
setTimeout(() => { clearInterval(timer); setPollTimer(null); setConnecting(null); setUserCode(''); }, 300000);
}, 3000);
}
}, 1000);
// 5-minute hard timeout — clean up everything.
setTimeout(() => {
if (stopped) return;
stopped = true;
clearInterval(devicePollTimer);
clearInterval(statusPollTimer);
clearInterval(popupCloseCheck);
setPollTimer(null);
setConnecting(null);
setUserCode('');
if (devicePopup && !devicePopup.closed) {
try { devicePopup.close(); } catch {}
}
}, 300000);
} else if (data.flow === 'authorization_code') {
const popup = window.open(data.auth_url, 'oauth_connect', 'width=600,height=700');
// Some providers (currently Gemini/Google) enforce an anti-embedded-
// browser policy on their OAuth consent page that no amount of
// user-agent spoofing defeats. For those, the backend sets
// `use_external_browser: true` and we open the auth URL in the
// user's default browser via shell.openExternal. The callback then
// lands on OpenSwarm's own /api/subscriptions/callback endpoint
// (backend/main.py:138) which performs the exchange itself and
// shows a "Connected!" page. Detection happens via the status
// poller below — no postMessage handoff possible because the
// system browser has no window.opener relationship back to us.
const useExternal = !!data.use_external_browser;
let popup: Window | null = null;
if (useExternal && (window as any).openswarm?.openExternal) {
(window as any).openswarm.openExternal(data.auth_url);
} else {
popup = window.open(data.auth_url, 'oauth_connect', 'width=600,height=700');
}
// Status polling as primary detection
// Status polling primary for external-browser flow, secondary
// (fast postMessage path below) for popup flow.
const statusPoller = setInterval(async () => {
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)) {
if (connections.some((p: any) => p.provider === providerId && (p.isActive || p.testStatus === 'active'))) {
clearInterval(statusPoller);
setPollTimer(null);
window.removeEventListener('message', msgHandler);
if (!useExternal) window.removeEventListener('message', msgHandler);
setConnecting(null);
fetchStatus();
refreshPickerModels();
}
} catch {}
}, 2000);
setPollTimer(statusPoller);
// postMessage listener as secondary (faster when it works)
// postMessage listener — only wired up for the Electron popup flow
// since the system-browser flow has no opener relationship.
const msgHandler = async (event: MessageEvent) => {
const d = event.data;
const callbackData = d?.type === 'oauth_callback' ? d.data : d;
@@ -333,17 +367,21 @@ const SubscriptionCards: React.FC = () => {
} catch {}
setConnecting(null);
fetchStatus();
refreshPickerModels();
}
};
window.addEventListener('message', msgHandler);
if (!useExternal) window.addEventListener('message', msgHandler);
// Timeout: reset after 30s so user can try again (not 5min)
// Timeout: 30s for popup flow (user finishes auth in a few seconds),
// 5 minutes for external-browser flow (user has to tab-switch, log
// in, consent — takes much longer in practice).
const timeoutMs = useExternal ? 300_000 : 30_000;
setTimeout(() => {
clearInterval(statusPoller);
setPollTimer(null);
window.removeEventListener('message', msgHandler);
if (!useExternal) window.removeEventListener('message', msgHandler);
setConnecting(null);
}, 30000);
}, timeoutMs);
} else {
setConnecting(null);
@@ -360,8 +398,13 @@ const SubscriptionCards: React.FC = () => {
body: JSON.stringify({ provider: providerId }),
});
} catch {}
// Wait briefly for 9Router to process, then refresh
setTimeout(() => { fetchStatus(); setDisconnecting(null); }, 500);
// Wait briefly for 9Router to process, then refresh both the
// subscription status and the chat model picker.
setTimeout(() => {
fetchStatus();
refreshPickerModels();
setDisconnecting(null);
}, 500);
};
if (!status) {
@@ -741,8 +784,15 @@ const Settings: React.FC = () => {
}, [dispatch]);
useEffect(() => {
if (open) setActiveTab('general');
}, [open]);
// Reset to the General tab on open, but NOT when the caller has
// explicitly requested a tab via openSettingsModal(<tab>) — e.g. the
// "Configure models" link in the warning banner dispatches
// openSettingsModal('models') and expects to land on Models. The
// separate `initialTab` effect above handles the targeted case;
// without this guard, that effect's write gets clobbered on the same
// render because React runs effects in declaration order.
if (open && !initialTab) setActiveTab('general');
}, [open, initialTab]);
useEffect(() => {
if (loaded) {
@@ -1467,6 +1517,126 @@ const Settings: React.FC = () => {
</Box>
</Box>
{/* OpenAI */}
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography sx={labelSx}>OpenAI</Typography>
{form.openai_api_key ? (
<Typography sx={{ fontSize: '0.6rem', fontWeight: 600, color: c.status.success, bgcolor: `${c.status.success}15`, px: 0.75, py: 0.15, borderRadius: '3px' }}>CONNECTED</Typography>
) : null}
</Box>
<Typography sx={{ ...descSx, mb: 1 }}>GPT-5.4, GPT-5.4 Mini, o-series reasoning models.</Typography>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<TextField
type={showApiKey ? 'text' : 'password'}
value={form.openai_api_key ?? ''}
onChange={(e) => setForm({ ...form, openai_api_key: e.target.value || null })}
size="small"
fullWidth
placeholder="sk-..."
sx={{ ...fieldSx, '& .MuiOutlinedInput-root': { ...fieldSx['& .MuiOutlinedInput-root'], fontFamily: c.font.mono } }}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton onClick={() => setShowApiKey(!showApiKey)} edge="end" size="small" sx={{ color: c.text.tertiary }}>
{showApiKey ? <VisibilityOffIcon sx={{ fontSize: 16 }} /> : <VisibilityIcon sx={{ fontSize: 16 }} />}
</IconButton>
</InputAdornment>
),
}}
/>
<Typography
component="a"
href="https://platform.openai.com/api-keys"
target="_blank"
rel="noopener"
sx={{ color: c.accent.primary, fontSize: '0.72rem', whiteSpace: 'nowrap', textDecoration: 'none', display: 'flex', alignItems: 'center', gap: 0.3, '&:hover': { textDecoration: 'underline' } }}
>
Get key <OpenInNewIcon sx={{ fontSize: 11 }} />
</Typography>
</Box>
</Box>
{/* Google */}
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography sx={labelSx}>Google</Typography>
{form.google_api_key ? (
<Typography sx={{ fontSize: '0.6rem', fontWeight: 600, color: c.status.success, bgcolor: `${c.status.success}15`, px: 0.75, py: 0.15, borderRadius: '3px' }}>CONNECTED</Typography>
) : null}
</Box>
<Typography sx={{ ...descSx, mb: 1 }}>Gemini 3 Pro, Gemini 3 Flash, Gemini 2.5 Pro.</Typography>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<TextField
type={showApiKey ? 'text' : 'password'}
value={form.google_api_key ?? ''}
onChange={(e) => setForm({ ...form, google_api_key: e.target.value || null })}
size="small"
fullWidth
placeholder="AIza..."
sx={{ ...fieldSx, '& .MuiOutlinedInput-root': { ...fieldSx['& .MuiOutlinedInput-root'], fontFamily: c.font.mono } }}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton onClick={() => setShowApiKey(!showApiKey)} edge="end" size="small" sx={{ color: c.text.tertiary }}>
{showApiKey ? <VisibilityOffIcon sx={{ fontSize: 16 }} /> : <VisibilityIcon sx={{ fontSize: 16 }} />}
</IconButton>
</InputAdornment>
),
}}
/>
<Typography
component="a"
href="https://aistudio.google.com/apikey"
target="_blank"
rel="noopener"
sx={{ color: c.accent.primary, fontSize: '0.72rem', whiteSpace: 'nowrap', textDecoration: 'none', display: 'flex', alignItems: 'center', gap: 0.3, '&:hover': { textDecoration: 'underline' } }}
>
Get key <OpenInNewIcon sx={{ fontSize: 11 }} />
</Typography>
</Box>
</Box>
{/* OpenRouter */}
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography sx={labelSx}>OpenRouter</Typography>
{form.openrouter_api_key ? (
<Typography sx={{ fontSize: '0.6rem', fontWeight: 600, color: c.status.success, bgcolor: `${c.status.success}15`, px: 0.75, py: 0.15, borderRadius: '3px' }}>CONNECTED</Typography>
) : null}
</Box>
<Typography sx={{ ...descSx, mb: 1 }}>300+ models from xAI, Meta, DeepSeek, Mistral, Qwen, and more.</Typography>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<TextField
type={showApiKey ? 'text' : 'password'}
value={form.openrouter_api_key ?? ''}
onChange={(e) => setForm({ ...form, openrouter_api_key: e.target.value || null })}
size="small"
fullWidth
placeholder="sk-or-..."
sx={{ ...fieldSx, '& .MuiOutlinedInput-root': { ...fieldSx['& .MuiOutlinedInput-root'], fontFamily: c.font.mono } }}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton onClick={() => setShowApiKey(!showApiKey)} edge="end" size="small" sx={{ color: c.text.tertiary }}>
{showApiKey ? <VisibilityOffIcon sx={{ fontSize: 16 }} /> : <VisibilityIcon sx={{ fontSize: 16 }} />}
</IconButton>
</InputAdornment>
),
}}
/>
<Typography
component="a"
href="https://openrouter.ai/keys"
target="_blank"
rel="noopener"
sx={{ color: c.accent.primary, fontSize: '0.72rem', whiteSpace: 'nowrap', textDecoration: 'none', display: 'flex', alignItems: 'center', gap: 0.3, '&:hover': { textDecoration: 'underline' } }}
>
Get key <OpenInNewIcon sx={{ fontSize: 11 }} />
</Typography>
</Box>
</Box>
</Box>
) : activeTab === 'usage' ? (
<Box sx={{ display: 'flex', flexDirection: 'column', pt: 2.5, pb: 1, animation: 'fadeIn 0.2s ease', '@keyframes fadeIn': { from: { opacity: 0 }, to: { opacity: 1 } } }}>
File diff suppressed because one or more lines are too long