mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-08 18:57:43 +02:00
[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:
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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}],
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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">✓</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">✓</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")
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user