package chat import ( "bufio" "log/slog" "os" "strings" "time" ) // BuilderInput drives provider construction. Each field maps to one // provider; empty fields mean "skip" (the registry won't have that // provider — :cloud suffix or openrouter/* prefixes will 404 cleanly). type BuilderInput struct { OllamaURL string // local Ollama, no auth (typically http://localhost:11434) OllamaCloudKey string // OLLAMA_CLOUD_KEY OpenRouterKey string // OPENROUTER_API_KEY OpenCodeKey string // OPENCODE_API_KEY KimiKey string // KIMI_API_KEY Timeout time.Duration // default 180s } // BuildRegistry constructs a Registry from the input. Logs which // providers were registered (for operator confidence at boot). func BuildRegistry(in BuilderInput) *Registry { if in.Timeout == 0 { in.Timeout = 180 * time.Second } var providers []Provider registered := []string{} // Local Ollama always registered if URL given (no auth needed). if in.OllamaURL != "" { providers = append(providers, NewOllama(in.OllamaURL, in.Timeout)) registered = append(registered, "ollama") } if in.OllamaCloudKey != "" { providers = append(providers, NewOllamaCloud(in.OllamaCloudKey, in.Timeout)) registered = append(registered, "ollama_cloud") } if in.OpenRouterKey != "" { providers = append(providers, NewOpenRouter(in.OpenRouterKey, in.Timeout)) registered = append(registered, "openrouter") } if in.OpenCodeKey != "" { providers = append(providers, NewOpenCode(in.OpenCodeKey, in.Timeout)) registered = append(registered, "opencode") } if in.KimiKey != "" { providers = append(providers, NewKimi(in.KimiKey, in.Timeout)) registered = append(registered, "kimi") } r := NewRegistry(providers...) slog.Info("chat registry built", "providers", registered) return r } // ResolveKey reads an API key with the priority chain: // 1. Explicit env var (named by envVar) // 2. .env file at envFilePath, looking for "=" line // 3. "" if neither set // // Mirrors the Rust adapter's resolve_*_key() pattern. Empty key // means the provider stays unregistered — operators see one fewer // entry in the boot log instead of a 503 at first request. // // Phase 4 scrum fix B-1 (Opus + Kimi convergent): collapsed from // 3-arg API to 2-arg. Previous version forced callers to pass the // env var name twice (once for env-lookup, once for file-key-lookup), // which let operator renames silently miss the file fallback. Now // both lookups use the same canonical name. Operators who want a // different file-key-name can use a different env var altogether. func ResolveKey(envVar, envFilePath string) string { if envVar != "" { if v := strings.TrimSpace(os.Getenv(envVar)); v != "" { return v } } if envFilePath != "" && envVar != "" { if v := readEnvFileVar(envFilePath, envVar); v != "" { return v } } return "" } // readEnvFileVar reads a KEY=value style env file and returns the // value of `name`. Returns "" on any error or missing key. Stops at // first match. No quoting/escaping — same simple shape that systemd // EnvironmentFile= reads. func readEnvFileVar(path, name string) string { f, err := os.Open(path) if err != nil { return "" } defer f.Close() scanner := bufio.NewScanner(f) prefix := name + "=" for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if line == "" || strings.HasPrefix(line, "#") { continue } if strings.HasPrefix(line, prefix) { return strings.Trim(strings.TrimPrefix(line, prefix), `"'`) } } return "" }