diff --git a/internal/pluginhost/adapters_interceptors.go b/internal/pluginhost/adapters_interceptors.go index c764e6ed4..055d5254d 100644 --- a/internal/pluginhost/adapters_interceptors.go +++ b/internal/pluginhost/adapters_interceptors.go @@ -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 diff --git a/internal/pluginhost/adapters_test.go b/internal/pluginhost/adapters_test.go index 629c69665..0b3b95c98 100644 --- a/internal/pluginhost/adapters_test.go +++ b/internal/pluginhost/adapters_test.go @@ -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 diff --git a/internal/pluginhost/host_callbacks.go b/internal/pluginhost/host_callbacks.go index e04ddaeb6..88b8c92b7 100644 --- a/internal/pluginhost/host_callbacks.go +++ b/internal/pluginhost/host_callbacks.go @@ -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), } } diff --git a/internal/runtime/executor/openai_compat_executor_images_test.go b/internal/runtime/executor/openai_compat_executor_images_test.go index c2d87fa92..9fbb4730f 100644 --- a/internal/runtime/executor/openai_compat_executor_images_test.go +++ b/internal/runtime/executor/openai_compat_executor_images_test.go @@ -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) + } +} diff --git a/sdk/api/handlers/handlers_execution.go b/sdk/api/handlers/handlers_execution.go index 225cbe659..75eab384c 100644 --- a/sdk/api/handlers/handlers_execution.go +++ b/sdk/api/handlers/handlers_execution.go @@ -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) } diff --git a/sdk/api/handlers/handlers_interceptors.go b/sdk/api/handlers/handlers_interceptors.go index ea8adb41a..a8acb2a95 100644 --- a/sdk/api/handlers/handlers_interceptors.go +++ b/sdk/api/handlers/handlers_interceptors.go @@ -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, diff --git a/sdk/api/handlers/handlers_stream.go b/sdk/api/handlers/handlers_stream.go index 935b00c8b..5ac61ec93 100644 --- a/sdk/api/handlers/handlers_stream.go +++ b/sdk/api/handlers/handlers_stream.go @@ -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) diff --git a/sdk/api/handlers/model_execution.go b/sdk/api/handlers/model_execution.go index 973285ff8..daf1f53e6 100644 --- a/sdk/api/handlers/model_execution.go +++ b/sdk/api/handlers/model_execution.go @@ -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 == "" { diff --git a/sdk/api/handlers/model_execution_test.go b/sdk/api/handlers/model_execution_test.go index c9015028a..bf8dbe2dd 100644 --- a/sdk/api/handlers/model_execution_test.go +++ b/sdk/api/handlers/model_execution_test.go @@ -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) + } +} diff --git a/sdk/cliproxy/auth/conductor_execution.go b/sdk/cliproxy/auth/conductor_execution.go index d84071d48..9616a8217 100644 --- a/sdk/cliproxy/auth/conductor_execution.go +++ b/sdk/cliproxy/auth/conductor_execution.go @@ -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, diff --git a/sdk/cliproxy/auth/conductor_execution_quota_test.go b/sdk/cliproxy/auth/conductor_execution_quota_test.go index 6d6157239..879fff4d9 100644 --- a/sdk/cliproxy/auth/conductor_execution_quota_test.go +++ b/sdk/cliproxy/auth/conductor_execution_quota_test.go @@ -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) + } +} diff --git a/sdk/cliproxy/executor/types.go b/sdk/cliproxy/executor/types.go index 7a0911710..d178957df 100644 --- a/sdk/cliproxy/executor/types.go +++ b/sdk/cliproxy/executor/types.go @@ -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. diff --git a/sdk/pluginapi/types.go b/sdk/pluginapi/types.go index dbf32350a..3d2e61976 100644 --- a/sdk/pluginapi/types.go +++ b/sdk/pluginapi/types.go @@ -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.