feat(i18n): OpenAI 兼容翻译后端 + fork PR 自动翻译回推 (#39)

## 目标

1. 翻译后端支持**任意 OpenAI 兼容接口**(自定义 URL +
Token):OpenAI、DeepSeek、Qwen、one-api/new-api 网关、vLLM 等
2. **fork PR 也能自动翻译并回推**——不再要求外部贡献者自己跑翻译脚本

## translate.py

- 新增 `TRANSLATE_BACKEND=openai` 后端:`TRANSLATE_ENDPOINT`(或
`OPENAI_BASE_URL`,以 /v1 结尾)+ `TRANSLATE_API_KEY`(或 `OPENAI_API_KEY`)
- gpt-5/o 系模型名自动用 `max_completion_tokens` 且不传 temperature;其余模型走经典
`max_tokens` + `temperature=0.1` 契约
- 已用本地 mock 服务器端到端验证两种契约、env 与 CLI 两种配置方式、产物 frontmatter(`translated_by:
ai:openai:<model>`)

## workflow 安全模型(评审重点)

触发器从 `pull_request` 改为 `pull_request_target`(fork PR 获得 secrets 与写 token
的唯一途径),配套的防护:

- **绝不执行 PR 代码**:workflow 与 `scripts/i18n/*` 一律来自 base 分支;PR 内容通过
`refs/pull/N/merge` 以**纯数据**方式覆盖(仅
`skills/`、`manifest.json`、`categories.json`)
- 覆盖后 `find skills -type l -delete` 丢弃符号链接(防经软链读 runner 文件系统并借"翻译"外泄)
- PR 派生值(head repo/ref)一律经 `env:` 注入,不在 `run:` 内 `${{ }}` 插值(防命令注入)
- 回推:在 PR head 的独立 worktree 上**只应用翻译产物文件**后用 bot token 推送;head SHA
变更则放弃(让新 run 接手);bot actor 触发的 run 直接跳过(防循环)

## 使用自定义端点需要配置

| 类型 | 名称 | 值 |
|---|---|---|
| secret | `TRANSLATE_API_KEY` | 你的 token |
| secret 或 variable | `TRANSLATE_ENDPOINT` | 如
`https://your-gateway.example.com/v1` |
| variable | `TRANSLATE_BACKEND` | `openai` |
| variable | `TRANSLATE_MODEL` | 端点上的模型 id |

注:推送到 fork 分支要求 `DESIRECORE_BOT_TOKEN` 是具有本仓库 push 权限的**用户
PAT**(GITHUB_TOKEN 与 App token 推不了 fork),且 PR 勾选 Allow edits by
maintainers;推不了时会以清晰错误失败并打标签。

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 18:54:57 +08:00
committed by GitHub
parent 61336c68b5
commit dc2476e630
2 changed files with 191 additions and 89 deletions

View File

@@ -10,13 +10,18 @@ manifest.json/supportedLocales. When a target locale is missing or stale (its
source_hash differs from the current source body+strings hash), translate from
metadata.i18n.<source_locale>.body using an LLM.
Backends (auto-selected, in this priority):
Backends (selected via TRANSLATE_BACKEND):
1. GitHub Models (default) — uses GITHUB_TOKEN with `models: read` permission,
OpenAI-compatible chat-completions API at https://models.github.ai/inference.
Model defaults to `openai/gpt-5-mini` (configure with TRANSLATE_MODEL).
2. Anthropic API direct — used when ANTHROPIC_API_KEY is set AND
TRANSLATE_BACKEND=anthropic. Endpoint https://api.anthropic.com/v1/messages.
Model should be a Claude model id (e.g. claude-sonnet-4-6).
3. Generic OpenAI-compatible — TRANSLATE_BACKEND=openai. Any endpoint that
speaks the OpenAI chat-completions contract (OpenAI, DeepSeek, Qwen,
one-api/new-api gateways, vLLM, Ollama, ...). Set TRANSLATE_ENDPOINT (or
OPENAI_BASE_URL) to the API base ending in /v1 (or your gateway's
equivalent) and TRANSLATE_API_KEY (or OPENAI_API_KEY) for auth.
Translations preserve:
- Markdown structure (heading hierarchy, list ordering, tables, fences)
@@ -41,9 +46,10 @@ Usage:
Env:
GITHUB_TOKEN required when backend=github (CI: provided automatically)
ANTHROPIC_API_KEY required when TRANSLATE_BACKEND=anthropic
TRANSLATE_BACKEND 'github' (default) | 'anthropic'
TRANSLATE_API_KEY required when TRANSLATE_BACKEND=openai (or OPENAI_API_KEY)
TRANSLATE_BACKEND 'github' (default) | 'anthropic' | 'openai'
TRANSLATE_MODEL backend-specific model id; default depends on backend
TRANSLATE_ENDPOINT override endpoint URL
TRANSLATE_ENDPOINT override endpoint URL (openai backend: OPENAI_BASE_URL also works)
TRANSLATE_MAX_RETRIES default 3
"""
from __future__ import annotations
@@ -71,10 +77,12 @@ DEFAULT_BACKEND = os.environ.get("TRANSLATE_BACKEND", "github").lower()
DEFAULT_MODEL_BY_BACKEND = {
"github": os.environ.get("TRANSLATE_MODEL", "openai/gpt-5-mini"),
"anthropic": os.environ.get("TRANSLATE_MODEL", "claude-sonnet-4-6"),
"openai": os.environ.get("TRANSLATE_MODEL", "gpt-4o-mini"),
}
DEFAULT_ENDPOINT_BY_BACKEND = {
"github": "https://models.github.ai/inference",
"anthropic": "https://api.anthropic.com",
"openai": "https://api.openai.com/v1",
}
MAX_RETRIES = int(os.environ.get("TRANSLATE_MAX_RETRIES", "3"))
HTTP_TIMEOUT = httpx.Timeout(connect=10, read=180, write=30, pool=10)
@@ -216,6 +224,47 @@ def call_github_models(system_prompt: str, user_payload: str, model: str, endpoi
return _post_with_retries(url, headers, payload, extract=_extract_openai_text)
def call_openai_compatible(system_prompt: str, user_payload: str, model: str, endpoint: str) -> str:
"""Call any OpenAI-compatible chat-completions endpoint (OpenAI, DeepSeek,
Qwen, one-api/new-api gateways, vLLM, Ollama, ...).
Endpoint base: TRANSLATE_ENDPOINT / OPENAI_BASE_URL, ending in /v1 for most
providers (the request path appends /chat/completions).
Auth: Authorization: Bearer <TRANSLATE_API_KEY or OPENAI_API_KEY>.
"""
api_key = os.environ.get("TRANSLATE_API_KEY") or os.environ.get("OPENAI_API_KEY")
if not api_key:
raise RuntimeError("TRANSLATE_API_KEY (or OPENAI_API_KEY) not set for backend='openai'")
url = f"{endpoint.rstrip('/')}/chat/completions"
payload: dict[str, Any] = {
"model": model,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_payload},
],
}
# GPT-5 / o-series require `max_completion_tokens` and reject non-default
# temperature; everything else (including most OpenAI-compatible providers)
# still expects the classic `max_tokens` + temperature contract.
if _is_new_openai_contract(model):
payload["max_completion_tokens"] = 8192
else:
payload["max_tokens"] = 8192
payload["temperature"] = 0.1
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
return _post_with_retries(url, headers, payload, extract=_extract_openai_text)
def _is_new_openai_contract(model: str) -> bool:
"""True for OpenAI GPT-5 / o-series ids, with or without a publisher prefix
(e.g. 'gpt-5-mini', 'openai/gpt-5', 'o1-mini', 'openai/o3')."""
bare = model.lower().rsplit("/", 1)[-1]
return "gpt-5" in bare or bool(re.match(r"^o[134](-|$)", bare))
def call_anthropic(system_prompt: str, user_payload: str, model: str, endpoint: str) -> str:
"""Call Anthropic Messages API directly."""
api_key = os.environ.get("ANTHROPIC_API_KEY")
@@ -298,6 +347,8 @@ def call_llm(system_prompt: str, user_payload: str, *, backend: str, model: str,
text = call_github_models(system_prompt, user_payload, model, endpoint)
elif backend == "anthropic":
text = call_anthropic(system_prompt, user_payload, model, endpoint)
elif backend == "openai":
text = call_openai_compatible(system_prompt, user_payload, model, endpoint)
else:
raise RuntimeError(f"Unknown backend: {backend}")
return parse_json_response(text)
@@ -472,10 +523,14 @@ def get_target_locales(args: argparse.Namespace) -> list[str]:
def resolve_backend(args: argparse.Namespace) -> tuple[str, str, str]:
backend = (args.backend or DEFAULT_BACKEND).lower()
if backend not in ("github", "anthropic"):
raise SystemExit(f"Unknown backend '{backend}'; choose 'github' or 'anthropic'")
if backend not in ("github", "anthropic", "openai"):
raise SystemExit(f"Unknown backend '{backend}'; choose 'github', 'anthropic' or 'openai'")
model = args.model or DEFAULT_MODEL_BY_BACKEND[backend]
endpoint = args.endpoint or os.environ.get("TRANSLATE_ENDPOINT") or DEFAULT_ENDPOINT_BY_BACKEND[backend]
endpoint = args.endpoint or os.environ.get("TRANSLATE_ENDPOINT")
if not endpoint and backend == "openai":
endpoint = os.environ.get("OPENAI_BASE_URL")
if not endpoint:
endpoint = DEFAULT_ENDPOINT_BY_BACKEND[backend]
return backend, model, endpoint
@@ -499,7 +554,7 @@ def main(argv: list[str]) -> int:
parser.add_argument("--target", help="Single target locale (default: all manifest.supportedLocales)")
parser.add_argument("--check", action="store_true", help="Report stale translations; exit 1 if any")
parser.add_argument("--human", action="store_true", help="Mark new translations as 'human' (locks against re-translation)")
parser.add_argument("--backend", choices=("github", "anthropic"), help="Override backend (default: env TRANSLATE_BACKEND or 'github')")
parser.add_argument("--backend", choices=("github", "anthropic", "openai"), help="Override backend (default: env TRANSLATE_BACKEND or 'github')")
parser.add_argument("--model", help="Override model id")
parser.add_argument("--endpoint", help="Override API endpoint")
parser.add_argument("--list-models", action="store_true", help="List models in GitHub Models catalog and exit")
@@ -517,6 +572,9 @@ def main(argv: list[str]) -> int:
if backend == "anthropic" and not os.environ.get("ANTHROPIC_API_KEY"):
sys.stderr.write("ERROR: ANTHROPIC_API_KEY not set for backend='anthropic'\n")
return 2
if backend == "openai" and not (os.environ.get("TRANSLATE_API_KEY") or os.environ.get("OPENAI_API_KEY")):
sys.stderr.write("ERROR: TRANSLATE_API_KEY (or OPENAI_API_KEY) not set for backend='openai'\n")
return 2
if args.paths:
targets = [Path(p).resolve() for p in args.paths]