diff --git a/.gitignore b/.gitignore index 20ada2a..864724f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -plexum-openai-provider-plugin /plexum-openai-provider-plugin +/bin/ *.tmp dist/ diff --git a/cmd/plexum-openai-provider-plugin/main.go b/cmd/plexum-openai-provider-plugin/main.go new file mode 100644 index 0000000..9efdcd7 --- /dev/null +++ b/cmd/plexum-openai-provider-plugin/main.go @@ -0,0 +1,189 @@ +// 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" + 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 +// stores transcripts at ~/.codex/sessions///
/rollout-* +// -.jsonl as response_item lines whose payloads carry +// function_call / function_call_output entries keyed by `call_id` +// (not toolu_*) — so a Plexum tool_use_id like "toolu_X" won't ever +// match in there. +// +// v1 ships as a logged no-op: we resolve the file path so operator +// log can confirm wiring, but we don't rewrite. When Plexum's tool +// dispatch eventually routes through codex's native tool surface +// (preserving call_id round-trip), the rewrite logic plugs in here. +func (p *openaiPlugin) MutateSession(ctx context.Context, req plugin.SessionMutateRequest) error { + path, err := runner.FindCodexSessionFile(req.Workspace) + if err != nil { + p.host.Log("debug", "codex session mutate: find failed", map[string]any{ + "agent": req.AgentID, "err": err.Error(), + }) + return nil + } + p.host.Log("info", "codex session mutate (no-op v1)", map[string]any{ + "agent": req.AgentID, + "session_path": path, + "requested": len(req.Mutations), + "reason": "codex uses call_* ids; tool-call round-trip not yet wired", + }) + 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 err := plugin.Serve(&openaiPlugin{}); err != nil { + fmt.Fprintf(os.Stderr, "plexum-openai-provider-plugin: %v\n", err) + os.Exit(1) + } +} diff --git a/internal/runner/session_mutate.go b/internal/runner/session_mutate.go new file mode 100644 index 0000000..c661c63 --- /dev/null +++ b/internal/runner/session_mutate.go @@ -0,0 +1,63 @@ +// session_mutate.go — codex session file path resolution. The actual +// JSONL rewrite is deferred (see main.go MutateSession comment): codex +// keys tool calls by `call_`, not the `toolu_` Plexum uses, +// so a v1 mirror would never match anything. We expose the path +// finder here so the host-side wiring telemetry stays useful and the +// v2 rewriter has its entry point. +// +// Codex layout (observed): +// +// ~/.codex/sessions///
/rollout--.jsonl +// +// Older layout (pre-0.5x) wrote into the top-level sessions dir with +// the same filename convention. We probe both. + +package runner + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" +) + +// FindCodexSessionFile resolves the rollout JSONL for the thread_id +// captured into workspace/.plexum-codex-session. Returns ("", error) +// when no session id is recorded yet OR no matching file is on disk. +func FindCodexSessionFile(workspace string) (string, error) { + threadID := loadSessionID(workspace) + if threadID == "" { + return "", errors.New("no thread_id captured yet") + } + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + root := filepath.Join(home, ".codex", "sessions") + var found string + err = filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error { + if err != nil { + return nil // tolerate per-entry errors; keep walking + } + if d.IsDir() { + return nil + } + name := d.Name() + if !strings.HasSuffix(name, ".jsonl") && !strings.HasSuffix(name, ".json") { + return nil + } + if strings.Contains(name, threadID) { + found = p + return filepath.SkipAll + } + return nil + }) + if err != nil { + return "", err + } + if found == "" { + return "", fmt.Errorf("no rollout file for thread_id=%s under %s", threadID, root) + } + return found, nil +}