feat: connect chat to managed model providers

This commit is contained in:
thanhnv
2026-07-10 17:40:44 +09:00
parent ef51c90d91
commit adce7717f7
9 changed files with 708 additions and 21 deletions
@@ -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',