Files
goclaw/internal/providers/codex.go
T
viettranx bdb60de7ae chore: upgrade Go 1.25 → 1.26 and apply go fix modernizations
- Update go.mod and Dockerfile to Go 1.26
- Apply `go fix ./...` stdlib modernizations across 170+ files
- Add `go fix` to post-implementation checklist in CLAUDE.md
- Fix go fix misapplied rewrite in loop_history.go
2026-03-10 00:09:15 +07:00

190 lines
4.9 KiB
Go

package providers
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// CodexProvider implements Provider for the OpenAI Responses API,
// used with ChatGPT subscription via OAuth (Codex flow).
// Wire format: POST /codex/responses on chatgpt.com backend.
type CodexProvider struct {
name string
apiBase string // e.g. "https://api.openai.com/v1" or "https://chatgpt.com/backend-api"
defaultModel string
client *http.Client
retryConfig RetryConfig
tokenSource TokenSource
}
// NewCodexProvider creates a provider for the OpenAI Responses API with OAuth token.
func NewCodexProvider(name string, tokenSource TokenSource, apiBase, defaultModel string) *CodexProvider {
if apiBase == "" {
apiBase = "https://chatgpt.com/backend-api"
}
apiBase = strings.TrimRight(apiBase, "/")
if defaultModel == "" {
defaultModel = "gpt-5.3-codex"
}
return &CodexProvider{
name: name,
apiBase: apiBase,
defaultModel: defaultModel,
client: &http.Client{Timeout: 300 * time.Second},
retryConfig: DefaultRetryConfig(),
tokenSource: tokenSource,
}
}
func (p *CodexProvider) Name() string { return p.name }
func (p *CodexProvider) DefaultModel() string { return p.defaultModel }
func (p *CodexProvider) SupportsThinking() bool { return true }
func (p *CodexProvider) Chat(ctx context.Context, req ChatRequest) (*ChatResponse, error) {
// Codex Responses API requires stream=true; delegate to ChatStream with no chunk handler.
return p.ChatStream(ctx, req, nil)
}
func (p *CodexProvider) ChatStream(ctx context.Context, req ChatRequest, onChunk func(StreamChunk)) (*ChatResponse, error) {
body := p.buildRequestBody(req, true)
respBody, err := RetryDo(ctx, p.retryConfig, func() (io.ReadCloser, error) {
return p.doRequest(ctx, body)
})
if err != nil {
return nil, err
}
defer respBody.Close()
result := &ChatResponse{FinishReason: "stop"}
toolCalls := make(map[string]*codexToolCallAcc) // keyed by item_id
scanner := bufio.NewScanner(respBody)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for scanner.Scan() {
line := scanner.Text()
if !strings.HasPrefix(line, "data:") {
continue
}
data := strings.TrimPrefix(line, "data:")
data = strings.TrimPrefix(data, " ")
if data == "[DONE]" {
break
}
var event codexSSEEvent
if err := json.Unmarshal([]byte(data), &event); err != nil {
continue
}
p.processSSEEvent(&event, result, toolCalls, onChunk)
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("%s: stream read error: %w", p.name, err)
}
// Build tool calls from accumulators
for _, acc := range toolCalls {
if acc.name == "" {
continue
}
args := make(map[string]any)
_ = json.Unmarshal([]byte(acc.rawArgs), &args)
result.ToolCalls = append(result.ToolCalls, ToolCall{
ID: acc.callID,
Name: acc.name,
Arguments: args,
})
}
if len(result.ToolCalls) > 0 {
result.FinishReason = "tool_calls"
}
if onChunk != nil {
onChunk(StreamChunk{Done: true})
}
return result, nil
}
// processSSEEvent handles a single SSE event during streaming.
func (p *CodexProvider) processSSEEvent(event *codexSSEEvent, result *ChatResponse, toolCalls map[string]*codexToolCallAcc, onChunk func(StreamChunk)) {
switch event.Type {
case "response.output_text.delta":
if event.Delta != "" {
result.Content += event.Delta
if onChunk != nil {
onChunk(StreamChunk{Content: event.Delta})
}
}
case "response.function_call_arguments.delta":
if event.ItemID != "" {
acc := toolCalls[event.ItemID]
if acc == nil {
acc = &codexToolCallAcc{}
toolCalls[event.ItemID] = acc
}
acc.rawArgs += event.Delta
}
case "response.output_item.done":
if event.Item != nil {
switch event.Item.Type {
case "message":
if event.Item.Phase != "" {
result.Phase = event.Item.Phase
}
case "function_call":
acc := toolCalls[event.Item.ID]
if acc == nil {
acc = &codexToolCallAcc{}
}
acc.callID = event.Item.CallID
acc.name = event.Item.Name
if event.Item.Arguments != "" {
acc.rawArgs = event.Item.Arguments
}
toolCalls[event.Item.ID] = acc
case "reasoning":
for _, s := range event.Item.Summary {
if s.Text != "" {
result.Thinking += s.Text
if onChunk != nil {
onChunk(StreamChunk{Thinking: s.Text})
}
}
}
}
}
case "response.completed", "response.incomplete", "response.failed":
if event.Response != nil {
if event.Response.Usage != nil {
u := event.Response.Usage
result.Usage = &Usage{
PromptTokens: u.InputTokens,
CompletionTokens: u.OutputTokens,
TotalTokens: u.TotalTokens,
}
if u.OutputTokensDetails != nil {
result.Usage.ThinkingTokens = u.OutputTokensDetails.ReasoningTokens
}
}
if event.Response.Status == "incomplete" {
result.FinishReason = "length"
}
}
}
}