From 1c95a72249b81a13bc642a7ed011321861d3af19 Mon Sep 17 00:00:00 2001 From: Emanuel Casco Date: Tue, 5 May 2026 13:53:38 +0200 Subject: [PATCH] feat: add support for codex cli --- README.md | 77 ++++++- cmd/ocgo/main.go | 457 +++++++++++++++++++++++++++++++++++++++++- cmd/ocgo/main_test.go | 102 ++++++++++ 3 files changed, 623 insertions(+), 13 deletions(-) create mode 100644 cmd/ocgo/main_test.go diff --git a/README.md b/README.md index db95615..6ec6897 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,33 @@ # ocgo -`ocgo` is a small Go CLI that lets [Claude Code](https://docs.anthropic.com/en/docs/claude-code) run against an OpenCode Go subscription. It starts a local Anthropic-compatible proxy, translates Claude Code's Anthropic Messages API requests to OpenCode Go's OpenAI-compatible chat completions endpoint, and launches `claude` with the right environment variables. +`ocgo` is a small Go CLI that lets [Claude Code](https://docs.anthropic.com/en/docs/claude-code) and [Codex CLI](https://developers.openai.com/codex/cli/) run against an OpenCode Go subscription. It starts a local compatibility proxy, translates Claude Code's Anthropic Messages API requests when needed, exposes OpenAI-compatible endpoints for Codex, and launches tools with the right configuration. ```bash +# 1. Setup your OpenCode API key ocgo setup + +# 2. Start coding! ocgo launch claude --model kimi-k2.6 +ocgo launch codex --model kimi-k2.6 ``` -Use your OpenCode Go subscription from Claude Code in one command — no manual proxy setup required. +Use your OpenCode Go subscription from Claude Code or Codex CLI in one command — no manual proxy setup required. ## Features - Save and reuse your OpenCode Go API key. - List known OpenCode Go model IDs. - Run Claude Code through OpenCode Go with one command. +- Run Codex CLI through OpenCode Go with one command. - Start, stop, and inspect a local proxy server. +- Exposes Anthropic-compatible and OpenAI-compatible local API layers. - Supports streaming text responses and basic tool-call translation. ## Requirements - Go 1.22 or newer. - A valid OpenCode Go API key. -- Claude Code installed and available as `claude` in your `PATH` when using `ocgo launch claude`. +- Claude Code or Codex CLI installed and available. ## Installation @@ -131,6 +137,55 @@ ANTHROPIC_SMALL_FAST_MODEL= If Claude Code requests a Claude model name or does not provide a model, `ocgo` defaults the upstream OpenCode Go model to `kimi-k2.6`. +### Launch Codex CLI + +Start Codex CLI through the local proxy: + +```bash +ocgo launch codex +``` + +Use a specific OpenCode Go model: + +```bash +ocgo launch codex --model kimi-k2.6 +``` + +Pass arguments through to Codex after `--`: + +```bash +ocgo launch codex --model kimi-k2.6 -- --sandbox workspace-write +``` + +Configure Codex without launching it: + +```bash +ocgo launch codex --config +``` + +When `ocgo launch codex` runs, it writes or updates this profile in `~/.codex/config.toml`: + +```toml +[profiles.ocgo-launch] +openai_base_url = "http://127.0.0.1:3456/v1/" +forced_login_method = "api" +model_provider = "ocgo-launch" +model_catalog_json = "/Users/you/.codex/ocgo-models.json" + +[model_providers.ocgo-launch] +name = "OpenCode Go" +base_url = "http://127.0.0.1:3456/v1/" +wire_api = "responses" +``` + +It then launches: + +```bash +codex --profile ocgo-launch -m +``` + +The Codex process receives `OPENAI_API_KEY=ocgo`; the local proxy injects your real OpenCode Go API key upstream. `ocgo` also writes `~/.codex/ocgo-models.json` so Codex has metadata for OpenCode Go model IDs such as `deepseek-v4-pro`. + ## Proxy commands Run the proxy in the foreground: @@ -256,23 +311,31 @@ The script builds macOS/Linux `amd64` and `arm64` archives, uploads them to GitH ## How it works -`ocgo` exposes a local subset of the Anthropic API used by Claude Code: +`ocgo` exposes a local compatibility API used by Claude Code and Codex CLI: - `GET /health` - `POST /v1/messages` - `POST /v1/messages/count_tokens` +- `POST /v1/chat/completions` +- `POST /v1/responses` -Requests sent to `/v1/messages` are converted into OpenAI-compatible chat completion requests and forwarded to: +Requests sent to `/v1/messages` are converted from Anthropic Messages format into OpenAI-compatible chat completion requests. + +Requests sent to `/v1/chat/completions` are passed through as OpenAI-compatible chat completion requests while `ocgo` injects the configured OpenCode Go API key. + +Requests sent to `/v1/responses` use a lightweight OpenAI Responses API adapter for Codex CLI. The adapter converts common Responses input, tool definitions, and streaming text events to and from chat completions. + +All upstream requests are forwarded to: ```text https://opencode.ai/zen/go/v1/chat/completions ``` -Responses are converted back into Anthropic-compatible responses for Claude Code. +Claude Code responses are converted back into Anthropic-compatible responses. Codex responses are returned in OpenAI-compatible Chat Completions or Responses API shapes depending on the requested endpoint. ## Limitations -`ocgo` is intentionally lightweight. Token counting currently returns `0`, and Anthropic/OpenAI compatibility is focused on the request and response shapes needed by Claude Code rather than full API parity. +`ocgo` is intentionally lightweight. Token counting currently returns `0`, and Anthropic/OpenAI compatibility is focused on the request and response shapes needed by Claude Code and Codex CLI rather than full API parity. The `/v1/responses` adapter is minimal and targets text/tool workflows used by Codex; it is not a complete OpenAI Responses API implementation. ## License diff --git a/cmd/ocgo/main.go b/cmd/ocgo/main.go index 3817839..dcfa87d 100644 --- a/cmd/ocgo/main.go +++ b/cmd/ocgo/main.go @@ -21,10 +21,11 @@ import ( ) const ( - appName = "ocgo" - defaultHost = "127.0.0.1" - defaultPort = 3456 - openAIURL = "https://opencode.ai/zen/go/v1/chat/completions" + appName = "ocgo" + defaultHost = "127.0.0.1" + defaultPort = 3456 + openAIURL = "https://opencode.ai/zen/go/v1/chat/completions" + codexProfileName = "ocgo-launch" ) var version = "dev" @@ -67,6 +68,24 @@ type OAIRequest struct { Tools []OAITool `json:"tools,omitempty"` } +type ResponsesRequest struct { + Model string `json:"model"` + Input json.RawMessage `json:"input"` + Instructions string `json:"instructions,omitempty"` + Stream bool `json:"stream,omitempty"` + MaxTokens int `json:"max_output_tokens,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + TopP *float64 `json:"top_p,omitempty"` + Tools []ResponseTool `json:"tools,omitempty"` +} + +type ResponseTool struct { + Type string `json:"type"` + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + Parameters json.RawMessage `json:"parameters,omitempty"` +} + type OAIMessage struct { Role string `json:"role"` Content string `json:"content,omitempty"` @@ -135,15 +154,20 @@ func setupCmd() *cobra.Command { func listCmd() *cobra.Command { return &cobra.Command{Use: "list", Aliases: []string{"ls", "models"}, Short: "List OpenCode Go models", Run: func(cmd *cobra.Command, args []string) { fmt.Println("OpenCode Go models:") - for _, m := range []string{"glm-5.1", "glm-5", "kimi-k2.6", "kimi-k2.5", "mimo-v2.5-pro", "mimo-v2.5", "mimo-v2-pro", "mimo-v2-omni", "minimax-m2.7", "minimax-m2.5", "deepseek-v4-pro", "deepseek-v4-flash", "qwen3.6-plus", "qwen3.5-plus"} { + for _, m := range knownModelIDs() { fmt.Printf(" %s\n", m) } }} } +func knownModelIDs() []string { + return []string{"glm-5.1", "glm-5", "kimi-k2.6", "kimi-k2.5", "mimo-v2.5-pro", "mimo-v2.5", "mimo-v2-pro", "mimo-v2-omni", "minimax-m2.7", "minimax-m2.5", "deepseek-v4-pro", "deepseek-v4-flash", "qwen3.6-plus", "qwen3.5-plus"} +} + func launchCmd() *cobra.Command { var model string var yes bool + var codexConfigOnly bool cmd := &cobra.Command{Use: "launch", Short: "Launch tools through ocgo"} claude := &cobra.Command{Use: "claude [-- claude args...]", Short: "Launch Claude Code through OpenCode Go", Args: cobra.ArbitraryArgs, RunE: func(cmd *cobra.Command, args []string) error { cfg, err := loadConfig() @@ -176,7 +200,46 @@ func launchCmd() *cobra.Command { }} claude.Flags().StringVar(&model, "model", "", "OpenCode Go model ID") claude.Flags().BoolVar(&yes, "yes", false, "Allow Claude Code to skip permission prompts") - cmd.AddCommand(claude) + codex := &cobra.Command{Use: "codex [-- codex args...]", Short: "Launch Codex CLI through OpenCode Go", Args: cobra.ArbitraryArgs, RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := loadConfig() + if err != nil { + return err + } + base := fmt.Sprintf("http://%s:%d", cfg.Host, cfg.Port) + if err := ensureCodexConfig(base); err != nil { + return fmt.Errorf("failed to configure codex: %w", err) + } + if codexConfigOnly { + fmt.Printf("Configured Codex profile %q in %s\n", codexProfileName, codexConfigFile()) + return nil + } + if err := checkCodexVersion(); err != nil { + return err + } + serverCmd, err := startLaunchServer(base) + if err != nil { + return err + } + if serverCmd != nil { + defer stopManagedServer(serverCmd) + } + codexArgs := []string{"--profile", codexProfileName} + if model != "" { + codexArgs = append(codexArgs, "-m", model) + } + codexArgs = append(codexArgs, args...) + bin, err := exec.LookPath("codex") + if err != nil { + return fmt.Errorf("codex not found in PATH; install with: npm install -g @openai/codex: %w", err) + } + c := exec.Command(bin, codexArgs...) + c.Stdin, c.Stdout, c.Stderr = os.Stdin, os.Stdout, os.Stderr + c.Env = append(os.Environ(), "OPENAI_API_KEY=ocgo") + return c.Run() + }} + codex.Flags().StringVar(&model, "model", "", "OpenCode Go model ID") + codex.Flags().BoolVar(&codexConfigOnly, "config", false, "Configure Codex profile without launching") + cmd.AddCommand(claude, codex) return cmd } @@ -246,6 +309,8 @@ func runServer(cfg Config) error { mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("ok\n")) }) mux.HandleFunc("/v1/messages/count_tokens", countTokens) mux.HandleFunc("/v1/messages", func(w http.ResponseWriter, r *http.Request) { proxyMessages(w, r, cfg) }) + mux.HandleFunc("/v1/chat/completions", func(w http.ResponseWriter, r *http.Request) { proxyChatCompletions(w, r, cfg) }) + mux.HandleFunc("/v1/responses", func(w http.ResponseWriter, r *http.Request) { proxyResponses(w, r, cfg) }) addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port) fmt.Printf("ocgo proxy listening on http://%s\n", addr) return http.ListenAndServe(addr, mux) @@ -288,6 +353,79 @@ func proxyMessages(w http.ResponseWriter, r *http.Request, cfg Config) { writeAnthropicResponse(w, resp.Body, or.Model) } +func proxyChatCompletions(w http.ResponseWriter, r *http.Request, cfg Config) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + req, err := http.NewRequestWithContext(r.Context(), http.MethodPost, openAIURL, bytes.NewReader(body)) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + req.Header.Set("Authorization", "Bearer "+cfg.APIKey) + req.Header.Set("Content-Type", "application/json") + resp, err := (&http.Client{Timeout: 10 * time.Minute}).Do(req) + if err != nil { + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + defer resp.Body.Close() + copyHeaders(w.Header(), resp.Header) + w.WriteHeader(resp.StatusCode) + _, _ = io.Copy(w, resp.Body) +} + +func proxyResponses(w http.ResponseWriter, r *http.Request, cfg Config) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var rr ResponsesRequest + if err := json.NewDecoder(r.Body).Decode(&rr); err != nil { + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + or := responsesToChat(rr) + body, _ := json.Marshal(or) + req, err := http.NewRequestWithContext(r.Context(), http.MethodPost, openAIURL, bytes.NewReader(body)) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + req.Header.Set("Authorization", "Bearer "+cfg.APIKey) + req.Header.Set("Content-Type", "application/json") + resp, err := (&http.Client{Timeout: 10 * time.Minute}).Do(req) + if err != nil { + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + w.WriteHeader(resp.StatusCode) + _, _ = io.Copy(w, resp.Body) + return + } + if rr.Stream { + streamResponses(w, resp.Body, or.Model) + return + } + writeResponsesResponse(w, resp.Body, or.Model) +} + +func copyHeaders(dst, src http.Header) { + for k, vals := range src { + for _, v := range vals { + dst.Add(k, v) + } + } +} + func convertRequest(ar AnthropicRequest) OAIRequest { model := ar.Model if model == "" || strings.HasPrefix(model, "claude-") { @@ -306,6 +444,101 @@ func convertRequest(ar AnthropicRequest) OAIRequest { return out } +func responsesToChat(rr ResponsesRequest) OAIRequest { + model := rr.Model + if model == "" { + model = "kimi-k2.6" + } + out := OAIRequest{Model: model, Stream: rr.Stream, MaxTokens: rr.MaxTokens, Temperature: rr.Temperature, TopP: rr.TopP} + if rr.Instructions != "" { + out.Messages = append(out.Messages, OAIMessage{Role: "system", Content: rr.Instructions}) + } + out.Messages = append(out.Messages, responsesInputToMessages(rr.Input)...) + for _, t := range rr.Tools { + if t.Type == "function" || t.Name != "" { + out.Tools = append(out.Tools, OAITool{Type: "function", Function: OAIFunction{Name: t.Name, Description: t.Description, Parameters: t.Parameters}}) + } + } + return out +} + +func responsesInputToMessages(raw json.RawMessage) []OAIMessage { + if len(raw) == 0 { + return nil + } + var s string + if json.Unmarshal(raw, &s) == nil { + return []OAIMessage{{Role: "user", Content: s}} + } + var items []map[string]json.RawMessage + if json.Unmarshal(raw, &items) != nil { + return []OAIMessage{{Role: "user", Content: string(raw)}} + } + var out []OAIMessage + var pendingCalls []OAIToolCall + for _, item := range items { + var typ, role string + _ = json.Unmarshal(item["type"], &typ) + _ = json.Unmarshal(item["role"], &role) + switch typ { + case "message", "": + if role == "developer" { + role = "system" + } + if role == "" { + role = "user" + } + out = append(out, OAIMessage{Role: role, Content: responsesContentText(item["content"])}) + case "function_call": + var id, callID, name, args string + _ = json.Unmarshal(item["id"], &id) + _ = json.Unmarshal(item["call_id"], &callID) + _ = json.Unmarshal(item["name"], &name) + _ = json.Unmarshal(item["arguments"], &args) + if id == "" { + id = callID + } + pendingCalls = append(pendingCalls, OAIToolCall{ID: id, Type: "function", Function: OAICallFunction{Name: name, Arguments: args}}) + case "function_call_output": + if len(pendingCalls) > 0 { + out = append(out, OAIMessage{Role: "assistant", ToolCalls: pendingCalls}) + pendingCalls = nil + } + var callID string + _ = json.Unmarshal(item["call_id"], &callID) + out = append(out, OAIMessage{Role: "tool", ToolCallID: callID, Content: responsesContentText(item["output"])}) + } + } + if len(pendingCalls) > 0 { + out = append(out, OAIMessage{Role: "assistant", ToolCalls: pendingCalls}) + } + return out +} + +func responsesContentText(raw json.RawMessage) string { + if len(raw) == 0 { + return "" + } + var s string + if json.Unmarshal(raw, &s) == nil { + return s + } + var parts []map[string]json.RawMessage + if json.Unmarshal(raw, &parts) != nil { + return string(raw) + } + var b strings.Builder + for _, p := range parts { + for _, key := range []string{"text", "output_text"} { + var v string + if json.Unmarshal(p[key], &v) == nil { + b.WriteString(v) + } + } + } + return b.String() +} + func contentToOpenAI(m AMessage) []OAIMessage { var s string if json.Unmarshal(m.Content, &s) == nil { @@ -443,6 +676,69 @@ func writeAnthropicResponse(w http.ResponseWriter, body io.Reader, model string) _ = json.NewEncoder(w).Encode(map[string]any{"id": "ocgo", "type": "message", "role": "assistant", "model": model, "content": []map[string]string{{"type": "text", "text": text}}, "stop_reason": "end_turn", "usage": map[string]int{"input_tokens": 0, "output_tokens": 0}}) } +func streamResponses(w http.ResponseWriter, body io.Reader, model string) { + w.Header().Set("Content-Type", "text/event-stream") + flusher, _ := w.(http.Flusher) + id := "resp_ocgo" + writeResponseEvent(w, "response.created", map[string]any{"type": "response.created", "response": map[string]any{"id": id, "object": "response", "model": model, "status": "in_progress", "output": []any{}}}) + writeResponseEvent(w, "response.output_item.added", map[string]any{"type": "response.output_item.added", "output_index": 0, "item": map[string]any{"id": "msg_ocgo", "type": "message", "role": "assistant", "content": []any{}}}) + writeResponseEvent(w, "response.content_part.added", map[string]any{"type": "response.content_part.added", "item_id": "msg_ocgo", "output_index": 0, "content_index": 0, "part": map[string]any{"type": "output_text", "text": ""}}) + if flusher != nil { + flusher.Flush() + } + s := bufio.NewScanner(body) + for s.Scan() { + line := strings.TrimSpace(s.Text()) + if !strings.HasPrefix(line, "data:") { + continue + } + data := strings.TrimSpace(strings.TrimPrefix(line, "data:")) + if data == "[DONE]" { + break + } + if delta := openAITextDelta([]byte(data)); delta != "" { + writeResponseEvent(w, "response.output_text.delta", map[string]any{"type": "response.output_text.delta", "item_id": "msg_ocgo", "output_index": 0, "content_index": 0, "delta": delta}) + if flusher != nil { + flusher.Flush() + } + } + } + writeResponseEvent(w, "response.output_text.done", map[string]any{"type": "response.output_text.done", "item_id": "msg_ocgo", "output_index": 0, "content_index": 0, "text": ""}) + writeResponseEvent(w, "response.completed", map[string]any{"type": "response.completed", "response": map[string]any{"id": id, "object": "response", "model": model, "status": "completed", "output": []any{}}}) +} + +func writeResponseEvent(w io.Writer, event string, payload any) { + b, _ := json.Marshal(payload) + fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, b) +} + +func writeResponsesResponse(w http.ResponseWriter, body io.Reader, model string) { + var v struct { + Choices []struct { + Message struct { + Content string `json:"content"` + ToolCalls []OAIToolCall `json:"tool_calls"` + } `json:"message"` + } `json:"choices"` + } + _ = json.NewDecoder(body).Decode(&v) + text := "" + var output []any + if len(v.Choices) > 0 { + text = v.Choices[0].Message.Content + if len(v.Choices[0].Message.ToolCalls) > 0 { + for _, call := range v.Choices[0].Message.ToolCalls { + output = append(output, map[string]any{"id": call.ID, "type": "function_call", "call_id": call.ID, "name": call.Function.Name, "arguments": call.Function.Arguments}) + } + } + } + if len(output) == 0 { + output = append(output, map[string]any{"id": "msg_ocgo", "type": "message", "role": "assistant", "content": []map[string]string{{"type": "output_text", "text": text}}}) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"id": "resp_ocgo", "object": "response", "created_at": time.Now().Unix(), "model": model, "status": "completed", "output": output, "usage": map[string]int{"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}}) +} + func countTokens(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(map[string]int{"input_tokens": 0}) } @@ -539,6 +835,155 @@ func configDir() string { home, _ := os.UserHomeDir(); return filepath.Join(hom func configFile() string { return filepath.Join(configDir(), "config.json") } func pidFile() string { return filepath.Join(configDir(), "ocgo.pid") } +func codexConfigFile() string { + home, _ := os.UserHomeDir() + return filepath.Join(home, ".codex", "config.toml") +} + +func codexModelCatalogFile() string { + home, _ := os.UserHomeDir() + return filepath.Join(home, ".codex", "ocgo-models.json") +} + +func ensureCodexConfig(base string) error { + path := codexConfigFile() + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return err + } + if err := writeCodexModelCatalog(codexModelCatalogFile()); err != nil { + return err + } + return writeCodexProfile(path, strings.TrimRight(base, "/")+"/v1/") +} + +func writeCodexProfile(path, baseURL string) error { + catalogPath := codexModelCatalogFile() + sections := []struct { + header string + lines []string + }{ + {fmt.Sprintf("[profiles.%s]", codexProfileName), []string{fmt.Sprintf("openai_base_url = %q", baseURL), `forced_login_method = "api"`, fmt.Sprintf("model_provider = %q", codexProfileName), fmt.Sprintf("model_catalog_json = %q", catalogPath)}}, + {fmt.Sprintf("[model_providers.%s]", codexProfileName), []string{`name = "OpenCode Go"`, fmt.Sprintf("base_url = %q", baseURL), `wire_api = "responses"`}}, + } + b, err := os.ReadFile(path) + text := "" + if err == nil { + text = string(b) + } else if !errors.Is(err, os.ErrNotExist) { + return err + } + for _, s := range sections { + block := strings.Join(append([]string{s.header}, s.lines...), "\n") + "\n" + if idx := strings.Index(text, s.header); idx >= 0 { + rest := text[idx+len(s.header):] + if endIdx := strings.Index(rest, "\n["); endIdx >= 0 { + text = text[:idx] + block + rest[endIdx+1:] + } else { + text = text[:idx] + block + } + } else { + if text != "" && !strings.HasSuffix(text, "\n") { + text += "\n" + } + if text != "" { + text += "\n" + } + text += block + } + } + return os.WriteFile(path, []byte(text), 0644) +} + +func writeCodexModelCatalog(path string) error { + models := make([]map[string]any, 0, len(knownModelIDs())) + for i, id := range knownModelIDs() { + models = append(models, map[string]any{ + "slug": id, + "display_name": id, + "description": "OpenCode Go model", + "default_reasoning_level": nil, + "supported_reasoning_levels": []any{}, + "shell_type": "shell_command", + "visibility": "list", + "supported_in_api": true, + "priority": i, + "availability_nux": nil, + "upgrade": nil, + "base_instructions": "You are Codex, a coding agent running in a terminal-based coding assistant.", + "supports_reasoning_summaries": false, + "default_reasoning_summary": "none", + "support_verbosity": false, + "default_verbosity": nil, + "apply_patch_tool_type": nil, + "web_search_tool_type": "text", + "truncation_policy": map[string]any{"mode": "tokens", "limit": 10000}, + "supports_parallel_tool_calls": false, + "supports_image_detail_original": false, + "context_window": 128000, + "max_context_window": 128000, + "auto_compact_token_limit": nil, + "effective_context_window_percent": 95, + "experimental_supported_tools": []any{}, + "input_modalities": []string{"text"}, + "supports_search_tool": false, + }) + } + b, err := json.MarshalIndent(map[string]any{"models": models}, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, append(b, '\n'), 0644) +} + +func checkCodexVersion() error { + if _, err := exec.LookPath("codex"); err != nil { + return fmt.Errorf("codex is not installed, install with: npm install -g @openai/codex") + } + out, err := exec.Command("codex", "--version").Output() + if err != nil { + return fmt.Errorf("failed to get codex version: %w", err) + } + fields := strings.Fields(strings.TrimSpace(string(out))) + if len(fields) == 0 { + return fmt.Errorf("unexpected codex version output: %s", string(out)) + } + version := fields[len(fields)-1] + if compareVersions(version, "0.81.0") < 0 { + return fmt.Errorf("codex version %s is too old, minimum required is 0.81.0; update with: npm update -g @openai/codex", version) + } + return nil +} + +func compareVersions(a, b string) int { + ap, bp := versionParts(a), versionParts(b) + for i := 0; i < 3; i++ { + if ap[i] > bp[i] { + return 1 + } + if ap[i] < bp[i] { + return -1 + } + } + return 0 +} + +func versionParts(v string) [3]int { + v = strings.TrimPrefix(v, "v") + fields := strings.Split(v, ".") + var out [3]int + for i := 0; i < len(fields) && i < 3; i++ { + part := fields[i] + for j, r := range part { + if r < '0' || r > '9' { + part = part[:j] + break + } + } + out[i], _ = strconv.Atoi(part) + } + return out +} + func saveConfig(cfg Config) error { if err := os.MkdirAll(configDir(), 0755); err != nil { return err diff --git a/cmd/ocgo/main_test.go b/cmd/ocgo/main_test.go new file mode 100644 index 0000000..7cdb664 --- /dev/null +++ b/cmd/ocgo/main_test.go @@ -0,0 +1,102 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestWriteCodexProfile(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.toml") + if err := writeCodexProfile(path, "http://127.0.0.1:3456/v1/"); err != nil { + t.Fatal(err) + } + b, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + content := string(b) + for _, want := range []string{ + "[profiles.ocgo-launch]", + `openai_base_url = "http://127.0.0.1:3456/v1/"`, + `forced_login_method = "api"`, + `model_provider = "ocgo-launch"`, + `model_catalog_json = `, + "[model_providers.ocgo-launch]", + `name = "OpenCode Go"`, + `base_url = "http://127.0.0.1:3456/v1/"`, + `wire_api = "responses"`, + } { + if !strings.Contains(content, want) { + t.Fatalf("missing %q in:\n%s", want, content) + } + } +} + +func TestWriteCodexProfileReplacesExistingSections(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.toml") + existing := "[profiles.ocgo-launch]\nopenai_base_url = \"http://old/v1/\"\n\n[other]\nkey = \"value\"\n\n[model_providers.ocgo-launch]\nbase_url = \"http://old/v1/\"\n" + if err := os.WriteFile(path, []byte(existing), 0644); err != nil { + t.Fatal(err) + } + if err := writeCodexProfile(path, "http://new/v1/"); err != nil { + t.Fatal(err) + } + b, _ := os.ReadFile(path) + content := string(b) + if strings.Contains(content, "http://old") { + t.Fatalf("old profile was not replaced:\n%s", content) + } + if strings.Count(content, "[profiles.ocgo-launch]") != 1 || strings.Count(content, "[model_providers.ocgo-launch]") != 1 { + t.Fatalf("profile sections should be unique:\n%s", content) + } + if !strings.Contains(content, "[other]") || !strings.Contains(content, `key = "value"`) { + t.Fatalf("unrelated section was not preserved:\n%s", content) + } +} + +func TestWriteCodexModelCatalog(t *testing.T) { + path := filepath.Join(t.TempDir(), "ocgo-models.json") + if err := writeCodexModelCatalog(path); err != nil { + t.Fatal(err) + } + b, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + content := string(b) + for _, want := range []string{`"models"`, `"slug": "deepseek-v4-pro"`, `"context_window": 128000`, `"truncation_policy"`} { + if !strings.Contains(content, want) { + t.Fatalf("missing %q in:\n%s", want, content) + } + } +} + +func TestCompareVersions(t *testing.T) { + if compareVersions("0.80.9", "0.81.0") >= 0 { + t.Fatal("0.80.9 should be older") + } + if compareVersions("0.81.0", "0.81.0") != 0 { + t.Fatal("same versions should compare equal") + } + if compareVersions("codex-cli", "0.81.0") >= 0 { + t.Fatal("invalid version should compare as old") + } + if compareVersions("0.87.0", "0.81.0") <= 0 { + t.Fatal("0.87.0 should be newer") + } +} + +func TestResponsesInputToMessages(t *testing.T) { + messages := responsesInputToMessages([]byte(`[{"type":"message","role":"developer","content":"rules"},{"type":"message","role":"user","content":[{"type":"input_text","text":"hello"}]}]`)) + if len(messages) != 2 { + t.Fatalf("got %d messages", len(messages)) + } + if messages[0].Role != "system" || messages[0].Content != "rules" { + t.Fatalf("bad developer conversion: %+v", messages[0]) + } + if messages[1].Role != "user" || messages[1].Content != "hello" { + t.Fatalf("bad user conversion: %+v", messages[1]) + } +}