feat(openai_gateway): 纯文本可单独使用 OPENAI_TEXT_* 凭据

chat_completion_text 经 resolve_text_channel_credentials;与配料/多模 OPENAI_* 可不同 Key 或不同 BASE_URL;单测与 .env.example 更新。

Made-with: Cursor
This commit is contained in:
hub-gif 2026-04-27 11:33:14 +08:00
parent f3975a0fd3
commit be05dd3577
5 changed files with 88 additions and 8 deletions

View File

@ -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 互斥;凭据与 ① 可不同)---

View File

@ -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",

View File

@ -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:

View File

@ -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,

View File

@ -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"