Files
BlackBean/pigo/internal/provider/special_auth.go
T
2026-08-14 23:41:57 +08:00

226 lines
10 KiB
Go

// This file implements parameter validation and endpoint construction for the
// "special auth" providers (US-007 / FR-12): Azure OpenAI, Amazon Bedrock,
// Google Vertex, and Cloudflare (Workers AI + AI Gateway). Unlike the generic
// bearer/x-api-key providers, each of these composes its endpoint from several
// environment variables and/or needs a non-standard credential path, so a
// dedicated resolver validates the required parameters and builds the concrete
// base URL before handing off to the shared OpenAI-/Anthropic-compatible driver.
//
// Scope note (PRD Non-Goals): AWS SigV4 request signing is NOT implemented.
// Bedrock supports only the AWS_BEARER_TOKEN_BEDROCK bearer path; other AWS
// credential sources (AWS_PROFILE, AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY)
// are only *detected* so that a clear, actionable error is returned instead of
// an opaque auth failure.
//
// Security: this file reads env var NAMES and composes URLs from non-secret
// parameters (region, resource name, account id, …). Secret values (API keys,
// bearer tokens) are never logged or embedded in error text — errors name the
// absent env var, never a value.
package provider
import (
"fmt"
"strings"
)
// IsSpecialAuthProvider reports whether a provider spec needs the bespoke
// endpoint-construction / credential-validation handled by ResolveSpecialProvider,
// rather than the generic driver wiring. It matches the multi-parameter auth
// schemes (azure/aws/special) and the two Cloudflare providers (which keep a
// standard auth scheme but still compose their endpoint from env vars).
func IsSpecialAuthProvider(spec ProviderSpec) bool {
switch spec.AuthScheme {
case AuthAzure, AuthAWS, AuthSpecial:
return true
}
return strings.HasPrefix(spec.Name, "cloudflare-")
}
// ResolveSpecialProvider validates the required parameters for a special-auth
// provider and constructs the matching wire driver against the composed base
// URL. flagBaseURL is the explicit --base-url override (highest precedence, wins
// over any composed default); env resolves environment variables (os.Getenv in
// production, a fake in tests). A missing required parameter yields an error
// naming exactly which env var is absent; no network request is made here.
func ResolveSpecialProvider(spec ProviderSpec, model, flagBaseURL string, env func(string) string) (Provider, error) {
if env == nil {
env = func(string) string { return "" }
}
models := []Model{{Provider: spec.Name, ID: model, SupportsImages: true}}
switch spec.Name {
case "azure-openai-responses":
return resolveAzureOpenAI(spec, model, flagBaseURL, env, models)
case "amazon-bedrock":
return resolveBedrock(spec, flagBaseURL, env, models)
case "google-vertex":
return resolveGoogleVertex(spec, flagBaseURL, env, models)
case "cloudflare-workers-ai":
return resolveCloudflareWorkersAI(spec, flagBaseURL, env, models)
case "cloudflare-ai-gateway":
return resolveCloudflareAIGateway(spec, flagBaseURL, env, models)
default:
return nil, fmt.Errorf("provider %q is not a special-auth provider", spec.Name)
}
}
// resolveAzureOpenAI composes the Azure OpenAI endpoint. The endpoint origin is
// AZURE_OPENAI_BASE_URL (or the --base-url override), else it is built from
// AZURE_OPENAI_RESOURCE_NAME as https://{resource}.openai.azure.com. The API
// version (AZURE_OPENAI_API_VERSION, default "v1") and an optional deployment
// mapping (AZURE_OPENAI_DEPLOYMENT_NAME_MAP) shape the path. Auth uses
// AZURE_OPENAI_API_KEY over the OpenAI wire.
func resolveAzureOpenAI(_ ProviderSpec, model, flagBaseURL string, env func(string) string, models []Model) (Provider, error) {
if strings.TrimSpace(env("AZURE_OPENAI_API_KEY")) == "" {
return nil, fmt.Errorf("azure-openai-responses: missing required env var AZURE_OPENAI_API_KEY")
}
origin := strings.TrimSpace(flagBaseURL)
if origin == "" {
origin = strings.TrimSpace(env("AZURE_OPENAI_BASE_URL"))
}
if origin == "" {
resource := strings.TrimSpace(env("AZURE_OPENAI_RESOURCE_NAME"))
if resource == "" {
return nil, fmt.Errorf("azure-openai-responses: missing endpoint configuration; set AZURE_OPENAI_BASE_URL or AZURE_OPENAI_RESOURCE_NAME")
}
origin = fmt.Sprintf("https://%s.openai.azure.com", resource)
}
apiVersion := strings.TrimSpace(env("AZURE_OPENAI_API_VERSION"))
if apiVersion == "" {
apiVersion = "v1"
}
deployment := resolveAzureDeployment(env("AZURE_OPENAI_DEPLOYMENT_NAME_MAP"), model)
baseURL := azureEndpoint(origin, apiVersion, deployment)
return NewOpenAICompatibleProvider(baseURL, models), nil
}
// azureEndpoint builds the Azure OpenAI base URL from a validated origin. When a
// deployment is resolved for the model, the classic deployment-scoped path is
// used (…/openai/deployments/{deployment}); otherwise the version-scoped v1 path
// (…/openai/{apiVersion}) is used. The shared driver appends /chat/completions.
func azureEndpoint(origin, apiVersion, deployment string) string {
origin = strings.TrimRight(strings.TrimSpace(origin), "/")
if deployment != "" {
return fmt.Sprintf("%s/openai/deployments/%s", origin, deployment)
}
return fmt.Sprintf("%s/openai/%s", origin, apiVersion)
}
// resolveAzureDeployment parses AZURE_OPENAI_DEPLOYMENT_NAME_MAP (a
// comma-separated list of model=deployment pairs) and returns the deployment
// mapped to model, or "" when the map is empty or has no entry for the model.
func resolveAzureDeployment(raw, model string) string {
m := parseDeploymentMap(raw)
return m[strings.TrimSpace(model)]
}
// parseDeploymentMap parses a comma-separated "model=deployment" list into a
// map. Blank entries and entries without '=' are skipped; keys and values are
// trimmed. It never returns nil so lookups are always safe.
func parseDeploymentMap(raw string) map[string]string {
out := make(map[string]string)
for _, pair := range strings.Split(raw, ",") {
pair = strings.TrimSpace(pair)
if pair == "" {
continue
}
k, v, ok := strings.Cut(pair, "=")
k, v = strings.TrimSpace(k), strings.TrimSpace(v)
if !ok || k == "" || v == "" {
continue
}
out[k] = v
}
return out
}
// resolveBedrock composes the Amazon Bedrock runtime endpoint
// (https://bedrock-runtime.{region}.amazonaws.com; region defaults to
// us-east-1) and validates credentials. Only the AWS_BEARER_TOKEN_BEDROCK
// bearer path is supported (SigV4 is out of scope): if only AWS_PROFILE or
// static AWS keys are present, a clear error explains SigV4 is unsupported and
// names the missing AWS_BEARER_TOKEN_BEDROCK.
func resolveBedrock(_ ProviderSpec, flagBaseURL string, env func(string) string, models []Model) (Provider, error) {
if strings.TrimSpace(env("AWS_BEARER_TOKEN_BEDROCK")) == "" {
hasProfile := strings.TrimSpace(env("AWS_PROFILE")) != ""
hasStaticKeys := strings.TrimSpace(env("AWS_ACCESS_KEY_ID")) != "" &&
strings.TrimSpace(env("AWS_SECRET_ACCESS_KEY")) != ""
if hasProfile || hasStaticKeys {
return nil, fmt.Errorf("amazon-bedrock: detected AWS credentials (AWS_PROFILE / AWS_ACCESS_KEY_ID) but SigV4 request signing is not supported yet; set AWS_BEARER_TOKEN_BEDROCK to use the bearer-token path")
}
return nil, fmt.Errorf("amazon-bedrock: missing required env var AWS_BEARER_TOKEN_BEDROCK")
}
baseURL := strings.TrimSpace(flagBaseURL)
if baseURL == "" {
region := strings.TrimSpace(env("AWS_REGION"))
if region == "" {
region = "us-east-1"
}
baseURL = fmt.Sprintf("https://bedrock-runtime.%s.amazonaws.com", region)
}
return NewBedrockProvider(baseURL, models), nil
}
// resolveGoogleVertex composes the Vertex AI endpoint
// (https://{location}-aiplatform.googleapis.com) and validates that a project,
// a location, and a credential source (GOOGLE_CLOUD_API_KEY or ADC via
// GOOGLE_APPLICATION_CREDENTIALS) are present, naming any absent env var.
func resolveGoogleVertex(_ ProviderSpec, flagBaseURL string, env func(string) string, models []Model) (Provider, error) {
if strings.TrimSpace(env("GOOGLE_CLOUD_PROJECT")) == "" {
return nil, fmt.Errorf("google-vertex: missing required env var GOOGLE_CLOUD_PROJECT")
}
location := strings.TrimSpace(env("GOOGLE_CLOUD_LOCATION"))
if location == "" {
return nil, fmt.Errorf("google-vertex: missing required env var GOOGLE_CLOUD_LOCATION")
}
if strings.TrimSpace(env("GOOGLE_CLOUD_API_KEY")) == "" &&
strings.TrimSpace(env("GOOGLE_APPLICATION_CREDENTIALS")) == "" {
return nil, fmt.Errorf("google-vertex: missing credentials; set GOOGLE_CLOUD_API_KEY or GOOGLE_APPLICATION_CREDENTIALS (ADC)")
}
baseURL := strings.TrimSpace(flagBaseURL)
if baseURL == "" {
baseURL = fmt.Sprintf("https://%s-aiplatform.googleapis.com", location)
}
return NewOpenAICompatibleProvider(baseURL, models), nil
}
// resolveCloudflareWorkersAI composes the Workers AI endpoint
// (https://api.cloudflare.com/client/v4/accounts/{account}/ai/v1), requiring
// CLOUDFLARE_API_KEY and CLOUDFLARE_ACCOUNT_ID. OpenAI wire.
func resolveCloudflareWorkersAI(_ ProviderSpec, flagBaseURL string, env func(string) string, models []Model) (Provider, error) {
if strings.TrimSpace(env("CLOUDFLARE_API_KEY")) == "" {
return nil, fmt.Errorf("cloudflare-workers-ai: missing required env var CLOUDFLARE_API_KEY")
}
account := strings.TrimSpace(env("CLOUDFLARE_ACCOUNT_ID"))
if account == "" {
return nil, fmt.Errorf("cloudflare-workers-ai: missing required env var CLOUDFLARE_ACCOUNT_ID")
}
baseURL := strings.TrimSpace(flagBaseURL)
if baseURL == "" {
baseURL = fmt.Sprintf("https://api.cloudflare.com/client/v4/accounts/%s/ai/v1", account)
}
return NewOpenAICompatibleProvider(baseURL, models), nil
}
// resolveCloudflareAIGateway composes the AI Gateway endpoint
// (https://gateway.ai.cloudflare.com/v1/{account}/{gateway}/anthropic),
// requiring CLOUDFLARE_API_KEY, CLOUDFLARE_ACCOUNT_ID, and CLOUDFLARE_GATEWAY_ID.
// Anthropic wire.
func resolveCloudflareAIGateway(_ ProviderSpec, flagBaseURL string, env func(string) string, models []Model) (Provider, error) {
if strings.TrimSpace(env("CLOUDFLARE_API_KEY")) == "" {
return nil, fmt.Errorf("cloudflare-ai-gateway: missing required env var CLOUDFLARE_API_KEY")
}
account := strings.TrimSpace(env("CLOUDFLARE_ACCOUNT_ID"))
if account == "" {
return nil, fmt.Errorf("cloudflare-ai-gateway: missing required env var CLOUDFLARE_ACCOUNT_ID")
}
gateway := strings.TrimSpace(env("CLOUDFLARE_GATEWAY_ID"))
if gateway == "" {
return nil, fmt.Errorf("cloudflare-ai-gateway: missing required env var CLOUDFLARE_GATEWAY_ID")
}
baseURL := strings.TrimSpace(flagBaseURL)
if baseURL == "" {
baseURL = fmt.Sprintf("https://gateway.ai.cloudflare.com/v1/%s/%s/anthropic", account, gateway)
}
return NewAnthropicProvider(baseURL, models), nil
}