Files
picoclaw/pkg/providers/codex_cli_provider.go
Leandro Barbosa e77b0a6755 feat: add Codex CLI provider for subprocess integration (#80)
* feat: add Codex CLI provider for OpenAI subprocess integration

Add CodexCliProvider that wraps `codex exec --json` as a subprocess,
analogous to the existing ClaudeCliProvider pattern. This enables using
OpenAI's Codex CLI tool as a local LLM backend.

- CodexCliProvider: subprocess wrapper parsing JSONL event stream
- Credential reader for ~/.codex/auth.json with token expiry detection
- Factory integration: provider "codex-cli" and auth_method "codex-cli"
- Fix tilde expansion in workspace path for CLI providers
- 37 unit tests covering parsing, prompt building, credentials, and mocks

* fix: add tool call extraction to Codex CLI provider

- Extract shared tool call parsing into tool_call_extract.go
  (extractToolCallsFromText, stripToolCallsFromText, findMatchingBrace)
- Both ClaudeCliProvider and CodexCliProvider now share the same
  tool call extraction logic for PicoClaw-specific tools
- Fix cache token accounting: include cached_input_tokens in total
- Add 2 new tests for tool call extraction from JSONL events
- Update existing tests for corrected token calculations

* fix(docker): update Go version to match go.mod requirement

Dockerfile used golang:1.24-alpine but go.mod requires go >= 1.25.7.
This caused Docker builds to fail on all branches with:
  "go: go.mod requires go >= 1.25.7 (running go 1.24.13)"

Update to golang:1.25-alpine to match the project requirement.

* fix: handle codex CLI stderr noise without losing valid stdout

Codex writes diagnostic messages to stderr (e.g. rollout errors) which
cause non-zero exit codes even when valid JSONL output exists on stdout.
Parse stdout first before checking exit code to avoid false errors.

* style: fix gofmt formatting and update web search API in tests

- Remove trailing whitespace in web.go and base_test.go
- Update config_test.go and web_test.go for WebSearchToolOptions API
2026-02-16 11:40:17 +08:00

252 lines
7.1 KiB
Go

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
}