@gajae-code/ai 0.17.2 → 0.17.4

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 (41) hide show
  1. package/CHANGELOG.md +103 -0
  2. package/dist/types/auth-gateway/server.d.ts +23 -1
  3. package/dist/types/auth-storage.d.ts +12 -1
  4. package/dist/types/providers/anthropic.d.ts +1 -1
  5. package/dist/types/providers/cursor.d.ts +10 -0
  6. package/dist/types/providers/openai-completions.d.ts +9 -1
  7. package/dist/types/types.d.ts +16 -0
  8. package/dist/types/utils/discovery/openai-compatible.d.ts +10 -0
  9. package/dist/types/utils/fallback-transport.d.ts +4 -0
  10. package/dist/types/utils/h2-fetch.d.ts +8 -7
  11. package/dist/types/utils/stream-repetition-guard.d.ts +107 -0
  12. package/dist/types/utils/tool-call-healing.d.ts +4 -0
  13. package/dist/types/utils/tool-fence-strip.d.ts +27 -0
  14. package/package.json +3 -3
  15. package/src/auth-gateway/server.ts +48 -9
  16. package/src/auth-storage.ts +185 -48
  17. package/src/model-pricing.ts +22 -0
  18. package/src/model-thinking.ts +40 -4
  19. package/src/models.json +191 -15
  20. package/src/providers/anthropic.d.ts +1 -1
  21. package/src/providers/anthropic.ts +1 -1
  22. package/src/providers/cursor.d.ts +10 -0
  23. package/src/providers/cursor.ts +144 -24
  24. package/src/providers/openai-completions.d.ts +9 -1
  25. package/src/providers/openai-completions.ts +371 -126
  26. package/src/stream.ts +7 -0
  27. package/src/types.d.ts +16 -0
  28. package/src/types.ts +17 -0
  29. package/src/utils/discovery/openai-compatible.ts +16 -2
  30. package/src/utils/fallback-transport.d.ts +4 -0
  31. package/src/utils/fallback-transport.ts +11 -0
  32. package/src/utils/h2-fetch.ts +65 -26
  33. package/src/utils/http-inspector.ts +2 -0
  34. package/src/utils/idle-iterator.ts +109 -96
  35. package/src/utils/json-parse.ts +12 -4
  36. package/src/utils/stream-repetition-guard.d.ts +107 -0
  37. package/src/utils/stream-repetition-guard.ts +290 -0
  38. package/src/utils/tool-call-healing.d.ts +4 -0
  39. package/src/utils/tool-call-healing.ts +4 -0
  40. package/src/utils/tool-fence-strip.d.ts +27 -0
  41. package/src/utils/tool-fence-strip.ts +64 -0
@@ -34,6 +34,7 @@ import {
34
34
  type Model,
35
35
  type OpenAICompat,
36
36
  type ProviderSessionState,
37
+ type RepetitionGuardOptions,
37
38
  resolveServiceTier,
38
39
  type ServiceTier,
39
40
  type StopReason,
@@ -79,6 +80,13 @@ import { callWithCopilotModelRetry } from "../utils/retry";
79
80
  import { resolveRetryBudget } from "../utils/retry-budget";
80
81
  import { adaptSchemaForStrict, flattenToolRootCombinators, NO_STRICT, toolWireSchema } from "../utils/schema";
81
82
  import { wrapFetchForSseDebug } from "../utils/sse-debug";
83
+ import {
84
+ DEFAULT_REPETITION_THRESHOLD,
85
+ REPETITION_GUARD_ERROR_CODE,
86
+ REPETITION_GUARD_STOP_MESSAGE,
87
+ StreamRepetitionGuard,
88
+ type StreamRepetitionTrip,
89
+ } from "../utils/stream-repetition-guard";
82
90
  import { type HealedToolCall, modelMayLeakKimiToolCalls, ToolCallHealer } from "../utils/tool-call-healing";
83
91
  import { isForcedToolChoice, mapToOpenAICompletionsToolChoice } from "../utils/tool-choice";
84
92
  import {
@@ -86,6 +94,7 @@ import {
86
94
  markToolChoiceIncapability,
87
95
  resolveToolChoice,
88
96
  } from "../utils/tool-choice-capability";
97
+ import { ToolFenceStripper } from "../utils/tool-fence-strip";
89
98
  import { COMPOSER_EDIT_DISCIPLINE_PROMPT, isComposerHarnessModel } from "./composer-discipline";
90
99
  import { mergeDashScopeTokenPlanHeaders } from "./dashscope-token-plan-headers";
91
100
  import {
@@ -359,6 +368,14 @@ export interface OpenAICompletionsOptions extends StreamOptions {
359
368
  /** Force-disable reasoning where supported, or request the lowest effort on generic effort endpoints. */
360
369
  disableReasoning?: boolean;
361
370
  serviceTier?: ServiceTier;
371
+ /**
372
+ * Runaway-repetition guard thresholds, per stream channel. A number sets the
373
+ * consecutive-repeat threshold; `false` disables the channel's guard.
374
+ * Defaults: thinking = DEFAULT_REPETITION_THRESHOLD, text = false — visible
375
+ * output is a deliverable and intentional repetition there (logs, fixtures,
376
+ * tables, generated code) must survive byte for byte (#5627).
377
+ */
378
+ repetitionGuard?: RepetitionGuardOptions;
362
379
  }
363
380
 
364
381
  type OpenAICompletionsParams = Omit<OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming, "reasoning_effort"> & {
@@ -530,6 +547,20 @@ const OPENAI_COMPLETIONS_EMPTY_RESPONSE_MESSAGE = "Provider returned an empty re
530
547
  const OPENAI_COMPLETIONS_NETWORK_ERROR_RETRY_MAX_RETRIES = 3;
531
548
  const OPENAI_COMPLETIONS_NETWORK_ERROR_RETRY_BASE_DELAY_MS = 2000;
532
549
 
550
+ // A tripped repetition guard stops *emitting* immediately, so the user-visible
551
+ // symptom is already fixed at the trip. Aborting the stream right then would
552
+ // also drop `tool_calls` frames a provider emits *after* the repeats, losing a
553
+ // valid invocation (#5627). The stream is drained for a bounded window instead;
554
+ // the only thing the abort still buys is not burning provider budget, and that
555
+ // can wait this long.
556
+ const REPETITION_DRAIN_MAX_CHUNKS = 64;
557
+ const REPETITION_DRAIN_MAX_MS = 2_000;
558
+ // A tool call whose accumulated arguments are not yet complete JSON is worth
559
+ // waiting longer for — but not forever, or a call whose arguments never
560
+ // complete would hold the stream open for the rest of the turn's budget.
561
+ const REPETITION_DRAIN_PENDING_TOOL_MAX_CHUNKS = 256;
562
+ const REPETITION_DRAIN_PENDING_TOOL_MAX_MS = 8_000;
563
+
533
564
  function hasReplayUnsafeOpenAICompletionsDelta(chunk: ChatCompletionChunk): boolean {
534
565
  const choice = Array.isArray(chunk.choices) ? chunk.choices[0] : undefined;
535
566
  const delta = choice?.delta;
@@ -584,6 +615,48 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
584
615
  const abortTracker = createAbortSourceTracker(options?.signal);
585
616
  const { requestAbortController, requestSignal } = abortTracker;
586
617
 
618
+ // Declared outside the try so the catch block — which is where the abort
619
+ // below lands — can tell a runaway-repetition stop from a transport error.
620
+ let repetitionTrip: (StreamRepetitionTrip & { channel: "text" | "thinking" }) | undefined;
621
+ // Drain-window bookkeeping: when the trip happened, and how many chunks have
622
+ // been consumed since. Both are only meaningful once `repetitionTrip` is set.
623
+ let repetitionTrippedAt: number | undefined;
624
+ let repetitionDrainedChunks = 0;
625
+ // Set immediately before the guard's own abort and nowhere else. The catch
626
+ // block must be able to tell OUR abort from a provider stall or a transport
627
+ // failure that merely happened to land inside the drain window: `repetitionTrip`
628
+ // alone is true for all three, and using it there discarded the real
629
+ // timeout/transport facts and flipped the retry classification (#5627 review r4).
630
+ let repetitionSelfAbort = false;
631
+ const finalizeRepetitionGuardStop = (): void => {
632
+ if (!repetitionTrip) return;
633
+ // `error`, not `aborted`: this is a provider-side failure we detected
634
+ // locally, and `aborted` is the wire for *client cancellation* — the
635
+ // auth gateway maps it to 499/`request_aborted` and telemetry counts it
636
+ // as a user cancel, so borrowing it misreports the turn (#5627). No new
637
+ // StopReason variant: the union is switched on exhaustively everywhere.
638
+ // `errorCode` stays the bounded classifier for *why* (#5624).
639
+ output.stopReason = "error";
640
+ output.errorCode = REPETITION_GUARD_ERROR_CODE;
641
+ // A fixed literal, never the observed sample/channel/count: the auth
642
+ // gateway forwards `errorMessage` to API clients on the streaming path,
643
+ // so interpolating here publishes raw model output and feeds it to a
644
+ // keyword classifier that picks HTTP status from message text. The
645
+ // diagnostic detail lives in the `logger.debug` at the trip site
646
+ // instead (#5627 review r5).
647
+ output.errorMessage = REPETITION_GUARD_STOP_MESSAGE;
648
+ output.duration = Date.now() - startTime;
649
+ if (firstTokenTime) output.ttft = firstTokenTime - startTime;
650
+ // No `transportFailure`: this is a local decision, not a retryable
651
+ // transport fault, and the agent loop's retry admission keys on that
652
+ // field. The session-layer classifier does not — absent transport facts
653
+ // it defaults to a bounded retry — so it branches on this `errorCode`
654
+ // and treats the trip as terminal instead (#5627). Retrying is pointless
655
+ // anyway: a decode loop is deterministic for the submitted context.
656
+ stream.push({ type: "error", reason: "error", error: output });
657
+ stream.end();
658
+ };
659
+
587
660
  try {
588
661
  const apiKey = options?.apiKey || getEnvApiKey(model.provider) || "";
589
662
  const idleTimeoutMs = options?.streamIdleTimeoutMs ?? getOpenAIStreamIdleTimeoutMs(model.provider, model.id);
@@ -870,15 +943,117 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
870
943
 
871
944
  let taggedTextBuffer = "";
872
945
  let insideTaggedThinking = false;
946
+ // One guard per channel: interleaving visible text and reasoning through
947
+ // a single instance would splice unrelated tokens into the same window.
948
+ // Tool-call frames are never fed through either guard.
949
+ //
950
+ // A disabled channel gets no guard at all rather than a lenient one, so
951
+ // it is structurally impossible for it to set `repetitionTrip`. Visible
952
+ // text is disabled by default: a decode loop there is not the reported
953
+ // failure (#5624 was reasoning-channel), and truncating deliverable
954
+ // output — a log dump, a fixture, a table — corrupts the answer (#5627).
955
+ const createRepetitionGuard = (
956
+ setting: number | false | undefined,
957
+ fallback: number | false,
958
+ ): StreamRepetitionGuard | undefined => {
959
+ const threshold = setting ?? fallback;
960
+ return threshold === false ? undefined : new StreamRepetitionGuard({ threshold });
961
+ };
962
+ const textRepetitionGuard = createRepetitionGuard(options?.repetitionGuard?.text, false);
963
+ const thinkingRepetitionGuard = createRepetitionGuard(
964
+ options?.repetitionGuard?.thinking,
965
+ DEFAULT_REPETITION_THRESHOLD,
966
+ );
967
+ const noteRepetitionTrip = (guard: StreamRepetitionGuard, channel: "text" | "thinking") => {
968
+ // `takeTrip()` latches once per guard; this latches once per request,
969
+ // so the drain window below opens exactly once no matter which
970
+ // channel loops.
971
+ if (repetitionTrip) return;
972
+ const trip = guard.takeTrip();
973
+ if (!trip) return;
974
+ repetitionTrip = { ...trip, channel };
975
+ // The repeated unit is never logged. `logger`'s default transport is a
976
+ // rotating file under `~/.gjc/logs` and `makeLogFormat` JSON-stringifies
977
+ // every metadata key verbatim — no redaction — so a sample would persist
978
+ // raw model output to disk and carry it into log rotation, support
979
+ // bundles and backups. If the loop swallowed a secret or a private
980
+ // fragment of the prompt, that is where it would land (#5627 review r6).
981
+ //
982
+ // Only bounded metadata the model cannot control the *content* of goes
983
+ // out: an id, two enums and two counts. `sampleLength` is deliberately a
984
+ // number, not a hash — a hash of a short secret is a probe oracle and
985
+ // buys nothing for debugging a decode loop. `trip.sample` itself stays on
986
+ // the in-memory trip for callers; this is only the logging contract.
987
+ //
988
+ // Separately, `errorMessage` stays a fixed literal because the gateway
989
+ // forwards it to API clients (#5627 review r5). Both hold at once.
990
+ logger.debug("openai-completions: repetition guard tripped", {
991
+ model: model.id,
992
+ channel,
993
+ kind: trip.kind,
994
+ repeats: trip.repeats,
995
+ sampleLength: trip.sample.length,
996
+ });
997
+ // Deliberately no abort here — see the REPETITION_DRAIN_* constants.
998
+ // The main loop closes the window once late tool-call frames have had
999
+ // their chance to land.
1000
+ repetitionTrippedAt = Date.now();
1001
+ repetitionDrainedChunks = 0;
1002
+ };
1003
+ /**
1004
+ * Closes the post-trip drain window. Called once per consumed chunk after
1005
+ * that chunk is fully processed, so the frames it carried are finalized
1006
+ * before the stream is cut.
1007
+ */
1008
+ const maybeAbortAfterRepetitionDrain = (): void => {
1009
+ if (repetitionTrippedAt === undefined || requestSignal.aborted) return;
1010
+ // Reuses the `stopReason === "length"` truncation check below: an open
1011
+ // tool call whose `partialArgs` will not parse is still mid-flight.
1012
+ const toolCallPending =
1013
+ currentBlock?.type === "toolCall" &&
1014
+ !isCompleteJson((currentBlock as { partialArgs?: string }).partialArgs);
1015
+ const maxChunks = toolCallPending ? REPETITION_DRAIN_PENDING_TOOL_MAX_CHUNKS : REPETITION_DRAIN_MAX_CHUNKS;
1016
+ const maxMs = toolCallPending ? REPETITION_DRAIN_PENDING_TOOL_MAX_MS : REPETITION_DRAIN_MAX_MS;
1017
+ if (repetitionDrainedChunks >= maxChunks || Date.now() - repetitionTrippedAt >= maxMs) {
1018
+ repetitionSelfAbort = true;
1019
+ requestAbortController.abort();
1020
+ }
1021
+ };
1022
+
1023
+ // Reasoning-channel only — a fence token in visible prose must survive
1024
+ // as text (CHANGELOG.md:1094), and the Kimi healer must not see this
1025
+ // channel at all or its holdback buffer corrupts.
1026
+ const thinkingFenceStripper = new ToolFenceStripper();
1027
+ let lastThinkingSignature: string | undefined;
1028
+
1029
+ /** Returns the portion safe to emit — the whole chunk when the channel is unguarded. */
1030
+ const feedRepetitionGuard = (
1031
+ guard: StreamRepetitionGuard | undefined,
1032
+ text: string,
1033
+ channel: "text" | "thinking",
1034
+ ): string => {
1035
+ if (!guard) return text;
1036
+ const emit = guard.feed(text);
1037
+ noteRepetitionTrip(guard, channel);
1038
+ return emit;
1039
+ };
1040
+
873
1041
  const appendTextDelta = (text: string) => {
874
1042
  if (!text) return;
875
1043
  if (!firstTokenTime) firstTokenTime = Date.now();
876
- appendText(output, stream, text);
1044
+ const emit = feedRepetitionGuard(textRepetitionGuard, text, "text");
1045
+ if (emit) appendText(output, stream, emit);
1046
+ };
1047
+ const emitThinkingText = (thinking: string, signature?: string) => {
1048
+ if (!thinking) return;
1049
+ const emit = feedRepetitionGuard(thinkingRepetitionGuard, thinking, "thinking");
1050
+ if (emit) appendThinking(output, stream, emit, signature);
877
1051
  };
878
1052
  const appendThinkingDelta = (thinking: string, signature?: string) => {
879
1053
  if (!thinking) return;
880
1054
  if (!firstTokenTime) firstTokenTime = Date.now();
881
- appendThinking(output, stream, thinking, signature);
1055
+ lastThinkingSignature = signature;
1056
+ emitThinkingText(thinkingFenceStripper.feed(thinking), signature);
882
1057
  };
883
1058
 
884
1059
  const flushTaggedTextBuffer = () => {
@@ -1001,150 +1176,180 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
1001
1176
  };
1002
1177
 
1003
1178
  for await (const chunk of iterateWithNetworkErrorRetry()) {
1004
- if (!chunk || typeof chunk !== "object") continue;
1005
-
1006
- // OpenAI documents ChatCompletionChunk.id as the unique chat completion identifier,
1007
- // and each chunk in a streamed completion carries the same id.
1008
- output.responseId ||= chunk.id;
1009
-
1010
- if (chunk.usage) {
1011
- applyUsage(chunk.usage);
1012
- }
1013
-
1014
- const choice = Array.isArray(chunk.choices) ? chunk.choices[0] : undefined;
1015
- if (!choice) continue;
1016
-
1017
- if (!chunk.usage) {
1018
- const choiceUsage = getChoiceUsage(choice);
1019
- if (choiceUsage) {
1020
- applyUsage(choiceUsage);
1179
+ // Counted before the guarded blocks below so chunks carrying no
1180
+ // delta still spend the drain budget rather than extending it.
1181
+ if (repetitionTrippedAt !== undefined) repetitionDrainedChunks += 1;
1182
+
1183
+ // Positive-form guards instead of early `continue`s: the drain check
1184
+ // at the bottom of the body has to be reached on *every* path, or a
1185
+ // provider that keeps emitting usage-only, keepalive-shaped,
1186
+ // `choices`-less or malformed chunks after a trip never spends the
1187
+ // budget and the request hangs open (#5627 review r5).
1188
+ if (chunk && typeof chunk === "object") {
1189
+ // OpenAI documents ChatCompletionChunk.id as the unique chat completion identifier,
1190
+ // and each chunk in a streamed completion carries the same id.
1191
+ output.responseId ||= chunk.id;
1192
+
1193
+ if (chunk.usage) {
1194
+ applyUsage(chunk.usage);
1021
1195
  }
1022
- }
1023
1196
 
1024
- if (choice.finish_reason) {
1025
- const finishReasonResult = mapStopReason(choice.finish_reason);
1026
- if (choice.finish_reason === "content_filter") {
1027
- markProviderSafetyStop(finishReasonResult.errorMessage);
1028
- } else if (!providerSafetyStop) {
1029
- output.stopReason = finishReasonResult.stopReason;
1030
- if (finishReasonResult.errorMessage) {
1031
- output.errorMessage = finishReasonResult.errorMessage;
1197
+ const choice = Array.isArray(chunk.choices) ? chunk.choices[0] : undefined;
1198
+ if (choice) {
1199
+ if (!chunk.usage) {
1200
+ const choiceUsage = getChoiceUsage(choice);
1201
+ if (choiceUsage) {
1202
+ applyUsage(choiceUsage);
1203
+ }
1032
1204
  }
1033
- }
1034
- }
1035
1205
 
1036
- if (choice.delta) {
1037
- if (typeof choice.delta.refusal === "string" && choice.delta.refusal.length > 0) {
1038
- appendTextDelta(choice.delta.refusal);
1039
- if (!providerSafetyStop) {
1040
- markProviderSafetyStop("Provider returned a safety refusal");
1041
- }
1042
- }
1043
- const normalizedDeltaText = normalizeStreamingContentText(choice.delta.content);
1044
- if (normalizedDeltaText.length > 0) {
1045
- if (!firstTokenTime) firstTokenTime = Date.now();
1046
- if (parseMiniMaxThinkTags) {
1047
- taggedTextBuffer += normalizedDeltaText;
1048
- flushTaggedTextBuffer();
1049
- } else if (stripDeepseekChatTemplateTokens) {
1050
- deepseekStripBuffer += normalizedDeltaText;
1051
- flushDeepseekStripBuffer(false);
1052
- } else if (kimiHealer) {
1053
- const hasStructuredToolCalls =
1054
- Array.isArray(choice.delta.tool_calls) && choice.delta.tool_calls.length > 0;
1055
- if (hasStructuredToolCalls) {
1056
- // Same chunk leaks markers AND carries structured tool_calls.
1057
- // Strip the marker text from visible output, but drop any
1058
- // synthesized calls so the structured payload stays the
1059
- // single source of truth (avoids double-dispatch).
1060
- const clean = kimiHealer.consumeWithoutCalls(normalizedDeltaText);
1061
- if (clean.length > 0) appendTextDelta(clean);
1062
- } else {
1063
- const clean = kimiHealer.feed(normalizedDeltaText);
1064
- if (clean.length > 0) appendTextDelta(clean);
1065
- flushHealedToolCalls();
1206
+ if (choice.finish_reason) {
1207
+ const finishReasonResult = mapStopReason(choice.finish_reason);
1208
+ if (choice.finish_reason === "content_filter") {
1209
+ markProviderSafetyStop(finishReasonResult.errorMessage);
1210
+ } else if (!providerSafetyStop) {
1211
+ output.stopReason = finishReasonResult.stopReason;
1212
+ if (finishReasonResult.errorMessage) {
1213
+ output.errorMessage = finishReasonResult.errorMessage;
1214
+ }
1066
1215
  }
1067
- } else {
1068
- appendTextDelta(normalizedDeltaText);
1069
1216
  }
1070
- }
1071
1217
 
1072
- // Some endpoints return reasoning in reasoning_content (llama.cpp),
1073
- // or reasoning (other openai compatible endpoints)
1074
- // Use the first non-empty reasoning field to avoid duplication
1075
- // (e.g., chutes.ai returns both reasoning_content and reasoning with same content)
1076
- const reasoningFields = ["reasoning_content", "reasoning", "reasoning_text"];
1077
- let foundReasoningField: string | null = null;
1078
- for (const field of reasoningFields) {
1079
- if (
1080
- (choice.delta as any)[field] !== null &&
1081
- (choice.delta as any)[field] !== undefined &&
1082
- (choice.delta as any)[field].length > 0
1083
- ) {
1084
- if (!foundReasoningField) {
1085
- foundReasoningField = field;
1086
- break;
1218
+ if (choice.delta) {
1219
+ if (typeof choice.delta.refusal === "string" && choice.delta.refusal.length > 0) {
1220
+ appendTextDelta(choice.delta.refusal);
1221
+ if (!providerSafetyStop) {
1222
+ markProviderSafetyStop("Provider returned a safety refusal");
1223
+ }
1224
+ }
1225
+ const normalizedDeltaText = normalizeStreamingContentText(choice.delta.content);
1226
+ if (normalizedDeltaText.length > 0) {
1227
+ if (!firstTokenTime) firstTokenTime = Date.now();
1228
+ if (parseMiniMaxThinkTags) {
1229
+ taggedTextBuffer += normalizedDeltaText;
1230
+ flushTaggedTextBuffer();
1231
+ } else if (stripDeepseekChatTemplateTokens) {
1232
+ deepseekStripBuffer += normalizedDeltaText;
1233
+ flushDeepseekStripBuffer(false);
1234
+ } else if (kimiHealer) {
1235
+ const hasStructuredToolCalls =
1236
+ Array.isArray(choice.delta.tool_calls) && choice.delta.tool_calls.length > 0;
1237
+ if (hasStructuredToolCalls) {
1238
+ // Same chunk leaks markers AND carries structured tool_calls.
1239
+ // Strip the marker text from visible output, but drop any
1240
+ // synthesized calls so the structured payload stays the
1241
+ // single source of truth (avoids double-dispatch).
1242
+ const clean = kimiHealer.consumeWithoutCalls(normalizedDeltaText);
1243
+ if (clean.length > 0) appendTextDelta(clean);
1244
+ } else {
1245
+ const clean = kimiHealer.feed(normalizedDeltaText);
1246
+ if (clean.length > 0) appendTextDelta(clean);
1247
+ flushHealedToolCalls();
1248
+ }
1249
+ } else {
1250
+ appendTextDelta(normalizedDeltaText);
1251
+ }
1087
1252
  }
1088
- }
1089
- }
1090
1253
 
1091
- if (foundReasoningField) {
1092
- const delta = (choice.delta as any)[foundReasoningField];
1093
- appendThinkingDelta(delta, foundReasoningField);
1094
- }
1254
+ // Some endpoints return reasoning in reasoning_content (llama.cpp),
1255
+ // or reasoning (other openai compatible endpoints)
1256
+ // Use the first non-empty reasoning field to avoid duplication
1257
+ // (e.g., chutes.ai returns both reasoning_content and reasoning with same content)
1258
+ const reasoningFields = ["reasoning_content", "reasoning", "reasoning_text"];
1259
+ let foundReasoningField: string | null = null;
1260
+ for (const field of reasoningFields) {
1261
+ if (
1262
+ (choice.delta as any)[field] !== null &&
1263
+ (choice.delta as any)[field] !== undefined &&
1264
+ (choice.delta as any)[field].length > 0
1265
+ ) {
1266
+ if (!foundReasoningField) {
1267
+ foundReasoningField = field;
1268
+ break;
1269
+ }
1270
+ }
1271
+ }
1095
1272
 
1096
- if (choice?.delta?.tool_calls && choice.delta.tool_calls.length > 0) {
1097
- for (const toolCall of choice.delta.tool_calls) {
1098
- if (currentBlock?.type !== "toolCall" || (toolCall.id && currentBlock.id !== toolCall.id)) {
1099
- finishCurrentBlock(currentBlock);
1100
- currentBlock = {
1101
- type: "toolCall",
1102
- id: toolCall.id || "",
1103
- name: toolCall.function?.name || "",
1104
- arguments: {},
1105
- partialArgs: "",
1106
- };
1107
- output.content.push(currentBlock);
1108
- stream.push({
1109
- type: "toolcall_start",
1110
- contentIndex: blockIndex(currentBlock),
1111
- partial: output,
1112
- });
1273
+ if (foundReasoningField) {
1274
+ const delta = (choice.delta as any)[foundReasoningField];
1275
+ appendThinkingDelta(delta, foundReasoningField);
1113
1276
  }
1114
1277
 
1115
- if (currentBlock.type === "toolCall") {
1116
- if (toolCall.id) currentBlock.id = toolCall.id;
1117
- if (toolCall.function?.name) currentBlock.name = toolCall.function.name;
1118
- let delta = "";
1119
- if (toolCall.function?.arguments) {
1120
- delta = toolCall.function.arguments;
1121
- currentBlock.partialArgs += toolCall.function.arguments;
1122
- currentBlock.arguments = parseStreamingJson(currentBlock.partialArgs);
1278
+ if (choice?.delta?.tool_calls && choice.delta.tool_calls.length > 0) {
1279
+ for (const toolCall of choice.delta.tool_calls) {
1280
+ if (currentBlock?.type !== "toolCall" || (toolCall.id && currentBlock.id !== toolCall.id)) {
1281
+ finishCurrentBlock(currentBlock);
1282
+ currentBlock = {
1283
+ type: "toolCall",
1284
+ id: toolCall.id || "",
1285
+ name: toolCall.function?.name || "",
1286
+ arguments: {},
1287
+ partialArgs: "",
1288
+ };
1289
+ output.content.push(currentBlock);
1290
+ stream.push({
1291
+ type: "toolcall_start",
1292
+ contentIndex: blockIndex(currentBlock),
1293
+ partial: output,
1294
+ });
1295
+ }
1296
+
1297
+ if (currentBlock.type === "toolCall") {
1298
+ if (toolCall.id) currentBlock.id = toolCall.id;
1299
+ if (toolCall.function?.name) currentBlock.name = toolCall.function.name;
1300
+ let delta = "";
1301
+ if (toolCall.function?.arguments) {
1302
+ delta = toolCall.function.arguments;
1303
+ currentBlock.partialArgs += toolCall.function.arguments;
1304
+ currentBlock.arguments = parseStreamingJson(currentBlock.partialArgs);
1305
+ }
1306
+ stream.push({
1307
+ type: "toolcall_delta",
1308
+ contentIndex: blockIndex(currentBlock),
1309
+ delta,
1310
+ partial: output,
1311
+ });
1312
+ }
1123
1313
  }
1124
- stream.push({
1125
- type: "toolcall_delta",
1126
- contentIndex: blockIndex(currentBlock),
1127
- delta,
1128
- partial: output,
1129
- });
1130
1314
  }
1131
- }
1132
- }
1133
1315
 
1134
- const reasoningDetails = (choice.delta as any).reasoning_details;
1135
- if (reasoningDetails && Array.isArray(reasoningDetails)) {
1136
- for (const detail of reasoningDetails) {
1137
- if (detail.type === "reasoning.encrypted" && detail.id && detail.data) {
1138
- const matchingToolCall = output.content.find(
1139
- b => b.type === "toolCall" && b.id === detail.id,
1140
- ) as ToolCall | undefined;
1141
- if (matchingToolCall) {
1142
- matchingToolCall.thoughtSignature = JSON.stringify(detail);
1316
+ const reasoningDetails = (choice.delta as any).reasoning_details;
1317
+ if (reasoningDetails && Array.isArray(reasoningDetails)) {
1318
+ for (const detail of reasoningDetails) {
1319
+ if (detail.type === "reasoning.encrypted" && detail.id && detail.data) {
1320
+ const matchingToolCall = output.content.find(
1321
+ b => b.type === "toolCall" && b.id === detail.id,
1322
+ ) as ToolCall | undefined;
1323
+ if (matchingToolCall) {
1324
+ matchingToolCall.thoughtSignature = JSON.stringify(detail);
1325
+ }
1326
+ }
1143
1327
  }
1144
1328
  }
1145
1329
  }
1146
1330
  }
1147
1331
  }
1332
+
1333
+ // Single invariant: exactly one evaluation per consumed chunk, on
1334
+ // every path. Two properties ride on this being the last *statement*
1335
+ // of the body rather than a `finally`:
1336
+ //
1337
+ // (a) This chunk is fully processed — anything it carried (including
1338
+ // `tool_calls` frames) has landed. Only now may the drain window
1339
+ // close and cut the stream. The check must never run *before*
1340
+ // the chunk's processing.
1341
+ // (b) A throwing chunk keeps its own transport facts. A `finally`
1342
+ // would also run when the body throws, and this check sets
1343
+ // `repetitionSelfAbort` — which the catch below branches on to
1344
+ // call `finalizeRepetitionGuardStop()`. A malformed payload
1345
+ // throwing inside the drain window would then be re-labelled as
1346
+ // the guard's own abort, discarding the real
1347
+ // errorMessage/errorStatus/transportFailure and flipping retry
1348
+ // admission — exactly the defect fixed in e577268e.
1349
+ //
1350
+ // Caller-abort priority is unchanged: a genuine caller abort still
1351
+ // wins over the guard's self-abort, and a trip is still not a cancel.
1352
+ maybeAbortAfterRepetitionDrain();
1148
1353
  }
1149
1354
 
1150
1355
  if (parseMiniMaxThinkTags && taggedTextBuffer.length > 0) {
@@ -1160,6 +1365,29 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
1160
1365
  flushDeepseekStripBuffer(true);
1161
1366
  }
1162
1367
 
1368
+ // A partial fence held back at the last chunk never completed, so it was
1369
+ // ordinary thinking text after all.
1370
+ emitThinkingText(thinkingFenceStripper.flush(), lastThinkingSignature);
1371
+
1372
+ // Close each guard's in-progress unit now that no more text is coming:
1373
+ // a final repeat with no trailing newline would otherwise go uncounted
1374
+ // and the runaway turn would read as a healthy completion. Must run
1375
+ // after the fence flush above, whose output feeds the thinking guard.
1376
+ //
1377
+ // Normal-completion path ONLY. Never finalize in the catch block: a
1378
+ // stream that threw mid-repeat must keep its own transport facts rather
1379
+ // than be reclassified as a decode loop (#5627 r4, commit c2aa25d30).
1380
+ // No abort either — the stream has already ended, so aborting would set
1381
+ // `repetitionSelfAbort` for nothing.
1382
+ for (const [guard, channel] of [
1383
+ [textRepetitionGuard, "text"],
1384
+ [thinkingRepetitionGuard, "thinking"],
1385
+ ] as const) {
1386
+ if (!guard) continue;
1387
+ guard.finalize();
1388
+ noteRepetitionTrip(guard, channel);
1389
+ }
1390
+
1163
1391
  if (kimiHealer) {
1164
1392
  const trailing = kimiHealer.flushPending();
1165
1393
  if (trailing.length > 0) appendTextDelta(trailing);
@@ -1187,6 +1415,14 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
1187
1415
 
1188
1416
  finishCurrentBlock(currentBlock);
1189
1417
 
1418
+ // A repetition abort usually surfaces as a throw from the stream
1419
+ // iterator, but a host that had already buffered the rest of the
1420
+ // response finishes the loop normally instead. Same outcome either way.
1421
+ if (repetitionTrip) {
1422
+ finalizeRepetitionGuardStop();
1423
+ return;
1424
+ }
1425
+
1190
1426
  const firstEventTimeoutError = abortTracker.getLocalAbortReason();
1191
1427
  if (firstEventTimeoutError) {
1192
1428
  throw firstEventTimeoutError;
@@ -1221,6 +1457,15 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
1221
1457
  stream.end();
1222
1458
  } catch (error) {
1223
1459
  for (const block of output.content) delete (block as any).index;
1460
+ // Our own abort landed here. Classify it before the generic transport
1461
+ // path turns it into a retryable provider error. A caller abort still
1462
+ // wins: the user's cancel is the more meaningful intent. Keyed on the
1463
+ // self-abort flag, not on `repetitionTrip`: a stall or transport error
1464
+ // during the drain window must keep its own facts.
1465
+ if (repetitionSelfAbort && !abortTracker.wasCallerAbort()) {
1466
+ finalizeRepetitionGuardStop();
1467
+ return;
1468
+ }
1224
1469
  const localAbortReason = abortTracker.getLocalAbortReason();
1225
1470
  const normalizedError =
1226
1471
  !streamConnected && model.provider === "alibaba-token-plan" && error instanceof APIConnectionTimeoutError
package/src/stream.ts CHANGED
@@ -500,6 +500,9 @@ export async function complete<TApi extends Api>(
500
500
  options?: OptionsForApi<TApi>,
501
501
  ): Promise<AssistantMessage> {
502
502
  const s = stream(model, context, options);
503
+ for await (const _event of s) {
504
+ // Completion callers only need the terminal message, not buffered events.
505
+ }
503
506
  return s.result();
504
507
  }
505
508
 
@@ -824,6 +827,9 @@ export async function completeSimple<TApi extends Api>(
824
827
  options?: SimpleStreamOptions,
825
828
  ): Promise<AssistantMessage> {
826
829
  const s = streamSimple(model, context, options);
830
+ for await (const _event of s) {
831
+ // Completion callers only need the terminal message, not buffered events.
832
+ }
827
833
  return s.result();
828
834
  }
829
835
 
@@ -1113,6 +1119,7 @@ function mapOptionsForApi<TApi extends Api>(
1113
1119
  : options?.disableReasoning,
1114
1120
  toolChoice: mapOpenAiToolChoice(options?.toolChoice),
1115
1121
  serviceTier: options?.serviceTier,
1122
+ repetitionGuard: options?.repetitionGuard,
1116
1123
  });
1117
1124
 
1118
1125
  case "openai-responses":
package/src/types.d.ts CHANGED
@@ -338,6 +338,14 @@ export interface AttemptScopeRef {
338
338
  readonly generation: number;
339
339
  readonly lineage: string;
340
340
  }
341
+ /**
342
+ * Runaway-repetition guard thresholds, per stream channel. A number sets the
343
+ * consecutive-repeat threshold; `false` disables that channel's guard.
344
+ */
345
+ export interface RepetitionGuardOptions {
346
+ thinking?: number | false;
347
+ text?: number | false;
348
+ }
341
349
  export interface SimpleStreamOptions extends StreamOptions {
342
350
  reasoning?: Effort;
343
351
  /**
@@ -373,6 +381,14 @@ export interface SimpleStreamOptions extends StreamOptions {
373
381
  syntheticApiFormat?: "openai" | "anthropic";
374
382
  /** Hint that websocket transport should be preferred when supported by the provider implementation. */
375
383
  preferWebsockets?: boolean;
384
+ /**
385
+ * Runaway-repetition guard thresholds, per stream channel. Honoured by the
386
+ * openai-completions transport; ignored by providers without a guard.
387
+ * Defaults: thinking = DEFAULT_REPETITION_THRESHOLD, text = false — visible
388
+ * output is a deliverable and intentional repetition there (logs, fixtures,
389
+ * tables, generated code) must survive byte for byte (#5627).
390
+ */
391
+ repetitionGuard?: RepetitionGuardOptions;
376
392
  }
377
393
  export type StreamFunction<TApi extends Api> = (model: Model<TApi>, context: Context, options: OptionsForApi<TApi>) => AssistantMessageEventStream;
378
394
  export interface TextSignatureV1 {