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.
@@ -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()