Files
2026-08-14 23:41:57 +08:00

224 lines
11 KiB
Go

package provider
// Provider resolution moved here from cmd/pigo (US-004, #361): mapping a model
// id / --provider / --protocol selection to a concrete wire driver, plus the
// base-url override precedence. Environment lookups are injected as an
// env func(string) string so callers (and tests) control the environment
// instead of reaching into the process env directly.
import (
"fmt"
"strings"
"github.com/smallnest/pigo/internal/cli/config"
)
// ResolveProvider maps a model id to a built-in provider. An explicit
// --provider name wins over every other rule: it selects a built-in provider
// from the registry and constructs the matching wire driver (see
// ResolveNamedProvider). When provider is empty, protocol and model-id
// heuristics apply as before.
//
// When protocol is a non-empty explicit selection ("openai" or "anthropic") it
// wins over the model-id heuristics: the provider is built directly for that
// wire format against baseURL, which is how a user points pigo at a self-hosted
// or third-party endpoint and says which protocol it speaks. An "anthropic"
// selection with no baseURL targets the public Anthropic API.
//
// When protocol is empty, resolution falls back to model-id heuristics:
//
// 1. If the id is in the preset catalog, use its declared provider (this is how
// OpenRouter/NVIDIA/Ollama presets pick the right gateway).
// 2. An "ollama/" prefix (or a base URL on the Ollama port) → local Ollama.
// 3. An "nvidia/" prefix → NVIDIA NIM (strips the prefix for the wire id).
// 4. Model-name inference: with no --base-url, a well-known model-name prefix
// (e.g. "claude-*", "deepseek-*") selects its first-party built-in provider
// via ResolveNamedProvider (see InferProviderFromModel).
// 5. Everything else → OpenRouter, the reference OpenAI-compatible gateway.
//
// An unknown protocol value is an error, surfaced to the caller for exit-code
// mapping rather than silently falling back.
func ResolveProvider(model, baseURL, protocol, providerName string, env func(string) string) (Provider, string, error) {
// Explicit --provider selects a built-in provider from the registry and
// wins over both --protocol inference and model-id heuristics.
if strings.TrimSpace(providerName) != "" {
return ResolveNamedProvider(providerName, model, baseURL, protocol, env)
}
// 0. Explicit protocol selection wins over every heuristic. Normalize the
// surface value first so "openai" and "openai/chat" collapse to the same
// Chat Completions selector and "openai/resp_api" routes to the Responses
// driver; an unknown value surfaces as an error for exit-code mapping.
canonical, err := NormalizeProtocol(protocol)
if err != nil {
return nil, "", err
}
switch canonical {
case ProtocolOpenAI:
if strings.TrimSpace(baseURL) == "" {
return nil, "", fmt.Errorf("--protocol openai requires --base-url")
}
return NewOpenAICompatibleProvider(baseURL, []Model{{Provider: "openai", ID: model, SupportsImages: true}}), "openai", nil
case ProtocolOpenAIResponses:
// The Responses driver has no public default endpoint here: unlike the
// anthropic path (which targets the public API), resp_api mirrors the
// Chat Completions requirement and demands an explicit --base-url.
if strings.TrimSpace(baseURL) == "" {
return nil, "", fmt.Errorf("--protocol openai/resp_api requires --base-url")
}
return NewOpenAIResponsesProvider("openai", baseURL, []Model{{Provider: "openai", ID: model, SupportsImages: true}}), "openai", nil
case ProtocolAnthropic:
return NewAnthropicProvider(baseURL, []Model{{Provider: "anthropic", ID: model, SupportsImages: true}}), "anthropic", nil
case "":
// fall through to heuristic resolution
}
// 1. Preset catalog wins: a curated id knows its own provider.
if p, ok := LookupPreset(model); ok {
switch p.Provider {
case "nvidia":
return NewNvidiaProvider(baseURL, []Model{{Provider: "nvidia", ID: model, SupportsImages: true}}), "nvidia", nil
case "ollama":
id := strings.TrimPrefix(model, "ollama/")
return NewOllamaProvider(baseURL, []Model{{Provider: "ollama", ID: id, SupportsImages: true}}), "ollama", nil
case "", "openrouter":
return NewOpenRouterProvider(baseURL, []Model{{Provider: "openrouter", ID: model, SupportsImages: true}}), "openrouter", nil
default:
// Any other preset provider is a named built-in (e.g. deepseek,
// qianfan, dashscope): build it from the registry so the correct
// base URL, protocol, and API-key env var are used — not OpenRouter's.
return ResolveNamedProvider(p.Provider, model, baseURL, protocol, env)
}
}
// 2. Local Ollama by prefix or port.
if strings.HasPrefix(model, "ollama/") || strings.Contains(baseURL, "11434") {
id := strings.TrimPrefix(model, "ollama/")
return NewOllamaProvider(baseURL, []Model{{Provider: "ollama", ID: id, SupportsImages: true}}), "ollama", nil
}
// 3. NVIDIA NIM by prefix.
if strings.HasPrefix(model, "nvidia/") {
id := strings.TrimPrefix(model, "nvidia/")
return NewNvidiaProvider(baseURL, []Model{{Provider: "nvidia", ID: id, SupportsImages: true}}), "nvidia", nil
}
// 4. Model-name inference: with no --provider/--protocol (both empty here) and
// no --base-url, guess the provider from the model name's well-known prefix
// (e.g. "claude-*" → anthropic, "deepseek-*" → deepseek). A confident hit is
// routed through ResolveNamedProvider so the provider's registry protocol,
// default base URL, and API-key env var are used. A --base-url is treated as
// a custom-endpoint signal that should not be second-guessed, so inference is
// skipped when one is given. Ambiguous/unknown names fall through to (5).
if strings.TrimSpace(baseURL) == "" {
if name, ok := InferProviderFromModel(model); ok {
return ResolveNamedProvider(name, model, baseURL, protocol, env)
}
}
// 5. Default: OpenRouter.
return NewOpenRouterProvider(baseURL, []Model{{Provider: "openrouter", ID: model, SupportsImages: true}}), "openrouter", nil
}
// ResolveNamedProvider builds the driver for an explicit --provider selection.
// It looks the name up in the built-in registry and constructs the wire driver
// matching the spec's Protocol: "openai" → an OpenAI-compatible (Bearer) driver,
// "anthropic" → an Anthropic-Messages driver. The base URL follows the override
// precedence in ResolveBaseURL (--base-url > provider-specific env > generic
// <PROVIDER>_BASE_URL > spec default). The returned provider-name string is the
// spec name, so downstream API-key resolution reads the provider's own env var
// (spec.EnvVars).
//
// Special providers with bespoke auth (azure/bedrock/vertex/cloudflare —
// AuthScheme aws/azure/special, or the cloudflare-* names) are routed to
// ResolveSpecialProvider, which validates their required env vars and composes
// the concrete endpoint (node #188).
func ResolveNamedProvider(name, model, baseURL, protocol string, env func(string) string) (Provider, string, error) {
spec, ok := LookupProviderSpec(name)
if !ok {
return nil, "", fmt.Errorf("unknown --provider %q (available: %s)", name, strings.Join(ProviderNames(), ", "))
}
// A concurrently-set --protocol must agree with the provider's own protocol;
// an incompatible pair is a user error naming both flags. Normalize the raw
// value first so aliases (e.g. "openai/chat" for an "openai" spec) don't
// falsely conflict, and a genuine typo surfaces as a clear "unknown --protocol"
// error rather than a misleading conflict message.
if strings.TrimSpace(protocol) != "" {
canonical, err := NormalizeProtocol(protocol)
if err != nil {
return nil, "", err
}
if canonical != spec.Protocol {
return nil, "", fmt.Errorf("--provider %q speaks the %q protocol, which conflicts with --protocol %q; drop --protocol or set it to %q", name, spec.Protocol, protocol, spec.Protocol)
}
}
// Special-auth providers (Azure / Bedrock / Vertex / Cloudflare) compose
// their endpoint from several env vars and/or need non-standard credential
// validation, so route them to the dedicated resolver (US-007 / node #188).
// It performs its own base-URL composition (honoring the --base-url override)
// and returns a clear error naming any absent required env var.
if IsSpecialAuthProvider(spec) {
p, err := ResolveSpecialProvider(spec, model, baseURL, env)
if err != nil {
return nil, "", err
}
return p, spec.Name, nil
}
// Base-URL precedence (US-004 / FR-8, FR-9): --base-url flag > provider-
// specific base-url env var(s) > generic <PROVIDER>_BASE_URL > spec default.
url := ResolveBaseURL(spec, baseURL, env)
models := []Model{{Provider: spec.Name, ID: model, SupportsImages: true}}
// Note: spec.ExtraHeaders would be attached here, but the exported generic
// constructors do not yet accept custom headers; all built-in specs currently
// carry no ExtraHeaders, so this is a no-op today (refined alongside #188).
switch spec.Protocol {
case ProtocolAnthropic:
// Auth header follows the spec's AuthScheme (x-api-key + anthropic-version
// for anthropic/minimax/minimax-cn; Bearer for any anthropic-protocol
// gateway that authenticates with a plain bearer token). The driver name is
// the spec name so errors reference the selected provider.
return NewAnthropicProtocolProvider(spec.Name, url, spec.AuthScheme, models), spec.Name, nil
case ProtocolOpenAI:
return NewOpenAICompatibleProvider(url, models), spec.Name, nil
case ProtocolOpenAIResponses:
return NewOpenAIResponsesProvider(spec.Name, url, models), spec.Name, nil
default:
// The registry only ever stores openai/openai-resp/anthropic; guard anyway
// so an unexpected value is a clear error rather than a nil provider.
return nil, "", fmt.Errorf("--provider %q has unsupported protocol %q", name, spec.Protocol)
}
}
// ResolveBaseURL determines the effective base URL for a selected provider,
// applying the base_url override precedence (US-004 / FR-8, FR-9). The first
// non-empty source wins, in this order:
//
// 1. flagBaseURL — the explicit --base-url/-u flag (highest).
// 2. provider-specific base-url env var(s) from spec.BaseURLEnvVars, in the
// order the registry declares them (e.g. AZURE_OPENAI_BASE_URL).
// 3. the generic <PROVIDER>_BASE_URL env var, where <PROVIDER> is the provider
// name uppercased with '-' rewritten to '_' (e.g. zai-coding-cn →
// ZAI_CODING_CN_BASE_URL).
// 4. spec.DefaultBaseURL — the registry default (lowest).
//
// Values are trimmed of surrounding whitespace before the non-empty check, so a
// whitespace-only env var does not shadow a lower-precedence source. Environment
// lookups go through the injected env func so callers control the environment.
func ResolveBaseURL(spec ProviderSpec, flagBaseURL string, env func(string) string) string {
// 1. Explicit flag wins over every env-var convention.
if v := strings.TrimSpace(flagBaseURL); v != "" {
return v
}
// 2. Provider-specific override env vars, in registry precedence order.
for _, name := range spec.BaseURLEnvVars {
if v := strings.TrimSpace(env(name)); v != "" {
return v
}
}
// 3. Generic <PROVIDER>_BASE_URL convention.
if envName := config.GenericBaseURLEnvVar(spec.Name); envName != "" {
if v := strings.TrimSpace(env(envName)); v != "" {
return v
}
}
// 4. Registry default.
return spec.DefaultBaseURL
}