Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.template
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,7 @@
# and is read-only in the dashboard. The same entries can be declared under
# `virtual_models:` in config.yaml. Each entry: source, target (single) or
# targets[] (load balanced), strategy (round_robin | cost), user_paths,
# slowdown (0 disables inherited slowdown; active factors are 0.1-10),
# description, enabled. A target has model and optional provider + weight.
# VIRTUAL_MODELS=[{"source":"smart","strategy":"round_robin","targets":[{"model":"openai/gpt-4o","weight":2},{"model":"anthropic/claude-sonnet-4-6"}]}]

Expand Down
1 change: 1 addition & 0 deletions config/config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ models:
# virtual_models:
# - source: regular # a plain alias
# target: anthropic/claude-sonnet-4-6
# slowdown: 0.5 # add 50%; use 0 to disable inherited slowdown (active range: 0.1-10)
# - source: smart # weighted round-robin load balancer
# strategy: round_robin # round_robin (default) | cost | adaptive (uses a routing extension when registered; otherwise falls back to round_robin)
# targets:
Expand Down
5 changes: 5 additions & 0 deletions config/virtualmodels.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ type VirtualModelConfig struct {
// Description is an optional human-readable note.
Description string `yaml:"description,omitempty" json:"description,omitempty"`

// Slowdown is an optional extra-time factor. For example, 0.5 adds 50% of
// measured inference time. Zero explicitly disables inherited slowdown;
// nil leaves the setting unspecified.
Slowdown *float64 `yaml:"slowdown,omitempty" json:"slowdown,omitempty"`

// Enabled toggles the entry. It defaults to true when omitted.
Enabled *bool `yaml:"enabled,omitempty" json:"enabled,omitempty"`
}
Expand Down
28 changes: 25 additions & 3 deletions docs/features/virtual-models.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,31 @@ policy on the **Source** selector.

GoModel does not persist empty, ineffective access policies, whether saved from
the dashboard or through the admin API. If a saved policy has no target, user
paths, or description and its enabled state matches the inherited/default
paths, description, or slowdown and its enabled state matches the inherited/default
access state, GoModel removes the stored row. It retains an otherwise empty
policy when that row still overrides a disabled default or an inherited
user-path restriction.

## Add artificial model latency

Set **Slowdown** on a concrete model or redirect to add a fraction of measured
inference time. Active values range from `0.1` to `10`; for example, `0.5`
adds 50%, so a response that takes 2 seconds upstream is returned after about
3 seconds. Set `0` to explicitly disable slowdown, including slowdown inherited
from a concrete model. Leaving the field empty leaves the setting unspecified.
Delays honor request cancellation.

For Chat Completions and Responses API SSE streams, GoModel drains the upstream
in the background and releases each read chunk on the scaled timeline. This
preserves the provider's relative chunk timing while delayed chunks accumulate
in memory. A large factor on a long or high-volume stream can therefore use
substantial memory. Realtime WebSocket sessions are not slowed.

When an alias and its concrete target both have a slowdown, the alias value
wins. An alias without its own value inherits the selected target model's
slowdown; set the alias to `0` to override that inheritance. This also applies
to load-balanced virtual models after a target is selected.

The rest of this page covers redirects.

## Use stable names
Expand Down Expand Up @@ -117,6 +137,7 @@ virtual_models:
# A plain alias.
- source: regular
target: anthropic/claude-sonnet-4-6
slowdown: 0.5

# A weighted round-robin load balancer.
- source: smart
Expand All @@ -143,8 +164,9 @@ VIRTUAL_MODELS=[{"source":"smart","strategy":"cost","targets":[{"model":"openai/

Each entry accepts `source`, a single `target` (shorthand) or a `targets` list,
`strategy` (`round_robin` or `cost`), `session_affinity` (default `true`; see
[Session Keeping](/features/session-keeping)), `user_paths`, `description`, and
`enabled`.
[Session Keeping](/features/session-keeping)), `user_paths`, `description`,
`slowdown` (`0` disables inherited slowdown; active factors range from `0.1` to
`10`), and `enabled`.
Leave the targets empty to declare an access policy on the `source` selector. An
invalid declaration (unknown strategy, missing or self-referential target, or a
target `provider` that matches no configured provider — a typo) fails startup
Expand Down
48 changes: 48 additions & 0 deletions docs/openapi.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

68 changes: 0 additions & 68 deletions internal/admin/dashboard/static/dist/assets/index-D05Km9Si.js

This file was deleted.

70 changes: 70 additions & 0 deletions internal/admin/dashboard/static/dist/assets/index-DiApg2WK.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion internal/admin/dashboard/static/dist/index.html

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion internal/admin/handler_virtualmodels.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ type upsertVirtualModelRequest struct {
SessionAffinity *bool `json:"session_affinity,omitempty"`
UserPaths []string `json:"user_paths,omitempty"`
Description string `json:"description,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
// Slowdown is an extra-time factor from 0.1 to 10; zero disables it.
Slowdown *float64 `json:"slowdown,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
}

// virtualModelTargetRequest is one load-balancing destination. Model may be a
Expand Down Expand Up @@ -162,6 +164,7 @@ func (h *Handler) buildVirtualModelUpsert(source string, req upsertVirtualModelR
SessionAffinity: req.SessionAffinity,
UserPaths: req.UserPaths,
Description: strings.TrimSpace(req.Description),
Slowdown: req.Slowdown,
Enabled: h.virtualModels.ResolveUpsertEnabled(source, req.OldSource, req.Enabled),
}

Expand Down
43 changes: 43 additions & 0 deletions internal/admin/handler_virtualmodels_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"testing"
Expand Down Expand Up @@ -399,6 +400,48 @@ func TestUpsertPolicyVirtualModelAcceptsEmptyUserPaths(t *testing.T) {
}
}

func TestUpsertVirtualModelValidatesSlowdown(t *testing.T) {
tests := []struct {
name string
slowdown float64
wantStatus int
}{
{name: "explicit zero", slowdown: 0, wantStatus: http.StatusOK},
{name: "minimum", slowdown: 0.1, wantStatus: http.StatusOK},
{name: "maximum", slowdown: 10, wantStatus: http.StatusOK},
{name: "below minimum", slowdown: 0.09, wantStatus: http.StatusBadRequest},
{name: "above maximum", slowdown: 10.1, wantStatus: http.StatusBadRequest},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
h := newVMHandler(t)
e := echo.New()
body := fmt.Sprintf(`{"source":"slow","target_model":"openai/gpt-4o","slowdown":%v}`, tt.slowdown)
req := httptest.NewRequest(http.MethodPut, "/admin/virtual-models", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
if err := h.UpsertVirtualModel(e.NewContext(req, rec)); err != nil {
t.Fatalf("UpsertVirtualModel() error = %v", err)
}
if rec.Code != tt.wantStatus {
t.Fatalf("status = %d, want %d body=%s", rec.Code, tt.wantStatus, rec.Body.String())
}
if tt.wantStatus != http.StatusOK {
return
}

var view virtualmodels.View
if err := json.Unmarshal(rec.Body.Bytes(), &view); err != nil {
t.Fatalf("decode response: %v", err)
}
if view.Slowdown == nil || *view.Slowdown != tt.slowdown {
t.Fatalf("view.Slowdown = %v, want %v", view.Slowdown, tt.slowdown)
}
})
}
}

func TestUpsertRedirectVirtualModelReplacesAccessPolicy(t *testing.T) {
h := newVMHandler(t, virtualmodels.VirtualModel{
Source: "openai/gpt-4o",
Expand Down
3 changes: 3 additions & 0 deletions internal/core/request_model_resolution.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ type RequestModelResolution struct {
ProviderType string
ProviderName string
AliasApplied bool
// Slowdown is the extra-time factor selected for this request. A value of
// 0.5 adds 50% of measured inference time; zero disables slowdown.
Slowdown float64
}

// RequestedQualifiedModel returns the canonical requested selector.
Expand Down
43 changes: 36 additions & 7 deletions internal/gateway/inference_execute.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ func (o *InferenceOrchestrator) ExecuteChatCompletion(ctx context.Context, workf
if err := o.validateProviderAndRequest(req != nil, "chat request is required"); err != nil {
return nil, err
}
return executeTranslatedResult(o, ctx, workflow, req, requestID, endpoint, chatExecutionSpec)
return executeResultWithSlowdown(ctx, workflow, func() (*ChatCompletionResult, error) {
return executeTranslatedResult(o, ctx, workflow, req, requestID, endpoint, chatExecutionSpec)
})
}

// DispatchChatCompletion executes a non-streaming chat request without usage side effects.
Expand All @@ -28,21 +30,26 @@ func (o *InferenceOrchestrator) DispatchChatCompletion(
if err := o.validateProviderAndRequest(req != nil, "chat request is required"); err != nil {
return nil, "", "", "", false, err
}
return o.executeChatCompletion(ctx, workflow, req)
return dispatchTranslatedWithSlowdown(ctx, workflow, func() (*core.ChatResponse, string, string, string, bool, error) {
return o.executeChatCompletion(ctx, workflow, req)
})
}

// StreamChatCompletion opens a chat SSE stream. Stream usage is recorded by the caller's stream observer.
func (o *InferenceOrchestrator) StreamChatCompletion(ctx context.Context, workflow *core.Workflow, req *core.ChatRequest) (*StreamResult, error) {
if err := o.validateProviderAndRequest(req != nil, "chat request is required"); err != nil {
return nil, err
}
started := time.Now()
streamReq, providerType, providerName, usageModel := o.ResolveChatRoute(workflow, req)
stream, resolvedProviderType, resolvedProviderName, resolvedUsageModel, failoverModel, usedFailover, err := o.streamChatCompletion(ctx, workflow, streamReq, providerType, providerName, usageModel)
if err != nil {
return nil, err
}
return &StreamResult{
Stream: stream,
Stream: stream,
slowdownFactor: workflowSlowdown(workflow),
inferenceStarted: started,
Meta: ExecutionMeta{
ProviderType: resolvedProviderType,
ProviderName: resolvedProviderName,
Expand All @@ -58,7 +65,9 @@ func (o *InferenceOrchestrator) ExecuteResponses(ctx context.Context, workflow *
if err := o.validateProviderAndRequest(req != nil, "responses request is required"); err != nil {
return nil, err
}
return executeTranslatedResult(o, ctx, workflow, req, requestID, endpoint, responsesExecutionSpec)
return executeResultWithSlowdown(ctx, workflow, func() (*ResponsesResult, error) {
return executeTranslatedResult(o, ctx, workflow, req, requestID, endpoint, responsesExecutionSpec)
})
}

// DispatchResponses executes a non-streaming Responses request without usage side effects.
Expand All @@ -70,14 +79,17 @@ func (o *InferenceOrchestrator) DispatchResponses(
if err := o.validateProviderAndRequest(req != nil, "responses request is required"); err != nil {
return nil, "", "", "", false, err
}
return o.executeResponses(ctx, workflow, req)
return dispatchTranslatedWithSlowdown(ctx, workflow, func() (*core.ResponsesResponse, string, string, string, bool, error) {
return o.executeResponses(ctx, workflow, req)
})
}

// StreamResponses opens a Responses API SSE stream. Stream usage is recorded by the caller's stream observer.
func (o *InferenceOrchestrator) StreamResponses(ctx context.Context, workflow *core.Workflow, req *core.ResponsesRequest) (*StreamResult, error) {
if err := o.validateProviderAndRequest(req != nil, "responses request is required"); err != nil {
return nil, err
}
started := time.Now()
providerType, providerName, usageModel := o.routeMetadata(workflow, req.Model)
if (workflow == nil || workflow.UsageEnabled()) && o.ShouldEnforceReturningUsageData() {
ctx = core.WithEnforceReturningUsageData(ctx, true)
Expand All @@ -87,7 +99,9 @@ func (o *InferenceOrchestrator) StreamResponses(ctx context.Context, workflow *c
return nil, err
}
return &StreamResult{
Stream: stream,
Stream: stream,
slowdownFactor: workflowSlowdown(workflow),
inferenceStarted: started,
Meta: ExecutionMeta{
ProviderType: resolvedProviderType,
ProviderName: resolvedProviderName,
Expand All @@ -103,6 +117,7 @@ func (o *InferenceOrchestrator) ExecuteEmbeddings(ctx context.Context, workflow
if err := o.validateProviderAndRequest(req != nil, "embeddings request is required"); err != nil {
return nil, err
}
started := time.Now()
resp, providerType, providerName, err := o.executeEmbeddings(ctx, workflow, req)
if err != nil {
return nil, err
Expand All @@ -111,6 +126,9 @@ func (o *InferenceOrchestrator) ExecuteEmbeddings(ctx context.Context, workflow
o.logUsage(ctx, workflow, pricingModel, providerType, providerName, func(pricing *core.ModelPricing) *usage.UsageEntry {
return usage.ExtractFromEmbeddingResponse(resp, requestID, providerType, endpoint, pricing)
})
if err := waitForInferenceSlowdown(ctx, workflow, time.Since(started)); err != nil {
return nil, err
}
return &EmbeddingResult{
Response: resp,
Meta: ExecutionMeta{
Expand All @@ -130,7 +148,15 @@ func (o *InferenceOrchestrator) DispatchEmbeddings(
if err := o.validateProviderAndRequest(req != nil, "embeddings request is required"); err != nil {
return nil, "", "", err
}
return o.executeEmbeddings(ctx, workflow, req)
started := time.Now()
resp, providerType, providerName, err := o.executeEmbeddings(ctx, workflow, req)
if err != nil {
return nil, "", "", err
}
if err := waitForInferenceSlowdown(ctx, workflow, time.Since(started)); err != nil {
return nil, "", "", err
}
return resp, providerType, providerName, nil
}

// ResolveChatRoute returns the provider route and the request to send for chat streams.
Expand Down Expand Up @@ -163,6 +189,9 @@ func (o *InferenceOrchestrator) CanFastPathStreamingChatPassthrough(workflow *co
if req == nil || !req.Stream {
return false
}
if workflowSlowdown(workflow) > 0 {
return false
}
if o.translatedRequestPatcher != nil || o.ShouldEnforceReturningUsageData() {
return false
}
Expand Down
Loading
Loading