package provider import ( "bytes" "context" "encoding/base64" "encoding/json" "fmt" "io" "net/http" "runtime" "strings" "time" ) // Codex (ChatGPT subscription) client. Uses OpenAI's Responses API via // chatgpt.com/backend-api with the chatgpt-account-id handshake. // // Wire protocol notes: // - Endpoint: POST https://chatgpt.com/backend-api/codex/responses // - Headers: Authorization: Bearer , chatgpt-account-id: , // OpenAI-Beta: responses=experimental, originator: zot (required legacy value) // - Body: OpenAI Responses API shape (not chat/completions). // input: [{role, content: [{type: "input_text" | "input_image" | ... }]}] // instructions: // tools: [{type:"function", name, description, parameters, strict}] // stream: true // - SSE events: response.output_item.added, response.output_text.delta, // response.function_call_arguments.delta, response.output_item.done, // response.completed, response.failed, error // // The access_token comes from the OpenAI OAuth flow (auth.openai.com). // The accountID is parsed from the id_token JWT's `chatgpt_account_id` // claim; see auth/oauth.go. const codexDefaultBaseURL = "https://chatgpt.com/backend-api/codex/responses" type codexClient struct { token string accountID string baseURL string errorLabel string providerName string modelName func(string) string disableCLIRouting bool cliRoutingAll bool capabilities responsesCapabilities http *http.Client } // responsesCapabilities records extensions verified for the exact provider // client, rather than assuming every Responses-shaped endpoint accepts them. type responsesCapabilities struct { StablePromptCacheKey bool ExplicitPromptCacheBreakpoint bool SessionHeader bool ThreadHeader bool ClientRequestHeader bool } // NewOpenAICodex creates a Responses client for ChatGPT's Codex endpoint // using a subscription OAuth access token and the user's ChatGPT account ID. // It prefers WebSocket mode and falls back to HTTP/SSE before visible output. // baseURL may be empty to use the default. func NewOpenAICodex(token, accountID, baseURL string) Client { return newResponsesWebSocketClient(newOpenAICodexClient(token, accountID, baseURL)) } func newOpenAICodexClient(token, accountID, baseURL string) *codexClient { if baseURL == "" { baseURL = codexDefaultBaseURL } return &codexClient{ token: token, accountID: accountID, baseURL: strings.TrimRight(baseURL, "/"), errorLabel: "codex", providerName: "openai-codex", // The ChatGPT Codex backend load-sheds requests from client // identities it does not recognize: unknown originator/user-agent // pairs receive "Our servers are currently overloaded" stream // errors near-deterministically while Codex CLI requests succeed. // Send the Codex CLI request shape for every model on this // backend. cliRoutingAll: true, capabilities: responsesCapabilities{ StablePromptCacheKey: true, SessionHeader: true, ThreadHeader: true, ClientRequestHeader: true, }, http: &http.Client{Timeout: 0}, } } func (c *codexClient) Name() string { return "openai-codex" } // ---- Responses API wire types (subset needed for zut's surface) ---- type codexInputText struct { Type string `json:"type"` // "input_text" Text string `json:"text"` PromptCacheBreakpoint *codexPromptCacheBreakpoint `json:"prompt_cache_breakpoint,omitempty"` } type codexInputImage struct { Type string `json:"type"` // "input_image" Detail string `json:"detail"` ImageURL string `json:"image_url"` } type codexOutputText struct { Type string `json:"type"` // "output_text" Text string `json:"text"` Annotations []any `json:"annotations"` } type codexPromptCacheOptions struct { Mode string `json:"mode"` } type codexPromptCacheBreakpoint struct { Mode string `json:"mode"` } type codexInputMessage struct { Type string `json:"type,omitempty"` // "message" (optional for input) Role string `json:"role"` Content []any `json:"content"` } type codexOutputMessage struct { Type string `json:"type"` // "message" Role string `json:"role"` Status string `json:"status,omitempty"` ID string `json:"id,omitempty"` Content []codexOutputText `json:"content"` } type codexFunctionCall struct { Type string `json:"type"` // "function_call" ID string `json:"id,omitempty"` CallID string `json:"call_id"` Name string `json:"name"` Arguments string `json:"arguments"` // JSON string } type codexFunctionCallOutput struct { Type string `json:"type"` // "function_call_output" CallID string `json:"call_id"` Output string `json:"output"` // string (or ResponseFunctionCallOutputItemList for images; v1 only uses string) } // codexReasoningItem mirrors the Responses API "reasoning" output item. // We capture it on incoming streams and replay it verbatim on follow-up // requests: the API rejects assistant tool-call replays without it when // thinking is enabled. type codexReasoningItem struct { Type string `json:"type"` // "reasoning" ID string `json:"id,omitempty"` EncryptedContent string `json:"encrypted_content,omitempty"` // Summary is required by the Responses API even when no summary text // was streamed; encode an empty array rather than omitting the field. Summary []codexReasoningSummary `json:"summary"` } type codexReasoningSummary struct { Type string `json:"type"` // "summary_text" Text string `json:"text"` } type codexTool struct { Type string `json:"type"` // "function" Name string `json:"name"` Description string `json:"description,omitempty"` Parameters json.RawMessage `json:"parameters"` // Responses otherwise attempts to normalize schemas into strict mode, // which makes non-nullable optional properties required model output. Strict bool `json:"strict"` } type codexReasoningConfig struct { Effort string `json:"effort,omitempty"` } type codexRequest struct { Model string `json:"model"` Store bool `json:"store"` Stream bool `json:"stream,omitempty"` Instructions string `json:"instructions,omitempty"` Input []any `json:"input"` Tools []codexTool `json:"tools,omitempty"` ToolChoice string `json:"tool_choice,omitempty"` ParallelToolCalls bool `json:"parallel_tool_calls"` Include []string `json:"include,omitempty"` Reasoning *codexReasoningConfig `json:"reasoning,omitempty"` PromptCacheKey string `json:"prompt_cache_key,omitempty"` PromptCacheOptions *codexPromptCacheOptions `json:"prompt_cache_options,omitempty"` ServiceTier string `json:"service_tier,omitempty"` PreviousResponseID string `json:"previous_response_id,omitempty"` } // ---- Request building ---- func (c *codexClient) findModel(id string) (Model, error) { if c.providerName != "" && c.providerName != "openai-codex" { if m, err := FindModel(c.providerName, id); err == nil { return m, nil } } if m, err := FindModel("openai-codex", id); err == nil { return m, nil } return FindModel("openai", id) } func isGPT56Model(model string) bool { return strings.HasPrefix(strings.ToLower(strings.TrimSpace(model)), "gpt-5.6-") } func (c *codexClient) buildRequest(req Request) (*codexRequest, error) { providerName := c.providerName if providerName == "" { providerName = "openai-codex" } if err := ValidateFastMode(providerName, req.FastMode); err != nil { return nil, err } m, err := c.findModel(req.Model) if err != nil { return nil, err } reasoning := ClampReasoningForModel(m, req.Reasoning) body := &codexRequest{ Model: req.Model, Store: false, Stream: true, Instructions: req.SystemPrompt(), ParallelToolCalls: true, Include: []string{"reasoning.encrypted_content"}, } if req.FastMode { body.ServiceTier = fastModeServiceTier } if c.capabilities.ExplicitPromptCacheBreakpoint && isGPT56Model(req.Model) { // Keep implicit mode so OpenAI can retain later conversation // checkpoints as well as this stable instruction/tool boundary. body.PromptCacheOptions = &codexPromptCacheOptions{Mode: "implicit"} body.Input = append(body.Input, codexInputMessage{ Role: "developer", Content: []any{codexInputText{ Type: "input_text", Text: "[Stable prompt boundary]", PromptCacheBreakpoint: &codexPromptCacheBreakpoint{Mode: "explicit"}, }}, }) } if m.Reasoning { effort := OpenAICodexReasoningEffort(reasoning, req.Model) if hasReasoningLevelOverride(m, req.Reasoning) { effort = reasoning } if effort != "" { body.Reasoning = &codexReasoningConfig{Effort: effort} } } activeTools := activeToolDefinitions(req.Tools, req.Messages) if len(activeTools) > 0 { body.ToolChoice = "auto" for _, t := range activeTools { params := t.Schema if len(params) == 0 { params = json.RawMessage(`{"type":"object","properties":{}}`) } body.Tools = append(body.Tools, codexTool{ Type: "function", Name: t.Name, Description: t.Description, Parameters: params, }) } } msgIdx := 0 req.Messages = RepairOrphanedToolResults(req.Messages) for _, msg := range req.Messages { switch msg.Role { case RoleDeveloper, RoleUser: content := []any{} for _, c := range msg.Content { switch v := c.(type) { case TextBlock: if v.Text != "" { content = append(content, codexInputText{Type: "input_text", Text: v.Text}) } case ImageBlock: url := "data:" + v.MimeType + ";base64," + base64.StdEncoding.EncodeToString(v.Data) content = append(content, codexInputImage{Type: "input_image", Detail: "auto", ImageURL: url}) } } if len(content) == 0 { continue } role := "user" if msg.Role == RoleDeveloper { role = "developer" } body.Input = append(body.Input, codexInputMessage{Role: role, Content: content}) case RoleAssistant: // Emit one output_message per text block, one function_call per // tool call, and one reasoning item per ReasoningBlock, // preserving the order so the model sees the same interleaving // we captured. The reasoning replay is what keeps OpenAI // Codex from rejecting follow-up tool calls with // "thinking is enabled but reasoning_content is missing". for _, c := range msg.Content { switch v := c.(type) { case ReasoningBlock: item := codexReasoningItem{ Type: "reasoning", ID: v.ID, EncryptedContent: v.Encrypted, Summary: []codexReasoningSummary{}, } if v.Summary != "" { item.Summary = []codexReasoningSummary{{Type: "summary_text", Text: v.Summary}} } body.Input = append(body.Input, item) case TextBlock: if v.Text == "" { continue } msgIdx++ body.Input = append(body.Input, codexOutputMessage{ Type: "message", Role: "assistant", Status: "completed", ID: fmt.Sprintf("msg_%d", msgIdx), Content: []codexOutputText{ {Type: "output_text", Text: v.Text, Annotations: []any{}}, }, }) case ToolCallBlock: args := string(v.Arguments) if args == "" || !json.Valid([]byte(args)) { args = "{}" } callID, _ := splitCallID(v.ID) body.Input = append(body.Input, codexFunctionCall{ Type: "function_call", CallID: callID, Name: v.Name, Arguments: args, }) } } case RoleTool: for _, c := range msg.Content { if tr, ok := c.(ToolResultBlock); ok { var text strings.Builder imageCount := 0 for _, inner := range tr.Content { switch v := inner.(type) { case TextBlock: if text.Len() > 0 { text.WriteString("\n") } text.WriteString(v.Text) case ImageBlock: imageCount++ } } // The Responses API function_call_output only carries a // string, so image bytes cannot ride along here. The agent // loop mirrors any tool-result images into the following // user message (which this client does serialize as // input_image). Leave a short text note so an image-only // result is not an empty output the API may reject, and so // the model knows the image arrives next. out := text.String() if out == "" && imageCount > 0 { if imageCount == 1 { out = "[image returned; see the following message]" } else { out = fmt.Sprintf("[%d images returned; see the following message]", imageCount) } } callID, _ := splitCallID(tr.CallID) body.Input = append(body.Input, codexFunctionCallOutput{ Type: "function_call_output", CallID: callID, Output: out, }) } } } } return body, nil } func splitCallID(id string) (string, string) { if i := strings.Index(id, "|"); i >= 0 { return id[:i], id[i+1:] } return id, "" } const minOpenAIPromptCacheTokens = 1024 func responsesCacheDiagnostics(wire *codexRequest, transport, continuation string) CacheDiagnostics { diagnostics := CacheDiagnostics{Mode: "none", Transport: transport, Continuation: continuation} if wire == nil || wire.PromptCacheOptions == nil { return diagnostics } diagnostics.Mode = wire.PromptCacheOptions.Mode diagnostics.Eligible = estimatedOpenAIPromptTokens(wire) >= minOpenAIPromptCacheTokens return diagnostics } // estimatedOpenAIPromptTokens supplies a deliberately conservative local // eligibility signal. OpenAI makes the final tokenization and cache decision; // this estimate never leaves the process and is not persisted in diagnostics. func estimatedOpenAIPromptTokens(wire *codexRequest) int { encoded, err := json.Marshal(wire) if err != nil { return 0 } return len(encoded) / 4 } func usesCodexCLIRouting(model string) bool { switch model { case "gpt-5.6-luna", "gpt-5.6-luna-pro", "gpt-5.6-terra", "gpt-5.6-terra-pro": return true default: return false } } // ---- Streaming ---- func (c *codexClient) Stream(ctx context.Context, req Request) (<-chan Event, error) { wire, err := c.buildRequest(req) if err != nil { return nil, err } ReportCacheDiagnostics(req.Lifecycle, responsesCacheDiagnostics(wire, "http_sse", "full")) if c.modelName != nil { wire.Model = c.modelName(wire.Model) } useCodexCLIRouting := !c.disableCLIRouting && (c.cliRoutingAll || usesCodexCLIRouting(wire.Model)) cacheSessionID := strings.TrimSpace(req.Context.CacheSessionID) if c.capabilities.StablePromptCacheKey && cacheSessionID != "" { wire.PromptCacheKey = cacheSessionID } body, err := json.Marshal(wire) if err != nil { return nil, err } newReq := func() (*http.Request, error) { httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL, bytes.NewReader(body)) if err != nil { return nil, err } httpReq.Header = c.responsesHeaders(req, useCodexCLIRouting, false) httpReq.Header.Set("content-type", "application/json") httpReq.Header.Set("accept", "text/event-stream") return httpReq, nil } resp, err := doStreamWithRetry(ctx, c.http, newReq, req.Lifecycle) if err != nil { return nil, fmt.Errorf("openai-codex: %w", err) } if resp.StatusCode != http.StatusOK { b, _ := io.ReadAll(resp.Body) resp.Body.Close() return nil, fmt.Errorf("openai-codex: http %d: %s", resp.StatusCode, strings.TrimSpace(string(b))) } out := make(chan Event, 16) go c.runStream(ctx, resp, req, out) return out, nil } func (c *codexClient) responsesHeaders(req Request, useCodexCLIRouting, websocketMode bool) http.Header { headers := make(http.Header) headers.Set("authorization", "Bearer "+c.token) if !websocketMode || c.accountID != "" { headers.Set("openai-beta", "responses=experimental") } if c.accountID != "" { headers.Set("chatgpt-account-id", c.accountID) } cacheSessionID := strings.TrimSpace(req.Context.CacheSessionID) threadID := strings.TrimSpace(req.Context.ThreadID) if useCodexCLIRouting { // The ChatGPT Codex backend only admits (and reliably serves) // requests that follow Codex CLI routing metadata; other client // identities are load-shed even when capacity is available. headers.Set("originator", "codex_cli_rs") if c.capabilities.SessionHeader && cacheSessionID != "" { headers.Set("session-id", cacheSessionID) } if c.capabilities.ThreadHeader && threadID != "" { headers.Set("thread-id", threadID) } if c.capabilities.ClientRequestHeader && threadID != "" { headers.Set("x-client-request-id", threadID) } headers.Set("user-agent", "codex_cli_rs/0.0.0") return headers } // ChatGPT's backend recognizes these established values. They are // external provider metadata, not zut product identifiers. if c.accountID != "" { headers.Set("originator", "zot") headers.Set("user-agent", fmt.Sprintf("zot (%s %s)", runtime.GOOS, runtime.GOARCH)) } return headers } func (c *codexClient) runStream(ctx context.Context, resp *http.Response, req Request, out chan<- Event) { defer close(out) defer resp.Body.Close() raw := make(chan sseEvent, 16) go readSSE(resp.Body, raw) c.runResponseEvents(ctx, req, out, raw, nil) } // runResponseEvents translates the Responses streaming event protocol into // provider events. Both HTTP/SSE and Responses WebSocket mode use the same // event vocabulary, so keeping the state machine here prevents the transports // from drifting in tool-call, reasoning, and usage handling. func (c *codexClient) runResponseEvents(ctx context.Context, req Request, out chan<- Event, raw <-chan sseEvent, completed func(string)) { var observe func(string, Message) if completed != nil { observe = func(responseID string, _ Message) { completed(responseID) } } c.runResponseEventsWithFirst(ctx, req, out, raw, nil, observe) } func (c *codexClient) runResponseEventsWithFirst(ctx context.Context, req Request, out chan<- Event, raw <-chan sseEvent, first *sseEvent, completed func(string, Message)) { model, _ := c.findModel(req.Model) providerName := c.providerName if providerName == "" { providerName = "openai-codex" } out <- EventStart{Model: req.Model, Provider: providerName} // Accumulators. The Responses API emits output_items in order; each // item is either a "message" (text) or a "function_call". We track // the in-flight item by its index. type itemState struct { kind string // "message" | "function_call" | "reasoning" callID string name string argsBuf strings.Builder textBuf strings.Builder summary strings.Builder rawID string encrypted string announced bool } var ( items = map[int]*itemState{} order []int usage Usage stop = StopEnd finalErr error ) assemble := func() Message { content := []Content{} for _, idx := range order { it := items[idx] switch it.kind { case "message": if it.textBuf.Len() > 0 { content = append(content, TextBlock{Text: it.textBuf.String()}) } case "function_call": args := it.argsBuf.String() if args == "" || !json.Valid([]byte(args)) { args = "{}" } content = append(content, ToolCallBlock{ ID: it.callID, Name: it.name, Arguments: json.RawMessage(args), }) case "reasoning": if it.encrypted == "" && it.summary.Len() == 0 && it.rawID == "" { continue } content = append(content, ReasoningBlock{ ID: it.rawID, Summary: it.summary.String(), Encrypted: it.encrypted, }) } } return Message{Role: RoleAssistant, Content: content, Time: time.Now()} } sendDone := func() { usage.CostUSD = ComputeCost(model, usage) out <- EventUsage{Usage: usage} out <- EventDone{Stop: stop, Err: finalErr, Message: assemble()} } for { var ev sseEvent if first != nil { ev = *first first = nil } else { select { case <-ctx.Done(): stop = StopAborted finalErr = ctx.Err() sendDone() return case next, ok := <-raw: if !ok { sendDone() return } ev = next } } if ev.Err != nil { stop = StopError finalErr = fmt.Errorf("read SSE: %w", ev.Err) sendDone() return } var head struct { Type string `json:"type"` } if err := json.Unmarshal([]byte(ev.Data), &head); err != nil { continue } switch head.Type { case "response.output_item.added": var p struct { OutputIndex int `json:"output_index"` Item struct { Type string `json:"type"` // "message" | "function_call" | "reasoning" ID string `json:"id"` CallID string `json:"call_id"` Name string `json:"name"` EncryptedContent string `json:"encrypted_content"` } `json:"item"` } _ = json.Unmarshal([]byte(ev.Data), &p) it := &itemState{} switch p.Item.Type { case "message": it.kind = "message" case "function_call": it.kind = "function_call" it.callID = p.Item.CallID it.name = p.Item.Name if !it.announced { it.announced = true out <- EventToolStart{ID: it.callID, Name: it.name} } case "reasoning": it.kind = "reasoning" it.rawID = p.Item.ID it.encrypted = p.Item.EncryptedContent default: continue } items[p.OutputIndex] = it order = append(order, p.OutputIndex) case "response.output_text.delta": var p struct { OutputIndex int `json:"output_index"` Delta string `json:"delta"` } _ = json.Unmarshal([]byte(ev.Data), &p) if it, ok := items[p.OutputIndex]; ok && it.kind == "message" { it.textBuf.WriteString(p.Delta) out <- EventTextDelta{Delta: p.Delta} } case "response.reasoning_summary_text.delta": var p struct { OutputIndex int `json:"output_index"` Delta string `json:"delta"` } _ = json.Unmarshal([]byte(ev.Data), &p) if it, ok := items[p.OutputIndex]; ok && it.kind == "reasoning" { it.summary.WriteString(p.Delta) } case "response.reasoning_summary_text.done": // summary text already accumulated via deltas case "response.function_call_arguments.delta": var p struct { OutputIndex int `json:"output_index"` Delta string `json:"delta"` } _ = json.Unmarshal([]byte(ev.Data), &p) if it, ok := items[p.OutputIndex]; ok && it.kind == "function_call" { it.argsBuf.WriteString(p.Delta) out <- EventToolArgs{ID: it.callID, Delta: p.Delta} } case "response.output_item.done": var p struct { OutputIndex int `json:"output_index"` Item struct { Type string `json:"type"` ID string `json:"id"` EncryptedContent string `json:"encrypted_content"` Summary []struct { Type string `json:"type"` Text string `json:"text"` } `json:"summary"` } `json:"item"` } _ = json.Unmarshal([]byte(ev.Data), &p) if it, ok := items[p.OutputIndex]; ok { switch it.kind { case "function_call": out <- EventToolEnd{ID: it.callID} case "reasoning": if p.Item.EncryptedContent != "" { it.encrypted = p.Item.EncryptedContent } if it.rawID == "" && p.Item.ID != "" { it.rawID = p.Item.ID } for _, s := range p.Item.Summary { if s.Text == "" { continue } if it.summary.Len() > 0 { it.summary.WriteString("\n") } it.summary.WriteString(s.Text) } } } case "response.completed", "response.done": var p struct { Response struct { ID string `json:"id"` Usage struct { InputTokens int `json:"input_tokens"` OutputTokens int `json:"output_tokens"` InputTokensDetails *openAIInputTokensDetails `json:"input_tokens_details"` OutputTokensDetails *struct { ReasoningTokens int `json:"reasoning_tokens"` } `json:"output_tokens_details"` } `json:"usage"` Status string `json:"status"` } `json:"response"` } _ = json.Unmarshal([]byte(ev.Data), &p) usage = normalizeOpenAIUsage(p.Response.Usage.InputTokens, p.Response.Usage.OutputTokens, p.Response.Usage.InputTokensDetails) if details := p.Response.Usage.OutputTokensDetails; details != nil { usage.ReasoningTokens = details.ReasoningTokens usage.ReasoningTokensKnown = true } hadTool := false for _, it := range items { if it.kind == "function_call" { hadTool = true break } } if hadTool { stop = StopToolUse } else { stop = StopEnd } if completed != nil && p.Response.ID != "" { completed(p.Response.ID, assemble()) } sendDone() return case "response.failed": var p struct { Response struct { Error struct { Message string `json:"message"` } `json:"error"` } `json:"response"` } _ = json.Unmarshal([]byte(ev.Data), &p) stop = StopError finalErr = fmt.Errorf("codex: %s", p.Response.Error.Message) sendDone() return case "error": var p struct { Message string `json:"message"` Code string `json:"code"` Error struct { Message string `json:"message"` Code string `json:"code"` } `json:"error"` } _ = json.Unmarshal([]byte(ev.Data), &p) msg := p.Message if msg == "" { msg = p.Error.Message } if msg == "" { msg = p.Code } if msg == "" { msg = p.Error.Code } if msg == "" { msg = strings.TrimSpace(ev.Data) } stop = StopError label := c.errorLabel if label == "" { label = "codex" } finalErr = fmt.Errorf("%s error: %s", label, msg) sendDone() return } } }