File size: 3,351 Bytes
cd8bd0a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
/**
 * Combo Resolver — FASE-09 Domain Extraction (T-46)
 *
 * Extracts combo resolution logic from handleChat into a dedicated
 * domain service. Handles model selection based on combo strategy
 * (priority, round-robin, random, least-used).
 *
 * @module domain/comboResolver
 */

/**
 * @typedef {import('./types.js').Combo} Combo
 */
import { getComboStepTarget, getComboStepWeight } from "@/lib/combos/steps";

/** @type {Map<string, number>} Persistent round-robin counters per combo */
const roundRobinCounters = new Map();

/**
 * Resolve which model to use from a combo based on its strategy.
 *
 * @param {Combo} combo - The combo configuration
 * @param {{ modelUsageCounts?: Record<string, number> }} [context] - Optional context
 * @returns {{ model: string, index: number }}
 * @throws {Error} If combo has no models
 */
export function resolveComboModel(combo: any, context: any = {}) {
  const models = combo.models || [];
  if (models.length === 0) {
    throw new Error(`Combo "${combo.name}" has no models configured`);
  }

  // Normalize models to { model, weight } format
  const normalized = models
    .map((entry) => ({
      model: getComboStepTarget(entry) || "",
      weight: getComboStepWeight(entry) || 1,
    }))
    .filter((entry) => entry.model);

  const strategy = combo.strategy || "priority";

  switch (strategy) {
    case "priority":
      return { model: normalized[0].model, index: 0 };

    case "round-robin": {
      // Persistent counter per combo for deterministic round-robin
      const comboKey = combo.id || combo.name || "default";
      if (!roundRobinCounters.has(comboKey)) {
        roundRobinCounters.set(comboKey, 0);
      }
      const counter = roundRobinCounters.get(comboKey);
      const index = counter % normalized.length;
      roundRobinCounters.set(comboKey, counter + 1);
      return { model: normalized[index].model, index };
    }

    case "random": {
      // Weighted random selection
      const totalWeight = normalized.reduce((sum, m) => sum + (m.weight || 1), 0);
      let rand = Math.random() * totalWeight;

      for (let i = 0; i < normalized.length; i++) {
        rand -= normalized[i].weight || 1;
        if (rand <= 0) {
          return { model: normalized[i].model, index: i };
        }
      }
      return { model: normalized[0].model, index: 0 };
    }

    case "least-used": {
      const usageCounts = context.modelUsageCounts || {};
      let minUsage = Infinity;
      let minIndex = 0;

      for (let i = 0; i < normalized.length; i++) {
        const usage = usageCounts[normalized[i].model] || 0;
        if (usage < minUsage) {
          minUsage = usage;
          minIndex = i;
        }
      }

      return { model: normalized[minIndex].model, index: minIndex };
    }

    default:
      return { model: normalized[0].model, index: 0 };
  }
}

/**
 * Get the fallback models for a combo (all models except the primary).
 *
 * @param {Combo} combo
 * @param {number} primaryIndex - Index of the primary model
 * @returns {string[]} Remaining models in order
 */
export function getComboFallbacks(combo, primaryIndex) {
  const models = (combo.models || [])
    .map((entry) => getComboStepTarget(entry))
    .filter((entry): entry is string => !!entry);
  return [...models.slice(primaryIndex + 1), ...models.slice(0, primaryIndex)];
}