len: Minstral additions (also add start.sh)
This commit is contained in:
parent
232df0bc7a
commit
80d203ec06
6 changed files with 4752 additions and 4193 deletions
20
.env.example
20
.env.example
|
|
@ -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
|
||||||
|
|
|
||||||
16
README.md
16
README.md
|
|
@ -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
245
START.sh
Normal 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."
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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"))
|
||||||
|
|
|
||||||
|
|
@ -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. 50k–1.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)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue