226 lines
10 KiB
Go
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
|
|
}
|