| 576 | } |
| 577 | |
| 578 | func wrapObjectStreamSeq( |
| 579 | ctx context.Context, |
| 580 | handle *stepHandle, |
| 581 | seq iter.Seq[fantasy.ObjectStreamPart], |
| 582 | ) fantasy.ObjectStreamResponse { |
| 583 | // Same safety-net pattern as wrapStreamSeq: a mutex rather |
| 584 | // than sync.Once lets the AfterFunc yield to the normal |
| 585 | // finalization path when the stream has already completed. |
| 586 | var ( |
| 587 | mu sync.Mutex |
| 588 | finalized bool |
| 589 | streamComplete atomic.Bool |
| 590 | ) |
| 591 | |
| 592 | heartbeatDone := make(chan struct{}) |
| 593 | |
| 594 | stop := context.AfterFunc(ctx, func() { |
| 595 | mu.Lock() |
| 596 | defer mu.Unlock() |
| 597 | if finalized || streamComplete.Load() { |
| 598 | return |
| 599 | } |
| 600 | finalized = true |
| 601 | close(heartbeatDone) |
| 602 | handle.finish(ctx, StatusInterrupted, nil, nil, nil, nil) |
| 603 | }) |
| 604 | |
| 605 | // Deferred heartbeat: start the heartbeat goroutine only when the |
| 606 | // caller begins consuming the stream. |
| 607 | startHeartbeat := sync.OnceFunc(func() { |
| 608 | launchHeartbeat(ctx, handle.svc, handle.stepCtx.StepID, handle.stepCtx.RunID, handle.stepCtx.ChatID, heartbeatDone) |
| 609 | }) |
| 610 | |
| 611 | return func(yield func(fantasy.ObjectStreamPart) bool) { |
| 612 | startHeartbeat() |
| 613 | var ( |
| 614 | summary = objectStreamSummary{StructuredOutput: true} |
| 615 | latestUsage fantasy.Usage |
| 616 | usageSeen bool |
| 617 | finishSeen bool |
| 618 | finishReason fantasy.FinishReason |
| 619 | rawTextLength int |
| 620 | warnings []normalizedWarning |
| 621 | streamError any |
| 622 | streamStatus = StatusCompleted |
| 623 | ) |
| 624 | |
| 625 | finalize := func(status Status) { |
| 626 | if stop != nil { |
| 627 | stop() |
| 628 | } |
| 629 | mu.Lock() |
| 630 | defer mu.Unlock() |
| 631 | if finalized { |
| 632 | return |
| 633 | } |
| 634 | finalized = true |
| 635 | close(heartbeatDone) |