Merge 8af6459b52 into 412705783d
This commit is contained in:
commit
87088d7239
3 changed files with 160 additions and 4 deletions
|
|
@ -78,6 +78,7 @@ func createClaudeAuthProvider() (LLMProvider, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// createCodexAuthProvider creates a Codex provider using OAuth credentials from auth store.
|
// 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) {
|
func createCodexAuthProvider() (LLMProvider, error) {
|
||||||
cred, err := getCredential("openai")
|
cred, err := getCredential("openai")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package oauthprovider
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
@ -13,11 +14,14 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/auth"
|
"github.com/sipeed/picoclaw/pkg/auth"
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
orc "github.com/sipeed/picoclaw/pkg/providers/openai_responses_common"
|
orc "github.com/sipeed/picoclaw/pkg/providers/openai_responses_common"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
codexDefaultModel = "gpt-5.3-codex"
|
codexDefaultModel = "gpt-5.3-codex"
|
||||||
codexDefaultInstructions = "You are Codex, a coding assistant."
|
codexDefaultInstructions = "You are Codex, a coding assistant."
|
||||||
|
|
||||||
|
codexAPIURL = "https://chatgpt.com/backend-api/codex"
|
||||||
)
|
)
|
||||||
|
|
||||||
type CodexProvider struct {
|
type CodexProvider struct {
|
||||||
|
|
@ -30,11 +34,15 @@ type CodexProvider struct {
|
||||||
const defaultCodexInstructions = "You are Codex, a coding assistant."
|
const defaultCodexInstructions = "You are Codex, a coding assistant."
|
||||||
|
|
||||||
func NewCodexProvider(token, accountID string) *CodexProvider {
|
func NewCodexProvider(token, accountID string) *CodexProvider {
|
||||||
|
return NewCodexProviderWithOptions(token, accountID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCodexProviderWithOptions(token, accountID string) *CodexProvider {
|
||||||
opts := []option.RequestOption{
|
opts := []option.RequestOption{
|
||||||
option.WithBaseURL("https://chatgpt.com/backend-api/codex"),
|
option.WithBaseURL(codexAPIURL),
|
||||||
option.WithAPIKey(token),
|
option.WithAPIKey(token),
|
||||||
option.WithHeader("originator", "codex_cli_rs"),
|
|
||||||
option.WithHeader("OpenAI-Beta", "responses=experimental"),
|
option.WithHeader("OpenAI-Beta", "responses=experimental"),
|
||||||
|
option.WithHeader("originator", "codex_cli_rs"),
|
||||||
}
|
}
|
||||||
if accountID != "" {
|
if accountID != "" {
|
||||||
opts = append(opts, option.WithHeader("Chatgpt-Account-Id", accountID))
|
opts = append(opts, option.WithHeader("Chatgpt-Account-Id", accountID))
|
||||||
|
|
@ -55,6 +63,14 @@ func NewCodexProviderWithTokenSource(
|
||||||
return p
|
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(
|
func (p *CodexProvider) Chat(
|
||||||
ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]any,
|
ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]any,
|
||||||
) (*LLMResponse, error) {
|
) (*LLMResponse, error) {
|
||||||
|
|
@ -104,9 +120,55 @@ func (p *CodexProvider) Chat(
|
||||||
defer stream.Close()
|
defer stream.Close()
|
||||||
|
|
||||||
var resp *responses.Response
|
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() {
|
for stream.Next() {
|
||||||
evt := stream.Current()
|
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
|
evtResp := evt.Response
|
||||||
if evtResp.ID != "" {
|
if evtResp.ID != "" {
|
||||||
evtRespCopy := evtResp
|
evtRespCopy := evtResp
|
||||||
|
|
@ -153,7 +215,40 @@ func (p *CodexProvider) Chat(
|
||||||
return nil, fmt.Errorf("codex API call: stream ended without completed response")
|
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 {
|
func (p *CodexProvider) GetDefaultModel() string {
|
||||||
|
|
@ -240,6 +335,13 @@ func buildCodexParams(
|
||||||
return params
|
return params
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type streamedToolCall struct {
|
||||||
|
key string
|
||||||
|
callID string
|
||||||
|
name string
|
||||||
|
arguments string
|
||||||
|
}
|
||||||
|
|
||||||
func CreateCodexTokenSource() func() (string, string, error) {
|
func CreateCodexTokenSource() func() (string, string, error) {
|
||||||
return func() (string, string, error) {
|
return func() (string, string, error) {
|
||||||
cred, err := auth.GetCredential("openai")
|
cred, err := auth.GetCredential("openai")
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
func TestCodexProvider_ChatRoundTrip_TokenSourceFallbackAccountID(t *testing.T) {
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.URL.Path != "/responses" {
|
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: %s\n\n", string(b))
|
||||||
fmt.Fprintf(w, "data: [DONE]\n\n")
|
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))
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue