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
49 changes: 45 additions & 4 deletions internal/server/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,13 @@ type modelIndex struct {
}

type catalog struct {
sig string
at time.Time
models []string
route map[string]string
sig string
at time.Time
models []string
route map[string]string
// routes is every route in the order asked, with what it answered: the
// Playground lists deployments by route, and two routes may serve one name.
routes []routeModels
refreshing bool
// partial: a route did not answer. openresty loads a new route up to a
// minute after autoconfig writes it, so such a list is re-asked sooner.
Expand Down Expand Up @@ -132,6 +135,11 @@ func (ix *modelIndex) probe(ctx context.Context, name string, t *backendTarget)
c := &catalog{route: map[string]string{}}
answered := 0
for i, route := range routes {
rm := routeModels{Route: route, Models: lists[i]}
if errs[i] != nil {
rm.Error = errs[i].Error()
}
c.routes = append(c.routes, rm)
if errs[i] != nil {
ix.log.Warn("route did not list its models", "backend", name, "route", route, "err", errs[i])
continue
Expand Down Expand Up @@ -228,6 +236,19 @@ func first(s []string) string {
return s[0]
}

// routeModels is one route as the Playground sees it: the models it lists, or
// why it listed none -- a route whose engines are not ready yet does not answer.
type routeModels struct {
Route string `json:"route"`
Models []string `json:"models"`
Error string `json:"error,omitempty"`
}

// routeHeader names the route a request is for. The Playground lists deployments,
// and two of them may serve the same model name; the name alone would always pick
// the first.
const routeHeader = "X-ModelSphere-Route"

// modelList is the OpenAI shape of a model list.
func modelList(ids []string) map[string]any {
data := make([]map[string]string, 0, len(ids))
Expand All @@ -244,6 +265,15 @@ func modelList(ids []string) map[string]any {
func (s *Server) routeByModel(w http.ResponseWriter, r *http.Request, b config.Backend, t *backendTarget) *backendTarget {
rest := strings.TrimPrefix(r.URL.Path, strings.TrimSuffix(b.Prefix, "/"))
if r.Method == http.MethodGet || r.Method == http.MethodHead {
if strings.TrimSuffix(rest, "/") == "/routes" {
c, err := s.models.get(r.Context(), b.Name, t, "")
if err != nil {
writeError(w, http.StatusBadGateway, fmt.Sprintf("backend %s: %v", b.Name, err))
return nil
}
writeJSON(w, http.StatusOK, map[string]any{"routes": c.routes})
return nil
}
if strings.TrimSuffix(rest, "/") == "/v1/models" {
c, err := s.models.get(r.Context(), b.Name, t, "")
if err != nil {
Expand All @@ -255,6 +285,17 @@ func (s *Server) routeByModel(w http.ResponseWriter, r *http.Request, b config.B
}
}

if want := r.Header.Get(routeHeader); want != "" {
r.Header.Del(routeHeader)
if !slices.Contains(t.routes, want) {
writeError(w, http.StatusBadRequest, fmt.Sprintf("backend %s has no route %q", b.Name, want))
return nil
}
routed := *t
routed.url.Path = strings.TrimSuffix(t.url.Path, "/") + "/" + want
return &routed
}

body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, config.DefaultMaxBodyBytes))
if err != nil {
writeError(w, http.StatusRequestEntityTooLarge, "request body too large or unreadable")
Expand Down
46 changes: 46 additions & 0 deletions internal/server/multiroute_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -209,3 +209,49 @@ func TestLateRouteJoinsTheListSoon(t *testing.T) {
time.Sleep(20 * time.Millisecond)
}
}

// The Playground lists deployments by route: it needs each route's own models,
// including a route that is not ready, and to send a turn to the route it picked
// even when another route serves the same model name.
func TestPlaygroundByRoute(t *testing.T) {
_, h, gw := multiRouteServer(t)
gw.serve("qwen-b", "qwen-a")
admin := login(t, h, "admin", "admin-pw")

var body struct {
Routes []routeModels `json:"routes"`
}
rec := do(h, "GET", "/api/llm/routes", admin, "")
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("%d %s: %v", rec.Code, rec.Body.String(), err)
}
got := map[string]routeModels{}
for _, r := range body.Routes {
got[r.Route] = r
}
if r := got["broken"]; r.Error == "" || len(r.Models) != 0 {
t.Fatalf("broken = %+v, want an error and no models", r)
}
if r := got["qwen-b"]; r.Error != "" || strings.Join(r.Models, ",") != "qwen-a" {
t.Fatalf("qwen-b = %+v", r)
}

byRoute := func(route string) *httptest.ResponseRecorder {
req := httptest.NewRequest("POST", "/api/llm/v1/chat/completions", strings.NewReader(`{"model":"qwen-a"}`))
req.Header.Set("Authorization", "Bearer "+admin)
req.Header.Set(routeHeader, route)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
// By name alone qwen-a goes to the first route serving it; the header picks.
if v := decode(t, do(h, "POST", "/api/llm/v1/chat/completions", admin, `{"model":"qwen-a"}`)); v["route"] != "qwen-a" {
t.Fatalf("by name went to %v", v)
}
if v := decode(t, byRoute("qwen-b")); v["route"] != "qwen-b" || v["path"] != "/v1/chat/completions" {
t.Fatalf("by route went to %v", v)
}
if rec := byRoute("elsewhere"); rec.Code != http.StatusBadRequest {
t.Fatalf("unknown route: %d %s", rec.Code, rec.Body.String())
}
}
17 changes: 10 additions & 7 deletions web/src/modules/playground/Chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Button, Card, CardContent, CardHeader, CardTitle, Label, PageBanner } f
import { Eraser, FlaskConical } from "lucide-react";
import { buildPayload } from "@/modules/playground/api";
import { Composer } from "@/modules/playground/components/Composer";
import { ModelSelect, NoModels, useModels } from "@/modules/playground/components/ModelSelect";
import { ModelSelect, NoModels, useModels, useTarget } from "@/modules/playground/components/ModelSelect";
import { ParamsPanel } from "@/modules/playground/components/ParamsPanel";
import { Transcript } from "@/modules/playground/components/Transcript";
import { ViewCode } from "@/modules/playground/components/ViewCode";
Expand All @@ -17,11 +17,14 @@ export function Chat() {
const models = useModels();
const [model, setModel] = useState("");
const [form, setForm] = useState<ParamsForm>(DEFAULT_FORM);
const params = useMemo(() => toChatParams(model, form), [model, form]);
// model is the picked deployment's route; target is that deployment while it
// is still listed and ready.
const target = useTarget(model);
const params = useMemo(() => toChatParams(target?.model ?? "", form, target?.id), [target?.model, target?.id, form]);
const chat = useChat(params);

useEffect(() => {
const first = models.data?.[0]?.id;
const first = models.data?.find((m) => m.ready)?.id;
if (!model && first) setModel(first);
}, [models.data, model]);

Expand All @@ -34,7 +37,7 @@ export function Chat() {
icon={<FlaskConical className="size-5" />}
actions={
<div className="flex items-center gap-2">
<ViewCode payload={payload} disabled={!model} />
<ViewCode payload={payload} disabled={!target} />
<Button variant="outline" onClick={chat.clear} disabled={!chat.turns.length}>
<Eraser data-icon="inline-start" />
{t("chat.newChat")}
Expand All @@ -55,7 +58,7 @@ export function Chat() {
<ModelSelect id="pg-model" value={model} onChange={setModel} />
{models.error ? (
<p className="text-xs text-destructive">{modelsHint(t, models.error)}</p>
) : models.data?.length === 0 ? (
) : models.data && !models.data.some((m) => m.ready) ? (
<NoModels />
) : (
<p className="text-xs text-muted-foreground">{t("chat.modelsSource")}</p>
Expand All @@ -80,14 +83,14 @@ export function Chat() {
<div className="flex h-full flex-col items-center justify-center gap-2 py-16 text-center">
<FlaskConical className="h-8 w-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground">
{model ? tNodes(t, "chat.emptyWithModel", { model: <span className="font-mono text-foreground">{model}</span> }) : t("chat.emptyTitle")}
{target ? tNodes(t, "chat.emptyWithModel", { model: <span className="font-mono text-foreground">{`${target.release} · ${target.model}`}</span> }) : t("chat.emptyTitle")}
</p>
<p className="max-w-md text-xs text-muted-foreground">{t("chat.emptyHint")}</p>
</div>
}
/>
<Composer
disabled={!model}
disabled={!target}
streaming={chat.streaming}
pendingUser={chat.turns.at(-1)?.role === "user"}
onSend={chat.send}
Expand Down
18 changes: 10 additions & 8 deletions web/src/modules/playground/Compare.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Button, Card, Dialog, DialogContent, DialogDescription, DialogHeader, D
import { Columns2, Eraser, Plus, SlidersHorizontal, X } from "lucide-react";
import { buildPayload } from "@/modules/playground/api";
import { Composer } from "@/modules/playground/components/Composer";
import { ModelSelect, NoModels, useModels } from "@/modules/playground/components/ModelSelect";
import { ModelSelect, NoModels, useModels, useTarget } from "@/modules/playground/components/ModelSelect";
import { ParamsPanel } from "@/modules/playground/components/ParamsPanel";
import { Transcript } from "@/modules/playground/components/Transcript";
import { ViewCode } from "@/modules/playground/components/ViewCode";
Expand Down Expand Up @@ -40,6 +40,7 @@ const GRID: Record<number, string> = {
export function Compare() {
const t = useT();
const models = useModels();
const ready = (models.data ?? []).filter((m) => m.ready);
const [slots, setSlots] = useState<Slot[]>(() => Array.from({ length: MIN_PANELS }, () => ({ id: newId(), model: "" })));
const [form, setForm] = useState<ParamsForm>(DEFAULT_FORM);
const [paramsOpen, setParamsOpen] = useState(false);
Expand All @@ -49,7 +50,7 @@ export function Compare() {
// Fill empty columns with models not yet shown, so the page compares something
// on first load.
useEffect(() => {
const ids = (models.data ?? []).map((m) => m.id);
const ids = (models.data ?? []).filter((m) => m.ready).map((m) => m.id);
if (!ids.length) return;
setSlots((prev) => {
if (prev.every((s) => s.model)) return prev;
Expand Down Expand Up @@ -87,7 +88,7 @@ export function Compare() {
onClick={() =>
setSlots((prev) => [
...prev,
{ id: newId(), model: models.data?.find((m) => !prev.some((s) => s.model === m.id))?.id ?? models.data?.[0]?.id ?? "" },
{ id: newId(), model: ready.find((m) => !prev.some((s) => s.model === m.id))?.id ?? ready[0]?.id ?? "" },
])
}
>
Expand Down Expand Up @@ -170,20 +171,21 @@ interface PanelProps {

function ComparePanel({ slot, index, form, height, onModel, onRemove, onState, ref }: PanelProps) {
const t = useT();
const params = useMemo(() => toChatParams(slot.model, form), [slot.model, form]);
const target = useTarget(slot.model);
const params = useMemo(() => toChatParams(target?.model ?? "", form, target?.id), [target?.model, target?.id, form]);
const chat = useChat(params);

useImperativeHandle(
ref,
() => ({
send: (text, role) => {
if (slot.model) chat.send(text, role);
if (target) chat.send(text, role);
},
add: (role, text) => chat.add(role, text),
stop: chat.stop,
clear: chat.clear,
}),
[chat, slot.model],
[chat, target],
);

const lastRole = chat.turns.at(-1)?.role;
Expand All @@ -203,7 +205,7 @@ function ComparePanel({ slot, index, form, height, onModel, onRemove, onState, r
onModel(model);
}}
/>
<ViewCode compact payload={() => buildPayload(historyOf(chat.turns), params)} disabled={!slot.model} />
<ViewCode compact payload={() => buildPayload(historyOf(chat.turns), params)} disabled={!target} />
<Button variant="ghost" size="icon-sm" title={t("compare.clear")} onClick={chat.clear} disabled={chat.streaming || !chat.turns.length}>
<Eraser className="h-3.5 w-3.5" />
</Button>
Expand All @@ -216,7 +218,7 @@ function ComparePanel({ slot, index, form, height, onModel, onRemove, onState, r
<Transcript
chat={chat}
className="min-h-0 flex-1"
empty={<p className="py-10 text-center text-sm text-muted-foreground">{slot.model ? t("compare.emptyReady") : t("compare.emptyPick")}</p>}
empty={<p className="py-10 text-center text-sm text-muted-foreground">{target ? t("compare.emptyReady") : t("compare.emptyPick")}</p>}
/>
</Card>
);
Expand Down
25 changes: 23 additions & 2 deletions web/src/modules/playground/api.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import { apiFetch, ApiError, getT, request } from "@/shell";
import "@/modules/playground/i18n";
import { SSEParser } from "@/modules/playground/sse";
import { joinTargets, type RouteModels, type Target } from "@/modules/playground/targets";
import { api as swiss } from "@swiss/lib/api";

// console proxies this prefix to llm-openresty's route (console.yaml backends:
// llm), adding the gateway key it holds. The browser never sees that key.
const BASE = "/api/llm/v1";
const GATEWAY = "/api/llm";
const BASE = `${GATEWAY}/v1`;

export interface Model {
id: string;
Expand All @@ -20,6 +23,9 @@ export type ReasoningEffort = "" | "none" | "minimal" | "low" | "medium" | "high

export interface ChatParams {
model: string;
// The gateway route the deployment is published on. Two deployments may serve
// the same model name; the route is what picks one.
route?: string;
system: string;
temperature: number;
topP: number;
Expand Down Expand Up @@ -72,6 +78,17 @@ export const api = {
return body.data ?? [];
},

// targets is the deployments the deployment pages list, each joined with what
// the gateway says about its route. One page of the largest size swissd
// serves: a Playground picker is not the place to page through releases.
targets: async (): Promise<Target[]> => {
const [deployments, routes] = await Promise.all([
swiss.deployments(1, 100),
request<{ routes?: RouteModels[] }>("GET", `${GATEWAY}/routes`),
]);
return joinTargets(deployments.deployments ?? [], routes.routes ?? []);
},

// streamChat posts one turn and reports deltas as they arrive. The
// conversation id rides X-Session-Id, which the gateway pins to a backend:
// every turn of a conversation hits the same engine, so its prefix cache is
Expand All @@ -91,7 +108,11 @@ export const api = {
try {
res = await apiFetch(`${BASE}/chat/completions`, {
method: "POST",
headers: { "Content-Type": "application/json", "X-Session-Id": sessionId },
headers: {
"Content-Type": "application/json",
"X-Session-Id": sessionId,
...(params.route ? { "X-ModelSphere-Route": params.route } : {}),
},
body: JSON.stringify(payload),
signal,
});
Expand Down
Loading
Loading