Merge branch 'feature/configurable-api-endpoints' into partak

This commit is contained in:
Developer 2026-01-27 08:01:41 +01:00
commit 5768e6a202
16 changed files with 135 additions and 26 deletions

View File

@ -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<void> {
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<bot_token>/getUpdates and read message.from.id",
`2) Or call ${TELEGRAM_API_BASE}/bot<bot_token>/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;

104
src/config/api-endpoints.ts Normal file
View File

@ -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";

View File

@ -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,

View File

@ -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;

View File

@ -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 {

View File

@ -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.";

View File

@ -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.";

View File

@ -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",

View File

@ -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 {

View File

@ -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");

View File

@ -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();

View File

@ -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;

View File

@ -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,

View File

@ -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<TelegramFileInfo> {
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<SavedMedia> {
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}`);

View File

@ -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;

View File

@ -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",