From bd7552f23be7a7f6af133afff8d7e5ffe72b7fe3 Mon Sep 17 00:00:00 2001 From: Von Date: Mon, 6 Apr 2026 09:46:28 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=20Provider=20?= =?UTF-8?q?=E5=A4=9A=E4=B8=8B=E6=B8=B8=20target=5Fformats=20=E5=B9=B6?= =?UTF-8?q?=E6=94=B6=E6=95=9B=E5=AF=B9=E5=A4=96=E9=85=8D=E7=BD=AE=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将 Provider 下游配置由单个 target_format 扩展为多个 target_formats - 保留旧字段 target_format 的启动期迁移逻辑,并标注为弃用兼容代码 - 收敛对外配置与接口返回,Provider 相关响应及 /v1/models 仅暴露 target_formats - 前端 Provider 表单改为多选下游,支持互斥项自动替换 - 新建 Provider 默认选中 openai_chat、codex、claude_chat - 更新示例配置、README、4+1 架构文档及相关测试 --- README.md | 68 ++++--- config.sample.yaml | 28 ++- config.yaml | 3 +- docs/architecture-4plus1.md | 27 +-- src/config/config_manager.py | 71 +++++++- src/config/provider_config.py | 111 ++++++++++-- src/config/provider_manager.py | 2 +- src/external/llm_provider.py | 26 +++ src/presentation/proxy_controller.py | 73 ++++++-- src/presentation/static/css/providers.css | 138 ++++++++++++++ src/presentation/templates/providers.html | 210 ++++++++++++++++++++-- src/services/provider_service.py | 17 +- src/services/proxy_service.py | 73 ++++++-- tests/test_provider_service.py | 37 ++++ tests/test_proxy_error_handling.py | 61 ++++++- tests/test_proxy_transport.py | 79 +++++++- 16 files changed, 913 insertions(+), 111 deletions(-) diff --git a/README.md b/README.md index d575721..dc7986f 100644 --- a/README.md +++ b/README.md @@ -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` diff --git a/config.sample.yaml b/config.sample.yaml index 57cf39d..97d0c8d 100644 --- a/config.sample.yaml +++ b/config.sample.yaml @@ -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 diff --git a/config.yaml b/config.yaml index 9db41d2..38dbd3e 100644 --- a/config.yaml +++ b/config.yaml @@ -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 diff --git a/docs/architecture-4plus1.md b/docs/architecture-4plus1.md index 4952d30..fa1c28f 100644 --- a/docs/architecture-4plus1.md +++ b/docs/architecture-4plus1.md @@ -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 自动判断。 diff --git a/src/config/config_manager.py b/src/config/config_manager.py index a4146e0..bd4c97a 100644 --- a/src/config/config_manager.py +++ b/src/config/config_manager.py @@ -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 diff --git a/src/config/provider_config.py b/src/config/provider_config.py index 4812c6f..fcac46a 100644 --- a/src/config/provider_config.py +++ b/src/config/provider_config.py @@ -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]], diff --git a/src/config/provider_manager.py b/src/config/provider_manager.py index 49bf60c..ad55088 100644 --- a/src/config/provider_manager.py +++ b/src/config/provider_manager.py @@ -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, diff --git a/src/external/llm_provider.py b/src/external/llm_provider.py index 07882be..257d1bb 100644 --- a/src/external/llm_provider.py +++ b/src/external/llm_provider.py @@ -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"): diff --git a/src/presentation/proxy_controller.py b/src/presentation/proxy_controller.py index 0724603..d2d446a 100644 --- a/src/presentation/proxy_controller.py +++ b/src/presentation/proxy_controller.py @@ -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 "" + 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 "", + 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 ''}, 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 ''}, 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, } ) diff --git a/src/presentation/static/css/providers.css b/src/presentation/static/css/providers.css index 276badd..7120775 100644 --- a/src/presentation/static/css/providers.css +++ b/src/presentation/static/css/providers.css @@ -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; diff --git a/src/presentation/templates/providers.html b/src/presentation/templates/providers.html index 1c906e3..1b07ada 100644 --- a/src/presentation/templates/providers.html +++ b/src/presentation/templates/providers.html @@ -135,9 +135,16 @@
- @@ -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: ` -

下游 = 调用这个代理的客户端或工具。你要根据客户端支持的协议面来选。

+

下游 = 调用这个代理的客户端或工具。现在可以多选,同一个 provider 可以同时暴露多个下游协议。

  • openai_chat:下游走 /v1/chat/completions。适合 OpenCode、Cherry Studio 的 OpenAI provider。
  • openai_responses:下游走 /v1/responses。适合 Responses API 客户端。
  • codex:也走 /v1/responses,但保留 Codex 的请求语义。
  • claude_chat:下游走 /v1/messages。适合 Claude Code 和 Anthropic 兼容客户端。
-

一个 provider 配好后只能被对应的 route family 使用,不能让下游随意混用。

+

注意:有些下游格式会互斥,因为它们共用同一个 route family 但请求语义不同。目前 openai_responses 和 codex 不能同时启用;以后如果有新的互斥组合,也会在这里说明。

+

在多选下拉里如果新选了冲突项,旧的冲突项会自动被替换。

`, }, auth_group_field: { @@ -595,7 +608,7 @@
  • HTTP/HTTPS:直接请求当前配置地址对应的模型接口。
  • WebSocket:会自动把 ws:// / wss:// 推导成对应的 http:// / https:// 后再尝试拉取。
  • -

    这一步只是帮你发现可选模型,不会修改上游协议,也不会自动替你决定 source_format 或 target_format。

    +

    这一步只是帮你发现可选模型,不会修改上游协议,也不会自动替你决定 source_format 或 target_formats。

    `, }, }; @@ -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 => `${escapeHtml(label)}`) + .join('') + + (hiddenCount ? `+${hiddenCount}` : '') + : `${escapeHtml(placeholder)}`; + + return ` + + ${chipsMarkup} + + `; + } + 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 = ` + + ${escapeHtml(option.textContent || '')} + `; + } 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 @@
    ` : ''; + const providerTargetFormats = getProviderTargetFormats(provider); + const targetFormatChips = providerTargetFormats + .map(format => `${escapeHtml(format)}`) + .join(''); html += ` @@ -1863,7 +2044,7 @@ ${isEnabled ? 'enabled' : 'disabled'} ${escapeHtml(provider.transport || 'http')} ${escapeHtml(provider.source_format || 'openai_chat')} - ${escapeHtml(provider.target_format || 'openai_chat')} + ${targetFormatChips} ${provider.auth_group ? `auth_group:${escapeHtml(provider.auth_group)}` : ''} ${provider.api_key ? 'legacy api_key' : ''} ${provider.hook ? `${escapeHtml(provider.hook)}` : ''} @@ -3344,3 +3525,4 @@ }); {% endblock %} + diff --git a/src/services/provider_service.py b/src/services/provider_service.py index f7707df..e51c357 100644 --- a/src/services/provider_service.py +++ b/src/services/provider_service.py @@ -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 { diff --git a/src/services/proxy_service.py b/src/services/proxy_service.py index d0d52d5..aff9bc3 100644 --- a/src/services/proxy_service.py +++ b/src/services/proxy_service.py @@ -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 "", @@ -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) diff --git a/tests/test_provider_service.py b/tests/test_provider_service.py index 7c244e1..e8bf324 100644 --- a/tests/test_provider_service.py +++ b/tests/test_provider_service.py @@ -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( diff --git a/tests/test_proxy_error_handling.py b/tests/test_proxy_error_handling.py index f3e5ad6..ea405ea 100644 --- a/tests/test_proxy_error_handling.py +++ b/tests/test_proxy_error_handling.py @@ -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(), diff --git a/tests/test_proxy_transport.py b/tests/test_proxy_transport.py index 4472cff..18729d8 100644 --- a/tests/test_proxy_transport.py +++ b/tests/test_proxy_transport.py @@ -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">多选', html) + self.assertNotIn('class="field-inline-note">可多选;点击已选项可取消,互斥项会自动替换。', 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"])