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:
Luis Pater
2026-09-29 22:44:33 +08:00
parent fbc8e443f0
commit b7f321f2a1
13 changed files with 311 additions and 4 deletions

View File

@@ -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

View File

@@ -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

View File

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

View File

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

View File

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

View File

@@ -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,

View File

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

View File

@@ -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 == "" {

View File

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

View File

@@ -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,

View File

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

View File

@@ -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.

View File

@@ -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.