diff --git a/pkg/providers/oauth/codex_provider.go b/pkg/providers/oauth/codex_provider.go index 88da42af7..32ebcdb11 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" @@ -9,6 +10,7 @@ import ( "github.com/openai/openai-go/v3" "github.com/openai/openai-go/v3/option" "github.com/openai/openai-go/v3/responses" + "github.com/openai/openai-go/v3/shared" "github.com/sipeed/picoclaw/pkg/auth" "github.com/sipeed/picoclaw/pkg/logger" @@ -105,6 +107,7 @@ func (p *CodexProvider) Chat( var resp *responses.Response var streamText strings.Builder + var streamToolCalls []ToolCall for stream.Next() { evt := stream.Current() if evt.Type == "response.output_text.done" { @@ -114,6 +117,12 @@ func (p *CodexProvider) Chat( streamText.WriteString(textDone.Text) } } + if evt.Type == "response.output_item.done" { + done := evt.AsResponseOutputItemDone() + if tc, ok := codexToolCallFromOutputItem(done.Item); ok { + streamToolCalls = append(streamToolCalls, tc) + } + } if evt.Type == "response.completed" || evt.Type == "response.failed" || evt.Type == "response.incomplete" { evtResp := evt.Response if evtResp.ID != "" { @@ -165,9 +174,44 @@ func (p *CodexProvider) Chat( if parsed.Content == "" && len(parsed.ToolCalls) == 0 && streamText.Len() > 0 { parsed.Content = streamText.String() } + if len(parsed.ToolCalls) == 0 && len(streamToolCalls) > 0 { + parsed.ToolCalls = streamToolCalls + parsed.FinishReason = "tool_calls" + } return parsed, nil } +func codexToolCallFromOutputItem(item responses.ResponseOutputItemUnion) (ToolCall, bool) { + if item.Type != "function_call" { + return ToolCall{}, false + } + + call := item.AsFunctionCall() + if call.Name == "" { + return ToolCall{}, false + } + + var args map[string]any + if err := json.Unmarshal([]byte(call.Arguments), &args); err != nil { + args = map[string]any{"raw": call.Arguments} + } + + id := call.CallID + if id == "" { + id = call.ID + } + + return ToolCall{ + ID: id, + Name: call.Name, + Arguments: args, + Function: &FunctionCall{ + Name: call.Name, + Arguments: call.Arguments, + }, + }, true +} + func (p *CodexProvider) GetDefaultModel() string { return codexDefaultModel } @@ -229,6 +273,9 @@ func buildCodexParams( OfInputItemList: inputItems, }, Store: openai.Opt(false), + Reasoning: shared.ReasoningParam{ + Effort: shared.ReasoningEffortNone, + }, } if instructions != "" { diff --git a/pkg/providers/oauth/codex_provider_test.go b/pkg/providers/oauth/codex_provider_test.go index aeeb18360..7b13f0365 100644 --- a/pkg/providers/oauth/codex_provider_test.go +++ b/pkg/providers/oauth/codex_provider_test.go @@ -374,6 +374,83 @@ func TestCodexProvider_ChatRoundTrip(t *testing.T) { } } +func TestCodexProvider_ChatRoundTrip_ToolCallFromStreamItem(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 + } + + var reqBody map[string]any + if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil { + http.Error(w, "invalid json", http.StatusBadRequest) + return + } + if reqBody["stream"] != true { + http.Error(w, "stream must be true", http.StatusBadRequest) + return + } + + w.Header().Set("Content-Type", "text/event-stream") + itemDone := map[string]any{ + "type": "response.output_item.done", + "sequence_number": 1, + "output_index": 0, + "item": map[string]any{ + "id": "fc_1", + "type": "function_call", + "call_id": "call_abc", + "name": "nutritiondb__list_weight_entries", + "arguments": `{"limit":5}`, + "status": "completed", + }, + } + b, _ := json.Marshal(itemDone) + fmt.Fprintf(w, "event: response.output_item.done\n") + fmt.Fprintf(w, "data: %s\n\n", string(b)) + + resp := map[string]any{ + "id": "resp_test", + "object": "response", + "status": "completed", + "output": []map[string]any{}, + "usage": map[string]any{ + "input_tokens": 10, + "output_tokens": 5, + "total_tokens": 15, + "input_tokens_details": map[string]any{"cached_tokens": 0}, + "output_tokens_details": map[string]any{"reasoning_tokens": 0}, + }, + } + writeCompletedSSE(w, resp) + })) + defer server.Close() + + provider := NewCodexProvider("test-token", "acc-123") + provider.client = createOpenAITestClient(server.URL, "test-token", "acc-123") + + resp, err := provider.Chat(t.Context(), []Message{{Role: "user", Content: "latest weights"}}, nil, "gpt-5.4", nil) + if err != nil { + t.Fatalf("Chat() error: %v", err) + } + if len(resp.ToolCalls) != 1 { + t.Fatalf("len(ToolCalls) = %d, want 1", len(resp.ToolCalls)) + } + tc := resp.ToolCalls[0] + if tc.ID != "call_abc" { + t.Errorf("ToolCall.ID = %q, want call_abc", tc.ID) + } + if tc.Name != "nutritiondb__list_weight_entries" { + t.Errorf("ToolCall.Name = %q, want nutritiondb__list_weight_entries", tc.Name) + } + if tc.Arguments["limit"] != float64(5) { + t.Errorf("ToolCall.Arguments[limit] = %v, want 5", tc.Arguments["limit"]) + } + if resp.FinishReason != "tool_calls" { + t.Errorf("FinishReason = %q, want tool_calls", resp.FinishReason) + } +} + func TestCodexProvider_ChatRoundTrip_WebSearchDisabled(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/responses" {