@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.
Files changed (128) hide show
  1. package/README.md +2 -2
  2. package/README.zh-CN.md +2 -2
  3. package/bin/gotry-bootstrap.js +63 -53
  4. package/bin/gotry-hbcli-release.d.ts +3 -0
  5. package/bin/gotry-hbcli-release.js +4 -0
  6. package/client/client.js +66 -21
  7. package/dist/capabilities/doctor.js +7 -8
  8. package/dist/capabilities/effect.js +45 -12
  9. package/dist/capabilities/flyai-config.js +12 -10
  10. package/dist/capabilities/flyai.js +87 -56
  11. package/dist/capabilities/hbcli-config.js +156 -0
  12. package/dist/capabilities/hbcli.js +29 -2
  13. package/dist/capabilities/resilience.js +6 -6
  14. package/dist/capabilities/session/adapters/ctrip-flight.js +42 -7
  15. package/dist/capabilities/session/adapters/ctrip-hotel.js +52 -3
  16. package/dist/capabilities/session/bridge-ledger.js +168 -67
  17. package/dist/capabilities/session/ctrip-city.js +186 -0
  18. package/dist/capabilities/session/extension-bridge.js +148 -47
  19. package/dist/capabilities/session/gui-flight.js +949 -0
  20. package/dist/capabilities/session/gui-hotel.js +470 -0
  21. package/dist/capabilities/session-search.js +102 -14
  22. package/dist/scripts/booking-copilot-dsh-planner-proof-tests.js +129 -2
  23. package/dist/scripts/booking-copilot-dsh-readiness-proof.js +53 -5
  24. package/dist/scripts/booking-copilot-gateway-chat-adapter-tests.js +233 -0
  25. package/dist/scripts/booking-copilot-planner-live-probe.js +1 -0
  26. package/dist/scripts/booking-copilot-startup-proof-tests.js +1 -1
  27. package/dist/scripts/bootstrap-tests.js +23 -6
  28. package/dist/scripts/bridge-ledger-tests.js +20 -18
  29. package/dist/scripts/ctrip-city-tests.js +80 -0
  30. package/dist/scripts/effect-tests.js +602 -504
  31. package/dist/scripts/extension-tests.js +14 -8
  32. package/dist/scripts/fixtures/tenant-runaway-worker-fixture.mjs +73 -0
  33. package/dist/scripts/flight-malformed-tests.js +1 -1
  34. package/dist/scripts/flyai-setup-tests.js +19 -4
  35. package/dist/scripts/flyai-tests.js +768 -653
  36. package/dist/scripts/gotry-web-api-tests.js +5 -0
  37. package/dist/scripts/gotry-web-client-tests.js +20 -1
  38. package/dist/scripts/gui-flight-live.js +27 -0
  39. package/dist/scripts/hbcli-tests.js +1 -1
  40. package/dist/scripts/hbcli-upgrade-tests.js +107 -0
  41. package/dist/scripts/hbcli-web-setup-tests.js +114 -0
  42. package/dist/scripts/hotel-date-gate-tests.js +1 -1
  43. package/dist/scripts/hotel-result-tests.js +169 -0
  44. package/dist/scripts/issue-271-liveness-tests.js +1 -1
  45. package/dist/scripts/mutation-check.js +153 -0
  46. package/dist/scripts/nightly-evidence.js +4 -111
  47. package/dist/scripts/session-tests.js +211 -6
  48. package/dist/scripts/tenant-bridge-org-tests.js +1264 -0
  49. package/dist/scripts/tenant-capacity-probe.js +263 -0
  50. package/dist/scripts/tenant-compute-isolation-tests.js +707 -0
  51. package/dist/scripts/tenant-egress-credentials-tests.js +822 -0
  52. package/dist/scripts/tenant-executor-binding-tests.js +203 -0
  53. package/dist/scripts/tenant-prompt-partition-tests.js +265 -0
  54. package/dist/scripts/tenant-seam-adversarial-tests.js +957 -0
  55. package/dist/scripts/tenant-seam-quota-tests.js +383 -0
  56. package/dist/scripts/tenant-seam-test-kit.js +292 -0
  57. package/dist/scripts/tenant-state-copies-tests.js +607 -0
  58. package/dist/scripts/tenant-state-drill-tests.js +298 -0
  59. package/dist/scripts/tenant-state-drill-worker.js +164 -0
  60. package/dist/scripts/tenant-state-durable-conformance.js +101 -0
  61. package/dist/scripts/tenant-state-handoff-tests.js +854 -0
  62. package/dist/scripts/tenant-state-lease-conformance.js +142 -0
  63. package/dist/scripts/tenant-state-lease-worker.js +28 -0
  64. package/dist/scripts/tenant-state-router-tests.js +464 -0
  65. package/dist/scripts/tenant-state-test-kit.js +92 -0
  66. package/dist/scripts/tenant-state-tests.js +564 -0
  67. package/dist/src/backend/modules/booking-executor.js +61 -6
  68. package/dist/src/backend/modules/session-search.js +136 -39
  69. package/dist/src/booking-surface/dsh-llm-gateway-chat.js +434 -0
  70. package/dist/src/booking-surface/dsh-planner.js +150 -19
  71. package/dist/src/booking-surface/index.js +1 -0
  72. package/dist/src/booking-surface/managed-dsh-run-port.js +121 -7
  73. package/dist/src/booking-surface/server.js +193 -80
  74. package/dist/src/booking-surface/startup.js +153 -0
  75. package/dist/src/booking-surface/tenancy.js +188 -0
  76. package/dist/src/gotry-web-api.js +64 -2
  77. package/dist/src/index.js +70 -18
  78. package/dist/src/llm-price-table.js +148 -0
  79. package/dist/src/tenant-seam/index.js +8 -0
  80. package/dist/src/tenant-seam/llm-broker.js +508 -0
  81. package/dist/src/tenant-seam/process-governor.js +281 -0
  82. package/dist/src/tenant-seam/quotas.js +360 -0
  83. package/dist/src/tenant-seam/seam.js +92 -0
  84. package/dist/src/tenant-state/durable-store.js +158 -0
  85. package/dist/src/tenant-state/epoch-copies.js +422 -0
  86. package/dist/src/tenant-state/errors.js +108 -0
  87. package/dist/src/tenant-state/ids.js +101 -0
  88. package/dist/src/tenant-state/index.js +11 -0
  89. package/dist/src/tenant-state/lease-store.js +203 -0
  90. package/dist/src/tenant-state/tenant-lease.js +150 -0
  91. package/dist/src/tenant-state/tenant-router.js +650 -0
  92. package/extension/manifest.json +2 -2
  93. package/package.json +5 -1
  94. package/ts/capabilities/flyai-config.ts +12 -10
  95. package/ts/capabilities/flyai.ts +91 -50
  96. package/ts/capabilities/hbcli-config.ts +128 -0
  97. package/ts/capabilities/hbcli.ts +30 -3
  98. package/ts/capabilities/session/adapters/ctrip-flight.ts +43 -7
  99. package/ts/capabilities/session/adapters/ctrip-hotel.ts +51 -4
  100. package/ts/capabilities/session/bridge-ledger.ts +192 -78
  101. package/ts/capabilities/session/ctrip-city.ts +158 -0
  102. package/ts/capabilities/session/extension-bridge.ts +185 -67
  103. package/ts/capabilities/session/gui-flight.ts +1022 -0
  104. package/ts/capabilities/session/gui-hotel.ts +505 -0
  105. package/ts/capabilities/session-search.ts +130 -18
  106. package/ts/src/booking-surface/dsh-llm-gateway-chat.js +434 -0
  107. package/ts/src/booking-surface/dsh-planner.ts +169 -19
  108. package/ts/src/booking-surface/index.ts +1 -0
  109. package/ts/src/booking-surface/managed-dsh-run-port.ts +150 -7
  110. package/ts/src/booking-surface/server.ts +215 -84
  111. package/ts/src/booking-surface/startup.ts +155 -0
  112. package/ts/src/booking-surface/tenancy.ts +242 -0
  113. package/ts/src/gotry-web-api.ts +51 -2
  114. package/ts/src/index.ts +67 -19
  115. package/ts/src/llm-price-table.ts +180 -0
  116. package/ts/src/tenant-seam/index.ts +9 -0
  117. package/ts/src/tenant-seam/llm-broker.ts +520 -0
  118. package/ts/src/tenant-seam/process-governor.ts +343 -0
  119. package/ts/src/tenant-seam/quotas.ts +433 -0
  120. package/ts/src/tenant-seam/seam.ts +142 -0
  121. package/ts/src/tenant-state/durable-store.ts +171 -0
  122. package/ts/src/tenant-state/epoch-copies.ts +491 -0
  123. package/ts/src/tenant-state/errors.ts +128 -0
  124. package/ts/src/tenant-state/ids.ts +108 -0
  125. package/ts/src/tenant-state/index.ts +12 -0
  126. package/ts/src/tenant-state/lease-store.ts +266 -0
  127. package/ts/src/tenant-state/tenant-lease.ts +175 -0
  128. package/ts/src/tenant-state/tenant-router.ts +743 -0
package/ts/src/index.ts CHANGED
@@ -523,7 +523,7 @@ export function apply(ctx: Context, config: Config, seams: ApplyTestSeams = {}):
523
523
  // 派生的后台规划会话:唯一出口 converge + 长 leash——前台转后台,后台
524
524
  // 必须产出最终交付物,不得再次 handoff(否则无限递归)。
525
525
  if (!rawBenchmarkEnvironmentConfigPath) {
526
- registerGotryWebApi(ctx, config.stateRoot ?? '.')
526
+ registerGotryWebApi(ctx, config.stateRoot ?? '.', config.hbcliBin)
527
527
  registerArtifactDeliveries(ctx)
528
528
  }
529
529
  const handoffChild = process.env.GOTRY_HANDOFF_CHILD === '1'
@@ -1419,10 +1419,15 @@ export function apply(ctx: Context, config: Config, seams: ApplyTestSeams = {}):
1419
1419
  const resp = itp.result
1420
1420
  const dir = await ensureStateDir(config.stateRoot)
1421
1421
  const isLive = resp.via === 'hbcli-realtime'
1422
- const evidence = isLive ? resp.evidence : '[静态包:估算]'
1422
+ const staticStays = (resp.hotels as { stays?: unknown[] } | null)?.stays
1423
+ const hasEstimate = Array.isArray(staticStays) && staticStays.length > 0
1424
+ const liveList = (resp.hotels as { list?: unknown[] } | null)?.list
1425
+ const verdict = isLive ? (liveList?.length ? 'hit' : 'miss') : hasEstimate ? 'estimated' : 'error'
1426
+ const evidence = hasEstimate ? '[静态包:估算]' : resp.evidence
1423
1427
  await recordLatency(join(dir, 'bridge-latency.jsonl'), Date.now() - started, `hotel_search:${resp.via}`).catch(() => {})
1424
1428
  const payload = {
1425
- ok: true,
1429
+ ok: isLive || hasEstimate,
1430
+ verdict,
1426
1431
  hotels: resp.hotels ?? null,
1427
1432
  evidence,
1428
1433
  destination: q.destination,
@@ -1430,6 +1435,7 @@ export function apply(ctx: Context, config: Config, seams: ApplyTestSeams = {}):
1430
1435
  latency_ms: Date.now() - started,
1431
1436
  summary: resp.summary,
1432
1437
  error: resp.error,
1438
+ ...(verdict !== 'hit' ? await routingField('search-hotel', 'hbcli-hotel') : {}),
1433
1439
  ...(dateNotes.length ? { date_notes: dateNotes } : {}),
1434
1440
  } as never
1435
1441
  return JSON.parse(JSON.stringify(payload)) as never
@@ -1456,13 +1462,14 @@ export function apply(ctx: Context, config: Config, seams: ApplyTestSeams = {}):
1456
1462
  }
1457
1463
  }
1458
1464
  const h = r.hotels
1459
- const liveCount = Array.isArray(h) ? h.length : 0
1465
+ const liveList = (h as { list?: unknown[] } | null)?.list
1466
+ const liveCount = Array.isArray(liveList) ? liveList.length : 0
1460
1467
  // 静态包是 {meta, stays} 对象而非数组(issue #24:此前计数恒 0 → UI 显示「无结果」)
1461
1468
  const stays = !Array.isArray(h) && h && typeof h === 'object' ? (h as { stays?: unknown[] }).stays : undefined
1462
1469
  const staticCount = Array.isArray(stays) ? stays.length : 0
1463
1470
  const tag = r.via === 'hbcli-realtime'
1464
- ? (liveCount ? `实时 ${liveCount} 家` : '实时')
1465
- : staticCount ? `静态包 ${staticCount} 块` : (liveCount ? `${liveCount} 家` : '无结果')
1471
+ ? `实时 ${liveCount} 家`
1472
+ : staticCount ? `静态包 ${staticCount} 块` : '查询未完成'
1466
1473
  return {
1467
1474
  card: 'generic',
1468
1475
  title: `酒店:${r.destination ?? ''} ${tag}`,
@@ -1859,8 +1866,9 @@ export function apply(ctx: Context, config: Config, seams: ApplyTestSeams = {}):
1859
1866
  'Search on the USER\'S OWN browser session (Ctrip kind="flight"(default)/"hotel"; 12306 kind="train" — public query face, no login needed). '
1860
1867
  + '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). '
1861
1868
  + '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). '
1862
- + 'kind="flight": from/to 中文城市名 + date YYYY-MM-DD — sniffs the site search API for structured options. Evidence [会话:ctrip-flight@ts]. '
1863
- + 'kind="hotel": to=目的地中文, cityId? = the numeric city= in a hotels.ctrip.com list URL (web-search it when the destination is outside the built-in city table), checkIn?/checkOut? (YYYY-MM-DD), adults?; hotel prices are the user\'s real logged-in prices. Evidence [会话:ctrip-hotel@ts]. '
1869
+ + '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. '
1870
+ + '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]. '
1871
+ + '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]. '
1864
1872
  + '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]. '
1865
1873
  + '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]. '
1866
1874
  + '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); '
@@ -1873,21 +1881,28 @@ export function apply(ctx: Context, config: Config, seams: ApplyTestSeams = {}):
1873
1881
  // execute 内归一后走原条件闸,结构化报错不崩。flyai 因 kind required 仍在宿主权即拒。
1874
1882
  parameters: {
1875
1883
  kind: { type: 'string', enum: ['flight', 'hotel', 'train', 'dida'], description: '默认 flight 机票;hotel 携程酒店(用户登录态真实价);train 12306 余票(公开面);dida 供应商门户实时价(员工登录态)' },
1876
- from: { type: 'string', description: '出发城市中文(词表内),如 上海——kind=flight|train 必填' },
1884
+ from: { type: 'string', description: '出发城市名(机票支持中英文及机场名,自动解析),如 上海——kind=flight|train 必填' },
1877
1885
  to: { type: 'string', description: '到达城市中文——kind=flight|train 必填;kind=hotel 时为目的地(必填)' },
1878
1886
  date: { type: 'string', description: '出发日期 YYYY-MM-DD——kind=flight|train 必填,须为今天或未来' },
1879
- cityId: { type: 'integer', description: '携程酒店 city= 数字,仅 kind=hotel;目的地码表外时 web 搜 hotels.ctrip.com list URL 取 city=' },
1887
+ fromCityCode: { type: 'string', description: '显式携程出发城市/机场三字码,仅 kind=flight;同名城市确认后使用' },
1888
+ toCityCode: { type: 'string', description: '显式携程到达城市/机场三字码,仅 kind=flight;同名城市确认后使用' },
1889
+ cityId: { type: 'integer', description: '显式携程酒店 city= 数字,仅 kind=hotel;未提供时自动解析酒店目的地,不能使用机票城市码' },
1890
+ country: { type: 'string', description: '目的地国家中英文,仅 kind=hotel;用于同名城市消歧,例如 阿联酋' },
1880
1891
  checkIn: { type: 'string', description: '入住日 YYYY-MM-DD,仅 kind=hotel 可选' },
1881
1892
  checkOut: { type: 'string', description: '退房日 YYYY-MM-DD,仅 kind=hotel 可选' },
1882
1893
  adults: { type: 'integer', description: '成人数,仅 kind=hotel 可选' },
1883
1894
  fromStationTelecode: { type: 'string', description: '出发站三位电报码,仅 kind=train;城市码表外时填(kyfw 查询页 URL 里)' },
1884
1895
  toStationTelecode: { type: 'string', description: '到达站三位电报码,仅 kind=train,同上' },
1896
+ backDate: { type: 'string', description: '回程日期 YYYY-MM-DD,仅 kind=flight;存在即往返检索——须 ui=gui(GUI 车道站点表单原生往返;bridge 车道不知往返 URL 形状,不猜)' },
1897
+ ui: { type: 'string', enum: ['bridge', 'gui'], description: 'kind=flight|hotel:bridge(默认,用户登录态被动嗅探)|gui(gotry 隔离匿名窗口驱动 PC 表单——flight 往返/多程/任意城市原生+低价日历;hotel 任意目的地原生;匿名公开价)' },
1898
+ legs: { type: 'string', description: '多程段 JSON 数组,仅 kind=flight+ui=gui:[{"from":"香港","to":"迪拜","date":"2026-12-06"},{"from":"迪拜","to":"上海","date":"2026-12-10"}](2–3 段)' },
1899
+ interactiveLogin: { type: 'boolean', description: '仅 ui=gui:登录墙命中时窗口停在携程官方登录页,等用户本人在窗口内完成登录(最多 6 分钟)后自动重查,拿登录态真实数据(行程明细/酒店真实价)。gotry 永不经手密码/验证码/cookie 值' },
1885
1900
  },
1886
1901
  output: { schema: { type: 'json' }, render: (_a, v) => [{ type: 'text', text: String((v as { summary?: string }).summary ?? JSON.stringify(v).slice(0, 600)) }] },
1887
1902
  async execute(args, _exec) {
1888
1903
  const selectedKind = resolveSessionSearchKind(args)
1889
1904
  if (!selectedKind.ok) return { ok: false, summary: selectedKind.reason } as const
1890
- const q = { ...unwrapQuery<{ kind?: string; from?: string; to?: string; date?: string; cityId?: number | string; checkIn?: string; checkOut?: string; adults?: number; fromStationTelecode?: string; toStationTelecode?: string }>(args), kind: selectedKind.kind }
1905
+ const q = { ...unwrapQuery<{ kind?: string; from?: string; to?: string; date?: string; fromCityCode?: string; toCityCode?: string; cityId?: number | string; country?: string; checkIn?: string; checkOut?: string; adults?: number; fromStationTelecode?: string; toStationTelecode?: string; backDate?: string; ui?: string; legs?: string; interactiveLogin?: boolean }>(args), kind: selectedKind.kind }
1891
1906
  const runSessionEffect = seams.effect ?? interpretEffect
1892
1907
  // ---- Dida 供应商门户(2026-09-09 实装;hotel-be portal integration 迁移线)----
1893
1908
  if (q.kind === 'dida') {
@@ -1955,16 +1970,17 @@ export function apply(ctx: Context, config: Config, seams: ApplyTestSeams = {}):
1955
1970
  }
1956
1971
  if (q.kind === 'hotel') {
1957
1972
  if (!q.to) {
1958
- return { ok: false, summary: 'kind=hotel 需要 to(目的地中文;城市码表外带 cityId=携程酒店 list 页 URL 里的 city= 数字)' } as const
1973
+ return { ok: false, summary: 'kind=hotel 需要 to(目的地名称);自动解析城市,同名城市可带 country 或确认后的 cityId' } as const
1959
1974
  }
1960
1975
  const itp = await runSessionEffect({
1961
- effect: 'SESSION_HOTEL_SEARCH',
1976
+ effect: q.ui === 'gui' ? 'SESSION_HOTEL_GUI_SEARCH' : 'SESSION_HOTEL_SEARCH',
1962
1977
  params: {
1963
- to: q.to, cityId: q.cityId, checkIn: q.checkIn, checkOut: q.checkOut, adults: q.adults,
1978
+ to: q.to, country: q.country, cityId: q.cityId, checkIn: q.checkIn, checkOut: q.checkOut, adults: q.adults,
1979
+ ...(q.ui === 'gui' && q.interactiveLogin ? { interactiveLoginMs: 360_000 } : {}),
1964
1980
  auditPath: join(config.stateRoot ?? '.', 'gotry-state', 'session-incidents.jsonl'),
1965
1981
  },
1966
1982
  })
1967
- if (!itp.result) return declinedObservation('SESSION_HOTEL_SEARCH', itp.trace)
1983
+ if (!itp.result) return declinedObservation(q.ui === 'gui' ? 'SESSION_HOTEL_GUI_SEARCH' : 'SESSION_HOTEL_SEARCH', itp.trace)
1968
1984
  const r = itp.result
1969
1985
  await noteChannel('session:ctrip-hotel', r.verdict)
1970
1986
  // D-26(issue #118):exact-date 会话酒店 hit/miss 落账(摸底无档期不落;cooldown 是节律闸非结论,不落)
@@ -1986,14 +2002,42 @@ export function apply(ctx: Context, config: Config, seams: ApplyTestSeams = {}):
1986
2002
  })) as Record<string, never>
1987
2003
  }
1988
2004
  if (!q.from || !q.to || !q.date) {
1989
- return { ok: false, summary: '需要 from/to(中文城市名,词表内)与 date(YYYY-MM-DD);酒店/火车检索分别用 kind=hotel / kind=train' } as const
2005
+ return { ok: false, summary: '需要 from/to(城市或机场名)与 date(YYYY-MM-DD);酒店/火车检索分别用 kind=hotel / kind=train' } as const
1990
2006
  }
1991
2007
  // 效应解译层(ADR-18):SESSION 通道策略=永不重试/不熔断,节律闸在渠道内;
1992
2008
  // 解译器只做分发与证据拼装,verdict 语义(risk 型 needs-login/challenged)原样透传
2009
+ // GUI 车道(2026-10-14):ui=gui → gotry 隔离匿名窗口驱动 PC 表单(往返 backDate/任意城市原生)。
2010
+ if (q.ui !== undefined && q.ui !== 'bridge' && q.ui !== 'gui') {
2011
+ return { ok: false, summary: 'kind=flight 的 ui 只接受 bridge(默认,登录态被动嗅探)|gui(隔离匿名窗口驱动表单)' } as const
2012
+ }
2013
+ // 多程段(仅 gui;严格形状闸——2–3 段、城市非空、日期 YMD、段内日期递增)
2014
+ let legs: Array<{ from: string; to: string; date: string }> | undefined
2015
+ if (q.legs !== undefined) {
2016
+ if (q.ui !== 'gui') return { ok: false, summary: 'legs(多程)仅支持 ui=gui(GUI 车道驱动多程表单)' } as const
2017
+ try {
2018
+ const parsed = JSON.parse(q.legs) as unknown
2019
+ if (!Array.isArray(parsed) || parsed.length < 2 || parsed.length > 3) throw new Error('段数须 2–3')
2020
+ legs = parsed.map((raw: unknown) => {
2021
+ const leg = raw as { from?: unknown; to?: unknown; date?: unknown }
2022
+ 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)) {
2023
+ throw new Error('每段须 {from,to,date(YYYY-MM-DD)}')
2024
+ }
2025
+ return { from: leg.from.trim(), to: leg.to.trim(), date: leg.date }
2026
+ })
2027
+ for (let i = 1; i < legs.length; i += 1) {
2028
+ if (legs[i]!.date <= legs[i - 1]!.date) throw new Error('段日期须递增')
2029
+ }
2030
+ } catch (e) {
2031
+ return { ok: false, summary: `legs 形状非法:${(e as Error).message}` } as const
2032
+ }
2033
+ }
1993
2034
  const itp = await runSessionEffect({
1994
- effect: 'SESSION_FLIGHT_SEARCH',
2035
+ effect: q.ui === 'gui' ? 'SESSION_FLIGHT_GUI_SEARCH' : 'SESSION_FLIGHT_SEARCH',
1995
2036
  params: {
1996
- from: q.from, to: q.to, date: q.date,
2037
+ from: q.from, to: q.to, date: q.date, fromCityCode: q.fromCityCode, toCityCode: q.toCityCode,
2038
+ ...(q.backDate ? { backDate: q.backDate } : {}),
2039
+ ...(legs ? { legs } : {}),
2040
+ ...(q.ui === 'gui' && q.interactiveLogin ? { interactiveLoginMs: 360_000 } : {}),
1997
2041
  // ADR-15 收尾:ReadGuard 审计在生产工具路径同样落盘(此前仅测试传隔离 stateRoot 才有 JSONL)
1998
2042
  auditPath: join(config.stateRoot ?? '.', 'gotry-state', 'session-incidents.jsonl'),
1999
2043
  },
@@ -2005,8 +2049,12 @@ export function apply(ctx: Context, config: Config, seams: ApplyTestSeams = {}):
2005
2049
  const avMapS = await loadAirlineAirportMap()
2006
2050
  await appendFacts(config.stateRoot ?? '.', factsFromSession({ origin: q.from, destination: q.to, date: q.date }, r, new Date().toISOString(), avMapS?.city_alias))
2007
2051
  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}`)
2052
+ const lane = q.ui === 'gui' ? `GUI 车道(携程,匿名公开价${q.backDate ? ',往返' : ''})` : '会话检索(携程,用户本人登录态)'
2053
+ const cal = (r.calendar ?? []).slice(0, 14).map(d => `${d.date} ${d.price !== undefined ? `¥${d.price}` : (d.priceRaw ?? '?')}`)
2054
+ const calBlock = cal.length > 0 ? `\n低价日历(匿名公开价,该航线哪些日期有票):\n${cal.join(' | ')}` : ''
2055
+ const urlBlock = q.ui === 'gui' && r.listUrl ? `\nlistUrl(站点真实形状,#663 URL 教师):${r.listUrl.slice(0, 160)}` : ''
2008
2056
  const summary = r.verdict === 'hit'
2009
- ? `${q.from}→${q.to} ${q.date} 会话检索(携程,用户本人登录态)前 ${top.length} 条:\n${top.join('\n')}\n${r.evidence}`
2057
+ ? `${q.from}→${q.to} ${q.date} ${lane}${(r.options ?? []).length > 0 ? ` 行情前 ${top.length} 条:\n${top.join('\n')}` : ''}${calBlock}${urlBlock}\n${r.evidence}`
2010
2058
  : `会话检索未取回(${r.verdict}):${r.error ?? ''} ${r.evidence}`
2011
2059
  return JSON.parse(JSON.stringify({
2012
2060
  ...r, summary,
@@ -0,0 +1,180 @@
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
+
14
+ import { readFileSync } from 'node:fs'
15
+ import type { LlmUsageTracker } from './dsh-llm.ts'
16
+
17
+ export interface PriceEntry {
18
+ input_cache_miss_usd_per_1m_peak: number
19
+ input_cache_miss_usd_per_1m_offpeak: number
20
+ input_cache_hit_usd_per_1m_peak: number
21
+ input_cache_hit_usd_per_1m_offpeak: number
22
+ output_usd_per_1m_peak: number
23
+ output_usd_per_1m_offpeak: number
24
+ }
25
+
26
+ export interface LlmPriceTable {
27
+ schema_version: 'gotry_llm_price_table_v1' | 'gotry_llm_price_table_v2'
28
+ aliases: Record<string, string>
29
+ prices: Record<string, PriceEntry>
30
+ }
31
+
32
+ function round6(value: number): number {
33
+ return Number(value.toFixed(6))
34
+ }
35
+
36
+ function assertPriceEntry(label: string, value: unknown): PriceEntry {
37
+ const entry = typeof value === 'object' && value !== null ? value as Record<string, unknown> : null
38
+ if (!entry) throw new Error(`${label} must be an object`)
39
+ for (const field of ['input_cache_miss_usd_per_1m_peak', 'input_cache_hit_usd_per_1m_peak', 'output_usd_per_1m_peak']) {
40
+ const num = entry[field]
41
+ if (typeof num !== 'number' || !Number.isFinite(num) || num < 0) {
42
+ throw new Error(`${label}.${field} must be a non-negative finite number`)
43
+ }
44
+ }
45
+ return value as PriceEntry
46
+ }
47
+
48
+ export function loadPriceTable(path: string): LlmPriceTable {
49
+ const raw = JSON.parse(readFileSync(path, 'utf8')) as Record<string, unknown>
50
+ const schemaVersion = raw['schema_version']
51
+ if (schemaVersion === 'gotry_llm_price_table_v2') {
52
+ return loadPriceTableV2(raw)
53
+ }
54
+ if (schemaVersion === 'gotry_llm_price_table_v1') {
55
+ return loadPriceTableV1(raw)
56
+ }
57
+ throw new Error(`price table schema_version must be gotry_llm_price_table_v1 or _v2, got: ${String(schemaVersion)}`)
58
+ }
59
+
60
+ function loadPriceTableV1(raw: Record<string, unknown>): LlmPriceTable {
61
+ const aliases = typeof raw['aliases'] === 'object' && raw['aliases'] !== null ? raw['aliases'] as Record<string, string> : {}
62
+ for (const [from, to] of Object.entries(aliases)) {
63
+ if (typeof to !== 'string') throw new Error(`price table alias ${from} must map to a model name`)
64
+ }
65
+ const prices: Record<string, PriceEntry> = {}
66
+ for (const [name, entry] of Object.entries(typeof raw['prices'] === 'object' && raw['prices'] !== null ? raw['prices'] as Record<string, unknown> : {})) {
67
+ prices[name] = assertPriceEntry(`price table entry ${name}`, entry)
68
+ }
69
+ return { schema_version: 'gotry_llm_price_table_v1', aliases, prices }
70
+ }
71
+
72
+ interface ProviderEntry {
73
+ family: string
74
+ price_strategy: 'tiered_peak_offpeak' | 'flat_no_offpeak' | string
75
+ api_base?: string
76
+ source_url?: string
77
+ aliases: Record<string, string>
78
+ models: Record<string, PriceEntry>
79
+ }
80
+
81
+ /** v2 reader: providers.<id>.models.<model> flattened into one aliases/prices pair, so the exposed API does not change.
82
+ * Lookup for a model name:
83
+ * 1. an alias jumps to its target (qualified `providerId:model`);
84
+ * 2. otherwise the qualified `providerId:model` key is looked up in prices;
85
+ * 3. otherwise the bare `model` key (transparent with one provider, e.g. MiniMax-M3 is unique under minimax:models);
86
+ * 4. still not found: fail closed. */
87
+ function loadPriceTableV2(raw: Record<string, unknown>): LlmPriceTable {
88
+ const providersObj = typeof raw['providers'] === 'object' && raw['providers'] !== null ? raw['providers'] as Record<string, unknown> : null
89
+ if (!providersObj) throw new Error('price table v2 requires providers object')
90
+ const aliases: Record<string, string> = {}
91
+ const prices: Record<string, PriceEntry> = {}
92
+ for (const [providerId, rawProvider] of Object.entries(providersObj)) {
93
+ const provider = assertProviderEntry(`price table provider ${providerId}`, rawProvider)
94
+ for (const [aliasName, target] of Object.entries(provider.aliases)) {
95
+ if (typeof target !== 'string') throw new Error(`provider ${providerId} alias ${aliasName} must map to a model name`)
96
+ if (!provider.models[target]) {
97
+ throw new Error(`provider ${providerId} alias ${aliasName} -> ${target} has no entry; update data/llm-price-table.json via PR`)
98
+ }
99
+ aliases[aliasName] = target
100
+ }
101
+ for (const [modelName, entry] of Object.entries(provider.models)) {
102
+ const qualified = `${providerId}:${modelName}`
103
+ const validated = assertPriceEntry(`price table provider ${providerId} model ${modelName}`, entry)
104
+ if (prices[qualified]) throw new Error(`duplicate price table key ${qualified}`)
105
+ prices[qualified] = validated
106
+ if (prices[modelName]) throw new Error(`duplicate price table key ${modelName} (across providers, disambiguate via aliases or qualified lookup)`)
107
+ prices[modelName] = validated
108
+ }
109
+ }
110
+ return { schema_version: 'gotry_llm_price_table_v2', aliases, prices }
111
+ }
112
+
113
+ function assertProviderEntry(label: string, value: unknown): ProviderEntry {
114
+ const entry = typeof value === 'object' && value !== null ? value as Record<string, unknown> : null
115
+ if (!entry) throw new Error(`${label} must be an object`)
116
+ const family = entry['family']
117
+ if (typeof family !== 'string' || family.length === 0) throw new Error(`${label}.family must be a non-empty string`)
118
+ const strategy = entry['price_strategy']
119
+ if (strategy !== 'tiered_peak_offpeak' && strategy !== 'flat_no_offpeak') {
120
+ throw new Error(`${label}.price_strategy must be 'tiered_peak_offpeak' or 'flat_no_offpeak', got: ${String(strategy)}`)
121
+ }
122
+ const aliases = typeof entry['aliases'] === 'object' && entry['aliases'] !== null ? entry['aliases'] as Record<string, string> : {}
123
+ const models = typeof entry['models'] === 'object' && entry['models'] !== null ? entry['models'] as Record<string, PriceEntry> : {}
124
+ return {
125
+ family,
126
+ price_strategy: strategy,
127
+ api_base: typeof entry['api_base'] === 'string' ? entry['api_base'] as string : undefined,
128
+ source_url: typeof entry['source_url'] === 'string' ? entry['source_url'] as string : undefined,
129
+ aliases,
130
+ models,
131
+ }
132
+ }
133
+
134
+ /** Dollars for one run's measured usage (peak-conservative upper bound: the table's peak columns, never a guess). */
135
+ export function priceRunCost(model: string, usage: LlmUsageTracker, table: LlmPriceTable): number {
136
+ if (usage.responsesMissingUsage > 0) {
137
+ throw new Error(`usage provider omitted usage on ${usage.responsesMissingUsage} response(s) — cost unprovable, refusing to price (fail-closed)`)
138
+ }
139
+ const alias = table.aliases[model]
140
+ if (alias && !table.prices[alias]) throw new Error(`price table alias ${model} -> ${alias} has no entry; update data/llm-price-table.json via PR`)
141
+ const entry = table.prices[alias ?? model]
142
+ if (!entry) throw new Error(`price table has no entry for model ${model}; update data/llm-price-table.json via PR (fail-closed, no guessed price)`)
143
+ const cost = (usage.inputCacheMissTokens * entry.input_cache_miss_usd_per_1m_peak
144
+ + usage.inputCacheHitTokens * entry.input_cache_hit_usd_per_1m_peak
145
+ + usage.outputTokens * entry.output_usd_per_1m_peak) / 1_000_000
146
+ return round6(cost)
147
+ }
148
+
149
+ /** The usage a planner invocation reports: fresh input, cache-read input and output tokens, as the gateway adapter maps them. */
150
+ export interface MeasuredLlmUsage {
151
+ readonly inputTokens: number
152
+ readonly outputTokens: number
153
+ readonly cacheReadTokens?: number
154
+ }
155
+
156
+ function nonNegativeInteger(name: string, value: unknown): number {
157
+ if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) throw new RangeError(`${name} must be a non-negative integer`)
158
+ return value
159
+ }
160
+
161
+ /** `priceRunCost` for one measured usage report. Malformed counts are rejected rather than priced. */
162
+ export function priceMeasuredUsage(model: string, usage: MeasuredLlmUsage, table: LlmPriceTable): { readonly tokens: number; readonly costUsd: number } {
163
+ const inputTokens = nonNegativeInteger('inputTokens', usage.inputTokens)
164
+ const outputTokens = nonNegativeInteger('outputTokens', usage.outputTokens)
165
+ const cacheReadTokens = nonNegativeInteger('cacheReadTokens', usage.cacheReadTokens ?? 0)
166
+ const costUsd = priceRunCost(model, {
167
+ calls: 1,
168
+ inputTokens: inputTokens + cacheReadTokens,
169
+ outputTokens,
170
+ inputCacheHitTokens: cacheReadTokens,
171
+ inputCacheMissTokens: inputTokens,
172
+ responsesMissingUsage: 0,
173
+ }, table)
174
+ return { tokens: inputTokens + cacheReadTokens + outputTokens, costUsd }
175
+ }
176
+
177
+ /** Throws unless the table prices `model`. Used at composition time so an unpriced model stops the service before any spend. */
178
+ export function assertModelPriced(model: string, table: LlmPriceTable): void {
179
+ priceRunCost(model, { calls: 0, inputTokens: 0, outputTokens: 0, inputCacheHitTokens: 0, inputCacheMissTokens: 0, responsesMissingUsage: 0 }, table)
180
+ }
@@ -0,0 +1,9 @@
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
+
6
+ export * from './quotas.ts'
7
+ export * from './seam.ts'
8
+ export * from './llm-broker.ts'
9
+ export * from './process-governor.ts'