package providers import ( "context" "encoding/json" "errors" "log" "net/http" "net/url" "sync" "time" "chat-switchboard/models" ) // ── Errors ───────────────────────────────── var ErrNotSupported = errors.New("operation not supported by this provider") // ── Provider Interface ────────────────────── // Provider is the contract for LLM API adapters. type Provider interface { // ID returns the provider identifier (e.g. "openai", "anthropic"). ID() string // ChatCompletion sends a non-streaming request and returns the full response. ChatCompletion(ctx context.Context, cfg ProviderConfig, req CompletionRequest) (*CompletionResponse, error) // StreamCompletion sends a streaming request and returns a channel of events. // The channel is closed when the stream ends. Errors are sent as StreamEvents. StreamCompletion(ctx context.Context, cfg ProviderConfig, req CompletionRequest) (<-chan StreamEvent, error) // ListModels returns available models from this provider. ListModels(ctx context.Context, cfg ProviderConfig) ([]Model, error) // Embed generates embeddings for the given input texts. // Returns ErrNotSupported if the provider has no embedding endpoint. Embed(ctx context.Context, cfg ProviderConfig, req EmbeddingRequest) (*EmbeddingResponse, error) } // ── Configuration ─────────────────────────── // ProviderConfig holds credentials and endpoint for a configured provider. // Populated from the provider_configs table at call time. type ProviderConfig struct { Endpoint string APIKey string CustomHeaders map[string]string // Extra HTTP headers (e.g. OpenRouter HTTP-Referer) Settings map[string]interface{} // Provider-specific settings from config JSONB ProxyMode string // "system" (default), "direct", "custom" ProxyURL string // Only used when ProxyMode == "custom" } // ── HTTP Client Pool ─────────────────────── // // Provider HTTP clients are pooled by (ProxyMode, ProxyURL) tuple. // Previously Client() created a new http.Transport per call, leaking // idle connections and preventing TCP reuse across requests to the same // provider. With pooling, a Venice model sync followed by a completion // reuses the same TCP connection to api.venice.ai. // clientPool holds shared *http.Client instances keyed by proxy config. var clientPool sync.Map // map[string]*http.Client // Client returns an *http.Client configured for this provider's proxy settings. // Clients are cached and reused across calls with the same proxy configuration. // system = http.ProxyFromEnvironment (Go default), direct = no proxy, // custom = explicit proxy URL (http/https/socks5). func (c ProviderConfig) Client() *http.Client { key := c.ProxyMode + "|" + c.ProxyURL if v, ok := clientPool.Load(key); ok { return v.(*http.Client) } transport := &http.Transport{ MaxIdleConns: 100, MaxIdleConnsPerHost: 10, IdleConnTimeout: 90 * time.Second, TLSHandshakeTimeout: 10 * time.Second, } switch c.ProxyMode { case "direct": transport.Proxy = nil case "custom": if c.ProxyURL != "" { u, err := url.Parse(c.ProxyURL) if err != nil { // Fall back to system rather than silently going direct — // a bad custom URL in a mandatory-proxy environment is a // security/audit hole if it silently bypasses. log.Printf("[proxy] invalid custom proxy URL %q: %v — falling back to system proxy", c.ProxyURL, err) transport.Proxy = http.ProxyFromEnvironment } else { transport.Proxy = http.ProxyURL(u) } } else { transport.Proxy = http.ProxyFromEnvironment } default: // "system" or empty transport.Proxy = http.ProxyFromEnvironment } client := &http.Client{Transport: transport} actual, _ := clientPool.LoadOrStore(key, client) return actual.(*http.Client) } // ── Request / Response Types ──────────────── // Message represents a chat message in the conversation. type Message struct { Role string `json:"role"` // user, assistant, system, tool Content string `json:"content"` // Multimodal content parts (v0.12.0+). When set, providers use these // instead of Content for the user message. Content is still set as // a fallback and for message persistence (extracted text summary). ContentParts []ContentPart `json:"content_parts,omitempty"` // Tool calling fields (assistant → tool_calls, tool → tool_call_id) ToolCalls []ToolCall `json:"tool_calls,omitempty"` // set when assistant requests tools ToolCallID string `json:"tool_call_id,omitempty"` // set for role="tool" result messages Name string `json:"name,omitempty"` // speaker name (tool result, or persona attribution v0.23.0) } // ContentPart is a single element in a multimodal message. // Providers convert these to their native wire format. type ContentPart struct { Type string `json:"type"` // "text", "image_url", "document" // Text content (type="text") Text string `json:"text,omitempty"` // Image content (type="image_url") — base64 data URI ImageURL *ImageURL `json:"image_url,omitempty"` } // ImageURL holds image data for multimodal requests. // URL is a data URI: "data:image/jpeg;base64,..." type ImageURL struct { URL string `json:"url"` Detail string `json:"detail,omitempty"` // "auto", "low", "high" } // ToolCall represents a function call requested by the LLM. type ToolCall struct { ID string `json:"id"` Type string `json:"type"` // "function" Function FunctionCall `json:"function"` } // FunctionCall is the function name and JSON arguments. type FunctionCall struct { Name string `json:"name"` Arguments string `json:"arguments"` } // ToolDef describes a tool available to the LLM. type ToolDef struct { Type string `json:"type"` // "function" Function FunctionDef `json:"function"` } // FunctionDef is the schema for a tool function. type FunctionDef struct { Name string `json:"name"` Description string `json:"description"` Parameters json.RawMessage `json:"parameters"` } // CompletionRequest is the normalized request sent to any provider. type CompletionRequest struct { Model string `json:"model"` Messages []Message `json:"messages"` MaxTokens int `json:"max_tokens,omitempty"` Temperature *float64 `json:"temperature,omitempty"` TopP *float64 `json:"top_p,omitempty"` Stream bool `json:"stream,omitempty"` Tools []ToolDef `json:"tools,omitempty"` // available tools for function calling ExtraBody map[string]interface{} `json:"-"` // provider-specific wire fields (v0.22.1) } // CompletionResponse is the normalized non-streaming response. type CompletionResponse struct { Content string `json:"content"` Model string `json:"model"` FinishReason string `json:"finish_reason"` InputTokens int `json:"input_tokens"` OutputTokens int `json:"output_tokens"` CacheCreationTokens int `json:"cache_creation_tokens,omitempty"` CacheReadTokens int `json:"cache_read_tokens,omitempty"` ToolCalls []ToolCall `json:"tool_calls,omitempty"` // set when finish_reason is "tool_calls" } // StreamEvent is a single chunk from a streaming response. type StreamEvent struct { Delta string `json:"delta,omitempty"` Reasoning string `json:"reasoning,omitempty"` Done bool `json:"done,omitempty"` FinishReason string `json:"finish_reason,omitempty"` ToolCalls []ToolCall `json:"tool_calls,omitempty"` Model string `json:"model,omitempty"` Error error `json:"-"` InputTokens int `json:"input_tokens,omitempty"` OutputTokens int `json:"output_tokens,omitempty"` CacheCreationTokens int `json:"cache_creation_tokens,omitempty"` CacheReadTokens int `json:"cache_read_tokens,omitempty"` } // ── Embedding Types ──────────────────────── // EmbeddingRequest is the normalized embedding request. type EmbeddingRequest struct { Model string `json:"model"` Input []string `json:"input"` // batch of texts to embed } // EmbeddingResponse is the normalized embedding response. type EmbeddingResponse struct { Embeddings [][]float64 `json:"embeddings"` Model string `json:"model"` InputTokens int `json:"input_tokens"` } // Model represents an available model from a provider, with capabilities. type Model struct { ID string `json:"id"` Name string `json:"name,omitempty"` Type string `json:"type,omitempty"` // "chat", "embedding", "image" — from provider API OwnedBy string `json:"owned_by,omitempty"` Capabilities models.ModelCapabilities `json:"capabilities"` Pricing *models.ModelPricing `json:"pricing,omitempty"` } // mergeExtraBody merges arbitrary key-value pairs into a marshalled JSON object. // Used by provider doRequest methods to inject hook-supplied fields (e.g. // venice_parameters, thinking config) into the wire-format request body. func mergeExtraBody(body []byte, extra map[string]interface{}) ([]byte, error) { var base map[string]interface{} if err := json.Unmarshal(body, &base); err != nil { return nil, err } for k, v := range extra { base[k] = v } return json.Marshal(base) }