From 1e4464d82cdd78097bbafd0b46e4c329bfab1525 Mon Sep 17 00:00:00 2001 From: ymuichiro Date: Wed, 12 Aug 2026 01:43:20 +0900 Subject: [PATCH] feat(proxy): add native OpenAI Skills routing --- .../anthropic_endpoints/skills_endpoints.py | 129 ++++++++- .../openai_files_endpoints/common_utils.py | 11 +- litellm/skills/main.py | 154 +++++++++- .../test_litellm/skills/test_native_skills.py | 274 ++++++++++++++++++ 4 files changed, 554 insertions(+), 14 deletions(-) create mode 100644 tests/test_litellm/skills/test_native_skills.py diff --git a/litellm/proxy/anthropic_endpoints/skills_endpoints.py b/litellm/proxy/anthropic_endpoints/skills_endpoints.py index 9390bf4c537..8e72754bbd1 100644 --- a/litellm/proxy/anthropic_endpoints/skills_endpoints.py +++ b/litellm/proxy/anthropic_endpoints/skills_endpoints.py @@ -2,8 +2,10 @@ Anthropic Skills API endpoints - /v1/skills """ -from typing import Final +from collections.abc import Awaitable, Callable +from typing import Annotated, Final, Literal +import httpx import orjson from fastapi import APIRouter, Depends, Request, Response @@ -13,11 +15,10 @@ from litellm.proxy.common_request_processing import ProxyBaseLLMRequestProcessin from litellm.proxy.common_utils.http_parsing_utils import ( convert_upload_files_to_file_data, get_form_data, + get_request_body, ) -from litellm.types.llms.anthropic_skills import ( - DeleteSkillResponse, - ListSkillsResponse, - Skill, +from litellm.proxy.openai_files_endpoints.common_utils import ( + extract_model_param, ) router: Final = APIRouter() @@ -27,7 +28,6 @@ router: Final = APIRouter() "/v1/skills", tags=["[beta] Anthropic Skills API"], dependencies=[Depends(user_api_key_auth)], - response_model=Skill, ) async def create_skill( fastapi_response: Response, @@ -87,6 +87,7 @@ async def create_skill( model: Final = data.get("model") or request.query_params.get("model") or request.headers.get("x-litellm-model") if model: data["model"] = model + data["_skill_operation"] = "create" if "custom_llm_provider" not in data: data["custom_llm_provider"] = custom_llm_provider @@ -125,7 +126,6 @@ async def create_skill( "/v1/skills", tags=["[beta] Anthropic Skills API"], dependencies=[Depends(user_api_key_auth)], - response_model=ListSkillsResponse, ) async def list_skills( fastapi_response: Response, @@ -176,7 +176,7 @@ async def list_skills( # Read request body body: Final = await request.body() - data: Final = orjson.loads(body) if body else {} + data: Final = {**dict(request.query_params), **(orjson.loads(body) if body else {})} # mutable-ok: pagination data # Use query params if not in body if "limit" not in data and limit is not None: @@ -190,6 +190,7 @@ async def list_skills( model: Final = data.get("model") or request.query_params.get("model") or request.headers.get("x-litellm-model") if model: data["model"] = model + data["_skill_operation"] = "list" # Set custom_llm_provider: body > query param > default if "custom_llm_provider" not in data: @@ -229,7 +230,6 @@ async def list_skills( "/v1/skills/{skill_id}", tags=["[beta] Anthropic Skills API"], dependencies=[Depends(user_api_key_auth)], - response_model=Skill, ) async def get_skill( skill_id: str, @@ -287,6 +287,7 @@ async def get_skill( model: Final = data.get("model") or request.query_params.get("model") or request.headers.get("x-litellm-model") if model: data["model"] = model + data["_skill_operation"] = "get" # Set custom_llm_provider: body > query param > default if "custom_llm_provider" not in data: @@ -326,7 +327,6 @@ async def get_skill( "/v1/skills/{skill_id}", tags=["[beta] Anthropic Skills API"], dependencies=[Depends(user_api_key_auth)], - response_model=DeleteSkillResponse, ) async def delete_skill( skill_id: str, @@ -386,6 +386,7 @@ async def delete_skill( model: Final = data.get("model") or request.query_params.get("model") or request.headers.get("x-litellm-model") if model: data["model"] = model + data["_skill_operation"] = "delete" # Set custom_llm_provider: body > query param > default if "custom_llm_provider" not in data: @@ -419,3 +420,111 @@ async def delete_skill( proxy_logging_obj=proxy_logging_obj, version=version, ) + + +SkillRouteType = Literal["acreate_skill", "alist_skills", "aget_skill", "adelete_skill"] + + +async def _native_skill_data(request: Request, operation: str) -> dict[str, object]: + body: Final = await convert_upload_files_to_file_data(await get_request_body(request)) + model: Final = extract_model_param(request, body) + custom_llm_provider: Final = ( + body.get("custom_llm_provider") or request.query_params.get("custom_llm_provider") or "openai" + ) + data: Final = dict(request.query_params) # mutable-ok: proxy processing mutates route data + data.update(body) + data.update(request.path_params) + if model: + data["model"] = model + data["custom_llm_provider"] = custom_llm_provider + data["_skill_operation"] = operation + return data + + +async def _native_skill_endpoint( + request: Request, + fastapi_response: Response, + user_api_key_dict: UserAPIKeyAuth, + operation: str, + route_type: SkillRouteType, +) -> object: + from litellm.proxy.proxy_server import ( + general_settings, + llm_router, + proxy_config, + proxy_logging_obj, + select_data_generator, + user_api_base, + user_max_tokens, + user_model, + user_request_timeout, + user_temperature, + version, + ) + + data: Final = await _native_skill_data(request, operation) + processor: Final = ProxyBaseLLMRequestProcessing(data=data) + try: + result: Final = await processor.base_process_llm_request( + request=request, + fastapi_response=fastapi_response, + user_api_key_dict=user_api_key_dict, + route_type=route_type, + proxy_logging_obj=proxy_logging_obj, + llm_router=llm_router, + general_settings=general_settings, + proxy_config=proxy_config, + select_data_generator=select_data_generator, + model=data.get("model"), + user_model=user_model, + user_temperature=user_temperature, + user_request_timeout=user_request_timeout, + user_max_tokens=user_max_tokens, + user_api_base=user_api_base, + version=version, + ) + if operation not in ("content", "version_content"): + return result + response: Final = getattr(result, "response", None) + if not isinstance(response, httpx.Response): + raise TypeError("Skills content response did not contain an HTTP response") + return Response(content=response.content, status_code=response.status_code, headers=response.headers) + except Exception as e: # noqa: BLE001 # proxy maps provider errors to the public exception contract + raise await processor._handle_llm_api_exception( + e=e, + user_api_key_dict=user_api_key_dict, + proxy_logging_obj=proxy_logging_obj, + version=version, + ) + + +def _native_skill_route(operation: str, route_type: SkillRouteType) -> Callable[..., Awaitable[object]]: + async def endpoint( + request: Request, + fastapi_response: Response, + user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)], + ) -> object: + return await _native_skill_endpoint(request, fastapi_response, user_api_key_dict, operation, route_type) + + return endpoint + + +_NATIVE_SKILL_ROUTES: Final[tuple[tuple[str, str, str, SkillRouteType], ...]] = ( + ("POST", "/v1/skills/{skill_id}", "update", "acreate_skill"), + ("GET", "/v1/skills/{skill_id}/content", "content", "aget_skill"), + ("POST", "/v1/skills/{skill_id}/versions", "create_version", "acreate_skill"), + ("GET", "/v1/skills/{skill_id}/versions", "list_versions", "alist_skills"), + ("GET", "/v1/skills/{skill_id}/versions/{version}", "version", "aget_skill"), + ("DELETE", "/v1/skills/{skill_id}/versions/{version}", "delete_version", "adelete_skill"), + ("GET", "/v1/skills/{skill_id}/versions/{version}/content", "version_content", "aget_skill"), +) + +for method, path, operation, route_type in _NATIVE_SKILL_ROUTES: + router.add_api_route( + path, + _native_skill_route(operation, route_type), + methods=[method], # mutable-ok: FastAPI contract + name=f"{operation}_skill", + response_model=None, + tags=["[beta] OpenAI Skills API"], # mutable-ok: FastAPI contract + ) diff --git a/litellm/proxy/openai_files_endpoints/common_utils.py b/litellm/proxy/openai_files_endpoints/common_utils.py index 142aced4a38..4583aed6a1c 100644 --- a/litellm/proxy/openai_files_endpoints/common_utils.py +++ b/litellm/proxy/openai_files_endpoints/common_utils.py @@ -855,7 +855,7 @@ async def extract_file_creation_params( target_model_names = await _extract_target_model_names_from_form(request) # Extract model parameter - model: Final = _extract_model_param(request, request_body) + model: Final = extract_model_param(request, request_body) return FileCreationParams( target_storage=target_storage, @@ -1026,7 +1026,7 @@ async def validate_managed_id_requirement( ) -def _extract_model_param(request: "Request", request_body: dict) -> str | None: +def extract_model_param(request: "Request", request_body: Mapping[str, object]) -> str | None: """ Extract model parameter from request. @@ -1035,7 +1035,12 @@ def _extract_model_param(request: "Request", request_body: dict) -> str | None: 2. Query parameter (?model=) 3. Header (x-litellm-model) """ - return request_body.get("model") or request.query_params.get("model") or request.headers.get("x-litellm-model") + body_model: Final = request_body.get("model") + return ( + body_model + if isinstance(body_model, str) + else request.query_params.get("model") or request.headers.get("x-litellm-model") + ) # ============================================================================ diff --git a/litellm/skills/main.py b/litellm/skills/main.py index ae1ce150368..61d21e7bb84 100644 --- a/litellm/skills/main.py +++ b/litellm/skills/main.py @@ -5,8 +5,11 @@ Provides create, list, get, and delete operations for skills import asyncio import contextvars +import inspect from collections.abc import Coroutine from functools import partial +from operator import attrgetter +from types import MappingProxyType from typing import Any, Final import httpx @@ -30,11 +33,116 @@ from litellm.utils import ProviderConfigManager, client # Initialize HTTP handler base_llm_http_handler = BaseLLMHTTPHandler() DEFAULT_ANTHROPIC_API_BASE: Final = "https://api.anthropic.com/v1" +_NATIVE_SKILL_PROVIDERS: Final = frozenset({"openai", "azure"}) +_NATIVE_SKILL_OPERATIONS: Final = MappingProxyType( + { + "create": ("skills.create", ("files",)), + "list": ("skills.list", ("after", "limit", "order")), + "get": ("skills.retrieve", ("skill_id",)), + "update": ("skills.update", ("skill_id", "default_version")), + "delete": ("skills.delete", ("skill_id",)), + "content": ("skills.content.retrieve", ("skill_id",)), + "create_version": ("skills.versions.create", ("skill_id", "default", "files")), + "list_versions": ("skills.versions.list", ("skill_id", "after", "limit", "order")), + "version": ("skills.versions.retrieve", ("skill_id", "version")), + "delete_version": ("skills.versions.delete", ("skill_id", "version")), + "version_content": ("skills.versions.content.retrieve", ("skill_id", "version")), + } +) # Initialize LiteLLM skills handler (lazy - only used when custom_llm_provider="litellm") _litellm_skills_handler = None +def _azure_skills_api_base(api_base: str | None) -> str | None: + if api_base is None: + return None + url: Final = httpx.URL(api_base) + path: Final = url.path.rstrip("/") + suffix: Final = next( + ( + item + for item in ("/openai/v1/responses", "/openai/responses", "/openai/v1", "/openai") + if path.endswith(item) + ), + "", + ) + return str(url.copy_with(path=path[: -len(suffix)] if suffix else path, query=None)).rstrip("/") + + +def _native_skill_request( + operation: str, + request_data: dict[str, Any], + custom_llm_provider: str, + litellm_params: GenericLiteLLMParams, + logging_obj: LiteLLMLoggingObj, + litellm_call_id: str | None, + is_async: bool, +) -> object: + from litellm.files.main import azure_files_instance, openai_files_instance + from litellm.llms.azure.common_utils import get_azure_credentials + from litellm.llms.openai.common_utils import get_openai_credentials + + method_path, request_fields = _NATIVE_SKILL_OPERATIONS[operation] + extra_headers: Final = request_data.get("extra_headers") + headers: Final = ( + {**(extra_headers or {}), "Foundry-Features": "Skills=V1Preview"} # mutable-ok: SDK headers + if custom_llm_provider == "azure" + else extra_headers + ) + params: Final = { # mutable-ok: SDK request parameters + field: value + for field, value in ( + *((field, request_data.get(field)) for field in request_fields), + ("extra_headers", headers), + ("extra_query", request_data.get("extra_query")), + ("extra_body", request_data.get("extra_body")), + ("timeout", request_data.get("timeout")), + ) + if value is not None + } + if custom_llm_provider == "openai": + openai_credentials: Final = get_openai_credentials( + api_base=litellm_params.api_base, + api_key=litellm_params.api_key, + organization=litellm_params.organization, + ) + sdk_client = openai_files_instance.get_openai_client( + api_key=openai_credentials.api_key, + api_base=openai_credentials.api_base, + timeout=request_data.get("timeout") or request_timeout, + max_retries=litellm_params.max_retries, + organization=openai_credentials.organization, + client=request_data.get("client"), + _is_async=is_async, + ) + else: + azure_credentials: Final = get_azure_credentials( + api_base=litellm_params.api_base, api_key=litellm_params.api_key + ) + api_base: Final = _azure_skills_api_base(azure_credentials.api_base) + if api_base is None: + raise ValueError("api_base is required for Azure OpenAI Skills") + sdk_client = azure_files_instance.get_azure_openai_client( + api_key=azure_credentials.api_key, + api_base=api_base, + api_version="v1", + client=request_data.get("client"), + litellm_params=litellm_params.model_dump(exclude_none=True), + _is_async=is_async, + ) + if sdk_client is None: + raise ValueError(f"{custom_llm_provider} client is not initialized") + logging_obj.update_from_kwargs( + kwargs=request_data, + model=None, + optional_params=params, + litellm_params={"litellm_call_id": litellm_call_id}, # mutable-ok: logging consumes request data + custom_llm_provider=custom_llm_provider, + ) + return attrgetter(method_path)(sdk_client)(**params) + + def _get_user_api_key_auth_from_kwargs(kwargs: dict[str, Any]) -> Any | None: for metadata_key in ("metadata", "litellm_metadata"): metadata = kwargs.get(metadata_key) @@ -195,6 +303,17 @@ def create_skill( litellm_call_id=litellm_call_id, ) + if custom_llm_provider in _NATIVE_SKILL_PROVIDERS: + return _native_skill_request( + kwargs.get("_skill_operation", "create"), + {**local_vars, **kwargs, **create_request}, # mutable-ok: Skills handlers consume request data + custom_llm_provider, + litellm_params, + litellm_logging_obj, + litellm_call_id, + _is_async, + ) + # Get provider config for external providers (Anthropic, etc.) skills_api_provider_config: BaseSkillsAPIConfig | None = ProviderConfigManager.get_provider_skills_api_config( provider=litellm.LlmProviders(custom_llm_provider), @@ -305,7 +424,7 @@ async def alist_skills( func_with_context: Final = partial(ctx.run, func) init_response: Final = await loop.run_in_executor(None, func_with_context) - if asyncio.iscoroutine(init_response): + if inspect.isawaitable(init_response): response = await init_response else: response = init_response @@ -371,6 +490,17 @@ def list_skills( litellm_call_id=litellm_call_id, ) + if custom_llm_provider in _NATIVE_SKILL_PROVIDERS: + return _native_skill_request( + kwargs.get("_skill_operation", "list"), + {**local_vars, **kwargs}, # mutable-ok: Skills handlers consume request data + custom_llm_provider, + litellm_params, + litellm_logging_obj, + litellm_call_id, + _is_async, + ) + # Get provider config for external providers (Anthropic, etc.) skills_api_provider_config: BaseSkillsAPIConfig | None = ProviderConfigManager.get_provider_skills_api_config( provider=litellm.LlmProviders(custom_llm_provider), @@ -543,6 +673,17 @@ def get_skill( litellm_call_id=litellm_call_id, ) + if custom_llm_provider in _NATIVE_SKILL_PROVIDERS: + return _native_skill_request( + kwargs.get("_skill_operation", "get"), + {**local_vars, **kwargs}, # mutable-ok: Skills handlers consume request data + custom_llm_provider, + litellm_params, + litellm_logging_obj, + litellm_call_id, + _is_async, + ) + # Get provider config for external providers (Anthropic, etc.) skills_api_provider_config: BaseSkillsAPIConfig | None = ProviderConfigManager.get_provider_skills_api_config( provider=litellm.LlmProviders(custom_llm_provider), @@ -707,6 +848,17 @@ def delete_skill( litellm_call_id=litellm_call_id, ) + if custom_llm_provider in _NATIVE_SKILL_PROVIDERS: + return _native_skill_request( + kwargs.get("_skill_operation", "delete"), + {**local_vars, **kwargs}, # mutable-ok: Skills handlers consume request data + custom_llm_provider, + litellm_params, + litellm_logging_obj, + litellm_call_id, + _is_async, + ) + # Get provider config for external providers (Anthropic, etc.) skills_api_provider_config: BaseSkillsAPIConfig | None = ProviderConfigManager.get_provider_skills_api_config( provider=litellm.LlmProviders(custom_llm_provider), diff --git a/tests/test_litellm/skills/test_native_skills.py b/tests/test_litellm/skills/test_native_skills.py new file mode 100644 index 00000000000..caf1484a32d --- /dev/null +++ b/tests/test_litellm/skills/test_native_skills.py @@ -0,0 +1,274 @@ +import json +from typing import Any +from unittest.mock import patch + +import httpx +import pytest +from fastapi.routing import APIRoute +from openai import AsyncOpenAI +from starlette.requests import Request + +import litellm +from litellm.files.main import openai_files_instance +from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER +from litellm.proxy.anthropic_endpoints.skills_endpoints import ( + _native_skill_data, +) +from litellm.proxy.anthropic_endpoints.skills_endpoints import ( + router as skills_router, +) +from litellm.router import Router +from litellm.skills.main import _azure_skills_api_base + +SKILL = { + "id": "skill_1", + "created_at": 1, + "default_version": "1", + "description": "description", + "latest_version": "1", + "name": "test-skill", + "object": "skill", +} +VERSION = { + "id": "version_1", + "created_at": 1, + "description": "description", + "name": "test-skill", + "object": "skill.version", + "skill_id": "skill_1", + "version": "1", +} + + +def _mock_response(request: httpx.Request) -> httpx.Response: + path = request.url.path + if path.endswith("/content"): + return httpx.Response(200, content=b"skill archive", headers={"content-type": "application/zip"}) + if request.method == "DELETE" and "/versions/" in path: + return httpx.Response( + 200, json={"id": "skill_1", "deleted": True, "object": "skill.version.deleted", "version": "1"} + ) + if request.method == "DELETE": + return httpx.Response(200, json={"id": "skill_1", "deleted": True, "object": "skill.deleted"}) + if path.endswith("/versions") and request.method == "GET": + return httpx.Response(200, json={"object": "list", "data": [VERSION], "has_more": False}) + if "/versions/" in path or path.endswith("/versions"): + return httpx.Response(200, json=VERSION) + if path.endswith("/skills") and request.method == "GET": + return httpx.Response(200, json={"object": "list", "data": [SKILL], "has_more": False}) + return httpx.Response(200, json=SKILL) + + +async def _call(operation: str, client: AsyncOpenAI, provider: str = "openai") -> Any: + common = { + "custom_llm_provider": provider, + "client": client, + "api_base": "https://resource.openai.azure.com/openai/v1" if provider == "azure" else None, + "extra_headers": {"x-test-header": "present"} if provider == "azure" else None, + "_skill_operation": operation, + } + if operation == "create": + return await litellm.acreate_skill(files=[("SKILL.md", b"skill")], **common) + if operation == "update": + return await litellm.acreate_skill(skill_id="skill_1", default_version=2, **common) + if operation == "create_version": + return await litellm.acreate_skill(skill_id="skill_1", files=[("SKILL.md", b"skill")], default=True, **common) + if operation in {"list", "list_versions"}: + return await litellm.alist_skills(skill_id="skill_1", after="cursor", limit=2, order="desc", **common) + if operation in {"get", "content", "version", "version_content"}: + return await litellm.aget_skill(skill_id="skill_1", version="1", **common) + return await litellm.adelete_skill(skill_id="skill_1", version="1", **common) + + +@pytest.mark.asyncio +async def test_openai_sdk_handles_every_native_skill_operation() -> None: + requests: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return _mock_response(request) + + client = AsyncOpenAI( + api_key="test", + base_url="https://api.openai.test/v1", + http_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)), + ) + operations = ( + "create", + "list", + "get", + "update", + "delete", + "content", + "create_version", + "list_versions", + "version", + "delete_version", + "version_content", + ) + try: + results = [await _call(operation, client) for operation in operations] + finally: + await GLOBAL_LOGGING_WORKER.flush() + await client.close() + + actual_requests = [(request.method, request.url.path) for request in requests] + expected_requests = [ + ("POST", "/v1/skills"), + ("GET", "/v1/skills"), + ("GET", "/v1/skills/skill_1"), + ("POST", "/v1/skills/skill_1"), + ("DELETE", "/v1/skills/skill_1"), + ("GET", "/v1/skills/skill_1/content"), + ("POST", "/v1/skills/skill_1/versions"), + ("GET", "/v1/skills/skill_1/versions"), + ("GET", "/v1/skills/skill_1/versions/1"), + ("DELETE", "/v1/skills/skill_1/versions/1"), + ("GET", "/v1/skills/skill_1/versions/1/content"), + ] + assert actual_requests == expected_requests, actual_requests + assert dict(requests[1].url.params) == {"after": "cursor", "limit": "2", "order": "desc"} + assert json.loads(requests[3].content) == {"default_version": 2} + assert b'name="files[]"' in requests[0].content + assert b'name="default"' in requests[6].content + assert b"SKILL.md" in requests[0].content + assert dict(requests[7].url.params) == {"after": "cursor", "limit": "2", "order": "desc"} + assert results[5].response.content == b"skill archive" + assert results[10].response.content == b"skill archive" + assert results[0].id == "skill_1" + assert results[1].data[0].id == "skill_1" + assert results[4].deleted is True + assert results[6].skill_id == "skill_1" + assert results[9].deleted is True + + +@pytest.mark.asyncio +async def test_azure_uses_preview_header_with_existing_sdk_client() -> None: + requests: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return _mock_response(request) + + client = AsyncOpenAI( + api_key="test", + base_url="https://resource.openai.azure.com/openai/v1", + http_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)), + ) + try: + await _call("get", client, provider="azure") + finally: + await GLOBAL_LOGGING_WORKER.flush() + await client.close() + + assert requests[0].headers["foundry-features"] == "Skills=V1Preview" + assert requests[0].headers["x-test-header"] == "present" + + +@pytest.mark.asyncio +async def test_router_model_configuration_overrides_request_provider() -> None: + requests: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return _mock_response(request) + + client = AsyncOpenAI( + api_key="test", + base_url="https://api.openai.test/v1", + http_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)), + ) + router = Router( + model_list=[ + { + "model_name": "skills-openai", + "litellm_params": { + "model": "openai/gpt-5", + "api_key": "test", + }, + } + ] + ) + try: + with patch.object(openai_files_instance, "get_openai_client", return_value=client) as get_client: + await router.acreate_skill( + model="skills-openai", + files=[("SKILL.md", b"skill")], + custom_llm_provider="anthropic", + ) + finally: + await GLOBAL_LOGGING_WORKER.flush() + await client.close() + + assert requests[0].url.path == "/v1/skills" + assert get_client.call_args.kwargs["api_key"] == "test" + + +@pytest.mark.parametrize( + ("body_model", "query", "header_model", "expected"), + [ + ("body", "model=query", "header", "body"), + (None, "model=query", "header", "query"), + (None, "", "header", "header"), + ], +) +@pytest.mark.asyncio +async def test_native_endpoint_model_priority( + body_model: str | None, + query: str, + header_model: str, + expected: str, +) -> None: + body = json.dumps({"model": body_model} if body_model else {}).encode() + + async def receive() -> dict[str, Any]: + return {"type": "http.request", "body": body, "more_body": False} + + request = Request( + { + "type": "http", + "method": "POST", + "path": "/v1/skills/skill_1", + "headers": [(b"content-type", b"application/json"), (b"x-litellm-model", header_model.encode())], + "query_string": query.encode(), + "path_params": {"skill_id": "skill_1"}, + }, + receive, + ) + + data = await _native_skill_data(request, "update") + + assert data["model"] == expected + assert data["skill_id"] == "skill_1" + assert data["custom_llm_provider"] == "openai" + assert data["_skill_operation"] == "update" + + +@pytest.mark.parametrize( + ("api_base", "expected"), + [ + ("https://resource.openai.azure.com", "https://resource.openai.azure.com"), + ("https://resource.openai.azure.com/openai", "https://resource.openai.azure.com"), + ("https://resource.openai.azure.com/openai/v1", "https://resource.openai.azure.com"), + ("https://resource.openai.azure.com/openai/responses?api-version=preview", "https://resource.openai.azure.com"), + ("https://resource.openai.azure.com/openai/v1/responses", "https://resource.openai.azure.com"), + ], +) +def test_azure_skills_api_base(api_base: str, expected: str) -> None: + assert _azure_skills_api_base(api_base) == expected + + +def test_native_routes_are_registered_without_anthropic_response_coercion() -> None: + expected = { + ("POST", "/v1/skills/{skill_id}"), + ("GET", "/v1/skills/{skill_id}/content"), + ("POST", "/v1/skills/{skill_id}/versions"), + ("GET", "/v1/skills/{skill_id}/versions"), + ("GET", "/v1/skills/{skill_id}/versions/{version}"), + ("DELETE", "/v1/skills/{skill_id}/versions/{version}"), + ("GET", "/v1/skills/{skill_id}/versions/{version}/content"), + } + routes = [route for route in skills_router.routes if isinstance(route, APIRoute)] + + assert expected <= {(method, route.path) for route in routes for method in route.methods} + assert all(route.response_model is None for route in routes)