@danceiny/gotry 0.2.0-rc.32 → 0.2.0-rc.33
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/README.zh-CN.md +2 -2
- package/dist/capabilities/session/bridge-ledger.js +168 -67
- package/dist/capabilities/session/extension-bridge.js +140 -46
- package/dist/capabilities/session/gui-flight.js +281 -69
- package/dist/capabilities/session/gui-hotel.js +74 -17
- package/dist/capabilities/session-search.js +6 -5
- package/dist/scripts/booking-copilot-dsh-planner-proof-tests.js +129 -2
- package/dist/scripts/booking-copilot-dsh-readiness-proof.js +53 -5
- package/dist/scripts/booking-copilot-gateway-chat-adapter-tests.js +233 -0
- package/dist/scripts/booking-copilot-planner-live-probe.js +1 -0
- package/dist/scripts/booking-copilot-startup-proof-tests.js +1 -1
- package/dist/scripts/bridge-ledger-tests.js +20 -18
- package/dist/scripts/fixtures/tenant-runaway-worker-fixture.mjs +73 -0
- package/dist/scripts/mutation-check.js +153 -0
- package/dist/scripts/nightly-evidence.js +4 -111
- package/dist/scripts/session-tests.js +42 -8
- package/dist/scripts/tenant-bridge-org-tests.js +1264 -0
- package/dist/scripts/tenant-capacity-probe.js +263 -0
- package/dist/scripts/tenant-compute-isolation-tests.js +707 -0
- package/dist/scripts/tenant-egress-credentials-tests.js +822 -0
- package/dist/scripts/tenant-executor-binding-tests.js +203 -0
- package/dist/scripts/tenant-prompt-partition-tests.js +265 -0
- package/dist/scripts/tenant-seam-adversarial-tests.js +957 -0
- package/dist/scripts/tenant-seam-quota-tests.js +383 -0
- package/dist/scripts/tenant-seam-test-kit.js +292 -0
- package/dist/scripts/tenant-state-copies-tests.js +607 -0
- package/dist/scripts/tenant-state-drill-tests.js +298 -0
- package/dist/scripts/tenant-state-drill-worker.js +164 -0
- package/dist/scripts/tenant-state-durable-conformance.js +101 -0
- package/dist/scripts/tenant-state-handoff-tests.js +854 -0
- package/dist/scripts/tenant-state-lease-conformance.js +142 -0
- package/dist/scripts/tenant-state-lease-worker.js +28 -0
- package/dist/scripts/tenant-state-router-tests.js +464 -0
- package/dist/scripts/tenant-state-test-kit.js +92 -0
- package/dist/scripts/tenant-state-tests.js +564 -0
- package/dist/src/backend/modules/booking-executor.js +61 -6
- package/dist/src/backend/modules/session-search.js +136 -39
- package/dist/src/booking-surface/dsh-llm-gateway-chat.js +434 -0
- package/dist/src/booking-surface/dsh-planner.js +150 -19
- package/dist/src/booking-surface/index.js +1 -0
- package/dist/src/booking-surface/managed-dsh-run-port.js +121 -7
- package/dist/src/booking-surface/server.js +193 -80
- package/dist/src/booking-surface/startup.js +153 -0
- package/dist/src/booking-surface/tenancy.js +188 -0
- package/dist/src/index.js +31 -1
- package/dist/src/llm-price-table.js +148 -0
- package/dist/src/tenant-seam/index.js +8 -0
- package/dist/src/tenant-seam/llm-broker.js +508 -0
- package/dist/src/tenant-seam/process-governor.js +281 -0
- package/dist/src/tenant-seam/quotas.js +360 -0
- package/dist/src/tenant-seam/seam.js +92 -0
- package/dist/src/tenant-state/durable-store.js +158 -0
- package/dist/src/tenant-state/epoch-copies.js +422 -0
- package/dist/src/tenant-state/errors.js +108 -0
- package/dist/src/tenant-state/ids.js +101 -0
- package/dist/src/tenant-state/index.js +11 -0
- package/dist/src/tenant-state/lease-store.js +203 -0
- package/dist/src/tenant-state/tenant-lease.js +150 -0
- package/dist/src/tenant-state/tenant-router.js +650 -0
- package/extension/manifest.json +2 -2
- package/package.json +4 -1
- package/ts/capabilities/session/bridge-ledger.ts +192 -78
- package/ts/capabilities/session/extension-bridge.ts +176 -65
- package/ts/capabilities/session/gui-flight.ts +318 -88
- package/ts/capabilities/session/gui-hotel.ts +72 -18
- package/ts/capabilities/session-search.ts +17 -5
- package/ts/src/booking-surface/dsh-llm-gateway-chat.js +434 -0
- package/ts/src/booking-surface/dsh-planner.ts +169 -19
- package/ts/src/booking-surface/index.ts +1 -0
- package/ts/src/booking-surface/managed-dsh-run-port.ts +150 -7
- package/ts/src/booking-surface/server.ts +215 -84
- package/ts/src/booking-surface/startup.ts +155 -0
- package/ts/src/booking-surface/tenancy.ts +242 -0
- package/ts/src/index.ts +28 -2
- package/ts/src/llm-price-table.ts +180 -0
- package/ts/src/tenant-seam/index.ts +9 -0
- package/ts/src/tenant-seam/llm-broker.ts +520 -0
- package/ts/src/tenant-seam/process-governor.ts +343 -0
- package/ts/src/tenant-seam/quotas.ts +433 -0
- package/ts/src/tenant-seam/seam.ts +142 -0
- package/ts/src/tenant-state/durable-store.ts +171 -0
- package/ts/src/tenant-state/epoch-copies.ts +491 -0
- package/ts/src/tenant-state/errors.ts +128 -0
- package/ts/src/tenant-state/ids.ts +108 -0
- package/ts/src/tenant-state/index.ts +12 -0
- package/ts/src/tenant-state/lease-store.ts +266 -0
- package/ts/src/tenant-state/tenant-lease.ts +175 -0
- package/ts/src/tenant-state/tenant-router.ts +743 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-tenant seam of the Booking Copilot surface (AaaS A2, docs/rfc/aaas-rfc.md §4.4; threats S-1 to S-4, S-10, S-13, S-14).
|
|
3
|
+
*
|
|
4
|
+
* One request becomes one `TurnScope` here and nowhere else:
|
|
5
|
+
*
|
|
6
|
+
* authenticate -> deriveSeamTenant -> quotas.admitTurn -> router.openTenant -> a runtime over the tenant's guarded ledger
|
|
7
|
+
*
|
|
8
|
+
* The tenant comes from the authenticated caller only. The body, the headers, the tool arguments and the model's output
|
|
9
|
+
* are never inputs to it, and the scope carries no method that takes a tenant. Everything the surface keeps between
|
|
10
|
+
* requests (planner sessions, single-flight decisions) is keyed by the scope, so two tenants whose task handles and
|
|
11
|
+
* request keys collide still never meet. The shared, single-ledger form of the surface (today's deployment) does not
|
|
12
|
+
* use this module and behaves exactly as before.
|
|
13
|
+
*/
|
|
14
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
15
|
+
import { assertSeamTenant, deriveSeamTenant, InvalidPrincipalError, SeamError, seamTenantKey, UnknownOrganizationError } from '../tenant-seam/seam.js';
|
|
16
|
+
import { QuotaExceededError } from '../tenant-seam/quotas.js';
|
|
17
|
+
import { InvalidTenantIdError, ReplicaAtCapacityError, RouterClosedError, TenantDrainingError, TenantStateError } from '../tenant-state/errors.js';
|
|
18
|
+
import { derivePromptCachePartition } from '../tenant-state/ids.js';
|
|
19
|
+
import { BookingCopilotTaskRuntime } from './runtime.js';
|
|
20
|
+
/** One planner shared by every tenant. Sessions are still keyed by tenant here, but the planner's own files are shared: for fixtures. */
|
|
21
|
+
export function sharedPlannerProvider(factory) {
|
|
22
|
+
return { forTenant: async () => factory, close: async () => undefined };
|
|
23
|
+
}
|
|
24
|
+
/** The handle and the ledger it exposes disagree with the tenant the seam derived: a wiring bug that must never serve a request. */
|
|
25
|
+
export class TenantBindingError extends SeamError {
|
|
26
|
+
constructor() {
|
|
27
|
+
super('GOTRY_SEAM_TENANT_MISMATCH', 'the opened ledger does not belong to the derived tenant');
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const invocations = new AsyncLocalStorage();
|
|
31
|
+
function ledgerBytes(handle) {
|
|
32
|
+
return handle.run((ledger) => {
|
|
33
|
+
const pages = ledger.db.pragma('page_count', { simple: true });
|
|
34
|
+
const size = ledger.db.pragma('page_size', { simple: true });
|
|
35
|
+
return pages * size;
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
export class BookingTenancy {
|
|
39
|
+
ingressMode;
|
|
40
|
+
options;
|
|
41
|
+
planners = new Map();
|
|
42
|
+
constructor(options) {
|
|
43
|
+
if (typeof options.authenticator?.authenticate !== 'function')
|
|
44
|
+
throw new Error('booking_copilot_tenancy_authenticator_required');
|
|
45
|
+
if (typeof options.keyring?.keyFor !== 'function')
|
|
46
|
+
throw new Error('booking_copilot_tenancy_keyring_required');
|
|
47
|
+
if (!options.router || !options.quotas || !options.planners)
|
|
48
|
+
throw new Error('booking_copilot_tenancy_incomplete');
|
|
49
|
+
if (options.ingressBinding !== undefined && typeof options.ingressBinding.bind !== 'function')
|
|
50
|
+
throw new Error('booking_copilot_ingress_binding_invalid');
|
|
51
|
+
this.options = options;
|
|
52
|
+
this.ingressMode = options.ingressBinding ? 'bff-ingress-binding' : 'bff-bound-turn-only';
|
|
53
|
+
}
|
|
54
|
+
/** The authenticated caller of a request, or `null`. The only place a credential is read. */
|
|
55
|
+
async authenticate(request) {
|
|
56
|
+
return this.options.authenticator.authenticate(request);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Derives the tenant, admits the turn, and opens the tenant's guarded ledger.
|
|
60
|
+
*
|
|
61
|
+
* @throws QuotaExceededError before any lease or file is touched, when the turn is over a limit
|
|
62
|
+
* @throws InvalidTenantIdError | InvalidPrincipalError | UnknownOrganizationError for a caller the seam cannot place
|
|
63
|
+
* @throws TenantStateError subclasses when the tenant cannot be served here now (owned elsewhere, draining, store down)
|
|
64
|
+
*/
|
|
65
|
+
async beginTurn(caller) {
|
|
66
|
+
const { quotas, router } = this.options;
|
|
67
|
+
const tenant = deriveSeamTenant(caller, this.options.keyring);
|
|
68
|
+
const permit = quotas.admitTurn(tenant);
|
|
69
|
+
try {
|
|
70
|
+
const handle = await router.openTenant(tenant.organization, tenant.ledgerTenant);
|
|
71
|
+
// Defense in depth: the handle, and the ledger inside it, must be exactly the tenant the seam derived.
|
|
72
|
+
if (handle.organization !== tenant.organization || handle.ledgerTenant !== tenant.ledgerTenant || handle.ledger.tenant !== tenant.ledgerTenant)
|
|
73
|
+
throw new TenantBindingError();
|
|
74
|
+
quotas.observeLedgerBytes(tenant, ledgerBytes(handle));
|
|
75
|
+
const plannerFactory = await this.plannerFor(tenant);
|
|
76
|
+
const key = seamTenantKey(tenant);
|
|
77
|
+
const runtime = new BookingCopilotTaskRuntime(handle.ledger, this.options.runtime);
|
|
78
|
+
const binding = this.options.ingressBinding;
|
|
79
|
+
return {
|
|
80
|
+
key,
|
|
81
|
+
runtime,
|
|
82
|
+
plannerFactory,
|
|
83
|
+
ingress: binding ? { binding, principal: { subject: caller.subject, scope: caller.scope } } : null,
|
|
84
|
+
sessionKey: (taskId) => `${key}\u0000${taskId}`,
|
|
85
|
+
flightKey: (parts) => JSON.stringify([key, ...parts]),
|
|
86
|
+
runDecision: (work) => handle.runAsync(work),
|
|
87
|
+
isTenantFault: (error) => error instanceof TenantStateError,
|
|
88
|
+
release: () => permit.release(),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
permit.release();
|
|
93
|
+
throw error;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Hands every tenant back and stops the planners. The tenancy owns the router and the planner provider it was given:
|
|
98
|
+
* a graceful handoff per tenant (drain, final snapshot, release), then the planners. Resolves when done; a tenant whose
|
|
99
|
+
* final snapshot could not be flushed is reported, because its last writes are then only as durable as the last snapshot.
|
|
100
|
+
*/
|
|
101
|
+
async close() {
|
|
102
|
+
const failures = [];
|
|
103
|
+
try {
|
|
104
|
+
await this.options.planners.close();
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
failures.push(error);
|
|
108
|
+
}
|
|
109
|
+
const unflushed = await this.options.router.close().catch((error) => { failures.push(error); return []; });
|
|
110
|
+
if (unflushed.length > 0)
|
|
111
|
+
failures.push(new Error(`booking_copilot_tenant_handoff_unflushed:${unflushed.length}`));
|
|
112
|
+
if (failures.length === 1)
|
|
113
|
+
throw failures[0];
|
|
114
|
+
if (failures.length > 1)
|
|
115
|
+
throw new AggregateError(failures, 'booking_copilot_tenancy_close_failed');
|
|
116
|
+
}
|
|
117
|
+
plannerFor(tenant) {
|
|
118
|
+
const seam = assertSeamTenant(tenant);
|
|
119
|
+
const key = seamTenantKey(seam);
|
|
120
|
+
let planner = this.planners.get(key);
|
|
121
|
+
if (!planner) {
|
|
122
|
+
const { quotas, planners, router } = this.options;
|
|
123
|
+
const organizationKey = this.options.keyring.keyFor(seam.organization);
|
|
124
|
+
if (organizationKey === null)
|
|
125
|
+
throw new UnknownOrganizationError();
|
|
126
|
+
planner = planners.forTenant({
|
|
127
|
+
tenantRoot: router.tenantRootOf(seam.organization, seam.ledgerTenant),
|
|
128
|
+
cachePartition: derivePromptCachePartition(organizationKey, seam.ledgerTenant),
|
|
129
|
+
reportUsage: (model, usage) => {
|
|
130
|
+
const meter = invocations.getStore();
|
|
131
|
+
if (!meter || meter.tenantKey !== key)
|
|
132
|
+
return;
|
|
133
|
+
try {
|
|
134
|
+
quotas.recordLlmUsage(seam, model, usage);
|
|
135
|
+
meter.reported = true;
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
// An unpriced model or malformed counts leave the invocation unproven: it is charged the worst case below.
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
}).then((inner) => (task) => this.metered(inner(task), seam, key));
|
|
142
|
+
planner.catch(() => this.planners.delete(key));
|
|
143
|
+
this.planners.set(key, planner);
|
|
144
|
+
}
|
|
145
|
+
return planner;
|
|
146
|
+
}
|
|
147
|
+
/** Wraps a planner session so that every invocation is charged: what it reported, or the worst case if it reported nothing. */
|
|
148
|
+
metered(session, tenant, key) {
|
|
149
|
+
const { quotas } = this.options;
|
|
150
|
+
return {
|
|
151
|
+
next: async (input) => {
|
|
152
|
+
const meter = { tenantKey: key, reported: false };
|
|
153
|
+
try {
|
|
154
|
+
return await invocations.run(meter, () => session.next(input));
|
|
155
|
+
}
|
|
156
|
+
finally {
|
|
157
|
+
if (!meter.reported)
|
|
158
|
+
quotas.chargeUnprovenInvocation(tenant);
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
...(session.close ? { close: () => session.close() } : {}),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
/** The typed HTTP answer for a failure raised before the response stream starts. Bodies carry codes, scopes and hints only. */
|
|
166
|
+
export function tenantRejection(error) {
|
|
167
|
+
if (error instanceof QuotaExceededError) {
|
|
168
|
+
return {
|
|
169
|
+
status: 429,
|
|
170
|
+
body: { error: { code: 'quota_exceeded', scope: error.scope, dimension: error.dimension, retryAfterSeconds: error.retryAfterSeconds } },
|
|
171
|
+
retryAfterSeconds: error.retryAfterSeconds,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
if (error instanceof InvalidTenantIdError || error instanceof InvalidPrincipalError)
|
|
175
|
+
return { status: 403, body: { error: { code: 'invalid_principal' } } };
|
|
176
|
+
if (error instanceof UnknownOrganizationError)
|
|
177
|
+
return { status: 403, body: { error: { code: 'organization_not_served' } } };
|
|
178
|
+
if (error instanceof RouterClosedError)
|
|
179
|
+
return { status: 503, body: { error: { code: 'booking_copilot_shutting_down' } } };
|
|
180
|
+
if (error instanceof TenantDrainingError)
|
|
181
|
+
return { status: 503, body: { error: { code: 'tenant_draining' } }, retryAfterSeconds: 1 };
|
|
182
|
+
// A full replica frees a slot only when some tenant is handed off, so retrying it soon is not a useful hint; the request belongs on another replica.
|
|
183
|
+
if (error instanceof ReplicaAtCapacityError)
|
|
184
|
+
return { status: 503, body: { error: { code: 'tenant_unavailable' } } };
|
|
185
|
+
if (error instanceof TenantStateError && !(error instanceof SeamError))
|
|
186
|
+
return { status: 503, body: { error: { code: 'tenant_unavailable' } }, retryAfterSeconds: 1 };
|
|
187
|
+
return { status: 500, body: { error: { code: 'tenant_seam_failure' } } };
|
|
188
|
+
}
|
package/dist/src/index.js
CHANGED
|
@@ -1766,7 +1766,9 @@ export function apply(ctx, config, seams = {}) {
|
|
|
1766
1766
|
fromStationTelecode: { type: 'string', description: '出发站三位电报码,仅 kind=train;城市码表外时填(kyfw 查询页 URL 里)' },
|
|
1767
1767
|
toStationTelecode: { type: 'string', description: '到达站三位电报码,仅 kind=train,同上' },
|
|
1768
1768
|
backDate: { type: 'string', description: '回程日期 YYYY-MM-DD,仅 kind=flight;存在即往返检索——须 ui=gui(GUI 车道站点表单原生往返;bridge 车道不知往返 URL 形状,不猜)' },
|
|
1769
|
-
ui: { type: 'string', enum: ['bridge', 'gui'], description: 'kind=flight|hotel:bridge(默认,用户登录态被动嗅探)|gui(gotry 隔离匿名窗口驱动 PC 表单——flight
|
|
1769
|
+
ui: { type: 'string', enum: ['bridge', 'gui'], description: 'kind=flight|hotel:bridge(默认,用户登录态被动嗅探)|gui(gotry 隔离匿名窗口驱动 PC 表单——flight 往返/多程/任意城市原生+低价日历;hotel 任意目的地原生;匿名公开价)' },
|
|
1770
|
+
legs: { type: 'string', description: '多程段 JSON 数组,仅 kind=flight+ui=gui:[{"from":"香港","to":"迪拜","date":"2026-12-06"},{"from":"迪拜","to":"上海","date":"2026-12-10"}](2–3 段)' },
|
|
1771
|
+
interactiveLogin: { type: 'boolean', description: '仅 ui=gui:登录墙命中时窗口停在携程官方登录页,等用户本人在窗口内完成登录(最多 6 分钟)后自动重查,拿登录态真实数据(行程明细/酒店真实价)。gotry 永不经手密码/验证码/cookie 值' },
|
|
1770
1772
|
},
|
|
1771
1773
|
output: { schema: { type: 'json' }, render: (_a, v) => [{ type: 'text', text: String(v.summary ?? JSON.stringify(v).slice(0, 600)) }] },
|
|
1772
1774
|
async execute(args, _exec) {
|
|
@@ -1849,6 +1851,7 @@ export function apply(ctx, config, seams = {}) {
|
|
|
1849
1851
|
effect: q.ui === 'gui' ? 'SESSION_HOTEL_GUI_SEARCH' : 'SESSION_HOTEL_SEARCH',
|
|
1850
1852
|
params: {
|
|
1851
1853
|
to: q.to, country: q.country, cityId: q.cityId, checkIn: q.checkIn, checkOut: q.checkOut, adults: q.adults,
|
|
1854
|
+
...(q.ui === 'gui' && q.interactiveLogin ? { interactiveLoginMs: 360_000 } : {}),
|
|
1852
1855
|
auditPath: join(config.stateRoot ?? '.', 'gotry-state', 'session-incidents.jsonl'),
|
|
1853
1856
|
},
|
|
1854
1857
|
});
|
|
@@ -1883,11 +1886,38 @@ export function apply(ctx, config, seams = {}) {
|
|
|
1883
1886
|
if (q.ui !== undefined && q.ui !== 'bridge' && q.ui !== 'gui') {
|
|
1884
1887
|
return { ok: false, summary: 'kind=flight 的 ui 只接受 bridge(默认,登录态被动嗅探)|gui(隔离匿名窗口驱动表单)' };
|
|
1885
1888
|
}
|
|
1889
|
+
// 多程段(仅 gui;严格形状闸——2–3 段、城市非空、日期 YMD、段内日期递增)
|
|
1890
|
+
let legs;
|
|
1891
|
+
if (q.legs !== undefined) {
|
|
1892
|
+
if (q.ui !== 'gui')
|
|
1893
|
+
return { ok: false, summary: 'legs(多程)仅支持 ui=gui(GUI 车道驱动多程表单)' };
|
|
1894
|
+
try {
|
|
1895
|
+
const parsed = JSON.parse(q.legs);
|
|
1896
|
+
if (!Array.isArray(parsed) || parsed.length < 2 || parsed.length > 3)
|
|
1897
|
+
throw new Error('段数须 2–3');
|
|
1898
|
+
legs = parsed.map((raw) => {
|
|
1899
|
+
const leg = raw;
|
|
1900
|
+
if (typeof leg.from !== 'string' || !leg.from.trim() || typeof leg.to !== 'string' || !leg.to.trim() || typeof leg.date !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(leg.date)) {
|
|
1901
|
+
throw new Error('每段须 {from,to,date(YYYY-MM-DD)}');
|
|
1902
|
+
}
|
|
1903
|
+
return { from: leg.from.trim(), to: leg.to.trim(), date: leg.date };
|
|
1904
|
+
});
|
|
1905
|
+
for (let i = 1; i < legs.length; i += 1) {
|
|
1906
|
+
if (legs[i].date <= legs[i - 1].date)
|
|
1907
|
+
throw new Error('段日期须递增');
|
|
1908
|
+
}
|
|
1909
|
+
}
|
|
1910
|
+
catch (e) {
|
|
1911
|
+
return { ok: false, summary: `legs 形状非法:${e.message}` };
|
|
1912
|
+
}
|
|
1913
|
+
}
|
|
1886
1914
|
const itp = await runSessionEffect({
|
|
1887
1915
|
effect: q.ui === 'gui' ? 'SESSION_FLIGHT_GUI_SEARCH' : 'SESSION_FLIGHT_SEARCH',
|
|
1888
1916
|
params: {
|
|
1889
1917
|
from: q.from, to: q.to, date: q.date, fromCityCode: q.fromCityCode, toCityCode: q.toCityCode,
|
|
1890
1918
|
...(q.backDate ? { backDate: q.backDate } : {}),
|
|
1919
|
+
...(legs ? { legs } : {}),
|
|
1920
|
+
...(q.ui === 'gui' && q.interactiveLogin ? { interactiveLoginMs: 360_000 } : {}),
|
|
1891
1921
|
// ADR-15 收尾:ReadGuard 审计在生产工具路径同样落盘(此前仅测试传隔离 stateRoot 才有 JSONL)
|
|
1892
1922
|
auditPath: join(config.stateRoot ?? '.', 'gotry-state', 'session-incidents.jsonl'),
|
|
1893
1923
|
},
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The frozen LLM price table (ADR-20) and the one function that turns measured usage into dollars.
|
|
3
|
+
*
|
|
4
|
+
* Moved verbatim from `scripts/nightly-evidence.ts`, which re-exports it, so the nightly evidence producer, the
|
|
5
|
+
* persona simulation and the AaaS quota meter all price through the same code. Prices live in
|
|
6
|
+
* `data/llm-price-table.json` and change only by PR (never auto-applied). Pricing is a peak-conservative upper bound,
|
|
7
|
+
* an unknown model has no price, and usage a provider omitted is unprovable: both fail closed and never guess.
|
|
8
|
+
*
|
|
9
|
+
* Schema evolution (issue #49): `gotry_llm_price_table_v1` is DeepSeek only, with flat fields under `prices.<model>`;
|
|
10
|
+
* `gotry_llm_price_table_v2` is `providers.<id>.models.<model>`, provider-aware, and lets a provider without an
|
|
11
|
+
* off-peak column fill it with the peak price. `loadPriceTable` reads both.
|
|
12
|
+
*/
|
|
13
|
+
import { readFileSync } from 'node:fs';
|
|
14
|
+
function round6(value) {
|
|
15
|
+
return Number(value.toFixed(6));
|
|
16
|
+
}
|
|
17
|
+
function assertPriceEntry(label, value) {
|
|
18
|
+
const entry = typeof value === 'object' && value !== null ? value : null;
|
|
19
|
+
if (!entry)
|
|
20
|
+
throw new Error(`${label} must be an object`);
|
|
21
|
+
for (const field of ['input_cache_miss_usd_per_1m_peak', 'input_cache_hit_usd_per_1m_peak', 'output_usd_per_1m_peak']) {
|
|
22
|
+
const num = entry[field];
|
|
23
|
+
if (typeof num !== 'number' || !Number.isFinite(num) || num < 0) {
|
|
24
|
+
throw new Error(`${label}.${field} must be a non-negative finite number`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return value;
|
|
28
|
+
}
|
|
29
|
+
export function loadPriceTable(path) {
|
|
30
|
+
const raw = JSON.parse(readFileSync(path, 'utf8'));
|
|
31
|
+
const schemaVersion = raw['schema_version'];
|
|
32
|
+
if (schemaVersion === 'gotry_llm_price_table_v2') {
|
|
33
|
+
return loadPriceTableV2(raw);
|
|
34
|
+
}
|
|
35
|
+
if (schemaVersion === 'gotry_llm_price_table_v1') {
|
|
36
|
+
return loadPriceTableV1(raw);
|
|
37
|
+
}
|
|
38
|
+
throw new Error(`price table schema_version must be gotry_llm_price_table_v1 or _v2, got: ${String(schemaVersion)}`);
|
|
39
|
+
}
|
|
40
|
+
function loadPriceTableV1(raw) {
|
|
41
|
+
const aliases = typeof raw['aliases'] === 'object' && raw['aliases'] !== null ? raw['aliases'] : {};
|
|
42
|
+
for (const [from, to] of Object.entries(aliases)) {
|
|
43
|
+
if (typeof to !== 'string')
|
|
44
|
+
throw new Error(`price table alias ${from} must map to a model name`);
|
|
45
|
+
}
|
|
46
|
+
const prices = {};
|
|
47
|
+
for (const [name, entry] of Object.entries(typeof raw['prices'] === 'object' && raw['prices'] !== null ? raw['prices'] : {})) {
|
|
48
|
+
prices[name] = assertPriceEntry(`price table entry ${name}`, entry);
|
|
49
|
+
}
|
|
50
|
+
return { schema_version: 'gotry_llm_price_table_v1', aliases, prices };
|
|
51
|
+
}
|
|
52
|
+
/** v2 reader: providers.<id>.models.<model> flattened into one aliases/prices pair, so the exposed API does not change.
|
|
53
|
+
* Lookup for a model name:
|
|
54
|
+
* 1. an alias jumps to its target (qualified `providerId:model`);
|
|
55
|
+
* 2. otherwise the qualified `providerId:model` key is looked up in prices;
|
|
56
|
+
* 3. otherwise the bare `model` key (transparent with one provider, e.g. MiniMax-M3 is unique under minimax:models);
|
|
57
|
+
* 4. still not found: fail closed. */
|
|
58
|
+
function loadPriceTableV2(raw) {
|
|
59
|
+
const providersObj = typeof raw['providers'] === 'object' && raw['providers'] !== null ? raw['providers'] : null;
|
|
60
|
+
if (!providersObj)
|
|
61
|
+
throw new Error('price table v2 requires providers object');
|
|
62
|
+
const aliases = {};
|
|
63
|
+
const prices = {};
|
|
64
|
+
for (const [providerId, rawProvider] of Object.entries(providersObj)) {
|
|
65
|
+
const provider = assertProviderEntry(`price table provider ${providerId}`, rawProvider);
|
|
66
|
+
for (const [aliasName, target] of Object.entries(provider.aliases)) {
|
|
67
|
+
if (typeof target !== 'string')
|
|
68
|
+
throw new Error(`provider ${providerId} alias ${aliasName} must map to a model name`);
|
|
69
|
+
if (!provider.models[target]) {
|
|
70
|
+
throw new Error(`provider ${providerId} alias ${aliasName} -> ${target} has no entry; update data/llm-price-table.json via PR`);
|
|
71
|
+
}
|
|
72
|
+
aliases[aliasName] = target;
|
|
73
|
+
}
|
|
74
|
+
for (const [modelName, entry] of Object.entries(provider.models)) {
|
|
75
|
+
const qualified = `${providerId}:${modelName}`;
|
|
76
|
+
const validated = assertPriceEntry(`price table provider ${providerId} model ${modelName}`, entry);
|
|
77
|
+
if (prices[qualified])
|
|
78
|
+
throw new Error(`duplicate price table key ${qualified}`);
|
|
79
|
+
prices[qualified] = validated;
|
|
80
|
+
if (prices[modelName])
|
|
81
|
+
throw new Error(`duplicate price table key ${modelName} (across providers, disambiguate via aliases or qualified lookup)`);
|
|
82
|
+
prices[modelName] = validated;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return { schema_version: 'gotry_llm_price_table_v2', aliases, prices };
|
|
86
|
+
}
|
|
87
|
+
function assertProviderEntry(label, value) {
|
|
88
|
+
const entry = typeof value === 'object' && value !== null ? value : null;
|
|
89
|
+
if (!entry)
|
|
90
|
+
throw new Error(`${label} must be an object`);
|
|
91
|
+
const family = entry['family'];
|
|
92
|
+
if (typeof family !== 'string' || family.length === 0)
|
|
93
|
+
throw new Error(`${label}.family must be a non-empty string`);
|
|
94
|
+
const strategy = entry['price_strategy'];
|
|
95
|
+
if (strategy !== 'tiered_peak_offpeak' && strategy !== 'flat_no_offpeak') {
|
|
96
|
+
throw new Error(`${label}.price_strategy must be 'tiered_peak_offpeak' or 'flat_no_offpeak', got: ${String(strategy)}`);
|
|
97
|
+
}
|
|
98
|
+
const aliases = typeof entry['aliases'] === 'object' && entry['aliases'] !== null ? entry['aliases'] : {};
|
|
99
|
+
const models = typeof entry['models'] === 'object' && entry['models'] !== null ? entry['models'] : {};
|
|
100
|
+
return {
|
|
101
|
+
family,
|
|
102
|
+
price_strategy: strategy,
|
|
103
|
+
api_base: typeof entry['api_base'] === 'string' ? entry['api_base'] : undefined,
|
|
104
|
+
source_url: typeof entry['source_url'] === 'string' ? entry['source_url'] : undefined,
|
|
105
|
+
aliases,
|
|
106
|
+
models,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
/** Dollars for one run's measured usage (peak-conservative upper bound: the table's peak columns, never a guess). */
|
|
110
|
+
export function priceRunCost(model, usage, table) {
|
|
111
|
+
if (usage.responsesMissingUsage > 0) {
|
|
112
|
+
throw new Error(`usage provider omitted usage on ${usage.responsesMissingUsage} response(s) — cost unprovable, refusing to price (fail-closed)`);
|
|
113
|
+
}
|
|
114
|
+
const alias = table.aliases[model];
|
|
115
|
+
if (alias && !table.prices[alias])
|
|
116
|
+
throw new Error(`price table alias ${model} -> ${alias} has no entry; update data/llm-price-table.json via PR`);
|
|
117
|
+
const entry = table.prices[alias ?? model];
|
|
118
|
+
if (!entry)
|
|
119
|
+
throw new Error(`price table has no entry for model ${model}; update data/llm-price-table.json via PR (fail-closed, no guessed price)`);
|
|
120
|
+
const cost = (usage.inputCacheMissTokens * entry.input_cache_miss_usd_per_1m_peak
|
|
121
|
+
+ usage.inputCacheHitTokens * entry.input_cache_hit_usd_per_1m_peak
|
|
122
|
+
+ usage.outputTokens * entry.output_usd_per_1m_peak) / 1_000_000;
|
|
123
|
+
return round6(cost);
|
|
124
|
+
}
|
|
125
|
+
function nonNegativeInteger(name, value) {
|
|
126
|
+
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0)
|
|
127
|
+
throw new RangeError(`${name} must be a non-negative integer`);
|
|
128
|
+
return value;
|
|
129
|
+
}
|
|
130
|
+
/** `priceRunCost` for one measured usage report. Malformed counts are rejected rather than priced. */
|
|
131
|
+
export function priceMeasuredUsage(model, usage, table) {
|
|
132
|
+
const inputTokens = nonNegativeInteger('inputTokens', usage.inputTokens);
|
|
133
|
+
const outputTokens = nonNegativeInteger('outputTokens', usage.outputTokens);
|
|
134
|
+
const cacheReadTokens = nonNegativeInteger('cacheReadTokens', usage.cacheReadTokens ?? 0);
|
|
135
|
+
const costUsd = priceRunCost(model, {
|
|
136
|
+
calls: 1,
|
|
137
|
+
inputTokens: inputTokens + cacheReadTokens,
|
|
138
|
+
outputTokens,
|
|
139
|
+
inputCacheHitTokens: cacheReadTokens,
|
|
140
|
+
inputCacheMissTokens: inputTokens,
|
|
141
|
+
responsesMissingUsage: 0,
|
|
142
|
+
}, table);
|
|
143
|
+
return { tokens: inputTokens + cacheReadTokens + outputTokens, costUsd };
|
|
144
|
+
}
|
|
145
|
+
/** Throws unless the table prices `model`. Used at composition time so an unpriced model stops the service before any spend. */
|
|
146
|
+
export function assertModelPriced(model, table) {
|
|
147
|
+
priceRunCost(model, { calls: 0, inputTokens: 0, outputTokens: 0, inputCacheHitTokens: 0, inputCacheMissTokens: 0, responsesMissingUsage: 0 }, table);
|
|
148
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The trusted seam of the AaaS line (docs/rfc/aaas-rfc.md §4.4): tenant derivation from the authenticated caller, and
|
|
3
|
+
* the quotas and metering that keep one tenant or organization from moving another's latency budget. Outside the kernel.
|
|
4
|
+
*/
|
|
5
|
+
export * from './quotas.js';
|
|
6
|
+
export * from './seam.js';
|
|
7
|
+
export * from './llm-broker.js';
|
|
8
|
+
export * from './process-governor.js';
|