feat: connect chat to managed model providers
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<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 { 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<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 {
|
||||
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<Record<string, any>>(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<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) {
|
||||
const res = runPython(RBAC_CLI, [
|
||||
'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;
|
||||
}
|
||||
|
||||
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<ChatAnswer>('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<void> => {
|
||||
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.
|
||||
|
||||
@@ -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<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 [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<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 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() {
|
||||
<div className="text-[10px] font-bold uppercase tracking-[0.15em] text-slate-400">Governance context</div>
|
||||
<div className="mt-3 rounded-xl border border-slate-200 bg-white p-3.5">
|
||||
<div className="flex items-center justify-between gap-2"><div className="text-sm font-semibold text-slate-800">{selectedAgent?.label ?? 'Evidence reader'}</div><StatusBadge value={selectedAgent?.allowed_for_role ? 'allowed' : 'locked'} /></div>
|
||||
<div className="mt-1 text-xs text-slate-500">{selectedAgent?.model_role ?? 'read_only'} · {selectedSkill || 'no skill selected'} · {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">
|
||||
<label>Agent<select value={selectedAgent?.id ?? agentId} onChange={(event) => { const next = agents.find((agent) => agent.id === event.target.value); setAgentId(event.target.value); setSkillId(next?.skills_allowed[0] ?? ''); }} className="mt-1 w-full rounded-lg border border-slate-200 px-2 py-1.5 text-xs">{agents.map((agent: ChatAgent) => <option key={agent.id} value={agent.id} disabled={!agent.allowed_for_role}>{agent.label}{agent.allowed_for_role ? '' : ' (locked)'}</option>)}</select></label>
|
||||
<label>Skill<select value={selectedSkill} onChange={(event) => setSkillId(event.target.value)} className="mt-1 w-full rounded-lg border border-slate-200 px-2 py-1.5 text-xs">{(selectedAgent?.skills_allowed ?? []).map((skill) => <option key={skill}>{skill}</option>)}</select></label>
|
||||
<label>Model provider<select value={selectedModel?.id ?? ''} onChange={(event) => setModelProvider(event.target.value)} className="mt-1 w-full rounded-lg border border-slate-200 px-2 py-1.5 text-xs">{models.map((provider: ChatModelProvider) => <option key={provider.id} value={provider.id} disabled={!provider.allowed || !provider.configured}>{provider.id} · {provider.model}{provider.allowed && provider.configured ? '' : ` (${provider.reason || 'not allowed'})`}</option>)}</select></label>
|
||||
<p className="text-[11px] leading-4 text-slate-500">Provider is policy-bound to your role and agent; cloud/gateway routes enforce preflight.</p>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
@@ -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>
|
||||
</aside>
|
||||
</div>
|
||||
<ModelConnectionPanel actor={actor} open={connectionsOpen} onClose={() => setConnectionsOpen(false)} onSelect={(provider, model) => { setModelProvider(provider); setModelId(model); void connectionsQuery.refetch(); }} />
|
||||
</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, "") \
|
||||
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}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user