Q171: provider policy fields say what they govern and show pending and saved state
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
7c291cf7a7
commit
059970e7e1
4 files changed
+212
-156
No files matched your search
@@ -687,4 +687,12 @@ export const zh: Record<string, string> = {
|
||||
"{model} (provider default)": "{model}(Provider 默认)",
|
||||
"Chosen when the session starts": "会话开始时确定",
|
||||
"The default model reaches {following} agents without their own model; {own} keep their own. Sessions already started keep the model they began with.": "默认模型作用于 {following} 个未单独设置模型的 Agent;{own} 个保留自己的模型。已开始的会话继续使用开始时的模型。",
|
||||
"Scope: “Workspace” refuses every call that is not made for a workspace, including agent sessions on tasks without one.": "范围:选择“工作区”后,凡不是为某个工作区发起的调用都会被拒绝,包括没有工作区的任务上的 Agent 会话。",
|
||||
"Data policy: zero data retention refuses background requests, so it also switches background off.": "数据策略:零数据保留会拒绝后台请求,因此会同时关闭后台执行。",
|
||||
"Background: lets requests from the request console run in the provider's background mode.": "后台:允许请求控制台发出的请求以 Provider 的后台模式运行。",
|
||||
"Monthly limit: counts every model call this company makes through the provider in the calendar month (UTC); at the limit, calls are refused and agent work waits until the month resets.": "月额度:统计本公司在当月(UTC 自然月)通过该 Provider 发起的每次模型调用;达到上限后调用会被拒绝,Agent 工作等待到下月重置。",
|
||||
"Context window: the size new sessions fit prompts, skills, history and tool results into; sessions already running keep theirs.": "上下文窗口:新会话把提示、技能、历史和工具结果放入的容量;已在运行的会话保持原值。",
|
||||
"Zero data retention switched background off.": "零数据保留已关闭后台执行。",
|
||||
"Resets {date} (UTC)": "{date} 重置(UTC)",
|
||||
"Unsaved": "未保存",
|
||||
};
|
||||
@@ -0,0 +1,196 @@
|
||||
import { useState } from "react";
|
||||
import { Pill } from "../../shared/ui";
|
||||
import { t } from "../../i18n/i18n";
|
||||
import type { ProviderRateLimitStateRecord, ProviderRecord } from "../../../../shared/domain";
|
||||
import { PROVIDER_DATA_POLICY } from "../../../../shared/domain";
|
||||
import type { SnapshotActions } from "../../app/useSnapshot";
|
||||
|
||||
const POLICY_GRID = "minmax(150px, 1.2fr) minmax(118px, 0.8fr) minmax(140px, 1fr) 112px 124px 124px 132px";
|
||||
type WorkspaceScope = "organization" | "workspace";
|
||||
|
||||
export function ProviderPolicyTable({
|
||||
orgId,
|
||||
providers,
|
||||
providerRateLimits,
|
||||
actions,
|
||||
}: {
|
||||
orgId: string;
|
||||
providers: ProviderRecord[];
|
||||
providerRateLimits: ProviderRateLimitStateRecord[];
|
||||
actions: SnapshotActions;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="section-label" style={{ marginBottom: 6 }}>{t("Runtime policy")}</div>
|
||||
{/* What each column governs, so a change is made knowing what it reaches. */}
|
||||
<ul style={{ fontSize: 12, color: "var(--text-3)", lineHeight: 1.6, margin: "0 0 10px", paddingLeft: 18 }}>
|
||||
<li>{t("Scope: “Workspace” refuses every call that is not made for a workspace, including agent sessions on tasks without one.")}</li>
|
||||
<li>{t("Data policy: zero data retention refuses background requests, so it also switches background off.")}</li>
|
||||
<li>{t("Background: lets requests from the request console run in the provider's background mode.")}</li>
|
||||
<li>{t("Monthly limit: counts every model call this company makes through the provider in the calendar month (UTC); at the limit, calls are refused and agent work waits until the month resets.")}</li>
|
||||
<li>{t("Context window: the size new sessions fit prompts, skills, history and tool results into; sessions already running keep theirs.")}</li>
|
||||
</ul>
|
||||
<div className="card" style={{ padding: 0, overflowX: "auto", marginBottom: 28 }}>
|
||||
<div
|
||||
className="table-row head"
|
||||
style={{ gridTemplateColumns: POLICY_GRID, minWidth: 940 }}
|
||||
>
|
||||
<div>{t("Provider")}</div><div>{t("Scope")}</div><div>{t("Data policy")}</div><div>{t("Background")}</div><div>{t("Monthly limit")}</div><div>{t("Context window (tokens)")}</div><div>{t("Usage")}</div>
|
||||
</div>
|
||||
{providers.map((provider, index) => (
|
||||
<ProviderPolicyRow
|
||||
key={provider.id}
|
||||
orgId={orgId}
|
||||
provider={provider}
|
||||
rateLimit={requireProviderRateLimit(provider, providerRateLimits)}
|
||||
last={index === providers.length - 1}
|
||||
actions={actions}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderPolicyRow({
|
||||
orgId,
|
||||
provider,
|
||||
rateLimit,
|
||||
last,
|
||||
actions,
|
||||
}: {
|
||||
orgId: string;
|
||||
provider: ProviderRecord;
|
||||
rateLimit: ProviderRateLimitStateRecord;
|
||||
last: boolean;
|
||||
actions: SnapshotActions;
|
||||
}) {
|
||||
const [dataPolicy, setDataPolicy] = useState(provider.dataPolicy);
|
||||
const [backgroundEnabled, setBackgroundEnabled] = useState(provider.backgroundEnabled);
|
||||
const [monthlyRequestLimit, setMonthlyRequestLimit] = useState(String(provider.monthlyRequestLimit));
|
||||
const [contextWindowTokens, setContextWindowTokens] = useState(String(provider.contextWindowTokens));
|
||||
const [workspaceScope, setWorkspaceScope] = useState<WorkspaceScope>(provider.workspaceScope === "workspace" ? "workspace" : "organization");
|
||||
const [linkedNote, setLinkedNote] = useState<string | null>(null);
|
||||
const [status, setStatus] = useState<{ kind: "saving" | "saved" } | { kind: "error"; message: string } | null>(null);
|
||||
const savedScope = provider.workspaceScope === "workspace" ? "workspace" : "organization";
|
||||
const dirty = dataPolicy !== provider.dataPolicy
|
||||
|| backgroundEnabled !== provider.backgroundEnabled
|
||||
|| Number(monthlyRequestLimit) !== provider.monthlyRequestLimit
|
||||
|| Number(contextWindowTokens) !== provider.contextWindowTokens
|
||||
|| workspaceScope !== savedScope;
|
||||
const edited = <T,>(set: (value: T) => void) => (value: T) => { set(value); setStatus(null); };
|
||||
|
||||
const savePolicy = () => {
|
||||
setStatus({ kind: "saving" });
|
||||
actions.run(() => window.agentCompany.updateProviderPolicy({
|
||||
orgId,
|
||||
providerId: provider.id,
|
||||
dataPolicy,
|
||||
backgroundEnabled,
|
||||
monthlyRequestLimit: Number(monthlyRequestLimit),
|
||||
workspaceScope,
|
||||
contextWindowTokens: Number(contextWindowTokens),
|
||||
}))
|
||||
.then((snapshot) => {
|
||||
const saved = snapshot?.providers.find((item) => item.id === provider.id);
|
||||
if (!saved) return setStatus(null);
|
||||
// The fields show what the backend stored (025 reads back as 25, 26.9 as 26).
|
||||
setMonthlyRequestLimit(String(saved.monthlyRequestLimit));
|
||||
setContextWindowTokens(String(saved.contextWindowTokens));
|
||||
setStatus({ kind: "saved" });
|
||||
setLinkedNote(null);
|
||||
})
|
||||
.catch((failure: unknown) => setStatus({ kind: "error", message: failure instanceof Error ? failure.message : String(failure) }));
|
||||
};
|
||||
|
||||
const updateDataPolicy = (value: ProviderRecord["dataPolicy"]) => {
|
||||
edited(setDataPolicy)(value);
|
||||
if (value === PROVIDER_DATA_POLICY.ZERO_DATA_RETENTION && backgroundEnabled) {
|
||||
setBackgroundEnabled(false);
|
||||
setLinkedNote(t("Zero data retention switched background off."));
|
||||
} else setLinkedNote(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="table-row"
|
||||
style={{
|
||||
gridTemplateColumns: POLICY_GRID,
|
||||
minWidth: 940,
|
||||
borderBottom: last ? "none" : "1px solid var(--border-soft)",
|
||||
}}
|
||||
>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 540, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{provider.label}</div>
|
||||
{linkedNote && <div style={{ fontSize: 12, color: "var(--text-3)" }}>{linkedNote}</div>}
|
||||
</div>
|
||||
<select
|
||||
className="input"
|
||||
aria-label={t("{label} workspace scope", { label: provider.label })}
|
||||
value={workspaceScope}
|
||||
onChange={(event) => edited(setWorkspaceScope)(event.target.value === "workspace" ? "workspace" : "organization")}
|
||||
>
|
||||
<option value="organization">{t("Organization")}</option>
|
||||
<option value="workspace">{t("Workspace")}</option>
|
||||
</select>
|
||||
<select
|
||||
className="input"
|
||||
aria-label={t("{label} data policy", { label: provider.label })}
|
||||
value={dataPolicy}
|
||||
onChange={(event) => updateDataPolicy(event.target.value as ProviderRecord["dataPolicy"])}
|
||||
>
|
||||
<option value={PROVIDER_DATA_POLICY.STANDARD}>{t("Standard retention")}</option>
|
||||
<option value={PROVIDER_DATA_POLICY.ZERO_DATA_RETENTION}>{t("Zero data retention")}</option>
|
||||
</select>
|
||||
<label className="row" style={{ gap: 8, fontSize: 12, color: "var(--text-2)" }}>
|
||||
<input
|
||||
aria-label={t("{label} background execution", { label: provider.label })}
|
||||
type="checkbox"
|
||||
checked={backgroundEnabled}
|
||||
disabled={dataPolicy === PROVIDER_DATA_POLICY.ZERO_DATA_RETENTION}
|
||||
onChange={(event) => edited(setBackgroundEnabled)(event.target.checked)}
|
||||
/>
|
||||
{t("Allow")}
|
||||
</label>
|
||||
<input
|
||||
className="input"
|
||||
aria-label={t("{label} monthly request limit", { label: provider.label })}
|
||||
type="number"
|
||||
min={1}
|
||||
value={monthlyRequestLimit}
|
||||
onChange={(event) => edited(setMonthlyRequestLimit)(event.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
aria-label={t("{label} context window", { label: provider.label })}
|
||||
title={t("The model's context window. Runs keep prompts, skills, history, and tool results inside it.")}
|
||||
type="number"
|
||||
min={24000}
|
||||
step={1000}
|
||||
value={contextWindowTokens}
|
||||
onChange={(event) => edited(setContextWindowTokens)(event.target.value)}
|
||||
/>
|
||||
<div>
|
||||
<div className="num mono">{rateLimit.requestCount} / {rateLimit.monthlyRequestLimit}</div>
|
||||
<div style={{ fontSize: 11, color: "var(--text-3)" }}>{t("Resets {date} (UTC)", { date: rateLimit.windowEnd.slice(0, 10) })}</div>
|
||||
<div className="row" style={{ gap: 6, marginTop: 4 }}>
|
||||
<button type="button" className="button small" aria-label={t("Save {label} policy", { label: provider.label })} onClick={savePolicy} disabled={!dirty || status?.kind === "saving"}>
|
||||
{t("Save")}
|
||||
</button>
|
||||
{dirty && status?.kind !== "saving" && <Pill tone="amber">{t("Unsaved")}</Pill>}
|
||||
{!dirty && status?.kind === "saved" && <Pill tone="green">{t("Saved")}</Pill>}
|
||||
</div>
|
||||
{status?.kind === "error" && <div className="form-error" role="alert">{status.message}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function requireProviderRateLimit(
|
||||
provider: ProviderRecord,
|
||||
rateLimits: ProviderRateLimitStateRecord[],
|
||||
): ProviderRateLimitStateRecord {
|
||||
const rateLimit = rateLimits.find((item) => item.providerId === provider.id);
|
||||
if (!rateLimit) throw new Error(`Provider rate limit state missing for ${provider.id}.`);
|
||||
return rateLimit;
|
||||
}
|
||||
@@ -11,10 +11,10 @@ import type {
|
||||
ProviderRateLimitStateRecord,
|
||||
ProviderRecord,
|
||||
} from "../../../../shared/domain";
|
||||
import { PROVIDER_DATA_POLICY } from "../../../../shared/domain";
|
||||
import { ProviderLogo } from "../../shared/ProviderLogo";
|
||||
import { ModelPicker, ProviderConnectFields, ProviderOAuthFields } from "./ProviderConnectionDialog";
|
||||
import { composeSecret } from "./provider-secret";
|
||||
import { ProviderPolicyTable } from "./ProviderPolicyTable";
|
||||
import type { SnapshotActions } from "../../app/useSnapshot";
|
||||
|
||||
interface ProvidersPanelProps {
|
||||
@@ -30,8 +30,6 @@ interface ProvidersPanelProps {
|
||||
}
|
||||
|
||||
const PRICE_GRID = "minmax(150px, 1.4fr) 110px 110px 110px 92px";
|
||||
const POLICY_GRID = "minmax(150px, 1.2fr) minmax(118px, 0.8fr) minmax(140px, 1fr) 112px 124px 124px 92px";
|
||||
type WorkspaceScope = "organization" | "workspace";
|
||||
|
||||
export function ProvidersPanel({ orgId, companyName, employeeId, providers, authProfiles, catalog, agents, providerRateLimits, actions }: ProvidersPanelProps) {
|
||||
const [active, setActive] = useState<ProviderRecord | null>(null);
|
||||
@@ -167,45 +165,6 @@ export function ProvidersPanel({ orgId, companyName, employeeId, providers, auth
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderPolicyTable({
|
||||
orgId,
|
||||
providers,
|
||||
providerRateLimits,
|
||||
actions,
|
||||
}: {
|
||||
orgId: string;
|
||||
providers: ProviderRecord[];
|
||||
providerRateLimits: ProviderRateLimitStateRecord[];
|
||||
actions: SnapshotActions;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="section-label" style={{ marginBottom: 10 }}>{t("Runtime policy")}</div>
|
||||
<div className="card" style={{ padding: 0, overflowX: "auto", marginBottom: 28 }}>
|
||||
<div
|
||||
className="table-row head"
|
||||
style={{ gridTemplateColumns: POLICY_GRID, minWidth: 900 }}
|
||||
>
|
||||
<div>{t("Provider")}</div><div>{t("Scope")}</div><div>{t("Data policy")}</div><div>{t("Background")}</div><div>{t("Monthly limit")}</div><div>{t("Context window (tokens)")}</div><div>{t("Usage")}</div>
|
||||
</div>
|
||||
{providers.map((provider, index) => {
|
||||
const rateLimit = requireProviderRateLimit(provider, providerRateLimits);
|
||||
return (
|
||||
<ProviderPolicyRow
|
||||
key={provider.id}
|
||||
orgId={orgId}
|
||||
provider={provider}
|
||||
rateLimit={rateLimit}
|
||||
last={index === providers.length - 1}
|
||||
actions={actions}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Prices turn recorded token usage into spend, which agent and department budgets are checked against. */
|
||||
function ProviderPricesTable({ orgId, providers, actions }: { orgId: string; providers: ProviderRecord[]; actions: SnapshotActions }) {
|
||||
return (
|
||||
@@ -251,110 +210,6 @@ function ProviderPricesRow({ orgId, provider, actions }: { orgId: string; provid
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderPolicyRow({
|
||||
orgId,
|
||||
provider,
|
||||
rateLimit,
|
||||
last,
|
||||
actions,
|
||||
}: {
|
||||
orgId: string;
|
||||
provider: ProviderRecord;
|
||||
rateLimit: ProviderRateLimitStateRecord;
|
||||
last: boolean;
|
||||
actions: SnapshotActions;
|
||||
}) {
|
||||
const [dataPolicy, setDataPolicy] = useState(provider.dataPolicy);
|
||||
const [backgroundEnabled, setBackgroundEnabled] = useState(provider.backgroundEnabled);
|
||||
const [monthlyRequestLimit, setMonthlyRequestLimit] = useState(String(provider.monthlyRequestLimit));
|
||||
const [contextWindowTokens, setContextWindowTokens] = useState(String(provider.contextWindowTokens));
|
||||
const [workspaceScope, setWorkspaceScope] = useState<WorkspaceScope>(provider.workspaceScope === "workspace" ? "workspace" : "organization");
|
||||
|
||||
const savePolicy = () => {
|
||||
void actions.run(() => window.agentCompany.updateProviderPolicy({
|
||||
orgId,
|
||||
providerId: provider.id,
|
||||
dataPolicy,
|
||||
backgroundEnabled,
|
||||
monthlyRequestLimit: Number(monthlyRequestLimit),
|
||||
workspaceScope,
|
||||
contextWindowTokens: Number(contextWindowTokens),
|
||||
}));
|
||||
};
|
||||
|
||||
const updateDataPolicy = (value: ProviderRecord["dataPolicy"]) => {
|
||||
setDataPolicy(value);
|
||||
if (value === PROVIDER_DATA_POLICY.ZERO_DATA_RETENTION) setBackgroundEnabled(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="table-row"
|
||||
style={{
|
||||
gridTemplateColumns: POLICY_GRID,
|
||||
minWidth: 900,
|
||||
borderBottom: last ? "none" : "1px solid var(--border-soft)",
|
||||
}}
|
||||
>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 540, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{provider.label}</div>
|
||||
</div>
|
||||
<select
|
||||
className="input"
|
||||
aria-label={t("{label} workspace scope", { label: provider.label })}
|
||||
value={workspaceScope}
|
||||
onChange={(event) => setWorkspaceScope(event.target.value === "workspace" ? "workspace" : "organization")}
|
||||
>
|
||||
<option value="organization">{t("Organization")}</option>
|
||||
<option value="workspace">{t("Workspace")}</option>
|
||||
</select>
|
||||
<select
|
||||
className="input"
|
||||
aria-label={t("{label} data policy", { label: provider.label })}
|
||||
value={dataPolicy}
|
||||
onChange={(event) => updateDataPolicy(event.target.value as ProviderRecord["dataPolicy"])}
|
||||
>
|
||||
<option value={PROVIDER_DATA_POLICY.STANDARD}>{t("Standard retention")}</option>
|
||||
<option value={PROVIDER_DATA_POLICY.ZERO_DATA_RETENTION}>{t("Zero data retention")}</option>
|
||||
</select>
|
||||
<label className="row" style={{ gap: 8, fontSize: 12, color: "var(--text-2)" }}>
|
||||
<input
|
||||
aria-label={t("{label} background execution", { label: provider.label })}
|
||||
type="checkbox"
|
||||
checked={backgroundEnabled}
|
||||
disabled={dataPolicy === PROVIDER_DATA_POLICY.ZERO_DATA_RETENTION}
|
||||
onChange={(event) => setBackgroundEnabled(event.target.checked)}
|
||||
/>
|
||||
{t("Allow")}
|
||||
</label>
|
||||
<input
|
||||
className="input"
|
||||
aria-label={t("{label} monthly request limit", { label: provider.label })}
|
||||
type="number"
|
||||
min={1}
|
||||
value={monthlyRequestLimit}
|
||||
onChange={(event) => setMonthlyRequestLimit(event.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
aria-label={t("{label} context window", { label: provider.label })}
|
||||
title={t("The model's context window. Runs keep prompts, skills, history, and tool results inside it.")}
|
||||
type="number"
|
||||
min={24000}
|
||||
step={1000}
|
||||
value={contextWindowTokens}
|
||||
onChange={(event) => setContextWindowTokens(event.target.value)}
|
||||
/>
|
||||
<div>
|
||||
<div className="num mono">{rateLimit.requestCount} / {rateLimit.monthlyRequestLimit}</div>
|
||||
<button type="button" className="button small" aria-label={t("Save {label} policy", { label: provider.label })} onClick={savePolicy}>
|
||||
{t("Save")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderRow({
|
||||
provider,
|
||||
authProfile,
|
||||
@@ -459,15 +314,6 @@ function readableAuth(authType: ProviderRecord["authType"]): string {
|
||||
return authType;
|
||||
}
|
||||
|
||||
function requireProviderRateLimit(
|
||||
provider: ProviderRecord,
|
||||
rateLimits: ProviderRateLimitStateRecord[],
|
||||
): ProviderRateLimitStateRecord {
|
||||
const rateLimit = rateLimits.find((item) => item.providerId === provider.id);
|
||||
if (!rateLimit) throw new Error(`Provider rate limit state missing for ${provider.id}.`);
|
||||
return rateLimit;
|
||||
}
|
||||
|
||||
function requireCatalogEntry(catalog: ProviderCatalogEntry[], providerKey: string): ProviderCatalogEntry {
|
||||
const entry = catalog.find((candidate) => candidate.key === providerKey);
|
||||
if (!entry) throw new Error(`Provider ${providerKey} is not in the catalog.`);
|
||||
|
||||
@@ -184,7 +184,10 @@ test("onboards, hires, and creates work in the Electron shell", async () => {
|
||||
await expect(page.getByText(label, { exact: true })).toBeVisible();
|
||||
}
|
||||
await page.getByLabel("OpenAI background execution").check();
|
||||
await expect(page.getByText("Unsaved", { exact: true })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Save OpenAI policy" }).click();
|
||||
await expect(page.getByText("Saved", { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Save OpenAI policy" })).toBeDisabled();
|
||||
|
||||
// The operator request console lives under Advanced.
|
||||
await page.getByRole("button", { name: "Advanced" }).click();
|
||||
@@ -197,8 +200,11 @@ test("onboards, hires, and creates work in the Electron shell", async () => {
|
||||
await page.getByRole("button", { name: "Providers" }).click();
|
||||
await page.getByLabel("OpenAI data policy").selectOption("zero_data_retention");
|
||||
await expect(page.getByLabel("OpenAI background execution")).not.toBeChecked();
|
||||
await page.getByLabel("OpenAI monthly request limit").fill("25");
|
||||
await expect(page.getByText("Zero data retention switched background off.")).toBeVisible();
|
||||
await page.getByLabel("OpenAI monthly request limit").fill("25.9");
|
||||
await page.getByRole("button", { name: "Save OpenAI policy" }).click();
|
||||
await expect(page.getByText("Saved", { exact: true })).toBeVisible();
|
||||
await expect(page.getByLabel("OpenAI monthly request limit")).toHaveValue("25");
|
||||
await expect(page.getByText("0 / 25").first()).toBeVisible();
|
||||
|
||||
// Changing only the default model asks for no key and keeps the sign-in.
|
||||
|
||||
Reference in new issue
Block a user