feat: govern chat model selection by policy

This commit is contained in:
thanhnv
2026-07-10 17:09:28 +09:00
parent f8215cd2eb
commit b0ced79af5
9 changed files with 186 additions and 12 deletions
@@ -50,4 +50,12 @@ export class ChatController {
agents(@Headers() headers: Record<string, string | string[] | undefined>) {
return ok(this.svc.listAgents(actorFromHeaders(headers)));
}
@Get('models')
models(
@Headers() headers: Record<string, string | string[] | undefined>,
@Query('modelRole') modelRole?: string,
) {
return ok(this.svc.listModels(actorFromHeaders(headers), modelRole || 'read_only'));
}
}
@@ -10,6 +10,7 @@ export interface ChatAskInput {
chatId?: string;
agentId?: string;
skillId?: string;
modelProvider?: string;
delegationLevel?: number;
}
@@ -78,6 +79,7 @@ export class ChatService {
];
if (input.agentId) args.push('--agent', input.agentId);
if (input.skillId) args.push('--skill', input.skillId);
if (input.modelProvider) args.push('--model-provider', input.modelProvider);
if (input.delegationLevel !== undefined) args.push('--delegation-level', String(input.delegationLevel));
const res = runPython(CHAT_CLI, args);
const parsed = parseJson<Record<string, any>>(res.stdout);
@@ -136,6 +138,7 @@ export class ChatService {
];
if (input.agentId) args.push('--agent', input.agentId);
if (input.skillId) args.push('--skill', input.skillId);
if (input.modelProvider) args.push('--model-provider', input.modelProvider);
if (input.delegationLevel !== undefined) args.push('--delegation-level', String(input.delegationLevel));
res.setHeader('Content-Type', 'application/x-ndjson; charset=utf-8');
res.setHeader('Cache-Control', 'no-cache');
@@ -176,6 +179,16 @@ export class ChatService {
throw new InternalServerErrorException(res.stderr || res.stdout || 'CHAT_AGENTS_FAILED');
}
listModels(actor: SettingsActor, modelRole = 'read_only') {
this.requireRead(actor);
const res = runPython(join(HARNESS_BIN, 'chat-model-resolver.py'), [
'list', '--actor', actor.actor, '--role', actor.role, '--model-role', modelRole,
]);
const parsed = parseJson<Record<string, unknown>>(res.stdout);
if (parsed) return parsed;
throw new InternalServerErrorException(res.stderr || res.stdout || 'CHAT_MODELS_FAILED');
}
private requireRead(actor: SettingsActor) {
const res = runPython(RBAC_CLI, [
'check',
@@ -185,6 +185,16 @@ export interface ChatAgent {
allowed_for_role?: boolean;
}
export interface ChatModelProvider {
id: string;
model: string;
class: string;
requires_preflight: boolean;
allowed: boolean;
configured: boolean;
reason: string;
}
export interface ChatReplay {
ok: boolean;
decision: 'MATCH' | 'DRIFT' | 'BREAK' | string;
@@ -268,11 +278,11 @@ export const api = {
post<{ proposal: any; audit_verify: { ok: boolean; output: string } }>('approvals/submit', body, actorHeaders(actor)),
decideApproval: (actor: SettingsActor, body: { id: string; decision: 'approve' | 'reject'; reason: string; approvalJwt?: string }) =>
post<{ proposal: any; applied: any; audit_verify: { ok: boolean; output: string } }>('approvals/decide', body, actorHeaders(actor)),
askChat: (actor: SettingsActor, body: { message: string; chatId?: string; agentId?: string; skillId?: string; delegationLevel?: number }) =>
askChat: (actor: SettingsActor, body: { message: string; chatId?: string; agentId?: string; skillId?: string; modelProvider?: string; delegationLevel?: number }) =>
post<ChatAnswer>('chat/ask', body, actorHeaders(actor)),
askChatStream: async (
actor: SettingsActor,
body: { message: string; chatId?: string; agentId?: string; skillId?: string; delegationLevel?: number },
body: { message: string; chatId?: string; agentId?: string; skillId?: string; modelProvider?: string; delegationLevel?: number },
onPhase: (phase: ChatStreamPhase) => void,
): Promise<void> => {
const base = import.meta.env.VITE_API_BASE_URL ?? '/api/v1';
@@ -314,6 +324,7 @@ export const api = {
replayChat: (chatId = '', turnId = '', tenant = '') => get<ChatReplay>(`chat/replay?chatId=${encodeURIComponent(chatId)}&turnId=${encodeURIComponent(turnId)}&tenant=${encodeURIComponent(tenant)}`),
chatActions: (actor: SettingsActor) => getWithHeaders<{ success: boolean; actions: ChatAction[] }>('chat/actions', actorHeaders(actor)),
chatAgents: (actor: SettingsActor) => getWithHeaders<{ success: boolean; agents: ChatAgent[] }>('chat/agents', actorHeaders(actor)),
chatModels: (actor: SettingsActor, modelRole: string) => getWithHeaders<{ success: boolean; default: string; providers: ChatModelProvider[] }>(`chat/models?modelRole=${encodeURIComponent(modelRole)}`, actorHeaders(actor)),
};
// Health is raw (not enveloped) + carries HTTP status.
@@ -1,6 +1,6 @@
import { type KeyboardEvent, useMemo, useState } from 'react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { api, ChatAction, ChatAgent, ChatAnswer, ChatHistoryTurn, ChatStreamPhase, SettingsActor } from '../lib/api';
import { api, ChatAction, ChatAgent, ChatAnswer, ChatHistoryTurn, ChatModelProvider, ChatStreamPhase, SettingsActor } from '../lib/api';
import { Card, StatusBadge } from '../components/ui/Card';
const ROLES = ['viewer', 'auditor', 'operator', 'project-admin', 'org-admin'];
@@ -82,6 +82,7 @@ export function Chat() {
const [chatId, setChatId] = useState('chat-default');
const [agentId, setAgentId] = useState('evidence-reader');
const [skillId, setSkillId] = useState('evidence-summary');
const [modelProvider, setModelProvider] = useState('local');
const [delegationLevel, setDelegationLevel] = useState(0);
const [message, setMessage] = useState('');
const [last, setLast] = useState<ChatAnswer | null>(null);
@@ -100,6 +101,9 @@ export function Chat() {
const agents = agentsQuery.data?.agents ?? [];
const selectedAgent = agents.find((agent) => agent.id === agentId) ?? agents.find((agent) => agent.allowed_for_role) ?? agents[0];
const selectedSkill = selectedAgent?.skills_allowed.includes(skillId) ? skillId : (selectedAgent?.skills_allowed[0] ?? '');
const modelsQuery = useQuery({ queryKey: ['chat-models', actor, selectedAgent?.model_role], queryFn: () => api.chatModels(actor, selectedAgent?.model_role ?? 'read_only'), retry: false });
const models = modelsQuery.data?.providers ?? [];
const selectedModel = models.find((provider) => provider.id === modelProvider) ?? models.find((provider) => provider.id === modelsQuery.data?.default) ?? models[0];
const persistedMessages = useMemo(() => (historyQuery.data?.turns ?? []).flatMap(turnMessages), [historyQuery.data]);
const liveAlreadyStored = Boolean(last?.turn_id && historyQuery.data?.turns.some((turn) => turn.turn_id === last.turn_id));
const liveMessages: WorkspaceMessage[] = [];
@@ -120,6 +124,7 @@ export function Chat() {
chatId,
agentId: override?.agentId ?? selectedAgent?.id ?? agentId,
skillId: override?.skillId ?? selectedSkill,
modelProvider: selectedModel?.id,
delegationLevel,
}),
onSuccess: (answer) => {
@@ -143,7 +148,7 @@ export function Chat() {
setDraftText(null);
setPendingMessage(text);
try {
await api.askChatStream(actor, { message: text, chatId, agentId: selectedAgent?.id ?? agentId, skillId: selectedSkill, delegationLevel }, (phase) => {
await api.askChatStream(actor, { message: text, chatId, agentId: selectedAgent?.id ?? agentId, skillId: selectedSkill, modelProvider: selectedModel?.id, delegationLevel }, (phase) => {
if (phase.phase === 'draft') setDraftText(phase.answer);
if (phase.phase === 'final') {
setDraftText(null);
@@ -267,7 +272,13 @@ export function Chat() {
<div className="text-[10px] font-bold uppercase tracking-[0.15em] text-slate-400">Governance context</div>
<div className="mt-3 rounded-xl border border-slate-200 bg-white p-3.5">
<div className="flex items-center justify-between gap-2"><div className="text-sm font-semibold text-slate-800">{selectedAgent?.label ?? 'Evidence reader'}</div><StatusBadge value={selectedAgent?.allowed_for_role ? 'allowed' : 'locked'} /></div>
<div className="mt-1 text-xs text-slate-500">{selectedAgent?.model_role ?? 'read_only'} · {selectedSkill || 'no skill selected'}</div>
<div className="mt-1 text-xs text-slate-500">{selectedAgent?.model_role ?? 'read_only'} · {selectedSkill || 'no skill selected'} · {selectedModel?.model ?? 'model unavailable'}</div>
<div className="mt-3 grid gap-2 border-t border-slate-100 pt-3 text-xs text-slate-600">
<label>Agent<select value={selectedAgent?.id ?? agentId} onChange={(event) => { const next = agents.find((agent) => agent.id === event.target.value); setAgentId(event.target.value); setSkillId(next?.skills_allowed[0] ?? ''); }} className="mt-1 w-full rounded-lg border border-slate-200 px-2 py-1.5 text-xs">{agents.map((agent: ChatAgent) => <option key={agent.id} value={agent.id} disabled={!agent.allowed_for_role}>{agent.label}{agent.allowed_for_role ? '' : ' (locked)'}</option>)}</select></label>
<label>Skill<select value={selectedSkill} onChange={(event) => setSkillId(event.target.value)} className="mt-1 w-full rounded-lg border border-slate-200 px-2 py-1.5 text-xs">{(selectedAgent?.skills_allowed ?? []).map((skill) => <option key={skill}>{skill}</option>)}</select></label>
<label>Model provider<select value={selectedModel?.id ?? ''} onChange={(event) => setModelProvider(event.target.value)} className="mt-1 w-full rounded-lg border border-slate-200 px-2 py-1.5 text-xs">{models.map((provider: ChatModelProvider) => <option key={provider.id} value={provider.id} disabled={!provider.allowed || !provider.configured}>{provider.id} · {provider.model}{provider.allowed && provider.configured ? '' : ` (${provider.reason || 'not allowed'})`}</option>)}</select></label>
<p className="text-[11px] leading-4 text-slate-500">Provider is policy-bound to your role and agent; cloud/gateway routes enforce preflight.</p>
</div>
<details className="mt-3 border-t border-slate-100 pt-3 text-xs text-slate-600"><summary className="cursor-pointer font-medium text-slate-700">Session scope</summary><div className="mt-3 grid grid-cols-2 gap-2"><label className="col-span-2">Actor<input value={actor.actor} onChange={(event) => setActor({ ...actor, actor: event.target.value })} className="mt-1 w-full rounded-lg border border-slate-200 px-2 py-1.5 text-xs" /></label><label>Role<select value={actor.role} onChange={(event) => setActor({ ...actor, role: event.target.value })} className="mt-1 w-full rounded-lg border border-slate-200 px-2 py-1.5 text-xs">{ROLES.map((role) => <option key={role}>{role}</option>)}</select></label><label>Delegate<input type="number" min={0} max={5} value={delegationLevel} onChange={(event) => setDelegationLevel(Number(event.target.value || 0))} className="mt-1 w-full rounded-lg border border-slate-200 px-2 py-1.5 text-xs" /></label><label>Project<input value={actor.project} onChange={(event) => setActor({ ...actor, project: event.target.value })} className="mt-1 w-full rounded-lg border border-slate-200 px-2 py-1.5 text-xs" /></label><label>Tenant<input value={actor.tenant} onChange={(event) => setActor({ ...actor, tenant: event.target.value })} className="mt-1 w-full rounded-lg border border-slate-200 px-2 py-1.5 text-xs" /></label><label className="col-span-2">Agent<select value={selectedAgent?.id ?? agentId} onChange={(event) => { const next = agents.find((agent) => agent.id === event.target.value); setAgentId(event.target.value); setSkillId(next?.skills_allowed[0] ?? ''); }} className="mt-1 w-full rounded-lg border border-slate-200 px-2 py-1.5 text-xs">{agents.map((agent: ChatAgent) => <option key={agent.id} value={agent.id}>{agent.label}{agent.allowed_for_role ? '' : ' (locked)'}</option>)}</select></label><label className="col-span-2">Skill<select value={selectedSkill} onChange={(event) => setSkillId(event.target.value)} className="mt-1 w-full rounded-lg border border-slate-200 px-2 py-1.5 text-xs">{(selectedAgent?.skills_allowed ?? []).map((skill) => <option key={skill}>{skill}</option>)}</select></label></div></details>
</div>