Files
aleksey a2d238d92e
CI / build-gateway (push) Failing after 16s
CI / sync-config (push) Failing after 0s
feat(agent): hierarchical executor with path resolve, runtime probe, quiet UI
Make Zed Agent closer to Cursor: deterministic DevOps path index, live Traefik
port probe before blind edits, stop-after-edit, and quieter Russian progress.
2026-08-13 11:41:27 +03:00

1591 lines
59 KiB
Python

"""
FastAPI AI router gateway — Zed entrypoint with tier/lane orchestration.
"""
from __future__ import annotations
import hashlib
import json
import logging
import os
import time
from typing import Any
import httpx
from fastapi import FastAPI, Header, HTTPException, Request, Response
from fastapi.responses import JSONResponse, StreamingResponse
from metrics import CLASSIFY, CLASSIFY_LLM, DURATION, ESCALATIONS, REQUESTS, metrics_payload
from hierarchical import HierarchicalRunner, should_run_hierarchical, find_pending_plan
from agent_stream import stream_agent_plan_then_act
from agent_hier import (
completion_has_tool_calls,
completion_preview,
completion_to_sse_chunks,
context_fill_for_forward,
executor_fallback_completion,
force_edit_after_read_completion,
inject_plan_context,
merge_agent_meta,
meta_header,
messages_have_tool_activity,
pick_agent_executor_model,
plan_payload_from_meta,
prepare_agent_executor_forward,
request_has_tools,
rewrite_redundant_reread_completion,
sanitize_plan_paths,
should_force_edit_after_read,
should_stop_after_edit,
stop_after_edit_completion,
synthetic_first_tool_completion,
)
from orchestrator import Orchestrator, SessionStore, Tier
from rules_loader import reload_configs
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("ai-router")
LITELLM_URL = os.environ.get("LITELLM_INTERNAL_URL", "http://litellm:4000").rstrip("/")
LITELLM_KEY = os.environ.get("LITELLM_MASTER_KEY", "")
ROUTER_API_KEY = os.environ.get("ROUTER_API_KEY", "")
DEFAULT_MODEL = os.environ.get("DEFAULT_LITELLM_MODEL", "smart-router")
REDIS_URL = os.environ.get("REDIS_URL", "redis://redis:6379/0")
session_store = SessionStore(REDIS_URL or None)
orchestrator = Orchestrator(session_store)
app = FastAPI(title="EventHub AI Router", version="2.0.0")
def _message_text(content: Any) -> str:
if isinstance(content, str):
return content
if isinstance(content, list):
parts: list[str] = []
for block in content:
if isinstance(block, dict) and block.get("type") == "text":
parts.append(str(block.get("text", "")))
return "\n".join(parts)
return ""
def _extract_text(messages: list[dict[str, Any]]) -> str:
parts: list[str] = []
for msg in messages:
parts.append(_message_text(msg.get("content", "")))
return "\n".join(parts)
def _last_user_text(messages: list[dict[str, Any]]) -> str:
"""Latest user turn only (plan approve/amend must not see prior transcript)."""
for msg in reversed(messages or []):
if not isinstance(msg, dict):
continue
if str(msg.get("role") or "") != "user":
continue
text = _message_text(msg.get("content", "")).strip()
if text:
return text
return _extract_text(messages)
def _has_image(messages: list[dict[str, Any]]) -> bool:
for msg in messages:
content = msg.get("content", "")
if not isinstance(content, list):
continue
for block in content:
if isinstance(block, dict) and block.get("type") in ("image_url", "image"):
return True
return False
def _estimate_tokens(text: str) -> int:
return max(1, len(text) // 4)
def _auth_or_403(authorization: str | None) -> None:
if not ROUTER_API_KEY:
return
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Missing Bearer token")
token = authorization.removeprefix("Bearer ").strip()
if token != ROUTER_API_KEY:
raise HTTPException(status_code=403, detail="Invalid API key")
def _orchestrate_header(header: str | None, body: dict[str, Any]) -> str | None:
if header:
return header.strip().lower()
meta = body.get("metadata") or {}
if isinstance(meta, dict) and meta.get("orchestrate") is not None:
return str(meta.get("orchestrate")).strip().lower()
return None
def _completion_from_text(content: str, *, model: str, meta: dict[str, Any]) -> dict[str, Any]:
return {
"id": f"hier-{int(time.time() * 1000)}",
"object": "chat.completion",
"created": int(time.time()),
"model": model,
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": content},
"finish_reason": "stop",
}
],
"x_router_meta": meta,
}
def _meta_header(meta: dict[str, Any] | None) -> str:
"""HTTP-header-safe meta (delegates to agent_hier.meta_header)."""
return meta_header(meta)
def _sse_completion(content: str, *, model: str, meta: dict[str, Any]) -> StreamingResponse:
chunk = {
"id": f"hier-{int(time.time() * 1000)}",
"object": "chat.completion.chunk",
"created": int(time.time()),
"model": model,
"choices": [
{
"index": 0,
"delta": {"role": "assistant", "content": content},
"finish_reason": "stop",
}
],
}
async def gen():
yield f"data: {json.dumps(chunk, ensure_ascii=False)}\n\n".encode("utf-8")
yield b"data: [DONE]\n\n"
return StreamingResponse(
gen(),
media_type="text/event-stream",
headers={"X-Router-Meta": _meta_header(meta)},
)
def _hier_cfg() -> dict[str, Any]:
from rules_loader import load_orchestration
return load_orchestration().get("hierarchical", {}) or {}
def _format_progress_block(lines: list[str]) -> str:
from progress_ui import format_progress_block
return format_progress_block(lines)
def _sse_chunk(
*,
cid: str,
model: str,
delta: dict[str, Any],
finish_reason: str | None = None,
) -> bytes:
payload = {
"id": cid,
"object": "chat.completion.chunk",
"created": int(time.time()),
"model": model,
"choices": [
{
"index": 0,
"delta": delta,
"finish_reason": finish_reason,
}
],
}
return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n".encode("utf-8")
def _sse_hierarchical_progress(
runner: HierarchicalRunner,
*,
user_text: str,
quality_mode: str | None,
session_id: str,
prompt_hash: str,
base_meta: dict[str, Any],
fallback_model: str,
messages: list[dict[str, Any]] | None = None,
) -> StreamingResponse:
"""Stream live stage updates, then final answer (Zed sees progress while waiting)."""
import asyncio
cfg = _hier_cfg()
stream_progress = bool(cfg.get("stream_progress", True))
progress_in_content = bool(cfg.get("progress_in_content", True))
cid = f"hier-{int(time.time() * 1000)}"
model = "hierarchical"
tier = str(base_meta.get("tier", "UNKNOWN"))
lane = str(base_meta.get("lane", "?"))
t0 = time.perf_counter()
async def gen():
queue: asyncio.Queue[tuple[str, Any]] = asyncio.Queue()
async def on_progress(msg: str) -> None:
if stream_progress:
await queue.put(("progress", msg))
async def work() -> None:
try:
hier = await runner.run(
user_text,
quality_mode=quality_mode,
session_id=session_id,
messages=messages,
on_progress=on_progress if stream_progress else None,
)
await queue.put(("done", hier))
except Exception as exc: # noqa: BLE001
await queue.put(("error", exc))
intro = "Планирую задачу"
yield _sse_chunk(
cid=cid,
model=model,
delta={"role": "assistant", "content": "**Agent**\n"}
if progress_in_content
else {"role": "assistant"},
)
if progress_in_content:
yield _sse_chunk(cid=cid, model=model, delta={"content": f"· {intro}\n"})
else:
yield _sse_chunk(
cid=cid,
model=model,
delta={"reasoning_content": f"{intro}\n"},
)
task = asyncio.create_task(work())
hier_meta = dict(base_meta)
final_text = ""
failed: Exception | None = None
while True:
kind, payload = await queue.get()
if kind == "progress":
from progress_ui import humanize_line, stream_step
line = humanize_line(str(payload))
if not line:
continue
if progress_in_content:
yield _sse_chunk(
cid=cid, model=model, delta={"content": stream_step(line)}
)
else:
yield _sse_chunk(
cid=cid,
model=model,
delta={"reasoning_content": stream_step(line)},
)
elif kind == "done":
hier = payload
hier_meta = {**base_meta, **hier.meta}
final_text = hier.content or ""
REQUESTS.labels(
tier=tier, lane=lane, model="hierarchical", status="200"
).inc()
DURATION.labels(tier=tier, model="hierarchical").observe(
time.perf_counter() - t0
)
orchestrator.after_request(
session_id,
prompt_hash=prompt_hash,
success=hier.ok,
escalate=False,
)
break
elif kind == "error":
failed = payload
break
await task
if failed is not None:
err = (str(failed) or type(failed).__name__)[:200]
hier_meta["hierarchical_error"] = err
note = (
f"- hierarchical сорвался ({err}); "
f"отвечаю одной моделью `{fallback_model}`…\n"
)
if progress_in_content:
yield _sse_chunk(cid=cid, model=model, delta={"content": note})
yield _sse_chunk(cid=cid, model=model, delta={"content": "\n---\n\n"})
try:
async with httpx.AsyncClient(timeout=120.0) as client:
resp = await client.post(
f"{LITELLM_URL}/v1/chat/completions",
headers={
"Authorization": f"Bearer {LITELLM_KEY}",
"Content-Type": "application/json",
},
json={
"model": fallback_model,
"messages": [
{"role": "user", "content": user_text[-12000:]}
],
"max_tokens": 2048,
"stream": False,
},
)
if resp.status_code >= 400:
raise RuntimeError(f"fallback HTTP {resp.status_code}")
data = resp.json()
msg = ((data.get("choices") or [{}])[0].get("message") or {})
final_text = str(
msg.get("content")
or msg.get("reasoning_content")
or msg.get("reasoning")
or ""
)
hier_meta["fallback_model"] = fallback_model
REQUESTS.labels(
tier=tier, lane=lane, model=fallback_model, status="200"
).inc()
orchestrator.after_request(
session_id,
prompt_hash=prompt_hash,
success=bool(final_text),
escalate=False,
)
except Exception as fb_exc: # noqa: BLE001
fb_err = (str(fb_exc) or type(fb_exc).__name__)[:200]
yield _sse_chunk(
cid=cid,
model=model,
delta={
"content": f"ошибка hierarchical: {err}; "
f"fallback тоже: {fb_err}\n"
},
)
REQUESTS.labels(
tier=tier, lane=lane, model="hierarchical", status="500"
).inc()
orchestrator.after_request(
session_id,
prompt_hash=prompt_hash,
success=False,
escalate=False,
)
yield _sse_chunk(cid=cid, model=model, delta={}, finish_reason="stop")
yield b"data: [DONE]\n\n"
return
if progress_in_content and failed is None:
yield _sse_chunk(cid=cid, model=model, delta={"content": "\n---\n\n"})
step = 240
for i in range(0, len(final_text), step):
yield _sse_chunk(
cid=cid, model=model, delta={"content": final_text[i : i + step]}
)
done_chunk = {
"id": cid,
"object": "chat.completion.chunk",
"created": int(time.time()),
"model": model if failed is None else fallback_model,
"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
"x_router_meta": hier_meta,
}
yield f"data: {json.dumps(done_chunk, ensure_ascii=False)}\n\n".encode("utf-8")
yield b"data: [DONE]\n\n"
return StreamingResponse(
gen(),
media_type="text/event-stream",
headers={"X-Router-Meta": _meta_header(base_meta)},
)
def _quality_mode(header: str | None, body: dict[str, Any]) -> str | None:
if header:
mode = header.strip().lower()
if mode in ("auto", "economy", "balanced", "max"):
return mode
meta = body.get("metadata") or {}
if isinstance(meta, dict):
mode = str(meta.get("quality_mode", "")).lower()
if mode in ("auto", "economy", "balanced", "max"):
return mode
return None
def _session_id(body: dict[str, Any]) -> str:
meta = body.get("metadata") or {}
if isinstance(meta, dict) and meta.get("session_id"):
return str(meta["session_id"])
if body.get("user"):
return str(body["user"])
return ""
def _router_meta(decision, *, requested: str) -> dict[str, Any]:
return {
"tier": decision.tier.value,
"lane": decision.lane,
"model": decision.model,
"escalation_level": decision.escalation_level,
"quality_mode": decision.quality_mode,
"confidence": round(decision.confidence, 3),
"requested_model": requested,
"delegated_internal": decision.delegated_internal,
"classifier_source": decision.classifier_source,
}
@app.get("/health")
async def health() -> dict[str, str]:
return {"status": "ok"}
@app.get("/metrics")
async def metrics() -> Response:
body, content_type = metrics_payload()
return Response(content=body, media_type=content_type)
@app.post("/admin/reload-config")
async def admin_reload(authorization: str | None = Header(default=None)) -> dict[str, str]:
_auth_or_403(authorization)
reload_configs()
global orchestrator # noqa: PLW0603
orchestrator = Orchestrator(session_store)
return {"status": "reloaded"}
@app.get("/v1/models")
async def list_models(authorization: str | None = Header(default=None)) -> dict[str, Any]:
_auth_or_403(authorization)
async with httpx.AsyncClient(timeout=30.0) as client:
resp = await client.get(
f"{LITELLM_URL}/v1/models",
headers={"Authorization": f"Bearer {LITELLM_KEY}"},
)
if resp.status_code >= 400:
raise HTTPException(status_code=resp.status_code, detail=resp.text)
return resp.json()
@app.post("/classify")
async def classify_debug(
request: Request,
authorization: str | None = Header(default=None),
x_ai_quality: str | None = Header(default=None, alias="X-AI-Quality"),
) -> dict[str, Any]:
_auth_or_403(authorization)
body = await request.json()
messages = body.get("messages") or []
text = _extract_text(messages)
decision = await orchestrator.resolve(
messages,
quality_mode=_quality_mode(x_ai_quality, body),
session_id=_session_id(body),
text=text,
has_image=_has_image(messages),
token_estimate=_estimate_tokens(text),
)
CLASSIFY.labels(tier=decision.tier.value).inc()
if decision.classifier_source == "gigachat":
CLASSIFY_LLM.labels(tier=decision.tier.value, status="ok").inc()
return {
"tier": decision.tier.value,
"lane": decision.lane,
"model": decision.model,
"x_router_meta": _router_meta(decision, requested=body.get("model", DEFAULT_MODEL)),
}
def _sanitize_reasoning_fields(obj: dict[str, Any]) -> bool:
"""Normalize reasoning fields for Zed: one key, promote into content if empty."""
changed = False
for choice in obj.get("choices") or []:
if not isinstance(choice, dict):
continue
for key in ("delta", "message"):
block = choice.get(key)
if not isinstance(block, dict):
continue
reasoning = block.get("reasoning")
reasoning_content = block.get("reasoning_content")
if reasoning is not None:
if reasoning_content is None:
block["reasoning_content"] = reasoning
reasoning_content = reasoning
changed = True
del block["reasoning"]
changed = True
if "reasoning_details" in block:
del block["reasoning_details"]
changed = True
psf = block.get("provider_specific_fields")
if isinstance(psf, dict) and "reasoning" in psf:
del psf["reasoning"]
changed = True
if not psf:
del block["provider_specific_fields"]
# Novita/Qwen3 often puts the visible answer only in reasoning_content
content = block.get("content")
rc = block.get("reasoning_content")
if (not isinstance(content, str) or not content.strip()) and isinstance(
rc, str
) and rc.strip():
block["content"] = rc
changed = True
return changed
def _sanitize_sse_line(line: bytes) -> bytes:
if not line.startswith(b"data:"):
return line
payload = line[5:].strip()
if payload == b"[DONE]":
return line
try:
obj = json.loads(payload)
except json.JSONDecodeError:
return line
if not isinstance(obj, dict) or not _sanitize_reasoning_fields(obj):
return line
return b"data: " + json.dumps(obj, ensure_ascii=False).encode("utf-8")
async def _iter_sanitized_sse(resp: httpx.Response):
buf = b""
try:
async for chunk in resp.aiter_bytes():
buf += chunk
while True:
split_at = buf.find(b"\n")
if split_at < 0:
break
line = buf[:split_at]
buf = buf[split_at + 1 :]
line = line.rstrip(b"\r")
if not line:
yield b"\n"
continue
yield _sanitize_sse_line(line) + b"\n"
if buf:
yield _sanitize_sse_line(buf.rstrip(b"\r")) + b"\n"
except httpx.HTTPError as exc:
# Upstream drop mid-SSE must not crash ASGI → Zed "unexpected EOF"
log.warning("upstream SSE aborted: %s", exc)
yield b"data: [DONE]\n\n"
async def _forward_litellm(
forward: dict[str, Any],
*,
stream: bool,
decision_meta: dict[str, Any],
) -> Any:
headers = {
"Authorization": f"Bearer {LITELLM_KEY}",
"Content-Type": "application/json",
}
start = time.perf_counter()
tier = decision_meta.get("tier", "UNKNOWN")
model = forward.get("model", DEFAULT_MODEL)
lane = decision_meta.get("lane", "?")
if stream:
client = httpx.AsyncClient(timeout=300.0)
try:
req = client.build_request(
"POST",
f"{LITELLM_URL}/v1/chat/completions",
headers=headers,
content=json.dumps(forward),
)
resp = await client.send(req, stream=True)
if resp.status_code >= 400:
body = await resp.aread()
await resp.aclose()
await client.aclose()
status = str(resp.status_code)
REQUESTS.labels(tier=tier, lane=lane, model=model, status=status).inc()
DURATION.labels(tier=tier, model=model).observe(time.perf_counter() - start)
try:
content = json.loads(body)
except json.JSONDecodeError:
content = {"error": body.decode("utf-8", errors="replace")}
return JSONResponse(status_code=resp.status_code, content=content)
async def event_stream():
try:
async for chunk in _iter_sanitized_sse(resp):
yield chunk
finally:
await resp.aclose()
await client.aclose()
REQUESTS.labels(
tier=tier,
lane=lane,
model=model,
status=str(resp.status_code),
).inc()
DURATION.labels(tier=tier, model=model).observe(time.perf_counter() - start)
return StreamingResponse(
event_stream(),
status_code=resp.status_code,
media_type=resp.headers.get("content-type", "text/event-stream"),
headers={"X-Router-Meta": _meta_header(decision_meta)},
)
except Exception:
await client.aclose()
raise
async with httpx.AsyncClient(timeout=300.0) as client:
resp = await client.post(
f"{LITELLM_URL}/v1/chat/completions",
headers=headers,
json=forward,
)
status = str(resp.status_code)
REQUESTS.labels(tier=tier, lane=lane, model=model, status=status).inc()
DURATION.labels(tier=tier, model=model).observe(time.perf_counter() - start)
if resp.status_code >= 400:
return JSONResponse(status_code=resp.status_code, content=resp.json())
data = resp.json()
if isinstance(data, dict):
_sanitize_reasoning_fields(data)
data["x_router_meta"] = decision_meta
return data
async def _forward_agent_executor(
body: dict[str, Any],
messages: list[dict[str, Any]],
*,
plan: dict[str, Any] | None,
base_meta: dict[str, Any],
stream: bool,
phase: str,
) -> Any:
from path_resolve import (
apply_deterministic_path_index,
enrich_plan_from_discovery_tools,
force_find_path_kickstart,
path_resolve_needed,
pick_path_resolve_model,
)
cfg = _hier_cfg()
plan = sanitize_plan_paths(plan) if plan else plan
# Re-apply index every turn (plan from Zed transcript loses path_source)
if cfg.get("path_resolve_enabled", True):
plan, unresolved = apply_deterministic_path_index(plan)
if unresolved and not messages_have_tool_activity(messages):
log.info("path_index miss → queries=%s", unresolved)
if messages_have_tool_activity(messages):
plan = enrich_plan_from_discovery_tools(plan, messages)
# DevOps: live TCP/HTTP facts before blind Traefik edits
if cfg.get("runtime_probe_enabled", True) and isinstance(plan, dict):
try:
from runtime_probe import attach_runtime_probe, runtime_facts_line
plan = attach_runtime_probe(
plan, user_text=_last_user_text(messages), cfg=cfg
)
line = runtime_facts_line(plan)
if line:
log.info("%s", line)
except Exception as exc: # noqa: BLE001
log.warning("runtime_probe skipped: %s", exc)
path_mode = bool(cfg.get("path_resolve_enabled", True)) and path_resolve_needed(
plan
)
if path_mode:
phase = "path_resolve"
executor = pick_path_resolve_model(cfg)
else:
executor = pick_agent_executor_model(cfg)
# After edit_file already ran — stop the Zed loop (was infinite force-edit)
if (
not path_mode
and phase == "tool_loop"
and should_stop_after_edit(messages, cfg)
):
log.info("stop after edit_file (prevent edit loop)")
data = stop_after_edit_completion(model=str(executor), messages=messages)
meta = merge_agent_meta(
base_meta, executor=executor, plan=plan, phase="edit_done"
)
meta["executor_stop_after_edit"] = True
REQUESTS.labels(
tier=meta.get("tier", "UNKNOWN"),
lane=meta.get("lane", "?"),
model=str(executor),
status="200",
).inc()
if not stream:
data["x_router_meta"] = meta
return JSONResponse(content=data)
cid = f"hier-edit-done-{int(time.time() * 1000)}"
async def edit_done_stream():
for chunk in completion_to_sse_chunks(
data, cid=cid, model=str(data.get("model") or executor)
):
yield chunk
return StreamingResponse(
edit_done_stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
"X-Router-Meta": _meta_header(meta),
},
)
# After successful read_file: skip Novita DeepSeek (408→400 loop) → edit_file
if (
not path_mode
and phase == "tool_loop"
and should_force_edit_after_read(messages, cfg, plan=plan)
):
log.info("force edit_file after successful read (skip DeepSeek)")
data = force_edit_after_read_completion(
plan=plan,
tools=body.get("tools") if isinstance(body.get("tools"), list) else [],
model=str(executor),
messages=messages,
)
meta = merge_agent_meta(
base_meta, executor=executor, plan=plan, phase="force_edit"
)
meta["executor_force_edit_after_read"] = True
facts = (plan or {}).get("runtime_facts") if isinstance(plan, dict) else None
if isinstance(facts, dict) and facts.get("suggested_backend_url"):
meta["runtime_targeted_edit"] = facts.get("suggested_backend_url")
REQUESTS.labels(
tier=meta.get("tier", "UNKNOWN"),
lane=meta.get("lane", "?"),
model=str(executor),
status="200",
).inc()
if not stream:
data["x_router_meta"] = meta
return JSONResponse(content=data)
cid = f"hier-force-edit-{int(time.time() * 1000)}"
from progress_ui import short_path, stream_step
from agent_hier import _last_successful_read_path
path = _last_successful_read_path(messages)
edit_label = (
f"Правлю порт Traefik → `{facts.get('suggested_backend_url')}`"
if isinstance(facts, dict) and facts.get("suggested_backend_url")
else (f"Правлю `{short_path(path)}`" if path else "Правлю файл")
)
async def force_edit_stream():
yield _sse_chunk(
cid=cid,
model=str(executor),
delta={
"role": "assistant",
"content": stream_step(edit_label),
},
)
for chunk in completion_to_sse_chunks(
data, cid=cid, model=str(data.get("model") or executor)
):
yield chunk
return StreamingResponse(
force_edit_stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
"X-Router-Meta": _meta_header(meta),
},
)
# Stream-to-Novita often aborts mid-SSE (Zed: unexpected EOF). Prefer non-stream.
use_upstream_stream = bool(cfg.get("executor_use_stream", False))
forward = prepare_agent_executor_forward(
body,
messages,
plan,
cfg,
executor=executor,
stream=use_upstream_stream,
path_resolve=path_mode,
)
def _synth_fallback(reason: str) -> dict[str, Any]:
if path_mode:
return force_find_path_kickstart(
model=str(executor),
tools=forward.get("tools") or [],
queries=list((plan or {}).get("path_resolve_queries") or [])
or ["dynamic_conf.yml"],
)
return executor_fallback_completion(
plan=plan,
tools=forward.get("tools") or [],
model=executor,
messages=messages,
cfg=cfg,
reason=reason,
)
budget = int(
cfg.get("executor_tool_loop_chars")
if phase == "tool_loop"
else cfg.get("executor_input_chars")
or 16000
)
ctx_line, ctx_meta = context_fill_for_forward(
forward, cfg, budget_chars=budget
)
meta = merge_agent_meta(base_meta, executor=executor, plan=plan, phase=phase)
meta.update(
{
"ctx_used_chars": ctx_meta["ctx_used_chars"],
"ctx_budget_chars": ctx_meta["ctx_budget_chars"],
"ctx_pct": ctx_meta["ctx_pct"],
"ctx_est_tokens": ctx_meta["ctx_est_tokens"],
}
)
# prepare_agent_executor_forward may escalate model after edit failures
actual_model = str(forward.get("model") or executor)
if actual_model != str(executor):
meta["executor_escalated"] = True
meta["executor_model_requested"] = str(executor)
meta["executor_model"] = actual_model
meta["selected_model"] = actual_model
meta["planner_model"] = meta.get("planner_model")
meta["role_cost"] = {
"planner": meta.get("planner_model"),
"executor": actual_model,
"escalated": True,
}
executor = actual_model
log.info("hierarchical_agent escalate → %s", actual_model)
else:
meta["role_cost"] = {
"planner": meta.get("planner_model"),
"executor": str(executor),
"escalated": False,
}
log.info(
"hierarchical_agent phase=%s executor=%s tools=%s msgs=%s upstream_stream=%s %s",
phase,
executor,
len(forward.get("tools") or []),
len(forward["messages"]),
use_upstream_stream,
ctx_line,
)
try:
if use_upstream_stream:
return await _forward_litellm(
forward, stream=stream, decision_meta=meta
)
# Gateway fails first via httpx; body timeout slightly under client so LiteLLM
# cancels cleanly — we still convert 408 → synthetic (never expose to Zed).
body_timeout = float(
forward.get("timeout") or cfg.get("executor_timeout_sec") or 55
)
client_timeout = body_timeout + 20.0
# Ensure LiteLLM sees the same budget (otherwise deployment 120 hangs us)
forward = dict(forward)
forward["timeout"] = body_timeout
async with httpx.AsyncClient(
timeout=httpx.Timeout(client_timeout, connect=15.0)
) as client:
resp = await client.post(
f"{LITELLM_URL}/v1/chat/completions",
headers={
"Authorization": f"Bearer {LITELLM_KEY}",
"Content-Type": "application/json",
},
json=forward,
)
if resp.status_code >= 400:
body_l = (resp.text or "").lower()
is_timeout = resp.status_code in (408, 504, 502) or "timeout" in body_l
is_bad_req = resp.status_code == 400 or "invalid request" in body_l
# Mid tool_loop: one real model retry (no synthetic) before stop
if (
is_timeout
and phase == "tool_loop"
and messages_have_tool_activity(messages)
and cfg.get("executor_midloop_model_retry", True)
):
from agent_hier import pick_agent_escalate_model
esc = pick_agent_escalate_model(cfg)
already_esc = str(forward.get("model") or executor) == str(esc)
if already_esc:
log.warning(
"mid-loop skip retry — already on escalate model %s → synth",
esc,
)
else:
log.warning(
"litellm HTTP %s mid tool_loop → one model retry (no synthetic)",
resp.status_code,
)
retry_fwd = dict(forward)
if cfg.get("executor_midloop_escalate", True):
retry_fwd["model"] = esc
meta["executor_midloop_escalate_model"] = esc
log.warning("mid-loop escalate → %s", esc)
tools_r = list(retry_fwd.get("tools") or [])
prefer = []
for t in tools_r:
n = str(
(
(t.get("function") or {})
if isinstance(t, dict)
else {}
).get("name")
or ""
).lower()
if "read" in n or "edit" in n or "write" in n:
prefer.append(t)
if prefer:
retry_fwd["tools"] = prefer[:3]
retry_fwd["tool_choice"] = "required"
# Re-sanitize messages (null content → 400)
from agent_hier import sanitize_outbound_messages
retry_fwd["messages"] = sanitize_outbound_messages(
retry_fwd.get("messages") or []
)
async with httpx.AsyncClient(
timeout=httpx.Timeout(client_timeout, connect=15.0)
) as client2:
resp2 = await client2.post(
f"{LITELLM_URL}/v1/chat/completions",
headers={
"Authorization": f"Bearer {LITELLM_KEY}",
"Content-Type": "application/json",
},
json=retry_fwd,
)
if resp2.status_code < 400:
data2 = resp2.json()
if isinstance(data2, dict):
_sanitize_reasoning_fields(data2)
if completion_has_tool_calls(data2):
log.info("mid-loop model retry OK → tool_calls")
meta["executor_midloop_retry"] = True
resp = resp2
data = rewrite_redundant_reread_completion(
data2,
messages=messages,
plan=plan,
tools=forward.get("tools") or [],
model=str(retry_fwd.get("model") or executor),
cfg=cfg,
)
choice0 = (data.get("choices") or [{}])[0]
msg0 = choice0.get("message") or {}
tcs = (
msg0.get("tool_calls")
if isinstance(msg0, dict)
else None
)
tc_names = []
if isinstance(tcs, list):
for tc in tcs:
if isinstance(tc, dict):
tc_names.append(
str(
(
(tc.get("function") or {}).get(
"name"
)
)
or "?"
)
)
log.info(
"executor result phase=%s finish=%s tools=%s",
phase,
choice0.get("finish_reason"),
tc_names,
)
REQUESTS.labels(
tier=meta.get("tier", "UNKNOWN"),
lane=meta.get("lane", "?"),
model=executor,
status=str(resp.status_code),
).inc()
if not stream:
data["x_router_meta"] = meta
return JSONResponse(content=data)
cid = f"hier-agent-{int(time.time() * 1000)}"
async def event_stream_retry():
if cfg.get("show_context_fill", False):
yield _sse_chunk(
cid=cid,
model=str(executor),
delta={
"role": "assistant",
"content": (
f"- {ctx_line}\n"
"- litellm mid-loop retry OK\n"
),
},
)
for chunk in completion_to_sse_chunks(
data,
cid=cid,
model=str(data.get("model") or executor),
):
yield chunk
return StreamingResponse(
event_stream_retry(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
"X-Router-Meta": _meta_header(meta),
},
)
log.warning(
"mid-loop model retry still failed status=%s",
getattr(resp2, "status_code", "?"),
)
if cfg.get("executor_synthetic_on_timeout", True) and (
is_timeout or is_bad_req
):
log.warning(
"litellm HTTP %s (timeout/400) → executor fallback",
resp.status_code,
)
data = _synth_fallback(f"litellm_{resp.status_code}")
# Prefer force-edit if we already have file body
if (
phase == "tool_loop"
and should_force_edit_after_read(messages, cfg, plan=plan)
and not completion_has_tool_calls(data)
):
data = force_edit_after_read_completion(
plan=plan,
tools=forward.get("tools") or [],
model=str(executor),
messages=messages,
)
meta["executor_force_edit_after_read"] = True
meta["executor_synthetic"] = True
fr0 = ((data.get("choices") or [{}])[0] or {}).get("finish_reason")
meta["executor_synthetic_reason"] = (
f"litellm_{resp.status_code}_abort"
if fr0 == "stop"
else f"litellm_{resp.status_code}"
)
if not stream:
data["x_router_meta"] = meta
return JSONResponse(content=data)
cid = f"hier-agent-synth-{int(time.time() * 1000)}"
async def timeout_synth_stream():
if cfg.get("show_context_fill", False):
label = (
"stop (no mid-loop synthetic)\n"
if fr0 == "stop"
else "kickstart/edit\n"
)
yield _sse_chunk(
cid=cid,
model=str(executor),
delta={
"role": "assistant",
"content": (
f"- {ctx_line}\n"
f"- litellm {resp.status_code}{label}"
),
},
)
for chunk in completion_to_sse_chunks(
data, cid=cid, model=executor
):
yield chunk
return StreamingResponse(
timeout_synth_stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
"X-Router-Meta": _meta_header(meta),
},
)
raise RuntimeError(
f"litellm HTTP {resp.status_code}: {resp.text[:300]}"
)
data = resp.json()
if not isinstance(data, dict):
raise RuntimeError("litellm returned non-object JSON")
if isinstance(data, dict):
_sanitize_reasoning_fields(data)
choice0 = (data.get("choices") or [{}])[0]
msg0 = choice0.get("message") or {}
tcs = msg0.get("tool_calls") if isinstance(msg0, dict) else None
tc_names = []
if isinstance(tcs, list):
for tc in tcs:
if isinstance(tc, dict):
tc_names.append(
str(((tc.get("function") or {}).get("name")) or "?")
)
if completion_has_tool_calls(data):
data = rewrite_redundant_reread_completion(
data,
messages=messages,
plan=plan,
tools=forward.get("tools") or [],
model=executor,
cfg=cfg,
)
choice0 = (data.get("choices") or [{}])[0]
msg0 = choice0.get("message") or {}
tcs = msg0.get("tool_calls") if isinstance(msg0, dict) else None
tc_names = []
if isinstance(tcs, list):
for tc in tcs:
if isinstance(tc, dict):
tc_names.append(
str(((tc.get("function") or {}).get("name")) or "?")
)
log.info(
"executor result phase=%s finish=%s tools=%s",
phase,
choice0.get("finish_reason"),
tc_names,
)
# Model returned prose / empty tools — unblock Zed
if (
cfg.get("executor_synthetic_on_timeout", True)
and not completion_has_tool_calls(data)
):
log.warning(
"executor no tool_calls phase=%s %s",
phase,
completion_preview(data),
)
# Mid-loop: escalate already tried on 408; for empty tools escalate once
if (
phase == "tool_loop"
and messages_have_tool_activity(messages)
and cfg.get("executor_no_tools_escalate", True)
):
from agent_hier import pick_agent_escalate_model
esc = pick_agent_escalate_model(cfg)
retry_fwd = dict(forward)
retry_fwd["model"] = esc
retry_fwd["tool_choice"] = "required"
try:
async with httpx.AsyncClient(
timeout=httpx.Timeout(client_timeout, connect=15.0)
) as client3:
resp3 = await client3.post(
f"{LITELLM_URL}/v1/chat/completions",
headers={
"Authorization": f"Bearer {LITELLM_KEY}",
"Content-Type": "application/json",
},
json=retry_fwd,
)
if resp3.status_code < 400:
data3 = resp3.json()
if isinstance(data3, dict) and completion_has_tool_calls(data3):
data = data3
_sanitize_reasoning_fields(data)
meta["executor_no_tools_escalated"] = True
except Exception as exc: # noqa: BLE001
log.warning("no_tools escalate failed: %s", exc)
if not completion_has_tool_calls(data):
data = _synth_fallback(
f"no_tool_calls:{choice0.get('finish_reason')}"
)
meta["executor_synthetic"] = True
fr_nt = ((data.get("choices") or [{}])[0] or {}).get("finish_reason")
meta["executor_synthetic_reason"] = (
"no_tool_calls_abort" if fr_nt == "stop" else "no_tool_calls"
)
if fr_nt == "stop":
log.warning("tool_loop fallback stop (kickstart_only / limit)")
else:
log.warning(
"no tool_calls (finish=%s) → kickstart/stop",
choice0.get("finish_reason"),
)
REQUESTS.labels(
tier=meta.get("tier", "UNKNOWN"),
lane=meta.get("lane", "?"),
model=executor,
status=str(resp.status_code),
).inc()
if not stream:
data["x_router_meta"] = meta
return JSONResponse(content=data)
cid = f"hier-agent-{int(time.time() * 1000)}"
async def event_stream():
if cfg.get("show_context_fill", False):
yield _sse_chunk(
cid=cid,
model=str(executor),
delta={"role": "assistant", "content": f"- {ctx_line}\n"},
)
for chunk in completion_to_sse_chunks(
data, cid=cid, model=str(data.get("model") or executor)
):
yield chunk
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
"X-Router-Meta": _meta_header(meta),
},
)
except Exception as exc: # noqa: BLE001
err = (str(exc) or type(exc).__name__)[:240]
log.exception("agent executor failed phase=%s: %s", phase, err)
meta["executor_error"] = err
# Always synthetic on timeout/errors when enabled — never raw 408 to Zed
if cfg.get("executor_synthetic_on_timeout", True):
data = _synth_fallback(f"exception:{err[:80]}")
cid = f"hier-agent-synth-{int(time.time() * 1000)}"
meta["executor_synthetic"] = True
fr_ex = ((data.get("choices") or [{}])[0] or {}).get("finish_reason")
meta["executor_synthetic_reason"] = (
"exception_abort" if fr_ex == "stop" else "exception"
)
if not stream:
data["x_router_meta"] = meta
return JSONResponse(content=data)
async def synth_stream():
if cfg.get("show_context_fill", False):
label = "stop" if fr_ex == "stop" else "kickstart"
yield _sse_chunk(
cid=cid,
model=str(executor),
delta={
"role": "assistant",
"content": (
f"- executor error → {label} ({err[:80]})\n"
),
},
)
for chunk in completion_to_sse_chunks(
data, cid=cid, model=executor
):
yield chunk
return StreamingResponse(
synth_stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
"X-Router-Meta": _meta_header(meta),
},
)
msg = (
f"Executor `{executor}` не ответил ({err}). "
"Роутер на IFT жив — это таймаут upstream, не «серверы недоступны»."
)
if stream:
return _sse_completion(msg, model="hierarchical", meta=meta)
return _completion_from_text(msg, model="hierarchical", meta=meta)
async def _agent_hierarchical_chat(
*,
body: dict[str, Any],
messages: list[dict[str, Any]],
last_user: str,
decision: Any,
meta: dict[str, Any],
session_id: str,
prompt_hash: str,
) -> Any:
"""Plan/confirm on gateway; executor model returns tool_calls for Zed.
Critical: when stream=True we MUST return StreamingResponse immediately and
emit progress — never await the full plan before the first SSE byte.
"""
stream = bool(body.get("stream", False))
cfg = _hier_cfg()
runner = HierarchicalRunner(litellm_url=LITELLM_URL, litellm_key=LITELLM_KEY)
# Zed tool loop mid-flight: never text-synthesize
if messages_have_tool_activity(messages):
plan = find_pending_plan(messages)
return await _forward_agent_executor(
body,
messages,
plan=plan,
base_meta=meta,
stream=stream,
phase="tool_loop",
)
if stream:
return await stream_agent_plan_then_act(
runner=runner,
last_user=last_user,
quality_mode=decision.quality_mode,
session_id=session_id,
messages=messages,
body=body,
meta=meta,
decision=decision,
prompt_hash=prompt_hash,
litellm_url=LITELLM_URL,
litellm_key=LITELLM_KEY,
cfg=cfg,
find_pending_plan=find_pending_plan,
plan_payload_from_meta=plan_payload_from_meta,
inject_plan_context=inject_plan_context,
pick_agent_executor_model=pick_agent_executor_model,
prepare_agent_executor_forward=prepare_agent_executor_forward,
completion_to_sse_chunks=completion_to_sse_chunks,
synthetic_first_tool_completion=synthetic_first_tool_completion,
executor_fallback_completion=executor_fallback_completion,
meta_header=meta_header,
orchestrator=orchestrator,
requests_metric=REQUESTS,
)
# Non-stream: still catch errors — never raw 500 hang for Zed
try:
hier = await runner.run(
last_user,
quality_mode=decision.quality_mode,
session_id=session_id,
messages=messages,
agent_mode=True,
)
except Exception as exc: # noqa: BLE001
log.exception("agent hierarchical plan failed: %s", exc)
meta["hierarchical_error"] = (str(exc) or type(exc).__name__)[:200]
return await _forward_agent_executor(
body,
messages,
plan=None,
base_meta=meta,
stream=False,
phase="plan_error_fallback",
)
hier_meta = {**meta, **hier.meta}
if hier.meta.get("agent_execute"):
plan = plan_payload_from_meta(hier.meta) or find_pending_plan(messages)
orchestrator.after_request(
session_id, prompt_hash=prompt_hash, success=True, escalate=False
)
return await _forward_agent_executor(
body,
messages,
plan=plan,
base_meta=hier_meta,
stream=False,
phase="execute",
)
content = hier.content or ""
if bool(cfg.get("progress_in_content", True)):
trail = hier.meta.get("progress") or []
if isinstance(trail, list) and trail:
content = _format_progress_block([str(x) for x in trail]) + content
REQUESTS.labels(
tier=decision.tier.value,
lane=decision.lane,
model="hierarchical_agent",
status="200",
).inc()
orchestrator.after_request(
session_id, prompt_hash=prompt_hash, success=True, escalate=False
)
return _completion_from_text(content, model="hierarchical", meta=hier_meta)
@app.post("/v1/chat/completions")
async def chat_completions(
request: Request,
authorization: str | None = Header(default=None),
x_ai_quality: str | None = Header(default=None, alias="X-AI-Quality"),
x_ai_orchestrate: str | None = Header(default=None, alias="X-AI-Orchestrate"),
) -> Any:
_auth_or_403(authorization)
body: dict[str, Any] = await request.json()
messages = body.get("messages") or []
if not isinstance(messages, list):
raise HTTPException(status_code=400, detail="messages must be a list")
text_all = _extract_text(messages)
last_user = _last_user_text(messages)
has_image = _has_image(messages)
session_id = _session_id(body)
prompt_hash = hashlib.sha256(text_all.encode()).hexdigest()[:16]
quality = _quality_mode(x_ai_quality, body)
orch_hdr = _orchestrate_header(x_ai_orchestrate, body)
requested_model = body.get("model", DEFAULT_MODEL)
if requested_model in ("smart-router", "auto", ""):
decision = await orchestrator.resolve(
messages,
quality_mode=quality,
session_id=session_id,
text=text_all,
has_image=has_image,
token_estimate=_estimate_tokens(text_all),
)
if decision.classifier_source == "gigachat":
CLASSIFY_LLM.labels(tier=decision.tier.value, status="ok").inc()
CLASSIFY.labels(tier=decision.tier.value).inc()
target_model = decision.model
meta = _router_meta(decision, requested=requested_model)
if (
not has_image
and should_run_hierarchical(
tier_value=decision.tier.value,
header=orch_hdr,
quality_mode=decision.quality_mode,
)
):
# Zed Agent Write: keep tool loop; hierarchical only plans/routes
if request_has_tools(body):
return await _agent_hierarchical_chat(
body=body,
messages=messages,
last_user=last_user,
decision=decision,
meta=meta,
session_id=session_id,
prompt_hash=prompt_hash,
)
runner = HierarchicalRunner(
litellm_url=LITELLM_URL,
litellm_key=LITELLM_KEY,
)
stream = bool(body.get("stream", False))
if stream:
return _sse_hierarchical_progress(
runner,
user_text=last_user,
quality_mode=decision.quality_mode,
session_id=session_id,
prompt_hash=prompt_hash,
base_meta=meta,
fallback_model=target_model,
messages=messages,
)
start = time.perf_counter()
try:
hier = await runner.run(
last_user,
quality_mode=decision.quality_mode,
session_id=session_id,
messages=messages,
)
except Exception as exc: # noqa: BLE001
log.exception("hierarchical failed, falling back to single route: %s", exc)
meta["hierarchical_error"] = (str(exc) or type(exc).__name__)[:200]
else:
hier_meta = {**meta, **hier.meta}
REQUESTS.labels(
tier=decision.tier.value,
lane=decision.lane,
model="hierarchical",
status="200",
).inc()
DURATION.labels(tier=decision.tier.value, model="hierarchical").observe(
time.perf_counter() - start
)
orchestrator.after_request(
session_id,
prompt_hash=prompt_hash,
success=hier.ok,
escalate=False,
)
content = hier.content or ""
if bool(_hier_cfg().get("progress_in_content", True)):
trail = hier.meta.get("progress") or []
if isinstance(trail, list) and trail:
content = _format_progress_block([str(x) for x in trail]) + content
return _completion_from_text(
content, model="hierarchical", meta=hier_meta
)
else:
target_model = requested_model
meta = {"selected_model": target_model, "requested_model": requested_model}
forward = dict(body)
forward["model"] = target_model
forward.setdefault("metadata", {})
if isinstance(forward["metadata"], dict):
forward["metadata"]["semantic_tier"] = meta.get("tier")
forward["metadata"]["session_id"] = session_id or forward["metadata"].get("session_id")
log.info(
"route tier=%s lane=%s model=%s requested=%s",
meta.get("tier"),
meta.get("lane"),
target_model,
requested_model,
)
stream = bool(body.get("stream", False))
result = await _forward_litellm(forward, stream=stream, decision_meta=meta)
escalate = isinstance(result, JSONResponse) and result.status_code >= 429
orchestrator.after_request(
session_id,
prompt_hash=prompt_hash,
success=not escalate,
escalate=escalate,
)
if escalate and meta.get("lane") != "C":
ESCALATIONS.labels(from_lane=meta.get("lane", "?"), to_lane="next", reason="http_error").inc()
return result