diff --git a/backend/apps/agents/9router_gpt5_patch.js b/backend/apps/agents/9router_gpt5_patch.js index f5295d54..c236c2c7 100644 --- a/backend/apps/agents/9router_gpt5_patch.js +++ b/backend/apps/agents/9router_gpt5_patch.js @@ -55,7 +55,7 @@ const _http = require('http'); const closePage = '
' + - 'You can close this tab, and any other Claude login tab still open.'; + 'You can close this tab, and any other login tab still open.'; http.Server.prototype.emit = function patchedEmit(event, req, res) { if (event === 'request' && req && res) { try { diff --git a/backend/apps/agents/agents.py b/backend/apps/agents/agents.py index 62ea20ce..081ab740 100644 --- a/backend/apps/agents/agents.py +++ b/backend/apps/agents/agents.py @@ -401,8 +401,8 @@ async def subscriptions_connect(body: dict): result = await start_oauth(provider) if result.get("flow") == "authorization_code" and result.get("state"): - from backend.main import p_pending_oauth - p_pending_oauth[result["state"]] = { + from backend.apps.oauth_state import pending_oauth + pending_oauth[result["state"]] = { "provider": provider, "code_verifier": result.get("code_verifier", ""), "redirect_uri": result.get("redirect_uri", ""), @@ -432,6 +432,8 @@ async def subscriptions_poll(body: dict): from backend.apps.service.client import sync as p_sync from backend.apps.settings.settings import load_settings p_sync(load_settings().model_dump()) + from backend.apps.subscription.free_trial import clear_free_trial_on_connect + await clear_free_trial_on_connect() return result except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @@ -465,6 +467,9 @@ async def subscriptions_exchange(body: dict): from backend.apps.service.client import sync as do_sync from backend.apps.settings.settings import load_settings do_sync(load_settings().model_dump()) + # A connected subscription takes precedence over the free trial right away. + from backend.apps.subscription.free_trial import clear_free_trial_on_connect + await clear_free_trial_on_connect() return result except Exception as e: if state and state in completed_oauth: @@ -805,6 +810,16 @@ async def list_models(): if entries: result[cp_name] = entries + # Free lane: nothing of the user's own is connected, so surface the funded Haiku as the free-trial face. The picker shows "Claude Haiku" and the session/default reconcile to it, instead of the picker going empty and the model staying stuck on a dead last-used id (active = it runs; spent = the send is gated by the out-of-runs UI). + if not result: + haiku_entry = next((m for m in anthropic_models if m.get("value") == "haiku"), None) + if haiku_entry: + haiku_rows = p_serialize([haiku_entry]) + for hr in haiku_rows: + hr["is_free"] = True + hr["billing_kind"] = "free" + result["Anthropic"] = haiku_rows + return {"models": result, "notes": notes} diff --git a/backend/apps/agents/core/models.py b/backend/apps/agents/core/models.py index fae0caae..6d8f588d 100644 --- a/backend/apps/agents/core/models.py +++ b/backend/apps/agents/core/models.py @@ -13,6 +13,8 @@ class AgentConfig(BaseModel): max_turns: Optional[int] = None target_directory: Optional[str] = None dashboard_id: Optional[str] = None + workflow_run_id: Optional[str] = None + workflow_edit_id: Optional[str] = None # App cards the user picked to edit. When exactly one resolves, launch binds the chat's cwd to that app instead of seeding a new "Untitled App". selected_app_output_ids: Optional[list[str]] = None @@ -110,6 +112,10 @@ class AgentSession(BaseModel): dashboard_id: Optional[str] = None browser_id: Optional[str] = None parent_session_id: Optional[str] = None + # Set when this session IS a workflow run's agent; the run renders in the Workflows monitor card, so the canvas suppresses the duplicate standalone agent card. + workflow_run_id: Optional[str] = None + # Set when this session IS a workflow's embedded edit/compose chat; it lives in the Workflows hub window, so the canvas suppresses its standalone card and docks its browser below the hub. + workflow_edit_id: Optional[str] = None workflow_test_state: Optional[Literal["running", "complete", "error"]] = None # Browser memory signals, drive the subtle "remembered/learned" card chip so the user feels the agent getting smarter without lifting a finger. memory_recalled: bool = False diff --git a/backend/apps/agents/core/openai_passthrough.py b/backend/apps/agents/core/openai_passthrough.py index ad23b942..20c6db99 100644 --- a/backend/apps/agents/core/openai_passthrough.py +++ b/backend/apps/agents/core/openai_passthrough.py @@ -5,7 +5,7 @@ import logging from contextlib import asynccontextmanager import httpx -from fastapi import Request +from fastapi import Request, Response from fastapi.responses import JSONResponse, StreamingResponse from backend.config.Apps import SubApp @@ -48,25 +48,43 @@ P_GPT5_UNSUPPORTED_PARAMS = ( "logprobs", "top_logprobs", "logit_bias", ) +# Our OpenAI lane's 9Router node prefix; 0.3.60 intermittently forwards the model WITH it (cp-openai/gpt-5.5) so OpenAI 400s "invalid model ID", and as the last hop we strip it to the bare id. +P_CP_OPENAI_PREFIX = "cp-openai/" + +# GPT-5 burns 8-30K hidden reasoning tokens before output and OpenAI 400s "max_tokens reached" under that; the 9router_gpt5 patch's floor never fires on our lane (9Router calls this passthrough, not api.openai.com), so floor it here, only raising. +P_GPT5_MIN_COMPLETION_TOKENS = 32768 + def scrub_gpt5_params(body: bytes) -> bytes: - """For GPT-5: rename max_tokens→max_completion_tokens and drop the sampling - params the reasoning models reject. Bytes in/out, never raises.""" + """Prep an OpenAI chat body: normalize the model id (drop a leaked `cp-openai/` + routing prefix) and, for GPT-5, rename max_tokens→max_completion_tokens and drop the + sampling params the reasoning models reject. Bytes in/out, never raises.""" if not body: return body try: parsed = json.loads(body) except Exception: return body - if not isinstance(parsed, dict) or not p_is_gpt5(str(parsed.get("model") or "")): + if not isinstance(parsed, dict): return body mutated = False + model = str(parsed.get("model") or "") + if model.startswith(P_CP_OPENAI_PREFIX): + model = model[len(P_CP_OPENAI_PREFIX):] + parsed["model"] = model + mutated = True + if not p_is_gpt5(model): + return json.dumps(parsed).encode("utf-8") if mutated else body if "max_tokens" in parsed: if "max_completion_tokens" not in parsed: parsed["max_completion_tokens"] = parsed.pop("max_tokens") else: parsed.pop("max_tokens", None) mutated = True + mct = parsed.get("max_completion_tokens") + if isinstance(mct, (int, float)) and not isinstance(mct, bool) and mct < P_GPT5_MIN_COMPLETION_TOKENS: + parsed["max_completion_tokens"] = P_GPT5_MIN_COMPLETION_TOKENS + mutated = True if "temperature" in parsed and parsed["temperature"] != 1: parsed.pop("temperature", None) mutated = True @@ -112,6 +130,21 @@ async def passthrough(rest: str, request: Request): status_code=502, ) + # OpenAI sends 4xx/5xx as a small JSON error (not a stream); surface its real complaint (we used to swallow it) and return it decoded so the caller sees why. + if upstream_resp.status_code >= 400: + raw = await upstream_resp.aread() + await upstream_resp.aclose() + await client.aclose() + logger.warning( + "openai-passthrough upstream %s on /%s: %s", + upstream_resp.status_code, rest, raw.decode("utf-8", "replace")[:400], + ) + return Response( + content=raw, + status_code=upstream_resp.status_code, + media_type=upstream_resp.headers.get("content-type", "application/json"), + ) + response_headers: dict[str, str] = {} for k, v in upstream_resp.headers.items(): if k.lower() in P_HOP_HEADERS: diff --git a/backend/apps/agents/core/ws_manager.py b/backend/apps/agents/core/ws_manager.py index bfe58269..d3e3aa27 100644 --- a/backend/apps/agents/core/ws_manager.py +++ b/backend/apps/agents/core/ws_manager.py @@ -1,6 +1,7 @@ import asyncio import json import logging +from typing import Optional from fastapi import WebSocket from backend.apps.agents.core.seq_log import TERMINAL_STATUSES, seq_log @@ -39,6 +40,9 @@ class ConnectionManager: def __init__(self): self.connections: dict[str, list[WebSocket]] = {} self.global_connections: list[WebSocket] = [] + # Which dashboard each global socket is currently showing, keyed by id(websocket). active_dashboard_id is the last one activated (the window the user is looking at most recently); a scheduled run targets it so its browser card spawns where the renderer can render it. + self.global_dashboard_ids: dict[int, str] = {} + self.active_dashboard_id: Optional[str] = None self.pending_futures: dict[str, asyncio.Future] = {} self.browser_futures: dict[str, asyncio.Future] = {} @@ -60,10 +64,19 @@ class ConnectionManager: if not self.connections[session_id]: del self.connections[session_id] + def set_active_dashboard(self, websocket: WebSocket, dashboard_id: str): + """Record which dashboard a renderer is showing; last activation wins.""" + self.global_dashboard_ids[id(websocket)] = dashboard_id + self.active_dashboard_id = dashboard_id + def disconnect_global(self, websocket: WebSocket): self.global_connections = [ ws for ws in self.global_connections if ws != websocket ] + # Drop this socket's active-dashboard pointer; if it owned the global one, fall back to any window still connected so a closed tab doesn't leave a stale target. + self.global_dashboard_ids.pop(id(websocket), None) + if self.active_dashboard_id not in self.global_dashboard_ids.values(): + self.active_dashboard_id = next(iter(self.global_dashboard_ids.values()), None) async def send_to_session(self, session_id: str, event: str, data: dict): """Broadcast a session event with monotonic sequencing; terminal statuses also persist to disk.""" diff --git a/backend/apps/agents/manager/AgentLaunch.py b/backend/apps/agents/manager/AgentLaunch.py index 40ff612b..c8d2a54a 100644 --- a/backend/apps/agents/manager/AgentLaunch.py +++ b/backend/apps/agents/manager/AgentLaunch.py @@ -115,6 +115,8 @@ class AgentLaunch(AgentManagerProtocol): repo_url=repo_url, branch=branch_name, dashboard_id=config.dashboard_id, + workflow_run_id=config.workflow_run_id, + workflow_edit_id=config.workflow_edit_id, thinking_level=getattr(global_settings, "default_thinking_level", "auto"), ) apply_context_window(session, global_settings) diff --git a/backend/apps/agents/providers/registry.py b/backend/apps/agents/providers/registry.py index 49458a7b..0d593468 100644 --- a/backend/apps/agents/providers/registry.py +++ b/backend/apps/agents/providers/registry.py @@ -217,6 +217,9 @@ def p_antigravity_connected() -> bool: def resolve_model_id_for_sdk(short_name: str, settings: AppSettings) -> str: """Short model name → id string for ClaudeAgentOptions.""" + # Free trial funds only Haiku via the cloud proxy; force it so a session left on a gpt-*/sub model can't escape to a lane the trial can't fund (which snags as a 401/404). + if getattr(settings, "connection_mode", "own_key") == "free-trial": + short_name = "haiku" entry = find_builtin_model(short_name) if entry is None: return short_name diff --git a/backend/apps/nine_router/oauth.py b/backend/apps/nine_router/oauth.py index 239d67cd..538bade6 100644 --- a/backend/apps/nine_router/oauth.py +++ b/backend/apps/nine_router/oauth.py @@ -17,7 +17,9 @@ logger = logging.getLogger(__name__) # OpenAI's Codex OAuth client is registered with a fixed redirect URI `http://localhost:1455/auth/callback` and rejects any other with `unknown_error`. Anthropic and Google's clients accept arbitrary localhost callbacks (we use 9Router's 20128 callback page). For Codex we spawn a one-shot listener on 1455 that serves the same postMessage/BroadcastChannel/localStorage relay so the frontend's existing popup + msgHandler flow works unchanged. -P_CODEX_CALLBACK_PORT = 1455 +# OpenAI's Codex OAuth client registers BOTH loopback redirect ports in its Hydra allow-list (1455 default, 1457 fallback) and the official Codex CLI falls back to 1457 for the "another app holds 1455" case (openai/codex PR #19334), so we try them in order and reject anything off the list. +P_CODEX_CALLBACK_PORTS = (1455, 1457) +P_CODEX_CALLBACK_PORT = P_CODEX_CALLBACK_PORTS[0] P_CODEX_CALLBACK_PATH = "/auth/callback" P_CODEX_CALLBACK_HTML = b"""
@@ -54,14 +56,19 @@ p{color:#888;margin:0}