// plexum-openai-provider-plugin wraps the local `codex` CLI binary // (OpenAI Codex CLI ≥ 0.135). Same CLI-driven approach as // Plexum-gemini-provider — each Plexum turn forks one `codex exec` // subprocess in the agent's workspace, captures JSONL output, and // emits canonical TurnEvents. // // Auth: codex CLI's own ~/.codex/ state (ChatGPT login or // OPENAI_API_KEY env). No api_key field in this plugin's config. // // Models: by default only "codex" (use whatever the CLI's bound // account supports — ChatGPT login restricts model selection). If // running on an API key, operator can add other ids via config. package main import ( "context" "encoding/json" "errors" "fmt" "os" "path/filepath" "git.hangman-lab.top/hzhang/Plexum-sdk-go/canonical" "git.hangman-lab.top/hzhang/Plexum-sdk-go/mcpbridge" plugin "git.hangman-lab.top/hzhang/Plexum-sdk-go/plugin" "git.hangman-lab.top/hzhang/Plexum-openai-provider/internal/runner" ) const pluginName = "plexum-openai-provider" // Default model surface. "codex" → no --model flag (CLI default). // Operator can extend via config.SupportedModels override (e.g. // "gpt-5", "o3" — only valid on API-key accounts). var defaultModels = []string{"codex"} // HostConfig at /plugins/plexum-openai-provider/config.json. // All fields optional. // // { // "binary": "codex", // "extra_args": ["--enable", "some-feature"], // "supported_models": ["codex","gpt-5"], // override manifest models list // "model_args": { // map plexum model id → --model value // "gpt-5": "gpt-5" // } // } type HostConfig struct { Binary string `json:"binary"` ExtraArgs []string `json:"extra_args"` SupportedModels []string `json:"supported_models"` ModelArgs map[string]string `json:"model_args"` } type openaiPlugin struct { host plugin.HostAPI cfg HostConfig // modelsAdvertised is the final list used for both the manifest // AND admission ("does the agent's model match one we serve?"). modelsAdvertised []string } func (p *openaiPlugin) Manifest() plugin.Manifest { // Manifest is built dynamically per init so an operator's // supported_models override surfaces correctly. loaded := p.loadConfigBareForManifest() models := loaded.SupportedModels if len(models) == 0 { models = defaultModels } return plugin.Manifest{ Name: pluginName, Version: "0.1.0", Activation: plugin.ActivationLazy, Executable: "plexum-openai-provider-plugin", Contracts: plugin.Contracts{ Provider: &plugin.ProviderContract{Models: models}, }, } } // loadConfigBareForManifest reads the config file once during // Manifest() (before Init runs) so the advertised model list reflects // operator overrides without requiring a second restart cycle. Errors // are silent — Init will surface them. func (p *openaiPlugin) loadConfigBareForManifest() HostConfig { profileRoot := os.Getenv("PLEXUM_PROFILE_ROOT") if profileRoot == "" { home, _ := os.UserHomeDir() profileRoot = filepath.Join(home, ".plexum") } raw, _ := os.ReadFile(filepath.Join(profileRoot, "plugins", pluginName, "config.json")) var c HostConfig _ = json.Unmarshal(raw, &c) return c } func (p *openaiPlugin) Init(ctx context.Context, host plugin.HostAPI) error { p.host = host profileRoot := os.Getenv("PLEXUM_PROFILE_ROOT") if profileRoot == "" { home, _ := os.UserHomeDir() profileRoot = filepath.Join(home, ".plexum") } cfgPath := filepath.Join(profileRoot, "plugins", pluginName, "config.json") raw, err := os.ReadFile(cfgPath) if err != nil && !errors.Is(err, os.ErrNotExist) { return fmt.Errorf("read %s: %w", cfgPath, err) } if len(raw) > 0 { if err := json.Unmarshal(raw, &p.cfg); err != nil { return fmt.Errorf("parse %s: %w", cfgPath, err) } } if p.cfg.Binary == "" { p.cfg.Binary = "codex" } if len(p.cfg.SupportedModels) > 0 { p.modelsAdvertised = p.cfg.SupportedModels } else { p.modelsAdvertised = defaultModels } host.Log("info", "openai (codex) provider initialized", map[string]any{ "binary": p.cfg.Binary, "extra_args": p.cfg.ExtraArgs, "models": p.modelsAdvertised, }) return nil } // Stream is the bare ProviderPlugin entrypoint; SDK uses // StreamWithAgent below when AgentContext is needed. func (p *openaiPlugin) Stream(ctx context.Context, modelID string, req canonical.TurnRequest) (<-chan canonical.TurnEvent, error) { return p.StreamWithAgent(ctx, modelID, plugin.AgentContext{}, req) } func (p *openaiPlugin) StreamWithAgent(ctx context.Context, modelID string, agent plugin.AgentContext, req canonical.TurnRequest) (<-chan canonical.TurnEvent, error) { cliModel := p.mapModel(modelID) return runner.Run(ctx, p.host, agent, cliModel, p.cfg.Binary, p.cfg.ExtraArgs, req) } // MutateSession is the session-mirror hook (decision #29x). codex // rollout JSONL at ~/.codex/sessions/.../rollout-*-.jsonl // is rewritten line-by-line: function_call_output entries matching // the request's call_ids get their output replaced with the consume // marker; heavy ops also stub function_call.arguments to "{}". // // Tool-call round-trip is provided by the runner package's // ParseRolloutToolCalls + EmitCodexToolCalls (which run at end of // each turn) — they push the codex call_X ids into Plexum's // dynamic.jsonl, so the BlockMutations the host fires here all // resolve. func (p *openaiPlugin) MutateSession(ctx context.Context, req plugin.SessionMutateRequest) error { if len(req.Mutations) == 0 { return nil } mu := make([]runner.CodexMutation, 0, len(req.Mutations)) for _, m := range req.Mutations { mu = append(mu, runner.CodexMutation{BlockID: m.BlockID, Op: m.Op}) } touched, err := runner.MutateCodexSession(req.Workspace, mu) if err != nil { p.host.Log("warn", "codex session mutate failed", map[string]any{ "agent": req.AgentID, "err": err.Error(), }) return err } p.host.Log("info", "codex session mutate complete", map[string]any{ "agent": req.AgentID, "touched": touched, "requested": len(req.Mutations), }) return nil } // mapModel resolves the Plexum-advertised model id into the CLI's // --model value. "" / "codex" → no --model flag (CLI default, only // thing ChatGPT-account installs support). func (p *openaiPlugin) mapModel(plexumModel string) string { if plexumModel == "" || plexumModel == "codex" { return "" } if v, ok := p.cfg.ModelArgs[plexumModel]; ok { return v } // Unknown id — pass through verbatim. Codex will accept or 400. return plexumModel } func main() { if len(os.Args) > 1 && os.Args[1] == "mcp-host" { if err := runMCPHost(os.Args[2:]); err != nil { fmt.Fprintf(os.Stderr, "mcp-host: %v\n", err) os.Exit(1) } return } if err := plugin.Serve(&openaiPlugin{}); err != nil { fmt.Fprintf(os.Stderr, "plexum-openai-provider-plugin: %v\n", err) os.Exit(1) } } // runMCPHost: codex registers our binary's mcp-host subcommand via // `codex mcp add --env PLEXUM_MCP_SOCKET=...`. The mcp-host process // reads those env vars + serves stdio MCP to codex while dialing the // plugin-side unix socket. func runMCPHost(argv []string) error { opts := mcpbridge.Opts{ SocketPath: os.Getenv("PLEXUM_MCP_SOCKET"), AgentID: os.Getenv("PLEXUM_MCP_AGENT_ID"), } for i := 0; i < len(argv); i++ { switch argv[i] { case "--socket": if i+1 >= len(argv) { return errors.New("--socket needs value") } opts.SocketPath = argv[i+1] i++ case "--agent-id": if i+1 >= len(argv) { return errors.New("--agent-id needs value") } opts.AgentID = argv[i+1] i++ default: return fmt.Errorf("unknown mcp-host flag: %s", argv[i]) } } return mcpbridge.Run(context.Background(), opts) }