mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-03 02:22:24 +00:00
feat(providers): add Y-API as a JSON-configured OpenAI-compatible provider
Y-API is an OpenAI-compatible relay fronting DeepSeek, Z.ai, Moonshot, Tencent, Xiaomi, Qwen and OpenAI models behind one key. It also serves the Anthropic Messages API and the OpenAI Responses API. Registered through the declarative JSON registry, so no Python provider module is needed: - litellm/llms/openai_like/providers.json: base URL, YAPI_API_KEY / YAPI_API_BASE, a max_tokens -> max_completion_tokens mapping, and the three supported endpoints. - litellm/types/utils.py: LlmProviders.Y_API. - litellm/constants.py: added to openai_compatible_providers, openai_compatible_endpoints and openai_text_completion_compatible_providers. - provider_endpoints_support.json + the runtime backup: chat_completions, messages and responses true; embeddings false. - provider_create_fields.json: an Add Model form entry, without which the provider would be invisible in the dashboard. - tests: resolution, api_base override, router config, both endpoint matrices, dashboard registration, and the token-parameter rewrite. Model ids keep the upstream organization prefix, so every model string has two slashes (`y-api/deepseek/deepseek-v4-flash`). Provider resolution splits on the first slash and forwards the rest unchanged; there is a test for it. The `openai/*` models this relay fronts reject `max_tokens` outright and require `max_completion_tokens`. LiteLLM does not translate the parameter for this provider, so the mapping rewrites the caller's `max_tokens` to the name the relay expects. Both spellings then arrive identically, and a caller who sets no cap still sends none.
This commit is contained in:
parent
f4308bc124
commit
3c6a6c0cc1
7 changed files with 318 additions and 0 deletions
|
|
@ -950,6 +950,7 @@ openai_compatible_endpoints: Final[list] = [
|
|||
"https://api.cognition.ai/v1",
|
||||
"https://api.scx.ai/v1",
|
||||
"https://gigachat.devices.sberbank.ru/api/v1",
|
||||
"https://api.y-api.bestvirtualgoods.com/v1",
|
||||
]
|
||||
|
||||
|
||||
|
|
@ -1023,6 +1024,7 @@ openai_compatible_providers: Final[list] = [
|
|||
"cognition",
|
||||
"scx-ai",
|
||||
"sail",
|
||||
"y-api", # Y-API - JSON-configured provider
|
||||
]
|
||||
|
||||
OPENAI_AUDIO_TRANSCRIPTION_PROVIDERS: Final = frozenset({"openai"} | frozenset(openai_compatible_providers))
|
||||
|
|
@ -1051,6 +1053,7 @@ openai_text_completion_compatible_providers: Final[list] = [ # providers that s
|
|||
"lambda_ai",
|
||||
"hyperbolic",
|
||||
"wandb",
|
||||
"y-api",
|
||||
]
|
||||
_openai_like_providers: Final[list] = [
|
||||
"predibase",
|
||||
|
|
|
|||
|
|
@ -206,5 +206,18 @@
|
|||
"api_key_env": "SAIL_API_KEY",
|
||||
"api_base_env": "SAIL_API_BASE",
|
||||
"supported_endpoints": ["/v1/chat/completions", "/v1/responses", "/v1/messages"]
|
||||
},
|
||||
"y-api": {
|
||||
"base_url": "https://api.y-api.bestvirtualgoods.com/v1",
|
||||
"api_key_env": "YAPI_API_KEY",
|
||||
"api_base_env": "YAPI_API_BASE",
|
||||
"param_mappings": {
|
||||
"max_tokens": "max_completion_tokens"
|
||||
},
|
||||
"supported_endpoints": [
|
||||
"/v1/chat/completions",
|
||||
"/v1/responses",
|
||||
"/v1/messages"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2624,6 +2624,15 @@
|
|||
"messages": true,
|
||||
"responses": true
|
||||
}
|
||||
},
|
||||
"y-api": {
|
||||
"display_name": "Y-API (`y-api`)",
|
||||
"url": "https://docs.litellm.ai/docs/providers/y-api",
|
||||
"endpoints": {
|
||||
"chat_completions": true,
|
||||
"messages": true,
|
||||
"responses": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"endpoints": {
|
||||
|
|
|
|||
|
|
@ -3601,6 +3601,34 @@
|
|||
],
|
||||
"default_model_placeholder": "gpt-3.5-turbo"
|
||||
},
|
||||
{
|
||||
"provider": "Y_API",
|
||||
"provider_display_name": "Y-API",
|
||||
"litellm_provider": "y-api",
|
||||
"credential_fields": [
|
||||
{
|
||||
"key": "api_base",
|
||||
"label": "API Base",
|
||||
"placeholder": "https://api.y-api.bestvirtualgoods.com/v1",
|
||||
"tooltip": "Y-API base URL (defaults to https://api.y-api.bestvirtualgoods.com/v1)",
|
||||
"required": false,
|
||||
"field_type": "text",
|
||||
"options": null,
|
||||
"default_value": null
|
||||
},
|
||||
{
|
||||
"key": "api_key",
|
||||
"label": "API Key",
|
||||
"placeholder": null,
|
||||
"tooltip": null,
|
||||
"required": true,
|
||||
"field_type": "password",
|
||||
"options": null,
|
||||
"default_value": null
|
||||
}
|
||||
],
|
||||
"default_model_placeholder": "y-api/deepseek/deepseek-v4-flash"
|
||||
},
|
||||
{
|
||||
"provider": "CURSOR",
|
||||
"provider_display_name": "Cursor",
|
||||
|
|
|
|||
|
|
@ -4142,6 +4142,7 @@ class LlmProviders(str, Enum):
|
|||
DARKBLOOM = "darkbloom"
|
||||
META = "meta"
|
||||
SAIL = "sail"
|
||||
Y_API = "y-api"
|
||||
LITELLM_AGENT = "litellm_agent"
|
||||
CURSOR = "cursor"
|
||||
BEDROCK_MANTLE = "bedrock_mantle"
|
||||
|
|
|
|||
|
|
@ -3087,6 +3087,23 @@
|
|||
"rerank": false,
|
||||
"a2a": false
|
||||
}
|
||||
},
|
||||
"y-api": {
|
||||
"display_name": "Y-API (`y-api`)",
|
||||
"url": "https://docs.litellm.ai/docs/providers/y-api",
|
||||
"endpoints": {
|
||||
"chat_completions": true,
|
||||
"messages": true,
|
||||
"responses": true,
|
||||
"embeddings": false,
|
||||
"image_generations": false,
|
||||
"audio_transcriptions": false,
|
||||
"audio_speech": false,
|
||||
"moderations": false,
|
||||
"batches": false,
|
||||
"rerank": false,
|
||||
"a2a": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"endpoints": {
|
||||
|
|
|
|||
247
tests/unit/llms/openai_like/test_y_api_provider.py
Normal file
247
tests/unit/llms/openai_like/test_y_api_provider.py
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
"""
|
||||
Tests for Y-API provider configuration and integration.
|
||||
|
||||
Y-API is a JSON-configured OpenAI-compatible relay. Its model ids keep the
|
||||
upstream organization prefix, so they contain a slash of their own
|
||||
(`y-api/deepseek/deepseek-v4-flash`) -- provider resolution must split on the
|
||||
first slash only.
|
||||
"""
|
||||
|
||||
import litellm
|
||||
|
||||
BASE_URL = "https://api.y-api.bestvirtualgoods.com/v1"
|
||||
|
||||
|
||||
class TestYApiProviderConfig:
|
||||
"""Test Y-API provider configuration"""
|
||||
|
||||
def test_y_api_in_provider_list(self):
|
||||
"""Test that y-api is in the provider list"""
|
||||
from litellm import LlmProviders
|
||||
|
||||
assert hasattr(LlmProviders, "Y_API")
|
||||
assert LlmProviders.Y_API.value == "y-api"
|
||||
assert "y-api" in litellm.provider_list
|
||||
|
||||
def test_y_api_json_config_exists(self):
|
||||
"""Test that y-api is configured in providers.json"""
|
||||
from litellm.llms.openai_like.json_loader import JSONProviderRegistry
|
||||
|
||||
assert JSONProviderRegistry.exists("y-api")
|
||||
|
||||
y_api = JSONProviderRegistry.get("y-api")
|
||||
assert y_api is not None
|
||||
assert y_api.base_url == BASE_URL
|
||||
assert y_api.api_key_env == "YAPI_API_KEY"
|
||||
assert y_api.api_base_env == "YAPI_API_BASE"
|
||||
assert y_api.param_mappings == {"max_tokens": "max_completion_tokens"}
|
||||
|
||||
def test_y_api_supported_endpoints(self):
|
||||
"""Test the endpoints declared for y-api"""
|
||||
from litellm.llms.openai_like.json_loader import JSONProviderRegistry
|
||||
|
||||
y_api = JSONProviderRegistry.get("y-api")
|
||||
assert y_api is not None
|
||||
assert "/v1/chat/completions" in y_api.supported_endpoints
|
||||
assert "/v1/responses" in y_api.supported_endpoints
|
||||
assert "/v1/messages" in y_api.supported_endpoints
|
||||
assert JSONProviderRegistry.supports_responses_api("y-api") is True
|
||||
|
||||
def test_y_api_provider_resolution_with_nested_model_id(self):
|
||||
"""Test that provider resolution finds y-api and keeps the nested model id"""
|
||||
from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider
|
||||
|
||||
model, provider, api_key, api_base = get_llm_provider(
|
||||
model="y-api/deepseek/deepseek-v4-flash",
|
||||
custom_llm_provider=None,
|
||||
api_base=None,
|
||||
api_key=None,
|
||||
)
|
||||
|
||||
assert model == "deepseek/deepseek-v4-flash"
|
||||
assert provider == "y-api"
|
||||
assert api_base == BASE_URL
|
||||
|
||||
def test_y_api_api_base_override(self):
|
||||
"""Test that an explicit api_base / api_key overrides the default"""
|
||||
from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider
|
||||
|
||||
model, provider, api_key, api_base = get_llm_provider(
|
||||
model="y-api/deepseek/deepseek-v4-flash",
|
||||
custom_llm_provider=None,
|
||||
api_base="https://custom.example.com/v1",
|
||||
api_key="sk-test",
|
||||
)
|
||||
|
||||
assert provider == "y-api"
|
||||
assert api_base == "https://custom.example.com/v1"
|
||||
assert api_key == "sk-test"
|
||||
|
||||
def test_y_api_router_config(self):
|
||||
"""Test that y-api can be used in Router configuration"""
|
||||
from litellm import Router
|
||||
|
||||
router = Router(
|
||||
model_list=[
|
||||
{
|
||||
"model_name": "y-api-chat",
|
||||
"litellm_params": {
|
||||
"model": "y-api/deepseek/deepseek-v4-flash",
|
||||
"api_key": "test-key",
|
||||
},
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
assert len(router.model_list) == 1
|
||||
assert router.model_list[0]["model_name"] == "y-api-chat"
|
||||
|
||||
def test_y_api_supported_endpoints_matrix(self):
|
||||
"""The documented matrix lists y-api with the endpoints it actually serves."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import litellm as _litellm
|
||||
|
||||
matrix_path = (
|
||||
Path(_litellm.__file__).parent.parent / "provider_endpoints_support.json"
|
||||
)
|
||||
matrix = json.loads(matrix_path.read_text())
|
||||
|
||||
assert "y-api" in matrix["providers"]
|
||||
endpoints = matrix["providers"]["y-api"]["endpoints"]
|
||||
assert endpoints["chat_completions"] is True
|
||||
assert endpoints["messages"] is True
|
||||
assert endpoints["responses"] is True
|
||||
# embeddings is advertised false: GET /v1/models returns no embedding
|
||||
# model, and POST /v1/embeddings answers
|
||||
# "No available channel for model ... under group y-api (distributor)".
|
||||
assert endpoints["embeddings"] is False
|
||||
|
||||
def test_y_api_runtime_supported_endpoints_matrix(self):
|
||||
"""The runtime-served backup matrix (GET /public/supported_endpoints) lists y-api."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import litellm as _litellm
|
||||
|
||||
backup_path = (
|
||||
Path(_litellm.__file__).parent / "provider_endpoints_support_backup.json"
|
||||
)
|
||||
matrix = json.loads(backup_path.read_text())
|
||||
|
||||
assert "y-api" in matrix["providers"]
|
||||
endpoints = matrix["providers"]["y-api"]["endpoints"]
|
||||
assert endpoints["chat_completions"] is True
|
||||
assert endpoints["messages"] is True
|
||||
assert endpoints["responses"] is True
|
||||
|
||||
|
||||
class TestYApiDashboardRegistration:
|
||||
"""The Add Model form must offer Y-API.
|
||||
|
||||
`test_every_backend_provider_is_listed_in_add_model_or_frozen_as_unlisted`
|
||||
fails if a provider exists in `LlmProviders` without an entry here, so this
|
||||
is the difference between "selectable in the UI" and "silently dropped into
|
||||
the frozen unlisted set".
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _provider_create_fields():
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import litellm
|
||||
|
||||
path = (
|
||||
Path(litellm.__file__).parent
|
||||
/ "proxy"
|
||||
/ "public_endpoints"
|
||||
/ "provider_create_fields.json"
|
||||
)
|
||||
with open(path) as f:
|
||||
return json.load(f)
|
||||
|
||||
def test_y_api_is_selectable_in_the_add_model_form(self):
|
||||
entries = [
|
||||
e
|
||||
for e in self._provider_create_fields()
|
||||
if e["litellm_provider"] == "y-api"
|
||||
]
|
||||
assert (
|
||||
len(entries) == 1
|
||||
), "y-api must appear exactly once in provider_create_fields.json"
|
||||
|
||||
entry = entries[0]
|
||||
assert entry["provider"] == "Y_API"
|
||||
assert entry["provider_display_name"] == "Y-API"
|
||||
assert entry["default_model_placeholder"].startswith("y-api/")
|
||||
|
||||
fields = {f["key"]: f for f in entry["credential_fields"]}
|
||||
assert fields["api_key"]["required"] is True
|
||||
assert fields["api_key"]["field_type"] == "password"
|
||||
assert fields["api_base"]["required"] is False
|
||||
# The base URL is surfaced as the placeholder so admins can see the
|
||||
# default without it being written into config.
|
||||
assert fields["api_base"]["placeholder"] == BASE_URL
|
||||
|
||||
def test_y_api_is_returned_by_the_public_providers_fields_endpoint(self):
|
||||
"""The endpoint the Add Model dropdown actually reads from."""
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from litellm.proxy.public_endpoints.public_endpoints import router
|
||||
|
||||
app_instance = FastAPI()
|
||||
app_instance.include_router(router)
|
||||
test_client = TestClient(app_instance)
|
||||
|
||||
response = test_client.get("/public/providers/fields")
|
||||
assert response.status_code == 200
|
||||
providers = {p["litellm_provider"] for p in response.json()}
|
||||
assert "y-api" in providers
|
||||
|
||||
|
||||
class TestYApiTokenParamMapping:
|
||||
"""`max_tokens` has to reach this relay as `max_completion_tokens`.
|
||||
|
||||
The `openai/*` models y-api fronts reject the legacy name outright:
|
||||
|
||||
Unsupported parameter: 'max_tokens' is not supported with this model.
|
||||
Use 'max_completion_tokens' instead.
|
||||
|
||||
LiteLLM's `openai_gpt` base class does not translate it for this provider --
|
||||
the model ids are not in the OpenAI cost map, so the caller's `max_tokens`
|
||||
is forwarded verbatim. These tests drive the transformation the provider
|
||||
actually applies to caller params, rather than reading the config, because
|
||||
the config alone cannot show which parameter name leaves the process.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _map(**params):
|
||||
from litellm.llms.openai_like.dynamic_config import create_config_class
|
||||
from litellm.llms.openai_like.json_loader import JSONProviderRegistry
|
||||
|
||||
provider = JSONProviderRegistry.get("y-api")
|
||||
config = create_config_class(provider)()
|
||||
return config.map_openai_params(
|
||||
non_default_params=dict(params),
|
||||
optional_params={},
|
||||
model="gpt-5.6-luna",
|
||||
drop_params=False,
|
||||
)
|
||||
|
||||
def test_max_tokens_is_rewritten(self):
|
||||
mapped = self._map(max_tokens=16)
|
||||
assert mapped["max_completion_tokens"] == 16
|
||||
assert "max_tokens" not in mapped
|
||||
|
||||
def test_max_completion_tokens_passes_through(self):
|
||||
mapped = self._map(max_completion_tokens=16)
|
||||
assert mapped["max_completion_tokens"] == 16
|
||||
assert "max_tokens" not in mapped
|
||||
|
||||
def test_absent_cap_sends_neither(self):
|
||||
mapped = self._map()
|
||||
assert "max_tokens" not in mapped
|
||||
assert "max_completion_tokens" not in mapped
|
||||
Loading…
Add table
Reference in a new issue