- 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 | pricing-page.png | ||||
| login-page | login-page | ||||
| scripts | 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 | # Mark web frontend as vendored so GitHub recognizes this as a Go project | ||||
| electron/** linguist-vendored | electron/** linguist-vendored | ||||
| .dockerignore text eol=lf | |||||
| @@ -375,6 +375,7 @@ const ( | |||||
| type ResponsesStreamResponse struct { | type ResponsesStreamResponse struct { | ||||
| Type string `json:"type"` | Type string `json:"type"` | ||||
| Response *OpenAIResponsesResponse `json:"response,omitempty"` | Response *OpenAIResponsesResponse `json:"response,omitempty"` | ||||
| Error any `json:"error,omitempty"` | |||||
| Delta string `json:"delta,omitempty"` | Delta string `json:"delta,omitempty"` | ||||
| Item *ResponsesOutput `json:"item,omitempty"` | Item *ResponsesOutput `json:"item,omitempty"` | ||||
| // - response.function_call_arguments.delta | // - 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 { | if info.RelayMode != relayconstant.RelayModeResponses && info.RelayMode != relayconstant.RelayModeResponsesCompact { | ||||
| return "", errors.New("codex channel: only /v1/responses and /v1/responses/compact are supported") | 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 { | if info.RelayMode == relayconstant.RelayModeResponsesCompact { | ||||
| path = "/backend-api/codex/responses/compact" | |||||
| path = "/v1/responses/compact" | |||||
| } | } | ||||
| return relaycommon.GetFullRequestURL(info.ChannelBaseUrl, path, info.ChannelType), nil | 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) | channel.SetupApiRequestHeader(info, c, req) | ||||
| key := strings.TrimSpace(info.ApiKey) | 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 { | if err != nil { | ||||
| return err | return err | ||||
| } | } | ||||
| @@ -178,13 +199,8 @@ func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *rel | |||||
| req.Set("originator", "codex_cli_rs") | 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") | 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") | req.Set("Accept", "application/json") | ||||
| } | } | ||||
| @@ -27,6 +27,22 @@ import ( | |||||
| "github.com/gin-gonic/gin" | "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) { | func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types.NewAPIError) { | ||||
| info.InitChannelMeta(c) | info.InitChannelMeta(c) | ||||
| @@ -76,10 +92,7 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types | |||||
| adaptor.Init(info) | adaptor.Init(info) | ||||
| passThroughGlobal := model_setting.GetGlobalSettings().PassThroughRequestEnabled | 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) | applySystemPromptIfNeeded(c, info, request) | ||||
| usage, newApiErr := chatCompletionsViaResponses(c, info, adaptor, request) | usage, newApiErr := chatCompletionsViaResponses(c, info, adaptor, request) | ||||
| if newApiErr != nil { | if newApiErr != nil { | ||||
| @@ -548,6 +548,9 @@ const EditChannelModal = (props) => { | |||||
| if (value === 57) { | if (value === 57) { | ||||
| setCodexCredentialMode(CODEX_CREDENTIAL_MODE.API_KEY); | setCodexCredentialMode(CODEX_CREDENTIAL_MODE.API_KEY); | ||||
| if (formApiRef.current) { | |||||
| formApiRef.current.setValue('codex_credential_mode', CODEX_CREDENTIAL_MODE.API_KEY); | |||||
| } | |||||
| setBatch(false); | setBatch(false); | ||||
| setMultiToSingle(false); | setMultiToSingle(false); | ||||
| setMultiKeyMode('random'); | setMultiKeyMode('random'); | ||||
| @@ -703,7 +706,9 @@ const EditChannelModal = (props) => { | |||||
| } | } | ||||
| if (data.type === 57) { | if (data.type === 57) { | ||||
| setCodexCredentialMode(detectCodexCredentialMode(data.key)); | |||||
| const mode = detectCodexCredentialMode(data.key); | |||||
| setCodexCredentialMode(mode); | |||||
| data.codex_credential_mode = mode; | |||||
| } else { | } else { | ||||
| setCodexCredentialMode(CODEX_CREDENTIAL_MODE.API_KEY); | setCodexCredentialMode(CODEX_CREDENTIAL_MODE.API_KEY); | ||||
| } | } | ||||
| @@ -942,6 +947,9 @@ const EditChannelModal = (props) => { | |||||
| const handleCodexOAuthGenerated = (key) => { | const handleCodexOAuthGenerated = (key) => { | ||||
| setCodexCredentialMode(CODEX_CREDENTIAL_MODE.OAUTH); | setCodexCredentialMode(CODEX_CREDENTIAL_MODE.OAUTH); | ||||
| if (formApiRef.current) { | |||||
| formApiRef.current.setValue('codex_credential_mode', CODEX_CREDENTIAL_MODE.OAUTH); | |||||
| } | |||||
| handleInputChange('key', key); | handleInputChange('key', key); | ||||
| formatJsonField('key'); | formatJsonField('key'); | ||||
| }; | }; | ||||
| @@ -1098,6 +1106,7 @@ const EditChannelModal = (props) => { | |||||
| // 清空表单中的key_mode字段 | // 清空表单中的key_mode字段 | ||||
| if (formApiRef.current) { | if (formApiRef.current) { | ||||
| formApiRef.current.setValue('key_mode', undefined); | formApiRef.current.setValue('key_mode', undefined); | ||||
| formApiRef.current.setValue('codex_credential_mode', CODEX_CREDENTIAL_MODE.API_KEY); | |||||
| } | } | ||||
| // 重置本地输入,避免下次打开残留上一次的 JSON 字段值 | // 重置本地输入,避免下次打开残留上一次的 JSON 字段值 | ||||
| setInputs(getInitValues()); | setInputs(getInitValues()); | ||||
| @@ -2175,8 +2184,12 @@ const EditChannelModal = (props) => { | |||||
| value: CODEX_CREDENTIAL_MODE.OAUTH, | 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%' }} | style={{ width: '100%' }} | ||||
| extraText={ | extraText={ | ||||
| isCodexOAuthMode | isCodexOAuthMode | ||||