diff --git a/pkg/providers/factory_provider.go b/pkg/providers/factory_provider.go index e9e0e6e98..765ff8c1e 100644 --- a/pkg/providers/factory_provider.go +++ b/pkg/providers/factory_provider.go @@ -78,6 +78,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..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,11 +14,14 @@ 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 ( codexDefaultModel = "gpt-5.3-codex" codexDefaultInstructions = "You are Codex, a coding assistant." + + codexAPIURL = "https://chatgpt.com/backend-api/codex" ) type CodexProvider struct { @@ -30,11 +34,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 +63,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 +120,55 @@ func (p *CodexProvider) Chat( defer stream.Close() 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() - 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.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 != "" { evtRespCopy := evtResp @@ -153,7 +215,40 @@ 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 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 == "" { + parsed.FinishReason = "stop" + } + } + return parsed, nil } func (p *CodexProvider) GetDefaultModel() string { @@ -240,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") 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)) +}