package providers import ( "bufio" "bytes" "context" "encoding/json" "fmt" "os/exec" "strings" ) // CodexCliProvider implements LLMProvider by wrapping the codex CLI as a subprocess. type CodexCliProvider struct { command string workspace string } // NewCodexCliProvider creates a new Codex CLI provider. func NewCodexCliProvider(workspace string) *CodexCliProvider { return &CodexCliProvider{ command: "codex", workspace: workspace, } } // Chat implements LLMProvider.Chat by executing the codex CLI in non-interactive mode. func (p *CodexCliProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) { if p.command == "" { return nil, fmt.Errorf("codex command not configured") } prompt := p.buildPrompt(messages, tools) args := []string{ "exec", "--json", "--dangerously-bypass-approvals-and-sandbox", "--skip-git-repo-check", "--color", "never", } if model != "" && model != "codex-cli" { args = append(args, "-m", model) } if p.workspace != "" { args = append(args, "-C", p.workspace) } args = append(args, "-") // read prompt from stdin cmd := exec.CommandContext(ctx, p.command, args...) cmd.Stdin = bytes.NewReader([]byte(prompt)) var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr err := cmd.Run() // Parse JSONL from stdout even if exit code is non-zero, // because codex writes diagnostic noise to stderr (e.g. rollout errors) // but still produces valid JSONL output. if stdoutStr := stdout.String(); stdoutStr != "" { resp, parseErr := p.parseJSONLEvents(stdoutStr) if parseErr == nil && resp != nil && (resp.Content != "" || len(resp.ToolCalls) > 0) { return resp, nil } } if err != nil { if ctx.Err() == context.Canceled { return nil, ctx.Err() } if stderrStr := stderr.String(); stderrStr != "" { return nil, fmt.Errorf("codex cli error: %s", stderrStr) } return nil, fmt.Errorf("codex cli error: %w", err) } return p.parseJSONLEvents(stdout.String()) } // GetDefaultModel returns the default model identifier. func (p *CodexCliProvider) GetDefaultModel() string { return "codex-cli" } // buildPrompt converts messages to a prompt string for the Codex CLI. // System messages are prepended as instructions since Codex CLI has no --system-prompt flag. func (p *CodexCliProvider) buildPrompt(messages []Message, tools []ToolDefinition) string { var systemParts []string var conversationParts []string for _, msg := range messages { switch msg.Role { case "system": systemParts = append(systemParts, msg.Content) case "user": conversationParts = append(conversationParts, msg.Content) case "assistant": conversationParts = append(conversationParts, "Assistant: "+msg.Content) case "tool": conversationParts = append(conversationParts, fmt.Sprintf("[Tool Result for %s]: %s", msg.ToolCallID, msg.Content)) } } var sb strings.Builder if len(systemParts) > 0 { sb.WriteString("## System Instructions\n\n") sb.WriteString(strings.Join(systemParts, "\n\n")) sb.WriteString("\n\n## Task\n\n") } if len(tools) > 0 { sb.WriteString(p.buildToolsPrompt(tools)) sb.WriteString("\n\n") } // Simplify single user message (no prefix) if len(conversationParts) == 1 && len(systemParts) == 0 && len(tools) == 0 { return conversationParts[0] } sb.WriteString(strings.Join(conversationParts, "\n")) return sb.String() } // buildToolsPrompt creates a tool definitions section for the prompt. func (p *CodexCliProvider) buildToolsPrompt(tools []ToolDefinition) string { var sb strings.Builder sb.WriteString("## Available Tools\n\n") sb.WriteString("When you need to use a tool, respond with ONLY a JSON object:\n\n") sb.WriteString("```json\n") sb.WriteString(`{"tool_calls":[{"id":"call_xxx","type":"function","function":{"name":"tool_name","arguments":"{...}"}}]}`) sb.WriteString("\n```\n\n") sb.WriteString("CRITICAL: The 'arguments' field MUST be a JSON-encoded STRING.\n\n") sb.WriteString("### Tool Definitions:\n\n") for _, tool := range tools { if tool.Type != "function" { continue } sb.WriteString(fmt.Sprintf("#### %s\n", tool.Function.Name)) if tool.Function.Description != "" { sb.WriteString(fmt.Sprintf("Description: %s\n", tool.Function.Description)) } if len(tool.Function.Parameters) > 0 { paramsJSON, _ := json.Marshal(tool.Function.Parameters) sb.WriteString(fmt.Sprintf("Parameters:\n```json\n%s\n```\n", string(paramsJSON))) } sb.WriteString("\n") } return sb.String() } // codexEvent represents a single JSONL event from `codex exec --json`. type codexEvent struct { Type string `json:"type"` ThreadID string `json:"thread_id,omitempty"` Message string `json:"message,omitempty"` Item *codexEventItem `json:"item,omitempty"` Usage *codexUsage `json:"usage,omitempty"` Error *codexEventErr `json:"error,omitempty"` } type codexEventItem struct { ID string `json:"id"` Type string `json:"type"` Text string `json:"text,omitempty"` Command string `json:"command,omitempty"` Status string `json:"status,omitempty"` ExitCode *int `json:"exit_code,omitempty"` Output string `json:"output,omitempty"` } type codexUsage struct { InputTokens int `json:"input_tokens"` CachedInputTokens int `json:"cached_input_tokens"` OutputTokens int `json:"output_tokens"` } type codexEventErr struct { Message string `json:"message"` } // parseJSONLEvents processes the JSONL output from codex exec --json. func (p *CodexCliProvider) parseJSONLEvents(output string) (*LLMResponse, error) { var contentParts []string var usage *UsageInfo var lastError string scanner := bufio.NewScanner(strings.NewReader(output)) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if line == "" { continue } var event codexEvent if err := json.Unmarshal([]byte(line), &event); err != nil { continue // skip malformed lines } switch event.Type { case "item.completed": if event.Item != nil && event.Item.Type == "agent_message" && event.Item.Text != "" { contentParts = append(contentParts, event.Item.Text) } case "turn.completed": if event.Usage != nil { promptTokens := event.Usage.InputTokens + event.Usage.CachedInputTokens usage = &UsageInfo{ PromptTokens: promptTokens, CompletionTokens: event.Usage.OutputTokens, TotalTokens: promptTokens + event.Usage.OutputTokens, } } case "error": lastError = event.Message case "turn.failed": if event.Error != nil { lastError = event.Error.Message } } } if lastError != "" && len(contentParts) == 0 { return nil, fmt.Errorf("codex cli: %s", lastError) } content := strings.Join(contentParts, "\n") // Extract tool calls from response text (same pattern as ClaudeCliProvider) toolCalls := extractToolCallsFromText(content) finishReason := "stop" if len(toolCalls) > 0 { finishReason = "tool_calls" content = stripToolCallsFromText(content) } return &LLMResponse{ Content: strings.TrimSpace(content), ToolCalls: toolCalls, FinishReason: finishReason, Usage: usage, }, nil }