commit 33de696653189bce8431f56f5f56db451c671ff1 Author: hzhang Date: Sun May 31 20:44:42 2026 +0100 feat: Plexum-openai-provider v0.1 — OpenAI codex CLI as backend Plexum ProviderPlugin that wraps the local `codex` CLI binary (OpenAI Codex CLI ≥ 0.135). Same CLI-driven pattern as Plexum-gemini-provider and Plexum-anthropic-provider's contractor. internal/runner/ (~200 LOC): - Per Plexum turn, fork: codex exec [resume ] \ --skip-git-repo-check \ --dangerously-bypass-approvals-and-sandbox \ --json \ [--model ] \ "" in agent workspace, with stdin = /dev/null (codex would otherwise block waiting for additional input). - Stream-parses JSONL events: thread.started → save thread_id to .plexum-codex-session item.completed → if type==agent_message, emit text_delta turn.completed → capture usage (input/output/cached/reasoning) error / turn.failed → emit canonical EventError - Other event types (turn.started, item.started, reasoning, etc.) logged at debug, don't produce user-facing events for v1 cmd/plexum-openai-provider-plugin/ implements ProviderPluginWithAgent (needs AgentContext.Workspace as cwd). Model surface: default: ["codex"] (no --model flag → CLI default; only thing ChatGPT-account installs support) override via config.supported_models + config.model_args for API-key installs that want gpt-5 / o3 / etc. HostConfig (all optional): binary default "codex" (operator should set abs path for systemd PATH) extra_args appended before the prompt supported_models override the advertised model list model_args map plexum id → CLI --model value No api_key field — codex CLI handles auth via ~/.codex/ state (ChatGPT login OR OPENAI_API_KEY env). End-to-end verified against local install (ChatGPT login): 1. CLI turn 1: "OpenAI built me." 2. CLI turn 2 (resume): "I said: 'OpenAI built me.'" (multi-turn ✓) 3. Fabric channel e2e: alice → cx agent → codex CLI → outbound REST → seq=23: "Use list comprehensions for concise, readable filtering and transformation of iterables." Known: when daemon runs under systemd PATH doesn't include nvm/local bin dirs; operator must set absolute binary path in config.json. Co-Authored-By: Claude Opus 4.7 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..20ada2a --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +plexum-openai-provider-plugin +/plexum-openai-provider-plugin +*.tmp +dist/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..a999b14 --- /dev/null +++ b/README.md @@ -0,0 +1,84 @@ +# Plexum-openai-provider + +Plexum ProviderPlugin that wraps the local `codex` CLI binary (OpenAI +Codex CLI ≥ 0.135). Same CLI-driven pattern as `Plexum-gemini-provider` +and `Plexum-anthropic-provider`'s contractor mode. + +## Status + +**v0.1**: subprocess-per-turn, JSONL stream parsing, per-workspace +session continuity via `codex exec resume `. Single model +`codex` advertised by default; operator can override. + +## Auth + +No API key in this plugin's config. The `codex` CLI handles auth via +`~/.codex/` (ChatGPT login OR `OPENAI_API_KEY`). Run `codex login` +once (or set env) and this plugin just shells out. + +## Install + +```bash +cd ~/Plexum-openai-provider +./scripts/install.sh +``` + +Operator notes in `~/.plexum/plugins/plexum-openai-provider/config.json`: + +```json +{ + "binary": "/home//.local/bin/codex", + "extra_args": [] +} +``` + +(absolute path is required when the daemon runs under systemd — +systemd doesn't inherit shell PATH). + +Then `.plugins.allow += ["plexum-openai-provider"]` and: + +```bash +plexum agent-add --model codex my-agent +systemctl --user restart plexum +plexum say --agent-id my-agent --session-id $(plexum new-session --agent-id my-agent) "hello" +``` + +## How it works + +Per Plexum turn: + +``` +codex exec [resume ] \ + --skip-git-repo-check \ + --dangerously-bypass-approvals-and-sandbox \ + --json \ + [--model ] \ + "" +``` + +cwd = ``, stdin = `/dev/null` (codex would otherwise +hang waiting for additional input). Codex emits JSONL: + +```jsonl +{"type":"thread.started","thread_id":"..."} +{"type":"turn.started"} +{"type":"item.completed","item":{"id":"...","type":"agent_message","text":"..."}} +{"type":"turn.completed","usage":{"input_tokens":...,...}} +``` + +`thread.started.thread_id` is captured into +`/.plexum-codex-session` so the next turn passes +`exec resume ` instead of `exec`. `item.completed` of type +`agent_message` becomes `text_delta`; `turn.completed.usage` populates +the canonical Usage on `message_end`. + +## ChatGPT account vs API key + +ChatGPT-account installs only support the default model; explicit +`--model gpt-5-codex` etc. returns `400 invalid_request_error`. With +an API key install, operator can declare more model ids via +`config.supported_models` + `config.model_args`. + +## License + +Same as Plexum. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..e509a19 --- /dev/null +++ b/go.mod @@ -0,0 +1,7 @@ +module git.hangman-lab.top/hzhang/Plexum-openai-provider + +go 1.24.2 + +require git.hangman-lab.top/hzhang/Plexum-sdk-go v0.0.0 + +replace git.hangman-lab.top/hzhang/Plexum-sdk-go => ../Plexum-sdk-go diff --git a/internal/runner/runner.go b/internal/runner/runner.go new file mode 100644 index 0000000..a59ddf4 --- /dev/null +++ b/internal/runner/runner.go @@ -0,0 +1,308 @@ +// Package runner forks the OpenAI `codex` CLI per Plexum turn and +// translates its JSONL output into canonical TurnEvents. +// +// Each Stream call spawns one subprocess: +// +// codex exec --skip-git-repo-check --dangerously-bypass-approvals-and-sandbox --json [] +// < /dev/null +// +// For multi-turn continuity: +// +// codex exec resume --skip-git-repo-check ... --json +// +// in the agent's workspace dir. Codex emits a stream of JSONL events: +// {"type":"thread.started","thread_id":"..."} +// {"type":"turn.started"} +// {"type":"item.completed","item":{"id":"...","type":"agent_message","text":"..."}} +// {"type":"turn.completed","usage":{"input_tokens":..., ...}} +// +// Session continuity: per-workspace thread_id stored at +// /.plexum-codex-session, used as the resume arg. +package runner + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + + "git.hangman-lab.top/hzhang/Plexum-sdk-go/canonical" + plugin "git.hangman-lab.top/hzhang/Plexum-sdk-go/plugin" +) + +// SessionFile is the per-workspace filename storing the codex +// thread_id between turns. +const SessionFile = ".plexum-codex-session" + +// Run is the ProviderPlugin Stream implementation. +func Run( + ctx context.Context, + host plugin.HostAPI, + agent plugin.AgentContext, + model string, + binary string, + extraArgs []string, + req canonical.TurnRequest, +) (<-chan canonical.TurnEvent, error) { + workspace := agent.Workspace + if workspace == "" { + return nil, errors.New("codex: agent workspace required (host must supply AgentContext)") + } + if err := os.MkdirAll(workspace, 0o755); err != nil { + return nil, fmt.Errorf("mkdir workspace: %w", err) + } + + prompt := extractLastUserText(req.Messages) + if prompt == "" { + return nil, errors.New("codex: no user text in request messages") + } + + resumeID := loadSessionID(workspace) + + // Build the argv. `codex exec [resume ]` then flags then prompt. + args := []string{"exec"} + if resumeID != "" { + args = append(args, "resume", resumeID) + } + args = append(args, + "--skip-git-repo-check", + "--dangerously-bypass-approvals-and-sandbox", + "--json", + ) + if model != "" { + args = append(args, "--model", model) + } + args = append(args, extraArgs...) + // Prompt is the last positional. Codex's exec/resume both accept + // it positionally; we pass via arg to avoid the stdin path. + args = append(args, prompt) + + host.Log("info", "codex: spawning CLI", map[string]any{ + "workspace": workspace, "model": model, "resume": resumeID, + "prompt_len": len(prompt), + }) + + cmd := exec.CommandContext(ctx, binary, args...) + cmd.Dir = workspace + cmd.Env = os.Environ() + cmd.Stdin = nil // detach from inherited stdin (codex would otherwise wait) + + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, fmt.Errorf("stdout pipe: %w", err) + } + stderr, err := cmd.StderrPipe() + if err != nil { + return nil, fmt.Errorf("stderr pipe: %w", err) + } + if err := cmd.Start(); err != nil { + return nil, fmt.Errorf("codex: start: %w", err) + } + + out := make(chan canonical.TurnEvent, 16) + go pumpEvents(ctx, host, cmd, stdout, stderr, workspace, out) + return out, nil +} + +// pumpEvents drains stdout (JSONL), translates each line to a +// canonical TurnEvent, and closes the channel on subprocess exit. +func pumpEvents( + ctx context.Context, + host plugin.HostAPI, + cmd *exec.Cmd, + stdout io.Reader, + stderr io.Reader, + workspace string, + out chan<- canonical.TurnEvent, +) { + defer close(out) + + // Drain stderr in a side goroutine — codex chatters informational + // stuff there ("Reading additional input from stdin..." etc.). + go func() { + sc := bufio.NewScanner(stderr) + for sc.Scan() { + host.Log("debug", "codex stderr: "+sc.Text(), nil) + } + }() + + emit := func(ev canonical.TurnEvent) { + select { + case out <- ev: + case <-ctx.Done(): + } + } + emit(canonical.TurnEvent{Type: canonical.EventMessageStart}) + + scanner := bufio.NewScanner(stdout) + scanner.Buffer(make([]byte, 64*1024), 4*1024*1024) + + var ( + usage canonical.Usage + hadError bool + errMsg string + ) + + for scanner.Scan() { + line := scanner.Bytes() + if len(line) == 0 { + continue + } + var head struct { + Type string `json:"type"` + } + if err := json.Unmarshal(line, &head); err != nil { + host.Log("warn", "codex: unparseable JSONL line", + map[string]any{"err": err.Error(), "line": string(line)}) + continue + } + + switch head.Type { + case "thread.started": + var ev struct { + ThreadID string `json:"thread_id"` + } + _ = json.Unmarshal(line, &ev) + if ev.ThreadID != "" { + saveSessionID(workspace, ev.ThreadID, host) + } + + case "turn.started": + // nothing user-facing + + case "item.started": + // codex announces an item; we wait for completed. + + case "item.completed": + var ev struct { + Item struct { + ID string `json:"id"` + Type string `json:"type"` + Text string `json:"text"` + } `json:"item"` + } + if err := json.Unmarshal(line, &ev); err != nil { + continue + } + // "agent_message" is the assistant's text reply. + // Other item types (reasoning_summary, tool_use, etc.) + // surface as logs for now. + if ev.Item.Type == "agent_message" && ev.Item.Text != "" { + emit(canonical.TurnEvent{ + Type: canonical.EventTextDelta, Text: ev.Item.Text, + }) + } else if ev.Item.Type != "" { + host.Log("debug", "codex item", + map[string]any{"type": ev.Item.Type, "id": ev.Item.ID}) + } + + case "turn.completed": + var ev struct { + Usage struct { + InputTokens int `json:"input_tokens"` + CachedInputTokens int `json:"cached_input_tokens"` + OutputTokens int `json:"output_tokens"` + ReasoningTokens int `json:"reasoning_output_tokens"` + } `json:"usage"` + } + if err := json.Unmarshal(line, &ev); err == nil { + usage.InputTokens = ev.Usage.InputTokens + usage.OutputTokens = ev.Usage.OutputTokens + usage.CacheReadTokens = ev.Usage.CachedInputTokens + usage.ThinkingTokens = ev.Usage.ReasoningTokens + } + + case "error", "turn.failed": + var ev struct { + Message string `json:"message"` + Error struct { + Message string `json:"message"` + } `json:"error"` + } + _ = json.Unmarshal(line, &ev) + hadError = true + errMsg = ev.Message + if errMsg == "" { + errMsg = ev.Error.Message + } + emit(canonical.TurnEvent{ + Type: canonical.EventError, + Error: &canonical.ErrorInfo{Code: head.Type, Message: errMsg}, + }) + + default: + host.Log("debug", "codex unknown event", + map[string]any{"type": head.Type, "line": truncate(string(line), 200)}) + } + } + if serr := scanner.Err(); serr != nil { + host.Log("warn", "codex scanner", map[string]any{"err": serr.Error()}) + } + + werr := cmd.Wait() + stopReason := canonical.StopEndTurn + if hadError || werr != nil { + stopReason = canonical.StopReason("error") + } + emit(canonical.TurnEvent{ + Type: canonical.EventMessageEnd, StopReason: stopReason, Usage: &usage, + }) +} + +// ---- helpers ---- + +func extractLastUserText(msgs []canonical.Message) string { + for i := len(msgs) - 1; i >= 0; i-- { + m := msgs[i] + if m.Role != canonical.RoleUser { + continue + } + var parts []string + for _, b := range m.Content { + if t, ok := b.(*canonical.TextBlock); ok { + parts = append(parts, t.Text) + } + } + if len(parts) > 0 { + out := parts[0] + for _, p := range parts[1:] { + out += "\n" + p + } + return out + } + } + return "" +} + +func loadSessionID(workspace string) string { + raw, err := os.ReadFile(filepath.Join(workspace, SessionFile)) + if err != nil { + return "" + } + // Trim possible trailing newline. + s := string(raw) + for len(s) > 0 && (s[len(s)-1] == '\n' || s[len(s)-1] == '\r' || s[len(s)-1] == ' ') { + s = s[:len(s)-1] + } + return s +} + +func saveSessionID(workspace, id string, host plugin.HostAPI) { + path := filepath.Join(workspace, SessionFile) + if err := os.WriteFile(path, []byte(id), 0o600); err != nil { + host.Log("warn", "codex: save session id failed", + map[string]any{"err": err.Error(), "path": path}) + } +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "…" +} diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..850111b --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# Plexum-openai-provider installer. +# +# Builds + installs the plugin under ~/.plexum/plugins/. Plugin wraps +# the local `codex` CLI; auth lives in the CLI's own ~/.codex/ state. +set -euo pipefail + +REPO="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +PROFILE_DIR="${HOME}/.plexum" + +while [[ $# -gt 0 ]]; do + case "$1" in + --profile) PROFILE_DIR="$2"; shift 2 ;; + -h|--help) sed -n '2,/^set -euo/p' "$0" | sed -n '/^#/p' | sed 's/^# \{0,1\}//'; exit 0 ;; + *) echo "unknown flag: $1" >&2; exit 2 ;; + esac +done + +log() { printf '\033[1;34m[openai-install]\033[0m %s\n' "$*"; } +command -v go >/dev/null || { echo "go not found on PATH" >&2; exit 1; } +command -v codex >/dev/null || log "WARN: codex CLI not on PATH yet — operator must install + 'codex login' before first turn" + +PLUGIN_DIR="${PROFILE_DIR}/plugins/plexum-openai-provider" +mkdir -p "${PLUGIN_DIR}" + +cd "${REPO}" +VERSION="$(git describe --tags --always 2>/dev/null || echo dev)" +LDFLAGS="-X main.Version=${VERSION}" +log "building plexum-openai-provider-plugin (v=${VERSION})" +CGO_ENABLED=0 go build -ldflags="${LDFLAGS}" \ + -o "${PLUGIN_DIR}/plexum-openai-provider-plugin" \ + ./cmd/plexum-openai-provider-plugin + +cat > "${PLUGIN_DIR}/manifest.json" <<'EOF' +{ + "name": "plexum-openai-provider", + "version": "0.1.0", + "activation": "lazy", + "executable": "plexum-openai-provider-plugin", + "contracts": { + "provider": { + "models": ["codex"] + } + } +} +EOF + +cat < + 3. systemctl --user restart plexum +EOF