From 0831739f4b1610d706fc576829d979778b5b0f1c Mon Sep 17 00:00:00 2001 From: X-Zero-L Date: Sun, 1 Mar 2026 19:48:17 +0800 Subject: [PATCH 1/3] feat: add configurable provider endpoint for codex-api accounts Add providerEndpoint field (responses/completions/auto) to openai-responses accounts, allowing admins to specify which API endpoint format the provider supports. The relay service normalizes request paths based on this config: - responses (default): routes all requests to /v1/responses - completions: routes all requests to /v1/chat/completions - auto: preserves the original request path Co-Authored-By: Claude Opus 4.6 --- .../account/openaiResponsesAccountService.js | 6 ++- .../relay/openaiResponsesRelayService.js | 18 ++++++++- .../src/components/accounts/AccountForm.vue | 40 +++++++++++++++++++ 3 files changed, 60 insertions(+), 4 deletions(-) diff --git a/src/services/account/openaiResponsesAccountService.js b/src/services/account/openaiResponsesAccountService.js index e16c7129..dc1517e0 100644 --- a/src/services/account/openaiResponsesAccountService.js +++ b/src/services/account/openaiResponsesAccountService.js @@ -51,7 +51,8 @@ class OpenAIResponsesAccountService { dailyQuota = 0, // 每日额度限制(美元),0表示不限制 quotaResetTime = '00:00', // 额度重置时间(HH:mm格式) rateLimitDuration = 60, // 限流时间(分钟) - disableAutoProtection = false // 是否关闭自动防护(429/401/400/529 不自动禁用) + disableAutoProtection = false, // 是否关闭自动防护(429/401/400/529 不自动禁用) + providerEndpoint = 'responses' // Provider 端点类型:responses | completions | auto } = options // 验证必填字段 @@ -96,7 +97,8 @@ class OpenAIResponsesAccountService { lastResetDate: redis.getDateStringInTimezone(), quotaResetTime, quotaStoppedAt: '', - disableAutoProtection: disableAutoProtection.toString() // 关闭自动防护 + disableAutoProtection: disableAutoProtection.toString(), // 关闭自动防护 + providerEndpoint // Provider 端点类型:responses(默认) | completions | auto } // 保存到 Redis diff --git a/src/services/relay/openaiResponsesRelayService.js b/src/services/relay/openaiResponsesRelayService.js index 1cb06e13..f1a6f8fc 100644 --- a/src/services/relay/openaiResponsesRelayService.js +++ b/src/services/relay/openaiResponsesRelayService.js @@ -91,8 +91,22 @@ class OpenAIResponsesRelayService { req.once('close', handleClientDisconnect) res.once('close', handleClientDisconnect) - // 构建目标 URL - const targetUrl = `${fullAccount.baseApi}${req.path}` + // 构建目标 URL(根据 providerEndpoint 配置决定端点路径) + const providerEndpoint = fullAccount.providerEndpoint || 'responses' + let targetPath = req.path + if (providerEndpoint === 'responses' && targetPath.includes('/completions')) { + // Provider 仅支持 Responses 端点,将 completions 路径归一化 + targetPath = '/v1/responses' + logger.info(`📝 Normalized path (${req.path}) → /v1/responses (providerEndpoint=responses)`) + } else if (providerEndpoint === 'completions' && targetPath.includes('/responses')) { + // Provider 仅支持 Completions 端点,将 responses 路径归一化 + targetPath = '/v1/chat/completions' + logger.info( + `📝 Normalized path (${req.path}) → /v1/chat/completions (providerEndpoint=completions)` + ) + } + // providerEndpoint === 'auto' 时保持原始路径不变 + const targetUrl = `${fullAccount.baseApi}${targetPath}` logger.info(`🎯 Forwarding to: ${targetUrl}`) // 构建请求头 - 使用统一的 headerFilter 移除 CDN headers diff --git a/web/admin-spa/src/components/accounts/AccountForm.vue b/web/admin-spa/src/components/accounts/AccountForm.vue index ce2eb71e..dddcf150 100644 --- a/web/admin-spa/src/components/accounts/AccountForm.vue +++ b/web/admin-spa/src/components/accounts/AccountForm.vue @@ -1692,6 +1692,24 @@

+
+ + +

+ 指定 Provider 支持的端点类型。Responses 会将所有请求路由到 /v1/responses;Chat + Completions 路由到 /v1/chat/completions;自动则保持客户端请求的原始路径 +

+
+ @@ -3440,6 +3458,24 @@

+
+ + +

+ 指定 Provider 支持的端点类型。Responses 路由到 /v1/responses;Chat Completions + 路由到 /v1/chat/completions;自动则保持原始路径 +

+
+ @@ -4272,6 +4308,7 @@ const form = ref({ endpointType: props.account?.endpointType || 'anthropic', // OpenAI-Responses 特定字段 baseApi: props.account?.baseApi || '', + providerEndpoint: props.account?.providerEndpoint || 'responses', // Gemini-API 特定字段 baseUrl: props.account?.baseUrl || 'https://generativelanguage.googleapis.com', rateLimitDuration: props.account?.rateLimitDuration || 60, @@ -5434,6 +5471,7 @@ const createAccount = async () => { data.baseApi = form.value.baseApi data.apiKey = form.value.apiKey data.userAgent = form.value.userAgent || '' + data.providerEndpoint = form.value.providerEndpoint || 'responses' data.priority = form.value.priority || 50 data.rateLimitDuration = 60 // 默认值60,不从用户输入获取 data.dailyQuota = form.value.dailyQuota || 0 @@ -5784,6 +5822,7 @@ const updateAccount = async () => { data.apiKey = form.value.apiKey } data.userAgent = form.value.userAgent || '' + data.providerEndpoint = form.value.providerEndpoint || 'responses' data.priority = form.value.priority || 50 // 编辑时不上传 rateLimitDuration,保持原值 data.dailyQuota = form.value.dailyQuota || 0 @@ -6406,6 +6445,7 @@ watch( deploymentName: newAccount.deploymentName || '', // OpenAI-Responses 特定字段 baseApi: newAccount.baseApi || '', + providerEndpoint: newAccount.providerEndpoint || 'responses', // Gemini-API 特定字段 baseUrl: newAccount.baseUrl || 'https://generativelanguage.googleapis.com', // 额度管理字段 From 8c332086ba43fcb3921e4fc2874180077ca6add5 Mon Sep 17 00:00:00 2001 From: X-Zero-L Date: Sun, 1 Mar 2026 20:33:26 +0800 Subject: [PATCH 2/3] fix: harden provider endpoint - remove broken completions mode, add validation - Remove `completions` option (path-only change without body conversion causes 400) - Add server-side enum validation for providerEndpoint in create/update - Use exact path matching instead of broad `.includes()` in relay service - Fix test endpoint to respect providerEndpoint config and /v1 dedup - Improve /v1 dedup with null-safe baseApi access Co-Authored-By: Claude Opus 4.6 --- src/routes/admin/openaiResponsesAccounts.js | 13 ++++++-- .../account/openaiResponsesAccountService.js | 22 ++++++++++++-- .../relay/openaiResponsesRelayService.js | 30 ++++++++++++------- .../src/components/accounts/AccountForm.vue | 9 +++--- 4 files changed, 54 insertions(+), 20 deletions(-) diff --git a/src/routes/admin/openaiResponsesAccounts.js b/src/routes/admin/openaiResponsesAccounts.js index 4efab59d..42d2d96e 100644 --- a/src/routes/admin/openaiResponsesAccounts.js +++ b/src/routes/admin/openaiResponsesAccounts.js @@ -472,9 +472,18 @@ router.post('/openai-responses-accounts/:accountId/test', authenticateAdmin, asy return res.status(401).json({ error: 'API Key not found or decryption failed' }) } - // 构造测试请求 + // 构造测试请求(根据 providerEndpoint 和 baseApi 决定端点路径) const baseUrl = account.baseApi || 'https://api.openai.com' - const apiUrl = `${baseUrl}/responses` + const providerEndpoint = account.providerEndpoint || 'responses' + let endpointPath = '/responses' + if (providerEndpoint === 'auto') { + endpointPath = '/responses' // 测试时默认用 responses + } + // 防止 baseApi 已含 /v1 时路径重复 + if (!baseUrl.endsWith('/v1')) { + endpointPath = '/v1' + endpointPath + } + const apiUrl = `${baseUrl}${endpointPath}` const payload = createOpenAITestPayload(model, { stream: false }) const requestConfig = { diff --git a/src/services/account/openaiResponsesAccountService.js b/src/services/account/openaiResponsesAccountService.js index dc1517e0..484df602 100644 --- a/src/services/account/openaiResponsesAccountService.js +++ b/src/services/account/openaiResponsesAccountService.js @@ -52,7 +52,7 @@ class OpenAIResponsesAccountService { quotaResetTime = '00:00', // 额度重置时间(HH:mm格式) rateLimitDuration = 60, // 限流时间(分钟) disableAutoProtection = false, // 是否关闭自动防护(429/401/400/529 不自动禁用) - providerEndpoint = 'responses' // Provider 端点类型:responses | completions | auto + providerEndpoint = 'responses' // Provider 端点类型:responses | auto } = options // 验证必填字段 @@ -60,6 +60,14 @@ class OpenAIResponsesAccountService { throw new Error('Base API URL and API Key are required for OpenAI-Responses account') } + // 验证 providerEndpoint 枚举值 + const validEndpoints = ['responses', 'auto'] + if (!validEndpoints.includes(providerEndpoint)) { + throw new Error( + `Invalid providerEndpoint: ${providerEndpoint}. Must be one of: ${validEndpoints.join(', ')}` + ) + } + // 规范化 baseApi(确保不以 / 结尾) const normalizedBaseApi = baseApi.endsWith('/') ? baseApi.slice(0, -1) : baseApi @@ -98,7 +106,7 @@ class OpenAIResponsesAccountService { quotaResetTime, quotaStoppedAt: '', disableAutoProtection: disableAutoProtection.toString(), // 关闭自动防护 - providerEndpoint // Provider 端点类型:responses(默认) | completions | auto + providerEndpoint // Provider 端点类型:responses(默认) | auto } // 保存到 Redis @@ -167,6 +175,16 @@ class OpenAIResponsesAccountService { // 直接保存,不做任何调整 } + // 验证 providerEndpoint 枚举值 + if (updates.providerEndpoint !== undefined) { + const validEndpoints = ['responses', 'auto'] + if (!validEndpoints.includes(updates.providerEndpoint)) { + throw new Error( + `Invalid providerEndpoint: ${updates.providerEndpoint}. Must be one of: ${validEndpoints.join(', ')}` + ) + } + } + // 自动防护开关 if (updates.disableAutoProtection !== undefined) { updates.disableAutoProtection = updates.disableAutoProtection.toString() diff --git a/src/services/relay/openaiResponsesRelayService.js b/src/services/relay/openaiResponsesRelayService.js index f1a6f8fc..8ce4a8b6 100644 --- a/src/services/relay/openaiResponsesRelayService.js +++ b/src/services/relay/openaiResponsesRelayService.js @@ -94,19 +94,27 @@ class OpenAIResponsesRelayService { // 构建目标 URL(根据 providerEndpoint 配置决定端点路径) const providerEndpoint = fullAccount.providerEndpoint || 'responses' let targetPath = req.path - if (providerEndpoint === 'responses' && targetPath.includes('/completions')) { - // Provider 仅支持 Responses 端点,将 completions 路径归一化 - targetPath = '/v1/responses' - logger.info(`📝 Normalized path (${req.path}) → /v1/responses (providerEndpoint=responses)`) - } else if (providerEndpoint === 'completions' && targetPath.includes('/responses')) { - // Provider 仅支持 Completions 端点,将 responses 路径归一化 - targetPath = '/v1/chat/completions' - logger.info( - `📝 Normalized path (${req.path}) → /v1/chat/completions (providerEndpoint=completions)` - ) + + // 根据 providerEndpoint 配置归一化路径 + // 注意:unified.js 已将 /v1/chat/completions 的请求体转换为 Responses 格式, + // 因此这里只需归一化路径即可;反向 responses→completions 需要同时转换请求体, + // 目前不支持,所以只保留 responses 和 auto 两种模式 + if ( + providerEndpoint === 'responses' && + (targetPath === '/v1/chat/completions' || targetPath === '/chat/completions') + ) { + const newPath = targetPath.startsWith('/v1') ? '/v1/responses' : '/responses' + logger.info(`📝 Normalized path (${req.path}) → ${newPath} (providerEndpoint=responses)`) + targetPath = newPath } // providerEndpoint === 'auto' 时保持原始路径不变 - const targetUrl = `${fullAccount.baseApi}${targetPath}` + + // 防止 baseApi 已含 /v1 时路径重复(如 baseApi=http://host/v1 + targetPath=/v1/responses → /v1/v1/responses) + const baseApi = fullAccount.baseApi || '' + if (baseApi.endsWith('/v1') && targetPath.startsWith('/v1/')) { + targetPath = targetPath.slice(3) // '/v1/responses' → '/responses' + } + const targetUrl = `${baseApi}${targetPath}` logger.info(`🎯 Forwarding to: ${targetUrl}`) // 构建请求头 - 使用统一的 headerFilter 移除 CDN headers diff --git a/web/admin-spa/src/components/accounts/AccountForm.vue b/web/admin-spa/src/components/accounts/AccountForm.vue index dddcf150..b70e8c74 100644 --- a/web/admin-spa/src/components/accounts/AccountForm.vue +++ b/web/admin-spa/src/components/accounts/AccountForm.vue @@ -1701,12 +1701,11 @@ class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200" > -

- 指定 Provider 支持的端点类型。Responses 会将所有请求路由到 /v1/responses;Chat - Completions 路由到 /v1/chat/completions;自动则保持客户端请求的原始路径 + 指定 Provider 支持的端点类型。Responses 会将所有请求路由到(包括来自 + /v1/chat/completions 的请求会自动转换);自动则保持客户端请求的原始路径

@@ -3471,8 +3470,8 @@

- 指定 Provider 支持的端点类型。Responses 路由到 /v1/responses;Chat Completions - 路由到 /v1/chat/completions;自动则保持原始路径 + 指定 Provider 支持的端点类型。Responses 会将所有请求路由到(包括来自 + /v1/chat/completions 的请求会自动转换);自动则保持原始路径

From 8fed791702a9ffc6a2e65089ec06027ef6c9039c Mon Sep 17 00:00:00 2001 From: X-Zero-L Date: Mon, 2 Mar 2026 14:52:16 +0800 Subject: [PATCH 3/3] fix: use template literal to satisfy ESLint prefer-template rule Co-Authored-By: Claude Opus 4.6 --- src/routes/admin/openaiResponsesAccounts.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes/admin/openaiResponsesAccounts.js b/src/routes/admin/openaiResponsesAccounts.js index 42d2d96e..5a3409e6 100644 --- a/src/routes/admin/openaiResponsesAccounts.js +++ b/src/routes/admin/openaiResponsesAccounts.js @@ -481,7 +481,7 @@ router.post('/openai-responses-accounts/:accountId/test', authenticateAdmin, asy } // 防止 baseApi 已含 /v1 时路径重复 if (!baseUrl.endsWith('/v1')) { - endpointPath = '/v1' + endpointPath + endpointPath = `/v1${endpointPath}` } const apiUrl = `${baseUrl}${endpointPath}` const payload = createOpenAITestPayload(model, { stream: false })