diff --git a/docs/guides/CASAN_USING_FOR_REAL_PROJECTS_VI.md b/docs/guides/CASAN_USING_FOR_REAL_PROJECTS_VI.md index 45a9462..8ced87e 100644 --- a/docs/guides/CASAN_USING_FOR_REAL_PROJECTS_VI.md +++ b/docs/guides/CASAN_USING_FOR_REAL_PROJECTS_VI.md @@ -97,14 +97,37 @@ rebuild image Control Panel từ source mới 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 1. Mở `/chat`. 2. Chọn Agent `Evidence Reader`. 3. Chọn Skill `evidence-summary`. -4. Chọn model: - - `local` để dùng Ornith/Ollama trên máy Mac; - - `omniroute` nếu key và gateway đã được truyền vào container. +4. Chọn model đã tải, ví dụ `Ollama Local · ornith:9b` hoặc + `OmniRoute Gateway · auto/best-coding`. 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 diff --git a/packages/casan-control-panel/backend/src/chat/chat.controller.ts b/packages/casan-control-panel/backend/src/chat/chat.controller.ts index 6b5914d..8a38509 100644 --- a/packages/casan-control-panel/backend/src/chat/chat.controller.ts +++ b/packages/casan-control-panel/backend/src/chat/chat.controller.ts @@ -2,7 +2,7 @@ import { Body, Controller, Get, Headers, Inject, Post, Query, Res } from '@nestj import type { Response } from 'express'; import { ok } from '../common/api-response.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') export class ChatController { @@ -58,4 +58,41 @@ export class ChatController { ) { return ok(this.svc.listModels(actorFromHeaders(headers), modelRole || 'read_only')); } + + @Get('connections') + connections(@Headers() headers: Record) { + return ok(this.svc.connections(actorFromHeaders(headers))); + } + + @Post('connections/connect') + connect( + @Headers() headers: Record, + @Body() body: ChatConnectionInput, + ) { + return ok(this.svc.connectProvider(body, actorFromHeaders(headers))); + } + + @Post('connections/refresh') + refreshConnection( + @Headers() headers: Record, + @Body() body: { provider: string }, + ) { + return ok(this.svc.refreshProvider(body.provider, actorFromHeaders(headers))); + } + + @Post('connections/default') + defaultModel( + @Headers() headers: Record, + @Body() body: ChatDefaultModelInput, + ) { + return ok(this.svc.setDefaultModel(body, actorFromHeaders(headers))); + } + + @Post('connections/disconnect') + disconnect( + @Headers() headers: Record, + @Body() body: { provider: string }, + ) { + return ok(this.svc.disconnectProvider(body.provider, actorFromHeaders(headers))); + } } diff --git a/packages/casan-control-panel/backend/src/chat/chat.service.ts b/packages/casan-control-panel/backend/src/chat/chat.service.ts index 44051fa..9d880e4 100644 --- a/packages/casan-control-panel/backend/src/chat/chat.service.ts +++ b/packages/casan-control-panel/backend/src/chat/chat.service.ts @@ -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 { join } from 'node:path'; import type { Response } from 'express'; @@ -11,9 +11,22 @@ export interface ChatAskInput { agentId?: string; skillId?: string; modelProvider?: string; + modelId?: string; delegationLevel?: number; } +export interface ChatConnectionInput { + provider: string; + endpoint?: string; + apiKey?: string; + defaultModel?: string; +} + +export interface ChatDefaultModelInput { + provider: string; + model: string; +} + interface CommandResult { status: number; 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 REPLAY_CLI = join(HARNESS_BIN, 'chat-replay.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 = { + openai: 'cloud-openai', + anthropic: 'cloud-anthropic', + omniroute: 'omniroute', + ollama: 'local', +}; + +function runPython(script: string, args: string[], extraEnv: NodeJS.ProcessEnv = {}, input?: string): CommandResult { try { const stdout = execFileSync('python3', [script, ...args], { cwd: APP_ROOT, encoding: 'utf8', - stdio: ['ignore', 'pipe', 'pipe'], + stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, ...extraEnv }, + input, }); return { status: 0, stdout: stdout.trim(), stderr: '' }; } catch (err: any) { @@ -79,9 +101,10 @@ 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); + 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)); - const res = runPython(CHAT_CLI, args); + const res = runPython(CHAT_CLI, args, runtime.env); const parsed = parseJson>(res.stdout); if (parsed) { return { ...parsed, actor, audit_verify: this.verifyAudit() }; @@ -138,12 +161,13 @@ 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); + 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)); res.setHeader('Content-Type', 'application/x-ndjson; charset=utf-8'); res.setHeader('Cache-Control', 'no-cache'); 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.on('error', () => { if (!res.headersSent) res.status(500); @@ -189,6 +213,71 @@ export class ChatService { 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>(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 }>(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) { const res = runPython(RBAC_CLI, [ 'check', diff --git a/packages/casan-control-panel/frontend/src/components/chat/ModelConnectionPanel.tsx b/packages/casan-control-panel/frontend/src/components/chat/ModelConnectionPanel.tsx new file mode 100644 index 0000000..595c9ad --- /dev/null +++ b/packages/casan-control-panel/frontend/src/components/chat/ModelConnectionPanel.tsx @@ -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 = { + 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 = { + 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>({}); + const [notice, setNotice] = useState(null); + const [error, setError] = useState(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 ( +
+
+
+
+
Provider control deck
+

Kết nối và chọn model cho CASAN Chat

+

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.

+
+ +
+ +
+ {actor.role !== 'project-admin' && actor.role !== 'org-admin' &&
Chuyển Session scope sang project-admin hoặc org-admin để quản lý kết nối. Các role khác vẫn có thể xem trạng thái.
} + {notice &&
{notice}
} + {error &&
{error}
} + {query.isLoading &&
Đang đọc kho kết nối đã mã hóa…
} +
+ {(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 ( +
+
+

{connection.label}

{PROVIDER_HELP[connection.id]}

+ {connection.connected ? 'Connected' : 'Offline'} +
+
+ + {connection.requiresKey && } + {connection.connected && connection.models.length > 0 && } +
+
+ + {connection.connected && <>} + {connection.lastCheckedAt && Checked {new Date(connection.lastCheckedAt).toLocaleString()}} +
+
+ ); + })} +
+
+
+
+ ); +} diff --git a/packages/casan-control-panel/frontend/src/lib/api.ts b/packages/casan-control-panel/frontend/src/lib/api.ts index 4c6dca7..d51c138 100644 --- a/packages/casan-control-panel/frontend/src/lib/api.ts +++ b/packages/casan-control-panel/frontend/src/lib/api.ts @@ -195,6 +195,19 @@ export interface ChatModelProvider { 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 { ok: boolean; 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)), 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; modelProvider?: string; delegationLevel?: number }) => + askChat: (actor: SettingsActor, body: { message: string; chatId?: string; agentId?: string; skillId?: string; modelProvider?: string; modelId?: string; delegationLevel?: number }) => post('chat/ask', body, actorHeaders(actor)), askChatStream: async ( 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, ): Promise => { 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)), 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)), + 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. diff --git a/packages/casan-control-panel/frontend/src/pages/Chat.tsx b/packages/casan-control-panel/frontend/src/pages/Chat.tsx index a604b6e..83e862a 100644 --- a/packages/casan-control-panel/frontend/src/pages/Chat.tsx +++ b/packages/casan-control-panel/frontend/src/pages/Chat.tsx @@ -2,6 +2,7 @@ import { type KeyboardEvent, useMemo, useState } from 'react'; import { useMutation, useQuery } from '@tanstack/react-query'; import { api, ChatAction, ChatAgent, ChatAnswer, ChatHistoryTurn, ChatModelProvider, ChatStreamPhase, SettingsActor } from '../lib/api'; import { Card, StatusBadge } from '../components/ui/Card'; +import { ModelConnectionPanel } from '../components/chat/ModelConnectionPanel'; 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() { - const [actor, setActor] = useState({ actor: 'local-operator', role: 'viewer', project: 'default', tenant: 'default' }); + const [actor, setActor] = useState({ actor: 'local-operator', role: 'project-admin', project: 'default', tenant: 'default' }); const [chatId, setChatId] = useState('chat-default'); const [agentId, setAgentId] = useState('evidence-reader'); 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 [message, setMessage] = useState(''); const [last, setLast] = useState(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 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 connectionsQuery = useQuery({ queryKey: ['chat-connections', actor], queryFn: () => api.chatConnections(actor), 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 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 liveAlreadyStored = Boolean(last?.turn_id && historyQuery.data?.turns.some((turn) => turn.turn_id === last.turn_id)); const liveMessages: WorkspaceMessage[] = []; @@ -125,6 +133,7 @@ export function Chat() { agentId: override?.agentId ?? selectedAgent?.id ?? agentId, skillId: override?.skillId ?? selectedSkill, modelProvider: selectedModel?.id, + ...(selectedConnection ? { modelProvider: selectedConnection.id, modelId: selectedRuntimeModel } : {}), delegationLevel, }), onSuccess: (answer) => { @@ -148,7 +157,7 @@ export function Chat() { setDraftText(null); setPendingMessage(text); 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 === 'final') { setDraftText(null); @@ -272,12 +281,13 @@ export function Chat() {
Governance context
{selectedAgent?.label ?? 'Evidence reader'}
-
{selectedAgent?.model_role ?? 'read_only'} · {selectedSkill || 'no skill selected'} · {selectedModel?.model ?? 'model unavailable'}
+
{selectedAgent?.model_role ?? 'read_only'} · {selectedSkill || 'no skill selected'} · {selectedRuntimeModel || selectedModel?.model || 'model unavailable'}
- -

Provider is policy-bound to your role and agent; cloud/gateway routes enforce preflight.

+ + +

Connected models are loaded live; every cloud/gateway request still passes policy and preflight.

Session scope
@@ -292,6 +302,7 @@ export function Chat() {
{(actionsQuery.data?.actions ?? []).slice(0, 3).map((action: ChatAction) => )}{actionsQuery.isError &&
Registered actions are unavailable.
}
+ setConnectionsOpen(false)} onSelect={(provider, model) => { setModelProvider(provider); setModelId(model); void connectionsQuery.refetch(); }} /> ); } diff --git a/packages/casan-harness/scripts/bash/chat-readonly.py b/packages/casan-harness/scripts/bash/chat-readonly.py index 28781a5..ab214dd 100755 --- a/packages/casan-harness/scripts/bash/chat-readonly.py +++ b/packages/casan-harness/scripts/bash/chat-readonly.py @@ -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, "") \ or cfg.get("role_bindings", {}).get("read_only", "") 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: return deterministic, {"mode": "deterministic", "reason": "provider_unresolved", "provider": provider_id} diff --git a/packages/casan-harness/scripts/bash/chat-turn.py b/packages/casan-harness/scripts/bash/chat-turn.py index e86df46..f392c0a 100755 --- a/packages/casan-harness/scripts/bash/chat-turn.py +++ b/packages/casan-harness/scripts/bash/chat-turn.py @@ -397,7 +397,7 @@ def _model_codegen_body(args): bindings = cfg.get("role_bindings", {}) provider_id = os.environ.get("CASAN_CHAT_MODEL_PROVIDER") or bindings.get("codegen", "") or bindings.get("read_only", "") 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: return None, {"mode": "deterministic", "reason": "provider_unresolved"} pclass = provider.get("class", "local") diff --git a/packages/casan-harness/scripts/bash/model-connections.py b/packages/casan-harness/scripts/bash/model-connections.py new file mode 100644 index 0000000..a4590f6 --- /dev/null +++ b/packages/casan-harness/scripts/bash/model-connections.py @@ -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())