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 })