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,194 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ZenBaseURL is the upstream OpenCode Zen API base URL.
|
||||
const ZenBaseURL = "https://opencode.ai/zen/v1"
|
||||
|
||||
// Free models available via OpenCode Zen (no API key required).
|
||||
// These are the 5 models currently offered for free.
|
||||
var zenModels = []ModelInfo{
|
||||
{
|
||||
ID: "big-pickle",
|
||||
Name: "Big Pickle (Free)",
|
||||
OwnedBy: "opencode-zen",
|
||||
ContextWindow: 200_000,
|
||||
MaxOutputTokens: 32_000,
|
||||
Description: "Permanentemente gratis. Código legacy, refactors, mantenimiento.",
|
||||
SupportsVision: false,
|
||||
},
|
||||
{
|
||||
ID: "deepseek-v4-flash-free",
|
||||
Name: "DeepSeek V4 Flash (Free)",
|
||||
OwnedBy: "deepseek",
|
||||
ContextWindow: 1_000_000,
|
||||
MaxOutputTokens: 384_000,
|
||||
Description: "El todoterreno. 1M contexto, 284B params (13B activos), razonamiento.",
|
||||
SupportsVision: false,
|
||||
},
|
||||
{
|
||||
ID: "mimo-v2.5-free",
|
||||
Name: "MiMo V2.5 (Free)",
|
||||
OwnedBy: "xiaomi",
|
||||
ContextWindow: 1_000_000,
|
||||
MaxOutputTokens: 32_000,
|
||||
Description: "Multimodal de Xiaomi. 1M contexto, imágenes → código. MIT license.",
|
||||
SupportsVision: true,
|
||||
},
|
||||
{
|
||||
ID: "north-mini-code-free",
|
||||
Name: "North Mini Code (Free)",
|
||||
OwnedBy: "cohere",
|
||||
ContextWindow: 256_000,
|
||||
MaxOutputTokens: 64_000,
|
||||
Description: "Cohere. 30B total / 3B activos (MoE). Respuesta rápida, scripts.",
|
||||
SupportsVision: false,
|
||||
},
|
||||
{
|
||||
ID: "nemotron-3-ultra-free",
|
||||
Name: "Nemotron 3 Ultra (Free)",
|
||||
OwnedBy: "nvidia",
|
||||
ContextWindow: 1_000_000,
|
||||
MaxOutputTokens: 16_384,
|
||||
Description: "NVIDIA. 550B params (55B activos). Mamba-2 + MoE híbrido. Razonamiento.",
|
||||
SupportsVision: false,
|
||||
},
|
||||
}
|
||||
|
||||
// Model aliases: short name → full model ID.
|
||||
var modelAliases = map[string]string{
|
||||
// Short aliases
|
||||
"pickle": "big-pickle",
|
||||
"big-pickle": "big-pickle",
|
||||
"deepseek": "deepseek-v4-flash-free",
|
||||
"deepseek-v4": "deepseek-v4-flash-free",
|
||||
"ds": "deepseek-v4-flash-free",
|
||||
"mimo": "mimo-v2.5-free",
|
||||
"mimo-v2.5": "mimo-v2.5-free",
|
||||
"xiaomi": "mimo-v2.5-free",
|
||||
"north": "north-mini-code-free",
|
||||
"north-mini": "north-mini-code-free",
|
||||
"cohere": "north-mini-code-free",
|
||||
"nemotron": "nemotron-3-ultra-free",
|
||||
"nemotron-3": "nemotron-3-ultra-free",
|
||||
"nvidia": "nemotron-3-ultra-free",
|
||||
|
||||
// Claude model name aliases (for Claude Code compatibility)
|
||||
// Claude Code validates model names client-side; set ANTHROPIC_MODEL to a Claude name.
|
||||
"claude-sonnet-4-6": "deepseek-v4-flash-free",
|
||||
"claude-sonnet-4-5": "deepseek-v4-flash-free",
|
||||
"claude-sonnet-4": "deepseek-v4-flash-free",
|
||||
"claude-opus-4-8": "deepseek-v4-flash-free",
|
||||
"claude-opus-4-5": "deepseek-v4-flash-free",
|
||||
"claude-opus-4": "deepseek-v4-flash-free",
|
||||
"claude-haiku-4-5": "north-mini-code-free",
|
||||
"claude-haiku-4": "north-mini-code-free",
|
||||
"claude-3.5-sonnet": "north-mini-code-free",
|
||||
"claude-3.5-haiku": "big-pickle",
|
||||
}
|
||||
|
||||
// ModelInfo describes a single model.
|
||||
type ModelInfo struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name,omitempty"`
|
||||
OwnedBy string `json:"owned_by"`
|
||||
ContextWindow int `json:"context_window"`
|
||||
MaxOutputTokens int `json:"max_output_tokens"`
|
||||
Description string `json:"description"`
|
||||
SupportsVision bool `json:"supports_vision"`
|
||||
}
|
||||
|
||||
// Proxy handles forwarding OpenAI-format requests to OpenCode Zen.
|
||||
type Proxy struct {
|
||||
apiKey string
|
||||
sessions map[string]*session // keyed by client-provided API key
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
type session struct {
|
||||
id string
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
// NewProxy creates a new Proxy instance.
|
||||
func NewProxy(apiKey string) *Proxy {
|
||||
return &Proxy{
|
||||
apiKey: apiKey,
|
||||
sessions: make(map[string]*session),
|
||||
}
|
||||
}
|
||||
|
||||
// APIKey returns the configured API key (empty = no auth).
|
||||
func (p *Proxy) APIKey() string { return p.apiKey }
|
||||
|
||||
// Models returns the list of available free models.
|
||||
func (p *Proxy) Models() []ModelInfo { return zenModels }
|
||||
|
||||
// ModelByID returns the ModelInfo entry for a given model ID, or nil if not found.
|
||||
func ModelByID(id string) *ModelInfo {
|
||||
for _, m := range zenModels {
|
||||
if m.ID == id {
|
||||
return &m
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResolveModel maps a requested model name to a Zen model ID.
|
||||
// Returns the resolved model ID and whether it was found.
|
||||
func (p *Proxy) ResolveModel(requested string) (string, bool) {
|
||||
// Check aliases first (covers both short names and exact IDs)
|
||||
if mapped, ok := modelAliases[requested]; ok {
|
||||
return mapped, true
|
||||
}
|
||||
// Check if it's already a valid model ID (belt and suspenders)
|
||||
for _, m := range zenModels {
|
||||
if m.ID == requested {
|
||||
return requested, true
|
||||
}
|
||||
}
|
||||
// Pass through unknown models (might be newly added upstream)
|
||||
return requested, false
|
||||
}
|
||||
|
||||
// ZenChatURL returns the full upstream URL for chat completions.
|
||||
func ZenChatURL() string {
|
||||
return ZenBaseURL + "/chat/completions"
|
||||
}
|
||||
|
||||
// ZenModelsURL returns the full upstream URL for model listing.
|
||||
func ZenModelsURL() string {
|
||||
return ZenBaseURL + "/models"
|
||||
}
|
||||
|
||||
// SessionID returns a session ID for the given user key, rotating every 30 minutes.
|
||||
func (p *Proxy) SessionID(userKey string) string {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
if s, ok := p.sessions[userKey]; ok && time.Now().Before(s.expiresAt) {
|
||||
return s.id
|
||||
}
|
||||
|
||||
p.sessions[userKey] = &session{
|
||||
id: "ses_" + randomHex(12),
|
||||
expiresAt: time.Now().Add(30 * time.Minute),
|
||||
}
|
||||
return p.sessions[userKey].id
|
||||
}
|
||||
|
||||
// RequestID generates a new unique request ID.
|
||||
func RequestID() string {
|
||||
return "msg_" + randomHex(12)
|
||||
}
|
||||
|
||||
func randomHex(n int) string {
|
||||
b := make([]byte, n)
|
||||
rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
Reference in New Issue
Block a user