3-lineage scrum (Opus 4.7 / Kimi K2.6 / Qwen3-coder) on today's wave landed 4 real findings (2 BLOCK + 2 WARN) and 2 INFO touch-ups. Verbatim verdicts + disposition table at: reports/scrum/_evidence/2026-04-30/ B-1 (BLOCK Opus + INFO Kimi convergent) — ResolveKey API: collapse from 3-arg (envVar, envFileName, envFilePath) to 2-arg (envVar, envFilePath). Pre-fix every chatd caller passed the env var name twice; if operator renamed *_key_env in lakehouse.toml while keeping the canonical KEY= line in the .env file, fallback silently missed. B-2 (WARN Opus + WARN Kimi convergent) — handleProviders probe: drop the synthesize-then-Resolve probe; look up by name directly via Registry.Available(name). Prior probe synthesized "<name>/probe" model strings and routed through Resolve, fragile to any future routing rule (e.g. cloud-suffix special case). B-3 (BLOCK Opus single — verified by trace + end-to-end probe) — OllamaCloud.Chat StripPrefix used "cloud" but registry routes "ollama_cloud/<m>". Result: upstream got the prefixed model name and 400'd. Smoke missed it because chatd_smoke runs without ollama_cloud registered. Now strips the right prefix; new TestOllamaCloud_StripsCorrectPrefix locks both prefix + suffix cases. Verified live: ollama_cloud/deepseek-v3.2 round-trips cleanly through the real ollama.com endpoint. B-4 (WARN Opus single) — Ollama finishReason: read done_reason field instead of inferring from done bool alone. Newer Ollama reports done=true with done_reason="length" on truncation; the prior code mapped that to "stop" and lost the truncation signal the playbook_lift judge needs to retry. New TestFinishReasonFromOllama_PrefersDoneReason covers the fallback ladder. INFOs: - B-5: replace hand-rolled insertion sort in Registry.Names with sort.Strings (Opus called the "avoid sort import" comment a false economy — correct). - A-1: clarify the playbook_lift.sh comment around -judge "" arg passing (Opus noted the comment said "env priority" but didn't reflect that the empty arg also passes through the Go driver's resolution chain). False positives dismissed (3, documented in disposition.md): - Kimi: TestMaybeDowngrade_WithConfigList wrong assertion (test IS correct per design — model excluded from weak list = strong = downgrade) - Qwen: nil-deref claim (defensive code already handles nil) - Opus: qwen3.5:latest doesn't exist on Ollama hub (true on the public hub but local install has it) just verify: PASS. chatd_smoke 6/6 PASS. New regression tests: 3 (B-2, B-3, B-4 each get a focused test). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
154 lines
5.1 KiB
Go
154 lines
5.1 KiB
Go
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 "<provider>/" 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
|
|
}
|