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:
Von committed 2026-04-06 09:46:28 +08:00
1 parent 3751b9ac32
commit bd7552f23b
16 files changed
+913 -111

No files matched your search

+48 -20
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 自动判断。
+63 -8
View File
@@ -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
+99 -12
View File
@@ -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]],
+1 -1
View File
@@ -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,
+26
View File
@@ -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"):
+63 -10
View File
@@ -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,
}
)
+138
View File
@@ -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;
+196 -14
View File
@@ -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 %}
+10 -7
View File
@@ -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 -16
View File
@@ -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)
+37
View File
@@ -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(
+58 -3
View File
@@ -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(),
+78 -1
View File
@@ -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"])