mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-11 12:17:45 +02:00
[haik]: refactor: standardize backend naming and import structure - strip leading underscores from all internal functions/constants across agents, settings, session, and provider modules in favor of public names or P_ prefix for module-private symbols; replace nine_router/init.py barrel re-exports with direct submodule imports (process, sync, sync_custom, oauth); hoist inline stdlib imports to module level; delete dead openai_passthrough.py GPT-5 proxy and the redundant _save_settings alias
This commit is contained in:
@@ -33,41 +33,41 @@ from backend.apps.agents.core.error_classify import (
|
||||
is_unknown_model_error,
|
||||
)
|
||||
from backend.apps.agents.manager.session.session_store import (
|
||||
_delete_session_file,
|
||||
_load_all_session_data,
|
||||
_load_session_data,
|
||||
_save_session,
|
||||
delete_session_file,
|
||||
load_all_session_data,
|
||||
load_session_data,
|
||||
save_session,
|
||||
build_search_text,
|
||||
)
|
||||
from backend.apps.agents.manager.session.cloud_sync import _sync_session_close
|
||||
from backend.apps.agents.manager.session.workspace_git import _detect_git_identity, _ensure_cwd_git_repo
|
||||
from backend.apps.agents.manager.session.cloud_sync import sync_session_close
|
||||
from backend.apps.agents.manager.session.workspace_git import detect_git_identity, ensure_cwd_git_repo
|
||||
from backend.apps.agents.manager.prompt.tool_catalog import (
|
||||
FULL_TOOLS,
|
||||
_get_all_known_tool_names,
|
||||
_get_denied_tool_names,
|
||||
_is_fully_denied,
|
||||
get_all_known_tool_names,
|
||||
get_denied_tool_names,
|
||||
is_fully_denied,
|
||||
)
|
||||
from backend.apps.agents.core.aux_llm import safe_resp_text, clean_short_label
|
||||
from backend.apps.agents.manager.session.history_compaction import (
|
||||
_build_history_prefix,
|
||||
_get_branch_messages,
|
||||
_truncate_large_tool_result,
|
||||
build_history_prefix,
|
||||
get_branch_messages,
|
||||
truncate_large_tool_result,
|
||||
)
|
||||
from backend.apps.agents.manager.prompt.prompt_context import (
|
||||
_build_browser_context,
|
||||
_build_selected_app_context,
|
||||
_build_connected_tools_context,
|
||||
_build_mcp_registry_summary,
|
||||
_compose_system_prompt,
|
||||
_resolve_attached_skills,
|
||||
_resolve_forced_tools,
|
||||
_resolve_mode,
|
||||
build_browser_context,
|
||||
build_selected_app_context,
|
||||
build_connected_tools_context,
|
||||
build_mcp_registry_summary,
|
||||
compose_system_prompt,
|
||||
resolve_attached_skills,
|
||||
resolve_forced_tools,
|
||||
resolve_mode,
|
||||
)
|
||||
from backend.apps.agents.manager.prompt.attachments import (
|
||||
_build_dir_tree,
|
||||
_build_prompt_content,
|
||||
_resolve_attachments,
|
||||
_resolve_context_paths,
|
||||
build_dir_tree,
|
||||
build_prompt_content,
|
||||
resolve_attachments,
|
||||
resolve_context_paths,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -119,7 +119,7 @@ def get_all_tool_names() -> list[str]:
|
||||
if t.mcp_config
|
||||
and t.enabled
|
||||
and t.auth_status in ("configured", "connected")
|
||||
and not _is_fully_denied(t)
|
||||
and not is_fully_denied(t)
|
||||
]
|
||||
return builtin_tools + mcp_names
|
||||
|
||||
@@ -130,7 +130,7 @@ class AgentManager:
|
||||
self.tasks: dict[str, asyncio.Task] = {}
|
||||
|
||||
def _resolve_mode(self, mode_id: str) -> tuple[list[str], str | None, str | None]:
|
||||
return _resolve_mode(mode_id, get_all_tool_names)
|
||||
return resolve_mode(mode_id, get_all_tool_names)
|
||||
|
||||
async def _build_mcp_servers(
|
||||
self,
|
||||
@@ -178,7 +178,7 @@ class AgentManager:
|
||||
logger.info(f"[MCP-DEBUG] GATED {server_name}: not in session.active_mcps, model must call MCPActivate first")
|
||||
continue
|
||||
|
||||
if _is_fully_denied(tool):
|
||||
if is_fully_denied(tool):
|
||||
logger.info(f"[MCP-DEBUG] SKIPPED {tool.name}: fully denied")
|
||||
continue
|
||||
|
||||
@@ -206,19 +206,19 @@ class AgentManager:
|
||||
return mcp_servers
|
||||
|
||||
def _build_connected_tools_context(self, allowed_tools: list[str]) -> str | None:
|
||||
return _build_connected_tools_context(allowed_tools, get_all_tool_names)
|
||||
return build_connected_tools_context(allowed_tools, get_all_tool_names)
|
||||
|
||||
def _build_browser_context(self, dashboard_id: str | None, selected_browser_ids: list[str] | None = None) -> str | None:
|
||||
return _build_browser_context(dashboard_id, selected_browser_ids)
|
||||
return build_browser_context(dashboard_id, selected_browser_ids)
|
||||
|
||||
def _build_selected_app_context(self, selected_app_output_ids: list[str] | None) -> str | None:
|
||||
return _build_selected_app_context(selected_app_output_ids)
|
||||
return build_selected_app_context(selected_app_output_ids)
|
||||
|
||||
def _build_mcp_registry_summary(self, allowed_tools: list[str], active_mcps: list[str]) -> str | None:
|
||||
return _build_mcp_registry_summary(allowed_tools, active_mcps, get_all_tool_names)
|
||||
return build_mcp_registry_summary(allowed_tools, active_mcps, get_all_tool_names)
|
||||
|
||||
def _compose_system_prompt(self, default_prompt: str | None, mode_prompt: str | None, session_prompt: str | None, connected_tools_ctx: str | None = None, browser_ctx: str | None = None, mcp_registry_ctx: str | None = None) -> str | None:
|
||||
return _compose_system_prompt(default_prompt, mode_prompt, session_prompt, connected_tools_ctx, browser_ctx, mcp_registry_ctx)
|
||||
return compose_system_prompt(default_prompt, mode_prompt, session_prompt, connected_tools_ctx, browser_ctx, mcp_registry_ctx)
|
||||
|
||||
async def launch_agent(self, config: AgentConfig) -> AgentSession:
|
||||
session_id = uuid4().hex
|
||||
@@ -291,9 +291,9 @@ class AgentManager:
|
||||
effective_cwd = os.path.join(_home, ".openswarm", "workspaces", session_id)
|
||||
os.makedirs(effective_cwd, exist_ok=True)
|
||||
|
||||
_ensure_cwd_git_repo(effective_cwd, _home)
|
||||
ensure_cwd_git_repo(effective_cwd, _home)
|
||||
|
||||
repo_url, branch_name = _detect_git_identity(effective_cwd)
|
||||
repo_url, branch_name = detect_git_identity(effective_cwd)
|
||||
|
||||
session = AgentSession(
|
||||
id=session_id,
|
||||
@@ -322,13 +322,13 @@ class AgentManager:
|
||||
return session
|
||||
|
||||
def _build_dir_tree(self, root: str, max_depth: int = 4, prefix: str = "") -> list[str]:
|
||||
return _build_dir_tree(root, max_depth, prefix)
|
||||
return build_dir_tree(root, max_depth, prefix)
|
||||
|
||||
def _resolve_forced_tools(self, forced_tools: list[str] | None) -> str:
|
||||
return _resolve_forced_tools(forced_tools)
|
||||
return resolve_forced_tools(forced_tools)
|
||||
|
||||
def _resolve_attached_skills(self, attached_skills: list | None) -> str:
|
||||
return _resolve_attached_skills(attached_skills)
|
||||
return resolve_attached_skills(attached_skills)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Compaction & token guard (Phase 2)
|
||||
@@ -351,12 +351,12 @@ class AgentManager:
|
||||
sets compacted_through_msg_id and emits a context_status event.
|
||||
Never modifies session.messages, originals stay around for the
|
||||
UI drawer; only the history *sent to the SDK* is trimmed (handled
|
||||
in _build_history_prefix lookups).
|
||||
in build_history_prefix lookups).
|
||||
"""
|
||||
ctx_used = session.tokens.get("input", 0) / max(1, session.context_window)
|
||||
if not force and ctx_used < session.compact_threshold_pct:
|
||||
return False
|
||||
msgs = _get_branch_messages(session)
|
||||
msgs = get_branch_messages(session)
|
||||
if len(msgs) < 4:
|
||||
return False
|
||||
# Summarize everything up to (but not including) the last 6
|
||||
@@ -373,13 +373,13 @@ class AgentManager:
|
||||
return True
|
||||
|
||||
def _build_prompt_content(self, prompt: str, images: list | None = None, context_paths: list | None = None, forced_tools: list[str] | None = None, attached_skills: list | None = None, api_type: str = "anthropic", model: str = ""):
|
||||
return _build_prompt_content(prompt, images, context_paths, forced_tools, attached_skills, api_type, model)
|
||||
return build_prompt_content(prompt, images, context_paths, forced_tools, attached_skills, api_type, model)
|
||||
|
||||
def _resolve_attachments(self, context_paths: list | None, api_type: str, model: str) -> tuple[str, list[dict], list[str]]:
|
||||
return _resolve_attachments(context_paths, api_type, model)
|
||||
return resolve_attachments(context_paths, api_type, model)
|
||||
|
||||
def _resolve_context_paths(self, context_paths: list | None) -> str:
|
||||
return _resolve_context_paths(context_paths)
|
||||
return resolve_context_paths(context_paths)
|
||||
|
||||
async def _run_agent_loop(self, session_id: str, prompt: str, images: list | None = None, context_paths: list | None = None, forced_tools: list[str] | None = None, attached_skills: list | None = None, fork_session: bool = False, selected_browser_ids: list[str] | None = None, selected_app_output_ids: list[str] | None = None):
|
||||
"""Run the Claude Agent SDK query loop for a session."""
|
||||
@@ -387,8 +387,8 @@ class AgentManager:
|
||||
if not session:
|
||||
return
|
||||
|
||||
from backend.apps.agents.providers.registry import get_api_type as _get_api_type
|
||||
_api = _get_api_type(session.model)
|
||||
from backend.apps.agents.providers.registry import get_api_type as idk_get_api_type
|
||||
_api = idk_get_api_type(session.model)
|
||||
prompt_content = self._build_prompt_content(
|
||||
prompt, images, context_paths, forced_tools, attached_skills,
|
||||
api_type=_api, model=session.model,
|
||||
@@ -997,7 +997,7 @@ class AgentManager:
|
||||
# at *write* time (before the next turn ships history to the
|
||||
# SDK) so the bloat never re-enters context.
|
||||
try:
|
||||
truncated_content, blob_path = _truncate_large_tool_result(
|
||||
truncated_content, blob_path = truncate_large_tool_result(
|
||||
result_msg.content, session.id, result_msg.id
|
||||
)
|
||||
if blob_path:
|
||||
@@ -1248,8 +1248,8 @@ class AgentManager:
|
||||
# settings is NOT enough; it must be a *-api route model. Everyone
|
||||
# else registers openswarm-web and cascades through /api/web/search.
|
||||
from backend.apps.agents.tools.web import anthropic_web_search_is_reliable
|
||||
from backend.apps.agents.providers.registry import _find_builtin_model as _fbm_web
|
||||
_web_model_entry = _fbm_web(session.model)
|
||||
from backend.apps.agents.providers.registry import find_builtin_model as fbm_web
|
||||
_web_model_entry = fbm_web(session.model)
|
||||
_uses_direct_anthropic_api = (
|
||||
_web_model_entry is not None
|
||||
and _web_model_entry.get("route") == "api"
|
||||
@@ -1344,8 +1344,8 @@ class AgentManager:
|
||||
None,
|
||||
)
|
||||
if tool_def:
|
||||
denied = _get_denied_tool_names(tool_def)
|
||||
known = _get_all_known_tool_names(tool_def)
|
||||
denied = get_denied_tool_names(tool_def)
|
||||
known = get_all_known_tool_names(tool_def)
|
||||
for tn in known - denied:
|
||||
policy = tool_def.tool_permissions.get(tn, "ask")
|
||||
if policy == "always_allow":
|
||||
@@ -1465,12 +1465,12 @@ class AgentManager:
|
||||
}
|
||||
# cc/cx/gc/ag/gemini/openrouter prefixes force 9Router; route="api"
|
||||
# bypasses to the provider's host directly; otherwise Pro proxy or key.
|
||||
from backend.apps.nine_router import is_running as _9r_running
|
||||
from backend.apps.agents.providers.registry import _NINEROUTER_MODEL_PREFIXES
|
||||
resolved_is_9router = isinstance(resolved_model, str) and resolved_model.startswith(_NINEROUTER_MODEL_PREFIXES)
|
||||
from backend.apps.nine_router.process import is_running
|
||||
from backend.apps.agents.providers.registry import NINEROUTER_MODEL_PREFIXES
|
||||
resolved_is_9router = isinstance(resolved_model, str) and resolved_model.startswith(NINEROUTER_MODEL_PREFIXES)
|
||||
|
||||
from backend.apps.agents.providers.registry import _find_builtin_model
|
||||
_model_entry = _find_builtin_model(session.model)
|
||||
from backend.apps.agents.providers.registry import find_builtin_model
|
||||
_model_entry = find_builtin_model(session.model)
|
||||
_is_pinned_api_route = (
|
||||
_model_entry is not None
|
||||
and _model_entry.get("route") == "api"
|
||||
@@ -1513,18 +1513,18 @@ class AgentManager:
|
||||
# User-configured OpenAI-compatible endpoint (Ollama Cloud,
|
||||
# Together, local Ollama, etc.). Routes through 9Router's
|
||||
# openai-compatible provider node we synced from settings.
|
||||
from backend.apps.nine_router import ensure_running as _9r_ensure_c
|
||||
if not _9r_running():
|
||||
from backend.apps.nine_router.process import ensure_running
|
||||
if not is_running():
|
||||
logger.info(f"[MCP-DEBUG] custom provider selected but 9Router not running; waiting for startup")
|
||||
await _9r_ensure_c()
|
||||
if not _9r_running():
|
||||
await ensure_running()
|
||||
if not is_running():
|
||||
raise ValueError(
|
||||
"9Router could not start. Custom OpenAI-compatible "
|
||||
"providers need 9Router to translate the Anthropic "
|
||||
"protocol, install Node.js and restart the app."
|
||||
)
|
||||
from backend.apps.agents.providers.registry import _find_custom_provider_for_value
|
||||
cp = _find_custom_provider_for_value(global_settings, session.model)
|
||||
from backend.apps.agents.providers.registry import find_custom_provider_for_value
|
||||
cp = find_custom_provider_for_value(global_settings, session.model)
|
||||
env = {
|
||||
"ANTHROPIC_API_KEY": "9router",
|
||||
"ANTHROPIC_BASE_URL": "http://localhost:20128",
|
||||
@@ -1538,8 +1538,8 @@ class AgentManager:
|
||||
# CLI can issue requests. Servers that DO check auth always
|
||||
# have a real key configured.
|
||||
env["OPENAI_API_KEY"] = (cp.api_key or "").strip() or "no-auth-required"
|
||||
from backend.apps.nine_router import normalize_openai_compat_base_url as _norm_cp_url
|
||||
env["OPENAI_BASE_URL"] = _norm_cp_url(cp.base_url or "")
|
||||
from backend.apps.nine_router.sync_custom import normalize_openai_compat_base_url
|
||||
env["OPENAI_BASE_URL"] = normalize_openai_compat_base_url(cp.base_url or "")
|
||||
# Pin subagent ids, without these, CLI's default Haiku 4.5
|
||||
# gets sent to the custom provider and 404s.
|
||||
if global_settings.anthropic_api_key:
|
||||
@@ -1575,11 +1575,11 @@ class AgentManager:
|
||||
# CLI's WebSearch delegation needs an Anthropic-shaped lane;
|
||||
# if the user has no Anthropic key/sub/Pro, fall back to OR's
|
||||
# resold Claude so subagents stay on the same OR billing.
|
||||
if not _9r_running():
|
||||
from backend.apps.nine_router import ensure_running as _9r_ensure
|
||||
if not is_running():
|
||||
from backend.apps.nine_router.process import ensure_running
|
||||
logger.info(f"[MCP-DEBUG] OpenRouter selected but 9Router not running; waiting for startup")
|
||||
await _9r_ensure()
|
||||
if not _9r_running():
|
||||
await ensure_running()
|
||||
if not is_running():
|
||||
raise ValueError(
|
||||
"9Router could not start. OpenRouter routing requires "
|
||||
"Node.js, install it and restart the app, or pick a "
|
||||
@@ -1619,7 +1619,7 @@ class AgentManager:
|
||||
elif api_type == "anthropic" and not resolved_is_9router and global_settings.anthropic_api_key:
|
||||
options_kwargs["env"] = {"ANTHROPIC_API_KEY": global_settings.anthropic_api_key}
|
||||
logger.info("[MCP-DEBUG] Using direct Anthropic API key")
|
||||
elif _9r_running():
|
||||
elif is_running():
|
||||
# Gemini-bound ids go through the local proxy for schema scrubbing;
|
||||
# everything else hits 9Router directly.
|
||||
_is_gemini_bound = (
|
||||
@@ -1663,10 +1663,10 @@ class AgentManager:
|
||||
logger.info(f"[MCP-DEBUG] Using 9Router (api_type={api_type})")
|
||||
else:
|
||||
if api_type != "anthropic":
|
||||
from backend.apps.nine_router import ensure_running as _9r_ensure
|
||||
from backend.apps.nine_router.process import ensure_running
|
||||
logger.info(f"[MCP-DEBUG] 9Router not running for non-Anthropic model {session.model}; waiting for startup")
|
||||
await _9r_ensure()
|
||||
if _9r_running():
|
||||
await ensure_running()
|
||||
if is_running():
|
||||
options_kwargs["env"] = {
|
||||
"ANTHROPIC_API_KEY": "9router",
|
||||
"ANTHROPIC_BASE_URL": "http://localhost:20128",
|
||||
@@ -1726,7 +1726,7 @@ class AgentManager:
|
||||
# the git-init block in launch_agent, leaving them
|
||||
# without a valid HEAD. Ensure it here so subagent
|
||||
# worktree-add always works.
|
||||
_ensure_cwd_git_repo(session.cwd)
|
||||
ensure_cwd_git_repo(session.cwd)
|
||||
options_kwargs["cwd"] = session.cwd
|
||||
|
||||
try:
|
||||
@@ -1764,7 +1764,7 @@ class AgentManager:
|
||||
# Fresh-restart path: some session changes must not reuse the
|
||||
# CLI's resume transcript. MCPActivate needs a new transport so
|
||||
# tool schemas are reread; branch edits/switches need the model
|
||||
# to see only _get_branch_messages(session), not facts from the
|
||||
# to see only get_branch_messages(session), not facts from the
|
||||
# old branch's SDK transcript. Soft restart: drop resume +
|
||||
# sdk_session_id, replay local history via the prompt, let the
|
||||
# SDK build a clean session from the current app state.
|
||||
@@ -1785,8 +1785,8 @@ class AgentManager:
|
||||
if session.needs_fork:
|
||||
session.needs_fork = False
|
||||
elif len(session.messages) > 1:
|
||||
history = _build_history_prefix(
|
||||
_get_branch_messages(session),
|
||||
history = build_history_prefix(
|
||||
get_branch_messages(session),
|
||||
cutoff_msg_id=session.compacted_through_msg_id,
|
||||
)
|
||||
if history:
|
||||
@@ -1990,11 +1990,11 @@ class AgentManager:
|
||||
# answer text (e.g. 13).
|
||||
if not _turn_thinking_text_parts or force_provider_unavailable:
|
||||
try:
|
||||
from backend.apps.nine_router import (
|
||||
from backend.apps.nine_router.process import (
|
||||
get_latest_reasoning_tokens,
|
||||
is_running as _9r_running,
|
||||
is_running
|
||||
)
|
||||
if _9r_running():
|
||||
if is_running():
|
||||
rt = await get_latest_reasoning_tokens(model_hint=session.model)
|
||||
if rt and rt > 0:
|
||||
upstream_reasoning_tokens = rt
|
||||
@@ -2039,11 +2039,11 @@ class AgentManager:
|
||||
turn_tokens = max(_turn_output_tokens, heuristic_tokens)
|
||||
else:
|
||||
try:
|
||||
from backend.apps.nine_router import (
|
||||
from backend.apps.nine_router.process import (
|
||||
get_latest_reasoning_tokens,
|
||||
is_running as _9r_running,
|
||||
is_running,
|
||||
)
|
||||
if _9r_running():
|
||||
if is_running():
|
||||
rt = await get_latest_reasoning_tokens(model_hint=session.model)
|
||||
if rt and rt > 0:
|
||||
turn_tokens = rt
|
||||
@@ -2704,7 +2704,7 @@ class AgentManager:
|
||||
cost = 0.0
|
||||
elif isinstance(resolved_model, str) and resolved_model.startswith("openrouter/"):
|
||||
# SDK assumes Anthropic rates → 50-100× off for OR.
|
||||
from backend.apps.agents.providers.registry import get_openrouter_pricing
|
||||
from backend.apps.agents.providers.openrouter import get_openrouter_pricing
|
||||
pricing = get_openrouter_pricing(resolved_model)
|
||||
if pricing:
|
||||
in_rate, out_rate = pricing
|
||||
@@ -2725,7 +2725,7 @@ class AgentManager:
|
||||
# ($30 instead of $0.04 per Mehmet-style
|
||||
# 4-PDF turn). Use the published per-model
|
||||
# rates instead.
|
||||
from backend.apps.agents.providers.registry import get_direct_pricing
|
||||
from backend.apps.agents.providers.openrouter import get_direct_pricing
|
||||
pricing = get_direct_pricing(resolved_model) or get_direct_pricing(session.model)
|
||||
if pricing:
|
||||
in_rate, out_rate = pricing
|
||||
@@ -3112,7 +3112,7 @@ class AgentManager:
|
||||
"session": session.model_dump(mode="json"),
|
||||
})
|
||||
try:
|
||||
_save_session(session_id, session.model_dump(mode="json"))
|
||||
save_session(session_id, session.model_dump(mode="json"))
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to snapshot session {session_id}: {e}")
|
||||
|
||||
@@ -3270,7 +3270,7 @@ class AgentManager:
|
||||
"""Send a follow-up message to an existing session."""
|
||||
session = self.sessions.get(session_id)
|
||||
if not session:
|
||||
data = _load_session_data(session_id)
|
||||
data = load_session_data(session_id)
|
||||
if data:
|
||||
session = AgentSession(**data)
|
||||
_apply_context_window(session)
|
||||
@@ -3293,8 +3293,8 @@ class AgentManager:
|
||||
# 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):
|
||||
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}")
|
||||
|
||||
@@ -3523,7 +3523,7 @@ class AgentManager:
|
||||
"session": session.model_dump(mode="json"),
|
||||
})
|
||||
try:
|
||||
_save_session(session_id, session.model_dump(mode="json"))
|
||||
save_session(session_id, session.model_dump(mode="json"))
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to snapshot session {session_id}: {e}")
|
||||
|
||||
@@ -3822,8 +3822,8 @@ class AgentManager:
|
||||
return
|
||||
|
||||
try:
|
||||
from backend.apps.agents.providers.registry import _find_builtin_model
|
||||
entry = _find_builtin_model(session.model)
|
||||
from backend.apps.agents.providers.registry import find_builtin_model
|
||||
entry = find_builtin_model(session.model)
|
||||
if not entry or entry.get("api") != "anthropic":
|
||||
return # other providers handle caching automatically
|
||||
|
||||
@@ -3962,7 +3962,7 @@ class AgentManager:
|
||||
return build_search_text(session, max_len)
|
||||
|
||||
def _sync_session_close(self, session: AgentSession, close_reason: str = "user"):
|
||||
_sync_session_close(session, close_reason)
|
||||
sync_session_close(session, close_reason)
|
||||
|
||||
async def close_session(self, session_id: str) -> None:
|
||||
"""Close a session: pause the agent if running, persist to JSON file,
|
||||
@@ -4002,7 +4002,7 @@ class AgentManager:
|
||||
doc_data = session.model_dump(mode="json")
|
||||
doc_data["search_text"] = self._build_search_text(session)
|
||||
|
||||
_save_session(session_id, doc_data)
|
||||
save_session(session_id, doc_data)
|
||||
|
||||
await ws_manager.send_to_session(session_id, "agent:closed", {
|
||||
"session_id": session_id,
|
||||
@@ -4041,7 +4041,7 @@ class AgentManager:
|
||||
self.sessions.pop(session_id, None)
|
||||
self.tasks.pop(session_id, None)
|
||||
|
||||
_delete_session_file(session_id)
|
||||
delete_session_file(session_id)
|
||||
logger.info(f"Session {session_id} permanently deleted")
|
||||
|
||||
async def resume_session(self, session_id: str) -> AgentSession:
|
||||
@@ -4049,7 +4049,7 @@ class AgentManager:
|
||||
if session_id in self.sessions:
|
||||
return self.sessions[session_id]
|
||||
|
||||
data = _load_session_data(session_id)
|
||||
data = load_session_data(session_id)
|
||||
if data is None:
|
||||
raise ValueError(f"Session {session_id} not found in history")
|
||||
|
||||
@@ -4064,7 +4064,7 @@ class AgentManager:
|
||||
# chat permanently removed it from history on the next restart.
|
||||
# The disk copy stays as the durable record; subsequent turn
|
||||
# completions and close_session calls overwrite it via
|
||||
# _save_session, so memory and disk stay in sync.
|
||||
# save_session, so memory and disk stay in sync.
|
||||
|
||||
await ws_manager.send_to_session(session_id, "agent:status", {
|
||||
"session_id": session_id,
|
||||
@@ -4083,7 +4083,7 @@ class AgentManager:
|
||||
dashboard_id: str | None = None,
|
||||
) -> dict:
|
||||
"""Return paginated, optionally filtered summaries of closed sessions."""
|
||||
all_data = _load_all_session_data()
|
||||
all_data = load_all_session_data()
|
||||
all_data.sort(key=lambda pair: pair[1].get("closed_at") or "", reverse=True)
|
||||
|
||||
q_lower = q.strip().lower()
|
||||
@@ -4118,7 +4118,7 @@ class AgentManager:
|
||||
|
||||
async def reconcile_on_startup(self) -> None:
|
||||
"""Mark any stale running sessions as stopped."""
|
||||
for sid, data in _load_all_session_data():
|
||||
for sid, data in load_all_session_data():
|
||||
dirty = False
|
||||
if data.get("status") in ("running", "waiting_approval"):
|
||||
data["status"] = "stopped"
|
||||
@@ -4130,7 +4130,7 @@ class AgentManager:
|
||||
data["mode"] = "ask"
|
||||
dirty = True
|
||||
if dirty:
|
||||
_save_session(sid, data)
|
||||
save_session(sid, data)
|
||||
|
||||
async def persist_all_sessions(self) -> None:
|
||||
"""Flush every in-memory session to JSON files (for graceful shutdown)."""
|
||||
@@ -4147,7 +4147,7 @@ class AgentManager:
|
||||
self._sync_session_close(session, close_reason="shutdown")
|
||||
doc_data = session.model_dump(mode="json")
|
||||
doc_data["search_text"] = self._build_search_text(session)
|
||||
_save_session(session_id, doc_data)
|
||||
save_session(session_id, doc_data)
|
||||
logger.info(f"Persisted session {session_id} on shutdown")
|
||||
self.sessions.clear()
|
||||
self.tasks.clear()
|
||||
@@ -4159,7 +4159,7 @@ class AgentManager:
|
||||
shutdown). Sessions with closed_at were explicitly closed by the user
|
||||
and stay on disk so the history endpoint can still serve them.
|
||||
"""
|
||||
for sid, data in _load_all_session_data():
|
||||
for sid, data in load_all_session_data():
|
||||
try:
|
||||
session = AgentSession(**data)
|
||||
except Exception as e:
|
||||
@@ -4172,14 +4172,14 @@ class AgentManager:
|
||||
session.pending_approvals = []
|
||||
_apply_context_window(session)
|
||||
self.sessions[session.id] = session
|
||||
_delete_session_file(sid)
|
||||
delete_session_file(sid)
|
||||
logger.info(f"Restored session {session.id}")
|
||||
|
||||
async def duplicate_session(self, session_id: str, dashboard_id: str | None = None, up_to_message_id: str | None = None) -> AgentSession:
|
||||
"""Create an independent copy of a session with the same chat history."""
|
||||
source = self.sessions.get(session_id)
|
||||
if not source:
|
||||
data = _load_session_data(session_id)
|
||||
data = load_session_data(session_id)
|
||||
if data is None:
|
||||
raise ValueError(f"Session {session_id} not found")
|
||||
source = AgentSession(**data)
|
||||
@@ -4262,7 +4262,7 @@ class AgentManager:
|
||||
"""Fork an existing session and send it a new message, returning the result."""
|
||||
source = self.sessions.get(source_session_id)
|
||||
if not source:
|
||||
data = _load_session_data(source_session_id)
|
||||
data = load_session_data(source_session_id)
|
||||
if data is None:
|
||||
raise ValueError(f"Session {source_session_id} not found")
|
||||
source = AgentSession(**data)
|
||||
@@ -4387,7 +4387,7 @@ class AgentManager:
|
||||
results.append(s.model_dump(mode="json"))
|
||||
seen.add(s.id)
|
||||
|
||||
for sid, data in _load_all_session_data():
|
||||
for sid, data in load_all_session_data():
|
||||
if sid in seen:
|
||||
continue
|
||||
if data.get("mode") == "browser-agent" and data.get("parent_session_id") == parent_session_id:
|
||||
|
||||
@@ -337,7 +337,8 @@ async def clear_session(session_id: str):
|
||||
@agents.router.get("/subscriptions/status")
|
||||
async def subscriptions_status():
|
||||
"""Check if 9Router is running and list connected providers."""
|
||||
from backend.apps.nine_router import is_running, get_providers, get_models
|
||||
from backend.apps.nine_router.process import is_running, get_providers
|
||||
from backend.apps.nine_router.oauth import get_models
|
||||
if not is_running():
|
||||
return {"running": False, "providers": [], "models": []}
|
||||
connections = await get_providers()
|
||||
@@ -349,7 +350,8 @@ async def subscriptions_status():
|
||||
@agents.router.post("/subscriptions/connect")
|
||||
async def subscriptions_connect(body: dict):
|
||||
"""Start OAuth flow for a subscription provider."""
|
||||
from backend.apps.nine_router import is_running, ensure_running, start_oauth
|
||||
from backend.apps.nine_router.process import is_running, ensure_running
|
||||
from backend.apps.nine_router.oauth import start_oauth
|
||||
provider = body.get("provider", "")
|
||||
if not provider:
|
||||
raise HTTPException(status_code=400, detail="provider required")
|
||||
@@ -386,7 +388,7 @@ async def subscriptions_connect(body: dict):
|
||||
@agents.router.post("/subscriptions/poll")
|
||||
async def subscriptions_poll(body: dict):
|
||||
"""Poll for OAuth completion."""
|
||||
from backend.apps.nine_router import poll_oauth
|
||||
from backend.apps.nine_router.oauth import poll_oauth
|
||||
provider = body.get("provider", "")
|
||||
device_code = body.get("device_code", "")
|
||||
if not provider or not device_code:
|
||||
@@ -410,7 +412,7 @@ async def subscriptions_poll(body: dict):
|
||||
@agents.router.post("/subscriptions/exchange")
|
||||
async def subscriptions_exchange(body: dict):
|
||||
"""Exchange OAuth code for tokens via 9Router."""
|
||||
from backend.apps.nine_router import exchange_oauth
|
||||
from backend.apps.nine_router.oauth import exchange_oauth
|
||||
provider = body.get("provider", "")
|
||||
code = body.get("code", "")
|
||||
redirect_uri = body.get("redirect_uri", "")
|
||||
@@ -434,7 +436,8 @@ async def subscriptions_exchange(body: dict):
|
||||
@agents.router.get("/subscriptions/models")
|
||||
async def subscriptions_models():
|
||||
"""List all models available through connected subscriptions."""
|
||||
from backend.apps.nine_router import is_running, get_models
|
||||
from backend.apps.nine_router.process import is_running
|
||||
from backend.apps.nine_router.oauth import get_models
|
||||
if not is_running():
|
||||
return {"models": []}
|
||||
models = await get_models()
|
||||
@@ -456,7 +459,7 @@ async def probe_model(body: dict):
|
||||
_NINEROUTER_MODEL_PREFIXES,
|
||||
)
|
||||
from backend.apps.settings.settings import load_settings
|
||||
from backend.apps.nine_router import is_running as _9r_running
|
||||
from backend.apps.nine_router.process import is_running
|
||||
settings = load_settings()
|
||||
api_type = get_api_type(short_name)
|
||||
resolved = resolve_model_id_for_sdk(short_name, settings)
|
||||
@@ -474,7 +477,7 @@ async def probe_model(body: dict):
|
||||
)
|
||||
|
||||
if resolved_is_9router:
|
||||
if not _9r_running():
|
||||
if not is_running():
|
||||
return {"ok": True, "skipped": True}
|
||||
client = anthropic.AsyncAnthropic(api_key="9router", base_url="http://localhost:20128")
|
||||
elif route == "api" and api_type == "anthropic" and getattr(settings, "anthropic_api_key", None):
|
||||
@@ -488,7 +491,7 @@ async def probe_model(body: dict):
|
||||
elif api_type == "anthropic" and getattr(settings, "anthropic_api_key", None):
|
||||
client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
|
||||
else:
|
||||
if not _9r_running():
|
||||
if not is_running():
|
||||
return {"ok": True, "skipped": True}
|
||||
client = anthropic.AsyncAnthropic(api_key="9router", base_url="http://localhost:20128")
|
||||
|
||||
@@ -521,16 +524,16 @@ async def probe_model(body: dict):
|
||||
async def list_models():
|
||||
"""Picker model list, grouped by provider, intersected with available creds."""
|
||||
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.nine_router.process import is_running, get_providers
|
||||
from backend.apps.settings.settings import load_settings
|
||||
|
||||
settings = load_settings()
|
||||
nine_router_up = _9r_running()
|
||||
nine_router_up = is_running()
|
||||
|
||||
connected: set[str] = set()
|
||||
if nine_router_up:
|
||||
try:
|
||||
conns = await _9r_providers()
|
||||
conns = await get_providers()
|
||||
raw_providers = {c.get("provider", "") for c in conns if c.get("isActive") or c.get("testStatus") == "active"}
|
||||
# 9Router uses "claude"; our models use api="anthropic". Map across.
|
||||
_9R_TO_API = {
|
||||
@@ -697,7 +700,7 @@ async def list_models():
|
||||
# Fetch OpenRouter catalog directly (independent of 9Router) so picker fills the moment a key lands.
|
||||
if has_openrouter_key:
|
||||
try:
|
||||
from backend.apps.agents.providers.registry import fetch_openrouter_models
|
||||
from backend.apps.agents.providers.openrouter import fetch_openrouter_models
|
||||
or_models = await fetch_openrouter_models(settings.openrouter_api_key)
|
||||
except Exception as e:
|
||||
logger.debug(f"OpenRouter catalog fetch failed: {e}")
|
||||
@@ -743,14 +746,14 @@ async def list_models():
|
||||
result[f"OpenRouter · {pretty}"] = entries
|
||||
|
||||
# Custom OpenAI-compatible providers (Ollama Cloud, Together, etc); addressed via custom/<slug>/<model_id>.
|
||||
from backend.apps.agents.providers.registry import _custom_provider_slug_for_lookup
|
||||
from backend.apps.agents.providers.registry import custom_provider_slug_for_lookup
|
||||
for cp in (getattr(settings, "custom_providers", None) or []):
|
||||
cp_name = (getattr(cp, "name", "") or "").strip()
|
||||
cp_base_url = (getattr(cp, "base_url", "") or "").strip()
|
||||
cp_models = getattr(cp, "models", None) or []
|
||||
if not cp_name or not cp_base_url or not cp_models:
|
||||
continue
|
||||
slug = _custom_provider_slug_for_lookup(cp_name)
|
||||
slug = custom_provider_slug_for_lookup(cp_name)
|
||||
entries: list[dict] = []
|
||||
for m in cp_models:
|
||||
bare = (m.get("value") or m.get("id") or "").strip()
|
||||
@@ -786,7 +789,7 @@ _PROVIDER_CASCADE_REMOVES: dict[str, list[str]] = {
|
||||
async def _delete_provider_connections(providers: list[str]) -> int:
|
||||
"""Delete 9Router connections in `providers`; returns count removed, silent on 9Router unreachable."""
|
||||
import httpx
|
||||
from backend.apps.nine_router import NINE_ROUTER_API, get_providers
|
||||
from backend.apps.nine_router.process import NINE_ROUTER_API, get_providers
|
||||
try:
|
||||
connections = await get_providers()
|
||||
except Exception:
|
||||
|
||||
@@ -17,9 +17,9 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Curated shortlist; `id` MUST match ToolDefinition.name exactly or the enabled/dismissed filter no-ops and the modal renders nothing.
|
||||
CuratedEntry = dict[str, Any]
|
||||
P_CuratedEntry = dict[str, Any]
|
||||
|
||||
CURATED_SHORTLIST: list[CuratedEntry] = [
|
||||
P_CURATED_SHORTLIST: list[P_CuratedEntry] = [
|
||||
{
|
||||
"id": "Google Workspace",
|
||||
"title": "Google Workspace",
|
||||
@@ -69,18 +69,18 @@ CURATED_SHORTLIST: list[CuratedEntry] = [
|
||||
|
||||
|
||||
# Short-circuit for obviously-local prompts where no MCP helps. Saves ~200ms + ~$0.0001 per launch.
|
||||
_PATH_LIKE = re.compile(r"^[./~]|/[\w\-]+/|\.[a-zA-Z]{1,5}\b")
|
||||
_SHELL_PREFIX = re.compile(r"^\s*[\$!/]")
|
||||
P_PATH_LIKE = re.compile(r"^[./~]|/[\w\-]+/|\.[a-zA-Z]{1,5}\b")
|
||||
P_SHELL_PREFIX = re.compile(r"^\s*[\$!/]")
|
||||
|
||||
|
||||
def _is_obviously_local(prompt: str) -> bool:
|
||||
def p_is_obviously_local(prompt: str) -> bool:
|
||||
"""True for prompts that obviously can't benefit from MCP (very short, shell-ish, single path)."""
|
||||
s = prompt.strip()
|
||||
if len(s) < 8:
|
||||
return True
|
||||
if _SHELL_PREFIX.match(s):
|
||||
if P_SHELL_PREFIX.match(s):
|
||||
return True
|
||||
if " " not in s and _PATH_LIKE.search(s):
|
||||
if " " not in s and P_PATH_LIKE.search(s):
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -92,21 +92,21 @@ async def run_preflight(prompt: str, timeout_s: float = 2.0) -> dict:
|
||||
if not prompt or not prompt.strip():
|
||||
return default
|
||||
|
||||
if _is_obviously_local(prompt):
|
||||
if p_is_obviously_local(prompt):
|
||||
return default
|
||||
|
||||
try:
|
||||
settings = load_settings()
|
||||
available = _build_available_shortlist(settings)
|
||||
available = p_build_available_shortlist(settings)
|
||||
|
||||
result = await asyncio.wait_for(
|
||||
_call_classifier(settings, prompt, available),
|
||||
p_call_classifier(settings, prompt, available),
|
||||
timeout=timeout_s,
|
||||
)
|
||||
# Re-validate ids against the curated shortlist so hallucinations can't reach the frontend.
|
||||
valid_ids = {e["id"] for e in CURATED_SHORTLIST}
|
||||
valid_ids = {e["id"] for e in P_CURATED_SHORTLIST}
|
||||
result["suggestions"] = [
|
||||
_decorate(s, available) for s in result.get("suggestions", [])
|
||||
p_decorate(s, available) for s in result.get("suggestions", [])
|
||||
if isinstance(s, dict) and s.get("id") in valid_ids
|
||||
]
|
||||
result["suggestions"] = [s for s in result["suggestions"] if s is not None]
|
||||
@@ -123,7 +123,7 @@ async def run_preflight(prompt: str, timeout_s: float = 2.0) -> dict:
|
||||
return default
|
||||
|
||||
|
||||
def _build_available_shortlist(settings) -> list[CuratedEntry]:
|
||||
def p_build_available_shortlist(settings) -> list[P_CuratedEntry]:
|
||||
"""Curated entries that are NOT currently enabled and NOT dismissed."""
|
||||
try:
|
||||
enabled_names = {t.name for t in load_all_tools() if getattr(t, "enabled", False)}
|
||||
@@ -133,12 +133,12 @@ def _build_available_shortlist(settings) -> list[CuratedEntry]:
|
||||
dismissed = set((getattr(settings, "dismissed_mcp_suggestions", {}) or {}).keys())
|
||||
|
||||
return [
|
||||
entry for entry in CURATED_SHORTLIST
|
||||
entry for entry in P_CURATED_SHORTLIST
|
||||
if entry["id"] not in enabled_names and entry["id"] not in dismissed
|
||||
]
|
||||
|
||||
|
||||
def _decorate(llm_suggestion: dict, available: list[CuratedEntry]) -> dict | None:
|
||||
def p_decorate(llm_suggestion: dict, available: list[P_CuratedEntry]) -> dict | None:
|
||||
"""Expand an LLM-returned {id, reason} into the full frontend shape."""
|
||||
entry = next((e for e in available if e["id"] == llm_suggestion["id"]), None)
|
||||
if entry is None:
|
||||
@@ -151,9 +151,9 @@ def _decorate(llm_suggestion: dict, available: list[CuratedEntry]) -> dict | Non
|
||||
}
|
||||
|
||||
|
||||
async def _call_classifier(settings, prompt: str, available: list[CuratedEntry]) -> dict:
|
||||
async def p_call_classifier(settings, prompt: str, available: list[P_CuratedEntry]) -> dict:
|
||||
"""One aux-model call, returns validated JSON {is_vague, suggestions}."""
|
||||
aux_model, _base = await resolve_aux_model(settings, preferred_tier="haiku")
|
||||
aux_model, _ = await resolve_aux_model(settings, preferred_tier="haiku")
|
||||
client = get_anthropic_client_for_model(settings, aux_model)
|
||||
|
||||
catalog_lines = "\n".join(
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
"""Tiny OpenAI passthrough renaming max_tokens to max_completion_tokens for GPT-5; 9Router 0.3.60 is pinned and doesn't know the change."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
import httpx
|
||||
from fastapi import Request
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
|
||||
from backend.config.Apps import SubApp
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def openai_passthrough_lifespan():
|
||||
yield
|
||||
|
||||
|
||||
openai_passthrough = SubApp("openai-passthrough", openai_passthrough_lifespan)
|
||||
|
||||
|
||||
# Mirrors anthropic_proxy.py's GPT-5 matcher; duplicated to avoid the cross-module dep.
|
||||
_GPT5_PREFIXES = ("gpt-5",)
|
||||
_OPENAI_UPSTREAM = "https://api.openai.com/v1"
|
||||
_HOP_HEADERS = {
|
||||
"host", "content-length", "connection", "keep-alive",
|
||||
"proxy-authenticate", "proxy-authorization", "te", "trailers",
|
||||
"transfer-encoding", "upgrade",
|
||||
}
|
||||
|
||||
|
||||
def _is_gpt5(model: str) -> bool:
|
||||
m = (model or "").strip().lower()
|
||||
if not m:
|
||||
return False
|
||||
for prefix in ("openai/", "cx/", "openrouter/", "or:openai/", "cp/", "cp-"):
|
||||
if m.startswith(prefix):
|
||||
m = m[len(prefix):]
|
||||
break
|
||||
return any(m.startswith(p) for p in _GPT5_PREFIXES)
|
||||
|
||||
|
||||
def _scrub_max_tokens(body: bytes) -> bytes:
|
||||
"""Rename max_tokens to max_completion_tokens for GPT-5; bytes in/out, never raises."""
|
||||
if not body:
|
||||
return body
|
||||
try:
|
||||
parsed = json.loads(body)
|
||||
except Exception:
|
||||
return body
|
||||
if not isinstance(parsed, dict):
|
||||
return body
|
||||
model = str(parsed.get("model") or "")
|
||||
if not _is_gpt5(model):
|
||||
return body
|
||||
if "max_tokens" in parsed and "max_completion_tokens" not in parsed:
|
||||
parsed["max_completion_tokens"] = parsed.pop("max_tokens")
|
||||
return json.dumps(parsed).encode("utf-8")
|
||||
if "max_tokens" in parsed and "max_completion_tokens" in parsed:
|
||||
parsed.pop("max_tokens", None)
|
||||
return json.dumps(parsed).encode("utf-8")
|
||||
return body
|
||||
|
||||
|
||||
@openai_passthrough.router.api_route(
|
||||
"/v1/{rest:path}",
|
||||
methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"],
|
||||
)
|
||||
async def passthrough(rest: str, request: Request):
|
||||
body = await request.body()
|
||||
body = _scrub_max_tokens(body)
|
||||
|
||||
forward_headers: dict[str, str] = {}
|
||||
for k, v in request.headers.items():
|
||||
if k.lower() in _HOP_HEADERS:
|
||||
continue
|
||||
forward_headers[k] = v
|
||||
|
||||
upstream_url = f"{_OPENAI_UPSTREAM}/{rest}"
|
||||
if request.url.query:
|
||||
upstream_url = f"{upstream_url}?{request.url.query}"
|
||||
|
||||
# Stream upstream body back; httpx handles SSE without buffering the full response.
|
||||
client = httpx.AsyncClient(timeout=httpx.Timeout(connect=10.0, read=300.0, write=60.0, pool=30.0))
|
||||
try:
|
||||
upstream_req = client.build_request(
|
||||
request.method,
|
||||
upstream_url,
|
||||
headers=forward_headers,
|
||||
content=body,
|
||||
)
|
||||
upstream_resp = await client.send(upstream_req, stream=True)
|
||||
except httpx.HTTPError as e:
|
||||
await client.aclose()
|
||||
logger.warning("openai-passthrough upstream error: %s", e)
|
||||
return JSONResponse(
|
||||
{"error": {"message": str(e), "type": "upstream_error"}},
|
||||
status_code=502,
|
||||
)
|
||||
|
||||
response_headers: dict[str, str] = {}
|
||||
for k, v in upstream_resp.headers.items():
|
||||
if k.lower() in _HOP_HEADERS:
|
||||
continue
|
||||
response_headers[k] = v
|
||||
|
||||
async def streamer():
|
||||
try:
|
||||
async for chunk in upstream_resp.aiter_raw():
|
||||
yield chunk
|
||||
finally:
|
||||
await upstream_resp.aclose()
|
||||
await client.aclose()
|
||||
|
||||
return StreamingResponse(
|
||||
streamer(),
|
||||
status_code=upstream_resp.status_code,
|
||||
headers=response_headers,
|
||||
)
|
||||
@@ -13,12 +13,12 @@ from typing import AsyncIterator, Optional
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 500 events covers a 30s drop even at ~20Hz thinking deltas (~50KB/session).
|
||||
BUFFER_LIMIT = 500
|
||||
P_BUFFER_LIMIT = 500
|
||||
|
||||
TERMINAL_STATUSES = {"completed", "stopped", "error"}
|
||||
P_TERMINAL_STATUSES = {"completed", "stopped", "error"}
|
||||
|
||||
|
||||
class _SessionSeqLog:
|
||||
class P_SessionSeqLog:
|
||||
"""Per-session lock + monotonic seq + recent-event ring buffer."""
|
||||
|
||||
__slots__ = ("lock", "seq", "buffer")
|
||||
@@ -27,43 +27,43 @@ class _SessionSeqLog:
|
||||
self.lock: asyncio.Lock = asyncio.Lock()
|
||||
self.seq: int = 0
|
||||
# (seq, json_payload_str): pre-serialized so replays don't redo json.dumps per reconnect.
|
||||
self.buffer: deque[tuple[int, str]] = deque(maxlen=BUFFER_LIMIT)
|
||||
self.buffer: deque[tuple[int, str]] = deque(maxlen=P_BUFFER_LIMIT)
|
||||
|
||||
|
||||
class SeqLogStore:
|
||||
"""Process-wide store. Per-session locks live inside `_SessionSeqLog`."""
|
||||
|
||||
def __init__(self, persist_dir: Optional[str] = None) -> None:
|
||||
self._per_session: dict[str, _SessionSeqLog] = {}
|
||||
self.p_per_session: dict[str, P_SessionSeqLog] = {}
|
||||
# Coarse lock guards only the setdefault path; never crosses an await.
|
||||
self._dict_lock = asyncio.Lock()
|
||||
self._persist_dir = persist_dir
|
||||
self.p_dict_lock = asyncio.Lock()
|
||||
self.p_persist_dir = persist_dir
|
||||
if persist_dir:
|
||||
try:
|
||||
os.makedirs(persist_dir, exist_ok=True)
|
||||
except Exception:
|
||||
logger.warning("seq_log: failed to create persist dir %s", persist_dir)
|
||||
|
||||
async def _get_or_create(self, session_id: str) -> _SessionSeqLog:
|
||||
log = self._per_session.get(session_id)
|
||||
async def p_get_or_create(self, session_id: str) -> P_SessionSeqLog:
|
||||
log = self.p_per_session.get(session_id)
|
||||
if log is not None:
|
||||
return log
|
||||
async with self._dict_lock:
|
||||
async with self.p_dict_lock:
|
||||
log = self._per_session.get(session_id)
|
||||
if log is None:
|
||||
log = _SessionSeqLog()
|
||||
self._per_session[session_id] = log
|
||||
log = P_SessionSeqLog()
|
||||
self.p_per_session[session_id] = log
|
||||
return log
|
||||
|
||||
def _peek(self, session_id: str) -> Optional[_SessionSeqLog]:
|
||||
return self._per_session.get(session_id)
|
||||
def p_peek(self, session_id: str) -> Optional[P_SessionSeqLog]:
|
||||
return self.p_per_session.get(session_id)
|
||||
|
||||
@asynccontextmanager
|
||||
async def stamp(
|
||||
self, session_id: str, event: str, data: dict
|
||||
) -> AsyncIterator[tuple[int, str]]:
|
||||
"""Atomically assign seq, buffer, and yield (seq, payload); caller's send must happen inside the with-block."""
|
||||
log = await self._get_or_create(session_id)
|
||||
log = await self.p_get_or_create(session_id)
|
||||
async with log.lock:
|
||||
log.seq += 1
|
||||
seq = log.seq
|
||||
@@ -81,7 +81,7 @@ class SeqLogStore:
|
||||
self, session_id: str, last_seq: int
|
||||
) -> tuple[Optional[int], Optional[int], list[str]]:
|
||||
"""Return (oldest_buffered_seq, newest_buffered_seq, events)."""
|
||||
log = self._peek(session_id)
|
||||
log = self.p_peek(session_id)
|
||||
if log is None:
|
||||
return (None, None, [])
|
||||
# asyncio is single-threaded; deque list() is safe vs concurrent append/eviction. No lock needed for read.
|
||||
@@ -95,21 +95,21 @@ class SeqLogStore:
|
||||
|
||||
def current_seq(self, session_id: str) -> int:
|
||||
"""Last assigned seq, or 0 if no log exists for the session."""
|
||||
log = self._peek(session_id)
|
||||
log = self.p_peek(session_id)
|
||||
return log.seq if log else 0
|
||||
|
||||
def _terminal_path(self, session_id: str) -> Optional[str]:
|
||||
if not self._persist_dir:
|
||||
def p_terminal_path(self, session_id: str) -> Optional[str]:
|
||||
if not self.p_persist_dir:
|
||||
return None
|
||||
# Session ids are uuid4 hex; sanitize anyway against path traversal.
|
||||
safe = "".join(c for c in session_id if c.isalnum() or c in ("-", "_"))
|
||||
if not safe:
|
||||
return None
|
||||
return os.path.join(self._persist_dir, f"{safe}.json")
|
||||
return os.path.join(self.p_persist_dir, f"{safe}.json")
|
||||
|
||||
def persist_terminal(self, session_id: str, payload_str: str) -> None:
|
||||
"""Atomic write of a terminal event for post-restart clients; best-effort, never blocks broadcast."""
|
||||
path = self._terminal_path(session_id)
|
||||
path = self.p_terminal_path(session_id)
|
||||
if not path:
|
||||
return
|
||||
try:
|
||||
@@ -123,7 +123,7 @@ class SeqLogStore:
|
||||
)
|
||||
|
||||
def load_terminal(self, session_id: str) -> Optional[str]:
|
||||
path = self._terminal_path(session_id)
|
||||
path = self.p_terminal_path(session_id)
|
||||
if not path or not os.path.exists(path):
|
||||
return None
|
||||
try:
|
||||
@@ -134,8 +134,8 @@ class SeqLogStore:
|
||||
|
||||
def clear(self, session_id: str) -> None:
|
||||
"""Drop in-memory log and persisted terminal; for full deletion only, closed-but-retained sessions keep it."""
|
||||
self._per_session.pop(session_id, None)
|
||||
path = self._terminal_path(session_id)
|
||||
self.p_per_session.pop(session_id, None)
|
||||
path = self.p_terminal_path(session_id)
|
||||
if path and os.path.exists(path):
|
||||
try:
|
||||
os.remove(path)
|
||||
@@ -143,7 +143,7 @@ class SeqLogStore:
|
||||
pass
|
||||
|
||||
|
||||
def _default_persist_dir() -> Optional[str]:
|
||||
def p_default_persist_dir() -> Optional[str]:
|
||||
try:
|
||||
from backend.config.paths import DATA_ROOT
|
||||
return os.path.join(DATA_ROOT, "agents", "terminal_events")
|
||||
@@ -151,4 +151,4 @@ def _default_persist_dir() -> Optional[str]:
|
||||
return None
|
||||
|
||||
|
||||
seq_log = SeqLogStore(persist_dir=_default_persist_dir())
|
||||
SEQ_LOG = SeqLogStore(persist_dir=p_default_persist_dir())
|
||||
|
||||
@@ -3,7 +3,7 @@ import json
|
||||
import logging
|
||||
from fastapi import WebSocket
|
||||
|
||||
from backend.apps.agents.core.seq_log import TERMINAL_STATUSES, seq_log
|
||||
from backend.apps.agents.core.seq_log import TERMINAL_STATUSES, SEQ_LOG
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -42,7 +42,7 @@ async def _await_reconnect(has_conn) -> bool:
|
||||
|
||||
|
||||
class ConnectionManager:
|
||||
"""Manages WebSocket connections and HITL approval bridging; events flow through seq_log so reconnects can replay."""
|
||||
"""Manages WebSocket connections and HITL approval bridging; events flow through SEQ_LOG so reconnects can replay."""
|
||||
|
||||
def __init__(self):
|
||||
self.connections: dict[str, list[WebSocket]] = {}
|
||||
@@ -75,7 +75,7 @@ class ConnectionManager:
|
||||
|
||||
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."""
|
||||
async with seq_log.stamp(session_id, event, data) as (seq, payload_str):
|
||||
async with SEQ_LOG.stamp(session_id, event, data) as (seq, payload_str):
|
||||
for ws in list(self.connections.get(session_id, [])):
|
||||
try:
|
||||
await ws.send_text(payload_str)
|
||||
@@ -88,13 +88,13 @@ class ConnectionManager:
|
||||
logger.debug("send_to_session: global send failed", exc_info=True)
|
||||
# Persist under the lock so a concurrent running status can't race past and overwrite with stale state.
|
||||
if event == "agent:status" and data.get("status") in TERMINAL_STATUSES:
|
||||
seq_log.persist_terminal(session_id, payload_str)
|
||||
SEQ_LOG.persist_terminal(session_id, payload_str)
|
||||
|
||||
async def replay_to(
|
||||
self, session_id: str, websocket: WebSocket, last_seq: int
|
||||
) -> dict:
|
||||
"""Replay buffered events with seq > last_seq; returns ack envelope for the resume handshake."""
|
||||
oldest, newest, events = seq_log.replay(session_id, last_seq)
|
||||
oldest, newest, events = SEQ_LOG.replay(session_id, last_seq)
|
||||
|
||||
# Gap-check first: if last_seq predates the buffer, signal REST-refresh; last_seq=0 means fresh client (full replay).
|
||||
if last_seq > 0 and oldest is not None and last_seq < oldest - 1:
|
||||
@@ -150,7 +150,7 @@ class ConnectionManager:
|
||||
"to_seq": newest,
|
||||
}
|
||||
|
||||
terminal = seq_log.load_terminal(session_id)
|
||||
terminal = SEQ_LOG.load_terminal(session_id)
|
||||
if terminal is not None:
|
||||
try:
|
||||
await websocket.send_text(terminal)
|
||||
@@ -211,7 +211,7 @@ class ConnectionManager:
|
||||
return out
|
||||
|
||||
async def broadcast_global(self, event: str, data: dict):
|
||||
"""Send to all dashboard connections; bypasses seq_log (dashboard resumes via full state refetch)."""
|
||||
"""Send to all dashboard connections; bypasses SEQ_LOG (dashboard resumes via full state refetch)."""
|
||||
payload = json.dumps({"event": event, "data": data})
|
||||
for ws in list(self.global_connections):
|
||||
try:
|
||||
@@ -286,7 +286,7 @@ class ConnectionManager:
|
||||
deadline = loop.time() + timeout
|
||||
# Re-broadcast until a client answers: a silently-dead dashboard
|
||||
# socket takes up to ~35s of heartbeat to notice, and a command
|
||||
# sent into that gap is lost forever (broadcast skips seq_log).
|
||||
# sent into that gap is lost forever (broadcast skips SEQ_LOG).
|
||||
# The renderer dedupes by request_id so re-sends can't double-act.
|
||||
while True:
|
||||
await self.broadcast_global("browser:command", payload)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import os
|
||||
|
||||
from backend.apps.agents.manager.prompt.prompt_context import _resolve_attached_skills, _resolve_forced_tools
|
||||
from backend.apps.agents.manager.prompt.prompt_context import resolve_attached_skills, resolve_forced_tools
|
||||
|
||||
|
||||
def _build_dir_tree(root: str, max_depth: int = 4, prefix: str = "") -> list[str]:
|
||||
def build_dir_tree(root: str, max_depth: int = 4, prefix: str = "") -> list[str]:
|
||||
"""Build a recursive directory tree listing."""
|
||||
lines = []
|
||||
try:
|
||||
@@ -17,12 +17,12 @@ def _build_dir_tree(root: str, max_depth: int = 4, prefix: str = "") -> list[str
|
||||
for d in dirs:
|
||||
lines.append(f"{prefix}{d}/")
|
||||
if max_depth > 1:
|
||||
sub = _build_dir_tree(os.path.join(root, d), max_depth - 1, prefix + " ")
|
||||
sub = build_dir_tree(os.path.join(root, d), max_depth - 1, prefix + " ")
|
||||
lines.extend(sub)
|
||||
return lines
|
||||
|
||||
|
||||
def _build_prompt_content(prompt: str, images: list | None = None, context_paths: list | None = None, forced_tools: list[str] | None = None, attached_skills: list | None = None, api_type: str = "anthropic", model: str = ""):
|
||||
def build_prompt_content(prompt: str, images: list | None = None, context_paths: list | None = None, forced_tools: list[str] | None = None, attached_skills: list | None = None, api_type: str = "anthropic", model: str = ""):
|
||||
"""Build message content for the Anthropic SDK's prompt stream.
|
||||
|
||||
Routes attachments per provider:
|
||||
@@ -42,11 +42,11 @@ def _build_prompt_content(prompt: str, images: list | None = None, context_paths
|
||||
anything binary, since native shape varies wildly. Caller can
|
||||
opt-in to the OR file-parser via a separate plugins config.
|
||||
"""
|
||||
context_text, native_blocks, refusals = _resolve_attachments(
|
||||
context_text, native_blocks, refusals = resolve_attachments(
|
||||
context_paths, api_type=api_type, model=model,
|
||||
)
|
||||
forced_tools_text = _resolve_forced_tools(forced_tools)
|
||||
skills_text = _resolve_attached_skills(attached_skills)
|
||||
forced_tools_text = resolve_forced_tools(forced_tools)
|
||||
skills_text = resolve_attached_skills(attached_skills)
|
||||
|
||||
refusal_text = "\n\n".join(refusals)
|
||||
parts = [p for p in (forced_tools_text, context_text, refusal_text, skills_text, prompt) if p]
|
||||
@@ -69,7 +69,7 @@ def _build_prompt_content(prompt: str, images: list | None = None, context_paths
|
||||
return content
|
||||
|
||||
|
||||
def _resolve_attachments(context_paths: list | None, api_type: str, model: str) -> tuple[str, list[dict], list[str]]:
|
||||
def resolve_attachments(context_paths: list | None, api_type: str, model: str) -> tuple[str, list[dict], list[str]]:
|
||||
"""Split context_paths into:
|
||||
- inline text (returned as the existing <context_file> block string)
|
||||
- native content blocks for this provider (PDFs/images)
|
||||
@@ -90,8 +90,8 @@ def _resolve_attachments(context_paths: list | None, api_type: str, model: str)
|
||||
"""
|
||||
if not context_paths:
|
||||
return "", [], []
|
||||
from backend.apps.settings.settings import _sniff_file_kind
|
||||
import base64 as _b64
|
||||
from backend.apps.settings.settings import sniff_file_kind
|
||||
import base64 as b64
|
||||
sections: list[str] = []
|
||||
native: list[dict] = []
|
||||
refusals: list[str] = []
|
||||
@@ -164,7 +164,7 @@ def _resolve_attachments(context_paths: list | None, api_type: str, model: str)
|
||||
sections.append(f"[Context: {path}, not found]")
|
||||
continue
|
||||
if cp_type == "directory" and os.path.isdir(path):
|
||||
tree_lines = _build_dir_tree(path, max_depth=4)
|
||||
tree_lines = build_dir_tree(path, max_depth=4)
|
||||
sections.append(
|
||||
f"<context_directory path=\"{path}\">\n{chr(10).join(tree_lines)}\n</context_directory>"
|
||||
)
|
||||
@@ -176,7 +176,7 @@ def _resolve_attachments(context_paths: list | None, api_type: str, model: str)
|
||||
size = os.path.getsize(path)
|
||||
with open(path, "rb") as fh:
|
||||
head = fh.read(4096)
|
||||
kind, media_type = _sniff_file_kind(head, os.path.basename(path))
|
||||
kind, media_type = sniff_file_kind(head, os.path.basename(path))
|
||||
|
||||
if kind == "text":
|
||||
with open(path, "r", errors="replace") as f:
|
||||
@@ -230,7 +230,7 @@ def _resolve_attachments(context_paths: list | None, api_type: str, model: str)
|
||||
)
|
||||
continue
|
||||
with open(path, "rb") as fh:
|
||||
data_b64 = _b64.b64encode(fh.read()).decode("ascii")
|
||||
data_b64 = b64.b64encode(fh.read()).decode("ascii")
|
||||
block = {
|
||||
"type": "document",
|
||||
"source": {
|
||||
@@ -263,7 +263,7 @@ def _resolve_attachments(context_paths: list | None, api_type: str, model: str)
|
||||
)
|
||||
continue
|
||||
with open(path, "rb") as fh:
|
||||
data_b64 = _b64.b64encode(fh.read()).decode("ascii")
|
||||
data_b64 = b64.b64encode(fh.read()).decode("ascii")
|
||||
native.append({
|
||||
"type": "image",
|
||||
"source": {
|
||||
@@ -299,7 +299,7 @@ def _resolve_attachments(context_paths: list | None, api_type: str, model: str)
|
||||
# Legacy entry point retained for any external caller; routes to the
|
||||
# new attachment resolver with anthropic-default routing (no native
|
||||
# blocks emitted, so behavior is the safe text-only old path).
|
||||
def _resolve_context_paths(context_paths: list | None) -> str:
|
||||
text, _native, refusals = _resolve_attachments(context_paths, api_type="anthropic", model="")
|
||||
def resolve_context_paths(context_paths: list | None) -> str:
|
||||
text, _, refusals = resolve_attachments(context_paths, api_type="anthropic", model="")
|
||||
refusal_text = "\n\n".join(refusals)
|
||||
return "\n\n".join(p for p in (text, refusal_text) if p)
|
||||
|
||||
@@ -5,10 +5,10 @@ from backend.apps.tools_lib.tools_lib import (
|
||||
_load_all as load_all_tools,
|
||||
)
|
||||
from backend.apps.tools_lib.mcp_config import sanitize_mcp_server_name
|
||||
from backend.apps.agents.manager.prompt.tool_catalog import _get_denied_tool_names, _is_fully_denied
|
||||
from backend.apps.agents.manager.prompt.tool_catalog import get_denied_tool_names, is_fully_denied
|
||||
|
||||
|
||||
def _resolve_mode(mode_id: str, get_all_tool_names: Callable[[], list[str]]) -> tuple[list[str], str | None, str | None]:
|
||||
def resolve_mode(mode_id: str, get_all_tool_names: Callable[[], list[str]]) -> tuple[list[str], str | None, str | None]:
|
||||
"""Return (tools, system_prompt, default_folder) resolved from the mode store."""
|
||||
mode_def = load_mode(mode_id)
|
||||
if mode_def:
|
||||
@@ -17,7 +17,7 @@ def _resolve_mode(mode_id: str, get_all_tool_names: Callable[[], list[str]]) ->
|
||||
return get_all_tool_names(), None, None
|
||||
|
||||
|
||||
def _build_connected_tools_context(allowed_tools: list[str], get_all_tool_names: Callable[[], list[str]]) -> str | None:
|
||||
def build_connected_tools_context(allowed_tools: list[str], get_all_tool_names: Callable[[], list[str]]) -> str | None:
|
||||
"""Build a context block describing connected MCP tools and their accounts.
|
||||
|
||||
Tools set to 'deny' and fully-denied servers are excluded.
|
||||
@@ -31,11 +31,11 @@ def _build_connected_tools_context(allowed_tools: list[str], get_all_tool_names:
|
||||
if tool_ref not in allowed_tools and allowed_tools != get_all_tool_names():
|
||||
continue
|
||||
|
||||
if _is_fully_denied(tool):
|
||||
if is_fully_denied(tool):
|
||||
continue
|
||||
|
||||
server_name = sanitize_mcp_server_name(tool.name)
|
||||
denied = _get_denied_tool_names(tool)
|
||||
denied = get_denied_tool_names(tool)
|
||||
tool_descs = {
|
||||
k: v for k, v in tool.tool_permissions.get("_tool_descriptions", {}).items()
|
||||
if k not in denied
|
||||
@@ -98,7 +98,7 @@ def _build_connected_tools_context(allowed_tools: list[str], get_all_tool_names:
|
||||
)
|
||||
|
||||
|
||||
def _build_browser_context(dashboard_id: str | None, selected_browser_ids: list[str] | None = None) -> str | None:
|
||||
def build_browser_context(dashboard_id: str | None, selected_browser_ids: list[str] | None = None) -> str | None:
|
||||
"""Build a context block listing browser cards and delegation instructions.
|
||||
|
||||
Only browser cards explicitly selected by the user are included.
|
||||
@@ -174,7 +174,7 @@ def _build_browser_context(dashboard_id: str | None, selected_browser_ids: list[
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _build_selected_app_context(selected_app_output_ids: list[str] | None) -> str | None:
|
||||
def build_selected_app_context(selected_app_output_ids: list[str] | None) -> str | None:
|
||||
"""Build a context block for dashboard App cards the user selected to edit.
|
||||
|
||||
Resolves each Output id to its on-disk workspace so the agent edits the
|
||||
@@ -233,7 +233,7 @@ def _build_selected_app_context(selected_app_output_ids: list[str] | None) -> st
|
||||
)
|
||||
|
||||
|
||||
def _build_mcp_registry_summary(allowed_tools: list[str], active_mcps: list[str], get_all_tool_names: Callable[[], list[str]]) -> str | None:
|
||||
def build_mcp_registry_summary(allowed_tools: list[str], active_mcps: list[str], get_all_tool_names: Callable[[], list[str]]) -> str | None:
|
||||
"""Compact registry of installed MCP servers, one line per server.
|
||||
|
||||
This is the visible surface that drives the activation gate: the model
|
||||
@@ -261,7 +261,7 @@ def _build_mcp_registry_summary(allowed_tools: list[str], active_mcps: list[str]
|
||||
tool_ref = f"mcp:{tool.name}"
|
||||
if tool_ref not in allowed_tools and allowed_tools != get_all_tool_names():
|
||||
continue
|
||||
if _is_fully_denied(tool):
|
||||
if is_fully_denied(tool):
|
||||
continue
|
||||
server_name = sanitize_mcp_server_name(tool.name)
|
||||
desc = (getattr(tool, "description", None) or "").strip()
|
||||
@@ -354,14 +354,14 @@ AGENT_IDENTITY = (
|
||||
)
|
||||
|
||||
|
||||
def _compose_system_prompt(default_prompt: str | None, mode_prompt: str | None, session_prompt: str | None, connected_tools_ctx: str | None = None, browser_ctx: str | None = None, mcp_registry_ctx: str | None = None) -> str | None:
|
||||
def compose_system_prompt(default_prompt: str | None, mode_prompt: str | None, session_prompt: str | None, connected_tools_ctx: str | None = None, browser_ctx: str | None = None, mcp_registry_ctx: str | None = None) -> str | None:
|
||||
# Identity always leads so it overrides the preset's Claude Code persona, even
|
||||
# when the user has no custom default/mode/session prompt of their own.
|
||||
parts = [AGENT_IDENTITY] + [p for p in (default_prompt, mode_prompt, session_prompt, connected_tools_ctx, mcp_registry_ctx, browser_ctx) if p]
|
||||
return "\n\n".join(parts)
|
||||
|
||||
|
||||
def _resolve_forced_tools(forced_tools: list[str] | None) -> str:
|
||||
def resolve_forced_tools(forced_tools: list[str] | None) -> str:
|
||||
"""Build a context block describing explicitly requested tools."""
|
||||
if not forced_tools:
|
||||
return ""
|
||||
@@ -401,7 +401,7 @@ def _resolve_forced_tools(forced_tools: list[str] | None) -> str:
|
||||
)
|
||||
|
||||
|
||||
def _resolve_attached_skills(attached_skills: list | None) -> str:
|
||||
def resolve_attached_skills(attached_skills: list | None) -> str:
|
||||
"""Build a context block injecting attached skill content into the prompt."""
|
||||
if not attached_skills:
|
||||
return ""
|
||||
|
||||
@@ -14,7 +14,7 @@ FULL_TOOLS = [
|
||||
]
|
||||
|
||||
|
||||
def _get_denied_tool_names(tool) -> set[str]:
|
||||
def get_denied_tool_names(tool) -> set[str]:
|
||||
"""Return the set of MCP sub-tool names whose permission is 'deny'."""
|
||||
return {
|
||||
key for key, value in tool.tool_permissions.items()
|
||||
@@ -22,14 +22,14 @@ def _get_denied_tool_names(tool) -> set[str]:
|
||||
}
|
||||
|
||||
|
||||
def _get_all_known_tool_names(tool) -> set[str]:
|
||||
def get_all_known_tool_names(tool) -> set[str]:
|
||||
"""Return all known sub-tool names for an MCP tool (from _tool_descriptions)."""
|
||||
return set(tool.tool_permissions.get("_tool_descriptions", {}).keys())
|
||||
|
||||
|
||||
def _is_fully_denied(tool) -> bool:
|
||||
def is_fully_denied(tool) -> bool:
|
||||
"""True when every known sub-tool on this MCP server is set to 'deny'."""
|
||||
known = _get_all_known_tool_names(tool)
|
||||
known = get_all_known_tool_names(tool)
|
||||
if not known:
|
||||
return False
|
||||
return known <= _get_denied_tool_names(tool)
|
||||
return known <= get_denied_tool_names(tool)
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
from datetime import datetime
|
||||
|
||||
from backend.apps.agents.core.models import AgentSession
|
||||
from backend.apps.service.client import sync as _sync
|
||||
from backend.apps.service.client import sync
|
||||
|
||||
|
||||
def _sync_session_close(session: AgentSession, close_reason: str = "user"):
|
||||
def sync_session_close(session: AgentSession, close_reason: str = "user"):
|
||||
"""Submit the session state to the cloud on close. The cloud
|
||||
consumes the dump however it sees fit; the desktop just hands off
|
||||
a snapshot. Skipped for mock sessions so dev runs don't post to
|
||||
@@ -31,6 +31,6 @@ def _sync_session_close(session: AgentSession, close_reason: str = "user"):
|
||||
dump = session.model_dump(mode="json")
|
||||
if not dump.get("closed_at"):
|
||||
dump["closed_at"] = datetime.now().isoformat()
|
||||
_sync(dump)
|
||||
sync(dump)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -8,7 +8,7 @@ from backend.config.paths import SESSIONS_DIR
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_branch_messages(session) -> list:
|
||||
def get_branch_messages(session) -> list:
|
||||
"""Return the linear message list for the active branch, walking the branch tree."""
|
||||
branch_id = session.active_branch_id or "main"
|
||||
branch = session.branches.get(branch_id)
|
||||
@@ -48,7 +48,7 @@ def _get_branch_messages(session) -> list:
|
||||
return result
|
||||
|
||||
|
||||
def _build_history_prefix(messages, cutoff_msg_id: str | None = None) -> str:
|
||||
def build_history_prefix(messages, cutoff_msg_id: str | None = None) -> str:
|
||||
"""Format branch messages into a conversation summary for context injection.
|
||||
|
||||
When `cutoff_msg_id` is provided (session.compacted_through_msg_id), drop every
|
||||
@@ -71,7 +71,7 @@ def _build_history_prefix(messages, cutoff_msg_id: str | None = None) -> str:
|
||||
return "<prior_conversation>\n" + "\n".join(lines) + "\n</prior_conversation>"
|
||||
|
||||
|
||||
def _truncate_large_tool_result(content: object, session_id: str, msg_id: str, max_bytes: int = 50_000) -> tuple[object, str | None]:
|
||||
def truncate_large_tool_result(content: object, session_id: str, msg_id: str, max_bytes: int = 50_000) -> tuple[object, str | None]:
|
||||
"""Spill a large tool_result body to disk, return a truncated
|
||||
inline replacement plus the on-disk path (or None if untouched).
|
||||
|
||||
|
||||
@@ -4,32 +4,32 @@ from backend.apps.agents.core.models import AgentSession
|
||||
from backend.config.json_store import read_json_or_none, atomic_write_json
|
||||
|
||||
|
||||
def _sessions_dir() -> str:
|
||||
def p_sessions_dir() -> str:
|
||||
# Resolve live so test patches on either the paths module or the
|
||||
# agent_manager facade re-export land on the same directory.
|
||||
from backend.config.paths import SESSIONS_DIR
|
||||
return SESSIONS_DIR
|
||||
|
||||
|
||||
def _save_session(session_id: str, doc_data: dict):
|
||||
sessions_dir = _sessions_dir()
|
||||
def save_session(session_id: str, doc_data: dict):
|
||||
sessions_dir = p_sessions_dir()
|
||||
os.makedirs(sessions_dir, exist_ok=True)
|
||||
atomic_write_json(os.path.join(sessions_dir, f"{session_id}.json"), doc_data)
|
||||
|
||||
|
||||
def _load_session_data(session_id: str) -> dict | None:
|
||||
return read_json_or_none(os.path.join(_sessions_dir(), f"{session_id}.json"))
|
||||
def load_session_data(session_id: str) -> dict | None:
|
||||
return read_json_or_none(os.path.join(p_sessions_dir(), f"{session_id}.json"))
|
||||
|
||||
|
||||
def _delete_session_file(session_id: str):
|
||||
path = os.path.join(_sessions_dir(), f"{session_id}.json")
|
||||
def delete_session_file(session_id: str):
|
||||
path = os.path.join(p_sessions_dir(), f"{session_id}.json")
|
||||
if os.path.exists(path):
|
||||
os.remove(path)
|
||||
|
||||
|
||||
def _load_all_session_data() -> list[tuple[str, dict]]:
|
||||
def load_all_session_data() -> list[tuple[str, dict]]:
|
||||
results = []
|
||||
sessions_dir = _sessions_dir()
|
||||
sessions_dir = p_sessions_dir()
|
||||
if not os.path.exists(sessions_dir):
|
||||
return results
|
||||
for fname in os.listdir(sessions_dir):
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import logging
|
||||
import subprocess
|
||||
import os
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _ensure_cwd_git_repo(cwd: str, home: str | None = None) -> None:
|
||||
def ensure_cwd_git_repo(cwd: str, home: str | None = None) -> None:
|
||||
"""Idempotently make `cwd` into a git repo with a valid HEAD.
|
||||
|
||||
The CLI's built-in Agent tool uses `isolation: "worktree"` to spawn
|
||||
@@ -30,36 +31,35 @@ def _ensure_cwd_git_repo(cwd: str, home: str | None = None) -> None:
|
||||
return
|
||||
if not os.path.isdir(cwd):
|
||||
return
|
||||
|
||||
import subprocess as _sp_git
|
||||
|
||||
# Case A: cwd is inside some git repo (possibly parent). Verify
|
||||
# HEAD resolves. If the enclosing repo is broken (e.g. a stray
|
||||
# `.git` in $HOME with no commits, which makes workspaces
|
||||
# under ~/.openswarm/workspaces/ inherit a broken HEAD), we
|
||||
# need to init a fresh repo AT cwd so it shadows the parent.
|
||||
_inside = _sp_git.run(
|
||||
inside = subprocess.run(
|
||||
["git", "rev-parse", "--is-inside-work-tree"],
|
||||
cwd=cwd,
|
||||
stdout=_sp_git.PIPE, stderr=_sp_git.DEVNULL, timeout=5,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=5,
|
||||
)
|
||||
if _inside.returncode == 0 and b"true" in _inside.stdout:
|
||||
if inside.returncode == 0 and b"true" in inside.stdout:
|
||||
# Check HEAD resolves (has at least one commit).
|
||||
_head = _sp_git.run(
|
||||
head = subprocess.run(
|
||||
["git", "rev-parse", "--verify", "HEAD"],
|
||||
cwd=cwd,
|
||||
stdout=_sp_git.DEVNULL, stderr=_sp_git.DEVNULL, timeout=5,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=5,
|
||||
)
|
||||
if _head.returncode == 0:
|
||||
if head.returncode == 0:
|
||||
return # parent repo is healthy, leave it alone
|
||||
# Parent repo exists but HEAD is broken.
|
||||
if os.path.isdir(os.path.join(cwd, ".git")):
|
||||
# .git is directly here, commit to fix it.
|
||||
_sp_git.run(
|
||||
subprocess.run(
|
||||
["git", "-c", "user.email=openswarm@local",
|
||||
"-c", "user.name=OpenSwarm",
|
||||
"commit", "--allow-empty", "-q", "-m", "openswarm init"],
|
||||
cwd=cwd,
|
||||
stdout=_sp_git.DEVNULL, stderr=_sp_git.DEVNULL, timeout=10,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10,
|
||||
)
|
||||
return
|
||||
# .git is in a parent dir (broken home-dir repo, etc.).
|
||||
@@ -68,23 +68,23 @@ def _ensure_cwd_git_repo(cwd: str, home: str | None = None) -> None:
|
||||
|
||||
# Case B: cwd is not a git repo at all (or parent is broken):
|
||||
# init + empty commit here.
|
||||
_sp_git.run(
|
||||
subprocess.run(
|
||||
["git", "init", "-q", "-b", "main"],
|
||||
cwd=cwd,
|
||||
stdout=_sp_git.DEVNULL, stderr=_sp_git.DEVNULL, timeout=10,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10,
|
||||
)
|
||||
_sp_git.run(
|
||||
subprocess.run(
|
||||
["git", "-c", "user.email=openswarm@local",
|
||||
"-c", "user.name=OpenSwarm",
|
||||
"commit", "--allow-empty", "-q", "-m", "openswarm init"],
|
||||
cwd=cwd,
|
||||
stdout=_sp_git.DEVNULL, stderr=_sp_git.DEVNULL, timeout=10,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10,
|
||||
)
|
||||
except Exception as _e:
|
||||
logger.info(f"[agent-cwd] git init skipped: {_e}")
|
||||
except Exception as e:
|
||||
logger.info(f"[agent-cwd] git init skipped: {e}")
|
||||
|
||||
|
||||
def _detect_git_identity(cwd: str) -> tuple[str | None, str | None]:
|
||||
def detect_git_identity(cwd: str) -> tuple[str | None, str | None]:
|
||||
"""Resolve the origin remote and current branch for `cwd`.
|
||||
|
||||
Used to label sessions in the session list ("Agent on owner/repo
|
||||
@@ -97,10 +97,9 @@ def _detect_git_identity(cwd: str) -> tuple[str | None, str | None]:
|
||||
if not cwd or not os.path.isdir(cwd):
|
||||
return (None, None)
|
||||
try:
|
||||
import subprocess as _sp
|
||||
url_proc = _sp.run(
|
||||
url_proc = subprocess.run(
|
||||
["git", "remote", "get-url", "origin"],
|
||||
cwd=cwd, stdout=_sp.PIPE, stderr=_sp.DEVNULL, timeout=3,
|
||||
cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=3,
|
||||
)
|
||||
repo_url: str | None = None
|
||||
if url_proc.returncode == 0:
|
||||
@@ -113,9 +112,9 @@ def _detect_git_identity(cwd: str) -> tuple[str | None, str | None]:
|
||||
repo_url = f"{scheme}://{rest}"
|
||||
else:
|
||||
repo_url = raw
|
||||
branch_proc = _sp.run(
|
||||
branch_proc = subprocess.run(
|
||||
["git", "branch", "--show-current"],
|
||||
cwd=cwd, stdout=_sp.PIPE, stderr=_sp.DEVNULL, timeout=3,
|
||||
cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=3,
|
||||
)
|
||||
branch_name: str | None = None
|
||||
if branch_proc.returncode == 0:
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -10,13 +12,13 @@ OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
|
||||
|
||||
# `or:` prefix on picker values so resolve_model_id_for_sdk recognises them
|
||||
# without a side-table.
|
||||
_OPENROUTER_VALUE_PREFIX = "or:"
|
||||
OPENROUTER_VALUE_PREFIX = "or:"
|
||||
|
||||
_OR_MODELS_TTL_OK = 3600.0
|
||||
_OR_MODELS_TTL_FAIL = 30.0
|
||||
_or_models_cache: dict = {"models": None, "fetched_at": 0.0, "ok": False}
|
||||
P_OR_MODELS_TTL_OK = 3600.0
|
||||
P_OR_MODELS_TTL_FAIL = 30.0
|
||||
P_OR_MODELS_CACHE: dict = {"models": None, "fetched_at": 0.0, "ok": False}
|
||||
|
||||
_9router_cache: dict = {"available": None, "checked_at": 0}
|
||||
P_9ROUTER_CACHE: dict = {"available": None, "checked_at": 0}
|
||||
|
||||
|
||||
# Per-model published pricing in $/1M tokens (input, output) for direct
|
||||
@@ -25,7 +27,7 @@ _9router_cache: dict = {"available": None, "checked_at": 0}
|
||||
# Anthropic rates; for any non-Anthropic upstream the SDK number is
|
||||
# 50-1000x wrong and we MUST recompute. Used by agent_manager's cost
|
||||
# recompute logic.
|
||||
_DIRECT_API_PRICING: dict[str, tuple[float, float]] = {
|
||||
P_DIRECT_API_PRICING: dict[str, tuple[float, float]] = {
|
||||
# OpenAI GPT-5.x family (source: platform.openai.com/docs/pricing).
|
||||
"gpt-5.5": (1.25, 10.00),
|
||||
"gpt-5.4": (1.25, 10.00),
|
||||
@@ -53,7 +55,7 @@ def get_direct_pricing(model_id: str) -> tuple[float, float] | None:
|
||||
if bare.startswith(prefix):
|
||||
bare = bare[len(prefix):]
|
||||
break
|
||||
return _DIRECT_API_PRICING.get(bare)
|
||||
return P_DIRECT_API_PRICING.get(bare)
|
||||
|
||||
|
||||
def get_openrouter_pricing(resolved_model: str) -> tuple[float, float] | None:
|
||||
@@ -61,7 +63,7 @@ def get_openrouter_pricing(resolved_model: str) -> tuple[float, float] | None:
|
||||
if not isinstance(resolved_model, str) or not resolved_model.startswith("openrouter/"):
|
||||
return None
|
||||
bare = resolved_model[len("openrouter/"):]
|
||||
for m in _or_models_cache.get("models") or []:
|
||||
for m in P_OR_MODELS_CACHE.get("models") or []:
|
||||
if m.get("model_id") == bare:
|
||||
return (
|
||||
float(m.get("input_cost_per_1m", 0.0)),
|
||||
@@ -71,26 +73,24 @@ def get_openrouter_pricing(resolved_model: str) -> tuple[float, float] | None:
|
||||
|
||||
|
||||
def invalidate_openrouter_cache() -> None:
|
||||
_or_models_cache["models"] = None
|
||||
_or_models_cache["fetched_at"] = 0.0
|
||||
_or_models_cache["ok"] = False
|
||||
P_OR_MODELS_CACHE["models"] = None
|
||||
P_OR_MODELS_CACHE["fetched_at"] = 0.0
|
||||
P_OR_MODELS_CACHE["ok"] = False
|
||||
|
||||
|
||||
async def fetch_openrouter_models(api_key: str | None) -> list[dict]:
|
||||
"""Return OR's tool-capable chat catalog. Cached. Never raises."""
|
||||
import time as _time
|
||||
if not api_key:
|
||||
invalidate_openrouter_cache()
|
||||
return []
|
||||
|
||||
now = _time.monotonic()
|
||||
fetched_at = _or_models_cache["fetched_at"]
|
||||
if _or_models_cache["models"] is not None:
|
||||
ttl = _OR_MODELS_TTL_OK if _or_models_cache["ok"] else _OR_MODELS_TTL_FAIL
|
||||
now = time.monotonic()
|
||||
fetched_at = P_OR_MODELS_CACHE["fetched_at"]
|
||||
if P_OR_MODELS_CACHE["models"] is not None:
|
||||
ttl = P_OR_MODELS_TTL_OK if P_OR_MODELS_CACHE["ok"] else P_OR_MODELS_TTL_FAIL
|
||||
if now - fetched_at < ttl:
|
||||
return _or_models_cache["models"]
|
||||
|
||||
import httpx
|
||||
return P_OR_MODELS_CACHE["models"]
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=8.0) as client:
|
||||
r = await client.get(
|
||||
@@ -98,12 +98,12 @@ async def fetch_openrouter_models(api_key: str | None) -> list[dict]:
|
||||
headers={"Authorization": f"Bearer {api_key}"},
|
||||
)
|
||||
if r.status_code != 200:
|
||||
_or_models_cache.update(models=[], fetched_at=now, ok=False)
|
||||
P_OR_MODELS_CACHE.update(models=[], fetched_at=now, ok=False)
|
||||
logger.debug(f"OpenRouter /models returned {r.status_code}")
|
||||
return []
|
||||
raw = r.json().get("data") or []
|
||||
except Exception as e:
|
||||
_or_models_cache.update(models=[], fetched_at=now, ok=False)
|
||||
P_OR_MODELS_CACHE.update(models=[], fetched_at=now, ok=False)
|
||||
logger.debug(f"OpenRouter /models fetch failed: {e}")
|
||||
return []
|
||||
|
||||
@@ -155,7 +155,7 @@ async def fetch_openrouter_models(api_key: str | None) -> list[dict]:
|
||||
except (TypeError, ValueError):
|
||||
max_completion = None
|
||||
out.append({
|
||||
"value": f"{_OPENROUTER_VALUE_PREFIX}{model_id}",
|
||||
"value": f"{OPENROUTER_VALUE_PREFIX}{model_id}",
|
||||
"label": label,
|
||||
"context_window": ctx,
|
||||
"model_id": model_id,
|
||||
@@ -170,5 +170,5 @@ async def fetch_openrouter_models(api_key: str | None) -> list[dict]:
|
||||
"max_completion_tokens": max_completion,
|
||||
})
|
||||
|
||||
_or_models_cache.update(models=out, fetched_at=now, ok=True)
|
||||
P_OR_MODELS_CACHE.update(models=out, fetched_at=now, ok=True)
|
||||
return out
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Curated model tiers; Intelligence, Speed, Cost on a 1-5 scale
|
||||
@@ -185,7 +186,7 @@ MODEL_TIERS: dict[str, tuple[int, int, int]] = {
|
||||
}
|
||||
|
||||
|
||||
def _heuristic_tiers(label: str, output_cost_per_1m: float, reasoning: bool) -> tuple[int, int, int]:
|
||||
def p_heuristic_tiers(label: str, output_cost_per_1m: float, reasoning: bool) -> tuple[int, int, int]:
|
||||
"""Fallback tier scoring for models not in MODEL_TIERS. Tries to
|
||||
extract a parameter count from the label (8B/70B/235B/etc.) and
|
||||
use that as a stronger size signal than cost alone, since open-
|
||||
@@ -204,7 +205,6 @@ def _heuristic_tiers(label: str, output_cost_per_1m: float, reasoning: bool) ->
|
||||
- inverse of size, with name keywords as ±1 nudges.
|
||||
Cost: pure cost bucket.
|
||||
"""
|
||||
import re as _re
|
||||
out = output_cost_per_1m or 0.0
|
||||
|
||||
# Cost bucket; same 5-tier cost ladder as before.
|
||||
@@ -225,7 +225,7 @@ def _heuristic_tiers(label: str, output_cost_per_1m: float, reasoning: bool) ->
|
||||
# clearly above 1B (so we don't pick up version numbers).
|
||||
lower = (label or "").lower()
|
||||
param_b = 0.0
|
||||
for m in _re.finditer(r"\b(\d{1,4}(?:\.\d+)?)\s*b\b", lower):
|
||||
for m in re.finditer(r"\b(\d{1,4}(?:\.\d+)?)\s*b\b", lower):
|
||||
try:
|
||||
v = float(m.group(1))
|
||||
if v >= 1 and v > param_b:
|
||||
@@ -259,9 +259,9 @@ def _heuristic_tiers(label: str, output_cost_per_1m: float, reasoning: bool) ->
|
||||
|
||||
# Speed inverse of intel.
|
||||
speed = 6 - intel
|
||||
if _re.search(r"\b(mini|lite|flash|haiku|nano|small|fast|turbo|micro|tiny)\b", lower):
|
||||
if re.search(r"\b(mini|lite|flash|haiku|nano|small|fast|turbo|micro|tiny)\b", lower):
|
||||
speed += 1
|
||||
if _re.search(r"\b(opus|ultra|max|xlarge|titan|huge)\b", lower):
|
||||
if re.search(r"\b(opus|ultra|max|xlarge|titan|huge)\b", lower):
|
||||
speed -= 1
|
||||
if reasoning and intel >= 4:
|
||||
# Frontier reasoning models burn lots of tokens on hidden
|
||||
@@ -310,7 +310,7 @@ def compute_tiers(
|
||||
if c in MODEL_TIERS:
|
||||
return MODEL_TIERS[c]
|
||||
|
||||
return _heuristic_tiers(label, output_cost_per_1m, reasoning)
|
||||
return p_heuristic_tiers(label, output_cost_per_1m, reasoning)
|
||||
|
||||
|
||||
def compute_billing_kind(
|
||||
|
||||
@@ -10,9 +10,10 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, TYPE_CHECKING
|
||||
import httpx
|
||||
|
||||
from .openrouter import (
|
||||
_OPENROUTER_VALUE_PREFIX,
|
||||
OPENROUTER_VALUE_PREFIX,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -21,7 +22,7 @@ if TYPE_CHECKING:
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Full set of model-id prefixes that force routing through 9Router.
|
||||
_NINEROUTER_MODEL_PREFIXES = ("cc/", "cx/", "gc/", "ag/", "gemini/", "openrouter/")
|
||||
NINEROUTER_MODEL_PREFIXES = ("cc/", "cx/", "gc/", "ag/", "gemini/", "openrouter/")
|
||||
|
||||
# Entry fields: value, label, context_window, model_id, router_model_id, api,
|
||||
# subscription_only, reasoning, route ("cc"|"api"|"openrouter"|None).
|
||||
@@ -151,10 +152,10 @@ BUILTIN_MODELS: dict[str, list[dict[str, Any]]] = {
|
||||
# Model resolution (used by the live claude_agent_sdk path)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_CUSTOM_VALUE_PREFIX = "custom/"
|
||||
P_CUSTOM_VALUE_PREFIX = "custom/"
|
||||
|
||||
|
||||
def _custom_provider_slug_for_lookup(name: str) -> str:
|
||||
def custom_provider_slug_for_lookup(name: str) -> str:
|
||||
"""Mirror nine_router._custom_provider_slug; duplicated here to avoid
|
||||
importing from nine_router (circular: nine_router imports from settings)."""
|
||||
import re
|
||||
@@ -162,22 +163,22 @@ def _custom_provider_slug_for_lookup(name: str) -> str:
|
||||
return s or "custom"
|
||||
|
||||
|
||||
def _find_custom_provider_for_value(settings, value: str):
|
||||
def find_custom_provider_for_value(settings, value: str):
|
||||
"""Look up the CustomProvider whose slug matches the slug encoded in a
|
||||
`custom/<slug>/<model_id>` picker value. Returns None if no match."""
|
||||
if not isinstance(value, str) or not value.startswith(_CUSTOM_VALUE_PREFIX):
|
||||
if not isinstance(value, str) or not value.startswith(P_CUSTOM_VALUE_PREFIX):
|
||||
return None
|
||||
rest = value[len(_CUSTOM_VALUE_PREFIX):]
|
||||
slug, _sep, _bare = rest.partition("/")
|
||||
rest = value[len(P_CUSTOM_VALUE_PREFIX):]
|
||||
slug, _, _ = rest.partition("/")
|
||||
if not slug:
|
||||
return None
|
||||
for cp in getattr(settings, "custom_providers", None) or []:
|
||||
if _custom_provider_slug_for_lookup(getattr(cp, "name", "")) == slug:
|
||||
if custom_provider_slug_for_lookup(getattr(cp, "name", "")) == slug:
|
||||
return cp
|
||||
return None
|
||||
|
||||
|
||||
def _find_builtin_model(short_name: str) -> dict | None:
|
||||
def find_builtin_model(short_name: str) -> dict | None:
|
||||
"""Look up a model entry by its short `value`.
|
||||
|
||||
OpenRouter entries (prefixed `or:<vendor>/<model>`) and custom-provider
|
||||
@@ -188,8 +189,8 @@ def _find_builtin_model(short_name: str) -> dict | None:
|
||||
for m in models:
|
||||
if m.get("value") == short_name:
|
||||
return m
|
||||
if isinstance(short_name, str) and short_name.startswith(_OPENROUTER_VALUE_PREFIX):
|
||||
bare = short_name[len(_OPENROUTER_VALUE_PREFIX):]
|
||||
if isinstance(short_name, str) and short_name.startswith(OPENROUTER_VALUE_PREFIX):
|
||||
bare = short_name[len(OPENROUTER_VALUE_PREFIX):]
|
||||
if bare:
|
||||
return {
|
||||
"value": short_name,
|
||||
@@ -201,9 +202,9 @@ def _find_builtin_model(short_name: str) -> dict | None:
|
||||
"route": "openrouter",
|
||||
"reasoning": False,
|
||||
}
|
||||
if isinstance(short_name, str) and short_name.startswith(_CUSTOM_VALUE_PREFIX):
|
||||
rest = short_name[len(_CUSTOM_VALUE_PREFIX):]
|
||||
slug, _sep, bare_model = rest.partition("/")
|
||||
if isinstance(short_name, str) and short_name.startswith(P_CUSTOM_VALUE_PREFIX):
|
||||
rest = short_name[len(P_CUSTOM_VALUE_PREFIX):]
|
||||
slug, _, bare_model = rest.partition("/")
|
||||
if slug and bare_model:
|
||||
# Routing string `cp-<slug>/<model>` matches the prefix we use
|
||||
# when sync_custom_providers registers the provider node.
|
||||
@@ -222,13 +223,25 @@ def _find_builtin_model(short_name: str) -> dict | None:
|
||||
|
||||
|
||||
def get_api_type(short_name: str) -> str:
|
||||
entry = _find_builtin_model(short_name)
|
||||
entry = find_builtin_model(short_name)
|
||||
return (entry or {}).get("api", "anthropic")
|
||||
|
||||
|
||||
P_ANTIGRAVITY_MAP = {
|
||||
# gemini-3-pro-preview disabled: AG returns 404 even with active conn.
|
||||
# gemini-3.1-pro-preview disabled: AG's `gemini-3.1-pro-high` variant
|
||||
# 400s every request with "invalid argument" (the `-high` thinking-
|
||||
# budget alias on AG requires a thinking_config the CLI doesn't
|
||||
# emit). Falls through to gc/gemini-3.1-pro-preview, which works
|
||||
# for non-tool turns; multi-step tool turns still hit the
|
||||
# thoughtSignature validator but that's a separate fight.
|
||||
"gemini-3-flash-preview": "gemini-3-flash",
|
||||
"gemini-3.1-flash-lite-preview": "gemini-3-flash",
|
||||
}
|
||||
|
||||
def resolve_model_id_for_sdk(short_name: str, settings: AppSettings) -> str:
|
||||
"""Short model name → id string for ClaudeAgentOptions."""
|
||||
entry = _find_builtin_model(short_name)
|
||||
entry = find_builtin_model(short_name)
|
||||
if entry is None:
|
||||
return short_name
|
||||
if entry.get("route") == "cc":
|
||||
@@ -250,28 +263,16 @@ def resolve_model_id_for_sdk(short_name: str, settings: AppSettings) -> str:
|
||||
# AG bypasses the thoughtSignature validator that breaks multi-step tool
|
||||
# turns on gc/. Without it, every Gemini turn 400s after the first tool
|
||||
# call with "Thought signature is not valid".
|
||||
_ANTIGRAVITY_MAP = {
|
||||
# gemini-3-pro-preview disabled: AG returns 404 even with active conn.
|
||||
# gemini-3.1-pro-preview disabled: AG's `gemini-3.1-pro-high` variant
|
||||
# 400s every request with "invalid argument" (the `-high` thinking-
|
||||
# budget alias on AG requires a thinking_config the CLI doesn't
|
||||
# emit). Falls through to gc/gemini-3.1-pro-preview, which works
|
||||
# for non-tool turns; multi-step tool turns still hit the
|
||||
# thoughtSignature validator but that's a separate fight.
|
||||
"gemini-3-flash-preview": "gemini-3-flash",
|
||||
"gemini-3.1-flash-lite-preview": "gemini-3-flash",
|
||||
}
|
||||
if entry.get("api") == "gemini-cli":
|
||||
rid = entry.get("router_model_id", "")
|
||||
if isinstance(rid, str) and rid.startswith("gc/"):
|
||||
suffix = rid[len("gc/"):]
|
||||
if getattr(settings, "google_api_key", None):
|
||||
return "gemini/" + suffix
|
||||
ag_suffix = _ANTIGRAVITY_MAP.get(suffix)
|
||||
ag_suffix = P_ANTIGRAVITY_MAP.get(suffix)
|
||||
if ag_suffix:
|
||||
try:
|
||||
import httpx as _httpx
|
||||
r = _httpx.get("http://localhost:20128/api/providers", timeout=2.0)
|
||||
r = httpx.get("http://localhost:20128/api/providers", timeout=2.0)
|
||||
if r.status_code == 200:
|
||||
data = r.json()
|
||||
conns = data.get("connections", []) if isinstance(data, dict) else (data if isinstance(data, list) else [])
|
||||
@@ -305,14 +306,14 @@ async def resolve_aux_model(
|
||||
or_sonnet = "openrouter/anthropic/claude-sonnet-4.5"
|
||||
bare = haiku_bare if preferred_tier == "haiku" else sonnet_bare
|
||||
or_aux = or_haiku if preferred_tier == "haiku" else or_sonnet
|
||||
|
||||
from backend.apps.nine_router import is_running as _9r_running, get_providers as _9r_providers
|
||||
|
||||
from backend.apps.nine_router.process import is_running, get_providers
|
||||
|
||||
base_url = "http://localhost:20128"
|
||||
connected: set[str] = set()
|
||||
if _9r_running():
|
||||
if is_running():
|
||||
try:
|
||||
connections = await _9r_providers()
|
||||
connections = await get_providers()
|
||||
connected = {c.get("provider") for c in connections if c.get("isActive")}
|
||||
except Exception:
|
||||
connected = set()
|
||||
@@ -340,7 +341,7 @@ async def resolve_aux_model(
|
||||
if getattr(settings, "anthropic_api_key", None):
|
||||
return (bare, None)
|
||||
|
||||
if not _9r_running():
|
||||
if not is_running():
|
||||
raise ValueError(
|
||||
"No AI provider configured for auxiliary LLM call. "
|
||||
"Set an Anthropic API key or connect a subscription."
|
||||
@@ -375,9 +376,9 @@ def get_context_window(model: str, settings: AppSettings | None = None) -> int:
|
||||
# bare-model tail against any custom provider's models list.
|
||||
if settings:
|
||||
bare_model = model
|
||||
if isinstance(model, str) and model.startswith(_CUSTOM_VALUE_PREFIX):
|
||||
rest = model[len(_CUSTOM_VALUE_PREFIX):]
|
||||
_slug, _sep, bare_model = rest.partition("/")
|
||||
if isinstance(model, str) and model.startswith(P_CUSTOM_VALUE_PREFIX):
|
||||
rest = model[len(P_CUSTOM_VALUE_PREFIX):]
|
||||
_, _, bare_model = rest.partition("/")
|
||||
for cp in getattr(settings, "custom_providers", []):
|
||||
for m in (getattr(cp, "models", None) or []):
|
||||
if m.get("value") == bare_model or m.get("id") == bare_model:
|
||||
|
||||
@@ -52,7 +52,7 @@ async def _sync_pro_routing(settings_obj) -> None:
|
||||
paying user into pro mode and sign-out must tear the lane down so a
|
||||
revoked bearer doesn't linger in the router."""
|
||||
try:
|
||||
from backend.apps.nine_router import sync_pro_routing
|
||||
from backend.apps.nine_router.sync_custom import sync_pro_routing
|
||||
await sync_pro_routing(settings_obj)
|
||||
except Exception as e:
|
||||
logger.debug("pro routing sync skipped: %s", e)
|
||||
@@ -211,7 +211,7 @@ async def signout():
|
||||
# Best-effort: failures here shouldn't block the sign-out itself.
|
||||
try:
|
||||
from backend.apps.agents.agent_manager import agent_manager
|
||||
from backend.apps.agents.agent_manager import _save_session
|
||||
from backend.apps.agents.manager.session.session_store import save_session
|
||||
|
||||
running = list(agent_manager.tasks.keys())
|
||||
for session_id in running:
|
||||
@@ -227,7 +227,7 @@ async def signout():
|
||||
if sess.sdk_session_id:
|
||||
sess.sdk_session_id = None
|
||||
try:
|
||||
_save_session(sess.id, sess.model_dump(mode="json"))
|
||||
save_session(sess.id, sess.model_dump(mode="json"))
|
||||
except Exception as e:
|
||||
logger.warning("signout: save_session(%s) failed: %s", sess.id, e)
|
||||
|
||||
|
||||
@@ -421,7 +421,7 @@ async def duplicate_dashboard(dashboard_id: str):
|
||||
now = datetime.now().isoformat()
|
||||
|
||||
from backend.apps.agents.agent_manager import agent_manager
|
||||
from backend.apps.agents.manager.session.session_store import _save_session
|
||||
from backend.apps.agents.manager.session.session_store import save_session
|
||||
|
||||
source_layout = source_data.get("layout", {}) or {}
|
||||
source_browser_cards = source_layout.get("browser_cards", {}) or {}
|
||||
@@ -471,7 +471,7 @@ async def duplicate_dashboard(dashboard_id: str):
|
||||
new_sess.browser_id = browser_id_remap[old_browser_id]
|
||||
if old_parent_sid and old_parent_sid in session_id_remap:
|
||||
new_sess.parent_session_id = session_id_remap[old_parent_sid]
|
||||
_save_session(new_sess.id, new_sess.model_dump(mode="json"))
|
||||
save_session(new_sess.id, new_sess.model_dump(mode="json"))
|
||||
|
||||
new_cards: dict[str, dict] = {}
|
||||
for old_sid, card in source_cards.items():
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
"""Auto-start and manage the 9Router subprocess.
|
||||
|
||||
9Router is a free AI subscription proxy that lets users connect their
|
||||
Claude/ChatGPT/Gemini subscriptions to OpenSwarm without API keys. It runs
|
||||
silently on port 20128 and exposes an OpenAI-compatible API at
|
||||
localhost:20128/v1.
|
||||
|
||||
This package was split out of a single ~1190-line module. The public API is
|
||||
unchanged: every `from backend.apps.nine_router import X` keeps resolving via
|
||||
the re-exports below.
|
||||
|
||||
- process.py: subprocess lifecycle (the single owner of the process handle),
|
||||
constants, ports/URLs, the pinned NPM version, path resolution, stats.
|
||||
- sync.py: Gemini/OpenAI/OpenRouter API-key sync.
|
||||
- sync_custom.py: custom OpenAI-compatible provider + OpenSwarm Pro sync.
|
||||
- oauth.py: OAuth start/poll/exchange + the Codex 1455 callback listener.
|
||||
"""
|
||||
|
||||
import httpx # noqa: F401 patch point: tests stub backend.apps.nine_router.httpx.AsyncClient
|
||||
|
||||
from .process import (
|
||||
NINE_ROUTER_API,
|
||||
NINE_ROUTER_NPM_VERSION,
|
||||
NINE_ROUTER_PORT,
|
||||
NINE_ROUTER_URL,
|
||||
NINE_ROUTER_V1,
|
||||
ensure_running,
|
||||
get_latest_reasoning_tokens,
|
||||
get_providers,
|
||||
get_usage_stats,
|
||||
is_running,
|
||||
stop,
|
||||
)
|
||||
from .sync import (
|
||||
NINE_ROUTER_CLAUDE_PRO_NAME,
|
||||
NINE_ROUTER_KEYED_NAME,
|
||||
NINE_ROUTER_OPENAI_KEYED_NAME,
|
||||
NINE_ROUTER_OPENAI_KEYED_PREFIX,
|
||||
NINE_ROUTER_OPENROUTER_KEYED_NAME,
|
||||
sync_gemini_api_key,
|
||||
sync_openai_api_key,
|
||||
sync_openrouter_api_key,
|
||||
)
|
||||
from .sync_custom import (
|
||||
NINE_ROUTER_CUSTOM_NAME_SUFFIX,
|
||||
normalize_openai_compat_base_url,
|
||||
sync_custom_providers,
|
||||
sync_openswarm_pro_as_claude,
|
||||
sync_pro_routing,
|
||||
)
|
||||
from .oauth import (
|
||||
exchange_oauth,
|
||||
get_models,
|
||||
poll_oauth,
|
||||
start_oauth,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"NINE_ROUTER_API",
|
||||
"NINE_ROUTER_NPM_VERSION",
|
||||
"NINE_ROUTER_PORT",
|
||||
"NINE_ROUTER_URL",
|
||||
"NINE_ROUTER_V1",
|
||||
"NINE_ROUTER_CLAUDE_PRO_NAME",
|
||||
"NINE_ROUTER_KEYED_NAME",
|
||||
"NINE_ROUTER_OPENAI_KEYED_NAME",
|
||||
"NINE_ROUTER_OPENAI_KEYED_PREFIX",
|
||||
"NINE_ROUTER_OPENROUTER_KEYED_NAME",
|
||||
"NINE_ROUTER_CUSTOM_NAME_SUFFIX",
|
||||
"ensure_running",
|
||||
"stop",
|
||||
"is_running",
|
||||
"get_usage_stats",
|
||||
"get_latest_reasoning_tokens",
|
||||
"get_providers",
|
||||
"get_models",
|
||||
"start_oauth",
|
||||
"poll_oauth",
|
||||
"exchange_oauth",
|
||||
"sync_gemini_api_key",
|
||||
"sync_openai_api_key",
|
||||
"sync_openrouter_api_key",
|
||||
"sync_custom_providers",
|
||||
"sync_openswarm_pro_as_claude",
|
||||
"sync_pro_routing",
|
||||
"normalize_openai_compat_base_url",
|
||||
]
|
||||
@@ -75,8 +75,8 @@ async def _pulse_loop():
|
||||
|
||||
cost_delta = 0.0
|
||||
try:
|
||||
from backend.apps.nine_router import get_usage_stats, is_running as _9r_running
|
||||
if _9r_running():
|
||||
from backend.apps.nine_router.process import get_usage_stats, is_running
|
||||
if is_running():
|
||||
stats = await get_usage_stats()
|
||||
if stats:
|
||||
cur_cost = stats.get("totalCost", 0) or 0
|
||||
@@ -124,13 +124,14 @@ async def service_lifespan():
|
||||
global _pulse_task, _drain_task
|
||||
|
||||
try:
|
||||
from backend.apps.settings.settings import load_settings, _save_settings
|
||||
from backend.apps.settings.settings import load_settings
|
||||
from backend.apps.settings.store import save_settings
|
||||
settings = load_settings()
|
||||
|
||||
is_first_open = settings.first_opened_at is None
|
||||
if is_first_open:
|
||||
settings.first_opened_at = datetime.now().isoformat()
|
||||
_save_settings(settings)
|
||||
save_settings(settings)
|
||||
|
||||
days_since_install = 0
|
||||
if settings.first_opened_at:
|
||||
@@ -192,8 +193,8 @@ async def service_lifespan():
|
||||
logger.debug(f"Service startup event failed (non-critical): {e}")
|
||||
|
||||
try:
|
||||
from backend.apps.nine_router import ensure_running as ensure_9router
|
||||
await ensure_9router()
|
||||
from backend.apps.nine_router.process import ensure_running
|
||||
await ensure_running()
|
||||
except Exception as e:
|
||||
logger.debug(f"9Router auto-start skipped: {e}")
|
||||
|
||||
@@ -219,8 +220,8 @@ async def service_lifespan():
|
||||
_drain_task = None
|
||||
|
||||
try:
|
||||
from backend.apps.nine_router import stop as stop_9router
|
||||
stop_9router()
|
||||
from backend.apps.nine_router.process import stop
|
||||
stop()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -321,8 +322,8 @@ async def usage_summary():
|
||||
completed = status_counts.get("completed", 0)
|
||||
completion_rate = completed / total_sessions if total_sessions > 0 else 0
|
||||
|
||||
from backend.apps.nine_router import get_usage_stats, is_running as _9r_running
|
||||
nine_router_stats = await get_usage_stats() if _9r_running() else None
|
||||
from backend.apps.nine_router.process import get_usage_stats, is_running
|
||||
nine_router_stats = await get_usage_stats() if is_running() else None
|
||||
|
||||
if nine_router_stats and nine_router_stats.get("totalCost", 0) > 0:
|
||||
cost_source = "9router"
|
||||
@@ -382,8 +383,8 @@ async def usage_summary():
|
||||
|
||||
@service.router.get("/cost-breakdown")
|
||||
async def cost_breakdown(period: str = "7d"):
|
||||
from backend.apps.nine_router import get_usage_stats, is_running as _9r_running
|
||||
if not _9r_running():
|
||||
from backend.apps.nine_router.process import get_usage_stats, is_running
|
||||
if not is_running():
|
||||
return {"available": False, "by_model": {}, "by_provider": {}}
|
||||
stats = await get_usage_stats(period)
|
||||
if not stats:
|
||||
|
||||
@@ -24,19 +24,12 @@ logger = logging.getLogger(__name__)
|
||||
async def settings_lifespan():
|
||||
os.makedirs(DATA_DIR, exist_ok=True)
|
||||
try:
|
||||
from backend.apps.nine_router import (
|
||||
ensure_running as _9r_ensure,
|
||||
is_running as _9r_running,
|
||||
sync_gemini_api_key,
|
||||
sync_openai_api_key,
|
||||
sync_openrouter_api_key,
|
||||
sync_openswarm_pro_as_claude,
|
||||
sync_custom_providers,
|
||||
)
|
||||
from backend.apps.nine_router.process import ensure_running, is_running
|
||||
from backend.apps.nine_router.sync import sync_gemini_api_key, sync_openai_api_key, sync_openrouter_api_key
|
||||
from backend.apps.nine_router.sync_custom import sync_openswarm_pro_as_claude, sync_custom_providers
|
||||
s = load_settings()
|
||||
import asyncio as _asyncio
|
||||
|
||||
async def _boot_router_then_sync():
|
||||
async def boot_router_then_sync():
|
||||
"""Boot 9Router then push key-based connections (sequential: sync helpers no-op pre-boot)."""
|
||||
needs_router = any([
|
||||
getattr(s, "google_api_key", None),
|
||||
@@ -47,7 +40,7 @@ async def settings_lifespan():
|
||||
])
|
||||
if needs_router:
|
||||
try:
|
||||
await _9r_ensure()
|
||||
await ensure_running()
|
||||
except Exception as e:
|
||||
logger.warning(f"9Router lifespan boot failed: {e}")
|
||||
# Reconcile, don't just add: pass the key OR None so a cleared/never-set key
|
||||
@@ -55,7 +48,7 @@ async def settings_lifespan():
|
||||
# old add-only guards left a zombie managed key alive after disconnect, which
|
||||
# kept routing to it (the "still defaults to gemini") and blocked the free
|
||||
# trial from arming. Only acts when 9Router is already up (_sync no-ops if not).
|
||||
if _9r_running():
|
||||
if is_running():
|
||||
await sync_gemini_api_key(getattr(s, "google_api_key", None) or None)
|
||||
await sync_openai_api_key(getattr(s, "openai_api_key", None) or None)
|
||||
await sync_openrouter_api_key(getattr(s, "openrouter_api_key", None) or None)
|
||||
@@ -66,14 +59,14 @@ async def settings_lifespan():
|
||||
await sync_openswarm_pro_as_claude(bearer, base)
|
||||
await sync_custom_providers(getattr(s, "custom_providers", None) or [])
|
||||
|
||||
_asyncio.create_task(_boot_router_then_sync())
|
||||
_asyncio.create_task(_upload_dir_gc_loop())
|
||||
asyncio.create_task(boot_router_then_sync())
|
||||
asyncio.create_task(p_upload_dir_gc_loop())
|
||||
except Exception as e:
|
||||
logger.warning(f"9Router sync startup failed: {e}")
|
||||
yield
|
||||
|
||||
|
||||
async def _upload_dir_gc_loop():
|
||||
async def p_upload_dir_gc_loop():
|
||||
"""Daily GC of UPLOAD_DIR. Without this, every PDF/image the user
|
||||
drops sits in the OS temp dir forever, growing unbounded across
|
||||
sessions. We keep files for 7 days to make resume-after-restart
|
||||
@@ -81,7 +74,6 @@ async def _upload_dir_gc_loop():
|
||||
by the OS but not aggressively; Windows temp is not. Belt and braces.
|
||||
Errors are swallowed: a chmod hiccup or in-use lock should never
|
||||
crash the backend."""
|
||||
import asyncio as _a
|
||||
while True:
|
||||
try:
|
||||
now = time.time()
|
||||
@@ -96,7 +88,7 @@ async def _upload_dir_gc_loop():
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
await _a.sleep(24 * 3600)
|
||||
await asyncio.sleep(24 * 3600)
|
||||
|
||||
|
||||
settings = SubApp("settings", settings_lifespan)
|
||||
@@ -137,7 +129,7 @@ SERVER_OWNED_FIELDS = (
|
||||
|
||||
@settings.router.put("")
|
||||
async def update_settings(body: AppSettings):
|
||||
from backend.apps.service.client import sync as _sync
|
||||
from backend.apps.service.client import sync
|
||||
|
||||
old = load_settings()
|
||||
for k in SERVER_OWNED_FIELDS:
|
||||
@@ -154,9 +146,8 @@ async def update_settings(body: AppSettings):
|
||||
body.free_trial_token = None
|
||||
body.free_trial_remaining = None
|
||||
try:
|
||||
import asyncio as _aio
|
||||
from backend.apps.nine_router import sync_pro_routing as _spr
|
||||
_aio.create_task(_spr(body)) # drop the now-stale free-trial 9router node
|
||||
from backend.apps.nine_router.sync_custom import sync_pro_routing
|
||||
asyncio.create_task(sync_pro_routing(body)) # drop the now-stale free-trial 9router node
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -164,11 +155,11 @@ async def update_settings(body: AppSettings):
|
||||
"claude_subscription_token", "openai_subscription_token", "gemini_subscription_token",
|
||||
"openswarm_bearer_token", "free_trial_token", "installation_id"}
|
||||
safe = {k: v for k, v in body.model_dump().items() if k not in secret_keys}
|
||||
_sync(safe)
|
||||
sync(safe)
|
||||
|
||||
if (body.user_email and body.user_email != getattr(old, "user_email", None)) or \
|
||||
(body.user_name and body.user_name != getattr(old, "user_name", None)):
|
||||
from backend.apps.service.client import identify as _identify
|
||||
from backend.apps.service.client import identify
|
||||
id_props = {}
|
||||
if body.user_email:
|
||||
id_props["email"] = body.user_email
|
||||
@@ -179,7 +170,7 @@ async def update_settings(body: AppSettings):
|
||||
if body.user_referral_source:
|
||||
id_props["referral_source"] = body.user_referral_source
|
||||
if id_props:
|
||||
_identify(id_props)
|
||||
identify(id_props)
|
||||
|
||||
await save_settings_async(body)
|
||||
|
||||
@@ -208,14 +199,14 @@ async def update_settings(body: AppSettings):
|
||||
|
||||
if openrouter_changed:
|
||||
try:
|
||||
from backend.apps.agents.providers.registry import invalidate_openrouter_cache
|
||||
from backend.apps.agents.providers.openrouter import invalidate_openrouter_cache
|
||||
invalidate_openrouter_cache()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Off the request path: ensure_running() can take 5min on first install (npm pull) and would freeze the loop.
|
||||
if google_changed or openai_changed or openrouter_changed or custom_providers_changed:
|
||||
async def _boot_and_sync_keys(
|
||||
async def boot_and_sync_keys(
|
||||
google_key: str | None,
|
||||
openai_key: str | None,
|
||||
openrouter_key: str | None,
|
||||
@@ -227,16 +218,11 @@ async def update_settings(body: AppSettings):
|
||||
need_boot: bool,
|
||||
):
|
||||
try:
|
||||
from backend.apps.nine_router import (
|
||||
ensure_running as _9r_ensure,
|
||||
is_running as _9r_running,
|
||||
sync_gemini_api_key,
|
||||
sync_openai_api_key,
|
||||
sync_openrouter_api_key,
|
||||
sync_custom_providers,
|
||||
)
|
||||
if need_boot and not _9r_running():
|
||||
await _9r_ensure()
|
||||
from backend.apps.nine_router.process import ensure_running, is_running
|
||||
from backend.apps.nine_router.sync import sync_gemini_api_key, sync_openai_api_key, sync_openrouter_api_key
|
||||
from backend.apps.nine_router.sync_custom import sync_custom_providers
|
||||
if need_boot and not is_running():
|
||||
await ensure_running()
|
||||
if do_google:
|
||||
await sync_gemini_api_key(google_key or None)
|
||||
if do_openai:
|
||||
@@ -248,7 +234,7 @@ async def update_settings(body: AppSettings):
|
||||
except Exception as e:
|
||||
logger.warning(f"Background apikey sync failed: {e}")
|
||||
|
||||
asyncio.create_task(_boot_and_sync_keys(
|
||||
asyncio.create_task(boot_and_sync_keys(
|
||||
getattr(body, "google_api_key", None),
|
||||
getattr(body, "openai_api_key", None),
|
||||
getattr(body, "openrouter_api_key", None),
|
||||
@@ -306,7 +292,7 @@ UPLOAD_DIR = os.path.join(tempfile.gettempdir(), "self-swarm-uploads")
|
||||
os.makedirs(UPLOAD_DIR, exist_ok=True)
|
||||
|
||||
|
||||
def _sniff_file_kind(contents: bytes) -> tuple[str, str | None]:
|
||||
def sniff_file_kind(contents: bytes) -> tuple[str, str | None]:
|
||||
"""Classify an uploaded file as text/pdf/image/binary so the agent
|
||||
layer can route it (inline as text, send as native document/image
|
||||
block, or refuse). Returns (kind, media_type)."""
|
||||
@@ -436,7 +422,7 @@ async def upload_files(files: list[UploadFile] = File(...)):
|
||||
pass
|
||||
raise
|
||||
|
||||
kind, media_type = _sniff_file_kind(contents, safe_name)
|
||||
kind, media_type = sniff_file_kind(contents, safe_name)
|
||||
|
||||
if kind == "text":
|
||||
try:
|
||||
|
||||
@@ -147,8 +147,4 @@ def _atomic_write_settings(payload: dict) -> None:
|
||||
|
||||
def save_settings(settings_obj: AppSettings) -> None:
|
||||
"""Sync atomic persist; thread-safe. Async callers should prefer save_settings_async (Defender can stretch writes to 50-200ms)."""
|
||||
_atomic_write_settings(settings_obj.model_dump())
|
||||
|
||||
|
||||
def _save_settings(settings_obj: AppSettings) -> None:
|
||||
save_settings(settings_obj)
|
||||
_atomic_write_settings(settings_obj.model_dump())
|
||||
@@ -100,10 +100,10 @@ async def _has_connected_subscription() -> bool:
|
||||
connections live in 9Router, not settings, so the sync check above misses
|
||||
them; this catches a sub connected while the trial was armed."""
|
||||
try:
|
||||
from backend.apps.nine_router import is_running as _9r_running, get_providers as _9r_providers
|
||||
if not _9r_running():
|
||||
from backend.apps.nine_router.process import is_running, get_providers
|
||||
if not is_running():
|
||||
return False
|
||||
conns = await _9r_providers()
|
||||
conns = await get_providers()
|
||||
return any(
|
||||
c.get("isActive") and c.get("provider") in ("claude", "codex", "gemini-cli")
|
||||
for c in conns
|
||||
@@ -118,7 +118,7 @@ def _proxy_base(settings_obj) -> str:
|
||||
|
||||
async def _sync_routing(settings_obj) -> None:
|
||||
try:
|
||||
from backend.apps.nine_router import sync_pro_routing
|
||||
from backend.apps.nine_router.sync_custom import sync_pro_routing
|
||||
await sync_pro_routing(settings_obj)
|
||||
except Exception as e:
|
||||
logger.debug("free-trial routing sync skipped: %s", e)
|
||||
|
||||
@@ -39,7 +39,7 @@ async def _sync_pro_routing(settings_obj) -> None:
|
||||
non-Claude primaries). PUT /api/settings no longer carries these fields,
|
||||
so the state-change endpoints here are the only trigger left."""
|
||||
try:
|
||||
from backend.apps.nine_router import sync_pro_routing
|
||||
from backend.apps.nine_router.sync_custom import sync_pro_routing
|
||||
await sync_pro_routing(settings_obj)
|
||||
except Exception as e:
|
||||
logger.debug("pro routing sync skipped: %s", e)
|
||||
|
||||
@@ -193,16 +193,16 @@ async def _refresh_9r_connected() -> set[str]:
|
||||
(e.g. {"claude", "codex", "antigravity", "gemini-cli"}). Cached for
|
||||
20s to keep search/fetch endpoints snappy."""
|
||||
global _NINE_ROUTER_CONNECTED, _NINE_ROUTER_CACHE_AT
|
||||
import time as _t
|
||||
now = _t.time()
|
||||
import time
|
||||
now = time.time()
|
||||
if now - _NINE_ROUTER_CACHE_AT < 20.0:
|
||||
return _NINE_ROUTER_CONNECTED
|
||||
try:
|
||||
from backend.apps.nine_router import is_running as _9r_running, get_providers as _9r_providers
|
||||
if not _9r_running():
|
||||
from backend.apps.nine_router.process import is_running, get_providers
|
||||
if not is_running():
|
||||
_NINE_ROUTER_CONNECTED = set()
|
||||
else:
|
||||
conns = await _9r_providers()
|
||||
conns = await get_providers()
|
||||
_NINE_ROUTER_CONNECTED = {
|
||||
c.get("provider")
|
||||
for c in conns
|
||||
|
||||
@@ -9,7 +9,7 @@ Two jobs:
|
||||
on a garbled/unreadable file, so a single corrupt file can't crash a whole
|
||||
load-all path (and take down boot or a page with it).
|
||||
|
||||
Settings/seq_log/auth keep their own inlined atomic writers; this is for the
|
||||
Settings/SEQ_LOG/auth keep their own inlined atomic writers; this is for the
|
||||
stores that were still doing plain open()+dump().
|
||||
"""
|
||||
import json
|
||||
|
||||
+3
-3
@@ -204,13 +204,13 @@ async def websocket_session(websocket: WebSocket, session_id: str):
|
||||
last_seq = int(payload.get("last_seq") or 0)
|
||||
connection_uuid = payload.get("connection_uuid") or ""
|
||||
ack = await ws_manager.replay_to(session_id, websocket, last_seq)
|
||||
from backend.apps.agents.core.seq_log import seq_log as _sl
|
||||
from backend.apps.agents.core.seq_log import SEQ_LOG
|
||||
await websocket.send_text(json.dumps({
|
||||
"event": "server:hello",
|
||||
"session_id": session_id,
|
||||
"data": {
|
||||
"connection_uuid": connection_uuid,
|
||||
"current_seq": _sl.current_seq(session_id),
|
||||
"current_seq": SEQ_LOG.current_seq(session_id),
|
||||
"ack": ack,
|
||||
},
|
||||
}))
|
||||
@@ -494,7 +494,7 @@ async def subscriptions_callback(request: Request):
|
||||
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
|
||||
from backend.apps.nine_router.oauth import exchange_oauth
|
||||
try:
|
||||
await exchange_oauth(pending["provider"], code, pending["redirect_uri"], pending["code_verifier"], state)
|
||||
except Exception as e:
|
||||
|
||||
@@ -46,27 +46,27 @@ from fastapi.testclient import TestClient
|
||||
_TMPROOT = tempfile.mkdtemp(prefix="openswarm-disconnect-test-")
|
||||
os.environ.setdefault("OPENSWARM_DATA_DIR", _TMPROOT)
|
||||
|
||||
# Push the seq_log persist dir to a deterministic location too.
|
||||
# Push the SEQ_LOG persist dir to a deterministic location too.
|
||||
_SEQ_DIR = os.path.join(_TMPROOT, "seq_terminals")
|
||||
os.makedirs(_SEQ_DIR, exist_ok=True)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _patch_persist_dir():
|
||||
"""Force the seq_log to use our tmp dir so we can assert on disk state."""
|
||||
from backend.apps.agents.core import seq_log as sl_mod
|
||||
"""Point the shared SEQ_LOG singleton at our tmp dir so we can assert on disk state.
|
||||
|
||||
# Rebuild the singleton with our test dir.
|
||||
new_store = sl_mod.SeqLogStore(persist_dir=_SEQ_DIR)
|
||||
monkey = patch.object(sl_mod, "seq_log", new_store)
|
||||
monkey.start()
|
||||
# Also patch the symbol re-exported into ws_manager's import scope.
|
||||
from backend.apps.agents.core import ws_manager as wm_mod
|
||||
wm_monkey = patch.object(wm_mod, "seq_log", new_store)
|
||||
wm_monkey.start()
|
||||
yield new_store
|
||||
monkey.stop()
|
||||
wm_monkey.stop()
|
||||
ws_manager imports SEQ_LOG by value, so both modules hold the same object;
|
||||
patching the object's attribute is visible everywhere with one patch, no
|
||||
need to rebind a name in each import scope.
|
||||
"""
|
||||
from backend.apps.agents.core.seq_log import SEQ_LOG
|
||||
|
||||
os.makedirs(_SEQ_DIR, exist_ok=True)
|
||||
with patch.object(SEQ_LOG, "_persist_dir", _SEQ_DIR):
|
||||
# Fresh per-session state so each test is isolated.
|
||||
SEQ_LOG._per_session.clear()
|
||||
yield SEQ_LOG
|
||||
SEQ_LOG._per_session.clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user