156 lines
5.2 KiB
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)
|
|
}
|