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:
JCAI Main Agent
2026-07-27 00:59:14 +08:00
co-authored by Claude Opus 5
parent 7cd664af3a
commit 14d4fa046a
120 changed files with 15236 additions and 78 deletions
+34
View File
@@ -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');
});
});
+106
View File
@@ -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>;
};
+39
View File
@@ -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);
}
@@ -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;
}
+7
View File
@@ -0,0 +1,7 @@
{
"name": "@modules/llm",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": { ".": "./index.ts" }
}
+49
View File
@@ -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 };