@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.
- package/CHANGELOG.md +118 -0
- package/dist/types/auth-gateway/server.d.ts +23 -1
- package/dist/types/auth-storage.d.ts +12 -1
- package/dist/types/providers/anthropic.d.ts +1 -1
- package/dist/types/providers/cursor.d.ts +10 -0
- package/dist/types/providers/openai-completions.d.ts +9 -1
- package/dist/types/types.d.ts +16 -0
- package/dist/types/utils/discovery/openai-compatible.d.ts +10 -0
- package/dist/types/utils/fallback-transport.d.ts +4 -0
- package/dist/types/utils/h2-fetch.d.ts +8 -7
- package/dist/types/utils/stream-repetition-guard.d.ts +107 -0
- package/dist/types/utils/tool-call-healing.d.ts +4 -0
- package/dist/types/utils/tool-fence-strip.d.ts +27 -0
- package/package.json +3 -3
- package/src/auth-gateway/server.ts +48 -9
- package/src/auth-storage.ts +194 -52
- package/src/model-pricing.ts +22 -0
- package/src/model-thinking.ts +40 -4
- package/src/models.json +10060 -3164
- package/src/providers/anthropic.d.ts +1 -1
- package/src/providers/anthropic.ts +1 -1
- package/src/providers/cursor.d.ts +10 -0
- package/src/providers/cursor.ts +144 -24
- package/src/providers/kiro-api-key.ts +7 -0
- package/src/providers/openai-completions.d.ts +9 -1
- package/src/providers/openai-completions.ts +410 -150
- package/src/providers/transform-messages.ts +54 -1
- package/src/stream.ts +7 -0
- package/src/types.d.ts +16 -0
- package/src/types.ts +17 -0
- package/src/utils/discovery/openai-compatible.ts +16 -2
- package/src/utils/fallback-transport.d.ts +4 -0
- package/src/utils/fallback-transport.ts +11 -0
- package/src/utils/h2-fetch.ts +65 -26
- package/src/utils/http-inspector.ts +2 -0
- package/src/utils/idle-iterator.ts +109 -96
- package/src/utils/json-parse.ts +12 -4
- package/src/utils/stream-repetition-guard.d.ts +107 -0
- package/src/utils/stream-repetition-guard.ts +290 -0
- package/src/utils/tool-call-healing.d.ts +4 -0
- package/src/utils/tool-call-healing.ts +4 -0
- package/src/utils/tool-fence-strip.d.ts +27 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
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
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
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
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
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
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
(
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
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
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
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
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
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 (
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
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
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
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
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
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;
|