From ed027dfb0a9579da0a2d384925ff75d4c4ed85c8 Mon Sep 17 00:00:00 2001 From: Rania Amina Date: Sat, 14 Feb 2026 15:02:51 +0700 Subject: [PATCH 1/4] add custom OpenAI compatible API support --- README.md | 15 ++++- python_gpt_po/main.py | 9 ++- python_gpt_po/models/enums.py | 1 + python_gpt_po/models/provider_clients.py | 15 +++++ .../services/providers/custom_provider.py | 62 +++++++++++++++++++ .../services/providers/provider_init.py | 2 + .../tests/providers/test_custom_provider.py | 62 +++++++++++++++++++ python_gpt_po/utils/cli.py | 10 +++ 8 files changed, 172 insertions(+), 4 deletions(-) create mode 100644 python_gpt_po/services/providers/custom_provider.py create mode 100644 python_gpt_po/tests/providers/test_custom_provider.py diff --git a/README.md b/README.md index e678a67..520a70e 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 | @@ -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 | diff --git a/python_gpt_po/main.py b/python_gpt_po/main.py index 2eb4f45..6571d1e 100644 --- a/python_gpt_po/main.py +++ b/python_gpt_po/main.py @@ -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( diff --git a/python_gpt_po/models/enums.py b/python_gpt_po/models/enums.py index e2a4fca..d30b84e 100644 --- a/python_gpt_po/models/enums.py +++ b/python_gpt_po/models/enums.py @@ -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] diff --git a/python_gpt_po/models/provider_clients.py b/python_gpt_po/models/provider_clients.py index 3c0f8ec..b5ce80c 100644 --- a/python_gpt_po/models/provider_clients.py +++ b/python_gpt_po/models/provider_clients.py @@ -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: @@ -129,6 +130,19 @@ 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, @@ -136,4 +150,5 @@ def initialize_clients(self, args: Namespace) -> Dict[str, str]: 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, } diff --git a/python_gpt_po/services/providers/custom_provider.py b/python_gpt_po/services/providers/custom_provider.py new file mode 100644 index 0000000..ecb7f64 --- /dev/null +++ b/python_gpt_po/services/providers/custom_provider.py @@ -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() diff --git a/python_gpt_po/services/providers/provider_init.py b/python_gpt_po/services/providers/provider_init.py index 0daddd3..b6b2f7a 100644 --- a/python_gpt_po/services/providers/provider_init.py +++ b/python_gpt_po/services/providers/provider_init.py @@ -14,12 +14,14 @@ def initialize_providers(): from .deepseek_provider import DeepSeekProvider from .ollama_provider import OllamaProvider from .openai_provider import OpenAIProvider + from .custom_provider import CustomProvider ProviderRegistry.register(ModelProvider.OPENAI, OpenAIProvider) ProviderRegistry.register(ModelProvider.ANTHROPIC, AnthropicProvider) ProviderRegistry.register(ModelProvider.DEEPSEEK, DeepSeekProvider) 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 diff --git a/python_gpt_po/tests/providers/test_custom_provider.py b/python_gpt_po/tests/providers/test_custom_provider.py new file mode 100644 index 0000000..42ebf59 --- /dev/null +++ b/python_gpt_po/tests/providers/test_custom_provider.py @@ -0,0 +1,62 @@ +""" +Unit tests for the custom provider. +""" +import unittest +from unittest.mock import MagicMock, patch +from argparse import Namespace + +from python_gpt_po.models.enums import ModelProvider +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() diff --git a/python_gpt_po/utils/cli.py b/python_gpt_po/utils/cli.py index d3a3c3d..ecad56e 100644 --- a/python_gpt_po/utils/cli.py +++ b/python_gpt_po/utils/cli.py @@ -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( @@ -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( From fcb3572414e830bc7a2ba5f8ba5d2f3567745398 Mon Sep 17 00:00:00 2001 From: Rania Amina Date: Sat, 14 Feb 2026 15:10:15 +0700 Subject: [PATCH 2/4] fix isort check issue --- python_gpt_po/services/providers/provider_init.py | 2 +- python_gpt_po/tests/providers/test_custom_provider.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/python_gpt_po/services/providers/provider_init.py b/python_gpt_po/services/providers/provider_init.py index b6b2f7a..18dbb61 100644 --- a/python_gpt_po/services/providers/provider_init.py +++ b/python_gpt_po/services/providers/provider_init.py @@ -11,10 +11,10 @@ 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 - from .custom_provider import CustomProvider ProviderRegistry.register(ModelProvider.OPENAI, OpenAIProvider) ProviderRegistry.register(ModelProvider.ANTHROPIC, AnthropicProvider) ProviderRegistry.register(ModelProvider.DEEPSEEK, DeepSeekProvider) diff --git a/python_gpt_po/tests/providers/test_custom_provider.py b/python_gpt_po/tests/providers/test_custom_provider.py index 42ebf59..1e0cf25 100644 --- a/python_gpt_po/tests/providers/test_custom_provider.py +++ b/python_gpt_po/tests/providers/test_custom_provider.py @@ -2,8 +2,8 @@ Unit tests for the custom provider. """ import unittest -from unittest.mock import MagicMock, patch from argparse import Namespace +from unittest.mock import MagicMock, patch from python_gpt_po.models.enums import ModelProvider from python_gpt_po.models.provider_clients import ProviderClients From 3e7c6fd4e8818d2c55c7ade92742fb709b8c96df Mon Sep 17 00:00:00 2001 From: Rania Amina Date: Sat, 14 Feb 2026 15:12:59 +0700 Subject: [PATCH 3/4] fix isort check issue --- python_gpt_po/tests/providers/test_custom_provider.py | 1 + 1 file changed, 1 insertion(+) diff --git a/python_gpt_po/tests/providers/test_custom_provider.py b/python_gpt_po/tests/providers/test_custom_provider.py index 1e0cf25..4f343e4 100644 --- a/python_gpt_po/tests/providers/test_custom_provider.py +++ b/python_gpt_po/tests/providers/test_custom_provider.py @@ -9,6 +9,7 @@ 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() From e15246eb36371bfd8c4b3152294f7a136ba58234 Mon Sep 17 00:00:00 2001 From: Rania Amina Date: Sat, 14 Feb 2026 15:15:02 +0700 Subject: [PATCH 4/4] remove unused import --- python_gpt_po/tests/providers/test_custom_provider.py | 1 - 1 file changed, 1 deletion(-) diff --git a/python_gpt_po/tests/providers/test_custom_provider.py b/python_gpt_po/tests/providers/test_custom_provider.py index 4f343e4..168054f 100644 --- a/python_gpt_po/tests/providers/test_custom_provider.py +++ b/python_gpt_po/tests/providers/test_custom_provider.py @@ -5,7 +5,6 @@ from argparse import Namespace from unittest.mock import MagicMock, patch -from python_gpt_po.models.enums import ModelProvider from python_gpt_po.models.provider_clients import ProviderClients from python_gpt_po.services.providers.custom_provider import CustomProvider