truthound-dashboard 1.0.2__py3-none-any.whl → 1.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- truthound_dashboard/cli.py +397 -0
- truthound_dashboard/translate/__init__.py +61 -0
- truthound_dashboard/translate/config_updater.py +327 -0
- truthound_dashboard/translate/exceptions.py +98 -0
- truthound_dashboard/translate/providers/__init__.py +49 -0
- truthound_dashboard/translate/providers/anthropic.py +135 -0
- truthound_dashboard/translate/providers/base.py +225 -0
- truthound_dashboard/translate/providers/mistral.py +138 -0
- truthound_dashboard/translate/providers/ollama.py +226 -0
- truthound_dashboard/translate/providers/openai.py +187 -0
- truthound_dashboard/translate/providers/registry.py +217 -0
- truthound_dashboard/translate/translator.py +443 -0
- {truthound_dashboard-1.0.2.dist-info → truthound_dashboard-1.1.0.dist-info}/METADATA +103 -4
- {truthound_dashboard-1.0.2.dist-info → truthound_dashboard-1.1.0.dist-info}/RECORD +17 -6
- {truthound_dashboard-1.0.2.dist-info → truthound_dashboard-1.1.0.dist-info}/WHEEL +0 -0
- {truthound_dashboard-1.0.2.dist-info → truthound_dashboard-1.1.0.dist-info}/entry_points.txt +0 -0
- {truthound_dashboard-1.0.2.dist-info → truthound_dashboard-1.1.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""OpenAI provider for translation.
|
|
2
|
+
|
|
3
|
+
This module implements the OpenAI translation provider using
|
|
4
|
+
GPT-4 and GPT-3.5 models.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from truthound_dashboard.translate.exceptions import TranslationAPIError
|
|
14
|
+
from truthound_dashboard.translate.providers.base import (
|
|
15
|
+
AIProvider,
|
|
16
|
+
ProviderConfig,
|
|
17
|
+
TranslationRequest,
|
|
18
|
+
TranslationResponse,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class OpenAIProvider(AIProvider):
|
|
23
|
+
"""OpenAI translation provider.
|
|
24
|
+
|
|
25
|
+
Uses OpenAI's GPT models for translation.
|
|
26
|
+
|
|
27
|
+
Environment:
|
|
28
|
+
OPENAI_API_KEY: API key for authentication
|
|
29
|
+
|
|
30
|
+
Example:
|
|
31
|
+
provider = OpenAIProvider()
|
|
32
|
+
response = await provider.translate(
|
|
33
|
+
TranslationRequest(text="Hello", source_lang="en", target_lang="ja")
|
|
34
|
+
)
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
name = "openai"
|
|
38
|
+
display_name = "OpenAI"
|
|
39
|
+
env_var = "OPENAI_API_KEY"
|
|
40
|
+
default_model = "gpt-4o-mini"
|
|
41
|
+
supported_models = [
|
|
42
|
+
"gpt-4o",
|
|
43
|
+
"gpt-4o-mini",
|
|
44
|
+
"gpt-4-turbo",
|
|
45
|
+
"gpt-4",
|
|
46
|
+
"gpt-3.5-turbo",
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
DEFAULT_BASE_URL = "https://api.openai.com/v1"
|
|
50
|
+
|
|
51
|
+
def __init__(self, config: ProviderConfig | None = None) -> None:
|
|
52
|
+
super().__init__(config)
|
|
53
|
+
self.base_url = self.config.base_url or self.DEFAULT_BASE_URL
|
|
54
|
+
|
|
55
|
+
async def translate(
|
|
56
|
+
self,
|
|
57
|
+
request: TranslationRequest,
|
|
58
|
+
max_retries: int = 5,
|
|
59
|
+
base_delay: float = 25.0,
|
|
60
|
+
) -> TranslationResponse:
|
|
61
|
+
"""Translate text using OpenAI API.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
request: Translation request
|
|
65
|
+
max_retries: Maximum number of retries for rate limit errors
|
|
66
|
+
base_delay: Base delay in seconds for rate limit retries
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
Translation response with translated text
|
|
70
|
+
|
|
71
|
+
Raises:
|
|
72
|
+
TranslationAPIError: If API call fails
|
|
73
|
+
"""
|
|
74
|
+
prompt = self.get_translation_prompt(request)
|
|
75
|
+
last_error = None
|
|
76
|
+
|
|
77
|
+
for attempt in range(max_retries + 1):
|
|
78
|
+
async with httpx.AsyncClient(timeout=self.config.timeout) as client:
|
|
79
|
+
try:
|
|
80
|
+
response = await client.post(
|
|
81
|
+
f"{self.base_url}/chat/completions",
|
|
82
|
+
headers={
|
|
83
|
+
"Authorization": f"Bearer {self.config.api_key}",
|
|
84
|
+
"Content-Type": "application/json",
|
|
85
|
+
},
|
|
86
|
+
json={
|
|
87
|
+
"model": self.model,
|
|
88
|
+
"messages": [
|
|
89
|
+
{
|
|
90
|
+
"role": "system",
|
|
91
|
+
"content": (
|
|
92
|
+
"You are a professional translator. "
|
|
93
|
+
"Translate the given text accurately and naturally. "
|
|
94
|
+
"Only output the translated text, nothing else."
|
|
95
|
+
),
|
|
96
|
+
},
|
|
97
|
+
{"role": "user", "content": prompt},
|
|
98
|
+
],
|
|
99
|
+
"temperature": 0.3,
|
|
100
|
+
"max_tokens": 4096,
|
|
101
|
+
},
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
# Handle rate limit with retry
|
|
105
|
+
if response.status_code == 429:
|
|
106
|
+
if attempt < max_retries:
|
|
107
|
+
# Exponential backoff with jitter
|
|
108
|
+
delay = base_delay * (1.5**attempt)
|
|
109
|
+
await asyncio.sleep(delay)
|
|
110
|
+
continue
|
|
111
|
+
else:
|
|
112
|
+
raise TranslationAPIError(
|
|
113
|
+
provider_name=self.name,
|
|
114
|
+
message=f"Rate limit exceeded after {max_retries} retries",
|
|
115
|
+
status_code=response.status_code,
|
|
116
|
+
response_body=response.text,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
if response.status_code != 200:
|
|
120
|
+
raise TranslationAPIError(
|
|
121
|
+
provider_name=self.name,
|
|
122
|
+
message=f"API request failed: {response.text}",
|
|
123
|
+
status_code=response.status_code,
|
|
124
|
+
response_body=response.text,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
data = response.json()
|
|
128
|
+
translated_text = data["choices"][0]["message"]["content"].strip()
|
|
129
|
+
|
|
130
|
+
usage = None
|
|
131
|
+
if "usage" in data:
|
|
132
|
+
usage = {
|
|
133
|
+
"prompt_tokens": data["usage"].get("prompt_tokens", 0),
|
|
134
|
+
"completion_tokens": data["usage"].get("completion_tokens", 0),
|
|
135
|
+
"total_tokens": data["usage"].get("total_tokens", 0),
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return TranslationResponse(
|
|
139
|
+
translated_text=translated_text,
|
|
140
|
+
source_lang=request.source_lang,
|
|
141
|
+
target_lang=request.target_lang,
|
|
142
|
+
model=self.model,
|
|
143
|
+
provider=self.name,
|
|
144
|
+
usage=usage,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
except httpx.TimeoutException as e:
|
|
148
|
+
last_error = TranslationAPIError(
|
|
149
|
+
provider_name=self.name,
|
|
150
|
+
message=f"Request timed out after {self.config.timeout}s",
|
|
151
|
+
)
|
|
152
|
+
if attempt < max_retries:
|
|
153
|
+
await asyncio.sleep(base_delay)
|
|
154
|
+
continue
|
|
155
|
+
raise last_error from e
|
|
156
|
+
except httpx.RequestError as e:
|
|
157
|
+
raise TranslationAPIError(
|
|
158
|
+
provider_name=self.name,
|
|
159
|
+
message=f"Request failed: {e}",
|
|
160
|
+
) from e
|
|
161
|
+
|
|
162
|
+
# Should not reach here, but just in case
|
|
163
|
+
if last_error:
|
|
164
|
+
raise last_error
|
|
165
|
+
raise TranslationAPIError(
|
|
166
|
+
provider_name=self.name,
|
|
167
|
+
message="Translation failed after all retries",
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
async def is_available(self) -> bool:
|
|
171
|
+
"""Check if OpenAI API is available.
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
True if API key is set and valid
|
|
175
|
+
"""
|
|
176
|
+
if not self.config.api_key:
|
|
177
|
+
return False
|
|
178
|
+
|
|
179
|
+
try:
|
|
180
|
+
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
181
|
+
response = await client.get(
|
|
182
|
+
f"{self.base_url}/models",
|
|
183
|
+
headers={"Authorization": f"Bearer {self.config.api_key}"},
|
|
184
|
+
)
|
|
185
|
+
return response.status_code == 200
|
|
186
|
+
except Exception:
|
|
187
|
+
return False
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Provider registry for managing AI translation providers.
|
|
2
|
+
|
|
3
|
+
This module provides a central registry for AI providers, enabling
|
|
4
|
+
dynamic provider discovery and auto-detection based on environment.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from truthound_dashboard.translate.providers.base import AIProvider, ProviderConfig
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ProviderRegistry:
|
|
17
|
+
"""Registry for AI translation providers.
|
|
18
|
+
|
|
19
|
+
This class manages provider registration and lookup, supporting
|
|
20
|
+
both explicit selection and auto-detection.
|
|
21
|
+
|
|
22
|
+
Example:
|
|
23
|
+
registry = ProviderRegistry()
|
|
24
|
+
registry.register("openai", OpenAIProvider)
|
|
25
|
+
|
|
26
|
+
# Get specific provider
|
|
27
|
+
provider = registry.get("openai")
|
|
28
|
+
|
|
29
|
+
# Auto-detect based on environment
|
|
30
|
+
provider = registry.detect()
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
_providers: dict[str, type["AIProvider"]] = {}
|
|
34
|
+
_instances: dict[str, "AIProvider"] = {}
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def register(cls, name: str, provider_class: type["AIProvider"]) -> None:
|
|
38
|
+
"""Register a provider class.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
name: Provider name (e.g., 'openai', 'anthropic')
|
|
42
|
+
provider_class: Provider class to register
|
|
43
|
+
"""
|
|
44
|
+
cls._providers[name.lower()] = provider_class
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
def get(
|
|
48
|
+
cls,
|
|
49
|
+
name: str,
|
|
50
|
+
config: "ProviderConfig | None" = None,
|
|
51
|
+
) -> "AIProvider":
|
|
52
|
+
"""Get a provider instance by name.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
name: Provider name
|
|
56
|
+
config: Optional configuration override
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
Provider instance
|
|
60
|
+
|
|
61
|
+
Raises:
|
|
62
|
+
ProviderNotFoundError: If provider is not registered
|
|
63
|
+
"""
|
|
64
|
+
from truthound_dashboard.translate.exceptions import ProviderNotFoundError
|
|
65
|
+
|
|
66
|
+
name_lower = name.lower()
|
|
67
|
+
if name_lower not in cls._providers:
|
|
68
|
+
raise ProviderNotFoundError(name, list(cls._providers.keys()))
|
|
69
|
+
|
|
70
|
+
# Create new instance with config
|
|
71
|
+
provider_class = cls._providers[name_lower]
|
|
72
|
+
return provider_class(config)
|
|
73
|
+
|
|
74
|
+
@classmethod
|
|
75
|
+
def detect(cls, config: "ProviderConfig | None" = None) -> "AIProvider":
|
|
76
|
+
"""Auto-detect and return an available provider.
|
|
77
|
+
|
|
78
|
+
Detection priority:
|
|
79
|
+
1. Anthropic (ANTHROPIC_API_KEY)
|
|
80
|
+
2. OpenAI (OPENAI_API_KEY)
|
|
81
|
+
3. Mistral (MISTRAL_API_KEY)
|
|
82
|
+
4. Ollama (if running locally)
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
config: Optional configuration override
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
First available provider instance
|
|
89
|
+
|
|
90
|
+
Raises:
|
|
91
|
+
ProviderNotFoundError: If no provider is available
|
|
92
|
+
"""
|
|
93
|
+
from truthound_dashboard.translate.exceptions import ProviderNotFoundError
|
|
94
|
+
|
|
95
|
+
# Priority order for detection
|
|
96
|
+
detection_order = [
|
|
97
|
+
("anthropic", "ANTHROPIC_API_KEY"),
|
|
98
|
+
("openai", "OPENAI_API_KEY"),
|
|
99
|
+
("mistral", "MISTRAL_API_KEY"),
|
|
100
|
+
("ollama", None), # Ollama doesn't need API key
|
|
101
|
+
]
|
|
102
|
+
|
|
103
|
+
for provider_name, env_var in detection_order:
|
|
104
|
+
if provider_name not in cls._providers:
|
|
105
|
+
continue
|
|
106
|
+
|
|
107
|
+
# Check if API key is available (or not needed)
|
|
108
|
+
if env_var is None or os.getenv(env_var):
|
|
109
|
+
try:
|
|
110
|
+
provider = cls.get(provider_name, config)
|
|
111
|
+
return provider
|
|
112
|
+
except Exception:
|
|
113
|
+
continue
|
|
114
|
+
|
|
115
|
+
raise ProviderNotFoundError(
|
|
116
|
+
"auto",
|
|
117
|
+
list(cls._providers.keys()),
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
@classmethod
|
|
121
|
+
def list_available(cls) -> list[dict[str, str]]:
|
|
122
|
+
"""List all registered providers with their status.
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
List of dicts with provider info and availability status
|
|
126
|
+
"""
|
|
127
|
+
result = []
|
|
128
|
+
for name, provider_class in cls._providers.items():
|
|
129
|
+
env_var = provider_class.env_var
|
|
130
|
+
has_key = (
|
|
131
|
+
env_var is None # Doesn't need key
|
|
132
|
+
or bool(os.getenv(env_var))
|
|
133
|
+
)
|
|
134
|
+
result.append({
|
|
135
|
+
"name": name,
|
|
136
|
+
"display_name": provider_class.display_name or name.title(),
|
|
137
|
+
"env_var": env_var or "N/A",
|
|
138
|
+
"available": has_key,
|
|
139
|
+
"default_model": provider_class.default_model,
|
|
140
|
+
})
|
|
141
|
+
return result
|
|
142
|
+
|
|
143
|
+
@classmethod
|
|
144
|
+
def clear(cls) -> None:
|
|
145
|
+
"""Clear all registered providers. Mainly for testing."""
|
|
146
|
+
cls._providers.clear()
|
|
147
|
+
cls._instances.clear()
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def register_provider(name: str, provider_class: type["AIProvider"]) -> None:
|
|
151
|
+
"""Register a provider class with the global registry.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
name: Provider name
|
|
155
|
+
provider_class: Provider class to register
|
|
156
|
+
"""
|
|
157
|
+
ProviderRegistry.register(name, provider_class)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def get_provider(
|
|
161
|
+
name: str,
|
|
162
|
+
config: "ProviderConfig | None" = None,
|
|
163
|
+
) -> "AIProvider":
|
|
164
|
+
"""Get a provider instance by name.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
name: Provider name (e.g., 'openai', 'anthropic')
|
|
168
|
+
config: Optional configuration override
|
|
169
|
+
|
|
170
|
+
Returns:
|
|
171
|
+
Provider instance
|
|
172
|
+
|
|
173
|
+
Raises:
|
|
174
|
+
ProviderNotFoundError: If provider not found
|
|
175
|
+
"""
|
|
176
|
+
return ProviderRegistry.get(name, config)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def detect_provider(config: "ProviderConfig | None" = None) -> "AIProvider":
|
|
180
|
+
"""Auto-detect and return an available provider.
|
|
181
|
+
|
|
182
|
+
Args:
|
|
183
|
+
config: Optional configuration override
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
First available provider
|
|
187
|
+
|
|
188
|
+
Raises:
|
|
189
|
+
ProviderNotFoundError: If no provider available
|
|
190
|
+
"""
|
|
191
|
+
return ProviderRegistry.detect(config)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def list_available_providers() -> list[dict[str, str]]:
|
|
195
|
+
"""List all registered providers with availability status.
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
List of provider info dicts
|
|
199
|
+
"""
|
|
200
|
+
return ProviderRegistry.list_available()
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# Auto-register built-in providers on module import
|
|
204
|
+
def _register_builtin_providers() -> None:
|
|
205
|
+
"""Register all built-in providers."""
|
|
206
|
+
from truthound_dashboard.translate.providers.openai import OpenAIProvider
|
|
207
|
+
from truthound_dashboard.translate.providers.anthropic import AnthropicProvider
|
|
208
|
+
from truthound_dashboard.translate.providers.ollama import OllamaProvider
|
|
209
|
+
from truthound_dashboard.translate.providers.mistral import MistralProvider
|
|
210
|
+
|
|
211
|
+
register_provider("openai", OpenAIProvider)
|
|
212
|
+
register_provider("anthropic", AnthropicProvider)
|
|
213
|
+
register_provider("ollama", OllamaProvider)
|
|
214
|
+
register_provider("mistral", MistralProvider)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
_register_builtin_providers()
|