package openai_compat import ( "context" "encoding/json" "fmt" "net/http" "strings" "time" "go.uber.org/zap" "iop/apps/node/internal/runtime" ) // Execute runs the OpenAI-compatible chat completions stream. It validates the // request and emits the start event, sends the request with native-tool and // text-tool fallback retries, then consumes the SSE stream into RuntimeEvents. func (a *Adapter) Execute(ctx context.Context, spec runtime.ExecutionSpec, sink runtime.EventSink) error { exec, err := a.prepareExecution(ctx, spec, sink) if err != nil { return err } resp, textToolFallback, err := exec.requestWithFallback() if err != nil { return err } return a.consumeStream(ctx, spec, sink, resp, textToolFallback) } // chatExecution holds the validated request context shared by the request and // retry stages so Execute stays a linear orchestration without a goto. type chatExecution struct { adapter *Adapter ctx context.Context sink runtime.EventSink runID string reqBody map[string]any body []byte } // prepareExecution validates the spec, emits the start event, and builds the // marshaled request body. The validation order and error text match the prior // single-function Execute. func (a *Adapter) prepareExecution(ctx context.Context, spec runtime.ExecutionSpec, sink runtime.EventSink) (*chatExecution, error) { if a.endpoint == "" { return nil, fmt.Errorf("openai_compat adapter: endpoint is required") } model := strings.TrimSpace(spec.Target) if model == "" { model = stringInput(spec.Input, "model") } if model == "" { return nil, fmt.Errorf("openai_compat adapter: target/model is required") } messages := messagesFromInput(spec.Input) if len(messages) == 0 { return nil, 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 nil, 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 nil, fmt.Errorf("openai_compat adapter: build request body: %w", err) } body, err := json.Marshal(reqBody) if err != nil { return nil, fmt.Errorf("openai_compat adapter: marshal request: %w", err) } 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), ) return &chatExecution{ adapter: a, ctx: ctx, sink: sink, runID: spec.RunID, reqBody: reqBody, body: body, }, nil } // requestWithFallback sends the chat completion request and, on a non-2xx // response, applies the forced-single-tool retry then the text-tool fallback // retry. It returns the streaming response to consume and whether the text-tool // fallback was used, or an error after emitting the failure event. The retry // order and short-circuit on the first 2xx match the prior goto-based flow. func (e *chatExecution) requestWithFallback() (*http.Response, bool, error) { a := e.adapter resp, err := a.doChatCompletion(e.ctx, e.body) if err != nil { _ = emitError(e.ctx, e.sink, e.runID, fmt.Sprintf("openai_compat request failed: %v", err)) return nil, false, fmt.Errorf("openai_compat adapter: request: %w", err) } if resp.StatusCode >= 200 && resp.StatusCode < 300 { return resp, false, nil } msg := readLimited(resp.Body, 4096) _ = resp.Body.Close() if retryBody, ok := retryBodyWithForcedSingleTool(e.reqBody, msg); ok { body, err := json.Marshal(retryBody) if err != nil { _ = emitError(e.ctx, e.sink, e.runID, fmt.Sprintf("openai_compat retry marshal failed: %v", err)) return nil, false, fmt.Errorf("openai_compat adapter: retry marshal: %w", err) } resp, err = a.doChatCompletion(e.ctx, body) if err != nil { _ = emitError(e.ctx, e.sink, e.runID, fmt.Sprintf("openai_compat retry request failed: %v", err)) return nil, false, fmt.Errorf("openai_compat adapter: retry request: %w", err) } if resp.StatusCode >= 200 && resp.StatusCode < 300 { return resp, false, nil } msg = readLimited(resp.Body, 4096) _ = resp.Body.Close() } if retryBody, ok := retryBodyWithTextToolFallback(e.reqBody, msg); ok { body, err := json.Marshal(retryBody) if err != nil { _ = emitError(e.ctx, e.sink, e.runID, fmt.Sprintf("openai_compat text tool fallback marshal failed: %v", err)) return nil, false, fmt.Errorf("openai_compat adapter: text tool fallback marshal: %w", err) } resp, err = a.doChatCompletion(e.ctx, body) if err != nil { _ = emitError(e.ctx, e.sink, e.runID, fmt.Sprintf("openai_compat text tool fallback request failed: %v", err)) return nil, false, fmt.Errorf("openai_compat adapter: text tool fallback request: %w", err) } if resp.StatusCode >= 200 && resp.StatusCode < 300 { return resp, true, nil } msg = readLimited(resp.Body, 4096) _ = resp.Body.Close() } if msg == "" { msg = resp.Status } _ = emitError(e.ctx, e.sink, e.runID, fmt.Sprintf("openai_compat returned %s: %s", resp.Status, msg)) return nil, false, fmt.Errorf("openai_compat adapter: non-2xx response: %s", resp.Status) }