feat: connect chat to managed model providers
This commit is contained in:
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user