@tangle-network/agent-provider-cli-bridge 2.0.2 → 2.0.3

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 CHANGED
@@ -104,6 +104,21 @@ The provider uses the explicit SSE event field to select the format and rejects
104
104
  The provider rejects a terminal response when its run id or request digest does not match the accepted stream.
105
105
  Usage events and terminal result metadata include `modelRequests` only when the bridge measured an exact non-negative integer count.
106
106
  Missing, malformed, partial, or estimated request counts remain unknown.
107
+
108
+ `metadata.model` and event `data.model` retain Bridge's compatibility response value; they do not prove which model served a request.
109
+ Explicit provider-response observations appear as `provider_models` in event data and result metadata.
110
+ `provider_model` is present only for one distinct observation, and never on a failed result.
111
+ The collector retains disagreements independently of its bounded result event history; overflow or malformed evidence refuses certification.
112
+ Requested routes, init records, fingerprints, configured session models and application JSON never establish provider identity.
113
+ Opaque native records remain in their existing envelope and cannot promote their own fields into host evidence.
114
+
115
+ On the qualified OpenAI wire path, raw Bridge usage is retained in `event.providerEvent.usage` even when a partial receipt cannot form `TokenUsage`.
116
+ `metadata.provider_usage` folds those receipts outside the bounded event history: a missing count stays unknown across later deltas, and a cumulative receipt replaces counts and coverage.
117
+ Events declare this distinction with the existing `usageMode` field.
118
+ Measured usage requires explicit `estimated: false` and valid reported input and output totals; `estimated: true` is an estimate, and absent provenance remains unknown.
119
+ Observed zero is retained. Missing cache classes and cost remain unknown; `cost_scope` applies only to cost.
120
+ The numeric `result.usage` compatibility projection alone does not certify complete measured usage.
121
+ Old peers and opaque canonical native envelopes remain readable with uncertified provenance; nested native or application records cannot assert host observation authority.
107
122
  When `dispatch()` receives no `sessionId`, it creates one from that turn's stable run id and returns it.
108
123
  Replay and result reads fail loudly after cli-bridge's configured replay retention expires.
109
124
  Stopping a `session.events()` reader detaches only that replay observer.
@@ -1,6 +1,6 @@
1
1
  import { type StreamEvent } from "@tangle-network/agent-interface";
2
2
  import type { AgentEnvironmentEvent } from "@tangle-network/agent-interface/environment-provider";
3
- import type { CliBridgeSseFrame } from "./wire.js";
3
+ import { type CliBridgeSseFrame } from "./wire.js";
4
4
  interface CanonicalEventContext {
5
5
  readonly runId: string;
6
6
  readonly sessionId: string;
@@ -1,4 +1,5 @@
1
1
  import { RuntimeEventEnvelopeSchema, } from "@tangle-network/agent-interface";
2
+ import { providerUsageFromOpenAi as recordValue, usageFromOpenAi } from "./wire.js";
2
3
  /** Decode one explicitly typed retained-run frame without guessing its wire format. */
3
4
  export function retainedCanonicalEvent(frame, context) {
4
5
  if (frame.event === undefined)
@@ -82,39 +83,12 @@ function usageFromCanonicalEvent(event) {
82
83
  const usage = recordValue(providerEvent.usage) ?? recordValue(data?.usage);
83
84
  if (!usage)
84
85
  return undefined;
85
- const inputTokens = nonNegativeInteger(usage.inputTokens);
86
- const outputTokens = nonNegativeInteger(usage.outputTokens);
87
- if (inputTokens === undefined || outputTokens === undefined)
88
- return undefined;
89
- return {
90
- inputTokens,
91
- outputTokens,
92
- ...optionalInteger(usage, "totalTokens"),
93
- ...optionalInteger(usage, "cacheReadInputTokens"),
94
- ...optionalInteger(usage, "cacheCreationInputTokens"),
95
- ...optionalInteger(usage, "reasoningTokens"),
96
- ...optionalNumber(usage, "cost"),
97
- };
98
- }
99
- function optionalInteger(source, key) {
100
- const value = nonNegativeInteger(source[key]);
101
- return value === undefined ? {} : { [key]: value };
102
- }
103
- function optionalNumber(source, key) {
104
- const value = source[key];
105
- return typeof value === "number" && Number.isFinite(value) && value >= 0
106
- ? { [key]: value }
107
- : {};
108
- }
109
- function nonNegativeInteger(value) {
110
- return typeof value === "number" && Number.isSafeInteger(value) && value >= 0
111
- ? value
112
- : undefined;
113
- }
114
- function recordValue(value) {
115
- return typeof value === "object" && value !== null && !Array.isArray(value)
116
- ? value
117
- : undefined;
86
+ return usageFromOpenAi({
87
+ prompt_tokens: usage.inputTokens, completion_tokens: usage.outputTokens,
88
+ total_tokens: usage.totalTokens, cache_read_input_tokens: usage.cacheReadInputTokens,
89
+ cache_write_input_tokens: usage.cacheCreationInputTokens, reasoning_tokens: usage.reasoningTokens,
90
+ cost: usage.cost,
91
+ });
118
92
  }
119
93
  function isBaseTenInteger(value) {
120
94
  return /^(0|[1-9][0-9]*)$/u.test(value) && Number.isSafeInteger(Number(value));
@@ -46,11 +46,10 @@ export function createCliBridgeEnvironment(args) {
46
46
  }
47
47
  assertCliBridgeRequestedInteractions(args.capabilities, turn.interactions);
48
48
  const prepared = prepareCliBridgeRun(options, environmentInput, turn, providerName, environmentId, useNativeInteractions, args.acceptedContextTransfer);
49
- // A streamed run reports usage across its events, so the totals are summed
50
- // here and recorded as one execution's measurement.
49
+ // Delta receipts add; cumulative receipts replace this execution's measurement.
51
50
  let streamedUsage;
52
51
  for await (const event of streamTrackedCliBridgeTurn(options, environmentInput, providerName, prepared, transport, runs, sessions, readers, nativeSessions, useNativeInteractions)) {
53
- streamedUsage = addTokenUsage(streamedUsage, event.usage);
52
+ streamedUsage = addTokenUsage(streamedUsage, event.usage, event.usageMode);
54
53
  usageLog.record(prepared.run.executionId, streamedUsage);
55
54
  yield event;
56
55
  }
@@ -14,5 +14,5 @@ export declare function streamCliBridgeSessionEvents(options: CliBridgeProviderO
14
14
  export declare function collectCliBridgeTurnResult(source: AsyncIterable<AgentEnvironmentEvent>, run: CliBridgeRun, options: CliBridgeProviderOptions, transport: CliBridgeTransport, signal?: AbortSignal): Promise<AgentTurnResult>;
15
15
  /** Keep reasoning and tool updates out of the assistant's visible result text. */
16
16
  export declare function visibleTextDelta(event: AgentEnvironmentEvent): string | undefined;
17
- /** Sum the usage a run reported across its events into one total. */
18
- export declare function addTokenUsage(current: TokenUsage | undefined, next: TokenUsage | undefined): TokenUsage | undefined;
17
+ /** Respect cumulative receipts and keep missing optional classes unknown. */
18
+ export declare function addTokenUsage(current: TokenUsage | undefined, next: TokenUsage | undefined, mode?: "delta" | "cumulative"): TokenUsage | undefined;
@@ -1,10 +1,9 @@
1
- import { AgentTurnResultSchema, } from "@tangle-network/agent-interface";
1
+ import { AgentTurnResultSchema, CONTRACT_MAX_ARRAY_LENGTH, } from "@tangle-network/agent-interface";
2
2
  import { cancelCliBridgeRun, captureCliBridgeRunIdentity, cliBridgeCancellationSignal, detachCliBridgeReader, getCliBridgeRun, } from "./retained-control.js";
3
3
  import { bindCliBridgeRun, bindCliBridgeSession, exactControlRefForRun, restoreCliBridgeRun, restoreCliBridgeSession, } from "./retained-run-state.js";
4
- import { CliBridgeRequestRejectedError, streamCliBridgeTurn, } from "./retained-stream.js";
4
+ import { CliBridgeRequestRejectedError, openCliBridgeStream, streamCliBridgeTurn, } from "./retained-stream.js";
5
5
  import { beginCliBridgeNativeTurn, } from "./retained-native.js";
6
- import { MAX_CLI_BRIDGE_CONTROL_RESPONSE_BYTES, readBoundedCliBridgeResponse, requestHeaders, trimSlash, } from "./transport.js";
7
- import { modelRequestCount } from "./wire.js";
6
+ import { modelRequestCount, providerUsageFromOpenAi, responseIdentityFromOpenAi, usageFromOpenAi } from "./wire.js";
8
7
  import { lookupCliBridgeContext } from "./context-transfer.js";
9
8
  const MAX_CLI_BRIDGE_RESULT_EVENTS = 1_024;
10
9
  export async function* streamTrackedCliBridgeTurn(options, environmentInput, providerName, prepared, transport, runs, sessions, readers, nativeSessions, useNativeInteractions) {
@@ -119,26 +118,9 @@ export async function dispatchCliBridgeTurn(options, environmentInput, prepared,
119
118
  });
120
119
  }
121
120
  else {
122
- const response = await transport.fetch(`${trimSlash(options.baseUrl)}/v1/chat/completions`, {
123
- method: "POST",
124
- headers: {
125
- ...requestHeaders(options),
126
- accept: "text/event-stream",
127
- ...(run.sessionId ? { "x-session-id": run.sessionId } : {}),
128
- },
129
- body: run.requestBody,
130
- ...(signal ? { signal } : {}),
131
- });
132
- if (!response.ok) {
133
- let detail = "request rejected";
134
- try {
135
- detail = await readBoundedCliBridgeResponse(response, MAX_CLI_BRIDGE_CONTROL_RESPONSE_BYTES, signal);
136
- }
137
- catch {
138
- // The HTTP status already proves this request was rejected.
139
- }
140
- throw new CliBridgeRequestRejectedError(response.status, detail);
141
- }
121
+ const response = await openCliBridgeStream([
122
+ options, { ...prepared.turn, sessionId: run.sessionId }, run.requestBody, transport, run.id, undefined, signal,
123
+ ]);
142
124
  accepted = true;
143
125
  if (!response.body)
144
126
  throw new Error("cli-bridge response body is empty");
@@ -261,6 +243,9 @@ export async function collectCliBridgeTurnResult(source, run, options, transport
261
243
  let eventCount = 0;
262
244
  let text = "";
263
245
  let usage;
246
+ let providerUsage;
247
+ let providerTokensSeen = false;
248
+ const providerModels = new Set();
264
249
  let observedModelRequests = 0;
265
250
  let modelRequestsKnown = false;
266
251
  let terminalModelRequests;
@@ -283,7 +268,20 @@ export async function collectCliBridgeTurnResult(source, run, options, transport
283
268
  if (delta !== undefined)
284
269
  text += delta;
285
270
  }
286
- usage = addTokenUsage(usage, event.usage);
271
+ const observedUsage = providerUsageFromOpenAi(providerUsageFromOpenAi(event.providerEvent)?.usage);
272
+ const rawUsage = observedUsage ??
273
+ (event.usage ? { prompt_tokens: event.usage.inputTokens, completion_tokens: event.usage.outputTokens } : undefined);
274
+ if (rawUsage) {
275
+ providerUsage = foldProviderUsage(providerUsage, rawUsage, event.usageMode, providerTokensSeen);
276
+ providerTokensSeen = event.usageMode === "cumulative"
277
+ ? hasTokenReceipt(rawUsage) : providerTokensSeen || hasTokenReceipt(rawUsage);
278
+ }
279
+ usage = observedUsage ? usageFromOpenAi(providerUsage) : addTokenUsage(usage, event.usage, event.usageMode);
280
+ for (const model of responseIdentityFromOpenAi(event.data)?.provider_models ?? []) {
281
+ providerModels.add(model);
282
+ if (providerModels.size > CONTRACT_MAX_ARRAY_LENGTH)
283
+ throw new Error("cli-bridge provider model observations exceed contract bounds");
284
+ }
287
285
  const eventModel = event.data.model;
288
286
  const eventFingerprint = event.data.system_fingerprint;
289
287
  if (typeof eventModel === "string" && eventModel.length > 0)
@@ -350,6 +348,9 @@ export async function collectCliBridgeTurnResult(source, run, options, transport
350
348
  ? { modelRequests: observedModelRequests }
351
349
  : {}),
352
350
  ...(servedModel === undefined ? {} : { model: servedModel }),
351
+ ...(providerModels.size ? { provider_models: [...providerModels] } : {}),
352
+ ...(success && providerModels.size === 1 ? { provider_model: [...providerModels][0] } : {}),
353
+ ...(providerUsage === undefined ? {} : { provider_usage: providerUsage }),
353
354
  ...(systemFingerprint === undefined ? {} : { system_fingerprint: systemFingerprint }),
354
355
  },
355
356
  events: resultEvents,
@@ -374,39 +375,56 @@ export function visibleTextDelta(event) {
374
375
  }
375
376
  return typeof event.data.delta === "string" ? event.data.delta : undefined;
376
377
  }
377
- /** Sum the usage a run reported across its events into one total. */
378
- export function addTokenUsage(current, next) {
378
+ /** Respect cumulative receipts and keep missing optional classes unknown. */
379
+ export function addTokenUsage(current, next, mode) {
380
+ if (mode === "cumulative")
381
+ return next ? { ...next } : undefined;
379
382
  if (!next)
380
383
  return current;
381
384
  if (!current)
382
385
  return { ...next };
383
- return {
384
- inputTokens: current.inputTokens + next.inputTokens,
385
- outputTokens: current.outputTokens + next.outputTokens,
386
- ...(current.totalTokens !== undefined || next.totalTokens !== undefined
387
- ? { totalTokens: (current.totalTokens ?? 0) + (next.totalTokens ?? 0) }
388
- : {}),
389
- ...(current.cacheReadInputTokens !== undefined ||
390
- next.cacheReadInputTokens !== undefined
391
- ? {
392
- cacheReadInputTokens: (current.cacheReadInputTokens ?? 0) +
393
- (next.cacheReadInputTokens ?? 0),
394
- }
395
- : {}),
396
- ...(current.cacheCreationInputTokens !== undefined ||
397
- next.cacheCreationInputTokens !== undefined
398
- ? {
399
- cacheCreationInputTokens: (current.cacheCreationInputTokens ?? 0) +
400
- (next.cacheCreationInputTokens ?? 0),
401
- }
402
- : {}),
403
- ...(current.reasoningTokens !== undefined || next.reasoningTokens !== undefined
404
- ? {
405
- reasoningTokens: (current.reasoningTokens ?? 0) + (next.reasoningTokens ?? 0),
386
+ return Object.fromEntries(Object.entries(next).flatMap(([key, count]) => {
387
+ const prior = current[key];
388
+ return prior === undefined ? [] : [[key, prior + count]];
389
+ }));
390
+ }
391
+ const TOKEN_FIELDS = ["prompt_tokens", "input_tokens", "completion_tokens", "output_tokens", "total_tokens",
392
+ "fresh_input_tokens", "cache_read_input_tokens", "cache_write_input_tokens", "cache_creation_input_tokens", "reasoning_tokens"];
393
+ const hasTokenReceipt = (usage) => TOKEN_FIELDS.some(key => key in usage) || "estimated" in usage;
394
+ /** Coverage lives outside the bounded event ring: a later delta cannot fill an earlier omission. */
395
+ function foldProviderUsage(current, next, mode, hasPriorTokens) {
396
+ if (!current || mode === "cumulative")
397
+ return { ...next };
398
+ const folded = { ...current, ...next };
399
+ if (hasTokenReceipt(next)) {
400
+ for (const key of [...TOKEN_FIELDS, "model_requests"]) {
401
+ const value = next[key], prior = current[key];
402
+ if (typeof value === "number" && Number.isSafeInteger(value) && value >= 0 &&
403
+ (!hasPriorTokens || (typeof prior === "number" && Number.isSafeInteger(prior) && prior >= 0))) {
404
+ const count = value + (hasPriorTokens ? prior : 0);
405
+ if (Number.isSafeInteger(count)) {
406
+ folded[key] = count;
407
+ continue;
408
+ }
406
409
  }
407
- : {}),
408
- ...(current.cost !== undefined || next.cost !== undefined
409
- ? { cost: (current.cost ?? 0) + (next.cost ?? 0) }
410
- : {}),
411
- };
410
+ delete folded[key];
411
+ }
412
+ const estimated = current.estimated === true || next.estimated === true ? true :
413
+ next.estimated === false && (!hasPriorTokens || current.estimated === false) ? false : undefined;
414
+ if (estimated === undefined)
415
+ delete folded.estimated;
416
+ else
417
+ folded.estimated = estimated;
418
+ }
419
+ if (next.cost_scope !== "total") {
420
+ const cost = current.cost, delta = next.cost;
421
+ if (current.cost_known === true && next.cost_known === true && typeof cost === "number" && typeof delta === "number") {
422
+ folded.cost = cost + delta;
423
+ }
424
+ else {
425
+ delete folded.cost;
426
+ folded.cost_known = false;
427
+ }
428
+ }
429
+ return folded;
412
430
  }
@@ -7,3 +7,5 @@ export declare class CliBridgeRequestRejectedError extends Error {
7
7
  constructor(status: number, detail: string);
8
8
  }
9
9
  export declare function streamCliBridgeTurn(options: CliBridgeProviderOptions, turn: AgentTurnInput, requestBody: string | undefined, transport: CliBridgeTransport, runId: string, lastEventId?: string, signal?: AbortSignal, onResponse?: (response: CliBridgeResponse) => void, readAuthoritativeRun?: () => Promise<CliBridgeRunSnapshot | null>, validateCanonicalEvent?: (event: AgentEnvironmentEvent) => void): AsyncIterable<AgentEnvironmentEvent>;
10
+ /** Both dispatch and live readers use the same admission and bounded HTTP refusal. */
11
+ export declare function openCliBridgeStream([options, turn, requestBody, transport, runId, lastEventId, signal, onResponse]: Parameters<typeof streamCliBridgeTurn>): Promise<CliBridgeResponse>;
@@ -2,7 +2,7 @@ import { retainedEventsWithIdentity, retainedReplayRequest, } from "./retained-e
2
2
  import { isTerminalCanonicalEvent, retainedCanonicalEvent, } from "./retained-canonical-event.js";
3
3
  import { detachCliBridgeReader } from "./retained-control.js";
4
4
  import { MAX_CLI_BRIDGE_CONTROL_RESPONSE_BYTES, MAX_CLI_BRIDGE_RESULT_RESPONSE_BYTES, readBoundedCliBridgeResponse, requestHeaders, trimSlash, } from "./transport.js";
5
- import { modelRequestsFromOpenAi, parseSse, responseIdentityFromOpenAi, safeJson, toolCallsFromDelta, usageFromOpenAi, } from "./wire.js";
5
+ import { modelRequestsFromOpenAi, parseSse, providerUsageFromOpenAi, responseIdentityFromOpenAi, safeJson, toolCallsFromDelta, usageFromOpenAi, } from "./wire.js";
6
6
  export class CliBridgeRequestRejectedError extends Error {
7
7
  status;
8
8
  constructor(status, detail) {
@@ -20,47 +20,8 @@ class CliBridgeRunCancelledError extends Error {
20
20
  }
21
21
  }
22
22
  export async function* streamCliBridgeTurn(options, turn, requestBody, transport, runId, lastEventId, signal, onResponse, readAuthoritativeRun, validateCanonicalEvent) {
23
- const reconnect = requestBody === undefined;
24
23
  const replay = retainedReplayRequest(lastEventId);
25
- const response = await transport.fetch(reconnect
26
- ? `${trimSlash(options.baseUrl)}/v1/runs/${encodeURIComponent(runId)}/events`
27
- : `${trimSlash(options.baseUrl)}/v1/chat/completions`, {
28
- method: reconnect ? "GET" : "POST",
29
- headers: {
30
- ...requestHeaders(options),
31
- accept: "text/event-stream",
32
- ...(!reconnect && turn.sessionId ? { "x-session-id": turn.sessionId } : {}),
33
- ...(replay.serverCursor === undefined
34
- ? {}
35
- : { "last-event-id": replay.serverCursor }),
36
- },
37
- ...(requestBody === undefined ? {} : { body: requestBody }),
38
- ...(signal ? { signal } : {}),
39
- });
40
- if (!response.ok) {
41
- let detail = "request rejected";
42
- try {
43
- detail = await readBoundedCliBridgeResponse(response, MAX_CLI_BRIDGE_CONTROL_RESPONSE_BYTES, signal);
44
- }
45
- catch {
46
- // The HTTP status already proves this request was rejected.
47
- }
48
- throw new CliBridgeRequestRejectedError(response.status, detail);
49
- }
50
- try {
51
- onResponse?.(response);
52
- }
53
- catch (error) {
54
- if (response.body) {
55
- try {
56
- await detachCliBridgeReader(response.body);
57
- }
58
- catch (detachError) {
59
- throw new AggregateError([error, detachError], `cli-bridge rejected response identity for run "${runId}" and could not detach its reader`);
60
- }
61
- }
62
- throw error;
63
- }
24
+ const response = await openCliBridgeStream([options, turn, requestBody, transport, runId, lastEventId, signal, onResponse]);
64
25
  if (!response.body)
65
26
  throw new Error("cli-bridge response body is empty");
66
27
  let text = "";
@@ -68,7 +29,6 @@ export async function* streamCliBridgeTurn(options, turn, requestBody, transport
68
29
  const messageId = turn.turnId ?? `${sessionId}:assistant`;
69
30
  const emittedToolCalls = new Set();
70
31
  let completed = false;
71
- let sawUsage = false;
72
32
  let observedModelRequests = 0;
73
33
  let modelRequestsKnown = false;
74
34
  let servedModel;
@@ -156,6 +116,8 @@ export async function* streamCliBridgeTurn(options, turn, requestBody, transport
156
116
  systemFingerprint = responseIdentity.system_fingerprint;
157
117
  }
158
118
  const identityData = {
119
+ ...(responseIdentity?.provider_models ? { provider_models: responseIdentity.provider_models } : {}),
120
+ ...(responseIdentity?.provider_model ? { provider_model: responseIdentity.provider_model } : {}),
159
121
  ...(servedModel === undefined ? {} : { model: servedModel }),
160
122
  ...(systemFingerprint === undefined ? {} : { system_fingerprint: systemFingerprint }),
161
123
  };
@@ -171,12 +133,11 @@ export async function* streamCliBridgeTurn(options, turn, requestBody, transport
171
133
  : rawChunk;
172
134
  if (rawChunk && toolCallDeltas.length === 0)
173
135
  textBoundaryPending = false;
174
- const nextUsage = usageFromOpenAi(parsed.usage);
136
+ const rawUsage = providerUsageFromOpenAi(parsed.usage);
137
+ const nextUsage = usageFromOpenAi(rawUsage);
175
138
  const nextModelRequests = modelRequestsFromOpenAi(parsed.usage);
176
139
  const frameEvents = [];
177
- if (nextUsage || nextModelRequests !== undefined) {
178
- if (nextUsage)
179
- sawUsage = true;
140
+ if (rawUsage || nextModelRequests !== undefined) {
180
141
  if (nextModelRequests !== undefined) {
181
142
  observedModelRequests += nextModelRequests;
182
143
  modelRequestsKnown = true;
@@ -188,6 +149,8 @@ export async function* streamCliBridgeTurn(options, turn, requestBody, transport
188
149
  ...(nextModelRequests === undefined ? {} : { modelRequests: nextModelRequests }),
189
150
  },
190
151
  ...(nextUsage ? { usage: nextUsage } : {}),
152
+ usageMode: "delta",
153
+ providerEvent: { usage: rawUsage },
191
154
  });
192
155
  }
193
156
  if (chunk) {
@@ -236,6 +199,9 @@ export async function* streamCliBridgeTurn(options, turn, requestBody, transport
236
199
  }
237
200
  if (toolCallDeltas.length > 0)
238
201
  textBoundaryPending = true;
202
+ if (responseIdentity?.provider_models && !frameEvents.some(event => event.data.provider_models !== undefined)) {
203
+ frameEvents.push({ type: "raw", data: identityData, providerEvent: parsed });
204
+ }
239
205
  if (choice?.finish_reason) {
240
206
  terminalFrameSeen = true;
241
207
  if (choice.finish_reason === "error") {
@@ -261,6 +227,58 @@ export async function* streamCliBridgeTurn(options, turn, requestBody, transport
261
227
  }
262
228
  yield* retainedEventsWithIdentity(frameEvents, frame.id, runId, sessionId, turn.executionId, replay.anchor);
263
229
  }
230
+ yield* finishCliBridgeStream([options, turn, requestBody, transport, runId, lastEventId, signal, onResponse, readAuthoritativeRun], { protocol, canonicalTerminalStatus, canonicalTerminalEvent, sawProtocolEnd, completed,
231
+ cancelledTerminalSeen, terminalFrame, text, modelRequestsKnown, observedModelRequests, servedModel, systemFingerprint }, replay.anchor);
232
+ }
233
+ /** Both dispatch and live readers use the same admission and bounded HTTP refusal. */
234
+ export async function openCliBridgeStream([options, turn, requestBody, transport, runId, lastEventId, signal, onResponse]) {
235
+ const reconnect = requestBody === undefined;
236
+ const replay = retainedReplayRequest(lastEventId);
237
+ const response = await transport.fetch(reconnect
238
+ ? `${trimSlash(options.baseUrl)}/v1/runs/${encodeURIComponent(runId)}/events`
239
+ : `${trimSlash(options.baseUrl)}/v1/chat/completions`, {
240
+ method: reconnect ? "GET" : "POST",
241
+ headers: {
242
+ ...requestHeaders(options),
243
+ accept: "text/event-stream",
244
+ ...(!reconnect && turn.sessionId ? { "x-session-id": turn.sessionId } : {}),
245
+ ...(replay.serverCursor === undefined
246
+ ? {}
247
+ : { "last-event-id": replay.serverCursor }),
248
+ },
249
+ ...(requestBody === undefined ? {} : { body: requestBody }),
250
+ ...(signal ? { signal } : {}),
251
+ });
252
+ if (!response.ok) {
253
+ let detail = "request rejected";
254
+ try {
255
+ detail = await readBoundedCliBridgeResponse(response, MAX_CLI_BRIDGE_CONTROL_RESPONSE_BYTES, signal);
256
+ }
257
+ catch {
258
+ // The HTTP status already proves this request was rejected.
259
+ }
260
+ throw new CliBridgeRequestRejectedError(response.status, detail);
261
+ }
262
+ try {
263
+ onResponse?.(response);
264
+ }
265
+ catch (error) {
266
+ if (response.body) {
267
+ try {
268
+ await detachCliBridgeReader(response.body);
269
+ }
270
+ catch (detachError) {
271
+ throw new AggregateError([error, detachError], `cli-bridge rejected response identity for run "${runId}" and could not detach its reader`);
272
+ }
273
+ }
274
+ throw error;
275
+ }
276
+ return response;
277
+ }
278
+ /** Confirm terminal authority before releasing the final frame and its cumulative receipt. */
279
+ async function* finishCliBridgeStream([options, turn, requestBody, transport, runId, lastEventId, signal, onResponse, readAuthoritativeRun], { protocol, canonicalTerminalStatus, canonicalTerminalEvent, sawProtocolEnd, completed, cancelledTerminalSeen, terminalFrame, text, modelRequestsKnown, observedModelRequests, servedModel, systemFingerprint }, anchor) {
280
+ const sessionId = turn.sessionId ?? runId;
281
+ const reconnect = requestBody === undefined;
264
282
  if (protocol === "canonical") {
265
283
  if (canonicalTerminalStatus === undefined) {
266
284
  throw new Error("cli-bridge canonical stream ended without terminal status");
@@ -359,7 +377,7 @@ export async function* streamCliBridgeTurn(options, turn, requestBody, transport
359
377
  finalText: lastEventId === undefined ? text : result.text,
360
378
  finishReason: lastEventId === undefined ? terminalFrame.finishReason : result.finishReason,
361
379
  status: "completed",
362
- ...(result.model === undefined ? {} : { model: result.model }),
380
+ ...result.identity,
363
381
  ...(result.system_fingerprint === undefined
364
382
  ? {}
365
383
  : { system_fingerprint: result.system_fingerprint }),
@@ -367,7 +385,9 @@ export async function* streamCliBridgeTurn(options, turn, requestBody, transport
367
385
  ? {}
368
386
  : { modelRequests: finalModelRequests }),
369
387
  },
370
- ...(result.usage && !sawUsage ? { usage: result.usage } : {}),
388
+ ...(result.usage ? { usage: result.usage } : {}),
389
+ ...(result.providerUsage ? { usageMode: "cumulative" } : {}),
390
+ ...(result.providerUsage ? { providerEvent: { usage: result.providerUsage } } : {}),
371
391
  };
372
392
  }
373
393
  }
@@ -401,7 +421,7 @@ export async function* streamCliBridgeTurn(options, turn, requestBody, transport
401
421
  throw new Error("cli-bridge terminal state did not produce a terminal event");
402
422
  }
403
423
  terminalFrame.events.push(terminalEvent);
404
- yield* retainedEventsWithIdentity(terminalFrame.events, terminalFrame.id, runId, sessionId, turn.executionId, replay.anchor);
424
+ yield* retainedEventsWithIdentity(terminalFrame.events, terminalFrame.id, runId, sessionId, turn.executionId, anchor);
405
425
  if (terminalFailure)
406
426
  throw terminalFailure;
407
427
  }
@@ -476,6 +496,8 @@ async function readFullCliBridgeResult(options, requestBody, transport, signal,
476
496
  text: message.content,
477
497
  finishReason: choice.finish_reason,
478
498
  ...(usage ? { usage } : {}),
499
+ providerUsage: providerUsageFromOpenAi(parsed?.usage),
500
+ identity: responseIdentity,
479
501
  ...(modelRequests === undefined ? {} : { modelRequests }),
480
502
  ...(responseIdentity?.model === undefined ? {} : { model: responseIdentity.model }),
481
503
  ...(responseIdentity?.system_fingerprint === undefined
package/dist/wire.d.ts CHANGED
@@ -27,11 +27,14 @@ export declare function toolCallsFromDelta(value: unknown): Array<{
27
27
  }>;
28
28
  export declare function safeJson(value: string): Record<string, unknown> | null;
29
29
  export declare function usageFromOpenAi(value: unknown): TokenUsage | undefined;
30
+ export declare function providerUsageFromOpenAi(value: unknown): Record<string, unknown> | undefined;
30
31
  export declare function modelRequestsFromOpenAi(value: unknown): number | undefined;
31
- /** Preserve the provider identity that cli-bridge returns on each completion frame. */
32
+ /** Compatibility model and explicit provider observations have different authority. */
32
33
  export declare function responseIdentityFromOpenAi(value: unknown): {
33
34
  model?: string;
34
35
  system_fingerprint?: string;
36
+ provider_models?: string[];
37
+ provider_model?: string;
35
38
  } | undefined;
36
39
  export declare function modelRequestCount(value: unknown): number | undefined;
37
40
  export declare function cliBridgeRunId(environmentId: string, turn: AgentTurnInput, turnId: string): string;
package/dist/wire.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
- import { AgentExactRunControlRefSchema, RequestedInteractionsSchema, WorkspaceRequestSchema, workspaceCwdPathForBase, } from "@tangle-network/agent-interface";
2
+ import { AgentExactRunControlRefSchema, CONTRACT_MAX_ARRAY_LENGTH, RequestedInteractionsSchema, WorkspaceRequestSchema, workspaceCwdPathForBase, } from "@tangle-network/agent-interface";
3
3
  export function toChatCompletionsBody(options, environmentInput, turn, coordinates) {
4
4
  const profile = inlineProfile(environmentInput.profile);
5
5
  const execution = executionFromInput(options, environmentInput);
@@ -257,47 +257,61 @@ export function safeJson(value) {
257
257
  }
258
258
  }
259
259
  export function usageFromOpenAi(value) {
260
- if (!value || typeof value !== "object")
260
+ const record = providerUsageFromOpenAi(value);
261
+ if (!record)
261
262
  return undefined;
262
- const record = value;
263
- const inputTokens = nonNegativeSafeInteger(record.prompt_tokens) ??
264
- nonNegativeSafeInteger(record.input_tokens);
265
- const outputTokens = nonNegativeSafeInteger(record.completion_tokens) ??
266
- nonNegativeSafeInteger(record.output_tokens);
263
+ const inputTokens = nonNegativeSafeInteger(record.prompt_tokens) ?? nonNegativeSafeInteger(record.input_tokens);
264
+ const outputTokens = nonNegativeSafeInteger(record.completion_tokens) ?? nonNegativeSafeInteger(record.output_tokens);
267
265
  if (inputTokens === undefined || outputTokens === undefined)
268
266
  return undefined;
269
- const totalTokens = nonNegativeSafeInteger(record.total_tokens);
270
- const cacheReadInputTokens = nonNegativeSafeInteger(record.cache_read_input_tokens);
271
- const cacheCreationInputTokens = nonNegativeSafeInteger(record.cache_creation_input_tokens);
272
- const reasoningTokens = nonNegativeSafeInteger(record.reasoning_tokens);
267
+ const usage = { inputTokens, outputTokens };
268
+ for (const [key, wireKey] of [
269
+ ["totalTokens", "total_tokens"], ["cacheReadInputTokens", "cache_read_input_tokens"],
270
+ ["cacheCreationInputTokens", "cache_write_input_tokens"], ["reasoningTokens", "reasoning_tokens"],
271
+ ]) {
272
+ const count = nonNegativeSafeInteger(record[wireKey]) ??
273
+ (key === "cacheCreationInputTokens" ? nonNegativeSafeInteger(record.cache_creation_input_tokens) : undefined);
274
+ if (count !== undefined)
275
+ usage[key] = count;
276
+ }
273
277
  const cost = finiteNonNegativeNumber(record.cost);
274
- return {
275
- inputTokens,
276
- outputTokens,
277
- ...(totalTokens !== undefined ? { totalTokens } : {}),
278
- ...(cacheReadInputTokens !== undefined ? { cacheReadInputTokens } : {}),
279
- ...(cacheCreationInputTokens !== undefined ? { cacheCreationInputTokens } : {}),
280
- ...(reasoningTokens !== undefined ? { reasoningTokens } : {}),
281
- ...(cost !== undefined ? { cost } : {}),
282
- };
278
+ if (cost !== undefined)
279
+ usage.cost = cost;
280
+ return usage;
281
+ }
282
+ export function providerUsageFromOpenAi(value) {
283
+ return value && typeof value === "object" && !Array.isArray(value)
284
+ ? value : undefined;
283
285
  }
284
286
  export function modelRequestsFromOpenAi(value) {
285
287
  if (!value || typeof value !== "object")
286
288
  return undefined;
287
289
  return modelRequestCount(value.model_requests);
288
290
  }
289
- /** Preserve the provider identity that cli-bridge returns on each completion frame. */
291
+ /** Compatibility model and explicit provider observations have different authority. */
290
292
  export function responseIdentityFromOpenAi(value) {
291
- if (!value || typeof value !== "object")
292
- return undefined;
293
- const record = value;
294
- const model = nonEmptyString(record.model);
295
- const systemFingerprint = nonEmptyString(record.system_fingerprint);
296
- if (model === undefined && systemFingerprint === undefined)
293
+ const record = providerUsageFromOpenAi(value);
294
+ if (!record)
297
295
  return undefined;
296
+ if (record.provider_model !== undefined && nonEmptyString(record.provider_model) === undefined) {
297
+ throw new Error("cli-bridge returned an invalid provider model observation");
298
+ }
299
+ const models = record.provider_models;
300
+ if (models !== undefined && (!Array.isArray(models) || models.length > CONTRACT_MAX_ARRAY_LENGTH ||
301
+ models.some(model => nonEmptyString(model) === undefined))) {
302
+ throw new Error("cli-bridge returned invalid provider model observations");
303
+ }
304
+ const observations = [...new Set([
305
+ ...(models ?? []),
306
+ ...(nonEmptyString(record.provider_model) ? [record.provider_model] : []),
307
+ ])];
308
+ if (observations.length > CONTRACT_MAX_ARRAY_LENGTH)
309
+ throw new Error("cli-bridge provider model observations exceed contract bounds");
298
310
  return {
299
- ...(model === undefined ? {} : { model }),
300
- ...(systemFingerprint === undefined ? {} : { system_fingerprint: systemFingerprint }),
311
+ ...(nonEmptyString(record.model) ? { model: record.model } : {}),
312
+ ...(nonEmptyString(record.system_fingerprint) ? { system_fingerprint: record.system_fingerprint } : {}),
313
+ ...(observations.length ? { provider_models: observations } : {}),
314
+ ...(observations.length === 1 ? { provider_model: observations[0] } : {}),
301
315
  };
302
316
  }
303
317
  export function modelRequestCount(value) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tangle-network/agent-provider-cli-bridge",
3
- "version": "2.0.2",
3
+ "version": "2.0.3",
4
4
  "description": "AgentEnvironmentProvider adapter for cli-bridge retained sessions and one-shot turns",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -78,6 +78,6 @@
78
78
  "check-types": "tsc --noEmit",
79
79
  "clean": "rm -rf dist",
80
80
  "test": "vitest run src",
81
- "test:integration": "test -n \"$CLI_BRIDGE_INTEGRATION_ROOT\" || { echo 'CLI_BRIDGE_INTEGRATION_ROOT is required; see README' >&2; exit 1; }; pnpm run build && vitest run tests/cli-bridge.integration.test.ts"
81
+ "test:integration": "test -n \"$CLI_BRIDGE_INTEGRATION_ROOT\" || { echo 'CLI_BRIDGE_INTEGRATION_ROOT is required; see README' >&2; exit 1; }; pnpm run build && vitest run tests"
82
82
  }
83
83
  }