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

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)
}