iop/apps/edge/internal/openai/stream_gate_policy.go
toki d1e32b6e06 feat(openai): 반복 출력 복구를 구현한다
출력 반복을 요청 단위로 감지하고 안전한 continuation lifecycle을 보장하기 위해 Chat/Responses codec과 stream gate evidence를 함께 정렬한다.
2026-07-29 18:40:36 +09:00

505 lines
18 KiB
Go

package openai
import (
"crypto/sha256"
"encoding/json"
"fmt"
"sort"
"strings"
"time"
"unicode/utf8"
edgeservice "iop/apps/edge/internal/service"
"iop/packages/go/config"
"iop/packages/go/streamgate"
)
// chatRequestHasSchemeMetadata reports whether the request carries a non-empty
// metadata.scheme output contract. Presence, not shape, is enough to decide
// whether the schema_gate filter participates; JSON-schema validation itself is
// the schema-contract Task's scope.
func chatRequestHasSchemeMetadata(metadata json.RawMessage) bool {
if len(metadata) == 0 {
return false
}
var m map[string]json.RawMessage
if err := json.Unmarshal(metadata, &m); err != nil {
return false
}
raw, ok := m["scheme"]
if !ok {
return false
}
trimmed := strings.TrimSpace(string(raw))
return trimmed != "" && trimmed != "null"
}
// This file translates the request-stable stream_evidence_gate filter policy
// (packages/go/config) into Core FilterRegistrations and FilterPolicyLayers, and
// resolves required output-filter capabilities for provider admission. The Core
// owns selector precedence, per-target Applies re-resolution, and eligibility;
// this file only builds the generation-bound inputs and adapts the config
// enums to the Core enums.
// openAIOutputFilterContext carries the immutable request-start facts used by
// selector resolution and endpoint-specific filter participation.
type openAIOutputFilterContext struct {
environment string
endpoint string
modelGroup string
hasScheme bool
requestRef string
history openAIRepeatHistorySnapshot
}
const (
openAIRepeatHistoryAnchorThreshold = 2
openAIRepeatHistoryMaxFingerprints = 1024
openAIRepeatHistoryMaxActions = 256
)
type openAIRepeatChannel string
const (
openAIRepeatChannelContent openAIRepeatChannel = "content"
openAIRepeatChannelReasoning openAIRepeatChannel = "reasoning"
)
type openAIRepeatHistoryAction struct {
actionFingerprint streamgate.FixedFingerprint
resultFingerprint streamgate.FixedFingerprint
}
type openAIRepeatHistoryCandidate struct {
count int
normalizedRunes int
}
// openAIRepeatHistorySnapshot is the immutable, raw-free semantic view passed
// from an endpoint decoder to the request-local repeat filter. It intentionally
// has no session, caller, TTL, raw text, tool arguments, or tool result fields.
type openAIRepeatHistorySnapshot struct {
assistantOccurrences map[openAIRepeatChannel]map[streamgate.FixedFingerprint]int
userOccurrences map[streamgate.FixedFingerprint]int
assistantCandidates map[openAIRepeatChannel]map[streamgate.FixedFingerprint]openAIRepeatHistoryCandidate
repeatedActions map[streamgate.FixedFingerprint]int
hasCompletedProgress bool
hasTerminalProgress bool
}
type openAIRepeatHistoryBuilder struct {
assistantOccurrences map[openAIRepeatChannel]map[streamgate.FixedFingerprint]int
userOccurrences map[streamgate.FixedFingerprint]int
normalizedTextRunes map[streamgate.FixedFingerprint]int
completedActions []openAIRepeatHistoryAction
hasTerminalProgress bool
}
func newOpenAIRepeatHistoryBuilder() *openAIRepeatHistoryBuilder {
return &openAIRepeatHistoryBuilder{
assistantOccurrences: map[openAIRepeatChannel]map[streamgate.FixedFingerprint]int{
openAIRepeatChannelContent: {},
openAIRepeatChannelReasoning: {},
},
userOccurrences: make(map[streamgate.FixedFingerprint]int),
normalizedTextRunes: make(map[streamgate.FixedFingerprint]int),
}
}
func normalizeOpenAIRepeatText(value string) string {
return strings.Join(strings.Fields(value), " ")
}
func openAIRepeatTextFingerprint(value string) (streamgate.FixedFingerprint, bool) {
normalized := normalizeOpenAIRepeatText(value)
if normalized == "" {
return streamgate.FixedFingerprint{}, false
}
return streamgate.FixedFingerprint(sha256.Sum256([]byte(normalized))), true
}
func (b *openAIRepeatHistoryBuilder) admitTextFingerprint(fingerprint streamgate.FixedFingerprint, normalizedRunes int) bool {
if _, exists := b.normalizedTextRunes[fingerprint]; exists {
return true
}
if len(b.normalizedTextRunes) >= openAIRepeatHistoryMaxFingerprints {
return false
}
b.normalizedTextRunes[fingerprint] = normalizedRunes
return true
}
func openAIRepeatActionFingerprint(name string, arguments json.RawMessage) (streamgate.FixedFingerprint, bool) {
name = normalizeOpenAIRepeatText(name)
if name == "" {
return streamgate.FixedFingerprint{}, false
}
var canonical any
if len(arguments) > 0 && json.Unmarshal(arguments, &canonical) == nil {
if encoded, err := json.Marshal(canonical); err == nil {
arguments = encoded
}
}
normalizedArgs := normalizeOpenAIRepeatText(string(arguments))
return streamgate.FixedFingerprint(sha256.Sum256([]byte(name + "\x00" + normalizedArgs))), true
}
func (b *openAIRepeatHistoryBuilder) recordText(role string, channel openAIRepeatChannel, value string) {
role = strings.ToLower(strings.TrimSpace(role))
if role != "assistant" && role != "user" {
return
}
normalized := normalizeOpenAIRepeatText(value)
if normalized == "" {
return
}
fingerprint := streamgate.FixedFingerprint(sha256.Sum256([]byte(normalized)))
if !b.admitTextFingerprint(fingerprint, utf8.RuneCountInString(normalized)) {
return
}
switch role {
case "assistant":
if b.assistantOccurrences[channel] == nil {
b.assistantOccurrences[channel] = make(map[streamgate.FixedFingerprint]int)
}
b.assistantOccurrences[channel][fingerprint]++
case "user":
b.userOccurrences[fingerprint]++
}
}
func (b *openAIRepeatHistoryBuilder) recordCompletedAction(action, result streamgate.FixedFingerprint) {
if action == (streamgate.FixedFingerprint{}) || result == (streamgate.FixedFingerprint{}) {
return
}
if len(b.completedActions) >= openAIRepeatHistoryMaxActions {
return
}
b.completedActions = append(b.completedActions, openAIRepeatHistoryAction{
actionFingerprint: action,
resultFingerprint: result,
})
}
func (b *openAIRepeatHistoryBuilder) snapshot() openAIRepeatHistorySnapshot {
snapshot := openAIRepeatHistorySnapshot{
assistantOccurrences: map[openAIRepeatChannel]map[streamgate.FixedFingerprint]int{
openAIRepeatChannelContent: {},
openAIRepeatChannelReasoning: {},
},
userOccurrences: map[streamgate.FixedFingerprint]int{},
assistantCandidates: map[openAIRepeatChannel]map[streamgate.FixedFingerprint]openAIRepeatHistoryCandidate{
openAIRepeatChannelContent: {},
openAIRepeatChannelReasoning: {},
},
repeatedActions: make(map[streamgate.FixedFingerprint]int),
hasTerminalProgress: b.hasTerminalProgress,
}
for fingerprint, count := range b.userOccurrences {
snapshot.userOccurrences[fingerprint] = count
}
totalAssistant := make(map[streamgate.FixedFingerprint]int)
for channel, occurrences := range b.assistantOccurrences {
for fingerprint, count := range occurrences {
snapshot.assistantOccurrences[channel][fingerprint] = count
totalAssistant[fingerprint] += count
}
}
for channel, occurrences := range snapshot.assistantOccurrences {
for fingerprint := range occurrences {
if snapshot.userOccurrences[fingerprint] == 0 &&
totalAssistant[fingerprint] >= openAIRepeatHistoryAnchorThreshold {
snapshot.assistantCandidates[channel][fingerprint] = openAIRepeatHistoryCandidate{
count: totalAssistant[fingerprint],
normalizedRunes: b.normalizedTextRunes[fingerprint],
}
}
}
}
if len(b.completedActions) >= 2 {
previous := b.completedActions[len(b.completedActions)-2]
current := b.completedActions[len(b.completedActions)-1]
snapshot.hasCompletedProgress =
previous.resultFingerprint != current.resultFingerprint
if previous.actionFingerprint == current.actionFingerprint &&
previous.resultFingerprint == current.resultFingerprint {
snapshot.repeatedActions[current.actionFingerprint] = 1
}
}
return snapshot
}
func (s openAIRepeatHistorySnapshot) assistantCandidate(channel openAIRepeatChannel, fingerprint streamgate.FixedFingerprint) (int, bool) {
candidate, ok := s.assistantCandidates[channel][fingerprint]
return candidate.count, ok
}
func (s openAIRepeatHistorySnapshot) assistantCandidateMetadata(channel openAIRepeatChannel, fingerprint streamgate.FixedFingerprint) (openAIRepeatHistoryCandidate, bool) {
candidate, ok := s.assistantCandidates[channel][fingerprint]
return candidate, ok
}
func (s openAIRepeatHistorySnapshot) repeatedAction(fingerprint streamgate.FixedFingerprint) (int, bool) {
count, ok := s.repeatedActions[fingerprint]
return count, ok
}
func (s openAIRepeatHistorySnapshot) completedProgress() bool {
return s.hasCompletedProgress || s.hasTerminalProgress
}
// streamgateFilterEnforcement adapts a config enforcement string to the Core
// enforcement enum, defaulting to blocking for empty/unknown input (validation
// rejects unknown values before this point).
func streamgateFilterEnforcement(s string) streamgate.FilterEnforcement {
if s == config.StreamGateFilterEnforcementObserveOnly {
return streamgate.FilterEnforcementObserveOnly
}
return streamgate.FilterEnforcementBlocking
}
// streamgateSelectorType adapts a config selector type to the Core selector
// type. It returns false when the type is unknown.
func streamgateSelectorType(s string) (streamgate.PolicySelectorType, bool) {
switch s {
case config.StreamGateFilterSelectorEnvironment:
return streamgate.PolicySelectorEnvironment, true
case config.StreamGateFilterSelectorModelGroup:
return streamgate.PolicySelectorModelGroup, true
case config.StreamGateFilterSelectorModel:
return streamgate.PolicySelectorModel, true
case config.StreamGateFilterSelectorProvider:
return streamgate.PolicySelectorProvider, true
default:
return "", false
}
}
// openAIOutputFilterRegistrations builds the Core registrations and policy
// layers for the configured semantic output filters. A schema_gate filter is
// only registered when the caller requested a schema contract; a request with
// no scheme neither registers nor requires it. The returned slices are the
// request-stable inputs to a generation-bound FilterRegistrySnapshot.
func openAIOutputFilterRegistrations(gateCfg config.StreamEvidenceGateConf, fctx openAIOutputFilterContext) ([]streamgate.FilterRegistration, []streamgate.FilterPolicyLayer, error) {
var (
regs []streamgate.FilterRegistration
policies []streamgate.FilterPolicyLayer
)
for _, fc := range gateCfg.Filters {
if fc.Filter == config.StreamGateFilterSchemaGate && !fctx.hasScheme {
continue
}
filter, err := newOpenAIOutputFilter(
openAIOutputFilterKind(fc.Filter),
fc.EffectiveHoldEvidenceRunes(),
fc.Priority,
fctx.requestRef,
fctx.history,
)
if err != nil {
return nil, nil, err
}
filters := []*openAIOutputFilter{filter}
if fc.Filter == config.StreamGateFilterRepeatGuard {
actionFilter, actionErr := newOpenAIOutputFilter(
openAIOutputFilterRepeatActionGuard,
fc.EffectiveHoldEvidenceRunes(),
fc.Priority,
fctx.requestRef,
fctx.history,
)
if actionErr != nil {
return nil, nil, actionErr
}
filters = append(filters, actionFilter)
}
timeout := time.Duration(fc.EffectiveTimeoutMS()) * time.Millisecond
for _, registeredFilter := range filters {
reg, err := streamgate.NewFilterRegistration(
registeredFilter, fc.EffectiveCapability(), fc.EffectiveEnabled(),
streamgateFilterEnforcement(fc.EffectiveEnforcement()), timeout, fc.Priority,
)
if err != nil {
return nil, nil, err
}
regs = append(regs, reg)
for i, sel := range fc.Selectors {
selType, ok := streamgateSelectorType(sel.Type)
if !ok {
return nil, nil, fmt.Errorf("openai output filter %q selector %d unknown type %q", fc.Filter, i, sel.Type)
}
enabled := fc.EffectiveEnabled()
if sel.Enabled != nil {
enabled = *sel.Enabled
}
enforcement := fc.EffectiveEnforcement()
if sel.Enforcement != "" {
enforcement = sel.Enforcement
}
layer, err := streamgate.NewFilterPolicyLayer(
registeredFilter.ID(), selType, sel.Key, enabled,
streamgateFilterEnforcement(enforcement), timeout, fc.Priority,
)
if err != nil {
return nil, nil, err
}
policies = append(policies, layer)
}
}
}
return regs, policies, nil
}
// openAIOutputFilterRequestContext builds the caller-neutral RequestFilterContext
// used to resolve required capabilities and eligibility. It intentionally
// carries no caller/agent product name: the same payload resolves identically
// for raw HTTP, OpenAI SDK, or any other caller.
func openAIOutputFilterRequestContext(fctx openAIOutputFilterContext) (streamgate.RequestFilterContext, error) {
return streamgate.NewRequestFilterContext(
streamGateConfigGeneration,
"admission",
fctx.environment,
fctx.endpoint,
openAIRebuildFamily,
"",
streamgate.CommitStateTransportUncommitted,
false,
false,
"",
)
}
// blockingCapabilitiesForTarget resolves effective policy at the actual target
// and excludes observe-only filters from admission.
func blockingCapabilitiesForTarget(reqSnap streamgate.RequestFilterSnapshot, target streamgate.AttemptTarget) ([]string, error) {
resolved, err := reqSnap.ResolveAttempt(target)
if err != nil {
return nil, err
}
set := make(map[string]struct{}, len(resolved))
for _, filter := range resolved {
if filter.Enforcement() != streamgate.FilterEnforcementBlocking {
continue
}
set[filter.Registration().RequiredCapabilityID()] = struct{}{}
}
required := make([]string, 0, len(set))
for capability := range set {
required = append(required, capability)
}
sort.Strings(required)
return required, nil
}
func candidateHasCapabilities(target streamgate.AttemptTarget, required []string) bool {
for _, capability := range required {
if !target.HasCapability(capability) {
return false
}
}
return true
}
// openAIStreamGateRequiredCapabilities returns the union of output-filter
// capabilities that the given candidate target must advertise. Only
// enabled+applicable filters at the target contribute.
func openAIStreamGateRequiredCapabilities(gateCfg config.StreamEvidenceGateConf, fctx openAIOutputFilterContext, target streamgate.AttemptTarget) ([]string, error) {
regs, policies, err := openAIOutputFilterRegistrations(gateCfg, fctx)
if err != nil {
return nil, err
}
snap, err := streamgate.NewFilterRegistrySnapshot(streamGateConfigGeneration, regs, policies)
if err != nil {
return nil, err
}
reqCtx, err := openAIOutputFilterRequestContext(fctx)
if err != nil {
return nil, err
}
reqSnap, err := snap.BeginRequest(reqCtx)
if err != nil {
return nil, err
}
return blockingCapabilitiesForTarget(reqSnap, target)
}
// errStreamGateRequiredCapabilityUnsupported is the admission-time error a
// handler maps to an OpenAI-compatible invalid_request_error (400) when a
// required output-filter capability is unsupported by every candidate provider,
// before any provider dispatch or recovery budget is consumed.
var errStreamGateRequiredCapabilityUnsupported = fmt.Errorf("stream gate: required output filter capability unsupported by all candidates")
// openAIStreamGateAdmitCandidates returns the subset of candidate targets whose
// capability set covers every required output-filter capability resolved at each
// candidate's own target context. It returns
// errStreamGateRequiredCapabilityUnsupported when no candidate is eligible, so
// the caller rejects the request with a pre-dispatch 400 instead of leaving a
// required filter silently unenforced.
func openAIStreamGateAdmitCandidates(gateCfg config.StreamEvidenceGateConf, fctx openAIOutputFilterContext, candidates []streamgate.AttemptTarget) ([]streamgate.AttemptTarget, error) {
regs, policies, err := openAIOutputFilterRegistrations(gateCfg, fctx)
if err != nil {
return nil, err
}
// No configured output filters => no required capability => every candidate
// is admissible; preserve the existing provider-pool admission unchanged.
if len(regs) == 0 {
return candidates, nil
}
snap, err := streamgate.NewFilterRegistrySnapshot(streamGateConfigGeneration, regs, policies)
if err != nil {
return nil, err
}
reqCtx, err := openAIOutputFilterRequestContext(fctx)
if err != nil {
return nil, err
}
reqSnap, err := snap.BeginRequest(reqCtx)
if err != nil {
return nil, err
}
eligible := make([]streamgate.AttemptTarget, 0, len(candidates))
for _, candidate := range candidates {
required, err := blockingCapabilitiesForTarget(reqSnap, candidate)
if err != nil {
return nil, err
}
if candidateHasCapabilities(candidate, required) {
eligible = append(eligible, candidate)
}
}
if len(eligible) == 0 {
return nil, errStreamGateRequiredCapabilityUnsupported
}
return eligible, nil
}
// openAIStreamGateCandidatePredicate turns one immutable request policy into a
// provider-pool admission predicate. Service invokes it before the first
// dispatch and on every recovery/queue re-resolution, so provider-specific
// selectors are evaluated against the actual selected target rather than a
// caller-supplied model or agent identity.
func openAIStreamGateCandidatePredicate(gateCfg config.StreamEvidenceGateConf, fctx openAIOutputFilterContext) (edgeservice.ProviderPoolCandidatePredicate, error) {
regs, _, err := openAIOutputFilterRegistrations(gateCfg, fctx)
if err != nil {
return nil, err
}
if len(regs) == 0 {
return nil, nil
}
return func(candidate edgeservice.ProviderPoolCandidate) bool {
target, err := streamgate.NewAttemptTarget(
fctx.modelGroup,
candidate.ActualModel,
candidate.ProviderID,
candidate.ExecutionPath,
candidate.LifecycleCapabilities,
)
if err != nil {
return false
}
eligible, err := openAIStreamGateAdmitCandidates(gateCfg, fctx, []streamgate.AttemptTarget{target})
return err == nil && len(eligible) == 1
}, nil
}