mirror of
https://git.openapi.site/https://github.com/desirecore/market.git
synced 2026-07-23 03:43:43 +08:00
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:
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user