mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-12 03:48:56 +00:00
* fix(websearch_interception): drop stream_options when forcing stream=False and replay it on the final stream * test(websearch_interception): type the conversion helpers and drive the replay test through mock_response * test(websearch_interception): pin the replay test's values as Final and record follow-ups immutably * fix(proxy): strip every agentic-loop control key from client bodies, the web search keys included * fix(interception): move stream_options off forced non-stream calls for code interpreter and headroom, and keep adapters from replaying nested calls * test: annotate new interception test locals with Final * fix(responses): drop params a deployment hook removed on sync Responses calls * test: drive the sync Responses stream_options regression through a mock transport and real Azure config * test: type the agentic loop recorder and capture the sent request on an object * test(integration): cover interception stream_options on chat, responses, messages and the SDK * test(integration): assert the follow-up error text and release a held burst on failure * test(integration): pin the follow-up failure contract and wait for the replacement worker before teardown --------- Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
245 lines
9.1 KiB
Python
245 lines
9.1 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
import uuid
|
|
from collections.abc import Mapping, Sequence
|
|
from dataclasses import dataclass
|
|
from typing import Final, Literal, TypeAlias
|
|
from urllib.parse import urlsplit
|
|
|
|
from integration._support.openai_wire import chat_stream, responses_stream
|
|
from integration._support.wire import Reply, Request
|
|
from pydantic import JsonValue, TypeAdapter
|
|
|
|
AZURE_KEY: Final = "synthetic-azure-key"
|
|
TAVILY_KEY: Final = "synthetic-tavily-key"
|
|
API_VERSION: Final = "2025-04-01-preview"
|
|
SEARCH_TOOL: Final = "litellm_web_search"
|
|
SEARCH_TARGET: Final = "/tavily/search"
|
|
STREAM_OPTIONS_REFUSAL: Final = "The 'stream_options' parameter is only allowed when 'stream' is enabled."
|
|
FOLLOWUP_FAILURE: Final = "scripted follow-up failure"
|
|
FIRST_USAGE: Final = {"prompt_tokens": 11, "completion_tokens": 7, "total_tokens": 18}
|
|
FOLLOWUP_USAGE: Final = {"prompt_tokens": 23, "completion_tokens": 5, "total_tokens": 28}
|
|
RESPONSES_FIRST_USAGE: Final = {"input_tokens": 13, "output_tokens": 6, "total_tokens": 19}
|
|
RESPONSES_FOLLOWUP_USAGE: Final = {"input_tokens": 29, "output_tokens": 4, "total_tokens": 33}
|
|
JSON_OBJECT: Final = TypeAdapter(dict[str, JsonValue])
|
|
_ITEMS: Final = TypeAdapter(list[dict[str, JsonValue]])
|
|
_CHAT_TARGET: Final = re.compile(r"^/openai/deployments/([^/]+)/chat/completions$")
|
|
|
|
Mode: TypeAlias = Literal["search", "plain", "search-down", "followup-fails", "missing"]
|
|
|
|
|
|
def deployment(mode: Mode) -> str:
|
|
return f"dep-{uuid.uuid4().hex}-{mode}"
|
|
|
|
|
|
def answer(name: str) -> str:
|
|
return f"answer-{name}"
|
|
|
|
|
|
def plain(name: str) -> str:
|
|
return f"plain-{name}"
|
|
|
|
|
|
def query(name: str) -> str:
|
|
return f"q-{name}"
|
|
|
|
|
|
def _minted(prefix: str) -> str:
|
|
return f"{prefix}{uuid.uuid4().hex[:12]}"
|
|
|
|
|
|
def _azure_error(status: int, message: str, *, param: str | None = None) -> Reply:
|
|
return Reply(
|
|
status=status,
|
|
body=json.dumps(
|
|
{"error": {"message": message, "type": "invalid_request_error", "param": param, "code": None}}
|
|
).encode(),
|
|
)
|
|
|
|
|
|
def _refusal(body: Mapping[str, JsonValue]) -> Reply | None:
|
|
if body.get("stream_options") is not None and body.get("stream") is not True:
|
|
return _azure_error(400, STREAM_OPTIONS_REFUSAL, param="stream_options")
|
|
unknown: Final = tuple(key for key in body if key.startswith("_"))
|
|
if unknown:
|
|
return _azure_error(400, f"Unrecognized request argument supplied: {unknown[0]}")
|
|
return None
|
|
|
|
|
|
def _offers_search(body: Mapping[str, JsonValue]) -> bool:
|
|
tools: Final = _ITEMS.validate_python(body.get("tools") or [])
|
|
return any(
|
|
tool.get("name") == SEARCH_TOOL
|
|
or JSON_OBJECT.validate_python(tool.get("function") or {}).get("name") == SEARCH_TOOL
|
|
for tool in tools
|
|
)
|
|
|
|
|
|
def _chat_completion(identity: str, message: Mapping[str, JsonValue], finish: str, usage: Mapping[str, int]) -> Reply:
|
|
return Reply(
|
|
body=json.dumps(
|
|
{
|
|
"id": identity,
|
|
"object": "chat.completion",
|
|
"created": 1,
|
|
"model": "gpt-4o",
|
|
"choices": [{"index": 0, "message": dict(message), "finish_reason": finish}],
|
|
"usage": dict(usage),
|
|
}
|
|
).encode()
|
|
)
|
|
|
|
|
|
def _chat(name: str, body: Mapping[str, JsonValue]) -> Reply:
|
|
if body.get("stream") is True:
|
|
return Reply(
|
|
content_type="text/event-stream", chunks=chat_stream(_minted(f"chatcmpl-{name}-s-"), "gpt-4o", plain(name))
|
|
)
|
|
messages: Final = _ITEMS.validate_python(body["messages"])
|
|
if any(message.get("role") == "tool" for message in messages):
|
|
if name.endswith("-followup-fails"):
|
|
return _azure_error(500, FOLLOWUP_FAILURE)
|
|
return _chat_completion(
|
|
_minted(f"chatcmpl-{name}-2-"), {"role": "assistant", "content": answer(name)}, "stop", FOLLOWUP_USAGE
|
|
)
|
|
if name.endswith("-plain") or not _offers_search(body):
|
|
return _chat_completion(
|
|
_minted(f"chatcmpl-{name}-1-"), {"role": "assistant", "content": plain(name)}, "stop", FIRST_USAGE
|
|
)
|
|
call: Final = {
|
|
"id": f"call_{name[4:36]}",
|
|
"type": "function",
|
|
"function": {"name": SEARCH_TOOL, "arguments": json.dumps({"query": query(name)})},
|
|
}
|
|
message: Final = JSON_OBJECT.validate_python({"role": "assistant", "content": None, "tool_calls": [call]})
|
|
return _chat_completion(_minted(f"chatcmpl-{name}-1-"), message, "tool_calls", FIRST_USAGE)
|
|
|
|
|
|
def _response(identity: str, name: str, output: Sequence[Mapping[str, JsonValue]], usage: Mapping[str, int]) -> Reply:
|
|
return Reply(
|
|
body=json.dumps(
|
|
{
|
|
"id": identity,
|
|
"object": "response",
|
|
"created_at": 1,
|
|
"status": "completed",
|
|
"model": name,
|
|
"output": [dict(item) for item in output],
|
|
"parallel_tool_calls": True,
|
|
"tool_choice": "auto",
|
|
"tools": [],
|
|
"usage": dict(usage),
|
|
}
|
|
).encode()
|
|
)
|
|
|
|
|
|
def _text_item(identity: str, text: str) -> Mapping[str, JsonValue]:
|
|
return {
|
|
"id": f"msg_{identity}",
|
|
"type": "message",
|
|
"role": "assistant",
|
|
"status": "completed",
|
|
"content": [{"type": "output_text", "text": text, "annotations": []}],
|
|
}
|
|
|
|
|
|
def _responses(body: Mapping[str, JsonValue]) -> Reply:
|
|
name: Final = str(body["model"])
|
|
if body.get("stream") is True:
|
|
return Reply(
|
|
content_type="text/event-stream", chunks=responses_stream(_minted(f"resp_{name}_s_"), name, plain(name))
|
|
)
|
|
items: Final = _ITEMS.validate_python(body["input"]) if isinstance(body.get("input"), list) else []
|
|
if any(item.get("type") == "function_call_output" for item in items):
|
|
if name.endswith("-followup-fails"):
|
|
return _azure_error(500, FOLLOWUP_FAILURE)
|
|
return _response(
|
|
_minted(f"resp_{name}_2_"), name, (_text_item(f"{name}_2", answer(name)),), RESPONSES_FOLLOWUP_USAGE
|
|
)
|
|
if name.endswith("-plain") or not _offers_search(body):
|
|
return _response(
|
|
_minted(f"resp_{name}_1_"), name, (_text_item(f"{name}_1", plain(name)),), RESPONSES_FIRST_USAGE
|
|
)
|
|
call: Final = {
|
|
"type": "function_call",
|
|
"id": f"fc_{name[4:36]}",
|
|
"call_id": f"call_{name[4:36]}",
|
|
"name": SEARCH_TOOL,
|
|
"arguments": json.dumps({"query": query(name)}),
|
|
"status": "completed",
|
|
}
|
|
return _response(_minted(f"resp_{name}_1_"), name, (call,), RESPONSES_FIRST_USAGE)
|
|
|
|
|
|
def _search(request: Request, body: Mapping[str, JsonValue]) -> Reply:
|
|
if request.headers.get("authorization") != f"Bearer {TAVILY_KEY}":
|
|
return Reply(status=401, body=b'{"detail":{"error":"Unauthorized"}}')
|
|
text: Final = str(body.get("query", ""))
|
|
if text.endswith("-search-down"):
|
|
return Reply(status=500, body=b'{"detail":{"error":"scripted search outage"}}')
|
|
return Reply(
|
|
body=json.dumps(
|
|
{
|
|
"query": text,
|
|
"results": [
|
|
{"title": f"title-{text}", "url": "https://example.test/result", "content": f"snippet-{text}"}
|
|
],
|
|
}
|
|
).encode()
|
|
)
|
|
|
|
|
|
def respond(request: Request) -> Reply:
|
|
target: Final = urlsplit(request.target).path
|
|
if request.method == "GET":
|
|
return Reply(body=b'{"object":"list","data":[]}')
|
|
body: Final = JSON_OBJECT.validate_json(request.body or b"{}")
|
|
if target == SEARCH_TARGET:
|
|
return _search(request, body)
|
|
if request.headers.get("api-key") != AZURE_KEY:
|
|
return Reply(
|
|
status=401, body=b'{"error":{"code":"401","message":"Access denied due to invalid subscription key."}}'
|
|
)
|
|
chat: Final = _CHAT_TARGET.match(target)
|
|
if chat is not None and chat.group(1).endswith("-missing"):
|
|
return Reply(
|
|
status=404,
|
|
body=b'{"error":{"code":"DeploymentNotFound","message":"The API deployment for this resource does not exist."}}',
|
|
)
|
|
refused: Final = _refusal(body)
|
|
if refused is not None:
|
|
return refused
|
|
if chat is not None:
|
|
return _chat(chat.group(1), body)
|
|
if target.endswith("/responses"):
|
|
return _responses(body)
|
|
return Reply(status=404, body=b'{"error":{"code":"404","message":"Resource not found"}}')
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class Received:
|
|
model_calls: tuple[Mapping[str, JsonValue], ...]
|
|
searches: tuple[Mapping[str, JsonValue], ...]
|
|
|
|
|
|
def _named(request: Request, name: str) -> bool:
|
|
return name in request.target or name in request.body.decode(errors="replace")
|
|
|
|
|
|
def received(drained: Sequence[Request], name: str) -> Received:
|
|
requests: Final = tuple(request for request in drained if request.method == "POST" and _named(request, name))
|
|
return Received(
|
|
model_calls=tuple(
|
|
JSON_OBJECT.validate_json(request.body)
|
|
for request in requests
|
|
if urlsplit(request.target).path != SEARCH_TARGET
|
|
),
|
|
searches=tuple(
|
|
JSON_OBJECT.validate_json(request.body)
|
|
for request in requests
|
|
if urlsplit(request.target).path == SEARCH_TARGET
|
|
),
|
|
)
|