Changeset 0.22.1 (#95)
This commit is contained in:
@@ -130,10 +130,15 @@ func (p *AnthropicProvider) StreamCompletion(ctx context.Context, cfg ProviderCo
|
||||
},
|
||||
})
|
||||
}
|
||||
// thinking blocks (extended thinking, v0.22.1) — no action on start,
|
||||
// deltas arrive as thinking_delta in content_block_delta
|
||||
|
||||
case "content_block_delta":
|
||||
if event.Delta.Type == "text_delta" {
|
||||
ch <- StreamEvent{Delta: event.Delta.Text}
|
||||
} else if event.Delta.Type == "thinking_delta" && event.Delta.Thinking != "" {
|
||||
// Extended thinking content → Reasoning field
|
||||
ch <- StreamEvent{Reasoning: event.Delta.Thinking}
|
||||
} else if event.Delta.Type == "input_json_delta" && currentToolIdx >= 0 {
|
||||
toolCalls[currentToolIdx].Function.Arguments += event.Delta.PartialJSON
|
||||
}
|
||||
@@ -343,6 +348,15 @@ func (p *AnthropicProvider) doRequest(ctx context.Context, cfg ProviderConfig, r
|
||||
return nil, fmt.Errorf("marshal request: %w", err)
|
||||
}
|
||||
|
||||
// Merge hook-supplied extra fields into wire JSON (v0.22.1)
|
||||
// This is how extended thinking config gets injected.
|
||||
if len(req.ExtraBody) > 0 {
|
||||
body, err = mergeExtraBody(body, req.ExtraBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("merge extra body: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -351,6 +365,13 @@ func (p *AnthropicProvider) doRequest(ctx context.Context, cfg ProviderConfig, r
|
||||
httpReq.Header.Set("x-api-key", cfg.APIKey)
|
||||
httpReq.Header.Set("anthropic-version", anthropicAPIVersion)
|
||||
|
||||
// Extended thinking requires the output-128k beta header
|
||||
if len(req.ExtraBody) > 0 {
|
||||
if _, hasThinking := req.ExtraBody["thinking"]; hasThinking {
|
||||
httpReq.Header.Set("anthropic-beta", "output-128k-2025-02-19")
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("provider request: %w", err)
|
||||
@@ -458,6 +479,7 @@ type anthropicStreamEvent struct {
|
||||
Delta struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
Thinking string `json:"thinking"` // extended thinking delta (v0.22.1)
|
||||
StopReason string `json:"stop_reason"`
|
||||
PartialJSON string `json:"partial_json"`
|
||||
} `json:"delta"`
|
||||
|
||||
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
|
||||
}
|
||||
340
server/providers/hooks_test.go
Normal file
340
server/providers/hooks_test.go
Normal file
@@ -0,0 +1,340 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ── Profile Schema Tests ────────────────────
|
||||
|
||||
func TestGetProfileSchema_BuiltIn(t *testing.T) {
|
||||
for _, id := range []string{"openai", "anthropic", "venice", "openrouter"} {
|
||||
s := GetProfileSchema(id)
|
||||
if len(s.Fields) == 0 {
|
||||
t.Errorf("GetProfileSchema(%q) returned empty fields", id)
|
||||
}
|
||||
// Every schema should have system_prompt_prefix
|
||||
found := false
|
||||
for _, f := range s.Fields {
|
||||
if f.Key == "system_prompt_prefix" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("GetProfileSchema(%q) missing system_prompt_prefix field", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetProfileSchema_UnknownFallsToOpenAI(t *testing.T) {
|
||||
s := GetProfileSchema("unknown-provider")
|
||||
oai := GetProfileSchema("openai")
|
||||
if len(s.Fields) != len(oai.Fields) {
|
||||
t.Errorf("Unknown provider should fall back to openai schema, got %d fields, want %d", len(s.Fields), len(oai.Fields))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Hook PreRequest Tests ───────────────────
|
||||
|
||||
func TestOpenAIHooks_SystemPromptPrefix(t *testing.T) {
|
||||
h := &OpenAIHooks{}
|
||||
cfg := ProviderConfig{
|
||||
Settings: map[string]interface{}{
|
||||
"system_prompt_prefix": "You are a helpful pirate.",
|
||||
},
|
||||
}
|
||||
req := &CompletionRequest{
|
||||
Messages: []Message{
|
||||
{Role: "system", Content: "Be concise."},
|
||||
{Role: "user", Content: "Hello"},
|
||||
},
|
||||
}
|
||||
|
||||
h.PreRequest(cfg, req)
|
||||
|
||||
if req.Messages[0].Content != "You are a helpful pirate.\n\nBe concise." {
|
||||
t.Errorf("system prompt prefix not prepended, got: %s", req.Messages[0].Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIHooks_SystemPromptPrefix_NoExisting(t *testing.T) {
|
||||
h := &OpenAIHooks{}
|
||||
cfg := ProviderConfig{
|
||||
Settings: map[string]interface{}{
|
||||
"system_prompt_prefix": "Be a pirate.",
|
||||
},
|
||||
}
|
||||
req := &CompletionRequest{
|
||||
Messages: []Message{
|
||||
{Role: "user", Content: "Hello"},
|
||||
},
|
||||
}
|
||||
|
||||
h.PreRequest(cfg, req)
|
||||
|
||||
if len(req.Messages) != 2 {
|
||||
t.Fatalf("expected 2 messages, got %d", len(req.Messages))
|
||||
}
|
||||
if req.Messages[0].Role != "system" || req.Messages[0].Content != "Be a pirate." {
|
||||
t.Errorf("expected injected system message, got role=%s content=%s", req.Messages[0].Role, req.Messages[0].Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIHooks_FrequencyPenalty(t *testing.T) {
|
||||
h := &OpenAIHooks{}
|
||||
cfg := ProviderConfig{
|
||||
Settings: map[string]interface{}{
|
||||
"frequency_penalty": 0.5,
|
||||
},
|
||||
}
|
||||
req := &CompletionRequest{Messages: []Message{{Role: "user", Content: "Hello"}}}
|
||||
|
||||
h.PreRequest(cfg, req)
|
||||
|
||||
if req.ExtraBody == nil {
|
||||
t.Fatal("ExtraBody should be set")
|
||||
}
|
||||
if v, ok := req.ExtraBody["frequency_penalty"].(float64); !ok || v != 0.5 {
|
||||
t.Errorf("frequency_penalty: got %v, want 0.5", req.ExtraBody["frequency_penalty"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIHooks_NilSettings(t *testing.T) {
|
||||
h := &OpenAIHooks{}
|
||||
cfg := ProviderConfig{Settings: nil}
|
||||
req := &CompletionRequest{Messages: []Message{{Role: "user", Content: "Hello"}}}
|
||||
|
||||
h.PreRequest(cfg, req) // should not panic
|
||||
if req.ExtraBody != nil {
|
||||
t.Errorf("ExtraBody should be nil with no settings")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnthropicHooks_ExtendedThinking(t *testing.T) {
|
||||
h := &AnthropicHooks{}
|
||||
temp := 0.7
|
||||
cfg := ProviderConfig{
|
||||
Settings: map[string]interface{}{
|
||||
"extended_thinking": true,
|
||||
"thinking_budget": float64(20000), // JSON numbers are float64
|
||||
},
|
||||
}
|
||||
req := &CompletionRequest{
|
||||
Messages: []Message{{Role: "user", Content: "Think hard"}},
|
||||
Temperature: &temp,
|
||||
}
|
||||
|
||||
h.PreRequest(cfg, req)
|
||||
|
||||
if req.Temperature != nil {
|
||||
t.Error("Temperature should be cleared for extended thinking")
|
||||
}
|
||||
if req.ExtraBody == nil {
|
||||
t.Fatal("ExtraBody should be set")
|
||||
}
|
||||
thinking, ok := req.ExtraBody["thinking"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("ExtraBody[thinking] should be map")
|
||||
}
|
||||
if thinking["type"] != "enabled" {
|
||||
t.Errorf("thinking.type = %v, want enabled", thinking["type"])
|
||||
}
|
||||
if thinking["budget_tokens"] != 20000 {
|
||||
t.Errorf("thinking.budget_tokens = %v, want 20000", thinking["budget_tokens"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnthropicHooks_ThinkingDefaultBudget(t *testing.T) {
|
||||
h := &AnthropicHooks{}
|
||||
cfg := ProviderConfig{
|
||||
Settings: map[string]interface{}{
|
||||
"extended_thinking": true,
|
||||
// no thinking_budget → should use default 10000
|
||||
},
|
||||
}
|
||||
req := &CompletionRequest{Messages: []Message{{Role: "user", Content: "Think"}}}
|
||||
|
||||
h.PreRequest(cfg, req)
|
||||
|
||||
thinking := req.ExtraBody["thinking"].(map[string]interface{})
|
||||
if thinking["budget_tokens"] != 10000 {
|
||||
t.Errorf("default budget = %v, want 10000", thinking["budget_tokens"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnthropicHooks_NoThinking(t *testing.T) {
|
||||
h := &AnthropicHooks{}
|
||||
temp := 0.7
|
||||
cfg := ProviderConfig{
|
||||
Settings: map[string]interface{}{
|
||||
"extended_thinking": false,
|
||||
},
|
||||
}
|
||||
req := &CompletionRequest{
|
||||
Messages: []Message{{Role: "user", Content: "Hi"}},
|
||||
Temperature: &temp,
|
||||
}
|
||||
|
||||
h.PreRequest(cfg, req)
|
||||
|
||||
if req.ExtraBody != nil {
|
||||
t.Error("ExtraBody should be nil when thinking is disabled")
|
||||
}
|
||||
if req.Temperature == nil {
|
||||
t.Error("Temperature should be preserved when thinking is off")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVeniceHooks_WebSearch(t *testing.T) {
|
||||
h := &VeniceHooks{}
|
||||
cfg := ProviderConfig{
|
||||
Settings: map[string]interface{}{
|
||||
"enable_web_search": true,
|
||||
},
|
||||
}
|
||||
req := &CompletionRequest{Messages: []Message{{Role: "user", Content: "Search for news"}}}
|
||||
|
||||
h.PreRequest(cfg, req)
|
||||
|
||||
vp, ok := req.ExtraBody["venice_parameters"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("venice_parameters missing from ExtraBody")
|
||||
}
|
||||
if vp["enable_web_search"] != "always" {
|
||||
t.Errorf("enable_web_search = %v, want 'always'", vp["enable_web_search"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestVeniceHooks_ThinkingDisablesSystemPrompt(t *testing.T) {
|
||||
h := &VeniceHooks{}
|
||||
cfg := ProviderConfig{
|
||||
Settings: map[string]interface{}{
|
||||
"enable_thinking": true,
|
||||
},
|
||||
}
|
||||
req := &CompletionRequest{Messages: []Message{{Role: "user", Content: "Think"}}}
|
||||
|
||||
h.PreRequest(cfg, req)
|
||||
|
||||
vp := req.ExtraBody["venice_parameters"].(map[string]interface{})
|
||||
if vp["include_venice_system_prompt"] != false {
|
||||
t.Error("Thinking mode should set include_venice_system_prompt=false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVeniceHooks_NoSettings(t *testing.T) {
|
||||
h := &VeniceHooks{}
|
||||
cfg := ProviderConfig{Settings: map[string]interface{}{}}
|
||||
req := &CompletionRequest{Messages: []Message{{Role: "user", Content: "Hi"}}}
|
||||
|
||||
h.PreRequest(cfg, req)
|
||||
|
||||
if req.ExtraBody != nil {
|
||||
t.Error("ExtraBody should be nil with empty settings")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenRouterHooks_Route(t *testing.T) {
|
||||
h := &OpenRouterHooks{}
|
||||
cfg := ProviderConfig{
|
||||
Settings: map[string]interface{}{
|
||||
"route": "fallback",
|
||||
"require_parameters": true,
|
||||
},
|
||||
CustomHeaders: map[string]string{},
|
||||
}
|
||||
req := &CompletionRequest{Messages: []Message{{Role: "user", Content: "Hi"}}}
|
||||
|
||||
h.PreRequest(cfg, req)
|
||||
|
||||
// Note: OpenRouter hooks set headers on the cfg copy, which is local.
|
||||
// The real effect is tested via the serialized header value.
|
||||
// Since cfg is passed by value in hooks, this test verifies the logic runs without panic.
|
||||
}
|
||||
|
||||
// ── GetHooks Tests ──────────────────────────
|
||||
|
||||
func TestGetHooks_BuiltIn(t *testing.T) {
|
||||
for _, id := range []string{"openai", "anthropic", "venice", "openrouter"} {
|
||||
h := GetHooks(id)
|
||||
if h == nil {
|
||||
t.Errorf("GetHooks(%q) returned nil", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetHooks_Unknown(t *testing.T) {
|
||||
h := GetHooks("nonexistent")
|
||||
if h != nil {
|
||||
t.Error("GetHooks for unknown type should return nil")
|
||||
}
|
||||
}
|
||||
|
||||
// ── MergePresetSettings Tests ───────────────
|
||||
|
||||
func TestMergePresetSettings_Basic(t *testing.T) {
|
||||
provider := map[string]interface{}{
|
||||
"system_prompt_prefix": "Provider prompt",
|
||||
"frequency_penalty": 0.3,
|
||||
}
|
||||
preset := map[string]interface{}{
|
||||
"system_prompt_prefix": "Preset prompt",
|
||||
}
|
||||
|
||||
merged := MergePresetSettings("openai", provider, preset)
|
||||
|
||||
if merged["system_prompt_prefix"] != "Preset prompt" {
|
||||
t.Errorf("preset should override provider, got %v", merged["system_prompt_prefix"])
|
||||
}
|
||||
if merged["frequency_penalty"] != 0.3 {
|
||||
t.Errorf("provider value should be preserved, got %v", merged["frequency_penalty"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergePresetSettings_ProviderOnlyBlocked(t *testing.T) {
|
||||
provider := map[string]interface{}{
|
||||
"route": "auto",
|
||||
}
|
||||
preset := map[string]interface{}{
|
||||
"route": "fallback", // route is ProviderOnly for openrouter
|
||||
}
|
||||
|
||||
merged := MergePresetSettings("openrouter", provider, preset)
|
||||
|
||||
if merged["route"] != "auto" {
|
||||
t.Errorf("ProviderOnly field should not be overridden by preset, got %v", merged["route"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergePresetSettings_EmptyOverrides(t *testing.T) {
|
||||
provider := map[string]interface{}{"key": "value"}
|
||||
merged := MergePresetSettings("openai", provider, nil)
|
||||
if merged["key"] != "value" {
|
||||
t.Error("empty overrides should return provider settings unchanged")
|
||||
}
|
||||
}
|
||||
|
||||
// ── mergeExtraBody Tests ────────────────────
|
||||
|
||||
func TestMergeExtraBody(t *testing.T) {
|
||||
body := []byte(`{"model":"gpt-4","stream":true}`)
|
||||
extra := map[string]interface{}{
|
||||
"venice_parameters": map[string]interface{}{
|
||||
"enable_web_search": "always",
|
||||
},
|
||||
}
|
||||
|
||||
merged, err := mergeExtraBody(body, extra)
|
||||
if err != nil {
|
||||
t.Fatalf("mergeExtraBody error: %v", err)
|
||||
}
|
||||
|
||||
// Should contain both original and extra fields
|
||||
s := string(merged)
|
||||
if !strings.Contains(s, `"model"`) || !strings.Contains(s, `"stream"`) {
|
||||
t.Error("original fields missing from merged body")
|
||||
}
|
||||
if !strings.Contains(s, `"venice_parameters"`) || !strings.Contains(s, `"enable_web_search"`) {
|
||||
t.Error("extra fields missing from merged body")
|
||||
}
|
||||
}
|
||||
@@ -432,6 +432,14 @@ func (p *OpenAIProvider) doRequest(ctx context.Context, cfg ProviderConfig, req
|
||||
return nil, fmt.Errorf("marshal request: %w", err)
|
||||
}
|
||||
|
||||
// Merge hook-supplied extra fields into wire JSON (v0.22.1)
|
||||
if len(req.ExtraBody) > 0 {
|
||||
body, err = mergeExtraBody(body, req.ExtraBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("merge extra body: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
154
server/providers/profile.go
Normal file
154
server/providers/profile.go
Normal file
@@ -0,0 +1,154 @@
|
||||
package providers
|
||||
|
||||
// ProfileSchema describes the available settings for a provider type.
|
||||
// Used by the admin UI to render forms and by validation on save.
|
||||
type ProfileSchema struct {
|
||||
// Fields lists the configurable settings in display order.
|
||||
Fields []ProfileField `json:"fields"`
|
||||
}
|
||||
|
||||
// ProfileField describes a single setting within a provider profile.
|
||||
type ProfileField struct {
|
||||
Key string `json:"key"` // settings JSON key
|
||||
Label string `json:"label"` // human-readable name
|
||||
Description string `json:"description,omitempty"` // help text
|
||||
Type string `json:"type"` // "bool", "int", "string", "enum", "float"
|
||||
Default interface{} `json:"default,omitempty"` // default value
|
||||
EnumValues []string `json:"enum_values,omitempty"` // valid values when Type="enum"
|
||||
Min *int `json:"min,omitempty"` // min for int/float
|
||||
Max *int `json:"max,omitempty"` // max for int/float
|
||||
DependsOn string `json:"depends_on,omitempty"` // only show when this key is truthy
|
||||
ProviderOnly bool `json:"provider_only,omitempty"` // not overridable at preset level
|
||||
}
|
||||
|
||||
// ── Built-in Profile Schemas ────────────────
|
||||
|
||||
// intPtr is a helper for literal pointers in struct init.
|
||||
func intPtr(v int) *int { return &v }
|
||||
|
||||
var profileSchemas = map[string]ProfileSchema{
|
||||
"openai": {
|
||||
Fields: []ProfileField{
|
||||
{
|
||||
Key: "system_prompt_prefix",
|
||||
Label: "System Prompt Prefix",
|
||||
Description: "Prepended to every system prompt sent to this provider.",
|
||||
Type: "string",
|
||||
},
|
||||
{
|
||||
Key: "frequency_penalty",
|
||||
Label: "Frequency Penalty",
|
||||
Description: "Penalizes repeated tokens. Range: -2.0 to 2.0.",
|
||||
Type: "float",
|
||||
},
|
||||
{
|
||||
Key: "presence_penalty",
|
||||
Label: "Presence Penalty",
|
||||
Description: "Penalizes tokens already present. Range: -2.0 to 2.0.",
|
||||
Type: "float",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
"anthropic": {
|
||||
Fields: []ProfileField{
|
||||
{
|
||||
Key: "system_prompt_prefix",
|
||||
Label: "System Prompt Prefix",
|
||||
Description: "Prepended to every system prompt sent to this provider.",
|
||||
Type: "string",
|
||||
},
|
||||
{
|
||||
Key: "extended_thinking",
|
||||
Label: "Extended Thinking",
|
||||
Description: "Enable Anthropic extended thinking mode. Sends thinking.type=enabled with budget_tokens.",
|
||||
Type: "bool",
|
||||
Default: false,
|
||||
},
|
||||
{
|
||||
Key: "thinking_budget",
|
||||
Label: "Thinking Budget (tokens)",
|
||||
Description: "Max tokens for the thinking phase. Only used when extended_thinking is enabled.",
|
||||
Type: "int",
|
||||
Default: 10000,
|
||||
Min: intPtr(1024),
|
||||
Max: intPtr(128000),
|
||||
DependsOn: "extended_thinking",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
"venice": {
|
||||
Fields: []ProfileField{
|
||||
{
|
||||
Key: "system_prompt_prefix",
|
||||
Label: "System Prompt Prefix",
|
||||
Description: "Prepended to every system prompt. Venice also injects its own character prompt.",
|
||||
Type: "string",
|
||||
},
|
||||
{
|
||||
Key: "enable_thinking",
|
||||
Label: "Enable Thinking",
|
||||
Description: "Send venice_parameters.include_venice_system_prompt=false and enable thinking output.",
|
||||
Type: "bool",
|
||||
Default: false,
|
||||
},
|
||||
{
|
||||
Key: "enable_web_search",
|
||||
Label: "Enable Web Search",
|
||||
Description: "Allow Venice to search the web for context (venice_parameters.enable_web_search).",
|
||||
Type: "bool",
|
||||
Default: false,
|
||||
},
|
||||
{
|
||||
Key: "include_venice_system_prompt",
|
||||
Label: "Include Venice System Prompt",
|
||||
Description: "Include Venice's built-in character system prompt.",
|
||||
Type: "bool",
|
||||
Default: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
"openrouter": {
|
||||
Fields: []ProfileField{
|
||||
{
|
||||
Key: "system_prompt_prefix",
|
||||
Label: "System Prompt Prefix",
|
||||
Description: "Prepended to every system prompt sent through OpenRouter.",
|
||||
Type: "string",
|
||||
},
|
||||
{
|
||||
Key: "route",
|
||||
Label: "Routing Strategy",
|
||||
Description: "How OpenRouter selects the underlying provider for a model.",
|
||||
Type: "enum",
|
||||
Default: "auto",
|
||||
EnumValues: []string{"auto", "fallback"},
|
||||
ProviderOnly: true,
|
||||
},
|
||||
{
|
||||
Key: "require_parameters",
|
||||
Label: "Require Parameters Support",
|
||||
Description: "Only route to providers that support the requested parameters (tools, temperature, etc.).",
|
||||
Type: "bool",
|
||||
Default: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// GetProfileSchema returns the profile schema for a provider type.
|
||||
// Falls back to the openai schema for unknown types (OpenAI-compatible).
|
||||
func GetProfileSchema(providerType string) ProfileSchema {
|
||||
if s, ok := profileSchemas[providerType]; ok {
|
||||
return s
|
||||
}
|
||||
return profileSchemas["openai"]
|
||||
}
|
||||
|
||||
// RegisterProfileSchema adds or replaces a profile schema for a provider type.
|
||||
// Used for testing and future plugin support.
|
||||
func RegisterProfileSchema(providerType string, schema ProfileSchema) {
|
||||
profileSchemas[providerType] = schema
|
||||
}
|
||||
@@ -110,13 +110,14 @@ type FunctionDef struct {
|
||||
|
||||
// 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
|
||||
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.
|
||||
@@ -170,3 +171,17 @@ type Model struct {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -40,10 +40,70 @@ func List() []string {
|
||||
return ids
|
||||
}
|
||||
|
||||
// ── Provider Type Metadata ──────────────────
|
||||
|
||||
// ProviderTypeMeta describes a registered provider type for the admin API.
|
||||
type ProviderTypeMeta struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
DefaultEndpoint string `json:"default_endpoint"`
|
||||
ProfileSchema ProfileSchema `json:"profile_schema"`
|
||||
}
|
||||
|
||||
var providerTypes = map[string]ProviderTypeMeta{}
|
||||
|
||||
// RegisterType adds a provider type with its metadata to the type registry.
|
||||
// Called by Init() for built-in types. External types can be registered at startup.
|
||||
func RegisterType(meta ProviderTypeMeta, p Provider) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
registry[meta.ID] = p
|
||||
providerTypes[meta.ID] = meta
|
||||
}
|
||||
|
||||
// ListTypes returns metadata for all registered provider types.
|
||||
func ListTypes() []ProviderTypeMeta {
|
||||
mu.RLock()
|
||||
defer mu.RUnlock()
|
||||
out := make([]ProviderTypeMeta, 0, len(providerTypes))
|
||||
for _, m := range providerTypes {
|
||||
out = append(out, m)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Init registers all built-in providers.
|
||||
func Init() {
|
||||
Register(&OpenAIProvider{})
|
||||
Register(&AnthropicProvider{})
|
||||
Register(&VeniceProvider{})
|
||||
Register(&OpenRouterProvider{})
|
||||
RegisterType(ProviderTypeMeta{
|
||||
ID: "openai",
|
||||
Name: "OpenAI",
|
||||
Description: "OpenAI and any OpenAI-compatible API (Ollama, LiteLLM, etc.)",
|
||||
DefaultEndpoint: "https://api.openai.com/v1",
|
||||
ProfileSchema: GetProfileSchema("openai"),
|
||||
}, &OpenAIProvider{})
|
||||
|
||||
RegisterType(ProviderTypeMeta{
|
||||
ID: "anthropic",
|
||||
Name: "Anthropic",
|
||||
Description: "Anthropic Messages API (Claude models)",
|
||||
DefaultEndpoint: "https://api.anthropic.com",
|
||||
ProfileSchema: GetProfileSchema("anthropic"),
|
||||
}, &AnthropicProvider{})
|
||||
|
||||
RegisterType(ProviderTypeMeta{
|
||||
ID: "venice",
|
||||
Name: "Venice",
|
||||
Description: "Venice.ai — privacy-focused, OpenAI-compatible with extensions",
|
||||
DefaultEndpoint: "https://api.venice.ai/api/v1",
|
||||
ProfileSchema: GetProfileSchema("venice"),
|
||||
}, &VeniceProvider{})
|
||||
|
||||
RegisterType(ProviderTypeMeta{
|
||||
ID: "openrouter",
|
||||
Name: "OpenRouter",
|
||||
Description: "OpenRouter — unified API for 300+ models with per-model pricing",
|
||||
DefaultEndpoint: "https://openrouter.ai/api/v1",
|
||||
ProfileSchema: GetProfileSchema("openrouter"),
|
||||
}, &OpenRouterProvider{})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user