fix codex oauth streamed tool calls

This commit is contained in:
Anton Bogdanovich 2026-05-03 19:55:37 -07:00
parent 5c96dc9005
commit 1627fd0240
2 changed files with 124 additions and 0 deletions

View file

@ -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 != "" {

View file

@ -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" {