Add provider policy usage controls
This commit is contained in:
1 parent
1ee877e630
commit
276929b855
18 files changed
+434
-59
No files matched your search
@@ -82,6 +82,7 @@ export function registerIpcHandlers(deps: IpcDeps): void {
|
||||
ipcMain.handle(IPC_CHANNELS.sendMessage, (_event, input) => mutate(() => chat.sendMessage(input)));
|
||||
ipcMain.handle(IPC_CHANNELS.markChatSpaceRead, (_event, input) => mutate(() => chat.markSpaceRead(input)));
|
||||
ipcMain.handle(IPC_CHANNELS.saveProviderCredential, (_event, input) => mutate(() => providers.saveCredential(input)));
|
||||
ipcMain.handle(IPC_CHANNELS.updateProviderPolicy, (_event, input) => mutate(() => providers.updatePolicy(input)));
|
||||
ipcMain.handle(IPC_CHANNELS.checkProviderHealth, async (_event, input) => {
|
||||
try {
|
||||
await providers.checkHealth(input.orgId, input.providerId);
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
export const providerPolicyUsageSchema = `
|
||||
alter table providers add column data_policy text not null default 'standard';
|
||||
alter table providers add column background_enabled integer not null default 0;
|
||||
alter table providers add column monthly_request_limit integer not null default 1000;
|
||||
alter table providers add column workspace_scope text not null default 'organization';
|
||||
|
||||
alter table provider_requests add column input_char_count integer not null default 0;
|
||||
alter table provider_requests add column output_char_count integer not null default 0;
|
||||
alter table provider_requests add column execution_mode text not null default 'foreground';
|
||||
alter table provider_requests add column policy_decision text not null default 'allowed:standard';
|
||||
|
||||
create table if not exists provider_usage_windows (
|
||||
id text primary key,
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
provider_id text not null references providers(id) on delete cascade,
|
||||
window_start text not null,
|
||||
window_end text not null,
|
||||
request_count integer not null,
|
||||
input_char_count integer not null,
|
||||
output_char_count integer not null,
|
||||
created_at text not null,
|
||||
updated_at text not null,
|
||||
unique(provider_id, window_start)
|
||||
);
|
||||
|
||||
create index if not exists idx_provider_usage_windows_org_start
|
||||
on provider_usage_windows(org_id, window_start);
|
||||
|
||||
insert into schema_migrations(version, name, applied_at)
|
||||
values (14, 'provider_policy_usage', datetime('now'));
|
||||
`;
|
||||
@@ -12,6 +12,7 @@ import { fileHandoffSecuritySchema } from "./migrations/010-file-handoff-securit
|
||||
import { boardInterventionSchema } from "./migrations/011-board-interventions";
|
||||
import { complianceControlsSchema } from "./migrations/012-compliance-controls";
|
||||
import { searchDocumentsSchema } from "./migrations/013-search-documents";
|
||||
import { providerPolicyUsageSchema } from "./migrations/014-provider-policy-usage";
|
||||
|
||||
interface Migration {
|
||||
version: number;
|
||||
@@ -33,6 +34,7 @@ const MIGRATIONS: Migration[] = [
|
||||
{ version: 11, name: "board_interventions", sql: boardInterventionSchema },
|
||||
{ version: 12, name: "compliance_controls", sql: complianceControlsSchema },
|
||||
{ version: 13, name: "search_documents", sql: searchDocumentsSchema },
|
||||
{ version: 14, name: "provider_policy_usage", sql: providerPolicyUsageSchema },
|
||||
];
|
||||
|
||||
const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
|
||||
|
||||
@@ -28,6 +28,7 @@ const LEGACY_TABLES = [
|
||||
"chat_read_state",
|
||||
"providers",
|
||||
"provider_requests",
|
||||
"provider_usage_windows",
|
||||
"credentials",
|
||||
"skill_definitions",
|
||||
"skill_revisions",
|
||||
@@ -130,6 +131,8 @@ const RELATION_CHECKS: RelationCheck[] = [
|
||||
{ table: "providers", column: "credential_id", referencedTable: "credentials", referencedColumn: "id", reason: "dangling_credential_id" },
|
||||
{ table: "provider_requests", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "provider_requests", column: "provider_id", referencedTable: "providers", referencedColumn: "id", reason: "dangling_provider_id" },
|
||||
{ table: "provider_usage_windows", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "provider_usage_windows", column: "provider_id", referencedTable: "providers", referencedColumn: "id", reason: "dangling_provider_id" },
|
||||
{ table: "credentials", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "skill_definitions", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "skill_revisions", column: "skill_id", referencedTable: "skill_definitions", referencedColumn: "id", reason: "dangling_skill_id" },
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { createHash } from "node:crypto";
|
||||
import { BrowserWindow, session } from "electron";
|
||||
import type { CreateProviderResponseInput, SaveProviderCredentialInput } from "../../shared/commands";
|
||||
import { PROVIDER_AUTH_TYPE, PROVIDER_REQUEST_STATE } from "../../shared/domain";
|
||||
import type { CreateProviderResponseInput, SaveProviderCredentialInput, UpdateProviderPolicyInput } from "../../shared/commands";
|
||||
import { PROVIDER_AUTH_TYPE, PROVIDER_DATA_POLICY, PROVIDER_EXECUTION_MODE, PROVIDER_REQUEST_STATE, type ProviderExecutionMode } from "../../shared/domain";
|
||||
import { PROVIDER_CATALOG } from "../../shared/provider-catalog";
|
||||
import { createId } from "../../shared/ids";
|
||||
import { AuditService } from "./audit-service";
|
||||
@@ -13,6 +13,7 @@ const AUTH_STATE_CONFIGURED = "configured";
|
||||
const HEALTH_STATE_HEALTHY = "healthy";
|
||||
const HEALTH_STATE_FAILED = "failed";
|
||||
const HEALTH_STATE_UNCHECKED = "unchecked";
|
||||
const WORKSPACE_SCOPES = new Set(["organization", "workspace"]);
|
||||
|
||||
function displayHint(secret: string): string {
|
||||
const clean = secret.trim();
|
||||
@@ -136,10 +137,46 @@ export class ProviderService {
|
||||
});
|
||||
}
|
||||
|
||||
updatePolicy(input: UpdateProviderPolicyInput): void {
|
||||
const monthlyRequestLimit = Math.trunc(input.monthlyRequestLimit);
|
||||
if (monthlyRequestLimit < 1) throw new Error("Monthly request limit must be at least 1.");
|
||||
if (!Object.values(PROVIDER_DATA_POLICY).includes(input.dataPolicy)) throw new Error("Provider data policy is invalid.");
|
||||
if (!WORKSPACE_SCOPES.has(input.workspaceScope)) throw new Error("Provider workspace scope is invalid.");
|
||||
if (input.dataPolicy === PROVIDER_DATA_POLICY.ZERO_DATA_RETENTION && input.backgroundEnabled) {
|
||||
throw new Error("Zero data retention policy requires foreground execution.");
|
||||
}
|
||||
const provider = this.db.prepare("select id, provider_key from providers where id = ? and org_id = ?")
|
||||
.get(input.providerId, input.orgId) as { id: string; provider_key: string } | undefined;
|
||||
if (!provider) throw new Error("Provider was not found.");
|
||||
|
||||
this.db.prepare(`
|
||||
update providers
|
||||
set data_policy = ?, background_enabled = ?, monthly_request_limit = ?, workspace_scope = ?
|
||||
where id = ? and org_id = ?
|
||||
`).run(input.dataPolicy, input.backgroundEnabled ? 1 : 0, monthlyRequestLimit, input.workspaceScope, input.providerId, input.orgId);
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "provider",
|
||||
entityId: input.providerId,
|
||||
actorType: "system",
|
||||
actorId: null,
|
||||
action: "policy_updated",
|
||||
payload: {
|
||||
providerKey: provider.provider_key,
|
||||
dataPolicy: input.dataPolicy,
|
||||
backgroundEnabled: input.backgroundEnabled,
|
||||
monthlyRequestLimit,
|
||||
workspaceScope: input.workspaceScope,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async createResponse(input: CreateProviderResponseInput): Promise<void> {
|
||||
const prompt = input.prompt.trim();
|
||||
if (!prompt) throw new Error("Prompt is required.");
|
||||
const provider = this.loadConfiguredProvider(input.orgId, input.providerId);
|
||||
const executionMode = input.executionMode ?? PROVIDER_EXECUTION_MODE.FOREGROUND;
|
||||
this.enforcePolicy(input.orgId, provider, executionMode);
|
||||
if (provider.auth_type !== PROVIDER_AUTH_TYPE.API_KEY) {
|
||||
throw new Error("Provider response execution requires an API key adapter.");
|
||||
}
|
||||
@@ -153,9 +190,10 @@ export class ProviderService {
|
||||
this.db.prepare(`
|
||||
insert into provider_requests(
|
||||
id, org_id, provider_id, provider_key, model, prompt_sha256,
|
||||
response_text, raw_response_json, state, error_message, created_at, completed_at
|
||||
response_text, raw_response_json, state, error_message, input_char_count,
|
||||
output_char_count, execution_mode, policy_decision, created_at, completed_at
|
||||
)
|
||||
values (?, ?, ?, ?, ?, ?, null, null, ?, null, ?, null)
|
||||
values (?, ?, ?, ?, ?, ?, null, null, ?, null, ?, 0, ?, ?, ?, null)
|
||||
`).run(
|
||||
requestId,
|
||||
input.orgId,
|
||||
@@ -164,6 +202,9 @@ export class ProviderService {
|
||||
provider.model,
|
||||
hashPrompt(prompt),
|
||||
PROVIDER_REQUEST_STATE.PENDING,
|
||||
prompt.length,
|
||||
executionMode,
|
||||
`allowed:${provider.data_policy}`,
|
||||
now,
|
||||
);
|
||||
|
||||
@@ -177,9 +218,10 @@ export class ProviderService {
|
||||
});
|
||||
this.db.prepare(`
|
||||
update provider_requests
|
||||
set state = ?, response_text = ?, raw_response_json = ?, completed_at = ?
|
||||
set state = ?, response_text = ?, raw_response_json = ?, output_char_count = ?, completed_at = ?
|
||||
where id = ? and org_id = ?
|
||||
`).run(PROVIDER_REQUEST_STATE.SUCCEEDED, result.text, result.rawResponseJson, new Date().toISOString(), requestId, input.orgId);
|
||||
`).run(PROVIDER_REQUEST_STATE.SUCCEEDED, result.text, result.rawResponseJson, result.text.length, new Date().toISOString(), requestId, input.orgId);
|
||||
this.recordUsage(input.orgId, provider.id, prompt.length, result.text.length);
|
||||
this.recordProviderHealth(provider.id, input.orgId, HEALTH_STATE_HEALTHY, null);
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
@@ -197,14 +239,34 @@ export class ProviderService {
|
||||
set state = ?, error_message = ?, completed_at = ?
|
||||
where id = ? and org_id = ?
|
||||
`).run(PROVIDER_REQUEST_STATE.FAILED, message, new Date().toISOString(), requestId, input.orgId);
|
||||
this.recordUsage(input.orgId, provider.id, prompt.length, 0);
|
||||
this.recordProviderHealth(provider.id, input.orgId, HEALTH_STATE_FAILED, message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private enforcePolicy(orgId: string, provider: ProviderRow, executionMode: ProviderExecutionMode): void {
|
||||
if (!Object.values(PROVIDER_EXECUTION_MODE).includes(executionMode)) throw new Error("Provider execution mode is invalid.");
|
||||
if (executionMode === PROVIDER_EXECUTION_MODE.BACKGROUND && provider.data_policy === PROVIDER_DATA_POLICY.ZERO_DATA_RETENTION) {
|
||||
throw new Error("Background execution conflicts with zero data retention policy.");
|
||||
}
|
||||
if (executionMode === PROVIDER_EXECUTION_MODE.BACKGROUND && provider.background_enabled !== 1) {
|
||||
throw new Error("Background execution is disabled for this provider policy.");
|
||||
}
|
||||
const window = currentMonthlyWindow();
|
||||
const usage = this.db.prepare(`
|
||||
select request_count
|
||||
from provider_usage_windows
|
||||
where org_id = ? and provider_id = ? and window_start = ?
|
||||
`).get(orgId, provider.id, window.start) as { request_count: number } | undefined;
|
||||
const requestCount = usage?.request_count ?? 0;
|
||||
if (requestCount >= provider.monthly_request_limit) throw new Error("Provider monthly request limit has been reached.");
|
||||
}
|
||||
|
||||
private loadConfiguredProvider(orgId: string, providerId: string): ProviderRow {
|
||||
const provider = this.db.prepare(`
|
||||
select id, provider_key, auth_type, model, credential_id, enabled, auth_state
|
||||
select id, provider_key, auth_type, model, credential_id, enabled, auth_state,
|
||||
data_policy, background_enabled, monthly_request_limit, workspace_scope
|
||||
from providers
|
||||
where id = ? and org_id = ?
|
||||
`).get(providerId, orgId) as ProviderRow | undefined;
|
||||
@@ -252,6 +314,22 @@ export class ProviderService {
|
||||
where id = ? and org_id = ?
|
||||
`).run(healthState, error, new Date().toISOString(), providerId, orgId);
|
||||
}
|
||||
|
||||
private recordUsage(orgId: string, providerId: string, inputCharCount: number, outputCharCount: number): void {
|
||||
const window = currentMonthlyWindow();
|
||||
const now = new Date().toISOString();
|
||||
this.db.prepare(`
|
||||
insert into provider_usage_windows(
|
||||
id, org_id, provider_id, window_start, window_end, request_count,
|
||||
input_char_count, output_char_count, created_at, updated_at
|
||||
) values (?, ?, ?, ?, ?, 1, ?, ?, ?, ?)
|
||||
on conflict(provider_id, window_start) do update set
|
||||
request_count = request_count + 1,
|
||||
input_char_count = input_char_count + excluded.input_char_count,
|
||||
output_char_count = output_char_count + excluded.output_char_count,
|
||||
updated_at = excluded.updated_at
|
||||
`).run(createId("puw"), orgId, providerId, window.start, window.end, inputCharCount, outputCharCount, now, now);
|
||||
}
|
||||
}
|
||||
|
||||
interface ProviderRow {
|
||||
@@ -262,8 +340,19 @@ interface ProviderRow {
|
||||
credential_id: string | null;
|
||||
enabled: number;
|
||||
auth_state: string;
|
||||
data_policy: string;
|
||||
background_enabled: number;
|
||||
monthly_request_limit: number;
|
||||
workspace_scope: string;
|
||||
}
|
||||
|
||||
function hashPrompt(prompt: string): string {
|
||||
return createHash("sha256").update(prompt).digest("hex");
|
||||
}
|
||||
|
||||
function currentMonthlyWindow(): { start: string; end: string } {
|
||||
const now = new Date();
|
||||
const start = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
|
||||
const end = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1));
|
||||
return { start: start.toISOString(), end: end.toISOString() };
|
||||
}
|
||||
@@ -42,7 +42,7 @@ export class SnapshotService {
|
||||
const workspaces = list<AppSnapshot["workspaces"][number]>(this.db, "workspaces", "created_at asc")
|
||||
.map((workspace) => ({ ...workspace, isPrimary: toBool(workspace.isPrimary) }));
|
||||
const providers = list<AppSnapshot["providers"][number]>(this.db, "providers", "label asc")
|
||||
.map((provider) => ({ ...provider, enabled: toBool(provider.enabled) }));
|
||||
.map((provider) => ({ ...provider, enabled: toBool(provider.enabled), backgroundEnabled: toBool(provider.backgroundEnabled) }));
|
||||
const skillBindings = list<AppSnapshot["skillBindings"][number]>(this.db, "skill_bindings", "priority asc")
|
||||
.map((binding) => ({ ...binding, enabled: toBool(binding.enabled) }));
|
||||
const files = list<AppSnapshot["files"][number]>(this.db, "file_objects", "created_at desc")
|
||||
|
||||
@@ -23,6 +23,7 @@ const api: DesktopApi = {
|
||||
saveProviderCredential: (input) => invoke(IPC_CHANNELS.saveProviderCredential, input),
|
||||
launchProviderSignIn: (input) => invoke(IPC_CHANNELS.launchProviderSignIn, input),
|
||||
checkProviderHealth: (input) => invoke(IPC_CHANNELS.checkProviderHealth, input),
|
||||
updateProviderPolicy: (input) => invoke(IPC_CHANNELS.updateProviderPolicy, input),
|
||||
openExternalUrl: (url) => invoke(IPC_CHANNELS.openExternalUrl, url),
|
||||
createProviderResponse: (input) => invoke(IPC_CHANNELS.createProviderResponse, input),
|
||||
importSkillDirectory: (input) => invoke(IPC_CHANNELS.importSkillDirectory, input),
|
||||
|
||||
@@ -99,6 +99,7 @@ export function SettingsPage({ snapshot, actions }: PageProps) {
|
||||
providers={providers}
|
||||
catalog={snapshot.providerCatalog}
|
||||
agents={agents}
|
||||
providerRequests={providerRequests}
|
||||
actions={actions}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -5,7 +5,9 @@ import type {
|
||||
AgentRecord,
|
||||
ProviderCatalogEntry,
|
||||
ProviderRecord,
|
||||
ProviderRequestRecord,
|
||||
} from "../../../../shared/domain";
|
||||
import { PROVIDER_DATA_POLICY } from "../../../../shared/domain";
|
||||
import { ProviderLogo } from "./ProviderLogo";
|
||||
import type { SnapshotActions } from "../../app/useSnapshot";
|
||||
|
||||
@@ -14,6 +16,7 @@ interface ProvidersPanelProps {
|
||||
providers: ProviderRecord[];
|
||||
catalog: ProviderCatalogEntry[];
|
||||
agents: AgentRecord[];
|
||||
providerRequests: ProviderRequestRecord[];
|
||||
actions: SnapshotActions;
|
||||
}
|
||||
|
||||
@@ -26,7 +29,7 @@ interface CloudFields {
|
||||
|
||||
const CLOUD_DEFAULTS: CloudFields = { baseUrl: "", region: "", projectId: "", deployment: "" };
|
||||
|
||||
export function ProvidersPanel({ orgId, providers, catalog, agents, actions }: ProvidersPanelProps) {
|
||||
export function ProvidersPanel({ orgId, providers, catalog, agents, providerRequests, actions }: ProvidersPanelProps) {
|
||||
const [active, setActive] = useState<ProviderRecord | null>(null);
|
||||
const [signInLaunched, setSignInLaunched] = useState(false);
|
||||
const connected = providers.filter((provider) => provider.enabled);
|
||||
@@ -76,6 +79,12 @@ export function ProvidersPanel({ orgId, providers, catalog, agents, actions }: P
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<ProviderPolicyTable
|
||||
orgId={orgId}
|
||||
providers={connected}
|
||||
providerRequests={providerRequests}
|
||||
actions={actions}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -110,6 +119,124 @@ export function ProvidersPanel({ orgId, providers, catalog, agents, actions }: P
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderPolicyTable({
|
||||
orgId,
|
||||
providers,
|
||||
providerRequests,
|
||||
actions,
|
||||
}: {
|
||||
orgId: string;
|
||||
providers: ProviderRecord[];
|
||||
providerRequests: ProviderRequestRecord[];
|
||||
actions: SnapshotActions;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="section-label" style={{ marginBottom: 10 }}>Runtime policy</div>
|
||||
<div className="card" style={{ padding: 0, overflow: "hidden", marginBottom: 28 }}>
|
||||
<div
|
||||
className="table-row head"
|
||||
style={{ gridTemplateColumns: "minmax(0, 1.4fr) minmax(140px, 1fr) 120px 132px 96px" }}
|
||||
>
|
||||
<div>Provider</div><div>Data policy</div><div>Background</div><div>Monthly limit</div><div>Usage</div>
|
||||
</div>
|
||||
{providers.map((provider, index) => (
|
||||
<ProviderPolicyRow
|
||||
key={`${provider.id}:${provider.dataPolicy}:${provider.backgroundEnabled}:${provider.monthlyRequestLimit}`}
|
||||
orgId={orgId}
|
||||
provider={provider}
|
||||
requests={providerRequests.filter((request) => request.providerId === provider.id && inCurrentUtcMonth(request.createdAt))}
|
||||
last={index === providers.length - 1}
|
||||
actions={actions}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderPolicyRow({
|
||||
orgId,
|
||||
provider,
|
||||
requests,
|
||||
last,
|
||||
actions,
|
||||
}: {
|
||||
orgId: string;
|
||||
provider: ProviderRecord;
|
||||
requests: ProviderRequestRecord[];
|
||||
last: boolean;
|
||||
actions: SnapshotActions;
|
||||
}) {
|
||||
const [dataPolicy, setDataPolicy] = useState(provider.dataPolicy);
|
||||
const [backgroundEnabled, setBackgroundEnabled] = useState(provider.backgroundEnabled);
|
||||
const [monthlyRequestLimit, setMonthlyRequestLimit] = useState(String(provider.monthlyRequestLimit));
|
||||
|
||||
const savePolicy = () => {
|
||||
void actions.run(() => window.agentCompany.updateProviderPolicy({
|
||||
orgId,
|
||||
providerId: provider.id,
|
||||
dataPolicy,
|
||||
backgroundEnabled,
|
||||
monthlyRequestLimit: Number(monthlyRequestLimit),
|
||||
workspaceScope: provider.workspaceScope === "workspace" ? "workspace" : "organization",
|
||||
}));
|
||||
};
|
||||
|
||||
const updateDataPolicy = (value: ProviderRecord["dataPolicy"]) => {
|
||||
setDataPolicy(value);
|
||||
if (value === PROVIDER_DATA_POLICY.ZERO_DATA_RETENTION) setBackgroundEnabled(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="table-row"
|
||||
style={{
|
||||
gridTemplateColumns: "minmax(0, 1.4fr) minmax(140px, 1fr) 120px 132px 96px",
|
||||
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 className="mono" style={{ fontSize: 11, color: "var(--text-3)" }}>{provider.workspaceScope} scope</div>
|
||||
</div>
|
||||
<select
|
||||
className="input"
|
||||
aria-label={`${provider.label} data policy`}
|
||||
value={dataPolicy}
|
||||
onChange={(event) => updateDataPolicy(event.target.value as ProviderRecord["dataPolicy"])}
|
||||
>
|
||||
<option value={PROVIDER_DATA_POLICY.STANDARD}>Standard retention</option>
|
||||
<option value={PROVIDER_DATA_POLICY.ZERO_DATA_RETENTION}>Zero data retention</option>
|
||||
</select>
|
||||
<label className="row" style={{ gap: 8, fontSize: 12, color: "var(--text-2)" }}>
|
||||
<input
|
||||
aria-label={`${provider.label} background execution`}
|
||||
type="checkbox"
|
||||
checked={backgroundEnabled}
|
||||
disabled={dataPolicy === PROVIDER_DATA_POLICY.ZERO_DATA_RETENTION}
|
||||
onChange={(event) => setBackgroundEnabled(event.target.checked)}
|
||||
/>
|
||||
Allow
|
||||
</label>
|
||||
<input
|
||||
className="input"
|
||||
aria-label={`${provider.label} monthly request limit`}
|
||||
type="number"
|
||||
min={1}
|
||||
value={monthlyRequestLimit}
|
||||
onChange={(event) => setMonthlyRequestLimit(event.target.value)}
|
||||
/>
|
||||
<div>
|
||||
<div className="num mono">{requests.length} / {provider.monthlyRequestLimit}</div>
|
||||
<button type="button" className="button small" aria-label={`Save ${provider.label} policy`} onClick={savePolicy}>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderConnectFields({
|
||||
provider,
|
||||
entry,
|
||||
@@ -311,3 +438,9 @@ function readableAuth(authType: ProviderRecord["authType"]): string {
|
||||
if (authType === "cloud") return "Cloud";
|
||||
return authType;
|
||||
}
|
||||
|
||||
function inCurrentUtcMonth(iso: string): boolean {
|
||||
const value = new Date(iso);
|
||||
const now = new Date();
|
||||
return value.getUTCFullYear() === now.getUTCFullYear() && value.getUTCMonth() === now.getUTCMonth();
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import type {
|
||||
ComplianceExportType,
|
||||
ComplianceSubjectType,
|
||||
LegalHoldEntityType,
|
||||
ProviderDataPolicy,
|
||||
ProviderExecutionMode,
|
||||
RetentionFreezeScopeType,
|
||||
RiskLevel,
|
||||
SearchEntityType,
|
||||
@@ -127,11 +129,21 @@ export interface CheckProviderHealthInput {
|
||||
providerId: string;
|
||||
}
|
||||
|
||||
export interface UpdateProviderPolicyInput {
|
||||
orgId: string;
|
||||
providerId: string;
|
||||
dataPolicy: ProviderDataPolicy;
|
||||
backgroundEnabled: boolean;
|
||||
monthlyRequestLimit: number;
|
||||
workspaceScope: "organization" | "workspace";
|
||||
}
|
||||
|
||||
export interface CreateProviderResponseInput {
|
||||
orgId: string;
|
||||
providerId: string;
|
||||
prompt: string;
|
||||
instructions: string | null;
|
||||
executionMode?: ProviderExecutionMode;
|
||||
}
|
||||
|
||||
export interface ImportSkillDirectoryInput {
|
||||
|
||||
@@ -33,6 +33,7 @@ import type {
|
||||
SetLegalHoldStateInput,
|
||||
SetRetentionFreezeStateInput,
|
||||
SetScheduledPostStateInput,
|
||||
UpdateProviderPolicyInput,
|
||||
UpdateTaskStatusInput,
|
||||
} from "./commands";
|
||||
|
||||
@@ -57,6 +58,7 @@ export interface DesktopApi {
|
||||
saveProviderCredential(input: SaveProviderCredentialInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
launchProviderSignIn(input: LaunchProviderSignInInput): Promise<DesktopResult<null>>;
|
||||
checkProviderHealth(input: CheckProviderHealthInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
updateProviderPolicy(input: UpdateProviderPolicyInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
openExternalUrl(url: string): Promise<DesktopResult<null>>;
|
||||
createProviderResponse(input: CreateProviderResponseInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
importSkillDirectory(input: ImportSkillDirectoryInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
|
||||
@@ -1,57 +1,18 @@
|
||||
import type { BoardInterventionRecord as BoardInterventionRecordModel, ChatThreadBoardState as ChatThreadBoardStateModel } from "./board-domain";
|
||||
import type { ComplianceExportRecord, LegalHoldRecord, RetentionFreezeRecord } from "./compliance-domain";
|
||||
import type { ProviderDataPolicy, ProviderExecutionMode } from "./provider-policy-domain";
|
||||
export { BOARD_INTERVENTION_ACTION, BOARD_INTERVENTION_MODE, CHAT_THREAD_BOARD_STATE, type BoardInterventionAction, type BoardInterventionMode, type BoardInterventionRecord, type ChatThreadBoardState } from "./board-domain";
|
||||
export { COMPLIANCE_EXPORT_STATE, COMPLIANCE_EXPORT_TYPE, type ComplianceExportRecord, type ComplianceExportState, type ComplianceExportType, type ComplianceSubjectType, type LegalHoldEntityType, type LegalHoldRecord, type RetentionFreezeRecord, type RetentionFreezeScopeType } from "./compliance-domain";
|
||||
export { PROVIDER_DATA_POLICY, PROVIDER_EXECUTION_MODE, type ProviderDataPolicy, type ProviderExecutionMode } from "./provider-policy-domain";
|
||||
export { SEARCH_ENTITY_TYPES, type SearchEntityType, type SearchResultRecord } from "./search-domain";
|
||||
export const TASK_STATUS = {
|
||||
BACKLOG: "backlog",
|
||||
IN_PROGRESS: "in_progress",
|
||||
REVIEW: "review",
|
||||
DONE: "done",
|
||||
} as const;
|
||||
|
||||
export const APPROVAL_STATE = {
|
||||
PENDING: "pending",
|
||||
APPROVED: "approved",
|
||||
DECLINED: "declined",
|
||||
} as const;
|
||||
|
||||
export const RISK_LEVEL = {
|
||||
LOW: "low",
|
||||
MEDIUM: "medium",
|
||||
HIGH: "high",
|
||||
} as const;
|
||||
|
||||
export const TASK_PRIORITY = {
|
||||
LOW: "low",
|
||||
NORMAL: "normal",
|
||||
HIGH: "high",
|
||||
} as const;
|
||||
|
||||
export const PROVIDER_AUTH_TYPE = {
|
||||
API_KEY: "api_key",
|
||||
OAUTH_PKCE: "oauth_pkce",
|
||||
ACCOUNT_SUBSCRIPTION: "account_subscription",
|
||||
CLOUD: "cloud",
|
||||
} as const;
|
||||
|
||||
export const SOCIAL_PLATFORM = {
|
||||
X: "x",
|
||||
LINKEDIN: "linkedin",
|
||||
YOUTUBE: "youtube",
|
||||
INSTAGRAM: "instagram",
|
||||
} as const;
|
||||
|
||||
export const PROVIDER_REQUEST_STATE = {
|
||||
PENDING: "pending",
|
||||
SUCCEEDED: "succeeded",
|
||||
FAILED: "failed",
|
||||
} as const;
|
||||
|
||||
export const FILE_SCAN_STATE = {
|
||||
APPROVED: "approved",
|
||||
BLOCKED: "blocked",
|
||||
} as const;
|
||||
export const TASK_STATUS = { BACKLOG: "backlog", IN_PROGRESS: "in_progress", REVIEW: "review", DONE: "done" } as const;
|
||||
export const APPROVAL_STATE = { PENDING: "pending", APPROVED: "approved", DECLINED: "declined" } as const;
|
||||
export const RISK_LEVEL = { LOW: "low", MEDIUM: "medium", HIGH: "high" } as const;
|
||||
export const TASK_PRIORITY = { LOW: "low", NORMAL: "normal", HIGH: "high" } as const;
|
||||
export const PROVIDER_AUTH_TYPE = { API_KEY: "api_key", OAUTH_PKCE: "oauth_pkce", ACCOUNT_SUBSCRIPTION: "account_subscription", CLOUD: "cloud" } as const;
|
||||
export const SOCIAL_PLATFORM = { X: "x", LINKEDIN: "linkedin", YOUTUBE: "youtube", INSTAGRAM: "instagram" } as const;
|
||||
export const PROVIDER_REQUEST_STATE = { PENDING: "pending", SUCCEEDED: "succeeded", FAILED: "failed" } as const;
|
||||
export const FILE_SCAN_STATE = { APPROVED: "approved", BLOCKED: "blocked" } as const;
|
||||
|
||||
export type ValueOf<T> = T[keyof T];
|
||||
export type TaskStatus = ValueOf<typeof TASK_STATUS>;
|
||||
@@ -323,6 +284,10 @@ export interface ProviderRecord {
|
||||
healthState: string;
|
||||
lastCheckedAt: string | null;
|
||||
lastError: string | null;
|
||||
dataPolicy: ProviderDataPolicy;
|
||||
backgroundEnabled: boolean;
|
||||
monthlyRequestLimit: number;
|
||||
workspaceScope: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
@@ -353,6 +318,10 @@ export interface ProviderRequestRecord {
|
||||
rawResponseJson: string | null;
|
||||
state: ProviderRequestState;
|
||||
errorMessage: string | null;
|
||||
inputCharCount: number;
|
||||
outputCharCount: number;
|
||||
executionMode: ProviderExecutionMode;
|
||||
policyDecision: string;
|
||||
createdAt: string;
|
||||
completedAt: string | null;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ export const IPC_CHANNELS = {
|
||||
saveProviderCredential: "agentcompany:save-provider-credential",
|
||||
launchProviderSignIn: "agentcompany:launch-provider-sign-in",
|
||||
checkProviderHealth: "agentcompany:check-provider-health",
|
||||
updateProviderPolicy: "agentcompany:update-provider-policy",
|
||||
openExternalUrl: "agentcompany:open-external-url",
|
||||
createProviderResponse: "agentcompany:create-provider-response",
|
||||
importSkillDirectory: "agentcompany:import-skill-directory",
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
type ValueOf<T> = T[keyof T];
|
||||
|
||||
export const PROVIDER_DATA_POLICY = {
|
||||
STANDARD: "standard",
|
||||
ZERO_DATA_RETENTION: "zero_data_retention",
|
||||
} as const;
|
||||
|
||||
export const PROVIDER_EXECUTION_MODE = {
|
||||
FOREGROUND: "foreground",
|
||||
BACKGROUND: "background",
|
||||
} as const;
|
||||
|
||||
export type ProviderDataPolicy = ValueOf<typeof PROVIDER_DATA_POLICY>;
|
||||
export type ProviderExecutionMode = ValueOf<typeof PROVIDER_EXECUTION_MODE>;
|
||||
@@ -97,6 +97,12 @@ test("onboards, hires, and creates work in the Electron shell", async () => {
|
||||
await expect(page.getByText("Published").first()).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Settings" }).click();
|
||||
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 page.getByRole("button", { name: "Save OpenAI policy" }).click();
|
||||
await expect(page.getByText("0 / 25").first()).toBeVisible();
|
||||
await page.getByRole("button", { name: "Compliance" }).click();
|
||||
await page.getByRole("button", { name: "Create" }).click();
|
||||
await expect(page.getByText("gdpr export · employee").first()).toBeVisible();
|
||||
|
||||
@@ -23,7 +23,7 @@ describe("domain services", () => {
|
||||
const connection = createConnection();
|
||||
|
||||
const rows = connection.db.prepare("select version from schema_migrations order by version asc").all() as Array<{ version: number }>;
|
||||
expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13]);
|
||||
expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14]);
|
||||
connection.close();
|
||||
});
|
||||
|
||||
|
||||
@@ -112,6 +112,7 @@ describe("migration service", () => {
|
||||
create table chat_read_state(org_id text, employee_id text, space_id text, last_read_message_id text);
|
||||
create table providers(id text primary key, org_id text, credential_id text);
|
||||
create table provider_requests(id text primary key, org_id text, provider_id text);
|
||||
create table provider_usage_windows(id text primary key, org_id text, provider_id text);
|
||||
create table credentials(id text primary key, org_id text);
|
||||
create table skill_definitions(id text primary key, org_id text);
|
||||
create table skill_revisions(id text primary key, skill_id text);
|
||||
@@ -157,6 +158,7 @@ describe("migration service", () => {
|
||||
insert into chat_presence(principal_type, principal_id) values ('agent', 'agent-1');
|
||||
insert into chat_read_state(org_id, employee_id, space_id, last_read_message_id) values ('org-1', 'emp-1', 'space-1', 'msg-1');
|
||||
insert into provider_requests(id, org_id, provider_id) values ('req-1', 'org-1', 'prov-1');
|
||||
insert into provider_usage_windows(id, org_id, provider_id) values ('usage-1', 'org-1', 'prov-1');
|
||||
insert into skill_definitions(id, org_id) values ('skill-1', 'org-1');
|
||||
insert into skill_revisions(id, skill_id) values ('rev-1', 'skill-1'), ('rev-2', 'skill-1');
|
||||
insert into skill_artifacts(id, revision_id) values ('artifact-1', 'rev-1');
|
||||
@@ -191,12 +193,14 @@ describe("migration service", () => {
|
||||
expect(body.tableCounts.board_interventions).toBe(1);
|
||||
expect(body.tableCounts.file_access_grants).toBe(1);
|
||||
expect(body.tableCounts.provider_requests).toBe(1);
|
||||
expect(body.tableCounts.provider_usage_windows).toBe(1);
|
||||
expect(body.tableCounts.compliance_exports).toBe(1);
|
||||
expect(body.tableCounts.search_documents).toBe(1);
|
||||
expect(body.tableChecksums.audit_events).toHaveLength(64);
|
||||
expect(body.idMaps.skill_runtime_snapshots).toEqual(["snapshot-1"]);
|
||||
expect(body.idMaps.board_interventions).toEqual(["bdi-1"]);
|
||||
expect(body.idMaps.retention_freezes).toEqual(["freeze-1"]);
|
||||
expect(body.idMaps.provider_usage_windows).toEqual(["usage-1"]);
|
||||
connection.close();
|
||||
});
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { OrganizationService } from "../../src/main/services/organization-servic
|
||||
import { ProviderService } from "../../src/main/services/provider-service";
|
||||
import { providerAdapters, type ProviderAdapterRegistry, type ProviderAuthProfile } from "../../src/main/services/provider-adapters";
|
||||
import { SnapshotService } from "../../src/main/services/snapshot-service";
|
||||
import { PROVIDER_DATA_POLICY, PROVIDER_EXECUTION_MODE } from "../../src/shared/domain";
|
||||
import { PROVIDER_CATALOG } from "../../src/shared/provider-catalog";
|
||||
|
||||
function createConnection(): DatabaseConnection {
|
||||
@@ -121,6 +122,111 @@ describe("provider service", () => {
|
||||
connection.close();
|
||||
});
|
||||
|
||||
test("tracks provider usage windows and enforces monthly request limits", async () => {
|
||||
const connection = createConnection();
|
||||
const organization = new OrganizationService(connection.db);
|
||||
const snapshot = new SnapshotService(connection.db);
|
||||
const adapters: ProviderAdapterRegistry = {
|
||||
...providerAdapters,
|
||||
openai: {
|
||||
...providerAdapters.openai,
|
||||
parseAuthProfile: (secret) => ({ type: "api_key", apiKey: secret.trim() }),
|
||||
checkHealth: async () => ({ checkedAt: new Date().toISOString() }),
|
||||
createResponse: async ({ prompt }) => ({ text: `accepted:${prompt}`, rawResponseJson: "{}" }),
|
||||
},
|
||||
};
|
||||
const providers = new ProviderService(connection.db, adapters, testVault);
|
||||
let orgId = "";
|
||||
let openAiProviderId = "";
|
||||
|
||||
connection.transaction(() => {
|
||||
orgId = createOrganization(organization);
|
||||
openAiProviderId = providerId(connection, orgId, "openai");
|
||||
providers.saveCredential({ orgId, providerId: openAiProviderId, secret: "sk-live-provider", model: "gpt-5.4" });
|
||||
providers.updatePolicy({
|
||||
orgId,
|
||||
providerId: openAiProviderId,
|
||||
dataPolicy: PROVIDER_DATA_POLICY.STANDARD,
|
||||
backgroundEnabled: false,
|
||||
monthlyRequestLimit: 1,
|
||||
workspaceScope: "organization",
|
||||
});
|
||||
});
|
||||
await providers.createResponse({
|
||||
orgId,
|
||||
providerId: openAiProviderId,
|
||||
prompt: "Draft board memo",
|
||||
instructions: null,
|
||||
});
|
||||
await expect(providers.createResponse({
|
||||
orgId,
|
||||
providerId: openAiProviderId,
|
||||
prompt: "Second board memo",
|
||||
instructions: null,
|
||||
})).rejects.toThrow("Provider monthly request limit has been reached.");
|
||||
|
||||
const usage = connection.db.prepare(`
|
||||
select request_count, input_char_count, output_char_count
|
||||
from provider_usage_windows
|
||||
where org_id = ? and provider_id = ?
|
||||
`).get(orgId, openAiProviderId) as { request_count: number; input_char_count: number; output_char_count: number };
|
||||
expect(usage).toEqual({ request_count: 1, input_char_count: 16, output_char_count: 25 });
|
||||
expect(snapshot.load().providerRequests[0]).toMatchObject({
|
||||
providerId: openAiProviderId,
|
||||
inputCharCount: 16,
|
||||
outputCharCount: 25,
|
||||
executionMode: PROVIDER_EXECUTION_MODE.FOREGROUND,
|
||||
policyDecision: "allowed:standard",
|
||||
});
|
||||
connection.close();
|
||||
});
|
||||
|
||||
test("blocks background execution under zero data retention policy", async () => {
|
||||
const connection = createConnection();
|
||||
const organization = new OrganizationService(connection.db);
|
||||
let adapterCalled = false;
|
||||
const adapters: ProviderAdapterRegistry = {
|
||||
...providerAdapters,
|
||||
openai: {
|
||||
...providerAdapters.openai,
|
||||
parseAuthProfile: (secret) => ({ type: "api_key", apiKey: secret.trim() }),
|
||||
checkHealth: async () => ({ checkedAt: new Date().toISOString() }),
|
||||
createResponse: async () => {
|
||||
adapterCalled = true;
|
||||
return { text: "blocked", rawResponseJson: "{}" };
|
||||
},
|
||||
},
|
||||
};
|
||||
const providers = new ProviderService(connection.db, adapters, testVault);
|
||||
let orgId = "";
|
||||
let openAiProviderId = "";
|
||||
|
||||
connection.transaction(() => {
|
||||
orgId = createOrganization(organization);
|
||||
openAiProviderId = providerId(connection, orgId, "openai");
|
||||
providers.saveCredential({ orgId, providerId: openAiProviderId, secret: "sk-live-provider", model: "gpt-5.4" });
|
||||
providers.updatePolicy({
|
||||
orgId,
|
||||
providerId: openAiProviderId,
|
||||
dataPolicy: PROVIDER_DATA_POLICY.ZERO_DATA_RETENTION,
|
||||
backgroundEnabled: false,
|
||||
monthlyRequestLimit: 10,
|
||||
workspaceScope: "organization",
|
||||
});
|
||||
});
|
||||
|
||||
await expect(providers.createResponse({
|
||||
orgId,
|
||||
providerId: openAiProviderId,
|
||||
prompt: "Run background analysis",
|
||||
instructions: null,
|
||||
executionMode: PROVIDER_EXECUTION_MODE.BACKGROUND,
|
||||
})).rejects.toThrow("Background execution conflicts with zero data retention policy.");
|
||||
expect(adapterCalled).toBe(false);
|
||||
expect(connection.db.prepare("select count(*) as count from provider_requests").get()).toEqual({ count: 0 });
|
||||
connection.close();
|
||||
});
|
||||
|
||||
test("validates cloud auth profiles before saving credentials", () => {
|
||||
const connection = createConnection();
|
||||
const organization = new OrganizationService(connection.db);
|
||||
|
||||
Reference in new issue
Block a user