iop/packages/go/config/protocol_profile.go
toki 892dc58369 fix(protocol-profile): 요청 경계와 후속 계획을 정리한다
Anthropic 전용 API key가 일반 OpenAI 요청에 적용되지 않도록 경계를 좁히고 잘못된 절대 operation URL을 fail-closed로 거부해야 한다.\n\n완료 Milestone archive와 principal credential 후속 agent-task를 같은 기준선에 맞춘다.
2026-08-01 12:52:56 +09:00

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),
}
}