fix(streamgate): 반복 복구 설정 모순을 차단한다
blocking repeat guard가 복구 예산 0과 함께 배포되면 런타임에서 recovery_failed로 종료된다. 설정 로드 단계에서 모순을 거부하고 dev의 단일 내부 복구 기준을 명시한다.
This commit is contained in:
parent
653977383b
commit
c31130e68b
5 changed files with 132 additions and 3 deletions
|
|
@ -51,7 +51,7 @@ tracked config에는 public 예시와 기본 구조만 두고, 실제 endpoint/c
|
|||
- `ConcreteProtocolProfile.ResolveOperationURL(op)`는 완성된 resolved upstream URL을 반환한다. absolute operation URL은 그대로 보존하며 relative operation path는 normalized base URL에 1회 join된다. 표기된 `/v1/...` 값은 return value가 아니라 operation-path input이다 (`models` → `GET /v1/models` 또는 `GET /anthropic/v1/models`, `chat_completions` → `POST /v1/chat/completions`, `messages` → `POST /v1/messages`, `count_tokens` → `POST /v1/messages/count_tokens`, `responses` → `POST /v1/responses`).
|
||||
- `validOperationsByDriver`는 driver별 허용 operation의 closed set이다. `openai_chat`은 `models`, `chat_completions`, `responses`, `count_tokens`를 허용한다. `anthropic_messages`는 `models`, `messages`, `count_tokens`를 허용한다. `openai_responses`는 `models`, `responses`, `count_tokens`를 허용한다.
|
||||
- `openai.provider_auth` is a legacy-mode-only request-time raw provider token forwarding rule. `enabled=false` is the default; when enabled in legacy mode, omitted fields resolve to `from_header=X-IOP-Provider-Authorization`, `target_header=Authorization`, `scheme=Bearer`, and `required=true`. Managed mode rejects this configuration and rejects a caller-supplied legacy provider credential header.
|
||||
- `openai.stream_evidence_gate` configures request-local Recovery Coordinator limits, the ingress snapshot bound, and optional semantic policy. Every supported Chat Completions, normalized Responses, provider tunnel, provider-pool, and tool-validation response already uses the `packages/go/streamgate` request runtime as its sole liveness owner. `enabled` defaults to false and controls only configured semantic filter registration/capability admission; false preserves endpoint-native compatibility inside the same runtime and does not restore a legacy response or retry owner. `max_request_fault_recovery`는 요청당 전체 fault recovery 상한(`0..3`, omitted 기본값 3, explicit 0은 모든 fault recovery 비활성화)이다. `max_strategy_fault_recovery`는 fault strategy(exact_replay/continuation_repair/schema_repair)별 상한(`0..max_request_fault_recovery`, omitted 기본값은 effective request total 상속, explicit 0은 해당 strategy 비활성화)이며 request-start 시점에 immutable runtime option snapshot으로 각 fault strategy에 동일하게 적용된다. `max_ingress_snapshot_bytes`는 ingress snapshot 바이트 상한(`1..16777216` [16 MiB], omitted/0 기본값 16 MiB)이다. `environment`는 request-start selector snapshot이며 `dev|dev-corp`만 허용하고 omitted 기본값은 `dev`다. `filters[]`는 unique `filter` (`repeat_guard|schema_gate|provider_error`) policy이다. `enabled` omitted=true, `enforcement` omitted=`blocking`, `capability` omitted=`output.<filter>`, `hold_evidence_runes` omitted=500, `timeout_ms` omitted=5000으로 정규화하며 selector는 `environment|model_group|model|provider`로만 filter enablement/enforcement를 보정한다. base-disabled filter도 registry snapshot에 남아 더 구체적인 selector가 활성화할 수 있고, 실제 target에서 활성화된 `blocking` filter만 provider capability admission에 참여한다. `observe_only`는 evidence를 만들지만 admission을 막지 않는다. `repeat_guard` uses the configured rune bound for active request-local history/current-stream inspection and stores only bounded fingerprints, counts, and offsets in its semantic snapshot and observations. `schema_gate` and `provider_error` remain lifecycle foundations until their matcher Tasks; an unmatched provider error never creates exact replay. Config accepts no caller/agent selector.
|
||||
- `openai.stream_evidence_gate` configures request-local Recovery Coordinator limits, the ingress snapshot bound, and optional semantic policy. Every supported Chat Completions, normalized Responses, provider tunnel, provider-pool, and tool-validation response already uses the `packages/go/streamgate` request runtime as its sole liveness owner. `enabled` defaults to false and controls only configured semantic filter registration/capability admission; false preserves endpoint-native compatibility inside the same runtime and does not restore a legacy response or retry owner. `max_request_fault_recovery`는 요청당 전체 fault recovery 상한(`0..3`, omitted 기본값 3, explicit 0은 모든 fault recovery 비활성화)이다. `max_strategy_fault_recovery`는 fault strategy(exact_replay/continuation_repair/schema_repair)별 상한(`0..max_request_fault_recovery`, omitted 기본값은 effective request total 상속, explicit 0은 해당 strategy 비활성화)이며 request-start 시점에 immutable runtime option snapshot으로 각 fault strategy에 동일하게 적용된다. base 또는 selector에서 활성화될 수 있는 blocking `repeat_guard`는 continuation recovery를 생성하므로 두 recovery 상한이 모두 최소 1이어야 하며, 모순된 설정은 load 시 거부한다. `max_ingress_snapshot_bytes`는 ingress snapshot 바이트 상한(`1..16777216` [16 MiB], omitted/0 기본값 16 MiB)이다. `environment`는 request-start selector snapshot이며 `dev|dev-corp`만 허용하고 omitted 기본값은 `dev`다. `filters[]`는 unique `filter` (`repeat_guard|schema_gate|provider_error`) policy이다. `enabled` omitted=true, `enforcement` omitted=`blocking`, `capability` omitted=`output.<filter>`, `hold_evidence_runes` omitted=500, `timeout_ms` omitted=5000으로 정규화하며 selector는 `environment|model_group|model|provider`로만 filter enablement/enforcement를 보정한다. base-disabled filter도 registry snapshot에 남아 더 구체적인 selector가 활성화할 수 있고, 실제 target에서 활성화된 `blocking` filter만 provider capability admission에 참여한다. `observe_only`는 evidence를 만들지만 admission을 막지 않는다. `repeat_guard` uses the configured rune bound for active request-local history/current-stream inspection and stores only bounded fingerprints, counts, and offsets in its semantic snapshot and observations. `schema_gate` and `provider_error` remain lifecycle foundations until their matcher Tasks; an unmatched provider error never creates exact replay. Config accepts no caller/agent selector.
|
||||
- `openai.stream_evidence_gate` 설정은 request-start 시점에 snapshot으로 고정되며 in-flight request의 실행 중 refresh 영향에서 격리된다 (generation isolation). 새 generation의 설정은 이후 시작되는 새 request에만 적용된다.
|
||||
- The internal `response_stalled` recovery registration is always present for a supported OpenAI runtime request. It is not a member of `filters[]`, has no configurable capability, and does not participate in provider capability admission. It consumes only an Edge-confirmed typed handoff; configurable `provider_error` keeps its generic foundation behavior.
|
||||
- The request-start `models[].context_window_tokens` snapshot is the resume builder's target context bound. Each Chat/Responses runtime shares one request-local content/reasoning recorder across its initial and recovery event sources. A continuation rebuild uses only that recorder and the fixed directive; unknown or exceeded context rejects the rebuild before re-admission. An omitted caller temperature selects `0.2`, `0.4`, then `0.6` by continuation strategy attempt, while an explicit value is preserved. Recorder state and its raw values remain request-local, are consumed once per attempt, and are never added to config refresh state or observations. Repeat history and counters are pinned to the same request-start config generation and are not refreshable TTL/session state.
|
||||
|
|
|
|||
|
|
@ -110,7 +110,7 @@ sequenceDiagram
|
|||
## 설정/데이터/이벤트
|
||||
|
||||
- `openai.stream_evidence_gate.enabled` defaults to `false` and controls only configured semantic filters and their capability admission. The Core owns the supported response/liveness lifecycle in both states, while disabled mode preserves endpoint-native compatibility through runtime adapters.
|
||||
- `max_request_fault_recovery`는 0..3, `max_strategy_fault_recovery`는 0..request-total이고 생략 시 request-total을 상속한다.
|
||||
- `max_request_fault_recovery`는 0..3, `max_strategy_fault_recovery`는 0..request-total이고 생략 시 request-total을 상속한다. base 또는 selector에서 활성화될 수 있는 blocking `repeat_guard`는 두 상한이 모두 최소 1이어야 한다.
|
||||
- `max_ingress_snapshot_bytes`는 1..16777216이며 생략 시 16 MiB다. raw body limit은 첫 read 전에 적용되고 canonical body, typed view와 rebuild peak가 같은 request-local ledger에 포함된다.
|
||||
- Stream Evidence Gate 설정 변경은 현재 restart-required다. request가 시작된 뒤 config/registry snapshot은 바뀌지 않는다.
|
||||
- The production Core registry includes the common Noop filter, configured active `repeat_guard`, schema/provider-error lifecycle foundations, and applicable request-local tool validation. Repeat detection uses the configured 500-rune default, never time-based release, and returns a continuation only before a tool/side-effect boundary. Provider-error still records unmatched errors as pass until its matcher Task.
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ Agent dispatcher silence safety = 70000
|
|||
Edge request hard timeout > 70000
|
||||
```
|
||||
|
||||
dev-runtime은 `max_request_fault_recovery: 0`과 `max_strategy_fault_recovery: 0`을 명시하고 Pi agent retry도 비활성화한다. 따라서 provider 무진행은 Node가 60초에 한 번만 종료하며 Pi·Edge·Dispatcher가 같은 요청을 다시 중첩 실행하지 않는다.
|
||||
dev-runtime은 blocking `repeat_guard`와 함께 `max_request_fault_recovery: 1`, `max_strategy_fault_recovery: 1`을 사용하고 Pi agent retry는 비활성화한다. 따라서 반복 출력에는 Stream Evidence Gate가 요청 내부 continuation recovery를 최대 한 번 수행하고, 외부 Pi 재시도나 중첩 요청은 만들지 않는다.
|
||||
|
||||
`response_stall_timeout_ms` 변경은 restart-required다. `config check`와 `config refresh --mode dry-run`에서 이를 확인한 뒤 Edge와 Node를 같은 source ref로 rebuild/restart하고, 각 binary의 build identity와 실행 중인 process identity를 다시 대조한다. tracked 검증 근거에는 source/build/config 식별자, 단조 시간, terminal 개수와 결과 분류만 남기며 prompt, output, token, credential 원문은 기록하지 않는다.
|
||||
|
||||
|
|
|
|||
|
|
@ -540,6 +540,11 @@ func (s StreamEvidenceGateConf) Validate() error {
|
|||
if effStrategy > effTotal {
|
||||
return fmt.Errorf("max_strategy_fault_recovery (%d) must not exceed max_request_fault_recovery (%d)", effStrategy, effTotal)
|
||||
}
|
||||
for i, filter := range s.Filters {
|
||||
if s.Enabled && filter.Filter == StreamGateFilterRepeatGuard && filterCanResolveBlocking(filter) && (effTotal == 0 || effStrategy == 0) {
|
||||
return fmt.Errorf("stream_evidence_gate filters[%d]: blocking repeat_guard requires max_request_fault_recovery and max_strategy_fault_recovery to be at least 1", i)
|
||||
}
|
||||
}
|
||||
|
||||
effIngress := s.EffectiveMaxIngressSnapshotBytes()
|
||||
maxAllowedIngress := 16 * 1024 * 1024
|
||||
|
|
@ -561,6 +566,29 @@ func (s StreamEvidenceGateConf) Validate() error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func filterCanResolveBlocking(filter StreamGateFilterPolicyConf) bool {
|
||||
if filter.EffectiveEnabled() && filter.EffectiveEnforcement() == StreamGateFilterEnforcementBlocking {
|
||||
return true
|
||||
}
|
||||
for _, selector := range filter.Selectors {
|
||||
enabled := filter.EffectiveEnabled()
|
||||
if selector.Enabled != nil {
|
||||
enabled = *selector.Enabled
|
||||
}
|
||||
if !enabled {
|
||||
continue
|
||||
}
|
||||
enforcement := filter.EffectiveEnforcement()
|
||||
if selector.Enforcement != "" {
|
||||
enforcement = selector.Enforcement
|
||||
}
|
||||
if enforcement == StreamGateFilterEnforcementBlocking {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// EdgeOpenAIProviderAuthConf configures request-time provider token forwarding.
|
||||
// It only describes which caller header to forward and never stores raw token
|
||||
// values in config.
|
||||
|
|
|
|||
|
|
@ -406,6 +406,107 @@ openai:
|
|||
}
|
||||
}
|
||||
|
||||
func TestStreamEvidenceGate_BlockingRepeatGuardRequiresRecoveryBudget(t *testing.T) {
|
||||
boolPtr := func(value bool) *bool { return &value }
|
||||
zero, one := 0, 1
|
||||
tests := []struct {
|
||||
name string
|
||||
gate config.StreamEvidenceGateConf
|
||||
expectErr bool
|
||||
}{
|
||||
{
|
||||
name: "blocking base with zero budget",
|
||||
gate: config.StreamEvidenceGateConf{
|
||||
Enabled: true,
|
||||
MaxRequestFaultRecovery: &zero, MaxStrategyFaultRecovery: &zero,
|
||||
Filters: []config.StreamGateFilterPolicyConf{{Filter: config.StreamGateFilterRepeatGuard}},
|
||||
},
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
name: "blocking selector with zero budget",
|
||||
gate: config.StreamEvidenceGateConf{
|
||||
Enabled: true,
|
||||
MaxRequestFaultRecovery: &zero, MaxStrategyFaultRecovery: &zero,
|
||||
Filters: []config.StreamGateFilterPolicyConf{{
|
||||
Filter: config.StreamGateFilterRepeatGuard, Enabled: boolPtr(false),
|
||||
Selectors: []config.StreamGateFilterSelectorConf{{
|
||||
Type: config.StreamGateFilterSelectorModelGroup, Key: "ornith:35b", Enabled: boolPtr(true),
|
||||
}},
|
||||
}},
|
||||
},
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
name: "blocking selector with one recovery",
|
||||
gate: config.StreamEvidenceGateConf{
|
||||
Enabled: true,
|
||||
MaxRequestFaultRecovery: &one, MaxStrategyFaultRecovery: &one,
|
||||
Filters: []config.StreamGateFilterPolicyConf{{
|
||||
Filter: config.StreamGateFilterRepeatGuard, Enabled: boolPtr(false),
|
||||
Selectors: []config.StreamGateFilterSelectorConf{{
|
||||
Type: config.StreamGateFilterSelectorModelGroup, Key: "ornith:35b", Enabled: boolPtr(true),
|
||||
}},
|
||||
}},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "observe only with zero budget",
|
||||
gate: config.StreamEvidenceGateConf{
|
||||
Enabled: true,
|
||||
MaxRequestFaultRecovery: &zero, MaxStrategyFaultRecovery: &zero,
|
||||
Filters: []config.StreamGateFilterPolicyConf{{
|
||||
Filter: config.StreamGateFilterRepeatGuard, Enforcement: config.StreamGateFilterEnforcementObserveOnly,
|
||||
}},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "blocking selector enforcement with zero budget",
|
||||
gate: config.StreamEvidenceGateConf{
|
||||
Enabled: true,
|
||||
MaxRequestFaultRecovery: &zero, MaxStrategyFaultRecovery: &zero,
|
||||
Filters: []config.StreamGateFilterPolicyConf{{
|
||||
Filter: config.StreamGateFilterRepeatGuard, Enforcement: config.StreamGateFilterEnforcementObserveOnly,
|
||||
Selectors: []config.StreamGateFilterSelectorConf{{
|
||||
Type: config.StreamGateFilterSelectorModelGroup, Key: "ornith:35b", Enforcement: config.StreamGateFilterEnforcementBlocking,
|
||||
}},
|
||||
}},
|
||||
},
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
name: "disabled with zero budget",
|
||||
gate: config.StreamEvidenceGateConf{
|
||||
Enabled: true,
|
||||
MaxRequestFaultRecovery: &zero, MaxStrategyFaultRecovery: &zero,
|
||||
Filters: []config.StreamGateFilterPolicyConf{{
|
||||
Filter: config.StreamGateFilterRepeatGuard, Enabled: boolPtr(false),
|
||||
}},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "disabled gate with blocking filter and zero budget",
|
||||
gate: config.StreamEvidenceGateConf{
|
||||
Enabled: false,
|
||||
MaxRequestFaultRecovery: &zero, MaxStrategyFaultRecovery: &zero,
|
||||
Filters: []config.StreamGateFilterPolicyConf{{Filter: config.StreamGateFilterRepeatGuard}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.gate.Validate()
|
||||
if tt.expectErr && err == nil {
|
||||
t.Fatal("Validate() succeeded, want recovery-budget error")
|
||||
}
|
||||
if !tt.expectErr && err != nil {
|
||||
t.Fatalf("Validate() failed: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamEvidenceGate_IngressSnapshotBytes_TableFixture(t *testing.T) {
|
||||
maxAllowed := 16 * 1024 * 1024 // 16777216
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue