diff --git a/internal/app/app.go b/internal/app/app.go index 25d40772..d17025c7 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -298,7 +298,7 @@ func New(ctx context.Context, cfg Config) (*App, error) { refreshInterval := workflowRefreshInterval(appCfg) var guardrailExecutor guardrails.ChatCompletionExecutor = app.providers.Router if vm != nil { - guardrailExecutor = virtualmodels.NewProviderWithOptions(app.providers.Router, vm, virtualmodels.Options{}) + guardrailExecutor = virtualmodels.NewChatExecutor(app.providers.Router, vm) } // Initialize reusable guardrail definitions using shared storage when already available. diff --git a/internal/server/handlers_test.go b/internal/server/handlers_test.go index c543b140..931f136d 100644 --- a/internal/server/handlers_test.go +++ b/internal/server/handlers_test.go @@ -1526,124 +1526,6 @@ func TestChatCompletion_NormalizesSemanticSelectorHints(t *testing.T) { } } -func TestChatCompletion_ResolvesQualifiedMaskingAliasBeforeHandlerRouting(t *testing.T) { - catalog := aliasesTestCatalog{ - supported: map[string]bool{ - "anthropic/claude-opus-4-6": true, - "openai/gpt-5-nano": true, - }, - providerTypes: map[string]string{ - "anthropic/claude-opus-4-6": "anthropic", - "openai/gpt-5-nano": "openai", - }, - models: map[string]core.Model{ - "anthropic/claude-opus-4-6": {ID: "claude-opus-4-6", Object: "model"}, - "openai/gpt-5-nano": {ID: "gpt-5-nano", Object: "model"}, - }, - } - - service, err := virtualmodels.NewService(newAliasesTestStore( - redirectVM("anthropic/claude-opus-4-6", "gpt-5-nano", "openai", true), - ), &catalog, true) - if err != nil { - t.Fatalf("NewService() error = %v", err) - } - if err := service.Refresh(context.Background()); err != nil { - t.Fatalf("Refresh() error = %v", err) - } - - inner := &capturingProvider{ - mockProvider: mockProvider{ - supportedModels: []string{"gpt-5-nano"}, - providerTypes: map[string]string{ - "openai/gpt-5-nano": "openai", - }, - response: &core.ChatResponse{ - ID: "chatcmpl_alias_123", - Object: "chat.completion", - Model: "gpt-5-nano", - Provider: "openai", - Choices: []core.Choice{ - { - Index: 0, - FinishReason: "stop", - Message: core.ResponseMessage{ - Role: "assistant", - Content: "ok", - }, - }, - }, - }, - }, - } - - provider := virtualmodels.NewProviderWithOptions(inner, service, virtualmodels.Options{}) - - e := echo.New() - handler := NewHandler(provider, nil, nil, nil) - - req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil) - req.Header.Set("Content-Type", "application/json") - req.Body = &explodingReadCloser{} - - frame := core.NewRequestSnapshot( - http.MethodPost, - "/v1/chat/completions", - nil, - nil, - nil, - "application/json", - []byte(`{ - "model":"anthropic/claude-opus-4-6", - "messages":[{"role":"user","content":"return json"}] - }`), - false, - "", - nil, - ) - req = withRequestSnapshotAndPrompt(req, frame) - - rec := httptest.NewRecorder() - c := e.NewContext(req, rec) - - err = handler.ChatCompletion(c) - if err != nil { - t.Fatalf("handler returned error: %v", err) - } - if rec.Code != http.StatusOK { - t.Fatalf("expected status 200, got %d (%s)", rec.Code, rec.Body.String()) - } - if inner.capturedChatReq == nil { - t.Fatal("expected chat request to be captured") - } - if inner.capturedChatReq.Model != "gpt-5-nano" { - t.Fatalf("captured model = %q, want gpt-5-nano", inner.capturedChatReq.Model) - } - if inner.capturedChatReq.Provider != "openai" { - t.Fatalf("captured provider = %q, want openai", inner.capturedChatReq.Provider) - } - - workflow := core.GetWorkflow(c.Request().Context()) - if workflow == nil { - t.Fatal("expected workflow in context") - } - if workflow.Mode != core.ExecutionModeTranslated { - t.Fatalf("workflow mode = %q, want %q", workflow.Mode, core.ExecutionModeTranslated) - } - if workflow.ProviderType != "openai" { - t.Fatalf("workflow provider type = %q, want openai", workflow.ProviderType) - } - if workflow.Resolution == nil { - t.Fatal("expected workflow resolution") - } - if !workflow.Resolution.AliasApplied { - t.Fatal("expected alias resolution to be marked as applied") - } - if workflow.ResolvedQualifiedModel() != "openai/gpt-5-nano" { - t.Fatalf("workflow resolved model = %q, want openai/gpt-5-nano", workflow.ResolvedQualifiedModel()) - } -} - func TestChatCompletion_UsesExplicitAliasResolverWithoutProviderDecorator(t *testing.T) { catalog := aliasesTestCatalog{ supported: map[string]bool{ @@ -1751,106 +1633,6 @@ func TestChatCompletion_UsesExplicitAliasResolverWithoutProviderDecorator(t *tes } } -func TestChatCompletion_UsesExplicitAliasResolverWithAliasProviderInventoryOnly(t *testing.T) { - catalog := aliasesTestCatalog{ - supported: map[string]bool{ - "anthropic/claude-opus-4-6": true, - "openai/gpt-5-nano": true, - }, - providerTypes: map[string]string{ - "anthropic/claude-opus-4-6": "anthropic", - "openai/gpt-5-nano": "openai", - }, - models: map[string]core.Model{ - "anthropic/claude-opus-4-6": {ID: "claude-opus-4-6", Object: "model"}, - "openai/gpt-5-nano": {ID: "gpt-5-nano", Object: "model"}, - }, - } - - service, err := virtualmodels.NewService(newAliasesTestStore( - redirectVM("anthropic/claude-opus-4-6", "gpt-5-nano", "openai", true), - ), &catalog, true) - if err != nil { - t.Fatalf("NewService() error = %v", err) - } - if err := service.Refresh(context.Background()); err != nil { - t.Fatalf("Refresh() error = %v", err) - } - - inner := &capturingProvider{ - mockProvider: mockProvider{ - supportedModels: []string{"gpt-5-nano"}, - providerTypes: map[string]string{ - "openai/gpt-5-nano": "openai", - }, - response: &core.ChatResponse{ - ID: "chatcmpl_alias_inventory_123", - Object: "chat.completion", - Model: "gpt-5-nano", - Provider: "openai", - Choices: []core.Choice{ - { - Index: 0, - FinishReason: "stop", - Message: core.ResponseMessage{ - Role: "assistant", - Content: "ok", - }, - }, - }, - }, - }, - } - provider := virtualmodels.NewProviderWithOptions(inner, service, virtualmodels.Options{ - DisableTranslatedRequestProcessing: true, - DisableNativeBatchPreparation: true, - }) - - e := echo.New() - handler := newHandler(provider, nil, nil, nil, service, nil, nil, nil) - - req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil) - req.Header.Set("Content-Type", "application/json") - req.Body = &explodingReadCloser{} - - frame := core.NewRequestSnapshot( - http.MethodPost, - "/v1/chat/completions", - nil, - nil, - nil, - "application/json", - []byte(`{ - "model":"anthropic/claude-opus-4-6", - "messages":[{"role":"user","content":"return json"}] - }`), - false, - "", - nil, - ) - req = withRequestSnapshotAndPrompt(req, frame) - - rec := httptest.NewRecorder() - c := e.NewContext(req, rec) - - err = handler.ChatCompletion(c) - if err != nil { - t.Fatalf("handler returned error: %v", err) - } - if rec.Code != http.StatusOK { - t.Fatalf("expected status 200, got %d (%s)", rec.Code, rec.Body.String()) - } - if inner.capturedChatReq == nil { - t.Fatal("expected chat request to be captured") - } - if inner.capturedChatReq.Model != "gpt-5-nano" { - t.Fatalf("captured model = %q, want gpt-5-nano", inner.capturedChatReq.Model) - } - if inner.capturedChatReq.Provider != "openai" { - t.Fatalf("captured provider = %q, want openai", inner.capturedChatReq.Provider) - } -} - func TestChatCompletion_UsesExplicitTranslatedRequestPatcher(t *testing.T) { pipeline := guardrails.NewPipeline() systemPrompt, err := guardrails.NewSystemPromptGuardrail("test", guardrails.SystemPromptInject, "guardrail system") @@ -4311,8 +4093,9 @@ func TestBatches_InputFileRewritesAliasesAndPersistsBatchPreparation(t *testing. }, } - provider := virtualmodels.NewProvider(inner, service) - handler := NewHandler(provider, nil, nil, nil) + handler := NewHandler(inner, nil, nil, nil) + handler.modelResolver = service + handler.batchRequestPreparer = virtualmodels.NewBatchPreparer(inner, service) batchStore := batchstore.NewMemoryStore() handler.SetBatchStore(batchStore) @@ -4479,8 +4262,9 @@ func TestBatches_InputFileRejectsDisabledAlias(t *testing.T) { }, } - provider := virtualmodels.NewProvider(inner, service) - handler := NewHandler(provider, nil, nil, nil) + handler := NewHandler(inner, nil, nil, nil) + handler.modelResolver = service + handler.batchRequestPreparer = virtualmodels.NewBatchPreparer(inner, service) e := echo.New() req := httptest.NewRequest(http.MethodPost, "/v1/batches", strings.NewReader(`{ @@ -6444,9 +6228,9 @@ func TestGetFileWithoutProviderUsesProviderInventoryWhenAliasMasksModel(t *testi }, } - provider := virtualmodels.NewProvider(mock, service) e := echo.New() - handler := NewHandler(provider, nil, nil, nil) + handler := NewHandler(mock, nil, nil, nil) + handler.modelResolver = service req := httptest.NewRequest(http.MethodGet, "/v1/files/file_ok_1", nil) rec := httptest.NewRecorder() diff --git a/internal/server/model_validation_test.go b/internal/server/model_validation_test.go index 5ffade32..e66f1295 100644 --- a/internal/server/model_validation_test.go +++ b/internal/server/model_validation_test.go @@ -736,12 +736,10 @@ func TestModelValidation_EnrichesAuditEntryWithRequestedModelOnResolutionError(t "openai/gpt-4o": "openai", }, } - provider := virtualmodels.NewProvider(inner, service) - e := echo.New() handlerCalled := false - middleware := WorkflowResolution(provider) + middleware := WorkflowResolutionWithResolver(inner, service) handler := middleware(func(c *echo.Context) error { handlerCalled = true return c.String(http.StatusOK, "ok") @@ -981,72 +979,6 @@ func BenchmarkSelectorHintsFromJSONGJSON(b *testing.B) { } } -func TestModelValidation_ResolvesQualifiedMaskingAliasBeforeProviderParsing(t *testing.T) { - catalog := aliasesTestCatalog{ - supported: map[string]bool{ - "anthropic/claude-opus-4-6": true, - "openai/gpt-5-nano": true, - }, - providerTypes: map[string]string{ - "anthropic/claude-opus-4-6": "anthropic", - "openai/gpt-5-nano": "openai", - }, - models: map[string]core.Model{ - "anthropic/claude-opus-4-6": {ID: "claude-opus-4-6", Object: "model"}, - "openai/gpt-5-nano": {ID: "gpt-5-nano", Object: "model"}, - }, - } - - service, err := virtualmodels.NewService(newAliasesTestStore( - redirectVM("anthropic/claude-opus-4-6", "gpt-5-nano", "openai", true), - ), &catalog, true) - if err != nil { - t.Fatalf("NewService() error = %v", err) - } - if err := service.Refresh(context.Background()); err != nil { - t.Fatalf("Refresh() error = %v", err) - } - - inner := &mockProvider{ - supportedModels: []string{"claude-opus-4-6", "gpt-5-nano"}, - providerTypes: map[string]string{ - "anthropic/claude-opus-4-6": "anthropic", - "openai/gpt-5-nano": "openai", - }, - } - provider := virtualmodels.NewProvider(inner, service) - - e := echo.New() - var ( - capturedProviderType string - capturedWorkflow *core.Workflow - ) - - middleware := WorkflowResolution(provider) - handler := middleware(func(c *echo.Context) error { - capturedProviderType = GetProviderType(c) - capturedWorkflow = core.GetWorkflow(c.Request().Context()) - return c.String(http.StatusOK, "ok") - }) - - req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", - strings.NewReader(`{"model":"anthropic/claude-opus-4-6","messages":[{"role":"user","content":"hi"}]}`)) - req.Header.Set("Content-Type", "application/json") - rec := httptest.NewRecorder() - c := e.NewContext(req, rec) - - err = handler(c) - require.NoError(t, err) - - assert.Equal(t, http.StatusOK, rec.Code) - assert.Equal(t, "openai", capturedProviderType) - if assert.NotNil(t, capturedWorkflow) && assert.NotNil(t, capturedWorkflow.Resolution) { - assert.True(t, capturedWorkflow.Resolution.AliasApplied) - assert.Equal(t, "anthropic/claude-opus-4-6", capturedWorkflow.RequestedQualifiedModel()) - assert.Equal(t, "openai/gpt-5-nano", capturedWorkflow.ResolvedQualifiedModel()) - } -} - func TestWorkflowResolutionWithResolver_UsesExplicitAliasResolverWithoutProviderDecorator(t *testing.T) { catalog := aliasesTestCatalog{ supported: map[string]bool{ diff --git a/internal/virtualmodels/provider.go b/internal/virtualmodels/provider.go deleted file mode 100644 index 901a3c2f..00000000 --- a/internal/virtualmodels/provider.go +++ /dev/null @@ -1,572 +0,0 @@ -package virtualmodels - -import ( - "context" - "fmt" - "io" - "sort" - "strings" - - "gomodel/internal/batchrewrite" - "gomodel/internal/core" -) - -// Provider wraps a routable provider and resolves redirects before delegating. -type Provider struct { - inner core.RoutableProvider - service *Service - options Options -} - -// Options controls optional behavior of Provider. -type Options struct { - // DisableTranslatedRequestProcessing lets explicit workflow resolution own - // translated-route selector resolution while this wrapper still exposes - // redirect inventory and batch preparation. - DisableTranslatedRequestProcessing bool - // DisableNativeBatchPreparation lets an explicit server-side batch - // preparer own redirect rewriting for native batch requests. - DisableNativeBatchPreparation bool -} - -// NewProvider creates a redirect-aware provider wrapper that exposes redirect -// inventory and native batch preparation without owning translated-route model -// resolution by default. -func NewProvider(inner core.RoutableProvider, service *Service) *Provider { - return NewProviderWithOptions(inner, service, Options{ - DisableTranslatedRequestProcessing: true, - }) -} - -// NewProviderWithOptions creates a redirect-aware provider wrapper with explicit options. -func NewProviderWithOptions(inner core.RoutableProvider, service *Service, options Options) *Provider { - return &Provider{inner: inner, service: service, options: options} -} - -// ResolveModel resolves a requested selector through the redirect table. -func (p *Provider) ResolveModel(requested core.RequestedModelSelector) (core.ModelSelector, bool, error) { - return resolveRedirectModel(p.service, requested) -} - -func (p *Provider) ChatCompletion(ctx context.Context, req *core.ChatRequest) (*core.ChatResponse, error) { - if p.options.DisableTranslatedRequestProcessing { - return p.inner.ChatCompletion(ctx, req) - } - forward, err := rewriteChatRequest(ctx, p.service, p.inner, req) - if err != nil { - return nil, err - } - return p.inner.ChatCompletion(ctx, forward) -} - -func (p *Provider) StreamChatCompletion(ctx context.Context, req *core.ChatRequest) (io.ReadCloser, error) { - if p.options.DisableTranslatedRequestProcessing { - return p.inner.StreamChatCompletion(ctx, req) - } - forward, err := rewriteChatRequest(ctx, p.service, p.inner, req) - if err != nil { - return nil, err - } - return p.inner.StreamChatCompletion(ctx, forward) -} - -func (p *Provider) ListModels(ctx context.Context) (*core.ModelsResponse, error) { - resp, err := p.inner.ListModels(ctx) - if err != nil { - return nil, err - } - if resp == nil { - resp = &core.ModelsResponse{Object: "list", Data: []core.Model{}} - } - if p.service == nil { - return resp, nil - } - - dataByID := make(map[string]core.Model, len(resp.Data)) - for _, model := range resp.Data { - dataByID[model.ID] = model - } - // Scope redirects by the caller's user_path so a user_paths-scoped alias is not - // listed to callers outside its scope (parity with the server /v1/models path). - for _, exposed := range p.service.ExposedModelsForUserPath(core.UserPathFromContext(ctx), nil) { - dataByID[exposed.ID] = exposed - } - data := make([]core.Model, 0, len(dataByID)) - for _, model := range dataByID { - data = append(data, model) - } - sort.Slice(data, func(i, j int) bool { return data[i].ID < data[j].ID }) - - cloned := *resp - cloned.Data = data - return &cloned, nil -} - -func (p *Provider) Responses(ctx context.Context, req *core.ResponsesRequest) (*core.ResponsesResponse, error) { - if p.options.DisableTranslatedRequestProcessing { - return p.inner.Responses(ctx, req) - } - forward, err := rewriteResponsesRequest(ctx, p.service, p.inner, req) - if err != nil { - return nil, err - } - return p.inner.Responses(ctx, forward) -} - -func (p *Provider) StreamResponses(ctx context.Context, req *core.ResponsesRequest) (io.ReadCloser, error) { - if p.options.DisableTranslatedRequestProcessing { - return p.inner.StreamResponses(ctx, req) - } - forward, err := rewriteResponsesRequest(ctx, p.service, p.inner, req) - if err != nil { - return nil, err - } - return p.inner.StreamResponses(ctx, forward) -} - -func (p *Provider) Embeddings(ctx context.Context, req *core.EmbeddingRequest) (*core.EmbeddingResponse, error) { - if p.options.DisableTranslatedRequestProcessing { - return p.inner.Embeddings(ctx, req) - } - forward, err := rewriteEmbeddingRequest(ctx, p.service, p.inner, req) - if err != nil { - return nil, err - } - return p.inner.Embeddings(ctx, forward) -} - -func (p *Provider) Supports(model string) bool { - if p.service != nil && p.service.Supports(model) { - return true - } - return p.inner.Supports(model) -} - -func (p *Provider) GetProviderType(model string) string { - if p.service != nil { - if providerType := p.service.GetProviderType(model); providerType != "" { - return providerType - } - } - return p.inner.GetProviderType(model) -} - -func (p *Provider) GetProviderName(model string) string { - if named, ok := p.inner.(core.ProviderNameResolver); ok { - return strings.TrimSpace(named.GetProviderName(model)) - } - return "" -} - -func (p *Provider) ModelCount() int { - if counted, ok := p.inner.(interface{ ModelCount() int }); ok { - return counted.ModelCount() - } - return -1 -} - -// NativeFileProviderTypes delegates provider capability inventory to the inner -// provider when available. -func (p *Provider) NativeFileProviderTypes() []string { - if typed, ok := p.inner.(core.NativeFileProviderTypeLister); ok { - return typed.NativeFileProviderTypes() - } - return nil -} - -// NativeBatchProviderTypes delegates provider capability inventory to the inner -// provider when available. -func (p *Provider) NativeBatchProviderTypes() []string { - if typed, ok := p.inner.(core.NativeBatchProviderTypeLister); ok { - return typed.NativeBatchProviderTypes() - } - return nil -} - -// NativeResponseProviderTypes delegates provider capability inventory to the -// inner provider when available. -func (p *Provider) NativeResponseProviderTypes() []string { - if typed, ok := p.inner.(core.NativeResponseProviderTypeLister); ok { - return typed.NativeResponseProviderTypes() - } - return nil -} - -func (p *Provider) CreateBatch(ctx context.Context, providerType string, req *core.BatchRequest) (*core.BatchResponse, error) { - native, err := p.nativeBatchRouter() - if err != nil { - return nil, err - } - if p.options.DisableNativeBatchPreparation { - return native.CreateBatch(ctx, providerType, req) - } - result, err := rewriteBatchSource(ctx, providerType, req, p.service, p.inner, p.batchFileTransport(), nil) - if err != nil { - return nil, err - } - batchrewrite.RecordPreparation(ctx, req, result.Request) - resp, err := native.CreateBatch(ctx, providerType, result.Request) - if err != nil { - batchrewrite.CleanupFileFromRouter(ctx, p.nativeFileRouter, providerType, result.RewrittenInputFileID, "") - return nil, err - } - batchrewrite.CleanupSupersededFileFromRouter(ctx, p.nativeFileRouter, providerType, result.RewrittenInputFileID, "") - return resp, nil -} - -func (p *Provider) GetBatch(ctx context.Context, providerType, id string) (*core.BatchResponse, error) { - native, err := p.nativeBatchRouter() - if err != nil { - return nil, err - } - return native.GetBatch(ctx, providerType, id) -} - -func (p *Provider) ListBatches(ctx context.Context, providerType string, limit int, after string) (*core.BatchListResponse, error) { - native, err := p.nativeBatchRouter() - if err != nil { - return nil, err - } - return native.ListBatches(ctx, providerType, limit, after) -} - -func (p *Provider) CancelBatch(ctx context.Context, providerType, id string) (*core.BatchResponse, error) { - native, err := p.nativeBatchRouter() - if err != nil { - return nil, err - } - return native.CancelBatch(ctx, providerType, id) -} - -func (p *Provider) GetBatchResults(ctx context.Context, providerType, id string) (*core.BatchResultsResponse, error) { - native, err := p.nativeBatchRouter() - if err != nil { - return nil, err - } - return native.GetBatchResults(ctx, providerType, id) -} - -func (p *Provider) CreateBatchWithHints(ctx context.Context, providerType string, req *core.BatchRequest) (*core.BatchResponse, map[string]string, error) { - hinted, err := p.nativeBatchHintRouter() - if err != nil { - return nil, nil, err - } - if p.options.DisableNativeBatchPreparation { - return hinted.CreateBatchWithHints(ctx, providerType, req) - } - result, err := rewriteBatchSource(ctx, providerType, req, p.service, p.inner, p.batchFileTransport(), nil) - if err != nil { - return nil, nil, err - } - batchrewrite.RecordPreparation(ctx, req, result.Request) - resp, hints, err := hinted.CreateBatchWithHints(ctx, providerType, result.Request) - if err != nil { - batchrewrite.CleanupFileFromRouter(ctx, p.nativeFileRouter, providerType, result.RewrittenInputFileID, "") - return nil, nil, err - } - batchrewrite.CleanupSupersededFileFromRouter(ctx, p.nativeFileRouter, providerType, result.RewrittenInputFileID, "") - return resp, batchrewrite.MergeEndpointHints(result.RequestEndpointHints, hints), nil -} - -func (p *Provider) GetBatchResultsWithHints(ctx context.Context, providerType, id string, endpointByCustomID map[string]string) (*core.BatchResultsResponse, error) { - hinted, err := p.nativeBatchHintRouter() - if err != nil { - return nil, err - } - return hinted.GetBatchResultsWithHints(ctx, providerType, id, endpointByCustomID) -} - -func (p *Provider) ClearBatchResultHints(providerType, batchID string) { - hinted, err := p.nativeBatchHintRouter() - if err != nil { - return - } - hinted.ClearBatchResultHints(providerType, batchID) -} - -func (p *Provider) CreateFile(ctx context.Context, providerType string, req *core.FileCreateRequest) (*core.FileObject, error) { - files, err := p.nativeFileRouter() - if err != nil { - return nil, err - } - return files.CreateFile(ctx, providerType, req) -} - -func (p *Provider) ListFiles(ctx context.Context, providerType, purpose string, limit int, after string) (*core.FileListResponse, error) { - files, err := p.nativeFileRouter() - if err != nil { - return nil, err - } - return files.ListFiles(ctx, providerType, purpose, limit, after) -} - -func (p *Provider) GetFile(ctx context.Context, providerType, id string) (*core.FileObject, error) { - files, err := p.nativeFileRouter() - if err != nil { - return nil, err - } - return files.GetFile(ctx, providerType, id) -} - -func (p *Provider) DeleteFile(ctx context.Context, providerType, id string) (*core.FileDeleteResponse, error) { - files, err := p.nativeFileRouter() - if err != nil { - return nil, err - } - return files.DeleteFile(ctx, providerType, id) -} - -func (p *Provider) GetFileContent(ctx context.Context, providerType, id string) (*core.FileContentResponse, error) { - files, err := p.nativeFileRouter() - if err != nil { - return nil, err - } - return files.GetFileContent(ctx, providerType, id) -} - -func (p *Provider) Passthrough(ctx context.Context, providerType string, req *core.PassthroughRequest) (*core.PassthroughResponse, error) { - passthrough, err := p.passthroughRouter() - if err != nil { - return nil, err - } - return passthrough.Passthrough(ctx, providerType, req) -} - -func (p *Provider) GetResponse(ctx context.Context, providerType, id string, params core.ResponseRetrieveParams) (*core.ResponsesResponse, error) { - responses, err := p.nativeResponseLifecycleRouter() - if err != nil { - return nil, err - } - return responses.GetResponse(ctx, providerType, id, params) -} - -func (p *Provider) ListResponseInputItems(ctx context.Context, providerType, id string, params core.ResponseInputItemsParams) (*core.ResponseInputItemListResponse, error) { - responses, err := p.nativeResponseLifecycleRouter() - if err != nil { - return nil, err - } - return responses.ListResponseInputItems(ctx, providerType, id, params) -} - -func (p *Provider) CancelResponse(ctx context.Context, providerType, id string) (*core.ResponsesResponse, error) { - responses, err := p.nativeResponseLifecycleRouter() - if err != nil { - return nil, err - } - return responses.CancelResponse(ctx, providerType, id) -} - -func (p *Provider) DeleteResponse(ctx context.Context, providerType, id string) (*core.ResponseDeleteResponse, error) { - responses, err := p.nativeResponseLifecycleRouter() - if err != nil { - return nil, err - } - return responses.DeleteResponse(ctx, providerType, id) -} - -func (p *Provider) CountResponseInputTokens(ctx context.Context, providerType string, req *core.ResponsesRequest) (*core.ResponseInputTokensResponse, error) { - responses, err := p.nativeResponseUtilityRouter() - if err != nil { - return nil, err - } - return responses.CountResponseInputTokens(ctx, providerType, req) -} - -func (p *Provider) CompactResponse(ctx context.Context, providerType string, req *core.ResponsesRequest) (*core.ResponseCompactResponse, error) { - responses, err := p.nativeResponseUtilityRouter() - if err != nil { - return nil, err - } - return responses.CompactResponse(ctx, providerType, req) -} - -// PrepareBatchRequest resolves redirects for batch subrequests without -// submitting the native batch to the wrapped provider. -func (p *Provider) PrepareBatchRequest(ctx context.Context, providerType string, req *core.BatchRequest) (*core.BatchRewriteResult, error) { - if p.options.DisableNativeBatchPreparation { - return &core.BatchRewriteResult{Request: req}, nil - } - return rewriteBatchSource(ctx, providerType, req, p.service, p.inner, p.batchFileTransport(), nil) -} - -func (p *Provider) nativeBatchRouter() (core.NativeBatchRoutableProvider, error) { - native, ok := p.inner.(core.NativeBatchRoutableProvider) - if !ok { - return nil, core.NewInvalidRequestError("batch routing is not supported by the current provider router", nil) - } - return native, nil -} - -func (p *Provider) nativeBatchHintRouter() (core.NativeBatchHintRoutableProvider, error) { - hinted, ok := p.inner.(core.NativeBatchHintRoutableProvider) - if !ok { - return nil, core.NewInvalidRequestError("batch hint routing is not supported by the current provider router", nil) - } - return hinted, nil -} - -func (p *Provider) nativeFileRouter() (core.NativeFileRoutableProvider, error) { - files, ok := p.inner.(core.NativeFileRoutableProvider) - if !ok { - return nil, core.NewInvalidRequestError("file routing is not supported by the current provider router", nil) - } - return files, nil -} - -func (p *Provider) nativeResponseLifecycleRouter() (core.NativeResponseLifecycleRoutableProvider, error) { - responses, ok := p.inner.(core.NativeResponseLifecycleRoutableProvider) - if !ok { - return nil, core.NewInvalidRequestError("response lifecycle routing is not supported by the current provider router", nil) - } - return responses, nil -} - -func (p *Provider) nativeResponseUtilityRouter() (core.NativeResponseUtilityRoutableProvider, error) { - responses, ok := p.inner.(core.NativeResponseUtilityRoutableProvider) - if !ok { - return nil, core.NewInvalidRequestError("response utility routing is not supported by the current provider router", nil) - } - return responses, nil -} - -func (p *Provider) batchFileTransport() core.BatchFileTransport { - files, err := p.nativeFileRouter() - if err != nil { - return nil - } - return files -} - -func (p *Provider) passthroughRouter() (core.RoutablePassthrough, error) { - passthrough, ok := p.inner.(core.RoutablePassthrough) - if !ok { - return nil, core.NewInvalidRequestError("passthrough routing is not supported by the current provider router", nil) - } - return passthrough, nil -} - -// --- shared redirect-rewrite helpers (used by Provider and BatchPreparer) --- - -type modelSupportChecker interface { - Supports(string) bool -} - -type modelProviderTypeChecker interface { - modelSupportChecker - GetProviderType(string) string -} - -func resolveRedirectModel(service *Service, requested core.RequestedModelSelector) (core.ModelSelector, bool, error) { - if service == nil { - selector, err := requested.Normalize() - return selector, false, err - } - return service.ResolveModel(requested) -} - -// resolveRedirectRequestSelector resolves a request-time selector through the -// redirect table honoring the caller's user path, so a user_paths-scoped redirect -// is not applied for callers outside its scope (it falls through to the literal -// name). Unscoped resolveRedirectModel is kept only for inventory/projection. -func resolveRedirectRequestSelector(ctx context.Context, service *Service, requested core.RequestedModelSelector) (core.ModelSelector, error) { - if service == nil { - return requested.Normalize() - } - selector, changed, err := service.ResolveModelForUserPath(ctx, requested) - if err != nil { - return core.ModelSelector{}, err - } - if changed { - return selector, nil - } - return requested.Normalize() -} - -func resolveRedirectRoutableSelector(ctx context.Context, service *Service, checker modelSupportChecker, requested core.RequestedModelSelector, expectedProviderType string) (core.ModelSelector, error) { - selector, err := resolveRedirectRequestSelector(ctx, service, requested) - if err != nil { - return core.ModelSelector{}, err - } - - resolvedModel := strings.TrimSpace(selector.QualifiedModel()) - if resolvedModel == "" { - return core.ModelSelector{}, core.NewInvalidRequestError("model is required", nil) - } - if checker == nil || !checker.Supports(resolvedModel) { - return core.ModelSelector{}, core.NewModelNotFoundError(resolvedModel) - } - if err := validateResolvedProviderType(checker, selector, expectedProviderType); err != nil { - return core.ModelSelector{}, err - } - return selector, nil -} - -func validateResolvedProviderType(checker modelSupportChecker, selector core.ModelSelector, expectedProviderType string) error { - expectedProviderType = strings.TrimSpace(expectedProviderType) - if expectedProviderType == "" { - return nil - } - - actualProviderType := "" - if typed, ok := checker.(modelProviderTypeChecker); ok { - actualProviderType = strings.TrimSpace(typed.GetProviderType(selector.QualifiedModel())) - } - if actualProviderType == "" || actualProviderType == expectedProviderType { - return nil - } - return core.NewInvalidRequestError( - fmt.Sprintf( - "native batch supports a single provider per batch; resolved model %q targets provider %q but batch provider is %q", - selector.QualifiedModel(), - actualProviderType, - expectedProviderType, - ), - nil, - ) -} - -// rewriteChatRequest resolves a translated chat request's redirect (user-path -// aware) and rewrites it for routing (the resolved provider is preserved so -// downstream routing can pick the target). Batch rewriting (which clears the -// provider and enforces a single provider per batch) lives in batch_preparer.go. -func rewriteChatRequest(ctx context.Context, service *Service, checker modelSupportChecker, req *core.ChatRequest) (*core.ChatRequest, error) { - if req == nil { - return nil, nil - } - selector, err := resolveRedirectRoutableSelector(ctx, service, checker, core.NewRequestedModelSelector(req.Model, req.Provider), "") - if err != nil { - return nil, err - } - forward := *req - forward.Model = selector.Model - forward.Provider = selector.Provider - return &forward, nil -} - -func rewriteResponsesRequest(ctx context.Context, service *Service, checker modelSupportChecker, req *core.ResponsesRequest) (*core.ResponsesRequest, error) { - if req == nil { - return nil, nil - } - selector, err := resolveRedirectRoutableSelector(ctx, service, checker, core.NewRequestedModelSelector(req.Model, req.Provider), "") - if err != nil { - return nil, err - } - forward := *req - forward.Model = selector.Model - forward.Provider = selector.Provider - return &forward, nil -} - -func rewriteEmbeddingRequest(ctx context.Context, service *Service, checker modelSupportChecker, req *core.EmbeddingRequest) (*core.EmbeddingRequest, error) { - if req == nil { - return nil, nil - } - selector, err := resolveRedirectRoutableSelector(ctx, service, checker, core.NewRequestedModelSelector(req.Model, req.Provider), "") - if err != nil { - return nil, err - } - forward := *req - forward.Model = selector.Model - forward.Provider = selector.Provider - return &forward, nil -} diff --git a/internal/virtualmodels/provider_test.go b/internal/virtualmodels/provider_test.go deleted file mode 100644 index 1a6f91ef..00000000 --- a/internal/virtualmodels/provider_test.go +++ /dev/null @@ -1,59 +0,0 @@ -package virtualmodels - -import ( - "context" - "testing" - - "gomodel/internal/core" -) - -// Translated request rewriting keeps the resolved provider because downstream -// routing still needs it; only native batch item rewriting clears providers. -func TestRewriteTranslatedRequests_PreservesResolvedProvider(t *testing.T) { - t.Parallel() - ctx := context.Background() - svc := newRedirectService(t) - checker := testCatalog() - - if chat, err := rewriteChatRequest(ctx, svc, checker, nil); err != nil || chat != nil { - t.Fatalf("rewriteChatRequest(nil) = (%v, %v), want nil, nil", chat, err) - } - chat, err := rewriteChatRequest(ctx, svc, checker, &core.ChatRequest{Model: "fast"}) - if err != nil { - t.Fatalf("rewriteChatRequest() error = %v", err) - } - if chat.Model != "gpt-4o" || chat.Provider != "openai" { - t.Fatalf("rewriteChatRequest() selector = %q/%q, want openai/gpt-4o", chat.Provider, chat.Model) - } - if _, err := rewriteChatRequest(ctx, svc, checker, &core.ChatRequest{}); err == nil { - t.Fatal("rewriteChatRequest(missing model) error = nil, want error") - } - - if responses, err := rewriteResponsesRequest(ctx, svc, checker, nil); err != nil || responses != nil { - t.Fatalf("rewriteResponsesRequest(nil) = (%v, %v), want nil, nil", responses, err) - } - responses, err := rewriteResponsesRequest(ctx, svc, checker, &core.ResponsesRequest{Model: "fast"}) - if err != nil { - t.Fatalf("rewriteResponsesRequest() error = %v", err) - } - if responses.Model != "gpt-4o" || responses.Provider != "openai" { - t.Fatalf("rewriteResponsesRequest() selector = %q/%q, want openai/gpt-4o", responses.Provider, responses.Model) - } - if _, err := rewriteResponsesRequest(ctx, svc, checker, &core.ResponsesRequest{}); err == nil { - t.Fatal("rewriteResponsesRequest(missing model) error = nil, want error") - } - - if embeddings, err := rewriteEmbeddingRequest(ctx, svc, checker, nil); err != nil || embeddings != nil { - t.Fatalf("rewriteEmbeddingRequest(nil) = (%v, %v), want nil, nil", embeddings, err) - } - embeddings, err := rewriteEmbeddingRequest(ctx, svc, checker, &core.EmbeddingRequest{Model: "fast"}) - if err != nil { - t.Fatalf("rewriteEmbeddingRequest() error = %v", err) - } - if embeddings.Model != "gpt-4o" || embeddings.Provider != "openai" { - t.Fatalf("rewriteEmbeddingRequest() selector = %q/%q, want openai/gpt-4o", embeddings.Provider, embeddings.Model) - } - if _, err := rewriteEmbeddingRequest(ctx, svc, checker, &core.EmbeddingRequest{}); err == nil { - t.Fatal("rewriteEmbeddingRequest(missing model) error = nil, want error") - } -} diff --git a/internal/virtualmodels/redirect_rewrite.go b/internal/virtualmodels/redirect_rewrite.go new file mode 100644 index 00000000..969e5c69 --- /dev/null +++ b/internal/virtualmodels/redirect_rewrite.go @@ -0,0 +1,131 @@ +package virtualmodels + +import ( + "context" + "fmt" + "strings" + + "gomodel/internal/core" +) + +// ChatExecutor applies virtual-model redirects to transport-free chat +// completions before delegating to the wrapped provider. It is the narrow +// replacement for the former redirect-aware Provider decorator, whose only +// production role was serving as the guardrail auxiliary-LLM executor +// (guardrails.ChatCompletionExecutor is a single-method interface). +type ChatExecutor struct { + inner ChatExecutorProvider + service *Service +} + +// ChatExecutorProvider is the slice of the router the executor needs: model +// support checks for redirect validation and chat dispatch. +type ChatExecutorProvider interface { + modelSupportChecker + ChatCompletion(ctx context.Context, req *core.ChatRequest) (*core.ChatResponse, error) +} + +// NewChatExecutor creates a redirect-aware chat executor over inner. +func NewChatExecutor(inner ChatExecutorProvider, service *Service) *ChatExecutor { + return &ChatExecutor{inner: inner, service: service} +} + +// ChatCompletion resolves the request's redirect (user-path aware) and +// delegates the rewritten request to the wrapped provider. +func (e *ChatExecutor) ChatCompletion(ctx context.Context, req *core.ChatRequest) (*core.ChatResponse, error) { + forward, err := rewriteChatRequest(ctx, e.service, e.inner, req) + if err != nil { + return nil, err + } + return e.inner.ChatCompletion(ctx, forward) +} + +// --- shared redirect-rewrite helpers (used by ChatExecutor and BatchPreparer) --- + +type modelSupportChecker interface { + Supports(string) bool +} + +type modelProviderTypeChecker interface { + modelSupportChecker + GetProviderType(string) string +} + +// resolveRedirectRequestSelector resolves a request-time selector through the +// redirect table honoring the caller's user path, so a user_paths-scoped redirect +// is not applied for callers outside its scope (it falls through to the literal +// name). +func resolveRedirectRequestSelector(ctx context.Context, service *Service, requested core.RequestedModelSelector) (core.ModelSelector, error) { + if service == nil { + return requested.Normalize() + } + selector, changed, err := service.ResolveModelForUserPath(ctx, requested) + if err != nil { + return core.ModelSelector{}, err + } + if changed { + return selector, nil + } + return requested.Normalize() +} + +func resolveRedirectRoutableSelector(ctx context.Context, service *Service, checker modelSupportChecker, requested core.RequestedModelSelector, expectedProviderType string) (core.ModelSelector, error) { + selector, err := resolveRedirectRequestSelector(ctx, service, requested) + if err != nil { + return core.ModelSelector{}, err + } + + resolvedModel := strings.TrimSpace(selector.QualifiedModel()) + if resolvedModel == "" { + return core.ModelSelector{}, core.NewInvalidRequestError("model is required", nil) + } + if checker == nil || !checker.Supports(resolvedModel) { + return core.ModelSelector{}, core.NewModelNotFoundError(resolvedModel) + } + if err := validateResolvedProviderType(checker, selector, expectedProviderType); err != nil { + return core.ModelSelector{}, err + } + return selector, nil +} + +func validateResolvedProviderType(checker modelSupportChecker, selector core.ModelSelector, expectedProviderType string) error { + expectedProviderType = strings.TrimSpace(expectedProviderType) + if expectedProviderType == "" { + return nil + } + + actualProviderType := "" + if typed, ok := checker.(modelProviderTypeChecker); ok { + actualProviderType = strings.TrimSpace(typed.GetProviderType(selector.QualifiedModel())) + } + if actualProviderType == "" || actualProviderType == expectedProviderType { + return nil + } + return core.NewInvalidRequestError( + fmt.Sprintf( + "native batch supports a single provider per batch; resolved model %q targets provider %q but batch provider is %q", + selector.QualifiedModel(), + actualProviderType, + expectedProviderType, + ), + nil, + ) +} + +// rewriteChatRequest resolves a translated chat request's redirect (user-path +// aware) and rewrites it for routing (the resolved provider is preserved so +// downstream routing can pick the target). Batch rewriting (which clears the +// provider and enforces a single provider per batch) lives in batch_preparer.go. +func rewriteChatRequest(ctx context.Context, service *Service, checker modelSupportChecker, req *core.ChatRequest) (*core.ChatRequest, error) { + if req == nil { + return nil, nil + } + selector, err := resolveRedirectRoutableSelector(ctx, service, checker, core.NewRequestedModelSelector(req.Model, req.Provider), "") + if err != nil { + return nil, err + } + forward := *req + forward.Model = selector.Model + forward.Provider = selector.Provider + return &forward, nil +} diff --git a/internal/virtualmodels/redirect_rewrite_test.go b/internal/virtualmodels/redirect_rewrite_test.go new file mode 100644 index 00000000..1ef82839 --- /dev/null +++ b/internal/virtualmodels/redirect_rewrite_test.go @@ -0,0 +1,116 @@ +package virtualmodels + +import ( + "context" + "errors" + "testing" + + "gomodel/internal/core" +) + +type chatExecMock struct { + supported map[string]bool + captured *core.ChatRequest + resp *core.ChatResponse +} + +func (m *chatExecMock) Supports(model string) bool { return m.supported[model] } + +func (m *chatExecMock) ChatCompletion(_ context.Context, req *core.ChatRequest) (*core.ChatResponse, error) { + m.captured = req + return m.resp, nil +} + +func TestChatExecutor_RewritesRedirectModel(t *testing.T) { + t.Parallel() + svc := newTestService(t) + ctx := context.Background() + if err := svc.Upsert(ctx, VirtualModel{ + Source: "fast", + Targets: []Target{{Provider: "openai", Model: "gpt-4o"}}, + Enabled: true, + }); err != nil { + t.Fatalf("Upsert(redirect) error = %v", err) + } + + inner := &chatExecMock{ + supported: map[string]bool{"openai/gpt-4o": true}, + resp: &core.ChatResponse{ID: "chatcmpl_1", Model: "gpt-4o"}, + } + executor := NewChatExecutor(inner, svc) + + resp, err := executor.ChatCompletion(ctx, &core.ChatRequest{Model: "fast"}) + if err != nil { + t.Fatalf("ChatCompletion() error = %v", err) + } + if resp == nil || resp.ID != "chatcmpl_1" { + t.Fatalf("response = %+v, want passthrough of inner response", resp) + } + if inner.captured == nil { + t.Fatal("inner provider was not called") + } + if inner.captured.Model != "gpt-4o" || inner.captured.Provider != "openai" { + t.Fatalf("forwarded selector = %s/%s, want openai/gpt-4o", inner.captured.Provider, inner.captured.Model) + } +} + +func TestChatExecutor_PassesThroughConcreteModel(t *testing.T) { + t.Parallel() + svc := newTestService(t) + + inner := &chatExecMock{ + supported: map[string]bool{"openai/gpt-4o": true}, + resp: &core.ChatResponse{ID: "chatcmpl_2"}, + } + executor := NewChatExecutor(inner, svc) + + if _, err := executor.ChatCompletion(context.Background(), &core.ChatRequest{Model: "gpt-4o", Provider: "openai"}); err != nil { + t.Fatalf("ChatCompletion() error = %v", err) + } + if inner.captured == nil || inner.captured.Model != "gpt-4o" || inner.captured.Provider != "openai" { + t.Fatalf("forwarded request = %+v, want unchanged selector", inner.captured) + } +} + +func TestChatExecutor_UnknownModelReturnsNotFound(t *testing.T) { + t.Parallel() + svc := newTestService(t) + + inner := &chatExecMock{supported: map[string]bool{}} + executor := NewChatExecutor(inner, svc) + + _, err := executor.ChatCompletion(context.Background(), &core.ChatRequest{Model: "missing"}) + if err == nil { + t.Fatal("ChatCompletion() error = nil, want model_not_found") + } + var gatewayErr *core.GatewayError + if !errors.As(err, &gatewayErr) || gatewayErr.Code == nil || *gatewayErr.Code != "model_not_found" { + t.Fatalf("error = %v, want model_not_found gateway error", err) + } + if inner.captured != nil { + t.Fatal("inner provider must not be called for unsupported models") + } +} + +// Translated request rewriting keeps the resolved provider because downstream +// routing still needs it; only native batch item rewriting clears providers. +func TestRewriteChatRequest_PreservesResolvedProvider(t *testing.T) { + t.Parallel() + ctx := context.Background() + svc := newRedirectService(t) + checker := testCatalog() + + if chat, err := rewriteChatRequest(ctx, svc, checker, nil); err != nil || chat != nil { + t.Fatalf("rewriteChatRequest(nil) = (%v, %v), want nil, nil", chat, err) + } + chat, err := rewriteChatRequest(ctx, svc, checker, &core.ChatRequest{Model: "fast"}) + if err != nil { + t.Fatalf("rewriteChatRequest() error = %v", err) + } + if chat.Provider != "openai" || chat.Model != "gpt-4o" { + t.Fatalf("rewriteChatRequest() selector = %q/%q, want openai/gpt-4o", chat.Provider, chat.Model) + } + if _, err := rewriteChatRequest(ctx, svc, checker, &core.ChatRequest{}); err == nil { + t.Fatal("rewriteChatRequest(missing model) error = nil, want error") + } +}