From da8c925e8e12369d316b89f0793f4725348287c8 Mon Sep 17 00:00:00 2001 From: Developer Date: Mon, 26 Jan 2026 11:22:48 +0100 Subject: [PATCH] feat(config): add centralized API endpoint configuration Add src/config/api-endpoints.ts with ENV variable support for all external API base URLs. This enables routing traffic through a proxy for multi-tenant deployments, usage metering, and security filtering. Supported ENV variables: - TELEGRAM_API_BASE (default: https://api.telegram.org) - DISCORD_API_BASE (default: https://discord.com/api/v10) - OPENAI_API_BASE (default: https://api.openai.com/v1) - ANTHROPIC_API_BASE (default: https://api.anthropic.com) - GOOGLE_GENERATIVE_API_BASE (default: https://generativelanguage.googleapis.com/v1beta) - GROQ_API_BASE (default: https://api.groq.com/openai/v1) - DEEPGRAM_API_BASE (default: https://api.deepgram.com/v1) - ELEVENLABS_API_BASE (default: https://api.elevenlabs.io) - OPENAI_TTS_BASE_URL (default: same as OPENAI_API_BASE) Updated files to use centralized configuration: - telegram/audit.ts, probe.ts, download.ts, bot/delivery.ts - channels/plugins/onboarding/telegram.ts - discord/api.ts, probe.ts - tts/tts.ts - memory/embeddings-openai.ts, embeddings-gemini.ts - media-understanding/providers/openai/audio.ts - media-understanding/providers/google/audio.ts, video.ts - media-understanding/providers/deepgram/audio.ts - media-understanding/providers/groq/index.ts Co-Authored-By: Claude Opus 4.5 --- src/channels/plugins/onboarding/telegram.ts | 5 +- src/config/api-endpoints.ts | 104 ++++++++++++++++++ src/discord/api.ts | 3 +- src/discord/probe.ts | 3 +- .../providers/deepgram/audio.ts | 3 +- .../providers/google/audio.ts | 5 +- .../providers/google/video.ts | 5 +- .../providers/groq/index.ts | 3 +- .../providers/openai/audio.ts | 3 +- src/memory/embeddings-gemini.ts | 3 +- src/memory/embeddings-openai.ts | 3 +- src/telegram/audit.ts | 3 +- src/telegram/bot/delivery.ts | 3 +- src/telegram/download.ts | 5 +- src/telegram/probe.ts | 3 +- src/tts/tts.ts | 7 +- 16 files changed, 135 insertions(+), 26 deletions(-) create mode 100644 src/config/api-endpoints.ts diff --git a/src/channels/plugins/onboarding/telegram.ts b/src/channels/plugins/onboarding/telegram.ts index fdbc044c5..666ff4e2b 100644 --- a/src/channels/plugins/onboarding/telegram.ts +++ b/src/channels/plugins/onboarding/telegram.ts @@ -1,3 +1,4 @@ +import { TELEGRAM_API_BASE } from "../../../config/api-endpoints.js"; import type { ClawdbotConfig } from "../../../config/config.js"; import type { DmPolicy } from "../../../config/types.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../routing/session-key.js"; @@ -48,7 +49,7 @@ async function noteTelegramUserIdHelp(prompter: WizardPrompter): Promise { await prompter.note( [ `1) DM your bot, then read from.id in \`${formatCliCommand("clawdbot logs --follow")}\` (safest)`, - "2) Or call https://api.telegram.org/bot/getUpdates and read message.from.id", + `2) Or call ${TELEGRAM_API_BASE}/bot/getUpdates and read message.from.id`, "3) Third-party: DM @userinfobot or @getidsbot", `Docs: ${formatDocsLink("/telegram")}`, "Website: https://clawd.bot", @@ -79,7 +80,7 @@ async function promptTelegramAllowFrom(params: { if (/^\d+$/.test(stripped)) return stripped; if (!token) return null; const username = stripped.startsWith("@") ? stripped : `@${stripped}`; - const url = `https://api.telegram.org/bot${token}/getChat?chat_id=${encodeURIComponent(username)}`; + const url = `${TELEGRAM_API_BASE}/bot${token}/getChat?chat_id=${encodeURIComponent(username)}`; try { const res = await fetch(url); if (!res.ok) return null; diff --git a/src/config/api-endpoints.ts b/src/config/api-endpoints.ts new file mode 100644 index 000000000..451e6f835 --- /dev/null +++ b/src/config/api-endpoints.ts @@ -0,0 +1,104 @@ +/** + * Centralized API endpoint configuration. + * + * All external API base URLs with ENV variable overrides. + * This enables routing all traffic through a proxy for: + * - Multi-tenant deployments + * - Usage metering and billing + * - Security filtering + * - Audit logging + * + * @example + * ```bash + * # Route all Telegram API calls through proxy + * TELEGRAM_API_BASE=https://proxy.example.com/tg + * + * # Route all Discord API calls through proxy + * DISCORD_API_BASE=https://proxy.example.com/dc + * ``` + */ + +const trimSlash = (url: string | undefined): string | undefined => + url?.trim().replace(/\/+$/, "") || undefined; + +// ============================================================================= +// MESSAGING PLATFORMS +// ============================================================================= + +/** + * Telegram Bot API base URL. + * Default: https://api.telegram.org + * + * Note: This is used for all outbound Telegram API calls including: + * - sendMessage, sendVoice, sendPhoto, etc. + * - getFile (file downloads) + * - setWebhook, deleteWebhook + */ +export const TELEGRAM_API_BASE = + trimSlash(process.env.TELEGRAM_API_BASE) ?? "https://api.telegram.org"; + +/** + * Discord API base URL. + * Default: https://discord.com/api/v10 + */ +export const DISCORD_API_BASE = + trimSlash(process.env.DISCORD_API_BASE) ?? "https://discord.com/api/v10"; + +// ============================================================================= +// LLM PROVIDERS +// ============================================================================= + +/** + * OpenAI API base URL. + * Default: https://api.openai.com/v1 + */ +export const OPENAI_API_BASE = + trimSlash(process.env.OPENAI_API_BASE) ?? "https://api.openai.com/v1"; + +/** + * Anthropic API base URL. + * Default: https://api.anthropic.com + */ +export const ANTHROPIC_API_BASE = + trimSlash(process.env.ANTHROPIC_API_BASE) ?? "https://api.anthropic.com"; + +/** + * Google Generative AI (Gemini) API base URL. + * Default: https://generativelanguage.googleapis.com/v1beta + */ +export const GOOGLE_GENERATIVE_API_BASE = + trimSlash(process.env.GOOGLE_GENERATIVE_API_BASE) ?? + "https://generativelanguage.googleapis.com/v1beta"; + +/** + * Groq API base URL. + * Default: https://api.groq.com/openai/v1 + */ +export const GROQ_API_BASE = + trimSlash(process.env.GROQ_API_BASE) ?? "https://api.groq.com/openai/v1"; + +/** + * Deepgram API base URL. + * Default: https://api.deepgram.com/v1 + */ +export const DEEPGRAM_API_BASE = + trimSlash(process.env.DEEPGRAM_API_BASE) ?? "https://api.deepgram.com/v1"; + +// ============================================================================= +// TTS PROVIDERS +// ============================================================================= + +/** + * OpenAI TTS API base URL. + * Default: Same as OPENAI_API_BASE + * + * Note: Supports custom OpenAI-compatible TTS endpoints (e.g., Kokoro, LocalAI). + */ +export const OPENAI_TTS_BASE = trimSlash(process.env.OPENAI_TTS_BASE_URL) ?? OPENAI_API_BASE; + +/** + * ElevenLabs API base URL. + * Default: https://api.elevenlabs.io + */ +export const ELEVENLABS_API_BASE = + trimSlash(process.env.ELEVENLABS_API_BASE) ?? "https://api.elevenlabs.io"; diff --git a/src/discord/api.ts b/src/discord/api.ts index 774342020..54d49f8b5 100644 --- a/src/discord/api.ts +++ b/src/discord/api.ts @@ -1,7 +1,6 @@ +import { DISCORD_API_BASE } from "../config/api-endpoints.js"; import { resolveFetch } from "../infra/fetch.js"; import { resolveRetryConfig, retryAsync, type RetryConfig } from "../infra/retry.js"; - -const DISCORD_API_BASE = "https://discord.com/api/v10"; const DISCORD_API_RETRY_DEFAULTS = { attempts: 3, minDelayMs: 500, diff --git a/src/discord/probe.ts b/src/discord/probe.ts index 78175c3b9..bbf2d5355 100644 --- a/src/discord/probe.ts +++ b/src/discord/probe.ts @@ -1,8 +1,7 @@ +import { DISCORD_API_BASE } from "../config/api-endpoints.js"; import { resolveFetch } from "../infra/fetch.js"; import { normalizeDiscordToken } from "./token.js"; -const DISCORD_API_BASE = "https://discord.com/api/v10"; - export type DiscordProbe = { ok: boolean; status?: number | null; diff --git a/src/media-understanding/providers/deepgram/audio.ts b/src/media-understanding/providers/deepgram/audio.ts index 1bc9bc782..ed4b9af51 100644 --- a/src/media-understanding/providers/deepgram/audio.ts +++ b/src/media-understanding/providers/deepgram/audio.ts @@ -1,7 +1,8 @@ +import { DEEPGRAM_API_BASE } from "../../../config/api-endpoints.js"; import type { AudioTranscriptionRequest, AudioTranscriptionResult } from "../../types.js"; import { fetchWithTimeout, normalizeBaseUrl, readErrorResponse } from "../shared.js"; -export const DEFAULT_DEEPGRAM_AUDIO_BASE_URL = "https://api.deepgram.com/v1"; +export const DEFAULT_DEEPGRAM_AUDIO_BASE_URL = DEEPGRAM_API_BASE; export const DEFAULT_DEEPGRAM_AUDIO_MODEL = "nova-3"; function resolveModel(model?: string): string { diff --git a/src/media-understanding/providers/google/audio.ts b/src/media-understanding/providers/google/audio.ts index 52a7136d2..fb3f201eb 100644 --- a/src/media-understanding/providers/google/audio.ts +++ b/src/media-understanding/providers/google/audio.ts @@ -1,8 +1,9 @@ -import type { AudioTranscriptionRequest, AudioTranscriptionResult } from "../../types.js"; +import { GOOGLE_GENERATIVE_API_BASE } from "../../../config/api-endpoints.js"; import { normalizeGoogleModelId } from "../../../agents/models-config.providers.js"; +import type { AudioTranscriptionRequest, AudioTranscriptionResult } from "../../types.js"; import { fetchWithTimeout, normalizeBaseUrl, readErrorResponse } from "../shared.js"; -export const DEFAULT_GOOGLE_AUDIO_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"; +export const DEFAULT_GOOGLE_AUDIO_BASE_URL = GOOGLE_GENERATIVE_API_BASE; const DEFAULT_GOOGLE_AUDIO_MODEL = "gemini-3-flash-preview"; const DEFAULT_GOOGLE_AUDIO_PROMPT = "Transcribe the audio."; diff --git a/src/media-understanding/providers/google/video.ts b/src/media-understanding/providers/google/video.ts index 0f483b280..9edcc9654 100644 --- a/src/media-understanding/providers/google/video.ts +++ b/src/media-understanding/providers/google/video.ts @@ -1,8 +1,9 @@ -import type { VideoDescriptionRequest, VideoDescriptionResult } from "../../types.js"; +import { GOOGLE_GENERATIVE_API_BASE } from "../../../config/api-endpoints.js"; import { normalizeGoogleModelId } from "../../../agents/models-config.providers.js"; +import type { VideoDescriptionRequest, VideoDescriptionResult } from "../../types.js"; import { fetchWithTimeout, normalizeBaseUrl, readErrorResponse } from "../shared.js"; -export const DEFAULT_GOOGLE_VIDEO_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"; +export const DEFAULT_GOOGLE_VIDEO_BASE_URL = GOOGLE_GENERATIVE_API_BASE; const DEFAULT_GOOGLE_VIDEO_MODEL = "gemini-3-flash-preview"; const DEFAULT_GOOGLE_VIDEO_PROMPT = "Describe the video."; diff --git a/src/media-understanding/providers/groq/index.ts b/src/media-understanding/providers/groq/index.ts index 5f59e5702..2b6640bf3 100644 --- a/src/media-understanding/providers/groq/index.ts +++ b/src/media-understanding/providers/groq/index.ts @@ -1,7 +1,8 @@ +import { GROQ_API_BASE } from "../../../config/api-endpoints.js"; import type { MediaUnderstandingProvider } from "../../types.js"; import { transcribeOpenAiCompatibleAudio } from "../openai/audio.js"; -const DEFAULT_GROQ_AUDIO_BASE_URL = "https://api.groq.com/openai/v1"; +const DEFAULT_GROQ_AUDIO_BASE_URL = GROQ_API_BASE; export const groqProvider: MediaUnderstandingProvider = { id: "groq", diff --git a/src/media-understanding/providers/openai/audio.ts b/src/media-understanding/providers/openai/audio.ts index acfe595a9..b88f5f731 100644 --- a/src/media-understanding/providers/openai/audio.ts +++ b/src/media-understanding/providers/openai/audio.ts @@ -1,9 +1,10 @@ import path from "node:path"; +import { OPENAI_API_BASE } from "../../../config/api-endpoints.js"; import type { AudioTranscriptionRequest, AudioTranscriptionResult } from "../../types.js"; import { fetchWithTimeout, normalizeBaseUrl, readErrorResponse } from "../shared.js"; -export const DEFAULT_OPENAI_AUDIO_BASE_URL = "https://api.openai.com/v1"; +export const DEFAULT_OPENAI_AUDIO_BASE_URL = OPENAI_API_BASE; const DEFAULT_OPENAI_AUDIO_MODEL = "gpt-4o-mini-transcribe"; function resolveModel(model?: string): string { diff --git a/src/memory/embeddings-gemini.ts b/src/memory/embeddings-gemini.ts index 244384df6..565c295ed 100644 --- a/src/memory/embeddings-gemini.ts +++ b/src/memory/embeddings-gemini.ts @@ -1,4 +1,5 @@ import { requireApiKey, resolveApiKeyForProvider } from "../agents/model-auth.js"; +import { GOOGLE_GENERATIVE_API_BASE } from "../config/api-endpoints.js"; import { isTruthyEnvValue } from "../infra/env.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import type { EmbeddingProvider, EmbeddingProviderOptions } from "./embeddings.js"; @@ -10,7 +11,7 @@ export type GeminiEmbeddingClient = { modelPath: string; }; -const DEFAULT_GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"; +const DEFAULT_GEMINI_BASE_URL = GOOGLE_GENERATIVE_API_BASE; export const DEFAULT_GEMINI_EMBEDDING_MODEL = "gemini-embedding-001"; const debugEmbeddings = isTruthyEnvValue(process.env.CLAWDBOT_DEBUG_MEMORY_EMBEDDINGS); const log = createSubsystemLogger("memory/embeddings"); diff --git a/src/memory/embeddings-openai.ts b/src/memory/embeddings-openai.ts index cfc53efae..81a60f6b8 100644 --- a/src/memory/embeddings-openai.ts +++ b/src/memory/embeddings-openai.ts @@ -1,4 +1,5 @@ import { requireApiKey, resolveApiKeyForProvider } from "../agents/model-auth.js"; +import { OPENAI_API_BASE } from "../config/api-endpoints.js"; import type { EmbeddingProvider, EmbeddingProviderOptions } from "./embeddings.js"; export type OpenAiEmbeddingClient = { @@ -8,7 +9,7 @@ export type OpenAiEmbeddingClient = { }; export const DEFAULT_OPENAI_EMBEDDING_MODEL = "text-embedding-3-small"; -const DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1"; +const DEFAULT_OPENAI_BASE_URL = OPENAI_API_BASE; export function normalizeOpenAiModel(model: string): string { const trimmed = model.trim(); diff --git a/src/telegram/audit.ts b/src/telegram/audit.ts index eb49f2d99..211a1822e 100644 --- a/src/telegram/audit.ts +++ b/src/telegram/audit.ts @@ -1,8 +1,7 @@ +import { TELEGRAM_API_BASE } from "../config/api-endpoints.js"; import type { TelegramGroupConfig } from "../config/types.js"; import { makeProxyFetch } from "./proxy.js"; -const TELEGRAM_API_BASE = "https://api.telegram.org"; - export type TelegramGroupMembershipAuditEntry = { chatId: string; ok: boolean; diff --git a/src/telegram/bot/delivery.ts b/src/telegram/bot/delivery.ts index c2489300c..462f2a5c2 100644 --- a/src/telegram/bot/delivery.ts +++ b/src/telegram/bot/delivery.ts @@ -1,4 +1,5 @@ import { type Bot, GrammyError, InputFile } from "grammy"; +import { TELEGRAM_API_BASE } from "../../config/api-endpoints.js"; import { markdownToTelegramChunks, markdownToTelegramHtml, @@ -274,7 +275,7 @@ export async function resolveMedia( if (!fetchImpl) { throw new Error("fetch is not available; set channels.telegram.proxy in config"); } - const url = `https://api.telegram.org/file/bot${token}/${file.file_path}`; + const url = `${TELEGRAM_API_BASE}/file/bot${token}/${file.file_path}`; const fetched = await fetchRemoteMedia({ url, fetchImpl, diff --git a/src/telegram/download.ts b/src/telegram/download.ts index 1b3c61e22..a10728991 100644 --- a/src/telegram/download.ts +++ b/src/telegram/download.ts @@ -1,3 +1,4 @@ +import { TELEGRAM_API_BASE } from "../config/api-endpoints.js"; import { detectMime } from "../media/mime.js"; import { type SavedMedia, saveMediaBuffer } from "../media/store.js"; @@ -10,7 +11,7 @@ export type TelegramFileInfo = { export async function getTelegramFile(token: string, fileId: string): Promise { const res = await fetch( - `https://api.telegram.org/bot${token}/getFile?file_id=${encodeURIComponent(fileId)}`, + `${TELEGRAM_API_BASE}/bot${token}/getFile?file_id=${encodeURIComponent(fileId)}`, ); if (!res.ok) { throw new Error(`getFile failed: ${res.status} ${res.statusText}`); @@ -28,7 +29,7 @@ export async function downloadTelegramFile( maxBytes?: number, ): Promise { if (!info.file_path) throw new Error("file_path missing"); - const url = `https://api.telegram.org/file/bot${token}/${info.file_path}`; + const url = `${TELEGRAM_API_BASE}/file/bot${token}/${info.file_path}`; const res = await fetch(url); if (!res.ok || !res.body) { throw new Error(`Failed to download telegram file: HTTP ${res.status}`); diff --git a/src/telegram/probe.ts b/src/telegram/probe.ts index 6ac8eeae8..88c1a2b73 100644 --- a/src/telegram/probe.ts +++ b/src/telegram/probe.ts @@ -1,7 +1,6 @@ +import { TELEGRAM_API_BASE } from "../config/api-endpoints.js"; import { makeProxyFetch } from "./proxy.js"; -const TELEGRAM_API_BASE = "https://api.telegram.org"; - export type TelegramProbe = { ok: boolean; status?: number | null; diff --git a/src/tts/tts.ts b/src/tts/tts.ts index 9507c5535..65a4997af 100644 --- a/src/tts/tts.ts +++ b/src/tts/tts.ts @@ -15,6 +15,7 @@ import { completeSimple, type TextContent } from "@mariozechner/pi-ai"; import { EdgeTTS } from "node-edge-tts"; import type { ReplyPayload } from "../auto-reply/types.js"; +import { ELEVENLABS_API_BASE, OPENAI_TTS_BASE } from "../config/api-endpoints.js"; import { normalizeChannelId } from "../channels/plugins/index.js"; import type { ChannelId } from "../channels/plugins/types.js"; import type { ClawdbotConfig } from "../config/config.js"; @@ -43,7 +44,7 @@ const DEFAULT_TTS_SUMMARIZE = true; const DEFAULT_MAX_TEXT_LENGTH = 4096; const TEMP_FILE_CLEANUP_DELAY_MS = 5 * 60 * 1000; // 5 minutes -const DEFAULT_ELEVENLABS_BASE_URL = "https://api.elevenlabs.io"; +const DEFAULT_ELEVENLABS_BASE_URL = ELEVENLABS_API_BASE; const DEFAULT_ELEVENLABS_VOICE_ID = "pMsXgVXv3BLzUgSXRplE"; const DEFAULT_ELEVENLABS_MODEL_ID = "eleven_multilingual_v2"; const DEFAULT_OPENAI_MODEL = "gpt-4o-mini-tts"; @@ -758,9 +759,7 @@ export const OPENAI_TTS_MODELS = ["gpt-4o-mini-tts", "tts-1", "tts-1-hd"] as con * When set, model/voice validation is relaxed to allow non-OpenAI models. * Example: OPENAI_TTS_BASE_URL=http://localhost:8880/v1 */ -const OPENAI_TTS_BASE_URL = ( - process.env.OPENAI_TTS_BASE_URL?.trim() || "https://api.openai.com/v1" -).replace(/\/+$/, ""); +const OPENAI_TTS_BASE_URL = OPENAI_TTS_BASE; const isCustomOpenAIEndpoint = OPENAI_TTS_BASE_URL !== "https://api.openai.com/v1"; export const OPENAI_TTS_VOICES = [ "alloy",