Files
CLIProxyAPI/internal/runtime/executor/openai_compat_executor.go
이대희 3b69068e6c fix(translator): stop dropping attachments and empty turns
An attachment a translator could not represent was dropped without a word, and
a turn whose only content was that attachment went upstream as an empty
messages, parts, or input array. The model then answered as if nothing was
attached, or the upstream rejected a request that never named the attachment.

A base64 document now reaches OpenAI as a file part. A file the target cannot
receive, such as container_upload, an OpenAI file_id, or input_audio sent to
Claude, is reported by name so the route answers 400 instead of forwarding an
empty turn. Text beside it still travels.

One rule, ErrIfNothingLeft, decides when to refuse, and RegisterCheckedRequest
carries the refusal on the request envelope. Every executor that translates
checks it before calling upstream. The exported converters keep their []byte
signature, so no caller or sibling test had to change.
2026-10-07 19:21:32 +09:00

1165 lines
42 KiB
Go

package executor
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"mime"
"mime/multipart"
"net/http"
"net/textproto"
"strconv"
"strings"
"time"
"github.com/google/uuid"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
"github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps"
"github.com/router-for-me/CLIProxyAPI/v8/internal/thinking"
"github.com/router-for-me/CLIProxyAPI/v8/internal/util"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
log "github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
const (
openAICompatImageHandlerType = "openai-image"
openAICompatImagesGenerationsPath = "/images/generations"
openAICompatImagesEditsPath = "/images/edits"
openAICompatDefaultImageEndpoint = openAICompatImagesGenerationsPath
openAICompatMultipartMemory int64 = 32 << 20
)
// OpenAICompatExecutor implements a stateless executor for OpenAI-compatible providers.
// It performs request/response translation and executes against the provider base URL
// using per-auth credentials (API key) and per-auth HTTP transport (proxy) from context.
type OpenAICompatExecutor struct {
provider string
cfg *config.Config
}
// NewOpenAICompatExecutor creates an executor bound to a provider key (e.g., "openrouter").
func NewOpenAICompatExecutor(provider string, cfg *config.Config) *OpenAICompatExecutor {
return &OpenAICompatExecutor{provider: provider, cfg: cfg}
}
// Identifier implements cliproxyauth.ProviderExecutor.
func (e *OpenAICompatExecutor) Identifier() string { return e.provider }
// PrepareRequest injects OpenAI-compatible credentials into the outgoing HTTP request.
func (e *OpenAICompatExecutor) PrepareRequest(req *http.Request, auth *cliproxyauth.Auth) error {
if req == nil {
return nil
}
_, apiKey := e.resolveCredentials(auth)
if strings.TrimSpace(apiKey) != "" {
req.Header.Set("Authorization", "Bearer "+apiKey)
}
var attrs map[string]string
if auth != nil {
attrs = auth.Attributes
}
util.ApplyCustomHeadersFromAttrs(req, attrs)
return nil
}
// HttpRequest injects OpenAI-compatible credentials into the request and executes it.
func (e *OpenAICompatExecutor) HttpRequest(ctx context.Context, auth *cliproxyauth.Auth, req *http.Request) (*http.Response, error) {
if req == nil {
return nil, fmt.Errorf("openai compat executor: request is nil")
}
if ctx == nil {
ctx = req.Context()
}
httpReq := req.WithContext(ctx)
if err := e.PrepareRequest(httpReq, auth); err != nil {
return nil, err
}
httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
return httpClient.Do(httpReq)
}
func (e *OpenAICompatExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) {
ctx = helps.EnsureSessionContext(ctx, opts, req.Payload)
if endpointPath := openAICompatImageEndpointPath(opts); endpointPath != "" {
return e.executeImages(ctx, auth, req, opts, endpointPath)
}
baseModel := thinking.ParseSuffix(req.Model).ModelName
reporter := helps.NewExecutorUsageReporter(ctx, e, baseModel, auth)
defer reporter.TrackFailure(ctx, &err)
baseURL, apiKey := e.resolveCredentials(auth)
if baseURL == "" {
err = statusErr{code: http.StatusUnauthorized, msg: "missing provider baseURL"}
return
}
from := opts.SourceFormat
responseFormat := cliproxyexecutor.ResponseFormatOrSource(opts)
to := sdktranslator.FromString("openai")
endpoint := "/chat/completions"
if opts.Alt == "responses/compact" {
to = sdktranslator.FromString("openai-response")
endpoint = "/responses/compact"
}
originalPayloadSource := req.Payload
if len(opts.OriginalRequest) > 0 {
originalPayloadSource = opts.OriginalRequest
}
originalPayload := originalPayloadSource
isCompat := helps.APIKeyModelIsCompat(req)
originalTranslated, translated, updatesChanged, err := helps.TranslateRequestPairWithAPIKeyModelCompatibilityAndUpdateIntent(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, req.Payload, opts.Stream, isCompat)
if err != nil {
return resp, err
}
translated, err = helps.ApplyRequestThinking(translated, req, opts, from.String(), to.String(), e.Identifier(), updatesChanged)
if err != nil {
return resp, err
}
requestedModel := helps.PayloadRequestedModel(opts, req.Model)
requestPath := helps.PayloadRequestPath(opts)
if helps.ShouldNormalizeOpenAIToolResultsForModel(e.resolveCompatConfig(auth, req), baseModel, requestedModel) {
translated = helps.NormalizeOpenAIToolResultsTextOnly(translated)
}
if opts.Alt != "responses/compact" {
useMCT := helps.ShouldUseMaxCompletionTokensForModel(e.resolveCompatConfig(auth, req), baseModel, requestedModel)
translated = helps.NormalizeOpenAIMaxTokens(translated, useMCT)
translated, err = e.applyPromptCacheKey(ctx, auth, from, baseModel, req, opts, translated)
if err != nil {
return resp, err
}
}
if opts.Alt == "responses/compact" {
if updated, errDelete := sjson.DeleteBytes(translated, "stream"); errDelete == nil {
translated = updated
}
translated = sanitizeOpenAIResponsesReasoningEncryptedContent(ctx, "openai compat executor", translated)
}
reporter.SetTranslatedReasoningEffort(translated, to.String())
url := strings.TrimSuffix(baseURL, "/") + endpoint
translated = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, to.String(), from.String(), "", translated, originalTranslated, requestedModel, requestPath, opts.Headers)
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(translated))
if err != nil {
return resp, err
}
httpReq.Header.Set("Content-Type", "application/json")
if apiKey != "" {
httpReq.Header.Set("Authorization", "Bearer "+apiKey)
}
httpReq.Header.Set("User-Agent", "cli-proxy-openai-compat")
var attrs map[string]string
if auth != nil {
attrs = auth.Attributes
}
util.ApplyCustomHeadersFromAttrs(httpReq, attrs, opts.Headers)
var authID, authLabel, authType, authValue string
if auth != nil {
authID = auth.ID
authLabel = auth.Label
authType, authValue = auth.AccountInfo()
}
helps.RecordAPIRequest(ctx, e.cfg, helps.UpstreamRequestLog{
URL: url,
Method: http.MethodPost,
Headers: httpReq.Header.Clone(),
Body: translated,
Provider: e.Identifier(),
AuthID: authID,
AuthLabel: authLabel,
AuthType: authType,
AuthValue: authValue,
})
httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
httpClient = reporter.TrackHTTPClient(httpClient)
httpResp, err := httpClient.Do(httpReq)
if err != nil {
helps.RecordAPIResponseError(ctx, e.cfg, err)
return resp, err
}
defer func() {
if errClose := httpResp.Body.Close(); errClose != nil {
log.Errorf("openai compat executor: close response body error: %v", errClose)
}
}()
helps.RecordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
b, _ := io.ReadAll(httpResp.Body)
helps.AppendAPIResponseChunk(ctx, e.cfg, b)
helps.LogWithRequestID(ctx).Debugf("request error, error status: %d, error message: %s", httpResp.StatusCode, helps.SummarizeErrorBody(httpResp.Header.Get("Content-Type"), b))
err = newOpenAICompatStatusError(httpResp.StatusCode, httpResp.Header, b)
return resp, err
}
body, err := io.ReadAll(httpResp.Body)
if err != nil {
helps.RecordAPIResponseError(ctx, e.cfg, err)
return resp, err
}
helps.AppendAPIResponseChunk(ctx, e.cfg, body)
reporter.ObserveResponseModel(body)
// Ensure we at least record the request even if upstream doesn't return usage
// Translate response back to source format when needed
var param any
out := sdktranslator.TranslateNonStream(ctx, to, responseFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), translated, body, &param)
if helps.ApplyPatchTranslationError(param) != nil || len(out) == 0 {
return cliproxyexecutor.Response{}, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}
}
reporter.Publish(ctx, helps.ParseOpenAIUsage(body))
if responseFormat == sdktranslator.FormatOpenAIResponse {
out = helps.EnsureResponsesUsageDetails(out)
}
resp = cliproxyexecutor.Response{Payload: out, Headers: httpResp.Header.Clone()}
return resp, nil
}
func (e *OpenAICompatExecutor) executeImages(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, endpointPath string) (resp cliproxyexecutor.Response, err error) {
baseModel := thinking.ParseSuffix(req.Model).ModelName
reporter := helps.NewExecutorUsageReporter(ctx, e, baseModel, auth)
defer reporter.TrackFailure(ctx, &err)
baseURL, apiKey := e.resolveCredentials(auth)
if baseURL == "" {
err = statusErr{code: http.StatusUnauthorized, msg: "missing provider baseURL"}
return resp, err
}
payload, contentType, errPrepare := prepareOpenAICompatImagesPayload(req.Payload, baseModel, opts.Headers.Get("Content-Type"), false)
if errPrepare != nil {
err = errPrepare
return resp, err
}
if contentType == "" {
contentType = "application/json"
}
payload, contentType, errPrepare = helps.ApplyMediaPayloadConfig(e.cfg, e.Identifier(), baseModel, "openai", payload, contentType, req, opts)
if errPrepare != nil {
return resp, errPrepare
}
reporter.SetTranslatedReasoningEffort(payload, "openai")
url := strings.TrimSuffix(baseURL, "/") + endpointPath
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload))
if err != nil {
return resp, err
}
httpReq.Header.Set("Content-Type", contentType)
if apiKey != "" {
httpReq.Header.Set("Authorization", "Bearer "+apiKey)
}
httpReq.Header.Set("User-Agent", "cli-proxy-openai-compat")
var attrs map[string]string
if auth != nil {
attrs = auth.Attributes
}
util.ApplyCustomHeadersFromAttrs(httpReq, attrs, opts.Headers)
var authID, authLabel, authType, authValue string
if auth != nil {
authID = auth.ID
authLabel = auth.Label
authType, authValue = auth.AccountInfo()
}
helps.RecordAPIRequest(ctx, e.cfg, helps.UpstreamRequestLog{
URL: url,
Method: http.MethodPost,
Headers: httpReq.Header.Clone(),
Body: payload,
Provider: e.Identifier(),
AuthID: authID,
AuthLabel: authLabel,
AuthType: authType,
AuthValue: authValue,
})
httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
httpClient = reporter.TrackHTTPClient(httpClient)
httpResp, err := httpClient.Do(httpReq)
if err != nil {
helps.RecordAPIResponseError(ctx, e.cfg, err)
return resp, err
}
defer func() {
if errClose := httpResp.Body.Close(); errClose != nil {
log.Errorf("openai compat executor: close response body error: %v", errClose)
}
}()
helps.RecordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())
body, errRead := io.ReadAll(httpResp.Body)
if errRead != nil {
helps.RecordAPIResponseError(ctx, e.cfg, errRead)
err = errRead
return resp, err
}
helps.AppendAPIResponseChunk(ctx, e.cfg, body)
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
helps.LogWithRequestID(ctx).Debugf("request error, error status: %d, error message: %s", httpResp.StatusCode, helps.SummarizeErrorBody(httpResp.Header.Get("Content-Type"), body))
err = newOpenAICompatStatusError(httpResp.StatusCode, httpResp.Header, body)
return resp, err
}
reporter.ObserveResponseModel(body)
reporter.Publish(ctx, helps.ParseOpenAIUsage(body))
reporter.EnsurePublished(ctx)
resp = cliproxyexecutor.Response{Payload: body, Headers: httpResp.Header.Clone()}
return resp, nil
}
func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (_ *cliproxyexecutor.StreamResult, err error) {
ctx = helps.EnsureSessionContext(ctx, opts, req.Payload)
if endpointPath := openAICompatImageEndpointPath(opts); endpointPath != "" {
return e.executeImagesStream(ctx, auth, req, opts, endpointPath)
}
baseModel := thinking.ParseSuffix(req.Model).ModelName
reporter := helps.NewExecutorUsageReporter(ctx, e, baseModel, auth)
defer reporter.TrackFailure(ctx, &err)
baseURL, apiKey := e.resolveCredentials(auth)
if baseURL == "" {
err = statusErr{code: http.StatusUnauthorized, msg: "missing provider baseURL"}
return nil, err
}
from := opts.SourceFormat
responseFormat := cliproxyexecutor.ResponseFormatOrSource(opts)
to := sdktranslator.FromString("openai")
originalPayloadSource := req.Payload
if len(opts.OriginalRequest) > 0 {
originalPayloadSource = opts.OriginalRequest
}
originalPayload := originalPayloadSource
isCompat := helps.APIKeyModelIsCompat(req)
originalTranslated, translated, updatesChanged, err := helps.TranslateRequestPairWithAPIKeyModelCompatibilityAndUpdateIntent(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, req.Payload, true, isCompat)
if err != nil {
return nil, err
}
translated, err = helps.ApplyRequestThinking(translated, req, opts, from.String(), to.String(), e.Identifier(), updatesChanged)
if err != nil {
return nil, err
}
requestedModel := helps.PayloadRequestedModel(opts, req.Model)
requestPath := helps.PayloadRequestPath(opts)
if helps.ShouldNormalizeOpenAIToolResultsForModel(e.resolveCompatConfig(auth, req), baseModel, requestedModel) {
translated = helps.NormalizeOpenAIToolResultsTextOnly(translated)
}
if opts.Alt != "responses/compact" {
useMCT := helps.ShouldUseMaxCompletionTokensForModel(e.resolveCompatConfig(auth, req), baseModel, requestedModel)
translated = helps.NormalizeOpenAIMaxTokens(translated, useMCT)
translated, err = e.applyPromptCacheKey(ctx, auth, from, baseModel, req, opts, translated)
if err != nil {
return nil, err
}
}
// Request usage data in the final streaming chunk so that token statistics
// are captured even when the upstream is an OpenAI-compatible provider.
translated = helps.SetBoolIfDifferent(translated, "stream_options.include_usage", true)
reporter.SetTranslatedReasoningEffort(translated, to.String())
url := strings.TrimSuffix(baseURL, "/") + "/chat/completions"
translated = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, to.String(), from.String(), "", translated, originalTranslated, requestedModel, requestPath, opts.Headers)
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(translated))
if err != nil {
return nil, err
}
httpReq.Header.Set("Content-Type", "application/json")
if apiKey != "" {
httpReq.Header.Set("Authorization", "Bearer "+apiKey)
}
httpReq.Header.Set("User-Agent", "cli-proxy-openai-compat")
var attrs map[string]string
if auth != nil {
attrs = auth.Attributes
}
util.ApplyCustomHeadersFromAttrs(httpReq, attrs, opts.Headers)
httpReq.Header.Set("Accept", "text/event-stream")
httpReq.Header.Set("Cache-Control", "no-cache")
var authID, authLabel, authType, authValue string
if auth != nil {
authID = auth.ID
authLabel = auth.Label
authType, authValue = auth.AccountInfo()
}
helps.RecordAPIRequest(ctx, e.cfg, helps.UpstreamRequestLog{
URL: url,
Method: http.MethodPost,
Headers: httpReq.Header.Clone(),
Body: translated,
Provider: e.Identifier(),
AuthID: authID,
AuthLabel: authLabel,
AuthType: authType,
AuthValue: authValue,
})
httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
httpClient = reporter.TrackHTTPClient(httpClient)
httpResp, err := httpClient.Do(httpReq)
if err != nil {
helps.RecordAPIResponseError(ctx, e.cfg, err)
return nil, err
}
helps.RecordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
b, _ := io.ReadAll(httpResp.Body)
helps.AppendAPIResponseChunk(ctx, e.cfg, b)
helps.LogWithRequestID(ctx).Debugf("request error, error status: %d, error message: %s", httpResp.StatusCode, helps.SummarizeErrorBody(httpResp.Header.Get("Content-Type"), b))
if errClose := httpResp.Body.Close(); errClose != nil {
log.Errorf("openai compat executor: close response body error: %v", errClose)
}
err = newOpenAICompatStatusError(httpResp.StatusCode, httpResp.Header, b)
return nil, err
}
out := make(chan cliproxyexecutor.StreamChunk)
go func() {
defer close(out)
defer func() {
if errClose := httpResp.Body.Close(); errClose != nil {
log.Errorf("openai compat executor: close response body error: %v", errClose)
}
}()
scanner := bufio.NewScanner(httpResp.Body)
scanner.Buffer(nil, 52_428_800) // 50MB
claudeInputTokens := helps.NewClaudeInputTokenState(from, to, responseFormat, originalPayload)
var param any
helps.InitializeApplyPatchStream(ctx, to, responseFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), translated, &param)
var streamUsage helps.StreamUsageBuffer
var seenDone bool
var streamFailed bool
var streamAborted bool
var upstreamEvent string
var frameData [][]byte
defer streamUsage.Publish(ctx, reporter)
publishStreamError := func(streamErr statusErr, containsPayload bool) {
loggedErr := streamErr
if containsPayload {
loggedErr = statusErr{code: streamErr.code, msg: "upstream stream returned an error payload"}
}
helps.RecordAPIResponseError(ctx, e.cfg, loggedErr)
reporter.PublishFailure(ctx, loggedErr)
select {
case out <- cliproxyexecutor.StreamChunk{Err: streamErr}:
case <-ctx.Done():
}
streamFailed = true
}
processFrame := func() bool {
eventName := upstreamEvent
upstreamEvent = ""
dataLines := frameData
frameData = nil
if len(dataLines) == 0 {
if openAICompatErrorEvent(eventName) {
publishStreamError(statusErr{code: http.StatusBadGateway, msg: "upstream error event ended without data"}, false)
return true
}
return false
}
if len(dataLines) > 1 {
for _, dataLine := range dataLines {
if bytes.Equal(bytes.TrimSpace(dataLine), []byte("[DONE]")) {
publishStreamError(statusErr{code: http.StatusBadGateway, msg: "upstream stream ended with incomplete data before [DONE]"}, false)
return true
}
}
}
dataPayload := bytes.TrimSpace(bytes.Join(dataLines, []byte("\n")))
isDone := bytes.Equal(dataPayload, []byte("[DONE]"))
if isDone && openAICompatErrorEvent(eventName) {
publishStreamError(statusErr{code: http.StatusBadGateway, msg: "upstream error event ended before [DONE]"}, false)
return true
}
if !isDone && !json.Valid(dataPayload) {
publishStreamError(statusErr{code: http.StatusBadGateway, msg: "upstream stream ended with incomplete SSE data frame"}, false)
return true
}
if !isDone {
if streamErr, isError := openAICompatStreamDataError(dataPayload, eventName); isError {
publishStreamError(streamErr, true)
return true
}
}
streamLine := append([]byte("data: "), dataPayload...)
chunks := helps.TranslateStreamWithClaudeInputTokens(ctx, to, responseFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), translated, streamLine, &param, claudeInputTokens)
helps.RecordApplyPatchStreamFailure(ctx, param, reporter, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage})
for i := range chunks {
select {
case out <- cliproxyexecutor.StreamChunk{Payload: chunks[i]}:
case <-ctx.Done():
streamAborted = true
return true
}
}
if helps.ApplyPatchTranslationError(param) != nil {
publishStreamError(statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}, false)
return true
}
if isDone {
seenDone = true
return true
}
return false
}
scanLoop:
for scanner.Scan() {
line := scanner.Bytes()
helps.AppendAPIResponseChunk(ctx, e.cfg, line)
reporter.ObserveResponseModel(line)
streamUsage.ObserveOpenAIStream(line)
trimmedLine := bytes.TrimSpace(line)
if len(trimmedLine) == 0 {
if processFrame() {
break scanLoop
}
continue
}
if bytes.HasPrefix(trimmedLine, []byte("data:")) {
frameData = append(frameData, bytes.Clone(bytes.TrimSpace(trimmedLine[len("data:"):])))
continue
}
if bytes.HasPrefix(trimmedLine, []byte("event:")) {
upstreamEvent = strings.TrimSpace(string(trimmedLine[len("event:"):]))
continue
}
if bytes.HasPrefix(trimmedLine, []byte(":")) || bytes.HasPrefix(trimmedLine, []byte("id:")) || bytes.HasPrefix(trimmedLine, []byte("retry:")) {
continue
}
if bytes.HasPrefix(trimmedLine, []byte("{")) || bytes.HasPrefix(trimmedLine, []byte("[")) {
publishStreamError(statusErr{code: http.StatusBadGateway, msg: string(trimmedLine)}, true)
break
}
}
errScan := scanner.Err()
if errScan == nil && !seenDone && !streamFailed && !streamAborted && len(frameData) > 0 {
_ = processFrame()
}
if !streamFailed && !streamAborted && helps.EndApplyPatchStream(ctx, param, reporter, out, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) {
return
}
if !streamFailed && !streamAborted && !seenDone && errScan == nil && responseFormat == sdktranslator.FormatOpenAIResponse && ctx.Err() == nil && helps.CanFinalizeResponseStream(param) {
finalChunks := helps.TranslateStreamWithClaudeInputTokens(ctx, to, responseFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), translated, []byte("data: [DONE]"), &param, claudeInputTokens)
for _, finalChunk := range finalChunks {
select {
case out <- cliproxyexecutor.StreamChunk{Payload: finalChunk}:
case <-ctx.Done():
streamAborted = true
}
if streamAborted {
break
}
}
if len(finalChunks) > 0 && !streamAborted {
seenDone = true
}
}
if streamFailed || streamAborted {
return
}
if errScan != nil && !seenDone {
helps.RecordAPIResponseError(ctx, e.cfg, errScan)
reporter.PublishFailure(ctx, errScan)
select {
case out <- cliproxyexecutor.StreamChunk{Err: errScan}:
case <-ctx.Done():
}
} else if !seenDone {
// Without a translator-confirmed terminal state, a clean Responses EOF
// without [DONE] remains a failed stream instead of completing it.
if responseFormat == sdktranslator.FormatOpenAIResponse {
streamErr := statusErr{code: http.StatusBadGateway, msg: "upstream stream closed before [DONE]"}
helps.RecordAPIResponseError(ctx, e.cfg, streamErr)
reporter.PublishFailure(ctx, streamErr)
select {
case out <- cliproxyexecutor.StreamChunk{Err: streamErr}:
case <-ctx.Done():
}
return
}
// Other protocols retain compatibility with providers that omit [DONE].
chunks := helps.TranslateStreamWithClaudeInputTokens(ctx, to, responseFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), translated, []byte("data: [DONE]"), &param, claudeInputTokens)
helps.RecordApplyPatchStreamFailure(ctx, param, reporter, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage})
for i := range chunks {
select {
case out <- cliproxyexecutor.StreamChunk{Payload: chunks[i]}:
case <-ctx.Done():
return
}
}
}
// Ensure we record the request if no usage chunk was ever seen.
streamUsage.Publish(ctx, reporter)
reporter.EnsurePublished(ctx)
}()
return &cliproxyexecutor.StreamResult{Headers: httpResp.Header.Clone(), Chunks: out}, nil
}
func (e *OpenAICompatExecutor) executeImagesStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, endpointPath string) (_ *cliproxyexecutor.StreamResult, err error) {
baseModel := thinking.ParseSuffix(req.Model).ModelName
reporter := helps.NewExecutorUsageReporter(ctx, e, baseModel, auth)
defer reporter.TrackFailure(ctx, &err)
baseURL, apiKey := e.resolveCredentials(auth)
if baseURL == "" {
err = statusErr{code: http.StatusUnauthorized, msg: "missing provider baseURL"}
return nil, err
}
payload, contentType, errPrepare := prepareOpenAICompatImagesPayload(req.Payload, baseModel, opts.Headers.Get("Content-Type"), true)
if errPrepare != nil {
err = errPrepare
return nil, err
}
if contentType == "" {
contentType = "application/json"
}
payload, contentType, errPrepare = helps.ApplyMediaPayloadConfig(e.cfg, e.Identifier(), baseModel, "openai", payload, contentType, req, opts)
if errPrepare != nil {
return nil, errPrepare
}
reporter.SetTranslatedReasoningEffort(payload, "openai")
url := strings.TrimSuffix(baseURL, "/") + endpointPath
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload))
if err != nil {
return nil, err
}
httpReq.Header.Set("Content-Type", contentType)
httpReq.Header.Set("Accept", "text/event-stream")
httpReq.Header.Set("Cache-Control", "no-cache")
if apiKey != "" {
httpReq.Header.Set("Authorization", "Bearer "+apiKey)
}
httpReq.Header.Set("User-Agent", "cli-proxy-openai-compat")
var attrs map[string]string
if auth != nil {
attrs = auth.Attributes
}
util.ApplyCustomHeadersFromAttrs(httpReq, attrs, opts.Headers)
var authID, authLabel, authType, authValue string
if auth != nil {
authID = auth.ID
authLabel = auth.Label
authType, authValue = auth.AccountInfo()
}
helps.RecordAPIRequest(ctx, e.cfg, helps.UpstreamRequestLog{
URL: url,
Method: http.MethodPost,
Headers: httpReq.Header.Clone(),
Body: payload,
Provider: e.Identifier(),
AuthID: authID,
AuthLabel: authLabel,
AuthType: authType,
AuthValue: authValue,
})
httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
httpClient = reporter.TrackHTTPClient(httpClient)
httpResp, err := httpClient.Do(httpReq)
if err != nil {
helps.RecordAPIResponseError(ctx, e.cfg, err)
return nil, err
}
helps.RecordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
body, errRead := io.ReadAll(httpResp.Body)
if errClose := httpResp.Body.Close(); errClose != nil {
log.Errorf("openai compat executor: close response body error: %v", errClose)
}
if errRead != nil {
helps.RecordAPIResponseError(ctx, e.cfg, errRead)
return nil, errRead
}
helps.AppendAPIResponseChunk(ctx, e.cfg, body)
helps.LogWithRequestID(ctx).Debugf("request error, error status: %d, error message: %s", httpResp.StatusCode, helps.SummarizeErrorBody(httpResp.Header.Get("Content-Type"), body))
return nil, statusErr{code: httpResp.StatusCode, msg: string(body)}
}
out := make(chan cliproxyexecutor.StreamChunk)
go func() {
defer close(out)
observer := helps.NewStreamResponseModelObserver(reporter)
defer func() {
observer.Finish()
if errClose := httpResp.Body.Close(); errClose != nil {
log.Errorf("openai compat executor: close response body error: %v", errClose)
}
reporter.EnsurePublished(ctx)
}()
buffer := make([]byte, 32*1024)
for {
n, errRead := httpResp.Body.Read(buffer)
if n > 0 {
chunk := bytes.Clone(buffer[:n])
helps.AppendAPIResponseChunk(ctx, e.cfg, chunk)
observer.Feed(chunk)
select {
case out <- cliproxyexecutor.StreamChunk{Payload: chunk}:
case <-ctx.Done():
return
}
}
if errRead != nil {
if errRead != io.EOF {
helps.RecordAPIResponseError(ctx, e.cfg, errRead)
reporter.PublishFailure(ctx, errRead)
select {
case out <- cliproxyexecutor.StreamChunk{Err: errRead}:
case <-ctx.Done():
}
}
return
}
}
}()
return &cliproxyexecutor.StreamResult{Headers: httpResp.Header.Clone(), Chunks: out}, nil
}
func (e *OpenAICompatExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
baseModel := thinking.ParseSuffix(req.Model).ModelName
from := opts.SourceFormat
responseFormat := cliproxyexecutor.ResponseFormatOrSource(opts)
to := sdktranslator.FromString("openai")
isCompat := helps.APIKeyModelIsCompat(req)
translated, updatesChanged, err := helps.TranslateRequestWithAPIKeyModelCompatibilityAndUpdateIntent(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, false, isCompat)
if err != nil {
return cliproxyexecutor.Response{}, err
}
originalTranslatedForPayload := append([]byte(nil), translated...)
if len(opts.OriginalRequest) > 0 {
originalTranslatedForPayload, _, _ = helps.TranslateRequestWithAPIKeyModelCompatibilityAndUpdateIntent(ctx, opts.Headers, e.cfg, from, to, baseModel, opts.OriginalRequest, false, isCompat)
}
modelForCounting := baseModel
translated, err = helps.ApplyRequestThinking(translated, req, opts, from.String(), to.String(), e.Identifier(), updatesChanged)
if err != nil {
return cliproxyexecutor.Response{}, err
}
translated = helps.NewPayloadFinalizer(e.cfg, e.Identifier(), baseModel, to.String(), "", originalTranslatedForPayload, req, opts)(translated)
enc, err := helps.TokenizerForModel(modelForCounting)
if err != nil {
return cliproxyexecutor.Response{}, fmt.Errorf("openai compat executor: tokenizer init failed: %w", err)
}
count, err := helps.CountOpenAIChatTokens(enc, translated)
if err != nil {
return cliproxyexecutor.Response{}, fmt.Errorf("openai compat executor: token counting failed: %w", err)
}
usageJSON := helps.BuildOpenAIUsageJSON(count)
translatedUsage := sdktranslator.TranslateTokenCount(ctx, to, responseFormat, count, usageJSON)
return cliproxyexecutor.Response{Payload: translatedUsage}, nil
}
// Refresh is a no-op for API-key based compatibility providers.
// OAuth-style credentials with a refresh token cannot be rotated here; callers
// that need plugin/Home refresh must bind a refresh-capable executor instead.
func (e *OpenAICompatExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) {
log.Debugf("openai compat executor: refresh called")
if refreshed, handled, err := helps.RefreshAuthViaHome(ctx, e.cfg, auth); handled {
return refreshed, err
}
if openAICompatAuthHasRefreshToken(auth) {
provider := ""
if e != nil {
provider = e.Identifier()
}
if provider == "" && auth != nil {
provider = strings.TrimSpace(auth.Provider)
}
return nil, fmt.Errorf("openai compat executor cannot refresh oauth credentials for provider %s", provider)
}
return auth, nil
}
func openAICompatAuthHasRefreshToken(auth *cliproxyauth.Auth) bool {
if auth == nil || auth.Metadata == nil {
return false
}
if token, _ := auth.Metadata["refresh_token"].(string); strings.TrimSpace(token) != "" {
return true
}
if token, _ := auth.Metadata["refreshToken"].(string); strings.TrimSpace(token) != "" {
return true
}
return false
}
func openAICompatImageEndpointPath(opts cliproxyexecutor.Options) string {
if opts.SourceFormat.String() != openAICompatImageHandlerType {
return ""
}
path := helps.PayloadRequestPath(opts)
if strings.HasSuffix(path, "/images/edits") {
return openAICompatImagesEditsPath
}
if strings.HasSuffix(path, "/images/generations") {
return openAICompatImagesGenerationsPath
}
return openAICompatDefaultImageEndpoint
}
func prepareOpenAICompatImagesPayload(payload []byte, model string, contentType string, stream bool) ([]byte, string, error) {
model = strings.TrimSpace(model)
contentType = strings.TrimSpace(contentType)
if json.Valid(payload) {
if model != "" {
payload = helps.SetStringIfDifferent(payload, "model", model)
}
if stream {
payload = helps.SetBoolIfDifferent(payload, "stream", true)
} else {
payload, _ = sjson.DeleteBytes(payload, "stream")
}
return payload, "application/json", nil
}
mediaType, params, errParse := mime.ParseMediaType(contentType)
if errParse != nil || !strings.HasPrefix(strings.ToLower(strings.TrimSpace(mediaType)), "multipart/") {
return payload, contentType, nil
}
boundary := strings.TrimSpace(params["boundary"])
if boundary == "" {
return nil, "", fmt.Errorf("multipart boundary is missing")
}
return rewriteOpenAICompatImagesMultipartPayload(payload, model, boundary, stream)
}
func cloneOpenAICompatMIMEHeader(src textproto.MIMEHeader) textproto.MIMEHeader {
dst := make(textproto.MIMEHeader, len(src))
for key, values := range src {
dst[key] = append([]string(nil), values...)
}
return dst
}
func rewriteOpenAICompatImagesMultipartPayload(payload []byte, model string, boundary string, stream bool) ([]byte, string, error) {
reader := multipart.NewReader(bytes.NewReader(payload), boundary)
form, errRead := reader.ReadForm(openAICompatMultipartMemory)
if errRead != nil {
return nil, "", fmt.Errorf("read multipart form failed: %w", errRead)
}
defer func() {
if errRemove := form.RemoveAll(); errRemove != nil {
log.Errorf("openai compat executor: remove multipart form files error: %v", errRemove)
}
}()
var body bytes.Buffer
writer := multipart.NewWriter(&body)
if model != "" {
if errWrite := writer.WriteField("model", model); errWrite != nil {
return nil, "", fmt.Errorf("write model field failed: %w", errWrite)
}
}
if stream {
if errWrite := writer.WriteField("stream", "true"); errWrite != nil {
return nil, "", fmt.Errorf("write stream field failed: %w", errWrite)
}
}
for key, values := range form.Value {
if key == "model" || key == "stream" {
continue
}
for _, value := range values {
if errWrite := writer.WriteField(key, value); errWrite != nil {
return nil, "", fmt.Errorf("write form field %s failed: %w", key, errWrite)
}
}
}
for key, files := range form.File {
for _, fileHeader := range files {
if fileHeader == nil {
continue
}
header := cloneOpenAICompatMIMEHeader(fileHeader.Header)
header.Set("Content-Disposition", multipart.FileContentDisposition(key, fileHeader.Filename))
if header.Get("Content-Type") == "" {
header.Set("Content-Type", "application/octet-stream")
}
part, errCreate := writer.CreatePart(header)
if errCreate != nil {
return nil, "", fmt.Errorf("create file field %s failed: %w", key, errCreate)
}
src, errOpen := fileHeader.Open()
if errOpen != nil {
return nil, "", fmt.Errorf("open upload file failed: %w", errOpen)
}
_, errCopy := io.Copy(part, src)
if errClose := src.Close(); errClose != nil {
log.Errorf("openai compat executor: close upload file error: %v", errClose)
if errCopy == nil {
errCopy = errClose
}
}
if errCopy != nil {
return nil, "", fmt.Errorf("copy upload file failed: %w", errCopy)
}
}
}
if errClose := writer.Close(); errClose != nil {
return nil, "", fmt.Errorf("close multipart writer failed: %w", errClose)
}
return body.Bytes(), writer.FormDataContentType(), nil
}
func (e *OpenAICompatExecutor) applyPromptCacheKey(ctx context.Context, auth *cliproxyauth.Auth, from sdktranslator.Format, baseModel string, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, translated []byte) ([]byte, error) {
compat := e.resolveCompatConfig(auth, req)
if compat == nil || !compat.SupportPromptCacheKey {
return translated, nil
}
for _, payload := range [][]byte{req.Payload, opts.OriginalRequest, translated} {
if promptCacheKey := strings.TrimSpace(gjson.GetBytes(payload, "prompt_cache_key").String()); promptCacheKey != "" {
return helps.SetStringIfDifferent(translated, "prompt_cache_key", promptCacheKey), nil
}
}
modelName := strings.TrimSpace(gjson.GetBytes(translated, "model").String())
if modelName == "" {
modelName = baseModel
}
if sourceFormatEqual(from, sdktranslator.FormatClaude) {
cached, ok, errCache := helps.ClaudeCodePromptCache(ctx, modelName, req.Payload, opts.Headers)
if errCache != nil {
return translated, errCache
}
if ok {
return helps.SetStringIfDifferent(translated, "prompt_cache_key", cached.ID), nil
}
}
sessionID := helps.ProviderSessionUUID(e.provider, opts.Metadata, req.Metadata)
if sessionID == "" {
return translated, nil
}
provider := strings.TrimSpace(e.provider)
if provider == "" {
provider = strings.TrimSpace(compat.Name)
}
identity := strings.Join([]string{
"cli-proxy-api:openai-compat:prompt-cache",
strings.ToLower(provider),
strings.ToLower(modelName),
strings.ToLower(strings.TrimSpace(from.String())),
sessionID,
}, "\x00")
promptCacheKey := uuid.NewSHA1(uuid.NameSpaceOID, []byte(identity)).String()
return helps.SetStringIfDifferent(translated, "prompt_cache_key", promptCacheKey), nil
}
func (e *OpenAICompatExecutor) resolveCredentials(auth *cliproxyauth.Auth) (baseURL, apiKey string) {
if auth == nil {
return "", ""
}
if auth.Attributes != nil {
baseURL = strings.TrimSpace(auth.Attributes["base_url"])
apiKey = strings.TrimSpace(auth.Attributes["api_key"])
}
return
}
func (e *OpenAICompatExecutor) resolveCompatConfig(auth *cliproxyauth.Auth, req cliproxyexecutor.Request) *config.OpenAICompatibility {
if auth == nil || e.cfg == nil {
return nil
}
if e.cfg.Home.Enabled {
// Home excludes provider credentials from the global configuration. These
// non-secret options belong to the credential selected for this attempt.
var options struct {
SupportPromptCacheKey bool `json:"support-prompt-cache-key"`
Models []config.OpenAICompatibilityModel `json:"models"`
}
present := false
if rawOptions, exists := auth.Metadata["credential_options"]; exists {
if data, errMarshal := json.Marshal(rawOptions); errMarshal == nil {
present = json.Unmarshal(data, &options) == nil
}
}
if raw, exists := auth.Attributes["support_prompt_cache_key"]; exists {
if value, errParse := strconv.ParseBool(raw); errParse == nil {
options.SupportPromptCacheKey = value
present = true
}
}
if model, ok := cliproxyauth.ResolvedHomeModelOptions(req); ok {
options.Models = []config.OpenAICompatibilityModel{model}
present = true
}
if present {
return &config.OpenAICompatibility{
Name: auth.Attributes["compat_name"],
SupportPromptCacheKey: options.SupportPromptCacheKey,
Models: options.Models,
}
}
}
if auth.AuthSourceKind() == cliproxyauth.AuthSourceConfig && auth.Attributes != nil {
if rawIndex := strings.TrimSpace(auth.Attributes["config_index"]); rawIndex != "" {
configIndex, errIndex := strconv.Atoi(rawIndex)
if errIndex == nil && configIndex >= 0 && configIndex < len(e.cfg.OpenAICompatibility) {
compat := &e.cfg.OpenAICompatibility[configIndex]
if !compat.Disabled {
return compat
}
}
}
}
candidates := make([]string, 0, 3)
if auth.Attributes != nil {
if v := strings.TrimSpace(auth.Attributes["compat_name"]); v != "" {
candidates = append(candidates, v)
}
if v := strings.TrimSpace(auth.Attributes["provider_key"]); v != "" {
candidates = append(candidates, v)
}
}
if v := strings.TrimSpace(auth.Provider); v != "" {
candidates = append(candidates, v)
}
for i := range e.cfg.OpenAICompatibility {
compat := &e.cfg.OpenAICompatibility[i]
if compat.Disabled {
continue
}
for _, candidate := range candidates {
if candidate != "" && strings.EqualFold(strings.TrimSpace(candidate), compat.Name) {
return compat
}
}
}
return nil
}
func (e *OpenAICompatExecutor) overrideModel(payload []byte, model string) []byte {
if len(payload) == 0 || model == "" {
return payload
}
return helps.SetStringIfDifferent(payload, "model", model)
}
func openAICompatErrorEvent(eventName string) bool {
return strings.EqualFold(eventName, "error") || strings.EqualFold(eventName, "response.error") || strings.EqualFold(eventName, "response.failed")
}
func openAICompatStreamDataError(payload []byte, eventName string) (statusErr, bool) {
if len(payload) == 0 || !json.Valid(payload) {
return statusErr{}, false
}
payloadType := gjson.GetBytes(payload, "type").String()
hasError := false
for _, path := range []string{"error", "response.error"} {
errorNode := gjson.GetBytes(payload, path)
if errorNode.Exists() && errorNode.Raw != "null" {
hasError = true
break
}
}
hasTopLevelErrorFields := gjson.GetBytes(payload, "code").Exists() && gjson.GetBytes(payload, "message").Exists()
if !hasError && !strings.EqualFold(payloadType, "error") && !strings.EqualFold(payloadType, "response.error") && !strings.EqualFold(payloadType, "response.failed") &&
!openAICompatErrorEvent(eventName) && !hasTopLevelErrorFields {
return statusErr{}, false
}
status := 0
for _, path := range []string{"status", "status_code", "error.status", "error.status_code", "response.error.status", "response.error.status_code"} {
status = int(gjson.GetBytes(payload, path).Int())
if status >= http.StatusBadRequest && status <= 599 {
break
}
}
if status < http.StatusBadRequest || status > 599 {
status = http.StatusBadGateway
}
return statusErr{code: status, msg: string(payload)}, true
}
type statusErr struct {
code int
msg string
retryAfter *time.Duration
credentialScoped bool
}
func (e statusErr) Error() string {
if e.msg != "" {
return e.msg
}
return fmt.Sprintf("status %d", e.code)
}
func (e statusErr) StatusCode() int { return e.code }
func (e statusErr) RetryAfter() *time.Duration { return e.retryAfter }
func (e statusErr) IsCredentialScoped() bool { return e.credentialScoped }
const openAICompatTPMFallbackRetryAfter = time.Minute
func newOpenAICompatStatusError(status int, headers http.Header, body []byte) statusErr {
return statusErr{
code: status,
msg: string(body),
retryAfter: openAICompatRetryAfter(status, headers, body, time.Now()),
}
}
// openAICompatRetryAfter preserves the provider's standard Retry-After signal.
// Some OpenAI-compatible providers omit that header for explicit per-minute
// token limits; in that narrow case a one-minute fallback prevents immediate
// replay of the same large request while keeping the retry wait bounded.
func openAICompatRetryAfter(status int, headers http.Header, body []byte, now time.Time) *time.Duration {
if status != http.StatusTooManyRequests {
return nil
}
if raw := strings.TrimSpace(headers.Get("Retry-After")); raw != "" {
if seconds, errParse := strconv.ParseInt(raw, 10, 64); errParse == nil && seconds >= 0 {
delay := time.Duration(seconds) * time.Second
return &delay
}
if deadline, errParse := http.ParseTime(raw); errParse == nil {
delay := deadline.Sub(now)
if delay < 0 {
delay = 0
}
return &delay
}
}
code := strings.ToLower(strings.TrimSpace(gjson.GetBytes(body, "error.code").String()))
message := strings.ToLower(strings.TrimSpace(gjson.GetBytes(body, "error.message").String()))
if strings.Contains(code, "tpmratelimitexceeded") ||
(strings.Contains(message, "tokens per minute") && strings.Contains(message, "limit") && strings.Contains(message, "exceeded")) {
delay := openAICompatTPMFallbackRetryAfter
return &delay
}
return nil
}
// SupportsApplyPatch reports the actual executor contract, independent of its provider name.
func (e *OpenAICompatExecutor) SupportsApplyPatch() bool { return e != nil }