mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-14 09:03:16 +00:00
Merge 9b63bda0b5 into 5a392b9e5f
This commit is contained in:
commit
4a02eaead2
28 changed files with 3153 additions and 192 deletions
|
|
@ -46,7 +46,43 @@ For a managed workspace rollout, use OpenAI's [gateway configuration guide](http
|
|||
|
||||
## Native compatibility and Pi
|
||||
|
||||
For a native Codex model catalog and request format, change Codex's `base_url` to `https://litellm.example.com/backend-api/codex`. This route uses the same credentials but does not need a model deployment or resolve model aliases. Restricted keys must allow the upstream model name.
|
||||
For a native Codex model catalog and request format, change Codex's `base_url` to `https://litellm.example.com/backend-api/codex`. This route uses the same credentials. A ChatGPT model keeps its upstream name and needs no deployment of its own.
|
||||
|
||||
### Mix ChatGPT and LiteLLM models
|
||||
|
||||
On the native route, Codex's model picker shows OpenAI's catalog first and then the other chat and responses models the virtual key can use. Pick one of those and Codex's requests go through LiteLLM's `/v1/responses` with the key's usual routing, budgets, guardrails, and logging. The proxy removes the ChatGPT bearer and account header from that request, so the other provider and your callbacks never receive them. A name is routed this way when another provider serves it as an exact `model_name`, through a router `model_group_alias`, or as a team's public model name. A ChatGPT model, a name that only a wildcard deployment matches, or a name OpenAI's catalog lists for the caller's ChatGPT account keeps the passthrough, so a model OpenAI adds later still uses the subscription even when a deployment of another provider has the same name. The proxy learns the account's list from the catalog Codex fetches, or asks OpenAI with the caller's login. When it cannot get the list, for example because OpenAI is down, the request times out, or the client is not Codex, every request keeps the passthrough for the next minute, as it did before LiteLLM models were added to the picker. When the key can use many models, the picker lists the ones that fit in Codex's catalog size limit, and the proxy logs a warning with how many it left out.
|
||||
|
||||
Codex's picker sends a `none` reasoning effort for a LiteLLM model whose catalog entry lists no effort levels. With `litellm_settings: drop_params: true`, the proxy drops it for a model that does not support `none`, and the provider applies its own default. A model whose cost map entry or `model_info` sets `supports_none_reasoning_effort: true` keeps it. Without `drop_params`, the effort reaches the provider unchanged.
|
||||
|
||||
Codex also sends its ChatGPT app tools with every turn, which on a LiteLLM model adds a few hundred thousand prompt tokens to each turn. Set `apps = false` under `[features]` in Codex's `config.toml` to turn them off.
|
||||
|
||||
Only a key that may call `/v1/responses` gets these models. A key whose `allowed_routes` covers only the native routes sees OpenAI's catalog unchanged, and every request it sends uses the subscription.
|
||||
|
||||
Codex sends earlier reasoning items back with each turn. The proxy tags the reasoning that a LiteLLM model returns on this route, OpenAI and Azure deployments included. When a session switches to a ChatGPT model, the proxy drops those items before forwarding the request to OpenAI, which would reject them. Messages, tool calls, and ChatGPT's own reasoning are kept, and a LiteLLM model still gets its own reasoning back.
|
||||
|
||||
When the catalog adds LiteLLM models for a key, its `ETag` is a digest of OpenAI's ETag and those models, and that key's responses carry the same value in `X-Models-Etag`. Codex keeps its cached catalog while the two match and fetches it again when OpenAI's catalog or the key's models change. A key whose catalog adds no LiteLLM models keeps OpenAI's ETags.
|
||||
|
||||
### Restrict native models with an access group
|
||||
|
||||
A key with a model allowlist needs a rule for ChatGPT names that OpenAI adds over time. Put the subscription wildcards in an access group under `chatgpt/` names:
|
||||
|
||||
```yaml
|
||||
model_list:
|
||||
- model_name: chatgpt/gpt-*
|
||||
litellm_params:
|
||||
model: chatgpt/gpt-*
|
||||
model_info:
|
||||
access_groups: ["chatgpt-native"]
|
||||
- model_name: chatgpt/codex-*
|
||||
litellm_params:
|
||||
model: chatgpt/codex-*
|
||||
model_info:
|
||||
access_groups: ["chatgpt-native"]
|
||||
```
|
||||
|
||||
Then add `chatgpt-native` to the key's models, next to any other model names it should reach. On the native route, a key that holds only the `chatgpt/` form of a name is checked as that form, so a request for `gpt-5.6-sol` is allowed when the key may call `chatgpt/gpt-5.6-sol`. The key can call every ChatGPT model that matches the wildcards, including ones the proxy configuration does not name.
|
||||
|
||||
On `/v1` these wildcards match only a request that asks for a `chatgpt/` name, so a request for a plain `gpt-` or `codex-` name still goes where it went before, such as a `*` catch-all deployment. A `/v1` request for a `chatgpt/` name without a ChatGPT login falls back to the proxy's own saved login if it has one, so keep that login unset as described above.
|
||||
|
||||
### Configure Pi
|
||||
|
||||
|
|
@ -71,7 +107,7 @@ Keep Pi's subscription login as the provider credential. The header value names
|
|||
|
||||
Send a prompt and check the completed request in LiteLLM's Logs page. Its gateway user/key and token counts should match the caller. Give each person their own LiteLLM virtual key for separate attribution. The gateway credential and ChatGPT login are independently authenticated; LiteLLM does not establish that both identities belong to the same person.
|
||||
|
||||
The native routes are `GET /backend-api/codex/models` and `POST /backend-api/codex/responses`. Model requests use the upstream model name, so a restricted LiteLLM key must allow that name. OpenAI's model catalog is returned in its native format; seeing a model there does not override gateway permissions.
|
||||
The native routes are `GET /backend-api/codex/models` and `POST /backend-api/codex/responses`. Model requests use the upstream model name, so a restricted LiteLLM key needs the `chatgpt-native` access group above or that exact name. OpenAI's model catalog is returned in its native format; seeing a model there does not override gateway permissions.
|
||||
|
||||
The proxy forwards the ChatGPT bearer and account header to the fixed OpenAI Codex endpoint. It removes gateway credentials and cookies from the upstream request and redacts credential headers in logging callbacks. The client remains responsible for refresh and reauthentication. Native passthrough returns authentication and quota errors without switching subscriptions. Standard model routes retain the caller login across ChatGPT retries; explicitly configured cross-provider fallbacks use their own credentials. An explicit workspace header is preserved for OpenAI to validate.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
from collections.abc import Mapping
|
||||
from collections.abc import Mapping, Sequence
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Final
|
||||
from typing import TYPE_CHECKING, Final, cast # noqa: TID251 # the base signature leaves the params bag untyped
|
||||
from urllib.parse import unquote
|
||||
|
||||
import httpx
|
||||
|
||||
from litellm.litellm_core_utils.core_helpers import drops_unsupported_params
|
||||
from litellm.litellm_core_utils.url_utils import join_api_base
|
||||
from litellm.llms.base_llm.responses.transformation import (
|
||||
STANDARD_MODE_WITHOUT_CONTEXT_REASONING_FIELD_VALUES,
|
||||
|
|
@ -20,8 +21,9 @@ from litellm.llms.fireworks_ai.common_utils import (
|
|||
)
|
||||
from litellm.llms.openai.responses.instruction_items import developer_items_as_system, instruction_items_folded
|
||||
from litellm.llms.openai.responses.transformation import OpenAIResponsesAPIConfig
|
||||
from litellm.responses.additional_tools import hoist_additional_tools
|
||||
from litellm.secret_managers.main import get_secret_str
|
||||
from litellm.types.llms.openai import ResponseInputParam
|
||||
from litellm.types.llms.openai import ALL_RESPONSES_API_TOOL_PARAMS, ResponseInputParam
|
||||
from litellm.types.responses.main import DeleteResponseResult
|
||||
from litellm.types.router import GenericLiteLLMParams
|
||||
from litellm.types.utils import LlmProviders
|
||||
|
|
@ -98,6 +100,17 @@ class FireworksAIResponsesAPIConfig(OpenAIResponsesAPIConfig):
|
|||
headers: dict[str, str], # mutable-ok: overrides the base class signature
|
||||
) -> dict[str, object]: # mutable-ok: overrides the base class signature
|
||||
validated_input: Final = self._validate_input_param(input)
|
||||
tools_param: Final = cast( # cast-ok: the base signature leaves the params bag untyped
|
||||
"Sequence[ALL_RESPONSES_API_TOOL_PARAMS] | None", response_api_optional_request_params.get("tools")
|
||||
)
|
||||
hoisted: Final = hoist_additional_tools(
|
||||
validated_input, tools_param, drop_params=drops_unsupported_params(litellm_params.drop_params)
|
||||
)
|
||||
hoisted_params: Final = (
|
||||
{**response_api_optional_request_params, "tools": list(hoisted.tools)}
|
||||
if hoisted.hoisted
|
||||
else response_api_optional_request_params
|
||||
)
|
||||
model_info: Final = deployment_model_info(litellm_params)
|
||||
declared: Final = declared_mid_conversation_system_support(model, self.custom_llm_provider.value, model_info)
|
||||
keeps_developer_items_in_place: Final = declared is True and (
|
||||
|
|
@ -107,14 +120,9 @@ class FireworksAIResponsesAPIConfig(OpenAIResponsesAPIConfig):
|
|||
folded: Final = (
|
||||
None
|
||||
if keeps_developer_items_in_place
|
||||
else instruction_items_folded(
|
||||
validated_input, response_api_optional_request_params, "user" if declared is False else "system"
|
||||
)
|
||||
)
|
||||
placed_input, placed_params = folded or (
|
||||
developer_items_as_system(validated_input),
|
||||
response_api_optional_request_params,
|
||||
else instruction_items_folded(hoisted.input, hoisted_params, "user" if declared is False else "system")
|
||||
)
|
||||
placed_input, placed_params = folded or (developer_items_as_system(hoisted.input), hoisted_params)
|
||||
request: Final = super().transform_responses_api_request(
|
||||
model=resolve_fireworks_resource_name(model),
|
||||
input=placed_input,
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ from litellm.llms.openai.responses.guardrail_translation.tool_merge import (
|
|||
append_returned_tools,
|
||||
merge_guardrailed_tools,
|
||||
)
|
||||
from litellm.responses.additional_tools import additional_tools_in_input
|
||||
from litellm.responses.litellm_completion_transformation.compaction_items import (
|
||||
compaction_summary,
|
||||
decode_compaction_item,
|
||||
|
|
@ -953,9 +954,14 @@ class OpenAIResponsesHandler(BaseTranslation):
|
|||
|
||||
def extract_request_tool_names(self, data: Mapping[str, object]) -> list[str]:
|
||||
"""Extract tool names from Responses API request (tools[].name for function
|
||||
and custom, tools[].server_label for mcp)."""
|
||||
and custom, tools[].server_label for mcp), including tools carried by
|
||||
``additional_tools`` input items."""
|
||||
names: Final[list[str]] = []
|
||||
for tool in list_field(data, "tools"):
|
||||
request_tools: Final[tuple[object, ...]] = (
|
||||
*list_field(data, "tools"),
|
||||
*additional_tools_in_input(data.get("input")),
|
||||
)
|
||||
for tool in request_tools:
|
||||
if not isinstance(tool, dict):
|
||||
continue
|
||||
if tool.get("type") in ("function", "custom") and tool.get("name"):
|
||||
|
|
|
|||
|
|
@ -9351,7 +9351,7 @@
|
|||
"paths": {
|
||||
"/backend-api/codex/{endpoint}": {
|
||||
"get": {
|
||||
"description": "Forward a client's ChatGPT subscription with separate LiteLLM authentication.",
|
||||
"description": "Forward a client's ChatGPT subscription with separate LiteLLM authentication, listing and serving the\nkey's other chat models through LiteLLM.",
|
||||
"operationId": "codex_passthrough_route_backend_api_codex__endpoint__get",
|
||||
"parameters": [
|
||||
{
|
||||
|
|
@ -9399,7 +9399,7 @@
|
|||
]
|
||||
},
|
||||
"post": {
|
||||
"description": "Forward a client's ChatGPT subscription with separate LiteLLM authentication.",
|
||||
"description": "Forward a client's ChatGPT subscription with separate LiteLLM authentication, listing and serving the\nkey's other chat models through LiteLLM.",
|
||||
"operationId": "codex_passthrough_route_backend_api_codex__endpoint__post",
|
||||
"parameters": [
|
||||
{
|
||||
|
|
@ -9442,7 +9442,7 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"description": "Unmodified Codex response or server-sent events"
|
||||
"description": "Codex response, the model catalog with the key's LiteLLM models, or server-sent events"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
|
|
|
|||
|
|
@ -169,6 +169,7 @@ from litellm.repositories.table_repositories import (
|
|||
)
|
||||
from litellm.repositories.team_repository import TeamRepository
|
||||
from litellm.repositories.user_repository import UserRepository
|
||||
from litellm.responses.additional_tools import additional_tools_in_input
|
||||
from litellm.router import Router
|
||||
from litellm.router_api import RegistryReader
|
||||
from litellm.types.proxy.auth.auth_checks import UserNotFoundError
|
||||
|
|
@ -1026,9 +1027,6 @@ async def check_tools_allowlist(
|
|||
call_types: Final = get_call_types_for_route(route)
|
||||
if not call_types or not any(ct.value in TOOL_CAPABLE_CALL_TYPES for ct in call_types):
|
||||
return
|
||||
tool_names: Final = extract_request_tool_names(route, request_body)
|
||||
if not tool_names:
|
||||
return
|
||||
key_meta: Final = (valid_token.metadata or {}) if isinstance(valid_token.metadata, dict) else {}
|
||||
team_meta: Final = (valid_token.team_metadata or {}) if isinstance(valid_token.team_metadata, dict) else {}
|
||||
key_allowed: Final = key_meta.get("allowed_tools")
|
||||
|
|
@ -1036,6 +1034,9 @@ async def check_tools_allowlist(
|
|||
effective: Final = key_allowed if (isinstance(key_allowed, list) and len(key_allowed) > 0) else team_allowed
|
||||
if not isinstance(effective, list) or len(effective) == 0:
|
||||
return
|
||||
tool_names: Final = extract_request_tool_names(route, request_body)
|
||||
if not tool_names:
|
||||
return
|
||||
allowed_set: Final = {str(t) for t in effective}
|
||||
disallowed: Final = [n for n in tool_names if n not in allowed_set]
|
||||
if disallowed:
|
||||
|
|
@ -7974,6 +7975,10 @@ def _tools(tools: object) -> tuple[object, ...]:
|
|||
return ()
|
||||
|
||||
|
||||
def _request_tools(request_body: Mapping[str, object]) -> tuple[object, ...]:
|
||||
return (*_tools(request_body.get("tools")), *additional_tools_in_input(request_body.get("input")))
|
||||
|
||||
|
||||
def _tool_vector_store_ids(tool: object) -> tuple[str, ...]:
|
||||
try:
|
||||
tool_fields: Final = _TOOL_ADAPTER.validate_python(tool, strict=True)
|
||||
|
|
@ -7984,14 +7989,14 @@ def _tool_vector_store_ids(tool: object) -> tuple[str, ...]:
|
|||
|
||||
def _strict_requested_vector_store_ids(request_body: Mapping[str, object]) -> tuple[str, ...]:
|
||||
"""
|
||||
Same fields VectorStoreRegistry.get_vector_store_ids_to_run reads, but a vector_store_ids that is
|
||||
not a list of strings is a 400 instead of being skipped or iterated, and tools that are not
|
||||
objects name no store.
|
||||
Same fields VectorStoreRegistry.get_vector_store_ids_to_run reads, plus the tools of `additional_tools`
|
||||
input items, but a vector_store_ids that is not a list of strings is a 400 instead of being skipped or
|
||||
iterated, and tools that are not objects name no store.
|
||||
"""
|
||||
return tuple(
|
||||
chain(
|
||||
_validated_vector_store_ids(request_body.get("vector_store_ids")),
|
||||
chain.from_iterable(_tool_vector_store_ids(tool) for tool in _tools(request_body.get("tools"))),
|
||||
chain.from_iterable(_tool_vector_store_ids(tool) for tool in _request_tools(request_body)),
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -8030,7 +8035,7 @@ def _legacy_requested_vector_store_ids(
|
|||
return tuple(
|
||||
chain(
|
||||
top_level,
|
||||
chain.from_iterable(vector_store_ids_in_tool(tool) for tool in _tools(request_body.get("tools"))),
|
||||
chain.from_iterable(vector_store_ids_in_tool(tool) for tool in _request_tools(request_body)),
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -8099,8 +8104,8 @@ async def vector_store_access_check(
|
|||
"""
|
||||
Checks whether the caller may use every vector store the request names.
|
||||
|
||||
Requested stores come from `vector_store_ids`, `tools[].vector_store_ids` and the RAG
|
||||
`retrieval_config.vector_store_id`. Grants come from each identity's
|
||||
Requested stores come from `vector_store_ids`, `tools[].vector_store_ids`, the tools of
|
||||
`additional_tools` input items and the RAG `retrieval_config.vector_store_id`. Grants come from each identity's
|
||||
`object_permission.vector_stores`.
|
||||
|
||||
Both modes read the same request fields, with or without a vector store registry, and answer 400
|
||||
|
|
|
|||
|
|
@ -821,7 +821,7 @@ async def pre_db_read_auth_checks(
|
|||
Raises:
|
||||
- HTTPException if request fails initial auth checks
|
||||
"""
|
||||
from litellm.proxy.proxy_server import general_settings, llm_router, premium_user
|
||||
from litellm.proxy.proxy_server import general_settings, llm_router
|
||||
|
||||
# Check 1. request size
|
||||
await check_if_request_size_is_safe(request=request)
|
||||
|
|
@ -849,19 +849,12 @@ async def pre_db_read_auth_checks(
|
|||
)
|
||||
|
||||
# Check 4. Check if request route is an allowed route on the proxy
|
||||
if "allowed_routes" in general_settings:
|
||||
_allowed_routes: Final = general_settings["allowed_routes"]
|
||||
if premium_user is not True:
|
||||
verbose_proxy_logger.error(
|
||||
"Trying to set allowed_routes. This is an Enterprise feature. %s",
|
||||
CommonProxyErrors.not_premium_user.value,
|
||||
)
|
||||
if route not in _allowed_routes:
|
||||
verbose_proxy_logger.error("Route %s not in allowed_routes=%s", route, _allowed_routes)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Access forbidden: Route {route} not allowed",
|
||||
)
|
||||
if not RouteChecks.is_allowed_on_proxy(route):
|
||||
verbose_proxy_logger.error("Route %s not in allowed_routes=%s", route, general_settings["allowed_routes"])
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Access forbidden: Route {route} not allowed",
|
||||
)
|
||||
|
||||
|
||||
def route_in_additonal_public_routes(current_route: str):
|
||||
|
|
|
|||
|
|
@ -439,6 +439,19 @@ class RouteChecks:
|
|||
detail=f"user not allowed to access this route. Route={route} is an admin only route",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def is_allowed_on_proxy(route: str) -> bool:
|
||||
from litellm.proxy.proxy_server import general_settings, premium_user
|
||||
|
||||
if "allowed_routes" not in general_settings:
|
||||
return True
|
||||
if premium_user is not True:
|
||||
verbose_proxy_logger.error(
|
||||
"Trying to set allowed_routes. This is an Enterprise feature. %s",
|
||||
CommonProxyErrors.not_premium_user.value,
|
||||
)
|
||||
return route in general_settings["allowed_routes"]
|
||||
|
||||
@staticmethod
|
||||
def is_unbilled_route(route: str) -> bool:
|
||||
resource, _, action = route.rsplit("/", 1)[-1].partition(":")
|
||||
|
|
|
|||
|
|
@ -339,6 +339,30 @@ async def _resolve_router_settings_model_group_alias(
|
|||
rewrite_request_model(request_data, request, target)
|
||||
|
||||
|
||||
_CODEX_NATIVE_RESPONSES_ROUTE: Final = "/backend-api/codex/responses"
|
||||
|
||||
|
||||
def _resolve_codex_native_chatgpt_model(
|
||||
request_data: dict[str, object], # mutable-ok: the request body is rewritten in place for every downstream reader
|
||||
valid_token: UserAPIKeyAuth,
|
||||
request: Request | None,
|
||||
route: str,
|
||||
) -> None:
|
||||
requested: Final = request_data.get("model")
|
||||
if route != _CODEX_NATIVE_RESPONSES_ROUTE or request is None or not isinstance(requested, str):
|
||||
return
|
||||
from litellm.proxy.pass_through_endpoints.codex_passthrough_endpoints import native_chatgpt_model
|
||||
from litellm.proxy.proxy_server import llm_router
|
||||
|
||||
if llm_router is None:
|
||||
return
|
||||
target: Final = native_chatgpt_model(requested, llm_router, valid_token)
|
||||
if target is None:
|
||||
return
|
||||
request.scope.setdefault(CLIENT_REQUESTED_MODEL_SCOPE_KEY, requested)
|
||||
rewrite_request_model(request_data, request, target)
|
||||
|
||||
|
||||
def _get_model_names_for_budget_checks(
|
||||
model: str | list[str] | None,
|
||||
) -> list[str]:
|
||||
|
|
@ -4161,6 +4185,7 @@ async def _authorize_authenticated_request(
|
|||
RouteChecks.should_call_route(route=route, valid_token=user_api_key_auth_obj, request=request)
|
||||
await _normalize_claude_model(request_data, user_api_key_auth_obj, request, route)
|
||||
await _resolve_router_settings_model_group_alias(request_data, user_api_key_auth_obj, request, route)
|
||||
_resolve_codex_native_chatgpt_model(request_data, user_api_key_auth_obj, request, route)
|
||||
|
||||
# Single authorization point. Builder paths MUST NOT call common_checks.
|
||||
# Route through the same exception handler the builder uses so
|
||||
|
|
@ -4628,6 +4653,7 @@ async def enforce_key_and_fallback_model_access(
|
|||
"""
|
||||
await _normalize_claude_model(request_data, valid_token, request, route)
|
||||
await _resolve_router_settings_model_group_alias(request_data, valid_token, request, route)
|
||||
_resolve_codex_native_chatgpt_model(request_data, valid_token, request, route)
|
||||
config: Final = valid_token.config
|
||||
|
||||
if config != {}:
|
||||
|
|
|
|||
|
|
@ -21,8 +21,10 @@ a newer tag.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import asdict, dataclass
|
||||
from functools import cache
|
||||
from itertools import accumulate, pairwise
|
||||
from pathlib import Path
|
||||
|
|
@ -49,6 +51,8 @@ CODEX_CHAT_MODES: Final = frozenset({"chat", "responses"})
|
|||
_TierId = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)]
|
||||
_BODY_PREFIX: Final = '{"models":['
|
||||
_BODY_SUFFIX: Final = "]}"
|
||||
_JSON_OBJECT: Final = TypeAdapter(dict[str, object])
|
||||
_JSON_OBJECTS: Final = TypeAdapter(tuple[dict[str, object], ...])
|
||||
_FIRST_CODEX_WITH_CUSTOM_EFFORTS: Final = Version("0.138.0")
|
||||
_EFFORTS_EVERY_CODEX_PARSES: Final = frozenset({"none", "minimal", "low", "medium", "high", "xhigh"})
|
||||
_PREFERRED_DEFAULT_EFFORT: Final = "medium"
|
||||
|
|
@ -169,6 +173,7 @@ class CodexStockCatalog(LiteLLMBaseModel):
|
|||
|
||||
CodexCatalogEntry = CodexFallbackModel | CodexStockModel
|
||||
CodexAliasDefaults: TypeAlias = Literal["codex", "deployment"]
|
||||
_NATIVE_ALIAS_DEFAULTS: Final[CodexAliasDefaults] = "deployment"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
|
|
@ -412,6 +417,7 @@ def _entry(
|
|||
stock_model.upgrade if stock_model.upgrade is not None and stock_model.upgrade.model in served else None
|
||||
)
|
||||
is_alias: Final = row.id != stock_model.slug
|
||||
deployment_decides: Final = is_alias and alias_defaults == "deployment"
|
||||
stock_name: Final = row.id if is_alias else stock_model.display_name
|
||||
listing: Final = {
|
||||
"slug": row.id,
|
||||
|
|
@ -421,7 +427,7 @@ def _entry(
|
|||
"supported_in_api": True,
|
||||
"upgrade": upgrade,
|
||||
**({} if search_tool is None else {"supports_search_tool": search_tool}),
|
||||
**(_deployment_defaults(row, stock_model) if is_alias and alias_defaults == "deployment" else {}),
|
||||
**(_deployment_defaults(row, stock_model) if deployment_decides else {}),
|
||||
**_configured_window(row, stock_model),
|
||||
}
|
||||
if configured is None:
|
||||
|
|
@ -432,7 +438,9 @@ def _entry(
|
|||
**listing,
|
||||
"service_tiers": configured,
|
||||
"default_service_tier": (
|
||||
stock_model.default_service_tier if stock_model.default_service_tier in configured_ids else None
|
||||
stock_model.default_service_tier
|
||||
if not deployment_decides and stock_model.default_service_tier in configured_ids
|
||||
else None
|
||||
),
|
||||
}
|
||||
)
|
||||
|
|
@ -448,13 +456,12 @@ def _bytes_left_after(left: int, size: int) -> int:
|
|||
return left - size if size <= left else left
|
||||
|
||||
|
||||
def _fits(serialized: Sequence[str], byte_limit: int | None) -> tuple[bool, ...]:
|
||||
"""Whether each entry, taken in order, fits in what is left of a body of at most `byte_limit`
|
||||
bytes: an entry larger than the bytes left is passed over and a smaller one after it is still
|
||||
taken, each entry counted with its separating comma and the last one's comma given back."""
|
||||
if byte_limit is None:
|
||||
def _fits(serialized: Sequence[str], budget: int | None) -> tuple[bool, ...]:
|
||||
"""Whether each entry, taken in order, fits in what is left of `budget` bytes: an entry larger
|
||||
than the bytes left is passed over and a smaller one after it is still taken, each entry
|
||||
counted with its separating comma."""
|
||||
if budget is None:
|
||||
return (True,) * len(serialized)
|
||||
budget: Final = byte_limit - len(_BODY_PREFIX) - len(_BODY_SUFFIX) + 1
|
||||
left: Final = tuple(
|
||||
accumulate((len(entry.encode()) + 1 for entry in serialized), _bytes_left_after, initial=budget)
|
||||
)
|
||||
|
|
@ -462,17 +469,34 @@ def _fits(serialized: Sequence[str], byte_limit: int | None) -> tuple[bool, ...]
|
|||
|
||||
|
||||
def _kept_positions(
|
||||
entries: Sequence[CodexCatalogEntry], serialized: Sequence[str], byte_limit: int | None
|
||||
entries: Sequence[CodexCatalogEntry], serialized: Sequence[str], budget: int | None
|
||||
) -> frozenset[int]:
|
||||
"""The listing positions that survive the byte limit: an entry offering a service tier is taken
|
||||
ahead of one offering none, each group in listing order, since a model without a tier is driven
|
||||
by name just as well off Codex's bundled catalog; an entry too large for the bytes left never
|
||||
costs the smaller ones after it their place."""
|
||||
survival: Final = sorted(range(len(entries)), key=lambda position: (not entries[position].service_tiers, position))
|
||||
fits: Final = _fits(tuple(serialized[position] for position in survival), byte_limit)
|
||||
fits: Final = _fits(tuple(serialized[position] for position in survival), budget)
|
||||
return frozenset(position for position, fit in zip(survival, fits, strict=True) if fit)
|
||||
|
||||
|
||||
def _entries(
|
||||
drivable: Sequence[CodexCatalogRow],
|
||||
stock: Mapping[str, CodexStockModel] | None,
|
||||
instructions: str | None,
|
||||
first_priority: int,
|
||||
alias_defaults: CodexAliasDefaults,
|
||||
client_version: str | None,
|
||||
) -> tuple[CodexCatalogEntry, ...]:
|
||||
known: Final = bundled_codex_models() if stock is None else stock
|
||||
prompt: Final = codex_base_instructions() if instructions is None else instructions
|
||||
served: Final = frozenset(row.id for row in drivable)
|
||||
return tuple(
|
||||
_entry(first_priority + index, row, known, served, prompt, alias_defaults, client_version)
|
||||
for index, row in enumerate(drivable)
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CodexCatalogBody:
|
||||
"""A `ModelsResponse` body, the ids it lists, and the chat-capable ids the byte limit left out."""
|
||||
|
|
@ -506,15 +530,12 @@ def codex_models_response_json(
|
|||
model's own name. `client_version` is the requesting Codex's version, which decides the reasoning
|
||||
levels a fallback entry may offer; None counts as a version too old for custom levels.
|
||||
"""
|
||||
known: Final = bundled_codex_models() if stock is None else stock
|
||||
prompt: Final = codex_base_instructions() if instructions is None else instructions
|
||||
drivable: Final = tuple(row for row in rows if _codex_can_drive(row.id, row.mode))
|
||||
served: Final = frozenset(row.id for row in drivable)
|
||||
entries: Final = tuple(
|
||||
_entry(index, row, known, served, prompt, alias_defaults, client_version) for index, row in enumerate(drivable)
|
||||
)
|
||||
entries: Final = _entries(drivable, stock, instructions, 0, alias_defaults, client_version)
|
||||
serialized: Final = tuple(_serialized(entry) for entry in entries)
|
||||
kept: Final = _kept_positions(entries, serialized, byte_limit)
|
||||
kept: Final = _kept_positions(
|
||||
entries, serialized, None if byte_limit is None else byte_limit - len(_BODY_PREFIX) - len(_BODY_SUFFIX) + 1
|
||||
)
|
||||
return CodexCatalogBody(
|
||||
json=f"{_BODY_PREFIX}{','.join(serialized[position] for position in sorted(kept))}{_BODY_SUFFIX}",
|
||||
listed=tuple(row.id for position, row in enumerate(drivable) if position in kept),
|
||||
|
|
@ -522,6 +543,68 @@ def codex_models_response_json(
|
|||
)
|
||||
|
||||
|
||||
def _priority(model: Mapping[str, object]) -> int:
|
||||
priority: Final = model.get("priority")
|
||||
return priority if isinstance(priority, int) else -1
|
||||
|
||||
|
||||
def _compact_json(value: object) -> str:
|
||||
return json.dumps(value, separators=(",", ":"))
|
||||
|
||||
|
||||
def _entry_json(entry: CodexCatalogEntry) -> Mapping[str, object]:
|
||||
return _JSON_OBJECT.validate_python(entry.model_dump(mode="json", exclude_unset=isinstance(entry, CodexStockModel)))
|
||||
|
||||
|
||||
def merged_codex_catalog(
|
||||
upstream_body: bytes,
|
||||
rows: Sequence[CodexCatalogRow],
|
||||
*,
|
||||
stock: Mapping[str, CodexStockModel] | None = None,
|
||||
instructions: str | None = None,
|
||||
byte_limit: int = CODEX_CATALOG_BYTE_LIMIT,
|
||||
client_version: str | None = None,
|
||||
) -> str | None:
|
||||
try:
|
||||
top: Final = _JSON_OBJECT.validate_json(upstream_body)
|
||||
upstream: Final = _JSON_OBJECTS.validate_python(top.get("models"))
|
||||
except ValidationError:
|
||||
return None
|
||||
slugs: Final = frozenset(model.get("slug") for model in upstream)
|
||||
added: Final = tuple(row for row in rows if _codex_can_drive(row.id, row.mode) and row.id not in slugs)
|
||||
first_priority: Final = max((_priority(model) for model in upstream), default=-1) + 1
|
||||
entries: Final = _entries(added, stock, instructions, first_priority, _NATIVE_ALIAS_DEFAULTS, client_version)
|
||||
added_models: Final = tuple(_entry_json(entry) for entry in entries)
|
||||
serialized: Final = tuple(_compact_json(model) for model in added_models)
|
||||
budget: Final = byte_limit - len(_compact_json({**top, "models": upstream}))
|
||||
kept: Final = tuple(sorted(_kept_positions(entries, serialized, budget)))
|
||||
if len(kept) < len(entries):
|
||||
verbose_proxy_logger.warning(
|
||||
"Codex native model catalog left out %d of %d LiteLLM models to stay within Codex's %d byte limit",
|
||||
len(entries) - len(kept),
|
||||
len(entries),
|
||||
byte_limit,
|
||||
)
|
||||
if not kept:
|
||||
return None
|
||||
return _compact_json({**top, "models": [*upstream, *(added_models[position] for position in kept)]})
|
||||
|
||||
|
||||
def codex_catalog_etag(upstream_etag: str, rows: Sequence[CodexCatalogRow]) -> str:
|
||||
canonical: Final = json.dumps([asdict(row) for row in rows], sort_keys=True, separators=(",", ":"), default=repr)
|
||||
digest: Final = hashlib.sha256(json.dumps([upstream_etag, canonical]).encode()).hexdigest()
|
||||
return f'"{digest}"'
|
||||
|
||||
|
||||
def upstream_codex_slugs(upstream_body: bytes) -> frozenset[str] | None:
|
||||
try:
|
||||
upstream: Final = _JSON_OBJECTS.validate_python(_JSON_OBJECT.validate_json(upstream_body).get("models"))
|
||||
except ValidationError:
|
||||
return None
|
||||
slugs: Final = (model.get("slug") for model in upstream)
|
||||
return frozenset(slug for slug in slugs if isinstance(slug, str))
|
||||
|
||||
|
||||
def _group_reasoning_efforts(
|
||||
row: ModelInfoResponse, lookup_id: str, registry: RouterRegistry | None, team_id: str | None
|
||||
) -> tuple[str, ...] | None:
|
||||
|
|
@ -531,6 +614,11 @@ def _group_reasoning_efforts(
|
|||
return group.supported_reasoning_efforts if group is not None else None
|
||||
|
||||
|
||||
def _shared_pinned_effort(pinned: tuple[object, ...]) -> str | None:
|
||||
first: Final = next(iter(pinned), None)
|
||||
return first if isinstance(first, str) and all(effort == first for effort in pinned) else None
|
||||
|
||||
|
||||
def _catalog_row(
|
||||
row: ModelInfoResponse, lookup_id: str, label_id: str, llm_router: Router | None, team_id: str | None
|
||||
) -> CodexCatalogRow:
|
||||
|
|
@ -541,6 +629,11 @@ def _catalog_row(
|
|||
max_input_tokens=row.get("max_input_tokens"),
|
||||
upstream_model=registry.resolve(lookup_id, kind="upstream", team_id=team_id) if registry is not None else None,
|
||||
display_name=registry.group(label_id, field="display_name") if registry is not None else None,
|
||||
reasoning_effort=(
|
||||
_shared_pinned_effort(registry.group(lookup_id, field="pinned_reasoning_efforts", team_id=team_id))
|
||||
if registry is not None
|
||||
else None
|
||||
),
|
||||
service_tiers=registry.group(lookup_id, field="service_tiers", team_id=team_id) if registry is not None else (),
|
||||
supported_reasoning_efforts=_group_reasoning_efforts(row, lookup_id, registry, team_id),
|
||||
search_tool_support=(
|
||||
|
|
@ -596,3 +689,21 @@ def codex_model_list_body(
|
|||
", ".join(body.left_out),
|
||||
)
|
||||
return body.json
|
||||
|
||||
|
||||
def codex_entry_names_default_reasoning_level(
|
||||
model_id: str, llm_router: Router, team_id: str | None, client_version: str | None
|
||||
) -> bool:
|
||||
"""Whether the native catalog entry for `model_id` names a `default_reasoning_level`. Codex's model picker
|
||||
sends reasoning effort `none` for a model whose entry names none, while showing that choice as
|
||||
"default"."""
|
||||
listed: Final[ModelInfoResponse] = {"id": model_id, "object": "model", "created": 0, "owned_by": ""}
|
||||
(entry,) = _entries(
|
||||
(_catalog_row(listed, model_id, model_id, llm_router, team_id),),
|
||||
None,
|
||||
"",
|
||||
0,
|
||||
_NATIVE_ALIAS_DEFAULTS,
|
||||
client_version,
|
||||
)
|
||||
return _entry_json(entry).get("default_reasoning_level") is not None
|
||||
|
|
|
|||
|
|
@ -1,15 +1,92 @@
|
|||
import asyncio
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import re
|
||||
from collections.abc import Iterator, Mapping
|
||||
from types import MappingProxyType
|
||||
from typing import Annotated, Final, Literal
|
||||
from typing import TYPE_CHECKING, Annotated, Final, Literal, Protocol
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
from starlette.types import Message
|
||||
|
||||
import litellm
|
||||
from litellm._internal_context import with_service_target
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.exceptions import BadRequestError
|
||||
from litellm.litellm_core_utils.core_helpers import drops_unsupported_params, normalize_drop_params
|
||||
from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider
|
||||
from litellm.llms.chatgpt.common_utils import CHATGPT_API_BASE
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
from litellm.llms.custom_httpx.http_handler import (
|
||||
get_async_httpx_client, # pyright: ignore[reportUnknownVariableType] # shared client factory has untyped params
|
||||
)
|
||||
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
|
||||
from litellm.proxy.auth.auth_checks import check_model_access_helper, check_tools_allowlist
|
||||
from litellm.proxy.auth.route_checks import RouteChecks
|
||||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||
from litellm.proxy.common_utils.codex_model_catalog import (
|
||||
CodexCatalogRow,
|
||||
bundled_codex_models,
|
||||
codex_catalog_etag,
|
||||
codex_entry_names_default_reasoning_level,
|
||||
merged_codex_catalog,
|
||||
upstream_codex_slugs,
|
||||
)
|
||||
from litellm.proxy.common_utils.http_parsing_utils import (
|
||||
get_client_requested_model,
|
||||
read_request_body, # pyright: ignore[reportUnknownVariableType] # returns a bare dict, validated at the call
|
||||
)
|
||||
from litellm.proxy.litellm_pre_call_utils import should_auto_drop_params_for_agentic_cli
|
||||
from litellm.proxy.pass_through_endpoints.pass_through_endpoints import pass_through_request
|
||||
from litellm.proxy.response_api_endpoints.endpoints import responses_api
|
||||
from litellm.responses.additional_tools import with_additional_tools_hoisted
|
||||
from litellm.responses.litellm_completion_transformation.reasoning_items import is_other_provider_reasoning_item
|
||||
from litellm.responses.utils import ResponsesAPIRequestUtils
|
||||
from litellm.types.llms.custom_http import httpxSpecialProvider
|
||||
from litellm.types.router import Deployment
|
||||
from litellm.types.utils import LlmProviders
|
||||
from litellm.utils import ProviderConfigManager, supports_none_reasoning_effort
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.router import Router
|
||||
|
||||
router: Final = APIRouter()
|
||||
_CHATGPT_PROVIDER: Final = "chatgpt"
|
||||
_CHATGPT_CREDENTIAL_HEADERS: Final = frozenset({b"authorization", b"chatgpt-account-id"})
|
||||
_MERGED_CATALOG_DROPPED_HEADERS: Final = frozenset({"etag", "content-length", "content-type"})
|
||||
_ETAG_HEADER: Final = "etag"
|
||||
_MODELS_ETAG_HEADER: Final = "x-models-etag"
|
||||
_ENCRYPTED_CONTENT_AFFINITY_FLAG: Final = "encrypted_content_affinity_enabled"
|
||||
_LITELLM_RESPONSES_PATH: Final = "/v1/responses"
|
||||
_CODEX_NO_DEFAULT_EFFORT: Final = "none"
|
||||
_NONE_EFFORT_SUPPORT_FLAG: Final = "supports_none_reasoning_effort"
|
||||
_JSON_OBJECT: Final = TypeAdapter(dict[str, object])
|
||||
_INPUT_ITEMS: Final = TypeAdapter(tuple[object, ...])
|
||||
_CACHED_SLUGS: Final = TypeAdapter(frozenset[str])
|
||||
_CACHED_MODELS_ETAG: Final = TypeAdapter(dict[Literal["etag"], str])
|
||||
_MODEL_NAMES: Final = TypeAdapter(list[str])
|
||||
_HEADER_DERIVED_STATE: Final = frozenset({"_cached_headers"})
|
||||
_ACCOUNT_CATALOG_HEADERS: Final = frozenset({"authorization", "chatgpt-account-id", "originator", "user-agent"})
|
||||
_CODEX_USER_AGENT_PRODUCT: Final = re.compile(r"([^/]+)/(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.+-]+)?)")
|
||||
_ACCOUNT_CATALOG_TIMEOUT_SECONDS: Final = 5.0
|
||||
_SHARED_CACHE_TTL_SECONDS: Final = 3600
|
||||
_ACCOUNT_SLUGS_UNAVAILABLE: Final = "unavailable"
|
||||
_ACCOUNT_SLUGS_UNAVAILABLE_TTL_SECONDS: Final = 60
|
||||
_ACCOUNT_SLUGS_CACHE_PREFIX: Final = "codex_account_slugs:"
|
||||
_ACCOUNT_SLUGS_TARGET: Final = "codex_account_slugs"
|
||||
_ACCOUNT_SLUGS_FETCHES_CACHE_PREFIX: Final = "codex_account_slugs_fetches:"
|
||||
_ACCOUNT_SLUGS_FETCHES_PER_CALLER: Final = 5
|
||||
_ACCOUNT_SLUGS_FETCHES_WINDOW_SECONDS: Final = 60
|
||||
_MODELS_ETAG_CACHE_PREFIX: Final = "codex_models_etag:"
|
||||
_MODELS_ETAG_TTL_SECONDS: Final = 300
|
||||
_OPENAI_API_PROVIDERS: Final = frozenset({LlmProviders.OPENAI.value, LlmProviders.AZURE.value})
|
||||
_CODEX_TOOL_PROVIDERS: Final = frozenset(
|
||||
{*_OPENAI_API_PROVIDERS, LlmProviders.FIREWORKS_AI.value, LlmProviders.BEDROCK_MANTLE.value}
|
||||
)
|
||||
_CHAT_COMPLETIONS_BRIDGE: Final = "use_chat_completions_api"
|
||||
_HEADERS: Final = frozenset(
|
||||
{
|
||||
"authorization",
|
||||
|
|
@ -29,10 +106,541 @@ _HEADERS: Final = frozenset(
|
|||
)
|
||||
|
||||
|
||||
@router.get("/backend-api/codex/{endpoint}", tags=["ChatGPT Pass-through", "pass-through"])
|
||||
def _is_chatgpt(deployment: Deployment) -> bool:
|
||||
return (
|
||||
deployment.litellm_params.custom_llm_provider == _CHATGPT_PROVIDER
|
||||
or deployment.litellm_params.model.partition("/")[0] == _CHATGPT_PROVIDER
|
||||
)
|
||||
|
||||
|
||||
def _serves_by_name(deployment: Deployment, names: frozenset[str]) -> bool:
|
||||
return not _is_chatgpt(deployment) and (
|
||||
deployment.model_name in names or deployment.model_info.team_public_model_name in names
|
||||
)
|
||||
|
||||
|
||||
def _candidate_deployments(model: str, llm_router: "Router", team_id: str | None) -> tuple[Deployment, ...]:
|
||||
registry: Final = llm_router.registry()
|
||||
candidates: Final = tuple(
|
||||
registry.deployment(model_id) for model_id in registry.names(model, kind="route_candidates", team_id=team_id)
|
||||
)
|
||||
return tuple(deployment for deployment in candidates if deployment is not None)
|
||||
|
||||
|
||||
def _routes_to_litellm(model: object, llm_router: "Router | None", team_id: str | None) -> bool:
|
||||
if (
|
||||
not isinstance(model, str)
|
||||
or llm_router is None
|
||||
or model in bundled_codex_models()
|
||||
or f"{_CHATGPT_PROVIDER}/{model}" in litellm.model_cost # pyright: ignore[reportUnknownMemberType] # registry is dynamically typed
|
||||
):
|
||||
return False
|
||||
deployments: Final = _candidate_deployments(model, llm_router, team_id)
|
||||
names: Final = frozenset({model, llm_router.registry().resolve(model, kind="alias") or model})
|
||||
return bool(deployments) and all(_serves_by_name(deployment, names) for deployment in deployments)
|
||||
|
||||
|
||||
def _key_and_team_allow(model: str, llm_router: "Router", user_api_key_dict: UserAPIKeyAuth) -> bool:
|
||||
allowed_lists: Final = (
|
||||
_MODEL_NAMES.validate_python(user_api_key_dict.models),
|
||||
_MODEL_NAMES.validate_python(user_api_key_dict.team_models),
|
||||
)
|
||||
return all(
|
||||
check_model_access_helper(model, llm_router, allowed, team_id=user_api_key_dict.team_id)
|
||||
for allowed in allowed_lists
|
||||
)
|
||||
|
||||
|
||||
def native_chatgpt_model(model: str, llm_router: "Router", user_api_key_dict: UserAPIKeyAuth) -> str | None:
|
||||
if model.startswith(f"{_CHATGPT_PROVIDER}/") or _key_and_team_allow(model, llm_router, user_api_key_dict):
|
||||
return None
|
||||
chatgpt_model: Final = f"{_CHATGPT_PROVIDER}/{model}"
|
||||
deployments: Final = _candidate_deployments(chatgpt_model, llm_router, user_api_key_dict.team_id)
|
||||
if not deployments or not all(_is_chatgpt(deployment) for deployment in deployments):
|
||||
return None
|
||||
return chatgpt_model if _key_and_team_allow(chatgpt_model, llm_router, user_api_key_dict) else None
|
||||
|
||||
|
||||
class _SharedCache(Protocol):
|
||||
async def async_get_cache(self, key: str) -> object: ...
|
||||
|
||||
async def async_set_cache(self, key: str, value: object, *, ttl: int) -> None: ...
|
||||
|
||||
async def async_increment_cache(self, key: str, value: float, *, ttl: int) -> float | None: ...
|
||||
|
||||
|
||||
def codex_shared_cache() -> _SharedCache:
|
||||
from litellm.proxy.proxy_server import proxy_logging_obj
|
||||
|
||||
return proxy_logging_obj.internal_usage_cache.dual_cache
|
||||
|
||||
|
||||
def _may_call_litellm_responses(user_api_key_dict: UserAPIKeyAuth) -> bool:
|
||||
if not RouteChecks.is_allowed_on_proxy(_LITELLM_RESPONSES_PATH):
|
||||
return False
|
||||
try:
|
||||
_ = RouteChecks.should_call_route(route=_LITELLM_RESPONSES_PATH, valid_token=user_api_key_dict)
|
||||
if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN:
|
||||
RouteChecks.custom_admin_only_route_check(route=_LITELLM_RESPONSES_PATH)
|
||||
except HTTPException:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _account_slugs_cache_key(headers: Mapping[str, str]) -> str | None:
|
||||
account_id: Final = headers.get("chatgpt-account-id")
|
||||
if not account_id:
|
||||
return None
|
||||
caller: Final = json.dumps([account_id, headers.get("authorization", "")])
|
||||
return f"{_ACCOUNT_SLUGS_CACHE_PREFIX}{hashlib.sha256(caller.encode()).hexdigest()}"
|
||||
|
||||
|
||||
def _cached_slugs(cached: object) -> frozenset[str] | None:
|
||||
if cached is None:
|
||||
return None
|
||||
try:
|
||||
return _CACHED_SLUGS.validate_python(cached)
|
||||
except ValidationError:
|
||||
return None
|
||||
|
||||
|
||||
async def _remember_account_slugs(cache_key: str, slugs: frozenset[str] | None, cache: _SharedCache) -> None:
|
||||
if slugs is None:
|
||||
await cache.async_set_cache(cache_key, _ACCOUNT_SLUGS_UNAVAILABLE, ttl=_ACCOUNT_SLUGS_UNAVAILABLE_TTL_SECONDS)
|
||||
return
|
||||
await cache.async_set_cache(cache_key, tuple(sorted(slugs)), ttl=_SHARED_CACHE_TTL_SECONDS)
|
||||
|
||||
|
||||
def _codex_client_version(headers: Mapping[str, str]) -> str | None:
|
||||
product: Final = _CODEX_USER_AGENT_PRODUCT.fullmatch(headers.get("user-agent", "").partition(" ")[0])
|
||||
if product is None or product.group(1) != headers.get("originator"):
|
||||
return None
|
||||
return product.group(2)
|
||||
|
||||
|
||||
async def _fetched_account_slugs(headers: Mapping[str, str], client_version: str) -> frozenset[str] | None:
|
||||
client: Final = get_async_httpx_client(
|
||||
llm_provider=httpxSpecialProvider.PassThroughEndpoint,
|
||||
params={"timeout": _ACCOUNT_CATALOG_TIMEOUT_SECONDS},
|
||||
)
|
||||
try:
|
||||
response: Final = await client.client.get(
|
||||
f"{CHATGPT_API_BASE}/models",
|
||||
params={"client_version": client_version},
|
||||
headers={name: value for name, value in headers.items() if name in _ACCOUNT_CATALOG_HEADERS},
|
||||
)
|
||||
except httpx.HTTPError:
|
||||
return None
|
||||
return upstream_codex_slugs(response.content) if response.status_code == 200 else None
|
||||
|
||||
|
||||
async def _admits_account_slugs_fetch(caller: str, cache: _SharedCache) -> bool:
|
||||
fetches: Final = await cache.async_increment_cache(
|
||||
f"{_ACCOUNT_SLUGS_FETCHES_CACHE_PREFIX}{caller}", 1, ttl=_ACCOUNT_SLUGS_FETCHES_WINDOW_SECONDS
|
||||
)
|
||||
return fetches is not None and fetches <= _ACCOUNT_SLUGS_FETCHES_PER_CALLER
|
||||
|
||||
|
||||
@with_service_target(_ACCOUNT_SLUGS_TARGET)
|
||||
async def _account_slugs(headers: Mapping[str, str], caller: str, cache: _SharedCache) -> frozenset[str] | None:
|
||||
cache_key: Final = _account_slugs_cache_key(headers)
|
||||
if cache_key is None:
|
||||
return None
|
||||
cached_value: Final = await cache.async_get_cache(cache_key)
|
||||
if cached_value == _ACCOUNT_SLUGS_UNAVAILABLE:
|
||||
return None
|
||||
cached: Final = _cached_slugs(cached_value)
|
||||
if cached is not None:
|
||||
return cached
|
||||
client_version: Final = _codex_client_version(headers)
|
||||
if client_version is None or not await _admits_account_slugs_fetch(caller, cache):
|
||||
return None
|
||||
fetched: Final = await _fetched_account_slugs(headers, client_version)
|
||||
await _remember_account_slugs(cache_key, fetched, cache)
|
||||
return fetched
|
||||
|
||||
|
||||
async def _serves_through_litellm(
|
||||
model: object,
|
||||
llm_router: "Router | None",
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
caller: str,
|
||||
headers: Mapping[str, str],
|
||||
cache: _SharedCache,
|
||||
) -> bool:
|
||||
if not _routes_to_litellm(model, llm_router, user_api_key_dict.team_id) or not _may_call_litellm_responses(
|
||||
user_api_key_dict
|
||||
):
|
||||
return False
|
||||
account_slugs: Final = await _account_slugs(headers, caller, cache)
|
||||
return account_slugs is not None and model not in account_slugs
|
||||
|
||||
|
||||
def _json_object(value: object) -> Mapping[str, object]:
|
||||
try:
|
||||
return _JSON_OBJECT.validate_python(value)
|
||||
except ValidationError:
|
||||
return MappingProxyType({})
|
||||
|
||||
|
||||
def _scope_state(request: Request) -> Mapping[str, object]:
|
||||
return _json_object(request.scope.get("state"))
|
||||
|
||||
|
||||
def _as_litellm_responses_request(request: Request, body: bytes, parsed_body: Mapping[str, object]) -> Request:
|
||||
replayed: Final[Iterator[Message]] = iter(({"type": "http.request", "body": body, "more_body": False},))
|
||||
|
||||
async def receive() -> Message:
|
||||
return next(replayed, None) or await request.receive()
|
||||
|
||||
return Request(
|
||||
{
|
||||
**request.scope,
|
||||
"path": _LITELLM_RESPONSES_PATH,
|
||||
"raw_path": _LITELLM_RESPONSES_PATH.encode(),
|
||||
"headers": [
|
||||
(name, value) for name, value in request.headers.raw if name not in _CHATGPT_CREDENTIAL_HEADERS
|
||||
],
|
||||
"parsed_body": (tuple(parsed_body), parsed_body),
|
||||
"state": {
|
||||
name: value for name, value in _scope_state(request).items() if name not in _HEADER_DERIVED_STATE
|
||||
},
|
||||
},
|
||||
receive,
|
||||
)
|
||||
|
||||
|
||||
def _drops_params(body: Mapping[str, object], model: str, llm_router: "Router", team_id: str | None) -> bool:
|
||||
deployments: Final = _candidate_deployments(model, llm_router, team_id)
|
||||
return drops_unsupported_params(body.get("drop_params")) or (
|
||||
bool(deployments)
|
||||
and all(normalize_drop_params(deployment.litellm_params.drop_params) is True for deployment in deployments)
|
||||
)
|
||||
|
||||
|
||||
def _deployment_supports_no_effort(deployment: Deployment) -> bool:
|
||||
configured: Final = _json_object(deployment.model_info.model_extra).get(_NONE_EFFORT_SUPPORT_FLAG)
|
||||
if isinstance(configured, bool):
|
||||
return configured
|
||||
return supports_none_reasoning_effort(
|
||||
model=deployment.litellm_params.model, custom_llm_provider=deployment.litellm_params.custom_llm_provider
|
||||
)
|
||||
|
||||
|
||||
def _supports_no_effort(model: str, llm_router: "Router", team_id: str | None) -> bool:
|
||||
deployments: Final = _candidate_deployments(model, llm_router, team_id)
|
||||
return bool(deployments) and all(_deployment_supports_no_effort(deployment) for deployment in deployments)
|
||||
|
||||
|
||||
def _with_provider_default_reasoning(
|
||||
body: Mapping[str, object], llm_router: "Router | None", team_id: str | None, client_version: str | None
|
||||
) -> Mapping[str, object]:
|
||||
model: Final = body.get("model")
|
||||
try:
|
||||
reasoning: Final = _JSON_OBJECT.validate_python(body.get("reasoning"))
|
||||
except ValidationError:
|
||||
return body
|
||||
if (
|
||||
reasoning.get("effort") != _CODEX_NO_DEFAULT_EFFORT
|
||||
or not isinstance(model, str)
|
||||
or llm_router is None
|
||||
or not _drops_params(body, model, llm_router, team_id)
|
||||
or codex_entry_names_default_reasoning_level(model, llm_router, team_id, client_version)
|
||||
or _supports_no_effort(model, llm_router, team_id)
|
||||
):
|
||||
return body
|
||||
return {**body, "reasoning": {key: value for key, value in reasoning.items() if key != "effort"}}
|
||||
|
||||
|
||||
def _with_wrapped_reasoning(body: Mapping[str, object]) -> Mapping[str, object]:
|
||||
return {
|
||||
**body,
|
||||
"litellm_metadata": {**_json_object(body.get("litellm_metadata")), _ENCRYPTED_CONTENT_AFFINITY_FLAG: True},
|
||||
}
|
||||
|
||||
|
||||
def _with_reasoning_the_targets_can_decrypt(
|
||||
body: Mapping[str, object], model: str, llm_router: "Router", deployments: tuple[Deployment, ...]
|
||||
) -> Mapping[str, object]:
|
||||
try:
|
||||
items: Final = _INPUT_ITEMS.validate_python(body.get("input"))
|
||||
except ValidationError:
|
||||
return body
|
||||
kept: Final = llm_router.registry().decryptable_reasoning(model, items, deployments)
|
||||
if body.get("store") is True:
|
||||
return {**body, "input": list(kept)}
|
||||
resolvable: Final = (_without_unpersisted_reasoning_reference(item) for item in kept)
|
||||
return {**body, "input": [item for item in resolvable if item is not None]}
|
||||
|
||||
|
||||
def _without_unpersisted_reasoning_reference(item: object) -> object | None:
|
||||
if not _is_unpersisted_reasoning_reference(item):
|
||||
return item
|
||||
return ResponsesAPIRequestUtils.readable_reasoning(_JSON_OBJECT.validate_python(item))
|
||||
|
||||
|
||||
def _model_and_provider(deployment: Deployment) -> tuple[str, str] | None:
|
||||
try:
|
||||
model, provider, _, _ = get_llm_provider(
|
||||
model=deployment.litellm_params.model, custom_llm_provider=deployment.litellm_params.custom_llm_provider
|
||||
)
|
||||
except BadRequestError:
|
||||
return None
|
||||
return model, provider
|
||||
|
||||
|
||||
def _is_openai_api(deployment: Deployment) -> bool:
|
||||
resolved: Final = _model_and_provider(deployment)
|
||||
return resolved is not None and resolved[1] in _OPENAI_API_PROVIDERS
|
||||
|
||||
|
||||
def _takes_codex_tools(deployment: Deployment) -> bool:
|
||||
resolved: Final = _model_and_provider(deployment)
|
||||
if resolved is None:
|
||||
return False
|
||||
model, provider = resolved
|
||||
return (
|
||||
provider in _CODEX_TOOL_PROVIDERS
|
||||
or ProviderConfigManager.get_provider_responses_api_config(
|
||||
provider=provider, model=model, api_base=deployment.litellm_params.api_base
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def _with_tools_the_targets_accept(
|
||||
body: Mapping[str, object], deployments: tuple[Deployment, ...], drop_params: bool
|
||||
) -> Mapping[str, object]:
|
||||
if all(_is_openai_api(deployment) for deployment in deployments):
|
||||
return body
|
||||
if all(_takes_codex_tools(deployment) for deployment in deployments):
|
||||
return with_additional_tools_hoisted(body, drop_params=drop_params)
|
||||
return {**body, _CHAT_COMPLETIONS_BRIDGE: True}
|
||||
|
||||
|
||||
def _drops_params_for_client(
|
||||
body: Mapping[str, object], model: str, llm_router: "Router", team_id: str | None, user_agent: str
|
||||
) -> bool:
|
||||
from litellm.proxy.proxy_server import proxy_config
|
||||
|
||||
return _drops_params(body, model, llm_router, team_id) or should_auto_drop_params_for_agentic_cli(
|
||||
user_agent, "drop_params" in body, proxy_config
|
||||
)
|
||||
|
||||
|
||||
def _litellm_body(
|
||||
body: Mapping[str, object], llm_router: "Router | None", team_id: str | None, headers: Mapping[str, str]
|
||||
) -> Mapping[str, object]:
|
||||
wrapped: Final = _with_wrapped_reasoning(
|
||||
_with_provider_default_reasoning(body, llm_router, team_id, _codex_client_version(headers))
|
||||
)
|
||||
model: Final = body.get("model")
|
||||
if not isinstance(model, str) or llm_router is None:
|
||||
return wrapped
|
||||
deployments: Final = _candidate_deployments(model, llm_router, team_id)
|
||||
return _with_tools_the_targets_accept(
|
||||
_with_reasoning_the_targets_can_decrypt(wrapped, model, llm_router, deployments),
|
||||
deployments,
|
||||
_drops_params_for_client(body, model, llm_router, team_id, headers.get("user-agent", "")),
|
||||
)
|
||||
|
||||
|
||||
def _is_unpersisted_reasoning_reference(item: object) -> bool:
|
||||
try:
|
||||
fields: Final = _JSON_OBJECT.validate_python(item)
|
||||
except ValidationError:
|
||||
return False
|
||||
item_id: Final = fields.get("id")
|
||||
encrypted_content: Final = fields.get("encrypted_content")
|
||||
return (
|
||||
fields.get("type") == "reasoning"
|
||||
and isinstance(item_id, str)
|
||||
and bool(item_id)
|
||||
and not (isinstance(encrypted_content, str) and bool(encrypted_content))
|
||||
)
|
||||
|
||||
|
||||
def _rejected_by_chatgpt(item: object, stored: bool) -> bool:
|
||||
return is_other_provider_reasoning_item(item) or (not stored and _is_unpersisted_reasoning_reference(item))
|
||||
|
||||
|
||||
def _input_chatgpt_accepts(body: Mapping[str, object]) -> tuple[object, ...] | None:
|
||||
try:
|
||||
items: Final = _INPUT_ITEMS.validate_python(body.get("input"))
|
||||
except ValidationError:
|
||||
return None
|
||||
stored: Final = body.get("store") is True
|
||||
kept: Final = tuple(item for item in items if not _rejected_by_chatgpt(item, stored))
|
||||
return kept if len(kept) < len(items) else None
|
||||
|
||||
|
||||
def _chatgpt_body(body: Mapping[str, object], client_model: str | None) -> Mapping[str, object] | None:
|
||||
kept_input: Final = _input_chatgpt_accepts(body)
|
||||
restored_model: Final = client_model is not None and body.get("model") == f"{_CHATGPT_PROVIDER}/{client_model}"
|
||||
if kept_input is None and not restored_model:
|
||||
return None
|
||||
return {
|
||||
**body,
|
||||
**({"input": list(kept_input)} if kept_input is not None else {}),
|
||||
**({"model": client_model} if restored_model else {}),
|
||||
}
|
||||
|
||||
|
||||
async def _litellm_catalog_rows(
|
||||
user_api_key_dict: UserAPIKeyAuth, llm_router: "Router | None"
|
||||
) -> tuple[CodexCatalogRow, ...]:
|
||||
from litellm.proxy.proxy_server import codex_catalog_rows_for_key
|
||||
|
||||
if llm_router is None or not _may_call_litellm_responses(user_api_key_dict):
|
||||
return ()
|
||||
try:
|
||||
rows: Final = await codex_catalog_rows_for_key(user_api_key_dict)
|
||||
except Exception as error: # noqa: BLE001 # a failed listing must never fail the ChatGPT answer it extends
|
||||
verbose_proxy_logger.warning(
|
||||
"Codex catalog keeps only ChatGPT's models: listing the key's LiteLLM models raised %s",
|
||||
type(error).__name__,
|
||||
)
|
||||
return ()
|
||||
return tuple(row for row in rows if _routes_to_litellm(row.id, llm_router, user_api_key_dict.team_id))
|
||||
|
||||
|
||||
def _models_etag_caller(user_api_key_dict: UserAPIKeyAuth, gateway_token: str) -> str:
|
||||
return user_api_key_dict.token or hashlib.sha256(gateway_token.encode()).hexdigest()
|
||||
|
||||
|
||||
def _models_etag_cache_key(caller: str, upstream_etag: str) -> str:
|
||||
return f"{_MODELS_ETAG_CACHE_PREFIX}{hashlib.sha256(json.dumps([caller, upstream_etag]).encode()).hexdigest()}"
|
||||
|
||||
|
||||
async def _remembered_models_etag(
|
||||
caller: str, upstream_etag: str, rows: tuple[CodexCatalogRow, ...], cache: _SharedCache
|
||||
) -> str:
|
||||
etag: Final = codex_catalog_etag(upstream_etag, rows) if rows else upstream_etag
|
||||
await cache.async_set_cache(
|
||||
_models_etag_cache_key(caller, upstream_etag), {"etag": etag}, ttl=_MODELS_ETAG_TTL_SECONDS
|
||||
)
|
||||
return etag
|
||||
|
||||
|
||||
async def _cached_models_etag(caller: str, upstream_etag: str, cache: _SharedCache) -> str | None:
|
||||
try:
|
||||
return _CACHED_MODELS_ETAG.validate_python(
|
||||
await cache.async_get_cache(_models_etag_cache_key(caller, upstream_etag))
|
||||
)["etag"]
|
||||
except ValidationError:
|
||||
return None
|
||||
|
||||
|
||||
async def _key_models_etag(
|
||||
upstream_etag: str,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
caller: str,
|
||||
llm_router: "Router | None",
|
||||
cache: _SharedCache,
|
||||
) -> str:
|
||||
cached: Final = await _cached_models_etag(caller, upstream_etag, cache)
|
||||
if cached is not None:
|
||||
return cached
|
||||
rows: Final = await _litellm_catalog_rows(user_api_key_dict, llm_router)
|
||||
return await _remembered_models_etag(caller, upstream_etag, rows, cache)
|
||||
|
||||
|
||||
def _with_etag(response: Response, content: bytes | str, etag: str | None) -> Response:
|
||||
kept_headers: Final = {
|
||||
name: value for name, value in response.headers.items() if name not in _MERGED_CATALOG_DROPPED_HEADERS
|
||||
}
|
||||
return Response(
|
||||
content=content,
|
||||
status_code=response.status_code,
|
||||
headers={**kept_headers, **({_ETAG_HEADER: etag} if etag is not None else {})},
|
||||
media_type="application/json",
|
||||
)
|
||||
|
||||
|
||||
async def _catalog(
|
||||
request: Request,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
caller: str,
|
||||
headers: Mapping[str, str],
|
||||
cache: _SharedCache,
|
||||
) -> Response:
|
||||
from litellm.proxy.proxy_server import llm_router
|
||||
|
||||
upstream: Final = await pass_through_request(
|
||||
request=request,
|
||||
target=f"{CHATGPT_API_BASE}/models",
|
||||
custom_headers=dict(headers),
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
custom_llm_provider=_CHATGPT_PROVIDER,
|
||||
upstream_headers_filter=lambda _: headers,
|
||||
)
|
||||
if upstream.status_code != 200 or isinstance(upstream, StreamingResponse):
|
||||
return upstream
|
||||
upstream_body: Final = bytes(upstream.body)
|
||||
slugs: Final = upstream_codex_slugs(upstream_body)
|
||||
if slugs is None:
|
||||
return upstream
|
||||
account_slugs_key: Final = _account_slugs_cache_key(headers)
|
||||
if account_slugs_key is not None:
|
||||
await _remember_account_slugs(account_slugs_key, slugs, cache)
|
||||
rows: Final = await _litellm_catalog_rows(user_api_key_dict, llm_router)
|
||||
upstream_etag: Final = upstream.headers.get(_ETAG_HEADER)
|
||||
etag: Final = None if upstream_etag is None else await _remembered_models_etag(caller, upstream_etag, rows, cache)
|
||||
if not rows:
|
||||
return upstream
|
||||
merged: Final = await asyncio.to_thread(
|
||||
merged_codex_catalog, upstream_body, rows, client_version=request.query_params.get("client_version")
|
||||
)
|
||||
return _with_etag(upstream, merged if merged is not None else upstream_body, etag)
|
||||
|
||||
|
||||
async def _responses(
|
||||
request: Request,
|
||||
fastapi_response: Response,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
caller: str,
|
||||
headers: Mapping[str, str],
|
||||
cache: _SharedCache,
|
||||
) -> object:
|
||||
from litellm.proxy.proxy_server import llm_router
|
||||
|
||||
body: Final = _JSON_OBJECT.validate_python(await read_request_body(request))
|
||||
if await _serves_through_litellm(body.get("model"), llm_router, user_api_key_dict, caller, headers, cache):
|
||||
litellm_body: Final = _litellm_body(body, llm_router, user_api_key_dict.team_id, headers)
|
||||
await check_tools_allowlist(
|
||||
request_body=dict(litellm_body),
|
||||
valid_token=user_api_key_dict,
|
||||
team_object=None,
|
||||
route=_LITELLM_RESPONSES_PATH,
|
||||
)
|
||||
return await responses_api(
|
||||
_as_litellm_responses_request(request, json.dumps(litellm_body).encode(), litellm_body),
|
||||
fastapi_response,
|
||||
user_api_key_dict.model_copy(update={"request_route": _LITELLM_RESPONSES_PATH}),
|
||||
)
|
||||
chatgpt_body: Final = _chatgpt_body(body, get_client_requested_model(request))
|
||||
upstream: Final = await pass_through_request(
|
||||
request=request,
|
||||
target=f"{CHATGPT_API_BASE}/responses",
|
||||
custom_headers=dict(headers),
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
custom_body=dict(chatgpt_body) if chatgpt_body is not None else None,
|
||||
custom_llm_provider=_CHATGPT_PROVIDER,
|
||||
upstream_headers_filter=lambda _: headers,
|
||||
)
|
||||
upstream_models_etag: Final = upstream.headers.get(_MODELS_ETAG_HEADER)
|
||||
if upstream_models_etag is not None:
|
||||
upstream.headers[_MODELS_ETAG_HEADER] = await _key_models_etag(
|
||||
upstream_models_etag, user_api_key_dict, caller, llm_router, cache
|
||||
)
|
||||
return upstream
|
||||
|
||||
|
||||
@router.get("/backend-api/codex/{endpoint}", tags=["ChatGPT Pass-through", "pass-through"], response_model=None)
|
||||
@router.post(
|
||||
"/backend-api/codex/{endpoint}",
|
||||
tags=["ChatGPT Pass-through", "pass-through"],
|
||||
response_model=None,
|
||||
openapi_extra={
|
||||
"requestBody": {
|
||||
"required": True,
|
||||
|
|
@ -41,7 +649,7 @@ _HEADERS: Final = frozenset(
|
|||
},
|
||||
responses={
|
||||
200: {
|
||||
"description": "Unmodified Codex response or server-sent events",
|
||||
"description": "Codex response, the model catalog with the key's LiteLLM models, or server-sent events",
|
||||
"content": {
|
||||
"application/json": {"schema": {"type": "object", "additionalProperties": True}},
|
||||
"text/event-stream": {"schema": {"type": "string"}},
|
||||
|
|
@ -52,9 +660,12 @@ _HEADERS: Final = frozenset(
|
|||
async def codex_passthrough_route(
|
||||
endpoint: Literal["models", "responses"],
|
||||
request: Request,
|
||||
fastapi_response: Response,
|
||||
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
|
||||
) -> Response:
|
||||
"""Forward a client's ChatGPT subscription with separate LiteLLM authentication."""
|
||||
cache: Annotated[_SharedCache, Depends(codex_shared_cache)],
|
||||
) -> object:
|
||||
"""Forward a client's ChatGPT subscription with separate LiteLLM authentication, listing and serving the
|
||||
key's other chat models through LiteLLM."""
|
||||
expected_method: Final = "GET" if endpoint == "models" else "POST"
|
||||
if request.method != expected_method:
|
||||
raise HTTPException(status_code=405, detail="Method not allowed", headers={"Allow": expected_method})
|
||||
|
|
@ -77,11 +688,7 @@ async def codex_passthrough_route(
|
|||
raise HTTPException(status_code=401, detail="ChatGPT and LiteLLM credentials must be separate")
|
||||
|
||||
headers: Final = MappingProxyType({name: value for name, value in request.headers.items() if name in _HEADERS})
|
||||
return await pass_through_request(
|
||||
request=request,
|
||||
target=f"{CHATGPT_API_BASE}/{endpoint}",
|
||||
custom_headers=dict(headers),
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
custom_llm_provider="chatgpt",
|
||||
upstream_headers_filter=lambda _: headers,
|
||||
)
|
||||
caller: Final = _models_etag_caller(user_api_key_dict, gateway_token)
|
||||
if endpoint == "models":
|
||||
return await _catalog(request, user_api_key_dict, caller, headers, cache)
|
||||
return await _responses(request, fastapi_response, user_api_key_dict, caller, headers, cache)
|
||||
|
|
|
|||
|
|
@ -232,6 +232,7 @@ if TYPE_CHECKING:
|
|||
|
||||
from litellm.integrations.opentelemetry import OpenTelemetry
|
||||
from litellm.proxy.health_check_utils.shared_health_check_manager import SharedHealthCheckManager
|
||||
from litellm.types.proxy.model_listing import ModelInfoResponse
|
||||
|
||||
Span = _Span | Any
|
||||
else:
|
||||
|
|
@ -518,7 +519,7 @@ from litellm.proxy.common_utils.auth_cache_invalidation_pubsub import (
|
|||
AuthCacheInvalidationSubscriber,
|
||||
)
|
||||
from litellm.proxy.common_utils.callback_utils import initialize_callbacks_on_proxy
|
||||
from litellm.proxy.common_utils.codex_model_catalog import codex_model_list_body
|
||||
from litellm.proxy.common_utils.codex_model_catalog import CodexCatalogRow, codex_catalog_rows, codex_model_list_body
|
||||
from litellm.proxy.common_utils.config_includes import resolve_include_file_path, resolve_includes
|
||||
from litellm.proxy.common_utils.config_sync_pubsub import ConfigSyncSubscriber
|
||||
from litellm.proxy.common_utils.credential_hydration import decrypted_or_stored
|
||||
|
|
@ -557,6 +558,7 @@ from litellm.proxy.common_utils.http_parsing_utils import (
|
|||
from litellm.proxy.common_utils.load_config_utils import get_config_from_bucket
|
||||
from litellm.proxy.common_utils.model_deprecation import collect_model_deprecations
|
||||
from litellm.proxy.common_utils.model_listing_utils import (
|
||||
CallerAliases,
|
||||
ClaudeCodeRoutingNames,
|
||||
DeployedModelGroups,
|
||||
TeamModelNameTranslator,
|
||||
|
|
@ -12641,6 +12643,116 @@ async def _deployment_hidden_by_listing_callbacks(deployment: Deployment, user_a
|
|||
return listed_name in await _names_hidden_by_listing_callbacks(user_api_key_dict, (listed_name,))
|
||||
|
||||
|
||||
async def _caller_aliases(user_api_key_dict: UserAPIKeyAuth, team_id: str | None) -> CallerAliases:
|
||||
caller_router_settings: Final = await proxy_config.get_hierarchical_router_settings(
|
||||
user_api_key_dict, prisma_client, proxy_logging_obj
|
||||
)
|
||||
return caller_alias_maps(
|
||||
user_api_key_dict.aliases,
|
||||
user_api_key_dict.team_model_aliases,
|
||||
user_api_key_dict.team_id,
|
||||
team_id,
|
||||
caller_router_settings.get("model_group_alias") if isinstance(caller_router_settings, Mapping) else None,
|
||||
)
|
||||
|
||||
|
||||
async def _listing_hidden_names(healthy_only: bool | None) -> frozenset[str]:
|
||||
blocked_names: Final = llm_router.health().fully_blocked_model_names() if llm_router is not None else set[str]()
|
||||
unhealthy_names: Final = await get_hidden_unhealthy_model_names(
|
||||
healthy_only=healthy_only,
|
||||
general_settings=general_settings_view(),
|
||||
llm_router=llm_router,
|
||||
)
|
||||
return frozenset(blocked_names | unhealthy_names)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _KeyModelListing:
|
||||
rows: "tuple[ModelInfoResponse, ...]"
|
||||
entries: tuple[tuple[str, str], ...]
|
||||
labels: tuple[tuple[str, str], ...]
|
||||
|
||||
|
||||
def _listed_model_info(
|
||||
response_id: str, lookup_id: str, include_metadata: bool, fallback_type: str | None
|
||||
) -> "ModelInfoResponse":
|
||||
from litellm.proxy.utils import create_model_info_response
|
||||
|
||||
model_info: Final = create_model_info_response(
|
||||
model_id=lookup_id,
|
||||
provider="openai",
|
||||
include_metadata=include_metadata,
|
||||
fallback_type=fallback_type,
|
||||
llm_router=llm_router,
|
||||
)
|
||||
model_info["id"] = response_id
|
||||
return model_info
|
||||
|
||||
|
||||
async def _key_model_listing(
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
caller_aliases: CallerAliases,
|
||||
hidden_names: frozenset[str],
|
||||
*,
|
||||
team_id: str | None = None,
|
||||
include_model_access_groups: bool = False,
|
||||
only_model_access_groups: bool = False,
|
||||
include_wildcard_routes: bool = False,
|
||||
include_metadata: bool = False,
|
||||
fallback_type: str | None = None,
|
||||
offered_model_names: Sequence[str] = (),
|
||||
) -> _KeyModelListing:
|
||||
from litellm.proxy.utils import get_available_models_for_user
|
||||
|
||||
available: Final = await get_available_models_for_user(
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
llm_router=llm_router,
|
||||
general_settings=general_settings,
|
||||
user_model=user_model,
|
||||
prisma_client=prisma_client,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
team_id=team_id,
|
||||
include_model_access_groups=include_model_access_groups,
|
||||
only_model_access_groups=only_model_access_groups,
|
||||
return_wildcard_routes=include_wildcard_routes,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
)
|
||||
names: Final = (*available, *(name for name in offered_model_names if name not in available))
|
||||
excluded: Final = hidden_names | undiscoverable_model_names(
|
||||
names, llm_router, user_api_key_dict, team_id or user_api_key_dict.team_id
|
||||
)
|
||||
listed_entries: Final = await _entries_kept_by_listing_callbacks(
|
||||
TeamModelNameTranslator.listing_entries(
|
||||
[name for name in names if name not in excluded], llm_router, general_settings_view()
|
||||
),
|
||||
user_api_key_dict,
|
||||
)
|
||||
entries: Final = alias_listing_entries(
|
||||
listed_entries,
|
||||
caller_aliases,
|
||||
_describable_model_groups(listed_entries, hidden_names, user_api_key_dict, team_id),
|
||||
)
|
||||
return _KeyModelListing(
|
||||
rows=tuple(
|
||||
_listed_model_info(response_id, lookup_id, include_metadata, fallback_type)
|
||||
for response_id, lookup_id in entries
|
||||
),
|
||||
entries=entries,
|
||||
labels=label_entries(listed_entries, entries),
|
||||
)
|
||||
|
||||
|
||||
async def codex_catalog_rows_for_key(user_api_key_dict: UserAPIKeyAuth) -> tuple[CodexCatalogRow, ...]:
|
||||
listing: Final = await _key_model_listing(
|
||||
user_api_key_dict,
|
||||
await _caller_aliases(user_api_key_dict, None),
|
||||
await _listing_hidden_names(None),
|
||||
)
|
||||
return await asyncio.to_thread(
|
||||
codex_catalog_rows, listing.rows, listing.entries, llm_router, user_api_key_dict.team_id, listing.labels
|
||||
)
|
||||
|
||||
|
||||
@router.get("/v1/models", dependencies=[Depends(user_api_key_auth)], tags=["model management"])
|
||||
@router.get(
|
||||
"/models", dependencies=[Depends(user_api_key_auth)], tags=["model management"]
|
||||
|
|
@ -12708,10 +12820,7 @@ async def model_list(
|
|||
from litellm.proxy.management_endpoints.common_utils import (
|
||||
user_has_admin_privileges,
|
||||
)
|
||||
from litellm.proxy.utils import (
|
||||
create_model_info_response,
|
||||
get_available_models_for_user,
|
||||
)
|
||||
from litellm.proxy.utils import create_model_info_response
|
||||
from litellm.types.proxy.model_listing import ModelInfoResponse
|
||||
|
||||
http_request: Final = cast(Request | None, request) # cast-ok: in-process callers pass no request
|
||||
|
|
@ -12719,16 +12828,7 @@ async def model_list(
|
|||
http_request is not None and http_request.headers.get("anthropic-version") is not None
|
||||
)
|
||||
client_headers: Final[Mapping[str, str]] = http_request.headers if http_request is not None else _EMPTY_HEADERS
|
||||
caller_router_settings: Final = await proxy_config.get_hierarchical_router_settings(
|
||||
user_api_key_dict, prisma_client, proxy_logging_obj
|
||||
)
|
||||
caller_aliases: Final = caller_alias_maps(
|
||||
user_api_key_dict.aliases,
|
||||
user_api_key_dict.team_model_aliases,
|
||||
user_api_key_dict.team_id,
|
||||
team_id,
|
||||
caller_router_settings.get("model_group_alias") if isinstance(caller_router_settings, Mapping) else None,
|
||||
)
|
||||
caller_aliases: Final = await _caller_aliases(user_api_key_dict, team_id)
|
||||
routing_names: Final = ClaudeCodeRoutingNames(
|
||||
llm_router,
|
||||
team_id or user_api_key_dict.team_id,
|
||||
|
|
@ -12754,18 +12854,7 @@ async def model_list(
|
|||
proxy_logging_obj=proxy_logging_obj,
|
||||
)
|
||||
|
||||
# Compute once — used in both branches below to hide paused models from the listing.
|
||||
blocked_names: Final = llm_router.health().fully_blocked_model_names() if llm_router is not None else set[str]()
|
||||
|
||||
# Opt-in: also hide models whose deployments are all unhealthy per background
|
||||
# health checks. Empty when health state is unavailable or stale (fail open).
|
||||
unhealthy_names: Final = await get_hidden_unhealthy_model_names(
|
||||
healthy_only=healthy_only,
|
||||
general_settings=settings,
|
||||
llm_router=llm_router,
|
||||
)
|
||||
|
||||
hidden_names: Final = blocked_names | unhealthy_names
|
||||
hidden_names: Final = await _listing_hidden_names(healthy_only)
|
||||
|
||||
from litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints import typesafe_allowlist_model_names
|
||||
|
||||
|
|
@ -12863,58 +12952,24 @@ async def model_list(
|
|||
object="list",
|
||||
)
|
||||
|
||||
# Otherwise, use the normal behavior (current implementation)
|
||||
# Get available models for the user
|
||||
available_models: Final = await get_available_models_for_user(
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
llm_router=llm_router,
|
||||
general_settings=general_settings,
|
||||
user_model=user_model,
|
||||
prisma_client=prisma_client,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
team_id=team_id,
|
||||
include_model_access_groups=include_model_access_groups or False,
|
||||
only_model_access_groups=only_model_access_groups or False,
|
||||
return_wildcard_routes=include_wildcard_routes,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
)
|
||||
offers_typesafe_allowlist_names: Final = (
|
||||
asks_for_allowlist_names and team_id is None and user_api_key_has_admin_view(user_api_key_dict)
|
||||
)
|
||||
typesafe_names_to_offer: Final = typesafe_allowlist_model_names() if offers_typesafe_allowlist_names else ()
|
||||
all_models = [*available_models, *(name for name in typesafe_names_to_offer if name not in available_models)]
|
||||
|
||||
undiscoverable_names: Final = undiscoverable_model_names(
|
||||
all_models, llm_router, user_api_key_dict, team_id or user_api_key_dict.team_id
|
||||
)
|
||||
if hidden_names or undiscoverable_names:
|
||||
all_models = [m for m in all_models if m not in hidden_names and m not in undiscoverable_names]
|
||||
|
||||
# Surface the public team name by default; legacy internal keys via flag.
|
||||
# The internal routing key drives the metadata/fallback lookup, while the
|
||||
# public name is what the client sees as the model id.
|
||||
model_data = []
|
||||
listed_entries: Final = await _entries_kept_by_listing_callbacks(
|
||||
TeamModelNameTranslator.listing_entries(all_models, llm_router, settings), user_api_key_dict
|
||||
)
|
||||
entries: Final = alias_listing_entries(
|
||||
listed_entries,
|
||||
key_listing: Final = await _key_model_listing(
|
||||
user_api_key_dict,
|
||||
caller_aliases,
|
||||
_describable_model_groups(listed_entries, hidden_names, user_api_key_dict, team_id),
|
||||
hidden_names,
|
||||
team_id=team_id,
|
||||
include_model_access_groups=include_model_access_groups or False,
|
||||
only_model_access_groups=only_model_access_groups or False,
|
||||
include_wildcard_routes=include_wildcard_routes,
|
||||
include_metadata=include_metadata or False,
|
||||
fallback_type=fallback_type,
|
||||
offered_model_names=typesafe_allowlist_model_names() if offers_typesafe_allowlist_names else (),
|
||||
)
|
||||
labels: Final = label_entries(listed_entries, entries)
|
||||
for response_id, lookup_id in entries:
|
||||
model_info = create_model_info_response(
|
||||
model_id=lookup_id,
|
||||
provider="openai",
|
||||
include_metadata=include_metadata or False,
|
||||
fallback_type=fallback_type,
|
||||
llm_router=llm_router,
|
||||
)
|
||||
model_info["id"] = response_id
|
||||
model_data.append(model_info)
|
||||
|
||||
listing: Final = cast(Sequence[ModelInfoResponse], model_data) # cast-ok: rows built above
|
||||
listing: Final = key_listing.rows
|
||||
entries: Final = key_listing.entries
|
||||
labels: Final = key_listing.labels
|
||||
if client_version is not None:
|
||||
return Response(
|
||||
content=await asyncio.to_thread(
|
||||
|
|
@ -12941,7 +12996,7 @@ async def model_list(
|
|||
)
|
||||
|
||||
return dict(
|
||||
data=model_data,
|
||||
data=list(listing),
|
||||
object="list",
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,21 +1,20 @@
|
|||
import json
|
||||
from collections.abc import Iterator, Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from itertools import groupby
|
||||
from itertools import chain, groupby
|
||||
from types import MappingProxyType
|
||||
from typing import Final, cast # noqa: TID251 # validating the openai tool union strips vendor keys from raw tools
|
||||
|
||||
from pydantic import ValidationError
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
from typing_extensions import TypeIs # noqa: TID251 # narrows a client input item before its type is read
|
||||
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm.types.llms.base import LiteLLMBaseModel
|
||||
from litellm.types.llms.openai import ALL_RESPONSES_API_TOOL_PARAMS, ResponseInputParam
|
||||
|
||||
ADDITIONAL_TOOLS_INPUT_ITEM_TYPE: Final = "additional_tools"
|
||||
|
||||
|
||||
class _InputItemType(LiteLLMBaseModel):
|
||||
type: str = ""
|
||||
_NAMESPACE_TOOL_TYPE: Final = "namespace"
|
||||
_ITEMS: Final = TypeAdapter(tuple[object, ...])
|
||||
|
||||
|
||||
class _ToolList(LiteLLMBaseModel):
|
||||
|
|
@ -29,11 +28,14 @@ class HoistedAdditionalTools:
|
|||
hoisted: tuple[ALL_RESPONSES_API_TOOL_PARAMS, ...]
|
||||
|
||||
|
||||
def _is_an_input_item(
|
||||
item: object,
|
||||
) -> TypeIs[Mapping[str, object]]: # guard-ok: auth scans every list input, and a pydantic error per item is slow
|
||||
return isinstance(item, Mapping)
|
||||
|
||||
|
||||
def _is_additional_tools_item(item: object) -> bool:
|
||||
try:
|
||||
return _InputItemType.model_validate(item).type == ADDITIONAL_TOOLS_INPUT_ITEM_TYPE
|
||||
except ValidationError:
|
||||
return False
|
||||
return _is_an_input_item(item) and item.get("type") == ADDITIONAL_TOOLS_INPUT_ITEM_TYPE
|
||||
|
||||
|
||||
def _tool_list_of(value: object) -> tuple[Mapping[str, object], ...]:
|
||||
|
|
@ -61,7 +63,7 @@ def _members_of_copies(copies: Sequence[Mapping[str, object]]) -> Iterator[Mappi
|
|||
|
||||
def _merged(copies: Sequence[Mapping[str, object]]) -> Mapping[str, object]:
|
||||
newest: Final = copies[-1]
|
||||
if newest.get("type") != "namespace" or len(copies) == 1:
|
||||
if newest.get("type") != _NAMESPACE_TOOL_TYPE or len(copies) == 1:
|
||||
return newest
|
||||
members: Final = tuple(_members_of_copies(copies))
|
||||
return {**newest, "tools": list(_unique_tools(members))} if members else newest
|
||||
|
|
@ -86,6 +88,11 @@ def _unique_tools(tools: Sequence[Mapping[str, object]]) -> tuple[Mapping[str, o
|
|||
return tuple(_merged(copies_by_identity[identity]) for identity in first_seen_order)
|
||||
|
||||
|
||||
def additional_tools_in_input(input: object) -> tuple[ALL_RESPONSES_API_TOOL_PARAMS, ...]:
|
||||
items: Final = _ITEMS.validate_python(input) if isinstance(input, list) else ()
|
||||
return tuple(chain.from_iterable(_tools_of_item(item) for item in items if _is_additional_tools_item(item)))
|
||||
|
||||
|
||||
def hoist_additional_tools(
|
||||
input: str | ResponseInputParam,
|
||||
tools: Sequence[ALL_RESPONSES_API_TOOL_PARAMS] | None,
|
||||
|
|
@ -112,3 +119,17 @@ def hoist_additional_tools(
|
|||
for tool in _unique_tools((*existing, *hoisted))
|
||||
)
|
||||
return HoistedAdditionalTools(input=remaining_input, tools=unique, hoisted=hoisted)
|
||||
|
||||
|
||||
def with_additional_tools_hoisted(request: Mapping[str, object], *, drop_params: bool) -> Mapping[str, object]:
|
||||
request_input: Final = request.get("input")
|
||||
if not isinstance(request_input, list):
|
||||
return request
|
||||
input_items: Final = cast(ResponseInputParam, request_input) # cast-ok: forwarded as sent, as inference does
|
||||
top_level_tools: Final = cast( # cast-ok: tools are forwarded as sent, as inference does
|
||||
"tuple[ALL_RESPONSES_API_TOOL_PARAMS, ...]", _ITEMS.validate_python(request.get("tools") or ())
|
||||
)
|
||||
hoisted: Final = hoist_additional_tools(input_items, top_level_tools, drop_params=drop_params)
|
||||
if not hoisted.hoisted:
|
||||
return request
|
||||
return {**request, "input": hoisted.input, "tools": list(hoisted.tools)}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ from litellm.litellm_core_utils.prompt_templates.common_utils import (
|
|||
)
|
||||
from litellm.litellm_core_utils.tool_fields import DropPolicy
|
||||
from litellm.llms.base_llm.files.batch_records import responses_batch_body_to_chat_body
|
||||
from litellm.responses.additional_tools import hoist_additional_tools
|
||||
from litellm.responses.additional_tools import with_additional_tools_hoisted
|
||||
from litellm.responses.file_search.emulated_handler import replace_file_search_tools
|
||||
from litellm.responses.litellm_completion_transformation.compaction_items import holds_compaction_summary
|
||||
from litellm.responses.litellm_completion_transformation.transformation import (
|
||||
|
|
@ -20,7 +20,7 @@ from litellm.responses.litellm_completion_transformation.transformation import (
|
|||
LiteLLMCompletionResponsesConfig,
|
||||
)
|
||||
from litellm.responses.main import previous_response_client_ids
|
||||
from litellm.types.llms.openai import ALL_RESPONSES_API_TOOL_PARAMS, ResponseInputParam
|
||||
from litellm.types.llms.openai import ResponseInputParam
|
||||
from litellm.utils import ProviderConfigManager
|
||||
|
||||
_COUNTED_CHAT_PARAMS: Final = (
|
||||
|
|
@ -114,20 +114,6 @@ def with_provider_file_ids(
|
|||
}
|
||||
|
||||
|
||||
def _with_hoisted_additional_tools(responses_request: Mapping[str, object], drop_params: bool) -> Mapping[str, object]:
|
||||
responses_input: Final = responses_request.get("input")
|
||||
if not isinstance(responses_input, list):
|
||||
return responses_request
|
||||
input_items: Final = cast(ResponseInputParam, responses_input) # cast-ok: forwarded as sent, as inference does
|
||||
top_level_tools: Final = cast( # cast-ok: tools are forwarded as sent, as inference does
|
||||
"tuple[ALL_RESPONSES_API_TOOL_PARAMS, ...]", _ITEMS.validate_python(responses_request.get("tools") or ())
|
||||
)
|
||||
hoisted: Final = hoist_additional_tools(input_items, top_level_tools, drop_params=drop_params)
|
||||
if not hoisted.hoisted:
|
||||
return responses_request
|
||||
return {**responses_request, "input": hoisted.input, "tools": list(hoisted.tools)}
|
||||
|
||||
|
||||
def _caller_input(responses_request: Mapping[str, object]) -> ResponseInputParam | None:
|
||||
responses_input: Final = responses_request.get("input")
|
||||
return (
|
||||
|
|
@ -205,7 +191,9 @@ async def chat_count_request(
|
|||
chat_body: Final = await _with_previous_turns(
|
||||
responses_batch_body_to_chat_body(
|
||||
_with_compaction_as_summary_text(
|
||||
_with_emulated_file_search(_with_hoisted_additional_tools(responses_request, policy.drop_params))
|
||||
_with_emulated_file_search(
|
||||
with_additional_tools_hoisted(responses_request, drop_params=policy.drop_params)
|
||||
)
|
||||
),
|
||||
custom_llm_provider,
|
||||
policy.drop_params,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ from typing import Final
|
|||
from pydantic import BaseModel, TypeAdapter, ValidationError
|
||||
|
||||
REASONING_ITEM_ID_PREFIX: Final = "rs_"
|
||||
WRAPPED_ENCRYPTED_CONTENT_PREFIX: Final = "litellm_enc:"
|
||||
_JSON_LIST: Final = TypeAdapter(list[object])
|
||||
_JSON_OBJECT: Final = TypeAdapter(dict[str, object])
|
||||
|
||||
|
|
@ -58,16 +59,35 @@ def is_minted_reasoning_item_id(item_id: object) -> bool:
|
|||
return parsed.version == 4 and str(parsed) == suffix
|
||||
|
||||
|
||||
def is_litellm_minted_reasoning_item(item: object) -> bool:
|
||||
def _reasoning_fields(item: object) -> Mapping[str, object] | None:
|
||||
try:
|
||||
fields: Final = _JSON_OBJECT.validate_python(
|
||||
item.model_dump(exclude_none=True) if isinstance(item, BaseModel) else item
|
||||
)
|
||||
except ValidationError:
|
||||
return False
|
||||
if fields.get("type") != "reasoning":
|
||||
return False
|
||||
return None
|
||||
return fields if fields.get("type") == "reasoning" else None
|
||||
|
||||
|
||||
def _is_minted(fields: Mapping[str, object]) -> bool:
|
||||
return (
|
||||
is_minted_reasoning_item_id(fields.get("id"))
|
||||
or decode_thinking_blocks(fields.get("encrypted_content")) is not None
|
||||
)
|
||||
|
||||
|
||||
def is_litellm_minted_reasoning_item(item: object) -> bool:
|
||||
fields: Final = _reasoning_fields(item)
|
||||
return fields is not None and _is_minted(fields)
|
||||
|
||||
|
||||
def is_other_provider_reasoning_item(item: object) -> bool:
|
||||
fields: Final = _reasoning_fields(item)
|
||||
if fields is None:
|
||||
return False
|
||||
encrypted_content: Final = fields.get("encrypted_content")
|
||||
return (
|
||||
_is_minted(fields)
|
||||
or bool(fields.get("content"))
|
||||
or (isinstance(encrypted_content, str) and encrypted_content.startswith(WRAPPED_ENCRYPTED_CONTENT_PREFIX))
|
||||
)
|
||||
|
|
|
|||
|
|
@ -84,6 +84,7 @@ from litellm.responses.litellm_completion_transformation.session_handler import
|
|||
ResponsesSessionHandler,
|
||||
StoredTurns,
|
||||
)
|
||||
from litellm.responses.utils import ResponsesAPIRequestUtils
|
||||
from litellm.types.llms.anthropic import AnthropicToolSearchToolRegex
|
||||
from litellm.types.llms.base import CachedTokensDetails
|
||||
from litellm.types.llms.openai import (
|
||||
|
|
@ -2517,7 +2518,12 @@ class LiteLLMCompletionResponsesConfig:
|
|||
Returns None for anything this deployment did not write, so a genuinely
|
||||
opaque blob is still skipped rather than forwarded as garbage.
|
||||
"""
|
||||
decoded: Final = decode_thinking_blocks(input_item.get("encrypted_content"))
|
||||
encrypted_content: Final = input_item.get("encrypted_content")
|
||||
decoded: Final = decode_thinking_blocks(
|
||||
ResponsesAPIRequestUtils.unwrap_encrypted_content_with_model_id(encrypted_content)[1]
|
||||
if isinstance(encrypted_content, str)
|
||||
else encrypted_content
|
||||
)
|
||||
if decoded is None:
|
||||
return None
|
||||
return tuple(
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ from litellm.types.utils import (
|
|||
_PROVIDER_SPECIFIC_HEADER_ADAPTER: Final[
|
||||
TypeAdapter[ProviderSpecificHeader | Sequence[ProviderSpecificHeader] | None]
|
||||
] = TypeAdapter(ProviderSpecificHeader | Sequence[ProviderSpecificHeader] | None)
|
||||
_REASONING_FIELDS_ONLY_ITS_ORIGIN_RESOLVES: Final = frozenset({"encrypted_content", "id", "status"})
|
||||
|
||||
|
||||
class _UsageExtraFields(ExtraItemsTypedDict, total=False, extra_items=object):
|
||||
|
|
@ -678,7 +679,7 @@ class ResponsesAPIRequestUtils:
|
|||
return
|
||||
items: Final = cast(list[object], request_input) # cast-ok: untyped client json
|
||||
stripped: Final = tuple(
|
||||
ResponsesAPIRequestUtils._without_encrypted_reasoning(reasoning)
|
||||
ResponsesAPIRequestUtils.readable_reasoning(reasoning)
|
||||
if (reasoning := ResponsesAPIRequestUtils._encrypted_reasoning_item(item)) is not None
|
||||
and (should_strip is None or should_strip(reasoning))
|
||||
else item
|
||||
|
|
@ -696,14 +697,14 @@ class ResponsesAPIRequestUtils:
|
|||
return reasoning
|
||||
|
||||
@staticmethod
|
||||
def _without_encrypted_reasoning(reasoning: Mapping[str, object]) -> object | None:
|
||||
def readable_reasoning(reasoning: Mapping[str, object]) -> Mapping[str, object] | None:
|
||||
readable: Final = any(
|
||||
ResponsesAPIRequestUtils._has_readable_text(reasoning.get(key)) for key in ("summary", "content")
|
||||
)
|
||||
if not readable:
|
||||
return None
|
||||
kept: Final[dict[str, object]] = { # mutable-ok: request item rebuilt without the undecryptable keys
|
||||
key: value for key, value in reasoning.items() if key not in ("encrypted_content", "id")
|
||||
key: value for key, value in reasoning.items() if key not in _REASONING_FIELDS_ONLY_ITS_ORIGIN_RESOLVES
|
||||
}
|
||||
return kept
|
||||
|
||||
|
|
|
|||
|
|
@ -155,6 +155,7 @@ GroupField: TypeAlias = Literal[
|
|||
"display_name",
|
||||
"mode",
|
||||
"service_tiers",
|
||||
"pinned_reasoning_efforts",
|
||||
"max_input_tokens",
|
||||
"token_limits",
|
||||
"listing_info",
|
||||
|
|
@ -851,7 +852,7 @@ class RouterRegistry:
|
|||
self,
|
||||
name: str,
|
||||
*,
|
||||
field: Literal["service_tiers"],
|
||||
field: Literal["service_tiers", "pinned_reasoning_efforts"],
|
||||
include_hidden: bool = False,
|
||||
team_id: str | None = None,
|
||||
) -> tuple[object, ...]: ...
|
||||
|
|
@ -903,6 +904,7 @@ class RouterRegistry:
|
|||
(were ``get_configured_display_name``, ``get_configured_mode``,
|
||||
``get_configured_token_limits``, ``get_model_listing_info``)
|
||||
- ``service_tiers``: (was ``get_configured_service_tiers``)
|
||||
- ``pinned_reasoning_efforts``: the ``reasoning_effort`` each routable deployment pins
|
||||
- ``max_input_tokens``: each routable deployment's own ``model_info.max_input_tokens``
|
||||
"""
|
||||
r: Final = self._router
|
||||
|
|
@ -916,6 +918,8 @@ class RouterRegistry:
|
|||
return r._core_get_configured_mode(name)
|
||||
if field == "service_tiers":
|
||||
return r._core_get_configured_service_tiers(name, team_id=team_id)
|
||||
if field == "pinned_reasoning_efforts":
|
||||
return r._core_get_pinned_reasoning_efforts(name, team_id=team_id)
|
||||
if field == "max_input_tokens":
|
||||
return r._core_get_configured_max_input_tokens(name, team_id=team_id)
|
||||
if field == "token_limits":
|
||||
|
|
@ -1035,6 +1039,34 @@ class RouterRegistry:
|
|||
return r._core_prompt_caching_pin_cache
|
||||
raise ValueError(f"unknown plugin kind {kind!r}")
|
||||
|
||||
def decryptable_reasoning(
|
||||
self, model_group: str, request_input: Sequence[object], targets: "Sequence[Deployment]"
|
||||
) -> tuple[object, ...]:
|
||||
"""
|
||||
``request_input`` without the encrypted reasoning that ``targets``, the deployments a
|
||||
``model_group`` request may go to, cannot decrypt, keeping each item's readable summary.
|
||||
When encrypted content affinity is on for the group, the router sends the request to the
|
||||
reasoning's origin, so reasoning one target can decrypt stays; otherwise every target must
|
||||
decrypt it (was reading ``optional_callbacks`` for ``EncryptedContentAffinityCheck``).
|
||||
"""
|
||||
from litellm.router_utils.pre_call_checks.encrypted_content_affinity_check import (
|
||||
EncryptedContentAffinityCheck,
|
||||
)
|
||||
|
||||
r: Final = self._router
|
||||
target_dicts: Final = tuple(target.model_dump(exclude_none=True) for target in targets)
|
||||
pinned: Final = any(
|
||||
isinstance(callback, EncryptedContentAffinityCheck) and callback.is_enabled_for_model_group(model_group)
|
||||
for callback in r.optional_callbacks or ()
|
||||
)
|
||||
target_groups: Final = (target_dicts,) if pinned else tuple((target,) for target in target_dicts)
|
||||
kept: Final = list(request_input)
|
||||
for group in target_groups:
|
||||
EncryptedContentAffinityCheck.strip_reasoning_the_targets_cannot_decrypt(
|
||||
r._core_ctx, kept, None, group, unmarked_origin=None
|
||||
)
|
||||
return tuple(kept)
|
||||
|
||||
|
||||
# Settings a caller may read. Assigning one from outside the router goes through apply(ChangeSet(assign=...)).
|
||||
SETTING_FIELDS: Final[tuple[str, ...]] = (
|
||||
|
|
|
|||
|
|
@ -12903,6 +12903,20 @@ class PythonCore(DeprecatedRouterMembers):
|
|||
for deployment in self._routable_deployments(model_name, team_id)
|
||||
)
|
||||
|
||||
def _core_get_pinned_reasoning_efforts(self, model_name: str, team_id: str | None = None) -> tuple[object, ...]:
|
||||
"""
|
||||
Return the litellm_params.reasoning_effort each deployment a request
|
||||
from team_id to model_name can route to (see _routable_deployments)
|
||||
pins, unvalidated and in model_list order, None for a deployment that
|
||||
pins none; the caller decides how the deployments combine.
|
||||
|
||||
Returns an empty tuple for wildcard-expanded or unknown names.
|
||||
"""
|
||||
return tuple(
|
||||
_LITELLM_PARAMS_ADAPTER.validate_python(deployment["litellm_params"]).get("reasoning_effort")
|
||||
for deployment in self._routable_deployments(model_name, team_id)
|
||||
)
|
||||
|
||||
def _core_get_configured_max_input_tokens(
|
||||
self, model_name: str, team_id: str | None = None
|
||||
) -> tuple[int | None, ...]:
|
||||
|
|
|
|||
|
|
@ -148,7 +148,7 @@ class EncryptedContentAffinityCheck(CustomLogger):
|
|||
|
||||
return any("encrypted_content_affinity" in checks for checks in model_group_affinity_config.values())
|
||||
|
||||
def _is_enabled_for_model_group(self, model_group: str) -> bool:
|
||||
def is_enabled_for_model_group(self, model_group: str) -> bool:
|
||||
group_checks: Final = self.model_group_affinity_config.get(model_group)
|
||||
return self.enable_global_affinity or (
|
||||
group_checks is not None and "encrypted_content_affinity" in group_checks
|
||||
|
|
@ -530,7 +530,7 @@ class EncryptedContentAffinityCheck(CustomLogger):
|
|||
"""
|
||||
request_kwargs = request_kwargs or {}
|
||||
typed_healthy_deployments: Final = cast(list[dict[str, object]], healthy_deployments)
|
||||
if not self._is_enabled_for_model_group(model):
|
||||
if not self.is_enabled_for_model_group(model):
|
||||
return typed_healthy_deployments
|
||||
typed_request_kwargs: Final = _REQUEST_KWARGS_ADAPTER.validate_python(request_kwargs)
|
||||
target_order: Final = typed_request_kwargs.get("_target_order")
|
||||
|
|
|
|||
|
|
@ -477,6 +477,68 @@ def test_transform_request_forwards_non_string_instructions_and_input_untouched(
|
|||
)
|
||||
|
||||
|
||||
CODEX_CLOCK_NAMESPACE_TOOL: Final = {
|
||||
"type": "namespace",
|
||||
"name": "clock",
|
||||
"description": "Clock tools.",
|
||||
"tools": [
|
||||
{
|
||||
"type": "function",
|
||||
"name": "sleep",
|
||||
"description": "Sleep for some seconds.",
|
||||
"parameters": {"type": "object", "properties": {"seconds": {"type": "number"}}, "required": ["seconds"]},
|
||||
}
|
||||
],
|
||||
}
|
||||
CODEX_ADDITIONAL_TOOLS_ITEM: Final = {
|
||||
"type": "additional_tools",
|
||||
"role": "developer",
|
||||
"tools": [CODEX_CLOCK_NAMESPACE_TOOL],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("instructions", [None, "Answer with exactly one word."], ids=["no-instructions", "instructions"])
|
||||
def test_transform_request_moves_codex_additional_tools_item_into_tools(instructions: str | None) -> None:
|
||||
user_item: Final = {"role": "user", "content": "Sleep for two seconds."}
|
||||
request: Final = FireworksAIResponsesAPIConfig().transform_responses_api_request(
|
||||
model="accounts/fireworks/models/glm-5p3",
|
||||
input=cast(ResponseInputParam, [CODEX_ADDITIONAL_TOOLS_ITEM, user_item]),
|
||||
response_api_optional_request_params={} if instructions is None else {"instructions": instructions},
|
||||
litellm_params=GenericLiteLLMParams(),
|
||||
headers={},
|
||||
)
|
||||
assert tuple(request["tools"]) == (CODEX_CLOCK_NAMESPACE_TOOL,)
|
||||
assert tuple(request["input"]) == (user_item,)
|
||||
assert request.get("instructions") == instructions
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("drop_params", "expected_clock_copies"),
|
||||
[
|
||||
pytest.param(True, 1, id="merged once under drop_params"),
|
||||
pytest.param(None, 2, id="forwarded as sent without drop_params"),
|
||||
],
|
||||
)
|
||||
def test_transform_request_merges_codex_additional_tools_into_existing_tools_only_under_drop_params(
|
||||
drop_params: bool | None, expected_clock_copies: int
|
||||
) -> None:
|
||||
weather_tool: Final = {
|
||||
"type": "function",
|
||||
"name": "get_weather",
|
||||
"parameters": {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]},
|
||||
}
|
||||
user_item: Final = {"role": "user", "content": "Sleep for two seconds, then check the weather in Paris."}
|
||||
request: Final = FireworksAIResponsesAPIConfig().transform_responses_api_request(
|
||||
model="accounts/fireworks/models/glm-5p3",
|
||||
input=cast(ResponseInputParam, [CODEX_ADDITIONAL_TOOLS_ITEM, user_item, CODEX_ADDITIONAL_TOOLS_ITEM]),
|
||||
response_api_optional_request_params={"tools": [weather_tool]},
|
||||
litellm_params=GenericLiteLLMParams(drop_params=drop_params),
|
||||
headers={},
|
||||
)
|
||||
assert tuple(request["tools"]) == (weather_tool, *(CODEX_CLOCK_NAMESPACE_TOOL,) * expected_clock_copies)
|
||||
assert tuple(request["input"]) == (user_item,)
|
||||
|
||||
|
||||
def test_responses_call_maps_pydantic_developer_items_and_replays_pydantic_output_items() -> None:
|
||||
client: Final = _mock_http_client(_fireworks_response("accounts/fireworks/models/qwen3p8-2p4t-a95b"))
|
||||
pydantic_input: Final = cast(
|
||||
|
|
|
|||
|
|
@ -2397,6 +2397,37 @@ async def test_deny_by_default_ignores_tools_that_name_no_vector_store(
|
|||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("deny_by_default", [False, True], ids=["legacy", "deny-by-default"])
|
||||
@pytest.mark.parametrize(
|
||||
("key_vector_stores", "denied_by"),
|
||||
[(["KBSTOREA"], _KEY_DENIED), (["KBSTOREB"], None)],
|
||||
ids=["ungranted", "granted"],
|
||||
)
|
||||
async def test_file_search_inside_additional_tools_input_item_needs_a_vector_store_grant(
|
||||
deny_by_default: bool, key_vector_stores: list[str], denied_by: ProxyErrorTypes | None
|
||||
):
|
||||
await _assert_rag_query_outcome(
|
||||
_common_checks_for_rag_query(
|
||||
{"vector_store_deny_by_default": deny_by_default},
|
||||
_virtual_key(object_permission_id="key-permission"),
|
||||
SimpleNamespace(vector_stores=key_vector_stores),
|
||||
request_body={
|
||||
"model": "gpt-4o-mini",
|
||||
"input": [
|
||||
{
|
||||
"type": "additional_tools",
|
||||
"role": "developer",
|
||||
"tools": [{"type": "file_search", "vector_store_ids": ["KBSTOREB"]}],
|
||||
},
|
||||
{"type": "message", "role": "user", "content": "what is in this KB?"},
|
||||
],
|
||||
},
|
||||
),
|
||||
denied_by,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"request_body",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import json
|
||||
import logging
|
||||
from collections.abc import Mapping
|
||||
from types import MappingProxyType
|
||||
from typing import Final, Literal
|
||||
|
|
@ -6,6 +7,7 @@ from typing import Final, Literal
|
|||
import pytest
|
||||
|
||||
import litellm
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.litellm_core_utils.get_model_cost_map import GetModelCostMap
|
||||
from litellm.proxy.common_utils.codex_model_catalog import (
|
||||
CODEX_CATALOG_BYTE_LIMIT,
|
||||
|
|
@ -15,9 +17,11 @@ from litellm.proxy.common_utils.codex_model_catalog import (
|
|||
CodexStockUpgrade,
|
||||
bundled_codex_models,
|
||||
codex_catalog_rows,
|
||||
codex_entry_names_default_reasoning_level,
|
||||
codex_model_list_body,
|
||||
codex_models_response_json,
|
||||
configured_service_tiers,
|
||||
merged_codex_catalog,
|
||||
)
|
||||
|
||||
_PROMPT = "base prompt"
|
||||
|
|
@ -586,6 +590,15 @@ def test_default_service_tier_survives_only_while_still_offered(configured, expe
|
|||
assert entry["default_service_tier"] == expected
|
||||
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("alias_defaults", "expected"), [("codex", "priority"), ("deployment", None)])
|
||||
def test_alias_with_offered_tiers_takes_codexs_default_tier_only_under_codex_defaults(alias_defaults, expected):
|
||||
(entry,) = _models(
|
||||
_row("team-sol", upstream_model="gpt-6-sol", service_tiers=["priority"]), alias_defaults=alias_defaults
|
||||
)
|
||||
|
||||
assert (_tiers(entry), entry["default_service_tier"]) == ([("priority", "Fast", "2x speed, increased usage")], expected)
|
||||
|
||||
def test_non_chat_and_wildcard_rows_are_left_out_of_the_catalog():
|
||||
body = _body(
|
||||
_row("gpt-6-astra"),
|
||||
|
|
@ -663,6 +676,55 @@ def test_an_entry_too_large_for_the_bytes_left_is_passed_over_and_the_smaller_on
|
|||
assert len(body.json.encode()) <= limit
|
||||
|
||||
|
||||
def test_merged_catalog_keeps_upstream_whole_and_adds_only_the_entries_that_fit_the_byte_limit():
|
||||
upstream = {"models": [{"slug": "upstream-model", "priority": 4, "base_instructions": "u" * 500}]}
|
||||
upstream_only = len(json.dumps(upstream, separators=(",", ":")))
|
||||
rows = tuple(_row(f"model-{index}") for index in range(4))
|
||||
|
||||
def merged(*merged_rows, byte_limit):
|
||||
return merged_codex_catalog(
|
||||
json.dumps(upstream).encode(), merged_rows, stock=_STOCK, instructions=_PROMPT, byte_limit=byte_limit
|
||||
)
|
||||
|
||||
unlimited = json.loads(merged(*rows, byte_limit=upstream_only * 100))["models"]
|
||||
limit = len(merged(*rows[:2], byte_limit=upstream_only * 100).encode())
|
||||
|
||||
body = merged(*rows, byte_limit=limit)
|
||||
|
||||
assert len(body.encode()) <= limit
|
||||
assert json.loads(body)["models"] == unlimited[:3]
|
||||
assert merged(*rows, byte_limit=upstream_only) is None
|
||||
|
||||
|
||||
def test_merged_catalog_gives_an_alias_no_service_tier_or_upgrade_of_codexs_own():
|
||||
upstream = json.dumps({"models": [{"slug": "upstream-model", "priority": 0}]}).encode()
|
||||
rows = (_row("team-sol", upstream_model="openai/gpt-6-sol"), _row("team-55", upstream_model="openai/gpt-5.5"))
|
||||
|
||||
added = json.loads(merged_codex_catalog(upstream, rows, stock=_STOCK, instructions=_PROMPT))["models"][1:]
|
||||
|
||||
assert [
|
||||
(entry["slug"], entry["service_tiers"], entry["default_service_tier"], entry["upgrade"]) for entry in added
|
||||
] == [
|
||||
("team-sol", [], None, None),
|
||||
("team-55", [], None, None),
|
||||
]
|
||||
|
||||
|
||||
def test_merged_catalog_warns_once_with_how_many_models_the_byte_limit_left_out(caplog, monkeypatch):
|
||||
upstream = json.dumps({"models": [{"slug": "upstream-model", "priority": 0}]}).encode()
|
||||
rows = tuple(_row(f"model-{index}") for index in range(4))
|
||||
limit = len(merged_codex_catalog(upstream, rows[:1], stock=_STOCK, instructions=_PROMPT, byte_limit=10**6).encode())
|
||||
monkeypatch.setattr(verbose_proxy_logger, "propagate", True)
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger=verbose_proxy_logger.name):
|
||||
merged_codex_catalog(upstream, rows, stock=_STOCK, instructions=_PROMPT, byte_limit=limit)
|
||||
merged_codex_catalog(upstream, rows[:1], stock=_STOCK, instructions=_PROMPT, byte_limit=limit)
|
||||
|
||||
assert [record.getMessage() for record in caplog.records] == [
|
||||
f"Codex native model catalog left out 3 of 4 LiteLLM models to stay within Codex's {limit} byte limit"
|
||||
]
|
||||
|
||||
|
||||
def test_unlimited_body_keeps_every_entry():
|
||||
rows = tuple(_row(f"model-{index}") for index in range(3))
|
||||
|
||||
|
|
@ -898,6 +960,38 @@ def test_catalog_rows_without_a_router_carry_only_the_listing():
|
|||
assert codex_catalog_rows(listing, (("plain", "plain"),), None) == (CodexCatalogRow(id="plain", mode="chat"),)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("client_version", [None, "0.165.0"])
|
||||
def test_entry_names_a_default_reasoning_level_exactly_when_the_native_entry_carries_one(client_version):
|
||||
router = litellm.Router(
|
||||
model_list=[
|
||||
{"model_name": "open-weights", "litellm_params": {"model": "fireworks_ai/open-weights"}},
|
||||
{"model_name": "coding-model", "litellm_params": {"model": "openai/gpt-5.5"}},
|
||||
{"model_name": "pinned-model", "litellm_params": {"model": "openai/gpt-5.5", "reasoning_effort": "high"}},
|
||||
{"model_name": "split-pin", "litellm_params": {"model": "openai/gpt-5.5", "reasoning_effort": "high"}},
|
||||
{"model_name": "split-pin", "litellm_params": {"model": "openai/gpt-5.5", "reasoning_effort": "low"}},
|
||||
{
|
||||
"model_name": "max-only",
|
||||
"litellm_params": {"model": "hosted_vllm/qa-local-coder", "api_base": "https://vllm.example/v1"},
|
||||
"model_info": {"supports_reasoning": True, "reasoning_effort_levels": ["max"]},
|
||||
},
|
||||
]
|
||||
)
|
||||
names = ("open-weights", "coding-model", "pinned-model", "split-pin", "max-only")
|
||||
listing = tuple(
|
||||
{"id": name, "object": "model", "created": 0, "owned_by": "openai", "mode": "chat"} for name in names
|
||||
)
|
||||
upstream = json.dumps({"models": [{"slug": "upstream-model", "priority": 0}]}).encode()
|
||||
rows = codex_catalog_rows(listing, tuple((name, name) for name in names), router)
|
||||
served = json.loads(merged_codex_catalog(upstream, rows, client_version=client_version))["models"][1:]
|
||||
served_names_one = [entry.get("default_reasoning_level") is not None for entry in served]
|
||||
|
||||
assert [
|
||||
codex_entry_names_default_reasoning_level(entry["slug"], router, None, client_version) for entry in served
|
||||
] == served_names_one
|
||||
assert served_names_one == [False, False, True, False, client_version is not None]
|
||||
assert served[2]["default_reasoning_level"] == "high"
|
||||
|
||||
|
||||
def test_codex_tool_search_is_offered_off_the_route_and_model_info_and_only_when_every_deployment_answers_it():
|
||||
claude = "anthropic/claude-sonnet-5-5"
|
||||
converse = "bedrock/us.anthropic.claude-sonnet-5-5"
|
||||
|
|
@ -944,3 +1038,31 @@ def test_codex_tool_search_is_offered_off_the_route_and_model_info_and_only_when
|
|||
"mixed": False,
|
||||
}
|
||||
assert models[0]["context_window"] == 1000000
|
||||
|
||||
|
||||
def test_native_merged_catalog_offers_codex_tool_search_as_the_proxy_listing_does():
|
||||
claude = "anthropic/claude-sonnet-5-5"
|
||||
router = litellm.Router(
|
||||
model_list=[
|
||||
{"model_name": "claude", "litellm_params": {"model": claude}},
|
||||
{
|
||||
"model_name": "claude-off",
|
||||
"litellm_params": {"model": claude},
|
||||
"model_info": {"supports_search_tool": False},
|
||||
},
|
||||
{"model_name": "coding-model", "litellm_params": {"model": "openai/gpt-5.5"}},
|
||||
]
|
||||
)
|
||||
names = ("claude", "claude-off", "coding-model")
|
||||
listing = tuple({"id": name, "object": "model", "created": 0, "owned_by": "x", "mode": "chat"} for name in names)
|
||||
entries = tuple((name, name) for name in names)
|
||||
upstream = json.dumps({"models": [{"slug": "upstream-model", "priority": 0}]}).encode()
|
||||
|
||||
listed = json.loads(codex_model_list_body(listing, entries, router))["models"]
|
||||
merged = json.loads(merged_codex_catalog(upstream, codex_catalog_rows(listing, entries, router)))["models"][1:]
|
||||
|
||||
assert [entry["slug"] for entry in merged] == list(names)
|
||||
assert [entry.get("supports_search_tool") for entry in merged] == [
|
||||
entry.get("supports_search_tool") for entry in listed
|
||||
]
|
||||
assert [entry["supports_search_tool"] for entry in merged[:2]] == [True, False]
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -123,6 +123,8 @@ def route_entry_points_to_stubs(router: _RouterDouble) -> _RouterDouble:
|
|||
return router.get_configured_display_name(name)
|
||||
if field == "service_tiers":
|
||||
return router.get_configured_service_tiers(name, **_given(team_id=team_id))
|
||||
if field == "pinned_reasoning_efforts":
|
||||
return router.get_pinned_reasoning_efforts(name, **_given(team_id=team_id))
|
||||
if field == "max_input_tokens":
|
||||
return router.get_configured_max_input_tokens(name, **_given(team_id=team_id))
|
||||
if field == "listing_info":
|
||||
|
|
|
|||
|
|
@ -242,6 +242,42 @@ class TestCheckToolsAllowlist:
|
|||
assert exc_info.value.type == ProxyErrorTypes.tool_access_denied
|
||||
assert "restricted_tool" in str(exc_info.value.message)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("allowed_tools", "denied"),
|
||||
[
|
||||
pytest.param(["get_weather"], True, id="forbidden"),
|
||||
pytest.param(["get_weather", "delete_user"], False, id="allowed"),
|
||||
],
|
||||
)
|
||||
async def test_tool_defined_in_an_additional_tools_input_item_is_checked_on_responses_route(
|
||||
self, allowed_tools: list[str], denied: bool
|
||||
) -> None:
|
||||
body = {
|
||||
"model": "fireworks_ai/glm-5p3",
|
||||
"input": [
|
||||
{
|
||||
"type": "additional_tools",
|
||||
"role": "developer",
|
||||
"tools": [{"type": "function", "name": "delete_user", "parameters": {"type": "object"}}],
|
||||
},
|
||||
{"type": "message", "role": "user", "content": "Delete user 42"},
|
||||
],
|
||||
}
|
||||
check = check_tools_allowlist(
|
||||
request_body=body,
|
||||
valid_token=_token(metadata={"allowed_tools": allowed_tools}),
|
||||
team_object=None,
|
||||
route="/v1/responses",
|
||||
)
|
||||
if not denied:
|
||||
await check
|
||||
return
|
||||
with pytest.raises(ProxyException) as exc_info:
|
||||
await check
|
||||
assert exc_info.value.type == ProxyErrorTypes.tool_access_denied
|
||||
assert "delete_user" in str(exc_info.value.message)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"loaded_tool",
|
||||
[
|
||||
|
|
|
|||
|
|
@ -1,12 +1,18 @@
|
|||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from litellm.responses.litellm_completion_transformation.reasoning_items import (
|
||||
decode_thinking_blocks,
|
||||
encode_thinking_blocks,
|
||||
is_litellm_minted_reasoning_item,
|
||||
is_minted_reasoning_item_id,
|
||||
is_other_provider_reasoning_item,
|
||||
mint_reasoning_item_id,
|
||||
)
|
||||
from litellm.responses.litellm_completion_transformation.transformation import LiteLLMCompletionResponsesConfig
|
||||
from litellm.responses.utils import ResponsesAPIRequestUtils
|
||||
from litellm.types.llms.openai import ResponsesAPIOptionalRequestParams
|
||||
|
||||
A_PROVIDER_OWNED_REASONING_ITEM_ID = "rs_08d3a89dbb92277a006abf04f4266087d0b4eedacd7848f306"
|
||||
A_PROVIDER_OWNED_ENCRYPTED_BLOB = "gAAAAABo-opaque-provider-blob"
|
||||
|
|
@ -58,5 +64,82 @@ def test_a_reasoning_item_is_litellm_minted_by_its_id_or_by_its_encoded_thinking
|
|||
"encrypted_content": A_PROVIDER_OWNED_ENCRYPTED_BLOB,
|
||||
}
|
||||
)
|
||||
assert not is_litellm_minted_reasoning_item({"type": "message", "id": mint_reasoning_item_id(), "role": "assistant"})
|
||||
assert not is_litellm_minted_reasoning_item(
|
||||
{"type": "message", "id": mint_reasoning_item_id(), "role": "assistant"}
|
||||
)
|
||||
assert not is_litellm_minted_reasoning_item("a bare string input")
|
||||
|
||||
|
||||
SUMMARY = [{"type": "summary_text", "text": "Paris first."}]
|
||||
PROVIDER_OWNED_ITEM = {
|
||||
"type": "reasoning",
|
||||
"id": A_PROVIDER_OWNED_REASONING_ITEM_ID,
|
||||
"summary": SUMMARY,
|
||||
"encrypted_content": A_PROVIDER_OWNED_ENCRYPTED_BLOB,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"item",
|
||||
[
|
||||
pytest.param({"type": "reasoning", "id": mint_reasoning_item_id(), "summary": SUMMARY}, id="minted id"),
|
||||
pytest.param(
|
||||
{"type": "reasoning", "summary": SUMMARY, "encrypted_content": encode_thinking_blocks([SIGNED_BLOCK])},
|
||||
id="encoded thinking blocks",
|
||||
),
|
||||
pytest.param(
|
||||
{"type": "reasoning", "summary": [], "content": [{"type": "reasoning_text", "text": "Paris first."}]},
|
||||
id="plain-text reasoning content",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
**PROVIDER_OWNED_ITEM,
|
||||
"encrypted_content": ResponsesAPIRequestUtils.wrap_encrypted_content_with_model_id(
|
||||
A_PROVIDER_OWNED_ENCRYPTED_BLOB, "another-deployment"
|
||||
),
|
||||
},
|
||||
id="blob wrapped with a litellm deployment id",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_reasoning_items_another_provider_produced_are_recognized(item):
|
||||
assert is_other_provider_reasoning_item(item)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"item",
|
||||
[
|
||||
pytest.param(PROVIDER_OWNED_ITEM, id="provider-owned blob"),
|
||||
pytest.param({"type": "reasoning", "summary": SUMMARY}, id="summary only, no id"),
|
||||
pytest.param(
|
||||
{"type": "reasoning", "summary": SUMMARY, "content": None, "encrypted_content": None}, id="nulled replay"
|
||||
),
|
||||
pytest.param({"type": "reasoning", "summary": SUMMARY, "content": []}, id="empty content"),
|
||||
pytest.param(
|
||||
{"type": "message", "id": mint_reasoning_item_id(), "role": "user", "content": "hi"}, id="not reasoning"
|
||||
),
|
||||
pytest.param("a stray string", id="not an object"),
|
||||
],
|
||||
)
|
||||
def test_reasoning_items_the_provider_can_replay_are_kept(item):
|
||||
assert not is_other_provider_reasoning_item(item)
|
||||
|
||||
|
||||
def test_signed_thinking_survives_the_model_id_wrap_on_its_way_back_to_the_chat_completions_bridge():
|
||||
wrapped = ResponsesAPIRequestUtils.wrap_encrypted_content_with_model_id(
|
||||
encode_thinking_blocks([SIGNED_BLOCK]), "anthropic-deployment"
|
||||
)
|
||||
messages = LiteLLMCompletionResponsesConfig.transform_responses_api_request_to_chat_completion_request(
|
||||
model="anthropic/claude-sonnet-5-5",
|
||||
input=[
|
||||
{"role": "user", "content": "Capital of France?"},
|
||||
{"type": "reasoning", "id": mint_reasoning_item_id(), "summary": [], "encrypted_content": wrapped},
|
||||
{"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "Paris."}]},
|
||||
{"role": "user", "content": "And of Italy?"},
|
||||
],
|
||||
responses_api_request=ResponsesAPIOptionalRequestParams(),
|
||||
)["messages"]
|
||||
|
||||
assert [message.get("thinking_blocks") for message in messages if message["role"] == "assistant"] == [
|
||||
[SIGNED_BLOCK]
|
||||
]
|
||||
|
|
|
|||
|
|
@ -829,6 +829,81 @@ def test_attribute_errors_about_other_objects_pass_through_unchanged(router):
|
|||
assert _public_attribute_error(router, "_core_x", "x", other) is other
|
||||
|
||||
|
||||
_MINTING_DEPLOYMENT = "dep-minting-org"
|
||||
_OTHER_ORG_DEPLOYMENT = "dep-other-org"
|
||||
_BOTH_ORGS = (_MINTING_DEPLOYMENT, _OTHER_ORG_DEPLOYMENT)
|
||||
_GROUP_AFFINITY = ["encrypted_content_affinity"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"affinity, target_ids, reasoning_stays",
|
||||
[
|
||||
pytest.param({}, _BOTH_ORGS, False, id="no affinity, one target cannot decrypt"),
|
||||
pytest.param({}, (_MINTING_DEPLOYMENT,), True, id="no affinity, the only target minted it"),
|
||||
pytest.param(
|
||||
{"optional_pre_call_checks": _GROUP_AFFINITY}, _BOTH_ORGS, True, id="global affinity, origin among targets"
|
||||
),
|
||||
pytest.param(
|
||||
{"optional_pre_call_checks": _GROUP_AFFINITY},
|
||||
(_OTHER_ORG_DEPLOYMENT,),
|
||||
False,
|
||||
id="global affinity, origin not among targets",
|
||||
),
|
||||
pytest.param(
|
||||
{"optional_pre_call_checks": [], "model_group_affinity_config": {"pair": _GROUP_AFFINITY}},
|
||||
_BOTH_ORGS,
|
||||
True,
|
||||
id="affinity on for this group",
|
||||
),
|
||||
pytest.param(
|
||||
{"optional_pre_call_checks": [], "model_group_affinity_config": {"other-group": _GROUP_AFFINITY}},
|
||||
_BOTH_ORGS,
|
||||
False,
|
||||
id="affinity on for another group only",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_decryptable_reasoning_keeps_encrypted_reasoning_only_when_the_router_sends_it_to_a_deployment_that_decrypts_it(
|
||||
affinity, target_ids, reasoning_stays
|
||||
):
|
||||
from litellm.responses.utils import ResponsesAPIRequestUtils
|
||||
|
||||
r = Router(
|
||||
model_list=[
|
||||
{
|
||||
"model_name": "pair",
|
||||
"litellm_params": {"model": "openai/gpt-4o-mini", "api_base": api_base, "api_key": "sk-pair"},
|
||||
"model_info": {"id": model_id},
|
||||
}
|
||||
for api_base, model_id in (
|
||||
("https://minting-org.test/v1", _MINTING_DEPLOYMENT),
|
||||
("https://other-org.test/v1", _OTHER_ORG_DEPLOYMENT),
|
||||
)
|
||||
],
|
||||
**affinity,
|
||||
)
|
||||
summary = [{"type": "summary_text", "text": "multiply 17 by 23"}]
|
||||
user_turn = {"type": "message", "role": "user", "content": "What is 17*23?"}
|
||||
reasoning = {
|
||||
"type": "reasoning",
|
||||
"id": "rs_minted",
|
||||
"encrypted_content": ResponsesAPIRequestUtils.wrap_encrypted_content_with_model_id(
|
||||
"gAAAAA-blob", _MINTING_DEPLOYMENT
|
||||
),
|
||||
"summary": summary,
|
||||
}
|
||||
request_input = [user_turn, reasoning]
|
||||
targets = tuple(r.registry().deployment(model_id) for model_id in target_ids)
|
||||
try:
|
||||
kept = r.registry().decryptable_reasoning("pair", request_input, targets)
|
||||
finally:
|
||||
r.retire()
|
||||
|
||||
readable_only = {"type": "reasoning", "summary": summary}
|
||||
assert kept == (user_turn, reasoning if reasoning_stays else readable_only)
|
||||
assert request_input == [user_turn, reasoning], "the caller's input must not change"
|
||||
|
||||
|
||||
# Router-family helpers whose Router parameter became a RoutingContext keep the old spellings.
|
||||
|
||||
|
||||
|
|
|
|||
8
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
8
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -1864,13 +1864,15 @@ export interface paths {
|
|||
};
|
||||
/**
|
||||
* Codex Passthrough Route
|
||||
* @description Forward a client's ChatGPT subscription with separate LiteLLM authentication.
|
||||
* @description Forward a client's ChatGPT subscription with separate LiteLLM authentication, listing and serving the
|
||||
* key's other chat models through LiteLLM.
|
||||
*/
|
||||
get: operations["codex_passthrough_route_backend_api_codex__endpoint__get"];
|
||||
put?: never;
|
||||
/**
|
||||
* Codex Passthrough Route
|
||||
* @description Forward a client's ChatGPT subscription with separate LiteLLM authentication.
|
||||
* @description Forward a client's ChatGPT subscription with separate LiteLLM authentication, listing and serving the
|
||||
* key's other chat models through LiteLLM.
|
||||
*/
|
||||
post: operations["codex_passthrough_route_backend_api_codex__endpoint__post"];
|
||||
delete?: never;
|
||||
|
|
@ -55033,7 +55035,7 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Unmodified Codex response or server-sent events */
|
||||
/** @description Codex response, the model catalog with the key's LiteLLM models, or server-sent events */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue