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,315 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ---------- OpenAI request/response types ----------
|
||||
|
||||
// OpenAIChatRequest is the incoming OpenAI-format chat completion request.
|
||||
type OpenAIChatRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []OpenAIMessage `json:"messages"`
|
||||
Stream bool `json:"stream,omitempty"`
|
||||
MaxTokens int `json:"max_tokens,omitempty"`
|
||||
Temperature float64 `json:"temperature,omitempty"`
|
||||
Tools []OpenAITool `json:"tools,omitempty"`
|
||||
}
|
||||
|
||||
// OpenAIMessage represents a chat message.
|
||||
type OpenAIMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content any `json:"content"`
|
||||
Name string `json:"name,omitempty"`
|
||||
ToolCallID string `json:"tool_call_id,omitempty"`
|
||||
ToolCalls []OpenAIToolCall `json:"tool_calls,omitempty"`
|
||||
}
|
||||
|
||||
// OpenAIToolCall represents a tool call from the assistant.
|
||||
type OpenAIToolCall struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Function OpenAIToolFunc `json:"function"`
|
||||
}
|
||||
|
||||
// OpenAIToolFunc is the function part of a tool call.
|
||||
type OpenAIToolFunc struct {
|
||||
Name string `json:"name"`
|
||||
Arguments string `json:"arguments"`
|
||||
}
|
||||
|
||||
// OpenAITool defines a function tool.
|
||||
type OpenAITool struct {
|
||||
Type string `json:"type"`
|
||||
Function OpenAIFunction `json:"function"`
|
||||
}
|
||||
|
||||
// OpenAIFunction is the function definition within a tool.
|
||||
type OpenAIFunction struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Parameters map[string]any `json:"parameters,omitempty"`
|
||||
}
|
||||
|
||||
// OpenAIChatResponse is the non-streaming response.
|
||||
type OpenAIChatResponse struct {
|
||||
ID string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
Created int64 `json:"created"`
|
||||
Model string `json:"model"`
|
||||
Choices []OpenAIChoice `json:"choices"`
|
||||
Usage *OpenAIUsage `json:"usage,omitempty"`
|
||||
}
|
||||
|
||||
// OpenAIChoice is a single completion choice.
|
||||
type OpenAIChoice struct {
|
||||
Index int `json:"index"`
|
||||
Message OpenAIMessage `json:"message,omitempty"`
|
||||
Delta *OpenAIDelta `json:"delta,omitempty"`
|
||||
FinishReason string `json:"finish_reason,omitempty"`
|
||||
}
|
||||
|
||||
// OpenAIDelta is a streaming delta.
|
||||
type OpenAIDelta struct {
|
||||
Role string `json:"role,omitempty"`
|
||||
Content string `json:"content,omitempty"`
|
||||
ToolCalls []OpenAIToolCall `json:"tool_calls,omitempty"`
|
||||
}
|
||||
|
||||
// OpenAIUsage holds token usage info.
|
||||
type OpenAIUsage struct {
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
CompletionTokens int `json:"completion_tokens"`
|
||||
TotalTokens int `json:"total_tokens"`
|
||||
}
|
||||
|
||||
// OpenAIError is a standard error response.
|
||||
type OpenAIError struct {
|
||||
Error OpenAIErrorDetail `json:"error"`
|
||||
}
|
||||
|
||||
// OpenAIErrorDetail holds error info.
|
||||
type OpenAIErrorDetail struct {
|
||||
Message string `json:"message"`
|
||||
Type string `json:"type"`
|
||||
Code string `json:"code,omitempty"`
|
||||
}
|
||||
|
||||
// ---------- Proxy forwarding logic ----------
|
||||
|
||||
// UpstreamHeaders builds the required headers for OpenCode Zen.
|
||||
func UpstreamHeaders(requestID, sessionID string) map[string]string {
|
||||
return map[string]string{
|
||||
"Authorization": "Bearer public",
|
||||
"User-Agent": "opencode/1.15.0 ai-sdk/provider-utils/4.0.23 runtime/bun/1.3.13",
|
||||
"x-opencode-client": "cli",
|
||||
"x-opencode-project": "global",
|
||||
"x-opencode-request": requestID,
|
||||
"x-opencode-session": sessionID,
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "text/event-stream, application/json",
|
||||
}
|
||||
}
|
||||
|
||||
// ForwardChatCompletion sends the OpenAI request to OpenCode Zen and streams or collects the response.
|
||||
func (p *Proxy) ForwardChatCompletion(w http.ResponseWriter, r *http.Request, body []byte) error {
|
||||
var req OpenAIChatRequest
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
return fmt.Errorf("invalid request body: %w", err)
|
||||
}
|
||||
|
||||
// Resolve model name
|
||||
resolved, ok := p.ResolveModel(req.Model)
|
||||
if !ok {
|
||||
// Unknown model — log but still forward (might be a new model)
|
||||
fmt.Printf("[proxy] unknown model '%s', forwarding as-is\n", req.Model)
|
||||
}
|
||||
|
||||
// Update the body with resolved model
|
||||
if resolved != req.Model {
|
||||
var reqMap map[string]any
|
||||
json.Unmarshal(body, &reqMap)
|
||||
reqMap["model"] = resolved
|
||||
body, _ = json.Marshal(reqMap)
|
||||
req.Model = resolved
|
||||
}
|
||||
|
||||
// Generate request/session IDs for upstream
|
||||
requestID := RequestID()
|
||||
|
||||
// Get session ID from the client's proxy API key (or default)
|
||||
userKey := "default"
|
||||
if p.apiKey != "" {
|
||||
userKey = r.Header.Get("Authorization")
|
||||
if userKey == "" {
|
||||
userKey = r.Header.Get("x-api-key")
|
||||
}
|
||||
}
|
||||
sessionID := p.SessionID(userKey)
|
||||
|
||||
// Build upstream request
|
||||
upstreamReq, err := http.NewRequestWithContext(r.Context(), "POST", ZenChatURL(), bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create upstream request: %w", err)
|
||||
}
|
||||
|
||||
for k, v := range UpstreamHeaders(requestID, sessionID) {
|
||||
upstreamReq.Header.Set(k, v)
|
||||
}
|
||||
|
||||
// Execute
|
||||
client := &http.Client{Timeout: 10 * time.Minute}
|
||||
resp, err := client.Do(upstreamReq)
|
||||
if err != nil {
|
||||
return fmt.Errorf("upstream request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Handle non-200 responses
|
||||
if resp.StatusCode != 200 {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
|
||||
// Try to parse upstream error, wrap as standard OpenAI error
|
||||
var upstreamErr map[string]any
|
||||
if json.Unmarshal(bodyBytes, &upstreamErr) == nil {
|
||||
errMsg := "upstream error"
|
||||
if msg, ok := upstreamErr["message"]; ok {
|
||||
errMsg = fmt.Sprintf("%v", msg)
|
||||
}
|
||||
json.NewEncoder(w).Encode(OpenAIError{
|
||||
Error: OpenAIErrorDetail{
|
||||
Message: errMsg,
|
||||
Type: "upstream_error",
|
||||
},
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
w.Write(bodyBytes)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Handle streaming vs non-streaming
|
||||
if req.Stream {
|
||||
return p.streamResponse(w, resp)
|
||||
}
|
||||
return p.nonStreamResponse(w, resp, req.Model)
|
||||
}
|
||||
|
||||
// streamResponse forwards SSE chunks directly from upstream to client.
|
||||
func (p *Proxy) streamResponse(w http.ResponseWriter, resp *http.Response) error {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.WriteHeader(200)
|
||||
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
return fmt.Errorf("streaming not supported")
|
||||
}
|
||||
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
// Write the line. If the client disconnects, stop silently.
|
||||
if _, err := fmt.Fprint(w, line+"\n"); err != nil {
|
||||
return nil
|
||||
}
|
||||
flusher.Flush()
|
||||
}
|
||||
|
||||
// Scanner errors (e.g., connection reset) are not real errors
|
||||
// after streaming has started — the client likely disconnected.
|
||||
return nil
|
||||
}
|
||||
|
||||
// nonStreamResponse collects the full upstream response and returns it as a single JSON.
|
||||
func (p *Proxy) nonStreamResponse(w http.ResponseWriter, resp *http.Response, model string) error {
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read upstream response: %w", err)
|
||||
}
|
||||
|
||||
// If the upstream already returned a single JSON (non-streaming), pass it through
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
if !strings.Contains(contentType, "text/event-stream") {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(200)
|
||||
w.Write(bodyBytes)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Otherwise accumulate SSE chunks into a single response
|
||||
var response OpenAIChatResponse
|
||||
response.ID = "chatcmpl-" + randomHex(12)
|
||||
response.Object = "chat.completion"
|
||||
response.Created = time.Now().Unix()
|
||||
response.Model = model
|
||||
response.Choices = []OpenAIChoice{{
|
||||
Index: 0,
|
||||
Message: OpenAIMessage{
|
||||
Role: "assistant",
|
||||
},
|
||||
}}
|
||||
|
||||
var contentBuf strings.Builder
|
||||
scanner := bufio.NewScanner(bytes.NewReader(bodyBytes))
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if !strings.HasPrefix(line, "data: ") {
|
||||
continue
|
||||
}
|
||||
data := strings.TrimPrefix(line, "data: ")
|
||||
if data == "[DONE]" {
|
||||
continue
|
||||
}
|
||||
|
||||
var chunk map[string]any
|
||||
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Extract content from delta
|
||||
if choices, ok := chunk["choices"].([]any); ok && len(choices) > 0 {
|
||||
choice := choices[0].(map[string]any)
|
||||
if delta, ok := choice["delta"].(map[string]any); ok {
|
||||
if content, ok := delta["content"].(string); ok {
|
||||
contentBuf.WriteString(content)
|
||||
}
|
||||
}
|
||||
if fr, ok := choice["finish_reason"].(string); ok && fr != "" {
|
||||
response.Choices[0].FinishReason = fr
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
response.Choices[0].Message.Content = contentBuf.String()
|
||||
if response.Choices[0].FinishReason == "" {
|
||||
response.Choices[0].FinishReason = "stop"
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(200)
|
||||
return json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
// WriteSSE writes a JSON object as an SSE data line.
|
||||
func WriteSSE(w io.Writer, v any) error {
|
||||
data, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = fmt.Fprintf(w, "data: %s\n\n", data)
|
||||
return err
|
||||
}
|
||||
Reference in New Issue
Block a user