chore: import upstream snapshot with attribution
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,234 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user