package openai_compat import ( "bufio" "context" "encoding/json" "fmt" "io" "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" ) // Execute runs the OpenAI-compatible chat completions stream. It builds the // request body, sends the streaming request, and emits RuntimeEvents for // reasoning deltas, content deltas, and completion. func (a *Adapter) Execute(ctx context.Context, spec runtime.ExecutionSpec, sink runtime.EventSink) error { if a.endpoint == "" { return fmt.Errorf("openai_compat adapter: endpoint is required") } model := strings.TrimSpace(spec.Target) if model == "" { model = stringInput(spec.Input, "model") } if model == "" { return fmt.Errorf("openai_compat adapter: target/model is required") } messages := messagesFromInput(spec.Input) if len(messages) == 0 { return fmt.Errorf("openai_compat adapter: messages are required") } if err := sink.Emit(ctx, runtime.RuntimeEvent{ RunID: spec.RunID, Type: runtime.EventTypeStart, Timestamp: time.Now(), }); err != nil { return err } reqBody, err := a.buildRequestBody(model, messages, spec.Input) if err != nil { _ = emitError(ctx, sink, spec.RunID, fmt.Sprintf("openai_compat build request body failed: %v", err)) return fmt.Errorf("openai_compat adapter: build request body: %w", err) } body, err := json.Marshal(reqBody) if err != nil { return fmt.Errorf("openai_compat adapter: marshal request: %w", err) } textToolFallback := false a.logger.Info("openai_compat adapter executing", zap.String("run_id", spec.RunID), zap.String("provider", a.provider), zap.String("target", model), zap.String("endpoint", a.endpoint), ) resp, err := a.doChatCompletion(ctx, body) if err != nil { _ = emitError(ctx, sink, spec.RunID, fmt.Sprintf("openai_compat request failed: %v", err)) return fmt.Errorf("openai_compat adapter: request: %w", err) } if resp.StatusCode < 200 || resp.StatusCode >= 300 { msg := readLimited(resp.Body, 4096) _ = resp.Body.Close() if retryBody, ok := retryBodyWithForcedSingleTool(reqBody, msg); ok { body, err = json.Marshal(retryBody) if err != nil { _ = emitError(ctx, sink, spec.RunID, fmt.Sprintf("openai_compat retry marshal failed: %v", err)) return fmt.Errorf("openai_compat adapter: retry marshal: %w", err) } resp, err = a.doChatCompletion(ctx, body) if err != nil { _ = emitError(ctx, sink, spec.RunID, fmt.Sprintf("openai_compat retry request failed: %v", err)) return fmt.Errorf("openai_compat adapter: retry request: %w", err) } if resp.StatusCode >= 200 && resp.StatusCode < 300 { goto streamResponse } msg = readLimited(resp.Body, 4096) _ = resp.Body.Close() } if retryBody, ok := retryBodyWithTextToolFallback(reqBody, msg); ok { body, err = json.Marshal(retryBody) if err != nil { _ = emitError(ctx, sink, spec.RunID, fmt.Sprintf("openai_compat text tool fallback marshal failed: %v", err)) return fmt.Errorf("openai_compat adapter: text tool fallback marshal: %w", err) } resp, err = a.doChatCompletion(ctx, body) if err != nil { _ = emitError(ctx, sink, spec.RunID, fmt.Sprintf("openai_compat text tool fallback request failed: %v", err)) return fmt.Errorf("openai_compat adapter: text tool fallback request: %w", err) } if resp.StatusCode >= 200 && resp.StatusCode < 300 { textToolFallback = true goto streamResponse } msg = readLimited(resp.Body, 4096) _ = resp.Body.Close() } if msg == "" { msg = resp.Status } _ = emitError(ctx, sink, spec.RunID, fmt.Sprintf("openai_compat returned %s: %s", resp.Status, msg)) return fmt.Errorf("openai_compat adapter: non-2xx response: %s", resp.Status) } streamResponse: defer resp.Body.Close() scanner := bufio.NewScanner(resp.Body) outputTokens := 0 finishReason := "" var usage *runtime.UsageStats var toolCalls openAIToolCallAccumulator traceStream := openAICompatTraceStreamEnabled(spec.Metadata) traceSeq := 0 for scanner.Scan() { line := scanner.Text() if !strings.HasPrefix(line, "data: ") { continue } payload := strings.TrimPrefix(line, "data: ") if payload == "[DONE]" { if traceStream { a.logger.Info("openai_compat provider stream done", zap.String("run_id", spec.RunID), zap.Int("seq", traceSeq+1), ) } return sink.Emit(ctx, completeEvent(spec.RunID, finishReason, usage, outputTokens, toolCalls.ToolCalls(), textToolFallback)) } traceSeq++ if traceStream { a.logger.Info("openai_compat provider stream raw chunk", zap.String("run_id", spec.RunID), zap.Int("seq", 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 traceStream { a.logger.Warn("openai_compat provider stream raw chunk ignored", zap.String("run_id", spec.RunID), zap.Int("seq", traceSeq), zap.Error(err), ) } continue } if chunk.Usage != nil { usage = &runtime.UsageStats{ InputTokens: chunk.Usage.PromptTokens, OutputTokens: chunk.Usage.CompletionTokens, } if d := chunk.Usage.PromptTokensDetails; d != nil { usage.CachedInputTokens = d.CachedTokens } if d := chunk.Usage.CompletionTokensDetails; d != nil { usage.ReasoningTokens = d.ReasoningTokens } } if len(chunk.Choices) == 0 { continue } choice := chunk.Choices[0] if choice.FinishReason != nil && *choice.FinishReason != "" { finishReason = *choice.FinishReason } if len(choice.Delta.ToolCalls) > 0 { toolCalls.AddDelta(choice.Delta.ToolCalls) } reasoning := choice.Delta.ReasoningText() if traceStream { a.logger.Info("openai_compat provider stream parsed chunk", zap.String("run_id", spec.RunID), zap.Int("seq", traceSeq), zap.String("finish_reason", 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: spec.RunID, Type: runtime.EventTypeReasoningDelta, Delta: reasoning, Timestamp: time.Now(), }); err != nil { return err } } if content := choice.Delta.Content; content != "" { outputTokens += len(strings.Fields(content)) if err := sink.Emit(ctx, runtime.RuntimeEvent{ RunID: spec.RunID, Type: runtime.EventTypeDelta, Delta: content, Timestamp: time.Now(), }); 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]") } 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), "