Files
go-llm/v2/parse.go
T
steve 4522310f5a
CI / Root Module (pull_request) Failing after 3s
CI / Lint (pull_request) Failing after 3s
CI / V2 Module (pull_request) Successful in 1m25s
feat(v2): add Parse() function and extensible Registry for model string resolution
Introduces llm.Parse(spec) backed by an extensible Registry that resolves
model strings like "openai/gpt-4o", aliases like "fast", and named targets
like "m5/qwen3:30b" (via LLM_M5 env var DSNs) into ready-to-use *Model
objects. Extension points: RegisterProvider, RegisterAlias, RegisterResolver.
Adds Foreman constructor and sentinel errors ErrAliasLoop, ErrUnknownProvider,
ErrInvalidDSN.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 22:58:14 -04:00

227 lines
7.3 KiB
Go

package llm
import (
"fmt"
"os"
"strings"
)
// DSN represents a parsed Data Source Name for an LLM provider endpoint.
// Format: scheme://[token@]host[/path]
//
// Why: multi-instance providers (e.g., multiple foreman daemons) need a compact
// way to encode scheme, credentials, and host in a single env var.
// What: holds the three components after parsing.
// Test: ParseDSN("foreman://tok@host") → {Scheme:"foreman", Token:"tok", Host:"host"}.
type DSN struct {
Scheme string // provider type: "foreman", "ollama", etc.
Token string // API key / bearer token; empty = none
Host string // hostname[:port][/path], no scheme prefix
}
// ParseDSN parses a raw DSN string into its components.
// Expected format: scheme://[token@]host[/path]
//
// Why: LLM_X env vars encode provider type, optional credentials, and host
// in a single string; this function decodes that.
// What: splits on "://", then optional "@" for token, remainder is host.
// Test: valid DSNs parse correctly, missing scheme or host returns ErrInvalidDSN.
func ParseDSN(raw string) (DSN, error) {
schemeEnd := strings.Index(raw, "://")
if schemeEnd < 0 {
return DSN{}, fmt.Errorf("%w: missing scheme://: %q", ErrInvalidDSN, raw)
}
scheme := raw[:schemeEnd]
rest := raw[schemeEnd+3:]
var token, host string
if atIdx := strings.Index(rest, "@"); atIdx >= 0 {
token = rest[:atIdx]
host = rest[atIdx+1:]
} else {
host = rest
}
host = strings.TrimRight(host, "/")
if host == "" {
return DSN{}, fmt.Errorf("%w: missing host: %q", ErrInvalidDSN, raw)
}
return DSN{Scheme: scheme, Token: token, Host: host}, nil
}
// splitReasoning strips a trailing ":low", ":medium", or ":high" reasoning
// suffix from a spec string. Only the last colon-delimited segment is
// considered, and only if it matches a known ReasoningLevel. This preserves
// Ollama-style tags like ":30b" or ":14b" which are not reasoning levels.
//
// Why: reasoning level is encoded as a suffix in the spec grammar, but
// colons also appear in Ollama model tags — this function disambiguates.
// What: returns the base string and the extracted level (empty if none).
// Test: "gpt-4o:high" → ("gpt-4o", high); "qwen3:30b" → ("qwen3:30b", "").
func splitReasoning(s string) (string, ReasoningLevel) {
idx := strings.LastIndex(s, ":")
if idx < 0 || idx == len(s)-1 {
return s, ""
}
suffix := ReasoningLevel(s[idx+1:])
switch suffix {
case ReasoningLow, ReasoningMedium, ReasoningHigh:
return s[:idx], suffix
}
return s, ""
}
// Parse resolves a spec string to a ready-to-use *Model using the
// DefaultRegistry. See Registry.Parse for the full grammar and resolution
// order.
//
// Why: provides a one-call entry point for resolving model strings without
// requiring callers to interact with the Registry directly.
// What: delegates to DefaultRegistry.Parse.
// Test: Parse("openai/gpt-4o") returns a non-nil *Model.
func Parse(spec string) (*Model, error) {
return DefaultRegistry.Parse(spec)
}
// Parse resolves a spec string to a ready-to-use *Model.
//
// Spec grammar:
//
// spec = alias | provider "/" model | envname "/" model
// alias = name (registered via RegisterAlias or matched by a Resolver)
// provider = registered-name (e.g., "openai", "foreman")
// envname = name (resolved via LLM_{UPPER(name)} env var containing a DSN)
// model = everything after the first "/"
//
// Any spec may carry a ":reasoning" suffix (":low", ":medium", ":high") after
// the last colon. Ollama-style tags like ":30b" are NOT consumed as reasoning.
//
// Resolution order:
// 1. Strip reasoning suffix
// 2. Check static aliases → recurse
// 3. Check dynamic resolvers → recurse
// 4. Split on first "/" → provider/model
// 5. Look up provider in registry
// 6. Look up LLM_{UPPER(left)} env var → parse DSN → create client
// 7. Return ErrUnknownProvider
//
// Why: consumers need a single function to go from a user-supplied string
// (CLI flag, config file, database row) to a ready-to-use Model.
// What: walks the resolution chain and returns the final *Model with reasoning applied.
// Test: see parse_test.go for comprehensive table-driven tests.
func (r *Registry) Parse(spec string) (*Model, error) {
m, level, err := r.parse(spec, 0)
if err != nil {
return nil, err
}
if level != "" {
m = m.WithReasoning(level)
}
return m, nil
}
// parse is the internal recursive resolver. depth is bounded to prevent
// alias loops.
func (r *Registry) parse(spec string, depth int) (*Model, ReasoningLevel, error) {
if depth > 10 {
return nil, "", ErrAliasLoop
}
// 1. Strip reasoning suffix.
base, userLevel := splitReasoning(spec)
// 2. Check static aliases.
r.mu.RLock()
target, isAlias := r.aliases[base]
r.mu.RUnlock()
if isAlias {
m, aliasLevel, err := r.parse(target, depth+1)
if err != nil {
return nil, "", err
}
if userLevel != "" {
return m, userLevel, nil
}
return m, aliasLevel, nil
}
// 3. Check dynamic resolvers (copy slice under lock to avoid holding
// the lock while calling back — resolvers may access the registry).
r.mu.RLock()
resolvers := make([]Resolver, len(r.resolvers))
copy(resolvers, r.resolvers)
r.mu.RUnlock()
for _, res := range resolvers {
if resolved, defaultLevel, ok := res.Resolve(base); ok {
if resolved == "" {
return nil, "", fmt.Errorf("resolver returned empty spec for %q", base)
}
m, resolvedLevel, err := r.parse(resolved, depth+1)
if err != nil {
return nil, "", err
}
level := resolvedLevel
if defaultLevel != "" && level == "" {
level = defaultLevel
}
if userLevel != "" {
level = userLevel
}
return m, level, nil
}
}
// 4. Split on first "/".
slashIdx := strings.Index(base, "/")
if slashIdx < 0 {
return nil, "", fmt.Errorf("%w: %q is not an alias and has no provider/ prefix", ErrUnknownProvider, spec)
}
left := base[:slashIdx]
right := base[slashIdx+1:]
// 5. Look up in provider registry.
if info := r.ProviderByName(left); info != nil {
client := r.createClient(info)
return client.Model(right), userLevel, nil
}
// 6. Check LLM_{UPPER(left)} env var.
envKey := "LLM_" + strings.ToUpper(strings.ReplaceAll(left, "-", "_"))
lookup := r.envLookup
if lookup == nil {
lookup = os.Getenv
}
envVal := lookup(envKey)
if envVal == "" {
return nil, "", fmt.Errorf("%w: %q (checked registry and %s env var)", ErrUnknownProvider, left, envKey)
}
dsn, err := ParseDSN(envVal)
if err != nil {
return nil, "", fmt.Errorf("parse %s: %w", envKey, err)
}
schemeInfo := r.ProviderByName(dsn.Scheme)
if schemeInfo == nil {
return nil, "", fmt.Errorf("%w: DSN scheme %q in %s is not a registered provider", ErrUnknownProvider, dsn.Scheme, envKey)
}
url := "https://" + dsn.Host
client := schemeInfo.New(dsn.Token, WithBaseURL(url))
return client.Model(right), userLevel, nil
}
// createClient builds a Client from a ProviderInfo, reading the API key from
// the environment when the provider specifies an EnvKey.
//
// Why: avoids duplicating env-var lookup logic across parse paths.
// What: reads the env var (if any), calls info.New with the key.
// Test: indirectly tested via Parse("openai/gpt-4o") with injected envLookup.
func (r *Registry) createClient(info *ProviderInfo) *Client {
apiKey := ""
if info.EnvKey != "" {
lookup := r.envLookup
if lookup == nil {
lookup = os.Getenv
}
apiKey = lookup(info.EnvKey)
}
return info.New(apiKey)
}