diff --git a/deploy/README.md b/deploy/README.md index ee4650f0e..7d93a8e58 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -335,7 +335,7 @@ SSE 注释保活用于防止传输链路空闲断开,不会重置 Codex 等待 管理端生成的 CC Switch 一键导入包含连接信息和当前 Key 的日/周额度查询,默认每 30 分钟刷新。 查询地址和凭据随导入生成,在 CC Switch 中修改 Provider 的地址或 Key 后,需重新导入以同步用量查询。 -导入不包含下方模板的远程压缩、原生生图和 WebSocket 设置。需要这些设置时,使用下方配置直连 CPR, +导入不包含下方模板的远程压缩、原生生图、WebSocket 和语音设置。需要这些设置时,使用下方配置直连 CPR, 避免 CC Switch 切换或接管后重写 Provider 配置 Linux/macOS 默认目录为 `~/.codex/`,Windows 为 `%USERPROFILE%\.codex\`; @@ -352,6 +352,8 @@ model = "gpt-5.6-terra" review_model = "gpt-5.6-terra" model_reasoning_effort = "max" service_tier = "default" +# 语音通话控制地址 +experimental_realtime_ws_base_url = "http://127.0.0.1:8080/v1" [model_providers.OpenAI] name = "OpenAI" @@ -359,15 +361,15 @@ base_url = "http://127.0.0.1:8080/v1" wire_api = "responses" supports_websockets = false requires_openai_auth = false -# 填写代理密钥,真实账号由服务端管理。 +# 代理密钥 experimental_bearer_token = "" [model_providers.OpenAI.capabilities] -# Codex 0.162.0+ 显式启用 V2 远程压缩,不依赖 Provider 名称识别 +# V2 远程压缩 remote_compaction = "v2" [model_providers.OpenAI.http_headers] -# 供客户端识别服务端托管认证,本身不是密钥。 +# 服务端托管认证 X-OpenAI-Actor-Authorization = "proxy-managed" [features] @@ -401,6 +403,20 @@ V2 远程压缩通过 Responses 接口发送 `compaction_trigger`,网关和上 Codex OAuth 账号的上游传输方式默认 WS;固定为 SSE 的账号不承接必须依赖 WS 的预热及连接内续接。 仅使用这类账号时,客户端保持 `supports_websockets = false`,避免先尝试 WS 再回退 +### 语音通话 + +Codex 0.164.0 的 `/voice` 通过 Provider 的 `base_url` 创建通话,控制连接另用顶层 +`experimental_realtime_ws_base_url`,不会自动继承 Provider 地址。两者应指向同一网关的 `/v1`, +使用相同的代理密钥,并确保反向代理允许 WebSocket Upgrade。此设置独立于 Responses 的 +`supports_websockets` 开关 + +已有配置需把 `experimental_realtime_ws_base_url` 合并到第一个 TOML 表之前,修改后完全退出并重启 +Codex。创建通话成功后仍报 `realtime websocket handshake failed` 时,先检查客户端日志中的 +`connecting realtime websocket` 是否指向网关;若仍为 `api.openai.com`,说明控制连接未经过代理 + +语音使用具备上游权限的 OAuth 账号,音频流由客户端与上游直接传输,路由与限制见 +[Codex Live 语音通话](../docs/api.md#codex-live-语音通话) + ### 时区与客户端环境信息 要覆盖模型请求中的位置和时区,在管理端启用全局自定义位置,或为账号关联的代理设置位置; diff --git a/frontend/src/utils/client.ts b/frontend/src/utils/client.ts index d3715ed85..ce9589cf8 100644 --- a/frontend/src/utils/client.ts +++ b/frontend/src/utils/client.ts @@ -17,6 +17,8 @@ model = "${CODEX_DEFAULT_MODEL}" review_model = "${CODEX_DEFAULT_MODEL}" model_reasoning_effort = "max" service_tier = "default" +# 语音通话控制地址 +experimental_realtime_ws_base_url = ${JSON.stringify(baseUrl)} [model_providers.OpenAI] name = "OpenAI" @@ -25,15 +27,15 @@ model_catalog_url = ${JSON.stringify(`${baseUrl}/models`)} wire_api = "responses" supports_websockets = ${websocketEnabled} requires_openai_auth = false -# 代理密钥仅用于网关鉴权,真实账号登录状态由服务端管理 +# 代理密钥 experimental_bearer_token = ${JSON.stringify(input.apiKey)} [model_providers.OpenAI.capabilities] -# Codex 0.162.0+ 显式启用 V2 远程压缩,不依赖 Provider 名称识别 +# V2 远程压缩 remote_compaction = "v2" [model_providers.OpenAI.http_headers] -# 声明服务端托管认证,让官方客户端启用原生生图,该标记不是密钥 +# 服务端托管认证 X-OpenAI-Actor-Authorization = "proxy-managed" [features] diff --git a/frontend/src/views/keys/components/ApiKeyActions.vue b/frontend/src/views/keys/components/ApiKeyActions.vue index 9d931a381..7908175d8 100644 --- a/frontend/src/views/keys/components/ApiKeyActions.vue +++ b/frontend/src/views/keys/components/ApiKeyActions.vue @@ -12,6 +12,7 @@ defineProps<{ deleting: boolean updatingStatus: boolean revealing: boolean + configuring: boolean }>() const emit = defineEmits<{ @@ -38,7 +39,7 @@ const { t } = useI18n({ useScope: 'global' }) diff --git a/frontend/src/views/keys/components/ApiKeyPrefixCell.vue b/frontend/src/views/keys/components/ApiKeyPrefixCell.vue index c2c6e43c2..4f4c52775 100644 --- a/frontend/src/views/keys/components/ApiKeyPrefixCell.vue +++ b/frontend/src/views/keys/components/ApiKeyPrefixCell.vue @@ -8,6 +8,7 @@ import { useI18n } from 'vue-i18n' defineProps<{ prefix: string revealing: boolean + copying: boolean }>() const emit = defineEmits<{ @@ -25,7 +26,7 @@ const { t } = useI18n({ useScope: 'global' }) diff --git a/frontend/src/views/keys/composables/useApiKeyUse.ts b/frontend/src/views/keys/composables/useApiKeyUse.ts index 2eb1cbb60..1c41f823d 100644 --- a/frontend/src/views/keys/composables/useApiKeyUse.ts +++ b/frontend/src/views/keys/composables/useApiKeyUse.ts @@ -2,16 +2,17 @@ import type { ApiKey } from '@/api' import { onScopeDispose, shallowRef } from 'vue' import { revealApiKey } from '@/api' import { useCopyText } from '@/composables/useCopyText' -import { useIdSet } from '@/composables/useIdSet' import { useRequestState } from '@/composables/useRequestState' import { t } from '@/i18n' import { buildCodexCcSwitchImportDeeplink, resolveServiceRootUrl } from '@/utils/client' import { notification as ZNotification } from '@/utils/notification' +type KeyRevealAction = 'copy' | 'use' | 'import' + // 明文读取、复制、展示与导入共用生命周期,列表数据不保存明文。 export function useApiKeyUse() { const copyText = useCopyText() - const revealingKeys = useIdSet() + const revealingKeys = shallowRef(new Map()) const revealRequest = useRequestState() const showUseKeyModal = shallowRef(false) const selectedUseKey = shallowRef<(ApiKey & { key: string }) | null>(null) @@ -41,7 +42,7 @@ export function useApiKeyUse() { } function openUseKeyModal(apiKey: ApiKey) { - return withPlaintextKey(apiKey, (key) => { + return withPlaintextKey(apiKey, 'use', (key) => { selectedUseKey.value = { ...apiKey, key } showUseKeyModal.value = true }) @@ -57,7 +58,7 @@ export function useApiKeyUse() { const apiKey = selectedImportKey.value if (!apiKey) return - return withPlaintextKey(apiKey, (key) => { + return withPlaintextKey(apiKey, 'import', (key) => { window.location.href = buildCodexCcSwitchImportDeeplink({ apiKey: key, baseUrl: openAiBaseUrl, @@ -73,14 +74,15 @@ export function useApiKeyUse() { }) } - async function withPlaintextKey(apiKey: ApiKey, use: (key: string) => void | Promise) { - if (revealingKeys.has(apiKey.id)) + async function withPlaintextKey(apiKey: ApiKey, action: KeyRevealAction, use: (key: string) => void | Promise) { + if (revealingKeys.value.has(apiKey.id)) return // 明文操作共用一个当前意图,跨行点击和页面离开都使旧结果失效 const requestId = revealRequest.start() + revealingKeys.value = new Map(revealingKeys.value).set(apiKey.id, action) try { - const result = await revealingKeys.run(apiKey.id, () => revealApiKey({ id: apiKey.id }, { signal: revealRequest.signal })) - if (!result || !revealRequest.isCurrent(requestId)) + const result = await revealApiKey({ id: apiKey.id }, { signal: revealRequest.signal }) + if (!revealRequest.isCurrent(requestId)) return if (!result.plaintextKey) { ZNotification.error({ @@ -94,12 +96,15 @@ export function useApiKeyUse() { revealRequest.fail(requestId, error) } finally { + const pendingKeys = new Map(revealingKeys.value) + pendingKeys.delete(apiKey.id) + revealingKeys.value = pendingKeys revealRequest.finish(requestId) } } function copyApiKey(apiKey: ApiKey) { - return withPlaintextKey(apiKey, copyToClipboard) + return withPlaintextKey(apiKey, 'copy', copyToClipboard) } function clearCreatedKey() { @@ -120,7 +125,7 @@ export function useApiKeyUse() { createdKey, showImportKeyModal, selectedImportKey, - revealingKeyIds: revealingKeys.ids, + revealingKeys, openAiBaseUrl, showCreatedKey, clearCreatedKey, diff --git a/frontend/src/views/keys/index.vue b/frontend/src/views/keys/index.vue index 268e3a1a9..032038a36 100644 --- a/frontend/src/views/keys/index.vue +++ b/frontend/src/views/keys/index.vue @@ -61,7 +61,7 @@ const { createdKey, showImportKeyModal, selectedImportKey, - revealingKeyIds, + revealingKeys, openAiBaseUrl, showCreatedKey, clearCreatedKey, @@ -164,7 +164,8 @@ watch( @@ -200,7 +201,8 @@ watch(