From 7d8551b4408de6d9e7a30f42f02b634ad79873d4 Mon Sep 17 00:00:00 2001 From: toki Date: Sun, 2 Aug 2026 12:43:48 +0900 Subject: [PATCH] =?UTF-8?q?fix(edge):=20=ED=94=84=EB=A1=9C=ED=95=84=20back?= =?UTF-8?q?ing=20=EC=A4=91=EB=B3=B5=EC=9D=84=20=EB=8B=A8=EC=9D=BC=ED=99=94?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/edge/internal/node/mapper.go | 29 ++++- .../node/protocol_profile_mapper_test.go | 100 ++++++++++++++++++ 2 files changed, 128 insertions(+), 1 deletion(-) diff --git a/apps/edge/internal/node/mapper.go b/apps/edge/internal/node/mapper.go index a18eb1cb..611a0f6e 100644 --- a/apps/edge/internal/node/mapper.go +++ b/apps/edge/internal/node/mapper.go @@ -19,7 +19,8 @@ import ( // Providers with a non-empty Adapter field and no protocol profile reference a // legacy instance and are skipped during provider-first compile. A profile- // backed provider is compiled under its provider id from the referenced HTTP -// adapter while the legacy instance is retained below. +// adapter. When that backing instance has the same key, it is consumed instead +// of being emitted a second time. // // After config normalisation, OllamaInstances, VllmInstances, and // OpenAICompatInstances already contain the promoted legacy single-instance @@ -45,6 +46,11 @@ func BuildConfigPayload(rec *NodeRecord) (*iop.NodeConfigPayload, error) { // flat namespace to detect conflicts between provider ids and legacy adapter keys seenFlat := make(map[string]struct{}) + // A profile-backed provider may intentionally reuse the exact legacy + // adapter key that supplies its transport settings. Config validation has + // already required both definitions to agree; emit only the provider-backed + // adapter so the Node receives one unambiguous instance key. + shadowedLegacy := make(map[string]struct{}) for _, p := range rec.Providers { if p.Adapter != "" && p.RuntimeProfile == nil { @@ -62,6 +68,18 @@ func BuildConfigPayload(rec *NodeRecord) (*iop.NodeConfigPayload, error) { var err error if p.RuntimeProfile != nil && strings.TrimSpace(p.Adapter) != "" { ac, err = profileBackedProviderToAdapterConfig(rec, p) + if err == nil && strings.TrimSpace(p.Adapter) == p.ID { + for _, inst := range rec.Adapters.VllmInstances { + if inst.Name == p.ID { + shadowedLegacy["vllm:"+p.ID] = struct{}{} + } + } + for _, inst := range rec.Adapters.OpenAICompatInstances { + if inst.Name == p.ID { + shadowedLegacy["openai_compat:"+p.ID] = struct{}{} + } + } + } } else { ac, err = providerToAdapterConfig(p) } @@ -83,6 +101,9 @@ func BuildConfigPayload(rec *NodeRecord) (*iop.NodeConfigPayload, error) { } seen[key] = struct{}{} if _, conflict := seenFlat[inst.Name]; conflict { + if _, shadowed := shadowedLegacy[key]; shadowed { + continue + } return nil, fmt.Errorf("ollama adapter instance key %q conflicts with provider id", inst.Name) } payload.Adapters = append(payload.Adapters, &iop.AdapterConfig{ @@ -112,6 +133,9 @@ func BuildConfigPayload(rec *NodeRecord) (*iop.NodeConfigPayload, error) { } seen[key] = struct{}{} if _, conflict := seenFlat[inst.Name]; conflict { + if _, shadowed := shadowedLegacy[key]; shadowed { + continue + } return nil, fmt.Errorf("vllm adapter instance key %q conflicts with provider id", inst.Name) } payload.Adapters = append(payload.Adapters, &iop.AdapterConfig{ @@ -140,6 +164,9 @@ func BuildConfigPayload(rec *NodeRecord) (*iop.NodeConfigPayload, error) { } seen[key] = struct{}{} if _, conflict := seenFlat[inst.Name]; conflict { + if _, shadowed := shadowedLegacy[key]; shadowed { + continue + } return nil, fmt.Errorf("openai_compat adapter instance key %q conflicts with provider id", inst.Name) } payload.Adapters = append(payload.Adapters, &iop.AdapterConfig{ diff --git a/apps/edge/internal/node/protocol_profile_mapper_test.go b/apps/edge/internal/node/protocol_profile_mapper_test.go index 0c5f3888..b0bf3d25 100644 --- a/apps/edge/internal/node/protocol_profile_mapper_test.go +++ b/apps/edge/internal/node/protocol_profile_mapper_test.go @@ -249,6 +249,106 @@ func TestProtocolProfileBuildConfigPayloadNoBase(t *testing.T) { _ = openaiCfg.GetProtocolProfile() } +func TestProtocolProfileProviderReusingBackingKeyEmitsOneAdapter(t *testing.T) { + profile := config.ConcreteProtocolProfile{ + ID: "openai_chat", + ProtocolProfileConf: config.ProtocolProfileConf{ + Driver: config.ProtocolDriverOpenAIChat, + }, + } + rec := &NodeRecord{ + ID: "node-profile-reuse", + Alias: "profile-reuse", + Token: "token", + Providers: []config.NodeProviderConf{ + { + ID: "provider-a", + Type: "vllm", + Adapter: "provider-a", + Capacity: 2, + RequestTimeoutMS: 120000, + RuntimeProfile: &profile, + }, + }, + Adapters: config.AdaptersConf{ + OpenAICompatInstances: []config.OpenAICompatInstanceConf{ + { + Name: "provider-a", + Enabled: true, + Provider: "vllm", + Endpoint: "http://127.0.0.1:8000/v1", + Capacity: 2, + RequestTimeoutMS: 120000, + }, + }, + }, + } + + payload, err := BuildConfigPayload(rec) + if err != nil { + t.Fatalf("BuildConfigPayload: %v", err) + } + if len(payload.GetAdapters()) != 1 { + t.Fatalf("adapter count = %d, want 1", len(payload.GetAdapters())) + } + adapter := payload.GetAdapters()[0] + if adapter.GetName() != "provider-a" { + t.Fatalf("adapter name = %q, want provider-a", adapter.GetName()) + } + openAICompat := adapter.GetOpenaiCompat() + if openAICompat == nil { + t.Fatal("expected OpenAI-compatible adapter") + } + if openAICompat.GetEndpoint() != "http://127.0.0.1:8000/v1" { + t.Fatalf("endpoint = %q", openAICompat.GetEndpoint()) + } + if openAICompat.GetProtocolProfile() == nil { + t.Fatal("expected resolved protocol profile") + } +} + +func TestProtocolProfileProviderReusingBackingKeyRejectsOtherLegacyType(t *testing.T) { + profile := config.ConcreteProtocolProfile{ + ID: "openai_chat", + ProtocolProfileConf: config.ProtocolProfileConf{ + Driver: config.ProtocolDriverOpenAIChat, + }, + } + rec := &NodeRecord{ + ID: "node-profile-reuse-conflict", + Alias: "profile-reuse-conflict", + Token: "token", + Providers: []config.NodeProviderConf{ + { + ID: "provider-a", + Type: "vllm", + Adapter: "provider-a", + Capacity: 2, + RuntimeProfile: &profile, + }, + }, + Adapters: config.AdaptersConf{ + OllamaInstances: []config.OllamaInstanceConf{ + {Name: "provider-a", Enabled: true, Capacity: 2}, + }, + OpenAICompatInstances: []config.OpenAICompatInstanceConf{ + { + Name: "provider-a", + Enabled: true, + Provider: "vllm", + Endpoint: "http://127.0.0.1:8000/v1", + Capacity: 2, + }, + }, + }, + } + + _, err := BuildConfigPayload(rec) + if err == nil || !strings.Contains(err.Error(), "ollama adapter instance key") { + t.Fatalf("BuildConfigPayload error = %v, want ollama key conflict", err) + } +} + func TestProtocolProfileAdapterBackingCompile(t *testing.T) { profile := config.ConcreteProtocolProfile{ ID: "custom", ProtocolProfileConf: config.ProtocolProfileConf{