feat: add capability and admin introspection endpoints

Expose capability discovery plus admin-only config and request inspection endpoints so clients and operators can understand gateway behavior without reading code.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
mmc
2026-05-12 14:30:08 +08:00
parent 94a8025ae5
commit b719bdeaa2
5 changed files with 780 additions and 93 deletions

View File

@@ -1,14 +1,37 @@
from __future__ import annotations
import asyncio
import sys
import types
import unittest
from unittest.mock import patch
from fastapi import HTTPException
from fastapi.testclient import TestClient
from starlette.requests import Request
from app.auth import AnthropicAuthError, require_anthropic_key, require_bearer, require_metrics_access
from app.concurrency import BackpressureRejected, InFlightGuard
_playwright = types.ModuleType("playwright")
_playwright_async = types.ModuleType("playwright.async_api")
class _StubPlaywrightTimeoutError(Exception):
pass
async def _stub_async_playwright():
raise RuntimeError("playwright is stubbed in unit tests")
_playwright_async.TimeoutError = _StubPlaywrightTimeoutError
_playwright_async.async_playwright = _stub_async_playwright
sys.modules.setdefault("playwright", _playwright)
sys.modules.setdefault("playwright.async_api", _playwright_async)
import app.main as main
def _req(headers: dict[str, str] | None = None) -> Request:
pairs = []
@@ -82,5 +105,48 @@ class AuthAndConcurrencyTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(guard.in_flight, 0)
class DebugRequestRecordingTests(unittest.TestCase):
def setUp(self) -> None:
main._DEBUG_REQUEST_LOG.clear()
def test_redacts_sensitive_fields_and_data_urls(self) -> None:
body = {
"authorization": "Bearer abc",
"x-api-key": "secret",
"session_bundle": "very-secret",
"images": ["data:image/png;base64,ABC"],
"tool": {"args": "x" * 3000},
}
redacted = main._redact_debug_value((), body)
self.assertEqual(redacted["authorization"], "***")
self.assertEqual(redacted["x-api-key"], "***")
self.assertEqual(redacted["session_bundle"], "***")
self.assertEqual(redacted["images"][0], "[redacted-data-url]")
self.assertIn("[truncated]", redacted["tool"]["args"])
def test_internal_debug_requests_requires_admin_and_returns_items(self) -> None:
with patch.object(main.settings, "api_keys", ["k1"]), patch.object(main.settings, "admin_token", "admin-1"):
client = TestClient(main.app)
req_payload = {
"model": "org_auto",
"messages": [{"role": "user", "content": "hello"}],
}
main._record_debug_request("openai", "/v1/chat/completions", req_payload, _req({"x-request-id": "req-1"}))
denied = client.get("/internal/debug/requests")
self.assertEqual(denied.status_code, 401)
ok = client.get(
"/internal/debug/requests?limit=1",
headers={"Authorization": "Bearer admin-1"},
)
self.assertEqual(ok.status_code, 200)
data = ok.json()
self.assertTrue(data["ok"])
self.assertEqual(data["count"], 1)
self.assertEqual(data["items"][0]["protocol"], "openai")
if __name__ == "__main__":
unittest.main()

View File

@@ -5,6 +5,7 @@ import sys
import types
import unittest
import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
@@ -1251,7 +1252,7 @@ class ToolCallBridgeTests(unittest.IsolatedAsyncioTestCase):
self.assertIn('"type": "tool_result"', body)
self.assertIn('"stop_reason": "end_turn"', body)
async def test_openai_non_stream_forwards_tool_config_when_enabled(self) -> None:
async def test_openai_non_stream_uses_emulation_instead_of_forwarding_tool_config(self) -> None:
spy_client = _SpyClient(
stream_events=[], complete_result={"text": "ok", "toolEvents": []}
)
@@ -1279,13 +1280,10 @@ class ToolCallBridgeTests(unittest.IsolatedAsyncioTestCase):
await main.v1_chat_completions(req, _make_request("/v1/chat/completions"))
self.assertIn("tool_config", spy_client.last_complete_kwargs)
cfg = spy_client.last_complete_kwargs["tool_config"]
self.assertEqual(cfg["provider"], "openai")
self.assertEqual(len(cfg["tools"]), 1)
self.assertIsInstance(cfg["tool_choice"], dict)
self.assertIsNone(spy_client.last_complete_kwargs["tool_config"])
self.assertEqual(spy_client.last_complete_args[2], "agent")
async def test_openai_stream_forwards_tool_config_when_enabled(self) -> None:
async def test_openai_stream_uses_emulation_instead_of_forwarding_tool_config(self) -> None:
spy_client = _SpyClient(
stream_events=[{"type": "text", "text": "ok"}], complete_result={}
)
@@ -1316,10 +1314,7 @@ class ToolCallBridgeTests(unittest.IsolatedAsyncioTestCase):
await _collect_stream(response)
self.assertIn("tool_config", spy_client.last_stream_kwargs)
cfg = spy_client.last_stream_kwargs["tool_config"]
self.assertEqual(cfg["provider"], "openai")
self.assertEqual(len(cfg["tools"]), 1)
self.assertIsInstance(cfg["tool_choice"], dict)
self.assertIsNone(spy_client.last_stream_kwargs["tool_config"])
self.assertEqual(spy_client.last_stream_args[2], "agent")
async def test_openai_non_stream_does_not_forward_tool_config_when_disabled(
@@ -1355,7 +1350,7 @@ class ToolCallBridgeTests(unittest.IsolatedAsyncioTestCase):
self.assertIsNone(spy_client.last_complete_kwargs["tool_config"])
self.assertEqual(spy_client.last_complete_args[2], "agent")
async def test_openai_non_stream_filters_tools_by_allowlist(self) -> None:
async def test_openai_non_stream_filters_tools_by_allowlist_before_emulation(self) -> None:
spy_client = _SpyClient(
stream_events=[], complete_result={"text": "ok", "toolEvents": []}
)
@@ -1386,11 +1381,9 @@ class ToolCallBridgeTests(unittest.IsolatedAsyncioTestCase):
):
await main.v1_chat_completions(req, _make_request("/v1/chat/completions"))
cfg = spy_client.last_complete_kwargs["tool_config"]
self.assertEqual(
[tool["function"]["name"] for tool in cfg["tools"]], ["lookup"]
)
self.assertEqual(cfg["tool_choice"], req.tool_choice)
prompt = spy_client.last_complete_args[0]
self.assertIn("lookup(", prompt)
self.assertNotIn("write_file(", prompt)
async def test_openai_non_stream_rejects_forced_tool_outside_allowlist(
self,
@@ -1579,7 +1572,7 @@ class ToolCallBridgeTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(openai_spy.last_complete_args[2], "chat")
self.assertEqual(anthropic_spy.last_complete_args[2], "chat")
async def test_anthropic_stream_forwards_tool_config_when_enabled(self) -> None:
async def test_anthropic_stream_uses_emulation_instead_of_forwarding_tool_config(self) -> None:
spy_client = _SpyClient(
stream_events=[{"type": "text", "text": "ok"}], complete_result={}
)
@@ -1619,9 +1612,7 @@ class ToolCallBridgeTests(unittest.IsolatedAsyncioTestCase):
await _collect_stream(response)
self.assertIn("tool_config", spy_client.last_stream_kwargs)
cfg = spy_client.last_stream_kwargs["tool_config"]
self.assertEqual(cfg["provider"], "anthropic")
self.assertEqual(len(cfg["tools"]), 1)
self.assertIsNone(spy_client.last_stream_kwargs["tool_config"])
self.assertEqual(spy_client.last_stream_args[2], "agent")
async def test_anthropic_non_stream_does_not_forward_tool_config_when_disabled(
@@ -1710,12 +1701,10 @@ class ToolCallBridgeTests(unittest.IsolatedAsyncioTestCase):
)
self.assertIn("tool_config", spy_client.last_complete_kwargs)
cfg = spy_client.last_complete_kwargs["tool_config"]
self.assertEqual(cfg["provider"], "anthropic")
self.assertEqual(len(cfg["tools"]), 1)
self.assertIsNone(spy_client.last_complete_kwargs["tool_config"])
self.assertEqual(spy_client.last_complete_args[2], "agent")
async def test_anthropic_non_stream_filters_tools_by_allowlist(self) -> None:
async def test_anthropic_non_stream_filters_tools_by_allowlist_before_emulation(self) -> None:
spy_client = _SpyClient(
stream_events=[], complete_result={"text": "ok", "toolEvents": []}
)
@@ -1760,9 +1749,9 @@ class ToolCallBridgeTests(unittest.IsolatedAsyncioTestCase):
),
)
cfg = spy_client.last_complete_kwargs["tool_config"]
self.assertEqual([tool["name"] for tool in cfg["tools"]], ["lookup"])
self.assertEqual(cfg["tool_choice"], req.tool_choice)
prompt = spy_client.last_complete_args[0]
self.assertIn("lookup(", prompt)
self.assertNotIn("write_file(", prompt)
async def test_anthropic_non_stream_rejects_forced_tool_outside_allowlist(
self,
@@ -2183,6 +2172,201 @@ class ToolCallBridgeTests(unittest.IsolatedAsyncioTestCase):
self.assertIn('{"temperature":"22C"}', prompt)
self.assertIn("Assistant:", prompt)
async def test_openai_assistant_tool_calls_are_projected_into_emulation_prompt(self) -> None:
spy_client = _SpyClient(
stream_events=[],
complete_result={
"text": "done",
"toolEvents": [],
"sessionId": "sess-emulated-tool-history",
},
)
req = ChatCompletionsRequest(
model="org_auto",
messages=[
{
"role": "assistant",
"content": "I will check that",
"tool_calls": [
{
"id": "call_1",
"type": "function",
"function": {
"name": "fetch_weather",
"arguments": '{"city":"Hangzhou"}',
},
}
],
},
{"role": "user", "content": "continue"},
],
stream=False,
tools=[
{
"type": "function",
"function": {
"name": "fetch_weather",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"],
},
},
}
],
)
with (
patch.object(main, "pool", _FakePool(_FakeInstance(spy_client))),
patch.object(main, "chat_guard", _FakeGuard()),
patch.object(
main, "_ensure_instance_logged_in", AsyncMock(return_value={"id": "u"})
),
patch.object(
main.stats_collector, "record_chat", AsyncMock(return_value=None)
),
):
await main.v1_chat_completions(req, _make_request("/v1/chat/completions"))
prompt = spy_client.last_complete_args[0]
self.assertIn("I will check that", prompt)
self.assertIn('"tool": "fetch_weather"', prompt)
self.assertIn('"city": "Hangzhou"', prompt)
async def test_openai_emulation_prompt_includes_proxy_tool_guidance(self) -> None:
spy_client = _SpyClient(
stream_events=[],
complete_result={"text": "done", "toolEvents": [], "sessionId": "sess-guidance"},
)
req = ChatCompletionsRequest(
model="org_auto",
messages=[{"role": "user", "content": "inspect README"}],
stream=False,
tools=[
{
"type": "function",
"function": {
"name": "read_file",
"description": "Read a file",
"parameters": {
"type": "object",
"properties": {"path": {"type": "string"}},
"required": ["path"],
},
},
},
{
"type": "function",
"function": {
"name": "bash",
"parameters": {
"type": "object",
"properties": {"command": {"type": "string"}},
},
},
},
],
)
with (
patch.object(main, "pool", _FakePool(_FakeInstance(spy_client))),
patch.object(main, "chat_guard", _FakeGuard()),
patch.object(
main, "_ensure_instance_logged_in", AsyncMock(return_value={"id": "u"})
),
patch.object(
main.stats_collector, "record_chat", AsyncMock(return_value=None)
),
):
await main.v1_chat_completions(req, _make_request("/v1/chat/completions"))
prompt = spy_client.last_complete_args[0]
self.assertIn("DIRECT tool access inside an IDE", prompt)
self.assertIn("Tool routing guide:", prompt)
self.assertIn("Read a specific local file or code path: use read_file.", prompt)
self.assertIn("Core tool syntax examples", prompt)
self.assertIn("Coding and file-work discipline:", prompt)
self.assertIn("NEVER say that tools are unavailable", prompt)
async def test_anthropic_tool_history_is_projected_into_emulation_prompt(self) -> None:
spy_client = _SpyClient(
stream_events=[],
complete_result={
"text": "done",
"toolEvents": [],
"sessionId": "sess-anthropic-history",
},
)
req = AnthropicMessagesRequest(
model="claude-3-5-sonnet-20241022",
max_tokens=128,
messages=[
{
"role": "assistant",
"content": [
{"type": "text", "text": "I will check"},
{
"type": "tool_use",
"id": "toolu_1",
"name": "fetch_weather",
"input": {"city": "Hangzhou"},
},
],
},
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_1",
"content": '{"temperature":"22C"}',
}
],
},
{"role": "user", "content": "continue"},
],
stream=False,
tools=[
{
"name": "fetch_weather",
"input_schema": {
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"],
},
}
],
)
with (
patch.object(main, "pool", _FakePool(_FakeInstance(spy_client))),
patch.object(main, "chat_guard", _FakeGuard()),
patch.object(
main, "_ensure_instance_logged_in", AsyncMock(return_value={"id": "u"})
),
patch.object(
main.stats_collector, "record_chat", AsyncMock(return_value=None)
),
patch.object(main.settings, "api_keys", ["test-key"]),
):
await main.v1_messages(
req,
_make_request(
"/v1/messages",
headers={
"x-api-key": "test-key",
"anthropic-version": "2023-06-01",
},
),
)
prompt = spy_client.last_complete_args[0]
self.assertIn("I will check", prompt)
self.assertIn('"tool": "fetch_weather"', prompt)
self.assertIn('"city": "Hangzhou"', prompt)
self.assertIn("Tool result:", prompt)
self.assertIn('{"temperature":"22C"}', prompt)
async def test_anthropic_non_stream_synthesizes_tool_use_from_json_action_block(
self,
) -> None:
@@ -2434,6 +2618,177 @@ class ToolCallBridgeTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(detail["error"]["message"], "invalid upstream response")
class CapabilitiesEndpointTests(unittest.IsolatedAsyncioTestCase):
async def test_capabilities_payload_shape(self) -> None:
with (
patch.object(main.settings, "tool_forward_enabled", True),
patch.object(main.settings, "tool_allowlist", ["lookup"]),
patch.object(main.settings, "session_reuse_enabled", True),
patch.object(main.settings, "session_cache_max_entries", 123),
patch.object(main.settings, "session_cache_ttl_sec", 45.0),
patch.object(main.settings, "instance_count", 2),
patch.object(main.settings, "default_model", "org_auto"),
patch.object(main.settings, "default_ask_mode", "chat"),
patch.object(main.settings, "api_keys", ["test-key"]),
patch.object(main.settings, "admin_token", "adm"),
patch.object(main.settings, "metrics_public", False),
):
response = await main.capabilities()
self.assertEqual(response.status_code, 200)
payload = json.loads(response.body)
self.assertEqual(payload["service"], "lingma-openai-gateway")
self.assertIn("protocols", payload)
self.assertIn("features", payload)
self.assertTrue(payload["protocols"]["openai"]["chat_completions"])
self.assertTrue(payload["protocols"]["anthropic"]["messages"])
self.assertTrue(payload["protocols"]["openai"]["request_tools_forwarded"])
self.assertEqual(payload["features"]["tooling"]["allowlist"], ["lookup"])
self.assertEqual(payload["features"]["pool"]["configured_instance_count"], 2)
self.assertTrue(payload["features"]["auth"]["v1_requires_auth"])
async def test_v1_capabilities_auth_guard_requires_authentication(self) -> None:
with patch.object(main.settings, "api_keys", ["test-key"]):
with self.assertRaises(main.AnthropicAuthError) as ctx:
main.anthropic_auth_guard(
_make_request(
"/v1/capabilities",
headers={"anthropic-version": "2023-06-01"},
)
)
self.assertEqual(ctx.exception.status_code, 401)
async def test_v1_capabilities_returns_payload_with_auth(self) -> None:
with (
patch.object(main.settings, "api_keys", ["test-key"]),
patch.object(main.settings, "tool_forward_enabled", False),
):
main.anthropic_auth_guard(
_make_request(
"/v1/capabilities",
headers={"x-api-key": "test-key", "anthropic-version": "2023-06-01"},
)
)
response = await main.v1_capabilities()
self.assertEqual(response.status_code, 200)
payload = json.loads(response.body)
self.assertFalse(payload["protocols"]["openai"]["request_tools_forwarded"])
class AdminIntrospectionEndpointTests(unittest.IsolatedAsyncioTestCase):
async def test_internal_effective_config_requires_admin_token(self) -> None:
with (
patch.object(main.settings, "api_keys", ["api-key"]),
patch.object(main.settings, "admin_token", "admin-secret"),
):
with self.assertRaises(main.HTTPException) as ctx:
main.admin_auth_guard(
_make_request(
"/internal/effective-config",
headers={"authorization": "Bearer wrong-token"},
)
)
self.assertEqual(ctx.exception.status_code, 401)
async def test_internal_effective_config_redacts_secrets(self) -> None:
with (
patch.object(main.settings, "api_keys", ["api-key-1", "api-key-2"]),
patch.object(main.settings, "admin_token", "admin-secret"),
patch.object(main.settings, "metrics_token", "metrics-secret"),
patch.object(main.settings, "default_model", "org_auto"),
patch.object(main.settings, "tool_forward_enabled", True),
patch.object(main.settings, "session_reuse_enabled", True),
patch.object(main.settings, "metrics_public", False),
patch.object(main.settings, "auto_login_enabled", True),
patch.object(
main.settings,
"accounts",
[
SimpleNamespace(
username="user-a",
password="pass-a",
session_bundle_b64="bundle-a",
session_bundle_file="/secrets/bundle-a.txt",
)
],
),
):
main.admin_auth_guard(
_make_request(
"/internal/effective-config",
headers={"authorization": "Bearer admin-secret"},
)
)
response = await main.internal_effective_config()
self.assertEqual(response.status_code, 200)
payload = json.loads(response.body)
settings_payload = payload["settings"]
self.assertEqual(settings_payload["api_keys"], ["***", "***"])
self.assertEqual(settings_payload["admin_token"], "***")
self.assertEqual(settings_payload["metrics_token"], "***")
self.assertEqual(settings_payload["accounts"][0]["password"], "***")
self.assertEqual(settings_payload["accounts"][0]["session_bundle_b64"], "***")
self.assertEqual(settings_payload["accounts"][0]["username"], "user-a")
self.assertEqual(
settings_payload["accounts"][0]["session_bundle_file"],
"/secrets/bundle-a.txt",
)
self.assertTrue(payload["feature_flags"]["tool_forward_enabled"])
self.assertTrue(payload["feature_flags"]["session_reuse_enabled"])
async def test_internal_debug_requests_redacts_sensitive_fields(self) -> None:
main._DEBUG_REQUEST_LOG.clear()
main._record_debug_request(
"openai",
"/v1/chat/completions",
{
"api_key": "secret-key",
"session_bundle": "bundle-value",
"image_url": "data:image/png;base64,abcd",
"tool_calls": [
{
"function": {
"arguments": "x" * 3001,
}
}
],
},
_make_request("/v1/chat/completions", headers={"x-request-id": "req-123"}),
)
response = await main.internal_debug_requests(limit=10)
self.assertEqual(response.status_code, 200)
payload = json.loads(response.body)
self.assertEqual(payload["count"], 1)
item = payload["items"][0]
self.assertEqual(item["request_id"], "req-123")
self.assertEqual(item["body"]["api_key"], "***")
self.assertEqual(item["body"]["session_bundle"], "***")
self.assertEqual(item["body"]["image_url"], "[redacted-data-url]")
self.assertTrue(item["body"]["tool_calls"][0]["function"]["arguments"].endswith("... [truncated]"))
async def test_internal_debug_requests_requires_admin_token(self) -> None:
with (
patch.object(main.settings, "api_keys", ["api-key"]),
patch.object(main.settings, "admin_token", "admin-secret"),
):
with self.assertRaises(main.HTTPException) as ctx:
main.admin_auth_guard(
_make_request(
"/internal/debug/requests",
headers={"authorization": "Bearer wrong-token"},
)
)
self.assertEqual(ctx.exception.status_code, 401)
class SessionCacheToolFingerprintTests(unittest.TestCase):
def test_build_key_changes_with_tool_config(self) -> None:
from app.session_cache import SessionCache