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 { settings } = useTranslationStore();
|
||||||
|
|
||||||
const providerNames: Record<string, string> = {
|
const providerNames: Record<string, string> = {
|
||||||
|
openrouter: "OpenRouter",
|
||||||
google: "Google Translate",
|
google: "Google Translate",
|
||||||
ollama: "Ollama",
|
ollama: "Ollama",
|
||||||
deepl: "DeepL",
|
deepl: "DeepL",
|
||||||
|
|||||||
@ -7,10 +7,10 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Switch } from "@/components/ui/switch";
|
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 { providers, testOpenAIConnection, testOllamaConnection, getOllamaModels, type OllamaModel } from "@/lib/api";
|
||||||
import { useWebLLM } from "@/lib/webllm";
|
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 {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@ -30,6 +30,8 @@ export default function TranslationServicesPage() {
|
|||||||
const [deeplApiKey, setDeeplApiKey] = useState(settings.deeplApiKey);
|
const [deeplApiKey, setDeeplApiKey] = useState(settings.deeplApiKey);
|
||||||
const [openaiApiKey, setOpenaiApiKey] = useState(settings.openaiApiKey);
|
const [openaiApiKey, setOpenaiApiKey] = useState(settings.openaiApiKey);
|
||||||
const [openaiModel, setOpenaiModel] = useState(settings.openaiModel);
|
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 [libreUrl, setLibreUrl] = useState(settings.libreTranslateUrl);
|
||||||
const [webllmModel, setWebllmModel] = useState(settings.webllmModel);
|
const [webllmModel, setWebllmModel] = useState(settings.webllmModel);
|
||||||
|
|
||||||
@ -44,6 +46,10 @@ export default function TranslationServicesPage() {
|
|||||||
// OpenAI connection test state
|
// OpenAI connection test state
|
||||||
const [openaiTestStatus, setOpenaiTestStatus] = useState<"idle" | "testing" | "success" | "error">("idle");
|
const [openaiTestStatus, setOpenaiTestStatus] = useState<"idle" | "testing" | "success" | "error">("idle");
|
||||||
const [openaiTestMessage, setOpenaiTestMessage] = useState("");
|
const [openaiTestMessage, setOpenaiTestMessage] = useState("");
|
||||||
|
|
||||||
|
// OpenRouter connection test state
|
||||||
|
const [openrouterTestStatus, setOpenrouterTestStatus] = useState<"idle" | "testing" | "success" | "error">("idle");
|
||||||
|
const [openrouterTestMessage, setOpenrouterTestMessage] = useState("");
|
||||||
|
|
||||||
// WebLLM hook
|
// WebLLM hook
|
||||||
const webllm = useWebLLM();
|
const webllm = useWebLLM();
|
||||||
@ -54,6 +60,8 @@ export default function TranslationServicesPage() {
|
|||||||
setDeeplApiKey(settings.deeplApiKey);
|
setDeeplApiKey(settings.deeplApiKey);
|
||||||
setOpenaiApiKey(settings.openaiApiKey);
|
setOpenaiApiKey(settings.openaiApiKey);
|
||||||
setOpenaiModel(settings.openaiModel);
|
setOpenaiModel(settings.openaiModel);
|
||||||
|
setOpenrouterApiKey(settings.openrouterApiKey);
|
||||||
|
setOpenrouterModel(settings.openrouterModel);
|
||||||
setLibreUrl(settings.libreTranslateUrl);
|
setLibreUrl(settings.libreTranslateUrl);
|
||||||
setWebllmModel(settings.webllmModel);
|
setWebllmModel(settings.webllmModel);
|
||||||
setOllamaUrl(settings.ollamaUrl);
|
setOllamaUrl(settings.ollamaUrl);
|
||||||
@ -124,6 +132,31 @@ export default function TranslationServicesPage() {
|
|||||||
setOpenaiTestMessage("Connection test failed");
|
setOpenaiTestMessage("Connection test failed");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 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 () => {
|
const handleSave = async () => {
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
@ -134,6 +167,8 @@ export default function TranslationServicesPage() {
|
|||||||
deeplApiKey,
|
deeplApiKey,
|
||||||
openaiApiKey,
|
openaiApiKey,
|
||||||
openaiModel,
|
openaiModel,
|
||||||
|
openrouterApiKey,
|
||||||
|
openrouterModel,
|
||||||
libreTranslateUrl: libreUrl,
|
libreTranslateUrl: libreUrl,
|
||||||
webllmModel,
|
webllmModel,
|
||||||
ollamaUrl,
|
ollamaUrl,
|
||||||
@ -553,6 +588,125 @@ export default function TranslationServicesPage() {
|
|||||||
</Card>
|
</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 */}
|
{/* OpenAI Settings */}
|
||||||
{selectedProvider === "openai" && (
|
{selectedProvider === "openai" && (
|
||||||
<Card className="border-zinc-800 bg-zinc-900/50">
|
<Card className="border-zinc-800 bg-zinc-900/50">
|
||||||
|
|||||||
@ -24,7 +24,7 @@ const fileIcons: Record<string, React.ElementType> = {
|
|||||||
ppt: Presentation,
|
ppt: Presentation,
|
||||||
};
|
};
|
||||||
|
|
||||||
type ProviderType = "google" | "ollama" | "deepl" | "libre" | "webllm" | "openai";
|
type ProviderType = "google" | "ollama" | "deepl" | "libre" | "webllm" | "openai" | "openrouter";
|
||||||
|
|
||||||
export function FileUploader() {
|
export function FileUploader() {
|
||||||
const { settings, isTranslating, progress, setTranslating, setProgress } = useTranslationStore();
|
const { settings, isTranslating, progress, setTranslating, setProgress } = useTranslationStore();
|
||||||
@ -220,6 +220,8 @@ export function FileUploader() {
|
|||||||
libreUrl: settings.libreTranslateUrl,
|
libreUrl: settings.libreTranslateUrl,
|
||||||
openaiApiKey: settings.openaiApiKey,
|
openaiApiKey: settings.openaiApiKey,
|
||||||
openaiModel: settings.openaiModel,
|
openaiModel: settings.openaiModel,
|
||||||
|
openrouterApiKey: settings.openrouterApiKey,
|
||||||
|
openrouterModel: settings.openrouterModel,
|
||||||
});
|
});
|
||||||
|
|
||||||
clearInterval(progressInterval);
|
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)"),
|
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')"),
|
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)"),
|
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"),
|
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)"),
|
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"),
|
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"),
|
libre_url: str = Form(default="https://libretranslate.com", description="LibreTranslate server URL"),
|
||||||
openai_api_key: str = Form(default="", description="OpenAI API key"),
|
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)"),
|
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")
|
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)
|
await cleanup_manager.track_file(output_path, ttl_minutes=60)
|
||||||
|
|
||||||
# Configure translation provider
|
# 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:
|
if not config.DEEPL_API_KEY:
|
||||||
raise HTTPException(status_code=400, detail="DeepL API key not configured")
|
raise HTTPException(status_code=400, detail="DeepL API key not configured")
|
||||||
translation_provider = DeepLTranslationProvider(config.DEEPL_API_KEY)
|
translation_provider = DeepLTranslationProvider(config.DEEPL_API_KEY)
|
||||||
@ -391,8 +403,15 @@ async def translate_document(
|
|||||||
if custom_prompt:
|
if custom_prompt:
|
||||||
logger.info(f"Custom system prompt provided ({len(custom_prompt)} chars)")
|
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)
|
translation_provider = OllamaTranslationProvider(config.OLLAMA_BASE_URL, model_to_use, model_to_use, custom_prompt)
|
||||||
else:
|
elif provider.lower() == "google":
|
||||||
translation_provider = GoogleTranslationProvider()
|
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
|
# Update the global translation service
|
||||||
translation_service.provider = translation_provider
|
translation_service.provider = translation_provider
|
||||||
|
|||||||
@ -331,7 +331,7 @@ class LanguageValidator:
|
|||||||
class ProviderValidator:
|
class ProviderValidator:
|
||||||
"""Validates translation provider configuration"""
|
"""Validates translation provider configuration"""
|
||||||
|
|
||||||
SUPPORTED_PROVIDERS = {"google", "ollama", "deepl", "libre", "openai", "webllm"}
|
SUPPORTED_PROVIDERS = {"google", "ollama", "deepl", "libre", "openai", "webllm", "openrouter"}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate(cls, provider: str, **kwargs) -> dict:
|
def validate(cls, provider: str, **kwargs) -> dict:
|
||||||
|
|||||||
@ -483,6 +483,143 @@ ADDITIONAL CONTEXT AND INSTRUCTIONS:
|
|||||||
return []
|
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):
|
class WebLLMTranslationProvider(TranslationProvider):
|
||||||
"""WebLLM browser-based translation (client-side processing)"""
|
"""WebLLM browser-based translation (client-side processing)"""
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user