Anthropic 전용 API key가 일반 OpenAI 요청에 적용되지 않도록 경계를 좁히고 잘못된 절대 operation URL을 fail-closed로 거부해야 한다.\n\n완료 Milestone archive와 principal credential 후속 agent-task를 같은 기준선에 맞춘다.
703 lines
25 KiB
Go
703 lines
25 KiB
Go
package config
|
|
|
|
import (
|
|
"fmt"
|
|
"net/url"
|
|
"strings"
|
|
)
|
|
|
|
// ProtocolDriver identifies the wire/stream semantics of a protocol profile.
|
|
type ProtocolDriver string
|
|
|
|
const (
|
|
ProtocolDriverOpenAIChat ProtocolDriver = "openai_chat"
|
|
ProtocolDriverAnthropicMessages ProtocolDriver = "anthropic_messages"
|
|
ProtocolDriverOpenAIResponses ProtocolDriver = "openai_responses"
|
|
)
|
|
|
|
// validProtocolDrivers is the closed set of admissible driver values.
|
|
var validProtocolDrivers = map[ProtocolDriver]struct{}{
|
|
ProtocolDriverOpenAIChat: {},
|
|
ProtocolDriverAnthropicMessages: {},
|
|
ProtocolDriverOpenAIResponses: {},
|
|
}
|
|
|
|
// ProtocolOperation is a named operation within a protocol profile.
|
|
type ProtocolOperation string
|
|
|
|
const (
|
|
OperationModels ProtocolOperation = "models"
|
|
OperationChatCompletions ProtocolOperation = "chat_completions"
|
|
OperationMessages ProtocolOperation = "messages"
|
|
OperationCountTokens ProtocolOperation = "count_tokens"
|
|
OperationResponses ProtocolOperation = "responses"
|
|
)
|
|
|
|
// validProtocolOperations is the closed set of admissible operation ids.
|
|
var validProtocolOperations = map[ProtocolOperation]struct{}{
|
|
OperationModels: {},
|
|
OperationChatCompletions: {},
|
|
OperationMessages: {},
|
|
OperationCountTokens: {},
|
|
OperationResponses: {},
|
|
}
|
|
|
|
// validOperationsByDriver restricts which operations each driver may declare.
|
|
var validOperationsByDriver = map[ProtocolDriver]map[ProtocolOperation]struct{}{
|
|
ProtocolDriverOpenAIChat: {
|
|
OperationModels: {},
|
|
OperationChatCompletions: {},
|
|
OperationResponses: {},
|
|
OperationCountTokens: {},
|
|
},
|
|
ProtocolDriverAnthropicMessages: {
|
|
OperationModels: {},
|
|
OperationMessages: {},
|
|
OperationCountTokens: {},
|
|
},
|
|
ProtocolDriverOpenAIResponses: {
|
|
OperationModels: {},
|
|
OperationResponses: {},
|
|
OperationCountTokens: {},
|
|
},
|
|
}
|
|
|
|
// validProtocolCapabilities is the closed vocabulary shared by config,
|
|
// admission, and request preparation. Lifecycle capabilities are a separate
|
|
// provider concern and intentionally do not participate in this set.
|
|
var validProtocolCapabilities = map[string]struct{}{
|
|
"models": {},
|
|
"chat": {},
|
|
"messages": {},
|
|
"responses": {},
|
|
"streaming": {},
|
|
"tool_calling": {},
|
|
"count_tokens": {},
|
|
}
|
|
|
|
var operationCapability = map[ProtocolOperation]string{
|
|
OperationModels: "models",
|
|
OperationChatCompletions: "chat",
|
|
OperationMessages: "messages",
|
|
OperationCountTokens: "count_tokens",
|
|
OperationResponses: "responses",
|
|
}
|
|
|
|
// TokenCounterMode describes how a model group's input tokens are counted.
|
|
type TokenCounterMode string
|
|
|
|
const (
|
|
TokenCounterDeterministic TokenCounterMode = "deterministic"
|
|
TokenCounterEstimate TokenCounterMode = "estimate"
|
|
)
|
|
|
|
// ProtocolAuthConf declares how a profile authenticates to its upstream.
|
|
type ProtocolAuthConf struct {
|
|
// Header is the request header name carrying the credential (e.g.
|
|
// "Authorization" or "x-api-key").
|
|
Header string `mapstructure:"header" yaml:"header,omitempty"`
|
|
// Scheme is the auth scheme prefix (e.g. "Bearer"). Empty means the
|
|
// raw header value is the credential.
|
|
Scheme string `mapstructure:"scheme" yaml:"scheme,omitempty"`
|
|
}
|
|
|
|
// ProtocolProfileConf is the overlayable configuration of a protocol profile.
|
|
// It is the source of truth for endpoint, operation paths, auth, and
|
|
// capabilities before concrete resolution.
|
|
type ProtocolProfileConf struct {
|
|
// Base is the optional id of a built-in/custom profile this overlay
|
|
// extends. At most one base is allowed; cycles and unknown bases are
|
|
// rejected at config load.
|
|
Base string `mapstructure:"base" yaml:"base,omitempty"`
|
|
// Driver is the protocol driver (openai_chat, anthropic_messages,
|
|
// openai_responses).
|
|
Driver ProtocolDriver `mapstructure:"driver" yaml:"driver,omitempty"`
|
|
// BaseURL is the normalized upstream base URL (no trailing slash).
|
|
BaseURL string `mapstructure:"base_url" yaml:"base_url,omitempty"`
|
|
// Operations maps operation id to a relative or absolute URL path.
|
|
// Absolute URLs are returned unchanged; relative paths are joined once
|
|
// to BaseURL.
|
|
Operations map[string]string `mapstructure:"operations" yaml:"operations,omitempty"`
|
|
// Auth declares the upstream authentication header/scheme.
|
|
Auth ProtocolAuthConf `mapstructure:"auth" yaml:"auth,omitempty"`
|
|
// Capabilities lists the profile's declared capabilities (e.g.
|
|
// "messages", "streaming", "tool_calling").
|
|
Capabilities []string `mapstructure:"capabilities" yaml:"capabilities,omitempty"`
|
|
// ModelMapping maps selected/canonical model ids to upstream model ids.
|
|
ModelMapping map[string]string `mapstructure:"model_mapping" yaml:"model_mapping,omitempty"`
|
|
// Extensions holds restricted profile-specific options that cannot be
|
|
// expressed in the typed fields above.
|
|
Extensions map[string]any `mapstructure:"extensions" yaml:"extensions,omitempty"`
|
|
}
|
|
|
|
// ConcreteProtocolProfile is the immutable, resolved snapshot of a protocol
|
|
// profile. It is what gets carried through the wire, Node config, and adapter
|
|
// resolution. It never contains a Base reference.
|
|
type ConcreteProtocolProfile struct {
|
|
ID string
|
|
ProtocolProfileConf
|
|
}
|
|
|
|
// TokenCounterConf declares how a model group's input tokens are counted
|
|
// without an upstream call.
|
|
type TokenCounterConf struct {
|
|
// Mode is "deterministic" or "estimate".
|
|
Mode TokenCounterMode `mapstructure:"mode" yaml:"mode,omitempty"`
|
|
// Per1kInput is the estimated input tokens per 1k characters for
|
|
// estimate mode.
|
|
Per1kInput int `mapstructure:"per_1k_input" yaml:"per_1k_input,omitempty"`
|
|
}
|
|
|
|
// builtInProtocolProfiles is the process-owned catalog. Config normalization
|
|
// always clones from this private map, so callers cannot mutate runtime
|
|
// defaults through the exported compatibility snapshot below.
|
|
var builtInProtocolProfiles = map[string]ProtocolProfileConf{
|
|
"openai": {
|
|
Driver: ProtocolDriverOpenAIChat,
|
|
BaseURL: "https://api.openai.com/v1",
|
|
Operations: map[string]string{
|
|
string(OperationModels): "/models",
|
|
string(OperationChatCompletions): "/chat/completions",
|
|
string(OperationResponses): "/responses",
|
|
},
|
|
Auth: ProtocolAuthConf{Header: "Authorization", Scheme: "Bearer"},
|
|
Capabilities: []string{"models", "chat", "streaming", "tool_calling", "responses"},
|
|
},
|
|
"gemini": {
|
|
Driver: ProtocolDriverOpenAIChat,
|
|
BaseURL: "https://generativelanguage.googleapis.com/v1beta/openai",
|
|
Operations: map[string]string{
|
|
string(OperationModels): "/models",
|
|
string(OperationChatCompletions): "/chat/completions",
|
|
},
|
|
Auth: ProtocolAuthConf{Header: "Authorization", Scheme: "Bearer"},
|
|
Capabilities: []string{"models", "chat", "streaming", "tool_calling"},
|
|
},
|
|
"anthropic": {
|
|
Driver: ProtocolDriverAnthropicMessages,
|
|
BaseURL: "https://api.anthropic.com",
|
|
Operations: map[string]string{
|
|
string(OperationMessages): "/v1/messages",
|
|
string(OperationCountTokens): "/v1/messages/count_tokens",
|
|
},
|
|
Auth: ProtocolAuthConf{Header: "x-api-key"},
|
|
Capabilities: []string{"messages", "streaming", "tool_calling", "count_tokens"},
|
|
},
|
|
"glm": {
|
|
Driver: ProtocolDriverOpenAIChat,
|
|
BaseURL: "https://api.z.ai/api/paas/v4",
|
|
Operations: map[string]string{
|
|
string(OperationModels): "/models",
|
|
string(OperationChatCompletions): "/chat/completions",
|
|
},
|
|
Auth: ProtocolAuthConf{Header: "Authorization", Scheme: "Bearer"},
|
|
Capabilities: []string{"models", "chat", "streaming"},
|
|
},
|
|
"kimi": {
|
|
Driver: ProtocolDriverOpenAIChat,
|
|
BaseURL: "https://api.moonshot.cn/v1",
|
|
Operations: map[string]string{
|
|
string(OperationModels): "/models",
|
|
string(OperationChatCompletions): "/chat/completions",
|
|
},
|
|
Auth: ProtocolAuthConf{Header: "Authorization", Scheme: "Bearer"},
|
|
Capabilities: []string{"models", "chat", "streaming"},
|
|
},
|
|
"minimax_chat": {
|
|
Driver: ProtocolDriverOpenAIChat,
|
|
BaseURL: "https://api.minimax.io/v1",
|
|
Operations: map[string]string{
|
|
string(OperationModels): "/models",
|
|
string(OperationChatCompletions): "/chat/completions",
|
|
},
|
|
Auth: ProtocolAuthConf{Header: "Authorization", Scheme: "Bearer"},
|
|
Capabilities: []string{"models", "chat", "streaming"},
|
|
},
|
|
"minimax_messages": {
|
|
Driver: ProtocolDriverAnthropicMessages,
|
|
BaseURL: "https://api.minimax.io/anthropic",
|
|
Operations: map[string]string{
|
|
string(OperationMessages): "/v1/messages",
|
|
},
|
|
Auth: ProtocolAuthConf{Header: "Authorization", Scheme: "Bearer"},
|
|
Capabilities: []string{"messages", "streaming"},
|
|
},
|
|
"mimo_chat": {
|
|
Driver: ProtocolDriverOpenAIChat,
|
|
BaseURL: "https://api.xiaomimimo.com/v1",
|
|
Operations: map[string]string{
|
|
string(OperationModels): "/models",
|
|
string(OperationChatCompletions): "/chat/completions",
|
|
},
|
|
Auth: ProtocolAuthConf{Header: "Authorization", Scheme: "Bearer"},
|
|
Capabilities: []string{"models", "chat", "streaming"},
|
|
},
|
|
"mimo_messages": {
|
|
Driver: ProtocolDriverAnthropicMessages,
|
|
BaseURL: "https://api.xiaomimimo.com/anthropic",
|
|
Operations: map[string]string{
|
|
string(OperationMessages): "/v1/messages",
|
|
},
|
|
Auth: ProtocolAuthConf{Header: "api-key"},
|
|
Capabilities: []string{"messages", "streaming"},
|
|
},
|
|
"grok": {
|
|
Driver: ProtocolDriverOpenAIChat,
|
|
BaseURL: "https://api.x.ai/v1",
|
|
Operations: map[string]string{
|
|
string(OperationModels): "/models",
|
|
string(OperationChatCompletions): "/chat/completions",
|
|
},
|
|
Auth: ProtocolAuthConf{Header: "Authorization", Scheme: "Bearer"},
|
|
Capabilities: []string{"models", "chat", "streaming"},
|
|
},
|
|
"seulgi_chat": {
|
|
Driver: ProtocolDriverOpenAIChat,
|
|
BaseURL: "https://seulgivibe.example.invalid/openai/v1",
|
|
Operations: map[string]string{
|
|
string(OperationModels): "/models",
|
|
string(OperationChatCompletions): "/chat/completions",
|
|
},
|
|
Auth: ProtocolAuthConf{Header: "Authorization", Scheme: "Bearer"},
|
|
Capabilities: []string{"models", "chat", "streaming"},
|
|
},
|
|
"seulgi_messages": {
|
|
Driver: ProtocolDriverAnthropicMessages,
|
|
BaseURL: "https://seulgivibe.example.invalid/anthropic/v1",
|
|
Operations: map[string]string{
|
|
string(OperationMessages): "/messages",
|
|
string(OperationCountTokens): "/messages/count_tokens",
|
|
},
|
|
Auth: ProtocolAuthConf{Header: "x-api-key"},
|
|
Capabilities: []string{"messages", "streaming", "tool_calling", "count_tokens"},
|
|
},
|
|
}
|
|
|
|
// BuiltInProtocolProfiles is a compatibility snapshot for callers that resolve
|
|
// profiles directly. Runtime config assembly uses a fresh private clone via
|
|
// BuiltInProtocolProfileCatalog and is therefore immune to caller mutation.
|
|
var BuiltInProtocolProfiles = cloneProfileCatalog(builtInProtocolProfiles)
|
|
|
|
// BuiltInProtocolProfileCatalog returns a fully independent built-in catalog.
|
|
func BuiltInProtocolProfileCatalog() map[string]ProtocolProfileConf {
|
|
return cloneProfileCatalog(builtInProtocolProfiles)
|
|
}
|
|
|
|
func cloneProfileCatalog(src map[string]ProtocolProfileConf) map[string]ProtocolProfileConf {
|
|
dst := make(map[string]ProtocolProfileConf, len(src))
|
|
for id, profile := range src {
|
|
dst[id] = deepCopyProfileConf(profile)
|
|
}
|
|
return dst
|
|
}
|
|
|
|
// legacyProviderTypeToProfile maps legacy provider type strings to built-in
|
|
// profile ids. This is used only when no explicit profile selector is set.
|
|
var legacyProviderTypeToProfile = map[string]string{
|
|
"openai_api": "openai",
|
|
"vllm": "openai",
|
|
"vllm-mlx": "openai",
|
|
"lemonade": "openai",
|
|
"sglang": "openai",
|
|
"openai_compat": "openai",
|
|
"seulgivibe_claude": "seulgi_messages",
|
|
"seulgivibe_openai": "seulgi_chat",
|
|
"anthropic": "anthropic",
|
|
}
|
|
|
|
// LegacyProviderTypeToProfile returns the built-in profile id that a legacy
|
|
// provider type normalizes to. Returns "" when no mapping exists.
|
|
func LegacyProviderTypeToProfile(providerType string) string {
|
|
return legacyProviderTypeToProfile[strings.TrimSpace(providerType)]
|
|
}
|
|
|
|
// validateProfileOperation checks that an operation id is valid for the
|
|
// given driver.
|
|
func validateProfileOperation(driver ProtocolDriver, op string) error {
|
|
ops, ok := validOperationsByDriver[driver]
|
|
if !ok {
|
|
return fmt.Errorf("profile driver %q is not recognized", driver)
|
|
}
|
|
opID := ProtocolOperation(op)
|
|
if _, valid := validProtocolOperations[opID]; !valid {
|
|
return fmt.Errorf("profile operation %q is not a known operation", op)
|
|
}
|
|
if _, valid := ops[opID]; !valid {
|
|
return fmt.Errorf("profile operation %q is not supported for driver %q", op, driver)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// validateProfileURL validates that an operation path is a well-formed
|
|
// relative path or absolute URL.
|
|
func validateProfileURL(baseURL, opPath string) error {
|
|
trimmed := strings.TrimSpace(opPath)
|
|
if trimmed == "" {
|
|
return fmt.Errorf("profile operation path must not be empty")
|
|
}
|
|
if strings.HasPrefix(trimmed, "http://") || strings.HasPrefix(trimmed, "https://") {
|
|
parsed, err := url.Parse(trimmed)
|
|
if err != nil {
|
|
return fmt.Errorf("profile operation path %q is not a valid absolute URL: %w", trimmed, err)
|
|
}
|
|
if (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {
|
|
return fmt.Errorf("profile operation path %q must be an absolute http(s) URL", trimmed)
|
|
}
|
|
return nil
|
|
}
|
|
if !strings.HasPrefix(trimmed, "/") {
|
|
return fmt.Errorf("profile operation path %q must start with '/' for relative paths (leading slash required)", trimmed)
|
|
}
|
|
if _, err := url.Parse(baseURL); err != nil {
|
|
return fmt.Errorf("profile base_url %q is not a valid URL: %w", baseURL, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// deepCopyProfileConf creates a deep copy of a ProtocolProfileConf, copying
|
|
// all maps and slices so mutations to the copy do not affect the original.
|
|
func deepCopyProfileConf(src ProtocolProfileConf) ProtocolProfileConf {
|
|
dst := src
|
|
if src.Operations != nil {
|
|
dst.Operations = make(map[string]string, len(src.Operations))
|
|
for k, v := range src.Operations {
|
|
dst.Operations[k] = v
|
|
}
|
|
}
|
|
if src.Capabilities != nil {
|
|
dst.Capabilities = append([]string(nil), src.Capabilities...)
|
|
}
|
|
if src.ModelMapping != nil {
|
|
dst.ModelMapping = make(map[string]string, len(src.ModelMapping))
|
|
for k, v := range src.ModelMapping {
|
|
dst.ModelMapping[k] = v
|
|
}
|
|
}
|
|
if src.Extensions != nil {
|
|
dst.Extensions = deepCopyExtensions(src.Extensions)
|
|
}
|
|
return dst
|
|
}
|
|
|
|
// deepCopyExtensions deep-copies a map[string]any, handling nested maps and
|
|
// slices.
|
|
func deepCopyExtensions(src map[string]any) map[string]any {
|
|
if src == nil {
|
|
return nil
|
|
}
|
|
dst := make(map[string]any, len(src))
|
|
for k, v := range src {
|
|
dst[k] = deepCopyAny(v)
|
|
}
|
|
return dst
|
|
}
|
|
|
|
func deepCopyAny(v any) any {
|
|
switch val := v.(type) {
|
|
case map[string]any:
|
|
return deepCopyExtensions(val)
|
|
case map[string]string:
|
|
out := make(map[string]string, len(val))
|
|
for k, item := range val {
|
|
out[k] = item
|
|
}
|
|
return out
|
|
case []any:
|
|
out := make([]any, len(val))
|
|
for i, item := range val {
|
|
out[i] = deepCopyAny(item)
|
|
}
|
|
return out
|
|
case []string:
|
|
return append([]string(nil), val...)
|
|
default:
|
|
return v
|
|
}
|
|
}
|
|
|
|
// ResolveProtocolProfile resolves a provider's profile selector against the
|
|
// catalog (built-in + custom overlays), producing an immutable
|
|
// ConcreteProtocolProfile snapshot. It enforces:
|
|
// - cycle detection (base chain must terminate)
|
|
// - unknown base rejection
|
|
// - driver conflict (overlay driver must match base driver)
|
|
// - operation/auth/capability validation
|
|
// - URL validation
|
|
//
|
|
// When selector is empty, legacyType is consulted for alias normalization.
|
|
// If neither resolves, the returned error explains the failure.
|
|
func ResolveProtocolProfile(selector, legacyType string, catalog map[string]ProtocolProfileConf) (ConcreteProtocolProfile, error) {
|
|
id := strings.TrimSpace(selector)
|
|
if id == "" {
|
|
id = LegacyProviderTypeToProfile(legacyType)
|
|
}
|
|
if id == "" {
|
|
return ConcreteProtocolProfile{}, fmt.Errorf("provider profile: no profile selector and no legacy type mapping for %q", legacyType)
|
|
}
|
|
|
|
visited := make(map[string]struct{})
|
|
var resolve func(pid string) (ProtocolProfileConf, error)
|
|
resolve = func(pid string) (ProtocolProfileConf, error) {
|
|
if _, cycle := visited[pid]; cycle {
|
|
return ProtocolProfileConf{}, fmt.Errorf("profile %q: base cycle detected", pid)
|
|
}
|
|
visited[pid] = struct{}{}
|
|
|
|
base, ok := catalog[pid]
|
|
if !ok {
|
|
return ProtocolProfileConf{}, fmt.Errorf("profile %q: unknown profile id", pid)
|
|
}
|
|
|
|
if strings.TrimSpace(base.Base) != "" {
|
|
parent, err := resolve(base.Base)
|
|
if err != nil {
|
|
return ProtocolProfileConf{}, err
|
|
}
|
|
merged := deepCopyProfileConf(parent)
|
|
overlay := deepCopyProfileConf(base)
|
|
merged, err = mergeProfileOverlay(merged, overlay)
|
|
if err != nil {
|
|
return ProtocolProfileConf{}, err
|
|
}
|
|
return merged, nil
|
|
}
|
|
return deepCopyProfileConf(base), nil
|
|
}
|
|
|
|
resolved, err := resolve(id)
|
|
if err != nil {
|
|
return ConcreteProtocolProfile{}, err
|
|
}
|
|
|
|
if err := validateConcreteProfile(id, resolved); err != nil {
|
|
return ConcreteProtocolProfile{}, err
|
|
}
|
|
|
|
return ConcreteProtocolProfile{ID: id, ProtocolProfileConf: resolved}, nil
|
|
}
|
|
|
|
// mergeProfileOverlay merges an overlay onto a base profile. Overlay fields
|
|
// that are non-zero/non-empty replace base fields; nil/empty overlay fields
|
|
// inherit from the base. A driver conflict (overlay driver differs from base
|
|
// driver) returns an error.
|
|
func mergeProfileOverlay(base, overlay ProtocolProfileConf) (ProtocolProfileConf, error) {
|
|
merged := base
|
|
if strings.TrimSpace(string(overlay.Driver)) != "" {
|
|
if overlay.Driver != base.Driver {
|
|
return ProtocolProfileConf{}, fmt.Errorf("profile: driver conflict: base %q vs overlay %q", base.Driver, overlay.Driver)
|
|
}
|
|
}
|
|
if strings.TrimSpace(overlay.BaseURL) != "" {
|
|
merged.BaseURL = overlay.BaseURL
|
|
}
|
|
if len(overlay.Operations) > 0 {
|
|
if merged.Operations == nil {
|
|
merged.Operations = make(map[string]string)
|
|
}
|
|
for k, v := range overlay.Operations {
|
|
merged.Operations[k] = v
|
|
}
|
|
}
|
|
if overlay.Auth.Header != "" {
|
|
merged.Auth.Header = overlay.Auth.Header
|
|
}
|
|
if overlay.Auth.Scheme != "" {
|
|
merged.Auth.Scheme = overlay.Auth.Scheme
|
|
}
|
|
if len(overlay.Capabilities) > 0 {
|
|
merged.Capabilities = overlay.Capabilities
|
|
}
|
|
if len(overlay.ModelMapping) > 0 {
|
|
if merged.ModelMapping == nil {
|
|
merged.ModelMapping = make(map[string]string)
|
|
}
|
|
for k, v := range overlay.ModelMapping {
|
|
merged.ModelMapping[k] = v
|
|
}
|
|
}
|
|
if len(overlay.Extensions) > 0 {
|
|
if merged.Extensions == nil {
|
|
merged.Extensions = make(map[string]any)
|
|
}
|
|
for k, v := range overlay.Extensions {
|
|
merged.Extensions[k] = v
|
|
}
|
|
}
|
|
return merged, nil
|
|
}
|
|
|
|
// validateConcreteProfile validates a resolved concrete profile: driver is
|
|
// known, operations are valid for the driver, URLs are well-formed, and
|
|
// capabilities are non-empty.
|
|
func validateConcreteProfile(id string, p ProtocolProfileConf) error {
|
|
if _, ok := validProtocolDrivers[p.Driver]; !ok {
|
|
return fmt.Errorf("profile %q: driver %q is not one of openai_chat/anthropic_messages/openai_responses", id, p.Driver)
|
|
}
|
|
if strings.TrimSpace(p.BaseURL) == "" {
|
|
return fmt.Errorf("profile %q: base_url must not be empty", id)
|
|
}
|
|
base, err := url.Parse(p.BaseURL)
|
|
if err != nil {
|
|
return fmt.Errorf("profile %q: base_url %q is not a valid URL: %w", id, p.BaseURL, err)
|
|
}
|
|
if (base.Scheme != "http" && base.Scheme != "https") || base.Host == "" {
|
|
return fmt.Errorf("profile %q: base_url %q must be an absolute http(s) URL", id, p.BaseURL)
|
|
}
|
|
if len(p.Operations) == 0 {
|
|
return fmt.Errorf("profile %q: operations must not be empty", id)
|
|
}
|
|
if len(p.Capabilities) == 0 {
|
|
return fmt.Errorf("profile %q: capabilities must not be empty", id)
|
|
}
|
|
capabilities := make(map[string]struct{}, len(p.Capabilities))
|
|
for _, capability := range p.Capabilities {
|
|
capability = strings.TrimSpace(capability)
|
|
if _, ok := validProtocolCapabilities[capability]; !ok {
|
|
return fmt.Errorf("profile %q: capability %q is not recognized", id, capability)
|
|
}
|
|
if _, duplicate := capabilities[capability]; duplicate {
|
|
return fmt.Errorf("profile %q: capability %q is duplicated", id, capability)
|
|
}
|
|
capabilities[capability] = struct{}{}
|
|
}
|
|
for op, path := range p.Operations {
|
|
if err := validateProfileOperation(p.Driver, op); err != nil {
|
|
return fmt.Errorf("profile %q: %w", id, err)
|
|
}
|
|
if err := validateProfileURL(p.BaseURL, path); err != nil {
|
|
return fmt.Errorf("profile %q: operation %q: %w", id, op, err)
|
|
}
|
|
if required := operationCapability[ProtocolOperation(op)]; required != "" {
|
|
if _, ok := capabilities[required]; !ok {
|
|
return fmt.Errorf("profile %q: operation %q requires capability %q", id, op, required)
|
|
}
|
|
}
|
|
}
|
|
if p.Auth.Header == "" {
|
|
return fmt.Errorf("profile %q: auth.header must not be empty", id)
|
|
}
|
|
if !validHTTPFieldName(p.Auth.Header) {
|
|
return fmt.Errorf("profile %q: auth.header %q is not a valid HTTP header name", id, p.Auth.Header)
|
|
}
|
|
authHeader := strings.ToLower(p.Auth.Header)
|
|
authScheme := strings.TrimSpace(p.Auth.Scheme)
|
|
if authHeader == "authorization" && !strings.EqualFold(authScheme, "Bearer") {
|
|
return fmt.Errorf("profile %q: Authorization auth requires Bearer scheme", id)
|
|
}
|
|
if authHeader != "authorization" && authScheme != "" {
|
|
return fmt.Errorf("profile %q: auth header %q must use a raw credential without scheme", id, p.Auth.Header)
|
|
}
|
|
switch p.Driver {
|
|
case ProtocolDriverOpenAIChat:
|
|
if _, ok := capabilities["chat"]; !ok {
|
|
return fmt.Errorf("profile %q: driver %q requires capability %q", id, p.Driver, "chat")
|
|
}
|
|
if _, ok := p.Operations[string(OperationChatCompletions)]; !ok {
|
|
return fmt.Errorf("profile %q: driver %q requires operation %q", id, p.Driver, OperationChatCompletions)
|
|
}
|
|
case ProtocolDriverAnthropicMessages:
|
|
if _, ok := capabilities["messages"]; !ok {
|
|
return fmt.Errorf("profile %q: driver %q requires capability %q", id, p.Driver, "messages")
|
|
}
|
|
if _, ok := p.Operations[string(OperationMessages)]; !ok {
|
|
return fmt.Errorf("profile %q: driver %q requires operation %q", id, p.Driver, OperationMessages)
|
|
}
|
|
case ProtocolDriverOpenAIResponses:
|
|
if _, ok := capabilities["responses"]; !ok {
|
|
return fmt.Errorf("profile %q: driver %q requires capability %q", id, p.Driver, "responses")
|
|
}
|
|
if _, ok := p.Operations[string(OperationResponses)]; !ok {
|
|
return fmt.Errorf("profile %q: driver %q requires operation %q", id, p.Driver, OperationResponses)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// validHTTPFieldName implements the RFC 9110 field-name token grammar. It is
|
|
// deliberately byte-oriented because HTTP field names are ASCII-only tokens.
|
|
func validHTTPFieldName(name string) bool {
|
|
if name == "" {
|
|
return false
|
|
}
|
|
for i := 0; i < len(name); i++ {
|
|
c := name[i]
|
|
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') {
|
|
continue
|
|
}
|
|
switch c {
|
|
case '!', '#', '$', '%', '&', '\'', '*', '+', '-', '.', '^', '_', '`', '|', '~':
|
|
continue
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// ResolveOperationURL resolves an operation id to its full URL. Absolute
|
|
// operation URLs are returned unchanged; relative paths are joined once to
|
|
// the normalized base URL. Missing operations return a typed error.
|
|
func (p ConcreteProtocolProfile) ResolveOperationURL(op string) (string, error) {
|
|
if strings.TrimSpace(op) == "" {
|
|
return "", fmt.Errorf("protocol profile %q: operation must not be empty", p.ID)
|
|
}
|
|
if err := validateProfileOperation(p.Driver, op); err != nil {
|
|
return "", fmt.Errorf("protocol profile %q: %w", p.ID, err)
|
|
}
|
|
path, ok := p.Operations[op]
|
|
if !ok {
|
|
return "", fmt.Errorf("protocol profile %q: operation %q is not supported", p.ID, op)
|
|
}
|
|
return resolveOperationURL(p.BaseURL, path)
|
|
}
|
|
|
|
// resolveOperationURL joins a base URL and operation path exactly once.
|
|
func resolveOperationURL(baseURL, opPath string) (string, error) {
|
|
trimmed := strings.TrimSpace(opPath)
|
|
if trimmed == "" {
|
|
return "", fmt.Errorf("operation path must not be empty")
|
|
}
|
|
if strings.HasPrefix(trimmed, "http://") || strings.HasPrefix(trimmed, "https://") {
|
|
return trimmed, nil
|
|
}
|
|
base, err := url.Parse(baseURL)
|
|
if err != nil {
|
|
return "", fmt.Errorf("base_url %q is not a valid URL: %w", baseURL, err)
|
|
}
|
|
operation, err := url.Parse(trimmed)
|
|
if err != nil {
|
|
return "", fmt.Errorf("operation path %q is not a valid URL path: %w", opPath, err)
|
|
}
|
|
basePath := strings.TrimRight(base.Path, "/")
|
|
base.Path = basePath + operation.Path
|
|
base.RawQuery = operation.RawQuery
|
|
base.Fragment = operation.Fragment
|
|
return base.String(), nil
|
|
}
|
|
|
|
// HasCapability reports whether the profile declares a capability.
|
|
func (p ConcreteProtocolProfile) HasCapability(cap string) bool {
|
|
for _, c := range p.Capabilities {
|
|
if c == cap {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// MapModel returns the upstream model id for a selected/canonical model. An
|
|
// absent mapping is an identity mapping.
|
|
func (p ConcreteProtocolProfile) MapModel(model string) string {
|
|
if mapped := strings.TrimSpace(p.ModelMapping[model]); mapped != "" {
|
|
return mapped
|
|
}
|
|
return model
|
|
}
|
|
|
|
// Clone returns a deep copy of the concrete profile.
|
|
func (p ConcreteProtocolProfile) Clone() ConcreteProtocolProfile {
|
|
return ConcreteProtocolProfile{
|
|
ID: p.ID,
|
|
ProtocolProfileConf: deepCopyProfileConf(p.ProtocolProfileConf),
|
|
}
|
|
}
|