@meetopenbot/openbot 0.2.7 → 1.0.0

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/runtime.js CHANGED
@@ -1,537 +1,413 @@
1
- import { generateText } from 'ai';
1
+ import { streamText, stepCountIs } from 'ai';
2
2
  import { eventsToModelMessages } from './history.js';
3
3
  import { buildContext } from './context.js';
4
4
  import { OPENBOT_SYSTEM_PROMPT } from './system-prompt.js';
5
5
  import { isAuthErrorMessage, isCreditsErrorMessage } from '@meetopenbot/plugin-sdk';
6
- import { AUTO_MODEL_ID, isAutoModel, isRetryableModelError, listAutoModelCandidates, } from './auto-model.js';
6
+ import { AUTO_MODEL_ID, isAutoModel, isRetryableModelError, listAutoModelCandidates, missingByokApiKeyMessage, } from './auto-model.js';
7
7
  import { includeReasoningInHistory, reasoningForModel, resolveModel } from './model.js';
8
8
  import { fetchModelRegistry, getProviderModelOptions, listApiKeyProviders, PROVIDER_API_KEY_LINKS, } from '@meetopenbot/plugin-sdk';
9
+ import { errorFromStreamPart } from './stream-error.js';
10
+ import { createAgentOutputBuffer, isOutputBoundaryPart } from './output-buffer.js';
9
11
  async function buildSystemPrompt(state, storage) {
10
12
  const context = await buildContext(state, storage);
11
13
  const sections = [OPENBOT_SYSTEM_PROMPT, '', context];
12
- // Hardcoded naming hint logic
13
14
  const threadState = state.threadDetails?.state;
14
15
  if (!threadState?.isSmartNamed) {
15
- sections.push('', '## SYSTEM HINT', 'This thread is unnamed. Please use the `patch_thread_details` tool to set a concise, descriptive, and regular `name` (e.g., "Project Brainstorming" instead of "project-brainstorm") in the thread state and set `isSmartNamed: true` in the same patch. Only do this once.');
16
+ sections.push('', '## SYSTEM HINT', 'This thread is unnamed. Please use the `set_thread_title` tool to set a concise, descriptive title (e.g., "Project Brainstorming" instead of "project-brainstorm"). Only do this once.');
16
17
  }
17
18
  return sections.join('\n');
18
19
  }
19
- /**
20
- * Tracks tool-call IDs from one LLM turn until matching `:result` events arrive.
21
- *
22
- * Melony runs yielded `action:*` events depth-first, so parallel tool calls from
23
- * a single `generateText` response execute one-by-one. We must wait for every ID
24
- * in the batch before calling the LLM again — not after the first result.
25
- */
26
- function createToolBatchTracker(state, storage, channelId, threadId) {
27
- const save = async (ids) => {
28
- if (!storage || !channelId || !threadId)
29
- return;
30
- try {
31
- await storage.patchThreadState({
32
- channelId,
33
- threadId,
34
- state: { pendingToolCallIds: ids },
35
- });
36
- }
37
- catch (error) {
38
- console.error('[openbot] Failed to persist pendingToolCallIds:', error);
39
- }
40
- };
41
- return {
42
- async startBatch(toolCallIds) {
43
- state.pendingToolCallIds = [...toolCallIds];
44
- await save(state.pendingToolCallIds);
45
- },
46
- async clear() {
47
- state.pendingToolCallIds = undefined;
48
- await save(undefined);
49
- },
50
- /** Returns true when this result completes the batch (time to call the LLM again). */
51
- async recordResult(toolCallId) {
52
- if (!state.pendingToolCallIds?.includes(toolCallId))
53
- return false;
54
- state.pendingToolCallIds = state.pendingToolCallIds.filter((id) => id !== toolCallId);
55
- const done = state.pendingToolCallIds.length === 0;
56
- if (done) {
57
- state.pendingToolCallIds = undefined;
58
- }
59
- await save(state.pendingToolCallIds);
60
- return done;
61
- },
62
- };
20
+ function toAiTools(tools, args, flushPendingOutput) {
21
+ const mapped = {};
22
+ for (const [name, def] of Object.entries(tools)) {
23
+ if (!def.execute)
24
+ continue;
25
+ mapped[name] = {
26
+ description: def.description,
27
+ inputSchema: def.inputSchema,
28
+ execute: async (input, { toolCallId }) => {
29
+ await flushPendingOutput();
30
+ await args.emit({
31
+ type: `action:${name}`,
32
+ data: input,
33
+ meta: {
34
+ toolCallId,
35
+ agentId: args.context.agentId,
36
+ threadId: args.threadId,
37
+ },
38
+ });
39
+ try {
40
+ const result = await def.execute(input, {
41
+ agentId: args.context.agentId,
42
+ channelId: args.state.channelId,
43
+ threadId: args.threadId,
44
+ runId: args.state.runId,
45
+ toolCallId,
46
+ abortSignal: args.context.abortSignal,
47
+ storage: args.context.storage,
48
+ host: args.context.host,
49
+ publicBaseUrl: args.context.publicBaseUrl,
50
+ config: args.context.config,
51
+ state: args.state,
52
+ emit: args.emit,
53
+ });
54
+ const data = typeof result === 'string'
55
+ ? { success: true, output: result }
56
+ : { success: result.success !== false, ...result };
57
+ await args.emit({
58
+ type: `action:${name}:result`,
59
+ data,
60
+ meta: {
61
+ toolCallId,
62
+ agentId: args.context.agentId,
63
+ threadId: args.threadId,
64
+ },
65
+ });
66
+ return data.output ?? JSON.stringify(data);
67
+ }
68
+ catch (error) {
69
+ const message = error instanceof Error ? error.message : String(error);
70
+ await args.emit({
71
+ type: `action:${name}:result`,
72
+ data: { success: false, error: message, output: message },
73
+ meta: {
74
+ toolCallId,
75
+ agentId: args.context.agentId,
76
+ threadId: args.threadId,
77
+ },
78
+ });
79
+ return message;
80
+ }
81
+ },
82
+ };
83
+ }
84
+ return mapped;
63
85
  }
64
- /**
65
- * OpenBot agent runtime.
66
- *
67
- * - One `generateText` call per `runLLM` (tools have no `execute`; SDK stops at 1 step).
68
- * - Tool calls become `action:*` events; plugins emit `:result` when done.
69
- * - When a full batch of results is in, `runLLM` runs again with updated history.
70
- */
71
- export const openbotRuntime = (options) => (builder) => {
72
- const { model: modelString = AUTO_MODEL_ID, authMode = 'byok', agentId, storage, toolDefinitions = {}, abortSignal, host, } = options;
86
+ export async function* runOpenbotTurn(args, options) {
87
+ const { model: modelString = AUTO_MODEL_ID, authMode = 'byok', storage, toolDefinitions = {}, abortSignal, host, } = options;
73
88
  let configuredModelString = modelString;
74
89
  const isCreditsCloudAgent = (id) => host.isCloudSystemAgent(id ?? '') && authMode === 'credits';
75
- const runLLM = async function* (context, threadId, trigger) {
76
- if (!storage)
77
- return;
78
- if (abortSignal?.aborted)
79
- return;
80
- const toolBatch = createToolBatchTracker(context.state, storage, context.state.channelId, threadId || context.state.threadId);
81
- // Capture parent metadata for event enrichment
82
- const triggerEvent = trigger || context.state.triggerEvent;
83
- const parentAgentId = triggerEvent?.meta?.parentAgentId;
84
- const parentToolCallId = triggerEvent?.meta?.parentToolCallId;
85
- const state = context.state;
86
- state.model = configuredModelString;
87
- const systemPrompt = await buildSystemPrompt(context.state, storage);
88
- const events = await storage.getEvents({
89
- channelId: context.state.channelId,
90
- threadId: context.state.threadId,
91
- });
92
- const candidates = isAutoModel(configuredModelString)
93
- ? listAutoModelCandidates({ authMode })
94
- : [configuredModelString];
95
- try {
96
- // Single LLM request tool execution happens externally via action:* handlers.
97
- const generate = async () => {
98
- let lastError;
99
- for (let i = 0; i < candidates.length; i++) {
100
- const candidate = candidates[i];
101
- try {
102
- return await generateText({
103
- model: resolveModel(candidate, { authMode }),
104
- system: systemPrompt,
105
- messages: eventsToModelMessages(events, {
106
- includeReasoning: includeReasoningInHistory(candidate),
107
- }),
108
- tools: toolDefinitions,
109
- allowSystemInMessages: true,
110
- abortSignal,
111
- reasoning: reasoningForModel(candidate),
112
- });
90
+ if (!storage || abortSignal?.aborted)
91
+ return;
92
+ const state = args.state;
93
+ state.model = configuredModelString;
94
+ const threadId = args.threadId || state.threadId;
95
+ const systemPrompt = await buildSystemPrompt(state, storage);
96
+ const events = await storage.getEvents({
97
+ channelId: state.channelId ?? '',
98
+ threadId,
99
+ });
100
+ const candidates = isAutoModel(configuredModelString)
101
+ ? listAutoModelCandidates({ authMode })
102
+ : [configuredModelString];
103
+ try {
104
+ const missingKey = missingByokApiKeyMessage(configuredModelString, { authMode });
105
+ if (missingKey)
106
+ throw new Error(missingKey);
107
+ let lastError;
108
+ let started = false;
109
+ for (let i = 0; i < candidates.length; i++) {
110
+ const candidate = candidates[i];
111
+ const output = createAgentOutputBuffer({ agentId: state.agentId, threadId });
112
+ const flushPendingOutput = async () => {
113
+ const event = output.take();
114
+ if (event)
115
+ await args.emit(event);
116
+ };
117
+ const tools = toAiTools(toolDefinitions, args, flushPendingOutput);
118
+ try {
119
+ const result = streamText({
120
+ model: resolveModel(candidate, { authMode }),
121
+ system: systemPrompt,
122
+ messages: eventsToModelMessages(events, {
123
+ includeReasoning: includeReasoningInHistory(candidate),
124
+ }),
125
+ tools,
126
+ abortSignal,
127
+ reasoning: reasoningForModel(candidate),
128
+ stopWhen: stepCountIs(20),
129
+ allowSystemInMessages: true,
130
+ });
131
+ for await (const part of result.fullStream) {
132
+ if (abortSignal?.aborted)
133
+ return;
134
+ const streamError = errorFromStreamPart(part);
135
+ if (streamError)
136
+ throw streamError;
137
+ if (part.type === 'text-delta' && part.text) {
138
+ output.addText(part.text);
139
+ yield {
140
+ type: 'agent:output:delta',
141
+ data: { content: part.text },
142
+ meta: { agentId: state.agentId, threadId },
143
+ };
113
144
  }
114
- catch (error) {
115
- lastError = error;
116
- if (abortSignal?.aborted)
117
- throw error;
118
- const errorMessage = error instanceof Error ? error.message : String(error);
119
- if (isCreditsErrorMessage(errorMessage) || isAuthErrorMessage(errorMessage)) {
120
- throw error;
121
- }
122
- if (!isRetryableModelError(error) || i === candidates.length - 1) {
123
- throw error;
124
- }
145
+ if (part.type === 'reasoning-delta' && 'text' in part && part.text) {
146
+ output.addReasoning(part.text);
147
+ yield {
148
+ type: 'agent:output:delta',
149
+ data: { reasoning: part.text },
150
+ meta: { agentId: state.agentId, threadId },
151
+ };
152
+ }
153
+ if (isOutputBoundaryPart(part.type)) {
154
+ const event = output.take();
155
+ if (event)
156
+ yield event;
125
157
  }
126
158
  }
127
- throw lastError ?? new Error('Model request failed.');
128
- };
129
- const result = await generate();
130
- const toolCalls = result.toolCalls ?? [];
131
- // if (result.usage) {
132
- // const usage = result.usage;
133
- // yield {
134
- // type: 'agent:usage',
135
- // data: {
136
- // usage: {
137
- // promptTokens: usage.inputTokens,
138
- // completionTokens: usage.outputTokens,
139
- // totalTokens: usage.totalTokens,
140
- // currentContextTokens: usage.inputTokens,
141
- // contextBudget: getContextBudgetForModel(currentModelString),
142
- // },
143
- // model: currentModelString,
144
- // },
145
- // meta: {
146
- // agentId: context.state.agentId,
147
- // threadId,
148
- // runId: context.state.runId,
149
- // },
150
- // } as OpenBotEvent;
151
- // }
152
- const outputMeta = {
153
- agentId: context.state.agentId,
154
- threadId,
155
- parentAgentId,
156
- parentToolCallId,
157
- };
158
- // Text before actions so history/UI show the model's intent first.
159
- const reasoningParts = result.reasoning.flatMap((part) => {
160
- if (part.type !== 'reasoning')
161
- return [];
162
- if (!part.text.trim() && !part.providerMetadata)
163
- return [];
164
- return [
165
- {
166
- text: part.text,
167
- ...(part.providerMetadata ? { providerOptions: part.providerMetadata } : {}),
168
- },
169
- ];
170
- });
171
- const reasoning = result.reasoningText?.trim() || undefined;
172
- if (result.text || reasoning || reasoningParts.length > 0) {
173
- yield {
174
- type: 'agent:output',
175
- data: {
176
- content: result.text ?? '',
177
- ...(reasoning ? { reasoning } : {}),
178
- ...(reasoningParts.length > 0 ? { reasoningParts } : {}),
179
- },
180
- meta: outputMeta,
181
- };
182
- }
183
- if (toolCalls.length > 0) {
184
- // when multiple tool calls are made, Melony runtime handles them one by one, thats why we need to start a new batch
185
- await toolBatch.startBatch(toolCalls.map((tc) => tc.toolCallId));
186
- for (const toolCall of toolCalls) {
187
- yield {
188
- type: `action:${toolCall.toolName}`,
189
- data: toolCall.input,
190
- meta: {
191
- toolCallId: toolCall.toolCallId,
192
- ...outputMeta,
193
- },
194
- };
195
- }
159
+ const leftover = output.take();
160
+ if (leftover)
161
+ yield leftover;
162
+ started = true;
163
+ lastError = undefined;
164
+ break;
196
165
  }
197
- else {
198
- // clear the tool batch if there are no tool calls
199
- await toolBatch.clear();
200
- }
201
- }
202
- catch (error) {
203
- // Run was stopped — unwind quietly without surfacing an error.
204
- if (abortSignal?.aborted)
205
- return;
206
- const errorMessage = error instanceof Error ? error.message : String(error);
207
- if (isCreditsCloudAgent(context.state.agentId)) {
208
- if (isCreditsErrorMessage(errorMessage)) {
209
- yield {
210
- type: 'agent:output',
211
- data: {
212
- content: 'Insufficient workspace credits. Add credits in workspace settings or switch this agent to BYOK mode.',
213
- },
214
- meta: { agentId: context.state.agentId, threadId },
215
- };
166
+ catch (error) {
167
+ lastError = error;
168
+ if (abortSignal?.aborted)
216
169
  return;
170
+ const errorMessage = error instanceof Error ? error.message : String(error);
171
+ if (isCreditsErrorMessage(errorMessage) || isAuthErrorMessage(errorMessage)) {
172
+ throw error;
217
173
  }
218
- if (isAuthErrorMessage(errorMessage)) {
219
- yield {
220
- type: 'agent:output',
221
- data: {
222
- content: 'Could not authenticate via OpenBot Credits. Check your workspace credit balance in settings, or switch this agent to BYOK mode.',
223
- },
224
- meta: { agentId: context.state.agentId, threadId },
225
- };
226
- return;
174
+ if (!isRetryableModelError(error) || i === candidates.length - 1) {
175
+ throw error;
227
176
  }
228
177
  }
229
- if (isAuthErrorMessage(errorMessage) && !isCreditsCloudAgent(context.state.agentId)) {
230
- const registry = await fetchModelRegistry();
231
- const providerActions = listApiKeyProviders(registry).map((provider) => ({
232
- id: provider.id,
233
- label: provider.label,
234
- variant: 'primary',
235
- }));
178
+ }
179
+ if (!started && lastError)
180
+ throw lastError;
181
+ }
182
+ catch (error) {
183
+ if (abortSignal?.aborted)
184
+ return;
185
+ const errorMessage = error instanceof Error ? error.message : String(error);
186
+ if (isCreditsCloudAgent(state.agentId)) {
187
+ if (isCreditsErrorMessage(errorMessage)) {
236
188
  yield {
237
- type: 'client:ui:widget',
189
+ type: 'agent:output',
238
190
  data: {
239
- kind: 'choice',
240
- widgetId: `api_provider_selection_${Date.now()}`,
241
- title: `Setup AI Provider`,
242
- description: `Select a provider to continue.`,
243
- actions: providerActions,
244
- metadata: {
245
- type: 'api_provider_selection',
246
- },
191
+ content: 'Insufficient workspace credits. Add credits in workspace settings or switch this agent to BYOK mode.',
247
192
  },
248
- meta: { agentId: context.state.agentId, threadId },
193
+ meta: { agentId: state.agentId, threadId },
249
194
  };
250
195
  return;
251
196
  }
252
- throw error;
253
- }
254
- };
255
- builder.on('agent:invoke', async function* (event, context) {
256
- const routedTo = event.data?.agentId;
257
- if (typeof routedTo === 'string' && routedTo && routedTo !== context.state.agentId) {
258
- return;
259
- }
260
- // Capture user info from meta if available
261
- if (event.meta?.userName) {
262
- context.state.currentUser = {
263
- userName: event.meta.userName,
264
- };
265
- }
266
- const threadId = event.meta?.threadId || context.state.threadId;
267
- // clear the tool batch if the agent is invoked
268
- // this is to prevent the tool batch from being used for a new agent invocation
269
- await createToolBatchTracker(context.state, storage, context.state.channelId, threadId).clear();
270
- yield* runLLM(context, threadId, event);
271
- });
272
- // this is to handle the tool results from the tool calls
273
- // because Melony runtime handles them one by one, thats why we need to record the result
274
- builder.on('*', async function* (event, context) {
275
- if (!event.type.endsWith(':result'))
276
- return;
277
- if (event.meta?.agentId !== context.state.agentId)
278
- return;
279
- const toolCallId = event.meta?.toolCallId;
280
- // record the result of the tool call
281
- if (!toolCallId ||
282
- !(await createToolBatchTracker(context.state, storage, context.state.channelId, event.meta?.threadId || context.state.threadId).recordResult(toolCallId)))
283
- return;
284
- const threadId = event.meta?.threadId || context.state.threadId;
285
- yield* runLLM(context, threadId);
286
- });
287
- builder.on('client:ui:widget:response', async function* (event, context) {
288
- const { metadata, values, actionId } = event.data;
289
- const threadId = event.meta?.threadId || context.state.threadId;
290
- if (isCreditsCloudAgent(context.state.agentId))
291
- return;
292
- if (metadata?.type === 'api_provider_selection') {
293
- const provider = actionId;
294
- const configuredModelId = isAutoModel(configuredModelString)
295
- ? ''
296
- : configuredModelString.split('/').slice(1).join('/');
297
- const registry = await fetchModelRegistry();
298
- const providerData = registry?.providers?.[provider];
299
- const label = providerData?.label || provider;
300
- const link = PROVIDER_API_KEY_LINKS[provider] || '';
301
- const modelOptions = getProviderModelOptions(registry, provider);
302
- yield {
303
- type: 'client:ui:widget',
304
- data: {
305
- widgetId: event.data.widgetId,
306
- kind: 'message',
307
- title: 'Provider Selected',
308
- body: `${label} provider was selected.`,
309
- state: 'submitted',
310
- display: 'collapsed',
311
- disabled: true,
312
- actions: [],
313
- },
314
- meta: { agentId: context.state.agentId, threadId },
315
- };
316
- if (modelOptions && modelOptions.length > 0 && !isAutoModel(configuredModelString)) {
317
- const defaultModelId = modelOptions.find((m) => m.value === configuredModelId)?.value || modelOptions[0].value;
197
+ if (isAuthErrorMessage(errorMessage)) {
318
198
  yield {
319
- type: 'client:ui:widget',
199
+ type: 'agent:output',
320
200
  data: {
321
- kind: 'choice',
322
- widgetId: `api_model_selection_${Date.now()}`,
323
- title: 'Select Model',
324
- description: `Choose a ${label} model.`,
325
- actions: modelOptions.map((option) => ({
326
- id: option.value,
327
- label: option.label,
328
- variant: option.value === defaultModelId ? 'primary' : 'secondary',
329
- })),
330
- metadata: {
331
- type: 'api_model_selection',
332
- provider,
333
- },
201
+ content: 'Could not authenticate via OpenBot Credits. Check your workspace credit balance in settings, or switch this agent to BYOK mode.',
334
202
  },
335
- meta: { agentId: context.state.agentId, threadId },
203
+ meta: { agentId: state.agentId, threadId },
336
204
  };
337
205
  return;
338
206
  }
339
- yield {
340
- type: 'client:ui:widget',
341
- data: {
342
- kind: 'form',
343
- widgetId: `api_key_request_${Date.now()}`,
344
- title: `${label} Setup`,
345
- description: isAutoModel(configuredModelString)
346
- ? `Enter your API key to continue.`
347
- : `Enter your API key and model ID.`,
348
- fields: [
349
- ...(isAutoModel(configuredModelString)
350
- ? []
351
- : [
352
- {
353
- id: 'model',
354
- label: 'Model',
355
- type: 'text',
356
- description: 'Enter the model ID for this provider.',
357
- placeholder: provider === 'openai'
358
- ? 'gpt-4o-mini'
359
- : provider === 'anthropic'
360
- ? 'claude-3-5-sonnet-20241022'
361
- : provider === 'deepseek'
362
- ? 'deepseek-chat'
363
- : 'gemini-2.0-flash',
364
- required: true,
365
- defaultValue: configuredModelId || '',
366
- },
367
- ]),
368
- {
369
- id: 'apiKey',
370
- label: 'API Key',
371
- type: 'password',
372
- description: `Get your key here: [${link}](${link})`,
373
- placeholder: `sk-...`,
374
- required: true,
375
- },
376
- ],
377
- submitLabel: 'Save & Continue',
378
- metadata: {
379
- type: 'api_key_request',
380
- provider,
381
- },
382
- },
383
- meta: { agentId: context.state.agentId, threadId },
384
- };
385
- return;
386
207
  }
387
- if (metadata?.type === 'api_model_selection') {
388
- const provider = String(metadata.provider);
389
- const modelId = String(actionId).trim();
208
+ if (isAuthErrorMessage(errorMessage) && !isCreditsCloudAgent(state.agentId)) {
390
209
  const registry = await fetchModelRegistry();
391
- const providerData = registry?.providers?.[provider];
392
- const label = providerData?.label || provider;
393
- const modelLabel = providerData?.models.find((model) => model.id === modelId)?.label || modelId;
394
- const link = PROVIDER_API_KEY_LINKS[provider] || '';
210
+ const providerActions = listApiKeyProviders(registry).map((provider) => ({
211
+ id: provider.id,
212
+ label: provider.label,
213
+ variant: 'primary',
214
+ }));
395
215
  yield {
396
216
  type: 'client:ui:widget',
397
217
  data: {
398
- widgetId: event.data.widgetId,
399
- kind: 'message',
400
- title: 'Model Selected',
401
- body: `${modelLabel} was selected.`,
402
- state: 'submitted',
403
- display: 'collapsed',
404
- disabled: true,
405
- actions: [],
218
+ kind: 'choice',
219
+ widgetId: `api_provider_selection_${Date.now()}`,
220
+ title: `Setup AI Provider`,
221
+ description: `Select a provider to continue.`,
222
+ actions: providerActions,
223
+ metadata: { type: 'api_provider_selection' },
406
224
  },
407
- meta: { agentId: context.state.agentId, threadId },
225
+ meta: { agentId: state.agentId, threadId },
408
226
  };
227
+ return;
228
+ }
229
+ throw error;
230
+ }
231
+ }
232
+ export async function* handleOpenbotWidgetResponse(args, options) {
233
+ const event = args.event;
234
+ const { metadata, values, actionId } = event.data;
235
+ const threadId = args.threadId;
236
+ const { host, storage } = options;
237
+ let configuredModelString = options.model ?? AUTO_MODEL_ID;
238
+ const state = args.state;
239
+ if (host.isCloudSystemAgent(state.agentId) && options.authMode === 'credits')
240
+ return;
241
+ if (metadata?.type === 'api_provider_selection') {
242
+ const provider = actionId;
243
+ const configuredModelId = isAutoModel(configuredModelString)
244
+ ? ''
245
+ : configuredModelString.split('/').slice(1).join('/');
246
+ const registry = await fetchModelRegistry();
247
+ const providerData = registry?.providers?.[provider];
248
+ const label = providerData?.label || provider;
249
+ const link = PROVIDER_API_KEY_LINKS[provider] || '';
250
+ const modelOptions = getProviderModelOptions(registry, provider);
251
+ yield {
252
+ type: 'client:ui:widget',
253
+ data: {
254
+ widgetId: event.data.widgetId,
255
+ kind: 'message',
256
+ title: 'Provider Selected',
257
+ body: `${label} provider was selected.`,
258
+ state: 'submitted',
259
+ display: 'collapsed',
260
+ disabled: true,
261
+ actions: [],
262
+ },
263
+ meta: { agentId: state.agentId, threadId },
264
+ };
265
+ if (modelOptions && modelOptions.length > 0 && !isAutoModel(configuredModelString)) {
266
+ const defaultModelId = modelOptions.find((m) => m.value === configuredModelId)?.value || modelOptions[0].value;
409
267
  yield {
410
268
  type: 'client:ui:widget',
411
269
  data: {
412
- kind: 'form',
413
- widgetId: `api_key_request_${Date.now()}`,
414
- title: `${label} Setup`,
415
- description: `Enter your API key to continue.`,
416
- fields: [
417
- {
418
- id: 'apiKey',
419
- label: 'API Key',
420
- type: 'password',
421
- description: `Get your key here: [${link}](${link})`,
422
- placeholder: `sk-...`,
423
- required: true,
424
- },
425
- ],
426
- submitLabel: 'Save & Continue',
427
- metadata: {
428
- type: 'api_key_request',
429
- provider,
430
- model: modelId,
431
- },
270
+ kind: 'choice',
271
+ widgetId: `api_model_selection_${Date.now()}`,
272
+ title: 'Select Model',
273
+ description: `Choose a ${label} model.`,
274
+ actions: modelOptions.map((option) => ({
275
+ id: option.value,
276
+ label: option.label,
277
+ variant: option.value === defaultModelId ? 'primary' : 'secondary',
278
+ })),
279
+ metadata: { type: 'api_model_selection', provider },
432
280
  },
433
- meta: { agentId: context.state.agentId, threadId },
434
- };
435
- return;
436
- }
437
- if (metadata?.type !== 'api_key_request')
438
- return;
439
- if (!values?.apiKey)
440
- return;
441
- const provider = String(values.provider || metadata.provider);
442
- const keepAuto = isAutoModel(configuredModelString);
443
- const modelId = String(values.model || metadata.model || '').trim();
444
- if (!keepAuto && !modelId)
445
- return;
446
- const apiKey = String(values.apiKey);
447
- if (provider !== 'openai' &&
448
- provider !== 'anthropic' &&
449
- provider !== 'google' &&
450
- provider !== 'deepseek') {
451
- yield {
452
- type: 'agent:output',
453
- data: { content: `Unsupported provider: ${provider}` },
454
- meta: { agentId: context.state.agentId },
281
+ meta: { agentId: state.agentId, threadId },
455
282
  };
456
283
  return;
457
284
  }
458
- const envVar = provider === 'openai'
459
- ? 'OPENAI_API_KEY'
460
- : provider === 'anthropic'
461
- ? 'ANTHROPIC_API_KEY'
462
- : provider === 'deepseek'
463
- ? 'DEEPSEEK_API_KEY'
464
- : 'GOOGLE_GENERATIVE_AI_API_KEY';
465
- const newModelString = keepAuto ? AUTO_MODEL_ID : `${provider}/${modelId}`;
466
- if (!storage)
467
- return;
468
- try {
469
- await storage.createVariable({ key: envVar, value: apiKey, secret: true });
470
- process.env[envVar] = apiKey;
471
- if (!keepAuto) {
472
- configuredModelString = newModelString;
473
- }
474
- try {
475
- if (!keepAuto) {
476
- host.saveConfig({ model: configuredModelString });
477
- }
478
- const details = await storage.getAgentDetails({ agentId: context.state.agentId });
479
- const updatedPlugins = details.pluginRefs.map((ref) => {
480
- if (ref.id === host.openbotPluginId) {
481
- return {
482
- ...ref,
483
- config: {
484
- ...ref.config,
485
- ...(keepAuto ? {} : { model: configuredModelString }),
486
- ...(host.isCloudSystemAgent(context.state.agentId) ? { authMode: 'byok' } : {}),
285
+ yield {
286
+ type: 'client:ui:widget',
287
+ data: {
288
+ kind: 'form',
289
+ widgetId: `api_key_request_${Date.now()}`,
290
+ title: `${label} Setup`,
291
+ description: isAutoModel(configuredModelString)
292
+ ? `Enter your API key to continue.`
293
+ : `Enter your API key and model ID.`,
294
+ fields: [
295
+ ...(isAutoModel(configuredModelString)
296
+ ? []
297
+ : [
298
+ {
299
+ id: 'model',
300
+ label: 'Model',
301
+ type: 'text',
302
+ required: true,
303
+ defaultValue: configuredModelId || '',
487
304
  },
488
- };
489
- }
490
- return ref;
491
- });
492
- await storage.updateAgent({
493
- agentId: context.state.agentId,
494
- plugins: updatedPlugins,
495
- });
496
- }
497
- catch {
498
- // best-effort: config persistence failure shouldn't block the conversation
499
- }
500
- yield {
501
- type: 'agent:output',
502
- data: {
503
- content: keepAuto
504
- ? `Saved ${provider} API key. Auto will use it when that provider is the cheapest available.`
505
- : `Saved ${provider} API key and set model to \`${newModelString}\`.`,
506
- },
507
- meta: { agentId: context.state.agentId },
508
- };
509
- yield {
510
- type: 'client:ui:widget',
511
- data: {
512
- widgetId: event.data.widgetId,
513
- kind: 'message',
514
- title: 'API Key Saved',
515
- body: keepAuto
516
- ? `Successfully saved ${provider} API key. You can now continue your conversation.`
517
- : `Successfully saved ${provider} API key and selected model \`${newModelString}\`. You can now continue your conversation.`,
518
- state: 'submitted',
519
- display: 'collapsed',
520
- disabled: true,
521
- actions: [],
522
- },
523
- meta: { agentId: context.state.agentId },
524
- };
525
- yield* runLLM(context, threadId);
305
+ ]),
306
+ {
307
+ id: 'apiKey',
308
+ label: 'API Key',
309
+ type: 'password',
310
+ description: `Get your key here: [${link}](${link})`,
311
+ required: true,
312
+ },
313
+ ],
314
+ submitLabel: 'Save & Continue',
315
+ metadata: { type: 'api_key_request', provider },
316
+ },
317
+ meta: { agentId: state.agentId, threadId },
318
+ };
319
+ return;
320
+ }
321
+ if (metadata?.type === 'api_model_selection') {
322
+ const provider = String(metadata.provider);
323
+ const modelId = String(actionId).trim();
324
+ const registry = await fetchModelRegistry();
325
+ const providerData = registry?.providers?.[provider];
326
+ const label = providerData?.label || provider;
327
+ const link = PROVIDER_API_KEY_LINKS[provider] || '';
328
+ yield {
329
+ type: 'client:ui:widget',
330
+ data: {
331
+ widgetId: event.data.widgetId,
332
+ kind: 'message',
333
+ title: 'Model Selected',
334
+ body: `${modelId} was selected.`,
335
+ state: 'submitted',
336
+ display: 'collapsed',
337
+ disabled: true,
338
+ actions: [],
339
+ },
340
+ meta: { agentId: state.agentId, threadId },
341
+ };
342
+ yield {
343
+ type: 'client:ui:widget',
344
+ data: {
345
+ kind: 'form',
346
+ widgetId: `api_key_request_${Date.now()}`,
347
+ title: `${label} Setup`,
348
+ description: `Enter your API key to continue.`,
349
+ fields: [
350
+ {
351
+ id: 'apiKey',
352
+ label: 'API Key',
353
+ type: 'password',
354
+ description: `Get your key here: [${link}](${link})`,
355
+ required: true,
356
+ },
357
+ ],
358
+ submitLabel: 'Save & Continue',
359
+ metadata: { type: 'api_key_request', provider, model: modelId },
360
+ },
361
+ meta: { agentId: state.agentId, threadId },
362
+ };
363
+ return;
364
+ }
365
+ if (metadata?.type !== 'api_key_request' || !values?.apiKey || !storage)
366
+ return;
367
+ const provider = String(values.provider || metadata.provider);
368
+ const keepAuto = isAutoModel(configuredModelString);
369
+ const modelId = String(values.model || metadata.model || '').trim();
370
+ if (!keepAuto && !modelId)
371
+ return;
372
+ const apiKey = String(values.apiKey);
373
+ if (provider !== 'openai' &&
374
+ provider !== 'anthropic' &&
375
+ provider !== 'google' &&
376
+ provider !== 'deepseek') {
377
+ yield {
378
+ type: 'agent:output',
379
+ data: { content: `Unsupported provider: ${provider}` },
380
+ meta: { agentId: state.agentId },
381
+ };
382
+ return;
383
+ }
384
+ const envVar = provider === 'openai'
385
+ ? 'OPENAI_API_KEY'
386
+ : provider === 'anthropic'
387
+ ? 'ANTHROPIC_API_KEY'
388
+ : provider === 'deepseek'
389
+ ? 'DEEPSEEK_API_KEY'
390
+ : 'GOOGLE_GENERATIVE_AI_API_KEY';
391
+ const newModelString = keepAuto ? AUTO_MODEL_ID : `${provider}/${modelId}`;
392
+ await storage.createVariable({ key: envVar, value: apiKey, secret: true });
393
+ process.env[envVar] = apiKey;
394
+ if (!keepAuto) {
395
+ configuredModelString = newModelString;
396
+ try {
397
+ host.saveConfig({ model: configuredModelString });
526
398
  }
527
- catch (error) {
528
- yield {
529
- type: 'agent:output',
530
- data: {
531
- content: `Failed to save API key: ${error instanceof Error ? error.message : 'Unknown error'}`,
532
- },
533
- meta: { agentId: context.state.agentId },
534
- };
399
+ catch {
400
+ /* ignore */
535
401
  }
536
- });
537
- };
402
+ }
403
+ yield {
404
+ type: 'agent:output',
405
+ data: {
406
+ content: keepAuto
407
+ ? `Saved ${provider} API key. Auto will use it when that provider is the cheapest available.`
408
+ : `Saved ${provider} API key and set model to \`${newModelString}\`.`,
409
+ },
410
+ meta: { agentId: state.agentId },
411
+ };
412
+ yield* runOpenbotTurn(args, { ...options, model: configuredModelString });
413
+ }