* 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
252 lines
7.1 KiB
Go
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
|
|
}
|