@oh-my-pi/pi-ai 17.0.7 → 17.0.8
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 +16 -0
- package/dist/types/error/flags.d.ts +7 -1
- package/dist/types/providers/__tests__/keyless-auth-header.test.d.ts +1 -0
- package/dist/types/providers/openai-shared.d.ts +9 -0
- package/dist/types/types.d.ts +2 -1
- package/package.json +4 -4
- package/src/auth-gateway/server.ts +13 -7
- package/src/error/flags.ts +11 -2
- package/src/providers/__tests__/keyless-auth-header.test.ts +39 -0
- package/src/providers/__tests__/kimi-code-thinking.test.ts +1 -1
- package/src/providers/google-gemini-cli.ts +11 -12
- package/src/providers/openai-shared.ts +19 -1
- package/src/registry/alibaba-coding-plan.ts +16 -7
- package/src/types.ts +9 -4
- package/src/utils/leaked-thinking-stream.ts +58 -9
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- Fixed GitHub Copilot OpenAI-compatible requests being rejected when the session's native OpenAI service tier was set to `priority` ([#5160](https://github.com/can1357/oh-my-pi/pull/5160) by [@audreyt](https://github.com/audreyt)).
|
|
8
|
+
|
|
9
|
+
## [17.0.8] - 2026-07-22
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- Fixed Gemini Flash Cloud Code Assist empty-response retries when responses contain only intercepted planning-leak JSON.
|
|
14
|
+
- Fixed Antigravity auto-routing to correctly fail over to the sandbox endpoint when the daily endpoint exhausts its retries.
|
|
15
|
+
- Fixed OpenAI-compatible providers configured with auth: none incorrectly sending an Authorization: Bearer N/A header, which broke custom endpoints using alternative authentication headers.
|
|
16
|
+
- Fixed auth-gateway model listings exposing duplicate or ambiguous model IDs by ensuring only provider-qualified routing IDs are advertised.
|
|
17
|
+
- Improved connection error handling by classifying generic connection failures as transient, allowing them to be retried, while keeping explicit authentication rejections non-retryable.
|
|
18
|
+
- Fixed custom Anthropic base URLs losing native thinking signatures during continuation requests.
|
|
19
|
+
- Fixed Alibaba Coding Plan Custom login rejecting valid API keys on endpoints that do not serve the default validation model by validating against the model catalog instead.
|
|
20
|
+
|
|
5
21
|
## [17.0.6] - 2026-07-20
|
|
6
22
|
|
|
7
23
|
### Fixed
|
|
@@ -73,7 +73,13 @@ export declare function classifyMessage(message: {
|
|
|
73
73
|
export declare function attach<E extends object>(error: E, id: number): E;
|
|
74
74
|
export declare function isContextOverflow(message: AssistantMessage, contextWindow?: number): boolean;
|
|
75
75
|
export declare function stringify(id: number | undefined): string;
|
|
76
|
-
/**
|
|
76
|
+
/**
|
|
77
|
+
* Transient stream corruption where the response was truncated mid-JSON.
|
|
78
|
+
*
|
|
79
|
+
* Strings (persisted `stopDetails.explanation`/`errorMessage` diagnostics) are matched with the
|
|
80
|
+
* stricter {@link STREAM_PARSE_DIAGNOSTIC_PATTERN} — bare "truncated"/"end of file" text is too
|
|
81
|
+
* low-signal to trust once detached from a live transport `Error`, which keeps the broad pattern.
|
|
82
|
+
*/
|
|
77
83
|
export declare function isTransientStreamParseError(error: unknown): boolean;
|
|
78
84
|
/** Any malformed stream-envelope error (prefix-tagged or out-of-order events). */
|
|
79
85
|
export declare function isStreamEnvelopeError(error: unknown): boolean;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -7,6 +7,15 @@ import type { CapturedHttpErrorResponse } from "../utils/http-inspector.js";
|
|
|
7
7
|
import type { ChatCompletionCreateParamsStreaming } from "./openai-chat-wire.js";
|
|
8
8
|
import type { InputItem } from "./openai-codex/request-transformer.js";
|
|
9
9
|
import type { ResponseContentPartAddedEvent, ResponseCreateParamsStreaming, ResponseInput, ResponseInputContent, ResponseInputItem, ResponseOutputItem, ResponseOutputMessage, ResponseReasoningItem, ResponseStatus, ResponseStreamEvent } from "./openai-responses-wire.js";
|
|
10
|
+
/**
|
|
11
|
+
* Keyless-provider sentinel. Custom providers configured with `auth: none`
|
|
12
|
+
* (models.yml) have no credential, so the coding-agent resolves their API key
|
|
13
|
+
* to this literal instead of a real secret. Providers must treat it as "no
|
|
14
|
+
* credential" and suppress any credential-bearing header (e.g. `Authorization:
|
|
15
|
+
* Bearer …`) rather than forwarding the sentinel on the wire. See #6188; the
|
|
16
|
+
* google-vertex and amazon-bedrock transports apply the same guard inline.
|
|
17
|
+
*/
|
|
18
|
+
export declare const NO_AUTH_SENTINEL = "N/A";
|
|
10
19
|
export interface OpenAIModelIdentity {
|
|
11
20
|
provider: string;
|
|
12
21
|
id: string;
|
package/dist/types/types.d.ts
CHANGED
|
@@ -108,7 +108,8 @@ type ServiceTierModel = Pick<Model, "provider" | "api" | "id">;
|
|
|
108
108
|
* `openai/`); Claude on Bedrock/Vertex (api `anthropic-messages`) is the
|
|
109
109
|
* anthropic family even though its provider is `amazon-bedrock`/`google-vertex`.
|
|
110
110
|
* Custom OpenAI-compatible relays that serve OpenAI model ids are OpenAI family
|
|
111
|
-
* too unless
|
|
111
|
+
* too unless the provider owns a separate tier control (Fireworks) or rejects
|
|
112
|
+
* OpenAI's service-tier field (GitHub Copilot).
|
|
112
113
|
*/
|
|
113
114
|
export declare function serviceTierFamily(model: ServiceTierModel): ServiceTierFamily | undefined;
|
|
114
115
|
/**
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-ai",
|
|
4
|
-
"version": "17.0.
|
|
4
|
+
"version": "17.0.8",
|
|
5
5
|
"description": "Unified LLM API with automatic model discovery and provider configuration",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -38,9 +38,9 @@
|
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
40
|
"@bufbuild/protobuf": "^2.12.1",
|
|
41
|
-
"@oh-my-pi/pi-catalog": "17.0.
|
|
42
|
-
"@oh-my-pi/pi-utils": "17.0.
|
|
43
|
-
"@oh-my-pi/pi-wire": "17.0.
|
|
41
|
+
"@oh-my-pi/pi-catalog": "17.0.8",
|
|
42
|
+
"@oh-my-pi/pi-utils": "17.0.8",
|
|
43
|
+
"@oh-my-pi/pi-wire": "17.0.8",
|
|
44
44
|
"arktype": "2.2.3",
|
|
45
45
|
"zod": "^4"
|
|
46
46
|
},
|
|
@@ -726,13 +726,19 @@ async function handleCredentialsCheck(storage: AuthStorage, signal: AbortSignal)
|
|
|
726
726
|
}
|
|
727
727
|
|
|
728
728
|
function handleModelsList(opts: AuthGatewayBootOptions): Response {
|
|
729
|
-
const
|
|
730
|
-
const data
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
729
|
+
const seen = new Set<string>();
|
|
730
|
+
const data: Array<{ id: string; object: "model"; owned_by: string; api: Api }> = [];
|
|
731
|
+
for (const model of opts.listModels?.() ?? []) {
|
|
732
|
+
const id = `${model.provider}/${model.id}`;
|
|
733
|
+
if (seen.has(id)) continue;
|
|
734
|
+
seen.add(id);
|
|
735
|
+
data.push({
|
|
736
|
+
id,
|
|
737
|
+
object: "model",
|
|
738
|
+
owned_by: model.provider,
|
|
739
|
+
api: model.api,
|
|
740
|
+
});
|
|
741
|
+
}
|
|
736
742
|
return json(200, { object: "list", data });
|
|
737
743
|
}
|
|
738
744
|
|
package/src/error/flags.ts
CHANGED
|
@@ -90,7 +90,7 @@ const TRANSIENT_ENVELOPE_PATTERN = /anthropic stream envelope error:/i;
|
|
|
90
90
|
const TRANSIENT_ENVELOPE_BEFORE_START_PATTERN = /before message_start/i;
|
|
91
91
|
export const STREAM_READ_ERROR_PATTERN = /stream[_ -]?read[_ -]?error/i;
|
|
92
92
|
export const TRANSIENT_TRANSPORT_PATTERN =
|
|
93
|
-
/overloaded|provider.?returned.?error|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server.?error|internal.?error|retry your request|network.?error|connection.?error|connection.?refused|other side closed|fetch failed|upstream.?connect|upstream.?request.?failed|reset before headers|socket hang up|timed? out|timeout|terminated|retry delay|stream stall|no error details in response|HTTP2(?:StreamReset|RefusedStream|EnhanceYourCalm)|malformed.?function.?call/i;
|
|
93
|
+
/overloaded|provider.?returned.?error|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server.?error|internal.?error|retry your request|network.?error|connection.?error|connection.?refused|unable.?to.?connect\.\s*is the computer able to access the url\?|other side closed|fetch failed|upstream.?connect|upstream.?request.?failed|reset before headers|socket hang up|timed? out|timeout|terminated|retry delay|stream stall|no error details in response|HTTP2(?:StreamReset|RefusedStream|EnhanceYourCalm)|malformed.?function.?call/i;
|
|
94
94
|
const AUTH_FAILURE_PATTERN =
|
|
95
95
|
/\b(?:401|403|unauthorized|forbidden|authentication|auth[_ ]?unavailable|no auth available|(?:invalid|no)[_ ]?api[_ ]?key)\b/i;
|
|
96
96
|
const MALFORMED_FUNCTION_CALL_PATTERN = /\bmalformed.?function.?call\b/i;
|
|
@@ -505,10 +505,19 @@ export function stringify(id: number | undefined): string {
|
|
|
505
505
|
|
|
506
506
|
const STREAM_PARSE_TRUNCATION_PATTERN =
|
|
507
507
|
/unterminated string|unexpected end of json input|unexpected end of data|unexpected eof|end of file|eof while parsing|truncated/i;
|
|
508
|
+
const STREAM_PARSE_DIAGNOSTIC_PATTERN =
|
|
509
|
+
/(?:json parse error:\s*(?:unterminated string|unexpected end of json input|unexpected end of data|unexpected eof|end of file|eof while parsing|truncated)|json\.parse:\s*(?:unterminated string|unexpected end of data)|unexpected end of json input|unexpected eof|eof while parsing)/i;
|
|
508
510
|
const STREAM_EVENT_ORDER_PATTERN = /stream event order|before message_start/i;
|
|
509
511
|
|
|
510
|
-
/**
|
|
512
|
+
/**
|
|
513
|
+
* Transient stream corruption where the response was truncated mid-JSON.
|
|
514
|
+
*
|
|
515
|
+
* Strings (persisted `stopDetails.explanation`/`errorMessage` diagnostics) are matched with the
|
|
516
|
+
* stricter {@link STREAM_PARSE_DIAGNOSTIC_PATTERN} — bare "truncated"/"end of file" text is too
|
|
517
|
+
* low-signal to trust once detached from a live transport `Error`, which keeps the broad pattern.
|
|
518
|
+
*/
|
|
511
519
|
export function isTransientStreamParseError(error: unknown): boolean {
|
|
520
|
+
if (typeof error === "string") return STREAM_PARSE_DIAGNOSTIC_PATTERN.test(error);
|
|
512
521
|
return error instanceof Error && STREAM_PARSE_TRUNCATION_PATTERN.test(error.message);
|
|
513
522
|
}
|
|
514
523
|
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { NO_AUTH_SENTINEL, resolveOpenAIRequestSetup } from "../openai-shared";
|
|
3
|
+
|
|
4
|
+
describe("resolveOpenAIRequestSetup keyless auth", () => {
|
|
5
|
+
test("omits Authorization for the keyless (auth: none) sentinel, keeping custom headers", () => {
|
|
6
|
+
const setup = resolveOpenAIRequestSetup(
|
|
7
|
+
{
|
|
8
|
+
provider: "qwen",
|
|
9
|
+
id: "Qwen3.6-35B-A3B",
|
|
10
|
+
baseUrl: "http://localhost:8788",
|
|
11
|
+
headers: { "x-api-key": "real-key" },
|
|
12
|
+
},
|
|
13
|
+
{ apiKey: NO_AUTH_SENTINEL, messages: [] },
|
|
14
|
+
);
|
|
15
|
+
expect(setup.headers.Authorization).toBeUndefined();
|
|
16
|
+
expect(setup.headers["x-api-key"]).toBe("real-key");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("still injects Bearer for a real key", () => {
|
|
20
|
+
const setup = resolveOpenAIRequestSetup(
|
|
21
|
+
{ provider: "custom", id: "m", baseUrl: "http://localhost:8788" },
|
|
22
|
+
{ apiKey: "sk-real", messages: [] },
|
|
23
|
+
);
|
|
24
|
+
expect(setup.headers.Authorization).toBe("Bearer sk-real");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("caller-supplied Authorization in model.headers is preserved even when keyless", () => {
|
|
28
|
+
const setup = resolveOpenAIRequestSetup(
|
|
29
|
+
{
|
|
30
|
+
provider: "qwen",
|
|
31
|
+
id: "m",
|
|
32
|
+
baseUrl: "http://localhost:8788",
|
|
33
|
+
headers: { Authorization: "Bearer custom-token" },
|
|
34
|
+
},
|
|
35
|
+
{ apiKey: NO_AUTH_SENTINEL, messages: [] },
|
|
36
|
+
);
|
|
37
|
+
expect(setup.headers.Authorization).toBe("Bearer custom-token");
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -161,7 +161,7 @@ describe("Kimi K3 thinking transport", () => {
|
|
|
161
161
|
it("downgrades named tool choice to required for K3 thinking", async () => {
|
|
162
162
|
vi.spyOn(kimiOauth, "getKimiCommonHeaders").mockReturnValue(KIMI_HEADERS);
|
|
163
163
|
const bundledModel = getBundledModel<"openai-completions">("kimi-code", "k3");
|
|
164
|
-
expect(bundledModel.compat.thinkingFormat).toBe("
|
|
164
|
+
expect(bundledModel.compat.thinkingFormat).toBe("kimi");
|
|
165
165
|
let payload: unknown;
|
|
166
166
|
const capturePayload = async (
|
|
167
167
|
model: Model<"openai-completions">,
|
|
@@ -815,9 +815,6 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
|
|
|
815
815
|
if (isBuffering) {
|
|
816
816
|
const buffered = consumePlanningBuffer(textBuffer, toolNames);
|
|
817
817
|
if (buffered.kind !== "incomplete") {
|
|
818
|
-
if (buffered.kind === "leak") {
|
|
819
|
-
sawLeak = true;
|
|
820
|
-
}
|
|
821
818
|
const visibleSignature = bufferedTextSignature;
|
|
822
819
|
isBuffering = false;
|
|
823
820
|
textBuffer = "";
|
|
@@ -896,9 +893,7 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
|
|
|
896
893
|
|
|
897
894
|
if (isBuffering && textBuffer !== "") {
|
|
898
895
|
const buffered = consumePlanningBuffer(textBuffer, toolNames, true);
|
|
899
|
-
|
|
900
|
-
sawLeak = true;
|
|
901
|
-
}
|
|
896
|
+
|
|
902
897
|
if (buffered.kind !== "incomplete") {
|
|
903
898
|
feedVisibleText(buffered.visibleText, bufferedTextSignature);
|
|
904
899
|
}
|
|
@@ -910,11 +905,10 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
|
|
|
910
905
|
flushVisibleText(bufferedTextSignature);
|
|
911
906
|
endCurrentBlock();
|
|
912
907
|
|
|
913
|
-
return hasMeaningfulGoogleContent(output)
|
|
908
|
+
return hasMeaningfulGoogleContent(output);
|
|
914
909
|
};
|
|
915
910
|
|
|
916
911
|
let receivedContent = false;
|
|
917
|
-
let sawLeak = false;
|
|
918
912
|
|
|
919
913
|
for (let i = 0; i < endpoints.length; i++) {
|
|
920
914
|
const endpoint = endpoints[i];
|
|
@@ -1055,10 +1049,15 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
|
|
|
1055
1049
|
break;
|
|
1056
1050
|
} catch (error) {
|
|
1057
1051
|
const status = extractHttpStatusFromError(error);
|
|
1058
|
-
if (
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1052
|
+
if (
|
|
1053
|
+
!isLastEndpoint &&
|
|
1054
|
+
!started &&
|
|
1055
|
+
(AIError.isTransientStatus(status) ||
|
|
1056
|
+
(status === undefined &&
|
|
1057
|
+
!(error instanceof AIError.ProviderResponseError && error.kind === "output") &&
|
|
1058
|
+
AIError.retriable(AIError.classify(error))))
|
|
1059
|
+
) {
|
|
1060
|
+
continue;
|
|
1062
1061
|
}
|
|
1063
1062
|
throw error;
|
|
1064
1063
|
}
|
|
@@ -101,6 +101,16 @@ import type {
|
|
|
101
101
|
import { transformMessages } from "./transform-messages";
|
|
102
102
|
import { joinTextWithImagePlaceholder, NON_VISION_IMAGE_PLACEHOLDER, partitionVisionContent } from "./vision-guard";
|
|
103
103
|
|
|
104
|
+
/**
|
|
105
|
+
* Keyless-provider sentinel. Custom providers configured with `auth: none`
|
|
106
|
+
* (models.yml) have no credential, so the coding-agent resolves their API key
|
|
107
|
+
* to this literal instead of a real secret. Providers must treat it as "no
|
|
108
|
+
* credential" and suppress any credential-bearing header (e.g. `Authorization:
|
|
109
|
+
* Bearer …`) rather than forwarding the sentinel on the wire. See #6188; the
|
|
110
|
+
* google-vertex and amazon-bedrock transports apply the same guard inline.
|
|
111
|
+
*/
|
|
112
|
+
export const NO_AUTH_SENTINEL = "N/A";
|
|
113
|
+
|
|
104
114
|
export interface OpenAIModelIdentity {
|
|
105
115
|
provider: string;
|
|
106
116
|
id: string;
|
|
@@ -279,7 +289,15 @@ export function resolveOpenAIRequestSetup(
|
|
|
279
289
|
baseUrl = baseUrl ?? ($env.OPENAI_BASE_URL?.trim() || options.defaultBaseUrl);
|
|
280
290
|
}
|
|
281
291
|
const requestHeaders = { ...headers };
|
|
282
|
-
|
|
292
|
+
// A keyless provider (`auth: none` in models.yml) resolves to the `N/A`
|
|
293
|
+
// sentinel rather than a real key. Injecting `Authorization: Bearer N/A`
|
|
294
|
+
// breaks custom endpoints that authenticate via their own headers (e.g.
|
|
295
|
+
// `headers.x-api-key`) and reject the bogus bearer — mirror the sentinel
|
|
296
|
+
// guards in google-vertex / amazon-bedrock and send no Authorization here
|
|
297
|
+
// (#6188). A caller-supplied Authorization in `model.headers` still wins.
|
|
298
|
+
if (apiKey !== NO_AUTH_SENTINEL) {
|
|
299
|
+
headers.Authorization ??= `Bearer ${apiKey}`;
|
|
300
|
+
}
|
|
283
301
|
return { copilotPremiumRequests, baseUrl, headers, query, requestHeaders };
|
|
284
302
|
}
|
|
285
303
|
|
|
@@ -71,13 +71,22 @@ export async function loginAlibabaCodingPlan(options: OAuthController): Promise<
|
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
options.onProgress?.("Validating API key...");
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
74
|
+
if (choice === "3") {
|
|
75
|
+
await apiKeyValidation.validateApiKeyAgainstModelsEndpoint({
|
|
76
|
+
provider: "Alibaba Coding Plan",
|
|
77
|
+
apiKey: trimmed,
|
|
78
|
+
modelsUrl: `${baseUrl}/models`,
|
|
79
|
+
signal: options.signal,
|
|
80
|
+
});
|
|
81
|
+
} else {
|
|
82
|
+
await apiKeyValidation.validateOpenAICompatibleApiKey({
|
|
83
|
+
provider: "Alibaba Coding Plan",
|
|
84
|
+
apiKey: trimmed,
|
|
85
|
+
baseUrl,
|
|
86
|
+
model: VALIDATION_MODEL,
|
|
87
|
+
signal: options.signal,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
81
90
|
|
|
82
91
|
return {
|
|
83
92
|
access: trimmed,
|
package/src/types.ts
CHANGED
|
@@ -141,13 +141,17 @@ function isOpenAIServiceTierApi(api: Api | undefined): boolean {
|
|
|
141
141
|
return api === "openai-completions" || api === "openai-responses" || api === "openai-codex-responses";
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
-
function
|
|
145
|
-
|
|
144
|
+
function excludesInferredOpenAIServiceTier(provider: Provider | undefined): boolean {
|
|
145
|
+
// Fireworks has its own priority-only control. GitHub Copilot proxies OpenAI
|
|
146
|
+
// models but rejects OpenAI's `service_tier` request field.
|
|
147
|
+
return provider === "fireworks" || provider === "github-copilot";
|
|
146
148
|
}
|
|
147
149
|
|
|
148
150
|
function isOpenAIServiceTierModel(model: ServiceTierModel): boolean {
|
|
149
151
|
return (
|
|
150
|
-
!
|
|
152
|
+
!excludesInferredOpenAIServiceTier(model.provider) &&
|
|
153
|
+
isOpenAIServiceTierApi(model.api) &&
|
|
154
|
+
isOpenAIModelId(model.id)
|
|
151
155
|
);
|
|
152
156
|
}
|
|
153
157
|
|
|
@@ -159,7 +163,8 @@ function isOpenAIServiceTierModel(model: ServiceTierModel): boolean {
|
|
|
159
163
|
* `openai/`); Claude on Bedrock/Vertex (api `anthropic-messages`) is the
|
|
160
164
|
* anthropic family even though its provider is `amazon-bedrock`/`google-vertex`.
|
|
161
165
|
* Custom OpenAI-compatible relays that serve OpenAI model ids are OpenAI family
|
|
162
|
-
* too unless
|
|
166
|
+
* too unless the provider owns a separate tier control (Fireworks) or rejects
|
|
167
|
+
* OpenAI's service-tier field (GitHub Copilot).
|
|
163
168
|
*/
|
|
164
169
|
export function serviceTierFamily(model: ServiceTierModel): ServiceTierFamily | undefined {
|
|
165
170
|
const provider = model.provider;
|
|
@@ -75,7 +75,19 @@ export function wrapLeakedThinkingStream(inner: AssistantMessageEventStream): As
|
|
|
75
75
|
case "thinking_delta": {
|
|
76
76
|
projector ??= new LeakedThinkingProjector(out, event.partial);
|
|
77
77
|
const block = event.partial.content[event.contentIndex];
|
|
78
|
-
projector.thinking(
|
|
78
|
+
projector.thinking(
|
|
79
|
+
event.contentIndex,
|
|
80
|
+
event.delta,
|
|
81
|
+
block?.type === "thinking" ? block.thinkingSignature : undefined,
|
|
82
|
+
);
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
case "thinking_end": {
|
|
86
|
+
const block = event.partial.content[event.contentIndex];
|
|
87
|
+
projector?.thinkingEnd(
|
|
88
|
+
event.contentIndex,
|
|
89
|
+
block?.type === "thinking" ? block.thinkingSignature : undefined,
|
|
90
|
+
);
|
|
79
91
|
break;
|
|
80
92
|
}
|
|
81
93
|
case "image_end":
|
|
@@ -108,8 +120,9 @@ export function wrapLeakedThinkingStream(inner: AssistantMessageEventStream): As
|
|
|
108
120
|
out.push({ type: "error", reason: event.reason, error: { ...event.error, content } });
|
|
109
121
|
return;
|
|
110
122
|
}
|
|
111
|
-
// text_start/text_end/thinking_start
|
|
112
|
-
//
|
|
123
|
+
// text_start/text_end/thinking_start are ignored: the projector owns
|
|
124
|
+
// block boundaries (matches wrapInbandToolStream). thinking_end is
|
|
125
|
+
// handled to capture the signature Anthropic delivers at block close.
|
|
113
126
|
}
|
|
114
127
|
}
|
|
115
128
|
// Inner ended via end(result) without a terminal event.
|
|
@@ -144,6 +157,10 @@ class LeakedThinkingProjector {
|
|
|
144
157
|
#lastTextSignature: string | undefined;
|
|
145
158
|
/** Forwarded native tool calls, keyed by the inner stream's `contentIndex`. */
|
|
146
159
|
#toolBlocks = new Map<number, { index: number; block: StreamingToolCall }>();
|
|
160
|
+
/** Projected native thinking blocks, keyed by the inner stream's `contentIndex`. */
|
|
161
|
+
#thinkingBlocks = new Map<number, number>();
|
|
162
|
+
/** Native thinking blocks whose projected `thinking_end` awaits the source end event. */
|
|
163
|
+
#pendingThinkingEnds = new Set<number>();
|
|
147
164
|
|
|
148
165
|
constructor(out: AssistantMessageEventStream, seed: AssistantMessage) {
|
|
149
166
|
this.#out = out;
|
|
@@ -158,15 +175,37 @@ class LeakedThinkingProjector {
|
|
|
158
175
|
this.#apply(this.#healer.feedEvents(delta), this.#lastTextSignature);
|
|
159
176
|
}
|
|
160
177
|
|
|
161
|
-
/** Forward a native thinking delta, preserving its signature. */
|
|
162
|
-
thinking(delta: string, signature: string | undefined): void {
|
|
163
|
-
|
|
178
|
+
/** Forward a native thinking delta, preserving its source block identity and signature. */
|
|
179
|
+
thinking(srcIndex: number, delta: string, signature: string | undefined): void {
|
|
180
|
+
let index = this.#thinkingBlocks.get(srcIndex);
|
|
181
|
+
if (index === undefined) {
|
|
182
|
+
if (this.#thinking && this.#pendingThinkingEnds.has(this.#thinking.index)) this.#closeThinking();
|
|
183
|
+
index = this.#openThinking();
|
|
184
|
+
this.#thinkingBlocks.set(srcIndex, index);
|
|
185
|
+
this.#pendingThinkingEnds.add(index);
|
|
186
|
+
}
|
|
164
187
|
const block = this.#partial.content[index] as ThinkingContent;
|
|
165
188
|
block.thinking += delta;
|
|
166
189
|
if (signature !== undefined) block.thinkingSignature = signature;
|
|
167
190
|
this.#out.push({ type: "thinking_delta", contentIndex: index, delta, partial: this.#partial });
|
|
168
191
|
}
|
|
169
192
|
|
|
193
|
+
/**
|
|
194
|
+
* Finalize a native thinking block by source identity. Its projected end is
|
|
195
|
+
* deferred until this event so stream consumers observe the completed
|
|
196
|
+
* signature before the block closes, even when later blocks started first.
|
|
197
|
+
*/
|
|
198
|
+
thinkingEnd(srcIndex: number, signature: string | undefined): void {
|
|
199
|
+
const index = this.#thinkingBlocks.get(srcIndex);
|
|
200
|
+
if (index === undefined) return;
|
|
201
|
+
if (signature !== undefined) {
|
|
202
|
+
(this.#partial.content[index] as ThinkingContent).thinkingSignature = signature;
|
|
203
|
+
}
|
|
204
|
+
if (!this.#pendingThinkingEnds.delete(index)) return;
|
|
205
|
+
if (this.#thinking?.index === index) this.#thinking = undefined;
|
|
206
|
+
this.#emitThinkingEnd(index);
|
|
207
|
+
}
|
|
208
|
+
|
|
170
209
|
/** Forward a completed native image after releasing held text. */
|
|
171
210
|
image(content: ImageContent): void {
|
|
172
211
|
this.#apply(this.#healer.flushEvents(), this.#lastTextSignature);
|
|
@@ -234,6 +273,10 @@ class LeakedThinkingProjector {
|
|
|
234
273
|
* flush held-back fragments, close open blocks, and return the healed content.
|
|
235
274
|
*/
|
|
236
275
|
finish(message: AssistantMessage): AssistantMessage["content"] {
|
|
276
|
+
for (const [srcIndex] of this.#thinkingBlocks) {
|
|
277
|
+
const block = message.content[srcIndex];
|
|
278
|
+
this.thinkingEnd(srcIndex, block?.type === "thinking" ? block.thinkingSignature : undefined);
|
|
279
|
+
}
|
|
237
280
|
let fullText = "";
|
|
238
281
|
let tailSignature: string | undefined;
|
|
239
282
|
for (const block of message.content) {
|
|
@@ -304,13 +347,19 @@ class LeakedThinkingProjector {
|
|
|
304
347
|
|
|
305
348
|
#closeThinking(): void {
|
|
306
349
|
if (!this.#thinking) return;
|
|
307
|
-
const
|
|
350
|
+
const index = this.#thinking.index;
|
|
351
|
+
this.#thinking = undefined;
|
|
352
|
+
if (this.#pendingThinkingEnds.has(index)) return;
|
|
353
|
+
this.#emitThinkingEnd(index);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
#emitThinkingEnd(index: number): void {
|
|
357
|
+
const block = this.#partial.content[index] as ThinkingContent;
|
|
308
358
|
this.#out.push({
|
|
309
359
|
type: "thinking_end",
|
|
310
|
-
contentIndex:
|
|
360
|
+
contentIndex: index,
|
|
311
361
|
content: block.thinking,
|
|
312
362
|
partial: this.#partial,
|
|
313
363
|
});
|
|
314
|
-
this.#thinking = undefined;
|
|
315
364
|
}
|
|
316
365
|
}
|