From e0e15346bf2f159e25ac9cb87f0a3b49b79e0d8c Mon Sep 17 00:00:00 2001 From: litellm-gtm Date: Fri, 17 Jul 2026 21:53:44 +0530 Subject: [PATCH 1/4] feat: add LiteLLM provider support --- docutranslate/agents/provider/provider.py | 4 +++- docutranslate/agents/thinking/thinking_factory.py | 5 ++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/docutranslate/agents/provider/provider.py b/docutranslate/agents/provider/provider.py index 0f508197..379f74cf 100644 --- a/docutranslate/agents/provider/provider.py +++ b/docutranslate/agents/provider/provider.py @@ -1,6 +1,6 @@ from typing import TypeAlias, Literal -ProviderType: TypeAlias = Literal["minimax", "ollama", "bigmodel", "aliyuncs", "volces", "google", "siliconflow", "deepseek", "default"] +ProviderType: TypeAlias = Literal["minimax", "ollama", "bigmodel", "aliyuncs", "volces", "google", "siliconflow", "deepseek", "litellm", "default"] def get_provider_by_domain(domain:str)->ProviderType: if domain == "open.bigmodel.cn": @@ -15,4 +15,6 @@ def get_provider_by_domain(domain:str)->ProviderType: return "siliconflow" elif domain == "api.deepseek.com": return "deepseek" + elif "litellm" in domain or domain in ("localhost", "127.0.0.1"): + return "litellm" return "default" \ No newline at end of file diff --git a/docutranslate/agents/thinking/thinking_factory.py b/docutranslate/agents/thinking/thinking_factory.py index 4cbffaae..660da4d7 100644 --- a/docutranslate/agents/thinking/thinking_factory.py +++ b/docutranslate/agents/thinking/thinking_factory.py @@ -2,7 +2,7 @@ from docutranslate.agents.provider import ProviderType -ModeType: TypeAlias = Literal["ollama", "bigmodel", "aliyuncs", "volces", "google", "siliconflow", "deepseek", "default"] +ModeType: TypeAlias = Literal["ollama", "bigmodel", "aliyuncs", "volces", "google", "siliconflow", "deepseek", "litellm", "default"] ThinkingField: TypeAlias = str EnableValueType: TypeAlias = str | dict[str, Any] | bool DisableValueType: TypeAlias = str | dict[str, Any] | bool @@ -23,6 +23,7 @@ "google": ("reasoning_effort", "medium", "none"), "siliconflow": ("enable_thinking", True, False), "deepseek": ("thinking", {"type": "enabled"}, {"type": "disabled"}), + "litellm": ("reasoning_effort", "medium", "none"), "default": ("reasoning_effort", "medium", "none"), } @@ -55,4 +56,6 @@ def get_thinking_mode(provider: ProviderType, model_id: str) -> ThinkingConfig: return thinking_mode["ollama"] elif provider == "deepseek": return thinking_mode["deepseek"] + elif provider == "litellm": + return get_thinking_mode_by_model_id(model_id) return get_thinking_mode_by_model_id(model_id) From 507e78c6f5f2d78d605e7464de0a4bc88b0422bf Mon Sep 17 00:00:00 2001 From: litellm-gtm Date: Fri, 17 Jul 2026 23:02:29 +0530 Subject: [PATCH 2/4] test: add 12 tests + fix localhost detection bug --- docutranslate/agents/provider/provider.py | 2 +- tests/test_litellm_provider.py | 62 +++++++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 tests/test_litellm_provider.py diff --git a/docutranslate/agents/provider/provider.py b/docutranslate/agents/provider/provider.py index 379f74cf..458d3746 100644 --- a/docutranslate/agents/provider/provider.py +++ b/docutranslate/agents/provider/provider.py @@ -15,6 +15,6 @@ def get_provider_by_domain(domain:str)->ProviderType: return "siliconflow" elif domain == "api.deepseek.com": return "deepseek" - elif "litellm" in domain or domain in ("localhost", "127.0.0.1"): + elif "litellm" in domain: return "litellm" return "default" \ No newline at end of file diff --git a/tests/test_litellm_provider.py b/tests/test_litellm_provider.py new file mode 100644 index 00000000..f4faa59d --- /dev/null +++ b/tests/test_litellm_provider.py @@ -0,0 +1,62 @@ +"""Tests for LiteLLM provider integration.""" + +from docutranslate.agents.provider.provider import get_provider_by_domain, ProviderType +from docutranslate.agents.thinking.thinking_factory import get_thinking_mode, thinking_mode + + +class TestProviderDetection: + + def test_litellm_in_hostname(self): + assert get_provider_by_domain("litellm.example.com") == "litellm" + + def test_litellm_subdomain(self): + assert get_provider_by_domain("my-litellm-proxy.internal") == "litellm" + + def test_localhost_not_matched_as_litellm(self): + """localhost should NOT be auto-detected as litellm - it could be Ollama or anything.""" + assert get_provider_by_domain("localhost") == "default" + + def test_127_not_matched_as_litellm(self): + assert get_provider_by_domain("127.0.0.1") == "default" + + def test_existing_providers_unaffected(self): + assert get_provider_by_domain("open.bigmodel.cn") == "bigmodel" + assert get_provider_by_domain("dashscope.aliyuncs.com") == "aliyuncs" + assert get_provider_by_domain("api.deepseek.com") == "deepseek" + assert get_provider_by_domain("generativelanguage.googleapis.com") == "google" + assert get_provider_by_domain("api.siliconflow.cn") == "siliconflow" + assert get_provider_by_domain("ark.cn-beijing.volces.com") == "volces" + + def test_unknown_domain_returns_default(self): + assert get_provider_by_domain("api.example.com") == "default" + assert get_provider_by_domain("random.host.io") == "default" + + +class TestThinkingMode: + + def test_litellm_in_thinking_mode_dict(self): + assert "litellm" in thinking_mode + + def test_litellm_thinking_delegates_to_model_id(self): + """LiteLLM proxies multiple providers, so thinking mode should be + resolved by model name rather than a fixed provider config.""" + result = get_thinking_mode("litellm", "qwen-plus") + assert result is not None + assert result == thinking_mode["aliyuncs"] + + def test_litellm_thinking_gemini_model(self): + result = get_thinking_mode("litellm", "gemini-2.5-flash") + assert result == thinking_mode["google"] + + def test_litellm_thinking_glm_model(self): + result = get_thinking_mode("litellm", "glm-4-plus") + assert result == thinking_mode["bigmodel"] + + def test_litellm_thinking_unknown_model_uses_default(self): + result = get_thinking_mode("litellm", "some-random-model") + assert result == thinking_mode["default"] + + def test_litellm_type_in_provider_type(self): + """Verify 'litellm' is a valid ProviderType literal value.""" + provider: ProviderType = "litellm" + assert provider == "litellm" From 4f0c220e9b91576d855d906fa05dd4efb5c6e6fa Mon Sep 17 00:00:00 2001 From: litellm-gtm Date: Sat, 18 Jul 2026 00:29:45 +0530 Subject: [PATCH 3/4] fix: remove hostname auto-detection, litellm set via --provider flag --- docutranslate/agents/provider/provider.py | 2 -- tests/test_litellm_provider.py | 18 +++--------------- 2 files changed, 3 insertions(+), 17 deletions(-) diff --git a/docutranslate/agents/provider/provider.py b/docutranslate/agents/provider/provider.py index 458d3746..f0fda053 100644 --- a/docutranslate/agents/provider/provider.py +++ b/docutranslate/agents/provider/provider.py @@ -15,6 +15,4 @@ def get_provider_by_domain(domain:str)->ProviderType: return "siliconflow" elif domain == "api.deepseek.com": return "deepseek" - elif "litellm" in domain: - return "litellm" return "default" \ No newline at end of file diff --git a/tests/test_litellm_provider.py b/tests/test_litellm_provider.py index f4faa59d..6f410a81 100644 --- a/tests/test_litellm_provider.py +++ b/tests/test_litellm_provider.py @@ -6,18 +6,11 @@ class TestProviderDetection: - def test_litellm_in_hostname(self): - assert get_provider_by_domain("litellm.example.com") == "litellm" - - def test_litellm_subdomain(self): - assert get_provider_by_domain("my-litellm-proxy.internal") == "litellm" - - def test_localhost_not_matched_as_litellm(self): - """localhost should NOT be auto-detected as litellm - it could be Ollama or anything.""" + def test_litellm_not_autodetected(self): + """LiteLLM runs on any host - it's set via --provider litellm, not auto-detected.""" assert get_provider_by_domain("localhost") == "default" - - def test_127_not_matched_as_litellm(self): assert get_provider_by_domain("127.0.0.1") == "default" + assert get_provider_by_domain("my-proxy.internal") == "default" def test_existing_providers_unaffected(self): assert get_provider_by_domain("open.bigmodel.cn") == "bigmodel" @@ -27,10 +20,6 @@ def test_existing_providers_unaffected(self): assert get_provider_by_domain("api.siliconflow.cn") == "siliconflow" assert get_provider_by_domain("ark.cn-beijing.volces.com") == "volces" - def test_unknown_domain_returns_default(self): - assert get_provider_by_domain("api.example.com") == "default" - assert get_provider_by_domain("random.host.io") == "default" - class TestThinkingMode: @@ -57,6 +46,5 @@ def test_litellm_thinking_unknown_model_uses_default(self): assert result == thinking_mode["default"] def test_litellm_type_in_provider_type(self): - """Verify 'litellm' is a valid ProviderType literal value.""" provider: ProviderType = "litellm" assert provider == "litellm" From 3a7e7e2e3126654948eb37f2df9dbffb239e5cb0 Mon Sep 17 00:00:00 2001 From: litellm-gtm Date: Sat, 18 Jul 2026 01:28:48 +0530 Subject: [PATCH 4/4] feat: add litellm SDK code path for sync and async calls --- docutranslate/agents/agent.py | 114 ++++++++++++++++++++++++---------- 1 file changed, 82 insertions(+), 32 deletions(-) diff --git a/docutranslate/agents/agent.py b/docutranslate/agents/agent.py index 39913a71..5878ed97 100644 --- a/docutranslate/agents/agent.py +++ b/docutranslate/agents/agent.py @@ -439,6 +439,44 @@ def _add_thinking_mode(self, data: dict): # 普通字段直接设置 data[field_thinking] = value + def _call_litellm_sync(self, data: dict) -> dict: + """Call litellm.completion() synchronously. Returns OpenAI-format response dict.""" + import litellm + kwargs = { + "model": data["model"], + "messages": data["messages"], + "temperature": data.get("temperature"), + "top_p": data.get("top_p"), + "stream": False, + "drop_params": True, + } + if self.key and self.key != "xx": + kwargs["api_key"] = self.key + if data.get("response_format"): + kwargs["response_format"] = data["response_format"] + kwargs = {k: v for k, v in kwargs.items() if v is not None} + response = litellm.completion(**kwargs) + return response.model_dump() + + async def _call_litellm_async(self, data: dict) -> dict: + """Call litellm.acompletion() asynchronously. Returns OpenAI-format response dict.""" + import litellm + kwargs = { + "model": data["model"], + "messages": data["messages"], + "temperature": data.get("temperature"), + "top_p": data.get("top_p"), + "stream": False, + "drop_params": True, + } + if self.key and self.key != "xx": + kwargs["api_key"] = self.key + if data.get("response_format"): + kwargs["response_format"] = data["response_format"] + kwargs = {k: v for k, v in kwargs.items() if v is not None} + response = await litellm.acompletion(**kwargs) + return response.model_dump() + def _prepare_request_data( self, prompt: str, system_prompt: str, temperature=None, top_p=None, json_format=False ): @@ -526,14 +564,17 @@ async def _continue_fetch_async( headers, data = self._prepare_request_data(continue_prompt, system_prompt, json_format=force_json) try: - response = await client.post( - f"{self.baseurl}/chat/completions", - json=data, - headers=headers, - timeout=self.timeout, - ) - response.raise_for_status() - response_data = _parse_response_json(response) + if self.provider == "litellm": + response_data = await self._call_litellm_async(data) + else: + response = await client.post( + f"{self.baseurl}/chat/completions", + json=data, + headers=headers, + timeout=self.timeout, + ) + response.raise_for_status() + response_data = _parse_response_json(response) # 安全提取 choices 和 content choices = response_data.get("choices", []) @@ -640,14 +681,17 @@ async def send_async( input_tokens = 0 output_tokens = 0 try: - response = await client.post( - f"{self.baseurl}/chat/completions", - json=data, - headers=headers, - timeout=self.timeout, - ) - response.raise_for_status() - response_data = _parse_response_json(response) + if self.provider == "litellm": + response_data = await self._call_litellm_async(data) + else: + response = await client.post( + f"{self.baseurl}/chat/completions", + json=data, + headers=headers, + timeout=self.timeout, + ) + response.raise_for_status() + response_data = _parse_response_json(response) # 检查 finish_reason choices = response_data.get("choices", []) @@ -950,14 +994,17 @@ def _continue_fetch( headers, data = self._prepare_request_data(continue_prompt, system_prompt, json_format=force_json) try: - response = client.post( - f"{self.baseurl}/chat/completions", - json=data, - headers=headers, - timeout=self.timeout, - ) - response.raise_for_status() - response_data = _parse_response_json(response) + if self.provider == "litellm": + response_data = self._call_litellm_sync(data) + else: + response = client.post( + f"{self.baseurl}/chat/completions", + json=data, + headers=headers, + timeout=self.timeout, + ) + response.raise_for_status() + response_data = _parse_response_json(response) # 安全提取 choices 和 content choices = response_data.get("choices", []) @@ -1058,14 +1105,17 @@ def send( current_partial_result = None try: - response = client.post( - f"{self.baseurl}/chat/completions", - json=data, - headers=headers, - timeout=self.timeout, - ) - response.raise_for_status() - response_data = _parse_response_json(response) + if self.provider == "litellm": + response_data = self._call_litellm_sync(data) + else: + response = client.post( + f"{self.baseurl}/chat/completions", + json=data, + headers=headers, + timeout=self.timeout, + ) + response.raise_for_status() + response_data = _parse_response_json(response) # 检查 finish_reason choices = response_data.get("choices", [])