From cd49684b0edbe18d86830e107378eed5978304b5 Mon Sep 17 00:00:00 2001 From: Mathieu Virbel Date: Thu, 8 Oct 2026 10:21:32 -0600 Subject: [PATCH 01/12] Store several targets per alias --- internal/catalog/catalog.go | 5 +- internal/store/migrate.go | 2 + internal/store/migrate_test.go | 2 +- internal/store/providers.go | 337 +++++++++++++++++++++++++++------ internal/store/schema.go | 19 +- internal/store/targets_test.go | 233 +++++++++++++++++++++++ internal/store/types.go | 23 ++- internal/store/usage.go | 14 +- 8 files changed, 566 insertions(+), 69 deletions(-) create mode 100644 internal/store/targets_test.go diff --git a/internal/catalog/catalog.go b/internal/catalog/catalog.go index 2793b99..1b18649 100644 --- a/internal/catalog/catalog.go +++ b/internal/catalog/catalog.go @@ -177,10 +177,11 @@ func (c *Catalog) lookup(ctx context.Context, alias string) (*Route, error) { } func (c *Catalog) load(ctx context.Context, alias string) (*Route, error) { - binding, provider, overrides, err := c.store.ResolveAlias(ctx, alias) - if err != nil || binding == nil || provider == nil { + binding, targets, err := c.store.ResolveAlias(ctx, alias) + if err != nil || binding == nil { return nil, err } + binding, provider, overrides := targets[0].Binding, targets[0].Provider, targets[0].Overrides credential := "" if provider.CredentialCiphertext.Valid && provider.CredentialCiphertext.String != "" { credential, err = secrets.DecryptCredential(c.secret, provider.CredentialCiphertext.String) diff --git a/internal/store/migrate.go b/internal/store/migrate.go index 2ae6bda..a668432 100644 --- a/internal/store/migrate.go +++ b/internal/store/migrate.go @@ -82,6 +82,8 @@ var migrations = []migration{ addColumn("009_model_binding_hidden", "model_binding", "hidden", "INTEGER NOT NULL DEFAULT 0"), addColumn("010_provider_rate_limit_headers", "provider", "rate_limit_headers", "TEXT NOT NULL DEFAULT ''"), addColumn("011_usage_event_ttft_ms", "usage_event", "ttft_ms", "INTEGER"), + addColumn("012_model_binding_strategy", "model_binding", "strategy", "TEXT NOT NULL DEFAULT ''"), + addColumn("013_usage_event_failed_over", "usage_event", "failed_over", "INTEGER NOT NULL DEFAULT 0"), } // migrate applies every pending migration in order. Each runs in its own diff --git a/internal/store/migrate_test.go b/internal/store/migrate_test.go index 339a3b8..78f768e 100644 --- a/internal/store/migrate_test.go +++ b/internal/store/migrate_test.go @@ -75,7 +75,7 @@ func TestMigrateUpgradesLegacyDump(t *testing.T) { t.Fatalf("begin: %v", err) } defer func() { _ = tx.Rollback() }() - for _, col := range []string{"client", "tags", "ttft_ms"} { + for _, col := range []string{"client", "tags", "ttft_ms", "failed_over"} { ok, err := st.columnExists(ctx, tx, "usage_event", col) if err != nil { t.Fatalf("columnExists %s: %v", col, err) diff --git a/internal/store/providers.go b/internal/store/providers.go index 00234a9..d03f2d7 100644 --- a/internal/store/providers.go +++ b/internal/store/providers.go @@ -111,6 +111,9 @@ func (s *Store) DeleteProvider(ctx context.Context, providerID string, audit *Au return err } defer tx.Rollback() + if err := s.dropProviderTargetsTx(ctx, tx, providerID); err != nil { + return err + } for _, stmt := range []string{ // Aliases of this provider's models go with them; leaving them would // strand rows pointing at nothing. @@ -161,7 +164,7 @@ const bindingColumns = `b.id, b.alias, COALESCE(t.provider_id, b.provider_id), COALESCE(t.upstream_name, b.upstream_name), COALESCE(t.capability_set, b.capability_set), b.origin, b.discovered_at, b.created_at, b.target_id, COALESCE(t.alias, ''), p.name, - b.hidden` + b.hidden, b.strategy` func scanBinding(row interface{ Scan(...any) error }) (*ModelBinding, error) { var b ModelBinding @@ -170,7 +173,7 @@ func scanBinding(row interface{ Scan(...any) error }) (*ModelBinding, error) { var hidden int64 err := row.Scan(&b.ID, &b.Alias, &b.ProviderID, &b.UpstreamName, &b.CapabilitySet, &b.Origin, &b.DiscoveredAt, &b.CreatedAt, - &b.TargetID, &b.TargetAlias, &b.ProviderName, &hidden) + &b.TargetID, &b.TargetAlias, &b.ProviderName, &hidden, &b.Strategy) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, nil @@ -178,76 +181,130 @@ func scanBinding(row interface{ Scan(...any) error }) (*ModelBinding, error) { return nil, err } b.Hidden = hidden != 0 + if b.TargetID.Valid { + b.Targets = []BindingTarget{{ID: b.TargetID.String, Alias: b.TargetAlias, Weight: 1}} + } return &b, nil } +// targetsWhere selects bindings with a listed target matching cond, which +// reads columns of the target binding t2 and its provider p2. +func targetsWhere(cond string) string { + return `EXISTS (SELECT 1 FROM model_binding_target bt + JOIN model_binding t2 ON t2.id = bt.target_id + JOIN provider p2 ON p2.id = t2.provider_id + WHERE bt.binding_id = b.id AND ` + cond + `)` +} + +// attachTargets fills Targets for aliases stored in the target table. +func (s *Store) attachTargets(ctx context.Context, bindings []*ModelBinding) error { + multi := map[string]*ModelBinding{} + var ids []any + for _, b := range bindings { + if b != nil && b.Strategy != "" { + multi[b.ID] = b + b.Targets = nil + ids = append(ids, b.ID) + } + } + if len(ids) == 0 { + return nil + } + rows, err := s.db.QueryContext(ctx, s.q(` + SELECT bt.binding_id, bt.target_id, t.alias, bt.weight + FROM model_binding_target bt JOIN model_binding t ON t.id = bt.target_id + WHERE bt.binding_id IN (`+placeholders(len(ids))+`) + ORDER BY bt.binding_id, bt.position`), ids...) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var bindingID string + var t BindingTarget + if err := rows.Scan(&bindingID, &t.ID, &t.Alias, &t.Weight); err != nil { + return err + } + multi[bindingID].Targets = append(multi[bindingID].Targets, t) + } + return rows.Err() +} + +func (s *Store) getBinding(ctx context.Context, where string, arg any) (*ModelBinding, error) { + row := s.db.QueryRowContext(ctx, s.q(`SELECT `+bindingColumns+bindingSource+where), arg) + b, err := scanBinding(row) + if err != nil || b == nil { + return nil, err + } + return b, s.attachTargets(ctx, []*ModelBinding{b}) +} + func (s *Store) GetBindingByID(ctx context.Context, id string) (*ModelBinding, error) { - row := s.db.QueryRowContext(ctx, s.q(`SELECT `+bindingColumns+bindingSource+` WHERE b.id = ?`), id) - return scanBinding(row) + return s.getBinding(ctx, ` WHERE b.id = ?`, id) } func (s *Store) GetBindingByAlias(ctx context.Context, alias string) (*ModelBinding, error) { - row := s.db.QueryRowContext(ctx, s.q(`SELECT `+bindingColumns+bindingSource+` WHERE b.alias = ?`), alias) - return scanBinding(row) + return s.getBinding(ctx, ` WHERE b.alias = ?`, alias) } -func (s *Store) ListBindings(ctx context.Context, providerName string, limit, offset int) ([]ModelBinding, error) { - query := `SELECT ` + bindingColumns + bindingSource - var args []any - if providerName != "" { - query += ` WHERE p.name = ?` - args = append(args, providerName) - } - query += ` ORDER BY b.alias LIMIT ? OFFSET ?` - args = append(args, limit, offset) +func (s *Store) listBindings(ctx context.Context, query string, args ...any) ([]ModelBinding, error) { rows, err := s.db.QueryContext(ctx, s.q(query), args...) if err != nil { return nil, err } - defer rows.Close() var out []ModelBinding for rows.Next() { b, err := scanBinding(rows) if err != nil { + rows.Close() return nil, err } out = append(out, *b) } - return out, rows.Err() + rows.Close() + if err := rows.Err(); err != nil { + return nil, err + } + ptrs := make([]*ModelBinding, len(out)) + for i := range out { + ptrs[i] = &out[i] + } + return out, s.attachTargets(ctx, ptrs) +} + +func (s *Store) ListBindings(ctx context.Context, providerName string, limit, offset int) ([]ModelBinding, error) { + query := `SELECT ` + bindingColumns + bindingSource + var args []any + if providerName != "" { + query += ` WHERE (p.name = ? OR ` + targetsWhere(`p2.name = ?`) + `)` + args = append(args, providerName, providerName) + } + query += ` ORDER BY b.alias LIMIT ? OFFSET ?` + args = append(args, limit, offset) + return s.listBindings(ctx, query, args...) } // ListServableBindings returns bindings on enabled providers, for the // caller-facing model list. An alias is servable when its target's provider -// is enabled, which the resolved join already accounts for. Hidden rows are +// is enabled, which the resolved join already accounts for, or with several +// targets when any target's provider is enabled. Hidden rows are // left out unless includeHidden asks for them; they stay servable either way, // hidden is a listing flag only. func (s *Store) ListServableBindings(ctx context.Context, includeHidden bool) ([]ModelBinding, error) { - where := ` WHERE p.enabled = 1` + where := ` WHERE ((b.strategy = '' AND p.enabled = 1) OR ` + targetsWhere(`p2.enabled = 1`) + `)` if !includeHidden { where += ` AND b.hidden = 0` } - rows, err := s.db.QueryContext(ctx, - s.q(`SELECT `+bindingColumns+bindingSource+where+` ORDER BY b.alias`)) - if err != nil { - return nil, err - } - defer rows.Close() - var out []ModelBinding - for rows.Next() { - b, err := scanBinding(rows) - if err != nil { - return nil, err - } - out = append(out, *b) - } - return out, rows.Err() + return s.listBindings(ctx, `SELECT `+bindingColumns+bindingSource+where+` ORDER BY b.alias`) } // ListBindingsTargeting returns the aliases pointing at a binding, so a // delete can refuse to strand them. func (s *Store) ListBindingsTargeting(ctx context.Context, bindingID string) ([]string, error) { rows, err := s.db.QueryContext(ctx, - s.q(`SELECT alias FROM model_binding WHERE target_id = ? ORDER BY alias`), bindingID) + s.q(`SELECT alias FROM model_binding WHERE target_id = ? + UNION SELECT b.alias FROM model_binding_target bt JOIN model_binding b ON b.id = bt.binding_id + WHERE bt.target_id = ? ORDER BY 1`), bindingID, bindingID) if err != nil { return nil, err } @@ -263,30 +320,157 @@ func (s *Store) ListBindingsTargeting(ctx context.Context, bindingID string) ([] return out, rows.Err() } +// prepareTargets picks the storage form for an alias: one target goes in +// target_id, several go in model_binding_target with a strategy. An alias +// borrows its first target's provider id to satisfy the reference. +func (s *Store) prepareTargets(ctx context.Context, tx *sql.Tx, b *ModelBinding) error { + switch { + case len(b.Targets) == 1: + b.TargetID = sql.NullString{String: b.Targets[0].ID, Valid: true} + b.Strategy = "" + case len(b.Targets) > 1: + b.TargetID = sql.NullString{} + if b.Strategy == "" { + b.Strategy = "failover" + } + for i := range b.Targets { + if b.Targets[i].Weight < 1 { + b.Targets[i].Weight = 1 + } + } + default: + b.Strategy = "" + } + first := b.TargetID.String + if len(b.Targets) > 1 { + first = b.Targets[0].ID + } + if first == "" { + return nil + } + return tx.QueryRowContext(ctx, s.q(`SELECT provider_id FROM model_binding WHERE id = ?`), first). + Scan(&b.ProviderID) +} + // ownRouting returns the columns a row stores for itself. An alias keeps none -// of its own: it borrows the target's provider id only to satisfy the -// reference, and reads everything through the join. +// of its own and reads everything through the join or the target table. func ownRouting(b *ModelBinding) (providerID, upstreamName, capabilitySet string) { - if b.TargetID.Valid { + if b.TargetID.Valid || b.Strategy != "" { return b.ProviderID, "", "" } return b.ProviderID, b.UpstreamName, b.CapabilitySet } +func (s *Store) writeTargetsTx(ctx context.Context, tx *sql.Tx, b *ModelBinding) error { + if _, err := tx.ExecContext(ctx, s.q(`DELETE FROM model_binding_target WHERE binding_id = ?`), b.ID); err != nil { + return err + } + if b.Strategy == "" { + return nil + } + for i, t := range b.Targets { + if _, err := tx.ExecContext(ctx, s.q(` + INSERT INTO model_binding_target (binding_id, target_id, position, weight) VALUES (?, ?, ?, ?)`), + b.ID, t.ID, i, t.Weight); err != nil { + return err + } + } + return nil +} + +// dropProviderTargetsTx removes a provider's models from aliases with several +// targets. An alias left with one target becomes a plain alias, with none it +// is deleted. +func (s *Store) dropProviderTargetsTx(ctx context.Context, tx *sql.Tx, providerID string) error { + rows, err := tx.QueryContext(ctx, s.q(` + SELECT DISTINCT bt.binding_id FROM model_binding_target bt + JOIN model_binding t ON t.id = bt.target_id WHERE t.provider_id = ?`), providerID) + if err != nil { + return err + } + var affected []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + rows.Close() + return err + } + affected = append(affected, id) + } + rows.Close() + if err := rows.Err(); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, s.q(`DELETE FROM model_binding_target + WHERE target_id IN (SELECT id FROM model_binding WHERE provider_id = ?)`), providerID); err != nil { + return err + } + for _, id := range affected { + var remaining []string + rows, err := tx.QueryContext(ctx, s.q(` + SELECT target_id FROM model_binding_target WHERE binding_id = ? ORDER BY position`), id) + if err != nil { + return err + } + for rows.Next() { + var t string + if err := rows.Scan(&t); err != nil { + rows.Close() + return err + } + remaining = append(remaining, t) + } + rows.Close() + if err := rows.Err(); err != nil { + return err + } + var stmts []string + var args [][]any + switch len(remaining) { + case 0: + stmts = []string{`DELETE FROM model_binding WHERE id = ?`} + args = [][]any{{id}} + case 1: + stmts = []string{ + `DELETE FROM model_binding_target WHERE binding_id = ?`, + `UPDATE model_binding SET strategy = '', target_id = ?, + provider_id = (SELECT provider_id FROM model_binding WHERE id = ?) WHERE id = ?`, + } + args = [][]any{{id}, {remaining[0], remaining[0], id}} + default: + stmts = []string{`UPDATE model_binding SET + provider_id = (SELECT provider_id FROM model_binding WHERE id = ?) WHERE id = ?`} + args = [][]any{{remaining[0], id}} + } + for i, stmt := range stmts { + if _, err := tx.ExecContext(ctx, s.q(stmt), args[i]...); err != nil { + return err + } + } + } + return nil +} + func (s *Store) CreateBinding(ctx context.Context, b *ModelBinding, audit *Audit) error { b.ID = secrets.NewID() b.CreatedAt = Now() - providerID, upstreamName, capabilitySet := ownRouting(b) tx, err := s.db.BeginTx(ctx, nil) if err != nil { return err } defer tx.Rollback() + if err := s.prepareTargets(ctx, tx, b); err != nil { + return err + } + providerID, upstreamName, capabilitySet := ownRouting(b) if _, err := tx.ExecContext(ctx, s.q(` - INSERT INTO model_binding (id, alias, provider_id, upstream_name, capability_set, origin, discovered_at, created_at, target_id, hidden) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`), + INSERT INTO model_binding (id, alias, provider_id, upstream_name, capability_set, origin, discovered_at, created_at, target_id, hidden, strategy) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`), b.ID, b.Alias, providerID, upstreamName, - capabilitySet, b.Origin, b.DiscoveredAt, b.CreatedAt, b.TargetID, boolInt(b.Hidden)); err != nil { + capabilitySet, b.Origin, b.DiscoveredAt, b.CreatedAt, b.TargetID, boolInt(b.Hidden), b.Strategy); err != nil { + return err + } + if err := s.writeTargetsTx(ctx, tx, b); err != nil { return err } if err := s.auditTx(ctx, tx, audit); err != nil { @@ -301,11 +485,17 @@ func (s *Store) UpdateBinding(ctx context.Context, b *ModelBinding, audit *Audit return err } defer tx.Rollback() + if err := s.prepareTargets(ctx, tx, b); err != nil { + return err + } providerID, upstreamName, capabilitySet := ownRouting(b) if _, err := tx.ExecContext(ctx, s.q(` UPDATE model_binding SET alias = ?, provider_id = ?, upstream_name = ?, capability_set = ?, - target_id = ?, hidden = ? WHERE id = ?`), - b.Alias, providerID, upstreamName, capabilitySet, b.TargetID, boolInt(b.Hidden), b.ID); err != nil { + target_id = ?, hidden = ?, strategy = ? WHERE id = ?`), + b.Alias, providerID, upstreamName, capabilitySet, b.TargetID, boolInt(b.Hidden), b.Strategy, b.ID); err != nil { + return err + } + if err := s.writeTargetsTx(ctx, tx, b); err != nil { return err } if err := s.auditTx(ctx, tx, audit); err != nil { @@ -320,9 +510,13 @@ func (s *Store) DeleteBinding(ctx context.Context, bindingID string, audit *Audi return err } defer tx.Rollback() - if _, err := tx.ExecContext(ctx, - s.q(`DELETE FROM model_binding WHERE id = ?`), bindingID); err != nil { - return err + for _, stmt := range []string{ + `DELETE FROM model_binding_target WHERE binding_id = ?`, + `DELETE FROM model_binding WHERE id = ?`, + } { + if _, err := tx.ExecContext(ctx, s.q(stmt), bindingID); err != nil { + return err + } } if err := s.auditTx(ctx, tx, audit); err != nil { return err @@ -330,23 +524,54 @@ func (s *Store) DeleteBinding(ctx context.Context, bindingID string, audit *Audi return tx.Commit() } -// ResolveAlias loads the routing row for an alias on an enabled provider, or -// nil when there is none. -func (s *Store) ResolveAlias(ctx context.Context, alias string) (*ModelBinding, *Provider, map[string]string, error) { +// ResolveAlias loads an alias and its servable targets in order: the binding +// itself when it routes through one hop or less, each listed target on an +// enabled provider otherwise. A nil binding means there is nothing to serve. +func (s *Store) ResolveAlias(ctx context.Context, alias string) (*ModelBinding, []ResolvedTarget, error) { binding, err := s.GetBindingByAlias(ctx, alias) if err != nil || binding == nil { - return nil, nil, nil, err + return nil, nil, err + } + var out []ResolvedTarget + if binding.Strategy == "" { + t, err := s.resolveTarget(ctx, binding, 1) + if err != nil || t == nil { + return nil, nil, err + } + return binding, []ResolvedTarget{*t}, nil } + for _, bt := range binding.Targets { + target, err := s.GetBindingByID(ctx, bt.ID) + if err != nil { + return nil, nil, err + } + if target == nil { + continue + } + t, err := s.resolveTarget(ctx, target, bt.Weight) + if err != nil { + return nil, nil, err + } + if t != nil { + out = append(out, *t) + } + } + if len(out) == 0 { + return nil, nil, nil + } + return binding, out, nil +} + +func (s *Store) resolveTarget(ctx context.Context, b *ModelBinding, weight int) (*ResolvedTarget, error) { row := s.db.QueryRowContext(ctx, - s.q(`SELECT `+providerColumns+` FROM provider WHERE id = ? AND enabled = 1`), - binding.ProviderID) + s.q(`SELECT `+providerColumns+` FROM provider WHERE id = ? AND enabled = 1`), b.ProviderID) provider, err := scanProvider(row) if err != nil || provider == nil { - return nil, nil, nil, err + return nil, err } overrides, err := s.ListEndpointOverrides(ctx, provider.ID) if err != nil { - return nil, nil, nil, err + return nil, err } - return binding, provider, overrides, nil + return &ResolvedTarget{Binding: b, Provider: provider, Overrides: overrides, Weight: weight}, nil } diff --git a/internal/store/schema.go b/internal/store/schema.go index ad7dd53..1d83c3b 100644 --- a/internal/store/schema.go +++ b/internal/store/schema.go @@ -82,6 +82,8 @@ CREATE TABLE IF NOT EXISTS provider_endpoint ( -- which is enforced at write time. An alias row inherits its target's -- provider, upstream name and capabilities, so its own upstream_name and -- capability_set are empty and provider_id only satisfies the reference. +-- An alias with several targets leaves target_id empty, lists them in +-- model_binding_target, and sets strategy. One target means no strategy. CREATE TABLE IF NOT EXISTS model_binding ( id TEXT PRIMARY KEY, alias TEXT NOT NULL UNIQUE, @@ -92,15 +94,25 @@ CREATE TABLE IF NOT EXISTS model_binding ( discovered_at TEXT, created_at TEXT NOT NULL, target_id TEXT REFERENCES model_binding(id), - hidden INTEGER NOT NULL DEFAULT 0 + hidden INTEGER NOT NULL DEFAULT 0, + strategy TEXT NOT NULL DEFAULT '' ); +CREATE TABLE IF NOT EXISTS model_binding_target ( + binding_id TEXT NOT NULL REFERENCES model_binding(id), + target_id TEXT NOT NULL REFERENCES model_binding(id), + position INTEGER NOT NULL, + weight INTEGER NOT NULL DEFAULT 1, + PRIMARY KEY (binding_id, target_id) +); +CREATE INDEX IF NOT EXISTS idx_model_binding_target_target ON model_binding_target(target_id); -- client is the caller's User-Agent header, truncated. tags is the caller's -- x-llmproxy-tags header, normalised to a canonical comma-separated -- "key:value" list. Both are header metadata only, never request or response -- content. error_kind is a classification token: the proxy's transport error -- class on unreachable, the upstream's error type/code (sanitised to a short -- identifier charset) on upstream_error. Never an error message: provider --- messages can echo request content. +-- messages can echo request content. failed_over marks an attempt that failed +-- before another alias target was tried. Request counts exclude it. CREATE TABLE IF NOT EXISTS usage_event ( id TEXT PRIMARY KEY, ts TEXT NOT NULL, @@ -120,7 +132,8 @@ CREATE TABLE IF NOT EXISTS usage_event ( cost DOUBLE PRECISION, unpriced INTEGER NOT NULL DEFAULT 0, duration_ms INTEGER NOT NULL DEFAULT 0, - ttft_ms INTEGER + ttft_ms INTEGER, + failed_over INTEGER NOT NULL DEFAULT 0 ); CREATE INDEX IF NOT EXISTS idx_usage_event_ts ON usage_event(ts); CREATE INDEX IF NOT EXISTS idx_usage_event_principal ON usage_event(principal_id); diff --git a/internal/store/targets_test.go b/internal/store/targets_test.go new file mode 100644 index 0000000..f3c3708 --- /dev/null +++ b/internal/store/targets_test.go @@ -0,0 +1,233 @@ +package store_test + +import ( + "context" + "database/sql" + "testing" + + "github.com/greyhavenhq/llmproxy/internal/store" +) + +type targetFixture struct { + st *store.Store + pa, pb *store.Provider + a, b *store.ModelBinding +} + +func newTargetFixture(t *testing.T) *targetFixture { + t.Helper() + ctx := context.Background() + f := &targetFixture{st: openTestStore(t)} + mk := func(name string) *store.Provider { + p := &store.Provider{Name: name, WireFormat: "openai", BaseURL: "https://" + name, + VerifyTLS: true, TimeoutConnect: 5, TimeoutRead: 30, Enabled: true} + if err := f.st.CreateProvider(ctx, p, nil, nil); err != nil { + t.Fatal(err) + } + return p + } + f.pa, f.pb = mk("pa"), mk("pb") + bind := func(alias string, p *store.Provider, caps string) *store.ModelBinding { + b := &store.ModelBinding{Alias: alias, ProviderID: p.ID, UpstreamName: "up-" + alias, + CapabilitySet: caps, Origin: "declared"} + if err := f.st.CreateBinding(ctx, b, nil); err != nil { + t.Fatal(err) + } + return b + } + f.a = bind("a", f.pa, "chat,chat_stream,embeddings") + f.b = bind("b", f.pb, "chat,chat_stream") + return f +} + +func (f *targetFixture) alias(t *testing.T, name string) *store.ModelBinding { + t.Helper() + b, err := f.st.GetBindingByAlias(context.Background(), name) + if err != nil || b == nil { + t.Fatalf("get %s: %v %v", name, b, err) + } + return b +} + +func TestAliasTargetsRoundTrip(t *testing.T) { + ctx := context.Background() + f := newTargetFixture(t) + ha := &store.ModelBinding{Alias: "ha", Origin: "declared", Strategy: "weighted", + Targets: []store.BindingTarget{{ID: f.b.ID, Weight: 3}, {ID: f.a.ID, Weight: 1}}} + if err := f.st.CreateBinding(ctx, ha, nil); err != nil { + t.Fatal(err) + } + got := f.alias(t, "ha") + if got.Strategy != "weighted" || len(got.Targets) != 2 { + t.Fatalf("got %+v", got) + } + if got.Targets[0].Alias != "b" || got.Targets[0].Weight != 3 || got.Targets[1].Alias != "a" { + t.Fatalf("targets = %+v", got.Targets) + } + if got.TargetID.Valid { + t.Fatal("multi-target alias must not set target_id") + } + + list, err := f.st.ListBindings(ctx, "", 100, 0) + if err != nil { + t.Fatal(err) + } + for _, b := range list { + if b.Alias == "ha" && len(b.Targets) != 2 { + t.Fatalf("list targets = %+v", b.Targets) + } + } + for _, p := range []string{"pa", "pb"} { + byProvider, err := f.st.ListBindings(ctx, p, 100, 0) + if err != nil { + t.Fatal(err) + } + found := false + for _, b := range byProvider { + found = found || b.Alias == "ha" + } + if !found { + t.Fatalf("ha not listed under provider %s", p) + } + } +} + +func TestAliasSwitchBetweenOneAndSeveralTargets(t *testing.T) { + ctx := context.Background() + f := newTargetFixture(t) + ha := &store.ModelBinding{Alias: "ha", Origin: "declared", + Targets: []store.BindingTarget{{ID: f.a.ID}}} + if err := f.st.CreateBinding(ctx, ha, nil); err != nil { + t.Fatal(err) + } + got := f.alias(t, "ha") + if !got.TargetID.Valid || got.TargetID.String != f.a.ID || got.Strategy != "" { + t.Fatalf("one target: %+v", got) + } + if len(got.Targets) != 1 || got.Targets[0].Alias != "a" || got.UpstreamName != "up-a" { + t.Fatalf("one target read: %+v", got) + } + + got.Targets = []store.BindingTarget{{ID: f.a.ID}, {ID: f.b.ID}} + got.Strategy = "round_robin" + if err := f.st.UpdateBinding(ctx, got, nil); err != nil { + t.Fatal(err) + } + got = f.alias(t, "ha") + if got.TargetID.Valid || got.Strategy != "round_robin" || len(got.Targets) != 2 { + t.Fatalf("two targets: %+v", got) + } + if got.Targets[0].Weight != 1 { + t.Fatalf("default weight = %d", got.Targets[0].Weight) + } + + got.Targets = []store.BindingTarget{{ID: f.b.ID}} + got.Strategy = "" + if err := f.st.UpdateBinding(ctx, got, nil); err != nil { + t.Fatal(err) + } + got = f.alias(t, "ha") + if !got.TargetID.Valid || got.TargetID.String != f.b.ID || got.Strategy != "" || len(got.Targets) != 1 { + t.Fatalf("back to one: %+v", got) + } + if got.UpstreamName != "up-b" || got.ProviderName != "pb" { + t.Fatalf("one target routing: %+v", got) + } +} + +func TestDeleteGuardSeesSeveralTargets(t *testing.T) { + ctx := context.Background() + f := newTargetFixture(t) + ha := &store.ModelBinding{Alias: "ha", Origin: "declared", Strategy: "failover", + Targets: []store.BindingTarget{{ID: f.a.ID}, {ID: f.b.ID}}} + if err := f.st.CreateBinding(ctx, ha, nil); err != nil { + t.Fatal(err) + } + one := &store.ModelBinding{Alias: "one", Origin: "declared", TargetID: sql.NullString{String: f.b.ID, Valid: true}} + if err := f.st.CreateBinding(ctx, one, nil); err != nil { + t.Fatal(err) + } + got, err := f.st.ListBindingsTargeting(ctx, f.b.ID) + if err != nil { + t.Fatal(err) + } + if len(got) != 2 || got[0] != "ha" || got[1] != "one" { + t.Fatalf("targeting = %v", got) + } +} + +func TestResolveAliasSeveralTargets(t *testing.T) { + ctx := context.Background() + f := newTargetFixture(t) + ha := &store.ModelBinding{Alias: "ha", Origin: "declared", Strategy: "failover", + Targets: []store.BindingTarget{{ID: f.b.ID}, {ID: f.a.ID, Weight: 2}}} + if err := f.st.CreateBinding(ctx, ha, nil); err != nil { + t.Fatal(err) + } + b, targets, err := f.st.ResolveAlias(ctx, "ha") + if err != nil || b == nil { + t.Fatalf("resolve: %v %v", b, err) + } + if b.Strategy != "failover" || len(targets) != 2 { + t.Fatalf("resolved %+v %d", b, len(targets)) + } + if targets[0].Binding.Alias != "b" || targets[0].Provider.Name != "pb" || targets[1].Weight != 2 { + t.Fatalf("targets[0] = %+v", targets[0]) + } + + f.pb.Enabled = false + if err := f.st.UpdateProvider(ctx, f.pb, nil); err != nil { + t.Fatal(err) + } + _, targets, err = f.st.ResolveAlias(ctx, "ha") + if err != nil || len(targets) != 1 || targets[0].Binding.Alias != "a" { + t.Fatalf("disabled provider target not dropped: %+v %v", targets, err) + } + servable, err := f.st.ListServableBindings(ctx, false) + if err != nil { + t.Fatal(err) + } + found := false + for _, s := range servable { + found = found || s.Alias == "ha" + } + if !found { + t.Fatal("ha must stay servable while one target provider is enabled") + } + + _, direct, err := f.st.ResolveAlias(ctx, "a") + if err != nil || len(direct) != 1 || direct[0].Binding.UpstreamName != "up-a" { + t.Fatalf("direct resolve: %+v %v", direct, err) + } +} + +func TestDeleteProviderShrinksAliasTargets(t *testing.T) { + ctx := context.Background() + f := newTargetFixture(t) + ha := &store.ModelBinding{Alias: "ha", Origin: "declared", Strategy: "failover", + Targets: []store.BindingTarget{{ID: f.a.ID}, {ID: f.b.ID}}} + if err := f.st.CreateBinding(ctx, ha, nil); err != nil { + t.Fatal(err) + } + if err := f.st.DeleteProvider(ctx, f.pa.ID, nil); err != nil { + t.Fatal(err) + } + got := f.alias(t, "ha") + if got.Strategy != "" || !got.TargetID.Valid || got.TargetID.String != f.b.ID || got.ProviderName != "pb" { + t.Fatalf("after provider delete: %+v", got) + } +} + +func TestUsageEventFailedOverRoundTrip(t *testing.T) { + ctx := context.Background() + st := openTestStore(t) + ev := &store.UsageEvent{PrincipalID: "p", APIKeyID: "k", ProviderID: "x", Alias: "ha", + UpstreamName: "u", Endpoint: "chat", Outcome: "unreachable", FailedOver: true} + if err := st.InsertUsageEvent(ctx, ev, nil); err != nil { + t.Fatal(err) + } + evs, err := st.ListUsageEvents(ctx) + if err != nil || len(evs) != 1 || !evs[0].FailedOver { + t.Fatalf("events = %+v %v", evs, err) + } +} diff --git a/internal/store/types.go b/internal/store/types.go index 011f330..f90bab9 100644 --- a/internal/store/types.go +++ b/internal/store/types.go @@ -97,6 +97,26 @@ type ModelBinding struct { // TargetAlias is empty for a direct binding. TargetAlias string ProviderName string + // Strategy is set only on an alias with two or more targets. + Strategy string + // Targets lists an alias's targets in order, empty for a direct binding. + // On write, two or more targets use the target table, one sets TargetID. + Targets []BindingTarget +} + +type BindingTarget struct { + ID string + Alias string + Weight int +} + +// ResolvedTarget is one servable target of an alias, or the binding itself +// when it routes directly. +type ResolvedTarget struct { + Binding *ModelBinding + Provider *Provider + Overrides map[string]string + Weight int } type UsageEvent struct { @@ -128,7 +148,8 @@ type UsageEvent struct { DurationMs int64 // TTFTMs is the time to the first content event of a stream. Null on // unary requests and on streams that ended before any content. - TTFTMs sql.NullInt64 + TTFTMs sql.NullInt64 + FailedOver bool } type UsageQuantity struct { diff --git a/internal/store/usage.go b/internal/store/usage.go index b5b8019..8e27ffe 100644 --- a/internal/store/usage.go +++ b/internal/store/usage.go @@ -23,11 +23,11 @@ func (s *Store) InsertUsageEvent(ctx context.Context, ev *UsageEvent, quantities defer tx.Rollback() if _, err := tx.ExecContext(ctx, s.q(` INSERT INTO usage_event (id, ts, principal_id, api_key_id, provider_id, alias, upstream_name, - endpoint, client, tags, status_code, outcome, error_kind, cancelled, streamed, cost, unpriced, duration_ms, ttft_ms) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`), + endpoint, client, tags, status_code, outcome, error_kind, cancelled, streamed, cost, unpriced, duration_ms, ttft_ms, failed_over) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`), ev.ID, ev.TS, ev.PrincipalID, ev.APIKeyID, ev.ProviderID, ev.Alias, ev.UpstreamName, ev.Endpoint, ev.Client, ev.Tags, ev.StatusCode, ev.Outcome, ev.ErrorKind, boolInt(ev.Cancelled), boolInt(ev.Streamed), - ev.Cost, boolInt(ev.Unpriced), ev.DurationMs, ev.TTFTMs); err != nil { + ev.Cost, boolInt(ev.Unpriced), ev.DurationMs, ev.TTFTMs, boolInt(ev.FailedOver)); err != nil { return err } for _, q := range quantities { @@ -45,7 +45,8 @@ func (s *Store) InsertUsageEvent(ctx context.Context, ev *UsageEvent, quantities func (s *Store) ListUsageEvents(ctx context.Context) ([]UsageEvent, error) { rows, err := s.db.QueryContext(ctx, s.q(` SELECT id, ts, principal_id, api_key_id, provider_id, alias, upstream_name, endpoint, - client, tags, status_code, outcome, error_kind, cancelled, streamed, cost, unpriced, duration_ms, ttft_ms + client, tags, status_code, outcome, error_kind, cancelled, streamed, cost, unpriced, duration_ms, ttft_ms, + failed_over FROM usage_event ORDER BY ts`)) if err != nil { return nil, err @@ -54,12 +55,13 @@ func (s *Store) ListUsageEvents(ctx context.Context) ([]UsageEvent, error) { var out []UsageEvent for rows.Next() { var ev UsageEvent - var cancelled, streamed, unpriced int64 + var cancelled, streamed, unpriced, failedOver int64 if err := rows.Scan(&ev.ID, &ev.TS, &ev.PrincipalID, &ev.APIKeyID, &ev.ProviderID, &ev.Alias, &ev.UpstreamName, &ev.Endpoint, &ev.Client, &ev.Tags, &ev.StatusCode, &ev.Outcome, - &ev.ErrorKind, &cancelled, &streamed, &ev.Cost, &unpriced, &ev.DurationMs, &ev.TTFTMs); err != nil { + &ev.ErrorKind, &cancelled, &streamed, &ev.Cost, &unpriced, &ev.DurationMs, &ev.TTFTMs, &failedOver); err != nil { return nil, err } + ev.FailedOver = failedOver != 0 ev.Cancelled = cancelled != 0 ev.Streamed = streamed != 0 ev.Unpriced = unpriced != 0 From 12b8d58dbca6fc46d7a9f4f16eb33df9dc0e50d1 Mon Sep 17 00:00:00 2001 From: Mathieu Virbel Date: Thu, 8 Oct 2026 10:23:39 -0600 Subject: [PATCH 02/12] Resolve aliases to an ordered plan of routes --- internal/catalog/balance.go | 174 +++++++++++++++++++++++++++++++ internal/catalog/balance_test.go | 134 ++++++++++++++++++++++++ internal/catalog/catalog.go | 75 +++++++------ internal/server/admin.go | 8 +- internal/server/openai.go | 3 +- 5 files changed, 361 insertions(+), 33 deletions(-) create mode 100644 internal/catalog/balance.go create mode 100644 internal/catalog/balance_test.go diff --git a/internal/catalog/balance.go b/internal/catalog/balance.go new file mode 100644 index 0000000..c3aac44 --- /dev/null +++ b/internal/catalog/balance.go @@ -0,0 +1,174 @@ +package catalog + +import ( + "math/rand/v2" + "sync" + "time" +) + +// Strategies an alias with several targets may declare. +var Strategies = []string{"failover", "round_robin", "weighted", "least_busy"} + +func IsStrategy(name string) bool { + for _, s := range Strategies { + if s == name { + return true + } + } + return false +} + +// Plan lists the routes of one alias. Strategy is empty with one route. +type Plan struct { + Alias string + Strategy string + Routes []*Route +} + +// Capabilities is the intersection over every route. +func (p *Plan) Capabilities() map[string]bool { + out := map[string]bool{} + for cap := range p.Routes[0].Capabilities { + out[cap] = true + } + for _, r := range p.Routes[1:] { + for cap := range out { + if !r.Capabilities[cap] { + delete(out, cap) + } + } + } + return out +} + +// Balancer holds the per-replica rotation, in-flight and cooldown state. +type Balancer struct { + cooldown time.Duration + now func() time.Time + rand func(n int) int + + mu sync.Mutex + next map[string]uint64 + inflight map[string]int + cooledUntil map[string]time.Time +} + +func NewBalancer(cooldown time.Duration) *Balancer { + return &Balancer{ + cooldown: cooldown, + now: time.Now, + rand: rand.IntN, + next: map[string]uint64{}, + inflight: map[string]int{}, + cooledUntil: map[string]time.Time{}, + } +} + +// TargetKey names the model a route serves, whichever alias reaches it. +func (r *Route) TargetKey() string { + if r.TargetAlias != "" { + return r.TargetAlias + } + return r.Alias +} + +// Order puts the strategy's pick first and routes in cooldown last. +func (b *Balancer) Order(p *Plan) []*Route { + if len(p.Routes) < 2 { + return p.Routes + } + b.mu.Lock() + defer b.mu.Unlock() + start := b.start(p) + ordered := make([]*Route, 0, len(p.Routes)) + ordered = append(ordered, p.Routes[start]) + for i, r := range p.Routes { + if i != start { + ordered = append(ordered, r) + } + } + now := b.now() + ready := make([]*Route, 0, len(ordered)) + var cooled []*Route + for _, r := range ordered { + if until, ok := b.cooledUntil[r.TargetKey()]; ok && now.Before(until) { + cooled = append(cooled, r) + } else { + ready = append(ready, r) + } + } + if len(ready) == 0 { + return p.Routes + } + return append(ready, cooled...) +} + +func (b *Balancer) start(p *Plan) int { + switch p.Strategy { + case "round_robin": + n := b.next[p.Alias] + b.next[p.Alias] = n + 1 + return int(n % uint64(len(p.Routes))) + case "weighted": + total := 0 + for _, r := range p.Routes { + total += max(r.Weight, 1) + } + pick := b.rand(total) + for i, r := range p.Routes { + pick -= max(r.Weight, 1) + if pick < 0 { + return i + } + } + case "least_busy": + best := 0 + for i, r := range p.Routes { + if b.inflight[r.TargetKey()] < b.inflight[p.Routes[best].TargetKey()] { + best = i + } + } + return best + } + return 0 +} + +// Fail puts a route in cooldown. +func (b *Balancer) Fail(r *Route) { + if b.cooldown <= 0 { + return + } + b.mu.Lock() + b.cooledUntil[r.TargetKey()] = b.now().Add(b.cooldown) + b.mu.Unlock() +} + +// CoolingDown reports whether a route is in cooldown and until when. +func (b *Balancer) CoolingDown(r *Route) (bool, time.Time) { + b.mu.Lock() + defer b.mu.Unlock() + until, ok := b.cooledUntil[r.TargetKey()] + if !ok { + return false, time.Time{} + } + if !b.now().Before(until) { + delete(b.cooledUntil, r.TargetKey()) + return false, time.Time{} + } + return true, until +} + +// Acquire counts a request in flight until the returned func runs. +func (b *Balancer) Acquire(r *Route) func() { + key := r.TargetKey() + b.mu.Lock() + b.inflight[key]++ + b.mu.Unlock() + return func() { + b.mu.Lock() + if b.inflight[key]--; b.inflight[key] <= 0 { + delete(b.inflight, key) + } + b.mu.Unlock() + } +} diff --git a/internal/catalog/balance_test.go b/internal/catalog/balance_test.go new file mode 100644 index 0000000..c02da3e --- /dev/null +++ b/internal/catalog/balance_test.go @@ -0,0 +1,134 @@ +package catalog + +import ( + "testing" + "time" +) + +func testPlan(strategy string, names ...string) *Plan { + p := &Plan{Alias: "ha", Strategy: strategy} + for _, n := range names { + p.Routes = append(p.Routes, &Route{Alias: "ha", TargetAlias: n, ProviderName: n, Weight: 1}) + } + return p +} + +func names(routes []*Route) string { + out := "" + for _, r := range routes { + out += r.TargetAlias + } + return out +} + +func newTestBalancer(cooldown time.Duration) (*Balancer, *time.Time) { + now := time.Unix(1000, 0) + b := NewBalancer(cooldown) + b.now = func() time.Time { return now } + return b, &now +} + +func TestOrderFailoverKeepsListedOrder(t *testing.T) { + b, _ := newTestBalancer(time.Minute) + p := testPlan("failover", "a", "b", "c") + for i := 0; i < 3; i++ { + if got := names(b.Order(p)); got != "abc" { + t.Fatalf("order = %s", got) + } + } +} + +func TestOrderRoundRobinRotatesStart(t *testing.T) { + b, _ := newTestBalancer(time.Minute) + p := testPlan("round_robin", "a", "b", "c") + var got []string + for i := 0; i < 4; i++ { + got = append(got, names(b.Order(p))) + } + want := []string{"abc", "bac", "cab", "abc"} + for i := range want { + if got[i] != want[i] { + t.Fatalf("orders = %v, want %v", got, want) + } + } +} + +func TestOrderWeightedFollowsWeights(t *testing.T) { + b, _ := newTestBalancer(time.Minute) + p := testPlan("weighted", "a", "b") + p.Routes[0].Weight = 3 + p.Routes[1].Weight = 1 + picks := []int{0, 2, 3} + i := 0 + b.rand = func(n int) int { + if n != 4 { + t.Fatalf("rand range = %d, want total weight 4", n) + } + v := picks[i] + i++ + return v + } + for _, want := range []string{"ab", "ab", "ba"} { + if got := names(b.Order(p)); got != want { + t.Fatalf("order = %s, want %s", got, want) + } + } +} + +func TestOrderLeastBusyPicksFewestInFlight(t *testing.T) { + b, _ := newTestBalancer(time.Minute) + p := testPlan("least_busy", "a", "b", "c") + releaseA := b.Acquire(p.Routes[0]) + releaseB := b.Acquire(p.Routes[1]) + if got := names(b.Order(p)); got != "cab" { + t.Fatalf("order = %s", got) + } + releaseA() + releaseB() + if got := names(b.Order(p)); got != "abc" { + t.Fatalf("after release = %s", got) + } +} + +func TestOrderMovesCooledTargetsLast(t *testing.T) { + b, now := newTestBalancer(30 * time.Second) + p := testPlan("failover", "a", "b", "c") + b.Fail(p.Routes[0]) + if got := names(b.Order(p)); got != "bca" { + t.Fatalf("order = %s", got) + } + if cooled, _ := b.CoolingDown(p.Routes[0]); !cooled { + t.Fatal("a must be cooling down") + } + b.Fail(p.Routes[1]) + b.Fail(p.Routes[2]) + if got := names(b.Order(p)); got != "abc" { + t.Fatalf("all cooled = %s, want listed order", got) + } + *now = now.Add(31 * time.Second) + if got := names(b.Order(p)); got != "abc" { + t.Fatalf("after expiry = %s", got) + } + if cooled, _ := b.CoolingDown(p.Routes[0]); cooled { + t.Fatal("cooldown must expire") + } +} + +func TestZeroCooldownDisables(t *testing.T) { + b, _ := newTestBalancer(0) + p := testPlan("failover", "a", "b") + b.Fail(p.Routes[0]) + if got := names(b.Order(p)); got != "ab" { + t.Fatalf("order = %s", got) + } +} + +func TestPlanCapabilitiesIntersect(t *testing.T) { + p := testPlan("failover", "a", "b") + p.Routes[0].Capabilities = map[string]bool{"chat": true, "chat_stream": true, "embeddings": true} + p.Routes[1].Capabilities = map[string]bool{"chat": true, "chat_stream": true} + caps := p.Capabilities() + if !caps["chat"] || !caps["chat_stream"] || caps["embeddings"] { + t.Fatalf("caps = %v", caps) + } +} diff --git a/internal/catalog/catalog.go b/internal/catalog/catalog.go index 1b18649..86390fc 100644 --- a/internal/catalog/catalog.go +++ b/internal/catalog/catalog.go @@ -1,13 +1,7 @@ -// Package catalog: alias resolution. -// -// The one rule: a caller-facing model name is a globally unique alias mapping -// to exactly one (provider, upstream model) pair, so resolution can never be -// ambiguous; ambiguity is rejected at write time by the alias unique -// constraint. A name may reach that pair through one hop, by pointing at -// another binding, which the store resolves in the same query — so this stays -// a single lookup and a chain can never loop. Resolution takes the endpoint -// into account: the binding's provider must be enabled and the endpoint in -// the capability set. +// Package catalog resolves a caller-facing alias to an ordered plan of routes. +// Aliases are globally unique, so resolution is never ambiguous. An alias +// reaches its targets through at most one hop, and every route's provider is +// enabled. package catalog import ( @@ -65,6 +59,7 @@ type Route struct { TimeoutConnect time.Duration TimeoutRead time.Duration MaxConcurrency int + Weight int Alias string // TargetAlias is the alias this one points at, empty when the model // routes to a provider directly. Pricing falls through it. @@ -103,7 +98,7 @@ func boolStr(b bool) string { type cacheEntry struct { expires time.Time - route *Route // nil means "known absent" + plan *Plan // nil means "known absent" } type Catalog struct { @@ -125,12 +120,12 @@ func (c *Catalog) Invalidate() { c.mu.Unlock() } -func (c *Catalog) Resolve(ctx context.Context, alias, endpoint string, streaming bool) (*Route, *apierr.ProxyError) { - route, err := c.lookup(ctx, alias) +func (c *Catalog) Resolve(ctx context.Context, alias, endpoint string, streaming bool) (*Plan, *apierr.ProxyError) { + plan, err := c.lookup(ctx, alias) if err != nil { return nil, apierr.New(500, "internal_error", "catalog lookup failed") } - if route == nil { + if plan == nil { return nil, apierr.Newf(404, "model_not_found", "model '%s' does not exist or its provider is disabled", alias).WithParam("model") } @@ -138,15 +133,16 @@ func (c *Catalog) Resolve(ctx context.Context, alias, endpoint string, streaming if streaming && endpoint == "chat" { needed = append(needed, "chat_stream") } + caps := plan.Capabilities() var missing []string for _, cap := range needed { - if !route.Capabilities[cap] { + if !caps[cap] { missing = append(missing, cap) } } if len(missing) > 0 { - supported := make([]string, 0, len(route.Capabilities)) - for cap := range route.Capabilities { + supported := make([]string, 0, len(caps)) + for cap := range caps { supported = append(supported, cap) } sort.Strings(supported) @@ -154,36 +150,52 @@ func (c *Catalog) Resolve(ctx context.Context, alias, endpoint string, streaming "model '%s' does not support %v; supported capabilities: %v", alias, missing, supported).WithParam("model") } - return route, nil + return plan, nil } -func (c *Catalog) lookup(ctx context.Context, alias string) (*Route, error) { +func (c *Catalog) lookup(ctx context.Context, alias string) (*Plan, error) { now := time.Now() c.mu.Lock() if entry, ok := c.cache[alias]; ok && entry.expires.After(now) { c.mu.Unlock() - return entry.route, nil + return entry.plan, nil } c.mu.Unlock() - route, err := c.load(ctx, alias) + plan, err := c.load(ctx, alias) if err != nil { return nil, err } c.mu.Lock() - c.cache[alias] = cacheEntry{expires: now.Add(c.ttl), route: route} + c.cache[alias] = cacheEntry{expires: now.Add(c.ttl), plan: plan} c.mu.Unlock() - return route, nil + return plan, nil } -func (c *Catalog) load(ctx context.Context, alias string) (*Route, error) { +func (c *Catalog) load(ctx context.Context, alias string) (*Plan, error) { binding, targets, err := c.store.ResolveAlias(ctx, alias) - if err != nil || binding == nil { + if err != nil || binding == nil || len(targets) == 0 { return nil, err } - binding, provider, overrides := targets[0].Binding, targets[0].Provider, targets[0].Overrides + plan := &Plan{Alias: binding.Alias, Strategy: binding.Strategy} + for _, t := range targets { + route, err := c.route(binding.Alias, t) + if err != nil { + return nil, err + } + plan.Routes = append(plan.Routes, route) + } + if len(plan.Routes) < 2 { + plan.Strategy = "" + } + return plan, nil +} + +func (c *Catalog) route(alias string, t store.ResolvedTarget) (*Route, error) { + binding, provider := t.Binding, t.Provider credential := "" if provider.CredentialCiphertext.Valid && provider.CredentialCiphertext.String != "" { + var err error credential, err = secrets.DecryptCredential(c.secret, provider.CredentialCiphertext.String) if err != nil { return nil, err @@ -195,6 +207,10 @@ func (c *Catalog) load(ctx context.Context, alias string) (*Route, error) { caps[cap] = true } } + targetAlias := binding.TargetAlias + if binding.Alias != alias { + targetAlias = binding.Alias + } return &Route{ ProviderID: provider.ID, ProviderName: provider.Name, @@ -206,11 +222,12 @@ func (c *Catalog) load(ctx context.Context, alias string) (*Route, error) { TimeoutConnect: time.Duration(provider.TimeoutConnect * float64(time.Second)), TimeoutRead: time.Duration(provider.TimeoutRead * float64(time.Second)), MaxConcurrency: int(provider.MaxConcurrency.Int64), - Alias: binding.Alias, - TargetAlias: binding.TargetAlias, + Weight: max(t.Weight, 1), + Alias: alias, + TargetAlias: targetAlias, UpstreamName: binding.UpstreamName, Capabilities: caps, - URLOverrides: overrides, + URLOverrides: t.Overrides, RateLimitHeaders: ParseRateLimitHeaders(provider.RateLimitHeaders), }, nil } diff --git a/internal/server/admin.go b/internal/server/admin.go index c9cd5c7..2592c5e 100644 --- a/internal/server/admin.go +++ b/internal/server/admin.go @@ -845,13 +845,15 @@ func (s *Server) handleResolve(w http.ResponseWriter, r *http.Request, auth *Aut endpoint = "chat" } stream := r.URL.Query().Get("stream") == "true" - route, perr := s.catalog.Resolve(r.Context(), model, endpoint, stream) + plan, perr := s.catalog.Resolve(r.Context(), model, endpoint, stream) if perr != nil { writeProxyError(w, perr) return } - caps := make([]string, 0, len(route.Capabilities)) - for c := range route.Capabilities { + route := plan.Routes[0] + planCaps := plan.Capabilities() + caps := make([]string, 0, len(planCaps)) + for c := range planCaps { caps = append(caps, c) } sort.Strings(caps) diff --git a/internal/server/openai.go b/internal/server/openai.go index 8cfe90b..b7e0956 100644 --- a/internal/server/openai.go +++ b/internal/server/openai.go @@ -142,11 +142,12 @@ func (s *Server) proxyCompletion(w http.ResponseWriter, r *http.Request, auth *A return } - route, rerr := s.catalog.Resolve(r.Context(), model, endpoint, stream) + plan, rerr := s.catalog.Resolve(r.Context(), model, endpoint, stream) if rerr != nil { writeProxyError(w, rerr) return } + route := plan.Routes[0] fields["model"], _ = json.Marshal(route.UpstreamName) if stream { From e8a9a0df0b13b756f4c4f431ab3448ae55b37d66 Mon Sep 17 00:00:00 2001 From: Mathieu Virbel Date: Thu, 8 Oct 2026 10:36:56 -0600 Subject: [PATCH 03/12] Fail over between alias targets before the first byte --- docker-compose.coolify.yml | 1 + docs/reference/configuration.md | 1 + internal/config/config.go | 2 + internal/metrics/metrics.go | 5 + internal/server/failover_test.go | 285 ++++++++++++++++++++++++++++ internal/server/openai.go | 68 +++++-- internal/server/persistence_test.go | 4 + internal/server/ratelimit.go | 7 +- internal/server/server.go | 9 +- internal/server/usagerec.go | 7 +- 10 files changed, 370 insertions(+), 19 deletions(-) create mode 100644 internal/server/failover_test.go diff --git a/docker-compose.coolify.yml b/docker-compose.coolify.yml index f57aeb8..c9b118d 100644 --- a/docker-compose.coolify.yml +++ b/docker-compose.coolify.yml @@ -50,6 +50,7 @@ services: LLMPROXY_ADMIN_PASSWORD_FILE: ${LLMPROXY_ADMIN_PASSWORD_FILE:-/data/admin-password} LLMPROXY_ADMIN_PASSWORD_DISABLED: ${LLMPROXY_ADMIN_PASSWORD_DISABLED:-0} LLMPROXY_CATALOG_TTL: ${LLMPROXY_CATALOG_TTL:-5s} + LLMPROXY_FAILOVER_COOLDOWN: ${LLMPROXY_FAILOVER_COOLDOWN:-30s} LLMPROXY_MAX_BODY_BYTES: ${LLMPROXY_MAX_BODY_BYTES:-10485760} LLMPROXY_MAX_EMBEDDING_BATCH: ${LLMPROXY_MAX_EMBEDDING_BATCH:-2048} LLMPROXY_PRICING_FILE: ${LLMPROXY_PRICING_FILE:-} diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 11525d5..a5306b9 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -30,6 +30,7 @@ default, so check the value when a setting looks like it was ignored. | `LLMPROXY_ADMIN_PASSWORD_FILE` | `.llmproxy/admin-password` | Where the generated admin password lives (created mode 0600). Ignored when `LLMPROXY_ADMIN_PASSWORD` is set | | `LLMPROXY_ADMIN_PASSWORD_DISABLED` | `false` | Disable password login entirely, for SSO-only deployments | | `LLMPROXY_CATALOG_TTL` | `5s` | Alias-resolution cache TTL. Admin API mutations invalidate the cache immediately; this TTL bounds staleness for changes made directly in the database or from another instance | +| `LLMPROXY_FAILOVER_COOLDOWN` | `30s` | How long an alias target that failed goes to the end of the order. `0` disables the cooldown. Kept per replica | | `LLMPROXY_MAX_BODY_BYTES` | `10485760` (10 MiB) | Request body ceiling on the `/v1` endpoints. Oversize requests get 413 `request_too_large` | | `LLMPROXY_MAX_EMBEDDING_BATCH` | `2048` | Maximum items in an embeddings `input` array. `0` or negative disables the check | | `LLMPROXY_PRICING_FILE` | empty | Path to a pricing feed JSON loaded at startup. Stored only when its `version` differs from the active feed. Format in [pricing](../guides/pricing.md#load-a-whole-price-list) | diff --git a/internal/config/config.go b/internal/config/config.go index 240264d..c094096 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -43,6 +43,7 @@ type Config struct { AdminPasswordDisabled bool CatalogTTL time.Duration + FailoverCooldown time.Duration MaxBodyBytes int64 MaxEmbeddingBatch int PricingFile string @@ -79,6 +80,7 @@ func FromEnv() Config { AdminPasswordFile: envStr("LLMPROXY_ADMIN_PASSWORD_FILE", ".llmproxy/admin-password"), AdminPasswordDisabled: envBool("LLMPROXY_ADMIN_PASSWORD_DISABLED", false), CatalogTTL: envDuration("LLMPROXY_CATALOG_TTL", 5*time.Second), + FailoverCooldown: envDuration("LLMPROXY_FAILOVER_COOLDOWN", 30*time.Second), MaxBodyBytes: envInt64("LLMPROXY_MAX_BODY_BYTES", 10<<20), MaxEmbeddingBatch: envInt("LLMPROXY_MAX_EMBEDDING_BATCH", 2048), PricingFile: envStr("LLMPROXY_PRICING_FILE", ""), diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go index 1ec94c7..bcda25a 100644 --- a/internal/metrics/metrics.go +++ b/internal/metrics/metrics.go @@ -55,6 +55,11 @@ func (m *Metrics) ObserveRequest(endpoint, provider, alias, outcome string, dura m.Inc("llmproxy_request_seconds_count", append([][2]string{}, base...), 1) } +// ObserveFailover counts one alias target given up on. reason is "unreachable" or the status code. +func (m *Metrics) ObserveFailover(provider, alias, reason string) { + m.Inc("llmproxy_failover_total", [][2]string{{"provider", provider}, {"model", alias}, {"reason", reason}}, 1) +} + func (m *Metrics) ObserveUnits(provider, alias, unit string, quantity float64, priced bool) { m.Inc("llmproxy_usage_units_total", [][2]string{{"provider", provider}, {"model", alias}, {"unit", unit}}, quantity) diff --git a/internal/server/failover_test.go b/internal/server/failover_test.go new file mode 100644 index 0000000..543eb81 --- /dev/null +++ b/internal/server/failover_test.go @@ -0,0 +1,285 @@ +package server_test + +import ( + "context" + "database/sql" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/greyhavenhq/llmproxy/internal/secrets" + "github.com/greyhavenhq/llmproxy/internal/store" +) + +type scriptedUpstream struct { + srv *httptest.Server + mu sync.Mutex + auth []string +} + +func newScripted(t *testing.T, handler http.HandlerFunc) *scriptedUpstream { + t.Helper() + u := &scriptedUpstream{} + u.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + u.mu.Lock() + u.auth = append(u.auth, r.Header.Get("Authorization")) + u.mu.Unlock() + handler(w, r) + })) + t.Cleanup(u.srv.Close) + return u +} + +func (u *scriptedUpstream) calls() []string { + u.mu.Lock() + defer u.mu.Unlock() + return append([]string(nil), u.auth...) +} + +func statusHandler(status int, body string, headers map[string]string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + _, _ = io.Copy(io.Discard, r.Body) + for k, v := range headers { + w.Header().Set(k, v) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + fmt.Fprint(w, body) + } +} + +func (e *env) upstreamCalls() int { + e.upstream.mu.Lock() + defer e.upstream.mu.Unlock() + return len(e.upstream.requests) +} + +// addTarget creates a provider at baseURL with one direct model on it. +func (e *env) addTarget(t *testing.T, name, baseURL, rateLimitHeaders string) *store.ModelBinding { + t.Helper() + ctx := context.Background() + encrypted, err := secrets.EncryptCredential(e.secret, upstreamKey) + if err != nil { + t.Fatal(err) + } + p := &store.Provider{Name: name, WireFormat: "openai", BaseURL: baseURL, + CredentialCiphertext: sql.NullString{String: encrypted, Valid: true}, + VerifyTLS: true, TimeoutConnect: 5, TimeoutRead: 30, Enabled: true, + RateLimitHeaders: rateLimitHeaders} + if err := e.st.CreateProvider(ctx, p, nil, nil); err != nil { + t.Fatal(err) + } + b := &store.ModelBinding{Alias: name + "-m", ProviderID: p.ID, UpstreamName: "up-" + name, + CapabilitySet: "chat,chat_stream", Origin: "declared"} + if err := e.st.CreateBinding(ctx, b, nil); err != nil { + t.Fatal(err) + } + return b +} + +func (e *env) addHA(t *testing.T, targets ...*store.ModelBinding) { + t.Helper() + ctx := context.Background() + alpha, err := e.st.GetBindingByAlias(ctx, "alpha") + if err != nil { + t.Fatal(err) + } + ha := &store.ModelBinding{Alias: "ha", Origin: "declared", Strategy: "failover"} + for _, b := range append(targets, alpha) { + ha.Targets = append(ha.Targets, store.BindingTarget{ID: b.ID}) + } + if err := e.st.CreateBinding(ctx, ha, nil); err != nil { + t.Fatal(err) + } +} + +func haChat(stream bool, content string) map[string]any { + return map[string]any{"model": "ha", "stream": stream, + "messages": []any{map[string]any{"role": "user", "content": content}}} +} + +func TestFailoverOnRefusedConnection(t *testing.T) { + e := newEnv(t) + dead := httptest.NewServer(http.NotFoundHandler()) + deadURL := dead.URL + dead.Close() + e.addHA(t, e.addTarget(t, "dead", deadURL+"/v1", "")) + + resp, body := e.request(t, "POST", "/v1/chat/completions", e.memberKey, haChat(false, "hi")) + if resp.StatusCode != 200 { + t.Fatalf("chat: %d %s", resp.StatusCode, body) + } + if resp.Header.Get("x-llmproxy-provider") != "fake" || resp.Header.Get("x-llmproxy-model") != "m-alpha" || + resp.Header.Get("x-llmproxy-attempts") != "2" { + t.Fatalf("headers: %v", resp.Header) + } + e.waitUsage(t, func(ev store.UsageEvent) bool { + return ev.Alias == "ha" && ev.FailedOver && ev.Outcome == "unreachable" && ev.ErrorKind == "connection_error" + }) + e.waitUsage(t, func(ev store.UsageEvent) bool { return ev.Alias == "ha" && !ev.FailedOver && ev.Outcome == "ok" }) + + mresp, err := http.Get(e.proxy.URL + "/metrics") + if err != nil { + t.Fatal(err) + } + metrics, _ := io.ReadAll(mresp.Body) + mresp.Body.Close() + if !strings.Contains(string(metrics), `llmproxy_failover_total{model="ha",provider="dead",reason="unreachable"} 1`) { + t.Fatalf("failover metric missing:\n%s", metrics) + } +} + +func TestFailoverOnRetryableStatus(t *testing.T) { + for _, status := range []int{429, 503} { + t.Run(fmt.Sprint(status), func(t *testing.T) { + e := newEnv(t) + failing := newScripted(t, statusHandler(status, `{"error":{"message":"busy","type":"overloaded"}}`, + map[string]string{"x-ratelimit-remaining-requests": "5"})) + e.addHA(t, e.addTarget(t, "flaky", failing.srv.URL+"/v1", + "remaining_requests:x-ratelimit-remaining-requests")) + + resp, body := e.request(t, "POST", "/v1/chat/completions", e.memberKey, haChat(false, "hi")) + if resp.StatusCode != 200 || resp.Header.Get("x-llmproxy-attempts") != "2" { + t.Fatalf("chat: %d %v %s", resp.StatusCode, resp.Header, body) + } + if got := resp.Header.Get("x-llmproxy-ratelimit-requests-remaining"); got != "" { + t.Fatalf("rate-limit header leaked from failed attempt: %q", got) + } + if calls := failing.calls(); len(calls) != 1 || calls[0] != "Bearer "+upstreamKey { + t.Fatalf("failed attempt credential: %v", calls) + } + if e.upstream.last(t).Header.Get("Authorization") != "Bearer "+upstreamKey { + t.Fatal("second attempt credential missing") + } + e.waitUsage(t, func(ev store.UsageEvent) bool { + return ev.FailedOver && ev.Outcome == "upstream_error" && ev.StatusCode.Int64 == int64(status) && + ev.ErrorKind == "overloaded" + }) + }) + } +} + +func TestNoFailoverOnClientError(t *testing.T) { + e := newEnv(t) + bad := newScripted(t, statusHandler(400, `{"error":{"message":"bad","type":"invalid_request_error"}}`, nil)) + e.addHA(t, e.addTarget(t, "bad", bad.srv.URL+"/v1", "")) + + resp, body := e.request(t, "POST", "/v1/chat/completions", e.memberKey, haChat(false, "hi")) + if resp.StatusCode != 400 || resp.Header.Get("x-llmproxy-attempts") != "1" { + t.Fatalf("chat: %d %v %s", resp.StatusCode, resp.Header, body) + } + if n := e.upstreamCalls(); n != 0 { + t.Fatalf("second target called %d times", n) + } +} + +func TestLastTargetResponsePassesThrough(t *testing.T) { + e := newEnv(t) + first := newScripted(t, statusHandler(503, `{"error":{"message":"first"}}`, nil)) + second := newScripted(t, statusHandler(503, `{"error":{"message":"second"}}`, nil)) + ctx := context.Background() + a, b := e.addTarget(t, "first", first.srv.URL+"/v1", ""), e.addTarget(t, "second", second.srv.URL+"/v1", "") + ha := &store.ModelBinding{Alias: "ha", Origin: "declared", Strategy: "failover", + Targets: []store.BindingTarget{{ID: a.ID}, {ID: b.ID}}} + if err := e.st.CreateBinding(ctx, ha, nil); err != nil { + t.Fatal(err) + } + + resp, body := e.request(t, "POST", "/v1/chat/completions", e.memberKey, haChat(false, "hi")) + if resp.StatusCode != 503 || !strings.Contains(string(body), "second") { + t.Fatalf("chat: %d %s", resp.StatusCode, body) + } + if resp.Header.Get("x-llmproxy-attempts") != "2" || resp.Header.Get("x-llmproxy-provider") != "second" { + t.Fatalf("headers: %v", resp.Header) + } +} + +func TestStreamFailoverBeforeFirstByte(t *testing.T) { + e := newEnv(t) + failing := newScripted(t, statusHandler(503, `{"error":{"message":"busy"}}`, nil)) + e.addHA(t, e.addTarget(t, "flaky", failing.srv.URL+"/v1", "")) + + resp, body := e.request(t, "POST", "/v1/chat/completions", e.memberKey, haChat(true, "hi")) + if resp.StatusCode != 200 || !strings.Contains(string(body), "word2") { + t.Fatalf("stream: %d %s", resp.StatusCode, body) + } + if calls := failing.calls(); len(calls) != 1 || calls[0] != "Bearer "+upstreamKey { + t.Fatalf("failed attempt credential: %v", calls) + } + if e.upstream.last(t).Header.Get("Authorization") != "Bearer "+upstreamKey { + t.Fatal("second attempt credential missing") + } + e.waitUsage(t, func(ev store.UsageEvent) bool { return ev.Alias == "ha" && ev.Streamed && ev.Outcome == "ok" }) +} + +func TestNoFailoverAfterFirstStreamByte(t *testing.T) { + e := newEnv(t) + breaking := newScripted(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(200) + _, _ = w.Write(sseChunk("up-breaking", map[string]any{"content": "partial"}, nil, nil, false)) + w.(http.Flusher).Flush() + panic(http.ErrAbortHandler) + }) + e.addHA(t, e.addTarget(t, "breaking", breaking.srv.URL+"/v1", "")) + + resp, body := e.request(t, "POST", "/v1/chat/completions", e.memberKey, haChat(true, "hi")) + if resp.StatusCode != 200 || !strings.Contains(string(body), "partial") { + t.Fatalf("stream: %d %s", resp.StatusCode, body) + } + if resp.Header.Get("x-llmproxy-attempts") != "1" { + t.Fatalf("attempts: %q", resp.Header.Get("x-llmproxy-attempts")) + } + if n := e.upstreamCalls(); n != 0 { + t.Fatalf("second target called %d times after the first byte", n) + } +} + +func TestClientCancelStopsFailover(t *testing.T) { + e := newEnv(t) + hanging := newScripted(t, func(w http.ResponseWriter, r *http.Request) { + _, _ = io.Copy(io.Discard, r.Body) + <-r.Context().Done() + }) + e.addHA(t, e.addTarget(t, "hanging", hanging.srv.URL+"/v1", "")) + + ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond) + defer cancel() + req, _ := http.NewRequestWithContext(ctx, "POST", e.proxy.URL+"/v1/chat/completions", + strings.NewReader(`{"model":"ha","messages":[{"role":"user","content":"hi"}]}`)) + req.Header.Set("Authorization", "Bearer "+e.memberKey) + if resp, err := http.DefaultClient.Do(req); err == nil { + resp.Body.Close() + t.Fatal("expected the client to time out") + } + e.waitUsage(t, func(ev store.UsageEvent) bool { return ev.Alias == "ha" && ev.Cancelled }) + time.Sleep(200 * time.Millisecond) + if n := e.upstreamCalls(); n != 0 { + t.Fatalf("second target called %d times after cancel", n) + } +} + +func TestFailoverMarkerNeverReachesDisk(t *testing.T) { + e := newEnv(t) + echo := newScripted(t, func(w http.ResponseWriter, r *http.Request) { + raw, _ := io.ReadAll(r.Body) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(503) + fmt.Fprintf(w, `{"error":{"message":%q,"type":%q}}`, raw, raw) + }) + e.addHA(t, e.addTarget(t, "echo", echo.srv.URL+"/v1", "")) + + resp, body := e.request(t, "POST", "/v1/chat/completions", e.memberKey, + haChat(false, "SECRETMARKER-failover content")) + if resp.StatusCode != 200 { + t.Fatalf("chat: %d %s", resp.StatusCode, body) + } + e.waitUsage(t, func(ev store.UsageEvent) bool { return ev.FailedOver }) + e.waitUsage(t, func(ev store.UsageEvent) bool { return ev.Alias == "ha" && ev.Outcome == "ok" }) + assertNoMarkerOnDisk(t, e) +} diff --git a/internal/server/openai.go b/internal/server/openai.go index b7e0956..764b134 100644 --- a/internal/server/openai.go +++ b/internal/server/openai.go @@ -8,6 +8,7 @@ import ( "errors" "io" "net/http" + "strconv" "strings" "time" @@ -147,9 +148,6 @@ func (s *Server) proxyCompletion(w http.ResponseWriter, r *http.Request, auth *A writeProxyError(w, rerr) return } - route := plan.Routes[0] - - fields["model"], _ = json.Marshal(route.UpstreamName) if stream { var opts map[string]any if raw, ok := fields["stream_options"]; ok { @@ -161,10 +159,26 @@ func (s *Server) proxyCompletion(w http.ResponseWriter, r *http.Request, auth *A opts["include_usage"] = true fields["stream_options"], _ = json.Marshal(opts) } + routes := s.balancer.Order(plan) + for i, route := range routes { + if s.attempt(w, r, auth, endpoint, fields, stream, route, i+1, i == len(routes)-1) { + return + } + } +} + +// failoverBodyBytes bounds what is read from a failed attempt to classify its error. +const failoverBodyBytes = 64 << 10 + +// attempt sends the request to one alias target. It returns false when the +// caller must try the next target, which only happens before any byte is written. +func (s *Server) attempt(w http.ResponseWriter, r *http.Request, auth *Auth, endpoint string, + fields map[string]json.RawMessage, stream bool, route *catalog.Route, n int, last bool) bool { + fields["model"], _ = json.Marshal(route.UpstreamName) outBody, err := json.Marshal(fields) if err != nil { writeProxyError(w, apierr.New(400, "invalid_json", "request body could not be re-encoded")) - return + return true } reqCtx := r.Context() @@ -177,7 +191,7 @@ func (s *Server) proxyCompletion(w http.ResponseWriter, r *http.Request, auth *A route.EndpointURL(endpoint), bytes.NewReader(outBody)) if err != nil { writeProxyError(w, apierr.New(500, "internal_error", "failed to build upstream request")) - return + return true } req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json, text/event-stream") @@ -185,34 +199,64 @@ func (s *Server) proxyCompletion(w http.ResponseWriter, r *http.Request, auth *A req.Header.Set("Authorization", "Bearer "+route.Credential) } + defer s.balancer.Acquire(route)() + w.Header().Set("x-llmproxy-attempts", strconv.Itoa(n)) started := time.Now() resp, err := s.pool.ClientFor(route).Do(req) if err != nil { // A caller that hung up before the upstream answered is a // cancellation, not a provider failure. - outcome, cancelled, kind := "unreachable", false, errClass(err) if r.Context().Err() != nil { - outcome, cancelled, kind = "cancelled", true, "" + s.recordUsageAsync(auth, route, endpoint, usageOutcome{ + Outcome: "cancelled", Cancelled: true, Streamed: stream, + DurationMs: time.Since(started).Milliseconds(), + }) + writeProxyError(w, apierr.Newf(502, "provider_unreachable", + "request to provider '%s' failed: %s", route.ProviderName, errClass(err))) + return true } s.recordUsageAsync(auth, route, endpoint, usageOutcome{ - Outcome: outcome, ErrorKind: kind, Cancelled: cancelled, Streamed: stream, - DurationMs: time.Since(started).Milliseconds(), + Outcome: "unreachable", ErrorKind: errClass(err), Streamed: stream, + DurationMs: time.Since(started).Milliseconds(), FailedOver: !last, }) + if !last { + s.failOver(route, "unreachable") + return false + } writeProxyError(w, apierr.Newf(502, "provider_unreachable", "request to provider '%s' failed: %s", route.ProviderName, errClass(err))) - return + return true } defer resp.Body.Close() + if !last && (resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500) { + data, _ := io.ReadAll(io.LimitReader(resp.Body, failoverBodyBytes)) + s.observeRateLimit(resp, route) + s.recordUsageAsync(auth, route, endpoint, usageOutcome{ + StatusCode: resp.StatusCode, Outcome: "upstream_error", ErrorKind: errorKindFromBody(data), + Streamed: stream, DurationMs: time.Since(started).Milliseconds(), FailedOver: true, + }) + s.failOver(route, strconv.Itoa(resp.StatusCode)) + return false + } + w.Header().Set("x-llmproxy-provider", route.ProviderName) w.Header().Set("x-llmproxy-model", route.UpstreamName) - s.observeRateLimit(w, resp, route) + if reading := s.observeRateLimit(resp, route); reading != nil { + setRateLimitResponseHeaders(w, reading) + } if !stream || resp.StatusCode >= 400 { s.relayUnary(w, auth, route, endpoint, resp, stream, started) - return + return true } s.relayStream(w, r, auth, route, endpoint, resp, started) + return true +} + +func (s *Server) failOver(route *catalog.Route, reason string) { + s.balancer.Fail(route) + s.metrics.ObserveFailover(route.ProviderName, route.Alias, reason) } // maxUnaryResponseBytes caps buffered upstream bodies. Oversize responses are diff --git a/internal/server/persistence_test.go b/internal/server/persistence_test.go index 6b475b2..c1d8841 100644 --- a/internal/server/persistence_test.go +++ b/internal/server/persistence_test.go @@ -48,7 +48,11 @@ func TestMarkerNeverReachesDisk(t *testing.T) { t.Fatal("chat failed") } e.waitUsage(t, func(ev store.UsageEvent) bool { return ev.Outcome == "ok" }) + assertNoMarkerOnDisk(t, e) +} +func assertNoMarkerOnDisk(t *testing.T, e *env) { + t.Helper() // Flush the WAL so everything SQLite knows about is in the main file. if _, err := e.st.DB().Exec("PRAGMA wal_checkpoint(TRUNCATE)"); err != nil { t.Fatal(err) diff --git a/internal/server/ratelimit.go b/internal/server/ratelimit.go index eeba361..25ae3f1 100644 --- a/internal/server/ratelimit.go +++ b/internal/server/ratelimit.go @@ -311,15 +311,15 @@ func (t *RateLimitTracker) Drain() []*store.RateLimitSample { // observeRateLimit is the single call site that reads upstream rate-limit // headers, updates the in-memory tracker, sets response headers, updates // Prometheus gauges, and flushes finished buckets to the store. -func (s *Server) observeRateLimit(w http.ResponseWriter, resp *http.Response, route *catalog.Route) { +// observeRateLimit records the upstream's rate-limit telemetry and returns the reading, or nil. +func (s *Server) observeRateLimit(resp *http.Response, route *catalog.Route) *RateLimitReading { reading := readRateLimitHeaders(resp.Header, route.RateLimitHeaders) if reading == nil { - return + return nil } reading.ProviderID = route.ProviderID reading.ProviderName = route.ProviderName - setRateLimitResponseHeaders(w, reading) setRateLimitGauges(s.metrics, route.ProviderName, reading) finished := s.rateLimits.Observe(reading, time.Now()) @@ -330,6 +330,7 @@ func (s *Server) observeRateLimit(w http.ResponseWriter, resp *http.Response, ro } }) } + return reading } func (s *Server) flushRateLimitSample(sample *store.RateLimitSample) { diff --git a/internal/server/server.go b/internal/server/server.go index 9a49791..5fa717b 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -32,9 +32,11 @@ type Server struct { store *store.Store secret []byte catalog *catalog.Catalog - pool *upstream.Pool - metrics *metrics.Metrics - pricing atomic.Pointer[pricing.Index] + // balancer orders alias targets and tracks cooldowns, per replica. + balancer *catalog.Balancer + pool *upstream.Pool + metrics *metrics.Metrics + pricing atomic.Pointer[pricing.Index] // transparent is the HTTP client for the transparent Anthropic relay; // separate from the provider pool because there is no provider row. @@ -69,6 +71,7 @@ func New(cfg config.Config, st *store.Store, secret []byte) *Server { store: st, secret: secret, catalog: catalog.New(st, secret, cfg.CatalogTTL), + balancer: catalog.NewBalancer(cfg.FailoverCooldown), pool: upstream.New(), metrics: metrics.New(), rateLimits: NewRateLimitTracker(), diff --git a/internal/server/usagerec.go b/internal/server/usagerec.go index 6649855..9b6ac68 100644 --- a/internal/server/usagerec.go +++ b/internal/server/usagerec.go @@ -126,6 +126,8 @@ type usageOutcome struct { // TS is when the response ended, taken before the async hop so that // TS minus DurationMs is the request's start. TS string + // FailedOver marks an attempt given up on before another alias target was tried. + FailedOver bool } // extractUsage pulls the usage object out of a unary JSON response. @@ -338,7 +340,9 @@ func (s *Server) priceAndInsert(ctx context.Context, ev *store.UsageEvent, provi if pricedAny { ev.Cost = sql.NullFloat64{Float64: cost, Valid: true} } - s.metrics.ObserveRequest(ev.Endpoint, providerName, ev.Alias, ev.Outcome, ev.DurationMs) + if !ev.FailedOver { + s.metrics.ObserveRequest(ev.Endpoint, providerName, ev.Alias, ev.Outcome, ev.DurationMs) + } if err := s.store.InsertUsageEvent(ctx, ev, rows); err != nil { // Never let accounting failures break or block the data plane. slog.Error("failed to record usage event", "error", err) @@ -362,6 +366,7 @@ func (s *Server) recordUsage(ctx context.Context, auth *Auth, route *catalog.Rou DurationMs: rec.DurationMs, TTFTMs: rec.TTFTMs, TS: rec.TS, + FailedOver: rec.FailedOver, } if rec.StatusCode != 0 { ev.StatusCode = sql.NullInt64{Int64: int64(rec.StatusCode), Valid: true} From 57ef34c2235f4a2dc2594e2265b327efc3bb2103 Mon Sep 17 00:00:00 2001 From: Mathieu Virbel Date: Thu, 8 Oct 2026 10:38:38 -0600 Subject: [PATCH 04/12] Exclude failed-over attempts from request counts --- internal/store/targets_test.go | 27 +++++++++++++++++++++++++++ internal/store/usage.go | 5 +++++ 2 files changed, 32 insertions(+) diff --git a/internal/store/targets_test.go b/internal/store/targets_test.go index f3c3708..8d4b77d 100644 --- a/internal/store/targets_test.go +++ b/internal/store/targets_test.go @@ -231,3 +231,30 @@ func TestUsageEventFailedOverRoundTrip(t *testing.T) { t.Fatalf("events = %+v %v", evs, err) } } + +func TestRequestCountsExcludeFailedOver(t *testing.T) { + ctx := context.Background() + st := openTestStore(t) + for _, ev := range []*store.UsageEvent{ + {PrincipalID: "p", APIKeyID: "k", ProviderID: "x", Alias: "ha", UpstreamName: "u", + Endpoint: "chat", Outcome: "upstream_error", StatusCode: sql.NullInt64{Int64: 429, Valid: true}, FailedOver: true}, + {PrincipalID: "p", APIKeyID: "k", ProviderID: "y", Alias: "ha", UpstreamName: "u", + Endpoint: "chat", Outcome: "ok", StatusCode: sql.NullInt64{Int64: 200, Valid: true}}, + } { + if err := st.InsertUsageEvent(ctx, ev, nil); err != nil { + t.Fatal(err) + } + } + summary, err := st.UsageSummary(ctx, "", "", "") + if err != nil || len(summary) != 1 || summary[0].Requests != 1 { + t.Fatalf("summary = %+v %v", summary, err) + } + series, err := st.UsageSeries(ctx, store.UsageFilter{}, false) + if err != nil || len(series) != 1 || series[0].Requests != 1 || series[0].Failed != 0 { + t.Fatalf("series = %+v %v", series, err) + } + errs, err := st.ErrorSeries(ctx, store.UsageFilter{}, false) + if err != nil || len(errs) != 1 || errs[0].Requests != 2 { + t.Fatalf("error series = %+v %v", errs, err) + } +} diff --git a/internal/store/usage.go b/internal/store/usage.go index 8e27ffe..2501784 100644 --- a/internal/store/usage.go +++ b/internal/store/usage.go @@ -314,6 +314,9 @@ const providerNameSQL = `CASE WHEN p.name IS NOT NULL THEN p.name ` + // request log still show them. const completedSQL = ` AND (e.cancelled = 1 OR e.outcome = 'ok')` +// servedSQL drops attempts that failed over to another target: the request is counted once. +const servedSQL = ` AND e.failed_over = 0` + // addQuantity accumulates one aggregated quantity into a units map, // normalising input_tokens to the non-cached input; see the comment on // anthropicProviderID. @@ -409,6 +412,7 @@ func usageWhere(f UsageFilter) (string, []any) { // Empty principalID/since/until disable the corresponding filter. func (s *Store) UsageSummary(ctx context.Context, principalID, since, until string) ([]UsageSummaryRow, error) { where, args := usageWhere(UsageFilter{PrincipalID: principalID, Since: since, Until: until}) + where += servedSQL rowsByKey := make(map[[3]string]*UsageSummaryRow) events, err := s.db.QueryContext(ctx, s.q(` @@ -544,6 +548,7 @@ func (s *Store) UsageSeries(ctx context.Context, f UsageFilter, hourly bool) ([] bucket = "SUBSTR(e.ts, 1, 13)" } where, args := usageWhere(f) + where += servedSQL // A cancelled request is its own outcome, so the three counts partition // the total exactly. From 161af7b08c75e0dd2875edb6db72d376411d48d3 Mon Sep 17 00:00:00 2001 From: Mathieu Virbel Date: Thu, 8 Oct 2026 10:40:18 -0600 Subject: [PATCH 05/12] Read an alias's capabilities as the intersection of its targets --- internal/store/providers.go | 38 +++++++++++++++++++++++++++++----- internal/store/targets_test.go | 5 ++++- 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/internal/store/providers.go b/internal/store/providers.go index d03f2d7..5d3ab04 100644 --- a/internal/store/providers.go +++ b/internal/store/providers.go @@ -4,6 +4,8 @@ import ( "context" "database/sql" "errors" + "sort" + "strings" "github.com/greyhavenhq/llmproxy/internal/secrets" ) @@ -196,7 +198,8 @@ func targetsWhere(cond string) string { WHERE bt.binding_id = b.id AND ` + cond + `)` } -// attachTargets fills Targets for aliases stored in the target table. +// attachTargets fills Targets for aliases stored in the target table, and +// their capabilities as the intersection over every target. func (s *Store) attachTargets(ctx context.Context, bindings []*ModelBinding) error { multi := map[string]*ModelBinding{} var ids []any @@ -211,7 +214,7 @@ func (s *Store) attachTargets(ctx context.Context, bindings []*ModelBinding) err return nil } rows, err := s.db.QueryContext(ctx, s.q(` - SELECT bt.binding_id, bt.target_id, t.alias, bt.weight + SELECT bt.binding_id, bt.target_id, t.alias, bt.weight, t.capability_set FROM model_binding_target bt JOIN model_binding t ON t.id = bt.target_id WHERE bt.binding_id IN (`+placeholders(len(ids))+`) ORDER BY bt.binding_id, bt.position`), ids...) @@ -219,13 +222,38 @@ func (s *Store) attachTargets(ctx context.Context, bindings []*ModelBinding) err return err } defer rows.Close() + caps := map[string]map[string]bool{} for rows.Next() { - var bindingID string + var bindingID, capabilitySet string var t BindingTarget - if err := rows.Scan(&bindingID, &t.ID, &t.Alias, &t.Weight); err != nil { + if err := rows.Scan(&bindingID, &t.ID, &t.Alias, &t.Weight, &capabilitySet); err != nil { return err } - multi[bindingID].Targets = append(multi[bindingID].Targets, t) + b := multi[bindingID] + own := map[string]bool{} + for _, c := range strings.Split(capabilitySet, ",") { + own[c] = true + } + if len(b.Targets) == 0 { + caps[bindingID] = own + } else { + for c := range caps[bindingID] { + if !own[c] { + delete(caps[bindingID], c) + } + } + } + b.Targets = append(b.Targets, t) + } + for id, set := range caps { + list := make([]string, 0, len(set)) + for c := range set { + if c != "" { + list = append(list, c) + } + } + sort.Strings(list) + multi[id].CapabilitySet = strings.Join(list, ",") } return rows.Err() } diff --git a/internal/store/targets_test.go b/internal/store/targets_test.go index 8d4b77d..ea387db 100644 --- a/internal/store/targets_test.go +++ b/internal/store/targets_test.go @@ -67,13 +67,16 @@ func TestAliasTargetsRoundTrip(t *testing.T) { if got.TargetID.Valid { t.Fatal("multi-target alias must not set target_id") } + if got.CapabilitySet != "chat,chat_stream" { + t.Fatalf("capabilities = %q, want the intersection", got.CapabilitySet) + } list, err := f.st.ListBindings(ctx, "", 100, 0) if err != nil { t.Fatal(err) } for _, b := range list { - if b.Alias == "ha" && len(b.Targets) != 2 { + if b.Alias == "ha" && (len(b.Targets) != 2 || b.CapabilitySet != "chat,chat_stream") { t.Fatalf("list targets = %+v", b.Targets) } } From b7d945ff132aa126bef0ee4620379f7651133ed5 Mon Sep 17 00:00:00 2001 From: Mathieu Virbel Date: Thu, 8 Oct 2026 10:43:01 -0600 Subject: [PATCH 06/12] Accept several targets and a strategy on aliases --- internal/server/admin.go | 211 ++++++++++++++++++++++----- internal/server/aliastargets_test.go | 117 +++++++++++++++ 2 files changed, 293 insertions(+), 35 deletions(-) create mode 100644 internal/server/aliastargets_test.go diff --git a/internal/server/admin.go b/internal/server/admin.go index 2592c5e..0ee7e47 100644 --- a/internal/server/admin.go +++ b/internal/server/admin.go @@ -436,15 +436,81 @@ func modelView(b *store.ModelBinding, idx *pricing.Index) map[string]any { "pricing": prices, "pricing_inherited": inherited, "hidden": b.Hidden, + "targets": targetViews(b.Targets), + "strategy": nil, } // provider, upstream_name and capabilities are the resolved ones either // way; target says where they came from. if b.TargetAlias != "" { view["target"] = b.TargetAlias } + if b.Strategy != "" { + view["strategy"] = b.Strategy + } return view } +func targetViews(targets []store.BindingTarget) []map[string]any { + out := make([]map[string]any, 0, len(targets)) + for _, t := range targets { + out = append(out, map[string]any{"alias": t.Alias, "weight": t.Weight}) + } + return out +} + +// targetSpec is one entry of "targets": a model name, or {alias, weight}. +type targetSpec struct { + Alias string `json:"alias"` + Weight int `json:"weight"` +} + +func (t *targetSpec) UnmarshalJSON(data []byte) error { + if len(data) > 0 && data[0] == '"' { + return json.Unmarshal(data, &t.Alias) + } + type plain targetSpec + return json.Unmarshal(data, (*plain)(t)) +} + +// resolveTargets checks every target of an alias and settles its strategy. +func (s *Server) resolveTargets(ctx context.Context, alias string, specs []targetSpec, + strategy string) ([]store.BindingTarget, string, *apierr.ProxyError) { + if strategy != "" && !catalog.IsStrategy(strategy) { + return nil, "", apierr.Newf(400, "invalid_strategy", "strategy must be one of %v", catalog.Strategies) + } + if strategy != "" && len(specs) < 2 { + return nil, "", apierr.New(400, "invalid_strategy", "a strategy needs two or more targets") + } + if len(specs) > 1 && strategy == "" { + strategy = "failover" + } + seen := make(map[string]bool) + out := make([]store.BindingTarget, 0, len(specs)) + for _, spec := range specs { + if seen[spec.Alias] { + return nil, "", apierr.Newf(400, "invalid_target", "'%s' is listed twice", spec.Alias) + } + seen[spec.Alias] = true + if spec.Weight < 0 { + return nil, "", apierr.New(400, "invalid_target", "weight must be 1 or more") + } + target, perr := s.resolveTarget(ctx, alias, spec.Alias) + if perr != nil { + return nil, "", perr + } + out = append(out, store.BindingTarget{ID: target.ID, Alias: target.Alias, Weight: max(spec.Weight, 1)}) + } + return out, strategy, nil +} + +func targetNames(targets []store.BindingTarget) string { + names := make([]string, 0, len(targets)) + for _, t := range targets { + names = append(names, "'"+t.Alias+"'") + } + return strings.Join(names, ", ") +} + // parsePricing validates a per-million price map from a request body. The map // is the model's complete price set: units left out are unpriced afterwards. func parsePricing(prices map[string]*float64) (map[string]float64, *apierr.ProxyError) { @@ -494,10 +560,10 @@ func (s *Server) resolveTarget(ctx context.Context, alias, target string) (*stor if binding == nil { return nil, apierr.Newf(404, "model_not_found", "no model named '%s' to point at", target) } - if binding.TargetID.Valid { + if len(binding.Targets) > 0 { return nil, apierr.Newf(400, "invalid_target", - "'%s' is itself an alias for '%s'; point at that instead (aliases are one hop)", - target, binding.TargetAlias) + "'%s' is itself an alias for %s; point at that instead (aliases are one hop)", + target, targetNames(binding.Targets)) } return binding, nil } @@ -529,6 +595,8 @@ func (s *Server) handleModelCreate(w http.ResponseWriter, r *http.Request, auth Provider string `json:"provider"` UpstreamName string `json:"upstream_name"` Target string `json:"target"` + Targets []targetSpec `json:"targets"` + Strategy string `json:"strategy"` Capabilities []string `json:"capabilities"` Origin string `json:"origin"` Pricing map[string]*float64 `json:"pricing"` @@ -538,12 +606,20 @@ func (s *Server) handleModelCreate(w http.ResponseWriter, r *http.Request, auth writeProxyError(w, perr) return } - // A model points either at a provider's model or at another model. + // A model points either at a provider's model or at other models. alias := body.Alias + specs := body.Targets if body.Target != "" { + if len(specs) > 0 { + writeProxyError(w, apierr.New(400, "invalid_target", "give either 'target' or 'targets', not both")) + return + } + specs = []targetSpec{{Alias: body.Target}} + } + if len(specs) > 0 { if body.Provider != "" || body.UpstreamName != "" { writeProxyError(w, apierr.New(400, "invalid_target", - "give either 'target' or 'provider' and 'upstream_name', not both")) + "give either targets or 'provider' and 'upstream_name', not both")) return } if alias == "" { @@ -551,6 +627,9 @@ func (s *Server) handleModelCreate(w http.ResponseWriter, r *http.Request, auth "an alias for another model needs its own name")) return } + } else if body.Strategy != "" { + writeProxyError(w, apierr.New(400, "invalid_strategy", "a strategy needs two or more targets")) + return } else if body.UpstreamName == "" || len(body.UpstreamName) > 200 { writeProxyError(w, apierr.New(400, "invalid_upstream_name", "upstream_name is required (max 200 chars)")) @@ -586,19 +665,15 @@ func (s *Server) handleModelCreate(w http.ResponseWriter, r *http.Request, auth } binding := &store.ModelBinding{Alias: alias, Origin: body.Origin, Hidden: body.Hidden} - if body.Target != "" { - target, perr := s.resolveTarget(r.Context(), alias, body.Target) + if len(specs) > 0 { + // Provider, upstream name and capabilities all come from the targets. + targets, strategy, perr := s.resolveTargets(r.Context(), alias, specs, body.Strategy) if perr != nil { writeProxyError(w, perr) return } - // Provider, upstream name and capabilities all come from the target. - binding.TargetID = sql.NullString{String: target.ID, Valid: true} - binding.TargetAlias = target.Alias - binding.ProviderID = target.ProviderID - binding.ProviderName = target.ProviderName - binding.UpstreamName = target.UpstreamName - binding.CapabilitySet = target.CapabilitySet + binding.Targets = targets + binding.Strategy = strategy } else { capabilitySet, perr := normalizeCapabilities(body.Capabilities) if perr != nil { @@ -627,6 +702,10 @@ func (s *Server) handleModelCreate(w http.ResponseWriter, r *http.Request, auth internalErr(w, "failed to create binding") return } + if binding, err = s.store.GetBindingByID(r.Context(), binding.ID); err != nil || binding == nil { + internalErr(w, "failed to load binding") + return + } if body.Pricing != nil { sets := map[string]map[string]float64{binding.Alias: prices} if err := s.setModelPricing(r.Context(), binding.Alias, sets, auth.PrincipalID); err != nil { @@ -674,6 +753,8 @@ func (s *Server) handleModelPatch(w http.ResponseWriter, r *http.Request, auth * Capabilities *[]string `json:"capabilities"` UpstreamName *string `json:"upstream_name"` Target *string `json:"target"` + Targets *[]targetSpec `json:"targets"` + Strategy *string `json:"strategy"` Pricing map[string]*float64 `json:"pricing"` Hidden *bool `json:"hidden"` } @@ -691,32 +772,63 @@ func (s *Server) handleModelPatch(w http.ResponseWriter, r *http.Request, auth * return } // Switching between the two kinds is an edit like any other: point the - // model at another model, or give it a provider of its own. + // model at other models, or give it a provider of its own. + specs := body.Targets if body.Target != nil { - if *body.Target == "" { - binding.TargetID = sql.NullString{} - binding.TargetAlias = "" - } else { - target, perr := s.resolveTarget(r.Context(), binding.Alias, *body.Target) - if perr != nil { - writeProxyError(w, perr) - return - } - binding.TargetID = sql.NullString{String: target.ID, Valid: true} - binding.TargetAlias = target.Alias - binding.ProviderID = target.ProviderID - binding.ProviderName = target.ProviderName - binding.UpstreamName = target.UpstreamName - binding.CapabilitySet = target.CapabilitySet + if specs != nil { + writeProxyError(w, apierr.New(400, "invalid_target", "give either 'target' or 'targets', not both")) + return + } + specs = &[]targetSpec{} + if *body.Target != "" { + specs = &[]targetSpec{{Alias: *body.Target}} + } + } + cleared := specs != nil && len(*specs) == 0 + switch { + case cleared: + binding.Targets = nil + binding.TargetID = sql.NullString{} + binding.TargetAlias = "" + binding.Strategy = "" + if body.Strategy != nil && *body.Strategy != "" { + writeProxyError(w, apierr.New(400, "invalid_strategy", "a strategy needs two or more targets")) + return + } + case specs != nil: + strategy := binding.Strategy + if body.Strategy != nil { + strategy = *body.Strategy + } else if len(*specs) < 2 { + strategy = "" + } + targets, strategy, perr := s.resolveTargets(r.Context(), binding.Alias, *specs, strategy) + if perr != nil { + writeProxyError(w, perr) + return + } + binding.Targets = targets + binding.Strategy = strategy + case body.Strategy != nil: + specs := make([]targetSpec, 0, len(binding.Targets)) + for _, t := range binding.Targets { + specs = append(specs, targetSpec{Alias: t.Alias, Weight: t.Weight}) + } + targets, strategy, perr := s.resolveTargets(r.Context(), binding.Alias, specs, *body.Strategy) + if perr != nil { + writeProxyError(w, perr) + return } + binding.Targets = targets + binding.Strategy = strategy } - if binding.TargetID.Valid { + if len(binding.Targets) > 0 { // An alias owns nothing but its name and its price; the rest is the - // target's and would silently do nothing. + // targets' and would silently do nothing. if body.Capabilities != nil || body.UpstreamName != nil || body.Provider != nil { writeProxyError(w, apierr.Newf(400, "invalid_target", - "'%s' is an alias for '%s' and inherits its provider, model and capabilities; "+ - "edit that one, or clear the target first", binding.Alias, binding.TargetAlias)) + "'%s' is an alias for %s and inherits its provider, model and capabilities; "+ + "edit that one, or clear the target first", binding.Alias, targetNames(binding.Targets))) return } } else { @@ -751,7 +863,7 @@ func (s *Server) handleModelPatch(w http.ResponseWriter, r *http.Request, auth * } // Clearing the target leaves a model with no route unless this call // gave it one. - if body.Target != nil && *body.Target == "" && binding.UpstreamName == "" { + if cleared && binding.UpstreamName == "" { writeProxyError(w, apierr.New(400, "invalid_upstream_name", "clearing the target needs 'provider' and 'upstream_name' in the same call")) return @@ -788,6 +900,12 @@ func (s *Server) handleModelPatch(w http.ResponseWriter, r *http.Request, auth * internalErr(w, "failed to update binding") return } + updated, err := s.store.GetBindingByID(r.Context(), binding.ID) + if err != nil || updated == nil { + internalErr(w, "failed to load binding") + return + } + binding = updated renamed := binding.Alias != oldAlias if body.Pricing != nil || renamed { if body.Pricing == nil { @@ -857,12 +975,35 @@ func (s *Server) handleResolve(w http.ResponseWriter, r *http.Request, auth *Aut caps = append(caps, c) } sort.Strings(caps) + targets := make([]map[string]any, 0, len(plan.Routes)) + for _, rt := range plan.Routes { + cooling, until := s.balancer.CoolingDown(rt) + t := map[string]any{ + "alias": rt.TargetKey(), + "provider": rt.ProviderName, + "upstream_name": rt.UpstreamName, + "url": rt.EndpointURL(endpoint), + "weight": rt.Weight, + "cooling_down": cooling, + "cooldown_until": nil, + } + if cooling { + t["cooldown_until"] = until.UTC().Format(time.RFC3339) + } + targets = append(targets, t) + } + var strategy any + if plan.Strategy != "" { + strategy = plan.Strategy + } writeJSON(w, 200, map[string]any{ "alias": route.Alias, "provider": route.ProviderName, "upstream_name": route.UpstreamName, "url": route.EndpointURL(endpoint), "capabilities": caps, + "strategy": strategy, + "targets": targets, }) } diff --git a/internal/server/aliastargets_test.go b/internal/server/aliastargets_test.go new file mode 100644 index 0000000..37d02ac --- /dev/null +++ b/internal/server/aliastargets_test.go @@ -0,0 +1,117 @@ +package server_test + +import ( + "context" + "testing" +) + +func TestAliasWithSeveralTargetsThroughAdminAPI(t *testing.T) { + e := newEnv(t) + a := e.addTarget(t, "pa", e.upstream.srv.URL, "") + b := e.addTarget(t, "pb", e.upstream.srv.URL, "") + + resp, body := e.request(t, "POST", "/admin/v1/models", e.adminKey, map[string]any{ + "alias": "ha", "strategy": "failover", "targets": []any{a.Alias}, + }) + if resp.StatusCode != 400 || errorCode(t, body) != "invalid_strategy" { + t.Fatalf("strategy with one target: %d %s", resp.StatusCode, body) + } + resp, body = e.request(t, "POST", "/admin/v1/models", e.adminKey, map[string]any{ + "alias": "ha", "strategy": "fastest", "targets": []any{a.Alias, b.Alias}, + }) + if resp.StatusCode != 400 || errorCode(t, body) != "invalid_strategy" { + t.Fatalf("unknown strategy: %d %s", resp.StatusCode, body) + } + resp, body = e.request(t, "POST", "/admin/v1/models", e.adminKey, map[string]any{ + "alias": "ha", "targets": []any{a.Alias, a.Alias}, + }) + if resp.StatusCode != 400 || errorCode(t, body) != "invalid_target" { + t.Fatalf("duplicate target: %d %s", resp.StatusCode, body) + } + resp, body = e.request(t, "POST", "/admin/v1/models", e.adminKey, map[string]any{ + "alias": "ha", "target": a.Alias, "targets": []any{b.Alias}, + }) + if resp.StatusCode != 400 || errorCode(t, body) != "invalid_target" { + t.Fatalf("target and targets: %d %s", resp.StatusCode, body) + } + + resp, body = e.request(t, "POST", "/admin/v1/models", e.adminKey, map[string]any{ + "alias": "ha", "targets": []any{b.Alias, map[string]any{"alias": a.Alias, "weight": 3}}, + }) + view := decode(t, body) + if resp.StatusCode != 201 || view["strategy"] != "failover" || view["target"] != nil { + t.Fatalf("create: %d %s", resp.StatusCode, body) + } + targets := view["targets"].([]any) + if len(targets) != 2 || targets[0].(map[string]any)["alias"] != b.Alias || + targets[1].(map[string]any)["weight"] != 3.0 { + t.Fatalf("targets = %v", targets) + } + if caps := view["capabilities"].([]any); len(caps) != 2 { + t.Fatalf("capabilities = %v", caps) + } + + resp, body = e.request(t, "POST", "/admin/v1/models", e.adminKey, map[string]any{ + "alias": "two-hop", "targets": []any{a.Alias, "ha"}, + }) + if resp.StatusCode != 400 || errorCode(t, body) != "invalid_target" { + t.Fatalf("alias as a target: %d %s", resp.StatusCode, body) + } + + resp, body = e.request(t, "PATCH", "/admin/v1/models/ha", e.adminKey, map[string]any{ + "strategy": "weighted", "targets": []any{map[string]any{"alias": a.Alias, "weight": 3}, b.Alias}, + }) + view = decode(t, body) + if resp.StatusCode != 200 || view["strategy"] != "weighted" || + view["targets"].([]any)[0].(map[string]any)["alias"] != a.Alias { + t.Fatalf("reorder: %d %s", resp.StatusCode, body) + } + resp, body = e.request(t, "PATCH", "/admin/v1/models/ha", e.adminKey, map[string]any{"strategy": "round_robin"}) + if resp.StatusCode != 200 || decode(t, body)["strategy"] != "round_robin" { + t.Fatalf("strategy only: %d %s", resp.StatusCode, body) + } + resp, body = e.request(t, "PATCH", "/admin/v1/models/ha", e.adminKey, map[string]any{"upstream_name": "x"}) + if resp.StatusCode != 400 || errorCode(t, body) != "invalid_target" { + t.Fatalf("route edit on an alias: %d %s", resp.StatusCode, body) + } + + resp, body = e.request(t, "GET", "/admin/v1/resolve?model=ha", e.adminKey, nil) + view = decode(t, body) + if resp.StatusCode != 200 || view["strategy"] != "round_robin" { + t.Fatalf("resolve: %d %s", resp.StatusCode, body) + } + cands := view["targets"].([]any) + if len(cands) != 2 || cands[0].(map[string]any)["provider"] != "pa" || + cands[0].(map[string]any)["cooling_down"] != false { + t.Fatalf("resolve targets = %v", cands) + } + + resp, body = e.request(t, "PATCH", "/admin/v1/models/ha", e.adminKey, map[string]any{"targets": []any{b.Alias}}) + view = decode(t, body) + if resp.StatusCode != 200 || view["strategy"] != nil || view["target"] != b.Alias || view["provider"] != "pb" { + t.Fatalf("back to one target: %d %s", resp.StatusCode, body) + } + resp, body = e.request(t, "PATCH", "/admin/v1/models/ha", e.adminKey, map[string]any{"strategy": "failover"}) + if resp.StatusCode != 400 || errorCode(t, body) != "invalid_strategy" { + t.Fatalf("strategy on one target: %d %s", resp.StatusCode, body) + } + + resp, body = e.request(t, "PATCH", "/admin/v1/models/ha", e.adminKey, map[string]any{"target": a.Alias}) + if resp.StatusCode != 200 || decode(t, body)["target"] != a.Alias { + t.Fatalf("legacy target: %d %s", resp.StatusCode, body) + } + + events, err := e.st.ListAdminEvents(context.Background(), 50, 0) + if err != nil { + t.Fatal(err) + } + updates := 0 + for _, ev := range events { + if ev.Action == "model.update" && ev.TargetRef == "ha" { + updates++ + } + } + if updates != 4 { + t.Fatalf("model.update events for ha = %d, want 4", updates) + } +} From a00af4b51f06f91120dd5cac2b55769ac1606f7d Mon Sep 17 00:00:00 2001 From: Mathieu Virbel Date: Thu, 8 Oct 2026 10:46:23 -0600 Subject: [PATCH 07/12] Add LiteLLM deployments sharing a model_name as alias targets The first deployment keeps its id: its row moves to a hidden @ name and a new alias row takes the model_name. --- internal/server/litellm.go | 270 +++++++++++++++++++++++++++++--- internal/server/litellm_test.go | 128 +++++++++++++-- 2 files changed, 361 insertions(+), 37 deletions(-) diff --git a/internal/server/litellm.go b/internal/server/litellm.go index 57a35ca..6e7f219 100644 --- a/internal/server/litellm.go +++ b/internal/server/litellm.go @@ -1,7 +1,9 @@ package server import ( + "context" "database/sql" + "math" "net/http" "net/url" "regexp" @@ -19,10 +21,10 @@ import ( // The mapping: a LiteLLM "deployment" (model_name + litellm_params) becomes a // provider (api_base + api_key, reused across deployments with the same // api_base) plus a model binding (model_name as the alias, litellm_params -// .model as the upstream name). One deliberate divergence: aliases are -// globally unique, so re-registering the same mapping is an idempotent -// success but a second different deployment under one model_name (LiteLLM's -// load balancing) is rejected with 409. +// .model as the upstream name). Re-registering the same mapping is an +// idempotent success. A second deployment under one model_name becomes a +// hidden "@" model, and model_name an alias over all +// of them with the weighted strategy. var providerNameStrip = regexp.MustCompile(`[^a-z0-9._-]+`) var providerNameLead = regexp.MustCompile(`^[^a-z0-9]+`) @@ -108,9 +110,9 @@ func (s *Server) ensureProviderFor(w http.ResponseWriter, r *http.Request, auth return p } -func litellmDeploymentView(b *store.ModelBinding, apiBase string) map[string]any { +func litellmDeploymentView(modelName string, b *store.ModelBinding, apiBase string) map[string]any { return map[string]any{ - "model_name": b.Alias, + "model_name": modelName, "litellm_params": map[string]any{ "model": b.UpstreamName, "api_base": apiBase, @@ -120,6 +122,24 @@ func litellmDeploymentView(b *store.ModelBinding, apiBase string) map[string]any } } +// isDeployment reports whether target was created for a deployment of alias. +func isDeployment(alias string, target *store.ModelBinding) bool { + return target.Hidden && strings.HasPrefix(target.Alias, alias+"@") +} + +// deploymentName picks a free "@" name. +func (s *Server) deploymentName(ctx context.Context, modelName, providerName string) (string, error) { + base := modelName + "@" + providerName + name := base + for i := 2; ; i++ { + taken, err := s.store.GetBindingByAlias(ctx, name) + if err != nil || taken == nil { + return name, err + } + name = base + "-" + strconv.Itoa(i) + } +} + // capabilitiesForMode maps LiteLLM's model_info.mode to a capability set. func capabilitiesForMode(mode string) string { switch mode { @@ -136,9 +156,10 @@ func (s *Server) handleLiteLLMModelNew(w http.ResponseWriter, r *http.Request, a var body struct { ModelName string `json:"model_name"` LiteLLMParams struct { - Model string `json:"model"` - APIBase string `json:"api_base"` - APIKey string `json:"api_key"` + Model string `json:"model"` + APIBase string `json:"api_base"` + APIKey string `json:"api_key"` + Weight float64 `json:"weight"` } `json:"litellm_params"` ModelInfo map[string]any `json:"model_info"` } @@ -172,15 +193,23 @@ func (s *Server) handleLiteLLMModelNew(w http.ResponseWriter, r *http.Request, a internalErr(w, "failed to check alias") return } - if existing != nil { - if existing.ProviderID == provider.ID && existing.UpstreamName == body.LiteLLMParams.Model { - writeJSON(w, 200, litellmDeploymentView(existing, provider.BaseURL)) + matches := func(b *store.ModelBinding) bool { + return b.ProviderID == provider.ID && b.UpstreamName == body.LiteLLMParams.Model + } + if existing != nil && len(existing.Targets) == 0 && matches(existing) { + writeJSON(w, 200, litellmDeploymentView(existing.Alias, existing, provider.BaseURL)) + return + } + for _, t := range targetsOf(existing) { + target, err := s.store.GetBindingByID(r.Context(), t.ID) + if err != nil { + internalErr(w, "failed to load target") + return + } + if target != nil && matches(target) { + writeJSON(w, 200, litellmDeploymentView(existing.Alias, target, provider.BaseURL)) return } - writeProxyError(w, apierr.Newf(409, "alias_exists", - "model_name '%s' is already bound to a different deployment; aliases are globally unique", - body.ModelName)) - return } mode, _ := body.ModelInfo["mode"].(string) @@ -192,13 +221,80 @@ func (s *Server) handleLiteLLMModelNew(w http.ResponseWriter, r *http.Request, a Origin: "declared", ProviderName: provider.Name, } + if existing != nil { + name, err := s.deploymentName(r.Context(), body.ModelName, provider.Name) + if err != nil { + internalErr(w, "failed to check alias") + return + } + binding.Alias = name + binding.Hidden = true + } audit := &store.Audit{Actor: auth.PrincipalID, Action: "model.create", TargetKind: "model", TargetRef: binding.Alias} if err := s.store.CreateBinding(r.Context(), binding, audit); err != nil { internalErr(w, "failed to create binding") return } + if existing != nil { + weight := max(int(math.Round(body.LiteLLMParams.Weight)), 1) + if perr := s.addDeployment(r.Context(), auth, existing, binding, weight); perr != nil { + s.invalidate() + writeProxyError(w, perr) + return + } + } s.invalidate() - writeJSON(w, 200, litellmDeploymentView(binding, provider.BaseURL)) + writeJSON(w, 200, litellmDeploymentView(body.ModelName, binding, provider.BaseURL)) +} + +func targetsOf(b *store.ModelBinding) []store.BindingTarget { + if b == nil { + return nil + } + return b.Targets +} + +// addDeployment makes deployment one more target of existing. A direct model +// keeps its id: it moves to a hidden deployment name, and a new alias takes +// its place. +func (s *Server) addDeployment(ctx context.Context, auth *Auth, existing, deployment *store.ModelBinding, + weight int) *apierr.ProxyError { + alias := existing + if len(existing.Targets) == 0 { + name, err := s.deploymentName(ctx, existing.Alias, existing.ProviderName) + if err != nil { + return apierr.New(500, "internal_error", "failed to check alias") + } + moved := *existing + moved.Alias = name + moved.Hidden = true + audit := &store.Audit{Actor: auth.PrincipalID, Action: "model.update", TargetKind: "model", TargetRef: name} + if err := s.store.UpdateBinding(ctx, &moved, audit); err != nil { + return apierr.New(500, "internal_error", "failed to update binding") + } + alias = &store.ModelBinding{ + Alias: existing.Alias, + Origin: existing.Origin, + Hidden: existing.Hidden, + Targets: []store.BindingTarget{{ID: moved.ID, Alias: name, Weight: 1}}, + } + } + alias.Targets = append(alias.Targets, store.BindingTarget{ID: deployment.ID, Alias: deployment.Alias, Weight: weight}) + if alias.Strategy == "" { + alias.Strategy = "weighted" + } + audit := &store.Audit{Actor: auth.PrincipalID, Action: "model.update", TargetKind: "model", TargetRef: alias.Alias} + if alias.ID == "" { + audit.Action = "model.create" + if err := s.store.CreateBinding(ctx, alias, audit); err != nil { + return apierr.New(500, "internal_error", "failed to create binding") + } + return nil + } + if err := s.store.UpdateBinding(ctx, alias, audit); err != nil { + return apierr.New(500, "internal_error", "failed to update binding") + } + return nil } func (s *Server) handleLiteLLMModelInfo(w http.ResponseWriter, r *http.Request, auth *Auth) { @@ -216,9 +312,37 @@ func (s *Server) handleLiteLLMModelInfo(w http.ResponseWriter, r *http.Request, for _, p := range providers { baseByID[p.ID] = p.BaseURL } + byID := make(map[string]*store.ModelBinding, len(bindings)) + for i := range bindings { + byID[bindings[i].ID] = &bindings[i] + } + // A deployment target is listed under its alias, not on its own. + listed := make(map[string]bool) + for _, b := range bindings { + if b.Strategy == "" { + continue + } + for _, t := range b.Targets { + if target := byID[t.ID]; target != nil && isDeployment(b.Alias, target) { + listed[t.ID] = true + } + } + } data := make([]map[string]any, 0, len(bindings)) for i := range bindings { - data = append(data, litellmDeploymentView(&bindings[i], baseByID[bindings[i].ProviderID])) + b := &bindings[i] + if listed[b.ID] { + continue + } + if b.Strategy == "" { + data = append(data, litellmDeploymentView(b.Alias, b, baseByID[b.ProviderID])) + continue + } + for _, t := range b.Targets { + if target := byID[t.ID]; target != nil { + data = append(data, litellmDeploymentView(b.Alias, target, baseByID[target.ProviderID])) + } + } } writeJSON(w, 200, map[string]any{"data": data}) } @@ -240,11 +364,111 @@ func (s *Server) handleLiteLLMModelDelete(w http.ResponseWriter, r *http.Request writeProxyError(w, apierr.Newf(404, "model_not_found", "no deployment with id '%s'", body.ID)) return } - audit := &store.Audit{Actor: auth.PrincipalID, Action: "model.delete", TargetKind: "model", TargetRef: binding.Alias} - if err := s.store.DeleteBinding(r.Context(), binding.ID, audit); err != nil { - internalErr(w, "failed to delete binding") + modelName, perr := s.deleteDeployment(r.Context(), auth, binding) + s.invalidate() + if perr != nil { + writeProxyError(w, perr) return } - s.invalidate() - writeJSON(w, 200, map[string]any{"deleted": body.ID, "model_name": binding.Alias}) + writeJSON(w, 200, map[string]any{"deleted": body.ID, "model_name": modelName}) +} + +// deleteDeployment removes a model and returns the model_name it served +// under. A target of several-target aliases leaves them first. An alias +// with one deployment left becomes that model again. +func (s *Server) deleteDeployment(ctx context.Context, auth *Auth, binding *store.ModelBinding) (string, *apierr.ProxyError) { + internal := func(msg string) (string, *apierr.ProxyError) { + return "", apierr.New(500, "internal_error", msg) + } + names, err := s.store.ListBindingsTargeting(ctx, binding.ID) + if err != nil { + return internal("failed to check aliases") + } + var aliases []*store.ModelBinding + for _, name := range names { + alias, err := s.store.GetBindingByAlias(ctx, name) + if err != nil || alias == nil { + return internal("failed to load alias") + } + if alias.Strategy == "" { + return "", apierr.Newf(409, "model_in_use", + "'%s' is the target of '%s'; delete or repoint that first", binding.Alias, name) + } + aliases = append(aliases, alias) + } + + modelName := binding.Alias + for _, alias := range aliases { + if isDeployment(alias.Alias, binding) { + modelName = alias.Alias + } + kept := alias.Targets[:0] + for _, t := range alias.Targets { + if t.ID != binding.ID { + kept = append(kept, t) + } + } + alias.Targets = kept + audit := &store.Audit{Actor: auth.PrincipalID, Action: "model.update", TargetKind: "model", TargetRef: alias.Alias} + if err := s.store.UpdateBinding(ctx, alias, audit); err != nil { + return internal("failed to update alias") + } + } + + // Deleting a several-target alias takes its deployments with it. + var deployments []*store.ModelBinding + for _, t := range binding.Targets { + target, err := s.store.GetBindingByID(ctx, t.ID) + if err != nil { + return internal("failed to load target") + } + if target != nil && binding.Strategy != "" && isDeployment(binding.Alias, target) { + deployments = append(deployments, target) + } + } + audit := &store.Audit{Actor: auth.PrincipalID, Action: "model.delete", TargetKind: "model", TargetRef: binding.Alias} + if err := s.store.DeleteBinding(ctx, binding.ID, audit); err != nil { + return internal("failed to delete binding") + } + for _, d := range deployments { + if _, perr := s.deleteDeployment(ctx, auth, d); perr != nil { + return "", perr + } + } + + for _, alias := range aliases { + if perr := s.collapseAlias(ctx, auth, alias); perr != nil { + return "", perr + } + } + return modelName, nil +} + +// collapseAlias turns an alias with one deployment left back into that model. +func (s *Server) collapseAlias(ctx context.Context, auth *Auth, alias *store.ModelBinding) *apierr.ProxyError { + if len(alias.Targets) != 1 { + return nil + } + last, err := s.store.GetBindingByID(ctx, alias.Targets[0].ID) + if err != nil { + return apierr.New(500, "internal_error", "failed to load target") + } + if last == nil || !isDeployment(alias.Alias, last) { + return nil + } + names, err := s.store.ListBindingsTargeting(ctx, last.ID) + if err != nil || len(names) != 1 { + return nil + } + audit := &store.Audit{Actor: auth.PrincipalID, Action: "model.delete", TargetKind: "model", TargetRef: alias.Alias} + if err := s.store.DeleteBinding(ctx, alias.ID, audit); err != nil { + return apierr.New(500, "internal_error", "failed to delete binding") + } + last.Alias = alias.Alias + last.Hidden = alias.Hidden + audit = &store.Audit{Actor: auth.PrincipalID, Action: "model.update", TargetKind: "model", TargetRef: last.Alias} + if err := s.store.UpdateBinding(ctx, last, audit); err != nil { + return apierr.New(500, "internal_error", "failed to update binding") + } + return nil } diff --git a/internal/server/litellm_test.go b/internal/server/litellm_test.go index b69abb3..84bd383 100644 --- a/internal/server/litellm_test.go +++ b/internal/server/litellm_test.go @@ -44,20 +44,6 @@ func TestLiteLLMManagementCompat(t *testing.T) { t.Fatalf("re-register: %d %s", resp.StatusCode, body) } - // A different mapping under the same model_name is rejected (aliases are - // unique; llmproxy does not do LiteLLM-style multi-deployment balancing). - conflicting := map[string]any{ - "model_name": "acme/zdr-kimi", - "litellm_params": map[string]any{ - "model": "some-other-model", - "api_base": e.upstream.srv.URL + "/v1", - }, - } - resp, body = e.request(t, "POST", "/model/new", e.adminKey, conflicting) - if resp.StatusCode != 409 || errorCode(t, body) != "alias_exists" { - t.Fatalf("conflicting deployment: %d %s", resp.StatusCode, body) - } - // A deployment on an already-known api_base reuses that provider (the // seeded "fake" provider points at the same upstream). _, body = e.request(t, "GET", "/admin/v1/providers", e.adminKey, nil) @@ -161,3 +147,117 @@ func TestRootOpenAIAliases(t *testing.T) { t.Fatalf("POST /embeddings: %d %s", resp.StatusCode, body) } } + +// A second deployment under one model_name becomes another target of that alias. +func TestLiteLLMDeploymentsShareModelName(t *testing.T) { + e := newEnv(t) + other := strings.Replace(e.upstream.srv.URL, "127.0.0.1", "localhost", 1) + deploy := func(model, base string, weight int) (int, map[string]any) { + t.Helper() + params := map[string]any{"model": model, "api_base": base, "api_key": upstreamKey} + if weight > 0 { + params["weight"] = weight + } + resp, body := e.request(t, "POST", "/model/new", e.adminKey, map[string]any{ + "model_name": "shared", "litellm_params": params, + }) + return resp.StatusCode, decode(t, body) + } + idOf := func(view map[string]any) string { + info, _ := view["model_info"].(map[string]any) + id, _ := info["id"].(string) + return id + } + resolve := func() map[string]any { + t.Helper() + resp, body := e.request(t, "GET", "/admin/v1/resolve?model=shared", e.adminKey, nil) + if resp.StatusCode != 200 { + t.Fatalf("resolve: %d %s", resp.StatusCode, body) + } + return decode(t, body) + } + listing := func() []map[string]any { + t.Helper() + _, body := e.request(t, "GET", "/model/info", e.adminKey, nil) + var out []map[string]any + for _, d := range decode(t, body)["data"].([]any) { + dep := d.(map[string]any) + if strings.Contains(dep["model_name"].(string), "@") { + t.Fatalf("a target created for a deployment is listed on its own: %v", dep) + } + if dep["model_name"] == "shared" { + out = append(out, dep) + } + } + return out + } + + code, first := deploy("m1", e.upstream.srv.URL+"/v1", 0) + if code != 200 { + t.Fatalf("first deployment: %d %v", code, first) + } + code, second := deploy("m2", other+"/v1", 3) + if code != 200 || second["model_name"] != "shared" || idOf(second) == "" || idOf(second) == idOf(first) { + t.Fatalf("second deployment: %d %v", code, second) + } + if code, again := deploy("m2", other+"/v1", 3); code != 200 || idOf(again) != idOf(second) { + t.Fatalf("re-register second: %d %v", code, again) + } + code, firstAgain := deploy("m1", e.upstream.srv.URL+"/v1", 0) + if code != 200 { + t.Fatalf("re-register first: %d %v", code, firstAgain) + } + + plan := resolve() + targets, _ := plan["targets"].([]any) + if plan["strategy"] != "weighted" || len(targets) != 2 { + t.Fatalf("plan after two deployments: %v", plan) + } + if w := targets[1].(map[string]any)["weight"]; w != float64(3) { + t.Fatalf("weight from litellm_params: %v", w) + } + + deps := listing() + if len(deps) != 2 { + t.Fatalf("/model/info deployments: %v", deps) + } + models := map[string]string{} + for _, d := range deps { + models[d["litellm_params"].(map[string]any)["model"].(string)] = idOf(d) + } + if models["m2"] != idOf(second) || models["m1"] == "" || models["m1"] != idOf(firstAgain) { + t.Fatalf("/model/info ids: %v", models) + } + + resp, body := e.request(t, "POST", "/v1/chat/completions", e.memberKey, map[string]any{ + "model": "shared", "messages": []map[string]any{{"role": "user", "content": "hi"}}, + }) + if resp.StatusCode != 200 { + t.Fatalf("chat through shared: %d %s", resp.StatusCode, body) + } + + resp, body = e.request(t, "POST", "/model/delete", e.adminKey, map[string]any{"id": idOf(second)}) + if resp.StatusCode != 200 || decode(t, body)["model_name"] != "shared" { + t.Fatalf("delete one deployment: %d %s", resp.StatusCode, body) + } + plan = resolve() + if plan["strategy"] != nil || plan["upstream_name"] != "m1" { + t.Fatalf("plan after delete: %v", plan) + } + deps = listing() + if len(deps) != 1 || deps[0]["litellm_params"].(map[string]any)["model"] != "m1" { + t.Fatalf("/model/info after delete: %v", deps) + } + + resp, body = e.request(t, "POST", "/model/delete", e.adminKey, map[string]any{"id": idOf(deps[0])}) + if resp.StatusCode != 200 { + t.Fatalf("delete last deployment: %d %s", resp.StatusCode, body) + } + if deps = listing(); len(deps) != 0 { + t.Fatalf("/model/info after deleting all: %v", deps) + } + _, body = e.request(t, "GET", "/admin/v1/models", e.adminKey, nil) + if strings.Contains(string(body), "shared") { + t.Fatalf("models left behind: %s", body) + } +} From 24caa49762347c0a69125f2d5235c2ffa98c738f Mon Sep 17 00:00:00 2001 From: Mathieu Virbel Date: Thu, 8 Oct 2026 10:55:11 -0600 Subject: [PATCH 08/12] Pick several targets for an alias in the UI --- internal/server/admin.go | 3 +- internal/server/failover_test.go | 30 +++ internal/server/openai.go | 11 +- .../server/uidist/assets/index-DRB3hIKQ.css | 1 + internal/store/types.go | 1 + internal/store/usage.go | 7 +- ui/src/components/ModelsCatalog.tsx | 7 + ui/src/lib/api.ts | 14 + ui/src/lib/strategies.ts | 10 + ui/src/pages/Models.tsx | 252 +++++++++++++++--- ui/src/pages/Requests.tsx | 5 + 11 files changed, 292 insertions(+), 49 deletions(-) create mode 100644 internal/server/uidist/assets/index-DRB3hIKQ.css create mode 100644 ui/src/lib/strategies.ts diff --git a/internal/server/admin.go b/internal/server/admin.go index 0ee7e47..68c4d85 100644 --- a/internal/server/admin.go +++ b/internal/server/admin.go @@ -983,7 +983,7 @@ func (s *Server) handleResolve(w http.ResponseWriter, r *http.Request, auth *Aut "provider": rt.ProviderName, "upstream_name": rt.UpstreamName, "url": rt.EndpointURL(endpoint), - "weight": rt.Weight, + "weight": max(rt.Weight, 1), "cooling_down": cooling, "cooldown_until": nil, } @@ -1306,6 +1306,7 @@ func (s *Server) serveRequestLog(w http.ResponseWriter, r *http.Request) { "status_code": nil, "streamed": ev.Streamed, "cancelled": ev.Cancelled, + "failed_over": ev.FailedOver, "cost": nil, "unpriced": ev.Unpriced, "duration_ms": ev.DurationMs, diff --git a/internal/server/failover_test.go b/internal/server/failover_test.go index 543eb81..5cc34b8 100644 --- a/internal/server/failover_test.go +++ b/internal/server/failover_test.go @@ -132,6 +132,36 @@ func TestFailoverOnRefusedConnection(t *testing.T) { if !strings.Contains(string(metrics), `llmproxy_failover_total{model="ha",provider="dead",reason="unreachable"} 1`) { t.Fatalf("failover metric missing:\n%s", metrics) } + + _, logBody := e.request(t, "GET", "/stats/requests?model=ha", e.adminKey, nil) + flags := map[string]bool{} + for _, row := range decode(t, logBody)["requests"].([]any) { + entry := row.(map[string]any) + flags[entry["outcome"].(string)] = entry["failed_over"] == true + } + if !flags["unreachable"] || flags["ok"] { + t.Fatalf("failed_over flags in the request log: %v %s", flags, logBody) + } + + if resp, body := e.request(t, "POST", "/admin/v1/models", e.adminKey, + map[string]any{"alias": "one", "target": "alpha"}); resp.StatusCode != 201 { + t.Fatalf("create one: %d %s", resp.StatusCode, body) + } + _, modelsBody := e.request(t, "GET", "/v1/models", e.memberKey, nil) + for _, m := range decode(t, modelsBody)["data"].([]any) { + entry := m.(map[string]any) + switch entry["id"] { + case "ha": + targets, _ := entry["targets"].([]any) + if entry["strategy"] != "failover" || len(targets) != 2 || targets[0] != "dead-m" || targets[1] != "alpha" { + t.Fatalf("ha in /v1/models: %v", entry) + } + case "one": + if entry["alias_of"] != "alpha" || entry["targets"] != nil || entry["strategy"] != nil { + t.Fatalf("one in /v1/models: %v", entry) + } + } + } } func TestFailoverOnRetryableStatus(t *testing.T) { diff --git a/internal/server/openai.go b/internal/server/openai.go index 764b134..9967ba6 100644 --- a/internal/server/openai.go +++ b/internal/server/openai.go @@ -58,10 +58,17 @@ func (s *Server) handleListModels(w http.ResponseWriter, r *http.Request) { // capabilities and alias_of are extensions to the OpenAI shape: // curated catalog metadata, no credentials, ignored by clients that // only read id. - var aliasOf any + var aliasOf, targets, strategy any if b.TargetAlias != "" { aliasOf = b.TargetAlias } + if len(b.Targets) > 1 { + names := make([]string, 0, len(b.Targets)) + for _, t := range b.Targets { + names = append(names, t.Alias) + } + targets, strategy = names, b.Strategy + } entry := map[string]any{ "id": b.Alias, "object": "model", @@ -69,6 +76,8 @@ func (s *Server) handleListModels(w http.ResponseWriter, r *http.Request) { "owned_by": b.ProviderName, "capabilities": splitCapabilitySet(b.CapabilitySet), "alias_of": aliasOf, + "targets": targets, + "strategy": strategy, "hidden": b.Hidden, } // Prices are what the proxy bills this model at, per million units, so diff --git a/internal/server/uidist/assets/index-DRB3hIKQ.css b/internal/server/uidist/assets/index-DRB3hIKQ.css new file mode 100644 index 0000000..7ddcf93 --- /dev/null +++ b/internal/server/uidist/assets/index-DRB3hIKQ.css @@ -0,0 +1 @@ +/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-space-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-duration:initial;--tw-animation-delay:0s;--tw-animation-direction:normal;--tw-animation-duration:initial;--tw-animation-fill-mode:forwards;--tw-animation-iteration-count:1;--tw-enter-opacity:1;--tw-enter-rotate:0;--tw-enter-scale:1;--tw-enter-translate-x:0;--tw-enter-translate-y:0;--tw-exit-opacity:1;--tw-exit-rotate:0;--tw-exit-scale:1;--tw-exit-translate-x:0;--tw-exit-translate-y:0}}}@layer theme{:root,:host{--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;--color-red-500:oklch(63.7% .237 25.331);--color-yellow-400:oklch(85.2% .199 91.936);--color-yellow-500:oklch(79.5% .184 86.047);--color-yellow-600:oklch(68.1% .162 75.834);--color-green-500:oklch(72.3% .219 149.579);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-sm:24rem;--container-lg:32rem;--container-6xl:72rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height: 1.5 ;--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--tracking-tight:-.025em;--tracking-widest:.1em;--leading-relaxed:1.625;--radius-xs:.125rem;--animate-spin:spin 1s linear infinite;--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:"Aspekta", ui-sans-serif, system-ui, sans-serif;--default-mono-font-family:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;--color-popover:rgb(var(--popover));--color-popover-foreground:rgb(var(--popover-foreground));--color-border:rgb(var(--border))}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;-moz-tab-size:4;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab,red,red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){-webkit-appearance:button;-moz-appearance:button;appearance:button}::file-selector-button{-webkit-appearance:button;-moz-appearance:button;appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}*{border-color:rgb(var(--border));outline-color:rgb(var(--ring))}@supports (color:color-mix(in lab,red,red)){*{outline-color:color-mix(in oklab,rgb(var(--ring)) 50%,transparent)}}body{background-color:rgb(var(--background));color:rgb(var(--foreground));-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;font-family:Aspekta,ui-sans-serif,system-ui,sans-serif}}@layer components;@layer utilities{.\@container\/card-header{container:card-header/inline-size}.pointer-events-none{pointer-events:none}.collapse{visibility:collapse}.invisible{visibility:hidden}.visible{visibility:visible}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.inset-0{top:0;right:0;bottom:0;left:0}.inset-x-0{inset-inline:0}.inset-y-0{inset-block:0}.-top-2{top:calc(var(--spacing) * -2)}.top-0{top:0}.top-1\/2,.top-\[50\%\]{top:50%}.-right-2{right:calc(var(--spacing) * -2)}.right-1{right:var(--spacing)}.right-2{right:calc(var(--spacing) * 2)}.right-8{right:calc(var(--spacing) * 8)}.left-1\/2{left:50%}.left-2{left:calc(var(--spacing) * 2)}.left-\[50\%\]{left:50%}.z-10{z-index:10}.z-20{z-index:20}.z-50{z-index:50}.col-start-2{grid-column-start:2}.row-span-2{grid-row:span 2/span 2}.row-start-1{grid-row-start:1}.-mx-1{margin-inline:calc(var(--spacing) * -1)}.mx-auto{margin-inline:auto}.my-1{margin-block:var(--spacing)}.-mt-2{margin-top:calc(var(--spacing) * -2)}.mt-1{margin-top:var(--spacing)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mr-1{margin-right:var(--spacing)}.mb-1{margin-bottom:var(--spacing)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.ml-1{margin-left:var(--spacing)}.ml-1\.5{margin-left:calc(var(--spacing) * 1.5)}.ml-2{margin-left:calc(var(--spacing) * 2)}.ml-auto{margin-left:auto}.block{display:block}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.table{display:table}.table-caption{display:table-caption}.table-cell{display:table-cell}.table-row{display:table-row}.field-sizing-content{field-sizing:content}.size-2{width:calc(var(--spacing) * 2);height:calc(var(--spacing) * 2)}.size-2\.5{width:calc(var(--spacing) * 2.5);height:calc(var(--spacing) * 2.5)}.size-3{width:calc(var(--spacing) * 3);height:calc(var(--spacing) * 3)}.size-3\.5{width:calc(var(--spacing) * 3.5);height:calc(var(--spacing) * 3.5)}.size-4{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.size-5{width:calc(var(--spacing) * 5);height:calc(var(--spacing) * 5)}.size-6{width:calc(var(--spacing) * 6);height:calc(var(--spacing) * 6)}.size-8{width:calc(var(--spacing) * 8);height:calc(var(--spacing) * 8)}.size-9{width:calc(var(--spacing) * 9);height:calc(var(--spacing) * 9)}.size-10{width:calc(var(--spacing) * 10);height:calc(var(--spacing) * 10)}.h-0\.5{height:calc(var(--spacing) * .5)}.h-1\.5{height:calc(var(--spacing) * 1.5)}.h-2{height:calc(var(--spacing) * 2)}.h-6{height:calc(var(--spacing) * 6)}.h-8{height:calc(var(--spacing) * 8)}.h-9{height:calc(var(--spacing) * 9)}.h-10{height:calc(var(--spacing) * 10)}.h-16{height:calc(var(--spacing) * 16)}.h-\[1\.15rem\]{height:1.15rem}.h-\[calc\(100\%-1px\)\]{height:calc(100% - 1px)}.h-\[var\(--radix-select-trigger-height\)\]{height:var(--radix-select-trigger-height)}.h-full{height:100%}.h-px{height:1px}.max-h-\(--radix-dropdown-menu-content-available-height\){max-height:var(--radix-dropdown-menu-content-available-height)}.max-h-\(--radix-select-content-available-height\){max-height:var(--radix-select-content-available-height)}.max-h-40{max-height:calc(var(--spacing) * 40)}.max-h-48{max-height:calc(var(--spacing) * 48)}.max-h-64{max-height:calc(var(--spacing) * 64)}.max-h-96{max-height:calc(var(--spacing) * 96)}.min-h-9{min-height:calc(var(--spacing) * 9)}.min-h-16{min-height:calc(var(--spacing) * 16)}.min-h-screen{min-height:100vh}.w-3{width:calc(var(--spacing) * 3)}.w-6{width:calc(var(--spacing) * 6)}.w-8{width:calc(var(--spacing) * 8)}.w-12{width:calc(var(--spacing) * 12)}.w-16{width:calc(var(--spacing) * 16)}.w-20{width:calc(var(--spacing) * 20)}.w-24{width:calc(var(--spacing) * 24)}.w-32{width:calc(var(--spacing) * 32)}.w-36{width:calc(var(--spacing) * 36)}.w-40{width:calc(var(--spacing) * 40)}.w-44{width:calc(var(--spacing) * 44)}.w-56{width:calc(var(--spacing) * 56)}.w-64{width:calc(var(--spacing) * 64)}.w-auto{width:auto}.w-fit{width:fit-content}.w-full{width:100%}.max-w-6xl{max-width:var(--container-6xl)}.max-w-36{max-width:calc(var(--spacing) * 36)}.max-w-40{max-width:calc(var(--spacing) * 40)}.max-w-44{max-width:calc(var(--spacing) * 44)}.max-w-\[85\%\]{max-width:85%}.max-w-\[calc\(100\%-2rem\)\]{max-width:calc(100% - 2rem)}.max-w-full{max-width:100%}.max-w-sm{max-width:var(--container-sm)}.min-w-0{min-width:0}.min-w-32{min-width:calc(var(--spacing) * 32)}.min-w-36{min-width:calc(var(--spacing) * 36)}.min-w-48{min-width:calc(var(--spacing) * 48)}.min-w-\[8rem\]{min-width:8rem}.min-w-\[var\(--radix-select-trigger-width\)\]{min-width:var(--radix-select-trigger-width)}.flex-1{flex:1}.shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.caption-bottom{caption-side:bottom}.origin-\(--radix-dropdown-menu-content-transform-origin\){transform-origin:var(--radix-dropdown-menu-content-transform-origin)}.origin-\(--radix-select-content-transform-origin\){transform-origin:var(--radix-select-content-transform-origin)}.-translate-x-1\/2{--tw-translate-x: -50% ;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-x-\[-50\%\]{--tw-translate-x:-50%;translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-y-1\/2{--tw-translate-y: -50% ;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-y-\[-50\%\]{--tw-translate-y:-50%;translate:var(--tw-translate-x) var(--tw-translate-y)}.rotate-180{rotate:180deg}.animate-pulse{animation:var(--animate-pulse)}.animate-spin{animation:var(--animate-spin)}.cursor-default{cursor:default}.cursor-pointer{cursor:pointer}.scroll-my-1{scroll-margin-block:var(--spacing)}.auto-rows-min{grid-auto-rows:min-content}.\[grid-template-columns\:repeat\(auto-fit\,minmax\(260px\,1fr\)\)\]{grid-template-columns:repeat(auto-fit,minmax(260px,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-\[minmax\(6rem\,14rem\)_1fr_auto\]{grid-template-columns:minmax(6rem,14rem) 1fr auto}.grid-rows-\[auto_auto\]{grid-template-rows:auto auto}.flex-col{flex-direction:column}.flex-col-reverse{flex-direction:column-reverse}.flex-row{flex-direction:row}.flex-wrap{flex-wrap:wrap}.items-baseline{align-items:baseline}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-0\.5{gap:calc(var(--spacing) * .5)}.gap-1{gap:var(--spacing)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-6{gap:calc(var(--spacing) * 6)}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}.gap-x-4{column-gap:calc(var(--spacing) * 4)}.gap-x-6{column-gap:calc(var(--spacing) * 6)}.gap-x-8{column-gap:calc(var(--spacing) * 8)}.gap-y-1{row-gap:var(--spacing)}.gap-y-2{row-gap:calc(var(--spacing) * 2)}.gap-y-3{row-gap:calc(var(--spacing) * 3)}.gap-y-4{row-gap:calc(var(--spacing) * 4)}.self-start{align-self:flex-start}.justify-self-end{justify-self:flex-end}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.overflow-visible{overflow:visible}.overflow-x-auto{overflow-x:auto}.overflow-x-hidden{overflow-x:hidden}.overflow-y-auto{overflow-y:auto}.rounded{border-radius:.25rem}.rounded-\[4px\]{border-radius:4px}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:calc(var(--radius) + 2px)}.rounded-md{border-radius:var(--radius)}.rounded-sm{border-radius:calc(var(--radius) - 2px)}.rounded-xl{border-radius:calc(var(--radius) + 4px)}.rounded-xs{border-radius:var(--radius-xs)}.border{border-style:var(--tw-border-style);border-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-b-2{border-bottom-style:var(--tw-border-style);border-bottom-width:2px}.border-dashed{--tw-border-style:dashed;border-style:dashed}.border-border{border-color:rgb(var(--border))}.border-foreground{border-color:rgb(var(--foreground))}.border-input{border-color:rgb(var(--input))}.border-primary\/40{border-color:rgb(var(--primary))}@supports (color:color-mix(in lab,red,red)){.border-primary\/40{border-color:color-mix(in oklab,rgb(var(--primary)) 40%,transparent)}}.border-transparent{border-color:#0000}.bg-\[\#1a7f37\]{background-color:#1a7f37}.bg-\[\#3b82f6\]{background-color:#3b82f6}.bg-\[\#4b5563\]{background-color:#4b5563}.bg-\[\#9a6700\]{background-color:#9a6700}.bg-\[\#22c55e\]{background-color:#22c55e}.bg-\[\#0969da\]{background-color:#0969da}.bg-\[\#a855f7\]{background-color:#a855f7}.bg-\[\#f97316\]{background-color:#f97316}.bg-\[rgb\(var\(--border\)\)\]{background-color:rgb(var(--border))}.bg-accent{background-color:rgb(var(--accent))}.bg-background{background-color:rgb(var(--background))}.bg-black\/50{background-color:#00000080}@supports (color:color-mix(in lab,red,red)){.bg-black\/50{background-color:color-mix(in oklab,var(--color-black) 50%,transparent)}}.bg-border{background-color:rgb(var(--border))}.bg-card{background-color:rgb(var(--card))}.bg-chart-1{background-color:rgb(var(--chart-1))}.bg-chart-2{background-color:rgb(var(--chart-2))}.bg-chart-3{background-color:rgb(var(--chart-3))}.bg-chart-5{background-color:rgb(var(--chart-5))}.bg-destructive{background-color:rgb(var(--destructive))}.bg-foreground\/5{background-color:rgb(var(--foreground))}@supports (color:color-mix(in lab,red,red)){.bg-foreground\/5{background-color:color-mix(in oklab,rgb(var(--foreground)) 5%,transparent)}}.bg-green-500{background-color:var(--color-green-500)}.bg-muted{background-color:rgb(var(--muted))}.bg-muted-foreground{background-color:rgb(var(--muted-foreground))}.bg-muted\/30{background-color:rgb(var(--muted))}@supports (color:color-mix(in lab,red,red)){.bg-muted\/30{background-color:color-mix(in oklab,rgb(var(--muted)) 30%,transparent)}}.bg-muted\/50{background-color:rgb(var(--muted))}@supports (color:color-mix(in lab,red,red)){.bg-muted\/50{background-color:color-mix(in oklab,rgb(var(--muted)) 50%,transparent)}}.bg-popover{background-color:rgb(var(--popover))}.bg-primary,.bg-primary\/5{background-color:rgb(var(--primary))}@supports (color:color-mix(in lab,red,red)){.bg-primary\/5{background-color:color-mix(in oklab,rgb(var(--primary)) 5%,transparent)}}.bg-red-500{background-color:var(--color-red-500)}.bg-secondary{background-color:rgb(var(--secondary))}.bg-transparent{background-color:#0000}.bg-yellow-500{background-color:var(--color-yellow-500)}.fill-border{fill:rgb(var(--border))}.fill-chart-1{fill:rgb(var(--chart-1))}.fill-chart-2{fill:rgb(var(--chart-2))}.fill-chart-3{fill:rgb(var(--chart-3))}.fill-chart-5{fill:rgb(var(--chart-5))}.fill-current{fill:currentColor}.fill-foreground,.fill-foreground\/5{fill:rgb(var(--foreground))}@supports (color:color-mix(in lab,red,red)){.fill-foreground\/5{fill:color-mix(in oklab,rgb(var(--foreground)) 5%,transparent)}}.fill-muted-foreground{fill:rgb(var(--muted-foreground))}.stroke-border{stroke:rgb(var(--border))}.object-cover{object-fit:cover}.p-1{padding:var(--spacing)}.p-2{padding:calc(var(--spacing) * 2)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-6{padding:calc(var(--spacing) * 6)}.p-\[3px\]{padding:3px}.px-0\.5{padding-inline:calc(var(--spacing) * .5)}.px-1{padding-inline:var(--spacing)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-6{padding-inline:calc(var(--spacing) * 6)}.py-0{padding-block:0}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:var(--spacing)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-8{padding-block:calc(var(--spacing) * 8)}.py-12{padding-block:calc(var(--spacing) * 12)}.pt-4{padding-top:calc(var(--spacing) * 4)}.pr-0{padding-right:0}.pr-1{padding-right:var(--spacing)}.pr-2{padding-right:calc(var(--spacing) * 2)}.pr-6{padding-right:calc(var(--spacing) * 6)}.pr-8{padding-right:calc(var(--spacing) * 8)}.pr-14{padding-right:calc(var(--spacing) * 14)}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.pb-3{padding-bottom:calc(var(--spacing) * 3)}.pl-2{padding-left:calc(var(--spacing) * 2)}.pl-8{padding-left:calc(var(--spacing) * 8)}.pl-\[18px\]{padding-left:18px}.pl-\[26px\]{padding-left:26px}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.align-middle{vertical-align:middle}.align-top{vertical-align:top}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace}.font-sans{font-family:Aspekta,ui-sans-serif,system-ui,sans-serif}.font-serif{font-family:"Source Serif 4",Source Serif Pro,Georgia,serif}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-sm\/relaxed{font-size:var(--text-sm);line-height:var(--leading-relaxed)}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[10px\]{font-size:10px}.leading-none{--tw-leading:1;line-height:1}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-widest{--tw-tracking:var(--tracking-widest);letter-spacing:var(--tracking-widest)}.text-balance{text-wrap:balance}.wrap-anywhere{overflow-wrap:anywhere}.break-all{word-break:break-all}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.text-accent-foreground{color:rgb(var(--accent-foreground))}.text-card-foreground{color:rgb(var(--card-foreground))}.text-current{color:currentColor}.text-destructive{color:rgb(var(--destructive))}.text-foreground{color:rgb(var(--foreground))}.text-muted-foreground{color:rgb(var(--muted-foreground))}.text-popover-foreground{color:rgb(var(--popover-foreground))}.text-primary{color:rgb(var(--primary))}.text-primary-foreground{color:rgb(var(--primary-foreground))}.text-secondary-foreground{color:rgb(var(--secondary-foreground))}.text-white{color:var(--color-white)}.text-yellow-600{color:var(--color-yellow-600)}.lowercase{text-transform:lowercase}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.underline-offset-2{text-underline-offset:2px}.underline-offset-4{text-underline-offset:4px}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a), 0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-xs{--tw-shadow:0 1px 2px 0 var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring-0{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.outline-hidden{--tw-outline-style:none;outline-style:none}@media(forced-colors:active){.outline-hidden{outline-offset:2px;outline:2px solid #0000}}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.transition-\[color\,box-shadow\]{transition-property:color,box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-shadow{transition-property:box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-none{transition-property:none}.duration-200{--tw-duration:.2s;transition-duration:.2s}.outline-none{--tw-outline-style:none;outline-style:none}.select-none{-webkit-user-select:none;user-select:none}.running{animation-play-state:running}.group-data-\[disabled\=true\]\:pointer-events-none:is(:where(.group)[data-disabled=true] *){pointer-events:none}.group-data-\[disabled\=true\]\:opacity-50:is(:where(.group)[data-disabled=true] *){opacity:.5}.peer-disabled\:cursor-not-allowed:is(:where(.peer):disabled~*){cursor:not-allowed}.peer-disabled\:opacity-50:is(:where(.peer):disabled~*){opacity:.5}.selection\:bg-primary ::selection{background-color:rgb(var(--primary))}.selection\:bg-primary::selection{background-color:rgb(var(--primary))}.selection\:text-primary-foreground ::selection{color:rgb(var(--primary-foreground))}.selection\:text-primary-foreground::selection{color:rgb(var(--primary-foreground))}.file\:inline-flex::file-selector-button{display:inline-flex}.file\:h-7::file-selector-button{height:calc(var(--spacing) * 7)}.file\:border-0::file-selector-button{border-style:var(--tw-border-style);border-width:0}.file\:bg-transparent::file-selector-button{background-color:#0000}.file\:text-sm::file-selector-button{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.file\:font-medium::file-selector-button{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.file\:text-foreground::file-selector-button{color:rgb(var(--foreground))}.placeholder\:text-muted-foreground::placeholder{color:rgb(var(--muted-foreground))}@media(hover:hover){.hover\:bg-accent:hover{background-color:rgb(var(--accent))}.hover\:bg-destructive\/90:hover{background-color:rgb(var(--destructive))}@supports (color:color-mix(in lab,red,red)){.hover\:bg-destructive\/90:hover{background-color:color-mix(in oklab,rgb(var(--destructive)) 90%,transparent)}}.hover\:bg-muted\/30:hover{background-color:rgb(var(--muted))}@supports (color:color-mix(in lab,red,red)){.hover\:bg-muted\/30:hover{background-color:color-mix(in oklab,rgb(var(--muted)) 30%,transparent)}}.hover\:bg-muted\/50:hover{background-color:rgb(var(--muted))}@supports (color:color-mix(in lab,red,red)){.hover\:bg-muted\/50:hover{background-color:color-mix(in oklab,rgb(var(--muted)) 50%,transparent)}}.hover\:bg-primary\/90:hover{background-color:rgb(var(--primary))}@supports (color:color-mix(in lab,red,red)){.hover\:bg-primary\/90:hover{background-color:color-mix(in oklab,rgb(var(--primary)) 90%,transparent)}}.hover\:bg-secondary\/80:hover{background-color:rgb(var(--secondary))}@supports (color:color-mix(in lab,red,red)){.hover\:bg-secondary\/80:hover{background-color:color-mix(in oklab,rgb(var(--secondary)) 80%,transparent)}}.hover\:text-accent-foreground:hover{color:rgb(var(--accent-foreground))}.hover\:text-foreground:hover{color:rgb(var(--foreground))}.hover\:underline:hover{text-decoration-line:underline}}.focus\:bg-accent:focus{background-color:rgb(var(--accent))}.focus\:text-accent-foreground:focus{color:rgb(var(--accent-foreground))}.focus-visible\:border-ring:focus-visible{border-color:rgb(var(--ring))}.focus-visible\:stroke-ring:focus-visible{stroke:rgb(var(--ring))}.focus-visible\:\[stroke-width\:2\]:focus-visible{stroke-width:2px}.focus-visible\:ring-2:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-visible\:ring-\[3px\]:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-visible\:ring-destructive\/20:focus-visible{--tw-ring-color:rgb(var(--destructive))}@supports (color:color-mix(in lab,red,red)){.focus-visible\:ring-destructive\/20:focus-visible{--tw-ring-color:color-mix(in oklab, rgb(var(--destructive)) 20%, transparent)}}.focus-visible\:ring-ring:focus-visible,.focus-visible\:ring-ring\/50:focus-visible{--tw-ring-color:rgb(var(--ring))}@supports (color:color-mix(in lab,red,red)){.focus-visible\:ring-ring\/50:focus-visible{--tw-ring-color:color-mix(in oklab, rgb(var(--ring)) 50%, transparent)}}.focus-visible\:outline-1:focus-visible{outline-style:var(--tw-outline-style);outline-width:1px}.focus-visible\:outline-ring:focus-visible{outline-color:rgb(var(--ring))}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-50:disabled{opacity:.5}.has-data-\[slot\=card-action\]\:grid-cols-\[1fr_auto\]:has([data-slot=card-action]){grid-template-columns:1fr auto}.has-\[\>svg\]\:px-2\.5:has(>svg){padding-inline:calc(var(--spacing) * 2.5)}.has-\[\>svg\]\:px-3:has(>svg){padding-inline:calc(var(--spacing) * 3)}.has-\[\>svg\]\:px-4:has(>svg){padding-inline:calc(var(--spacing) * 4)}.aria-invalid\:border-destructive[aria-invalid=true]{border-color:rgb(var(--destructive))}.aria-invalid\:ring-destructive\/20[aria-invalid=true]{--tw-ring-color:rgb(var(--destructive))}@supports (color:color-mix(in lab,red,red)){.aria-invalid\:ring-destructive\/20[aria-invalid=true]{--tw-ring-color:color-mix(in oklab, rgb(var(--destructive)) 20%, transparent)}}.data-\[disabled\]\:pointer-events-none[data-disabled]{pointer-events:none}.data-\[disabled\]\:opacity-50[data-disabled]{opacity:.5}.data-\[inset\]\:pl-8[data-inset]{padding-left:calc(var(--spacing) * 8)}.data-\[orientation\=horizontal\]\:h-px[data-orientation=horizontal]{height:1px}.data-\[orientation\=horizontal\]\:w-full[data-orientation=horizontal]{width:100%}.data-\[orientation\=vertical\]\:h-full[data-orientation=vertical]{height:100%}.data-\[orientation\=vertical\]\:w-px[data-orientation=vertical]{width:1px}.data-\[placeholder\]\:text-muted-foreground[data-placeholder]{color:rgb(var(--muted-foreground))}.data-\[side\=bottom\]\:translate-y-1[data-side=bottom]{--tw-translate-y:var(--spacing);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[side\=bottom\]\:slide-in-from-top-2[data-side=bottom]{--tw-enter-translate-y:calc(2*var(--spacing)*-1)}.data-\[side\=left\]\:-translate-x-1[data-side=left]{--tw-translate-x:calc(var(--spacing) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[side\=left\]\:slide-in-from-right-2[data-side=left]{--tw-enter-translate-x:calc(2*var(--spacing))}.data-\[side\=right\]\:translate-x-1[data-side=right]{--tw-translate-x:var(--spacing);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[side\=right\]\:slide-in-from-left-2[data-side=right]{--tw-enter-translate-x:calc(2*var(--spacing)*-1)}.data-\[side\=top\]\:-translate-y-1[data-side=top]{--tw-translate-y:calc(var(--spacing) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[side\=top\]\:slide-in-from-bottom-2[data-side=top]{--tw-enter-translate-y:calc(2*var(--spacing))}.data-\[size\=default\]\:h-9[data-size=default]{height:calc(var(--spacing) * 9)}.data-\[size\=sm\]\:h-8[data-size=sm]{height:calc(var(--spacing) * 8)}:is(.\*\:data-\[slot\=select-value\]\:line-clamp-1>*)[data-slot=select-value]{-webkit-line-clamp:1;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}:is(.\*\:data-\[slot\=select-value\]\:flex>*)[data-slot=select-value]{display:flex}:is(.\*\:data-\[slot\=select-value\]\:items-center>*)[data-slot=select-value]{align-items:center}:is(.\*\:data-\[slot\=select-value\]\:gap-2>*)[data-slot=select-value]{gap:calc(var(--spacing) * 2)}.data-\[state\=active\]\:bg-card[data-state=active]{background-color:rgb(var(--card))}.data-\[state\=active\]\:shadow-sm[data-state=active]{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.data-\[state\=checked\]\:translate-x-\[calc\(100\%-2px\)\][data-state=checked]{--tw-translate-x: calc(100% - 2px) ;translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[state\=checked\]\:border-primary[data-state=checked]{border-color:rgb(var(--primary))}.data-\[state\=checked\]\:bg-primary[data-state=checked]{background-color:rgb(var(--primary))}.data-\[state\=checked\]\:text-primary-foreground[data-state=checked]{color:rgb(var(--primary-foreground))}.data-\[state\=closed\]\:animate-out[data-state=closed]{animation:exit var(--tw-animation-duration,var(--tw-duration,.15s))var(--tw-ease,ease)var(--tw-animation-delay,0s)var(--tw-animation-iteration-count,1)var(--tw-animation-direction,normal)var(--tw-animation-fill-mode,forwards)}.data-\[state\=closed\]\:fade-out-0[data-state=closed]{--tw-exit-opacity:0}.data-\[state\=closed\]\:zoom-out-95[data-state=closed]{--tw-exit-scale:.95}.data-\[state\=inactive\]\:hidden[data-state=inactive]{display:none}.data-\[state\=open\]\:animate-in[data-state=open]{animation:enter var(--tw-animation-duration,var(--tw-duration,.15s))var(--tw-ease,ease)var(--tw-animation-delay,0s)var(--tw-animation-iteration-count,1)var(--tw-animation-direction,normal)var(--tw-animation-fill-mode,forwards)}.data-\[state\=open\]\:bg-accent[data-state=open]{background-color:rgb(var(--accent))}.data-\[state\=open\]\:text-accent-foreground[data-state=open]{color:rgb(var(--accent-foreground))}.data-\[state\=open\]\:fade-in-0[data-state=open]{--tw-enter-opacity:0}.data-\[state\=open\]\:zoom-in-95[data-state=open]{--tw-enter-scale:.95}.data-\[state\=selected\]\:bg-muted[data-state=selected]{background-color:rgb(var(--muted))}.data-\[state\=unchecked\]\:translate-x-0[data-state=unchecked]{--tw-translate-x:0px;translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[state\=unchecked\]\:bg-\[\#7F7F79\][data-state=unchecked]{background-color:#7f7f79}.data-\[variant\=destructive\]\:text-destructive[data-variant=destructive]{color:rgb(var(--destructive))}.data-\[variant\=destructive\]\:focus\:bg-destructive\/10[data-variant=destructive]:focus{background-color:rgb(var(--destructive))}@supports (color:color-mix(in lab,red,red)){.data-\[variant\=destructive\]\:focus\:bg-destructive\/10[data-variant=destructive]:focus{background-color:color-mix(in oklab,rgb(var(--destructive)) 10%,transparent)}}.data-\[variant\=destructive\]\:focus\:text-destructive[data-variant=destructive]:focus{color:rgb(var(--destructive))}@media(min-width:40rem){.sm\:col-span-2{grid-column:span 2/span 2}.sm\:max-w-lg{max-width:var(--container-lg)}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:justify-end{justify-content:flex-end}.sm\:text-left{text-align:left}}@media(min-width:48rem){.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:p-12{padding:calc(var(--spacing) * 12)}.md\:text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}}@media(min-width:64rem){.lg\:col-span-2{grid-column:span 2/span 2}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}.dark\:border-input:is(.dark *){border-color:rgb(var(--input))}.dark\:bg-chart-4:is(.dark *){background-color:rgb(var(--chart-4))}.dark\:bg-destructive\/60:is(.dark *){background-color:rgb(var(--destructive))}@supports (color:color-mix(in lab,red,red)){.dark\:bg-destructive\/60:is(.dark *){background-color:color-mix(in oklab,rgb(var(--destructive)) 60%,transparent)}}.dark\:bg-input\/30:is(.dark *){background-color:rgb(var(--input))}@supports (color:color-mix(in lab,red,red)){.dark\:bg-input\/30:is(.dark *){background-color:color-mix(in oklab,rgb(var(--input)) 30%,transparent)}}.dark\:fill-chart-4:is(.dark *){fill:rgb(var(--chart-4))}.dark\:text-muted-foreground:is(.dark *){color:rgb(var(--muted-foreground))}.dark\:text-yellow-400:is(.dark *){color:var(--color-yellow-400)}@media(hover:hover){.dark\:hover\:bg-accent\/50:is(.dark *):hover{background-color:rgb(var(--accent))}@supports (color:color-mix(in lab,red,red)){.dark\:hover\:bg-accent\/50:is(.dark *):hover{background-color:color-mix(in oklab,rgb(var(--accent)) 50%,transparent)}}.dark\:hover\:bg-input\/50:is(.dark *):hover{background-color:rgb(var(--input))}@supports (color:color-mix(in lab,red,red)){.dark\:hover\:bg-input\/50:is(.dark *):hover{background-color:color-mix(in oklab,rgb(var(--input)) 50%,transparent)}}}.dark\:focus-visible\:ring-destructive\/40:is(.dark *):focus-visible{--tw-ring-color:rgb(var(--destructive))}@supports (color:color-mix(in lab,red,red)){.dark\:focus-visible\:ring-destructive\/40:is(.dark *):focus-visible{--tw-ring-color:color-mix(in oklab, rgb(var(--destructive)) 40%, transparent)}}.dark\:aria-invalid\:ring-destructive\/40:is(.dark *)[aria-invalid=true]{--tw-ring-color:rgb(var(--destructive))}@supports (color:color-mix(in lab,red,red)){.dark\:aria-invalid\:ring-destructive\/40:is(.dark *)[aria-invalid=true]{--tw-ring-color:color-mix(in oklab, rgb(var(--destructive)) 40%, transparent)}}.dark\:data-\[state\=active\]\:border-input:is(.dark *)[data-state=active]{border-color:rgb(var(--input))}.dark\:data-\[state\=active\]\:bg-input\/30:is(.dark *)[data-state=active]{background-color:rgb(var(--input))}@supports (color:color-mix(in lab,red,red)){.dark\:data-\[state\=active\]\:bg-input\/30:is(.dark *)[data-state=active]{background-color:color-mix(in oklab,rgb(var(--input)) 30%,transparent)}}.dark\:data-\[state\=active\]\:text-foreground:is(.dark *)[data-state=active]{color:rgb(var(--foreground))}.dark\:data-\[state\=checked\]\:bg-primary:is(.dark *)[data-state=checked]{background-color:rgb(var(--primary))}.dark\:data-\[state\=checked\]\:bg-primary-foreground:is(.dark *)[data-state=checked]{background-color:rgb(var(--primary-foreground))}.dark\:data-\[state\=unchecked\]\:bg-foreground:is(.dark *)[data-state=unchecked]{background-color:rgb(var(--foreground))}.dark\:data-\[state\=unchecked\]\:bg-input:is(.dark *)[data-state=unchecked]{background-color:rgb(var(--input))}.dark\:data-\[variant\=destructive\]\:focus\:bg-destructive\/20:is(.dark *)[data-variant=destructive]:focus{background-color:rgb(var(--destructive))}@supports (color:color-mix(in lab,red,red)){.dark\:data-\[variant\=destructive\]\:focus\:bg-destructive\/20:is(.dark *)[data-variant=destructive]:focus{background-color:color-mix(in oklab,rgb(var(--destructive)) 20%,transparent)}}.\[\&_svg\]\:pointer-events-none svg{pointer-events:none}.\[\&_svg\]\:shrink-0 svg{flex-shrink:0}.\[\&_svg\:not\(\[class\*\=\'size-\'\]\)\]\:size-4 svg:not([class*=size-]){width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.\[\&_svg\:not\(\[class\*\=\'size-\'\]\)\]\:size-6 svg:not([class*=size-]){width:calc(var(--spacing) * 6);height:calc(var(--spacing) * 6)}.\[\&_svg\:not\(\[class\*\=\'text-\'\]\)\]\:text-muted-foreground svg:not([class*=text-]){color:rgb(var(--muted-foreground))}.\[\&_tr\]\:border-b tr{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.\[\&_tr\:last-child\]\:border-0 tr:last-child{border-style:var(--tw-border-style);border-width:0}.\[\&\:has\(\[role\=checkbox\]\)\]\:pr-0:has([role=checkbox]){padding-right:0}.\[\.border-b\]\:pb-6.border-b{padding-bottom:calc(var(--spacing) * 6)}.\[\.border-t\]\:pt-6.border-t{padding-top:calc(var(--spacing) * 6)}:is(.\*\:\[span\]\:last\:flex>*):is(span):last-child{display:flex}:is(.\*\:\[span\]\:last\:items-center>*):is(span):last-child{align-items:center}:is(.\*\:\[span\]\:last\:gap-2>*):is(span):last-child{gap:calc(var(--spacing) * 2)}:is(.data-\[variant\=destructive\]\:\*\:\[svg\]\:\!text-destructive[data-variant=destructive]>*):is(svg){color:rgb(var(--destructive))!important}.\[\&\>\[role\=checkbox\]\]\:translate-y-\[2px\]>[role=checkbox]{--tw-translate-y:2px;translate:var(--tw-translate-x) var(--tw-translate-y)}.\[\&\>a\]\:underline>a{text-decoration-line:underline}.\[\&\>a\]\:underline-offset-4>a{text-underline-offset:4px}.\[\&\>a\:hover\]\:text-primary>a:hover{color:rgb(var(--primary))}.\[\&\>svg\]\:pointer-events-none>svg{pointer-events:none}.\[\&\>svg\]\:size-3>svg{width:calc(var(--spacing) * 3);height:calc(var(--spacing) * 3)}.\[\&\>svg\]\:size-3\.5>svg{width:calc(var(--spacing) * 3.5);height:calc(var(--spacing) * 3.5)}.\[\&\>tr\]\:last\:border-b-0>tr:last-child{border-bottom-style:var(--tw-border-style);border-bottom-width:0}@media(hover:hover){a.\[a\&\]\:hover\:bg-\[\#1a7f37\]\/90:hover{background-color:#1a7f37e6}a.\[a\&\]\:hover\:bg-\[\#3b82f6\]\/90:hover{background-color:#3b82f6e6}a.\[a\&\]\:hover\:bg-\[\#4b5563\]\/90:hover{background-color:#4b5563e6}a.\[a\&\]\:hover\:bg-\[\#9a6700\]\/90:hover{background-color:#9a6700e6}a.\[a\&\]\:hover\:bg-\[\#22c55e\]\/90:hover{background-color:#22c55ee6}a.\[a\&\]\:hover\:bg-\[\#0969da\]\/90:hover{background-color:#0969dae6}a.\[a\&\]\:hover\:bg-\[\#a855f7\]\/90:hover{background-color:#a855f7e6}a.\[a\&\]\:hover\:bg-\[\#f97316\]\/90:hover{background-color:#f97316e6}a.\[a\&\]\:hover\:bg-accent\/10:hover{background-color:rgb(var(--accent))}@supports (color:color-mix(in lab,red,red)){a.\[a\&\]\:hover\:bg-accent\/10:hover{background-color:color-mix(in oklab,rgb(var(--accent)) 10%,transparent)}}a.\[a\&\]\:hover\:bg-destructive\/90:hover{background-color:rgb(var(--destructive))}@supports (color:color-mix(in lab,red,red)){a.\[a\&\]\:hover\:bg-destructive\/90:hover{background-color:color-mix(in oklab,rgb(var(--destructive)) 90%,transparent)}}a.\[a\&\]\:hover\:bg-muted:hover,a.\[a\&\]\:hover\:bg-muted\/80:hover{background-color:rgb(var(--muted))}@supports (color:color-mix(in lab,red,red)){a.\[a\&\]\:hover\:bg-muted\/80:hover{background-color:color-mix(in oklab,rgb(var(--muted)) 80%,transparent)}}a.\[a\&\]\:hover\:bg-primary\/90:hover{background-color:rgb(var(--primary))}@supports (color:color-mix(in lab,red,red)){a.\[a\&\]\:hover\:bg-primary\/90:hover{background-color:color-mix(in oklab,rgb(var(--primary)) 90%,transparent)}}a.\[a\&\]\:hover\:bg-secondary\/90:hover{background-color:rgb(var(--secondary))}@supports (color:color-mix(in lab,red,red)){a.\[a\&\]\:hover\:bg-secondary\/90:hover{background-color:color-mix(in oklab,rgb(var(--secondary)) 90%,transparent)}}a.\[a\&\]\:hover\:text-accent-foreground:hover{color:rgb(var(--accent-foreground))}}}@property --tw-animation-delay{syntax:"*";inherits:false;initial-value:0s}@property --tw-animation-direction{syntax:"*";inherits:false;initial-value:normal}@property --tw-animation-duration{syntax:"*";inherits:false}@property --tw-animation-fill-mode{syntax:"*";inherits:false;initial-value:forwards}@property --tw-animation-iteration-count{syntax:"*";inherits:false;initial-value:1}@property --tw-enter-opacity{syntax:"*";inherits:false;initial-value:1}@property --tw-enter-rotate{syntax:"*";inherits:false;initial-value:0}@property --tw-enter-scale{syntax:"*";inherits:false;initial-value:1}@property --tw-enter-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-enter-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-opacity{syntax:"*";inherits:false;initial-value:1}@property --tw-exit-rotate{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-scale{syntax:"*";inherits:false;initial-value:1}@property --tw-exit-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-translate-y{syntax:"*";inherits:false;initial-value:0}@font-face{font-family:Aspekta;font-style:normal;font-weight:400;font-display:swap;src:url(/fonts/Aspekta-400.woff2)format("woff2")}@font-face{font-family:Aspekta;font-style:normal;font-weight:500;font-display:swap;src:url(/fonts/Aspekta-500.woff2)format("woff2")}@font-face{font-family:Aspekta;font-style:normal;font-weight:600;font-display:swap;src:url(/fonts/Aspekta-600.woff2)format("woff2")}@font-face{font-family:Aspekta;font-style:normal;font-weight:700;font-display:swap;src:url(/fonts/Aspekta-700.woff2)format("woff2")}:root{--background:240 240 236;--foreground:22 22 20;--card:249 249 247;--card-foreground:22 22 20;--popover:249 249 247;--popover-foreground:22 22 20;--primary:217 94 42;--primary-foreground:249 249 247;--secondary:240 240 236;--secondary-foreground:47 47 44;--muted:240 240 236;--muted-foreground:87 87 83;--accent:221 221 215;--accent-foreground:22 22 20;--destructive:180 50 50;--destructive-foreground:249 249 247;--border:196 196 189;--input:196 196 189;--ring:217 94 42;--chart-1:217 94 42;--chart-2:87 87 83;--chart-3:127 127 121;--chart-4:166 166 159;--chart-5:47 47 44;--radius:.375rem}.dark{--background:22 22 20;--foreground:249 249 247;--card:47 47 44;--card-foreground:249 249 247;--popover:47 47 44;--popover-foreground:249 249 247;--primary:217 94 42;--primary-foreground:249 249 247;--secondary:87 87 83;--secondary-foreground:249 249 247;--muted:87 87 83;--muted-foreground:196 196 189;--accent:87 87 83;--accent-foreground:249 249 247;--destructive:180 50 50;--destructive-foreground:249 249 247;--border:87 87 83;--input:87 87 83;--ring:217 94 42;--chart-1:217 94 42;--chart-2:196 196 189;--chart-3:166 166 159;--chart-4:127 127 121;--chart-5:240 240 236}.chat-markdown{overflow-wrap:break-word;line-height:1.55}.chat-markdown>:first-child{margin-top:0}.chat-markdown>:last-child{margin-bottom:0}.chat-markdown p{margin:.5em 0}.chat-markdown h1,.chat-markdown h2,.chat-markdown h3,.chat-markdown h4,.chat-markdown h5,.chat-markdown h6{margin:1em 0 .4em;font-size:1em;font-weight:600;line-height:1.3}.chat-markdown h1{font-size:1.15em}.chat-markdown h2{font-size:1.08em}.chat-markdown ul,.chat-markdown ol{margin:.5em 0;padding-left:1.4em}.chat-markdown ul{list-style:outside}.chat-markdown ol{list-style:decimal}.chat-markdown li,.chat-markdown li>ul,.chat-markdown li>ol{margin:.2em 0}.chat-markdown li::marker{color:rgb(var(--muted-foreground))}.chat-markdown code{font-family:var(--font-mono);background:rgb(var(--muted));border:1px solid rgb(var(--border) / .6);border-radius:4px;padding:.1em .35em;font-size:.9em}.chat-markdown pre{background:rgb(var(--muted));border:1px solid rgb(var(--border) / .6);border-radius:var(--radius);margin:.6em 0;padding:.6em .75em;overflow-x:auto}.chat-markdown pre code{background:0 0;border:none;padding:0;font-size:.85em;line-height:1.5}.chat-markdown blockquote{border-left:3px solid rgb(var(--border));color:rgb(var(--muted-foreground));margin:.6em 0;padding:.1em 0 .1em .9em}.chat-markdown a{color:rgb(var(--primary));text-underline-offset:2px;text-decoration:underline}.chat-markdown table{border-collapse:collapse;margin:.6em 0;font-size:.95em;display:block;overflow-x:auto}.chat-markdown th,.chat-markdown td{border:1px solid rgb(var(--border));text-align:left;padding:4px 10px}.chat-markdown th{background:rgb(var(--muted));font-weight:600}.chat-markdown hr{border:none;border-top:1px solid rgb(var(--border));margin:1em 0}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@keyframes spin{to{transform:rotate(360deg)}}@keyframes pulse{50%{opacity:.5}}@keyframes enter{0%{opacity:var(--tw-enter-opacity,1);transform:translate3d(var(--tw-enter-translate-x,0),var(--tw-enter-translate-y,0),0)scale3d(var(--tw-enter-scale,1),var(--tw-enter-scale,1),var(--tw-enter-scale,1))rotate(var(--tw-enter-rotate,0))}}@keyframes exit{to{opacity:var(--tw-exit-opacity,1);transform:translate3d(var(--tw-exit-translate-x,0),var(--tw-exit-translate-y,0),0)scale3d(var(--tw-exit-scale,1),var(--tw-exit-scale,1),var(--tw-exit-scale,1))rotate(var(--tw-exit-rotate,0))}} diff --git a/internal/store/types.go b/internal/store/types.go index f90bab9..7eaf847 100644 --- a/internal/store/types.go +++ b/internal/store/types.go @@ -206,6 +206,7 @@ type RequestLogRow struct { StatusCode sql.NullInt64 Streamed bool Cancelled bool + FailedOver bool Cost sql.NullFloat64 Unpriced bool DurationMs int64 diff --git a/internal/store/usage.go b/internal/store/usage.go index 2501784..be7be88 100644 --- a/internal/store/usage.go +++ b/internal/store/usage.go @@ -102,7 +102,7 @@ func (s *Store) ListRequests(ctx context.Context, f UsageFilter, limit, offset i rows, err := s.db.QueryContext(ctx, s.q(` SELECT e.id, e.ts, COALESCE(pp.name, e.principal_id), `+providerNameSQL+`, e.alias, e.endpoint, e.client, e.tags, e.api_key_id, COALESCE(k.label, ''), COALESCE(k.key_suffix, ''), e.outcome, - e.error_kind, e.status_code, e.streamed, e.cancelled, e.cost, e.unpriced, e.duration_ms, e.ttft_ms + e.error_kind, e.status_code, e.streamed, e.cancelled, e.cost, e.unpriced, e.duration_ms, e.ttft_ms, e.failed_over FROM usage_event e LEFT JOIN principal pp ON e.principal_id = pp.id LEFT JOIN provider p ON e.provider_id = p.id @@ -117,15 +117,16 @@ func (s *Store) ListRequests(ctx context.Context, f UsageFilter, limit, offset i ids := make([]any, 0, limit) for rows.Next() { var r RequestLogRow - var streamed, cancelled, unpriced int64 + var streamed, cancelled, unpriced, failedOver int64 if err := rows.Scan(&r.ID, &r.TS, &r.PrincipalName, &r.Provider, &r.Alias, &r.Endpoint, &r.Client, &r.Tags, &r.APIKeyID, &r.KeyLabel, &r.KeySuffix, &r.Outcome, - &r.ErrorKind, &r.StatusCode, &streamed, &cancelled, &r.Cost, &unpriced, &r.DurationMs, &r.TTFTMs); err != nil { + &r.ErrorKind, &r.StatusCode, &streamed, &cancelled, &r.Cost, &unpriced, &r.DurationMs, &r.TTFTMs, &failedOver); err != nil { return nil, err } r.Streamed = streamed != 0 r.Cancelled = cancelled != 0 r.Unpriced = unpriced != 0 + r.FailedOver = failedOver != 0 r.Units = make(map[string]float64) index[r.ID] = len(out) ids = append(ids, r.ID) diff --git a/ui/src/components/ModelsCatalog.tsx b/ui/src/components/ModelsCatalog.tsx index b6c8a02..f6dc637 100644 --- a/ui/src/components/ModelsCatalog.tsx +++ b/ui/src/components/ModelsCatalog.tsx @@ -20,6 +20,7 @@ import { import { addBuckets, floorBucket, RANGES } from '@/lib/timerange' import { useAsync } from '@/lib/useAsync' import { useQueryParam } from '@/lib/useQueryParam' +import { strategyLabel } from '@/lib/strategies' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Checkbox } from '@/components/ui/checkbox' @@ -249,6 +250,12 @@ export function ModelsCatalog() { → {r.model.alias_of} )} + {r.model.targets && ( + + → {r.model.targets.join(', ')}{' '} + · {strategyLabel(r.model.strategy)} + + )} diff --git a/ui/src/lib/api.ts b/ui/src/lib/api.ts index 2b222f6..67af1ba 100644 --- a/ui/src/lib/api.ts +++ b/ui/src/lib/api.ts @@ -164,6 +164,11 @@ export interface RateLimitSnapshot { reset_tokens_at?: string } +export interface AliasTarget { + alias: string + weight: number +} + export interface Model { alias: string // provider, upstream_name and capabilities are always the resolved ones: @@ -173,6 +178,10 @@ export interface Model { upstream_name: string capabilities: string[] target: string | null + // targets lists an alias with several targets in order; it is empty + // otherwise, and strategy is null. + targets: AliasTarget[] + strategy: string | null origin: string created_at: string // Prices per million units, keyed by unit; a unit without an entry is @@ -207,6 +216,9 @@ export interface CatalogModel { owned_by: string capabilities: string[] alias_of: string | null + // targets and strategy are set on an alias with several targets. + targets: string[] | null + strategy: string | null // Only ever true on a list fetched with include_hidden=1; the plain list // leaves hidden models out. hidden: boolean @@ -239,6 +251,8 @@ export interface RequestRow { status_code: number | null streamed: boolean cancelled: boolean + // failed_over marks an attempt given up on before another target was tried. + failed_over: boolean cost: number | null unpriced: boolean duration_ms: number diff --git a/ui/src/lib/strategies.ts b/ui/src/lib/strategies.ts new file mode 100644 index 0000000..441f426 --- /dev/null +++ b/ui/src/lib/strategies.ts @@ -0,0 +1,10 @@ +export const STRATEGIES = [ + { value: 'failover', label: 'Failover', hint: 'Use the first target. Try the next one if it fails.' }, + { value: 'round_robin', label: 'Round robin', hint: 'Send each request to the next target in turn.' }, + { value: 'weighted', label: 'Weighted', hint: 'Spread requests by weight.' }, + { value: 'least_busy', label: 'Least busy', hint: 'Send each request to the target with the fewest open requests.' }, +] + +export function strategyLabel(strategy: string | null): string { + return STRATEGIES.find((s) => s.value === strategy)?.label ?? strategy ?? '' +} diff --git a/ui/src/pages/Models.tsx b/ui/src/pages/Models.tsx index ef993be..2b485fe 100644 --- a/ui/src/pages/Models.tsx +++ b/ui/src/pages/Models.tsx @@ -1,7 +1,14 @@ import { useEffect, useRef, useState } from 'react' import { toast } from 'sonner' -import { api, type DiscoveredModel, type Model, type Provider } from '@/lib/api' +import { + api, + type AliasTarget, + type DiscoveredModel, + type Model, + type Provider, +} from '@/lib/api' import { useAsync } from '@/lib/useAsync' +import { STRATEGIES, strategyLabel } from '@/lib/strategies' import { Combobox, type ComboboxOption } from '@/components/Combobox' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' @@ -43,7 +50,7 @@ import { AlertDialogTitle, AlertDialogTrigger, } from '@/components/ui/alert-dialog' -import { Eye, EyeOff, Pencil, Trash2 } from 'lucide-react' +import { ArrowDown, ArrowUp, Eye, EyeOff, Pencil, Plus, Trash2, X } from 'lucide-react' // vision is declarative: it tells callers the model reads images, and gates // nothing. The others gate the endpoints of the same name. @@ -183,7 +190,8 @@ interface ModelForm { alias: string provider: string upstream_name: string - target: string + targets: AliasTarget[] + strategy: string capabilities: string[] prices: PriceForm } @@ -196,10 +204,142 @@ interface EditState extends ModelForm { // hop, so an alias never targets another alias. function targetOptions(models: Model[], self?: string): ComboboxOption[] { return models - .filter((m) => m.target === null && m.alias !== self) + .filter((m) => m.target === null && m.targets.length === 0 && m.alias !== self) .map((m) => ({ value: m.alias, hint: `${m.provider} · ${m.upstream_name}` })) } +function filledTargets(targets: AliasTarget[]): AliasTarget[] { + return targets + .map((t) => ({ alias: t.alias.trim(), weight: t.weight })) + .filter((t) => t.alias !== '') +} + +// An ordered list of targets. The strategy appears once there are two. +function TargetsField({ + id, + targets, + strategy, + options, + onChange, + note, +}: { + id: string + targets: AliasTarget[] + strategy: string + options: ComboboxOption[] + onChange: (targets: AliasTarget[], strategy: string) => void + note?: string +}) { + const several = filledTargets(targets).length > 1 + const set = (next: AliasTarget[]) => onChange(next, strategy) + const move = (i: number, step: number) => { + const next = [...targets] + ;[next[i], next[i + step]] = [next[i + step], next[i]] + set(next) + } + return ( +
+ + {targets.map((t, i) => ( +
+ set(targets.map((x, j) => (j === i ? { ...x, alias: v } : x)))} + options={options.filter( + (o) => o.value === t.alias || !targets.some((x) => x.alias === o.value), + )} + note={note} + /> + {several && strategy === 'weighted' && ( + + set( + targets.map((x, j) => + j === i ? { ...x, weight: Math.max(1, Number(e.target.value) || 1) } : x, + ), + ) + } + /> + )} + {targets.length > 1 && ( + <> + + + + + )} +
+ ))} +
+ + {several && ( + <> + + + {STRATEGIES.find((s) => s.value === strategy)?.hint} + + + )} +
+
+ ) +} + // Both kinds stay visible side by side; the fields below follow the choice. function KindSwitch({ value, onChange }: { value: Kind; onChange: (kind: Kind) => void }) { return ( @@ -280,7 +420,8 @@ export function Models() { alias: '', provider: '', upstream_name: '', - target: '', + targets: [{ alias: '', weight: 1 }], + strategy: 'failover', capabilities: ['chat', 'chat_stream'], prices: {}, }) @@ -306,18 +447,21 @@ export function Models() { }, [form.kind, form.provider, form.upstream_name, newDiscovery.loading, newDiscovery.options]) // The two kinds send different bodies; everything else is shared. - const routing = (f: ModelForm) => - f.kind === 'alias' - ? { target: f.target.trim() } - : { - provider: f.provider, - upstream_name: f.upstream_name.trim(), - capabilities: f.capabilities, - } + const routing = (f: ModelForm) => { + if (f.kind === 'alias') { + const targets = filledTargets(f.targets) + return targets.length > 1 ? { targets, strategy: f.strategy } : { targets } + } + return { + provider: f.provider, + upstream_name: f.upstream_name.trim(), + capabilities: f.capabilities, + } + } const invalid = (f: ModelForm): string | null => { if (f.kind === 'alias') { - if (f.target.trim() === '') return 'Pick the model this one points at' + if (filledTargets(f.targets).length === 0) return 'Pick the model this one points at' if (f.alias.trim() === '') return 'An alias for another model needs its own name' return null } @@ -348,7 +492,14 @@ export function Models() { pricing: Object.keys(prices).length > 0 ? prices : undefined, }) toast.success(`Model "${alias}" bound`) - setForm({ ...form, alias: '', upstream_name: '', target: '', prices: {} }) + setForm({ + ...form, + alias: '', + upstream_name: '', + targets: [{ alias: '', weight: 1 }], + strategy: 'failover', + prices: {}, + }) models.reload() } catch (err) { toast.error(errMsg(err)) @@ -378,8 +529,7 @@ export function Models() { alias: editing.alias.trim(), // An empty target turns an alias back into its own binding, which is // why the provider fields ride along. - target: editing.kind === 'alias' ? editing.target.trim() : '', - ...(editing.kind === 'alias' ? {} : routing(editing)), + ...(editing.kind === 'alias' ? routing(editing) : { target: '', ...routing(editing) }), pricing: prices, }) toast.success( @@ -486,20 +636,18 @@ export function Models() { ) : ( -
- - setForm({ ...form, target: v })} - options={targetOptions(allModels)} - note={ - allModels.some((m) => m.target === null) - ? undefined - : 'Bind a provider model first; an alias points at one of those.' - } - /> -
+ setForm({ ...form, targets, strategy })} + note={ + targetOptions(allModels).length > 0 + ? undefined + : 'Bind a provider model first; an alias points at one of those.' + } + /> )} {form.kind === 'provider' && nameField} @@ -528,7 +676,7 @@ export function Models() { busy || (form.kind === 'provider' ? form.provider === '' || form.upstream_name.trim() === '' - : form.target.trim() === '' || form.alias.trim() === '') + : filledTargets(form.targets).length === 0 || form.alias.trim() === '') } > {busy && } @@ -616,15 +764,15 @@ export function Models() { ) : ( -
- - setEditing({ ...editing, target: v })} - options={targetOptions(allModels, editing.original)} - /> -
+ + setEditing({ ...editing, targets, strategy }) + } + /> )}
@@ -684,7 +832,19 @@ export function Models() { {m.provider} - {m.target ? ( + {m.targets.length > 1 ? ( + + {m.targets.map((t) => ( + + → {t.alias} + {m.strategy === 'weighted' && ( + ×{t.weight} + )} + + ))} + {strategyLabel(m.strategy)} + + ) : m.target ? ( // An alias shows what it points at, and under it // where that lands. @@ -724,11 +884,15 @@ export function Models() { onClick={() => setEditing({ original: m.alias, - kind: m.target ? 'alias' : 'provider', + kind: m.target || m.targets.length > 0 ? 'alias' : 'provider', alias: m.alias, provider: m.provider, upstream_name: m.upstream_name, - target: m.target ?? '', + targets: + m.targets.length > 0 + ? m.targets.map((t) => ({ ...t })) + : [{ alias: m.target ?? '', weight: 1 }], + strategy: m.strategy ?? 'failover', capabilities: [...m.capabilities], // Inherited prices show as blank so saving // keeps inheriting; a typed value overrides. diff --git a/ui/src/pages/Requests.tsx b/ui/src/pages/Requests.tsx index fc80e12..c47c79e 100644 --- a/ui/src/pages/Requests.tsx +++ b/ui/src/pages/Requests.tsx @@ -561,6 +561,11 @@ export function Requests() { + {r.failed_over && ( + + failed over + + )} {r.streamed && ( From 5785e3e0f93e65aa550b71d3c02221458d983289 Mon Sep 17 00:00:00 2001 From: Mathieu Virbel Date: Thu, 8 Oct 2026 10:55:11 -0600 Subject: [PATCH 09/12] Document aliases with several targets --- docs/concepts/architecture.md | 9 +++ docs/guides/models.md | 82 ++++++++++++++++++++- docs/reference/api.md | 17 +++-- docs/reference/openapi.yaml | 133 +++++++++++++++++++++++++++++++++- 4 files changed, 229 insertions(+), 12 deletions(-) diff --git a/docs/concepts/architecture.md b/docs/concepts/architecture.md index 5f2b8ba..f23d19d 100644 --- a/docs/concepts/architecture.md +++ b/docs/concepts/architecture.md @@ -226,6 +226,15 @@ silent pick makes routing a mystery. A dry-run `resolve` endpoint answers another model, but only one hop, resolved in the same query, so lookup stays single-step and cycles are unrepresentable. +### An alias with several targets fails over before the first byte + +An alias may list several targets with a declared strategy. The strategy is +explicit, so the pick is never silent. The proxy tries the next target only +before it writes a byte to the caller, because the body passes through +unchanged and a partial response cannot be taken back. Rotation, in-flight +counts and cooldowns are kept in memory per replica: they are hints about +the near past, not state, and the database stays the source of truth. + ### Admin mutations are audited in-transaction Every admin mutation writes a metadata-only `admin_event` row in the same diff --git a/docs/guides/models.md b/docs/guides/models.md index 468e41f..4f10662 100644 --- a/docs/guides/models.md +++ b/docs/guides/models.md @@ -142,10 +142,72 @@ Three rules, enforced at write time: Usage is recorded against the name the caller passed, so two teams pointing at one model stay distinguishable in the summaries. +## Point a name at several models + +Give `targets` and a `strategy` to spread one name over several models, for +example the same model on two providers: + +```bash +curl -s $P/admin/v1/models -H "authorization: Bearer $ADMIN" \ + -H 'content-type: application/json' \ + -d '{"alias": "acme/smart", "targets": ["glm-tensorx", "glm-vllm"], "strategy": "failover"}' +``` + +```json +{ + "alias": "acme/smart", + "target": null, + "targets": [ + {"alias": "glm-tensorx", "weight": 1}, + {"alias": "glm-vllm", "weight": 1} + ], + "strategy": "failover", + "capabilities": ["chat", "chat_stream"] +} +``` + +The strategy picks the first target for each request: + +| `strategy` | First target | +|---|---| +| `failover` (default) | The first in the list | +| `round_robin` | The next in the list, in turn | +| `weighted` | A random pick, in proportion to `weight` | +| `least_busy` | The one with the fewest requests in flight | + +A target entry is a model name, or `{"alias": "...", "weight": 3}`. A weight +is used only by `weighted` and defaults to 1. + +With every strategy, the proxy tries the next target when an attempt fails +before any byte reaches the caller: + +- The upstream is unreachable. +- The upstream answers 429 or a 5xx status. + +After the first byte, the response is the caller's, and an error after that +point is relayed as it is. A target that failed goes to the end of the order +for `LLMPROXY_FAILOVER_COOLDOWN` (30 seconds by default). The response header +`x-llmproxy-attempts` gives the number of targets tried. Each failed attempt +is recorded as a usage row flagged `failed_over`, which request counts leave +out. + +The rules of [a single target](#point-a-name-at-another-model) apply to each +target. Two more apply to the list: + +- **Capabilities are the intersection.** The name serves only what every + target serves. +- **Prices come from the target that answered,** unless the name has a price + of its own. + +A `strategy` with one target, or an unknown one, is 400 `invalid_strategy`. A +name listed twice is 400 `invalid_target`. + ## Edit a model `PATCH /admin/v1/models/{alias}` takes any of `alias`, `provider`, -`capabilities`, `upstream_name`, `target`, `pricing` and `hidden`. Everything +`capabilities`, `upstream_name`, `target`, `targets`, `strategy`, `pricing` +and `hidden`. `targets` replaces the whole list, and `strategy` alone keeps +the list. Everything is editable in place; nothing here needs deleting and recreating. ```bash @@ -213,10 +275,26 @@ curl -s "$P/admin/v1/resolve?model=qwen-72b&endpoint=chat&stream=true" \ "provider": "vllm-1", "upstream_name": "Qwen/Qwen2.5-VL-72B-Instruct", "url": "http://10.0.0.5:8000/v1/chat/completions", - "capabilities": ["chat", "chat_stream", "completions"] + "capabilities": ["chat", "chat_stream", "completions"], + "strategy": null, + "targets": [ + { + "alias": "qwen-72b", + "provider": "vllm-1", + "upstream_name": "Qwen/Qwen2.5-VL-72B-Instruct", + "url": "http://10.0.0.5:8000/v1/chat/completions", + "weight": 1, + "cooling_down": false, + "cooldown_until": null + } + ] } ``` +For a name with several targets, `targets` lists them in the order the +next request would try, and `cooling_down` marks a target that failed +recently. The order is per replica. + `endpoint` defaults to `chat`; `stream=true` additionally requires `chat_stream`. `url` reflects any per-endpoint override. The failure modes are the data-plane ones: 404 `model_not_found` (unknown name or disabled provider) diff --git a/docs/reference/api.md b/docs/reference/api.md index 2f2cafe..4719ee7 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -11,7 +11,7 @@ The full machine-readable spec is in [openapi.yaml](openapi.yaml). |---|---|---| | `POST /v1/chat/completions` | **Supported** | Unary JSON and SSE streaming. Vision content parts, tool/function calls, response_format, logprobs etc. pass through untouched. Capability `chat` (+ `chat_stream` for streaming). | | `POST /v1/completions` | **Supported** | Legacy text completions, unary and streamed. Capability `completions`. | -| `GET /v1/models` | **Supported** | Aliases on enabled providers, minus the hidden ones; `?endpoint=chat\|embeddings\|...` filters by capability. `?include_hidden=1` adds hidden models and needs an API key or session (401 without one). `?include_pricing=1` adds `pricing` (per million units, the prices in force) and `pricing_inherited`, and needs an API key or session too. Each entry carries the OpenAI fields plus `capabilities` (resolved), `alias_of` (the model this name points at, or `null`) and `hidden`. Public, no API key required. | +| `GET /v1/models` | **Supported** | Aliases on enabled providers, minus the hidden ones; `?endpoint=chat\|embeddings\|...` filters by capability. `?include_hidden=1` adds hidden models and needs an API key or session (401 without one). `?include_pricing=1` adds `pricing` (per million units, the prices in force) and `pricing_inherited`, and needs an API key or session too. Each entry carries the OpenAI fields plus `capabilities` (resolved), `alias_of` (the model this name points at, or `null`), `targets` and `strategy` (for an alias with several targets, else `null`) and `hidden`. Public, no API key required. | | `GET /v1/models/{id}` | Not yet | Trivial to add; nothing has needed it. | | `POST /v1/embeddings` | **Supported** | Unary passthrough. Capability `embeddings`. Array `input` is capped at `LLMPROXY_MAX_EMBEDDING_BATCH` items (default 2048); larger batches get 400 `embedding_batch_too_large`. | | `POST /v1/audio/transcriptions` | Planned | Streamed multipart, no disk spill; `audio_seconds` unit reserved. | @@ -29,8 +29,10 @@ only exposes curated aliases and provider names. Its `include_hidden=1` form is not, since it names models an admin took off the list. Model names callers see are curated aliases, globally unique across all -providers; there is no bare-upstream-name fallback and no multi-provider -resolution. Calling an endpoint outside a model's capability set fails at the +providers; there is no bare-upstream-name fallback. An alias can point at +several models, and the proxy then picks one per request by the alias's +strategy (see [models](../guides/models.md#point-a-name-at-several-models)). +Calling an endpoint outside a model's capability set fails at the proxy with a 400 naming the supported capabilities, never with a confusing upstream 404. @@ -38,7 +40,8 @@ Every response carries `x-llmproxy-provider` and `x-llmproxy-model`. When the provider has rate-limit tracking configured, responses also carry `x-llmproxy-ratelimit-requests-remaining`, `x-llmproxy-ratelimit-requests-limit`, `x-llmproxy-ratelimit-tokens-remaining` and `x-llmproxy-ratelimit-tokens-limit` -(only the observed values are set). Errors are +(only the observed values are set). A request to an alias with several +targets also carries `x-llmproxy-attempts`, the number of targets tried. Errors are OpenAI-shaped with an added `llmproxy.source` field plus `x-llmproxy-error-source: proxy|upstream`; upstream error bodies and status codes pass through intact. @@ -89,7 +92,7 @@ together; a pair nothing carries simply matches nothing. `outcome` takes |---|---| | `GET /stats/series?bucket&since&until` | Bucketed usage across everyone | | `GET /stats/summary?since&until` | Usage aggregated per (principal, provider, model, endpoint, client, tags) | -| `GET /stats/requests?limit&offset` | One page of the filtered request metadata log (never content), newest first; returns `{requests, limit, offset, total}` | +| `GET /stats/requests?limit&offset` | One page of the filtered request metadata log (never content), newest first, with `failed_over` set on attempts another target replaced; returns `{requests, limit, offset, total}` | | `GET /stats/requests/facets?since&until` | Distinct principals, keys, providers, models, clients and tags in the window, for the explorer's filter options | | `GET /stats/errors?bucket&since&until` | The errors dashboard in one call: a gap-filled series of counts per outcome, plus a breakdown per (provider, model, endpoint, client, tags, outcome, error_kind, status_code) with request count, average duration, last-seen and time-to-outcome bands (<1s, 1-5s, 5-15s, 15-30s, 30-60s, 60-120s, >=120s). Rows with outcome `ok` are included so error rates have their denominator. | | `GET /stats/performance?bucket&since&until` | The performance view in one call: a summary, a gap-filled series and a per-model breakdown. Each holds `requests`, `measured`, and `duration_ms`, `ttft_ms` and `tokens_per_second` as `{mean, p50, p95}`, null when no request qualifies. The summary and the series also hold `concurrency` as `{average, peak}`. A range with more than 500,000 requests is a 400 `range_too_large`. | @@ -125,7 +128,7 @@ Proxy-generated errors: 404 `unknown_relay_token`, 404 |---|---| | `POST/GET/PATCH/DELETE /providers[/{name}]` | Register, inspect, edit, unregister providers (upstream key encrypted at rest, never returned) | | `GET /providers/{name}/discover` | Upstream model listing; read-only, never auto-binds | -| `POST/GET/PATCH/DELETE /models[/{alias}]` | Bind upstream models to globally unique aliases with capability sets and per-unit prices; a name can instead `target` another model and inherit its provider, capabilities and prices (one hop). Everything, including the name, is editable in place; a binding serves as soon as it exists (disable the provider to take it offline) | +| `POST/GET/PATCH/DELETE /models[/{alias}]` | Bind upstream models to globally unique aliases with capability sets and per-unit prices; a name can instead `target` another model and inherit its provider, capabilities and prices (one hop), or list several `targets` with a `strategy`. Everything, including the name, is editable in place; a binding serves as soon as it exists (disable the provider to take it offline) | | `GET /resolve?model&endpoint&stream` | Dry-run alias resolution | | `POST/GET /principals` | Users and service principals | | `POST /principals/{id}/revoke-sessions` | Delete every browser session of a principal (API keys untouched) | @@ -133,7 +136,7 @@ Proxy-generated errors: 404 `unknown_relay_token`, 404 | `POST/GET /pricing` | Load/inspect the versioned pricing feed (bulk; per-model prices go through `/models`) | | `GET /usage/summary?since&until&principal` | Usage and cost by principal/model/endpoint/unit | | `GET /usage/series?bucket&since&until&principal` | Usage bucketed by hour/day/week/month across everyone | -| `GET /requests?limit&offset` | The request metadata log with per-unit quantities (who, key, model, outcome, tokens; never content); same filters as `/stats/requests` | +| `GET /requests?limit&offset` | The request metadata log with per-unit quantities (who, key, model, outcome, `failed_over`, tokens; never content); same filters as `/stats/requests` | | `GET /events` | Metadata-only admin audit trail | ### Pagination diff --git a/docs/reference/openapi.yaml b/docs/reference/openapi.yaml index 936cc8f..ee125ff 100644 --- a/docs/reference/openapi.yaml +++ b/docs/reference/openapi.yaml @@ -123,6 +123,12 @@ components: description: Upstream model name the alias resolved to. schema: type: string + XLLMProxyAttempts: + description: > + Number of alias targets tried, the last one being the one that + answered. Above 1 only for an alias with several targets. + schema: + type: integer XLLMProxyErrorSource: description: > `proxy` when the error was generated by llmproxy itself, `upstream` @@ -266,7 +272,19 @@ components: nullable: true description: > The model this name points at, `null` when it binds a provider - directly. An extension to the OpenAI shape. + directly or has several targets. An extension to the OpenAI shape. + targets: + type: [array, "null"] + items: + type: string + description: > + The models an alias with several targets points at, in order; + `null` otherwise. An extension to the OpenAI shape. + strategy: + type: [string, "null"] + description: > + How an alias with several targets picks one; `null` otherwise. An + extension to the OpenAI shape. hidden: type: boolean description: > @@ -674,6 +692,16 @@ components: provider itself. `provider`, `upstream_name` and `capabilities` above are always the resolved ones; for an alias they are the target's. + targets: + type: array + items: + $ref: '#/components/schemas/AliasTarget' + description: > + The targets of an alias with several, in order. Empty otherwise. + `provider` and `upstream_name` are the first target's, and + `capabilities` is the intersection over all of them. + strategy: + $ref: '#/components/schemas/Strategy' pricing: $ref: '#/components/schemas/ModelPricing' pricing_inherited: @@ -688,6 +716,42 @@ components: serves requests under this name. The flag is the row's own: hiding a model does not hide the aliases pointing at it. + AliasTarget: + type: object + required: [alias, weight] + properties: + alias: + type: string + weight: + type: integer + minimum: 1 + description: Share of requests under the `weighted` strategy. + + TargetSpec: + description: A model name, or an object with a weight. + oneOf: + - type: string + - type: object + required: [alias] + properties: + alias: + type: string + weight: + type: integer + minimum: 0 + default: 1 + + Strategy: + type: [string, "null"] + enum: [failover, round_robin, weighted, least_busy, null] + description: > + How an alias with several targets picks the first target to try: + `failover` the first in the list, `round_robin` each in turn, + `weighted` at random by weight, `least_busy` the one with the fewest + requests in flight. With any strategy, an attempt that fails before + the first byte (unreachable, 429 or 5xx) moves on to the next target. + `null` for a model with one target or none. + ModelPricing: type: object description: > @@ -706,8 +770,8 @@ components: type: object description: > A model either routes to a provider's model (`provider` + - `upstream_name`) or is a name for a model already bound (`target`). - Give one or the other, never both. + `upstream_name`) or is a name for models already bound (`target` or + `targets`). Give one of the three, never several. properties: alias: type: string @@ -727,6 +791,19 @@ components: `upstream_name`. It must route to a provider itself (aliases are one hop) and the new model needs its own `alias`. Provider, upstream model, capabilities and prices are inherited. + targets: + type: array + items: + $ref: '#/components/schemas/TargetSpec' + description: > + Several models this one points at, in order. Each follows the + `target` rules. A name listed twice is 400 `invalid_target`. + strategy: + type: string + enum: [failover, round_robin, weighted, least_busy] + description: > + Needs at least two `targets`, else 400 `invalid_strategy`. + Defaults to `failover`. capabilities: type: array items: @@ -771,6 +848,17 @@ components: `upstream_name` in the same call. Sending `provider`, `upstream_name` or `capabilities` for a model that stays an alias is rejected with `invalid_target`. + targets: + type: array + items: + $ref: '#/components/schemas/TargetSpec' + description: > + Replace the whole target list. An empty list turns the model back + into a binding of its own, like an empty `target`. + strategy: + type: string + enum: [failover, round_robin, weighted, least_busy] + description: Change the strategy and keep the target list. pricing: $ref: '#/components/schemas/ModelPricing' hidden: @@ -801,6 +889,32 @@ components: type: array items: $ref: '#/components/schemas/Capability' + strategy: + $ref: '#/components/schemas/Strategy' + targets: + type: array + description: > + Every target, in the order the next request would try them. The + order and cooldowns are per replica. + items: + type: object + properties: + alias: + type: string + provider: + type: string + upstream_name: + type: string + url: + type: string + weight: + type: integer + cooling_down: + type: boolean + description: True when the target failed within the cooldown. + cooldown_until: + type: [string, 'null'] + format: date-time Principal: type: object @@ -1233,6 +1347,8 @@ paths: $ref: '#/components/headers/XLLMProxyProvider' x-llmproxy-model: $ref: '#/components/headers/XLLMProxyModel' + x-llmproxy-attempts: + $ref: '#/components/headers/XLLMProxyAttempts' content: application/json: schema: @@ -1282,6 +1398,8 @@ paths: $ref: '#/components/headers/XLLMProxyProvider' x-llmproxy-model: $ref: '#/components/headers/XLLMProxyModel' + x-llmproxy-attempts: + $ref: '#/components/headers/XLLMProxyAttempts' x-llmproxy-error-source: $ref: '#/components/headers/XLLMProxyErrorSource' @@ -1308,6 +1426,8 @@ paths: $ref: '#/components/headers/XLLMProxyProvider' x-llmproxy-model: $ref: '#/components/headers/XLLMProxyModel' + x-llmproxy-attempts: + $ref: '#/components/headers/XLLMProxyAttempts' content: application/json: schema: @@ -1368,6 +1488,8 @@ paths: $ref: '#/components/headers/XLLMProxyProvider' x-llmproxy-model: $ref: '#/components/headers/XLLMProxyModel' + x-llmproxy-attempts: + $ref: '#/components/headers/XLLMProxyAttempts' content: application/json: schema: @@ -2703,6 +2825,11 @@ paths: type: boolean cancelled: type: boolean + failed_over: + type: boolean + description: > + An attempt given up on before another alias + target was tried. Request counts leave it out. cost: type: [number, 'null'] unpriced: From ee0c33236847bfd473a478aef5b13530b73ea40b Mon Sep 17 00:00:00 2001 From: Mathieu Virbel Date: Thu, 8 Oct 2026 11:04:57 -0600 Subject: [PATCH 10/12] Show each target's provider and a weight label --- docs/reference/openapi.yaml | 5 +++- internal/server/admin.go | 2 +- internal/server/aliastargets_test.go | 3 ++- .../server/uidist/assets/index-COK_nABL.css | 1 + .../server/uidist/assets/index-DRB3hIKQ.css | 1 - internal/store/providers.go | 7 ++--- internal/store/targets_test.go | 3 ++- internal/store/types.go | 7 ++--- ui/src/lib/api.ts | 1 + ui/src/pages/Models.tsx | 27 +++++++++++++++---- 10 files changed, 41 insertions(+), 16 deletions(-) create mode 100644 internal/server/uidist/assets/index-COK_nABL.css delete mode 100644 internal/server/uidist/assets/index-DRB3hIKQ.css diff --git a/docs/reference/openapi.yaml b/docs/reference/openapi.yaml index ee125ff..c30e541 100644 --- a/docs/reference/openapi.yaml +++ b/docs/reference/openapi.yaml @@ -718,10 +718,13 @@ components: AliasTarget: type: object - required: [alias, weight] + required: [alias, provider, weight] properties: alias: type: string + provider: + type: string + description: The provider this target routes to. weight: type: integer minimum: 1 diff --git a/internal/server/admin.go b/internal/server/admin.go index 68c4d85..f9cda26 100644 --- a/internal/server/admin.go +++ b/internal/server/admin.go @@ -453,7 +453,7 @@ func modelView(b *store.ModelBinding, idx *pricing.Index) map[string]any { func targetViews(targets []store.BindingTarget) []map[string]any { out := make([]map[string]any, 0, len(targets)) for _, t := range targets { - out = append(out, map[string]any{"alias": t.Alias, "weight": t.Weight}) + out = append(out, map[string]any{"alias": t.Alias, "provider": t.Provider, "weight": t.Weight}) } return out } diff --git a/internal/server/aliastargets_test.go b/internal/server/aliastargets_test.go index 37d02ac..211072a 100644 --- a/internal/server/aliastargets_test.go +++ b/internal/server/aliastargets_test.go @@ -44,7 +44,8 @@ func TestAliasWithSeveralTargetsThroughAdminAPI(t *testing.T) { } targets := view["targets"].([]any) if len(targets) != 2 || targets[0].(map[string]any)["alias"] != b.Alias || - targets[1].(map[string]any)["weight"] != 3.0 { + targets[1].(map[string]any)["weight"] != 3.0 || + targets[0].(map[string]any)["provider"] != "pb" || targets[1].(map[string]any)["provider"] != "pa" { t.Fatalf("targets = %v", targets) } if caps := view["capabilities"].([]any); len(caps) != 2 { diff --git a/internal/server/uidist/assets/index-COK_nABL.css b/internal/server/uidist/assets/index-COK_nABL.css new file mode 100644 index 0000000..3b0c6a4 --- /dev/null +++ b/internal/server/uidist/assets/index-COK_nABL.css @@ -0,0 +1 @@ +/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-space-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-duration:initial;--tw-animation-delay:0s;--tw-animation-direction:normal;--tw-animation-duration:initial;--tw-animation-fill-mode:forwards;--tw-animation-iteration-count:1;--tw-enter-opacity:1;--tw-enter-rotate:0;--tw-enter-scale:1;--tw-enter-translate-x:0;--tw-enter-translate-y:0;--tw-exit-opacity:1;--tw-exit-rotate:0;--tw-exit-scale:1;--tw-exit-translate-x:0;--tw-exit-translate-y:0}}}@layer theme{:root,:host{--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;--color-red-500:oklch(63.7% .237 25.331);--color-yellow-400:oklch(85.2% .199 91.936);--color-yellow-500:oklch(79.5% .184 86.047);--color-yellow-600:oklch(68.1% .162 75.834);--color-green-500:oklch(72.3% .219 149.579);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-sm:24rem;--container-lg:32rem;--container-6xl:72rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height: 1.5 ;--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--tracking-tight:-.025em;--tracking-widest:.1em;--leading-relaxed:1.625;--radius-xs:.125rem;--animate-spin:spin 1s linear infinite;--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:"Aspekta", ui-sans-serif, system-ui, sans-serif;--default-mono-font-family:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;--color-popover:rgb(var(--popover));--color-popover-foreground:rgb(var(--popover-foreground));--color-border:rgb(var(--border))}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;-moz-tab-size:4;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab,red,red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){-webkit-appearance:button;-moz-appearance:button;appearance:button}::file-selector-button{-webkit-appearance:button;-moz-appearance:button;appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}*{border-color:rgb(var(--border));outline-color:rgb(var(--ring))}@supports (color:color-mix(in lab,red,red)){*{outline-color:color-mix(in oklab,rgb(var(--ring)) 50%,transparent)}}body{background-color:rgb(var(--background));color:rgb(var(--foreground));-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;font-family:Aspekta,ui-sans-serif,system-ui,sans-serif}}@layer components;@layer utilities{.\@container\/card-header{container:card-header/inline-size}.pointer-events-none{pointer-events:none}.collapse{visibility:collapse}.invisible{visibility:hidden}.visible{visibility:visible}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.inset-0{top:0;right:0;bottom:0;left:0}.inset-x-0{inset-inline:0}.inset-y-0{inset-block:0}.-top-2{top:calc(var(--spacing) * -2)}.top-0{top:0}.top-1\/2,.top-\[50\%\]{top:50%}.-right-2{right:calc(var(--spacing) * -2)}.right-1{right:var(--spacing)}.right-2{right:calc(var(--spacing) * 2)}.right-8{right:calc(var(--spacing) * 8)}.left-1\/2{left:50%}.left-2{left:calc(var(--spacing) * 2)}.left-\[50\%\]{left:50%}.z-10{z-index:10}.z-20{z-index:20}.z-50{z-index:50}.col-start-2{grid-column-start:2}.row-span-2{grid-row:span 2/span 2}.row-start-1{grid-row-start:1}.-mx-1{margin-inline:calc(var(--spacing) * -1)}.mx-auto{margin-inline:auto}.my-1{margin-block:var(--spacing)}.-mt-2{margin-top:calc(var(--spacing) * -2)}.mt-1{margin-top:var(--spacing)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mr-1{margin-right:var(--spacing)}.mb-1{margin-bottom:var(--spacing)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.ml-1{margin-left:var(--spacing)}.ml-1\.5{margin-left:calc(var(--spacing) * 1.5)}.ml-2{margin-left:calc(var(--spacing) * 2)}.ml-auto{margin-left:auto}.block{display:block}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.table{display:table}.table-caption{display:table-caption}.table-cell{display:table-cell}.table-row{display:table-row}.field-sizing-content{field-sizing:content}.size-2{width:calc(var(--spacing) * 2);height:calc(var(--spacing) * 2)}.size-2\.5{width:calc(var(--spacing) * 2.5);height:calc(var(--spacing) * 2.5)}.size-3{width:calc(var(--spacing) * 3);height:calc(var(--spacing) * 3)}.size-3\.5{width:calc(var(--spacing) * 3.5);height:calc(var(--spacing) * 3.5)}.size-4{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.size-5{width:calc(var(--spacing) * 5);height:calc(var(--spacing) * 5)}.size-6{width:calc(var(--spacing) * 6);height:calc(var(--spacing) * 6)}.size-8{width:calc(var(--spacing) * 8);height:calc(var(--spacing) * 8)}.size-9{width:calc(var(--spacing) * 9);height:calc(var(--spacing) * 9)}.size-10{width:calc(var(--spacing) * 10);height:calc(var(--spacing) * 10)}.h-0\.5{height:calc(var(--spacing) * .5)}.h-1\.5{height:calc(var(--spacing) * 1.5)}.h-2{height:calc(var(--spacing) * 2)}.h-6{height:calc(var(--spacing) * 6)}.h-8{height:calc(var(--spacing) * 8)}.h-9{height:calc(var(--spacing) * 9)}.h-10{height:calc(var(--spacing) * 10)}.h-16{height:calc(var(--spacing) * 16)}.h-\[1\.15rem\]{height:1.15rem}.h-\[calc\(100\%-1px\)\]{height:calc(100% - 1px)}.h-\[var\(--radix-select-trigger-height\)\]{height:var(--radix-select-trigger-height)}.h-full{height:100%}.h-px{height:1px}.max-h-\(--radix-dropdown-menu-content-available-height\){max-height:var(--radix-dropdown-menu-content-available-height)}.max-h-\(--radix-select-content-available-height\){max-height:var(--radix-select-content-available-height)}.max-h-40{max-height:calc(var(--spacing) * 40)}.max-h-48{max-height:calc(var(--spacing) * 48)}.max-h-64{max-height:calc(var(--spacing) * 64)}.max-h-96{max-height:calc(var(--spacing) * 96)}.min-h-9{min-height:calc(var(--spacing) * 9)}.min-h-16{min-height:calc(var(--spacing) * 16)}.min-h-screen{min-height:100vh}.w-3{width:calc(var(--spacing) * 3)}.w-6{width:calc(var(--spacing) * 6)}.w-8{width:calc(var(--spacing) * 8)}.w-12{width:calc(var(--spacing) * 12)}.w-16{width:calc(var(--spacing) * 16)}.w-20{width:calc(var(--spacing) * 20)}.w-24{width:calc(var(--spacing) * 24)}.w-26{width:calc(var(--spacing) * 26)}.w-32{width:calc(var(--spacing) * 32)}.w-36{width:calc(var(--spacing) * 36)}.w-40{width:calc(var(--spacing) * 40)}.w-44{width:calc(var(--spacing) * 44)}.w-56{width:calc(var(--spacing) * 56)}.w-64{width:calc(var(--spacing) * 64)}.w-auto{width:auto}.w-fit{width:fit-content}.w-full{width:100%}.max-w-6xl{max-width:var(--container-6xl)}.max-w-36{max-width:calc(var(--spacing) * 36)}.max-w-40{max-width:calc(var(--spacing) * 40)}.max-w-44{max-width:calc(var(--spacing) * 44)}.max-w-\[85\%\]{max-width:85%}.max-w-\[calc\(100\%-2rem\)\]{max-width:calc(100% - 2rem)}.max-w-full{max-width:100%}.max-w-sm{max-width:var(--container-sm)}.min-w-0{min-width:0}.min-w-32{min-width:calc(var(--spacing) * 32)}.min-w-36{min-width:calc(var(--spacing) * 36)}.min-w-48{min-width:calc(var(--spacing) * 48)}.min-w-\[8rem\]{min-width:8rem}.min-w-\[var\(--radix-select-trigger-width\)\]{min-width:var(--radix-select-trigger-width)}.flex-1{flex:1}.shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.caption-bottom{caption-side:bottom}.origin-\(--radix-dropdown-menu-content-transform-origin\){transform-origin:var(--radix-dropdown-menu-content-transform-origin)}.origin-\(--radix-select-content-transform-origin\){transform-origin:var(--radix-select-content-transform-origin)}.-translate-x-1\/2{--tw-translate-x: -50% ;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-x-\[-50\%\]{--tw-translate-x:-50%;translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-y-1\/2{--tw-translate-y: -50% ;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-y-\[-50\%\]{--tw-translate-y:-50%;translate:var(--tw-translate-x) var(--tw-translate-y)}.rotate-180{rotate:180deg}.animate-pulse{animation:var(--animate-pulse)}.animate-spin{animation:var(--animate-spin)}.cursor-default{cursor:default}.cursor-pointer{cursor:pointer}.scroll-my-1{scroll-margin-block:var(--spacing)}.auto-rows-min{grid-auto-rows:min-content}.\[grid-template-columns\:repeat\(auto-fit\,minmax\(260px\,1fr\)\)\]{grid-template-columns:repeat(auto-fit,minmax(260px,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-\[minmax\(6rem\,14rem\)_1fr_auto\]{grid-template-columns:minmax(6rem,14rem) 1fr auto}.grid-rows-\[auto_auto\]{grid-template-rows:auto auto}.flex-col{flex-direction:column}.flex-col-reverse{flex-direction:column-reverse}.flex-row{flex-direction:row}.flex-wrap{flex-wrap:wrap}.items-baseline{align-items:baseline}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-0\.5{gap:calc(var(--spacing) * .5)}.gap-1{gap:var(--spacing)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-6{gap:calc(var(--spacing) * 6)}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}.gap-x-4{column-gap:calc(var(--spacing) * 4)}.gap-x-6{column-gap:calc(var(--spacing) * 6)}.gap-x-8{column-gap:calc(var(--spacing) * 8)}.gap-y-1{row-gap:var(--spacing)}.gap-y-2{row-gap:calc(var(--spacing) * 2)}.gap-y-3{row-gap:calc(var(--spacing) * 3)}.gap-y-4{row-gap:calc(var(--spacing) * 4)}.self-start{align-self:flex-start}.justify-self-end{justify-self:flex-end}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.overflow-visible{overflow:visible}.overflow-x-auto{overflow-x:auto}.overflow-x-hidden{overflow-x:hidden}.overflow-y-auto{overflow-y:auto}.rounded{border-radius:.25rem}.rounded-\[4px\]{border-radius:4px}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:calc(var(--radius) + 2px)}.rounded-md{border-radius:var(--radius)}.rounded-sm{border-radius:calc(var(--radius) - 2px)}.rounded-xl{border-radius:calc(var(--radius) + 4px)}.rounded-xs{border-radius:var(--radius-xs)}.border{border-style:var(--tw-border-style);border-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-b-2{border-bottom-style:var(--tw-border-style);border-bottom-width:2px}.border-dashed{--tw-border-style:dashed;border-style:dashed}.border-border{border-color:rgb(var(--border))}.border-foreground{border-color:rgb(var(--foreground))}.border-input{border-color:rgb(var(--input))}.border-primary\/40{border-color:rgb(var(--primary))}@supports (color:color-mix(in lab,red,red)){.border-primary\/40{border-color:color-mix(in oklab,rgb(var(--primary)) 40%,transparent)}}.border-transparent{border-color:#0000}.bg-\[\#1a7f37\]{background-color:#1a7f37}.bg-\[\#3b82f6\]{background-color:#3b82f6}.bg-\[\#4b5563\]{background-color:#4b5563}.bg-\[\#9a6700\]{background-color:#9a6700}.bg-\[\#22c55e\]{background-color:#22c55e}.bg-\[\#0969da\]{background-color:#0969da}.bg-\[\#a855f7\]{background-color:#a855f7}.bg-\[\#f97316\]{background-color:#f97316}.bg-\[rgb\(var\(--border\)\)\]{background-color:rgb(var(--border))}.bg-accent{background-color:rgb(var(--accent))}.bg-background{background-color:rgb(var(--background))}.bg-black\/50{background-color:#00000080}@supports (color:color-mix(in lab,red,red)){.bg-black\/50{background-color:color-mix(in oklab,var(--color-black) 50%,transparent)}}.bg-border{background-color:rgb(var(--border))}.bg-card{background-color:rgb(var(--card))}.bg-chart-1{background-color:rgb(var(--chart-1))}.bg-chart-2{background-color:rgb(var(--chart-2))}.bg-chart-3{background-color:rgb(var(--chart-3))}.bg-chart-5{background-color:rgb(var(--chart-5))}.bg-destructive{background-color:rgb(var(--destructive))}.bg-foreground\/5{background-color:rgb(var(--foreground))}@supports (color:color-mix(in lab,red,red)){.bg-foreground\/5{background-color:color-mix(in oklab,rgb(var(--foreground)) 5%,transparent)}}.bg-green-500{background-color:var(--color-green-500)}.bg-muted{background-color:rgb(var(--muted))}.bg-muted-foreground{background-color:rgb(var(--muted-foreground))}.bg-muted\/30{background-color:rgb(var(--muted))}@supports (color:color-mix(in lab,red,red)){.bg-muted\/30{background-color:color-mix(in oklab,rgb(var(--muted)) 30%,transparent)}}.bg-muted\/50{background-color:rgb(var(--muted))}@supports (color:color-mix(in lab,red,red)){.bg-muted\/50{background-color:color-mix(in oklab,rgb(var(--muted)) 50%,transparent)}}.bg-popover{background-color:rgb(var(--popover))}.bg-primary,.bg-primary\/5{background-color:rgb(var(--primary))}@supports (color:color-mix(in lab,red,red)){.bg-primary\/5{background-color:color-mix(in oklab,rgb(var(--primary)) 5%,transparent)}}.bg-red-500{background-color:var(--color-red-500)}.bg-secondary{background-color:rgb(var(--secondary))}.bg-transparent{background-color:#0000}.bg-yellow-500{background-color:var(--color-yellow-500)}.fill-border{fill:rgb(var(--border))}.fill-chart-1{fill:rgb(var(--chart-1))}.fill-chart-2{fill:rgb(var(--chart-2))}.fill-chart-3{fill:rgb(var(--chart-3))}.fill-chart-5{fill:rgb(var(--chart-5))}.fill-current{fill:currentColor}.fill-foreground,.fill-foreground\/5{fill:rgb(var(--foreground))}@supports (color:color-mix(in lab,red,red)){.fill-foreground\/5{fill:color-mix(in oklab,rgb(var(--foreground)) 5%,transparent)}}.fill-muted-foreground{fill:rgb(var(--muted-foreground))}.stroke-border{stroke:rgb(var(--border))}.object-cover{object-fit:cover}.p-1{padding:var(--spacing)}.p-2{padding:calc(var(--spacing) * 2)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-6{padding:calc(var(--spacing) * 6)}.p-\[3px\]{padding:3px}.px-0\.5{padding-inline:calc(var(--spacing) * .5)}.px-1{padding-inline:var(--spacing)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-6{padding-inline:calc(var(--spacing) * 6)}.py-0{padding-block:0}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:var(--spacing)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-8{padding-block:calc(var(--spacing) * 8)}.py-12{padding-block:calc(var(--spacing) * 12)}.pt-4{padding-top:calc(var(--spacing) * 4)}.pr-0{padding-right:0}.pr-1{padding-right:var(--spacing)}.pr-2{padding-right:calc(var(--spacing) * 2)}.pr-6{padding-right:calc(var(--spacing) * 6)}.pr-8{padding-right:calc(var(--spacing) * 8)}.pr-14{padding-right:calc(var(--spacing) * 14)}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.pb-3{padding-bottom:calc(var(--spacing) * 3)}.pl-2{padding-left:calc(var(--spacing) * 2)}.pl-8{padding-left:calc(var(--spacing) * 8)}.pl-\[18px\]{padding-left:18px}.pl-\[26px\]{padding-left:26px}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.align-middle{vertical-align:middle}.align-top{vertical-align:top}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace}.font-sans{font-family:Aspekta,ui-sans-serif,system-ui,sans-serif}.font-serif{font-family:"Source Serif 4",Source Serif Pro,Georgia,serif}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-sm\/relaxed{font-size:var(--text-sm);line-height:var(--leading-relaxed)}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[10px\]{font-size:10px}.leading-none{--tw-leading:1;line-height:1}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-widest{--tw-tracking:var(--tracking-widest);letter-spacing:var(--tracking-widest)}.text-balance{text-wrap:balance}.wrap-anywhere{overflow-wrap:anywhere}.break-all{word-break:break-all}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.text-accent-foreground{color:rgb(var(--accent-foreground))}.text-card-foreground{color:rgb(var(--card-foreground))}.text-current{color:currentColor}.text-destructive{color:rgb(var(--destructive))}.text-foreground{color:rgb(var(--foreground))}.text-muted-foreground{color:rgb(var(--muted-foreground))}.text-popover-foreground{color:rgb(var(--popover-foreground))}.text-primary{color:rgb(var(--primary))}.text-primary-foreground{color:rgb(var(--primary-foreground))}.text-secondary-foreground{color:rgb(var(--secondary-foreground))}.text-white{color:var(--color-white)}.text-yellow-600{color:var(--color-yellow-600)}.lowercase{text-transform:lowercase}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.underline-offset-2{text-underline-offset:2px}.underline-offset-4{text-underline-offset:4px}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a), 0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-xs{--tw-shadow:0 1px 2px 0 var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring-0{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.outline-hidden{--tw-outline-style:none;outline-style:none}@media(forced-colors:active){.outline-hidden{outline-offset:2px;outline:2px solid #0000}}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.transition-\[color\,box-shadow\]{transition-property:color,box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-shadow{transition-property:box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-none{transition-property:none}.duration-200{--tw-duration:.2s;transition-duration:.2s}.outline-none{--tw-outline-style:none;outline-style:none}.select-none{-webkit-user-select:none;user-select:none}.running{animation-play-state:running}.group-data-\[disabled\=true\]\:pointer-events-none:is(:where(.group)[data-disabled=true] *){pointer-events:none}.group-data-\[disabled\=true\]\:opacity-50:is(:where(.group)[data-disabled=true] *){opacity:.5}.peer-disabled\:cursor-not-allowed:is(:where(.peer):disabled~*){cursor:not-allowed}.peer-disabled\:opacity-50:is(:where(.peer):disabled~*){opacity:.5}.selection\:bg-primary ::selection{background-color:rgb(var(--primary))}.selection\:bg-primary::selection{background-color:rgb(var(--primary))}.selection\:text-primary-foreground ::selection{color:rgb(var(--primary-foreground))}.selection\:text-primary-foreground::selection{color:rgb(var(--primary-foreground))}.file\:inline-flex::file-selector-button{display:inline-flex}.file\:h-7::file-selector-button{height:calc(var(--spacing) * 7)}.file\:border-0::file-selector-button{border-style:var(--tw-border-style);border-width:0}.file\:bg-transparent::file-selector-button{background-color:#0000}.file\:text-sm::file-selector-button{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.file\:font-medium::file-selector-button{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.file\:text-foreground::file-selector-button{color:rgb(var(--foreground))}.placeholder\:text-muted-foreground::placeholder{color:rgb(var(--muted-foreground))}@media(hover:hover){.hover\:bg-accent:hover{background-color:rgb(var(--accent))}.hover\:bg-destructive\/90:hover{background-color:rgb(var(--destructive))}@supports (color:color-mix(in lab,red,red)){.hover\:bg-destructive\/90:hover{background-color:color-mix(in oklab,rgb(var(--destructive)) 90%,transparent)}}.hover\:bg-muted\/30:hover{background-color:rgb(var(--muted))}@supports (color:color-mix(in lab,red,red)){.hover\:bg-muted\/30:hover{background-color:color-mix(in oklab,rgb(var(--muted)) 30%,transparent)}}.hover\:bg-muted\/50:hover{background-color:rgb(var(--muted))}@supports (color:color-mix(in lab,red,red)){.hover\:bg-muted\/50:hover{background-color:color-mix(in oklab,rgb(var(--muted)) 50%,transparent)}}.hover\:bg-primary\/90:hover{background-color:rgb(var(--primary))}@supports (color:color-mix(in lab,red,red)){.hover\:bg-primary\/90:hover{background-color:color-mix(in oklab,rgb(var(--primary)) 90%,transparent)}}.hover\:bg-secondary\/80:hover{background-color:rgb(var(--secondary))}@supports (color:color-mix(in lab,red,red)){.hover\:bg-secondary\/80:hover{background-color:color-mix(in oklab,rgb(var(--secondary)) 80%,transparent)}}.hover\:text-accent-foreground:hover{color:rgb(var(--accent-foreground))}.hover\:text-foreground:hover{color:rgb(var(--foreground))}.hover\:underline:hover{text-decoration-line:underline}}.focus\:bg-accent:focus{background-color:rgb(var(--accent))}.focus\:text-accent-foreground:focus{color:rgb(var(--accent-foreground))}.focus-visible\:border-ring:focus-visible{border-color:rgb(var(--ring))}.focus-visible\:stroke-ring:focus-visible{stroke:rgb(var(--ring))}.focus-visible\:\[stroke-width\:2\]:focus-visible{stroke-width:2px}.focus-visible\:ring-2:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-visible\:ring-\[3px\]:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-visible\:ring-destructive\/20:focus-visible{--tw-ring-color:rgb(var(--destructive))}@supports (color:color-mix(in lab,red,red)){.focus-visible\:ring-destructive\/20:focus-visible{--tw-ring-color:color-mix(in oklab, rgb(var(--destructive)) 20%, transparent)}}.focus-visible\:ring-ring:focus-visible,.focus-visible\:ring-ring\/50:focus-visible{--tw-ring-color:rgb(var(--ring))}@supports (color:color-mix(in lab,red,red)){.focus-visible\:ring-ring\/50:focus-visible{--tw-ring-color:color-mix(in oklab, rgb(var(--ring)) 50%, transparent)}}.focus-visible\:outline-1:focus-visible{outline-style:var(--tw-outline-style);outline-width:1px}.focus-visible\:outline-ring:focus-visible{outline-color:rgb(var(--ring))}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-50:disabled{opacity:.5}.has-data-\[slot\=card-action\]\:grid-cols-\[1fr_auto\]:has([data-slot=card-action]){grid-template-columns:1fr auto}.has-\[\>svg\]\:px-2\.5:has(>svg){padding-inline:calc(var(--spacing) * 2.5)}.has-\[\>svg\]\:px-3:has(>svg){padding-inline:calc(var(--spacing) * 3)}.has-\[\>svg\]\:px-4:has(>svg){padding-inline:calc(var(--spacing) * 4)}.aria-invalid\:border-destructive[aria-invalid=true]{border-color:rgb(var(--destructive))}.aria-invalid\:ring-destructive\/20[aria-invalid=true]{--tw-ring-color:rgb(var(--destructive))}@supports (color:color-mix(in lab,red,red)){.aria-invalid\:ring-destructive\/20[aria-invalid=true]{--tw-ring-color:color-mix(in oklab, rgb(var(--destructive)) 20%, transparent)}}.data-\[disabled\]\:pointer-events-none[data-disabled]{pointer-events:none}.data-\[disabled\]\:opacity-50[data-disabled]{opacity:.5}.data-\[inset\]\:pl-8[data-inset]{padding-left:calc(var(--spacing) * 8)}.data-\[orientation\=horizontal\]\:h-px[data-orientation=horizontal]{height:1px}.data-\[orientation\=horizontal\]\:w-full[data-orientation=horizontal]{width:100%}.data-\[orientation\=vertical\]\:h-full[data-orientation=vertical]{height:100%}.data-\[orientation\=vertical\]\:w-px[data-orientation=vertical]{width:1px}.data-\[placeholder\]\:text-muted-foreground[data-placeholder]{color:rgb(var(--muted-foreground))}.data-\[side\=bottom\]\:translate-y-1[data-side=bottom]{--tw-translate-y:var(--spacing);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[side\=bottom\]\:slide-in-from-top-2[data-side=bottom]{--tw-enter-translate-y:calc(2*var(--spacing)*-1)}.data-\[side\=left\]\:-translate-x-1[data-side=left]{--tw-translate-x:calc(var(--spacing) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[side\=left\]\:slide-in-from-right-2[data-side=left]{--tw-enter-translate-x:calc(2*var(--spacing))}.data-\[side\=right\]\:translate-x-1[data-side=right]{--tw-translate-x:var(--spacing);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[side\=right\]\:slide-in-from-left-2[data-side=right]{--tw-enter-translate-x:calc(2*var(--spacing)*-1)}.data-\[side\=top\]\:-translate-y-1[data-side=top]{--tw-translate-y:calc(var(--spacing) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[side\=top\]\:slide-in-from-bottom-2[data-side=top]{--tw-enter-translate-y:calc(2*var(--spacing))}.data-\[size\=default\]\:h-9[data-size=default]{height:calc(var(--spacing) * 9)}.data-\[size\=sm\]\:h-8[data-size=sm]{height:calc(var(--spacing) * 8)}:is(.\*\:data-\[slot\=select-value\]\:line-clamp-1>*)[data-slot=select-value]{-webkit-line-clamp:1;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}:is(.\*\:data-\[slot\=select-value\]\:flex>*)[data-slot=select-value]{display:flex}:is(.\*\:data-\[slot\=select-value\]\:items-center>*)[data-slot=select-value]{align-items:center}:is(.\*\:data-\[slot\=select-value\]\:gap-2>*)[data-slot=select-value]{gap:calc(var(--spacing) * 2)}.data-\[state\=active\]\:bg-card[data-state=active]{background-color:rgb(var(--card))}.data-\[state\=active\]\:shadow-sm[data-state=active]{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.data-\[state\=checked\]\:translate-x-\[calc\(100\%-2px\)\][data-state=checked]{--tw-translate-x: calc(100% - 2px) ;translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[state\=checked\]\:border-primary[data-state=checked]{border-color:rgb(var(--primary))}.data-\[state\=checked\]\:bg-primary[data-state=checked]{background-color:rgb(var(--primary))}.data-\[state\=checked\]\:text-primary-foreground[data-state=checked]{color:rgb(var(--primary-foreground))}.data-\[state\=closed\]\:animate-out[data-state=closed]{animation:exit var(--tw-animation-duration,var(--tw-duration,.15s))var(--tw-ease,ease)var(--tw-animation-delay,0s)var(--tw-animation-iteration-count,1)var(--tw-animation-direction,normal)var(--tw-animation-fill-mode,forwards)}.data-\[state\=closed\]\:fade-out-0[data-state=closed]{--tw-exit-opacity:0}.data-\[state\=closed\]\:zoom-out-95[data-state=closed]{--tw-exit-scale:.95}.data-\[state\=inactive\]\:hidden[data-state=inactive]{display:none}.data-\[state\=open\]\:animate-in[data-state=open]{animation:enter var(--tw-animation-duration,var(--tw-duration,.15s))var(--tw-ease,ease)var(--tw-animation-delay,0s)var(--tw-animation-iteration-count,1)var(--tw-animation-direction,normal)var(--tw-animation-fill-mode,forwards)}.data-\[state\=open\]\:bg-accent[data-state=open]{background-color:rgb(var(--accent))}.data-\[state\=open\]\:text-accent-foreground[data-state=open]{color:rgb(var(--accent-foreground))}.data-\[state\=open\]\:fade-in-0[data-state=open]{--tw-enter-opacity:0}.data-\[state\=open\]\:zoom-in-95[data-state=open]{--tw-enter-scale:.95}.data-\[state\=selected\]\:bg-muted[data-state=selected]{background-color:rgb(var(--muted))}.data-\[state\=unchecked\]\:translate-x-0[data-state=unchecked]{--tw-translate-x:0px;translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[state\=unchecked\]\:bg-\[\#7F7F79\][data-state=unchecked]{background-color:#7f7f79}.data-\[variant\=destructive\]\:text-destructive[data-variant=destructive]{color:rgb(var(--destructive))}.data-\[variant\=destructive\]\:focus\:bg-destructive\/10[data-variant=destructive]:focus{background-color:rgb(var(--destructive))}@supports (color:color-mix(in lab,red,red)){.data-\[variant\=destructive\]\:focus\:bg-destructive\/10[data-variant=destructive]:focus{background-color:color-mix(in oklab,rgb(var(--destructive)) 10%,transparent)}}.data-\[variant\=destructive\]\:focus\:text-destructive[data-variant=destructive]:focus{color:rgb(var(--destructive))}@media(min-width:40rem){.sm\:col-span-2{grid-column:span 2/span 2}.sm\:max-w-lg{max-width:var(--container-lg)}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:justify-end{justify-content:flex-end}.sm\:text-left{text-align:left}}@media(min-width:48rem){.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:p-12{padding:calc(var(--spacing) * 12)}.md\:text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}}@media(min-width:64rem){.lg\:col-span-2{grid-column:span 2/span 2}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}.dark\:border-input:is(.dark *){border-color:rgb(var(--input))}.dark\:bg-chart-4:is(.dark *){background-color:rgb(var(--chart-4))}.dark\:bg-destructive\/60:is(.dark *){background-color:rgb(var(--destructive))}@supports (color:color-mix(in lab,red,red)){.dark\:bg-destructive\/60:is(.dark *){background-color:color-mix(in oklab,rgb(var(--destructive)) 60%,transparent)}}.dark\:bg-input\/30:is(.dark *){background-color:rgb(var(--input))}@supports (color:color-mix(in lab,red,red)){.dark\:bg-input\/30:is(.dark *){background-color:color-mix(in oklab,rgb(var(--input)) 30%,transparent)}}.dark\:fill-chart-4:is(.dark *){fill:rgb(var(--chart-4))}.dark\:text-muted-foreground:is(.dark *){color:rgb(var(--muted-foreground))}.dark\:text-yellow-400:is(.dark *){color:var(--color-yellow-400)}@media(hover:hover){.dark\:hover\:bg-accent\/50:is(.dark *):hover{background-color:rgb(var(--accent))}@supports (color:color-mix(in lab,red,red)){.dark\:hover\:bg-accent\/50:is(.dark *):hover{background-color:color-mix(in oklab,rgb(var(--accent)) 50%,transparent)}}.dark\:hover\:bg-input\/50:is(.dark *):hover{background-color:rgb(var(--input))}@supports (color:color-mix(in lab,red,red)){.dark\:hover\:bg-input\/50:is(.dark *):hover{background-color:color-mix(in oklab,rgb(var(--input)) 50%,transparent)}}}.dark\:focus-visible\:ring-destructive\/40:is(.dark *):focus-visible{--tw-ring-color:rgb(var(--destructive))}@supports (color:color-mix(in lab,red,red)){.dark\:focus-visible\:ring-destructive\/40:is(.dark *):focus-visible{--tw-ring-color:color-mix(in oklab, rgb(var(--destructive)) 40%, transparent)}}.dark\:aria-invalid\:ring-destructive\/40:is(.dark *)[aria-invalid=true]{--tw-ring-color:rgb(var(--destructive))}@supports (color:color-mix(in lab,red,red)){.dark\:aria-invalid\:ring-destructive\/40:is(.dark *)[aria-invalid=true]{--tw-ring-color:color-mix(in oklab, rgb(var(--destructive)) 40%, transparent)}}.dark\:data-\[state\=active\]\:border-input:is(.dark *)[data-state=active]{border-color:rgb(var(--input))}.dark\:data-\[state\=active\]\:bg-input\/30:is(.dark *)[data-state=active]{background-color:rgb(var(--input))}@supports (color:color-mix(in lab,red,red)){.dark\:data-\[state\=active\]\:bg-input\/30:is(.dark *)[data-state=active]{background-color:color-mix(in oklab,rgb(var(--input)) 30%,transparent)}}.dark\:data-\[state\=active\]\:text-foreground:is(.dark *)[data-state=active]{color:rgb(var(--foreground))}.dark\:data-\[state\=checked\]\:bg-primary:is(.dark *)[data-state=checked]{background-color:rgb(var(--primary))}.dark\:data-\[state\=checked\]\:bg-primary-foreground:is(.dark *)[data-state=checked]{background-color:rgb(var(--primary-foreground))}.dark\:data-\[state\=unchecked\]\:bg-foreground:is(.dark *)[data-state=unchecked]{background-color:rgb(var(--foreground))}.dark\:data-\[state\=unchecked\]\:bg-input:is(.dark *)[data-state=unchecked]{background-color:rgb(var(--input))}.dark\:data-\[variant\=destructive\]\:focus\:bg-destructive\/20:is(.dark *)[data-variant=destructive]:focus{background-color:rgb(var(--destructive))}@supports (color:color-mix(in lab,red,red)){.dark\:data-\[variant\=destructive\]\:focus\:bg-destructive\/20:is(.dark *)[data-variant=destructive]:focus{background-color:color-mix(in oklab,rgb(var(--destructive)) 20%,transparent)}}.\[\&_svg\]\:pointer-events-none svg{pointer-events:none}.\[\&_svg\]\:shrink-0 svg{flex-shrink:0}.\[\&_svg\:not\(\[class\*\=\'size-\'\]\)\]\:size-4 svg:not([class*=size-]){width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.\[\&_svg\:not\(\[class\*\=\'size-\'\]\)\]\:size-6 svg:not([class*=size-]){width:calc(var(--spacing) * 6);height:calc(var(--spacing) * 6)}.\[\&_svg\:not\(\[class\*\=\'text-\'\]\)\]\:text-muted-foreground svg:not([class*=text-]){color:rgb(var(--muted-foreground))}.\[\&_tr\]\:border-b tr{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.\[\&_tr\:last-child\]\:border-0 tr:last-child{border-style:var(--tw-border-style);border-width:0}.\[\&\:has\(\[role\=checkbox\]\)\]\:pr-0:has([role=checkbox]){padding-right:0}.\[\.border-b\]\:pb-6.border-b{padding-bottom:calc(var(--spacing) * 6)}.\[\.border-t\]\:pt-6.border-t{padding-top:calc(var(--spacing) * 6)}:is(.\*\:\[span\]\:last\:flex>*):is(span):last-child{display:flex}:is(.\*\:\[span\]\:last\:items-center>*):is(span):last-child{align-items:center}:is(.\*\:\[span\]\:last\:gap-2>*):is(span):last-child{gap:calc(var(--spacing) * 2)}:is(.data-\[variant\=destructive\]\:\*\:\[svg\]\:\!text-destructive[data-variant=destructive]>*):is(svg){color:rgb(var(--destructive))!important}.\[\&\>\[role\=checkbox\]\]\:translate-y-\[2px\]>[role=checkbox]{--tw-translate-y:2px;translate:var(--tw-translate-x) var(--tw-translate-y)}.\[\&\>a\]\:underline>a{text-decoration-line:underline}.\[\&\>a\]\:underline-offset-4>a{text-underline-offset:4px}.\[\&\>a\:hover\]\:text-primary>a:hover{color:rgb(var(--primary))}.\[\&\>svg\]\:pointer-events-none>svg{pointer-events:none}.\[\&\>svg\]\:size-3>svg{width:calc(var(--spacing) * 3);height:calc(var(--spacing) * 3)}.\[\&\>svg\]\:size-3\.5>svg{width:calc(var(--spacing) * 3.5);height:calc(var(--spacing) * 3.5)}.\[\&\>tr\]\:last\:border-b-0>tr:last-child{border-bottom-style:var(--tw-border-style);border-bottom-width:0}@media(hover:hover){a.\[a\&\]\:hover\:bg-\[\#1a7f37\]\/90:hover{background-color:#1a7f37e6}a.\[a\&\]\:hover\:bg-\[\#3b82f6\]\/90:hover{background-color:#3b82f6e6}a.\[a\&\]\:hover\:bg-\[\#4b5563\]\/90:hover{background-color:#4b5563e6}a.\[a\&\]\:hover\:bg-\[\#9a6700\]\/90:hover{background-color:#9a6700e6}a.\[a\&\]\:hover\:bg-\[\#22c55e\]\/90:hover{background-color:#22c55ee6}a.\[a\&\]\:hover\:bg-\[\#0969da\]\/90:hover{background-color:#0969dae6}a.\[a\&\]\:hover\:bg-\[\#a855f7\]\/90:hover{background-color:#a855f7e6}a.\[a\&\]\:hover\:bg-\[\#f97316\]\/90:hover{background-color:#f97316e6}a.\[a\&\]\:hover\:bg-accent\/10:hover{background-color:rgb(var(--accent))}@supports (color:color-mix(in lab,red,red)){a.\[a\&\]\:hover\:bg-accent\/10:hover{background-color:color-mix(in oklab,rgb(var(--accent)) 10%,transparent)}}a.\[a\&\]\:hover\:bg-destructive\/90:hover{background-color:rgb(var(--destructive))}@supports (color:color-mix(in lab,red,red)){a.\[a\&\]\:hover\:bg-destructive\/90:hover{background-color:color-mix(in oklab,rgb(var(--destructive)) 90%,transparent)}}a.\[a\&\]\:hover\:bg-muted:hover,a.\[a\&\]\:hover\:bg-muted\/80:hover{background-color:rgb(var(--muted))}@supports (color:color-mix(in lab,red,red)){a.\[a\&\]\:hover\:bg-muted\/80:hover{background-color:color-mix(in oklab,rgb(var(--muted)) 80%,transparent)}}a.\[a\&\]\:hover\:bg-primary\/90:hover{background-color:rgb(var(--primary))}@supports (color:color-mix(in lab,red,red)){a.\[a\&\]\:hover\:bg-primary\/90:hover{background-color:color-mix(in oklab,rgb(var(--primary)) 90%,transparent)}}a.\[a\&\]\:hover\:bg-secondary\/90:hover{background-color:rgb(var(--secondary))}@supports (color:color-mix(in lab,red,red)){a.\[a\&\]\:hover\:bg-secondary\/90:hover{background-color:color-mix(in oklab,rgb(var(--secondary)) 90%,transparent)}}a.\[a\&\]\:hover\:text-accent-foreground:hover{color:rgb(var(--accent-foreground))}}}@property --tw-animation-delay{syntax:"*";inherits:false;initial-value:0s}@property --tw-animation-direction{syntax:"*";inherits:false;initial-value:normal}@property --tw-animation-duration{syntax:"*";inherits:false}@property --tw-animation-fill-mode{syntax:"*";inherits:false;initial-value:forwards}@property --tw-animation-iteration-count{syntax:"*";inherits:false;initial-value:1}@property --tw-enter-opacity{syntax:"*";inherits:false;initial-value:1}@property --tw-enter-rotate{syntax:"*";inherits:false;initial-value:0}@property --tw-enter-scale{syntax:"*";inherits:false;initial-value:1}@property --tw-enter-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-enter-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-opacity{syntax:"*";inherits:false;initial-value:1}@property --tw-exit-rotate{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-scale{syntax:"*";inherits:false;initial-value:1}@property --tw-exit-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-translate-y{syntax:"*";inherits:false;initial-value:0}@font-face{font-family:Aspekta;font-style:normal;font-weight:400;font-display:swap;src:url(/fonts/Aspekta-400.woff2)format("woff2")}@font-face{font-family:Aspekta;font-style:normal;font-weight:500;font-display:swap;src:url(/fonts/Aspekta-500.woff2)format("woff2")}@font-face{font-family:Aspekta;font-style:normal;font-weight:600;font-display:swap;src:url(/fonts/Aspekta-600.woff2)format("woff2")}@font-face{font-family:Aspekta;font-style:normal;font-weight:700;font-display:swap;src:url(/fonts/Aspekta-700.woff2)format("woff2")}:root{--background:240 240 236;--foreground:22 22 20;--card:249 249 247;--card-foreground:22 22 20;--popover:249 249 247;--popover-foreground:22 22 20;--primary:217 94 42;--primary-foreground:249 249 247;--secondary:240 240 236;--secondary-foreground:47 47 44;--muted:240 240 236;--muted-foreground:87 87 83;--accent:221 221 215;--accent-foreground:22 22 20;--destructive:180 50 50;--destructive-foreground:249 249 247;--border:196 196 189;--input:196 196 189;--ring:217 94 42;--chart-1:217 94 42;--chart-2:87 87 83;--chart-3:127 127 121;--chart-4:166 166 159;--chart-5:47 47 44;--radius:.375rem}.dark{--background:22 22 20;--foreground:249 249 247;--card:47 47 44;--card-foreground:249 249 247;--popover:47 47 44;--popover-foreground:249 249 247;--primary:217 94 42;--primary-foreground:249 249 247;--secondary:87 87 83;--secondary-foreground:249 249 247;--muted:87 87 83;--muted-foreground:196 196 189;--accent:87 87 83;--accent-foreground:249 249 247;--destructive:180 50 50;--destructive-foreground:249 249 247;--border:87 87 83;--input:87 87 83;--ring:217 94 42;--chart-1:217 94 42;--chart-2:196 196 189;--chart-3:166 166 159;--chart-4:127 127 121;--chart-5:240 240 236}.chat-markdown{overflow-wrap:break-word;line-height:1.55}.chat-markdown>:first-child{margin-top:0}.chat-markdown>:last-child{margin-bottom:0}.chat-markdown p{margin:.5em 0}.chat-markdown h1,.chat-markdown h2,.chat-markdown h3,.chat-markdown h4,.chat-markdown h5,.chat-markdown h6{margin:1em 0 .4em;font-size:1em;font-weight:600;line-height:1.3}.chat-markdown h1{font-size:1.15em}.chat-markdown h2{font-size:1.08em}.chat-markdown ul,.chat-markdown ol{margin:.5em 0;padding-left:1.4em}.chat-markdown ul{list-style:outside}.chat-markdown ol{list-style:decimal}.chat-markdown li,.chat-markdown li>ul,.chat-markdown li>ol{margin:.2em 0}.chat-markdown li::marker{color:rgb(var(--muted-foreground))}.chat-markdown code{font-family:var(--font-mono);background:rgb(var(--muted));border:1px solid rgb(var(--border) / .6);border-radius:4px;padding:.1em .35em;font-size:.9em}.chat-markdown pre{background:rgb(var(--muted));border:1px solid rgb(var(--border) / .6);border-radius:var(--radius);margin:.6em 0;padding:.6em .75em;overflow-x:auto}.chat-markdown pre code{background:0 0;border:none;padding:0;font-size:.85em;line-height:1.5}.chat-markdown blockquote{border-left:3px solid rgb(var(--border));color:rgb(var(--muted-foreground));margin:.6em 0;padding:.1em 0 .1em .9em}.chat-markdown a{color:rgb(var(--primary));text-underline-offset:2px;text-decoration:underline}.chat-markdown table{border-collapse:collapse;margin:.6em 0;font-size:.95em;display:block;overflow-x:auto}.chat-markdown th,.chat-markdown td{border:1px solid rgb(var(--border));text-align:left;padding:4px 10px}.chat-markdown th{background:rgb(var(--muted));font-weight:600}.chat-markdown hr{border:none;border-top:1px solid rgb(var(--border));margin:1em 0}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@keyframes spin{to{transform:rotate(360deg)}}@keyframes pulse{50%{opacity:.5}}@keyframes enter{0%{opacity:var(--tw-enter-opacity,1);transform:translate3d(var(--tw-enter-translate-x,0),var(--tw-enter-translate-y,0),0)scale3d(var(--tw-enter-scale,1),var(--tw-enter-scale,1),var(--tw-enter-scale,1))rotate(var(--tw-enter-rotate,0))}}@keyframes exit{to{opacity:var(--tw-exit-opacity,1);transform:translate3d(var(--tw-exit-translate-x,0),var(--tw-exit-translate-y,0),0)scale3d(var(--tw-exit-scale,1),var(--tw-exit-scale,1),var(--tw-exit-scale,1))rotate(var(--tw-exit-rotate,0))}} diff --git a/internal/server/uidist/assets/index-DRB3hIKQ.css b/internal/server/uidist/assets/index-DRB3hIKQ.css deleted file mode 100644 index 7ddcf93..0000000 --- a/internal/server/uidist/assets/index-DRB3hIKQ.css +++ /dev/null @@ -1 +0,0 @@ -/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-space-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-duration:initial;--tw-animation-delay:0s;--tw-animation-direction:normal;--tw-animation-duration:initial;--tw-animation-fill-mode:forwards;--tw-animation-iteration-count:1;--tw-enter-opacity:1;--tw-enter-rotate:0;--tw-enter-scale:1;--tw-enter-translate-x:0;--tw-enter-translate-y:0;--tw-exit-opacity:1;--tw-exit-rotate:0;--tw-exit-scale:1;--tw-exit-translate-x:0;--tw-exit-translate-y:0}}}@layer theme{:root,:host{--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;--color-red-500:oklch(63.7% .237 25.331);--color-yellow-400:oklch(85.2% .199 91.936);--color-yellow-500:oklch(79.5% .184 86.047);--color-yellow-600:oklch(68.1% .162 75.834);--color-green-500:oklch(72.3% .219 149.579);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-sm:24rem;--container-lg:32rem;--container-6xl:72rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height: 1.5 ;--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--tracking-tight:-.025em;--tracking-widest:.1em;--leading-relaxed:1.625;--radius-xs:.125rem;--animate-spin:spin 1s linear infinite;--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:"Aspekta", ui-sans-serif, system-ui, sans-serif;--default-mono-font-family:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;--color-popover:rgb(var(--popover));--color-popover-foreground:rgb(var(--popover-foreground));--color-border:rgb(var(--border))}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;-moz-tab-size:4;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab,red,red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){-webkit-appearance:button;-moz-appearance:button;appearance:button}::file-selector-button{-webkit-appearance:button;-moz-appearance:button;appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}*{border-color:rgb(var(--border));outline-color:rgb(var(--ring))}@supports (color:color-mix(in lab,red,red)){*{outline-color:color-mix(in oklab,rgb(var(--ring)) 50%,transparent)}}body{background-color:rgb(var(--background));color:rgb(var(--foreground));-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;font-family:Aspekta,ui-sans-serif,system-ui,sans-serif}}@layer components;@layer utilities{.\@container\/card-header{container:card-header/inline-size}.pointer-events-none{pointer-events:none}.collapse{visibility:collapse}.invisible{visibility:hidden}.visible{visibility:visible}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.inset-0{top:0;right:0;bottom:0;left:0}.inset-x-0{inset-inline:0}.inset-y-0{inset-block:0}.-top-2{top:calc(var(--spacing) * -2)}.top-0{top:0}.top-1\/2,.top-\[50\%\]{top:50%}.-right-2{right:calc(var(--spacing) * -2)}.right-1{right:var(--spacing)}.right-2{right:calc(var(--spacing) * 2)}.right-8{right:calc(var(--spacing) * 8)}.left-1\/2{left:50%}.left-2{left:calc(var(--spacing) * 2)}.left-\[50\%\]{left:50%}.z-10{z-index:10}.z-20{z-index:20}.z-50{z-index:50}.col-start-2{grid-column-start:2}.row-span-2{grid-row:span 2/span 2}.row-start-1{grid-row-start:1}.-mx-1{margin-inline:calc(var(--spacing) * -1)}.mx-auto{margin-inline:auto}.my-1{margin-block:var(--spacing)}.-mt-2{margin-top:calc(var(--spacing) * -2)}.mt-1{margin-top:var(--spacing)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mr-1{margin-right:var(--spacing)}.mb-1{margin-bottom:var(--spacing)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.ml-1{margin-left:var(--spacing)}.ml-1\.5{margin-left:calc(var(--spacing) * 1.5)}.ml-2{margin-left:calc(var(--spacing) * 2)}.ml-auto{margin-left:auto}.block{display:block}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.table{display:table}.table-caption{display:table-caption}.table-cell{display:table-cell}.table-row{display:table-row}.field-sizing-content{field-sizing:content}.size-2{width:calc(var(--spacing) * 2);height:calc(var(--spacing) * 2)}.size-2\.5{width:calc(var(--spacing) * 2.5);height:calc(var(--spacing) * 2.5)}.size-3{width:calc(var(--spacing) * 3);height:calc(var(--spacing) * 3)}.size-3\.5{width:calc(var(--spacing) * 3.5);height:calc(var(--spacing) * 3.5)}.size-4{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.size-5{width:calc(var(--spacing) * 5);height:calc(var(--spacing) * 5)}.size-6{width:calc(var(--spacing) * 6);height:calc(var(--spacing) * 6)}.size-8{width:calc(var(--spacing) * 8);height:calc(var(--spacing) * 8)}.size-9{width:calc(var(--spacing) * 9);height:calc(var(--spacing) * 9)}.size-10{width:calc(var(--spacing) * 10);height:calc(var(--spacing) * 10)}.h-0\.5{height:calc(var(--spacing) * .5)}.h-1\.5{height:calc(var(--spacing) * 1.5)}.h-2{height:calc(var(--spacing) * 2)}.h-6{height:calc(var(--spacing) * 6)}.h-8{height:calc(var(--spacing) * 8)}.h-9{height:calc(var(--spacing) * 9)}.h-10{height:calc(var(--spacing) * 10)}.h-16{height:calc(var(--spacing) * 16)}.h-\[1\.15rem\]{height:1.15rem}.h-\[calc\(100\%-1px\)\]{height:calc(100% - 1px)}.h-\[var\(--radix-select-trigger-height\)\]{height:var(--radix-select-trigger-height)}.h-full{height:100%}.h-px{height:1px}.max-h-\(--radix-dropdown-menu-content-available-height\){max-height:var(--radix-dropdown-menu-content-available-height)}.max-h-\(--radix-select-content-available-height\){max-height:var(--radix-select-content-available-height)}.max-h-40{max-height:calc(var(--spacing) * 40)}.max-h-48{max-height:calc(var(--spacing) * 48)}.max-h-64{max-height:calc(var(--spacing) * 64)}.max-h-96{max-height:calc(var(--spacing) * 96)}.min-h-9{min-height:calc(var(--spacing) * 9)}.min-h-16{min-height:calc(var(--spacing) * 16)}.min-h-screen{min-height:100vh}.w-3{width:calc(var(--spacing) * 3)}.w-6{width:calc(var(--spacing) * 6)}.w-8{width:calc(var(--spacing) * 8)}.w-12{width:calc(var(--spacing) * 12)}.w-16{width:calc(var(--spacing) * 16)}.w-20{width:calc(var(--spacing) * 20)}.w-24{width:calc(var(--spacing) * 24)}.w-32{width:calc(var(--spacing) * 32)}.w-36{width:calc(var(--spacing) * 36)}.w-40{width:calc(var(--spacing) * 40)}.w-44{width:calc(var(--spacing) * 44)}.w-56{width:calc(var(--spacing) * 56)}.w-64{width:calc(var(--spacing) * 64)}.w-auto{width:auto}.w-fit{width:fit-content}.w-full{width:100%}.max-w-6xl{max-width:var(--container-6xl)}.max-w-36{max-width:calc(var(--spacing) * 36)}.max-w-40{max-width:calc(var(--spacing) * 40)}.max-w-44{max-width:calc(var(--spacing) * 44)}.max-w-\[85\%\]{max-width:85%}.max-w-\[calc\(100\%-2rem\)\]{max-width:calc(100% - 2rem)}.max-w-full{max-width:100%}.max-w-sm{max-width:var(--container-sm)}.min-w-0{min-width:0}.min-w-32{min-width:calc(var(--spacing) * 32)}.min-w-36{min-width:calc(var(--spacing) * 36)}.min-w-48{min-width:calc(var(--spacing) * 48)}.min-w-\[8rem\]{min-width:8rem}.min-w-\[var\(--radix-select-trigger-width\)\]{min-width:var(--radix-select-trigger-width)}.flex-1{flex:1}.shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.caption-bottom{caption-side:bottom}.origin-\(--radix-dropdown-menu-content-transform-origin\){transform-origin:var(--radix-dropdown-menu-content-transform-origin)}.origin-\(--radix-select-content-transform-origin\){transform-origin:var(--radix-select-content-transform-origin)}.-translate-x-1\/2{--tw-translate-x: -50% ;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-x-\[-50\%\]{--tw-translate-x:-50%;translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-y-1\/2{--tw-translate-y: -50% ;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-y-\[-50\%\]{--tw-translate-y:-50%;translate:var(--tw-translate-x) var(--tw-translate-y)}.rotate-180{rotate:180deg}.animate-pulse{animation:var(--animate-pulse)}.animate-spin{animation:var(--animate-spin)}.cursor-default{cursor:default}.cursor-pointer{cursor:pointer}.scroll-my-1{scroll-margin-block:var(--spacing)}.auto-rows-min{grid-auto-rows:min-content}.\[grid-template-columns\:repeat\(auto-fit\,minmax\(260px\,1fr\)\)\]{grid-template-columns:repeat(auto-fit,minmax(260px,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-\[minmax\(6rem\,14rem\)_1fr_auto\]{grid-template-columns:minmax(6rem,14rem) 1fr auto}.grid-rows-\[auto_auto\]{grid-template-rows:auto auto}.flex-col{flex-direction:column}.flex-col-reverse{flex-direction:column-reverse}.flex-row{flex-direction:row}.flex-wrap{flex-wrap:wrap}.items-baseline{align-items:baseline}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-0\.5{gap:calc(var(--spacing) * .5)}.gap-1{gap:var(--spacing)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-6{gap:calc(var(--spacing) * 6)}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}.gap-x-4{column-gap:calc(var(--spacing) * 4)}.gap-x-6{column-gap:calc(var(--spacing) * 6)}.gap-x-8{column-gap:calc(var(--spacing) * 8)}.gap-y-1{row-gap:var(--spacing)}.gap-y-2{row-gap:calc(var(--spacing) * 2)}.gap-y-3{row-gap:calc(var(--spacing) * 3)}.gap-y-4{row-gap:calc(var(--spacing) * 4)}.self-start{align-self:flex-start}.justify-self-end{justify-self:flex-end}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.overflow-visible{overflow:visible}.overflow-x-auto{overflow-x:auto}.overflow-x-hidden{overflow-x:hidden}.overflow-y-auto{overflow-y:auto}.rounded{border-radius:.25rem}.rounded-\[4px\]{border-radius:4px}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:calc(var(--radius) + 2px)}.rounded-md{border-radius:var(--radius)}.rounded-sm{border-radius:calc(var(--radius) - 2px)}.rounded-xl{border-radius:calc(var(--radius) + 4px)}.rounded-xs{border-radius:var(--radius-xs)}.border{border-style:var(--tw-border-style);border-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-b-2{border-bottom-style:var(--tw-border-style);border-bottom-width:2px}.border-dashed{--tw-border-style:dashed;border-style:dashed}.border-border{border-color:rgb(var(--border))}.border-foreground{border-color:rgb(var(--foreground))}.border-input{border-color:rgb(var(--input))}.border-primary\/40{border-color:rgb(var(--primary))}@supports (color:color-mix(in lab,red,red)){.border-primary\/40{border-color:color-mix(in oklab,rgb(var(--primary)) 40%,transparent)}}.border-transparent{border-color:#0000}.bg-\[\#1a7f37\]{background-color:#1a7f37}.bg-\[\#3b82f6\]{background-color:#3b82f6}.bg-\[\#4b5563\]{background-color:#4b5563}.bg-\[\#9a6700\]{background-color:#9a6700}.bg-\[\#22c55e\]{background-color:#22c55e}.bg-\[\#0969da\]{background-color:#0969da}.bg-\[\#a855f7\]{background-color:#a855f7}.bg-\[\#f97316\]{background-color:#f97316}.bg-\[rgb\(var\(--border\)\)\]{background-color:rgb(var(--border))}.bg-accent{background-color:rgb(var(--accent))}.bg-background{background-color:rgb(var(--background))}.bg-black\/50{background-color:#00000080}@supports (color:color-mix(in lab,red,red)){.bg-black\/50{background-color:color-mix(in oklab,var(--color-black) 50%,transparent)}}.bg-border{background-color:rgb(var(--border))}.bg-card{background-color:rgb(var(--card))}.bg-chart-1{background-color:rgb(var(--chart-1))}.bg-chart-2{background-color:rgb(var(--chart-2))}.bg-chart-3{background-color:rgb(var(--chart-3))}.bg-chart-5{background-color:rgb(var(--chart-5))}.bg-destructive{background-color:rgb(var(--destructive))}.bg-foreground\/5{background-color:rgb(var(--foreground))}@supports (color:color-mix(in lab,red,red)){.bg-foreground\/5{background-color:color-mix(in oklab,rgb(var(--foreground)) 5%,transparent)}}.bg-green-500{background-color:var(--color-green-500)}.bg-muted{background-color:rgb(var(--muted))}.bg-muted-foreground{background-color:rgb(var(--muted-foreground))}.bg-muted\/30{background-color:rgb(var(--muted))}@supports (color:color-mix(in lab,red,red)){.bg-muted\/30{background-color:color-mix(in oklab,rgb(var(--muted)) 30%,transparent)}}.bg-muted\/50{background-color:rgb(var(--muted))}@supports (color:color-mix(in lab,red,red)){.bg-muted\/50{background-color:color-mix(in oklab,rgb(var(--muted)) 50%,transparent)}}.bg-popover{background-color:rgb(var(--popover))}.bg-primary,.bg-primary\/5{background-color:rgb(var(--primary))}@supports (color:color-mix(in lab,red,red)){.bg-primary\/5{background-color:color-mix(in oklab,rgb(var(--primary)) 5%,transparent)}}.bg-red-500{background-color:var(--color-red-500)}.bg-secondary{background-color:rgb(var(--secondary))}.bg-transparent{background-color:#0000}.bg-yellow-500{background-color:var(--color-yellow-500)}.fill-border{fill:rgb(var(--border))}.fill-chart-1{fill:rgb(var(--chart-1))}.fill-chart-2{fill:rgb(var(--chart-2))}.fill-chart-3{fill:rgb(var(--chart-3))}.fill-chart-5{fill:rgb(var(--chart-5))}.fill-current{fill:currentColor}.fill-foreground,.fill-foreground\/5{fill:rgb(var(--foreground))}@supports (color:color-mix(in lab,red,red)){.fill-foreground\/5{fill:color-mix(in oklab,rgb(var(--foreground)) 5%,transparent)}}.fill-muted-foreground{fill:rgb(var(--muted-foreground))}.stroke-border{stroke:rgb(var(--border))}.object-cover{object-fit:cover}.p-1{padding:var(--spacing)}.p-2{padding:calc(var(--spacing) * 2)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-6{padding:calc(var(--spacing) * 6)}.p-\[3px\]{padding:3px}.px-0\.5{padding-inline:calc(var(--spacing) * .5)}.px-1{padding-inline:var(--spacing)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-6{padding-inline:calc(var(--spacing) * 6)}.py-0{padding-block:0}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:var(--spacing)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-8{padding-block:calc(var(--spacing) * 8)}.py-12{padding-block:calc(var(--spacing) * 12)}.pt-4{padding-top:calc(var(--spacing) * 4)}.pr-0{padding-right:0}.pr-1{padding-right:var(--spacing)}.pr-2{padding-right:calc(var(--spacing) * 2)}.pr-6{padding-right:calc(var(--spacing) * 6)}.pr-8{padding-right:calc(var(--spacing) * 8)}.pr-14{padding-right:calc(var(--spacing) * 14)}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.pb-3{padding-bottom:calc(var(--spacing) * 3)}.pl-2{padding-left:calc(var(--spacing) * 2)}.pl-8{padding-left:calc(var(--spacing) * 8)}.pl-\[18px\]{padding-left:18px}.pl-\[26px\]{padding-left:26px}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.align-middle{vertical-align:middle}.align-top{vertical-align:top}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace}.font-sans{font-family:Aspekta,ui-sans-serif,system-ui,sans-serif}.font-serif{font-family:"Source Serif 4",Source Serif Pro,Georgia,serif}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-sm\/relaxed{font-size:var(--text-sm);line-height:var(--leading-relaxed)}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[10px\]{font-size:10px}.leading-none{--tw-leading:1;line-height:1}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-widest{--tw-tracking:var(--tracking-widest);letter-spacing:var(--tracking-widest)}.text-balance{text-wrap:balance}.wrap-anywhere{overflow-wrap:anywhere}.break-all{word-break:break-all}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.text-accent-foreground{color:rgb(var(--accent-foreground))}.text-card-foreground{color:rgb(var(--card-foreground))}.text-current{color:currentColor}.text-destructive{color:rgb(var(--destructive))}.text-foreground{color:rgb(var(--foreground))}.text-muted-foreground{color:rgb(var(--muted-foreground))}.text-popover-foreground{color:rgb(var(--popover-foreground))}.text-primary{color:rgb(var(--primary))}.text-primary-foreground{color:rgb(var(--primary-foreground))}.text-secondary-foreground{color:rgb(var(--secondary-foreground))}.text-white{color:var(--color-white)}.text-yellow-600{color:var(--color-yellow-600)}.lowercase{text-transform:lowercase}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.underline-offset-2{text-underline-offset:2px}.underline-offset-4{text-underline-offset:4px}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a), 0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-xs{--tw-shadow:0 1px 2px 0 var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring-0{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.outline-hidden{--tw-outline-style:none;outline-style:none}@media(forced-colors:active){.outline-hidden{outline-offset:2px;outline:2px solid #0000}}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.transition-\[color\,box-shadow\]{transition-property:color,box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-shadow{transition-property:box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-none{transition-property:none}.duration-200{--tw-duration:.2s;transition-duration:.2s}.outline-none{--tw-outline-style:none;outline-style:none}.select-none{-webkit-user-select:none;user-select:none}.running{animation-play-state:running}.group-data-\[disabled\=true\]\:pointer-events-none:is(:where(.group)[data-disabled=true] *){pointer-events:none}.group-data-\[disabled\=true\]\:opacity-50:is(:where(.group)[data-disabled=true] *){opacity:.5}.peer-disabled\:cursor-not-allowed:is(:where(.peer):disabled~*){cursor:not-allowed}.peer-disabled\:opacity-50:is(:where(.peer):disabled~*){opacity:.5}.selection\:bg-primary ::selection{background-color:rgb(var(--primary))}.selection\:bg-primary::selection{background-color:rgb(var(--primary))}.selection\:text-primary-foreground ::selection{color:rgb(var(--primary-foreground))}.selection\:text-primary-foreground::selection{color:rgb(var(--primary-foreground))}.file\:inline-flex::file-selector-button{display:inline-flex}.file\:h-7::file-selector-button{height:calc(var(--spacing) * 7)}.file\:border-0::file-selector-button{border-style:var(--tw-border-style);border-width:0}.file\:bg-transparent::file-selector-button{background-color:#0000}.file\:text-sm::file-selector-button{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.file\:font-medium::file-selector-button{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.file\:text-foreground::file-selector-button{color:rgb(var(--foreground))}.placeholder\:text-muted-foreground::placeholder{color:rgb(var(--muted-foreground))}@media(hover:hover){.hover\:bg-accent:hover{background-color:rgb(var(--accent))}.hover\:bg-destructive\/90:hover{background-color:rgb(var(--destructive))}@supports (color:color-mix(in lab,red,red)){.hover\:bg-destructive\/90:hover{background-color:color-mix(in oklab,rgb(var(--destructive)) 90%,transparent)}}.hover\:bg-muted\/30:hover{background-color:rgb(var(--muted))}@supports (color:color-mix(in lab,red,red)){.hover\:bg-muted\/30:hover{background-color:color-mix(in oklab,rgb(var(--muted)) 30%,transparent)}}.hover\:bg-muted\/50:hover{background-color:rgb(var(--muted))}@supports (color:color-mix(in lab,red,red)){.hover\:bg-muted\/50:hover{background-color:color-mix(in oklab,rgb(var(--muted)) 50%,transparent)}}.hover\:bg-primary\/90:hover{background-color:rgb(var(--primary))}@supports (color:color-mix(in lab,red,red)){.hover\:bg-primary\/90:hover{background-color:color-mix(in oklab,rgb(var(--primary)) 90%,transparent)}}.hover\:bg-secondary\/80:hover{background-color:rgb(var(--secondary))}@supports (color:color-mix(in lab,red,red)){.hover\:bg-secondary\/80:hover{background-color:color-mix(in oklab,rgb(var(--secondary)) 80%,transparent)}}.hover\:text-accent-foreground:hover{color:rgb(var(--accent-foreground))}.hover\:text-foreground:hover{color:rgb(var(--foreground))}.hover\:underline:hover{text-decoration-line:underline}}.focus\:bg-accent:focus{background-color:rgb(var(--accent))}.focus\:text-accent-foreground:focus{color:rgb(var(--accent-foreground))}.focus-visible\:border-ring:focus-visible{border-color:rgb(var(--ring))}.focus-visible\:stroke-ring:focus-visible{stroke:rgb(var(--ring))}.focus-visible\:\[stroke-width\:2\]:focus-visible{stroke-width:2px}.focus-visible\:ring-2:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-visible\:ring-\[3px\]:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-visible\:ring-destructive\/20:focus-visible{--tw-ring-color:rgb(var(--destructive))}@supports (color:color-mix(in lab,red,red)){.focus-visible\:ring-destructive\/20:focus-visible{--tw-ring-color:color-mix(in oklab, rgb(var(--destructive)) 20%, transparent)}}.focus-visible\:ring-ring:focus-visible,.focus-visible\:ring-ring\/50:focus-visible{--tw-ring-color:rgb(var(--ring))}@supports (color:color-mix(in lab,red,red)){.focus-visible\:ring-ring\/50:focus-visible{--tw-ring-color:color-mix(in oklab, rgb(var(--ring)) 50%, transparent)}}.focus-visible\:outline-1:focus-visible{outline-style:var(--tw-outline-style);outline-width:1px}.focus-visible\:outline-ring:focus-visible{outline-color:rgb(var(--ring))}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-50:disabled{opacity:.5}.has-data-\[slot\=card-action\]\:grid-cols-\[1fr_auto\]:has([data-slot=card-action]){grid-template-columns:1fr auto}.has-\[\>svg\]\:px-2\.5:has(>svg){padding-inline:calc(var(--spacing) * 2.5)}.has-\[\>svg\]\:px-3:has(>svg){padding-inline:calc(var(--spacing) * 3)}.has-\[\>svg\]\:px-4:has(>svg){padding-inline:calc(var(--spacing) * 4)}.aria-invalid\:border-destructive[aria-invalid=true]{border-color:rgb(var(--destructive))}.aria-invalid\:ring-destructive\/20[aria-invalid=true]{--tw-ring-color:rgb(var(--destructive))}@supports (color:color-mix(in lab,red,red)){.aria-invalid\:ring-destructive\/20[aria-invalid=true]{--tw-ring-color:color-mix(in oklab, rgb(var(--destructive)) 20%, transparent)}}.data-\[disabled\]\:pointer-events-none[data-disabled]{pointer-events:none}.data-\[disabled\]\:opacity-50[data-disabled]{opacity:.5}.data-\[inset\]\:pl-8[data-inset]{padding-left:calc(var(--spacing) * 8)}.data-\[orientation\=horizontal\]\:h-px[data-orientation=horizontal]{height:1px}.data-\[orientation\=horizontal\]\:w-full[data-orientation=horizontal]{width:100%}.data-\[orientation\=vertical\]\:h-full[data-orientation=vertical]{height:100%}.data-\[orientation\=vertical\]\:w-px[data-orientation=vertical]{width:1px}.data-\[placeholder\]\:text-muted-foreground[data-placeholder]{color:rgb(var(--muted-foreground))}.data-\[side\=bottom\]\:translate-y-1[data-side=bottom]{--tw-translate-y:var(--spacing);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[side\=bottom\]\:slide-in-from-top-2[data-side=bottom]{--tw-enter-translate-y:calc(2*var(--spacing)*-1)}.data-\[side\=left\]\:-translate-x-1[data-side=left]{--tw-translate-x:calc(var(--spacing) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[side\=left\]\:slide-in-from-right-2[data-side=left]{--tw-enter-translate-x:calc(2*var(--spacing))}.data-\[side\=right\]\:translate-x-1[data-side=right]{--tw-translate-x:var(--spacing);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[side\=right\]\:slide-in-from-left-2[data-side=right]{--tw-enter-translate-x:calc(2*var(--spacing)*-1)}.data-\[side\=top\]\:-translate-y-1[data-side=top]{--tw-translate-y:calc(var(--spacing) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[side\=top\]\:slide-in-from-bottom-2[data-side=top]{--tw-enter-translate-y:calc(2*var(--spacing))}.data-\[size\=default\]\:h-9[data-size=default]{height:calc(var(--spacing) * 9)}.data-\[size\=sm\]\:h-8[data-size=sm]{height:calc(var(--spacing) * 8)}:is(.\*\:data-\[slot\=select-value\]\:line-clamp-1>*)[data-slot=select-value]{-webkit-line-clamp:1;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}:is(.\*\:data-\[slot\=select-value\]\:flex>*)[data-slot=select-value]{display:flex}:is(.\*\:data-\[slot\=select-value\]\:items-center>*)[data-slot=select-value]{align-items:center}:is(.\*\:data-\[slot\=select-value\]\:gap-2>*)[data-slot=select-value]{gap:calc(var(--spacing) * 2)}.data-\[state\=active\]\:bg-card[data-state=active]{background-color:rgb(var(--card))}.data-\[state\=active\]\:shadow-sm[data-state=active]{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.data-\[state\=checked\]\:translate-x-\[calc\(100\%-2px\)\][data-state=checked]{--tw-translate-x: calc(100% - 2px) ;translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[state\=checked\]\:border-primary[data-state=checked]{border-color:rgb(var(--primary))}.data-\[state\=checked\]\:bg-primary[data-state=checked]{background-color:rgb(var(--primary))}.data-\[state\=checked\]\:text-primary-foreground[data-state=checked]{color:rgb(var(--primary-foreground))}.data-\[state\=closed\]\:animate-out[data-state=closed]{animation:exit var(--tw-animation-duration,var(--tw-duration,.15s))var(--tw-ease,ease)var(--tw-animation-delay,0s)var(--tw-animation-iteration-count,1)var(--tw-animation-direction,normal)var(--tw-animation-fill-mode,forwards)}.data-\[state\=closed\]\:fade-out-0[data-state=closed]{--tw-exit-opacity:0}.data-\[state\=closed\]\:zoom-out-95[data-state=closed]{--tw-exit-scale:.95}.data-\[state\=inactive\]\:hidden[data-state=inactive]{display:none}.data-\[state\=open\]\:animate-in[data-state=open]{animation:enter var(--tw-animation-duration,var(--tw-duration,.15s))var(--tw-ease,ease)var(--tw-animation-delay,0s)var(--tw-animation-iteration-count,1)var(--tw-animation-direction,normal)var(--tw-animation-fill-mode,forwards)}.data-\[state\=open\]\:bg-accent[data-state=open]{background-color:rgb(var(--accent))}.data-\[state\=open\]\:text-accent-foreground[data-state=open]{color:rgb(var(--accent-foreground))}.data-\[state\=open\]\:fade-in-0[data-state=open]{--tw-enter-opacity:0}.data-\[state\=open\]\:zoom-in-95[data-state=open]{--tw-enter-scale:.95}.data-\[state\=selected\]\:bg-muted[data-state=selected]{background-color:rgb(var(--muted))}.data-\[state\=unchecked\]\:translate-x-0[data-state=unchecked]{--tw-translate-x:0px;translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[state\=unchecked\]\:bg-\[\#7F7F79\][data-state=unchecked]{background-color:#7f7f79}.data-\[variant\=destructive\]\:text-destructive[data-variant=destructive]{color:rgb(var(--destructive))}.data-\[variant\=destructive\]\:focus\:bg-destructive\/10[data-variant=destructive]:focus{background-color:rgb(var(--destructive))}@supports (color:color-mix(in lab,red,red)){.data-\[variant\=destructive\]\:focus\:bg-destructive\/10[data-variant=destructive]:focus{background-color:color-mix(in oklab,rgb(var(--destructive)) 10%,transparent)}}.data-\[variant\=destructive\]\:focus\:text-destructive[data-variant=destructive]:focus{color:rgb(var(--destructive))}@media(min-width:40rem){.sm\:col-span-2{grid-column:span 2/span 2}.sm\:max-w-lg{max-width:var(--container-lg)}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:justify-end{justify-content:flex-end}.sm\:text-left{text-align:left}}@media(min-width:48rem){.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:p-12{padding:calc(var(--spacing) * 12)}.md\:text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}}@media(min-width:64rem){.lg\:col-span-2{grid-column:span 2/span 2}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}.dark\:border-input:is(.dark *){border-color:rgb(var(--input))}.dark\:bg-chart-4:is(.dark *){background-color:rgb(var(--chart-4))}.dark\:bg-destructive\/60:is(.dark *){background-color:rgb(var(--destructive))}@supports (color:color-mix(in lab,red,red)){.dark\:bg-destructive\/60:is(.dark *){background-color:color-mix(in oklab,rgb(var(--destructive)) 60%,transparent)}}.dark\:bg-input\/30:is(.dark *){background-color:rgb(var(--input))}@supports (color:color-mix(in lab,red,red)){.dark\:bg-input\/30:is(.dark *){background-color:color-mix(in oklab,rgb(var(--input)) 30%,transparent)}}.dark\:fill-chart-4:is(.dark *){fill:rgb(var(--chart-4))}.dark\:text-muted-foreground:is(.dark *){color:rgb(var(--muted-foreground))}.dark\:text-yellow-400:is(.dark *){color:var(--color-yellow-400)}@media(hover:hover){.dark\:hover\:bg-accent\/50:is(.dark *):hover{background-color:rgb(var(--accent))}@supports (color:color-mix(in lab,red,red)){.dark\:hover\:bg-accent\/50:is(.dark *):hover{background-color:color-mix(in oklab,rgb(var(--accent)) 50%,transparent)}}.dark\:hover\:bg-input\/50:is(.dark *):hover{background-color:rgb(var(--input))}@supports (color:color-mix(in lab,red,red)){.dark\:hover\:bg-input\/50:is(.dark *):hover{background-color:color-mix(in oklab,rgb(var(--input)) 50%,transparent)}}}.dark\:focus-visible\:ring-destructive\/40:is(.dark *):focus-visible{--tw-ring-color:rgb(var(--destructive))}@supports (color:color-mix(in lab,red,red)){.dark\:focus-visible\:ring-destructive\/40:is(.dark *):focus-visible{--tw-ring-color:color-mix(in oklab, rgb(var(--destructive)) 40%, transparent)}}.dark\:aria-invalid\:ring-destructive\/40:is(.dark *)[aria-invalid=true]{--tw-ring-color:rgb(var(--destructive))}@supports (color:color-mix(in lab,red,red)){.dark\:aria-invalid\:ring-destructive\/40:is(.dark *)[aria-invalid=true]{--tw-ring-color:color-mix(in oklab, rgb(var(--destructive)) 40%, transparent)}}.dark\:data-\[state\=active\]\:border-input:is(.dark *)[data-state=active]{border-color:rgb(var(--input))}.dark\:data-\[state\=active\]\:bg-input\/30:is(.dark *)[data-state=active]{background-color:rgb(var(--input))}@supports (color:color-mix(in lab,red,red)){.dark\:data-\[state\=active\]\:bg-input\/30:is(.dark *)[data-state=active]{background-color:color-mix(in oklab,rgb(var(--input)) 30%,transparent)}}.dark\:data-\[state\=active\]\:text-foreground:is(.dark *)[data-state=active]{color:rgb(var(--foreground))}.dark\:data-\[state\=checked\]\:bg-primary:is(.dark *)[data-state=checked]{background-color:rgb(var(--primary))}.dark\:data-\[state\=checked\]\:bg-primary-foreground:is(.dark *)[data-state=checked]{background-color:rgb(var(--primary-foreground))}.dark\:data-\[state\=unchecked\]\:bg-foreground:is(.dark *)[data-state=unchecked]{background-color:rgb(var(--foreground))}.dark\:data-\[state\=unchecked\]\:bg-input:is(.dark *)[data-state=unchecked]{background-color:rgb(var(--input))}.dark\:data-\[variant\=destructive\]\:focus\:bg-destructive\/20:is(.dark *)[data-variant=destructive]:focus{background-color:rgb(var(--destructive))}@supports (color:color-mix(in lab,red,red)){.dark\:data-\[variant\=destructive\]\:focus\:bg-destructive\/20:is(.dark *)[data-variant=destructive]:focus{background-color:color-mix(in oklab,rgb(var(--destructive)) 20%,transparent)}}.\[\&_svg\]\:pointer-events-none svg{pointer-events:none}.\[\&_svg\]\:shrink-0 svg{flex-shrink:0}.\[\&_svg\:not\(\[class\*\=\'size-\'\]\)\]\:size-4 svg:not([class*=size-]){width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.\[\&_svg\:not\(\[class\*\=\'size-\'\]\)\]\:size-6 svg:not([class*=size-]){width:calc(var(--spacing) * 6);height:calc(var(--spacing) * 6)}.\[\&_svg\:not\(\[class\*\=\'text-\'\]\)\]\:text-muted-foreground svg:not([class*=text-]){color:rgb(var(--muted-foreground))}.\[\&_tr\]\:border-b tr{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.\[\&_tr\:last-child\]\:border-0 tr:last-child{border-style:var(--tw-border-style);border-width:0}.\[\&\:has\(\[role\=checkbox\]\)\]\:pr-0:has([role=checkbox]){padding-right:0}.\[\.border-b\]\:pb-6.border-b{padding-bottom:calc(var(--spacing) * 6)}.\[\.border-t\]\:pt-6.border-t{padding-top:calc(var(--spacing) * 6)}:is(.\*\:\[span\]\:last\:flex>*):is(span):last-child{display:flex}:is(.\*\:\[span\]\:last\:items-center>*):is(span):last-child{align-items:center}:is(.\*\:\[span\]\:last\:gap-2>*):is(span):last-child{gap:calc(var(--spacing) * 2)}:is(.data-\[variant\=destructive\]\:\*\:\[svg\]\:\!text-destructive[data-variant=destructive]>*):is(svg){color:rgb(var(--destructive))!important}.\[\&\>\[role\=checkbox\]\]\:translate-y-\[2px\]>[role=checkbox]{--tw-translate-y:2px;translate:var(--tw-translate-x) var(--tw-translate-y)}.\[\&\>a\]\:underline>a{text-decoration-line:underline}.\[\&\>a\]\:underline-offset-4>a{text-underline-offset:4px}.\[\&\>a\:hover\]\:text-primary>a:hover{color:rgb(var(--primary))}.\[\&\>svg\]\:pointer-events-none>svg{pointer-events:none}.\[\&\>svg\]\:size-3>svg{width:calc(var(--spacing) * 3);height:calc(var(--spacing) * 3)}.\[\&\>svg\]\:size-3\.5>svg{width:calc(var(--spacing) * 3.5);height:calc(var(--spacing) * 3.5)}.\[\&\>tr\]\:last\:border-b-0>tr:last-child{border-bottom-style:var(--tw-border-style);border-bottom-width:0}@media(hover:hover){a.\[a\&\]\:hover\:bg-\[\#1a7f37\]\/90:hover{background-color:#1a7f37e6}a.\[a\&\]\:hover\:bg-\[\#3b82f6\]\/90:hover{background-color:#3b82f6e6}a.\[a\&\]\:hover\:bg-\[\#4b5563\]\/90:hover{background-color:#4b5563e6}a.\[a\&\]\:hover\:bg-\[\#9a6700\]\/90:hover{background-color:#9a6700e6}a.\[a\&\]\:hover\:bg-\[\#22c55e\]\/90:hover{background-color:#22c55ee6}a.\[a\&\]\:hover\:bg-\[\#0969da\]\/90:hover{background-color:#0969dae6}a.\[a\&\]\:hover\:bg-\[\#a855f7\]\/90:hover{background-color:#a855f7e6}a.\[a\&\]\:hover\:bg-\[\#f97316\]\/90:hover{background-color:#f97316e6}a.\[a\&\]\:hover\:bg-accent\/10:hover{background-color:rgb(var(--accent))}@supports (color:color-mix(in lab,red,red)){a.\[a\&\]\:hover\:bg-accent\/10:hover{background-color:color-mix(in oklab,rgb(var(--accent)) 10%,transparent)}}a.\[a\&\]\:hover\:bg-destructive\/90:hover{background-color:rgb(var(--destructive))}@supports (color:color-mix(in lab,red,red)){a.\[a\&\]\:hover\:bg-destructive\/90:hover{background-color:color-mix(in oklab,rgb(var(--destructive)) 90%,transparent)}}a.\[a\&\]\:hover\:bg-muted:hover,a.\[a\&\]\:hover\:bg-muted\/80:hover{background-color:rgb(var(--muted))}@supports (color:color-mix(in lab,red,red)){a.\[a\&\]\:hover\:bg-muted\/80:hover{background-color:color-mix(in oklab,rgb(var(--muted)) 80%,transparent)}}a.\[a\&\]\:hover\:bg-primary\/90:hover{background-color:rgb(var(--primary))}@supports (color:color-mix(in lab,red,red)){a.\[a\&\]\:hover\:bg-primary\/90:hover{background-color:color-mix(in oklab,rgb(var(--primary)) 90%,transparent)}}a.\[a\&\]\:hover\:bg-secondary\/90:hover{background-color:rgb(var(--secondary))}@supports (color:color-mix(in lab,red,red)){a.\[a\&\]\:hover\:bg-secondary\/90:hover{background-color:color-mix(in oklab,rgb(var(--secondary)) 90%,transparent)}}a.\[a\&\]\:hover\:text-accent-foreground:hover{color:rgb(var(--accent-foreground))}}}@property --tw-animation-delay{syntax:"*";inherits:false;initial-value:0s}@property --tw-animation-direction{syntax:"*";inherits:false;initial-value:normal}@property --tw-animation-duration{syntax:"*";inherits:false}@property --tw-animation-fill-mode{syntax:"*";inherits:false;initial-value:forwards}@property --tw-animation-iteration-count{syntax:"*";inherits:false;initial-value:1}@property --tw-enter-opacity{syntax:"*";inherits:false;initial-value:1}@property --tw-enter-rotate{syntax:"*";inherits:false;initial-value:0}@property --tw-enter-scale{syntax:"*";inherits:false;initial-value:1}@property --tw-enter-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-enter-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-opacity{syntax:"*";inherits:false;initial-value:1}@property --tw-exit-rotate{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-scale{syntax:"*";inherits:false;initial-value:1}@property --tw-exit-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-translate-y{syntax:"*";inherits:false;initial-value:0}@font-face{font-family:Aspekta;font-style:normal;font-weight:400;font-display:swap;src:url(/fonts/Aspekta-400.woff2)format("woff2")}@font-face{font-family:Aspekta;font-style:normal;font-weight:500;font-display:swap;src:url(/fonts/Aspekta-500.woff2)format("woff2")}@font-face{font-family:Aspekta;font-style:normal;font-weight:600;font-display:swap;src:url(/fonts/Aspekta-600.woff2)format("woff2")}@font-face{font-family:Aspekta;font-style:normal;font-weight:700;font-display:swap;src:url(/fonts/Aspekta-700.woff2)format("woff2")}:root{--background:240 240 236;--foreground:22 22 20;--card:249 249 247;--card-foreground:22 22 20;--popover:249 249 247;--popover-foreground:22 22 20;--primary:217 94 42;--primary-foreground:249 249 247;--secondary:240 240 236;--secondary-foreground:47 47 44;--muted:240 240 236;--muted-foreground:87 87 83;--accent:221 221 215;--accent-foreground:22 22 20;--destructive:180 50 50;--destructive-foreground:249 249 247;--border:196 196 189;--input:196 196 189;--ring:217 94 42;--chart-1:217 94 42;--chart-2:87 87 83;--chart-3:127 127 121;--chart-4:166 166 159;--chart-5:47 47 44;--radius:.375rem}.dark{--background:22 22 20;--foreground:249 249 247;--card:47 47 44;--card-foreground:249 249 247;--popover:47 47 44;--popover-foreground:249 249 247;--primary:217 94 42;--primary-foreground:249 249 247;--secondary:87 87 83;--secondary-foreground:249 249 247;--muted:87 87 83;--muted-foreground:196 196 189;--accent:87 87 83;--accent-foreground:249 249 247;--destructive:180 50 50;--destructive-foreground:249 249 247;--border:87 87 83;--input:87 87 83;--ring:217 94 42;--chart-1:217 94 42;--chart-2:196 196 189;--chart-3:166 166 159;--chart-4:127 127 121;--chart-5:240 240 236}.chat-markdown{overflow-wrap:break-word;line-height:1.55}.chat-markdown>:first-child{margin-top:0}.chat-markdown>:last-child{margin-bottom:0}.chat-markdown p{margin:.5em 0}.chat-markdown h1,.chat-markdown h2,.chat-markdown h3,.chat-markdown h4,.chat-markdown h5,.chat-markdown h6{margin:1em 0 .4em;font-size:1em;font-weight:600;line-height:1.3}.chat-markdown h1{font-size:1.15em}.chat-markdown h2{font-size:1.08em}.chat-markdown ul,.chat-markdown ol{margin:.5em 0;padding-left:1.4em}.chat-markdown ul{list-style:outside}.chat-markdown ol{list-style:decimal}.chat-markdown li,.chat-markdown li>ul,.chat-markdown li>ol{margin:.2em 0}.chat-markdown li::marker{color:rgb(var(--muted-foreground))}.chat-markdown code{font-family:var(--font-mono);background:rgb(var(--muted));border:1px solid rgb(var(--border) / .6);border-radius:4px;padding:.1em .35em;font-size:.9em}.chat-markdown pre{background:rgb(var(--muted));border:1px solid rgb(var(--border) / .6);border-radius:var(--radius);margin:.6em 0;padding:.6em .75em;overflow-x:auto}.chat-markdown pre code{background:0 0;border:none;padding:0;font-size:.85em;line-height:1.5}.chat-markdown blockquote{border-left:3px solid rgb(var(--border));color:rgb(var(--muted-foreground));margin:.6em 0;padding:.1em 0 .1em .9em}.chat-markdown a{color:rgb(var(--primary));text-underline-offset:2px;text-decoration:underline}.chat-markdown table{border-collapse:collapse;margin:.6em 0;font-size:.95em;display:block;overflow-x:auto}.chat-markdown th,.chat-markdown td{border:1px solid rgb(var(--border));text-align:left;padding:4px 10px}.chat-markdown th{background:rgb(var(--muted));font-weight:600}.chat-markdown hr{border:none;border-top:1px solid rgb(var(--border));margin:1em 0}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@keyframes spin{to{transform:rotate(360deg)}}@keyframes pulse{50%{opacity:.5}}@keyframes enter{0%{opacity:var(--tw-enter-opacity,1);transform:translate3d(var(--tw-enter-translate-x,0),var(--tw-enter-translate-y,0),0)scale3d(var(--tw-enter-scale,1),var(--tw-enter-scale,1),var(--tw-enter-scale,1))rotate(var(--tw-enter-rotate,0))}}@keyframes exit{to{opacity:var(--tw-exit-opacity,1);transform:translate3d(var(--tw-exit-translate-x,0),var(--tw-exit-translate-y,0),0)scale3d(var(--tw-exit-scale,1),var(--tw-exit-scale,1),var(--tw-exit-scale,1))rotate(var(--tw-exit-rotate,0))}} diff --git a/internal/store/providers.go b/internal/store/providers.go index 5d3ab04..ae7b627 100644 --- a/internal/store/providers.go +++ b/internal/store/providers.go @@ -184,7 +184,7 @@ func scanBinding(row interface{ Scan(...any) error }) (*ModelBinding, error) { } b.Hidden = hidden != 0 if b.TargetID.Valid { - b.Targets = []BindingTarget{{ID: b.TargetID.String, Alias: b.TargetAlias, Weight: 1}} + b.Targets = []BindingTarget{{ID: b.TargetID.String, Alias: b.TargetAlias, Provider: b.ProviderName, Weight: 1}} } return &b, nil } @@ -214,8 +214,9 @@ func (s *Store) attachTargets(ctx context.Context, bindings []*ModelBinding) err return nil } rows, err := s.db.QueryContext(ctx, s.q(` - SELECT bt.binding_id, bt.target_id, t.alias, bt.weight, t.capability_set + SELECT bt.binding_id, bt.target_id, t.alias, p.name, bt.weight, t.capability_set FROM model_binding_target bt JOIN model_binding t ON t.id = bt.target_id + JOIN provider p ON p.id = t.provider_id WHERE bt.binding_id IN (`+placeholders(len(ids))+`) ORDER BY bt.binding_id, bt.position`), ids...) if err != nil { @@ -226,7 +227,7 @@ func (s *Store) attachTargets(ctx context.Context, bindings []*ModelBinding) err for rows.Next() { var bindingID, capabilitySet string var t BindingTarget - if err := rows.Scan(&bindingID, &t.ID, &t.Alias, &t.Weight, &capabilitySet); err != nil { + if err := rows.Scan(&bindingID, &t.ID, &t.Alias, &t.Provider, &t.Weight, &capabilitySet); err != nil { return err } b := multi[bindingID] diff --git a/internal/store/targets_test.go b/internal/store/targets_test.go index ea387db..15d6168 100644 --- a/internal/store/targets_test.go +++ b/internal/store/targets_test.go @@ -61,7 +61,8 @@ func TestAliasTargetsRoundTrip(t *testing.T) { if got.Strategy != "weighted" || len(got.Targets) != 2 { t.Fatalf("got %+v", got) } - if got.Targets[0].Alias != "b" || got.Targets[0].Weight != 3 || got.Targets[1].Alias != "a" { + if got.Targets[0].Alias != "b" || got.Targets[0].Weight != 3 || got.Targets[1].Alias != "a" || + got.Targets[0].Provider != "pb" || got.Targets[1].Provider != "pa" { t.Fatalf("targets = %+v", got.Targets) } if got.TargetID.Valid { diff --git a/internal/store/types.go b/internal/store/types.go index 7eaf847..4eb10be 100644 --- a/internal/store/types.go +++ b/internal/store/types.go @@ -105,9 +105,10 @@ type ModelBinding struct { } type BindingTarget struct { - ID string - Alias string - Weight int + ID string + Alias string + Provider string + Weight int } // ResolvedTarget is one servable target of an alias, or the binding itself diff --git a/ui/src/lib/api.ts b/ui/src/lib/api.ts index 67af1ba..294fd77 100644 --- a/ui/src/lib/api.ts +++ b/ui/src/lib/api.ts @@ -166,6 +166,7 @@ export interface RateLimitSnapshot { export interface AliasTarget { alias: string + provider?: string weight: number } diff --git a/ui/src/pages/Models.tsx b/ui/src/pages/Models.tsx index 2b485fe..cfbd9d2 100644 --- a/ui/src/pages/Models.tsx +++ b/ui/src/pages/Models.tsx @@ -239,9 +239,17 @@ function TargetsField({ } return (
- +
+ + {several && strategy === 'weighted' && ( + <> + Weight + + + )} +
{targets.map((t, i) => (
@@ -830,7 +837,17 @@ export function Models() { {m.hidden && hidden} - {m.provider} + + {m.targets.length > 1 ? ( + + {[...new Set(m.targets.map((t) => t.provider))].map((p) => ( + {p} + ))} + + ) : ( + m.provider + )} + {m.targets.length > 1 ? ( From c697e837ace46e0bd4059330c9669794ce6063e1 Mon Sep 17 00:00:00 2001 From: Mathieu Virbel Date: Thu, 8 Oct 2026 11:11:05 -0600 Subject: [PATCH 11/12] Show weighted targets by share, most used first --- .../server/uidist/assets/index-COK_nABL.css | 1 - .../server/uidist/assets/index-CuOhbZNq.css | 1 + ui/src/lib/strategies.ts | 7 ++++- ui/src/pages/Models.tsx | 26 +++++++++++++++---- 4 files changed, 28 insertions(+), 7 deletions(-) delete mode 100644 internal/server/uidist/assets/index-COK_nABL.css create mode 100644 internal/server/uidist/assets/index-CuOhbZNq.css diff --git a/internal/server/uidist/assets/index-COK_nABL.css b/internal/server/uidist/assets/index-COK_nABL.css deleted file mode 100644 index 3b0c6a4..0000000 --- a/internal/server/uidist/assets/index-COK_nABL.css +++ /dev/null @@ -1 +0,0 @@ -/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-space-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-duration:initial;--tw-animation-delay:0s;--tw-animation-direction:normal;--tw-animation-duration:initial;--tw-animation-fill-mode:forwards;--tw-animation-iteration-count:1;--tw-enter-opacity:1;--tw-enter-rotate:0;--tw-enter-scale:1;--tw-enter-translate-x:0;--tw-enter-translate-y:0;--tw-exit-opacity:1;--tw-exit-rotate:0;--tw-exit-scale:1;--tw-exit-translate-x:0;--tw-exit-translate-y:0}}}@layer theme{:root,:host{--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;--color-red-500:oklch(63.7% .237 25.331);--color-yellow-400:oklch(85.2% .199 91.936);--color-yellow-500:oklch(79.5% .184 86.047);--color-yellow-600:oklch(68.1% .162 75.834);--color-green-500:oklch(72.3% .219 149.579);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-sm:24rem;--container-lg:32rem;--container-6xl:72rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height: 1.5 ;--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--tracking-tight:-.025em;--tracking-widest:.1em;--leading-relaxed:1.625;--radius-xs:.125rem;--animate-spin:spin 1s linear infinite;--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:"Aspekta", ui-sans-serif, system-ui, sans-serif;--default-mono-font-family:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;--color-popover:rgb(var(--popover));--color-popover-foreground:rgb(var(--popover-foreground));--color-border:rgb(var(--border))}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;-moz-tab-size:4;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab,red,red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){-webkit-appearance:button;-moz-appearance:button;appearance:button}::file-selector-button{-webkit-appearance:button;-moz-appearance:button;appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}*{border-color:rgb(var(--border));outline-color:rgb(var(--ring))}@supports (color:color-mix(in lab,red,red)){*{outline-color:color-mix(in oklab,rgb(var(--ring)) 50%,transparent)}}body{background-color:rgb(var(--background));color:rgb(var(--foreground));-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;font-family:Aspekta,ui-sans-serif,system-ui,sans-serif}}@layer components;@layer utilities{.\@container\/card-header{container:card-header/inline-size}.pointer-events-none{pointer-events:none}.collapse{visibility:collapse}.invisible{visibility:hidden}.visible{visibility:visible}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.inset-0{top:0;right:0;bottom:0;left:0}.inset-x-0{inset-inline:0}.inset-y-0{inset-block:0}.-top-2{top:calc(var(--spacing) * -2)}.top-0{top:0}.top-1\/2,.top-\[50\%\]{top:50%}.-right-2{right:calc(var(--spacing) * -2)}.right-1{right:var(--spacing)}.right-2{right:calc(var(--spacing) * 2)}.right-8{right:calc(var(--spacing) * 8)}.left-1\/2{left:50%}.left-2{left:calc(var(--spacing) * 2)}.left-\[50\%\]{left:50%}.z-10{z-index:10}.z-20{z-index:20}.z-50{z-index:50}.col-start-2{grid-column-start:2}.row-span-2{grid-row:span 2/span 2}.row-start-1{grid-row-start:1}.-mx-1{margin-inline:calc(var(--spacing) * -1)}.mx-auto{margin-inline:auto}.my-1{margin-block:var(--spacing)}.-mt-2{margin-top:calc(var(--spacing) * -2)}.mt-1{margin-top:var(--spacing)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mr-1{margin-right:var(--spacing)}.mb-1{margin-bottom:var(--spacing)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.ml-1{margin-left:var(--spacing)}.ml-1\.5{margin-left:calc(var(--spacing) * 1.5)}.ml-2{margin-left:calc(var(--spacing) * 2)}.ml-auto{margin-left:auto}.block{display:block}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.table{display:table}.table-caption{display:table-caption}.table-cell{display:table-cell}.table-row{display:table-row}.field-sizing-content{field-sizing:content}.size-2{width:calc(var(--spacing) * 2);height:calc(var(--spacing) * 2)}.size-2\.5{width:calc(var(--spacing) * 2.5);height:calc(var(--spacing) * 2.5)}.size-3{width:calc(var(--spacing) * 3);height:calc(var(--spacing) * 3)}.size-3\.5{width:calc(var(--spacing) * 3.5);height:calc(var(--spacing) * 3.5)}.size-4{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.size-5{width:calc(var(--spacing) * 5);height:calc(var(--spacing) * 5)}.size-6{width:calc(var(--spacing) * 6);height:calc(var(--spacing) * 6)}.size-8{width:calc(var(--spacing) * 8);height:calc(var(--spacing) * 8)}.size-9{width:calc(var(--spacing) * 9);height:calc(var(--spacing) * 9)}.size-10{width:calc(var(--spacing) * 10);height:calc(var(--spacing) * 10)}.h-0\.5{height:calc(var(--spacing) * .5)}.h-1\.5{height:calc(var(--spacing) * 1.5)}.h-2{height:calc(var(--spacing) * 2)}.h-6{height:calc(var(--spacing) * 6)}.h-8{height:calc(var(--spacing) * 8)}.h-9{height:calc(var(--spacing) * 9)}.h-10{height:calc(var(--spacing) * 10)}.h-16{height:calc(var(--spacing) * 16)}.h-\[1\.15rem\]{height:1.15rem}.h-\[calc\(100\%-1px\)\]{height:calc(100% - 1px)}.h-\[var\(--radix-select-trigger-height\)\]{height:var(--radix-select-trigger-height)}.h-full{height:100%}.h-px{height:1px}.max-h-\(--radix-dropdown-menu-content-available-height\){max-height:var(--radix-dropdown-menu-content-available-height)}.max-h-\(--radix-select-content-available-height\){max-height:var(--radix-select-content-available-height)}.max-h-40{max-height:calc(var(--spacing) * 40)}.max-h-48{max-height:calc(var(--spacing) * 48)}.max-h-64{max-height:calc(var(--spacing) * 64)}.max-h-96{max-height:calc(var(--spacing) * 96)}.min-h-9{min-height:calc(var(--spacing) * 9)}.min-h-16{min-height:calc(var(--spacing) * 16)}.min-h-screen{min-height:100vh}.w-3{width:calc(var(--spacing) * 3)}.w-6{width:calc(var(--spacing) * 6)}.w-8{width:calc(var(--spacing) * 8)}.w-12{width:calc(var(--spacing) * 12)}.w-16{width:calc(var(--spacing) * 16)}.w-20{width:calc(var(--spacing) * 20)}.w-24{width:calc(var(--spacing) * 24)}.w-26{width:calc(var(--spacing) * 26)}.w-32{width:calc(var(--spacing) * 32)}.w-36{width:calc(var(--spacing) * 36)}.w-40{width:calc(var(--spacing) * 40)}.w-44{width:calc(var(--spacing) * 44)}.w-56{width:calc(var(--spacing) * 56)}.w-64{width:calc(var(--spacing) * 64)}.w-auto{width:auto}.w-fit{width:fit-content}.w-full{width:100%}.max-w-6xl{max-width:var(--container-6xl)}.max-w-36{max-width:calc(var(--spacing) * 36)}.max-w-40{max-width:calc(var(--spacing) * 40)}.max-w-44{max-width:calc(var(--spacing) * 44)}.max-w-\[85\%\]{max-width:85%}.max-w-\[calc\(100\%-2rem\)\]{max-width:calc(100% - 2rem)}.max-w-full{max-width:100%}.max-w-sm{max-width:var(--container-sm)}.min-w-0{min-width:0}.min-w-32{min-width:calc(var(--spacing) * 32)}.min-w-36{min-width:calc(var(--spacing) * 36)}.min-w-48{min-width:calc(var(--spacing) * 48)}.min-w-\[8rem\]{min-width:8rem}.min-w-\[var\(--radix-select-trigger-width\)\]{min-width:var(--radix-select-trigger-width)}.flex-1{flex:1}.shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.caption-bottom{caption-side:bottom}.origin-\(--radix-dropdown-menu-content-transform-origin\){transform-origin:var(--radix-dropdown-menu-content-transform-origin)}.origin-\(--radix-select-content-transform-origin\){transform-origin:var(--radix-select-content-transform-origin)}.-translate-x-1\/2{--tw-translate-x: -50% ;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-x-\[-50\%\]{--tw-translate-x:-50%;translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-y-1\/2{--tw-translate-y: -50% ;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-y-\[-50\%\]{--tw-translate-y:-50%;translate:var(--tw-translate-x) var(--tw-translate-y)}.rotate-180{rotate:180deg}.animate-pulse{animation:var(--animate-pulse)}.animate-spin{animation:var(--animate-spin)}.cursor-default{cursor:default}.cursor-pointer{cursor:pointer}.scroll-my-1{scroll-margin-block:var(--spacing)}.auto-rows-min{grid-auto-rows:min-content}.\[grid-template-columns\:repeat\(auto-fit\,minmax\(260px\,1fr\)\)\]{grid-template-columns:repeat(auto-fit,minmax(260px,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-\[minmax\(6rem\,14rem\)_1fr_auto\]{grid-template-columns:minmax(6rem,14rem) 1fr auto}.grid-rows-\[auto_auto\]{grid-template-rows:auto auto}.flex-col{flex-direction:column}.flex-col-reverse{flex-direction:column-reverse}.flex-row{flex-direction:row}.flex-wrap{flex-wrap:wrap}.items-baseline{align-items:baseline}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-0\.5{gap:calc(var(--spacing) * .5)}.gap-1{gap:var(--spacing)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-6{gap:calc(var(--spacing) * 6)}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}.gap-x-4{column-gap:calc(var(--spacing) * 4)}.gap-x-6{column-gap:calc(var(--spacing) * 6)}.gap-x-8{column-gap:calc(var(--spacing) * 8)}.gap-y-1{row-gap:var(--spacing)}.gap-y-2{row-gap:calc(var(--spacing) * 2)}.gap-y-3{row-gap:calc(var(--spacing) * 3)}.gap-y-4{row-gap:calc(var(--spacing) * 4)}.self-start{align-self:flex-start}.justify-self-end{justify-self:flex-end}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.overflow-visible{overflow:visible}.overflow-x-auto{overflow-x:auto}.overflow-x-hidden{overflow-x:hidden}.overflow-y-auto{overflow-y:auto}.rounded{border-radius:.25rem}.rounded-\[4px\]{border-radius:4px}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:calc(var(--radius) + 2px)}.rounded-md{border-radius:var(--radius)}.rounded-sm{border-radius:calc(var(--radius) - 2px)}.rounded-xl{border-radius:calc(var(--radius) + 4px)}.rounded-xs{border-radius:var(--radius-xs)}.border{border-style:var(--tw-border-style);border-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-b-2{border-bottom-style:var(--tw-border-style);border-bottom-width:2px}.border-dashed{--tw-border-style:dashed;border-style:dashed}.border-border{border-color:rgb(var(--border))}.border-foreground{border-color:rgb(var(--foreground))}.border-input{border-color:rgb(var(--input))}.border-primary\/40{border-color:rgb(var(--primary))}@supports (color:color-mix(in lab,red,red)){.border-primary\/40{border-color:color-mix(in oklab,rgb(var(--primary)) 40%,transparent)}}.border-transparent{border-color:#0000}.bg-\[\#1a7f37\]{background-color:#1a7f37}.bg-\[\#3b82f6\]{background-color:#3b82f6}.bg-\[\#4b5563\]{background-color:#4b5563}.bg-\[\#9a6700\]{background-color:#9a6700}.bg-\[\#22c55e\]{background-color:#22c55e}.bg-\[\#0969da\]{background-color:#0969da}.bg-\[\#a855f7\]{background-color:#a855f7}.bg-\[\#f97316\]{background-color:#f97316}.bg-\[rgb\(var\(--border\)\)\]{background-color:rgb(var(--border))}.bg-accent{background-color:rgb(var(--accent))}.bg-background{background-color:rgb(var(--background))}.bg-black\/50{background-color:#00000080}@supports (color:color-mix(in lab,red,red)){.bg-black\/50{background-color:color-mix(in oklab,var(--color-black) 50%,transparent)}}.bg-border{background-color:rgb(var(--border))}.bg-card{background-color:rgb(var(--card))}.bg-chart-1{background-color:rgb(var(--chart-1))}.bg-chart-2{background-color:rgb(var(--chart-2))}.bg-chart-3{background-color:rgb(var(--chart-3))}.bg-chart-5{background-color:rgb(var(--chart-5))}.bg-destructive{background-color:rgb(var(--destructive))}.bg-foreground\/5{background-color:rgb(var(--foreground))}@supports (color:color-mix(in lab,red,red)){.bg-foreground\/5{background-color:color-mix(in oklab,rgb(var(--foreground)) 5%,transparent)}}.bg-green-500{background-color:var(--color-green-500)}.bg-muted{background-color:rgb(var(--muted))}.bg-muted-foreground{background-color:rgb(var(--muted-foreground))}.bg-muted\/30{background-color:rgb(var(--muted))}@supports (color:color-mix(in lab,red,red)){.bg-muted\/30{background-color:color-mix(in oklab,rgb(var(--muted)) 30%,transparent)}}.bg-muted\/50{background-color:rgb(var(--muted))}@supports (color:color-mix(in lab,red,red)){.bg-muted\/50{background-color:color-mix(in oklab,rgb(var(--muted)) 50%,transparent)}}.bg-popover{background-color:rgb(var(--popover))}.bg-primary,.bg-primary\/5{background-color:rgb(var(--primary))}@supports (color:color-mix(in lab,red,red)){.bg-primary\/5{background-color:color-mix(in oklab,rgb(var(--primary)) 5%,transparent)}}.bg-red-500{background-color:var(--color-red-500)}.bg-secondary{background-color:rgb(var(--secondary))}.bg-transparent{background-color:#0000}.bg-yellow-500{background-color:var(--color-yellow-500)}.fill-border{fill:rgb(var(--border))}.fill-chart-1{fill:rgb(var(--chart-1))}.fill-chart-2{fill:rgb(var(--chart-2))}.fill-chart-3{fill:rgb(var(--chart-3))}.fill-chart-5{fill:rgb(var(--chart-5))}.fill-current{fill:currentColor}.fill-foreground,.fill-foreground\/5{fill:rgb(var(--foreground))}@supports (color:color-mix(in lab,red,red)){.fill-foreground\/5{fill:color-mix(in oklab,rgb(var(--foreground)) 5%,transparent)}}.fill-muted-foreground{fill:rgb(var(--muted-foreground))}.stroke-border{stroke:rgb(var(--border))}.object-cover{object-fit:cover}.p-1{padding:var(--spacing)}.p-2{padding:calc(var(--spacing) * 2)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-6{padding:calc(var(--spacing) * 6)}.p-\[3px\]{padding:3px}.px-0\.5{padding-inline:calc(var(--spacing) * .5)}.px-1{padding-inline:var(--spacing)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-6{padding-inline:calc(var(--spacing) * 6)}.py-0{padding-block:0}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:var(--spacing)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-8{padding-block:calc(var(--spacing) * 8)}.py-12{padding-block:calc(var(--spacing) * 12)}.pt-4{padding-top:calc(var(--spacing) * 4)}.pr-0{padding-right:0}.pr-1{padding-right:var(--spacing)}.pr-2{padding-right:calc(var(--spacing) * 2)}.pr-6{padding-right:calc(var(--spacing) * 6)}.pr-8{padding-right:calc(var(--spacing) * 8)}.pr-14{padding-right:calc(var(--spacing) * 14)}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.pb-3{padding-bottom:calc(var(--spacing) * 3)}.pl-2{padding-left:calc(var(--spacing) * 2)}.pl-8{padding-left:calc(var(--spacing) * 8)}.pl-\[18px\]{padding-left:18px}.pl-\[26px\]{padding-left:26px}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.align-middle{vertical-align:middle}.align-top{vertical-align:top}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace}.font-sans{font-family:Aspekta,ui-sans-serif,system-ui,sans-serif}.font-serif{font-family:"Source Serif 4",Source Serif Pro,Georgia,serif}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-sm\/relaxed{font-size:var(--text-sm);line-height:var(--leading-relaxed)}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[10px\]{font-size:10px}.leading-none{--tw-leading:1;line-height:1}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-widest{--tw-tracking:var(--tracking-widest);letter-spacing:var(--tracking-widest)}.text-balance{text-wrap:balance}.wrap-anywhere{overflow-wrap:anywhere}.break-all{word-break:break-all}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.text-accent-foreground{color:rgb(var(--accent-foreground))}.text-card-foreground{color:rgb(var(--card-foreground))}.text-current{color:currentColor}.text-destructive{color:rgb(var(--destructive))}.text-foreground{color:rgb(var(--foreground))}.text-muted-foreground{color:rgb(var(--muted-foreground))}.text-popover-foreground{color:rgb(var(--popover-foreground))}.text-primary{color:rgb(var(--primary))}.text-primary-foreground{color:rgb(var(--primary-foreground))}.text-secondary-foreground{color:rgb(var(--secondary-foreground))}.text-white{color:var(--color-white)}.text-yellow-600{color:var(--color-yellow-600)}.lowercase{text-transform:lowercase}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.underline-offset-2{text-underline-offset:2px}.underline-offset-4{text-underline-offset:4px}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a), 0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-xs{--tw-shadow:0 1px 2px 0 var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring-0{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.outline-hidden{--tw-outline-style:none;outline-style:none}@media(forced-colors:active){.outline-hidden{outline-offset:2px;outline:2px solid #0000}}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.transition-\[color\,box-shadow\]{transition-property:color,box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-shadow{transition-property:box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-none{transition-property:none}.duration-200{--tw-duration:.2s;transition-duration:.2s}.outline-none{--tw-outline-style:none;outline-style:none}.select-none{-webkit-user-select:none;user-select:none}.running{animation-play-state:running}.group-data-\[disabled\=true\]\:pointer-events-none:is(:where(.group)[data-disabled=true] *){pointer-events:none}.group-data-\[disabled\=true\]\:opacity-50:is(:where(.group)[data-disabled=true] *){opacity:.5}.peer-disabled\:cursor-not-allowed:is(:where(.peer):disabled~*){cursor:not-allowed}.peer-disabled\:opacity-50:is(:where(.peer):disabled~*){opacity:.5}.selection\:bg-primary ::selection{background-color:rgb(var(--primary))}.selection\:bg-primary::selection{background-color:rgb(var(--primary))}.selection\:text-primary-foreground ::selection{color:rgb(var(--primary-foreground))}.selection\:text-primary-foreground::selection{color:rgb(var(--primary-foreground))}.file\:inline-flex::file-selector-button{display:inline-flex}.file\:h-7::file-selector-button{height:calc(var(--spacing) * 7)}.file\:border-0::file-selector-button{border-style:var(--tw-border-style);border-width:0}.file\:bg-transparent::file-selector-button{background-color:#0000}.file\:text-sm::file-selector-button{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.file\:font-medium::file-selector-button{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.file\:text-foreground::file-selector-button{color:rgb(var(--foreground))}.placeholder\:text-muted-foreground::placeholder{color:rgb(var(--muted-foreground))}@media(hover:hover){.hover\:bg-accent:hover{background-color:rgb(var(--accent))}.hover\:bg-destructive\/90:hover{background-color:rgb(var(--destructive))}@supports (color:color-mix(in lab,red,red)){.hover\:bg-destructive\/90:hover{background-color:color-mix(in oklab,rgb(var(--destructive)) 90%,transparent)}}.hover\:bg-muted\/30:hover{background-color:rgb(var(--muted))}@supports (color:color-mix(in lab,red,red)){.hover\:bg-muted\/30:hover{background-color:color-mix(in oklab,rgb(var(--muted)) 30%,transparent)}}.hover\:bg-muted\/50:hover{background-color:rgb(var(--muted))}@supports (color:color-mix(in lab,red,red)){.hover\:bg-muted\/50:hover{background-color:color-mix(in oklab,rgb(var(--muted)) 50%,transparent)}}.hover\:bg-primary\/90:hover{background-color:rgb(var(--primary))}@supports (color:color-mix(in lab,red,red)){.hover\:bg-primary\/90:hover{background-color:color-mix(in oklab,rgb(var(--primary)) 90%,transparent)}}.hover\:bg-secondary\/80:hover{background-color:rgb(var(--secondary))}@supports (color:color-mix(in lab,red,red)){.hover\:bg-secondary\/80:hover{background-color:color-mix(in oklab,rgb(var(--secondary)) 80%,transparent)}}.hover\:text-accent-foreground:hover{color:rgb(var(--accent-foreground))}.hover\:text-foreground:hover{color:rgb(var(--foreground))}.hover\:underline:hover{text-decoration-line:underline}}.focus\:bg-accent:focus{background-color:rgb(var(--accent))}.focus\:text-accent-foreground:focus{color:rgb(var(--accent-foreground))}.focus-visible\:border-ring:focus-visible{border-color:rgb(var(--ring))}.focus-visible\:stroke-ring:focus-visible{stroke:rgb(var(--ring))}.focus-visible\:\[stroke-width\:2\]:focus-visible{stroke-width:2px}.focus-visible\:ring-2:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-visible\:ring-\[3px\]:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-visible\:ring-destructive\/20:focus-visible{--tw-ring-color:rgb(var(--destructive))}@supports (color:color-mix(in lab,red,red)){.focus-visible\:ring-destructive\/20:focus-visible{--tw-ring-color:color-mix(in oklab, rgb(var(--destructive)) 20%, transparent)}}.focus-visible\:ring-ring:focus-visible,.focus-visible\:ring-ring\/50:focus-visible{--tw-ring-color:rgb(var(--ring))}@supports (color:color-mix(in lab,red,red)){.focus-visible\:ring-ring\/50:focus-visible{--tw-ring-color:color-mix(in oklab, rgb(var(--ring)) 50%, transparent)}}.focus-visible\:outline-1:focus-visible{outline-style:var(--tw-outline-style);outline-width:1px}.focus-visible\:outline-ring:focus-visible{outline-color:rgb(var(--ring))}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-50:disabled{opacity:.5}.has-data-\[slot\=card-action\]\:grid-cols-\[1fr_auto\]:has([data-slot=card-action]){grid-template-columns:1fr auto}.has-\[\>svg\]\:px-2\.5:has(>svg){padding-inline:calc(var(--spacing) * 2.5)}.has-\[\>svg\]\:px-3:has(>svg){padding-inline:calc(var(--spacing) * 3)}.has-\[\>svg\]\:px-4:has(>svg){padding-inline:calc(var(--spacing) * 4)}.aria-invalid\:border-destructive[aria-invalid=true]{border-color:rgb(var(--destructive))}.aria-invalid\:ring-destructive\/20[aria-invalid=true]{--tw-ring-color:rgb(var(--destructive))}@supports (color:color-mix(in lab,red,red)){.aria-invalid\:ring-destructive\/20[aria-invalid=true]{--tw-ring-color:color-mix(in oklab, rgb(var(--destructive)) 20%, transparent)}}.data-\[disabled\]\:pointer-events-none[data-disabled]{pointer-events:none}.data-\[disabled\]\:opacity-50[data-disabled]{opacity:.5}.data-\[inset\]\:pl-8[data-inset]{padding-left:calc(var(--spacing) * 8)}.data-\[orientation\=horizontal\]\:h-px[data-orientation=horizontal]{height:1px}.data-\[orientation\=horizontal\]\:w-full[data-orientation=horizontal]{width:100%}.data-\[orientation\=vertical\]\:h-full[data-orientation=vertical]{height:100%}.data-\[orientation\=vertical\]\:w-px[data-orientation=vertical]{width:1px}.data-\[placeholder\]\:text-muted-foreground[data-placeholder]{color:rgb(var(--muted-foreground))}.data-\[side\=bottom\]\:translate-y-1[data-side=bottom]{--tw-translate-y:var(--spacing);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[side\=bottom\]\:slide-in-from-top-2[data-side=bottom]{--tw-enter-translate-y:calc(2*var(--spacing)*-1)}.data-\[side\=left\]\:-translate-x-1[data-side=left]{--tw-translate-x:calc(var(--spacing) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[side\=left\]\:slide-in-from-right-2[data-side=left]{--tw-enter-translate-x:calc(2*var(--spacing))}.data-\[side\=right\]\:translate-x-1[data-side=right]{--tw-translate-x:var(--spacing);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[side\=right\]\:slide-in-from-left-2[data-side=right]{--tw-enter-translate-x:calc(2*var(--spacing)*-1)}.data-\[side\=top\]\:-translate-y-1[data-side=top]{--tw-translate-y:calc(var(--spacing) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[side\=top\]\:slide-in-from-bottom-2[data-side=top]{--tw-enter-translate-y:calc(2*var(--spacing))}.data-\[size\=default\]\:h-9[data-size=default]{height:calc(var(--spacing) * 9)}.data-\[size\=sm\]\:h-8[data-size=sm]{height:calc(var(--spacing) * 8)}:is(.\*\:data-\[slot\=select-value\]\:line-clamp-1>*)[data-slot=select-value]{-webkit-line-clamp:1;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}:is(.\*\:data-\[slot\=select-value\]\:flex>*)[data-slot=select-value]{display:flex}:is(.\*\:data-\[slot\=select-value\]\:items-center>*)[data-slot=select-value]{align-items:center}:is(.\*\:data-\[slot\=select-value\]\:gap-2>*)[data-slot=select-value]{gap:calc(var(--spacing) * 2)}.data-\[state\=active\]\:bg-card[data-state=active]{background-color:rgb(var(--card))}.data-\[state\=active\]\:shadow-sm[data-state=active]{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.data-\[state\=checked\]\:translate-x-\[calc\(100\%-2px\)\][data-state=checked]{--tw-translate-x: calc(100% - 2px) ;translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[state\=checked\]\:border-primary[data-state=checked]{border-color:rgb(var(--primary))}.data-\[state\=checked\]\:bg-primary[data-state=checked]{background-color:rgb(var(--primary))}.data-\[state\=checked\]\:text-primary-foreground[data-state=checked]{color:rgb(var(--primary-foreground))}.data-\[state\=closed\]\:animate-out[data-state=closed]{animation:exit var(--tw-animation-duration,var(--tw-duration,.15s))var(--tw-ease,ease)var(--tw-animation-delay,0s)var(--tw-animation-iteration-count,1)var(--tw-animation-direction,normal)var(--tw-animation-fill-mode,forwards)}.data-\[state\=closed\]\:fade-out-0[data-state=closed]{--tw-exit-opacity:0}.data-\[state\=closed\]\:zoom-out-95[data-state=closed]{--tw-exit-scale:.95}.data-\[state\=inactive\]\:hidden[data-state=inactive]{display:none}.data-\[state\=open\]\:animate-in[data-state=open]{animation:enter var(--tw-animation-duration,var(--tw-duration,.15s))var(--tw-ease,ease)var(--tw-animation-delay,0s)var(--tw-animation-iteration-count,1)var(--tw-animation-direction,normal)var(--tw-animation-fill-mode,forwards)}.data-\[state\=open\]\:bg-accent[data-state=open]{background-color:rgb(var(--accent))}.data-\[state\=open\]\:text-accent-foreground[data-state=open]{color:rgb(var(--accent-foreground))}.data-\[state\=open\]\:fade-in-0[data-state=open]{--tw-enter-opacity:0}.data-\[state\=open\]\:zoom-in-95[data-state=open]{--tw-enter-scale:.95}.data-\[state\=selected\]\:bg-muted[data-state=selected]{background-color:rgb(var(--muted))}.data-\[state\=unchecked\]\:translate-x-0[data-state=unchecked]{--tw-translate-x:0px;translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[state\=unchecked\]\:bg-\[\#7F7F79\][data-state=unchecked]{background-color:#7f7f79}.data-\[variant\=destructive\]\:text-destructive[data-variant=destructive]{color:rgb(var(--destructive))}.data-\[variant\=destructive\]\:focus\:bg-destructive\/10[data-variant=destructive]:focus{background-color:rgb(var(--destructive))}@supports (color:color-mix(in lab,red,red)){.data-\[variant\=destructive\]\:focus\:bg-destructive\/10[data-variant=destructive]:focus{background-color:color-mix(in oklab,rgb(var(--destructive)) 10%,transparent)}}.data-\[variant\=destructive\]\:focus\:text-destructive[data-variant=destructive]:focus{color:rgb(var(--destructive))}@media(min-width:40rem){.sm\:col-span-2{grid-column:span 2/span 2}.sm\:max-w-lg{max-width:var(--container-lg)}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:justify-end{justify-content:flex-end}.sm\:text-left{text-align:left}}@media(min-width:48rem){.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:p-12{padding:calc(var(--spacing) * 12)}.md\:text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}}@media(min-width:64rem){.lg\:col-span-2{grid-column:span 2/span 2}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}.dark\:border-input:is(.dark *){border-color:rgb(var(--input))}.dark\:bg-chart-4:is(.dark *){background-color:rgb(var(--chart-4))}.dark\:bg-destructive\/60:is(.dark *){background-color:rgb(var(--destructive))}@supports (color:color-mix(in lab,red,red)){.dark\:bg-destructive\/60:is(.dark *){background-color:color-mix(in oklab,rgb(var(--destructive)) 60%,transparent)}}.dark\:bg-input\/30:is(.dark *){background-color:rgb(var(--input))}@supports (color:color-mix(in lab,red,red)){.dark\:bg-input\/30:is(.dark *){background-color:color-mix(in oklab,rgb(var(--input)) 30%,transparent)}}.dark\:fill-chart-4:is(.dark *){fill:rgb(var(--chart-4))}.dark\:text-muted-foreground:is(.dark *){color:rgb(var(--muted-foreground))}.dark\:text-yellow-400:is(.dark *){color:var(--color-yellow-400)}@media(hover:hover){.dark\:hover\:bg-accent\/50:is(.dark *):hover{background-color:rgb(var(--accent))}@supports (color:color-mix(in lab,red,red)){.dark\:hover\:bg-accent\/50:is(.dark *):hover{background-color:color-mix(in oklab,rgb(var(--accent)) 50%,transparent)}}.dark\:hover\:bg-input\/50:is(.dark *):hover{background-color:rgb(var(--input))}@supports (color:color-mix(in lab,red,red)){.dark\:hover\:bg-input\/50:is(.dark *):hover{background-color:color-mix(in oklab,rgb(var(--input)) 50%,transparent)}}}.dark\:focus-visible\:ring-destructive\/40:is(.dark *):focus-visible{--tw-ring-color:rgb(var(--destructive))}@supports (color:color-mix(in lab,red,red)){.dark\:focus-visible\:ring-destructive\/40:is(.dark *):focus-visible{--tw-ring-color:color-mix(in oklab, rgb(var(--destructive)) 40%, transparent)}}.dark\:aria-invalid\:ring-destructive\/40:is(.dark *)[aria-invalid=true]{--tw-ring-color:rgb(var(--destructive))}@supports (color:color-mix(in lab,red,red)){.dark\:aria-invalid\:ring-destructive\/40:is(.dark *)[aria-invalid=true]{--tw-ring-color:color-mix(in oklab, rgb(var(--destructive)) 40%, transparent)}}.dark\:data-\[state\=active\]\:border-input:is(.dark *)[data-state=active]{border-color:rgb(var(--input))}.dark\:data-\[state\=active\]\:bg-input\/30:is(.dark *)[data-state=active]{background-color:rgb(var(--input))}@supports (color:color-mix(in lab,red,red)){.dark\:data-\[state\=active\]\:bg-input\/30:is(.dark *)[data-state=active]{background-color:color-mix(in oklab,rgb(var(--input)) 30%,transparent)}}.dark\:data-\[state\=active\]\:text-foreground:is(.dark *)[data-state=active]{color:rgb(var(--foreground))}.dark\:data-\[state\=checked\]\:bg-primary:is(.dark *)[data-state=checked]{background-color:rgb(var(--primary))}.dark\:data-\[state\=checked\]\:bg-primary-foreground:is(.dark *)[data-state=checked]{background-color:rgb(var(--primary-foreground))}.dark\:data-\[state\=unchecked\]\:bg-foreground:is(.dark *)[data-state=unchecked]{background-color:rgb(var(--foreground))}.dark\:data-\[state\=unchecked\]\:bg-input:is(.dark *)[data-state=unchecked]{background-color:rgb(var(--input))}.dark\:data-\[variant\=destructive\]\:focus\:bg-destructive\/20:is(.dark *)[data-variant=destructive]:focus{background-color:rgb(var(--destructive))}@supports (color:color-mix(in lab,red,red)){.dark\:data-\[variant\=destructive\]\:focus\:bg-destructive\/20:is(.dark *)[data-variant=destructive]:focus{background-color:color-mix(in oklab,rgb(var(--destructive)) 20%,transparent)}}.\[\&_svg\]\:pointer-events-none svg{pointer-events:none}.\[\&_svg\]\:shrink-0 svg{flex-shrink:0}.\[\&_svg\:not\(\[class\*\=\'size-\'\]\)\]\:size-4 svg:not([class*=size-]){width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.\[\&_svg\:not\(\[class\*\=\'size-\'\]\)\]\:size-6 svg:not([class*=size-]){width:calc(var(--spacing) * 6);height:calc(var(--spacing) * 6)}.\[\&_svg\:not\(\[class\*\=\'text-\'\]\)\]\:text-muted-foreground svg:not([class*=text-]){color:rgb(var(--muted-foreground))}.\[\&_tr\]\:border-b tr{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.\[\&_tr\:last-child\]\:border-0 tr:last-child{border-style:var(--tw-border-style);border-width:0}.\[\&\:has\(\[role\=checkbox\]\)\]\:pr-0:has([role=checkbox]){padding-right:0}.\[\.border-b\]\:pb-6.border-b{padding-bottom:calc(var(--spacing) * 6)}.\[\.border-t\]\:pt-6.border-t{padding-top:calc(var(--spacing) * 6)}:is(.\*\:\[span\]\:last\:flex>*):is(span):last-child{display:flex}:is(.\*\:\[span\]\:last\:items-center>*):is(span):last-child{align-items:center}:is(.\*\:\[span\]\:last\:gap-2>*):is(span):last-child{gap:calc(var(--spacing) * 2)}:is(.data-\[variant\=destructive\]\:\*\:\[svg\]\:\!text-destructive[data-variant=destructive]>*):is(svg){color:rgb(var(--destructive))!important}.\[\&\>\[role\=checkbox\]\]\:translate-y-\[2px\]>[role=checkbox]{--tw-translate-y:2px;translate:var(--tw-translate-x) var(--tw-translate-y)}.\[\&\>a\]\:underline>a{text-decoration-line:underline}.\[\&\>a\]\:underline-offset-4>a{text-underline-offset:4px}.\[\&\>a\:hover\]\:text-primary>a:hover{color:rgb(var(--primary))}.\[\&\>svg\]\:pointer-events-none>svg{pointer-events:none}.\[\&\>svg\]\:size-3>svg{width:calc(var(--spacing) * 3);height:calc(var(--spacing) * 3)}.\[\&\>svg\]\:size-3\.5>svg{width:calc(var(--spacing) * 3.5);height:calc(var(--spacing) * 3.5)}.\[\&\>tr\]\:last\:border-b-0>tr:last-child{border-bottom-style:var(--tw-border-style);border-bottom-width:0}@media(hover:hover){a.\[a\&\]\:hover\:bg-\[\#1a7f37\]\/90:hover{background-color:#1a7f37e6}a.\[a\&\]\:hover\:bg-\[\#3b82f6\]\/90:hover{background-color:#3b82f6e6}a.\[a\&\]\:hover\:bg-\[\#4b5563\]\/90:hover{background-color:#4b5563e6}a.\[a\&\]\:hover\:bg-\[\#9a6700\]\/90:hover{background-color:#9a6700e6}a.\[a\&\]\:hover\:bg-\[\#22c55e\]\/90:hover{background-color:#22c55ee6}a.\[a\&\]\:hover\:bg-\[\#0969da\]\/90:hover{background-color:#0969dae6}a.\[a\&\]\:hover\:bg-\[\#a855f7\]\/90:hover{background-color:#a855f7e6}a.\[a\&\]\:hover\:bg-\[\#f97316\]\/90:hover{background-color:#f97316e6}a.\[a\&\]\:hover\:bg-accent\/10:hover{background-color:rgb(var(--accent))}@supports (color:color-mix(in lab,red,red)){a.\[a\&\]\:hover\:bg-accent\/10:hover{background-color:color-mix(in oklab,rgb(var(--accent)) 10%,transparent)}}a.\[a\&\]\:hover\:bg-destructive\/90:hover{background-color:rgb(var(--destructive))}@supports (color:color-mix(in lab,red,red)){a.\[a\&\]\:hover\:bg-destructive\/90:hover{background-color:color-mix(in oklab,rgb(var(--destructive)) 90%,transparent)}}a.\[a\&\]\:hover\:bg-muted:hover,a.\[a\&\]\:hover\:bg-muted\/80:hover{background-color:rgb(var(--muted))}@supports (color:color-mix(in lab,red,red)){a.\[a\&\]\:hover\:bg-muted\/80:hover{background-color:color-mix(in oklab,rgb(var(--muted)) 80%,transparent)}}a.\[a\&\]\:hover\:bg-primary\/90:hover{background-color:rgb(var(--primary))}@supports (color:color-mix(in lab,red,red)){a.\[a\&\]\:hover\:bg-primary\/90:hover{background-color:color-mix(in oklab,rgb(var(--primary)) 90%,transparent)}}a.\[a\&\]\:hover\:bg-secondary\/90:hover{background-color:rgb(var(--secondary))}@supports (color:color-mix(in lab,red,red)){a.\[a\&\]\:hover\:bg-secondary\/90:hover{background-color:color-mix(in oklab,rgb(var(--secondary)) 90%,transparent)}}a.\[a\&\]\:hover\:text-accent-foreground:hover{color:rgb(var(--accent-foreground))}}}@property --tw-animation-delay{syntax:"*";inherits:false;initial-value:0s}@property --tw-animation-direction{syntax:"*";inherits:false;initial-value:normal}@property --tw-animation-duration{syntax:"*";inherits:false}@property --tw-animation-fill-mode{syntax:"*";inherits:false;initial-value:forwards}@property --tw-animation-iteration-count{syntax:"*";inherits:false;initial-value:1}@property --tw-enter-opacity{syntax:"*";inherits:false;initial-value:1}@property --tw-enter-rotate{syntax:"*";inherits:false;initial-value:0}@property --tw-enter-scale{syntax:"*";inherits:false;initial-value:1}@property --tw-enter-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-enter-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-opacity{syntax:"*";inherits:false;initial-value:1}@property --tw-exit-rotate{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-scale{syntax:"*";inherits:false;initial-value:1}@property --tw-exit-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-translate-y{syntax:"*";inherits:false;initial-value:0}@font-face{font-family:Aspekta;font-style:normal;font-weight:400;font-display:swap;src:url(/fonts/Aspekta-400.woff2)format("woff2")}@font-face{font-family:Aspekta;font-style:normal;font-weight:500;font-display:swap;src:url(/fonts/Aspekta-500.woff2)format("woff2")}@font-face{font-family:Aspekta;font-style:normal;font-weight:600;font-display:swap;src:url(/fonts/Aspekta-600.woff2)format("woff2")}@font-face{font-family:Aspekta;font-style:normal;font-weight:700;font-display:swap;src:url(/fonts/Aspekta-700.woff2)format("woff2")}:root{--background:240 240 236;--foreground:22 22 20;--card:249 249 247;--card-foreground:22 22 20;--popover:249 249 247;--popover-foreground:22 22 20;--primary:217 94 42;--primary-foreground:249 249 247;--secondary:240 240 236;--secondary-foreground:47 47 44;--muted:240 240 236;--muted-foreground:87 87 83;--accent:221 221 215;--accent-foreground:22 22 20;--destructive:180 50 50;--destructive-foreground:249 249 247;--border:196 196 189;--input:196 196 189;--ring:217 94 42;--chart-1:217 94 42;--chart-2:87 87 83;--chart-3:127 127 121;--chart-4:166 166 159;--chart-5:47 47 44;--radius:.375rem}.dark{--background:22 22 20;--foreground:249 249 247;--card:47 47 44;--card-foreground:249 249 247;--popover:47 47 44;--popover-foreground:249 249 247;--primary:217 94 42;--primary-foreground:249 249 247;--secondary:87 87 83;--secondary-foreground:249 249 247;--muted:87 87 83;--muted-foreground:196 196 189;--accent:87 87 83;--accent-foreground:249 249 247;--destructive:180 50 50;--destructive-foreground:249 249 247;--border:87 87 83;--input:87 87 83;--ring:217 94 42;--chart-1:217 94 42;--chart-2:196 196 189;--chart-3:166 166 159;--chart-4:127 127 121;--chart-5:240 240 236}.chat-markdown{overflow-wrap:break-word;line-height:1.55}.chat-markdown>:first-child{margin-top:0}.chat-markdown>:last-child{margin-bottom:0}.chat-markdown p{margin:.5em 0}.chat-markdown h1,.chat-markdown h2,.chat-markdown h3,.chat-markdown h4,.chat-markdown h5,.chat-markdown h6{margin:1em 0 .4em;font-size:1em;font-weight:600;line-height:1.3}.chat-markdown h1{font-size:1.15em}.chat-markdown h2{font-size:1.08em}.chat-markdown ul,.chat-markdown ol{margin:.5em 0;padding-left:1.4em}.chat-markdown ul{list-style:outside}.chat-markdown ol{list-style:decimal}.chat-markdown li,.chat-markdown li>ul,.chat-markdown li>ol{margin:.2em 0}.chat-markdown li::marker{color:rgb(var(--muted-foreground))}.chat-markdown code{font-family:var(--font-mono);background:rgb(var(--muted));border:1px solid rgb(var(--border) / .6);border-radius:4px;padding:.1em .35em;font-size:.9em}.chat-markdown pre{background:rgb(var(--muted));border:1px solid rgb(var(--border) / .6);border-radius:var(--radius);margin:.6em 0;padding:.6em .75em;overflow-x:auto}.chat-markdown pre code{background:0 0;border:none;padding:0;font-size:.85em;line-height:1.5}.chat-markdown blockquote{border-left:3px solid rgb(var(--border));color:rgb(var(--muted-foreground));margin:.6em 0;padding:.1em 0 .1em .9em}.chat-markdown a{color:rgb(var(--primary));text-underline-offset:2px;text-decoration:underline}.chat-markdown table{border-collapse:collapse;margin:.6em 0;font-size:.95em;display:block;overflow-x:auto}.chat-markdown th,.chat-markdown td{border:1px solid rgb(var(--border));text-align:left;padding:4px 10px}.chat-markdown th{background:rgb(var(--muted));font-weight:600}.chat-markdown hr{border:none;border-top:1px solid rgb(var(--border));margin:1em 0}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@keyframes spin{to{transform:rotate(360deg)}}@keyframes pulse{50%{opacity:.5}}@keyframes enter{0%{opacity:var(--tw-enter-opacity,1);transform:translate3d(var(--tw-enter-translate-x,0),var(--tw-enter-translate-y,0),0)scale3d(var(--tw-enter-scale,1),var(--tw-enter-scale,1),var(--tw-enter-scale,1))rotate(var(--tw-enter-rotate,0))}}@keyframes exit{to{opacity:var(--tw-exit-opacity,1);transform:translate3d(var(--tw-exit-translate-x,0),var(--tw-exit-translate-y,0),0)scale3d(var(--tw-exit-scale,1),var(--tw-exit-scale,1),var(--tw-exit-scale,1))rotate(var(--tw-exit-rotate,0))}} diff --git a/internal/server/uidist/assets/index-CuOhbZNq.css b/internal/server/uidist/assets/index-CuOhbZNq.css new file mode 100644 index 0000000..512b4af --- /dev/null +++ b/internal/server/uidist/assets/index-CuOhbZNq.css @@ -0,0 +1 @@ +/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-space-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-duration:initial;--tw-animation-delay:0s;--tw-animation-direction:normal;--tw-animation-duration:initial;--tw-animation-fill-mode:forwards;--tw-animation-iteration-count:1;--tw-enter-opacity:1;--tw-enter-rotate:0;--tw-enter-scale:1;--tw-enter-translate-x:0;--tw-enter-translate-y:0;--tw-exit-opacity:1;--tw-exit-rotate:0;--tw-exit-scale:1;--tw-exit-translate-x:0;--tw-exit-translate-y:0}}}@layer theme{:root,:host{--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;--color-red-500:oklch(63.7% .237 25.331);--color-yellow-400:oklch(85.2% .199 91.936);--color-yellow-500:oklch(79.5% .184 86.047);--color-yellow-600:oklch(68.1% .162 75.834);--color-green-500:oklch(72.3% .219 149.579);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-sm:24rem;--container-lg:32rem;--container-6xl:72rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height: 1.5 ;--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--tracking-tight:-.025em;--tracking-widest:.1em;--leading-relaxed:1.625;--radius-xs:.125rem;--animate-spin:spin 1s linear infinite;--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:"Aspekta", ui-sans-serif, system-ui, sans-serif;--default-mono-font-family:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;--color-popover:rgb(var(--popover));--color-popover-foreground:rgb(var(--popover-foreground));--color-border:rgb(var(--border))}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;-moz-tab-size:4;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab,red,red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){-webkit-appearance:button;-moz-appearance:button;appearance:button}::file-selector-button{-webkit-appearance:button;-moz-appearance:button;appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}*{border-color:rgb(var(--border));outline-color:rgb(var(--ring))}@supports (color:color-mix(in lab,red,red)){*{outline-color:color-mix(in oklab,rgb(var(--ring)) 50%,transparent)}}body{background-color:rgb(var(--background));color:rgb(var(--foreground));-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;font-family:Aspekta,ui-sans-serif,system-ui,sans-serif}}@layer components;@layer utilities{.\@container\/card-header{container:card-header/inline-size}.pointer-events-none{pointer-events:none}.collapse{visibility:collapse}.invisible{visibility:hidden}.visible{visibility:visible}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.inset-0{top:0;right:0;bottom:0;left:0}.inset-x-0{inset-inline:0}.inset-y-0{inset-block:0}.-top-2{top:calc(var(--spacing) * -2)}.top-0{top:0}.top-1\/2,.top-\[50\%\]{top:50%}.-right-2{right:calc(var(--spacing) * -2)}.right-1{right:var(--spacing)}.right-2{right:calc(var(--spacing) * 2)}.right-8{right:calc(var(--spacing) * 8)}.left-1\/2{left:50%}.left-2{left:calc(var(--spacing) * 2)}.left-\[50\%\]{left:50%}.z-10{z-index:10}.z-20{z-index:20}.z-50{z-index:50}.col-start-2{grid-column-start:2}.row-span-2{grid-row:span 2/span 2}.row-start-1{grid-row-start:1}.-mx-1{margin-inline:calc(var(--spacing) * -1)}.mx-auto{margin-inline:auto}.my-1{margin-block:var(--spacing)}.-mt-2{margin-top:calc(var(--spacing) * -2)}.mt-1{margin-top:var(--spacing)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mr-1{margin-right:var(--spacing)}.mb-1{margin-bottom:var(--spacing)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.ml-1{margin-left:var(--spacing)}.ml-1\.5{margin-left:calc(var(--spacing) * 1.5)}.ml-2{margin-left:calc(var(--spacing) * 2)}.ml-auto{margin-left:auto}.block{display:block}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.table{display:table}.table-caption{display:table-caption}.table-cell{display:table-cell}.table-row{display:table-row}.field-sizing-content{field-sizing:content}.size-2{width:calc(var(--spacing) * 2);height:calc(var(--spacing) * 2)}.size-2\.5{width:calc(var(--spacing) * 2.5);height:calc(var(--spacing) * 2.5)}.size-3{width:calc(var(--spacing) * 3);height:calc(var(--spacing) * 3)}.size-3\.5{width:calc(var(--spacing) * 3.5);height:calc(var(--spacing) * 3.5)}.size-4{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.size-5{width:calc(var(--spacing) * 5);height:calc(var(--spacing) * 5)}.size-6{width:calc(var(--spacing) * 6);height:calc(var(--spacing) * 6)}.size-8{width:calc(var(--spacing) * 8);height:calc(var(--spacing) * 8)}.size-9{width:calc(var(--spacing) * 9);height:calc(var(--spacing) * 9)}.size-10{width:calc(var(--spacing) * 10);height:calc(var(--spacing) * 10)}.h-0\.5{height:calc(var(--spacing) * .5)}.h-1\.5{height:calc(var(--spacing) * 1.5)}.h-2{height:calc(var(--spacing) * 2)}.h-6{height:calc(var(--spacing) * 6)}.h-8{height:calc(var(--spacing) * 8)}.h-9{height:calc(var(--spacing) * 9)}.h-10{height:calc(var(--spacing) * 10)}.h-16{height:calc(var(--spacing) * 16)}.h-\[1\.15rem\]{height:1.15rem}.h-\[calc\(100\%-1px\)\]{height:calc(100% - 1px)}.h-\[var\(--radix-select-trigger-height\)\]{height:var(--radix-select-trigger-height)}.h-full{height:100%}.h-px{height:1px}.max-h-\(--radix-dropdown-menu-content-available-height\){max-height:var(--radix-dropdown-menu-content-available-height)}.max-h-\(--radix-select-content-available-height\){max-height:var(--radix-select-content-available-height)}.max-h-40{max-height:calc(var(--spacing) * 40)}.max-h-48{max-height:calc(var(--spacing) * 48)}.max-h-64{max-height:calc(var(--spacing) * 64)}.max-h-96{max-height:calc(var(--spacing) * 96)}.min-h-9{min-height:calc(var(--spacing) * 9)}.min-h-16{min-height:calc(var(--spacing) * 16)}.min-h-screen{min-height:100vh}.w-3{width:calc(var(--spacing) * 3)}.w-6{width:calc(var(--spacing) * 6)}.w-8{width:calc(var(--spacing) * 8)}.w-10{width:calc(var(--spacing) * 10)}.w-12{width:calc(var(--spacing) * 12)}.w-16{width:calc(var(--spacing) * 16)}.w-20{width:calc(var(--spacing) * 20)}.w-24{width:calc(var(--spacing) * 24)}.w-26{width:calc(var(--spacing) * 26)}.w-31{width:calc(var(--spacing) * 31)}.w-32{width:calc(var(--spacing) * 32)}.w-36{width:calc(var(--spacing) * 36)}.w-40{width:calc(var(--spacing) * 40)}.w-44{width:calc(var(--spacing) * 44)}.w-56{width:calc(var(--spacing) * 56)}.w-64{width:calc(var(--spacing) * 64)}.w-auto{width:auto}.w-fit{width:fit-content}.w-full{width:100%}.max-w-6xl{max-width:var(--container-6xl)}.max-w-36{max-width:calc(var(--spacing) * 36)}.max-w-40{max-width:calc(var(--spacing) * 40)}.max-w-44{max-width:calc(var(--spacing) * 44)}.max-w-\[85\%\]{max-width:85%}.max-w-\[calc\(100\%-2rem\)\]{max-width:calc(100% - 2rem)}.max-w-full{max-width:100%}.max-w-sm{max-width:var(--container-sm)}.min-w-0{min-width:0}.min-w-32{min-width:calc(var(--spacing) * 32)}.min-w-36{min-width:calc(var(--spacing) * 36)}.min-w-48{min-width:calc(var(--spacing) * 48)}.min-w-\[8rem\]{min-width:8rem}.min-w-\[var\(--radix-select-trigger-width\)\]{min-width:var(--radix-select-trigger-width)}.flex-1{flex:1}.shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.caption-bottom{caption-side:bottom}.origin-\(--radix-dropdown-menu-content-transform-origin\){transform-origin:var(--radix-dropdown-menu-content-transform-origin)}.origin-\(--radix-select-content-transform-origin\){transform-origin:var(--radix-select-content-transform-origin)}.-translate-x-1\/2{--tw-translate-x: -50% ;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-x-\[-50\%\]{--tw-translate-x:-50%;translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-y-1\/2{--tw-translate-y: -50% ;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-y-\[-50\%\]{--tw-translate-y:-50%;translate:var(--tw-translate-x) var(--tw-translate-y)}.rotate-180{rotate:180deg}.animate-pulse{animation:var(--animate-pulse)}.animate-spin{animation:var(--animate-spin)}.cursor-default{cursor:default}.cursor-pointer{cursor:pointer}.scroll-my-1{scroll-margin-block:var(--spacing)}.auto-rows-min{grid-auto-rows:min-content}.\[grid-template-columns\:repeat\(auto-fit\,minmax\(260px\,1fr\)\)\]{grid-template-columns:repeat(auto-fit,minmax(260px,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-\[minmax\(6rem\,14rem\)_1fr_auto\]{grid-template-columns:minmax(6rem,14rem) 1fr auto}.grid-rows-\[auto_auto\]{grid-template-rows:auto auto}.flex-col{flex-direction:column}.flex-col-reverse{flex-direction:column-reverse}.flex-row{flex-direction:row}.flex-wrap{flex-wrap:wrap}.items-baseline{align-items:baseline}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-0\.5{gap:calc(var(--spacing) * .5)}.gap-1{gap:var(--spacing)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-6{gap:calc(var(--spacing) * 6)}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}.gap-x-4{column-gap:calc(var(--spacing) * 4)}.gap-x-6{column-gap:calc(var(--spacing) * 6)}.gap-x-8{column-gap:calc(var(--spacing) * 8)}.gap-y-1{row-gap:var(--spacing)}.gap-y-2{row-gap:calc(var(--spacing) * 2)}.gap-y-3{row-gap:calc(var(--spacing) * 3)}.gap-y-4{row-gap:calc(var(--spacing) * 4)}.self-start{align-self:flex-start}.justify-self-end{justify-self:flex-end}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.overflow-visible{overflow:visible}.overflow-x-auto{overflow-x:auto}.overflow-x-hidden{overflow-x:hidden}.overflow-y-auto{overflow-y:auto}.rounded{border-radius:.25rem}.rounded-\[4px\]{border-radius:4px}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:calc(var(--radius) + 2px)}.rounded-md{border-radius:var(--radius)}.rounded-sm{border-radius:calc(var(--radius) - 2px)}.rounded-xl{border-radius:calc(var(--radius) + 4px)}.rounded-xs{border-radius:var(--radius-xs)}.border{border-style:var(--tw-border-style);border-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-b-2{border-bottom-style:var(--tw-border-style);border-bottom-width:2px}.border-dashed{--tw-border-style:dashed;border-style:dashed}.border-border{border-color:rgb(var(--border))}.border-foreground{border-color:rgb(var(--foreground))}.border-input{border-color:rgb(var(--input))}.border-primary\/40{border-color:rgb(var(--primary))}@supports (color:color-mix(in lab,red,red)){.border-primary\/40{border-color:color-mix(in oklab,rgb(var(--primary)) 40%,transparent)}}.border-transparent{border-color:#0000}.bg-\[\#1a7f37\]{background-color:#1a7f37}.bg-\[\#3b82f6\]{background-color:#3b82f6}.bg-\[\#4b5563\]{background-color:#4b5563}.bg-\[\#9a6700\]{background-color:#9a6700}.bg-\[\#22c55e\]{background-color:#22c55e}.bg-\[\#0969da\]{background-color:#0969da}.bg-\[\#a855f7\]{background-color:#a855f7}.bg-\[\#f97316\]{background-color:#f97316}.bg-\[rgb\(var\(--border\)\)\]{background-color:rgb(var(--border))}.bg-accent{background-color:rgb(var(--accent))}.bg-background{background-color:rgb(var(--background))}.bg-black\/50{background-color:#00000080}@supports (color:color-mix(in lab,red,red)){.bg-black\/50{background-color:color-mix(in oklab,var(--color-black) 50%,transparent)}}.bg-border{background-color:rgb(var(--border))}.bg-card{background-color:rgb(var(--card))}.bg-chart-1{background-color:rgb(var(--chart-1))}.bg-chart-2{background-color:rgb(var(--chart-2))}.bg-chart-3{background-color:rgb(var(--chart-3))}.bg-chart-5{background-color:rgb(var(--chart-5))}.bg-destructive{background-color:rgb(var(--destructive))}.bg-foreground\/5{background-color:rgb(var(--foreground))}@supports (color:color-mix(in lab,red,red)){.bg-foreground\/5{background-color:color-mix(in oklab,rgb(var(--foreground)) 5%,transparent)}}.bg-green-500{background-color:var(--color-green-500)}.bg-muted{background-color:rgb(var(--muted))}.bg-muted-foreground{background-color:rgb(var(--muted-foreground))}.bg-muted\/30{background-color:rgb(var(--muted))}@supports (color:color-mix(in lab,red,red)){.bg-muted\/30{background-color:color-mix(in oklab,rgb(var(--muted)) 30%,transparent)}}.bg-muted\/50{background-color:rgb(var(--muted))}@supports (color:color-mix(in lab,red,red)){.bg-muted\/50{background-color:color-mix(in oklab,rgb(var(--muted)) 50%,transparent)}}.bg-popover{background-color:rgb(var(--popover))}.bg-primary,.bg-primary\/5{background-color:rgb(var(--primary))}@supports (color:color-mix(in lab,red,red)){.bg-primary\/5{background-color:color-mix(in oklab,rgb(var(--primary)) 5%,transparent)}}.bg-red-500{background-color:var(--color-red-500)}.bg-secondary{background-color:rgb(var(--secondary))}.bg-transparent{background-color:#0000}.bg-yellow-500{background-color:var(--color-yellow-500)}.fill-border{fill:rgb(var(--border))}.fill-chart-1{fill:rgb(var(--chart-1))}.fill-chart-2{fill:rgb(var(--chart-2))}.fill-chart-3{fill:rgb(var(--chart-3))}.fill-chart-5{fill:rgb(var(--chart-5))}.fill-current{fill:currentColor}.fill-foreground,.fill-foreground\/5{fill:rgb(var(--foreground))}@supports (color:color-mix(in lab,red,red)){.fill-foreground\/5{fill:color-mix(in oklab,rgb(var(--foreground)) 5%,transparent)}}.fill-muted-foreground{fill:rgb(var(--muted-foreground))}.stroke-border{stroke:rgb(var(--border))}.object-cover{object-fit:cover}.p-1{padding:var(--spacing)}.p-2{padding:calc(var(--spacing) * 2)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-6{padding:calc(var(--spacing) * 6)}.p-\[3px\]{padding:3px}.px-0\.5{padding-inline:calc(var(--spacing) * .5)}.px-1{padding-inline:var(--spacing)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-6{padding-inline:calc(var(--spacing) * 6)}.py-0{padding-block:0}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:var(--spacing)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-8{padding-block:calc(var(--spacing) * 8)}.py-12{padding-block:calc(var(--spacing) * 12)}.pt-4{padding-top:calc(var(--spacing) * 4)}.pr-0{padding-right:0}.pr-1{padding-right:var(--spacing)}.pr-2{padding-right:calc(var(--spacing) * 2)}.pr-6{padding-right:calc(var(--spacing) * 6)}.pr-8{padding-right:calc(var(--spacing) * 8)}.pr-14{padding-right:calc(var(--spacing) * 14)}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.pb-3{padding-bottom:calc(var(--spacing) * 3)}.pl-2{padding-left:calc(var(--spacing) * 2)}.pl-8{padding-left:calc(var(--spacing) * 8)}.pl-\[18px\]{padding-left:18px}.pl-\[26px\]{padding-left:26px}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.align-middle{vertical-align:middle}.align-top{vertical-align:top}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace}.font-sans{font-family:Aspekta,ui-sans-serif,system-ui,sans-serif}.font-serif{font-family:"Source Serif 4",Source Serif Pro,Georgia,serif}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-sm\/relaxed{font-size:var(--text-sm);line-height:var(--leading-relaxed)}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[10px\]{font-size:10px}.leading-none{--tw-leading:1;line-height:1}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-widest{--tw-tracking:var(--tracking-widest);letter-spacing:var(--tracking-widest)}.text-balance{text-wrap:balance}.wrap-anywhere{overflow-wrap:anywhere}.break-all{word-break:break-all}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.text-accent-foreground{color:rgb(var(--accent-foreground))}.text-card-foreground{color:rgb(var(--card-foreground))}.text-current{color:currentColor}.text-destructive{color:rgb(var(--destructive))}.text-foreground{color:rgb(var(--foreground))}.text-muted-foreground{color:rgb(var(--muted-foreground))}.text-popover-foreground{color:rgb(var(--popover-foreground))}.text-primary{color:rgb(var(--primary))}.text-primary-foreground{color:rgb(var(--primary-foreground))}.text-secondary-foreground{color:rgb(var(--secondary-foreground))}.text-white{color:var(--color-white)}.text-yellow-600{color:var(--color-yellow-600)}.lowercase{text-transform:lowercase}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.underline-offset-2{text-underline-offset:2px}.underline-offset-4{text-underline-offset:4px}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a), 0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-xs{--tw-shadow:0 1px 2px 0 var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring-0{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.outline-hidden{--tw-outline-style:none;outline-style:none}@media(forced-colors:active){.outline-hidden{outline-offset:2px;outline:2px solid #0000}}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.transition-\[color\,box-shadow\]{transition-property:color,box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-shadow{transition-property:box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-none{transition-property:none}.duration-200{--tw-duration:.2s;transition-duration:.2s}.outline-none{--tw-outline-style:none;outline-style:none}.select-none{-webkit-user-select:none;user-select:none}.running{animation-play-state:running}.group-data-\[disabled\=true\]\:pointer-events-none:is(:where(.group)[data-disabled=true] *){pointer-events:none}.group-data-\[disabled\=true\]\:opacity-50:is(:where(.group)[data-disabled=true] *){opacity:.5}.peer-disabled\:cursor-not-allowed:is(:where(.peer):disabled~*){cursor:not-allowed}.peer-disabled\:opacity-50:is(:where(.peer):disabled~*){opacity:.5}.selection\:bg-primary ::selection{background-color:rgb(var(--primary))}.selection\:bg-primary::selection{background-color:rgb(var(--primary))}.selection\:text-primary-foreground ::selection{color:rgb(var(--primary-foreground))}.selection\:text-primary-foreground::selection{color:rgb(var(--primary-foreground))}.file\:inline-flex::file-selector-button{display:inline-flex}.file\:h-7::file-selector-button{height:calc(var(--spacing) * 7)}.file\:border-0::file-selector-button{border-style:var(--tw-border-style);border-width:0}.file\:bg-transparent::file-selector-button{background-color:#0000}.file\:text-sm::file-selector-button{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.file\:font-medium::file-selector-button{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.file\:text-foreground::file-selector-button{color:rgb(var(--foreground))}.placeholder\:text-muted-foreground::placeholder{color:rgb(var(--muted-foreground))}@media(hover:hover){.hover\:bg-accent:hover{background-color:rgb(var(--accent))}.hover\:bg-destructive\/90:hover{background-color:rgb(var(--destructive))}@supports (color:color-mix(in lab,red,red)){.hover\:bg-destructive\/90:hover{background-color:color-mix(in oklab,rgb(var(--destructive)) 90%,transparent)}}.hover\:bg-muted\/30:hover{background-color:rgb(var(--muted))}@supports (color:color-mix(in lab,red,red)){.hover\:bg-muted\/30:hover{background-color:color-mix(in oklab,rgb(var(--muted)) 30%,transparent)}}.hover\:bg-muted\/50:hover{background-color:rgb(var(--muted))}@supports (color:color-mix(in lab,red,red)){.hover\:bg-muted\/50:hover{background-color:color-mix(in oklab,rgb(var(--muted)) 50%,transparent)}}.hover\:bg-primary\/90:hover{background-color:rgb(var(--primary))}@supports (color:color-mix(in lab,red,red)){.hover\:bg-primary\/90:hover{background-color:color-mix(in oklab,rgb(var(--primary)) 90%,transparent)}}.hover\:bg-secondary\/80:hover{background-color:rgb(var(--secondary))}@supports (color:color-mix(in lab,red,red)){.hover\:bg-secondary\/80:hover{background-color:color-mix(in oklab,rgb(var(--secondary)) 80%,transparent)}}.hover\:text-accent-foreground:hover{color:rgb(var(--accent-foreground))}.hover\:text-foreground:hover{color:rgb(var(--foreground))}.hover\:underline:hover{text-decoration-line:underline}}.focus\:bg-accent:focus{background-color:rgb(var(--accent))}.focus\:text-accent-foreground:focus{color:rgb(var(--accent-foreground))}.focus-visible\:border-ring:focus-visible{border-color:rgb(var(--ring))}.focus-visible\:stroke-ring:focus-visible{stroke:rgb(var(--ring))}.focus-visible\:\[stroke-width\:2\]:focus-visible{stroke-width:2px}.focus-visible\:ring-2:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-visible\:ring-\[3px\]:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-visible\:ring-destructive\/20:focus-visible{--tw-ring-color:rgb(var(--destructive))}@supports (color:color-mix(in lab,red,red)){.focus-visible\:ring-destructive\/20:focus-visible{--tw-ring-color:color-mix(in oklab, rgb(var(--destructive)) 20%, transparent)}}.focus-visible\:ring-ring:focus-visible,.focus-visible\:ring-ring\/50:focus-visible{--tw-ring-color:rgb(var(--ring))}@supports (color:color-mix(in lab,red,red)){.focus-visible\:ring-ring\/50:focus-visible{--tw-ring-color:color-mix(in oklab, rgb(var(--ring)) 50%, transparent)}}.focus-visible\:outline-1:focus-visible{outline-style:var(--tw-outline-style);outline-width:1px}.focus-visible\:outline-ring:focus-visible{outline-color:rgb(var(--ring))}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-50:disabled{opacity:.5}.has-data-\[slot\=card-action\]\:grid-cols-\[1fr_auto\]:has([data-slot=card-action]){grid-template-columns:1fr auto}.has-\[\>svg\]\:px-2\.5:has(>svg){padding-inline:calc(var(--spacing) * 2.5)}.has-\[\>svg\]\:px-3:has(>svg){padding-inline:calc(var(--spacing) * 3)}.has-\[\>svg\]\:px-4:has(>svg){padding-inline:calc(var(--spacing) * 4)}.aria-invalid\:border-destructive[aria-invalid=true]{border-color:rgb(var(--destructive))}.aria-invalid\:ring-destructive\/20[aria-invalid=true]{--tw-ring-color:rgb(var(--destructive))}@supports (color:color-mix(in lab,red,red)){.aria-invalid\:ring-destructive\/20[aria-invalid=true]{--tw-ring-color:color-mix(in oklab, rgb(var(--destructive)) 20%, transparent)}}.data-\[disabled\]\:pointer-events-none[data-disabled]{pointer-events:none}.data-\[disabled\]\:opacity-50[data-disabled]{opacity:.5}.data-\[inset\]\:pl-8[data-inset]{padding-left:calc(var(--spacing) * 8)}.data-\[orientation\=horizontal\]\:h-px[data-orientation=horizontal]{height:1px}.data-\[orientation\=horizontal\]\:w-full[data-orientation=horizontal]{width:100%}.data-\[orientation\=vertical\]\:h-full[data-orientation=vertical]{height:100%}.data-\[orientation\=vertical\]\:w-px[data-orientation=vertical]{width:1px}.data-\[placeholder\]\:text-muted-foreground[data-placeholder]{color:rgb(var(--muted-foreground))}.data-\[side\=bottom\]\:translate-y-1[data-side=bottom]{--tw-translate-y:var(--spacing);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[side\=bottom\]\:slide-in-from-top-2[data-side=bottom]{--tw-enter-translate-y:calc(2*var(--spacing)*-1)}.data-\[side\=left\]\:-translate-x-1[data-side=left]{--tw-translate-x:calc(var(--spacing) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[side\=left\]\:slide-in-from-right-2[data-side=left]{--tw-enter-translate-x:calc(2*var(--spacing))}.data-\[side\=right\]\:translate-x-1[data-side=right]{--tw-translate-x:var(--spacing);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[side\=right\]\:slide-in-from-left-2[data-side=right]{--tw-enter-translate-x:calc(2*var(--spacing)*-1)}.data-\[side\=top\]\:-translate-y-1[data-side=top]{--tw-translate-y:calc(var(--spacing) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[side\=top\]\:slide-in-from-bottom-2[data-side=top]{--tw-enter-translate-y:calc(2*var(--spacing))}.data-\[size\=default\]\:h-9[data-size=default]{height:calc(var(--spacing) * 9)}.data-\[size\=sm\]\:h-8[data-size=sm]{height:calc(var(--spacing) * 8)}:is(.\*\:data-\[slot\=select-value\]\:line-clamp-1>*)[data-slot=select-value]{-webkit-line-clamp:1;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}:is(.\*\:data-\[slot\=select-value\]\:flex>*)[data-slot=select-value]{display:flex}:is(.\*\:data-\[slot\=select-value\]\:items-center>*)[data-slot=select-value]{align-items:center}:is(.\*\:data-\[slot\=select-value\]\:gap-2>*)[data-slot=select-value]{gap:calc(var(--spacing) * 2)}.data-\[state\=active\]\:bg-card[data-state=active]{background-color:rgb(var(--card))}.data-\[state\=active\]\:shadow-sm[data-state=active]{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.data-\[state\=checked\]\:translate-x-\[calc\(100\%-2px\)\][data-state=checked]{--tw-translate-x: calc(100% - 2px) ;translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[state\=checked\]\:border-primary[data-state=checked]{border-color:rgb(var(--primary))}.data-\[state\=checked\]\:bg-primary[data-state=checked]{background-color:rgb(var(--primary))}.data-\[state\=checked\]\:text-primary-foreground[data-state=checked]{color:rgb(var(--primary-foreground))}.data-\[state\=closed\]\:animate-out[data-state=closed]{animation:exit var(--tw-animation-duration,var(--tw-duration,.15s))var(--tw-ease,ease)var(--tw-animation-delay,0s)var(--tw-animation-iteration-count,1)var(--tw-animation-direction,normal)var(--tw-animation-fill-mode,forwards)}.data-\[state\=closed\]\:fade-out-0[data-state=closed]{--tw-exit-opacity:0}.data-\[state\=closed\]\:zoom-out-95[data-state=closed]{--tw-exit-scale:.95}.data-\[state\=inactive\]\:hidden[data-state=inactive]{display:none}.data-\[state\=open\]\:animate-in[data-state=open]{animation:enter var(--tw-animation-duration,var(--tw-duration,.15s))var(--tw-ease,ease)var(--tw-animation-delay,0s)var(--tw-animation-iteration-count,1)var(--tw-animation-direction,normal)var(--tw-animation-fill-mode,forwards)}.data-\[state\=open\]\:bg-accent[data-state=open]{background-color:rgb(var(--accent))}.data-\[state\=open\]\:text-accent-foreground[data-state=open]{color:rgb(var(--accent-foreground))}.data-\[state\=open\]\:fade-in-0[data-state=open]{--tw-enter-opacity:0}.data-\[state\=open\]\:zoom-in-95[data-state=open]{--tw-enter-scale:.95}.data-\[state\=selected\]\:bg-muted[data-state=selected]{background-color:rgb(var(--muted))}.data-\[state\=unchecked\]\:translate-x-0[data-state=unchecked]{--tw-translate-x:0px;translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[state\=unchecked\]\:bg-\[\#7F7F79\][data-state=unchecked]{background-color:#7f7f79}.data-\[variant\=destructive\]\:text-destructive[data-variant=destructive]{color:rgb(var(--destructive))}.data-\[variant\=destructive\]\:focus\:bg-destructive\/10[data-variant=destructive]:focus{background-color:rgb(var(--destructive))}@supports (color:color-mix(in lab,red,red)){.data-\[variant\=destructive\]\:focus\:bg-destructive\/10[data-variant=destructive]:focus{background-color:color-mix(in oklab,rgb(var(--destructive)) 10%,transparent)}}.data-\[variant\=destructive\]\:focus\:text-destructive[data-variant=destructive]:focus{color:rgb(var(--destructive))}@media(min-width:40rem){.sm\:col-span-2{grid-column:span 2/span 2}.sm\:max-w-lg{max-width:var(--container-lg)}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:justify-end{justify-content:flex-end}.sm\:text-left{text-align:left}}@media(min-width:48rem){.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:p-12{padding:calc(var(--spacing) * 12)}.md\:text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}}@media(min-width:64rem){.lg\:col-span-2{grid-column:span 2/span 2}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}.dark\:border-input:is(.dark *){border-color:rgb(var(--input))}.dark\:bg-chart-4:is(.dark *){background-color:rgb(var(--chart-4))}.dark\:bg-destructive\/60:is(.dark *){background-color:rgb(var(--destructive))}@supports (color:color-mix(in lab,red,red)){.dark\:bg-destructive\/60:is(.dark *){background-color:color-mix(in oklab,rgb(var(--destructive)) 60%,transparent)}}.dark\:bg-input\/30:is(.dark *){background-color:rgb(var(--input))}@supports (color:color-mix(in lab,red,red)){.dark\:bg-input\/30:is(.dark *){background-color:color-mix(in oklab,rgb(var(--input)) 30%,transparent)}}.dark\:fill-chart-4:is(.dark *){fill:rgb(var(--chart-4))}.dark\:text-muted-foreground:is(.dark *){color:rgb(var(--muted-foreground))}.dark\:text-yellow-400:is(.dark *){color:var(--color-yellow-400)}@media(hover:hover){.dark\:hover\:bg-accent\/50:is(.dark *):hover{background-color:rgb(var(--accent))}@supports (color:color-mix(in lab,red,red)){.dark\:hover\:bg-accent\/50:is(.dark *):hover{background-color:color-mix(in oklab,rgb(var(--accent)) 50%,transparent)}}.dark\:hover\:bg-input\/50:is(.dark *):hover{background-color:rgb(var(--input))}@supports (color:color-mix(in lab,red,red)){.dark\:hover\:bg-input\/50:is(.dark *):hover{background-color:color-mix(in oklab,rgb(var(--input)) 50%,transparent)}}}.dark\:focus-visible\:ring-destructive\/40:is(.dark *):focus-visible{--tw-ring-color:rgb(var(--destructive))}@supports (color:color-mix(in lab,red,red)){.dark\:focus-visible\:ring-destructive\/40:is(.dark *):focus-visible{--tw-ring-color:color-mix(in oklab, rgb(var(--destructive)) 40%, transparent)}}.dark\:aria-invalid\:ring-destructive\/40:is(.dark *)[aria-invalid=true]{--tw-ring-color:rgb(var(--destructive))}@supports (color:color-mix(in lab,red,red)){.dark\:aria-invalid\:ring-destructive\/40:is(.dark *)[aria-invalid=true]{--tw-ring-color:color-mix(in oklab, rgb(var(--destructive)) 40%, transparent)}}.dark\:data-\[state\=active\]\:border-input:is(.dark *)[data-state=active]{border-color:rgb(var(--input))}.dark\:data-\[state\=active\]\:bg-input\/30:is(.dark *)[data-state=active]{background-color:rgb(var(--input))}@supports (color:color-mix(in lab,red,red)){.dark\:data-\[state\=active\]\:bg-input\/30:is(.dark *)[data-state=active]{background-color:color-mix(in oklab,rgb(var(--input)) 30%,transparent)}}.dark\:data-\[state\=active\]\:text-foreground:is(.dark *)[data-state=active]{color:rgb(var(--foreground))}.dark\:data-\[state\=checked\]\:bg-primary:is(.dark *)[data-state=checked]{background-color:rgb(var(--primary))}.dark\:data-\[state\=checked\]\:bg-primary-foreground:is(.dark *)[data-state=checked]{background-color:rgb(var(--primary-foreground))}.dark\:data-\[state\=unchecked\]\:bg-foreground:is(.dark *)[data-state=unchecked]{background-color:rgb(var(--foreground))}.dark\:data-\[state\=unchecked\]\:bg-input:is(.dark *)[data-state=unchecked]{background-color:rgb(var(--input))}.dark\:data-\[variant\=destructive\]\:focus\:bg-destructive\/20:is(.dark *)[data-variant=destructive]:focus{background-color:rgb(var(--destructive))}@supports (color:color-mix(in lab,red,red)){.dark\:data-\[variant\=destructive\]\:focus\:bg-destructive\/20:is(.dark *)[data-variant=destructive]:focus{background-color:color-mix(in oklab,rgb(var(--destructive)) 20%,transparent)}}.\[\&_svg\]\:pointer-events-none svg{pointer-events:none}.\[\&_svg\]\:shrink-0 svg{flex-shrink:0}.\[\&_svg\:not\(\[class\*\=\'size-\'\]\)\]\:size-4 svg:not([class*=size-]){width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.\[\&_svg\:not\(\[class\*\=\'size-\'\]\)\]\:size-6 svg:not([class*=size-]){width:calc(var(--spacing) * 6);height:calc(var(--spacing) * 6)}.\[\&_svg\:not\(\[class\*\=\'text-\'\]\)\]\:text-muted-foreground svg:not([class*=text-]){color:rgb(var(--muted-foreground))}.\[\&_tr\]\:border-b tr{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.\[\&_tr\:last-child\]\:border-0 tr:last-child{border-style:var(--tw-border-style);border-width:0}.\[\&\:has\(\[role\=checkbox\]\)\]\:pr-0:has([role=checkbox]){padding-right:0}.\[\.border-b\]\:pb-6.border-b{padding-bottom:calc(var(--spacing) * 6)}.\[\.border-t\]\:pt-6.border-t{padding-top:calc(var(--spacing) * 6)}:is(.\*\:\[span\]\:last\:flex>*):is(span):last-child{display:flex}:is(.\*\:\[span\]\:last\:items-center>*):is(span):last-child{align-items:center}:is(.\*\:\[span\]\:last\:gap-2>*):is(span):last-child{gap:calc(var(--spacing) * 2)}:is(.data-\[variant\=destructive\]\:\*\:\[svg\]\:\!text-destructive[data-variant=destructive]>*):is(svg){color:rgb(var(--destructive))!important}.\[\&\>\[role\=checkbox\]\]\:translate-y-\[2px\]>[role=checkbox]{--tw-translate-y:2px;translate:var(--tw-translate-x) var(--tw-translate-y)}.\[\&\>a\]\:underline>a{text-decoration-line:underline}.\[\&\>a\]\:underline-offset-4>a{text-underline-offset:4px}.\[\&\>a\:hover\]\:text-primary>a:hover{color:rgb(var(--primary))}.\[\&\>svg\]\:pointer-events-none>svg{pointer-events:none}.\[\&\>svg\]\:size-3>svg{width:calc(var(--spacing) * 3);height:calc(var(--spacing) * 3)}.\[\&\>svg\]\:size-3\.5>svg{width:calc(var(--spacing) * 3.5);height:calc(var(--spacing) * 3.5)}.\[\&\>tr\]\:last\:border-b-0>tr:last-child{border-bottom-style:var(--tw-border-style);border-bottom-width:0}@media(hover:hover){a.\[a\&\]\:hover\:bg-\[\#1a7f37\]\/90:hover{background-color:#1a7f37e6}a.\[a\&\]\:hover\:bg-\[\#3b82f6\]\/90:hover{background-color:#3b82f6e6}a.\[a\&\]\:hover\:bg-\[\#4b5563\]\/90:hover{background-color:#4b5563e6}a.\[a\&\]\:hover\:bg-\[\#9a6700\]\/90:hover{background-color:#9a6700e6}a.\[a\&\]\:hover\:bg-\[\#22c55e\]\/90:hover{background-color:#22c55ee6}a.\[a\&\]\:hover\:bg-\[\#0969da\]\/90:hover{background-color:#0969dae6}a.\[a\&\]\:hover\:bg-\[\#a855f7\]\/90:hover{background-color:#a855f7e6}a.\[a\&\]\:hover\:bg-\[\#f97316\]\/90:hover{background-color:#f97316e6}a.\[a\&\]\:hover\:bg-accent\/10:hover{background-color:rgb(var(--accent))}@supports (color:color-mix(in lab,red,red)){a.\[a\&\]\:hover\:bg-accent\/10:hover{background-color:color-mix(in oklab,rgb(var(--accent)) 10%,transparent)}}a.\[a\&\]\:hover\:bg-destructive\/90:hover{background-color:rgb(var(--destructive))}@supports (color:color-mix(in lab,red,red)){a.\[a\&\]\:hover\:bg-destructive\/90:hover{background-color:color-mix(in oklab,rgb(var(--destructive)) 90%,transparent)}}a.\[a\&\]\:hover\:bg-muted:hover,a.\[a\&\]\:hover\:bg-muted\/80:hover{background-color:rgb(var(--muted))}@supports (color:color-mix(in lab,red,red)){a.\[a\&\]\:hover\:bg-muted\/80:hover{background-color:color-mix(in oklab,rgb(var(--muted)) 80%,transparent)}}a.\[a\&\]\:hover\:bg-primary\/90:hover{background-color:rgb(var(--primary))}@supports (color:color-mix(in lab,red,red)){a.\[a\&\]\:hover\:bg-primary\/90:hover{background-color:color-mix(in oklab,rgb(var(--primary)) 90%,transparent)}}a.\[a\&\]\:hover\:bg-secondary\/90:hover{background-color:rgb(var(--secondary))}@supports (color:color-mix(in lab,red,red)){a.\[a\&\]\:hover\:bg-secondary\/90:hover{background-color:color-mix(in oklab,rgb(var(--secondary)) 90%,transparent)}}a.\[a\&\]\:hover\:text-accent-foreground:hover{color:rgb(var(--accent-foreground))}}}@property --tw-animation-delay{syntax:"*";inherits:false;initial-value:0s}@property --tw-animation-direction{syntax:"*";inherits:false;initial-value:normal}@property --tw-animation-duration{syntax:"*";inherits:false}@property --tw-animation-fill-mode{syntax:"*";inherits:false;initial-value:forwards}@property --tw-animation-iteration-count{syntax:"*";inherits:false;initial-value:1}@property --tw-enter-opacity{syntax:"*";inherits:false;initial-value:1}@property --tw-enter-rotate{syntax:"*";inherits:false;initial-value:0}@property --tw-enter-scale{syntax:"*";inherits:false;initial-value:1}@property --tw-enter-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-enter-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-opacity{syntax:"*";inherits:false;initial-value:1}@property --tw-exit-rotate{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-scale{syntax:"*";inherits:false;initial-value:1}@property --tw-exit-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-translate-y{syntax:"*";inherits:false;initial-value:0}@font-face{font-family:Aspekta;font-style:normal;font-weight:400;font-display:swap;src:url(/fonts/Aspekta-400.woff2)format("woff2")}@font-face{font-family:Aspekta;font-style:normal;font-weight:500;font-display:swap;src:url(/fonts/Aspekta-500.woff2)format("woff2")}@font-face{font-family:Aspekta;font-style:normal;font-weight:600;font-display:swap;src:url(/fonts/Aspekta-600.woff2)format("woff2")}@font-face{font-family:Aspekta;font-style:normal;font-weight:700;font-display:swap;src:url(/fonts/Aspekta-700.woff2)format("woff2")}:root{--background:240 240 236;--foreground:22 22 20;--card:249 249 247;--card-foreground:22 22 20;--popover:249 249 247;--popover-foreground:22 22 20;--primary:217 94 42;--primary-foreground:249 249 247;--secondary:240 240 236;--secondary-foreground:47 47 44;--muted:240 240 236;--muted-foreground:87 87 83;--accent:221 221 215;--accent-foreground:22 22 20;--destructive:180 50 50;--destructive-foreground:249 249 247;--border:196 196 189;--input:196 196 189;--ring:217 94 42;--chart-1:217 94 42;--chart-2:87 87 83;--chart-3:127 127 121;--chart-4:166 166 159;--chart-5:47 47 44;--radius:.375rem}.dark{--background:22 22 20;--foreground:249 249 247;--card:47 47 44;--card-foreground:249 249 247;--popover:47 47 44;--popover-foreground:249 249 247;--primary:217 94 42;--primary-foreground:249 249 247;--secondary:87 87 83;--secondary-foreground:249 249 247;--muted:87 87 83;--muted-foreground:196 196 189;--accent:87 87 83;--accent-foreground:249 249 247;--destructive:180 50 50;--destructive-foreground:249 249 247;--border:87 87 83;--input:87 87 83;--ring:217 94 42;--chart-1:217 94 42;--chart-2:196 196 189;--chart-3:166 166 159;--chart-4:127 127 121;--chart-5:240 240 236}.chat-markdown{overflow-wrap:break-word;line-height:1.55}.chat-markdown>:first-child{margin-top:0}.chat-markdown>:last-child{margin-bottom:0}.chat-markdown p{margin:.5em 0}.chat-markdown h1,.chat-markdown h2,.chat-markdown h3,.chat-markdown h4,.chat-markdown h5,.chat-markdown h6{margin:1em 0 .4em;font-size:1em;font-weight:600;line-height:1.3}.chat-markdown h1{font-size:1.15em}.chat-markdown h2{font-size:1.08em}.chat-markdown ul,.chat-markdown ol{margin:.5em 0;padding-left:1.4em}.chat-markdown ul{list-style:outside}.chat-markdown ol{list-style:decimal}.chat-markdown li,.chat-markdown li>ul,.chat-markdown li>ol{margin:.2em 0}.chat-markdown li::marker{color:rgb(var(--muted-foreground))}.chat-markdown code{font-family:var(--font-mono);background:rgb(var(--muted));border:1px solid rgb(var(--border) / .6);border-radius:4px;padding:.1em .35em;font-size:.9em}.chat-markdown pre{background:rgb(var(--muted));border:1px solid rgb(var(--border) / .6);border-radius:var(--radius);margin:.6em 0;padding:.6em .75em;overflow-x:auto}.chat-markdown pre code{background:0 0;border:none;padding:0;font-size:.85em;line-height:1.5}.chat-markdown blockquote{border-left:3px solid rgb(var(--border));color:rgb(var(--muted-foreground));margin:.6em 0;padding:.1em 0 .1em .9em}.chat-markdown a{color:rgb(var(--primary));text-underline-offset:2px;text-decoration:underline}.chat-markdown table{border-collapse:collapse;margin:.6em 0;font-size:.95em;display:block;overflow-x:auto}.chat-markdown th,.chat-markdown td{border:1px solid rgb(var(--border));text-align:left;padding:4px 10px}.chat-markdown th{background:rgb(var(--muted));font-weight:600}.chat-markdown hr{border:none;border-top:1px solid rgb(var(--border));margin:1em 0}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@keyframes spin{to{transform:rotate(360deg)}}@keyframes pulse{50%{opacity:.5}}@keyframes enter{0%{opacity:var(--tw-enter-opacity,1);transform:translate3d(var(--tw-enter-translate-x,0),var(--tw-enter-translate-y,0),0)scale3d(var(--tw-enter-scale,1),var(--tw-enter-scale,1),var(--tw-enter-scale,1))rotate(var(--tw-enter-rotate,0))}}@keyframes exit{to{opacity:var(--tw-exit-opacity,1);transform:translate3d(var(--tw-exit-translate-x,0),var(--tw-exit-translate-y,0),0)scale3d(var(--tw-exit-scale,1),var(--tw-exit-scale,1),var(--tw-exit-scale,1))rotate(var(--tw-exit-rotate,0))}} diff --git a/ui/src/lib/strategies.ts b/ui/src/lib/strategies.ts index 441f426..0b0965f 100644 --- a/ui/src/lib/strategies.ts +++ b/ui/src/lib/strategies.ts @@ -1,10 +1,15 @@ export const STRATEGIES = [ { value: 'failover', label: 'Failover', hint: 'Use the first target. Try the next one if it fails.' }, { value: 'round_robin', label: 'Round robin', hint: 'Send each request to the next target in turn.' }, - { value: 'weighted', label: 'Weighted', hint: 'Spread requests by weight.' }, + { value: 'weighted', label: 'Weighted', hint: 'A higher weight gets more requests.' }, { value: 'least_busy', label: 'Least busy', hint: 'Send each request to the target with the fewest open requests.' }, ] export function strategyLabel(strategy: string | null): string { return STRATEGIES.find((s) => s.value === strategy)?.label ?? strategy ?? '' } + +export function weightShare(weight: number, weights: number[]): string { + const total = weights.reduce((sum, w) => sum + Math.max(w, 1), 0) + return `${Math.round((Math.max(weight, 1) / total) * 100)}%` +} diff --git a/ui/src/pages/Models.tsx b/ui/src/pages/Models.tsx index cfbd9d2..28d61a0 100644 --- a/ui/src/pages/Models.tsx +++ b/ui/src/pages/Models.tsx @@ -8,7 +8,7 @@ import { type Provider, } from '@/lib/api' import { useAsync } from '@/lib/useAsync' -import { STRATEGIES, strategyLabel } from '@/lib/strategies' +import { STRATEGIES, strategyLabel, weightShare } from '@/lib/strategies' import { Combobox, type ComboboxOption } from '@/components/Combobox' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' @@ -214,6 +214,12 @@ function filledTargets(targets: AliasTarget[]): AliasTarget[] { .filter((t) => t.alias !== '') } +// Weighted targets list the most used first. Other strategies keep their order. +function listedTargets(m: { strategy: string | null; targets: AliasTarget[] }): AliasTarget[] { + if (m.strategy !== 'weighted') return m.targets + return [...m.targets].sort((a, b) => b.weight - a.weight) +} + // An ordered list of targets. The strategy appears once there are two. function TargetsField({ id, @@ -245,7 +251,7 @@ function TargetsField({ {several && strategy === 'weighted' && ( <> - Weight + Weight )} @@ -278,6 +284,13 @@ function TargetsField({ } /> )} + {several && strategy === 'weighted' && ( + + {t.alias.trim() + ? weightShare(t.weight, filledTargets(targets).map((x) => x.weight)) + : ''} + + )} {targets.length > 1 && ( <>