"backend/vscode:/vscode.git/clone" did not exist on "ee86dbca9d8f1c52698ed03da370e3ad037cab5e"
Commit 1d0872e7 authored by yangjianbo's avatar yangjianbo
Browse files

feat(openai-ws): 合并 WS v2 透传模式与前端 ws mode



新增 OpenAI WebSocket v2 passthrough relay 数据面与服务适配层,
支持按账号 ws mode 在 ctx_pool 与 passthrough 间路由。

同步调整前端 OpenAI ws mode 选项为 off/ctx_pool/passthrough,
并补充 i18n 文案与对应单测。

新增 Caddyfile.dmit 与 docker-compose-aicodex.yml 部署配置,
用于宿主机场景下的反向代理与服务编排。
Co-Authored-By: default avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 078fefed
# =============================================================================
# aicodex2api Docker Compose Host Configuration (Local Build)
# =============================================================================
# Quick Start:
# 1. Copy .env.example to .env and configure
# 2. docker-compose -f docker-compose-host.yml up -d --build
# 3. Check logs: docker-compose -f docker-compose-host.yml logs -f aicodex2api
# 4. Access: http://localhost:8080
#
# This configuration builds the image from source (Dockerfile in project root).
# All configuration is done via environment variables.
# No Setup Wizard needed - the system auto-initializes on first run.
# =============================================================================
services:
# ===========================================================================
# aicodex2api Application
# ===========================================================================
aicodex2api:
image: yangjianbo/aicodex2api:latest
build:
context: ..
dockerfile: Dockerfile
container_name: aicodex2api
restart: unless-stopped
network_mode: host
ulimits:
nofile:
soft: 800000
hard: 800000
volumes:
# Data persistence (config.yaml will be auto-generated here)
- aicodex2api_data:/app/data
# Mount custom config.yaml (optional, overrides auto-generated config)
#- ./config.yaml:/app/data/config.yaml:ro
environment:
# =======================================================================
# Auto Setup (REQUIRED for Docker deployment)
# =======================================================================
- AUTO_SETUP=true
# =======================================================================
# Server Configuration
# =======================================================================
- SERVER_HOST=0.0.0.0
- SERVER_PORT=8080
- SERVER_MODE=${SERVER_MODE:-release}
- RUN_MODE=${RUN_MODE:-standard}
# 新用户默认并发(仅影响新注册用户;已有用户请在后台或数据库单独调整)
- DEFAULT_USER_CONCURRENCY=${DEFAULT_USER_CONCURRENCY:-12}
# =======================================================================
# Database Configuration (PostgreSQL)
# =======================================================================
# Using host network: point to host/external DB by DATABASE_HOST/DATABASE_PORT
- DATABASE_HOST=${DATABASE_HOST:-127.0.0.1}
- DATABASE_PORT=${DATABASE_PORT:-5432}
- DATABASE_USER=${POSTGRES_USER:-aicodex2api}
- DATABASE_PASSWORD=${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
- DATABASE_DBNAME=${POSTGRES_DB:-aicodex2api}
- DATABASE_SSLMODE=disable
- DATABASE_MAX_OPEN_CONNS=${DATABASE_MAX_OPEN_CONNS:-50}
- DATABASE_MAX_IDLE_CONNS=${DATABASE_MAX_IDLE_CONNS:-10}
- DATABASE_CONN_MAX_LIFETIME_MINUTES=${DATABASE_CONN_MAX_LIFETIME_MINUTES:-30}
- DATABASE_CONN_MAX_IDLE_TIME_MINUTES=${DATABASE_CONN_MAX_IDLE_TIME_MINUTES:-5}
# =======================================================================
# Gateway Configuration
# =======================================================================
- GATEWAY_FORCE_CODEX_CLI=${GATEWAY_FORCE_CODEX_CLI:-false}
- GATEWAY_OPENAI_WS_ENABLED=${GATEWAY_OPENAI_WS_ENABLED:-true}
- GATEWAY_OPENAI_WS_OAUTH_ENABLED=${GATEWAY_OPENAI_WS_OAUTH_ENABLED:-true}
- GATEWAY_OPENAI_WS_APIKEY_ENABLED=${GATEWAY_OPENAI_WS_APIKEY_ENABLED:-true}
- GATEWAY_OPENAI_WS_FORCE_HTTP=${GATEWAY_OPENAI_WS_FORCE_HTTP:-false}
- GATEWAY_OPENAI_WS_RESPONSES_WEBSOCKETS_V2=${GATEWAY_OPENAI_WS_RESPONSES_WEBSOCKETS_V2:-true}
# 多窗口场景建议 adaptive:兼顾会话隔离与连接复用
- GATEWAY_OPENAI_WS_STORE_DISABLED_CONN_MODE=${GATEWAY_OPENAI_WS_STORE_DISABLED_CONN_MODE:-adaptive}
- GATEWAY_OPENAI_WS_MAX_CONNS_PER_ACCOUNT=${GATEWAY_OPENAI_WS_MAX_CONNS_PER_ACCOUNT:-128}
- GATEWAY_OPENAI_WS_MIN_IDLE_PER_ACCOUNT=${GATEWAY_OPENAI_WS_MIN_IDLE_PER_ACCOUNT:-4}
- GATEWAY_OPENAI_WS_MAX_IDLE_PER_ACCOUNT=${GATEWAY_OPENAI_WS_MAX_IDLE_PER_ACCOUNT:-16}
- GATEWAY_OPENAI_WS_DYNAMIC_MAX_CONNS_BY_ACCOUNT_CONCURRENCY_ENABLED=${GATEWAY_OPENAI_WS_DYNAMIC_MAX_CONNS_BY_ACCOUNT_CONCURRENCY_ENABLED:-true}
- GATEWAY_OPENAI_WS_OAUTH_MAX_CONNS_FACTOR=${GATEWAY_OPENAI_WS_OAUTH_MAX_CONNS_FACTOR:-1.5}
- GATEWAY_OPENAI_WS_APIKEY_MAX_CONNS_FACTOR=${GATEWAY_OPENAI_WS_APIKEY_MAX_CONNS_FACTOR:-1.5}
- GATEWAY_OPENAI_WS_DIAL_TIMEOUT_SECONDS=${GATEWAY_OPENAI_WS_DIAL_TIMEOUT_SECONDS:-15}
- GATEWAY_OPENAI_WS_READ_TIMEOUT_SECONDS=${GATEWAY_OPENAI_WS_READ_TIMEOUT_SECONDS:-900}
- GATEWAY_OPENAI_WS_WRITE_TIMEOUT_SECONDS=${GATEWAY_OPENAI_WS_WRITE_TIMEOUT_SECONDS:-120}
- GATEWAY_OPENAI_WS_QUEUE_LIMIT_PER_CONN=${GATEWAY_OPENAI_WS_QUEUE_LIMIT_PER_CONN:-128}
- GATEWAY_OPENAI_WS_RETRY_BACKOFF_INITIAL_MS=${GATEWAY_OPENAI_WS_RETRY_BACKOFF_INITIAL_MS:-150}
- GATEWAY_OPENAI_WS_RETRY_BACKOFF_MAX_MS=${GATEWAY_OPENAI_WS_RETRY_BACKOFF_MAX_MS:-3000}
- GATEWAY_OPENAI_WS_RETRY_TOTAL_BUDGET_MS=${GATEWAY_OPENAI_WS_RETRY_TOTAL_BUDGET_MS:-15000}
- GATEWAY_MAX_IDLE_CONNS=${GATEWAY_MAX_IDLE_CONNS:-2560}
- GATEWAY_MAX_IDLE_CONNS_PER_HOST=${GATEWAY_MAX_IDLE_CONNS_PER_HOST:-120}
- GATEWAY_MAX_CONNS_PER_HOST=${GATEWAY_MAX_CONNS_PER_HOST:-8192}
# =======================================================================
# Redis Configuration
# =======================================================================
# Using host network: point to host/external Redis by REDIS_HOST/REDIS_PORT
- REDIS_HOST=${REDIS_HOST:-127.0.0.1}
- REDIS_PORT=${REDIS_PORT:-6379}
- REDIS_PASSWORD=${REDIS_PASSWORD:-}
- REDIS_DB=${REDIS_DB:-0}
- REDIS_POOL_SIZE=${REDIS_POOL_SIZE:-1024}
- REDIS_MIN_IDLE_CONNS=${REDIS_MIN_IDLE_CONNS:-10}
- REDIS_ENABLE_TLS=${REDIS_ENABLE_TLS:-false}
# =======================================================================
# Admin Account (auto-created on first run)
# =======================================================================
- ADMIN_EMAIL=${ADMIN_EMAIL:-admin@aicodex2api.local}
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-}
# =======================================================================
# JWT Configuration
# =======================================================================
# Leave empty to auto-generate (recommended)
- JWT_SECRET=${JWT_SECRET:-}
- JWT_EXPIRE_HOUR=${JWT_EXPIRE_HOUR:-24}
# =======================================================================
# TOTP (2FA) Configuration
# =======================================================================
# IMPORTANT: Set a fixed encryption key for TOTP secrets. If left empty,
# a random key will be generated on each startup, causing all existing
# TOTP configurations to become invalid (users won't be able to login
# with 2FA).
# Generate a secure key: openssl rand -hex 32
- TOTP_ENCRYPTION_KEY=${TOTP_ENCRYPTION_KEY:-}
# =======================================================================
# Timezone Configuration
# This affects ALL time operations in the application:
# - Database timestamps
# - Usage statistics "today" boundary
# - Subscription expiry times
# - Log timestamps
# Common values: Asia/Shanghai, America/New_York, Europe/London, UTC
# =======================================================================
- TZ=${TZ:-Asia/Shanghai}
# =======================================================================
# Gemini OAuth Configuration (for Gemini accounts)
# =======================================================================
- GEMINI_OAUTH_CLIENT_ID=${GEMINI_OAUTH_CLIENT_ID:-}
- GEMINI_OAUTH_CLIENT_SECRET=${GEMINI_OAUTH_CLIENT_SECRET:-}
- GEMINI_OAUTH_SCOPES=${GEMINI_OAUTH_SCOPES:-}
- GEMINI_QUOTA_POLICY=${GEMINI_QUOTA_POLICY:-}
# Built-in OAuth client secrets (optional)
# SECURITY: This repo does not embed third-party client_secret.
- GEMINI_CLI_OAUTH_CLIENT_SECRET=${GEMINI_CLI_OAUTH_CLIENT_SECRET:-}
- ANTIGRAVITY_OAUTH_CLIENT_SECRET=${ANTIGRAVITY_OAUTH_CLIENT_SECRET:-}
# =======================================================================
# Security Configuration (URL Allowlist)
# =======================================================================
# Allow private IP addresses for CRS sync (for internal deployments)
- SECURITY_URL_ALLOWLIST_ALLOW_PRIVATE_HOSTS=${SECURITY_URL_ALLOWLIST_ALLOW_PRIVATE_HOSTS:-true}
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
# ===========================================================================
# PostgreSQL Database
# ===========================================================================
postgres:
image: postgres:18-alpine
container_name: aicodex2api-postgres
restart: unless-stopped
network_mode: host
ulimits:
nofile:
soft: 800000
hard: 800000
volumes:
- postgres_data:/var/lib/postgresql/data
environment:
# postgres:18-alpine 默认 PGDATA=/var/lib/postgresql/18/docker(位于镜像声明的匿名卷 /var/lib/postgresql 内)。
# 若不显式设置 PGDATA,则即使挂载了 postgres_data 到 /var/lib/postgresql/data,数据也不会落盘到该命名卷,
# docker compose down/up 后会触发 initdb 重新初始化,导致用户/密码等数据丢失。
- PGDATA=/var/lib/postgresql/data
- POSTGRES_USER=${POSTGRES_USER:-aicodex2api}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
- POSTGRES_DB=${POSTGRES_DB:-aicodex2api}
- TZ=${TZ:-Asia/Shanghai}
command:
- "postgres"
- "-c"
- "listen_addresses=127.0.0.1"
# 监听端口:与应用侧 DATABASE_PORT 保持一致。
- "-c"
- "port=${DATABASE_PORT:-5432}"
# 连接数上限:需要结合应用侧 DATABASE_MAX_OPEN_CONNS 调整。
# 注意:max_connections 过大可能导致内存占用与上下文切换开销显著上升。
- "-c"
- "max_connections=${POSTGRES_MAX_CONNECTIONS:-1024}"
# 典型内存参数(建议结合机器内存调优;不确定就保持默认或小步调大)。
- "-c"
- "shared_buffers=${POSTGRES_SHARED_BUFFERS:-1GB}"
- "-c"
- "effective_cache_size=${POSTGRES_EFFECTIVE_CACHE_SIZE:-6GB}"
- "-c"
- "maintenance_work_mem=${POSTGRES_MAINTENANCE_WORK_MEM:-128MB}"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-aicodex2api} -d ${POSTGRES_DB:-aicodex2api} -p ${DATABASE_PORT:-5432}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
# Note: bound to localhost only; not exposed to external network by default.
# ===========================================================================
# Redis Cache
# ===========================================================================
redis:
image: redis:8-alpine
container_name: aicodex2api-redis
restart: unless-stopped
network_mode: host
ulimits:
nofile:
soft: 100000
hard: 100000
volumes:
- redis_data:/data
command: >
redis-server
--bind 127.0.0.1
--port ${REDIS_PORT:-6379}
--maxclients ${REDIS_MAXCLIENTS:-50000}
--save 60 1
--appendonly yes
--appendfsync everysec
${REDIS_PASSWORD:+--requirepass ${REDIS_PASSWORD}}
environment:
- TZ=${TZ:-Asia/Shanghai}
# REDISCLI_AUTH is used by redis-cli for authentication (safer than -a flag)
- REDISCLI_AUTH=${REDIS_PASSWORD:-}
healthcheck:
test: ["CMD-SHELL", "redis-cli -p ${REDIS_PORT:-6379} -a \"$REDISCLI_AUTH\" ping | grep -q PONG || redis-cli -p ${REDIS_PORT:-6379} ping | grep -q PONG"]
interval: 10s
timeout: 5s
retries: 5
start_period: 5s
# =============================================================================
# Volumes
# =============================================================================
volumes:
aicodex2api_data:
driver: local
postgres_data:
driver: local
redis_data:
driver: local
...@@ -1807,7 +1807,7 @@ ...@@ -1807,7 +1807,7 @@
</div> </div>
</div> </div>
<!-- OpenAI WS Mode 三态off/shared/dedicated --> <!-- OpenAI WS Mode 三态off/ctx_pool/passthrough -->
<div <div
v-if="form.platform === 'openai' && (accountCategory === 'oauth-based' || accountCategory === 'apikey')" v-if="form.platform === 'openai' && (accountCategory === 'oauth-based' || accountCategory === 'apikey')"
class="border-t border-gray-200 pt-4 dark:border-dark-600" class="border-t border-gray-200 pt-4 dark:border-dark-600"
...@@ -1819,7 +1819,7 @@ ...@@ -1819,7 +1819,7 @@
{{ t('admin.accounts.openai.wsModeDesc') }} {{ t('admin.accounts.openai.wsModeDesc') }}
</p> </p>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400"> <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.openai.wsModeConcurrencyHint') }} {{ t(openAIWSModeConcurrencyHintKey) }}
</p> </p>
</div> </div>
<div class="w-52"> <div class="w-52">
...@@ -2341,10 +2341,11 @@ import { applyInterceptWarmup } from '@/components/account/credentialsBuilder' ...@@ -2341,10 +2341,11 @@ import { applyInterceptWarmup } from '@/components/account/credentialsBuilder'
import { formatDateTimeLocalInput, parseDateTimeLocalInput } from '@/utils/format' import { formatDateTimeLocalInput, parseDateTimeLocalInput } from '@/utils/format'
import { createStableObjectKeyResolver } from '@/utils/stableObjectKey' import { createStableObjectKeyResolver } from '@/utils/stableObjectKey'
import { import {
OPENAI_WS_MODE_DEDICATED, OPENAI_WS_MODE_CTX_POOL,
OPENAI_WS_MODE_OFF, OPENAI_WS_MODE_OFF,
OPENAI_WS_MODE_SHARED, OPENAI_WS_MODE_PASSTHROUGH,
isOpenAIWSModeEnabled, isOpenAIWSModeEnabled,
resolveOpenAIWSModeConcurrencyHintKey,
type OpenAIWSMode type OpenAIWSMode
} from '@/utils/openaiWsMode' } from '@/utils/openaiWsMode'
import OAuthAuthorizationFlow from './OAuthAuthorizationFlow.vue' import OAuthAuthorizationFlow from './OAuthAuthorizationFlow.vue'
...@@ -2541,8 +2542,8 @@ const geminiSelectedTier = computed(() => { ...@@ -2541,8 +2542,8 @@ const geminiSelectedTier = computed(() => {
const openAIWSModeOptions = computed(() => [ const openAIWSModeOptions = computed(() => [
{ value: OPENAI_WS_MODE_OFF, label: t('admin.accounts.openai.wsModeOff') }, { value: OPENAI_WS_MODE_OFF, label: t('admin.accounts.openai.wsModeOff') },
{ value: OPENAI_WS_MODE_SHARED, label: t('admin.accounts.openai.wsModeShared') }, { value: OPENAI_WS_MODE_CTX_POOL, label: t('admin.accounts.openai.wsModeCtxPool') },
{ value: OPENAI_WS_MODE_DEDICATED, label: t('admin.accounts.openai.wsModeDedicated') } { value: OPENAI_WS_MODE_PASSTHROUGH, label: t('admin.accounts.openai.wsModePassthrough') }
]) ])
const openaiResponsesWebSocketV2Mode = computed({ const openaiResponsesWebSocketV2Mode = computed({
...@@ -2561,6 +2562,10 @@ const openaiResponsesWebSocketV2Mode = computed({ ...@@ -2561,6 +2562,10 @@ const openaiResponsesWebSocketV2Mode = computed({
} }
}) })
const openAIWSModeConcurrencyHintKey = computed(() =>
resolveOpenAIWSModeConcurrencyHintKey(openaiResponsesWebSocketV2Mode.value)
)
const isOpenAIModelRestrictionDisabled = computed(() => const isOpenAIModelRestrictionDisabled = computed(() =>
form.platform === 'openai' && openaiPassthroughEnabled.value form.platform === 'openai' && openaiPassthroughEnabled.value
) )
...@@ -3180,10 +3185,13 @@ const buildOpenAIExtra = (base?: Record<string, unknown>): Record<string, unknow ...@@ -3180,10 +3185,13 @@ const buildOpenAIExtra = (base?: Record<string, unknown>): Record<string, unknow
} }
const extra: Record<string, unknown> = { ...(base || {}) } const extra: Record<string, unknown> = { ...(base || {}) }
extra.openai_oauth_responses_websockets_v2_mode = openaiOAuthResponsesWebSocketV2Mode.value if (accountCategory.value === 'oauth-based') {
extra.openai_apikey_responses_websockets_v2_mode = openaiAPIKeyResponsesWebSocketV2Mode.value extra.openai_oauth_responses_websockets_v2_mode = openaiOAuthResponsesWebSocketV2Mode.value
extra.openai_oauth_responses_websockets_v2_enabled = isOpenAIWSModeEnabled(openaiOAuthResponsesWebSocketV2Mode.value) extra.openai_oauth_responses_websockets_v2_enabled = isOpenAIWSModeEnabled(openaiOAuthResponsesWebSocketV2Mode.value)
extra.openai_apikey_responses_websockets_v2_enabled = isOpenAIWSModeEnabled(openaiAPIKeyResponsesWebSocketV2Mode.value) } else if (accountCategory.value === 'apikey') {
extra.openai_apikey_responses_websockets_v2_mode = openaiAPIKeyResponsesWebSocketV2Mode.value
extra.openai_apikey_responses_websockets_v2_enabled = isOpenAIWSModeEnabled(openaiAPIKeyResponsesWebSocketV2Mode.value)
}
// 清理兼容旧键,统一改用分类型开关。 // 清理兼容旧键,统一改用分类型开关。
delete extra.responses_websockets_v2_enabled delete extra.responses_websockets_v2_enabled
delete extra.openai_ws_enabled delete extra.openai_ws_enabled
......
...@@ -708,7 +708,7 @@ ...@@ -708,7 +708,7 @@
</div> </div>
</div> </div>
<!-- OpenAI WS Mode 三态off/shared/dedicated --> <!-- OpenAI WS Mode 三态off/ctx_pool/passthrough -->
<div <div
v-if="account?.platform === 'openai' && (account?.type === 'oauth' || account?.type === 'apikey')" v-if="account?.platform === 'openai' && (account?.type === 'oauth' || account?.type === 'apikey')"
class="border-t border-gray-200 pt-4 dark:border-dark-600" class="border-t border-gray-200 pt-4 dark:border-dark-600"
...@@ -720,7 +720,7 @@ ...@@ -720,7 +720,7 @@
{{ t('admin.accounts.openai.wsModeDesc') }} {{ t('admin.accounts.openai.wsModeDesc') }}
</p> </p>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400"> <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.openai.wsModeConcurrencyHint') }} {{ t(openAIWSModeConcurrencyHintKey) }}
</p> </p>
</div> </div>
<div class="w-52"> <div class="w-52">
...@@ -1273,10 +1273,11 @@ import { applyInterceptWarmup } from '@/components/account/credentialsBuilder' ...@@ -1273,10 +1273,11 @@ import { applyInterceptWarmup } from '@/components/account/credentialsBuilder'
import { formatDateTimeLocalInput, parseDateTimeLocalInput } from '@/utils/format' import { formatDateTimeLocalInput, parseDateTimeLocalInput } from '@/utils/format'
import { createStableObjectKeyResolver } from '@/utils/stableObjectKey' import { createStableObjectKeyResolver } from '@/utils/stableObjectKey'
import { import {
OPENAI_WS_MODE_DEDICATED, OPENAI_WS_MODE_CTX_POOL,
OPENAI_WS_MODE_OFF, OPENAI_WS_MODE_OFF,
OPENAI_WS_MODE_SHARED, OPENAI_WS_MODE_PASSTHROUGH,
isOpenAIWSModeEnabled, isOpenAIWSModeEnabled,
resolveOpenAIWSModeConcurrencyHintKey,
type OpenAIWSMode, type OpenAIWSMode,
resolveOpenAIWSModeFromExtra resolveOpenAIWSModeFromExtra
} from '@/utils/openaiWsMode' } from '@/utils/openaiWsMode'
...@@ -1387,8 +1388,8 @@ const codexCLIOnlyEnabled = ref(false) ...@@ -1387,8 +1388,8 @@ const codexCLIOnlyEnabled = ref(false)
const anthropicPassthroughEnabled = ref(false) const anthropicPassthroughEnabled = ref(false)
const openAIWSModeOptions = computed(() => [ const openAIWSModeOptions = computed(() => [
{ value: OPENAI_WS_MODE_OFF, label: t('admin.accounts.openai.wsModeOff') }, { value: OPENAI_WS_MODE_OFF, label: t('admin.accounts.openai.wsModeOff') },
{ value: OPENAI_WS_MODE_SHARED, label: t('admin.accounts.openai.wsModeShared') }, { value: OPENAI_WS_MODE_CTX_POOL, label: t('admin.accounts.openai.wsModeCtxPool') },
{ value: OPENAI_WS_MODE_DEDICATED, label: t('admin.accounts.openai.wsModeDedicated') } { value: OPENAI_WS_MODE_PASSTHROUGH, label: t('admin.accounts.openai.wsModePassthrough') }
]) ])
const openaiResponsesWebSocketV2Mode = computed({ const openaiResponsesWebSocketV2Mode = computed({
get: () => { get: () => {
...@@ -1405,6 +1406,9 @@ const openaiResponsesWebSocketV2Mode = computed({ ...@@ -1405,6 +1406,9 @@ const openaiResponsesWebSocketV2Mode = computed({
openaiOAuthResponsesWebSocketV2Mode.value = mode openaiOAuthResponsesWebSocketV2Mode.value = mode
} }
}) })
const openAIWSModeConcurrencyHintKey = computed(() =>
resolveOpenAIWSModeConcurrencyHintKey(openaiResponsesWebSocketV2Mode.value)
)
const isOpenAIModelRestrictionDisabled = computed(() => const isOpenAIModelRestrictionDisabled = computed(() =>
props.account?.platform === 'openai' && openaiPassthroughEnabled.value props.account?.platform === 'openai' && openaiPassthroughEnabled.value
) )
...@@ -2248,10 +2252,13 @@ const handleSubmit = async () => { ...@@ -2248,10 +2252,13 @@ const handleSubmit = async () => {
const currentExtra = (props.account.extra as Record<string, unknown>) || {} const currentExtra = (props.account.extra as Record<string, unknown>) || {}
const newExtra: Record<string, unknown> = { ...currentExtra } const newExtra: Record<string, unknown> = { ...currentExtra }
const hadCodexCLIOnlyEnabled = currentExtra.codex_cli_only === true const hadCodexCLIOnlyEnabled = currentExtra.codex_cli_only === true
newExtra.openai_oauth_responses_websockets_v2_mode = openaiOAuthResponsesWebSocketV2Mode.value if (props.account.type === 'oauth') {
newExtra.openai_apikey_responses_websockets_v2_mode = openaiAPIKeyResponsesWebSocketV2Mode.value newExtra.openai_oauth_responses_websockets_v2_mode = openaiOAuthResponsesWebSocketV2Mode.value
newExtra.openai_oauth_responses_websockets_v2_enabled = isOpenAIWSModeEnabled(openaiOAuthResponsesWebSocketV2Mode.value) newExtra.openai_oauth_responses_websockets_v2_enabled = isOpenAIWSModeEnabled(openaiOAuthResponsesWebSocketV2Mode.value)
newExtra.openai_apikey_responses_websockets_v2_enabled = isOpenAIWSModeEnabled(openaiAPIKeyResponsesWebSocketV2Mode.value) } else if (props.account.type === 'apikey') {
newExtra.openai_apikey_responses_websockets_v2_mode = openaiAPIKeyResponsesWebSocketV2Mode.value
newExtra.openai_apikey_responses_websockets_v2_enabled = isOpenAIWSModeEnabled(openaiAPIKeyResponsesWebSocketV2Mode.value)
}
delete newExtra.responses_websockets_v2_enabled delete newExtra.responses_websockets_v2_enabled
delete newExtra.openai_ws_enabled delete newExtra.openai_ws_enabled
if (openaiPassthroughEnabled.value) { if (openaiPassthroughEnabled.value) {
......
...@@ -1787,10 +1787,13 @@ export default { ...@@ -1787,10 +1787,13 @@ export default {
wsMode: 'WS mode', wsMode: 'WS mode',
wsModeDesc: 'Only applies to the current OpenAI account type.', wsModeDesc: 'Only applies to the current OpenAI account type.',
wsModeOff: 'Off (off)', wsModeOff: 'Off (off)',
wsModeCtxPool: 'Context Pool (ctx_pool)',
wsModePassthrough: 'Passthrough (passthrough)',
wsModeShared: 'Shared (shared)', wsModeShared: 'Shared (shared)',
wsModeDedicated: 'Dedicated (dedicated)', wsModeDedicated: 'Dedicated (dedicated)',
wsModeConcurrencyHint: wsModeConcurrencyHint:
'When WS mode is enabled, account concurrency becomes the WS connection pool limit for this account.', 'When WS mode is enabled, account concurrency becomes the WS connection pool limit for this account.',
wsModePassthroughHint: 'Passthrough mode does not use the WS connection pool.',
oauthResponsesWebsocketsV2: 'OAuth WebSocket Mode', oauthResponsesWebsocketsV2: 'OAuth WebSocket Mode',
oauthResponsesWebsocketsV2Desc: oauthResponsesWebsocketsV2Desc:
'Only applies to OpenAI OAuth. This account can use OpenAI WebSocket Mode only when enabled.', 'Only applies to OpenAI OAuth. This account can use OpenAI WebSocket Mode only when enabled.',
......
...@@ -1935,9 +1935,12 @@ export default { ...@@ -1935,9 +1935,12 @@ export default {
wsMode: 'WS mode', wsMode: 'WS mode',
wsModeDesc: '仅对当前 OpenAI 账号类型生效。', wsModeDesc: '仅对当前 OpenAI 账号类型生效。',
wsModeOff: '关闭(off)', wsModeOff: '关闭(off)',
wsModeCtxPool: '上下文池(ctx_pool)',
wsModePassthrough: '透传(passthrough)',
wsModeShared: '共享(shared)', wsModeShared: '共享(shared)',
wsModeDedicated: '独享(dedicated)', wsModeDedicated: '独享(dedicated)',
wsModeConcurrencyHint: '启用 WS mode 后,该账号并发数将作为该账号 WS 连接池上限。', wsModeConcurrencyHint: '启用 WS mode 后,该账号并发数将作为该账号 WS 连接池上限。',
wsModePassthroughHint: 'passthrough 模式不使用 WS 连接池。',
oauthResponsesWebsocketsV2: 'OAuth WebSocket Mode', oauthResponsesWebsocketsV2: 'OAuth WebSocket Mode',
oauthResponsesWebsocketsV2Desc: oauthResponsesWebsocketsV2Desc:
'仅对 OpenAI OAuth 生效。开启后该账号才允许使用 OpenAI WebSocket Mode 协议。', '仅对 OpenAI OAuth 生效。开启后该账号才允许使用 OpenAI WebSocket Mode 协议。',
......
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { import {
OPENAI_WS_MODE_DEDICATED, OPENAI_WS_MODE_CTX_POOL,
OPENAI_WS_MODE_OFF, OPENAI_WS_MODE_OFF,
OPENAI_WS_MODE_SHARED, OPENAI_WS_MODE_PASSTHROUGH,
isOpenAIWSModeEnabled, isOpenAIWSModeEnabled,
normalizeOpenAIWSMode, normalizeOpenAIWSMode,
openAIWSModeFromEnabled, openAIWSModeFromEnabled,
resolveOpenAIWSModeConcurrencyHintKey,
resolveOpenAIWSModeFromExtra resolveOpenAIWSModeFromExtra
} from '@/utils/openaiWsMode' } from '@/utils/openaiWsMode'
describe('openaiWsMode utils', () => { describe('openaiWsMode utils', () => {
it('normalizes mode values', () => { it('normalizes mode values', () => {
expect(normalizeOpenAIWSMode('off')).toBe(OPENAI_WS_MODE_OFF) expect(normalizeOpenAIWSMode('off')).toBe(OPENAI_WS_MODE_OFF)
expect(normalizeOpenAIWSMode(' Shared ')).toBe(OPENAI_WS_MODE_SHARED) expect(normalizeOpenAIWSMode('ctx_pool')).toBe(OPENAI_WS_MODE_CTX_POOL)
expect(normalizeOpenAIWSMode('DEDICATED')).toBe(OPENAI_WS_MODE_DEDICATED) expect(normalizeOpenAIWSMode('passthrough')).toBe(OPENAI_WS_MODE_PASSTHROUGH)
expect(normalizeOpenAIWSMode(' Shared ')).toBe(OPENAI_WS_MODE_CTX_POOL)
expect(normalizeOpenAIWSMode('DEDICATED')).toBe(OPENAI_WS_MODE_CTX_POOL)
expect(normalizeOpenAIWSMode('invalid')).toBeNull() expect(normalizeOpenAIWSMode('invalid')).toBeNull()
}) })
it('maps legacy enabled flag to mode', () => { it('maps legacy enabled flag to mode', () => {
expect(openAIWSModeFromEnabled(true)).toBe(OPENAI_WS_MODE_SHARED) expect(openAIWSModeFromEnabled(true)).toBe(OPENAI_WS_MODE_CTX_POOL)
expect(openAIWSModeFromEnabled(false)).toBe(OPENAI_WS_MODE_OFF) expect(openAIWSModeFromEnabled(false)).toBe(OPENAI_WS_MODE_OFF)
expect(openAIWSModeFromEnabled('true')).toBeNull() expect(openAIWSModeFromEnabled('true')).toBeNull()
}) })
it('resolves by mode key first, then enabled, then fallback enabled keys', () => { it('resolves by mode key first, then enabled, then fallback enabled keys', () => {
const extra = { const extra = {
openai_oauth_responses_websockets_v2_mode: 'dedicated', openai_oauth_responses_websockets_v2_mode: 'passthrough',
openai_oauth_responses_websockets_v2_enabled: false, openai_oauth_responses_websockets_v2_enabled: false,
responses_websockets_v2_enabled: false responses_websockets_v2_enabled: false
} }
...@@ -34,7 +37,7 @@ describe('openaiWsMode utils', () => { ...@@ -34,7 +37,7 @@ describe('openaiWsMode utils', () => {
enabledKey: 'openai_oauth_responses_websockets_v2_enabled', enabledKey: 'openai_oauth_responses_websockets_v2_enabled',
fallbackEnabledKeys: ['responses_websockets_v2_enabled', 'openai_ws_enabled'] fallbackEnabledKeys: ['responses_websockets_v2_enabled', 'openai_ws_enabled']
}) })
expect(mode).toBe(OPENAI_WS_MODE_DEDICATED) expect(mode).toBe(OPENAI_WS_MODE_PASSTHROUGH)
}) })
it('falls back to default when nothing is present', () => { it('falls back to default when nothing is present', () => {
...@@ -47,9 +50,21 @@ describe('openaiWsMode utils', () => { ...@@ -47,9 +50,21 @@ describe('openaiWsMode utils', () => {
expect(mode).toBe(OPENAI_WS_MODE_OFF) expect(mode).toBe(OPENAI_WS_MODE_OFF)
}) })
it('treats off as disabled and shared/dedicated as enabled', () => { it('treats off as disabled and non-off modes as enabled', () => {
expect(isOpenAIWSModeEnabled(OPENAI_WS_MODE_OFF)).toBe(false) expect(isOpenAIWSModeEnabled(OPENAI_WS_MODE_OFF)).toBe(false)
expect(isOpenAIWSModeEnabled(OPENAI_WS_MODE_SHARED)).toBe(true) expect(isOpenAIWSModeEnabled(OPENAI_WS_MODE_CTX_POOL)).toBe(true)
expect(isOpenAIWSModeEnabled(OPENAI_WS_MODE_DEDICATED)).toBe(true) expect(isOpenAIWSModeEnabled(OPENAI_WS_MODE_PASSTHROUGH)).toBe(true)
})
it('resolves concurrency hint key by mode', () => {
expect(resolveOpenAIWSModeConcurrencyHintKey(OPENAI_WS_MODE_OFF)).toBe(
'admin.accounts.openai.wsModeConcurrencyHint'
)
expect(resolveOpenAIWSModeConcurrencyHintKey(OPENAI_WS_MODE_CTX_POOL)).toBe(
'admin.accounts.openai.wsModeConcurrencyHint'
)
expect(resolveOpenAIWSModeConcurrencyHintKey(OPENAI_WS_MODE_PASSTHROUGH)).toBe(
'admin.accounts.openai.wsModePassthroughHint'
)
}) })
}) })
export const OPENAI_WS_MODE_OFF = 'off' export const OPENAI_WS_MODE_OFF = 'off'
export const OPENAI_WS_MODE_SHARED = 'shared' export const OPENAI_WS_MODE_CTX_POOL = 'ctx_pool'
export const OPENAI_WS_MODE_DEDICATED = 'dedicated' export const OPENAI_WS_MODE_PASSTHROUGH = 'passthrough'
export type OpenAIWSMode = export type OpenAIWSMode =
| typeof OPENAI_WS_MODE_OFF | typeof OPENAI_WS_MODE_OFF
| typeof OPENAI_WS_MODE_SHARED | typeof OPENAI_WS_MODE_CTX_POOL
| typeof OPENAI_WS_MODE_DEDICATED | typeof OPENAI_WS_MODE_PASSTHROUGH
const OPENAI_WS_MODES = new Set<OpenAIWSMode>([ const OPENAI_WS_MODES = new Set<OpenAIWSMode>([
OPENAI_WS_MODE_OFF, OPENAI_WS_MODE_OFF,
OPENAI_WS_MODE_SHARED, OPENAI_WS_MODE_CTX_POOL,
OPENAI_WS_MODE_DEDICATED OPENAI_WS_MODE_PASSTHROUGH
]) ])
export interface ResolveOpenAIWSModeOptions { export interface ResolveOpenAIWSModeOptions {
...@@ -23,6 +23,9 @@ export interface ResolveOpenAIWSModeOptions { ...@@ -23,6 +23,9 @@ export interface ResolveOpenAIWSModeOptions {
export const normalizeOpenAIWSMode = (mode: unknown): OpenAIWSMode | null => { export const normalizeOpenAIWSMode = (mode: unknown): OpenAIWSMode | null => {
if (typeof mode !== 'string') return null if (typeof mode !== 'string') return null
const normalized = mode.trim().toLowerCase() const normalized = mode.trim().toLowerCase()
if (normalized === 'shared' || normalized === 'dedicated') {
return OPENAI_WS_MODE_CTX_POOL
}
if (OPENAI_WS_MODES.has(normalized as OpenAIWSMode)) { if (OPENAI_WS_MODES.has(normalized as OpenAIWSMode)) {
return normalized as OpenAIWSMode return normalized as OpenAIWSMode
} }
...@@ -31,13 +34,22 @@ export const normalizeOpenAIWSMode = (mode: unknown): OpenAIWSMode | null => { ...@@ -31,13 +34,22 @@ export const normalizeOpenAIWSMode = (mode: unknown): OpenAIWSMode | null => {
export const openAIWSModeFromEnabled = (enabled: unknown): OpenAIWSMode | null => { export const openAIWSModeFromEnabled = (enabled: unknown): OpenAIWSMode | null => {
if (typeof enabled !== 'boolean') return null if (typeof enabled !== 'boolean') return null
return enabled ? OPENAI_WS_MODE_SHARED : OPENAI_WS_MODE_OFF return enabled ? OPENAI_WS_MODE_CTX_POOL : OPENAI_WS_MODE_OFF
} }
export const isOpenAIWSModeEnabled = (mode: OpenAIWSMode): boolean => { export const isOpenAIWSModeEnabled = (mode: OpenAIWSMode): boolean => {
return mode !== OPENAI_WS_MODE_OFF return mode !== OPENAI_WS_MODE_OFF
} }
export const resolveOpenAIWSModeConcurrencyHintKey = (
mode: OpenAIWSMode
): 'admin.accounts.openai.wsModeConcurrencyHint' | 'admin.accounts.openai.wsModePassthroughHint' => {
if (mode === OPENAI_WS_MODE_PASSTHROUGH) {
return 'admin.accounts.openai.wsModePassthroughHint'
}
return 'admin.accounts.openai.wsModeConcurrencyHint'
}
export const resolveOpenAIWSModeFromExtra = ( export const resolveOpenAIWSModeFromExtra = (
extra: Record<string, unknown> | null | undefined, extra: Record<string, unknown> | null | undefined,
options: ResolveOpenAIWSModeOptions options: ResolveOpenAIWSModeOptions
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment