fix codex oauth streamed tool calls
This commit is contained in:
parent
5c96dc9005
commit
1627fd0240
2 changed files with 124 additions and 0 deletions
|
|
@ -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 != "" {
|
||||
|
|
|
|||
|
|
@ -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" {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue