iop/apps/edge/internal/node/protocol_profile_mapper_test.go
toki f2306f4dc8 feat(protocol-profile): 멀티 프로토콜 프로필을 추가한다
OpenAI 호환 및 Anthropic Messages 경로의 프로토콜 프로필, 라우팅 계약, 테스트와 실행 증거를 함께 반영한다.
2026-08-01 10:33:10 +09:00

341 lines
12 KiB
Go

package node
import (
"reflect"
"strings"
"testing"
"google.golang.org/protobuf/types/known/structpb"
"iop/packages/go/config"
)
// TestConcreteProfileToProtoDeepCopy verifies that concreteProfileToProto
// deep-copies maps so the wire payload is independent of the source config
// snapshot.
func TestProtocolProfileConcreteToProtoDeepCopy(t *testing.T) {
profile := config.ConcreteProtocolProfile{
ID: "openai",
ProtocolProfileConf: config.ProtocolProfileConf{
Driver: config.ProtocolDriverOpenAIChat,
BaseURL: "https://api.openai.com/v1",
Operations: map[string]string{
"models": "/models",
"chat_completions": "/chat/completions",
},
Auth: config.ProtocolAuthConf{Header: "Authorization", Scheme: "Bearer"},
Capabilities: []string{"chat", "streaming"},
ModelMapping: map[string]string{
"gpt-4": "gpt-4",
},
},
}
proto := concreteProfileToProto(&profile)
if proto == nil {
t.Fatal("expected non-nil proto")
}
if proto.GetId() != "openai" {
t.Errorf("Id = %q, want %q", proto.GetId(), "openai")
}
if proto.GetDriver() != string(config.ProtocolDriverOpenAIChat) {
t.Errorf("Driver = %q, want %q", proto.GetDriver(), config.ProtocolDriverOpenAIChat)
}
if proto.GetBaseUrl() != "https://api.openai.com/v1" {
t.Errorf("BaseUrl = %q, want %q", proto.GetBaseUrl(), "https://api.openai.com/v1")
}
if proto.GetAuth().GetHeader() != "Authorization" {
t.Errorf("Auth.Header = %q, want %q", proto.GetAuth().GetHeader(), "Authorization")
}
if len(proto.GetOperations()) != 2 {
t.Errorf("Operations len = %d, want 2", len(proto.GetOperations()))
}
if len(proto.GetCapabilities()) != 2 {
t.Errorf("Capabilities len = %d, want 2", len(proto.GetCapabilities()))
}
// Mutate the proto maps and verify the source profile is unaffected.
proto.Operations["injected"] = "/injected"
if _, ok := profile.Operations["injected"]; ok {
t.Error("mutating proto operations affected source profile")
}
proto.Capabilities = append(proto.Capabilities, "injected")
if len(profile.Capabilities) != 2 {
t.Error("mutating proto capabilities affected source profile")
}
}
// TestConcreteProfileToProtoNil verifies that a nil profile produces a nil
// proto, preserving legacy payloads without a profile.
func TestProtocolProfileConcreteToProtoNil(t *testing.T) {
if proto := concreteProfileToProto(nil); proto != nil {
t.Errorf("expected nil proto for nil profile, got %v", proto)
}
}
// TestConcreteProfileToProtoNoBase verifies that the concrete proto snapshot
// never carries a Base field across the wire.
func TestProtocolProfileConcreteToProtoNoBase(t *testing.T) {
profile := config.ConcreteProtocolProfile{
ID: "custom",
ProtocolProfileConf: config.ProtocolProfileConf{
Base: "openai",
Driver: config.ProtocolDriverOpenAIChat,
BaseURL: "https://proxy.example.invalid/v1",
Operations: map[string]string{
"models": "/models",
},
Auth: config.ProtocolAuthConf{Header: "Authorization", Scheme: "Bearer"},
Capabilities: []string{"chat"},
},
}
proto := concreteProfileToProto(&profile)
if proto == nil {
t.Fatal("expected non-nil proto")
}
// The ConcreteProtocolProfile proto message has no Base field, so there
// is nothing to assert beyond confirming the snapshot resolves correctly.
if proto.GetId() != "custom" {
t.Errorf("Id = %q, want %q", proto.GetId(), "custom")
}
if proto.GetBaseUrl() != "https://proxy.example.invalid/v1" {
t.Errorf("BaseUrl = %q, want overridden value", proto.GetBaseUrl())
}
}
// TestConcreteProfileToProtoExtensions verifies that extensions are carried
// without panicking even when the struct conversion is partial.
func TestProtocolProfileConcreteToProtoExtensions(t *testing.T) {
profile := config.ConcreteProtocolProfile{
ID: "ext",
ProtocolProfileConf: config.ProtocolProfileConf{
Driver: config.ProtocolDriverOpenAIChat,
BaseURL: "https://api.example.com/v1",
Operations: map[string]string{
"models": "/models",
},
Auth: config.ProtocolAuthConf{Header: "Authorization", Scheme: "Bearer"},
Capabilities: []string{"chat"},
Extensions: map[string]any{
"key": "value",
"nested": map[string]any{
"enabled": true,
"items": []any{"one", map[string]any{"leaf": "two"}},
},
},
},
}
proto := concreteProfileToProto(&profile)
if proto == nil {
t.Fatal("expected non-nil proto")
}
// Extensions are converted to a structpb.Struct; verify it is non-nil
// when the source has extensions.
if proto.GetExtensions() == nil {
t.Error("expected non-nil extensions struct")
}
want := map[string]any{
"key": "value",
"nested": map[string]any{
"enabled": true,
"items": []any{"one", map[string]any{"leaf": "two"}},
},
}
if got := proto.GetExtensions().AsMap(); !reflect.DeepEqual(got, want) {
t.Fatalf("extensions = %#v, want %#v", got, want)
}
profile.Extensions["nested"].(map[string]any)["enabled"] = false
if got := proto.GetExtensions().AsMap()["nested"].(map[string]any)["enabled"]; got != true {
t.Fatalf("source mutation crossed mapper boundary: %v", got)
}
proto.Extensions.Fields["key"] = structpb.NewStringValue("wire-mutated")
if got := profile.Extensions["key"]; got != "value" {
t.Fatalf("wire mutation crossed mapper boundary: %v", got)
}
}
// TestProviderToAdapterConfigProfile verifies that providerToAdapterConfig
// carries the concrete profile into the OpenAICompatAdapterConfig payload.
func TestProtocolProfileProviderToAdapterConfig(t *testing.T) {
profile := config.ConcreteProtocolProfile{
ID: "openai",
ProtocolProfileConf: config.ProtocolProfileConf{
Driver: config.ProtocolDriverOpenAIChat,
BaseURL: "https://api.openai.com/v1",
Operations: map[string]string{
"models": "/models",
},
Auth: config.ProtocolAuthConf{Header: "Authorization", Scheme: "Bearer"},
Capabilities: []string{"chat"},
},
}
p := config.NodeProviderConf{
ID: "my-provider",
Type: "openai_api",
Profile: "openai",
RuntimeProfile: &profile,
Endpoint: "https://api.openai.com/v1",
Models: []string{"gpt-4"},
Capacity: 4,
}
ac, err := providerToAdapterConfig(p)
if err != nil {
t.Fatalf("providerToAdapterConfig: %v", err)
}
if ac.GetType() != "openai_compat" {
t.Errorf("Type = %q, want %q", ac.GetType(), "openai_compat")
}
if ac.GetName() != "my-provider" {
t.Errorf("Name = %q, want %q", ac.GetName(), "my-provider")
}
openaiCfg := ac.GetOpenaiCompat()
if openaiCfg == nil {
t.Fatal("expected non-nil OpenAICompatAdapterConfig")
}
if openaiCfg.GetProtocolProfile() == nil {
t.Error("expected non-nil protocol_profile in payload")
}
if openaiCfg.GetProtocolProfile().GetId() != "openai" {
t.Errorf("ProtocolProfile.Id = %q, want %q", openaiCfg.GetProtocolProfile().GetId(), "openai")
}
}
// TestBuildConfigPayloadNoBaseCrossesWire is a guard that the mapper does not
// leak a Base reference into the wire payload for any adapter type.
func TestProtocolProfileBuildConfigPayloadNoBase(t *testing.T) {
profile := config.ConcreteProtocolProfile{
ID: "openai",
ProtocolProfileConf: config.ProtocolProfileConf{
Driver: config.ProtocolDriverOpenAIChat,
BaseURL: "https://api.openai.com/v1",
Operations: map[string]string{
"models": "/models",
},
Auth: config.ProtocolAuthConf{Header: "Authorization", Scheme: "Bearer"},
Capabilities: []string{"chat"},
},
}
rec := &NodeRecord{
ID: "node-1",
Providers: []config.NodeProviderConf{
{
ID: "my-provider",
Type: "openai_api",
Profile: "openai",
RuntimeProfile: &profile,
Endpoint: "https://api.openai.com/v1",
Models: []string{"gpt-4"},
Capacity: 4,
},
},
}
payload, err := BuildConfigPayload(rec)
if err != nil {
t.Fatalf("BuildConfigPayload: %v", err)
}
if len(payload.GetAdapters()) != 1 {
t.Fatalf("expected 1 adapter, got %d", len(payload.GetAdapters()))
}
openaiCfg := payload.GetAdapters()[0].GetOpenaiCompat()
if openaiCfg == nil {
t.Fatal("expected non-nil OpenAICompatAdapterConfig")
}
// ConcreteProtocolProfile proto has no Base field, so this is always nil.
_ = openaiCfg.GetProtocolProfile()
}
func TestProtocolProfileAdapterBackingCompile(t *testing.T) {
profile := config.ConcreteProtocolProfile{
ID: "custom", ProtocolProfileConf: config.ProtocolProfileConf{
Driver: config.ProtocolDriverOpenAIChat, BaseURL: "http://backing.invalid/v1",
Operations: map[string]string{"chat_completions": "/chat/completions"},
Auth: config.ProtocolAuthConf{Header: "Authorization", Scheme: "Bearer"}, Capabilities: []string{"chat"},
},
}
t.Run("vllm_backing_with_provider_overlay", func(t *testing.T) {
rec := &NodeRecord{
ID: "node-vllm",
Adapters: config.AdaptersConf{VllmInstances: []config.VllmInstanceConf{{
Name: "gpu", Enabled: true, Endpoint: "http://vllm:8000/v1", Capacity: 2, MaxQueue: 3, QueueTimeoutMS: 4, RequestTimeoutMS: 5,
}}},
Providers: []config.NodeProviderConf{{
ID: "profile-provider", Type: "vllm", Adapter: "gpu", RuntimeProfile: &profile,
Endpoint: "http://overlay:9000/v1", Capacity: 7,
}},
}
payload, err := BuildConfigPayload(rec)
if err != nil {
t.Fatalf("BuildConfigPayload: %v", err)
}
if len(payload.GetAdapters()) != 2 {
t.Fatalf("adapters = %d, want profile adapter plus retained backing", len(payload.GetAdapters()))
}
compiled := payload.GetAdapters()[0]
got := compiled.GetOpenaiCompat()
if compiled.GetName() != "profile-provider" || got == nil {
t.Fatalf("compiled adapter = %#v", compiled)
}
if got.GetProvider() != "vllm" || got.GetEndpoint() != "http://overlay:9000/v1" || got.GetCapacity() != 7 || got.GetMaxQueue() != 3 {
t.Fatalf("compiled backing overlay = %#v", got)
}
if got.GetProtocolProfile().GetId() != "custom" || payload.GetAdapters()[1].GetVllm() == nil || payload.GetAdapters()[1].GetName() != "gpu" {
t.Fatalf("profile/backing payload = %#v", payload.GetAdapters())
}
})
t.Run("openai_compat_backing_fields", func(t *testing.T) {
rec := &NodeRecord{
ID: "node-openai",
Adapters: config.AdaptersConf{OpenAICompatInstances: []config.OpenAICompatInstanceConf{{
Name: "api", Enabled: true, Provider: "lemonade", Endpoint: "http://api:8000/v1",
Headers: map[string]string{"Authorization": "Bearer secret"}, Capacity: 2, RequestTimeoutMS: 90,
}}},
Providers: []config.NodeProviderConf{{ID: "profile-provider", Type: "openai_compat", Adapter: "api", RuntimeProfile: &profile}},
}
payload, err := BuildConfigPayload(rec)
if err != nil {
t.Fatalf("BuildConfigPayload: %v", err)
}
got := payload.GetAdapters()[0].GetOpenaiCompat()
if got.GetProvider() != "lemonade" || got.GetEndpoint() != "http://api:8000/v1" || got.GetHeaders()["Authorization"] != "Bearer secret" || got.GetRequestTimeoutMs() != 90 {
t.Fatalf("compiled adapter did not inherit backing fields: %#v", got)
}
got.Headers["Authorization"] = "mutated"
if rec.Adapters.OpenAICompatInstances[0].Headers["Authorization"] != "Bearer secret" {
t.Fatal("wire headers alias backing config")
}
})
}
func TestProtocolProfileBackingAdapterRejected(t *testing.T) {
profile := config.ConcreteProtocolProfile{ID: "openai", ProtocolProfileConf: config.ProtocolProfileConf{Driver: config.ProtocolDriverOpenAIChat}}
for _, tc := range []struct {
name, ref, want string
adapters config.AdaptersConf
}{
{name: "missing", ref: "missing", want: "missing"},
{name: "disabled", ref: "api", want: "disabled", adapters: config.AdaptersConf{OpenAICompatInstances: []config.OpenAICompatInstanceConf{{Name: "api", Enabled: false}}}},
{name: "ambiguous_exact", ref: "shared", want: "ambiguous", adapters: config.AdaptersConf{
VllmInstances: []config.VllmInstanceConf{{Name: "shared", Enabled: true}},
OpenAICompatInstances: []config.OpenAICompatInstanceConf{{Name: "shared", Enabled: true}},
}},
{name: "ambiguous_type", ref: "vllm", want: "ambiguous", adapters: config.AdaptersConf{VllmInstances: []config.VllmInstanceConf{
{Name: "one", Enabled: true}, {Name: "two", Enabled: true},
}}},
} {
t.Run(tc.name, func(t *testing.T) {
rec := &NodeRecord{ID: "node", Adapters: tc.adapters, Providers: []config.NodeProviderConf{{
ID: "profile-provider", Type: "openai_compat", Adapter: tc.ref, RuntimeProfile: &profile,
}}}
_, err := BuildConfigPayload(rec)
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)
}
})
}
}