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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user