mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-11 03:13:24 +00:00
* refactor(codex-pool): remove redundant primary_first strategy * test(tools): update tool schema fixtures to pointer form * feat(create_image): route Codex pools through chain with member failover Codex pool chain entries now iterate pool members per the pool's own strategy (round_robin or priority_order) and fail over internally before the outer chain advances to the next entry. - ChatGPTOAuthRouter.GenerateImage implements NativeImageProvider: iterates orderedProviders, tries each member, advances round-robin state only on success, aggregates errors on pool exhaustion. - media_provider_chain.wrapPoolProvider wraps a *CodexProvider in a router when RoutingDefaults has extras or a non-primary-first strategy. Solo Codex (no extras) stays unwrapped. - Router exposes ProviderType() so chain telemetry records "chatgpt_oauth". Closes #1008 * feat(ui/builtin-tools): pool badge on create_image chain entries Chain entry card shows a read-only "Pool · <strategy>" badge when the entry's provider is a Codex pool base. Strategy label translates in en/vi/zh. No new toggle or selector — pool config lives on the provider itself. #1008 * refactor(providers): move Codex test helpers to providertest subpackage Addresses code-review High finding: NewTestCodexProviderFast and its staticTestTokenSource were exported from a non-_test.go file, compiling into the production binary. Relocating to internal/providers/providertest/ keeps the helper importable from other packages' tests without leaking test-only symbols into production. Also tightens wrapPoolProvider — pools with zero extra members no longer get wrapped in a router (KISS: nothing to rotate between). - Added CodexProvider.WithRetryConfig as a legitimate fluent option (the test helper now uses the public API). - Dropped the internal-package test helper file. #1008 * docs(pr-1006): add pool badge UI evidence Force-UI style capture of the read-only "Pool · Round-robin" badge on the create_image chain entry card when the selected provider carries settings.codex_pool with extras. Captured against staging gateway. * refactor(ui/builtin-tools): unify pool UX with Create Agent pattern The create_image chain Provider dropdown was listing pool members alongside pool owners, letting users accidentally bypass pool semantics by picking a member directly. Matches the pattern already used by Create Agent: hide pool members, show an inline "Pool" chip on owners. - Filter pool members from the chain Provider dropdown via getChatGPTOAuthPoolOwnership.ownerByMember. - Inline "Pool" chip on owner options, reusing the existing providers:list.poolBadge i18n key. - Drop the separate card-level "Pool · <strategy>" badge — the dropdown chip alone conveys the information without duplication. - Remove the now-unused isPoolProvider / poolStrategyOf helpers and the builtin.mediaChain.poolBadge* i18n keys we briefly introduced. #1008 * docs(pr-1006): add pool-filtered dropdown screenshot * fix(ui/builtin-tools): migrate stale pool-member chain entries on load Red-team blocker: a chain saved before the pool-aware UI landed may reference a pool member by name (e.g. openai-codex-2). After the dropdown started hiding members, such an entry produced: - empty Select trigger (no SelectItem matches the stored value) - conflicting Row 1 label still showing the member name - silent runtime misroute (bare solo call, no pool wrap) parseInitialEntries now consults getChatGPTOAuthPoolOwnership and rewrites any chain entry whose provider is a pool member to the owner's name + id. The next save persists the migrated value. Safe no-op for non-pool entries and for entries already pointing at an owner. Also memoize enabledProviders in the parent form so the card's useMemo boundaries actually hold (minor perf ding flagged by same review). * docs(pr-1006): refresh HTML evidence to match filter-based UX * fix(ui/agent-codex-pool): traffic policy reflects effective strategy under inherit On the agent's OpenAI Account Pool page, when Agent routing mode is Use Provider Defaults, the Traffic Policy buttons painted the draft's placeholder strategy ("priority_order") as selected — contradicting the top-of-page chip which already correctly shows the provider's effective strategy. The removal of primary_first in this PR unmasked the latent bug: the placeholder used to be a deprecated value that didn't match any live button, so nothing appeared selected. Derive selectedStrategy from defaultRouting when mode === "inherit": the button highlight now mirrors what actually runs. Buttons remain disabled in inherit mode (unchanged), but the displayed selection no longer misleads the user. * docs(pr-1006): add screenshot of inherit-mode Traffic Policy fix * fix(permissions): remove duplicate MethodSessionsCompact entry The writeExact slice listed MethodSessionsCompact twice. slices.Contains still returned correct results so runtime behavior is unchanged, but the duplicate entry was dead code. Spotted during review of PR #1006. --------- Co-authored-by: viettranx <edu@200lab.io>
608 lines
22 KiB
Go
608 lines
22 KiB
Go
package http
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
|
"github.com/nextlevelbuilder/goclaw/internal/config"
|
|
"github.com/nextlevelbuilder/goclaw/internal/i18n"
|
|
"github.com/nextlevelbuilder/goclaw/internal/oauth"
|
|
"github.com/nextlevelbuilder/goclaw/internal/permissions"
|
|
"github.com/nextlevelbuilder/goclaw/internal/providers"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
|
|
)
|
|
|
|
// ProvidersHandler handles LLM provider CRUD endpoints.
|
|
type ProvidersHandler struct {
|
|
store store.ProviderStore
|
|
secretStore store.ConfigSecretsStore
|
|
providerReg *providers.Registry
|
|
gatewayAddr string // for injecting MCP bridge into Claude CLI providers
|
|
mcpLookup providers.MCPServerLookup // optional: resolves per-agent MCP servers
|
|
apiBaseFallback func(providerType string) string // optional: config/env fallback for api_base
|
|
cliMu sync.Mutex // serializes Claude CLI provider create to prevent duplicates
|
|
msgBus *bus.MessageBus
|
|
sysConfigStore store.SystemConfigStore
|
|
tracingStore store.TracingStore // optional: for provider-scoped pool activity
|
|
agents store.AgentCRUDStore // optional: for provider pool activity agent lookup
|
|
modelReg providers.ModelRegistry // optional: forward-compat model resolver for Anthropic
|
|
}
|
|
|
|
// NewProvidersHandler creates a handler for provider management endpoints.
|
|
func NewProvidersHandler(s store.ProviderStore, secretStore store.ConfigSecretsStore, providerReg *providers.Registry, gatewayAddr string) *ProvidersHandler {
|
|
return &ProvidersHandler{store: s, secretStore: secretStore, providerReg: providerReg, gatewayAddr: gatewayAddr}
|
|
}
|
|
|
|
// SetMessageBus sets the message bus for audit event broadcasting.
|
|
// Must be called before serving requests (not thread-safe).
|
|
func (h *ProvidersHandler) SetMessageBus(msgBus *bus.MessageBus) {
|
|
h.msgBus = msgBus
|
|
}
|
|
|
|
// SetSystemConfigStore sets the system config store for embedding status checks.
|
|
func (h *ProvidersHandler) SetSystemConfigStore(s store.SystemConfigStore) {
|
|
h.sysConfigStore = s
|
|
}
|
|
|
|
// SetMCPServerLookup sets the per-agent MCP server lookup for Claude CLI providers.
|
|
// Must be called before serving requests (not thread-safe).
|
|
func (h *ProvidersHandler) SetMCPServerLookup(lookup providers.MCPServerLookup) {
|
|
h.mcpLookup = lookup
|
|
}
|
|
|
|
// SetAPIBaseFallback sets a function that returns config/env api_base by provider type.
|
|
// Used as fallback when DB providers have no api_base set.
|
|
func (h *ProvidersHandler) SetAPIBaseFallback(fn func(providerType string) string) {
|
|
h.apiBaseFallback = fn
|
|
}
|
|
|
|
// SetTracingStore sets the tracing store for provider-scoped pool activity.
|
|
func (h *ProvidersHandler) SetTracingStore(ts store.TracingStore) {
|
|
h.tracingStore = ts
|
|
}
|
|
|
|
// SetAgentStore sets the agent store for provider pool activity agent lookup.
|
|
func (h *ProvidersHandler) SetAgentStore(as store.AgentCRUDStore) {
|
|
h.agents = as
|
|
}
|
|
|
|
// SetModelRegistry sets the forward-compat model registry used by Anthropic providers
|
|
// for model alias resolution and token counting. Must be called before serving requests.
|
|
func (h *ProvidersHandler) SetModelRegistry(r providers.ModelRegistry) {
|
|
h.modelReg = r
|
|
}
|
|
|
|
// resolveAPIBase returns the provider's api_base, falling back to config/env if empty.
|
|
// For Ollama/OllamaCloud providers, applies a safety-net normalization: if the stored
|
|
// value is missing the /v1 suffix (pre-existing record before write-time normalization),
|
|
// the suffix is appended so all downstream call sites receive a ready-to-use URL.
|
|
func (h *ProvidersHandler) resolveAPIBase(p *store.LLMProviderData) string {
|
|
base := ""
|
|
if p.APIBase != "" {
|
|
base = p.APIBase
|
|
} else if h.apiBaseFallback != nil {
|
|
base = h.apiBaseFallback(p.ProviderType)
|
|
}
|
|
// Safety net: normalize Ollama URLs missing /v1 (pre-existing DB records).
|
|
if base != "" && (p.ProviderType == store.ProviderOllama || p.ProviderType == store.ProviderOllamaCloud) {
|
|
base = strings.TrimRight(base, "/")
|
|
if !strings.HasSuffix(base, "/v1") {
|
|
base += "/v1"
|
|
}
|
|
}
|
|
return base
|
|
}
|
|
|
|
// emitProviderCacheInvalidate broadcasts a provider cache invalidation event.
|
|
// Subscribers (e.g. ACP re-registration in gateway_managed.go) react to reload from DB.
|
|
func (h *ProvidersHandler) emitProviderCacheInvalidate(name string) {
|
|
if h.msgBus == nil {
|
|
return
|
|
}
|
|
h.msgBus.Broadcast(bus.Event{
|
|
Name: protocol.EventCacheInvalidate,
|
|
Payload: bus.CacheInvalidatePayload{Kind: bus.CacheKindProvider, Key: name},
|
|
})
|
|
}
|
|
|
|
// RegisterRoutes registers all provider management routes on the given mux.
|
|
func (h *ProvidersHandler) RegisterRoutes(mux *http.ServeMux) {
|
|
// Provider CRUD
|
|
mux.HandleFunc("GET /v1/providers", h.auth(h.handleListProviders))
|
|
mux.HandleFunc("POST /v1/providers", h.auth(h.handleCreateProvider))
|
|
mux.HandleFunc("GET /v1/providers/{id}", h.auth(h.handleGetProvider))
|
|
mux.HandleFunc("PUT /v1/providers/{id}", h.auth(h.handleUpdateProvider))
|
|
mux.HandleFunc("DELETE /v1/providers/{id}", h.auth(h.handleDeleteProvider))
|
|
|
|
// Model listing (proxied to upstream provider API)
|
|
mux.HandleFunc("GET /v1/providers/{id}/models", h.auth(h.handleListProviderModels))
|
|
|
|
// Provider + model verification (pre-flight check)
|
|
mux.HandleFunc("POST /v1/providers/{id}/verify", h.auth(h.handleVerifyProvider))
|
|
mux.HandleFunc("POST /v1/providers/{id}/verify-embedding", h.auth(h.handleVerifyEmbedding))
|
|
|
|
// Provider-scoped Codex pool activity monitor
|
|
mux.HandleFunc("GET /v1/providers/{id}/codex-pool-activity", h.auth(h.handleProviderCodexPoolActivity))
|
|
|
|
// Embedding system status
|
|
mux.HandleFunc("GET /v1/embedding/status", h.auth(h.handleEmbeddingStatus))
|
|
|
|
// Claude CLI auth status (global — not per-provider)
|
|
mux.HandleFunc("GET /v1/providers/claude-cli/auth-status", h.auth(h.handleClaudeCLIAuthStatus))
|
|
}
|
|
|
|
func (h *ProvidersHandler) auth(next http.HandlerFunc) http.HandlerFunc {
|
|
return requireAuth(permissions.RoleAdmin, next)
|
|
}
|
|
|
|
// maskAPIKey replaces non-empty API keys with "***".
|
|
func maskAPIKey(p *store.LLMProviderData) {
|
|
if p.APIKey != "" {
|
|
p.APIKey = "***"
|
|
}
|
|
}
|
|
|
|
// registerInMemory adds (or replaces) a provider in the in-memory registry
|
|
// so it's immediately usable for verify/chat without a gateway restart.
|
|
func (h *ProvidersHandler) registerInMemory(p *store.LLMProviderData) {
|
|
if h.providerReg == nil || !p.Enabled {
|
|
return
|
|
}
|
|
// ACP agents don't need an API key — skip in-memory registration
|
|
// (ACP providers are registered via gateway_providers.go on startup or restart)
|
|
if p.ProviderType == store.ProviderACP {
|
|
return
|
|
}
|
|
// Claude CLI doesn't need an API key — register immediately
|
|
if p.ProviderType == store.ProviderClaudeCLI {
|
|
cliPath := p.APIBase // reuse APIBase field for CLI path
|
|
if cliPath == "" {
|
|
cliPath = "claude"
|
|
}
|
|
// Validate: only accept "claude" or absolute path (mirrors startup path in cmd/gateway_providers.go).
|
|
// Prevents DB-poisoning attacks where a relative path resolves against CWD.
|
|
if cliPath != "claude" && !filepath.IsAbs(cliPath) {
|
|
slog.Warn("security.claude_cli: invalid path, using default", "path", cliPath, "provider", p.Name)
|
|
cliPath = "claude"
|
|
}
|
|
if _, err := exec.LookPath(cliPath); err != nil {
|
|
slog.Warn("claude-cli: binary not found, skipping in-memory registration", "path", cliPath, "provider", p.Name, "error", err)
|
|
return
|
|
}
|
|
cliOpts := []providers.ClaudeCLIOption{
|
|
providers.WithClaudeCLIName(p.Name),
|
|
providers.WithClaudeCLISecurityHooks("", true),
|
|
}
|
|
if h.gatewayAddr != "" {
|
|
mcpData := providers.BuildCLIMCPConfigData(nil, h.gatewayAddr, pkgGatewayToken)
|
|
mcpData.AgentMCPLookup = h.mcpLookup
|
|
cliOpts = append(cliOpts, providers.WithClaudeCLIMCPConfigData(mcpData))
|
|
}
|
|
h.providerReg.RegisterForTenant(p.TenantID, providers.NewClaudeCLIProvider(cliPath, cliOpts...))
|
|
return
|
|
}
|
|
// Ollama doesn't need an API key — handle before the key guard (same as startup).
|
|
// In Docker, swap localhost → host.docker.internal so the container can reach the host.
|
|
// api_base is stored with /v1 (normalized at write time), so no suffix appending needed.
|
|
if p.ProviderType == store.ProviderOllama {
|
|
host := p.APIBase
|
|
if host == "" {
|
|
host = "http://localhost:11434/v1"
|
|
}
|
|
h.providerReg.RegisterForTenant(p.TenantID, providers.NewOpenAIProvider(p.Name, "ollama", config.DockerLocalhost(host), "llama3.3"))
|
|
return
|
|
}
|
|
if p.APIKey == "" {
|
|
return
|
|
}
|
|
apiBase := h.resolveAPIBase(p)
|
|
switch p.ProviderType {
|
|
case store.ProviderChatGPTOAuth:
|
|
ts := oauth.NewDBTokenSource(h.store, h.secretStore, p.Name).WithTenantID(p.TenantID)
|
|
codex := providers.NewCodexProvider(p.Name, ts, apiBase, "")
|
|
if oauthSettings := store.ParseChatGPTOAuthProviderSettings(p.Settings); oauthSettings != nil {
|
|
codex.WithRoutingDefaults(oauthSettings.CodexPool.Strategy, oauthSettings.CodexPool.ExtraProviderNames)
|
|
}
|
|
h.providerReg.RegisterForTenant(p.TenantID, codex)
|
|
case store.ProviderAnthropicNative:
|
|
anthOpts := []providers.AnthropicOption{
|
|
providers.WithAnthropicName(p.Name),
|
|
providers.WithAnthropicBaseURL(apiBase),
|
|
}
|
|
if h.modelReg != nil {
|
|
anthOpts = append(anthOpts, providers.WithAnthropicRegistry(h.modelReg))
|
|
}
|
|
h.providerReg.RegisterForTenant(p.TenantID, providers.NewAnthropicProvider(p.APIKey, anthOpts...))
|
|
case store.ProviderDashScope:
|
|
h.providerReg.RegisterForTenant(p.TenantID, providers.NewDashScopeProvider(p.Name, p.APIKey, apiBase, ""))
|
|
case store.ProviderBailian:
|
|
base := apiBase
|
|
if base == "" {
|
|
base = "https://coding-intl.dashscope.aliyuncs.com/v1"
|
|
}
|
|
h.providerReg.RegisterForTenant(p.TenantID, providers.NewOpenAIProvider(p.Name, p.APIKey, base, "qwen3.5-plus"))
|
|
case store.ProviderNovita:
|
|
base := apiBase
|
|
if base == "" {
|
|
base = store.NovitaDefaultAPIBase
|
|
}
|
|
h.providerReg.RegisterForTenant(p.TenantID, providers.NewOpenAIProvider(p.Name, p.APIKey, base, store.NovitaDefaultModel))
|
|
default:
|
|
prov := providers.NewOpenAIProvider(p.Name, p.APIKey, apiBase, "")
|
|
if p.ProviderType == store.ProviderMiniMax {
|
|
prov.WithChatPath("/text/chatcompletion_v2")
|
|
}
|
|
h.providerReg.RegisterForTenant(p.TenantID, prov)
|
|
}
|
|
}
|
|
|
|
// normalizeOllamaAPIBase ensures Ollama and OllamaCloud api_base values include the
|
|
// /v1 suffix required for OpenAI-compatible endpoints. Normalizing at write time means
|
|
// resolveAPIBase() always returns a ready-to-use base URL.
|
|
func normalizeOllamaAPIBase(p *store.LLMProviderData) {
|
|
if p.ProviderType != store.ProviderOllama && p.ProviderType != store.ProviderOllamaCloud {
|
|
return
|
|
}
|
|
if p.APIBase == "" {
|
|
return
|
|
}
|
|
p.APIBase = strings.TrimRight(p.APIBase, "/")
|
|
if !strings.HasSuffix(p.APIBase, "/v1") {
|
|
p.APIBase += "/v1"
|
|
}
|
|
}
|
|
|
|
// localProviderTypes are provider types that legitimately run on localhost
|
|
// (e.g. Ollama, Claude CLI). SSRF checks are skipped for these.
|
|
var localProviderTypes = map[string]bool{
|
|
store.ProviderOllama: true,
|
|
store.ProviderClaudeCLI: true,
|
|
store.ProviderACP: true,
|
|
}
|
|
|
|
// validateProviderURL rejects provider base URLs pointing to internal/private networks.
|
|
// Defense-in-depth: prevents SSRF when providers are later used for API calls.
|
|
func validateProviderURL(rawURL string, providerType string) error {
|
|
if rawURL == "" || localProviderTypes[providerType] {
|
|
return nil
|
|
}
|
|
u, err := url.Parse(rawURL)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid URL: %w", err)
|
|
}
|
|
// Only allow http/https schemes — block file://, gopher://, dict://, etc.
|
|
switch u.Scheme {
|
|
case "http", "https":
|
|
default:
|
|
return fmt.Errorf("provider URL must use http or https scheme, got %q", u.Scheme)
|
|
}
|
|
host := u.Hostname()
|
|
// Block obvious internal targets
|
|
blocked := []string{"localhost", "127.0.0.1", "::1", "0.0.0.0", "169.254.169.254", "metadata.google.internal"}
|
|
for _, b := range blocked {
|
|
if strings.EqualFold(host, b) {
|
|
return fmt.Errorf("provider URL cannot point to %s", b)
|
|
}
|
|
}
|
|
// Block private IP ranges (normalize IPv6-mapped IPv4 to catch ::ffff:127.0.0.1)
|
|
ip := net.ParseIP(host)
|
|
if ip != nil {
|
|
if v4 := ip.To4(); v4 != nil {
|
|
ip = v4
|
|
}
|
|
if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
|
|
return fmt.Errorf("provider URL cannot point to private network: %s", host)
|
|
}
|
|
}
|
|
// Block common internal hostnames
|
|
if strings.HasSuffix(host, ".internal") || strings.HasSuffix(host, ".local") {
|
|
return fmt.Errorf("provider URL cannot point to internal hostname: %s", host)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// --- Provider CRUD ---
|
|
|
|
func (h *ProvidersHandler) handleListProviders(w http.ResponseWriter, r *http.Request) {
|
|
providers, err := h.store.ListProviders(r.Context())
|
|
if err != nil {
|
|
slog.Error("providers.list", "error", err)
|
|
locale := extractLocale(r)
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToList, "providers")})
|
|
return
|
|
}
|
|
|
|
for i := range providers {
|
|
maskAPIKey(&providers[i])
|
|
}
|
|
|
|
publicProviders := make([]store.LLMProviderData, 0, len(providers))
|
|
for i := range providers {
|
|
publicProviders = append(publicProviders, canonicalizeProviderForResponse(&providers[i]))
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"providers": publicProviders})
|
|
}
|
|
|
|
func (h *ProvidersHandler) handleCreateProvider(w http.ResponseWriter, r *http.Request) {
|
|
locale := extractLocale(r)
|
|
var p store.LLMProviderData
|
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&p); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
|
|
return
|
|
}
|
|
|
|
if p.Name == "" {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "name")})
|
|
return
|
|
}
|
|
if !isValidSlug(p.Name) {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidSlug, "name")})
|
|
return
|
|
}
|
|
if !store.ValidProviderTypes[p.ProviderType] {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidRequest, "unsupported provider_type")})
|
|
return
|
|
}
|
|
|
|
// Only one Claude CLI provider is allowed per instance (1 machine = 1 auth session).
|
|
// Mutex serializes check+create to prevent TOCTOU race.
|
|
if p.ProviderType == store.ProviderClaudeCLI {
|
|
h.cliMu.Lock()
|
|
defer h.cliMu.Unlock()
|
|
|
|
existing, _ := h.store.ListProviders(r.Context())
|
|
for _, ep := range existing {
|
|
if ep.ProviderType == store.ProviderClaudeCLI {
|
|
writeJSON(w, http.StatusConflict, map[string]string{
|
|
"error": i18n.T(locale, i18n.MsgAlreadyExists, "Claude CLI provider", "only one is allowed per instance"),
|
|
})
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
if err := validateChatGPTOAuthProviderCandidate(r.Context(), h.store, uuid.Nil, &p); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
if err := validateProviderEmbeddingSettings(&p); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidRequest, err.Error())})
|
|
return
|
|
}
|
|
|
|
if err := validateProviderURL(p.APIBase, p.ProviderType); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Normalize Ollama base URL to include /v1 so all code paths
|
|
// (chat, model listing, embedding verify) use the same value from DB.
|
|
normalizeOllamaAPIBase(&p)
|
|
|
|
if err := h.store.CreateProvider(r.Context(), &p); err != nil {
|
|
slog.Error("providers.create", "error", err)
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Register in-memory so verify/chat work without restart
|
|
h.registerInMemory(&p)
|
|
h.emitProviderCacheInvalidate(p.Name)
|
|
|
|
emitAudit(h.msgBus, r, "provider.created", "provider", p.ID.String())
|
|
maskAPIKey(&p)
|
|
publicProvider := canonicalizeProviderForResponse(&p)
|
|
writeJSON(w, http.StatusCreated, publicProvider)
|
|
}
|
|
|
|
func (h *ProvidersHandler) handleGetProvider(w http.ResponseWriter, r *http.Request) {
|
|
locale := extractLocale(r)
|
|
id, err := uuid.Parse(r.PathValue("id"))
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "provider")})
|
|
return
|
|
}
|
|
|
|
p, err := h.store.GetProvider(r.Context(), id)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "provider", id.String())})
|
|
return
|
|
}
|
|
|
|
maskAPIKey(p)
|
|
publicProvider := canonicalizeProviderForResponse(p)
|
|
writeJSON(w, http.StatusOK, publicProvider)
|
|
}
|
|
|
|
func (h *ProvidersHandler) handleUpdateProvider(w http.ResponseWriter, r *http.Request) {
|
|
locale := extractLocale(r)
|
|
id, err := uuid.Parse(r.PathValue("id"))
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "provider")})
|
|
return
|
|
}
|
|
|
|
var updates map[string]any
|
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&updates); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
|
|
return
|
|
}
|
|
|
|
// Validate name if being updated
|
|
if name, ok := updates["name"]; ok {
|
|
if s, _ := name.(string); !isValidSlug(s) {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidSlug, "name")})
|
|
return
|
|
}
|
|
}
|
|
|
|
// Strip masked API key — don't overwrite real value with "***"
|
|
if apiKey, ok := updates["api_key"]; ok {
|
|
if s, _ := apiKey.(string); s == "***" || s == "" {
|
|
delete(updates, "api_key")
|
|
}
|
|
}
|
|
|
|
// Allowlist: only permit known provider columns.
|
|
updates = filterAllowedKeys(updates, providerAllowedFields)
|
|
|
|
currentProvider, err := h.store.GetProvider(r.Context(), id)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "provider", id.String())})
|
|
return
|
|
}
|
|
|
|
candidate := *currentProvider
|
|
if name, ok := updates["name"].(string); ok && name != "" {
|
|
candidate.Name = name
|
|
}
|
|
if apiKey, ok := updates["api_key"].(string); ok {
|
|
candidate.APIKey = apiKey
|
|
}
|
|
if apiBase, ok := updates["api_base"].(string); ok {
|
|
candidate.APIBase = apiBase
|
|
}
|
|
if enabled, ok := updates["enabled"].(bool); ok {
|
|
candidate.Enabled = enabled
|
|
}
|
|
if displayName, ok := updates["display_name"].(string); ok {
|
|
candidate.DisplayName = displayName
|
|
}
|
|
if pt, ok := updates["provider_type"].(string); ok && pt != "" {
|
|
candidate.ProviderType = pt
|
|
}
|
|
if settings, ok := updates["settings"]; ok {
|
|
rawSettings, err := marshalJSONRaw(settings)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
|
|
return
|
|
}
|
|
candidate.Settings = rawSettings
|
|
}
|
|
|
|
// Re-validate URLs against the (possibly new) provider type.
|
|
// When provider_type changes, existing api_base must also pass validation
|
|
// for the new type — prevents SSRF via ACP→non-ACP type switch.
|
|
typeChanged := candidate.ProviderType != currentProvider.ProviderType
|
|
|
|
if apiBase, ok := updates["api_base"]; ok {
|
|
if s, _ := apiBase.(string); s != "" {
|
|
if err := validateProviderURL(s, candidate.ProviderType); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
}
|
|
} else if typeChanged && candidate.APIBase != "" {
|
|
if err := validateProviderURL(candidate.APIBase, candidate.ProviderType); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
}
|
|
if baseURL, ok := updates["base_url"]; ok {
|
|
if s, _ := baseURL.(string); s != "" {
|
|
if err := validateProviderURL(s, candidate.ProviderType); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// Normalize Ollama base URL to include /v1 so all code paths use the same value.
|
|
normalizeOllamaAPIBase(&candidate)
|
|
if candidate.APIBase != currentProvider.APIBase {
|
|
updates["api_base"] = candidate.APIBase
|
|
}
|
|
|
|
if err := validateChatGPTOAuthProviderCandidate(r.Context(), h.store, id, &candidate); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
if err := validateProviderEmbeddingSettings(&candidate); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidRequest, err.Error())})
|
|
return
|
|
}
|
|
|
|
// Track old name before update for registry cleanup
|
|
var oldName string
|
|
if h.providerReg != nil {
|
|
oldName = currentProvider.Name
|
|
}
|
|
|
|
if err := h.store.UpdateProvider(r.Context(), id, updates); err != nil {
|
|
slog.Error("providers.update", "error", err)
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Sync in-memory registry with updated provider
|
|
if h.providerReg != nil {
|
|
if updated, err := h.store.GetProvider(r.Context(), id); err == nil {
|
|
// Unregister old name if renamed to prevent ghost entries
|
|
if oldName != "" && oldName != updated.Name {
|
|
h.providerReg.UnregisterForTenant(updated.TenantID, oldName)
|
|
}
|
|
if !updated.Enabled {
|
|
h.providerReg.UnregisterForTenant(updated.TenantID, updated.Name)
|
|
} else {
|
|
h.registerInMemory(updated)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Notify subscribers (e.g. ACP re-registration) about the change
|
|
if updated, err := h.store.GetProvider(r.Context(), id); err == nil {
|
|
h.emitProviderCacheInvalidate(updated.Name)
|
|
if oldName != "" && oldName != updated.Name {
|
|
h.emitProviderCacheInvalidate(oldName)
|
|
}
|
|
}
|
|
|
|
emitAudit(h.msgBus, r, "provider.updated", "provider", id.String())
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "updated"})
|
|
}
|
|
|
|
func (h *ProvidersHandler) handleDeleteProvider(w http.ResponseWriter, r *http.Request) {
|
|
locale := extractLocale(r)
|
|
id, err := uuid.Parse(r.PathValue("id"))
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "provider")})
|
|
return
|
|
}
|
|
|
|
// Read provider before deleting so we can unregister it
|
|
var providerName string
|
|
var providerTenantID uuid.UUID
|
|
if p, err := h.store.GetProvider(r.Context(), id); err == nil {
|
|
providerName = p.Name
|
|
providerTenantID = p.TenantID
|
|
}
|
|
|
|
if err := h.store.DeleteProvider(r.Context(), id); err != nil {
|
|
slog.Error("providers.delete", "error", err)
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
if h.providerReg != nil && providerName != "" {
|
|
h.providerReg.UnregisterForTenant(providerTenantID, providerName)
|
|
}
|
|
if providerName != "" {
|
|
h.emitProviderCacheInvalidate(providerName)
|
|
}
|
|
|
|
emitAudit(h.msgBus, r, "provider.deleted", "provider", id.String())
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
|
}
|