@bubblebrain-ai/bubble 0.0.54 → 0.0.56

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/README.md CHANGED
@@ -67,6 +67,49 @@ Resume your last conversation:
67
67
  bubble --resume
68
68
  ```
69
69
 
70
+ ## Embed with the SDK
71
+
72
+ The npm package exports `BubbleSdk` from both `@bubblebrain-ai/bubble` and
73
+ `@bubblebrain-ai/bubble/sdk`. Turns are serialized FIFO within one session and
74
+ remain parallel across different sessions.
75
+
76
+ ```ts
77
+ import { BubbleSdk } from "@bubblebrain-ai/bubble/sdk";
78
+
79
+ const sdk = new BubbleSdk({ defaultCwd: process.cwd() });
80
+ const session = sdk.createSession();
81
+ const events = sdk.runTurn(session.id, { prompt: "Inspect this project" });
82
+
83
+ for await (const event of events) {
84
+ if (event.type === "text_delta") process.stdout.write(event.content);
85
+ }
86
+
87
+ const queued = sdk.enqueueTurn(session.id, { prompt: "Run the tests next" });
88
+ void (async () => {
89
+ for await (const event of queued) {
90
+ // Render the queued turn's events.
91
+ }
92
+ })();
93
+ ```
94
+
95
+ While a turn owns the session slot, `sdk.steer(session.id, text)` adds text at
96
+ the next model-call boundary and returns a receipt: `steered` (injected into
97
+ the active turn), `queued` (the session is idle or the active turn can no
98
+ longer accept input, so the text becomes the next FIFO turn automatically),
99
+ or `rejected`. Every accepted receipt includes an `outcome` promise that
100
+ settles exactly once to `input_applied`, `input_queued`, or `input_rejected` —
101
+ even if the host drops the event iterator early.
102
+
103
+ Turns are driven by the SDK itself, not by iterator consumption: a returned
104
+ iterator is a replay subscription, so dropping it never stalls or cancels the
105
+ turn. `openSession(sessionId)` returns a durable handle whose `events` iterable
106
+ replays the whole session log from sequence 1 and `eventsFrom(n)` resumes after
107
+ the last sequence the host durably processed. `stop(sessionId)` is
108
+ Claude-style: it interrupts the active turn and keeps the queue; pass
109
+ `{ cancelQueued: true }` to clear both. `clearQueue(sessionId)` cancels only
110
+ queued turns, and `deleteSession(sessionId)` awaits teardown before the JSONL
111
+ file is removed.
112
+
70
113
  ## Model providers
71
114
 
72
115
  Bubble ships with a catalog of built-in providers. Configure them inside the app — no environment variables required.
@@ -3,9 +3,12 @@ export declare class AgentRunInputQueue implements AgentInputController {
3
3
  private readonly idPrefix;
4
4
  private pending;
5
5
  private nextInputId;
6
+ private accepting;
6
7
  constructor(idPrefix?: string);
7
8
  enqueue(content: string): AgentRunInput;
9
+ tryEnqueue(content: string): AgentRunInput | undefined;
8
10
  drainPendingInputs(): AgentRunInput[];
9
11
  pendingInputCount(): number;
10
12
  clear(): AgentRunInput[];
13
+ closePendingInputs(): AgentRunInput[];
11
14
  }
@@ -2,10 +2,19 @@ export class AgentRunInputQueue {
2
2
  idPrefix;
3
3
  pending = [];
4
4
  nextInputId = 0;
5
+ accepting = true;
5
6
  constructor(idPrefix = "input") {
6
7
  this.idPrefix = idPrefix;
7
8
  }
8
9
  enqueue(content) {
10
+ const input = this.tryEnqueue(content);
11
+ if (!input)
12
+ throw new Error("Agent input queue is closed");
13
+ return input;
14
+ }
15
+ tryEnqueue(content) {
16
+ if (!this.accepting)
17
+ return undefined;
9
18
  const input = {
10
19
  id: `${this.idPrefix}-${++this.nextInputId}`,
11
20
  content,
@@ -27,4 +36,8 @@ export class AgentRunInputQueue {
27
36
  clear() {
28
37
  return this.drainPendingInputs();
29
38
  }
39
+ closePendingInputs() {
40
+ this.accepting = false;
41
+ return this.drainPendingInputs();
42
+ }
30
43
  }
package/dist/agent.js CHANGED
@@ -644,7 +644,11 @@ export class Agent {
644
644
  return events;
645
645
  };
646
646
  const rejectPendingInputs = async (reason) => {
647
- const pendingInputs = inputController?.drainPendingInputs() ?? [];
647
+ // Seal before the first await. A host can submit in reaction to the final
648
+ // turn boundary; accepting after this drain would orphan that input.
649
+ const pendingInputs = inputController?.closePendingInputs?.()
650
+ ?? inputController?.drainPendingInputs()
651
+ ?? [];
648
652
  if (pendingInputs.length === 0)
649
653
  return [];
650
654
  const events = [];
@@ -697,6 +701,8 @@ export class Agent {
697
701
  yield emit({ type: "turn_start" });
698
702
  yield emit({ type: "text_delta", content: message });
699
703
  yield emit({ type: "turn_end", willContinue: false });
704
+ for (const event of await rejectPendingInputs("no_continuation"))
705
+ yield emit(event);
700
706
  yield emit({ type: "agent_end" });
701
707
  return;
702
708
  }
@@ -36,8 +36,11 @@ export function estimateMessageTokens(message, providerId) {
36
36
  }
37
37
  }
38
38
  function estimateProviderMetadataOverhead(metadata, providerId) {
39
- const blocks = metadata?.anthropic?.contentBlocks;
40
- if (!blocks || blocks.length === 0)
39
+ const blocks = [
40
+ ...(metadata?.anthropic?.contentBlocks ?? []),
41
+ ...(metadata?.openai?.contentBlocks ?? []),
42
+ ];
43
+ if (blocks.length === 0)
41
44
  return 0;
42
45
  const estimate = (text) => estimateTextTokens(text, providerId);
43
46
  return blocks.reduce((sum, block) => {
@@ -46,6 +49,8 @@ function estimateProviderMetadataOverhead(metadata, providerId) {
46
49
  overhead += estimate(block.signature);
47
50
  if (block.type === "redacted_thinking" && typeof block.data === "string")
48
51
  overhead += estimate(block.data);
52
+ if (block.type === "reasoning" && typeof block.encrypted_content === "string")
53
+ overhead += estimate(block.encrypted_content);
49
54
  return sum + overhead;
50
55
  }, 0);
51
56
  }
@@ -1,5 +1,5 @@
1
1
  import type { ReasoningEffort } from "./types.js";
2
- export type ProviderProtocol = "openai-chat" | "anthropic-messages" | "ark-responses" | "ai-sdk";
2
+ export type ProviderProtocol = "openai-chat" | "openai-responses" | "anthropic-messages" | "ark-responses" | "ai-sdk";
3
3
  export interface BuiltinProviderDefinition {
4
4
  id: string;
5
5
  name: string;
@@ -3,6 +3,7 @@ export const BUILTIN_PROVIDERS = [
3
3
  { id: "openrouter", name: "OpenRouter", baseURL: "https://openrouter.ai/api/v1" },
4
4
  { id: "openai", name: "OpenAI", baseURL: "https://api.openai.com/v1", supportsOAuth: true },
5
5
  { id: "openai-codex", name: "OpenAI Codex (ChatGPT)", baseURL: "https://chatgpt.com/backend-api" },
6
+ { id: "opencode-zen", name: "OpenCode Zen", baseURL: "https://opencode.ai/zen/v1", protocol: "openai-responses" },
6
7
  // Grok subscription models via the CLI chat proxy. OAuth-only: the proxy
7
8
  // accepts xAI session bearers (from /login grok), not api.x.ai API keys.
8
9
  { id: "grok", name: "Grok Subscription", baseURL: "https://cli-chat-proxy.grok.com/v1", supportsOAuth: true },
@@ -39,6 +40,7 @@ const GPT51_LEVELS = ["off", "low", "medium", "high"];
39
40
  const GPT51_CODEX_MAX_LEVELS = ["off", "low", "medium", "high", "xhigh"];
40
41
  const GPT51_CODEX_MINI_LEVELS = ["off", "medium", "high"];
41
42
  const OPENAI_CHAT_LEVELS = ["off"];
43
+ const MUSE_SPARK_LEVELS = ["minimal", "low", "medium", "high", "xhigh"];
42
44
  // Ox Alpha declares mandatory reasoning with exactly low/high/max through
43
45
  // OpenRouter's live model metadata. The endpoint rejects disabled reasoning.
44
46
  const OPENROUTER_OX_REASONING_LEVELS = ["low", "high", "max"];
@@ -87,6 +89,9 @@ const ANTHROPIC_CHAT_LEVELS = ["off"];
87
89
  const GROK_45_LEVELS = ["low", "medium", "high"];
88
90
  const GEMINI_3_LEVELS = ["low", "medium", "high"];
89
91
  const GEMINI_3_FLASH_LEVELS = ["minimal", "low", "medium", "high"];
92
+ // Verified against the native API on 2026-09-02: Gemini 3.7/3.8 Flash reject
93
+ // thinkingLevel=minimal but accept thinkingBudget=0 plus low/medium/high.
94
+ const GEMINI_37_38_FLASH_LEVELS = ["off", "low", "medium", "high"];
90
95
  const GEMINI_25_PRO_LEVELS = ["low", "medium", "high"];
91
96
  const GEMINI_25_FLASH_LEVELS = ["off", "low", "medium", "high"];
92
97
  export const BUILTIN_MODELS = [
@@ -114,6 +119,9 @@ export const BUILTIN_MODELS = [
114
119
  { id: "o1-preview", name: "o1-preview", providerId: "openai", tier: "strong", reasoningLevels: ["off", "low", "medium", "high"], contextWindow: 128000 },
115
120
  { id: "o1-mini", name: "o1-mini", providerId: "openai", tier: "fast", reasoningLevels: ["off", "low", "medium", "high"], contextWindow: 128000 },
116
121
  { id: "gpt-4-turbo", name: "gpt-4-turbo", providerId: "openai", reasoningLevels: OPENAI_CHAT_LEVELS, contextWindow: 128000 },
122
+ { id: "muse-spark-1.3-contributor-free", name: "Muse Spark 1.3 Free", providerId: "opencode-zen", reasoningLevels: MUSE_SPARK_LEVELS, defaultReasoningLevel: "high", contextWindow: 1048576 },
123
+ { id: "muse-spark-1.2", name: "Muse Spark 1.2", providerId: "opencode-zen", reasoningLevels: MUSE_SPARK_LEVELS, defaultReasoningLevel: "high", contextWindow: 1048576 },
124
+ { id: "muse-spark-1.2-contributor-free", name: "Muse Spark 1.2 Free", providerId: "opencode-zen", reasoningLevels: MUSE_SPARK_LEVELS, defaultReasoningLevel: "high", contextWindow: 1048576 },
117
125
  { id: "claude-fable-5", name: "Claude Fable 5", providerId: "anthropic", tier: "strong", reasoningLevels: ANTHROPIC_FABLE_EFFORT_LEVELS, defaultReasoningLevel: "high", contextWindow: 1000000 },
118
126
  { id: "claude-opus-4-8", name: "Claude Opus 4.8", providerId: "anthropic", tier: "strong", reasoningLevels: ANTHROPIC_OPUS_EFFORT_LEVELS, defaultReasoningLevel: "high", contextWindow: 1000000 },
119
127
  { id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6", providerId: "anthropic", tier: "balanced", reasoningLevels: ANTHROPIC_SONNET_EFFORT_LEVELS, defaultReasoningLevel: "high", contextWindow: 1000000 },
@@ -126,8 +134,10 @@ export const BUILTIN_MODELS = [
126
134
  { id: "deepseek-v4-pro", name: "deepseek-v4-pro", providerId: "deepseek", tier: "strong", reasoningLevels: DEEPSEEK_V4_LEVELS, defaultReasoningLevel: "high", contextWindow: 1048576 },
127
135
  // Offline/no-key fallback only: with an API key the registry replaces this
128
136
  // list via fetchGeminiModels (GET /v1beta/models, newest five). Gemini 3
129
- // exposes thinking_level (minimal/low/medium/high); 2.5 Pro cannot disable
130
- // thinking (no "off"), 2.5 Flash can (thinkingBudget 0).
137
+ // thinking support varies by release: 3.7/3.8 Flash support off/low/medium/high
138
+ // while older Flash releases expose minimal/low/medium/high.
139
+ { id: "gemini-3.8-flash", name: "Gemini 3.8 Flash", providerId: "google", tier: "fast", reasoningLevels: GEMINI_37_38_FLASH_LEVELS, contextWindow: 1048576 },
140
+ { id: "gemini-3.7-flash", name: "Gemini 3.7 Flash", providerId: "google", tier: "fast", reasoningLevels: GEMINI_37_38_FLASH_LEVELS, contextWindow: 1048576 },
131
141
  { id: "gemini-3.5-flash", name: "Gemini 3.5 Flash", providerId: "google", tier: "fast", reasoningLevels: GEMINI_3_FLASH_LEVELS, contextWindow: 1048576 },
132
142
  { id: "gemini-3.1-pro-preview", name: "Gemini 3.1 Pro", providerId: "google", tier: "strong", reasoningLevels: GEMINI_3_LEVELS, defaultReasoningLevel: "high", contextWindow: 1048576 },
133
143
  { id: "gemini-3-flash-preview", name: "Gemini 3 Flash", providerId: "google", tier: "fast", reasoningLevels: GEMINI_3_FLASH_LEVELS, contextWindow: 1048576 },
@@ -35,11 +35,7 @@ export interface ModelPricing extends ModelPricingRate {
35
35
  /** Rate used before `effectiveFrom` (for historical session statistics). */
36
36
  prior?: ModelPricingRate;
37
37
  effectiveUntil?: string;
38
- original?: {
39
- inputCacheHitPerMillion: number;
40
- inputCacheMissPerMillion: number;
41
- outputPerMillion: number;
42
- };
38
+ original?: ModelPricingRate;
43
39
  }
44
40
  /**
45
41
  * Hand-maintained corrections layered over the generated models.dev snapshot.
Binary file
@@ -509,6 +509,11 @@ export function selectLatestGeminiModels(entries, limit = 5) {
509
509
  export function geminiReasoningLevels(modelId) {
510
510
  const version = Number.parseFloat(/^gemini-(\d+(?:\.\d+)?)/.exec(modelId)?.[1] ?? "0");
511
511
  const isPro = modelId.includes("-pro");
512
+ // Live probes show 3.7 and 3.8 Flash share this newer ladder. Keep the
513
+ // exception bounded to verified releases instead of guessing for 3.9+.
514
+ if (!isPro && (version === 3.7 || version === 3.8)) {
515
+ return ["off", "low", "medium", "high"];
516
+ }
512
517
  if (version >= 3) {
513
518
  return isPro ? ["low", "medium", "high"] : ["minimal", "low", "medium", "high"];
514
519
  }
@@ -1,5 +1,5 @@
1
1
  import type { Provider, ProviderMessage, StreamChunk, ThinkingLevel, TokenUsage, ToolChoiceMode, ToolDefinition } from "./types.js";
2
- export interface ArkResponsesProviderOptions {
2
+ export interface ResponsesProviderOptions {
3
3
  providerId?: string;
4
4
  apiKey: string;
5
5
  baseURL: string;
@@ -7,7 +7,9 @@ export interface ArkResponsesProviderOptions {
7
7
  /** User-configured extra headers, merged last (client-identity gates). */
8
8
  headers?: Record<string, string>;
9
9
  }
10
+ export type ArkResponsesProviderOptions = ResponsesProviderOptions;
10
11
  export declare function createArkResponsesProvider(options: ArkResponsesProviderOptions): Provider;
12
+ export declare function createOpenAIResponsesProvider(options: ResponsesProviderOptions): Provider;
11
13
  export declare function buildArkResponsesBody(messages: ProviderMessage[], options: {
12
14
  model: string;
13
15
  tools?: ToolDefinition[];
@@ -15,5 +17,13 @@ export declare function buildArkResponsesBody(messages: ProviderMessage[], optio
15
17
  thinkingLevel?: ThinkingLevel;
16
18
  stream: boolean;
17
19
  }): Record<string, unknown>;
18
- export declare function translateArkResponsesStream(response: Response): AsyncIterable<StreamChunk>;
20
+ export declare function buildOpenAIResponsesBody(messages: ProviderMessage[], options: {
21
+ model: string;
22
+ tools?: ToolDefinition[];
23
+ toolChoice?: ToolChoiceMode;
24
+ temperature?: number;
25
+ thinkingLevel?: ThinkingLevel;
26
+ stream: boolean;
27
+ }): Record<string, unknown>;
28
+ export declare function translateArkResponsesStream(response: Response, captureOpenAIReasoning?: boolean): AsyncIterable<StreamChunk>;
19
29
  export declare function normalizeArkResponsesUsage(usage: any): TokenUsage;
@@ -1,33 +1,43 @@
1
1
  import { createProviderProtocolArtifactFilter } from "./provider-artifacts.js";
2
2
  import { RateLimitError } from "./network/errors.js";
3
3
  import { computeRetryDelayMs, getProviderMaxRetries, isRetryableHttpStatus, retryAfterMsFromResponse, sleepBeforeRetry, } from "./network/retry.js";
4
+ import { providerFetch } from "./network/provider-transport.js";
4
5
  const DEFAULT_ARK_MODEL = "doubao-seed-2-1-pro-260628";
6
+ const DEFAULT_MUSE_MODEL = "muse-spark-1.3-contributor-free";
5
7
  export function createArkResponsesProvider(options) {
8
+ return createResponsesProvider(options, "ark", DEFAULT_ARK_MODEL);
9
+ }
10
+ export function createOpenAIResponsesProvider(options) {
11
+ return createResponsesProvider(options, "openai", DEFAULT_MUSE_MODEL);
12
+ }
13
+ function createResponsesProvider(options, dialect, defaultModel) {
6
14
  async function* streamChat(messages, chatOptions) {
7
- const body = buildArkResponsesBody(messages, {
15
+ const body = buildResponsesBody(messages, {
8
16
  model: chatOptions.model,
9
17
  tools: chatOptions.tools,
10
18
  toolChoice: chatOptions.toolChoice,
19
+ temperature: chatOptions.temperature,
11
20
  thinkingLevel: chatOptions.thinkingLevel ?? options.thinkingLevel ?? "high",
12
21
  stream: true,
13
- });
22
+ }, dialect);
14
23
  for (let attempt = 0;; attempt += 1) {
15
24
  const response = await sendArkResponsesRequest(options, body, {
16
25
  signal: chatOptions.abortSignal,
17
26
  rateLimitPolicy: chatOptions.rateLimitPolicy,
18
27
  attempt,
19
28
  });
20
- yield* translateArkResponsesStream(response);
29
+ yield* translateArkResponsesStream(response, dialect === "openai");
21
30
  yield { type: "done" };
22
31
  return;
23
32
  }
24
33
  }
25
34
  async function complete(messages, chatOptions) {
26
- const body = buildArkResponsesBody(messages, {
27
- model: chatOptions?.model ?? DEFAULT_ARK_MODEL,
35
+ const body = buildResponsesBody(messages, {
36
+ model: chatOptions?.model ?? defaultModel,
37
+ temperature: chatOptions?.temperature,
28
38
  thinkingLevel: chatOptions?.thinkingLevel ?? options.thinkingLevel ?? "high",
29
39
  stream: false,
30
- });
40
+ }, dialect);
31
41
  const response = await sendArkResponsesRequest(options, body, {
32
42
  signal: chatOptions?.abortSignal,
33
43
  attempt: 0,
@@ -38,15 +48,34 @@ export function createArkResponsesProvider(options) {
38
48
  return { streamChat, complete };
39
49
  }
40
50
  export function buildArkResponsesBody(messages, options) {
51
+ return buildResponsesBody(messages, options, "ark");
52
+ }
53
+ export function buildOpenAIResponsesBody(messages, options) {
54
+ return buildResponsesBody(messages, options, "openai");
55
+ }
56
+ function buildResponsesBody(messages, options, dialect) {
41
57
  const body = {
42
58
  model: options.model,
43
- input: messages.flatMap((message) => convertMessageToArkResponsesInput(message)),
59
+ input: messages.flatMap((message) => convertMessageToResponsesInput(message, dialect)),
44
60
  store: false,
45
61
  stream: options.stream,
46
- thinking: {
47
- type: shouldDisableArkThinking(options.thinkingLevel) ? "disabled" : "enabled",
48
- },
49
62
  };
63
+ if (dialect === "ark") {
64
+ body.thinking = {
65
+ type: shouldDisableArkThinking(options.thinkingLevel) ? "disabled" : "enabled",
66
+ };
67
+ }
68
+ else {
69
+ body.include = ["reasoning.encrypted_content"];
70
+ if (options.thinkingLevel && options.thinkingLevel !== "off") {
71
+ body.reasoning = {
72
+ effort: options.thinkingLevel === "ultra" ? "xhigh" : options.thinkingLevel,
73
+ summary: "auto",
74
+ };
75
+ }
76
+ if (options.temperature !== undefined)
77
+ body.temperature = options.temperature;
78
+ }
50
79
  if (options.tools && options.tools.length > 0) {
51
80
  body.tools = options.tools.map((tool) => ({
52
81
  type: "function",
@@ -63,7 +92,7 @@ export function buildArkResponsesBody(messages, options) {
63
92
  function shouldDisableArkThinking(level) {
64
93
  return level === "off" || level === "minimal";
65
94
  }
66
- function convertMessageToArkResponsesInput(message) {
95
+ function convertMessageToResponsesInput(message, dialect) {
67
96
  if (message.role === "user") {
68
97
  if (typeof message.content === "string") {
69
98
  return [{
@@ -92,6 +121,17 @@ function convertMessageToArkResponsesInput(message) {
92
121
  }
93
122
  if (message.role === "assistant") {
94
123
  const items = [];
124
+ if (dialect === "openai") {
125
+ for (const block of message.providerMetadata?.openai?.contentBlocks ?? []) {
126
+ if (block.type !== "reasoning" || typeof block.encrypted_content !== "string")
127
+ continue;
128
+ items.push({
129
+ type: "reasoning",
130
+ summary: Array.isArray(block.summary) ? block.summary : [],
131
+ encrypted_content: block.encrypted_content,
132
+ });
133
+ }
134
+ }
95
135
  if (message.content) {
96
136
  items.push({
97
137
  type: "message",
@@ -118,7 +158,7 @@ function convertMessageToArkResponsesInput(message) {
118
158
  }];
119
159
  }
120
160
  async function sendArkResponsesRequest(options, body, requestOptions) {
121
- const response = await fetch(resolveArkResponsesUrl(options.baseURL), {
161
+ const response = await providerFetch(resolveArkResponsesUrl(options.baseURL), {
122
162
  method: "POST",
123
163
  headers: {
124
164
  "Authorization": `Bearer ${options.apiKey}`,
@@ -128,6 +168,8 @@ async function sendArkResponsesRequest(options, body, requestOptions) {
128
168
  },
129
169
  body: JSON.stringify(body),
130
170
  signal: requestOptions.signal,
171
+ }, {
172
+ providerName: options.providerId ?? "Responses provider",
131
173
  });
132
174
  if (response.ok)
133
175
  return response;
@@ -159,7 +201,7 @@ async function sendArkResponsesRequest(options, body, requestOptions) {
159
201
  function resolveArkResponsesUrl(baseURL) {
160
202
  return `${baseURL.trim().replace(/\/+$/, "")}/responses`;
161
203
  }
162
- export async function* translateArkResponsesStream(response) {
204
+ export async function* translateArkResponsesStream(response, captureOpenAIReasoning = false) {
163
205
  const toolCalls = new Map();
164
206
  const textFilter = createProviderProtocolArtifactFilter();
165
207
  for await (const event of parseArkResponsesSse(response)) {
@@ -253,6 +295,19 @@ export async function* translateArkResponsesStream(response) {
253
295
  }
254
296
  if (type === "response.output_item.done") {
255
297
  const item = event.item;
298
+ if (captureOpenAIReasoning
299
+ && item?.type === "reasoning"
300
+ && typeof item.encrypted_content === "string") {
301
+ yield {
302
+ type: "provider_content_block",
303
+ provider: "openai",
304
+ block: {
305
+ type: "reasoning",
306
+ summary: Array.isArray(item.summary) ? item.summary : [],
307
+ encrypted_content: item.encrypted_content,
308
+ },
309
+ };
310
+ }
256
311
  if (item?.type === "function_call") {
257
312
  const key = toolCallEventKey(event, item);
258
313
  const entry = toolCalls.get(key);
@@ -11,7 +11,7 @@ import { getBubbleHome } from "./bubble-home.js";
11
11
  import { BUILTIN_PROVIDERS as CATALOG_PROVIDERS, clearDynamicModelMetadata, getBuiltinModel, getBuiltinProvider, inferGrokModelMetadata, listBuiltinModels, replaceDynamicModelMetadata, } from "./model-catalog.js";
12
12
  import { ModelConfig } from "./model-config.js";
13
13
  import { AuthStorage } from "./oauth/index.js";
14
- import { fetchGeminiModels } from "./provider-ai-sdk.js";
14
+ import { fetchGeminiModels, geminiReasoningLevels } from "./provider-ai-sdk.js";
15
15
  import { extractChatGptAccountId, fetchOpenAICodexModelCatalog } from "./provider-openai-codex.js";
16
16
  import { fetchGrokSubscriptionModels } from "./provider-grok.js";
17
17
  import { refreshOpenAICodex } from "./oauth/openai-codex.js";
@@ -169,18 +169,23 @@ export class ProviderRegistry {
169
169
  continue;
170
170
  if (!entry.result || !Array.isArray(entry.result.models))
171
171
  continue;
172
+ const provider = { id: entry.providerId ?? "", authType: entry.authType, protocol: entry.protocol };
173
+ // Capability fields may have been derived by an older Bubble build.
174
+ // Recompute them with the current code while retaining cached remote
175
+ // membership, display names and context windows.
176
+ const result = normalizeProviderDiscoveryMetadata(provider, entry.result);
172
177
  this.modelDiscoveryCache.set(key, {
173
- result: entry.result,
178
+ result,
174
179
  expiresAt: entry.expiresAt,
175
180
  identityKey: entry.identityKey ?? "unknown",
176
- providerId: entry.providerId ?? "",
181
+ providerId: provider.id,
177
182
  authType: entry.authType,
178
183
  protocol: entry.protocol,
179
184
  });
180
185
  // Rebuild the dynamic overlay so context window / reasoning levels
181
186
  // resolve from the cached catalog at startup, not only on /model open.
182
187
  if (entry.providerId) {
183
- this.applyDynamicDiscoveryMetadata({ id: entry.providerId, authType: entry.authType, protocol: entry.protocol }, entry.result);
188
+ this.applyDynamicDiscoveryMetadata(provider, result);
184
189
  }
185
190
  }
186
191
  }
@@ -513,6 +518,8 @@ export class ProviderRegistry {
513
518
  if (!options.forceRefresh) {
514
519
  const cached = this.modelDiscoveryCache.get(key);
515
520
  if (cached && cached.expiresAt > now) {
521
+ const cachedResult = normalizeProviderDiscoveryMetadata(provider, cached.result);
522
+ cached.result = cachedResult;
516
523
  // A disk cache can outlive the application version that wrote it. Keep
517
524
  // the current curated catalog authoritative over stale cached metadata,
518
525
  // then retain cached remote-only ids for non-authoritative union results.
@@ -520,12 +527,12 @@ export class ProviderRegistry {
520
527
  // without waiting up to 24 hours or requiring a manual Ctrl+R refresh.
521
528
  const local = this.localModelsForProvider(provider);
522
529
  const localIds = new Set(local.map((model) => model.id));
523
- const models = cached.result.source === "static"
530
+ const models = cachedResult.source === "static"
524
531
  ? local
525
- : cached.result.authoritative
526
- ? cached.result.models
527
- : [...local, ...cached.result.models.filter((model) => !localIds.has(model.id))];
528
- const result = { ...cached.result, models, source: "cache" };
532
+ : cachedResult.authoritative
533
+ ? cachedResult.models
534
+ : [...local, ...cachedResult.models.filter((model) => !localIds.has(model.id))];
535
+ const result = { ...cachedResult, models, source: "cache" };
529
536
  const current = this.getConfigured().find((item) => item.id === provider.id);
530
537
  if (!current || this.modelDiscoveryKey(current) === key) {
531
538
  this.applyDynamicDiscoveryMetadata(provider, result);
@@ -544,7 +551,8 @@ export class ProviderRegistry {
544
551
  const generation = (this.modelDiscoveryGeneration.get(provider.id) ?? 0) + 1;
545
552
  this.modelDiscoveryGeneration.set(provider.id, generation);
546
553
  let pending;
547
- pending = this.performModelDiscovery(provider).then((result) => {
554
+ pending = this.performModelDiscovery(provider).then((rawResult) => {
555
+ const result = normalizeProviderDiscoveryMetadata(provider, rawResult);
548
556
  if (!this.isCurrentModelDiscovery(provider, key, generation)) {
549
557
  return {
550
558
  models: this.localModelsForProvider(this.getConfigured().find((item) => item.id === provider.id) ?? provider),
@@ -879,6 +887,22 @@ export class ProviderRegistry {
879
887
  })));
880
888
  }
881
889
  }
890
+ /**
891
+ * Remote Gemini discovery does not expose Bubble's reasoning ladder; that
892
+ * metadata is inferred locally. Never trust a persisted inference across app
893
+ * upgrades, or a corrected capability matrix can remain stale for the cache
894
+ * TTL after restart.
895
+ */
896
+ function normalizeProviderDiscoveryMetadata(provider, result) {
897
+ if (provider.id !== "google" || provider.protocol !== "ai-sdk")
898
+ return result;
899
+ return {
900
+ ...result,
901
+ models: result.models.map((model) => model.id.startsWith("gemini-")
902
+ ? { ...model, reasoningLevels: geminiReasoningLevels(model.id) }
903
+ : model),
904
+ };
905
+ }
882
906
  function modelDiscoveryError(error) {
883
907
  if (error instanceof Error && error.message.trim())
884
908
  return error.message;
package/dist/provider.js CHANGED
@@ -6,7 +6,7 @@
6
6
  import OpenAI from "openai";
7
7
  import { appendFileSync } from "node:fs";
8
8
  import { createAnthropicMessagesProvider } from "./provider-anthropic.js";
9
- import { createArkResponsesProvider } from "./provider-ark-responses.js";
9
+ import { createArkResponsesProvider, createOpenAIResponsesProvider } from "./provider-ark-responses.js";
10
10
  import { createAiSdkProvider } from "./provider-ai-sdk.js";
11
11
  import { createOpenAICodexProvider, isOpenAICodexBaseUrl } from "./provider-openai-codex.js";
12
12
  import { buildGrokSubscriptionHeaders, createGrokSubscriptionFetch, isGrokSubscriptionBaseUrl, } from "./provider-grok.js";
@@ -89,6 +89,9 @@ export function createProviderInstance(options) {
89
89
  if (protocol === "ark-responses") {
90
90
  return createArkResponsesProvider(options);
91
91
  }
92
+ if (protocol === "openai-responses") {
93
+ return createOpenAIResponsesProvider(options);
94
+ }
92
95
  if (protocol === "ai-sdk") {
93
96
  return createAiSdkProvider(options);
94
97
  }
@@ -273,6 +276,9 @@ function resolveProviderProtocol(options) {
273
276
  return options.protocol;
274
277
  const providerId = (options.providerId || "").toLowerCase();
275
278
  const baseURL = (options.baseURL || "").toLowerCase();
279
+ if (providerId === "opencode-zen") {
280
+ return "openai-responses";
281
+ }
276
282
  if (providerId === "doubao"
277
283
  && baseURL.replace(/\/+$/, "") === "https://ark.cn-beijing.volces.com/api/v3") {
278
284
  return "ark-responses";