chainlesschain 0.166.131 → 0.166.133

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 (32) hide show
  1. package/README.md +46 -18
  2. package/package.json +1 -1
  3. package/src/assets/linux-subreaper/linux-arm64/manifest.json +1 -1
  4. package/src/assets/linux-subreaper/linux-x64/manifest.json +1 -1
  5. package/src/data/changelog.json +26 -0
  6. package/src/gateways/ws/llm-chat-protocol.js +1 -1
  7. package/src/gateways/ws/personal-data-hub-protocol.js +27 -11
  8. package/src/gateways/ws/session-core-protocol.js +22 -17
  9. package/src/gateways/ws/session-protocol.js +19 -8
  10. package/src/gateways/ws/ws-agent-handler.js +30 -0
  11. package/src/lib/chat-core.js +40 -35
  12. package/src/lib/context-memory-kernel/message-adapter.js +27 -6
  13. package/src/lib/context-memory-kernel/provider-context.js +48 -3
  14. package/src/lib/evolution/agent-model-projection.js +0 -1
  15. package/src/lib/evolution/governed-hub-llm.js +5 -1
  16. package/src/lib/evolution/governed-hub-resolver.js +15 -6
  17. package/src/lib/evolution/governed-hub-skill.js +2 -0
  18. package/src/lib/evolution/optional-ui-model-turn.js +13 -2
  19. package/src/lib/llm-config-defaults.js +17 -5
  20. package/src/lib/llm-connection-config.js +10 -2
  21. package/src/lib/llm-connection-probe.js +5 -2
  22. package/src/lib/llm-connection-resolution.js +7 -4
  23. package/src/lib/llm-providers.js +24 -11
  24. package/src/lib/openai-responses.js +2 -1
  25. package/src/lib/personal-data-hub-wiring.js +69 -36
  26. package/src/lib/provider-protocol.js +35 -3
  27. package/src/lib/provider-stream.js +6 -4
  28. package/src/lib/runnable-provider.js +4 -0
  29. package/src/lib/ws-chat-handler.js +22 -9
  30. package/src/runtime/agent-core.js +201 -15
  31. package/src/runtime/message-roles.js +67 -3
  32. package/src/runtime/provider-stream-state.js +35 -1
@@ -1,3 +1,4 @@
1
+ import { projectMessagesWithTaskBinding } from "./message-roles.js";
1
2
  import {
2
3
  providerFetch,
3
4
  requireUnscopedToolExecution,
@@ -9,7 +10,10 @@ import {
9
10
  normalizeProviderBaseUrl,
10
11
  openAICompatibleBaseUrl,
11
12
  } from "../lib/provider-protocol.js";
12
- import { BUILT_IN_PROVIDERS } from "../lib/llm-providers.js";
13
+ import {
14
+ BUILT_IN_PROVIDERS,
15
+ resolveProviderDefinition,
16
+ } from "../lib/llm-providers.js";
13
17
  import { resolveLlmConnection } from "../lib/llm-connection-resolution.js";
14
18
  /**
15
19
  * Agent Core — transport-independent agentic logic
@@ -28,6 +32,7 @@ import { resolveLlmConnection } from "../lib/llm-connection-resolution.js";
28
32
  */
29
33
 
30
34
  import fs from "fs";
35
+ import { isDeepStrictEqual } from "node:util";
31
36
  import { isUtf8 } from "node:buffer";
32
37
  import {
33
38
  _isNonNegativeSafeInteger,
@@ -272,6 +277,8 @@ import { isMcpRpcError } from "../harness/mcp-client.js";
272
277
  import {
273
278
  projectCanonicalResumeMessages,
274
279
  markRuntimeOnlySessionMessage,
280
+ isRuntimeOnlySessionMessage,
281
+ encodePersistedMessage,
275
282
  } from "../lib/session-message-provenance.js";
276
283
  import { releaseOldLiveSessionResults } from "../lib/session-runtime-retention.js";
277
284
  import {
@@ -12118,11 +12125,65 @@ function getEffectiveToolDefinitions(options = {}) {
12118
12125
  });
12119
12126
  }
12120
12127
 
12128
+ function projectExactFileMutationToolSchemas(
12129
+ definitions,
12130
+ scope,
12131
+ externalToolExecutors,
12132
+ ) {
12133
+ if (scope == null) return definitions;
12134
+ return definitions.map((tool) => {
12135
+ const name = tool?.function?.name;
12136
+ if (
12137
+ !["write_file", "edit_file", "edit_file_hashed"].includes(name) ||
12138
+ !AGENT_TOOLS.includes(tool) ||
12139
+ externalToolExecutors?.[name]
12140
+ ) {
12141
+ return tool;
12142
+ }
12143
+ const parameters = tool.function.parameters;
12144
+ return {
12145
+ ...tool,
12146
+ function: {
12147
+ ...tool.function,
12148
+ parameters: {
12149
+ ...parameters,
12150
+ properties: {
12151
+ ...parameters.properties,
12152
+ path: {
12153
+ ...parameters.properties.path,
12154
+ // Complete host-authorized set: never truncate this enum or
12155
+ // derive it from a read result. Normal tool context budgeting
12156
+ // sees the same schema that is sent to the provider.
12157
+ enum: [...scope.allowedPaths],
12158
+ description:
12159
+ "Use exactly one listed canonical repo-relative path. Read tools may return absolute paths; those are not valid mutation arguments in this run. Do not use an absolute path, ./ prefix, .. segment or backslash. This schema is guidance; all execution-time permission and file-identity checks still apply.",
12160
+ },
12161
+ },
12162
+ },
12163
+ },
12164
+ };
12165
+ });
12166
+ }
12167
+
12121
12168
  function getProviderToolDefinitions(options = {}) {
12122
- return projectMcpToolDefinitions(
12169
+ const definitions = projectMcpToolDefinitions(
12123
12170
  getEffectiveToolDefinitions(options),
12124
12171
  options.externalToolExecutors,
12125
12172
  );
12173
+ if (options.fileMutationScope == null) return definitions;
12174
+ if (options.exactToolNames !== true || options.hermeticExecution !== true) {
12175
+ throw new Error(
12176
+ "exact file mutation scope requires exactToolNames=true and hermeticExecution=true",
12177
+ );
12178
+ }
12179
+ const scope = normalizeExactFileMutationScope(options.fileMutationScope, {
12180
+ cwd: options.cwd || process.cwd(),
12181
+ });
12182
+ return projectExactFileMutationToolSchemas(
12183
+ definitions,
12184
+ scope,
12185
+ options.externalToolExecutors,
12186
+ );
12126
12187
  }
12127
12188
 
12128
12189
  const PROVIDER_REQUEST_ID_RE = /^[\x21-\x7e]{1,512}$/;
@@ -12536,7 +12597,7 @@ export async function chatWithTools(rawMessages, options) {
12536
12597
  }
12537
12598
 
12538
12599
  // OpenAI-compatible providers
12539
- const providerDefinition = BUILT_IN_PROVIDERS[provider];
12600
+ const providerDefinition = resolveProviderDefinition(provider);
12540
12601
  const url = openAICompatibleBaseUrl(provider, baseUrl);
12541
12602
 
12542
12603
  if (!providerDefinition || !url) {
@@ -12551,7 +12612,8 @@ export async function chatWithTools(rawMessages, options) {
12551
12612
 
12552
12613
  const envKey = providerDefinition.apiKeyEnv;
12553
12614
  const key = apiKey;
12554
- if (!key) throw new Error(`${envKey} required for provider ${provider}`);
12615
+ if (!key && provider !== "custom")
12616
+ throw new Error(`${envKey} required for provider ${provider}`);
12555
12617
  const effectiveModel = model || providerDefinition.models?.[0];
12556
12618
  const modelProfile = resolveModelCapabilityProfile({
12557
12619
  provider,
@@ -12691,7 +12753,7 @@ export async function chatWithTools(rawMessages, options) {
12691
12753
  method: "POST",
12692
12754
  headers: {
12693
12755
  "Content-Type": "application/json",
12694
- Authorization: `Bearer ${key}`,
12756
+ ...(key ? { Authorization: `Bearer ${key}` } : {}),
12695
12757
  ...(supportsOpenAIRequestIdentity && providerRequestId
12696
12758
  ? { "X-Client-Request-Id": providerRequestId }
12697
12759
  : {}),
@@ -12748,6 +12810,10 @@ export async function chatWithTools(rawMessages, options) {
12748
12810
  message.tool_calls = providerMessage.tool_calls;
12749
12811
  }
12750
12812
  const out = { message };
12813
+ if (choice.finish_reason != null) {
12814
+ out.finishReason = choice.finish_reason;
12815
+ out.providerTerminationComplete = data.choices.length === 1;
12816
+ }
12751
12817
  const providerReceipt = supportsOpenAIRequestIdentity
12752
12818
  ? _openAIProviderRequestReceipt(response, data, providerRequestId)
12753
12819
  : null;
@@ -13307,7 +13373,7 @@ async function _chatOpenAIStreaming(
13307
13373
  method: "POST",
13308
13374
  headers: {
13309
13375
  "Content-Type": "application/json",
13310
- Authorization: `Bearer ${apiKey}`,
13376
+ ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
13311
13377
  ...(providerRequestId
13312
13378
  ? { "X-Client-Request-Id": providerRequestId }
13313
13379
  : {}),
@@ -14800,6 +14866,30 @@ function permissionDecision(callId, tool, result) {
14800
14866
  }
14801
14867
 
14802
14868
  export async function* agentLoop(messages, options) {
14869
+ // Bind the actual task before compaction, runtime nudges or provider projection.
14870
+ // A new host-admitted user turn starts a new loop and receives its own binding.
14871
+ const currentUserTask = messages.findLast(
14872
+ (message) =>
14873
+ message?.role === "user" && !isRuntimeOnlySessionMessage(message),
14874
+ );
14875
+ const currentUserTaskSnapshot = currentUserTask
14876
+ ? encodePersistedMessage(currentUserTask)
14877
+ : null;
14878
+ const currentUserTaskBinding = currentUserTaskSnapshot
14879
+ ? {
14880
+ message: currentUserTaskSnapshot,
14881
+ original: currentUserTask,
14882
+ allowContentRelocation:
14883
+ messages.filter(
14884
+ (message) =>
14885
+ message?.role === "user" &&
14886
+ isDeepStrictEqual(
14887
+ encodePersistedMessage(message),
14888
+ currentUserTaskSnapshot,
14889
+ ),
14890
+ ).length === 1,
14891
+ }
14892
+ : null;
14803
14893
  if (options.serviceTier != null) {
14804
14894
  resolveModelServiceTier(
14805
14895
  resolveModelCapabilityProfile(options),
@@ -14986,6 +15076,7 @@ export async function* agentLoop(messages, options) {
14986
15076
  let effectiveToolOptions = hermeticExecution
14987
15077
  ? {
14988
15078
  ...options,
15079
+ fileMutationScope,
14989
15080
  hostManagedToolPolicy: null,
14990
15081
  hostManagedToolPolicyAuthority: null,
14991
15082
  extraToolDefinitions: [],
@@ -15384,6 +15475,8 @@ export async function* agentLoop(messages, options) {
15384
15475
  // one-shot guard means a model that keeps returning empty turns completes
15385
15476
  // rather than looping forever (the iteration budget is the hard backstop).
15386
15477
  let emptyThinkingReprompted = false;
15478
+ let outputLimitRecoveryUsed = false;
15479
+ let outputLimitRecoveryPending = null;
15387
15480
  let readFileLoopGuard = new ReadFileLoopGuard();
15388
15481
  let remoteReadLoopGuard = new RemoteReadLoopGuard();
15389
15482
  const prActionTask = isPrActionRequest(messages);
@@ -16316,27 +16409,43 @@ export async function* agentLoop(messages, options) {
16316
16409
  index === resultIndex ? { ...result, content } : message,
16317
16410
  );
16318
16411
  } else {
16319
- // Full compaction can leave only the pinned request. Reiterate that
16320
- // exact request after the source data, not a replacement objective.
16321
- const request = requestMessages.findLast(
16412
+ // Full compaction can leave only the pinned request. Move that same
16413
+ // message after the source data, preserving its metadata and identity.
16414
+ const requestIndex = requestMessages.findLastIndex(
16322
16415
  (message) => message.role === "user",
16323
16416
  );
16417
+ const request = requestMessages[requestIndex];
16324
16418
  callMessages = [
16325
- ...requestMessages,
16419
+ ...requestMessages.filter((_, index) => index !== requestIndex),
16326
16420
  ...readContext.map((content) => ({ role: "assistant", content })),
16327
- {
16328
- role: "user",
16329
- content: request?.content || "Continue the original user task.",
16330
- },
16421
+ ...(request ? [request] : []),
16331
16422
  ];
16332
16423
  }
16333
16424
  }
16334
16425
  // Role normalization is request-only: durable history remains unchanged
16335
16426
  // for tool settlement and canonical compaction authority comparisons.
16427
+ let projectedUserTaskBinding = currentUserTaskBinding;
16336
16428
  if (typeof options.projectProviderMessages === "function") {
16337
- callMessages = options.projectProviderMessages(callMessages);
16429
+ const projected = projectMessagesWithTaskBinding(
16430
+ options.projectProviderMessages,
16431
+ callMessages,
16432
+ currentUserTaskBinding,
16433
+ );
16434
+ callMessages = projected.messages;
16435
+ projectedUserTaskBinding = projected.taskBinding;
16338
16436
  }
16339
16437
  const contextMemoryTrustedSystemIndexes = [];
16438
+ if (outputLimitRecoveryPending !== null) {
16439
+ // A synthetic user turn would replace the protected real task under
16440
+ // canonical context pressure. Keep this one-request hint out of history
16441
+ // and explicitly admit it as trusted system policy in the next plan.
16442
+ callMessages = [
16443
+ ...callMessages,
16444
+ { role: "system", content: outputLimitRecoveryPending },
16445
+ ];
16446
+ contextMemoryTrustedSystemIndexes.push(callMessages.length - 1);
16447
+ outputLimitRecoveryPending = null;
16448
+ }
16340
16449
  if (Number.isFinite(budget.limit)) {
16341
16450
  // consume() already charged this dispatch. The current request may still
16342
16451
  // act even when no further provider request remains. Rebuild this hint
@@ -16496,6 +16605,11 @@ export async function* agentLoop(messages, options) {
16496
16605
  contextMemoryToolDefinitions:
16497
16606
  getProviderToolDefinitions(iterationToolOptions),
16498
16607
  contextMemoryTrustedSystemIndexes,
16608
+ // Independent review uses its existing merged requirement journal and
16609
+ // validates every original requirement after planning below.
16610
+ contextMemoryRequiredUserBinding: independentReviewContext?.active
16611
+ ? null
16612
+ : projectedUserTaskBinding,
16499
16613
  },
16500
16614
  );
16501
16615
  if (canonicalProviderContext.plan) {
@@ -16761,6 +16875,78 @@ export async function* agentLoop(messages, options) {
16761
16875
  throw error;
16762
16876
  }
16763
16877
 
16878
+ if (result?.finishReason === "content_filter") {
16879
+ // Usage has settled above. A provider-filtered round is never a complete
16880
+ // answer or an executable tool batch, and must not trigger a fresh call.
16881
+ await _awaitBackgroundUsageSettlement(
16882
+ backgroundSubAgents,
16883
+ backgroundUsageFailureState,
16884
+ );
16885
+ const error = new Error(
16886
+ "The provider filtered this response. No tools from that response were executed; the task remains incomplete.",
16887
+ );
16888
+ error.code = "CC_MODEL_CONTENT_FILTER";
16889
+ throw error;
16890
+ }
16891
+
16892
+ if (
16893
+ result?.providerTerminationComplete === false &&
16894
+ result.finishReason != null &&
16895
+ result.finishReason !== "length"
16896
+ ) {
16897
+ // Explicit ambiguity is not an executable or final provider round.
16898
+ // Keep absent-reason compatibility and the separate length policy.
16899
+ await _awaitBackgroundUsageSettlement(
16900
+ backgroundSubAgents,
16901
+ backgroundUsageFailureState,
16902
+ );
16903
+ const error = new Error(
16904
+ "The provider response has an incomplete or ambiguous termination. No tools from that response were executed; the task remains incomplete.",
16905
+ );
16906
+ error.code = "CC_MODEL_TERMINATION_INCOMPLETE";
16907
+ throw error;
16908
+ }
16909
+
16910
+ if (result?.finishReason === "length") {
16911
+ // Settle the completed physical request above before any decision. Never
16912
+ // execute even syntactically valid calls from an incomplete generation,
16913
+ // and never insert its partial assistant/tool group into canonical history.
16914
+ const canRecover =
16915
+ result.providerTerminationComplete === true &&
16916
+ !msg?._truncated &&
16917
+ _hasCompleteProviderUsage(result.usage) &&
16918
+ !outputLimitRecoveryUsed &&
16919
+ budget.hasRemaining() &&
16920
+ !prInvestigationExhausted &&
16921
+ !inspectionSynthesisRequired;
16922
+ if (!canRecover) {
16923
+ await _awaitBackgroundUsageSettlement(
16924
+ backgroundSubAgents,
16925
+ backgroundUsageFailureState,
16926
+ );
16927
+ const error = new Error(
16928
+ "Model output reached its token limit. No tools from that response were executed; the task remains incomplete.",
16929
+ );
16930
+ error.code = "CC_MODEL_OUTPUT_LIMIT";
16931
+ throw error;
16932
+ }
16933
+ outputLimitRecoveryUsed = true;
16934
+ outputLimitRecoveryPending =
16935
+ "The previous model response reached its output token limit and was discarded. " +
16936
+ "None of its tool calls were executed. Continue from the last confirmed tool results. " +
16937
+ "Produce one small, complete operation per response. For large files, write a minimal " +
16938
+ "complete scaffold first, then use separate small edit_file calls to add sections. " +
16939
+ "Do not resend the entire large file or continue an unfinished JSON fragment. " +
16940
+ "If no tools are needed, give a shorter complete answer.";
16941
+ yield {
16942
+ type: "iteration-warning",
16943
+ message:
16944
+ "Model output was truncated; requesting one smaller complete step within the remaining budget.",
16945
+ };
16946
+ // This is a fresh budgeted iteration with new input, never a transport retry.
16947
+ continue;
16948
+ }
16949
+
16764
16950
  if (!msg) {
16765
16951
  await _awaitBackgroundUsageSettlement(
16766
16952
  backgroundSubAgents,
@@ -1,3 +1,4 @@
1
+ import { isDeepStrictEqual } from "node:util";
1
2
  import { canonicalJson } from "@chainlesschain/context-memory-kernel";
2
3
 
3
4
  /**
@@ -119,6 +120,8 @@ export function mergeConsecutiveMessages(messages) {
119
120
  * or folding newly added interrupt/feedback turns. Canonical fingerprints also
120
121
  * recognize the same retained messages after compaction recreates objects.
121
122
  */
123
+ const RESUME_ROLE_PROJECTIONS = new WeakMap();
124
+
122
125
  export function createResumeRoleProjection(messages) {
123
126
  const pairs = new Map();
124
127
  const pairKey = (left, right) => canonicalJson([left, right]);
@@ -137,20 +140,81 @@ export function createResumeRoleProjection(messages) {
137
140
  pairs.set(key, (pairs.get(key) || 0) + 1);
138
141
  }
139
142
  }
140
- return (requestMessages) => {
143
+ const project = (requestMessages) => {
141
144
  const remaining = new Map(pairs);
142
145
  const projected = [];
146
+ const sources = [];
143
147
  for (let index = 0; index < requestMessages.length; index++) {
144
148
  const message = requestMessages[index];
145
149
  const key =
146
150
  index > 0 ? pairKey(requestMessages[index - 1], message) : null;
147
151
  if (key && remaining.get(key) > 0) {
148
152
  const previous = projected.pop();
153
+ const previousSources = sources.pop();
149
154
  projected.push(...mergeConsecutiveMessages([previous, message]));
155
+ sources.push([...previousSources, index]);
150
156
  remaining.set(key, remaining.get(key) - 1);
151
- } else projected.push(message);
157
+ } else {
158
+ projected.push(message);
159
+ sources.push([index]);
160
+ }
152
161
  }
153
- return projected;
162
+ return { messages: projected, sources };
163
+ };
164
+ const projection = (requestMessages) => project(requestMessages).messages;
165
+ RESUME_ROLE_PROJECTIONS.set(projection, project);
166
+ return projection;
167
+ }
168
+
169
+ /**
170
+ * Preserve a bound task through this module's exact, captured resume merges.
171
+ * Arbitrary callbacks, wrappers and forged properties never receive lineage.
172
+ */
173
+ export function projectMessagesWithTaskBinding(projection, input, taskBinding) {
174
+ const owned = RESUME_ROLE_PROJECTIONS.get(projection);
175
+ if (!owned) {
176
+ return { messages: projection(input), taskBinding };
177
+ }
178
+ const projected = owned(input);
179
+ if (!taskBinding) return { messages: projected.messages, taskBinding };
180
+ const identities = input.flatMap((message, index) =>
181
+ message === taskBinding.original ? [index] : [],
182
+ );
183
+ const candidates = identities.length
184
+ ? identities
185
+ : taskBinding.allowContentRelocation === true
186
+ ? input.map((_, index) => index)
187
+ : [];
188
+ const matches = candidates.filter(
189
+ (index) =>
190
+ input[index]?.role === "user" &&
191
+ isDeepStrictEqual(input[index], taskBinding.message),
192
+ );
193
+ // Leave invalid bindings intact so canonical admission rejects them normally.
194
+ if (matches.length !== 1 || identities.length > 1)
195
+ return { messages: projected.messages, taskBinding };
196
+ const sourceIndex = matches[0];
197
+ const targetIndex = projected.sources.findIndex((group) =>
198
+ group.includes(sourceIndex),
199
+ );
200
+ const target = projected.messages[targetIndex];
201
+ if (target === input[sourceIndex])
202
+ return { messages: projected.messages, taskBinding };
203
+ // A merge may discard right-message metadata. Only bare user content has a
204
+ // complete preservation proof under the established joinContent contract.
205
+ if (
206
+ Object.keys(taskBinding.message).some(
207
+ (key) => !["role", "content"].includes(key),
208
+ )
209
+ )
210
+ return { messages: projected.messages, taskBinding };
211
+ return {
212
+ messages: projected.messages,
213
+ taskBinding: {
214
+ message: JSON.parse(canonicalJson(target)),
215
+ original: target,
216
+ allowContentRelocation: false,
217
+ },
154
218
  };
155
219
  }
156
220
 
@@ -291,6 +291,9 @@ export function _openaiCachedTokens(usage) {
291
291
  export function _openaiInitState() {
292
292
  return {
293
293
  responseId: null,
294
+ finishReason: null,
295
+ streamDone: false,
296
+ terminationInvalid: false,
294
297
  text: "",
295
298
  tools: [],
296
299
  inputTokens: null,
@@ -304,13 +307,39 @@ export function _openaiReduceLine(state, raw, onToken) {
304
307
  const line = (raw || "").trim();
305
308
  if (!line.startsWith("data:")) return state;
306
309
  const payload = line.slice(5).trim();
307
- if (!payload || payload === "[DONE]") return state;
310
+ if (!payload) return state;
311
+ if (payload === "[DONE]") {
312
+ if (!state.finishReason || state.streamDone)
313
+ state.terminationInvalid = true;
314
+ state.streamDone = true;
315
+ return state;
316
+ }
308
317
  let obj;
309
318
  try {
310
319
  obj = JSON.parse(payload);
311
320
  } catch {
321
+ state.terminationInvalid = true;
312
322
  return state;
313
323
  }
324
+ if (state.streamDone || obj.error) state.terminationInvalid = true;
325
+ if (!Array.isArray(obj.choices) || obj.choices.length > 1) {
326
+ state.terminationInvalid = true;
327
+ }
328
+ const choice = obj.choices?.[0];
329
+ if (choice) {
330
+ if (state.finishReason || (choice.index != null && choice.index !== 0))
331
+ state.terminationInvalid = true;
332
+ if (choice.finish_reason != null) {
333
+ // Terminal filtering cannot be downgraded to stop/tools by a later
334
+ // conflicting frame. Length likewise remains non-complete.
335
+ if (
336
+ state.finishReason !== "content_filter" &&
337
+ (state.finishReason !== "length" ||
338
+ choice.finish_reason === "content_filter")
339
+ )
340
+ state.finishReason = choice.finish_reason;
341
+ }
342
+ }
314
343
  if (!state.responseId) state.responseId = _providerReceiptId(obj.id);
315
344
  const pricing = projectEnvelopePricingContext(obj.usage || {}, obj);
316
345
  for (const [key, value] of Object.entries(pricing)) {
@@ -376,6 +405,11 @@ export function _openaiFinalize(state) {
376
405
  const message = { role: "assistant", content: state.text };
377
406
  if (toolCalls.length) message.tool_calls = toolCalls;
378
407
  const out = { message };
408
+ if (state.finishReason != null) {
409
+ out.finishReason = state.finishReason;
410
+ out.providerTerminationComplete =
411
+ state.streamDone && !state.terminationInvalid;
412
+ }
379
413
  if (
380
414
  state.usageInvalid !== true &&
381
415
  _isNonNegativeSafeInteger(state.inputTokens) &&