mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-10-18 13:34:46 +08:00
feat(api): support request path overrides and image protocols in model execution
- Add `Path` field to model execution requests and plugin interceptor responses - Propagate custom request paths through execution metadata - Allow image models during execution when entry or exit protocol is `openai-image` Closes: #6196
This commit is contained in:
@@ -10,6 +10,7 @@ import (
|
||||
"strings"
|
||||
|
||||
coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
|
||||
coreexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
|
||||
"github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginabi"
|
||||
"github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginapi"
|
||||
log "github.com/sirupsen/logrus"
|
||||
@@ -127,6 +128,12 @@ func (h *Host) interceptRequest(ctx context.Context, req pluginapi.RequestInterc
|
||||
nextReq.Body = nil
|
||||
}
|
||||
nextReq.Metadata = cloneInterceptorMetadata(req.Metadata)
|
||||
if current.Path != "" {
|
||||
if nextReq.Metadata == nil {
|
||||
nextReq.Metadata = make(map[string]any, 1)
|
||||
}
|
||||
nextReq.Metadata[coreexecutor.RequestPathMetadataKey] = current.Path
|
||||
}
|
||||
if resp, ok := h.callRequestInterceptor(ctx, record, method, func(callCtx context.Context, callReq pluginapi.RequestInterceptRequest) (pluginapi.RequestInterceptResponse, error) {
|
||||
return invoke(interceptor, callCtx, callReq)
|
||||
}, nextReq); ok {
|
||||
@@ -135,6 +142,9 @@ func (h *Host) interceptRequest(ctx context.Context, req pluginapi.RequestInterc
|
||||
currentBase = bytes.Clone(resp.Body)
|
||||
bodyModified = true
|
||||
}
|
||||
if strings.TrimSpace(resp.Path) != "" {
|
||||
current.Path = strings.TrimSpace(resp.Path)
|
||||
}
|
||||
if resp.Terminate {
|
||||
current.Terminate = true
|
||||
current.StatusCode = resp.StatusCode
|
||||
|
||||
@@ -1361,6 +1361,79 @@ func TestInterceptRequestAfterAuthPassesTargetFormat(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterceptRequestPropagatesPath_Issue6196(t *testing.T) {
|
||||
host := newHostWithRecords(capabilityRecord{
|
||||
id: "path-rewriter",
|
||||
plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{
|
||||
RequestInterceptor: requestInterceptorFunc(func(ctx context.Context, req pluginapi.RequestInterceptRequest) (pluginapi.RequestInterceptResponse, error) {
|
||||
return pluginapi.RequestInterceptResponse{
|
||||
Path: "/v1/images/generations",
|
||||
Body: []byte(`{"model":"gpt-image-2.5"}`),
|
||||
}, nil
|
||||
}),
|
||||
}},
|
||||
})
|
||||
|
||||
got := host.InterceptRequestAfterAuth(context.Background(), pluginapi.RequestInterceptRequest{
|
||||
SourceFormat: "openai-image",
|
||||
Model: "gpt-image-2.5",
|
||||
Body: []byte(`{"model":"gpt-image-2.5"}`),
|
||||
})
|
||||
|
||||
if got.Path != "/v1/images/generations" {
|
||||
t.Fatalf("got.Path = %q, want /v1/images/generations", got.Path)
|
||||
}
|
||||
if string(got.Body) != `{"model":"gpt-image-2.5"}` {
|
||||
t.Fatalf("got.Body = %s, want expected body", string(got.Body))
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterceptRequestPropagatesPathAcrossPluginChain_Issue6196(t *testing.T) {
|
||||
var secondPluginSeenPath any
|
||||
host := newHostWithRecords(
|
||||
capabilityRecord{
|
||||
id: "first-path-rewriter",
|
||||
priority: 20,
|
||||
plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{
|
||||
RequestInterceptor: requestInterceptorFunc(func(ctx context.Context, req pluginapi.RequestInterceptRequest) (pluginapi.RequestInterceptResponse, error) {
|
||||
return pluginapi.RequestInterceptResponse{
|
||||
Path: "/v1/images/generations",
|
||||
Body: []byte(`{"model":"gpt-image-2.5","rewritten":true}`),
|
||||
}, nil
|
||||
}),
|
||||
}},
|
||||
},
|
||||
capabilityRecord{
|
||||
id: "second-downstream-observer",
|
||||
priority: 10,
|
||||
plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{
|
||||
RequestInterceptor: requestInterceptorFunc(func(ctx context.Context, req pluginapi.RequestInterceptRequest) (pluginapi.RequestInterceptResponse, error) {
|
||||
if req.Metadata != nil {
|
||||
secondPluginSeenPath = req.Metadata[coreexecutor.RequestPathMetadataKey]
|
||||
}
|
||||
return pluginapi.RequestInterceptResponse{}, nil
|
||||
}),
|
||||
}},
|
||||
},
|
||||
)
|
||||
|
||||
got := host.InterceptRequestAfterAuth(context.Background(), pluginapi.RequestInterceptRequest{
|
||||
SourceFormat: "openai-image",
|
||||
Model: "gpt-image-2.5",
|
||||
Body: []byte(`{"model":"gpt-image-2.5"}`),
|
||||
Metadata: map[string]any{
|
||||
coreexecutor.RequestPathMetadataKey: "/v1/images/edits",
|
||||
},
|
||||
})
|
||||
|
||||
if got.Path != "/v1/images/generations" {
|
||||
t.Fatalf("got.Path = %q, want /v1/images/generations", got.Path)
|
||||
}
|
||||
if secondPluginSeenPath != "/v1/images/generations" {
|
||||
t.Fatalf("second plugin saw path = %v, want /v1/images/generations", secondPluginSeenPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterceptorsSkipExceptedPlugin(t *testing.T) {
|
||||
originCalls := 0
|
||||
otherCalls := 0
|
||||
|
||||
@@ -445,6 +445,7 @@ func modelExecutionRequestFromPlugin(req pluginapi.HostModelExecutionRequest, sk
|
||||
ForcedProvider: req.ForcedProvider,
|
||||
AuthID: req.AuthID,
|
||||
ProxyURL: strings.TrimSpace(req.ProxyURL),
|
||||
Path: strings.TrimSpace(req.Path),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -119,3 +119,68 @@ func TestOpenAICompatExecutor_ImageStreamChunkBoundaryObservability(t *testing.T
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAICompatExecutor_ImageEndpointPath_HonorsOverriddenRequestPath_Issue6196(t *testing.T) {
|
||||
var requestedPath string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requestedPath = r.URL.Path
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"created":123,"data":[{"b64_json":"img"}]}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
executor := NewOpenAICompatExecutor("openai-compatibility", &config.Config{
|
||||
OpenAICompatibility: []config.OpenAICompatibility{{
|
||||
Name: "compat",
|
||||
}},
|
||||
})
|
||||
auth := &cliproxyauth.Auth{
|
||||
Provider: "openai-compatibility",
|
||||
Attributes: map[string]string{
|
||||
"base_url": server.URL,
|
||||
"api_key": "test-key",
|
||||
"compat_name": "compat",
|
||||
"provider_key": "compat",
|
||||
},
|
||||
}
|
||||
|
||||
payload := []byte(`{"model":"gpt-image-2.5","prompt":"a red apple"}`)
|
||||
// Case 1: default inbound path is edits
|
||||
optsEdits := cliproxyexecutor.Options{
|
||||
SourceFormat: sdktranslator.FromString("openai-image"),
|
||||
Metadata: map[string]any{
|
||||
cliproxyexecutor.RequestPathMetadataKey: "/v1/images/edits",
|
||||
},
|
||||
}
|
||||
if _, err := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{
|
||||
Model: "gpt-image-2.5",
|
||||
Payload: payload,
|
||||
}, optsEdits); err != nil {
|
||||
t.Fatalf("Execute edits error: %v", err)
|
||||
}
|
||||
if requestedPath != "/images/edits" {
|
||||
t.Fatalf("upstream path = %q, want /images/edits", requestedPath)
|
||||
}
|
||||
|
||||
// Case 2: interceptor rewrites to generations
|
||||
optsGenerations := cliproxyexecutor.Options{
|
||||
SourceFormat: sdktranslator.FromString("openai-image"),
|
||||
Metadata: map[string]any{
|
||||
cliproxyexecutor.RequestPathMetadataKey: "/v1/images/generations",
|
||||
},
|
||||
}
|
||||
resp, err := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{
|
||||
Model: "gpt-image-2.5",
|
||||
Payload: payload,
|
||||
}, optsGenerations)
|
||||
if err != nil {
|
||||
t.Fatalf("Execute error: %v", err)
|
||||
}
|
||||
if len(resp.Payload) == 0 {
|
||||
t.Fatalf("Execute returned empty payload")
|
||||
}
|
||||
if requestedPath != "/images/generations" {
|
||||
t.Fatalf("upstream path = %q, want /images/generations", requestedPath)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v8/internal/clienterror"
|
||||
"github.com/router-for-me/CLIProxyAPI/v8/internal/interfaces"
|
||||
@@ -58,6 +60,9 @@ func (h *BaseAPIHandler) executeWithAuthManagerFormats(ctx context.Context, entr
|
||||
}
|
||||
providers = adjustExecutionProvidersForEntryProtocol(entryProtocol, providers)
|
||||
reqMeta := requestExecutionMetadata(ctx)
|
||||
if execOptions.Path != "" {
|
||||
reqMeta[coreexecutor.RequestPathMetadataKey] = execOptions.Path
|
||||
}
|
||||
reqMeta[coreexecutor.RequestedModelMetadataKey] = originalRequestedModel
|
||||
addAuthSelectionModelMetadata(reqMeta, execOptions.AuthSelectionModel)
|
||||
addModelExecutionSourceMetadata(reqMeta, execOptions.InternalSource)
|
||||
@@ -270,6 +275,9 @@ func (h *BaseAPIHandler) countWithPluginExecutor(ctx context.Context, handlerTyp
|
||||
|
||||
func (h *BaseAPIHandler) pluginExecutorRequest(ctx context.Context, entryProtocol, responseProtocol, modelName, originalRequestedModel string, rawJSON []byte, alt string, stream bool, execOptions modelExecutionOptions) (coreexecutor.Request, coreexecutor.Options) {
|
||||
reqMeta := requestExecutionMetadata(ctx)
|
||||
if execOptions.Path != "" {
|
||||
reqMeta[coreexecutor.RequestPathMetadataKey] = execOptions.Path
|
||||
}
|
||||
reqMeta[coreexecutor.RequestedModelMetadataKey] = originalRequestedModel
|
||||
addAuthSelectionModelMetadata(reqMeta, execOptions.AuthSelectionModel)
|
||||
addModelExecutionSourceMetadata(reqMeta, execOptions.InternalSource)
|
||||
@@ -321,6 +329,14 @@ func (h *BaseAPIHandler) applyRequestInterceptorsAfterPluginExecutorRoute(ctx co
|
||||
req.Payload = cloneBytes(resp.Body)
|
||||
opts.OriginalRequest = cloneBytes(resp.Body)
|
||||
}
|
||||
if path := strings.TrimSpace(resp.Path); path != "" {
|
||||
if opts.Metadata == nil {
|
||||
opts.Metadata = make(map[string]any, 1)
|
||||
} else {
|
||||
opts.Metadata = maps.Clone(opts.Metadata)
|
||||
}
|
||||
opts.Metadata[coreexecutor.RequestPathMetadataKey] = path
|
||||
}
|
||||
if resp.Terminate {
|
||||
return req, opts, directTerminationError(resp.StatusCode, resp.ResponseHeaders, resp.ResponseBody)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@ package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"maps"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -368,6 +370,7 @@ type requestAfterAuthCapture struct {
|
||||
body []byte
|
||||
originalRequest []byte
|
||||
originalRequestReplaced bool
|
||||
path string
|
||||
}
|
||||
|
||||
func (c *requestAfterAuthCapture) record(req coreexecutor.RequestAfterAuthInterceptRequest, resp coreexecutor.RequestAfterAuthInterceptResponse) {
|
||||
@@ -383,6 +386,7 @@ func (c *requestAfterAuthCapture) record(req coreexecutor.RequestAfterAuthInterc
|
||||
originalRequest = cloneBytes(resp.Body)
|
||||
originalRequestReplaced = true
|
||||
}
|
||||
path := strings.TrimSpace(resp.Path)
|
||||
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
@@ -391,6 +395,7 @@ func (c *requestAfterAuthCapture) record(req coreexecutor.RequestAfterAuthInterc
|
||||
c.body = body
|
||||
c.originalRequest = originalRequest
|
||||
c.originalRequestReplaced = originalRequestReplaced
|
||||
c.path = path
|
||||
}
|
||||
|
||||
func (c *requestAfterAuthCapture) apply(req coreexecutor.Request, opts coreexecutor.Options) (coreexecutor.Request, coreexecutor.Options) {
|
||||
@@ -407,6 +412,14 @@ func (c *requestAfterAuthCapture) apply(req coreexecutor.Request, opts coreexecu
|
||||
opts.OriginalRequest = cloneBytes(c.originalRequest)
|
||||
}
|
||||
opts.Headers = cloneHeader(c.headers)
|
||||
if c.path != "" {
|
||||
if opts.Metadata == nil {
|
||||
opts.Metadata = make(map[string]any, 1)
|
||||
} else {
|
||||
opts.Metadata = maps.Clone(opts.Metadata)
|
||||
}
|
||||
opts.Metadata[coreexecutor.RequestPathMetadataKey] = c.path
|
||||
}
|
||||
return req, opts
|
||||
}
|
||||
|
||||
@@ -487,6 +500,14 @@ func (h *BaseAPIHandler) applyRequestInterceptorsBeforeAuth(ctx context.Context,
|
||||
req.Payload = cloneBytes(resp.Body)
|
||||
opts.OriginalRequest = cloneBytes(resp.Body)
|
||||
}
|
||||
if strings.TrimSpace(resp.Path) != "" {
|
||||
if opts.Metadata == nil {
|
||||
opts.Metadata = make(map[string]any, 1)
|
||||
} else {
|
||||
opts.Metadata = maps.Clone(opts.Metadata)
|
||||
}
|
||||
opts.Metadata[coreexecutor.RequestPathMetadataKey] = strings.TrimSpace(resp.Path)
|
||||
}
|
||||
if resp.Terminate {
|
||||
return req, opts, requestTerminationError(resp)
|
||||
}
|
||||
@@ -565,6 +586,7 @@ func (h *BaseAPIHandler) applyRequestInterceptorsAfterAuth(ctx context.Context,
|
||||
Metadata: req.Metadata,
|
||||
}, skipPluginID)
|
||||
return coreexecutor.RequestAfterAuthInterceptResponse{
|
||||
Path: strings.TrimSpace(resp.Path),
|
||||
Headers: resp.Headers,
|
||||
Body: resp.Body,
|
||||
ClearHeaders: resp.ClearHeaders,
|
||||
|
||||
@@ -319,6 +319,9 @@ func (h *BaseAPIHandler) executeStreamWithAuthManagerFormats(ctx context.Context
|
||||
}
|
||||
providers = adjustExecutionProvidersForEntryProtocol(entryProtocol, providers)
|
||||
reqMeta := requestExecutionMetadata(ctx)
|
||||
if execOptions.Path != "" {
|
||||
reqMeta[coreexecutor.RequestPathMetadataKey] = execOptions.Path
|
||||
}
|
||||
reqMeta[coreexecutor.RequestedModelMetadataKey] = originalRequestedModel
|
||||
addAuthSelectionModelMetadata(reqMeta, execOptions.AuthSelectionModel)
|
||||
addModelExecutionSourceMetadata(reqMeta, execOptions.InternalSource)
|
||||
|
||||
@@ -25,6 +25,7 @@ type modelExecutionOptions struct {
|
||||
ForcedProvider string
|
||||
AuthSelectionModel string
|
||||
ProxyURL string
|
||||
Path string
|
||||
}
|
||||
|
||||
// ProtocolExecutionRequest describes a route-level model execution request with explicit protocols.
|
||||
@@ -39,6 +40,7 @@ type ProtocolExecutionRequest struct {
|
||||
Headers http.Header
|
||||
Query url.Values
|
||||
Alt string
|
||||
Path string
|
||||
}
|
||||
|
||||
// ModelExecutionRequest describes an internal model execution request.
|
||||
@@ -56,6 +58,7 @@ type ModelExecutionRequest struct {
|
||||
ForcedProvider string
|
||||
AuthID string
|
||||
ProxyURL string
|
||||
Path string
|
||||
}
|
||||
|
||||
// ModelExecutionResponse describes a non-streaming internal model execution response.
|
||||
@@ -111,7 +114,8 @@ func (h *BaseAPIHandler) ExecuteModel(ctx context.Context, req ModelExecutionReq
|
||||
if req.AuthID != "" {
|
||||
ctx = WithPinnedAuthID(ctx, req.AuthID)
|
||||
}
|
||||
body, headers, errMsg := h.executeWithAuthManagerFormats(ctx, req.EntryProtocol, req.ExitProtocol, req.Model, cloneBytes(req.Body), req.Alt, false, modelExecutionOptions{
|
||||
allowImageModel := isModelExecutionImageProtocol(req.EntryProtocol) || isModelExecutionImageProtocol(req.ExitProtocol)
|
||||
body, headers, errMsg := h.executeWithAuthManagerFormats(ctx, req.EntryProtocol, req.ExitProtocol, req.Model, cloneBytes(req.Body), req.Alt, allowImageModel, modelExecutionOptions{
|
||||
Headers: req.Headers,
|
||||
Query: req.Query,
|
||||
InternalSource: true,
|
||||
@@ -119,6 +123,7 @@ func (h *BaseAPIHandler) ExecuteModel(ctx context.Context, req ModelExecutionReq
|
||||
SkipRouterPluginID: req.SkipRouterPluginID,
|
||||
ForcedProvider: req.ForcedProvider,
|
||||
ProxyURL: strings.TrimSpace(req.ProxyURL),
|
||||
Path: strings.TrimSpace(req.Path),
|
||||
})
|
||||
if errMsg != nil {
|
||||
return ModelExecutionResponse{}, errMsg
|
||||
@@ -145,7 +150,8 @@ func (h *BaseAPIHandler) ExecuteModelStream(ctx context.Context, req ModelExecut
|
||||
if req.AuthID != "" {
|
||||
ctx = WithPinnedAuthID(ctx, req.AuthID)
|
||||
}
|
||||
dataChan, headers, errChan := h.executeStreamWithAuthManagerFormats(ctx, req.EntryProtocol, req.ExitProtocol, req.Model, cloneBytes(req.Body), req.Alt, false, modelExecutionOptions{
|
||||
allowImageModel := isModelExecutionImageProtocol(req.EntryProtocol) || isModelExecutionImageProtocol(req.ExitProtocol)
|
||||
dataChan, headers, errChan := h.executeStreamWithAuthManagerFormats(ctx, req.EntryProtocol, req.ExitProtocol, req.Model, cloneBytes(req.Body), req.Alt, allowImageModel, modelExecutionOptions{
|
||||
Headers: req.Headers,
|
||||
Query: req.Query,
|
||||
InternalSource: true,
|
||||
@@ -153,6 +159,7 @@ func (h *BaseAPIHandler) ExecuteModelStream(ctx context.Context, req ModelExecut
|
||||
SkipRouterPluginID: req.SkipRouterPluginID,
|
||||
ForcedProvider: req.ForcedProvider,
|
||||
ProxyURL: strings.TrimSpace(req.ProxyURL),
|
||||
Path: strings.TrimSpace(req.Path),
|
||||
})
|
||||
chunks, errMsg := prepareModelExecutionStream(ctx, dataChan, errChan)
|
||||
if errMsg != nil {
|
||||
@@ -170,11 +177,13 @@ func (h *BaseAPIHandler) ExecuteProtocolWithAuthManager(ctx context.Context, req
|
||||
if req.Stream {
|
||||
return ModelExecutionResponse{}, modelExecutionModeError("ExecuteProtocolWithAuthManager requires Stream=false")
|
||||
}
|
||||
body, headers, errMsg := h.executeWithAuthManagerFormats(ctx, req.EntryProtocol, req.ExitProtocol, req.Model, cloneBytes(req.Body), req.Alt, false, modelExecutionOptions{
|
||||
allowImageModel := isModelExecutionImageProtocol(req.EntryProtocol) || isModelExecutionImageProtocol(req.ExitProtocol)
|
||||
body, headers, errMsg := h.executeWithAuthManagerFormats(ctx, req.EntryProtocol, req.ExitProtocol, req.Model, cloneBytes(req.Body), req.Alt, allowImageModel, modelExecutionOptions{
|
||||
Headers: req.Headers,
|
||||
Query: req.Query,
|
||||
ForcedProvider: req.ForcedProvider,
|
||||
AuthSelectionModel: req.AuthSelectionModel,
|
||||
Path: strings.TrimSpace(req.Path),
|
||||
})
|
||||
if errMsg != nil {
|
||||
return ModelExecutionResponse{}, errMsg
|
||||
@@ -191,11 +200,13 @@ func (h *BaseAPIHandler) ExecuteProtocolStreamWithAuthManager(ctx context.Contex
|
||||
if !req.Stream {
|
||||
return ModelExecutionStream{}, modelExecutionModeError("ExecuteProtocolStreamWithAuthManager requires Stream=true")
|
||||
}
|
||||
dataChan, headers, errChan := h.executeStreamWithAuthManagerFormats(ctx, req.EntryProtocol, req.ExitProtocol, req.Model, cloneBytes(req.Body), req.Alt, false, modelExecutionOptions{
|
||||
allowImageModel := isModelExecutionImageProtocol(req.EntryProtocol) || isModelExecutionImageProtocol(req.ExitProtocol)
|
||||
dataChan, headers, errChan := h.executeStreamWithAuthManagerFormats(ctx, req.EntryProtocol, req.ExitProtocol, req.Model, cloneBytes(req.Body), req.Alt, allowImageModel, modelExecutionOptions{
|
||||
Headers: req.Headers,
|
||||
Query: req.Query,
|
||||
ForcedProvider: req.ForcedProvider,
|
||||
AuthSelectionModel: req.AuthSelectionModel,
|
||||
Path: strings.TrimSpace(req.Path),
|
||||
})
|
||||
chunks, errMsg := prepareModelExecutionStream(ctx, dataChan, errChan)
|
||||
if errMsg != nil {
|
||||
@@ -212,6 +223,10 @@ func modelExecutionModeError(message string) *interfaces.ErrorMessage {
|
||||
return &interfaces.ErrorMessage{StatusCode: http.StatusBadRequest, Error: errors.New(message)}
|
||||
}
|
||||
|
||||
func isModelExecutionImageProtocol(protocol string) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(protocol), "openai-image")
|
||||
}
|
||||
|
||||
func validateModelExecutionProxy(raw string) *interfaces.ErrorMessage {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
|
||||
@@ -1193,3 +1193,61 @@ func TestExecuteModelStreamReturnsFilteredHeadersWhenPassthroughDisabled(t *test
|
||||
t.Fatalf("Set-Cookie = %q, want filtered", stream.Headers.Get("Set-Cookie"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteModelAllowsImageModel_Issue6196(t *testing.T) {
|
||||
model := "gpt-image-2.5"
|
||||
requestBody := []byte(`{"prompt":"a red apple"}`)
|
||||
executor := &modelExecutionCaptureExecutor{
|
||||
provider: "openai",
|
||||
execute: func(ctx context.Context, auth *coreauth.Auth, req coreexecutor.Request, opts coreexecutor.Options) (coreexecutor.Response, error) {
|
||||
return coreexecutor.Response{
|
||||
Payload: []byte(`{"data":[{"b64_json":"test"}]}`),
|
||||
}, nil
|
||||
},
|
||||
stream: func(ctx context.Context, auth *coreauth.Auth, req coreexecutor.Request, opts coreexecutor.Options) (*coreexecutor.StreamResult, error) {
|
||||
chunks := make(chan coreexecutor.StreamChunk, 1)
|
||||
chunks <- coreexecutor.StreamChunk{Payload: []byte(`data: {"data":[{"b64_json":"test"}]}`)}
|
||||
close(chunks)
|
||||
return &coreexecutor.StreamResult{
|
||||
Chunks: chunks,
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
handler := newModelExecutionHandler(t, model, executor, &sdkconfig.SDKConfig{})
|
||||
|
||||
resp, errMsg := handler.ExecuteModel(context.Background(), ModelExecutionRequest{
|
||||
EntryProtocol: "openai-image",
|
||||
ExitProtocol: "openai-image",
|
||||
Model: model,
|
||||
Body: requestBody,
|
||||
Path: "/v1/images/generations",
|
||||
})
|
||||
if errMsg != nil {
|
||||
t.Fatalf("ExecuteModel() error = %+v, want nil", errMsg)
|
||||
}
|
||||
if string(resp.Body) != `{"data":[{"b64_json":"test"}]}` {
|
||||
t.Fatalf("ExecuteModel() body = %s, want expected body", string(resp.Body))
|
||||
}
|
||||
_, capturedOpts := executor.captured()
|
||||
if gotPath := capturedOpts.Metadata[coreexecutor.RequestPathMetadataKey]; gotPath != "/v1/images/generations" {
|
||||
t.Fatalf("captured request path = %v, want /v1/images/generations", gotPath)
|
||||
}
|
||||
|
||||
stream, errStream := handler.ExecuteModelStream(context.Background(), ModelExecutionRequest{
|
||||
EntryProtocol: "openai-image",
|
||||
ExitProtocol: "openai-image",
|
||||
Model: model,
|
||||
Stream: true,
|
||||
Body: requestBody,
|
||||
Path: "/v1/images/edits",
|
||||
})
|
||||
if errStream != nil {
|
||||
t.Fatalf("ExecuteModelStream() error = %+v, want nil", errStream)
|
||||
}
|
||||
for range stream.Chunks {
|
||||
}
|
||||
_, streamOpts := executor.captured()
|
||||
if gotPath := streamOpts.Metadata[coreexecutor.RequestPathMetadataKey]; gotPath != "/v1/images/edits" {
|
||||
t.Fatalf("captured stream request path = %v, want /v1/images/edits", gotPath)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"maps"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
@@ -347,6 +348,14 @@ func applyRequestAfterAuthInterceptor(ctx context.Context, executor ProviderExec
|
||||
req.Payload = bytes.Clone(resp.Body)
|
||||
opts.OriginalRequest = bytes.Clone(resp.Body)
|
||||
}
|
||||
if path := strings.TrimSpace(resp.Path); path != "" {
|
||||
if opts.Metadata == nil {
|
||||
opts.Metadata = make(map[string]any, 1)
|
||||
} else {
|
||||
opts.Metadata = maps.Clone(opts.Metadata)
|
||||
}
|
||||
opts.Metadata[cliproxyexecutor.RequestPathMetadataKey] = path
|
||||
}
|
||||
if resp.Terminate {
|
||||
return req, opts, &cliproxyexecutor.RequestTerminatedError{
|
||||
HTTPStatus: resp.StatusCode,
|
||||
|
||||
@@ -353,3 +353,32 @@ func TestApplyRequestAfterAuthInterceptorPreservesLCPHierarchyOnUnrelatedHeaderC
|
||||
t.Fatalf("synced context hierarchy = (%q, %q), want (lcp:v1:child-fork-123, lcp:v1:parent-trunk-000)", meta.SessionID, meta.ParentSessionID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyRequestAfterAuthInterceptor_OverridesPath_Issue6196(t *testing.T) {
|
||||
req := cliproxyexecutor.Request{
|
||||
Model: "gpt-image-2.5",
|
||||
Payload: []byte(`{"model":"gpt-image-2.5","prompt":"edit this"}`),
|
||||
}
|
||||
opts := cliproxyexecutor.Options{
|
||||
Metadata: map[string]any{
|
||||
cliproxyexecutor.RequestPathMetadataKey: "/v1/images/edits",
|
||||
},
|
||||
RequestAfterAuthInterceptor: func(ctx context.Context, req cliproxyexecutor.RequestAfterAuthInterceptRequest) cliproxyexecutor.RequestAfterAuthInterceptResponse {
|
||||
return cliproxyexecutor.RequestAfterAuthInterceptResponse{
|
||||
Path: "/v1/images/generations",
|
||||
Body: []byte(`{"model":"gpt-image-2.5","prompt":"rewritten for generations"}`),
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
finalReq, finalOpts, err := applyRequestAfterAuthInterceptor(context.Background(), nil, "openai-compatibility", req, opts, "gpt-image-2.5")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if string(finalReq.Payload) != `{"model":"gpt-image-2.5","prompt":"rewritten for generations"}` {
|
||||
t.Fatalf("final payload = %s, want rewritten", string(finalReq.Payload))
|
||||
}
|
||||
if gotPath := finalOpts.Metadata[cliproxyexecutor.RequestPathMetadataKey]; gotPath != "/v1/images/generations" {
|
||||
t.Fatalf("final request path = %v, want /v1/images/generations", gotPath)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,6 +123,8 @@ type RequestAfterAuthInterceptRequest struct {
|
||||
|
||||
// RequestAfterAuthInterceptResponse returns selected-auth request modifications.
|
||||
type RequestAfterAuthInterceptResponse struct {
|
||||
// Path optionally overrides the inbound request path in Options.Metadata[RequestPathMetadataKey].
|
||||
Path string
|
||||
// Headers replaces matching current request headers and preserves headers not mentioned here.
|
||||
Headers http.Header
|
||||
// Body replaces the current request body only when non-empty.
|
||||
|
||||
@@ -693,6 +693,8 @@ type HostModelExecutionRequest struct {
|
||||
// ProxyURL optionally overrides the outbound proxy for this model execution only.
|
||||
// Supported schemes are http, https, socks5, and socks5h.
|
||||
ProxyURL string `json:"proxy_url,omitempty"`
|
||||
// Path optionally specifies or overrides the request path (e.g. "/v1/images/generations" or "/v1/images/edits").
|
||||
Path string `json:"path,omitempty"`
|
||||
}
|
||||
|
||||
// HostModelExecutionResponse describes a non-streaming host model execution response.
|
||||
@@ -1137,6 +1139,8 @@ type RequestInterceptRequest struct {
|
||||
|
||||
// RequestInterceptResponse returns request modifications.
|
||||
type RequestInterceptResponse struct {
|
||||
// Path optionally overrides the target request path (e.g. "/v1/images/generations").
|
||||
Path string `json:"path,omitempty"`
|
||||
// Headers replaces matching current request headers and preserves headers not mentioned here.
|
||||
Headers http.Header
|
||||
// Body replaces the current request body only when non-empty.
|
||||
|
||||
Reference in New Issue
Block a user