Skip to content
Open
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ All notable changes to **omp-web** (`@kahme247/ompweb`) are documented in this f
### Fixed

- **Restore HTTP LAN and VPN access.** Browser UUID generation now works without secure-context-only `crypto.randomUUID()`, preventing the "Something went wrong" crash when opening the app or creating a session.
- **Select reasoning effort for fallback models.** Settings → Models → Native OMP Retry & Fallback now stores each fallback as `provider/model:effort`, while leaving the effort unset uses the model default.

## [v0.7.1] - 2026-11-14

Expand Down
22 changes: 19 additions & 3 deletions components/ModelsConfig-panels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,12 +87,17 @@ export function RetryFallbackDetail({ models }: { models: RuntimeModelEntry[] })
const [settings, setSettings] = useState<RetrySettings | null>(null);
const [role, setRole] = useState("default");
const [candidate, setCandidate] = useState("");
const [candidateEffort, setCandidateEffort] = useState("");
const [error, setError] = useState<string | null>(null);

useEffect(() => {
fetch("/api/omp-settings")
.then((response) => response.ok ? response.json() : Promise.reject(new Error(`HTTP ${response.status}`)))
.then((data: { settings?: RetrySettings }) => setSettings(data.settings ?? {}))
.then((data: { settings?: RetrySettings }) => {
const loaded = data.settings ?? {};
confirmedRef.current = loaded;
setSettings(loaded);
})
.catch((reason) => setError(reason instanceof Error ? reason.message : String(reason)));
}, []);

Expand All @@ -101,6 +106,7 @@ export function RetryFallbackDetail({ models }: { models: RuntimeModelEntry[] })
// the latest snapshot and drain a single serialized save always writing the
// most recent state (fixes rapid fallback-chain edits scheduling stale writes).
const latestRef = useRef<RetrySettings | null>(null);
const confirmedRef = useRef<RetrySettings | null>(null);
const drainingRef = useRef(false);
const save = (next: RetrySettings) => {
setSettings(next);
Expand All @@ -117,8 +123,12 @@ export function RetryFallbackDetail({ models }: { models: RuntimeModelEntry[] })
const response = await fetch("/api/omp-settings", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ settings: snapshot }) });
const data = await response.json() as { settings?: RetrySettings; error?: string };
if (!response.ok || data.error) throw new Error(data.error || `HTTP ${response.status}`);
if (latestRef.current === null) setSettings(data.settings ?? snapshot);
const confirmed = data.settings ?? snapshot;
confirmedRef.current = confirmed;
if (latestRef.current === null) setSettings(confirmed);
} catch (reason) {
latestRef.current = null;
setSettings(confirmedRef.current ?? {});
setError(reason instanceof Error ? reason.message : String(reason));
break;
}
Expand All @@ -133,6 +143,8 @@ export function RetryFallbackDetail({ models }: { models: RuntimeModelEntry[] })
const retry = settings.retry ?? {};
const chain = retry.fallbackChains?.[role] ?? [];
const modelOptions = models.map((model) => `${model.provider}/${model.id}`);
const candidateModel = models.find((model) => `${model.provider}/${model.id}` === candidate);
const candidateSelector = candidate ? `${candidate}${candidateEffort ? `:${candidateEffort}` : ""}` : "";
const updateChain = (next: string[]) => void save({ ...settings, retry: { ...retry, fallbackChains: { ...(retry.fallbackChains ?? {}), [role]: next } } });

return <div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
Expand All @@ -145,7 +157,11 @@ export function RetryFallbackDetail({ models }: { models: RuntimeModelEntry[] })
</div>
<section style={{ border: "1px solid var(--border)", borderRadius: "var(--radius-card)", overflow: "hidden" }}>
<div style={{ padding: "10px 12px", background: "var(--bg-panel)", display: "flex", alignItems: "center", gap: 8 }}><span style={{ color: "var(--text)", fontSize: 12, fontWeight: 600 }}>{t("modelsConfig.fallbackChainFor")}</span><ThemedSelect aria-label={t("modelsConfig.fallbackChainFor")} value={role} onChange={(nextValue) => setRole(nextValue)} style={{ padding: "4px 8px", border: "1px solid var(--border)", borderRadius: "var(--radius-control)", background: "var(--bg)", color: "var(--text)" }} options={[...NATIVE_MODEL_ROLES.map((value) => ({ value: value, label: value }))]} /></div>
<div style={{ padding: 12, display: "flex", gap: 8 }}><ThemedSelect aria-label={t("modelsConfig.selectFallbackModel")} value={candidate} onChange={(nextValue) => setCandidate(nextValue)} style={{ flex: 1, minWidth: 0, padding: "6px 8px", border: "1px solid var(--border)", borderRadius: "var(--radius-control)", background: "var(--bg)", color: "var(--text)" }} options={[{ value: "", label: t("modelsConfig.selectFallbackModel") }, ...modelOptions.filter((value) => !chain.includes(value)).map((value) => ({ value: value, label: value }))]} /><button type="button" disabled={!candidate} onClick={() => { updateChain([...chain, candidate]); setCandidate(""); }} style={{ padding: "6px 10px", border: "none", borderRadius: "var(--radius-control)", background: "var(--accent-strong)", color: "white", cursor: "pointer", fontSize: 12 }}>{t("modelsConfig.add")}</button></div>
<div style={{ padding: 12, display: "flex", gap: 8 }}>
<ThemedSelect aria-label={t("modelsConfig.selectFallbackModel")} value={candidate} onChange={(nextValue) => { setCandidate(nextValue); setCandidateEffort(""); }} style={{ flex: 1, minWidth: 0, padding: "6px 8px", border: "1px solid var(--border)", borderRadius: "var(--radius-control)", background: "var(--bg)", color: "var(--text)" }} options={[{ value: "", label: t("modelsConfig.selectFallbackModel") }, ...modelOptions.map((value) => ({ value: value, label: value }))]} />
<ThemedSelect aria-label={t("modelsConfig.thinkingLevels")} value={candidateEffort} disabled={!candidateModel} onChange={setCandidateEffort} style={{ minWidth: 110, padding: "6px 8px", border: "1px solid var(--border)", borderRadius: "var(--radius-control)", background: "var(--bg)", color: "var(--text)", opacity: candidateModel ? 1 : 0.55 }} options={[{ value: "", label: t("modelsConfig.modelDefault") }, ...(candidateModel?.thinkingLevels ?? []).map((level) => ({ value: level, label: level }))]} />
<button type="button" disabled={!candidate || chain.includes(candidateSelector)} onClick={() => { updateChain([...chain, candidateSelector]); setCandidate(""); setCandidateEffort(""); }} style={{ padding: "6px 10px", border: "none", borderRadius: "var(--radius-control)", background: "var(--accent-strong)", color: "white", cursor: "pointer", fontSize: 12 }}>{t("modelsConfig.add")}</button>
</div>
{chain.length === 0 ? (
<div style={{ padding: "0 12px 12px", color: "var(--text-dim)", fontSize: 12 }}>{t("modelsConfig.noExplicitChain")}</div>
) : (
Expand Down
120 changes: 119 additions & 1 deletion components/ModelsConfig.test.mjs
Original file line number Diff line number Diff line change
@@ -1,11 +1,129 @@
import "../tests/setup-dom.mjs";
import assert from "node:assert/strict";
import test from "node:test";
import test, { afterEach } from "node:test";
import { createElement } from "react";

const { cleanup, render, screen, waitFor } = await import("@testing-library/react/pure.js");
const { default: userEvent } = await import("@testing-library/user-event");
const { providerInitials } = await import("./ModelsConfig.tsx");
const { RetryFallbackDetail } = await import("./ModelsConfig-panels.tsx");

afterEach(cleanup);

const runtimeModel = {
provider: "openai-codex",
id: "gpt-5.6-sol",
name: "GPT 5.6 Sol",
thinkingLevels: ["off", "low", "medium", "high"],
};

async function chooseFallback(user, effort) {
await user.click(await screen.findByRole("combobox", { name: "Select a fallback model" }));
await user.click(await screen.findByRole("option", { name: "openai-codex/gpt-5.6-sol" }));
await user.click(screen.getByRole("combobox", { name: "Thinking levels" }));
await user.click(await screen.findByRole("option", { name: effort }));
}

test("provider glyphs derive from arbitrary runtime provider ids", () => {
assert.equal(providerInitials("acme-provider"), "AP");
assert.equal(providerInitials("my_custom_gateway"), "MC");
assert.equal(providerInitials("provider"), "P");
assert.equal(providerInitials(""), "?");
});

test("fallback chains can select a model effort", async (t) => {
const writes = [];
t.mock.method(globalThis, "fetch", async (_url, init) => {
if (init?.method === "PUT") {
writes.push(JSON.parse(init.body));
return Response.json(JSON.parse(init.body));
}
return Response.json({ settings: {} });
});

render(createElement(RetryFallbackDetail, { models: [runtimeModel] }));
const user = userEvent.setup();
await chooseFallback(user, "high");
await user.click(screen.getByRole("button", { name: "Add" }));

await waitFor(() => assert.deepEqual(
writes.at(-1).settings.retry.fallbackChains.default,
["openai-codex/gpt-5.6-sol:high"],
));
});

test("fallback chains expose the runtime off effort", async (t) => {
const writes = [];
t.mock.method(globalThis, "fetch", async (_url, init) => {
if (init?.method === "PUT") {
writes.push(JSON.parse(init.body));
return Response.json(JSON.parse(init.body));
}
return Response.json({ settings: {} });
});

render(createElement(RetryFallbackDetail, { models: [runtimeModel] }));
const user = userEvent.setup();
await chooseFallback(user, "off");
await user.click(screen.getByRole("button", { name: "Add" }));

await waitFor(() => assert.deepEqual(
writes.at(-1).settings.retry.fallbackChains.default,
["openai-codex/gpt-5.6-sol:off"],
));
});

test("fallback chains allow an effort variant after the model default", async (t) => {
const writes = [];
t.mock.method(globalThis, "fetch", async (_url, init) => {
if (init?.method === "PUT") {
writes.push(JSON.parse(init.body));
return Response.json(JSON.parse(init.body));
}
return Response.json({ settings: { retry: { fallbackChains: { default: ["openai-codex/gpt-5.6-sol"] } } } });
});

render(createElement(RetryFallbackDetail, { models: [runtimeModel] }));
const user = userEvent.setup();
await chooseFallback(user, "high");
await user.click(screen.getByRole("button", { name: "Add" }));

await waitFor(() => assert.deepEqual(
writes.at(-1).settings.retry.fallbackChains.default,
["openai-codex/gpt-5.6-sol", "openai-codex/gpt-5.6-sol:high"],
));
});

test("fallback chains reject an exact duplicate selector", async (t) => {
const writes = [];
t.mock.method(globalThis, "fetch", async (_url, init) => {
if (init?.method === "PUT") {
writes.push(JSON.parse(init.body));
return Response.json(JSON.parse(init.body));
}
return Response.json({ settings: { retry: { fallbackChains: { default: ["openai-codex/gpt-5.6-sol:high"] } } } });
});

render(createElement(RetryFallbackDetail, { models: [runtimeModel] }));
const user = userEvent.setup();
await chooseFallback(user, "high");

assert.equal(screen.getByRole("button", { name: "Add" }).disabled, true);
assert.deepEqual(writes, []);
});

test("a failed fallback save restores the confirmed chain", async (t) => {
t.mock.method(globalThis, "fetch", async (_url, init) => {
if (init?.method === "PUT") return Response.json({ error: "save failed" }, { status: 500 });
return Response.json({ settings: {} });
});

render(createElement(RetryFallbackDetail, { models: [runtimeModel] }));
const user = userEvent.setup();
await chooseFallback(user, "high");
await user.click(screen.getByRole("button", { name: "Add" }));

await screen.findByRole("alert");
assert.equal(screen.queryByText("openai-codex/gpt-5.6-sol:high"), null);
assert.ok(screen.getByText("No explicit chain. OMP uses the default chain when available."));
});
2 changes: 1 addition & 1 deletion graphify-out/graph.json

Large diffs are not rendered by default.

Loading