// Package codex is the OpenAI Codex CLI llm.Provider. // // It is the `claudecli` analog for OpenAI's Codex: pure Go, available // in every build (no `-tags llama`), inference delegated to the user's // locally installed `codex` binary. The binary reuses the user's // existing Codex / ChatGPT sign-in instead of requiring an // OPENAI_API_KEY in gortex's environment. // // Each Complete call spawns one `codex exec` subprocess in // non-interactive mode. The conversation is flattened to a single // prompt fed on stdin (so very large contexts don't trip ARG_MAX); // Codex has no system-prompt flag, so RoleSystem turns are folded into // the prompt as a leading "System instructions:" block. The agent's // final message is captured via `--output-last-message` into a temp // file — that isolates the answer from Codex's own progress logging // on stdout. The subprocess runs `--sandbox read-only` so a completion // can never mutate the working tree. // // Structured output (the expand / rerank / verify shapes and the // agent tool-call shape) reuses the shared CLI plumbing — a JSON- // Schema rider on the prompt (llm.AppendSchemaInstruction) plus // llm.ExtractJSON on the response. Codex has no native structured- // output mechanism, exactly like the `claude` CLI. package codex import ( "bytes" "context" "errors" "fmt" "os" "os/exec" "strings" "time" "github.com/zzet/gortex/internal/llm" ) // defaultTimeout caps one Complete call when the user hasn't set // codex.timeout_seconds. Codex CLI startup plus one model round-trip // runs longer than the `claude` CLI, so the default is more generous. const defaultTimeout = 180 * time.Second // Provider implements llm.Provider against the `codex` CLI. type Provider struct { binary string model string extra []string timeout time.Duration } var _ llm.Provider = (*Provider)(nil) // New constructs the Codex CLI provider. It verifies the binary is // reachable on $PATH (or as an absolute path) so misconfiguration // surfaces at startup, not on the first Complete call. func New(cfg llm.CodexConfig) (llm.Provider, error) { bin := strings.TrimSpace(cfg.Binary) if bin == "" { bin = "codex" } resolved, err := exec.LookPath(bin) if err != nil { return nil, fmt.Errorf("codex: binary %q not found on PATH: %w", bin, err) } timeout := defaultTimeout if cfg.TimeoutSeconds > 0 { timeout = time.Duration(cfg.TimeoutSeconds) * time.Second } return &Provider{ binary: resolved, model: strings.TrimSpace(cfg.Model), extra: append([]string(nil), cfg.Args...), timeout: timeout, }, nil } // Name implements llm.Provider. func (p *Provider) Name() string { return "codex" } // Close is a no-op — every Complete spawns a fresh subprocess; there // is no long-lived connection or model handle to release. func (p *Provider) Close() error { return nil } // Complete implements llm.Provider. It runs one `codex exec` // subprocess: the conversation (system turns folded in) is piped on // stdin, the agent's final message is captured from the // --output-last-message sidecar, and structured shapes get the // JSON-Schema rider + extraction treatment. func (p *Provider) Complete(ctx context.Context, req llm.CompletionRequest) (llm.CompletionResponse, error) { prompt := buildPrompt(req.Messages) structured := req.Shape != llm.ShapeFreeform if structured { prompt = llm.AppendSchemaInstruction(prompt, req.Shape, req.Tools) } if strings.TrimSpace(prompt) == "" { return llm.CompletionResponse{}, errors.New("codex: empty prompt") } // --output-last-message writes ONLY the agent's final message to a // sidecar file, isolating the answer from Codex's progress logging // on stdout. sidecar, err := os.CreateTemp("", "gortex-codex-*.txt") if err != nil { return llm.CompletionResponse{}, fmt.Errorf("codex: create output sidecar: %w", err) } sidecarPath := sidecar.Name() _ = sidecar.Close() defer func() { _ = os.Remove(sidecarPath) }() // `codex exec` runs non-interactively (no approval prompts). // --sandbox read-only keeps a completion from mutating the tree; // --skip-git-repo-check lets it run outside a git repo; the `-` // positional makes Codex read the prompt from stdin, dodging // ARG_MAX on long contexts. args := []string{ "exec", "--skip-git-repo-check", "--color", "never", "--sandbox", "read-only", "--output-last-message", sidecarPath, } if p.model != "" { args = append(args, "--model", p.model) } args = append(args, p.extra...) args = append(args, "-") runCtx := ctx if p.timeout > 0 { var cancel context.CancelFunc runCtx, cancel = context.WithTimeout(ctx, p.timeout) defer cancel() } cmd := exec.CommandContext(runCtx, p.binary, args...) cmd.Stdin = strings.NewReader(prompt) var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr if err := cmd.Run(); err != nil { if errors.Is(runCtx.Err(), context.DeadlineExceeded) { return llm.CompletionResponse{}, fmt.Errorf("codex: timed out after %s: %s", p.timeout, llm.Snippet(stderr.Bytes())) } if msg := llm.Snippet(stderr.Bytes()); msg != "" { return llm.CompletionResponse{}, fmt.Errorf("codex: %w: %s", err, msg) } return llm.CompletionResponse{}, fmt.Errorf("codex: %w", err) } text := readResponse(sidecarPath, stdout.String()) if text == "" { return llm.CompletionResponse{}, errors.New("codex: empty response from CLI") } if structured { extracted, ok := llm.ExtractJSON(text) if !ok { return llm.CompletionResponse{}, fmt.Errorf("codex: response carried no JSON: %s", llm.Snippet([]byte(text))) } text = extracted } return llm.CompletionResponse{Text: text}, nil } // readResponse prefers the --output-last-message sidecar (the agent's // final message, clean of progress logs) and falls back to raw stdout // when the sidecar is missing or empty — e.g. an older Codex build // that does not honour the flag. func readResponse(sidecarPath, stdout string) string { if raw, err := os.ReadFile(sidecarPath); err == nil { if s := strings.TrimSpace(string(raw)); s != "" { return s } } return strings.TrimSpace(stdout) } // buildPrompt flattens the conversation into a single prompt string. // Codex has no system-prompt flag, so RoleSystem turns are folded in // as a leading "System instructions:" block; the rest render as // "User:" / "Assistant:" / "Tool result:" turns. The whole thing is // piped on stdin. func buildPrompt(in []llm.Message) string { var sys []string var turns strings.Builder n := 0 for _, m := range in { switch m.Role { case llm.RoleSystem: if s := strings.TrimSpace(m.Content); s != "" { sys = append(sys, s) } case llm.RoleAssistant: if n > 0 { turns.WriteString("\n\n") } turns.WriteString("Assistant: ") turns.WriteString(m.Content) n++ case llm.RoleTool: if n > 0 { turns.WriteString("\n\n") } turns.WriteString(renderToolResult(m)) n++ default: if n > 0 { turns.WriteString("\n\n") } turns.WriteString("User: ") turns.WriteString(m.Content) n++ } } var b strings.Builder if len(sys) > 0 { b.WriteString("System instructions:\n") b.WriteString(strings.Join(sys, "\n\n")) if turns.Len() > 0 { b.WriteString("\n\n") } } b.WriteString(turns.String()) return b.String() } func renderToolResult(m llm.Message) string { if m.ToolName != "" { return "Tool result (" + m.ToolName + "):\n" + m.Content } return "Tool result:\n" + m.Content }