@oh-my-pi/pi-ai 16.2.5 → 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.
Files changed (31) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/dist/types/auth-storage.d.ts +11 -10
  3. package/dist/types/providers/__tests__/kimi-code-thinking.test.d.ts +1 -0
  4. package/dist/types/providers/google-auth.d.ts +8 -0
  5. package/dist/types/providers/google-interactions.d.ts +65 -0
  6. package/dist/types/providers/google-shared.d.ts +20 -5
  7. package/dist/types/providers/google-types.d.ts +5 -0
  8. package/dist/types/providers/openai-codex/request-transformer.d.ts +1 -1
  9. package/dist/types/providers/openai-codex-responses.d.ts +1 -1
  10. package/dist/types/providers/openai-shared.d.ts +3 -3
  11. package/dist/types/types.d.ts +80 -30
  12. package/dist/types/utils/leaked-thinking-stream.d.ts +29 -0
  13. package/package.json +4 -4
  14. package/src/auth-storage.ts +56 -52
  15. package/src/providers/__tests__/kimi-code-thinking.test.ts +112 -0
  16. package/src/providers/anthropic.ts +11 -10
  17. package/src/providers/google-auth.ts +25 -0
  18. package/src/providers/google-gemini-cli.ts +90 -41
  19. package/src/providers/google-interactions.ts +753 -0
  20. package/src/providers/google-shared.ts +74 -13
  21. package/src/providers/google-types.ts +5 -1
  22. package/src/providers/google-vertex.ts +117 -26
  23. package/src/providers/google.ts +61 -19
  24. package/src/providers/openai-chat-server.ts +2 -2
  25. package/src/providers/openai-codex/request-transformer.ts +17 -4
  26. package/src/providers/openai-codex-responses.ts +23 -5
  27. package/src/providers/openai-shared.ts +5 -11
  28. package/src/stream.ts +34 -12
  29. package/src/types.ts +164 -51
  30. package/src/usage/google-antigravity.ts +59 -5
  31. package/src/utils/leaked-thinking-stream.ts +260 -0
@@ -1736,8 +1736,8 @@ export class AuthStorage {
1736
1736
  /**
1737
1737
  * Classify where a provider's auth comes from, following the same precedence
1738
1738
  * as {@link AuthStorage.getApiKey}: runtime override → config override →
1739
- * stored credential (api_key before oauth, matching getApiKey)env var
1740
- * fallback resolver. Returns undefined when no auth is configured.
1739
+ * stored OAuth env var stored api_key fallback resolver. Returns
1740
+ * undefined when no auth is configured.
1741
1741
  *
1742
1742
  * Compact, structured counterpart to {@link describeCredentialSource}.
1743
1743
  */
@@ -1745,10 +1745,9 @@ export class AuthStorage {
1745
1745
  if (this.#runtimeOverrides.has(provider)) return { kind: "runtime" };
1746
1746
  if (this.#configOverrides.has(provider)) return { kind: "config" };
1747
1747
  const stored = this.#getCredentialsForProvider(provider);
1748
- if (stored.length > 0) {
1749
- return { kind: stored.some(credential => credential.type === "api_key") ? "api_key" : "oauth" };
1750
- }
1748
+ if (stored.some(credential => credential.type === "oauth")) return { kind: "oauth" };
1751
1749
  if (getEnvApiKey(provider)) return { kind: "env", envVar: getEnvApiKeyName(provider) };
1750
+ if (stored.some(credential => credential.type === "api_key")) return { kind: "api_key" };
1752
1751
  if (this.#fallbackResolver?.(provider)) return { kind: "fallback" };
1753
1752
  return undefined;
1754
1753
  }
@@ -3760,12 +3759,8 @@ export class AuthStorage {
3760
3759
  return configKey;
3761
3760
  }
3762
3761
 
3763
- const apiKeySelection = this.#selectCredentialByType(provider, "api_key");
3764
- if (apiKeySelection) {
3765
- return this.#configValueResolver(apiKeySelection.credential.key);
3766
- }
3767
-
3768
- // Return current OAuth access token only if it is not already expired.
3762
+ // Precedence: a deliberate OAuth login wins, then an explicit env var, then a stored
3763
+ // static api_key (which may be a stale broker-migrated copy) as a last resort.
3769
3764
  const oauthSelection = this.#selectCredentialByType(provider, "oauth");
3770
3765
  if (oauthSelection) {
3771
3766
  const expiresAt = oauthSelection.credential.expires;
@@ -3784,17 +3779,22 @@ export class AuthStorage {
3784
3779
  const envKey = getEnvApiKey(provider);
3785
3780
  if (envKey) return envKey;
3786
3781
 
3782
+ const apiKeySelection = this.#selectCredentialByType(provider, "api_key");
3783
+ if (apiKeySelection) {
3784
+ return this.#configValueResolver(apiKeySelection.credential.key);
3785
+ }
3786
+
3787
3787
  return this.#fallbackResolver?.(provider) ?? undefined;
3788
3788
  }
3789
3789
 
3790
3790
  /**
3791
3791
  * Get API key for a provider.
3792
- * Priority:
3792
+ * Priority (first match wins):
3793
3793
  * 1. Runtime override (CLI --api-key)
3794
3794
  * 2. Config override (models.yml `providers.<name>.apiKey`)
3795
- * 3. API key from storage
3796
- * 4. OAuth token from storage (auto-refreshed)
3797
- * 5. Environment variable
3795
+ * 3. OAuth token from storage (auto-refreshed)
3796
+ * 4. Environment variable
3797
+ * 5. Stored API key (e.g. a broker-migrated copy) — last resort, so an explicit env var wins
3798
3798
  * 6. Fallback resolver (models.yml custom providers, last-resort)
3799
3799
  */
3800
3800
  async getApiKey(provider: string, sessionId?: string, options?: AuthApiKeyOptions): Promise<string | undefined> {
@@ -3814,25 +3814,27 @@ export class AuthStorage {
3814
3814
  return configKey;
3815
3815
  }
3816
3816
 
3817
- const apiKeySelection = this.#selectCredentialByType(provider, "api_key", sessionId);
3818
- if (apiKeySelection) {
3819
- this.#recordSessionCredential(provider, sessionId, "api_key", apiKeySelection.index);
3820
- return this.#configValueResolver(apiKeySelection.credential.key);
3821
- }
3822
-
3817
+ // Precedence: a deliberate OAuth login wins, then an explicit env var, then a stored
3818
+ // static api_key (which may be a stale broker-migrated copy) as a last resort.
3823
3819
  const oauthResolved = await this.#resolveOAuthSelection(provider, sessionId, options);
3824
3820
  if (oauthResolved) {
3825
3821
  return oauthResolved.apiKey;
3826
3822
  }
3827
3823
 
3828
- // Fall back to environment variable or custom resolver. If we reach here after
3829
- // an OAuth miss, the session sticky (if any) is stale — the request will
3830
- // authenticate via env/fallback, not OAuth, so clear the sticky now so that
3831
- // getOAuthAccountId() correctly suppresses account_uuid for this session.
3824
+ // Past OAuth: the session sticky (if any) is stale the request authenticates via
3825
+ // env/api_key/fallback, not OAuth, so clear it now so getOAuthAccountId() correctly
3826
+ // suppresses account_uuid for this session.
3832
3827
  if (sessionId) this.#sessionLastCredential.get(provider)?.delete(sessionId);
3828
+
3833
3829
  const envKey = getEnvApiKey(provider);
3834
3830
  if (envKey) return envKey;
3835
3831
 
3832
+ const apiKeySelection = this.#selectCredentialByType(provider, "api_key", sessionId);
3833
+ if (apiKeySelection) {
3834
+ this.#recordSessionCredential(provider, sessionId, "api_key", apiKeySelection.index);
3835
+ return this.#configValueResolver(apiKeySelection.credential.key);
3836
+ }
3837
+
3836
3838
  // Fall back to custom resolver (e.g., models.json custom providers)
3837
3839
  return this.#fallbackResolver?.(provider) ?? undefined;
3838
3840
  }
@@ -4486,12 +4488,13 @@ export class AuthStorage {
4486
4488
  /**
4487
4489
  * Describe where the active credential for a provider came from.
4488
4490
  *
4489
- * Surfaces four layers, highest precedence first:
4491
+ * Mirrors {@link AuthStorage.getApiKey} precedence, highest first:
4490
4492
  * 1. Runtime override (`--api-key`).
4491
4493
  * 2. Config override (`models.yml` `providers.<name>.apiKey`).
4492
- * 3. Stored credential (the one this session is currently sticky to, or the
4493
- * one round-robin would pick next when no session id is supplied).
4494
- * 4. Env var / fallback resolver — when no stored credential exists.
4494
+ * 3. Stored OAuth credential.
4495
+ * 4. Env var overrides a stored static api_key (e.g. a stale broker copy).
4496
+ * 5. Stored api_key credential.
4497
+ * 6. Fallback resolver.
4495
4498
  *
4496
4499
  * The string is purely informational; consumers must not parse it.
4497
4500
  */
@@ -4505,30 +4508,31 @@ export class AuthStorage {
4505
4508
 
4506
4509
  const baseLabel = this.#sourceLabel ?? "local store";
4507
4510
  const stored = this.#getStoredCredentials(provider);
4508
- if (stored.length === 0) {
4509
- if (getEnvApiKey(provider)) return `env ${baseLabel ? `(fallback over ${baseLabel})` : ""}`.trim();
4510
- if (this.#fallbackResolver?.(provider) !== undefined) return `fallback resolver`;
4511
- return undefined;
4512
- }
4513
-
4514
4511
  const session = sessionId ? this.#sessionLastCredential.get(provider)?.get(sessionId) : undefined;
4515
- // Same selection logic as #selectCredentialByType for "no session" lookups: prefer
4516
- // the type with stored credentials, lean OAuth before api_key. We don't run the
4517
- // full round-robin here because describing the source shouldn't advance the index.
4518
- const preferredType: AuthCredential["type"] =
4519
- session?.type ?? (stored.some(entry => entry.credential.type === "oauth") ? "oauth" : "api_key");
4520
- const typed = stored
4521
- .map((entry, index) => ({ entry, index }))
4522
- .filter(({ entry }) => entry.credential.type === preferredType);
4523
- if (typed.length === 0) return baseLabel;
4524
- const index = session?.index ?? typed[0].index;
4525
- const chosen = stored[index] ?? typed[0].entry;
4526
- const credential = chosen.credential;
4527
- const identity =
4528
- credential.type === "oauth"
4529
- ? (credential.email ?? credential.accountId ?? credential.projectId ?? `cred ${chosen.id}`)
4530
- : `cred ${chosen.id}`;
4531
- return `${baseLabel} · ${preferredType} #${chosen.id} (${identity})`;
4512
+ // Describe the stored credential of a given type, honoring the session sticky index.
4513
+ const describeStored = (type: AuthCredential["type"]): string | undefined => {
4514
+ const typed = stored
4515
+ .map((entry, index) => ({ entry, index }))
4516
+ .filter(({ entry }) => entry.credential.type === type);
4517
+ if (typed.length === 0) return undefined;
4518
+ const index = session?.type === type ? session.index : typed[0].index;
4519
+ const chosen = stored[index] ?? typed[0].entry;
4520
+ const credential = chosen.credential;
4521
+ const identity =
4522
+ credential.type === "oauth"
4523
+ ? (credential.email ?? credential.accountId ?? credential.projectId ?? `cred ${chosen.id}`)
4524
+ : `cred ${chosen.id}`;
4525
+ return `${baseLabel} · ${type} #${chosen.id} (${identity})`;
4526
+ };
4527
+
4528
+ // A deliberate OAuth login wins; then an explicit env var; then a stored static api_key.
4529
+ const oauthSource = describeStored("oauth");
4530
+ if (oauthSource) return oauthSource;
4531
+ if (getEnvApiKey(provider)) return `env (over ${baseLabel})`;
4532
+ const apiKeySource = describeStored("api_key");
4533
+ if (apiKeySource) return apiKeySource;
4534
+ if (this.#fallbackResolver?.(provider) !== undefined) return "fallback resolver";
4535
+ return undefined;
4532
4536
  }
4533
4537
  }
4534
4538
 
@@ -0,0 +1,112 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { getBundledModel } from "@oh-my-pi/pi-catalog";
3
+ import type { Context } from "../../types";
4
+ import type { MessageCreateParamsStreaming } from "../anthropic-wire";
5
+ import { streamOpenAIAnthropicShim } from "../openai-anthropic-shim";
6
+ import {
7
+ applyChatCompletionsCompatPolicy,
8
+ type OpenAICompletionsParams,
9
+ resolveOpenAICompatPolicy,
10
+ } from "../openai-shared";
11
+
12
+ const BASE_CHAT_COMPLETIONS_PARAMS: OpenAICompletionsParams = { messages: [], model: "unused", stream: true };
13
+ const TITLE_CONTEXT: Context = {
14
+ systemPrompt: ["Generate a title."],
15
+ messages: [{ role: "user", content: "Explain the login failure", timestamp: 0 }],
16
+ tools: [
17
+ {
18
+ name: "set_title",
19
+ description: "Set title",
20
+ parameters: {
21
+ type: "object",
22
+ properties: { title: { type: "string" } },
23
+ required: ["title"],
24
+ additionalProperties: false,
25
+ },
26
+ },
27
+ ],
28
+ };
29
+
30
+ describe("Kimi K2.7 Code thinking policy", () => {
31
+ it("omits disabled thinking for title-generator-style Kimi Code requests", () => {
32
+ const model = getBundledModel<"openai-completions">("kimi-code", "kimi-for-coding");
33
+ const policy = resolveOpenAICompatPolicy(model, {
34
+ endpoint: "chat-completions",
35
+ disableReasoning: true,
36
+ toolChoice: { type: "tool", name: "set_title" },
37
+ });
38
+ const params = { ...BASE_CHAT_COMPLETIONS_PARAMS };
39
+
40
+ applyChatCompletionsCompatPolicy(params, policy);
41
+
42
+ expect("thinking" in params).toBe(false);
43
+ expect(model.compat.supportsForcedToolChoice).toBe(false);
44
+ });
45
+
46
+ it("enables thinking and downgrades forced tool choice on Kimi Code's Anthropic endpoint", async () => {
47
+ const model = getBundledModel<"openai-completions">("kimi-code", "kimi-for-coding");
48
+ let payload: MessageCreateParamsStreaming | undefined;
49
+ const stream = streamOpenAIAnthropicShim(
50
+ model,
51
+ TITLE_CONTEXT,
52
+ {
53
+ apiKey: "test-key",
54
+ maxTokens: 1024,
55
+ disableReasoning: true,
56
+ toolChoice: { type: "tool", name: "set_title" },
57
+ onPayload: body => {
58
+ payload = body as MessageCreateParamsStreaming;
59
+ throw new Error("stop after payload capture");
60
+ },
61
+ },
62
+ {
63
+ anthropicBaseUrl: "https://api.kimi.com/coding",
64
+ defaultFormat: "anthropic",
65
+ },
66
+ );
67
+
68
+ await stream.result();
69
+
70
+ expect(payload?.thinking?.type).toBe("enabled");
71
+ expect(payload?.tool_choice).toEqual({ type: "auto" });
72
+ });
73
+
74
+ it("omits disabled thinking for native Moonshot Kimi K2.7 Code variants", () => {
75
+ for (const modelId of ["kimi-k2.7-code", "kimi-k2.7-code-highspeed"]) {
76
+ const model = getBundledModel<"openai-completions">("moonshot", modelId);
77
+ const policy = resolveOpenAICompatPolicy(model, {
78
+ endpoint: "chat-completions",
79
+ disableReasoning: true,
80
+ });
81
+ const params = { ...BASE_CHAT_COMPLETIONS_PARAMS };
82
+ applyChatCompletionsCompatPolicy(params, policy);
83
+
84
+ expect("thinking" in params).toBe(false);
85
+ expect(model.compat.supportsForcedToolChoice).toBe(false);
86
+ }
87
+ });
88
+
89
+ it("keeps the openai disable shape for non-native Kimi K2.7 Code aliases", () => {
90
+ for (const { provider, id } of [
91
+ { provider: "fireworks", id: "kimi-k2.7-code" },
92
+ { provider: "openrouter", id: "moonshotai/kimi-k2.7-code" },
93
+ ] as const) {
94
+ const model = getBundledModel<"openai-completions">(provider, id);
95
+ expect(model.compat.supportsForcedToolChoice).toBe(true);
96
+ expect(model.compat.reasoningDisableMode).not.toBe("omit");
97
+ }
98
+ });
99
+
100
+ it("keeps explicit disabled thinking for Kimi K2.6", () => {
101
+ const model = getBundledModel<"openai-completions">("moonshot", "kimi-k2.6");
102
+ const policy = resolveOpenAICompatPolicy(model, {
103
+ endpoint: "chat-completions",
104
+ disableReasoning: true,
105
+ });
106
+ const params = { ...BASE_CHAT_COMPLETIONS_PARAMS };
107
+
108
+ applyChatCompletionsCompatPolicy(params, policy);
109
+
110
+ expect(params.thinking).toEqual({ type: "disabled" });
111
+ });
112
+ });
@@ -43,7 +43,6 @@ import type {
43
43
  ToolResultMessage,
44
44
  Usage,
45
45
  } from "../types";
46
- import { resolveServiceTier } from "../types";
47
46
  import { isRecord, normalizeSystemPrompts, normalizeToolCallId, resolveCacheRetention } from "../utils";
48
47
  import { createAbortSourceTracker } from "../utils/abort";
49
48
  import {
@@ -1595,7 +1594,7 @@ const streamAnthropicOnce = (
1595
1594
  isOAuthToken = false;
1596
1595
  } else {
1597
1596
  const extraBetas = normalizeExtraBetas(options?.betas);
1598
- const wantsAnthropicPriority = resolveServiceTier(options?.serviceTier, model.provider) === "priority";
1597
+ const wantsAnthropicPriority = model.provider === "anthropic" && options?.serviceTier === "priority";
1599
1598
  // Skip the fast-mode beta when this session already learned the
1600
1599
  // endpoint+model rejects fast mode; `speed` is dropped from the params
1601
1600
  // too (dropFastMode), so the request stays a faithful non-fast request.
@@ -2191,7 +2190,8 @@ const streamAnthropicOnce = (
2191
2190
  }
2192
2191
  if (
2193
2192
  !dropFastMode &&
2194
- resolveServiceTier(options?.serviceTier, model.provider) === "priority" &&
2193
+ model.provider === "anthropic" &&
2194
+ options?.serviceTier === "priority" &&
2195
2195
  firstTokenTime === undefined &&
2196
2196
  AIError.isFastModeUnsupported(streamFailure)
2197
2197
  ) {
@@ -2259,7 +2259,7 @@ const streamAnthropicOnce = (
2259
2259
  }
2260
2260
  output.duration = performance.now() - startTime;
2261
2261
  if (firstTokenTime) output.ttft = firstTokenTime - startTime;
2262
- if (dropFastMode && resolveServiceTier(options?.serviceTier, model.provider) === "priority") {
2262
+ if (dropFastMode && model.provider === "anthropic" && options?.serviceTier === "priority") {
2263
2263
  output.disabledFeatures = [...(output.disabledFeatures ?? []), "priority"];
2264
2264
  }
2265
2265
  stream.push({ type: "done", reason: output.stopReason, message: output });
@@ -2876,9 +2876,10 @@ function buildParams(
2876
2876
  let thinking: MessageCreateParamsStreaming["thinking"] | undefined;
2877
2877
  let outputConfigEffort: AnthropicOutputEffort | undefined;
2878
2878
  if (model.reasoning) {
2879
- if (options?.thinkingEnabled) {
2879
+ if (options?.thinkingEnabled || model.compat.requiresThinkingEnabled) {
2880
+ const thinkingOptions = options ?? {};
2880
2881
  const mode = model.thinking?.mode;
2881
- const effort = resolveAnthropicAdaptiveEffort(model, options);
2882
+ const effort = resolveAnthropicAdaptiveEffort(model, thinkingOptions);
2882
2883
  const compat = model.compat;
2883
2884
  if (mode === "anthropic-adaptive" && !compat.disableAdaptiveThinking) {
2884
2885
  const adaptive: { type: "adaptive"; display?: AnthropicThinkingDisplay } = { type: "adaptive" };
@@ -2889,15 +2890,15 @@ function buildParams(
2889
2890
  // support: Opus 4.6 / Sonnet 4.6+ reject it with a 400, so an explicit
2890
2891
  // `thinkingDisplay` MUST NOT force it onto a model that can't accept it.
2891
2892
  if (model.thinking?.supportsDisplay) {
2892
- adaptive.display = options.thinkingDisplay ?? "summarized";
2893
+ adaptive.display = thinkingOptions.thinkingDisplay ?? "summarized";
2893
2894
  }
2894
2895
  thinking = adaptive;
2895
2896
  if (effort && effort !== "adaptive") outputConfigEffort = effort;
2896
2897
  } else {
2897
2898
  thinking = {
2898
2899
  type: "enabled",
2899
- budget_tokens: options.thinkingBudgetTokens || 1024,
2900
- display: options.thinkingDisplay ?? "summarized",
2900
+ budget_tokens: thinkingOptions.thinkingBudgetTokens || 1024,
2901
+ display: thinkingOptions.thinkingDisplay ?? "summarized",
2901
2902
  };
2902
2903
  if (mode === "anthropic-budget-effort" && effort && effort !== "adaptive") outputConfigEffort = effort;
2903
2904
  }
@@ -2996,7 +2997,7 @@ function buildParams(
2996
2997
  seqs.length > ANTHROPIC_STOP_SEQUENCES_MAX ? seqs.slice(0, ANTHROPIC_STOP_SEQUENCES_MAX) : seqs;
2997
2998
  }
2998
2999
 
2999
- if (resolveServiceTier(options?.serviceTier, model.provider) === "priority") {
3000
+ if (model.provider === "anthropic" && options?.serviceTier === "priority") {
3000
3001
  params.speed = "fast";
3001
3002
  }
3002
3003
 
@@ -13,6 +13,7 @@
13
13
  */
14
14
 
15
15
  import { Buffer } from "node:buffer";
16
+ import * as fs from "node:fs";
16
17
  import * as os from "node:os";
17
18
  import * as path from "node:path";
18
19
  import { $envpos, isEnoent, logger } from "@oh-my-pi/pi-utils";
@@ -281,6 +282,11 @@ const SHARED_TOKEN_RESOLVE_TIMEOUT_MS = 30_000;
281
282
  * The token is cached in module scope and refreshed `GOOGLE_VERTEX_REFRESH_SKEW_MS` ms before it expires.
282
283
  */
283
284
  export async function getVertexAccessToken(options?: { signal?: AbortSignal; fetch?: FetchImpl }): Promise<string> {
285
+ // An explicit access token (e.g. `gcloud auth print-access-token`) bypasses the cache so a
286
+ // refreshed env token takes effect immediately. `CLOUDSDK_AUTH_ACCESS_TOKEN` is gcloud's own
287
+ // override var; `GOOGLE_CLOUD_ACCESS_TOKEN` is the omp-facing alias.
288
+ const explicitToken = Bun.env.GOOGLE_CLOUD_ACCESS_TOKEN || Bun.env.CLOUDSDK_AUTH_ACCESS_TOKEN;
289
+ if (explicitToken) return explicitToken;
284
290
  const fetchImpl = options?.fetch ?? globalThis.fetch.bind(globalThis);
285
291
  const skew = getRefreshSkewMs();
286
292
  const now = Date.now();
@@ -323,3 +329,22 @@ export function __resetVertexTokenCache(): void {
323
329
  tokenCache.clear();
324
330
  inflight.clear();
325
331
  }
332
+
333
+ /**
334
+ * Sync best-effort probe for a usable Vertex bearer credential source — an explicit access-token
335
+ * env var, `GOOGLE_APPLICATION_CREDENTIALS`, a user ADC file, or a GCP runtime whose metadata
336
+ * server can mint ADC (GCE/Cloud Run/App Engine/Functions). Lets callers prefer the bearer
337
+ * Interactions transport only when ADC is actually reachable, without paying the async
338
+ * metadata-probe cost for API-key-only setups.
339
+ */
340
+ export function hasVertexBearerCredentialsHint(): boolean {
341
+ if (Bun.env.GOOGLE_CLOUD_ACCESS_TOKEN || Bun.env.CLOUDSDK_AUTH_ACCESS_TOKEN) return true;
342
+ if (Bun.env.GOOGLE_APPLICATION_CREDENTIALS) return true;
343
+ // GCP-hosted runtimes expose ADC via the metadata server; these env vars mark those runtimes.
344
+ if (Bun.env.K_SERVICE || Bun.env.FUNCTION_TARGET || Bun.env.GAE_ENV || Bun.env.GCE_METADATA_HOST) return true;
345
+ try {
346
+ return fs.existsSync(userAdcPath());
347
+ } catch {
348
+ return false;
349
+ }
350
+ }
@@ -35,6 +35,7 @@ import { armPreResponseTimeout, getStreamFirstEventTimeoutMs } from "../utils/id
35
35
  // Refresh is the sole responsibility of AuthStorage (broker-aware, single-flighted);
36
36
  // the stream provider trusts the access token threaded through `options.apiKey`.
37
37
  import { normalizeSchemaForCCA } from "../utils/schema";
38
+ import { StreamMarkupHealing, type StreamMarkupHealingEvent } from "../utils/stream-markup-healing";
38
39
  import type { Content, FunctionCallingConfigMode, ThinkingConfig } from "./google-shared";
39
40
  import {
40
41
  convertMessages,
@@ -665,15 +666,43 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
665
666
  let currentBlock: TextContent | ThinkingContent | null = null;
666
667
  const blocks = output.content;
667
668
  const blockIndex = () => blocks.length - 1;
669
+ const visibleTextHealing = new StreamMarkupHealing({ pattern: "thinking" });
668
670
 
669
671
  let isBuffering = false;
670
672
  let textBuffer = "";
671
673
  let bufferedTextSignature: string | undefined;
672
674
 
673
- const emitVisibleText = (delta: string, thoughtSignature?: string) => {
674
- if (!delta || !currentBlock || currentBlock.type !== "text") return;
675
- currentBlock.text += delta;
676
- currentBlock.textSignature = retainThoughtSignature(currentBlock.textSignature, thoughtSignature);
675
+ const endCurrentBlock = (): void => {
676
+ if (!currentBlock) return;
677
+ pushBlockEndEvent(currentBlock, blockIndex(), output, stream);
678
+ currentBlock = null;
679
+ };
680
+
681
+ const startTextBlock = (): TextContent => {
682
+ let block = currentBlock;
683
+ if (block?.type !== "text") {
684
+ endCurrentBlock();
685
+ block = startTextOrThinkingBlock(false, output, stream, ensureStarted);
686
+ currentBlock = block;
687
+ }
688
+ return block;
689
+ };
690
+
691
+ const startThinkingBlock = (): ThinkingContent => {
692
+ let block = currentBlock;
693
+ if (block?.type !== "thinking") {
694
+ endCurrentBlock();
695
+ block = startTextOrThinkingBlock(true, output, stream, ensureStarted);
696
+ currentBlock = block;
697
+ }
698
+ return block;
699
+ };
700
+
701
+ const emitVisibleText = (delta: string, thoughtSignature?: string): void => {
702
+ if (!delta) return;
703
+ const block = startTextBlock();
704
+ block.text += delta;
705
+ block.textSignature = retainThoughtSignature(block.textSignature, thoughtSignature);
677
706
  stream.push({
678
707
  type: "text_delta",
679
708
  contentIndex: blockIndex(),
@@ -682,6 +711,48 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
682
711
  });
683
712
  };
684
713
 
714
+ const emitVisibleThinking = (delta: string): void => {
715
+ if (!delta) return;
716
+ const block = startThinkingBlock();
717
+ block.thinking += delta;
718
+ stream.push({
719
+ type: "thinking_delta",
720
+ contentIndex: blockIndex(),
721
+ delta,
722
+ partial: output,
723
+ });
724
+ };
725
+
726
+ const emitHealingEvent = (event: StreamMarkupHealingEvent, thoughtSignature?: string): void => {
727
+ if (event.type === "text") {
728
+ emitVisibleText(event.text, thoughtSignature);
729
+ } else if (event.type === "thinking") {
730
+ emitVisibleThinking(event.thinking);
731
+ }
732
+ };
733
+
734
+ const feedVisibleText = (delta: string, thoughtSignature?: string): void => {
735
+ for (const event of visibleTextHealing.feedEvents(delta)) {
736
+ emitHealingEvent(event, thoughtSignature);
737
+ }
738
+ };
739
+
740
+ const flushVisibleText = (thoughtSignature?: string): void => {
741
+ for (const event of visibleTextHealing.flushEvents()) {
742
+ emitHealingEvent(event, thoughtSignature);
743
+ }
744
+ };
745
+
746
+ const retainCurrentBlockThoughtSignature = (thoughtSignature: string): void => {
747
+ const block = currentBlock;
748
+ if (!block) return;
749
+ if (block.type === "thinking") {
750
+ block.thinkingSignature = retainThoughtSignature(block.thinkingSignature, thoughtSignature);
751
+ } else {
752
+ block.textSignature = retainThoughtSignature(block.textSignature, thoughtSignature);
753
+ }
754
+ };
755
+
685
756
  for await (const chunk of readSseJson<CloudCodeAssistResponseChunk>(
686
757
  activeResponse.body!,
687
758
  options?.signal,
@@ -710,20 +781,12 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
710
781
  for (const part of candidate.content.parts) {
711
782
  if (part.text !== undefined && part.text !== "") {
712
783
  const isThinking = isThinkingPart(part);
713
- if (
714
- !currentBlock ||
715
- (isThinking && currentBlock.type !== "thinking") ||
716
- (!isThinking && currentBlock.type !== "text")
717
- ) {
718
- if (currentBlock) {
719
- pushBlockEndEvent(currentBlock, blockIndex(), output, stream);
720
- }
721
- currentBlock = startTextOrThinkingBlock(isThinking, output, stream, ensureStarted);
722
- }
723
- if (currentBlock.type === "thinking") {
724
- currentBlock.thinking += part.text;
725
- currentBlock.thinkingSignature = retainThoughtSignature(
726
- currentBlock.thinkingSignature,
784
+ if (isThinking) {
785
+ flushVisibleText();
786
+ const block = startThinkingBlock();
787
+ block.thinking += part.text;
788
+ block.thinkingSignature = retainThoughtSignature(
789
+ block.thinkingSignature,
727
790
  part.thoughtSignature,
728
791
  );
729
792
  stream.push({
@@ -744,7 +807,7 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
744
807
  textBuffer = part.text;
745
808
  bufferedTextSignature = part.thoughtSignature;
746
809
  } else {
747
- emitVisibleText(part.text, part.thoughtSignature);
810
+ feedVisibleText(part.text, part.thoughtSignature);
748
811
  }
749
812
 
750
813
  if (isBuffering) {
@@ -757,32 +820,19 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
757
820
  isBuffering = false;
758
821
  textBuffer = "";
759
822
  bufferedTextSignature = undefined;
760
- emitVisibleText(buffered.visibleText, visibleSignature);
823
+ feedVisibleText(buffered.visibleText, visibleSignature);
761
824
  }
762
825
  }
763
826
  }
764
- } else if (part.text === "" && part.thoughtSignature && currentBlock && !part.functionCall) {
765
- if (currentBlock.type === "thinking") {
766
- currentBlock.thinkingSignature = retainThoughtSignature(
767
- currentBlock.thinkingSignature,
768
- part.thoughtSignature,
769
- );
770
- } else {
771
- currentBlock.textSignature = retainThoughtSignature(
772
- currentBlock.textSignature,
773
- part.thoughtSignature,
774
- );
775
- }
827
+ } else if (part.text === "" && part.thoughtSignature && !part.functionCall) {
828
+ retainCurrentBlockThoughtSignature(part.thoughtSignature);
776
829
  }
777
830
 
778
831
  if (part.functionCall) {
779
- if (currentBlock) {
780
- pushBlockEndEvent(currentBlock, blockIndex(), output, stream);
781
- currentBlock = null;
782
- }
832
+ flushVisibleText();
833
+ endCurrentBlock();
783
834
  isBuffering = false;
784
835
  textBuffer = "";
785
-
786
836
  const providedId = part.functionCall.id;
787
837
  const needsNewId =
788
838
  !providedId || output.content.some(b => b.type === "toolCall" && b.id === providedId);
@@ -848,16 +898,15 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
848
898
  sawLeak = true;
849
899
  }
850
900
  if (buffered.kind !== "incomplete") {
851
- emitVisibleText(buffered.visibleText, bufferedTextSignature);
901
+ feedVisibleText(buffered.visibleText, bufferedTextSignature);
852
902
  }
853
903
  bufferedTextSignature = undefined;
854
904
  isBuffering = false;
855
905
  textBuffer = "";
856
906
  }
857
907
 
858
- if (currentBlock) {
859
- pushBlockEndEvent(currentBlock, blockIndex(), output, stream);
860
- }
908
+ flushVisibleText(bufferedTextSignature);
909
+ endCurrentBlock();
861
910
 
862
911
  return hasMeaningfulGoogleContent(output) || sawLeak;
863
912
  };