diff --git a/README.md b/README.md index 31a93bf8c67..b9f978849fa 100644 --- a/README.md +++ b/README.md @@ -465,6 +465,7 @@ Set `LITELLM_PROXY_API_BASE` and `LITELLM_PROXY_API_KEY` and every model call th | [Together AI (`together_ai`)](https://docs.litellm.ai/docs/providers/togetherai) | ✅ | ✅ | ✅ | | | | | | | | | [Topaz (`topaz`)](https://docs.litellm.ai/docs/providers/topaz) | ✅ | ✅ | ✅ | | | | | | | | | [Triton (`triton`)](https://docs.litellm.ai/docs/providers/triton-inference-server) | ✅ | ✅ | ✅ | | | | | | | | +| [Tsubasa (`tsubasa`)](https://docs.litellm.ai/docs/providers/tsubasa) | Yes | | | | | | | | | | | [Typesafe Decisions API (`typesafe`)](https://docs.litellm.ai/docs/providers) | | | | | | | | | | | | [V0 (`v0`)](https://docs.litellm.ai/docs/providers/v0) | ✅ | ✅ | ✅ | | | | | | | | | [Vercel AI Gateway (`vercel_ai_gateway`)](https://docs.litellm.ai/docs/providers/vercel_ai_gateway) | ✅ | ✅ | ✅ | | | | | | | | diff --git a/docs/my-website/docs/providers/tsubasa.md b/docs/my-website/docs/providers/tsubasa.md new file mode 100644 index 00000000000..13748b94683 --- /dev/null +++ b/docs/my-website/docs/providers/tsubasa.md @@ -0,0 +1,46 @@ +# Tsubasa + +Tsubasa uses LiteLLM's existing OpenAI-compatible Chat Completions transport + +Use an active Tsubasa API key for text chat. This integration does not declare +tool-calling, structured-output, vision, or reasoning support. Hosted inference +has not been qualified through this integration + +## API key + +Set `TSUBASA_API_KEY` in the process environment. LiteLLM resolves the public +endpoint automatically; no `api_base` override is required + +## Completion + +Select a public model with the `tsubasa/` provider prefix + +```python +from litellm import completion + +response = completion( + model="tsubasa/tsubasa-pro", + messages=[{"role": "user", "content": "Explain binary search."}], + max_tokens=256, +) +``` + +Use `tsubasa/tsubasa-fast` for the other public model. Both use +`https://api.tsubasa.sh/v1/chat/completions` + +## Streaming + +Set `stream=True` to receive Chat Completions chunks + +```python +stream = completion( + model="tsubasa/tsubasa-fast", + messages=[{"role": "user", "content": "Explain a stack."}], + stream=True, +) +for chunk in stream: + print(chunk.choices[0].delta.content or "", end="") +``` + +The integration does not declare Responses, embeddings, image, or audio support. +See [Tsubasa's API documentation](https://tsubasa.sh/docs) for availability diff --git a/litellm/constants.py b/litellm/constants.py index b43f6d9f358..585082c4958 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -1172,6 +1172,7 @@ openai_compatible_providers: Final[list[str]] = [ "pinstripes", # Pinstripes - JSON-configured provider "openinfer", "darkbloom", + "tsubasa", "meta", # Meta Model API (Muse Spark) - JSON-configured provider "cognition", "coralbricks", diff --git a/litellm/llms/openai_like/providers.json b/litellm/llms/openai_like/providers.json index f937e4d2f75..c40e504dfa9 100644 --- a/litellm/llms/openai_like/providers.json +++ b/litellm/llms/openai_like/providers.json @@ -30,6 +30,13 @@ "base_url": "https://api.scaleway.ai/v1", "api_key_env": "SCW_SECRET_KEY" }, + "tsubasa": { + "base_url": "https://api.tsubasa.sh/v1", + "api_key_env": "TSUBASA_API_KEY", + "param_mappings": { + "max_completion_tokens": "max_tokens" + } + }, "synthetic": { "base_url": "https://api.synthetic.new/openai/v1", "api_key_env": "SYNTHETIC_API_KEY", diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 84609da226f..385e630c05b 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -49845,6 +49845,34 @@ "mode": "embedding", "output_cost_per_token": 0.0 }, + "tsubasa/tsubasa-fast": { + "max_input_tokens": 32768, + "max_output_tokens": 8192, + "input_cost_per_token": 2.0000000000000002e-07, + "output_cost_per_token": 1e-06, + "litellm_provider": "tsubasa", + "mode": "chat", + "supports_system_messages": true, + "supports_function_calling": false, + "supports_parallel_function_calling": false, + "supports_response_schema": false, + "supports_vision": false, + "supports_reasoning": false + }, + "tsubasa/tsubasa-pro": { + "max_input_tokens": 32768, + "max_output_tokens": 16384, + "input_cost_per_token": 5e-07, + "output_cost_per_token": 4e-06, + "litellm_provider": "tsubasa", + "mode": "chat", + "supports_system_messages": true, + "supports_function_calling": false, + "supports_parallel_function_calling": false, + "supports_response_schema": false, + "supports_vision": false, + "supports_reasoning": false + }, "together_ai/BAAI/bge-base-en-v1.5": { "input_cost_per_token": 8e-09, "litellm_provider": "together_ai", diff --git a/litellm/provider_endpoints_support_backup.json b/litellm/provider_endpoints_support_backup.json index 00fee0a0d5a..1f59baa409c 100644 --- a/litellm/provider_endpoints_support_backup.json +++ b/litellm/provider_endpoints_support_backup.json @@ -4280,6 +4280,32 @@ "generateContent": false } }, + "tsubasa": { + "display_name": "Tsubasa (`tsubasa`)", + "url": "https://docs.litellm.ai/docs/providers/tsubasa", + "endpoints": { + "chat_completions": true, + "responses": true, + "text_completion": true, + "embeddings": false, + "image_generations": false, + "image_edits": false, + "audio_transcriptions": false, + "audio_speech": false, + "rerank": false, + "ocr": false, + "search": false, + "moderations": false, + "realtime": false, + "video_generations": false, + "batches": false, + "messages": true, + "interactions": true, + "systemone": false, + "a2a": true, + "generateContent": true + } + }, "v0": { "display_name": "V0 (`v0`)", "url": "https://docs.litellm.ai/docs/providers/v0", diff --git a/litellm/proxy/public_endpoints/provider_create_fields.json b/litellm/proxy/public_endpoints/provider_create_fields.json index 5afc3908820..8297b42554f 100644 --- a/litellm/proxy/public_endpoints/provider_create_fields.json +++ b/litellm/proxy/public_endpoints/provider_create_fields.json @@ -4003,6 +4003,34 @@ ], "default_model_placeholder": "typesafe/jev-latest" }, + { + "provider": "Tsubasa", + "provider_display_name": "Tsubasa", + "litellm_provider": "tsubasa", + "credential_fields": [ + { + "key": "api_key", + "label": "API Key", + "placeholder": null, + "tooltip": "Your Tsubasa API key", + "required": true, + "field_type": "password", + "options": null, + "default_value": null + }, + { + "key": "api_base", + "label": "API Base", + "placeholder": "https://api.tsubasa.sh/v1", + "tooltip": "Optional: override the Tsubasa API base URL", + "required": false, + "field_type": "text", + "options": null, + "default_value": "https://api.tsubasa.sh/v1" + } + ], + "default_model_placeholder": "tsubasa/tsubasa-pro" + }, { "provider": "V0", "provider_display_name": "V0", diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 755a0a8cc7a..25b724e629d 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -4791,6 +4791,7 @@ class LlmProviders(str, Enum): HOSTINGER_ROUTER = "hostinger_router" ZEROGPU = "zerogpu" DARKBLOOM = "darkbloom" + TSUBASA = "tsubasa" META = "meta" SAIL = "sail" REKA = "reka" diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 84609da226f..385e630c05b 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -49845,6 +49845,34 @@ "mode": "embedding", "output_cost_per_token": 0.0 }, + "tsubasa/tsubasa-fast": { + "max_input_tokens": 32768, + "max_output_tokens": 8192, + "input_cost_per_token": 2.0000000000000002e-07, + "output_cost_per_token": 1e-06, + "litellm_provider": "tsubasa", + "mode": "chat", + "supports_system_messages": true, + "supports_function_calling": false, + "supports_parallel_function_calling": false, + "supports_response_schema": false, + "supports_vision": false, + "supports_reasoning": false + }, + "tsubasa/tsubasa-pro": { + "max_input_tokens": 32768, + "max_output_tokens": 16384, + "input_cost_per_token": 5e-07, + "output_cost_per_token": 4e-06, + "litellm_provider": "tsubasa", + "mode": "chat", + "supports_system_messages": true, + "supports_function_calling": false, + "supports_parallel_function_calling": false, + "supports_response_schema": false, + "supports_vision": false, + "supports_reasoning": false + }, "together_ai/BAAI/bge-base-en-v1.5": { "input_cost_per_token": 8e-09, "litellm_provider": "together_ai", diff --git a/provider_endpoints_support.json b/provider_endpoints_support.json index 00fee0a0d5a..1f59baa409c 100644 --- a/provider_endpoints_support.json +++ b/provider_endpoints_support.json @@ -4280,6 +4280,32 @@ "generateContent": false } }, + "tsubasa": { + "display_name": "Tsubasa (`tsubasa`)", + "url": "https://docs.litellm.ai/docs/providers/tsubasa", + "endpoints": { + "chat_completions": true, + "responses": true, + "text_completion": true, + "embeddings": false, + "image_generations": false, + "image_edits": false, + "audio_transcriptions": false, + "audio_speech": false, + "rerank": false, + "ocr": false, + "search": false, + "moderations": false, + "realtime": false, + "video_generations": false, + "batches": false, + "messages": true, + "interactions": true, + "systemone": false, + "a2a": true, + "generateContent": true + } + }, "v0": { "display_name": "V0 (`v0`)", "url": "https://docs.litellm.ai/docs/providers/v0", diff --git a/tests/unit/llms/openai_like/test_tsubasa_provider.py b/tests/unit/llms/openai_like/test_tsubasa_provider.py new file mode 100644 index 00000000000..1169f03b9f4 --- /dev/null +++ b/tests/unit/llms/openai_like/test_tsubasa_provider.py @@ -0,0 +1,183 @@ +import json +from typing import Final + +import httpx +import pytest +from openai import AsyncOpenAI, OpenAI + +from litellm import AuthenticationError, LlmProviders, RateLimitError, UnsupportedParamsError, acompletion, completion +from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider +from litellm.llms.openai_like.json_loader import JSONProviderRegistry +from litellm.utils import ProviderConfigManager, get_model_info, supports_response_schema + + +@pytest.mark.parametrize("model_id", ("tsubasa-fast", "tsubasa-pro")) +def test_tsubasa_routes_named_models_and_resolves_credentials(model_id: str, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("TSUBASA_API_KEY", "test-credential") + registration: Final = JSONProviderRegistry.get("tsubasa") + assert registration is not None + model, provider, api_key, api_base = get_llm_provider(model=f"tsubasa/{model_id}") + assert (model, provider, api_key, api_base) == ( + model_id, + registration.slug, + "test-credential", + registration.base_url, + ) + config: Final = ProviderConfigManager.get_provider_chat_config(model=model_id, provider=LlmProviders.TSUBASA) + assert config is not None + assert ( + config.get_complete_url(api_base, api_key, model_id, {}, {}, stream=True) + == f"{registration.base_url}/chat/completions" + ) + assert config._get_openai_compatible_provider_info(None, "explicit-credential") == ( + registration.base_url, + "explicit-credential", + ) + assert config.map_openai_params({"max_completion_tokens": 64}, {}, model_id, False) == {"max_tokens": 64} + info: Final = get_model_info(model=f"tsubasa/{model_id}") + assert info["max_output_tokens"] <= info["max_input_tokens"] + assert info["litellm_provider"] == registration.slug + + +@pytest.mark.parametrize("model_id", ("tsubasa-fast", "tsubasa-pro")) +@pytest.mark.parametrize("stream", (False, True)) +def test_tsubasa_chat_transport(model_id: str, stream: bool) -> None: + registration: Final = JSONProviderRegistry.get("tsubasa") + assert registration is not None + + def respond(request: httpx.Request) -> httpx.Response: + assert str(request.url) == f"{registration.base_url}/chat/completions" + assert request.headers["authorization"] == "Bearer test-credential" + assert json.loads(request.content) == { + "model": model_id, + "messages": [{"role": "user", "content": "Hello"}], + "max_tokens": 32, + **({"stream": True, "stream_options": {"include_usage": True}} if stream else {}), + } + result: Final = { + "id": "chatcmpl-contract", + "created": 1, + "model": model_id, + "object": "chat.completion.chunk" if stream else "chat.completion", + "choices": [ + { + "index": 0, + "finish_reason": "stop", + "delta" if stream else "message": {"role": "assistant", "content": "Hello"}, + } + ], + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, + } + if stream: + return httpx.Response( + 200, + request=request, + headers={"content-type": "text/event-stream"}, + content=f"data: {json.dumps(result)}\n\ndata: [DONE]\n\n", + ) + return httpx.Response(200, request=request, json=result) + + with httpx.Client(transport=httpx.MockTransport(respond)) as client: + result: Final = completion( + model=f"tsubasa/{model_id}", + messages=[{"role": "user", "content": "Hello"}], + api_key="test-credential", + max_completion_tokens=32, + stream=stream, + stream_options={"include_usage": True} if stream else None, + client=OpenAI(api_key="test-credential", base_url=registration.base_url, http_client=client), + ) + if stream: + chunks: Final = tuple(result) + assert "".join(chunk.choices[0].delta.content or "" for chunk in chunks if chunk.choices) == "Hello" + assert chunks[-1].usage.total_tokens == 2 + else: + assert result.choices[0].message.content == "Hello" + assert result.usage.total_tokens == 2 + + +@pytest.mark.parametrize("status, error", ((401, AuthenticationError), (429, RateLimitError))) +def test_tsubasa_preserves_upstream_error_status(status: int, error: type[Exception]) -> None: + registration: Final = JSONProviderRegistry.get("tsubasa") + assert registration is not None + + def respond(request: httpx.Request) -> httpx.Response: + return httpx.Response(status, request=request, json={"error": {"message": "Request rejected"}}) + + with httpx.Client(transport=httpx.MockTransport(respond)) as client, pytest.raises(error) as raised: + completion( + model="tsubasa/tsubasa-pro", + messages=[{"role": "user", "content": "Hello"}], + api_key="test-credential", + num_retries=0, + client=OpenAI(api_key="test-credential", base_url=registration.base_url, http_client=client, max_retries=0), + ) + assert raised.value.status_code == status + + +@pytest.mark.parametrize("model_id", ("tsubasa-fast", "tsubasa-pro")) +def test_tsubasa_rejects_tools_while_qualification_is_pending(model_id: str) -> None: + with pytest.raises(UnsupportedParamsError, match="tools"): + completion( + model=f"tsubasa/{model_id}", + messages=[{"role": "user", "content": "Hello"}], + api_key="test-credential", + tools=[{"type": "function", "function": {"name": "get_weather", "parameters": {"type": "object"}}}], + ) + + +async def test_tsubasa_forwards_json_schema_without_declaring_qualified_support() -> None: + registration: Final = JSONProviderRegistry.get("tsubasa") + assert registration is not None + response_format: Final = { + "type": "json_schema", + "json_schema": { + "name": "answer", + "strict": True, + "schema": { + "type": "object", + "properties": {"answer": {"type": "string"}}, + "required": ["answer"], + "additionalProperties": False, + }, + }, + } + + def respond(request: httpx.Request) -> httpx.Response: + assert str(request.url) == f"{registration.base_url}/chat/completions" + assert request.headers["authorization"] == "Bearer test-credential" + assert json.loads(request.content) == { + "model": "tsubasa-pro", + "messages": [{"role": "user", "content": "Hello"}], + "response_format": response_format, + } + return httpx.Response( + 200, + json={ + "id": "chatcmpl-contract", + "created": 1, + "model": "tsubasa-pro", + "object": "chat.completion", + "choices": [ + { + "index": 0, + "finish_reason": "stop", + "message": { + "role": "assistant", + "content": '{"answer":"Hello"}', + }, + } + ], + }, + ) + + async with httpx.AsyncClient(transport=httpx.MockTransport(respond)) as client: + result: Final = await acompletion( + model="tsubasa/tsubasa-pro", + messages=[{"role": "user", "content": "Hello"}], + response_format=response_format, + api_key="test-credential", + client=AsyncOpenAI(api_key="test-credential", base_url=registration.base_url, http_client=client), + ) + assert json.loads(result.choices[0].message.content) == {"answer": "Hello"} + assert supports_response_schema("tsubasa/tsubasa-pro") is False diff --git a/tests/unit/proxy/public_endpoints/test_provider_fields.py b/tests/unit/proxy/public_endpoints/test_provider_fields.py new file mode 100644 index 00000000000..c7017f57c4a --- /dev/null +++ b/tests/unit/proxy/public_endpoints/test_provider_fields.py @@ -0,0 +1,46 @@ +from typing import Final + +import httpx +from fastapi import FastAPI + +from litellm.llms.openai_like.json_loader import JSONProviderRegistry +from litellm.proxy.public_endpoints.public_endpoints import router +from litellm.types.proxy.public_endpoints.public_endpoints import ProviderCreateInfo, SupportedEndpointsResponse +from litellm.utils import get_model_info + + +async def test_tsubasa_dashboard_fields_resolve_to_a_registered_model() -> None: + app: Final = FastAPI() + app.include_router(router) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app), base_url="http://test") as client: + response: Final = await client.get("/public/providers/fields") + endpoints_response: Final = await client.get("/public/endpoints") + assert response.status_code == 200, response.text + providers: Final = tuple(ProviderCreateInfo.model_validate(entry) for entry in response.json()) + matches: Final = tuple(provider for provider in providers if provider.litellm_provider == "tsubasa") + assert len(matches) == 1 + provider: Final = matches[0] + assert provider.provider == provider.provider_display_name == "Tsubasa" + assert provider.default_model_placeholder is not None + assert get_model_info(provider.default_model_placeholder)["litellm_provider"] == provider.litellm_provider + registration: Final = JSONProviderRegistry.get(provider.litellm_provider) + assert registration is not None + assert { + field.key: (field.field_type, field.required, field.default_value) for field in provider.credential_fields + } == { + "api_key": ("password", True, None), + "api_base": ("text", False, registration.base_url), + } + assert endpoints_response.status_code == 200, endpoints_response.text + endpoints: Final = SupportedEndpointsResponse.model_validate(endpoints_response.json()).endpoints + assert { + endpoint.key for endpoint in endpoints if any(p.slug == provider.litellm_provider for p in endpoint.providers) + } == { + "chat_completions", + "responses", + "text_completion", + "messages", + "interactions", + "a2a", + "generateContent", + } diff --git a/ui/litellm-dashboard/src/components/provider_info_helpers.test.tsx b/ui/litellm-dashboard/src/components/provider_info_helpers.test.tsx index 6a78fe9441a..68a704df2ae 100644 --- a/ui/litellm-dashboard/src/components/provider_info_helpers.test.tsx +++ b/ui/litellm-dashboard/src/components/provider_info_helpers.test.tsx @@ -11,16 +11,33 @@ import { resolveLitellmProviderSlug, } from "./provider_info_helpers"; -const PROVIDER_CREATE_FIELDS: { provider: string; provider_display_name: string; litellm_provider: string }[] = - JSON.parse( - readFileSync(resolve(__dirname, "../../../../litellm/proxy/public_endpoints/provider_create_fields.json"), "utf8"), - ); +const PROVIDER_CREATE_FIELDS: { + provider: string; + litellm_provider: string; + default_model_placeholder: string; + provider_display_name: string; +}[] = JSON.parse( + readFileSync(resolve(__dirname, "../../../../litellm/proxy/public_endpoints/provider_create_fields.json"), "utf8"), +); const BUNDLED_MODEL_MAP: Record = JSON.parse( readFileSync(resolve(__dirname, "../../../../model_prices_and_context_window.json"), "utf8"), ); describe("provider_info_helpers", () => { + it("resolves the Tsubasa dashboard selection to its models and placeholder", () => { + const provider = PROVIDER_CREATE_FIELDS.find((entry) => entry.litellm_provider === "tsubasa")!; + const models = { + "tsubasa/tsubasa-fast": { litellm_provider: provider.litellm_provider }, + "tsubasa/tsubasa-pro": { litellm_provider: provider.litellm_provider }, + "openai/gpt-4o": { litellm_provider: "openai" }, + }; + + expect(getProviderModels(provider.provider, models)).toEqual(["tsubasa/tsubasa-fast", "tsubasa/tsubasa-pro"]); + expect(getPlaceholder(provider.provider)).toBe(provider.default_model_placeholder); + expect(getProviderLogoAndName(provider.litellm_provider).displayName).toBe(provider.provider_display_name); + }); + it("should identify the upstream LiteLLM Gateway consistently with provider metadata", () => { const metadata = PROVIDER_CREATE_FIELDS.find((provider) => provider.provider === "LITELLM_PROXY"); expect(resolveLitellmProviderSlug("LITELLM_PROXY")).toBe(metadata?.litellm_provider); @@ -282,6 +299,7 @@ describe("provider_info_helpers", () => { Providers.PREDIBASE, Providers.Sail, Providers.StreamLake, + Providers.Tsubasa, Providers.WANDB, Providers.ZAI, Providers.ZeroGPU, diff --git a/ui/litellm-dashboard/src/components/provider_info_helpers.tsx b/ui/litellm-dashboard/src/components/provider_info_helpers.tsx index 8efd42f52e5..ad92c53c601 100644 --- a/ui/litellm-dashboard/src/components/provider_info_helpers.tsx +++ b/ui/litellm-dashboard/src/components/provider_info_helpers.tsx @@ -193,6 +193,7 @@ export enum Providers { TogetherAI = "TogetherAI", TOPAZ = "Topaz", Triton = "Triton", + Tsubasa = "Tsubasa", TypeSafe = "TypeSafe", Nace = "Nace", PARALLEL_AI = "Parallel AI", @@ -330,6 +331,7 @@ export const provider_map: Record = { TogetherAI: "together_ai", TOPAZ: "topaz", Triton: "triton", + Tsubasa: "tsubasa", TypeSafe: "typesafe", Nace: "nace", PARALLEL_AI: "parallel_ai", @@ -522,6 +524,7 @@ const providerPlaceholderMap: Partial> = { [Providers.Bespoke]: "bespoke/nimble", [Providers.Laya]: "laya/english", [Providers.Snowflake]: "snowflake/mistral-7b", + [Providers.Tsubasa]: "tsubasa/tsubasa-pro", [Providers.StrandsDecider]: "strands_decider/strands-decider-2B-hobson-v19", [Providers.Tencent]: "tencent/deepseek-v4-pro", [Providers.TypeSafe]: "typesafe/jev-latest",