From be05dd3577e9e77364e16922508e582b52543647 Mon Sep 17 00:00:00 2001 From: hub-gif <2487812171@qq.com> Date: Mon, 27 Apr 2026 11:33:14 +0800 Subject: [PATCH] =?UTF-8?q?feat(openai=5Fgateway):=20=E7=BA=AF=E6=96=87?= =?UTF-8?q?=E6=9C=AC=E5=8F=AF=E5=8D=95=E7=8B=AC=E4=BD=BF=E7=94=A8=20OPENAI?= =?UTF-8?q?=5FTEXT=5F*=20=E5=87=AD=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit chat_completion_text 经 resolve_text_channel_credentials;与配料/多模 OPENAI_* 可不同 Key 或不同 BASE_URL;单测与 .env.example 更新。 Made-with: Cursor --- .env.example | 13 +++--- backend/pipeline/openai_gateway/__init__.py | 2 + .../pipeline/openai_gateway/credentials.py | 31 ++++++++++++++ backend/pipeline/openai_gateway/text_chat.py | 8 ++-- ...openai_gateway_text_channel_credentials.py | 42 +++++++++++++++++++ 5 files changed, 88 insertions(+), 8 deletions(-) create mode 100644 backend/pipeline/tests/test_openai_gateway_text_channel_credentials.py diff --git a/.env.example b/.env.example index c6c84e0..a67816e 100644 --- a/.env.example +++ b/.env.example @@ -21,20 +21,23 @@ DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1 CORS_ALLOWED_ORIGINS=http://localhost:5173,http://127.0.0.1:5173 CSRF_TRUSTED_ORIGINS=http://localhost:5173,http://127.0.0.1:5173 -# --- LLM ① 自建/兼容网关(与 pipeline.openai_gateway 共用;商详多模、配料/视觉等走 OPENAI_*。若 ② 选方案 A,纯文本也读同一套 Key、BASE_URL,仅「文本模型名」可经 OPENAI_TEXT_MODEL 与 OPENAI_VISION_MODEL 不同;与 ① 完全独立网关/Key 时需改 ②-B~②-D)--- +# --- LLM ① 自建/兼容网关(与 pipeline.openai_gateway 共用;商详多模、配料/视觉走下面 OPENAI_* + OPENAI_VISION_MODEL)--- # OPENAI_API_KEY=sk-your-key-here # OPENAI_BASE_URL=https://llm.example.com/v1 # OPENAI_VISION_MODEL=Qwen/Qwen3-Omni-30B-A3B -# 若 OPENAI_BASE_URL=https://llm.rekeymed.com/v1,可用同一 Key 调 GET /v1/models 查看当前账号可见的 model id;以下为某次查询示例(以实际返回为准): +# 若 ② 选方案 A:纯文本默认也走 ① 的 Key/基址,但可只用下面两枚变量「只拆纯文本」:任一项不设则回退 ① +# OPENAI_TEXT_API_KEY=(与配料/视觉同网关不同 Key、或分账号) +# OPENAI_TEXT_BASE_URL=(可仅换路径/集群,仍与 ① 同域名) +# 若 OPENAI_BASE_URL=https://llm.rekeymed.com/v1,可用 Key 调 GET /v1/models 看 id;以下为某次查询示例(以实际返回为准): # Qwen/Qwen3-Omni-30B-A3B # openai/gpt-oss-120b # zai-org/GLM-4.5-Air -# 仅当 ② 选「方案 A」且要在自建上单独换「只文本补全」模型时填写;不填时回退为视觉模型名或网关默认 +# 纯文本专用模型名(不填时回退 OPENAI_VISION_MODEL / LLM_MODEL 等;与多模可不同) # OPENAI_TEXT_MODEL= -# 别名:LLM_API_KEY、LLM_BASE_URL、LLM_MODEL +# 别名:LLM_API_KEY、LLM_BASE_URL、LLM_MODEL、LLM_TEXT_API_KEY、LLM_TEXT_BASE_URL # --- LLM ② 纯文本(报告/策略/关键词等 call_llm;与 ②-B~②-D 多选一,只让一条 MA_LLM_TEXT_PROVIDER 生效,换方案时先改/注释本行再启别节)--- -# 方案 A(默认)= 经 openai_gateway 调你「当前 .env 里」的 OPENAI_*(不绑死 ① 里某条示例,可换任意兼容网关);要换 B=官站、C=Kimi、D=DeepSeek 时,把下面这行改成 openai_official|kimi|deepseek 等,并去对应小节填凭据 +# 方案 A(默认)= 经 openai_gateway:纯文本读 OPENAI_TEXT_*(若有)否则 OPENAI_*;要换 B=官站、C=Kimi、D=DeepSeek 时改下行为 openai_official|kimi|deepseek 等 MA_LLM_TEXT_PROVIDER=crawler_openai_compatible # --- ②-B 纯文本用 OpenAI 官站(仅当 MA=openai_official / openai_chatgpt / chatgpt;与 ②-A、②-C 互斥;凭据与 ① 可不同)--- diff --git a/backend/pipeline/openai_gateway/__init__.py b/backend/pipeline/openai_gateway/__init__.py index dc52623..884bf19 100644 --- a/backend/pipeline/openai_gateway/__init__.py +++ b/backend/pipeline/openai_gateway/__init__.py @@ -9,6 +9,7 @@ from .chat_content import normalize_message_content from .credentials import ( _resolve_credentials, resolve_credentials, + resolve_text_channel_credentials, resolve_text_model_name, ) from .ingredients_op import ( @@ -35,6 +36,7 @@ __all__ = [ "normalize_message_content", "parse_joined_image_urls", "resolve_credentials", + "resolve_text_channel_credentials", "resolve_text_model_name", "sanitize_vision_ingredients_output", "strip_outer_markdown_fence", diff --git a/backend/pipeline/openai_gateway/credentials.py b/backend/pipeline/openai_gateway/credentials.py index b0c595f..b2480cc 100644 --- a/backend/pipeline/openai_gateway/credentials.py +++ b/backend/pipeline/openai_gateway/credentials.py @@ -38,6 +38,37 @@ def _resolve_credentials( return key, base, m +def resolve_text_channel_credentials( + api_key: str | None = None, + base_url: str | None = None, +) -> tuple[str, str]: + """ + 仅用于 ``text_chat.chat_completion_text`` 的 Key 与基址。 + + 可与商详多模/配料的 ``OPENAI_API_KEY``、``OPENAI_BASE_URL`` **分开**(例如同网关两枚 Key、或同 Key 不同路径), + 任一侧未设 ``OPENAI_TEXT_*`` 时回退到 ``OPENAI_*`` / ``LLM_*``。 + + 别名:``LLM_TEXT_API_KEY``、``LLM_TEXT_BASE_URL``(与 ``OPENAI_TEXT_MODEL`` 等命名一致)。 + """ + key = (api_key or "").strip() or ( + (os.environ.get("OPENAI_TEXT_API_KEY") or os.environ.get("LLM_TEXT_API_KEY") or "").strip() + ) + if not key: + key = (os.environ.get("OPENAI_API_KEY") or os.environ.get("LLM_API_KEY") or "").strip() + base = (base_url or "").strip().rstrip("/") + if not base: + base = (os.environ.get("OPENAI_TEXT_BASE_URL") or os.environ.get("LLM_TEXT_BASE_URL") or "").strip().rstrip( + "/" + ) + if not base: + base = (os.environ.get("OPENAI_BASE_URL") or os.environ.get("LLM_BASE_URL") or "").strip().rstrip("/") + if not key: + raise ValueError("纯文本需设置 OPENAI_TEXT_API_KEY 或 OPENAI_API_KEY(或 LLM_* 别名)") + if not base: + raise ValueError("纯文本需设置 OPENAI_TEXT_BASE_URL 或 OPENAI_BASE_URL(或 LLM_* 别名)") + return key, base + + def resolve_text_model_name(model: str | None = None) -> str: m = (model or "").strip() if m: diff --git a/backend/pipeline/openai_gateway/text_chat.py b/backend/pipeline/openai_gateway/text_chat.py index a13f9b9..e10c151 100644 --- a/backend/pipeline/openai_gateway/text_chat.py +++ b/backend/pipeline/openai_gateway/text_chat.py @@ -1,5 +1,7 @@ """ -OpenAI 兼容 `chat/completions` 纯文本(system + user),与多模态配料识别共用 `OPENAI_*` / `LLM_*` 环境配置。 +OpenAI 兼容 `chat/completions` 纯文本(system + user)。 + +凭据与基址可经 ``OPENAI_TEXT_API_KEY`` / ``OPENAI_TEXT_BASE_URL`` 与多模/配料的 ``OPENAI_API_KEY`` / ``OPENAI_BASE_URL`` **分开**(可只覆写其中一项,另一项回退到 ``OPENAI_*``)。详见 ``credentials.resolve_text_channel_credentials``。 """ from __future__ import annotations @@ -9,7 +11,7 @@ from typing import Any import requests from .chat_content import normalize_message_content -from .credentials import _resolve_credentials, resolve_text_model_name +from .credentials import resolve_text_channel_credentials, resolve_text_model_name from .estimate import estimate_chat_input_tokens from .timeouts import chat_completion_read_timeout as _chat_completion_timeout @@ -41,7 +43,7 @@ def chat_completion_text( ) -> str: if timeout is None: timeout = _chat_completion_timeout() - k, b, _ = _resolve_credentials(api_key, base_url, None) + k, b = resolve_text_channel_credentials(api_key, base_url) m = resolve_text_model_name(model) body: dict[str, Any] = { "model": m, diff --git a/backend/pipeline/tests/test_openai_gateway_text_channel_credentials.py b/backend/pipeline/tests/test_openai_gateway_text_channel_credentials.py new file mode 100644 index 0000000..42367fe --- /dev/null +++ b/backend/pipeline/tests/test_openai_gateway_text_channel_credentials.py @@ -0,0 +1,42 @@ +"""OPENAI_TEXT_* 与 OPENAI_* 可分开供纯文本使用。""" +from __future__ import annotations + +import pytest + +from pipeline.openai_gateway.credentials import resolve_text_channel_credentials + + +def test_text_channel_falls_back_to_openai_when_no_text_specific(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("OPENAI_API_KEY", "sk-main") + monkeypatch.setenv("OPENAI_BASE_URL", "https://gw.example.com/v1") + monkeypatch.delenv("OPENAI_TEXT_API_KEY", raising=False) + monkeypatch.delenv("OPENAI_TEXT_BASE_URL", raising=False) + k, b = resolve_text_channel_credentials() + assert k == "sk-main" + assert b == "https://gw.example.com/v1" + + +def test_text_channel_uses_text_key_same_base(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("OPENAI_API_KEY", "sk-vision") + monkeypatch.setenv("OPENAI_BASE_URL", "https://llm.rekeymed.com/v1") + monkeypatch.setenv("OPENAI_TEXT_API_KEY", "sk-text-only") + k, b = resolve_text_channel_credentials() + assert k == "sk-text-only" + assert b == "https://llm.rekeymed.com/v1" + + +def test_text_channel_uses_text_base_same_key(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("OPENAI_API_KEY", "sk-shared") + monkeypatch.setenv("OPENAI_BASE_URL", "https://a.com/v1") + monkeypatch.setenv("OPENAI_TEXT_BASE_URL", "https://b.com/v1") + k, b = resolve_text_channel_credentials() + assert k == "sk-shared" + assert b == "https://b.com/v1" + + +def test_explicit_args_win_over_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("OPENAI_API_KEY", "sk-env") + monkeypatch.setenv("OPENAI_BASE_URL", "https://env.com/v1") + k, b = resolve_text_channel_credentials("sk-arg", "https://arg.com/v1") + assert k == "sk-arg" + assert b == "https://arg.com/v1"