@relayplane/proxy 0.1.7 → 0.1.9

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/dist/cli.mjs CHANGED
@@ -1568,12 +1568,127 @@ ${input.prompt}` : input.prompt;
1568
1568
  }
1569
1569
  };
1570
1570
 
1571
+ // src/config.ts
1572
+ import * as fs2 from "fs";
1573
+ import * as path2 from "path";
1574
+ import * as os2 from "os";
1575
+ import { z } from "zod";
1576
+ var StrategySchema = z.object({
1577
+ model: z.string(),
1578
+ minConfidence: z.number().min(0).max(1).optional(),
1579
+ fallback: z.string().optional()
1580
+ });
1581
+ var AuthSchema = z.object({
1582
+ anthropicApiKey: z.string().optional(),
1583
+ anthropicMaxToken: z.string().optional(),
1584
+ useMaxForModels: z.array(z.string()).optional()
1585
+ // Default: ['opus']
1586
+ }).optional();
1587
+ var ConfigSchema = z.object({
1588
+ strategies: z.record(z.string(), StrategySchema).optional(),
1589
+ defaults: z.object({
1590
+ qualityModel: z.string().optional(),
1591
+ costModel: z.string().optional()
1592
+ }).optional(),
1593
+ auth: AuthSchema
1594
+ });
1595
+ var DEFAULT_CONFIG = {
1596
+ strategies: {
1597
+ code_review: { model: "anthropic:claude-sonnet-4-20250514" },
1598
+ code_generation: { model: "anthropic:claude-3-5-haiku-latest" },
1599
+ analysis: { model: "anthropic:claude-sonnet-4-20250514" },
1600
+ summarization: { model: "anthropic:claude-3-5-haiku-latest" },
1601
+ creative_writing: { model: "anthropic:claude-sonnet-4-20250514" },
1602
+ data_extraction: { model: "anthropic:claude-3-5-haiku-latest" },
1603
+ translation: { model: "anthropic:claude-3-5-haiku-latest" },
1604
+ question_answering: { model: "anthropic:claude-3-5-haiku-latest" },
1605
+ general: { model: "anthropic:claude-3-5-haiku-latest" }
1606
+ },
1607
+ defaults: {
1608
+ qualityModel: "claude-sonnet-4-20250514",
1609
+ costModel: "claude-3-5-haiku-latest"
1610
+ }
1611
+ };
1612
+ function getConfigPath() {
1613
+ return path2.join(os2.homedir(), ".relayplane", "config.json");
1614
+ }
1615
+ function writeDefaultConfig() {
1616
+ const configPath = getConfigPath();
1617
+ const dir = path2.dirname(configPath);
1618
+ if (!fs2.existsSync(dir)) {
1619
+ fs2.mkdirSync(dir, { recursive: true });
1620
+ }
1621
+ if (!fs2.existsSync(configPath)) {
1622
+ fs2.writeFileSync(
1623
+ configPath,
1624
+ JSON.stringify(DEFAULT_CONFIG, null, 2) + "\n",
1625
+ "utf-8"
1626
+ );
1627
+ console.log(`[relayplane] Created default config at ${configPath}`);
1628
+ }
1629
+ }
1630
+ function loadConfig() {
1631
+ const configPath = getConfigPath();
1632
+ writeDefaultConfig();
1633
+ try {
1634
+ const raw = fs2.readFileSync(configPath, "utf-8");
1635
+ const parsed = JSON.parse(raw);
1636
+ const validated = ConfigSchema.parse(parsed);
1637
+ return validated;
1638
+ } catch (err) {
1639
+ if (err instanceof z.ZodError) {
1640
+ console.error(`[relayplane] Invalid config: ${err.message}`);
1641
+ } else if (err instanceof SyntaxError) {
1642
+ console.error(`[relayplane] Config JSON parse error: ${err.message}`);
1643
+ } else {
1644
+ console.error(`[relayplane] Failed to load config: ${err}`);
1645
+ }
1646
+ console.log("[relayplane] Using default config");
1647
+ return DEFAULT_CONFIG;
1648
+ }
1649
+ }
1650
+ function getStrategy(config, taskType) {
1651
+ return config.strategies?.[taskType] ?? null;
1652
+ }
1653
+ function getAnthropicAuth(config, model) {
1654
+ const auth = config.auth;
1655
+ const useMaxForModels = auth?.useMaxForModels ?? ["opus"];
1656
+ const shouldUseMax = useMaxForModels.some((m) => model.toLowerCase().includes(m.toLowerCase()));
1657
+ if (shouldUseMax && auth?.anthropicMaxToken) {
1658
+ return { type: "max", value: auth.anthropicMaxToken };
1659
+ }
1660
+ const apiKey = auth?.anthropicApiKey ?? process.env["ANTHROPIC_API_KEY"];
1661
+ if (apiKey) {
1662
+ return { type: "apiKey", value: apiKey };
1663
+ }
1664
+ return null;
1665
+ }
1666
+ function watchConfig(onChange) {
1667
+ const configPath = getConfigPath();
1668
+ const dir = path2.dirname(configPath);
1669
+ if (!fs2.existsSync(dir)) {
1670
+ fs2.mkdirSync(dir, { recursive: true });
1671
+ }
1672
+ let debounceTimer = null;
1673
+ fs2.watch(dir, (eventType, filename) => {
1674
+ if (filename === "config.json") {
1675
+ if (debounceTimer) clearTimeout(debounceTimer);
1676
+ debounceTimer = setTimeout(() => {
1677
+ console.log("[relayplane] Config file changed, reloading...");
1678
+ const newConfig = loadConfig();
1679
+ onChange(newConfig);
1680
+ }, 100);
1681
+ }
1682
+ });
1683
+ }
1684
+
1571
1685
  // src/proxy.ts
1572
- var VERSION = "0.1.7";
1686
+ var VERSION = "0.1.9";
1573
1687
  var recentRuns = [];
1574
1688
  var MAX_RECENT_RUNS = 100;
1575
1689
  var modelCounts = {};
1576
1690
  var serverStartTime = 0;
1691
+ var currentConfig = loadConfig();
1577
1692
  var DEFAULT_ENDPOINTS = {
1578
1693
  anthropic: {
1579
1694
  baseUrl: "https://api.anthropic.com/v1",
@@ -1636,13 +1751,17 @@ function extractPromptText(messages) {
1636
1751
  return "";
1637
1752
  }).join("\n");
1638
1753
  }
1639
- async function forwardToAnthropic(request, targetModel, apiKey, betaHeaders) {
1754
+ async function forwardToAnthropic(request, targetModel, auth, betaHeaders) {
1640
1755
  const anthropicBody = buildAnthropicBody(request, targetModel, false);
1641
1756
  const headers = {
1642
1757
  "Content-Type": "application/json",
1643
- "x-api-key": apiKey,
1644
1758
  "anthropic-version": "2023-06-01"
1645
1759
  };
1760
+ if (auth.type === "max") {
1761
+ headers["Authorization"] = `Bearer ${auth.value}`;
1762
+ } else {
1763
+ headers["x-api-key"] = auth.value;
1764
+ }
1646
1765
  if (betaHeaders) {
1647
1766
  headers["anthropic-beta"] = betaHeaders;
1648
1767
  }
@@ -1653,13 +1772,17 @@ async function forwardToAnthropic(request, targetModel, apiKey, betaHeaders) {
1653
1772
  });
1654
1773
  return response;
1655
1774
  }
1656
- async function forwardToAnthropicStream(request, targetModel, apiKey, betaHeaders) {
1775
+ async function forwardToAnthropicStream(request, targetModel, auth, betaHeaders) {
1657
1776
  const anthropicBody = buildAnthropicBody(request, targetModel, true);
1658
1777
  const headers = {
1659
1778
  "Content-Type": "application/json",
1660
- "x-api-key": apiKey,
1661
1779
  "anthropic-version": "2023-06-01"
1662
1780
  };
1781
+ if (auth.type === "max") {
1782
+ headers["Authorization"] = `Bearer ${auth.value}`;
1783
+ } else {
1784
+ headers["x-api-key"] = auth.value;
1785
+ }
1663
1786
  if (betaHeaders) {
1664
1787
  headers["anthropic-beta"] = betaHeaders;
1665
1788
  }
@@ -2424,42 +2547,65 @@ async function startProxy(config = {}) {
2424
2547
  const confidence = getInferenceConfidence(promptText, taskType);
2425
2548
  log(`Inferred task: ${taskType} (confidence: ${confidence.toFixed(2)})`);
2426
2549
  if (routingMode !== "passthrough") {
2427
- const rule = relay.routing.get(taskType);
2428
- if (rule && rule.preferredModel) {
2429
- const parsed = parsePreferredModel(rule.preferredModel);
2550
+ const configStrategy = getStrategy(currentConfig, taskType);
2551
+ if (configStrategy) {
2552
+ const parsed = parsePreferredModel(configStrategy.model);
2430
2553
  if (parsed) {
2431
2554
  targetProvider = parsed.provider;
2432
2555
  targetModel = parsed.model;
2433
- log(`Using learned rule: ${rule.preferredModel}`);
2556
+ log(`Using config strategy: ${configStrategy.model}`);
2557
+ }
2558
+ }
2559
+ if (!configStrategy) {
2560
+ const rule = relay.routing.get(taskType);
2561
+ if (rule && rule.preferredModel) {
2562
+ const parsed = parsePreferredModel(rule.preferredModel);
2563
+ if (parsed) {
2564
+ targetProvider = parsed.provider;
2565
+ targetModel = parsed.model;
2566
+ log(`Using learned rule: ${rule.preferredModel}`);
2567
+ } else {
2568
+ const defaultRoute = DEFAULT_ROUTING[taskType];
2569
+ targetProvider = defaultRoute.provider;
2570
+ targetModel = defaultRoute.model;
2571
+ }
2434
2572
  } else {
2435
2573
  const defaultRoute = DEFAULT_ROUTING[taskType];
2436
2574
  targetProvider = defaultRoute.provider;
2437
2575
  targetModel = defaultRoute.model;
2438
2576
  }
2439
- } else {
2440
- const defaultRoute = DEFAULT_ROUTING[taskType];
2441
- targetProvider = defaultRoute.provider;
2442
- targetModel = defaultRoute.model;
2443
2577
  }
2444
2578
  if (routingMode === "cost") {
2445
- const simpleTasks = ["summarization", "data_extraction", "translation", "question_answering"];
2446
- if (simpleTasks.includes(taskType)) {
2447
- targetModel = "claude-3-5-haiku-latest";
2448
- targetProvider = "anthropic";
2449
- }
2579
+ const costModel = currentConfig.defaults?.costModel || "claude-3-5-haiku-latest";
2580
+ targetModel = costModel;
2581
+ targetProvider = "anthropic";
2582
+ log(`Cost mode: using ${costModel}`);
2450
2583
  } else if (routingMode === "quality") {
2451
- const qualityModel = process.env["RELAYPLANE_QUALITY_MODEL"] || "claude-sonnet-4-20250514";
2584
+ const qualityModel = currentConfig.defaults?.qualityModel || process.env["RELAYPLANE_QUALITY_MODEL"] || "claude-sonnet-4-20250514";
2452
2585
  targetModel = qualityModel;
2453
2586
  targetProvider = "anthropic";
2587
+ log(`Quality mode: using ${qualityModel}`);
2454
2588
  }
2455
2589
  }
2456
2590
  log(`Routing to: ${targetProvider}/${targetModel}`);
2457
- const apiKeyEnv = DEFAULT_ENDPOINTS[targetProvider]?.apiKeyEnv ?? `${targetProvider.toUpperCase()}_API_KEY`;
2458
- const apiKey = process.env[apiKeyEnv];
2459
- if (!apiKey) {
2460
- res.writeHead(500, { "Content-Type": "application/json" });
2461
- res.end(JSON.stringify({ error: `Missing ${apiKeyEnv} environment variable` }));
2462
- return;
2591
+ let apiKey;
2592
+ let anthropicAuth = null;
2593
+ if (targetProvider === "anthropic") {
2594
+ anthropicAuth = getAnthropicAuth(currentConfig, targetModel);
2595
+ if (!anthropicAuth) {
2596
+ res.writeHead(500, { "Content-Type": "application/json" });
2597
+ res.end(JSON.stringify({ error: "No Anthropic auth configured (set ANTHROPIC_API_KEY or config.auth.anthropicMaxToken)" }));
2598
+ return;
2599
+ }
2600
+ log(`Using ${anthropicAuth.type === "max" ? "MAX token" : "API key"} auth for ${targetModel}`);
2601
+ } else {
2602
+ const apiKeyEnv = DEFAULT_ENDPOINTS[targetProvider]?.apiKeyEnv ?? `${targetProvider.toUpperCase()}_API_KEY`;
2603
+ apiKey = process.env[apiKeyEnv];
2604
+ if (!apiKey) {
2605
+ res.writeHead(500, { "Content-Type": "application/json" });
2606
+ res.end(JSON.stringify({ error: `Missing ${apiKeyEnv} environment variable` }));
2607
+ return;
2608
+ }
2463
2609
  }
2464
2610
  const startTime = Date.now();
2465
2611
  const betaHeaders = req.headers["anthropic-beta"];
@@ -2470,6 +2616,7 @@ async function startProxy(config = {}) {
2470
2616
  targetProvider,
2471
2617
  targetModel,
2472
2618
  apiKey,
2619
+ anthropicAuth,
2473
2620
  relay,
2474
2621
  promptText,
2475
2622
  taskType,
@@ -2486,6 +2633,7 @@ async function startProxy(config = {}) {
2486
2633
  targetProvider,
2487
2634
  targetModel,
2488
2635
  apiKey,
2636
+ anthropicAuth,
2489
2637
  relay,
2490
2638
  promptText,
2491
2639
  taskType,
@@ -2497,6 +2645,10 @@ async function startProxy(config = {}) {
2497
2645
  );
2498
2646
  }
2499
2647
  });
2648
+ watchConfig((newConfig) => {
2649
+ currentConfig = newConfig;
2650
+ console.log("[relayplane] Config reloaded");
2651
+ });
2500
2652
  return new Promise((resolve, reject) => {
2501
2653
  server.on("error", reject);
2502
2654
  server.listen(port, host, () => {
@@ -2505,17 +2657,19 @@ async function startProxy(config = {}) {
2505
2657
  console.log(` Models: relayplane:auto, relayplane:cost, relayplane:quality`);
2506
2658
  console.log(` Endpoint: POST /v1/chat/completions`);
2507
2659
  console.log(` Stats: GET /stats, /runs, /health`);
2660
+ console.log(` Config: ~/.relayplane/config.json (hot-reload enabled)`);
2508
2661
  console.log(` Streaming: \u2705 Enabled`);
2509
2662
  resolve(server);
2510
2663
  });
2511
2664
  });
2512
2665
  }
2513
- async function handleStreamingRequest(res, request, targetProvider, targetModel, apiKey, relay, promptText, taskType, confidence, routingMode, startTime, log, betaHeaders) {
2666
+ async function handleStreamingRequest(res, request, targetProvider, targetModel, apiKey, anthropicAuth, relay, promptText, taskType, confidence, routingMode, startTime, log, betaHeaders) {
2514
2667
  let providerResponse;
2515
2668
  try {
2516
2669
  switch (targetProvider) {
2517
2670
  case "anthropic":
2518
- providerResponse = await forwardToAnthropicStream(request, targetModel, apiKey, betaHeaders);
2671
+ if (!anthropicAuth) throw new Error("No Anthropic auth");
2672
+ providerResponse = await forwardToAnthropicStream(request, targetModel, anthropicAuth, betaHeaders);
2519
2673
  break;
2520
2674
  case "google":
2521
2675
  providerResponse = await forwardToGeminiStream(request, targetModel, apiKey);
@@ -2593,13 +2747,14 @@ async function handleStreamingRequest(res, request, targetProvider, targetModel,
2593
2747
  });
2594
2748
  res.end();
2595
2749
  }
2596
- async function handleNonStreamingRequest(res, request, targetProvider, targetModel, apiKey, relay, promptText, taskType, confidence, routingMode, startTime, log, betaHeaders) {
2750
+ async function handleNonStreamingRequest(res, request, targetProvider, targetModel, apiKey, anthropicAuth, relay, promptText, taskType, confidence, routingMode, startTime, log, betaHeaders) {
2597
2751
  let providerResponse;
2598
2752
  let responseData;
2599
2753
  try {
2600
2754
  switch (targetProvider) {
2601
2755
  case "anthropic": {
2602
- providerResponse = await forwardToAnthropic(request, targetModel, apiKey, betaHeaders);
2756
+ if (!anthropicAuth) throw new Error("No Anthropic auth");
2757
+ providerResponse = await forwardToAnthropic(request, targetModel, anthropicAuth, betaHeaders);
2603
2758
  const rawData = await providerResponse.json();
2604
2759
  if (!providerResponse.ok) {
2605
2760
  res.writeHead(providerResponse.status, { "Content-Type": "application/json" });