374 lines
12 KiB
Go
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
|
|
}
|