caller별 예외 대신 요청 의미와 protocol profile capability로 operation을 선택해 tools와 effort 조합을 보존한다. 지원하지 않는 effort는 가장 가까운 하위 등급으로만 내리고 상향 매핑은 거부한다.
1015 lines
36 KiB
Go
1015 lines
36 KiB
Go
package config_test
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"iop/packages/go/config"
|
|
)
|
|
|
|
// --- S01: Overlay resolution, rejection, and legacy alias normalization ---
|
|
|
|
func TestProtocolProfileOverlayResolution(t *testing.T) {
|
|
catalog := map[string]config.ProtocolProfileConf{
|
|
"base": {
|
|
Driver: config.ProtocolDriverOpenAIChat,
|
|
BaseURL: "https://api.example.com/v1",
|
|
Operations: map[string]string{
|
|
"models": "/models",
|
|
},
|
|
Auth: config.ProtocolAuthConf{Header: "Authorization", Scheme: "Bearer"},
|
|
Capabilities: []string{"models", "chat"},
|
|
},
|
|
"overlay": {
|
|
Base: "base",
|
|
Operations: map[string]string{
|
|
"chat_completions": "/chat/completions",
|
|
},
|
|
Capabilities: []string{"models", "chat", "streaming"},
|
|
},
|
|
}
|
|
|
|
resolved, err := config.ResolveProtocolProfile("overlay", "", catalog)
|
|
if err != nil {
|
|
t.Fatalf("ResolveProtocolProfile: %v", err)
|
|
}
|
|
if resolved.ID != "overlay" {
|
|
t.Errorf("ID = %q, want %q", resolved.ID, "overlay")
|
|
}
|
|
if resolved.Driver != config.ProtocolDriverOpenAIChat {
|
|
t.Errorf("Driver = %q, want %q", resolved.Driver, config.ProtocolDriverOpenAIChat)
|
|
}
|
|
if resolved.BaseURL != "https://api.example.com/v1" {
|
|
t.Errorf("BaseURL = %q, want inherited value", resolved.BaseURL)
|
|
}
|
|
if _, ok := resolved.Operations["models"]; !ok {
|
|
t.Errorf("expected inherited operation models")
|
|
}
|
|
if _, ok := resolved.Operations["chat_completions"]; !ok {
|
|
t.Errorf("expected overlay operation chat_completions")
|
|
}
|
|
if len(resolved.Capabilities) != 3 {
|
|
t.Errorf("Capabilities = %v, want 3", resolved.Capabilities)
|
|
}
|
|
}
|
|
|
|
func TestProtocolProfileOverlayDeepCopyImmutability(t *testing.T) {
|
|
baseOps := map[string]string{"models": "/models"}
|
|
catalog := map[string]config.ProtocolProfileConf{
|
|
"base": {
|
|
Driver: config.ProtocolDriverOpenAIChat,
|
|
BaseURL: "https://api.example.com/v1",
|
|
Operations: baseOps,
|
|
Auth: config.ProtocolAuthConf{Header: "Authorization", Scheme: "Bearer"},
|
|
Capabilities: []string{"models", "chat"},
|
|
},
|
|
"overlay": {
|
|
Base: "base",
|
|
Operations: map[string]string{"chat_completions": "/chat/completions"},
|
|
},
|
|
}
|
|
|
|
resolved, err := config.ResolveProtocolProfile("overlay", "", catalog)
|
|
if err != nil {
|
|
t.Fatalf("ResolveProtocolProfile: %v", err)
|
|
}
|
|
|
|
// Mutate the resolved snapshot and verify the catalog is unaffected.
|
|
resolved.Operations["injected"] = "/injected"
|
|
if _, ok := catalog["base"].Operations["injected"]; ok {
|
|
t.Error("mutating resolved profile affected catalog base")
|
|
}
|
|
if _, ok := catalog["overlay"].Operations["injected"]; ok {
|
|
t.Error("mutating resolved profile affected catalog overlay")
|
|
}
|
|
}
|
|
|
|
func TestProtocolProfileReasoningEffortNormalization(t *testing.T) {
|
|
profile, err := config.ResolveProtocolProfile("openai", "", config.BuiltInProtocolProfileCatalog())
|
|
if err != nil {
|
|
t.Fatalf("ResolveProtocolProfile: %v", err)
|
|
}
|
|
if got, ok := profile.MapReasoningEffort(config.OperationResponses, "xhigh", true); !ok || got != "xhigh" {
|
|
t.Fatalf("Responses xhigh with tools = %q,%t; want xhigh,true", got, ok)
|
|
}
|
|
if got, ok := profile.MapReasoningEffort(config.OperationChatCompletions, "high", true); ok || got != "" {
|
|
t.Fatalf("Chat high with tools = %q,%t; want unsupported", got, ok)
|
|
}
|
|
if got, ok := profile.MapReasoningEffort(config.OperationChatCompletions, "high", false); !ok || got != "high" {
|
|
t.Fatalf("Chat high without tools = %q,%t; want high,true", got, ok)
|
|
}
|
|
}
|
|
|
|
func TestProtocolProfileEffortNormalizationOverlayAndValidation(t *testing.T) {
|
|
catalog := config.BuiltInProtocolProfileCatalog()
|
|
catalog["four-grade-openai"] = config.ProtocolProfileConf{
|
|
Base: "openai",
|
|
Normalization: config.ProtocolNormalizationConf{Effort: map[string]config.ProtocolEffortMappingConf{
|
|
string(config.OperationResponses): {
|
|
WithTools: true,
|
|
Wire: config.ProtocolEffortWireOpenAIResponses,
|
|
Levels: map[string]string{
|
|
"none": "none", "low": "low", "medium": "medium",
|
|
"high": "high", "xhigh": "xhigh",
|
|
},
|
|
},
|
|
}},
|
|
}
|
|
profile, err := config.ResolveProtocolProfile("four-grade-openai", "", catalog)
|
|
if err != nil {
|
|
t.Fatalf("ResolveProtocolProfile: %v", err)
|
|
}
|
|
if got, ok := profile.MapReasoningEffort(config.OperationResponses, "max", true); !ok || got != "xhigh" {
|
|
t.Fatalf("mapped max = %q,%t; want xhigh,true", got, ok)
|
|
}
|
|
|
|
bad := config.BuiltInProtocolProfileCatalog()
|
|
bad["bad-effort"] = config.ProtocolProfileConf{
|
|
Base: "openai",
|
|
Normalization: config.ProtocolNormalizationConf{Effort: map[string]config.ProtocolEffortMappingConf{
|
|
"messages": {Wire: config.ProtocolEffortWireAnthropicMessage, Levels: map[string]string{"high": "high"}},
|
|
}},
|
|
}
|
|
if _, err := config.ResolveProtocolProfile("bad-effort", "", bad); err == nil || !strings.Contains(err.Error(), "not declared") {
|
|
t.Fatalf("expected undeclared effort operation error, got %v", err)
|
|
}
|
|
|
|
badUpgrade := config.BuiltInProtocolProfileCatalog()
|
|
badUpgrade["bad-upgrade"] = config.ProtocolProfileConf{
|
|
Base: "openai",
|
|
Normalization: config.ProtocolNormalizationConf{Effort: map[string]config.ProtocolEffortMappingConf{
|
|
string(config.OperationResponses): {
|
|
WithTools: true,
|
|
Wire: config.ProtocolEffortWireOpenAIResponses,
|
|
Levels: map[string]string{"high": "max"},
|
|
},
|
|
}},
|
|
}
|
|
if _, err := config.ResolveProtocolProfile("bad-upgrade", "", badUpgrade); err == nil || !strings.Contains(err.Error(), "upgrades") {
|
|
t.Fatalf("expected effort upgrade error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestProtocolProfileOverlayRejected(t *testing.T) {
|
|
t.Run("cycle", func(t *testing.T) {
|
|
catalog := map[string]config.ProtocolProfileConf{
|
|
"a": {Base: "b", Driver: config.ProtocolDriverOpenAIChat, BaseURL: "https://a/v1", Operations: map[string]string{"models": "/m"}, Capabilities: []string{"chat"}},
|
|
"b": {Base: "a", Driver: config.ProtocolDriverOpenAIChat, BaseURL: "https://b/v1", Operations: map[string]string{"models": "/m"}, Capabilities: []string{"chat"}},
|
|
}
|
|
_, err := config.ResolveProtocolProfile("a", "", catalog)
|
|
if err == nil || !strings.Contains(err.Error(), "cycle") {
|
|
t.Fatalf("expected cycle error, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("unknown_base", func(t *testing.T) {
|
|
catalog := map[string]config.ProtocolProfileConf{
|
|
"overlay": {Base: "nonexistent", Driver: config.ProtocolDriverOpenAIChat, BaseURL: "https://a/v1", Operations: map[string]string{"models": "/m"}, Capabilities: []string{"chat"}},
|
|
}
|
|
_, err := config.ResolveProtocolProfile("overlay", "", catalog)
|
|
if err == nil || !strings.Contains(err.Error(), "unknown") {
|
|
t.Fatalf("expected unknown base error, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("conflicting_driver", func(t *testing.T) {
|
|
catalog := map[string]config.ProtocolProfileConf{
|
|
"base": {Driver: config.ProtocolDriverOpenAIChat, BaseURL: "https://a/v1", Operations: map[string]string{"models": "/m"}, Capabilities: []string{"chat"}},
|
|
"overlay": {Base: "base", Driver: config.ProtocolDriverAnthropicMessages, BaseURL: "https://a/v1", Operations: map[string]string{"models": "/m", "messages": "/msg"}, Capabilities: []string{"messages"}},
|
|
}
|
|
_, err := config.ResolveProtocolProfile("overlay", "", catalog)
|
|
if err == nil || !strings.Contains(err.Error(), "driver") {
|
|
t.Fatalf("expected driver conflict error, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("invalid_operation", func(t *testing.T) {
|
|
catalog := map[string]config.ProtocolProfileConf{
|
|
"bad": {Driver: config.ProtocolDriverOpenAIChat, BaseURL: "https://a/v1", Operations: map[string]string{"messages": "/msg"}, Capabilities: []string{"chat"}},
|
|
}
|
|
_, err := config.ResolveProtocolProfile("bad", "", catalog)
|
|
if err == nil || !strings.Contains(err.Error(), "messages") {
|
|
t.Fatalf("expected invalid operation error, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("invalid_auth_header", func(t *testing.T) {
|
|
catalog := map[string]config.ProtocolProfileConf{
|
|
"bad": {Driver: config.ProtocolDriverOpenAIChat, BaseURL: "https://a/v1", Operations: map[string]string{"models": "/m", "chat_completions": "/chat"}, Auth: config.ProtocolAuthConf{Header: ""}, Capabilities: []string{"models", "chat"}},
|
|
}
|
|
_, err := config.ResolveProtocolProfile("bad", "", catalog)
|
|
if err == nil || !strings.Contains(err.Error(), "auth") {
|
|
t.Fatalf("expected auth validation error, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("empty_capabilities", func(t *testing.T) {
|
|
catalog := map[string]config.ProtocolProfileConf{
|
|
"bad": {Driver: config.ProtocolDriverOpenAIChat, BaseURL: "https://a/v1", Operations: map[string]string{"models": "/m"}, Capabilities: []string{}},
|
|
}
|
|
_, err := config.ResolveProtocolProfile("bad", "", catalog)
|
|
if err == nil || !strings.Contains(err.Error(), "capabilities") {
|
|
t.Fatalf("expected capabilities error, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("malformed_absolute_url", func(t *testing.T) {
|
|
catalog := map[string]config.ProtocolProfileConf{
|
|
"bad": {Driver: config.ProtocolDriverOpenAIChat, BaseURL: "https://a/v1", Operations: map[string]string{"models": "://not-a-url"}, Capabilities: []string{"chat"}},
|
|
}
|
|
_, err := config.ResolveProtocolProfile("bad", "", catalog)
|
|
if err == nil || !strings.Contains(err.Error(), "operation") {
|
|
t.Fatalf("expected URL validation error, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("absolute_url_without_host", func(t *testing.T) {
|
|
catalog := map[string]config.ProtocolProfileConf{
|
|
"bad": {
|
|
Driver: config.ProtocolDriverOpenAIChat,
|
|
BaseURL: "https://a/v1",
|
|
Operations: map[string]string{"chat_completions": "https:///v1/chat/completions"},
|
|
Auth: config.ProtocolAuthConf{Header: "Authorization", Scheme: "Bearer"},
|
|
Capabilities: []string{"chat"},
|
|
},
|
|
}
|
|
_, err := config.ResolveProtocolProfile("bad", "", catalog)
|
|
if err == nil || !strings.Contains(err.Error(), "absolute http(s) URL") {
|
|
t.Fatalf("expected absolute URL host validation error, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("relative_path_no_leading_slash", func(t *testing.T) {
|
|
catalog := map[string]config.ProtocolProfileConf{
|
|
"bad": {Driver: config.ProtocolDriverOpenAIChat, BaseURL: "https://a/v1", Operations: map[string]string{"models": "models"}, Capabilities: []string{"chat"}},
|
|
}
|
|
_, err := config.ResolveProtocolProfile("bad", "", catalog)
|
|
if err == nil || !strings.Contains(err.Error(), "leading") {
|
|
t.Fatalf("expected leading slash error, got %v", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestLegacyProviderAliasProfileNormalization(t *testing.T) {
|
|
cases := []struct {
|
|
legacyType string
|
|
wantID string
|
|
}{
|
|
{"vllm", "openai"},
|
|
{"openai_api", "openai"},
|
|
{"openai_compat", "openai"},
|
|
{"vllm-mlx", "openai"},
|
|
{"lemonade", "openai"},
|
|
{"sglang", "openai"},
|
|
{"seulgivibe_claude", "seulgi_messages"},
|
|
{"seulgivibe_openai", "seulgi_chat"},
|
|
{"ollama", ""},
|
|
{"unknown_type", ""},
|
|
}
|
|
for _, c := range cases {
|
|
got := config.LegacyProviderTypeToProfile(c.legacyType)
|
|
if got != c.wantID {
|
|
t.Errorf("LegacyProviderTypeToProfile(%q) = %q, want %q", c.legacyType, got, c.wantID)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLegacyProviderAliasResolvesToBuiltIn(t *testing.T) {
|
|
// No explicit selector: legacy type should normalize to the built-in.
|
|
resolved, err := config.ResolveProtocolProfile("", "vllm", config.BuiltInProtocolProfiles)
|
|
if err != nil {
|
|
t.Fatalf("ResolveProtocolProfile: %v", err)
|
|
}
|
|
if resolved.ID != "openai" {
|
|
t.Errorf("ID = %q, want %q", resolved.ID, "openai")
|
|
}
|
|
if resolved.Driver != config.ProtocolDriverOpenAIChat {
|
|
t.Errorf("Driver = %q, want %q", resolved.Driver, config.ProtocolDriverOpenAIChat)
|
|
}
|
|
}
|
|
|
|
func TestExplicitProfilePrecedenceOverLegacyType(t *testing.T) {
|
|
// Explicit selector should win over legacy type mapping.
|
|
resolved, err := config.ResolveProtocolProfile("anthropic", "vllm", config.BuiltInProtocolProfiles)
|
|
if err != nil {
|
|
t.Fatalf("ResolveProtocolProfile: %v", err)
|
|
}
|
|
if resolved.ID != "anthropic" {
|
|
t.Errorf("ID = %q, want %q", resolved.ID, "anthropic")
|
|
}
|
|
if resolved.Driver != config.ProtocolDriverAnthropicMessages {
|
|
t.Errorf("Driver = %q, want %q", resolved.Driver, config.ProtocolDriverAnthropicMessages)
|
|
}
|
|
}
|
|
|
|
func TestProtocolProfileMultiLevelOverlay(t *testing.T) {
|
|
catalog := map[string]config.ProtocolProfileConf{
|
|
"l1": {
|
|
Driver: config.ProtocolDriverOpenAIChat,
|
|
BaseURL: "https://l1/v1",
|
|
Operations: map[string]string{"models": "/models"},
|
|
Auth: config.ProtocolAuthConf{Header: "Authorization", Scheme: "Bearer"},
|
|
Capabilities: []string{"models", "chat"},
|
|
},
|
|
"l2": {
|
|
Base: "l1",
|
|
BaseURL: "https://l2/v1",
|
|
Operations: map[string]string{"chat_completions": "/chat/completions"},
|
|
},
|
|
"l3": {
|
|
Base: "l2",
|
|
Capabilities: []string{"models", "chat", "streaming"},
|
|
},
|
|
}
|
|
resolved, err := config.ResolveProtocolProfile("l3", "", catalog)
|
|
if err != nil {
|
|
t.Fatalf("ResolveProtocolProfile: %v", err)
|
|
}
|
|
if resolved.BaseURL != "https://l2/v1" {
|
|
t.Errorf("BaseURL = %q, want %q", resolved.BaseURL, "https://l2/v1")
|
|
}
|
|
if _, ok := resolved.Operations["models"]; !ok {
|
|
t.Error("expected inherited operation models")
|
|
}
|
|
if _, ok := resolved.Operations["chat_completions"]; !ok {
|
|
t.Error("expected inherited operation chat_completions")
|
|
}
|
|
if len(resolved.Capabilities) != 3 {
|
|
t.Errorf("Capabilities = %v, want 3", resolved.Capabilities)
|
|
}
|
|
}
|
|
|
|
func TestProtocolProfileCustomOverlay(t *testing.T) {
|
|
catalog := map[string]config.ProtocolProfileConf{
|
|
"openai": config.BuiltInProtocolProfiles["openai"],
|
|
"my-proxy": {
|
|
Base: "openai",
|
|
BaseURL: "https://my-proxy.example.invalid/v1",
|
|
},
|
|
}
|
|
resolved, err := config.ResolveProtocolProfile("my-proxy", "", catalog)
|
|
if err != nil {
|
|
t.Fatalf("ResolveProtocolProfile: %v", err)
|
|
}
|
|
if resolved.BaseURL != "https://my-proxy.example.invalid/v1" {
|
|
t.Errorf("BaseURL = %q, want overridden value", resolved.BaseURL)
|
|
}
|
|
if resolved.ID != "my-proxy" {
|
|
t.Errorf("ID = %q, want %q", resolved.ID, "my-proxy")
|
|
}
|
|
}
|
|
|
|
// --- S03: Built-in catalog admission ---
|
|
|
|
func TestBuiltInProtocolProfileCatalog(t *testing.T) {
|
|
expectedIDs := []string{
|
|
"anthropic", "gemini", "glm", "glm_coding", "grok", "kimi", "minimax_chat", "minimax_messages",
|
|
"mimo_chat", "mimo_messages",
|
|
"openai", "seulgi_chat", "seulgi_messages",
|
|
}
|
|
for _, id := range expectedIDs {
|
|
if _, ok := config.BuiltInProtocolProfiles[id]; !ok {
|
|
t.Fatalf("built-in profile %q not found", id)
|
|
}
|
|
resolved, err := config.ResolveProtocolProfile(id, "", config.BuiltInProtocolProfiles)
|
|
if err != nil {
|
|
t.Fatalf("ResolveProtocolProfile(%q): %v", id, err)
|
|
}
|
|
if resolved.ID != id {
|
|
t.Errorf("profile %q: ID = %q, want %q", id, resolved.ID, id)
|
|
}
|
|
if resolved.Base != "" {
|
|
t.Errorf("profile %q: Base should be empty in concrete snapshot, got %q", id, resolved.Base)
|
|
}
|
|
}
|
|
|
|
// Verify all expected IDs are present
|
|
for _, id := range expectedIDs {
|
|
if _, ok := config.BuiltInProtocolProfiles[id]; !ok {
|
|
t.Errorf("expected built-in profile %q", id)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuiltInProtocolProfileOperations(t *testing.T) {
|
|
t.Run("openai_has_chat_completions", func(t *testing.T) {
|
|
r, err := config.ResolveProtocolProfile("openai", "", config.BuiltInProtocolProfiles)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, ok := r.Operations["chat_completions"]; !ok {
|
|
t.Error("openai should declare chat_completions")
|
|
}
|
|
if _, ok := r.Operations["responses"]; !ok {
|
|
t.Error("openai should declare responses")
|
|
}
|
|
})
|
|
|
|
t.Run("anthropic_has_messages", func(t *testing.T) {
|
|
r, err := config.ResolveProtocolProfile("anthropic", "", config.BuiltInProtocolProfiles)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, ok := r.Operations["messages"]; !ok {
|
|
t.Error("anthropic should declare messages")
|
|
}
|
|
if _, ok := r.Operations["chat_completions"]; ok {
|
|
t.Error("anthropic should NOT declare chat_completions")
|
|
}
|
|
})
|
|
|
|
t.Run("seulgi_messages_has_messages", func(t *testing.T) {
|
|
r, err := config.ResolveProtocolProfile("seulgi_messages", "", config.BuiltInProtocolProfiles)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, ok := r.Operations["messages"]; !ok {
|
|
t.Error("seulgi_messages should declare messages")
|
|
}
|
|
})
|
|
|
|
t.Run("seulgi_chat_has_chat_completions", func(t *testing.T) {
|
|
r, err := config.ResolveProtocolProfile("seulgi_chat", "", config.BuiltInProtocolProfiles)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, ok := r.Operations["chat_completions"]; !ok {
|
|
t.Error("seulgi_chat should declare chat_completions")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestBuiltInProtocolProfileURLs(t *testing.T) {
|
|
t.Run("anthropic_absolute_path", func(t *testing.T) {
|
|
r, err := config.ResolveProtocolProfile("anthropic", "", config.BuiltInProtocolProfiles)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
url, err := r.ResolveOperationURL("messages")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.HasSuffix(url, "/v1/messages") {
|
|
t.Errorf("expected /v1/messages suffix, got %q", url)
|
|
}
|
|
})
|
|
|
|
t.Run("gemini_v1beta_openai", func(t *testing.T) {
|
|
r, err := config.ResolveProtocolProfile("gemini", "", config.BuiltInProtocolProfiles)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
url, err := r.ResolveOperationURL("chat_completions")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.HasSuffix(url, "/v1beta/openai/chat/completions") {
|
|
t.Errorf("expected /v1beta/openai/chat/completions suffix, got %q", url)
|
|
}
|
|
})
|
|
|
|
t.Run("glm_api_paas_v4", func(t *testing.T) {
|
|
r, err := config.ResolveProtocolProfile("glm", "", config.BuiltInProtocolProfiles)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
url, err := r.ResolveOperationURL("chat_completions")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.HasSuffix(url, "/api/paas/v4/chat/completions") {
|
|
t.Errorf("expected /api/paas/v4/chat/completions suffix, got %q", url)
|
|
}
|
|
})
|
|
|
|
t.Run("glm_coding_api_coding_paas_v4", func(t *testing.T) {
|
|
r, err := config.ResolveProtocolProfile("glm_coding", "", config.BuiltInProtocolProfiles)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
url, err := r.ResolveOperationURL("chat_completions")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.HasSuffix(url, "/api/coding/paas/v4/chat/completions") {
|
|
t.Errorf("expected /api/coding/paas/v4/chat/completions suffix, got %q", url)
|
|
}
|
|
})
|
|
|
|
t.Run("glm_coding_models_url", func(t *testing.T) {
|
|
r, err := config.ResolveProtocolProfile("glm_coding", "", config.BuiltInProtocolProfiles)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
url, err := r.ResolveOperationURL("models")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.HasSuffix(url, "/api/coding/paas/v4/models") {
|
|
t.Errorf("expected /api/coding/paas/v4/models suffix, got %q", url)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestProtocolProfileUnsupportedOperationRejected(t *testing.T) {
|
|
r, err := config.ResolveProtocolProfile("anthropic", "", config.BuiltInProtocolProfiles)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, err = r.ResolveOperationURL("chat_completions")
|
|
if err == nil || !strings.Contains(err.Error(), "not supported") {
|
|
t.Fatalf("expected unsupported operation error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestProtocolProfileResolveOperationURLAbsolute(t *testing.T) {
|
|
catalog := map[string]config.ProtocolProfileConf{
|
|
"custom": {
|
|
Driver: config.ProtocolDriverOpenAIChat,
|
|
BaseURL: "https://base.example.com/v1",
|
|
Operations: map[string]string{
|
|
"models": "https://override.example.com/v1/models",
|
|
"chat_completions": "/chat/completions",
|
|
},
|
|
Auth: config.ProtocolAuthConf{Header: "Authorization", Scheme: "Bearer"},
|
|
Capabilities: []string{"models", "chat"},
|
|
},
|
|
}
|
|
r, err := config.ResolveProtocolProfile("custom", "", catalog)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
url, err := r.ResolveOperationURL("models")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if url != "https://override.example.com/v1/models" {
|
|
t.Errorf("expected absolute URL unchanged, got %q", url)
|
|
}
|
|
}
|
|
|
|
func TestProtocolProfileClone(t *testing.T) {
|
|
r, err := config.ResolveProtocolProfile("openai", "", config.BuiltInProtocolProfiles)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
clone := r.Clone()
|
|
if clone.ID != r.ID {
|
|
t.Errorf("ID mismatch: %q vs %q", clone.ID, r.ID)
|
|
}
|
|
clone.Operations["injected"] = "/injected"
|
|
if _, ok := r.Operations["injected"]; ok {
|
|
t.Error("clone mutation affected original")
|
|
}
|
|
}
|
|
|
|
func TestProtocolProfileProviderEndpointOverlay(t *testing.T) {
|
|
cfg, err := loadProtocolProfileYAML(t, `
|
|
nodes:
|
|
- id: node-profile
|
|
providers:
|
|
- id: provider-profile
|
|
type: vllm
|
|
category: api
|
|
endpoint: http://127.0.0.1:18080/v1/
|
|
base_url: http://127.0.0.1:18081/ignored
|
|
models: [upstream-model]
|
|
models:
|
|
- id: canonical-model
|
|
providers:
|
|
provider-profile: upstream-model
|
|
`)
|
|
if err != nil {
|
|
t.Fatalf("LoadEdge: %v", err)
|
|
}
|
|
profile := cfg.Nodes[0].Providers[0].RuntimeProfile
|
|
if profile == nil || profile.BaseURL != "http://127.0.0.1:18080/v1" {
|
|
t.Fatalf("runtime profile base_url = %#v", profile)
|
|
}
|
|
url, err := profile.ResolveOperationURL(string(config.OperationChatCompletions))
|
|
if err != nil || url != "http://127.0.0.1:18080/v1/chat/completions" {
|
|
t.Fatalf("resolved chat URL = %q, err=%v", url, err)
|
|
}
|
|
fresh := config.BuiltInProtocolProfileCatalog()["openai"]
|
|
if fresh.BaseURL != "https://api.openai.com/v1" {
|
|
t.Fatalf("provider endpoint mutated built-in: %q", fresh.BaseURL)
|
|
}
|
|
rootCfg, err := loadProtocolProfileYAML(t, `
|
|
nodes:
|
|
- id: node-root
|
|
providers:
|
|
- id: provider-root
|
|
type: lemonade
|
|
category: api
|
|
endpoint: http://127.0.0.1:18082
|
|
models: [upstream-model]
|
|
models:
|
|
- id: root-model
|
|
providers: {provider-root: upstream-model}
|
|
`)
|
|
if err != nil {
|
|
t.Fatalf("LoadEdge root endpoint: %v", err)
|
|
}
|
|
rootProfile := rootCfg.Nodes[0].Providers[0].RuntimeProfile
|
|
if rootProfile == nil || rootProfile.BaseURL != "http://127.0.0.1:18082/v1" {
|
|
t.Fatalf("root endpoint did not retain profile prefix: %#v", rootProfile)
|
|
}
|
|
}
|
|
|
|
func TestProtocolProfileCatalogCollisionRejected(t *testing.T) {
|
|
_, err := loadProtocolProfileYAML(t, `
|
|
protocol_profiles:
|
|
openai:
|
|
driver: openai_chat
|
|
base_url: https://collision.example.invalid/v1
|
|
operations: {chat_completions: /chat/completions}
|
|
auth: {header: Authorization, scheme: Bearer}
|
|
capabilities: [chat]
|
|
`)
|
|
if err == nil || !strings.Contains(err.Error(), "reserved") {
|
|
t.Fatalf("expected reserved built-in collision error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestProtocolProfileCapabilityMatrixRejected(t *testing.T) {
|
|
base := config.ProtocolProfileConf{
|
|
Driver: config.ProtocolDriverOpenAIChat,
|
|
BaseURL: "https://matrix.example.invalid/v1",
|
|
Operations: map[string]string{"chat_completions": "/chat/completions"},
|
|
Auth: config.ProtocolAuthConf{Header: "Authorization", Scheme: "Bearer"},
|
|
Capabilities: []string{"chat"},
|
|
}
|
|
for _, tc := range []struct {
|
|
name string
|
|
edit func(*config.ProtocolProfileConf)
|
|
want string
|
|
}{
|
|
{name: "unknown_capability", edit: func(p *config.ProtocolProfileConf) { p.Capabilities = append(p.Capabilities, "telepathy") }, want: "not recognized"},
|
|
{name: "operation_without_capability", edit: func(p *config.ProtocolProfileConf) { p.Operations["models"] = "/models" }, want: "requires capability"},
|
|
{name: "wrong_driver_operation", edit: func(p *config.ProtocolProfileConf) { p.Operations["messages"] = "/messages" }, want: "not supported"},
|
|
{name: "raw_header_with_scheme", edit: func(p *config.ProtocolProfileConf) {
|
|
p.Auth = config.ProtocolAuthConf{Header: "X-Api-Key", Scheme: "Bearer"}
|
|
}, want: "raw credential"},
|
|
{name: "authorization_without_bearer", edit: func(p *config.ProtocolProfileConf) { p.Auth.Scheme = "Token" }, want: "Bearer"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
profile := base
|
|
profile.Operations = map[string]string{"chat_completions": "/chat/completions"}
|
|
profile.Capabilities = append([]string(nil), base.Capabilities...)
|
|
tc.edit(&profile)
|
|
_, err := config.ResolveProtocolProfile("bad", "", map[string]config.ProtocolProfileConf{"bad": profile})
|
|
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
|
t.Fatalf("expected %q error, got %v", tc.want, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestProtocolProfileChatOnlyTokenCounter(t *testing.T) {
|
|
base := func(profile, counter string) string {
|
|
return `
|
|
nodes:
|
|
- id: node-counter
|
|
providers:
|
|
- id: provider-counter
|
|
type: openai_api
|
|
category: api
|
|
profile: ` + profile + `
|
|
endpoint: http://127.0.0.1:18080/v1
|
|
models: [upstream-model]
|
|
models:
|
|
- id: canonical-model
|
|
providers:
|
|
provider-counter: upstream-model
|
|
token_counter:
|
|
` + counter
|
|
}
|
|
if _, err := loadProtocolProfileYAML(t, base("openai", " mode: deterministic\n")); err != nil {
|
|
t.Fatalf("Chat deterministic counter: %v", err)
|
|
}
|
|
for _, tc := range []struct {
|
|
name, profile, counter, want string
|
|
}{
|
|
{name: "messages", profile: "anthropic", counter: " mode: deterministic\n", want: "Chat-only"},
|
|
{name: "estimate_range", profile: "openai", counter: " mode: estimate\n per_1k_input: 1001\n", want: "between 1 and 1000"},
|
|
{name: "deterministic_parameter", profile: "openai", counter: " mode: deterministic\n per_1k_input: 250\n", want: "must be 0"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
_, err := loadProtocolProfileYAML(t, base(tc.profile, tc.counter))
|
|
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
|
t.Fatalf("expected %q error, got %v", tc.want, err)
|
|
}
|
|
})
|
|
}
|
|
_, err := loadProtocolProfileYAML(t, `
|
|
protocol_profiles:
|
|
responses_custom:
|
|
driver: openai_responses
|
|
base_url: https://responses.example.invalid/v1
|
|
operations: {responses: /responses}
|
|
auth: {header: Authorization, scheme: Bearer}
|
|
capabilities: [responses]
|
|
nodes:
|
|
- id: node-responses-counter
|
|
providers:
|
|
- id: responses-counter
|
|
type: openai_api
|
|
category: api
|
|
profile: responses_custom
|
|
models: [upstream-response]
|
|
models:
|
|
- id: canonical-response
|
|
providers: {responses-counter: upstream-response}
|
|
token_counter: {mode: deterministic}
|
|
`)
|
|
if err == nil || !strings.Contains(err.Error(), "Chat-only") {
|
|
t.Fatalf("expected Responses profile token-counter rejection, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestBuiltInProtocolProfileCatalogImmutable(t *testing.T) {
|
|
first := config.BuiltInProtocolProfileCatalog()
|
|
openai := first["openai"]
|
|
openai.Operations[string(config.OperationChatCompletions)] = "/mutated"
|
|
openai.Capabilities[0] = "mutated"
|
|
first["openai"] = openai
|
|
fresh := config.BuiltInProtocolProfileCatalog()["openai"]
|
|
if fresh.Operations[string(config.OperationChatCompletions)] != "/chat/completions" || fresh.Capabilities[0] == "mutated" {
|
|
t.Fatalf("built-in catalog mutation leaked: %+v", fresh)
|
|
}
|
|
}
|
|
|
|
// TestProtocolProfileHeaderValidation covers the RFC 9110 field-name token
|
|
// grammar independently from Go's permissive MIME canonicalization helpers.
|
|
func TestProtocolProfileHeaderValidation(t *testing.T) {
|
|
validProfile := func(header string) config.ProtocolProfileConf {
|
|
return config.ProtocolProfileConf{
|
|
Driver: config.ProtocolDriverOpenAIChat, BaseURL: "https://example.invalid/v1",
|
|
Operations: map[string]string{"chat_completions": "/chat/completions"},
|
|
Auth: config.ProtocolAuthConf{Header: header}, Capabilities: []string{"chat"},
|
|
}
|
|
}
|
|
for _, tc := range []struct {
|
|
name, header string
|
|
valid bool
|
|
}{
|
|
{name: "token_punctuation", header: "X!#$%&'*+-.^_`|~Key", valid: true},
|
|
{name: "empty", header: ""},
|
|
{name: "leading_space", header: " X-Key"},
|
|
{name: "trailing_space", header: "X-Key "},
|
|
{name: "embedded_space", header: "X Key"},
|
|
{name: "colon", header: "X:Key"},
|
|
{name: "tab", header: "X\tKey"},
|
|
{name: "control", header: "X\x01Key"},
|
|
{name: "non_ascii", header: "X-Kéy"},
|
|
{name: "separator", header: "X(Key)"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
profile := validProfile(tc.header)
|
|
_, err := config.ResolveProtocolProfile("header-test", "", map[string]config.ProtocolProfileConf{"header-test": profile})
|
|
if tc.valid && err != nil {
|
|
t.Fatalf("valid header rejected: %v", err)
|
|
}
|
|
if !tc.valid && (err == nil || !strings.Contains(err.Error(), "auth")) {
|
|
t.Fatalf("invalid header %q was not rejected: %v", tc.header, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestProtocolProfileBuiltInAuthMatrix is intentionally literal so catalog
|
|
// drift cannot update the expected values by sharing production data. MiniMax
|
|
// and MiMo Anthropic-compatible auth was rechecked against official provider
|
|
// documentation on 2026-08-01:
|
|
// https://platform.minimax.io/docs/token-plan/other-tools
|
|
// https://mimo.mi.com/docs/en-US/quick-start/summary/first-api-call
|
|
func TestProtocolProfileBuiltInAuthMatrix(t *testing.T) {
|
|
type wantAuth struct{ header, scheme string }
|
|
want := map[string]wantAuth{
|
|
"openai": {"Authorization", "Bearer"}, "gemini": {"Authorization", "Bearer"},
|
|
"anthropic": {"x-api-key", ""}, "glm": {"Authorization", "Bearer"},
|
|
"glm_coding": {"Authorization", "Bearer"},
|
|
"kimi": {"Authorization", "Bearer"}, "minimax_chat": {"Authorization", "Bearer"},
|
|
"minimax_messages": {"Authorization", "Bearer"}, "mimo_chat": {"Authorization", "Bearer"},
|
|
"mimo_messages": {"api-key", ""}, "grok": {"Authorization", "Bearer"},
|
|
"seulgi_chat": {"Authorization", "Bearer"}, "seulgi_messages": {"x-api-key", ""},
|
|
}
|
|
if len(config.BuiltInProtocolProfiles) != len(want) {
|
|
t.Fatalf("built-in profile count = %d, want %d", len(config.BuiltInProtocolProfiles), len(want))
|
|
}
|
|
for id, expected := range want {
|
|
resolved, err := config.ResolveProtocolProfile(id, "", config.BuiltInProtocolProfiles)
|
|
if err != nil {
|
|
t.Fatalf("ResolveProtocolProfile(%q): %v", id, err)
|
|
}
|
|
if resolved.Auth.Header != expected.header || resolved.Auth.Scheme != expected.scheme {
|
|
t.Errorf("%s auth = {%q %q}, want {%q %q}", id, resolved.Auth.Header, resolved.Auth.Scheme, expected.header, expected.scheme)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestBuiltInGLMProtocolProfiles asserts that both GLM profiles carry the
|
|
// expected capabilities, lack Responses, and expose only models+chat operations.
|
|
// Official docs rechecked on 2026-08-02:
|
|
// https://docs.z.ai/api-reference/introduction
|
|
// https://docs.z.ai/api-reference/llm/chat-completion
|
|
// https://docs.z.ai/scenario-example/develop-tools/others
|
|
func TestBuiltInGLMProtocolProfiles(t *testing.T) {
|
|
for _, id := range []string{"glm", "glm_coding"} {
|
|
t.Run(id, func(t *testing.T) {
|
|
r, err := config.ResolveProtocolProfile(id, "", config.BuiltInProtocolProfiles)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, want := range []string{"models", "chat", "streaming", "tool_calling"} {
|
|
if !r.HasCapability(want) {
|
|
t.Errorf("%s: missing capability %q", id, want)
|
|
}
|
|
}
|
|
if r.HasCapability("responses") {
|
|
t.Errorf("%s: must not advertise responses capability", id)
|
|
}
|
|
if _, ok := r.Operations[string(config.OperationResponses)]; ok {
|
|
t.Errorf("%s: must not declare responses operation", id)
|
|
}
|
|
for _, op := range []config.ProtocolOperation{config.OperationModels, config.OperationChatCompletions} {
|
|
if _, ok := r.Operations[string(op)]; !ok {
|
|
t.Errorf("%s: missing operation %q", id, op)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestGLMProfilesRemainDistinctThroughProviderPoolConfig loads a provider-pool
|
|
// mapping with two distinct external model IDs that route to two distinct
|
|
// provider IDs and two distinct GLM profiles, then asserts no cross-mapping
|
|
// and both map to the same upstream model.
|
|
// Official endpoint distinction rechecked on 2026-08-02:
|
|
// https://docs.z.ai/scenario-example/develop-tools/others
|
|
func TestGLMProfilesRemainDistinctThroughProviderPoolConfig(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
yaml := `
|
|
models:
|
|
- id: "glm-5.1-api"
|
|
providers:
|
|
glm-api: "glm-5.1"
|
|
- id: "glm-5.1-coding"
|
|
providers:
|
|
glm-coding: "glm-5.1"
|
|
nodes:
|
|
- id: "node-glm"
|
|
providers:
|
|
- id: "glm-api"
|
|
type: "openai_api"
|
|
category: "api"
|
|
profile: "glm"
|
|
endpoint: "https://api.z.ai/api/paas/v4"
|
|
models: ["glm-5.1"]
|
|
capacity: 1
|
|
- id: "glm-coding"
|
|
type: "openai_api"
|
|
category: "api"
|
|
profile: "glm_coding"
|
|
endpoint: "https://api.z.ai/api/coding/paas/v4"
|
|
models: ["glm-5.1"]
|
|
capacity: 1
|
|
`
|
|
path := filepath.Join(tmpDir, "edge.yaml")
|
|
if err := os.WriteFile(path, []byte(yaml), 0o600); err != nil {
|
|
t.Fatalf("write: %v", err)
|
|
}
|
|
cfg, err := config.LoadEdge(path)
|
|
if err != nil {
|
|
t.Fatalf("LoadEdge: %v", err)
|
|
}
|
|
if len(cfg.Models) != 2 {
|
|
t.Fatalf("expected 2 models, got %d", len(cfg.Models))
|
|
}
|
|
if len(cfg.Nodes) != 1 {
|
|
t.Fatalf("expected 1 node, got %d", len(cfg.Nodes))
|
|
}
|
|
if len(cfg.Nodes[0].Providers) != 2 {
|
|
t.Fatalf("expected 2 providers, got %d", len(cfg.Nodes[0].Providers))
|
|
}
|
|
|
|
var apiProv, codingProv *config.NodeProviderConf
|
|
for i := range cfg.Nodes[0].Providers {
|
|
p := &cfg.Nodes[0].Providers[i]
|
|
if p.ID == "glm-api" {
|
|
apiProv = p
|
|
} else if p.ID == "glm-coding" {
|
|
codingProv = p
|
|
}
|
|
}
|
|
if apiProv == nil {
|
|
t.Fatal("glm-api provider missing")
|
|
}
|
|
if codingProv == nil {
|
|
t.Fatal("glm-coding provider missing")
|
|
}
|
|
if apiProv.Profile != "glm" {
|
|
t.Errorf("glm-api profile = %q, want %q", apiProv.Profile, "glm")
|
|
}
|
|
if codingProv.Profile != "glm_coding" {
|
|
t.Errorf("glm-coding profile = %q, want %q", codingProv.Profile, "glm_coding")
|
|
}
|
|
|
|
if len(cfg.Models[0].Providers) != 1 {
|
|
t.Fatalf("glm-5.1-api: expected 1 provider mapping, got %d", len(cfg.Models[0].Providers))
|
|
}
|
|
if len(cfg.Models[1].Providers) != 1 {
|
|
t.Fatalf("glm-5.1-coding: expected 1 provider mapping, got %d", len(cfg.Models[1].Providers))
|
|
}
|
|
if cfg.Models[0].Providers["glm-api"] != "glm-5.1" {
|
|
t.Errorf("glm-5.1-api provider served = %q, want %q", cfg.Models[0].Providers["glm-api"], "glm-5.1")
|
|
}
|
|
if cfg.Models[1].Providers["glm-coding"] != "glm-5.1" {
|
|
t.Errorf("glm-5.1-coding provider served = %q, want %q", cfg.Models[1].Providers["glm-coding"], "glm-5.1")
|
|
}
|
|
|
|
// Resolved runtime profiles must be distinct.
|
|
apiProfile, err := config.ResolveProtocolProfile("glm", "", config.BuiltInProtocolProfiles)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
codingProfile, err := config.ResolveProtocolProfile("glm_coding", "", config.BuiltInProtocolProfiles)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if apiProfile.BaseURL == codingProfile.BaseURL {
|
|
t.Errorf("glm and glm_coding must resolve to different base URLs, both got %q", apiProfile.BaseURL)
|
|
}
|
|
}
|
|
|
|
func TestProtocolProfileAdapterBackingResolution(t *testing.T) {
|
|
cfg, err := loadProtocolProfileYAML(t, `
|
|
nodes:
|
|
- id: node-backed
|
|
adapters:
|
|
openai_compat_instances:
|
|
- name: upstream
|
|
enabled: true
|
|
provider: lemonade
|
|
endpoint: http://127.0.0.1:18080
|
|
providers:
|
|
- id: profile-provider
|
|
type: lemonade
|
|
category: api
|
|
adapter: upstream
|
|
profile: openai
|
|
models: [served]
|
|
capacity: 1
|
|
models:
|
|
- id: canonical
|
|
providers: {profile-provider: served}
|
|
`)
|
|
if err != nil {
|
|
t.Fatalf("LoadEdge: %v", err)
|
|
}
|
|
profile := cfg.Nodes[0].Providers[0].RuntimeProfile
|
|
if profile == nil || profile.BaseURL != "http://127.0.0.1:18080/v1" {
|
|
t.Fatalf("backing endpoint was not applied: %#v", profile)
|
|
}
|
|
}
|
|
|
|
func TestProtocolProfileAdapterBackingRejected(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name, adapters, ref, want string
|
|
}{
|
|
{name: "missing", ref: "missing", adapters: " openai_compat_instances: []", want: "missing"},
|
|
{name: "disabled", ref: "backing", adapters: " openai_compat_instances:\n - {name: backing, enabled: false, endpoint: http://127.0.0.1:1}", want: "disabled"},
|
|
{name: "ambiguous", ref: "backing", adapters: " vllm_instances:\n - {name: backing, enabled: true, endpoint: http://127.0.0.1:1}\n openai_compat_instances:\n - {name: backing, enabled: true, endpoint: http://127.0.0.1:2}", want: "ambiguous"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
_, err := loadProtocolProfileYAML(t, `
|
|
nodes:
|
|
- id: node-bad-backing
|
|
adapters:
|
|
`+tc.adapters+`
|
|
providers:
|
|
- id: profile-provider
|
|
type: openai_compat
|
|
category: api
|
|
adapter: `+tc.ref+`
|
|
profile: openai
|
|
models: [served]
|
|
capacity: 1
|
|
`)
|
|
if err == nil || !strings.Contains(err.Error(), tc.want) || !strings.Contains(err.Error(), "profile-provider") || !strings.Contains(err.Error(), tc.ref) {
|
|
t.Fatalf("expected contextual %q error, got %v", tc.want, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func loadProtocolProfileYAML(t *testing.T, contents string) (*config.EdgeConfig, error) {
|
|
t.Helper()
|
|
path := filepath.Join(t.TempDir(), "edge.yaml")
|
|
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
|
|
t.Fatalf("write config: %v", err)
|
|
}
|
|
return config.LoadEdge(path)
|
|
}
|