@bubblebrain-ai/bubble 0.0.33 → 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 +3 -5
- package/dist/context/overflow.js +2 -0
- package/dist/model-catalog.d.ts +1 -1
- package/dist/model-catalog.js +17 -4
- package/dist/provider-ai-sdk.d.ts +56 -0
- package/dist/provider-ai-sdk.js +518 -0
- package/dist/provider-registry.js +49 -3
- package/dist/provider.js +4 -0
- package/dist/slash-commands/commands.js +3 -7
- package/dist/slash-commands/types.d.ts +1 -1
- package/dist/tui-ink/app.js +26 -1
- package/dist/tui-ink/model-picker.d.ts +3 -1
- package/dist/tui-ink/model-picker.js +6 -1
- package/dist/types.d.ts +13 -4
- package/package.json +5 -2
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
|
-
|
|
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
|
-
|
|
2564
|
-
...message.providerMetadata?.
|
|
2561
|
+
[provider]: {
|
|
2562
|
+
...message.providerMetadata?.[provider],
|
|
2565
2563
|
contentBlocks: [...current, cloneProviderRawContentBlock(block)],
|
|
2566
2564
|
},
|
|
2567
2565
|
};
|
package/dist/context/overflow.js
CHANGED
|
@@ -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)
|
package/dist/model-catalog.d.ts
CHANGED
|
@@ -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;
|
package/dist/model-catalog.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|
142
|
+
const baseURL = upgradeLegacyBaseURL(provider.id, provider.baseURL, provider.protocol);
|
|
142
143
|
return {
|
|
143
144
|
...provider,
|
|
144
|
-
|
|
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: "
|
|
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
|
-
|
|
340
|
-
|
|
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;
|
package/dist/tui-ink/app.js
CHANGED
|
@@ -254,6 +254,11 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
|
|
|
254
254
|
setThemeMode(mode);
|
|
255
255
|
onThemeModeChange?.(mode);
|
|
256
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");
|
|
257
262
|
const themeResolved = themeMode === "auto" ? autoResolved : themeMode;
|
|
258
263
|
const { exit } = useApp();
|
|
259
264
|
const [messages, setMessages] = useState(() => compactDisplayMessages(reconstructDisplayMessages(agent.messages)));
|
|
@@ -718,6 +723,9 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
|
|
|
718
723
|
if (mode === "key") {
|
|
719
724
|
setKeyProviderId(providerId ?? null);
|
|
720
725
|
}
|
|
726
|
+
if (mode === "theme") {
|
|
727
|
+
themePickerRevertRef.current = themeModeRef.current;
|
|
728
|
+
}
|
|
721
729
|
setStatsPanel(null);
|
|
722
730
|
setPickerMode(mode);
|
|
723
731
|
}, []);
|
|
@@ -836,6 +844,19 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
|
|
|
836
844
|
closePicker();
|
|
837
845
|
});
|
|
838
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]);
|
|
839
860
|
const handleProviderSelect = useCallback((providerId) => {
|
|
840
861
|
const run = async () => {
|
|
841
862
|
await safeRegistry.prepareProvider(providerId);
|
|
@@ -1723,7 +1744,11 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
|
|
|
1723
1744
|
} }) })), pickerMode === "skill" && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(SkillPicker, { skills: safeSkillRegistry.summaries(), onSelect: (name) => {
|
|
1724
1745
|
fillComposer(`/${name} `);
|
|
1725
1746
|
closePicker();
|
|
1726
|
-
}, onCancel: closePicker }) })), pickerMode === "
|
|
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) => {
|
|
1727
1752
|
closePicker();
|
|
1728
1753
|
if (item.action === "insert-skill") {
|
|
1729
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;
|
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
|
-
|
|
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:
|
|
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.
|
|
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",
|