thepopebot 1.2.76-beta.17 → 1.2.76-beta.19

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/lib/ai/CLAUDE.md CHANGED
@@ -46,11 +46,30 @@ The "job" sub-mode is no longer wired — a skill will replace autonomous job di
46
46
 
47
47
  On the first message in a new chat, `chatStream` yields a visible `tool-call`/`tool-result` pair with `toolName: 'workspace'` so the setup appears in the UI.
48
48
 
49
- ## Utility LLM Calls
49
+ ## Helper LLM (`helper-llm.js`)
50
50
 
51
- `createModel()` in `model.js` remains LangChain-based for two utility calls: `autoTitle()` (2-5 word chat title on first message) and `summarizeAgentJob()` (webhook-triggered PR merge summary). These use `LLM_PROVIDER` + `LLM_MODEL` configured via `/admin/event-handler/chat`.
51
+ Small one-shot completions used by the event handler itself. Independent of the coding agent it has its own provider/model selection at `/admin/event-handler/helper-llm`.
52
52
 
53
- Phase 2 will replace `createModel()` with a tiny fetch-based multi-provider client (or route utility calls through the active coding agent's credentials) and drop the remaining `@langchain/*` dependencies.
53
+ **Callers:**
54
+ - `autoTitle()` (chat title — 2-5 word title for "New Chat")
55
+ - `summarizeAgentJob()` (webhook-triggered PR merge summary)
56
+ - `generateAgentJobTitle()` (~10 word title for an agent job)
57
+
58
+ **API:**
59
+ - `callHelperLlm({system, user, maxTokens})` → returns trimmed text (uses AI SDK `generateText`)
60
+ - `callHelperLlmStructured({system, user, schema, maxTokens})` → returns parsed object (uses AI SDK `generateObject`); throws on schema/parse failure (callers catch and fall back)
61
+
62
+ **Provider resolution.** Reads `LLM_PROVIDER` and `LLM_MODEL` from config and builds the right AI SDK adapter:
63
+
64
+ | Provider slug | AI SDK adapter |
65
+ |---|---|
66
+ | `anthropic` | `@ai-sdk/anthropic` |
67
+ | `openai` | `@ai-sdk/openai` |
68
+ | `google` | `@ai-sdk/google` |
69
+ | built-in OpenAI-compatible (`deepseek`, `mistral`, `xai`, `kimi`, `openrouter`, `nvidia`) | `@ai-sdk/openai-compatible` with each provider's `baseUrl` from `BUILTIN_PROVIDERS` |
70
+ | custom user-added | `@ai-sdk/openai-compatible` with the custom provider's `baseUrl` and `apiKey` |
71
+
72
+ The AI SDK handles per-provider quirks (max-token param naming, thinking/reasoning block stripping, structured output via the right native mechanism per provider). Helper LLM has no LangChain dependency.
54
73
 
55
74
  ### LLM Providers
56
75
 
@@ -70,9 +89,7 @@ Source of truth: `lib/llm-providers.js` (`BUILTIN_PROVIDERS`).
70
89
 
71
90
  All credentials are stored in the settings DB (encrypted). `LLM_MAX_TOKENS` defaults to 4096.
72
91
 
73
- **Custom providers**: users can add OpenAI-compatible providers via `/admin/event-handler/llms`. Stored as `type: 'llm_provider'` in the settings table. Resolved in `model.js` via `getCustomProvider()`.
74
-
75
- > **Google model compatibility note:** `gemini-2.5-pro` and `gemini-3.*` require `thought_signature` round-tripping that `@langchain/google-genai` doesn't support. Auto-falls back to `gemini-2.5-flash` (issue #201).
92
+ **Custom providers**: users can add OpenAI-compatible providers via `/admin/event-handler/llms`. Stored as `type: 'llm_provider'` in the settings table. Resolved at call time via `getCustomProvider()` in `helper-llm.js`.
76
93
 
77
94
  ## Headless Stream Parser (headless-stream.js)
78
95
 
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Helper LLM — small one-shot completions used by the event handler itself
3
+ * (chat titles, agent-job summaries, agent-job titles). Independent of the
4
+ * coding agent and the streaming chat path.
5
+ *
6
+ * Provider/model is set at /admin/event-handler/helper-llm and stored as
7
+ * LLM_PROVIDER / LLM_MODEL config keys. Credentials live in the same settings
8
+ * DB used by /admin/event-handler/llms.
9
+ */
10
+
11
+ import { generateText, generateObject } from 'ai';
12
+ import { createAnthropic } from '@ai-sdk/anthropic';
13
+ import { createOpenAI } from '@ai-sdk/openai';
14
+ import { createGoogleGenerativeAI } from '@ai-sdk/google';
15
+ import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
16
+ import { getConfig } from '../config.js';
17
+ import { getCustomProvider } from '../db/config.js';
18
+ import { BUILTIN_PROVIDERS } from '../llm-providers.js';
19
+
20
+ /**
21
+ * Build the active LanguageModelV2 instance for helper LLM calls.
22
+ * Reads LLM_PROVIDER + LLM_MODEL from config and selects the right adapter.
23
+ *
24
+ * @returns {import('ai').LanguageModelV2}
25
+ */
26
+ function resolveModel() {
27
+ const slug = getConfig('LLM_PROVIDER');
28
+ const modelName = getConfig('LLM_MODEL');
29
+ if (!slug) throw new Error('LLM_PROVIDER not configured');
30
+ if (!modelName) throw new Error('LLM_MODEL not configured');
31
+
32
+ if (slug === 'anthropic') {
33
+ return createAnthropic({ apiKey: getConfig('ANTHROPIC_API_KEY') })(modelName);
34
+ }
35
+ if (slug === 'google') {
36
+ return createGoogleGenerativeAI({ apiKey: getConfig('GOOGLE_API_KEY') })(modelName);
37
+ }
38
+ if (slug === 'openai') {
39
+ return createOpenAI({ apiKey: getConfig('OPENAI_API_KEY') })(modelName);
40
+ }
41
+
42
+ // Built-in OpenAI-compatible providers (deepseek, mistral, xai, kimi, openrouter, nvidia)
43
+ const builtin = BUILTIN_PROVIDERS[slug];
44
+ if (builtin) {
45
+ if (!builtin.baseUrl) throw new Error(`Provider ${slug} has no baseUrl`);
46
+ return createOpenAICompatible({
47
+ name: slug,
48
+ baseURL: builtin.baseUrl,
49
+ apiKey: getConfig(builtin.credentials[0].key),
50
+ })(modelName);
51
+ }
52
+
53
+ // Custom user-added OpenAI-compatible provider
54
+ const custom = getCustomProvider(slug);
55
+ if (custom) {
56
+ return createOpenAICompatible({
57
+ name: slug,
58
+ baseURL: custom.baseUrl,
59
+ apiKey: custom.apiKey || 'not-needed',
60
+ })(modelName);
61
+ }
62
+
63
+ throw new Error(`Unknown LLM provider: ${slug}`);
64
+ }
65
+
66
+ /**
67
+ * Plain-text helper LLM call. Returns the trimmed text.
68
+ *
69
+ * @param {object} args
70
+ * @param {string} args.system - System prompt
71
+ * @param {string} args.user - User prompt
72
+ * @param {number} args.maxTokens - Max output tokens
73
+ * @returns {Promise<string>}
74
+ */
75
+ export async function callHelperLlm({ system, user, maxTokens }) {
76
+ const model = resolveModel();
77
+ const { text } = await generateText({
78
+ model,
79
+ system,
80
+ prompt: user,
81
+ maxOutputTokens: maxTokens,
82
+ });
83
+ return (text || '').trim();
84
+ }
85
+
86
+ /**
87
+ * Structured helper LLM call. Returns the parsed object matching the schema.
88
+ * Throws if the response can't be parsed or fails schema validation —
89
+ * callers catch and fall back as appropriate.
90
+ *
91
+ * @param {object} args
92
+ * @param {string} args.system - System prompt
93
+ * @param {string} args.user - User prompt
94
+ * @param {import('zod').ZodTypeAny} args.schema - Zod schema for the output
95
+ * @param {number} args.maxTokens - Max output tokens
96
+ * @returns {Promise<unknown>}
97
+ */
98
+ export async function callHelperLlmStructured({ system, user, schema, maxTokens }) {
99
+ const model = resolveModel();
100
+ const { object } = await generateObject({
101
+ model,
102
+ system,
103
+ prompt: user,
104
+ schema,
105
+ maxOutputTokens: maxTokens,
106
+ });
107
+ return object;
108
+ }
package/lib/ai/index.js CHANGED
@@ -2,7 +2,7 @@ import { randomUUID } from 'crypto';
2
2
  import { z } from 'zod';
3
3
  import path from 'path';
4
4
  import { existsSync } from 'fs';
5
- import { createModel } from './model.js';
5
+ import { callHelperLlm, callHelperLlmStructured } from './helper-llm.js';
6
6
  import { PROJECT_ROOT } from '../paths.js';
7
7
  import { render_md } from '../utils/render-md.js';
8
8
  import { buildCodingAgentSystemPrompt } from './system-prompt.js';
@@ -392,15 +392,16 @@ async function autoTitle(threadId, firstMessage) {
392
392
  const chat = getChatById(threadId);
393
393
  if (!chat || chat.title !== 'New Chat') return;
394
394
 
395
- const model = await createModel({ maxTokens: 250 });
396
- const response = await model.withStructuredOutput(z.object({ title: z.string() })).invoke([
397
- ['system', 'Title this chat in 2-5 words. Name the subject matter only. Never start with "User". Never describe what the user is doing — just the topic. Always produce a title, even for vague messages — infer the likely topic.'],
398
- ['human', firstMessage],
399
- ]);
400
- if (response.title.trim()) {
401
- updateChatTitle(threadId, response.title.trim());
402
-
403
- return response.title.trim();
395
+ const result = await callHelperLlmStructured({
396
+ system: 'Title this chat in 2-5 words. Name the subject matter only. Never start with "User". Never describe what the user is doing — just the topic. Always produce a title, even for vague messages — infer the likely topic.',
397
+ user: firstMessage,
398
+ schema: z.object({ title: z.string() }),
399
+ maxTokens: 250,
400
+ });
401
+ const title = result?.title?.trim();
402
+ if (title) {
403
+ updateChatTitle(threadId, title);
404
+ return title;
404
405
  }
405
406
  } catch (err) {
406
407
  console.error('[autoTitle] Failed to generate title:', err.message);
@@ -414,7 +415,6 @@ async function autoTitle(threadId, firstMessage) {
414
415
  */
415
416
  async function summarizeAgentJob(results) {
416
417
  try {
417
- const model = await createModel({ maxTokens: 1024 });
418
418
  const summaryMdPath = path.join(PROJECT_ROOT, 'event-handler/SUMMARY.md');
419
419
  const systemPrompt = render_md(summaryMdPath);
420
420
 
@@ -437,22 +437,11 @@ async function summarizeAgentJob(results) {
437
437
 
438
438
  console.log(`[summarizeAgentJob] System prompt: ${systemPrompt.length} chars, user message: ${userMessage.length} chars`);
439
439
 
440
- const response = await model.invoke([
441
- ['system', systemPrompt],
442
- ['human', userMessage],
443
- ]);
444
-
445
- const text =
446
- typeof response.content === 'string'
447
- ? response.content
448
- : response.content
449
- .filter((block) => block.type === 'text')
450
- .map((block) => block.text)
451
- .join('\n');
440
+ const text = await callHelperLlm({ system: systemPrompt, user: userMessage, maxTokens: 1024 });
452
441
 
453
442
  console.log(`[summarizeAgentJob] Result: ${text.length} chars — ${text.slice(0, 200)}`);
454
443
 
455
- return text.trim() || 'Agent job finished.';
444
+ return text || 'Agent job finished.';
456
445
  } catch (err) {
457
446
  console.error('[summarizeAgentJob] Failed to summarize agent job:', err);
458
447
  return 'Agent job finished.';
@@ -17,7 +17,7 @@ Abstract interface for platform integrations. Methods:
17
17
  **Critical distinction**: Audio is preprocessed at the adapter layer. Images are passed through to the LLM.
18
18
 
19
19
  - **Images** (`message.photo`) → Downloaded, passed as `{ category: 'image', mimeType, data: Buffer }` attachment → LLM receives as vision content
20
- - **Audio** (`message.voice`/`message.audio`) → Transcribed via Whisper → merged into `text` field → **never passed as attachment**
20
+ - **Audio** (`message.voice`/`message.audio`) → Transcribed via AssemblyAI → merged into `text` field → **never passed as attachment**
21
21
  - **Documents** (`message.document`) → Downloaded as `{ category: 'document', mimeType, data: Buffer }`
22
22
 
23
23
  ## Factory (index.js) — Lazy Singleton
@@ -6,7 +6,7 @@ import {
6
6
  startTypingIndicator,
7
7
  formatToolCall,
8
8
  } from '../tools/telegram.js';
9
- import { isWhisperEnabled, transcribeAudio } from '../tools/openai.js';
9
+ import { isAssemblyAIEnabled, transcribeAudio } from '../tools/assemblyai.js';
10
10
  import { getConfig } from '../config.js';
11
11
 
12
12
  class TelegramAdapter extends ChannelAdapter {
@@ -48,17 +48,17 @@ class TelegramAdapter extends ChannelAdapter {
48
48
 
49
49
  // Voice messages → transcribe to text
50
50
  if (message.voice) {
51
- if (!isWhisperEnabled()) {
51
+ if (!isAssemblyAIEnabled()) {
52
52
  await sendMessage(
53
53
  this.botToken,
54
54
  chatId,
55
- 'Voice messages are not supported. Please set OPENAI_API_KEY to enable transcription.'
55
+ 'Voice messages are not supported. Please set ASSEMBLYAI_API_KEY to enable transcription.'
56
56
  );
57
57
  return null;
58
58
  }
59
59
  try {
60
- const { buffer, filename } = await downloadFile(this.botToken, message.voice.file_id);
61
- text = await transcribeAudio(buffer, filename);
60
+ const { buffer } = await downloadFile(this.botToken, message.voice.file_id);
61
+ text = await transcribeAudio(buffer);
62
62
  } catch (err) {
63
63
  console.error('Failed to transcribe voice:', err);
64
64
  await sendMessage(this.botToken, chatId, 'Sorry, I could not transcribe your voice message.');
@@ -68,17 +68,17 @@ class TelegramAdapter extends ChannelAdapter {
68
68
 
69
69
  // Audio messages → transcribe to text
70
70
  if (message.audio && !text) {
71
- if (!isWhisperEnabled()) {
71
+ if (!isAssemblyAIEnabled()) {
72
72
  await sendMessage(
73
73
  this.botToken,
74
74
  chatId,
75
- 'Audio messages are not supported. Please set OPENAI_API_KEY to enable transcription.'
75
+ 'Audio messages are not supported. Please set ASSEMBLYAI_API_KEY to enable transcription.'
76
76
  );
77
77
  return null;
78
78
  }
79
79
  try {
80
- const { buffer, filename } = await downloadFile(this.botToken, message.audio.file_id);
81
- text = await transcribeAudio(buffer, filename);
80
+ const { buffer } = await downloadFile(this.botToken, message.audio.file_id);
81
+ text = await transcribeAudio(buffer);
82
82
  } catch (err) {
83
83
  console.error('Failed to transcribe audio:', err);
84
84
  await sendMessage(this.botToken, chatId, 'Sorry, I could not transcribe your audio message.');
@@ -1123,7 +1123,7 @@ export async function registerTelegramWebhook() {
1123
1123
  // ─────────────────────────────────────────────────────────────────────────────
1124
1124
 
1125
1125
  /**
1126
- * Get settings for the Chat sub-tab.
1126
+ * Get settings for the Helper LLM page (and Providers page — both share this).
1127
1127
  */
1128
1128
  export async function getChatSettings() {
1129
1129
  await requireAuth();
@@ -1138,34 +1138,16 @@ export async function getChatSettings() {
1138
1138
  // Get custom providers (masked)
1139
1139
  const customProviders = getCustomProviders();
1140
1140
 
1141
- // Get active config values
1141
+ // Get active helper LLM config
1142
1142
  const activeProvider = getConfigValue('LLM_PROVIDER') || '';
1143
1143
  const activeModel = getConfigValue('LLM_MODEL') || '';
1144
1144
  const maxTokens = getConfigValue('LLM_MAX_TOKENS') || '4096';
1145
1145
  const agentBackend = getConfigValue('AGENT_BACKEND') || '';
1146
1146
 
1147
- // Check if the current coding agent has an in-process SDK adapter
1148
- const { getConfig } = await import('../config.js');
1149
- const { getSdkAdapter } = await import('../ai/sdk-adapters/index.js');
1150
- const codingAgent = getConfig('CODING_AGENT');
1151
- const sdkAgentActive = !!getSdkAdapter(codingAgent);
1152
-
1153
- // Human-readable agent names
1154
- const agentNames = {
1155
- 'claude-code': 'Claude Code',
1156
- 'pi-coding-agent': 'Pi Coding Agent',
1157
- 'gemini-cli': 'Gemini CLI',
1158
- 'codex-cli': 'Codex CLI',
1159
- 'opencode': 'OpenCode',
1160
- 'kimi-cli': 'Kimi CLI',
1161
- };
1162
-
1163
1147
  return {
1164
1148
  builtinProviders: BUILTIN_PROVIDERS,
1165
1149
  credentialStatuses,
1166
1150
  customProviders,
1167
- sdkAgentActive,
1168
- defaultAgent: agentNames[codingAgent] || codingAgent,
1169
1151
  active: {
1170
1152
  provider: activeProvider,
1171
1153
  model: activeModel,
@@ -1174,8 +1156,8 @@ export async function getChatSettings() {
1174
1156
  },
1175
1157
  };
1176
1158
  } catch (err) {
1177
- console.error('Failed to get chat settings:', err);
1178
- return { error: 'Failed to load chat settings' };
1159
+ console.error('Failed to get helper LLM settings:', err);
1160
+ return { error: 'Failed to load helper LLM settings' };
1179
1161
  }
1180
1162
  }
1181
1163
 
@@ -8,8 +8,8 @@ Admin pages live under `/admin/` with two top-level sections:
8
8
 
9
9
  - **`/admin/event-handler/`** — Event handler config with pill-style sub-tabs via `SubTabLayout` (`settings-secrets-layout.jsx`):
10
10
  - `/admin/event-handler/llms` — LLM provider credentials
11
- - `/admin/event-handler/chat` — Chat LLM settings
12
11
  - `/admin/event-handler/coding-agents` — Multi-agent config (5 backends)
12
+ - `/admin/event-handler/helper-llm` — Helper LLM settings (provider/model used for chat titles, agent-job titles + summaries)
13
13
  - `/admin/event-handler/jobs` — Agent job custom secrets
14
14
  - `/admin/event-handler/telegram` — Telegram integration
15
15
  - `/admin/event-handler/voice` — Voice input (AssemblyAI)
@@ -10,7 +10,7 @@ export { SettingsLayout } from './settings-layout.js';
10
10
  export { SubTabLayout, ApiKeysLayout, EventHandlerLayout, ChatSettingsLayout, GitHubSettingsLayout, SecretsLayout } from './settings-secrets-layout.js';
11
11
  export { ApiKeysListPage, ApiKeysVoicePage, ApiKeysTelegramPage, SettingsSecretsPage } from './settings-secrets-page.js';
12
12
  export { SettingsUsersPage } from './settings-users-page.js';
13
- export { ChatConfigPage, ChatProvidersPage, SettingsChatPage } from './settings-chat-page.js';
13
+ export { HelperLlmPage, ChatConfigPage, ChatProvidersPage, SettingsChatPage } from './settings-chat-page.js';
14
14
  export { ChatProvidersPage as LlmsPage } from './settings-chat-page.js';
15
15
  export { CodingAgentsPage } from './settings-coding-agents-page.js';
16
16
  export { JobsPage } from './settings-jobs-page.js';
@@ -14,7 +14,7 @@ import {
14
14
  getOAuthTokens,
15
15
  deleteOAuthToken
16
16
  } from "../actions.js";
17
- function ChatConfigPage() {
17
+ function HelperLlmPage() {
18
18
  const [settings, setSettings] = useState(null);
19
19
  const [loading, setLoading] = useState(true);
20
20
  const loadSettings = async () => {
@@ -40,20 +40,19 @@ function ChatConfigPage() {
40
40
  if (settings?.error) {
41
41
  return /* @__PURE__ */ jsx("p", { className: "text-sm text-destructive", children: settings.error });
42
42
  }
43
- const sdkAgentActive = settings?.sdkAgentActive;
44
- const defaultAgent = settings?.defaultAgent;
45
43
  return /* @__PURE__ */ jsxs("div", { children: [
46
- sdkAgentActive && /* @__PURE__ */ jsx("div", { className: "rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3 mb-4", children: /* @__PURE__ */ jsxs("p", { className: "text-sm text-destructive", children: [
47
- defaultAgent,
48
- " manages its own LLM directly. These settings only apply when using a coding agent without built-in SDK support."
49
- ] }) }),
50
44
  /* @__PURE__ */ jsxs("div", { className: "mb-4", children: [
51
- /* @__PURE__ */ jsx("h2", { className: "text-base font-medium", children: "Configuration" }),
52
- /* @__PURE__ */ jsx("p", { className: "text-sm text-muted-foreground", children: "Select the LLM provider and model for chat. Only providers with configured API keys appear in the dropdown." })
45
+ /* @__PURE__ */ jsx("h2", { className: "text-base font-medium", children: "Helper LLM" }),
46
+ /* @__PURE__ */ jsxs("p", { className: "text-sm text-muted-foreground", children: [
47
+ "Used for chat titles, agent-job titles, and agent-job summaries. Independent of your coding agent. Only providers with configured API keys appear in the dropdown \u2014 set keys at ",
48
+ /* @__PURE__ */ jsx("a", { href: "/admin/event-handler/llms", className: "underline hover:text-foreground", children: "Providers" }),
49
+ "."
50
+ ] })
53
51
  ] }),
54
- /* @__PURE__ */ jsx("div", { className: sdkAgentActive ? "opacity-50 pointer-events-none" : "", children: /* @__PURE__ */ jsx(ActiveConfig, { settings, onSave: handleSaveActive }) })
52
+ /* @__PURE__ */ jsx(ActiveConfig, { settings, onSave: handleSaveActive })
55
53
  ] });
56
54
  }
55
+ const ChatConfigPage = HelperLlmPage;
57
56
  function ActiveConfig({ settings, onSave }) {
58
57
  const [provider, setProvider] = useState("");
59
58
  const [model, setModel] = useState("");
@@ -738,10 +737,11 @@ function CustomProviderDialog({ open, initial, onSave, onCancel }) {
738
737
  ] });
739
738
  }
740
739
  function SettingsChatPage() {
741
- return /* @__PURE__ */ jsx(ChatConfigPage, {});
740
+ return /* @__PURE__ */ jsx(HelperLlmPage, {});
742
741
  }
743
742
  export {
744
743
  ChatConfigPage,
745
744
  ChatProvidersPage,
745
+ HelperLlmPage,
746
746
  SettingsChatPage
747
747
  };
@@ -16,10 +16,14 @@ import {
16
16
  } from '../actions.js';
17
17
 
18
18
  // ─────────────────────────────────────────────────────────────────────────────
19
- // Configuration sub-tab (auto-save)
19
+ // Helper LLM page (auto-save)
20
+ //
21
+ // Picks the provider + model used for short background generations: chat
22
+ // titles, agent-job titles, and agent-job summaries. Independent of the
23
+ // coding agent. Credentials live at /admin/event-handler/llms.
20
24
  // ─────────────────────────────────────────────────────────────────────────────
21
25
 
22
- export function ChatConfigPage() {
26
+ export function HelperLlmPage() {
23
27
  const [settings, setSettings] = useState(null);
24
28
  const [loading, setLoading] = useState(true);
25
29
 
@@ -52,29 +56,21 @@ export function ChatConfigPage() {
52
56
  return <p className="text-sm text-destructive">{settings.error}</p>;
53
57
  }
54
58
 
55
- const sdkAgentActive = settings?.sdkAgentActive;
56
- const defaultAgent = settings?.defaultAgent;
57
-
58
59
  return (
59
60
  <div>
60
- {sdkAgentActive && (
61
- <div className="rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3 mb-4">
62
- <p className="text-sm text-destructive">
63
- {defaultAgent} manages its own LLM directly. These settings only apply when using a coding agent without built-in SDK support.
64
- </p>
65
- </div>
66
- )}
67
61
  <div className="mb-4">
68
- <h2 className="text-base font-medium">Configuration</h2>
69
- <p className="text-sm text-muted-foreground">Select the LLM provider and model for chat. Only providers with configured API keys appear in the dropdown.</p>
70
- </div>
71
- <div className={sdkAgentActive ? 'opacity-50 pointer-events-none' : ''}>
72
- <ActiveConfig settings={settings} onSave={handleSaveActive} />
62
+ <h2 className="text-base font-medium">Helper LLM</h2>
63
+ <p className="text-sm text-muted-foreground">Used for chat titles, agent-job titles, and agent-job summaries. Independent of your coding agent. Only providers with configured API keys appear in the dropdown — set keys at <a href="/admin/event-handler/llms" className="underline hover:text-foreground">Providers</a>.</p>
73
64
  </div>
65
+ <ActiveConfig settings={settings} onSave={handleSaveActive} />
74
66
  </div>
75
67
  );
76
68
  }
77
69
 
70
+ // Backwards-compat alias — kept so route files importing the old name keep working
71
+ // during the route move.
72
+ export const ChatConfigPage = HelperLlmPage;
73
+
78
74
  function ActiveConfig({ settings, onSave }) {
79
75
  const [provider, setProvider] = useState('');
80
76
  const [model, setModel] = useState('');
@@ -777,5 +773,5 @@ function CustomProviderDialog({ open, initial, onSave, onCancel }) {
777
773
 
778
774
  // Backwards compat
779
775
  export function SettingsChatPage() {
780
- return <ChatConfigPage />;
776
+ return <HelperLlmPage />;
781
777
  }
@@ -29,7 +29,7 @@ const API_KEYS_TABS = [
29
29
  const EVENT_HANDLER_TABS = [
30
30
  { id: "llms", label: "LLMs", href: "/admin/event-handler/llms" },
31
31
  { id: "coding-agents", label: "Coding Agents", href: "/admin/event-handler/coding-agents" },
32
- { id: "chat", label: "Chat", href: "/admin/event-handler/chat" },
32
+ { id: "helper-llm", label: "Helper LLM", href: "/admin/event-handler/helper-llm" },
33
33
  { id: "agent-secrets", label: "Agent Secrets", href: "/admin/event-handler/agent-secrets" },
34
34
  { id: "webhooks", label: "Webhooks", href: "/admin/event-handler/webhooks" },
35
35
  { id: "telegram", label: "Telegram", href: "/admin/event-handler/telegram" },
@@ -51,7 +51,7 @@ const API_KEYS_TABS = [
51
51
  const EVENT_HANDLER_TABS = [
52
52
  { id: 'llms', label: 'LLMs', href: '/admin/event-handler/llms' },
53
53
  { id: 'coding-agents', label: 'Coding Agents', href: '/admin/event-handler/coding-agents' },
54
- { id: 'chat', label: 'Chat', href: '/admin/event-handler/chat' },
54
+ { id: 'helper-llm', label: 'Helper LLM', href: '/admin/event-handler/helper-llm' },
55
55
  { id: 'agent-secrets', label: 'Agent Secrets', href: '/admin/event-handler/agent-secrets' },
56
56
  { id: 'webhooks', label: 'Webhooks', href: '/admin/event-handler/webhooks' },
57
57
  { id: 'telegram', label: 'Telegram', href: '/admin/event-handler/telegram' },
@@ -30,11 +30,30 @@ function DropdownMenuContent({ children, className, align = "start", side = "bot
30
30
  const updatePosition = useCallback(() => {
31
31
  if (!triggerRef.current) return;
32
32
  const rect = triggerRef.current.getBoundingClientRect();
33
+ const margin = 8;
34
+ let left = align === "start" ? rect.left : void 0;
35
+ let right = align === "end" ? window.innerWidth - rect.right : void 0;
36
+ const menuWidth = ref.current?.getBoundingClientRect().width || 0;
37
+ if (menuWidth > 0) {
38
+ if (align === "end") {
39
+ const computedLeft = rect.right - menuWidth;
40
+ if (computedLeft < margin) {
41
+ right = void 0;
42
+ left = margin;
43
+ }
44
+ } else {
45
+ const computedRight = rect.left + menuWidth;
46
+ if (computedRight > window.innerWidth - margin) {
47
+ left = void 0;
48
+ right = margin;
49
+ }
50
+ }
51
+ }
33
52
  setPos({
34
53
  top: side === "bottom" ? rect.bottom + sideOffset : void 0,
35
54
  bottom: side === "top" ? window.innerHeight - rect.top + sideOffset : void 0,
36
- left: align === "start" ? rect.left : void 0,
37
- right: align === "end" ? window.innerWidth - rect.right : void 0
55
+ left,
56
+ right
38
57
  });
39
58
  }, [triggerRef, side, align, sideOffset]);
40
59
  useEffect(() => {
@@ -43,6 +62,7 @@ function DropdownMenuContent({ children, className, align = "start", side = "bot
43
62
  return;
44
63
  }
45
64
  updatePosition();
65
+ const raf = requestAnimationFrame(updatePosition);
46
66
  const handleClickOutside = (e) => {
47
67
  if (ref.current && !ref.current.contains(e.target) && triggerRef.current && !triggerRef.current.contains(e.target)) {
48
68
  onOpenChange(false);
@@ -56,6 +76,7 @@ function DropdownMenuContent({ children, className, align = "start", side = "bot
56
76
  document.addEventListener("keydown", handleEsc);
57
77
  window.addEventListener("scroll", handleScroll, true);
58
78
  return () => {
79
+ cancelAnimationFrame(raf);
59
80
  document.removeEventListener("click", handleClickOutside);
60
81
  document.removeEventListener("keydown", handleEsc);
61
82
  window.removeEventListener("scroll", handleScroll, true);
@@ -47,17 +47,41 @@ export function DropdownMenuContent({ children, className, align = 'start', side
47
47
  const updatePosition = useCallback(() => {
48
48
  if (!triggerRef.current) return;
49
49
  const rect = triggerRef.current.getBoundingClientRect();
50
+ const margin = 8;
51
+ let left = align === 'start' ? rect.left : undefined;
52
+ let right = align === 'end' ? window.innerWidth - rect.right : undefined;
53
+
54
+ // Clamp horizontally so the menu never overflows the viewport.
55
+ const menuWidth = ref.current?.getBoundingClientRect().width || 0;
56
+ if (menuWidth > 0) {
57
+ if (align === 'end') {
58
+ const computedLeft = rect.right - menuWidth;
59
+ if (computedLeft < margin) {
60
+ right = undefined;
61
+ left = margin;
62
+ }
63
+ } else {
64
+ const computedRight = rect.left + menuWidth;
65
+ if (computedRight > window.innerWidth - margin) {
66
+ left = undefined;
67
+ right = margin;
68
+ }
69
+ }
70
+ }
71
+
50
72
  setPos({
51
73
  top: side === 'bottom' ? rect.bottom + sideOffset : undefined,
52
74
  bottom: side === 'top' ? window.innerHeight - rect.top + sideOffset : undefined,
53
- left: align === 'start' ? rect.left : undefined,
54
- right: align === 'end' ? window.innerWidth - rect.right : undefined,
75
+ left,
76
+ right,
55
77
  });
56
78
  }, [triggerRef, side, align, sideOffset]);
57
79
 
58
80
  useEffect(() => {
59
81
  if (!open) { setPos(null); return; }
60
82
  updatePosition();
83
+ // Re-run after render so menu width is measurable for clamping.
84
+ const raf = requestAnimationFrame(updatePosition);
61
85
  const handleClickOutside = (e) => {
62
86
  if (ref.current && !ref.current.contains(e.target) && triggerRef.current && !triggerRef.current.contains(e.target)) {
63
87
  onOpenChange(false);
@@ -71,6 +95,7 @@ export function DropdownMenuContent({ children, className, align = 'start', side
71
95
  document.addEventListener('keydown', handleEsc);
72
96
  window.addEventListener('scroll', handleScroll, true);
73
97
  return () => {
98
+ cancelAnimationFrame(raf);
74
99
  document.removeEventListener('click', handleClickOutside);
75
100
  document.removeEventListener('keydown', handleEsc);
76
101
  window.removeEventListener('scroll', handleScroll, true);
@@ -62,6 +62,7 @@ export const BUILTIN_PROVIDERS = {
62
62
  credentials: [
63
63
  { type: 'api_key', key: 'DEEPSEEK_API_KEY', label: 'API Key' },
64
64
  ],
65
+ baseUrl: 'https://api.deepseek.com/v1',
65
66
  anthropicEndpoint: 'https://api.deepseek.com/anthropic',
66
67
  models: [
67
68
  { id: 'deepseek-chat', name: 'DeepSeek V3', default: true },
@@ -73,6 +74,7 @@ export const BUILTIN_PROVIDERS = {
73
74
  credentials: [
74
75
  { type: 'api_key', key: 'MINIMAX_API_KEY', label: 'API Key' },
75
76
  ],
77
+ baseUrl: 'https://api.minimax.io/v1',
76
78
  anthropicEndpoint: 'https://api.minimax.io/anthropic',
77
79
  models: [
78
80
  { id: 'MiniMax-M2.7', name: 'MiniMax M2.7', default: true },
@@ -84,6 +86,7 @@ export const BUILTIN_PROVIDERS = {
84
86
  mistral: {
85
87
  name: 'Mistral',
86
88
  litellmProxy: true, litellmPrefix: 'mistral',
89
+ baseUrl: 'https://api.mistral.ai/v1',
87
90
  credentials: [
88
91
  { type: 'api_key', key: 'MISTRAL_API_KEY', label: 'API Key' },
89
92
  ],
@@ -98,6 +101,7 @@ export const BUILTIN_PROVIDERS = {
98
101
  xai: {
99
102
  name: 'xAI',
100
103
  litellmProxy: true, litellmPrefix: 'xai',
104
+ baseUrl: 'https://api.x.ai/v1',
101
105
  credentials: [
102
106
  { type: 'api_key', key: 'XAI_API_KEY', label: 'API Key' },
103
107
  ],
@@ -113,6 +117,7 @@ export const BUILTIN_PROVIDERS = {
113
117
  credentials: [
114
118
  { type: 'api_key', key: 'MOONSHOT_API_KEY', label: 'API Key' },
115
119
  ],
120
+ baseUrl: 'https://api.moonshot.cn/v1',
116
121
  anthropicEndpoint: 'https://api.moonshot.cn/anthropic',
117
122
  models: [
118
123
  { id: 'kimi-k2.5', name: 'Kimi K2.5', default: true },
@@ -125,12 +130,14 @@ export const BUILTIN_PROVIDERS = {
125
130
  credentials: [
126
131
  { type: 'api_key', key: 'OPENROUTER_API_KEY', label: 'API Key' },
127
132
  ],
133
+ baseUrl: 'https://openrouter.ai/api/v1',
128
134
  anthropicEndpoint: 'https://openrouter.ai/api',
129
135
  models: [],
130
136
  },
131
137
  nvidia: {
132
138
  name: 'NVIDIA',
133
139
  litellmProxy: true, litellmPrefix: 'nvidia_nim',
140
+ baseUrl: 'https://integrate.api.nvidia.com/v1',
134
141
  credentials: [
135
142
  { type: 'api_key', key: 'NVIDIA_API_KEY', label: 'API Key' },
136
143
  ],
@@ -36,6 +36,6 @@ Calls Docker Engine API directly through `/var/run/docker.sock` using Node's `ht
36
36
 
37
37
  **Typing indicator with jitter**: Re-sends typing action at 5.5–8s random intervals (Telegram expires indicators after 5s). Returns a stop function.
38
38
 
39
- ## openai.js — Whisper Transcription
39
+ ## assemblyai.js — Voice Transcription
40
40
 
41
- **Feature flag via API key**: `isWhisperEnabled()` checks if `OPENAI_API_KEY` is set. Used by the Telegram adapter to conditionally offer voice transcription. `transcribeAudio()` sends binary audio via FormData to `/v1/audio/transcriptions`.
41
+ **Feature flag via API key**: `isAssemblyAIEnabled()` checks if `ASSEMBLYAI_API_KEY` is set. Used by the Telegram adapter to conditionally offer voice transcription. `transcribeAudio(buffer)` uses the official `assemblyai` SDK (`client.transcripts.transcribe`), which handles upload + polling internally. Throws when the transcript status is `'error'`.
@@ -0,0 +1,17 @@
1
+ import { AssemblyAI } from 'assemblyai';
2
+ import { getConfig } from '../config.js';
3
+
4
+ function isAssemblyAIEnabled() {
5
+ return Boolean(getConfig('ASSEMBLYAI_API_KEY'));
6
+ }
7
+
8
+ async function transcribeAudio(audioBuffer) {
9
+ const client = new AssemblyAI({ apiKey: getConfig('ASSEMBLYAI_API_KEY') });
10
+ const transcript = await client.transcripts.transcribe({ audio: audioBuffer });
11
+ if (transcript.status === 'error') {
12
+ throw new Error(`AssemblyAI error: ${transcript.error}`);
13
+ }
14
+ return transcript.text;
15
+ }
16
+
17
+ export { isAssemblyAIEnabled, transcribeAudio };
@@ -1,22 +1,23 @@
1
1
  import { v4 as uuidv4 } from 'uuid';
2
2
  import { z } from 'zod';
3
3
  import { githubApi } from './github.js';
4
- import { createModel } from '../ai/model.js';
4
+ import { callHelperLlmStructured } from '../ai/helper-llm.js';
5
5
  import { getConfig } from '../config.js';
6
6
  /**
7
- * Generate a short descriptive title for an agent job using the LLM.
7
+ * Generate a short descriptive title for an agent job using the helper LLM.
8
8
  * Uses structured output to avoid thinking-token leaks with extended-thinking models.
9
9
  * @param {string} agentJobDescription - The full job description
10
10
  * @returns {Promise<string>} ~10 word title
11
11
  */
12
12
  async function generateAgentJobTitle(agentJobDescription) {
13
13
  try {
14
- const model = await createModel({ maxTokens: 100 });
15
- const response = await model.withStructuredOutput(z.object({ title: z.string() })).invoke([
16
- ['system', 'Generate a descriptive ~10 word title for this agent job. The title should clearly describe what the job will do.'],
17
- ['human', agentJobDescription],
18
- ]);
19
- return response.title.trim() || agentJobDescription.slice(0, 80);
14
+ const result = await callHelperLlmStructured({
15
+ system: 'Generate a descriptive ~10 word title for this agent job. The title should clearly describe what the job will do.',
16
+ user: agentJobDescription,
17
+ schema: z.object({ title: z.string() }),
18
+ maxTokens: 100,
19
+ });
20
+ return result?.title?.trim() || agentJobDescription.slice(0, 80);
20
21
  } catch {
21
22
  // Fallback: first line, truncated
22
23
  const firstLine = agentJobDescription.split('\n').find(l => l.trim()) || agentJobDescription;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thepopebot",
3
- "version": "1.2.76-beta.17",
3
+ "version": "1.2.76-beta.19",
4
4
  "type": "module",
5
5
  "description": "Create autonomous AI agents with a two-layer architecture: Next.js Event Handler + Docker Agent.",
6
6
  "bin": {
@@ -68,6 +68,10 @@
68
68
  "author": "Stephen Pope",
69
69
  "license": "MIT",
70
70
  "dependencies": {
71
+ "@ai-sdk/anthropic": "^2.0.0",
72
+ "@ai-sdk/google": "^2.0.0",
73
+ "@ai-sdk/openai": "^2.0.0",
74
+ "@ai-sdk/openai-compatible": "^1.0.0",
71
75
  "@ai-sdk/react": "^2.0.0",
72
76
  "@anthropic-ai/claude-agent-sdk": "^0.2.101",
73
77
  "@clack/prompts": "^0.10.0",
@@ -75,10 +79,6 @@
75
79
  "@dnd-kit/modifiers": "^9.0.0",
76
80
  "@dnd-kit/sortable": "^10.0.0",
77
81
  "@grammyjs/parse-mode": "^2.2.0",
78
- "@langchain/anthropic": "^1.3.17",
79
- "@langchain/core": "^1.1.24",
80
- "@langchain/google-genai": "^2.1.18",
81
- "@langchain/openai": "^1.2.7",
82
82
  "@monaco-editor/react": "^4.7.0",
83
83
  "@xterm/addon-fit": "^0.10.0",
84
84
  "@xterm/addon-search": "^0.15.0",
@@ -86,6 +86,7 @@
86
86
  "@xterm/addon-web-links": "^0.11.0",
87
87
  "@xterm/xterm": "^5.5.0",
88
88
  "ai": "^5.0.0",
89
+ "assemblyai": "^4.30.0",
89
90
  "bcrypt-ts": "^6.0.0",
90
91
  "better-sqlite3": "^12.6.2",
91
92
  "chalk": "^5.3.0",
package/lib/ai/model.js DELETED
@@ -1,130 +0,0 @@
1
- import { ChatAnthropic } from '@langchain/anthropic';
2
- import { getConfig } from '../config.js';
3
- import { BUILTIN_PROVIDERS } from '../llm-providers.js';
4
-
5
- // These models require thought_signature round-tripping which @langchain/google-genai doesn't support.
6
- // Auto-replace with gemini-2.5-flash until we migrate to @langchain/google (see issue #201).
7
- const GEMINI_UNSUPPORTED_MODELS = ['gemini-2.5-pro', 'gemini-3'];
8
- const GEMINI_FALLBACK = 'gemini-2.5-flash';
9
-
10
- /**
11
- * Create a LangChain chat model based on DB/env configuration.
12
- *
13
- * @param {object} [options]
14
- * @param {number} [options.maxTokens] - Max tokens for the response
15
- * @returns {import('@langchain/core/language_models/chat_models').BaseChatModel}
16
- */
17
- export async function createModel(options = {}) {
18
- const provider = getConfig('LLM_PROVIDER');
19
- const modelName = getConfig('LLM_MODEL');
20
- const maxTokens = options.maxTokens || Number(getConfig('LLM_MAX_TOKENS')) || 4096;
21
-
22
- // Custom provider (not in BUILTIN_PROVIDERS) → OpenAI-compatible
23
- if (!BUILTIN_PROVIDERS[provider]) {
24
- const { ChatOpenAI } = await import('@langchain/openai');
25
- const { getCustomProvider } = await import('../db/config.js');
26
- const custom = getCustomProvider(provider);
27
- if (!custom) throw new Error(`Unknown LLM provider: ${provider}`);
28
- const config = { modelName: custom.models?.[0] || modelName, maxTokens };
29
- config.apiKey = custom.apiKey || 'not-needed';
30
- if (custom.baseUrl) {
31
- config.configuration = { baseURL: custom.baseUrl };
32
- }
33
- return new ChatOpenAI(config);
34
- }
35
-
36
- const LLM_NOT_CONFIGURED = 'No chat LLM configured — set one up in Admin > Event Handler > LLMs';
37
-
38
- switch (provider) {
39
- case 'anthropic': {
40
- const apiKey = getConfig('ANTHROPIC_API_KEY');
41
- if (!apiKey) {
42
- throw new Error(LLM_NOT_CONFIGURED);
43
- }
44
- return new ChatAnthropic({
45
- modelName,
46
- maxTokens,
47
- anthropicApiKey: apiKey,
48
- });
49
- }
50
- case 'openai': {
51
- const { ChatOpenAI } = await import('@langchain/openai');
52
- const apiKey = getConfig('OPENAI_API_KEY');
53
- const baseURL = getConfig('CUSTOM_OPENAI_BASE_URL');
54
- if (!apiKey && !baseURL) {
55
- throw new Error(LLM_NOT_CONFIGURED);
56
- }
57
- const config = { modelName, maxTokens };
58
- config.apiKey = apiKey || 'not-needed';
59
- if (baseURL) {
60
- config.configuration = { baseURL };
61
- }
62
- return new ChatOpenAI(config);
63
- }
64
- case 'google': {
65
- const { ChatGoogleGenerativeAI } = await import('@langchain/google-genai');
66
- const apiKey = getConfig('GOOGLE_API_KEY');
67
- if (!apiKey) {
68
- throw new Error(LLM_NOT_CONFIGURED);
69
- }
70
- let resolvedModel = modelName;
71
- const isUnsupported = GEMINI_UNSUPPORTED_MODELS.some(m => resolvedModel.startsWith(m));
72
- if (isUnsupported) {
73
- console.warn(
74
- `[model] ${resolvedModel} requires thought_signature support not yet available in @langchain/google-genai. ` +
75
- `Falling back to ${GEMINI_FALLBACK}. See https://github.com/stephengpope/thepopebot/issues/201.`
76
- );
77
- resolvedModel = GEMINI_FALLBACK;
78
- }
79
- return new ChatGoogleGenerativeAI({
80
- model: resolvedModel,
81
- maxOutputTokens: maxTokens,
82
- apiKey,
83
- });
84
- }
85
- case 'deepseek': {
86
- const { ChatOpenAI } = await import('@langchain/openai');
87
- const apiKey = getConfig('DEEPSEEK_API_KEY');
88
- if (!apiKey) throw new Error(LLM_NOT_CONFIGURED);
89
- return new ChatOpenAI({ modelName, maxTokens, apiKey, configuration: { baseURL: 'https://api.deepseek.com' } });
90
- }
91
- case 'minimax': {
92
- const { ChatOpenAI } = await import('@langchain/openai');
93
- const apiKey = getConfig('MINIMAX_API_KEY');
94
- if (!apiKey) throw new Error(LLM_NOT_CONFIGURED);
95
- return new ChatOpenAI({ modelName, maxTokens, apiKey, configuration: { baseURL: 'https://api.minimax.io/v1' } });
96
- }
97
- case 'mistral': {
98
- const { ChatOpenAI } = await import('@langchain/openai');
99
- const apiKey = getConfig('MISTRAL_API_KEY');
100
- if (!apiKey) throw new Error(LLM_NOT_CONFIGURED);
101
- return new ChatOpenAI({ modelName, maxTokens, apiKey, configuration: { baseURL: 'https://api.mistral.ai/v1' } });
102
- }
103
- case 'xai': {
104
- const { ChatOpenAI } = await import('@langchain/openai');
105
- const apiKey = getConfig('XAI_API_KEY');
106
- if (!apiKey) throw new Error(LLM_NOT_CONFIGURED);
107
- return new ChatOpenAI({ modelName, maxTokens, apiKey, configuration: { baseURL: 'https://api.x.ai/v1' } });
108
- }
109
- case 'kimi': {
110
- const { ChatOpenAI } = await import('@langchain/openai');
111
- const apiKey = getConfig('MOONSHOT_API_KEY');
112
- if (!apiKey) throw new Error(LLM_NOT_CONFIGURED);
113
- return new ChatOpenAI({ modelName, maxTokens, apiKey, configuration: { baseURL: 'https://api.moonshot.cn/v1' } });
114
- }
115
- case 'openrouter': {
116
- const { ChatOpenAI } = await import('@langchain/openai');
117
- const apiKey = getConfig('OPENROUTER_API_KEY');
118
- if (!apiKey) throw new Error(LLM_NOT_CONFIGURED);
119
- return new ChatOpenAI({ modelName, maxTokens, apiKey, configuration: { baseURL: 'https://openrouter.ai/api/v1' } });
120
- }
121
- case 'nvidia': {
122
- const { ChatOpenAI } = await import('@langchain/openai');
123
- const apiKey = getConfig('NVIDIA_API_KEY');
124
- if (!apiKey) throw new Error(LLM_NOT_CONFIGURED);
125
- return new ChatOpenAI({ modelName, maxTokens, apiKey, configuration: { baseURL: 'https://integrate.api.nvidia.com/v1' } });
126
- }
127
- default:
128
- throw new Error(`Unknown LLM provider: ${provider}`);
129
- }
130
- }
@@ -1,37 +0,0 @@
1
- import { getConfig } from '../config.js';
2
-
3
- /**
4
- * Check if Whisper transcription is enabled
5
- * @returns {boolean}
6
- */
7
- function isWhisperEnabled() {
8
- return Boolean(getConfig('OPENAI_API_KEY'));
9
- }
10
-
11
- /**
12
- * Transcribe audio using OpenAI Whisper API
13
- * @param {Buffer} audioBuffer - Audio file buffer
14
- * @param {string} filename - Original filename (e.g., "voice.ogg")
15
- * @returns {Promise<string>} Transcribed text
16
- */
17
- async function transcribeAudio(audioBuffer, filename) {
18
- const formData = new FormData();
19
- formData.append('file', new Blob([audioBuffer]), filename);
20
- formData.append('model', 'whisper-1');
21
-
22
- const response = await fetch('https://api.openai.com/v1/audio/transcriptions', {
23
- method: 'POST',
24
- headers: { 'Authorization': `Bearer ${getConfig('OPENAI_API_KEY')}` },
25
- body: formData,
26
- });
27
-
28
- if (!response.ok) {
29
- const error = await response.text();
30
- throw new Error(`OpenAI API error: ${response.status} ${error}`);
31
- }
32
-
33
- const result = await response.json();
34
- return result.text;
35
- }
36
-
37
- export { isWhisperEnabled, transcribeAudio };