forked from ChaiHao/BPOS-MVP
M1: 底座 —— @contracts 首版 + event / ledger / org / llm
@contracts 首版(§8.1 九个文件): - id 品牌 ID / terminal 终态五名与归侧四标签 / envelope 信封六字段与四类事件 - card 工单两模式 + 对话卡族(形状对齐 2026-07-26 原型定稿) - contract7 契约七件 / ledger 三级寻址与写三分 / process-package 流程包 DSL 与触发器三类 - manifest 活动包清单六区(含 CMMN·DMN·步序声明的资产形状)/ org / api 路由契约 模块(L1): - event 信封存储、归属解析三级短路、投递五诺、监听注册表、三级消费裁定 - ledger schema 自生长(最大化合并/最宽类型/只增不破坏)、任务视图叠加、写三分、冲突检查 - org 岗位授权(客体三类×动作三种)、停用前置检查、会话、种子播种 - llm 四元绑定、ModelGateway 三模式(live/record/replay)—— 两缝之一 app 装配:Hono 服务 + 鉴权中间件 + 治理台/工作台路由;drizzle 迁移;AES-256-GCM 凭证密封 ADR-007 工具链与门禁 · ADR-008 投递队列 · ADR-009 凭证加密 (ADR 编号自 007 续:编码规范 §14 已用到 006,README 写「从 005 续」是笔误) 门禁五道全绿;96 个测试通过,含对真实 PG 的 ledger 集成测试 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
co-authored by
Claude Opus 5
parent
7cd664af3a
commit
14d4fa046a
@@ -0,0 +1,34 @@
|
||||
# module: llm
|
||||
|
||||
职责: LLM 实例:自定义模型名 → 四元绑定(Provider 枚举 / URL / API-Key / Model);
|
||||
`ModelGateway` —— **两条缝之一**,支持 live / record / replay;系统用途模型的选取;
|
||||
安装静态审的「模型名已绑定」核对
|
||||
不负责: LLM 工具(是 `toolbox` 的一类工具,与基座的 LLM 调用不同物,OS 07)、
|
||||
凭证主密钥的持有(见 app 的 `SecretBox` 实现,ADR-009)、预算扣减(见 task 的 Harness)
|
||||
|
||||
公开出口: index.ts
|
||||
- 类型:`ModelGateway` `ModelCall` `ModelResult` `ModelUsage` `GatewayMode` `LlmService`
|
||||
`UpsertLlmInstance` `ModelBinding` `LlmInstanceView`
|
||||
- 错误:`ModelNotBoundError` `ModelDisabledError` `ReplayMissError`
|
||||
- 端口类型:`LlmInstanceRepository` `ReplayStore` `ProviderInvoker` `SecretBox` `Clock` `IdGenerator`
|
||||
- 用例工厂:`createLlmService` `createModelGateway`
|
||||
- 纯函数:`validateBinding` `isBound` `toPublicView` `pickSystemUse` `replayKey`
|
||||
`callFingerprint` `stableStringify`
|
||||
- adapter:`createLlmInstanceRepository` `createReplayStore` `createProviderInvoker` `parseJsonLoose`
|
||||
- Drizzle 表:`llmInstanceTable` `replayModelTable`
|
||||
|
||||
层级: L1(编码规范 §4)
|
||||
依赖: @contracts;@shared/db;port:`SecretBox`(app 注入)、`ProviderInvoker`、`ReplayStore`
|
||||
被依赖: toolbox, memory, loop-engine, flow-engine, skill-engine, task, activity-registry,
|
||||
report(叙述性小结)、app(治理台 · 系统管理路由)
|
||||
|
||||
不变量:
|
||||
- **一切模型调用经 `ModelGateway`** —— 绕过它,回放审就对它失明(§12.5#10)
|
||||
- `replay` 模式**绝不回退实调**:缺录即 `ReplayMissError`
|
||||
- 明文 API-Key **永不进 domain**:domain 只见密文串,开封在 usecase 调 `SecretBox.open`
|
||||
- 对外读模型 `LlmInstanceView` **结构上没有密钥字段** —— 只回 `apiKeyBound: boolean`(ADR-009)
|
||||
- 录制不含凭证:录的是语义层(model / system / prompt / 参数 / 出参),不是 HTTP 层
|
||||
- `upsert` 的 `apiKey` 留空 = 保持原密文(只写不读)
|
||||
- 四元缺一即未绑定;未绑定 / 已停用的实例调用一律抛错,不静默降级
|
||||
|
||||
影响半径: index/contract/port 未变 → 对外零影响
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isBound, pickSystemUse, toPublicView, validateBinding } from '../internal/domain/model-binding.ts';
|
||||
import type { ModelBinding } from '../internal/domain/model-binding.ts';
|
||||
import { callFingerprint, replayKey, stableStringify } from '../internal/domain/replay-key.ts';
|
||||
import type { TaskId } from '@contracts';
|
||||
|
||||
const binding = (over: Partial<ModelBinding> = {}): ModelBinding => ({
|
||||
id: 'i1',
|
||||
name: 'quote-extractor',
|
||||
provider: 'openai-compatible',
|
||||
baseUrl: 'https://api.example.com/v1',
|
||||
model: 'gpt-4o-mini',
|
||||
apiKeyCipher: 'v1.aa.bb.cc',
|
||||
systemUse: false,
|
||||
enabled: true,
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('validateBinding —— 四元齐备才算绑定', () => {
|
||||
it('四元齐备通过', () => {
|
||||
expect(validateBinding(binding())).toEqual([]);
|
||||
expect(isBound(binding())).toBe(true);
|
||||
});
|
||||
|
||||
it('缺 API-Key 即未绑定', () => {
|
||||
const v = validateBinding(binding({ apiKeyCipher: undefined }));
|
||||
expect(v.map((x) => x.field)).toEqual(['apiKey']);
|
||||
expect(isBound(binding({ apiKeyCipher: undefined }))).toBe(false);
|
||||
});
|
||||
|
||||
it('URL 必须是 http(s)', () => {
|
||||
expect(validateBinding(binding({ baseUrl: 'ftp://x' })).map((x) => x.field)).toEqual(['baseUrl']);
|
||||
expect(validateBinding(binding({ baseUrl: '' })).map((x) => x.field)).toEqual(['baseUrl']);
|
||||
});
|
||||
|
||||
it('模型名不得为空', () => {
|
||||
expect(validateBinding(binding({ model: ' ' })).map((x) => x.field)).toEqual(['model']);
|
||||
});
|
||||
|
||||
it('自定义模型名有字符集约束', () => {
|
||||
expect(validateBinding(binding({ name: '模型 一' })).map((x) => x.field)).toEqual(['name']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toPublicView —— 结构上不可能泄漏密钥', () => {
|
||||
it('只回是否已绑定', () => {
|
||||
const view = toPublicView(binding());
|
||||
expect(view).toEqual({
|
||||
id: 'i1',
|
||||
name: 'quote-extractor',
|
||||
provider: 'openai-compatible',
|
||||
baseUrl: 'https://api.example.com/v1',
|
||||
model: 'gpt-4o-mini',
|
||||
apiKeyBound: true,
|
||||
systemUse: false,
|
||||
enabled: true,
|
||||
});
|
||||
expect(JSON.stringify(view)).not.toContain('v1.aa.bb.cc');
|
||||
});
|
||||
|
||||
it('未绑定时 apiKeyBound = false', () => {
|
||||
expect(toPublicView(binding({ apiKeyCipher: '' })).apiKeyBound).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pickSystemUse', () => {
|
||||
it('取启用且标记系统用途的实例', () => {
|
||||
const picked = pickSystemUse([
|
||||
binding({ id: 'a', name: 'a', systemUse: false }),
|
||||
binding({ id: 'b', name: 'b', systemUse: true, enabled: false }),
|
||||
binding({ id: 'c', name: 'c', systemUse: true }),
|
||||
]);
|
||||
expect(picked?.id).toBe('c');
|
||||
});
|
||||
|
||||
it('没有即 undefined', () => {
|
||||
expect(pickSystemUse([])).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('replayKey —— 按任务 × 步序号', () => {
|
||||
it('有任务与步序号时用定位键', () => {
|
||||
expect(replayKey({ model: 'm', prompt: 'p', taskId: 't1' as TaskId, stepNo: 3 })).toBe('task:t1#3');
|
||||
});
|
||||
|
||||
it('缺定位时退到内容指纹,且同输入同键', () => {
|
||||
const a = replayKey({ model: 'm', prompt: 'p', temperature: 0 });
|
||||
const b = replayKey({ model: 'm', prompt: 'p', temperature: 0 });
|
||||
expect(a).toBe(b);
|
||||
expect(a.startsWith('fp:')).toBe(true);
|
||||
});
|
||||
|
||||
it('不同 prompt 得不同键', () => {
|
||||
expect(replayKey({ model: 'm', prompt: 'p1' })).not.toBe(replayKey({ model: 'm', prompt: 'p2' }));
|
||||
});
|
||||
|
||||
it('指纹不含凭证字段', () => {
|
||||
expect(callFingerprint({ model: 'm', prompt: 'p' })).not.toMatch(/key|token|authorization/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stableStringify —— 键排序,指纹不随属性顺序漂移', () => {
|
||||
it('对象键排序', () => {
|
||||
expect(stableStringify({ b: 1, a: 2 })).toBe(stableStringify({ a: 2, b: 1 }));
|
||||
});
|
||||
it('嵌套与数组', () => {
|
||||
expect(stableStringify({ a: [1, { d: 4, c: 3 }] })).toBe('{"a":[1,{"c":3,"d":4}]}');
|
||||
});
|
||||
it('null 与标量', () => {
|
||||
expect(stableStringify(null)).toBe('null');
|
||||
expect(stableStringify(3)).toBe('3');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* module: llm —— 对外类型(编码规范 §10.1)。
|
||||
*
|
||||
* 两条缝之一(§7.3):`ModelGateway` 支持 record | replay | live 三模式 ——
|
||||
* **任何模型调用不经 ModelGateway,回放审就对它失明**(§12.5#10)。
|
||||
*/
|
||||
import type { LlmInstanceView, LlmProvider, TaskId } from '@contracts';
|
||||
import type { ModelBinding, BindingViolation } from './internal/domain/model-binding.ts';
|
||||
|
||||
export type { LlmInstanceView, LlmProvider, ModelBinding, BindingViolation };
|
||||
|
||||
/** 一条缝的三个开关位 */
|
||||
export const GATEWAY_MODES = ['live', 'record', 'replay'] as const;
|
||||
export type GatewayMode = (typeof GATEWAY_MODES)[number];
|
||||
|
||||
export class ModelNotBoundError extends Error {
|
||||
constructor(
|
||||
readonly modelName: string,
|
||||
readonly violations: readonly BindingViolation[],
|
||||
) {
|
||||
super(`模型未绑定:${modelName}(${violations.map((v) => `${v.field}:${v.message}`).join('; ')})`);
|
||||
this.name = 'ModelNotBoundError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ModelDisabledError extends Error {
|
||||
constructor(readonly modelName: string) {
|
||||
super(`模型实例已停用:${modelName}`);
|
||||
this.name = 'ModelDisabledError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ReplayMissError extends Error {
|
||||
constructor(readonly key: string) {
|
||||
super(`回放缺录:${key} —— 回放模式下不得回退为实调(否则回放审失去意义)`);
|
||||
this.name = 'ReplayMissError';
|
||||
}
|
||||
}
|
||||
|
||||
/** 模型调用的语义入参 —— 录制的就是它,不含任何凭证(ADR-009 #5) */
|
||||
export type ModelCall = {
|
||||
/** 活动声明的自定义模型名(逻辑名) */
|
||||
readonly model: string;
|
||||
readonly system?: string;
|
||||
readonly prompt: string;
|
||||
readonly temperature?: number;
|
||||
readonly maxOutputTokens?: number;
|
||||
/** 出参 schema(JSON Schema);给定即要求结构化出参并校验 */
|
||||
readonly outputSchema?: Record<string, unknown>;
|
||||
/** 录制/回放的定位:任务 × 步序号(§7.3) */
|
||||
readonly taskId?: TaskId;
|
||||
readonly stepNo?: number;
|
||||
/** 归因用:调用点标签(flow 步名 / loop 回合 / 系统用途) */
|
||||
readonly label?: string;
|
||||
};
|
||||
|
||||
export type ModelUsage = {
|
||||
readonly inputTokens: number;
|
||||
readonly outputTokens: number;
|
||||
readonly latencyMs: number;
|
||||
};
|
||||
|
||||
export type ModelResult = {
|
||||
readonly text: string;
|
||||
/** outputSchema 给定时的解析结果 */
|
||||
readonly object?: unknown;
|
||||
readonly usage: ModelUsage;
|
||||
/** 行为可归因:模型与版本记入留痕(OS 4.1) */
|
||||
readonly binding: { readonly name: string; readonly provider: LlmProvider; readonly model: string };
|
||||
readonly mode: GatewayMode;
|
||||
};
|
||||
|
||||
/**
|
||||
* 模型网关 —— 活动基座六件里的「LLM 调用」。
|
||||
* 活动只见自定义模型名;解析、凭证、录制回放全在此。
|
||||
*/
|
||||
export type ModelGateway = {
|
||||
readonly mode: GatewayMode;
|
||||
generate(call: ModelCall): Promise<ModelResult>;
|
||||
/** 换模式的网关 —— 回放审用 replay 模式重跑引擎(§7.3) */
|
||||
withMode(mode: GatewayMode): ModelGateway;
|
||||
};
|
||||
|
||||
export type UpsertLlmInstance = {
|
||||
readonly id?: string | undefined;
|
||||
readonly name: string;
|
||||
readonly provider: LlmProvider;
|
||||
readonly baseUrl: string;
|
||||
readonly model: string;
|
||||
/** 只写不读:留空表示保持原值 */
|
||||
readonly apiKey?: string | undefined;
|
||||
readonly systemUse: boolean;
|
||||
readonly enabled: boolean;
|
||||
};
|
||||
|
||||
export type LlmService = {
|
||||
list(): Promise<LlmInstanceView[]>;
|
||||
upsert(input: UpsertLlmInstance): Promise<LlmInstanceView>;
|
||||
remove(id: string): Promise<void>;
|
||||
/** 连通性探针 —— 治理台「测试连接」 */
|
||||
probe(id: string): Promise<{ ok: boolean; latencyMs?: number; error?: string }>;
|
||||
/** 安装静态审:核对活动声明的模型名已绑定(AM 11.3) */
|
||||
assertNamesBound(names: readonly string[]): Promise<{ ok: boolean; missing: string[] }>;
|
||||
/** 系统用途模型 —— 叙述性小结 / 汇报生成 / 蒸馏钩子 */
|
||||
systemUseBinding(): Promise<ModelBinding | undefined>;
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* module: llm —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。
|
||||
*/
|
||||
export type {
|
||||
BindingViolation,
|
||||
GatewayMode,
|
||||
LlmInstanceView,
|
||||
LlmProvider,
|
||||
LlmService,
|
||||
ModelBinding,
|
||||
ModelCall,
|
||||
ModelGateway,
|
||||
ModelResult,
|
||||
ModelUsage,
|
||||
UpsertLlmInstance,
|
||||
} from './contract.ts';
|
||||
export { GATEWAY_MODES, ModelDisabledError, ModelNotBoundError, ReplayMissError } from './contract.ts';
|
||||
|
||||
export type {
|
||||
Clock,
|
||||
IdGenerator,
|
||||
LlmInstanceRepository,
|
||||
ProviderInvoker,
|
||||
ReplayRecord,
|
||||
ReplayStore,
|
||||
SecretBox,
|
||||
} from './port.ts';
|
||||
|
||||
export type { LlmServiceDeps } from './internal/usecase/llm-service.ts';
|
||||
export { createLlmService } from './internal/usecase/llm-service.ts';
|
||||
export type { ModelGatewayDeps } from './internal/usecase/model-gateway.ts';
|
||||
export { createModelGateway } from './internal/usecase/model-gateway.ts';
|
||||
|
||||
export { isBound, pickSystemUse, toPublicView, validateBinding } from './internal/domain/model-binding.ts';
|
||||
export { callFingerprint, replayKey, stableStringify } from './internal/domain/replay-key.ts';
|
||||
|
||||
export { createLlmInstanceRepository, createReplayStore } from './internal/adapter/llm-repository.ts';
|
||||
export { createProviderInvoker, parseJsonLoose } from './internal/adapter/ai-sdk-provider.ts';
|
||||
export { llmInstanceTable, replayModelTable } from './internal/adapter/llm-schema.ts';
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Provider 工厂 —— Vercel AI SDK 只准出现在 adapter(编码规范 §6.2 / §11.4)。
|
||||
*
|
||||
* 四元绑定落在这里:Provider 枚举 → provider 工厂(baseURL / apiKey)→ 模型实例。
|
||||
* 上层只见 `ProviderInvoker` 端口,拿不到任何 SDK 类型。
|
||||
*/
|
||||
import { generateText } from 'ai';
|
||||
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
|
||||
import type { ProviderInvoker } from '../../port.ts';
|
||||
|
||||
/**
|
||||
* MVP 的全部 Provider 都以 OpenAI 兼容端点接入(README:「绑定 LLM 实例(任一 OpenAI 兼容端点)」)。
|
||||
* 枚举是为了治理台的呈现与日后分化,当下共用一个工厂。
|
||||
*/
|
||||
export function createProviderInvoker(): ProviderInvoker {
|
||||
return {
|
||||
async invoke({ binding, apiKey, call }) {
|
||||
const provider = createOpenAICompatible({
|
||||
name: binding.provider,
|
||||
baseURL: binding.baseUrl,
|
||||
apiKey,
|
||||
});
|
||||
|
||||
const started = Date.now();
|
||||
const result = await generateText({
|
||||
model: provider(binding.model),
|
||||
...(call.system === undefined ? {} : { system: call.system }),
|
||||
prompt: call.prompt,
|
||||
...(call.temperature === undefined ? {} : { temperature: call.temperature }),
|
||||
...(call.maxOutputTokens === undefined ? {} : { maxOutputTokens: call.maxOutputTokens }),
|
||||
});
|
||||
|
||||
const object = call.outputSchema === undefined ? undefined : parseJsonLoose(result.text);
|
||||
|
||||
return {
|
||||
text: result.text,
|
||||
...(object === undefined ? {} : { object }),
|
||||
usage: {
|
||||
inputTokens: result.usage.inputTokens ?? 0,
|
||||
outputTokens: result.usage.outputTokens ?? 0,
|
||||
latencyMs: Date.now() - started,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 结构化出参:模型常把 JSON 包在 ```json 围栏里。这里只做**提取**,
|
||||
* schema 校验归调用方(ADR-003:运行时校验用 ajv)。
|
||||
*/
|
||||
export function parseJsonLoose(text: string): unknown {
|
||||
const fenced = /```(?:json)?\s*([\s\S]*?)```/.exec(text);
|
||||
const candidate = (fenced?.[1] ?? text).trim();
|
||||
try {
|
||||
return JSON.parse(candidate);
|
||||
} catch {
|
||||
const start = candidate.search(/[[{]/);
|
||||
const end = Math.max(candidate.lastIndexOf('}'), candidate.lastIndexOf(']'));
|
||||
if (start >= 0 && end > start) {
|
||||
try {
|
||||
return JSON.parse(candidate.slice(start, end + 1));
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* llm 的仓储与录制存储 adapter —— Drizzle 只准出现在 adapter(编码规范 §5.3)。
|
||||
*/
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { llmProviderSchema } from '@contracts';
|
||||
import type { Db } from '@shared/db.ts';
|
||||
import type { LlmInstanceRepository, ReplayRecord, ReplayStore } from '../../port.ts';
|
||||
import type { ModelBinding } from '../domain/model-binding.ts';
|
||||
import { llmInstanceTable, replayModelTable } from './llm-schema.ts';
|
||||
|
||||
type Row = typeof llmInstanceTable.$inferSelect;
|
||||
|
||||
function toBinding(row: Row): ModelBinding {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
provider: llmProviderSchema.parse(row.provider),
|
||||
baseUrl: row.baseUrl,
|
||||
model: row.model,
|
||||
apiKeyCipher: row.apiKeyCipher ?? undefined,
|
||||
systemUse: row.systemUse,
|
||||
enabled: row.enabled,
|
||||
};
|
||||
}
|
||||
|
||||
export function createLlmInstanceRepository(db: Db): LlmInstanceRepository {
|
||||
return {
|
||||
async list() {
|
||||
const rows = await db.select().from(llmInstanceTable).orderBy(llmInstanceTable.name);
|
||||
return rows.map(toBinding);
|
||||
},
|
||||
async findByName(name) {
|
||||
const rows = await db.select().from(llmInstanceTable).where(eq(llmInstanceTable.name, name)).limit(1);
|
||||
return rows[0] === undefined ? undefined : toBinding(rows[0]);
|
||||
},
|
||||
async findById(id) {
|
||||
const rows = await db.select().from(llmInstanceTable).where(eq(llmInstanceTable.id, id)).limit(1);
|
||||
return rows[0] === undefined ? undefined : toBinding(rows[0]);
|
||||
},
|
||||
async upsert(binding) {
|
||||
const values = {
|
||||
id: binding.id,
|
||||
name: binding.name,
|
||||
provider: binding.provider,
|
||||
baseUrl: binding.baseUrl,
|
||||
model: binding.model,
|
||||
apiKeyCipher: binding.apiKeyCipher ?? null,
|
||||
systemUse: binding.systemUse,
|
||||
enabled: binding.enabled,
|
||||
};
|
||||
const [row] = await db
|
||||
.insert(llmInstanceTable)
|
||||
.values(values)
|
||||
.onConflictDoUpdate({
|
||||
target: llmInstanceTable.name,
|
||||
set: {
|
||||
provider: values.provider,
|
||||
baseUrl: values.baseUrl,
|
||||
model: values.model,
|
||||
apiKeyCipher: values.apiKeyCipher,
|
||||
systemUse: values.systemUse,
|
||||
enabled: values.enabled,
|
||||
},
|
||||
})
|
||||
.returning();
|
||||
if (row === undefined) throw new Error('LLM 实例写入未返回行');
|
||||
return toBinding(row);
|
||||
},
|
||||
async remove(id) {
|
||||
await db.delete(llmInstanceTable).where(eq(llmInstanceTable.id, id));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createReplayStore(db: Db): ReplayStore {
|
||||
return {
|
||||
async put(record: ReplayRecord) {
|
||||
await db
|
||||
.insert(replayModelTable)
|
||||
.values({
|
||||
key: record.key,
|
||||
call: record.call,
|
||||
result: record.result,
|
||||
recordedAt: record.recordedAt,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: replayModelTable.key,
|
||||
set: { call: record.call, result: record.result, recordedAt: record.recordedAt },
|
||||
});
|
||||
},
|
||||
async get(key: string) {
|
||||
const rows = await db.select().from(replayModelTable).where(eq(replayModelTable.key, key)).limit(1);
|
||||
const row = rows[0];
|
||||
if (row === undefined) return undefined;
|
||||
return {
|
||||
key: row.key,
|
||||
call: row.call as ReplayRecord['call'],
|
||||
result: row.result as ReplayRecord['result'],
|
||||
recordedAt: row.recordedAt,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* llm 的 sys schema 表(编码规范 §7 / ADR-002 / ADR-009)。
|
||||
*/
|
||||
import { boolean, index, jsonb, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
import { sys } from '@shared/db.ts';
|
||||
|
||||
export const llmInstanceTable = sys.table(
|
||||
'llm_instance',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
/** 自定义模型名(逻辑名)—— 活动以此声明引用,全局唯一 */
|
||||
name: text('name').notNull(),
|
||||
provider: text('provider').notNull(),
|
||||
baseUrl: text('base_url').notNull(),
|
||||
model: text('model').notNull(),
|
||||
/** AES-256-GCM 密文;明文与 endpoint 值只存治理台,不入包、不入活动、不入留痕(ADR-009) */
|
||||
apiKeyCipher: text('api_key_cipher'),
|
||||
systemUse: boolean('system_use').notNull().default(false),
|
||||
enabled: boolean('enabled').notNull().default(true),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [uniqueIndex('llm_instance_name_key').on(t.name)],
|
||||
);
|
||||
|
||||
/** 录制回放:按任务 × 步序号(编码规范 §7.3 两缝之一) */
|
||||
export const replayModelTable = sys.table(
|
||||
'replay_model',
|
||||
{
|
||||
key: text('key').primaryKey(),
|
||||
call: jsonb('call').notNull(),
|
||||
result: jsonb('result').notNull(),
|
||||
recordedAt: timestamp('recorded_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [index('replay_model_recorded_idx').on(t.recordedAt)],
|
||||
);
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* 模型四元绑定 —— 纯逻辑,零 IO(编码规范 §5.3;语义出处 OS 02 系统管理 / 04 基座六件)。
|
||||
*
|
||||
* 活动侧只声明**自定义模型名(逻辑名)**;名称背后的
|
||||
* Provider(枚举)/ URL / API-Key / Model name 四元在系统侧创建绑定 —— 活动不触厂商与密钥。
|
||||
*/
|
||||
import type { LlmProvider } from '@contracts';
|
||||
|
||||
export type ModelBinding = {
|
||||
readonly id: string;
|
||||
/** 自定义模型名 —— 活动以此声明引用 */
|
||||
readonly name: string;
|
||||
readonly provider: LlmProvider;
|
||||
readonly baseUrl: string;
|
||||
readonly model: string;
|
||||
/** 密文;明文永不进 domain(ADR-009) */
|
||||
readonly apiKeyCipher: string | undefined;
|
||||
readonly systemUse: boolean;
|
||||
readonly enabled: boolean;
|
||||
};
|
||||
|
||||
export type BindingViolation = { readonly field: string; readonly message: string };
|
||||
|
||||
const NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
||||
|
||||
/** 四元齐备才算绑定;缺一即静态审的 `model-name-unbound` */
|
||||
export function validateBinding(binding: ModelBinding): BindingViolation[] {
|
||||
const out: BindingViolation[] = [];
|
||||
if (!NAME.test(binding.name)) out.push({ field: 'name', message: '自定义模型名只准字母数字与 . _ -,不超 64 字符' });
|
||||
if (binding.baseUrl.trim() === '') out.push({ field: 'baseUrl', message: 'URL 不得为空' });
|
||||
else if (!/^https?:\/\//.test(binding.baseUrl)) out.push({ field: 'baseUrl', message: 'URL 必须是 http(s)' });
|
||||
if (binding.model.trim() === '') out.push({ field: 'model', message: 'Model name 不得为空' });
|
||||
if (binding.apiKeyCipher === undefined || binding.apiKeyCipher === '')
|
||||
out.push({ field: 'apiKey', message: 'API-Key 未绑定' });
|
||||
return out;
|
||||
}
|
||||
|
||||
export function isBound(binding: ModelBinding): boolean {
|
||||
return validateBinding(binding).length === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 对外读模型 —— **结构上不可能泄漏密钥**(ADR-009 #4):
|
||||
* 只回是否已绑定,不回密文、不回明文。
|
||||
*/
|
||||
export function toPublicView(binding: ModelBinding): {
|
||||
id: string;
|
||||
name: string;
|
||||
provider: LlmProvider;
|
||||
baseUrl: string;
|
||||
model: string;
|
||||
apiKeyBound: boolean;
|
||||
systemUse: boolean;
|
||||
enabled: boolean;
|
||||
} {
|
||||
return {
|
||||
id: binding.id,
|
||||
name: binding.name,
|
||||
provider: binding.provider,
|
||||
baseUrl: binding.baseUrl,
|
||||
model: binding.model,
|
||||
apiKeyBound: binding.apiKeyCipher !== undefined && binding.apiKeyCipher !== '',
|
||||
systemUse: binding.systemUse,
|
||||
enabled: binding.enabled,
|
||||
};
|
||||
}
|
||||
|
||||
/** 系统用途模型:叙述性小结 / 汇报生成 / 蒸馏钩子 —— 计量入系统账、不占活动预算 */
|
||||
export function pickSystemUse(bindings: readonly ModelBinding[]): ModelBinding | undefined {
|
||||
return bindings.find((b) => b.systemUse && b.enabled);
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* llm 用例 —— LLM 实例的四元绑定管理(编码规范 §5.3;语义出处 OS 02 系统管理)。
|
||||
*/
|
||||
import type { LlmInstanceView } from '@contracts';
|
||||
import type { LlmService, UpsertLlmInstance } from '../../contract.ts';
|
||||
import type { Clock, IdGenerator, LlmInstanceRepository, ProviderInvoker, SecretBox } from '../../port.ts';
|
||||
import { pickSystemUse, toPublicView, validateBinding } from '../domain/model-binding.ts';
|
||||
import type { ModelBinding } from '../domain/model-binding.ts';
|
||||
|
||||
export type LlmServiceDeps = {
|
||||
readonly instances: LlmInstanceRepository;
|
||||
readonly provider: ProviderInvoker;
|
||||
readonly secrets: SecretBox;
|
||||
readonly clock: Clock;
|
||||
readonly ids: IdGenerator;
|
||||
};
|
||||
|
||||
export function createLlmService(deps: LlmServiceDeps): LlmService {
|
||||
return {
|
||||
async list(): Promise<LlmInstanceView[]> {
|
||||
const all = await deps.instances.list();
|
||||
return all.map(toPublicView);
|
||||
},
|
||||
|
||||
async upsert(input: UpsertLlmInstance): Promise<LlmInstanceView> {
|
||||
const existing = input.id === undefined ? undefined : await deps.instances.findById(input.id);
|
||||
// 只写不读:apiKey 留空即保持原密文(ADR-009 #4)
|
||||
const apiKeyCipher =
|
||||
input.apiKey !== undefined && input.apiKey !== ''
|
||||
? deps.secrets.seal(input.apiKey)
|
||||
: existing?.apiKeyCipher;
|
||||
|
||||
const binding: ModelBinding = {
|
||||
id: existing?.id ?? input.id ?? deps.ids.next(),
|
||||
name: input.name,
|
||||
provider: input.provider,
|
||||
baseUrl: input.baseUrl,
|
||||
model: input.model,
|
||||
apiKeyCipher,
|
||||
systemUse: input.systemUse,
|
||||
enabled: input.enabled,
|
||||
};
|
||||
const saved = await deps.instances.upsert(binding);
|
||||
return toPublicView(saved);
|
||||
},
|
||||
|
||||
async remove(id) {
|
||||
await deps.instances.remove(id);
|
||||
},
|
||||
|
||||
async probe(id) {
|
||||
const binding = await deps.instances.findById(id);
|
||||
if (binding === undefined) return { ok: false, error: '实例不存在' };
|
||||
const violations = validateBinding(binding);
|
||||
if (violations.length > 0) {
|
||||
return { ok: false, error: violations.map((v) => `${v.field}:${v.message}`).join('; ') };
|
||||
}
|
||||
const started = deps.clock.now().getTime();
|
||||
try {
|
||||
await deps.provider.invoke({
|
||||
binding,
|
||||
apiKey: deps.secrets.open(binding.apiKeyCipher!),
|
||||
call: { model: binding.name, prompt: 'ping', maxOutputTokens: 8, label: 'probe' },
|
||||
});
|
||||
return { ok: true, latencyMs: deps.clock.now().getTime() - started };
|
||||
} catch (error) {
|
||||
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
},
|
||||
|
||||
async assertNamesBound(names) {
|
||||
const all = await deps.instances.list();
|
||||
const bound = new Set(all.filter((b) => validateBinding(b).length === 0 && b.enabled).map((b) => b.name));
|
||||
const missing = [...new Set(names)].filter((n) => !bound.has(n));
|
||||
return { ok: missing.length === 0, missing };
|
||||
},
|
||||
|
||||
async systemUseBinding() {
|
||||
return pickSystemUse(await deps.instances.list());
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* ModelGateway —— 两条缝之一(编码规范 §7.3)。
|
||||
*
|
||||
* live 实调 provider(经 ProviderInvoker 端口,AI SDK 在 adapter)
|
||||
* record 实调 + 落录 `sys.replay_model`
|
||||
* replay 只读录、**绝不回退实调** —— 回退即回放审失明(ReplayMissError)
|
||||
*
|
||||
* 活动只见自定义模型名;解析、凭证开封、留痕在此。
|
||||
*/
|
||||
import type { GatewayMode, ModelCall, ModelGateway, ModelResult } from '../../contract.ts';
|
||||
import { ModelDisabledError, ModelNotBoundError, ReplayMissError } from '../../contract.ts';
|
||||
import type { Clock, LlmInstanceRepository, ProviderInvoker, ReplayStore, SecretBox } from '../../port.ts';
|
||||
import { validateBinding } from '../domain/model-binding.ts';
|
||||
import { replayKey } from '../domain/replay-key.ts';
|
||||
|
||||
export type ModelGatewayDeps = {
|
||||
readonly instances: LlmInstanceRepository;
|
||||
readonly provider: ProviderInvoker;
|
||||
readonly replay: ReplayStore;
|
||||
readonly secrets: SecretBox;
|
||||
readonly clock: Clock;
|
||||
readonly mode: GatewayMode;
|
||||
};
|
||||
|
||||
export function createModelGateway(deps: ModelGatewayDeps): ModelGateway {
|
||||
const gateway: ModelGateway = {
|
||||
mode: deps.mode,
|
||||
|
||||
async generate(call: ModelCall): Promise<ModelResult> {
|
||||
const key = replayKey(call);
|
||||
|
||||
if (deps.mode === 'replay') {
|
||||
const record = await deps.replay.get(key);
|
||||
if (record === undefined) throw new ReplayMissError(key);
|
||||
const binding = await deps.instances.findByName(call.model);
|
||||
return {
|
||||
text: record.result.text,
|
||||
...(record.result.object === undefined ? {} : { object: record.result.object }),
|
||||
usage: record.result.usage,
|
||||
binding: {
|
||||
name: call.model,
|
||||
provider: binding?.provider ?? 'openai-compatible',
|
||||
model: binding?.model ?? record.call.model,
|
||||
},
|
||||
mode: 'replay',
|
||||
};
|
||||
}
|
||||
|
||||
const binding = await deps.instances.findByName(call.model);
|
||||
if (binding === undefined) {
|
||||
throw new ModelNotBoundError(call.model, [{ field: 'name', message: '实例不存在' }]);
|
||||
}
|
||||
if (!binding.enabled) throw new ModelDisabledError(call.model);
|
||||
const violations = validateBinding(binding);
|
||||
if (violations.length > 0) throw new ModelNotBoundError(call.model, violations);
|
||||
|
||||
const apiKey = deps.secrets.open(binding.apiKeyCipher!);
|
||||
const started = deps.clock.now().getTime();
|
||||
const raw = await deps.provider.invoke({ binding, apiKey, call });
|
||||
const usage = { ...raw.usage, latencyMs: raw.usage.latencyMs || deps.clock.now().getTime() - started };
|
||||
|
||||
if (deps.mode === 'record') {
|
||||
await deps.replay.put({
|
||||
key,
|
||||
call,
|
||||
result: { text: raw.text, ...(raw.object === undefined ? {} : { object: raw.object }), usage },
|
||||
recordedAt: deps.clock.now(),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
text: raw.text,
|
||||
...(raw.object === undefined ? {} : { object: raw.object }),
|
||||
usage,
|
||||
binding: { name: binding.name, provider: binding.provider, model: binding.model },
|
||||
mode: deps.mode,
|
||||
};
|
||||
},
|
||||
|
||||
withMode(mode) {
|
||||
return createModelGateway({ ...deps, mode });
|
||||
},
|
||||
};
|
||||
return gateway;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "@modules/llm",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": { ".": "./index.ts" }
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* module: llm —— 本模块要外界提供的能力(编码规范 §10.1 / §5.3)。
|
||||
*/
|
||||
import type { ModelBinding } from './internal/domain/model-binding.ts';
|
||||
import type { ModelCall, ModelResult, ModelUsage } from './contract.ts';
|
||||
|
||||
export type Clock = { now(): Date };
|
||||
export type IdGenerator = { next(): string };
|
||||
|
||||
/** 凭证密封 —— 明文永不进 domain(ADR-009) */
|
||||
export type SecretBox = {
|
||||
seal(plaintext: string): string;
|
||||
open(cipher: string): string;
|
||||
};
|
||||
|
||||
export type LlmInstanceRepository = {
|
||||
list(): Promise<ModelBinding[]>;
|
||||
findByName(name: string): Promise<ModelBinding | undefined>;
|
||||
findById(id: string): Promise<ModelBinding | undefined>;
|
||||
upsert(binding: ModelBinding): Promise<ModelBinding>;
|
||||
remove(id: string): Promise<void>;
|
||||
};
|
||||
|
||||
/** 录制回放存储:按任务 × 步序号(§7.3) */
|
||||
export type ReplayRecord = {
|
||||
readonly key: string;
|
||||
readonly call: ModelCall;
|
||||
readonly result: { readonly text: string; readonly object?: unknown; readonly usage: ModelUsage };
|
||||
readonly recordedAt: Date;
|
||||
};
|
||||
|
||||
export type ReplayStore = {
|
||||
put(record: ReplayRecord): Promise<void>;
|
||||
get(key: string): Promise<ReplayRecord | undefined>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Provider 工厂 —— Vercel AI SDK 只准出现在 adapter(§6.2 / §11.4)。
|
||||
* usecase 只见本端口,拿不到任何 SDK 类型。
|
||||
*/
|
||||
export type ProviderInvoker = {
|
||||
invoke(input: {
|
||||
readonly binding: ModelBinding;
|
||||
readonly apiKey: string;
|
||||
readonly call: ModelCall;
|
||||
}): Promise<{ text: string; object?: unknown; usage: ModelUsage }>;
|
||||
};
|
||||
|
||||
export type { ModelCall, ModelResult };
|
||||
Reference in New Issue
Block a user