@danceiny/gotry 0.2.0-rc.31 → 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/bin/gotry-bootstrap.js +63 -53
- package/bin/gotry-hbcli-release.d.ts +3 -0
- package/bin/gotry-hbcli-release.js +4 -0
- package/client/client.js +66 -21
- package/dist/capabilities/doctor.js +7 -8
- package/dist/capabilities/effect.js +45 -12
- package/dist/capabilities/flyai-config.js +12 -10
- package/dist/capabilities/flyai.js +87 -56
- package/dist/capabilities/hbcli-config.js +156 -0
- package/dist/capabilities/hbcli.js +29 -2
- package/dist/capabilities/resilience.js +6 -6
- package/dist/capabilities/session/adapters/ctrip-flight.js +42 -7
- package/dist/capabilities/session/adapters/ctrip-hotel.js +52 -3
- package/dist/capabilities/session/bridge-ledger.js +168 -67
- package/dist/capabilities/session/ctrip-city.js +186 -0
- package/dist/capabilities/session/extension-bridge.js +148 -47
- package/dist/capabilities/session/gui-flight.js +949 -0
- package/dist/capabilities/session/gui-hotel.js +470 -0
- package/dist/capabilities/session-search.js +102 -14
- 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/bootstrap-tests.js +23 -6
- package/dist/scripts/bridge-ledger-tests.js +20 -18
- package/dist/scripts/ctrip-city-tests.js +80 -0
- package/dist/scripts/effect-tests.js +602 -504
- package/dist/scripts/extension-tests.js +14 -8
- package/dist/scripts/fixtures/tenant-runaway-worker-fixture.mjs +73 -0
- package/dist/scripts/flight-malformed-tests.js +1 -1
- package/dist/scripts/flyai-setup-tests.js +19 -4
- package/dist/scripts/flyai-tests.js +768 -653
- package/dist/scripts/gotry-web-api-tests.js +5 -0
- package/dist/scripts/gotry-web-client-tests.js +20 -1
- package/dist/scripts/gui-flight-live.js +27 -0
- package/dist/scripts/hbcli-tests.js +1 -1
- package/dist/scripts/hbcli-upgrade-tests.js +107 -0
- package/dist/scripts/hbcli-web-setup-tests.js +114 -0
- package/dist/scripts/hotel-date-gate-tests.js +1 -1
- package/dist/scripts/hotel-result-tests.js +169 -0
- package/dist/scripts/issue-271-liveness-tests.js +1 -1
- package/dist/scripts/mutation-check.js +153 -0
- package/dist/scripts/nightly-evidence.js +4 -111
- package/dist/scripts/session-tests.js +211 -6
- 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/gotry-web-api.js +64 -2
- package/dist/src/index.js +70 -18
- 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 +5 -1
- package/ts/capabilities/flyai-config.ts +12 -10
- package/ts/capabilities/flyai.ts +91 -50
- package/ts/capabilities/hbcli-config.ts +128 -0
- package/ts/capabilities/hbcli.ts +30 -3
- package/ts/capabilities/session/adapters/ctrip-flight.ts +43 -7
- package/ts/capabilities/session/adapters/ctrip-hotel.ts +51 -4
- package/ts/capabilities/session/bridge-ledger.ts +192 -78
- package/ts/capabilities/session/ctrip-city.ts +158 -0
- package/ts/capabilities/session/extension-bridge.ts +185 -67
- package/ts/capabilities/session/gui-flight.ts +1022 -0
- package/ts/capabilities/session/gui-hotel.ts +505 -0
- package/ts/capabilities/session-search.ts +130 -18
- 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/gotry-web-api.ts +51 -2
- package/ts/src/index.ts +67 -19
- 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
|
+
}
|
|
@@ -4,6 +4,7 @@ import { isAbsolute } from 'node:path';
|
|
|
4
4
|
import { verifyFlyaiCandidate } from '../../bin/gotry-flyai-verification.js';
|
|
5
5
|
import { listArtifacts } from '../capabilities/artifacts.js';
|
|
6
6
|
import { atomicWriteJson, clearFlyaiKey, displayEndpoint, endpointFingerprint, flyaiConfigPath, isPlaceholderFlyaiKey, maskFlyaiKey, readFlyaiConfig, readFlyaiVerification, removeFlyaiVerification, resolveFlyaiEndpoint, resolveFlyaiKey, saveFlyaiKey, sha256Hex, writeFlyaiVerification, } from '../capabilities/flyai-config.js';
|
|
7
|
+
import { hbcliCredentialPath, hbcliSetupStatus, readHbcliCredentials, removeHbcliVerification, verifyHbcliCandidate, withHbcliCredentials, withoutHbcliCredentials, writeHbcliCredentials, writeHbcliVerification, } from '../capabilities/hbcli-config.js';
|
|
7
8
|
const json = (value, status = 200) => Response.json(value, { status, headers: { 'cache-control': 'no-store' } });
|
|
8
9
|
const rejection = (error, status = 400) => json({ ok: false, error }, status);
|
|
9
10
|
const BODY_LIMIT = 8192;
|
|
@@ -40,6 +41,7 @@ async function readInput(request) {
|
|
|
40
41
|
export function createGotryWebHandlers(options) {
|
|
41
42
|
const homeDir = options.homeDir ?? homedir(), env = options.env ?? process.env;
|
|
42
43
|
const paths = { homeDir, env }, configPath = flyaiConfigPath(homeDir);
|
|
44
|
+
const hbPaths = { homeDir, env, hbcliBin: options.hbcliBin };
|
|
43
45
|
let mutations = Promise.resolve();
|
|
44
46
|
const status = () => {
|
|
45
47
|
const current = resolveFlyaiKey(paths), endpoint = resolveFlyaiEndpoint(paths), receipt = readFlyaiVerification(homeDir);
|
|
@@ -99,7 +101,65 @@ export function createGotryWebHandlers(options) {
|
|
|
99
101
|
}
|
|
100
102
|
return json(status());
|
|
101
103
|
}
|
|
104
|
+
async function mutateHbcli(input, signal) {
|
|
105
|
+
if (signal.aborted)
|
|
106
|
+
return rejection('操作已取消。', 409);
|
|
107
|
+
if (!hbcliSetupStatus(hbPaths).writable)
|
|
108
|
+
return rejection('当前凭证来自环境变量,或本机配置不可安全写入。', 409);
|
|
109
|
+
const before = readHbcliCredentials(hbPaths);
|
|
110
|
+
if (!before.ok)
|
|
111
|
+
return rejection('原配置损坏,已保留原文件,请先修复。', 409);
|
|
112
|
+
if (input.action === 'clear') {
|
|
113
|
+
if (!writeHbcliCredentials(withoutHbcliCredentials(before.data), hbPaths))
|
|
114
|
+
return rejection('无法清除配置,请检查本机文件及权限。', 409);
|
|
115
|
+
removeHbcliVerification(homeDir);
|
|
116
|
+
return json(hbcliSetupStatus(hbPaths));
|
|
117
|
+
}
|
|
118
|
+
const appKey = typeof input.appKey === 'string' ? input.appKey.trim() : '';
|
|
119
|
+
const appSecret = typeof input.appSecret === 'string' ? input.appSecret.trim() : '';
|
|
120
|
+
if ([appKey, appSecret].some(value => !value || value.length > 2048 || /[\r\n\0]/.test(value)))
|
|
121
|
+
return rejection('请填写 HotelByte 提供的 App Key 和 App Secret。');
|
|
122
|
+
const verified = await verifyHbcliCandidate(appKey, appSecret, hbPaths, signal);
|
|
123
|
+
if (signal.aborted || verified.verdict === 'error' || verified.verdict === 'cancelled') {
|
|
124
|
+
return json({ ok: false, error: verified.error ?? '验证已取消,旧配置保留。' }, 422);
|
|
125
|
+
}
|
|
126
|
+
const current = readHbcliCredentials(hbPaths);
|
|
127
|
+
if (!hbcliSetupStatus(hbPaths).writable || !current.ok || current.bytes !== before.bytes)
|
|
128
|
+
return rejection('配置在验证期间发生变化,请重新读取后再试。', 409);
|
|
129
|
+
const next = withHbcliCredentials(before.data, appKey, appSecret);
|
|
130
|
+
if (!writeHbcliCredentials(next, hbPaths))
|
|
131
|
+
return rejection('安全保存失败,旧配置保留,请检查本机权限。', 409);
|
|
132
|
+
if (!writeHbcliVerification(next, verified.verdict, hbPaths)) {
|
|
133
|
+
const restored = before.bytes === undefined
|
|
134
|
+
? (() => { try {
|
|
135
|
+
rmSync(hbcliCredentialPath(hbPaths));
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return false;
|
|
140
|
+
} })()
|
|
141
|
+
: writeHbcliCredentials(before.data, hbPaths);
|
|
142
|
+
return rejection(restored ? '验证回执保存失败,旧配置已恢复。' : '验证回执保存失败,请检查本机文件。', 409);
|
|
143
|
+
}
|
|
144
|
+
return json(hbcliSetupStatus(hbPaths));
|
|
145
|
+
}
|
|
102
146
|
return {
|
|
147
|
+
async hbcli(request) {
|
|
148
|
+
if (request.method === 'GET')
|
|
149
|
+
return json(hbcliSetupStatus(hbPaths));
|
|
150
|
+
if (request.method !== 'POST')
|
|
151
|
+
return rejection('不支持此操作。', 405);
|
|
152
|
+
const input = await readInput(request);
|
|
153
|
+
if (input instanceof Response)
|
|
154
|
+
return input;
|
|
155
|
+
if (!['save', 'clear'].includes(String(input.action))
|
|
156
|
+
|| Object.keys(input).some(key => !['action', 'appKey', 'appSecret'].includes(key))
|
|
157
|
+
|| (input.action === 'clear' && ('appKey' in input || 'appSecret' in input)))
|
|
158
|
+
return rejection('请求无效。');
|
|
159
|
+
const operation = mutations.then(() => mutateHbcli(input, request.signal)).catch(() => rejection('操作失败,未能确认保存,请重新读取状态。', 500));
|
|
160
|
+
mutations = operation.then(() => undefined);
|
|
161
|
+
return operation;
|
|
162
|
+
},
|
|
103
163
|
async flyai(request) {
|
|
104
164
|
if (request.method === 'GET')
|
|
105
165
|
return json(status());
|
|
@@ -137,12 +197,12 @@ export function createGotryWebHandlers(options) {
|
|
|
137
197
|
},
|
|
138
198
|
};
|
|
139
199
|
}
|
|
140
|
-
export function registerGotryWebApi(ctx, stateRoot) {
|
|
200
|
+
export function registerGotryWebApi(ctx, stateRoot, hbcliBin) {
|
|
141
201
|
if (typeof ctx.inject !== 'function')
|
|
142
202
|
return;
|
|
143
203
|
ctx.inject(['connection', 'sessions'], scope => {
|
|
144
204
|
const connection = scope.get('connection');
|
|
145
|
-
const handlers = createGotryWebHandlers({ stateRoot, workspaceForSession: async (sessionId) => {
|
|
205
|
+
const handlers = createGotryWebHandlers({ stateRoot, hbcliBin, workspaceForSession: async (sessionId) => {
|
|
146
206
|
const id = sessionId;
|
|
147
207
|
const live = scope.sessions.get(id)?.header;
|
|
148
208
|
const stored = live ? undefined : await scope.get('sessionPersistence')?.stat(sessionId);
|
|
@@ -152,6 +212,8 @@ export function registerGotryWebApi(ctx, stateRoot) {
|
|
|
152
212
|
// it. Exact routes have one body mode, so reads and bounded writes differ.
|
|
153
213
|
scope.effect(() => connection.fetch.register({ path: '/api/gotry/flyai', methods: ['GET'], requestBody: 'buffered', fetch: handlers.flyai }));
|
|
154
214
|
scope.effect(() => connection.fetch.register({ path: '/api/gotry/flyai/write', methods: ['POST'], requestBody: 'streaming', fetch: handlers.flyai }));
|
|
215
|
+
scope.effect(() => connection.fetch.register({ path: '/api/gotry/hbcli', methods: ['GET'], requestBody: 'buffered', fetch: handlers.hbcli }));
|
|
216
|
+
scope.effect(() => connection.fetch.register({ path: '/api/gotry/hbcli/write', methods: ['POST'], requestBody: 'streaming', fetch: handlers.hbcli }));
|
|
155
217
|
scope.effect(() => connection.fetch.register({ path: '/api/gotry/artifacts', methods: ['GET'], requestBody: 'buffered', fetch: handlers.artifacts }));
|
|
156
218
|
});
|
|
157
219
|
}
|
package/dist/src/index.js
CHANGED
|
@@ -432,7 +432,7 @@ export function apply(ctx, config, seams = {}) {
|
|
|
432
432
|
// 派生的后台规划会话:唯一出口 converge + 长 leash——前台转后台,后台
|
|
433
433
|
// 必须产出最终交付物,不得再次 handoff(否则无限递归)。
|
|
434
434
|
if (!rawBenchmarkEnvironmentConfigPath) {
|
|
435
|
-
registerGotryWebApi(ctx, config.stateRoot ?? '.');
|
|
435
|
+
registerGotryWebApi(ctx, config.stateRoot ?? '.', config.hbcliBin);
|
|
436
436
|
registerArtifactDeliveries(ctx);
|
|
437
437
|
}
|
|
438
438
|
const handoffChild = process.env.GOTRY_HANDOFF_CHILD === '1';
|
|
@@ -1305,10 +1305,15 @@ export function apply(ctx, config, seams = {}) {
|
|
|
1305
1305
|
const resp = itp.result;
|
|
1306
1306
|
const dir = await ensureStateDir(config.stateRoot);
|
|
1307
1307
|
const isLive = resp.via === 'hbcli-realtime';
|
|
1308
|
-
const
|
|
1308
|
+
const staticStays = resp.hotels?.stays;
|
|
1309
|
+
const hasEstimate = Array.isArray(staticStays) && staticStays.length > 0;
|
|
1310
|
+
const liveList = resp.hotels?.list;
|
|
1311
|
+
const verdict = isLive ? (liveList?.length ? 'hit' : 'miss') : hasEstimate ? 'estimated' : 'error';
|
|
1312
|
+
const evidence = hasEstimate ? '[静态包:估算]' : resp.evidence;
|
|
1309
1313
|
await recordLatency(join(dir, 'bridge-latency.jsonl'), Date.now() - started, `hotel_search:${resp.via}`).catch(() => { });
|
|
1310
1314
|
const payload = {
|
|
1311
|
-
ok:
|
|
1315
|
+
ok: isLive || hasEstimate,
|
|
1316
|
+
verdict,
|
|
1312
1317
|
hotels: resp.hotels ?? null,
|
|
1313
1318
|
evidence,
|
|
1314
1319
|
destination: q.destination,
|
|
@@ -1316,6 +1321,7 @@ export function apply(ctx, config, seams = {}) {
|
|
|
1316
1321
|
latency_ms: Date.now() - started,
|
|
1317
1322
|
summary: resp.summary,
|
|
1318
1323
|
error: resp.error,
|
|
1324
|
+
...(verdict !== 'hit' ? await routingField('search-hotel', 'hbcli-hotel') : {}),
|
|
1319
1325
|
...(dateNotes.length ? { date_notes: dateNotes } : {}),
|
|
1320
1326
|
};
|
|
1321
1327
|
return JSON.parse(JSON.stringify(payload));
|
|
@@ -1342,13 +1348,14 @@ export function apply(ctx, config, seams = {}) {
|
|
|
1342
1348
|
};
|
|
1343
1349
|
}
|
|
1344
1350
|
const h = r.hotels;
|
|
1345
|
-
const
|
|
1351
|
+
const liveList = h?.list;
|
|
1352
|
+
const liveCount = Array.isArray(liveList) ? liveList.length : 0;
|
|
1346
1353
|
// 静态包是 {meta, stays} 对象而非数组(issue #24:此前计数恒 0 → UI 显示「无结果」)
|
|
1347
1354
|
const stays = !Array.isArray(h) && h && typeof h === 'object' ? h.stays : undefined;
|
|
1348
1355
|
const staticCount = Array.isArray(stays) ? stays.length : 0;
|
|
1349
1356
|
const tag = r.via === 'hbcli-realtime'
|
|
1350
|
-
?
|
|
1351
|
-
: staticCount ? `静态包 ${staticCount} 块` :
|
|
1357
|
+
? `实时 ${liveCount} 家`
|
|
1358
|
+
: staticCount ? `静态包 ${staticCount} 块` : '查询未完成';
|
|
1352
1359
|
return {
|
|
1353
1360
|
card: 'generic',
|
|
1354
1361
|
title: `酒店:${r.destination ?? ''} ${tag}`,
|
|
@@ -1731,8 +1738,9 @@ export function apply(ctx, config, seams = {}) {
|
|
|
1731
1738
|
description: routed('gotry_session_search', 'Search on the USER\'S OWN browser session (Ctrip kind="flight"(default)/"hotel"; 12306 kind="train" — public query face, no login needed). '
|
|
1732
1739
|
+ 'Consent gate: the FIRST call in a session asks the user via the runtime approval card; once granted it holds for the session, a refusal revokes it for the session (no repeat prompting). '
|
|
1733
1740
|
+ 'Transport: Stai Travel Bridge browser extension (one-time install) — the agent side never talks to Chrome debugging, ZERO system dialogs; read-only by construction (the extension never issues requests; it only passively forwards the site\'s own search responses; agent NEVER touches credentials/captcha; on captcha it stops and returns challenged). '
|
|
1734
|
-
+ 'kind="flight": from/to
|
|
1735
|
-
+ '
|
|
1741
|
+
+ 'kind="flight": from/to city or airport names (Chinese/English) + date YYYY-MM-DD; unknown names resolve from supplier metadata, ambiguous names require confirmation; optional fromCityCode/toCityCode are explicit Ctrip three-letter codes — sniffs the site search API for structured options. '
|
|
1742
|
+
+ 'ui?="bridge" (default; the user\'s own logged-in session, real member prices) | "gui" (gotry opens its OWN isolated anonymous Chrome window and drives the Ctrip PC search form — roundtrip via backDate? and ANY city by construction: the site\'s own autocomplete resolves cities; anonymous public prices, NOT logged-in prices; verdicts carry anonymous=true; result-binding iteration tracked in #661). Evidence [会话:ctrip-flight@ts] / [会话:ctrip-flight-gui@ts]. '
|
|
1743
|
+
+ 'kind="hotel": to=destination name, country? for same-name disambiguation; cityId? = explicit numeric city= from a Ctrip hotel list URL; otherwise resolves from the hotel destination service, checkIn?/checkOut? (YYYY-MM-DD), adults?; hotel prices are the user\'s real logged-in prices. Evidence [会话:ctrip-hotel@ts]. '
|
|
1736
1744
|
+ 'kind="dida": Dida supplier-portal realtime hotel rates on the employee\'s own logged-in session (hotel-be portal integration line) — no from/to needed; the portal find page\'s own requests are sniffed passively. Rates carry ratePlanId/referenceNo for the server-side booking chain. Evidence [会话:dida-portal@ts]. '
|
|
1737
1745
|
+ 'kind="train": from/to/date(YYYY-MM-DD) + fromStationTelecode?/toStationTelecode? (three-letter codes in the kyfw query URL, for cities outside the built-in table) — 12306 left-ticket query (public face): train codes, times, durations, seat availability; the list API carries NO prices (prices live on the 12306 page). Evidence [会话:train-12306@ts]. '
|
|
1738
1746
|
+ 'verdict needs-login = call gotry_session_login (opens the Ctrip login entry in the user\'s own foreground tab — no terminal, no credentials through GoTry); '
|
|
@@ -1745,15 +1753,22 @@ export function apply(ctx, config, seams = {}) {
|
|
|
1745
1753
|
// execute 内归一后走原条件闸,结构化报错不崩。flyai 因 kind required 仍在宿主权即拒。
|
|
1746
1754
|
parameters: {
|
|
1747
1755
|
kind: { type: 'string', enum: ['flight', 'hotel', 'train', 'dida'], description: '默认 flight 机票;hotel 携程酒店(用户登录态真实价);train 12306 余票(公开面);dida 供应商门户实时价(员工登录态)' },
|
|
1748
|
-
from: { type: 'string', description: '
|
|
1756
|
+
from: { type: 'string', description: '出发城市名(机票支持中英文及机场名,自动解析),如 上海——kind=flight|train 必填' },
|
|
1749
1757
|
to: { type: 'string', description: '到达城市中文——kind=flight|train 必填;kind=hotel 时为目的地(必填)' },
|
|
1750
1758
|
date: { type: 'string', description: '出发日期 YYYY-MM-DD——kind=flight|train 必填,须为今天或未来' },
|
|
1751
|
-
|
|
1759
|
+
fromCityCode: { type: 'string', description: '显式携程出发城市/机场三字码,仅 kind=flight;同名城市确认后使用' },
|
|
1760
|
+
toCityCode: { type: 'string', description: '显式携程到达城市/机场三字码,仅 kind=flight;同名城市确认后使用' },
|
|
1761
|
+
cityId: { type: 'integer', description: '显式携程酒店 city= 数字,仅 kind=hotel;未提供时自动解析酒店目的地,不能使用机票城市码' },
|
|
1762
|
+
country: { type: 'string', description: '目的地国家中英文,仅 kind=hotel;用于同名城市消歧,例如 阿联酋' },
|
|
1752
1763
|
checkIn: { type: 'string', description: '入住日 YYYY-MM-DD,仅 kind=hotel 可选' },
|
|
1753
1764
|
checkOut: { type: 'string', description: '退房日 YYYY-MM-DD,仅 kind=hotel 可选' },
|
|
1754
1765
|
adults: { type: 'integer', description: '成人数,仅 kind=hotel 可选' },
|
|
1755
1766
|
fromStationTelecode: { type: 'string', description: '出发站三位电报码,仅 kind=train;城市码表外时填(kyfw 查询页 URL 里)' },
|
|
1756
1767
|
toStationTelecode: { type: 'string', description: '到达站三位电报码,仅 kind=train,同上' },
|
|
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 往返/多程/任意城市原生+低价日历;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 值' },
|
|
1757
1772
|
},
|
|
1758
1773
|
output: { schema: { type: 'json' }, render: (_a, v) => [{ type: 'text', text: String(v.summary ?? JSON.stringify(v).slice(0, 600)) }] },
|
|
1759
1774
|
async execute(args, _exec) {
|
|
@@ -1830,17 +1845,18 @@ export function apply(ctx, config, seams = {}) {
|
|
|
1830
1845
|
}
|
|
1831
1846
|
if (q.kind === 'hotel') {
|
|
1832
1847
|
if (!q.to) {
|
|
1833
|
-
return { ok: false, summary: 'kind=hotel 需要 to
|
|
1848
|
+
return { ok: false, summary: 'kind=hotel 需要 to(目的地名称);自动解析城市,同名城市可带 country 或确认后的 cityId' };
|
|
1834
1849
|
}
|
|
1835
1850
|
const itp = await runSessionEffect({
|
|
1836
|
-
effect: 'SESSION_HOTEL_SEARCH',
|
|
1851
|
+
effect: q.ui === 'gui' ? 'SESSION_HOTEL_GUI_SEARCH' : 'SESSION_HOTEL_SEARCH',
|
|
1837
1852
|
params: {
|
|
1838
|
-
to: q.to, cityId: q.cityId, checkIn: q.checkIn, checkOut: q.checkOut, adults: q.adults,
|
|
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 } : {}),
|
|
1839
1855
|
auditPath: join(config.stateRoot ?? '.', 'gotry-state', 'session-incidents.jsonl'),
|
|
1840
1856
|
},
|
|
1841
1857
|
});
|
|
1842
1858
|
if (!itp.result)
|
|
1843
|
-
return declinedObservation('SESSION_HOTEL_SEARCH', itp.trace);
|
|
1859
|
+
return declinedObservation(q.ui === 'gui' ? 'SESSION_HOTEL_GUI_SEARCH' : 'SESSION_HOTEL_SEARCH', itp.trace);
|
|
1844
1860
|
const r = itp.result;
|
|
1845
1861
|
await noteChannel('session:ctrip-hotel', r.verdict);
|
|
1846
1862
|
// D-26(issue #118):exact-date 会话酒店 hit/miss 落账(摸底无档期不落;cooldown 是节律闸非结论,不落)
|
|
@@ -1862,14 +1878,46 @@ export function apply(ctx, config, seams = {}) {
|
|
|
1862
1878
|
}));
|
|
1863
1879
|
}
|
|
1864
1880
|
if (!q.from || !q.to || !q.date) {
|
|
1865
|
-
return { ok: false, summary: '需要 from/to
|
|
1881
|
+
return { ok: false, summary: '需要 from/to(城市或机场名)与 date(YYYY-MM-DD);酒店/火车检索分别用 kind=hotel / kind=train' };
|
|
1866
1882
|
}
|
|
1867
1883
|
// 效应解译层(ADR-18):SESSION 通道策略=永不重试/不熔断,节律闸在渠道内;
|
|
1868
1884
|
// 解译器只做分发与证据拼装,verdict 语义(risk 型 needs-login/challenged)原样透传
|
|
1885
|
+
// GUI 车道(2026-10-14):ui=gui → gotry 隔离匿名窗口驱动 PC 表单(往返 backDate/任意城市原生)。
|
|
1886
|
+
if (q.ui !== undefined && q.ui !== 'bridge' && q.ui !== 'gui') {
|
|
1887
|
+
return { ok: false, summary: 'kind=flight 的 ui 只接受 bridge(默认,登录态被动嗅探)|gui(隔离匿名窗口驱动表单)' };
|
|
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
|
+
}
|
|
1869
1914
|
const itp = await runSessionEffect({
|
|
1870
|
-
effect: 'SESSION_FLIGHT_SEARCH',
|
|
1915
|
+
effect: q.ui === 'gui' ? 'SESSION_FLIGHT_GUI_SEARCH' : 'SESSION_FLIGHT_SEARCH',
|
|
1871
1916
|
params: {
|
|
1872
|
-
from: q.from, to: q.to, date: q.date,
|
|
1917
|
+
from: q.from, to: q.to, date: q.date, fromCityCode: q.fromCityCode, toCityCode: q.toCityCode,
|
|
1918
|
+
...(q.backDate ? { backDate: q.backDate } : {}),
|
|
1919
|
+
...(legs ? { legs } : {}),
|
|
1920
|
+
...(q.ui === 'gui' && q.interactiveLogin ? { interactiveLoginMs: 360_000 } : {}),
|
|
1873
1921
|
// ADR-15 收尾:ReadGuard 审计在生产工具路径同样落盘(此前仅测试传隔离 stateRoot 才有 JSONL)
|
|
1874
1922
|
auditPath: join(config.stateRoot ?? '.', 'gotry-state', 'session-incidents.jsonl'),
|
|
1875
1923
|
},
|
|
@@ -1882,8 +1930,12 @@ export function apply(ctx, config, seams = {}) {
|
|
|
1882
1930
|
const avMapS = await loadAirlineAirportMap();
|
|
1883
1931
|
await appendFacts(config.stateRoot ?? '.', factsFromSession({ origin: q.from, destination: q.to, date: q.date }, r, new Date().toISOString(), avMapS?.city_alias));
|
|
1884
1932
|
const top = (r.options ?? []).slice(0, 8).map(o => `${o.flightNo} ${o.airline} ${o.depDateTime.slice(11, 16)}→${o.arrDateTime.slice(11, 16)} ¥${o.price}`);
|
|
1933
|
+
const lane = q.ui === 'gui' ? `GUI 车道(携程,匿名公开价${q.backDate ? ',往返' : ''})` : '会话检索(携程,用户本人登录态)';
|
|
1934
|
+
const cal = (r.calendar ?? []).slice(0, 14).map(d => `${d.date} ${d.price !== undefined ? `¥${d.price}` : (d.priceRaw ?? '?')}`);
|
|
1935
|
+
const calBlock = cal.length > 0 ? `\n低价日历(匿名公开价,该航线哪些日期有票):\n${cal.join(' | ')}` : '';
|
|
1936
|
+
const urlBlock = q.ui === 'gui' && r.listUrl ? `\nlistUrl(站点真实形状,#663 URL 教师):${r.listUrl.slice(0, 160)}` : '';
|
|
1885
1937
|
const summary = r.verdict === 'hit'
|
|
1886
|
-
? `${q.from}→${q.to} ${q.date}
|
|
1938
|
+
? `${q.from}→${q.to} ${q.date} ${lane}${(r.options ?? []).length > 0 ? ` 行情前 ${top.length} 条:\n${top.join('\n')}` : ''}${calBlock}${urlBlock}\n${r.evidence}`
|
|
1887
1939
|
: `会话检索未取回(${r.verdict}):${r.error ?? ''} ${r.evidence}`;
|
|
1888
1940
|
return JSON.parse(JSON.stringify({
|
|
1889
1941
|
...r, summary,
|
|
@@ -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';
|