dsh-plugin-usage 0.2.0 → 0.3.0

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/lib/host/fold.js CHANGED
@@ -1,60 +1,167 @@
1
1
  /**
2
- * Fold session events into usage records.
2
+ * Fold durable session events into usage records.
3
3
  *
4
- * foldNew is called by scan.js for both live and persisted sessions.
4
+ * This mirrors the official `tokenUsage` session projection
5
+ * (@deepseek-ai/dsh-token-meter) so a third-party dashboard and the built-in
6
+ * stats agree on what one session consumed:
5
7
  *
6
- * P0-2: dedup ownership lives here. Records are keyed by
7
- * state.dedup (sessionId + turn + step); a later sample for the same
8
- * (turn, step) REPLACES the previous one in place instead of appending,
9
- * calling state.index.remove() for the dropped record and state.index.add()
10
- * for the replacement. Records without turn/step (legacy logs) are never
11
- * deduped — they always append.
8
+ * - A **billed settlement** is `assistant/message` or `assistant/attempt`.
9
+ * `assistant/message` carries `data.usage`; a retried/failed attempt has no
10
+ * usage field but can still have reported usage on its exact stream, so the
11
+ * last raw `usage` chunk of `data.stream` is used when the field is absent.
12
+ * - `llm/retry-started` closes the replacement slot for that (turn, step):
13
+ * the retried attempt is a NEW billable attempt and must add, not replace.
14
+ * - Otherwise a later sample for the same (turn, step) replaces the previous
15
+ * one in place (index remove + add), because a settlement re-reports the
16
+ * cumulative usage of its step.
17
+ * - `request/context` and `request/header` provide the route (provider/model)
18
+ * for settlements that carry no `message.source`.
19
+ *
20
+ * Records without turn/step (legacy logs) are never deduped — they always
21
+ * append.
22
+ */
23
+ /**
24
+ * Last raw `usage` chunk of one durable assistant stream.
25
+ *
26
+ * @param {unknown} stream - `AssistantStreamRecord[]` (packed records included).
27
+ * @returns {object | undefined} the reported usage, when the stream has one.
28
+ */
29
+ export function streamUsage(stream) {
30
+ if (!Array.isArray(stream))
31
+ return undefined;
32
+ for (let index = stream.length - 1; index >= 0; index -= 1) {
33
+ const record = stream[index];
34
+ if (record && record.type === 'chunk' && record.chunk && record.chunk.type === 'usage') {
35
+ return record.chunk.usage;
36
+ }
37
+ }
38
+ return undefined;
39
+ }
40
+ /**
41
+ * Usage reported by one durable assistant settlement.
42
+ *
43
+ * @param {{ type?: string, data?: any }} event - session event.
44
+ * @returns {object | undefined} reported usage, when the settlement billed one.
45
+ */
46
+ export function usageOf(event) {
47
+ const data = event && event.data;
48
+ if (event.type === 'assistant/message' && data && data.usage !== undefined)
49
+ return data.usage;
50
+ if (event.type !== 'assistant/message' && event.type !== 'assistant/attempt')
51
+ return undefined;
52
+ return streamUsage(data && data.stream);
53
+ }
54
+ /** Route carried by a request event, when present. */
55
+ function routeOf(event) {
56
+ const data = event.data || {};
57
+ if (event.type === 'request/context') {
58
+ if (typeof data.provider === 'string' && typeof data.model === 'string') {
59
+ return { provider: data.provider, model: data.model };
60
+ }
61
+ return undefined;
62
+ }
63
+ if (event.type === 'request/header') {
64
+ const config = data.header && data.header.config;
65
+ if (config && typeof config.provider === 'string' && typeof config.model === 'string') {
66
+ return { provider: config.provider, model: config.model };
67
+ }
68
+ }
69
+ return undefined;
70
+ }
71
+ /**
72
+ * Fold one durable event into the usage records.
73
+ *
74
+ * @param {object} state - runtime state capsule (see state.js).
75
+ * @param {string} sessionId - owning session.
76
+ * @param {{ type?: string, seq?: number, time?: number, data?: any }} e - session event.
77
+ * @returns {object | null} the affected record, or null when the event billed nothing.
12
78
  */
13
79
  export function foldEvent(state, sessionId, e) {
14
- if (!e || e.type !== 'assistant/message' || !e.data || !e.data.usage)
80
+ if (!e || typeof e !== 'object')
81
+ return null;
82
+ // Route tracking: every settlement of a turn is priced/attributed under the
83
+ // route its request ran on.
84
+ const route = routeOf(e);
85
+ if (route !== undefined) {
86
+ state.route.set(sessionId, route);
87
+ return null;
88
+ }
89
+ // Retry boundary: the retried attempt is a new billable attempt.
90
+ if (e.type === 'llm/retry-started') {
91
+ const data = e.data || {};
92
+ const last = state.lastSample.get(sessionId);
93
+ if (last && last.turn === data.turn && last.step === data.step) {
94
+ state.lastSample.delete(sessionId);
95
+ }
96
+ return null;
97
+ }
98
+ if (e.type !== 'assistant/message' && e.type !== 'assistant/attempt')
15
99
  return null;
16
- const source = (e.data.message && e.data.message.source) || {};
17
- const u = e.data.usage;
100
+ const usage = usageOf(e);
101
+ if (usage === undefined || usage === null || typeof usage !== 'object')
102
+ return null;
103
+ const data = e.data || {};
104
+ const source = (data.message && data.message.source) || state.route.get(sessionId) || {};
105
+ const u = usage;
106
+ const inputTokens = u.inputTokens || 0;
107
+ const outputTokens = u.outputTokens || 0;
108
+ const cacheReadTokens = u.cacheReadTokens || 0;
109
+ const cacheWriteTokens = u.cacheWriteTokens || 0;
18
110
  const rec = {
19
111
  time: e.time,
20
112
  session: sessionId,
21
113
  provider: source.provider || '',
22
114
  model: source.model || '',
23
- turn: e.data.turn,
24
- step: e.data.step,
25
- inputTokens: u.inputTokens || 0,
26
- outputTokens: u.outputTokens || 0,
27
- cacheReadTokens: u.cacheReadTokens || 0,
28
- cacheWriteTokens: u.cacheWriteTokens || 0,
115
+ turn: data.turn,
116
+ step: data.step,
117
+ attempt: e.type === 'assistant/attempt',
118
+ inputTokens,
119
+ outputTokens,
120
+ cacheReadTokens,
121
+ cacheWriteTokens,
29
122
  reasoningTokens: u.reasoningTokens || 0,
30
- totalTokens: (u.inputTokens || 0) + (u.outputTokens || 0) + (u.cacheReadTokens || 0) + (u.cacheWriteTokens || 0),
123
+ // Prefer the provider's authoritative full-call total when reported.
124
+ totalTokens: typeof u.totalTokens === 'number'
125
+ ? u.totalTokens
126
+ : inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens,
31
127
  };
32
- const key = sessionId + ':' + rec.turn + ':' + rec.step;
33
- if (rec.turn !== undefined && rec.step !== undefined) {
34
- const prevIndex = state.dedup.get(key);
35
- if (prevIndex !== undefined) {
36
- const prev = state.records[prevIndex];
37
- if (state.index)
38
- state.index.remove(prev);
39
- Object.assign(prev, rec);
40
- if (state.index)
41
- state.index.add(prev);
42
- return prev;
43
- }
44
- state.records.push(rec);
45
- state.dedup.set(key, state.records.length - 1);
128
+ const previous = state.lastSample.get(sessionId);
129
+ if (previous !== undefined
130
+ && data.turn !== undefined && data.step !== undefined
131
+ && previous.turn === data.turn && previous.step === data.step) {
132
+ const prev = previous.record;
46
133
  if (state.index)
47
- state.index.add(rec);
48
- return rec;
134
+ state.index.remove(prev);
135
+ Object.assign(prev, rec);
136
+ if (state.index)
137
+ state.index.add(prev);
138
+ state.lastSample.set(sessionId, { turn: data.turn, step: data.step, record: prev });
139
+ return prev;
49
140
  }
50
141
  state.records.push(rec);
51
142
  if (state.index)
52
143
  state.index.add(rec);
144
+ if (data.turn !== undefined && data.step !== undefined) {
145
+ state.lastSample.set(sessionId, { turn: data.turn, step: data.step, record: rec });
146
+ }
147
+ else {
148
+ state.lastSample.delete(sessionId);
149
+ }
53
150
  return rec;
54
151
  }
55
- /** Fold events with seq > watermark for one session; returns new watermark. */
152
+ /**
153
+ * Fold events with seq > watermark for one session; returns the new watermark.
154
+ *
155
+ * @param {object} state - runtime state capsule.
156
+ * @param {string} sessionId - owning session.
157
+ * @param {ReadonlyArray<object>} events - events in log order.
158
+ * @param {number} wm - current watermark (last folded seq).
159
+ * @returns {number} the new watermark.
160
+ */
56
161
  export function foldNew(state, sessionId, events, wm) {
57
162
  let max = wm;
163
+ if (!Array.isArray(events))
164
+ return max;
58
165
  for (const e of events) {
59
166
  if (e && typeof e.seq === 'number' && e.seq > wm) {
60
167
  foldEvent(state, sessionId, e);
package/lib/host/index.js CHANGED
@@ -3,33 +3,51 @@
3
3
  *
4
4
  * Wires the submodules (fold / scan / query / settings / aggregate / rpc /
5
5
  * client-source) into the cordis plugin lifecycle. Strategy notes:
6
- * - DSH already persists every loop model call's token usage durably in each
7
- * session log as `assistant/message` events carrying `usage` (TokenUsage)
8
- * and `message.source.{provider,model}`. We fold those events instead of
6
+ * - DSH persists every loop model call's token usage in each session log as
7
+ * an `assistant/message` (or `assistant/attempt`) settlement carrying
8
+ * `usage`; this plugin folds those events with the same replacement /
9
+ * retry semantics as the official `tokenUsage` projection instead of
9
10
  * tapping `llm/stream` (unreachable from an agent-scoped dynamic plugin).
10
11
  * - Live sessions are scanned from the in-memory store every poll tick;
11
12
  * persisted sessions are backfilled at startup and re-scanned incrementally
12
- * on a slower cadence (watermark per session).
13
+ * (watermark + revision per session).
13
14
  * - Aggregation, cost (user-configurable price table) and model list are
14
- * served to the Client via the RPC bridge (rpc.js).
15
+ * served through src/host/rpc.js.
16
+ *
17
+ * The price table is the plugin's own config: the exported `Config` schema is
18
+ * the official, durable form surface (`Plugins` settings tab -> profile
19
+ * patch), replacing the 0.1.x `settings.register`/`settings.yaml` path that
20
+ * does not exist in DSH 0.2.x.
15
21
  *
16
22
  * The same entry doubles as the cordis.yml host plugin module (exports name /
17
- * inject / apply) and as the `code.host` function body for a dynamic plugin.
23
+ * inject / Config / apply) and as the `code.host` function body for a dynamic
24
+ * plugin.
18
25
  */
19
- export const name = 'usage-host';
20
- export const inject = ['timer'];
21
- import { DEFAULT_PRICES, DEFAULT_CURRENCY, createPriceStore, initSettings } from './settings.js';
26
+ import Schema from '@deepseek-ai/schemastery';
27
+ import { DEFAULT_PRICES, DEFAULT_CURRENCY, createPriceStore, mergePrices } from './settings.js';
22
28
  import { createState } from './state.js';
23
29
  import { createUsageIndex } from './aggregate.js';
24
30
  import { scanLive, scanPersisted, runBackfill } from './scan.js';
25
31
  import { query } from './query.js';
26
32
  import { registerRPC } from './rpc.js';
27
33
  import { getClientSource } from './client-source.js';
34
+ export const name = 'usage-host';
35
+ export const inject = ['timer'];
36
+ /** Durable price table + currency, materialized as the plugin's config form. */
37
+ export const Config = Schema.object({
38
+ currency: Schema.string().default(DEFAULT_CURRENCY),
39
+ prices: Schema.dict(Schema.object({
40
+ inputPerM: Schema.number().default(0),
41
+ outputPerM: Schema.number().default(0),
42
+ cacheReadPerM: Schema.number().default(0),
43
+ cacheWritePerM: Schema.number().default(0),
44
+ })).default(DEFAULT_PRICES),
45
+ });
28
46
  const LIVE_POLL_MS = 2000;
29
47
  const PERSISTED_POLL_MS = 30000;
30
- export function apply(ctx) {
31
- const priceStore = createPriceStore(DEFAULT_PRICES, DEFAULT_CURRENCY);
32
- initSettings(ctx, priceStore);
48
+ export function apply(ctx, config) {
49
+ const resolved = config && typeof config === 'object' ? config : {};
50
+ const priceStore = createPriceStore(mergePrices(resolved.prices), resolved.currency || DEFAULT_CURRENCY);
33
51
  const state = createState(priceStore);
34
52
  state.index = createUsageIndex(state);
35
53
  const handlers = new Map();
@@ -39,6 +57,19 @@ export function apply(ctx) {
39
57
  store.setPrices(args && args.prices);
40
58
  if (args && typeof args.currency === 'string')
41
59
  store.setCurrency(args.currency);
60
+ // Secondary durability for hosts where the client cannot reach its config
61
+ // form: write the same values through the official SettingsForms API.
62
+ // The entry id is the bundle patch row id (`usage`).
63
+ try {
64
+ const settings = typeof ctx.get === 'function' ? ctx.get('settings') : undefined;
65
+ if (settings && typeof settings.update === 'function') {
66
+ settings.update('usage', { prices: Object.assign({}, store.prices), currency: store.currency })
67
+ .catch(() => { });
68
+ }
69
+ }
70
+ catch {
71
+ // settings is optional; the in-memory store still answers this session.
72
+ }
42
73
  return store.snapshot();
43
74
  });
44
75
  handlers.set('client-source', () => getClientSource(ctx));
@@ -46,7 +77,7 @@ export function apply(ctx) {
46
77
  // Full history scan once at startup.
47
78
  runBackfill(ctx, state);
48
79
  // Live sessions every tick; persisted sessions on a slow cadence to keep
49
- // per-session JSONL re-parsing cheap.
80
+ // per-session log reads cheap (unchanged revisions are skipped).
50
81
  ctx.interval(() => {
51
82
  scanLive(ctx, state);
52
83
  }, LIVE_POLL_MS);
package/lib/host/query.js CHANGED
@@ -5,6 +5,7 @@
5
5
  * by-model table and model candidate list) are owned here.
6
6
  */
7
7
  import { dayKey, DAY_MS, bucketKeyFor, stepMs, alignFloor, stepDayKey, MAX_BUCKETS, } from './time.js';
8
+ import { currencyOf } from './settings.js';
8
9
  function emptyBucket(key) {
9
10
  return {
10
11
  key,
@@ -52,7 +53,7 @@ export function query(state, args) {
52
53
  const dailyMap = new Map();
53
54
  const modelMap = new Map();
54
55
  const total = emptyBucket('total');
55
- const seenModels = new Map(); // composite key -> {provider, model} (first composite wins)
56
+ const seenModels = new Map(); // composite key -> {key, provider, model} (first composite wins)
56
57
  let unpricedCalls = 0;
57
58
  // Range-anchored day-step grid: blocks start at the range's first day.
58
59
  const dayAnchor = Number.isFinite(from) ? alignFloor(from, 'day') : null;
@@ -60,14 +61,17 @@ export function query(state, args) {
60
61
  for (const r of state.index ? state.index.recordsInRange(from, to) : state.records) {
61
62
  if (r.time < from || r.time > to)
62
63
  continue;
64
+ // Candidate list first, selection filter second: the dropdown must offer
65
+ // every model that has data in the range, not only the ones already ticked
66
+ // (ticking one used to collapse the list to that single entry).
67
+ const modelKey = compositeModelKey(r);
68
+ if (!seenModels.has(modelKey))
69
+ seenModels.set(modelKey, { key: modelKey, provider: r.provider, model: r.model });
63
70
  if (!modelMatches(r))
64
71
  continue;
65
72
  const cost = state.prices.costOf(r);
66
73
  if (r.totalTokens > 0 && !state.prices.hasPrice(r))
67
74
  unpricedCalls += 1;
68
- const modelKey = compositeModelKey(r);
69
- if (!seenModels.has(modelKey))
70
- seenModels.set(modelKey, { provider: r.provider, model: r.model });
71
75
  const dk = useStepGrid ? stepDayKey(r, dayAnchor, stepDays) : bucketKeyFor(r.time, granularity, stepDays);
72
76
  let db = dailyMap.get(dk);
73
77
  if (db === undefined) {
@@ -145,12 +149,28 @@ export function query(state, args) {
145
149
  heatmap.push(hmMap.get(k) || emptyBucket(k));
146
150
  }
147
151
  }
152
+ // Display currency: the price table is denominated in USD and stays as it is
153
+ // (it is shared, polled, and never mutated per request). Conversion happens
154
+ // here, once, at the output boundary — including the heatmap and the totals,
155
+ // so no two views of the same data disagree. Six decimals keep float tails
156
+ // out of the sums.
157
+ const money = currencyOf(args.currency, state.prices.currency);
158
+ const toDisplay = (v) => (money.perUsd === 1 ? v : Math.round(v * money.perUsd * 1e6) / 1e6);
159
+ for (const b of daily)
160
+ b.cost = toDisplay(b.cost);
161
+ for (const b of byModel)
162
+ b.cost = toDisplay(b.cost);
163
+ total.cost = toDisplay(total.cost);
164
+ if (heatmap !== null)
165
+ for (const b of heatmap)
166
+ b.cost = toDisplay(b.cost);
148
167
  return {
149
168
  daily,
150
169
  byModel,
151
170
  total,
152
171
  models,
153
172
  unpricedCalls,
173
+ currency: { code: money.code, symbol: money.symbol },
154
174
  ...heatmap === null ? {} : { heatmap },
155
175
  backfill: { status: state.backfill.status, total: state.backfill.total, done: state.backfill.done },
156
176
  };
package/lib/host/rpc.js CHANGED
@@ -2,12 +2,109 @@
2
2
  * RPC registration.
3
3
  *
4
4
  * Dynamic-plugin runs expose `harness` directly; static (bundle) installs
5
- * expose the generic Connection RPC bridge (the browser half calls
6
- * connection.rpc.call('/usage', endpoint, args)). Support both from one file.
5
+ * expose the Connection service. Support both from one file, plus the
6
+ * official Typert gateway path (P0-3).
7
7
  *
8
- * P0-3: the official Typert gateway path (@deepseek-ai/dsh-api-gateway host
9
- * service, ctx key `typertGateway`) is wired here when the detected gateway
10
- * exposes a registration surface, keeping these paths as fallbacks.
8
+ * Host side, preference order:
9
+ * 1. dynamic-plugin run -> `harness.handle(endpoint, fn)`
10
+ * 2. Typert gateway present -> `typert.register(channel, endpoints)`
11
+ * 3. static (bundle) install -> Connection
12
+ *
13
+ * Connection has two registries and they are NOT interchangeable:
14
+ *
15
+ * - `connection.fetch.register(route)` adds one exact route to the shared
16
+ * `/api` dispatch table. It only touches the service's own map, so it
17
+ * works from any fiber.
18
+ * - `connection.rpc.handle(channel, handler)` mounts a physical prefix route
19
+ * through `owner.webServer.register(route)`, where `owner` is the fiber
20
+ * that READ the service. On 0.2.x every plugin fiber is a strict proxy: a
21
+ * plugin whose `inject` is `['timer']` throws `cannot get property
22
+ * "webServer" without inject` inside that registration, `effect()`
23
+ * rethrows it, and the channel silently never mounts — observed as HTTP
24
+ * 405 on `/usage/*` with no host-side message. Adding `webServer` to the
25
+ * plugin's static `inject` would "fix" it only for compositions that own a
26
+ * web server, and would gate dynamic-plugin loads on a service they never
27
+ * get, so the fetch registry is the correct seam for a bundle install.
28
+ *
29
+ * Therefore: register exact Fetch routes on the shared `/api` channel when
30
+ * that registry exists, and keep the legacy `/usage` prefix channel as a
31
+ * best-effort fallback for older hosts. The browser half calls the Fetch route
32
+ * first and falls back to the legacy channel on "route missing".
33
+ *
34
+ * Wire format of one Fetch route (POST `/api/usage/<endpoint>`):
35
+ * request body = JSON of the endpoint args (or empty for no args)
36
+ * response body = `{ ok: true, value }` | `{ ok: false, error: { code, message } }`
37
+ * i.e. the same result shape the legacy RPC channel wraps, minus its envelope.
38
+ */
39
+ /** Absolute logical channel owned by this plugin on pre-0.2 hosts. */
40
+ export const USAGE_RPC_CHANNEL = '/usage';
41
+ /** Shared-channel path prefix owned by this plugin (`/api` is Connection's mount). */
42
+ export const USAGE_API_PREFIX = '/api/usage';
43
+ /**
44
+ * Exact Fetch route path for one endpoint.
45
+ *
46
+ * @param {string} endpoint - endpoint name such as `query`.
47
+ * @returns {string} absolute path below `/api`, as `connection.fetch.register` requires.
48
+ */
49
+ export function fetchRoutePath(endpoint) {
50
+ return USAGE_API_PREFIX + '/' + endpoint;
51
+ }
52
+ /**
53
+ * Failure result in the Connection RPC result shape.
54
+ *
55
+ * @param {string} message - human-readable failure.
56
+ * @param {string} [code] - stable error code.
57
+ * @returns {{ ok: false, error: { code: string, message: string, details: object } }} failure result.
58
+ */
59
+ export function errorResult(message, code = 'internal') {
60
+ return { ok: false, error: { code, message, details: {} } };
61
+ }
62
+ /**
63
+ * Run one endpoint through the handler map, converting throws into results.
64
+ *
65
+ * @param {Map<string, (args: any) => any>} handlers - endpoint handlers.
66
+ * @param {string} endpoint - endpoint name.
67
+ * @param {unknown} args - decoded endpoint arguments.
68
+ * @returns {Promise<{ ok: true, value: any } | { ok: false, error: object }>} RPC result.
69
+ */
70
+ export async function dispatch(handlers, endpoint, args) {
71
+ const fn = handlers.get(endpoint);
72
+ if (fn === undefined)
73
+ return errorResult(`unknown endpoint: ${endpoint}`, 'bad-request');
74
+ try {
75
+ return { ok: true, value: await fn(args) };
76
+ }
77
+ catch (error) {
78
+ return errorResult(String((error && error.message) || error));
79
+ }
80
+ }
81
+ /**
82
+ * Build the Fetch-shaped handler for one exact route.
83
+ *
84
+ * @param {Map<string, (args: any) => any>} handlers - endpoint handlers.
85
+ * @param {string} endpoint - endpoint name.
86
+ * @returns {(request: Request) => Promise<Response>} route handler.
87
+ */
88
+ export function createFetchRoute(handlers, endpoint) {
89
+ return async function fetchRoute(request) {
90
+ let args = null;
91
+ const text = await request.text();
92
+ if (text.length > 0) {
93
+ try {
94
+ args = JSON.parse(text);
95
+ }
96
+ catch {
97
+ return Response.json(errorResult('request body is not JSON', 'bad-request'), { status: 400 });
98
+ }
99
+ }
100
+ return Response.json(await dispatch(handlers, endpoint, args));
101
+ };
102
+ }
103
+ /**
104
+ * Register every endpoint on the best transport this host offers.
105
+ *
106
+ * @param {any} ctx - host plugin context.
107
+ * @param {Map<string, (args: any) => any>} handlers - endpoint handlers.
11
108
  */
12
109
  export function registerRPC(ctx, handlers) {
13
110
  const dynamicHarness = (typeof harness !== 'undefined' && harness && typeof harness.handle === 'function') ? harness : null;
@@ -48,22 +145,47 @@ export function registerRPC(ctx, handlers) {
48
145
  }
49
146
  // Static (bundle) installs: the `connection` service is provided
50
147
  // asynchronously by the client-connection plugin, so wait for it before
51
- // registering the /usage channel (the official api-gateway pattern).
148
+ // registering (the official api-gateway pattern).
52
149
  ctx.inject(['connection'], (connectionCtx) => {
53
150
  const connection = connectionCtx.connection;
54
- if (connection === undefined || connection.rpc === undefined)
151
+ if (connection === undefined || connection === null)
55
152
  return;
56
- connection.rpc.handle('/usage', async (endpoint, args) => {
57
- const fn = handlers.get(endpoint);
58
- if (fn === undefined) {
59
- return { ok: false, error: { code: 'bad-request', message: `unknown endpoint: ${endpoint}`, details: { issues: [] } } };
153
+ // 0.2+: exact Fetch routes on the shared /api channel. This is the path
154
+ // that actually mounts a route: it never reads `webServer`, so the strict
155
+ // fiber proxy cannot reject it.
156
+ const fetchRegistry = connection.fetch;
157
+ let mountedFetchRoutes = 0;
158
+ if (fetchRegistry !== undefined && typeof fetchRegistry.register === 'function') {
159
+ for (const endpoint of handlers.keys()) {
160
+ const path = fetchRoutePath(endpoint);
161
+ try {
162
+ fetchRegistry.register({
163
+ path,
164
+ methods: ['POST'],
165
+ requestBody: 'buffered',
166
+ fetch: createFetchRoute(handlers, endpoint),
167
+ });
168
+ mountedFetchRoutes += 1;
169
+ }
170
+ catch (error) {
171
+ console.warn(`[usage] failed to mount shared Fetch route ${path}: ${String((error && error.message) || error)}`);
172
+ }
60
173
  }
174
+ }
175
+ // Pre-0.2 hosts (and any composition that still exposes the prefix
176
+ // channel): the legacy /usage RPC bridge. On 0.2.x this throws inside
177
+ // effect() because the reading fiber owns no `webServer`; that is expected
178
+ // and already covered by the Fetch routes, so the failure stays local.
179
+ const rpcRegistry = connection.rpc;
180
+ if (rpcRegistry !== undefined && typeof rpcRegistry.handle === 'function') {
61
181
  try {
62
- return { ok: true, value: await fn(args) };
182
+ rpcRegistry.handle(USAGE_RPC_CHANNEL, async (endpoint, args) => dispatch(handlers, endpoint, args), { authority: 'loopback' });
63
183
  }
64
184
  catch (error) {
65
- return { ok: false, error: { code: 'internal', message: String((error && error.message) || error), details: {} } };
185
+ if (mountedFetchRoutes === 0) {
186
+ console.warn(`[usage] failed to mount ${USAGE_RPC_CHANNEL} RPC channel: ${String((error && error.message) || error)}`);
187
+ }
66
188
  }
67
- }, { authority: 'loopback' });
189
+ }
68
190
  });
69
191
  }