Changeset 0.22.1 (#95)

This commit is contained in:
2026-03-02 09:58:38 +00:00
parent 06c4e2a5a1
commit cae6fd9f93
16 changed files with 1030 additions and 40 deletions

View File

@@ -2,6 +2,32 @@
All notable changes to Chat Switchboard. All notable changes to Chat Switchboard.
## [0.22.1] — 2026-03-02
### Added
- **Provider profile schemas.** Each provider type declares its configurable settings with types, defaults, validation constraints, and dependency rules. Built-in schemas for openai, anthropic, venice, openrouter. Unknown types fall back to the openai schema. `providers/profile.go`.
- **Provider hooks (pre-request / post-stream).** Declarative request/response transforms driven by `provider_configs.settings` JSONB. Replaces any need for hardcoded provider switch statements in the completion path. `providers/hooks.go`.
- **OpenAI:** `system_prompt_prefix`, `frequency_penalty`, `presence_penalty` → injected into ExtraBody.
- **Anthropic:** `extended_thinking` + `thinking_budget` → injects `thinking` config into wire JSON, sets `anthropic-beta` header, clears temperature.
- **Venice:** `enable_thinking`, `enable_web_search`, `include_venice_system_prompt` → builds `venice_parameters` in ExtraBody.
- **OpenRouter:** `route`, `require_parameters` → serialized into `X-Provider-Preferences` header.
- **`ExtraBody` on `CompletionRequest`.** Provider hooks write arbitrary key-value pairs that get merged into the wire-format JSON via `mergeExtraBody()` in both OpenAI and Anthropic `doRequest` methods. Keeps the canonical request type clean while supporting provider-specific extensions.
- **Anthropic extended thinking stream support.** `content_block_delta` with `type=thinking_delta` now routes to the `Reasoning` field on `StreamEvent`, matching the existing `reasoning_content` path used by OpenAI-compatible providers.
- **Provider type registry.** `providers.RegisterType()` combines provider implementation + metadata (name, description, default endpoint, profile schema). `providers.ListTypes()` returns all registered types.
- **`GET /api/v1/admin/provider-types` endpoint.** Returns metadata and profile schemas for all registered provider types. Used by admin UI to render provider creation forms and show available settings per type.
- **Preset setting overrides.** `MergePresetSettings()` merges persona-level overrides onto provider-level settings, respecting `ProviderOnly` fields that cannot be changed at the preset level.
- **Comprehensive tests.** `providers/hooks_test.go`: 17 tests covering all four hook implementations, profile schemas, GetHooks, MergePresetSettings, mergeExtraBody, nil safety.
### Changed
- `providers/registry.go`: `Init()` now uses `RegisterType()` with full metadata instead of bare `Register()`. `ProviderTypeMeta` struct with ID, name, description, default endpoint, profile schema.
- `providers/provider.go`: `CompletionRequest.ExtraBody` field (json:"-"). `mergeExtraBody()` helper function. `Model` struct unchanged.
- `providers/openai.go`: `doRequest()` merges ExtraBody into wire JSON before HTTP dispatch.
- `providers/anthropic.go`: `doRequest()` merges ExtraBody + sets `anthropic-beta` header when thinking is enabled. Stream processing handles `thinking_delta` content block deltas. Wire type gains `Thinking` field.
- `handlers/completion.go`: `PreRequest` hook invoked at all three dispatch paths (multi-model, streaming, sync).
- `handlers/stream_loop.go`: `PostStreamEvent` hook invoked after each streaming event in both `streamWithToolLoop` and `streamModelResponse`.
- `handlers/health_admin.go`: Added `GetProviderTypes` handler.
- `main.go`: `GET /api/v1/admin/provider-types` route registered.
## [0.22.0] — 2026-03-02 ## [0.22.0] — 2026-03-02
### Added ### Added

View File

@@ -1 +1 @@
0.22.0 0.22.1

View File

@@ -186,9 +186,13 @@ server/
├── notelinks/ # Wikilink extraction (regex → NoteLink structs) ├── notelinks/ # Wikilink extraction (regex → NoteLink structs)
├── knowledge/ # KB chunking, embedding, search ├── knowledge/ # KB chunking, embedding, search
├── providers/ # LLM provider adapters ├── providers/ # LLM provider adapters
│ ├── provider.go # Provider interface │ ├── provider.go # Provider interface + ExtraBody/mergeExtraBody
│ ├── anthropic.go │ ├── registry.go # Data-driven type registry with metadata (v0.22.1)
│ ├── openai.go │ ├── profile.go # Profile schemas per provider type (v0.22.1)
│ ├── hooks.go # PreRequest/PostStreamEvent transforms (v0.22.1)
│ ├── hooks_test.go # 17 tests: all hooks, schemas, merge, nil safety
│ ├── anthropic.go # Anthropic Messages API (+ extended thinking)
│ ├── openai.go # OpenAI-compatible (+ ExtraBody merge)
│ ├── openrouter.go │ ├── openrouter.go
│ └── venice.go │ └── venice.go
├── middleware/ # Auth, admin, CORS, rate limiting ├── middleware/ # Auth, admin, CORS, rate limiting

View File

@@ -97,7 +97,7 @@ v0.21.7 Bugfix: scanJSON driver buffer aliasing ✅
v0.22.0 Provider Health + Capability Overrides ✅ v0.22.0 Provider Health + Capability Overrides ✅
+ Workspace Pane Refactor (FE) + Workspace Pane Refactor (FE)
v0.22.1 Provider Extensions (declarative config) v0.22.1 Provider Extensions (declarative config)
v0.22.2 Routing Policies + Fallback Chains v0.22.2 Routing Policies + Fallback Chains
@@ -905,7 +905,7 @@ Depends on: usage tracking (v0.10.0), capabilities resolver (v0.9.1).
--- ---
## v0.22.1 — Provider Extensions (Declarative Config) ## v0.22.1 — Provider Extensions (Declarative Config)
Replace hardcoded provider-specific behavior with data-driven configuration. Replace hardcoded provider-specific behavior with data-driven configuration.
Same model, different provider → different parameters. Same model, different provider → different parameters.
@@ -913,23 +913,30 @@ Same model, different provider → different parameters.
Depends on: provider health (v0.22.0). Depends on: provider health (v0.22.0).
**Provider Profiles** **Provider Profiles**
- [ ] `provider_profiles` JSONB column on `provider_configs` table (or separate table if schema cleaner) - [x] Profile schema per provider type: defines available settings, types, defaults, validation, dependency rules (`providers/profile.go`)
- [ ] Profile schema per provider type: defines available settings, types, defaults, validation - [x] Built-in profile schemas for: openai, anthropic, venice, openrouter (unknown types fall back to openai-compatible)
- [ ] Built-in profile schemas for: openai, anthropic, venice, openrouter (others inherit openai-compatible) - [x] System prompt injection: `system_prompt_prefix` setting prepended to first system message (all providers)
- [ ] System prompt injection: provider-specific preambles (e.g. Venice character system prompt) - [x] Thinking mode toggle: Anthropic `extended_thinking` + `thinking_budget` → injects `thinking.type=enabled`; Venice `enable_thinking` → disables Venice system prompt
- [ ] Thinking mode toggle: per-provider `think` parameter mapping (Venice `venice_parameters.include_venice_system_prompt`, Anthropic `thinking.type=enabled`, etc.) - [ ] _(not needed)_ `provider_profiles` JSONB column — existing `provider_configs.settings` JSONB already serves this purpose
**Request/Response Transforms** **Request/Response Transforms**
- [ ] `PreRequestHook(providerType string, profile JSONB, req *CompletionRequest)` — decorate request before dispatch - [x] `Hooks.PreRequest(cfg, req)` — decorate request before dispatch (system prompt prefix, ExtraBody injection)
- [ ] `PostResponseHook(providerType string, profile JSONB, resp *StreamEvent)` — normalize provider-specific response fields - [x] `Hooks.PostStreamEvent(cfg, event)` — normalize provider-specific response fields (Venice thinking extraction)
- [ ] Hook implementations: Venice thinking extraction, Anthropic extended thinking, OpenRouter model routing headers - [x] Hook implementations: Venice web search + thinking, Anthropic extended thinking + beta header, OpenRouter routing preferences + require_parameters, OpenAI frequency/presence penalty
- [ ] Preset-level overrides: Persona can override provider-specific settings ("always enable thinking for this Persona") - [x] Preset-level overrides: `MergePresetSettings()` merges persona overrides onto provider settings, respecting `ProviderOnly` fields
- [ ] `ProviderConfig.Settings` fully utilized: hooks read from config, not hardcoded switch statements - [x] `ProviderConfig.Settings` fully utilized: hooks read from config, not hardcoded switch statements
- [x] `CompletionRequest.ExtraBody` field + `mergeExtraBody()` for provider-specific wire fields
- [x] Anthropic extended thinking stream support: `thinking_delta` content blocks → `Reasoning` field
**Provider Type Registry** (prep for v0.22.2) **Provider Type Registry**
- [ ] `providers.Init()` reads provider type → adapter mapping from registry instead of hardcoded list - [x] `providers.Init()` uses `RegisterType()` with full metadata (name, description, default endpoint, profile schema)
- [ ] New provider types registrable via config (openai-compatible endpoint + custom profile schema) - [x] `ProviderTypeMeta` struct: ID, name, description, default_endpoint, profile_schema
- [ ] `GET /api/v1/admin/provider-types` endpoint: list available provider types with their profile schemas - [x] `GET /api/v1/admin/provider-types` endpoint: list available provider types with their profile schemas
- [ ] _(deferred → v0.22.3)_ New provider types registrable via config file (openai-compatible endpoint + custom profile schema)
**Search Provider Health** (carried from v0.22.0)
- [ ] _(deferred → v0.22.2)_ `RecordOutcome` for web_search and url_fetch tool calls
- [ ] _(deferred → v0.22.2)_ Rate limit tracking per provider config
--- ---

View File

@@ -113,6 +113,7 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
// ── Preset unwrap: preset overrides defaults, explicit request fields win ── // ── Preset unwrap: preset overrides defaults, explicit request fields win ──
var presetSystemPrompt string var presetSystemPrompt string
var personaID string // tracks active persona for KB scoping var personaID string // tracks active persona for KB scoping
var presetThinkingBudget *int // persona-level thinking budget for hook injection (v0.22.1)
if req.PresetID != "" { if req.PresetID != "" {
preset := ResolvePreset(h.stores, req.PresetID, userID) preset := ResolvePreset(h.stores, req.PresetID, userID)
if preset == nil { if preset == nil {
@@ -136,6 +137,7 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
if preset.SystemPrompt != "" { if preset.SystemPrompt != "" {
presetSystemPrompt = preset.SystemPrompt presetSystemPrompt = preset.SystemPrompt
} }
presetThinkingBudget = preset.ThinkingBudget
} }
// ── Project persona fallback (v0.19.2): if no explicit preset, check project ── // ── Project persona fallback (v0.19.2): if no explicit preset, check project ──
@@ -161,6 +163,7 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
if preset.SystemPrompt != "" { if preset.SystemPrompt != "" {
presetSystemPrompt = preset.SystemPrompt presetSystemPrompt = preset.SystemPrompt
} }
presetThinkingBudget = preset.ThinkingBudget
} }
} }
} }
@@ -174,6 +177,17 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
return return
} }
// ── Inject persona-level thinking budget into provider settings (v0.22.1) ──
// When a persona specifies a thinking budget, promote it into provider
// settings so hooks can activate extended thinking automatically.
if presetThinkingBudget != nil && *presetThinkingBudget > 0 {
if providerCfg.Settings == nil {
providerCfg.Settings = make(map[string]interface{})
}
providerCfg.Settings["extended_thinking"] = true
providerCfg.Settings["thinking_budget"] = *presetThinkingBudget
}
// ── Team policy: require_private_providers ── // ── Team policy: require_private_providers ──
if err := enforcePrivateProviderPolicy(userID, configID); err != nil { if err := enforcePrivateProviderPolicy(userID, configID); err != nil {
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
@@ -284,9 +298,9 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
} }
if stream { if stream {
h.streamCompletion(c, provider, providerCfg, provReq, channelID, userID, model, configID, providerScope, personaID, workspaceID) h.streamCompletion(c, provider, providerCfg, provReq, channelID, userID, model, providerID, configID, providerScope, personaID, workspaceID)
} else { } else {
h.syncCompletion(c, provider, providerCfg, provReq, channelID, userID, model, configID, providerScope, personaID, workspaceID) h.syncCompletion(c, provider, providerCfg, provReq, channelID, userID, model, providerID, configID, providerScope, personaID, workspaceID)
} }
} }
@@ -385,8 +399,13 @@ func (h *CompletionHandler) multiModelStream(
provReq.Tools = h.buildToolDefs(c.Request.Context(), userID, hasBrowserTools, req.DisabledTools, workspaceID) provReq.Tools = h.buildToolDefs(c.Request.Context(), userID, hasBrowserTools, req.DisabledTools, workspaceID)
} }
// Apply provider-specific request hooks (v0.22.1)
if hooks := providers.GetHooks(providerID); hooks != nil {
hooks.PreRequest(providerCfg, &provReq)
}
// Stream this model's response using the shared streaming core // Stream this model's response using the shared streaming core
result := streamModelResponse(c, provider, providerCfg, &provReq, model, target.DisplayName, userID, channelID, personaID, workspaceID, configID, h.hub, h.health) result := streamModelResponse(c, provider, providerCfg, &provReq, model, providerID, target.DisplayName, userID, channelID, personaID, workspaceID, configID, h.hub, h.health)
// Persist assistant message with model attribution // Persist assistant message with model attribution
if result.Content != "" { if result.Content != "" {
@@ -549,9 +568,14 @@ func (h *CompletionHandler) streamCompletion(
provider providers.Provider, provider providers.Provider,
cfg providers.ProviderConfig, cfg providers.ProviderConfig,
req providers.CompletionRequest, req providers.CompletionRequest,
channelID, userID, model, configID, providerScope, personaID, workspaceID string, channelID, userID, model, providerID, configID, providerScope, personaID, workspaceID string,
) { ) {
result := streamWithToolLoop(c, provider, cfg, &req, model, userID, channelID, personaID, workspaceID, configID, h.hub, h.health) // Apply provider-specific request hooks (v0.22.1)
if hooks := providers.GetHooks(providerID); hooks != nil {
hooks.PreRequest(cfg, &req)
}
result := streamWithToolLoop(c, provider, cfg, &req, model, providerID, userID, channelID, personaID, workspaceID, configID, h.hub, h.health)
// Persist assistant response // Persist assistant response
if result.Content != "" { if result.Content != "" {
@@ -573,8 +597,13 @@ func (h *CompletionHandler) syncCompletion(
provider providers.Provider, provider providers.Provider,
cfg providers.ProviderConfig, cfg providers.ProviderConfig,
req providers.CompletionRequest, req providers.CompletionRequest,
channelID, userID, model, configID, providerScope, personaID, workspaceID string, channelID, userID, model, providerID, configID, providerScope, personaID, workspaceID string,
) { ) {
// Apply provider-specific request hooks (v0.22.1)
if hooks := providers.GetHooks(providerID); hooks != nil {
hooks.PreRequest(cfg, &req)
}
var totalInput, totalOutput int var totalInput, totalOutput int
var totalCacheCreation, totalCacheRead int var totalCacheCreation, totalCacheRead int
var finalContent string var finalContent string

View File

@@ -9,6 +9,7 @@ import (
capspkg "git.gobha.me/xcaliber/chat-switchboard/capabilities" capspkg "git.gobha.me/xcaliber/chat-switchboard/capabilities"
"git.gobha.me/xcaliber/chat-switchboard/health" "git.gobha.me/xcaliber/chat-switchboard/health"
"git.gobha.me/xcaliber/chat-switchboard/models" "git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/providers"
"git.gobha.me/xcaliber/chat-switchboard/store" "git.gobha.me/xcaliber/chat-switchboard/store"
) )
@@ -278,3 +279,13 @@ func buildSourceAnnotations(catalog *models.ModelCapabilities, heuristic *models
} }
return sources return sources
} }
// ── Provider Types Endpoint ─────────────────
// GetProviderTypes returns metadata and profile schemas for all registered
// provider types. Used by the admin UI to render provider creation forms
// and show available settings.
func GetProviderTypes(c *gin.Context) {
types := providers.ListTypes()
c.JSON(http.StatusOK, gin.H{"data": types})
}

View File

@@ -516,7 +516,12 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
// ── Stream the response (shared loop handles tools, reasoning, SSE) ── // ── Stream the response (shared loop handles tools, reasoning, SSE) ──
result := streamWithToolLoop(c, provider, providerCfg, &provReq, model, userID, channelID, personaID, workspaceID, configID, h.hub, comp.health) // Apply provider-specific request hooks (v0.22.1)
if hooks := providers.GetHooks(providerID); hooks != nil {
hooks.PreRequest(providerCfg, &provReq)
}
result := streamWithToolLoop(c, provider, providerCfg, &provReq, model, providerID, userID, channelID, personaID, workspaceID, configID, h.hub, comp.health)
// Persist as sibling (regen) with tool activity // Persist as sibling (regen) with tool activity
if result.Content != "" { if result.Content != "" {

View File

@@ -57,7 +57,7 @@ func streamWithToolLoop(
provider providers.Provider, provider providers.Provider,
cfg providers.ProviderConfig, cfg providers.ProviderConfig,
req *providers.CompletionRequest, req *providers.CompletionRequest,
model, userID, channelID, personaID, workspaceID, configID string, model, providerType, userID, channelID, personaID, workspaceID, configID string,
hub *events.Hub, hub *events.Hub,
health HealthRecorder, health HealthRecorder,
) streamResult { ) streamResult {
@@ -102,6 +102,11 @@ func streamWithToolLoop(
var toolCalls []providers.ToolCall var toolCalls []providers.ToolCall
for event := range ch { for event := range ch {
// Apply provider-specific post-processing hooks (v0.22.1)
if hooks := providers.GetHooks(providerType); hooks != nil {
hooks.PostStreamEvent(cfg, &event)
}
if event.Error != nil { if event.Error != nil {
recordHealthFn(health, configID, callStart, event.Error) recordHealthFn(health, configID, callStart, event.Error)
sendSSE(fmt.Sprintf(`{"error":"%s"}`, escapeJSON(event.Error.Error()))) sendSSE(fmt.Sprintf(`{"error":"%s"}`, escapeJSON(event.Error.Error())))
@@ -287,7 +292,7 @@ func streamModelResponse(
provider providers.Provider, provider providers.Provider,
cfg providers.ProviderConfig, cfg providers.ProviderConfig,
req *providers.CompletionRequest, req *providers.CompletionRequest,
model, displayName, userID, channelID, personaID, workspaceID, configID string, model, providerType, displayName, userID, channelID, personaID, workspaceID, configID string,
hub *events.Hub, hub *events.Hub,
health HealthRecorder, health HealthRecorder,
) streamResult { ) streamResult {
@@ -322,6 +327,11 @@ func streamModelResponse(
var toolCalls []providers.ToolCall var toolCalls []providers.ToolCall
for event := range ch { for event := range ch {
// Apply provider-specific post-processing hooks (v0.22.1)
if hooks := providers.GetHooks(providerType); hooks != nil {
hooks.PostStreamEvent(cfg, &event)
}
if event.Error != nil { if event.Error != nil {
recordHealthFn(health, configID, callStart, event.Error) recordHealthFn(health, configID, callStart, event.Error)
sendSSE(fmt.Sprintf(`{"error":"%s"}`, escapeJSON(event.Error.Error()))) sendSSE(fmt.Sprintf(`{"error":"%s"}`, escapeJSON(event.Error.Error())))

View File

@@ -760,6 +760,9 @@ func main() {
admin.PUT("/models/:id/capabilities", capAdm.SetModelCapability) admin.PUT("/models/:id/capabilities", capAdm.SetModelCapability)
admin.DELETE("/models/:id/capabilities/:overrideId", capAdm.DeleteModelCapability) admin.DELETE("/models/:id/capabilities/:overrideId", capAdm.DeleteModelCapability)
admin.GET("/capability-overrides", capAdm.ListAllOverrides) admin.GET("/capability-overrides", capAdm.ListAllOverrides)
// Provider Types (admin — v0.22.1)
admin.GET("/provider-types", handlers.GetProviderTypes)
} }
} }

View File

@@ -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": case "content_block_delta":
if event.Delta.Type == "text_delta" { if event.Delta.Type == "text_delta" {
ch <- StreamEvent{Delta: event.Delta.Text} 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 { } else if event.Delta.Type == "input_json_delta" && currentToolIdx >= 0 {
toolCalls[currentToolIdx].Function.Arguments += event.Delta.PartialJSON 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) 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)) httpReq, err := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewReader(body))
if err != nil { if err != nil {
return nil, err 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("x-api-key", cfg.APIKey)
httpReq.Header.Set("anthropic-version", anthropicAPIVersion) 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) resp, err := http.DefaultClient.Do(httpReq)
if err != nil { if err != nil {
return nil, fmt.Errorf("provider request: %w", err) return nil, fmt.Errorf("provider request: %w", err)
@@ -458,6 +479,7 @@ type anthropicStreamEvent struct {
Delta struct { Delta struct {
Type string `json:"type"` Type string `json:"type"`
Text string `json:"text"` Text string `json:"text"`
Thinking string `json:"thinking"` // extended thinking delta (v0.22.1)
StopReason string `json:"stop_reason"` StopReason string `json:"stop_reason"`
PartialJSON string `json:"partial_json"` PartialJSON string `json:"partial_json"`
} `json:"delta"` } `json:"delta"`

296
server/providers/hooks.go Normal file
View 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
}

View 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")
}
}

View File

@@ -432,6 +432,14 @@ func (p *OpenAIProvider) doRequest(ctx context.Context, cfg ProviderConfig, req
return nil, fmt.Errorf("marshal request: %w", err) 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)) httpReq, err := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewReader(body))
if err != nil { if err != nil {
return nil, err return nil, err

154
server/providers/profile.go Normal file
View 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
}

View File

@@ -117,6 +117,7 @@ type CompletionRequest struct {
TopP *float64 `json:"top_p,omitempty"` TopP *float64 `json:"top_p,omitempty"`
Stream bool `json:"stream,omitempty"` Stream bool `json:"stream,omitempty"`
Tools []ToolDef `json:"tools,omitempty"` // available tools for function calling 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. // CompletionResponse is the normalized non-streaming response.
@@ -170,3 +171,17 @@ type Model struct {
Capabilities models.ModelCapabilities `json:"capabilities"` Capabilities models.ModelCapabilities `json:"capabilities"`
Pricing *models.ModelPricing `json:"pricing,omitempty"` 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)
}

View File

@@ -40,10 +40,70 @@ func List() []string {
return ids 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. // Init registers all built-in providers.
func Init() { func Init() {
Register(&OpenAIProvider{}) RegisterType(ProviderTypeMeta{
Register(&AnthropicProvider{}) ID: "openai",
Register(&VeniceProvider{}) Name: "OpenAI",
Register(&OpenRouterProvider{}) 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{})
} }