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 }