mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-05 08:07:05 +00:00
* fix: rust ocr tests finally pass * fix: move realtime dir * fix(realtime): normalize azure realtime api_base to host for Foundry endpoints The azure realtime handler appended the realtime path to api_base verbatim, so a Foundry base carrying a project path (.../api/projects/<name>) produced an invalid realtime URL and the websocket handshake hung. Normalize api_base to scheme and host before building the realtime path so both Azure OpenAI and Foundry bases connect Point the e2e realtime azure deployment at the GA gpt-realtime model and stop passing the os.environ refs the realtime path never unwraps, resolving them from the gateway env by name instead. Drop the local docker-compose scaffolding from the tree * test(e2e): add Gateway.list_files and list_fine_tuning_jobs for the discovery suite The discovery endpoints suite calls client.gateway.list_files and list_fine_tuning_jobs, which did not exist on Gateway, so both tests errored with AttributeError before reaching the proxy. Add the two GET wrappers using the existing FileListResponse / FineTuningJobsResponse models * revert(realtime): drop azure realtime api_base host-normalization The azure realtime handshake failure was a config issue, not a litellm bug: the realtime base was set to the Azure AI Foundry project endpoint (.../api/projects/<p>), but the OpenAI-compatible realtime route lives at the resource root. litellm correctly appends the realtime path to whatever base it is given, so pointing the realtime deployment at the resource root is the fix and no core change is needed * fix(ocr): route azure_ai doc-intelligence to its own endpoint at the source get_llm_provider inherits AZURE_AI_API_BASE into api_base for every azure_ai/* OCR model, but Azure Document Intelligence is a separate resource reached via AZURE_DOCUMENT_INTELLIGENCE_ENDPOINT, so doc-intelligence requests went to the wrong host. Stop inheriting the azure_ai base for doc-intelligence models so api_base stays unset and both the rust bridge and the python get_complete_url fall back to the document-intelligence endpoint. This drops the earlier _rust_bridge_api_base reorder, which only covered the rust path and let the env silently override an explicit api_base * refactor(ocr): consolidate azure doc-intelligence detection; keep explicit api_base Extract is_azure_document_intelligence_model as the single source of truth for the azure_ai doc-intelligence sub-route so the check is no longer duplicated across _prepare_ocr_request and _rust_bridge_api_base, and gate the dynamic_api_base suppression on the caller not supplying an api_base so an explicit endpoint is always honoured. Restore xai to the realtime PROVIDERS as a documented disabled entry instead of dropping it silently, and add a regression test pinning doc-intelligence api_base resolution. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Mubashir Osmani <mubashir@berri.ai> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
247 lines
8.9 KiB
Python
247 lines
8.9 KiB
Python
"""Bob the builder: on a red e2e run, ask Devin to fix the failing tests.
|
|
|
|
Wired as a ``pytest_sessionfinish`` step (see ``conftest.py``). When the run went
|
|
red and remediation is enabled, it hands the failing tests plus their captured
|
|
tracebacks to Devin *through the LiteLLM proxy's own MCP gateway* -- the same
|
|
gateway + master key the suite already uses -- so Devin files a Linear ticket per
|
|
failure and opens fix PRs. Nothing new ships in the runner pod: the proxy already
|
|
registers the ``devin`` MCP server and holds ``DEVIN_API_KEY``, injecting it
|
|
upstream, so this process only needs the proxy key it always has.
|
|
|
|
Opt-in via ``E2E_DEVIN_REMEDIATION=1`` so a normal local ``pytest tests/e2e`` run
|
|
never spawns a Devin session. ``DEVIN_DRY_RUN=1`` prints the prompt it would send
|
|
and makes no call. Everything is best-effort: any error here is logged and
|
|
swallowed so the run's exit status still reflects the tests, not remediation.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import os
|
|
from collections.abc import Mapping, Sequence
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Protocol, cast
|
|
|
|
import pytest
|
|
from pydantic import BaseModel, ConfigDict
|
|
|
|
from e2e_config import MASTER_KEY, PROXY_BASE_URL
|
|
from e2e_http import Success
|
|
from transport import HttpTransport
|
|
|
|
REMEDIATION_ENV = "E2E_DEVIN_REMEDIATION"
|
|
_LIST_PATH = "/mcp-rest/tools/list"
|
|
_CALL_PATH = "/mcp-rest/tools/call"
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class Failure:
|
|
"""One failed test: its pytest node id and the captured failure text."""
|
|
|
|
nodeid: str
|
|
detail: str
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class Config:
|
|
server: str
|
|
create_tool: str
|
|
linear_team: str
|
|
target_repo: str
|
|
target_ref: str
|
|
max_failures: int
|
|
max_detail_chars: int
|
|
tags: tuple[str, ...]
|
|
dry_run: bool
|
|
|
|
|
|
class _NoParams(BaseModel):
|
|
pass
|
|
|
|
|
|
class _McpToolInfo(BaseModel):
|
|
model_config = ConfigDict(extra="allow")
|
|
server_name: str | None = None
|
|
alias: str | None = None
|
|
|
|
|
|
class _McpTool(BaseModel):
|
|
model_config = ConfigDict(extra="allow")
|
|
name: str
|
|
mcp_info: _McpToolInfo | None = None
|
|
|
|
|
|
class _McpToolsList(BaseModel):
|
|
model_config = ConfigDict(extra="allow")
|
|
tools: tuple[_McpTool, ...] = ()
|
|
|
|
|
|
class _DevinSessionArgs(BaseModel):
|
|
prompt: str
|
|
title: str
|
|
tags: list[str]
|
|
|
|
|
|
class _ToolCallBody(BaseModel):
|
|
name: str
|
|
arguments: _DevinSessionArgs
|
|
|
|
|
|
class _ToolCallResult(BaseModel):
|
|
model_config = ConfigDict(extra="allow")
|
|
|
|
|
|
class _Report(Protocol):
|
|
@property
|
|
def nodeid(self) -> str: ...
|
|
|
|
@property
|
|
def longreprtext(self) -> str: ...
|
|
|
|
|
|
class _TerminalReporter(Protocol):
|
|
stats: Mapping[str, Sequence[_Report]]
|
|
|
|
|
|
def _env(name: str, default: str) -> str:
|
|
value = os.environ.get(name, "").strip()
|
|
return value or default
|
|
|
|
|
|
def load_config() -> Config:
|
|
raw_tags = _env("DEVIN_TAGS", "e2e,stage")
|
|
return Config(
|
|
server=_env("DEVIN_MCP_SERVER", "devin"),
|
|
create_tool=_env("DEVIN_SESSION_TOOL", "devin_session_create"),
|
|
linear_team=_env("DEVIN_LINEAR_TEAM", "LIT"),
|
|
target_repo=_env("DEVIN_TARGET_REPO", "BerriAI/litellm"),
|
|
target_ref=_env("DEVIN_TARGET_REF", "litellm_internal_staging"),
|
|
max_failures=int(_env("DEVIN_MAX_FAILURES", "50")),
|
|
max_detail_chars=int(_env("DEVIN_MAX_DETAIL_CHARS", "3000")),
|
|
tags=tuple(t.strip() for t in raw_tags.split(",") if t.strip()),
|
|
dry_run=_env("DEVIN_DRY_RUN", "0") == "1",
|
|
)
|
|
|
|
|
|
def collect_failures(session: pytest.Session, max_detail_chars: int) -> tuple[Failure, ...]:
|
|
"""Pull the failed and errored tests (with their tracebacks) off the run's
|
|
terminal reporter. Returns empty when nothing failed or the reporter is
|
|
absent (e.g. a skipped, proxy-less session)."""
|
|
plugin: object = session.config.pluginmanager.getplugin("terminalreporter")
|
|
if plugin is None:
|
|
return ()
|
|
reporter = cast(_TerminalReporter, plugin)
|
|
reports = (*reporter.stats.get("failed", ()), *reporter.stats.get("error", ()))
|
|
return tuple(
|
|
Failure(nodeid=r.nodeid, detail=r.longreprtext.strip()[-max_detail_chars:]) for r in reports
|
|
)
|
|
|
|
|
|
def dedup_tag(failures: tuple[Failure, ...]) -> str:
|
|
"""Stable short tag identifying this exact set of failing tests, so repeated
|
|
nightly runs on the same failures reference one body of work."""
|
|
joined = "\n".join(sorted(f.nodeid for f in failures))
|
|
return "e2e-fail-" + hashlib.sha256(joined.encode()).hexdigest()[:12]
|
|
|
|
|
|
def _revision() -> str:
|
|
for candidate in (Path(__file__).parent / ".litellm-revision", Path("/app/e2e/.litellm-revision")):
|
|
try:
|
|
return candidate.read_text(encoding="utf-8").strip()
|
|
except OSError:
|
|
continue
|
|
return _env("E2E_REVISION", "unknown")
|
|
|
|
|
|
def build_prompt(cfg: Config, failures: tuple[Failure, ...], tag: str) -> str:
|
|
shown = failures[: cfg.max_failures]
|
|
header = (
|
|
f"The LiteLLM end-to-end suite failed on the "
|
|
f"{_env('E2E_ENVIRONMENT', 'stage')} proxy. Source repo {cfg.target_repo} "
|
|
f"at revision {_revision()} (branch {cfg.target_ref}). {len(failures)} "
|
|
f"test(s) failed"
|
|
+ (f"; the first {len(shown)} are shown" if len(shown) < len(failures) else "")
|
|
+ ".\n\n"
|
|
)
|
|
task = (
|
|
"For each failing test below:\n"
|
|
f"1. Open a Linear ticket under the {cfg.linear_team} team describing the "
|
|
"failure (test id, the assertion/error, likely cause), unless an open "
|
|
"ticket for that same test already exists -- do not create duplicates.\n"
|
|
f"2. Fix it in {cfg.target_repo}, branching off {cfg.target_ref} and "
|
|
"following the repo's CONTRIBUTING and CLAUDE.md conventions (meaningful "
|
|
"regression coverage, conventional commits, run the suite locally), then "
|
|
"open a PR that references the Linear ticket.\n"
|
|
"3. Prefer one focused PR per failing test; if several share a root cause, "
|
|
"group them and say so.\n"
|
|
f"Before starting, search existing sessions/PRs tagged '{tag}' or "
|
|
"referencing these test ids and continue that work instead of restarting.\n\n"
|
|
"Failing tests and their captured output:\n"
|
|
)
|
|
blocks = [f"### {i}. {f.nodeid}\n```\n{f.detail}\n```\n" for i, f in enumerate(shown, start=1)]
|
|
return header + task + "\n".join(blocks)
|
|
|
|
|
|
def _resolve_tool_name(transport: HttpTransport, cfg: Config) -> str | None:
|
|
"""Find Devin's create-session tool on the gateway. The proxy prefixes tools
|
|
with the server alias, so match by suffix and (when present) the owning
|
|
server."""
|
|
result = transport.get(
|
|
_LIST_PATH, headers=transport.master, params=_NoParams(), response_type=_McpToolsList
|
|
)
|
|
if not isinstance(result, Success):
|
|
print(f"bob_the_builder: could not list gateway MCP tools: {result}")
|
|
return None
|
|
for tool in result.data.tools:
|
|
owner = tool.mcp_info.server_name or tool.mcp_info.alias if tool.mcp_info else None
|
|
if (owner is None or owner == cfg.server) and (
|
|
tool.name == cfg.create_tool or tool.name.endswith(cfg.create_tool)
|
|
):
|
|
return tool.name
|
|
print(
|
|
f"bob_the_builder: no '{cfg.create_tool}' tool for server '{cfg.server}' on the gateway; "
|
|
f"saw {[t.name for t in result.data.tools]}"
|
|
)
|
|
return None
|
|
|
|
|
|
def remediate(session: pytest.Session) -> None:
|
|
"""Entry point called from ``pytest_sessionfinish``. No-op unless remediation
|
|
is enabled and the run actually had failures."""
|
|
if os.environ.get(REMEDIATION_ENV) != "1":
|
|
return
|
|
cfg = load_config()
|
|
failures = collect_failures(session, cfg.max_detail_chars)
|
|
if not failures:
|
|
return
|
|
|
|
tag = dedup_tag(failures)
|
|
title = f"Fix {len(failures)} failing LiteLLM e2e test(s) [{tag}]"
|
|
prompt = build_prompt(cfg, failures, tag)
|
|
args = _DevinSessionArgs(prompt=prompt, title=title, tags=[*cfg.tags, tag])
|
|
|
|
if cfg.dry_run:
|
|
print("bob_the_builder: DRY RUN -- would create a Devin session:")
|
|
print(f" server : {cfg.server}\n tool : {cfg.create_tool}\n title : {title}")
|
|
print(f" tags : {args.tags}\n---- prompt ----\n{prompt}")
|
|
return
|
|
|
|
try:
|
|
transport = HttpTransport(base_url=PROXY_BASE_URL, master_key=MASTER_KEY)
|
|
tool_name = _resolve_tool_name(transport, cfg)
|
|
if tool_name is None:
|
|
return
|
|
result = transport.post(
|
|
_CALL_PATH,
|
|
headers=transport.master,
|
|
json=_ToolCallBody(name=tool_name, arguments=args),
|
|
response_type=_ToolCallResult,
|
|
)
|
|
if isinstance(result, Success):
|
|
print(f"bob_the_builder: created Devin session for {len(failures)} failure(s) [{tag}]")
|
|
print(result.data.model_dump_json())
|
|
else:
|
|
print(f"bob_the_builder: Devin session call failed: {result}")
|
|
except Exception as exc: # noqa: BLE001 - remediation must never fail the run
|
|
print(f"bob_the_builder: remediation error (ignored): {exc}")
|