From b7fc808a5638da2383e2022962272b9e7367446a Mon Sep 17 00:00:00 2001 From: toki Date: Thu, 13 Aug 2026 23:12:00 +0900 Subject: [PATCH] =?UTF-8?q?fix(config):=20provider=20admission=EC=9D=84=20?= =?UTF-8?q?transport=20=EC=83=81=ED=95=9C=EA=B3=BC=20=EB=B6=84=EB=A6=AC?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../inner/edge-config-runtime-refresh.md | 2 +- .../runtime/provider-pool-config-refresh.md | 2 +- agent-test/inventory-dev.yaml | 12 ++--- .../provider_stall_timeout_test.go | 2 +- ...provider_catalog_validation_config_test.go | 44 ++++++------------- packages/go/config/validate.go | 8 ++-- 6 files changed, 27 insertions(+), 43 deletions(-) diff --git a/agent-contract/inner/edge-config-runtime-refresh.md b/agent-contract/inner/edge-config-runtime-refresh.md index c920efac..4b9c2d76 100644 --- a/agent-contract/inner/edge-config-runtime-refresh.md +++ b/agent-contract/inner/edge-config-runtime-refresh.md @@ -74,7 +74,7 @@ tracked config에는 public 예시와 기본 구조만 두고, 실제 endpoint/c - `nodes[].providers[].id`는 전체 Edge config 안에서 중복되면 안 된다. - `nodes[].providers[].adapter`는 같은 Node 안의 enabled adapter instance key를 참조해야 한다. Exact instance key를 우선하고, legacy type-name route는 같은 type의 enabled instance가 정확히 하나일 때만 허용한다. - `nodes[].providers[].enabled`: 생략 또는 `true` → provider pool dispatch 후보에 포함. `false` → dispatch pool에서 제외. 비활성화된 provider는 status snapshot에 `status=disabled`, `health=disabled`, `capacity=0`으로 표시된다. adapter process lifecycle 변경 없음. config refresh 시 `enabled` 토글은 live-apply(restart 불필요)로 분류된다. disabled provider의 adapter reference check는 skip되지만 structural validation(type, category, models, numeric bounds)은 수행된다. -- `nodes[].providers[].capacity`와 `long_context_capacity`는 `node_id + provider_id` resource가 소유한다. 같은 provider를 참조하는 여러 `models[].id`는 일반·long slot을 합산 공유한다. `total_context_tokens`는 runtime counter가 아니라 `context_window_tokens * long_context_capacity` 이상이어야 하는 정적 load/refresh validation 값이다. +- `nodes[].providers[].capacity`와 `long_context_capacity`는 `node_id + provider_id` resource가 소유한다. 같은 key의 legacy adapter가 함께 있으면 adapter capacity는 transport 상한이며 provider capacity는 그 이하의 admission 한도로 설정할 수 있고, 상한 초과만 load에서 거부한다. 같은 provider를 참조하는 여러 `models[].id`는 일반·long slot을 합산 공유한다. `total_context_tokens`는 runtime counter가 아니라 `context_window_tokens * long_context_capacity` 이상이어야 하는 정적 load/refresh validation 값이다. - `nodes[].providers[].priority`: provider-pool dispatch tie-breaker다. 기본값은 `0`이고 음수는 validation error다. dispatch는 `in_flight < capacity` 후보 중 가장 낮은 `in_flight`를 먼저 선택하며, `in_flight`가 같은 후보에서만 낮은 숫자의 `priority`를 우선한다. `in_flight`와 `priority`가 모두 같으면 기존 순환을 유지한다. priority 변경은 live-apply(restart 불필요)로 분류된다. - Configured provider health remains an immutable input snapshot during request execution. Confirmed current bound runtime-unavailable evidence is stored separately under `(node_id, connection_generation, provider_id)`, gates effective admission, and projects the runtime ProviderSnapshot unavailable without changing `NodeProviderConf.Health`, refresh diffs, or Node config payloads. A later exact higher-sequence available CAPABILITIES probe or a newer connection generation clears effective exclusion under the runtime contract, not through config refresh. - After the queue makes that authoritative overlay decision, Edge emits bounded operational evidence only: `iop_edge_provider_health_evidence_total{source,evidence_health,decision}` and `iop_edge_provider_health_transitions_total{from_health,to_health}`, plus `edge_provider_health_observation`. Sources, health values, and decisions use closed vocabularies; provider/node/run/session/adapter/target identity, payloads, and credentials are excluded. The observer is post-lock and cannot validate or mutate config/overlay state. diff --git a/agent-spec/runtime/provider-pool-config-refresh.md b/agent-spec/runtime/provider-pool-config-refresh.md index 65518c45..a07be806 100644 --- a/agent-spec/runtime/provider-pool-config-refresh.md +++ b/agent-spec/runtime/provider-pool-config-refresh.md @@ -203,7 +203,7 @@ sequenceDiagram - `filters[].hold_evidence_runes` is bounded `1..65536` and defaults to 500. For `repeat_guard` it controls the Unicode pending/look-behind evidence window, not a time-based release or a cross-request retention period. - Blocking repeat capability admission is re-resolved for the actual provider/path while the request-start filter policy, history snapshot, recovery ordinals, and temperature candidate order remain generation-stable across provider switches. - `provider_pool.max_queue`는 0/생략 시 기본값 `16`, `queue_timeout_ms`는 생략 시 `30000`이고 명시적 0은 timeout 없음이다. canonical root key가 없을 때만 서로 같은 legacy provider queue pair를 승격하며 값이 다르면 load를 거부한다. -- `nodes[].providers[].capacity`와 `long_context_capacity`는 provider resource 속성이고 같은 provider를 공유하는 model alias가 합산 점유한다. `total_context_tokens`는 runtime ledger가 아니라 `context_window_tokens * long_context_capacity` 정적 validation 값이다. +- `nodes[].providers[].capacity`와 `long_context_capacity`는 provider resource 속성이고 같은 provider를 공유하는 model alias가 합산 점유한다. 같은 key의 legacy adapter capacity는 transport 상한이므로 provider admission capacity는 그 이하일 수 있고 상한 초과만 거부한다. `total_context_tokens`는 runtime ledger가 아니라 `context_window_tokens * long_context_capacity` 정적 validation 값이다. - `models[].usage_attribution`은 생략 시 `provider`, 명시값은 `provider|model_group`만 허용한다. 변경은 model catalog policy 변경으로 live apply되며 `models[""].usage_attribution` 경로로 보고한다. - provider `enabled=false`는 dispatch pool에서 제외하지만 adapter process lifecycle 변경을 의미하지 않는다. - Runtime health is not a config-refresh field. The overlay never rewrites `nodes[].providers[].health`, is discarded across connection generations, and participates only in effective candidate eligibility and snapshot projection. diff --git a/agent-test/inventory-dev.yaml b/agent-test/inventory-dev.yaml index 40903fef..cd639051 100644 --- a/agent-test/inventory-dev.yaml +++ b/agent-test/inventory-dev.yaml @@ -427,10 +427,10 @@ model: upstream_url: https://huggingface.co/LordNeel/Ornith-1.0-35B-GGUF-llamacpp-tp1 upstream_revision: c50d5d4407f70e43208dee836c66bb8a05c1be91 quantization: Q5_K_M - capacity: 1 + capacity: 3 priority: 2 total_context_tokens: 524288 - long_context_capacity: 1 + long_context_capacity: 2 runtime_args: ctx_size: 524288 llamacpp_backend: vulkan @@ -807,11 +807,11 @@ model: health: healthy served_model: mlx-community/Qwen3.6-35B-A3B-4bit onexplayer-lemonade: - capacity: 1 + capacity: 3 priority: 2 in_flight: 0 queued: 0 - long_context_capacity: 1 + long_context_capacity: 2 health: healthy served_model: Ornith-1.0-35B-GGUF-llamacpp-tp1-Q5_K_M rtx5090-lemonade: @@ -1174,8 +1174,8 @@ nodes: removed_args: "-np 3" expected_slot_n_ctx: 262144 expected_auto_slots: 4 - iop_capacity_guard: 1 - long_context_capacity: 1 + iop_capacity_guard: 3 + long_context_capacity: 2 validation_required_after_load: true validation_basis: Same Lemonade llama.cpp backend and current Ornith Q5 runtime confirmed --kv-unified with omitted -np prevents fixed ctx_size/parallel partitioning. capacity: 1 diff --git a/apps/edge/internal/configrefresh/provider_stall_timeout_test.go b/apps/edge/internal/configrefresh/provider_stall_timeout_test.go index 2bed404b..34382ab5 100644 --- a/apps/edge/internal/configrefresh/provider_stall_timeout_test.go +++ b/apps/edge/internal/configrefresh/provider_stall_timeout_test.go @@ -42,7 +42,7 @@ nodes: raw string want bool }{ - {name: "positive is restart required", raw: "60000", want: true}, + {name: "positive is restart required", raw: "45000", want: true}, {name: "explicit zero matches omitted", raw: "0"}, } { t.Run(tc.name, func(t *testing.T) { diff --git a/packages/go/config/provider_catalog_validation_config_test.go b/packages/go/config/provider_catalog_validation_config_test.go index d6f89174..fe6034a3 100644 --- a/packages/go/config/provider_catalog_validation_config_test.go +++ b/packages/go/config/provider_catalog_validation_config_test.go @@ -493,10 +493,9 @@ nodes: } } -// REVIEW_COMPAT-1: TestLoadEdge_ProviderFirstLegacyCapacityMismatchZeroProvider -// verifies that same-key provider-first/legacy mixed config is rejected when -// provider capacity is 0 (not-dispatchable) and legacy capacity is > 0. -func TestLoadEdge_ProviderFirstLegacyCapacityMismatchZeroProvider(t *testing.T) { +// Provider capacity is an admission limit and may be lower than the backing +// adapter's transport capacity. +func TestLoadEdge_ProviderCapacityMayBeLowerThanAdapter(t *testing.T) { dir := t.TempDir() f := filepath.Join(dir, "edge.yaml") yaml := ` @@ -516,24 +515,17 @@ nodes: base_url: "http://127.0.0.1:11434" models: - "llama3:8b" - capacity: 0 + capacity: 1 ` if err := os.WriteFile(f, []byte(yaml), 0o600); err != nil { t.Fatalf("write yaml: %v", err) } - _, err := config.LoadEdge(f) - if err == nil { - t.Fatal("expected error for provider capacity 0 / legacy capacity 2 conflict") - } - if !strings.Contains(err.Error(), "conflicts with adapters") || !strings.Contains(err.Error(), "capacity mismatch") { - t.Fatalf("expected error containing 'conflicts with adapters' and 'capacity mismatch', got %v", err) + if _, err := config.LoadEdge(f); err != nil { + t.Fatalf("lower provider admission capacity must load: %v", err) } } -// REVIEW_COMPAT-1: TestLoadEdge_ProviderFirstLegacyCapacityMismatchOmittedProvider -// verifies that same-key provider-first/legacy mixed config is rejected when -// provider capacity is omitted (defaults to 0) and legacy capacity is > 0. -func TestLoadEdge_ProviderFirstLegacyCapacityMismatchOmittedProvider(t *testing.T) { +func TestLoadEdge_ProviderCapacityCannotExceedAdapter(t *testing.T) { dir := t.TempDir() f := filepath.Join(dir, "edge.yaml") yaml := ` @@ -553,22 +545,18 @@ nodes: base_url: "http://127.0.0.1:11434" models: - "llama3:8b" + capacity: 4 ` if err := os.WriteFile(f, []byte(yaml), 0o600); err != nil { t.Fatalf("write yaml: %v", err) } _, err := config.LoadEdge(f) - if err == nil { - t.Fatal("expected error for omitted provider capacity / legacy capacity 3 conflict") - } - if !strings.Contains(err.Error(), "conflicts with adapters") || !strings.Contains(err.Error(), "capacity mismatch") { - t.Fatalf("expected error containing 'conflicts with adapters' and 'capacity mismatch', got %v", err) + if err == nil || !strings.Contains(err.Error(), "provider capacity exceeds adapter capacity") { + t.Fatalf("provider capacity above adapter limit error = %v", err) } } -// REVIEW_COMPAT-1: TestLoadEdge_ProviderFirstLegacyCapacityMismatchVllm -// verifies capacity mismatch rejection for vllm/openai_compat provider type. -func TestLoadEdge_ProviderFirstLegacyCapacityMismatchVllm(t *testing.T) { +func TestLoadEdge_OpenAICompatProviderCapacityMayBeLowerThanAdapter(t *testing.T) { dir := t.TempDir() f := filepath.Join(dir, "edge.yaml") yaml := ` @@ -589,17 +577,13 @@ nodes: endpoint: "http://127.0.0.1:8000/v1" models: - "model-a" - capacity: 0 + capacity: 1 ` if err := os.WriteFile(f, []byte(yaml), 0o600); err != nil { t.Fatalf("write yaml: %v", err) } - _, err := config.LoadEdge(f) - if err == nil { - t.Fatal("expected error for vllm provider capacity 0 / legacy capacity 4 conflict") - } - if !strings.Contains(err.Error(), "conflicts with adapters") || !strings.Contains(err.Error(), "capacity mismatch") { - t.Fatalf("expected error containing 'conflicts with adapters' and 'capacity mismatch', got %v", err) + if _, err := config.LoadEdge(f); err != nil { + t.Fatalf("lower provider admission capacity must load: %v", err) } } diff --git a/packages/go/config/validate.go b/packages/go/config/validate.go index d9169dad..3f3c1ef3 100644 --- a/packages/go/config/validate.go +++ b/packages/go/config/validate.go @@ -447,8 +447,8 @@ func CheckProviderLegacyConflict(nodeIdx int, nodeName string, node *NodeDefinit return fmt.Errorf("nodes[%d].providers[%d] %q conflicts with adapters.ollama instance %q: context_size mismatch (provider=%d, legacy=%d)", nodeIdx, j, p.ID, p.ID, p.ContextSize, legacy.ContextSize) } - if p.Capacity != legacy.Capacity { - return fmt.Errorf("nodes[%d].providers[%d] %q conflicts with adapters.ollama instance %q: capacity mismatch (provider=%d, legacy=%d)", + if p.Capacity > legacy.Capacity { + return fmt.Errorf("nodes[%d].providers[%d] %q conflicts with adapters.ollama instance %q: provider capacity exceeds adapter capacity (provider=%d, legacy=%d)", nodeIdx, j, p.ID, p.ID, p.Capacity, legacy.Capacity) } case "vllm", "openai_compat": @@ -459,8 +459,8 @@ func CheckProviderLegacyConflict(nodeIdx int, nodeName string, node *NodeDefinit return fmt.Errorf("nodes[%d].providers[%d] %q conflicts with adapters.%s instance %q: endpoint mismatch (provider=%q, legacy=%q)", nodeIdx, j, p.ID, legacy.Type, p.ID, p.Endpoint, legacy.Endpoint) } - if p.Capacity != legacy.Capacity { - return fmt.Errorf("nodes[%d].providers[%d] %q conflicts with adapters.%s instance %q: capacity mismatch (provider=%d, legacy=%d)", + if p.Capacity > legacy.Capacity { + return fmt.Errorf("nodes[%d].providers[%d] %q conflicts with adapters.%s instance %q: provider capacity exceeds adapter capacity (provider=%d, legacy=%d)", nodeIdx, j, p.ID, legacy.Type, p.ID, p.Capacity, legacy.Capacity) } }