feat: add support for codex cli
This commit is contained in:
parent
502e6d28c7
commit
1c95a72249
3 changed files with 623 additions and 13 deletions
77
README.md
77
README.md
|
|
@ -1,27 +1,33 @@
|
||||||
# ocgo
|
# 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
|
```bash
|
||||||
|
# 1. Setup your OpenCode API key
|
||||||
ocgo setup
|
ocgo setup
|
||||||
|
|
||||||
|
# 2. Start coding!
|
||||||
ocgo launch claude --model kimi-k2.6
|
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
|
## Features
|
||||||
|
|
||||||
- Save and reuse your OpenCode Go API key.
|
- Save and reuse your OpenCode Go API key.
|
||||||
- List known OpenCode Go model IDs.
|
- List known OpenCode Go model IDs.
|
||||||
- Run Claude Code through OpenCode Go with one command.
|
- 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.
|
- 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.
|
- Supports streaming text responses and basic tool-call translation.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- Go 1.22 or newer.
|
- Go 1.22 or newer.
|
||||||
- A valid OpenCode Go API key.
|
- 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
|
## Installation
|
||||||
|
|
||||||
|
|
@ -131,6 +137,55 @@ ANTHROPIC_SMALL_FAST_MODEL=<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`.
|
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 <model>
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
## Proxy commands
|
||||||
|
|
||||||
Run the proxy in the foreground:
|
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
|
## 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`
|
- `GET /health`
|
||||||
- `POST /v1/messages`
|
- `POST /v1/messages`
|
||||||
- `POST /v1/messages/count_tokens`
|
- `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
|
```text
|
||||||
https://opencode.ai/zen/go/v1/chat/completions
|
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
|
## 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
|
## License
|
||||||
|
|
||||||
|
|
|
||||||
457
cmd/ocgo/main.go
457
cmd/ocgo/main.go
|
|
@ -21,10 +21,11 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
appName = "ocgo"
|
appName = "ocgo"
|
||||||
defaultHost = "127.0.0.1"
|
defaultHost = "127.0.0.1"
|
||||||
defaultPort = 3456
|
defaultPort = 3456
|
||||||
openAIURL = "https://opencode.ai/zen/go/v1/chat/completions"
|
openAIURL = "https://opencode.ai/zen/go/v1/chat/completions"
|
||||||
|
codexProfileName = "ocgo-launch"
|
||||||
)
|
)
|
||||||
|
|
||||||
var version = "dev"
|
var version = "dev"
|
||||||
|
|
@ -67,6 +68,24 @@ type OAIRequest struct {
|
||||||
Tools []OAITool `json:"tools,omitempty"`
|
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 {
|
type OAIMessage struct {
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Content string `json:"content,omitempty"`
|
Content string `json:"content,omitempty"`
|
||||||
|
|
@ -135,15 +154,20 @@ func setupCmd() *cobra.Command {
|
||||||
func listCmd() *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) {
|
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:")
|
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)
|
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 {
|
func launchCmd() *cobra.Command {
|
||||||
var model string
|
var model string
|
||||||
var yes bool
|
var yes bool
|
||||||
|
var codexConfigOnly bool
|
||||||
cmd := &cobra.Command{Use: "launch", Short: "Launch tools through ocgo"}
|
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 {
|
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()
|
cfg, err := loadConfig()
|
||||||
|
|
@ -176,7 +200,46 @@ func launchCmd() *cobra.Command {
|
||||||
}}
|
}}
|
||||||
claude.Flags().StringVar(&model, "model", "", "OpenCode Go model ID")
|
claude.Flags().StringVar(&model, "model", "", "OpenCode Go model ID")
|
||||||
claude.Flags().BoolVar(&yes, "yes", false, "Allow Claude Code to skip permission prompts")
|
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
|
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("/health", func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("ok\n")) })
|
||||||
mux.HandleFunc("/v1/messages/count_tokens", countTokens)
|
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/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)
|
addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
|
||||||
fmt.Printf("ocgo proxy listening on http://%s\n", addr)
|
fmt.Printf("ocgo proxy listening on http://%s\n", addr)
|
||||||
return http.ListenAndServe(addr, mux)
|
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)
|
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 {
|
func convertRequest(ar AnthropicRequest) OAIRequest {
|
||||||
model := ar.Model
|
model := ar.Model
|
||||||
if model == "" || strings.HasPrefix(model, "claude-") {
|
if model == "" || strings.HasPrefix(model, "claude-") {
|
||||||
|
|
@ -306,6 +444,101 @@ func convertRequest(ar AnthropicRequest) OAIRequest {
|
||||||
return out
|
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 {
|
func contentToOpenAI(m AMessage) []OAIMessage {
|
||||||
var s string
|
var s string
|
||||||
if json.Unmarshal(m.Content, &s) == nil {
|
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}})
|
_ = 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) {
|
func countTokens(w http.ResponseWriter, r *http.Request) {
|
||||||
_ = json.NewEncoder(w).Encode(map[string]int{"input_tokens": 0})
|
_ = 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 configFile() string { return filepath.Join(configDir(), "config.json") }
|
||||||
func pidFile() string { return filepath.Join(configDir(), "ocgo.pid") }
|
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 {
|
func saveConfig(cfg Config) error {
|
||||||
if err := os.MkdirAll(configDir(), 0755); err != nil {
|
if err := os.MkdirAll(configDir(), 0755); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
|
||||||
102
cmd/ocgo/main_test.go
Normal file
102
cmd/ocgo/main_test.go
Normal file
|
|
@ -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])
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue