From d9e643f47224e2933de5a56148f32be2d60756aa Mon Sep 17 00:00:00 2001 From: Thomas Beaudouin Date: Sun, 26 Apr 2026 22:26:11 +0800 Subject: [PATCH 1/2] 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)) +} From 8af6459b5222db5c198899f6a10fc16d5c4ac776 Mon Sep 17 00:00:00 2001 From: Thomas Beaudouin Date: Mon, 27 Apr 2026 16:45:35 +0800 Subject: [PATCH 2/2] Handle streamed function/tool calls in Codex provider Parse and assemble streamed function_call events in pkg/providers/oauth/codex_provider.go. Added handling for response.output_item.added, response.function_call_arguments.delta and .done events to accumulate partial tool call info (name, call ID, argument deltas) into an ordered map, then convert them into protocoltypes.ToolCall entries when no tool calls were present in the final parsed response. Introduced a streamedToolCall struct and rememberToolCall helper, added imports (encoding/json, protocoltypes), and fallbacks for argument JSON parsing and finish reason adjustment. --- pkg/providers/oauth/codex_provider.go | 77 +++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/pkg/providers/oauth/codex_provider.go b/pkg/providers/oauth/codex_provider.go index a4abcefa9..b0c13d175 100644 --- a/pkg/providers/oauth/codex_provider.go +++ b/pkg/providers/oauth/codex_provider.go @@ -2,6 +2,7 @@ package oauthprovider import ( "context" + "encoding/json" "errors" "fmt" "strings" @@ -13,6 +14,7 @@ import ( "github.com/sipeed/picoclaw/pkg/auth" "github.com/sipeed/picoclaw/pkg/logger" orc "github.com/sipeed/picoclaw/pkg/providers/openai_responses_common" + "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" ) const ( @@ -119,11 +121,53 @@ func (p *CodexProvider) Chat( var resp *responses.Response var streamedText strings.Builder + streamedToolCalls := map[string]*streamedToolCall{} + streamedToolCallOrder := make([]string, 0, 4) + rememberToolCall := func(key string) *streamedToolCall { + if key == "" { + key = fmt.Sprintf("streamed_tool_call_%d", len(streamedToolCallOrder)) + } + if tc, ok := streamedToolCalls[key]; ok { + return tc + } + tc := &streamedToolCall{key: key} + streamedToolCalls[key] = tc + streamedToolCallOrder = append(streamedToolCallOrder, key) + return tc + } for stream.Next() { evt := stream.Current() switch evt.Type { case "response.output_text.delta": streamedText.WriteString(evt.Delta) + case "response.output_item.added": + item := evt.AsResponseOutputItemAdded().Item + if item.Type == "function_call" { + fc := item.AsFunctionCall() + st := rememberToolCall(fc.ID) + if fc.CallID != "" { + st.callID = fc.CallID + } + if fc.Name != "" { + st.name = fc.Name + } + if fc.Arguments != "" { + st.arguments = fc.Arguments + } + } + case "response.function_call_arguments.delta": + delta := evt.AsResponseFunctionCallArgumentsDelta() + st := rememberToolCall(delta.ItemID) + st.arguments += delta.Delta + case "response.function_call_arguments.done": + done := evt.AsResponseFunctionCallArgumentsDone() + st := rememberToolCall(done.ItemID) + if done.Name != "" { + st.name = done.Name + } + if done.Arguments != "" { + st.arguments = done.Arguments + } case "response.completed", "response.failed", "response.incomplete": evtResp := evt.Response if evtResp.ID != "" { @@ -172,6 +216,32 @@ func (p *CodexProvider) Chat( } parsed := orc.ParseResponseFromStruct(resp) + if len(parsed.ToolCalls) == 0 && len(streamedToolCalls) > 0 { + for _, key := range streamedToolCallOrder { + if tc := streamedToolCalls[key]; tc != nil && tc.name != "" { + arguments := strings.TrimSpace(tc.arguments) + if arguments == "" { + arguments = "{}" + } + var args map[string]any + if err := json.Unmarshal([]byte(arguments), &args); err != nil { + args = map[string]any{"raw": arguments} + } + callID := tc.callID + if callID == "" { + callID = tc.key + } + parsed.ToolCalls = append(parsed.ToolCalls, protocoltypes.ToolCall{ + ID: callID, + Name: tc.name, + Arguments: args, + }) + } + } + if len(parsed.ToolCalls) > 0 && parsed.FinishReason == "" { + parsed.FinishReason = "tool_calls" + } + } if parsed.Content == "" && streamedText.Len() > 0 { parsed.Content = streamedText.String() if parsed.FinishReason == "" { @@ -265,6 +335,13 @@ func buildCodexParams( return params } +type streamedToolCall struct { + key string + callID string + name string + arguments string +} + func CreateCodexTokenSource() func() (string, string, error) { return func() (string, string, error) { cred, err := auth.GetCredential("openai")