출력 반복을 요청 단위로 감지하고 안전한 continuation lifecycle을 보장하기 위해 Chat/Responses codec과 stream gate evidence를 함께 정렬한다.
959 lines
29 KiB
Go
959 lines
29 KiB
Go
package openai
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"sync"
|
|
"sync/atomic"
|
|
|
|
"iop/packages/go/streamgate"
|
|
)
|
|
|
|
const (
|
|
openAIRebuildEndpointChat = "/v1/chat/completions"
|
|
openAIRebuildEndpointResponses = "/v1/responses"
|
|
openAIRebuildFamily = "openai.json"
|
|
)
|
|
|
|
// openAIRepeatResumeDirective is the fixed English continuation instruction the
|
|
// resume-notice builder writes when a repeat-loop continuation plan is selected.
|
|
// It is byte-stable: recovery never rewrites, summarizes, or translates it, and
|
|
// it is the only non-model text added to a resume body.
|
|
const openAIRepeatResumeDirective = "The previous model output was stopped after a repetition loop was detected. Continue from the provided content and reasoning output without repeating already generated text."
|
|
|
|
// openAIRebuildContextReserveTokens is the completion-token headroom reserved on
|
|
// top of the measured resume prompt when comparing against the target model's
|
|
// context window. A resume body whose prompt plus this reserve exceeds the
|
|
// window (or whose window is unknown) is refused before any dispatch.
|
|
const openAIRebuildContextReserveTokens = 256
|
|
|
|
var openAIRebuiltSequence atomic.Uint64
|
|
|
|
var (
|
|
errOpenAIRecoveryPatchDuplicate = errors.New("duplicate OpenAI recovery patch")
|
|
errOpenAIRebuildContextOverflow = errors.New("OpenAI resume request exceeds the target model context window")
|
|
errOpenAIRecoverySourceUnavailable = errors.New("OpenAI recovery source snapshot is unavailable")
|
|
errOpenAIRecoverySourceCursorRange = errors.New("OpenAI recovery source cursor is out of range")
|
|
)
|
|
|
|
// openAIRecoverySourceStore is the request-local recorder of the current
|
|
// attempt's model output. It captures text and reasoning deltas in event order
|
|
// per channel behind a bounded byte cap and exposes only a stable snapshot ref
|
|
// and cursor to filters, never the raw output. The Rebuilder consumes a snapshot
|
|
// once, after the attempt that produced it is aborted, to assemble an
|
|
// endpoint-specific continuation body. Its ref is the ingress recovery ref so a
|
|
// continuation directive can address it without leaking model output.
|
|
type openAIRecoverySourceStore struct {
|
|
mu sync.Mutex
|
|
ref string
|
|
maxBytes int
|
|
content []byte
|
|
reasoning []byte
|
|
// suppressContent/suppressReasoning are one-attempt known-prefix guards.
|
|
// consume installs the safe continuation prefix and the replacement event
|
|
// source removes it only when the provider echoes it byte-for-byte.
|
|
suppressContent []byte
|
|
suppressReasoning []byte
|
|
overflow bool
|
|
consumed bool
|
|
closed bool
|
|
}
|
|
|
|
func newOpenAIRecoverySourceStore(ingress *openAIIngressSnapshot) *openAIRecoverySourceStore {
|
|
store := &openAIRecoverySourceStore{}
|
|
if ref, err := ingress.recoveryRef(); err == nil {
|
|
store.ref = ref.SnapshotRef()
|
|
store.maxBytes = int(ref.MaxBytes())
|
|
}
|
|
return store
|
|
}
|
|
|
|
func (s *openAIRecoverySourceStore) snapshotRef() string {
|
|
if s == nil {
|
|
return ""
|
|
}
|
|
return s.ref
|
|
}
|
|
|
|
func (s *openAIRecoverySourceStore) recordText(text string) { s.record(true, text) }
|
|
func (s *openAIRecoverySourceStore) recordReasoning(text string) { s.record(false, text) }
|
|
|
|
func (s *openAIRecoverySourceStore) record(isContent bool, text string) {
|
|
if s == nil || text == "" {
|
|
return
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.closed {
|
|
return
|
|
}
|
|
if s.maxBytes > 0 && len(s.content)+len(s.reasoning)+len(text) > s.maxBytes {
|
|
s.overflow = true
|
|
return
|
|
}
|
|
if isContent {
|
|
s.content = append(s.content, text...)
|
|
} else {
|
|
s.reasoning = append(s.reasoning, text...)
|
|
}
|
|
}
|
|
|
|
// resetAttempt clears the recorded output at the start of a new attempt so a
|
|
// snapshot only ever contains the single attempt that produced it.
|
|
func (s *openAIRecoverySourceStore) resetAttempt() {
|
|
if s == nil {
|
|
return
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.closed {
|
|
return
|
|
}
|
|
s.content = s.content[:0]
|
|
s.reasoning = s.reasoning[:0]
|
|
s.overflow = false
|
|
s.consumed = false
|
|
}
|
|
|
|
// consume returns the recorded channels with the repeated tail removed. A
|
|
// cursor at or below content length addresses the content channel. A cursor
|
|
// above content length uses content-length+1 as a reasoning-channel sentinel
|
|
// and addresses the reasoning prefix after it. This keeps the public directive
|
|
// raw-free and single-field while preserving a rune-boundary byte cursor for
|
|
// either model-output channel.
|
|
func (s *openAIRecoverySourceStore) consume(cursor int) (string, string, error) {
|
|
if s == nil {
|
|
return "", "", streamgate.ErrIngressSnapshotClosed
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.closed {
|
|
return "", "", streamgate.ErrIngressSnapshotClosed
|
|
}
|
|
if s.consumed {
|
|
return "", "", errOpenAIRecoverySourceUnavailable
|
|
}
|
|
if s.overflow {
|
|
return "", "", errOpenAIRebuildContextOverflow
|
|
}
|
|
if len(s.content)+len(s.reasoning) == 0 {
|
|
return "", "", errOpenAIRecoverySourceUnavailable
|
|
}
|
|
if cursor < 0 {
|
|
return "", "", errOpenAIRecoverySourceCursorRange
|
|
}
|
|
contentEnd, reasoningEnd := len(s.content), len(s.reasoning)
|
|
if cursor <= len(s.content) {
|
|
contentEnd = cursor
|
|
} else {
|
|
reasoningEnd = cursor - len(s.content) - 1
|
|
if reasoningEnd < 0 || reasoningEnd > len(s.reasoning) {
|
|
return "", "", errOpenAIRecoverySourceCursorRange
|
|
}
|
|
}
|
|
s.consumed = true
|
|
content := string(s.content[:contentEnd])
|
|
reasoning := string(s.reasoning[:reasoningEnd])
|
|
s.suppressContent = append(s.suppressContent[:0], content...)
|
|
s.suppressReasoning = append(s.suppressReasoning[:0], reasoning...)
|
|
return content, reasoning, nil
|
|
}
|
|
|
|
func (s *openAIRecoverySourceStore) suppressKnownPrefix(isContent bool, value string) string {
|
|
if s == nil || value == "" {
|
|
return value
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
var expected *[]byte
|
|
if isContent {
|
|
expected = &s.suppressContent
|
|
} else {
|
|
expected = &s.suppressReasoning
|
|
}
|
|
if len(*expected) == 0 {
|
|
return value
|
|
}
|
|
incoming := []byte(value)
|
|
switch {
|
|
case len(incoming) <= len(*expected) && string((*expected)[:len(incoming)]) == value:
|
|
*expected = (*expected)[len(incoming):]
|
|
return ""
|
|
case len(incoming) > len(*expected) && string(incoming[:len(*expected)]) == string(*expected):
|
|
value = string(incoming[len(*expected):])
|
|
*expected = nil
|
|
return value
|
|
default:
|
|
// The replacement started with novel output. Disable suppression
|
|
// immediately so a coincidental later substring is never removed.
|
|
*expected = nil
|
|
return value
|
|
}
|
|
}
|
|
|
|
func (s *openAIRecoverySourceStore) close() {
|
|
if s == nil {
|
|
return
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.closed {
|
|
return
|
|
}
|
|
s.closed = true
|
|
s.content = nil
|
|
s.reasoning = nil
|
|
s.suppressContent = nil
|
|
s.suppressReasoning = nil
|
|
}
|
|
|
|
// openAIRecoverySourceEventSource wraps a NormalizedEventSource so every text and
|
|
// reasoning delta the Core reads is recorded into the request-local source store
|
|
// before it reaches any filter. It resets the store on each response start so
|
|
// each attempt records only its own output. It is otherwise a transparent
|
|
// passthrough and never mutates events.
|
|
type openAIRecoverySourceEventSource struct {
|
|
inner streamgate.NormalizedEventSource
|
|
store *openAIRecoverySourceStore
|
|
}
|
|
|
|
func newOpenAIRecoverySourceEventSource(inner streamgate.NormalizedEventSource, store *openAIRecoverySourceStore) streamgate.NormalizedEventSource {
|
|
if store == nil {
|
|
return inner
|
|
}
|
|
return &openAIRecoverySourceEventSource{inner: inner, store: store}
|
|
}
|
|
|
|
func (s *openAIRecoverySourceEventSource) NextEvent(ctx context.Context) (streamgate.NormalizedEvent, error) {
|
|
for {
|
|
ev, err := s.inner.NextEvent(ctx)
|
|
if err != nil {
|
|
return ev, err
|
|
}
|
|
switch ev.Kind() {
|
|
case streamgate.EventKindResponseStart:
|
|
s.store.resetAttempt()
|
|
case streamgate.EventKindTextDelta:
|
|
if value, valueErr := ev.AsTextDelta(); valueErr == nil {
|
|
value = s.store.suppressKnownPrefix(true, value)
|
|
if value == "" {
|
|
continue
|
|
}
|
|
s.store.recordText(value)
|
|
ev, err = streamgate.NewTextDeltaEvent(ev.Channel(), value, ev.Timestamp())
|
|
if err != nil {
|
|
return streamgate.NormalizedEvent{}, err
|
|
}
|
|
}
|
|
case streamgate.EventKindReasoningDelta:
|
|
if value, valueErr := ev.AsReasoningDelta(); valueErr == nil {
|
|
value = s.store.suppressKnownPrefix(false, value)
|
|
if value == "" {
|
|
continue
|
|
}
|
|
s.store.recordReasoning(value)
|
|
ev, err = streamgate.NewReasoningDeltaEvent(ev.Channel(), value, ev.Timestamp())
|
|
if err != nil {
|
|
return streamgate.NormalizedEvent{}, err
|
|
}
|
|
}
|
|
}
|
|
return ev, err
|
|
}
|
|
}
|
|
|
|
var _ streamgate.NormalizedEventSource = (*openAIRecoverySourceEventSource)(nil)
|
|
|
|
type openAIRecoveryPatchEntry struct {
|
|
cursor int
|
|
value []byte
|
|
guard *streamgate.IngressSnapshotRebuildGuard
|
|
once sync.Once
|
|
}
|
|
|
|
func (e *openAIRecoveryPatchEntry) release() {
|
|
if e == nil {
|
|
return
|
|
}
|
|
e.once.Do(func() {
|
|
e.value = nil
|
|
if e.guard != nil {
|
|
e.guard.Close()
|
|
e.guard = nil
|
|
}
|
|
})
|
|
}
|
|
|
|
// openAIRecoveryPatchStore is request-local. It retains only the typed subtree
|
|
// named by a safe directive reference; it never retains a second request tree,
|
|
// caller headers, or provider credentials.
|
|
type openAIRecoveryPatchStore struct {
|
|
mu sync.Mutex
|
|
ingress *openAIIngressSnapshot
|
|
continuation map[string]*openAIRecoveryPatchEntry
|
|
schema map[string]*openAIRecoveryPatchEntry
|
|
closed bool
|
|
}
|
|
|
|
func newOpenAIRecoveryPatchStore(ingress *openAIIngressSnapshot) *openAIRecoveryPatchStore {
|
|
return &openAIRecoveryPatchStore{
|
|
ingress: ingress,
|
|
continuation: make(map[string]*openAIRecoveryPatchEntry),
|
|
schema: make(map[string]*openAIRecoveryPatchEntry),
|
|
}
|
|
}
|
|
|
|
func marshalOpenAIPatchValue(value any) ([]byte, error) {
|
|
if raw, ok := value.(json.RawMessage); ok {
|
|
if !json.Valid(raw) {
|
|
return nil, fmt.Errorf("invalid OpenAI recovery patch JSON")
|
|
}
|
|
return raw, nil
|
|
}
|
|
encoded, err := json.Marshal(value)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return encoded, nil
|
|
}
|
|
|
|
func (s *openAIRecoveryPatchStore) PutContinuation(snapshotRef string, cursor int, value any) error {
|
|
if _, err := streamgate.NewStableTokenRequired("snapshotRef", snapshotRef); err != nil {
|
|
return err
|
|
}
|
|
if cursor < 0 {
|
|
return fmt.Errorf("continuation cursor must be non-negative")
|
|
}
|
|
encoded, err := marshalOpenAIPatchValue(value)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
s.mu.Lock()
|
|
if s.closed {
|
|
s.mu.Unlock()
|
|
return streamgate.ErrIngressSnapshotClosed
|
|
}
|
|
if _, duplicate := s.continuation[snapshotRef]; duplicate {
|
|
s.mu.Unlock()
|
|
return errOpenAIRecoveryPatchDuplicate
|
|
}
|
|
ingress := s.ingress
|
|
s.mu.Unlock()
|
|
guard, err := ingress.reserveRebuild(int64(len(encoded)))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
entry := &openAIRecoveryPatchEntry{cursor: cursor, value: encoded, guard: guard}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.closed {
|
|
entry.release()
|
|
return streamgate.ErrIngressSnapshotClosed
|
|
}
|
|
if _, duplicate := s.continuation[snapshotRef]; duplicate {
|
|
entry.release()
|
|
return errOpenAIRecoveryPatchDuplicate
|
|
}
|
|
s.continuation[snapshotRef] = entry
|
|
return nil
|
|
}
|
|
|
|
func openAISchemaPatchKey(schemaRef, patchCode string) string {
|
|
return schemaRef + ":" + patchCode
|
|
}
|
|
|
|
func (s *openAIRecoveryPatchStore) PutSchema(schemaRef, patchCode string, value any) error {
|
|
if _, err := streamgate.NewStableTokenRequired("schemaRef", schemaRef); err != nil {
|
|
return err
|
|
}
|
|
if _, err := streamgate.NewStableTokenRequired("patchCode", patchCode); err != nil {
|
|
return err
|
|
}
|
|
encoded, err := marshalOpenAIPatchValue(value)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
key := openAISchemaPatchKey(schemaRef, patchCode)
|
|
s.mu.Lock()
|
|
if s.closed {
|
|
s.mu.Unlock()
|
|
return streamgate.ErrIngressSnapshotClosed
|
|
}
|
|
if _, duplicate := s.schema[key]; duplicate {
|
|
s.mu.Unlock()
|
|
return errOpenAIRecoveryPatchDuplicate
|
|
}
|
|
ingress := s.ingress
|
|
s.mu.Unlock()
|
|
guard, err := ingress.reserveRebuild(int64(len(encoded)))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
entry := &openAIRecoveryPatchEntry{value: encoded, guard: guard}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.closed {
|
|
entry.release()
|
|
return streamgate.ErrIngressSnapshotClosed
|
|
}
|
|
if _, duplicate := s.schema[key]; duplicate {
|
|
entry.release()
|
|
return errOpenAIRecoveryPatchDuplicate
|
|
}
|
|
s.schema[key] = entry
|
|
return nil
|
|
}
|
|
|
|
func (s *openAIRecoveryPatchStore) takeContinuation(snapshotRef string, cursor int) (*openAIRecoveryPatchEntry, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.closed {
|
|
return nil, streamgate.ErrIngressSnapshotClosed
|
|
}
|
|
entry := s.continuation[snapshotRef]
|
|
if entry == nil || entry.cursor != cursor {
|
|
return nil, fmt.Errorf("OpenAI continuation patch is unavailable")
|
|
}
|
|
delete(s.continuation, snapshotRef)
|
|
return entry, nil
|
|
}
|
|
|
|
func (s *openAIRecoveryPatchStore) takeSchema(schemaRef, patchCode string) (*openAIRecoveryPatchEntry, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.closed {
|
|
return nil, streamgate.ErrIngressSnapshotClosed
|
|
}
|
|
key := openAISchemaPatchKey(schemaRef, patchCode)
|
|
entry := s.schema[key]
|
|
if entry == nil {
|
|
return nil, fmt.Errorf("OpenAI schema patch is unavailable")
|
|
}
|
|
delete(s.schema, key)
|
|
return entry, nil
|
|
}
|
|
|
|
func (s *openAIRecoveryPatchStore) close() {
|
|
if s == nil {
|
|
return
|
|
}
|
|
s.mu.Lock()
|
|
if s.closed {
|
|
s.mu.Unlock()
|
|
return
|
|
}
|
|
s.closed = true
|
|
continuation := s.continuation
|
|
schema := s.schema
|
|
s.continuation = make(map[string]*openAIRecoveryPatchEntry)
|
|
s.schema = make(map[string]*openAIRecoveryPatchEntry)
|
|
s.ingress = nil
|
|
s.mu.Unlock()
|
|
for _, entry := range continuation {
|
|
entry.release()
|
|
}
|
|
for _, entry := range schema {
|
|
entry.release()
|
|
}
|
|
}
|
|
|
|
type openAIRebuiltLease struct {
|
|
mu sync.Mutex
|
|
ingress *openAIIngressSnapshot
|
|
guard *streamgate.IngressSnapshotRebuildGuard
|
|
rebuilt *streamgate.IngressSnapshot
|
|
bodyAlias []byte
|
|
released bool
|
|
once sync.Once
|
|
}
|
|
|
|
func (l *openAIRebuiltLease) body() ([]byte, error) {
|
|
if l == nil {
|
|
return nil, fmt.Errorf("rebuilt OpenAI request is unavailable")
|
|
}
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
if l.released || l.bodyAlias == nil {
|
|
return nil, fmt.Errorf("rebuilt OpenAI request is unavailable")
|
|
}
|
|
return l.bodyAlias, nil
|
|
}
|
|
|
|
func (l *openAIRebuiltLease) release() {
|
|
if l == nil {
|
|
return
|
|
}
|
|
l.once.Do(func() {
|
|
l.mu.Lock()
|
|
rebuilt := l.rebuilt
|
|
guard := l.guard
|
|
l.bodyAlias = nil
|
|
l.rebuilt = nil
|
|
l.guard = nil
|
|
l.ingress = nil
|
|
l.released = true
|
|
l.mu.Unlock()
|
|
if rebuilt != nil {
|
|
rebuilt.Close()
|
|
}
|
|
if guard != nil {
|
|
guard.Close()
|
|
}
|
|
})
|
|
}
|
|
|
|
func (l *openAIRebuiltLease) isReleased() bool {
|
|
if l == nil {
|
|
return true
|
|
}
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
return l.released
|
|
}
|
|
|
|
type openAIRebuiltRequestStore struct {
|
|
mu sync.Mutex
|
|
leases map[string]*openAIRebuiltLease
|
|
closed bool
|
|
}
|
|
|
|
func newOpenAIRebuiltRequestStore() *openAIRebuiltRequestStore {
|
|
return &openAIRebuiltRequestStore{leases: make(map[string]*openAIRebuiltLease)}
|
|
}
|
|
|
|
func (s *openAIRebuiltRequestStore) put(ref string, lease *openAIRebuiltLease) error {
|
|
s.mu.Lock()
|
|
if s.closed {
|
|
s.mu.Unlock()
|
|
return streamgate.ErrIngressSnapshotClosed
|
|
}
|
|
if _, exists := s.leases[ref]; exists {
|
|
s.mu.Unlock()
|
|
return fmt.Errorf("duplicate rebuilt OpenAI request reference")
|
|
}
|
|
s.leases[ref] = lease
|
|
s.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
func (s *openAIRebuiltRequestStore) take(ref string) (*openAIRebuiltLease, error) {
|
|
s.mu.Lock()
|
|
if s.closed {
|
|
s.mu.Unlock()
|
|
return nil, streamgate.ErrIngressSnapshotClosed
|
|
}
|
|
lease := s.leases[ref]
|
|
if lease == nil {
|
|
s.mu.Unlock()
|
|
return nil, fmt.Errorf("rebuilt OpenAI request reference is unavailable")
|
|
}
|
|
delete(s.leases, ref)
|
|
s.mu.Unlock()
|
|
return lease, nil
|
|
}
|
|
|
|
func (s *openAIRebuiltRequestStore) close() {
|
|
s.mu.Lock()
|
|
if s.closed {
|
|
s.mu.Unlock()
|
|
return
|
|
}
|
|
s.closed = true
|
|
leases := s.leases
|
|
s.leases = make(map[string]*openAIRebuiltLease)
|
|
s.mu.Unlock()
|
|
for _, lease := range leases {
|
|
lease.release()
|
|
}
|
|
}
|
|
|
|
// openAIRequestRebuilder implements the Core raw-free RequestRebuilder seam.
|
|
// Its endpoint and patch sources are fixed for one HTTP request.
|
|
// Lifecycle is strictly linear: open → in-flight rebuilds → closed.
|
|
// Close blocks until every in-flight rebuild has released its patch/lease
|
|
// reservation; nil receivers return ErrIngressSnapshotClosed instead of
|
|
// panicking.
|
|
type openAIRequestRebuilder struct {
|
|
mu sync.Mutex
|
|
cond *sync.Cond
|
|
inFlight int
|
|
closeWaiters int
|
|
closed bool
|
|
closeDone bool
|
|
ingress *openAIIngressSnapshot
|
|
endpoint string
|
|
recoverySource *openAIRecoverySourceStore
|
|
contextWindowTokens int
|
|
patches *openAIRecoveryPatchStore
|
|
rebuilt *openAIRebuiltRequestStore
|
|
}
|
|
|
|
// newOpenAIRequestRebuilder binds the request-local ingress, the optional model
|
|
// output recovery source, and the target model's context window (0 when
|
|
// unknown) for one HTTP request. The recovery source powers the S20 resume
|
|
// builder; a nil source keeps only the exact/schema/patch continuation paths.
|
|
func newOpenAIRequestRebuilder(ingress *openAIIngressSnapshot, endpoint string, recoverySource *openAIRecoverySourceStore, contextWindowTokens int) (*openAIRequestRebuilder, error) {
|
|
switch endpoint {
|
|
case openAIRebuildEndpointChat, openAIRebuildEndpointResponses:
|
|
default:
|
|
return nil, fmt.Errorf("unsupported OpenAI rebuild endpoint")
|
|
}
|
|
if ingress == nil || ingress.isClosed() {
|
|
return nil, streamgate.ErrIngressSnapshotClosed
|
|
}
|
|
r := &openAIRequestRebuilder{
|
|
ingress: ingress,
|
|
endpoint: endpoint,
|
|
recoverySource: recoverySource,
|
|
contextWindowTokens: contextWindowTokens,
|
|
patches: newOpenAIRecoveryPatchStore(ingress),
|
|
rebuilt: newOpenAIRebuiltRequestStore(),
|
|
}
|
|
r.cond = sync.NewCond(&r.mu)
|
|
return r, nil
|
|
}
|
|
|
|
func (r *openAIRequestRebuilder) PatchStore() *openAIRecoveryPatchStore { return r.patches }
|
|
func (r *openAIRequestRebuilder) RebuiltStore() *openAIRebuiltRequestStore { return r.rebuilt }
|
|
|
|
func (r *openAIRequestRebuilder) Close() {
|
|
if r == nil {
|
|
return
|
|
}
|
|
r.mu.Lock()
|
|
if r.closeDone {
|
|
r.mu.Unlock()
|
|
return
|
|
}
|
|
r.closeWaiters++
|
|
r.cond.Broadcast()
|
|
defer r.leaveCloseWaiter()
|
|
r.closed = true
|
|
for r.inFlight > 0 && !r.closeDone {
|
|
r.cond.Wait()
|
|
}
|
|
if r.closeDone {
|
|
r.mu.Unlock()
|
|
return
|
|
}
|
|
r.rebuilt.close()
|
|
r.patches.close()
|
|
r.recoverySource.close()
|
|
r.closeDone = true
|
|
r.cond.Broadcast()
|
|
r.mu.Unlock()
|
|
}
|
|
|
|
func (r *openAIRequestRebuilder) RebuildRequest(ctx context.Context, snapshotRef streamgate.RecoveryRequestSnapshotRef, plan streamgate.RecoveryPlan) (streamgate.RebuiltRequestDraft, error) {
|
|
if r == nil {
|
|
return streamgate.RebuiltRequestDraft{}, streamgate.ErrIngressSnapshotClosed
|
|
}
|
|
if err := ctx.Err(); err != nil {
|
|
return streamgate.RebuiltRequestDraft{}, err
|
|
}
|
|
r.mu.Lock()
|
|
if r.closed {
|
|
r.mu.Unlock()
|
|
return streamgate.RebuiltRequestDraft{}, streamgate.ErrIngressSnapshotClosed
|
|
}
|
|
r.inFlight++
|
|
r.mu.Unlock()
|
|
defer func() {
|
|
r.mu.Lock()
|
|
r.inFlight--
|
|
if r.inFlight == 0 {
|
|
r.cond.Broadcast()
|
|
} else {
|
|
r.cond.Signal()
|
|
}
|
|
r.mu.Unlock()
|
|
}()
|
|
if r.ingress == nil || r.ingress.isClosed() {
|
|
return streamgate.RebuiltRequestDraft{}, streamgate.ErrIngressSnapshotClosed
|
|
}
|
|
if err := snapshotRef.Validate(); err != nil {
|
|
return streamgate.RebuiltRequestDraft{}, err
|
|
}
|
|
if err := plan.Validate(); err != nil {
|
|
return streamgate.RebuiltRequestDraft{}, err
|
|
}
|
|
if !plan.ReadyForRebuild() {
|
|
return streamgate.RebuiltRequestDraft{}, streamgate.ErrRecoveryPlanNotReady
|
|
}
|
|
currentRef, err := r.ingress.recoveryRef()
|
|
if err != nil {
|
|
return streamgate.RebuiltRequestDraft{}, err
|
|
}
|
|
if snapshotRef.SnapshotRef() != currentRef.SnapshotRef() {
|
|
return streamgate.RebuiltRequestDraft{}, fmt.Errorf("OpenAI ingress snapshot reference mismatch")
|
|
}
|
|
|
|
directive := plan.Directive()
|
|
// S20 resume builder: a continuation directive that addresses the
|
|
// request-local recovery source is assembled from recorded model output and
|
|
// the fixed English directive, without any caller history or extra model
|
|
// call. Continuation directives that address a pre-computed patch keep the
|
|
// existing patch-store path below.
|
|
if directive.Kind() == streamgate.RecoveryDirectiveKindContinuation &&
|
|
r.recoverySource != nil && directive.SnapshotRef() == r.recoverySource.snapshotRef() {
|
|
return r.rebuildResumeContinuation(ctx, plan, directive)
|
|
}
|
|
var patchEntry *openAIRecoveryPatchEntry
|
|
switch directive.Kind() {
|
|
case streamgate.RecoveryDirectiveKindExact:
|
|
if directive.RequestRef() != snapshotRef.SnapshotRef() {
|
|
return streamgate.RebuiltRequestDraft{}, fmt.Errorf("OpenAI exact request reference mismatch")
|
|
}
|
|
case streamgate.RecoveryDirectiveKindContinuation:
|
|
patchEntry, err = r.patches.takeContinuation(directive.SnapshotRef(), directive.Cursor())
|
|
case streamgate.RecoveryDirectiveKindSchema:
|
|
patchEntry, err = r.patches.takeSchema(directive.SchemaRef(), directive.PatchCode())
|
|
default:
|
|
err = fmt.Errorf("unsupported OpenAI recovery directive")
|
|
}
|
|
if err != nil {
|
|
return streamgate.RebuiltRequestDraft{}, err
|
|
}
|
|
if patchEntry != nil {
|
|
defer patchEntry.release()
|
|
}
|
|
if err := ctx.Err(); err != nil {
|
|
return streamgate.RebuiltRequestDraft{}, err
|
|
}
|
|
|
|
requestRef := fmt.Sprintf("openai.rebuilt.%d", openAIRebuiltSequence.Add(1))
|
|
lease := &openAIRebuiltLease{ingress: r.ingress}
|
|
retained, peak, maxBytes := currentRef.RetainedBytes(), currentRef.PeakBytes(), currentRef.MaxBytes()
|
|
rebuiltPromptTokens := 0
|
|
|
|
if patchEntry == nil {
|
|
body, err := r.ingress.canonicalBody()
|
|
if err != nil {
|
|
return streamgate.RebuiltRequestDraft{}, err
|
|
}
|
|
lease.bodyAlias = body
|
|
rebuiltPromptTokens = estimateInputTokensBytes(body, nil, nil, nil)
|
|
} else {
|
|
field := "messages"
|
|
if r.endpoint == openAIRebuildEndpointResponses {
|
|
field = "input"
|
|
}
|
|
body, err := r.ingress.canonicalBody()
|
|
if err != nil {
|
|
return streamgate.RebuiltRequestDraft{}, err
|
|
}
|
|
patchPlan, err := planTopLevelJSONPatches(body, []topLevelJSONPatch{{name: field, value: patchEntry.value}})
|
|
if err != nil {
|
|
return streamgate.RebuiltRequestDraft{}, err
|
|
}
|
|
guard, err := r.ingress.reserveRebuild(int64(patchPlan.outputSize))
|
|
if err != nil {
|
|
return streamgate.RebuiltRequestDraft{}, err
|
|
}
|
|
output := patchPlan.apply()
|
|
rebuilt, err := guard.CommitOwnedTyped(openAIRebuiltBodyViewName, output)
|
|
if err != nil {
|
|
guard.Close()
|
|
return streamgate.RebuiltRequestDraft{}, err
|
|
}
|
|
lease.guard = guard
|
|
lease.rebuilt = rebuilt
|
|
lease.bodyAlias = output
|
|
accessor := rebuilt.Accessor()
|
|
retained = uint64(accessor.RetainedBytes())
|
|
maxBytes = uint64(accessor.MaxBytes())
|
|
peak = retained
|
|
if current, refErr := r.ingress.recoveryRef(); refErr == nil && current.PeakBytes() > peak {
|
|
peak = current.PeakBytes()
|
|
}
|
|
rebuiltPromptTokens = estimateInputTokensBytes(output, nil, nil, nil)
|
|
}
|
|
|
|
draft, err := streamgate.NewRebuiltRequestDraftWithIdempotency(
|
|
plan.PlanID(), plan.IdempotencyKey(), requestRef, r.endpoint, openAIRebuildFamily,
|
|
retained, peak, maxBytes, rebuiltPromptTokens, plan.RequiredCapabilities(),
|
|
)
|
|
if err != nil {
|
|
lease.release()
|
|
return streamgate.RebuiltRequestDraft{}, err
|
|
}
|
|
if err := r.rebuilt.put(requestRef, lease); err != nil {
|
|
lease.release()
|
|
return streamgate.RebuiltRequestDraft{}, err
|
|
}
|
|
return draft, nil
|
|
}
|
|
|
|
// rebuildResumeContinuation assembles an endpoint-specific continuation body
|
|
// from the request-local recovery source snapshot and the fixed English resume
|
|
// directive. It consumes the snapshot once (so it only runs after the aborted
|
|
// attempt recorded output), refuses to build when the target context window is
|
|
// unknown or the resume prompt plus reserve would exceed it, and only then
|
|
// reserves, commits, and leases the rebuilt body. A context-window refusal
|
|
// returns before any lease is created so no dispatch or recovery budget is
|
|
// consumed.
|
|
func (r *openAIRequestRebuilder) rebuildResumeContinuation(ctx context.Context, plan streamgate.RecoveryPlan, directive streamgate.RecoveryDirective) (streamgate.RebuiltRequestDraft, error) {
|
|
content, reasoning, err := r.recoverySource.consume(directive.Cursor())
|
|
if err != nil {
|
|
return streamgate.RebuiltRequestDraft{}, err
|
|
}
|
|
if err := ctx.Err(); err != nil {
|
|
return streamgate.RebuiltRequestDraft{}, err
|
|
}
|
|
output, err := r.buildResumeBody(content, reasoning, plan)
|
|
if err != nil {
|
|
return streamgate.RebuiltRequestDraft{}, err
|
|
}
|
|
rebuiltPromptTokens := estimateInputTokensBytes(output, nil, nil, nil)
|
|
if r.contextWindowTokens <= 0 || rebuiltPromptTokens+openAIRebuildContextReserveTokens > r.contextWindowTokens {
|
|
return streamgate.RebuiltRequestDraft{}, errOpenAIRebuildContextOverflow
|
|
}
|
|
|
|
requestRef := fmt.Sprintf("openai.rebuilt.%d", openAIRebuiltSequence.Add(1))
|
|
lease := &openAIRebuiltLease{ingress: r.ingress}
|
|
guard, err := r.ingress.reserveRebuild(int64(len(output)))
|
|
if err != nil {
|
|
return streamgate.RebuiltRequestDraft{}, err
|
|
}
|
|
rebuilt, err := guard.CommitOwnedTyped(openAIRebuiltBodyViewName, output)
|
|
if err != nil {
|
|
guard.Close()
|
|
return streamgate.RebuiltRequestDraft{}, err
|
|
}
|
|
lease.guard = guard
|
|
lease.rebuilt = rebuilt
|
|
lease.bodyAlias = output
|
|
accessor := rebuilt.Accessor()
|
|
retained := uint64(accessor.RetainedBytes())
|
|
maxBytes := uint64(accessor.MaxBytes())
|
|
peak := retained
|
|
if current, refErr := r.ingress.recoveryRef(); refErr == nil && current.PeakBytes() > peak {
|
|
peak = current.PeakBytes()
|
|
}
|
|
|
|
draft, err := streamgate.NewRebuiltRequestDraftWithIdempotency(
|
|
plan.PlanID(), plan.IdempotencyKey(), requestRef, r.endpoint, openAIRebuildFamily,
|
|
retained, peak, maxBytes, rebuiltPromptTokens, plan.RequiredCapabilities(),
|
|
)
|
|
if err != nil {
|
|
lease.release()
|
|
return streamgate.RebuiltRequestDraft{}, err
|
|
}
|
|
if err := r.rebuilt.put(requestRef, lease); err != nil {
|
|
lease.release()
|
|
return streamgate.RebuiltRequestDraft{}, err
|
|
}
|
|
return draft, nil
|
|
}
|
|
|
|
// buildResumeBody serializes a fresh endpoint-native continuation request that
|
|
// carries only the recorded assistant content/reasoning provenance and the
|
|
// fixed resume directive. It drops the caller's original messages/input/
|
|
// instructions and never summarizes, truncates (beyond the content cursor
|
|
// already applied), or rewrites the recorded output.
|
|
func (r *openAIRequestRebuilder) buildResumeBody(content, reasoning string, plan streamgate.RecoveryPlan) ([]byte, error) {
|
|
model, stream, ingressTemperature, err := r.ingressResumeHead()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
temperature := continuationTemperature(plan.StrategyAttempt(), ingressTemperature)
|
|
if r.endpoint == openAIRebuildEndpointResponses {
|
|
return buildOpenAIResponsesResumeBody(model, content, reasoning, temperature)
|
|
}
|
|
return buildOpenAIChatResumeBody(model, stream, content, reasoning, temperature)
|
|
}
|
|
|
|
// ingressResumeHead reads only the target model, stream flag, and caller
|
|
// temperature from the canonical body. No caller message or input is copied.
|
|
func (r *openAIRequestRebuilder) ingressResumeHead() (string, bool, *float64, error) {
|
|
body, err := r.ingress.canonicalBody()
|
|
if err != nil {
|
|
return "", false, nil, err
|
|
}
|
|
var head struct {
|
|
Model string `json:"model"`
|
|
Stream bool `json:"stream"`
|
|
Temperature *float64 `json:"temperature"`
|
|
}
|
|
if err := json.Unmarshal(body, &head); err != nil {
|
|
return "", false, nil, fmt.Errorf("decode OpenAI resume ingress head: %w", err)
|
|
}
|
|
return head.Model, head.Stream, head.Temperature, nil
|
|
}
|
|
|
|
func continuationTemperature(strategyAttempt int, ingress *float64) *float64 {
|
|
if ingress != nil {
|
|
value := *ingress
|
|
return &value
|
|
}
|
|
candidates := [...]float64{0.2, 0.4, 0.6}
|
|
index := strategyAttempt - 1
|
|
if index < 0 {
|
|
index = 0
|
|
}
|
|
if index >= len(candidates) {
|
|
index = len(candidates) - 1
|
|
}
|
|
value := candidates[index]
|
|
return &value
|
|
}
|
|
|
|
func buildOpenAIChatResumeBody(model string, stream bool, content, reasoning string, temperature *float64) ([]byte, error) {
|
|
assistant := map[string]any{"role": "assistant", "content": content}
|
|
if reasoning != "" {
|
|
assistant["reasoning_content"] = reasoning
|
|
}
|
|
body := map[string]any{
|
|
"model": model,
|
|
"stream": stream,
|
|
"messages": []any{
|
|
assistant,
|
|
map[string]any{"role": "user", "content": openAIRepeatResumeDirective},
|
|
},
|
|
}
|
|
if temperature != nil {
|
|
body["temperature"] = *temperature
|
|
}
|
|
return json.Marshal(body)
|
|
}
|
|
|
|
func buildOpenAIResponsesResumeBody(model, content, reasoning string, temperature *float64) ([]byte, error) {
|
|
input := make([]any, 0, 2)
|
|
if reasoning != "" {
|
|
input = append(input, map[string]any{
|
|
"type": "reasoning",
|
|
"content": []any{map[string]any{"type": "reasoning_text", "text": reasoning}},
|
|
})
|
|
}
|
|
input = append(input, map[string]any{
|
|
"type": "message",
|
|
"role": "assistant",
|
|
"content": []any{map[string]any{"type": "output_text", "text": content}},
|
|
})
|
|
body := map[string]any{
|
|
"model": model,
|
|
"stream": false,
|
|
"instructions": openAIRepeatResumeDirective,
|
|
"input": input,
|
|
}
|
|
if temperature != nil {
|
|
body["temperature"] = *temperature
|
|
}
|
|
return json.Marshal(body)
|
|
}
|
|
|
|
func (r *openAIRequestRebuilder) leaveCloseWaiter() {
|
|
r.mu.Lock()
|
|
r.closeWaiters--
|
|
r.cond.Broadcast()
|
|
r.mu.Unlock()
|
|
}
|
|
|
|
var _ streamgate.RequestRebuilder = (*openAIRequestRebuilder)(nil)
|
|
|
|
func isOpenAIRebuildOverflow(err error) bool {
|
|
return errors.Is(err, streamgate.ErrIngressSnapshotRebuildOverflow) ||
|
|
errors.Is(err, streamgate.ErrRecoverySnapshotLimitExceeded)
|
|
}
|