package providers import ( "encoding/json" "strings" ) // Hooks defines provider-specific request/response transforms. // Implementations read from ProviderConfig.Settings (populated from the // provider_configs.settings JSONB column) to decorate requests and // normalize responses. This replaces hardcoded provider switch statements. type Hooks interface { // PreRequest modifies the canonical request before dispatch. // May inject system prompt prefixes, set ExtraBody fields, // and adjust request parameters based on provider settings. PreRequest(cfg ProviderConfig, req *CompletionRequest) // PostStreamEvent normalizes a streaming event after the provider // emits it. For example, extracting thinking output from content. PostStreamEvent(cfg ProviderConfig, event *StreamEvent) } // ── Hook Registry ─────────────────────────── var hooks = map[string]Hooks{ "openai": &OpenAIHooks{}, "anthropic": &AnthropicHooks{}, "venice": &VeniceHooks{}, "openrouter": &OpenRouterHooks{}, } // GetHooks returns the hooks for a provider type. // Returns nil for unknown types (no transforms applied). func GetHooks(providerType string) Hooks { return hooks[providerType] } // RegisterHooks adds or replaces hooks for a provider type. func RegisterHooks(providerType string, h Hooks) { hooks[providerType] = h } // ── Settings Helpers ──────────────────────── func settingString(s map[string]interface{}, key string) string { if v, ok := s[key]; ok { if str, ok := v.(string); ok { return str } } return "" } func settingBool(s map[string]interface{}, key string) bool { if v, ok := s[key]; ok { switch b := v.(type) { case bool: return b case string: return b == "true" } } return false } func settingInt(s map[string]interface{}, key string, fallback int) int { if v, ok := s[key]; ok { switch n := v.(type) { case float64: return int(n) // JSON numbers decode as float64 case int: return n } } return fallback } func settingFloat(s map[string]interface{}, key string) (float64, bool) { if v, ok := s[key]; ok { switch n := v.(type) { case float64: return n, true case int: return float64(n), true } } return 0, false } // ── Common: System Prompt Prefix ──────────── // prependSystemPrompt injects a provider-level system prompt prefix // into the first system message, or creates one if none exists. func prependSystemPrompt(prefix string, req *CompletionRequest) { if prefix == "" { return } for i, m := range req.Messages { if m.Role == "system" { req.Messages[i].Content = prefix + "\n\n" + m.Content return } } // No system message — insert at position 0 req.Messages = append([]Message{{Role: "system", Content: prefix}}, req.Messages...) } // ── OpenAI Hooks ──────────────────────────── type OpenAIHooks struct{} func (h *OpenAIHooks) PreRequest(cfg ProviderConfig, req *CompletionRequest) { s := cfg.Settings if s == nil { return } prependSystemPrompt(settingString(s, "system_prompt_prefix"), req) if req.ExtraBody == nil { req.ExtraBody = make(map[string]interface{}) } if v, ok := settingFloat(s, "frequency_penalty"); ok { req.ExtraBody["frequency_penalty"] = v } if v, ok := settingFloat(s, "presence_penalty"); ok { req.ExtraBody["presence_penalty"] = v } } func (h *OpenAIHooks) PostStreamEvent(_ ProviderConfig, _ *StreamEvent) { // No post-processing needed for vanilla OpenAI. } // ── Anthropic Hooks ───────────────────────── type AnthropicHooks struct{} func (h *AnthropicHooks) PreRequest(cfg ProviderConfig, req *CompletionRequest) { s := cfg.Settings if s == nil { return } prependSystemPrompt(settingString(s, "system_prompt_prefix"), req) if settingBool(s, "extended_thinking") { budget := settingInt(s, "thinking_budget", 10000) if req.ExtraBody == nil { req.ExtraBody = make(map[string]interface{}) } req.ExtraBody["thinking"] = map[string]interface{}{ "type": "enabled", "budget_tokens": budget, } // Anthropic requires temperature=1 with extended thinking, and // thinking is incompatible with temperature overrides. req.Temperature = nil } } func (h *AnthropicHooks) PostStreamEvent(_ ProviderConfig, _ *StreamEvent) { // Anthropic streaming already normalizes thinking → Reasoning field. } // ── Venice Hooks ──────────────────────────── type VeniceHooks struct{} func (h *VeniceHooks) PreRequest(cfg ProviderConfig, req *CompletionRequest) { s := cfg.Settings if s == nil { return } prependSystemPrompt(settingString(s, "system_prompt_prefix"), req) // Build venice_parameters from settings veniceParams := map[string]interface{}{} hasParams := false if settingBool(s, "enable_web_search") { veniceParams["enable_web_search"] = "always" hasParams = true } // include_venice_system_prompt defaults to true. // Only set to false when explicitly configured. if v, ok := s["include_venice_system_prompt"]; ok { if b, ok := v.(bool); ok && !b { veniceParams["include_venice_system_prompt"] = false hasParams = true } } if settingBool(s, "enable_thinking") { // Thinking requires disabling Venice system prompt veniceParams["include_venice_system_prompt"] = false hasParams = true } if hasParams { if req.ExtraBody == nil { req.ExtraBody = make(map[string]interface{}) } req.ExtraBody["venice_parameters"] = veniceParams } } func (h *VeniceHooks) PostStreamEvent(cfg ProviderConfig, event *StreamEvent) { // Venice thinking output arrives in the regular content stream // wrapped in ... tags. The stream_loop already handles // extraction from reasoning_content, so we extract Venice // thinking from Delta → Reasoning when enable_thinking is on. if cfg.Settings == nil || !settingBool(cfg.Settings, "enable_thinking") { return } // Venice sends thinking content inline in Delta. The frontend // expects it in the Reasoning field. We detect blocks // and move them. if event.Delta != "" && strings.Contains(event.Delta, "") { // Full block in one chunk (rare but handle it) event.Reasoning += event.Delta event.Delta = "" } } // ── OpenRouter Hooks ──────────────────────── type OpenRouterHooks struct{} func (h *OpenRouterHooks) PreRequest(cfg ProviderConfig, req *CompletionRequest) { s := cfg.Settings if s == nil { return } prependSystemPrompt(settingString(s, "system_prompt_prefix"), req) // OpenRouter route/transforms go in provider preferences header. // These are sent as JSON in the X-Provider-Preferences header by // the OpenAI doRequest (reads from ExtraBody["provider_preferences"]). route := settingString(s, "route") requireParams := settingBool(s, "require_parameters") if route != "" || requireParams { prefs := map[string]interface{}{} if route != "" && route != "auto" { prefs["route"] = route } if requireParams { prefs["require_parameters"] = true } // Serialize for the HTTP header (OpenRouter reads from extra headers) prefsJSON, _ := json.Marshal(prefs) if cfg.CustomHeaders == nil { cfg.CustomHeaders = make(map[string]string) } cfg.CustomHeaders["X-Provider-Preferences"] = string(prefsJSON) } } func (h *OpenRouterHooks) PostStreamEvent(_ ProviderConfig, _ *StreamEvent) { // No post-processing needed. OpenRouter returns standard OAI format. } // ── Preset Override Merge ─────────────────── // MergePresetSettings merges persona-level setting overrides onto // provider-level settings. Preset values take priority except for // fields marked ProviderOnly in the schema. func MergePresetSettings(providerType string, providerSettings, presetOverrides map[string]interface{}) map[string]interface{} { if len(presetOverrides) == 0 { return providerSettings } schema := GetProfileSchema(providerType) providerOnly := make(map[string]bool, len(schema.Fields)) for _, f := range schema.Fields { if f.ProviderOnly { providerOnly[f.Key] = true } } merged := make(map[string]interface{}, len(providerSettings)+len(presetOverrides)) for k, v := range providerSettings { merged[k] = v } for k, v := range presetOverrides { if !providerOnly[k] { merged[k] = v } } return merged }