len: Minstral additions (also add start.sh)

This commit is contained in:
DazedAnon 2026-06-13 14:32:57 -05:00
parent 232df0bc7a
commit 80d203ec06
6 changed files with 4752 additions and 4193 deletions

View file

@ -1,3 +1,17 @@
# --------------------------------------------------
# Mistral-Specific Settings (Optional)
# --------------------------------------------------
# Budgets for the adaptive rate limiter. Mistral enforces a per-MINUTE request
# limit and a per-minute token limit, both PER-MODEL (e.g. mistral-medium is
# 25 req/min, ministral-3b is 750/min). The tool reads these from the live
# ratelimit response headers and paces calls so you never trip a 429 — so
# these seeds only matter for the very first request of a run.
# mistralReqPerSec is the per-SECOND seed (req-per-minute / 60).
#mistralReqPerSec="0.5"
#mistralTokPerMin="50000"
#mistralTokenHeadroom="4000"
# -------------------------------------------------- # --------------------------------------------------
# Gemini-Specific Settings (Optional) # Gemini-Specific Settings (Optional)
# -------------------------------------------------- # --------------------------------------------------
@ -9,11 +23,13 @@
# - Leave this variable out to use the model's default setting. # - Leave this variable out to use the model's default setting.
GEMINI_THINKING_BUDGET= GEMINI_THINKING_BUDGET=
# Set to "gemini" to use the Gemini API or "openai" for OpenAI-compatible APIs (If empty it will default to openai.) # Set to "gemini" to use the Gemini API, "mistral" for Mistral (la Plateforme),
# or "openai" for OpenAI-compatible APIs (If empty it will default to openai.)
API_PROVIDER=openai API_PROVIDER=openai
# API URL, leave blank to use OpenAI API. # API URL, leave blank to use OpenAI API.
# Nvidia example: "https://integrate.api.nvidia.com/v1/" # Nvidia example: "https://integrate.api.nvidia.com/v1/"
# Mistral example: "https://api.mistral.ai/v1/"
api="" api=""
# API key # API key
@ -24,6 +40,8 @@ organization=""
# LLM model name. # LLM model name.
# Default below works for OpenAI; for Gemini/Nvidia set your provider model name. # Default below works for OpenAI; for Gemini/Nvidia set your provider model name.
# For Mistral, "mistral-medium-3.5" is the recommended model
# (avoid "mistral-medium-latest" — it still points at the older 3.1).
model="gpt-4.1" model="gpt-4.1"
#The language to translate TO, Don't forget to change the prompt #The language to translate TO, Don't forget to change the prompt

View file

@ -112,13 +112,25 @@ This means Python wasn't added to your PATH. You have two options:
1. Inside the tool folder, find `.env.example` and make a copy of it named `.env`. 1. Inside the tool folder, find `.env.example` and make a copy of it named `.env`.
2. Open `.env` in any text editor (Notepad works fine) and fill in your API details: 2. Open `.env` in any text editor (Notepad works fine) and fill in your API details:
- `api` — Your API base URL (for Nvidia use `https://integrate.api.nvidia.com/v1/`). - `api` — Your API base URL (for Nvidia use `https://integrate.api.nvidia.com/v1/`, for Mistral use `https://api.mistral.ai/v1/`).
- `key` — Your API key. - `key` — Your API key.
- `organization` — Your organization key (make something up if using a self-hosted or non-OpenAI API). - `organization` — Your organization key (make something up if using a self-hosted or non-OpenAI API).
- `API_PROVIDER` — Use `openai` for OpenAI-compatible providers (including Nvidia), or `gemini` for Gemini. - `API_PROVIDER` — Use `openai` for OpenAI-compatible providers (including Nvidia), `gemini` for Gemini, or `mistral` for Mistral (only needed when `api` is left empty).
- `model` — For Nvidia/custom OpenAI-compatible endpoints, enter the model name manually (example: `deepseek-ai/deepseek-v4-pro`). - `model` — For Nvidia/custom OpenAI-compatible endpoints, enter the model name manually (example: `deepseek-ai/deepseek-v4-pro`).
3. The rest of the settings (wordwrap, batch size, etc.) can be left as defaults for now. You can tweak them later. 3. The rest of the settings (wordwrap, batch size, etc.) can be left as defaults for now. You can tweak them later.
> **Mistral note:** When the API URL points at `api.mistral.ai`, requests are paced by an
> adaptive rate limiter. Mistral enforces a **per-minute request limit** and a **per-minute
> token limit**, both of which are **per-model** — e.g. `mistral-medium` allows 25 req/min
> while `ministral-3b` allows 750/min (Mistral's dashboard shows these ÷60 as "RPS", so the
> effective rate ranges from well under 1 to over 12 requests/sec depending on the model). The
> limiter reads both limits straight from the live `x-ratelimit-*` response headers, spaces
> requests so it never overruns the per-minute request budget, charges them against a rolling
> per-minute token budget, and honours `Retry-After` on 429s. It starts from a conservative
> seed and pins itself to the exact per-model rate after the first response — so you can run
> multiple `fileThreads` and it won't error out, it just paces the calls. Override the seeds
> with `mistralReqPerSec`, `mistralTokPerMin`, and `mistralTokenHeadroom` if needed (rarely).
### 3. Launch the GUI ### 3. Launch the GUI
**Double-click `START.bat`**. It will: **Double-click `START.bat`**. It will:

245
START.sh Normal file
View file

@ -0,0 +1,245 @@
#!/usr/bin/env bash
# DazedMTLTool startup script for Linux/macOS
set -o pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
NEED_VENV_CREATE=0
VENV_DIR=""
CREATE_VENV_DIR=""
FOUND_PYTHON=""
pause_on_error() {
if [[ -t 0 ]]; then
read -r -p "Press Enter to exit..."
fi
}
die() {
echo "ERROR: $*"
pause_on_error
exit 1
}
python_version_ok() {
local version="$1"
local major="${version%%.*}"
local rest="${version#*.}"
local minor="${rest%%.*}"
[[ "$major" == "3" && "$minor" -ge 12 && "$minor" -lt 15 ]]
}
get_python_version() {
"$1" --version 2>&1 | awk '{print $2}'
}
check_python_version() {
[[ -n "$FOUND_PYTHON" ]] && return 0
local py="$1"
[[ -n "$py" && -x "$py" ]] || return 0
local ver
ver="$(get_python_version "$py" 2>/dev/null)" || return 0
if python_version_ok "$ver"; then
FOUND_PYTHON="$py"
fi
}
find_suitable_python() {
FOUND_PYTHON=""
local py candidate
while IFS= read -r py; do
check_python_version "$py"
[[ -n "$FOUND_PYTHON" ]] && return 0
done < <(type -a python3 python 2>/dev/null | awk '/ is / {print $NF}' | awk '!seen[$0]++')
for candidate in python3.14 python3.13 python3.12 python3 python; do
py="$(command -v "$candidate" 2>/dev/null || true)"
check_python_version "$py"
[[ -n "$FOUND_PYTHON" ]] && return 0
done
return 1
}
backup_venv() {
local target_dir="${1:-.venv}"
local bak_idx=1
while [[ -d "${target_dir}.bak_${bak_idx}" ]]; do
((bak_idx++))
done
if mv "$target_dir" "${target_dir}.bak_${bak_idx}"; then
echo "$target_dir renamed to ${target_dir}.bak_${bak_idx}"
else
echo "ERROR: Failed to back up $target_dir to ${target_dir}.bak_${bak_idx}."
echo "Please ensure no files are locked and try again."
return 1
fi
}
create_venv() {
[[ -n "$CREATE_VENV_DIR" ]] || CREATE_VENV_DIR=".venv"
echo "Creating new $CREATE_VENV_DIR using $FOUND_PYTHON ..."
if ! "$FOUND_PYTHON" -m venv "$CREATE_VENV_DIR"; then
die "Failed to create virtual environment."
fi
echo "Virtual environment created"
echo
VENV_DIR="$CREATE_VENV_DIR"
}
activate_venv() {
echo "Activating virtual environment..."
# shellcheck source=/dev/null
if source "$VENV_DIR/bin/activate"; then
echo "Virtual environment activated"
echo
return 0
fi
echo "ERROR: Failed to activate virtual environment at \"$VENV_DIR\"."
echo "Attempting to recreate the virtual environment with a compatible Python..."
CREATE_VENV_DIR="$VENV_DIR"
backup_venv "$VENV_DIR" || die "Failed to back up existing virtual environment."
if ! find_suitable_python; then
die "No suitable Python (>=3.12 and <3.15) found in PATH for recreation."
fi
echo "Recreating $CREATE_VENV_DIR using $FOUND_PYTHON ..."
if ! "$FOUND_PYTHON" -m venv "$CREATE_VENV_DIR"; then
die "Failed to create virtual environment during recreation."
fi
VENV_DIR="$CREATE_VENV_DIR"
echo "Retrying activation..."
# shellcheck source=/dev/null
if ! source "$VENV_DIR/bin/activate"; then
die "Activation failed after recreation."
fi
echo "Virtual environment activated"
echo
}
ensure_vocab_file() {
if [[ -f "vocab.txt" ]]; then
return 0
fi
if [[ -f "vocab.txt.example" ]]; then
echo "vocab.txt not found - creating from vocab.txt.example..."
if cp "vocab.txt.example" "vocab.txt"; then
echo "Created vocab.txt from vocab.txt.example"
else
echo "ERROR: Failed to copy vocab.txt.example to vocab.txt."
fi
else
echo "vocab.txt and vocab.txt.example not found - creating empty vocab.txt to avoid import errors..."
if : > "vocab.txt"; then
echo "Created empty vocab.txt"
else
echo "ERROR: Failed to create empty vocab.txt."
fi
fi
}
echo "=========================================="
echo " DazedMTLTool Startup Script"
echo "=========================================="
echo
echo "[1/4] Checking for a virtual environment..."
if [[ -d ".venv" ]]; then
VENV_DIR=".venv"
elif [[ -d "venv" ]]; then
VENV_DIR="venv"
fi
if [[ -n "$VENV_DIR" ]]; then
echo "$VENV_DIR found. Checking its Python version..."
venv_python="$VENV_DIR/bin/python"
if [[ ! -x "$venv_python" ]]; then
echo "ERROR: Python executable not found at \"$venv_python\"."
CREATE_VENV_DIR="$VENV_DIR"
backup_venv "$VENV_DIR" || die "Failed to back up existing virtual environment."
NEED_VENV_CREATE=1
else
venv_python_version="$(get_python_version "$venv_python" 2>/dev/null || true)"
if [[ -z "$venv_python_version" ]]; then
echo "ERROR: Could not determine Python version from \"$venv_python\"."
CREATE_VENV_DIR="$VENV_DIR"
backup_venv "$VENV_DIR" || die "Failed to back up existing virtual environment."
NEED_VENV_CREATE=1
else
echo "Detected Python version: $venv_python_version"
if python_version_ok "$venv_python_version"; then
echo "$VENV_DIR Python version $venv_python_version is compatible (>=3.12 and <3.15)."
else
echo "$VENV_DIR Python version $venv_python_version is not supported (requires >=3.12 and <3.15)"
echo "Backing up $VENV_DIR..."
CREATE_VENV_DIR="$VENV_DIR"
backup_venv "$VENV_DIR" || die "Failed to back up existing virtual environment."
NEED_VENV_CREATE=1
fi
fi
fi
else
echo "No existing virtual environment found."
CREATE_VENV_DIR=".venv"
NEED_VENV_CREATE=1
fi
echo
if [[ "$NEED_VENV_CREATE" -eq 1 ]]; then
echo "[2/4] Finding a compatible Python..."
if ! find_suitable_python; then
die "No suitable Python (>=3.12 and <3.15) found in PATH. Please install Python 3.12, 3.13, or 3.14 and ensure it is in your PATH."
fi
create_venv
fi
activate_venv
echo "Checking dependencies..."
echo "Checking if requirements are satisfied..."
if ! python -c "import PyQt5; import openai; import dotenv; import PIL; import anthropic; print('All dependencies satisfied')" >/dev/null 2>&1; then
echo "Upgrading pip..."
python -m pip install --upgrade pip >/dev/null 2>&1
echo "Installing/updating requirements..."
if ! pip install -r requirements.txt; then
die "Failed to install requirements."
fi
echo "Dependencies installed successfully"
else
echo "All dependencies are already satisfied"
fi
echo
echo "Checking RPG Maker Ace tools..."
if ! python -m util.ace.update_tools; then
echo "WARNING: Ace tool download failed. Ace translation features may not work until fixed."
fi
echo
echo "=========================================="
echo " Launching DazedMTLTool GUI..."
echo "=========================================="
echo
ensure_vocab_file
if ! python start_gui.py; then
echo
echo "ERROR: Failed to launch GUI."
echo "Check the error messages above."
pause_on_error
exit 1
fi
echo
echo "GUI closed successfully."

View file

@ -33,6 +33,7 @@ class ModelFetchThread(QThread):
"claude-opus-4-5", "claude-sonnet-4-5", "claude-haiku-4-5", "claude-opus-4-5", "claude-sonnet-4-5", "claude-haiku-4-5",
"gemini-2.0-flash", "gemini-2.5-flash", "gemini-2.5-pro", "gemini-2.0-flash", "gemini-2.5-flash", "gemini-2.5-pro",
"deepseek-chat", "deepseek-chat",
"mistral-medium-3.5", # best quality/cost Mistral for translation (don't use -latest, it points at the older 3.1)
] ]
def __init__(self, api_key, api_url, parent=None): def __init__(self, api_key, api_url, parent=None):
@ -341,6 +342,7 @@ class ConfigTab(QWidget):
("Claude (Anthropic)", "https://api.anthropic.com/v1"), ("Claude (Anthropic)", "https://api.anthropic.com/v1"),
("Gemini", "https://generativelanguage.googleapis.com/v1beta/openai/"), ("Gemini", "https://generativelanguage.googleapis.com/v1beta/openai/"),
("DeepSeek", "https://api.deepseek.com/v1/"), ("DeepSeek", "https://api.deepseek.com/v1/"),
("Mistral", "https://api.mistral.ai/v1/"),
("Nvidia", "https://integrate.api.nvidia.com/v1/"), ("Nvidia", "https://integrate.api.nvidia.com/v1/"),
] ]
for _name, _url in _url_presets: for _name, _url in _url_presets:

View file

@ -281,18 +281,30 @@ def _call_llm(messages: list, model: str, api_url: str, api_key: str,
out_tok = getattr(response.usage, "output_tokens", 0) or 0 out_tok = getattr(response.usage, "output_tokens", 0) or 0
return text, in_tok, out_tok return text, in_tok, out_tok
else: else:
import openai as _openai from util.translation import isMistralAPI, callMistral
client_kwargs = {"api_key": api_key} if isMistralAPI():
if api_url: # Mistral budgets requests AND tokens per minute — go through the
client_kwargs["base_url"] = api_url # shared adaptive limiter (same one the translation runs use).
client = _openai.OpenAI(**client_kwargs) response = callMistral({
response = client.chat.completions.create( "model": model,
model=model, "messages": messages,
messages=messages, "max_tokens": 512,
max_tokens=512, "temperature": 0,
temperature=0, "timeout": timeout,
timeout=timeout, })
) else:
import openai as _openai
client_kwargs = {"api_key": api_key}
if api_url:
client_kwargs["base_url"] = api_url
client = _openai.OpenAI(**client_kwargs)
response = client.chat.completions.create(
model=model,
messages=messages,
max_tokens=512,
temperature=0,
timeout=timeout,
)
text = _extract_openai_text(response) text = _extract_openai_text(response)
in_tok = getattr(response.usage, "prompt_tokens", 0) or 0 in_tok = getattr(response.usage, "prompt_tokens", 0) or 0
out_tok = getattr(response.usage, "completion_tokens", 0) or 0 out_tok = getattr(response.usage, "completion_tokens", 0) or 0
@ -351,6 +363,8 @@ class RewriteWorker(QThread):
if provider == "gemini": if provider == "gemini":
api_url = api_url or "https://generativelanguage.googleapis.com/v1beta/openai/" api_url = api_url or "https://generativelanguage.googleapis.com/v1beta/openai/"
elif provider == "mistral":
api_url = api_url or "https://api.mistral.ai/v1/"
is_claude = ( is_claude = (
any(x in model.lower() for x in ("claude", "sonnet", "haiku", "opus")) any(x in model.lower() for x in ("claude", "sonnet", "haiku", "opus"))

View file

@ -7,6 +7,7 @@ import os
import re import re
import json import json
import time import time
import random
import unicodedata import unicodedata
import tiktoken import tiktoken
import openai import openai
@ -113,6 +114,19 @@ def isClaudeNative(model):
return isClaudeModel(model) and (not live_api or "anthropic" in live_api.lower()) return isClaudeModel(model) and (not live_api or "anthropic" in live_api.lower())
def isMistralAPI():
"""True when requests go to the Mistral platform API (la Plateforme).
Detection is URL-based: the adaptive rate limiter keys off x-ratelimit-*
headers that only api.mistral.ai sends. Mistral models served through other
OpenAI-compatible providers (Nvidia, OpenRouter, ...) use the generic path.
"""
live_api = os.getenv("api", "").strip().lower()
if "mistral.ai" in live_api:
return True
return not live_api and os.getenv("API_PROVIDER", "").strip().lower() == "mistral"
# Models that REJECT sampling params (temperature/top_p/top_k) with a 400 — # Models that REJECT sampling params (temperature/top_p/top_k) with a 400 —
# Claude Opus 4.7 and up retired them in favour of adaptive thinking. Matches # Claude Opus 4.7 and up retired them in favour of adaptive thinking. Matches
# opus-4-7, opus-4-8, opus-4-10+ and fable, but NOT opus-4-6 or Sonnet/Haiku. # opus-4-7, opus-4-8, opus-4-10+ and fable, but NOT opus-4-6 or Sonnet/Haiku.
@ -359,7 +373,7 @@ def validate_translation_content(original_items, translated_items, langRegex):
return is_valid, invalid_indices, reasons return is_valid, invalid_indices, reasons
# Load .env, strip accidental whitespace, set base URL / org / API key. # Load .env, strip accidental whitespace, set base URL / org / API key.
# Gemini uses its compatibility endpoint only when no custom API URL is set. # Gemini/Mistral use their endpoints only when no custom API URL is set.
load_dotenv() load_dotenv()
api_provider = os.getenv("API_PROVIDER", "openai").lower() api_provider = os.getenv("API_PROVIDER", "openai").lower()
env_api = os.getenv("api", "").strip() env_api = os.getenv("api", "").strip()
@ -367,6 +381,9 @@ if api_provider == "gemini" and not env_api:
# Use Google Generative Language compatibility endpoint only as fallback. # Use Google Generative Language compatibility endpoint only as fallback.
openai.base_url = "https://generativelanguage.googleapis.com/v1beta/openai/" openai.base_url = "https://generativelanguage.googleapis.com/v1beta/openai/"
openai.organization = None openai.organization = None
elif api_provider == "mistral" and not env_api:
openai.base_url = "https://api.mistral.ai/v1/"
openai.organization = None
else: else:
if env_api: if env_api:
openai.base_url = _normalize_openai_base_url(env_api) openai.base_url = _normalize_openai_base_url(env_api)
@ -1067,6 +1084,216 @@ def runTranslationBatches(poll=60):
return fetchTranslationBatches() return fetchTranslationBatches()
# ===== Mistral (la Plateforme) adaptive rate limiting =====
# Mistral enforces independent request and token limits, both PER-MODEL.
# Verified live against api.mistral.ai (the response headers are authoritative):
# * x-ratelimit-limit-req-minute — requests/minute. PER-MODEL and varies a
# lot: mistral-medium=25, mistral-small=50, codestral=125, ministral-3b=750.
# Mistral's dashboard shows this /60 as "RPS" (25/min = 0.42 "RPS"), so the
# effective rate spans well below AND above 1 req/sec. There is NO
# per-second header; we divide req-minute by 60 to get the pacing interval.
# * x-ratelimit-limit-tokens-minute — input+output throughput, a true minute
# window, also per-model (e.g. 50k1.3M).
# * Tokens per month — overall cap (not paced here; surfaces as a 429).
# The limiter paces requests >= 1/RPS apart (so a burst of file threads can't
# overrun the per-minute request budget) AND charges them against a
# minute-windowed token budget. It starts from a conservative seed, then the
# live headers on the first response pin req_per_sec / tok_per_min to the exact
# per-model values. One limiter is shared across all file threads (this tool
# runs one model per session). Override the seeds with
# mistralReqPerSec / mistralTokPerMin / mistralTokenHeadroom.
_mistral_limiter = None
_mistral_limiter_lock = threading.Lock()
def _estimate_mistral_tokens(text):
# JP ~1 token/char with Mistral tokenizers; EN ~1 per 3.5 chars. Be pessimistic.
return int(len(str(text)) * 1.1) + 8
def _header_int(headers, *names):
"""First present header among names parsed as int, else None."""
for n in names:
v = headers.get(n)
if v is not None and str(v).strip() != "":
try:
return int(float(v))
except (TypeError, ValueError):
pass
return None
def _header_float(headers, *names):
"""First present header among names parsed as float, else None.
Used for the RPS limit header, which per-model is often FRACTIONAL
(e.g. 0.08, 0.42, 0.83) parsing it as int would floor those to 0."""
for n in names:
v = headers.get(n)
if v is not None and str(v).strip() != "":
try:
return float(v)
except (TypeError, ValueError):
pass
return None
class AdaptiveLimiter:
"""Paces requests off live ratelimit headers.
Two dimensions, enforced together by acquire():
* requests: spaced >= min_interval (1/RPS) apart Mistral's request cap
is per-second, so this prevents bursts from tripping it.
* tokens: a minute-windowed budget (input+output), with a headroom margin.
"""
def __init__(self, req_per_sec, tok_per_min, headroom):
self.lock = threading.Lock()
self.req_per_sec = max(0.001, float(req_per_sec))
self.tok_per_min = tok_per_min
self.headroom = headroom # margin left under the TPM cap
self.min_interval = 1.0 / self.req_per_sec
self.next_request_at = time.monotonic()
self.tokens_remaining = tok_per_min
self.window_reset = time.monotonic() + 60
def acquire(self, est_tokens):
"""Block until the request clears both the RPS pace and the TPM budget."""
while True:
with self.lock:
now = time.monotonic()
if now >= self.window_reset:
self.tokens_remaining = self.tok_per_min
self.window_reset = now + 60
# token gate
if self.tokens_remaining - est_tokens <= self.headroom:
sleep_for = max(0.05, self.window_reset - now)
# request-pace gate
elif now < self.next_request_at:
sleep_for = self.next_request_at - now
else:
# both gates clear — reserve this slot
self.next_request_at = max(now, self.next_request_at) + self.min_interval
self.tokens_remaining -= est_tokens
return
time.sleep(min(sleep_for, 5))
def update(self, headers):
"""Sync budgets from the live ratelimit headers (authoritative).
Accepts both the per-second request headers and (for forward/back compat)
the older per-minute request header names; the token headers are minute."""
if not headers:
return
with self.lock:
# The authoritative request limit is x-ratelimit-limit-req-MINUTE
# (verified live: mistral-medium=25, ministral-3b=750, codestral=125;
# Mistral's dashboard "RPS" is just this number / 60). There is no
# per-second header. Convert to RPS for the pacing interval. A
# per-second header is still accepted first in case Mistral adds one.
limit_rps = _header_float(headers, "x-ratelimit-limit-req-second",
"x-ratelimit-limit-requests-second")
if limit_rps is None:
limit_rpm = _header_float(headers, "x-ratelimit-limit-req-minute",
"x-ratelimit-limit-requests-minute")
if limit_rpm is not None:
limit_rps = limit_rpm / 60.0
if limit_rps and limit_rps > 0:
self.req_per_sec = limit_rps
self.min_interval = 1.0 / self.req_per_sec
limit_tok = _header_int(headers, "x-ratelimit-limit-tokens-minute",
"x-ratelimit-limit-tokens")
if limit_tok and limit_tok > 0:
self.tok_per_min = limit_tok
rem_tok = _header_int(headers, "x-ratelimit-remaining-tokens-minute",
"x-ratelimit-remaining-tokens")
if rem_tok is not None:
self.tokens_remaining = rem_tok
# Remaining requests this minute -> also cap the pace so a fresh
# limiter that joins mid-window doesn't burst the leftover budget.
rem_req = _header_int(headers, "x-ratelimit-remaining-req-minute",
"x-ratelimit-remaining-requests-minute")
if rem_req is not None and rem_req <= 0:
# budget already spent this minute — hold off ~ a minute
self.next_request_at = max(self.next_request_at, time.monotonic() + 60.0)
def _get_mistral_limiter():
global _mistral_limiter
with _mistral_limiter_lock:
if _mistral_limiter is None:
# mistralReqPerMin kept as a deprecated alias: a per-minute number is
# converted to RPS so old .env files keep pacing sanely.
rps_env = os.getenv("mistralReqPerSec")
if rps_env:
req_per_sec = float(rps_env)
elif os.getenv("mistralReqPerMin"):
req_per_sec = max(0.05, float(os.getenv("mistralReqPerMin")) / 60.0)
else:
# Per-model RPS varies and is often well under 1 (free-tier
# mistral-medium ~0.83, magistral ~0.08). Start conservative so
# the FIRST request doesn't over-burst; the live header from that
# first response then corrects req_per_sec to the model's exact
# value (which is why few large batches > many tiny ones).
req_per_sec = 0.5
_mistral_limiter = AdaptiveLimiter(
req_per_sec,
int(os.getenv("mistralTokPerMin", "50000") or 50000),
int(os.getenv("mistralTokenHeadroom", "4000") or 4000),
)
return _mistral_limiter
def callMistral(params, retries=6):
"""Call the Mistral chat completions endpoint with adaptive pacing.
Acquires from the shared limiter before each attempt, syncs budgets from
the live x-ratelimit headers after each response, honours Retry-After on
429, backs off with jitter on 5xx/network errors, and downgrades
json_schema -> json_object for models without structured-output support.
"""
limiter = _get_mistral_limiter()
est = sum(_estimate_mistral_tokens(m.get("content", "")) for m in params.get("messages", []))
est += params.get("max_tokens", 0) // 2
last_error = None
for attempt in range(retries + 1):
limiter.acquire(est)
try:
raw = openai.chat.completions.with_raw_response.create(**params)
response = raw.parse()
limiter.update(raw.headers)
return response
except RateLimitError as e:
last_error = e
resp = getattr(e, "response", None)
limiter.update(getattr(resp, "headers", None))
retry_after = None
try:
retry_after = float(resp.headers.get("retry-after"))
except (AttributeError, TypeError, ValueError):
pass
time.sleep(retry_after if retry_after is not None else min(60, 2 ** attempt + random.random() * 2))
except APIStatusError as e:
last_error = e
# Mistral rejects unsupported params with 400/422 — downgrade the
# structured-output format once and retry immediately.
if e.status_code in (400, 422) and (params.get("response_format") or {}).get("type") == "json_schema":
params = dict(params)
params["response_format"] = {"type": "json_object"}
continue
if e.status_code >= 500 and attempt < retries:
time.sleep(min(45, 2 ** attempt + random.random()))
continue
raise Exception(f"Mistral API error ({e.status_code}): {e}")
except APIConnectionError as e:
last_error = e
if attempt < retries:
time.sleep(min(45, 2 ** attempt + random.random()))
continue
raise Exception(f"Mistral connection error: {e}")
raise Exception(f"Mistral API failed after {retries + 1} attempts: {last_error}")
class TranslationConfig: class TranslationConfig:
"""Configuration class to hold all translation settings""" """Configuration class to hold all translation settings"""
@ -1288,6 +1515,28 @@ def getPricingConfig(model):
cfg = {"inputAPICost": 1.25, "outputAPICost": 10.00, "batchSize": 30, "frequencyPenalty": 0.05} cfg = {"inputAPICost": 1.25, "outputAPICost": 10.00, "batchSize": 30, "frequencyPenalty": 0.05}
elif "deepseek" in model: elif "deepseek" in model:
cfg = {"inputAPICost": 0.27, "outputAPICost": 1.10, "batchSize": 30, "frequencyPenalty": 0.05} cfg = {"inputAPICost": 0.27, "outputAPICost": 1.10, "batchSize": 30, "frequencyPenalty": 0.05}
# Mistral — system prompt is resent per request (no prompt caching), so
# throughput/cost favor larger batches. Live LiteLLM pricing overrides these.
elif "mistral-large" in model or "pixtral-large" in model:
cfg = {"inputAPICost": 2.00, "outputAPICost": 6.00, "batchSize": 40, "frequencyPenalty": 0.0}
elif "magistral-medium" in model:
cfg = {"inputAPICost": 2.00, "outputAPICost": 5.00, "batchSize": 40, "frequencyPenalty": 0.0}
elif "mistral-medium-3.5" in model or "mistral-medium-3-5" in model or "mistral-medium-26" in model:
# Medium 3.5 (v26.04) — also matches dated 26xx ids
cfg = {"inputAPICost": 1.50, "outputAPICost": 7.50, "batchSize": 40, "frequencyPenalty": 0.0}
elif "mistral-medium" in model:
# Medium 3 / 3.1 (what -latest still points at)
cfg = {"inputAPICost": 0.40, "outputAPICost": 2.00, "batchSize": 40, "frequencyPenalty": 0.0}
elif "magistral-small" in model:
cfg = {"inputAPICost": 0.50, "outputAPICost": 1.50, "batchSize": 40, "frequencyPenalty": 0.0}
elif "mistral-small" in model:
cfg = {"inputAPICost": 0.10, "outputAPICost": 0.30, "batchSize": 40, "frequencyPenalty": 0.0}
elif "ministral" in model or "open-mistral" in model or "mistral-nemo" in model:
cfg = {"inputAPICost": 0.10, "outputAPICost": 0.10, "batchSize": 40, "frequencyPenalty": 0.0}
elif "codestral" in model:
cfg = {"inputAPICost": 0.30, "outputAPICost": 0.90, "batchSize": 40, "frequencyPenalty": 0.0}
elif "mistral" in model or "pixtral" in model:
cfg = {"inputAPICost": 2.00, "outputAPICost": 6.00, "batchSize": 40, "frequencyPenalty": 0.0}
elif "claude-opus-4-5" in model or "claude-opus-4-6" in model: elif "claude-opus-4-5" in model or "claude-opus-4-6" in model:
cfg = {"inputAPICost": 5.00, "outputAPICost": 25.00, "batchSize": 30, "frequencyPenalty": 0.05} cfg = {"inputAPICost": 5.00, "outputAPICost": 25.00, "batchSize": 30, "frequencyPenalty": 0.05}
elif "claude-opus" in model or model == "claude-3-opus": elif "claude-opus" in model or model == "claude-3-opus":
@ -1650,6 +1899,7 @@ def translateText(system, user, history, penalty, formatType, model, numLines=No
and (not _live_api_check or "anthropic" in _live_api_check.lower()) and (not _live_api_check or "anthropic" in _live_api_check.lower())
) )
_is_deepseek = model and "deepseek" in model.lower() _is_deepseek = model and "deepseek" in model.lower()
_is_mistral = not _is_claude and isMistralAPI()
# Build message list. # Build message list.
# Claude: static prompt gets cache_control; vocab appended uncached so it # Claude: static prompt gets cache_control; vocab appended uncached so it
@ -1717,6 +1967,8 @@ def translateText(system, user, history, penalty, formatType, model, numLines=No
_live_provider = os.getenv("API_PROVIDER", "openai").lower() _live_provider = os.getenv("API_PROVIDER", "openai").lower()
if _live_provider == "gemini" and not _live_api: if _live_provider == "gemini" and not _live_api:
openai.base_url = "https://generativelanguage.googleapis.com/v1beta/openai/" openai.base_url = "https://generativelanguage.googleapis.com/v1beta/openai/"
elif _live_provider == "mistral" and not _live_api:
openai.base_url = "https://api.mistral.ai/v1/"
elif _live_api: elif _live_api:
openai.base_url = _normalize_openai_base_url(_live_api) openai.base_url = _normalize_openai_base_url(_live_api)
if _live_key: if _live_key:
@ -1755,6 +2007,12 @@ def translateText(system, user, history, penalty, formatType, model, numLines=No
elif _is_claude: elif _is_claude:
params["temperature"] = 0 params["temperature"] = 0
# cache_control is set on the system message content block above. # cache_control is set on the system message content block above.
elif _is_mistral:
params["temperature"] = 0
# max_tokens caps the response AND feeds the limiter's token estimate
# (input+output count against the per-minute budget). Sized from the
# payload: JP ~1 token/char, output echoes the JSON scaffold + EN.
params["max_tokens"] = min(16000, max(1500, int(len(str(user)) * 1.2) + 40 * (numLines or 1)))
else: # Default to OpenAI behavior else: # Default to OpenAI behavior
if "gpt-5" in model: if "gpt-5" in model:
params["reasoning_effort"] = "minimal" params["reasoning_effort"] = "minimal"
@ -1789,6 +2047,16 @@ def translateText(system, user, history, penalty, formatType, model, numLines=No
_write_request_debug_log("anthropic", ant_kwargs, compat_response.usage) _write_request_debug_log("anthropic", ant_kwargs, compat_response.usage)
return compat_response return compat_response
# Mistral (la Plateforme): same OpenAI-compatible request, but routed
# through the adaptive rate limiter so the per-minute request/token
# budgets are paced off the live response headers instead of tripping 429s.
if _is_mistral:
response = callMistral(params)
if not response or not hasattr(response, 'choices') or not response.choices:
raise Exception("API returned invalid or empty response - retrying...")
_write_request_debug_log("mistral", params, getattr(response, "usage", None))
return response
# Call API (reaches here only for non-Claude providers) # Call API (reaches here only for non-Claude providers)
try: try:
response = openai.chat.completions.create(**params) response = openai.chat.completions.create(**params)