BirkhoffLee commited on
Commit
90423c7
·
unverified ·
1 Parent(s): 37e12f1

feat: 基本上搞定了翻译的页面

Browse files
Files changed (3) hide show
  1. README.md +14 -7
  2. entrypoint.sh +0 -5
  3. src/gateway.py +243 -63
README.md CHANGED
@@ -12,16 +12,18 @@ pinned: false
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`,并把 OpenAI 请求发到本机内部代理
24
- 3. 内部代理转发到 OpenAI 官方 API,同时记录 token 用量
 
 
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
- DEFAULT_MODEL = os.environ.get("DEFAULT_OPENAI_MODEL", "gpt-4o-mini").strip()
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="en">
506
  <head>
507
  <meta charset="UTF-8">
508
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
509
- <title>Sign In</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>Welcome back</h1>
570
- <p class="sub">Sign in to continue</p>
571
  __ERROR_BLOCK__
572
  <form method="post" action="/login">
573
- <label for="u">Username</label>
574
  <input id="u" type="text" name="username" autocomplete="username" required autofocus>
575
- <label for="p">Password</label>
576
  <input id="p" type="password" name="password" autocomplete="current-password" required>
577
- <button type="submit">Sign in</button>
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="en">
598
  <head>
599
  <meta charset="UTF-8" />
600
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
601
- <title>PDF Translation Console</title>
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 Translation Console</h1>
671
- <div class="user">Signed in as <strong>{safe_user}</strong></div>
672
  </div>
673
- <div><a href="/logout"><button class="muted">Sign out</button></a></div>
674
  </div>
675
 
676
  <div class="grid">
677
  <section class="card">
678
- <h2>New Job</h2>
679
  <form id="jobForm">
680
- <label>PDF File</label>
681
  <input name="file" type="file" accept=".pdf" required />
682
 
683
  <div class="row">
684
  <div>
685
- <label>Source Language</label>
686
  <input name="lang_in" type="text" value="{safe_lang_in}" required />
687
  </div>
688
  <div>
689
- <label>Target Language</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">Submit Job</button>
699
  </div>
700
  </form>
701
- <div class="hint">Billing is based on your login user and OpenAI token usage.</div>
702
  <div id="jobStatus" class="status"></div>
703
  </section>
704
 
705
  <section class="card">
706
- <h2>My Billing</h2>
707
- <div id="billingSummary" class="mono">Loading...</div>
708
  <table>
709
  <thead>
710
  <tr>
711
- <th>Time (UTC)</th>
712
- <th>Model</th>
713
- <th>Prompt</th>
714
- <th>Completion</th>
715
- <th>Total</th>
716
- <th>Cost (USD)</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>My Jobs</h2>
726
  <table>
727
  <thead>
728
  <tr>
729
  <th>ID</th>
730
- <th>File</th>
731
- <th>Status</th>
732
- <th>Progress</th>
733
- <th>Model</th>
734
- <th>Updated (UTC)</th>
735
- <th>Actions</th>
736
  </tr>
737
  </thead>
738
  <tbody id="jobsBody"></tbody>
739
  </table>
740
  </section>
741
 
742
- <div class="foot">Internal OpenAI endpoint is localhost-only and not exposed to end users.</div>
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
- `total_tokens=${{summary.total_tokens}} | total_cost_usd=${{Number(summary.total_cost_usd).toFixed(6)}}`;
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}}')">Cancel</button>`);
792
  }}
793
  if (job.artifact_urls?.mono) {{
794
- actions.push(`<a href="${{job.artifact_urls.mono}}"><button class="muted">Mono</button></a>`);
795
  }}
796
  if (job.artifact_urls?.dual) {{
797
- actions.push(`<a href="${{job.artifact_urls.dual}}"><button class="muted">Dual</button></a>`);
798
  }}
799
  if (job.artifact_urls?.glossary) {{
800
- actions.push(`<a href="${{job.artifact_urls.glossary}}"><button class="muted">Glossary</button></a>`);
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(`Cancel failed: ${{err.message}}`);
831
  }}
832
  }}
833
 
834
  document.getElementById('jobForm').addEventListener('submit', async (event) => {{
835
  event.preventDefault();
836
  const status = document.getElementById('jobStatus');
837
- status.textContent = 'Submitting...';
838
 
839
  const formData = new FormData(event.target);
840
  try {{
841
  const created = await apiJson('/api/jobs', {{ method: 'POST', body: formData }});
842
- status.textContent = `Job queued: ${{created.job.id}}`;
843
  event.target.reset();
844
  await refreshJobs();
845
  }} catch (err) {{
846
- status.textContent = `Submit failed: ${{err.message}}`;
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.warning("OPENAI_API_KEY is empty, translation jobs will fail")
 
 
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("Invalid username or password."), status_code=401)
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="Only PDF file is allowed")
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
- model.strip(),
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(