Anthropic 전용 API key가 일반 OpenAI 요청에 적용되지 않도록 경계를 좁히고 잘못된 절대 operation URL을 fail-closed로 거부해야 한다.\n\n완료 Milestone archive와 principal credential 후속 agent-task를 같은 기준선에 맞춘다.
787 lines
28 KiB
Go
787 lines
28 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 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", ""},
|
|
{"cli", ""},
|
|
{"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", "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)
|
|
}
|
|
})
|
|
}
|
|
|
|
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"},
|
|
"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)
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|