diff --git a/CHANGELOG.md b/CHANGELOG.md index b6ef3d8..1e68157 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,32 @@ 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 ### Added diff --git a/VERSION b/VERSION index a7f3fc2..a04c2de 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.22.0 \ No newline at end of file +0.22.1 \ No newline at end of file diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d1a0dad..e4c904e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -186,9 +186,13 @@ server/ ├── notelinks/ # Wikilink extraction (regex → NoteLink structs) ├── knowledge/ # KB chunking, embedding, search ├── providers/ # LLM provider adapters -│ ├── provider.go # Provider interface -│ ├── anthropic.go -│ ├── openai.go +│ ├── provider.go # Provider interface + ExtraBody/mergeExtraBody +│ ├── registry.go # Data-driven type registry with metadata (v0.22.1) +│ ├── 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 │ └── venice.go ├── middleware/ # Auth, admin, CORS, rate limiting diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index b97c973..14c4d36 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -97,7 +97,7 @@ v0.21.7 Bugfix: scanJSON driver buffer aliasing ✅ v0.22.0 Provider Health + Capability Overrides ✅ + 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 │ @@ -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. Same model, different provider → different parameters. @@ -913,23 +913,30 @@ Same model, different provider → different parameters. Depends on: provider health (v0.22.0). **Provider Profiles** -- [ ] `provider_profiles` JSONB column on `provider_configs` table (or separate table if schema cleaner) -- [ ] Profile schema per provider type: defines available settings, types, defaults, validation -- [ ] Built-in profile schemas for: openai, anthropic, venice, openrouter (others inherit openai-compatible) -- [ ] System prompt injection: provider-specific preambles (e.g. Venice character system prompt) -- [ ] Thinking mode toggle: per-provider `think` parameter mapping (Venice `venice_parameters.include_venice_system_prompt`, Anthropic `thinking.type=enabled`, etc.) +- [x] Profile schema per provider type: defines available settings, types, defaults, validation, dependency rules (`providers/profile.go`) +- [x] Built-in profile schemas for: openai, anthropic, venice, openrouter (unknown types fall back to openai-compatible) +- [x] System prompt injection: `system_prompt_prefix` setting prepended to first system message (all providers) +- [x] Thinking mode toggle: Anthropic `extended_thinking` + `thinking_budget` → injects `thinking.type=enabled`; Venice `enable_thinking` → disables Venice system prompt +- [ ] _(not needed)_ `provider_profiles` JSONB column — existing `provider_configs.settings` JSONB already serves this purpose **Request/Response Transforms** -- [ ] `PreRequestHook(providerType string, profile JSONB, req *CompletionRequest)` — decorate request before dispatch -- [ ] `PostResponseHook(providerType string, profile JSONB, resp *StreamEvent)` — normalize provider-specific response fields -- [ ] Hook implementations: Venice thinking extraction, Anthropic extended thinking, OpenRouter model routing headers -- [ ] Preset-level overrides: Persona can override provider-specific settings ("always enable thinking for this Persona") -- [ ] `ProviderConfig.Settings` fully utilized: hooks read from config, not hardcoded switch statements +- [x] `Hooks.PreRequest(cfg, req)` — decorate request before dispatch (system prompt prefix, ExtraBody injection) +- [x] `Hooks.PostStreamEvent(cfg, event)` — normalize provider-specific response fields (Venice thinking extraction) +- [x] Hook implementations: Venice web search + thinking, Anthropic extended thinking + beta header, OpenRouter routing preferences + require_parameters, OpenAI frequency/presence penalty +- [x] Preset-level overrides: `MergePresetSettings()` merges persona overrides onto provider settings, respecting `ProviderOnly` fields +- [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) -- [ ] `providers.Init()` reads provider type → adapter mapping from registry instead of hardcoded list -- [ ] New provider types registrable via config (openai-compatible endpoint + custom profile schema) -- [ ] `GET /api/v1/admin/provider-types` endpoint: list available provider types with their profile schemas +**Provider Type Registry** +- [x] `providers.Init()` uses `RegisterType()` with full metadata (name, description, default endpoint, profile schema) +- [x] `ProviderTypeMeta` struct: ID, name, description, default_endpoint, profile_schema +- [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 --- diff --git a/server/handlers/completion.go b/server/handlers/completion.go index 3843055..b2e2f30 100644 --- a/server/handlers/completion.go +++ b/server/handlers/completion.go @@ -113,6 +113,7 @@ func (h *CompletionHandler) Complete(c *gin.Context) { // ── Preset unwrap: preset overrides defaults, explicit request fields win ── var presetSystemPrompt string 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 != "" { preset := ResolvePreset(h.stores, req.PresetID, userID) if preset == nil { @@ -136,6 +137,7 @@ func (h *CompletionHandler) Complete(c *gin.Context) { if preset.SystemPrompt != "" { presetSystemPrompt = preset.SystemPrompt } + presetThinkingBudget = preset.ThinkingBudget } // ── 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 != "" { presetSystemPrompt = preset.SystemPrompt } + presetThinkingBudget = preset.ThinkingBudget } } } @@ -174,6 +177,17 @@ func (h *CompletionHandler) Complete(c *gin.Context) { 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 ── if err := enforcePrivateProviderPolicy(userID, configID); err != nil { c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) @@ -284,9 +298,9 @@ func (h *CompletionHandler) Complete(c *gin.Context) { } 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 { - 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) } + // 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 - 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 if result.Content != "" { @@ -549,9 +568,14 @@ func (h *CompletionHandler) streamCompletion( provider providers.Provider, cfg providers.ProviderConfig, 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 if result.Content != "" { @@ -573,8 +597,13 @@ func (h *CompletionHandler) syncCompletion( provider providers.Provider, cfg providers.ProviderConfig, 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 totalCacheCreation, totalCacheRead int var finalContent string diff --git a/server/handlers/health_admin.go b/server/handlers/health_admin.go index 572b99c..c6e3abe 100644 --- a/server/handlers/health_admin.go +++ b/server/handlers/health_admin.go @@ -9,6 +9,7 @@ import ( capspkg "git.gobha.me/xcaliber/chat-switchboard/capabilities" "git.gobha.me/xcaliber/chat-switchboard/health" "git.gobha.me/xcaliber/chat-switchboard/models" + "git.gobha.me/xcaliber/chat-switchboard/providers" "git.gobha.me/xcaliber/chat-switchboard/store" ) @@ -278,3 +279,13 @@ func buildSourceAnnotations(catalog *models.ModelCapabilities, heuristic *models } 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}) +} diff --git a/server/handlers/messages.go b/server/handlers/messages.go index 14f30c8..91dd410 100644 --- a/server/handlers/messages.go +++ b/server/handlers/messages.go @@ -516,7 +516,12 @@ func (h *MessageHandler) Regenerate(c *gin.Context) { // ── 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 if result.Content != "" { diff --git a/server/handlers/stream_loop.go b/server/handlers/stream_loop.go index 64ae3b2..3721bbf 100644 --- a/server/handlers/stream_loop.go +++ b/server/handlers/stream_loop.go @@ -57,7 +57,7 @@ func streamWithToolLoop( provider providers.Provider, cfg providers.ProviderConfig, req *providers.CompletionRequest, - model, userID, channelID, personaID, workspaceID, configID string, + model, providerType, userID, channelID, personaID, workspaceID, configID string, hub *events.Hub, health HealthRecorder, ) streamResult { @@ -102,6 +102,11 @@ func streamWithToolLoop( var toolCalls []providers.ToolCall 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 { recordHealthFn(health, configID, callStart, event.Error) sendSSE(fmt.Sprintf(`{"error":"%s"}`, escapeJSON(event.Error.Error()))) @@ -287,7 +292,7 @@ func streamModelResponse( provider providers.Provider, cfg providers.ProviderConfig, req *providers.CompletionRequest, - model, displayName, userID, channelID, personaID, workspaceID, configID string, + model, providerType, displayName, userID, channelID, personaID, workspaceID, configID string, hub *events.Hub, health HealthRecorder, ) streamResult { @@ -322,6 +327,11 @@ func streamModelResponse( var toolCalls []providers.ToolCall 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 { recordHealthFn(health, configID, callStart, event.Error) sendSSE(fmt.Sprintf(`{"error":"%s"}`, escapeJSON(event.Error.Error()))) diff --git a/server/main.go b/server/main.go index 772130d..b98e383 100644 --- a/server/main.go +++ b/server/main.go @@ -760,6 +760,9 @@ func main() { admin.PUT("/models/:id/capabilities", capAdm.SetModelCapability) admin.DELETE("/models/:id/capabilities/:overrideId", capAdm.DeleteModelCapability) admin.GET("/capability-overrides", capAdm.ListAllOverrides) + + // Provider Types (admin — v0.22.1) + admin.GET("/provider-types", handlers.GetProviderTypes) } } diff --git a/server/providers/anthropic.go b/server/providers/anthropic.go index d56e429..a5163d5 100644 --- a/server/providers/anthropic.go +++ b/server/providers/anthropic.go @@ -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"` diff --git a/server/providers/hooks.go b/server/providers/hooks.go new file mode 100644 index 0000000..444a36b --- /dev/null +++ b/server/providers/hooks.go @@ -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 ... tags. The stream_loop already handles + // extraction from reasoning_content, so we extract Venice + // thinking from Delta → Reasoning when enable_thinking is on. + if cfg.Settings == nil || !settingBool(cfg.Settings, "enable_thinking") { + return + } + + // Venice sends thinking content inline in Delta. The frontend + // expects it in the Reasoning field. We detect blocks + // and move them. + if event.Delta != "" && strings.Contains(event.Delta, "") { + // Full block in one chunk (rare but handle it) + event.Reasoning += event.Delta + event.Delta = "" + } +} + +// ── OpenRouter Hooks ──────────────────────── + +type OpenRouterHooks struct{} + +func (h *OpenRouterHooks) PreRequest(cfg ProviderConfig, req *CompletionRequest) { + s := cfg.Settings + if s == nil { + return + } + + prependSystemPrompt(settingString(s, "system_prompt_prefix"), req) + + // OpenRouter route/transforms go in provider preferences header. + // These are sent as JSON in the X-Provider-Preferences header by + // the OpenAI doRequest (reads from ExtraBody["provider_preferences"]). + route := settingString(s, "route") + requireParams := settingBool(s, "require_parameters") + + if route != "" || requireParams { + prefs := map[string]interface{}{} + if route != "" && route != "auto" { + prefs["route"] = route + } + if requireParams { + prefs["require_parameters"] = true + } + // Serialize for the HTTP header (OpenRouter reads from extra headers) + prefsJSON, _ := json.Marshal(prefs) + if cfg.CustomHeaders == nil { + cfg.CustomHeaders = make(map[string]string) + } + cfg.CustomHeaders["X-Provider-Preferences"] = string(prefsJSON) + } +} + +func (h *OpenRouterHooks) PostStreamEvent(_ ProviderConfig, _ *StreamEvent) { + // No post-processing needed. OpenRouter returns standard OAI format. +} + +// ── Preset Override Merge ─────────────────── + +// MergePresetSettings merges persona-level setting overrides onto +// provider-level settings. Preset values take priority except for +// fields marked ProviderOnly in the schema. +func MergePresetSettings(providerType string, providerSettings, presetOverrides map[string]interface{}) map[string]interface{} { + if len(presetOverrides) == 0 { + return providerSettings + } + + schema := GetProfileSchema(providerType) + providerOnly := make(map[string]bool, len(schema.Fields)) + for _, f := range schema.Fields { + if f.ProviderOnly { + providerOnly[f.Key] = true + } + } + + merged := make(map[string]interface{}, len(providerSettings)+len(presetOverrides)) + for k, v := range providerSettings { + merged[k] = v + } + for k, v := range presetOverrides { + if !providerOnly[k] { + merged[k] = v + } + } + return merged +} diff --git a/server/providers/hooks_test.go b/server/providers/hooks_test.go new file mode 100644 index 0000000..f8b74a5 --- /dev/null +++ b/server/providers/hooks_test.go @@ -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") + } +} diff --git a/server/providers/openai.go b/server/providers/openai.go index 9d19ed9..a559f0e 100644 --- a/server/providers/openai.go +++ b/server/providers/openai.go @@ -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 diff --git a/server/providers/profile.go b/server/providers/profile.go new file mode 100644 index 0000000..ece3603 --- /dev/null +++ b/server/providers/profile.go @@ -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 +} diff --git a/server/providers/provider.go b/server/providers/provider.go index a8c5a3a..528ce58 100644 --- a/server/providers/provider.go +++ b/server/providers/provider.go @@ -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) +} diff --git a/server/providers/registry.go b/server/providers/registry.go index 9818e06..5945f3e 100644 --- a/server/providers/registry.go +++ b/server/providers/registry.go @@ -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{}) }