iop/apps/node/internal/adapters/openai_compat/execute.go

156 lines
5.2 KiB
Go

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