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
5 changes: 5 additions & 0 deletions docs-site/src/content/docs/guides/web-dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,11 @@ new or that every upstream measurement was refreshed.

The **Models** switches show final Codex visibility: a routed model is on only when its provider allowlist includes it (or no allowlist is set) and it is not disabled. Turning a model on reconciles both filters atomically; **All on** clears the provider allowlist so newly discovered models are also on.

Switches respond immediately so you can keep changing models while saves run in the background in
click order. Saved feedback appears after the queue finishes and the list is reconciled with the
server. Failed saves restore the server's state when it can be read and show an error. Wait for that
feedback before leaving Models or changing servers: unsent queued changes are discarded on departure.

### Managing models in a provider workspace

In a provider’s **Models** tab, **Delete** removes the stored custom definition. An underlying
Expand Down
4 changes: 4 additions & 0 deletions docs-site/src/content/docs/zh-cn/guides/web-dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,10 @@ Logs 可组合界面、被拦截请求、提供商、完整模型名、状态、

## 模型可见性

开关会立即响应,保存则按点击顺序在后台执行,因此可以连续调整多个模型。队列处理完并与服务器
核对列表后,才显示保存结果。保存失败会提示错误,并在能读取服务器状态时恢复实际选择。
请等保存结果出现后再离开模型页或切换服务器;离开时会丢弃尚未发送的排队修改。

**Models** 开关表示 Codex 中的最终可见状态。路由模型只有在 provider allowlist 中(或未设置 allowlist)且未被禁用时才会开启。开启模型会原子地协调两个过滤条件;**全部开启** 会清除 allowlist,因此以后新发现的模型也会开启。

### 在提供方工作区管理模型
Expand Down
2 changes: 2 additions & 0 deletions gui/src/model-visibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,11 +82,13 @@ export async function putModelVisibility(
targets: ModelVisibilityTarget[],
enabled: boolean,
fetchImpl: typeof fetch = fetch,
signal?: AbortSignal,
): Promise<Response> {
return fetchImpl(`${apiBase}/api/model-visibility`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ scope, provider, targets, enabled }),
...(signal ? { signal } : {}),
});
}

Expand Down
63 changes: 25 additions & 38 deletions gui/src/pages/Models.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
type ModelPickerOrderMode, type PickerOrderSettings, type PickerOrderSaved, type ModelPickerUsage,
} from "../model-picker-order";
import { startVisibilityPoll } from "../visibility-poll";
import { useModelVisibility } from "../use-model-visibility";
import { useDataSurface } from "../data-surface";
import { DataSurfaceSkeleton } from "../components/data-surface";
import ErrorBoundary from "../components/ErrorBoundary";
Expand All @@ -48,8 +49,7 @@ import {
} from "../models-groups";
import {
fetchSelectedModels,
modelVisible,
putModelVisibility,
modelVisible as savedModelVisible,
clientCatalogRefreshFailures,
type ClientCatalogRefreshFailure,
shouldApplyLoadGeneration,
Expand Down Expand Up @@ -340,6 +340,18 @@ export default function Models({ apiBase, restartEpoch = 0, connected = false, c
const catalogMutationRef = useRef(false);
const loadGenerationRef = useRef(0);
const loadPendingRef = useRef(false);
const visibility = useModelVisibility(apiBase, {
onQueued: () => { ++loadGenerationRef.current; setStatus(""); },
onBusy: value => { ++loadGenerationRef.current; loadPendingRef.current = false; catalogMutationRef.current = value; busyRef.current = value; setBusy(value); },
onResponse: body => {
const failures = clientCatalogRefreshFailures(body);
if (failures !== undefined) setIntegrationFailures(failures);
},
refresh: signal => load(true, signal),
onSettled: error => { setOk(!error); setStatus(t(error ?? "models.applied")); },
});
const modelVisible = (selected: ProviderModelMap, provider: string, id: string, native: boolean, blocked: boolean) =>
visibility.visible(provider, id, native, savedModelVisible(selected, provider, id, native, blocked));
// multi_agent_v2 / ultra gate. null = endpoint unavailable (older proxy build) -> section hidden.
const [v2, setV2] = useState<V2Status | null>(null);
// #2465: per-provider model-preset state. Keyed by provider so one card's busy state cannot
Expand Down Expand Up @@ -497,6 +509,7 @@ export default function Models({ apiBase, restartEpoch = 0, connected = false, c
}, [apiBase]);

const fetchCatalog = useCallback(async (signal: AbortSignal): Promise<CachedModelsPage> => {
const generation = loadGenerationRef.current;
const [modelsRes, capsRes, providersRes, selectionData] = await Promise.all([
// Every request carries the resource signal, so leaving the catalog tab cancels
// the work rather than only discarding its result.
Expand Down Expand Up @@ -528,7 +541,7 @@ export default function Models({ apiBase, restartEpoch = 0, connected = false, c
contextCapValues: capsData.values ?? capsData.caps ?? {},
contextCapValue: nextCapValue,
} satisfies CachedModelsPage;
writeSessionListCache(cacheKey, next);
if (generation === loadGenerationRef.current) writeSessionListCache(cacheKey, next);
return next;
}, [apiBase, cacheKey]);

Expand All @@ -552,11 +565,12 @@ export default function Models({ apiBase, restartEpoch = 0, connected = false, c
cacheKey,
[apiBase],
async (signal) => {
const generation = loadGenerationRef.current;
const next = await fetchCatalog(signal);
// A manual mutation refresh may have invalidated this request while its JSON was decoding.
// Do not let the aborted catalog repaint controls after the newer result is applied.
if (signal.aborted) throw new Error("models request aborted");
applyCatalog(next);
if (!catalogMutationRef.current && generation === loadGenerationRef.current) applyCatalog(next);
return next;
},
// Gated on the catalog tab: a 10-second poll that keeps running while the user
Expand Down Expand Up @@ -888,7 +902,7 @@ export default function Models({ apiBase, restartEpoch = 0, connected = false, c
model.native === true,
disabled.has(model.namespaced),
)).length;
}, [disabled, models, selectedModels]);
}, [disabled, models, selectedModels, visibility.overrides]);

/*
* Quiet per-tab counts. A count is omitted, never zeroed, while it is unknown: the
Expand All @@ -910,35 +924,8 @@ export default function Models({ apiBase, restartEpoch = 0, connected = false, c
targets: ModelVisibilityTarget[],
enabled: boolean,
) => {
if (catalogMutationRef.current) return;
catalogMutationRef.current = true;
++loadGenerationRef.current;
setBusy(true);
busyRef.current = true;
setStatus("");
let errorKey: "models.saveFailed" | "models.networkError" | null = null;
try {
const response = await putModelVisibility(apiBase, scope, provider, targets, enabled);
if (!response.ok) errorKey = "models.saveFailed";
else {
const failures = clientCatalogRefreshFailures(await response.json());
if (failures !== undefined) setIntegrationFailures(failures);
}
} catch {
errorKey = "models.networkError";
} finally {
const refreshed = await load(true);
if (errorKey) {
setOk(false);
setStatus(t(errorKey));
} else if (refreshed) {
setOk(true);
setStatus(t("models.applied"));
}
setBusy(false);
busyRef.current = false;
catalogMutationRef.current = false;
}
if (busyRef.current && !visibility.isRunning()) return;
visibility.enqueue(scope, provider, targets, enabled);
};

const toggleProviderCap = async (provider: string) => {
Expand Down Expand Up @@ -1589,8 +1576,8 @@ export default function Models({ apiBase, restartEpoch = 0, connected = false, c
</>
);
})()}
<button type="button" className="btn btn-ghost btn-sm text-caption" disabled={busy || allOn || selectionPending} onClick={() => bulkToggle(true)}>{t("models.allOn")}</button>
<button type="button" className="btn btn-ghost btn-sm text-caption" disabled={busy || allOff || selectionPending} onClick={() => bulkToggle(false)}>{t("models.allOff")}</button>
<button type="button" className="btn btn-ghost btn-sm text-caption" disabled={(busy && !visibility.pending) || allOn || selectionPending} onClick={() => bulkToggle(true)}>{t("models.allOn")}</button>
<button type="button" className="btn btn-ghost btn-sm text-caption" disabled={(busy && !visibility.pending) || allOff || selectionPending} onClick={() => bulkToggle(false)}>{t("models.allOff")}</button>
<div className="models-cap-cluster">
{/* The label names the FUNCTION. It used to be `models.capValue` -
"기본 128k" - which is a value masquerading as a name: even a
Expand Down Expand Up @@ -1730,7 +1717,7 @@ export default function Models({ apiBase, restartEpoch = 0, connected = false, c
}}
>
<div className="row models-model-row">
<Switch on={!off} onClick={() => void applyVisibility("models", provider, [{ id: m.id, native: m.native === true }], off)} disabled={busy || m.initialSelectionPending} label={m.native ? m.id : m.namespaced} />
<Switch on={!off} onClick={() => void applyVisibility("models", provider, [{ id: m.id, native: m.native === true }], off)} disabled={(busy && !visibility.pending) || m.initialSelectionPending} label={m.native ? m.id : m.namespaced} />
{m.initialSelectionPending && <span className="models-chip muted" role="status">{t("models.initialSelectionPending")}</span>}
{/* #1711: listed and selectable, but every usable target is out of credit.
Not a visibility change and not the operator's disable flag — the row is
Expand Down Expand Up @@ -2568,7 +2555,7 @@ export default function Models({ apiBase, restartEpoch = 0, connected = false, c
pickerMode: modelPickerOrderMode(pickerSettings?.pickerAvailable ?? [], pickerSettings?.pickerOrder ?? [], pickerSettings?.pickerOrderMode) })}>
{controlsBlock}
</ModelsSettingsPanel>
<div className="models-workspace-root" aria-busy={catalogState.refreshing || undefined}>
<div className="models-workspace-root" aria-busy={visibility.pending || catalogState.refreshing || undefined}>
<aside className="models-workspace-rail" aria-label={t("nav.models")}>
<div className="models-workspace-rail-header">
<span className="models-workspace-rail-title">{t("models.workspace.providers")}</span>
Expand Down
109 changes: 109 additions & 0 deletions gui/src/use-model-visibility.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { useLayoutEffect, useRef, useState } from "react";
import { createBoundedFetch } from "./bounded-fetch";
import { putModelVisibility, type ModelVisibilityScope, type ModelVisibilityTarget } from "./model-visibility";

type Mutation = { scope: ModelVisibilityScope; provider: string; targets: ModelVisibilityTarget[]; enabled: boolean };
type ErrorKey = "models.saveFailed" | "models.networkError";
type Options = {
onQueued(): void;
onBusy(busy: boolean): void;
onResponse(body: unknown): void;
refresh(signal: AbortSignal): Promise<boolean>;
onSettled(error: ErrorKey | null): void;
};

const key = (provider: string, id: string, native: boolean) => JSON.stringify([provider, id, native]);
const emptyOverrides: ReadonlyMap<string, boolean> = new Map();

/** Immediate row feedback, ordered writes, and one authoritative read when the queue drains. */
export function useModelVisibility(apiBase: string, options: Options) {
const [draft, setDraft] = useState<{ apiBase: string; overrides: ReadonlyMap<string, boolean>; pending: boolean }>({
apiBase, overrides: emptyOverrides, pending: false,
});
if (draft.apiBase !== apiBase) setDraft({ apiBase, overrides: emptyOverrides, pending: false });
// A new target never displays another server's optimistic draft, without an effect reset.
const overrides = draft.apiBase === apiBase ? draft.overrides : emptyOverrides;
const pending = draft.apiBase === apiBase && draft.pending;
const callbacks = useRef(options);
useLayoutEffect(() => { callbacks.current = options; });
const flight = useRef<{
active: boolean; running: boolean; queue: Mutation[]; readVersion: number;
bounded: ReturnType<typeof createBoundedFetch> | null;
} | null>(null);

useLayoutEffect(() => {
const current = { active: true, running: false, queue: [] as Mutation[], readVersion: 0, bounded: null as ReturnType<typeof createBoundedFetch> | null };
flight.current = current;
callbacks.current.onBusy(false);
return () => {
current.active = false;
current.queue.length = 0;
current.bounded?.controller.abort();
current.bounded?.clear();
};
}, [apiBase]);

const drain = async (current: NonNullable<typeof flight.current>) => {
let error: ErrorKey | null = null;
while (current.active) {
while (current.active && current.queue.length > 0) {
const mutation = current.queue.shift()!;
const bounded = createBoundedFetch(60_000);
current.bounded = bounded;
try {
const response = await putModelVisibility(apiBase, mutation.scope, mutation.provider,
mutation.targets, mutation.enabled, fetch, bounded.signal);
if (!current.active) return;
if (!response.ok) error = "models.saveFailed";
else {
const body: unknown = await response.json();
if (current.active) callbacks.current.onResponse(body);
}
} catch {
if (!current.active) return;
error = "models.networkError";
} finally {
bounded.clear();
current.bounded = null;
}
}
if (!current.active) return;
const readVersion = current.readVersion;
let refreshed = false;
const bounded = createBoundedFetch(60_000);
current.bounded = bounded;
try { refreshed = await callbacks.current.refresh(bounded.signal); } catch { /* reconcile failure is surfaced below */ }
finally { bounded.clear(); current.bounded = null; }
if (!current.active) return;
// A click during the read invalidates it and keeps the newer optimistic intent visible.
if (current.queue.length || readVersion !== current.readVersion) continue;
current.running = false;
setDraft({ apiBase, overrides: emptyOverrides, pending: false });
callbacks.current.onBusy(false);
callbacks.current.onSettled(error ?? (refreshed ? null : "models.networkError"));
return;
}
};

const enqueue = (scope: ModelVisibilityScope, provider: string, targets: ModelVisibilityTarget[], enabled: boolean) => {
const current = flight.current;
if (!current?.active) return;
current.queue.push({ scope, provider, targets, enabled });
current.readVersion++;
callbacks.current.onQueued();
setDraft(previous => {
const next = new Map(previous.apiBase === apiBase ? previous.overrides : emptyOverrides);
for (const target of targets) next.set(key(provider, target.id, target.native === true), enabled);
return { apiBase, overrides: next, pending: true };
});
if (!current.running) {
current.running = true;
callbacks.current.onBusy(true);
void drain(current);
}
};

return { pending, overrides, enqueue, isRunning: () => flight.current?.running === true,
visible: (provider: string, id: string, native: boolean, saved: boolean) => overrides.get(key(provider, id, native)) ?? saved,
};
}
Loading
Loading