@gajae-code/ai 0.17.2 → 0.17.5

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 (43) hide show
  1. package/CHANGELOG.md +118 -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 +194 -52
  17. package/src/model-pricing.ts +22 -0
  18. package/src/model-thinking.ts +40 -4
  19. package/src/models.json +10060 -3164
  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/kiro-api-key.ts +7 -0
  25. package/src/providers/openai-completions.d.ts +9 -1
  26. package/src/providers/openai-completions.ts +410 -150
  27. package/src/providers/transform-messages.ts +54 -1
  28. package/src/stream.ts +7 -0
  29. package/src/types.d.ts +16 -0
  30. package/src/types.ts +17 -0
  31. package/src/utils/discovery/openai-compatible.ts +16 -2
  32. package/src/utils/fallback-transport.d.ts +4 -0
  33. package/src/utils/fallback-transport.ts +11 -0
  34. package/src/utils/h2-fetch.ts +65 -26
  35. package/src/utils/http-inspector.ts +2 -0
  36. package/src/utils/idle-iterator.ts +109 -96
  37. package/src/utils/json-parse.ts +12 -4
  38. package/src/utils/stream-repetition-guard.d.ts +107 -0
  39. package/src/utils/stream-repetition-guard.ts +290 -0
  40. package/src/utils/tool-call-healing.d.ts +4 -0
  41. package/src/utils/tool-call-healing.ts +4 -0
  42. package/src/utils/tool-fence-strip.d.ts +27 -0
  43. package/src/utils/tool-fence-strip.ts +64 -0
@@ -8,6 +8,7 @@ import type {
8
8
  ChatCompletionContentPartImage,
9
9
  ChatCompletionContentPartText,
10
10
  ChatCompletionMessageParam,
11
+ ChatCompletionToolChoiceOption,
11
12
  ChatCompletionToolMessageParam,
12
13
  } from "openai/resources/chat/completions";
13
14
  import packageJson from "../../package.json" with { type: "json" };
@@ -34,6 +35,7 @@ import {
34
35
  type Model,
35
36
  type OpenAICompat,
36
37
  type ProviderSessionState,
38
+ type RepetitionGuardOptions,
37
39
  resolveServiceTier,
38
40
  type ServiceTier,
39
41
  type StopReason,
@@ -79,6 +81,13 @@ import { callWithCopilotModelRetry } from "../utils/retry";
79
81
  import { resolveRetryBudget } from "../utils/retry-budget";
80
82
  import { adaptSchemaForStrict, flattenToolRootCombinators, NO_STRICT, toolWireSchema } from "../utils/schema";
81
83
  import { wrapFetchForSseDebug } from "../utils/sse-debug";
84
+ import {
85
+ DEFAULT_REPETITION_THRESHOLD,
86
+ REPETITION_GUARD_ERROR_CODE,
87
+ REPETITION_GUARD_STOP_MESSAGE,
88
+ StreamRepetitionGuard,
89
+ type StreamRepetitionTrip,
90
+ } from "../utils/stream-repetition-guard";
82
91
  import { type HealedToolCall, modelMayLeakKimiToolCalls, ToolCallHealer } from "../utils/tool-call-healing";
83
92
  import { isForcedToolChoice, mapToOpenAICompletionsToolChoice } from "../utils/tool-choice";
84
93
  import {
@@ -86,6 +95,7 @@ import {
86
95
  markToolChoiceIncapability,
87
96
  resolveToolChoice,
88
97
  } from "../utils/tool-choice-capability";
98
+ import { ToolFenceStripper } from "../utils/tool-fence-strip";
89
99
  import { COMPOSER_EDIT_DISCIPLINE_PROMPT, isComposerHarnessModel } from "./composer-discipline";
90
100
  import { mergeDashScopeTokenPlanHeaders } from "./dashscope-token-plan-headers";
91
101
  import {
@@ -359,6 +369,14 @@ export interface OpenAICompletionsOptions extends StreamOptions {
359
369
  /** Force-disable reasoning where supported, or request the lowest effort on generic effort endpoints. */
360
370
  disableReasoning?: boolean;
361
371
  serviceTier?: ServiceTier;
372
+ /**
373
+ * Runaway-repetition guard thresholds, per stream channel. A number sets the
374
+ * consecutive-repeat threshold; `false` disables the channel's guard.
375
+ * Defaults: thinking = DEFAULT_REPETITION_THRESHOLD, text = false — visible
376
+ * output is a deliverable and intentional repetition there (logs, fixtures,
377
+ * tables, generated code) must survive byte for byte (#5627).
378
+ */
379
+ repetitionGuard?: RepetitionGuardOptions;
362
380
  }
363
381
 
364
382
  type OpenAICompletionsParams = Omit<OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming, "reasoning_effort"> & {
@@ -530,6 +548,20 @@ const OPENAI_COMPLETIONS_EMPTY_RESPONSE_MESSAGE = "Provider returned an empty re
530
548
  const OPENAI_COMPLETIONS_NETWORK_ERROR_RETRY_MAX_RETRIES = 3;
531
549
  const OPENAI_COMPLETIONS_NETWORK_ERROR_RETRY_BASE_DELAY_MS = 2000;
532
550
 
551
+ // A tripped repetition guard stops *emitting* immediately, so the user-visible
552
+ // symptom is already fixed at the trip. Aborting the stream right then would
553
+ // also drop `tool_calls` frames a provider emits *after* the repeats, losing a
554
+ // valid invocation (#5627). The stream is drained for a bounded window instead;
555
+ // the only thing the abort still buys is not burning provider budget, and that
556
+ // can wait this long.
557
+ const REPETITION_DRAIN_MAX_CHUNKS = 64;
558
+ const REPETITION_DRAIN_MAX_MS = 2_000;
559
+ // A tool call whose accumulated arguments are not yet complete JSON is worth
560
+ // waiting longer for — but not forever, or a call whose arguments never
561
+ // complete would hold the stream open for the rest of the turn's budget.
562
+ const REPETITION_DRAIN_PENDING_TOOL_MAX_CHUNKS = 256;
563
+ const REPETITION_DRAIN_PENDING_TOOL_MAX_MS = 8_000;
564
+
533
565
  function hasReplayUnsafeOpenAICompletionsDelta(chunk: ChatCompletionChunk): boolean {
534
566
  const choice = Array.isArray(chunk.choices) ? chunk.choices[0] : undefined;
535
567
  const delta = choice?.delta;
@@ -584,6 +616,48 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
584
616
  const abortTracker = createAbortSourceTracker(options?.signal);
585
617
  const { requestAbortController, requestSignal } = abortTracker;
586
618
 
619
+ // Declared outside the try so the catch block — which is where the abort
620
+ // below lands — can tell a runaway-repetition stop from a transport error.
621
+ let repetitionTrip: (StreamRepetitionTrip & { channel: "text" | "thinking" }) | undefined;
622
+ // Drain-window bookkeeping: when the trip happened, and how many chunks have
623
+ // been consumed since. Both are only meaningful once `repetitionTrip` is set.
624
+ let repetitionTrippedAt: number | undefined;
625
+ let repetitionDrainedChunks = 0;
626
+ // Set immediately before the guard's own abort and nowhere else. The catch
627
+ // block must be able to tell OUR abort from a provider stall or a transport
628
+ // failure that merely happened to land inside the drain window: `repetitionTrip`
629
+ // alone is true for all three, and using it there discarded the real
630
+ // timeout/transport facts and flipped the retry classification (#5627 review r4).
631
+ let repetitionSelfAbort = false;
632
+ const finalizeRepetitionGuardStop = (): void => {
633
+ if (!repetitionTrip) return;
634
+ // `error`, not `aborted`: this is a provider-side failure we detected
635
+ // locally, and `aborted` is the wire for *client cancellation* — the
636
+ // auth gateway maps it to 499/`request_aborted` and telemetry counts it
637
+ // as a user cancel, so borrowing it misreports the turn (#5627). No new
638
+ // StopReason variant: the union is switched on exhaustively everywhere.
639
+ // `errorCode` stays the bounded classifier for *why* (#5624).
640
+ output.stopReason = "error";
641
+ output.errorCode = REPETITION_GUARD_ERROR_CODE;
642
+ // A fixed literal, never the observed sample/channel/count: the auth
643
+ // gateway forwards `errorMessage` to API clients on the streaming path,
644
+ // so interpolating here publishes raw model output and feeds it to a
645
+ // keyword classifier that picks HTTP status from message text. The
646
+ // diagnostic detail lives in the `logger.debug` at the trip site
647
+ // instead (#5627 review r5).
648
+ output.errorMessage = REPETITION_GUARD_STOP_MESSAGE;
649
+ output.duration = Date.now() - startTime;
650
+ if (firstTokenTime) output.ttft = firstTokenTime - startTime;
651
+ // No `transportFailure`: this is a local decision, not a retryable
652
+ // transport fault, and the agent loop's retry admission keys on that
653
+ // field. The session-layer classifier does not — absent transport facts
654
+ // it defaults to a bounded retry — so it branches on this `errorCode`
655
+ // and treats the trip as terminal instead (#5627). Retrying is pointless
656
+ // anyway: a decode loop is deterministic for the submitted context.
657
+ stream.push({ type: "error", reason: "error", error: output });
658
+ stream.end();
659
+ };
660
+
587
661
  try {
588
662
  const apiKey = options?.apiKey || getEnvApiKey(model.provider) || "";
589
663
  const idleTimeoutMs = options?.streamIdleTimeoutMs ?? getOpenAIStreamIdleTimeoutMs(model.provider, model.id);
@@ -870,15 +944,117 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
870
944
 
871
945
  let taggedTextBuffer = "";
872
946
  let insideTaggedThinking = false;
947
+ // One guard per channel: interleaving visible text and reasoning through
948
+ // a single instance would splice unrelated tokens into the same window.
949
+ // Tool-call frames are never fed through either guard.
950
+ //
951
+ // A disabled channel gets no guard at all rather than a lenient one, so
952
+ // it is structurally impossible for it to set `repetitionTrip`. Visible
953
+ // text is disabled by default: a decode loop there is not the reported
954
+ // failure (#5624 was reasoning-channel), and truncating deliverable
955
+ // output — a log dump, a fixture, a table — corrupts the answer (#5627).
956
+ const createRepetitionGuard = (
957
+ setting: number | false | undefined,
958
+ fallback: number | false,
959
+ ): StreamRepetitionGuard | undefined => {
960
+ const threshold = setting ?? fallback;
961
+ return threshold === false ? undefined : new StreamRepetitionGuard({ threshold });
962
+ };
963
+ const textRepetitionGuard = createRepetitionGuard(options?.repetitionGuard?.text, false);
964
+ const thinkingRepetitionGuard = createRepetitionGuard(
965
+ options?.repetitionGuard?.thinking,
966
+ DEFAULT_REPETITION_THRESHOLD,
967
+ );
968
+ const noteRepetitionTrip = (guard: StreamRepetitionGuard, channel: "text" | "thinking") => {
969
+ // `takeTrip()` latches once per guard; this latches once per request,
970
+ // so the drain window below opens exactly once no matter which
971
+ // channel loops.
972
+ if (repetitionTrip) return;
973
+ const trip = guard.takeTrip();
974
+ if (!trip) return;
975
+ repetitionTrip = { ...trip, channel };
976
+ // The repeated unit is never logged. `logger`'s default transport is a
977
+ // rotating file under `~/.gjc/logs` and `makeLogFormat` JSON-stringifies
978
+ // every metadata key verbatim — no redaction — so a sample would persist
979
+ // raw model output to disk and carry it into log rotation, support
980
+ // bundles and backups. If the loop swallowed a secret or a private
981
+ // fragment of the prompt, that is where it would land (#5627 review r6).
982
+ //
983
+ // Only bounded metadata the model cannot control the *content* of goes
984
+ // out: an id, two enums and two counts. `sampleLength` is deliberately a
985
+ // number, not a hash — a hash of a short secret is a probe oracle and
986
+ // buys nothing for debugging a decode loop. `trip.sample` itself stays on
987
+ // the in-memory trip for callers; this is only the logging contract.
988
+ //
989
+ // Separately, `errorMessage` stays a fixed literal because the gateway
990
+ // forwards it to API clients (#5627 review r5). Both hold at once.
991
+ logger.debug("openai-completions: repetition guard tripped", {
992
+ model: model.id,
993
+ channel,
994
+ kind: trip.kind,
995
+ repeats: trip.repeats,
996
+ sampleLength: trip.sample.length,
997
+ });
998
+ // Deliberately no abort here — see the REPETITION_DRAIN_* constants.
999
+ // The main loop closes the window once late tool-call frames have had
1000
+ // their chance to land.
1001
+ repetitionTrippedAt = Date.now();
1002
+ repetitionDrainedChunks = 0;
1003
+ };
1004
+ /**
1005
+ * Closes the post-trip drain window. Called once per consumed chunk after
1006
+ * that chunk is fully processed, so the frames it carried are finalized
1007
+ * before the stream is cut.
1008
+ */
1009
+ const maybeAbortAfterRepetitionDrain = (): void => {
1010
+ if (repetitionTrippedAt === undefined || requestSignal.aborted) return;
1011
+ // Reuses the `stopReason === "length"` truncation check below: an open
1012
+ // tool call whose `partialArgs` will not parse is still mid-flight.
1013
+ const toolCallPending =
1014
+ currentBlock?.type === "toolCall" &&
1015
+ !isCompleteJson((currentBlock as { partialArgs?: string }).partialArgs);
1016
+ const maxChunks = toolCallPending ? REPETITION_DRAIN_PENDING_TOOL_MAX_CHUNKS : REPETITION_DRAIN_MAX_CHUNKS;
1017
+ const maxMs = toolCallPending ? REPETITION_DRAIN_PENDING_TOOL_MAX_MS : REPETITION_DRAIN_MAX_MS;
1018
+ if (repetitionDrainedChunks >= maxChunks || Date.now() - repetitionTrippedAt >= maxMs) {
1019
+ repetitionSelfAbort = true;
1020
+ requestAbortController.abort();
1021
+ }
1022
+ };
1023
+
1024
+ // Reasoning-channel only — a fence token in visible prose must survive
1025
+ // as text (CHANGELOG.md:1094), and the Kimi healer must not see this
1026
+ // channel at all or its holdback buffer corrupts.
1027
+ const thinkingFenceStripper = new ToolFenceStripper();
1028
+ let lastThinkingSignature: string | undefined;
1029
+
1030
+ /** Returns the portion safe to emit — the whole chunk when the channel is unguarded. */
1031
+ const feedRepetitionGuard = (
1032
+ guard: StreamRepetitionGuard | undefined,
1033
+ text: string,
1034
+ channel: "text" | "thinking",
1035
+ ): string => {
1036
+ if (!guard) return text;
1037
+ const emit = guard.feed(text);
1038
+ noteRepetitionTrip(guard, channel);
1039
+ return emit;
1040
+ };
1041
+
873
1042
  const appendTextDelta = (text: string) => {
874
1043
  if (!text) return;
875
1044
  if (!firstTokenTime) firstTokenTime = Date.now();
876
- appendText(output, stream, text);
1045
+ const emit = feedRepetitionGuard(textRepetitionGuard, text, "text");
1046
+ if (emit) appendText(output, stream, emit);
1047
+ };
1048
+ const emitThinkingText = (thinking: string, signature?: string) => {
1049
+ if (!thinking) return;
1050
+ const emit = feedRepetitionGuard(thinkingRepetitionGuard, thinking, "thinking");
1051
+ if (emit) appendThinking(output, stream, emit, signature);
877
1052
  };
878
1053
  const appendThinkingDelta = (thinking: string, signature?: string) => {
879
1054
  if (!thinking) return;
880
1055
  if (!firstTokenTime) firstTokenTime = Date.now();
881
- appendThinking(output, stream, thinking, signature);
1056
+ lastThinkingSignature = signature;
1057
+ emitThinkingText(thinkingFenceStripper.feed(thinking), signature);
882
1058
  };
883
1059
 
884
1060
  const flushTaggedTextBuffer = () => {
@@ -1001,150 +1177,180 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
1001
1177
  };
1002
1178
 
1003
1179
  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);
1180
+ // Counted before the guarded blocks below so chunks carrying no
1181
+ // delta still spend the drain budget rather than extending it.
1182
+ if (repetitionTrippedAt !== undefined) repetitionDrainedChunks += 1;
1183
+
1184
+ // Positive-form guards instead of early `continue`s: the drain check
1185
+ // at the bottom of the body has to be reached on *every* path, or a
1186
+ // provider that keeps emitting usage-only, keepalive-shaped,
1187
+ // `choices`-less or malformed chunks after a trip never spends the
1188
+ // budget and the request hangs open (#5627 review r5).
1189
+ if (chunk && typeof chunk === "object") {
1190
+ // OpenAI documents ChatCompletionChunk.id as the unique chat completion identifier,
1191
+ // and each chunk in a streamed completion carries the same id.
1192
+ output.responseId ||= chunk.id;
1193
+
1194
+ if (chunk.usage) {
1195
+ applyUsage(chunk.usage);
1021
1196
  }
1022
- }
1023
1197
 
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;
1198
+ const choice = Array.isArray(chunk.choices) ? chunk.choices[0] : undefined;
1199
+ if (choice) {
1200
+ if (!chunk.usage) {
1201
+ const choiceUsage = getChoiceUsage(choice);
1202
+ if (choiceUsage) {
1203
+ applyUsage(choiceUsage);
1204
+ }
1032
1205
  }
1033
- }
1034
- }
1035
1206
 
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();
1207
+ if (choice.finish_reason) {
1208
+ const finishReasonResult = mapStopReason(choice.finish_reason);
1209
+ if (choice.finish_reason === "content_filter") {
1210
+ markProviderSafetyStop(finishReasonResult.errorMessage);
1211
+ } else if (!providerSafetyStop) {
1212
+ output.stopReason = finishReasonResult.stopReason;
1213
+ if (finishReasonResult.errorMessage) {
1214
+ output.errorMessage = finishReasonResult.errorMessage;
1215
+ }
1066
1216
  }
1067
- } else {
1068
- appendTextDelta(normalizedDeltaText);
1069
1217
  }
1070
- }
1071
1218
 
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;
1219
+ if (choice.delta) {
1220
+ if (typeof choice.delta.refusal === "string" && choice.delta.refusal.length > 0) {
1221
+ appendTextDelta(choice.delta.refusal);
1222
+ if (!providerSafetyStop) {
1223
+ markProviderSafetyStop("Provider returned a safety refusal");
1224
+ }
1225
+ }
1226
+ const normalizedDeltaText = normalizeStreamingContentText(choice.delta.content);
1227
+ if (normalizedDeltaText.length > 0) {
1228
+ if (!firstTokenTime) firstTokenTime = Date.now();
1229
+ if (parseMiniMaxThinkTags) {
1230
+ taggedTextBuffer += normalizedDeltaText;
1231
+ flushTaggedTextBuffer();
1232
+ } else if (stripDeepseekChatTemplateTokens) {
1233
+ deepseekStripBuffer += normalizedDeltaText;
1234
+ flushDeepseekStripBuffer(false);
1235
+ } else if (kimiHealer) {
1236
+ const hasStructuredToolCalls =
1237
+ Array.isArray(choice.delta.tool_calls) && choice.delta.tool_calls.length > 0;
1238
+ if (hasStructuredToolCalls) {
1239
+ // Same chunk leaks markers AND carries structured tool_calls.
1240
+ // Strip the marker text from visible output, but drop any
1241
+ // synthesized calls so the structured payload stays the
1242
+ // single source of truth (avoids double-dispatch).
1243
+ const clean = kimiHealer.consumeWithoutCalls(normalizedDeltaText);
1244
+ if (clean.length > 0) appendTextDelta(clean);
1245
+ } else {
1246
+ const clean = kimiHealer.feed(normalizedDeltaText);
1247
+ if (clean.length > 0) appendTextDelta(clean);
1248
+ flushHealedToolCalls();
1249
+ }
1250
+ } else {
1251
+ appendTextDelta(normalizedDeltaText);
1252
+ }
1087
1253
  }
1088
- }
1089
- }
1090
1254
 
1091
- if (foundReasoningField) {
1092
- const delta = (choice.delta as any)[foundReasoningField];
1093
- appendThinkingDelta(delta, foundReasoningField);
1094
- }
1255
+ // Some endpoints return reasoning in reasoning_content (llama.cpp),
1256
+ // or reasoning (other openai compatible endpoints)
1257
+ // Use the first non-empty reasoning field to avoid duplication
1258
+ // (e.g., chutes.ai returns both reasoning_content and reasoning with same content)
1259
+ const reasoningFields = ["reasoning_content", "reasoning", "reasoning_text"];
1260
+ let foundReasoningField: string | null = null;
1261
+ for (const field of reasoningFields) {
1262
+ if (
1263
+ (choice.delta as any)[field] !== null &&
1264
+ (choice.delta as any)[field] !== undefined &&
1265
+ (choice.delta as any)[field].length > 0
1266
+ ) {
1267
+ if (!foundReasoningField) {
1268
+ foundReasoningField = field;
1269
+ break;
1270
+ }
1271
+ }
1272
+ }
1095
1273
 
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
- });
1274
+ if (foundReasoningField) {
1275
+ const delta = (choice.delta as any)[foundReasoningField];
1276
+ appendThinkingDelta(delta, foundReasoningField);
1113
1277
  }
1114
1278
 
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);
1279
+ if (choice?.delta?.tool_calls && choice.delta.tool_calls.length > 0) {
1280
+ for (const toolCall of choice.delta.tool_calls) {
1281
+ if (currentBlock?.type !== "toolCall" || (toolCall.id && currentBlock.id !== toolCall.id)) {
1282
+ finishCurrentBlock(currentBlock);
1283
+ currentBlock = {
1284
+ type: "toolCall",
1285
+ id: toolCall.id || "",
1286
+ name: toolCall.function?.name || "",
1287
+ arguments: {},
1288
+ partialArgs: "",
1289
+ };
1290
+ output.content.push(currentBlock);
1291
+ stream.push({
1292
+ type: "toolcall_start",
1293
+ contentIndex: blockIndex(currentBlock),
1294
+ partial: output,
1295
+ });
1296
+ }
1297
+
1298
+ if (currentBlock.type === "toolCall") {
1299
+ if (toolCall.id) currentBlock.id = toolCall.id;
1300
+ if (toolCall.function?.name) currentBlock.name = toolCall.function.name;
1301
+ let delta = "";
1302
+ if (toolCall.function?.arguments) {
1303
+ delta = toolCall.function.arguments;
1304
+ currentBlock.partialArgs += toolCall.function.arguments;
1305
+ currentBlock.arguments = parseStreamingJson(currentBlock.partialArgs);
1306
+ }
1307
+ stream.push({
1308
+ type: "toolcall_delta",
1309
+ contentIndex: blockIndex(currentBlock),
1310
+ delta,
1311
+ partial: output,
1312
+ });
1313
+ }
1123
1314
  }
1124
- stream.push({
1125
- type: "toolcall_delta",
1126
- contentIndex: blockIndex(currentBlock),
1127
- delta,
1128
- partial: output,
1129
- });
1130
1315
  }
1131
- }
1132
- }
1133
1316
 
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);
1317
+ const reasoningDetails = (choice.delta as any).reasoning_details;
1318
+ if (reasoningDetails && Array.isArray(reasoningDetails)) {
1319
+ for (const detail of reasoningDetails) {
1320
+ if (detail.type === "reasoning.encrypted" && detail.id && detail.data) {
1321
+ const matchingToolCall = output.content.find(
1322
+ b => b.type === "toolCall" && b.id === detail.id,
1323
+ ) as ToolCall | undefined;
1324
+ if (matchingToolCall) {
1325
+ matchingToolCall.thoughtSignature = JSON.stringify(detail);
1326
+ }
1327
+ }
1143
1328
  }
1144
1329
  }
1145
1330
  }
1146
1331
  }
1147
1332
  }
1333
+
1334
+ // Single invariant: exactly one evaluation per consumed chunk, on
1335
+ // every path. Two properties ride on this being the last *statement*
1336
+ // of the body rather than a `finally`:
1337
+ //
1338
+ // (a) This chunk is fully processed — anything it carried (including
1339
+ // `tool_calls` frames) has landed. Only now may the drain window
1340
+ // close and cut the stream. The check must never run *before*
1341
+ // the chunk's processing.
1342
+ // (b) A throwing chunk keeps its own transport facts. A `finally`
1343
+ // would also run when the body throws, and this check sets
1344
+ // `repetitionSelfAbort` — which the catch below branches on to
1345
+ // call `finalizeRepetitionGuardStop()`. A malformed payload
1346
+ // throwing inside the drain window would then be re-labelled as
1347
+ // the guard's own abort, discarding the real
1348
+ // errorMessage/errorStatus/transportFailure and flipping retry
1349
+ // admission — exactly the defect fixed in e577268e.
1350
+ //
1351
+ // Caller-abort priority is unchanged: a genuine caller abort still
1352
+ // wins over the guard's self-abort, and a trip is still not a cancel.
1353
+ maybeAbortAfterRepetitionDrain();
1148
1354
  }
1149
1355
 
1150
1356
  if (parseMiniMaxThinkTags && taggedTextBuffer.length > 0) {
@@ -1160,6 +1366,29 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
1160
1366
  flushDeepseekStripBuffer(true);
1161
1367
  }
1162
1368
 
1369
+ // A partial fence held back at the last chunk never completed, so it was
1370
+ // ordinary thinking text after all.
1371
+ emitThinkingText(thinkingFenceStripper.flush(), lastThinkingSignature);
1372
+
1373
+ // Close each guard's in-progress unit now that no more text is coming:
1374
+ // a final repeat with no trailing newline would otherwise go uncounted
1375
+ // and the runaway turn would read as a healthy completion. Must run
1376
+ // after the fence flush above, whose output feeds the thinking guard.
1377
+ //
1378
+ // Normal-completion path ONLY. Never finalize in the catch block: a
1379
+ // stream that threw mid-repeat must keep its own transport facts rather
1380
+ // than be reclassified as a decode loop (#5627 r4, commit c2aa25d30).
1381
+ // No abort either — the stream has already ended, so aborting would set
1382
+ // `repetitionSelfAbort` for nothing.
1383
+ for (const [guard, channel] of [
1384
+ [textRepetitionGuard, "text"],
1385
+ [thinkingRepetitionGuard, "thinking"],
1386
+ ] as const) {
1387
+ if (!guard) continue;
1388
+ guard.finalize();
1389
+ noteRepetitionTrip(guard, channel);
1390
+ }
1391
+
1163
1392
  if (kimiHealer) {
1164
1393
  const trailing = kimiHealer.flushPending();
1165
1394
  if (trailing.length > 0) appendTextDelta(trailing);
@@ -1187,6 +1416,14 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
1187
1416
 
1188
1417
  finishCurrentBlock(currentBlock);
1189
1418
 
1419
+ // A repetition abort usually surfaces as a throw from the stream
1420
+ // iterator, but a host that had already buffered the rest of the
1421
+ // response finishes the loop normally instead. Same outcome either way.
1422
+ if (repetitionTrip) {
1423
+ finalizeRepetitionGuardStop();
1424
+ return;
1425
+ }
1426
+
1190
1427
  const firstEventTimeoutError = abortTracker.getLocalAbortReason();
1191
1428
  if (firstEventTimeoutError) {
1192
1429
  throw firstEventTimeoutError;
@@ -1221,6 +1458,15 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
1221
1458
  stream.end();
1222
1459
  } catch (error) {
1223
1460
  for (const block of output.content) delete (block as any).index;
1461
+ // Our own abort landed here. Classify it before the generic transport
1462
+ // path turns it into a retryable provider error. A caller abort still
1463
+ // wins: the user's cancel is the more meaningful intent. Keyed on the
1464
+ // self-abort flag, not on `repetitionTrip`: a stall or transport error
1465
+ // during the drain window must keep its own facts.
1466
+ if (repetitionSelfAbort && !abortTracker.wasCallerAbort()) {
1467
+ finalizeRepetitionGuardStop();
1468
+ return;
1469
+ }
1224
1470
  const localAbortReason = abortTracker.getLocalAbortReason();
1225
1471
  const normalizedError =
1226
1472
  !streamConnected && model.provider === "alibaba-token-plan" && error instanceof APIConnectionTimeoutError
@@ -1639,25 +1885,6 @@ function buildParams(
1639
1885
  params.reasoning_effort = mapReasoningEffort(minEffort, compat.reasoningEffortMap) as Effort;
1640
1886
  }
1641
1887
 
1642
- if (compat.disableReasoningOnToolChoice && params.tool_choice !== undefined) {
1643
- // DeepSeek reasoning models accept tools/tool_choice, but reject that
1644
- // control field while thinking is enabled. Keep the tool-selection
1645
- // contract and suppress reasoning for this single request.
1646
- delete params.reasoning_effort;
1647
- delete params.reasoning;
1648
- }
1649
-
1650
- if (compat.disableReasoningOnForcedToolChoice && isForcedToolChoice(params.tool_choice)) {
1651
- // Backends like Kimi 400 with `tool_choice 'specified' is incompatible
1652
- // with thinking enabled`. Suppress thinking for this single forced-tool
1653
- // turn while keeping the tool-selection contract intact.
1654
- delete params.reasoning_effort;
1655
- delete params.reasoning;
1656
- if (compat.thinkingFormat === "zai") {
1657
- params.thinking = { type: "disabled" };
1658
- }
1659
- }
1660
-
1661
1888
  // OpenRouter provider routing preferences
1662
1889
  if (model.baseUrl.includes("openrouter.ai") && compat.openRouterRouting) {
1663
1890
  params.provider = compat.openRouterRouting;
@@ -1677,13 +1904,46 @@ function buildParams(
1677
1904
  if (compat.extraBody) {
1678
1905
  // The resolved output limit owns the selected wire field; extraBody is a
1679
1906
  // free-form compatibility escape hatch and must not add a competing
1680
- // max-token field or overwrite the resolved budget.
1681
- const { max_tokens, max_completion_tokens, max_output_tokens, ...restExtra } = compat.extraBody as Record<
1682
- string,
1683
- unknown
1684
- >;
1907
+ // max-token field or overwrite the resolved budget. tool_choice follows
1908
+ // the same discipline on both sides: an injected default (an endpoint
1909
+ // whose tool_choice default is "none", like IO Intelligence, would
1910
+ // otherwise stop tool calls) may only fill the gap on an ordinary turn
1911
+ // that offers tools but resolved no directive of its own. Explicit
1912
+ // directives — forced tools, retry reminders — stay untouched, and
1913
+ // turns that deliberately carry no tools keep their stripped shape
1914
+ // instead of re-adding tool_choice with an empty tools list.
1915
+ const { max_tokens, max_completion_tokens, max_output_tokens, tool_choice, ...restExtra } =
1916
+ compat.extraBody as Record<string, unknown>;
1917
+ if (
1918
+ tool_choice !== undefined &&
1919
+ params.tool_choice === undefined &&
1920
+ Array.isArray(params.tools) &&
1921
+ params.tools.length > 0
1922
+ ) {
1923
+ params.tool_choice = tool_choice as ChatCompletionToolChoiceOption;
1924
+ }
1685
1925
  Object.assign(params, restExtra);
1686
1926
  }
1927
+
1928
+ if (compat.disableReasoningOnToolChoice && params.tool_choice !== undefined) {
1929
+ // DeepSeek reasoning models accept tools/tool_choice, but reject that
1930
+ // control field while thinking is enabled. Keep the tool-selection
1931
+ // contract and suppress reasoning for this single request.
1932
+ delete params.reasoning_effort;
1933
+ delete params.reasoning;
1934
+ }
1935
+
1936
+ if (compat.disableReasoningOnForcedToolChoice && isForcedToolChoice(params.tool_choice)) {
1937
+ // Backends like Kimi 400 with `tool_choice 'specified' is incompatible
1938
+ // with thinking enabled`. Suppress thinking for this single forced-tool
1939
+ // turn while keeping the tool-selection contract intact.
1940
+ delete params.reasoning_effort;
1941
+ delete params.reasoning;
1942
+ if (compat.thinkingFormat === "zai") {
1943
+ params.thinking = { type: "disabled" };
1944
+ }
1945
+ }
1946
+
1687
1947
  applyOpenAIRequestTransformBody(params, model.requestTransform);
1688
1948
  if (!supportsReasoningParams) {
1689
1949
  delete params.reasoning;