출력 반복을 요청 단위로 감지하고 안전한 continuation lifecycle을 보장하기 위해 Chat/Responses codec과 stream gate evidence를 함께 정렬한다.
505 lines
18 KiB
Go
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
|
|
}
|