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

@@ -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