Преглед изворни кода

refactor(proxy): introduce Agent Pool for connection management

- Add AgentPool class for centralized agent lifecycle management
- Implement LRU-style eviction with configurable TTL and max size
- Support health tracking for detecting SSL certificate errors
- Add cache key generation based on endpoint + proxy + http2 config
- Re-export pool utilities from proxy-agent.ts
- Add comprehensive unit tests for agent pool behavior

Co-Authored-By: Claude Opus 4.5 <[email protected]>
ding113 пре 3 недеља
родитељ
комит
2dd5043c99

+ 69 - 0
src/lib/proxy-agent.ts

@@ -1,5 +1,6 @@
 import { socksDispatcher } from "fetch-socks";
 import { socksDispatcher } from "fetch-socks";
 import { Agent, type Dispatcher, ProxyAgent, setGlobalDispatcher } from "undici";
 import { Agent, type Dispatcher, ProxyAgent, setGlobalDispatcher } from "undici";
+import { getGlobalAgentPool as getPool } from "@/lib/proxy-agent/agent-pool";
 import type { Provider } from "@/types/provider";
 import type { Provider } from "@/types/provider";
 import { getEnvConfig } from "./config/env.schema";
 import { getEnvConfig } from "./config/env.schema";
 import { logger } from "./logger";
 import { logger } from "./logger";
@@ -239,3 +240,71 @@ export function isValidProxyUrl(proxyUrl: string): boolean {
     return false;
     return false;
   }
   }
 }
 }
+
+// Re-export from agent-pool module
+export {
+  type AgentPool,
+  type AgentPoolConfig,
+  type AgentPoolStats,
+  generateAgentCacheKey,
+  getGlobalAgentPool,
+  resetGlobalAgentPool,
+} from "./proxy-agent/agent-pool";
+
+/**
+ * Extended ProxyConfig with cache key for health management
+ */
+export interface ProxyConfigWithCacheKey extends ProxyConfig {
+  /** Cache key for marking agent as unhealthy on SSL errors */
+  cacheKey: string;
+}
+
+/**
+ * Get proxy agent for provider using the global Agent Pool
+ *
+ * This is the recommended way to get a proxy agent as it:
+ * 1. Reuses agents across requests to the same endpoint
+ * 2. Isolates connections between different endpoints
+ * 3. Supports health management (mark unhealthy on SSL errors)
+ *
+ * @param provider Provider configuration
+ * @param targetUrl Target request URL
+ * @param enableHttp2 Whether to enable HTTP/2 (default: false)
+ * @returns ProxyConfig with cacheKey, or null if no proxy configured
+ */
+export async function getProxyAgentForProvider(
+  provider: Provider | ProviderProxyConfig,
+  targetUrl: string,
+  enableHttp2 = false
+): Promise<ProxyConfigWithCacheKey | null> {
+  // No proxy configured
+  if (!provider.proxyUrl) {
+    return null;
+  }
+
+  const proxyUrl = provider.proxyUrl.trim();
+  if (!proxyUrl) {
+    return null;
+  }
+
+  const pool = getPool();
+
+  const { agent, cacheKey } = await pool.getAgent({
+    endpointUrl: targetUrl,
+    proxyUrl,
+    enableHttp2,
+  });
+
+  // Determine actual HTTP/2 status (SOCKS doesn't support HTTP/2)
+  const parsedProxy = new URL(proxyUrl);
+  const isSocks = parsedProxy.protocol === "socks5:" || parsedProxy.protocol === "socks4:";
+  const actualHttp2Enabled = isSocks ? false : enableHttp2;
+
+  return {
+    agent,
+    fallbackToDirect: provider.proxyFallbackToDirect ?? false,
+    proxyUrl: maskProxyUrl(proxyUrl),
+    http2Enabled: actualHttp2Enabled,
+    cacheKey,
+  };
+}

+ 444 - 0
src/lib/proxy-agent/agent-pool.ts

@@ -0,0 +1,444 @@
+/**
+ * Agent Pool - Connection caching for HTTP/HTTPS requests
+ *
+ * Provides Agent caching per endpoint to:
+ * 1. Reuse connections across requests to the same endpoint
+ * 2. Isolate connections between different endpoints (prevents SSL certificate issues)
+ * 3. Support health management (mark unhealthy on SSL errors)
+ * 4. Implement TTL-based expiration and LRU eviction
+ */
+import { socksDispatcher } from "fetch-socks";
+import { Agent, type Dispatcher, ProxyAgent } from "undici";
+import { getEnvConfig } from "@/lib/config/env.schema";
+import { logger } from "@/lib/logger";
+
+/**
+ * Agent Pool Configuration
+ */
+export interface AgentPoolConfig {
+  /** Maximum total number of cached agents (default: 100) */
+  maxTotalAgents: number;
+  /** Agent TTL in milliseconds (default: 300000 = 5 minutes) */
+  agentTtlMs: number;
+  /** Connection idle timeout in milliseconds (default: 60000 = 1 minute) */
+  connectionIdleTimeoutMs: number;
+  /** Cleanup interval in milliseconds (default: 30000 = 30 seconds) */
+  cleanupIntervalMs: number;
+}
+
+/**
+ * Cached Agent entry
+ */
+interface CachedAgent {
+  agent: Dispatcher;
+  endpointKey: string;
+  createdAt: number;
+  lastUsedAt: number;
+  requestCount: number;
+  healthy: boolean;
+}
+
+/**
+ * Agent Pool Statistics
+ */
+export interface AgentPoolStats {
+  cacheSize: number;
+  totalRequests: number;
+  cacheHits: number;
+  cacheMisses: number;
+  hitRate: number;
+  unhealthyAgents: number;
+  evictedAgents: number;
+}
+
+/**
+ * Get Agent parameters
+ */
+export interface GetAgentParams {
+  endpointUrl: string;
+  proxyUrl: string | null;
+  enableHttp2: boolean;
+}
+
+/**
+ * Get Agent result
+ */
+export interface GetAgentResult {
+  agent: Dispatcher;
+  isNew: boolean;
+  cacheKey: string;
+}
+
+/**
+ * Agent Pool interface
+ */
+export interface AgentPool {
+  /**
+   * Get or create an Agent for the given parameters
+   */
+  getAgent(params: GetAgentParams): Promise<GetAgentResult>;
+
+  /**
+   * Mark an Agent as unhealthy (will be replaced on next getAgent call)
+   */
+  markUnhealthy(cacheKey: string, reason: string): void;
+
+  /**
+   * Evict all Agents for a specific endpoint
+   */
+  evictEndpoint(endpointKey: string): Promise<void>;
+
+  /**
+   * Get pool statistics
+   */
+  getPoolStats(): AgentPoolStats;
+
+  /**
+   * Cleanup expired Agents
+   * @returns Number of agents cleaned up
+   */
+  cleanup(): Promise<number>;
+
+  /**
+   * Shutdown the pool and close all agents
+   */
+  shutdown(): Promise<void>;
+}
+
+/**
+ * Generate cache key for Agent lookup
+ *
+ * Format: "${endpointOrigin}|${proxyUrl || 'direct'}|${h2 ? 'h2' : 'h1'}"
+ */
+export function generateAgentCacheKey(params: GetAgentParams): string {
+  const url = new URL(params.endpointUrl);
+  const origin = url.origin;
+  const proxy = params.proxyUrl || "direct";
+  const protocol = params.enableHttp2 ? "h2" : "h1";
+  return `${origin}|${proxy}|${protocol}`;
+}
+
+/**
+ * Default Agent Pool configuration
+ */
+const DEFAULT_CONFIG: AgentPoolConfig = {
+  maxTotalAgents: 100,
+  agentTtlMs: 300000, // 5 minutes
+  connectionIdleTimeoutMs: 60000, // 1 minute
+  cleanupIntervalMs: 30000, // 30 seconds
+};
+
+/**
+ * Agent Pool Implementation
+ */
+export class AgentPoolImpl implements AgentPool {
+  private cache: Map<string, CachedAgent> = new Map();
+  private unhealthyKeys: Set<string> = new Set();
+  private cleanupTimer: ReturnType<typeof setInterval> | null = null;
+  private config: AgentPoolConfig;
+  private stats = {
+    totalRequests: 0,
+    cacheHits: 0,
+    cacheMisses: 0,
+    evictedAgents: 0,
+  };
+  /** Pending agent creation promises to prevent race conditions */
+  private pendingCreations: Map<string, Promise<GetAgentResult>> = new Map();
+
+  constructor(config: Partial<AgentPoolConfig> = {}) {
+    this.config = { ...DEFAULT_CONFIG, ...config };
+    this.startCleanupTimer();
+  }
+
+  private startCleanupTimer(): void {
+    if (this.cleanupTimer) {
+      clearInterval(this.cleanupTimer);
+    }
+    this.cleanupTimer = setInterval(() => {
+      void this.cleanup();
+    }, this.config.cleanupIntervalMs);
+    // Allow process to exit gracefully without waiting for cleanup timer
+    this.cleanupTimer.unref();
+  }
+
+  async getAgent(params: GetAgentParams): Promise<GetAgentResult> {
+    const cacheKey = generateAgentCacheKey(params);
+    this.stats.totalRequests++;
+
+    // Check if marked as unhealthy
+    if (this.unhealthyKeys.has(cacheKey)) {
+      this.unhealthyKeys.delete(cacheKey);
+      await this.evictByKey(cacheKey);
+    }
+
+    // Try to get from cache
+    const cached = this.cache.get(cacheKey);
+    if (cached && !this.isExpired(cached)) {
+      cached.lastUsedAt = Date.now();
+      cached.requestCount++;
+      this.stats.cacheHits++;
+      return { agent: cached.agent, isNew: false, cacheKey };
+    }
+
+    // Check if there's a pending creation for this key (race condition prevention)
+    const pending = this.pendingCreations.get(cacheKey);
+    if (pending) {
+      // Wait for the pending creation and return its result
+      const result = await pending;
+      // Update stats for cache hit (since we're reusing the pending result)
+      this.stats.cacheHits++;
+      this.stats.cacheMisses--; // Adjust since we counted it as miss initially
+      return { ...result, isNew: false };
+    }
+
+    // Cache miss - create new agent with race condition protection
+    this.stats.cacheMisses++;
+
+    // Create the agent creation promise and store it
+    const creationPromise = this.createAgentWithCache(params, cacheKey, cached);
+    this.pendingCreations.set(cacheKey, creationPromise);
+
+    try {
+      return await creationPromise;
+    } finally {
+      // Clean up pending creation
+      this.pendingCreations.delete(cacheKey);
+    }
+  }
+
+  /**
+   * Internal method to create agent and update cache
+   * Separated to enable race condition protection via Promise caching
+   */
+  private async createAgentWithCache(
+    params: GetAgentParams,
+    cacheKey: string,
+    existingCached: CachedAgent | undefined
+  ): Promise<GetAgentResult> {
+    // Evict old entry if exists
+    if (existingCached) {
+      await this.evictByKey(cacheKey);
+    }
+
+    // Create new agent
+    const agent = await this.createAgent(params);
+    const url = new URL(params.endpointUrl);
+
+    const newCached: CachedAgent = {
+      agent,
+      endpointKey: url.origin,
+      createdAt: Date.now(),
+      lastUsedAt: Date.now(),
+      requestCount: 1,
+      healthy: true,
+    };
+
+    this.cache.set(cacheKey, newCached);
+
+    // Enforce max size (LRU eviction)
+    await this.enforceMaxSize();
+
+    return { agent, isNew: true, cacheKey };
+  }
+
+  markUnhealthy(cacheKey: string, reason: string): void {
+    this.unhealthyKeys.add(cacheKey);
+    logger.warn("AgentPool: Agent marked as unhealthy", {
+      cacheKey,
+      reason,
+    });
+  }
+
+  async evictEndpoint(endpointKey: string): Promise<void> {
+    const keysToEvict: string[] = [];
+
+    for (const [key, cached] of this.cache.entries()) {
+      if (cached.endpointKey === endpointKey) {
+        keysToEvict.push(key);
+      }
+    }
+
+    for (const key of keysToEvict) {
+      await this.evictByKey(key);
+    }
+  }
+
+  getPoolStats(): AgentPoolStats {
+    const unhealthyCount = this.unhealthyKeys.size;
+    const hitRate =
+      this.stats.totalRequests > 0 ? this.stats.cacheHits / this.stats.totalRequests : 0;
+
+    return {
+      cacheSize: this.cache.size,
+      totalRequests: this.stats.totalRequests,
+      cacheHits: this.stats.cacheHits,
+      cacheMisses: this.stats.cacheMisses,
+      hitRate,
+      unhealthyAgents: unhealthyCount,
+      evictedAgents: this.stats.evictedAgents,
+    };
+  }
+
+  async cleanup(): Promise<number> {
+    const now = Date.now();
+    const keysToCleanup: string[] = [];
+
+    for (const [key, cached] of this.cache.entries()) {
+      if (this.isExpired(cached, now)) {
+        keysToCleanup.push(key);
+      }
+    }
+
+    for (const key of keysToCleanup) {
+      await this.evictByKey(key);
+    }
+
+    if (keysToCleanup.length > 0) {
+      logger.debug("AgentPool: Cleaned up expired agents", {
+        count: keysToCleanup.length,
+      });
+    }
+
+    return keysToCleanup.length;
+  }
+
+  async shutdown(): Promise<void> {
+    if (this.cleanupTimer) {
+      clearInterval(this.cleanupTimer);
+      this.cleanupTimer = null;
+    }
+
+    const closePromises: Promise<void>[] = [];
+
+    for (const [key, cached] of this.cache.entries()) {
+      closePromises.push(this.closeAgent(cached.agent, key));
+    }
+
+    await Promise.all(closePromises);
+    this.cache.clear();
+    this.unhealthyKeys.clear();
+
+    logger.info("AgentPool: Shutdown complete");
+  }
+
+  private isExpired(cached: CachedAgent, now: number = Date.now()): boolean {
+    return now - cached.lastUsedAt > this.config.agentTtlMs;
+  }
+
+  private async evictByKey(key: string): Promise<void> {
+    const cached = this.cache.get(key);
+    if (cached) {
+      await this.closeAgent(cached.agent, key);
+      this.cache.delete(key);
+      this.stats.evictedAgents++;
+    }
+  }
+
+  private async closeAgent(agent: Dispatcher, key: string): Promise<void> {
+    try {
+      if (typeof agent.close === "function") {
+        await agent.close();
+      } else if (typeof agent.destroy === "function") {
+        await agent.destroy();
+      }
+    } catch (error) {
+      logger.warn("AgentPool: Error closing agent", {
+        key,
+        error: error instanceof Error ? error.message : String(error),
+      });
+    }
+  }
+
+  private async enforceMaxSize(): Promise<void> {
+    if (this.cache.size <= this.config.maxTotalAgents) {
+      return;
+    }
+
+    // Sort by lastUsedAt (oldest first) for LRU eviction
+    const entries = Array.from(this.cache.entries()).sort(
+      ([, a], [, b]) => a.lastUsedAt - b.lastUsedAt
+    );
+
+    const toEvict = entries.slice(0, this.cache.size - this.config.maxTotalAgents);
+
+    for (const [key] of toEvict) {
+      await this.evictByKey(key);
+    }
+  }
+
+  private async createAgent(params: GetAgentParams): Promise<Dispatcher> {
+    const {
+      FETCH_CONNECT_TIMEOUT: connectTimeout,
+      FETCH_HEADERS_TIMEOUT: headersTimeout,
+      FETCH_BODY_TIMEOUT: bodyTimeout,
+    } = getEnvConfig();
+
+    // No proxy - create direct Agent
+    if (!params.proxyUrl) {
+      return new Agent({
+        connectTimeout,
+        headersTimeout,
+        bodyTimeout,
+        allowH2: params.enableHttp2,
+      });
+    }
+
+    const proxyUrl = params.proxyUrl.trim();
+    const parsedProxy = new URL(proxyUrl);
+
+    // SOCKS proxy
+    if (parsedProxy.protocol === "socks5:" || parsedProxy.protocol === "socks4:") {
+      return socksDispatcher(
+        {
+          type: parsedProxy.protocol === "socks5:" ? 5 : 4,
+          host: parsedProxy.hostname,
+          port: parseInt(parsedProxy.port, 10) || 1080,
+          userId: parsedProxy.username || undefined,
+          password: parsedProxy.password || undefined,
+        },
+        {
+          connect: {
+            timeout: connectTimeout,
+          },
+        }
+      );
+    }
+
+    // HTTP/HTTPS proxy
+    if (parsedProxy.protocol === "http:" || parsedProxy.protocol === "https:") {
+      return new ProxyAgent({
+        uri: proxyUrl,
+        allowH2: params.enableHttp2,
+        connectTimeout,
+        headersTimeout,
+        bodyTimeout,
+      });
+    }
+
+    throw new Error(`Unsupported proxy protocol: ${parsedProxy.protocol}`);
+  }
+}
+
+// Global singleton instance
+let globalAgentPool: AgentPool | null = null;
+
+/**
+ * Get the global Agent Pool singleton
+ */
+export function getGlobalAgentPool(): AgentPool {
+  if (!globalAgentPool) {
+    globalAgentPool = new AgentPoolImpl();
+    logger.info("AgentPool: Global instance created");
+  }
+  return globalAgentPool;
+}
+
+/**
+ * Reset the global Agent Pool (for testing)
+ */
+export async function resetGlobalAgentPool(): Promise<void> {
+  if (globalAgentPool) {
+    await globalAgentPool.shutdown();
+    globalAgentPool = null;
+  }
+}

+ 467 - 0
tests/unit/lib/proxy-agent/agent-pool.test.ts

@@ -0,0 +1,467 @@
+/**
+ * Agent Pool Tests
+ *
+ * TDD: Tests written first, implementation follows
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+// Mock undici before importing agent-pool
+vi.mock("undici", () => ({
+  Agent: vi.fn().mockImplementation((options) => ({
+    options,
+    close: vi.fn().mockResolvedValue(undefined),
+    destroy: vi.fn().mockResolvedValue(undefined),
+  })),
+  ProxyAgent: vi.fn().mockImplementation((options) => ({
+    options,
+    close: vi.fn().mockResolvedValue(undefined),
+    destroy: vi.fn().mockResolvedValue(undefined),
+  })),
+}));
+
+vi.mock("fetch-socks", () => ({
+  socksDispatcher: vi.fn().mockImplementation((proxy, options) => ({
+    proxy,
+    options,
+    close: vi.fn().mockResolvedValue(undefined),
+    destroy: vi.fn().mockResolvedValue(undefined),
+  })),
+}));
+
+import {
+  type AgentPool,
+  AgentPoolImpl,
+  generateAgentCacheKey,
+  getGlobalAgentPool,
+  resetGlobalAgentPool,
+  type AgentPoolConfig,
+} from "@/lib/proxy-agent/agent-pool";
+
+describe("generateAgentCacheKey", () => {
+  it("should generate correct cache key for direct connection", () => {
+    const key = generateAgentCacheKey({
+      endpointUrl: "https://api.anthropic.com/v1/messages",
+      proxyUrl: null,
+      enableHttp2: false,
+    });
+    expect(key).toBe("https://api.anthropic.com|direct|h1");
+  });
+
+  it("should generate correct cache key with proxy", () => {
+    const key = generateAgentCacheKey({
+      endpointUrl: "https://api.openai.com/v1/chat/completions",
+      proxyUrl: "http://proxy.example.com:8080",
+      enableHttp2: false,
+    });
+    expect(key).toBe("https://api.openai.com|http://proxy.example.com:8080|h1");
+  });
+
+  it("should generate correct cache key with HTTP/2 enabled", () => {
+    const key = generateAgentCacheKey({
+      endpointUrl: "https://api.anthropic.com/v1/messages",
+      proxyUrl: null,
+      enableHttp2: true,
+    });
+    expect(key).toBe("https://api.anthropic.com|direct|h2");
+  });
+
+  it("should generate correct cache key with proxy and HTTP/2", () => {
+    const key = generateAgentCacheKey({
+      endpointUrl: "https://api.anthropic.com/v1/messages",
+      proxyUrl: "https://secure-proxy.example.com:443",
+      enableHttp2: true,
+    });
+    expect(key).toBe("https://api.anthropic.com|https://secure-proxy.example.com:443|h2");
+  });
+
+  it("should use origin only (strip path and query)", () => {
+    const key = generateAgentCacheKey({
+      endpointUrl: "https://api.anthropic.com/v1/messages?key=value",
+      proxyUrl: null,
+      enableHttp2: false,
+    });
+    expect(key).toBe("https://api.anthropic.com|direct|h1");
+  });
+
+  it("should handle different ports", () => {
+    const key = generateAgentCacheKey({
+      endpointUrl: "https://api.example.com:8443/v1/messages",
+      proxyUrl: null,
+      enableHttp2: false,
+    });
+    expect(key).toBe("https://api.example.com:8443|direct|h1");
+  });
+
+  it("should differentiate HTTP and HTTPS", () => {
+    const httpKey = generateAgentCacheKey({
+      endpointUrl: "http://api.example.com/v1/messages",
+      proxyUrl: null,
+      enableHttp2: false,
+    });
+    const httpsKey = generateAgentCacheKey({
+      endpointUrl: "https://api.example.com/v1/messages",
+      proxyUrl: null,
+      enableHttp2: false,
+    });
+    expect(httpKey).not.toBe(httpsKey);
+    expect(httpKey).toBe("http://api.example.com|direct|h1");
+    expect(httpsKey).toBe("https://api.example.com|direct|h1");
+  });
+});
+
+describe("AgentPool", () => {
+  let pool: AgentPool;
+  const defaultConfig: AgentPoolConfig = {
+    maxTotalAgents: 10,
+    agentTtlMs: 300000, // 5 minutes
+    connectionIdleTimeoutMs: 60000, // 1 minute
+    cleanupIntervalMs: 30000, // 30 seconds
+  };
+
+  beforeEach(() => {
+    vi.useFakeTimers();
+    pool = new AgentPoolImpl(defaultConfig);
+  });
+
+  afterEach(async () => {
+    await pool.shutdown();
+    vi.useRealTimers();
+    vi.clearAllMocks();
+  });
+
+  describe("caching behavior", () => {
+    it("should reuse Agent for same endpoint", async () => {
+      const params = {
+        endpointUrl: "https://api.anthropic.com/v1/messages",
+        proxyUrl: null,
+        enableHttp2: false,
+      };
+
+      const result1 = await pool.getAgent(params);
+      const result2 = await pool.getAgent(params);
+
+      expect(result1.cacheKey).toBe(result2.cacheKey);
+      expect(result1.agent).toBe(result2.agent);
+      expect(result1.isNew).toBe(true);
+      expect(result2.isNew).toBe(false);
+    });
+
+    it("should create different Agent for different endpoints", async () => {
+      const result1 = await pool.getAgent({
+        endpointUrl: "https://api.anthropic.com/v1/messages",
+        proxyUrl: null,
+        enableHttp2: false,
+      });
+
+      const result2 = await pool.getAgent({
+        endpointUrl: "https://api.openai.com/v1/chat/completions",
+        proxyUrl: null,
+        enableHttp2: false,
+      });
+
+      expect(result1.cacheKey).not.toBe(result2.cacheKey);
+      expect(result1.agent).not.toBe(result2.agent);
+      expect(result1.isNew).toBe(true);
+      expect(result2.isNew).toBe(true);
+    });
+
+    it("should create different Agent for different proxy configs", async () => {
+      const result1 = await pool.getAgent({
+        endpointUrl: "https://api.anthropic.com/v1/messages",
+        proxyUrl: null,
+        enableHttp2: false,
+      });
+
+      const result2 = await pool.getAgent({
+        endpointUrl: "https://api.anthropic.com/v1/messages",
+        proxyUrl: "http://proxy.example.com:8080",
+        enableHttp2: false,
+      });
+
+      expect(result1.cacheKey).not.toBe(result2.cacheKey);
+      expect(result1.agent).not.toBe(result2.agent);
+    });
+
+    it("should create different Agent for HTTP/2 vs HTTP/1.1", async () => {
+      const result1 = await pool.getAgent({
+        endpointUrl: "https://api.anthropic.com/v1/messages",
+        proxyUrl: null,
+        enableHttp2: false,
+      });
+
+      const result2 = await pool.getAgent({
+        endpointUrl: "https://api.anthropic.com/v1/messages",
+        proxyUrl: null,
+        enableHttp2: true,
+      });
+
+      expect(result1.cacheKey).not.toBe(result2.cacheKey);
+      expect(result1.agent).not.toBe(result2.agent);
+    });
+
+    it("should track request count", async () => {
+      const params = {
+        endpointUrl: "https://api.anthropic.com/v1/messages",
+        proxyUrl: null,
+        enableHttp2: false,
+      };
+
+      await pool.getAgent(params);
+      await pool.getAgent(params);
+      await pool.getAgent(params);
+
+      const stats = pool.getPoolStats();
+      expect(stats.totalRequests).toBe(3);
+      expect(stats.cacheHits).toBe(2);
+      expect(stats.cacheMisses).toBe(1);
+    });
+  });
+
+  describe("health management", () => {
+    it("should create new Agent after marking unhealthy", async () => {
+      const params = {
+        endpointUrl: "https://api.anthropic.com/v1/messages",
+        proxyUrl: null,
+        enableHttp2: false,
+      };
+
+      const result1 = await pool.getAgent(params);
+      pool.markUnhealthy(result1.cacheKey, "SSL certificate error");
+
+      const result2 = await pool.getAgent(params);
+
+      expect(result2.isNew).toBe(true);
+      expect(result2.agent).not.toBe(result1.agent);
+    });
+
+    it("should track unhealthy agents in stats", async () => {
+      const params = {
+        endpointUrl: "https://api.anthropic.com/v1/messages",
+        proxyUrl: null,
+        enableHttp2: false,
+      };
+
+      const result = await pool.getAgent(params);
+      pool.markUnhealthy(result.cacheKey, "SSL certificate error");
+
+      const stats = pool.getPoolStats();
+      expect(stats.unhealthyAgents).toBe(1);
+    });
+
+    it("should evict all Agents for endpoint on evictEndpoint", async () => {
+      // Create agents for same endpoint with different configs
+      await pool.getAgent({
+        endpointUrl: "https://api.anthropic.com/v1/messages",
+        proxyUrl: null,
+        enableHttp2: false,
+      });
+      await pool.getAgent({
+        endpointUrl: "https://api.anthropic.com/v1/messages",
+        proxyUrl: null,
+        enableHttp2: true,
+      });
+      await pool.getAgent({
+        endpointUrl: "https://api.openai.com/v1/chat/completions",
+        proxyUrl: null,
+        enableHttp2: false,
+      });
+
+      const statsBefore = pool.getPoolStats();
+      expect(statsBefore.cacheSize).toBe(3);
+
+      await pool.evictEndpoint("https://api.anthropic.com");
+
+      const statsAfter = pool.getPoolStats();
+      expect(statsAfter.cacheSize).toBe(1);
+      expect(statsAfter.evictedAgents).toBe(2);
+    });
+  });
+
+  describe("expiration cleanup", () => {
+    it("should cleanup expired Agents", async () => {
+      const shortTtlPool = new AgentPoolImpl({
+        ...defaultConfig,
+        agentTtlMs: 1000, // 1 second TTL
+      });
+
+      await shortTtlPool.getAgent({
+        endpointUrl: "https://api.anthropic.com/v1/messages",
+        proxyUrl: null,
+        enableHttp2: false,
+      });
+
+      expect(shortTtlPool.getPoolStats().cacheSize).toBe(1);
+
+      // Advance time past TTL
+      vi.advanceTimersByTime(2000);
+
+      const cleaned = await shortTtlPool.cleanup();
+      expect(cleaned).toBe(1);
+      expect(shortTtlPool.getPoolStats().cacheSize).toBe(0);
+
+      await shortTtlPool.shutdown();
+    });
+
+    it("should not cleanup recently used Agents", async () => {
+      const shortTtlPool = new AgentPoolImpl({
+        ...defaultConfig,
+        agentTtlMs: 1000,
+      });
+
+      const params = {
+        endpointUrl: "https://api.anthropic.com/v1/messages",
+        proxyUrl: null,
+        enableHttp2: false,
+      };
+
+      await shortTtlPool.getAgent(params);
+
+      // Advance time but not past TTL
+      vi.advanceTimersByTime(500);
+
+      // Use the agent again (updates lastUsedAt)
+      await shortTtlPool.getAgent(params);
+
+      // Advance time again
+      vi.advanceTimersByTime(500);
+
+      const cleaned = await shortTtlPool.cleanup();
+      expect(cleaned).toBe(0);
+      expect(shortTtlPool.getPoolStats().cacheSize).toBe(1);
+
+      await shortTtlPool.shutdown();
+    });
+
+    it("should implement LRU eviction when max size reached", async () => {
+      const smallPool = new AgentPoolImpl({
+        ...defaultConfig,
+        maxTotalAgents: 2,
+      });
+
+      // Create 3 agents (exceeds max of 2)
+      await smallPool.getAgent({
+        endpointUrl: "https://api1.example.com/v1",
+        proxyUrl: null,
+        enableHttp2: false,
+      });
+
+      vi.advanceTimersByTime(100);
+
+      await smallPool.getAgent({
+        endpointUrl: "https://api2.example.com/v1",
+        proxyUrl: null,
+        enableHttp2: false,
+      });
+
+      vi.advanceTimersByTime(100);
+
+      await smallPool.getAgent({
+        endpointUrl: "https://api3.example.com/v1",
+        proxyUrl: null,
+        enableHttp2: false,
+      });
+
+      // Should have evicted the oldest (LRU)
+      const stats = smallPool.getPoolStats();
+      expect(stats.cacheSize).toBeLessThanOrEqual(2);
+
+      await smallPool.shutdown();
+    });
+  });
+
+  describe("proxy support", () => {
+    it("should create ProxyAgent for HTTP proxy", async () => {
+      const result = await pool.getAgent({
+        endpointUrl: "https://api.anthropic.com/v1/messages",
+        proxyUrl: "http://proxy.example.com:8080",
+        enableHttp2: false,
+      });
+
+      expect(result.isNew).toBe(true);
+      expect(result.cacheKey).toContain("http://proxy.example.com:8080");
+    });
+
+    it("should create SOCKS dispatcher for SOCKS proxy", async () => {
+      const result = await pool.getAgent({
+        endpointUrl: "https://api.anthropic.com/v1/messages",
+        proxyUrl: "socks5://proxy.example.com:1080",
+        enableHttp2: false,
+      });
+
+      expect(result.isNew).toBe(true);
+      expect(result.cacheKey).toContain("socks5://proxy.example.com:1080");
+    });
+  });
+
+  describe("pool stats", () => {
+    it("should return accurate pool statistics", async () => {
+      await pool.getAgent({
+        endpointUrl: "https://api.anthropic.com/v1/messages",
+        proxyUrl: null,
+        enableHttp2: false,
+      });
+
+      await pool.getAgent({
+        endpointUrl: "https://api.anthropic.com/v1/messages",
+        proxyUrl: null,
+        enableHttp2: false,
+      });
+
+      await pool.getAgent({
+        endpointUrl: "https://api.openai.com/v1/chat/completions",
+        proxyUrl: null,
+        enableHttp2: false,
+      });
+
+      const stats = pool.getPoolStats();
+
+      expect(stats.cacheSize).toBe(2);
+      expect(stats.totalRequests).toBe(3);
+      expect(stats.cacheHits).toBe(1);
+      expect(stats.cacheMisses).toBe(2);
+      expect(stats.hitRate).toBeCloseTo(1 / 3, 2);
+    });
+  });
+
+  describe("shutdown", () => {
+    it("should close all agents on shutdown", async () => {
+      await pool.getAgent({
+        endpointUrl: "https://api.anthropic.com/v1/messages",
+        proxyUrl: null,
+        enableHttp2: false,
+      });
+
+      await pool.getAgent({
+        endpointUrl: "https://api.openai.com/v1/chat/completions",
+        proxyUrl: null,
+        enableHttp2: false,
+      });
+
+      await pool.shutdown();
+
+      const stats = pool.getPoolStats();
+      expect(stats.cacheSize).toBe(0);
+    });
+  });
+});
+
+describe("getGlobalAgentPool", () => {
+  afterEach(async () => {
+    await resetGlobalAgentPool();
+  });
+
+  it("should return singleton instance", () => {
+    const pool1 = getGlobalAgentPool();
+    const pool2 = getGlobalAgentPool();
+
+    expect(pool1).toBe(pool2);
+  });
+
+  it("should create new instance after reset", async () => {
+    const pool1 = getGlobalAgentPool();
+    await resetGlobalAgentPool();
+    const pool2 = getGlobalAgentPool();
+
+    expect(pool1).not.toBe(pool2);
+  });
+});

+ 346 - 0
tests/unit/lib/proxy-agent/get-proxy-agent.test.ts

@@ -0,0 +1,346 @@
+/**
+ * getProxyAgentForProvider Tests
+ *
+ * TDD: Tests written first, implementation follows
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import type { Provider } from "@/types/provider";
+
+// Create mock objects outside the mock factory
+const mockAgent = {
+  close: vi.fn().mockResolvedValue(undefined),
+  destroy: vi.fn().mockResolvedValue(undefined),
+};
+
+const mockPool = {
+  getAgent: vi.fn().mockResolvedValue({
+    agent: mockAgent,
+    isNew: true,
+    cacheKey: "https://api.anthropic.com|direct|h1",
+  }),
+  markUnhealthy: vi.fn(),
+  evictEndpoint: vi.fn().mockResolvedValue(undefined),
+  getPoolStats: vi.fn().mockReturnValue({
+    cacheSize: 1,
+    totalRequests: 1,
+    cacheHits: 0,
+    cacheMisses: 1,
+    hitRate: 0,
+    unhealthyAgents: 0,
+    evictedAgents: 0,
+  }),
+  cleanup: vi.fn().mockResolvedValue(0),
+  shutdown: vi.fn().mockResolvedValue(undefined),
+};
+
+// Mock the agent pool module
+vi.mock("@/lib/proxy-agent/agent-pool", () => ({
+  getGlobalAgentPool: vi.fn(() => mockPool),
+  resetGlobalAgentPool: vi.fn().mockResolvedValue(undefined),
+  generateAgentCacheKey: vi.fn().mockImplementation((params) => {
+    const url = new URL(params.endpointUrl);
+    const proxy = params.proxyUrl || "direct";
+    const protocol = params.enableHttp2 ? "h2" : "h1";
+    return `${url.origin}|${proxy}|${protocol}`;
+  }),
+  AgentPoolImpl: vi.fn(),
+}));
+
+// Import after mock setup
+import { getProxyAgentForProvider, type ProxyConfigWithCacheKey } from "@/lib/proxy-agent";
+
+describe("getProxyAgentForProvider", () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    // Reset default mock return value
+    mockPool.getAgent.mockResolvedValue({
+      agent: mockAgent,
+      isNew: true,
+      cacheKey: "https://api.anthropic.com|direct|h1",
+    });
+  });
+
+  afterEach(() => {
+    vi.clearAllMocks();
+  });
+
+  describe("direct connection (no proxy)", () => {
+    it("should return null when provider has no proxy configured", async () => {
+      const provider: Partial<Provider> = {
+        id: 1,
+        name: "Test Provider",
+        proxyUrl: null,
+        proxyFallbackToDirect: false,
+      };
+
+      const result = await getProxyAgentForProvider(
+        provider as Provider,
+        "https://api.anthropic.com/v1/messages",
+        false
+      );
+
+      expect(result).toBeNull();
+      expect(mockPool.getAgent).not.toHaveBeenCalled();
+    });
+
+    it("should return null when proxyUrl is empty string", async () => {
+      const provider: Partial<Provider> = {
+        id: 1,
+        name: "Test Provider",
+        proxyUrl: "",
+        proxyFallbackToDirect: false,
+      };
+
+      const result = await getProxyAgentForProvider(
+        provider as Provider,
+        "https://api.anthropic.com/v1/messages",
+        false
+      );
+
+      expect(result).toBeNull();
+    });
+
+    it("should return null when proxyUrl is whitespace only", async () => {
+      const provider: Partial<Provider> = {
+        id: 1,
+        name: "Test Provider",
+        proxyUrl: "   ",
+        proxyFallbackToDirect: false,
+      };
+
+      const result = await getProxyAgentForProvider(
+        provider as Provider,
+        "https://api.anthropic.com/v1/messages",
+        false
+      );
+
+      expect(result).toBeNull();
+    });
+  });
+
+  describe("with proxy configured", () => {
+    it("should return ProxyConfig with cacheKey for HTTP proxy", async () => {
+      const provider: Partial<Provider> = {
+        id: 1,
+        name: "Test Provider",
+        proxyUrl: "http://proxy.example.com:8080",
+        proxyFallbackToDirect: false,
+      };
+
+      mockPool.getAgent.mockResolvedValueOnce({
+        agent: mockAgent,
+        isNew: true,
+        cacheKey: "https://api.anthropic.com|http://proxy.example.com:8080|h1",
+      });
+
+      const result = await getProxyAgentForProvider(
+        provider as Provider,
+        "https://api.anthropic.com/v1/messages",
+        false
+      );
+
+      expect(result).not.toBeNull();
+      expect(result?.cacheKey).toBe("https://api.anthropic.com|http://proxy.example.com:8080|h1");
+      expect(result?.fallbackToDirect).toBe(false);
+      expect(result?.http2Enabled).toBe(false);
+      expect(mockPool.getAgent).toHaveBeenCalledWith({
+        endpointUrl: "https://api.anthropic.com/v1/messages",
+        proxyUrl: "http://proxy.example.com:8080",
+        enableHttp2: false,
+      });
+    });
+
+    it("should return ProxyConfig with HTTP/2 enabled", async () => {
+      const provider: Partial<Provider> = {
+        id: 1,
+        name: "Test Provider",
+        proxyUrl: "http://proxy.example.com:8080",
+        proxyFallbackToDirect: true,
+      };
+
+      mockPool.getAgent.mockResolvedValueOnce({
+        agent: mockAgent,
+        isNew: true,
+        cacheKey: "https://api.anthropic.com|http://proxy.example.com:8080|h2",
+      });
+
+      const result = await getProxyAgentForProvider(
+        provider as Provider,
+        "https://api.anthropic.com/v1/messages",
+        true
+      );
+
+      expect(result).not.toBeNull();
+      expect(result?.http2Enabled).toBe(true);
+      expect(result?.fallbackToDirect).toBe(true);
+      expect(mockPool.getAgent).toHaveBeenCalledWith({
+        endpointUrl: "https://api.anthropic.com/v1/messages",
+        proxyUrl: "http://proxy.example.com:8080",
+        enableHttp2: true,
+      });
+    });
+
+    it("should handle SOCKS proxy", async () => {
+      const provider: Partial<Provider> = {
+        id: 1,
+        name: "Test Provider",
+        proxyUrl: "socks5://proxy.example.com:1080",
+        proxyFallbackToDirect: false,
+      };
+
+      mockPool.getAgent.mockResolvedValueOnce({
+        agent: mockAgent,
+        isNew: true,
+        cacheKey: "https://api.anthropic.com|socks5://proxy.example.com:1080|h1",
+      });
+
+      const result = await getProxyAgentForProvider(
+        provider as Provider,
+        "https://api.anthropic.com/v1/messages",
+        false
+      );
+
+      expect(result).not.toBeNull();
+      expect(result?.cacheKey).toContain("socks5://");
+    });
+
+    it("should disable HTTP/2 for SOCKS proxy even when requested", async () => {
+      const provider: Partial<Provider> = {
+        id: 1,
+        name: "Test Provider",
+        proxyUrl: "socks5://proxy.example.com:1080",
+        proxyFallbackToDirect: false,
+      };
+
+      mockPool.getAgent.mockResolvedValueOnce({
+        agent: mockAgent,
+        isNew: true,
+        cacheKey: "https://api.anthropic.com|socks5://proxy.example.com:1080|h1",
+      });
+
+      const result = await getProxyAgentForProvider(
+        provider as Provider,
+        "https://api.anthropic.com/v1/messages",
+        true // Request HTTP/2
+      );
+
+      expect(result).not.toBeNull();
+      expect(result?.http2Enabled).toBe(false); // Should be false for SOCKS
+    });
+
+    it("should mask proxy URL in result", async () => {
+      const provider: Partial<Provider> = {
+        id: 1,
+        name: "Test Provider",
+        proxyUrl: "http://user:[email protected]:8080",
+        proxyFallbackToDirect: false,
+      };
+
+      mockPool.getAgent.mockResolvedValueOnce({
+        agent: mockAgent,
+        isNew: true,
+        cacheKey: "https://api.anthropic.com|http://user:[email protected]:8080|h1",
+      });
+
+      const result = await getProxyAgentForProvider(
+        provider as Provider,
+        "https://api.anthropic.com/v1/messages",
+        false
+      );
+
+      expect(result).not.toBeNull();
+      // proxyUrl should be masked (password hidden)
+      expect(result?.proxyUrl).not.toContain("password");
+      expect(result?.proxyUrl).toContain("***");
+    });
+  });
+
+  describe("ProviderProxyConfig interface", () => {
+    it("should work with minimal ProviderProxyConfig", async () => {
+      const config = {
+        id: 1,
+        proxyUrl: "http://proxy.example.com:8080",
+        proxyFallbackToDirect: false,
+      };
+
+      mockPool.getAgent.mockResolvedValueOnce({
+        agent: mockAgent,
+        isNew: true,
+        cacheKey: "https://api.anthropic.com|http://proxy.example.com:8080|h1",
+      });
+
+      const result = await getProxyAgentForProvider(
+        config,
+        "https://api.anthropic.com/v1/messages",
+        false
+      );
+
+      expect(result).not.toBeNull();
+      expect(result?.cacheKey).toBeDefined();
+    });
+
+    it("should work with optional name field", async () => {
+      const config = {
+        id: 1,
+        name: "My Proxy",
+        proxyUrl: "http://proxy.example.com:8080",
+        proxyFallbackToDirect: true,
+      };
+
+      mockPool.getAgent.mockResolvedValueOnce({
+        agent: mockAgent,
+        isNew: true,
+        cacheKey: "https://api.anthropic.com|http://proxy.example.com:8080|h1",
+      });
+
+      const result = await getProxyAgentForProvider(
+        config,
+        "https://api.anthropic.com/v1/messages",
+        false
+      );
+
+      expect(result).not.toBeNull();
+      expect(result?.fallbackToDirect).toBe(true);
+    });
+  });
+
+  describe("error handling", () => {
+    it("should throw on invalid proxy URL", async () => {
+      const provider: Partial<Provider> = {
+        id: 1,
+        name: "Test Provider",
+        proxyUrl: "not-a-valid-url",
+        proxyFallbackToDirect: false,
+      };
+
+      mockPool.getAgent.mockRejectedValueOnce(new Error("Invalid URL"));
+
+      await expect(
+        getProxyAgentForProvider(
+          provider as Provider,
+          "https://api.anthropic.com/v1/messages",
+          false
+        )
+      ).rejects.toThrow();
+    });
+
+    it("should throw on unsupported proxy protocol", async () => {
+      const provider: Partial<Provider> = {
+        id: 1,
+        name: "Test Provider",
+        proxyUrl: "ftp://proxy.example.com:21",
+        proxyFallbackToDirect: false,
+      };
+
+      mockPool.getAgent.mockRejectedValueOnce(new Error("Unsupported proxy protocol"));
+
+      await expect(
+        getProxyAgentForProvider(
+          provider as Provider,
+          "https://api.anthropic.com/v1/messages",
+          false
+        )
+      ).rejects.toThrow();
+    });
+  });
+});