feat: 基本上搞定了翻译的页面
Browse files- README.md +14 -7
- entrypoint.sh +0 -5
- src/gateway.py +243 -63
README.md
CHANGED
|
@@ -12,16 +12,18 @@ pinned: false
|
|
| 12 |
这个仓库部署一个单体 FastAPI 服务,包含:
|
| 13 |
|
| 14 |
1. 用户登录(Session Cookie)
|
| 15 |
-
2.
|
| 16 |
3. 任务队列(单 worker)调用 `pdf2zh_next` Python API
|
| 17 |
-
4. 内部 OpenAI 兼容代理:`/internal/openai/v1/chat/completions`
|
| 18 |
5. 按登录用户名计费(token + USD)
|
| 19 |
|
| 20 |
### 运行架构
|
| 21 |
|
| 22 |
1. 用户访问 `:7860` 登录并提交翻译任务
|
| 23 |
-
2. 后台 worker 调用 `pdf2zh_next`,并把
|
| 24 |
-
3. 内部代理
|
|
|
|
|
|
|
| 25 |
4. 计费按 `username` 聚合,前端展示账单
|
| 26 |
|
| 27 |
### 必需 Secret
|
|
@@ -29,7 +31,6 @@ pinned: false
|
|
| 29 |
在 HuggingFace Space 设置:
|
| 30 |
|
| 31 |
- `BASIC_AUTH_USERS`(多行文本)
|
| 32 |
-
- `OPENAI_API_KEY`
|
| 33 |
|
| 34 |
`BASIC_AUTH_USERS` 格式:
|
| 35 |
|
|
@@ -48,11 +49,18 @@ bob:your_password_2
|
|
| 48 |
|
| 49 |
- `SESSION_SECRET`:Session 签名密钥
|
| 50 |
- `INTERNAL_KEY_SALT`:内部 key 生成盐(默认复用 `SESSION_SECRET`)
|
| 51 |
-
- `DEFAULT_OPENAI_MODEL`:默认模型(默认 `gpt-4o-mini`)
|
| 52 |
- `DEFAULT_LANG_IN`:默认源语言(默认 `en`)
|
| 53 |
- `DEFAULT_LANG_OUT`:默认目标语言(默认 `zh`)
|
| 54 |
- `TRANSLATION_QPS`:翻译 QPS(默认 `4`)
|
| 55 |
- `DATA_DIR`:数据目录(默认 `/data`)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
|
| 57 |
### 健康检查
|
| 58 |
|
|
@@ -64,7 +72,6 @@ bob:your_password_2
|
|
| 64 |
docker build -t pdf2zh-gated .
|
| 65 |
docker run --rm -p 7860:7860 \
|
| 66 |
-e BASIC_AUTH_USERS=$'alice:pass1\nbob:pass2' \
|
| 67 |
-
-e OPENAI_API_KEY='sk-your-openai-key' \
|
| 68 |
pdf2zh-gated
|
| 69 |
```
|
| 70 |
|
|
|
|
| 12 |
这个仓库部署一个单体 FastAPI 服务,包含:
|
| 13 |
|
| 14 |
1. 用户登录(Session Cookie)
|
| 15 |
+
2. 中文 Web UI(上传 PDF、查看任务、下载结果)
|
| 16 |
3. 任务队列(单 worker)调用 `pdf2zh_next` Python API
|
| 17 |
+
4. 内部 OpenAI 兼容代理:`/internal/openai/v1/chat/completions`(按模型路由)
|
| 18 |
5. 按登录用户名计费(token + USD)
|
| 19 |
|
| 20 |
### 运行架构
|
| 21 |
|
| 22 |
1. 用户访问 `:7860` 登录并提交翻译任务
|
| 23 |
+
2. 后台 worker 调用 `pdf2zh_next`,并把 `chat/completions` 请求发到本机内部代理
|
| 24 |
+
3. 内部代理根据 `model` 路由上游:
|
| 25 |
+
- `SiliconFlowFree` -> 作者维护的 `chatproxy` 接口
|
| 26 |
+
- 其他模型 -> OpenAI 风格上游(默认 OpenAI 官方)
|
| 27 |
4. 计费按 `username` 聚合,前端展示账单
|
| 28 |
|
| 29 |
### 必需 Secret
|
|
|
|
| 31 |
在 HuggingFace Space 设置:
|
| 32 |
|
| 33 |
- `BASIC_AUTH_USERS`(多行文本)
|
|
|
|
| 34 |
|
| 35 |
`BASIC_AUTH_USERS` 格式:
|
| 36 |
|
|
|
|
| 49 |
|
| 50 |
- `SESSION_SECRET`:Session 签名密钥
|
| 51 |
- `INTERNAL_KEY_SALT`:内部 key 生成盐(默认复用 `SESSION_SECRET`)
|
|
|
|
| 52 |
- `DEFAULT_LANG_IN`:默认源语言(默认 `en`)
|
| 53 |
- `DEFAULT_LANG_OUT`:默认目标语言(默认 `zh`)
|
| 54 |
- `TRANSLATION_QPS`:翻译 QPS(默认 `4`)
|
| 55 |
- `DATA_DIR`:数据目录(默认 `/data`)
|
| 56 |
+
- `OPENAI_API_KEY`:仅当你希望代理“非 SiliconFlowFree 模型”时需要
|
| 57 |
+
- `OPENAI_UPSTREAM_CHAT_URL`:非 SiliconFlowFree 模型的 OpenAI 风格上游地址
|
| 58 |
+
|
| 59 |
+
### 固定模型与路由表
|
| 60 |
+
|
| 61 |
+
- 前端不提供模型选择,任务模型固定为 `SiliconFlowFree`
|
| 62 |
+
- 路由表在 `src/gateway.py` 的 `MODEL_ROUTE_TABLE`
|
| 63 |
+
- 当前只维护一条:`SiliconFlowFree`,带两个 `chatproxy` 备用地址
|
| 64 |
|
| 65 |
### 健康检查
|
| 66 |
|
|
|
|
| 72 |
docker build -t pdf2zh-gated .
|
| 73 |
docker run --rm -p 7860:7860 \
|
| 74 |
-e BASIC_AUTH_USERS=$'alice:pass1\nbob:pass2' \
|
|
|
|
| 75 |
pdf2zh-gated
|
| 76 |
```
|
| 77 |
|
entrypoint.sh
CHANGED
|
@@ -7,11 +7,6 @@ if [[ -z "${RAW_USERS}" ]]; then
|
|
| 7 |
exit 1
|
| 8 |
fi
|
| 9 |
|
| 10 |
-
if [[ -z "${OPENAI_API_KEY:-}" ]]; then
|
| 11 |
-
echo "[ERROR] OPENAI_API_KEY is required." >&2
|
| 12 |
-
exit 1
|
| 13 |
-
fi
|
| 14 |
-
|
| 15 |
echo "[INFO] Starting gateway on :7860"
|
| 16 |
cd / && exec /opt/gateway/bin/uvicorn gateway:app \
|
| 17 |
--app-dir /src \
|
|
|
|
| 7 |
exit 1
|
| 8 |
fi
|
| 9 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
echo "[INFO] Starting gateway on :7860"
|
| 11 |
cd / && exec /opt/gateway/bin/uvicorn gateway:app \
|
| 12 |
--app-dir /src \
|
src/gateway.py
CHANGED
|
@@ -56,13 +56,25 @@ OPENAI_UPSTREAM_CHAT_URL = os.environ.get(
|
|
| 56 |
)
|
| 57 |
OPENAI_REAL_API_KEY = os.environ.get("OPENAI_API_KEY", "").strip()
|
| 58 |
|
| 59 |
-
|
| 60 |
DEFAULT_LANG_IN = os.environ.get("DEFAULT_LANG_IN", "en").strip()
|
| 61 |
DEFAULT_LANG_OUT = os.environ.get("DEFAULT_LANG_OUT", "zh").strip()
|
| 62 |
TRANSLATION_QPS = int(os.environ.get("TRANSLATION_QPS", "4"))
|
| 63 |
|
| 64 |
INTERNAL_KEY_SALT = (os.environ.get("INTERNAL_KEY_SALT") or SECRET_KEY).strip()
|
| 65 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
# 价格单位:USD / 1M tokens
|
| 67 |
DEFAULT_INPUT_PRICE_PER_1M = float(
|
| 68 |
os.environ.get("OPENAI_DEFAULT_INPUT_PRICE_PER_1M", "0.15")
|
|
@@ -502,11 +514,11 @@ def _enqueue_pending_jobs() -> None:
|
|
| 502 |
# ── 页面模板 ───────────────────────────────────────────────────────────────────
|
| 503 |
_LOGIN_HTML = """\
|
| 504 |
<!DOCTYPE html>
|
| 505 |
-
<html lang="
|
| 506 |
<head>
|
| 507 |
<meta charset="UTF-8">
|
| 508 |
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 509 |
-
<title>
|
| 510 |
<style>
|
| 511 |
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
| 512 |
body {
|
|
@@ -566,15 +578,15 @@ _LOGIN_HTML = """\
|
|
| 566 |
</head>
|
| 567 |
<body>
|
| 568 |
<div class="card">
|
| 569 |
-
<h1>
|
| 570 |
-
<p class="sub">
|
| 571 |
__ERROR_BLOCK__
|
| 572 |
<form method="post" action="/login">
|
| 573 |
-
<label for="u">
|
| 574 |
<input id="u" type="text" name="username" autocomplete="username" required autofocus>
|
| 575 |
-
<label for="p">
|
| 576 |
<input id="p" type="password" name="password" autocomplete="current-password" required>
|
| 577 |
-
<button type="submit">
|
| 578 |
</form>
|
| 579 |
</div>
|
| 580 |
</body>
|
|
@@ -589,16 +601,15 @@ def _login_page(error: str = "") -> str:
|
|
| 589 |
|
| 590 |
def _dashboard_page(username: str) -> str:
|
| 591 |
safe_user = html.escape(username)
|
| 592 |
-
safe_model = html.escape(DEFAULT_MODEL)
|
| 593 |
safe_lang_in = html.escape(DEFAULT_LANG_IN)
|
| 594 |
safe_lang_out = html.escape(DEFAULT_LANG_OUT)
|
| 595 |
|
| 596 |
return f"""<!DOCTYPE html>
|
| 597 |
-
<html lang="
|
| 598 |
<head>
|
| 599 |
<meta charset="UTF-8" />
|
| 600 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 601 |
-
<title>PDF
|
| 602 |
<style>
|
| 603 |
:root {{
|
| 604 |
--bg: #f4f7fb;
|
|
@@ -667,53 +678,50 @@ def _dashboard_page(username: str) -> str:
|
|
| 667 |
<div class="wrap">
|
| 668 |
<div class="top">
|
| 669 |
<div>
|
| 670 |
-
<h1>PDF
|
| 671 |
-
<div class="user">
|
| 672 |
</div>
|
| 673 |
-
<div><a href="/logout"><button class="muted">
|
| 674 |
</div>
|
| 675 |
|
| 676 |
<div class="grid">
|
| 677 |
<section class="card">
|
| 678 |
-
<h2>
|
| 679 |
<form id="jobForm">
|
| 680 |
-
<label>PDF
|
| 681 |
<input name="file" type="file" accept=".pdf" required />
|
| 682 |
|
| 683 |
<div class="row">
|
| 684 |
<div>
|
| 685 |
-
<label>
|
| 686 |
<input name="lang_in" type="text" value="{safe_lang_in}" required />
|
| 687 |
</div>
|
| 688 |
<div>
|
| 689 |
-
<label>
|
| 690 |
<input name="lang_out" type="text" value="{safe_lang_out}" required />
|
| 691 |
</div>
|
| 692 |
</div>
|
| 693 |
|
| 694 |
-
<label>OpenAI Model</label>
|
| 695 |
-
<input name="model" type="text" value="{safe_model}" required />
|
| 696 |
-
|
| 697 |
<div style="margin-top: 12px;">
|
| 698 |
-
<button class="primary" type="submit">
|
| 699 |
</div>
|
| 700 |
</form>
|
| 701 |
-
<div class="hint">
|
| 702 |
<div id="jobStatus" class="status"></div>
|
| 703 |
</section>
|
| 704 |
|
| 705 |
<section class="card">
|
| 706 |
-
<h2>
|
| 707 |
-
<div id="billingSummary" class="mono">
|
| 708 |
<table>
|
| 709 |
<thead>
|
| 710 |
<tr>
|
| 711 |
-
<th>
|
| 712 |
-
<th>
|
| 713 |
-
<th>
|
| 714 |
-
<th>
|
| 715 |
-
<th>
|
| 716 |
-
<th>
|
| 717 |
</tr>
|
| 718 |
</thead>
|
| 719 |
<tbody id="billingBody"></tbody>
|
|
@@ -722,24 +730,24 @@ def _dashboard_page(username: str) -> str:
|
|
| 722 |
</div>
|
| 723 |
|
| 724 |
<section class="card" style="margin-top: 14px;">
|
| 725 |
-
<h2>
|
| 726 |
<table>
|
| 727 |
<thead>
|
| 728 |
<tr>
|
| 729 |
<th>ID</th>
|
| 730 |
-
<th>
|
| 731 |
-
<th>
|
| 732 |
-
<th>
|
| 733 |
-
<th>
|
| 734 |
-
<th>
|
| 735 |
-
<th>
|
| 736 |
</tr>
|
| 737 |
</thead>
|
| 738 |
<tbody id="jobsBody"></tbody>
|
| 739 |
</table>
|
| 740 |
</section>
|
| 741 |
|
| 742 |
-
<div class="foot">
|
| 743 |
</div>
|
| 744 |
|
| 745 |
<script>
|
|
@@ -767,7 +775,7 @@ async function refreshBilling() {{
|
|
| 767 |
const rows = await apiJson('/api/billing/me/records?limit=20');
|
| 768 |
|
| 769 |
document.getElementById('billingSummary').textContent =
|
| 770 |
-
`
|
| 771 |
|
| 772 |
const body = document.getElementById('billingBody');
|
| 773 |
body.innerHTML = '';
|
|
@@ -788,20 +796,31 @@ async function refreshBilling() {{
|
|
| 788 |
function actionButtons(job) {{
|
| 789 |
const actions = [];
|
| 790 |
if (job.status === 'queued' || job.status === 'running') {{
|
| 791 |
-
actions.push(`<button class="danger" onclick="cancelJob('${{job.id}}')">
|
| 792 |
}}
|
| 793 |
if (job.artifact_urls?.mono) {{
|
| 794 |
-
actions.push(`<a href="${{job.artifact_urls.mono}}"><button class="muted">
|
| 795 |
}}
|
| 796 |
if (job.artifact_urls?.dual) {{
|
| 797 |
-
actions.push(`<a href="${{job.artifact_urls.dual}}"><button class="muted">
|
| 798 |
}}
|
| 799 |
if (job.artifact_urls?.glossary) {{
|
| 800 |
-
actions.push(`<a href="${{job.artifact_urls.glossary}}"><button class="muted">
|
| 801 |
}}
|
| 802 |
return actions.join(' ');
|
| 803 |
}}
|
| 804 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 805 |
async function refreshJobs() {{
|
| 806 |
const data = await apiJson('/api/jobs?limit=50');
|
| 807 |
const body = document.getElementById('jobsBody');
|
|
@@ -812,7 +831,7 @@ async function refreshJobs() {{
|
|
| 812 |
tr.innerHTML = `
|
| 813 |
<td class="mono">${{esc(job.id)}}</td>
|
| 814 |
<td>${{esc(job.filename)}}</td>
|
| 815 |
-
<td>${{esc(job.status)}}${{job.error ? ' / ' + esc(job.error) : ''}}</td>
|
| 816 |
<td>${{Number(job.progress).toFixed(1)}}%</td>
|
| 817 |
<td class="mono">${{esc(job.model)}}</td>
|
| 818 |
<td class="mono">${{esc(job.updated_at)}}</td>
|
|
@@ -827,23 +846,23 @@ async function cancelJob(jobId) {{
|
|
| 827 |
await apiJson(`/api/jobs/${{jobId}}/cancel`, {{ method: 'POST' }});
|
| 828 |
await refreshJobs();
|
| 829 |
}} catch (err) {{
|
| 830 |
-
alert(`
|
| 831 |
}}
|
| 832 |
}}
|
| 833 |
|
| 834 |
document.getElementById('jobForm').addEventListener('submit', async (event) => {{
|
| 835 |
event.preventDefault();
|
| 836 |
const status = document.getElementById('jobStatus');
|
| 837 |
-
status.textContent = '
|
| 838 |
|
| 839 |
const formData = new FormData(event.target);
|
| 840 |
try {{
|
| 841 |
const created = await apiJson('/api/jobs', {{ method: 'POST', body: formData }});
|
| 842 |
-
status.textContent = `
|
| 843 |
event.target.reset();
|
| 844 |
await refreshJobs();
|
| 845 |
}} catch (err) {{
|
| 846 |
-
status.textContent = `
|
| 847 |
}}
|
| 848 |
}});
|
| 849 |
|
|
@@ -875,7 +894,9 @@ async def _startup() -> None:
|
|
| 875 |
_worker_task = asyncio.create_task(_job_worker(), name="job-worker")
|
| 876 |
|
| 877 |
if not OPENAI_REAL_API_KEY:
|
| 878 |
-
logger.
|
|
|
|
|
|
|
| 879 |
|
| 880 |
logger.info("Gateway started. Data dir: %s", DATA_DIR)
|
| 881 |
|
|
@@ -935,7 +956,7 @@ async def login(
|
|
| 935 |
return resp
|
| 936 |
|
| 937 |
logger.warning("Login failed: %s", username)
|
| 938 |
-
return HTMLResponse(_login_page("
|
| 939 |
|
| 940 |
|
| 941 |
@app.get("/logout")
|
|
@@ -993,15 +1014,11 @@ async def api_create_job(
|
|
| 993 |
file: UploadFile = File(...),
|
| 994 |
lang_in: str = Form(DEFAULT_LANG_IN),
|
| 995 |
lang_out: str = Form(DEFAULT_LANG_OUT),
|
| 996 |
-
model: str = Form(DEFAULT_MODEL),
|
| 997 |
username: str = Depends(_require_user),
|
| 998 |
) -> dict[str, Any]:
|
| 999 |
filename = file.filename or "input.pdf"
|
| 1000 |
if not filename.lower().endswith(".pdf"):
|
| 1001 |
-
raise HTTPException(status_code=400, detail="
|
| 1002 |
-
|
| 1003 |
-
if not model.strip():
|
| 1004 |
-
raise HTTPException(status_code=400, detail="Model is required")
|
| 1005 |
|
| 1006 |
job_id = uuid.uuid4().hex
|
| 1007 |
safe_filename = Path(filename).name
|
|
@@ -1036,7 +1053,7 @@ async def api_create_job(
|
|
| 1036 |
0.0,
|
| 1037 |
"Queued",
|
| 1038 |
None,
|
| 1039 |
-
|
| 1040 |
lang_in.strip() or DEFAULT_LANG_IN,
|
| 1041 |
lang_out.strip() or DEFAULT_LANG_OUT,
|
| 1042 |
0,
|
|
@@ -1193,6 +1210,154 @@ def _require_localhost(request: Request) -> None:
|
|
| 1193 |
raise HTTPException(status_code=403, detail="Internal endpoint only")
|
| 1194 |
|
| 1195 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1196 |
@app.post("/internal/openai/v1/chat/completions")
|
| 1197 |
async def internal_openai_chat_completions(request: Request) -> Response:
|
| 1198 |
_require_localhost(request)
|
|
@@ -1202,9 +1367,6 @@ async def internal_openai_chat_completions(request: Request) -> Response:
|
|
| 1202 |
if not username:
|
| 1203 |
raise HTTPException(status_code=401, detail="Invalid internal API key")
|
| 1204 |
|
| 1205 |
-
if not OPENAI_REAL_API_KEY:
|
| 1206 |
-
raise HTTPException(status_code=500, detail="OPENAI_API_KEY is not configured")
|
| 1207 |
-
|
| 1208 |
try:
|
| 1209 |
payload = await request.json()
|
| 1210 |
except json.JSONDecodeError as exc:
|
|
@@ -1216,6 +1378,26 @@ async def internal_openai_chat_completions(request: Request) -> Response:
|
|
| 1216 |
if _http_client is None:
|
| 1217 |
raise HTTPException(status_code=500, detail="HTTP client is not ready")
|
| 1218 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1219 |
headers = {
|
| 1220 |
"Authorization": f"Bearer {OPENAI_REAL_API_KEY}",
|
| 1221 |
"Content-Type": "application/json",
|
|
@@ -1231,9 +1413,8 @@ async def internal_openai_chat_completions(request: Request) -> Response:
|
|
| 1231 |
logger.error("Upstream OpenAI call failed: %s", exc)
|
| 1232 |
raise HTTPException(status_code=502, detail="Upstream OpenAI request failed") from exc
|
| 1233 |
|
| 1234 |
-
content_type = upstream.headers.get("content-type", "")
|
| 1235 |
-
|
| 1236 |
response_json: dict[str, Any] | None = None
|
|
|
|
| 1237 |
if "application/json" in content_type.lower():
|
| 1238 |
try:
|
| 1239 |
response_json = upstream.json()
|
|
@@ -1246,7 +1427,6 @@ async def internal_openai_chat_completions(request: Request) -> Response:
|
|
| 1246 |
completion_tokens = int(usage.get("completion_tokens") or 0)
|
| 1247 |
total_tokens = int(usage.get("total_tokens") or (prompt_tokens + completion_tokens))
|
| 1248 |
|
| 1249 |
-
model = str(response_json.get("model") or payload.get("model") or "unknown")
|
| 1250 |
job_id = _active_job_by_user.get(username)
|
| 1251 |
|
| 1252 |
_record_usage(
|
|
|
|
| 56 |
)
|
| 57 |
OPENAI_REAL_API_KEY = os.environ.get("OPENAI_API_KEY", "").strip()
|
| 58 |
|
| 59 |
+
FIXED_TRANSLATION_MODEL = "SiliconFlowFree"
|
| 60 |
DEFAULT_LANG_IN = os.environ.get("DEFAULT_LANG_IN", "en").strip()
|
| 61 |
DEFAULT_LANG_OUT = os.environ.get("DEFAULT_LANG_OUT", "zh").strip()
|
| 62 |
TRANSLATION_QPS = int(os.environ.get("TRANSLATION_QPS", "4"))
|
| 63 |
|
| 64 |
INTERNAL_KEY_SALT = (os.environ.get("INTERNAL_KEY_SALT") or SECRET_KEY).strip()
|
| 65 |
|
| 66 |
+
# 模型路由表:模型名 -> 上游配置
|
| 67 |
+
MODEL_ROUTE_TABLE: dict[str, dict[str, Any]] = {
|
| 68 |
+
"SiliconFlowFree": {
|
| 69 |
+
"route_type": "chatproxy",
|
| 70 |
+
"base_urls": [
|
| 71 |
+
"https://api1.pdf2zh-next.com/chatproxy",
|
| 72 |
+
"https://api2.pdf2zh-next.com/chatproxy",
|
| 73 |
+
],
|
| 74 |
+
"api_key": "",
|
| 75 |
+
}
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
# 价格单位:USD / 1M tokens
|
| 79 |
DEFAULT_INPUT_PRICE_PER_1M = float(
|
| 80 |
os.environ.get("OPENAI_DEFAULT_INPUT_PRICE_PER_1M", "0.15")
|
|
|
|
| 514 |
# ── 页面模板 ───────────────────────────────────────────────────────────────────
|
| 515 |
_LOGIN_HTML = """\
|
| 516 |
<!DOCTYPE html>
|
| 517 |
+
<html lang="zh-CN">
|
| 518 |
<head>
|
| 519 |
<meta charset="UTF-8">
|
| 520 |
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 521 |
+
<title>登录</title>
|
| 522 |
<style>
|
| 523 |
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
| 524 |
body {
|
|
|
|
| 578 |
</head>
|
| 579 |
<body>
|
| 580 |
<div class="card">
|
| 581 |
+
<h1>欢迎回来</h1>
|
| 582 |
+
<p class="sub">请先登录后继续</p>
|
| 583 |
__ERROR_BLOCK__
|
| 584 |
<form method="post" action="/login">
|
| 585 |
+
<label for="u">用户名</label>
|
| 586 |
<input id="u" type="text" name="username" autocomplete="username" required autofocus>
|
| 587 |
+
<label for="p">密码</label>
|
| 588 |
<input id="p" type="password" name="password" autocomplete="current-password" required>
|
| 589 |
+
<button type="submit">登录</button>
|
| 590 |
</form>
|
| 591 |
</div>
|
| 592 |
</body>
|
|
|
|
| 601 |
|
| 602 |
def _dashboard_page(username: str) -> str:
|
| 603 |
safe_user = html.escape(username)
|
|
|
|
| 604 |
safe_lang_in = html.escape(DEFAULT_LANG_IN)
|
| 605 |
safe_lang_out = html.escape(DEFAULT_LANG_OUT)
|
| 606 |
|
| 607 |
return f"""<!DOCTYPE html>
|
| 608 |
+
<html lang="zh-CN">
|
| 609 |
<head>
|
| 610 |
<meta charset="UTF-8" />
|
| 611 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 612 |
+
<title>PDF 翻译控制台</title>
|
| 613 |
<style>
|
| 614 |
:root {{
|
| 615 |
--bg: #f4f7fb;
|
|
|
|
| 678 |
<div class="wrap">
|
| 679 |
<div class="top">
|
| 680 |
<div>
|
| 681 |
+
<h1>PDF 翻译控制台</h1>
|
| 682 |
+
<div class="user">当前用户:<strong>{safe_user}</strong></div>
|
| 683 |
</div>
|
| 684 |
+
<div><a href="/logout"><button class="muted">退出登录</button></a></div>
|
| 685 |
</div>
|
| 686 |
|
| 687 |
<div class="grid">
|
| 688 |
<section class="card">
|
| 689 |
+
<h2>新建任务</h2>
|
| 690 |
<form id="jobForm">
|
| 691 |
+
<label>PDF 文件</label>
|
| 692 |
<input name="file" type="file" accept=".pdf" required />
|
| 693 |
|
| 694 |
<div class="row">
|
| 695 |
<div>
|
| 696 |
+
<label>源语言</label>
|
| 697 |
<input name="lang_in" type="text" value="{safe_lang_in}" required />
|
| 698 |
</div>
|
| 699 |
<div>
|
| 700 |
+
<label>目标语言</label>
|
| 701 |
<input name="lang_out" type="text" value="{safe_lang_out}" required />
|
| 702 |
</div>
|
| 703 |
</div>
|
| 704 |
|
|
|
|
|
|
|
|
|
|
| 705 |
<div style="margin-top: 12px;">
|
| 706 |
+
<button class="primary" type="submit">提交任务</button>
|
| 707 |
</div>
|
| 708 |
</form>
|
| 709 |
+
<div class="hint">模型由后台固定为 SiliconFlowFree,用户无需选择。</div>
|
| 710 |
<div id="jobStatus" class="status"></div>
|
| 711 |
</section>
|
| 712 |
|
| 713 |
<section class="card">
|
| 714 |
+
<h2>我的账单</h2>
|
| 715 |
+
<div id="billingSummary" class="mono">加载中...</div>
|
| 716 |
<table>
|
| 717 |
<thead>
|
| 718 |
<tr>
|
| 719 |
+
<th>时间 (UTC)</th>
|
| 720 |
+
<th>模型</th>
|
| 721 |
+
<th>输入</th>
|
| 722 |
+
<th>输出</th>
|
| 723 |
+
<th>总计</th>
|
| 724 |
+
<th>费用 (USD)</th>
|
| 725 |
</tr>
|
| 726 |
</thead>
|
| 727 |
<tbody id="billingBody"></tbody>
|
|
|
|
| 730 |
</div>
|
| 731 |
|
| 732 |
<section class="card" style="margin-top: 14px;">
|
| 733 |
+
<h2>我的任务</h2>
|
| 734 |
<table>
|
| 735 |
<thead>
|
| 736 |
<tr>
|
| 737 |
<th>ID</th>
|
| 738 |
+
<th>文件</th>
|
| 739 |
+
<th>状态</th>
|
| 740 |
+
<th>进度</th>
|
| 741 |
+
<th>模型</th>
|
| 742 |
+
<th>更新时间 (UTC)</th>
|
| 743 |
+
<th>操作</th>
|
| 744 |
</tr>
|
| 745 |
</thead>
|
| 746 |
<tbody id="jobsBody"></tbody>
|
| 747 |
</table>
|
| 748 |
</section>
|
| 749 |
|
| 750 |
+
<div class="foot">内部 OpenAI 接口仅允许 localhost 访问,不会直接暴露给终端用户。</div>
|
| 751 |
</div>
|
| 752 |
|
| 753 |
<script>
|
|
|
|
| 775 |
const rows = await apiJson('/api/billing/me/records?limit=20');
|
| 776 |
|
| 777 |
document.getElementById('billingSummary').textContent =
|
| 778 |
+
`总 tokens=${{summary.total_tokens}} | 总费用(USD)=${{Number(summary.total_cost_usd).toFixed(6)}}`;
|
| 779 |
|
| 780 |
const body = document.getElementById('billingBody');
|
| 781 |
body.innerHTML = '';
|
|
|
|
| 796 |
function actionButtons(job) {{
|
| 797 |
const actions = [];
|
| 798 |
if (job.status === 'queued' || job.status === 'running') {{
|
| 799 |
+
actions.push(`<button class="danger" onclick="cancelJob('${{job.id}}')">取消</button>`);
|
| 800 |
}}
|
| 801 |
if (job.artifact_urls?.mono) {{
|
| 802 |
+
actions.push(`<a href="${{job.artifact_urls.mono}}"><button class="muted">单语版</button></a>`);
|
| 803 |
}}
|
| 804 |
if (job.artifact_urls?.dual) {{
|
| 805 |
+
actions.push(`<a href="${{job.artifact_urls.dual}}"><button class="muted">双语版</button></a>`);
|
| 806 |
}}
|
| 807 |
if (job.artifact_urls?.glossary) {{
|
| 808 |
+
actions.push(`<a href="${{job.artifact_urls.glossary}}"><button class="muted">术语表</button></a>`);
|
| 809 |
}}
|
| 810 |
return actions.join(' ');
|
| 811 |
}}
|
| 812 |
|
| 813 |
+
function statusText(status) {{
|
| 814 |
+
const statusMap = {{
|
| 815 |
+
queued: '排队中',
|
| 816 |
+
running: '进行中',
|
| 817 |
+
succeeded: '成功',
|
| 818 |
+
failed: '失败',
|
| 819 |
+
cancelled: '已取消'
|
| 820 |
+
}};
|
| 821 |
+
return statusMap[status] || status;
|
| 822 |
+
}}
|
| 823 |
+
|
| 824 |
async function refreshJobs() {{
|
| 825 |
const data = await apiJson('/api/jobs?limit=50');
|
| 826 |
const body = document.getElementById('jobsBody');
|
|
|
|
| 831 |
tr.innerHTML = `
|
| 832 |
<td class="mono">${{esc(job.id)}}</td>
|
| 833 |
<td>${{esc(job.filename)}}</td>
|
| 834 |
+
<td>${{esc(statusText(job.status))}}${{job.error ? ' / ' + esc(job.error) : ''}}</td>
|
| 835 |
<td>${{Number(job.progress).toFixed(1)}}%</td>
|
| 836 |
<td class="mono">${{esc(job.model)}}</td>
|
| 837 |
<td class="mono">${{esc(job.updated_at)}}</td>
|
|
|
|
| 846 |
await apiJson(`/api/jobs/${{jobId}}/cancel`, {{ method: 'POST' }});
|
| 847 |
await refreshJobs();
|
| 848 |
}} catch (err) {{
|
| 849 |
+
alert(`取消失败: ${{err.message}}`);
|
| 850 |
}}
|
| 851 |
}}
|
| 852 |
|
| 853 |
document.getElementById('jobForm').addEventListener('submit', async (event) => {{
|
| 854 |
event.preventDefault();
|
| 855 |
const status = document.getElementById('jobStatus');
|
| 856 |
+
status.textContent = '提交中...';
|
| 857 |
|
| 858 |
const formData = new FormData(event.target);
|
| 859 |
try {{
|
| 860 |
const created = await apiJson('/api/jobs', {{ method: 'POST', body: formData }});
|
| 861 |
+
status.textContent = `任务已入队: ${{created.job.id}}`;
|
| 862 |
event.target.reset();
|
| 863 |
await refreshJobs();
|
| 864 |
}} catch (err) {{
|
| 865 |
+
status.textContent = `提交失败: ${{err.message}}`;
|
| 866 |
}}
|
| 867 |
}});
|
| 868 |
|
|
|
|
| 894 |
_worker_task = asyncio.create_task(_job_worker(), name="job-worker")
|
| 895 |
|
| 896 |
if not OPENAI_REAL_API_KEY:
|
| 897 |
+
logger.info(
|
| 898 |
+
"OPENAI_API_KEY is empty, non-routed OpenAI models will fail"
|
| 899 |
+
)
|
| 900 |
|
| 901 |
logger.info("Gateway started. Data dir: %s", DATA_DIR)
|
| 902 |
|
|
|
|
| 956 |
return resp
|
| 957 |
|
| 958 |
logger.warning("Login failed: %s", username)
|
| 959 |
+
return HTMLResponse(_login_page("用户名或密码错误。"), status_code=401)
|
| 960 |
|
| 961 |
|
| 962 |
@app.get("/logout")
|
|
|
|
| 1014 |
file: UploadFile = File(...),
|
| 1015 |
lang_in: str = Form(DEFAULT_LANG_IN),
|
| 1016 |
lang_out: str = Form(DEFAULT_LANG_OUT),
|
|
|
|
| 1017 |
username: str = Depends(_require_user),
|
| 1018 |
) -> dict[str, Any]:
|
| 1019 |
filename = file.filename or "input.pdf"
|
| 1020 |
if not filename.lower().endswith(".pdf"):
|
| 1021 |
+
raise HTTPException(status_code=400, detail="仅支持 PDF 文件")
|
|
|
|
|
|
|
|
|
|
| 1022 |
|
| 1023 |
job_id = uuid.uuid4().hex
|
| 1024 |
safe_filename = Path(filename).name
|
|
|
|
| 1053 |
0.0,
|
| 1054 |
"Queued",
|
| 1055 |
None,
|
| 1056 |
+
FIXED_TRANSLATION_MODEL,
|
| 1057 |
lang_in.strip() or DEFAULT_LANG_IN,
|
| 1058 |
lang_out.strip() or DEFAULT_LANG_OUT,
|
| 1059 |
0,
|
|
|
|
| 1210 |
raise HTTPException(status_code=403, detail="Internal endpoint only")
|
| 1211 |
|
| 1212 |
|
| 1213 |
+
def _extract_text_from_message_content(content: Any) -> str:
|
| 1214 |
+
if isinstance(content, str):
|
| 1215 |
+
return content
|
| 1216 |
+
if not isinstance(content, list):
|
| 1217 |
+
return ""
|
| 1218 |
+
|
| 1219 |
+
parts: list[str] = []
|
| 1220 |
+
for item in content:
|
| 1221 |
+
if not isinstance(item, dict):
|
| 1222 |
+
continue
|
| 1223 |
+
if item.get("type") != "text":
|
| 1224 |
+
continue
|
| 1225 |
+
text = item.get("text")
|
| 1226 |
+
if isinstance(text, str):
|
| 1227 |
+
parts.append(text)
|
| 1228 |
+
return "".join(parts)
|
| 1229 |
+
|
| 1230 |
+
|
| 1231 |
+
def _extract_text_from_messages(messages: Any) -> str:
|
| 1232 |
+
if not isinstance(messages, list):
|
| 1233 |
+
raise HTTPException(status_code=400, detail="messages must be a list")
|
| 1234 |
+
|
| 1235 |
+
for message in reversed(messages):
|
| 1236 |
+
if not isinstance(message, dict):
|
| 1237 |
+
continue
|
| 1238 |
+
if message.get("role") != "user":
|
| 1239 |
+
continue
|
| 1240 |
+
text = _extract_text_from_message_content(message.get("content"))
|
| 1241 |
+
if text:
|
| 1242 |
+
return text
|
| 1243 |
+
|
| 1244 |
+
for message in reversed(messages):
|
| 1245 |
+
if not isinstance(message, dict):
|
| 1246 |
+
continue
|
| 1247 |
+
text = _extract_text_from_message_content(message.get("content"))
|
| 1248 |
+
if text:
|
| 1249 |
+
return text
|
| 1250 |
+
|
| 1251 |
+
raise HTTPException(status_code=400, detail="messages does not contain text content")
|
| 1252 |
+
|
| 1253 |
+
|
| 1254 |
+
def _should_request_json_mode(payload: dict[str, Any]) -> bool:
|
| 1255 |
+
response_format = payload.get("response_format")
|
| 1256 |
+
if not isinstance(response_format, dict):
|
| 1257 |
+
return False
|
| 1258 |
+
return response_format.get("type") == "json_object"
|
| 1259 |
+
|
| 1260 |
+
|
| 1261 |
+
def _build_openai_compatible_response(model: str, content: str) -> dict[str, Any]:
|
| 1262 |
+
return {
|
| 1263 |
+
"id": f"chatcmpl-{uuid.uuid4().hex}",
|
| 1264 |
+
"object": "chat.completion",
|
| 1265 |
+
"created": int(datetime.now(timezone.utc).timestamp()),
|
| 1266 |
+
"model": model,
|
| 1267 |
+
"choices": [
|
| 1268 |
+
{
|
| 1269 |
+
"index": 0,
|
| 1270 |
+
"message": {
|
| 1271 |
+
"role": "assistant",
|
| 1272 |
+
"content": content,
|
| 1273 |
+
},
|
| 1274 |
+
"finish_reason": "stop",
|
| 1275 |
+
}
|
| 1276 |
+
],
|
| 1277 |
+
"usage": {
|
| 1278 |
+
"prompt_tokens": 0,
|
| 1279 |
+
"completion_tokens": 0,
|
| 1280 |
+
"total_tokens": 0,
|
| 1281 |
+
},
|
| 1282 |
+
}
|
| 1283 |
+
|
| 1284 |
+
|
| 1285 |
+
async def _forward_to_chatproxy(
|
| 1286 |
+
payload: dict[str, Any],
|
| 1287 |
+
model: str,
|
| 1288 |
+
route: dict[str, Any],
|
| 1289 |
+
) -> dict[str, Any]:
|
| 1290 |
+
if _http_client is None:
|
| 1291 |
+
raise HTTPException(status_code=500, detail="HTTP client is not ready")
|
| 1292 |
+
|
| 1293 |
+
base_urls = route.get("base_urls", [])
|
| 1294 |
+
if not isinstance(base_urls, list) or not base_urls:
|
| 1295 |
+
raise HTTPException(status_code=500, detail=f"No upstream configured for model {model}")
|
| 1296 |
+
|
| 1297 |
+
request_json = {
|
| 1298 |
+
"text": _extract_text_from_messages(payload.get("messages")),
|
| 1299 |
+
}
|
| 1300 |
+
if _should_request_json_mode(payload):
|
| 1301 |
+
request_json["requestJsonMode"] = True
|
| 1302 |
+
|
| 1303 |
+
api_key = str(route.get("api_key") or "").strip()
|
| 1304 |
+
headers = {"Content-Type": "application/json"}
|
| 1305 |
+
if api_key:
|
| 1306 |
+
headers["Authorization"] = f"Bearer {api_key}"
|
| 1307 |
+
|
| 1308 |
+
last_error = "No available upstream"
|
| 1309 |
+
for base_url in base_urls:
|
| 1310 |
+
try:
|
| 1311 |
+
upstream = await _http_client.post(
|
| 1312 |
+
str(base_url),
|
| 1313 |
+
headers=headers,
|
| 1314 |
+
json=request_json,
|
| 1315 |
+
)
|
| 1316 |
+
except httpx.HTTPError as exc:
|
| 1317 |
+
last_error = str(exc)
|
| 1318 |
+
logger.warning("chatproxy call failed: model=%s url=%s error=%s", model, base_url, exc)
|
| 1319 |
+
continue
|
| 1320 |
+
|
| 1321 |
+
if upstream.status_code >= 400:
|
| 1322 |
+
last_error = f"status={upstream.status_code}"
|
| 1323 |
+
logger.warning(
|
| 1324 |
+
"chatproxy upstream returned error: model=%s url=%s status=%s",
|
| 1325 |
+
model,
|
| 1326 |
+
base_url,
|
| 1327 |
+
upstream.status_code,
|
| 1328 |
+
)
|
| 1329 |
+
continue
|
| 1330 |
+
|
| 1331 |
+
try:
|
| 1332 |
+
body = upstream.json()
|
| 1333 |
+
except Exception as exc: # noqa: BLE001
|
| 1334 |
+
last_error = f"invalid json response: {exc}"
|
| 1335 |
+
logger.warning(
|
| 1336 |
+
"chatproxy upstream returned invalid json: model=%s url=%s",
|
| 1337 |
+
model,
|
| 1338 |
+
base_url,
|
| 1339 |
+
)
|
| 1340 |
+
continue
|
| 1341 |
+
|
| 1342 |
+
content = body.get("content")
|
| 1343 |
+
if not isinstance(content, str):
|
| 1344 |
+
last_error = "missing content field"
|
| 1345 |
+
logger.warning(
|
| 1346 |
+
"chatproxy upstream missing content: model=%s url=%s body=%s",
|
| 1347 |
+
model,
|
| 1348 |
+
base_url,
|
| 1349 |
+
body,
|
| 1350 |
+
)
|
| 1351 |
+
continue
|
| 1352 |
+
|
| 1353 |
+
return _build_openai_compatible_response(model=model, content=content)
|
| 1354 |
+
|
| 1355 |
+
raise HTTPException(
|
| 1356 |
+
status_code=502,
|
| 1357 |
+
detail=f"All chatproxy upstreams failed for model {model}: {last_error}",
|
| 1358 |
+
)
|
| 1359 |
+
|
| 1360 |
+
|
| 1361 |
@app.post("/internal/openai/v1/chat/completions")
|
| 1362 |
async def internal_openai_chat_completions(request: Request) -> Response:
|
| 1363 |
_require_localhost(request)
|
|
|
|
| 1367 |
if not username:
|
| 1368 |
raise HTTPException(status_code=401, detail="Invalid internal API key")
|
| 1369 |
|
|
|
|
|
|
|
|
|
|
| 1370 |
try:
|
| 1371 |
payload = await request.json()
|
| 1372 |
except json.JSONDecodeError as exc:
|
|
|
|
| 1378 |
if _http_client is None:
|
| 1379 |
raise HTTPException(status_code=500, detail="HTTP client is not ready")
|
| 1380 |
|
| 1381 |
+
model = str(payload.get("model") or "").strip()
|
| 1382 |
+
if not model:
|
| 1383 |
+
raise HTTPException(status_code=400, detail="model is required")
|
| 1384 |
+
|
| 1385 |
+
route = MODEL_ROUTE_TABLE.get(model)
|
| 1386 |
+
if route and route.get("route_type") == "chatproxy":
|
| 1387 |
+
response_json = await _forward_to_chatproxy(payload=payload, model=model, route=route)
|
| 1388 |
+
_record_usage(
|
| 1389 |
+
username=username,
|
| 1390 |
+
job_id=_active_job_by_user.get(username),
|
| 1391 |
+
model=model,
|
| 1392 |
+
prompt_tokens=0,
|
| 1393 |
+
completion_tokens=0,
|
| 1394 |
+
total_tokens=0,
|
| 1395 |
+
)
|
| 1396 |
+
return JSONResponse(response_json, status_code=200)
|
| 1397 |
+
|
| 1398 |
+
if not OPENAI_REAL_API_KEY:
|
| 1399 |
+
raise HTTPException(status_code=500, detail="OPENAI_API_KEY is not configured")
|
| 1400 |
+
|
| 1401 |
headers = {
|
| 1402 |
"Authorization": f"Bearer {OPENAI_REAL_API_KEY}",
|
| 1403 |
"Content-Type": "application/json",
|
|
|
|
| 1413 |
logger.error("Upstream OpenAI call failed: %s", exc)
|
| 1414 |
raise HTTPException(status_code=502, detail="Upstream OpenAI request failed") from exc
|
| 1415 |
|
|
|
|
|
|
|
| 1416 |
response_json: dict[str, Any] | None = None
|
| 1417 |
+
content_type = upstream.headers.get("content-type", "")
|
| 1418 |
if "application/json" in content_type.lower():
|
| 1419 |
try:
|
| 1420 |
response_json = upstream.json()
|
|
|
|
| 1427 |
completion_tokens = int(usage.get("completion_tokens") or 0)
|
| 1428 |
total_tokens = int(usage.get("total_tokens") or (prompt_tokens + completion_tokens))
|
| 1429 |
|
|
|
|
| 1430 |
job_id = _active_job_by_user.get(username)
|
| 1431 |
|
| 1432 |
_record_usage(
|