Changeset 0.22.1 (#95)
This commit is contained in:
296
server/providers/hooks.go
Normal file
296
server/providers/hooks.go
Normal file
@@ -0,0 +1,296 @@
|
||||
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 <think>...</think> tags. The stream_loop already handles
|
||||
// <think> 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 <think> blocks
|
||||
// and move them.
|
||||
if event.Delta != "" && strings.Contains(event.Delta, "<think>") {
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user