diff --git a/src/app/(dashboard)/dashboard/combos/page.js b/src/app/(dashboard)/dashboard/combos/page.js index 591c8a72fc..92386ba8a5 100644 --- a/src/app/(dashboard)/dashboard/combos/page.js +++ b/src/app/(dashboard)/dashboard/combos/page.js @@ -278,7 +278,16 @@ function ComboCard({ combo, getCaps, activeProviders = [], copied, onCopy, onEdi layers
- {combo.name} +
+ {combo.name} + {combo.context_length && ( + + {combo.context_length >= 1000000 + ? `${(combo.context_length / 1000000).toFixed(1)}M ctx` + : `${(combo.context_length / 1000).toFixed(0)}k ctx`} + + )} +
{combo.models.length === 0 ? ( No models @@ -480,6 +489,7 @@ function ModelItem({ id, index, model, isFirst, isLast, onEdit, onMoveUp, onMove function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, kindFilter = null }) { // Initialize state with combo values - key prop on parent handles reset on remount const [name, setName] = useState(combo?.name || ""); + const [contextLength, setContextLength] = useState(combo?.context_length || ""); const [models, setModels] = useState(combo?.models || []); const [showModelSelect, setShowModelSelect] = useState(false); const [saving, setSaving] = useState(false); @@ -573,7 +583,11 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, kindF const handleSave = async () => { if (!validateName(name)) return; setSaving(true); - await onSave({ name: name.trim(), models }); + await onSave({ + name: name.trim(), + models, + context_length: contextLength ? Number(contextLength) : null + }); setSaving(false); }; @@ -601,6 +615,53 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, kindF

+ {/* Context Length */} +
+ setContextLength(e.target.value)} + placeholder="e.g. 1000000 (leave blank for auto)" + /> +
+ Presets: + {[ + { label: "128k", value: 128000 }, + { label: "256k", value: 256000 }, + { label: "512k", value: 512000 }, + { label: "1M", value: 1000000 }, + { label: "2M", value: 2000000 }, + ].map((p) => ( + + ))} + {contextLength && ( + + )} +
+

+ warning + Note: Advertised context window tokens via /v1/models. Real capacity depends on the underlying models. +

+
+ {/* Models */}
diff --git a/src/app/(dashboard)/dashboard/media-providers/combo/[id]/page.js b/src/app/(dashboard)/dashboard/media-providers/combo/[id]/page.js index aa6857e5b0..c040a0e8f8 100644 --- a/src/app/(dashboard)/dashboard/media-providers/combo/[id]/page.js +++ b/src/app/(dashboard)/dashboard/media-providers/combo/[id]/page.js @@ -90,7 +90,7 @@ function ComboProvidersCard({ providers, onAdd, onMove, onRemove }) { +
); @@ -122,6 +122,7 @@ export default function ComboDetailPage() { const [combo, setCombo] = useState(null); const [loading, setLoading] = useState(true); const [name, setName] = useState(""); + const [contextLength, setContextLength] = useState(""); const [nameError, setNameError] = useState(""); const [providers, setProviders] = useState([]); const [roundRobin, setRoundRobin] = useState(false); @@ -154,6 +155,7 @@ export default function ComboDetailPage() { const c = await comboRes.json(); setCombo(c); setName(c.name); + setContextLength(c.context_length || ""); setProviders(c.models || []); const s = settingsRes.ok ? await settingsRes.json() : {}; setRoundRobin(s.comboStrategies?.[c.name]?.fallbackStrategy === "round-robin"); @@ -189,6 +191,12 @@ export default function ComboDetailPage() { const ok = await saveCombo({ name }); if (ok) await fetchAll(); }; + const handleSaveContextLength = async () => { + const val = contextLength ? Number(contextLength) : null; + if (val === combo.context_length) return; + const ok = await saveCombo({ context_length: val }); + if (ok) await fetchAll(); + }; const handleAddModel = async (model) => { const value = model?.value || model; if (!value || providers.includes(value)) return; @@ -316,6 +324,53 @@ export default function ComboDetailPage() { { setName(e.target.value); validateName(e.target.value); }} onBlur={handleSaveName} error={nameError} />

Only letters, numbers, -, _ and .

+
+ setContextLength(e.target.value)} onBlur={handleSaveContextLength} placeholder="e.g. 1000000 (leave blank for auto)" /> +
+ Presets: + {[ + { label: "128k", value: 128000 }, + { label: "256k", value: 256000 }, + { label: "512k", value: 512000 }, + { label: "1M", value: 1000000 }, + { label: "2M", value: 2000000 }, + ].map((p) => ( + + ))} + {contextLength && ( + + )} +
+

+ warning + Note: Advertised context window tokens via /v1/models. Real capacity depends on the underlying models. +

+

Round Robin

@@ -411,4 +466,4 @@ export default function ComboDetailPage() { )}
); -} +} diff --git a/src/app/api/combos/[id]/route.js b/src/app/api/combos/[id]/route.js index af6dd70057..beaac01122 100644 --- a/src/app/api/combos/[id]/route.js +++ b/src/app/api/combos/[id]/route.js @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import { getComboById, updateCombo, deleteCombo, getComboByName } from "@/lib/localDb"; import { resetComboRotation } from "open-sse/services/combo.js"; +import { invalidateAllowedModelsCache } from "@/sse/services/allowedModels.js"; // Validate combo name: only a-z, A-Z, 0-9, -, _ const VALID_NAME_REGEX = /^[a-zA-Z0-9_.\-]+$/; @@ -49,9 +50,10 @@ export async function PUT(request, { params }) { return NextResponse.json({ error: "Combo not found" }, { status: 404 }); } - // Invalidate rotation state (models/strategy/name may have changed) + // Invalidate rotation state and models list cache (models/strategy/name/context_length may have changed) if (prev?.name) resetComboRotation(prev.name); if (combo.name && combo.name !== prev?.name) resetComboRotation(combo.name); + invalidateAllowedModelsCache(); return NextResponse.json(combo); } catch (error) { @@ -74,6 +76,7 @@ export async function DELETE(request, { params }) { } if (prev?.name) resetComboRotation(prev.name); + invalidateAllowedModelsCache(); return NextResponse.json({ success: true }); } catch (error) { diff --git a/src/app/api/combos/route.js b/src/app/api/combos/route.js index 38966ed140..e7f885fba7 100644 --- a/src/app/api/combos/route.js +++ b/src/app/api/combos/route.js @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; import { getCombos, createCombo, getComboByName } from "@/lib/localDb"; +import { invalidateAllowedModelsCache } from "@/sse/services/allowedModels.js"; export const dynamic = "force-dynamic"; @@ -21,7 +22,7 @@ export async function GET() { export async function POST(request) { try { const body = await request.json(); - const { name, models, kind } = body; + const { name, models, kind, context_length } = body; if (!name) { return NextResponse.json({ error: "Name is required" }, { status: 400 }); @@ -38,7 +39,8 @@ export async function POST(request) { return NextResponse.json({ error: "Combo name already exists" }, { status: 400 }); } - const combo = await createCombo({ name, models: models || [], kind: kind || null }); + const combo = await createCombo({ name, models: models || [], kind: kind || null, context_length: context_length ? Number(context_length) : null }); + invalidateAllowedModelsCache(); return NextResponse.json(combo, { status: 201 }); } catch (error) { diff --git a/src/lib/db/repos/combosRepo.js b/src/lib/db/repos/combosRepo.js index 3e85be9b53..209a5f608b 100644 --- a/src/lib/db/repos/combosRepo.js +++ b/src/lib/db/repos/combosRepo.js @@ -8,6 +8,7 @@ function rowToCombo(row) { id: row.id, name: row.name, kind: row.kind, + context_length: row.context_length ?? null, models: parseJson(row.models, []), createdAt: row.createdAt, updatedAt: row.updatedAt, @@ -39,13 +40,14 @@ export async function createCombo(data) { id: randomUUID(), name: data.name, kind: data.kind || null, + context_length: data.context_length || null, models: data.models || [], createdAt: now, updatedAt: now, }; db.run( - `INSERT INTO combos(id, name, kind, models, createdAt, updatedAt) VALUES(?, ?, ?, ?, ?, ?)`, - [combo.id, combo.name, combo.kind, stringifyJson(combo.models), combo.createdAt, combo.updatedAt] + `INSERT INTO combos(id, name, kind, context_length, models, createdAt, updatedAt) VALUES(?, ?, ?, ?, ?, ?, ?)`, + [combo.id, combo.name, combo.kind, combo.context_length, stringifyJson(combo.models), combo.createdAt, combo.updatedAt] ); return combo; } @@ -58,8 +60,8 @@ export async function updateCombo(id, data) { if (!row) return; const merged = { ...rowToCombo(row), ...data, updatedAt: new Date().toISOString() }; db.run( - `UPDATE combos SET name = ?, kind = ?, models = ?, updatedAt = ? WHERE id = ?`, - [merged.name, merged.kind, stringifyJson(merged.models || []), merged.updatedAt, id] + `UPDATE combos SET name = ?, kind = ?, context_length = ?, models = ?, updatedAt = ? WHERE id = ?`, + [merged.name, merged.kind, merged.context_length || null, stringifyJson(merged.models || []), merged.updatedAt, id] ); result = merged; }); diff --git a/src/lib/db/schema.js b/src/lib/db/schema.js index 095187814b..48cbc02984 100644 --- a/src/lib/db/schema.js +++ b/src/lib/db/schema.js @@ -91,6 +91,7 @@ export const TABLES = { id: "TEXT PRIMARY KEY", name: "TEXT UNIQUE NOT NULL", kind: "TEXT", + context_length: "INTEGER", models: "TEXT NOT NULL", createdAt: "TEXT NOT NULL", updatedAt: "TEXT NOT NULL", diff --git a/src/sse/services/allowedModels.js b/src/sse/services/allowedModels.js index 2fade87a31..367ca5ffe5 100644 --- a/src/sse/services/allowedModels.js +++ b/src/sse/services/allowedModels.js @@ -302,7 +302,19 @@ async function buildAllModelEntries(kindFilter, combos, customModels, modelAlias if (combo.kind === "webSearch" || combo.kind === "webFetch") { entry.kind = combo.kind; } + if (combo.context_length) { + entry.context_length = Number(combo.context_length); + } entries.push(entry); + + const bareEntry = { id: combo.name, object: "model", owned_by: "combo" }; + if (combo.kind === "webSearch" || combo.kind === "webFetch") { + bareEntry.kind = combo.kind; + } + if (combo.context_length) { + bareEntry.context_length = Number(combo.context_length); + } + entries.push(bareEntry); } if (!dbAvailable) { @@ -590,6 +602,7 @@ export async function buildModelsList(kindFilter, options = {}) { }; if (entry.kind) model.kind = entry.kind; if (entry.capabilities) model.capabilities = entry.capabilities; + if (entry.context_length) model.context_length = entry.context_length; dedupedModels.push(model); }