Skip to content
Closed
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
15 changes: 12 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
![PyPI](https://img.shields.io/pypi/v/gpt-po-translator?label=gpt-po-translator)
![Downloads](https://pepy.tech/badge/gpt-po-translator)

**Translate gettext (.po) files using AI models.** Supports OpenAI, Azure OpenAI, Anthropic/Claude, and DeepSeek with automatic AI translation tagging and context-aware translations.
**Translate gettext (.po) files using AI models.** Supports OpenAI, Azure OpenAI, Anthropic/Claude, DeepSeek, and OpenAI-compatible custom endpoints with automatic AI translation tagging and context-aware translations.

## 🚀 Quick Start

Expand All @@ -21,7 +21,7 @@ gpt-po-translator --folder ./locales --bulk

## ✨ Key Features

- **Multiple AI providers** - OpenAI, Azure OpenAI, Anthropic/Claude, DeepSeek, Ollama
- **Multiple AI providers** - OpenAI, Azure OpenAI, Anthropic/Claude, DeepSeek, Ollama, and Custom (OpenAI-compatible)
- **Context-aware translations** - Automatically uses `msgctxt` for better accuracy with ambiguous terms
- **AI translation tracking** - Auto-tags AI-generated translations with `#. AI-generated` comments
- **Bulk processing** - Efficient batch translation for large files
Expand Down Expand Up @@ -68,6 +68,10 @@ export DEEPSEEK_API_KEY='your_key'
export AZURE_OPENAI_API_KEY='your_key'
export AZURE_OPENAI_ENDPOINT='https://your-resource.openai.azure.com/'
export AZURE_OPENAI_API_VERSION='2024-02-01'

# Custom (OpenAI-compatible)
export CUSTOM_API_KEY='your_key'
export CUSTOM_BASE_URL='https://api.your-provider.com/v1'
```

## 💡 Usage Examples
Expand Down Expand Up @@ -97,6 +101,9 @@ gpt-po-translator --provider azure_openai --folder ./locales --bulk

# Use Ollama (local, see docs/usage.md for setup)
gpt-po-translator --provider ollama --folder ./locales

# Use Custom OpenAI-compatible provider
gpt-po-translator --provider custom --custom-base-url "https://api.your-provider.com/v1" --folder ./locales --bulk
```

### Docker Usage
Expand Down Expand Up @@ -187,7 +194,7 @@ This helps you:
|--------|-------------|
| `--folder` | Path to .po files |
| `--lang` | Target languages (e.g., `de,fr,es`, `fr_CA`, `pt_BR`) |
| `--provider` | AI provider: `openai`, `azure_openai`, `anthropic`, `deepseek`, `ollama` |
| `--provider` | AI provider: `openai`, `azure_openai`, `anthropic`, `deepseek`, `ollama`, `custom` |
| `--bulk` | Enable batch translation (recommended for large files) |
| `--bulksize` | Entries per batch (default: 50) |
| `--model` | Specific model to use |
Expand All @@ -198,6 +205,8 @@ This helps you:
| `--no-ai-comment` | Disable AI tagging |
| `--ollama-base-url` | Ollama server URL (default: `http://localhost:11434`) |
| `--ollama-timeout` | Ollama timeout in seconds (default: 120) |
| `--custom-key` | Custom provider API key |
| `--custom-base-url` | Custom provider API base URL |
| `-v, --verbose` | Show progress information (use `-vv` for debug) |
| `-q, --quiet` | Only show errors |
| `--version` | Show version and exit |
Expand Down
9 changes: 8 additions & 1 deletion python_gpt_po/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,8 +182,15 @@ def main():
# Initialize logging with verbosity settings
setup_logging(verbose=args.verbose, quiet=args.quiet)

# 1. Handle --list-models early to avoid needing a folder
if args.list_models:
provider_clients, provider, final_model_id = get_offline_provider_info(args)
initialize_provider(args, provider_clients, provider, final_model_id)
# initialize_provider will sys.exit(0) if list_models is True
return

try:
# 1. Get languages (Pure logic)
# 2. Get languages (Pure logic)
try:
respect_gitignore = not args.no_gitignore
languages = LanguageDetector.validate_or_detect_languages(
Expand Down
1 change: 1 addition & 0 deletions python_gpt_po/models/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ class ModelProvider(Enum):
AZURE_OPENAI = "azure_openai"
OLLAMA = "ollama"
CLAUDE_SDK = "claude_sdk"
CUSTOM = "custom"


ModelProviderList = [provider.value for provider in ModelProvider]
15 changes: 15 additions & 0 deletions python_gpt_po/models/provider_clients.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ def __init__(self):
self.deepseek_base_url = None
self.ollama_base_url = None
self.ollama_timeout = None
self.custom_client = None

def _get_setting(self, args: Namespace, arg_name: str, env_var: str = None,
config_provider: str = None, config_key: str = None, default: any = None) -> any:
Expand Down Expand Up @@ -129,11 +130,25 @@ def initialize_clients(self, args: Namespace) -> Dict[str, str]:
'ollama', 'timeout', 120
)

# Custom (OpenAI-compatible)
custom_key = self._get_setting(
args, 'custom_key', 'CUSTOM_API_KEY', 'custom', 'api_key', ''
)
if custom_key:
custom_base_url = self._get_setting(
args, 'custom_base_url', 'CUSTOM_BASE_URL', 'custom', 'base_url', None
)
if not custom_base_url:
raise ValueError("Missing custom provider base URL.")

self.custom_client = OpenAI(api_key=custom_key, base_url=custom_base_url)

return {
ModelProvider.OPENAI.value: openai_key,
ModelProvider.ANTHROPIC.value: antropic_key,
ModelProvider.DEEPSEEK.value: deepseek_key,
ModelProvider.AZURE_OPENAI.value: azure_openai_key,
ModelProvider.OLLAMA.value: "local", # Ollama doesn't need API key
ModelProvider.CLAUDE_SDK.value: "local",
ModelProvider.CUSTOM.value: custom_key,
}
62 changes: 62 additions & 0 deletions python_gpt_po/services/providers/custom_provider.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"""
Custom provider implementation (OpenAI-compatible).
"""
import logging
from typing import List

from ...models.provider_clients import ProviderClients
from .base import ModelProviderInterface


class CustomProvider(ModelProviderInterface):
"""Custom model provider implementation (OpenAI-compatible)."""

def get_models(self, provider_clients: ProviderClients) -> List[str]:
"""Retrieve available models from the custom provider."""
models = []

if not self.is_client_initialized(provider_clients):
logging.error("Custom client not initialized")
return models

try:
response = provider_clients.custom_client.models.list()
models = [model.id for model in response.data]
except Exception as e:
logging.error("Error fetching custom models: %s", str(e))
models = self.get_fallback_models()

return models

def get_default_model(self) -> str:
"""Get a generic default model name for custom providers."""
return "gpt-4o-mini" # Often used as a safe default for proxies

def get_preferred_models(self, task: str = "translation") -> List[str]:
"""Get possible models for the custom provider."""
return ["gpt-4", "gpt-4o", "gpt-4o-mini", "llama3", "mistral"]

def is_client_initialized(self, provider_clients: ProviderClients) -> bool:
"""Check if custom client is initialized."""
return provider_clients.custom_client is not None

def get_fallback_models(self) -> List[str]:
"""Get fallback models for custom provider."""
return [
"gpt-4o-mini",
"gpt-4o",
"gpt-4",
"gpt-3.5-turbo"
]

def translate(self, provider_clients: ProviderClients, model: str, content: str) -> str:
"""Get response from custom OpenAI-compatible API."""
if not self.is_client_initialized(provider_clients):
raise ValueError("Custom client not initialized")

message = {"role": "user", "content": content}
completion = provider_clients.custom_client.chat.completions.create(
model=model,
messages=[message]
)
return completion.choices[0].message.content.strip()
2 changes: 2 additions & 0 deletions python_gpt_po/services/providers/provider_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ def initialize_providers():
from .anthropic_provider import AnthropicProvider
from .azure_openai_provider import AzureOpenAIProvider
from .claude_sdk_provider import ClaudeSdkProvider
from .custom_provider import CustomProvider
from .deepseek_provider import DeepSeekProvider
from .ollama_provider import OllamaProvider
from .openai_provider import OpenAIProvider
Expand All @@ -20,6 +21,7 @@ def initialize_providers():
ProviderRegistry.register(ModelProvider.AZURE_OPENAI, AzureOpenAIProvider)
ProviderRegistry.register(ModelProvider.OLLAMA, OllamaProvider)
ProviderRegistry.register(ModelProvider.CLAUDE_SDK, ClaudeSdkProvider)
ProviderRegistry.register(ModelProvider.CUSTOM, CustomProvider)


# Providers will be initialized lazily when first accessed
62 changes: 62 additions & 0 deletions python_gpt_po/tests/providers/test_custom_provider.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"""
Unit tests for the custom provider.
"""
import unittest
from argparse import Namespace
from unittest.mock import MagicMock, patch

from python_gpt_po.models.provider_clients import ProviderClients
from python_gpt_po.services.providers.custom_provider import CustomProvider


class TestCustomProvider(unittest.TestCase):
def setUp(self):
self.provider = CustomProvider()
self.args = Namespace(
custom_key="test-key",
custom_base_url="https://api.custom-ai.com/v1",
folder="."
)

@patch('python_gpt_po.models.provider_clients.OpenAI')
def test_provider_initialization(self, mock_openai):
"""Verify that ProviderClients initializes the custom client correctly."""
clients = ProviderClients()
clients.initialize_clients(self.args)

mock_openai.assert_called_once_with(
api_key="test-key",
base_url="https://api.custom-ai.com/v1"
)
self.assertIsNotNone(clients.custom_client)

def test_is_client_initialized(self):
"""Verify initialization check."""
clients = ProviderClients()
self.assertFalse(self.provider.is_client_initialized(clients))

clients.custom_client = MagicMock()
self.assertTrue(self.provider.is_client_initialized(clients))

@patch('python_gpt_po.models.provider_clients.OpenAI')
def test_translate_call(self, mock_openai):
"""Verify that translate calls the underlying openai client correctly."""
clients = ProviderClients()
clients.custom_client = MagicMock()

# Mock the chat.completions.create response
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message.content = "Translated Text"
clients.custom_client.chat.completions.create.return_value = mock_response

result = self.provider.translate(clients, "model-x", "Hello")

clients.custom_client.chat.completions.create.assert_called_once_with(
model="model-x",
messages=[{"role": "user", "content": "Hello"}]
)
self.assertEqual(result, "Translated Text")

if __name__ == '__main__':
unittest.main()
10 changes: 10 additions & 0 deletions python_gpt_po/utils/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,11 @@ def parse_args() -> Namespace:
metavar="KEY",
help="Fallback API key for OpenAI (deprecated, use --openai-key instead)"
)
api_group.add_argument(
"--custom-key",
metavar="KEY",
help="Custom provider API key (can also use CUSTOM_API_KEY env var)"
)

# Azure OpenAI options
advanced_group.add_argument(
Expand All @@ -166,6 +171,11 @@ def parse_args() -> Namespace:
metavar="SECONDS",
help="Ollama request timeout in seconds (default: 120)"
)
advanced_group.add_argument(
"--custom-base-url",
metavar="URL",
help="Custom provider API base URL (can also use CUSTOM_BASE_URL env var)"
)

# Advanced options
advanced_group.add_argument(
Expand Down