Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 82 additions & 32 deletions docutranslate/agents/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
):
Expand Down Expand Up @@ -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", [])
Expand Down Expand Up @@ -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", [])
Expand Down Expand Up @@ -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", [])
Expand Down Expand Up @@ -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", [])
Expand Down
2 changes: 1 addition & 1 deletion docutranslate/agents/provider/provider.py
Original file line number Diff line number Diff line change
@@ -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":
Expand Down
5 changes: 4 additions & 1 deletion docutranslate/agents/thinking/thinking_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"),
}

Expand Down Expand Up @@ -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)
50 changes: 50 additions & 0 deletions tests/test_litellm_provider.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
"""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_not_autodetected(self):
"""LiteLLM runs on any host - it's set via --provider litellm, not auto-detected."""
assert get_provider_by_domain("localhost") == "default"
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"
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"


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):
provider: ProviderType = "litellm"
assert provider == "litellm"