package chat import ( "context" "fmt" "sort" "strings" "time" ) // Registry resolves a model name to its Provider. Lookup is by the // first slash-delimited prefix; bare names (no slash) fall through to // the configured default provider — typically `ollama` so local // model names like `qwen3.5:latest` work without a prefix. // // Mirrors the Rust gateway's resolve_provider() pattern from // crates/gateway/src/v1/mod.rs. type Registry struct { providers map[string]Provider // name → provider defaultName string // resolved when no prefix matches } // NewRegistry builds a registry from a list of providers. The first // "ollama" provider becomes the bare-name default; callers can // override via SetDefault. func NewRegistry(providers ...Provider) *Registry { r := &Registry{providers: make(map[string]Provider, len(providers))} for _, p := range providers { r.providers[p.Name()] = p if r.defaultName == "" && p.Name() == "ollama" { r.defaultName = "ollama" } } return r } // Register adds or replaces a provider. Used after construction (e.g. // for tests injecting fakes). func (r *Registry) Register(p Provider) { r.providers[p.Name()] = p } // SetDefault sets the provider used when no prefix matches. Empty // model names always 404 — the default only kicks in for unprefixed // non-empty names. func (r *Registry) SetDefault(name string) { r.defaultName = name } // Names returns the registered provider names, sorted (deterministic // output for /v1/chat/providers listing). func (r *Registry) Names() []string { out := make([]string, 0, len(r.providers)) for n := range r.providers { out = append(out, n) } sort.Strings(out) return out } // Available reports whether the named provider is registered AND // its Available() method returns true. Returns false for unregistered // names — used by /v1/chat/providers (Phase 4 scrum fix B-2: replaces // the prior synthetic-route probe that mis-routed via Resolve). func (r *Registry) Available(name string) bool { p, ok := r.providers[name] if !ok { return false } return p.Available() } // Resolve returns the Provider for a model name. Resolution rules: // // 1. Empty model → ErrProviderNotFound // 2. Suffix ":cloud" → ollama_cloud (e.g. "kimi-k2.6:cloud") // 3. Prefix match (e.g. "openrouter/...") → that provider // 4. No prefix or unknown prefix → default provider (typically ollama) // 5. No default registered → ErrProviderNotFound // // The suffix rule mirrors the Rust gateway and the Ollama Cloud // upstream's own naming convention — kimi-k2.6:cloud, qwen3-coder:480b // (when on cloud) etc. Without it, every cloud model would need a // "cloud/" prefix in lakehouse.toml, which clashes with the Ollama // upstream that wants the bare suffix-named model. func (r *Registry) Resolve(model string) (Provider, error) { if model == "" { return nil, fmt.Errorf("%w: empty model name", ErrProviderNotFound) } // Suffix detection — `:cloud` always means Ollama Cloud. if strings.HasSuffix(model, ":cloud") { if p, ok := r.providers["ollama_cloud"]; ok { return p, nil } // :cloud suffix with no ollama_cloud provider → 404. Don't // silently fall through to local Ollama; that would burn the // model name on a provider that doesn't have it. return nil, fmt.Errorf("%w: %q has :cloud suffix but ollama_cloud provider is not registered", ErrProviderNotFound, model) } // Prefix match: "openrouter/anthropic/claude-opus-4-7" splits on // first "/". Multi-segment provider names not supported (none // shipped use them). if idx := strings.Index(model, "/"); idx > 0 { prefix := model[:idx] if p, ok := r.providers[prefix]; ok { return p, nil } // Unknown prefix — falls through to default. Lets bare model // names with slashes (e.g. "anthropic/claude-3.5") still hit // ollama if that's how the operator named local models. } if r.defaultName == "" { return nil, fmt.Errorf("%w: %q", ErrProviderNotFound, model) } p, ok := r.providers[r.defaultName] if !ok { return nil, fmt.Errorf("%w: default provider %q not registered", ErrProviderNotFound, r.defaultName) } return p, nil } // Chat is the dispatcher entry point: resolve provider, dispatch, // stamp telemetry on the response. Returns ErrProviderDisabled when // the resolved provider isn't Available() (caller should map to 503). func (r *Registry) Chat(ctx context.Context, req Request) (*Response, error) { p, err := r.Resolve(req.Model) if err != nil { return nil, err } if !p.Available() { return nil, fmt.Errorf("%w: %s", ErrProviderDisabled, p.Name()) } t0 := time.Now() resp, err := p.Chat(ctx, req) if err != nil { return nil, err } resp.LatencyMs = time.Since(t0).Milliseconds() resp.Provider = p.Name() return resp, nil } // StripPrefix removes the leading "/" from model when // present. Helpers for upstream calls — providers that need the bare // model name (e.g. OpenRouter sees "anthropic/claude-opus-4-7", not // "openrouter/anthropic/claude-opus-4-7") use this. func StripPrefix(model, prefix string) string { want := prefix + "/" if strings.HasPrefix(model, want) { return model[len(want):] } return model }