Initial commit: OpenCode Zen Proxy
Proxy translating OpenAI/Anthropic Messages API to OpenCode Zen's free models. Pure stdlib Go 1.22, no external dependencies. - OpenAI Chat Completions (/v1/chat/completions) with streaming - Anthropic Messages API (/v1/messages) with streaming - Model aliases for 5 free models - Auth middleware support - Session rotation (30min per user key)
This commit is contained in:
@@ -0,0 +1,269 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultAPIKey = "opx-57ffcb013651f7acae558cd9ab094dbd719fdec980a6ad53"
|
||||
proxyBaseURL = "http://127.0.0.1:6446/v1"
|
||||
)
|
||||
|
||||
func main() {
|
||||
debug := os.Getenv("MCP_DEBUG") != ""
|
||||
logf := func(f string, a ...any) {
|
||||
if debug {
|
||||
fmt.Fprintf(os.Stderr, f+"\n", a...)
|
||||
}
|
||||
}
|
||||
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
buf := make([]byte, 0, 64*1024)
|
||||
|
||||
for {
|
||||
var msg map[string]any
|
||||
|
||||
first, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
logf("stdin EOF: %v", err)
|
||||
return
|
||||
}
|
||||
logf("RAW stdin first line: %q", first)
|
||||
|
||||
if strings.HasPrefix(first, "Content-Length: ") {
|
||||
var contentLen int
|
||||
fmt.Sscanf(first, "Content-Length: %d", &contentLen)
|
||||
logf("mode=Content-Length len=%d", contentLen)
|
||||
|
||||
emptyLine, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
logf("err reading empty line: %v", err)
|
||||
return
|
||||
}
|
||||
logf("empty line: %q", emptyLine)
|
||||
|
||||
buf = buf[:contentLen]
|
||||
if _, err := io.ReadFull(reader, buf); err != nil {
|
||||
logf("err reading body: %v", err)
|
||||
return
|
||||
}
|
||||
logf("body: %s", string(buf))
|
||||
json.Unmarshal(buf, &msg)
|
||||
} else if len(first) > 0 && first[0] == '{' {
|
||||
// Raw JSON line mode (like engram)
|
||||
logf("mode=rawJSON")
|
||||
json.Unmarshal([]byte(strings.TrimRight(first, "\r\n")), &msg)
|
||||
} else {
|
||||
logf("unrecognized input, skipping: %q", first)
|
||||
continue
|
||||
}
|
||||
|
||||
if msg == nil {
|
||||
logf("msg is nil, skipping")
|
||||
continue
|
||||
}
|
||||
|
||||
method, _ := msg["method"].(string)
|
||||
id, _ := msg["id"]
|
||||
|
||||
switch method {
|
||||
case "initialize":
|
||||
respond(id, map[string]any{
|
||||
"protocolVersion": "2025-03-26",
|
||||
"capabilities": map[string]any{
|
||||
"tools": map[string]any{},
|
||||
},
|
||||
"serverInfo": map[string]any{
|
||||
"name": "mcp-vision",
|
||||
"version": "1.0.0",
|
||||
},
|
||||
})
|
||||
|
||||
case "tools/list":
|
||||
respond(id, map[string]any{
|
||||
"tools": []map[string]any{
|
||||
{
|
||||
"name": "read_image",
|
||||
"description": "Describe an image file using AI vision. Sends only the image + prompt, no conversation context.",
|
||||
"inputSchema": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"path": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Absolute path to the image file (jpg, png, webp)",
|
||||
},
|
||||
"prompt": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Optional custom question about the image",
|
||||
},
|
||||
},
|
||||
"required": []string{"path"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
case "tools/call":
|
||||
params, _ := msg["params"].(map[string]any)
|
||||
name, _ := params["name"].(string)
|
||||
args, _ := params["arguments"].(map[string]any)
|
||||
|
||||
if name != "read_image" {
|
||||
respondError(id, -32601, fmt.Sprintf("Unknown tool: %s", name))
|
||||
continue
|
||||
}
|
||||
|
||||
path, _ := args["path"].(string)
|
||||
prompt, _ := args["prompt"].(string)
|
||||
if prompt == "" {
|
||||
prompt = "Describe esta imagen en detalle."
|
||||
}
|
||||
|
||||
result, err := readImage(path, prompt)
|
||||
if err != nil {
|
||||
respondError(id, -32000, err.Error())
|
||||
continue
|
||||
}
|
||||
|
||||
respond(id, map[string]any{
|
||||
"content": []map[string]any{
|
||||
{"type": "text", "text": result},
|
||||
},
|
||||
})
|
||||
|
||||
case "notifications/initialized":
|
||||
case "notifications/cancelled":
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func readImage(path, prompt string) (string, error) {
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
var mediaType string
|
||||
switch ext {
|
||||
case ".jpg", ".jpeg":
|
||||
mediaType = "image/jpeg"
|
||||
case ".png":
|
||||
mediaType = "image/png"
|
||||
case ".webp":
|
||||
mediaType = "image/webp"
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported format: %s (use jpg, png, or webp)", ext)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot read file: %w", err)
|
||||
}
|
||||
|
||||
b64 := base64.StdEncoding.EncodeToString(data)
|
||||
|
||||
reqBody := map[string]any{
|
||||
"model": "mimo-v2.5-free",
|
||||
"messages": []map[string]any{
|
||||
{
|
||||
"role": "user",
|
||||
"content": []map[string]any{
|
||||
{"type": "text", "text": prompt},
|
||||
{"type": "image_url", "image_url": map[string]string{
|
||||
"url": fmt.Sprintf("data:%s;base64,%s", mediaType, b64),
|
||||
}},
|
||||
},
|
||||
},
|
||||
},
|
||||
"stream": false,
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(reqBody)
|
||||
|
||||
httpReq, err := http.NewRequest("POST", proxyBaseURL+"/chat/completions", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("request error: %w", err)
|
||||
}
|
||||
|
||||
apiKey := os.Getenv("VISION_API_KEY")
|
||||
if apiKey == "" {
|
||||
apiKey = defaultAPIKey
|
||||
}
|
||||
// using proxy at proxyBaseURL
|
||||
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
httpReq.Header.Set("User-Agent", "mcp-vision/1.0")
|
||||
httpReq.Header.Set("x-opencode-client", "mcp-vision")
|
||||
httpReq.Header.Set("x-opencode-project", "vision")
|
||||
httpReq.Header.Set("x-opencode-request", fmt.Sprintf("req_%d", len(data)))
|
||||
httpReq.Header.Set("x-opencode-session", fmt.Sprintf("ses_%d", len(data)%1000000))
|
||||
|
||||
client := &http.Client{Timeout: 2 * 60}
|
||||
resp, err := client.Do(httpReq)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("api error: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respData, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != 200 {
|
||||
return "", fmt.Errorf("api returned %d: %s", resp.StatusCode, string(respData))
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content any `json:"content"`
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
if err := json.Unmarshal(respData, &result); err != nil {
|
||||
return "", fmt.Errorf("parse error: %w", err)
|
||||
}
|
||||
|
||||
if len(result.Choices) == 0 {
|
||||
return "", fmt.Errorf("empty response")
|
||||
}
|
||||
|
||||
switch c := result.Choices[0].Message.Content.(type) {
|
||||
case string:
|
||||
return c, nil
|
||||
default:
|
||||
b, _ := json.MarshalIndent(c, "", " ")
|
||||
return string(b), nil
|
||||
}
|
||||
}
|
||||
|
||||
func respond(id any, result any) {
|
||||
resp := map[string]any{
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"result": result,
|
||||
}
|
||||
writeJSON(resp)
|
||||
}
|
||||
|
||||
func respondError(id any, code int, message string) {
|
||||
resp := map[string]any{
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"error": map[string]any{
|
||||
"code": code,
|
||||
"message": message,
|
||||
},
|
||||
}
|
||||
writeJSON(resp)
|
||||
}
|
||||
|
||||
func writeJSON(v any) {
|
||||
data, _ := json.Marshal(v)
|
||||
// Claude Code accepts newline-delimited JSON (same format as engram)
|
||||
os.Stdout.Write(data)
|
||||
os.Stdout.Write([]byte{'\n'})
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const defaultAPIKey = "opx-57ffcb013651f7acae558cd9ab094dbd719fdec980a6ad53"
|
||||
|
||||
type visionRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []visionMessage `json:"messages"`
|
||||
Stream bool `json:"stream"`
|
||||
}
|
||||
|
||||
type visionMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content interface{} `json:"content"`
|
||||
}
|
||||
|
||||
type visionResponse struct {
|
||||
ID string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
Created int64 `json:"created"`
|
||||
Model string `json:"model"`
|
||||
Choices []visionChoice `json:"choices"`
|
||||
}
|
||||
|
||||
type visionChoice struct {
|
||||
Index int `json:"index"`
|
||||
Message visionContent `json:"message"`
|
||||
FinishReason string `json:"finish_reason"`
|
||||
}
|
||||
|
||||
type visionContent struct {
|
||||
Role string `json:"role"`
|
||||
Content interface{} `json:"content"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
fmt.Fprintf(os.Stderr, "Usage: vision <image-path> [prompt]\n")
|
||||
fmt.Fprintf(os.Stderr, "Example: vision C:\\path\\to\\photo.jpg \"Describe this image\"\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
imagePath := os.Args[1]
|
||||
prompt := "Describe esta imagen en detalle."
|
||||
if len(os.Args) > 2 {
|
||||
prompt = strings.Join(os.Args[2:], " ")
|
||||
}
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(imagePath))
|
||||
var mediaType string
|
||||
switch ext {
|
||||
case ".jpg", ".jpeg":
|
||||
mediaType = "image/jpeg"
|
||||
case ".png":
|
||||
mediaType = "image/png"
|
||||
case ".webp":
|
||||
mediaType = "image/webp"
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "Unsupported image format: %s (supported: jpg, png, webp)\n", ext)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(imagePath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error reading file: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
b64 := base64.StdEncoding.EncodeToString(data)
|
||||
dataURL := fmt.Sprintf("data:%s;base64,%s", mediaType, b64)
|
||||
|
||||
reqBody := visionRequest{
|
||||
Model: "mimo-v2.5-free",
|
||||
Messages: []visionMessage{
|
||||
{
|
||||
Role: "user",
|
||||
Content: []map[string]interface{}{
|
||||
{"type": "text", "text": prompt},
|
||||
{"type": "image_url", "image_url": map[string]string{"url": dataURL}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(reqBody)
|
||||
|
||||
httpReq, err := http.NewRequest("POST", "https://opencode.ai/zen/v1/chat/completions", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error creating request: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+defaultAPIKey)
|
||||
httpReq.Header.Set("User-Agent", "vision-tool/1.0")
|
||||
httpReq.Header.Set("x-opencode-client", "cli")
|
||||
httpReq.Header.Set("x-opencode-project", "vision")
|
||||
httpReq.Header.Set("x-opencode-request", fmt.Sprintf("req_%d", time.Now().UnixNano()))
|
||||
httpReq.Header.Set("x-opencode-session", fmt.Sprintf("ses_%d", time.Now().UnixNano()))
|
||||
|
||||
client := &http.Client{Timeout: 2 * time.Minute}
|
||||
resp, err := client.Do(httpReq)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respData, _ := io.ReadAll(resp.Body)
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
fmt.Fprintf(os.Stderr, "API error %d: %s\n", resp.StatusCode, string(respData))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
var result visionResponse
|
||||
if err := json.Unmarshal(respData, &result); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error parsing response: %v\n%s\n", err, string(respData))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if len(result.Choices) > 0 {
|
||||
switch c := result.Choices[0].Message.Content.(type) {
|
||||
case string:
|
||||
fmt.Println(c)
|
||||
default:
|
||||
b, _ := json.MarshalIndent(c, "", " ")
|
||||
fmt.Println(string(b))
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user