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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 63 additions & 2 deletions src/app/(dashboard)/dashboard/combos/page.js
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,16 @@ function ComboCard({ combo, getCaps, activeProviders = [], copied, onCopy, onEdi
<span className="material-symbols-outlined text-primary text-[18px]">layers</span>
</div>
<div className="min-w-0 flex-1">
<code className="block truncate font-mono text-sm font-medium">{combo.name}</code>
<div className="flex items-center gap-2">
<code className="block truncate font-mono text-sm font-medium">{combo.name}</code>
{combo.context_length && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-primary/10 text-primary font-mono font-medium shrink-0">
{combo.context_length >= 1000000
? `${(combo.context_length / 1000000).toFixed(1)}M ctx`
: `${(combo.context_length / 1000).toFixed(0)}k ctx`}
</span>
)}
</div>
<div className="mt-1 flex min-w-0 flex-wrap items-center gap-1">
{combo.models.length === 0 ? (
<span className="text-xs text-text-muted italic">No models</span>
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
};

Expand Down Expand Up @@ -601,6 +615,53 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, kindF
</p>
</div>

{/* Context Length */}
<div>
<Input
label="Advertised Context Length (optional)"
type="number"
value={contextLength}
onChange={(e) => setContextLength(e.target.value)}
placeholder="e.g. 1000000 (leave blank for auto)"
/>
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
<span className="text-[10px] text-text-muted">Presets:</span>
{[
{ label: "128k", value: 128000 },
{ label: "256k", value: 256000 },
{ label: "512k", value: 512000 },
{ label: "1M", value: 1000000 },
{ label: "2M", value: 2000000 },
].map((p) => (
<button
key={p.label}
type="button"
onClick={() => setContextLength(String(p.value))}
className={`px-1.5 py-0.5 rounded text-[10px] font-mono transition-colors border ${
Number(contextLength) === p.value
? "bg-primary/10 border-primary/40 text-primary font-medium"
: "bg-black/[0.03] dark:bg-white/[0.03] border-black/10 dark:border-white/10 text-text-muted hover:border-primary/40 hover:text-text-main"
}`}
>
{p.label}
</button>
))}
{contextLength && (
<button
type="button"
onClick={() => setContextLength("")}
className="px-1.5 py-0.5 rounded text-[10px] text-text-muted hover:text-red-500 transition-colors"
>
Clear
</button>
)}
</div>
<p className="text-[10px] text-amber-600 dark:text-amber-400 mt-1 flex items-center gap-1">
<span className="material-symbols-outlined text-[13px]">warning</span>
Note: Advertised context window tokens via /v1/models. Real capacity depends on the underlying models.
</p>
</div>

{/* Models */}
<div>
<label className="text-sm font-medium mb-1.5 block">Models</label>
Expand Down
59 changes: 57 additions & 2 deletions src/app/(dashboard)/dashboard/media-providers/combo/[id]/page.js
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ function ComboProvidersCard({ providers, onAdd, onMove, onRemove }) {
</button>
<button type="button" onClick={() => onRemove(idx)} className="p-1 rounded text-text-muted hover:text-red-500 hover:bg-red-500/10" title="Remove">
<span className="material-symbols-outlined text-[16px]">close</span>
</button>
</button>
</div>
</div>
);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -316,6 +324,53 @@ export default function ComboDetailPage() {
<Input label="Combo Name" value={name} onChange={(e) => { setName(e.target.value); validateName(e.target.value); }} onBlur={handleSaveName} error={nameError} />
<p className="text-[10px] text-text-muted mt-0.5">Only letters, numbers, -, _ and .</p>
</div>
<div>
<Input label="Advertised Context Length (optional)" type="number" value={contextLength} onChange={(e) => setContextLength(e.target.value)} onBlur={handleSaveContextLength} placeholder="e.g. 1000000 (leave blank for auto)" />
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
<span className="text-[10px] text-text-muted">Presets:</span>
{[
{ label: "128k", value: 128000 },
{ label: "256k", value: 256000 },
{ label: "512k", value: 512000 },
{ label: "1M", value: 1000000 },
{ label: "2M", value: 2000000 },
].map((p) => (
<button
key={p.label}
type="button"
onClick={async () => {
setContextLength(String(p.value));
await saveCombo({ context_length: p.value });
await fetchAll();
}}
className={`px-1.5 py-0.5 rounded text-[10px] font-mono transition-colors border ${
Number(contextLength) === p.value
? "bg-primary/10 border-primary/40 text-primary font-medium"
: "bg-black/[0.03] dark:bg-white/[0.03] border-black/10 dark:border-white/10 text-text-muted hover:border-primary/40 hover:text-text-main"
}`}
>
{p.label}
</button>
))}
{contextLength && (
<button
type="button"
onClick={async () => {
setContextLength("");
await saveCombo({ context_length: null });
await fetchAll();
}}
className="px-1.5 py-0.5 rounded text-[10px] text-text-muted hover:text-red-500 transition-colors"
>
Clear
</button>
)}
</div>
<p className="text-[10px] text-amber-600 dark:text-amber-400 mt-1 flex items-center gap-1">
<span className="material-symbols-outlined text-[13px]">warning</span>
Note: Advertised context window tokens via /v1/models. Real capacity depends on the underlying models.
</p>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">Round Robin</p>
Expand Down Expand Up @@ -411,4 +466,4 @@ export default function ComboDetailPage() {
)}
</div>
);
}
}
5 changes: 4 additions & 1 deletion src/app/api/combos/[id]/route.js
Original file line number Diff line number Diff line change
@@ -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_.\-]+$/;
Expand Down Expand Up @@ -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) {
Expand All @@ -74,6 +76,7 @@ export async function DELETE(request, { params }) {
}

if (prev?.name) resetComboRotation(prev.name);
invalidateAllowedModelsCache();

return NextResponse.json({ success: true });
} catch (error) {
Expand Down
6 changes: 4 additions & 2 deletions src/app/api/combos/route.js
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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 });
Expand All @@ -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) {
Expand Down
10 changes: 6 additions & 4 deletions src/lib/db/repos/combosRepo.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}
Expand All @@ -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;
});
Expand Down
1 change: 1 addition & 0 deletions src/lib/db/schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
13 changes: 13 additions & 0 deletions src/sse/services/allowedModels.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
}

Expand Down
Loading