fix: route all agent loopback calls through internal_api_base() helper (#3322)

#2753 made the agent loopback base port-configurable but only for
_COOKBOOK_BASE in tool_implementations. Several other in-process loopback
calls still hardcoded http://localhost:7000 and broke off port 7000:
cookbook_serve_lifecycle (model-endpoints x2, shell/exec), builtin_actions
(model/serve), task_routes (calendar x3), and the gallery/email calls in
tool_implementations.

Extract the resolution (ODYSSEUS_INTERNAL_BASE / APP_PORT / 7000 fallback,
127.0.0.1 to avoid IPv6 ambiguity) into core.constants.internal_api_base()
and route every call site through it. Rename the now-misnamed _COOKBOOK_BASE
to _INTERNAL_BASE since it serves gallery/email/calendar/serve too. Adds a
test for the resolver plus a regression guard against reintroducing the
literal.

Part of #2752.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Kenny Van de Maele
2026-06-07 23:22:09 +02:00
committed by GitHub
parent d85c5e335e
commit 76c1f42ab0
6 changed files with 116 additions and 53 deletions
+35 -46
View File
@@ -13,6 +13,7 @@ import re
from typing import Any, Dict, List, Optional
from src.constants import MAX_OUTPUT_CHARS, MAX_READ_CHARS
from core.constants import internal_api_base
def get_mcp_manager():
@@ -2492,24 +2493,12 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None) -> Dict:
# ── Cookbook tools ──
# Cookbook routes loopback. The agent's tool calls run in-process but
# need to reach admin-gated cookbook routes; we ride the per-process
# internal token so require_admin lets us through. See core/middleware.py.
#
# Resolution order:
# 1. ODYSSEUS_INTERNAL_BASE — explicit override (e.g. behind a TLS proxy).
# 2. APP_PORT — derive http://127.0.0.1:$APP_PORT (matches docker-compose).
# 3. Fallback http://127.0.0.1:7000 — preserves legacy default.
#
# 127.0.0.1 (not "localhost") avoids IPv6/DNS ambiguity for a strictly-local
# call. Without this, tools that loop back (app_api, trigger_research,
# cookbook state read/write) fail with "All connection attempts failed"
# whenever the running uvicorn isn't on 7000 — which is most non-default
# deployments and any side-by-side multi-instance setup.
_COOKBOOK_BASE = os.environ.get(
"ODYSSEUS_INTERNAL_BASE",
f"http://127.0.0.1:{os.environ.get('APP_PORT', '7000')}",
)
# In-process loopback base for agent tools that call Odysseus's own API
# (cookbook state, model serve, gallery, email, calendar). We ride the
# per-process internal token so require_admin lets us through. See
# core/middleware.py. Resolution (override / APP_PORT / 7000) lives in
# core.constants.internal_api_base().
_INTERNAL_BASE = internal_api_base()
def _internal_headers(owner: Optional[str] = None) -> Dict[str, str]:
@@ -2528,7 +2517,7 @@ async def _cookbook_servers() -> Dict[str, Any]:
import httpx
try:
async with httpx.AsyncClient(timeout=10) as client:
r = await client.get(f"{_COOKBOOK_BASE}/api/cookbook/state", headers=_internal_headers())
r = await client.get(f"{_INTERNAL_BASE}/api/cookbook/state", headers=_internal_headers())
state = r.json() if r.headers.get("content-type", "").startswith("application/json") else {}
except Exception:
return {"default_host": "", "hosts": []}
@@ -2594,7 +2583,7 @@ async def _cookbook_env_for_host(host: str) -> Dict[str, Any]:
state: Dict[str, Any] = {}
try:
async with httpx.AsyncClient(timeout=10) as client:
r = await client.get(f"{_COOKBOOK_BASE}/api/cookbook/state", headers=headers)
r = await client.get(f"{_INTERNAL_BASE}/api/cookbook/state", headers=headers)
state = r.json() if r.headers.get("content-type", "").startswith("application/json") else {}
except Exception as e:
logger.debug(f"cookbook env lookup failed for host={host!r}: {e}")
@@ -2654,7 +2643,7 @@ async def _cookbook_register_task(session_id: str, model: str, host: str,
headers = _internal_headers()
try:
async with httpx.AsyncClient(timeout=10) as client:
r = await client.get(f"{_COOKBOOK_BASE}/api/cookbook/state", headers=headers)
r = await client.get(f"{_INTERNAL_BASE}/api/cookbook/state", headers=headers)
state = r.json() if r.headers.get("content-type", "").startswith("application/json") else {}
except Exception as e:
logger.debug(f"cookbook state read failed: {e}")
@@ -2698,7 +2687,7 @@ async def _cookbook_register_task(session_id: str, model: str, host: str,
state["tasks"] = tasks
try:
async with httpx.AsyncClient(timeout=10) as client:
r = await client.post(f"{_COOKBOOK_BASE}/api/cookbook/state",
r = await client.post(f"{_INTERNAL_BASE}/api/cookbook/state",
json=state, headers=headers)
return r.status_code < 400
except Exception as e:
@@ -2781,7 +2770,7 @@ async def do_app_api(content: str, owner: Optional[str] = None) -> Dict:
return {"error": "Invalid JSON arguments", "exit_code": 1}
action = (args.get("action") or "call").lower()
base = _COOKBOOK_BASE
base = _INTERNAL_BASE
if action == "endpoints":
# Fetch FastAPI's OpenAPI schema so the agent can discover any
@@ -3042,7 +3031,7 @@ async def do_download_model(content: str, owner: Optional[str] = None) -> Dict:
if env_cfg.get("ssh_port"): payload["ssh_port"] = env_cfg["ssh_port"]
try:
async with httpx.AsyncClient(timeout=30) as client:
resp = await client.post(f"{_COOKBOOK_BASE}/api/model/download",
resp = await client.post(f"{_INTERNAL_BASE}/api/model/download",
json=payload, headers=_internal_headers())
data = resp.json()
if data.get("ok"):
@@ -3118,7 +3107,7 @@ async def do_serve_model(content: str, owner: Optional[str] = None) -> Dict:
if env_cfg.get("ssh_port"): payload["ssh_port"] = env_cfg["ssh_port"]
try:
async with httpx.AsyncClient(timeout=30) as client:
resp = await client.post(f"{_COOKBOOK_BASE}/api/model/serve",
resp = await client.post(f"{_INTERNAL_BASE}/api/model/serve",
json=payload, headers=_internal_headers())
data = resp.json()
if data.get("ok"):
@@ -3158,7 +3147,7 @@ async def do_list_served_models(content: str, owner: Optional[str] = None) -> Di
cookbook_tasks: List[Dict[str, Any]] = []
try:
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.get(f"{_COOKBOOK_BASE}/api/cookbook/tasks/status",
resp = await client.get(f"{_INTERNAL_BASE}/api/cookbook/tasks/status",
headers=_internal_headers())
cookbook_tasks = (resp.json() or {}).get("tasks") or []
except Exception as e:
@@ -3277,7 +3266,7 @@ async def _cookbook_kill_session(session_id: str, *, remote_host: str = "",
state: Dict[str, Any] = {}
try:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(f"{_COOKBOOK_BASE}/api/cookbook/state", headers=headers)
resp = await client.get(f"{_INTERNAL_BASE}/api/cookbook/state", headers=headers)
state = resp.json() or {}
except Exception as e:
logger.debug(f"cookbook state lookup failed for {session_id}: {e}")
@@ -3306,7 +3295,7 @@ async def _cookbook_kill_session(session_id: str, *, remote_host: str = "",
try:
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.post(f"{_COOKBOOK_BASE}/api/shell/exec",
resp = await client.post(f"{_INTERNAL_BASE}/api/shell/exec",
json={"command": cmd}, headers=headers)
if resp.status_code >= 400:
return {"error": f"shell/exec returned HTTP {resp.status_code}: {resp.text[:200]}", "exit_code": 1}
@@ -3327,7 +3316,7 @@ async def _cookbook_kill_session(session_id: str, *, remote_host: str = "",
try:
matched["status"] = "stopped"
async with httpx.AsyncClient(timeout=10) as client:
await client.post(f"{_COOKBOOK_BASE}/api/cookbook/state",
await client.post(f"{_INTERNAL_BASE}/api/cookbook/state",
json=state, headers=headers)
except Exception as e:
logger.debug(f"failed to mark {session_id} stopped in state: {e}")
@@ -3390,7 +3379,7 @@ async def do_tail_serve_output(content: str, owner: Optional[str] = None) -> Dic
state: Dict[str, Any] = {}
try:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(f"{_COOKBOOK_BASE}/api/cookbook/state", headers=headers)
resp = await client.get(f"{_INTERNAL_BASE}/api/cookbook/state", headers=headers)
state = resp.json() or {}
except Exception as e:
logger.debug(f"cookbook state lookup failed for {session_id}: {e}")
@@ -3428,7 +3417,7 @@ async def do_tail_serve_output(content: str, owner: Optional[str] = None) -> Dic
host_label = "local"
try:
async with httpx.AsyncClient(timeout=20) as client:
resp = await client.post(f"{_COOKBOOK_BASE}/api/shell/exec",
resp = await client.post(f"{_INTERNAL_BASE}/api/shell/exec",
json={"command": cmd}, headers=headers)
if resp.status_code >= 400:
return {"error": f"shell/exec returned HTTP {resp.status_code}: {resp.text[:200]}", "exit_code": 1}
@@ -3479,7 +3468,7 @@ async def do_list_downloads(content: str, owner: Optional[str] = None) -> Dict:
import httpx
try:
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.get(f"{_COOKBOOK_BASE}/api/cookbook/tasks/status",
resp = await client.get(f"{_INTERNAL_BASE}/api/cookbook/tasks/status",
headers=_internal_headers())
data = resp.json()
tasks = [t for t in data.get("tasks", []) if (t.get("type") or "").lower() == "download"]
@@ -3530,7 +3519,7 @@ async def do_search_hf_models(content: str, owner: Optional[str] = None) -> Dict
params["limit"] = str(limit)
try:
async with httpx.AsyncClient(timeout=30) as client:
resp = await client.get(f"{_COOKBOOK_BASE}/api/cookbook/hf-latest",
resp = await client.get(f"{_INTERNAL_BASE}/api/cookbook/hf-latest",
params=params, headers=_internal_headers())
data = resp.json()
models = data.get("models") if isinstance(data, dict) else data
@@ -3596,7 +3585,7 @@ async def do_adopt_served_model(content: str, owner: Optional[str] = None) -> Di
check = f"tmux has-session -t {shlex.quote(sess)} 2>&1"
try:
async with httpx.AsyncClient(timeout=10) as client:
r = await client.post(f"{_COOKBOOK_BASE}/api/shell/exec",
r = await client.post(f"{_INTERNAL_BASE}/api/shell/exec",
json={"command": check}, headers=headers)
data = r.json() if r.headers.get("content-type", "").startswith("application/json") else {}
if r.status_code >= 400 or (data.get("exit_code") not in (None, 0)):
@@ -3613,7 +3602,7 @@ async def do_adopt_served_model(content: str, owner: Optional[str] = None) -> Di
server_up = False
try:
async with httpx.AsyncClient(timeout=10) as client:
r = await client.post(f"{_COOKBOOK_BASE}/api/shell/exec",
r = await client.post(f"{_INTERNAL_BASE}/api/shell/exec",
json={"command": health_cmd}, headers=headers)
body = (r.json() or {}).get("stdout", "") if r.headers.get("content-type", "").startswith("application/json") else ""
server_up = '"data"' in body or '"object"' in body
@@ -3624,7 +3613,7 @@ async def do_adopt_served_model(content: str, owner: Optional[str] = None) -> Di
# overwrite the whole file (that'd nuke presets).
try:
async with httpx.AsyncClient(timeout=10) as client:
r = await client.get(f"{_COOKBOOK_BASE}/api/cookbook/state", headers=headers)
r = await client.get(f"{_INTERNAL_BASE}/api/cookbook/state", headers=headers)
state = r.json() if r.headers.get("content-type", "").startswith("application/json") else {}
except Exception as e:
return {"error": f"could not read cookbook state: {e}", "exit_code": 1}
@@ -3660,7 +3649,7 @@ async def do_adopt_served_model(content: str, owner: Optional[str] = None) -> Di
state["tasks"] = tasks
try:
async with httpx.AsyncClient(timeout=10) as client:
await client.post(f"{_COOKBOOK_BASE}/api/cookbook/state",
await client.post(f"{_INTERNAL_BASE}/api/cookbook/state",
json=state, headers=headers)
except Exception as e:
return {"error": f"could not save cookbook state: {e}", "exit_code": 1}
@@ -3737,7 +3726,7 @@ async def do_list_serve_presets(content: str, owner: Optional[str] = None) -> Di
import httpx
try:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(f"{_COOKBOOK_BASE}/api/cookbook/state",
resp = await client.get(f"{_INTERNAL_BASE}/api/cookbook/state",
headers=_internal_headers())
state = resp.json() or {}
except Exception as e:
@@ -3785,7 +3774,7 @@ async def do_serve_preset(content: str, owner: Optional[str] = None) -> Dict:
try:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(f"{_COOKBOOK_BASE}/api/cookbook/state",
resp = await client.get(f"{_INTERNAL_BASE}/api/cookbook/state",
headers=_internal_headers())
state = resp.json() or {}
except Exception as e:
@@ -3829,7 +3818,7 @@ async def do_serve_preset(content: str, owner: Optional[str] = None) -> Dict:
try:
async with httpx.AsyncClient(timeout=30) as client:
resp = await client.post(f"{_COOKBOOK_BASE}/api/model/serve",
resp = await client.post(f"{_INTERNAL_BASE}/api/model/serve",
json=payload, headers=_internal_headers())
data = resp.json()
if data.get("ok"):
@@ -3881,7 +3870,7 @@ async def do_list_cached_models(content: str, owner: Optional[str] = None) -> Di
p["platform"] = args["platform"]
try:
async with httpx.AsyncClient(timeout=60) as client:
resp = await client.get(f"{_COOKBOOK_BASE}/api/model/cached",
resp = await client.get(f"{_INTERNAL_BASE}/api/model/cached",
params=p, headers=headers)
data = resp.json()
ms = data.get("models", []) if isinstance(data, dict) else (data or [])
@@ -3901,7 +3890,7 @@ async def do_list_cached_models(content: str, owner: Optional[str] = None) -> Di
servers: list = []
try:
async with httpx.AsyncClient(timeout=10) as client:
st = await client.get(f"{_COOKBOOK_BASE}/api/cookbook/state", headers=headers)
st = await client.get(f"{_INTERNAL_BASE}/api/cookbook/state", headers=headers)
st_data = st.json() if st.headers.get("content-type", "").startswith("application/json") else {}
servers = (st_data.get("env", {}) or {}).get("servers") or []
except Exception as e:
@@ -3972,7 +3961,7 @@ async def do_list_cached_models(content: str, owner: Optional[str] = None) -> Di
downloaded = []
try:
async with httpx.AsyncClient(timeout=10) as client:
st = await client.get(f"{_COOKBOOK_BASE}/api/cookbook/state", headers=headers)
st = await client.get(f"{_INTERNAL_BASE}/api/cookbook/state", headers=headers)
state = st.json() if st.headers.get("content-type", "").startswith("application/json") else {}
for t in (state.get("tasks") or []):
if not isinstance(t, dict) or t.get("type") != "download":
@@ -4043,7 +4032,7 @@ async def do_edit_image(content: str, owner: Optional[str] = None) -> Dict:
payload["scale"] = args["scale"]
try:
async with httpx.AsyncClient(timeout=120) as client:
resp = await client.post(f"http://localhost:7000/api/gallery/{action}", json=payload)
resp = await client.post(f"{_INTERNAL_BASE}/api/gallery/{action}", json=payload)
data = resp.json()
if data.get("success") or data.get("id"):
return {"output": f"Image edited ({action}). New image ID: {data.get('id', '?')}", "exit_code": 0}
@@ -4159,7 +4148,7 @@ async def do_trigger_research(content: str, owner: Optional[str] = None) -> Dict
payload["search_provider"] = args["search_provider"]
try:
async with httpx.AsyncClient(timeout=30) as client:
resp = await client.post(f"{_COOKBOOK_BASE}/api/research/start",
resp = await client.post(f"{_INTERNAL_BASE}/api/research/start",
json=payload, headers=_internal_headers(owner))
if resp.status_code >= 400:
return {"error": f"research/start returned HTTP {resp.status_code}: {resp.text[:200]}", "exit_code": 1}
@@ -4219,7 +4208,7 @@ async def do_resolve_contact(content: str, owner: Optional[str] = None) -> Dict:
async with httpx.AsyncClient(timeout=30) as client:
# 2. Email history (sent/received)
try:
resp = await client.get("http://localhost:7000/api/email/resolve-contact", params={"name": name})
resp = await client.get(f"{_INTERNAL_BASE}/api/email/resolve-contact", params={"name": name})
if resp.status_code == 200:
for c in (resp.json().get("contacts") or []):
email = (c.get("email") or "").strip().lower()