feat(llm-gateway): 新增对外 LLM API 代理服务 — 完整的监管注入、内容审查和管理后台
## 新增微服务: llm-gateway (端口 3008) 对外提供与 Anthropic/OpenAI 完全兼容的 API 接口,中间拦截实现: - API Key 认证:由我们分配 Key 给外部用户,SHA-256 哈希存储 - System Prompt 注入:在请求转发前注入监管合规内容(支持 prepend/append) - 内容审查过滤:对用户消息进行关键词/正则匹配,支持 block/warn/log 三种动作 - 用量记录:异步批量写入,跟踪 token 消耗和费用估算 - 审计日志:记录每次请求的来源 IP、过滤状态、注入状态等 - 速率限制:基于内存滑动窗口的 RPM 限制 ### 技术选型 - Fastify (非 NestJS):纯代理场景无需 DI 容器,路由开销 ~2ms - SSE 流式管道:零缓冲直通,支持 Anthropic streaming 和 OpenAI streaming - 规则缓存:30 秒 TTL,避免每次请求查库 ### API 端点 - POST /v1/messages — Anthropic Messages API 代理(流式+非流式) - POST /v1/embeddings — OpenAI Embeddings API 代理 - POST /v1/chat/completions — OpenAI Chat Completions API 代理 - GET /health — 健康检查 ## 数据库 (5 张新表) - gateway_api_keys: 外部用户 API Key(权限、限速、预算、过期时间) - gateway_injection_rules: 监管内容注入规则(位置、匹配模型、匹配 Key) - gateway_content_rules: 内容审查规则(关键词/正则、block/warn/log) - gateway_usage_logs: Token 用量记录(按 Key、模型、提供商统计) - gateway_audit_logs: 请求审计日志(IP、过滤状态、注入状态) ## Admin 后端 (conversation-service) 4 个 NestJS 控制器,挂载在 /conversations/admin/gateway/ 下: - AdminGatewayKeysController: Key 的 CRUD + toggle - AdminGatewayInjectionRulesController: 注入规则 CRUD + toggle - AdminGatewayContentRulesController: 内容审查规则 CRUD + toggle - AdminGatewayDashboardController: 仪表盘汇总、用量查询、审计日志查询 5 个 ORM 实体文件对应 5 张数据库表。 ## Admin 前端 (admin-client) 新增 features/llm-gateway 模块,Tabs 布局包含 5 个管理面板: - API Key Tab: 创建/删除/启停 Key,创建时一次性显示完整 Key - 注入规则 Tab: 配置监管内容(前置/追加到 system prompt) - 内容审查 Tab: 配置关键词/正则过滤规则 - 用量统计 Tab: 查看 token 消耗、费用、响应时间 - 审计日志 Tab: 查看请求记录、过滤命中、注入状态 菜单项: GatewayOutlined + "LLM 网关",位于"系统总监"和"数据分析"之间。 ## 基础设施 - docker-compose.yml: 新增 llm-gateway 服务定义 - kong.yml: 新增 /v1/messages、/v1/embeddings、/v1/chat/completions 路由 - 超时设置 300 秒(LLM 长响应) - CORS 新增 X-Api-Key、anthropic-version、anthropic-beta 头 - init-db.sql: 新增 5 张 gateway 表的建表语句 ## 架构说明 内部服务(conversation-service、knowledge-service、evolution-service)继续直连 API, llm-gateway 仅服务外部用户。两者通过共享 PostgreSQL 数据库关联配置。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
021afd8677
commit
6476bd868f
|
|
@ -426,6 +426,40 @@ services:
|
|||
networks:
|
||||
- iconsulting-network
|
||||
|
||||
#=============================================================================
|
||||
# LLM Gateway - 对外 API 代理服务
|
||||
#=============================================================================
|
||||
|
||||
llm-gateway:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: packages/services/llm-gateway/Dockerfile
|
||||
container_name: iconsulting-llm-gateway
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: 3008
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-iconsulting}
|
||||
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY}
|
||||
ANTHROPIC_UPSTREAM_URL: ${ANTHROPIC_BASE_URL:-https://api.anthropic.com}
|
||||
OPENAI_API_KEY: ${OPENAI_API_KEY}
|
||||
OPENAI_UPSTREAM_URL: ${OPENAI_BASE_URL:-https://api.openai.com}
|
||||
RULES_CACHE_TTL_MS: 30000
|
||||
LOG_LEVEL: info
|
||||
ports:
|
||||
- "3008:3008"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:3008/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
networks:
|
||||
- iconsulting-network
|
||||
|
||||
#=============================================================================
|
||||
# 前端 Nginx
|
||||
#=============================================================================
|
||||
|
|
|
|||
|
|
@ -169,6 +169,28 @@ services:
|
|||
- DELETE
|
||||
- OPTIONS
|
||||
|
||||
#-----------------------------------------------------------------------------
|
||||
# LLM Gateway - 对外 API 代理服务
|
||||
# 注意: 需要长超时以支持 LLM 流式响应
|
||||
#-----------------------------------------------------------------------------
|
||||
- name: llm-gateway
|
||||
url: http://llm-gateway:3008
|
||||
connect_timeout: 60000
|
||||
write_timeout: 300000
|
||||
read_timeout: 300000
|
||||
retries: 2
|
||||
routes:
|
||||
- name: llm-gateway-routes
|
||||
paths:
|
||||
- /v1/messages
|
||||
- /v1/embeddings
|
||||
- /v1/chat/completions
|
||||
strip_path: false
|
||||
preserve_host: true
|
||||
methods:
|
||||
- POST
|
||||
- OPTIONS
|
||||
|
||||
#===============================================================================
|
||||
# 全局插件配置
|
||||
#===============================================================================
|
||||
|
|
@ -198,6 +220,9 @@ plugins:
|
|||
- Authorization
|
||||
- X-User-Id
|
||||
- X-Request-Id
|
||||
- X-Api-Key
|
||||
- anthropic-version
|
||||
- anthropic-beta
|
||||
exposed_headers:
|
||||
- X-Request-Id
|
||||
credentials: true
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { ObservabilityPage } from './features/observability';
|
|||
import { AssessmentConfigPage } from './features/assessment-config';
|
||||
import { CollectionConfigPage } from './features/collection-config';
|
||||
import { SupervisorPage } from './features/supervisor';
|
||||
import { LLMGatewayPage } from './features/llm-gateway';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
|
|
@ -44,6 +45,7 @@ function App() {
|
|||
<Route path="tenants" element={<TenantsPage />} />
|
||||
<Route path="mcp" element={<McpPage />} />
|
||||
<Route path="supervisor" element={<SupervisorPage />} />
|
||||
<Route path="llm-gateway" element={<LLMGatewayPage />} />
|
||||
<Route path="observability" element={<ObservabilityPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
</Route>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,209 @@
|
|||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { message } from 'antd';
|
||||
import { gatewayApi } from '../infrastructure/llm-gateway.api';
|
||||
|
||||
const KEYS = {
|
||||
dashboard: 'gateway-dashboard',
|
||||
keys: 'gateway-keys',
|
||||
injectionRules: 'gateway-injection-rules',
|
||||
contentRules: 'gateway-content-rules',
|
||||
usage: 'gateway-usage',
|
||||
auditLogs: 'gateway-audit-logs',
|
||||
};
|
||||
|
||||
// ─── Dashboard ───
|
||||
|
||||
export function useDashboard() {
|
||||
return useQuery({
|
||||
queryKey: [KEYS.dashboard],
|
||||
queryFn: () => gatewayApi.getDashboard(),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── API Keys ───
|
||||
|
||||
export function useApiKeys() {
|
||||
return useQuery({
|
||||
queryKey: [KEYS.keys],
|
||||
queryFn: () => gatewayApi.listKeys(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateKey() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: gatewayApi.createKey,
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: [KEYS.keys] });
|
||||
qc.invalidateQueries({ queryKey: [KEYS.dashboard] });
|
||||
message.success('API Key 创建成功');
|
||||
},
|
||||
onError: () => message.error('创建失败'),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateKey() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, dto }: { id: string; dto: any }) => gatewayApi.updateKey(id, dto),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: [KEYS.keys] });
|
||||
message.success('更新成功');
|
||||
},
|
||||
onError: () => message.error('更新失败'),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteKey() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: gatewayApi.deleteKey,
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: [KEYS.keys] });
|
||||
qc.invalidateQueries({ queryKey: [KEYS.dashboard] });
|
||||
message.success('删除成功');
|
||||
},
|
||||
onError: () => message.error('删除失败'),
|
||||
});
|
||||
}
|
||||
|
||||
export function useToggleKey() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: gatewayApi.toggleKey,
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: [KEYS.keys] });
|
||||
qc.invalidateQueries({ queryKey: [KEYS.dashboard] });
|
||||
},
|
||||
onError: () => message.error('操作失败'),
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Injection Rules ───
|
||||
|
||||
export function useInjectionRules() {
|
||||
return useQuery({
|
||||
queryKey: [KEYS.injectionRules],
|
||||
queryFn: () => gatewayApi.listInjectionRules(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateInjectionRule() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: gatewayApi.createInjectionRule,
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: [KEYS.injectionRules] });
|
||||
message.success('注入规则创建成功');
|
||||
},
|
||||
onError: () => message.error('创建失败'),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateInjectionRule() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, dto }: { id: string; dto: any }) => gatewayApi.updateInjectionRule(id, dto),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: [KEYS.injectionRules] });
|
||||
message.success('更新成功');
|
||||
},
|
||||
onError: () => message.error('更新失败'),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteInjectionRule() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: gatewayApi.deleteInjectionRule,
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: [KEYS.injectionRules] });
|
||||
message.success('删除成功');
|
||||
},
|
||||
onError: () => message.error('删除失败'),
|
||||
});
|
||||
}
|
||||
|
||||
export function useToggleInjectionRule() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: gatewayApi.toggleInjectionRule,
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: [KEYS.injectionRules] });
|
||||
},
|
||||
onError: () => message.error('操作失败'),
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Content Rules ───
|
||||
|
||||
export function useContentRules() {
|
||||
return useQuery({
|
||||
queryKey: [KEYS.contentRules],
|
||||
queryFn: () => gatewayApi.listContentRules(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateContentRule() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: gatewayApi.createContentRule,
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: [KEYS.contentRules] });
|
||||
message.success('审查规则创建成功');
|
||||
},
|
||||
onError: () => message.error('创建失败'),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateContentRule() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, dto }: { id: string; dto: any }) => gatewayApi.updateContentRule(id, dto),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: [KEYS.contentRules] });
|
||||
message.success('更新成功');
|
||||
},
|
||||
onError: () => message.error('更新失败'),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteContentRule() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: gatewayApi.deleteContentRule,
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: [KEYS.contentRules] });
|
||||
message.success('删除成功');
|
||||
},
|
||||
onError: () => message.error('删除失败'),
|
||||
});
|
||||
}
|
||||
|
||||
export function useToggleContentRule() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: gatewayApi.toggleContentRule,
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: [KEYS.contentRules] });
|
||||
},
|
||||
onError: () => message.error('操作失败'),
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Usage & Audit ───
|
||||
|
||||
export function useUsageLogs(params?: { keyId?: string; startDate?: string; endDate?: string }) {
|
||||
return useQuery({
|
||||
queryKey: [KEYS.usage, params],
|
||||
queryFn: () => gatewayApi.getUsage(params),
|
||||
});
|
||||
}
|
||||
|
||||
export function useAuditLogs(params?: { keyId?: string; filtered?: boolean; startDate?: string; endDate?: string }) {
|
||||
return useQuery({
|
||||
queryKey: [KEYS.auditLogs, params],
|
||||
queryFn: () => gatewayApi.getAuditLogs(params),
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { LLMGatewayPage } from './presentation/pages/LLMGatewayPage';
|
||||
|
|
@ -0,0 +1,176 @@
|
|||
import api from '../../../shared/utils/api';
|
||||
|
||||
const BASE = '/conversations/admin/gateway';
|
||||
|
||||
// ─── Types ───
|
||||
|
||||
export interface GatewayApiKey {
|
||||
id: string;
|
||||
tenantId: string | null;
|
||||
keyHash: string;
|
||||
keyPrefix: string;
|
||||
name: string;
|
||||
owner: string;
|
||||
permissions: {
|
||||
allowedModels: string[];
|
||||
allowStreaming: boolean;
|
||||
allowTools: boolean;
|
||||
};
|
||||
rateLimitRpm: number;
|
||||
rateLimitTpd: number;
|
||||
monthlyBudget: number | null;
|
||||
enabled: boolean;
|
||||
expiresAt: string | null;
|
||||
lastUsedAt: string | null;
|
||||
createdBy: string | null;
|
||||
createdAt: string;
|
||||
rawKey?: string; // only on creation
|
||||
}
|
||||
|
||||
export interface InjectionRule {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
position: 'prepend' | 'append';
|
||||
content: string;
|
||||
matchModels: string[];
|
||||
matchKeyIds: string[] | null;
|
||||
priority: number;
|
||||
enabled: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ContentRule {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'keyword' | 'regex';
|
||||
pattern: string;
|
||||
action: 'block' | 'warn' | 'log';
|
||||
rejectMessage: string | null;
|
||||
priority: number;
|
||||
enabled: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface UsageLog {
|
||||
id: string;
|
||||
apiKeyId: string;
|
||||
model: string;
|
||||
provider: string;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalTokens: number;
|
||||
costUsd: number | null;
|
||||
durationMs: number;
|
||||
statusCode: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface AuditLog {
|
||||
id: string;
|
||||
apiKeyId: string;
|
||||
requestMethod: string;
|
||||
requestPath: string;
|
||||
requestModel: string | null;
|
||||
requestIp: string;
|
||||
contentFiltered: boolean;
|
||||
filterRuleId: string | null;
|
||||
injectionApplied: boolean;
|
||||
responseStatus: number;
|
||||
durationMs: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface DashboardData {
|
||||
keys: { total: number; active: number };
|
||||
today: { requests: number; tokens: number; cost: number; filtered: number };
|
||||
month: { requests: number; tokens: number; cost: number };
|
||||
}
|
||||
|
||||
// ─── API ───
|
||||
|
||||
export const gatewayApi = {
|
||||
// Dashboard
|
||||
getDashboard: async (): Promise<DashboardData> => {
|
||||
const res = await api.get(`${BASE}/dashboard`);
|
||||
return res.data.data;
|
||||
},
|
||||
|
||||
// Keys
|
||||
listKeys: async () => {
|
||||
const res = await api.get(`${BASE}/keys`);
|
||||
return res.data.data;
|
||||
},
|
||||
createKey: async (dto: { name: string; owner?: string; rateLimitRpm?: number; rateLimitTpd?: number; monthlyBudget?: number; expiresAt?: string }) => {
|
||||
const res = await api.post(`${BASE}/keys`, dto);
|
||||
return res.data.data;
|
||||
},
|
||||
updateKey: async (id: string, dto: Partial<GatewayApiKey>) => {
|
||||
const res = await api.put(`${BASE}/keys/${id}`, dto);
|
||||
return res.data.data;
|
||||
},
|
||||
deleteKey: async (id: string) => {
|
||||
const res = await api.delete(`${BASE}/keys/${id}`);
|
||||
return res.data;
|
||||
},
|
||||
toggleKey: async (id: string) => {
|
||||
const res = await api.post(`${BASE}/keys/${id}/toggle`);
|
||||
return res.data.data;
|
||||
},
|
||||
|
||||
// Injection Rules
|
||||
listInjectionRules: async () => {
|
||||
const res = await api.get(`${BASE}/injection-rules`);
|
||||
return res.data.data;
|
||||
},
|
||||
createInjectionRule: async (dto: { name: string; position?: string; content: string; description?: string; matchModels?: string[]; priority?: number }) => {
|
||||
const res = await api.post(`${BASE}/injection-rules`, dto);
|
||||
return res.data.data;
|
||||
},
|
||||
updateInjectionRule: async (id: string, dto: Partial<InjectionRule>) => {
|
||||
const res = await api.put(`${BASE}/injection-rules/${id}`, dto);
|
||||
return res.data.data;
|
||||
},
|
||||
deleteInjectionRule: async (id: string) => {
|
||||
const res = await api.delete(`${BASE}/injection-rules/${id}`);
|
||||
return res.data;
|
||||
},
|
||||
toggleInjectionRule: async (id: string) => {
|
||||
const res = await api.post(`${BASE}/injection-rules/${id}/toggle`);
|
||||
return res.data.data;
|
||||
},
|
||||
|
||||
// Content Rules
|
||||
listContentRules: async () => {
|
||||
const res = await api.get(`${BASE}/content-rules`);
|
||||
return res.data.data;
|
||||
},
|
||||
createContentRule: async (dto: { name: string; type?: string; pattern: string; action?: string; rejectMessage?: string; priority?: number }) => {
|
||||
const res = await api.post(`${BASE}/content-rules`, dto);
|
||||
return res.data.data;
|
||||
},
|
||||
updateContentRule: async (id: string, dto: Partial<ContentRule>) => {
|
||||
const res = await api.put(`${BASE}/content-rules/${id}`, dto);
|
||||
return res.data.data;
|
||||
},
|
||||
deleteContentRule: async (id: string) => {
|
||||
const res = await api.delete(`${BASE}/content-rules/${id}`);
|
||||
return res.data;
|
||||
},
|
||||
toggleContentRule: async (id: string) => {
|
||||
const res = await api.post(`${BASE}/content-rules/${id}/toggle`);
|
||||
return res.data.data;
|
||||
},
|
||||
|
||||
// Usage & Audit
|
||||
getUsage: async (params?: { keyId?: string; startDate?: string; endDate?: string; limit?: number }) => {
|
||||
const res = await api.get(`${BASE}/usage`, { params });
|
||||
return res.data.data;
|
||||
},
|
||||
getAuditLogs: async (params?: { keyId?: string; filtered?: boolean; startDate?: string; endDate?: string; limit?: number }) => {
|
||||
const res = await api.get(`${BASE}/audit-logs`, { params });
|
||||
return res.data.data;
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,137 @@
|
|||
import { useState } from 'react';
|
||||
import { Table, Button, Modal, Form, Input, InputNumber, Switch, Space, Tag, Popconfirm, Typography, message as antMsg } from 'antd';
|
||||
import { PlusOutlined, CopyOutlined } from '@ant-design/icons';
|
||||
import { useApiKeys, useCreateKey, useDeleteKey, useToggleKey } from '../../application/useLLMGateway';
|
||||
import type { GatewayApiKey } from '../../infrastructure/llm-gateway.api';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
export function ApiKeysTab() {
|
||||
const { data, isLoading } = useApiKeys();
|
||||
const createMutation = useCreateKey();
|
||||
const deleteMutation = useDeleteKey();
|
||||
const toggleMutation = useToggleKey();
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [newKeyModal, setNewKeyModal] = useState<{ rawKey: string } | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const handleCreate = async () => {
|
||||
const values = await form.validateFields();
|
||||
const result = await createMutation.mutateAsync(values);
|
||||
setCreateOpen(false);
|
||||
form.resetFields();
|
||||
if (result.rawKey) {
|
||||
setNewKeyModal({ rawKey: result.rawKey });
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
},
|
||||
{
|
||||
title: 'Key 前缀',
|
||||
dataIndex: 'keyPrefix',
|
||||
key: 'keyPrefix',
|
||||
render: (v: string) => <Text code>{v}...</Text>,
|
||||
},
|
||||
{
|
||||
title: '所属',
|
||||
dataIndex: 'owner',
|
||||
key: 'owner',
|
||||
},
|
||||
{
|
||||
title: '限速 (RPM)',
|
||||
dataIndex: 'rateLimitRpm',
|
||||
key: 'rateLimitRpm',
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'enabled',
|
||||
key: 'enabled',
|
||||
render: (v: boolean, record: GatewayApiKey) => (
|
||||
<Switch
|
||||
checked={v}
|
||||
size="small"
|
||||
onChange={() => toggleMutation.mutate(record.id)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '最后使用',
|
||||
dataIndex: 'lastUsedAt',
|
||||
key: 'lastUsedAt',
|
||||
render: (v: string | null) => v ? new Date(v).toLocaleString() : '-',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
render: (_: any, record: GatewayApiKey) => (
|
||||
<Popconfirm title="确认删除?" onConfirm={() => deleteMutation.mutate(record.id)}>
|
||||
<Button type="link" danger size="small">删除</Button>
|
||||
</Popconfirm>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 16 }}>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>
|
||||
创建 API Key
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={data?.items}
|
||||
rowKey="id"
|
||||
loading={isLoading}
|
||||
size="small"
|
||||
pagination={false}
|
||||
/>
|
||||
|
||||
{/* Create Modal */}
|
||||
<Modal
|
||||
title="创建 API Key"
|
||||
open={createOpen}
|
||||
onOk={handleCreate}
|
||||
onCancel={() => setCreateOpen(false)}
|
||||
confirmLoading={createMutation.isPending}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请输入名称' }]}>
|
||||
<Input placeholder="例如: 客户A测试Key" />
|
||||
</Form.Item>
|
||||
<Form.Item name="owner" label="所属用户/组织">
|
||||
<Input placeholder="例如: Company A" />
|
||||
</Form.Item>
|
||||
<Form.Item name="rateLimitRpm" label="每分钟请求限制" initialValue={60}>
|
||||
<InputNumber min={1} max={10000} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="rateLimitTpd" label="每日 Token 限制" initialValue={1000000}>
|
||||
<InputNumber min={1000} max={100000000} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* New Key Display Modal */}
|
||||
<Modal
|
||||
title="API Key 已创建"
|
||||
open={!!newKeyModal}
|
||||
onOk={() => setNewKeyModal(null)}
|
||||
onCancel={() => setNewKeyModal(null)}
|
||||
cancelButtonProps={{ style: { display: 'none' } }}
|
||||
>
|
||||
<p>请立即复制保存此 Key,关闭后将无法再次查看:</p>
|
||||
<div style={{ background: '#f5f5f5', padding: 12, borderRadius: 6, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Text code copyable style={{ flex: 1, wordBreak: 'break-all' }}>
|
||||
{newKeyModal?.rawKey}
|
||||
</Text>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
import { Table, Tag } from 'antd';
|
||||
import { useAuditLogs } from '../../application/useLLMGateway';
|
||||
|
||||
export function AuditLogsTab() {
|
||||
const { data, isLoading } = useAuditLogs();
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
render: (v: string) => new Date(v).toLocaleString(),
|
||||
width: 180,
|
||||
},
|
||||
{ title: '方法', dataIndex: 'requestMethod', key: 'requestMethod', width: 70 },
|
||||
{ title: '路径', dataIndex: 'requestPath', key: 'requestPath', width: 160 },
|
||||
{ title: '模型', dataIndex: 'requestModel', key: 'requestModel', ellipsis: true },
|
||||
{ title: 'IP', dataIndex: 'requestIp', key: 'requestIp', width: 130 },
|
||||
{
|
||||
title: '内容过滤',
|
||||
dataIndex: 'contentFiltered',
|
||||
key: 'contentFiltered',
|
||||
render: (v: boolean) => v ? <Tag color="red">已过滤</Tag> : <Tag color="green">通过</Tag>,
|
||||
},
|
||||
{
|
||||
title: '注入',
|
||||
dataIndex: 'injectionApplied',
|
||||
key: 'injectionApplied',
|
||||
render: (v: boolean) => v ? <Tag color="blue">已注入</Tag> : '-',
|
||||
},
|
||||
{
|
||||
title: '状态码',
|
||||
dataIndex: 'responseStatus',
|
||||
key: 'responseStatus',
|
||||
render: (v: number) => <Tag color={v < 400 ? 'green' : 'red'}>{v}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '耗时',
|
||||
dataIndex: 'durationMs',
|
||||
key: 'durationMs',
|
||||
render: (v: number) => `${(v / 1000).toFixed(1)}s`,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={data?.items}
|
||||
rowKey="id"
|
||||
loading={isLoading}
|
||||
size="small"
|
||||
pagination={{ pageSize: 20, showSizeChanger: false }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,109 @@
|
|||
import { useState } from 'react';
|
||||
import { Table, Button, Modal, Form, Input, Select, Switch, Popconfirm, Tag } from 'antd';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import { useContentRules, useCreateContentRule, useDeleteContentRule, useToggleContentRule } from '../../application/useLLMGateway';
|
||||
import type { ContentRule } from '../../infrastructure/llm-gateway.api';
|
||||
|
||||
export function ContentRulesTab() {
|
||||
const { data, isLoading } = useContentRules();
|
||||
const createMutation = useCreateContentRule();
|
||||
const deleteMutation = useDeleteContentRule();
|
||||
const toggleMutation = useToggleContentRule();
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const handleCreate = async () => {
|
||||
const values = await form.validateFields();
|
||||
await createMutation.mutateAsync(values);
|
||||
setCreateOpen(false);
|
||||
form.resetFields();
|
||||
};
|
||||
|
||||
const actionColors: Record<string, string> = { block: 'red', warn: 'orange', log: 'blue' };
|
||||
const actionLabels: Record<string, string> = { block: '拦截', warn: '警告', log: '记录' };
|
||||
|
||||
const columns = [
|
||||
{ title: '名称', dataIndex: 'name', key: 'name' },
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'type',
|
||||
key: 'type',
|
||||
render: (v: string) => <Tag>{v === 'keyword' ? '关键词' : '正则'}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '匹配模式',
|
||||
dataIndex: 'pattern',
|
||||
key: 'pattern',
|
||||
ellipsis: true,
|
||||
width: 200,
|
||||
},
|
||||
{
|
||||
title: '动作',
|
||||
dataIndex: 'action',
|
||||
key: 'action',
|
||||
render: (v: string) => <Tag color={actionColors[v]}>{actionLabels[v]}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '拒绝消息',
|
||||
dataIndex: 'rejectMessage',
|
||||
key: 'rejectMessage',
|
||||
ellipsis: true,
|
||||
width: 200,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'enabled',
|
||||
key: 'enabled',
|
||||
render: (v: boolean, record: ContentRule) => (
|
||||
<Switch checked={v} size="small" onChange={() => toggleMutation.mutate(record.id)} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
render: (_: any, record: ContentRule) => (
|
||||
<Popconfirm title="确认删除?" onConfirm={() => deleteMutation.mutate(record.id)}>
|
||||
<Button type="link" danger size="small">删除</Button>
|
||||
</Popconfirm>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 16 }}>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>
|
||||
创建审查规则
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Table columns={columns} dataSource={data?.items} rowKey="id" loading={isLoading} size="small" pagination={false} />
|
||||
|
||||
<Modal
|
||||
title="创建内容审查规则"
|
||||
open={createOpen}
|
||||
onOk={handleCreate}
|
||||
onCancel={() => setCreateOpen(false)}
|
||||
confirmLoading={createMutation.isPending}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="name" label="规则名称" rules={[{ required: true }]}>
|
||||
<Input placeholder="例如: 禁止政治敏感话题" />
|
||||
</Form.Item>
|
||||
<Form.Item name="type" label="匹配类型" initialValue="keyword">
|
||||
<Select options={[{ label: '关键词匹配', value: 'keyword' }, { label: '正则表达式', value: 'regex' }]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="pattern" label="匹配模式" rules={[{ required: true }]}>
|
||||
<Input placeholder="关键词或正则表达式" />
|
||||
</Form.Item>
|
||||
<Form.Item name="action" label="触发动作" initialValue="block">
|
||||
<Select options={[{ label: '拦截请求', value: 'block' }, { label: '警告并放行', value: 'warn' }, { label: '仅记录', value: 'log' }]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="rejectMessage" label="拒绝消息(拦截时返回给用户)">
|
||||
<Input placeholder="Content blocked by policy." />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
import { useState } from 'react';
|
||||
import { Table, Button, Modal, Form, Input, Select, Switch, Space, Popconfirm, Tag } from 'antd';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import { useInjectionRules, useCreateInjectionRule, useDeleteInjectionRule, useToggleInjectionRule } from '../../application/useLLMGateway';
|
||||
import type { InjectionRule } from '../../infrastructure/llm-gateway.api';
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
export function InjectionRulesTab() {
|
||||
const { data, isLoading } = useInjectionRules();
|
||||
const createMutation = useCreateInjectionRule();
|
||||
const deleteMutation = useDeleteInjectionRule();
|
||||
const toggleMutation = useToggleInjectionRule();
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const handleCreate = async () => {
|
||||
const values = await form.validateFields();
|
||||
await createMutation.mutateAsync(values);
|
||||
setCreateOpen(false);
|
||||
form.resetFields();
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: '名称', dataIndex: 'name', key: 'name' },
|
||||
{
|
||||
title: '位置',
|
||||
dataIndex: 'position',
|
||||
key: 'position',
|
||||
render: (v: string) => <Tag color={v === 'prepend' ? 'blue' : 'green'}>{v === 'prepend' ? '前置' : '追加'}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '内容预览',
|
||||
dataIndex: 'content',
|
||||
key: 'content',
|
||||
ellipsis: true,
|
||||
width: 300,
|
||||
},
|
||||
{
|
||||
title: '匹配模型',
|
||||
dataIndex: 'matchModels',
|
||||
key: 'matchModels',
|
||||
render: (v: string[]) => v?.includes('*') ? <Tag>全部</Tag> : v?.map((m: string) => <Tag key={m}>{m}</Tag>),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'enabled',
|
||||
key: 'enabled',
|
||||
render: (v: boolean, record: InjectionRule) => (
|
||||
<Switch checked={v} size="small" onChange={() => toggleMutation.mutate(record.id)} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
render: (_: any, record: InjectionRule) => (
|
||||
<Popconfirm title="确认删除?" onConfirm={() => deleteMutation.mutate(record.id)}>
|
||||
<Button type="link" danger size="small">删除</Button>
|
||||
</Popconfirm>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 16 }}>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>
|
||||
创建注入规则
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Table columns={columns} dataSource={data?.items} rowKey="id" loading={isLoading} size="small" pagination={false} />
|
||||
|
||||
<Modal
|
||||
title="创建注入规则"
|
||||
open={createOpen}
|
||||
onOk={handleCreate}
|
||||
onCancel={() => setCreateOpen(false)}
|
||||
confirmLoading={createMutation.isPending}
|
||||
width={600}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="name" label="规则名称" rules={[{ required: true }]}>
|
||||
<Input placeholder="例如: 合规声明" />
|
||||
</Form.Item>
|
||||
<Form.Item name="position" label="注入位置" initialValue="append">
|
||||
<Select options={[{ label: '追加到 system prompt 末尾', value: 'append' }, { label: '前置到 system prompt 开头', value: 'prepend' }]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="content" label="注入内容" rules={[{ required: true }]}>
|
||||
<TextArea rows={6} placeholder="输入要注入到 system prompt 中的监管内容..." />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="描述">
|
||||
<Input placeholder="规则说明(可选)" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
import { Table, Tag } from 'antd';
|
||||
import { useUsageLogs } from '../../application/useLLMGateway';
|
||||
|
||||
export function UsageDashboardTab() {
|
||||
const { data, isLoading } = useUsageLogs();
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
render: (v: string) => new Date(v).toLocaleString(),
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
title: '提供商',
|
||||
dataIndex: 'provider',
|
||||
key: 'provider',
|
||||
render: (v: string) => <Tag color={v === 'anthropic' ? 'purple' : 'green'}>{v}</Tag>,
|
||||
},
|
||||
{ title: '模型', dataIndex: 'model', key: 'model', ellipsis: true },
|
||||
{ title: '输入 Tokens', dataIndex: 'inputTokens', key: 'inputTokens' },
|
||||
{ title: '输出 Tokens', dataIndex: 'outputTokens', key: 'outputTokens' },
|
||||
{ title: '总 Tokens', dataIndex: 'totalTokens', key: 'totalTokens' },
|
||||
{
|
||||
title: '费用 (USD)',
|
||||
dataIndex: 'costUsd',
|
||||
key: 'costUsd',
|
||||
render: (v: number | null) => v !== null ? `$${Number(v).toFixed(6)}` : '-',
|
||||
},
|
||||
{
|
||||
title: '耗时',
|
||||
dataIndex: 'durationMs',
|
||||
key: 'durationMs',
|
||||
render: (v: number) => `${(v / 1000).toFixed(1)}s`,
|
||||
},
|
||||
{
|
||||
title: '状态码',
|
||||
dataIndex: 'statusCode',
|
||||
key: 'statusCode',
|
||||
render: (v: number) => <Tag color={v === 200 ? 'green' : 'red'}>{v}</Tag>,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={data?.items}
|
||||
rowKey="id"
|
||||
loading={isLoading}
|
||||
size="small"
|
||||
pagination={{ pageSize: 20, showSizeChanger: false }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
import { useState } from 'react';
|
||||
import { Typography, Tabs, Card, Row, Col, Statistic, Spin } from 'antd';
|
||||
import { KeyOutlined, EditOutlined, SafetyOutlined, BarChartOutlined, FileSearchOutlined } from '@ant-design/icons';
|
||||
import { useDashboard } from '../../application/useLLMGateway';
|
||||
import { ApiKeysTab } from '../components/ApiKeysTab';
|
||||
import { InjectionRulesTab } from '../components/InjectionRulesTab';
|
||||
import { ContentRulesTab } from '../components/ContentRulesTab';
|
||||
import { UsageDashboardTab } from '../components/UsageDashboardTab';
|
||||
import { AuditLogsTab } from '../components/AuditLogsTab';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
export function LLMGatewayPage() {
|
||||
const [activeTab, setActiveTab] = useState('keys');
|
||||
const { data: dashboard, isLoading: dashLoading } = useDashboard();
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<Title level={4} style={{ marginBottom: 16 }}>LLM 网关管理</Title>
|
||||
|
||||
{/* Dashboard Summary Cards */}
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title="活跃 Key"
|
||||
value={dashLoading ? '-' : `${dashboard?.keys.active || 0} / ${dashboard?.keys.total || 0}`}
|
||||
prefix={<KeyOutlined />}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title="今日请求"
|
||||
value={dashboard?.today.requests || 0}
|
||||
loading={dashLoading}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title="今日 Tokens"
|
||||
value={dashboard?.today.tokens || 0}
|
||||
loading={dashLoading}
|
||||
formatter={(val) => {
|
||||
const n = Number(val);
|
||||
return n >= 1000000 ? `${(n / 1000000).toFixed(1)}M` : n >= 1000 ? `${(n / 1000).toFixed(1)}K` : String(n);
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title="今日费用"
|
||||
value={dashboard?.today.cost || 0}
|
||||
loading={dashLoading}
|
||||
precision={4}
|
||||
prefix="$"
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* Tabs */}
|
||||
<Card>
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={setActiveTab}
|
||||
items={[
|
||||
{
|
||||
key: 'keys',
|
||||
label: <span><KeyOutlined /> API Key</span>,
|
||||
children: <ApiKeysTab />,
|
||||
},
|
||||
{
|
||||
key: 'injection',
|
||||
label: <span><EditOutlined /> 注入规则</span>,
|
||||
children: <InjectionRulesTab />,
|
||||
},
|
||||
{
|
||||
key: 'content',
|
||||
label: <span><SafetyOutlined /> 内容审查</span>,
|
||||
children: <ContentRulesTab />,
|
||||
},
|
||||
{
|
||||
key: 'usage',
|
||||
label: <span><BarChartOutlined /> 用量统计</span>,
|
||||
children: <UsageDashboardTab />,
|
||||
},
|
||||
{
|
||||
key: 'audit',
|
||||
label: <span><FileSearchOutlined /> 审计日志</span>,
|
||||
children: <AuditLogsTab />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -22,6 +22,7 @@ import {
|
|||
ExperimentOutlined,
|
||||
FormOutlined,
|
||||
EyeOutlined,
|
||||
GatewayOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
|
||||
|
|
@ -59,6 +60,11 @@ const menuItems: MenuProps['items'] = [
|
|||
icon: <EyeOutlined />,
|
||||
label: '系统总监',
|
||||
},
|
||||
{
|
||||
key: '/llm-gateway',
|
||||
icon: <GatewayOutlined />,
|
||||
label: 'LLM 网关',
|
||||
},
|
||||
{
|
||||
key: 'analytics-group',
|
||||
icon: <BarChartOutlined />,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,605 @@
|
|||
/**
|
||||
* Admin Gateway Controller
|
||||
* LLM 网关管理 API — API Key / 注入规则 / 内容审查规则 / 用量 / 审计日志
|
||||
*
|
||||
* ⚠️ 路由顺序:静态路由(keys, injection-rules, content-rules, usage, audit-logs, dashboard)
|
||||
* 必须在动态路由(:id)之前声明
|
||||
*/
|
||||
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Param,
|
||||
Body,
|
||||
Query,
|
||||
Headers,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
UnauthorizedException,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, IsNull, Between, MoreThanOrEqual } from 'typeorm';
|
||||
import * as jwt from 'jsonwebtoken';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { createHash, randomBytes } from 'crypto';
|
||||
import { GatewayApiKeyORM } from '../../infrastructure/database/postgres/entities/gateway-api-key.orm';
|
||||
import { GatewayInjectionRuleORM } from '../../infrastructure/database/postgres/entities/gateway-injection-rule.orm';
|
||||
import { GatewayContentRuleORM } from '../../infrastructure/database/postgres/entities/gateway-content-rule.orm';
|
||||
import { GatewayUsageLogORM } from '../../infrastructure/database/postgres/entities/gateway-usage-log.orm';
|
||||
import { GatewayAuditLogORM } from '../../infrastructure/database/postgres/entities/gateway-audit-log.orm';
|
||||
|
||||
interface AdminPayload {
|
||||
id: string;
|
||||
username: string;
|
||||
role: string;
|
||||
tenantId?: string;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// API Key 管理
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
@Controller('conversations/admin/gateway/keys')
|
||||
export class AdminGatewayKeysController {
|
||||
constructor(
|
||||
@InjectRepository(GatewayApiKeyORM)
|
||||
private readonly repo: Repository<GatewayApiKeyORM>,
|
||||
) {}
|
||||
|
||||
private verifyAdmin(authorization: string): AdminPayload {
|
||||
const token = authorization?.replace('Bearer ', '');
|
||||
if (!token) throw new UnauthorizedException('Missing token');
|
||||
try {
|
||||
return jwt.verify(token, process.env.JWT_SECRET || 'your-jwt-secret-key') as AdminPayload;
|
||||
} catch {
|
||||
throw new UnauthorizedException('Invalid token');
|
||||
}
|
||||
}
|
||||
|
||||
@Get()
|
||||
async listKeys(@Headers('authorization') auth: string) {
|
||||
const admin = this.verifyAdmin(auth);
|
||||
const tenantId = admin.tenantId || null;
|
||||
|
||||
const keys = await this.repo.find({
|
||||
where: { tenantId: tenantId ?? IsNull() },
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
items: keys,
|
||||
total: keys.length,
|
||||
enabledCount: keys.filter((k) => k.enabled).length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@Post()
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
async createKey(
|
||||
@Headers('authorization') auth: string,
|
||||
@Body() dto: {
|
||||
name: string;
|
||||
owner?: string;
|
||||
permissions?: { allowedModels?: string[]; allowStreaming?: boolean; allowTools?: boolean };
|
||||
rateLimitRpm?: number;
|
||||
rateLimitTpd?: number;
|
||||
monthlyBudget?: number;
|
||||
expiresAt?: string;
|
||||
},
|
||||
) {
|
||||
const admin = this.verifyAdmin(auth);
|
||||
const tenantId = admin.tenantId || null;
|
||||
|
||||
if (!dto.name?.trim()) {
|
||||
throw new BadRequestException('name is required');
|
||||
}
|
||||
|
||||
// Generate a unique API key: sk-gw-<32 random hex chars>
|
||||
const rawKey = `sk-gw-${randomBytes(16).toString('hex')}`;
|
||||
const keyHash = createHash('sha256').update(rawKey).digest('hex');
|
||||
const keyPrefix = rawKey.slice(0, 12);
|
||||
|
||||
const entity = this.repo.create({
|
||||
id: uuidv4(),
|
||||
tenantId,
|
||||
keyHash,
|
||||
keyPrefix,
|
||||
name: dto.name.trim(),
|
||||
owner: dto.owner?.trim() || '',
|
||||
permissions: {
|
||||
allowedModels: dto.permissions?.allowedModels || ['*'],
|
||||
allowStreaming: dto.permissions?.allowStreaming !== false,
|
||||
allowTools: dto.permissions?.allowTools !== false,
|
||||
},
|
||||
rateLimitRpm: dto.rateLimitRpm ?? 60,
|
||||
rateLimitTpd: dto.rateLimitTpd ?? 1000000,
|
||||
monthlyBudget: dto.monthlyBudget ?? null,
|
||||
enabled: true,
|
||||
expiresAt: dto.expiresAt ? new Date(dto.expiresAt) : null,
|
||||
createdBy: admin.id,
|
||||
});
|
||||
|
||||
const saved = await this.repo.save(entity);
|
||||
|
||||
// Return the raw key only on creation (never stored, only the hash)
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
...saved,
|
||||
rawKey, // ⚠️ 仅此一次返回完整 Key
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
async updateKey(
|
||||
@Headers('authorization') auth: string,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: {
|
||||
name?: string;
|
||||
owner?: string;
|
||||
permissions?: { allowedModels?: string[]; allowStreaming?: boolean; allowTools?: boolean };
|
||||
rateLimitRpm?: number;
|
||||
rateLimitTpd?: number;
|
||||
monthlyBudget?: number | null;
|
||||
expiresAt?: string | null;
|
||||
enabled?: boolean;
|
||||
},
|
||||
) {
|
||||
this.verifyAdmin(auth);
|
||||
|
||||
const key = await this.repo.findOne({ where: { id } });
|
||||
if (!key) return { success: false, error: 'Key not found' };
|
||||
|
||||
if (dto.name !== undefined) key.name = dto.name.trim();
|
||||
if (dto.owner !== undefined) key.owner = dto.owner.trim();
|
||||
if (dto.permissions) key.permissions = { ...key.permissions, ...dto.permissions };
|
||||
if (dto.rateLimitRpm !== undefined) key.rateLimitRpm = dto.rateLimitRpm;
|
||||
if (dto.rateLimitTpd !== undefined) key.rateLimitTpd = dto.rateLimitTpd;
|
||||
if (dto.monthlyBudget !== undefined) key.monthlyBudget = dto.monthlyBudget;
|
||||
if (dto.expiresAt !== undefined) key.expiresAt = dto.expiresAt ? new Date(dto.expiresAt) : null;
|
||||
if (dto.enabled !== undefined) key.enabled = dto.enabled;
|
||||
|
||||
const updated = await this.repo.save(key);
|
||||
return { success: true, data: updated };
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async deleteKey(
|
||||
@Headers('authorization') auth: string,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
this.verifyAdmin(auth);
|
||||
const key = await this.repo.findOne({ where: { id } });
|
||||
if (!key) return { success: false, error: 'Key not found' };
|
||||
await this.repo.delete(id);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Post(':id/toggle')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async toggleKey(
|
||||
@Headers('authorization') auth: string,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
this.verifyAdmin(auth);
|
||||
const key = await this.repo.findOne({ where: { id } });
|
||||
if (!key) return { success: false, error: 'Key not found' };
|
||||
key.enabled = !key.enabled;
|
||||
const updated = await this.repo.save(key);
|
||||
return { success: true, data: updated };
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// 注入规则管理
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
@Controller('conversations/admin/gateway/injection-rules')
|
||||
export class AdminGatewayInjectionRulesController {
|
||||
constructor(
|
||||
@InjectRepository(GatewayInjectionRuleORM)
|
||||
private readonly repo: Repository<GatewayInjectionRuleORM>,
|
||||
) {}
|
||||
|
||||
private verifyAdmin(authorization: string): AdminPayload {
|
||||
const token = authorization?.replace('Bearer ', '');
|
||||
if (!token) throw new UnauthorizedException('Missing token');
|
||||
try {
|
||||
return jwt.verify(token, process.env.JWT_SECRET || 'your-jwt-secret-key') as AdminPayload;
|
||||
} catch {
|
||||
throw new UnauthorizedException('Invalid token');
|
||||
}
|
||||
}
|
||||
|
||||
@Get()
|
||||
async listRules(@Headers('authorization') auth: string) {
|
||||
const admin = this.verifyAdmin(auth);
|
||||
const tenantId = admin.tenantId || null;
|
||||
|
||||
const rules = await this.repo.find({
|
||||
where: { tenantId: tenantId ?? IsNull() },
|
||||
order: { priority: 'ASC', createdAt: 'ASC' },
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
items: rules,
|
||||
total: rules.length,
|
||||
enabledCount: rules.filter((r) => r.enabled).length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@Post()
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
async createRule(
|
||||
@Headers('authorization') auth: string,
|
||||
@Body() dto: {
|
||||
name: string;
|
||||
description?: string;
|
||||
position?: 'prepend' | 'append';
|
||||
content: string;
|
||||
matchModels?: string[];
|
||||
matchKeyIds?: string[];
|
||||
priority?: number;
|
||||
},
|
||||
) {
|
||||
const admin = this.verifyAdmin(auth);
|
||||
const tenantId = admin.tenantId || null;
|
||||
|
||||
if (!dto.name?.trim()) throw new BadRequestException('name is required');
|
||||
if (!dto.content?.trim()) throw new BadRequestException('content is required');
|
||||
|
||||
const rule = this.repo.create({
|
||||
id: uuidv4(),
|
||||
tenantId,
|
||||
name: dto.name.trim(),
|
||||
description: dto.description || null,
|
||||
position: dto.position || 'append',
|
||||
content: dto.content.trim(),
|
||||
matchModels: dto.matchModels || ['*'],
|
||||
matchKeyIds: dto.matchKeyIds || null,
|
||||
priority: dto.priority ?? 0,
|
||||
enabled: true,
|
||||
createdBy: admin.id,
|
||||
});
|
||||
|
||||
const saved = await this.repo.save(rule);
|
||||
return { success: true, data: saved };
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
async updateRule(
|
||||
@Headers('authorization') auth: string,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: {
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
position?: 'prepend' | 'append';
|
||||
content?: string;
|
||||
matchModels?: string[];
|
||||
matchKeyIds?: string[] | null;
|
||||
priority?: number;
|
||||
enabled?: boolean;
|
||||
},
|
||||
) {
|
||||
this.verifyAdmin(auth);
|
||||
const rule = await this.repo.findOne({ where: { id } });
|
||||
if (!rule) return { success: false, error: 'Rule not found' };
|
||||
|
||||
if (dto.name !== undefined) rule.name = dto.name.trim();
|
||||
if (dto.description !== undefined) rule.description = dto.description;
|
||||
if (dto.position !== undefined) rule.position = dto.position;
|
||||
if (dto.content !== undefined) rule.content = dto.content.trim();
|
||||
if (dto.matchModels !== undefined) rule.matchModels = dto.matchModels;
|
||||
if (dto.matchKeyIds !== undefined) rule.matchKeyIds = dto.matchKeyIds;
|
||||
if (dto.priority !== undefined) rule.priority = dto.priority;
|
||||
if (dto.enabled !== undefined) rule.enabled = dto.enabled;
|
||||
|
||||
const updated = await this.repo.save(rule);
|
||||
return { success: true, data: updated };
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async deleteRule(@Headers('authorization') auth: string, @Param('id') id: string) {
|
||||
this.verifyAdmin(auth);
|
||||
const rule = await this.repo.findOne({ where: { id } });
|
||||
if (!rule) return { success: false, error: 'Rule not found' };
|
||||
await this.repo.delete(id);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Post(':id/toggle')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async toggleRule(@Headers('authorization') auth: string, @Param('id') id: string) {
|
||||
this.verifyAdmin(auth);
|
||||
const rule = await this.repo.findOne({ where: { id } });
|
||||
if (!rule) return { success: false, error: 'Rule not found' };
|
||||
rule.enabled = !rule.enabled;
|
||||
const updated = await this.repo.save(rule);
|
||||
return { success: true, data: updated };
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// 内容审查规则管理
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
@Controller('conversations/admin/gateway/content-rules')
|
||||
export class AdminGatewayContentRulesController {
|
||||
constructor(
|
||||
@InjectRepository(GatewayContentRuleORM)
|
||||
private readonly repo: Repository<GatewayContentRuleORM>,
|
||||
) {}
|
||||
|
||||
private verifyAdmin(authorization: string): AdminPayload {
|
||||
const token = authorization?.replace('Bearer ', '');
|
||||
if (!token) throw new UnauthorizedException('Missing token');
|
||||
try {
|
||||
return jwt.verify(token, process.env.JWT_SECRET || 'your-jwt-secret-key') as AdminPayload;
|
||||
} catch {
|
||||
throw new UnauthorizedException('Invalid token');
|
||||
}
|
||||
}
|
||||
|
||||
@Get()
|
||||
async listRules(@Headers('authorization') auth: string) {
|
||||
const admin = this.verifyAdmin(auth);
|
||||
const tenantId = admin.tenantId || null;
|
||||
|
||||
const rules = await this.repo.find({
|
||||
where: { tenantId: tenantId ?? IsNull() },
|
||||
order: { priority: 'ASC', createdAt: 'ASC' },
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
items: rules,
|
||||
total: rules.length,
|
||||
enabledCount: rules.filter((r) => r.enabled).length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@Post()
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
async createRule(
|
||||
@Headers('authorization') auth: string,
|
||||
@Body() dto: {
|
||||
name: string;
|
||||
type?: 'keyword' | 'regex';
|
||||
pattern: string;
|
||||
action?: 'block' | 'warn' | 'log';
|
||||
rejectMessage?: string;
|
||||
priority?: number;
|
||||
},
|
||||
) {
|
||||
const admin = this.verifyAdmin(auth);
|
||||
const tenantId = admin.tenantId || null;
|
||||
|
||||
if (!dto.name?.trim()) throw new BadRequestException('name is required');
|
||||
if (!dto.pattern?.trim()) throw new BadRequestException('pattern is required');
|
||||
|
||||
// Validate regex if type is regex
|
||||
if (dto.type === 'regex') {
|
||||
try {
|
||||
new RegExp(dto.pattern);
|
||||
} catch {
|
||||
throw new BadRequestException('Invalid regex pattern');
|
||||
}
|
||||
}
|
||||
|
||||
const rule = this.repo.create({
|
||||
id: uuidv4(),
|
||||
tenantId,
|
||||
name: dto.name.trim(),
|
||||
type: dto.type || 'keyword',
|
||||
pattern: dto.pattern.trim(),
|
||||
action: dto.action || 'block',
|
||||
rejectMessage: dto.rejectMessage || null,
|
||||
priority: dto.priority ?? 0,
|
||||
enabled: true,
|
||||
createdBy: admin.id,
|
||||
});
|
||||
|
||||
const saved = await this.repo.save(rule);
|
||||
return { success: true, data: saved };
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
async updateRule(
|
||||
@Headers('authorization') auth: string,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: {
|
||||
name?: string;
|
||||
type?: 'keyword' | 'regex';
|
||||
pattern?: string;
|
||||
action?: 'block' | 'warn' | 'log';
|
||||
rejectMessage?: string | null;
|
||||
priority?: number;
|
||||
enabled?: boolean;
|
||||
},
|
||||
) {
|
||||
this.verifyAdmin(auth);
|
||||
const rule = await this.repo.findOne({ where: { id } });
|
||||
if (!rule) return { success: false, error: 'Rule not found' };
|
||||
|
||||
if (dto.name !== undefined) rule.name = dto.name.trim();
|
||||
if (dto.type !== undefined) rule.type = dto.type;
|
||||
if (dto.pattern !== undefined) {
|
||||
if (dto.type === 'regex' || rule.type === 'regex') {
|
||||
try { new RegExp(dto.pattern); } catch { throw new BadRequestException('Invalid regex pattern'); }
|
||||
}
|
||||
rule.pattern = dto.pattern.trim();
|
||||
}
|
||||
if (dto.action !== undefined) rule.action = dto.action;
|
||||
if (dto.rejectMessage !== undefined) rule.rejectMessage = dto.rejectMessage;
|
||||
if (dto.priority !== undefined) rule.priority = dto.priority;
|
||||
if (dto.enabled !== undefined) rule.enabled = dto.enabled;
|
||||
|
||||
const updated = await this.repo.save(rule);
|
||||
return { success: true, data: updated };
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async deleteRule(@Headers('authorization') auth: string, @Param('id') id: string) {
|
||||
this.verifyAdmin(auth);
|
||||
const rule = await this.repo.findOne({ where: { id } });
|
||||
if (!rule) return { success: false, error: 'Rule not found' };
|
||||
await this.repo.delete(id);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Post(':id/toggle')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async toggleRule(@Headers('authorization') auth: string, @Param('id') id: string) {
|
||||
this.verifyAdmin(auth);
|
||||
const rule = await this.repo.findOne({ where: { id } });
|
||||
if (!rule) return { success: false, error: 'Rule not found' };
|
||||
rule.enabled = !rule.enabled;
|
||||
const updated = await this.repo.save(rule);
|
||||
return { success: true, data: updated };
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// 用量统计 + 审计日志 + 仪表盘
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
@Controller('conversations/admin/gateway')
|
||||
export class AdminGatewayDashboardController {
|
||||
constructor(
|
||||
@InjectRepository(GatewayUsageLogORM)
|
||||
private readonly usageRepo: Repository<GatewayUsageLogORM>,
|
||||
@InjectRepository(GatewayAuditLogORM)
|
||||
private readonly auditRepo: Repository<GatewayAuditLogORM>,
|
||||
@InjectRepository(GatewayApiKeyORM)
|
||||
private readonly keyRepo: Repository<GatewayApiKeyORM>,
|
||||
) {}
|
||||
|
||||
private verifyAdmin(authorization: string): AdminPayload {
|
||||
const token = authorization?.replace('Bearer ', '');
|
||||
if (!token) throw new UnauthorizedException('Missing token');
|
||||
try {
|
||||
return jwt.verify(token, process.env.JWT_SECRET || 'your-jwt-secret-key') as AdminPayload;
|
||||
} catch {
|
||||
throw new UnauthorizedException('Invalid token');
|
||||
}
|
||||
}
|
||||
|
||||
@Get('dashboard')
|
||||
async getDashboard(@Headers('authorization') auth: string) {
|
||||
this.verifyAdmin(auth);
|
||||
|
||||
const now = new Date();
|
||||
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
|
||||
// Parallel queries
|
||||
const [totalKeys, activeKeys, todayUsage, monthUsage, recentAudits] = await Promise.all([
|
||||
this.keyRepo.count(),
|
||||
this.keyRepo.count({ where: { enabled: true } }),
|
||||
this.usageRepo
|
||||
.createQueryBuilder('u')
|
||||
.select('COUNT(*)', 'requestCount')
|
||||
.addSelect('COALESCE(SUM(u.total_tokens), 0)', 'totalTokens')
|
||||
.addSelect('COALESCE(SUM(u.cost_usd), 0)', 'totalCost')
|
||||
.where('u.created_at >= :start', { start: todayStart })
|
||||
.getRawOne(),
|
||||
this.usageRepo
|
||||
.createQueryBuilder('u')
|
||||
.select('COUNT(*)', 'requestCount')
|
||||
.addSelect('COALESCE(SUM(u.total_tokens), 0)', 'totalTokens')
|
||||
.addSelect('COALESCE(SUM(u.cost_usd), 0)', 'totalCost')
|
||||
.where('u.created_at >= :start', { start: monthStart })
|
||||
.getRawOne(),
|
||||
this.auditRepo.count({
|
||||
where: { contentFiltered: true, createdAt: MoreThanOrEqual(todayStart) },
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
keys: { total: totalKeys, active: activeKeys },
|
||||
today: {
|
||||
requests: parseInt(todayUsage?.requestCount || '0'),
|
||||
tokens: parseInt(todayUsage?.totalTokens || '0'),
|
||||
cost: parseFloat(todayUsage?.totalCost || '0'),
|
||||
filtered: recentAudits,
|
||||
},
|
||||
month: {
|
||||
requests: parseInt(monthUsage?.requestCount || '0'),
|
||||
tokens: parseInt(monthUsage?.totalTokens || '0'),
|
||||
cost: parseFloat(monthUsage?.totalCost || '0'),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@Get('usage')
|
||||
async getUsage(
|
||||
@Headers('authorization') auth: string,
|
||||
@Query('keyId') keyId?: string,
|
||||
@Query('startDate') startDate?: string,
|
||||
@Query('endDate') endDate?: string,
|
||||
@Query('limit') limit?: string,
|
||||
) {
|
||||
this.verifyAdmin(auth);
|
||||
|
||||
const qb = this.usageRepo.createQueryBuilder('u');
|
||||
|
||||
if (keyId) qb.andWhere('u.api_key_id = :keyId', { keyId });
|
||||
if (startDate) qb.andWhere('u.created_at >= :startDate', { startDate: new Date(startDate) });
|
||||
if (endDate) qb.andWhere('u.created_at <= :endDate', { endDate: new Date(endDate) });
|
||||
|
||||
qb.orderBy('u.created_at', 'DESC');
|
||||
qb.take(parseInt(limit || '100'));
|
||||
|
||||
const [items, total] = await qb.getManyAndCount();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: { items, total },
|
||||
};
|
||||
}
|
||||
|
||||
@Get('audit-logs')
|
||||
async getAuditLogs(
|
||||
@Headers('authorization') auth: string,
|
||||
@Query('keyId') keyId?: string,
|
||||
@Query('filtered') filtered?: string,
|
||||
@Query('startDate') startDate?: string,
|
||||
@Query('endDate') endDate?: string,
|
||||
@Query('limit') limit?: string,
|
||||
) {
|
||||
this.verifyAdmin(auth);
|
||||
|
||||
const qb = this.auditRepo.createQueryBuilder('a');
|
||||
|
||||
if (keyId) qb.andWhere('a.api_key_id = :keyId', { keyId });
|
||||
if (filtered === 'true') qb.andWhere('a.content_filtered = true');
|
||||
if (startDate) qb.andWhere('a.created_at >= :startDate', { startDate: new Date(startDate) });
|
||||
if (endDate) qb.andWhere('a.created_at <= :endDate', { endDate: new Date(endDate) });
|
||||
|
||||
qb.orderBy('a.created_at', 'DESC');
|
||||
qb.take(parseInt(limit || '100'));
|
||||
|
||||
const [items, total] = await qb.getManyAndCount();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: { items, total },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,11 @@ import { TokenUsageORM } from '../infrastructure/database/postgres/entities/toke
|
|||
import { AgentExecutionORM } from '../infrastructure/database/postgres/entities/agent-execution.orm';
|
||||
import { AssessmentDirectiveORM } from '../infrastructure/database/postgres/entities/assessment-directive.orm';
|
||||
import { CollectionDirectiveORM } from '../infrastructure/database/postgres/entities/collection-directive.orm';
|
||||
import { GatewayApiKeyORM } from '../infrastructure/database/postgres/entities/gateway-api-key.orm';
|
||||
import { GatewayInjectionRuleORM } from '../infrastructure/database/postgres/entities/gateway-injection-rule.orm';
|
||||
import { GatewayContentRuleORM } from '../infrastructure/database/postgres/entities/gateway-content-rule.orm';
|
||||
import { GatewayUsageLogORM } from '../infrastructure/database/postgres/entities/gateway-usage-log.orm';
|
||||
import { GatewayAuditLogORM } from '../infrastructure/database/postgres/entities/gateway-audit-log.orm';
|
||||
import { ConversationPostgresRepository } from '../adapters/outbound/persistence/conversation-postgres.repository';
|
||||
import { MessagePostgresRepository } from '../adapters/outbound/persistence/message-postgres.repository';
|
||||
import { TokenUsagePostgresRepository } from '../adapters/outbound/persistence/token-usage-postgres.repository';
|
||||
|
|
@ -22,12 +27,18 @@ import { AdminObservabilityController } from '../adapters/inbound/admin-observab
|
|||
import { AdminAssessmentDirectiveController } from '../adapters/inbound/admin-assessment-directive.controller';
|
||||
import { AdminCollectionDirectiveController } from '../adapters/inbound/admin-collection-directive.controller';
|
||||
import { AdminSupervisorController } from '../adapters/inbound/admin-supervisor.controller';
|
||||
import {
|
||||
AdminGatewayKeysController,
|
||||
AdminGatewayInjectionRulesController,
|
||||
AdminGatewayContentRulesController,
|
||||
AdminGatewayDashboardController,
|
||||
} from '../adapters/inbound/admin-gateway.controller';
|
||||
import { ConversationGateway } from '../adapters/inbound/conversation.gateway';
|
||||
import { PaymentModule } from '../infrastructure/payment/payment.module';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([ConversationORM, MessageORM, TokenUsageORM, AgentExecutionORM, AssessmentDirectiveORM, CollectionDirectiveORM]), PaymentModule],
|
||||
controllers: [ConversationController, InternalConversationController, AdminMcpController, AdminEvaluationRuleController, AdminAssessmentDirectiveController, AdminCollectionDirectiveController, AdminSupervisorController, AdminConversationController, AdminObservabilityController],
|
||||
imports: [TypeOrmModule.forFeature([ConversationORM, MessageORM, TokenUsageORM, AgentExecutionORM, AssessmentDirectiveORM, CollectionDirectiveORM, GatewayApiKeyORM, GatewayInjectionRuleORM, GatewayContentRuleORM, GatewayUsageLogORM, GatewayAuditLogORM]), PaymentModule],
|
||||
controllers: [ConversationController, InternalConversationController, AdminMcpController, AdminEvaluationRuleController, AdminAssessmentDirectiveController, AdminCollectionDirectiveController, AdminSupervisorController, AdminConversationController, AdminObservabilityController, AdminGatewayKeysController, AdminGatewayInjectionRulesController, AdminGatewayContentRulesController, AdminGatewayDashboardController],
|
||||
providers: [
|
||||
ConversationService,
|
||||
ConversationGateway,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,60 @@
|
|||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity('gateway_api_keys')
|
||||
@Index('idx_gateway_api_keys_tenant', ['tenantId'])
|
||||
@Index('idx_gateway_api_keys_enabled', ['enabled'])
|
||||
export class GatewayApiKeyORM {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'tenant_id', type: 'uuid', nullable: true })
|
||||
tenantId: string | null;
|
||||
|
||||
@Column({ name: 'key_hash', type: 'varchar', length: 64 })
|
||||
keyHash: string;
|
||||
|
||||
@Column({ name: 'key_prefix', type: 'varchar', length: 12 })
|
||||
keyPrefix: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 100 })
|
||||
name: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 200, default: '' })
|
||||
owner: string;
|
||||
|
||||
@Column({ type: 'jsonb', default: () => `'{"allowedModels": ["*"], "allowStreaming": true, "allowTools": true}'` })
|
||||
permissions: {
|
||||
allowedModels: string[];
|
||||
allowStreaming: boolean;
|
||||
allowTools: boolean;
|
||||
};
|
||||
|
||||
@Column({ name: 'rate_limit_rpm', type: 'int', default: 60 })
|
||||
rateLimitRpm: number;
|
||||
|
||||
@Column({ name: 'rate_limit_tpd', type: 'int', default: 1000000 })
|
||||
rateLimitTpd: number;
|
||||
|
||||
@Column({ name: 'monthly_budget', type: 'decimal', precision: 10, scale: 2, nullable: true })
|
||||
monthlyBudget: number | null;
|
||||
|
||||
@Column({ type: 'boolean', default: true })
|
||||
enabled: boolean;
|
||||
|
||||
@Column({ name: 'expires_at', type: 'timestamp', nullable: true })
|
||||
expiresAt: Date | null;
|
||||
|
||||
@Column({ name: 'last_used_at', type: 'timestamp', nullable: true })
|
||||
lastUsedAt: Date | null;
|
||||
|
||||
@Column({ name: 'created_by', type: 'uuid', nullable: true })
|
||||
createdBy: string | null;
|
||||
|
||||
@Column({ name: 'created_at', type: 'timestamp', default: () => 'NOW()' })
|
||||
createdAt: Date;
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity('gateway_audit_logs')
|
||||
@Index('idx_gateway_audit_logs_key', ['apiKeyId'])
|
||||
@Index('idx_gateway_audit_logs_created', ['createdAt'])
|
||||
export class GatewayAuditLogORM {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'api_key_id', type: 'uuid' })
|
||||
apiKeyId: string;
|
||||
|
||||
@Column({ name: 'request_method', type: 'varchar', length: 10 })
|
||||
requestMethod: string;
|
||||
|
||||
@Column({ name: 'request_path', type: 'varchar', length: 200 })
|
||||
requestPath: string;
|
||||
|
||||
@Column({ name: 'request_model', type: 'varchar', length: 100, nullable: true })
|
||||
requestModel: string | null;
|
||||
|
||||
@Column({ name: 'request_ip', type: 'varchar', length: 50, default: '' })
|
||||
requestIp: string;
|
||||
|
||||
@Column({ name: 'content_filtered', type: 'boolean', default: false })
|
||||
contentFiltered: boolean;
|
||||
|
||||
@Column({ name: 'filter_rule_id', type: 'uuid', nullable: true })
|
||||
filterRuleId: string | null;
|
||||
|
||||
@Column({ name: 'injection_applied', type: 'boolean', default: false })
|
||||
injectionApplied: boolean;
|
||||
|
||||
@Column({ name: 'response_status', type: 'int', default: 200 })
|
||||
responseStatus: number;
|
||||
|
||||
@Column({ name: 'duration_ms', type: 'int', default: 0 })
|
||||
durationMs: number;
|
||||
|
||||
@Column({ name: 'created_at', type: 'timestamp', default: () => 'NOW()' })
|
||||
createdAt: Date;
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity('gateway_content_rules')
|
||||
@Index('idx_gateway_content_rules_tenant', ['tenantId'])
|
||||
@Index('idx_gateway_content_rules_enabled', ['tenantId', 'enabled'])
|
||||
export class GatewayContentRuleORM {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'tenant_id', type: 'uuid', nullable: true })
|
||||
tenantId: string | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 100 })
|
||||
name: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 20, default: 'keyword' })
|
||||
type: 'keyword' | 'regex';
|
||||
|
||||
@Column({ type: 'text' })
|
||||
pattern: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 20, default: 'block' })
|
||||
action: 'block' | 'warn' | 'log';
|
||||
|
||||
@Column({ name: 'reject_message', type: 'text', nullable: true })
|
||||
rejectMessage: string | null;
|
||||
|
||||
@Column({ type: 'int', default: 0 })
|
||||
priority: number;
|
||||
|
||||
@Column({ type: 'boolean', default: true })
|
||||
enabled: boolean;
|
||||
|
||||
@Column({ name: 'created_by', type: 'uuid', nullable: true })
|
||||
createdBy: string | null;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity('gateway_injection_rules')
|
||||
@Index('idx_gateway_injection_rules_tenant', ['tenantId'])
|
||||
@Index('idx_gateway_injection_rules_enabled', ['tenantId', 'enabled'])
|
||||
export class GatewayInjectionRuleORM {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'tenant_id', type: 'uuid', nullable: true })
|
||||
tenantId: string | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 100 })
|
||||
name: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description: string | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 10, default: 'append' })
|
||||
position: 'prepend' | 'append';
|
||||
|
||||
@Column({ type: 'text' })
|
||||
content: string;
|
||||
|
||||
@Column({ name: 'match_models', type: 'jsonb', default: () => `'["*"]'` })
|
||||
matchModels: string[];
|
||||
|
||||
@Column({ name: 'match_key_ids', type: 'jsonb', nullable: true })
|
||||
matchKeyIds: string[] | null;
|
||||
|
||||
@Column({ type: 'int', default: 0 })
|
||||
priority: number;
|
||||
|
||||
@Column({ type: 'boolean', default: true })
|
||||
enabled: boolean;
|
||||
|
||||
@Column({ name: 'created_by', type: 'uuid', nullable: true })
|
||||
createdBy: string | null;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity('gateway_usage_logs')
|
||||
@Index('idx_gateway_usage_logs_key', ['apiKeyId'])
|
||||
@Index('idx_gateway_usage_logs_created', ['createdAt'])
|
||||
export class GatewayUsageLogORM {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'api_key_id', type: 'uuid' })
|
||||
apiKeyId: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 100 })
|
||||
model: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 20 })
|
||||
provider: string;
|
||||
|
||||
@Column({ name: 'input_tokens', type: 'int', default: 0 })
|
||||
inputTokens: number;
|
||||
|
||||
@Column({ name: 'output_tokens', type: 'int', default: 0 })
|
||||
outputTokens: number;
|
||||
|
||||
@Column({ name: 'total_tokens', type: 'int', default: 0 })
|
||||
totalTokens: number;
|
||||
|
||||
@Column({ name: 'cost_usd', type: 'decimal', precision: 10, scale: 6, nullable: true })
|
||||
costUsd: number | null;
|
||||
|
||||
@Column({ name: 'duration_ms', type: 'int', default: 0 })
|
||||
durationMs: number;
|
||||
|
||||
@Column({ name: 'status_code', type: 'int', default: 200 })
|
||||
statusCode: number;
|
||||
|
||||
@Column({ name: 'created_at', type: 'timestamp', default: () => 'NOW()' })
|
||||
createdAt: Date;
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
# ===========================================
|
||||
# iConsulting LLM Gateway Dockerfile
|
||||
# ===========================================
|
||||
|
||||
# 构建阶段
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 安装 pnpm
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
|
||||
# 复制 workspace 配置
|
||||
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./
|
||||
COPY packages/services/llm-gateway/package.json ./packages/services/llm-gateway/
|
||||
|
||||
# 安装依赖
|
||||
RUN pnpm install --frozen-lockfile --filter @iconsulting/llm-gateway
|
||||
|
||||
# 复制源代码
|
||||
COPY packages/services/llm-gateway ./packages/services/llm-gateway
|
||||
|
||||
# 构建服务
|
||||
RUN cd packages/services/llm-gateway && npx tsc
|
||||
|
||||
# 运行阶段
|
||||
FROM node:20-alpine AS runner
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 创建非 root 用户
|
||||
RUN addgroup --system --gid 1001 nodejs && \
|
||||
adduser --system --uid 1001 gateway
|
||||
|
||||
# 复制构建产物和依赖配置
|
||||
COPY --from=builder /app/packages/services/llm-gateway/dist ./dist
|
||||
COPY --from=builder /app/packages/services/llm-gateway/package.json ./
|
||||
|
||||
# 安装生产依赖
|
||||
RUN npm install --omit=dev --ignore-scripts
|
||||
|
||||
# 设置环境变量
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3008
|
||||
|
||||
# 切换用户
|
||||
USER gateway
|
||||
|
||||
# 暴露端口
|
||||
EXPOSE 3008
|
||||
|
||||
# 健康检查
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:3008/health || exit 1
|
||||
|
||||
# 启动服务
|
||||
CMD ["node", "dist/main.js"]
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"name": "@iconsulting/llm-gateway",
|
||||
"version": "1.0.0",
|
||||
"description": "LLM API Gateway - Anthropic/OpenAI compatible proxy with regulatory injection and content filtering",
|
||||
"main": "dist/main.js",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsx watch src/main.ts",
|
||||
"start": "node dist/main.js",
|
||||
"lint": "eslint \"src/**/*.ts\"",
|
||||
"test": "jest",
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"fastify": "^5.2.0",
|
||||
"pg": "^8.11.0",
|
||||
"uuid": "^9.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.10.0",
|
||||
"@types/pg": "^8.10.0",
|
||||
"@types/uuid": "^9.0.0",
|
||||
"tsx": "^4.7.0",
|
||||
"typescript": "^5.3.0"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
-- ===========================================
|
||||
-- LLM Gateway Database Tables
|
||||
-- ===========================================
|
||||
|
||||
-- 1. API Keys for external users
|
||||
CREATE TABLE IF NOT EXISTS gateway_api_keys (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID,
|
||||
key_hash VARCHAR(64) NOT NULL UNIQUE,
|
||||
key_prefix VARCHAR(12) NOT NULL,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
owner VARCHAR(200) NOT NULL DEFAULT '',
|
||||
permissions JSONB NOT NULL DEFAULT '{"allowedModels": ["*"], "allowStreaming": true, "allowTools": true}',
|
||||
rate_limit_rpm INTEGER NOT NULL DEFAULT 60,
|
||||
rate_limit_tpd INTEGER NOT NULL DEFAULT 1000000,
|
||||
monthly_budget DECIMAL(10,2),
|
||||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
expires_at TIMESTAMP,
|
||||
last_used_at TIMESTAMP,
|
||||
created_by UUID,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_gateway_api_keys_hash ON gateway_api_keys (key_hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_gateway_api_keys_tenant ON gateway_api_keys (tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_gateway_api_keys_enabled ON gateway_api_keys (enabled);
|
||||
|
||||
-- 2. Injection rules (regulatory content to inject into system prompts)
|
||||
CREATE TABLE IF NOT EXISTS gateway_injection_rules (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
description TEXT,
|
||||
position VARCHAR(10) NOT NULL DEFAULT 'append' CHECK (position IN ('prepend', 'append')),
|
||||
content TEXT NOT NULL,
|
||||
match_models JSONB NOT NULL DEFAULT '["*"]',
|
||||
match_key_ids JSONB,
|
||||
priority INTEGER NOT NULL DEFAULT 0,
|
||||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
created_by UUID,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_gateway_injection_rules_tenant ON gateway_injection_rules (tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_gateway_injection_rules_enabled ON gateway_injection_rules (tenant_id, enabled);
|
||||
|
||||
-- 3. Content filter rules (message auditing)
|
||||
CREATE TABLE IF NOT EXISTS gateway_content_rules (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
type VARCHAR(20) NOT NULL DEFAULT 'keyword' CHECK (type IN ('keyword', 'regex')),
|
||||
pattern TEXT NOT NULL,
|
||||
action VARCHAR(20) NOT NULL DEFAULT 'block' CHECK (action IN ('block', 'warn', 'log')),
|
||||
reject_message TEXT,
|
||||
priority INTEGER NOT NULL DEFAULT 0,
|
||||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
created_by UUID,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_gateway_content_rules_tenant ON gateway_content_rules (tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_gateway_content_rules_enabled ON gateway_content_rules (tenant_id, enabled);
|
||||
|
||||
-- 4. Usage logs (token consumption tracking)
|
||||
CREATE TABLE IF NOT EXISTS gateway_usage_logs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
api_key_id UUID NOT NULL REFERENCES gateway_api_keys(id) ON DELETE CASCADE,
|
||||
model VARCHAR(100) NOT NULL,
|
||||
provider VARCHAR(20) NOT NULL,
|
||||
input_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
output_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
total_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
cost_usd DECIMAL(10,6),
|
||||
duration_ms INTEGER NOT NULL DEFAULT 0,
|
||||
status_code INTEGER NOT NULL DEFAULT 200,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_gateway_usage_logs_key ON gateway_usage_logs (api_key_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_gateway_usage_logs_created ON gateway_usage_logs (created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_gateway_usage_logs_key_created ON gateway_usage_logs (api_key_id, created_at);
|
||||
|
||||
-- 5. Audit logs (request/response auditing)
|
||||
CREATE TABLE IF NOT EXISTS gateway_audit_logs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
api_key_id UUID NOT NULL REFERENCES gateway_api_keys(id) ON DELETE CASCADE,
|
||||
request_method VARCHAR(10) NOT NULL,
|
||||
request_path VARCHAR(200) NOT NULL,
|
||||
request_model VARCHAR(100),
|
||||
request_ip VARCHAR(50) NOT NULL DEFAULT '',
|
||||
content_filtered BOOLEAN NOT NULL DEFAULT false,
|
||||
filter_rule_id UUID,
|
||||
injection_applied BOOLEAN NOT NULL DEFAULT false,
|
||||
response_status INTEGER NOT NULL DEFAULT 200,
|
||||
duration_ms INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_gateway_audit_logs_key ON gateway_audit_logs (api_key_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_gateway_audit_logs_created ON gateway_audit_logs (created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_gateway_audit_logs_filtered ON gateway_audit_logs (content_filtered) WHERE content_filtered = true;
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
export interface GatewayConfig {
|
||||
port: number;
|
||||
databaseUrl: string;
|
||||
anthropicApiKey: string;
|
||||
anthropicUpstreamUrl: string;
|
||||
openaiApiKey: string;
|
||||
openaiUpstreamUrl: string;
|
||||
rulesCacheTtlMs: number;
|
||||
logLevel: string;
|
||||
}
|
||||
|
||||
export function loadConfig(): GatewayConfig {
|
||||
const required = (key: string): string => {
|
||||
const val = process.env[key];
|
||||
if (!val) throw new Error(`Missing required env var: ${key}`);
|
||||
return val;
|
||||
};
|
||||
|
||||
return {
|
||||
port: parseInt(process.env.PORT || '3008', 10),
|
||||
databaseUrl: required('DATABASE_URL'),
|
||||
anthropicApiKey: required('ANTHROPIC_API_KEY'),
|
||||
anthropicUpstreamUrl: (process.env.ANTHROPIC_UPSTREAM_URL || 'https://api.anthropic.com').replace(/\/$/, ''),
|
||||
openaiApiKey: required('OPENAI_API_KEY'),
|
||||
openaiUpstreamUrl: (process.env.OPENAI_UPSTREAM_URL || 'https://api.openai.com').replace(/\/$/, ''),
|
||||
rulesCacheTtlMs: parseInt(process.env.RULES_CACHE_TTL_MS || '30000', 10),
|
||||
logLevel: process.env.LOG_LEVEL || 'info',
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
import { Pool, PoolClient } from 'pg';
|
||||
import { GatewayConfig } from './config';
|
||||
|
||||
let pool: Pool;
|
||||
|
||||
export function initDb(config: GatewayConfig): Pool {
|
||||
pool = new Pool({
|
||||
connectionString: config.databaseUrl,
|
||||
max: 20,
|
||||
idleTimeoutMillis: 30000,
|
||||
connectionTimeoutMillis: 5000,
|
||||
});
|
||||
|
||||
pool.on('error', (err) => {
|
||||
console.error('[DB] Unexpected error on idle client', err);
|
||||
});
|
||||
|
||||
return pool;
|
||||
}
|
||||
|
||||
export function getPool(): Pool {
|
||||
if (!pool) throw new Error('Database not initialized');
|
||||
return pool;
|
||||
}
|
||||
|
||||
export async function query<T = any>(text: string, params?: any[]): Promise<T[]> {
|
||||
const result = await getPool().query(text, params);
|
||||
return result.rows as T[];
|
||||
}
|
||||
|
||||
export async function queryOne<T = any>(text: string, params?: any[]): Promise<T | null> {
|
||||
const rows = await query<T>(text, params);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
export async function withClient<T>(fn: (client: PoolClient) => Promise<T>): Promise<T> {
|
||||
const client = await getPool().connect();
|
||||
try {
|
||||
return await fn(client);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
import { query } from '../db';
|
||||
import { InjectionRule, AnthropicSystem, AnthropicSystemBlock } from '../types';
|
||||
|
||||
// ─── Cache ───
|
||||
|
||||
let cachedRules: InjectionRule[] = [];
|
||||
let cacheTimestamp = 0;
|
||||
let cacheTtlMs = 30_000;
|
||||
|
||||
export function setInjectionCacheTtl(ttlMs: number): void {
|
||||
cacheTtlMs = ttlMs;
|
||||
}
|
||||
|
||||
async function loadRules(): Promise<InjectionRule[]> {
|
||||
if (Date.now() - cacheTimestamp < cacheTtlMs && cachedRules.length > 0) {
|
||||
return cachedRules;
|
||||
}
|
||||
|
||||
const rows = await query<any>(
|
||||
`SELECT id, tenant_id, name, position, content, match_models, match_key_ids, priority, enabled
|
||||
FROM gateway_injection_rules
|
||||
WHERE enabled = true
|
||||
ORDER BY priority ASC`,
|
||||
);
|
||||
|
||||
cachedRules = rows.map((row) => ({
|
||||
id: row.id,
|
||||
tenantId: row.tenant_id,
|
||||
name: row.name,
|
||||
position: row.position,
|
||||
content: row.content,
|
||||
matchModels: row.match_models || ['*'],
|
||||
matchKeyIds: row.match_key_ids,
|
||||
priority: row.priority,
|
||||
enabled: row.enabled,
|
||||
}));
|
||||
|
||||
cacheTimestamp = Date.now();
|
||||
return cachedRules;
|
||||
}
|
||||
|
||||
// ─── Match logic ───
|
||||
|
||||
function matchesModel(ruleModels: string[], requestModel: string): boolean {
|
||||
if (ruleModels.includes('*')) return true;
|
||||
return ruleModels.some((pattern) => {
|
||||
if (pattern.endsWith('*')) {
|
||||
return requestModel.startsWith(pattern.slice(0, -1));
|
||||
}
|
||||
return requestModel === pattern;
|
||||
});
|
||||
}
|
||||
|
||||
function matchesKey(ruleKeyIds: string[] | null, apiKeyId: string): boolean {
|
||||
if (!ruleKeyIds) return true; // null = matches all keys
|
||||
return ruleKeyIds.includes(apiKeyId);
|
||||
}
|
||||
|
||||
// ─── Inject regulatory content into system prompt ───
|
||||
|
||||
export async function injectSystemPrompt(
|
||||
system: AnthropicSystem | undefined,
|
||||
model: string,
|
||||
apiKeyId: string,
|
||||
): Promise<{ system: AnthropicSystem | undefined; applied: boolean; ruleIds: string[] }> {
|
||||
const allRules = await loadRules();
|
||||
|
||||
const applicableRules = allRules.filter(
|
||||
(r) => matchesModel(r.matchModels, model) && matchesKey(r.matchKeyIds, apiKeyId),
|
||||
);
|
||||
|
||||
if (applicableRules.length === 0) {
|
||||
return { system, applied: false, ruleIds: [] };
|
||||
}
|
||||
|
||||
const prependRules = applicableRules.filter((r) => r.position === 'prepend');
|
||||
const appendRules = applicableRules.filter((r) => r.position === 'append');
|
||||
|
||||
const prependText = prependRules.map((r) => r.content).join('\n\n');
|
||||
const appendText = appendRules.map((r) => r.content).join('\n\n');
|
||||
const ruleIds = applicableRules.map((r) => r.id);
|
||||
|
||||
if (!prependText && !appendText) {
|
||||
return { system, applied: false, ruleIds: [] };
|
||||
}
|
||||
|
||||
// Handle the three possible system formats
|
||||
if (system === undefined || system === null) {
|
||||
// No existing system prompt — create one
|
||||
const combined = [prependText, appendText].filter(Boolean).join('\n\n');
|
||||
return { system: combined || undefined, applied: true, ruleIds };
|
||||
}
|
||||
|
||||
if (typeof system === 'string') {
|
||||
// String format
|
||||
const parts = [prependText, system, appendText].filter(Boolean);
|
||||
return { system: parts.join('\n\n'), applied: true, ruleIds };
|
||||
}
|
||||
|
||||
if (Array.isArray(system)) {
|
||||
// Array of content blocks format
|
||||
const blocks: AnthropicSystemBlock[] = [...system];
|
||||
if (prependText) {
|
||||
blocks.unshift({ type: 'text', text: prependText });
|
||||
}
|
||||
if (appendText) {
|
||||
blocks.push({ type: 'text', text: appendText });
|
||||
}
|
||||
return { system: blocks, applied: true, ruleIds };
|
||||
}
|
||||
|
||||
// Fallback — unknown format, don't modify
|
||||
return { system, applied: false, ruleIds: [] };
|
||||
}
|
||||
|
||||
export function clearInjectionRulesCache(): void {
|
||||
cachedRules = [];
|
||||
cacheTimestamp = 0;
|
||||
}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
import { query } from '../db';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { AuditLogEntry } from '../types';
|
||||
|
||||
// ─── Batch queue (async, non-blocking) ───
|
||||
|
||||
const pendingAudits: AuditLogEntry[] = [];
|
||||
let flushTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const FLUSH_INTERVAL_MS = 5_000;
|
||||
const BATCH_SIZE = 50;
|
||||
|
||||
async function flushAudits(): Promise<void> {
|
||||
if (pendingAudits.length === 0) return;
|
||||
|
||||
const batch = pendingAudits.splice(0, BATCH_SIZE);
|
||||
|
||||
try {
|
||||
const values: any[] = [];
|
||||
const placeholders: string[] = [];
|
||||
|
||||
for (let i = 0; i < batch.length; i++) {
|
||||
const entry = batch[i];
|
||||
const offset = i * 11;
|
||||
placeholders.push(
|
||||
`($${offset + 1}, $${offset + 2}, $${offset + 3}, $${offset + 4}, $${offset + 5}, $${offset + 6}, $${offset + 7}, $${offset + 8}, $${offset + 9}, $${offset + 10}, $${offset + 11})`,
|
||||
);
|
||||
values.push(
|
||||
uuidv4(),
|
||||
entry.apiKeyId,
|
||||
entry.requestMethod,
|
||||
entry.requestPath,
|
||||
entry.requestModel,
|
||||
entry.requestIp,
|
||||
entry.contentFiltered,
|
||||
entry.filterRuleId,
|
||||
entry.injectionApplied,
|
||||
entry.responseStatus,
|
||||
entry.durationMs,
|
||||
);
|
||||
}
|
||||
|
||||
await query(
|
||||
`INSERT INTO gateway_audit_logs (id, api_key_id, request_method, request_path, request_model, request_ip, content_filtered, filter_rule_id, injection_applied, response_status, duration_ms)
|
||||
VALUES ${placeholders.join(', ')}`,
|
||||
values,
|
||||
);
|
||||
} catch (err: any) {
|
||||
console.error('[AuditLogger] Failed to flush audits:', err.message);
|
||||
if (pendingAudits.length < 500) {
|
||||
pendingAudits.unshift(...batch);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleFlush(): void {
|
||||
if (flushTimer) return;
|
||||
flushTimer = setTimeout(async () => {
|
||||
flushTimer = null;
|
||||
await flushAudits();
|
||||
if (pendingAudits.length > 0) scheduleFlush();
|
||||
}, FLUSH_INTERVAL_MS);
|
||||
}
|
||||
|
||||
// ─── Public API ───
|
||||
|
||||
export function recordAudit(entry: AuditLogEntry): void {
|
||||
pendingAudits.push(entry);
|
||||
|
||||
if (pendingAudits.length >= BATCH_SIZE) {
|
||||
flushAudits();
|
||||
} else {
|
||||
scheduleFlush();
|
||||
}
|
||||
}
|
||||
|
||||
export async function shutdown(): Promise<void> {
|
||||
if (flushTimer) clearTimeout(flushTimer);
|
||||
await flushAudits();
|
||||
}
|
||||
|
|
@ -0,0 +1,142 @@
|
|||
import { query } from '../db';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { UsageLogEntry } from '../types';
|
||||
|
||||
// ─── Cost estimation (USD per million tokens, approximate) ───
|
||||
|
||||
const COST_TABLE: Record<string, { input: number; output: number }> = {
|
||||
'claude-sonnet-4-5': { input: 3.0, output: 15.0 },
|
||||
'claude-haiku-4-5': { input: 0.8, output: 4.0 },
|
||||
'claude-opus-4': { input: 15.0, output: 75.0 },
|
||||
'text-embedding-3-small': { input: 0.02, output: 0 },
|
||||
'text-embedding-3-large': { input: 0.13, output: 0 },
|
||||
};
|
||||
|
||||
function estimateCost(model: string, inputTokens: number, outputTokens: number): number | null {
|
||||
// Find matching cost entry by prefix
|
||||
for (const [prefix, costs] of Object.entries(COST_TABLE)) {
|
||||
if (model.startsWith(prefix)) {
|
||||
return (inputTokens * costs.input + outputTokens * costs.output) / 1_000_000;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── Batch insert queue (async, non-blocking) ───
|
||||
|
||||
const pendingLogs: UsageLogEntry[] = [];
|
||||
let flushTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const FLUSH_INTERVAL_MS = 5_000;
|
||||
const BATCH_SIZE = 50;
|
||||
|
||||
async function flushLogs(): Promise<void> {
|
||||
if (pendingLogs.length === 0) return;
|
||||
|
||||
const batch = pendingLogs.splice(0, BATCH_SIZE);
|
||||
|
||||
try {
|
||||
const values: any[] = [];
|
||||
const placeholders: string[] = [];
|
||||
|
||||
for (let i = 0; i < batch.length; i++) {
|
||||
const entry = batch[i];
|
||||
const offset = i * 10;
|
||||
placeholders.push(
|
||||
`($${offset + 1}, $${offset + 2}, $${offset + 3}, $${offset + 4}, $${offset + 5}, $${offset + 6}, $${offset + 7}, $${offset + 8}, $${offset + 9}, $${offset + 10})`,
|
||||
);
|
||||
values.push(
|
||||
uuidv4(),
|
||||
entry.apiKeyId,
|
||||
entry.model,
|
||||
entry.provider,
|
||||
entry.inputTokens,
|
||||
entry.outputTokens,
|
||||
entry.totalTokens,
|
||||
entry.costUsd,
|
||||
entry.durationMs,
|
||||
entry.statusCode,
|
||||
);
|
||||
}
|
||||
|
||||
await query(
|
||||
`INSERT INTO gateway_usage_logs (id, api_key_id, model, provider, input_tokens, output_tokens, total_tokens, cost_usd, duration_ms, status_code)
|
||||
VALUES ${placeholders.join(', ')}`,
|
||||
values,
|
||||
);
|
||||
} catch (err: any) {
|
||||
console.error('[UsageTracker] Failed to flush logs:', err.message);
|
||||
// Re-queue failed entries (at the front, max 500 pending)
|
||||
if (pendingLogs.length < 500) {
|
||||
pendingLogs.unshift(...batch);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleFlush(): void {
|
||||
if (flushTimer) return;
|
||||
flushTimer = setTimeout(async () => {
|
||||
flushTimer = null;
|
||||
await flushLogs();
|
||||
if (pendingLogs.length > 0) scheduleFlush();
|
||||
}, FLUSH_INTERVAL_MS);
|
||||
}
|
||||
|
||||
// ─── Public API ───
|
||||
|
||||
export function recordUsage(entry: UsageLogEntry): void {
|
||||
if (!entry.costUsd && entry.costUsd !== 0) {
|
||||
entry.costUsd = estimateCost(entry.model, entry.inputTokens, entry.outputTokens);
|
||||
}
|
||||
pendingLogs.push(entry);
|
||||
|
||||
if (pendingLogs.length >= BATCH_SIZE) {
|
||||
flushLogs();
|
||||
} else {
|
||||
scheduleFlush();
|
||||
}
|
||||
}
|
||||
|
||||
export function recordFromAnthropicResponse(
|
||||
apiKeyId: string,
|
||||
model: string,
|
||||
usage: { input_tokens?: number; output_tokens?: number } | undefined,
|
||||
statusCode: number,
|
||||
durationMs: number,
|
||||
): void {
|
||||
recordUsage({
|
||||
apiKeyId,
|
||||
model,
|
||||
provider: 'anthropic',
|
||||
inputTokens: usage?.input_tokens || 0,
|
||||
outputTokens: usage?.output_tokens || 0,
|
||||
totalTokens: (usage?.input_tokens || 0) + (usage?.output_tokens || 0),
|
||||
costUsd: null,
|
||||
durationMs,
|
||||
statusCode,
|
||||
});
|
||||
}
|
||||
|
||||
export function recordFromOpenAIResponse(
|
||||
apiKeyId: string,
|
||||
model: string,
|
||||
usage: { prompt_tokens?: number; total_tokens?: number } | undefined,
|
||||
statusCode: number,
|
||||
durationMs: number,
|
||||
): void {
|
||||
recordUsage({
|
||||
apiKeyId,
|
||||
model,
|
||||
provider: 'openai',
|
||||
inputTokens: usage?.prompt_tokens || 0,
|
||||
outputTokens: 0,
|
||||
totalTokens: usage?.total_tokens || 0,
|
||||
costUsd: null,
|
||||
durationMs,
|
||||
statusCode,
|
||||
});
|
||||
}
|
||||
|
||||
export async function shutdown(): Promise<void> {
|
||||
if (flushTimer) clearTimeout(flushTimer);
|
||||
await flushLogs();
|
||||
}
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
import Fastify from 'fastify';
|
||||
import { loadConfig } from './config';
|
||||
import { initDb, getPool } from './db';
|
||||
import { registerHealthRoutes } from './routes/health';
|
||||
import { registerAnthropicRoutes } from './routes/anthropic';
|
||||
import { registerOpenAIRoutes } from './routes/openai';
|
||||
import { setContentFilterCacheTtl } from './middleware/content-filter';
|
||||
import { setInjectionCacheTtl } from './injection/system-prompt-injector';
|
||||
import { shutdown as shutdownUsageTracker } from './logging/usage-tracker';
|
||||
import { shutdown as shutdownAuditLogger } from './logging/audit-logger';
|
||||
|
||||
async function main() {
|
||||
// Load configuration
|
||||
const config = loadConfig();
|
||||
|
||||
// Initialize database
|
||||
initDb(config);
|
||||
|
||||
// Set cache TTLs
|
||||
setContentFilterCacheTtl(config.rulesCacheTtlMs);
|
||||
setInjectionCacheTtl(config.rulesCacheTtlMs);
|
||||
|
||||
// Create Fastify instance
|
||||
const app = Fastify({
|
||||
logger: {
|
||||
level: config.logLevel,
|
||||
},
|
||||
bodyLimit: 50 * 1024 * 1024, // 50MB for base64 PDFs
|
||||
trustProxy: true,
|
||||
});
|
||||
|
||||
// Disable Fastify's default JSON body parsing — we parse manually
|
||||
// to avoid double-parse overhead and to handle raw body forwarding
|
||||
app.addContentTypeParser(
|
||||
'application/json',
|
||||
{ parseAs: 'string' },
|
||||
(_req, body, done) => {
|
||||
done(null, body);
|
||||
},
|
||||
);
|
||||
|
||||
// Register routes
|
||||
registerHealthRoutes(app);
|
||||
registerAnthropicRoutes(app, config);
|
||||
registerOpenAIRoutes(app, config);
|
||||
|
||||
// Graceful shutdown
|
||||
const shutdown = async (signal: string) => {
|
||||
console.log(`[LLM Gateway] Received ${signal}, shutting down...`);
|
||||
await shutdownUsageTracker();
|
||||
await shutdownAuditLogger();
|
||||
await app.close();
|
||||
const pool = getPool();
|
||||
await pool.end();
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
|
||||
// Start server
|
||||
try {
|
||||
await app.listen({ port: config.port, host: '0.0.0.0' });
|
||||
console.log(`[LLM Gateway] Running on port ${config.port}`);
|
||||
console.log(`[LLM Gateway] Anthropic upstream: ${config.anthropicUpstreamUrl}`);
|
||||
console.log(`[LLM Gateway] OpenAI upstream: ${config.openaiUpstreamUrl}`);
|
||||
} catch (err) {
|
||||
console.error('[LLM Gateway] Failed to start:', err);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
|
|
@ -0,0 +1,213 @@
|
|||
import { createHash } from 'crypto';
|
||||
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { query, queryOne } from '../db';
|
||||
import { ApiKeyRecord, ApiKeyPermissions } from '../types';
|
||||
|
||||
// ─── Cache ───
|
||||
|
||||
interface CachedKey {
|
||||
record: ApiKeyRecord;
|
||||
cachedAt: number;
|
||||
}
|
||||
|
||||
const keyCache = new Map<string, CachedKey>();
|
||||
const KEY_CACHE_TTL_MS = 60_000; // 1 minute
|
||||
|
||||
// ─── Hash ───
|
||||
|
||||
function hashApiKey(key: string): string {
|
||||
return createHash('sha256').update(key).digest('hex');
|
||||
}
|
||||
|
||||
// ─── Extract API Key from request ───
|
||||
// Supports both Anthropic (X-Api-Key) and OpenAI (Authorization: Bearer) formats
|
||||
|
||||
function extractApiKey(request: FastifyRequest): string | null {
|
||||
// Anthropic style
|
||||
const xApiKey = request.headers['x-api-key'];
|
||||
if (typeof xApiKey === 'string' && xApiKey) return xApiKey;
|
||||
|
||||
// OpenAI style
|
||||
const auth = request.headers['authorization'];
|
||||
if (typeof auth === 'string' && auth.startsWith('Bearer ')) {
|
||||
return auth.slice(7);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── Lookup API Key record ───
|
||||
|
||||
async function lookupApiKey(apiKey: string): Promise<ApiKeyRecord | null> {
|
||||
const hash = hashApiKey(apiKey);
|
||||
|
||||
// Check cache
|
||||
const cached = keyCache.get(hash);
|
||||
if (cached && Date.now() - cached.cachedAt < KEY_CACHE_TTL_MS) {
|
||||
return cached.record;
|
||||
}
|
||||
|
||||
const row = await queryOne<any>(
|
||||
`SELECT id, tenant_id, key_hash, key_prefix, name, owner,
|
||||
permissions, rate_limit_rpm, rate_limit_tpd, monthly_budget,
|
||||
enabled, expires_at, last_used_at
|
||||
FROM gateway_api_keys
|
||||
WHERE key_hash = $1`,
|
||||
[hash],
|
||||
);
|
||||
|
||||
if (!row) return null;
|
||||
|
||||
const record: ApiKeyRecord = {
|
||||
id: row.id,
|
||||
tenantId: row.tenant_id,
|
||||
keyHash: row.key_hash,
|
||||
keyPrefix: row.key_prefix,
|
||||
name: row.name,
|
||||
owner: row.owner,
|
||||
permissions: row.permissions as ApiKeyPermissions,
|
||||
rateLimitRpm: row.rate_limit_rpm,
|
||||
rateLimitTpd: row.rate_limit_tpd,
|
||||
monthlyBudget: row.monthly_budget ? parseFloat(row.monthly_budget) : null,
|
||||
enabled: row.enabled,
|
||||
expiresAt: row.expires_at ? new Date(row.expires_at) : null,
|
||||
lastUsedAt: row.last_used_at ? new Date(row.last_used_at) : null,
|
||||
};
|
||||
|
||||
keyCache.set(hash, { record, cachedAt: Date.now() });
|
||||
return record;
|
||||
}
|
||||
|
||||
// ─── Rate limit check (simple in-memory sliding window) ───
|
||||
|
||||
interface RateWindow {
|
||||
timestamps: number[];
|
||||
}
|
||||
|
||||
const rateWindows = new Map<string, RateWindow>();
|
||||
|
||||
function checkRateLimit(keyId: string, limitRpm: number): boolean {
|
||||
if (limitRpm <= 0) return true; // no limit
|
||||
|
||||
const now = Date.now();
|
||||
const windowStart = now - 60_000;
|
||||
|
||||
let window = rateWindows.get(keyId);
|
||||
if (!window) {
|
||||
window = { timestamps: [] };
|
||||
rateWindows.set(keyId, window);
|
||||
}
|
||||
|
||||
// Remove expired entries
|
||||
window.timestamps = window.timestamps.filter((t) => t > windowStart);
|
||||
|
||||
if (window.timestamps.length >= limitRpm) {
|
||||
return false; // rate limited
|
||||
}
|
||||
|
||||
window.timestamps.push(now);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Periodic cleanup of stale rate windows
|
||||
setInterval(() => {
|
||||
const cutoff = Date.now() - 120_000;
|
||||
for (const [key, window] of rateWindows.entries()) {
|
||||
if (window.timestamps.length === 0 || window.timestamps[window.timestamps.length - 1] < cutoff) {
|
||||
rateWindows.delete(key);
|
||||
}
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
// ─── Update last_used_at (async, fire-and-forget) ───
|
||||
|
||||
function touchLastUsed(keyId: string): void {
|
||||
query('UPDATE gateway_api_keys SET last_used_at = NOW() WHERE id = $1', [keyId]).catch((err) => {
|
||||
console.error('[Auth] Failed to update last_used_at:', err.message);
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Auth middleware ───
|
||||
|
||||
export async function authMiddleware(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
): Promise<void> {
|
||||
const apiKey = extractApiKey(request);
|
||||
|
||||
if (!apiKey) {
|
||||
reply.status(401).send({
|
||||
error: {
|
||||
type: 'authentication_error',
|
||||
message: 'Missing API key. Provide via X-Api-Key header or Authorization: Bearer header.',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const record = await lookupApiKey(apiKey);
|
||||
|
||||
if (!record) {
|
||||
reply.status(401).send({
|
||||
error: {
|
||||
type: 'authentication_error',
|
||||
message: 'Invalid API key.',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!record.enabled) {
|
||||
reply.status(403).send({
|
||||
error: {
|
||||
type: 'permission_error',
|
||||
message: 'API key is disabled.',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (record.expiresAt && record.expiresAt < new Date()) {
|
||||
reply.status(403).send({
|
||||
error: {
|
||||
type: 'permission_error',
|
||||
message: 'API key has expired.',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!checkRateLimit(record.id, record.rateLimitRpm)) {
|
||||
reply.status(429).send({
|
||||
error: {
|
||||
type: 'rate_limit_error',
|
||||
message: `Rate limit exceeded. Maximum ${record.rateLimitRpm} requests per minute.`,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Attach to request for downstream use
|
||||
(request as any).apiKeyRecord = record;
|
||||
|
||||
// Fire-and-forget update
|
||||
touchLastUsed(record.id);
|
||||
}
|
||||
|
||||
// ─── Model permission check ───
|
||||
|
||||
export function isModelAllowed(record: ApiKeyRecord, model: string): boolean {
|
||||
const allowed = record.permissions?.allowedModels;
|
||||
if (!allowed || allowed.length === 0 || allowed.includes('*')) return true;
|
||||
|
||||
return allowed.some((pattern) => {
|
||||
if (pattern.endsWith('*')) {
|
||||
return model.startsWith(pattern.slice(0, -1));
|
||||
}
|
||||
return model === pattern;
|
||||
});
|
||||
}
|
||||
|
||||
export function clearKeyCache(): void {
|
||||
keyCache.clear();
|
||||
}
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
import { query } from '../db';
|
||||
import { ContentRule, FilterResult, AnthropicMessage, AnthropicContentBlock } from '../types';
|
||||
|
||||
// ─── Cache ───
|
||||
|
||||
let cachedRules: ContentRule[] = [];
|
||||
let cacheTimestamp = 0;
|
||||
let cacheTtlMs = 30_000;
|
||||
|
||||
export function setContentFilterCacheTtl(ttlMs: number): void {
|
||||
cacheTtlMs = ttlMs;
|
||||
}
|
||||
|
||||
async function loadRules(): Promise<ContentRule[]> {
|
||||
if (Date.now() - cacheTimestamp < cacheTtlMs && cachedRules.length > 0) {
|
||||
return cachedRules;
|
||||
}
|
||||
|
||||
const rows = await query<any>(
|
||||
`SELECT id, tenant_id, name, type, pattern, action, reject_message, priority, enabled
|
||||
FROM gateway_content_rules
|
||||
WHERE enabled = true
|
||||
ORDER BY priority ASC`,
|
||||
);
|
||||
|
||||
cachedRules = rows.map((row) => ({
|
||||
id: row.id,
|
||||
tenantId: row.tenant_id,
|
||||
name: row.name,
|
||||
type: row.type,
|
||||
pattern: row.pattern,
|
||||
action: row.action,
|
||||
rejectMessage: row.reject_message,
|
||||
priority: row.priority,
|
||||
enabled: row.enabled,
|
||||
}));
|
||||
|
||||
cacheTimestamp = Date.now();
|
||||
return cachedRules;
|
||||
}
|
||||
|
||||
// ─── Extract text from messages ───
|
||||
|
||||
function extractTextFromContent(content: string | AnthropicContentBlock[]): string {
|
||||
if (typeof content === 'string') return content;
|
||||
if (!Array.isArray(content)) return '';
|
||||
|
||||
return content
|
||||
.filter((block) => block.type === 'text' && block.text)
|
||||
.map((block) => block.text!)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
// ─── Check messages against rules ───
|
||||
|
||||
export async function checkContent(messages: AnthropicMessage[]): Promise<FilterResult> {
|
||||
const rules = await loadRules();
|
||||
if (rules.length === 0) return { blocked: false };
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.role !== 'user') continue;
|
||||
|
||||
const text = extractTextFromContent(msg.content);
|
||||
if (!text) continue;
|
||||
|
||||
for (const rule of rules) {
|
||||
let matched = false;
|
||||
|
||||
if (rule.type === 'keyword') {
|
||||
matched = text.toLowerCase().includes(rule.pattern.toLowerCase());
|
||||
} else if (rule.type === 'regex') {
|
||||
try {
|
||||
matched = new RegExp(rule.pattern, 'i').test(text);
|
||||
} catch {
|
||||
console.warn(`[ContentFilter] Invalid regex pattern in rule ${rule.id}: ${rule.pattern}`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (matched) {
|
||||
if (rule.action === 'block') {
|
||||
return {
|
||||
blocked: true,
|
||||
reason: rule.rejectMessage || 'Content blocked by policy.',
|
||||
ruleId: rule.id,
|
||||
action: 'block',
|
||||
};
|
||||
}
|
||||
if (rule.action === 'warn') {
|
||||
// Log but allow through
|
||||
console.warn(`[ContentFilter] Warning triggered by rule "${rule.name}" (${rule.id})`);
|
||||
return {
|
||||
blocked: false,
|
||||
ruleId: rule.id,
|
||||
action: 'warn',
|
||||
};
|
||||
}
|
||||
if (rule.action === 'log') {
|
||||
console.info(`[ContentFilter] Log match by rule "${rule.name}" (${rule.id})`);
|
||||
return {
|
||||
blocked: false,
|
||||
ruleId: rule.id,
|
||||
action: 'log',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { blocked: false };
|
||||
}
|
||||
|
||||
export function clearContentRulesCache(): void {
|
||||
cachedRules = [];
|
||||
cacheTimestamp = 0;
|
||||
}
|
||||
|
|
@ -0,0 +1,205 @@
|
|||
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { GatewayConfig } from '../config';
|
||||
import { ApiKeyRecord, AnthropicRequestBody, FilterResult } from '../types';
|
||||
import { isModelAllowed } from '../middleware/auth';
|
||||
import { checkContent } from '../middleware/content-filter';
|
||||
import { injectSystemPrompt } from '../injection/system-prompt-injector';
|
||||
import { recordFromAnthropicResponse } from '../logging/usage-tracker';
|
||||
import { recordAudit } from '../logging/audit-logger';
|
||||
import { pipeSSEStream, createStreamUsageTracker } from './stream-pipe';
|
||||
|
||||
export function createAnthropicProxy(config: GatewayConfig) {
|
||||
return async function handleMessages(request: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
const apiKeyRecord: ApiKeyRecord = (request as any).apiKeyRecord;
|
||||
const clientIp = request.ip || request.headers['x-forwarded-for'] as string || 'unknown';
|
||||
|
||||
// 1. Parse request body
|
||||
let body: AnthropicRequestBody;
|
||||
try {
|
||||
body = JSON.parse(request.body as string);
|
||||
} catch {
|
||||
reply.status(400).send({
|
||||
error: { type: 'invalid_request_error', message: 'Invalid JSON body.' },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const model = body.model || 'unknown';
|
||||
|
||||
// 2. Check model permission
|
||||
if (!isModelAllowed(apiKeyRecord, model)) {
|
||||
reply.status(403).send({
|
||||
error: {
|
||||
type: 'permission_error',
|
||||
message: `Model "${model}" is not allowed for this API key.`,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Check streaming permission
|
||||
if (body.stream && apiKeyRecord.permissions?.allowStreaming === false) {
|
||||
reply.status(403).send({
|
||||
error: {
|
||||
type: 'permission_error',
|
||||
message: 'Streaming is not allowed for this API key.',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. Content filtering
|
||||
let filterResult: FilterResult = { blocked: false };
|
||||
if (body.messages) {
|
||||
filterResult = await checkContent(body.messages);
|
||||
if (filterResult.blocked) {
|
||||
recordAudit({
|
||||
apiKeyId: apiKeyRecord.id,
|
||||
requestMethod: 'POST',
|
||||
requestPath: '/v1/messages',
|
||||
requestModel: model,
|
||||
requestIp: clientIp,
|
||||
contentFiltered: true,
|
||||
filterRuleId: filterResult.ruleId || null,
|
||||
injectionApplied: false,
|
||||
responseStatus: 403,
|
||||
durationMs: Date.now() - startTime,
|
||||
});
|
||||
|
||||
reply.status(403).send({
|
||||
error: {
|
||||
type: 'content_policy_violation',
|
||||
message: filterResult.reason || 'Content blocked by policy.',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Inject regulatory content into system prompt
|
||||
const injection = await injectSystemPrompt(body.system, model, apiKeyRecord.id);
|
||||
body.system = injection.system;
|
||||
|
||||
// 6. Build upstream request headers
|
||||
const upstreamHeaders: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'anthropic-version': (request.headers['anthropic-version'] as string) || '2023-06-01',
|
||||
'X-Api-Key': config.anthropicApiKey,
|
||||
};
|
||||
|
||||
// Forward optional headers
|
||||
const beta = request.headers['anthropic-beta'];
|
||||
if (beta) upstreamHeaders['anthropic-beta'] = beta as string;
|
||||
|
||||
// 7. Forward to upstream
|
||||
let upstreamResponse: Response;
|
||||
try {
|
||||
upstreamResponse = await fetch(`${config.anthropicUpstreamUrl}/v1/messages`, {
|
||||
method: 'POST',
|
||||
headers: upstreamHeaders,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
} catch (err: any) {
|
||||
const durationMs = Date.now() - startTime;
|
||||
recordAudit({
|
||||
apiKeyId: apiKeyRecord.id,
|
||||
requestMethod: 'POST',
|
||||
requestPath: '/v1/messages',
|
||||
requestModel: model,
|
||||
requestIp: clientIp,
|
||||
contentFiltered: filterResult.action === 'warn' || filterResult.action === 'log',
|
||||
filterRuleId: filterResult.ruleId || null,
|
||||
injectionApplied: injection.applied,
|
||||
responseStatus: 502,
|
||||
durationMs,
|
||||
});
|
||||
|
||||
reply.status(502).send({
|
||||
error: {
|
||||
type: 'upstream_error',
|
||||
message: `Failed to connect to upstream API: ${err.message}`,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const durationMs = Date.now() - startTime;
|
||||
|
||||
// 8. Handle response
|
||||
if (body.stream && upstreamResponse.ok && upstreamResponse.body) {
|
||||
// Streaming response — pipe SSE directly
|
||||
reply.raw.writeHead(upstreamResponse.status, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
'X-Accel-Buffering': 'no',
|
||||
});
|
||||
|
||||
const usageTracker = createStreamUsageTracker();
|
||||
|
||||
await pipeSSEStream(upstreamResponse.body, reply.raw, usageTracker.onDataLine);
|
||||
|
||||
// Record usage from stream (async)
|
||||
const streamUsage = usageTracker.getUsage();
|
||||
recordFromAnthropicResponse(
|
||||
apiKeyRecord.id,
|
||||
model,
|
||||
{ input_tokens: streamUsage.inputTokens, output_tokens: streamUsage.outputTokens },
|
||||
upstreamResponse.status,
|
||||
Date.now() - startTime,
|
||||
);
|
||||
|
||||
recordAudit({
|
||||
apiKeyId: apiKeyRecord.id,
|
||||
requestMethod: 'POST',
|
||||
requestPath: '/v1/messages',
|
||||
requestModel: model,
|
||||
requestIp: clientIp,
|
||||
contentFiltered: filterResult.action === 'warn' || filterResult.action === 'log',
|
||||
filterRuleId: filterResult.ruleId || null,
|
||||
injectionApplied: injection.applied,
|
||||
responseStatus: upstreamResponse.status,
|
||||
durationMs: Date.now() - startTime,
|
||||
});
|
||||
} else {
|
||||
// Non-streaming response — buffer and forward
|
||||
const responseText = await upstreamResponse.text();
|
||||
|
||||
// Try to extract usage for logging
|
||||
try {
|
||||
const responseJson = JSON.parse(responseText);
|
||||
if (responseJson.usage) {
|
||||
recordFromAnthropicResponse(
|
||||
apiKeyRecord.id,
|
||||
model,
|
||||
responseJson.usage,
|
||||
upstreamResponse.status,
|
||||
durationMs,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Not JSON — still forward
|
||||
}
|
||||
|
||||
recordAudit({
|
||||
apiKeyId: apiKeyRecord.id,
|
||||
requestMethod: 'POST',
|
||||
requestPath: '/v1/messages',
|
||||
requestModel: model,
|
||||
requestIp: clientIp,
|
||||
contentFiltered: filterResult.action === 'warn' || filterResult.action === 'log',
|
||||
filterRuleId: filterResult.ruleId || null,
|
||||
injectionApplied: injection.applied,
|
||||
responseStatus: upstreamResponse.status,
|
||||
durationMs,
|
||||
});
|
||||
|
||||
// Forward all response headers from upstream
|
||||
reply.raw.writeHead(upstreamResponse.status, {
|
||||
'Content-Type': upstreamResponse.headers.get('content-type') || 'application/json',
|
||||
});
|
||||
reply.raw.end(responseText);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,216 @@
|
|||
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { GatewayConfig } from '../config';
|
||||
import { ApiKeyRecord } from '../types';
|
||||
import { isModelAllowed } from '../middleware/auth';
|
||||
import { recordFromOpenAIResponse } from '../logging/usage-tracker';
|
||||
import { recordAudit } from '../logging/audit-logger';
|
||||
import { pipeSSEStream, createStreamUsageTracker } from './stream-pipe';
|
||||
|
||||
export function createOpenAIEmbeddingsProxy(config: GatewayConfig) {
|
||||
return async function handleEmbeddings(request: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
const apiKeyRecord: ApiKeyRecord = (request as any).apiKeyRecord;
|
||||
const clientIp = request.ip || request.headers['x-forwarded-for'] as string || 'unknown';
|
||||
|
||||
let body: any;
|
||||
try {
|
||||
body = JSON.parse(request.body as string);
|
||||
} catch {
|
||||
reply.status(400).send({
|
||||
error: { type: 'invalid_request_error', message: 'Invalid JSON body.' },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const model = body.model || 'unknown';
|
||||
|
||||
if (!isModelAllowed(apiKeyRecord, model)) {
|
||||
reply.status(403).send({
|
||||
error: {
|
||||
type: 'permission_error',
|
||||
message: `Model "${model}" is not allowed for this API key.`,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let upstreamResponse: Response;
|
||||
try {
|
||||
upstreamResponse = await fetch(`${config.openaiUpstreamUrl}/v1/embeddings`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${config.openaiApiKey}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
} catch (err: any) {
|
||||
recordAudit({
|
||||
apiKeyId: apiKeyRecord.id,
|
||||
requestMethod: 'POST',
|
||||
requestPath: '/v1/embeddings',
|
||||
requestModel: model,
|
||||
requestIp: clientIp,
|
||||
contentFiltered: false,
|
||||
filterRuleId: null,
|
||||
injectionApplied: false,
|
||||
responseStatus: 502,
|
||||
durationMs: Date.now() - startTime,
|
||||
});
|
||||
|
||||
reply.status(502).send({
|
||||
error: {
|
||||
type: 'upstream_error',
|
||||
message: `Failed to connect to upstream API: ${err.message}`,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const durationMs = Date.now() - startTime;
|
||||
const responseText = await upstreamResponse.text();
|
||||
|
||||
try {
|
||||
const responseJson = JSON.parse(responseText);
|
||||
if (responseJson.usage) {
|
||||
recordFromOpenAIResponse(
|
||||
apiKeyRecord.id,
|
||||
model,
|
||||
responseJson.usage,
|
||||
upstreamResponse.status,
|
||||
durationMs,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Not JSON
|
||||
}
|
||||
|
||||
recordAudit({
|
||||
apiKeyId: apiKeyRecord.id,
|
||||
requestMethod: 'POST',
|
||||
requestPath: '/v1/embeddings',
|
||||
requestModel: model,
|
||||
requestIp: clientIp,
|
||||
contentFiltered: false,
|
||||
filterRuleId: null,
|
||||
injectionApplied: false,
|
||||
responseStatus: upstreamResponse.status,
|
||||
durationMs,
|
||||
});
|
||||
|
||||
reply.raw.writeHead(upstreamResponse.status, {
|
||||
'Content-Type': upstreamResponse.headers.get('content-type') || 'application/json',
|
||||
});
|
||||
reply.raw.end(responseText);
|
||||
};
|
||||
}
|
||||
|
||||
export function createOpenAIChatProxy(config: GatewayConfig) {
|
||||
return async function handleChatCompletions(request: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
const apiKeyRecord: ApiKeyRecord = (request as any).apiKeyRecord;
|
||||
const clientIp = request.ip || request.headers['x-forwarded-for'] as string || 'unknown';
|
||||
|
||||
let body: any;
|
||||
try {
|
||||
body = JSON.parse(request.body as string);
|
||||
} catch {
|
||||
reply.status(400).send({
|
||||
error: { type: 'invalid_request_error', message: 'Invalid JSON body.' },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const model = body.model || 'unknown';
|
||||
|
||||
if (!isModelAllowed(apiKeyRecord, model)) {
|
||||
reply.status(403).send({
|
||||
error: {
|
||||
type: 'permission_error',
|
||||
message: `Model "${model}" is not allowed for this API key.`,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let upstreamResponse: Response;
|
||||
try {
|
||||
upstreamResponse = await fetch(`${config.openaiUpstreamUrl}/v1/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${config.openaiApiKey}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
} catch (err: any) {
|
||||
recordAudit({
|
||||
apiKeyId: apiKeyRecord.id,
|
||||
requestMethod: 'POST',
|
||||
requestPath: '/v1/chat/completions',
|
||||
requestModel: model,
|
||||
requestIp: clientIp,
|
||||
contentFiltered: false,
|
||||
filterRuleId: null,
|
||||
injectionApplied: false,
|
||||
responseStatus: 502,
|
||||
durationMs: Date.now() - startTime,
|
||||
});
|
||||
|
||||
reply.status(502).send({
|
||||
error: {
|
||||
type: 'upstream_error',
|
||||
message: `Failed to connect to upstream API: ${err.message}`,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const durationMs = Date.now() - startTime;
|
||||
|
||||
// Handle streaming chat completions
|
||||
if (body.stream && upstreamResponse.ok && upstreamResponse.body) {
|
||||
reply.raw.writeHead(upstreamResponse.status, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
'X-Accel-Buffering': 'no',
|
||||
});
|
||||
|
||||
await pipeSSEStream(upstreamResponse.body, reply.raw);
|
||||
|
||||
recordAudit({
|
||||
apiKeyId: apiKeyRecord.id,
|
||||
requestMethod: 'POST',
|
||||
requestPath: '/v1/chat/completions',
|
||||
requestModel: model,
|
||||
requestIp: clientIp,
|
||||
contentFiltered: false,
|
||||
filterRuleId: null,
|
||||
injectionApplied: false,
|
||||
responseStatus: upstreamResponse.status,
|
||||
durationMs: Date.now() - startTime,
|
||||
});
|
||||
} else {
|
||||
const responseText = await upstreamResponse.text();
|
||||
|
||||
recordAudit({
|
||||
apiKeyId: apiKeyRecord.id,
|
||||
requestMethod: 'POST',
|
||||
requestPath: '/v1/chat/completions',
|
||||
requestModel: model,
|
||||
requestIp: clientIp,
|
||||
contentFiltered: false,
|
||||
filterRuleId: null,
|
||||
injectionApplied: false,
|
||||
responseStatus: upstreamResponse.status,
|
||||
durationMs,
|
||||
});
|
||||
|
||||
reply.raw.writeHead(upstreamResponse.status, {
|
||||
'Content-Type': upstreamResponse.headers.get('content-type') || 'application/json',
|
||||
});
|
||||
reply.raw.end(responseText);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
import { Readable } from 'stream';
|
||||
import { ServerResponse } from 'http';
|
||||
|
||||
/**
|
||||
* Pipe an upstream SSE response body to the client response.
|
||||
* Uses zero-buffering: chunks are forwarded as they arrive.
|
||||
*
|
||||
* Also provides a callback for each SSE data line, allowing
|
||||
* the caller to extract usage info from the stream without buffering.
|
||||
*/
|
||||
export async function pipeSSEStream(
|
||||
upstreamBody: ReadableStream<Uint8Array>,
|
||||
clientResponse: ServerResponse,
|
||||
onDataLine?: (line: string) => void,
|
||||
): Promise<void> {
|
||||
const reader = upstreamBody.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
const chunk = decoder.decode(value, { stream: true });
|
||||
|
||||
// Forward chunk immediately
|
||||
const canContinue = clientResponse.write(chunk);
|
||||
|
||||
// Parse SSE data lines for usage extraction
|
||||
if (onDataLine) {
|
||||
const lines = chunk.split('\n');
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
onDataLine(line.slice(6).trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle backpressure
|
||||
if (!canContinue) {
|
||||
await new Promise<void>((resolve) => clientResponse.once('drain', resolve));
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
// Connection closed by client — expected during cancellation
|
||||
if (err.code === 'ERR_STREAM_PREMATURE_CLOSE' || clientResponse.destroyed) {
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
clientResponse.end();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract usage from Anthropic's message_delta SSE event.
|
||||
* The event format: {"type":"message_delta","delta":{...},"usage":{"output_tokens":N}}
|
||||
* Final usage is in the message_start event's message.usage for input_tokens.
|
||||
*/
|
||||
export interface StreamUsage {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
}
|
||||
|
||||
export function createStreamUsageTracker(): {
|
||||
onDataLine: (line: string) => void;
|
||||
getUsage: () => StreamUsage;
|
||||
} {
|
||||
const usage: StreamUsage = { inputTokens: 0, outputTokens: 0 };
|
||||
|
||||
return {
|
||||
onDataLine: (line: string) => {
|
||||
if (line === '[DONE]') return;
|
||||
|
||||
try {
|
||||
const event = JSON.parse(line);
|
||||
|
||||
// message_start contains input_tokens
|
||||
if (event.type === 'message_start' && event.message?.usage) {
|
||||
usage.inputTokens = event.message.usage.input_tokens || 0;
|
||||
}
|
||||
|
||||
// message_delta contains output_tokens
|
||||
if (event.type === 'message_delta' && event.usage) {
|
||||
usage.outputTokens = event.usage.output_tokens || 0;
|
||||
}
|
||||
} catch {
|
||||
// Not valid JSON — ignore
|
||||
}
|
||||
},
|
||||
getUsage: () => usage,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
import { FastifyInstance } from 'fastify';
|
||||
import { GatewayConfig } from '../config';
|
||||
import { authMiddleware } from '../middleware/auth';
|
||||
import { createAnthropicProxy } from '../proxy/anthropic-proxy';
|
||||
|
||||
export function registerAnthropicRoutes(app: FastifyInstance, config: GatewayConfig): void {
|
||||
const handleMessages = createAnthropicProxy(config);
|
||||
|
||||
app.post('/v1/messages', {
|
||||
preHandler: authMiddleware,
|
||||
handler: handleMessages,
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
import { FastifyInstance } from 'fastify';
|
||||
import { getPool } from '../db';
|
||||
|
||||
export function registerHealthRoutes(app: FastifyInstance): void {
|
||||
app.get('/health', async (_request, reply) => {
|
||||
try {
|
||||
const pool = getPool();
|
||||
await pool.query('SELECT 1');
|
||||
return { status: 'ok', service: 'llm-gateway', timestamp: new Date().toISOString() };
|
||||
} catch (err: any) {
|
||||
reply.status(503);
|
||||
return { status: 'unhealthy', error: err.message };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import { FastifyInstance } from 'fastify';
|
||||
import { GatewayConfig } from '../config';
|
||||
import { authMiddleware } from '../middleware/auth';
|
||||
import { createOpenAIEmbeddingsProxy, createOpenAIChatProxy } from '../proxy/openai-proxy';
|
||||
|
||||
export function registerOpenAIRoutes(app: FastifyInstance, config: GatewayConfig): void {
|
||||
const handleEmbeddings = createOpenAIEmbeddingsProxy(config);
|
||||
const handleChatCompletions = createOpenAIChatProxy(config);
|
||||
|
||||
app.post('/v1/embeddings', {
|
||||
preHandler: authMiddleware,
|
||||
handler: handleEmbeddings,
|
||||
});
|
||||
|
||||
app.post('/v1/chat/completions', {
|
||||
preHandler: authMiddleware,
|
||||
handler: handleChatCompletions,
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,146 @@
|
|||
// ─── API Key Record (from gateway_api_keys table) ───
|
||||
|
||||
export interface ApiKeyRecord {
|
||||
id: string;
|
||||
tenantId: string | null;
|
||||
keyHash: string;
|
||||
keyPrefix: string;
|
||||
name: string;
|
||||
owner: string;
|
||||
permissions: ApiKeyPermissions;
|
||||
rateLimitRpm: number;
|
||||
rateLimitTpd: number;
|
||||
monthlyBudget: number | null;
|
||||
enabled: boolean;
|
||||
expiresAt: Date | null;
|
||||
lastUsedAt: Date | null;
|
||||
}
|
||||
|
||||
export interface ApiKeyPermissions {
|
||||
allowedModels: string[]; // ["*"] = all, or ["claude-haiku-4-5-*", "text-embedding-3-small"]
|
||||
allowStreaming: boolean;
|
||||
allowTools: boolean;
|
||||
}
|
||||
|
||||
// ─── Injection Rule (from gateway_injection_rules table) ───
|
||||
|
||||
export interface InjectionRule {
|
||||
id: string;
|
||||
tenantId: string | null;
|
||||
name: string;
|
||||
position: 'prepend' | 'append';
|
||||
content: string;
|
||||
matchModels: string[]; // ["*"] or specific model patterns
|
||||
matchKeyIds: string[] | null; // null = all keys
|
||||
priority: number;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
// ─── Content Filter Rule (from gateway_content_rules table) ───
|
||||
|
||||
export interface ContentRule {
|
||||
id: string;
|
||||
tenantId: string | null;
|
||||
name: string;
|
||||
type: 'keyword' | 'regex';
|
||||
pattern: string;
|
||||
action: 'block' | 'warn' | 'log';
|
||||
rejectMessage: string | null;
|
||||
priority: number;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
// ─── Anthropic Types ───
|
||||
|
||||
export interface AnthropicSystemBlock {
|
||||
type: 'text';
|
||||
text: string;
|
||||
cache_control?: { type: string };
|
||||
}
|
||||
|
||||
export type AnthropicSystem = string | AnthropicSystemBlock[];
|
||||
|
||||
export interface AnthropicRequestBody {
|
||||
model: string;
|
||||
system?: AnthropicSystem;
|
||||
messages: AnthropicMessage[];
|
||||
max_tokens: number;
|
||||
stream?: boolean;
|
||||
tools?: any[];
|
||||
tool_choice?: any;
|
||||
temperature?: number;
|
||||
top_p?: number;
|
||||
top_k?: number;
|
||||
stop_sequences?: string[];
|
||||
metadata?: any;
|
||||
output_config?: any;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface AnthropicMessage {
|
||||
role: 'user' | 'assistant';
|
||||
content: string | AnthropicContentBlock[];
|
||||
}
|
||||
|
||||
export interface AnthropicContentBlock {
|
||||
type: string;
|
||||
text?: string;
|
||||
source?: any;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
// ─── OpenAI Types ───
|
||||
|
||||
export interface OpenAIEmbeddingRequest {
|
||||
model: string;
|
||||
input: string | string[];
|
||||
encoding_format?: string;
|
||||
dimensions?: number;
|
||||
}
|
||||
|
||||
export interface OpenAIChatRequest {
|
||||
model: string;
|
||||
messages: Array<{ role: string; content: any }>;
|
||||
stream?: boolean;
|
||||
max_tokens?: number;
|
||||
temperature?: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
// ─── Filter Result ───
|
||||
|
||||
export interface FilterResult {
|
||||
blocked: boolean;
|
||||
reason?: string;
|
||||
ruleId?: string;
|
||||
action?: 'block' | 'warn' | 'log';
|
||||
}
|
||||
|
||||
// ─── Usage Log ───
|
||||
|
||||
export interface UsageLogEntry {
|
||||
apiKeyId: string;
|
||||
model: string;
|
||||
provider: 'anthropic' | 'openai';
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalTokens: number;
|
||||
costUsd: number | null;
|
||||
durationMs: number;
|
||||
statusCode: number;
|
||||
}
|
||||
|
||||
// ─── Audit Log ───
|
||||
|
||||
export interface AuditLogEntry {
|
||||
apiKeyId: string;
|
||||
requestMethod: string;
|
||||
requestPath: string;
|
||||
requestModel: string | null;
|
||||
requestIp: string;
|
||||
contentFiltered: boolean;
|
||||
filterRuleId: string | null;
|
||||
injectionApplied: boolean;
|
||||
responseStatus: number;
|
||||
durationMs: number;
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
"target": "ES2021",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"strictPropertyInitialization": false,
|
||||
"noImplicitAny": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
|
|
@ -1810,6 +1810,117 @@ COMMENT ON TABLE collection_directives IS '信息收集指令表 - 管理员配
|
|||
CREATE INDEX idx_collection_directives_tenant ON collection_directives(tenant_id);
|
||||
CREATE INDEX idx_collection_directives_tenant_enabled ON collection_directives(tenant_id, enabled);
|
||||
|
||||
-- ===========================================
|
||||
-- LLM Gateway 相关表
|
||||
-- 对外 API 代理服务的配置、用量和审计
|
||||
-- ===========================================
|
||||
|
||||
-- API Keys for external users
|
||||
CREATE TABLE IF NOT EXISTS gateway_api_keys (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID,
|
||||
key_hash VARCHAR(64) NOT NULL UNIQUE,
|
||||
key_prefix VARCHAR(12) NOT NULL,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
owner VARCHAR(200) NOT NULL DEFAULT '',
|
||||
permissions JSONB NOT NULL DEFAULT '{"allowedModels": ["*"], "allowStreaming": true, "allowTools": true}',
|
||||
rate_limit_rpm INTEGER NOT NULL DEFAULT 60,
|
||||
rate_limit_tpd INTEGER NOT NULL DEFAULT 1000000,
|
||||
monthly_budget DECIMAL(10,2),
|
||||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
expires_at TIMESTAMP,
|
||||
last_used_at TIMESTAMP,
|
||||
created_by UUID,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
COMMENT ON TABLE gateway_api_keys IS 'LLM Gateway - 外部用户 API Key 管理';
|
||||
CREATE INDEX IF NOT EXISTS idx_gateway_api_keys_hash ON gateway_api_keys (key_hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_gateway_api_keys_tenant ON gateway_api_keys (tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_gateway_api_keys_enabled ON gateway_api_keys (enabled);
|
||||
|
||||
-- Injection rules (regulatory content injected into system prompts)
|
||||
CREATE TABLE IF NOT EXISTS gateway_injection_rules (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
description TEXT,
|
||||
position VARCHAR(10) NOT NULL DEFAULT 'append' CHECK (position IN ('prepend', 'append')),
|
||||
content TEXT NOT NULL,
|
||||
match_models JSONB NOT NULL DEFAULT '["*"]',
|
||||
match_key_ids JSONB,
|
||||
priority INTEGER NOT NULL DEFAULT 0,
|
||||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
created_by UUID,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
COMMENT ON TABLE gateway_injection_rules IS 'LLM Gateway - 监管内容注入规则';
|
||||
CREATE INDEX IF NOT EXISTS idx_gateway_injection_rules_tenant ON gateway_injection_rules (tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_gateway_injection_rules_enabled ON gateway_injection_rules (tenant_id, enabled);
|
||||
|
||||
-- Content filter rules (message auditing)
|
||||
CREATE TABLE IF NOT EXISTS gateway_content_rules (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
type VARCHAR(20) NOT NULL DEFAULT 'keyword' CHECK (type IN ('keyword', 'regex')),
|
||||
pattern TEXT NOT NULL,
|
||||
action VARCHAR(20) NOT NULL DEFAULT 'block' CHECK (action IN ('block', 'warn', 'log')),
|
||||
reject_message TEXT,
|
||||
priority INTEGER NOT NULL DEFAULT 0,
|
||||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
created_by UUID,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
COMMENT ON TABLE gateway_content_rules IS 'LLM Gateway - 内容审查过滤规则';
|
||||
CREATE INDEX IF NOT EXISTS idx_gateway_content_rules_tenant ON gateway_content_rules (tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_gateway_content_rules_enabled ON gateway_content_rules (tenant_id, enabled);
|
||||
|
||||
-- Usage logs (token consumption tracking)
|
||||
CREATE TABLE IF NOT EXISTS gateway_usage_logs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
api_key_id UUID NOT NULL REFERENCES gateway_api_keys(id) ON DELETE CASCADE,
|
||||
model VARCHAR(100) NOT NULL,
|
||||
provider VARCHAR(20) NOT NULL,
|
||||
input_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
output_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
total_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
cost_usd DECIMAL(10,6),
|
||||
duration_ms INTEGER NOT NULL DEFAULT 0,
|
||||
status_code INTEGER NOT NULL DEFAULT 200,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
COMMENT ON TABLE gateway_usage_logs IS 'LLM Gateway - API 用量记录';
|
||||
CREATE INDEX IF NOT EXISTS idx_gateway_usage_logs_key ON gateway_usage_logs (api_key_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_gateway_usage_logs_created ON gateway_usage_logs (created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_gateway_usage_logs_key_created ON gateway_usage_logs (api_key_id, created_at);
|
||||
|
||||
-- Audit logs (request/response auditing)
|
||||
CREATE TABLE IF NOT EXISTS gateway_audit_logs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
api_key_id UUID NOT NULL REFERENCES gateway_api_keys(id) ON DELETE CASCADE,
|
||||
request_method VARCHAR(10) NOT NULL,
|
||||
request_path VARCHAR(200) NOT NULL,
|
||||
request_model VARCHAR(100),
|
||||
request_ip VARCHAR(50) NOT NULL DEFAULT '',
|
||||
content_filtered BOOLEAN NOT NULL DEFAULT false,
|
||||
filter_rule_id UUID,
|
||||
injection_applied BOOLEAN NOT NULL DEFAULT false,
|
||||
response_status INTEGER NOT NULL DEFAULT 200,
|
||||
duration_ms INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
COMMENT ON TABLE gateway_audit_logs IS 'LLM Gateway - 请求审计日志';
|
||||
CREATE INDEX IF NOT EXISTS idx_gateway_audit_logs_key ON gateway_audit_logs (api_key_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_gateway_audit_logs_created ON gateway_audit_logs (created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_gateway_audit_logs_filtered ON gateway_audit_logs (content_filtered) WHERE content_filtered = true;
|
||||
|
||||
-- ===========================================
|
||||
-- 结束
|
||||
-- ===========================================
|
||||
|
|
|
|||
Loading…
Reference in New Issue