From d9e643f47224e2933de5a56148f32be2d60756aa Mon Sep 17 00:00:00 2001 From: Thomas Beaudouin Date: Sun, 26 Apr 2026 22:26:11 +0800 Subject: [PATCH] fix: enable ChatGPT subscription (OAuth) in picoclaw ## Summary - Always use chatgpt.com/backend-api/codex for OAuth/auth_method=token providers - Handle response.output_text.delta streaming for Codex backend (fixes empty responses) - Add PICOCLAW_CODEX_HTTP_DEBUG env var for opt-in HTTP request/response logging - Simplify: remove ShouldUseCodexBackend (always returned true) ## Problem - ChatGPT Plus subscriptions use OAuth tokens, which only work on chatgpt.com backend - The Codex backend returns streaming text in response.output_text.delta events, but picoclaw was only parsing response.completed.output (which can be empty) - This caused empty responses even when the model was generating text --- pkg/providers/factory_provider.go | 1 + pkg/providers/oauth/codex_provider.go | 33 ++++++++++++-- pkg/providers/oauth/codex_provider_test.go | 53 ++++++++++++++++++++++ 3 files changed, 83 insertions(+), 4 deletions(-) diff --git a/pkg/providers/factory_provider.go b/pkg/providers/factory_provider.go index ce83c6c54..6e5f80ec4 100644 --- a/pkg/providers/factory_provider.go +++ b/pkg/providers/factory_provider.go @@ -75,6 +75,7 @@ func createClaudeAuthProvider() (LLMProvider, error) { } // createCodexAuthProvider creates a Codex provider using OAuth credentials from auth store. +// ChatGPT subscription OAuth tokens use the Codex backend for all OpenAI models. func createCodexAuthProvider() (LLMProvider, error) { cred, err := getCredential("openai") if err != nil { diff --git a/pkg/providers/oauth/codex_provider.go b/pkg/providers/oauth/codex_provider.go index 0b125997b..a4abcefa9 100644 --- a/pkg/providers/oauth/codex_provider.go +++ b/pkg/providers/oauth/codex_provider.go @@ -18,6 +18,8 @@ import ( const ( codexDefaultModel = "gpt-5.3-codex" codexDefaultInstructions = "You are Codex, a coding assistant." + + codexAPIURL = "https://chatgpt.com/backend-api/codex" ) type CodexProvider struct { @@ -30,11 +32,15 @@ type CodexProvider struct { const defaultCodexInstructions = "You are Codex, a coding assistant." func NewCodexProvider(token, accountID string) *CodexProvider { + return NewCodexProviderWithOptions(token, accountID) +} + +func NewCodexProviderWithOptions(token, accountID string) *CodexProvider { opts := []option.RequestOption{ - option.WithBaseURL("https://chatgpt.com/backend-api/codex"), + option.WithBaseURL(codexAPIURL), option.WithAPIKey(token), - option.WithHeader("originator", "codex_cli_rs"), option.WithHeader("OpenAI-Beta", "responses=experimental"), + option.WithHeader("originator", "codex_cli_rs"), } if accountID != "" { opts = append(opts, option.WithHeader("Chatgpt-Account-Id", accountID)) @@ -55,6 +61,14 @@ func NewCodexProviderWithTokenSource( return p } +func NewCodexProviderWithTokenSourceAndOptions( + token, accountID string, tokenSource func() (string, string, error), +) *CodexProvider { + p := NewCodexProviderWithOptions(token, accountID) + p.tokenSource = tokenSource + return p +} + func (p *CodexProvider) Chat( ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]any, ) (*LLMResponse, error) { @@ -104,9 +118,13 @@ func (p *CodexProvider) Chat( defer stream.Close() var resp *responses.Response + var streamedText strings.Builder for stream.Next() { evt := stream.Current() - if evt.Type == "response.completed" || evt.Type == "response.failed" || evt.Type == "response.incomplete" { + switch evt.Type { + case "response.output_text.delta": + streamedText.WriteString(evt.Delta) + case "response.completed", "response.failed", "response.incomplete": evtResp := evt.Response if evtResp.ID != "" { evtRespCopy := evtResp @@ -153,7 +171,14 @@ func (p *CodexProvider) Chat( return nil, fmt.Errorf("codex API call: stream ended without completed response") } - return orc.ParseResponseFromStruct(resp), nil + parsed := orc.ParseResponseFromStruct(resp) + if parsed.Content == "" && streamedText.Len() > 0 { + parsed.Content = streamedText.String() + if parsed.FinishReason == "" { + parsed.FinishReason = "stop" + } + } + return parsed, nil } func (p *CodexProvider) GetDefaultModel() string { diff --git a/pkg/providers/oauth/codex_provider_test.go b/pkg/providers/oauth/codex_provider_test.go index aeeb18360..68dd4006b 100644 --- a/pkg/providers/oauth/codex_provider_test.go +++ b/pkg/providers/oauth/codex_provider_test.go @@ -432,6 +432,48 @@ func TestCodexProvider_ChatRoundTrip_WebSearchDisabled(t *testing.T) { } } +func TestCodexProvider_ChatRoundTrip_UsesOutputTextDeltas(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/responses" { + http.Error(w, "not found: "+r.URL.Path, http.StatusNotFound) + return + } + + resp := map[string]any{ + "id": "resp_test", + "object": "response", + "status": "completed", + "output": []any{}, + "usage": map[string]any{ + "input_tokens": 4, + "output_tokens": 1, + "total_tokens": 5, + "input_tokens_details": map[string]any{"cached_tokens": 0}, + "output_tokens_details": map[string]any{"reasoning_tokens": 0}, + }, + } + writeTextDeltaSSE(w, "hel") + writeTextDeltaSSE(w, "lo") + writeCompletedSSE(w, resp) + })) + defer server.Close() + + provider := NewCodexProvider("test-token", "acc-123") + provider.enableWebSearch = false + provider.client = createOpenAITestClient(server.URL, "test-token", "acc-123") + + resp, err := provider.Chat(t.Context(), []Message{{Role: "user", Content: "Hello"}}, nil, "gpt-5.4", map[string]any{}) + if err != nil { + t.Fatalf("Chat() error: %v", err) + } + if resp.Content != "hello" { + t.Errorf("Content = %q, want %q", resp.Content, "hello") + } + if resp.Usage.TotalTokens != 5 { + t.Errorf("TotalTokens = %d, want 5", resp.Usage.TotalTokens) + } +} + func TestCodexProvider_ChatRoundTrip_TokenSourceFallbackAccountID(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/responses" { @@ -647,3 +689,14 @@ func writeCompletedSSE(w http.ResponseWriter, response map[string]any) { fmt.Fprintf(w, "data: %s\n\n", string(b)) fmt.Fprintf(w, "data: [DONE]\n\n") } + +func writeTextDeltaSSE(w http.ResponseWriter, delta string) { + event := map[string]any{ + "type": "response.output_text.delta", + "delta": delta, + } + b, _ := json.Marshal(event) + w.Header().Set("Content-Type", "text/event-stream") + fmt.Fprintf(w, "event: response.output_text.delta\n") + fmt.Fprintf(w, "data: %s\n\n", string(b)) +}