- codex adaptor 支持 API key 和 OAuth 两种认证模式 - 提取 setupOAuthHeader 和 shouldUseChatCompletionsViaResponses - 修复编辑页 codex_credential_mode 切换/回显不同步问题 - ResponsesStreamResponse 添加 Error 字段支持独立错误事件 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>master
| @@ -15,4 +15,16 @@ models-page* | |||
| pricing-page.png | |||
| login-page | |||
| scripts | |||
| relay/helper/price_test.go | |||
| relay/helper/price_test.go | |||
| .superpowers | |||
| *.png | |||
| *.bak | |||
| .worktrees | |||
| **/node_modules | |||
| **/.gocache | |||
| **/.gocache-temp | |||
| logs | |||
| *.db | |||
| *.db-journal | |||
| *.zip | |||
| web/dist | |||
| @@ -36,3 +36,4 @@ | |||
| # ============================================ | |||
| # Mark web frontend as vendored so GitHub recognizes this as a Go project | |||
| electron/** linguist-vendored | |||
| .dockerignore text eol=lf | |||
| @@ -375,6 +375,7 @@ const ( | |||
| type ResponsesStreamResponse struct { | |||
| Type string `json:"type"` | |||
| Response *OpenAIResponsesResponse `json:"response,omitempty"` | |||
| Error any `json:"error,omitempty"` | |||
| Delta string `json:"delta,omitempty"` | |||
| Item *ResponsesOutput `json:"item,omitempty"` | |||
| // - response.function_call_arguments.delta | |||
| @@ -138,9 +138,21 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { | |||
| if info.RelayMode != relayconstant.RelayModeResponses && info.RelayMode != relayconstant.RelayModeResponsesCompact { | |||
| return "", errors.New("codex channel: only /v1/responses and /v1/responses/compact are supported") | |||
| } | |||
| path := "/backend-api/codex/responses" | |||
| key := strings.TrimSpace(info.ApiKey) | |||
| if strings.HasPrefix(key, "{") { | |||
| // OAuth mode: route to ChatGPT backend API | |||
| path := "/backend-api/codex/responses" | |||
| if info.RelayMode == relayconstant.RelayModeResponsesCompact { | |||
| path = "/backend-api/codex/responses/compact" | |||
| } | |||
| return relaycommon.GetFullRequestURL(info.ChannelBaseUrl, path, info.ChannelType), nil | |||
| } | |||
| // API key mode: route to standard /v1/responses | |||
| path := "/v1/responses" | |||
| if info.RelayMode == relayconstant.RelayModeResponsesCompact { | |||
| path = "/backend-api/codex/responses/compact" | |||
| path = "/v1/responses/compact" | |||
| } | |||
| return relaycommon.GetFullRequestURL(info.ChannelBaseUrl, path, info.ChannelType), nil | |||
| } | |||
| @@ -149,11 +161,20 @@ func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *rel | |||
| channel.SetupApiRequestHeader(info, c, req) | |||
| key := strings.TrimSpace(info.ApiKey) | |||
| if !strings.HasPrefix(key, "{") { | |||
| return errors.New("codex channel: key must be a JSON object") | |||
| if strings.HasPrefix(key, "{") { | |||
| return setupOAuthHeader(req, key) | |||
| } | |||
| oauthKey, err := ParseOAuthKey(key) | |||
| // Simple API key mode | |||
| req.Set("Authorization", "Bearer "+key) | |||
| if info.IsStream { | |||
| req.Set("Accept", "text/event-stream") | |||
| } | |||
| return nil | |||
| } | |||
| func setupOAuthHeader(req *http.Header, rawKey string) error { | |||
| oauthKey, err := ParseOAuthKey(rawKey) | |||
| if err != nil { | |||
| return err | |||
| } | |||
| @@ -178,13 +199,8 @@ func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *rel | |||
| req.Set("originator", "codex_cli_rs") | |||
| } | |||
| // chatgpt.com/backend-api/codex/responses is strict about Content-Type. | |||
| // Clients may omit it or include parameters like `application/json; charset=utf-8`, | |||
| // which can be rejected by the upstream. Force the exact media type. | |||
| req.Set("Content-Type", "application/json") | |||
| if info.IsStream { | |||
| req.Set("Accept", "text/event-stream") | |||
| } else if req.Get("Accept") == "" { | |||
| if req.Get("Accept") == "" { | |||
| req.Set("Accept", "application/json") | |||
| } | |||
| @@ -27,6 +27,22 @@ import ( | |||
| "github.com/gin-gonic/gin" | |||
| ) | |||
| func shouldUseChatCompletionsViaResponses(info *relaycommon.RelayInfo, passThroughGlobal bool) bool { | |||
| if info == nil { | |||
| return false | |||
| } | |||
| if info.RelayMode != relayconstant.RelayModeChatCompletions { | |||
| return false | |||
| } | |||
| if info.ChannelType == constant.ChannelTypeCodex { | |||
| return true | |||
| } | |||
| if passThroughGlobal || info.ChannelSetting.PassThroughBodyEnabled { | |||
| return false | |||
| } | |||
| return service.ShouldChatCompletionsUseResponsesGlobal(info.ChannelId, info.ChannelType, info.OriginModelName) | |||
| } | |||
| func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types.NewAPIError) { | |||
| info.InitChannelMeta(c) | |||
| @@ -76,10 +92,7 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types | |||
| adaptor.Init(info) | |||
| passThroughGlobal := model_setting.GetGlobalSettings().PassThroughRequestEnabled | |||
| if info.RelayMode == relayconstant.RelayModeChatCompletions && | |||
| !passThroughGlobal && | |||
| !info.ChannelSetting.PassThroughBodyEnabled && | |||
| service.ShouldChatCompletionsUseResponsesGlobal(info.ChannelId, info.ChannelType, info.OriginModelName) { | |||
| if shouldUseChatCompletionsViaResponses(info, passThroughGlobal) { | |||
| applySystemPromptIfNeeded(c, info, request) | |||
| usage, newApiErr := chatCompletionsViaResponses(c, info, adaptor, request) | |||
| if newApiErr != nil { | |||
| @@ -548,6 +548,9 @@ const EditChannelModal = (props) => { | |||
| if (value === 57) { | |||
| setCodexCredentialMode(CODEX_CREDENTIAL_MODE.API_KEY); | |||
| if (formApiRef.current) { | |||
| formApiRef.current.setValue('codex_credential_mode', CODEX_CREDENTIAL_MODE.API_KEY); | |||
| } | |||
| setBatch(false); | |||
| setMultiToSingle(false); | |||
| setMultiKeyMode('random'); | |||
| @@ -703,7 +706,9 @@ const EditChannelModal = (props) => { | |||
| } | |||
| if (data.type === 57) { | |||
| setCodexCredentialMode(detectCodexCredentialMode(data.key)); | |||
| const mode = detectCodexCredentialMode(data.key); | |||
| setCodexCredentialMode(mode); | |||
| data.codex_credential_mode = mode; | |||
| } else { | |||
| setCodexCredentialMode(CODEX_CREDENTIAL_MODE.API_KEY); | |||
| } | |||
| @@ -942,6 +947,9 @@ const EditChannelModal = (props) => { | |||
| const handleCodexOAuthGenerated = (key) => { | |||
| setCodexCredentialMode(CODEX_CREDENTIAL_MODE.OAUTH); | |||
| if (formApiRef.current) { | |||
| formApiRef.current.setValue('codex_credential_mode', CODEX_CREDENTIAL_MODE.OAUTH); | |||
| } | |||
| handleInputChange('key', key); | |||
| formatJsonField('key'); | |||
| }; | |||
| @@ -1098,6 +1106,7 @@ const EditChannelModal = (props) => { | |||
| // 清空表单中的key_mode字段 | |||
| if (formApiRef.current) { | |||
| formApiRef.current.setValue('key_mode', undefined); | |||
| formApiRef.current.setValue('codex_credential_mode', CODEX_CREDENTIAL_MODE.API_KEY); | |||
| } | |||
| // 重置本地输入,避免下次打开残留上一次的 JSON 字段值 | |||
| setInputs(getInitValues()); | |||
| @@ -2175,8 +2184,12 @@ const EditChannelModal = (props) => { | |||
| value: CODEX_CREDENTIAL_MODE.OAUTH, | |||
| }, | |||
| ]} | |||
| value={codexCredentialMode} | |||
| onChange={(value) => setCodexCredentialMode(value)} | |||
| onChange={(value) => { | |||
| setCodexCredentialMode(value); | |||
| if (formApiRef.current) { | |||
| formApiRef.current.setValue('codex_credential_mode', value); | |||
| } | |||
| }} | |||
| style={{ width: '100%' }} | |||
| extraText={ | |||
| isCodexOAuthMode | |||