feat: 支持 Provider 多下游 target_formats 并收敛对外配置接口
- 将 Provider 下游配置由单个 target_format 扩展为多个 target_formats - 保留旧字段 target_format 的启动期迁移逻辑,并标注为弃用兼容代码 - 收敛对外配置与接口返回,Provider 相关响应及 /v1/models 仅暴露 target_formats - 前端 Provider 表单改为多选下游,支持互斥项自动替换 - 新建 Provider 默认选中 openai_chat、codex、claude_chat - 更新示例配置、README、4+1 架构文档及相关测试
This commit is contained in:
1 parent
3751b9ac32
commit
bd7552f23b
16 files changed
+913
-111
No files matched your search
@@ -10,7 +10,8 @@ LLM_Proxy 用来把不同的模型服务、鉴权方式和传输协议收敛到
|
||||
|
||||
- 统一接入多种上游:同一个服务可以同时接 OpenAI Chat、OpenAI Responses、Claude Messages、Codex 风格上游
|
||||
- 对下游保持稳定接口:支持 `POST /v1/chat/completions`、`POST /v1/responses`、`POST /v1/messages`、`GET /v1/models`
|
||||
- 协议转换明确可控:通过 `source_format` 和 `target_format` 驱动内置 translator 做协议转换
|
||||
- 协议转换明确可控:通过 `source_format` 和本次请求命中的 `target_format` 驱动内置 translator 做协议转换
|
||||
- 多下游暴露:同一个 Provider 可以同时配置多个 `target_formats`
|
||||
- 多 Provider 精确路由:下游使用 `provider/model` 即可命中指定上游
|
||||
- 多 Key 池化调度:`auth_group` 支持并发限制、429 冷却、分钟/日请求配额、分钟/日 Token 配额
|
||||
- WebSocket 上游支持:上游可以是 HTTP,也可以是 WebSocket;下游接口保持不变
|
||||
@@ -47,7 +48,8 @@ providers:
|
||||
api: https://api.openai.com/v1/chat/completions
|
||||
transport: http
|
||||
source_format: openai_chat
|
||||
target_format: openai_chat
|
||||
target_formats:
|
||||
- openai_chat
|
||||
api_key: sk-your-openai-key
|
||||
verify_ssl: false
|
||||
model_list:
|
||||
@@ -58,6 +60,7 @@ providers:
|
||||
也可以直接从 [config.sample.yaml](config.sample.yaml) 开始裁剪。
|
||||
注意:下游调用时的模型名不是裸模型名,而是 `provider/model`,例如 `openai-chat/gpt-4.1`。
|
||||
如果未配置 `providers[].enabled`,默认就是启用状态。
|
||||
启动时如果发现旧配置字段 `target_format`,系统会自动把它规范化为 `target_formats` 并回写配置文件;这是兼容迁移逻辑,后续会删除。
|
||||
|
||||
### 3. 启动服务
|
||||
|
||||
@@ -72,6 +75,7 @@ python main.py --config path/to/config.yaml
|
||||
```
|
||||
|
||||
### 4. 验证服务是否可用
|
||||
访问Web: http://127.0.0.1:8080
|
||||
|
||||
查询模型列表:
|
||||
|
||||
@@ -116,7 +120,7 @@ curl http://127.0.0.1:8080/v1/chat/completions \
|
||||
|
||||
### 4. 非标准上游协议适配
|
||||
|
||||
当上游不是标准 OpenAI 格式时,优先通过 `source_format` 和 `target_format` 使用内置协议转换;如果还需要补充 Cookie、自定义 Header、特殊字段,或者对成功响应做额外清洗,再用 Hook 在代理层补充这部分逻辑。
|
||||
当上游不是标准 OpenAI 格式时,优先通过 `source_format` 和 `target_formats` 使用内置协议转换;运行时会按当前路由解析出实际命中的 `target_format`。如果还需要补充 Cookie、自定义 Header、特殊字段,或者对成功响应做额外清洗,再用 Hook 在代理层补充这部分逻辑。
|
||||
|
||||
### 5. 协议迁移期兼容
|
||||
|
||||
@@ -124,7 +128,7 @@ curl http://127.0.0.1:8080/v1/chat/completions \
|
||||
|
||||
## 接口概览
|
||||
|
||||
| 路由 | 说明 | 允许的 `target_format` |
|
||||
| 路由 | 说明 | 允许命中的 `target_formats` |
|
||||
| --- | --- | --- |
|
||||
| `POST /v1/chat/completions` | OpenAI Chat Completions 兼容接口 | `openai_chat` |
|
||||
| `POST /v1/responses` | OpenAI Responses / Codex 风格接口 | `openai_responses`、`codex` |
|
||||
@@ -158,7 +162,7 @@ curl http://127.0.0.1:8080/v1/chat/completions \
|
||||
- `api`:上游接口地址,支持 `http://`、`https://`、`ws://`、`wss://`
|
||||
- `transport`:上游传输方式,支持 `http` 和 `websocket`
|
||||
- `source_format`:上游真实协议格式
|
||||
- `target_format`:当前 Provider 对下游暴露的协议格式
|
||||
- `target_formats`:当前 Provider 对下游暴露的协议格式列表;默认 `['openai_chat']`
|
||||
- `api_key`:单 Provider 直接使用的凭据
|
||||
- `auth_group`:绑定一个凭据池;和 `api_key` 二选一,不能同时使用
|
||||
- `proxy`:上游代理地址
|
||||
@@ -175,16 +179,19 @@ curl http://127.0.0.1:8080/v1/chat/completions \
|
||||
- `claude_chat`
|
||||
- `codex`
|
||||
|
||||
如何理解 `source_format` 和 `target_format`:
|
||||
如何理解 `source_format` 和 `target_formats`:
|
||||
|
||||
- `source_format` 描述上游实际上接受什么协议
|
||||
- `target_format` 描述这个 Provider 希望下游通过什么协议访问
|
||||
- `target_formats` 描述这个 Provider 允许下游通过哪些协议访问
|
||||
- 实际请求进入时,controller 会根据当前 route family 从 `target_formats` 里解析出本次命中的单个 `target_format`
|
||||
- 旧配置字段 `target_format` 会在启动时自动迁移为 `target_formats`;这是弃用兼容逻辑,后续会删除
|
||||
- 同一个 Provider 里,`openai_responses` 和 `codex` 不能同时出现在 `target_formats` 中;它们都走 `/v1/responses`,但请求语义不同
|
||||
|
||||
例如:
|
||||
|
||||
- 上游和下游都用 Chat Completions:`openai_chat -> openai_chat`
|
||||
- 上游是 Responses,下游继续暴露 Chat Completions:`openai_responses -> openai_chat`
|
||||
- 上游和下游都走 Claude Messages:`claude_chat -> claude_chat`
|
||||
- 上游和下游都用 Chat Completions:`openai_chat -> [openai_chat]`
|
||||
- 上游是 Responses,下游继续暴露 Chat Completions:`openai_responses -> [openai_chat]`
|
||||
- 同一个上游同时暴露 Chat 和 Claude:`openai_chat -> [openai_chat, claude_chat]`
|
||||
|
||||
### `auth_groups[]` 字段说明
|
||||
|
||||
@@ -218,7 +225,8 @@ providers:
|
||||
- name: openai-chat
|
||||
api: https://api.openai.com/v1/chat/completions
|
||||
source_format: openai_chat
|
||||
target_format: openai_chat
|
||||
target_formats:
|
||||
- openai_chat
|
||||
api_key: ${OPENAI_API_KEY}
|
||||
verify_ssl: true
|
||||
model_list:
|
||||
@@ -233,14 +241,31 @@ providers:
|
||||
- name: responses-upstream
|
||||
api: https://api.openai.com/v1/responses
|
||||
source_format: openai_responses
|
||||
target_format: openai_chat
|
||||
target_formats:
|
||||
- openai_chat
|
||||
api_key: ${OPENAI_API_KEY}
|
||||
verify_ssl: true
|
||||
model_list:
|
||||
- gpt-4.1
|
||||
```
|
||||
|
||||
### 3. 使用 Auth Group 管理多个 Key
|
||||
### 3. 同一个上游同时暴露多个下游
|
||||
|
||||
```yaml
|
||||
providers:
|
||||
- name: openai-shared
|
||||
api: https://api.openai.com/v1/chat/completions
|
||||
source_format: openai_chat
|
||||
target_formats:
|
||||
- openai_chat
|
||||
- claude_chat
|
||||
api_key: ${OPENAI_API_KEY}
|
||||
verify_ssl: true
|
||||
model_list:
|
||||
- gpt-4.1
|
||||
```
|
||||
|
||||
### 4. 使用 Auth Group 管理多个 Key
|
||||
|
||||
```yaml
|
||||
auth_groups:
|
||||
@@ -262,21 +287,23 @@ providers:
|
||||
- name: openai-chat
|
||||
api: https://api.openai.com/v1/chat/completions
|
||||
source_format: openai_chat
|
||||
target_format: openai_chat
|
||||
target_formats:
|
||||
- openai_chat
|
||||
auth_group: openai-shared
|
||||
verify_ssl: true
|
||||
model_list:
|
||||
- gpt-4.1
|
||||
```
|
||||
|
||||
### 4. 使用 Hook 做协议补充
|
||||
### 5. 使用 Hook 做协议补充
|
||||
|
||||
```yaml
|
||||
providers:
|
||||
- name: custom-gateway
|
||||
api: https://example.com/v1/chat/completions
|
||||
source_format: openai_chat
|
||||
target_format: openai_chat
|
||||
target_formats:
|
||||
- openai_chat
|
||||
model_list:
|
||||
- my-model
|
||||
hook: example_hook.py
|
||||
@@ -292,16 +319,17 @@ providers:
|
||||
### 1. 代理与协议转换
|
||||
|
||||
- 按请求里的 `model` 自动选择对应 Provider
|
||||
- 按 `target_format` 校验当前路由是否匹配,避免请求打错入口
|
||||
- 按 `target_formats` 校验当前路由是否匹配,并解析出本次请求实际使用的 `target_format`
|
||||
- 支持流式和非流式响应
|
||||
- 自动识别 SSE、NDJSON、WebSocket JSON 等上游返回形态
|
||||
- `GET /v1/models` 会返回当前已启用 Provider 的模型列表,以及 `provider_name`、`source_format`、`target_format`、`transport` 等元信息
|
||||
- `GET /v1/models` 会返回当前已启用 Provider 的模型列表,以及 `provider_name`、`source_format`、`target_formats`、`transport` 等元信息
|
||||
|
||||
### 2. Provider 与 Auth Group 管理
|
||||
|
||||
- Provider 增删改查
|
||||
- 支持行内启用 / 禁用,以及批量启用 / 禁用 / 删除
|
||||
- 拉取上游模型列表并辅助填充 `model_list`
|
||||
- Provider 表单支持多选下游格式,新建时默认选中 `openai_chat`、`codex`、`claude_chat`,并在前端自动处理互斥项替换
|
||||
- Auth Group 增删改查
|
||||
- YAML 批量导入 Auth Entries
|
||||
- 查看 Auth Group 运行时状态
|
||||
@@ -339,7 +367,7 @@ from src.hooks import BaseHook, HookContext
|
||||
|
||||
职责边界先说明白:
|
||||
|
||||
- 标准协议转换由 `source_format`、`target_format` 对应的 translator 完成
|
||||
- 标准协议转换由 `source_format`、当前请求命中的 `target_format` 对应的 translator 完成
|
||||
- Hook 不是协议转换层,不负责定义 `openai_chat`、`openai_responses`、`claude_chat`、`codex` 之间的标准映射
|
||||
- Hook 负责的是请求进入上游前后的定制化处理,例如补 Header、做请求护栏、调整局部字段、清洗成功响应
|
||||
|
||||
@@ -384,7 +412,7 @@ from src.hooks import BaseHook, HookContext
|
||||
- `request_model`:下游请求里的模型名,通常是 `provider/model`
|
||||
- `upstream_model`:真正发往上游的模型名
|
||||
- `provider_source_format`
|
||||
- `provider_target_format`
|
||||
- `provider_target_format`:当前请求实际命中的下游格式
|
||||
- `transport`
|
||||
- `stream`
|
||||
- `auth_group_name`
|
||||
|
||||
+21
-7
@@ -36,7 +36,8 @@ providers:
|
||||
api: https://api.openai.com/v1/chat/completions
|
||||
transport: http
|
||||
source_format: openai_chat
|
||||
target_format: openai_chat
|
||||
target_formats:
|
||||
- openai_chat
|
||||
auth_group: openai-shared
|
||||
verify_ssl: true
|
||||
timeout_seconds: 1200
|
||||
@@ -50,7 +51,8 @@ providers:
|
||||
api: https://api.openai.com/v1/responses
|
||||
transport: http
|
||||
source_format: openai_responses
|
||||
target_format: openai_chat
|
||||
target_formats:
|
||||
- openai_chat
|
||||
auth_group: openai-shared
|
||||
verify_ssl: true
|
||||
timeout_seconds: 1200
|
||||
@@ -62,7 +64,8 @@ providers:
|
||||
api: https://api.anthropic.com/v1/messages
|
||||
transport: http
|
||||
source_format: claude_chat
|
||||
target_format: claude_chat
|
||||
target_formats:
|
||||
- claude_chat
|
||||
api_key: sk-ant-your-anthropic-key
|
||||
verify_ssl: true
|
||||
timeout_seconds: 1200
|
||||
@@ -74,7 +77,8 @@ providers:
|
||||
api: https://api.openai.com/v1/responses
|
||||
transport: http
|
||||
source_format: codex
|
||||
target_format: codex
|
||||
target_formats:
|
||||
- codex
|
||||
api_key: sk-your-openai-key
|
||||
verify_ssl: true
|
||||
timeout_seconds: 1200
|
||||
@@ -83,13 +87,23 @@ providers:
|
||||
- gpt-5-codex
|
||||
|
||||
# OpenCode / Cherry Studio (OpenAI provider) commonly use:
|
||||
# target_format: openai_chat
|
||||
# target_formats:
|
||||
# - openai_chat
|
||||
#
|
||||
# Claude Code / Cherry Studio (Anthropic provider) commonly use:
|
||||
# target_format: claude_chat
|
||||
# target_formats:
|
||||
# - claude_chat
|
||||
#
|
||||
# Codex commonly uses:
|
||||
# target_format: codex
|
||||
# target_formats:
|
||||
# - codex
|
||||
#
|
||||
# A single provider can expose multiple downstream formats, for example:
|
||||
# target_formats:
|
||||
# - openai_chat
|
||||
# - claude_chat
|
||||
#
|
||||
# openai_responses and codex are mutually exclusive in the same provider.
|
||||
#
|
||||
# Common upstream choices:
|
||||
# /v1/chat/completions -> source_format: openai_chat
|
||||
|
||||
+2
-1
@@ -12,7 +12,6 @@ providers:
|
||||
api: http://127.0.0.1:22026
|
||||
transport: http
|
||||
source_format: openai_chat
|
||||
target_format: openai_chat
|
||||
verify_ssl: false
|
||||
model_list:
|
||||
- kimi-k2.5
|
||||
@@ -33,6 +32,8 @@ providers:
|
||||
- volc_code/GLM-4.7
|
||||
- volc_code/MiniMax-M2.5
|
||||
- volc_code/kimi-k2.5
|
||||
target_formats:
|
||||
- openai_chat
|
||||
auth_groups:
|
||||
- name: a
|
||||
strategy: least_inflight
|
||||
|
||||
+16
-11
@@ -17,7 +17,7 @@
|
||||
- `POST /v1/messages`
|
||||
- `GET /v1/models`
|
||||
|
||||
这个版本不保留 Gemini / Antigravity,也不保留旧字段兼容逻辑。
|
||||
这个版本不保留 Gemini / Antigravity。配置加载阶段会把旧字段 `target_format` 规范化为 `target_formats`,这段弃用兼容逻辑后续会删除;除此之外不再继续扩展旧字段兼容逻辑。
|
||||
|
||||
## 2. Logical View
|
||||
|
||||
@@ -47,7 +47,8 @@ downstream request
|
||||
### 2.2 Major Components
|
||||
|
||||
- `ProxyController`
|
||||
- 校验下游 route 和 `target_format`
|
||||
- 校验下游 route 和 `target_formats`
|
||||
- 根据 route family 解析本次请求命中的 `target_format`
|
||||
- 构造标准错误体
|
||||
- `ProviderManager`
|
||||
- 加载 provider 配置
|
||||
@@ -58,6 +59,7 @@ downstream request
|
||||
- 持久化冷却、禁用与配额运行态
|
||||
- `ProxyService`
|
||||
- 组装整条代理链路
|
||||
- 使用本次解析出的 `target_format` 选择 translator 和 encoder
|
||||
- `ExecutorRegistry`
|
||||
- 负责 HTTP / WebSocket 上游连接
|
||||
- `Decoder`
|
||||
@@ -96,20 +98,23 @@ Hook 组件除了 header / guard,还会收到最小重试上下文:
|
||||
|
||||
### 3.1 Downstream Route Contract
|
||||
|
||||
`target_format` 和 route family 强绑定:
|
||||
`target_formats` 和 route family 强绑定:
|
||||
|
||||
| route | allowed target_format |
|
||||
| route | allowed target_formats |
|
||||
| --- | --- |
|
||||
| `/v1/chat/completions` | `openai_chat` |
|
||||
| `/v1/responses` | `openai_responses`, `codex` |
|
||||
| `/v1/messages` | `claude_chat` |
|
||||
|
||||
不匹配时,controller 直接返回 400,不会继续请求上游。
|
||||
controller 会根据 route 在 `target_formats` 里解析出本次请求实际命中的 `target_format`。
|
||||
|
||||
- 不匹配时,controller 直接返回 400,不会继续请求上游
|
||||
- 当前 `openai_responses` 和 `codex` 在同一个 provider 内互斥,因为它们共用 `/v1/responses`,但请求语义不同
|
||||
|
||||
`GET /v1/models` 除了模型 id,还会返回模型所属 provider 的:
|
||||
|
||||
- `source_format`
|
||||
- `target_format`
|
||||
- `target_formats`
|
||||
- `transport`
|
||||
|
||||
### 3.2 Provider Runtime Contract
|
||||
@@ -120,7 +125,7 @@ Provider 公共运行时字段只有:
|
||||
- `api`
|
||||
- `transport`
|
||||
- `source_format`
|
||||
- `target_format`
|
||||
- `target_formats`
|
||||
- `api_key`
|
||||
- `auth_group`
|
||||
- `proxy`
|
||||
@@ -134,8 +139,8 @@ Provider 公共运行时字段只有:
|
||||
|
||||
- `source_format`
|
||||
- 上游真实协议
|
||||
- `target_format`
|
||||
- 下游暴露协议
|
||||
- `target_formats`
|
||||
- 当前 provider 对下游暴露的协议集合
|
||||
- `transport`
|
||||
- HTTP 或 WebSocket
|
||||
|
||||
@@ -207,7 +212,7 @@ Hook 运行时上下文还会暴露最小重试状态:
|
||||
- [src/translators/registry.py](/d:/001Code/008llm/003LLM_Proxy/src/translators/registry.py)
|
||||
- 4x4 translator registry
|
||||
- [src/presentation/templates/providers.html](/d:/001Code/008llm/003LLM_Proxy/src/presentation/templates/providers.html)
|
||||
- Provider 页面与帮助说明
|
||||
- Provider 页面、多选 `target_formats` 与互斥说明
|
||||
|
||||
## 5. Physical View
|
||||
|
||||
@@ -295,7 +300,7 @@ Gemini / Antigravity 这类协议面会显著增加配置复杂度,但对当
|
||||
用户只需要清楚:
|
||||
|
||||
- 上游是什么协议
|
||||
- 下游要暴露成什么协议
|
||||
- 下游要暴露成什么协议集合
|
||||
- 上游通过 HTTP 还是 WebSocket 连接
|
||||
|
||||
上游到底是 SSE、NDJSON 还是非流式,由 executor / decoder 自动判断。
|
||||
|
||||
@@ -2,15 +2,19 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""配置访问层。"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional, Union
|
||||
from typing import Any, Dict, Optional, Tuple, Union
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
LOGGER = logging.getLogger("app")
|
||||
|
||||
|
||||
class ConfigManager:
|
||||
"""提供配置项的类型化读取方法。"""
|
||||
|
||||
@@ -68,14 +72,15 @@ class ConfigManager:
|
||||
if not isinstance(config, dict):
|
||||
raise ValueError('Configuration file must contain a top-level mapping')
|
||||
|
||||
self._write_config(config)
|
||||
self._config = deepcopy(config)
|
||||
normalized, _ = self._normalize_config(config)
|
||||
self._write_config(normalized)
|
||||
self._config = deepcopy(normalized)
|
||||
|
||||
def reload(self) -> None:
|
||||
self._config = self._load_config(self._config_path)
|
||||
|
||||
@staticmethod
|
||||
def _load_config(config_path: Union[str, Path]) -> Dict[str, Any]:
|
||||
@classmethod
|
||||
def _load_config(cls, config_path: Union[str, Path]) -> Dict[str, Any]:
|
||||
path = Path(config_path).resolve()
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f'Configuration file not found: {path}')
|
||||
@@ -87,15 +92,26 @@ class ConfigManager:
|
||||
return {}
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError('Configuration file must contain a top-level mapping')
|
||||
return data
|
||||
normalized, changed = cls._normalize_config(data)
|
||||
if changed:
|
||||
cls._write_config_file(path, normalized)
|
||||
LOGGER.info(
|
||||
"Migrated legacy provider config field 'target_format' to 'target_formats': %s",
|
||||
path,
|
||||
)
|
||||
return normalized
|
||||
|
||||
def _write_config(self, config: Dict[str, Any]) -> None:
|
||||
self._write_config_file(self._config_path, config)
|
||||
|
||||
@staticmethod
|
||||
def _write_config_file(config_path: Path, config: Dict[str, Any]) -> None:
|
||||
temp_file_path: Optional[str] = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(
|
||||
'w',
|
||||
encoding='utf-8',
|
||||
dir=self._config_path.parent,
|
||||
dir=config_path.parent,
|
||||
delete=False,
|
||||
) as temp_file:
|
||||
yaml.safe_dump(config, temp_file, allow_unicode=True, sort_keys=False)
|
||||
@@ -103,7 +119,46 @@ class ConfigManager:
|
||||
os.fsync(temp_file.fileno())
|
||||
temp_file_path = temp_file.name
|
||||
|
||||
os.replace(temp_file_path, self._config_path)
|
||||
os.replace(temp_file_path, config_path)
|
||||
finally:
|
||||
if temp_file_path and os.path.exists(temp_file_path):
|
||||
os.remove(temp_file_path)
|
||||
|
||||
@classmethod
|
||||
def _normalize_config(cls, config: Dict[str, Any]) -> Tuple[Dict[str, Any], bool]:
|
||||
normalized = deepcopy(config)
|
||||
providers = normalized.get('providers')
|
||||
if not isinstance(providers, list):
|
||||
return normalized, False
|
||||
|
||||
changed = False
|
||||
normalized_providers = []
|
||||
for provider in providers:
|
||||
if not isinstance(provider, dict):
|
||||
normalized_providers.append(provider)
|
||||
continue
|
||||
|
||||
normalized_provider = dict(provider)
|
||||
# DEPRECATED compatibility path for legacy config files that still
|
||||
# use `target_format`. Public config/API support is now limited to
|
||||
# `target_formats`, and this migration block should be removed later.
|
||||
legacy_target_format = normalized_provider.pop('target_format', None)
|
||||
if legacy_target_format is not None:
|
||||
changed = True
|
||||
if not cls._has_target_formats_value(normalized_provider.get('target_formats')):
|
||||
normalized_provider['target_formats'] = [legacy_target_format]
|
||||
normalized_providers.append(normalized_provider)
|
||||
|
||||
if changed:
|
||||
normalized['providers'] = normalized_providers
|
||||
return normalized, changed
|
||||
|
||||
@staticmethod
|
||||
def _has_target_formats_value(value: Any) -> bool:
|
||||
if value is None:
|
||||
return False
|
||||
if isinstance(value, str):
|
||||
return bool(value.strip())
|
||||
if isinstance(value, (list, tuple)):
|
||||
return any(str(item or '').strip() for item in value)
|
||||
return True
|
||||
@@ -16,6 +16,7 @@ DEFAULT_PROVIDER_VERIFY_SSL = False
|
||||
DEFAULT_PROVIDER_TRANSPORT = "http"
|
||||
DEFAULT_PROVIDER_SOURCE_FORMAT = "openai_chat"
|
||||
DEFAULT_PROVIDER_TARGET_FORMAT = "openai_chat"
|
||||
DEFAULT_PROVIDER_TARGET_FORMATS = (DEFAULT_PROVIDER_TARGET_FORMAT,)
|
||||
DEFAULT_AUTH_GROUP_STRATEGY = "least_inflight"
|
||||
DEFAULT_AUTH_GROUP_COOLDOWN_SECONDS_ON_429 = 60
|
||||
SUPPORTED_PROVIDER_TRANSPORTS = {"http", "websocket"}
|
||||
@@ -25,6 +26,9 @@ SUPPORTED_PROVIDER_PROTOCOLS = {
|
||||
"claude_chat",
|
||||
"codex",
|
||||
}
|
||||
SUPPORTED_PROVIDER_TARGET_FORMAT_CONFLICT_GROUPS = (
|
||||
("openai_responses", "codex"),
|
||||
)
|
||||
SUPPORTED_PROVIDER_API_SCHEMES = {"http", "https", "ws", "wss"}
|
||||
SUPPORTED_AUTH_GROUP_STRATEGIES = {DEFAULT_AUTH_GROUP_STRATEGY}
|
||||
SUPPORTED_PROVIDER_FIELDS = {
|
||||
@@ -33,7 +37,7 @@ SUPPORTED_PROVIDER_FIELDS = {
|
||||
"api",
|
||||
"transport",
|
||||
"source_format",
|
||||
"target_format",
|
||||
"target_formats",
|
||||
"api_key",
|
||||
"auth_group",
|
||||
"proxy",
|
||||
@@ -196,6 +200,61 @@ def resolve_provider_protocol(
|
||||
return resolved
|
||||
|
||||
|
||||
def normalize_provider_target_formats(value: Any, *, field_name: str = "target_formats") -> tuple[str, ...]:
|
||||
raw_items: Sequence[Any]
|
||||
if value is None:
|
||||
raw_items = ()
|
||||
elif isinstance(value, str):
|
||||
raw_items = value.replace(",", "\n").splitlines()
|
||||
elif isinstance(value, Sequence):
|
||||
raw_items = value
|
||||
else:
|
||||
raise ValueError(f"{field_name} must be a list or newline-separated string")
|
||||
|
||||
target_formats: List[str] = []
|
||||
seen_formats: set[str] = set()
|
||||
for item in raw_items:
|
||||
normalized_item = clean_optional_string(item)
|
||||
if normalized_item is None:
|
||||
continue
|
||||
resolved = resolve_provider_protocol(
|
||||
normalized_item,
|
||||
default=DEFAULT_PROVIDER_TARGET_FORMAT,
|
||||
field_name=field_name,
|
||||
)
|
||||
if resolved in seen_formats:
|
||||
continue
|
||||
seen_formats.add(resolved)
|
||||
target_formats.append(resolved)
|
||||
return tuple(target_formats)
|
||||
|
||||
|
||||
def validate_provider_target_format_conflicts(target_formats: Sequence[str]) -> None:
|
||||
selected_formats = {
|
||||
str(item or "").strip().lower()
|
||||
for item in target_formats
|
||||
if str(item or "").strip()
|
||||
}
|
||||
for group in SUPPORTED_PROVIDER_TARGET_FORMAT_CONFLICT_GROUPS:
|
||||
conflicting = [item for item in group if item in selected_formats]
|
||||
if len(conflicting) > 1:
|
||||
raise ValueError(
|
||||
"Provider target_formats contains mutually exclusive formats: "
|
||||
+ ", ".join(conflicting)
|
||||
)
|
||||
|
||||
|
||||
def resolve_provider_target_formats(target_formats_value: Any) -> tuple[str, ...]:
|
||||
resolved_target_formats = list(
|
||||
normalize_provider_target_formats(target_formats_value, field_name="target_formats")
|
||||
)
|
||||
if not resolved_target_formats:
|
||||
resolved_target_formats = list(DEFAULT_PROVIDER_TARGET_FORMATS)
|
||||
|
||||
validate_provider_target_format_conflicts(resolved_target_formats)
|
||||
return tuple(resolved_target_formats)
|
||||
|
||||
|
||||
def resolve_auth_group_strategy(value: Any) -> str:
|
||||
resolved = (clean_optional_string(value) or DEFAULT_AUTH_GROUP_STRATEGY).lower()
|
||||
if resolved not in SUPPORTED_AUTH_GROUP_STRATEGIES:
|
||||
@@ -371,7 +430,7 @@ class ProviderConfigSchema:
|
||||
enabled: bool = True
|
||||
transport: str = DEFAULT_PROVIDER_TRANSPORT
|
||||
source_format: str = DEFAULT_PROVIDER_SOURCE_FORMAT
|
||||
target_format: str = DEFAULT_PROVIDER_TARGET_FORMAT
|
||||
target_formats: tuple[str, ...] = DEFAULT_PROVIDER_TARGET_FORMATS
|
||||
api_key: Optional[str] = None
|
||||
auth_group: Optional[str] = None
|
||||
proxy: Optional[str] = None
|
||||
@@ -407,6 +466,8 @@ class ProviderConfigSchema:
|
||||
if api_key and auth_group:
|
||||
raise ValueError("Provider must define either auth_group or api_key, not both")
|
||||
|
||||
target_formats = resolve_provider_target_formats(config.get("target_formats"))
|
||||
|
||||
return cls(
|
||||
name=name,
|
||||
enabled=parse_optional_bool(
|
||||
@@ -422,11 +483,7 @@ class ProviderConfigSchema:
|
||||
default=DEFAULT_PROVIDER_SOURCE_FORMAT,
|
||||
field_name="source_format",
|
||||
),
|
||||
target_format=resolve_provider_protocol(
|
||||
config.get("target_format"),
|
||||
default=DEFAULT_PROVIDER_TARGET_FORMAT,
|
||||
field_name="target_format",
|
||||
),
|
||||
target_formats=target_formats,
|
||||
api_key=api_key,
|
||||
auth_group=auth_group,
|
||||
proxy=normalize_proxy_url(config.get("proxy")),
|
||||
@@ -437,6 +494,17 @@ class ProviderConfigSchema:
|
||||
hook=clean_optional_string(config.get("hook")),
|
||||
)
|
||||
|
||||
@property
|
||||
def primary_target_format(self) -> str:
|
||||
return self.target_formats[0]
|
||||
|
||||
@property
|
||||
def target_format(self) -> str:
|
||||
# DEPRECATED compatibility property. Public config/API payloads should
|
||||
# only expose `target_formats`, and callers should migrate to
|
||||
# `primary_target_format` or `target_formats` as appropriate.
|
||||
return self.primary_target_format
|
||||
|
||||
def to_mapping(self) -> Dict[str, Any]:
|
||||
config: Dict[str, Any] = {
|
||||
"name": self.name,
|
||||
@@ -444,7 +512,7 @@ class ProviderConfigSchema:
|
||||
"api": self.api,
|
||||
"transport": self.transport,
|
||||
"source_format": self.source_format,
|
||||
"target_format": self.target_format,
|
||||
"target_formats": list(self.target_formats),
|
||||
}
|
||||
|
||||
if self.api_key is not None:
|
||||
@@ -466,6 +534,9 @@ class ProviderConfigSchema:
|
||||
|
||||
return config
|
||||
|
||||
def to_storage_mapping(self) -> Dict[str, Any]:
|
||||
return self.to_mapping()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RuntimeProviderSpec:
|
||||
@@ -476,7 +547,7 @@ class RuntimeProviderSpec:
|
||||
api: str
|
||||
transport: str
|
||||
source_format: str
|
||||
target_format: str
|
||||
target_formats: tuple[str, ...]
|
||||
api_key: str
|
||||
auth_group: Optional[str]
|
||||
model_list: tuple[str, ...]
|
||||
@@ -498,7 +569,7 @@ class RuntimeProviderSpec:
|
||||
api=config.api,
|
||||
transport=config.transport,
|
||||
source_format=config.source_format,
|
||||
target_format=config.target_format,
|
||||
target_formats=config.target_formats,
|
||||
api_key=config.api_key or "",
|
||||
auth_group=config.auth_group,
|
||||
model_list=config.model_list,
|
||||
@@ -513,6 +584,12 @@ class RuntimeProviderSpec:
|
||||
hook=config.hook,
|
||||
)
|
||||
|
||||
@property
|
||||
def target_format(self) -> str:
|
||||
# DEPRECATED compatibility property. Runtime specs should prefer
|
||||
# `target_formats` and derive a primary value explicitly when needed.
|
||||
return self.target_formats[0]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ProviderRuntimeView:
|
||||
@@ -523,7 +600,7 @@ class ProviderRuntimeView:
|
||||
api: str
|
||||
transport: str
|
||||
source_format: str
|
||||
target_format: str
|
||||
target_formats: tuple[str, ...]
|
||||
auth_group: Optional[str]
|
||||
legacy_api_key: bool
|
||||
model_list: tuple[str, ...]
|
||||
@@ -541,7 +618,7 @@ class ProviderRuntimeView:
|
||||
api=spec.api,
|
||||
transport=spec.transport,
|
||||
source_format=spec.source_format,
|
||||
target_format=spec.target_format,
|
||||
target_formats=spec.target_formats,
|
||||
auth_group=spec.auth_group,
|
||||
legacy_api_key=legacy_api_key,
|
||||
model_list=spec.model_list,
|
||||
@@ -552,6 +629,16 @@ class ProviderRuntimeView:
|
||||
hook=spec.hook,
|
||||
)
|
||||
|
||||
@property
|
||||
def primary_target_format(self) -> str:
|
||||
return self.target_formats[0]
|
||||
|
||||
@property
|
||||
def target_format(self) -> str:
|
||||
# DEPRECATED compatibility property. Public runtime metadata should
|
||||
# only expose `target_formats`.
|
||||
return self.primary_target_format
|
||||
|
||||
|
||||
def build_auth_group_schemas(
|
||||
auth_groups: Sequence[Mapping[str, Any]],
|
||||
|
||||
@@ -93,7 +93,7 @@ class ProviderManager:
|
||||
api=runtime_spec.api,
|
||||
transport=runtime_spec.transport,
|
||||
source_format=runtime_spec.source_format,
|
||||
target_format=runtime_spec.target_format,
|
||||
target_formats=runtime_spec.target_formats,
|
||||
api_key=spec.api_key,
|
||||
auth_group=runtime_spec.auth_group,
|
||||
model_list=runtime_spec.model_list,
|
||||
|
||||
Vendored
+26
@@ -6,6 +6,10 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from ..config.provider_config import (
|
||||
DEFAULT_PROVIDER_TARGET_FORMAT,
|
||||
resolve_provider_target_formats,
|
||||
)
|
||||
from ..hooks import HookContext, HookModule
|
||||
from ..utils.compat import dataclass
|
||||
|
||||
@@ -19,6 +23,7 @@ class LLMProvider:
|
||||
transport: str = "http"
|
||||
source_format: str = "openai_chat"
|
||||
target_format: str = "openai_chat"
|
||||
target_formats: tuple[str, ...] = ()
|
||||
api_key: Optional[str] = None
|
||||
auth_group: Optional[str] = None
|
||||
model_list: tuple[str, ...] = ()
|
||||
@@ -30,6 +35,27 @@ class LLMProvider:
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
object.__setattr__(self, "model_list", tuple(self.model_list))
|
||||
explicit_target_formats = tuple(self.target_formats)
|
||||
# DEPRECATED compatibility path for legacy callers that still
|
||||
# construct LLMProvider with a single `target_format` value.
|
||||
normalized_target_formats = resolve_provider_target_formats(
|
||||
explicit_target_formats or (self.target_format,)
|
||||
)
|
||||
normalized_target_format = str(self.target_format or "").strip().lower()
|
||||
if explicit_target_formats and normalized_target_format and normalized_target_format != DEFAULT_PROVIDER_TARGET_FORMAT:
|
||||
if normalized_target_format not in normalized_target_formats:
|
||||
raise ValueError(
|
||||
"Provider target_format must also appear in target_formats when both are provided"
|
||||
)
|
||||
normalized_target_formats = (normalized_target_format,) + tuple(
|
||||
item for item in normalized_target_formats if item != normalized_target_format
|
||||
)
|
||||
object.__setattr__(self, "target_formats", normalized_target_formats)
|
||||
object.__setattr__(self, "target_format", normalized_target_formats[0])
|
||||
|
||||
def supports_target_format(self, target_format: str) -> bool:
|
||||
normalized_target_format = str(target_format or "").strip().lower()
|
||||
return normalized_target_format in self.target_formats
|
||||
|
||||
def apply_header_hook(self, ctx: HookContext, headers: Dict[str, str]) -> Dict[str, str]:
|
||||
if self.hook and hasattr(self.hook, "header_hook"):
|
||||
|
||||
@@ -122,6 +122,35 @@ class ProxyController:
|
||||
status_code,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _get_provider_target_formats(provider: Any) -> tuple[str, ...]:
|
||||
candidate_formats = getattr(provider, "target_formats", None)
|
||||
if candidate_formats:
|
||||
normalized = tuple(
|
||||
str(item or "").strip().lower()
|
||||
for item in candidate_formats
|
||||
if str(item or "").strip()
|
||||
)
|
||||
if normalized:
|
||||
return normalized
|
||||
|
||||
# DEPRECATED compatibility path for legacy objects that still expose a
|
||||
# single `target_format` field. New runtime/provider APIs should only
|
||||
# provide `target_formats`, and this fallback can be removed later.
|
||||
target_format = str(getattr(provider, "target_format", "") or "").strip().lower()
|
||||
if target_format:
|
||||
return (target_format,)
|
||||
return ()
|
||||
|
||||
@staticmethod
|
||||
def _format_provider_target_formats(target_formats: Iterable[str]) -> str:
|
||||
normalized = [str(item or "").strip().lower() for item in target_formats if str(item or "").strip()]
|
||||
if not normalized:
|
||||
return "<empty>"
|
||||
if len(normalized) == 1:
|
||||
return normalized[0]
|
||||
return ", ".join(normalized)
|
||||
|
||||
def chat_completions(self) -> Response:
|
||||
return self._proxy_completion_request(
|
||||
route_name="chat_completions",
|
||||
@@ -222,25 +251,30 @@ class ProxyController:
|
||||
error_format=resolved_error_format,
|
||||
)
|
||||
|
||||
provider_target_format = str(getattr(provider, "target_format", "") or "").strip().lower()
|
||||
if provider_target_format not in normalized_expected_target_formats:
|
||||
provider_target_formats = self._get_provider_target_formats(provider)
|
||||
matched_target_formats = tuple(
|
||||
item
|
||||
for item in provider_target_formats
|
||||
if item in normalized_expected_target_formats
|
||||
)
|
||||
if not matched_target_formats:
|
||||
self._logger.warning(
|
||||
"Proxy rejected: model=%s configured for target_format=%s route=%s",
|
||||
"Proxy rejected: model=%s configured for target_formats=%s route=%s",
|
||||
model_name,
|
||||
provider_target_format or "<empty>",
|
||||
self._format_provider_target_formats(provider_target_formats),
|
||||
route_name,
|
||||
)
|
||||
if len(normalized_expected_target_formats) == 1:
|
||||
expected_hint = normalized_expected_target_formats[0]
|
||||
mismatch_message = (
|
||||
f"Model {model_name} is configured for downstream format "
|
||||
f"{provider_target_format or '<empty>'}, not {expected_hint}"
|
||||
f"Model {model_name} is configured for downstream formats "
|
||||
f"{self._format_provider_target_formats(provider_target_formats)}, not {expected_hint}"
|
||||
)
|
||||
else:
|
||||
expected_hint = ", ".join(normalized_expected_target_formats)
|
||||
mismatch_message = (
|
||||
f"Model {model_name} is configured for downstream format "
|
||||
f"{provider_target_format or '<empty>'}, not one of {expected_hint}"
|
||||
f"Model {model_name} is configured for downstream formats "
|
||||
f"{self._format_provider_target_formats(provider_target_formats)}, not one of {expected_hint}"
|
||||
)
|
||||
return self._error_response(
|
||||
mismatch_message,
|
||||
@@ -249,6 +283,24 @@ class ProxyController:
|
||||
code="target_format_mismatch",
|
||||
error_format=resolved_error_format,
|
||||
)
|
||||
if len(matched_target_formats) > 1:
|
||||
self._logger.warning(
|
||||
"Proxy rejected: model=%s matched multiple target_formats=%s route=%s",
|
||||
model_name,
|
||||
self._format_provider_target_formats(matched_target_formats),
|
||||
route_name,
|
||||
)
|
||||
return self._error_response(
|
||||
(
|
||||
f"Model {model_name} matches multiple downstream formats on route {route_name}: "
|
||||
f"{self._format_provider_target_formats(matched_target_formats)}"
|
||||
),
|
||||
400,
|
||||
error_type="invalid_request_error",
|
||||
code="ambiguous_target_formats",
|
||||
error_format=resolved_error_format,
|
||||
)
|
||||
resolved_target_format = matched_target_formats[0]
|
||||
|
||||
client_requested_usage_chunk = False
|
||||
if inspect_stream_usage:
|
||||
@@ -286,6 +338,7 @@ class ProxyController:
|
||||
headers,
|
||||
on_complete=on_proxy_complete,
|
||||
forward_stream_usage=client_requested_usage_chunk,
|
||||
resolved_target_format=resolved_target_format,
|
||||
)
|
||||
if result is None:
|
||||
failure_info = failure_info or ProxyErrorInfo(
|
||||
@@ -362,7 +415,7 @@ class ProxyController:
|
||||
provider_name, _, _ = str(model_key).partition("/")
|
||||
provider_view = self._provider_manager.get_provider_view(provider_name)
|
||||
source_format = getattr(provider_view, "source_format", None)
|
||||
target_format = getattr(provider_view, "target_format", None)
|
||||
target_formats = self._get_provider_target_formats(provider_view)
|
||||
transport = getattr(provider_view, "transport", None)
|
||||
data.append(
|
||||
{
|
||||
@@ -371,7 +424,7 @@ class ProxyController:
|
||||
"owned_by": provider_name or "proxy",
|
||||
"provider_name": provider_name or "proxy",
|
||||
"source_format": source_format,
|
||||
"target_format": target_format,
|
||||
"target_formats": list(target_formats),
|
||||
"transport": transport,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1379,6 +1379,19 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.providers-page .field-mode-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 22px;
|
||||
padding: 0 8px;
|
||||
border-radius: 999px;
|
||||
background: rgba(58, 127, 237, 0.12);
|
||||
color: var(--primary-active);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.providers-page .field-help-button {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
@@ -1423,6 +1436,105 @@
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.providers-page .custom-select.is-multi .custom-select-trigger {
|
||||
min-height: calc(1.5em + 0.75rem + 2px);
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.providers-page .custom-select.is-multi .custom-select-trigger::after {
|
||||
top: 50%;
|
||||
}
|
||||
|
||||
.providers-page .custom-select-trigger-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
padding-right: 12px;
|
||||
}
|
||||
|
||||
.providers-page .custom-select-trigger-chips {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.providers-page .custom-select-trigger-placeholder {
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.providers-page .custom-select-trigger-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
max-width: 100%;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid rgba(58, 127, 237, 0.18);
|
||||
border-radius: 999px;
|
||||
background: #edf4fc;
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.providers-page .custom-select-trigger-chip-more {
|
||||
border-color: rgba(100, 116, 139, 0.18);
|
||||
background: #f6f8fb;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.providers-page .custom-select-option-multi {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.providers-page .custom-select-option-check {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 1px solid rgba(100, 116, 139, 0.42);
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
flex: 0 0 auto;
|
||||
position: relative;
|
||||
transition: border-color var(--transition), background-color var(--transition), box-shadow var(--transition);
|
||||
}
|
||||
|
||||
.providers-page .custom-select-option-label {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.providers-page .custom-select-option.selected .custom-select-option-check {
|
||||
border-color: var(--primary);
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
.providers-page .custom-select-option.selected .custom-select-option-check::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
top: 1px;
|
||||
width: 5px;
|
||||
height: 9px;
|
||||
border-right: 2px solid #fff;
|
||||
border-bottom: 2px solid #fff;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.providers-page .custom-select-menu-hint {
|
||||
margin-top: 6px;
|
||||
padding: 8px 10px 4px;
|
||||
border-top: 1px dashed rgba(100, 116, 139, 0.22);
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.providers-page .modal .custom-select-menu {
|
||||
z-index: 1065;
|
||||
}
|
||||
@@ -1760,6 +1872,32 @@
|
||||
color: #d9e7f7;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .providers-page .custom-select-trigger-chip {
|
||||
border-color: #355a84;
|
||||
background: linear-gradient(180deg, rgba(22, 40, 65, 0.98), rgba(16, 31, 51, 0.98));
|
||||
color: #d9eaff;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .providers-page .custom-select-trigger-chip-more {
|
||||
border-color: #3b4654;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: #a8b3c2;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .providers-page .custom-select-option-check {
|
||||
border-color: #4a586b;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .providers-page .custom-select-option.selected .custom-select-option-check {
|
||||
border-color: #5f8fbe;
|
||||
background: #3a7fed;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .providers-page .custom-select-menu-hint {
|
||||
border-top-color: rgba(168, 179, 194, 0.18);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .providers-page .modal .btn-secondary:hover,
|
||||
:root[data-theme="dark"] .providers-page .modal .btn-secondary:focus {
|
||||
border-color: #5f8fbe;
|
||||
|
||||
@@ -135,9 +135,16 @@
|
||||
<div class="mb-3">
|
||||
<label class="form-label field-label-with-help">
|
||||
<span>下游格式</span>
|
||||
<span class="field-mode-badge">多选</span>
|
||||
<button type="button" class="field-help-button" data-provider-help-topic="target_format" onclick="toggleProviderHelp('target_format', event)" aria-label="查看下游格式说明">?</button>
|
||||
</label>
|
||||
<select class="form-control" id="providerTargetFormat">
|
||||
<select
|
||||
class="form-control"
|
||||
id="providerTargetFormat"
|
||||
multiple
|
||||
data-placeholder="请选择下游格式"
|
||||
data-multi-select-hint="可多选,点击已选项可取消;互斥项会自动替换"
|
||||
>
|
||||
<option value="openai_chat">openai_chat</option>
|
||||
<option value="openai_responses">openai_responses</option>
|
||||
<option value="claude_chat">claude_chat</option>
|
||||
@@ -504,6 +511,11 @@
|
||||
let providerTransportAutoSyncEnabled = true;
|
||||
let authEntryYamlEditor = null;
|
||||
let authEntryYamlThemeObserver = null;
|
||||
const providerTargetFormatOptions = ['openai_chat', 'openai_responses', 'claude_chat', 'codex'];
|
||||
const defaultNewProviderTargetFormats = ['openai_chat', 'codex', 'claude_chat'];
|
||||
const providerTargetFormatConflictGroups = [
|
||||
['openai_responses', 'codex'],
|
||||
];
|
||||
const providerHelpTopics = {
|
||||
transport: {
|
||||
title: '上游传输怎么选',
|
||||
@@ -532,14 +544,15 @@
|
||||
target_format: {
|
||||
title: '下游格式怎么选',
|
||||
html: `
|
||||
<p>下游 = 调用这个代理的客户端或工具。你要根据客户端支持的协议面来选。</p>
|
||||
<p>下游 = 调用这个代理的客户端或工具。现在可以多选,同一个 provider 可以同时暴露多个下游协议。</p>
|
||||
<ul>
|
||||
<li><strong>openai_chat</strong>:下游走 <code>/v1/chat/completions</code>。适合 OpenCode、Cherry Studio 的 OpenAI provider。</li>
|
||||
<li><strong>openai_responses</strong>:下游走 <code>/v1/responses</code>。适合 Responses API 客户端。</li>
|
||||
<li><strong>codex</strong>:也走 <code>/v1/responses</code>,但保留 Codex 的请求语义。</li>
|
||||
<li><strong>claude_chat</strong>:下游走 <code>/v1/messages</code>。适合 Claude Code 和 Anthropic 兼容客户端。</li>
|
||||
</ul>
|
||||
<p>一个 provider 配好后只能被对应的 route family 使用,不能让下游随意混用。</p>
|
||||
<p>注意:有些下游格式会互斥,因为它们共用同一个 route family 但请求语义不同。目前 <code>openai_responses</code> 和 <code>codex</code> 不能同时启用;以后如果有新的互斥组合,也会在这里说明。</p>
|
||||
<p>在多选下拉里如果新选了冲突项,旧的冲突项会自动被替换。</p>
|
||||
`,
|
||||
},
|
||||
auth_group_field: {
|
||||
@@ -595,7 +608,7 @@
|
||||
<li><strong>HTTP/HTTPS</strong>:直接请求当前配置地址对应的模型接口。</li>
|
||||
<li><strong>WebSocket</strong>:会自动把 <code>ws://</code> / <code>wss://</code> 推导成对应的 <code>http://</code> / <code>https://</code> 后再尝试拉取。</li>
|
||||
</ul>
|
||||
<p>这一步只是帮你发现可选模型,不会修改上游协议,也不会自动替你决定 <code>source_format</code> 或 <code>target_format</code>。</p>
|
||||
<p>这一步只是帮你发现可选模型,不会修改上游协议,也不会自动替你决定 <code>source_format</code> 或 <code>target_formats</code>。</p>
|
||||
`,
|
||||
},
|
||||
};
|
||||
@@ -1104,7 +1117,7 @@
|
||||
switchProviderTab('providers');
|
||||
activeModelProvider = provider;
|
||||
document.getElementById('modelDetailTitle').textContent = `${provider.name} - 模型列表`;
|
||||
document.getElementById('modelDetailSubtitle').textContent = `API 地址: ${provider.api}` || '未配置 API 地址';
|
||||
document.getElementById('modelDetailSubtitle').textContent = provider?.api ? `API 地址: ${provider.api}` : '未配置 API 地址';
|
||||
document.getElementById('modelSearchInput').value = '';
|
||||
renderModelDetailList();
|
||||
modelDetailModalInstance.show();
|
||||
@@ -1397,8 +1410,83 @@
|
||||
updateFetchModelsButtonState();
|
||||
}
|
||||
|
||||
function getSelectedOptionValues(select) {
|
||||
if (!select) {
|
||||
return [];
|
||||
}
|
||||
return Array.from(select.options)
|
||||
.filter(option => option.selected)
|
||||
.map(option => option.value);
|
||||
}
|
||||
|
||||
function normalizeProviderTargetFormatValues(values, preferredValue = '') {
|
||||
const normalizedPreferredValue = String(preferredValue ?? '').trim();
|
||||
const requestedValues = Array.isArray(values) ? values : [values];
|
||||
const selectedValues = new Set();
|
||||
|
||||
requestedValues.forEach(value => {
|
||||
const normalizedValue = String(value ?? '').trim();
|
||||
if (!normalizedValue || !providerTargetFormatOptions.includes(normalizedValue)) {
|
||||
return;
|
||||
}
|
||||
selectedValues.add(normalizedValue);
|
||||
});
|
||||
|
||||
providerTargetFormatConflictGroups.forEach(group => {
|
||||
const conflictingValues = group.filter(value => selectedValues.has(value));
|
||||
if (conflictingValues.length <= 1) {
|
||||
return;
|
||||
}
|
||||
const keptValue = conflictingValues.includes(normalizedPreferredValue)
|
||||
? normalizedPreferredValue
|
||||
: conflictingValues[conflictingValues.length - 1];
|
||||
conflictingValues.forEach(value => {
|
||||
if (value !== keptValue) {
|
||||
selectedValues.delete(value);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (!selectedValues.size) {
|
||||
selectedValues.add('openai_chat');
|
||||
}
|
||||
|
||||
return providerTargetFormatOptions.filter(value => selectedValues.has(value));
|
||||
}
|
||||
|
||||
function setProviderTargetFormatValues(values, preferredValue = '', options = {}) {
|
||||
const select = document.getElementById('providerTargetFormat');
|
||||
if (!select) {
|
||||
return [];
|
||||
}
|
||||
const { dispatchChange = false } = options || {};
|
||||
const normalizedValues = normalizeProviderTargetFormatValues(values, preferredValue);
|
||||
Array.from(select.options).forEach(option => {
|
||||
option.selected = normalizedValues.includes(option.value);
|
||||
});
|
||||
if (dispatchChange) {
|
||||
select.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
} else {
|
||||
syncCustomSelectValue('providerTargetFormat');
|
||||
}
|
||||
return normalizedValues;
|
||||
}
|
||||
|
||||
function getProviderTargetFormats(provider) {
|
||||
const rawValues = Array.isArray(provider?.target_formats)
|
||||
? provider.target_formats
|
||||
// DEPRECATED compatibility path for legacy payloads that still
|
||||
// expose `target_format`. New backend responses should only
|
||||
// provide `target_formats`, and this fallback can be removed later.
|
||||
: [provider?.target_format];
|
||||
return normalizeProviderTargetFormatValues(rawValues);
|
||||
}
|
||||
|
||||
function collectFormData() {
|
||||
const targetFormatInput = document.getElementById('providerTargetFormat');
|
||||
const normalizedTargetFormats = targetFormatInput
|
||||
? normalizeProviderTargetFormatValues(getSelectedOptionValues(targetFormatInput))
|
||||
: ['openai_chat'];
|
||||
const normalizedModelList = buildModelListValue(
|
||||
normalizeModelListItems(document.getElementById('providerModelList').value)
|
||||
);
|
||||
@@ -1414,7 +1502,7 @@
|
||||
api: document.getElementById('providerApi').value.trim(),
|
||||
transport: document.getElementById('providerTransport').value,
|
||||
source_format: document.getElementById('providerSourceFormat').value.trim() || 'openai_chat',
|
||||
target_format: targetFormatInput?.value?.trim() || 'openai_chat',
|
||||
target_formats: normalizedTargetFormats,
|
||||
auth_group: authGroup,
|
||||
api_key: apiKey,
|
||||
proxy: document.getElementById('providerProxy').value.trim(),
|
||||
@@ -1426,19 +1514,57 @@
|
||||
};
|
||||
}
|
||||
|
||||
function buildMultiSelectTriggerMarkup(nativeSelect, selectedLabels) {
|
||||
const placeholder = nativeSelect.dataset.placeholder || '请选择';
|
||||
const visibleLabels = selectedLabels.slice(0, 2);
|
||||
const hiddenCount = Math.max(selectedLabels.length - visibleLabels.length, 0);
|
||||
const chipsMarkup = selectedLabels.length
|
||||
? visibleLabels
|
||||
.map(label => `<span class="custom-select-trigger-chip">${escapeHtml(label)}</span>`)
|
||||
.join('')
|
||||
+ (hiddenCount ? `<span class="custom-select-trigger-chip custom-select-trigger-chip-more">+${hiddenCount}</span>` : '')
|
||||
: `<span class="custom-select-trigger-placeholder">${escapeHtml(placeholder)}</span>`;
|
||||
|
||||
return `
|
||||
<span class="custom-select-trigger-main">
|
||||
<span class="custom-select-trigger-chips${selectedLabels.length ? '' : ' is-placeholder'}">${chipsMarkup}</span>
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
|
||||
function syncCustomSelectValue(selectId) {
|
||||
const nativeSelect = document.getElementById(selectId);
|
||||
const customSelect = document.querySelector(`.custom-select[data-for="${selectId}"]`);
|
||||
if (!nativeSelect || !customSelect) return;
|
||||
|
||||
const isMultiSelect = nativeSelect.multiple;
|
||||
const trigger = customSelect.querySelector('.custom-select-trigger');
|
||||
const selectedOption = nativeSelect.options[nativeSelect.selectedIndex] || nativeSelect.options[0];
|
||||
const selectedOptions = isMultiSelect
|
||||
? Array.from(nativeSelect.options).filter(option => option.selected)
|
||||
: [nativeSelect.options[nativeSelect.selectedIndex] || nativeSelect.options[0]].filter(Boolean);
|
||||
const selectedLabels = selectedOptions
|
||||
.map(option => option.textContent || '')
|
||||
.filter(Boolean);
|
||||
if (trigger) {
|
||||
trigger.textContent = selectedOption ? selectedOption.textContent : '请选择';
|
||||
if (isMultiSelect) {
|
||||
trigger.innerHTML = buildMultiSelectTriggerMarkup(nativeSelect, selectedLabels);
|
||||
} else {
|
||||
const triggerText = selectedLabels.length > 2
|
||||
? `${selectedLabels.slice(0, 2).join(', ')} +${selectedLabels.length - 2}`
|
||||
: (selectedLabels.join(', ') || nativeSelect.dataset.placeholder || '请选择');
|
||||
trigger.textContent = triggerText;
|
||||
}
|
||||
trigger.title = selectedLabels.join(', ');
|
||||
}
|
||||
|
||||
const selectedValues = getSelectedOptionValues(nativeSelect);
|
||||
customSelect.classList.toggle('is-multi', isMultiSelect);
|
||||
customSelect.querySelectorAll('.custom-select-option').forEach(option => {
|
||||
option.classList.toggle('selected', option.dataset.value === nativeSelect.value);
|
||||
const isSelected = isMultiSelect
|
||||
? selectedValues.includes(option.dataset.value || '')
|
||||
: option.dataset.value === nativeSelect.value;
|
||||
option.classList.toggle('selected', isSelected);
|
||||
option.setAttribute('aria-selected', isSelected ? 'true' : 'false');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1447,17 +1573,46 @@
|
||||
const customSelect = document.querySelector(`.custom-select[data-for="${selectId}"]`);
|
||||
if (!nativeSelect || !customSelect) return;
|
||||
|
||||
const isMultiSelect = nativeSelect.multiple;
|
||||
const menu = customSelect.querySelector('.custom-select-menu');
|
||||
if (!menu) return;
|
||||
menu.innerHTML = '';
|
||||
menu.setAttribute('aria-multiselectable', isMultiSelect ? 'true' : 'false');
|
||||
|
||||
Array.from(nativeSelect.options).forEach(option => {
|
||||
const optionButton = document.createElement('button');
|
||||
optionButton.type = 'button';
|
||||
optionButton.className = 'custom-select-option';
|
||||
optionButton.textContent = option.textContent;
|
||||
optionButton.className = isMultiSelect
|
||||
? 'custom-select-option custom-select-option-multi'
|
||||
: 'custom-select-option';
|
||||
if (isMultiSelect) {
|
||||
optionButton.innerHTML = `
|
||||
<span class="custom-select-option-check" aria-hidden="true"></span>
|
||||
<span class="custom-select-option-label">${escapeHtml(option.textContent || '')}</span>
|
||||
`;
|
||||
} else {
|
||||
optionButton.textContent = option.textContent;
|
||||
}
|
||||
optionButton.dataset.value = option.value;
|
||||
optionButton.addEventListener('click', function() {
|
||||
optionButton.setAttribute('role', 'option');
|
||||
optionButton.setAttribute('aria-selected', option.selected ? 'true' : 'false');
|
||||
optionButton.addEventListener('click', function(event) {
|
||||
event.preventDefault();
|
||||
if (isMultiSelect) {
|
||||
const currentValues = getSelectedOptionValues(nativeSelect);
|
||||
const nextValues = currentValues.includes(option.value)
|
||||
? currentValues.filter(value => value !== option.value)
|
||||
: currentValues.concat(option.value);
|
||||
if (selectId === 'providerTargetFormat') {
|
||||
setProviderTargetFormatValues(nextValues, option.value, { dispatchChange: true });
|
||||
} else {
|
||||
Array.from(nativeSelect.options).forEach(selectOption => {
|
||||
selectOption.selected = nextValues.includes(selectOption.value);
|
||||
});
|
||||
nativeSelect.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (nativeSelect.value !== option.value) {
|
||||
nativeSelect.value = option.value;
|
||||
nativeSelect.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
@@ -1467,6 +1622,12 @@
|
||||
});
|
||||
menu.appendChild(optionButton);
|
||||
});
|
||||
if (isMultiSelect) {
|
||||
const menuHint = document.createElement('div');
|
||||
menuHint.className = 'custom-select-menu-hint';
|
||||
menuHint.textContent = nativeSelect.dataset.multiSelectHint || '可多选,点击已选项可取消';
|
||||
menu.appendChild(menuHint);
|
||||
}
|
||||
|
||||
syncCustomSelectValue(selectId);
|
||||
}
|
||||
@@ -1519,6 +1680,7 @@
|
||||
});
|
||||
}
|
||||
|
||||
customSelect.classList.toggle('is-multi', nativeSelect.multiple);
|
||||
renderCustomSelectOptions(selectId);
|
||||
}
|
||||
|
||||
@@ -1527,6 +1689,21 @@
|
||||
if (!select) {
|
||||
return;
|
||||
}
|
||||
if (select.multiple) {
|
||||
const desiredValues = Array.isArray(desiredValue) ? desiredValue : [desiredValue];
|
||||
const fallbackValues = Array.isArray(fallbackValue) ? fallbackValue : [fallbackValue];
|
||||
const resolvedValues = desiredValues.filter(value => value !== undefined && value !== null && value !== '');
|
||||
if (elementId === 'providerTargetFormat') {
|
||||
setProviderTargetFormatValues(resolvedValues.length ? resolvedValues : fallbackValues);
|
||||
} else {
|
||||
const nextValues = resolvedValues.length ? resolvedValues : fallbackValues;
|
||||
Array.from(select.options).forEach(option => {
|
||||
option.selected = nextValues.includes(option.value);
|
||||
});
|
||||
syncCustomSelectValue(elementId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const resolvedFallback = fallbackValue ?? '';
|
||||
const resolvedValue = desiredValue !== undefined && desiredValue !== null && desiredValue !== ''
|
||||
? String(desiredValue)
|
||||
@@ -1548,7 +1725,7 @@
|
||||
document.getElementById('providerApi').value = provider?.api || '';
|
||||
setSelectValueOrFallback('providerTransport', resolvedTransport, inferredTransport);
|
||||
setSelectValueOrFallback('providerSourceFormat', provider?.source_format, 'openai_chat');
|
||||
setSelectValueOrFallback('providerTargetFormat', provider?.target_format, 'openai_chat');
|
||||
setProviderTargetFormatValues(provider ? getProviderTargetFormats(provider) : defaultNewProviderTargetFormats);
|
||||
setSelectValueOrFallback('providerAuthMode', authMode, 'legacy_api_key');
|
||||
renderProviderAuthGroupOptions(provider?.auth_group || '');
|
||||
document.getElementById('providerApiKey').value = provider?.api_key || '';
|
||||
@@ -1841,6 +2018,10 @@
|
||||
</div>
|
||||
`
|
||||
: '';
|
||||
const providerTargetFormats = getProviderTargetFormats(provider);
|
||||
const targetFormatChips = providerTargetFormats
|
||||
.map(format => `<span class="provider-chip provider-chip-target">${escapeHtml(format)}</span>`)
|
||||
.join('');
|
||||
|
||||
html += `
|
||||
<tr>
|
||||
@@ -1863,7 +2044,7 @@
|
||||
<span class="provider-chip ${isEnabled ? 'provider-chip-hook' : 'provider-chip-muted'}">${isEnabled ? 'enabled' : 'disabled'}</span>
|
||||
<span class="provider-chip">${escapeHtml(provider.transport || 'http')}</span>
|
||||
<span class="provider-chip">${escapeHtml(provider.source_format || 'openai_chat')}</span>
|
||||
<span class="provider-chip provider-chip-target">${escapeHtml(provider.target_format || 'openai_chat')}</span>
|
||||
${targetFormatChips}
|
||||
${provider.auth_group ? `<span class="provider-chip provider-chip-hook">auth_group:${escapeHtml(provider.auth_group)}</span>` : ''}
|
||||
${provider.api_key ? '<span class="provider-chip provider-chip-muted">legacy api_key</span>' : ''}
|
||||
${provider.hook ? `<span class="provider-chip provider-chip-hook">${escapeHtml(provider.hook)}</span>` : ''}
|
||||
@@ -3344,3 +3525,4 @@
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -41,11 +41,12 @@ class ProviderService:
|
||||
providers = self._extract_providers(config)
|
||||
provider_config = ProviderConfigSchema.from_payload(payload)
|
||||
normalized = provider_config.to_mapping()
|
||||
stored = provider_config.to_storage_mapping()
|
||||
|
||||
if self._find_provider(providers, provider_config.name):
|
||||
raise ValueError(f"Provider already exists: {provider_config.name}")
|
||||
|
||||
providers.append(normalized)
|
||||
providers.append(stored)
|
||||
self._save_providers(config, providers)
|
||||
return normalized
|
||||
|
||||
@@ -62,12 +63,13 @@ class ProviderService:
|
||||
|
||||
provider_config = ProviderConfigSchema.from_payload(normalized_payload)
|
||||
normalized = provider_config.to_mapping()
|
||||
stored = provider_config.to_storage_mapping()
|
||||
|
||||
duplicate = self._find_provider(providers, provider_config.name)
|
||||
if duplicate is not None and duplicate is not target:
|
||||
raise ValueError(f"Provider already exists: {provider_config.name}")
|
||||
|
||||
providers[providers.index(target)] = normalized
|
||||
providers[providers.index(target)] = stored
|
||||
self._save_providers(config, providers)
|
||||
return normalized
|
||||
|
||||
@@ -89,11 +91,12 @@ class ProviderService:
|
||||
raise ValueError(f'Provider not found: {name}')
|
||||
|
||||
target_index = providers.index(target)
|
||||
normalized = ProviderConfigSchema.from_mapping(target).to_mapping()
|
||||
normalized = ProviderConfigSchema.from_mapping(target).to_storage_mapping()
|
||||
normalized['enabled'] = bool(enabled)
|
||||
providers[target_index] = ProviderConfigSchema.from_payload(normalized).to_mapping()
|
||||
provider_config = ProviderConfigSchema.from_payload(normalized)
|
||||
providers[target_index] = provider_config.to_storage_mapping()
|
||||
self._save_providers(config, providers)
|
||||
return providers[target_index]
|
||||
return provider_config.to_mapping()
|
||||
|
||||
def batch_set_provider_enabled(self, names: List[str], enabled: bool) -> Dict[str, Any]:
|
||||
normalized_names = self._normalize_provider_names(names)
|
||||
@@ -102,9 +105,9 @@ class ProviderService:
|
||||
target_indexes = self._find_provider_indexes(providers, normalized_names)
|
||||
|
||||
for target_index in target_indexes:
|
||||
normalized = ProviderConfigSchema.from_mapping(providers[target_index]).to_mapping()
|
||||
normalized = ProviderConfigSchema.from_mapping(providers[target_index]).to_storage_mapping()
|
||||
normalized['enabled'] = bool(enabled)
|
||||
providers[target_index] = ProviderConfigSchema.from_payload(normalized).to_mapping()
|
||||
providers[target_index] = ProviderConfigSchema.from_payload(normalized).to_storage_mapping()
|
||||
|
||||
self._save_providers(config, providers)
|
||||
return {
|
||||
|
||||
@@ -57,6 +57,7 @@ class ProxyService:
|
||||
request_headers: Dict[str, str],
|
||||
on_complete: Optional[Callable[[Dict[str, Any]], None]] = None,
|
||||
forward_stream_usage: bool = False,
|
||||
resolved_target_format: Optional[str] = None,
|
||||
) -> Tuple[Optional[Response], int, Optional[ProxyErrorInfo]]:
|
||||
"""代理请求到目标 provider,并处理重试、格式转换与 guard。"""
|
||||
target_url = provider.api
|
||||
@@ -66,11 +67,15 @@ class ProxyService:
|
||||
max_retries = provider.max_retries
|
||||
verify_ssl = provider.verify_ssl
|
||||
request_proxies = build_requests_proxies(provider.proxy)
|
||||
translator = self._translator_registry.get(provider.source_format, provider.target_format)
|
||||
downstream_target_format = self._resolve_downstream_target_format(
|
||||
provider,
|
||||
resolved_target_format,
|
||||
)
|
||||
translator = self._translator_registry.get(provider.source_format, downstream_target_format)
|
||||
last_error: Optional[ProxyErrorInfo] = None
|
||||
previous_status_code: Optional[int] = None
|
||||
previous_error_type: Optional[HookErrorType] = None
|
||||
self._ensure_supported_target_format(provider.target_format)
|
||||
self._ensure_supported_target_format(downstream_target_format)
|
||||
|
||||
def build_request(
|
||||
attempt: int,
|
||||
@@ -85,7 +90,7 @@ class ProxyService:
|
||||
request_model=requested_model,
|
||||
upstream_model=upstream_model,
|
||||
provider_source_format=provider.source_format,
|
||||
provider_target_format=provider.target_format,
|
||||
provider_target_format=downstream_target_format,
|
||||
transport=provider.transport,
|
||||
stream=initial_stream,
|
||||
auth_group_name=(
|
||||
@@ -152,7 +157,7 @@ class ProxyService:
|
||||
provider.name,
|
||||
provider.transport,
|
||||
provider.source_format,
|
||||
provider.target_format,
|
||||
downstream_target_format,
|
||||
translated_body.get("model"),
|
||||
attempt + 1,
|
||||
max_retries,
|
||||
@@ -180,7 +185,11 @@ class ProxyService:
|
||||
previous_status_code = opened.status_code
|
||||
previous_error_type = None
|
||||
raw_response_headers = dict(getattr(opened.response, "headers", {}) or {})
|
||||
_, _, retry_summary = self._consume_upstream_error(provider, opened)
|
||||
_, _, retry_summary = self._consume_upstream_error(
|
||||
provider,
|
||||
opened,
|
||||
downstream_target_format,
|
||||
)
|
||||
finalize_attempt(
|
||||
status_code=opened.status_code,
|
||||
error_message=retry_summary,
|
||||
@@ -208,7 +217,11 @@ class ProxyService:
|
||||
|
||||
if opened.status_code >= 400:
|
||||
raw_response_headers = dict(getattr(opened.response, "headers", {}) or {})
|
||||
response, error_summary = self._build_error_response(provider, opened)
|
||||
response, error_summary = self._build_error_response(
|
||||
provider,
|
||||
opened,
|
||||
downstream_target_format,
|
||||
)
|
||||
finalize_attempt(
|
||||
status_code=opened.status_code,
|
||||
error_message=error_summary,
|
||||
@@ -221,6 +234,7 @@ class ProxyService:
|
||||
provider=provider,
|
||||
translator=translator,
|
||||
request_ctx=request_ctx,
|
||||
downstream_target_format=downstream_target_format,
|
||||
original_request=guarded_body,
|
||||
translated_request=translated_body,
|
||||
opened=opened,
|
||||
@@ -233,6 +247,7 @@ class ProxyService:
|
||||
provider=provider,
|
||||
translator=translator,
|
||||
request_ctx=request_ctx,
|
||||
downstream_target_format=downstream_target_format,
|
||||
original_request=guarded_body,
|
||||
translated_request=translated_body,
|
||||
opened=opened,
|
||||
@@ -245,7 +260,7 @@ class ProxyService:
|
||||
provider.name,
|
||||
provider.transport,
|
||||
provider.source_format,
|
||||
provider.target_format,
|
||||
downstream_target_format,
|
||||
opened.status_code,
|
||||
opened.is_stream,
|
||||
)
|
||||
@@ -318,6 +333,7 @@ class ProxyService:
|
||||
provider: LLMProvider,
|
||||
translator: Translator,
|
||||
request_ctx: HookContext,
|
||||
downstream_target_format: str,
|
||||
original_request: Dict[str, Any],
|
||||
translated_request: Dict[str, Any],
|
||||
opened: OpenedUpstreamResponse,
|
||||
@@ -371,15 +387,15 @@ class ProxyService:
|
||||
guarded_chunk = self._guard_stream_chunk(provider, request_ctx, downstream_chunk)
|
||||
if guarded_chunk is None:
|
||||
continue
|
||||
if is_terminal_chunk(guarded_chunk, provider.target_format):
|
||||
if is_terminal_chunk(guarded_chunk, downstream_target_format):
|
||||
terminal_sent = True
|
||||
if guarded_chunk.kind == "done":
|
||||
encoded_terminal = encode_downstream_chunk(guarded_chunk, provider.target_format)
|
||||
encoded_terminal = encode_downstream_chunk(guarded_chunk, downstream_target_format)
|
||||
if encoded_terminal:
|
||||
yield encoded_terminal
|
||||
continue
|
||||
if (
|
||||
provider.target_format == "openai_chat"
|
||||
downstream_target_format == "openai_chat"
|
||||
and guarded_chunk.kind == "json"
|
||||
and not forward_stream_usage
|
||||
and self._is_usage_only_stream_chunk(guarded_chunk.payload)
|
||||
@@ -387,14 +403,14 @@ class ProxyService:
|
||||
continue
|
||||
if guarded_chunk.kind == "json" and isinstance(guarded_chunk.payload, dict):
|
||||
self._update_meta_from_payload(meta, guarded_chunk.payload)
|
||||
encoded_chunk = encode_downstream_chunk(guarded_chunk, provider.target_format)
|
||||
encoded_chunk = encode_downstream_chunk(guarded_chunk, downstream_target_format)
|
||||
if encoded_chunk:
|
||||
yield encoded_chunk
|
||||
|
||||
if should_emit_terminal_chunk(provider.target_format) and not terminal_sent:
|
||||
if should_emit_terminal_chunk(downstream_target_format) and not terminal_sent:
|
||||
encoded_terminal = encode_downstream_chunk(
|
||||
DownstreamChunk(kind="done"),
|
||||
provider.target_format,
|
||||
downstream_target_format,
|
||||
)
|
||||
if encoded_terminal:
|
||||
yield encoded_terminal
|
||||
@@ -437,6 +453,7 @@ class ProxyService:
|
||||
provider: LLMProvider,
|
||||
translator: Translator,
|
||||
request_ctx: HookContext,
|
||||
downstream_target_format: str,
|
||||
original_request: Dict[str, Any],
|
||||
translated_request: Dict[str, Any],
|
||||
opened: OpenedUpstreamResponse,
|
||||
@@ -467,7 +484,7 @@ class ProxyService:
|
||||
if finalize_attempt is not None:
|
||||
finalize_attempt(status_code=opened.status_code, usage=meta)
|
||||
|
||||
response_body = encode_downstream_response_body(body_to_send, provider.target_format)
|
||||
response_body = encode_downstream_response_body(body_to_send, downstream_target_format)
|
||||
headers = self._filter_response_headers(getattr(response, "headers", {}))
|
||||
headers["Content-Type"] = self._resolve_nonstream_content_type(body_to_send, opened.content_type)
|
||||
return Response(
|
||||
@@ -482,6 +499,7 @@ class ProxyService:
|
||||
self,
|
||||
provider: LLMProvider,
|
||||
opened: OpenedUpstreamResponse,
|
||||
target_format: Optional[str] = None,
|
||||
) -> tuple[bytes, Dict[str, str], Optional[str]]:
|
||||
response = opened.response
|
||||
try:
|
||||
@@ -492,7 +510,7 @@ class ProxyService:
|
||||
"Upstream returned error: provider=%s transport=%s format=%s status=%s stream=%s error=%s",
|
||||
provider.name,
|
||||
provider.transport,
|
||||
f"{provider.source_format}->{provider.target_format}",
|
||||
f"{provider.source_format}->{self._resolve_downstream_target_format(provider, target_format)}",
|
||||
opened.status_code,
|
||||
opened.is_stream,
|
||||
summary or "<empty>",
|
||||
@@ -508,8 +526,9 @@ class ProxyService:
|
||||
self,
|
||||
provider: LLMProvider,
|
||||
opened: OpenedUpstreamResponse,
|
||||
target_format: Optional[str] = None,
|
||||
) -> tuple[Response, Optional[str]]:
|
||||
body, headers, summary = self._consume_upstream_error(provider, opened)
|
||||
body, headers, summary = self._consume_upstream_error(provider, opened, target_format)
|
||||
return Response(body, status=opened.status_code, headers=headers), summary
|
||||
|
||||
def _guard_stream_chunk(
|
||||
@@ -729,6 +748,28 @@ class ProxyService:
|
||||
}:
|
||||
raise ValueError(f"Unsupported downstream target_format: {target_format}")
|
||||
|
||||
@staticmethod
|
||||
def _resolve_downstream_target_format(
|
||||
provider: LLMProvider,
|
||||
resolved_target_format: Optional[str] = None,
|
||||
) -> str:
|
||||
normalized_target_format = str(resolved_target_format or "").strip().lower()
|
||||
if normalized_target_format:
|
||||
return normalized_target_format
|
||||
|
||||
provider_target_formats = tuple(
|
||||
str(item or "").strip().lower()
|
||||
for item in getattr(provider, "target_formats", ())
|
||||
if str(item or "").strip()
|
||||
)
|
||||
if provider_target_formats:
|
||||
return provider_target_formats[0]
|
||||
|
||||
# DEPRECATED compatibility path for legacy provider objects that still
|
||||
# rely on a single `target_format` field. New callers should only pass
|
||||
# `target_formats`, and this fallback can be removed later.
|
||||
return str(getattr(provider, "target_format", "") or "").strip().lower()
|
||||
|
||||
@staticmethod
|
||||
def _read_response_body(response: Any) -> bytes:
|
||||
content = getattr(response, "content", None)
|
||||
|
||||
@@ -60,6 +60,35 @@ class ProviderServiceTests(unittest.TestCase):
|
||||
self.config_manager.reload()
|
||||
self.reload_count = 0
|
||||
|
||||
def test_config_manager_migrates_legacy_target_format_on_load(self) -> None:
|
||||
self._write_config(
|
||||
{
|
||||
"auth_groups": [],
|
||||
"providers": [
|
||||
{
|
||||
"name": "demo",
|
||||
"api": "https://example.com/v1/chat/completions",
|
||||
"api_key": "sk-demo",
|
||||
"target_format": "claude_chat",
|
||||
"model_list": ["gpt-4.1"],
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
migrated_manager = ConfigManager(self.config_path, self.root_path)
|
||||
|
||||
current_config = migrated_manager.get_raw_config()
|
||||
provider = current_config["providers"][0]
|
||||
self.assertNotIn("target_format", provider)
|
||||
self.assertEqual(["claude_chat"], provider["target_formats"])
|
||||
|
||||
with open(self.config_path, "r", encoding="utf-8") as handle:
|
||||
persisted = yaml.safe_load(handle)
|
||||
persisted_provider = persisted["providers"][0]
|
||||
self.assertNotIn("target_format", persisted_provider)
|
||||
self.assertEqual(["claude_chat"], persisted_provider["target_formats"])
|
||||
|
||||
def test_update_provider_preserves_enabled_when_payload_omits_enabled(self) -> None:
|
||||
self._seed_providers(
|
||||
[
|
||||
@@ -86,10 +115,14 @@ class ProviderServiceTests(unittest.TestCase):
|
||||
|
||||
self.assertFalse(updated["enabled"])
|
||||
self.assertEqual("example_hook.py", updated["hook"])
|
||||
self.assertNotIn("target_format", updated)
|
||||
self.assertEqual(["openai_chat"], updated["target_formats"])
|
||||
self.assertEqual(1, self.reload_count)
|
||||
current_config = self.config_manager.get_raw_config()
|
||||
self.assertFalse(current_config["providers"][0]["enabled"])
|
||||
self.assertEqual("example_hook.py", current_config["providers"][0]["hook"])
|
||||
self.assertNotIn("target_format", current_config["providers"][0])
|
||||
self.assertEqual(["openai_chat"], current_config["providers"][0]["target_formats"])
|
||||
|
||||
def test_set_provider_enabled_updates_config(self) -> None:
|
||||
self._seed_providers(
|
||||
@@ -106,9 +139,12 @@ class ProviderServiceTests(unittest.TestCase):
|
||||
updated = self.service.set_provider_enabled("demo", False)
|
||||
|
||||
self.assertFalse(updated["enabled"])
|
||||
self.assertNotIn("target_format", updated)
|
||||
self.assertEqual(["openai_chat"], updated["target_formats"])
|
||||
self.assertEqual(1, self.reload_count)
|
||||
current_config = self.config_manager.get_raw_config()
|
||||
self.assertFalse(current_config["providers"][0]["enabled"])
|
||||
self.assertNotIn("target_format", current_config["providers"][0])
|
||||
|
||||
def test_batch_set_provider_enabled_updates_multiple_providers(self) -> None:
|
||||
self._seed_providers(
|
||||
@@ -137,6 +173,7 @@ class ProviderServiceTests(unittest.TestCase):
|
||||
self.assertEqual(1, self.reload_count)
|
||||
current_config = self.config_manager.get_raw_config()
|
||||
self.assertEqual([True, True], [item["enabled"] for item in current_config["providers"]])
|
||||
self.assertTrue(all("target_format" not in item for item in current_config["providers"]))
|
||||
|
||||
def test_batch_delete_providers_removes_selected_entries(self) -> None:
|
||||
self._seed_providers(
|
||||
|
||||
@@ -105,6 +105,7 @@ class FakeProviderManager:
|
||||
transport=self._provider.transport,
|
||||
source_format=self._provider.source_format,
|
||||
target_format=self._provider.target_format,
|
||||
target_formats=self._provider.target_formats,
|
||||
model_list=self._provider.model_list,
|
||||
proxy=self._provider.proxy,
|
||||
timeout_seconds=self._provider.timeout_seconds,
|
||||
@@ -123,6 +124,18 @@ class StubProxyService:
|
||||
return self._proxy_result
|
||||
|
||||
|
||||
class RecordingProxyService:
|
||||
def __init__(self, proxy_result) -> None:
|
||||
self._proxy_result = proxy_result
|
||||
self.last_args = None
|
||||
self.last_kwargs = None
|
||||
|
||||
def proxy_request(self, *args, **kwargs):
|
||||
self.last_args = args
|
||||
self.last_kwargs = kwargs
|
||||
return self._proxy_result
|
||||
|
||||
|
||||
class ContextRecordingHook(BaseHook):
|
||||
def __init__(self) -> None:
|
||||
self.contexts: list[HookContext] = []
|
||||
@@ -170,7 +183,7 @@ class ProxyControllerErrorFormatTests(unittest.TestCase):
|
||||
"owned_by": "demo",
|
||||
"provider_name": "demo",
|
||||
"source_format": "openai_responses",
|
||||
"target_format": "codex",
|
||||
"target_formats": ["codex"],
|
||||
"transport": "http",
|
||||
}
|
||||
],
|
||||
@@ -352,7 +365,7 @@ class ProxyControllerErrorFormatTests(unittest.TestCase):
|
||||
self.assertEqual(
|
||||
{
|
||||
"error": {
|
||||
"message": "Model demo/gpt-4.1 is configured for downstream format openai_chat, not one of openai_responses, codex",
|
||||
"message": "Model demo/gpt-4.1 is configured for downstream formats openai_chat, not one of openai_responses, codex",
|
||||
"type": "invalid_request_error",
|
||||
"param": None,
|
||||
"code": "target_format_mismatch",
|
||||
@@ -402,6 +415,48 @@ class ProxyControllerErrorFormatTests(unittest.TestCase):
|
||||
self.assertEqual(200, response.status_code)
|
||||
self.assertEqual('{"id":"resp_1","object":"response"}', response.get_data(as_text=True))
|
||||
|
||||
def test_chat_route_resolves_target_format_from_multi_target_provider(self) -> None:
|
||||
provider = LLMProvider(
|
||||
name="demo",
|
||||
api="https://example.com/v1/chat/completions",
|
||||
model_list=("gpt-4.1",),
|
||||
target_formats=("openai_chat", "claude_chat"),
|
||||
)
|
||||
app = Flask(__name__)
|
||||
ctx = AppContext(
|
||||
logger=FakeLogger(),
|
||||
config_manager=FakeConfigManager(),
|
||||
root_path=Path(__file__).resolve().parents[1],
|
||||
flask_app=app,
|
||||
)
|
||||
proxy_service = RecordingProxyService(
|
||||
(
|
||||
app.response_class(
|
||||
'{"id":"chatcmpl_1","object":"chat.completion"}',
|
||||
status=200,
|
||||
mimetype="application/json",
|
||||
),
|
||||
200,
|
||||
None,
|
||||
)
|
||||
)
|
||||
ProxyController(
|
||||
ctx,
|
||||
proxy_service,
|
||||
FakeUserService(),
|
||||
FakeLogService(),
|
||||
FakeProviderManager(provider),
|
||||
)
|
||||
|
||||
response = app.test_client().post(
|
||||
"/v1/chat/completions",
|
||||
json={"model": "demo/gpt-4.1", "messages": [{"role": "user", "content": "hi"}]},
|
||||
environ_base={"REMOTE_ADDR": "127.0.0.1"},
|
||||
)
|
||||
|
||||
self.assertEqual(200, response.status_code)
|
||||
self.assertEqual("openai_chat", proxy_service.last_kwargs["resolved_target_format"])
|
||||
|
||||
def test_responses_route_allows_codex_target(self) -> None:
|
||||
provider = LLMProvider(
|
||||
name="demo",
|
||||
@@ -477,7 +532,7 @@ class ProxyControllerErrorFormatTests(unittest.TestCase):
|
||||
"type": "error",
|
||||
"error": {
|
||||
"type": "invalid_request_error",
|
||||
"message": "Model demo/gpt-4.1 is configured for downstream format openai_chat, not claude_chat",
|
||||
"message": "Model demo/gpt-4.1 is configured for downstream formats openai_chat, not claude_chat",
|
||||
},
|
||||
},
|
||||
response.get_json(),
|
||||
|
||||
@@ -150,9 +150,52 @@ class ProviderTransportTests(unittest.TestCase):
|
||||
|
||||
self.assertEqual("openai_chat", schema.source_format)
|
||||
self.assertEqual("openai_chat", schema.target_format)
|
||||
self.assertEqual(("openai_chat",), schema.target_formats)
|
||||
runtime = RuntimeProviderSpec.from_schema(schema)
|
||||
self.assertEqual("openai_chat", runtime.source_format)
|
||||
self.assertEqual("openai_chat", runtime.target_format)
|
||||
self.assertEqual(("openai_chat",), runtime.target_formats)
|
||||
|
||||
def test_provider_schema_accepts_multiple_target_formats(self) -> None:
|
||||
schema = ProviderConfigSchema.from_mapping(
|
||||
{
|
||||
"name": "demo",
|
||||
"api": "https://example.com/v1/chat/completions",
|
||||
"api_key": "demo-key",
|
||||
"target_formats": ["openai_chat", "claude_chat"],
|
||||
"model_list": ["gpt-4.1"],
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual("openai_chat", schema.target_format)
|
||||
self.assertEqual(("openai_chat", "claude_chat"), schema.target_formats)
|
||||
|
||||
def test_provider_schema_rejects_conflicting_target_formats(self) -> None:
|
||||
with self.assertRaisesRegex(
|
||||
ValueError,
|
||||
"Provider target_formats contains mutually exclusive formats: openai_responses, codex",
|
||||
):
|
||||
ProviderConfigSchema.from_mapping(
|
||||
{
|
||||
"name": "demo",
|
||||
"api": "https://example.com/v1/chat/completions",
|
||||
"api_key": "demo-key",
|
||||
"target_formats": ["openai_responses", "codex"],
|
||||
"model_list": ["gpt-4.1"],
|
||||
}
|
||||
)
|
||||
|
||||
def test_provider_schema_rejects_legacy_target_format_field(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "Unsupported provider field\\(s\\): target_format"):
|
||||
ProviderConfigSchema.from_mapping(
|
||||
{
|
||||
"name": "demo",
|
||||
"api": "https://example.com/v1/chat/completions",
|
||||
"api_key": "demo-key",
|
||||
"target_format": "claude_chat",
|
||||
"model_list": ["gpt-4.1"],
|
||||
}
|
||||
)
|
||||
|
||||
def test_provider_schema_rejects_removed_fields(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "Unsupported provider field\\(s\\): format"):
|
||||
@@ -355,6 +398,10 @@ class ProviderTemplateTransportTests(unittest.TestCase):
|
||||
self.assertIn('id="providerTransport"', html)
|
||||
self.assertIn('id="providerSourceFormat"', html)
|
||||
self.assertIn('id="providerTargetFormat"', html)
|
||||
self.assertNotIn('data-multi-select-badge="多选"', html)
|
||||
self.assertIn('data-multi-select-hint="可多选,点击已选项可取消;互斥项会自动替换"', html)
|
||||
self.assertIn('class="field-mode-badge">多选</span>', html)
|
||||
self.assertNotIn('class="field-inline-note">可多选;点击已选项可取消,互斥项会自动替换。</div>', html)
|
||||
self.assertIn('id="providerAuthMode"', html)
|
||||
self.assertIn('id="providerAuthGroup"', html)
|
||||
self.assertIn('providerTransportAutoSyncEnabled', html)
|
||||
@@ -408,6 +455,15 @@ class ProviderTemplateTransportTests(unittest.TestCase):
|
||||
self.assertIn("setupCustomSelect('providerSourceFormat');", html)
|
||||
self.assertIn("setupCustomSelect('providerTargetFormat');", html)
|
||||
self.assertIn("setupCustomSelect('providerVerifySsl');", html)
|
||||
self.assertIn("function buildMultiSelectTriggerMarkup(", html)
|
||||
self.assertNotIn("custom-select-trigger-badge", html)
|
||||
self.assertNotIn("已选 ${selectedLabels.length} 项", html)
|
||||
self.assertIn("custom-select-option custom-select-option-multi", html)
|
||||
self.assertIn("custom-select-menu-hint", html)
|
||||
self.assertIn("const defaultNewProviderTargetFormats = ['openai_chat', 'codex', 'claude_chat'];", html)
|
||||
self.assertIn("const providerTargetFormatConflictGroups = [", html)
|
||||
self.assertIn("function setProviderTargetFormatValues(", html)
|
||||
self.assertIn("target_formats: normalizedTargetFormats,", html)
|
||||
self.assertIn("id=\"selectedProviderCount\"", html)
|
||||
self.assertIn("id=\"enableSelectedProvidersBtn\"", html)
|
||||
self.assertIn("id=\"disableSelectedProvidersBtn\"", html)
|
||||
@@ -507,6 +563,13 @@ class ProviderTemplateTransportTests(unittest.TestCase):
|
||||
self.assertNotIn(".providers-page .provider-batch-delete-modal-dialog {", css)
|
||||
self.assertIn(".providers-page .provider-table-checkbox {", css)
|
||||
self.assertIn(".providers-page .field-label-with-help {", css)
|
||||
self.assertIn(".providers-page .field-mode-badge {", css)
|
||||
self.assertNotIn(".providers-page .field-inline-note {", css)
|
||||
self.assertNotIn(".providers-page .custom-select-trigger-summary {", css)
|
||||
self.assertIn(".providers-page .custom-select.is-multi .custom-select-trigger {", css)
|
||||
self.assertNotIn(".providers-page .custom-select-trigger-badge {", css)
|
||||
self.assertIn(".providers-page .custom-select-option-check {", css)
|
||||
self.assertIn(".providers-page .custom-select-menu-hint {", css)
|
||||
self.assertIn(".providers-page .entry-import-editor {", css)
|
||||
self.assertIn(".providers-page .entry-import-editor .ace_gutter-cell,", css)
|
||||
self.assertIn(".providers-page .entry-import-editor .ace_invisible.ace_invisible_eol {", css)
|
||||
@@ -535,6 +598,8 @@ const vm = require("vm");
|
||||
const sandbox = {{
|
||||
console,
|
||||
messages: [],
|
||||
providerTargetFormatOptions: ["openai_chat", "openai_responses", "claude_chat", "codex"],
|
||||
providerTargetFormatConflictGroups: [["openai_responses", "codex"]],
|
||||
showMessage(message, level) {{
|
||||
sandbox.messages.push({{ message, level }});
|
||||
}},
|
||||
@@ -548,7 +613,15 @@ const sandbox = {{
|
||||
providerAuthGroup: {{ value: " shared-pool " }},
|
||||
providerTransport: {{ value: "http" }},
|
||||
providerSourceFormat: {{ value: "openai_chat" }},
|
||||
providerTargetFormat: {{ value: "openai_chat" }},
|
||||
providerTargetFormat: {{
|
||||
multiple: true,
|
||||
options: [
|
||||
{{ value: "openai_chat", textContent: "openai_chat", selected: true }},
|
||||
{{ value: "openai_responses", textContent: "openai_responses", selected: false }},
|
||||
{{ value: "claude_chat", textContent: "claude_chat", selected: false }},
|
||||
{{ value: "codex", textContent: "codex", selected: false }},
|
||||
],
|
||||
}},
|
||||
providerApiKey: {{ value: " secret " }},
|
||||
providerProxy: {{ value: "" }},
|
||||
providerTimeout: {{ value: "" }},
|
||||
@@ -570,10 +643,12 @@ const collectedAfter = sandbox.collectFormData();
|
||||
|
||||
process.stdout.write(JSON.stringify({{
|
||||
beforeModelList: collectedBefore.model_list,
|
||||
beforeTargetFormats: collectedBefore.target_formats,
|
||||
beforeAuthGroup: collectedBefore.auth_group,
|
||||
beforeApiKey: collectedBefore.api_key,
|
||||
afterTextarea: sandbox.document.elements.providerModelList.value,
|
||||
afterModelList: collectedAfter.model_list,
|
||||
afterTargetFormats: collectedAfter.target_formats,
|
||||
afterAuthGroup: collectedAfter.auth_group,
|
||||
afterApiKey: collectedAfter.api_key,
|
||||
countText: sandbox.document.elements.providerModelCount.textContent,
|
||||
@@ -589,10 +664,12 @@ process.stdout.write(JSON.stringify({{
|
||||
payload = json.loads(completed.stdout.decode("utf-8"))
|
||||
|
||||
self.assertEqual("beta\nAlpha\nalpha\nBeta", payload["beforeModelList"])
|
||||
self.assertEqual(["openai_chat"], payload["beforeTargetFormats"])
|
||||
self.assertEqual("shared-pool", payload["beforeAuthGroup"])
|
||||
self.assertEqual("", payload["beforeApiKey"])
|
||||
self.assertEqual("Alpha\nBeta\nalpha\nbeta", payload["afterTextarea"])
|
||||
self.assertEqual("Alpha\nBeta\nalpha\nbeta", payload["afterModelList"])
|
||||
self.assertEqual(["openai_chat"], payload["afterTargetFormats"])
|
||||
self.assertEqual("shared-pool", payload["afterAuthGroup"])
|
||||
self.assertEqual("", payload["afterApiKey"])
|
||||
self.assertIn("4", payload["countText"])
|
||||
|
||||
Reference in new issue
Block a user