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