Skip to content
Closed
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
14 changes: 14 additions & 0 deletions docs/features/virtual-models.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,20 @@ For example, point `smart` at both `openai/gpt-4o` and `anthropic/claude-sonnet-
with the round-robin strategy to split load, or at a premium and a budget model
with the cost strategy to always take the cheaper one.

## Reorder targets

The target list is an ordered list, and the editor keeps it editable: drag the
**grip handle** (☰) left of a target's remove button onto another row to change
its position. The drop row is not replaced — the dragged row moves into its
position and the rows in between shift. Keyboard users can focus the handle and
press **ArrowUp** / **ArrowDown** for the same move.

The handle shows only when the redirect has more than one target, and every
strategy benefits: for **failover** the order is the priority chain (position
`1` is tried first), for the balancing strategies position `1` is the first
slot in the queue. Weights and explicit provider pins stay attached to their
target when it moves.

## Chain virtual models

A redirect target can name **another virtual model** instead of a concrete
Expand Down
87 changes: 87 additions & 0 deletions internal/admin/handler_virtualmodels_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"slices"
"testing"

"github.com/labstack/echo/v5"
Expand Down Expand Up @@ -649,3 +650,89 @@ func TestDeleteVirtualModelNotFound(t *testing.T) {
t.Fatalf("status = %d, want 404 body=%s", rec.Code, rec.Body.String())
}
}

// TestUpsertVirtualModelTargetOrderRoundTrips pins the contract the
// dashboard's drag-to-reorder relies on: the targets array travels in display
// order (failover primary first), and a reorder save stores and returns the
// targets in exactly the order the editor sent them.
func TestUpsertVirtualModelTargetOrderRoundTrips(t *testing.T) {
catalog := newVMTestCatalog()
catalog.add("openai/gpt-4o", "openai")
catalog.add("openai/gpt-4o-mini", "openai")
catalog.add("anthropic/claude-haiku", "anthropic")
service := newVMService(t, catalog, newVMTestStore(redirectVM("smart", "openai/gpt-4o", true)), true)
h := NewHandler(nil, nil, WithVirtualModels(service))
e := echo.New()

put := func(body string) {
t.Helper()
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 != http.StatusOK {
t.Fatalf("put status = %d, want 200 body=%s", rec.Code, rec.Body.String())
}
}

// Initial order: gpt-4o is the failover primary.
put(`{"source":"smart","strategy":"failover","targets":[{"model":"openai/gpt-4o"},{"model":"openai/gpt-4o-mini"},{"model":"anthropic/claude-haiku"}]}`)

vm, ok := service.Get("smart")
if !ok {
t.Fatal("stored virtual model missing after initial put")
}
want := []string{"openai/gpt-4o", "openai/gpt-4o-mini", "anthropic/claude-haiku"}
if !slices.Equal(qualifiedTargetNames(vm.Targets), want) {
t.Fatalf("stored order = %v, want %v", vm.Targets, want)
}

// Reorder save: what the editor sends after dragging the last target onto
// the first row. The new primary must land first, the rest keep their
// relative order.
put(`{"source":"smart","strategy":"failover","targets":[{"model":"anthropic/claude-haiku"},{"model":"openai/gpt-4o"},{"model":"openai/gpt-4o-mini"}]}`)

vm, ok = service.Get("smart")
if !ok {
t.Fatal("stored virtual model missing after reorder put")
}
want = []string{"anthropic/claude-haiku", "openai/gpt-4o", "openai/gpt-4o-mini"}
if !slices.Equal(qualifiedTargetNames(vm.Targets), want) {
t.Fatalf("stored order after reorder = %v, want %v", vm.Targets, want)
}

// The list view the dashboard renders must return the same order.
c, rec := newHandlerContext("/admin/virtual-models")
if err := h.ListVirtualModels(c); err != nil {
t.Fatalf("ListVirtualModels() error = %v", err)
}
var views []virtualmodels.View
if err := json.Unmarshal(rec.Body.Bytes(), &views); err != nil {
t.Fatalf("decode list response: %v", err)
}
for _, view := range views {
if view.Source != "smart" {
continue
}
if !slices.Equal(qualifiedTargetNames(view.Targets), want) {
t.Fatalf("view order after reorder = %v, want %v", view.Targets, want)
}
return
}
t.Fatal("smart missing from list views")
}

func qualifiedTargetNames(targets []virtualmodels.Target) []string {
names := make([]string, 0, len(targets))
for _, target := range targets {
if target.Provider != "" {
names = append(names, target.Provider+"/"+target.Model)
continue
}
names = append(names, target.Model)
}
return names
}

1 change: 1 addition & 0 deletions web/dashboard/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -630,6 +630,7 @@
"models_weight": "weight",
"models_weight_help": "Relative share of requests: 1 is a normal share, 2 gets twice as many, 3 three times (whole numbers, 1 or more).",
"models_remove_target": "Remove target",
"models_move_target": "Drag to reorder (or focus and use the arrow keys)",
"models_pricing_editor": "Model pricing editor",
"models_pricing_save": "Save Pricing",
"models_pricing_override": "Pricing override",
Expand Down
1 change: 1 addition & 0 deletions web/dashboard/messages/pl.json
Original file line number Diff line number Diff line change
Expand Up @@ -638,6 +638,7 @@
"models_weight": "waga",
"models_weight_help": "Względny udział żądań: 1 to zwykły udział, 2 dostaje dwa razy więcej, 3 trzy razy (liczby całkowite, co najmniej 1).",
"models_remove_target": "Usuń cel",
"models_move_target": "Przeciągnij, aby zmienić kolejność (lub skup i użyj klawiszy strzałek)",
"models_pricing_editor": "Edytor cen modelu",
"models_pricing_save": "Zapisz ceny",
"models_pricing_override": "Nadpisanie cen",
Expand Down
14 changes: 13 additions & 1 deletion web/dashboard/src/pages/models/VirtualModelEditor.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@

const vm = virtualModelEditor;

// The handle shows only when there is more than one row to reorder.
const canReorder = $derived(vm.vmTargetCount() > 1 && !vm.vmFormManaged);
// Row indices must match the flattened target list the move logic operates
// on: an empty primary row is not in that list, so extras start at 0 then.
const hasPrimary = $derived(vmFormHasPrimaryTarget(vm.vmForm));

// The strategy dropdown is server-driven (VIRTUAL_MODEL_STRATEGIES); make
// sure the runtime config is loaded by the time the editor shows it.
$effect(() => {
Expand Down Expand Up @@ -73,11 +79,15 @@
/>
</FormField>

<!-- Every target renders the same; two or more turn the redirect into a load balancer. -->
<!-- Every target renders the same; two or more turn the redirect into a load balancer.
Rows carry their position in the flattened list (primary first) so the drag
handle can reorder across the primary/extra boundary. -->
<div class="form-field">
<span class="form-field-label">{m.models_targets()}</span>
<VmTargetRow
id="virtual-model-target"
index={0}
draggable={canReorder && hasPrimary}
bind:provider={vm.vmForm.target_provider}
bind:model={vm.vmForm.target_model}
bind:weight={vm.vmForm.target_weight}
Expand All @@ -87,6 +97,8 @@
{#each vm.vmForm.targets as target, index (index)}
<VmTargetRow
placeholder="groq/llama"
index={(hasPrimary ? 1 : 0) + index}
draggable={canReorder}
bind:provider={target.provider}
bind:model={target.model}
bind:weight={target.weight}
Expand Down
115 changes: 111 additions & 4 deletions web/dashboard/src/pages/models/VmTargetRow.svelte
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
<script>
// One target row of the virtual-model editor. The primary target and the
// extra {#each} rows share this shape; two or more rows make the redirect
// a load balancer (weights show only for round-robin).
// a load balancer (weights show only for round-robin). The ☰ handle drags
// a row onto another row to reorder the list; keyboard users get the same
// via ArrowUp/ArrowDown on the focused handle.
import Icon from "$lib/components/atoms/Icon.svelte";
import SearchSelect from "$lib/components/molecules/SearchSelect.svelte";
import TableActionButton from "$lib/components/atoms/TableActionButton.svelte";
import { virtualModelEditor as vm } from "./virtualModelEditor.svelte.js";
import { vmFormShowWeights } from "./vmForm.js";
import { Trash2 } from "lucide";
import { moveFormTarget, vmFormShowWeights } from "./vmForm.js";
import { GripVertical, Trash2 } from "lucide";
import * as m from "$lib/paraglide/messages.js";

// provider is the explicit provider a stored target may carry (API- or
Expand All @@ -21,10 +23,56 @@
placeholder = "openai/gpt-4o",
showRemove = true,
onremove,
// index is this row's position in the flattened target list (primary
// first); draggable turns on the reorder handle.
index = undefined,
draggable = false,
} = $props();

let moveHandle = $state(null);

// After a keyboard move the editor asks for focus at the moved row's new
// index; grab it here so repeated arrows walk the same model, not whatever
// row now sits at the old index.
$effect(() => {
if (draggable && vm.vmFocusHandle === index && moveHandle) {
moveHandle.focus();
vm.clearVmFocusHandle();
}
});
</script>

<div class="vm-target-row">
<div
class="vm-target-row"
role="group"
class:vm-target-dragging={vm.vmDragIndex === index}
class:vm-target-drop={vm.vmDropIndex === index && vm.vmDragIndex !== null && vm.vmDragIndex !== index}
ondragenter={(event) => {
if (draggable && vm.vmDragIndex !== null) {
event.preventDefault();
}
}}
ondragover={(event) => {
if (draggable && vm.vmDragIndex !== null) {
event.preventDefault();
event.dataTransfer.dropEffect = "move";
vm.enterVmTargetDrop(index);
}
}}
ondrop={(event) => {
if (draggable && vm.vmDragIndex !== null) {
event.preventDefault();
vm.dropVmTarget(index);
}
}}
ondragleave={(event) => {
// Child elements fire dragleave when the cursor moves between them;
// only clear the highlight when the cursor left the row itself.
if (!event.currentTarget.contains(event.relatedTarget)) {
vm.leaveVmTargetDrop(index);
}
}}
>
<SearchSelect
{id}
class="vm-target-model"
Expand Down Expand Up @@ -54,6 +102,39 @@
aria-label={m.models_target_weight()}
/>
{/if}
{#if draggable}
<span
bind:this={moveHandle}
class="vm-target-move"
role="button"
tabindex="0"
aria-label={m.models_move_target()}
title={m.models_move_target()}
draggable="true"
ondragstart={(event) => {
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", String(index));
vm.startVmTargetDrag(index);
}}
ondragend={() => vm.endVmTargetDrag()}
onkeydown={(event) => {
if (event.key === "ArrowUp" && index > 0) {
event.preventDefault();
moveFormTarget(vm.vmForm, index, index - 1);
vm.requestVmFocusHandle(index - 1);
} else if (
event.key === "ArrowDown" &&
index < vm.vmTargetCount() - 1
) {
event.preventDefault();
moveFormTarget(vm.vmForm, index, index + 1);
vm.requestVmFocusHandle(index + 1);
}
}}
>
<Icon icon={GripVertical} class="table-icon-svg" />
</span>
{/if}
{#if showRemove}
<TableActionButton
label={m.models_remove_target()}
Expand All @@ -65,3 +146,29 @@
</TableActionButton>
{/if}
</div>

<style>
.vm-target-move {
display: inline-flex;
align-items: center;
justify-content: center;
cursor: grab;
color: var(--muted, var(--text));
touch-action: none;
user-select: none;
-webkit-user-select: none;
}

.vm-target-move:active {
cursor: grabbing;
}

.vm-target-row.vm-target-dragging {
opacity: 0.4;
}

.vm-target-row.vm-target-drop {
outline: 2px dashed var(--accent);
outline-offset: 2px;
}
</style>
Loading