diff --git a/src/agents/llm-proxy.ts b/src/agents/llm-proxy.ts new file mode 100644 index 000000000..16e62c6be --- /dev/null +++ b/src/agents/llm-proxy.ts @@ -0,0 +1,62 @@ +/** + * LLM API proxy support. + * + * Uses undici's ProxyAgent to route all fetch() calls through a configured proxy. + * This is needed because pi-ai uses globalThis.fetch internally and doesn't + * accept a custom fetch function. + */ +import { ProxyAgent, setGlobalDispatcher, getGlobalDispatcher, type Dispatcher } from "undici"; + +import type { ClawdbotConfig } from "../config/config.js"; + +let originalDispatcher: Dispatcher | null = null; +let currentProxyUrl: string | null = null; + +/** + * Configure the global fetch dispatcher to use an HTTP proxy for all requests. + * Call this before making LLM API calls when models.proxy is configured. + * + * Safe to call multiple times - only reconfigures if the proxy URL changes. + */ +export function setupLlmProxy(cfg: ClawdbotConfig | undefined): void { + const proxyUrl = cfg?.models?.proxy?.trim(); + + // No proxy configured - restore original dispatcher if we changed it + if (!proxyUrl) { + if (originalDispatcher) { + setGlobalDispatcher(originalDispatcher); + originalDispatcher = null; + currentProxyUrl = null; + } + return; + } + + // Same proxy already configured - no action needed + if (proxyUrl === currentProxyUrl) { + return; + } + + // Save original dispatcher on first proxy setup + if (!originalDispatcher) { + originalDispatcher = getGlobalDispatcher(); + } + + // Set up new proxy agent + const proxyAgent = new ProxyAgent(proxyUrl); + setGlobalDispatcher(proxyAgent); + currentProxyUrl = proxyUrl; +} + +/** + * Check if an LLM proxy is currently configured and active. + */ +export function isLlmProxyActive(): boolean { + return currentProxyUrl !== null; +} + +/** + * Get the currently configured proxy URL, if any. + */ +export function getLlmProxyUrl(): string | null { + return currentProxyUrl; +} diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index 2daafd086..b9791fb3b 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -24,6 +24,7 @@ import { isReasoningTagProvider } from "../../utils/provider-utils.js"; import { resolveUserPath } from "../../utils.js"; import { resolveClawdbotAgentDir } from "../agent-paths.js"; import { resolveSessionAgentIds } from "../agent-scope.js"; +import { setupLlmProxy } from "../llm-proxy.js"; import { makeBootstrapWarn, resolveBootstrapContextForRun } from "../bootstrap-files.js"; import { resolveClawdbotDocsPath } from "../docs-path.js"; import type { ExecElevatedDefaults } from "../bash-tools.js"; @@ -109,6 +110,9 @@ export type CompactEmbeddedPiSessionParams = { export async function compactEmbeddedPiSessionDirect( params: CompactEmbeddedPiSessionParams, ): Promise { + // Set up LLM proxy if configured (must be done before any fetch calls) + setupLlmProxy(params.config); + const resolvedWorkspace = resolveUserPath(params.workspaceDir); const prevCwd = process.cwd(); diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index f1c487470..2e01782fd 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -22,6 +22,7 @@ import { isSubagentSessionKey } from "../../../routing/session-key.js"; import { resolveUserPath } from "../../../utils.js"; import { createCacheTrace } from "../../cache-trace.js"; import { createAnthropicPayloadLogger } from "../../anthropic-payload-log.js"; +import { setupLlmProxy } from "../../llm-proxy.js"; import { resolveClawdbotAgentDir } from "../../agent-paths.js"; import { resolveSessionAgentIds } from "../../agent-scope.js"; import { makeBootstrapWarn, resolveBootstrapContextForRun } from "../../bootstrap-files.js"; @@ -133,6 +134,9 @@ export function injectHistoryImagesIntoMessages( export async function runEmbeddedAttempt( params: EmbeddedRunAttemptParams, ): Promise { + // Set up LLM proxy if configured (must be done before any fetch calls) + setupLlmProxy(params.config); + const resolvedWorkspace = resolveUserPath(params.workspaceDir); const prevCwd = process.cwd(); const runAbortController = new AbortController(); diff --git a/src/agents/tools/image-tool.ts b/src/agents/tools/image-tool.ts index a640830fd..11d93a489 100644 --- a/src/agents/tools/image-tool.ts +++ b/src/agents/tools/image-tool.ts @@ -13,6 +13,7 @@ import { Type } from "@sinclair/typebox"; import type { ClawdbotConfig } from "../../config/config.js"; import { resolveUserPath } from "../../utils.js"; +import { setupLlmProxy } from "../llm-proxy.js"; import { loadWebMedia } from "../../web/media.js"; import { ensureAuthProfileStore, listProfilesForProvider } from "../auth-profiles.js"; import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../defaults.js"; @@ -219,6 +220,9 @@ async function runImagePrompt(params: { model: string; attempts: Array<{ provider: string; model: string; error: string }>; }> { + // Set up LLM proxy if configured (must be done before any fetch calls) + setupLlmProxy(params.cfg); + const effectiveCfg: ClawdbotConfig | undefined = params.cfg ? { ...params.cfg, diff --git a/src/config/types.models.ts b/src/config/types.models.ts index 11b6c64cb..39086f658 100644 --- a/src/config/types.models.ts +++ b/src/config/types.models.ts @@ -56,4 +56,6 @@ export type ModelsConfig = { mode?: "merge" | "replace"; providers?: Record; bedrockDiscovery?: BedrockDiscoveryConfig; + /** HTTP proxy URL for all LLM API calls (e.g., "http://user:pass@proxy:8080"). */ + proxy?: string; }; diff --git a/src/config/zod-schema.core.ts b/src/config/zod-schema.core.ts index 4a8c80bcc..8765c177d 100644 --- a/src/config/zod-schema.core.ts +++ b/src/config/zod-schema.core.ts @@ -77,6 +77,7 @@ export const ModelsConfigSchema = z mode: z.union([z.literal("merge"), z.literal("replace")]).optional(), providers: z.record(z.string(), ModelProviderSchema).optional(), bedrockDiscovery: BedrockDiscoverySchema, + proxy: z.string().optional(), }) .strict() .optional(); diff --git a/src/feishu/monitor.ts b/src/feishu/monitor.ts index 62246785a..5878c4dc2 100644 --- a/src/feishu/monitor.ts +++ b/src/feishu/monitor.ts @@ -7,6 +7,7 @@ */ import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import os from "node:os"; import type { ClawdbotConfig } from "../config/config.js"; import { loadConfig } from "../config/config.js"; @@ -71,6 +72,24 @@ const FEISHU_RESTART_POLICY = { jitter: 0.25, }; +/** + * Get primary local IPv4 address + */ +function getLocalIPv4(): string { + const nets = os.networkInterfaces(); + const prefer = ["en0", "eth0", "wlan0"]; + for (const name of prefer) { + const list = nets[name]; + const entry = list?.find((n) => n.family === "IPv4" && !n.internal); + if (entry?.address) return entry.address; + } + for (const list of Object.values(nets)) { + const entry = list?.find((n) => n.family === "IPv4" && !n.internal); + if (entry?.address) return entry.address; + } + return "unknown"; +} + /** * Process a Feishu message event */ @@ -458,13 +477,44 @@ export async function monitorFeishuProvider(opts: MonitorFeishuOpts = {}): Promi // Verify credentials by getting bot info log(`feishu: verifying bot credentials...`); + let botName = "unknown"; + let botOpenId = "unknown"; try { const botInfo = await client.getBotInfo(); - log(`feishu: connected as "${botInfo.app_name}" (${botInfo.open_id})`); + botName = botInfo.app_name ?? "unknown"; + botOpenId = botInfo.open_id ?? "unknown"; + log(`feishu: connected as "${botName}" (${botOpenId})`); } catch (err) { throw new Error(`Feishu authentication failed: ${formatErrorMessage(err)}`); } + // Send startup message if startupChatId is configured + const startupChatId = account.config.startupChatId?.trim(); + if (startupChatId) { + const localIp = getLocalIPv4(); + const hostname = os.hostname(); + const version = process.env.CLAWDBOT_VERSION ?? process.env.npm_package_version ?? "unknown"; + const timestamp = new Date().toLocaleString("zh-CN", { timeZone: "Asia/Shanghai" }); + + const startupMessage = [ + `🤖 飞书机器人启动通知`, + ``, + `机器人: ${botName}`, + `Bot ID: ${botOpenId}`, + `主机名: ${hostname}`, + `本地IP: ${localIp}`, + `版本: ${version}`, + `启动时间: ${timestamp}`, + ].join("\n"); + + try { + await client.sendTextMessage(startupChatId, startupMessage); + log(`feishu: sent startup message to chat ${startupChatId}`); + } catch (err) { + log(`feishu: failed to send startup message: ${formatErrorMessage(err)}`); + } + } + // Default to websocket mode (recommended, no public URL required) const eventMode = opts.eventMode ?? account.config.eventMode ?? "websocket"; log(`feishu: event mode is "${eventMode}"`); diff --git a/src/media-understanding/providers/image.ts b/src/media-understanding/providers/image.ts index c2c4dbbd6..452b4eec3 100644 --- a/src/media-understanding/providers/image.ts +++ b/src/media-understanding/providers/image.ts @@ -3,6 +3,7 @@ import { complete } from "@mariozechner/pi-ai"; import { discoverAuthStorage, discoverModels } from "@mariozechner/pi-coding-agent"; import { getApiKeyForModel, requireApiKey } from "../../agents/model-auth.js"; +import { setupLlmProxy } from "../../agents/llm-proxy.js"; import { ensureClawdbotModelsJson } from "../../agents/models-config.js"; import { minimaxUnderstandImage } from "../../agents/minimax-vlm.js"; import { coerceImageAssistantText } from "../../agents/tools/image-tool.helpers.js"; @@ -11,6 +12,9 @@ import type { ImageDescriptionRequest, ImageDescriptionResult } from "../types.j export async function describeImageWithModel( params: ImageDescriptionRequest, ): Promise { + // Set up LLM proxy if configured (must be done before any fetch calls) + setupLlmProxy(params.cfg); + await ensureClawdbotModelsJson(params.cfg, params.agentDir); const authStorage = discoverAuthStorage(params.agentDir); const modelRegistry = discoverModels(authStorage, params.agentDir);