@oh-my-pi/pi-ai 16.2.6 → 16.2.7
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 +22 -0
- package/dist/types/auth-storage.d.ts +11 -10
- package/dist/types/providers/__tests__/kimi-code-thinking.test.d.ts +1 -0
- package/dist/types/providers/google-auth.d.ts +8 -0
- package/dist/types/providers/google-interactions.d.ts +65 -0
- package/dist/types/providers/google-shared.d.ts +20 -5
- package/dist/types/providers/google-types.d.ts +5 -0
- package/dist/types/providers/openai-codex/request-transformer.d.ts +1 -1
- package/dist/types/providers/openai-codex-responses.d.ts +1 -1
- package/dist/types/providers/openai-shared.d.ts +3 -3
- package/dist/types/types.d.ts +80 -30
- package/dist/types/utils/leaked-thinking-stream.d.ts +29 -0
- package/package.json +4 -4
- package/src/auth-storage.ts +56 -52
- package/src/providers/__tests__/kimi-code-thinking.test.ts +112 -0
- package/src/providers/anthropic.ts +11 -10
- package/src/providers/google-auth.ts +25 -0
- package/src/providers/google-gemini-cli.ts +90 -41
- package/src/providers/google-interactions.ts +753 -0
- package/src/providers/google-shared.ts +74 -13
- package/src/providers/google-types.ts +5 -1
- package/src/providers/google-vertex.ts +117 -26
- package/src/providers/google.ts +61 -19
- package/src/providers/openai-chat-server.ts +2 -2
- package/src/providers/openai-codex/request-transformer.ts +17 -4
- package/src/providers/openai-codex-responses.ts +1 -1
- package/src/providers/openai-shared.ts +5 -11
- package/src/stream.ts +21 -6
- package/src/types.ts +164 -51
- package/src/utils/leaked-thinking-stream.ts +260 -0
package/src/stream.ts
CHANGED
|
@@ -71,6 +71,7 @@ import type {
|
|
|
71
71
|
ToolChoice,
|
|
72
72
|
} from "./types";
|
|
73
73
|
import { AssistantMessageEventStream } from "./utils/event-stream";
|
|
74
|
+
import { wrapLeakedThinkingStream } from "./utils/leaked-thinking-stream";
|
|
74
75
|
import { wrapFetchForProxy } from "./utils/proxy";
|
|
75
76
|
import { withRequestDebugFetch } from "./utils/request-debug";
|
|
76
77
|
import { withGeminiThinkingLoopGuard } from "./utils/thinking-loop";
|
|
@@ -499,8 +500,11 @@ function withProviderInFlightLimit<TOptions extends Pick<StreamOptions, "signal"
|
|
|
499
500
|
options: TOptions | undefined,
|
|
500
501
|
dispatch: () => AssistantMessageEventStream,
|
|
501
502
|
): AssistantMessageEventStream {
|
|
503
|
+
// Leaked-thinking healing folds in here — the one shared provider-dispatch
|
|
504
|
+
// chokepoint — so the loop guard (which wraps this) sees healed events and all
|
|
505
|
+
// six provider exits are covered by one wrap. Healing is idempotent.
|
|
502
506
|
const limit = resolveProviderInFlightLimit(model.provider, options);
|
|
503
|
-
if (limit === undefined) return dispatch();
|
|
507
|
+
if (limit === undefined) return wrapLeakedThinkingStream(dispatch());
|
|
504
508
|
|
|
505
509
|
const outer = new AssistantMessageEventStream();
|
|
506
510
|
void (async () => {
|
|
@@ -520,7 +524,7 @@ function withProviderInFlightLimit<TOptions extends Pick<StreamOptions, "signal"
|
|
|
520
524
|
if (options?.signal?.aborted) {
|
|
521
525
|
throw options.signal.reason ?? new AIError.AbortError("Provider request aborted before dispatch");
|
|
522
526
|
}
|
|
523
|
-
const inner = dispatch();
|
|
527
|
+
const inner = wrapLeakedThinkingStream(dispatch());
|
|
524
528
|
try {
|
|
525
529
|
for await (const event of inner) {
|
|
526
530
|
outer.push(event);
|
|
@@ -1105,10 +1109,13 @@ export function streamSimple<TApi extends Api>(
|
|
|
1105
1109
|
|
|
1106
1110
|
// GitLab Duo Workflow - IDE workflow protocol + WebSocket action bridge
|
|
1107
1111
|
if (model.api === "gitlab-duo-agent") {
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
+
// Does not route through withProviderInFlightLimit, so heal explicitly.
|
|
1113
|
+
return wrapLeakedThinkingStream(
|
|
1114
|
+
streamGitLabDuoWorkflow(model as Model<"gitlab-duo-agent">, context, {
|
|
1115
|
+
...requestOptions,
|
|
1116
|
+
apiKey,
|
|
1117
|
+
}),
|
|
1118
|
+
);
|
|
1112
1119
|
}
|
|
1113
1120
|
|
|
1114
1121
|
// Kimi Code - route to dedicated handler that wraps OpenAI or Anthropic API
|
|
@@ -1355,6 +1362,9 @@ function mapOptionsForApi<TApi extends Api>(
|
|
|
1355
1362
|
streamFirstEventTimeoutMs: options?.streamFirstEventTimeoutMs,
|
|
1356
1363
|
streamIdleTimeoutMs: options?.streamIdleTimeoutMs,
|
|
1357
1364
|
providerSessionState: options?.providerSessionState,
|
|
1365
|
+
useInteractionsApi: options?.useInteractionsApi,
|
|
1366
|
+
storeInteraction: options?.storeInteraction,
|
|
1367
|
+
previousInteractionId: options?.previousInteractionId,
|
|
1358
1368
|
maxInFlightRequests: options?.maxInFlightRequests,
|
|
1359
1369
|
onPayload: options?.onPayload,
|
|
1360
1370
|
onResponse: options?.onResponse,
|
|
@@ -1565,6 +1575,7 @@ function mapOptionsForApi<TApi extends Api>(
|
|
|
1565
1575
|
if (!reasoning || !model.reasoning) {
|
|
1566
1576
|
return castApi<"google-generative-ai">({
|
|
1567
1577
|
...base,
|
|
1578
|
+
serviceTier: options?.serviceTier,
|
|
1568
1579
|
thinking: { enabled: false },
|
|
1569
1580
|
toolChoice: mapGoogleToolChoice(options?.toolChoice),
|
|
1570
1581
|
});
|
|
@@ -1578,6 +1589,7 @@ function mapOptionsForApi<TApi extends Api>(
|
|
|
1578
1589
|
if (googleModel.thinking?.mode === "google-level") {
|
|
1579
1590
|
return castApi<"google-generative-ai">({
|
|
1580
1591
|
...base,
|
|
1592
|
+
serviceTier: options?.serviceTier,
|
|
1581
1593
|
thinking: {
|
|
1582
1594
|
enabled: true,
|
|
1583
1595
|
level: mapEffortToGoogleThinkingLevel(effort),
|
|
@@ -1661,6 +1673,7 @@ function mapOptionsForApi<TApi extends Api>(
|
|
|
1661
1673
|
if (!reasoning || !model.reasoning) {
|
|
1662
1674
|
return castApi<"google-vertex">({
|
|
1663
1675
|
...base,
|
|
1676
|
+
serviceTier: options?.serviceTier,
|
|
1664
1677
|
thinking: { enabled: false },
|
|
1665
1678
|
toolChoice: mapGoogleToolChoice(options?.toolChoice),
|
|
1666
1679
|
});
|
|
@@ -1673,6 +1686,7 @@ function mapOptionsForApi<TApi extends Api>(
|
|
|
1673
1686
|
if (geminiModel.thinking?.mode === "google-level") {
|
|
1674
1687
|
return castApi<"google-vertex">({
|
|
1675
1688
|
...base,
|
|
1689
|
+
serviceTier: options?.serviceTier,
|
|
1676
1690
|
thinking: {
|
|
1677
1691
|
enabled: true,
|
|
1678
1692
|
level: mapEffortToGoogleThinkingLevel(effort),
|
|
@@ -1683,6 +1697,7 @@ function mapOptionsForApi<TApi extends Api>(
|
|
|
1683
1697
|
|
|
1684
1698
|
return castApi<"google-vertex">({
|
|
1685
1699
|
...base,
|
|
1700
|
+
serviceTier: options?.serviceTier,
|
|
1686
1701
|
thinking: {
|
|
1687
1702
|
enabled: true,
|
|
1688
1703
|
budgetTokens: getGoogleBudget(geminiModel, effort, options?.thinkingBudgets),
|
package/src/types.ts
CHANGED
|
@@ -105,84 +105,181 @@ export type ToolChoice =
|
|
|
105
105
|
export type CacheRetention = "none" | "short" | "long";
|
|
106
106
|
|
|
107
107
|
/**
|
|
108
|
-
* Service tier hint for processing priority / cost control.
|
|
108
|
+
* Service tier hint for processing priority / cost control. These are the
|
|
109
|
+
* values providers consume on the wire:
|
|
109
110
|
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
* (
|
|
113
|
-
* `
|
|
111
|
+
* - OpenAI / OpenAI-Codex: sent verbatim as the `service_tier` field
|
|
112
|
+
* (`flex`/`scale`/`priority`).
|
|
113
|
+
* - Google (Gemini API + Vertex AI): sent as the top-level `serviceTier`
|
|
114
|
+
* field (`flex`/`priority`).
|
|
115
|
+
* - OpenRouter: passed through as `service_tier`; OpenRouter realizes it for
|
|
116
|
+
* the OpenAI- and Google-family upstreams it supports and ignores it
|
|
117
|
+
* otherwise.
|
|
118
|
+
* - Direct Anthropic: `"priority"` is translated into `speed: "fast"` plus the
|
|
119
|
+
* fast-mode beta on supported Opus models. Other tiers are ignored.
|
|
114
120
|
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
* They let users opt into priority on one family without paying premium
|
|
118
|
-
* costs on the other when switching models mid-session.
|
|
119
|
-
*
|
|
120
|
-
* - `"openai-only"` → `"priority"` on `openai` and `openai-codex`; ignored elsewhere.
|
|
121
|
-
* - `"claude-only"` → `"priority"` on direct `anthropic` (not Bedrock/Vertex Claude).
|
|
121
|
+
* Per-family scoping is expressed by {@link ServiceTierByFamily}, not by
|
|
122
|
+
* scoped sentinel values — see {@link serviceTierFamily}.
|
|
122
123
|
*/
|
|
123
|
-
export type ServiceTier = "auto" | "default" | "flex" | "scale" | "priority"
|
|
124
|
+
export type ServiceTier = "auto" | "default" | "flex" | "scale" | "priority";
|
|
124
125
|
|
|
125
|
-
/**
|
|
126
|
-
export type
|
|
126
|
+
/** Provider families that expose an independent service-tier knob. */
|
|
127
|
+
export type ServiceTierFamily = "openai" | "anthropic" | "google";
|
|
127
128
|
|
|
128
129
|
/**
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
130
|
+
* Per-family service-tier selection. A request consults only the entry for the
|
|
131
|
+
* family its model belongs to (see {@link resolveModelServiceTier}), so a user
|
|
132
|
+
* can opt one family into priority without affecting the others when switching
|
|
133
|
+
* models mid-session.
|
|
132
134
|
*/
|
|
133
|
-
export
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
135
|
+
export type ServiceTierByFamily = Partial<Record<ServiceTierFamily, ServiceTier>>;
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Classify a model into the service-tier family whose knob governs it, or
|
|
139
|
+
* `undefined` when the model exposes no serving-priority control.
|
|
140
|
+
*
|
|
141
|
+
* OpenRouter models are classified by id namespace (`anthropic/`, `google/`,
|
|
142
|
+
* `openai/`); Claude on Bedrock/Vertex (api `anthropic-messages`) is the
|
|
143
|
+
* anthropic family even though its provider is `amazon-bedrock`/`google-vertex`.
|
|
144
|
+
*/
|
|
145
|
+
export function serviceTierFamily(model: Pick<Model, "provider" | "api" | "id">): ServiceTierFamily | undefined {
|
|
146
|
+
const provider = model.provider;
|
|
147
|
+
if (provider === "openrouter") {
|
|
148
|
+
const id = model.id.toLowerCase();
|
|
149
|
+
if (id.startsWith("anthropic/")) return "anthropic";
|
|
150
|
+
if (id.startsWith("google/")) return "google";
|
|
151
|
+
if (id.startsWith("openai/")) return "openai";
|
|
152
|
+
return undefined;
|
|
145
153
|
}
|
|
154
|
+
if (provider === "openai" || provider === "openai-codex") return "openai";
|
|
155
|
+
if (model.api === "anthropic-messages") return "anthropic";
|
|
156
|
+
if (provider === "google" || provider === "google-vertex") return "google";
|
|
157
|
+
return undefined;
|
|
146
158
|
}
|
|
147
159
|
|
|
148
160
|
/**
|
|
149
|
-
*
|
|
150
|
-
* `
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
161
|
+
* Reduce a per-family tier map to the single wire tier for `model` — the entry
|
|
162
|
+
* for the model's family, or `undefined` when the model has no family.
|
|
163
|
+
*/
|
|
164
|
+
export function resolveModelServiceTier(
|
|
165
|
+
tiers: ServiceTierByFamily | null | undefined,
|
|
166
|
+
model: Pick<Model, "provider" | "api" | "id">,
|
|
167
|
+
): ServiceTier | undefined {
|
|
168
|
+
if (!tiers) return undefined;
|
|
169
|
+
const family = serviceTierFamily(model);
|
|
170
|
+
return family ? tiers[family] : undefined;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* True when the tier should be sent on the wire as the provider's service-tier
|
|
175
|
+
* request field. OpenAI / OpenAI-Codex accept `flex`/`scale`/`priority`; Google
|
|
176
|
+
* (Gemini API + Vertex) and OpenRouter accept `flex`/`priority`; Fireworks
|
|
177
|
+
* Serverless realizes only its Priority serving path. Anthropic is absent — it
|
|
178
|
+
* realizes `priority` via `speed: "fast"`, not a service-tier field.
|
|
155
179
|
*/
|
|
156
180
|
export function shouldSendServiceTier(
|
|
157
181
|
serviceTier: ServiceTier | null | undefined,
|
|
158
182
|
provider: Provider | undefined,
|
|
159
183
|
): boolean {
|
|
160
|
-
|
|
161
|
-
if (provider === "openai" || provider === "openai-codex") {
|
|
162
|
-
return
|
|
184
|
+
if (!serviceTier) return false;
|
|
185
|
+
if (provider === "openai" || provider === "openai-codex" || provider === "openrouter") {
|
|
186
|
+
return serviceTier === "flex" || serviceTier === "scale" || serviceTier === "priority";
|
|
187
|
+
}
|
|
188
|
+
if (provider === "google") {
|
|
189
|
+
return serviceTier === "flex" || serviceTier === "priority";
|
|
163
190
|
}
|
|
164
|
-
|
|
165
|
-
|
|
191
|
+
// Vertex realizes only priority (via header); flex has no documented control.
|
|
192
|
+
if (provider === "google-vertex" || provider === "fireworks") {
|
|
193
|
+
return serviceTier === "priority";
|
|
166
194
|
}
|
|
167
195
|
return false;
|
|
168
196
|
}
|
|
169
197
|
|
|
170
198
|
/**
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
199
|
+
* True when `priority` will actually be realized on the wire for `model`.
|
|
200
|
+
* Direct Anthropic realizes fast mode; OpenAI/Google/Fireworks emit the
|
|
201
|
+
* service-tier field; OpenRouter realizes it only for its OpenAI- and
|
|
202
|
+
* Google-family upstreams. Bedrock/Vertex Claude and OpenRouter Anthropic
|
|
203
|
+
* models do not realize priority and return `false`.
|
|
204
|
+
*/
|
|
205
|
+
export function realizesPriorityServiceTier(
|
|
206
|
+
serviceTier: ServiceTier | null | undefined,
|
|
207
|
+
model: Pick<Model, "provider" | "api" | "id">,
|
|
208
|
+
): boolean {
|
|
209
|
+
if (serviceTier !== "priority") return false;
|
|
210
|
+
if (model.provider === "anthropic") return true;
|
|
211
|
+
if (model.provider === "openrouter") {
|
|
212
|
+
const family = serviceTierFamily(model);
|
|
213
|
+
return family === "openai" || family === "google";
|
|
214
|
+
}
|
|
215
|
+
if (model.api === "anthropic-messages") return false;
|
|
216
|
+
return shouldSendServiceTier(serviceTier, model.provider);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Premium-request weight contributed by a priority request to a provider that
|
|
221
|
+
* realizes it and bills extra. Mirrors GitHub Copilot's `premiumRequests`
|
|
222
|
+
* accounting so the "premium requests" stat aggregates priority traffic across
|
|
223
|
+
* the OpenAI family, direct Anthropic fast mode, and Google priority.
|
|
175
224
|
*
|
|
176
|
-
* Returns 1
|
|
225
|
+
* Returns 1 only when priority is actually realized on the wire for `model`
|
|
226
|
+
* (see {@link realizesPriorityServiceTier}) and the provider bills it as a
|
|
227
|
+
* premium request. OpenRouter is excluded — it bills per its own pricing, not
|
|
228
|
+
* Copilot-premium semantics — as are Bedrock/Vertex Claude, where priority is
|
|
229
|
+
* silently dropped.
|
|
177
230
|
*/
|
|
178
231
|
export function getPriorityPremiumRequests(
|
|
179
232
|
serviceTier: ServiceTier | null | undefined,
|
|
180
|
-
|
|
233
|
+
model: Pick<Model, "provider" | "api" | "id">,
|
|
181
234
|
): number {
|
|
182
|
-
if (
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
235
|
+
if (!realizesPriorityServiceTier(serviceTier, model)) return 0;
|
|
236
|
+
const provider = model.provider;
|
|
237
|
+
return provider === "openai" ||
|
|
238
|
+
provider === "openai-codex" ||
|
|
239
|
+
provider === "anthropic" ||
|
|
240
|
+
provider === "google" ||
|
|
241
|
+
provider === "google-vertex"
|
|
242
|
+
? 1
|
|
243
|
+
: 0;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Coerce a persisted service-tier value to a {@link ServiceTierByFamily}. Newer
|
|
248
|
+
* sessions store the family map directly; legacy sessions stored a single
|
|
249
|
+
* scalar — `"priority"` applied everywhere, `"openai-only"`/`"claude-only"`
|
|
250
|
+
* scoped to one family, and the remaining values were OpenAI-only semantics.
|
|
251
|
+
*/
|
|
252
|
+
export function coerceServiceTierByFamily(value: unknown): ServiceTierByFamily | undefined {
|
|
253
|
+
if (value === null || value === undefined) return undefined;
|
|
254
|
+
if (typeof value === "object") {
|
|
255
|
+
const src = value as Record<string, unknown>;
|
|
256
|
+
const out: ServiceTierByFamily = {};
|
|
257
|
+
for (const family of ["openai", "anthropic", "google"] as const) {
|
|
258
|
+
const tier = src[family];
|
|
259
|
+
if (tier === "auto" || tier === "default" || tier === "flex" || tier === "scale" || tier === "priority") {
|
|
260
|
+
out[family] = tier;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
264
|
+
}
|
|
265
|
+
switch (value) {
|
|
266
|
+
case "priority":
|
|
267
|
+
return { openai: "priority", anthropic: "priority", google: "priority" };
|
|
268
|
+
case "openai-only":
|
|
269
|
+
return { openai: "priority" };
|
|
270
|
+
case "claude-only":
|
|
271
|
+
return { anthropic: "priority" };
|
|
272
|
+
case "auto":
|
|
273
|
+
return { openai: "auto" };
|
|
274
|
+
case "default":
|
|
275
|
+
return { openai: "default" };
|
|
276
|
+
case "flex":
|
|
277
|
+
return { openai: "flex" };
|
|
278
|
+
case "scale":
|
|
279
|
+
return { openai: "scale" };
|
|
280
|
+
default:
|
|
281
|
+
return undefined;
|
|
282
|
+
}
|
|
186
283
|
}
|
|
187
284
|
|
|
188
285
|
export interface ProviderSessionState {
|
|
@@ -277,6 +374,22 @@ export interface StreamOptions {
|
|
|
277
374
|
* Providers can use this to persist transport/session state between turns.
|
|
278
375
|
*/
|
|
279
376
|
providerSessionState?: Map<string, ProviderSessionState>;
|
|
377
|
+
/**
|
|
378
|
+
* Force Gemini model-mode Interactions API transport for providers that support it.
|
|
379
|
+
* When unset, those providers may still use Interactions to continue known
|
|
380
|
+
* server-side conversation lineage via `previousInteractionId` or stored state.
|
|
381
|
+
*/
|
|
382
|
+
useInteractionsApi?: boolean;
|
|
383
|
+
/**
|
|
384
|
+
* Whether supported Interactions transports should store server-side conversation
|
|
385
|
+
* state and return response ids for follow-up turns. Defaults to true.
|
|
386
|
+
*/
|
|
387
|
+
storeInteraction?: boolean;
|
|
388
|
+
/**
|
|
389
|
+
* Explicit Interactions response id to continue. Mutually exclusive with
|
|
390
|
+
* `storeInteraction: false` because the follow-up itself must be storable.
|
|
391
|
+
*/
|
|
392
|
+
previousInteractionId?: string;
|
|
280
393
|
/**
|
|
281
394
|
* Optional per-provider concurrent request cap for LLM stream calls. Keys are
|
|
282
395
|
* provider ids (`model.provider`); positive numeric values cap in-flight
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Central live healing for leaked reasoning markup in the visible text channel.
|
|
3
|
+
*
|
|
4
|
+
* Some providers emit their canonical reasoning idioms (` ```thinking `,
|
|
5
|
+
* `<think>`, Gemma/Harmony channels, …) into the *visible* text stream instead
|
|
6
|
+
* of a structured thinking part. {@link wrapLeakedThinkingStream} re-projects any
|
|
7
|
+
* provider stream into a fresh {@link AssistantMessageEventStream}, splitting the
|
|
8
|
+
* leaked fences out into proper `thinking` blocks *live* as deltas arrive — so
|
|
9
|
+
* every provider gets the same healing, not just the three with provider-local
|
|
10
|
+
* {@link StreamMarkupHealing} loops.
|
|
11
|
+
*
|
|
12
|
+
* The healing is idempotent: a second pass over already-clean text finds no
|
|
13
|
+
* fences, so wrapping a provider that already heals (or wrapping twice) is a
|
|
14
|
+
* harmless pass-through. Signatures are load-bearing for Google/Gemini/Vertex
|
|
15
|
+
* thought round-tripping, so text sub-blocks carry the source `textSignature`,
|
|
16
|
+
* forwarded thinking blocks their `thinkingSignature`, and forwarded tool calls
|
|
17
|
+
* their `thoughtSignature`.
|
|
18
|
+
*
|
|
19
|
+
* Modeled on {@link wrapInbandToolStream} / `InbandStreamProjector` in
|
|
20
|
+
* `../dialect/owned-stream.ts`, minus all in-band tool-call grammar: tool-call
|
|
21
|
+
* events are forwarded verbatim.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type { AssistantMessage, TextContent, ThinkingContent, ToolCall } from "../types";
|
|
25
|
+
import { AssistantMessageEventStream } from "./event-stream";
|
|
26
|
+
import { StreamMarkupHealing, type StreamMarkupHealingEvent } from "./stream-markup-healing";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Wrap a provider stream so leaked reasoning fences are healed into thinking
|
|
30
|
+
* blocks live, for every provider. Returns a new stream that re-projects the
|
|
31
|
+
* inner one; the inner stream is fully consumed.
|
|
32
|
+
*/
|
|
33
|
+
export function wrapLeakedThinkingStream(inner: AssistantMessageEventStream): AssistantMessageEventStream {
|
|
34
|
+
const out = new AssistantMessageEventStream();
|
|
35
|
+
void (async () => {
|
|
36
|
+
try {
|
|
37
|
+
let projector: LeakedThinkingProjector | undefined;
|
|
38
|
+
for await (const event of inner) {
|
|
39
|
+
switch (event.type) {
|
|
40
|
+
case "start":
|
|
41
|
+
projector = new LeakedThinkingProjector(out, event.partial);
|
|
42
|
+
break;
|
|
43
|
+
case "text_delta": {
|
|
44
|
+
projector ??= new LeakedThinkingProjector(out, event.partial);
|
|
45
|
+
const block = event.partial.content[event.contentIndex];
|
|
46
|
+
projector.text(event.delta, block?.type === "text" ? block.textSignature : undefined);
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
case "thinking_delta": {
|
|
50
|
+
projector ??= new LeakedThinkingProjector(out, event.partial);
|
|
51
|
+
const block = event.partial.content[event.contentIndex];
|
|
52
|
+
projector.thinking(event.delta, block?.type === "thinking" ? block.thinkingSignature : undefined);
|
|
53
|
+
break;
|
|
54
|
+
}
|
|
55
|
+
case "toolcall_start": {
|
|
56
|
+
projector ??= new LeakedThinkingProjector(out, event.partial);
|
|
57
|
+
const block = event.partial.content[event.contentIndex];
|
|
58
|
+
projector.toolStart(event.contentIndex, block?.type === "toolCall" ? block.name : "");
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
case "toolcall_delta":
|
|
62
|
+
projector?.toolDelta(event.contentIndex, event.delta);
|
|
63
|
+
break;
|
|
64
|
+
case "toolcall_end":
|
|
65
|
+
projector?.toolEnd(event.contentIndex, event.toolCall);
|
|
66
|
+
break;
|
|
67
|
+
case "done": {
|
|
68
|
+
projector ??= new LeakedThinkingProjector(out, event.message);
|
|
69
|
+
const content = projector.finish(event.message);
|
|
70
|
+
out.push({ type: "done", reason: event.reason, message: { ...event.message, content } });
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
case "error": {
|
|
74
|
+
projector ??= new LeakedThinkingProjector(out, event.error);
|
|
75
|
+
const content = projector.finish(event.error);
|
|
76
|
+
out.push({ type: "error", reason: event.reason, error: { ...event.error, content } });
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
// text_start/text_end/thinking_start/thinking_end are ignored: the
|
|
80
|
+
// projector owns block boundaries (matches wrapInbandToolStream).
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
// Inner ended via end(result) without a terminal event.
|
|
84
|
+
if (!out.done) {
|
|
85
|
+
const result = await inner.result();
|
|
86
|
+
projector ??= new LeakedThinkingProjector(out, result);
|
|
87
|
+
const content = projector.finish(result);
|
|
88
|
+
out.end({ ...result, content });
|
|
89
|
+
}
|
|
90
|
+
} catch (err) {
|
|
91
|
+
if (!out.done) out.fail(err);
|
|
92
|
+
}
|
|
93
|
+
})();
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
type OpenBlock = { index: number } | undefined;
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Re-projects an inner stream's events into `out`, healing leaked reasoning out
|
|
101
|
+
* of the visible text channel while forwarding native thinking and tool calls.
|
|
102
|
+
*/
|
|
103
|
+
class LeakedThinkingProjector {
|
|
104
|
+
readonly #out: AssistantMessageEventStream;
|
|
105
|
+
readonly #healer = new StreamMarkupHealing({ pattern: "thinking" });
|
|
106
|
+
#partial: AssistantMessage;
|
|
107
|
+
#text: OpenBlock;
|
|
108
|
+
#thinking: OpenBlock;
|
|
109
|
+
/** Total visible text length fed to the healer, to replay any un-streamed tail in {@link finish}. */
|
|
110
|
+
#fedLen = 0;
|
|
111
|
+
/** Latest non-undefined text signature seen, stamped onto held-back text flushed later. */
|
|
112
|
+
#lastTextSignature: string | undefined;
|
|
113
|
+
/** Forwarded native tool calls, keyed by the inner stream's `contentIndex`. */
|
|
114
|
+
#toolBlocks = new Map<number, { index: number }>();
|
|
115
|
+
|
|
116
|
+
constructor(out: AssistantMessageEventStream, seed: AssistantMessage) {
|
|
117
|
+
this.#out = out;
|
|
118
|
+
this.#partial = { ...seed, content: [] };
|
|
119
|
+
this.#out.push({ type: "start", partial: this.#partial });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Feed a visible-text delta through the healer, splitting leaked fences live. */
|
|
123
|
+
text(delta: string, signature: string | undefined): void {
|
|
124
|
+
this.#fedLen += delta.length;
|
|
125
|
+
if (signature !== undefined) this.#lastTextSignature = signature;
|
|
126
|
+
this.#apply(this.#healer.feedEvents(delta), this.#lastTextSignature);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Forward a native thinking delta, preserving its signature. */
|
|
130
|
+
thinking(delta: string, signature: string | undefined): void {
|
|
131
|
+
const index = this.#openThinking();
|
|
132
|
+
const block = this.#partial.content[index] as ThinkingContent;
|
|
133
|
+
block.thinking += delta;
|
|
134
|
+
if (signature !== undefined) block.thinkingSignature = signature;
|
|
135
|
+
this.#out.push({ type: "thinking_delta", contentIndex: index, delta, partial: this.#partial });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Forward a native tool call's start, releasing any held-back text first. */
|
|
139
|
+
toolStart(srcIndex: number, name: string): void {
|
|
140
|
+
this.#apply(this.#healer.flushEvents(), this.#lastTextSignature);
|
|
141
|
+
this.#closeText();
|
|
142
|
+
this.#closeThinking();
|
|
143
|
+
const block: ToolCall = { type: "toolCall", id: "", name, arguments: {} };
|
|
144
|
+
this.#partial.content.push(block);
|
|
145
|
+
const index = this.#partial.content.length - 1;
|
|
146
|
+
this.#toolBlocks.set(srcIndex, { index });
|
|
147
|
+
this.#out.push({ type: "toolcall_start", contentIndex: index, partial: this.#partial });
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
toolDelta(srcIndex: number, delta: string): void {
|
|
151
|
+
const entry = this.#toolBlocks.get(srcIndex);
|
|
152
|
+
if (!entry) return;
|
|
153
|
+
this.#out.push({ type: "toolcall_delta", contentIndex: entry.index, delta, partial: this.#partial });
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
toolEnd(srcIndex: number, toolCall: ToolCall): void {
|
|
157
|
+
const entry = this.#toolBlocks.get(srcIndex);
|
|
158
|
+
if (entry) {
|
|
159
|
+
const block = this.#partial.content[entry.index] as ToolCall;
|
|
160
|
+
Object.assign(block, toolCall);
|
|
161
|
+
this.#out.push({ type: "toolcall_end", contentIndex: entry.index, toolCall: block, partial: this.#partial });
|
|
162
|
+
this.#toolBlocks.delete(srcIndex);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
// `end` without a matching `start` — release held text, then forward whole.
|
|
166
|
+
this.#apply(this.#healer.flushEvents(), this.#lastTextSignature);
|
|
167
|
+
this.#closeText();
|
|
168
|
+
this.#closeThinking();
|
|
169
|
+
const block: ToolCall = { ...toolCall };
|
|
170
|
+
this.#partial.content.push(block);
|
|
171
|
+
const index = this.#partial.content.length - 1;
|
|
172
|
+
this.#out.push({ type: "toolcall_start", contentIndex: index, partial: this.#partial });
|
|
173
|
+
this.#out.push({ type: "toolcall_end", contentIndex: index, toolCall: block, partial: this.#partial });
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Finalize: replay any un-streamed visible-text tail from `message.content`,
|
|
178
|
+
* flush held-back fragments, close open blocks, and return the healed content.
|
|
179
|
+
*/
|
|
180
|
+
finish(message: AssistantMessage): AssistantMessage["content"] {
|
|
181
|
+
let fullText = "";
|
|
182
|
+
let tailSignature: string | undefined;
|
|
183
|
+
for (const block of message.content) {
|
|
184
|
+
if (block.type === "text") {
|
|
185
|
+
fullText += block.text;
|
|
186
|
+
tailSignature = block.textSignature;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (tailSignature !== undefined) this.#lastTextSignature = tailSignature;
|
|
190
|
+
if (fullText.length > this.#fedLen) {
|
|
191
|
+
this.#apply(this.#healer.feedEvents(fullText.slice(this.#fedLen)), this.#lastTextSignature);
|
|
192
|
+
}
|
|
193
|
+
this.#apply(this.#healer.flushEvents(), this.#lastTextSignature);
|
|
194
|
+
this.#closeText();
|
|
195
|
+
this.#closeThinking();
|
|
196
|
+
return this.#partial.content;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
#apply(events: readonly StreamMarkupHealingEvent[], signature?: string): void {
|
|
200
|
+
for (const event of events) {
|
|
201
|
+
if (event.type === "text") this.#emitText(event.text, signature);
|
|
202
|
+
else if (event.type === "thinking") this.#emitHealedThinking(event.thinking);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
#emitText(text: string, signature: string | undefined): void {
|
|
207
|
+
if (text.length === 0) return;
|
|
208
|
+
this.#closeThinking();
|
|
209
|
+
if (!this.#text) {
|
|
210
|
+
const block: TextContent =
|
|
211
|
+
signature === undefined ? { type: "text", text: "" } : { type: "text", text: "", textSignature: signature };
|
|
212
|
+
this.#partial.content.push(block);
|
|
213
|
+
this.#text = { index: this.#partial.content.length - 1 };
|
|
214
|
+
this.#out.push({ type: "text_start", contentIndex: this.#text.index, partial: this.#partial });
|
|
215
|
+
} else if (signature !== undefined) {
|
|
216
|
+
(this.#partial.content[this.#text.index] as TextContent).textSignature = signature;
|
|
217
|
+
}
|
|
218
|
+
const block = this.#partial.content[this.#text.index] as TextContent;
|
|
219
|
+
block.text += text;
|
|
220
|
+
this.#out.push({ type: "text_delta", contentIndex: this.#text.index, delta: text, partial: this.#partial });
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Healed (leaked) thinking carries no signature, matching the source fence. */
|
|
224
|
+
#emitHealedThinking(text: string): void {
|
|
225
|
+
if (text.length === 0) return;
|
|
226
|
+
const index = this.#openThinking();
|
|
227
|
+
const block = this.#partial.content[index] as ThinkingContent;
|
|
228
|
+
block.thinking += text;
|
|
229
|
+
this.#out.push({ type: "thinking_delta", contentIndex: index, delta: text, partial: this.#partial });
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
#openThinking(): number {
|
|
233
|
+
this.#closeText();
|
|
234
|
+
if (!this.#thinking) {
|
|
235
|
+
this.#partial.content.push({ type: "thinking", thinking: "" });
|
|
236
|
+
this.#thinking = { index: this.#partial.content.length - 1 };
|
|
237
|
+
this.#out.push({ type: "thinking_start", contentIndex: this.#thinking.index, partial: this.#partial });
|
|
238
|
+
}
|
|
239
|
+
return this.#thinking.index;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
#closeText(): void {
|
|
243
|
+
if (!this.#text) return;
|
|
244
|
+
const block = this.#partial.content[this.#text.index] as TextContent;
|
|
245
|
+
this.#out.push({ type: "text_end", contentIndex: this.#text.index, content: block.text, partial: this.#partial });
|
|
246
|
+
this.#text = undefined;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
#closeThinking(): void {
|
|
250
|
+
if (!this.#thinking) return;
|
|
251
|
+
const block = this.#partial.content[this.#thinking.index] as ThinkingContent;
|
|
252
|
+
this.#out.push({
|
|
253
|
+
type: "thinking_end",
|
|
254
|
+
contentIndex: this.#thinking.index,
|
|
255
|
+
content: block.thinking,
|
|
256
|
+
partial: this.#partial,
|
|
257
|
+
});
|
|
258
|
+
this.#thinking = undefined;
|
|
259
|
+
}
|
|
260
|
+
}
|