Skip to content
Merged
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
-- CreateIndex
CREATE INDEX "LiteLLM_VerificationToken_user_id_team_id_idx" ON "LiteLLM_VerificationToken"("user_id", "team_id");

-- CreateIndex
CREATE INDEX "LiteLLM_VerificationToken_team_id_idx" ON "LiteLLM_VerificationToken"("team_id");

-- CreateIndex
CREATE INDEX "LiteLLM_VerificationToken_budget_reset_at_expires_idx" ON "LiteLLM_VerificationToken"("budget_reset_at", "expires");
10 changes: 10 additions & 0 deletions litellm-proxy-extras/litellm_proxy_extras/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,16 @@ model LiteLLM_VerificationToken {
litellm_budget_table LiteLLM_BudgetTable? @relation(fields: [budget_id], references: [budget_id])
litellm_organization_table LiteLLM_OrganizationTable? @relation(fields: [organization_id], references: [organization_id])
object_permission LiteLLM_ObjectPermissionTable? @relation(fields: [object_permission_id], references: [object_permission_id])

// SELECT COUNT(*) FROM (SELECT "public"."LiteLLM_VerificationToken"."token" FROM "public"."LiteLLM_VerificationToken" WHERE ("public"."LiteLLM_VerificationToken"."user_id" = $1 AND ("public"."LiteLLM_VerificationToken"."team_id" IS NULL OR "public"."LiteLLM_VerificationToken"."team_id" <> $2)) OFFSET $3 ) AS "sub"
// SELECT ... FROM "public"."LiteLLM_VerificationToken" WHERE "public"."LiteLLM_VerificationToken"."user_id" = $1 OFFSET $2
@@index([user_id, team_id])

// SELECT ... FROM "public"."LiteLLM_VerificationToken" WHERE "public"."LiteLLM_VerificationToken"."team_id" = $1 OFFSET $2
@@index([team_id])

// SELECT ... FROM "public"."LiteLLM_VerificationToken" WHERE (("public"."LiteLLM_VerificationToken"."expires" IS NULL OR "public"."LiteLLM_VerificationToken"."expires" > $1) AND "public"."LiteLLM_VerificationToken"."budget_reset_at" < $2) OFFSET $3
@@index([budget_reset_at, expires])
}

// Audit table for deleted keys - preserves spend and key information for historical tracking
Expand Down
34 changes: 32 additions & 2 deletions litellm/proxy/_experimental/mcp_server/openapi_to_mcp_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
"""

import json
import asyncio
import os
from pathlib import PurePosixPath
from typing import Any, Dict, Optional
from urllib.parse import quote
Expand Down Expand Up @@ -45,8 +47,36 @@ def _sanitize_path_parameter_value(param_value: Any, param_name: str) -> str:


def load_openapi_spec(filepath: str) -> Dict[str, Any]:
"""Load OpenAPI specification from JSON file."""
with open(filepath, "r") as f:
"""
Sync wrapper. For URL specs, use the shared/custom MCP httpx client.
"""
try:
# If we're already inside an event loop, prefer the async function.
asyncio.get_running_loop()
raise RuntimeError(
"load_openapi_spec() was called from within a running event loop. "
"Use 'await load_openapi_spec_async(...)' instead."
)
except RuntimeError as e:
# "no running event loop" is fine; other RuntimeErrors we re-raise
if "no running event loop" not in str(e).lower():
raise
return asyncio.run(load_openapi_spec_async(filepath))

async def load_openapi_spec_async(filepath: str) -> Dict[str, Any]:
if filepath.startswith("http://") or filepath.startswith("https://"):
client = get_async_httpx_client(llm_provider=httpxSpecialProvider.MCP)
# NOTE: do not close shared client if get_async_httpx_client returns a shared singleton.
# If it returns a new client each time, consider wrapping it in an async context manager.
r = await client.get(filepath)
r.raise_for_status()
return r.json()
Comment on lines 49 to +73

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bypasses configured HTTP client

load_openapi_spec() now fetches URL specs via httpx.get(...) directly, but the rest of this module uses get_async_httpx_client(..., llm_provider=httpxSpecialProvider.MCP) to ensure the project’s custom httpx configuration (proxies, TLS settings, auth headers, instrumentation, etc.) is applied. As-is, loading specs from URLs will ignore those settings and can fail in deployments that require them. Prefer using the same shared/custom httpx client path for URL loads as well.


# fallback: local file
# Local filesystem path
if not os.path.exists(filepath):
raise FileNotFoundError(f"OpenAPI spec not found at {filepath}")
with open(filepath, "r", encoding="utf-8") as f:
return json.load(f)


Expand Down
10 changes: 10 additions & 0 deletions litellm/proxy/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,16 @@ model LiteLLM_VerificationToken {
litellm_budget_table LiteLLM_BudgetTable? @relation(fields: [budget_id], references: [budget_id])
litellm_organization_table LiteLLM_OrganizationTable? @relation(fields: [organization_id], references: [organization_id])
object_permission LiteLLM_ObjectPermissionTable? @relation(fields: [object_permission_id], references: [object_permission_id])

// SELECT COUNT(*) FROM (SELECT "public"."LiteLLM_VerificationToken"."token" FROM "public"."LiteLLM_VerificationToken" WHERE ("public"."LiteLLM_VerificationToken"."user_id" = $1 AND ("public"."LiteLLM_VerificationToken"."team_id" IS NULL OR "public"."LiteLLM_VerificationToken"."team_id" <> $2)) OFFSET $3 ) AS "sub"
// SELECT ... FROM "public"."LiteLLM_VerificationToken" WHERE "public"."LiteLLM_VerificationToken"."user_id" = $1 OFFSET $2
@@index([user_id, team_id])

// SELECT ... FROM "public"."LiteLLM_VerificationToken" WHERE "public"."LiteLLM_VerificationToken"."team_id" = $1 OFFSET $2
@@index([team_id])

// SELECT ... FROM "public"."LiteLLM_VerificationToken" WHERE (("public"."LiteLLM_VerificationToken"."expires" IS NULL OR "public"."LiteLLM_VerificationToken"."expires" > $1) AND "public"."LiteLLM_VerificationToken"."budget_reset_at" < $2) OFFSET $3
@@index([budget_reset_at, expires])
}

// Audit table for deleted keys - preserves spend and key information for historical tracking
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@ def __init__(
self._pending_tool_events: List[BaseLiteLLMOpenAIResponseObject] = []
self._tool_output_index_by_call_id: dict[str, int] = {}
self._tool_args_by_call_id: dict[str, str] = {}
self._tool_call_id_by_index: dict[int, str] = {}
self._ambiguous_tool_call_indexes: set[int] = set()
self._next_tool_output_index: int = 1 # output_index=0 reserved for the message item
self._final_tool_events_queued: bool = False
self._sequence_number: int = 0
Expand All @@ -111,6 +113,19 @@ def _get_or_assign_tool_output_index(self, call_id: str) -> int:
self._tool_output_index_by_call_id[call_id] = idx
return idx

def _normalize_tool_call_index(self, tool_call: object) -> Optional[int]:
idx_raw = (
tool_call.get("index")
if isinstance(tool_call, dict)
else getattr(tool_call, "index", None)
)
if idx_raw is None:
return None
try:
return int(idx_raw)
except (TypeError, ValueError):
return None


def _is_reasoning_end(self, chunk):
delta = chunk.choices[0].delta
Expand Down Expand Up @@ -143,10 +158,28 @@ def _queue_tool_call_delta_events(self, tool_calls: object) -> None:
return

for tc in tool_calls:
tc_index = self._normalize_tool_call_index(tc)
call_id_raw = tc.get("id") if isinstance(tc, dict) else getattr(tc, "id", None)
if not call_id_raw:
call_id = ""

if call_id_raw:
call_id = str(call_id_raw)
if tc_index is not None:
existing_call_id = self._tool_call_id_by_index.get(tc_index)
if existing_call_id is not None and existing_call_id != call_id:
# Reusing the same index for multiple call_ids is ambiguous for id-less deltas.
# Guard against silent misrouting by disabling index fallback for this index.
self._ambiguous_tool_call_indexes.add(tc_index)
self._tool_call_id_by_index[tc_index] = call_id
elif tc_index is not None:
if tc_index in self._ambiguous_tool_call_indexes:
continue
mapped_call_id = self._tool_call_id_by_index.get(tc_index)
if mapped_call_id:
call_id = mapped_call_id

if not call_id:
continue
call_id = str(call_id_raw)

fn = tc.get("function") if isinstance(tc, dict) else getattr(tc, "function", None)
fn_name = ""
Expand Down
10 changes: 10 additions & 0 deletions schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,16 @@ model LiteLLM_VerificationToken {
litellm_budget_table LiteLLM_BudgetTable? @relation(fields: [budget_id], references: [budget_id])
litellm_organization_table LiteLLM_OrganizationTable? @relation(fields: [organization_id], references: [organization_id])
object_permission LiteLLM_ObjectPermissionTable? @relation(fields: [object_permission_id], references: [object_permission_id])

// SELECT COUNT(*) FROM (SELECT "public"."LiteLLM_VerificationToken"."token" FROM "public"."LiteLLM_VerificationToken" WHERE ("public"."LiteLLM_VerificationToken"."user_id" = $1 AND ("public"."LiteLLM_VerificationToken"."team_id" IS NULL OR "public"."LiteLLM_VerificationToken"."team_id" <> $2)) OFFSET $3 ) AS "sub"
// SELECT ... FROM "public"."LiteLLM_VerificationToken" WHERE "public"."LiteLLM_VerificationToken"."user_id" = $1 OFFSET $2
@@index([user_id, team_id])

// SELECT ... FROM "public"."LiteLLM_VerificationToken" WHERE "public"."LiteLLM_VerificationToken"."team_id" = $1 OFFSET $2
@@index([team_id])

// SELECT ... FROM "public"."LiteLLM_VerificationToken" WHERE (("public"."LiteLLM_VerificationToken"."expires" IS NULL OR "public"."LiteLLM_VerificationToken"."expires" > $1) AND "public"."LiteLLM_VerificationToken"."budget_reset_at" < $2) OFFSET $3
@@index([budget_reset_at, expires])
}

// Audit table for deleted keys - preserves spend and key information for historical tracking
Expand Down
92 changes: 92 additions & 0 deletions tests/mcp_tests/test_openapi_spec_path_url.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
from __future__ import annotations

from typing import Any, Dict

import httpx
import pytest

from litellm.proxy._experimental.mcp_server import openapi_to_mcp_generator as gen


class _FakeAsyncHTTPHandler:
"""
Minimal stand-in for the object returned by get_async_httpx_client().
openapi_to_mcp_generator.load_openapi_spec_async() calls:

client = get_async_httpx_client(...)
r = await client.get(url, timeout=30.0)

So we must implement async get().
"""

def __init__(self, response: httpx.Response, expected_url: str):
self._response = response
self._expected_url = expected_url
self.calls = 0

async def get(self, request_url: str, timeout: float = 30.0):
self.calls += 1
assert request_url == self._expected_url
assert timeout == 30.0
return self._response


def test_load_openapi_spec_supports_http_url(monkeypatch: pytest.MonkeyPatch) -> None:
url = "http://example.local/openapi.json"
expected: Dict[str, Any] = {
"openapi": "3.0.0",
"info": {"title": "Test API", "version": "1.0.0"},
"paths": {},
}

# httpx.Response must include a Request for raise_for_status() to work.
req = httpx.Request("GET", url)
resp = httpx.Response(status_code=200, json=expected, request=req)

calls = {"get_async_httpx_client": 0}
handler_holder: Dict[str, Any] = {}

def fake_get_async_httpx_client(*args, **kwargs):
calls["get_async_httpx_client"] += 1
h = _FakeAsyncHTTPHandler(resp, expected_url=url)
handler_holder["handler"] = h
return h

# Ensure shared/custom client path is used
monkeypatch.setattr(gen, "get_async_httpx_client", fake_get_async_httpx_client)

# Fail loudly if someone reintroduces direct httpx.get()
def boom(*args, **kwargs):
raise AssertionError("Direct httpx.get() must not be used for URL spec loading")

monkeypatch.setattr(httpx, "get", boom)

spec = gen.load_openapi_spec(url)

assert spec == expected
assert calls["get_async_httpx_client"] == 1
assert handler_holder["handler"].calls == 1


def test_load_openapi_spec_supports_local_file_path(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
expected: Dict[str, Any] = {
"openapi": "3.0.0",
"info": {"title": "Local API", "version": "1.0.0"},
"paths": {},
}

p = tmp_path / "openapi.json"
p.write_text(
'{"openapi":"3.0.0","info":{"title":"Local API","version":"1.0.0"},"paths":{}}',
encoding="utf-8",
)

# For local files, shared client must NOT be used.
def boom_client(*args, **kwargs):
raise AssertionError("get_async_httpx_client() must not be called for local file paths")

monkeypatch.setattr(gen, "get_async_httpx_client", boom_client)

spec = gen.load_openapi_spec(str(p))
assert spec == expected

Loading
Loading