Prepare automated releases
This commit is contained in:
parent
3d43448442
commit
50a2d887fd
10 changed files with 850 additions and 2 deletions
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
bin/
|
||||||
|
dist/
|
||||||
|
*.exe
|
||||||
|
*.pid
|
||||||
|
.env
|
||||||
|
.tmp/
|
||||||
|
oc-go-cc
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Emanuel Casco
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
22
Makefile
Normal file
22
Makefile
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
.PHONY: build run test clean install release
|
||||||
|
|
||||||
|
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
|
||||||
|
|
||||||
|
build:
|
||||||
|
go build -ldflags "-X main.version=$(VERSION)" -o bin/ocgo ./cmd/ocgo
|
||||||
|
|
||||||
|
run:
|
||||||
|
go run ./cmd/ocgo
|
||||||
|
|
||||||
|
test:
|
||||||
|
go test ./...
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -rf bin
|
||||||
|
|
||||||
|
install: build
|
||||||
|
install -m 0755 bin/ocgo $(HOME)/go/bin/ocgo
|
||||||
|
|
||||||
|
release:
|
||||||
|
@[ -n "$(TAG)" ] || (echo "Usage: make release TAG=v0.1.0" && exit 1)
|
||||||
|
./scripts/release.sh "$(TAG)"
|
||||||
|
|
@ -226,10 +226,10 @@ gh auth login
|
||||||
Release a new version:
|
Release a new version:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
HOMEBREW_TAP_REPO=YOUR_GITHUB_USER/homebrew-tap make release TAG=v0.1.0
|
make release TAG=v0.1.0
|
||||||
```
|
```
|
||||||
|
|
||||||
Optionally set `GITHUB_REPOSITORY=owner/repo` if the script cannot infer it from `origin`.
|
By default, releases are published to `emanuelcasco/ocgo` and the Homebrew formula is pushed to `emanuelcasco/homebrew-tap`. You can override those with `GITHUB_REPOSITORY=owner/repo` and `HOMEBREW_TAP_REPO=owner/homebrew-tap`.
|
||||||
|
|
||||||
The script builds macOS/Linux `amd64` and `arm64` archives, uploads them to GitHub Releases, and commits `Formula/ocgo.rb` to the tap repo.
|
The script builds macOS/Linux `amd64` and `arm64` archives, uploads them to GitHub Releases, and commits `Formula/ocgo.rb` to the tap repo.
|
||||||
|
|
||||||
|
|
|
||||||
9
cmd/ocgo/detach_unix.go
Normal file
9
cmd/ocgo/detach_unix.go
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import "syscall"
|
||||||
|
|
||||||
|
func detachedAttrs() *syscall.SysProcAttr {
|
||||||
|
return &syscall.SysProcAttr{Setsid: true}
|
||||||
|
}
|
||||||
7
cmd/ocgo/detach_windows.go
Normal file
7
cmd/ocgo/detach_windows.go
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import "syscall"
|
||||||
|
|
||||||
|
func detachedAttrs() *syscall.SysProcAttr { return nil }
|
||||||
601
cmd/ocgo/main.go
Normal file
601
cmd/ocgo/main.go
Normal file
|
|
@ -0,0 +1,601 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
appName = "ocgo"
|
||||||
|
defaultHost = "127.0.0.1"
|
||||||
|
defaultPort = 3456
|
||||||
|
openAIURL = "https://opencode.ai/zen/go/v1/chat/completions"
|
||||||
|
)
|
||||||
|
|
||||||
|
var version = "dev"
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
APIKey string `json:"api_key"`
|
||||||
|
Host string `json:"host"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AnthropicRequest struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
MaxTokens int `json:"max_tokens"`
|
||||||
|
System json.RawMessage `json:"system,omitempty"`
|
||||||
|
Messages []AMessage `json:"messages"`
|
||||||
|
Stream bool `json:"stream,omitempty"`
|
||||||
|
Tools []ATool `json:"tools,omitempty"`
|
||||||
|
Temperature *float64 `json:"temperature,omitempty"`
|
||||||
|
TopP *float64 `json:"top_p,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AMessage struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content json.RawMessage `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ATool struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
InputSchema json.RawMessage `json:"input_schema,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OAIRequest struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Messages []OAIMessage `json:"messages"`
|
||||||
|
Stream bool `json:"stream,omitempty"`
|
||||||
|
MaxTokens int `json:"max_tokens,omitempty"`
|
||||||
|
Temperature *float64 `json:"temperature,omitempty"`
|
||||||
|
TopP *float64 `json:"top_p,omitempty"`
|
||||||
|
Tools []OAITool `json:"tools,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OAIMessage struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content string `json:"content,omitempty"`
|
||||||
|
ToolCalls []OAIToolCall `json:"tool_calls,omitempty"`
|
||||||
|
ToolCallID string `json:"tool_call_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OAITool struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Function OAIFunction `json:"function"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OAIFunction struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
Parameters json.RawMessage `json:"parameters,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OAIToolCall struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Function OAICallFunction `json:"function"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OAICallFunction struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Arguments string `json:"arguments"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
root := &cobra.Command{Use: appName, Short: "Run Claude Code with OpenCode Go", Version: version}
|
||||||
|
root.AddCommand(setupCmd(), listCmd(), launchCmd(), serveCmd(), stopCmd(), statusCmd())
|
||||||
|
if err := root.Execute(); err != nil {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupCmd() *cobra.Command {
|
||||||
|
var key string
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "setup",
|
||||||
|
Short: "Save your OpenCode Go API key",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if strings.TrimSpace(key) == "" {
|
||||||
|
key = os.Getenv("OCGO_API_KEY")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(key) == "" {
|
||||||
|
fmt.Print("OpenCode Go API key: ")
|
||||||
|
line, err := bufio.NewReader(os.Stdin).ReadString('\n')
|
||||||
|
if err != nil && line == "" {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
key = line
|
||||||
|
}
|
||||||
|
cfg := Config{APIKey: strings.TrimSpace(key), Host: defaultHost, Port: defaultPort}
|
||||||
|
if cfg.APIKey == "" {
|
||||||
|
return errors.New("API key cannot be empty")
|
||||||
|
}
|
||||||
|
return saveConfig(cfg)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().StringVar(&key, "api-key", "", "OpenCode Go API key")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
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"} {
|
||||||
|
fmt.Printf(" %s\n", m)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func launchCmd() *cobra.Command {
|
||||||
|
var model string
|
||||||
|
var yes 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()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
base := fmt.Sprintf("http://%s:%d", cfg.Host, cfg.Port)
|
||||||
|
serverCmd, err := startLaunchServer(base)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if serverCmd != nil {
|
||||||
|
defer stopManagedServer(serverCmd)
|
||||||
|
}
|
||||||
|
claudeArgs := append([]string{}, args...)
|
||||||
|
if yes {
|
||||||
|
claudeArgs = append([]string{"--dangerously-skip-permissions"}, claudeArgs...)
|
||||||
|
}
|
||||||
|
bin, err := exec.LookPath("claude")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("claude not found in PATH: %w", err)
|
||||||
|
}
|
||||||
|
c := exec.Command(bin, claudeArgs...)
|
||||||
|
c.Stdin, c.Stdout, c.Stderr = os.Stdin, os.Stdout, os.Stderr
|
||||||
|
c.Env = append(os.Environ(), "ANTHROPIC_BASE_URL="+base, "ANTHROPIC_AUTH_TOKEN=unused")
|
||||||
|
if model != "" {
|
||||||
|
c.Env = append(c.Env, "ANTHROPIC_MODEL="+model, "ANTHROPIC_SMALL_FAST_MODEL="+model)
|
||||||
|
}
|
||||||
|
return c.Run()
|
||||||
|
}}
|
||||||
|
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)
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveCmd() *cobra.Command {
|
||||||
|
var background bool
|
||||||
|
cmd := &cobra.Command{Use: "serve", Short: "Start local Anthropic-compatible proxy", RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if background {
|
||||||
|
return startBackground()
|
||||||
|
}
|
||||||
|
cfg, err := loadConfig()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return runServer(cfg)
|
||||||
|
}}
|
||||||
|
cmd.Flags().BoolVarP(&background, "background", "b", false, "Run proxy in the background")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{Use: "stop", Short: "Stop background proxy", RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
pid, err := readPID()
|
||||||
|
if err != nil {
|
||||||
|
cfg, cfgErr := loadConfig()
|
||||||
|
if cfgErr != nil {
|
||||||
|
return errors.New("proxy is not running")
|
||||||
|
}
|
||||||
|
pid, err = findListenerPID(cfg.Port)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("proxy is not running")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p, err := os.FindProcess(pid)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_ = os.Remove(pidFile())
|
||||||
|
if err := p.Kill(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Printf("Stopped proxy process %d\n", pid)
|
||||||
|
return nil
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func statusCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{Use: "status", Short: "Show proxy status", Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
cfg, err := loadConfig()
|
||||||
|
if err != nil || !healthy(fmt.Sprintf("http://%s:%d", cfg.Host, cfg.Port)) {
|
||||||
|
fmt.Println("Proxy is not running")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if pid, err := readPID(); err == nil {
|
||||||
|
fmt.Printf("Proxy is running on %s:%d (PID %d)\n", cfg.Host, cfg.Port, pid)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf("Proxy is running on %s:%d (no ocgo PID file)\n", cfg.Host, cfg.Port)
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runServer(cfg Config) error {
|
||||||
|
if err := os.MkdirAll(configDir(), 0755); err == nil {
|
||||||
|
_ = os.WriteFile(pidFile(), []byte(fmt.Sprint(os.Getpid())), 0644)
|
||||||
|
defer os.Remove(pidFile())
|
||||||
|
}
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
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) })
|
||||||
|
addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
|
||||||
|
fmt.Printf("ocgo proxy listening on http://%s\n", addr)
|
||||||
|
return http.ListenAndServe(addr, mux)
|
||||||
|
}
|
||||||
|
|
||||||
|
func proxyMessages(w http.ResponseWriter, r *http.Request, cfg Config) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var ar AnthropicRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&ar); err != nil {
|
||||||
|
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
or := convertRequest(ar)
|
||||||
|
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 ar.Stream {
|
||||||
|
streamAnthropic(w, resp.Body, or.Model)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeAnthropicResponse(w, resp.Body, or.Model)
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertRequest(ar AnthropicRequest) OAIRequest {
|
||||||
|
model := ar.Model
|
||||||
|
if model == "" || strings.HasPrefix(model, "claude-") {
|
||||||
|
model = "kimi-k2.6"
|
||||||
|
}
|
||||||
|
out := OAIRequest{Model: model, Stream: ar.Stream, MaxTokens: ar.MaxTokens, Temperature: ar.Temperature, TopP: ar.TopP}
|
||||||
|
if sys := systemText(ar.System); sys != "" {
|
||||||
|
out.Messages = append(out.Messages, OAIMessage{Role: "system", Content: sys})
|
||||||
|
}
|
||||||
|
for _, m := range ar.Messages {
|
||||||
|
out.Messages = append(out.Messages, contentToOpenAI(m)...)
|
||||||
|
}
|
||||||
|
for _, t := range ar.Tools {
|
||||||
|
out.Tools = append(out.Tools, OAITool{Type: "function", Function: OAIFunction{Name: t.Name, Description: t.Description, Parameters: t.InputSchema}})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func contentToOpenAI(m AMessage) []OAIMessage {
|
||||||
|
var s string
|
||||||
|
if json.Unmarshal(m.Content, &s) == nil {
|
||||||
|
return []OAIMessage{{Role: m.Role, Content: s}}
|
||||||
|
}
|
||||||
|
var blocks []map[string]json.RawMessage
|
||||||
|
if json.Unmarshal(m.Content, &blocks) != nil {
|
||||||
|
return []OAIMessage{{Role: m.Role, Content: string(m.Content)}}
|
||||||
|
}
|
||||||
|
var text strings.Builder
|
||||||
|
var calls []OAIToolCall
|
||||||
|
var toolMsgs []OAIMessage
|
||||||
|
for _, b := range blocks {
|
||||||
|
var typ string
|
||||||
|
_ = json.Unmarshal(b["type"], &typ)
|
||||||
|
switch typ {
|
||||||
|
case "text":
|
||||||
|
var v string
|
||||||
|
_ = json.Unmarshal(b["text"], &v)
|
||||||
|
text.WriteString(v)
|
||||||
|
case "tool_use":
|
||||||
|
var id, name string
|
||||||
|
_ = json.Unmarshal(b["id"], &id)
|
||||||
|
_ = json.Unmarshal(b["name"], &name)
|
||||||
|
args := "{}"
|
||||||
|
if raw := b["input"]; len(raw) > 0 {
|
||||||
|
args = string(raw)
|
||||||
|
}
|
||||||
|
calls = append(calls, OAIToolCall{ID: id, Type: "function", Function: OAICallFunction{Name: name, Arguments: args}})
|
||||||
|
case "tool_result":
|
||||||
|
var id string
|
||||||
|
_ = json.Unmarshal(b["tool_use_id"], &id)
|
||||||
|
toolMsgs = append(toolMsgs, OAIMessage{Role: "tool", ToolCallID: id, Content: blockText(b["content"])})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(calls) > 0 {
|
||||||
|
return []OAIMessage{{Role: "assistant", Content: text.String(), ToolCalls: calls}}
|
||||||
|
}
|
||||||
|
if len(toolMsgs) > 0 {
|
||||||
|
return toolMsgs
|
||||||
|
}
|
||||||
|
return []OAIMessage{{Role: m.Role, Content: text.String()}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func systemText(raw json.RawMessage) string {
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var s string
|
||||||
|
if json.Unmarshal(raw, &s) == nil {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return blockText(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
func blockText(raw json.RawMessage) string {
|
||||||
|
var s string
|
||||||
|
if json.Unmarshal(raw, &s) == nil {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
var blocks []map[string]json.RawMessage
|
||||||
|
if json.Unmarshal(raw, &blocks) != nil {
|
||||||
|
return string(raw)
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
for _, x := range blocks {
|
||||||
|
var t string
|
||||||
|
if json.Unmarshal(x["text"], &t) == nil {
|
||||||
|
b.WriteString(t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func streamAnthropic(w http.ResponseWriter, body io.Reader, model string) {
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
flusher, _ := w.(http.Flusher)
|
||||||
|
fmt.Fprintf(w, "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"ocgo\",\"type\":\"message\",\"role\":\"assistant\",\"model\":%q,\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":0,\"output_tokens\":0}}}\n\n", model)
|
||||||
|
fmt.Fprint(w, "event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n")
|
||||||
|
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 != "" {
|
||||||
|
b, _ := json.Marshal(delta)
|
||||||
|
fmt.Fprintf(w, "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":%s}}\n\n", b)
|
||||||
|
if flusher != nil {
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Fprint(w, "event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\n")
|
||||||
|
fmt.Fprint(w, "event: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null},\"usage\":{\"output_tokens\":0}}\n\n")
|
||||||
|
fmt.Fprint(w, "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func openAITextDelta(data []byte) string {
|
||||||
|
var v struct {
|
||||||
|
Choices []struct {
|
||||||
|
Delta struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
} `json:"delta"`
|
||||||
|
} `json:"choices"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(data, &v)
|
||||||
|
if len(v.Choices) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return v.Choices[0].Delta.Content
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeAnthropicResponse(w http.ResponseWriter, body io.Reader, model string) {
|
||||||
|
var v struct {
|
||||||
|
Choices []struct {
|
||||||
|
Message struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
} `json:"message"`
|
||||||
|
} `json:"choices"`
|
||||||
|
}
|
||||||
|
_ = json.NewDecoder(body).Decode(&v)
|
||||||
|
text := ""
|
||||||
|
if len(v.Choices) > 0 {
|
||||||
|
text = v.Choices[0].Message.Content
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = 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 countTokens(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]int{"input_tokens": 0})
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureServer(base string) error {
|
||||||
|
if healthy(base) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := startBackground(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
for ctx.Err() == nil {
|
||||||
|
if healthy(base) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
}
|
||||||
|
return errors.New("proxy did not start")
|
||||||
|
}
|
||||||
|
|
||||||
|
func startLaunchServer(base string) (*exec.Cmd, error) {
|
||||||
|
if healthy(base) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
cmd, err := startServerProcess(false)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
for ctx.Err() == nil {
|
||||||
|
if healthy(base) {
|
||||||
|
return cmd, nil
|
||||||
|
}
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
}
|
||||||
|
stopManagedServer(cmd)
|
||||||
|
return nil, errors.New("proxy did not start")
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopManagedServer(cmd *exec.Cmd) {
|
||||||
|
if cmd == nil || cmd.Process == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = cmd.Process.Kill()
|
||||||
|
_, _ = cmd.Process.Wait()
|
||||||
|
_ = os.Remove(pidFile())
|
||||||
|
}
|
||||||
|
|
||||||
|
func healthy(base string) bool {
|
||||||
|
c := http.Client{Timeout: 500 * time.Millisecond}
|
||||||
|
resp, err := c.Get(base + "/health")
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
return resp.StatusCode == 200
|
||||||
|
}
|
||||||
|
|
||||||
|
func startBackground() error {
|
||||||
|
_, err := startServerProcess(true)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func startServerProcess(detached bool) (*exec.Cmd, error) {
|
||||||
|
bin, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(configDir(), 0755); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
args := []string{"serve"}
|
||||||
|
cmd := exec.Command(bin, args...)
|
||||||
|
logf, err := os.OpenFile(filepath.Join(configDir(), "ocgo.log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cmd.Stdout, cmd.Stderr = logf, logf
|
||||||
|
cmd.Stdin = nil
|
||||||
|
if detached && runtime.GOOS != "windows" {
|
||||||
|
cmd.SysProcAttr = detachedAttrs()
|
||||||
|
}
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
_ = logf.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return cmd, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func configDir() string { home, _ := os.UserHomeDir(); return filepath.Join(home, ".config", "ocgo") }
|
||||||
|
func configFile() string { return filepath.Join(configDir(), "config.json") }
|
||||||
|
func pidFile() string { return filepath.Join(configDir(), "ocgo.pid") }
|
||||||
|
|
||||||
|
func saveConfig(cfg Config) error {
|
||||||
|
if err := os.MkdirAll(configDir(), 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b, _ := json.MarshalIndent(cfg, "", " ")
|
||||||
|
if err := os.WriteFile(configFile(), append(b, '\n'), 0600); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Printf("Saved config to %s\n", configFile())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadConfig() (Config, error) {
|
||||||
|
cfg := Config{Host: defaultHost, Port: defaultPort, APIKey: os.Getenv("OCGO_API_KEY")}
|
||||||
|
b, err := os.ReadFile(configFile())
|
||||||
|
if err == nil {
|
||||||
|
_ = json.Unmarshal(b, &cfg)
|
||||||
|
}
|
||||||
|
if cfg.APIKey == "" {
|
||||||
|
return cfg, errors.New("missing API key; run: ocgo setup")
|
||||||
|
}
|
||||||
|
if cfg.Host == "" {
|
||||||
|
cfg.Host = defaultHost
|
||||||
|
}
|
||||||
|
if cfg.Port == 0 {
|
||||||
|
cfg.Port = defaultPort
|
||||||
|
}
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readPID() (int, error) {
|
||||||
|
b, err := os.ReadFile(pidFile())
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
var pid int
|
||||||
|
_, err = fmt.Sscan(string(b), &pid)
|
||||||
|
return pid, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func findListenerPID(port int) (int, error) {
|
||||||
|
if port == 0 {
|
||||||
|
return 0, errors.New("missing port")
|
||||||
|
}
|
||||||
|
out, err := exec.Command("lsof", "-nP", "-tiTCP:"+strconv.Itoa(port), "-sTCP:LISTEN").Output()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pid, err := strconv.Atoi(line)
|
||||||
|
if err == nil && pid > 0 {
|
||||||
|
return pid, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, errors.New("no listener found")
|
||||||
|
}
|
||||||
10
go.mod
Normal file
10
go.mod
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
module ocgo
|
||||||
|
|
||||||
|
go 1.22
|
||||||
|
|
||||||
|
require github.com/spf13/cobra v1.8.1
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
|
github.com/spf13/pflag v1.0.5 // indirect
|
||||||
|
)
|
||||||
10
go.sum
Normal file
10
go.sum
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
|
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
|
||||||
|
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
|
||||||
|
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||||
|
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
161
scripts/release.sh
Executable file
161
scripts/release.sh
Executable file
|
|
@ -0,0 +1,161 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
APP_NAME="${APP_NAME:-ocgo}"
|
||||||
|
CMD_PATH="${CMD_PATH:-./cmd/ocgo}"
|
||||||
|
TAG="${1:-${TAG:-}}"
|
||||||
|
|
||||||
|
if [[ -z "$TAG" ]]; then
|
||||||
|
echo "Usage: $0 v0.1.0"
|
||||||
|
echo " or: TAG=v0.1.0 make release"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
VERSION="${TAG#v}"
|
||||||
|
REPO="${GITHUB_REPOSITORY:-emanuelcasco/ocgo}"
|
||||||
|
if [[ -z "$REPO" ]]; then
|
||||||
|
origin_url="$(git config --get remote.origin.url || true)"
|
||||||
|
if [[ "$origin_url" =~ github.com[:/]([^/]+)/([^/.]+)(\.git)?$ ]]; then
|
||||||
|
REPO="${BASH_REMATCH[1]}/${BASH_REMATCH[2]}"
|
||||||
|
else
|
||||||
|
echo "Set GITHUB_REPOSITORY=owner/repo, or configure a GitHub origin remote."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
HOMEBREW_TAP_REPO="${HOMEBREW_TAP_REPO:-emanuelcasco/homebrew-tap}"
|
||||||
|
|
||||||
|
if ! command -v gh >/dev/null 2>&1; then
|
||||||
|
echo "GitHub CLI is required: brew install gh && gh auth login"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! gh auth status >/dev/null 2>&1; then
|
||||||
|
echo "GitHub CLI is not authenticated. Run: gh auth login"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v go >/dev/null 2>&1; then
|
||||||
|
echo "Go is required."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! git diff --quiet || ! git diff --cached --quiet; then
|
||||||
|
echo "Working tree has uncommitted changes. Commit or stash them first."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Verify the project builds/tests before tagging.
|
||||||
|
go test ./...
|
||||||
|
|
||||||
|
if ! git rev-parse "$TAG" >/dev/null 2>&1; then
|
||||||
|
git tag -a "$TAG" -m "$TAG"
|
||||||
|
fi
|
||||||
|
|
||||||
|
git push origin "$TAG"
|
||||||
|
|
||||||
|
DIST_DIR="dist"
|
||||||
|
rm -rf "$DIST_DIR"
|
||||||
|
mkdir -p "$DIST_DIR"
|
||||||
|
|
||||||
|
build_one() {
|
||||||
|
local goos="$1"
|
||||||
|
local goarch="$2"
|
||||||
|
local arch_name="$goarch"
|
||||||
|
if [[ "$goarch" == "amd64" ]]; then
|
||||||
|
arch_name="x86_64"
|
||||||
|
fi
|
||||||
|
|
||||||
|
local dir="$DIST_DIR/${APP_NAME}_${VERSION}_${goos}_${arch_name}"
|
||||||
|
mkdir -p "$dir"
|
||||||
|
|
||||||
|
local bin="$APP_NAME"
|
||||||
|
if [[ "$goos" == "windows" ]]; then
|
||||||
|
bin="$APP_NAME.exe"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Building $goos/$goarch..."
|
||||||
|
CGO_ENABLED=0 GOOS="$goos" GOARCH="$goarch" \
|
||||||
|
go build -trimpath -ldflags "-s -w -X main.version=$VERSION" -o "$dir/$bin" "$CMD_PATH"
|
||||||
|
|
||||||
|
cp README.md "$dir/" 2>/dev/null || true
|
||||||
|
cp LICENSE "$dir/" 2>/dev/null || true
|
||||||
|
|
||||||
|
tar -C "$DIST_DIR" -czf "$dir.tar.gz" "$(basename "$dir")"
|
||||||
|
rm -rf "$dir"
|
||||||
|
}
|
||||||
|
|
||||||
|
build_one darwin amd64
|
||||||
|
build_one darwin arm64
|
||||||
|
build_one linux amd64
|
||||||
|
build_one linux arm64
|
||||||
|
|
||||||
|
(
|
||||||
|
cd "$DIST_DIR"
|
||||||
|
shasum -a 256 *.tar.gz > checksums.txt
|
||||||
|
)
|
||||||
|
|
||||||
|
if gh release view "$TAG" --repo "$REPO" >/dev/null 2>&1; then
|
||||||
|
echo "GitHub release $TAG already exists; uploading artifacts with --clobber."
|
||||||
|
gh release upload "$TAG" "$DIST_DIR"/*.tar.gz "$DIST_DIR/checksums.txt" --repo "$REPO" --clobber
|
||||||
|
else
|
||||||
|
gh release create "$TAG" "$DIST_DIR"/*.tar.gz "$DIST_DIR/checksums.txt" \
|
||||||
|
--repo "$REPO" \
|
||||||
|
--title "$TAG" \
|
||||||
|
--generate-notes
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Update Homebrew tap formula to install the macOS artifacts.
|
||||||
|
TAP_TMP="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$TAP_TMP"' EXIT
|
||||||
|
|
||||||
|
gh repo clone "$HOMEBREW_TAP_REPO" "$TAP_TMP" -- --quiet
|
||||||
|
mkdir -p "$TAP_TMP/Formula"
|
||||||
|
|
||||||
|
DARWIN_ARM_SHA="$(shasum -a 256 "$DIST_DIR/${APP_NAME}_${VERSION}_darwin_arm64.tar.gz" | awk '{print $1}')"
|
||||||
|
DARWIN_AMD_SHA="$(shasum -a 256 "$DIST_DIR/${APP_NAME}_${VERSION}_darwin_x86_64.tar.gz" | awk '{print $1}')"
|
||||||
|
|
||||||
|
cat > "$TAP_TMP/Formula/${APP_NAME}.rb" <<EOF_FORMULA
|
||||||
|
class Ocgo < Formula
|
||||||
|
desc "Run Claude Code through an OpenCode Go-compatible Anthropic proxy"
|
||||||
|
homepage "https://github.com/${REPO}"
|
||||||
|
version "${VERSION}"
|
||||||
|
license "MIT"
|
||||||
|
|
||||||
|
on_macos do
|
||||||
|
if Hardware::CPU.arm?
|
||||||
|
url "https://github.com/${REPO}/releases/download/${TAG}/${APP_NAME}_${VERSION}_darwin_arm64.tar.gz"
|
||||||
|
sha256 "${DARWIN_ARM_SHA}"
|
||||||
|
else
|
||||||
|
url "https://github.com/${REPO}/releases/download/${TAG}/${APP_NAME}_${VERSION}_darwin_x86_64.tar.gz"
|
||||||
|
sha256 "${DARWIN_AMD_SHA}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def install
|
||||||
|
bin.install "${APP_NAME}"
|
||||||
|
end
|
||||||
|
|
||||||
|
test do
|
||||||
|
system "#{bin}/${APP_NAME}", "--help"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
EOF_FORMULA
|
||||||
|
|
||||||
|
(
|
||||||
|
cd "$TAP_TMP"
|
||||||
|
git add "Formula/${APP_NAME}.rb"
|
||||||
|
if git diff --cached --quiet; then
|
||||||
|
echo "Homebrew formula is already up to date."
|
||||||
|
else
|
||||||
|
git commit -m "Update ${APP_NAME} to ${TAG}"
|
||||||
|
git push
|
||||||
|
fi
|
||||||
|
)
|
||||||
|
|
||||||
|
TAP_OWNER="${HOMEBREW_TAP_REPO%%/*}"
|
||||||
|
TAP_REPO_NAME="${HOMEBREW_TAP_REPO#*/}"
|
||||||
|
TAP_NAME="${TAP_REPO_NAME#homebrew-}"
|
||||||
|
|
||||||
|
echo "Release complete: https://github.com/${REPO}/releases/tag/${TAG}"
|
||||||
|
echo "Install with: brew install ${TAP_OWNER}/${TAP_NAME}/${APP_NAME}"
|
||||||
Loading…
Add table
Reference in a new issue