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:
renato97
2026-06-25 11:49:11 -03:00
commit 0a4289cafc
11 changed files with 2396 additions and 0 deletions
+862
View File
@@ -0,0 +1,862 @@
package proxy
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"sort"
"strings"
"time"
)
// ---------- Anthropic request/response types ----------
// AnthropicRequest is the incoming Anthropic Messages API request.
type AnthropicRequest struct {
Model string `json:"model"`
Messages []AnthropicMessage `json:"messages"`
System any `json:"system,omitempty"` // string or []AnthropicTextBlock
MaxTokens int `json:"max_tokens"`
Temperature *float64 `json:"temperature,omitempty"`
TopP *float64 `json:"top_p,omitempty"`
TopK *int `json:"top_k,omitempty"`
StopSequences []string `json:"stop_sequences,omitempty"`
Stream bool `json:"stream,omitempty"`
Tools []AnthropicTool `json:"tools,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
}
// AnthropicMessage is a message in Anthropic format.
// Content can be either a plain string or an array of content blocks.
type AnthropicMessage struct {
Role string `json:"role"` // "user" or "assistant"
Content AnthropicContentBlocks `json:"content"`
}
// AnthropicContentBlocks handles both string and array content formats.
type AnthropicContentBlocks []AnthropicContent
func (a *AnthropicContentBlocks) UnmarshalJSON(b []byte) error {
// Try string first: "content": "hello"
if len(b) > 0 && b[0] == '"' {
var s string
if err := json.Unmarshal(b, &s); err != nil {
return err
}
*a = []AnthropicContent{{Type: "text", Text: s}}
return nil
}
// Otherwise it's an array: "content": [{"type":"text","text":"hello"}]
var blocks []AnthropicContent
if err := json.Unmarshal(b, &blocks); err != nil {
return err
}
*a = blocks
return nil
}
// AnthropicContent is a content block (text, tool_use, tool_result, image).
type AnthropicContent struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Input json.RawMessage `json:"input,omitempty"`
ToolUseID string `json:"tool_use_id,omitempty"`
Content any `json:"content,omitempty"` // for tool_result
IsError *bool `json:"is_error,omitempty"`
Source *AnthropicImage `json:"source,omitempty"`
}
// AnthropicImage is an image source in Anthropic format.
type AnthropicImage struct {
Type string `json:"type"`
MediaType string `json:"media_type"`
Data string `json:"data"`
}
// AnthropicTool defines a tool in Anthropic format.
type AnthropicTool struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
InputSchema map[string]any `json:"input_schema"`
}
// AnthropicResponse is the non-streaming Anthropic response.
type AnthropicResponse struct {
ID string `json:"id"`
Type string `json:"type"`
Role string `json:"role"`
Model string `json:"model"`
Content []AnthropicContent `json:"content"`
StopReason string `json:"stop_reason"`
StopSeq string `json:"stop_sequence,omitempty"`
Usage AnthropicUsage `json:"usage"`
}
// AnthropicUsage holds token usage in Anthropic format.
type AnthropicUsage struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
}
// AnthropicError is an error response in Anthropic format.
type AnthropicError struct {
Type string `json:"type"`
Error AnthropicErrorBody `json:"error"`
}
// AnthropicErrorBody is the inner error details.
type AnthropicErrorBody struct {
Type string `json:"type"`
Message string `json:"message"`
}
// ---------- Anthropic SSE event types ----------
type anthropicSSE struct {
Type string `json:"type"`
Index *int `json:"index,omitempty"`
Delta *struct {
Type string `json:"type,omitempty"`
Text string `json:"text,omitempty"`
PartialJSON string `json:"partial_json,omitempty"`
StopReason string `json:"stop_reason,omitempty"`
StopSequence string `json:"stop_sequence,omitempty"`
} `json:"delta,omitempty"`
Message *anthropicSSEMessage `json:"message,omitempty"`
Usage *AnthropicUsage `json:"usage,omitempty"`
ContentBlock *struct {
Type string `json:"type"`
Name string `json:"name,omitempty"`
ID string `json:"id,omitempty"`
} `json:"content_block,omitempty"`
}
type anthropicSSEMessage struct {
ID string `json:"id"`
Type string `json:"type"`
Role string `json:"role"`
Model string `json:"model"`
Content []AnthropicContent `json:"content"`
StopReason string `json:"stop_reason"`
StopSeq string `json:"stop_sequence,omitempty"`
Usage AnthropicUsage `json:"usage"`
}
// ---------- Anthropic → OpenAI conversion ----------
// AnthropicToOpenAI converts an Anthropic Messages request to OpenAI Chat Completions format.
func AnthropicToOpenAI(ar *AnthropicRequest) *OpenAIChatRequest {
var messages []OpenAIMessage
// System prompt: Anthropic system → OpenAI system message
systemText := extractSystemText(ar.System)
if systemText != "" {
messages = append(messages, OpenAIMessage{
Role: "system",
Content: systemText,
})
}
// Convert each message
for _, am := range ar.Messages {
msg := anthropicMessageToOpenAI(am)
messages = append(messages, msg)
}
// Convert tools
var tools []OpenAITool
for _, at := range ar.Tools {
tools = append(tools, OpenAITool{
Type: "function",
Function: OpenAIFunction{
Name: at.Name,
Description: at.Description,
Parameters: at.InputSchema,
},
})
}
temperature := 0.7
if ar.Temperature != nil {
temperature = *ar.Temperature
}
oaReq := &OpenAIChatRequest{
Model: ar.Model,
Messages: messages,
MaxTokens: ar.MaxTokens,
Temperature: temperature,
Stream: ar.Stream,
Tools: tools,
}
return oaReq
}
func extractSystemText(system any) string {
switch s := system.(type) {
case string:
return s
case []any:
var parts []string
for _, block := range s {
if b, ok := block.(map[string]any); ok {
if b["type"] == "text" {
if text, ok := b["text"].(string); ok {
parts = append(parts, text)
}
}
}
}
return strings.Join(parts, "\n")
}
return ""
}
func anthropicMessageToOpenAI(am AnthropicMessage) OpenAIMessage {
msg := OpenAIMessage{
Role: am.Role,
}
var textParts []string
var imageParts []map[string]any
var toolCalls []OpenAIToolCall
for _, block := range am.Content {
switch block.Type {
case "text":
textParts = append(textParts, block.Text)
case "tool_use":
toolCalls = append(toolCalls, OpenAIToolCall{
ID: block.ID,
Type: "function",
Function: OpenAIToolFunc{
Name: block.Name,
Arguments: string(block.Input),
},
})
case "tool_result":
msg.Role = "tool"
msg.ToolCallID = block.ToolUseID
if block.Content != nil {
msg.Content = flattenContent(block.Content)
}
if block.IsError != nil && *block.IsError {
msg.Content = fmt.Sprintf("Error: %v", msg.Content)
}
return msg
case "image":
if block.Source != nil {
imageParts = append(imageParts, map[string]any{
"type": "image_url",
"image_url": map[string]any{
"url": fmt.Sprintf("data:%s;base64,%s", block.Source.MediaType, block.Source.Data),
},
})
}
}
}
if len(imageParts) > 0 {
var content []map[string]any
for _, t := range textParts {
content = append(content, map[string]any{"type": "text", "text": t})
}
content = append(content, imageParts...)
msg.Content = content
} else if len(toolCalls) > 0 {
msg.ToolCalls = toolCalls
msg.Content = strings.Join(textParts, "\n")
} else {
msg.Content = strings.Join(textParts, "\n")
}
return msg
}
// flattenContent converts tool_result content (string or array of content blocks) to a plain string.
func flattenContent(v any) string {
if s, ok := v.(string); ok {
return s
}
if arr, ok := v.([]any); ok {
var parts []string
for _, item := range arr {
if m, ok := item.(map[string]any); ok {
if t, ok := m["text"].(string); ok {
parts = append(parts, t)
}
}
}
return strings.Join(parts, "\n")
}
return fmt.Sprintf("%v", v)
}
// requestHasImages checks whether any message in the Anthropic request contains an image block.
func requestHasImages(ar *AnthropicRequest) bool {
for _, m := range ar.Messages {
for _, block := range m.Content {
if block.Type == "image" {
return true
}
}
}
return false
}
// ---------- OpenAI → Anthropic response conversion ----------
// OpenAIToAnthropicResponse converts a non-streaming OpenAI response to Anthropic format.
func OpenAIToAnthropicResponse(oaResp *OpenAIChatResponse, model string) *AnthropicResponse {
ar := &AnthropicResponse{
ID: oaResp.ID,
Type: "message",
Role: "assistant",
Model: model,
Content: make([]AnthropicContent, 0),
}
if len(oaResp.Choices) > 0 {
choice := oaResp.Choices[0]
msg := choice.Message
// Extract text content
if text, ok := msg.Content.(string); ok && text != "" {
ar.Content = append(ar.Content, AnthropicContent{
Type: "text",
Text: text,
})
}
// Extract tool calls
for _, tc := range msg.ToolCalls {
ar.Content = append(ar.Content, AnthropicContent{
Type: "tool_use",
ID: tc.ID,
Name: tc.Function.Name,
Input: json.RawMessage(tc.Function.Arguments),
})
}
// Map finish reason
ar.StopReason = mapFinishReason(choice.FinishReason)
}
// Map usage
if oaResp.Usage != nil {
ar.Usage = AnthropicUsage{
InputTokens: oaResp.Usage.PromptTokens,
OutputTokens: oaResp.Usage.CompletionTokens,
}
}
return ar
}
func mapFinishReason(oaReason string) string {
switch oaReason {
case "stop":
return "end_turn"
case "length":
return "max_tokens"
case "tool_calls":
return "tool_use"
case "content_filter":
return "end_turn"
default:
return "end_turn"
}
}
// ---------- OpenAI SSE → Anthropic SSE streaming ----------
// ---------- Anthropic SSE state machine ----------
type anthropicSSEState struct {
w http.ResponseWriter
flusher http.Flusher
model string
msgID string
messageStarted bool
finished bool
inputTokens int
outputTokens int
textBlockIdx int // -1 = not started
nextBlockIdx int // next sequential block index
// toolCallIdx maps OpenAI tool call index → anthropic block state
toolByOpenAIIdx map[int]*anthropicToolBlock
// openBlocks tracks blocks in open order for sequential close
openBlocks []int // values are anthropic block indices
}
type anthropicToolBlock struct {
anthropicIdx int
id string
name string
}
func newAnthropicSSEState(w http.ResponseWriter, flusher http.Flusher, model, requestID string) *anthropicSSEState {
return &anthropicSSEState{
w: w,
flusher: flusher,
model: model,
msgID: fmt.Sprintf("msg_%s", requestID),
textBlockIdx: -1,
toolByOpenAIIdx: make(map[int]*anthropicToolBlock),
}
}
func (s *anthropicSSEState) startMessage() {
writeAnthropicSSE(s.w, anthropicSSE{
Type: "message_start",
Message: &anthropicSSEMessage{
ID: s.msgID,
Type: "message",
Role: "assistant",
Model: s.model,
Content: []AnthropicContent{},
},
}, s.flusher)
s.messageStarted = true
}
func (s *anthropicSSEState) ensureTextBlock() {
if s.textBlockIdx >= 0 {
return
}
s.textBlockIdx = s.nextBlockIdx
s.nextBlockIdx++
s.openBlocks = append(s.openBlocks, s.textBlockIdx)
writeAnthropicSSE(s.w, anthropicSSE{
Type: "content_block_start",
Index: &s.textBlockIdx,
ContentBlock: &struct {
Type string `json:"type"`
Name string `json:"name,omitempty"`
ID string `json:"id,omitempty"`
}{Type: "text"},
}, s.flusher)
}
func (s *anthropicSSEState) handleText(text string) {
s.ensureTextBlock()
writeAnthropicSSE(s.w, anthropicSSE{
Type: "content_block_delta",
Index: &s.textBlockIdx,
Delta: &struct {
Type string `json:"type,omitempty"`
Text string `json:"text,omitempty"`
PartialJSON string `json:"partial_json,omitempty"`
StopReason string `json:"stop_reason,omitempty"`
StopSequence string `json:"stop_sequence,omitempty"`
}{Type: "text_delta", Text: text},
}, s.flusher)
}
func (s *anthropicSSEState) handleToolCall(openaiIdx int, tc map[string]any) {
id, _ := tc["id"].(string)
fn, _ := tc["function"].(map[string]any)
name, _ := fn["name"].(string)
args, _ := fn["arguments"].(string)
block, exists := s.toolByOpenAIIdx[openaiIdx]
if !exists {
// Close text block if open — text must precede all tool blocks
if s.textBlockIdx >= 0 {
writeAnthropicSSE(s.w, anthropicSSE{
Type: "content_block_stop",
Index: &s.textBlockIdx,
}, s.flusher)
// textBlockIdx stays set (don't reopen)
}
block = &anthropicToolBlock{
anthropicIdx: s.nextBlockIdx,
id: id,
name: name,
}
s.nextBlockIdx++
s.toolByOpenAIIdx[openaiIdx] = block
s.openBlocks = append(s.openBlocks, block.anthropicIdx)
writeAnthropicSSE(s.w, anthropicSSE{
Type: "content_block_start",
Index: &block.anthropicIdx,
ContentBlock: &struct {
Type string `json:"type"`
Name string `json:"name,omitempty"`
ID string `json:"id,omitempty"`
}{Type: "tool_use", Name: block.name, ID: block.id},
}, s.flusher)
} else {
// Update id/name if present (first chunk may have both, later only args)
if id != "" {
block.id = id
}
if name != "" {
block.name = name
}
}
if args != "" {
writeAnthropicSSE(s.w, anthropicSSE{
Type: "content_block_delta",
Index: &block.anthropicIdx,
Delta: &struct {
Type string `json:"type,omitempty"`
Text string `json:"text,omitempty"`
PartialJSON string `json:"partial_json,omitempty"`
StopReason string `json:"stop_reason,omitempty"`
StopSequence string `json:"stop_sequence,omitempty"`
}{Type: "input_json_delta", PartialJSON: args},
}, s.flusher)
}
}
func (s *anthropicSSEState) finish(stopReason string) {
// Close all open blocks in order
for _, blockIdx := range s.openBlocks {
writeAnthropicSSE(s.w, anthropicSSE{
Type: "content_block_stop",
Index: &blockIdx,
}, s.flusher)
}
s.openBlocks = nil
writeAnthropicSSE(s.w, anthropicSSE{
Type: "message_delta",
Delta: &struct {
Type string `json:"type,omitempty"`
Text string `json:"text,omitempty"`
PartialJSON string `json:"partial_json,omitempty"`
StopReason string `json:"stop_reason,omitempty"`
StopSequence string `json:"stop_sequence,omitempty"`
}{StopReason: stopReason},
Usage: &AnthropicUsage{
InputTokens: s.inputTokens,
OutputTokens: s.outputTokens,
},
}, s.flusher)
writeAnthropicSSE(s.w, anthropicSSE{Type: "message_stop"}, s.flusher)
s.finished = true
}
// StreamOpenAIAsAnthropic reads OpenAI SSE chunks and converts them to Anthropic SSE events.
func StreamOpenAIAsAnthropic(w http.ResponseWriter, resp *http.Response, model string, requestID string) error {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("anthropic-version", "2023-06-01")
w.Header().Set("x-request-id", requestID)
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)
state := newAnthropicSSEState(w, flusher, model, requestID)
for scanner.Scan() {
line := scanner.Text()
if !strings.HasPrefix(line, "data: ") {
continue
}
data := strings.TrimPrefix(line, "data: ")
if data == "[DONE]" {
if !state.finished {
state.finish("end_turn")
}
continue
}
var chunk map[string]any
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
continue
}
choices, _ := chunk["choices"].([]any)
if len(choices) == 0 {
if u, ok := chunk["usage"].(map[string]any); ok {
if it, ok := u["prompt_tokens"].(float64); ok {
state.inputTokens = int(it)
}
if ot, ok := u["completion_tokens"].(float64); ok {
state.outputTokens = int(ot)
}
}
continue
}
choice := choices[0].(map[string]any)
delta, _ := choice["delta"].(map[string]any)
if !state.messageStarted {
state.startMessage()
}
if content, ok := delta["content"].(string); ok && content != "" {
state.handleText(content)
}
if tcs, ok := delta["tool_calls"].([]any); ok {
for _, tcAny := range tcs {
tc := tcAny.(map[string]any)
idxFloat, ok := tc["index"].(float64)
if !ok {
continue
}
state.handleToolCall(int(idxFloat), tc)
}
}
if fr, ok := choice["finish_reason"].(string); ok && fr != "" && !state.finished {
state.finish(mapFinishReason(fr))
}
}
if _, err := fmt.Fprint(w, ""); err != nil {
return nil
}
flusher.Flush()
return nil
}
func writeAnthropicSSE(w io.Writer, event anthropicSSE, flusher http.Flusher) {
data, _ := json.Marshal(event)
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event.Type, data)
if flusher != nil {
flusher.Flush()
}
}
// ---------- Forwarding for Anthropic ----------
// ForwardAnthropicMessages converts an Anthropic request, forwards to Zen, and returns Anthropic response.
func (p *Proxy) ForwardAnthropicMessages(w http.ResponseWriter, r *http.Request, body []byte) error {
var ar AnthropicRequest
if err := json.Unmarshal(body, &ar); err != nil {
return fmt.Errorf("invalid Anthropic request: %w", err)
}
// Resolve model
resolved, _ := p.ResolveModel(ar.Model)
// Keep original client-requested model name for response rewriting.
// Claude Code validates that the response model matches the request model.
clientModel := ar.Model
// Auto-route to vision model if the request has images and resolved model can't handle them
hasImages := requestHasImages(&ar)
if hasImages {
mi := ModelByID(resolved)
if mi == nil || !mi.SupportsVision {
fmt.Printf("[anthropic] model=%s lacks vision, auto-routing to mimo-v2.5-free\n", resolved)
resolved = "mimo-v2.5-free"
}
}
fmt.Printf("[anthropic] model=%s → %s, max_tokens=%d, stream=%v, messages=%d, tools=%d\n",
clientModel, resolved, ar.MaxTokens, ar.Stream, len(ar.Messages), len(ar.Tools))
// Convert Anthropic → OpenAI
oaReq := AnthropicToOpenAI(&ar)
oaReq.Model = resolved
oaBody, err := json.Marshal(oaReq)
if err != nil {
return fmt.Errorf("failed to marshal OpenAI request: %w", err)
}
fmt.Printf("[anthropic] → openai request (%d bytes)\n", len(oaBody))
// Generate request/session IDs
requestID := RequestID()
sessionID := p.SessionID("anthropic")
// Build upstream request
upstreamReq, err := http.NewRequestWithContext(r.Context(), "POST", ZenChatURL(), bytes.NewReader(oaBody))
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()
fmt.Printf("[anthropic] upstream status=%d, content-type=%s\n", resp.StatusCode, resp.Header.Get("Content-Type"))
if resp.StatusCode != 200 {
bodyBytes, _ := io.ReadAll(resp.Body)
fmt.Printf("[anthropic] upstream error body (%d bytes): %s\n", len(bodyBytes), string(bodyBytes))
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(resp.StatusCode)
json.NewEncoder(w).Encode(AnthropicError{
Type: "error",
Error: AnthropicErrorBody{
Type: "api_error",
Message: string(bodyBytes),
},
})
return nil
}
// Handle streaming vs non-streaming
if ar.Stream {
return StreamOpenAIAsAnthropic(w, resp, clientModel, requestID)
}
// Non-streaming: collect full response and convert
return p.anthropicNonStreamResponse(w, resp, clientModel)
}
func (p *Proxy) anthropicNonStreamResponse(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: %w", err)
}
contentType := resp.Header.Get("Content-Type")
if strings.Contains(contentType, "text/event-stream") {
// Accumulate SSE chunks into a single OpenAI response, then convert
var accumulated OpenAIChatResponse
accumulated.ID = "msg_" + RequestID()
accumulated.Object = "chat.completion"
accumulated.Created = time.Now().Unix()
accumulated.Model = model
accumulated.Choices = []OpenAIChoice{{
Index: 0,
Message: OpenAIMessage{
Role: "assistant",
},
}}
var contentBuf strings.Builder
toolCallsByIndex := make(map[int]*OpenAIToolCall)
var toolIndices []int
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
}
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 tcs, ok := delta["tool_calls"].([]any); ok && len(tcs) > 0 {
for _, tcAny := range tcs {
tc := tcAny.(map[string]any)
idxFloat, ok := tc["index"].(float64)
if !ok {
continue
}
idx := int(idxFloat)
fn, _ := tc["function"].(map[string]any)
existing, exists := toolCallsByIndex[idx]
if !exists {
id, _ := tc["id"].(string)
name, _ := fn["name"].(string)
existing = &OpenAIToolCall{
ID: id,
Type: "function",
Function: OpenAIToolFunc{
Name: name,
Arguments: "",
},
}
toolCallsByIndex[idx] = existing
toolIndices = append(toolIndices, idx)
}
if name, ok := fn["name"].(string); ok && name != "" {
existing.Function.Name = name
}
if args, ok := fn["arguments"].(string); ok {
existing.Function.Arguments += args
}
if id, ok := tc["id"].(string); ok && id != "" {
existing.ID = id
}
}
}
}
if fr, ok := choice["finish_reason"].(string); ok && fr != "" {
accumulated.Choices[0].FinishReason = fr
}
}
if u, ok := chunk["usage"].(map[string]any); ok {
accumulated.Usage = &OpenAIUsage{}
if it, ok := u["prompt_tokens"].(float64); ok {
accumulated.Usage.PromptTokens = int(it)
}
if ot, ok := u["completion_tokens"].(float64); ok {
accumulated.Usage.CompletionTokens = int(ot)
}
accumulated.Usage.TotalTokens = accumulated.Usage.PromptTokens + accumulated.Usage.CompletionTokens
}
}
accumulated.Choices[0].Message.Content = contentBuf.String()
sort.Ints(toolIndices)
for _, idx := range toolIndices {
accumulated.Choices[0].Message.ToolCalls = append(accumulated.Choices[0].Message.ToolCalls, *toolCallsByIndex[idx])
}
if accumulated.Choices[0].FinishReason == "" {
accumulated.Choices[0].FinishReason = "stop"
}
anthroResp := OpenAIToAnthropicResponse(&accumulated, model)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(200)
return json.NewEncoder(w).Encode(anthroResp)
}
// Direct JSON response from upstream
var oaResp OpenAIChatResponse
if err := json.Unmarshal(bodyBytes, &oaResp); err != nil {
// Pass through if not valid OpenAI JSON
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(200)
w.Write(bodyBytes)
return nil
}
anthroResp := OpenAIToAnthropicResponse(&oaResp, model)
w.Header().Set("Content-Type", "application/json")
w.Header().Set("anthropic-version", "2023-06-01")
w.Header().Set("x-request-id", "req_"+RequestID())
w.WriteHeader(200)
return json.NewEncoder(w).Encode(anthroResp)
}
+194
View File
@@ -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)
}
+315
View File
@@ -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
}
+308
View File
@@ -0,0 +1,308 @@
package server
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"opencode-proxy/internal/proxy"
)
const defaultPort = "6446"
const defaultHost = "127.0.0.1"
// Server is the HTTP server that exposes OpenAI-compatible endpoints.
type Server struct {
proxy *proxy.Proxy
port string
host string
mux *http.ServeMux
}
// NewServer creates a new Server with the given proxy.
func NewServer(p *proxy.Proxy) *Server {
s := &Server{
proxy: p,
port: defaultPort,
host: defaultHost,
mux: http.NewServeMux(),
}
s.routes()
return s
}
// SetPort sets the server port.
func (s *Server) SetPort(port string) { s.port = port }
// SetHost sets the server host.
func (s *Server) SetHost(host string) { s.host = host }
// Port returns the current port.
func (s *Server) Port() string { return s.port }
// Host returns the current host.
func (s *Server) Host() string { return s.host }
func (s *Server) routes() {
// Catch-all logger first — logs every request we receive
s.mux.HandleFunc("/", s.logAll)
s.mux.HandleFunc("GET /health", s.handleHealth)
s.mux.HandleFunc("GET /v1/models", s.handleModels)
s.mux.HandleFunc("GET /v1/models/{id}", s.handleModelByID)
s.mux.HandleFunc("POST /v1/chat/completions", s.handleChatCompletions)
s.mux.HandleFunc("POST /v1/messages", s.handleMessages)
// Claude Code appends /v1/messages to ANTHROPIC_BASE_URL automatically.
// If the user sets ANTHROPIC_BASE_URL=http://...:6446/v1, we get /v1/v1/messages.
// Fix: also handle the double /v1/v1/messages path.
s.mux.HandleFunc("POST /v1/v1/messages", s.handleMessages)
}
// logAll captures unmatched requests so we can see what Claude Code is actually hitting.
func (s *Server) logAll(w http.ResponseWriter, r *http.Request) {
fmt.Printf("[CATCH-ALL] %s %s | Headers: ", r.Method, r.URL.Path)
for k, v := range r.Header {
fmt.Printf("%s=%q ", k, v[0])
}
fmt.Println()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNotFound)
fmt.Fprintf(w, `{"error":{"message":"not found: %s %s","type":"not_found"}}`, r.Method, r.URL.Path)
}
// Start begins listening on the configured host:port.
func (s *Server) Start() error {
addr := fmt.Sprintf("%s:%s", s.host, s.port)
fmt.Printf("Listening on http://%s\n", addr)
return http.ListenAndServe(addr, s.mux)
}
// ---------- Auth middleware ----------
// requireAuth returns true if the request has a valid API key.
// If no API key is configured on the proxy, all requests are allowed.
func (s *Server) requireAuth(w http.ResponseWriter, r *http.Request) bool {
key := s.proxy.APIKey()
if key == "" {
return true // no auth required
}
// Check Authorization: Bearer <key>
authHeader := r.Header.Get("Authorization")
if strings.HasPrefix(authHeader, "Bearer ") {
if strings.TrimPrefix(authHeader, "Bearer ") == key {
return true
}
}
// Check x-api-key header
if r.Header.Get("x-api-key") == key {
return true
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(proxy.OpenAIError{
Error: proxy.OpenAIErrorDetail{
Message: "Invalid or missing API key. Use Authorization: Bearer <key> or x-api-key header.",
Type: "authentication_error",
Code: "invalid_api_key",
},
})
return false
}
// ---------- Endpoints ----------
// GET /health
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"status": "ok",
"version": "1.0.0",
"models": len(s.proxy.Models()),
"endpoints": []string{"/v1/chat/completions", "/v1/messages", "/v1/models", "/v1/models/{id}", "/health"},
"auth_required": s.proxy.APIKey() != "",
})
}
// GET /v1/models
func (s *Server) handleModels(w http.ResponseWriter, r *http.Request) {
if !s.requireAuth(w, r) {
return
}
type modelEntry struct {
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
OwnedBy string `json:"owned_by"`
ContextWindow int `json:"context_window"`
MaxOutputTokens int `json:"max_output_tokens"`
Description string `json:"description"`
}
models := s.proxy.Models()
entries := make([]modelEntry, len(models))
for i, m := range models {
entries[i] = modelEntry{
ID: m.ID,
Object: "model",
Created: 1779000000,
OwnedBy: m.OwnedBy,
ContextWindow: m.ContextWindow,
MaxOutputTokens: m.MaxOutputTokens,
Description: m.Description,
}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"object": "list",
"data": entries,
})
}
// GET /v1/models/{id}
func (s *Server) handleModelByID(w http.ResponseWriter, r *http.Request) {
if !s.requireAuth(w, r) {
return
}
id := r.PathValue("id")
for _, m := range s.proxy.Models() {
if m.ID == id {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"id": m.ID,
"object": "model",
"created": 1779000000,
"owned_by": m.OwnedBy,
"context_window": m.ContextWindow,
"max_output_tokens": m.MaxOutputTokens,
"description": m.Description,
})
return
}
}
writeError(w, http.StatusNotFound, fmt.Sprintf("Model '%s' not found", id), "model_not_found")
}
// POST /v1/chat/completions
func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
logRequest(r, "chat")
if !s.requireAuth(w, r) {
return
}
// Read the full body
body, err := io.ReadAll(r.Body)
if err != nil {
writeError(w, http.StatusBadRequest, "Failed to read request body", "invalid_request")
return
}
defer r.Body.Close()
logBody("chat", body)
if len(body) == 0 {
writeError(w, http.StatusBadRequest, "Empty request body", "invalid_request")
return
}
// Forward to upstream
if err := s.proxy.ForwardChatCompletion(w, r, body); err != nil {
fmt.Printf("[server] error forwarding request: %v\n", err)
writeError(w, http.StatusInternalServerError, err.Error(), "proxy_error")
}
}
// POST /v1/messages — Anthropic Messages API
func (s *Server) handleMessages(w http.ResponseWriter, r *http.Request) {
logRequest(r, "anthropic")
if !s.requireAuth(w, r) {
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
writeError(w, http.StatusBadRequest, "Failed to read request body", "invalid_request")
return
}
defer r.Body.Close()
logBody("anthropic", body)
if len(body) == 0 {
writeError(w, http.StatusBadRequest, "Empty request body", "invalid_request")
return
}
// Check for Anthropic version header
av := r.Header.Get("anthropic-version")
if av != "" {
fmt.Printf("[anthropic] version: %s\n", av)
}
if err := s.proxy.ForwardAnthropicMessages(w, r, body); err != nil {
fmt.Printf("[server] error forwarding anthropic request: %v\n", err)
writeError(w, http.StatusInternalServerError, err.Error(), "proxy_error")
}
}
func logRequest(r *http.Request, kind string) {
fmt.Printf("[%s] %s %s | Auth=%s | ContentType=%s\n",
kind, r.Method, r.URL.Path,
truncate(r.Header.Get("Authorization"), 30),
r.Header.Get("Content-Type"))
}
func logBody(kind string, body []byte) {
if len(body) > 500 {
fmt.Printf("[%s] body (%d bytes): %s...\n", kind, len(body), string(body[:500]))
} else {
fmt.Printf("[%s] body: %s\n", kind, string(body))
}
}
func truncate(s string, maxLen int) string {
if len(s) > maxLen {
return s[:maxLen] + "..."
}
return s
}
// ---------- Helpers ----------
func writeError(w http.ResponseWriter, status int, message, errType string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(proxy.OpenAIError{
Error: proxy.OpenAIErrorDetail{
Message: message,
Type: errType,
},
})
}
func writeMethodNotAllowed(w http.ResponseWriter, allowed string) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Allow", allowed)
w.WriteHeader(http.StatusMethodNotAllowed)
json.NewEncoder(w).Encode(proxy.OpenAIError{
Error: proxy.OpenAIErrorDetail{
Message: fmt.Sprintf("Method not allowed. Use %s.", allowed),
Type: "invalid_request_error",
},
})
}
// init sets timezone for consistent timestamps
func init() {
time.Local = time.UTC
}