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

374 lines
12 KiB
Go

package adapters
import (
"reflect"
"strings"
"testing"
"go.uber.org/zap"
"google.golang.org/protobuf/types/known/structpb"
"iop/packages/go/config"
iop "iop/proto/gen/iop"
)
func TestProtocolProfileExtensionsRoundTrip(t *testing.T) {
profile := mustResolveProfileForAdapter(t, "openai")
profile.Extensions = map[string]any{
"nested": map[string]any{
"enabled": true,
"items": []any{"one", map[string]any{"leaf": "two"}},
},
}
wire := concreteProfileToProtoForTest(&profile)
decoded := concreteProfileFromProto(wire)
if decoded == nil {
t.Fatal("decoded profile is nil")
}
if !reflect.DeepEqual(decoded.Extensions, profile.Extensions) {
t.Fatalf("extensions = %#v, want %#v", decoded.Extensions, profile.Extensions)
}
profile.Extensions["nested"].(map[string]any)["enabled"] = false
if got := decoded.Extensions["nested"].(map[string]any)["enabled"]; got != true {
t.Fatalf("source mutation crossed Node boundary: %v", got)
}
decoded.Extensions["nested"].(map[string]any)["items"].([]any)[1].(map[string]any)["leaf"] = "changed"
if got := wire.GetExtensions().AsMap()["nested"].(map[string]any)["items"].([]any)[1].(map[string]any)["leaf"]; got != "two" {
t.Fatalf("decoded mutation crossed wire boundary: %v", got)
}
payload := func(profile *iop.ConcreteProtocolProfile) *iop.NodeConfigPayload {
return &iop.NodeConfigPayload{Adapters: []*iop.AdapterConfig{{
Type: "openai_compat", Enabled: true, Name: "ext-adapter",
Config: &iop.AdapterConfig_OpenaiCompat{OpenaiCompat: &iop.OpenAICompatAdapterConfig{ProtocolProfile: profile}},
}}}
}
first, err := BuildConfigSet(payload(wire), zap.NewNop())
if err != nil {
t.Fatalf("BuildConfigSet first: %v", err)
}
wireChanged := concreteProfileToProtoForTest(&profile)
second, err := BuildConfigSet(payload(wireChanged), zap.NewNop())
if err != nil {
t.Fatalf("BuildConfigSet second: %v", err)
}
if first.Items["ext-adapter"].Fingerprint == second.Items["ext-adapter"].Fingerprint {
t.Fatal("nested extension change did not change config fingerprint")
}
}
func TestProtocolProfileAdapterBackedConfigFingerprint(t *testing.T) {
profile := mustResolveProfileForAdapter(t, "openai")
profile.ModelMapping = map[string]string{"canonical": "upstream-a"}
payload := func(p config.ConcreteProtocolProfile) *iop.NodeConfigPayload {
return &iop.NodeConfigPayload{Adapters: []*iop.AdapterConfig{
{
Type: "openai_compat", Enabled: true, Name: "profile-provider",
Config: &iop.AdapterConfig_OpenaiCompat{OpenaiCompat: &iop.OpenAICompatAdapterConfig{
Provider: "lemonade", Endpoint: "http://backing:8000/v1",
Headers: map[string]string{"Authorization": "Bearer secret"}, ProtocolProfile: concreteProfileToProtoForTest(&p),
}},
},
{
Type: "openai_compat", Enabled: true, Name: "legacy-backing",
Config: &iop.AdapterConfig_OpenaiCompat{OpenaiCompat: &iop.OpenAICompatAdapterConfig{
Provider: "lemonade", Endpoint: "http://backing:8000/v1", Headers: map[string]string{"Authorization": "Bearer secret"},
}},
},
}}
}
first, err := BuildConfigSet(payload(profile), zap.NewNop())
if err != nil {
t.Fatalf("BuildConfigSet first: %v", err)
}
if len(first.Items) != 2 {
t.Fatalf("items = %d, want profile provider plus retained backing", len(first.Items))
}
fingerprint := first.Items["profile-provider"].Fingerprint
for _, fragment := range []string{"http://backing:8000/v1", "Authorization", "upstream-a"} {
if !strings.Contains(fingerprint, fragment) {
t.Errorf("profile fingerprint does not contain %q: %s", fragment, fingerprint)
}
}
profile.ModelMapping["canonical"] = "upstream-b"
second, err := BuildConfigSet(payload(profile), zap.NewNop())
if err != nil {
t.Fatalf("BuildConfigSet second: %v", err)
}
diff := DiffConfigSets(first, second)
if !reflect.DeepEqual(diff.Updated, []string{"profile-provider"}) {
t.Fatalf("profile-only diff = %+v", diff)
}
if first.Items["legacy-backing"].Fingerprint != second.Items["legacy-backing"].Fingerprint {
t.Fatal("profile change altered retained legacy backing fingerprint")
}
}
// TestProtocolProfileConfigUpdate verifies that a profile change in the config
// payload produces a different fingerprint, triggering a config update.
func TestProtocolProfileConfigUpdate(t *testing.T) {
profile := mustResolveProfileForAdapter(t, "openai")
payload1 := &iop.NodeConfigPayload{
Adapters: []*iop.AdapterConfig{
{
Type: "openai_compat",
Enabled: true,
Name: "my-adapter",
Config: &iop.AdapterConfig_OpenaiCompat{
OpenaiCompat: &iop.OpenAICompatAdapterConfig{
Provider: "openai_api",
Endpoint: "https://api.openai.com/v1",
Capacity: 4,
ProtocolProfile: concreteProfileToProtoForTest(&profile),
},
},
},
},
}
set1, err := BuildConfigSet(payload1, zap.NewNop())
if err != nil {
t.Fatalf("BuildConfigSet 1: %v", err)
}
// Change the profile to anthropic.
anthropicProfile := mustResolveProfileForAdapter(t, "anthropic")
payload2 := &iop.NodeConfigPayload{
Adapters: []*iop.AdapterConfig{
{
Type: "openai_compat",
Enabled: true,
Name: "my-adapter",
Config: &iop.AdapterConfig_OpenaiCompat{
OpenaiCompat: &iop.OpenAICompatAdapterConfig{
Provider: "openai_api",
Endpoint: "https://api.openai.com/v1",
Capacity: 4,
ProtocolProfile: concreteProfileToProtoForTest(&anthropicProfile),
},
},
},
},
}
set2, err := BuildConfigSet(payload2, zap.NewNop())
if err != nil {
t.Fatalf("BuildConfigSet 2: %v", err)
}
diff := DiffConfigSets(set1, set2)
if len(diff.Updated) != 1 || diff.Updated[0] != "my-adapter" {
t.Fatalf("expected my-adapter to be updated, got diff: %+v", diff)
}
}
// TestProtocolProfileConfigInheritedStateAbsent verifies that the concrete
// profile snapshot does not carry inherited Base state into the adapter config.
func TestProtocolProfileConfigInheritedStateAbsent(t *testing.T) {
profile := mustResolveProfileForAdapter(t, "openai")
payload := &iop.NodeConfigPayload{
Adapters: []*iop.AdapterConfig{
{
Type: "openai_compat",
Enabled: true,
Name: "my-adapter",
Config: &iop.AdapterConfig_OpenaiCompat{
OpenaiCompat: &iop.OpenAICompatAdapterConfig{
Provider: "openai_api",
Endpoint: "https://api.openai.com/v1",
ProtocolProfile: concreteProfileToProtoForTest(&profile),
},
},
},
},
}
set, err := BuildConfigSet(payload, zap.NewNop())
if err != nil {
t.Fatalf("BuildConfigSet: %v", err)
}
item, ok := set.Items["my-adapter"]
if !ok {
t.Fatal("expected my-adapter in config set")
}
if item.Type != "openai_compat" {
t.Errorf("Type = %q, want %q", item.Type, "openai_compat")
}
if item.Fingerprint == "" {
t.Error("expected non-empty fingerprint")
}
}
// TestProtocolProfileConfigNilProfile verifies that a payload without a profile
// (legacy) still builds a config set with a valid fingerprint.
func TestProtocolProfileConfigNilProfile(t *testing.T) {
payload := &iop.NodeConfigPayload{
Adapters: []*iop.AdapterConfig{
{
Type: "openai_compat",
Enabled: true,
Name: "legacy-adapter",
Config: &iop.AdapterConfig_OpenaiCompat{
OpenaiCompat: &iop.OpenAICompatAdapterConfig{
Provider: "openai_api",
Endpoint: "https://api.openai.com/v1",
Capacity: 4,
// ProtocolProfile is nil (legacy payload)
},
},
},
},
}
set, err := BuildConfigSet(payload, zap.NewNop())
if err != nil {
t.Fatalf("BuildConfigSet: %v", err)
}
item, ok := set.Items["legacy-adapter"]
if !ok {
t.Fatal("expected legacy-adapter in config set")
}
if item.Fingerprint == "" {
t.Error("expected non-empty fingerprint for legacy config")
}
}
// TestProtocolProfileConfigFingerprintIncludesProfile verifies that the
// fingerprint includes the profile data so that profile changes are detected.
func TestProtocolProfileConfigFingerprintIncludesProfile(t *testing.T) {
profile1 := mustResolveProfileForAdapter(t, "openai")
profile2 := mustResolveProfileForAdapter(t, "anthropic")
payload1 := &iop.NodeConfigPayload{
Adapters: []*iop.AdapterConfig{
{
Type: "openai_compat",
Enabled: true,
Name: "fp-adapter",
Config: &iop.AdapterConfig_OpenaiCompat{
OpenaiCompat: &iop.OpenAICompatAdapterConfig{
Endpoint: "https://api.openai.com/v1",
ProtocolProfile: concreteProfileToProtoForTest(&profile1),
},
},
},
},
}
payload2 := &iop.NodeConfigPayload{
Adapters: []*iop.AdapterConfig{
{
Type: "openai_compat",
Enabled: true,
Name: "fp-adapter",
Config: &iop.AdapterConfig_OpenaiCompat{
OpenaiCompat: &iop.OpenAICompatAdapterConfig{
Endpoint: "https://api.openai.com/v1",
ProtocolProfile: concreteProfileToProtoForTest(&profile2),
},
},
},
},
}
set1, err := BuildConfigSet(payload1, zap.NewNop())
if err != nil {
t.Fatalf("BuildConfigSet 1: %v", err)
}
set2, err := BuildConfigSet(payload2, zap.NewNop())
if err != nil {
t.Fatalf("BuildConfigSet 2: %v", err)
}
fp1 := set1.Items["fp-adapter"].Fingerprint
fp2 := set2.Items["fp-adapter"].Fingerprint
if fp1 == fp2 {
t.Error("expected different fingerprints for different profiles")
}
}
// TestProtocolProfileConfigDiffUnchanged verifies that identical profile
// payloads produce no diff.
func TestProtocolProfileConfigDiffUnchanged(t *testing.T) {
profile := mustResolveProfileForAdapter(t, "openai")
payload := &iop.NodeConfigPayload{
Adapters: []*iop.AdapterConfig{
{
Type: "openai_compat",
Enabled: true,
Name: "same-adapter",
Config: &iop.AdapterConfig_OpenaiCompat{
OpenaiCompat: &iop.OpenAICompatAdapterConfig{
Endpoint: "https://api.openai.com/v1",
ProtocolProfile: concreteProfileToProtoForTest(&profile),
},
},
},
},
}
set1, err := BuildConfigSet(payload, zap.NewNop())
if err != nil {
t.Fatalf("BuildConfigSet 1: %v", err)
}
set2, err := BuildConfigSet(payload, zap.NewNop())
if err != nil {
t.Fatalf("BuildConfigSet 2: %v", err)
}
diff := DiffConfigSets(set1, set2)
if len(diff.Updated) != 0 {
t.Errorf("expected no updated items, got %v", diff.Updated)
}
if len(diff.Added) != 0 {
t.Errorf("expected no added items, got %v", diff.Added)
}
}
// concreteProfileToProtoForTest converts a config.ConcreteProtocolProfile to
// its wire representation for test payloads.
func concreteProfileToProtoForTest(p *config.ConcreteProtocolProfile) *iop.ConcreteProtocolProfile {
if p == nil {
return nil
}
cp := p.Clone()
proto := &iop.ConcreteProtocolProfile{
Id: cp.ID,
Driver: string(cp.Driver),
BaseUrl: cp.BaseURL,
Auth: &iop.ProtocolAuth{Header: cp.Auth.Header, Scheme: cp.Auth.Scheme},
Capabilities: append([]string(nil), cp.Capabilities...),
}
if len(cp.Operations) > 0 {
proto.Operations = make(map[string]string, len(cp.Operations))
for k, v := range cp.Operations {
proto.Operations[k] = v
}
}
if len(cp.ModelMapping) > 0 {
proto.ModelMapping = make(map[string]string, len(cp.ModelMapping))
for k, v := range cp.ModelMapping {
proto.ModelMapping[k] = v
}
}
if len(cp.Extensions) > 0 {
if s, err := structpb.NewStruct(cp.Extensions); err == nil {
proto.Extensions = s
}
}
return proto
}
// mustResolveProfileForAdapter resolves a built-in profile for use in adapter
// config tests.
func mustResolveProfileForAdapter(t *testing.T, id string) config.ConcreteProtocolProfile {
t.Helper()
profile, err := config.ResolveProtocolProfile(id, "", config.BuiltInProtocolProfiles)
if err != nil {
t.Fatalf("ResolveProtocolProfile(%q): %v", id, err)
}
return profile
}