From 6b591c34f141d4977078da2e7d67ad320d898f13 Mon Sep 17 00:00:00 2001 From: KunalG67 Date: Mon, 27 Apr 2026 17:15:11 +0530 Subject: [PATCH 01/28] fix(ovhcloud): migrate reasoning_content->reasoning and duration->seconds fields OVHCloud is deprecating two response fields on 2026-05-11: - reasoning_content replaced by reasoning (LLM reasoning models) - duration replaced by seconds (Speech-to-Text models) Adds backward-compatible support for both field names during the transition window, preferring the new field when present and falling back to the legacy field. Fixes #26586 --- .../audio_transcription/transformation.py | 8 ++ litellm/llms/ovhcloud/chat/transformation.py | 14 +++- ...loud_audio_transcription_transformation.py | 43 +++++++++++ .../test_ovhcloud_chat_transformation.py | 73 +++++++++++++++++++ 4 files changed, 134 insertions(+), 4 deletions(-) diff --git a/litellm/llms/ovhcloud/audio_transcription/transformation.py b/litellm/llms/ovhcloud/audio_transcription/transformation.py index 7ff6dc986b..e3c8308d50 100644 --- a/litellm/llms/ovhcloud/audio_transcription/transformation.py +++ b/litellm/llms/ovhcloud/audio_transcription/transformation.py @@ -156,5 +156,13 @@ class OVHCloudAudioTranscriptionConfig(BaseAudioTranscriptionConfig): text = response_json.get("text") or response_json.get("transcript") or "" response = TranscriptionResponse(text=text) + # OVHCloud field migration (deadline: 2026-05-11): + # `duration` is replaced by `seconds` in STT responses. + # Prefer `seconds`, fall back to `duration`, normalize to `duration` + # so downstream consumers see a consistent key. + duration = response_json.get("seconds") or response_json.get("duration") + if duration is not None: + response_json["duration"] = duration + response._hidden_params = response_json return response diff --git a/litellm/llms/ovhcloud/chat/transformation.py b/litellm/llms/ovhcloud/chat/transformation.py index ae9271ddb1..4100c548f2 100644 --- a/litellm/llms/ovhcloud/chat/transformation.py +++ b/litellm/llms/ovhcloud/chat/transformation.py @@ -98,10 +98,16 @@ class OVHCloudChatCompletionStreamingHandler(BaseModelResponseIterator): new_choices = [] for choice in chunk["choices"]: - if "delta" in choice and "reasoning" in choice["delta"]: - choice["delta"]["reasoning_content"] = choice["delta"].get( - "reasoning" - ) + if "delta" in choice: + delta = choice["delta"] + # OVHCloud field migration (deadline: 2026-05-11): + # `reasoning_content` is replaced by `reasoning`. + # Normalise to `reasoning_content` so downstream consumers + # see a consistent key during the transition window. + reasoning_new = delta.get("reasoning") + reasoning_legacy = delta.get("reasoning_content") + if reasoning_new is not None and reasoning_legacy is None: + delta["reasoning_content"] = reasoning_new new_choices.append(choice) return ModelResponseStream( diff --git a/tests/test_litellm/llms/ovhcloud/test_ovhcloud_audio_transcription_transformation.py b/tests/test_litellm/llms/ovhcloud/test_ovhcloud_audio_transcription_transformation.py index 8cc46dc98d..e9abf50ba7 100644 --- a/tests/test_litellm/llms/ovhcloud/test_ovhcloud_audio_transcription_transformation.py +++ b/tests/test_litellm/llms/ovhcloud/test_ovhcloud_audio_transcription_transformation.py @@ -54,3 +54,46 @@ def test_ovhcloud_audio_transcription_config_installed(): assert config is not None assert isinstance(config, BaseAudioTranscriptionConfig) + + + +class TestOVHCloudDurationFieldMigration: + """Tests for OVHCloud duration -> seconds field migration.""" + + def test_seconds_field_mapped_to_duration(self): + """New `seconds` field should be normalized to `duration`.""" + from litellm.llms.ovhcloud.audio_transcription.transformation import ( + OVHCloudAudioTranscriptionConfig, + ) + from unittest.mock import MagicMock + + config = OVHCloudAudioTranscriptionConfig() + mock_response = MagicMock() + mock_response.json.return_value = { + "text": "Hello world", + "seconds": 3.14, + } + + result = config.transform_audio_transcription_response(mock_response) + + assert result.text == "Hello world" + assert result._hidden_params["duration"] == 3.14 + + def test_legacy_duration_field_still_works(self): + """Legacy `duration` field should still be accepted.""" + from litellm.llms.ovhcloud.audio_transcription.transformation import ( + OVHCloudAudioTranscriptionConfig, + ) + from unittest.mock import MagicMock + + config = OVHCloudAudioTranscriptionConfig() + mock_response = MagicMock() + mock_response.json.return_value = { + "text": "Hello world", + "duration": 2.71, + } + + result = config.transform_audio_transcription_response(mock_response) + + assert result.text == "Hello world" + assert result._hidden_params["duration"] == 2.71 \ No newline at end of file diff --git a/tests/test_litellm/llms/ovhcloud/test_ovhcloud_chat_transformation.py b/tests/test_litellm/llms/ovhcloud/test_ovhcloud_chat_transformation.py index a1b3b31f78..88ce3b4c29 100644 --- a/tests/test_litellm/llms/ovhcloud/test_ovhcloud_chat_transformation.py +++ b/tests/test_litellm/llms/ovhcloud/test_ovhcloud_chat_transformation.py @@ -292,3 +292,76 @@ def test_ovhcloud_with_custom_base_url(): if __name__ == "__main__": pytest.main([__file__, "-v"]) + + +class TestOVHCloudReasoningFieldMigration: + """Tests for OVHCloud reasoning_content -> reasoning field migration.""" + + def test_streaming_new_reasoning_field(self): + """New `reasoning` field should be mapped to `reasoning_content`.""" + handler = OVHCloudChatCompletionStreamingHandler( + streaming_response=iter([]), + sync_stream=True, + ) + chunk = { + "id": "test-id", + "created": 1234567890, + "model": "test-model", + "choices": [ + { + "delta": { + "role": "assistant", + "reasoning": "Let me think...", + }, + "index": 0, + } + ], + } + result = handler.chunk_parser(chunk) + assert result.choices[0]["delta"]["reasoning_content"] == "Let me think..." + + def test_streaming_legacy_reasoning_content_unchanged(self): + """Legacy `reasoning_content` field should pass through untouched.""" + handler = OVHCloudChatCompletionStreamingHandler( + streaming_response=iter([]), + sync_stream=True, + ) + chunk = { + "id": "test-id", + "created": 1234567890, + "model": "test-model", + "choices": [ + { + "delta": { + "role": "assistant", + "reasoning_content": "Already correct field.", + }, + "index": 0, + } + ], + } + result = handler.chunk_parser(chunk) + assert result.choices[0]["delta"]["reasoning_content"] == "Already correct field." + + def test_streaming_both_fields_legacy_wins(self): + """When both fields present, existing `reasoning_content` is not overwritten.""" + handler = OVHCloudChatCompletionStreamingHandler( + streaming_response=iter([]), + sync_stream=True, + ) + chunk = { + "id": "test-id", + "created": 1234567890, + "model": "test-model", + "choices": [ + { + "delta": { + "reasoning": "new field", + "reasoning_content": "legacy field", + }, + "index": 0, + } + ], + } + result = handler.chunk_parser(chunk) + assert result.choices[0]["delta"]["reasoning_content"] == "legacy field" \ No newline at end of file From e55e73d69b0be420a7092fcfa1159db2b7bd2d0e Mon Sep 17 00:00:00 2001 From: KunalG67 Date: Mon, 27 Apr 2026 17:37:27 +0530 Subject: [PATCH 02/28] fix(ovhcloud): use explicit None check for seconds field in STT response Replaces falsy or with explicit is not None check so that a valid seconds=0.0 value is not silently dropped during field migration. Addresses Greptile review feedback on #26595 --- .../audio_transcription/transformation.py | 6 +++++- ...hcloud_audio_transcription_transformation.py | 17 ++++++++++++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/litellm/llms/ovhcloud/audio_transcription/transformation.py b/litellm/llms/ovhcloud/audio_transcription/transformation.py index e3c8308d50..f49f31d7ec 100644 --- a/litellm/llms/ovhcloud/audio_transcription/transformation.py +++ b/litellm/llms/ovhcloud/audio_transcription/transformation.py @@ -160,7 +160,11 @@ class OVHCloudAudioTranscriptionConfig(BaseAudioTranscriptionConfig): # `duration` is replaced by `seconds` in STT responses. # Prefer `seconds`, fall back to `duration`, normalize to `duration` # so downstream consumers see a consistent key. - duration = response_json.get("seconds") or response_json.get("duration") + duration = ( + response_json["seconds"] + if "seconds" in response_json and response_json["seconds"] is not None + else response_json.get("duration") + ) if duration is not None: response_json["duration"] = duration diff --git a/tests/test_litellm/llms/ovhcloud/test_ovhcloud_audio_transcription_transformation.py b/tests/test_litellm/llms/ovhcloud/test_ovhcloud_audio_transcription_transformation.py index e9abf50ba7..c8751fb2d9 100644 --- a/tests/test_litellm/llms/ovhcloud/test_ovhcloud_audio_transcription_transformation.py +++ b/tests/test_litellm/llms/ovhcloud/test_ovhcloud_audio_transcription_transformation.py @@ -96,4 +96,19 @@ class TestOVHCloudDurationFieldMigration: result = config.transform_audio_transcription_response(mock_response) assert result.text == "Hello world" - assert result._hidden_params["duration"] == 2.71 \ No newline at end of file + assert result._hidden_params["duration"] == 2.71 + + + + def test_seconds_zero_mapped_to_duration(self): + """seconds=0.0 must not be treated as falsy and lost.""" + from litellm.llms.ovhcloud.audio_transcription.transformation import ( + OVHCloudAudioTranscriptionConfig, + ) + from unittest.mock import MagicMock + + config = OVHCloudAudioTranscriptionConfig() + mock_response = MagicMock() + mock_response.json.return_value = {"text": "silence", "seconds": 0.0} + result = config.transform_audio_transcription_response(mock_response) + assert result._hidden_params["duration"] == 0.0 \ No newline at end of file From c0da139540345e1319c5936435f611a6aa307c44 Mon Sep 17 00:00:00 2001 From: Aneesh-Fiddler Date: Mon, 27 Apr 2026 22:51:39 +0530 Subject: [PATCH 03/28] fix(otel): populate gen_ai.output.messages and gen_ai.system_instructions for Responses API Fixes #25840 The OTel integration's set_attributes() method never populates gen_ai.output.messages, gen_ai.system_instructions, or gen_ai.response.finish_reasons for /v1/responses calls because ResponsesAPIResponse uses 'output' instead of 'choices' and the system prompt arrives as 'instructions' instead of 'system_instructions'. Changes: - Add elif branch for response_obj.get('output') to extract response text from Responses API output items (type='message'/output_text) and tool calls (type='function_call') - Coalesce system_instructions/instructions/system kwargs so the system prompt is captured for Responses API, Anthropic Messages API, and Vertex AI Gemini paths - Handle plain-string system prompts without unnecessary wrapping - Extract response_obj.get('status') as finish reason for Responses API - Add _transform_responses_api_output_to_otel() method --- litellm/integrations/opentelemetry.py | 111 +++++++++++++++++++++++--- 1 file changed, 101 insertions(+), 10 deletions(-) diff --git a/litellm/integrations/opentelemetry.py b/litellm/integrations/opentelemetry.py index b6d91d0b76..b26850657d 100644 --- a/litellm/integrations/opentelemetry.py +++ b/litellm/integrations/opentelemetry.py @@ -1678,17 +1678,35 @@ class OpenTelemetry(CustomLogger): value=safe_dumps(transformed_messages), ) - if kwargs.get("system_instructions"): - transformed_system_instructions = ( - self._transform_messages_to_otel_semantic_conventions( - kwargs.get("system_instructions") + # Coalesce the different kwarg names that carry the system + # prompt depending on the call path: + # - "system_instructions" — Vertex AI Gemini chat-completion + # - "instructions" — OpenAI Responses API + # - "system" — Anthropic Messages API + system_instructions = ( + kwargs.get("system_instructions") + or kwargs.get("instructions") + or kwargs.get("system") + ) + if system_instructions: + if isinstance(system_instructions, str): + # Plain text system prompt — no transformation needed + self.safe_set_attribute( + span=span, + key=SpanAttributes.GEN_AI_SYSTEM_INSTRUCTIONS.value, + value=system_instructions, + ) + else: + transformed_system_instructions = ( + self._transform_messages_to_otel_semantic_conventions( + system_instructions + ) + ) + self.safe_set_attribute( + span=span, + key=SpanAttributes.GEN_AI_SYSTEM_INSTRUCTIONS.value, + value=safe_dumps(transformed_system_instructions), ) - ) - self.safe_set_attribute( - span=span, - key=SpanAttributes.GEN_AI_SYSTEM_INSTRUCTIONS.value, - value=safe_dumps(transformed_system_instructions), - ) self.safe_set_attribute( span=span, @@ -1747,6 +1765,32 @@ class OpenTelemetry(CustomLogger): value=value, ) + elif response_obj.get("output"): + # Responses API: ResponsesAPIResponse has an "output" + # list instead of "choices". Each item with + # type="message" contains a "content" list of + # OutputText objects (type="output_text"). + output_messages = ( + self._transform_responses_api_output_to_otel( + response_obj.get("output") + ) + ) + if output_messages: + self.safe_set_attribute( + span=span, + key=SpanAttributes.GEN_AI_OUTPUT_MESSAGES.value, + value=safe_dumps(output_messages), + ) + + # Extract finish reason from ResponsesAPIResponse.status + status = response_obj.get("status") + if status: + self.safe_set_attribute( + span=span, + key=SpanAttributes.GEN_AI_RESPONSE_FINISH_REASONS.value, + value=safe_dumps([status]), + ) + except Exception as e: self.handle_callback_failure( callback_name=self.callback_name or "opentelemetry" @@ -1842,6 +1886,53 @@ class OpenTelemetry(CustomLogger): transformed.append(transformed_msg) return transformed + def _transform_responses_api_output_to_otel( + self, output: List[dict] + ) -> List[dict]: + """ + Transform Responses API output items into OTEL GenAI 1.38 format. + + The Responses API returns output as a list of items, each with a + ``type`` field. Message items (``type="message"``) contain a + ``content`` list of ``OutputText`` objects with ``type="output_text"`` + and ``text`` fields. + + This method converts them to the same ``{"role": ..., "parts": [...]}`` + format used by ``_transform_choices_to_otel_semantic_conventions``. + """ + transformed = [] + for item in output: + if not isinstance(item, dict): + continue + if item.get("type") == "message": + role = item.get("role", "assistant") + parts = [] + for content in item.get("content", []): + if not isinstance(content, dict): + continue + if content.get("type") == "output_text": + text = content.get("text", "") + if text: + parts.append({"type": "text", "content": text}) + if parts: + transformed.append({"role": role, "parts": parts}) + elif item.get("type") == "function_call": + # Surface tool calls from Responses API output + tool_call = { + "role": "assistant", + "parts": [ + { + "type": "tool_call", + "name": item.get("name", ""), + "arguments": item.get("arguments", ""), + } + ], + } + if item.get("call_id"): + tool_call["parts"][0]["id"] = item["call_id"] + transformed.append(tool_call) + return transformed + def set_raw_request_attributes(self, span: Span, kwargs, response_obj): try: # Only set provider-specific raw payload attributes on this span. From 4d2e13c9070b74b0945613516e09917e15421023 Mon Sep 17 00:00:00 2001 From: Aneesh-Fiddler Date: Mon, 27 Apr 2026 23:12:34 +0530 Subject: [PATCH 04/28] test(otel): add tests for Responses API output messages, system instructions, and finish reasons Add 21 tests covering the new Responses API OTel attribute handling: TestOpenTelemetryResponsesAPI (13 tests): - gen_ai.output.messages from output items (text, function_call, mixed, multi-part) - gen_ai.response.finish_reasons from ResponsesAPIResponse.status - gen_ai.system_instructions from instructions/system/system_instructions kwargs - Precedence and absence edge cases - Regression test for existing choices-based responses TestTransformResponsesAPIOutput (8 tests): - Message with output_text, function_call items, unknown types - Edge cases: empty output, empty text, missing call_id, default role, non-dict items --- .../integrations/test_opentelemetry.py | 456 ++++++++++++++++++ 1 file changed, 456 insertions(+) diff --git a/tests/test_litellm/integrations/test_opentelemetry.py b/tests/test_litellm/integrations/test_opentelemetry.py index f710647189..5f29400564 100644 --- a/tests/test_litellm/integrations/test_opentelemetry.py +++ b/tests/test_litellm/integrations/test_opentelemetry.py @@ -2859,3 +2859,459 @@ class TestResponseIdFallback(unittest.TestCase): otel.set_attributes(mock_span, kwargs, response_obj) mock_span.set_attribute.assert_any_call("litellm.call_id", call_id) + + + +class TestOpenTelemetryResponsesAPI(unittest.TestCase): + """ + Tests for Responses API (/v1/responses) OTel span attributes. + + The Responses API uses ``output`` (list of output items) instead of + ``choices``, ``instructions`` instead of ``system_instructions``, and + ``status`` instead of per-choice ``finish_reason``. + + See: https://github.com/BerriAI/litellm/issues/25840 + """ + + def _base_kwargs(self, **overrides): + """Return minimal kwargs for set_attributes with Responses API defaults.""" + kwargs = { + "model": "gpt-4o", + "messages": [{"role": "user", "content": "What is 2+2?"}], + "optional_params": {}, + "litellm_params": {"custom_llm_provider": "openai"}, + "standard_logging_object": { + "id": "resp_abc123", + "call_type": "responses", + "metadata": {}, + }, + } + kwargs.update(overrides) + return kwargs + + def _responses_api_response_obj(self, text="The answer is 4.", status="completed"): + """Return a dict mimicking ResponsesAPIResponse with a message output.""" + return { + "id": "resp_abc123", + "model": "gpt-4o", + "status": status, + "output": [ + { + "type": "message", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": text, + } + ], + } + ], + "usage": { + "prompt_tokens": 10, + "completion_tokens": 20, + "total_tokens": 30, + }, + } + + def _get_attr(self, mock_span, attr_name): + """Extract the value set for a specific attribute name, or None.""" + calls = [ + call + for call in mock_span.set_attribute.call_args_list + if call[0][0] == attr_name + ] + if not calls: + return None + return calls[0][0][1] + + # ------------------------------------------------------------------ + # gen_ai.output.messages + # ------------------------------------------------------------------ + + def test_output_messages_populated_for_responses_api(self): + """gen_ai.output.messages must be set when response has output items.""" + otel = OpenTelemetry() + mock_span = MagicMock() + + kwargs = self._base_kwargs() + response_obj = self._responses_api_response_obj(text="The answer is 4.") + + otel.set_attributes(span=mock_span, kwargs=kwargs, response_obj=response_obj) + + raw = self._get_attr(mock_span, "gen_ai.output.messages") + self.assertIsNotNone(raw, "gen_ai.output.messages should be set") + + parsed = json.loads(raw) + self.assertIsInstance(parsed, list) + self.assertEqual(len(parsed), 1) + self.assertEqual(parsed[0]["role"], "assistant") + self.assertIn("parts", parsed[0]) + self.assertEqual(parsed[0]["parts"][0]["type"], "text") + self.assertEqual(parsed[0]["parts"][0]["content"], "The answer is 4.") + + def test_output_messages_with_multiple_content_items(self): + """Multiple output_text items in a single message should all appear as parts.""" + otel = OpenTelemetry() + mock_span = MagicMock() + + response_obj = { + "id": "resp_multi", + "model": "gpt-4o", + "status": "completed", + "output": [ + { + "type": "message", + "role": "assistant", + "content": [ + {"type": "output_text", "text": "First paragraph."}, + {"type": "output_text", "text": "Second paragraph."}, + ], + } + ], + } + + otel.set_attributes( + span=mock_span, kwargs=self._base_kwargs(), response_obj=response_obj + ) + + raw = self._get_attr(mock_span, "gen_ai.output.messages") + parsed = json.loads(raw) + self.assertEqual(len(parsed[0]["parts"]), 2) + self.assertEqual(parsed[0]["parts"][0]["content"], "First paragraph.") + self.assertEqual(parsed[0]["parts"][1]["content"], "Second paragraph.") + + def test_output_messages_with_function_call(self): + """function_call output items should appear as tool_call parts.""" + otel = OpenTelemetry() + mock_span = MagicMock() + + response_obj = { + "id": "resp_fc", + "model": "gpt-4o", + "status": "completed", + "output": [ + { + "type": "function_call", + "name": "get_weather", + "call_id": "call_abc", + "arguments": '{"location": "SF"}', + } + ], + } + + otel.set_attributes( + span=mock_span, kwargs=self._base_kwargs(), response_obj=response_obj + ) + + raw = self._get_attr(mock_span, "gen_ai.output.messages") + parsed = json.loads(raw) + self.assertEqual(len(parsed), 1) + self.assertEqual(parsed[0]["role"], "assistant") + self.assertEqual(parsed[0]["parts"][0]["type"], "tool_call") + self.assertEqual(parsed[0]["parts"][0]["name"], "get_weather") + self.assertEqual(parsed[0]["parts"][0]["arguments"], '{"location": "SF"}') + self.assertEqual(parsed[0]["parts"][0]["id"], "call_abc") + + def test_output_messages_mixed_message_and_function_call(self): + """Mixed output with both message and function_call items.""" + otel = OpenTelemetry() + mock_span = MagicMock() + + response_obj = { + "id": "resp_mixed", + "model": "gpt-4o", + "status": "completed", + "output": [ + { + "type": "message", + "role": "assistant", + "content": [ + {"type": "output_text", "text": "Let me check the weather."}, + ], + }, + { + "type": "function_call", + "name": "get_weather", + "call_id": "call_xyz", + "arguments": "{}", + }, + ], + } + + otel.set_attributes( + span=mock_span, kwargs=self._base_kwargs(), response_obj=response_obj + ) + + raw = self._get_attr(mock_span, "gen_ai.output.messages") + parsed = json.loads(raw) + self.assertEqual(len(parsed), 2) + self.assertEqual(parsed[0]["role"], "assistant") + self.assertEqual(parsed[0]["parts"][0]["content"], "Let me check the weather.") + self.assertEqual(parsed[1]["parts"][0]["type"], "tool_call") + + def test_output_messages_empty_text_skipped(self): + """Output items with empty text should not produce parts.""" + otel = OpenTelemetry() + mock_span = MagicMock() + + response_obj = { + "id": "resp_empty", + "model": "gpt-4o", + "status": "completed", + "output": [ + { + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": ""}], + } + ], + } + + otel.set_attributes( + span=mock_span, kwargs=self._base_kwargs(), response_obj=response_obj + ) + + # No output messages should be set since the text is empty + raw = self._get_attr(mock_span, "gen_ai.output.messages") + self.assertIsNone(raw, "Empty output text should not produce gen_ai.output.messages") + + def test_choices_still_work(self): + """Existing choices-based responses must still work (no regression).""" + otel = OpenTelemetry() + mock_span = MagicMock() + + kwargs = { + "model": "gpt-4", + "messages": [{"role": "user", "content": "Hello"}], + "optional_params": {}, + "litellm_params": {"custom_llm_provider": "openai"}, + "standard_logging_object": { + "id": "test-id", + "call_type": "completion", + "metadata": {}, + }, + } + + response_obj = { + "id": "chatcmpl-123", + "model": "gpt-4", + "choices": [ + { + "finish_reason": "stop", + "message": {"role": "assistant", "content": "Hi there!"}, + } + ], + "usage": {"prompt_tokens": 5, "completion_tokens": 10, "total_tokens": 15}, + } + + otel.set_attributes(span=mock_span, kwargs=kwargs, response_obj=response_obj) + + raw = self._get_attr(mock_span, "gen_ai.output.messages") + parsed = json.loads(raw) + self.assertEqual(parsed[0]["parts"][0]["content"], "Hi there!") + self.assertEqual(parsed[0]["finish_reason"], "stop") + + # ------------------------------------------------------------------ + # gen_ai.response.finish_reasons + # ------------------------------------------------------------------ + + def test_finish_reasons_from_status(self): + """gen_ai.response.finish_reasons should use ResponsesAPIResponse.status.""" + otel = OpenTelemetry() + mock_span = MagicMock() + + otel.set_attributes( + span=mock_span, + kwargs=self._base_kwargs(), + response_obj=self._responses_api_response_obj(status="completed"), + ) + + raw = self._get_attr(mock_span, "gen_ai.response.finish_reasons") + self.assertIsNotNone(raw) + parsed = json.loads(raw) + self.assertEqual(parsed, ["completed"]) + + def test_finish_reasons_incomplete_status(self): + """Non-completed status values should still be captured.""" + otel = OpenTelemetry() + mock_span = MagicMock() + + otel.set_attributes( + span=mock_span, + kwargs=self._base_kwargs(), + response_obj=self._responses_api_response_obj(status="incomplete"), + ) + + raw = self._get_attr(mock_span, "gen_ai.response.finish_reasons") + parsed = json.loads(raw) + self.assertEqual(parsed, ["incomplete"]) + + # ------------------------------------------------------------------ + # gen_ai.system_instructions + # ------------------------------------------------------------------ + + def test_system_instructions_from_instructions_kwarg(self): + """Responses API passes system prompt as kwargs['instructions'].""" + otel = OpenTelemetry() + mock_span = MagicMock() + + kwargs = self._base_kwargs(instructions="You are a math tutor.") + response_obj = self._responses_api_response_obj() + + otel.set_attributes(span=mock_span, kwargs=kwargs, response_obj=response_obj) + + value = self._get_attr(mock_span, "gen_ai.system_instructions") + self.assertEqual(value, "You are a math tutor.") + + def test_system_instructions_from_system_kwarg(self): + """Anthropic Messages API passes system prompt as kwargs['system'].""" + otel = OpenTelemetry() + mock_span = MagicMock() + + kwargs = self._base_kwargs(system="You are a helpful assistant.") + response_obj = self._responses_api_response_obj() + + otel.set_attributes(span=mock_span, kwargs=kwargs, response_obj=response_obj) + + value = self._get_attr(mock_span, "gen_ai.system_instructions") + self.assertEqual(value, "You are a helpful assistant.") + + def test_system_instructions_from_system_instructions_kwarg(self): + """Vertex AI Gemini path uses kwargs['system_instructions'] (existing behavior).""" + otel = OpenTelemetry() + mock_span = MagicMock() + + kwargs = self._base_kwargs( + system_instructions=[{"role": "system", "content": "Be concise."}] + ) + response_obj = self._responses_api_response_obj() + + otel.set_attributes(span=mock_span, kwargs=kwargs, response_obj=response_obj) + + raw = self._get_attr(mock_span, "gen_ai.system_instructions") + self.assertIsNotNone(raw) + parsed = json.loads(raw) + self.assertEqual(parsed[0]["role"], "system") + self.assertIn("parts", parsed[0]) + + def test_system_instructions_precedence(self): + """system_instructions takes precedence over instructions and system.""" + otel = OpenTelemetry() + mock_span = MagicMock() + + kwargs = self._base_kwargs( + system_instructions="From Gemini", + instructions="From Responses API", + system="From Anthropic", + ) + response_obj = self._responses_api_response_obj() + + otel.set_attributes(span=mock_span, kwargs=kwargs, response_obj=response_obj) + + # system_instructions (string) should win — it's checked first + value = self._get_attr(mock_span, "gen_ai.system_instructions") + self.assertEqual(value, "From Gemini") + + def test_no_system_instructions_when_absent(self): + """No gen_ai.system_instructions attr when none of the kwargs are set.""" + otel = OpenTelemetry() + mock_span = MagicMock() + + kwargs = self._base_kwargs() + response_obj = self._responses_api_response_obj() + + otel.set_attributes(span=mock_span, kwargs=kwargs, response_obj=response_obj) + + value = self._get_attr(mock_span, "gen_ai.system_instructions") + self.assertIsNone(value) + + +class TestTransformResponsesAPIOutput(unittest.TestCase): + """ + Unit tests for _transform_responses_api_output_to_otel. + """ + + def test_message_with_output_text(self): + otel = OpenTelemetry() + output = [ + { + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "Hello!"}], + } + ] + result = otel._transform_responses_api_output_to_otel(output) + self.assertEqual(len(result), 1) + self.assertEqual(result[0]["role"], "assistant") + self.assertEqual(result[0]["parts"], [{"type": "text", "content": "Hello!"}]) + + def test_function_call_item(self): + otel = OpenTelemetry() + output = [ + { + "type": "function_call", + "name": "search", + "call_id": "call_1", + "arguments": '{"q": "test"}', + } + ] + result = otel._transform_responses_api_output_to_otel(output) + self.assertEqual(len(result), 1) + self.assertEqual(result[0]["role"], "assistant") + self.assertEqual(result[0]["parts"][0]["type"], "tool_call") + self.assertEqual(result[0]["parts"][0]["name"], "search") + self.assertEqual(result[0]["parts"][0]["id"], "call_1") + + def test_function_call_without_call_id(self): + otel = OpenTelemetry() + output = [ + { + "type": "function_call", + "name": "search", + "arguments": "{}", + } + ] + result = otel._transform_responses_api_output_to_otel(output) + self.assertNotIn("id", result[0]["parts"][0]) + + def test_unknown_type_ignored(self): + otel = OpenTelemetry() + output = [{"type": "reasoning", "content": "thinking..."}] + result = otel._transform_responses_api_output_to_otel(output) + self.assertEqual(result, []) + + def test_non_dict_items_ignored(self): + otel = OpenTelemetry() + output = ["not a dict", 42, None] + result = otel._transform_responses_api_output_to_otel(output) + self.assertEqual(result, []) + + def test_empty_output(self): + otel = OpenTelemetry() + result = otel._transform_responses_api_output_to_otel([]) + self.assertEqual(result, []) + + def test_message_with_empty_text_skipped(self): + otel = OpenTelemetry() + output = [ + { + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": ""}], + } + ] + result = otel._transform_responses_api_output_to_otel(output) + self.assertEqual(result, []) + + def test_message_default_role(self): + """Messages without explicit role should default to assistant.""" + otel = OpenTelemetry() + output = [ + { + "type": "message", + "content": [{"type": "output_text", "text": "Hi"}], + } + ] + result = otel._transform_responses_api_output_to_otel(output) + self.assertEqual(result[0]["role"], "assistant") From e70b0c97a4c8d6aef4efd7d1738172acfe4510ea Mon Sep 17 00:00:00 2001 From: Aneesh-Fiddler Date: Tue, 28 Apr 2026 11:26:14 +0530 Subject: [PATCH 05/28] style: apply black formatting to opentelemetry.py --- litellm/integrations/opentelemetry.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/litellm/integrations/opentelemetry.py b/litellm/integrations/opentelemetry.py index b26850657d..664aeadc7c 100644 --- a/litellm/integrations/opentelemetry.py +++ b/litellm/integrations/opentelemetry.py @@ -1770,10 +1770,8 @@ class OpenTelemetry(CustomLogger): # list instead of "choices". Each item with # type="message" contains a "content" list of # OutputText objects (type="output_text"). - output_messages = ( - self._transform_responses_api_output_to_otel( - response_obj.get("output") - ) + output_messages = self._transform_responses_api_output_to_otel( + response_obj.get("output") ) if output_messages: self.safe_set_attribute( @@ -1886,9 +1884,7 @@ class OpenTelemetry(CustomLogger): transformed.append(transformed_msg) return transformed - def _transform_responses_api_output_to_otel( - self, output: List[dict] - ) -> List[dict]: + def _transform_responses_api_output_to_otel(self, output: List[dict]) -> List[dict]: """ Transform Responses API output items into OTEL GenAI 1.38 format. From c30d58f7e30a0568c265814d70cea2301bea51a4 Mon Sep 17 00:00:00 2001 From: Aneesh-Fiddler Date: Tue, 28 Apr 2026 12:30:39 +0530 Subject: [PATCH 06/28] fix: resolve mypy indexed assignment error in function_call handling Build the tool_call part dict separately with an explicit type annotation so mypy can track the type, avoiding the 'Unsupported target for indexed assignment' error on tool_call["parts"][0]["id"]. --- litellm/integrations/opentelemetry.py | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/litellm/integrations/opentelemetry.py b/litellm/integrations/opentelemetry.py index 664aeadc7c..8084a9d0f2 100644 --- a/litellm/integrations/opentelemetry.py +++ b/litellm/integrations/opentelemetry.py @@ -1914,19 +1914,14 @@ class OpenTelemetry(CustomLogger): transformed.append({"role": role, "parts": parts}) elif item.get("type") == "function_call": # Surface tool calls from Responses API output - tool_call = { - "role": "assistant", - "parts": [ - { - "type": "tool_call", - "name": item.get("name", ""), - "arguments": item.get("arguments", ""), - } - ], + part: dict = { + "type": "tool_call", + "name": item.get("name", ""), + "arguments": item.get("arguments", ""), } if item.get("call_id"): - tool_call["parts"][0]["id"] = item["call_id"] - transformed.append(tool_call) + part["id"] = item["call_id"] + transformed.append({"role": "assistant", "parts": [part]}) return transformed def set_raw_request_attributes(self, span: Span, kwargs, response_obj): From 466b4ddae31beb94635f72adc796d731b485930f Mon Sep 17 00:00:00 2001 From: Aneesh-Fiddler Date: Tue, 28 Apr 2026 13:33:33 +0530 Subject: [PATCH 07/28] =?UTF-8?q?fix:=20address=20review=20comments=20?= =?UTF-8?q?=E2=80=94=20Pydantic=20compat,=20falsy=20fallthrough,=20per-too?= =?UTF-8?q?l-call=20attrs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace isinstance(item, dict) with hasattr(item, 'get') so Pydantic model instances (ResponseOutputMessage, ResponseFunctionToolCall) are accepted alongside plain dicts (P1) - Use 'is not None' guards instead of or-chain for system_instructions coalescing to prevent falsy values (e.g. []) falling through to the wrong kwarg (P2) - Emit per-tool-call span attributes (gen_ai.completion.N.function_call.*) for Responses API function_call items, matching the choices branch parity with _tool_calls_kv_pair (P2) - Add 4 new tests: Pydantic-like objects, falsy fallthrough guard, per-tool-call attribute emission, multiple tool call indexing --- litellm/integrations/opentelemetry.py | 62 ++++++- .../integrations/test_opentelemetry.py | 171 ++++++++++++++++++ 2 files changed, 227 insertions(+), 6 deletions(-) diff --git a/litellm/integrations/opentelemetry.py b/litellm/integrations/opentelemetry.py index 8084a9d0f2..90e647d86b 100644 --- a/litellm/integrations/opentelemetry.py +++ b/litellm/integrations/opentelemetry.py @@ -1683,10 +1683,16 @@ class OpenTelemetry(CustomLogger): # - "system_instructions" — Vertex AI Gemini chat-completion # - "instructions" — OpenAI Responses API # - "system" — Anthropic Messages API + # Use `is not None` rather than truthiness to avoid falsy + # values (e.g. []) falling through to the wrong kwarg. system_instructions = ( kwargs.get("system_instructions") - or kwargs.get("instructions") - or kwargs.get("system") + if kwargs.get("system_instructions") is not None + else ( + kwargs.get("instructions") + if kwargs.get("instructions") is not None + else kwargs.get("system") + ) ) if system_instructions: if isinstance(system_instructions, str): @@ -1770,8 +1776,9 @@ class OpenTelemetry(CustomLogger): # list instead of "choices". Each item with # type="message" contains a "content" list of # OutputText objects (type="output_text"). + output_items = response_obj.get("output") output_messages = self._transform_responses_api_output_to_otel( - response_obj.get("output") + output_items ) if output_messages: self.safe_set_attribute( @@ -1780,6 +1787,43 @@ class OpenTelemetry(CustomLogger): value=safe_dumps(output_messages), ) + # Emit per-tool-call span attributes (parity with + # the choices branch that calls _tool_calls_kv_pair). + # Convert Responses API function_call items to the + # ChatCompletionMessageToolCall format expected by + # _tool_calls_kv_pair. + tool_calls = [] + for out_item in output_items: + if ( + hasattr(out_item, "get") + and out_item.get("type") == "function_call" + ): + tool_calls.append( + { + "function": { + "name": out_item.get("name", ""), + "arguments": out_item.get("arguments", ""), + } + } + ) + if tool_calls: + kv_pairs = OpenTelemetry._tool_calls_kv_pair(tool_calls) # type: ignore + for key, value in kv_pairs.items(): + self.safe_set_attribute( + span=span, + key=key, + value=value, + ) + + # Extract finish reason from ResponsesAPIResponse.status + status = response_obj.get("status") + if status: + self.safe_set_attribute( + span=span, + key=SpanAttributes.GEN_AI_RESPONSE_FINISH_REASONS.value, + value=safe_dumps([status]), + ) + # Extract finish reason from ResponsesAPIResponse.status status = response_obj.get("status") if status: @@ -1884,7 +1928,7 @@ class OpenTelemetry(CustomLogger): transformed.append(transformed_msg) return transformed - def _transform_responses_api_output_to_otel(self, output: List[dict]) -> List[dict]: + def _transform_responses_api_output_to_otel(self, output: List) -> List[dict]: """ Transform Responses API output items into OTEL GenAI 1.38 format. @@ -1893,18 +1937,24 @@ class OpenTelemetry(CustomLogger): ``content`` list of ``OutputText`` objects with ``type="output_text"`` and ``text`` fields. + Items may be plain dicts or Pydantic model instances (e.g. + ``ResponseOutputMessage``, ``ResponseFunctionToolCall``). Both + expose a ``.get()`` method via ``BaseLiteLLMOpenAIResponseObject``, + so we use ``hasattr(item, "get")`` rather than ``isinstance(item, + dict)`` to accept either form. + This method converts them to the same ``{"role": ..., "parts": [...]}`` format used by ``_transform_choices_to_otel_semantic_conventions``. """ transformed = [] for item in output: - if not isinstance(item, dict): + if not hasattr(item, "get"): continue if item.get("type") == "message": role = item.get("role", "assistant") parts = [] for content in item.get("content", []): - if not isinstance(content, dict): + if not hasattr(content, "get"): continue if content.get("type") == "output_text": text = content.get("text", "") diff --git a/tests/test_litellm/integrations/test_opentelemetry.py b/tests/test_litellm/integrations/test_opentelemetry.py index 5f29400564..56aba4bc5e 100644 --- a/tests/test_litellm/integrations/test_opentelemetry.py +++ b/tests/test_litellm/integrations/test_opentelemetry.py @@ -3315,3 +3315,174 @@ class TestTransformResponsesAPIOutput(unittest.TestCase): ] result = otel._transform_responses_api_output_to_otel(output) self.assertEqual(result[0]["role"], "assistant") + + + def test_pydantic_like_objects_accepted(self): + """Items with .get() but not isinstance(dict) should be accepted.""" + + class FakeOutputItem: + """Mimics BaseLiteLLMOpenAIResponseObject duck-typing.""" + + def __init__(self, data): + self._data = data + + def get(self, key, default=None): + return self._data.get(key, default) + + class FakeContent: + def __init__(self, data): + self._data = data + + def get(self, key, default=None): + return self._data.get(key, default) + + otel = OpenTelemetry() + output = [ + FakeOutputItem( + { + "type": "message", + "role": "assistant", + "content": [ + FakeContent({"type": "output_text", "text": "Pydantic works!"}), + ], + } + ) + ] + result = otel._transform_responses_api_output_to_otel(output) + self.assertEqual(len(result), 1) + self.assertEqual(result[0]["parts"][0]["content"], "Pydantic works!") + + +class TestSystemInstructionsPrecedence(unittest.TestCase): + """Tests for the is-not-None precedence in system_instructions coalescing.""" + + def _get_attr(self, mock_span, attr_name): + calls = [ + call + for call in mock_span.set_attribute.call_args_list + if call[0][0] == attr_name + ] + if not calls: + return None + return calls[0][0][1] + + def _base_kwargs(self, **overrides): + kwargs = { + "model": "gpt-4o", + "messages": [{"role": "user", "content": "Hi"}], + "optional_params": {}, + "litellm_params": {"custom_llm_provider": "openai"}, + "standard_logging_object": { + "id": "test-id", + "call_type": "responses", + "metadata": {}, + }, + } + kwargs.update(overrides) + return kwargs + + def test_empty_list_system_instructions_does_not_fallthrough(self): + """An empty list for system_instructions should NOT fall through to instructions.""" + otel = OpenTelemetry() + mock_span = MagicMock() + + kwargs = self._base_kwargs( + system_instructions=[], + instructions="Should not be used", + ) + response_obj = {"id": "r1", "model": "gpt-4o"} + + otel.set_attributes(span=mock_span, kwargs=kwargs, response_obj=response_obj) + + # system_instructions is [] (falsy but not None), so it wins. + # Since it's an empty list, no attribute should be set (nothing to transform). + value = self._get_attr(mock_span, "gen_ai.system_instructions") + # The empty list is truthy for `is not None` but produces empty + # transformed output — the attribute should NOT contain "Should not be used". + if value is not None: + self.assertNotIn("Should not be used", str(value)) + + +class TestResponsesAPIToolCallSpanAttributes(unittest.TestCase): + """Tests for per-tool-call span attributes on Responses API function_call items.""" + + def _base_kwargs(self): + return { + "model": "gpt-4o", + "messages": [{"role": "user", "content": "What is the weather?"}], + "optional_params": {}, + "litellm_params": {"custom_llm_provider": "openai"}, + "standard_logging_object": { + "id": "resp_tc", + "call_type": "responses", + "metadata": {}, + }, + } + + def test_per_tool_call_attributes_emitted(self): + """function_call output items should produce per-tool-call span attributes.""" + otel = OpenTelemetry() + mock_span = MagicMock() + + response_obj = { + "id": "resp_tc", + "model": "gpt-4o", + "status": "completed", + "output": [ + { + "type": "function_call", + "name": "get_weather", + "call_id": "call_abc", + "arguments": '{"location": "SF"}', + } + ], + } + + otel.set_attributes(span=mock_span, kwargs=self._base_kwargs(), response_obj=response_obj) + + # Verify per-tool-call attributes were set (same format as choices branch) + attr_names = [call[0][0] for call in mock_span.set_attribute.call_args_list] + tool_call_attrs = [a for a in attr_names if "function_call" in a] + self.assertTrue(len(tool_call_attrs) > 0, "Per-tool-call span attributes should be emitted") + + # Verify the name attribute specifically + mock_span.set_attribute.assert_any_call( + "gen_ai.completion.0.function_call.name", "get_weather" + ) + mock_span.set_attribute.assert_any_call( + "gen_ai.completion.0.function_call.arguments", '{"location": "SF"}' + ) + + def test_multiple_tool_calls_indexed(self): + """Multiple function_call items should be indexed correctly.""" + otel = OpenTelemetry() + mock_span = MagicMock() + + response_obj = { + "id": "resp_tc2", + "model": "gpt-4o", + "status": "completed", + "output": [ + { + "type": "function_call", + "name": "get_weather", + "call_id": "call_1", + "arguments": "{}", + }, + { + "type": "function_call", + "name": "get_time", + "call_id": "call_2", + "arguments": "{}", + }, + ], + } + + otel.set_attributes(span=mock_span, kwargs=self._base_kwargs(), response_obj=response_obj) + + mock_span.set_attribute.assert_any_call( + "gen_ai.completion.0.function_call.name", "get_weather" + ) + mock_span.set_attribute.assert_any_call( + "gen_ai.completion.1.function_call.name", "get_time" + ) From 982fed46321041c4bc46d02312aa731d6662bd88 Mon Sep 17 00:00:00 2001 From: KunalG67 Date: Tue, 28 Apr 2026 18:09:12 +0530 Subject: [PATCH 08/28] fix(ovhcloud): handle reasoning field migration in non-streaming responses Adds transform_response to OVHCloudChatConfig to normalise the new easoning field to easoning_content in non-streaming responses, matching the existing streaming fix in chunk_parser. Addresses maintainer feedback on #26595 --- litellm/llms/ovhcloud/chat/transformation.py | 50 ++++++++++++++++-- .../test_ovhcloud_chat_transformation.py | 52 ++++++++++++++++++- 2 files changed, 97 insertions(+), 5 deletions(-) diff --git a/litellm/llms/ovhcloud/chat/transformation.py b/litellm/llms/ovhcloud/chat/transformation.py index 4100c548f2..77d3683566 100644 --- a/litellm/llms/ovhcloud/chat/transformation.py +++ b/litellm/llms/ovhcloud/chat/transformation.py @@ -5,17 +5,17 @@ Our unified API follows the OpenAI standard. More information on our website: https://endpoints.ai.cloud.ovh.net """ -from typing import Optional, Union, List +from typing import Any, Optional, Union, List import httpx -from litellm.utils import ModelResponseStream +from litellm.utils import ModelResponse, ModelResponseStream from litellm.llms.openai.chat.gpt_transformation import OpenAIGPTConfig from litellm.llms.ovhcloud.utils import OVHCloudException from litellm.llms.base_llm.base_model_iterator import BaseModelResponseIterator from litellm.llms.base_llm.chat.transformation import BaseLLMException +from litellm.llms.base_llm.chat.transformation import LiteLLMLoggingObj from litellm.types.llms.openai import AllMessageValues - class OVHCloudChatConfig(OpenAIGPTConfig): @property def custom_llm_provider(self) -> Optional[str]: @@ -75,6 +75,50 @@ class OVHCloudChatConfig(OpenAIGPTConfig): return response + def transform_response( + self, + model: str, + raw_response: httpx.Response, + model_response: ModelResponse, + logging_obj: LiteLLMLoggingObj, + request_data: dict, + messages: List[AllMessageValues], + optional_params: dict, + litellm_params: dict, + encoding: Any, + api_key: Optional[str] = None, + json_mode: Optional[bool] = None, + ) -> ModelResponse: + # Call parent to do standard OpenAI response parsing + model_response = super().transform_response( + model=model, + raw_response=raw_response, + model_response=model_response, + logging_obj=logging_obj, + request_data=request_data, + messages=messages, + optional_params=optional_params, + litellm_params=litellm_params, + encoding=encoding, + api_key=api_key, + json_mode=json_mode, + ) + + # OVHCloud field migration (deadline: 2026-05-11): + # `reasoning_content` is replaced by `reasoning` in non-streaming responses. + # Normalise to `reasoning_content` so downstream consumers + # see a consistent key during the transition window. + for choice in model_response.choices: + message = getattr(choice, "message", None) + if message is not None: + reasoning_new = getattr(message, "reasoning", None) + reasoning_legacy = getattr(message, "reasoning_content", None) + if reasoning_new is not None and reasoning_legacy is None: + message.reasoning_content = reasoning_new + + return model_response + + class OVHCloudChatCompletionStreamingHandler(BaseModelResponseIterator): """ Handler for OVHCloud AI Endpoints streaming chat completion responses diff --git a/tests/test_litellm/llms/ovhcloud/test_ovhcloud_chat_transformation.py b/tests/test_litellm/llms/ovhcloud/test_ovhcloud_chat_transformation.py index 88ce3b4c29..b112f6d87f 100644 --- a/tests/test_litellm/llms/ovhcloud/test_ovhcloud_chat_transformation.py +++ b/tests/test_litellm/llms/ovhcloud/test_ovhcloud_chat_transformation.py @@ -4,7 +4,7 @@ Unit tests for OVHCloud AI Endpoints chat integration. import os import sys - +import litellm import pytest from litellm.llms.ovhcloud.utils import OVHCloudException @@ -364,4 +364,52 @@ class TestOVHCloudReasoningFieldMigration: ], } result = handler.chunk_parser(chunk) - assert result.choices[0]["delta"]["reasoning_content"] == "legacy field" \ No newline at end of file + assert result.choices[0]["delta"]["reasoning_content"] == "legacy field" + + + def test_non_streaming_new_reasoning_field(self): + """Non-streaming: new `reasoning` field should be mapped to `reasoning_content`.""" + from unittest.mock import MagicMock, patch + import json + + config = OVHCloudChatConfig() + + raw_response = MagicMock() + raw_response.status_code = 200 + raw_response.headers = {"Content-Type": "application/json"} + raw_response.text = json.dumps({ + "id": "test-id", + "object": "chat.completion", + "created": 1234567890, + "model": "test-model", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "Hello!", + "reasoning": "Let me think...", + }, + "finish_reason": "stop", + } + ], + "usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}, + }) + raw_response.json.return_value = json.loads(raw_response.text) + + model_response = litellm.ModelResponse() + + result = config.transform_response( + model="ovhcloud/test-model", + raw_response=raw_response, + model_response=model_response, + logging_obj=MagicMock(), + request_data={}, + messages=[], + optional_params={}, + litellm_params={}, + encoding=None, + api_key="test-key", + ) + + assert result.choices[0].message.reasoning_content == "Let me think..." \ No newline at end of file From 8f48d880da974e349deb63a47363a12c618a5301 Mon Sep 17 00:00:00 2001 From: KunalG67 Date: Tue, 28 Apr 2026 18:25:11 +0530 Subject: [PATCH 09/28] style: apply black formatting to ovhcloud chat transformation --- litellm/llms/ovhcloud/chat/transformation.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/litellm/llms/ovhcloud/chat/transformation.py b/litellm/llms/ovhcloud/chat/transformation.py index 77d3683566..b5752d1630 100644 --- a/litellm/llms/ovhcloud/chat/transformation.py +++ b/litellm/llms/ovhcloud/chat/transformation.py @@ -16,6 +16,7 @@ from litellm.llms.base_llm.chat.transformation import BaseLLMException from litellm.llms.base_llm.chat.transformation import LiteLLMLoggingObj from litellm.types.llms.openai import AllMessageValues + class OVHCloudChatConfig(OpenAIGPTConfig): @property def custom_llm_provider(self) -> Optional[str]: @@ -74,7 +75,6 @@ class OVHCloudChatConfig(OpenAIGPTConfig): response.update(extra_body) return response - def transform_response( self, model: str, @@ -116,7 +116,7 @@ class OVHCloudChatConfig(OpenAIGPTConfig): if reasoning_new is not None and reasoning_legacy is None: message.reasoning_content = reasoning_new - return model_response + return model_response class OVHCloudChatCompletionStreamingHandler(BaseModelResponseIterator): From 9928618788389e623aa0c57b9249d084bfe72482 Mon Sep 17 00:00:00 2001 From: Aneesh-Fiddler Date: Tue, 28 Apr 2026 19:58:58 +0530 Subject: [PATCH 10/28] fix: remove duplicate gen_ai.response.finish_reasons block --- litellm/integrations/opentelemetry.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/litellm/integrations/opentelemetry.py b/litellm/integrations/opentelemetry.py index 90e647d86b..d116fc4465 100644 --- a/litellm/integrations/opentelemetry.py +++ b/litellm/integrations/opentelemetry.py @@ -1824,15 +1824,6 @@ class OpenTelemetry(CustomLogger): value=safe_dumps([status]), ) - # Extract finish reason from ResponsesAPIResponse.status - status = response_obj.get("status") - if status: - self.safe_set_attribute( - span=span, - key=SpanAttributes.GEN_AI_RESPONSE_FINISH_REASONS.value, - value=safe_dumps([status]), - ) - except Exception as e: self.handle_callback_failure( callback_name=self.callback_name or "opentelemetry" From 90bcd232c37389397cdcb763737f150899f1f722 Mon Sep 17 00:00:00 2001 From: KunalG67 Date: Tue, 28 Apr 2026 23:09:17 +0530 Subject: [PATCH 11/28] fix(ovhcloud): remove dead transform_response override The parent OpenAIGPTConfig already handles reasoning->reasoning_content for non-streaming via _extract_reasoning_content. The override was dead code giving false confidence. Streaming fix in chunk_parser is the only change needed for chat completions. Addresses Agent Shin review feedback on #26595 --- litellm/llms/ovhcloud/chat/transformation.py | 47 ++---------------- .../test_ovhcloud_chat_transformation.py | 48 +------------------ 2 files changed, 4 insertions(+), 91 deletions(-) diff --git a/litellm/llms/ovhcloud/chat/transformation.py b/litellm/llms/ovhcloud/chat/transformation.py index b5752d1630..140cb85532 100644 --- a/litellm/llms/ovhcloud/chat/transformation.py +++ b/litellm/llms/ovhcloud/chat/transformation.py @@ -5,15 +5,15 @@ Our unified API follows the OpenAI standard. More information on our website: https://endpoints.ai.cloud.ovh.net """ -from typing import Any, Optional, Union, List +from typing import Optional, Union, List import httpx -from litellm.utils import ModelResponse, ModelResponseStream +from litellm.utils import ModelResponseStream from litellm.llms.openai.chat.gpt_transformation import OpenAIGPTConfig from litellm.llms.ovhcloud.utils import OVHCloudException from litellm.llms.base_llm.base_model_iterator import BaseModelResponseIterator from litellm.llms.base_llm.chat.transformation import BaseLLMException -from litellm.llms.base_llm.chat.transformation import LiteLLMLoggingObj + from litellm.types.llms.openai import AllMessageValues @@ -75,48 +75,7 @@ class OVHCloudChatConfig(OpenAIGPTConfig): response.update(extra_body) return response - def transform_response( - self, - model: str, - raw_response: httpx.Response, - model_response: ModelResponse, - logging_obj: LiteLLMLoggingObj, - request_data: dict, - messages: List[AllMessageValues], - optional_params: dict, - litellm_params: dict, - encoding: Any, - api_key: Optional[str] = None, - json_mode: Optional[bool] = None, - ) -> ModelResponse: - # Call parent to do standard OpenAI response parsing - model_response = super().transform_response( - model=model, - raw_response=raw_response, - model_response=model_response, - logging_obj=logging_obj, - request_data=request_data, - messages=messages, - optional_params=optional_params, - litellm_params=litellm_params, - encoding=encoding, - api_key=api_key, - json_mode=json_mode, - ) - # OVHCloud field migration (deadline: 2026-05-11): - # `reasoning_content` is replaced by `reasoning` in non-streaming responses. - # Normalise to `reasoning_content` so downstream consumers - # see a consistent key during the transition window. - for choice in model_response.choices: - message = getattr(choice, "message", None) - if message is not None: - reasoning_new = getattr(message, "reasoning", None) - reasoning_legacy = getattr(message, "reasoning_content", None) - if reasoning_new is not None and reasoning_legacy is None: - message.reasoning_content = reasoning_new - - return model_response class OVHCloudChatCompletionStreamingHandler(BaseModelResponseIterator): diff --git a/tests/test_litellm/llms/ovhcloud/test_ovhcloud_chat_transformation.py b/tests/test_litellm/llms/ovhcloud/test_ovhcloud_chat_transformation.py index b112f6d87f..40d57c76d0 100644 --- a/tests/test_litellm/llms/ovhcloud/test_ovhcloud_chat_transformation.py +++ b/tests/test_litellm/llms/ovhcloud/test_ovhcloud_chat_transformation.py @@ -4,7 +4,7 @@ Unit tests for OVHCloud AI Endpoints chat integration. import os import sys -import litellm + import pytest from litellm.llms.ovhcloud.utils import OVHCloudException @@ -367,49 +367,3 @@ class TestOVHCloudReasoningFieldMigration: assert result.choices[0]["delta"]["reasoning_content"] == "legacy field" - def test_non_streaming_new_reasoning_field(self): - """Non-streaming: new `reasoning` field should be mapped to `reasoning_content`.""" - from unittest.mock import MagicMock, patch - import json - - config = OVHCloudChatConfig() - - raw_response = MagicMock() - raw_response.status_code = 200 - raw_response.headers = {"Content-Type": "application/json"} - raw_response.text = json.dumps({ - "id": "test-id", - "object": "chat.completion", - "created": 1234567890, - "model": "test-model", - "choices": [ - { - "index": 0, - "message": { - "role": "assistant", - "content": "Hello!", - "reasoning": "Let me think...", - }, - "finish_reason": "stop", - } - ], - "usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}, - }) - raw_response.json.return_value = json.loads(raw_response.text) - - model_response = litellm.ModelResponse() - - result = config.transform_response( - model="ovhcloud/test-model", - raw_response=raw_response, - model_response=model_response, - logging_obj=MagicMock(), - request_data={}, - messages=[], - optional_params={}, - litellm_params={}, - encoding=None, - api_key="test-key", - ) - - assert result.choices[0].message.reasoning_content == "Let me think..." \ No newline at end of file From d73e24c1f93664dc147ce8ef2c5a2ffcef6f9eb7 Mon Sep 17 00:00:00 2001 From: KunalG67 Date: Tue, 28 Apr 2026 23:19:13 +0530 Subject: [PATCH 12/28] fix(ovhcloud): remove dead transform_response override, parent already handles non-streaming via _extract_reasoning_content --- litellm/llms/ovhcloud/chat/transformation.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/litellm/llms/ovhcloud/chat/transformation.py b/litellm/llms/ovhcloud/chat/transformation.py index 140cb85532..62f51f1e9d 100644 --- a/litellm/llms/ovhcloud/chat/transformation.py +++ b/litellm/llms/ovhcloud/chat/transformation.py @@ -76,8 +76,6 @@ class OVHCloudChatConfig(OpenAIGPTConfig): return response - - class OVHCloudChatCompletionStreamingHandler(BaseModelResponseIterator): """ Handler for OVHCloud AI Endpoints streaming chat completion responses From 53102529ca6e817cd398f23bcc0bd63501c9f169 Mon Sep 17 00:00:00 2001 From: Aneesh-Fiddler Date: Wed, 29 Apr 2026 19:00:43 +0530 Subject: [PATCH 13/28] ci: retrigger checks after retargeting to litellm_oss_staging_04_27_2026 From c319a19c25d746dbcfd27ab3c69984f5d66eb358 Mon Sep 17 00:00:00 2001 From: Aneesh-Fiddler Date: Thu, 30 Apr 2026 11:04:08 +0530 Subject: [PATCH 14/28] fix: handle raw Pydantic v2 models from openai SDK in output transformation The openai SDK returns ResponseOutputMessage and ResponseOutputText as raw Pydantic v2 models that lack .get() (unlike LiteLLM's own wrapper objects). Add a _to_dict() helper that normalizes plain dicts, BaseLiteLLMOpenAIResponseObject (has .get()), and raw Pydantic models (has .model_dump()) into a consistent dict interface. --- litellm/integrations/opentelemetry.py | 54 +++++++++++++++++++-------- 1 file changed, 39 insertions(+), 15 deletions(-) diff --git a/litellm/integrations/opentelemetry.py b/litellm/integrations/opentelemetry.py index d116fc4465..a12d67de4b 100644 --- a/litellm/integrations/opentelemetry.py +++ b/litellm/integrations/opentelemetry.py @@ -1794,15 +1794,13 @@ class OpenTelemetry(CustomLogger): # _tool_calls_kv_pair. tool_calls = [] for out_item in output_items: - if ( - hasattr(out_item, "get") - and out_item.get("type") == "function_call" - ): + item_d = self._to_dict(out_item) + if item_d and item_d.get("type") == "function_call": tool_calls.append( { "function": { - "name": out_item.get("name", ""), - "arguments": out_item.get("arguments", ""), + "name": item_d.get("name", ""), + "arguments": item_d.get("arguments", ""), } } ) @@ -1919,6 +1917,31 @@ class OpenTelemetry(CustomLogger): transformed.append(transformed_msg) return transformed + @staticmethod + def _to_dict(obj) -> Optional[dict]: + """Normalize an object to a plain dict. + + Handles three forms that appear in practice: + + 1. Plain ``dict`` — returned as-is. + 2. LiteLLM's ``BaseLiteLLMOpenAIResponseObject`` — exposes a + ``.get()`` method that delegates to ``__dict__``. + 3. Raw Pydantic v2 models from the ``openai`` SDK (e.g. + ``ResponseOutputMessage``, ``ResponseOutputText``) — these do + **not** have ``.get()`` but do have ``.model_dump()``. + + Returns ``None`` for anything else so callers can skip it. + """ + if isinstance(obj, dict): + return obj + if hasattr(obj, "get"): + # BaseLiteLLMOpenAIResponseObject duck-type + return obj # type: ignore[return-value] + if hasattr(obj, "model_dump"): + # Raw Pydantic v2 model (e.g. openai SDK types) + return obj.model_dump() # type: ignore[union-attr] + return None + def _transform_responses_api_output_to_otel(self, output: List) -> List[dict]: """ Transform Responses API output items into OTEL GenAI 1.38 format. @@ -1928,24 +1951,25 @@ class OpenTelemetry(CustomLogger): ``content`` list of ``OutputText`` objects with ``type="output_text"`` and ``text`` fields. - Items may be plain dicts or Pydantic model instances (e.g. - ``ResponseOutputMessage``, ``ResponseFunctionToolCall``). Both - expose a ``.get()`` method via ``BaseLiteLLMOpenAIResponseObject``, - so we use ``hasattr(item, "get")`` rather than ``isinstance(item, - dict)`` to accept either form. + Items may be plain dicts, LiteLLM wrapper objects (with ``.get()``), + or raw Pydantic v2 models from the ``openai`` SDK (with + ``.model_dump()``). We normalize each item to a dict via + ``_to_dict`` before processing. This method converts them to the same ``{"role": ..., "parts": [...]}`` format used by ``_transform_choices_to_otel_semantic_conventions``. """ transformed = [] - for item in output: - if not hasattr(item, "get"): + for raw_item in output: + item = self._to_dict(raw_item) + if item is None: continue if item.get("type") == "message": role = item.get("role", "assistant") parts = [] - for content in item.get("content", []): - if not hasattr(content, "get"): + for raw_content in item.get("content", []): + content = self._to_dict(raw_content) + if content is None: continue if content.get("type") == "output_text": text = content.get("text", "") From 209bd0b9061f7b602d148f8d6da6ffddc2014c87 Mon Sep 17 00:00:00 2001 From: pnookala-godaddy Date: Tue, 5 May 2026 12:18:44 -0700 Subject: [PATCH 15/28] fix(proxy): sort spend updates to prevent DB deadlocks Iterate user/key/team/team_member/org/end_user/tag spend dicts in sorted order inside each Prisma transaction so concurrent pods acquire row locks in the same order, avoiding PostgreSQL deadlocks under load. --- litellm/proxy/db/db_spend_update_writer.py | 48 +++--- litellm/proxy/utils.py | 8 +- .../proxy/db/test_db_spend_update_writer.py | 143 ++++++++++++++++++ 3 files changed, 174 insertions(+), 25 deletions(-) diff --git a/litellm/proxy/db/db_spend_update_writer.py b/litellm/proxy/db/db_spend_update_writer.py index c06e1850d9..418fc2d2f0 100644 --- a/litellm/proxy/db/db_spend_update_writer.py +++ b/litellm/proxy/db/db_spend_update_writer.py @@ -1133,10 +1133,12 @@ class DBSpendUpdateWriter: timeout=timedelta(seconds=60) ) as transaction: async with transaction.batch_() as batcher: - for ( - user_id, - response_cost, - ) in user_list_transactions.items(): + # Sort by ID for consistent lock ordering across pods to prevent deadlocks. + # batch_() issues statements sequentially within the tx, so iteration + # order = lock acquisition order. + for user_id, response_cost in sorted( + user_list_transactions.items() + ): batcher.litellm_usertable.update_many( where={"user_id": user_id}, data={"spend": {"increment": response_cost}}, @@ -1188,10 +1190,10 @@ class DBSpendUpdateWriter: timeout=timedelta(seconds=60) ) as transaction: async with transaction.batch_() as batcher: - for ( - token, - response_cost, - ) in key_list_transactions.items(): + # Sort by token for consistent lock ordering across pods to prevent deadlocks. + for token, response_cost in sorted( + key_list_transactions.items() + ): batcher.litellm_verificationtoken.update_many( # 'update_many' prevents error from being raised if no row exists where={"token": token}, data={ @@ -1232,10 +1234,10 @@ class DBSpendUpdateWriter: timeout=timedelta(seconds=60) ) as transaction: async with transaction.batch_() as batcher: - for ( - team_id, - response_cost, - ) in team_list_transactions.items(): + # Sort by team_id for consistent lock ordering across pods to prevent deadlocks. + for team_id, response_cost in sorted( + team_list_transactions.items() + ): verbose_proxy_logger.debug( "Updating spend for team id={} by {}".format( team_id, response_cost @@ -1290,10 +1292,11 @@ class DBSpendUpdateWriter: timeout=timedelta(seconds=60) ) as transaction: async with transaction.batch_() as batcher: - for ( - key, - response_cost, - ) in team_member_list_transactions.items(): + # Sort by composite key for consistent lock ordering across pods to prevent deadlocks. + # Key format "team_id::::user_id::" makes the string sort equivalent to sorting by (team_id, user_id). + for key, response_cost in sorted( + team_member_list_transactions.items() + ): # key is "team_id::::user_id::" team_id = key.split("::")[1] user_id = key.split("::")[3] @@ -1350,10 +1353,10 @@ class DBSpendUpdateWriter: timeout=timedelta(seconds=60) ) as transaction: async with transaction.batch_() as batcher: - for ( - org_id, - response_cost, - ) in org_list_transactions.items(): + # Sort by org_id for consistent lock ordering across pods to prevent deadlocks. + for org_id, response_cost in sorted( + org_list_transactions.items() + ): batcher.litellm_organizationtable.update_many( # 'update_many' prevents error from being raised if no row exists where={"organization_id": org_id}, data={"spend": {"increment": response_cost}}, @@ -1441,7 +1444,10 @@ class DBSpendUpdateWriter: timeout=timedelta(seconds=60) ) as transaction: async with transaction.batch_() as batcher: - for entity_id, response_cost in transactions.items(): + # Sort by entity_id for consistent lock ordering across pods to prevent deadlocks. + for entity_id, response_cost in sorted( + transactions.items() + ): verbose_proxy_logger.debug( f"Updating spend for {entity_name} {where_field}={entity_id} by {response_cost}" ) diff --git a/litellm/proxy/utils.py b/litellm/proxy/utils.py index d2dfa17751..800a4be37a 100644 --- a/litellm/proxy/utils.py +++ b/litellm/proxy/utils.py @@ -4874,10 +4874,10 @@ class ProxyUpdateSpend: timeout=timedelta(seconds=60) ) as transaction: async with transaction.batch_() as batcher: - for ( - end_user_id, - response_cost, - ) in end_user_list_transactions.items(): + # Sort by end_user_id for consistent lock ordering across pods to prevent deadlocks. + for end_user_id, response_cost in sorted( + end_user_list_transactions.items() + ): if litellm.max_end_user_budget is not None: pass batcher.litellm_endusertable.upsert( diff --git a/tests/test_litellm/proxy/db/test_db_spend_update_writer.py b/tests/test_litellm/proxy/db/test_db_spend_update_writer.py index 4d58434934..9d4d3c4a56 100644 --- a/tests/test_litellm/proxy/db/test_db_spend_update_writer.py +++ b/tests/test_litellm/proxy/db/test_db_spend_update_writer.py @@ -1508,3 +1508,146 @@ async def test_commit_spend_updates_uses_pipeline(): mock_redis_update_buffer.get_all_daily_end_user_spend_update_transactions_from_redis_buffer.assert_not_called() mock_redis_update_buffer.get_all_daily_agent_spend_update_transactions_from_redis_buffer.assert_not_called() mock_redis_update_buffer.get_all_daily_tag_spend_update_transactions_from_redis_buffer.assert_not_called() + + +@pytest.mark.parametrize( + "bucket_name,input_dict,table_attr,method_name,where_key,expected_order", + [ + pytest.param( + "user_list_transactions", + {"user_c": 0.1, "user_a": 0.2, "user_b": 0.3}, + "litellm_usertable", + "update_many", + "user_id", + ["user_a", "user_b", "user_c"], + id="user", + ), + pytest.param( + "key_list_transactions", + {"tok_c": 0.1, "tok_a": 0.2, "tok_b": 0.3}, + "litellm_verificationtoken", + "update_many", + "token", + ["tok_a", "tok_b", "tok_c"], + id="key", + ), + pytest.param( + "team_list_transactions", + {"team_c": 0.1, "team_a": 0.2, "team_b": 0.3}, + "litellm_teamtable", + "update_many", + "team_id", + ["team_a", "team_b", "team_c"], + id="team", + ), + pytest.param( + "team_member_list_transactions", + { + "team_id::team_c::user_id::user_x": 0.1, + "team_id::team_a::user_id::user_x": 0.2, + "team_id::team_b::user_id::user_x": 0.3, + }, + "litellm_teammembership", + "update_many", + "team_id", + ["team_a", "team_b", "team_c"], + id="team_member", + ), + pytest.param( + "org_list_transactions", + {"org_c": 0.1, "org_a": 0.2, "org_b": 0.3}, + "litellm_organizationtable", + "update_many", + "organization_id", + ["org_a", "org_b", "org_c"], + id="org", + ), + pytest.param( + "end_user_list_transactions", + {"eu_c": 0.1, "eu_a": 0.2, "eu_b": 0.3}, + "litellm_endusertable", + "upsert", + "user_id", + ["eu_a", "eu_b", "eu_c"], + id="end_user", + ), + pytest.param( + "tag_list_transactions", + {"prod": 0.1, "customer-x": 0.2, "test": 0.3}, + "litellm_tagtable", + "update_many", + "tag_name", + ["customer-x", "prod", "test"], + id="tag", + ), + pytest.param( + "agent_list_transactions", + {"agent_c": 0.1, "agent_a": 0.2, "agent_b": 0.3}, + "litellm_agentstable", + "update_many", + "agent_id", + ["agent_a", "agent_b", "agent_c"], + id="agent", + ), + ], +) +@pytest.mark.asyncio +async def test_commit_spend_updates_iterates_in_sorted_order( + bucket_name, input_dict, table_attr, method_name, where_key, expected_order +): + """ + Every spend-bucket code path in _commit_spend_updates_to_db must iterate + in sorted order so concurrent pods acquire row locks in the same order + and avoid PostgreSQL deadlocks. Covers the 5 direct loops (user/key/team/ + team_member/org), the end_user path in ProxyUpdateSpend.update_end_user_spend, + and the shared _update_entity_spend_in_db helper (tag, agent). + """ + db_writer = DBSpendUpdateWriter() + + captured_where_values = [] + + def capture(*, where, data): + captured_where_values.append(where[where_key]) + + mock_batcher = MagicMock() + table_mock = MagicMock() + setattr(table_mock, method_name, MagicMock(side_effect=capture)) + setattr(mock_batcher, table_attr, table_mock) + + mock_transaction = AsyncMock() + mock_transaction.__aenter__ = AsyncMock(return_value=mock_transaction) + mock_transaction.__aexit__ = AsyncMock(return_value=False) + mock_transaction.batch_ = MagicMock( + return_value=AsyncMock( + __aenter__=AsyncMock(return_value=mock_batcher), + __aexit__=AsyncMock(return_value=False), + ) + ) + + mock_prisma_client = MagicMock() + mock_prisma_client.db = MagicMock() + mock_prisma_client.db.tx = MagicMock(return_value=mock_transaction) + + mock_proxy_logging = MagicMock() + mock_proxy_logging.call_details = {} + + buckets = { + "user_list_transactions": {}, + "end_user_list_transactions": {}, + "key_list_transactions": {}, + "team_list_transactions": {}, + "team_member_list_transactions": {}, + "org_list_transactions": {}, + "tag_list_transactions": {}, + "agent_list_transactions": {}, + } + buckets[bucket_name] = input_dict + + await db_writer._commit_spend_updates_to_db( + prisma_client=mock_prisma_client, + n_retry_times=3, + proxy_logging_obj=mock_proxy_logging, + db_spend_update_transactions=buckets, + ) + + assert captured_where_values == expected_order From 2993e45ad18e7508d7f4a262608006bc787082c5 Mon Sep 17 00:00:00 2001 From: Michael Riad Zaky Date: Tue, 5 May 2026 11:37:12 -0700 Subject: [PATCH 16/28] allow non-admin roles on /compliance/* read routes --- litellm/proxy/_types.py | 10 +++++- .../proxy/auth/test_route_checks.py | 33 +++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index c6653a722d..7a049dcc5d 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -656,6 +656,13 @@ class LiteLLMRoutes(enum.Enum): "/health/services", ] + info_routes + # Stateless validators on caller-supplied log data; source logs are + # already accessible via spend_tracking_routes, so no scope expansion. + compliance_check_routes = [ + "/compliance/eu-ai-act", + "/compliance/gdpr", + ] + # Routes in `global_spend_tracking_routes` return proxy-wide spend across # every team, customer, and api_key. They are intentionally NOT included # here — non-admin roles must not see other tenants' spend. Admin roles go @@ -675,9 +682,10 @@ class LiteLLMRoutes(enum.Enum): ] + spend_tracking_routes + key_management_routes + + compliance_check_routes ) - internal_user_view_only_routes = spend_tracking_routes + internal_user_view_only_routes = spend_tracking_routes + compliance_check_routes self_managed_routes = [ "/team/member_add", diff --git a/tests/test_litellm/proxy/auth/test_route_checks.py b/tests/test_litellm/proxy/auth/test_route_checks.py index cf6feabf85..3e0b1b739e 100644 --- a/tests/test_litellm/proxy/auth/test_route_checks.py +++ b/tests/test_litellm/proxy/auth/test_route_checks.py @@ -53,6 +53,39 @@ def test_non_admin_config_update_route_rejected(): assert "Your role=internal_user" in str(exc_info.value) +@pytest.mark.parametrize( + "role", + [ + LitellmUserRoles.INTERNAL_USER.value, + LitellmUserRoles.INTERNAL_USER_VIEW_ONLY.value, + ], +) +@pytest.mark.parametrize( + "route", + ["/compliance/eu-ai-act", "/compliance/gdpr"], +) +def test_compliance_routes_open_to_non_admin_roles(role, route): + """Compliance routes are stateless validators on caller-supplied log data + — both non-admin internal_user roles can call them.""" + user_obj = LiteLLM_UserTable( + user_id="test_user", + user_email="test@example.com", + user_role=role, + ) + valid_token = UserAPIKeyAuth(user_id="test_user", user_role=role) + request = MagicMock(spec=Request) + request.query_params = {} + + RouteChecks.non_proxy_admin_allowed_routes_check( + user_obj=user_obj, + _user_role=role, + route=route, + request=request, + valid_token=valid_token, + request_data={}, + ) + + def test_proxy_admin_viewer_config_update_route_rejected(): """Test that proxy admin viewer users are rejected when trying to call /config/update""" From 85d4d96c1bf1d823b1c66d5464768d568302b3f0 Mon Sep 17 00:00:00 2001 From: michelligabriele Date: Wed, 6 May 2026 00:28:42 +0200 Subject: [PATCH 17/28] fix(proxy): preserve HTTP operations when injecting WebSocket stubs into OpenAPI schema --- litellm/proxy/proxy_server.py | 88 ++++++++------ .../proxy/test_openapi_schema_validation.py | 107 ++++++++++++++++++ 2 files changed, 158 insertions(+), 37 deletions(-) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index a5905765c6..b136464fd2 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -1057,6 +1057,52 @@ vertex_live_passthrough_vertex_base = VertexBase() from fastapi.routing import APIWebSocketRoute +def _inject_websocket_stubs_into_openapi_schema( + openapi_schema: dict, websocket_routes: list +) -> dict: + """ + Add a synthetic GET stub for each WebSocket route so it appears in Swagger UI. + + Merges into any existing path entry rather than replacing it — a WebSocket route + that shares its path with an HTTP route must not erase the HTTP operation. If + a "get" operation is already documented on the path, the WebSocket stub is + skipped to preserve the real GET. + """ + for route in websocket_routes: + base_path = route.path.split("{")[0].rstrip("?") + + parameters = [] + try: + if hasattr(route, "dependant") and route.dependant is not None: + # Handle both FastAPI <0.120 and >=0.120 + query_params = getattr(route.dependant, "query_params", []) + if query_params: + for param in query_params: + parameters.append( + { + "name": param.name, + "in": "query", + "required": param.required, + "schema": {"type": "string"}, + } + ) + except (AttributeError, TypeError): + pass + + path_entry = openapi_schema["paths"].setdefault(base_path, {}) + if "get" not in path_entry: + path_entry["get"] = { + "summary": f"WebSocket: {route.name or base_path}", + "description": "WebSocket connection endpoint", + "operationId": f"websocket_{route.name or base_path.replace('/', '_')}", + "parameters": parameters, + "responses": {"101": {"description": "WebSocket Protocol Switched"}}, + "tags": ["WebSocket"], + } + + return openapi_schema + + def get_openapi_schema(): if app.openapi_schema: return app.openapi_schema @@ -1079,43 +1125,11 @@ def get_openapi_schema(): route for route in app.routes if isinstance(route, APIWebSocketRoute) ] - # Add each WebSocket route to the schema - for route in websocket_routes: - # Get the base path without query parameters - base_path = route.path.split("{")[0].rstrip("?") - - # Extract parameters from the route - parameters = [] - try: - if hasattr(route, "dependant") and route.dependant is not None: - # Handle both FastAPI <0.120 and >=0.120 - query_params = getattr(route.dependant, "query_params", []) - if query_params: - for param in query_params: - parameters.append( - { - "name": param.name, - "in": "query", - "required": param.required, - "schema": { - "type": "string" - }, # You can make this more specific if needed - } - ) - except (AttributeError, TypeError): - # If we can't access query_params, continue without them - pass - - openapi_schema["paths"][base_path] = { - "get": { - "summary": f"WebSocket: {route.name or base_path}", - "description": "WebSocket connection endpoint", - "operationId": f"websocket_{route.name or base_path.replace('/', '_')}", - "parameters": parameters, - "responses": {"101": {"description": "WebSocket Protocol Switched"}}, - "tags": ["WebSocket"], - } - } + # Add a synthetic GET stub for each so they render in Swagger UI, + # without clobbering existing HTTP operations on the same path. + openapi_schema = _inject_websocket_stubs_into_openapi_schema( + openapi_schema, websocket_routes + ) # Add LLM API request schema bodies for documentation from litellm.proxy.common_utils.custom_openapi_spec import CustomOpenAPISpec diff --git a/tests/test_litellm/proxy/test_openapi_schema_validation.py b/tests/test_litellm/proxy/test_openapi_schema_validation.py index 68d537b259..b44edc8a3b 100644 --- a/tests/test_litellm/proxy/test_openapi_schema_validation.py +++ b/tests/test_litellm/proxy/test_openapi_schema_validation.py @@ -140,3 +140,110 @@ class TestCredentialEndpointsOpenAPISchema: assert ( "credential_name" in sig.parameters ), "get_credential_by_name must have a credential_name parameter" + + +class TestWebSocketStubInjection: + """ + Regression test for the v1.82.3 bug where adding a WebSocket route on a path + that already had an HTTP route silently dropped the HTTP operation from the + OpenAPI schema. + + Related case: 2026-05-05-madhu-swagger-responses-missing + """ + + def _make_fake_ws_route(self, path: str, name: str = "fake_ws"): + """Minimal stand-in for fastapi.routing.APIWebSocketRoute for the helper's purposes.""" + from types import SimpleNamespace + + return SimpleNamespace(path=path, name=name, dependant=None) + + def test_websocket_stub_does_not_clobber_existing_post(self): + """ + When a WebSocket route shares its path with an existing POST operation, + the POST must survive — the WebSocket stub is added alongside, not on top. + """ + from litellm.proxy.proxy_server import ( + _inject_websocket_stubs_into_openapi_schema, + ) + + schema = { + "paths": { + "/v1/responses": { + "post": {"summary": "responses_api", "operationId": "responses_api"} + } + } + } + ws_routes = [self._make_fake_ws_route("/v1/responses", name="responses_ws")] + + result = _inject_websocket_stubs_into_openapi_schema(schema, ws_routes) + + assert ( + "post" in result["paths"]["/v1/responses"] + ), "POST operation must be preserved when a WebSocket route shares the path" + assert ( + result["paths"]["/v1/responses"]["post"]["operationId"] == "responses_api" + ) + assert ( + "get" in result["paths"]["/v1/responses"] + ), "WebSocket stub should also be added under 'get'" + assert result["paths"]["/v1/responses"]["get"]["tags"] == ["WebSocket"] + + def test_websocket_stub_added_when_path_is_new(self): + """ + When a WebSocket route's path is not already in the schema, the stub + creates a fresh entry — preserving the original behavior for WebSocket-only + paths. + """ + from litellm.proxy.proxy_server import ( + _inject_websocket_stubs_into_openapi_schema, + ) + + schema = {"paths": {}} + ws_routes = [self._make_fake_ws_route("/ws_only", name="ws_only")] + + result = _inject_websocket_stubs_into_openapi_schema(schema, ws_routes) + + assert "/ws_only" in result["paths"] + assert "get" in result["paths"]["/ws_only"] + assert result["paths"]["/ws_only"]["get"]["tags"] == ["WebSocket"] + + def test_websocket_stub_skipped_when_existing_get(self): + """ + If a real GET is already documented on the path, the WebSocket stub is + skipped — a real operation always wins over the synthetic stub. This + closes the same trap for future GET-vs-WebSocket collisions. + """ + from litellm.proxy.proxy_server import ( + _inject_websocket_stubs_into_openapi_schema, + ) + + schema = { + "paths": { + "/health": { + "get": {"summary": "health_check", "operationId": "real_get"} + } + } + } + ws_routes = [self._make_fake_ws_route("/health", name="health_ws")] + + result = _inject_websocket_stubs_into_openapi_schema(schema, ws_routes) + + assert ( + result["paths"]["/health"]["get"]["operationId"] == "real_get" + ), "Real GET must take precedence over WebSocket stub" + + def test_responses_post_routes_registered_on_router(self): + """ + Sanity check: the three POST routes for the responses API are still wired + on the responses router. Guards against accidental removal at the source. + """ + from litellm.proxy.response_api_endpoints.endpoints import router + + post_paths = { + route.path + for route in router.routes + if hasattr(route, "methods") + and "POST" in (route.methods or set()) + and route.path in {"/v1/responses", "/responses", "/openai/v1/responses"} + } + assert post_paths == {"/v1/responses", "/responses", "/openai/v1/responses"} From 062b5b31fb0d7e9f7cce989d22bf0c0561c83ea9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 6 May 2026 00:26:17 +0000 Subject: [PATCH 18/28] Add main module header comment Co-authored-by: ishaan-berri --- .evidence/main_header_repro.png | Bin 0 -> 61972 bytes litellm/main.py | 2 ++ tests/test_litellm/test_main_module_header.py | 13 +++++++++++++ 3 files changed, 15 insertions(+) create mode 100644 .evidence/main_header_repro.png create mode 100644 tests/test_litellm/test_main_module_header.py diff --git a/.evidence/main_header_repro.png b/.evidence/main_header_repro.png new file mode 100644 index 0000000000000000000000000000000000000000..d71ab04d02a56b3ad8f2e8015bf53ca13f8cd8d3 GIT binary patch literal 61972 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�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

H@QX+s59MD47n0_fW*KfNCqU)*DRd2 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 literal 0 HcmV?d00001 diff --git a/litellm/main.py b/litellm/main.py index 0553cf9d42..66d69e9fb5 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -1,3 +1,5 @@ +# LiteLLM main module: public completion, embedding, streaming, and moderation entrypoints. +# # +-----------------------------------------------+ # | | # | Give Feedback / Get Help | diff --git a/tests/test_litellm/test_main_module_header.py b/tests/test_litellm/test_main_module_header.py new file mode 100644 index 0000000000..a16e14e8c3 --- /dev/null +++ b/tests/test_litellm/test_main_module_header.py @@ -0,0 +1,13 @@ +from pathlib import Path + + +def test_main_py_starts_with_brief_file_description(): + repo_root = Path(__file__).resolve().parents[2] + main_py = repo_root / "litellm" / "main.py" + + first_two_lines = main_py.read_text(encoding="utf-8").splitlines()[:2] + + assert any( + "LiteLLM main module" in line and "entrypoints" in line + for line in first_two_lines + ) From b631863b13abe81c8de78a0787f3520d52427215 Mon Sep 17 00:00:00 2001 From: oss-agent-shin <279349115+oss-agent-shin@users.noreply.github.com> Date: Wed, 6 May 2026 00:42:49 +0000 Subject: [PATCH 19/28] Add utils module docstring Co-authored-by: ishaan-berri --- litellm/utils.py | 2 ++ tests/test_litellm/test_utils_module_docstring.py | 11 +++++++++++ 2 files changed, 13 insertions(+) create mode 100644 tests/test_litellm/test_utils_module_docstring.py diff --git a/litellm/utils.py b/litellm/utils.py index 019fbc2add..5589852ce4 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -1,3 +1,5 @@ +"""Utility helpers for LiteLLM core request handling and provider support.""" + # from __future__ import annotations must be the first non-comment statement from __future__ import annotations diff --git a/tests/test_litellm/test_utils_module_docstring.py b/tests/test_litellm/test_utils_module_docstring.py new file mode 100644 index 0000000000..ac99fb63fd --- /dev/null +++ b/tests/test_litellm/test_utils_module_docstring.py @@ -0,0 +1,11 @@ +import ast +from pathlib import Path + + +def test_utils_module_has_docstring(): + utils_path = Path(__file__).parents[2] / "litellm" / "utils.py" + module = ast.parse(utils_path.read_text()) + + assert ast.get_docstring(module) == ( + "Utility helpers for LiteLLM core request handling and provider support." + ) From f58f8927f27ced15d9e27dcb46cfef530d670f52 Mon Sep 17 00:00:00 2001 From: shivam Date: Thu, 7 May 2026 18:39:26 -0700 Subject: [PATCH 20/28] feat(guardrails): optional skip tool message in unified guardrail inputs Mirrors the system-message skip in PR #25481 for tool-role messages. Adds a global litellm.skip_tool_message_in_guardrail flag and a per-guardrail litellm_params.skip_tool_message_in_guardrail override, applied in the OpenAI and Anthropic chat translation handlers. Co-Authored-By: Claude Opus 4.7 (1M context) --- litellm/__init__.py | 1 + .../chat/guardrail_translation/handler.py | 12 +- .../base_llm/guardrail_translation/utils.py | 15 ++ .../chat/guardrail_translation/handler.py | 24 +++- .../proxy/guardrails/guardrail_registry.py | 5 + litellm/types/guardrails.py | 10 ++ .../test_unified_guardrail.py | 132 ++++++++++++++++++ 7 files changed, 192 insertions(+), 7 deletions(-) diff --git a/litellm/__init__.py b/litellm/__init__.py index cf05fc4c98..fd3d47ec15 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -206,6 +206,7 @@ add_user_information_to_llm_headers: Optional[bool] = ( ) store_audit_logs = False # Enterprise feature, allow users to see audit logs skip_system_message_in_guardrail: bool = False +skip_tool_message_in_guardrail: bool = False ### end of callbacks ############# email: Optional[str] = ( diff --git a/litellm/llms/anthropic/chat/guardrail_translation/handler.py b/litellm/llms/anthropic/chat/guardrail_translation/handler.py index 2bb82f227b..74dadee5ec 100644 --- a/litellm/llms/anthropic/chat/guardrail_translation/handler.py +++ b/litellm/llms/anthropic/chat/guardrail_translation/handler.py @@ -23,7 +23,9 @@ from litellm.llms.anthropic.experimental_pass_through.adapters.transformation im from litellm.llms.base_llm.guardrail_translation.base_translation import BaseTranslation from litellm.llms.base_llm.guardrail_translation.utils import ( effective_skip_system_message_for_guardrail, + effective_skip_tool_message_for_guardrail, openai_messages_without_system, + openai_messages_without_tool, ) from litellm.proxy.pass_through_endpoints.llm_provider_handlers.anthropic_passthrough_logging_handler import ( AnthropicPassthroughLoggingHandler, @@ -108,6 +110,7 @@ class AnthropicMessagesHandler(BaseTranslation): return data skip_system = effective_skip_system_message_for_guardrail(guardrail_to_apply) + skip_tool = effective_skip_tool_message_for_guardrail(guardrail_to_apply) chat_completion_compatible_request = self._translate_to_openai(data) @@ -117,6 +120,8 @@ class AnthropicMessagesHandler(BaseTranslation): ) if skip_system: structured_messages = openai_messages_without_system(structured_messages) + if skip_tool: + structured_messages = openai_messages_without_tool(structured_messages) texts_to_check: List[str] = [] images_to_check: List[str] = [] @@ -134,6 +139,7 @@ class AnthropicMessagesHandler(BaseTranslation): images_to_check=images_to_check, task_mappings=task_mappings, skip_system_message=skip_system, + skip_tool_message=skip_tool, ) # Step 2: Apply guardrail to all texts in batch @@ -198,13 +204,17 @@ class AnthropicMessagesHandler(BaseTranslation): images_to_check: List[str], task_mappings: List[Tuple[int, Optional[int]]], skip_system_message: bool = False, + skip_tool_message: bool = False, ) -> None: """ Extract text content and images from a message. Override this method to customize text/image extraction logic. """ - if skip_system_message and str(message.get("role") or "").lower() == "system": + role = str(message.get("role") or "").lower() + if skip_system_message and role == "system": + return + if skip_tool_message and role == "tool": return content = message.get("content", None) diff --git a/litellm/llms/base_llm/guardrail_translation/utils.py b/litellm/llms/base_llm/guardrail_translation/utils.py index cdd2d77537..97ece6b5ea 100644 --- a/litellm/llms/base_llm/guardrail_translation/utils.py +++ b/litellm/llms/base_llm/guardrail_translation/utils.py @@ -14,7 +14,22 @@ def effective_skip_system_message_for_guardrail(guardrail_to_apply: Any) -> bool return bool(getattr(litellm, "skip_system_message_in_guardrail", False)) +def effective_skip_tool_message_for_guardrail(guardrail_to_apply: Any) -> bool: + per = getattr(guardrail_to_apply, "skip_tool_message_in_guardrail", None) + if per is not None: + return bool(per) + import litellm + + return bool(getattr(litellm, "skip_tool_message_in_guardrail", False)) + + def openai_messages_without_system( messages: List[AllMessageValues], ) -> List[AllMessageValues]: return [m for m in messages if str((m or {}).get("role") or "").lower() != "system"] + + +def openai_messages_without_tool( + messages: List[AllMessageValues], +) -> List[AllMessageValues]: + return [m for m in messages if str((m or {}).get("role") or "").lower() != "tool"] diff --git a/litellm/llms/openai/chat/guardrail_translation/handler.py b/litellm/llms/openai/chat/guardrail_translation/handler.py index 86ca662562..d413a24453 100644 --- a/litellm/llms/openai/chat/guardrail_translation/handler.py +++ b/litellm/llms/openai/chat/guardrail_translation/handler.py @@ -21,7 +21,9 @@ from litellm._logging import verbose_proxy_logger from litellm.llms.base_llm.guardrail_translation.base_translation import BaseTranslation from litellm.llms.base_llm.guardrail_translation.utils import ( effective_skip_system_message_for_guardrail, + effective_skip_tool_message_for_guardrail, openai_messages_without_system, + openai_messages_without_tool, ) from litellm.main import stream_chunk_builder from litellm.types.llms.openai import AllMessageValues, ChatCompletionToolParam @@ -73,6 +75,7 @@ class OpenAIChatCompletionsHandler(BaseTranslation): return data skip_system = effective_skip_system_message_for_guardrail(guardrail_to_apply) + skip_tool = effective_skip_tool_message_for_guardrail(guardrail_to_apply) texts_to_check: List[str] = [] images_to_check: List[str] = [] @@ -91,6 +94,7 @@ class OpenAIChatCompletionsHandler(BaseTranslation): text_task_mappings=text_task_mappings, tool_call_task_mappings=tool_call_task_mappings, skip_system_message=skip_system, + skip_tool_message=skip_tool, ) # Step 2: Apply guardrail to all texts and tool calls in batch @@ -102,11 +106,15 @@ class OpenAIChatCompletionsHandler(BaseTranslation): inputs["tool_calls"] = tool_calls_to_check # type: ignore structured_messages = self.get_structured_messages(data) if structured_messages: - inputs["structured_messages"] = ( - openai_messages_without_system(structured_messages) - if skip_system - else structured_messages - ) + if skip_system: + structured_messages = openai_messages_without_system( + structured_messages + ) + if skip_tool: + structured_messages = openai_messages_without_tool( + structured_messages + ) + inputs["structured_messages"] = structured_messages # Pass tools (function definitions) to the guardrail tools = data.get("tools") if tools: @@ -176,13 +184,17 @@ class OpenAIChatCompletionsHandler(BaseTranslation): text_task_mappings: List[Tuple[int, Optional[int]]], tool_call_task_mappings: List[Tuple[int, int]], skip_system_message: bool = False, + skip_tool_message: bool = False, ) -> None: """ Extract text content, images, and tool calls from a message. Override this method to customize text/image/tool call extraction logic. """ - if skip_system_message and str(message.get("role") or "").lower() == "system": + role = str(message.get("role") or "").lower() + if skip_system_message and role == "system": + return + if skip_tool_message and role == "tool": return content = message.get("content", None) diff --git a/litellm/proxy/guardrails/guardrail_registry.py b/litellm/proxy/guardrails/guardrail_registry.py index 868b23756d..838fb2e01a 100644 --- a/litellm/proxy/guardrails/guardrail_registry.py +++ b/litellm/proxy/guardrails/guardrail_registry.py @@ -482,6 +482,11 @@ class InMemoryGuardrailHandler: "skip_system_message_in_guardrail", getattr(litellm_params, "skip_system_message_in_guardrail", None), ) + setattr( + custom_guardrail_callback, + "skip_tool_message_in_guardrail", + getattr(litellm_params, "skip_tool_message_in_guardrail", None), + ) parsed_guardrail = Guardrail( guardrail_id=guardrail.get("guardrail_id"), diff --git a/litellm/types/guardrails.py b/litellm/types/guardrails.py index 04347aebe3..751113400d 100644 --- a/litellm/types/guardrails.py +++ b/litellm/types/guardrails.py @@ -633,6 +633,16 @@ class BaseLitellmParams( ), ) + skip_tool_message_in_guardrail: Optional[bool] = Field( + default=None, + description=( + "When True, unified guardrails skip tool-role messages when building " + "evaluation inputs (texts and structured_messages). When False, tool " + "messages are included even if litellm_settings sets a global skip. When " + "None, use the global litellm.skip_tool_message_in_guardrail setting." + ), + ) + # Lakera specific params category_thresholds: Optional[LakeraCategoryThresholds] = Field( default=None, diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/unified_guardrails/test_unified_guardrail.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/unified_guardrails/test_unified_guardrail.py index 2418d7af04..6e027fa494 100644 --- a/tests/test_litellm/proxy/guardrails/guardrail_hooks/unified_guardrails/test_unified_guardrail.py +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/unified_guardrails/test_unified_guardrail.py @@ -8,7 +8,9 @@ from litellm.integrations.custom_guardrail import CustomGuardrail from litellm.llms.base_llm.guardrail_translation.base_translation import BaseTranslation from litellm.llms.base_llm.guardrail_translation.utils import ( effective_skip_system_message_for_guardrail, + effective_skip_tool_message_for_guardrail, openai_messages_without_system, + openai_messages_without_tool, ) from litellm.llms.openai.chat.guardrail_translation.handler import ( OpenAIChatCompletionsHandler, @@ -180,6 +182,136 @@ class TestUnifiedLLMGuardrails: } assert "system" in roles + class TestSkipToolMessageForChatCompletions: + def test_openai_messages_without_tool(self): + msgs = [ + {"role": "user", "content": "hi"}, + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": {"name": "f", "arguments": "{}"}, + } + ], + }, + {"role": "tool", "content": "tool result", "tool_call_id": "call_1"}, + ] + out = openai_messages_without_tool(msgs) + assert len(out) == 2 + assert all(m["role"] != "tool" for m in out) + assert msgs[2]["content"] == "tool result" + + def test_effective_skip_tool_respects_per_guardrail_over_global( + self, monkeypatch + ): + monkeypatch.setattr( + litellm, "skip_tool_message_in_guardrail", True, raising=False + ) + + class G: + skip_tool_message_in_guardrail = False + + assert effective_skip_tool_message_for_guardrail(G()) is False + + class G2: + skip_tool_message_in_guardrail = None + + assert effective_skip_tool_message_for_guardrail(G2()) is True + + @pytest.mark.asyncio + async def test_openai_handler_skips_tool_in_guardrail_inputs(self, monkeypatch): + monkeypatch.setattr( + litellm, "skip_tool_message_in_guardrail", True, raising=False + ) + + captured = {} + + class MockGuardrail: + skip_tool_message_in_guardrail = None + + async def apply_guardrail( + self, inputs, request_data, input_type, logging_obj=None + ): + captured["inputs"] = inputs + return inputs + + data = { + "messages": [ + {"role": "user", "content": "hello"}, + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": {"name": "f", "arguments": "{}"}, + } + ], + }, + { + "role": "tool", + "content": "secret tool result", + "tool_call_id": "call_1", + }, + ], + "model": "gpt-4o", + } + + handler = OpenAIChatCompletionsHandler() + await handler.process_input_messages( + data=data, + guardrail_to_apply=MockGuardrail(), + litellm_logging_obj=None, + ) + + assert "secret tool result" not in captured["inputs"]["texts"] + sm = captured["inputs"].get("structured_messages") or [] + assert all(m.get("role") != "tool" for m in sm) + assert data["messages"][2]["content"] == "secret tool result" + + @pytest.mark.asyncio + async def test_openai_handler_per_guardrail_skip_tool_false_overrides_global( + self, monkeypatch + ): + monkeypatch.setattr( + litellm, "skip_tool_message_in_guardrail", True, raising=False + ) + + captured = {} + + class MockGuardrail: + skip_tool_message_in_guardrail = False + + async def apply_guardrail( + self, inputs, request_data, input_type, logging_obj=None + ): + captured["inputs"] = inputs + return inputs + + data = { + "messages": [ + {"role": "user", "content": "u"}, + {"role": "tool", "content": "tr", "tool_call_id": "call_1"}, + ], + } + + await OpenAIChatCompletionsHandler().process_input_messages( + data=data, + guardrail_to_apply=MockGuardrail(), + litellm_logging_obj=None, + ) + + assert "tr" in captured["inputs"]["texts"] + roles = { + m.get("role") + for m in (captured["inputs"].get("structured_messages") or []) + } + assert "tool" in roles + class TestAsyncPreCallHook: @pytest.mark.asyncio async def test_uses_mcp_event_type(self): From bdb2b0e708a032d6235b934917d297168f7bd9e1 Mon Sep 17 00:00:00 2001 From: shivam Date: Thu, 7 May 2026 19:11:41 -0700 Subject: [PATCH 21/28] feat(dashboard): skip_tool_message_in_guardrail in guardrail UI Adds a tri-state control (inherit / yes / no) when creating or editing guardrails so admins can set litellm_params.skip_tool_message_in_guardrail without YAML, mirroring the existing skip_system_message control. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../guardrails/add_guardrail_form.tsx | 20 +++++++++++ .../guardrails/edit_guardrail_form.tsx | 23 ++++++++++++ .../components/guardrails/guardrail_info.tsx | 36 +++++++++++++++++++ .../guardrail_info_helpers.test.tsx | 16 +++++++++ .../guardrails/guardrail_info_helpers.tsx | 16 +++++++++ .../components/guardrails/guardrail_table.tsx | 10 +++++- 6 files changed, 120 insertions(+), 1 deletion(-) diff --git a/ui/litellm-dashboard/src/components/guardrails/add_guardrail_form.tsx b/ui/litellm-dashboard/src/components/guardrails/add_guardrail_form.tsx index c91a6e85cd..16c1c6efec 100644 --- a/ui/litellm-dashboard/src/components/guardrails/add_guardrail_form.tsx +++ b/ui/litellm-dashboard/src/components/guardrails/add_guardrail_form.tsx @@ -5,6 +5,7 @@ import { createGuardrailCall, getGuardrailProviderSpecificParams, getGuardrailUI import ContentFilterConfiguration from "./content_filter/ContentFilterConfiguration"; import { choiceToSkipSystemForCreate, + choiceToSkipToolForCreate, getGuardrailProviders, guardrail_provider_map, guardrailLogoMap, @@ -188,6 +189,7 @@ const AddGuardrailForm: React.FC = ({ visible, onClose, a mode: preset.mode, default_on: preset.defaultOn, skip_system_message_choice: "inherit", + skip_tool_message_choice: "inherit", }; if (preset.provider === "BlockCodeExecution") { baseValues.confidence_threshold = 0.5; @@ -433,6 +435,11 @@ const AddGuardrailForm: React.FC = ({ visible, onClose, a guardrailData.litellm_params.skip_system_message_in_guardrail = skipForCreate; } + const skipToolForCreate = choiceToSkipToolForCreate(values.skip_tool_message_choice); + if (skipToolForCreate !== undefined) { + guardrailData.litellm_params.skip_tool_message_in_guardrail = skipToolForCreate; + } + // For Presidio PII, add the entity and action configurations if (values.provider === "PresidioPII" && selectedEntities.length > 0) { const piiEntitiesConfig: { [key: string]: string } = {}; @@ -804,6 +811,18 @@ const AddGuardrailForm: React.FC = ({ visible, onClose, a + + + + {/* Use the GuardrailProviderFields component to render provider-specific fields */} {!isToolPermissionProvider && !shouldRenderContentFilterConfigSettings(selectedProvider) && !shouldRenderLLMJudgeFields(selectedProvider) && ( = ({ visible, onClose, a mode: "pre_call", default_on: false, skip_system_message_choice: "inherit", + skip_tool_message_choice: "inherit", }} > {stepConfigs.map((step, index) => { diff --git a/ui/litellm-dashboard/src/components/guardrails/edit_guardrail_form.tsx b/ui/litellm-dashboard/src/components/guardrails/edit_guardrail_form.tsx index ad823df53f..8ba9b0b312 100644 --- a/ui/litellm-dashboard/src/components/guardrails/edit_guardrail_form.tsx +++ b/ui/litellm-dashboard/src/components/guardrails/edit_guardrail_form.tsx @@ -6,6 +6,7 @@ import { guardrailLogoMap, getGuardrailProviders, type SkipSystemMessageChoice, + type SkipToolMessageChoice, } from "./guardrail_info_helpers"; import { getGuardrailUISettings, getGlobalLitellmHeaderName } from "../networking"; import PiiConfiguration from "./pii_configuration"; @@ -29,6 +30,7 @@ interface EditGuardrailFormProps { default_on: boolean; pii_entities_config?: { [key: string]: string }; skip_system_message_choice?: SkipSystemMessageChoice; + skip_tool_message_choice?: SkipToolMessageChoice; [key: string]: any; }; } @@ -138,6 +140,15 @@ const EditGuardrailForm: React.FC = ({ delete litellm_params.skip_system_message_in_guardrail; } + const skipToolChoice = values.skip_tool_message_choice as SkipToolMessageChoice | undefined; + if (skipToolChoice === "yes") { + litellm_params.skip_tool_message_in_guardrail = true; + } else if (skipToolChoice === "no") { + litellm_params.skip_tool_message_in_guardrail = false; + } else { + delete litellm_params.skip_tool_message_in_guardrail; + } + let guardrail_info: any = {}; // For Presidio PII, add the entity and action configurations @@ -432,6 +443,18 @@ const EditGuardrailForm: React.FC = ({ + + + + {renderProviderSpecificFields()}

diff --git a/ui/litellm-dashboard/src/components/guardrails/guardrail_info.tsx b/ui/litellm-dashboard/src/components/guardrails/guardrail_info.tsx index 60400443d5..53aebcff0d 100644 --- a/ui/litellm-dashboard/src/components/guardrails/guardrail_info.tsx +++ b/ui/litellm-dashboard/src/components/guardrails/guardrail_info.tsx @@ -29,7 +29,9 @@ import { getGuardrailLogoAndName, guardrail_provider_map, skipSystemMessageToChoice, + skipToolMessageToChoice, type SkipSystemMessageChoice, + type SkipToolMessageChoice, } from "./guardrail_info_helpers"; import GuardrailOptionalParams from "./guardrail_optional_params"; import GuardrailProviderFields from "./guardrail_provider_fields"; @@ -214,12 +216,16 @@ const GuardrailInfoView: React.FC = ({ guardrailId, onClose, if (guardrailData && form) { const lp = { ...(guardrailData.litellm_params || {}) }; delete lp.skip_system_message_in_guardrail; + delete lp.skip_tool_message_in_guardrail; form.setFieldsValue({ guardrail_name: guardrailData.guardrail_name, ...lp, skip_system_message_choice: skipSystemMessageToChoice( guardrailData.litellm_params?.skip_system_message_in_guardrail, ), + skip_tool_message_choice: skipToolMessageToChoice( + guardrailData.litellm_params?.skip_tool_message_in_guardrail, + ), guardrail_info: guardrailData.guardrail_info ? JSON.stringify(guardrailData.guardrail_info, null, 2) : "", // Include any optional_params if they exist ...(guardrailData.litellm_params?.optional_params && { @@ -302,6 +308,20 @@ const GuardrailInfoView: React.FC = ({ guardrailId, onClose, } } + const prevSkipToolChoice = skipToolMessageToChoice( + guardrailData.litellm_params?.skip_tool_message_in_guardrail, + ); + const nextSkipToolChoice = values.skip_tool_message_choice as SkipToolMessageChoice | undefined; + if (nextSkipToolChoice !== undefined && nextSkipToolChoice !== prevSkipToolChoice) { + if (nextSkipToolChoice === "inherit") { + updateData.litellm_params.skip_tool_message_in_guardrail = null; + } else if (nextSkipToolChoice === "yes") { + updateData.litellm_params.skip_tool_message_in_guardrail = true; + } else { + updateData.litellm_params.skip_tool_message_in_guardrail = false; + } + } + // Only include guardrail_info if it has changed const originalGuardrailInfo = guardrailData.guardrail_info; const newGuardrailInfo = values.guardrail_info ? JSON.parse(values.guardrail_info) : undefined; @@ -674,11 +694,15 @@ const GuardrailInfoView: React.FC = ({ guardrailId, onClose, ...(() => { const lp = { ...(guardrailData.litellm_params || {}) }; delete lp.skip_system_message_in_guardrail; + delete lp.skip_tool_message_in_guardrail; return lp; })(), skip_system_message_choice: skipSystemMessageToChoice( guardrailData.litellm_params?.skip_system_message_in_guardrail, ), + skip_tool_message_choice: skipToolMessageToChoice( + guardrailData.litellm_params?.skip_tool_message_in_guardrail, + ), guardrail_info: guardrailData.guardrail_info ? JSON.stringify(guardrailData.guardrail_info, null, 2) : "", @@ -716,6 +740,18 @@ const GuardrailInfoView: React.FC = ({ guardrailId, onClose, + + + + {guardrailData.litellm_params?.guardrail === "presidio" && ( <> PII Protection diff --git a/ui/litellm-dashboard/src/components/guardrails/guardrail_info_helpers.test.tsx b/ui/litellm-dashboard/src/components/guardrails/guardrail_info_helpers.test.tsx index dfda86c1e4..1fc62f94cf 100644 --- a/ui/litellm-dashboard/src/components/guardrails/guardrail_info_helpers.test.tsx +++ b/ui/litellm-dashboard/src/components/guardrails/guardrail_info_helpers.test.tsx @@ -12,6 +12,8 @@ import { GuardrailProviders, skipSystemMessageToChoice, choiceToSkipSystemForCreate, + skipToolMessageToChoice, + choiceToSkipToolForCreate, } from "./guardrail_info_helpers"; describe("guardrail_info_helpers", () => { @@ -215,4 +217,18 @@ describe("guardrail_info_helpers", () => { expect(choiceToSkipSystemForCreate("no")).toBe(false); }); }); + + describe("skipToolMessageToChoice / choiceToSkipToolForCreate", () => { + it("maps API values to form choices and back for create", () => { + expect(skipToolMessageToChoice(undefined)).toBe("inherit"); + expect(skipToolMessageToChoice(null)).toBe("inherit"); + expect(skipToolMessageToChoice(true)).toBe("yes"); + expect(skipToolMessageToChoice(false)).toBe("no"); + + expect(choiceToSkipToolForCreate("inherit")).toBeUndefined(); + expect(choiceToSkipToolForCreate(undefined)).toBeUndefined(); + expect(choiceToSkipToolForCreate("yes")).toBe(true); + expect(choiceToSkipToolForCreate("no")).toBe(false); + }); + }); }); diff --git a/ui/litellm-dashboard/src/components/guardrails/guardrail_info_helpers.tsx b/ui/litellm-dashboard/src/components/guardrails/guardrail_info_helpers.tsx index ac4b787e96..54b16b8176 100644 --- a/ui/litellm-dashboard/src/components/guardrails/guardrail_info_helpers.tsx +++ b/ui/litellm-dashboard/src/components/guardrails/guardrail_info_helpers.tsx @@ -179,3 +179,19 @@ export function choiceToSkipSystemForCreate(choice: SkipSystemMessageChoice | un if (choice === "no") return false; return undefined; } + +/** Tri-state UI value for `litellm_params.skip_tool_message_in_guardrail` (inherit = use global). */ +export type SkipToolMessageChoice = "inherit" | "yes" | "no"; + +export function skipToolMessageToChoice(v: boolean | null | undefined): SkipToolMessageChoice { + if (v === true) return "yes"; + if (v === false) return "no"; + return "inherit"; +} + +/** Create flow: omit key when inheriting global default. */ +export function choiceToSkipToolForCreate(choice: SkipToolMessageChoice | undefined): boolean | undefined { + if (choice === "yes") return true; + if (choice === "no") return false; + return undefined; +} diff --git a/ui/litellm-dashboard/src/components/guardrails/guardrail_table.tsx b/ui/litellm-dashboard/src/components/guardrails/guardrail_table.tsx index 5bb2da78fa..ecf6ce48fd 100644 --- a/ui/litellm-dashboard/src/components/guardrails/guardrail_table.tsx +++ b/ui/litellm-dashboard/src/components/guardrails/guardrail_table.tsx @@ -11,7 +11,12 @@ import { SortingState, useReactTable, } from "@tanstack/react-table"; -import { getGuardrailLogoAndName, guardrail_provider_map, skipSystemMessageToChoice } from "./guardrail_info_helpers"; +import { + getGuardrailLogoAndName, + guardrail_provider_map, + skipSystemMessageToChoice, + skipToolMessageToChoice, +} from "./guardrail_info_helpers"; import EditGuardrailForm from "./edit_guardrail_form"; import { Guardrail, GuardrailDefinitionLocation } from "./types"; @@ -304,6 +309,9 @@ const GuardrailTable: React.FC = ({ skip_system_message_choice: skipSystemMessageToChoice( selectedGuardrail.litellm_params?.skip_system_message_in_guardrail, ), + skip_tool_message_choice: skipToolMessageToChoice( + selectedGuardrail.litellm_params?.skip_tool_message_in_guardrail, + ), ...selectedGuardrail.guardrail_info, }} /> From 8686001b3b56463a8564a640a44eff50c595efdf Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Sat, 9 May 2026 13:50:22 -0700 Subject: [PATCH 22/28] build(packaging): raise jinja2 floor to 3.1.6 Our `uv.lock` already resolves jinja2 to 3.1.6, so Docker / CI installs get that version. The `pyproject.toml` floor was lagging at 3.1.0, which means downstream consumers using `--resolution=lowest-direct` or older constraint files can land on 3.1.0-3.1.5 instead of the version we actually test against. Aligns the declared floor with the resolved version so external installers see the same baseline our test matrix exercises. `uv lock` diff is metadata-only (no resolved-version drift). --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index d194d46791..5cd83148d3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,7 @@ dependencies = [ "importlib-metadata>=8.0.0,<9.0", "tokenizers>=0.21.0,<1.0", "click>=8.0.0,<9.0", - "jinja2>=3.1.0,<4.0", + "jinja2>=3.1.6,<4.0", "aiohttp>=3.10,<4.0", "pydantic>=2.10.0,<3.0.0", "jsonschema>=4.0.0,<5.0", diff --git a/uv.lock b/uv.lock index f8d78fe879..ab9aba1e38 100644 --- a/uv.lock +++ b/uv.lock @@ -3405,7 +3405,7 @@ requires-dist = [ { name = "gunicorn", marker = "extra == 'proxy'", specifier = "==23.0.0" }, { name = "httpx", specifier = ">=0.28.0,<1.0" }, { name = "importlib-metadata", specifier = ">=8.0.0,<9.0" }, - { name = "jinja2", specifier = ">=3.1.0,<4.0" }, + { name = "jinja2", specifier = ">=3.1.6,<4.0" }, { name = "jsonschema", specifier = ">=4.0.0,<5.0" }, { name = "langfuse", marker = "extra == 'proxy-runtime'", specifier = "==2.59.7" }, { name = "litellm-enterprise", marker = "extra == 'proxy'", editable = "enterprise" }, From fa5eae8bc9d07d8e6293e97ed1a4e6120fe83cc5 Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Sat, 9 May 2026 13:51:34 -0700 Subject: [PATCH 23/28] chore: remove legacy deployment artifacts and litellm-js packages (#27541) - Remove litellm-js/proxy and litellm-js/spend-logs TypeScript packages that provided Cloudflare Worker proxy and Node.js spend logging services, as these are no longer maintained - Remove deprecated Docker variants (Dockerfile.alpine, Dockerfile.dev, Dockerfile.custom_ui, Dockerfile.health_check, Dockerfile.ghcr_base) that have been superseded by the primary Dockerfile - Remove legacy Kubernetes manifests (kub.yaml, service.yaml) from deploy/kubernetes in favor of the Helm chart - Remove stale index.yaml Helm chart index pinned to an old version (v1.43.18) - Remove dev_config.yaml development configuration file that contained hardcoded credentials and example endpoints - Clean up ~3,500 lines of unused code and configuration to reduce repository maintenance burden Co-authored-by: Yassin Kortam --- AGENTS.md | 21 +- CLAUDE.md | 2 +- deploy/Dockerfile.ghcr_base | 18 - deploy/kubernetes/kub.yaml | 56 - deploy/kubernetes/service.yaml | 12 - dev_config.yaml | 13 - docker/Dockerfile.alpine | 68 - docker/Dockerfile.custom_ui | 86 - docker/Dockerfile.dev | 121 -- docker/Dockerfile.health_check | 30 - index.yaml | 108 -- litellm-js/proxy/.npmrc | 5 - litellm-js/proxy/README.md | 8 - litellm-js/proxy/package-lock.json | 2054 ----------------------- litellm-js/proxy/package.json | 14 - litellm-js/proxy/src/index.ts | 59 - litellm-js/proxy/tsconfig.json | 17 - litellm-js/proxy/wrangler.toml | 18 - litellm-js/spend-logs/.npmrc | 5 - litellm-js/spend-logs/Dockerfile | 26 - litellm-js/spend-logs/README.md | 8 - litellm-js/spend-logs/package-lock.json | 597 ------- litellm-js/spend-logs/package.json | 13 - litellm-js/spend-logs/schema.prisma | 29 - litellm-js/spend-logs/src/_types.ts | 32 - litellm-js/spend-logs/src/index.ts | 84 - litellm-js/spend-logs/tsconfig.json | 13 - 27 files changed, 20 insertions(+), 3497 deletions(-) delete mode 100644 deploy/Dockerfile.ghcr_base delete mode 100644 deploy/kubernetes/kub.yaml delete mode 100644 deploy/kubernetes/service.yaml delete mode 100644 dev_config.yaml delete mode 100644 docker/Dockerfile.alpine delete mode 100644 docker/Dockerfile.custom_ui delete mode 100644 docker/Dockerfile.dev delete mode 100644 docker/Dockerfile.health_check delete mode 100644 index.yaml delete mode 100644 litellm-js/proxy/.npmrc delete mode 100644 litellm-js/proxy/README.md delete mode 100644 litellm-js/proxy/package-lock.json delete mode 100644 litellm-js/proxy/package.json delete mode 100644 litellm-js/proxy/src/index.ts delete mode 100644 litellm-js/proxy/tsconfig.json delete mode 100644 litellm-js/proxy/wrangler.toml delete mode 100644 litellm-js/spend-logs/.npmrc delete mode 100644 litellm-js/spend-logs/Dockerfile delete mode 100644 litellm-js/spend-logs/README.md delete mode 100644 litellm-js/spend-logs/package-lock.json delete mode 100644 litellm-js/spend-logs/package.json delete mode 100644 litellm-js/spend-logs/schema.prisma delete mode 100644 litellm-js/spend-logs/src/_types.ts delete mode 100644 litellm-js/spend-logs/src/index.ts delete mode 100644 litellm-js/spend-logs/tsconfig.json diff --git a/AGENTS.md b/AGENTS.md index 4bdbf26ae9..e99bf79d78 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -241,10 +241,27 @@ When opening issues or pull requests, follow these templates: ### Running the proxy server -Start the proxy with a config file: +Create a minimal config file and start the proxy: + +```yaml +# config.yaml +model_list: + - model_name: fake-openai-endpoint + litellm_params: + model: openai/fake-model + api_key: fake-key + api_base: https://fake-api.example.com + +general_settings: + master_key: sk-1234 + +litellm_settings: + drop_params: True + telemetry: False +``` ```bash -uv run litellm --config dev_config.yaml --port 4000 +uv run litellm --config config.yaml --port 4000 ``` The proxy takes ~15-20 seconds to fully start (it runs Prisma migrations on boot). Wait for `/health` to return before sending requests. Without a PostgreSQL `DATABASE_URL`, the proxy connects to a default Neon dev database embedded in the `litellm-proxy-extras` package. diff --git a/CLAUDE.md b/CLAUDE.md index 71e5af28ee..938801df7c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -146,7 +146,7 @@ LiteLLM is a unified interface for 100+ LLM providers with two main components: - **Bound large result sets.** Prisma materializes full results in memory. For results over ~10 MB, paginate with `take`/`skip` or `cursor`/`take`, always with an explicit `order`. Prefer cursor-based pagination (`skip` is O(n)). Don't paginate naturally small result sets. - **Limit fetched columns on wide tables.** Use `select` to fetch only needed fields — returns a partial object, so downstream code must not access unselected fields. - **Check index coverage.** For new or modified queries, check `schema.prisma` for a supporting index. Prefer extending an existing index (e.g. `@@index([a])` → `@@index([a, b])`) over adding a new one, unless it's a `@@unique`. Only add indexes for large/frequent queries. -- **Keep schema files in sync.** Apply schema changes to all `schema.prisma` copies (`schema.prisma`, `litellm/proxy/`, `litellm-proxy-extras/`, `litellm-js/spend-logs/` for SpendLogs) with a migration under `litellm-proxy-extras/litellm_proxy_extras/migrations/`. +- **Keep schema files in sync.** Apply schema changes to all `schema.prisma` copies (`schema.prisma`, `litellm/proxy/`, `litellm-proxy-extras/`) with a migration under `litellm-proxy-extras/litellm_proxy_extras/migrations/`. ### Setup Wizard (`litellm/setup_wizard.py`) - The wizard is implemented as a single `SetupWizard` class with `@staticmethod` methods — keep it that way. No module-level functions except `run_setup_wizard()` (the public entrypoint) and pure helpers (color, ANSI). diff --git a/deploy/Dockerfile.ghcr_base b/deploy/Dockerfile.ghcr_base deleted file mode 100644 index 66e64e5b77..0000000000 --- a/deploy/Dockerfile.ghcr_base +++ /dev/null @@ -1,18 +0,0 @@ -# Use the provided base image -FROM ghcr.io/berriai/litellm:main-latest@sha256:7c311546c25e7bb6e8cafede9fcd3d0d622ac636b5c9418befaa32e85dfb0186 - -# Set the working directory to /app -WORKDIR /app - -# Copy the configuration file into the container at /app -COPY config.yaml . - -# Make sure your docker/entrypoint.sh is executable -# Convert Windows line endings to Unix -RUN sed -i 's/\r$//' docker/entrypoint.sh && chmod +x docker/entrypoint.sh - -# Expose the necessary port -EXPOSE 4000/tcp - -# Override the CMD instruction with your desired command and arguments -CMD ["--port", "4000", "--config", "config.yaml", "--detailed_debug", "--run_gunicorn"] diff --git a/deploy/kubernetes/kub.yaml b/deploy/kubernetes/kub.yaml deleted file mode 100644 index d5ba500d8f..0000000000 --- a/deploy/kubernetes/kub.yaml +++ /dev/null @@ -1,56 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: litellm-deployment -spec: - replicas: 3 - selector: - matchLabels: - app: litellm - template: - metadata: - labels: - app: litellm - spec: - containers: - - name: litellm-container - image: ghcr.io/berriai/litellm:main-latest - imagePullPolicy: Always - env: - - name: AZURE_API_KEY - value: "d6f****" - - name: AZURE_API_BASE - value: "https://openai" - - name: LITELLM_MASTER_KEY - value: "sk-1234" - - name: DATABASE_URL - value: "postgresql://ishaan*********" - args: - - "--config" - - "/app/proxy_config.yaml" # Update the path to mount the config file - volumeMounts: # Define volume mount for proxy_config.yaml - - name: config-volume - mountPath: /app - readOnly: true - livenessProbe: - httpGet: - path: /health/liveliness - port: 4000 - initialDelaySeconds: 120 - periodSeconds: 15 - successThreshold: 1 - failureThreshold: 3 - timeoutSeconds: 10 - readinessProbe: - httpGet: - path: /health/readiness - port: 4000 - initialDelaySeconds: 120 - periodSeconds: 15 - successThreshold: 1 - failureThreshold: 3 - timeoutSeconds: 10 - volumes: # Define volume to mount proxy_config.yaml - - name: config-volume - configMap: - name: litellm-config diff --git a/deploy/kubernetes/service.yaml b/deploy/kubernetes/service.yaml deleted file mode 100644 index 4751c83725..0000000000 --- a/deploy/kubernetes/service.yaml +++ /dev/null @@ -1,12 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: litellm-service -spec: - selector: - app: litellm - ports: - - protocol: TCP - port: 4000 - targetPort: 4000 - type: LoadBalancer \ No newline at end of file diff --git a/dev_config.yaml b/dev_config.yaml deleted file mode 100644 index 64e3c14703..0000000000 --- a/dev_config.yaml +++ /dev/null @@ -1,13 +0,0 @@ -model_list: - - model_name: fake-openai-endpoint - litellm_params: - model: openai/fake-model - api_key: fake-key - api_base: https://exampleopenaiendpoint-production.up.railway.app/ - -general_settings: - master_key: sk-1234 - -litellm_settings: - drop_params: True - telemetry: False diff --git a/docker/Dockerfile.alpine b/docker/Dockerfile.alpine deleted file mode 100644 index 5de588cf4e..0000000000 --- a/docker/Dockerfile.alpine +++ /dev/null @@ -1,68 +0,0 @@ -# Base image for building -ARG LITELLM_BUILD_IMAGE=python:3.11-alpine@sha256:f07e2ace46f560f09a6eeec7b4913b80ee99546e749ef82342a419a326620856 - -# Runtime image -ARG LITELLM_RUNTIME_IMAGE=python:3.11-alpine@sha256:f07e2ace46f560f09a6eeec7b4913b80ee99546e749ef82342a419a326620856 -ARG UV_IMAGE=ghcr.io/astral-sh/uv:0.11.7@sha256:240fb85ab0f263ef12f492d8476aa3a2e4e1e333f7d67fbdd923d00a506a516a - -FROM $UV_IMAGE AS uvbin - -FROM $LITELLM_BUILD_IMAGE AS builder - -WORKDIR /app - -COPY --from=uvbin /uv /usr/local/bin/uv -COPY --from=uvbin /uvx /usr/local/bin/uvx - -RUN apk add --no-cache gcc python3-dev musl-dev nodejs npm libsndfile - -ENV PRISMA_BINARY_CACHE_DIR=/app/.cache/prisma-python/binaries \ - UV_PROJECT_ENVIRONMENT=/app/.venv \ - UV_LINK_MODE=copy \ - XDG_CACHE_HOME=/app/.cache \ - PATH="/app/.venv/bin:${PATH}" - -# Copy dependency metadata first for layer caching -COPY pyproject.toml uv.lock ./ -COPY enterprise/pyproject.toml enterprise/ -COPY litellm-proxy-extras/pyproject.toml litellm-proxy-extras/ - -# Install third-party dependencies (cached unless pyproject.toml/uv.lock change) -RUN uv sync --frozen --no-install-project --no-install-workspace --no-default-groups --no-editable \ - --extra proxy \ - --extra proxy-runtime \ - --extra extra_proxy \ - --extra semantic-router \ - --python python3 - -# Copy full source tree -COPY . . - -# Install project and workspace packages (fast - deps already cached) -RUN uv sync --frozen --no-default-groups --no-editable \ - --extra proxy \ - --extra proxy-runtime \ - --extra extra_proxy \ - --extra semantic-router \ - --python python3 - -RUN prisma generate --schema=./schema.prisma - -RUN sed -i 's/\r$//' docker/entrypoint.sh && chmod +x docker/entrypoint.sh && \ - sed -i 's/\r$//' docker/prod_entrypoint.sh && chmod +x docker/prod_entrypoint.sh - -FROM $LITELLM_RUNTIME_IMAGE AS runtime - -RUN apk upgrade --no-cache && apk add --no-cache libsndfile nodejs npm - -WORKDIR /app -ENV PRISMA_BINARY_CACHE_DIR=/app/.cache/prisma-python/binaries \ - XDG_CACHE_HOME=/app/.cache \ - PATH="/app/.venv/bin:${PATH}" - -COPY --from=builder /app /app - -EXPOSE 4000/tcp - -ENTRYPOINT ["docker/prod_entrypoint.sh"] -CMD ["--port", "4000"] diff --git a/docker/Dockerfile.custom_ui b/docker/Dockerfile.custom_ui deleted file mode 100644 index cc44893bf9..0000000000 --- a/docker/Dockerfile.custom_ui +++ /dev/null @@ -1,86 +0,0 @@ -# Use the provided base image -# NOTE: This is a dev/branch-specific tag. Update digest when the base image is rebuilt. -FROM ghcr.io/berriai/litellm:litellm_fwd_server_root_path-dev - -# Set the working directory to /app -WORKDIR /app - -# Install Node.js and npm (adjust version as needed) -RUN apt-get update && apt-get upgrade -y \ - libxml2 \ - libexpat1 \ - openssl \ - libssl3 \ - git \ - libkrb5-3 \ - libglib2.0-0 \ - wget \ - libaom3 \ - libxslt1.1 \ - libgnutls30 \ - libc6 && \ - apt-get install -y --no-install-recommends nodejs npm && \ - npm install -g npm@11.12.1 tar@7.5.11 glob@11.1.0 @isaacs/brace-expansion@5.0.1 minimatch@10.2.4 diff@8.0.3 && \ - GLOBAL="$(npm root -g)" && \ - find "$GLOBAL/npm" -type d -name "tar" -path "*/node_modules/tar" | while read d; do \ - rm -rf "$d" && cp -rL "$GLOBAL/tar" "$d"; \ - done && \ - find "$GLOBAL/npm" -type d -name "glob" -path "*/node_modules/glob" | while read d; do \ - rm -rf "$d" && cp -rL "$GLOBAL/glob" "$d"; \ - done && \ - find "$GLOBAL/npm" -type d -name "brace-expansion" -path "*/node_modules/@isaacs/brace-expansion" | while read d; do \ - rm -rf "$d" && cp -rL "$GLOBAL/@isaacs/brace-expansion" "$d"; \ - done && \ - find "$GLOBAL/npm" -type d -name "minimatch" -path "*/node_modules/minimatch" | while read d; do \ - rm -rf "$d" && cp -rL "$GLOBAL/minimatch" "$d"; \ - done && \ - find "$GLOBAL/npm" -type d -name "diff" -path "*/node_modules/diff" | while read d; do \ - rm -rf "$d" && cp -rL "$GLOBAL/diff" "$d"; \ - done && \ - find /usr/local/lib /usr/lib -path "*/node_modules/npm/package.json" -exec \ - sed -i 's/"tar": "\^7\.5\.[0-9]*"/"tar": "^7.5.10"/g; s/"minimatch": "\^10\.[0-9.]*"/"minimatch": "^10.2.4"/g' {} + 2>/dev/null && \ - npm cache clean --force && \ - apt-get purge -y npm - -# Copy the UI source into the container -COPY ./ui/litellm-dashboard /app/ui/litellm-dashboard - -# Set an environment variable for UI_BASE_PATH -# This can be overridden at build time -# set UI_BASE_PATH to "/ui" -ENV UI_BASE_PATH="/prod/ui" - -# Build the UI with the specified UI_BASE_PATH -WORKDIR /app/ui/litellm-dashboard -RUN npm ci -RUN UI_BASE_PATH=$UI_BASE_PATH npm run build - -# Create the destination directory -RUN mkdir -p /app/litellm/proxy/_experimental/out - -# Move the built files to the appropriate location -# Assuming the build output is in ./out directory -RUN rm -rf /app/litellm/proxy/_experimental/out/* && \ - mv ./out/* /app/litellm/proxy/_experimental/out/ - -# Switch back to the main app directory -WORKDIR /app - -# Make sure your docker/entrypoint.sh is executable -# Convert Windows line endings to Unix for entrypoint scripts -RUN sed -i 's/\r$//' docker/entrypoint.sh && chmod +x docker/entrypoint.sh -RUN sed -i 's/\r$//' docker/prod_entrypoint.sh && chmod +x docker/prod_entrypoint.sh - -# Run as non-root user -RUN groupadd --gid 1000 appuser && useradd --uid 1000 --gid 1000 --no-create-home appuser \ - && chown -R appuser:appuser /app -USER appuser - -# Expose the necessary port -EXPOSE 4000/tcp - -HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ - CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:4000/health')"] - -# Override the CMD instruction with your desired command and arguments -CMD ["--port", "4000", "--config", "config.yaml", "--detailed_debug"] \ No newline at end of file diff --git a/docker/Dockerfile.dev b/docker/Dockerfile.dev deleted file mode 100644 index ebc92a22d5..0000000000 --- a/docker/Dockerfile.dev +++ /dev/null @@ -1,121 +0,0 @@ -# Base image for building -ARG LITELLM_BUILD_IMAGE=python:3.13-slim@sha256:739e7213785e88c0f702dcdc12c0973afcbd606dbf021a589cab77d6b00b579d - -# Runtime image -ARG LITELLM_RUNTIME_IMAGE=python:3.13-slim@sha256:739e7213785e88c0f702dcdc12c0973afcbd606dbf021a589cab77d6b00b579d -ARG UV_IMAGE=ghcr.io/astral-sh/uv:0.11.7@sha256:240fb85ab0f263ef12f492d8476aa3a2e4e1e333f7d67fbdd923d00a506a516a - -FROM $UV_IMAGE AS uvbin - -FROM $LITELLM_BUILD_IMAGE AS builder - -WORKDIR /app -USER root - -COPY --from=uvbin /uv /usr/local/bin/uv -COPY --from=uvbin /uvx /usr/local/bin/uvx - -RUN apt-get update && apt-get install -y --no-install-recommends \ - gcc \ - g++ \ - python3-dev \ - libssl-dev \ - pkg-config \ - nodejs \ - npm \ - && rm -rf /var/lib/apt/lists/* - -ENV PRISMA_BINARY_CACHE_DIR=/app/.cache/prisma-python/binaries \ - UV_PROJECT_ENVIRONMENT=/app/.venv \ - UV_LINK_MODE=copy \ - XDG_CACHE_HOME=/app/.cache \ - PATH="/app/.venv/bin:${PATH}" - -# Copy dependency metadata first for layer caching -COPY pyproject.toml uv.lock ./ -COPY enterprise/pyproject.toml enterprise/ -COPY litellm-proxy-extras/pyproject.toml litellm-proxy-extras/ - -# Install third-party dependencies (cached unless pyproject.toml/uv.lock change) -RUN uv sync --frozen --no-install-project --no-install-workspace --no-default-groups --no-editable \ - --extra proxy \ - --extra proxy-runtime \ - --extra extra_proxy \ - --extra semantic-router \ - --python python - -# Copy full source tree -COPY . . - -# Build Admin UI before final sync -RUN sed -i 's/\r$//' docker/build_admin_ui.sh && chmod +x docker/build_admin_ui.sh && ./docker/build_admin_ui.sh - -# Install project and workspace packages (fast - deps already cached) -RUN uv sync --frozen --no-default-groups --no-editable \ - --extra proxy \ - --extra proxy-runtime \ - --extra extra_proxy \ - --extra semantic-router \ - --python python - -RUN prisma generate --schema=./schema.prisma - -RUN sed -i 's/\r$//' docker/entrypoint.sh && chmod +x docker/entrypoint.sh && \ - sed -i 's/\r$//' docker/prod_entrypoint.sh && chmod +x docker/prod_entrypoint.sh - -FROM $LITELLM_RUNTIME_IMAGE AS runtime - -USER root - -RUN apt-get update && apt-get upgrade -y \ - libxml2 \ - libexpat1 \ - openssl \ - libssl3 \ - git \ - libkrb5-3 \ - libglib2.0-0 \ - wget \ - libaom3 \ - libxslt1.1 \ - libgnutls30 \ - libc6 \ - && apt-get install -y --no-install-recommends \ - libssl3 \ - libatomic1 \ - nodejs \ - npm \ - && rm -rf /var/lib/apt/lists/* \ - && npm install -g npm@11.12.1 tar@7.5.11 glob@11.1.0 @isaacs/brace-expansion@5.0.1 minimatch@10.2.4 diff@8.0.3 \ - && GLOBAL="$(npm root -g)" \ - && find "$GLOBAL/npm" -type d -name "tar" -path "*/node_modules/tar" | while read d; do \ - rm -rf "$d" && cp -rL "$GLOBAL/tar" "$d"; \ - done \ - && find "$GLOBAL/npm" -type d -name "glob" -path "*/node_modules/glob" | while read d; do \ - rm -rf "$d" && cp -rL "$GLOBAL/glob" "$d"; \ - done \ - && find "$GLOBAL/npm" -type d -name "brace-expansion" -path "*/node_modules/@isaacs/brace-expansion" | while read d; do \ - rm -rf "$d" && cp -rL "$GLOBAL/@isaacs/brace-expansion" "$d"; \ - done \ - && find "$GLOBAL/npm" -type d -name "minimatch" -path "*/node_modules/minimatch" | while read d; do \ - rm -rf "$d" && cp -rL "$GLOBAL/minimatch" "$d"; \ - done \ - && find "$GLOBAL/npm" -type d -name "diff" -path "*/node_modules/diff" | while read d; do \ - rm -rf "$d" && cp -rL "$GLOBAL/diff" "$d"; \ - done \ - && find /usr/local/lib /usr/lib -path "*/node_modules/npm/package.json" -exec \ - sed -i 's/"tar": "\^7\.5\.[0-9]*"/"tar": "^7.5.10"/g; s/"minimatch": "\^10\.[0-9.]*"/"minimatch": "^10.2.4"/g' {} + 2>/dev/null \ - && npm cache clean --force \ - && apt-get purge -y npm - -WORKDIR /app -ENV PRISMA_BINARY_CACHE_DIR=/app/.cache/prisma-python/binaries \ - XDG_CACHE_HOME=/app/.cache \ - PATH="/app/.venv/bin:${PATH}" - -COPY --from=builder /app /app - -EXPOSE 4000/tcp - -ENTRYPOINT ["docker/prod_entrypoint.sh"] -CMD ["--port", "4000"] diff --git a/docker/Dockerfile.health_check b/docker/Dockerfile.health_check deleted file mode 100644 index a2e5cb9f71..0000000000 --- a/docker/Dockerfile.health_check +++ /dev/null @@ -1,30 +0,0 @@ -ARG UV_IMAGE=ghcr.io/astral-sh/uv:0.11.7@sha256:240fb85ab0f263ef12f492d8476aa3a2e4e1e333f7d67fbdd923d00a506a516a -FROM $UV_IMAGE AS uvbin - -FROM python:3.13-slim@sha256:739e7213785e88c0f702dcdc12c0973afcbd606dbf021a589cab77d6b00b579d - -WORKDIR /app - -# Copy the uv binary and the health check script. -COPY --from=uvbin /uv /usr/local/bin/uv -COPY pyproject.toml uv.lock /app/ -COPY scripts/health_check/health_check_client.py /app/health_check_client.py - -# Resolve and install the health-check dependencies from the project lockfile -# so the runtime image stays self-contained and reproducible. -RUN uv export --frozen --no-default-groups --only-group healthcheck --no-emit-project --no-hashes --output-file /tmp/health-check-requirements.txt \ - && uv pip install --system -r /tmp/health-check-requirements.txt \ - && rm /tmp/health-check-requirements.txt \ - && rm /app/pyproject.toml /app/uv.lock \ - && chmod +x /app/health_check_client.py - -# Run as non-root user -RUN groupadd --gid 1000 appuser && useradd --uid 1000 --gid 1000 --no-create-home appuser -USER appuser - -# Health check -HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ - CMD ["python", "/app/health_check_client.py", "--help"] - -# Set entrypoint -ENTRYPOINT ["python", "/app/health_check_client.py"] diff --git a/index.yaml b/index.yaml deleted file mode 100644 index 9b2461c36b..0000000000 --- a/index.yaml +++ /dev/null @@ -1,108 +0,0 @@ -apiVersion: v1 -entries: - litellm-helm: - - apiVersion: v2 - appVersion: v1.43.18 - created: "2024-08-19T23:58:25.331689+08:00" - dependencies: - - condition: db.deployStandalone - name: postgresql - repository: oci://registry-1.docker.io/bitnamicharts - version: '>=13.3.0' - - condition: redis.enabled - name: redis - repository: oci://registry-1.docker.io/bitnamicharts - version: '>=18.0.0' - description: Call all LLM APIs using the OpenAI format - digest: 0411df3dc42868be8af3ad3e00cb252790e6bd7ad15f5b77f1ca5214573a8531 - name: litellm-helm - type: application - urls: - - https://berriai.github.io/litellm/litellm-helm-0.2.3.tgz - version: 0.2.3 - postgresql: - - annotations: - category: Database - images: | - - name: os-shell - image: docker.io/bitnami/os-shell:12-debian-12-r16 - - name: postgres-exporter - image: docker.io/bitnami/postgres-exporter:0.15.0-debian-12-r14 - - name: postgresql - image: docker.io/bitnami/postgresql:16.2.0-debian-12-r6 - licenses: Apache-2.0 - apiVersion: v2 - appVersion: 16.2.0 - created: "2024-08-19T23:58:25.335716+08:00" - dependencies: - - name: common - repository: oci://registry-1.docker.io/bitnamicharts - tags: - - bitnami-common - version: 2.x.x - description: PostgreSQL (Postgres) is an open source object-relational database - known for reliability and data integrity. ACID-compliant, it supports foreign - keys, joins, views, triggers and stored procedures. - digest: 3c8125526b06833df32e2f626db34aeaedb29d38f03d15349db6604027d4a167 - home: https://bitnami.com - icon: https://bitnami.com/assets/stacks/postgresql/img/postgresql-stack-220x234.png - keywords: - - postgresql - - postgres - - database - - sql - - replication - - cluster - maintainers: - - name: VMware, Inc. - url: https://github.com/bitnami/charts - name: postgresql - sources: - - https://github.com/bitnami/charts/tree/main/bitnami/postgresql - urls: - - https://berriai.github.io/litellm/charts/postgresql-14.3.1.tgz - version: 14.3.1 - redis: - - annotations: - category: Database - images: | - - name: kubectl - image: docker.io/bitnami/kubectl:1.29.2-debian-12-r3 - - name: os-shell - image: docker.io/bitnami/os-shell:12-debian-12-r16 - - name: redis - image: docker.io/bitnami/redis:7.2.4-debian-12-r9 - - name: redis-exporter - image: docker.io/bitnami/redis-exporter:1.58.0-debian-12-r4 - - name: redis-sentinel - image: docker.io/bitnami/redis-sentinel:7.2.4-debian-12-r7 - licenses: Apache-2.0 - apiVersion: v2 - appVersion: 7.2.4 - created: "2024-08-19T23:58:25.339392+08:00" - dependencies: - - name: common - repository: oci://registry-1.docker.io/bitnamicharts - tags: - - bitnami-common - version: 2.x.x - description: Redis(R) is an open source, advanced key-value store. It is often - referred to as a data structure server since keys can contain strings, hashes, - lists, sets and sorted sets. - digest: b2fa1835f673a18002ca864c54fadac3c33789b26f6c5e58e2851b0b14a8f984 - home: https://bitnami.com - icon: https://bitnami.com/assets/stacks/redis/img/redis-stack-220x234.png - keywords: - - redis - - keyvalue - - database - maintainers: - - name: VMware, Inc. - url: https://github.com/bitnami/charts - name: redis - sources: - - https://github.com/bitnami/charts/tree/main/bitnami/redis - urls: - - https://berriai.github.io/litellm/charts/redis-18.19.1.tgz - version: 18.19.1 -generated: "2024-08-19T23:58:25.322532+08:00" diff --git a/litellm-js/proxy/.npmrc b/litellm-js/proxy/.npmrc deleted file mode 100644 index 7999681cc3..0000000000 --- a/litellm-js/proxy/.npmrc +++ /dev/null @@ -1,5 +0,0 @@ -# Supply-chain hardening -# Packages needing lifecycle scripts: npm rebuild -ignore-scripts=true -# Protects local npm install only — npm ci (used in CI) ignores this -min-release-age=3 diff --git a/litellm-js/proxy/README.md b/litellm-js/proxy/README.md deleted file mode 100644 index cc58e962d8..0000000000 --- a/litellm-js/proxy/README.md +++ /dev/null @@ -1,8 +0,0 @@ -``` -npm install -npm run dev -``` - -``` -npm run deploy -``` diff --git a/litellm-js/proxy/package-lock.json b/litellm-js/proxy/package-lock.json deleted file mode 100644 index 0d09fa1a6c..0000000000 --- a/litellm-js/proxy/package-lock.json +++ /dev/null @@ -1,2054 +0,0 @@ -{ - "name": "proxy", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "dependencies": { - "hono": "4.12.16", - "openai": "4.29.2" - }, - "devDependencies": { - "@cloudflare/workers-types": "4.20260501.1", - "wrangler": "4.87.0" - } - }, - "node_modules/@cloudflare/kv-asset-handler": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", - "integrity": "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==", - "dev": true, - "license": "MIT OR Apache-2.0", - "engines": { - "node": ">=22.0.0" - } - }, - "node_modules/@cloudflare/unenv-preset": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.1.tgz", - "integrity": "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==", - "dev": true, - "license": "MIT OR Apache-2.0", - "peerDependencies": { - "unenv": "2.0.0-rc.24", - "workerd": ">1.20260305.0 <2.0.0-0" - }, - "peerDependenciesMeta": { - "workerd": { - "optional": true - } - } - }, - "node_modules/@cloudflare/workerd-darwin-64": { - "version": "1.20260430.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260430.1.tgz", - "integrity": "sha512-ADohZUHf7NBvPp2PdZig2Opxx+hDkk3ve7jrTne3JRx9kDSB73zc4LzcEeEN8LKkbAcqZmvfRJfpChSlusu0lA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=16" - } - }, - "node_modules/@cloudflare/workerd-darwin-arm64": { - "version": "1.20260430.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260430.1.tgz", - "integrity": "sha512-/DoYC/1wHs+YRZzzqSQg1/EHB4hiv1yV5U8FnmapRRIzVaPtnt+ApeOXeMrIdKidgKOI8TqQzgBU8xbIM7Cl4Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=16" - } - }, - "node_modules/@cloudflare/workerd-linux-64": { - "version": "1.20260430.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260430.1.tgz", - "integrity": "sha512-koJhBWvEVZPKCVFtMLp2iMHlYr+lFCF47wGbnlKdHVlemV0zTxJEyHI8aLlrhPLhBmOmYLp46rXw09/qJkRIhQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16" - } - }, - "node_modules/@cloudflare/workerd-linux-arm64": { - "version": "1.20260430.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260430.1.tgz", - "integrity": "sha512-hMdapNAzNQZDXGGkg4Slydc3fRJP5FUZLJVVcZCW/+imhhJro9Z1rv5n/wfR+txKoSWhTYR8eOp8Pyi2bzLzlw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16" - } - }, - "node_modules/@cloudflare/workerd-windows-64": { - "version": "1.20260430.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260430.1.tgz", - "integrity": "sha512-jS3ffixjb5USOwz4frw4WzCz0HrjVxkgyU3WiYb06N7hBAfN6eOrveAJ4QRef0+suK4V1vQFoB1oKdRBsXe9Dw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=16" - } - }, - "node_modules/@cloudflare/workers-types": { - "version": "4.20260501.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260501.1.tgz", - "integrity": "sha512-B/VX2w3my/sCqxKyWOX7SxUpFC1uD8Gh7I2zbI1d3zA8p7Tx03AFsnuEx8lYLmcd8yONAA93YsAZb1wAaLK83w==", - "dev": true, - "license": "MIT OR Apache-2.0" - }, - "node_modules/@cspotcode/source-map-support": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", - "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "0.3.9" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", - "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", - "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", - "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", - "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", - "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", - "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", - "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", - "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", - "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", - "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", - "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", - "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", - "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", - "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", - "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", - "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", - "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", - "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", - "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", - "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", - "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", - "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", - "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", - "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", - "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", - "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@img/colour": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", - "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@img/sharp-darwin-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", - "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.2.4" - } - }, - "node_modules/@img/sharp-darwin-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", - "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.2.4" - } - }, - "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", - "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", - "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", - "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", - "cpu": [ - "arm" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", - "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", - "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", - "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", - "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", - "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", - "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", - "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-linux-arm": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", - "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", - "cpu": [ - "arm" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", - "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-ppc64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", - "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-riscv64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", - "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", - "cpu": [ - "riscv64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-s390x": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", - "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", - "cpu": [ - "s390x" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", - "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.2.4" - } - }, - "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", - "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" - } - }, - "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", - "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.2.4" - } - }, - "node_modules/@img/sharp-wasm32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", - "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", - "optional": true, - "dependencies": { - "@emnapi/runtime": "^1.7.0" - }, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", - "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-ia32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", - "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", - "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", - "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.0.3", - "@jridgewell/sourcemap-codec": "^1.4.10" - } - }, - "node_modules/@poppinss/colors": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", - "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==", - "dev": true, - "license": "MIT", - "dependencies": { - "kleur": "^4.1.5" - } - }, - "node_modules/@poppinss/dumper": { - "version": "0.6.5", - "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz", - "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@poppinss/colors": "^4.1.5", - "@sindresorhus/is": "^7.0.2", - "supports-color": "^10.0.0" - } - }, - "node_modules/@poppinss/exception": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz", - "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@sindresorhus/is": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", - "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sindresorhus/is?sponsor=1" - } - }, - "node_modules/@speed-highlight/core": { - "version": "1.2.15", - "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.15.tgz", - "integrity": "sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw==", - "dev": true, - "license": "CC0-1.0" - }, - "node_modules/@types/node": { - "version": "18.19.130", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", - "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", - "license": "MIT", - "dependencies": { - "undici-types": "~5.26.4" - } - }, - "node_modules/@types/node-fetch": { - "version": "2.6.13", - "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", - "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", - "license": "MIT", - "dependencies": { - "@types/node": "*", - "form-data": "^4.0.4" - } - }, - "node_modules/abort-controller": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", - "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", - "license": "MIT", - "dependencies": { - "event-target-shim": "^5.0.0" - }, - "engines": { - "node": ">=6.5" - } - }, - "node_modules/agentkeepalive": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", - "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", - "license": "MIT", - "dependencies": { - "humanize-ms": "^1.2.1" - }, - "engines": { - "node": ">= 8.0.0" - } - }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "license": "MIT" - }, - "node_modules/base-64": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/base-64/-/base-64-0.1.0.tgz", - "integrity": "sha512-Y5gU45svrR5tI2Vt/X9GPd3L0HNIKzGu202EjxrXMpuc2V2CiKgemAbUUsqYmZJvPtCXoUKjNZwBJzsNScUbXA==" - }, - "node_modules/blake3-wasm": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", - "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", - "dev": true, - "license": "MIT" - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/charenc": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", - "integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==", - "license": "BSD-3-Clause", - "engines": { - "node": "*" - } - }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/cookie": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", - "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/crypt": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", - "integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==", - "license": "BSD-3-Clause", - "engines": { - "node": "*" - } - }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, - "node_modules/digest-fetch": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/digest-fetch/-/digest-fetch-1.3.0.tgz", - "integrity": "sha512-CGJuv6iKNM7QyZlM2T3sPAdZWd/p9zQiRNS9G+9COUCwzWFTs0Xp8NF5iePx7wtvhDykReiRRrSeNb4oMmB8lA==", - "license": "ISC", - "dependencies": { - "base-64": "^0.1.0", - "md5": "^2.3.0" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/error-stack-parser-es": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", - "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/esbuild": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", - "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.3", - "@esbuild/android-arm": "0.27.3", - "@esbuild/android-arm64": "0.27.3", - "@esbuild/android-x64": "0.27.3", - "@esbuild/darwin-arm64": "0.27.3", - "@esbuild/darwin-x64": "0.27.3", - "@esbuild/freebsd-arm64": "0.27.3", - "@esbuild/freebsd-x64": "0.27.3", - "@esbuild/linux-arm": "0.27.3", - "@esbuild/linux-arm64": "0.27.3", - "@esbuild/linux-ia32": "0.27.3", - "@esbuild/linux-loong64": "0.27.3", - "@esbuild/linux-mips64el": "0.27.3", - "@esbuild/linux-ppc64": "0.27.3", - "@esbuild/linux-riscv64": "0.27.3", - "@esbuild/linux-s390x": "0.27.3", - "@esbuild/linux-x64": "0.27.3", - "@esbuild/netbsd-arm64": "0.27.3", - "@esbuild/netbsd-x64": "0.27.3", - "@esbuild/openbsd-arm64": "0.27.3", - "@esbuild/openbsd-x64": "0.27.3", - "@esbuild/openharmony-arm64": "0.27.3", - "@esbuild/sunos-x64": "0.27.3", - "@esbuild/win32-arm64": "0.27.3", - "@esbuild/win32-ia32": "0.27.3", - "@esbuild/win32-x64": "0.27.3" - } - }, - "node_modules/event-target-shim": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", - "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/form-data-encoder": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz", - "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==", - "license": "MIT" - }, - "node_modules/formdata-node": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz", - "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==", - "license": "MIT", - "dependencies": { - "node-domexception": "1.0.0", - "web-streams-polyfill": "4.0.0-beta.3" - }, - "engines": { - "node": ">= 12.20" - } - }, - "node_modules/formdata-node/node_modules/web-streams-polyfill": { - "version": "4.0.0-beta.3", - "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", - "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==", - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", - "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/hono": { - "version": "4.12.16", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.16.tgz", - "integrity": "sha512-jN0ZewiNAWSe5khM3EyCmBb250+b40wWbwNILNfEvq84VREWwOIkuUsFONk/3i3nqkz7Oe1PcpM2mwQEK2L9Kg==", - "license": "MIT", - "engines": { - "node": ">=16.9.0" - } - }, - "node_modules/humanize-ms": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", - "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", - "license": "MIT", - "dependencies": { - "ms": "^2.0.0" - } - }, - "node_modules/is-buffer": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", - "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", - "license": "MIT" - }, - "node_modules/kleur": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", - "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/md5": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", - "integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==", - "license": "BSD-3-Clause", - "dependencies": { - "charenc": "0.0.2", - "crypt": "0.0.2", - "is-buffer": "~1.1.6" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/miniflare": { - "version": "4.20260430.0", - "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260430.0.tgz", - "integrity": "sha512-MWvMm3Siho9Yj7lbJZidLs8hbrRvIcOrif2mnsHQZdvoKfedpea+GaN8XJxbpRcq0B2WzNI1BB1ihdnqes3/ZA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@cspotcode/source-map-support": "0.8.1", - "sharp": "^0.34.5", - "undici": "7.24.8", - "workerd": "1.20260430.1", - "ws": "8.18.0", - "youch": "4.1.0-beta.10" - }, - "bin": { - "miniflare": "bootstrap.js" - }, - "engines": { - "node": ">=22.0.0" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/node-domexception": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", - "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", - "deprecated": "Use your platform's native DOMException instead", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "github", - "url": "https://paypal.me/jimmywarting" - } - ], - "license": "MIT", - "engines": { - "node": ">=10.5.0" - } - }, - "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "license": "MIT", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, - "node_modules/openai": { - "version": "4.29.2", - "resolved": "https://registry.npmjs.org/openai/-/openai-4.29.2.tgz", - "integrity": "sha512-cPkT6zjEcE4qU5OW/SoDDuXEsdOLrXlAORhzmaguj5xZSPlgKvLhi27sFWhLKj07Y6WKNWxcwIbzm512FzTBNQ==", - "license": "Apache-2.0", - "dependencies": { - "@types/node": "^18.11.18", - "@types/node-fetch": "^2.6.4", - "abort-controller": "^3.0.0", - "agentkeepalive": "^4.2.1", - "digest-fetch": "^1.3.0", - "form-data-encoder": "1.7.2", - "formdata-node": "^4.3.2", - "node-fetch": "^2.6.7", - "web-streams-polyfill": "^3.2.1" - }, - "bin": { - "openai": "bin/cli" - } - }, - "node_modules/path-to-regexp": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", - "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, - "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/sharp": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", - "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", - "dev": true, - "hasInstallScript": true, - "license": "Apache-2.0", - "dependencies": { - "@img/colour": "^1.0.0", - "detect-libc": "^2.1.2", - "semver": "^7.7.3" - }, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.34.5", - "@img/sharp-darwin-x64": "0.34.5", - "@img/sharp-libvips-darwin-arm64": "1.2.4", - "@img/sharp-libvips-darwin-x64": "1.2.4", - "@img/sharp-libvips-linux-arm": "1.2.4", - "@img/sharp-libvips-linux-arm64": "1.2.4", - "@img/sharp-libvips-linux-ppc64": "1.2.4", - "@img/sharp-libvips-linux-riscv64": "1.2.4", - "@img/sharp-libvips-linux-s390x": "1.2.4", - "@img/sharp-libvips-linux-x64": "1.2.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", - "@img/sharp-libvips-linuxmusl-x64": "1.2.4", - "@img/sharp-linux-arm": "0.34.5", - "@img/sharp-linux-arm64": "0.34.5", - "@img/sharp-linux-ppc64": "0.34.5", - "@img/sharp-linux-riscv64": "0.34.5", - "@img/sharp-linux-s390x": "0.34.5", - "@img/sharp-linux-x64": "0.34.5", - "@img/sharp-linuxmusl-arm64": "0.34.5", - "@img/sharp-linuxmusl-x64": "0.34.5", - "@img/sharp-wasm32": "0.34.5", - "@img/sharp-win32-arm64": "0.34.5", - "@img/sharp-win32-ia32": "0.34.5", - "@img/sharp-win32-x64": "0.34.5" - } - }, - "node_modules/supports-color": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", - "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "license": "MIT" - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true - }, - "node_modules/undici": { - "version": "7.24.8", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.8.tgz", - "integrity": "sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20.18.1" - } - }, - "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "license": "MIT" - }, - "node_modules/unenv": { - "version": "2.0.0-rc.24", - "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", - "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "pathe": "^2.0.3" - } - }, - "node_modules/web-streams-polyfill": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", - "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "license": "BSD-2-Clause" - }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "license": "MIT", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, - "node_modules/workerd": { - "version": "1.20260430.1", - "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260430.1.tgz", - "integrity": "sha512-KEgIWyiw3Jmn+DCd/L3ePo5fmiiYb/UcwKvDWPf/nLLOiwShDFzDSsegU5NY/JcwgvO/QsLHVi2FYrbkcXNY5Q==", - "dev": true, - "hasInstallScript": true, - "license": "Apache-2.0", - "bin": { - "workerd": "bin/workerd" - }, - "engines": { - "node": ">=16" - }, - "optionalDependencies": { - "@cloudflare/workerd-darwin-64": "1.20260430.1", - "@cloudflare/workerd-darwin-arm64": "1.20260430.1", - "@cloudflare/workerd-linux-64": "1.20260430.1", - "@cloudflare/workerd-linux-arm64": "1.20260430.1", - "@cloudflare/workerd-windows-64": "1.20260430.1" - } - }, - "node_modules/wrangler": { - "version": "4.87.0", - "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.87.0.tgz", - "integrity": "sha512-lfhfKwLfQlowwgV0xhlYgE9fU3n0I30d4ccGY/rTCEm/n42Mjvlr0Ng3ZPNqlsrsKBcDR531V7dsPkgELvrk/Q==", - "dev": true, - "license": "MIT OR Apache-2.0", - "dependencies": { - "@cloudflare/kv-asset-handler": "0.5.0", - "@cloudflare/unenv-preset": "2.16.1", - "blake3-wasm": "2.1.5", - "esbuild": "0.27.3", - "miniflare": "4.20260430.0", - "path-to-regexp": "6.3.0", - "unenv": "2.0.0-rc.24", - "workerd": "1.20260430.1" - }, - "bin": { - "wrangler": "bin/wrangler.js", - "wrangler2": "bin/wrangler.js" - }, - "engines": { - "node": ">=22.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - }, - "peerDependencies": { - "@cloudflare/workers-types": "^4.20260430.1" - }, - "peerDependenciesMeta": { - "@cloudflare/workers-types": { - "optional": true - } - } - }, - "node_modules/ws": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", - "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/youch": { - "version": "4.1.0-beta.10", - "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", - "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@poppinss/colors": "^4.1.5", - "@poppinss/dumper": "^0.6.4", - "@speed-highlight/core": "^1.2.7", - "cookie": "^1.0.2", - "youch-core": "^0.3.3" - } - }, - "node_modules/youch-core": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz", - "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@poppinss/exception": "^1.2.2", - "error-stack-parser-es": "^1.0.5" - } - } - } -} diff --git a/litellm-js/proxy/package.json b/litellm-js/proxy/package.json deleted file mode 100644 index 9fd94cd882..0000000000 --- a/litellm-js/proxy/package.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "scripts": { - "dev": "wrangler dev src/index.ts", - "deploy": "wrangler deploy --minify src/index.ts" - }, - "dependencies": { - "hono": "4.12.16", - "openai": "4.29.2" - }, - "devDependencies": { - "@cloudflare/workers-types": "4.20260501.1", - "wrangler": "4.87.0" - } -} diff --git a/litellm-js/proxy/src/index.ts b/litellm-js/proxy/src/index.ts deleted file mode 100644 index dc5dc9c689..0000000000 --- a/litellm-js/proxy/src/index.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { Hono } from 'hono' -import { Context } from 'hono'; -import { bearerAuth } from 'hono/bearer-auth' -import OpenAI from "openai"; - -const openai = new OpenAI({ - apiKey: "sk-1234", - baseURL: "https://openai-endpoint.ishaanjaffer0324.workers.dev" -}); - -async function call_proxy() { - const completion = await openai.chat.completions.create({ - messages: [{ role: "system", content: "You are a helpful assistant." }], - model: "gpt-3.5-turbo", - }); - - return completion -} - -const app = new Hono() - -// Middleware for API Key Authentication -const apiKeyAuth = async (c: Context, next: Function) => { - const apiKey = c.req.header('Authorization'); - if (!apiKey || apiKey !== 'Bearer sk-1234') { - return c.text('Unauthorized', 401); - } - await next(); -}; - - -app.use('/*', apiKeyAuth) - - -app.get('/', (c) => { - return c.text('Hello Hono!') -}) - - - - -// Handler for chat completions -const chatCompletionHandler = async (c: Context) => { - // Assuming your logic for handling chat completion goes here - // For demonstration, just returning a simple JSON response - const response = await call_proxy() - return c.json(response); -}; - -// Register the above handler for different POST routes with the apiKeyAuth middleware -app.post('/v1/chat/completions', chatCompletionHandler); -app.post('/chat/completions', chatCompletionHandler); - -// Example showing how you might handle dynamic segments within the URL -// Here, using ':model*' to capture the rest of the path as a parameter 'model' -app.post('/openai/deployments/:model*/chat/completions', chatCompletionHandler); - - -export default app diff --git a/litellm-js/proxy/tsconfig.json b/litellm-js/proxy/tsconfig.json deleted file mode 100644 index 28fcfb5824..0000000000 --- a/litellm-js/proxy/tsconfig.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "compilerOptions": { - "target": "ESNext", - "module": "ESNext", - "moduleResolution": "Bundler", - "strict": true, - "lib": [ - "ESNext" - ], - "types": [ - "@cloudflare/workers-types" - ], - "jsx": "react-jsx", - "jsxImportSource": "hono/jsx", - "skipLibCheck": true - }, -} \ No newline at end of file diff --git a/litellm-js/proxy/wrangler.toml b/litellm-js/proxy/wrangler.toml deleted file mode 100644 index e7c323dff9..0000000000 --- a/litellm-js/proxy/wrangler.toml +++ /dev/null @@ -1,18 +0,0 @@ -name = "my-app" -compatibility_date = "2023-12-01" - -# [vars] -# MY_VAR = "my-variable" - -# [[kv_namespaces]] -# binding = "MY_KV_NAMESPACE" -# id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" - -# [[r2_buckets]] -# binding = "MY_BUCKET" -# bucket_name = "my-bucket" - -# [[d1_databases]] -# binding = "DB" -# database_name = "my-database" -# database_id = "" diff --git a/litellm-js/spend-logs/.npmrc b/litellm-js/spend-logs/.npmrc deleted file mode 100644 index 7999681cc3..0000000000 --- a/litellm-js/spend-logs/.npmrc +++ /dev/null @@ -1,5 +0,0 @@ -# Supply-chain hardening -# Packages needing lifecycle scripts: npm rebuild -ignore-scripts=true -# Protects local npm install only — npm ci (used in CI) ignores this -min-release-age=3 diff --git a/litellm-js/spend-logs/Dockerfile b/litellm-js/spend-logs/Dockerfile deleted file mode 100644 index 5040dc74bf..0000000000 --- a/litellm-js/spend-logs/Dockerfile +++ /dev/null @@ -1,26 +0,0 @@ -# Use the specific Node.js v20.11.0 image -FROM node:20.18.1-alpine3.20 - -# Set the working directory inside the container -WORKDIR /app - -# Copy package.json and package-lock.json to the working directory -COPY ./litellm-js/spend-logs/package*.json ./ - -# Install dependencies -RUN npm ci - -# Install Prisma globally -RUN npm install -g prisma - -# Copy the rest of the application code -COPY ./litellm-js/spend-logs . - -# Generate Prisma client -RUN npx prisma generate - -# Expose the port that the Node.js server will run on -EXPOSE 3000 - -# Command to run the Node.js app with npm run dev -CMD ["npm", "run", "dev"] diff --git a/litellm-js/spend-logs/README.md b/litellm-js/spend-logs/README.md deleted file mode 100644 index e12b31db70..0000000000 --- a/litellm-js/spend-logs/README.md +++ /dev/null @@ -1,8 +0,0 @@ -``` -npm install -npm run dev -``` - -``` -open http://localhost:3000 -``` diff --git a/litellm-js/spend-logs/package-lock.json b/litellm-js/spend-logs/package-lock.json deleted file mode 100644 index e33079766c..0000000000 --- a/litellm-js/spend-logs/package-lock.json +++ /dev/null @@ -1,597 +0,0 @@ -{ - "name": "spend-logs", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "dependencies": { - "@hono/node-server": "1.19.13", - "hono": "4.12.16" - }, - "devDependencies": { - "@types/node": "20.19.25", - "tsx": "4.20.6" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@hono/node-server": { - "version": "1.19.13", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.13.tgz", - "integrity": "sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ==", - "license": "MIT", - "engines": { - "node": ">=18.14.1" - }, - "peerDependencies": { - "hono": "^4" - } - }, - "node_modules/@types/node": { - "version": "20.19.25", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.25.tgz", - "integrity": "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/esbuild": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.12", - "@esbuild/android-arm": "0.25.12", - "@esbuild/android-arm64": "0.25.12", - "@esbuild/android-x64": "0.25.12", - "@esbuild/darwin-arm64": "0.25.12", - "@esbuild/darwin-x64": "0.25.12", - "@esbuild/freebsd-arm64": "0.25.12", - "@esbuild/freebsd-x64": "0.25.12", - "@esbuild/linux-arm": "0.25.12", - "@esbuild/linux-arm64": "0.25.12", - "@esbuild/linux-ia32": "0.25.12", - "@esbuild/linux-loong64": "0.25.12", - "@esbuild/linux-mips64el": "0.25.12", - "@esbuild/linux-ppc64": "0.25.12", - "@esbuild/linux-riscv64": "0.25.12", - "@esbuild/linux-s390x": "0.25.12", - "@esbuild/linux-x64": "0.25.12", - "@esbuild/netbsd-arm64": "0.25.12", - "@esbuild/netbsd-x64": "0.25.12", - "@esbuild/openbsd-arm64": "0.25.12", - "@esbuild/openbsd-x64": "0.25.12", - "@esbuild/openharmony-arm64": "0.25.12", - "@esbuild/sunos-x64": "0.25.12", - "@esbuild/win32-arm64": "0.25.12", - "@esbuild/win32-ia32": "0.25.12", - "@esbuild/win32-x64": "0.25.12" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/get-tsconfig": { - "version": "4.14.0", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", - "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", - "dev": true, - "license": "MIT", - "dependencies": { - "resolve-pkg-maps": "^1.0.0" - }, - "funding": { - "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" - } - }, - "node_modules/hono": { - "version": "4.12.16", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.16.tgz", - "integrity": "sha512-jN0ZewiNAWSe5khM3EyCmBb250+b40wWbwNILNfEvq84VREWwOIkuUsFONk/3i3nqkz7Oe1PcpM2mwQEK2L9Kg==", - "license": "MIT", - "engines": { - "node": ">=16.9.0" - } - }, - "node_modules/resolve-pkg-maps": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", - "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" - } - }, - "node_modules/tsx": { - "version": "4.20.6", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.6.tgz", - "integrity": "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "~0.25.0", - "get-tsconfig": "^4.7.5" - }, - "bin": { - "tsx": "dist/cli.mjs" - }, - "engines": { - "node": ">=18.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - } - }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - } - } -} diff --git a/litellm-js/spend-logs/package.json b/litellm-js/spend-logs/package.json deleted file mode 100644 index 5a7a95c5de..0000000000 --- a/litellm-js/spend-logs/package.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "scripts": { - "dev": "tsx watch src/index.ts" - }, - "dependencies": { - "@hono/node-server": "1.19.13", - "hono": "4.12.16" - }, - "devDependencies": { - "@types/node": "20.19.25", - "tsx": "4.20.6" - } -} diff --git a/litellm-js/spend-logs/schema.prisma b/litellm-js/spend-logs/schema.prisma deleted file mode 100644 index b0403f277a..0000000000 --- a/litellm-js/spend-logs/schema.prisma +++ /dev/null @@ -1,29 +0,0 @@ -generator client { - provider = "prisma-client-js" -} - -datasource client { - provider = "postgresql" - url = env("DATABASE_URL") -} - -model LiteLLM_SpendLogs { - request_id String @id - call_type String - api_key String @default("") - spend Float @default(0.0) - total_tokens Int @default(0) - prompt_tokens Int @default(0) - completion_tokens Int @default(0) - startTime DateTime - endTime DateTime - model String @default("") - api_base String @default("") - user String @default("") - metadata Json @default("{}") - cache_hit String @default("") - cache_key String @default("") - request_tags Json @default("[]") - team_id String? - end_user String? -} \ No newline at end of file diff --git a/litellm-js/spend-logs/src/_types.ts b/litellm-js/spend-logs/src/_types.ts deleted file mode 100644 index 6a9b499171..0000000000 --- a/litellm-js/spend-logs/src/_types.ts +++ /dev/null @@ -1,32 +0,0 @@ -export type LiteLLM_IncrementSpend = { - key_transactions: Array, // [{"key": spend},..] - user_transactions: Array, - team_transactions: Array, - spend_logs_transactions: Array -} - -export type LiteLLM_IncrementObject = { - key: string, - spend: number -} - -export type LiteLLM_SpendLogs = { - request_id: string; // @id means it's a unique identifier - call_type: string; - api_key: string; // @default("") means it defaults to an empty string if not provided - spend: number; // Float in Prisma corresponds to number in TypeScript - total_tokens: number; // Int in Prisma corresponds to number in TypeScript - prompt_tokens: number; - completion_tokens: number; - startTime: Date; // DateTime in Prisma corresponds to Date in TypeScript - endTime: Date; - model: string; // @default("") means it defaults to an empty string if not provided - api_base: string; - user: string; - metadata: any; // Json type in Prisma is represented by any in TypeScript; could also use a more specific type if the structure of JSON is known - cache_hit: string; - cache_key: string; - request_tags: any; // Similarly, this could be an array or a more specific type depending on the expected structure - team_id?: string | null; // ? indicates it's optional and can be undefined, but could also be null if not provided - end_user?: string | null; -}; \ No newline at end of file diff --git a/litellm-js/spend-logs/src/index.ts b/litellm-js/spend-logs/src/index.ts deleted file mode 100644 index 3581d95c83..0000000000 --- a/litellm-js/spend-logs/src/index.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { serve } from '@hono/node-server' -import { Hono } from 'hono' -import { PrismaClient } from '@prisma/client' -import {LiteLLM_SpendLogs, LiteLLM_IncrementSpend, LiteLLM_IncrementObject} from './_types' - -const app = new Hono() -const prisma = new PrismaClient() -// In-memory storage for logs -let spend_logs: LiteLLM_SpendLogs[] = []; -const key_logs: LiteLLM_IncrementObject[] = []; -const user_logs: LiteLLM_IncrementObject[] = []; -const transaction_logs: LiteLLM_IncrementObject[] = []; - - -app.get('/', (c) => { - return c.text('Hello Hono!') -}) - -const MIN_LOGS = 1; // Minimum number of logs needed to initiate a flush -const FLUSH_INTERVAL = 5000; // Time in ms to wait before trying to flush again -const BATCH_SIZE = 100; // Preferred size of each batch to write to the database -const MAX_LOGS_PER_INTERVAL = 1000; // Maximum number of logs to flush in a single interval - -const flushLogsToDb = async () => { - if (spend_logs.length >= MIN_LOGS) { - // Limit the logs to process in this interval to MAX_LOGS_PER_INTERVAL or less - const logsToProcess = spend_logs.slice(0, MAX_LOGS_PER_INTERVAL); - - for (let i = 0; i < logsToProcess.length; i += BATCH_SIZE) { - // Create subarray for current batch, ensuring it doesn't exceed the BATCH_SIZE - const batch = logsToProcess.slice(i, i + BATCH_SIZE); - - // Convert datetime strings to Date objects - const batchWithDates = batch.map(entry => ({ - ...entry, - startTime: new Date(entry.startTime), - endTime: new Date(entry.endTime), - // Repeat for any other DateTime fields you may have - })); - - await prisma.liteLLM_SpendLogs.createMany({ - data: batchWithDates, - }); - - console.log(`Flushed ${batch.length} logs to the DB.`); - } - - // Remove the processed logs from spend_logs - spend_logs = spend_logs.slice(logsToProcess.length); - - console.log(`${logsToProcess.length} logs processed. Remaining in queue: ${spend_logs.length}`); - } else { - // This will ensure it doesn't falsely claim "No logs to flush." when it's merely below the MIN_LOGS threshold. - if(spend_logs.length > 0) { - console.log(`Accumulating logs. Currently at ${spend_logs.length}, waiting for at least ${MIN_LOGS}.`); - } else { - console.log("No logs to flush."); - } - } -}; - -// Setup interval for attempting to flush the logs -setInterval(flushLogsToDb, FLUSH_INTERVAL); - -// Route to receive log messages -app.post('/spend/update', async (c) => { - const incomingLogs = await c.req.json(); - - spend_logs.push(...incomingLogs); - - console.log(`Received and stored ${incomingLogs.length} logs. Total logs in memory: ${spend_logs.length}`); - - return c.json({ message: `Successfully stored ${incomingLogs.length} logs` }); -}); - - - -const port = 3000 -console.log(`Server is running on port ${port}`) - -serve({ - fetch: app.fetch, - port -}) diff --git a/litellm-js/spend-logs/tsconfig.json b/litellm-js/spend-logs/tsconfig.json deleted file mode 100644 index 028c03b6a8..0000000000 --- a/litellm-js/spend-logs/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "compilerOptions": { - "target": "ESNext", - "module": "ESNext", - "moduleResolution": "Bundler", - "strict": true, - "types": [ - "node" - ], - "jsx": "react-jsx", - "jsxImportSource": "hono/jsx", - } -} \ No newline at end of file From d67dfca1e111cbb63f662cc31878d8e475113baa Mon Sep 17 00:00:00 2001 From: ishaan-berri <155045088+ishaan-berri@users.noreply.github.com> Date: Sat, 9 May 2026 14:47:48 -0700 Subject: [PATCH 24/28] Fix proxy auth status code tests (#27555) * Fix proxy auth status code tests Co-authored-by: ishaan-berri * Update user model access status expectation Co-authored-by: ishaan-berri --------- Co-authored-by: oss-agent-shin <279349115+oss-agent-shin@users.noreply.github.com> Co-authored-by: ishaan-berri --- litellm/proxy/auth/auth_checks.py | 6 +-- litellm/proxy/auth/auth_exception_handler.py | 2 +- litellm/proxy/auth/user_api_key_auth.py | 6 +-- tests/otel_tests/test_e2e_budgeting.py | 4 +- tests/otel_tests/test_e2e_model_access.py | 4 +- .../proxy/auth/test_auth_checks.py | 49 +++++++++++++++++++ .../proxy/auth/test_auth_exception_handler.py | 1 + .../proxy/auth/test_user_api_key_auth.py | 23 +++++++++ tests/test_openai_endpoints.py | 2 +- tests/test_users.py | 4 +- 10 files changed, 87 insertions(+), 14 deletions(-) diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py index f6f99eb62c..0b30999aa2 100644 --- a/litellm/proxy/auth/auth_checks.py +++ b/litellm/proxy/auth/auth_checks.py @@ -2849,7 +2849,7 @@ def _can_object_call_model( object_type=object_type ), param="model", - code=status.HTTP_401_UNAUTHORIZED, + code=status.HTTP_403_FORBIDDEN, ) @@ -3082,7 +3082,7 @@ async def can_user_call_model( message=f"User not allowed to access model. No default model access, only team models allowed. Tried to access {model}", type=ProxyErrorTypes.key_model_access_denied, param="model", - code=status.HTTP_401_UNAUTHORIZED, + code=status.HTTP_403_FORBIDDEN, ) return _can_object_call_model( @@ -3625,7 +3625,7 @@ async def _check_team_member_model_access( message=f"Team member not allowed to access model. User={valid_token.user_id}, Team={team_object.team_id}, Model={model}. Allowed member models = {member_allowed_models}", type=ProxyErrorTypes.team_model_access_denied, param="model", - code=status.HTTP_401_UNAUTHORIZED, + code=status.HTTP_403_FORBIDDEN, ) diff --git a/litellm/proxy/auth/auth_exception_handler.py b/litellm/proxy/auth/auth_exception_handler.py index 5ded8136ef..431db4254e 100644 --- a/litellm/proxy/auth/auth_exception_handler.py +++ b/litellm/proxy/auth/auth_exception_handler.py @@ -123,7 +123,7 @@ class UserAPIKeyAuthExceptionHandler: message=e.message, type=ProxyErrorTypes.budget_exceeded, param=None, - code=400, + code=getattr(e, "status_code", status.HTTP_429_TOO_MANY_REQUESTS), ) if isinstance(e, HTTPException): raise ProxyException( diff --git a/litellm/proxy/auth/user_api_key_auth.py b/litellm/proxy/auth/user_api_key_auth.py index 9d3c06e641..4778549bef 100644 --- a/litellm/proxy/auth/user_api_key_auth.py +++ b/litellm/proxy/auth/user_api_key_auth.py @@ -1107,7 +1107,7 @@ async def _user_api_key_auth_builder( # noqa: PLR0915 raise ProxyException( message=f"Authentication Error - Expired Key. Key Expiry time {expiry_time} and current time {current_time}", type=ProxyErrorTypes.expired_key, - code=400, + code=status.HTTP_401_UNAUTHORIZED, param=abbreviate_api_key(api_key=api_key), ) valid_token = update_valid_token_with_end_user_params( @@ -1432,7 +1432,7 @@ async def _user_api_key_auth_builder( # noqa: PLR0915 raise ProxyException( message=f"Authentication Error - Expired Key. Key Expiry time {expiry_time} and current time {current_time}", type=ProxyErrorTypes.expired_key, - code=400, + code=status.HTTP_401_UNAUTHORIZED, param=abbreviate_api_key(api_key=api_key), ) @@ -2417,7 +2417,7 @@ async def _run_post_custom_auth_checks( raise ProxyException( message=f"Authentication Error - Expired Key. Key Expiry time {expiry_time} and current time {current_time}", type=ProxyErrorTypes.expired_key, - code=400, + code=status.HTTP_401_UNAUTHORIZED, param=( abbreviate_api_key(api_key=valid_token.token) if valid_token.token diff --git a/tests/otel_tests/test_e2e_budgeting.py b/tests/otel_tests/test_e2e_budgeting.py index 62fc8732eb..f61befac4f 100644 --- a/tests/otel_tests/test_e2e_budgeting.py +++ b/tests/otel_tests/test_e2e_budgeting.py @@ -25,8 +25,8 @@ async def make_calls_until_budget_exceeded(session, key: str, call_function, **k # Check error structure and values that should be consistent assert ( - error_dict["code"] == "400" - ), f"Expected error code 400, got: {error_dict['code']}" + error_dict["code"] == "429" + ), f"Expected error code 429, got: {error_dict['code']}" assert ( error_dict["type"] == "budget_exceeded" ), f"Expected error type budget_exceeded, got: {error_dict['type']}" diff --git a/tests/otel_tests/test_e2e_model_access.py b/tests/otel_tests/test_e2e_model_access.py index 7ea75a9d61..87d85a1960 100644 --- a/tests/otel_tests/test_e2e_model_access.py +++ b/tests/otel_tests/test_e2e_model_access.py @@ -99,7 +99,7 @@ async def test_model_access_patterns(key_models, test_model, expect_success): # Assert error structure and values assert _error_body["type"] == "key_model_access_denied" assert _error_body["param"] == "model" - assert _error_body["code"] == "401" + assert _error_body["code"] == "403" assert "key not allowed to access model" in _error_body["message"] @@ -297,7 +297,7 @@ def _validate_model_access_exception( # Assert error structure and values assert _error_body["type"] == expected_type assert _error_body["param"] == "model" - assert _error_body["code"] == "401" + assert _error_body["code"] == "403" if expected_type == "key_model_access_denied": assert "key not allowed to access model" in _error_body["message"] elif expected_type == "team_model_access_denied": diff --git a/tests/test_litellm/proxy/auth/test_auth_checks.py b/tests/test_litellm/proxy/auth/test_auth_checks.py index 8a854bcd6a..26f04a4abc 100644 --- a/tests/test_litellm/proxy/auth/test_auth_checks.py +++ b/tests/test_litellm/proxy/auth/test_auth_checks.py @@ -12,6 +12,7 @@ from datetime import datetime, timedelta import httpx import pytest +from fastapi import status import litellm from litellm.proxy._types import ( @@ -31,6 +32,7 @@ from litellm.proxy._types import ( ) from litellm.proxy.auth.auth_checks import ( ExperimentalUIJWTToken, + _can_object_call_model, _can_object_call_vector_stores, _check_end_user_budget, _check_team_member_budget, @@ -206,6 +208,52 @@ def test_get_key_object_from_ui_hash_key_invalid(): assert key_object is None +@pytest.mark.parametrize( + "object_type,expected_error_type", + [ + ("key", ProxyErrorTypes.key_model_access_denied), + ("team", ProxyErrorTypes.team_model_access_denied), + ("user", ProxyErrorTypes.user_model_access_denied), + ("org", ProxyErrorTypes.org_model_access_denied), + ("project", ProxyErrorTypes.project_model_access_denied), + ], +) +def test_can_object_call_model_denials_return_forbidden( + object_type, expected_error_type +): + with pytest.raises(ProxyException) as exc_info: + _can_object_call_model( + model="restricted-model", + llm_router=None, + models=["allowed-model"], + object_type=object_type, + ) + + assert exc_info.value.type == expected_error_type + assert int(exc_info.value.code) == status.HTTP_403_FORBIDDEN + + +@pytest.mark.asyncio +async def test_can_user_call_model_no_default_models_returns_forbidden(): + from litellm.proxy._types import SpecialModelNames + from litellm.proxy.auth.auth_checks import can_user_call_model + + user_object = LiteLLM_UserTable( + user_id="test-user", + models=[SpecialModelNames.no_default_models.value], + ) + + with pytest.raises(ProxyException) as exc_info: + await can_user_call_model( + model="restricted-model", + llm_router=None, + user_object=user_object, + ) + + assert exc_info.value.type == ProxyErrorTypes.key_model_access_denied + assert int(exc_info.value.code) == status.HTTP_403_FORBIDDEN + + @pytest.mark.asyncio async def test_get_key_object_should_reconnect_once_on_db_connection_error(): mock_prisma_client = MagicMock() @@ -1144,6 +1192,7 @@ async def test_check_team_member_model_access_denied_model(): proxy_logging_obj=MagicMock(), ) assert exc_info.value.type == ProxyErrorTypes.team_model_access_denied + assert int(exc_info.value.code) == status.HTTP_403_FORBIDDEN @pytest.mark.asyncio diff --git a/tests/test_litellm/proxy/auth/test_auth_exception_handler.py b/tests/test_litellm/proxy/auth/test_auth_exception_handler.py index 2d1586f0b1..4ccde85dae 100644 --- a/tests/test_litellm/proxy/auth/test_auth_exception_handler.py +++ b/tests/test_litellm/proxy/auth/test_auth_exception_handler.py @@ -140,6 +140,7 @@ async def test_handle_authentication_error_budget_exceeded(): ) assert exc_info.value.type == ProxyErrorTypes.budget_exceeded + assert int(exc_info.value.code) == status.HTTP_429_TOO_MANY_REQUESTS @pytest.mark.asyncio diff --git a/tests/test_litellm/proxy/auth/test_user_api_key_auth.py b/tests/test_litellm/proxy/auth/test_user_api_key_auth.py index 95b3d746c6..50c5f43b21 100644 --- a/tests/test_litellm/proxy/auth/test_user_api_key_auth.py +++ b/tests/test_litellm/proxy/auth/test_user_api_key_auth.py @@ -1,6 +1,7 @@ import json import os import sys +from datetime import datetime, timedelta from types import SimpleNamespace from unittest.mock import ANY, AsyncMock, MagicMock, patch @@ -9,6 +10,7 @@ sys.path.insert( ) # Adds the parent directory to the system path import pytest +from fastapi import status import litellm import litellm.proxy.proxy_server @@ -178,6 +180,26 @@ async def test_custom_auth_does_not_enforce_key_model_access_by_default(): mock_can_key.assert_not_awaited() +@pytest.mark.asyncio +async def test_post_custom_auth_expired_key_returns_unauthorized(): + expired_token = UserAPIKeyAuth( + token="test_token", + expires=datetime.now() - timedelta(minutes=1), + ) + + with pytest.raises(ProxyException) as exc_info: + await _run_post_custom_auth_checks( + valid_token=expired_token, + request=MagicMock(), + request_data={}, + route="/v1/chat/completions", + parent_otel_span=None, + ) + + assert exc_info.value.type == ProxyErrorTypes.expired_key + assert int(exc_info.value.code) == status.HTTP_401_UNAUTHORIZED + + @pytest.mark.asyncio async def test_custom_auth_honors_key_level_model_access_restriction_allowed_with_opt_in(): valid_token = UserAPIKeyAuth(token="test_token", models=["gpt-4o-mini"]) @@ -934,6 +956,7 @@ async def test_proxy_admin_expired_key_from_cache(): assert ( exc_info.value.type == ProxyErrorTypes.expired_key ), f"Expected expired_key error type, got {exc_info.value.type}" + assert int(exc_info.value.code) == status.HTTP_401_UNAUTHORIZED assert "Expired Key" in str( exc_info.value.message ), f"Exception message should mention 'Expired Key', got: {exc_info.value.message}" diff --git a/tests/test_openai_endpoints.py b/tests/test_openai_endpoints.py index 024d05e103..8a3f9361ba 100644 --- a/tests/test_openai_endpoints.py +++ b/tests/test_openai_endpoints.py @@ -303,7 +303,7 @@ async def test_chat_completion(): api_key=key_gen["key"], api_version="2024-02-15-preview", ) - with pytest.raises(openai.AuthenticationError) as e: + with pytest.raises(openai.PermissionDeniedError) as e: response = await azure_client.chat.completions.create( model="gpt-4", messages=[{"role": "user", "content": "Hello!"}], diff --git a/tests/test_users.py b/tests/test_users.py index 05253a19aa..57fbb0483e 100644 --- a/tests/test_users.py +++ b/tests/test_users.py @@ -302,14 +302,14 @@ async def test_user_model_access(): model="good-model", ) - with pytest.raises(openai.AuthenticationError): + with pytest.raises(openai.PermissionDeniedError): await chat_completion( session=session, key=key, model="bedrock/anthropic.claude-3-sonnet-20240229-v1:0", ) - with pytest.raises(openai.AuthenticationError): + with pytest.raises(openai.PermissionDeniedError): await chat_completion( session=session, key=key, From b888177ea67a83511cdd67fbd55b820059ac7e81 Mon Sep 17 00:00:00 2001 From: Michael-RZ-Berri Date: Sat, 9 May 2026 15:33:36 -0700 Subject: [PATCH 25/28] fix: reset proxy budget when initial reset duration is null then updated (#27488) Co-authored-by: Michael Riad Zaky --- litellm/proxy/proxy_server.py | 74 ++++++++++++++----- tests/test_litellm/proxy/test_proxy_server.py | 61 +++++++++++++++ 2 files changed, 117 insertions(+), 18 deletions(-) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 02f9c9bef2..493519f232 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -211,6 +211,7 @@ from litellm import Router from litellm._logging import verbose_proxy_logger, verbose_router_logger from litellm.caching.caching import DualCache, RedisCache from litellm.caching.redis_cluster_cache import RedisClusterCache +from litellm.proxy.common_utils.timezone_utils import get_budget_reset_time from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache from litellm.constants import ( _REALTIME_BODY_CACHE_SIZE, @@ -6750,27 +6751,64 @@ class ProxyStartupEvent: "budget_duration not set on Proxy. budget_duration is required to use max_budget." ) - # add proxy budget to db in the user table asyncio.create_task( - generate_key_helper_fn( # type: ignore - request_type="user", - table_name="user", - user_id=litellm_proxy_budget_name, - duration=None, - models=[], - aliases={}, - config={}, - spend=0, - max_budget=litellm.max_budget, - budget_duration=litellm.budget_duration, - query_type="update_data", - update_key_values={ - "max_budget": litellm.max_budget, - "budget_duration": litellm.budget_duration, - }, - ) + cls._upsert_proxy_budget_with_reset_at_backfill(litellm_proxy_budget_name) ) + @classmethod + async def _upsert_proxy_budget_with_reset_at_backfill( + cls, litellm_proxy_budget_name: str + ) -> None: + """ + Upsert the proxy admin user row with the configured max_budget / + budget_duration, then backfill budget_reset_at if currently NULL. + + The backfill uses `WHERE budget_reset_at IS NULL` so it only fires + when the row pre-existed without a reset schedule (e.g. row created + via a different path before the proxy budget was configured). On + subsequent restarts it no-ops, so an active reset window is never + slid forward. + """ + await generate_key_helper_fn( # type: ignore + request_type="user", + table_name="user", + user_id=litellm_proxy_budget_name, + duration=None, + models=[], + aliases={}, + config={}, + spend=0, + max_budget=litellm.max_budget, + budget_duration=litellm.budget_duration, + query_type="update_data", + update_key_values={ + "max_budget": litellm.max_budget, + "budget_duration": litellm.budget_duration, + }, + ) + + # Without this, the upsert leaves budget_reset_at=NULL on rows that + # took the UPDATE path, and reset_budget_for_litellm_users never + # matches them (NULL < now() is unknown in SQL) — so the proxy-wide + # spend cap blocks forever once it's hit. + if prisma_client is not None and litellm.budget_duration is not None: + try: + await prisma_client.db.litellm_usertable.update_many( + where={ + "user_id": litellm_proxy_budget_name, + "budget_reset_at": None, + }, + data={ + "budget_reset_at": get_budget_reset_time( + budget_duration=litellm.budget_duration + ) + }, + ) + except Exception as e: + verbose_proxy_logger.warning( + "Failed to backfill budget_reset_at on proxy admin row: %s", e + ) + @classmethod async def _warm_global_spend_cache( cls, diff --git a/tests/test_litellm/proxy/test_proxy_server.py b/tests/test_litellm/proxy/test_proxy_server.py index 6718f52cbf..859594f7a0 100644 --- a/tests/test_litellm/proxy/test_proxy_server.py +++ b/tests/test_litellm/proxy/test_proxy_server.py @@ -1728,6 +1728,67 @@ async def test_add_proxy_budget_to_db_only_creates_user_no_keys(): assert call_args.kwargs["query_type"] == "update_data" +@pytest.mark.asyncio +async def test_add_proxy_budget_to_db_backfills_budget_reset_at(): + """ + Test that _upsert_proxy_budget_with_reset_at_backfill issues a conditional + update_many with `WHERE budget_reset_at IS NULL` to backfill the column on + rows that pre-existed without a reset schedule. Without this, the proxy + admin row stays at NULL and reset_budget_for_litellm_users never matches + it (NULL < now() is unknown in SQL), so the global proxy budget never + resets. + """ + from unittest.mock import AsyncMock, MagicMock, patch + + import litellm + from litellm.proxy.proxy_server import ProxyStartupEvent + + litellm.budget_duration = "30d" + litellm.max_budget = 100.0 + litellm_proxy_budget_name = "litellm-proxy-budget" + + mock_prisma = MagicMock() + mock_prisma.db.litellm_usertable.update_many = AsyncMock(return_value={"count": 1}) + + mock_generate_key_helper = AsyncMock( + return_value={ + "user_id": litellm_proxy_budget_name, + "max_budget": 100.0, + "budget_duration": "30d", + "spend": 0, + "models": [], + } + ) + + with ( + patch( + "litellm.proxy.proxy_server.generate_key_helper_fn", + mock_generate_key_helper, + ), + patch("litellm.proxy.proxy_server.prisma_client", mock_prisma), + ): + await ProxyStartupEvent._upsert_proxy_budget_with_reset_at_backfill( + litellm_proxy_budget_name + ) + + # Upsert ran with the configured budget + mock_generate_key_helper.assert_called_once() + + # Backfill update_many ran with the conditional WHERE + mock_prisma.db.litellm_usertable.update_many.assert_called_once() + backfill_call = mock_prisma.db.litellm_usertable.update_many.call_args + assert backfill_call.kwargs["where"]["user_id"] == litellm_proxy_budget_name + assert backfill_call.kwargs["where"]["budget_reset_at"] is None + + # The backfilled value must be a real future datetime — anything else and + # reset_budget_for_litellm_users would still skip the row. + from datetime import datetime, timezone + + backfilled_reset_at = backfill_call.kwargs["data"]["budget_reset_at"] + assert isinstance(backfilled_reset_at, datetime) + assert backfilled_reset_at > datetime.now(timezone.utc) + + @pytest.mark.asyncio async def test_custom_ui_sso_sign_in_handler_config_loading(): """ From 02edaef50c46dd69fc8cc5ba5dda237eef478e94 Mon Sep 17 00:00:00 2001 From: Michael-RZ-Berri Date: Sat, 9 May 2026 16:15:32 -0700 Subject: [PATCH 26/28] fix: reset org and tag budgets (#27326) * reset org budgets * reset tag budgets --------- Co-authored-by: Michael Riad Zaky --- .../proxy/common_utils/reset_budget_job.py | 152 ++++++----- .../test_proxy_budget_reset.py | 28 ++ .../common_utils/test_reset_budget_job.py | 253 ++++++++++++++++++ 3 files changed, 363 insertions(+), 70 deletions(-) diff --git a/litellm/proxy/common_utils/reset_budget_job.py b/litellm/proxy/common_utils/reset_budget_job.py index 0928ce914d..d4c5d76ac1 100644 --- a/litellm/proxy/common_utils/reset_budget_job.py +++ b/litellm/proxy/common_utils/reset_budget_job.py @@ -2,7 +2,7 @@ import asyncio import json import time from datetime import datetime, timezone -from typing import Any, List, Literal, Optional, Union +from typing import Any, Callable, List, Literal, Optional, Union import litellm from litellm._logging import verbose_proxy_logger @@ -83,93 +83,97 @@ class ResetBudgetJob: "Failed to reset spend counter %s: %s", counter_key, e ) + async def _cascade_reset_spend_for_budget_link( + self, + budgets_to_reset: List[LiteLLM_BudgetTableFull], + table: Any, + counter_key_fn: Callable[[Any], str], + log_subject: str, + extra_where: Optional[dict] = None, + ): + """ + Generic cascade: zero spend on rows whose budget_id is in the reset set. + """ + budget_ids = [b.budget_id for b in budgets_to_reset if b.budget_id is not None] + if not budget_ids: + return + + where: dict = {"budget_id": {"in": budget_ids}} + if extra_where: + where.update(extra_where) + + try: + rows = await table.find_many(where=where) + except Exception as e: + rows = [] + verbose_proxy_logger.warning( + "Failed to fetch %s for counter invalidation: %s", log_subject, e + ) + + update_result = await table.update_many(where=where, data={"spend": 0}) + + for row in rows: + await self._invalidate_spend_counter(counter_key_fn(row)) + + return update_result + async def reset_budget_for_litellm_team_members( self, budgets_to_reset: List[LiteLLM_BudgetTableFull] ): """ Resets the budget for all LiteLLM Team Members if their budget has expired """ - budget_ids = [ - budget.budget_id - for budget in budgets_to_reset - if budget.budget_id is not None - ] - - try: - memberships = await self.prisma_client.db.litellm_teammembership.find_many( - where={"budget_id": {"in": budget_ids}} - ) - except Exception as e: - memberships = [] - verbose_proxy_logger.warning( - "Failed to fetch team memberships for counter invalidation: %s", e - ) - - update_result = await self.prisma_client.db.litellm_teammembership.update_many( - where={"budget_id": {"in": budget_ids}}, - data={ - "spend": 0, - }, + return await self._cascade_reset_spend_for_budget_link( + budgets_to_reset=budgets_to_reset, + table=self.prisma_client.db.litellm_teammembership, + counter_key_fn=lambda m: f"spend:team_member:{m.user_id}:{m.team_id}", + log_subject="team memberships", ) - for m in memberships: - await self._invalidate_spend_counter( - f"spend:team_member:{m.user_id}:{m.team_id}" - ) - - return update_result - async def reset_budget_for_keys_linked_to_budgets( self, budgets_to_reset: List[LiteLLM_BudgetTableFull] ): """ Resets the spend for keys linked to budget tiers that are being reset. - This handles keys that have budget_id but no budget_duration set on the key - itself. Keys with budget_id rely on their linked budget tier's reset schedule - rather than having their own budget_duration. - - Keys that have their own budget_duration are already handled by - reset_budget_for_litellm_keys() and are excluded here to avoid - double-resetting. + Excludes keys with their own budget_duration; those are reset by + reset_budget_for_litellm_keys() to avoid double-resetting. """ - budget_ids = [ - budget.budget_id - for budget in budgets_to_reset - if budget.budget_id is not None - ] - if not budget_ids: - return - - where_clause: dict = { - "budget_id": {"in": budget_ids}, - "budget_duration": None, # only keys without their own reset schedule - "spend": {"gt": 0}, # only reset keys that have accumulated spend - } - - try: - keys = await self.prisma_client.db.litellm_verificationtoken.find_many( - where=where_clause - ) - except Exception as e: - keys = [] - verbose_proxy_logger.warning( - "Failed to fetch keys for counter invalidation: %s", e - ) - - update_result = ( - await self.prisma_client.db.litellm_verificationtoken.update_many( - where=where_clause, - data={ - "spend": 0, - }, - ) + return await self._cascade_reset_spend_for_budget_link( + budgets_to_reset=budgets_to_reset, + table=self.prisma_client.db.litellm_verificationtoken, + counter_key_fn=lambda k: f"spend:key:{k.token}", + log_subject="keys", + extra_where={"budget_duration": None, "spend": {"gt": 0}}, ) - for k in keys: - await self._invalidate_spend_counter(f"spend:key:{k.token}") + async def reset_budget_for_orgs_linked_to_budgets( + self, budgets_to_reset: List[LiteLLM_BudgetTableFull] + ): + """ + Resets the spend for orgs linked to budget tiers that are being reset. + """ + return await self._cascade_reset_spend_for_budget_link( + budgets_to_reset=budgets_to_reset, + table=self.prisma_client.db.litellm_organizationtable, + counter_key_fn=lambda o: f"spend:org:{o.organization_id}", + log_subject="orgs", + extra_where={"spend": {"gt": 0}}, + ) - return update_result + async def reset_budget_for_tags_linked_to_budgets( + self, budgets_to_reset: List[LiteLLM_BudgetTableFull] + ): + """ + Resets the spend for tags linked to budget tiers that are being reset. + """ + return await self._cascade_reset_spend_for_budget_link( + budgets_to_reset=budgets_to_reset, + table=self.prisma_client.db.litellm_tagtable, + counter_key_fn=lambda t: f"spend:tag:{t.tag_name}", + log_subject="tags", + extra_where={"spend": {"gt": 0}}, + ) async def reset_budget_for_litellm_budget_table(self): """ @@ -237,6 +241,14 @@ class ResetBudgetJob: budgets_to_reset=budgets_to_reset ) + await self.reset_budget_for_orgs_linked_to_budgets( + budgets_to_reset=budgets_to_reset + ) + + await self.reset_budget_for_tags_linked_to_budgets( + budgets_to_reset=budgets_to_reset + ) + if endusers_to_reset is not None and len(endusers_to_reset) > 0: for enduser in endusers_to_reset: try: diff --git a/tests/litellm_utils_tests/test_proxy_budget_reset.py b/tests/litellm_utils_tests/test_proxy_budget_reset.py index a64c6c7aa3..6240bedd3e 100644 --- a/tests/litellm_utils_tests/test_proxy_budget_reset.py +++ b/tests/litellm_utils_tests/test_proxy_budget_reset.py @@ -233,6 +233,12 @@ async def test_reset_budget_endusers_partial_failure(): prisma_client.db.litellm_verificationtoken.update_many = AsyncMock( return_value={"count": 0} ) + # Mock db.litellm_organizationtable.update_many (used by reset_budget_for_orgs_linked_to_budgets) + prisma_client.db.litellm_organizationtable.update_many = AsyncMock( + return_value={"count": 0} + ) + # Mock db.litellm_tagtable.update_many (used by reset_budget_for_tags_linked_to_budgets) + prisma_client.db.litellm_tagtable.update_many = AsyncMock(return_value={"count": 0}) proxy_logging_obj = MagicMock() proxy_logging_obj.service_logging_obj = MagicMock() @@ -400,6 +406,12 @@ async def test_reset_budget_continues_other_categories_on_failure(): prisma_client.db.litellm_verificationtoken.update_many = AsyncMock( return_value={"count": 0} ) + # Mock db.litellm_organizationtable.update_many (used by reset_budget_for_orgs_linked_to_budgets) + prisma_client.db.litellm_organizationtable.update_many = AsyncMock( + return_value={"count": 0} + ) + # Mock db.litellm_tagtable.update_many (used by reset_budget_for_tags_linked_to_budgets) + prisma_client.db.litellm_tagtable.update_many = AsyncMock(return_value={"count": 0}) proxy_logging_obj = MagicMock() proxy_logging_obj.service_logging_obj = MagicMock() @@ -884,6 +896,12 @@ async def test_service_logger_endusers_success(): prisma_client.db.litellm_verificationtoken.update_many = AsyncMock( return_value={"count": 0} ) + # Mock db.litellm_organizationtable.update_many (used by reset_budget_for_orgs_linked_to_budgets) + prisma_client.db.litellm_organizationtable.update_many = AsyncMock( + return_value={"count": 0} + ) + # Mock db.litellm_tagtable.update_many (used by reset_budget_for_tags_linked_to_budgets) + prisma_client.db.litellm_tagtable.update_many = AsyncMock(return_value={"count": 0}) proxy_logging_obj = MagicMock() proxy_logging_obj.service_logging_obj = MagicMock() @@ -966,6 +984,12 @@ async def test_service_logger_endusers_failure(): prisma_client.db.litellm_verificationtoken.update_many = AsyncMock( return_value={"count": 0} ) + # Mock db.litellm_organizationtable.update_many (used by reset_budget_for_orgs_linked_to_budgets) + prisma_client.db.litellm_organizationtable.update_many = AsyncMock( + return_value={"count": 0} + ) + # Mock db.litellm_tagtable.update_many (used by reset_budget_for_tags_linked_to_budgets) + prisma_client.db.litellm_tagtable.update_many = AsyncMock(return_value={"count": 0}) proxy_logging_obj = MagicMock() proxy_logging_obj.service_logging_obj = MagicMock() @@ -1060,6 +1084,10 @@ async def test_reset_budget_for_litellm_team_members_called(): prisma_client.db.litellm_verificationtoken.update_many = AsyncMock( return_value={"count": 0} ) + prisma_client.db.litellm_organizationtable.update_many = AsyncMock( + return_value={"count": 0} + ) + prisma_client.db.litellm_tagtable.update_many = AsyncMock(return_value={"count": 0}) proxy_logging_obj = MagicMock() proxy_logging_obj.service_logging_obj = MagicMock() diff --git a/tests/test_litellm/proxy/common_utils/test_reset_budget_job.py b/tests/test_litellm/proxy/common_utils/test_reset_budget_job.py index 5c86f9057a..82511fbc55 100644 --- a/tests/test_litellm/proxy/common_utils/test_reset_budget_job.py +++ b/tests/test_litellm/proxy/common_utils/test_reset_budget_job.py @@ -39,6 +39,46 @@ class MockLiteLLMVerificationToken: return {"count": 1} +class MockLiteLLMOrganizationTable: + def __init__(self): + self.update_many_calls: List[Dict[str, Any]] = [] + self.find_many_calls: List[Dict[str, Any]] = [] + self._find_many_results: List[Any] = [] + + def set_find_many_results(self, results: List[Any]): + self._find_many_results = results + + async def find_many(self, where: Dict[str, Any]) -> List[Any]: + self.find_many_calls.append({"where": where}) + return self._find_many_results + + async def update_many( + self, where: Dict[str, Any], data: Dict[str, Any] + ) -> Dict[str, Any]: + self.update_many_calls.append({"where": where, "data": data}) + return {"count": 1} + + +class MockLiteLLMTagTable: + def __init__(self): + self.update_many_calls: List[Dict[str, Any]] = [] + self.find_many_calls: List[Dict[str, Any]] = [] + self._find_many_results: List[Any] = [] + + def set_find_many_results(self, results: List[Any]): + self._find_many_results = results + + async def find_many(self, where: Dict[str, Any]) -> List[Any]: + self.find_many_calls.append({"where": where}) + return self._find_many_results + + async def update_many( + self, where: Dict[str, Any], data: Dict[str, Any] + ) -> Dict[str, Any]: + self.update_many_calls.append({"where": where, "data": data}) + return {"count": 1} + + class MockLiteLLMEndUserTable: def __init__(self): self.find_many_calls: List[Dict[str, Any]] = [] @@ -57,6 +97,8 @@ class MockDB: self.litellm_teammembership = MockLiteLLMTeamMembership() self.litellm_verificationtoken = MockLiteLLMVerificationToken() self.litellm_endusertable = MockLiteLLMEndUserTable() + self.litellm_organizationtable = MockLiteLLMOrganizationTable() + self.litellm_tagtable = MockLiteLLMTagTable() class MockPrismaClient: @@ -459,6 +501,100 @@ def test_reset_budget_for_keys_linked_to_budgets_empty( assert len(calls) == 0 +def test_reset_budget_for_orgs_linked_to_budgets(reset_budget_job, mock_prisma_client): + """ + Test that when a budget tier is reset, orgs linked to that budget + (via budget_id) also get their spend reset. + """ + now = datetime.now(timezone.utc) + + test_budget = type( + "LiteLLM_BudgetTableFull", + (), + { + "max_budget": 100.0, + "budget_duration": "30d", + "budget_reset_at": now - timedelta(hours=1), + "budget_id": "30d-org-budget", + "created_at": now - timedelta(days=30), + }, + ) + + asyncio.run( + reset_budget_job.reset_budget_for_orgs_linked_to_budgets( + budgets_to_reset=[test_budget] + ) + ) + + calls = mock_prisma_client.db.litellm_organizationtable.update_many_calls + assert len(calls) == 1 + call = calls[0] + assert call["where"]["budget_id"] == {"in": ["30d-org-budget"]} + assert call["where"]["spend"] == {"gt": 0} + assert call["data"]["spend"] == 0 + + +def test_reset_budget_for_orgs_linked_to_budgets_empty( + reset_budget_job, mock_prisma_client +): + """ + Test that when there are no budgets to reset, no update is performed + on the organization table. + """ + asyncio.run( + reset_budget_job.reset_budget_for_orgs_linked_to_budgets(budgets_to_reset=[]) + ) + calls = mock_prisma_client.db.litellm_organizationtable.update_many_calls + assert len(calls) == 0 + + +def test_reset_budget_for_tags_linked_to_budgets(reset_budget_job, mock_prisma_client): + """ + Test that when a budget tier is reset, tags linked to that budget + (via budget_id) also get their spend reset. + """ + now = datetime.now(timezone.utc) + + test_budget = type( + "LiteLLM_BudgetTableFull", + (), + { + "max_budget": 50.0, + "budget_duration": "30d", + "budget_reset_at": now - timedelta(hours=1), + "budget_id": "30d-tag-budget", + "created_at": now - timedelta(days=30), + }, + ) + + asyncio.run( + reset_budget_job.reset_budget_for_tags_linked_to_budgets( + budgets_to_reset=[test_budget] + ) + ) + + calls = mock_prisma_client.db.litellm_tagtable.update_many_calls + assert len(calls) == 1 + call = calls[0] + assert call["where"]["budget_id"] == {"in": ["30d-tag-budget"]} + assert call["where"]["spend"] == {"gt": 0} + assert call["data"]["spend"] == 0 + + +def test_reset_budget_for_tags_linked_to_budgets_empty( + reset_budget_job, mock_prisma_client +): + """ + Test that when there are no budgets to reset, no update is performed + on the tag table. + """ + asyncio.run( + reset_budget_job.reset_budget_for_tags_linked_to_budgets(budgets_to_reset=[]) + ) + calls = mock_prisma_client.db.litellm_tagtable.update_many_calls + assert len(calls) == 0 + + @pytest.mark.parametrize( "budget_duration, expected_day, expected_month", [ @@ -618,6 +754,75 @@ def test_budget_table_reset_also_resets_linked_keys( assert calls[0]["data"]["spend"] == 0 +def test_budget_table_reset_also_resets_linked_orgs( + reset_budget_job, mock_prisma_client +): + """ + Integration-style test: when reset_budget_for_litellm_budget_table runs, + it should also reset spend for orgs linked to the expiring budget tiers + (in addition to end-users, team members, and keys). + """ + now = datetime.now(timezone.utc) + + test_budget = type( + "LiteLLM_BudgetTableFull", + (), + { + "max_budget": 100.0, + "budget_duration": "30d", + "budget_reset_at": now - timedelta(hours=1), + "budget_id": "30d-org-budget", + "created_at": now - timedelta(days=30), + }, + ) + + mock_prisma_client.data["budget"] = [test_budget] + + asyncio.run(reset_budget_job.reset_budget_for_litellm_budget_table()) + + calls = mock_prisma_client.db.litellm_organizationtable.update_many_calls + assert len(calls) == 1, ( + "Expected reset_budget_for_litellm_budget_table to also reset orgs " + f"linked to expiring budgets, but got {len(calls)} update_many calls" + ) + assert calls[0]["where"]["budget_id"] == {"in": ["30d-org-budget"]} + assert calls[0]["data"]["spend"] == 0 + + +def test_budget_table_reset_also_resets_linked_tags( + reset_budget_job, mock_prisma_client +): + """ + Integration-style test: when reset_budget_for_litellm_budget_table runs, + it should also reset spend for tags linked to the expiring budget tiers. + """ + now = datetime.now(timezone.utc) + + test_budget = type( + "LiteLLM_BudgetTableFull", + (), + { + "max_budget": 50.0, + "budget_duration": "30d", + "budget_reset_at": now - timedelta(hours=1), + "budget_id": "30d-tag-budget", + "created_at": now - timedelta(days=30), + }, + ) + + mock_prisma_client.data["budget"] = [test_budget] + + asyncio.run(reset_budget_job.reset_budget_for_litellm_budget_table()) + + calls = mock_prisma_client.db.litellm_tagtable.update_many_calls + assert len(calls) == 1, ( + "Expected reset_budget_for_litellm_budget_table to also reset tags " + f"linked to expiring budgets, but got {len(calls)} update_many calls" + ) + assert calls[0]["where"]["budget_id"] == {"in": ["30d-tag-budget"]} + assert calls[0]["data"]["spend"] == 0 + + def test_reset_budget_resets_endusers_with_null_budget_id( reset_budget_job, mock_prisma_client ): @@ -1205,3 +1410,51 @@ def test_reset_budget_for_keys_linked_to_budgets_invalidates_redis_counter(monke counter_cache.in_memory_cache.set_cache.assert_any_call( key="spend:key:sk-linked", value=0.0, ttl=60 ) + + +def test_reset_budget_for_orgs_linked_to_budgets_invalidates_redis_counter(monkeypatch): + """Resetting orgs via budget tier must clear each linked org's counter.""" + counter_cache = _make_counter_invalidation_job(monkeypatch) + + expired_budget = type("B", (), {"budget_id": "budget-1"}) + linked_org = type("Org", (), {"organization_id": "org-acme"}) + + prisma_client = MagicMock() + prisma_client.db.litellm_organizationtable.find_many = AsyncMock( + return_value=[linked_org] + ) + prisma_client.db.litellm_organizationtable.update_many = AsyncMock( + return_value={"count": 1} + ) + + job = ResetBudgetJob(proxy_logging_obj=MagicMock(), prisma_client=prisma_client) + asyncio.run(job.reset_budget_for_orgs_linked_to_budgets([expired_budget])) + + counter_cache.in_memory_cache.set_cache.assert_any_call( + key="spend:org:org-acme", value=0.0, ttl=60 + ) + counter_cache.redis_cache.async_set_cache.assert_any_await( + key="spend:org:org-acme", value=0.0, ttl=60 + ) + + +def test_reset_budget_for_tags_linked_to_budgets_invalidates_redis_counter(monkeypatch): + """Resetting tags via budget tier must clear each linked tag's counter.""" + counter_cache = _make_counter_invalidation_job(monkeypatch) + + expired_budget = type("B", (), {"budget_id": "budget-1"}) + linked_tag = type("Tag", (), {"tag_name": "tenant-42"}) + + prisma_client = MagicMock() + prisma_client.db.litellm_tagtable.find_many = AsyncMock(return_value=[linked_tag]) + prisma_client.db.litellm_tagtable.update_many = AsyncMock(return_value={"count": 1}) + + job = ResetBudgetJob(proxy_logging_obj=MagicMock(), prisma_client=prisma_client) + asyncio.run(job.reset_budget_for_tags_linked_to_budgets([expired_budget])) + + counter_cache.in_memory_cache.set_cache.assert_any_call( + key="spend:tag:tenant-42", value=0.0, ttl=60 + ) + counter_cache.redis_cache.async_set_cache.assert_any_await( + key="spend:tag:tenant-42", value=0.0, ttl=60 + ) From 0af33fbe7004d8d13aaa912373bba2d20e62042b Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Sat, 9 May 2026 19:01:58 -0700 Subject: [PATCH 27/28] fix(ui): omit allowed_routes from key edit save when unchanged (#27553) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(ui): omit allowed_routes from key edit save when unchanged When a team admin opens Edit Settings on a key with key_type=AI APIs and saves without changing anything, the UI re-sends the existing allowed_routes value, which the backend's _check_allowed_routes_caller_permission gate rejects for non-proxy-admins (LIT-2681). Strip allowed_routes from the patch in handleSubmit when it deep-equals the original keyData.allowed_routes. The backend treats absence as "leave alone," so no-op saves now succeed for non-admins. Admins explicitly editing the field still send the new value. * fix(ui): order-insensitive allowed_routes diff + cover null-original case Address Greptile review: - Switch the "is allowed_routes unchanged" check to a Set-based comparison so a server-side reorder of the array doesn't register as a user edit and re-trigger LIT-2681. - Add two regression tests: (1) keyData.allowed_routes is null and the form is untouched — patch should strip the field; (2) server returned routes in a different order than the user originally entered — patch should still recognize the value as unchanged. * chore(ui): strip ticket refs and tighten comments in key edit fix - Remove internal-tracker references from in-code comments - Tighten the WHY comment in handleSubmit to two lines - Drop redundant test-block comments — test names already describe the case * fix(ui): annotate Set generic in allowed_routes diff to fix tsc --- .../templates/key_edit_view.test.tsx | 162 ++++++++++++++---- .../components/templates/key_edit_view.tsx | 82 +++++---- 2 files changed, 180 insertions(+), 64 deletions(-) diff --git a/ui/litellm-dashboard/src/components/templates/key_edit_view.test.tsx b/ui/litellm-dashboard/src/components/templates/key_edit_view.test.tsx index 2e4d0d97e4..1886075a9d 100644 --- a/ui/litellm-dashboard/src/components/templates/key_edit_view.test.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_edit_view.test.tsx @@ -158,8 +158,8 @@ describe("KeyEditView", () => { const { getByText } = renderWithProviders( { }} - onSubmit={async () => { }} + onCancel={() => {}} + onSubmit={async () => {}} accessToken={""} userID={""} userRole={""} @@ -176,8 +176,8 @@ describe("KeyEditView", () => { const { getByText } = renderWithProviders( { }} - onSubmit={async () => { }} + onCancel={() => {}} + onSubmit={async () => {}} accessToken={""} userID={""} userRole={""} @@ -194,8 +194,8 @@ describe("KeyEditView", () => { const { getByLabelText } = renderWithProviders( { }} - onSubmit={async () => { }} + onCancel={() => {}} + onSubmit={async () => {}} accessToken={""} userID={""} userRole={""} @@ -219,7 +219,7 @@ describe("KeyEditView", () => { { }} + onSubmit={async () => {}} accessToken={""} userID={""} userRole={""} @@ -241,8 +241,8 @@ describe("KeyEditView", () => { renderWithProviders( { }} - onSubmit={async () => { }} + onCancel={() => {}} + onSubmit={async () => {}} accessToken={""} userID={""} userRole={""} @@ -259,8 +259,8 @@ describe("KeyEditView", () => { renderWithProviders( { }} - onSubmit={async () => { }} + onCancel={() => {}} + onSubmit={async () => {}} accessToken={""} userID={""} userRole={""} @@ -277,8 +277,8 @@ describe("KeyEditView", () => { renderWithProviders( { }} - onSubmit={async () => { }} + onCancel={() => {}} + onSubmit={async () => {}} accessToken={""} userID={""} userRole={""} @@ -295,8 +295,8 @@ describe("KeyEditView", () => { renderWithProviders( { }} - onSubmit={async () => { }} + onCancel={() => {}} + onSubmit={async () => {}} accessToken={""} userID={""} userRole={""} @@ -314,7 +314,7 @@ describe("KeyEditView", () => { renderWithProviders( { }} + onCancel={() => {}} onSubmit={onSubmitMock} accessToken={"test-token"} userID={"test-user"} @@ -344,8 +344,8 @@ describe("KeyEditView", () => { renderWithProviders( { }} - onSubmit={async () => { }} + onCancel={() => {}} + onSubmit={async () => {}} accessToken={""} userID={""} userRole={""} @@ -367,8 +367,8 @@ describe("KeyEditView", () => { renderWithProviders( { }} - onSubmit={async () => { }} + onCancel={() => {}} + onSubmit={async () => {}} accessToken={""} userID={""} userRole={""} @@ -385,8 +385,8 @@ describe("KeyEditView", () => { renderWithProviders( { }} - onSubmit={async () => { }} + onCancel={() => {}} + onSubmit={async () => {}} accessToken={"test-token"} userID={""} userRole={""} @@ -404,7 +404,7 @@ describe("KeyEditView", () => { renderWithProviders( { }} + onCancel={() => {}} onSubmit={onSubmitMock} accessToken={"test-token"} userID={"test-user"} @@ -434,10 +434,14 @@ describe("KeyEditView", () => { it("should handle empty allowed routes string on submit", async () => { const onSubmitMock = vi.fn().mockResolvedValue(undefined); + const keyDataWithRoutes = { + ...MOCK_KEY_DATA, + allowed_routes: ["llm_api_routes"], + }; renderWithProviders( { }} + keyData={keyDataWithRoutes} + onCancel={() => {}} onSubmit={onSubmitMock} accessToken={"test-token"} userID={"test-user"} @@ -463,6 +467,101 @@ describe("KeyEditView", () => { }); }); + it("should omit allowed_routes from submit when value is unchanged", async () => { + const onSubmitMock = vi.fn().mockResolvedValue(undefined); + const aiApisKeyData = { + ...MOCK_KEY_DATA, + allowed_routes: ["llm_api_routes"], + }; + renderWithProviders( + {}} + onSubmit={onSubmitMock} + accessToken={"test-token"} + userID={"test-user"} + userRole={"admin"} + premiumUser={false} + />, + ); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /save changes/i })).toBeInTheDocument(); + }); + + const submitButton = screen.getByRole("button", { name: /save changes/i }); + await userEvent.click(submitButton); + + await waitFor(() => { + expect(onSubmitMock).toHaveBeenCalled(); + const callArgs = onSubmitMock.mock.calls[0][0]; + expect("allowed_routes" in callArgs).toBe(false); + }); + }); + + it("should omit allowed_routes from submit when keyData.allowed_routes is null and form is untouched", async () => { + const onSubmitMock = vi.fn().mockResolvedValue(undefined); + const keyDataNullRoutes = { + ...MOCK_KEY_DATA, + allowed_routes: null as unknown as string[], + }; + renderWithProviders( + {}} + onSubmit={onSubmitMock} + accessToken={"test-token"} + userID={"test-user"} + userRole={"admin"} + premiumUser={false} + />, + ); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /save changes/i })).toBeInTheDocument(); + }); + + const submitButton = screen.getByRole("button", { name: /save changes/i }); + await userEvent.click(submitButton); + + await waitFor(() => { + expect(onSubmitMock).toHaveBeenCalled(); + const callArgs = onSubmitMock.mock.calls[0][0]; + expect("allowed_routes" in callArgs).toBe(false); + }); + }); + + it("should omit allowed_routes from submit when server returned routes in a different order", async () => { + const onSubmitMock = vi.fn().mockResolvedValue(undefined); + const keyDataReordered = { + ...MOCK_KEY_DATA, + allowed_routes: ["beta_routes", "alpha_routes"], + }; + renderWithProviders( + {}} + onSubmit={onSubmitMock} + accessToken={"test-token"} + userID={"test-user"} + userRole={"admin"} + premiumUser={false} + />, + ); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /save changes/i })).toBeInTheDocument(); + }); + + const submitButton = screen.getByRole("button", { name: /save changes/i }); + await userEvent.click(submitButton); + + await waitFor(() => { + expect(onSubmitMock).toHaveBeenCalled(); + const callArgs = onSubmitMock.mock.calls[0][0]; + expect("allowed_routes" in callArgs).toBe(false); + }); + }); it("should pass access_group_ids to onSubmit when saving key with access groups", async () => { const onSubmitMock = vi.fn().mockResolvedValue(undefined); @@ -554,7 +653,7 @@ describe("KeyEditView", () => { renderWithProviders( { }} + onCancel={() => {}} onSubmit={onSubmitMock} accessToken={"test-token"} userID={"test-user"} @@ -576,10 +675,13 @@ describe("KeyEditView", () => { }); // Wait for the cancel button to actually be disabled (state update may take a moment) - await waitFor(() => { - const cancelButton = screen.getByRole("button", { name: /cancel/i }); - expect(cancelButton).toBeDisabled(); - }, { timeout: 3000 }); + await waitFor( + () => { + const cancelButton = screen.getByRole("button", { name: /cancel/i }); + expect(cancelButton).toBeDisabled(); + }, + { timeout: 3000 }, + ); // Clean up: resolve the promise to allow the form to complete if (resolveSubmit) { diff --git a/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx b/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx index 9b38d930a5..d5e410029a 100644 --- a/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx @@ -78,7 +78,6 @@ const getKeyTypeFromRoutes = (allowedRoutes: string[] | null | undefined): strin return "default"; }; - export function KeyEditView({ keyData, onCancel, @@ -106,7 +105,7 @@ export function KeyEditView({ const [neverExpire, setNeverExpire] = useState(!keyData.expires); const [isKeySaving, setIsKeySaving] = useState(false); const [budgetLimits, setBudgetLimits] = useState( - Array.isArray(keyData.budget_limits) ? keyData.budget_limits : [] + Array.isArray(keyData.budget_limits) ? keyData.budget_limits : [], ); const { data: organizations, isLoading: isOrganizationsLoading } = useOrganizations(); const { data: projects } = useProjects(); @@ -116,9 +115,7 @@ export function KeyEditView({ const projectDisplay = (() => { if (!keyData.project_id) return null; const project = projects?.find((p) => p.project_id === keyData.project_id); - return project?.project_alias - ? `${project.project_alias} (${keyData.project_id})` - : keyData.project_id; + return project?.project_alias ? `${project.project_alias} (${keyData.project_id})` : keyData.project_id; })(); useEffect(() => { @@ -198,9 +195,10 @@ export function KeyEditView({ access_group_ids: keyData.access_group_ids || [], auto_rotate: keyData.auto_rotate || false, ...(keyData.rotation_interval && { rotation_interval: keyData.rotation_interval }), - allowed_routes: Array.isArray(keyData.allowed_routes) && keyData.allowed_routes.length > 0 - ? keyData.allowed_routes.join(", ") - : "", + allowed_routes: + Array.isArray(keyData.allowed_routes) && keyData.allowed_routes.length > 0 + ? keyData.allowed_routes.join(", ") + : "", }; useEffect(() => { @@ -226,9 +224,10 @@ export function KeyEditView({ access_group_ids: keyData.access_group_ids || [], auto_rotate: keyData.auto_rotate || false, ...(keyData.rotation_interval && { rotation_interval: keyData.rotation_interval }), - allowed_routes: Array.isArray(keyData.allowed_routes) && keyData.allowed_routes.length > 0 - ? keyData.allowed_routes.join(", ") - : "", + allowed_routes: + Array.isArray(keyData.allowed_routes) && keyData.allowed_routes.length > 0 + ? keyData.allowed_routes.join(", ") + : "", }); }, [keyData, form]); @@ -275,12 +274,25 @@ export function KeyEditView({ } // If it's already an array (shouldn't happen, but handle it), keep as is + // Backend rejects non-empty allowed_routes from non-admins, so re-sending + // an unchanged value 403s a team admin. Set compare tolerates reorder. + const originalRoutesSet = new Set(Array.isArray(keyData.allowed_routes) ? keyData.allowed_routes : []); + const submittedRoutesSet = new Set(Array.isArray(values.allowed_routes) ? values.allowed_routes : []); + const allowedRoutesUnchanged = + originalRoutesSet.size === submittedRoutesSet.size && + [...submittedRoutesSet].every((r) => originalRoutesSet.has(r)); + if (allowedRoutesUnchanged) { + delete values.allowed_routes; + } + if (neverExpire) { values.duration = null; } // Include multi-window budget limits (filter out incomplete entries) - const validWindows = budgetLimits.filter((w) => w.budget_duration && w.max_budget !== null && w.max_budget !== undefined); + const validWindows = budgetLimits.filter( + (w) => w.budget_duration && w.max_budget !== null && w.max_budget !== undefined, + ); values.budget_limits = validWindows.length > 0 ? validWindows : undefined; await onSubmit(values); @@ -305,9 +317,13 @@ export function KeyEditView({ {({ getFieldValue, setFieldValue }) => { const allowedRoutesValue = getFieldValue("allowed_routes") || ""; // Convert string to array for checking - const allowedRoutes = typeof allowedRoutesValue === "string" && allowedRoutesValue.trim() !== "" - ? allowedRoutesValue.split(",").map((r: string) => r.trim()).filter((r: string) => r.length > 0) - : []; + const allowedRoutes = + typeof allowedRoutesValue === "string" && allowedRoutesValue.trim() !== "" + ? allowedRoutesValue + .split(",") + .map((r: string) => r.trim()) + .filter((r: string) => r.length > 0) + : []; const isDisabled = allowedRoutes.includes("management_routes") || allowedRoutes.includes("info_routes"); const models = getFieldValue("models") || []; @@ -348,9 +364,13 @@ export function KeyEditView({ {({ getFieldValue, setFieldValue }) => { const allowedRoutesValue = getFieldValue("allowed_routes") || ""; // Convert string to array for getKeyTypeFromRoutes - const allowedRoutes = typeof allowedRoutesValue === "string" && allowedRoutesValue.trim() !== "" - ? allowedRoutesValue.split(",").map((r: string) => r.trim()).filter((r: string) => r.length > 0) - : []; + const allowedRoutes = + typeof allowedRoutesValue === "string" && allowedRoutesValue.trim() !== "" + ? allowedRoutesValue + .split(",") + .map((r: string) => r.trim()) + .filter((r: string) => r.length > 0) + : []; const keyTypeValue = getKeyTypeFromRoutes(allowedRoutes); return ( @@ -415,9 +435,7 @@ export function KeyEditView({ } name="allowed_routes" > - + @@ -442,10 +460,7 @@ export function KeyEditView({ } > - + @@ -579,7 +594,7 @@ export function KeyEditView({ !premiumUser ? "Premium feature - Upgrade to set allowed pass through routes by key" : Array.isArray(keyData.metadata?.allowed_passthrough_routes) && - keyData.metadata.allowed_passthrough_routes.length > 0 + keyData.metadata.allowed_passthrough_routes.length > 0 ? `Current: ${keyData.metadata.allowed_passthrough_routes.join(", ")}` : "Select or enter allowed pass through routes" } @@ -690,14 +705,13 @@ export function KeyEditView({ return team.team_alias?.toLowerCase().includes(input.toLowerCase()) ?? false; }} > - {(selectedOrganizationId - ? teams?.filter((t) => t.organization_id === selectedOrganizationId) - : teams - )?.map((team) => ( - - {`${team.team_alias} (${team.team_id})`} - - ))} + {(selectedOrganizationId ? teams?.filter((t) => t.organization_id === selectedOrganizationId) : teams)?.map( + (team) => ( + + {`${team.team_alias} (${team.team_id})`} + + ), + )} {enableProjectsUI && hasProject && ( From 99218c6fa0326deb0ff7c1a8ee84f00cd693c7c0 Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Mon, 11 May 2026 09:49:47 +0530 Subject: [PATCH 28/28] Fix deprecated model test --- tests/llm_translation/test_openrouter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/llm_translation/test_openrouter.py b/tests/llm_translation/test_openrouter.py index 631b0770e3..8fbb8803d1 100644 --- a/tests/llm_translation/test_openrouter.py +++ b/tests/llm_translation/test_openrouter.py @@ -11,7 +11,7 @@ import litellm def test_completion_openrouter_reasoning_content(): litellm._turn_on_debug() resp = litellm.completion( - model="openrouter/anthropic/claude-3.7-sonnet", + model="openrouter/anthropic/claude-sonnet-4", messages=[{"role": "user", "content": "Hello world"}], reasoning={"effort": "high"}, )