feat: connect chat to managed model providers

This commit is contained in:
thanhnv
2026-07-10 17:40:44 +09:00
parent ef51c90d91
commit adce7717f7
9 changed files with 708 additions and 21 deletions
@@ -97,14 +97,37 @@ rebuild image Control Panel từ source mới nhất.
Đây là case đã chạy đầy đủ nhất. Đây là case đã chạy đầy đủ nhất.
### Kết nối và tải model lần đầu
1. Mở `/chat`, trong cột **Governance context** chọn
**Manage model connections**.
2. Với model local, bấm **Kết nối & tải model** tại `Ollama Local`. Endpoint mặc
định trong Docker là `http://host.docker.internal:11434`.
3. Với OmniRoute, giữ endpoint
`http://host.docker.internal:20128/v1` rồi bấm kết nối. Local gateway không
bắt buộc key; nếu production gateway có authentication thì cấu hình key ở
backend trước khi triển khai.
4. Với OpenAI/Codex API hoặc Anthropic/Claude API, nhập **API key**, không nhập
mật khẩu tài khoản ChatGPT/Claude. CASAN gọi Models API chính thức để tải
danh sách model mà key đó được phép dùng.
5. Chọn **Model mặc định**, đóng bảng kết nối, rồi chọn model cụ thể ngay trong
selector **Model** của Chat.
API key chỉ được gửi khi kết nối/reconnect, sau đó được mã hóa AES-256 theo
tenant trong CASAN state store. UI không có chức năng đọc lại key. Nếu bấm
**Disconnect**, bản ghi kết nối và secret tương ứng bị xóa.
Đăng nhập bằng tài khoản người dùng qua `codex login` hoặc Claude Code OAuth là
luồng đăng nhập riêng của các CLI đó. CASAN Chat không sao chép token đăng nhập
đã cache từ Codex/Claude Code; đây là ranh giới an toàn có chủ ý.
### Hỏi đáp trên evidence ### Hỏi đáp trên evidence
1. Mở `/chat`. 1. Mở `/chat`.
2. Chọn Agent `Evidence Reader`. 2. Chọn Agent `Evidence Reader`.
3. Chọn Skill `evidence-summary`. 3. Chọn Skill `evidence-summary`.
4. Chọn model: 4. Chọn model đã tải, ví dụ `Ollama Local · ornith:9b` hoặc
- `local` để dùng Ornith/Ollama trên máy Mac; `OmniRoute Gateway · auto/best-coding`.
- `omniroute` nếu key và gateway đã được truyền vào container.
5. Hỏi: `Summarize the remaining production gaps and cite the evidence.` 5. Hỏi: `Summarize the remaining production gaps and cite the evidence.`
CASAN sẽ quét câu hỏi, lấy context trong whitelist, gọi model nếu provider sẵn CASAN sẽ quét câu hỏi, lấy context trong whitelist, gọi model nếu provider sẵn
@@ -2,7 +2,7 @@ import { Body, Controller, Get, Headers, Inject, Post, Query, Res } from '@nestj
import type { Response } from 'express'; import type { Response } from 'express';
import { ok } from '../common/api-response.js'; import { ok } from '../common/api-response.js';
import { actorFromHeaders } from '../common/auth-context.js'; import { actorFromHeaders } from '../common/auth-context.js';
import { ChatAskInput, ChatService } from './chat.service.js'; import { ChatAskInput, ChatConnectionInput, ChatDefaultModelInput, ChatService } from './chat.service.js';
@Controller('api/v1/chat') @Controller('api/v1/chat')
export class ChatController { export class ChatController {
@@ -58,4 +58,41 @@ export class ChatController {
) { ) {
return ok(this.svc.listModels(actorFromHeaders(headers), modelRole || 'read_only')); return ok(this.svc.listModels(actorFromHeaders(headers), modelRole || 'read_only'));
} }
@Get('connections')
connections(@Headers() headers: Record<string, string | string[] | undefined>) {
return ok(this.svc.connections(actorFromHeaders(headers)));
}
@Post('connections/connect')
connect(
@Headers() headers: Record<string, string | string[] | undefined>,
@Body() body: ChatConnectionInput,
) {
return ok(this.svc.connectProvider(body, actorFromHeaders(headers)));
}
@Post('connections/refresh')
refreshConnection(
@Headers() headers: Record<string, string | string[] | undefined>,
@Body() body: { provider: string },
) {
return ok(this.svc.refreshProvider(body.provider, actorFromHeaders(headers)));
}
@Post('connections/default')
defaultModel(
@Headers() headers: Record<string, string | string[] | undefined>,
@Body() body: ChatDefaultModelInput,
) {
return ok(this.svc.setDefaultModel(body, actorFromHeaders(headers)));
}
@Post('connections/disconnect')
disconnect(
@Headers() headers: Record<string, string | string[] | undefined>,
@Body() body: { provider: string },
) {
return ok(this.svc.disconnectProvider(body.provider, actorFromHeaders(headers)));
}
} }
@@ -1,4 +1,4 @@
import { ForbiddenException, Injectable, InternalServerErrorException } from '@nestjs/common'; import { BadRequestException, ForbiddenException, Injectable, InternalServerErrorException } from '@nestjs/common';
import { execFileSync, spawn } from 'node:child_process'; import { execFileSync, spawn } from 'node:child_process';
import { join } from 'node:path'; import { join } from 'node:path';
import type { Response } from 'express'; import type { Response } from 'express';
@@ -11,9 +11,22 @@ export interface ChatAskInput {
agentId?: string; agentId?: string;
skillId?: string; skillId?: string;
modelProvider?: string; modelProvider?: string;
modelId?: string;
delegationLevel?: number; delegationLevel?: number;
} }
export interface ChatConnectionInput {
provider: string;
endpoint?: string;
apiKey?: string;
defaultModel?: string;
}
export interface ChatDefaultModelInput {
provider: string;
model: string;
}
interface CommandResult { interface CommandResult {
status: number; status: number;
stdout: string; stdout: string;
@@ -26,14 +39,23 @@ const OPERATOR_CLI = join(HARNESS_BIN, 'chat-operator.py');
const AGENT_CLI = join(HARNESS_BIN, 'chat-agent-resolver.py'); const AGENT_CLI = join(HARNESS_BIN, 'chat-agent-resolver.py');
const REPLAY_CLI = join(HARNESS_BIN, 'chat-replay.py'); const REPLAY_CLI = join(HARNESS_BIN, 'chat-replay.py');
const RBAC_CLI = join(HARNESS_BIN, 'rbac-check.py'); const RBAC_CLI = join(HARNESS_BIN, 'rbac-check.py');
const CONNECTIONS_CLI = join(HARNESS_BIN, 'model-connections.py');
function runPython(script: string, args: string[], extraEnv: NodeJS.ProcessEnv = {}): CommandResult { const CONNECTION_POLICY_PROVIDER: Record<string, string> = {
openai: 'cloud-openai',
anthropic: 'cloud-anthropic',
omniroute: 'omniroute',
ollama: 'local',
};
function runPython(script: string, args: string[], extraEnv: NodeJS.ProcessEnv = {}, input?: string): CommandResult {
try { try {
const stdout = execFileSync('python3', [script, ...args], { const stdout = execFileSync('python3', [script, ...args], {
cwd: APP_ROOT, cwd: APP_ROOT,
encoding: 'utf8', encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'], stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env, ...extraEnv }, env: { ...process.env, ...extraEnv },
input,
}); });
return { status: 0, stdout: stdout.trim(), stderr: '' }; return { status: 0, stdout: stdout.trim(), stderr: '' };
} catch (err: any) { } catch (err: any) {
@@ -79,9 +101,10 @@ export class ChatService {
]; ];
if (input.agentId) args.push('--agent', input.agentId); if (input.agentId) args.push('--agent', input.agentId);
if (input.skillId) args.push('--skill', input.skillId); if (input.skillId) args.push('--skill', input.skillId);
if (input.modelProvider) args.push('--model-provider', input.modelProvider); const runtime = this.modelRuntime(input, actor);
if (runtime.policyProvider) args.push('--model-provider', runtime.policyProvider);
if (input.delegationLevel !== undefined) args.push('--delegation-level', String(input.delegationLevel)); if (input.delegationLevel !== undefined) args.push('--delegation-level', String(input.delegationLevel));
const res = runPython(CHAT_CLI, args); const res = runPython(CHAT_CLI, args, runtime.env);
const parsed = parseJson<Record<string, any>>(res.stdout); const parsed = parseJson<Record<string, any>>(res.stdout);
if (parsed) { if (parsed) {
return { ...parsed, actor, audit_verify: this.verifyAudit() }; return { ...parsed, actor, audit_verify: this.verifyAudit() };
@@ -138,12 +161,13 @@ export class ChatService {
]; ];
if (input.agentId) args.push('--agent', input.agentId); if (input.agentId) args.push('--agent', input.agentId);
if (input.skillId) args.push('--skill', input.skillId); if (input.skillId) args.push('--skill', input.skillId);
if (input.modelProvider) args.push('--model-provider', input.modelProvider); const runtime = this.modelRuntime(input, actor);
if (runtime.policyProvider) args.push('--model-provider', runtime.policyProvider);
if (input.delegationLevel !== undefined) args.push('--delegation-level', String(input.delegationLevel)); if (input.delegationLevel !== undefined) args.push('--delegation-level', String(input.delegationLevel));
res.setHeader('Content-Type', 'application/x-ndjson; charset=utf-8'); res.setHeader('Content-Type', 'application/x-ndjson; charset=utf-8');
res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Cache-Control', 'no-cache');
res.setHeader('X-Accel-Buffering', 'no'); res.setHeader('X-Accel-Buffering', 'no');
const child = spawn('python3', args, { cwd: APP_ROOT, env: { ...process.env } }); const child = spawn('python3', args, { cwd: APP_ROOT, env: { ...process.env, ...runtime.env } });
child.stdout.on('data', (chunk) => res.write(chunk)); child.stdout.on('data', (chunk) => res.write(chunk));
child.on('error', () => { child.on('error', () => {
if (!res.headersSent) res.status(500); if (!res.headersSent) res.status(500);
@@ -189,6 +213,71 @@ export class ChatService {
throw new InternalServerErrorException(res.stderr || res.stdout || 'CHAT_MODELS_FAILED'); throw new InternalServerErrorException(res.stderr || res.stdout || 'CHAT_MODELS_FAILED');
} }
connections(actor: SettingsActor) {
this.requireRead(actor);
return this.connectionCommand(['list'], actor);
}
connectProvider(input: ChatConnectionInput, actor: SettingsActor) {
this.requireConnectionAdmin(actor);
if (!input.provider) throw new BadRequestException('provider required');
return this.connectionCommand(
['connect', '--provider', input.provider],
actor,
JSON.stringify({ endpoint: input.endpoint ?? '', apiKey: input.apiKey ?? '', defaultModel: input.defaultModel ?? '' }),
);
}
refreshProvider(provider: string, actor: SettingsActor) {
this.requireConnectionAdmin(actor);
if (!provider) throw new BadRequestException('provider required');
return this.connectionCommand(['refresh', '--provider', provider], actor);
}
setDefaultModel(input: ChatDefaultModelInput, actor: SettingsActor) {
this.requireConnectionAdmin(actor);
if (!input.provider || !input.model) throw new BadRequestException('provider and model required');
return this.connectionCommand(['set-default', '--provider', input.provider, '--model', input.model], actor);
}
disconnectProvider(provider: string, actor: SettingsActor) {
this.requireConnectionAdmin(actor);
if (!provider) throw new BadRequestException('provider required');
return this.connectionCommand(['disconnect', '--provider', provider], actor);
}
private connectionCommand(args: string[], actor: SettingsActor, stdin?: string) {
const res = runPython(CONNECTIONS_CLI, args, { CASAN_TENANT_ID: actor.tenant || 'default' }, stdin);
const parsed = parseJson<Record<string, unknown>>(res.stdout);
if (res.status === 0 && parsed) return parsed;
const errorPayload = parseJson<{ reason?: string }>(res.stderr);
throw new BadRequestException(errorPayload?.reason || res.stderr || res.stdout || 'MODEL_CONNECTION_FAILED');
}
private modelRuntime(input: ChatAskInput, actor: SettingsActor): { policyProvider: string; env: NodeJS.ProcessEnv } {
const provider = input.modelProvider || '';
const policyProvider = CONNECTION_POLICY_PROVIDER[provider] || provider;
if (!input.modelId) return { policyProvider, env: { CASAN_TENANT_ID: actor.tenant || 'default' } };
if (!CONNECTION_POLICY_PROVIDER[provider]) throw new BadRequestException('connected provider required for model selection');
const res = runPython(
CONNECTIONS_CLI,
['runtime-env', '--provider', provider, '--model', input.modelId],
{ CASAN_TENANT_ID: actor.tenant || 'default' },
);
const parsed = parseJson<{ success?: boolean; env?: Record<string, string> }>(res.stdout);
if (res.status !== 0 || !parsed?.success || !parsed.env) {
const errorPayload = parseJson<{ reason?: string }>(res.stderr);
throw new BadRequestException(errorPayload?.reason || 'MODEL_RUNTIME_UNAVAILABLE');
}
return { policyProvider, env: { CASAN_TENANT_ID: actor.tenant || 'default', ...parsed.env } };
}
private requireConnectionAdmin(actor: SettingsActor) {
if (!['project-admin', 'org-admin'].includes(actor.role)) {
throw new ForbiddenException('MODEL_CONNECTION_ADMIN_REQUIRED');
}
}
private requireRead(actor: SettingsActor) { private requireRead(actor: SettingsActor) {
const res = runPython(RBAC_CLI, [ const res = runPython(RBAC_CLI, [
'check', 'check',
@@ -0,0 +1,145 @@
import { useEffect, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { api, ChatModelConnection, SettingsActor } from '../../lib/api';
interface ModelConnectionPanelProps {
actor: SettingsActor;
open: boolean;
onClose: () => void;
onSelect: (provider: string, model: string) => void;
}
interface ConnectionForm {
endpoint: string;
apiKey: string;
}
const DEFAULT_ENDPOINTS: Record<string, string> = {
openai: 'https://api.openai.com/v1',
anthropic: 'https://api.anthropic.com/v1',
omniroute: 'http://host.docker.internal:20128/v1',
ollama: 'http://host.docker.internal:11434',
};
const PROVIDER_HELP: Record<string, string> = {
openai: 'Dùng OpenAI API key để tải các model API mà tài khoản được phép sử dụng.',
anthropic: 'Dùng Anthropic API key để tải model Claude. Không nhập mật khẩu claude.ai.',
omniroute: 'Gateway OpenAI-compatible đang chạy trên máy host, mặc định cổng 20128; local setup không bắt buộc key.',
ollama: 'Model chạy hoàn toàn local qua Ollama, không cần API key.',
};
function messageOf(error: unknown): string {
if (typeof error === 'object' && error !== null) {
const value = error as { message?: string; response?: { data?: { message?: string } } };
return value.response?.data?.message || value.message || 'Không thể cập nhật kết nối model.';
}
return 'Không thể cập nhật kết nối model.';
}
export function ModelConnectionPanel({ actor, open, onClose, onSelect }: ModelConnectionPanelProps) {
const queryClient = useQueryClient();
const [forms, setForms] = useState<Record<string, ConnectionForm>>({});
const [notice, setNotice] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const query = useQuery({
queryKey: ['chat-connections', actor],
queryFn: () => api.chatConnections(actor),
enabled: open,
retry: false,
});
useEffect(() => {
if (!query.data?.connections) return;
setForms((current) => Object.fromEntries(query.data.connections.map((connection) => [connection.id, {
endpoint: current[connection.id]?.endpoint || connection.endpoint || DEFAULT_ENDPOINTS[connection.id],
apiKey: '',
}])));
}, [query.data]);
const finish = async (text: string) => {
setError(null);
setNotice(text);
await queryClient.invalidateQueries({ queryKey: ['chat-connections'] });
await query.refetch();
};
const connect = useMutation({
mutationFn: ({ connection, form }: { connection: ChatModelConnection; form: ConnectionForm }) => api.connectChatProvider(actor, {
provider: connection.id,
endpoint: form.endpoint,
apiKey: form.apiKey,
}),
onSuccess: async (result) => {
setForms((current) => ({ ...current, [result.connection.id]: { ...current[result.connection.id], apiKey: '' } }));
onSelect(result.connection.id, result.connection.defaultModel);
await finish(`${result.connection.label} đã kết nối và tải ${result.connection.models.length} model.`);
},
onError: (reason) => { setNotice(null); setError(messageOf(reason)); },
});
const refresh = useMutation({
mutationFn: (connection: ChatModelConnection) => api.refreshChatProvider(actor, connection.id),
onSuccess: async (result) => finish(`Đã làm mới ${result.connection.models.length} model từ ${result.connection.label}.`),
onError: (reason) => { setNotice(null); setError(messageOf(reason)); },
});
const setDefault = useMutation({
mutationFn: ({ connection, model }: { connection: ChatModelConnection; model: string }) => api.setChatDefaultModel(actor, connection.id, model),
onSuccess: async (result) => {
onSelect(result.connection.id, result.connection.defaultModel);
await finish(`Model mặc định đã đổi thành ${result.connection.defaultModel}.`);
},
onError: (reason) => { setNotice(null); setError(messageOf(reason)); },
});
const disconnect = useMutation({
mutationFn: (connection: ChatModelConnection) => api.disconnectChatProvider(actor, connection.id),
onSuccess: async (result) => finish(`${result.connection.label} đã được ngắt kết nối; secret đã bị xóa.`),
onError: (reason) => { setNotice(null); setError(messageOf(reason)); },
});
const busy = connect.isPending || refresh.isPending || setDefault.isPending || disconnect.isPending;
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-end justify-center bg-slate-950/45 p-0 backdrop-blur-sm sm:items-center sm:p-6" role="dialog" aria-modal="true" aria-labelledby="model-connections-title">
<div className="max-h-[94vh] w-full max-w-5xl overflow-y-auto rounded-t-3xl border border-slate-200 bg-[#f8fafc] shadow-2xl sm:rounded-3xl">
<header className="sticky top-0 z-10 flex items-start justify-between gap-5 border-b border-slate-200 bg-white/95 px-5 py-4 backdrop-blur sm:px-7">
<div>
<div className="text-[10px] font-bold uppercase tracking-[0.18em] text-indigo-600">Provider control deck</div>
<h2 id="model-connections-title" className="mt-1 text-xl font-semibold text-slate-900">Kết nối và chọn model cho CASAN Chat</h2>
<p className="mt-1 max-w-3xl text-sm leading-6 text-slate-500">API key được mã hóa theo tenant và không bao giờ được hiển thị lại. Đăng nhập tài khoản Codex/Claude trên CLI là luồng riêng; CASAN Chat chỉ dùng API key hoặc gateway của bạn.</p>
</div>
<button type="button" onClick={onClose} className="rounded-xl border border-slate-200 px-3 py-2 text-sm font-semibold text-slate-600 transition hover:bg-slate-100">Đóng</button>
</header>
<div className="p-5 sm:p-7">
{actor.role !== 'project-admin' && actor.role !== 'org-admin' && <div role="alert" className="mb-5 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">Chuyển Session scope sang <strong>project-admin</strong> hoặc <strong>org-admin</strong> để quản lý kết nối. Các role khác vẫn có thể xem trạng thái.</div>}
{notice && <div className="mb-5 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">{notice}</div>}
{error && <div role="alert" className="mb-5 rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">{error}</div>}
{query.isLoading && <div className="rounded-2xl border border-dashed border-slate-300 bg-white p-8 text-center text-sm text-slate-500">Đang đọc kho kết nối đã mã hóa…</div>}
<div className="grid gap-4 md:grid-cols-2">
{(query.data?.connections ?? []).map((connection) => {
const form = forms[connection.id] ?? { endpoint: connection.endpoint || DEFAULT_ENDPOINTS[connection.id], apiKey: '' };
const canManage = actor.role === 'project-admin' || actor.role === 'org-admin';
return (
<section key={connection.id} className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm transition hover:-translate-y-px hover:shadow-md">
<div className="flex items-start justify-between gap-3">
<div><h3 className="font-semibold text-slate-900">{connection.label}</h3><p className="mt-1 text-xs leading-5 text-slate-500">{PROVIDER_HELP[connection.id]}</p></div>
<span className={`shrink-0 rounded-full px-2.5 py-1 text-[10px] font-bold uppercase tracking-wide ${connection.connected ? 'bg-emerald-100 text-emerald-700' : 'bg-slate-100 text-slate-500'}`}>{connection.connected ? 'Connected' : 'Offline'}</span>
</div>
<div className="mt-4 space-y-3">
<label className="block text-xs font-medium text-slate-600">API base URL<input value={form.endpoint} onChange={(event) => setForms((current) => ({ ...current, [connection.id]: { ...form, endpoint: event.target.value } }))} className="mt-1.5 w-full rounded-xl border border-slate-200 px-3 py-2 text-sm text-slate-800 outline-none transition focus:border-indigo-400 focus:ring-4 focus:ring-indigo-100" disabled={connection.id === 'openai' || connection.id === 'anthropic' || busy} /></label>
{connection.requiresKey && <label className="block text-xs font-medium text-slate-600">API key {connection.connected && <span className="font-normal text-slate-400">· để trống không có nghĩa là key cũ được đọc lại</span>}<input type="password" autoComplete="new-password" value={form.apiKey} onChange={(event) => setForms((current) => ({ ...current, [connection.id]: { ...form, apiKey: event.target.value } }))} placeholder={connection.connected ? 'Nhập key mới nếu muốn reconnect' : 'Nhập API key'} className="mt-1.5 w-full rounded-xl border border-slate-200 px-3 py-2 text-sm text-slate-800 outline-none transition focus:border-indigo-400 focus:ring-4 focus:ring-indigo-100" disabled={busy} /></label>}
{connection.connected && connection.models.length > 0 && <label className="block text-xs font-medium text-slate-600">Model mặc định<select value={connection.defaultModel} onChange={(event) => setDefault.mutate({ connection, model: event.target.value })} className="mt-1.5 w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800 outline-none focus:border-indigo-400 focus:ring-4 focus:ring-indigo-100" disabled={busy || !canManage}>{connection.models.map((model) => <option key={model} value={model}>{model}</option>)}</select></label>}
</div>
<div className="mt-4 flex flex-wrap items-center gap-2 border-t border-slate-100 pt-4">
<button type="button" onClick={() => connect.mutate({ connection, form })} disabled={busy || !canManage || (connection.requiresKey && !form.apiKey)} className="rounded-xl bg-indigo-600 px-3 py-2 text-xs font-semibold text-white transition hover:bg-indigo-700 disabled:cursor-not-allowed disabled:bg-slate-300">{connection.connected ? 'Reconnect' : 'Kết nối & tải model'}</button>
{connection.connected && <><button type="button" onClick={() => refresh.mutate(connection)} disabled={busy || !canManage} className="rounded-xl border border-slate-200 px-3 py-2 text-xs font-semibold text-slate-600 transition hover:bg-slate-50 disabled:opacity-50">Refresh</button><button type="button" onClick={() => disconnect.mutate(connection)} disabled={busy || !canManage} className="rounded-xl px-3 py-2 text-xs font-semibold text-rose-600 transition hover:bg-rose-50 disabled:opacity-50">Disconnect</button></>}
{connection.lastCheckedAt && <span className="ml-auto text-[10px] text-slate-400">Checked {new Date(connection.lastCheckedAt).toLocaleString()}</span>}
</div>
</section>
);
})}
</div>
</div>
</div>
</div>
);
}
@@ -195,6 +195,19 @@ export interface ChatModelProvider {
reason: string; reason: string;
} }
export interface ChatModelConnection {
id: 'openai' | 'anthropic' | 'omniroute' | 'ollama';
label: string;
kind: 'cloud' | 'gateway' | 'local';
connected: boolean;
endpoint: string;
models: string[];
defaultModel: string;
lastCheckedAt: string | null;
status: string;
requiresKey: boolean;
}
export interface ChatReplay { export interface ChatReplay {
ok: boolean; ok: boolean;
decision: 'MATCH' | 'DRIFT' | 'BREAK' | string; decision: 'MATCH' | 'DRIFT' | 'BREAK' | string;
@@ -278,11 +291,11 @@ export const api = {
post<{ proposal: any; audit_verify: { ok: boolean; output: string } }>('approvals/submit', body, actorHeaders(actor)), 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 }) => 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)), 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; modelProvider?: string; delegationLevel?: number }) => askChat: (actor: SettingsActor, body: { message: string; chatId?: string; agentId?: string; skillId?: string; modelProvider?: string; modelId?: string; delegationLevel?: number }) =>
post<ChatAnswer>('chat/ask', body, actorHeaders(actor)), post<ChatAnswer>('chat/ask', body, actorHeaders(actor)),
askChatStream: async ( askChatStream: async (
actor: SettingsActor, actor: SettingsActor,
body: { message: string; chatId?: string; agentId?: string; skillId?: string; modelProvider?: string; delegationLevel?: number }, body: { message: string; chatId?: string; agentId?: string; skillId?: string; modelProvider?: string; modelId?: string; delegationLevel?: number },
onPhase: (phase: ChatStreamPhase) => void, onPhase: (phase: ChatStreamPhase) => void,
): Promise<void> => { ): Promise<void> => {
const base = import.meta.env.VITE_API_BASE_URL ?? '/api/v1'; const base = import.meta.env.VITE_API_BASE_URL ?? '/api/v1';
@@ -325,6 +338,15 @@ export const api = {
chatActions: (actor: SettingsActor) => getWithHeaders<{ success: boolean; actions: ChatAction[] }>('chat/actions', actorHeaders(actor)), chatActions: (actor: SettingsActor) => getWithHeaders<{ success: boolean; actions: ChatAction[] }>('chat/actions', actorHeaders(actor)),
chatAgents: (actor: SettingsActor) => getWithHeaders<{ success: boolean; agents: ChatAgent[] }>('chat/agents', 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)), chatModels: (actor: SettingsActor, modelRole: string) => getWithHeaders<{ success: boolean; default: string; providers: ChatModelProvider[] }>(`chat/models?modelRole=${encodeURIComponent(modelRole)}`, actorHeaders(actor)),
chatConnections: (actor: SettingsActor) => getWithHeaders<{ success: boolean; connections: ChatModelConnection[] }>('chat/connections', actorHeaders(actor)),
connectChatProvider: (actor: SettingsActor, body: { provider: string; endpoint: string; apiKey: string; defaultModel?: string }) =>
post<{ success: boolean; connection: ChatModelConnection }>('chat/connections/connect', body, actorHeaders(actor)),
refreshChatProvider: (actor: SettingsActor, provider: string) =>
post<{ success: boolean; connection: ChatModelConnection }>('chat/connections/refresh', { provider }, actorHeaders(actor)),
setChatDefaultModel: (actor: SettingsActor, provider: string, model: string) =>
post<{ success: boolean; connection: ChatModelConnection }>('chat/connections/default', { provider, model }, actorHeaders(actor)),
disconnectChatProvider: (actor: SettingsActor, provider: string) =>
post<{ success: boolean; connection: ChatModelConnection }>('chat/connections/disconnect', { provider }, actorHeaders(actor)),
}; };
// Health is raw (not enveloped) + carries HTTP status. // Health is raw (not enveloped) + carries HTTP status.
@@ -2,6 +2,7 @@ import { type KeyboardEvent, useMemo, useState } from 'react';
import { useMutation, useQuery } from '@tanstack/react-query'; import { useMutation, useQuery } from '@tanstack/react-query';
import { api, ChatAction, ChatAgent, ChatAnswer, ChatHistoryTurn, ChatModelProvider, ChatStreamPhase, SettingsActor } from '../lib/api'; import { api, ChatAction, ChatAgent, ChatAnswer, ChatHistoryTurn, ChatModelProvider, ChatStreamPhase, SettingsActor } from '../lib/api';
import { Card, StatusBadge } from '../components/ui/Card'; import { Card, StatusBadge } from '../components/ui/Card';
import { ModelConnectionPanel } from '../components/chat/ModelConnectionPanel';
const ROLES = ['viewer', 'auditor', 'operator', 'project-admin', 'org-admin']; const ROLES = ['viewer', 'auditor', 'operator', 'project-admin', 'org-admin'];
@@ -78,11 +79,13 @@ function Glyph({ name }: { name: 'add' | 'send' | 'spark' | 'lock' | 'chevron' |
} }
export function Chat() { export function Chat() {
const [actor, setActor] = useState<SettingsActor>({ actor: 'local-operator', role: 'viewer', project: 'default', tenant: 'default' }); const [actor, setActor] = useState<SettingsActor>({ actor: 'local-operator', role: 'project-admin', project: 'default', tenant: 'default' });
const [chatId, setChatId] = useState('chat-default'); const [chatId, setChatId] = useState('chat-default');
const [agentId, setAgentId] = useState('evidence-reader'); const [agentId, setAgentId] = useState('evidence-reader');
const [skillId, setSkillId] = useState('evidence-summary'); const [skillId, setSkillId] = useState('evidence-summary');
const [modelProvider, setModelProvider] = useState('local'); const [modelProvider, setModelProvider] = useState('ollama');
const [modelId, setModelId] = useState('');
const [connectionsOpen, setConnectionsOpen] = useState(false);
const [delegationLevel, setDelegationLevel] = useState(0); const [delegationLevel, setDelegationLevel] = useState(0);
const [message, setMessage] = useState(''); const [message, setMessage] = useState('');
const [last, setLast] = useState<ChatAnswer | null>(null); const [last, setLast] = useState<ChatAnswer | null>(null);
@@ -102,8 +105,13 @@ export function Chat() {
const selectedAgent = agents.find((agent) => agent.id === agentId) ?? agents.find((agent) => agent.allowed_for_role) ?? agents[0]; 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 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 modelsQuery = useQuery({ queryKey: ['chat-models', actor, selectedAgent?.model_role], queryFn: () => api.chatModels(actor, selectedAgent?.model_role ?? 'read_only'), retry: false });
const connectionsQuery = useQuery({ queryKey: ['chat-connections', actor], queryFn: () => api.chatConnections(actor), retry: false });
const models = modelsQuery.data?.providers ?? []; const models = modelsQuery.data?.providers ?? [];
const selectedModel = models.find((provider) => provider.id === modelProvider) ?? models.find((provider) => provider.id === modelsQuery.data?.default) ?? models[0]; const selectedModel = models.find((provider) => provider.id === modelProvider) ?? models.find((provider) => provider.id === modelsQuery.data?.default) ?? models[0];
const connections = connectionsQuery.data?.connections ?? [];
const connectedModels = connections.filter((connection) => connection.connected).flatMap((connection) => connection.models.map((model) => ({ provider: connection.id, providerLabel: connection.label, model })));
const selectedConnection = connections.find((connection) => connection.id === modelProvider && connection.connected);
const selectedRuntimeModel = selectedConnection?.models.includes(modelId) ? modelId : (selectedConnection?.defaultModel || selectedConnection?.models[0] || '');
const persistedMessages = useMemo(() => (historyQuery.data?.turns ?? []).flatMap(turnMessages), [historyQuery.data]); 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 liveAlreadyStored = Boolean(last?.turn_id && historyQuery.data?.turns.some((turn) => turn.turn_id === last.turn_id));
const liveMessages: WorkspaceMessage[] = []; const liveMessages: WorkspaceMessage[] = [];
@@ -125,6 +133,7 @@ export function Chat() {
agentId: override?.agentId ?? selectedAgent?.id ?? agentId, agentId: override?.agentId ?? selectedAgent?.id ?? agentId,
skillId: override?.skillId ?? selectedSkill, skillId: override?.skillId ?? selectedSkill,
modelProvider: selectedModel?.id, modelProvider: selectedModel?.id,
...(selectedConnection ? { modelProvider: selectedConnection.id, modelId: selectedRuntimeModel } : {}),
delegationLevel, delegationLevel,
}), }),
onSuccess: (answer) => { onSuccess: (answer) => {
@@ -148,7 +157,7 @@ export function Chat() {
setDraftText(null); setDraftText(null);
setPendingMessage(text); setPendingMessage(text);
try { try {
await api.askChatStream(actor, { message: text, chatId, agentId: selectedAgent?.id ?? agentId, skillId: selectedSkill, modelProvider: selectedModel?.id, delegationLevel }, (phase) => { await api.askChatStream(actor, { message: text, chatId, agentId: selectedAgent?.id ?? agentId, skillId: selectedSkill, modelProvider: selectedConnection?.id ?? selectedModel?.id, modelId: selectedConnection ? selectedRuntimeModel : undefined, delegationLevel }, (phase) => {
if (phase.phase === 'draft') setDraftText(phase.answer); if (phase.phase === 'draft') setDraftText(phase.answer);
if (phase.phase === 'final') { if (phase.phase === 'final') {
setDraftText(null); setDraftText(null);
@@ -272,12 +281,13 @@ export function Chat() {
<div className="text-[10px] font-bold uppercase tracking-[0.15em] text-slate-400">Governance context</div> <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="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="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'} · {selectedModel?.model ?? 'model unavailable'}</div> <div className="mt-1 text-xs text-slate-500">{selectedAgent?.model_role ?? 'read_only'} · {selectedSkill || 'no skill selected'} · {selectedRuntimeModel || selectedModel?.model || 'model unavailable'}</div>
<div className="mt-3 grid gap-2 border-t border-slate-100 pt-3 text-xs text-slate-600"> <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>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>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> <label>Model<select value={selectedConnection ? `${selectedConnection.id}::${selectedRuntimeModel}` : `policy::${selectedModel?.id ?? ''}`} onChange={(event) => { const [provider, model] = event.target.value.split('::'); if (provider === 'policy') { setModelProvider(model); setModelId(''); } else { setModelProvider(provider); setModelId(model); } }} className="mt-1 w-full rounded-lg border border-slate-200 px-2 py-1.5 text-xs">{connectedModels.map((item) => <option key={`${item.provider}-${item.model}`} value={`${item.provider}::${item.model}`}>{item.providerLabel} · {item.model}</option>)}{models.map((provider: ChatModelProvider) => <option key={`policy-${provider.id}`} value={`policy::${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> <button type="button" onClick={() => setConnectionsOpen(true)} className="flex w-full items-center justify-center gap-2 rounded-lg border border-indigo-200 bg-indigo-50 px-3 py-2 text-xs font-semibold text-indigo-700 transition hover:bg-indigo-100"><Glyph name="bolt" />Manage model connections</button>
<p className="text-[11px] leading-4 text-slate-500">Connected models are loaded live; every cloud/gateway request still passes policy and preflight.</p>
</div> </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> <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> </div>
@@ -292,6 +302,7 @@ export function Chat() {
<div className="mt-2 space-y-2">{(actionsQuery.data?.actions ?? []).slice(0, 3).map((action: ChatAction) => <button key={action.id} type="button" disabled={busy} onClick={() => { const trigger = action.triggers[0] || action.id; setMessage(trigger); setPendingMessage(trigger); ask.mutate({ message: trigger, agentId: 'ops-operator', skillId: 'registered-actions' }); }} className="w-full rounded-xl border border-slate-200 bg-white p-3 text-left transition hover:border-indigo-200 hover:bg-indigo-50/50 disabled:opacity-50"><div className="flex items-center gap-2 text-sm font-semibold text-slate-800"><Glyph name="bolt" />{action.label}</div><p className="mt-1 text-xs leading-5 text-slate-500">{action.description}</p></button>)}{actionsQuery.isError && <div className="text-xs text-rose-600">Registered actions are unavailable.</div>}</div> <div className="mt-2 space-y-2">{(actionsQuery.data?.actions ?? []).slice(0, 3).map((action: ChatAction) => <button key={action.id} type="button" disabled={busy} onClick={() => { const trigger = action.triggers[0] || action.id; setMessage(trigger); setPendingMessage(trigger); ask.mutate({ message: trigger, agentId: 'ops-operator', skillId: 'registered-actions' }); }} className="w-full rounded-xl border border-slate-200 bg-white p-3 text-left transition hover:border-indigo-200 hover:bg-indigo-50/50 disabled:opacity-50"><div className="flex items-center gap-2 text-sm font-semibold text-slate-800"><Glyph name="bolt" />{action.label}</div><p className="mt-1 text-xs leading-5 text-slate-500">{action.description}</p></button>)}{actionsQuery.isError && <div className="text-xs text-rose-600">Registered actions are unavailable.</div>}</div>
</aside> </aside>
</div> </div>
<ModelConnectionPanel actor={actor} open={connectionsOpen} onClose={() => setConnectionsOpen(false)} onSelect={(provider, model) => { setModelProvider(provider); setModelId(model); void connectionsQuery.refetch(); }} />
</div> </div>
); );
} }
@@ -279,7 +279,7 @@ def synthesize_answer(message: str, sources, role: str = "read_only", history: s
provider_id = os.environ.get("CASAN_CHAT_MODEL_PROVIDER") or cfg.get("role_bindings", {}).get(role, "") \ provider_id = os.environ.get("CASAN_CHAT_MODEL_PROVIDER") or cfg.get("role_bindings", {}).get(role, "") \
or cfg.get("role_bindings", {}).get("read_only", "") or cfg.get("role_bindings", {}).get("read_only", "")
provider = providers.get(provider_id, {}) provider = providers.get(provider_id, {})
model_spec = provider.get("model") model_spec = os.environ.get("CASAN_CHAT_SELECTED_MODEL") or provider.get("model")
if not model_spec: if not model_spec:
return deterministic, {"mode": "deterministic", "reason": "provider_unresolved", "provider": provider_id} return deterministic, {"mode": "deterministic", "reason": "provider_unresolved", "provider": provider_id}
@@ -397,7 +397,7 @@ def _model_codegen_body(args):
bindings = cfg.get("role_bindings", {}) bindings = cfg.get("role_bindings", {})
provider_id = os.environ.get("CASAN_CHAT_MODEL_PROVIDER") or bindings.get("codegen", "") or bindings.get("read_only", "") provider_id = os.environ.get("CASAN_CHAT_MODEL_PROVIDER") or bindings.get("codegen", "") or bindings.get("read_only", "")
provider = providers.get(provider_id, {}) provider = providers.get(provider_id, {})
model_spec = provider.get("model") model_spec = os.environ.get("CASAN_CHAT_SELECTED_MODEL") or provider.get("model")
if not model_spec: if not model_spec:
return None, {"mode": "deterministic", "reason": "provider_unresolved"} return None, {"mode": "deterministic", "reason": "provider_unresolved"}
pclass = provider.get("class", "local") pclass = provider.get("class", "local")
@@ -0,0 +1,360 @@
#!/usr/bin/env python3
"""Tenant-scoped model connections for CASAN Chat.
Secrets are accepted only through stdin, encrypted with tenant-crypt.sh, and
never returned by public list/refresh operations. ``runtime-env`` is intended
only for the backend child process that launches a governed chat turn.
"""
import argparse
import json
import os
import subprocess
import sys
import tempfile
import urllib.error
import urllib.request
from datetime import datetime, timezone
from typing import Optional
from urllib.parse import urlparse
def project_root() -> str:
current = os.path.abspath(os.path.dirname(__file__))
while current != os.path.dirname(current):
if os.path.isdir(os.path.join(current, ".specify")):
return current
current = os.path.dirname(current)
raise SystemExit("MODEL_CONNECTION_ROOT_NOT_FOUND")
ROOT = project_root()
BIN = os.path.join(ROOT, "packages", "casan-harness", "scripts", "bash")
TENANT_STORE = os.path.join(BIN, "tenant-store.sh")
TENANT_CRYPT = os.path.join(BIN, "tenant-crypt.sh")
PROVIDERS = {
"openai": {
"label": "OpenAI / Codex API",
"kind": "cloud",
"policy_provider": "cloud-openai",
"default_endpoint": "https://api.openai.com/v1",
"model_prefix": "openai",
"requires_key": True,
},
"anthropic": {
"label": "Anthropic / Claude API",
"kind": "cloud",
"policy_provider": "cloud-anthropic",
"default_endpoint": "https://api.anthropic.com/v1",
"model_prefix": "anthropic",
"requires_key": True,
},
"omniroute": {
"label": "OmniRoute Gateway",
"kind": "gateway",
"policy_provider": "omniroute",
"default_endpoint": "http://host.docker.internal:20128/v1",
"model_prefix": "openai-compatible",
"requires_key": False,
},
"ollama": {
"label": "Ollama Local",
"kind": "local",
"policy_provider": "local",
"default_endpoint": "http://host.docker.internal:11434",
"model_prefix": "ollama",
"requires_key": False,
},
}
def now() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def fail(message: str, code: int = 2) -> None:
print(json.dumps({"success": False, "reason": message}), file=sys.stderr)
raise SystemExit(code)
def run_bash(script: str, args: list[str]) -> subprocess.CompletedProcess[str]:
return subprocess.run(["bash", script, *args], cwd=ROOT, capture_output=True, text=True)
def store_path() -> str:
result = run_bash(TENANT_STORE, ["resolve", "model-connections/connections.json.enc"])
if result.returncode != 0:
fail((result.stderr or result.stdout or "TENANT_STORE_DENIED").strip(), 3)
return result.stdout.strip()
def load_store() -> dict:
path = store_path()
if not os.path.isfile(path):
return {"version": 1, "connections": {}}
with tempfile.TemporaryDirectory() as tmp:
plain = os.path.join(tmp, "connections.json")
result = run_bash(TENANT_CRYPT, ["decrypt", path, plain])
if result.returncode != 0:
fail("MODEL_CONNECTION_DECRYPT_FAILED", 3)
try:
with open(plain, encoding="utf-8") as handle:
payload = json.load(handle)
except (OSError, ValueError):
fail("MODEL_CONNECTION_STORE_INVALID", 3)
return payload if isinstance(payload, dict) else {"version": 1, "connections": {}}
def save_store(payload: dict) -> None:
destination = store_path()
os.makedirs(os.path.dirname(destination), exist_ok=True)
with tempfile.TemporaryDirectory(dir=os.path.dirname(destination)) as tmp:
plain = os.path.join(tmp, "connections.json")
encrypted = os.path.join(tmp, "connections.json.enc")
with open(plain, "w", encoding="utf-8") as handle:
json.dump(payload, handle, ensure_ascii=False, sort_keys=True)
os.chmod(plain, 0o600)
result = run_bash(TENANT_CRYPT, ["encrypt", plain, encrypted])
if result.returncode != 0:
fail("MODEL_CONNECTION_ENCRYPT_FAILED", 3)
os.chmod(encrypted, 0o600)
os.replace(encrypted, destination)
def provider_config(provider_id: str) -> dict:
config = PROVIDERS.get(provider_id)
if not config:
fail("unknown_provider")
return config
def allowed_local_hosts() -> set[str]:
configured = os.environ.get("CASAN_MODEL_CONNECTION_ALLOWED_HOSTS", "")
return {"127.0.0.1", "localhost", "host.docker.internal"} | {
item.strip().lower() for item in configured.split(",") if item.strip()
}
def validate_endpoint(provider_id: str, endpoint: str) -> str:
config = provider_config(provider_id)
value = (endpoint or config["default_endpoint"]).strip().rstrip("/")
parsed = urlparse(value)
if parsed.scheme not in {"http", "https"} or not parsed.hostname or parsed.username or parsed.password:
fail("endpoint_invalid")
if parsed.query or parsed.fragment:
fail("endpoint_invalid")
host = parsed.hostname.lower()
if provider_id == "openai" and value != "https://api.openai.com/v1":
fail("endpoint_not_allowed")
if provider_id == "anthropic" and value != "https://api.anthropic.com/v1":
fail("endpoint_not_allowed")
if provider_id in {"omniroute", "ollama"}:
if host not in allowed_local_hosts():
fail("endpoint_not_allowed")
if parsed.scheme == "http" and host not in allowed_local_hosts():
fail("insecure_endpoint_not_allowed")
allowed_paths = {"", "/v1"} if provider_id == "omniroute" else {""}
if parsed.path.rstrip("/") not in allowed_paths:
fail("endpoint_path_not_allowed")
return value
def request_json(url: str, headers: dict[str, str]) -> dict:
request = urllib.request.Request(url, headers=headers)
try:
with urllib.request.urlopen(request, timeout=12) as response:
return json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
fail(f"provider_http_{exc.code}")
except (urllib.error.URLError, TimeoutError, ValueError):
fail("provider_unreachable")
return {}
def discover(provider_id: str, endpoint: str, api_key: str) -> list[str]:
if provider_id == "ollama":
payload = request_json(f"{endpoint}/api/tags", {})
rows = payload.get("models", [])
ids = [row.get("name", "") for row in rows if isinstance(row, dict)]
else:
headers = {"Accept": "application/json"}
if provider_id == "anthropic":
headers.update({"x-api-key": api_key, "anthropic-version": "2023-06-01"})
else:
headers["Authorization"] = f"Bearer {api_key}"
payload = request_json(f"{endpoint}/models", headers)
rows = payload.get("data", payload.get("models", []))
excluded_types = {"image", "video", "audio", "embedding", "rerank"}
ids = [
row.get("id", row.get("name", ""))
for row in rows
if isinstance(row, dict) and str(row.get("type", "")).lower() not in excluded_types
]
return sorted({str(model_id).strip() for model_id in ids if str(model_id).strip()})
def public_connection(provider_id: str, saved: Optional[dict]) -> dict:
config = provider_config(provider_id)
row = saved or {}
return {
"id": provider_id,
"label": config["label"],
"kind": config["kind"],
"connected": bool(saved),
"endpoint": row.get("endpoint", config["default_endpoint"]),
"models": row.get("models", []),
"defaultModel": row.get("default_model", ""),
"lastCheckedAt": row.get("last_checked_at"),
"status": row.get("status", "not_connected"),
"requiresKey": config["requires_key"],
}
def list_connections() -> int:
connections = load_store().get("connections", {})
print(json.dumps({
"success": True,
"connections": [public_connection(provider_id, connections.get(provider_id)) for provider_id in PROVIDERS],
}, ensure_ascii=False))
return 0
def stdin_payload() -> dict:
try:
payload = json.load(sys.stdin)
except ValueError:
fail("invalid_request")
return payload if isinstance(payload, dict) else {}
def connect(provider_id: str) -> int:
config = provider_config(provider_id)
body = stdin_payload()
api_key = str(body.get("apiKey", "")).strip()
if config["requires_key"] and not api_key:
fail("api_key_required")
if provider_id == "omniroute" and not api_key:
api_key = os.environ.get("CASAN_OPENAI_COMPATIBLE_API_KEY", "local-omniroute")
endpoint = validate_endpoint(provider_id, str(body.get("endpoint", "")))
models = discover(provider_id, endpoint, api_key)
if not models:
fail("provider_returned_no_models")
payload = load_store()
payload.setdefault("connections", {})[provider_id] = {
"endpoint": endpoint,
"api_key": api_key,
"models": models,
"default_model": str(body.get("defaultModel", "")) if body.get("defaultModel") in models else models[0],
"last_checked_at": now(),
"status": "connected",
}
save_store(payload)
print(json.dumps({"success": True, "connection": public_connection(provider_id, payload["connections"][provider_id])}, ensure_ascii=False))
return 0
def refresh(provider_id: str) -> int:
provider_config(provider_id)
payload = load_store()
saved = payload.get("connections", {}).get(provider_id)
if not saved:
fail("provider_not_connected")
models = discover(provider_id, validate_endpoint(provider_id, saved.get("endpoint", "")), saved.get("api_key", ""))
if not models:
fail("provider_returned_no_models")
saved["models"] = models
saved["last_checked_at"] = now()
saved["status"] = "connected"
if saved.get("default_model") not in models:
saved["default_model"] = models[0]
save_store(payload)
print(json.dumps({"success": True, "connection": public_connection(provider_id, saved)}, ensure_ascii=False))
return 0
def set_default(provider_id: str, model_id: str) -> int:
payload = load_store()
saved = payload.get("connections", {}).get(provider_id)
if not saved:
fail("provider_not_connected")
if model_id not in saved.get("models", []):
fail("model_not_discovered")
saved["default_model"] = model_id
save_store(payload)
print(json.dumps({"success": True, "connection": public_connection(provider_id, saved)}, ensure_ascii=False))
return 0
def disconnect(provider_id: str) -> int:
provider_config(provider_id)
payload = load_store()
payload.get("connections", {}).pop(provider_id, None)
save_store(payload)
print(json.dumps({"success": True, "connection": public_connection(provider_id, None)}, ensure_ascii=False))
return 0
def runtime_env(provider_id: str, model_id: str) -> int:
config = provider_config(provider_id)
saved = load_store().get("connections", {}).get(provider_id)
if not saved:
fail("provider_not_connected")
selected = model_id or saved.get("default_model", "")
if selected not in saved.get("models", []):
fail("model_not_discovered")
endpoint = validate_endpoint(provider_id, saved.get("endpoint", ""))
env = {
"CASAN_CHAT_MODEL_MODE": "model",
"CASAN_CHAT_MODEL_PROVIDER": config["policy_provider"],
"CASAN_CHAT_SELECTED_MODEL": f"{config['model_prefix']}:{selected}",
}
if provider_id == "openai":
env["OPENAI_API_KEY"] = saved.get("api_key", "")
elif provider_id == "anthropic":
env["ANTHROPIC_API_KEY"] = saved.get("api_key", "")
elif provider_id == "omniroute":
parsed = urlparse(endpoint)
env.update({
"CASAN_OPENAI_COMPATIBLE_API_KEY": saved.get("api_key", ""),
"CASAN_OPENAI_COMPATIBLE_BASE_URL": endpoint,
"CASAN_OPENAI_COMPATIBLE_ALLOWED_HOSTS": parsed.hostname or "",
})
else:
parsed = urlparse(endpoint)
env.update({
"CASAN_OLLAMA_HOST": parsed.netloc,
"OLLAMA_HOST": parsed.netloc,
"CASAN_ALLOW_DOCKER_HOST_OLLAMA": "1" if parsed.hostname == "host.docker.internal" else "0",
})
print(json.dumps({"success": True, "env": env}, ensure_ascii=False))
return 0
def main() -> int:
parser = argparse.ArgumentParser()
sub = parser.add_subparsers(dest="command", required=True)
sub.add_parser("list")
for name in ("connect", "refresh", "disconnect"):
command = sub.add_parser(name)
command.add_argument("--provider", required=True)
default = sub.add_parser("set-default")
default.add_argument("--provider", required=True)
default.add_argument("--model", required=True)
runtime = sub.add_parser("runtime-env")
runtime.add_argument("--provider", required=True)
runtime.add_argument("--model", default="")
args = parser.parse_args()
if args.command == "list":
return list_connections()
if args.command == "connect":
return connect(args.provider)
if args.command == "refresh":
return refresh(args.provider)
if args.command == "set-default":
return set_default(args.provider, args.model)
if args.command == "disconnect":
return disconnect(args.provider)
return runtime_env(args.provider, args.model)
if __name__ == "__main__":
raise SystemExit(main())