@bubblebrain-ai/bubble 0.0.32 → 0.0.34

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/dist/agent.js CHANGED
@@ -2555,13 +2555,11 @@ function estimateResidentChars(messages) {
2555
2555
  return total;
2556
2556
  }
2557
2557
  function appendProviderContentBlock(message, provider, block) {
2558
- if (provider !== "anthropic")
2559
- return;
2560
- const current = message.providerMetadata?.anthropic?.contentBlocks ?? [];
2558
+ const current = message.providerMetadata?.[provider]?.contentBlocks ?? [];
2561
2559
  message.providerMetadata = {
2562
2560
  ...message.providerMetadata,
2563
- anthropic: {
2564
- ...message.providerMetadata?.anthropic,
2561
+ [provider]: {
2562
+ ...message.providerMetadata?.[provider],
2565
2563
  contentBlocks: [...current, cloneProviderRawContentBlock(block)],
2566
2564
  },
2567
2565
  };
@@ -12,6 +12,8 @@ const OVERFLOW_PATTERNS = [
12
12
  /prompt is too long/i,
13
13
  /maximum context length/i,
14
14
  /too many tokens/i,
15
+ // Gemini: "The input token count (N) exceeds the maximum number of tokens allowed (M)."
16
+ /input token count.*exceeds the maximum/i,
15
17
  ];
16
18
  export function isContextOverflowError(error) {
17
19
  if (!error)
@@ -1,5 +1,5 @@
1
1
  import type { ReasoningEffort } from "./types.js";
2
- export type ProviderProtocol = "openai-chat" | "anthropic-messages" | "ark-responses";
2
+ export type ProviderProtocol = "openai-chat" | "anthropic-messages" | "ark-responses" | "ai-sdk";
3
3
  export interface BuiltinProviderDefinition {
4
4
  id: string;
5
5
  name: string;
@@ -4,7 +4,10 @@ export const BUILTIN_PROVIDERS = [
4
4
  { id: "openai-codex", name: "OpenAI Codex (ChatGPT)", baseURL: "https://chatgpt.com/backend-api" },
5
5
  { id: "anthropic", name: "Anthropic", baseURL: "https://api.anthropic.com", protocol: "anthropic-messages" },
6
6
  { id: "deepseek", name: "DeepSeek", baseURL: "https://api.deepseek.com" },
7
- { id: "google", name: "Google", baseURL: "https://generativelanguage.googleapis.com/v1beta/openai" },
7
+ // Native Gemini API via the AI SDK google provider. Users who configured the
8
+ // old OpenAI-compat endpoint can keep it by setting protocol "openai-chat"
9
+ // and the /openai baseURL explicitly in models.json.
10
+ { id: "google", name: "Google Gemini", baseURL: "https://generativelanguage.googleapis.com/v1beta", protocol: "ai-sdk" },
8
11
  { id: "zhipuai", name: "Zhipu AI", baseURL: "https://open.bigmodel.cn/api/paas/v4" },
9
12
  { id: "zhipuai-coding-plan", name: "Zhipu AI Coding Plan", baseURL: "https://open.bigmodel.cn/api/coding/paas/v4" },
10
13
  { id: "zai", name: "Z.AI", baseURL: "https://api.z.ai/api/paas/v4" },
@@ -51,6 +54,10 @@ const ANTHROPIC_OPUS_EFFORT_LEVELS = ["off", "low", "medium", "high", "xhigh", "
51
54
  const ANTHROPIC_SONNET_EFFORT_LEVELS = ["off", "low", "medium", "high", "max"];
52
55
  const ANTHROPIC_FABLE_EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"];
53
56
  const ANTHROPIC_CHAT_LEVELS = ["off"];
57
+ const GEMINI_3_LEVELS = ["low", "medium", "high"];
58
+ const GEMINI_3_FLASH_LEVELS = ["minimal", "low", "medium", "high"];
59
+ const GEMINI_25_PRO_LEVELS = ["low", "medium", "high"];
60
+ const GEMINI_25_FLASH_LEVELS = ["off", "low", "medium", "high"];
54
61
  export const BUILTIN_MODELS = [
55
62
  { id: "gpt-5.5", name: "gpt-5.5", providerId: "openai-codex", reasoningLevels: ALL_OPENAI_LEVELS, contextWindow: 272000, toolOutputTokenLimit: 10000 },
56
63
  { id: "gpt-5.4", name: "gpt-5.4", providerId: "openai-codex", reasoningLevels: ALL_OPENAI_LEVELS, contextWindow: 272000 },
@@ -73,9 +80,15 @@ export const BUILTIN_MODELS = [
73
80
  { id: "claude-haiku-4-5-20251001", name: "Claude Haiku 4.5", providerId: "anthropic", reasoningLevels: ANTHROPIC_CHAT_LEVELS, contextWindow: 200000 },
74
81
  { id: "deepseek-v4-flash", name: "deepseek-v4-flash", providerId: "deepseek", reasoningLevels: DEEPSEEK_V4_LEVELS, contextWindow: 1048576 },
75
82
  { id: "deepseek-v4-pro", name: "deepseek-v4-pro", providerId: "deepseek", reasoningLevels: DEEPSEEK_V4_LEVELS, contextWindow: 1048576 },
76
- { id: "gemini-2.5-pro-preview-03-25", name: "gemini-2.5-pro-preview-03-25", providerId: "google", reasoningLevels: ["off", "low", "high"], contextWindow: 128000 },
77
- { id: "gemini-2.0-flash-001", name: "gemini-2.0-flash-001", providerId: "google", reasoningLevels: ["off"], contextWindow: 128000 },
78
- { id: "gemini-1.5-pro-latest", name: "gemini-1.5-pro-latest", providerId: "google", reasoningLevels: ["off"], contextWindow: 128000 },
83
+ // Offline/no-key fallback only: with an API key the registry replaces this
84
+ // list via fetchGeminiModels (GET /v1beta/models, newest five). Gemini 3
85
+ // exposes thinking_level (minimal/low/medium/high); 2.5 Pro cannot disable
86
+ // thinking (no "off"), 2.5 Flash can (thinkingBudget 0).
87
+ { id: "gemini-3.5-flash", name: "Gemini 3.5 Flash", providerId: "google", reasoningLevels: GEMINI_3_FLASH_LEVELS, contextWindow: 1048576 },
88
+ { id: "gemini-3.1-pro-preview", name: "Gemini 3.1 Pro", providerId: "google", reasoningLevels: GEMINI_3_LEVELS, defaultReasoningLevel: "high", contextWindow: 1048576 },
89
+ { id: "gemini-3-flash-preview", name: "Gemini 3 Flash", providerId: "google", reasoningLevels: GEMINI_3_FLASH_LEVELS, contextWindow: 1048576 },
90
+ { id: "gemini-2.5-pro", name: "Gemini 2.5 Pro", providerId: "google", reasoningLevels: GEMINI_25_PRO_LEVELS, defaultReasoningLevel: "high", contextWindow: 1048576 },
91
+ { id: "gemini-2.5-flash", name: "Gemini 2.5 Flash", providerId: "google", reasoningLevels: GEMINI_25_FLASH_LEVELS, contextWindow: 1048576 },
79
92
  { id: "glm-5.2", name: "GLM-5.2", providerId: "zhipuai", reasoningLevels: GLM_5_2_LEVELS, contextWindow: 1000000 },
80
93
  { id: "glm-5.1", name: "GLM-5.1", providerId: "zhipuai", reasoningLevels: TOGGLE_THINKING_LEVELS, contextWindow: 200000 },
81
94
  { id: "glm-4.7", name: "GLM-4.7", providerId: "zhipuai", reasoningLevels: TOGGLE_THINKING_LEVELS, contextWindow: 204800 },
@@ -0,0 +1,56 @@
1
+ /**
2
+ * AI SDK provider backend ("ai-sdk" protocol).
3
+ *
4
+ * Consumes AI SDK provider packages at the LanguageModelV3 spec layer
5
+ * (`model.doStream`) rather than through the high-level `streamText` API:
6
+ * pre-stream HTTP errors throw natively (which our retry/rate-limit contract
7
+ * needs), tool-call inputs arrive as JSON strings (matching `argumentsFull`),
8
+ * and no step machinery or extra dependencies come along.
9
+ *
10
+ * Registered providers: google (Gemini native API). Adding another AI SDK
11
+ * provider is one entry in AI_SDK_PROVIDER_FACTORIES.
12
+ */
13
+ import { type ProviderFetch } from "./network/provider-transport.js";
14
+ import type { Provider, ReasoningEffort, ThinkingLevel } from "./types.js";
15
+ export interface AiSdkProviderOptions {
16
+ providerId?: string;
17
+ apiKey: string;
18
+ baseURL?: string;
19
+ thinkingLevel?: ThinkingLevel;
20
+ /** Transport override for tests; defaults to the shared provider fetch. */
21
+ fetch?: ProviderFetch;
22
+ }
23
+ export declare function isAiSdkProviderId(providerId: string | undefined): boolean;
24
+ export declare function createAiSdkProvider(options: AiSdkProviderOptions): Provider;
25
+ export interface GeminiModelDescriptor {
26
+ id: string;
27
+ name: string;
28
+ contextWindow?: number;
29
+ reasoningLevels: ReasoningEffort[];
30
+ defaultReasoningLevel?: ReasoningEffort;
31
+ }
32
+ interface GeminiModelListEntry {
33
+ name?: string;
34
+ displayName?: string;
35
+ inputTokenLimit?: number;
36
+ supportedGenerationMethods?: string[];
37
+ }
38
+ /**
39
+ * Fetch Google's model list and keep the newest general-purpose text models.
40
+ * Newly released Gemini versions appear without a catalog change; the static
41
+ * BUILTIN_MODELS entries stay as the offline/no-key fallback.
42
+ */
43
+ export declare function fetchGeminiModels(options: {
44
+ apiKey: string;
45
+ baseURL?: string;
46
+ fetch?: ProviderFetch;
47
+ limit?: number;
48
+ }): Promise<GeminiModelDescriptor[]>;
49
+ /**
50
+ * Ranking: one entry per (version, tier) family — GA ids beat dated previews —
51
+ * then newest version first, pro before flash before flash-lite, top N.
52
+ */
53
+ export declare function selectLatestGeminiModels(entries: GeminiModelListEntry[], limit?: number): GeminiModelDescriptor[];
54
+ /** Mirrors the static catalog's per-family thinking support. */
55
+ export declare function geminiReasoningLevels(modelId: string): ReasoningEffort[];
56
+ export {};
@@ -0,0 +1,518 @@
1
+ /**
2
+ * AI SDK provider backend ("ai-sdk" protocol).
3
+ *
4
+ * Consumes AI SDK provider packages at the LanguageModelV3 spec layer
5
+ * (`model.doStream`) rather than through the high-level `streamText` API:
6
+ * pre-stream HTTP errors throw natively (which our retry/rate-limit contract
7
+ * needs), tool-call inputs arrive as JSON strings (matching `argumentsFull`),
8
+ * and no step machinery or extra dependencies come along.
9
+ *
10
+ * Registered providers: google (Gemini native API). Adding another AI SDK
11
+ * provider is one entry in AI_SDK_PROVIDER_FACTORIES.
12
+ */
13
+ import { createGoogleGenerativeAI } from "@ai-sdk/google";
14
+ import { APICallError } from "@ai-sdk/provider";
15
+ import { RateLimitError } from "./network/errors.js";
16
+ import { ProviderStreamInterruptedError, computeRetryDelayMs, getProviderMaxRetries, isRetryableHttpStatus, sleepBeforeRetry, } from "./network/retry.js";
17
+ import { createProviderFetch, isProviderTransportError } from "./network/provider-transport.js";
18
+ const GEMINI_DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1beta";
19
+ const AI_SDK_PROVIDER_FACTORIES = {
20
+ google: (options) => {
21
+ const provider = createGoogleGenerativeAI({
22
+ apiKey: options.apiKey,
23
+ ...(options.baseURL ? { baseURL: normalizeBaseURL(options.baseURL) } : {}),
24
+ fetch: (options.fetch ?? createProviderFetch({
25
+ providerName: "Google Gemini",
26
+ verboseEnvVar: "BUBBLE_AI_SDK_FETCH_VERBOSE",
27
+ })),
28
+ });
29
+ return (modelId) => provider(modelId);
30
+ },
31
+ };
32
+ export function isAiSdkProviderId(providerId) {
33
+ return !!providerId && providerId in AI_SDK_PROVIDER_FACTORIES;
34
+ }
35
+ export function createAiSdkProvider(options) {
36
+ const providerId = options.providerId || "google";
37
+ const factory = AI_SDK_PROVIDER_FACTORIES[providerId];
38
+ if (!factory) {
39
+ const known = Object.keys(AI_SDK_PROVIDER_FACTORIES).join(", ");
40
+ throw new Error(`Provider "${providerId}" is configured with protocol "ai-sdk" but no AI SDK backend is registered for it (known: ${known}).`);
41
+ }
42
+ const getModel = factory(options);
43
+ async function* streamChat(messages, chatOptions) {
44
+ const model = getModel(chatOptions.model);
45
+ const callOptions = buildCallOptions(messages, chatOptions, options);
46
+ const maxRetries = getProviderMaxRetries();
47
+ for (let attempt = 0;; attempt++) {
48
+ let stream;
49
+ try {
50
+ ({ stream } = await model.doStream(callOptions));
51
+ }
52
+ catch (error) {
53
+ // No stream established: the request is safe to classify and re-issue.
54
+ handlePreStreamError(error, {
55
+ attempt,
56
+ maxRetries,
57
+ rateLimitPolicy: chatOptions.rateLimitPolicy,
58
+ signal: chatOptions.abortSignal,
59
+ });
60
+ await sleepBeforeRetry(computeRetryDelayMs(attempt + 1, { retryAfterMs: retryAfterMsFromError(error) }), chatOptions.abortSignal);
61
+ continue;
62
+ }
63
+ const translator = new StreamTranslator();
64
+ let surfacedContent = false;
65
+ try {
66
+ for await (const part of stream) {
67
+ // The SDK surfaces post-200 failures as error parts and lets the
68
+ // stream "finish" normally; that would look like an empty reply to
69
+ // the agent loop, so convert them back into throws here.
70
+ if (part.type === "error") {
71
+ throw coerceError(part.error);
72
+ }
73
+ for (const chunk of translator.translate(part)) {
74
+ surfacedContent = surfacedContent || isContentChunk(chunk);
75
+ yield chunk;
76
+ }
77
+ }
78
+ yield { type: "done" };
79
+ return;
80
+ }
81
+ catch (error) {
82
+ if (chatOptions.abortSignal?.aborted)
83
+ throw coerceError(error);
84
+ if (surfacedContent) {
85
+ // Partial content already reached the UI — only the agent loop can
86
+ // discard the half-built assistant message and re-issue the request.
87
+ throw new ProviderStreamInterruptedError(`Gemini stream interrupted: ${errorMessage(error)}`, { cause: error });
88
+ }
89
+ if (attempt >= maxRetries || !isRetryableStreamError(error))
90
+ throw coerceError(error);
91
+ await sleepBeforeRetry(computeRetryDelayMs(attempt + 1), chatOptions.abortSignal);
92
+ }
93
+ }
94
+ }
95
+ async function complete(messages, completeOptions) {
96
+ const modelId = completeOptions?.model;
97
+ if (!modelId)
98
+ throw new Error("ai-sdk provider requires an explicit model for complete().");
99
+ const model = getModel(modelId);
100
+ const callOptions = buildCallOptions(messages, {
101
+ model: modelId,
102
+ temperature: completeOptions?.temperature,
103
+ thinkingLevel: completeOptions?.thinkingLevel ?? "off",
104
+ abortSignal: completeOptions?.abortSignal,
105
+ }, options);
106
+ const result = await model.doGenerate(callOptions);
107
+ return result.content
108
+ .filter((part) => part.type === "text")
109
+ .map((part) => part.text)
110
+ .join("");
111
+ }
112
+ return { streamChat, complete };
113
+ }
114
+ // ============================================================================
115
+ // Request building
116
+ // ============================================================================
117
+ function buildCallOptions(messages, chatOptions, providerOptions) {
118
+ const prompt = convertMessages(messages);
119
+ const thinking = buildGoogleThinkingOptions(chatOptions.model, chatOptions.thinkingLevel ?? providerOptions.thinkingLevel);
120
+ return {
121
+ prompt,
122
+ ...(chatOptions.temperature !== undefined ? { temperature: chatOptions.temperature } : {}),
123
+ ...(chatOptions.abortSignal ? { abortSignal: chatOptions.abortSignal } : {}),
124
+ ...(chatOptions.tools?.length
125
+ ? {
126
+ tools: chatOptions.tools.map((tool) => ({
127
+ type: "function",
128
+ name: tool.name,
129
+ description: tool.description,
130
+ inputSchema: tool.parameters,
131
+ })),
132
+ toolChoice: { type: chatOptions.toolChoice === "none" ? "none" : "auto" },
133
+ }
134
+ : {}),
135
+ ...(thinking ? { providerOptions: { google: thinking } } : {}),
136
+ };
137
+ }
138
+ /**
139
+ * Gemini 3 models take a graded thinking_level; 2.5-era models take a token
140
+ * budget. "off" (budget 0) is only offered in the catalog for models that
141
+ * accept it (2.5 Flash); xhigh/max clamp to high.
142
+ */
143
+ function buildGoogleThinkingOptions(modelId, level) {
144
+ if (!level)
145
+ return undefined;
146
+ if (level === "off")
147
+ return { thinkingConfig: { thinkingBudget: 0 } };
148
+ const clamped = level === "xhigh" || level === "max" ? "high" : level;
149
+ if (modelId.includes("gemini-3")) {
150
+ return { thinkingConfig: { thinkingLevel: clamped, includeThoughts: true } };
151
+ }
152
+ const budgets = { minimal: 512, low: 2048, medium: 8192, high: 24576 };
153
+ return { thinkingConfig: { thinkingBudget: budgets[clamped] ?? 8192, includeThoughts: true } };
154
+ }
155
+ function convertMessages(messages) {
156
+ const out = [];
157
+ const toolNamesById = new Map();
158
+ for (const message of messages) {
159
+ if (message.role === "system") {
160
+ out.push({ role: "system", content: message.content });
161
+ continue;
162
+ }
163
+ if (message.role === "user") {
164
+ out.push({ role: "user", content: convertUserContent(message.content) });
165
+ continue;
166
+ }
167
+ if (message.role === "assistant") {
168
+ for (const call of message.toolCalls ?? [])
169
+ toolNamesById.set(call.id, call.name);
170
+ const content = convertAssistantContent(message);
171
+ if (content.length > 0)
172
+ out.push({ role: "assistant", content });
173
+ continue;
174
+ }
175
+ // tool result
176
+ out.push({
177
+ role: "tool",
178
+ content: [{
179
+ type: "tool-result",
180
+ toolCallId: message.toolCallId,
181
+ toolName: toolNamesById.get(message.toolCallId) ?? "unknown_tool",
182
+ output: toToolResultOutput(message.content, message.isError),
183
+ }],
184
+ });
185
+ }
186
+ return out;
187
+ }
188
+ function convertUserContent(content) {
189
+ if (typeof content === "string")
190
+ return [{ type: "text", text: content }];
191
+ const parts = [];
192
+ for (const part of content) {
193
+ if (part.type === "text" && typeof part.text === "string") {
194
+ parts.push({ type: "text", text: part.text });
195
+ }
196
+ else if (part.type === "image_url" && part.image_url?.url) {
197
+ parts.push(toImageFilePart(part.image_url.url));
198
+ }
199
+ }
200
+ return parts.length > 0 ? parts : [{ type: "text", text: "" }];
201
+ }
202
+ function toImageFilePart(url) {
203
+ const dataUrl = /^data:([^;,]+)?(;base64)?,(.*)$/s.exec(url);
204
+ if (dataUrl) {
205
+ return {
206
+ type: "file",
207
+ mediaType: dataUrl[1] || "image/png",
208
+ data: dataUrl[2] ? dataUrl[3] : decodeURIComponent(dataUrl[3]),
209
+ };
210
+ }
211
+ const extension = /\.(png|jpe?g|gif|webp)(?:[?#]|$)/i.exec(url)?.[1]?.toLowerCase();
212
+ const mediaType = extension === "jpg" || extension === "jpeg"
213
+ ? "image/jpeg"
214
+ : extension
215
+ ? `image/${extension}`
216
+ : "image/png";
217
+ return { type: "file", mediaType, data: new URL(url) };
218
+ }
219
+ function convertAssistantContent(message) {
220
+ const parts = [];
221
+ const blocks = message.providerMetadata?.google?.contentBlocks ?? [];
222
+ const toolCallSignatures = new Map();
223
+ // Replay captured Gemini parts: signed reasoning comes back verbatim so the
224
+ // thought signature round-trips; tool-call signatures re-attach by id.
225
+ for (const block of blocks) {
226
+ if (block.type === "reasoning" && typeof block.text === "string" && typeof block.thoughtSignature === "string") {
227
+ parts.push({
228
+ type: "reasoning",
229
+ text: block.text,
230
+ providerOptions: { google: { thoughtSignature: block.thoughtSignature } },
231
+ });
232
+ }
233
+ else if (block.type === "tool-call" && typeof block.toolCallId === "string" && typeof block.thoughtSignature === "string") {
234
+ toolCallSignatures.set(block.toolCallId, block.thoughtSignature);
235
+ }
236
+ }
237
+ if (message.content)
238
+ parts.push({ type: "text", text: message.content });
239
+ for (const call of message.toolCalls ?? []) {
240
+ const signature = toolCallSignatures.get(call.id);
241
+ parts.push({
242
+ type: "tool-call",
243
+ toolCallId: call.id,
244
+ toolName: call.name,
245
+ input: parseToolArguments(call.arguments),
246
+ ...(signature ? { providerOptions: { google: { thoughtSignature: signature } } } : {}),
247
+ });
248
+ }
249
+ return parts;
250
+ }
251
+ function parseToolArguments(raw) {
252
+ if (!raw)
253
+ return {};
254
+ try {
255
+ const parsed = JSON.parse(raw);
256
+ return parsed && typeof parsed === "object" ? parsed : {};
257
+ }
258
+ catch {
259
+ return {};
260
+ }
261
+ }
262
+ function toToolResultOutput(content, isError) {
263
+ return isError ? { type: "error-text", value: content } : { type: "text", value: content };
264
+ }
265
+ function normalizeBaseURL(baseURL) {
266
+ return baseURL.trim().replace(/\/+$/, "");
267
+ }
268
+ // ============================================================================
269
+ // Stream translation
270
+ // ============================================================================
271
+ class StreamTranslator {
272
+ reasoningText = "";
273
+ reasoningSignature;
274
+ toolNamesById = new Map();
275
+ translate(part) {
276
+ switch (part.type) {
277
+ case "text-delta":
278
+ return part.delta ? [{ type: "text", content: part.delta }] : [];
279
+ case "reasoning-start":
280
+ this.reasoningText = "";
281
+ this.reasoningSignature = thoughtSignatureOf(part.providerMetadata);
282
+ return [];
283
+ case "reasoning-delta": {
284
+ this.reasoningText += part.delta;
285
+ this.reasoningSignature ??= thoughtSignatureOf(part.providerMetadata);
286
+ return part.delta ? [{ type: "reasoning_delta", content: part.delta }] : [];
287
+ }
288
+ case "reasoning-end": {
289
+ this.reasoningSignature ??= thoughtSignatureOf(part.providerMetadata);
290
+ if (!this.reasoningSignature || !this.reasoningText)
291
+ return [];
292
+ const block = {
293
+ type: "reasoning",
294
+ text: this.reasoningText,
295
+ thoughtSignature: this.reasoningSignature,
296
+ };
297
+ this.reasoningText = "";
298
+ this.reasoningSignature = undefined;
299
+ return [{ type: "provider_content_block", provider: "google", block }];
300
+ }
301
+ case "tool-input-start":
302
+ this.toolNamesById.set(part.id, part.toolName);
303
+ return [{ type: "tool_call", id: part.id, name: part.toolName, arguments: "", isStart: true, isEnd: false }];
304
+ case "tool-input-delta": {
305
+ if (!part.delta)
306
+ return [];
307
+ const name = this.toolNamesById.get(part.id) ?? "";
308
+ return [{ type: "tool_call", id: part.id, name, arguments: part.delta, isStart: false, isEnd: false }];
309
+ }
310
+ case "tool-call": {
311
+ const argumentsFull = typeof part.input === "string" ? part.input : JSON.stringify(part.input ?? {});
312
+ const chunks = [{
313
+ type: "tool_call",
314
+ id: part.toolCallId,
315
+ name: part.toolName,
316
+ arguments: "",
317
+ isStart: false,
318
+ isEnd: true,
319
+ argumentsFull,
320
+ }];
321
+ const signature = thoughtSignatureOf(part.providerMetadata);
322
+ if (signature) {
323
+ chunks.push({
324
+ type: "provider_content_block",
325
+ provider: "google",
326
+ block: { type: "tool-call", toolCallId: part.toolCallId, thoughtSignature: signature },
327
+ });
328
+ }
329
+ return chunks;
330
+ }
331
+ case "finish": {
332
+ const usage = translateUsage(part.usage);
333
+ return usage ? [{ type: "usage", usage }] : [];
334
+ }
335
+ default:
336
+ // stream-start, response-metadata, text-start/end, tool-input-end,
337
+ // source, raw: nothing to surface.
338
+ return [];
339
+ }
340
+ }
341
+ }
342
+ function thoughtSignatureOf(metadata) {
343
+ const signature = metadata?.google?.thoughtSignature;
344
+ return typeof signature === "string" && signature.length > 0 ? signature : undefined;
345
+ }
346
+ function translateUsage(usage) {
347
+ const inputTokens = usage?.inputTokens?.total;
348
+ const outputTokens = usage?.outputTokens?.total;
349
+ // Without a real input count the chunk would poison the agent's context
350
+ // budget tracking (lastInputTokens), so skip rather than report zeros.
351
+ if (!Number.isFinite(inputTokens))
352
+ return undefined;
353
+ const result = {
354
+ promptTokens: inputTokens,
355
+ completionTokens: Number.isFinite(outputTokens) ? outputTokens : 0,
356
+ };
357
+ const cacheRead = usage?.inputTokens?.cacheRead;
358
+ const noCache = usage?.inputTokens?.noCache;
359
+ const cacheWrite = usage?.inputTokens?.cacheWrite;
360
+ const reasoning = usage?.outputTokens?.reasoning;
361
+ if (Number.isFinite(cacheRead))
362
+ result.promptCacheHitTokens = cacheRead;
363
+ if (Number.isFinite(noCache))
364
+ result.promptCacheMissTokens = noCache;
365
+ if (Number.isFinite(cacheWrite))
366
+ result.cacheCreationTokens = cacheWrite;
367
+ if (Number.isFinite(reasoning))
368
+ result.reasoningTokens = reasoning;
369
+ if (Number.isFinite(inputTokens) && Number.isFinite(outputTokens)) {
370
+ result.totalTokens = inputTokens + outputTokens;
371
+ }
372
+ return result;
373
+ }
374
+ function isContentChunk(chunk) {
375
+ return chunk.type === "text" || chunk.type === "reasoning_delta" || chunk.type === "tool_call";
376
+ }
377
+ // ============================================================================
378
+ // Error handling
379
+ // ============================================================================
380
+ /**
381
+ * Classify a pre-stream error. Throws (RateLimitError / the original error)
382
+ * when the request must not be retried here; returns normally when the caller
383
+ * should back off and retry.
384
+ */
385
+ function handlePreStreamError(error, context) {
386
+ if (context.signal?.aborted)
387
+ throw coerceError(error);
388
+ const status = APICallError.isInstance(error) ? error.statusCode : undefined;
389
+ if (status === 429) {
390
+ const retryAfterMs = retryAfterMsFromError(error);
391
+ if (context.rateLimitPolicy === "defer") {
392
+ // Rate-limit contract: under "defer" the transport does no 429 backoff;
393
+ // the subagent scheduler owns it.
394
+ throw new RateLimitError(`Gemini API rate limited (429): ${errorMessage(error)}`, {
395
+ status: 429,
396
+ retryAfterMs,
397
+ cause: error,
398
+ });
399
+ }
400
+ if (context.attempt >= context.maxRetries) {
401
+ throw new RateLimitError(`Gemini API rate limited (429) after ${context.attempt + 1} attempts: ${errorMessage(error)}`, { status: 429, retryAfterMs, cause: error });
402
+ }
403
+ return;
404
+ }
405
+ const retryable = APICallError.isInstance(error)
406
+ ? (error.isRetryable || (typeof status === "number" && isRetryableHttpStatus(status)))
407
+ : isProviderTransportError(error);
408
+ if (!retryable || context.attempt >= context.maxRetries)
409
+ throw coerceError(error);
410
+ }
411
+ function retryAfterMsFromError(error) {
412
+ if (!APICallError.isInstance(error))
413
+ return undefined;
414
+ const header = error.responseHeaders?.["retry-after"]?.trim();
415
+ if (!header)
416
+ return undefined;
417
+ const seconds = Number(header);
418
+ if (Number.isFinite(seconds) && seconds >= 0)
419
+ return Math.round(seconds * 1000);
420
+ const date = Date.parse(header);
421
+ if (!Number.isNaN(date))
422
+ return Math.max(0, date - Date.now());
423
+ return undefined;
424
+ }
425
+ function isRetryableStreamError(error) {
426
+ if (APICallError.isInstance(error)) {
427
+ return error.isRetryable || (typeof error.statusCode === "number" && isRetryableHttpStatus(error.statusCode));
428
+ }
429
+ return isProviderTransportError(error);
430
+ }
431
+ function coerceError(error) {
432
+ return error instanceof Error ? error : new Error(String(error));
433
+ }
434
+ function errorMessage(error) {
435
+ return error instanceof Error ? error.message : String(error);
436
+ }
437
+ // Specialised variants that are not general text/coding models.
438
+ const GEMINI_MODEL_EXCLUDE = /(tts|image|audio|embedding|live|computer-use|robotics|banana|aqa|learnlm|gemma|imagen|veo)/i;
439
+ /**
440
+ * Fetch Google's model list and keep the newest general-purpose text models.
441
+ * Newly released Gemini versions appear without a catalog change; the static
442
+ * BUILTIN_MODELS entries stay as the offline/no-key fallback.
443
+ */
444
+ export async function fetchGeminiModels(options) {
445
+ const baseURL = normalizeBaseURL(options.baseURL || GEMINI_DEFAULT_BASE_URL);
446
+ const fetchImpl = options.fetch ?? createProviderFetch({
447
+ providerName: "Google Gemini",
448
+ verboseEnvVar: "BUBBLE_AI_SDK_FETCH_VERBOSE",
449
+ });
450
+ const response = await fetchImpl(`${baseURL}/models?pageSize=1000`, {
451
+ headers: { "x-goog-api-key": options.apiKey },
452
+ });
453
+ if (!response.ok) {
454
+ throw new Error(`Gemini model list failed (${response.status}): ${await response.text().catch(() => response.statusText)}`);
455
+ }
456
+ const data = await response.json();
457
+ return selectLatestGeminiModels(data.models ?? [], options.limit ?? 5);
458
+ }
459
+ /**
460
+ * Ranking: one entry per (version, tier) family — GA ids beat dated previews —
461
+ * then newest version first, pro before flash before flash-lite, top N.
462
+ */
463
+ export function selectLatestGeminiModels(entries, limit = 5) {
464
+ const TIER_RANK = { pro: 0, flash: 1, "flash-lite": 2 };
465
+ const candidates = [];
466
+ for (const entry of entries) {
467
+ const id = (entry.name ?? "").replace(/^models\//, "");
468
+ if (!id.startsWith("gemini-"))
469
+ continue;
470
+ if (GEMINI_MODEL_EXCLUDE.test(id))
471
+ continue;
472
+ if (entry.supportedGenerationMethods && !entry.supportedGenerationMethods.includes("generateContent"))
473
+ continue;
474
+ const match = /^gemini-(\d+(?:\.\d+)?)-(pro|flash)(-lite)?/.exec(id);
475
+ if (!match)
476
+ continue;
477
+ candidates.push({
478
+ id,
479
+ entry,
480
+ version: Number.parseFloat(match[1]),
481
+ tierRank: TIER_RANK[`${match[2]}${match[3] ?? ""}`] ?? 3,
482
+ isPreview: id.includes("preview") || id.includes("exp"),
483
+ });
484
+ }
485
+ // One winner per family: GA over preview, then the shortest (least-suffixed) id.
486
+ const families = new Map();
487
+ for (const candidate of candidates) {
488
+ const key = `${candidate.version}-${candidate.tierRank}`;
489
+ const current = families.get(key);
490
+ if (!current
491
+ || (current.isPreview && !candidate.isPreview)
492
+ || (current.isPreview === candidate.isPreview && candidate.id.length < current.id.length)) {
493
+ families.set(key, candidate);
494
+ }
495
+ }
496
+ return [...families.values()]
497
+ .sort((a, b) => (b.version - a.version) || (a.tierRank - b.tierRank))
498
+ .slice(0, limit)
499
+ .map((candidate) => ({
500
+ id: candidate.id,
501
+ name: candidate.entry.displayName || candidate.id,
502
+ contextWindow: Number.isFinite(candidate.entry.inputTokenLimit) ? candidate.entry.inputTokenLimit : undefined,
503
+ reasoningLevels: geminiReasoningLevels(candidate.id),
504
+ ...(candidate.tierRank === 0 ? { defaultReasoningLevel: "high" } : {}),
505
+ }));
506
+ }
507
+ /** Mirrors the static catalog's per-family thinking support. */
508
+ export function geminiReasoningLevels(modelId) {
509
+ const version = Number.parseFloat(/^gemini-(\d+(?:\.\d+)?)/.exec(modelId)?.[1] ?? "0");
510
+ const isPro = modelId.includes("-pro");
511
+ if (version >= 3) {
512
+ return isPro ? ["low", "medium", "high"] : ["minimal", "low", "medium", "high"];
513
+ }
514
+ if (version >= 2.5) {
515
+ return isPro ? ["low", "medium", "high"] : ["off", "low", "medium", "high"];
516
+ }
517
+ return ["off"];
518
+ }
@@ -7,6 +7,7 @@
7
7
  import { BUILTIN_PROVIDERS as CATALOG_PROVIDERS, getBuiltinModel, getBuiltinProvider, listBuiltinModels, registerDynamicModelMetadata, } from "./model-catalog.js";
8
8
  import { ModelConfig } from "./model-config.js";
9
9
  import { AuthStorage } from "./oauth/index.js";
10
+ import { fetchGeminiModels } from "./provider-ai-sdk.js";
10
11
  import { fetchOpenAICodexModels } from "./provider-openai-codex.js";
11
12
  import { refreshOpenAICodex } from "./oauth/openai-codex.js";
12
13
  export const BUILTIN_PROVIDERS = CATALOG_PROVIDERS;
@@ -123,7 +124,7 @@ export class ProviderRegistry {
123
124
  providers = keys.map((id) => {
124
125
  const builtin = getBuiltinProvider(id);
125
126
  const cfg = modelsJsonProviders[id];
126
- const baseURL = cfg.baseURL || builtin?.baseURL || "";
127
+ const baseURL = upgradeLegacyBaseURL(id, cfg.baseURL || builtin?.baseURL || "", cfg.protocol);
127
128
  return {
128
129
  id,
129
130
  name: builtin?.name || id,
@@ -138,10 +139,11 @@ export class ProviderRegistry {
138
139
  else {
139
140
  // 2. Fall back to config.json providers (interactive TUI style)
140
141
  providers = this.config.getProviders().map((provider) => {
141
- const builtin = getBuiltinProvider(provider.id);
142
+ const baseURL = upgradeLegacyBaseURL(provider.id, provider.baseURL, provider.protocol);
142
143
  return {
143
144
  ...provider,
144
- protocol: resolveConfiguredProtocol(provider.id, provider.baseURL, provider.protocol),
145
+ baseURL,
146
+ protocol: resolveConfiguredProtocol(provider.id, baseURL, provider.protocol),
145
147
  };
146
148
  });
147
149
  }
@@ -247,6 +249,31 @@ export class ProviderRegistry {
247
249
  // fall through to static
248
250
  }
249
251
  }
252
+ if (provider.id === "google" && provider.protocol === "ai-sdk" && provider.apiKey) {
253
+ try {
254
+ const descriptors = await fetchGeminiModels({
255
+ apiKey: provider.apiKey,
256
+ baseURL: provider.baseURL,
257
+ });
258
+ if (descriptors.length > 0) {
259
+ for (const d of descriptors) {
260
+ const catalogEntry = getBuiltinModel("google", d.id);
261
+ registerDynamicModelMetadata({
262
+ id: d.id,
263
+ name: d.name,
264
+ providerId: "google",
265
+ reasoningLevels: d.reasoningLevels,
266
+ defaultReasoningLevel: d.defaultReasoningLevel ?? catalogEntry?.defaultReasoningLevel,
267
+ contextWindow: d.contextWindow ?? catalogEntry?.contextWindow,
268
+ });
269
+ }
270
+ return descriptors.map((d) => ({ id: d.id, name: d.name, providerId: provider.id }));
271
+ }
272
+ }
273
+ catch {
274
+ // fall through to static
275
+ }
276
+ }
250
277
  if (provider.id === "openai" && provider.authType === "oauth" && provider.apiKey) {
251
278
  try {
252
279
  await this.prepareProvider(provider.id);
@@ -291,6 +318,25 @@ export class ProviderRegistry {
291
318
  }));
292
319
  }
293
320
  }
321
+ /**
322
+ * Builtin defaults that were captured into stored profiles before a builtin's
323
+ * baseURL moved. Exactly these values are treated as "not customized" and
324
+ * follow the builtin to its new address (and thereby its new protocol);
325
+ * genuinely custom URLs and profiles with an explicit protocol are untouched.
326
+ */
327
+ const LEGACY_BUILTIN_BASE_URLS = {
328
+ // google moved from the Gemini OpenAI-compat endpoint to the native API
329
+ // when the "ai-sdk" protocol landed.
330
+ google: "https://generativelanguage.googleapis.com/v1beta/openai",
331
+ };
332
+ function upgradeLegacyBaseURL(providerId, baseURL, explicitProtocol) {
333
+ if (explicitProtocol)
334
+ return baseURL;
335
+ const legacy = LEGACY_BUILTIN_BASE_URLS[providerId];
336
+ if (!legacy || normalizeBaseURL(baseURL) !== normalizeBaseURL(legacy))
337
+ return baseURL;
338
+ return getBuiltinProvider(providerId)?.baseURL ?? baseURL;
339
+ }
294
340
  function resolveConfiguredProtocol(providerId, baseURL, explicitProtocol) {
295
341
  if (explicitProtocol)
296
342
  return explicitProtocol;
package/dist/provider.js CHANGED
@@ -7,6 +7,7 @@ import OpenAI from "openai";
7
7
  import { appendFileSync } from "node:fs";
8
8
  import { createAnthropicMessagesProvider } from "./provider-anthropic.js";
9
9
  import { createArkResponsesProvider } from "./provider-ark-responses.js";
10
+ import { createAiSdkProvider } from "./provider-ai-sdk.js";
10
11
  import { createOpenAICodexProvider, isOpenAICodexBaseUrl } from "./provider-openai-codex.js";
11
12
  import { createProviderProtocolArtifactFilter } from "./provider-artifacts.js";
12
13
  import { resolveProviderRequestConfig } from "./provider-transform.js";
@@ -84,6 +85,9 @@ export function createProviderInstance(options) {
84
85
  if (protocol === "ark-responses") {
85
86
  return createArkResponsesProvider(options);
86
87
  }
88
+ if (protocol === "ai-sdk") {
89
+ return createAiSdkProvider(options);
90
+ }
87
91
  if (isOpenAICodexBaseUrl(options.baseURL)) {
88
92
  return createOpenAICodexProvider({
89
93
  ...options,
@@ -329,19 +329,15 @@ const builtinSlashCommandEntries = [
329
329
  },
330
330
  {
331
331
  name: "theme",
332
- description: "Switch the color theme. Usage: /theme [auto|light|dark]",
332
+ description: "Pick the color theme. Usage: /theme [auto|light|dark]",
333
333
  async handler(args, ctx) {
334
334
  if (!ctx.setThemeMode || !ctx.getThemeMode || !ctx.getResolvedTheme) {
335
335
  return "Theme switching is only available inside the TUI.";
336
336
  }
337
337
  const arg = args.trim().toLowerCase();
338
338
  if (!arg) {
339
- const order = ["auto", "light", "dark"];
340
- const current = ctx.getThemeMode();
341
- const next = order[(order.indexOf(current) + 1) % order.length];
342
- ctx.setThemeMode(next);
343
- const resolved = next === "auto" ? ctx.getResolvedTheme() : next;
344
- return `Theme: ${next}${next === "auto" ? ` (resolved to ${resolved})` : ""}`;
339
+ ctx.openPicker("theme");
340
+ return;
345
341
  }
346
342
  if (arg !== "auto" && arg !== "light" && arg !== "dark") {
347
343
  return "Usage: /theme [auto|light|dark]";
@@ -28,7 +28,7 @@ export interface SlashCommandContext {
28
28
  exit: () => void;
29
29
  sessionManager?: SessionManager;
30
30
  createProvider: (providerId: string, apiKey: string, baseURL: string) => Provider;
31
- openPicker: (mode: "model" | "key" | "provider" | "provider-add" | "login" | "logout" | "skill" | "feishu-setup", providerId?: string) => void;
31
+ openPicker: (mode: "model" | "key" | "provider" | "provider-add" | "login" | "logout" | "skill" | "theme" | "feishu-setup", providerId?: string) => void;
32
32
  registry: ProviderRegistry;
33
33
  skillRegistry: SkillRegistry;
34
34
  bashAllowlist?: BashAllowlist;
@@ -72,6 +72,12 @@ function friendlyCwd(cwd) {
72
72
  return "~" + cwd.slice(home.length);
73
73
  return cwd;
74
74
  }
75
+ function sessionBasename(sessionFile) {
76
+ if (!sessionFile)
77
+ return undefined;
78
+ const base = sessionFile.split("/").pop() ?? sessionFile;
79
+ return base.replace(/\.jsonl$/, "");
80
+ }
75
81
  function truncate(value, max) {
76
82
  if (value.length <= max)
77
83
  return value;
@@ -248,6 +254,11 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
248
254
  setThemeMode(mode);
249
255
  onThemeModeChange?.(mode);
250
256
  }, [onThemeModeChange]);
257
+ // Theme mode at the moment the /theme picker opened, so Esc can restore it
258
+ // after live-previewing other themes while navigating the picker.
259
+ const themeModeRef = useRef(themeMode);
260
+ themeModeRef.current = themeMode;
261
+ const themePickerRevertRef = useRef("auto");
251
262
  const themeResolved = themeMode === "auto" ? autoResolved : themeMode;
252
263
  const { exit } = useApp();
253
264
  const [messages, setMessages] = useState(() => compactDisplayMessages(reconstructDisplayMessages(agent.messages)));
@@ -712,6 +723,9 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
712
723
  if (mode === "key") {
713
724
  setKeyProviderId(providerId ?? null);
714
725
  }
726
+ if (mode === "theme") {
727
+ themePickerRevertRef.current = themeModeRef.current;
728
+ }
715
729
  setStatsPanel(null);
716
730
  setPickerMode(mode);
717
731
  }, []);
@@ -830,6 +844,19 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
830
844
  closePicker();
831
845
  });
832
846
  }, [agent, addMessage, closePicker, sessionManager, userConfig, safeRegistry, createProvider]);
847
+ const handleThemeHighlight = useCallback((mode) => {
848
+ setThemeMode(mode);
849
+ }, []);
850
+ const handleThemeSelect = useCallback((mode) => {
851
+ applyThemeMode(mode);
852
+ const resolvedNote = mode === "auto" ? ` (resolved to ${autoResolved})` : "";
853
+ addMessage("assistant", `Theme set to ${mode}${resolvedNote}.`);
854
+ closePicker();
855
+ }, [addMessage, applyThemeMode, autoResolved, closePicker]);
856
+ const handleThemeCancel = useCallback(() => {
857
+ setThemeMode(themePickerRevertRef.current);
858
+ closePicker();
859
+ }, [closePicker]);
833
860
  const handleProviderSelect = useCallback((providerId) => {
834
861
  const run = async () => {
835
862
  await safeRegistry.prepareProvider(providerId);
@@ -1686,7 +1713,7 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
1686
1713
  const showThinkingLabel = Boolean(thinkingLevel)
1687
1714
  && thinkingLevel !== "off"
1688
1715
  && (isMiniMaxProvider || getAvailableThinkingLevels(agent.providerId, agent.apiModel).length > 2);
1689
- const welcomeBannerNode = showWelcome ? (_jsx(WelcomeBanner, { terminalColumns: terminalColumns, tips: buildTips(agent, safeRegistry), updateNotice: currentUpdateNotice, cwd: friendlyCwd(args.cwd), providerId: agent.providerId || safeRegistry.getDefault()?.id, modelLabel: agent.model ? displayModel(agent.model) : undefined, thinkingLabel: showThinkingLabel ? thinkingLevel : undefined })) : null;
1716
+ const welcomeBannerNode = showWelcome ? (_jsx(WelcomeBanner, { terminalColumns: terminalColumns, tips: buildTips(agent, safeRegistry), updateNotice: currentUpdateNotice, cwd: friendlyCwd(args.cwd), sessionLabel: sessionBasename(currentSessionFile()), providerId: agent.providerId || safeRegistry.getDefault()?.id, modelLabel: agent.model ? displayModel(agent.model) : undefined, thinkingLabel: showThinkingLabel ? thinkingLevel : undefined })) : null;
1690
1717
  const commandPaletteItems = useMemo(() => buildCommandPaletteItems(safeSkillRegistry), [safeSkillRegistry]);
1691
1718
  const mcpReconnectItems = useMemo(() => buildMcpReconnectItems(mcpManager), [mcpManager]);
1692
1719
  // No fixed-height frame: settled rows flow into the terminal's native
@@ -1717,7 +1744,11 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
1717
1744
  } }) })), pickerMode === "skill" && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(SkillPicker, { skills: safeSkillRegistry.summaries(), onSelect: (name) => {
1718
1745
  fillComposer(`/${name} `);
1719
1746
  closePicker();
1720
- }, onCancel: closePicker }) })), pickerMode === "slash" && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(CommandPalette, { items: commandPaletteItems, terminalColumns: mainWidth, terminalRows: terminalRows, onSelect: (item) => {
1747
+ }, onCancel: closePicker }) })), pickerMode === "theme" && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(ProviderPicker, { title: "Select Theme", providers: [
1748
+ { id: "auto", name: `Auto — match terminal (${autoResolved})`, enabled: true },
1749
+ { id: "light", name: "Light", enabled: true },
1750
+ { id: "dark", name: "Dark", enabled: true },
1751
+ ], current: themePickerRevertRef.current, onSelect: handleThemeSelect, onHighlight: handleThemeHighlight, onCancel: handleThemeCancel }) })), pickerMode === "slash" && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(CommandPalette, { items: commandPaletteItems, terminalColumns: mainWidth, terminalRows: terminalRows, onSelect: (item) => {
1721
1752
  closePicker();
1722
1753
  if (item.action === "insert-skill") {
1723
1754
  fillComposer(`/${item.value} `);
@@ -65,8 +65,10 @@ export interface ProviderPickerProps {
65
65
  onSelect: (providerId: string) => void;
66
66
  onCancel: () => void;
67
67
  title?: string;
68
+ /** Fires whenever the highlighted row changes (and once on mount) — lets callers live-preview the selection. */
69
+ onHighlight?: (providerId: string) => void;
68
70
  }
69
- export declare function ProviderPicker({ providers, current, onSelect, onCancel, title }: ProviderPickerProps): import("react/jsx-runtime").JSX.Element;
71
+ export declare function ProviderPicker({ providers, current, onSelect, onCancel, title, onHighlight }: ProviderPickerProps): import("react/jsx-runtime").JSX.Element;
70
72
  export interface KeyPickerProps {
71
73
  providerName: string;
72
74
  onSubmit: (key: string) => void;
@@ -448,7 +448,7 @@ function reasoningLevelsForModel(model) {
448
448
  const { providerId, modelId } = decodeModel(model);
449
449
  return getAvailableThinkingLevels(providerId || "openai", modelId);
450
450
  }
451
- export function ProviderPicker({ providers, current, onSelect, onCancel, title }) {
451
+ export function ProviderPicker({ providers, current, onSelect, onCancel, title, onHighlight }) {
452
452
  const theme = useTheme();
453
453
  const { stdout } = useStdout();
454
454
  const termHeight = stdout?.rows || 24;
@@ -457,6 +457,11 @@ export function ProviderPicker({ providers, current, onSelect, onCancel, title }
457
457
  const idx = providers.findIndex((p) => p.id === current);
458
458
  return idx >= 0 ? idx : 0;
459
459
  });
460
+ useEffect(() => {
461
+ const p = providers[selectedIndex];
462
+ if (p)
463
+ onHighlight?.(p.id);
464
+ }, [selectedIndex]);
460
465
  useInput((input, key) => {
461
466
  if (isKeyReleaseEvent(key))
462
467
  return;
@@ -17,6 +17,11 @@ export interface Theme {
17
17
  success: string;
18
18
  background: string;
19
19
  accent: string;
20
+ /** Welcome banner border. */
21
+ bannerBorder: string;
22
+ /** Welcome banner logo/title gradient endpoints (top→bottom, left→right). */
23
+ bannerGradientFrom: string;
24
+ bannerGradientTo: string;
20
25
  border: string;
21
26
  borderActive: string;
22
27
  backgroundPanel: string;
@@ -16,6 +16,9 @@ export const darkTheme = {
16
16
  success: "green",
17
17
  background: "#0A0A0A",
18
18
  accent: "cyan",
19
+ bannerBorder: "#38bdf8",
20
+ bannerGradientFrom: "#67e8f9",
21
+ bannerGradientTo: "#a78bfa",
19
22
  border: "gray",
20
23
  borderActive: "cyan",
21
24
  backgroundPanel: "#141414",
@@ -62,6 +65,9 @@ export const lightTheme = {
62
65
  success: "#2F7D4A",
63
66
  background: "#FCFCFA",
64
67
  accent: "#8B4A00",
68
+ bannerBorder: "#356FD2",
69
+ bannerGradientFrom: "#0E7490",
70
+ bannerGradientTo: "#6D28D9",
65
71
  border: "#B9BDB8",
66
72
  borderActive: "#356FD2",
67
73
  backgroundPanel: "#F6F6F3",
@@ -6,6 +6,8 @@ interface WelcomeBannerProps {
6
6
  updateNotice?: string;
7
7
  /** Friendly working directory (~ collapsed). */
8
8
  cwd?: string;
9
+ /** Session identifier (session file basename). */
10
+ sessionLabel?: string;
9
11
  providerId?: string;
10
12
  modelLabel?: string;
11
13
  /** Active thinking level, rendered as part of the model unit (e.g. "xhigh"). */
@@ -16,6 +18,7 @@ interface WelcomeVisibilityInput {
16
18
  startedWithVisibleHistory: boolean;
17
19
  }
18
20
  export declare function shouldShowWelcomeBanner({ startedWithVisibleHistory, }: WelcomeVisibilityInput): boolean;
19
- export declare function WelcomeBanner({ terminalColumns, tips, updateNotice, cwd, providerId, modelLabel, thinkingLabel, }: WelcomeBannerProps): import("react/jsx-runtime").JSX.Element;
21
+ export declare function lerpColor(from: string, to: string, t: number): string;
22
+ export declare function WelcomeBanner({ terminalColumns, tips, updateNotice, cwd, sessionLabel, providerId, modelLabel, thinkingLabel, }: WelcomeBannerProps): import("react/jsx-runtime").JSX.Element;
20
23
  export declare function formatModelLine({ providerId, modelLabel, thinkingLabel, tips, }: Pick<WelcomeBannerProps, "providerId" | "modelLabel" | "thinkingLabel" | "tips">): string;
21
24
  export {};
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
3
  import { createRequire } from "node:module";
4
4
  import { useTheme } from "./theme.js";
@@ -18,7 +18,17 @@ export function shouldShowWelcomeBanner({ startedWithVisibleHistory, }) {
18
18
  return false;
19
19
  return true;
20
20
  }
21
- export function WelcomeBanner({ terminalColumns, tips, updateNotice, cwd, providerId, modelLabel, thinkingLabel, }) {
21
+ export function lerpColor(from, to, t) {
22
+ const pa = [1, 3, 5].map((i) => parseInt(from.slice(i, i + 2), 16));
23
+ const pb = [1, 3, 5].map((i) => parseInt(to.slice(i, i + 2), 16));
24
+ const out = pa.map((v, i) => Math.round(v + ((pb[i] ?? v) - v) * t));
25
+ return `#${out.map((v) => v.toString(16).padStart(2, "0")).join("")}`;
26
+ }
27
+ function GradientText({ text, from, to }) {
28
+ const chars = [...text];
29
+ return (_jsx(_Fragment, { children: chars.map((ch, i) => (_jsx(Text, { bold: true, color: lerpColor(from, to, chars.length <= 1 ? 0 : i / (chars.length - 1)), children: ch }, `ch-${i}`))) }));
30
+ }
31
+ export function WelcomeBanner({ terminalColumns, tips, updateNotice, cwd, sessionLabel, providerId, modelLabel, thinkingLabel, }) {
22
32
  const theme = useTheme();
23
33
  const effectiveWidth = Math.max(24, Math.min(terminalColumns - 2, 96));
24
34
  const modelLine = formatModelLine({
@@ -27,7 +37,16 @@ export function WelcomeBanner({ terminalColumns, tips, updateNotice, cwd, provid
27
37
  thinkingLabel,
28
38
  tips,
29
39
  });
30
- return (_jsxs(Box, { width: effectiveWidth, flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { flexDirection: "column", marginRight: 2, flexShrink: 0, children: COMPACT_LOGO.map((line, rowIndex) => (_jsx(Text, { color: theme.warning, bold: true, children: line }, `logo-row-${rowIndex}`))) }), _jsxs(Box, { flexDirection: "column", flexGrow: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: theme.inputText, children: "Bubble" }), _jsxs(Text, { color: theme.muted, children: [" ", PACKAGE_VERSION] })] }), modelLine && (_jsx(Text, { color: theme.muted, children: modelLine })), cwd && (_jsx(Text, { color: theme.muted, children: cwd }))] })] }), updateNotice && (_jsx(Box, { children: _jsx(Text, { color: theme.accent, children: updateNotice }) }))] }));
40
+ const infoRows = [];
41
+ if (cwd)
42
+ infoRows.push({ label: "Directory:", value: cwd, color: theme.inputText });
43
+ if (sessionLabel)
44
+ infoRows.push({ label: "Session:", value: sessionLabel, color: theme.muted });
45
+ if (modelLine)
46
+ infoRows.push({ label: "Model:", value: modelLine, color: theme.traceCommand });
47
+ infoRows.push({ label: "Version:", value: PACKAGE_VERSION, color: theme.muted });
48
+ const labelWidth = Math.max(...infoRows.map((row) => row.label.length)) + 1;
49
+ return (_jsxs(Box, { width: effectiveWidth, flexDirection: "column", marginBottom: 1, borderStyle: "round", borderColor: theme.bannerBorder, paddingX: 2, children: [_jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { flexDirection: "column", marginRight: 2, flexShrink: 0, children: COMPACT_LOGO.map((line, rowIndex) => (_jsx(Text, { bold: true, color: lerpColor(theme.bannerGradientFrom, theme.bannerGradientTo, COMPACT_LOGO.length <= 1 ? 0 : rowIndex / (COMPACT_LOGO.length - 1)), children: line }, `logo-row-${rowIndex}`))) }), _jsxs(Box, { flexDirection: "column", marginTop: 1, flexGrow: 1, flexShrink: 1, children: [_jsx(Box, { children: _jsx(GradientText, { text: "Welcome to Bubble!", from: theme.bannerGradientFrom, to: theme.bannerGradientTo }) }), _jsx(Text, { color: theme.muted, wrap: "wrap", children: "I am a cat and you can send /help for help information." })] })] }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: infoRows.map((row) => (_jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { flexShrink: 0, children: _jsx(Text, { color: theme.dim, children: row.label.padEnd(labelWidth) }) }), _jsx(Box, { flexGrow: 1, flexShrink: 1, children: _jsx(Text, { color: row.color, wrap: "wrap", children: row.value }) })] }, row.label))) }), updateNotice && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.accent, children: updateNotice }) }))] }));
31
50
  }
32
51
  export function formatModelLine({ providerId, modelLabel, thinkingLabel, tips, }) {
33
52
  const parts = [];
package/dist/types.d.ts CHANGED
@@ -17,10 +17,19 @@ export type ReasoningEffort = ThinkingLevel;
17
17
  export type ProviderRawContentBlock = Record<string, unknown> & {
18
18
  type: string;
19
19
  };
20
+ export type ProviderMetadataProvider = "anthropic" | "google";
21
+ export interface ProviderContentBlockStore {
22
+ contentBlocks?: ProviderRawContentBlock[];
23
+ }
20
24
  export interface AssistantProviderMetadata {
21
- anthropic?: {
22
- contentBlocks?: ProviderRawContentBlock[];
23
- };
25
+ anthropic?: ProviderContentBlockStore;
26
+ /**
27
+ * Gemini raw parts captured for replay: reasoning/tool-call parts carrying
28
+ * thoughtSignature so multi-turn thinking round-trips through our own
29
+ * message rebuild (the SDK's automatic replay assumes appending its
30
+ * response.messages verbatim, which we don't do).
31
+ */
32
+ google?: ProviderContentBlockStore;
24
33
  }
25
34
  export interface UserMessage {
26
35
  role: "user";
@@ -302,7 +311,7 @@ export type StreamChunk = {
302
311
  content: string;
303
312
  } | {
304
313
  type: "provider_content_block";
305
- provider: "anthropic";
314
+ provider: ProviderMetadataProvider;
306
315
  block: ProviderRawContentBlock;
307
316
  } | {
308
317
  type: "tool_call";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bubblebrain-ai/bubble",
3
- "version": "0.0.32",
3
+ "version": "0.0.34",
4
4
  "description": "A terminal coding agent",
5
5
  "type": "module",
6
6
  "engines": {
@@ -24,6 +24,8 @@
24
24
  "test:watch": "vitest"
25
25
  },
26
26
  "dependencies": {
27
+ "@ai-sdk/google": "^3.0.88",
28
+ "@ai-sdk/provider": "^3.0.13",
27
29
  "@larksuiteoapi/node-sdk": "^1.65.0",
28
30
  "@types/better-sqlite3": "^7.6.13",
29
31
  "@types/react": "^19.2.14",
@@ -43,7 +45,8 @@
43
45
  "typescript-language-server": "^5.1.3",
44
46
  "undici": "^6.26.0",
45
47
  "vscode-jsonrpc": "^8.2.1",
46
- "vscode-langservers-extracted": "^4.10.0"
48
+ "vscode-langservers-extracted": "^4.10.0",
49
+ "zod": "^4.4.3"
47
50
  },
48
51
  "devDependencies": {
49
52
  "@types/node": "^22.0.0",