Add OpenRouter provider with DeepSeek support - best value for translation (.14/M tokens)
This commit is contained in:
parent
b65e683d32
commit
3346817a8a
280
benchmark.py
Normal file
280
benchmark.py
Normal file
@ -0,0 +1,280 @@
|
||||
"""
|
||||
Translation Benchmark Script
|
||||
Tests translation performance for 200 pages equivalent of text
|
||||
"""
|
||||
import time
|
||||
import random
|
||||
import statistics
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Add project root to path
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from services.translation_service import (
|
||||
GoogleTranslationProvider,
|
||||
TranslationService,
|
||||
_translation_cache
|
||||
)
|
||||
|
||||
|
||||
# Sample texts of varying complexity (simulating real document content)
|
||||
SAMPLE_TEXTS = [
|
||||
"Welcome to our company",
|
||||
"Please review the attached document",
|
||||
"The quarterly results exceeded expectations",
|
||||
"Meeting scheduled for next Monday at 10 AM",
|
||||
"Thank you for your continued support",
|
||||
"This report contains confidential information",
|
||||
"Please contact customer support for assistance",
|
||||
"The project deadline has been extended",
|
||||
"Annual revenue increased by 15% compared to last year",
|
||||
"Our team is committed to delivering excellence",
|
||||
"The new product launch was a great success",
|
||||
"Please find the updated specifications attached",
|
||||
"We appreciate your patience during this transition",
|
||||
"The contract terms have been finalized",
|
||||
"Quality assurance testing is now complete",
|
||||
"The budget allocation has been approved",
|
||||
"Employee satisfaction survey results are available",
|
||||
"The system maintenance is scheduled for this weekend",
|
||||
"Our partnership continues to grow stronger",
|
||||
"The training program will begin next month",
|
||||
"Customer feedback has been overwhelmingly positive",
|
||||
"The risk assessment has been completed",
|
||||
"Strategic planning session is confirmed",
|
||||
"Performance metrics indicate steady improvement",
|
||||
"The compliance audit was successful",
|
||||
"Innovation remains our top priority",
|
||||
"Market analysis shows promising trends",
|
||||
"The implementation phase is on track",
|
||||
"Stakeholder engagement continues to increase",
|
||||
"Operational efficiency has improved significantly",
|
||||
# Longer paragraphs
|
||||
"In accordance with the terms of our agreement, we are pleased to inform you that all deliverables have been completed on schedule and within budget.",
|
||||
"The comprehensive analysis of market trends indicates that our strategic positioning remains strong, with continued growth expected in the coming quarters.",
|
||||
"We would like to express our sincere gratitude for your partnership and look forward to continuing our successful collaboration in the future.",
|
||||
"Following a thorough review of the project requirements, our team has identified several opportunities for optimization and cost reduction.",
|
||||
"The executive summary provides an overview of key findings, recommendations, and next steps for the proposed initiative.",
|
||||
]
|
||||
|
||||
# Average words per page (standard document)
|
||||
WORDS_PER_PAGE = 250
|
||||
# Target: 200 pages
|
||||
TARGET_PAGES = 200
|
||||
TARGET_WORDS = WORDS_PER_PAGE * TARGET_PAGES # 50,000 words
|
||||
|
||||
|
||||
def generate_document_content(target_words: int) -> list[str]:
|
||||
"""Generate a list of text segments simulating a multi-page document"""
|
||||
segments = []
|
||||
current_words = 0
|
||||
|
||||
while current_words < target_words:
|
||||
# Pick a random sample text
|
||||
text = random.choice(SAMPLE_TEXTS)
|
||||
segments.append(text)
|
||||
current_words += len(text.split())
|
||||
|
||||
return segments
|
||||
|
||||
|
||||
def run_benchmark(target_language: str = "fr", use_cache: bool = True):
|
||||
"""Run the translation benchmark"""
|
||||
print("=" * 60)
|
||||
print("TRANSLATION BENCHMARK - 200 PAGES")
|
||||
print("=" * 60)
|
||||
print(f"Target: {TARGET_PAGES} pages (~{TARGET_WORDS:,} words)")
|
||||
print(f"Target language: {target_language}")
|
||||
print(f"Cache enabled: {use_cache}")
|
||||
print()
|
||||
|
||||
# Clear cache if needed
|
||||
if not use_cache:
|
||||
_translation_cache.clear()
|
||||
|
||||
# Generate document content
|
||||
print("Generating document content...")
|
||||
segments = generate_document_content(TARGET_WORDS)
|
||||
total_words = sum(len(s.split()) for s in segments)
|
||||
total_chars = sum(len(s) for s in segments)
|
||||
|
||||
print(f"Generated {len(segments):,} text segments")
|
||||
print(f"Total words: {total_words:,}")
|
||||
print(f"Total characters: {total_chars:,}")
|
||||
print(f"Estimated pages: {total_words / WORDS_PER_PAGE:.1f}")
|
||||
print()
|
||||
|
||||
# Initialize translation service
|
||||
provider = GoogleTranslationProvider()
|
||||
service = TranslationService(provider)
|
||||
|
||||
# Warm-up (optional)
|
||||
print("Warming up...")
|
||||
_ = service.translate_text("Hello world", target_language)
|
||||
print()
|
||||
|
||||
# Benchmark 1: Individual translations
|
||||
print("-" * 40)
|
||||
print("TEST 1: Individual Translations")
|
||||
print("-" * 40)
|
||||
|
||||
start_time = time.time()
|
||||
translated_individual = []
|
||||
|
||||
for i, text in enumerate(segments):
|
||||
result = service.translate_text(text, target_language)
|
||||
translated_individual.append(result)
|
||||
if (i + 1) % 500 == 0:
|
||||
elapsed = time.time() - start_time
|
||||
rate = (i + 1) / elapsed
|
||||
print(f" Progress: {i + 1:,}/{len(segments):,} ({rate:.1f} segments/sec)")
|
||||
|
||||
individual_time = time.time() - start_time
|
||||
individual_rate = len(segments) / individual_time
|
||||
individual_words_per_sec = total_words / individual_time
|
||||
individual_pages_per_min = (total_words / WORDS_PER_PAGE) / (individual_time / 60)
|
||||
|
||||
print(f"\n Total time: {individual_time:.2f} seconds")
|
||||
print(f" Rate: {individual_rate:.1f} segments/second")
|
||||
print(f" Words/second: {individual_words_per_sec:.1f}")
|
||||
print(f" Pages/minute: {individual_pages_per_min:.1f}")
|
||||
|
||||
# Get cache stats after individual translations
|
||||
cache_stats_1 = _translation_cache.stats()
|
||||
print(f" Cache: {cache_stats_1}")
|
||||
print()
|
||||
|
||||
# Clear cache for fair comparison
|
||||
_translation_cache.clear()
|
||||
|
||||
# Benchmark 2: Batch translations
|
||||
print("-" * 40)
|
||||
print("TEST 2: Batch Translations")
|
||||
print("-" * 40)
|
||||
|
||||
batch_sizes = [50, 100, 200]
|
||||
|
||||
for batch_size in batch_sizes:
|
||||
_translation_cache.clear()
|
||||
|
||||
start_time = time.time()
|
||||
translated_batch = []
|
||||
|
||||
for i in range(0, len(segments), batch_size):
|
||||
batch = segments[i:i + batch_size]
|
||||
results = service.translate_batch(batch, target_language)
|
||||
translated_batch.extend(results)
|
||||
|
||||
if len(translated_batch) % 1000 < batch_size:
|
||||
elapsed = time.time() - start_time
|
||||
rate = len(translated_batch) / elapsed if elapsed > 0 else 0
|
||||
print(f" [batch={batch_size}] Progress: {len(translated_batch):,}/{len(segments):,} ({rate:.1f} seg/sec)")
|
||||
|
||||
batch_time = time.time() - start_time
|
||||
batch_rate = len(segments) / batch_time
|
||||
batch_words_per_sec = total_words / batch_time
|
||||
batch_pages_per_min = (total_words / WORDS_PER_PAGE) / (batch_time / 60)
|
||||
speedup = individual_time / batch_time if batch_time > 0 else 0
|
||||
|
||||
cache_stats = _translation_cache.stats()
|
||||
|
||||
print(f"\n Batch size: {batch_size}")
|
||||
print(f" Total time: {batch_time:.2f} seconds")
|
||||
print(f" Rate: {batch_rate:.1f} segments/second")
|
||||
print(f" Words/second: {batch_words_per_sec:.1f}")
|
||||
print(f" Pages/minute: {batch_pages_per_min:.1f}")
|
||||
print(f" Speedup vs individual: {speedup:.2f}x")
|
||||
print(f" Cache: {cache_stats}")
|
||||
print()
|
||||
|
||||
# Benchmark 3: With cache (simulating re-translation of similar content)
|
||||
print("-" * 40)
|
||||
print("TEST 3: Cache Performance (Re-translation)")
|
||||
print("-" * 40)
|
||||
|
||||
# First pass - populate cache
|
||||
_translation_cache.clear()
|
||||
print(" First pass (populating cache)...")
|
||||
start_time = time.time()
|
||||
_ = service.translate_batch(segments, target_language)
|
||||
first_pass_time = time.time() - start_time
|
||||
|
||||
cache_after_first = _translation_cache.stats()
|
||||
print(f" First pass time: {first_pass_time:.2f} seconds")
|
||||
print(f" Cache after first pass: {cache_after_first}")
|
||||
|
||||
# Second pass - should use cache
|
||||
print("\n Second pass (using cache)...")
|
||||
start_time = time.time()
|
||||
_ = service.translate_batch(segments, target_language)
|
||||
second_pass_time = time.time() - start_time
|
||||
|
||||
cache_after_second = _translation_cache.stats()
|
||||
cache_speedup = first_pass_time / second_pass_time if second_pass_time > 0 else float('inf')
|
||||
|
||||
print(f" Second pass time: {second_pass_time:.2f} seconds")
|
||||
print(f" Cache after second pass: {cache_after_second}")
|
||||
print(f" Cache speedup: {cache_speedup:.1f}x")
|
||||
print()
|
||||
|
||||
# Summary
|
||||
print("=" * 60)
|
||||
print("BENCHMARK SUMMARY")
|
||||
print("=" * 60)
|
||||
print(f"Document size: {TARGET_PAGES} pages ({total_words:,} words)")
|
||||
print(f"Text segments: {len(segments):,}")
|
||||
print()
|
||||
print(f"Individual translation: {individual_time:.1f}s ({individual_pages_per_min:.1f} pages/min)")
|
||||
print(f"Batch translation (50): ~{individual_time/3:.1f}s estimated")
|
||||
print(f"With cache (2nd pass): {second_pass_time:.2f}s ({cache_speedup:.1f}x faster)")
|
||||
print()
|
||||
print("Recommendations:")
|
||||
print(" - Use batch_size=50 for optimal API performance")
|
||||
print(" - Enable caching for documents with repetitive content")
|
||||
print(" - For 200 pages, expect ~2-5 minutes with Google Translate")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
def quick_benchmark(num_segments: int = 100, target_language: str = "fr"):
|
||||
"""Quick benchmark with fewer segments for testing"""
|
||||
print(f"Quick benchmark: {num_segments} segments to {target_language}")
|
||||
print("-" * 40)
|
||||
|
||||
provider = GoogleTranslationProvider()
|
||||
service = TranslationService(provider)
|
||||
|
||||
# Generate test content
|
||||
segments = [random.choice(SAMPLE_TEXTS) for _ in range(num_segments)]
|
||||
|
||||
# Test batch translation
|
||||
_translation_cache.clear()
|
||||
start = time.time()
|
||||
results = service.translate_batch(segments, target_language)
|
||||
elapsed = time.time() - start
|
||||
|
||||
print(f"Translated {len(results)} segments in {elapsed:.2f}s")
|
||||
print(f"Rate: {len(results)/elapsed:.1f} segments/second")
|
||||
print(f"Cache: {_translation_cache.stats()}")
|
||||
|
||||
# Show sample translations
|
||||
print("\nSample translations:")
|
||||
for i in range(min(3, len(results))):
|
||||
print(f" '{segments[i]}' -> '{results[i]}'")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="Translation Benchmark")
|
||||
parser.add_argument("--quick", action="store_true", help="Run quick benchmark (100 segments)")
|
||||
parser.add_argument("--full", action="store_true", help="Run full 200-page benchmark")
|
||||
parser.add_argument("--segments", type=int, default=100, help="Number of segments for quick test")
|
||||
parser.add_argument("--lang", type=str, default="fr", help="Target language code")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.full:
|
||||
run_benchmark(target_language=args.lang)
|
||||
else:
|
||||
quick_benchmark(num_segments=args.segments, target_language=args.lang)
|
||||
@ -16,6 +16,7 @@ export default function Home() {
|
||||
const { settings } = useTranslationStore();
|
||||
|
||||
const providerNames: Record<string, string> = {
|
||||
openrouter: "OpenRouter",
|
||||
google: "Google Translate",
|
||||
ollama: "Ollama",
|
||||
deepl: "DeepL",
|
||||
|
||||
@ -7,10 +7,10 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useTranslationStore, webllmModels, openaiModels } from "@/lib/store";
|
||||
import { useTranslationStore, webllmModels, openaiModels, openrouterModels } from "@/lib/store";
|
||||
import { providers, testOpenAIConnection, testOllamaConnection, getOllamaModels, type OllamaModel } from "@/lib/api";
|
||||
import { useWebLLM } from "@/lib/webllm";
|
||||
import { Save, Loader2, Cloud, Check, ExternalLink, Wifi, CheckCircle, XCircle, Download, Trash2, Cpu, Server, RefreshCw } from "lucide-react";
|
||||
import { Save, Loader2, Cloud, Check, ExternalLink, Wifi, CheckCircle, XCircle, Download, Trash2, Cpu, Server, RefreshCw, Zap } from "lucide-react";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@ -30,6 +30,8 @@ export default function TranslationServicesPage() {
|
||||
const [deeplApiKey, setDeeplApiKey] = useState(settings.deeplApiKey);
|
||||
const [openaiApiKey, setOpenaiApiKey] = useState(settings.openaiApiKey);
|
||||
const [openaiModel, setOpenaiModel] = useState(settings.openaiModel);
|
||||
const [openrouterApiKey, setOpenrouterApiKey] = useState(settings.openrouterApiKey);
|
||||
const [openrouterModel, setOpenrouterModel] = useState(settings.openrouterModel);
|
||||
const [libreUrl, setLibreUrl] = useState(settings.libreTranslateUrl);
|
||||
const [webllmModel, setWebllmModel] = useState(settings.webllmModel);
|
||||
|
||||
@ -45,6 +47,10 @@ export default function TranslationServicesPage() {
|
||||
const [openaiTestStatus, setOpenaiTestStatus] = useState<"idle" | "testing" | "success" | "error">("idle");
|
||||
const [openaiTestMessage, setOpenaiTestMessage] = useState("");
|
||||
|
||||
// OpenRouter connection test state
|
||||
const [openrouterTestStatus, setOpenrouterTestStatus] = useState<"idle" | "testing" | "success" | "error">("idle");
|
||||
const [openrouterTestMessage, setOpenrouterTestMessage] = useState("");
|
||||
|
||||
// WebLLM hook
|
||||
const webllm = useWebLLM();
|
||||
|
||||
@ -54,6 +60,8 @@ export default function TranslationServicesPage() {
|
||||
setDeeplApiKey(settings.deeplApiKey);
|
||||
setOpenaiApiKey(settings.openaiApiKey);
|
||||
setOpenaiModel(settings.openaiModel);
|
||||
setOpenrouterApiKey(settings.openrouterApiKey);
|
||||
setOpenrouterModel(settings.openrouterModel);
|
||||
setLibreUrl(settings.libreTranslateUrl);
|
||||
setWebllmModel(settings.webllmModel);
|
||||
setOllamaUrl(settings.ollamaUrl);
|
||||
@ -125,6 +133,31 @@ export default function TranslationServicesPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// Test OpenRouter connection
|
||||
const testOpenRouterConnection = async () => {
|
||||
if (!openrouterApiKey) {
|
||||
setOpenrouterTestStatus("error");
|
||||
setOpenrouterTestMessage("API key required");
|
||||
return;
|
||||
}
|
||||
setOpenrouterTestStatus("testing");
|
||||
try {
|
||||
const response = await fetch("https://openrouter.ai/api/v1/models", {
|
||||
headers: { Authorization: `Bearer ${openrouterApiKey}` }
|
||||
});
|
||||
if (response.ok) {
|
||||
setOpenrouterTestStatus("success");
|
||||
setOpenrouterTestMessage("Connected successfully!");
|
||||
} else {
|
||||
setOpenrouterTestStatus("error");
|
||||
setOpenrouterTestMessage("Invalid API key");
|
||||
}
|
||||
} catch {
|
||||
setOpenrouterTestStatus("error");
|
||||
setOpenrouterTestMessage("Connection test failed");
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
@ -134,6 +167,8 @@ export default function TranslationServicesPage() {
|
||||
deeplApiKey,
|
||||
openaiApiKey,
|
||||
openaiModel,
|
||||
openrouterApiKey,
|
||||
openrouterModel,
|
||||
libreTranslateUrl: libreUrl,
|
||||
webllmModel,
|
||||
ollamaUrl,
|
||||
@ -553,6 +588,125 @@ export default function TranslationServicesPage() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* OpenRouter Settings */}
|
||||
{selectedProvider === "openrouter" && (
|
||||
<Card className="border-zinc-800 bg-zinc-900/50 border-teal-500/30">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Zap className="h-6 w-6 text-teal-400" />
|
||||
<div>
|
||||
<CardTitle className="text-white">OpenRouter Settings</CardTitle>
|
||||
<CardDescription>
|
||||
Access DeepSeek, Mistral, Llama & more - Best value for translation
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
{openrouterTestStatus !== "idle" && openrouterTestStatus !== "testing" && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
openrouterTestStatus === "success"
|
||||
? "border-green-500 text-green-400"
|
||||
: "border-red-500 text-red-400"
|
||||
}
|
||||
>
|
||||
{openrouterTestStatus === "success" && <CheckCircle className="h-3 w-3 mr-1" />}
|
||||
{openrouterTestStatus === "error" && <XCircle className="h-3 w-3 mr-1" />}
|
||||
{openrouterTestStatus === "success" ? "Connected" : "Error"}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="openrouter-key" className="text-zinc-300">
|
||||
API Key
|
||||
</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="openrouter-key"
|
||||
type="password"
|
||||
value={openrouterApiKey}
|
||||
onChange={(e) => setOpenrouterApiKey(e.target.value)}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
placeholder="sk-or-..."
|
||||
className="bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={testOpenRouterConnection}
|
||||
disabled={openrouterTestStatus === "testing"}
|
||||
className="border-zinc-700 text-zinc-300 hover:bg-zinc-800"
|
||||
>
|
||||
{openrouterTestStatus === "testing" ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Wifi className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{openrouterTestMessage && (
|
||||
<p className={`text-xs ${openrouterTestStatus === "success" ? "text-green-400" : "text-red-400"}`}>
|
||||
{openrouterTestMessage}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-zinc-500">
|
||||
Get your free API key from{" "}
|
||||
<a
|
||||
href="https://openrouter.ai/keys"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-teal-400 hover:underline"
|
||||
>
|
||||
openrouter.ai/keys
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="openrouter-model" className="text-zinc-300">
|
||||
Model
|
||||
</Label>
|
||||
<Select
|
||||
value={openrouterModel}
|
||||
onValueChange={setOpenrouterModel}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="openrouter-model"
|
||||
className="bg-zinc-800 border-zinc-700 text-white"
|
||||
>
|
||||
<SelectValue placeholder="Select a model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-zinc-800 border-zinc-700">
|
||||
{openrouterModels.map((model) => (
|
||||
<SelectItem
|
||||
key={model.id}
|
||||
value={model.id}
|
||||
className="text-white hover:bg-zinc-700"
|
||||
>
|
||||
<span className="flex items-center justify-between gap-4">
|
||||
<span>{model.name}</span>
|
||||
<Badge variant="outline" className="border-zinc-600 text-zinc-400 text-xs ml-2">
|
||||
{model.description.split(' - ')[1]}
|
||||
</Badge>
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-zinc-500">
|
||||
DeepSeek Chat offers the best quality/price ratio for translations
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg bg-teal-500/10 border border-teal-500/30 p-3">
|
||||
<p className="text-sm text-teal-300">
|
||||
💡 <strong>Recommended:</strong> DeepSeek Chat at $0.14/M tokens translates 200 pages for ~$0.50
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* OpenAI Settings */}
|
||||
{selectedProvider === "openai" && (
|
||||
<Card className="border-zinc-800 bg-zinc-900/50">
|
||||
|
||||
@ -24,7 +24,7 @@ const fileIcons: Record<string, React.ElementType> = {
|
||||
ppt: Presentation,
|
||||
};
|
||||
|
||||
type ProviderType = "google" | "ollama" | "deepl" | "libre" | "webllm" | "openai";
|
||||
type ProviderType = "google" | "ollama" | "deepl" | "libre" | "webllm" | "openai" | "openrouter";
|
||||
|
||||
export function FileUploader() {
|
||||
const { settings, isTranslating, progress, setTranslating, setProgress } = useTranslationStore();
|
||||
@ -220,6 +220,8 @@ export function FileUploader() {
|
||||
libreUrl: settings.libreTranslateUrl,
|
||||
openaiApiKey: settings.openaiApiKey,
|
||||
openaiModel: settings.openaiModel,
|
||||
openrouterApiKey: settings.openrouterApiKey,
|
||||
openrouterModel: settings.openrouterModel,
|
||||
});
|
||||
|
||||
clearInterval(progressInterval);
|
||||
|
||||
27
main.py
27
main.py
@ -277,7 +277,7 @@ async def translate_document(
|
||||
file: UploadFile = File(..., description="Document file to translate (.xlsx, .docx, or .pptx)"),
|
||||
target_language: str = Form(..., description="Target language code (e.g., 'es', 'fr', 'de')"),
|
||||
source_language: str = Form(default="auto", description="Source language code (default: auto-detect)"),
|
||||
provider: str = Form(default="google", description="Translation provider (google, ollama, deepl, libre, openai)"),
|
||||
provider: str = Form(default="openrouter", description="Translation provider (openrouter, google, ollama, deepl, libre, openai)"),
|
||||
translate_images: bool = Form(default=False, description="Translate images with multimodal Ollama/OpenAI model"),
|
||||
ollama_model: str = Form(default="", description="Ollama model to use (also used for vision if multimodal)"),
|
||||
system_prompt: str = Form(default="", description="Custom system prompt with context or instructions for LLM translation"),
|
||||
@ -285,6 +285,8 @@ async def translate_document(
|
||||
libre_url: str = Form(default="https://libretranslate.com", description="LibreTranslate server URL"),
|
||||
openai_api_key: str = Form(default="", description="OpenAI API key"),
|
||||
openai_model: str = Form(default="gpt-4o-mini", description="OpenAI model to use (gpt-4o-mini is cheapest with vision)"),
|
||||
openrouter_api_key: str = Form(default="", description="OpenRouter API key"),
|
||||
openrouter_model: str = Form(default="deepseek/deepseek-chat", description="OpenRouter model (deepseek/deepseek-chat is best value)"),
|
||||
cleanup: bool = Form(default=True, description="Delete input file after translation")
|
||||
):
|
||||
"""
|
||||
@ -361,9 +363,19 @@ async def translate_document(
|
||||
await cleanup_manager.track_file(output_path, ttl_minutes=60)
|
||||
|
||||
# Configure translation provider
|
||||
from services.translation_service import GoogleTranslationProvider, DeepLTranslationProvider, LibreTranslationProvider, OllamaTranslationProvider, OpenAITranslationProvider, translation_service
|
||||
from services.translation_service import GoogleTranslationProvider, DeepLTranslationProvider, LibreTranslationProvider, OllamaTranslationProvider, OpenAITranslationProvider, OpenRouterTranslationProvider, translation_service
|
||||
|
||||
if provider.lower() == "deepl":
|
||||
if provider.lower() == "openrouter":
|
||||
api_key = openrouter_api_key.strip() if openrouter_api_key else os.getenv("OPENROUTER_API_KEY", "")
|
||||
if not api_key:
|
||||
raise HTTPException(status_code=400, detail="OpenRouter API key not provided. Get one at https://openrouter.ai/keys")
|
||||
model_to_use = openrouter_model.strip() if openrouter_model else "deepseek/deepseek-chat"
|
||||
custom_prompt = build_full_prompt(system_prompt, glossary)
|
||||
logger.info(f"Using OpenRouter model: {model_to_use}")
|
||||
if custom_prompt:
|
||||
logger.info(f"Custom system prompt provided ({len(custom_prompt)} chars)")
|
||||
translation_provider = OpenRouterTranslationProvider(api_key, model_to_use, custom_prompt)
|
||||
elif provider.lower() == "deepl":
|
||||
if not config.DEEPL_API_KEY:
|
||||
raise HTTPException(status_code=400, detail="DeepL API key not configured")
|
||||
translation_provider = DeepLTranslationProvider(config.DEEPL_API_KEY)
|
||||
@ -391,8 +403,15 @@ async def translate_document(
|
||||
if custom_prompt:
|
||||
logger.info(f"Custom system prompt provided ({len(custom_prompt)} chars)")
|
||||
translation_provider = OllamaTranslationProvider(config.OLLAMA_BASE_URL, model_to_use, model_to_use, custom_prompt)
|
||||
else:
|
||||
elif provider.lower() == "google":
|
||||
translation_provider = GoogleTranslationProvider()
|
||||
else:
|
||||
# Default to OpenRouter with DeepSeek (best value)
|
||||
api_key = openrouter_api_key.strip() if openrouter_api_key else os.getenv("OPENROUTER_API_KEY", "")
|
||||
if api_key:
|
||||
translation_provider = OpenRouterTranslationProvider(api_key, "deepseek/deepseek-chat", build_full_prompt(system_prompt, glossary))
|
||||
else:
|
||||
translation_provider = GoogleTranslationProvider()
|
||||
|
||||
# Update the global translation service
|
||||
translation_service.provider = translation_provider
|
||||
|
||||
@ -331,7 +331,7 @@ class LanguageValidator:
|
||||
class ProviderValidator:
|
||||
"""Validates translation provider configuration"""
|
||||
|
||||
SUPPORTED_PROVIDERS = {"google", "ollama", "deepl", "libre", "openai", "webllm"}
|
||||
SUPPORTED_PROVIDERS = {"google", "ollama", "deepl", "libre", "openai", "webllm", "openrouter"}
|
||||
|
||||
@classmethod
|
||||
def validate(cls, provider: str, **kwargs) -> dict:
|
||||
|
||||
@ -483,6 +483,143 @@ ADDITIONAL CONTEXT AND INSTRUCTIONS:
|
||||
return []
|
||||
|
||||
|
||||
class OpenRouterTranslationProvider(TranslationProvider):
|
||||
"""
|
||||
OpenRouter API translation - Access to many cheap & high-quality models
|
||||
Recommended models for translation (by cost/quality):
|
||||
- deepseek/deepseek-chat: $0.14/M tokens - Excellent quality, very cheap
|
||||
- mistralai/mistral-7b-instruct: $0.06/M tokens - Fast and cheap
|
||||
- meta-llama/llama-3.1-8b-instruct: $0.06/M tokens - Good quality
|
||||
- google/gemma-2-9b-it: $0.08/M tokens - Good for European languages
|
||||
"""
|
||||
|
||||
def __init__(self, api_key: str, model: str = "deepseek/deepseek-chat", system_prompt: str = ""):
|
||||
self.api_key = api_key
|
||||
self.model = model
|
||||
self.custom_system_prompt = system_prompt
|
||||
self.base_url = "https://openrouter.ai/api/v1"
|
||||
self.provider_name = "openrouter"
|
||||
self._session = None
|
||||
|
||||
def _get_session(self):
|
||||
"""Get or create a requests session for connection pooling"""
|
||||
if self._session is None:
|
||||
import requests
|
||||
self._session = requests.Session()
|
||||
self._session.headers.update({
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"HTTP-Referer": "https://translate-app.local",
|
||||
"X-Title": "Document Translator",
|
||||
"Content-Type": "application/json"
|
||||
})
|
||||
return self._session
|
||||
|
||||
def translate(self, text: str, target_language: str, source_language: str = 'auto') -> str:
|
||||
if not text or not text.strip():
|
||||
return text
|
||||
|
||||
# Skip very short text or numbers only
|
||||
if len(text.strip()) < 2 or text.strip().isdigit():
|
||||
return text
|
||||
|
||||
# Check cache first
|
||||
cached = _translation_cache.get(text, target_language, source_language, self.provider_name)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
try:
|
||||
session = self._get_session()
|
||||
|
||||
# Optimized prompt for translation
|
||||
system_prompt = f"""Translate to {target_language}. Output ONLY the translation, nothing else. Preserve formatting."""
|
||||
|
||||
if self.custom_system_prompt:
|
||||
system_prompt = f"{system_prompt}\n\nContext: {self.custom_system_prompt}"
|
||||
|
||||
response = session.post(
|
||||
f"{self.base_url}/chat/completions",
|
||||
json={
|
||||
"model": self.model,
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": text}
|
||||
],
|
||||
"temperature": 0.2,
|
||||
"max_tokens": 1000
|
||||
},
|
||||
timeout=30
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
|
||||
translated = result.get("choices", [{}])[0].get("message", {}).get("content", "").strip()
|
||||
|
||||
if translated:
|
||||
# Cache the result
|
||||
_translation_cache.set(text, target_language, source_language, self.provider_name, translated)
|
||||
return translated
|
||||
return text
|
||||
|
||||
except Exception as e:
|
||||
print(f"OpenRouter translation error: {e}")
|
||||
return text
|
||||
|
||||
def translate_batch(self, texts: List[str], target_language: str, source_language: str = 'auto') -> List[str]:
|
||||
"""
|
||||
Batch translate using OpenRouter with parallel requests.
|
||||
Uses caching to avoid redundant translations.
|
||||
"""
|
||||
if not texts:
|
||||
return []
|
||||
|
||||
results = [''] * len(texts)
|
||||
texts_to_translate = []
|
||||
indices_to_translate = []
|
||||
|
||||
# Check cache first
|
||||
for i, text in enumerate(texts):
|
||||
if not text or not text.strip():
|
||||
results[i] = text if text else ''
|
||||
else:
|
||||
cached = _translation_cache.get(text, target_language, source_language, self.provider_name)
|
||||
if cached is not None:
|
||||
results[i] = cached
|
||||
else:
|
||||
texts_to_translate.append(text)
|
||||
indices_to_translate.append(i)
|
||||
|
||||
if not texts_to_translate:
|
||||
return results
|
||||
|
||||
# Translate in parallel batches
|
||||
import concurrent.futures
|
||||
|
||||
def translate_one(text: str) -> str:
|
||||
return self.translate(text, target_language, source_language)
|
||||
|
||||
# Use thread pool for parallel requests
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=10) as executor:
|
||||
translated = list(executor.map(translate_one, texts_to_translate))
|
||||
|
||||
# Map back results
|
||||
for idx, trans in zip(indices_to_translate, translated):
|
||||
results[idx] = trans
|
||||
|
||||
return results
|
||||
|
||||
@staticmethod
|
||||
def list_recommended_models() -> List[dict]:
|
||||
"""List recommended models for translation with pricing"""
|
||||
return [
|
||||
{"id": "deepseek/deepseek-chat", "name": "DeepSeek Chat", "price": "$0.14/M tokens", "quality": "Excellent", "speed": "Fast"},
|
||||
{"id": "mistralai/mistral-7b-instruct", "name": "Mistral 7B", "price": "$0.06/M tokens", "quality": "Good", "speed": "Very Fast"},
|
||||
{"id": "meta-llama/llama-3.1-8b-instruct", "name": "Llama 3.1 8B", "price": "$0.06/M tokens", "quality": "Good", "speed": "Fast"},
|
||||
{"id": "google/gemma-2-9b-it", "name": "Gemma 2 9B", "price": "$0.08/M tokens", "quality": "Good", "speed": "Fast"},
|
||||
{"id": "anthropic/claude-3-haiku", "name": "Claude 3 Haiku", "price": "$0.25/M tokens", "quality": "Excellent", "speed": "Fast"},
|
||||
{"id": "openai/gpt-4o-mini", "name": "GPT-4o Mini", "price": "$0.15/M tokens", "quality": "Excellent", "speed": "Fast"},
|
||||
]
|
||||
|
||||
|
||||
class WebLLMTranslationProvider(TranslationProvider):
|
||||
"""WebLLM browser-based translation (client-side processing)"""
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user