package openai_compat import ( "bufio" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "os" "strings" "time" "go.uber.org/zap" "iop/apps/node/internal/runtime" ) const ( runtimeMetadataOpenAIToolCalls = "openai_tool_calls" runtimeMetadataOpenAITextToolFallback = "openai_text_tool_fallback" streamTraceMetadataKey = "iop_trace_stream" streamTraceEnvKey = "IOP_OPENAI_COMPAT_TRACE_STREAM" ) // chatStreamSession owns the accumulated stream state (usage, finish reason, // tool-call accumulator, counted output tokens, and trace sequence) while the // SSE payloads are decoded into RuntimeEvents. type chatStreamSession struct { adapter *Adapter spec runtime.ExecutionSpec textToolFallback bool traceStream bool traceSeq int outputTokens int finishReason string usage *runtime.UsageStats toolCalls openAIToolCallAccumulator } // consumeStream decodes the chat completions SSE stream, emitting reasoning and // content deltas and, on [DONE], the completion event. The scan/emit ordering // and terminal error handling match the prior single-function Execute. func (a *Adapter) consumeStream(ctx context.Context, spec runtime.ExecutionSpec, sink runtime.EventSink, resp *http.Response, textToolFallback bool) error { defer resp.Body.Close() session := &chatStreamSession{ adapter: a, spec: spec, textToolFallback: textToolFallback, traceStream: openAICompatTraceStreamEnabled(spec.Metadata), } scanner := bufio.NewScanner(resp.Body) for scanner.Scan() { line := scanner.Text() if !strings.HasPrefix(line, "data: ") { continue } payload := strings.TrimPrefix(line, "data: ") if payload == "[DONE]" { return session.emitComplete(ctx, sink) } if err := session.handlePayload(ctx, sink, payload); err != nil { return err } } if err := scanner.Err(); err != nil { _ = emitError(ctx, sink, spec.RunID, fmt.Sprintf("openai_compat stream scan failed: %v", err)) return fmt.Errorf("openai_compat adapter: scan stream: %w", err) } _ = emitError(ctx, sink, spec.RunID, "openai_compat stream ended without [DONE]") return fmt.Errorf("openai_compat adapter: stream ended without [DONE]") } // emitComplete emits the terminal completion event with the accumulated finish // reason, usage, counted output tokens, and tool calls. func (s *chatStreamSession) emitComplete(ctx context.Context, sink runtime.EventSink) error { if s.traceStream { s.adapter.logger.Info("openai_compat provider stream done", zap.String("run_id", s.spec.RunID), zap.Int("seq", s.traceSeq+1), ) } return sink.Emit(ctx, completeEvent(s.spec.RunID, s.finishReason, s.usage, s.outputTokens, s.toolCalls.ToolCalls(), s.textToolFallback)) } // applyUsage records the usage stats from a chunk when present. func (s *chatStreamSession) applyUsage(chunk chatChunk) { if chunk.Usage == nil { return } s.usage = &runtime.UsageStats{ InputTokens: chunk.Usage.PromptTokens, OutputTokens: chunk.Usage.CompletionTokens, } if d := chunk.Usage.PromptTokensDetails; d != nil { s.usage.CachedInputTokens = d.CachedTokens } if d := chunk.Usage.CompletionTokensDetails; d != nil { s.usage.ReasoningTokens = d.ReasoningTokens } } // handlePayload decodes one SSE data payload, updating usage/finish/tool-call // state and emitting reasoning and content deltas. A malformed chunk or a // choice-less chunk is skipped, matching the prior continue semantics. func (s *chatStreamSession) handlePayload(ctx context.Context, sink runtime.EventSink, payload string) error { a := s.adapter s.traceSeq++ if s.traceStream { a.logger.Info("openai_compat provider stream raw chunk", zap.String("run_id", s.spec.RunID), zap.Int("seq", s.traceSeq), zap.Int("raw_len", len(payload)), zap.Bool("raw_has_open_think", hasOpenThinkTag(payload)), zap.Bool("raw_has_close_think", hasCloseThinkTag(payload)), zap.String("raw_preview", tracePreview(payload, 4000)), ) } var chunk chatChunk if err := json.Unmarshal([]byte(payload), &chunk); err != nil { if s.traceStream { a.logger.Warn("openai_compat provider stream raw chunk ignored", zap.String("run_id", s.spec.RunID), zap.Int("seq", s.traceSeq), zap.Error(err), ) } return nil } s.applyUsage(chunk) if len(chunk.Choices) == 0 { return nil } choice := chunk.Choices[0] if choice.FinishReason != nil && *choice.FinishReason != "" { s.finishReason = *choice.FinishReason } if len(choice.Delta.ToolCalls) > 0 { s.toolCalls.AddDelta(choice.Delta.ToolCalls) } reasoning := choice.Delta.ReasoningText() if s.traceStream { a.logger.Info("openai_compat provider stream parsed chunk", zap.String("run_id", s.spec.RunID), zap.Int("seq", s.traceSeq), zap.String("finish_reason", s.finishReason), zap.Int("tool_call_delta_count", len(choice.Delta.ToolCalls)), zap.Int("content_len", len(choice.Delta.Content)), zap.Bool("content_has_open_think", hasOpenThinkTag(choice.Delta.Content)), zap.Bool("content_has_close_think", hasCloseThinkTag(choice.Delta.Content)), zap.String("content_preview", tracePreview(choice.Delta.Content, 2000)), zap.Int("reasoning_len", len(reasoning)), zap.Bool("reasoning_has_open_think", hasOpenThinkTag(reasoning)), zap.Bool("reasoning_has_close_think", hasCloseThinkTag(reasoning)), zap.String("reasoning_preview", tracePreview(reasoning, 2000)), ) } if reasoning != "" { if err := sink.Emit(ctx, runtime.RuntimeEvent{ RunID: s.spec.RunID, Type: runtime.EventTypeReasoningDelta, Delta: reasoning, Timestamp: time.Now(), }); err != nil { return err } } if content := choice.Delta.Content; content != "" { s.outputTokens += len(strings.Fields(content)) if err := sink.Emit(ctx, runtime.RuntimeEvent{ RunID: s.spec.RunID, Type: runtime.EventTypeDelta, Delta: content, Timestamp: time.Now(), }); err != nil { return err } } return nil } func openAICompatTraceStreamEnabled(metadata map[string]string) bool { if traceBool(os.Getenv(streamTraceEnvKey)) { return true } if metadata == nil { return false } return traceBool(metadata[streamTraceMetadataKey]) } func traceBool(v string) bool { switch strings.ToLower(strings.TrimSpace(v)) { case "1", "true", "yes", "on": return true default: return false } } func tracePreview(s string, max int) string { if max <= 0 || len(s) <= max { return s } return s[:max] + "...[truncated]" } func hasOpenThinkTag(s string) bool { return strings.Contains(strings.ToLower(s), "