dynamic-model-selection.ts 38 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348
  1. import { buildModelKeyAliases } from './model-key-normalization';
  2. import { resolveAgentWithPrecedence } from './precedence-resolver';
  3. import { rankModelsV2, scoreCandidateV2 } from './scoring-v2';
  4. import type {
  5. DiscoveredModel,
  6. DynamicModelPlan,
  7. ExternalSignalMap,
  8. InstallConfig,
  9. ScoringEngineVersion,
  10. } from './types';
  11. const AGENTS = [
  12. 'orchestrator',
  13. 'oracle',
  14. 'designer',
  15. 'explorer',
  16. 'librarian',
  17. 'fixer',
  18. ] as const;
  19. type AgentName = (typeof AGENTS)[number];
  20. export type V1RankedScore = {
  21. model: string;
  22. totalScore: number;
  23. baseScore: number;
  24. externalSignalBoost: number;
  25. };
  26. const FREE_BIASED_PROVIDERS = new Set(['opencode']);
  27. const PRIMARY_ASSIGNMENT_ORDER: AgentName[] = [
  28. 'oracle',
  29. 'orchestrator',
  30. 'fixer',
  31. 'designer',
  32. 'librarian',
  33. 'explorer',
  34. ];
  35. const ROLE_VARIANT: Record<AgentName, string | undefined> = {
  36. orchestrator: undefined,
  37. oracle: 'high',
  38. designer: 'medium',
  39. explorer: 'low',
  40. librarian: 'low',
  41. fixer: 'low',
  42. };
  43. function getEnabledProviders(config: InstallConfig): string[] {
  44. const providers: string[] = [];
  45. if (config.hasOpenAI) providers.push('openai');
  46. if (config.hasAnthropic) providers.push('anthropic');
  47. if (config.hasCopilot) providers.push('github-copilot');
  48. if (config.hasZaiPlan) providers.push('zai-coding-plan');
  49. if (config.hasKimi) providers.push('kimi-for-coding');
  50. if (config.hasAntigravity) providers.push('google');
  51. if (config.hasChutes) providers.push('chutes');
  52. if (config.useOpenCodeFreeModels) providers.push('opencode');
  53. return providers;
  54. }
  55. function tokenScore(name: string, re: RegExp, points: number): number {
  56. return re.test(name) ? points : 0;
  57. }
  58. function statusScore(status: DiscoveredModel['status']): number {
  59. if (status === 'active') return 20;
  60. if (status === 'beta') return 8;
  61. if (status === 'alpha') return -5;
  62. return -40;
  63. }
  64. type VersionFamilyInfo = {
  65. family: string;
  66. version: [number, number, number];
  67. confidence: number;
  68. prereleasePenalty: number;
  69. };
  70. function toVersionTuple(
  71. major: string,
  72. minor?: string,
  73. patch?: string,
  74. ): [number, number, number] {
  75. return [
  76. Number.parseInt(major, 10) || 0,
  77. Number.parseInt(minor ?? '0', 10) || 0,
  78. Number.parseInt(patch ?? '0', 10) || 0,
  79. ];
  80. }
  81. function compareVersionTuple(
  82. a: [number, number, number],
  83. b: [number, number, number],
  84. ): number {
  85. if (a[0] !== b[0]) return a[0] - b[0];
  86. if (a[1] !== b[1]) return a[1] - b[1];
  87. return a[2] - b[2];
  88. }
  89. function extractVersionFamily(
  90. model: DiscoveredModel,
  91. ): VersionFamilyInfo | null {
  92. const text = `${model.model} ${model.name}`.toLowerCase();
  93. const gpt = text.match(/\bgpt[-_ ]?(\d+)(?:[.-](\d+))?(?:[.-](\d+))?\b/);
  94. if (gpt) {
  95. return {
  96. family: 'gpt',
  97. version: toVersionTuple(gpt[1] ?? '0', gpt[2], gpt[3]),
  98. confidence: 1,
  99. prereleasePenalty: /preview|experimental|exp|\brc\b/.test(text) ? -2 : 0,
  100. };
  101. }
  102. const gemini = text.match(
  103. /\bgemini[-_ ]?(\d+)(?:[.-](\d+))?(?:[.-](\d+))?\b/,
  104. );
  105. if (gemini) {
  106. return {
  107. family: 'gemini',
  108. version: toVersionTuple(gemini[1] ?? '0', gemini[2], gemini[3]),
  109. confidence: 1,
  110. prereleasePenalty: /preview|experimental|exp|\brc\b/.test(text) ? -2 : 0,
  111. };
  112. }
  113. const kimi = text.match(/\bkimi[-_ ]?k(\d+)(?:[.-]?(\d+))?(?:[.-](\d+))?\b/);
  114. if (kimi) {
  115. return {
  116. family: 'kimi-k',
  117. version: toVersionTuple(kimi[1] ?? '0', kimi[2], kimi[3]),
  118. confidence: 1,
  119. prereleasePenalty: /preview|experimental|exp|\brc\b/.test(text) ? -2 : 0,
  120. };
  121. }
  122. const generic = text.match(
  123. /\b([a-z][a-z0-9-]{1,20})[-_ ](\d+)(?:[.-](\d+))?(?:[.-](\d+))?\b/,
  124. );
  125. if (generic) {
  126. return {
  127. family: generic[1] ?? 'generic',
  128. version: toVersionTuple(generic[2] ?? '0', generic[3], generic[4]),
  129. confidence: 0.7,
  130. prereleasePenalty: /preview|experimental|exp|\brc\b/.test(text) ? -2 : 0,
  131. };
  132. }
  133. return null;
  134. }
  135. function getVersionRecencyMap(
  136. models: DiscoveredModel[],
  137. ): Record<string, number> {
  138. const familyVersions = new Map<string, Array<[number, number, number]>>();
  139. const modelInfo = new Map<string, VersionFamilyInfo>();
  140. for (const model of models) {
  141. const info = extractVersionFamily(model);
  142. if (!info) continue;
  143. modelInfo.set(model.model, info);
  144. const current = familyVersions.get(info.family) ?? [];
  145. current.push(info.version);
  146. familyVersions.set(info.family, current);
  147. }
  148. const recencyMap: Record<string, number> = {};
  149. for (const model of models) {
  150. const info = modelInfo.get(model.model);
  151. if (!info) {
  152. recencyMap[model.model] = 0;
  153. continue;
  154. }
  155. const versions = familyVersions.get(info.family) ?? [];
  156. const unique = versions
  157. .map((tuple) => `${tuple[0]}.${tuple[1]}.${tuple[2]}`)
  158. .filter((value, index, arr) => arr.indexOf(value) === index)
  159. .map((value) => {
  160. const [major, minor, patch] = value
  161. .split('.')
  162. .map((v) => Number.parseInt(v, 10) || 0);
  163. return [major, minor, patch] as [number, number, number];
  164. })
  165. .sort(compareVersionTuple);
  166. if (unique.length === 0) {
  167. recencyMap[model.model] = 0;
  168. continue;
  169. }
  170. const index = unique.findIndex(
  171. (tuple) => compareVersionTuple(tuple, info.version) === 0,
  172. );
  173. const percentile = unique.length === 1 ? 0.5 : index / (unique.length - 1);
  174. const raw = -3 + percentile * (12 - -3);
  175. const final = Math.max(
  176. -3,
  177. Math.min(12, raw * info.confidence + info.prereleasePenalty),
  178. );
  179. recencyMap[model.model] = final;
  180. }
  181. return recencyMap;
  182. }
  183. function baseScore(model: DiscoveredModel, versionRecencyBoost = 0): number {
  184. const lowered = `${model.model} ${model.name}`.toLowerCase();
  185. const context = Math.min(model.contextLimit, 1_000_000) / 50_000;
  186. const output = Math.min(model.outputLimit, 300_000) / 30_000;
  187. const deep = tokenScore(
  188. lowered,
  189. /(opus|pro|thinking|reason|r1|gpt-5|k2\.5)/i,
  190. 12,
  191. );
  192. const fast = tokenScore(
  193. lowered,
  194. /(nano|flash|mini|lite|fast|turbo|haiku|small)/i,
  195. 4,
  196. );
  197. const code = tokenScore(lowered, /(codex|coder|code|dev|program)/i, 12);
  198. return (
  199. statusScore(model.status) +
  200. context +
  201. output +
  202. deep +
  203. fast +
  204. code +
  205. versionRecencyBoost +
  206. (model.toolcall ? 25 : 0)
  207. );
  208. }
  209. function hasFlashToken(model: DiscoveredModel): boolean {
  210. return /flash/i.test(`${model.model} ${model.name}`);
  211. }
  212. function isZai47Model(model: DiscoveredModel): boolean {
  213. return (
  214. model.providerID === 'zai-coding-plan' &&
  215. /glm-4\.7/i.test(`${model.model} ${model.name}`)
  216. );
  217. }
  218. function isKimiK25Model(model: DiscoveredModel): boolean {
  219. return /kimi-k2\.?5|k2\.?5/i.test(`${model.model} ${model.name}`);
  220. }
  221. function geminiPreferenceAdjustment(
  222. _agent: AgentName,
  223. model: DiscoveredModel,
  224. ): number {
  225. const lowered = `${model.model} ${model.name}`.toLowerCase();
  226. const isGemini25Pro = /gemini-2\.5-pro/.test(lowered);
  227. return isGemini25Pro ? -14 : 0;
  228. }
  229. function chutesPreferenceAdjustment(
  230. agent: AgentName,
  231. model: DiscoveredModel,
  232. ): number {
  233. if (model.providerID !== 'chutes') return 0;
  234. const lowered = `${model.model} ${model.name}`.toLowerCase();
  235. const isQwen3 = /qwen3/.test(lowered);
  236. const isKimiK25 = /kimi-k2\.5|k2\.5/.test(lowered);
  237. const isMinimaxM21 = /minimax[-_ ]?m2\.1/.test(lowered);
  238. const qwenPenalty: Record<AgentName, number> = {
  239. oracle: -12,
  240. orchestrator: -10,
  241. fixer: -22,
  242. designer: -14,
  243. librarian: -18,
  244. explorer: -10,
  245. };
  246. const kimiBonus: Record<AgentName, number> = {
  247. oracle: 0,
  248. orchestrator: 0,
  249. fixer: 8,
  250. designer: 6,
  251. librarian: 5,
  252. explorer: 4,
  253. };
  254. const minimaxBonus: Record<AgentName, number> = {
  255. oracle: 0,
  256. orchestrator: 0,
  257. fixer: 10,
  258. designer: 3,
  259. librarian: 9,
  260. explorer: 12,
  261. };
  262. return (
  263. (isQwen3 ? qwenPenalty[agent] : 0) +
  264. (isKimiK25 ? kimiBonus[agent] : 0) +
  265. (isMinimaxM21 ? minimaxBonus[agent] : 0)
  266. );
  267. }
  268. function modelLookupKeys(model: DiscoveredModel): string[] {
  269. return buildModelKeyAliases(model.model);
  270. }
  271. function roleScore(
  272. agent: AgentName,
  273. model: DiscoveredModel,
  274. versionRecencyBoost = 0,
  275. ): number {
  276. const lowered = `${model.model} ${model.name}`.toLowerCase();
  277. const reasoning = model.reasoning ? 1 : 0;
  278. const toolcall = model.toolcall ? 1 : 0;
  279. const attachment = model.attachment ? 1 : 0;
  280. const context = Math.min(model.contextLimit, 1_000_000) / 60_000;
  281. const output = Math.min(model.outputLimit, 300_000) / 40_000;
  282. const deep = tokenScore(
  283. lowered,
  284. /(opus|pro|thinking|reason|r1|gpt-5|k2\.5)/i,
  285. 1,
  286. );
  287. const fast = tokenScore(
  288. lowered,
  289. /(nano|flash|mini|lite|fast|turbo|haiku|small)/i,
  290. 1,
  291. );
  292. const code = tokenScore(lowered, /(codex|coder|code|dev|program)/i, 1);
  293. if (
  294. (agent === 'orchestrator' ||
  295. agent === 'explorer' ||
  296. agent === 'librarian' ||
  297. agent === 'fixer') &&
  298. !model.toolcall
  299. ) {
  300. return -10_000;
  301. }
  302. if (model.status === 'deprecated') {
  303. return -5_000;
  304. }
  305. const score = baseScore(model, versionRecencyBoost);
  306. const flash = hasFlashToken(model);
  307. const isZai47 = isZai47Model(model);
  308. const zai47Flash = isZai47 && flash;
  309. const zai47NonFlash = isZai47 && !flash;
  310. const providerBias =
  311. model.providerID === 'openai'
  312. ? 3
  313. : model.providerID === 'anthropic'
  314. ? 3
  315. : model.providerID === 'kimi-for-coding'
  316. ? 2
  317. : model.providerID === 'google'
  318. ? 2
  319. : model.providerID === 'github-copilot'
  320. ? 1
  321. : model.providerID === 'zai-coding-plan'
  322. ? 0
  323. : model.providerID === 'chutes'
  324. ? 2
  325. : model.providerID === 'opencode'
  326. ? -2
  327. : 0;
  328. const geminiAdjustment = geminiPreferenceAdjustment(agent, model);
  329. const chutesAdjustment = chutesPreferenceAdjustment(agent, model);
  330. if (agent === 'orchestrator') {
  331. const flashAdjustment = flash ? -22 : 0;
  332. const zaiAdjustment = zai47NonFlash ? 16 : zai47Flash ? -18 : 0;
  333. const nonReasoningFlashPenalty = flash && !model.reasoning ? -16 : 0;
  334. return (
  335. score +
  336. reasoning * 40 +
  337. toolcall * 25 +
  338. deep * 10 +
  339. code * 8 +
  340. context +
  341. flashAdjustment +
  342. zaiAdjustment +
  343. nonReasoningFlashPenalty +
  344. geminiAdjustment +
  345. chutesAdjustment +
  346. providerBias
  347. );
  348. }
  349. if (agent === 'oracle') {
  350. const flashAdjustment = flash ? -34 : 0;
  351. const zaiAdjustment = zai47NonFlash ? 16 : zai47Flash ? -18 : 0;
  352. const nonReasoningFlashPenalty = flash && !model.reasoning ? -16 : 0;
  353. return (
  354. score +
  355. reasoning * 55 +
  356. deep * 18 +
  357. context * 1.2 +
  358. toolcall * 10 +
  359. flashAdjustment +
  360. zaiAdjustment +
  361. nonReasoningFlashPenalty +
  362. geminiAdjustment +
  363. chutesAdjustment +
  364. providerBias
  365. );
  366. }
  367. if (agent === 'designer') {
  368. const flashAdjustment = flash ? -8 : 0;
  369. const zaiAdjustment = zai47NonFlash ? 10 : zai47Flash ? -8 : 0;
  370. return (
  371. score +
  372. attachment * 25 +
  373. reasoning * 18 +
  374. toolcall * 15 +
  375. context * 0.8 +
  376. output +
  377. flashAdjustment +
  378. zaiAdjustment +
  379. geminiAdjustment +
  380. chutesAdjustment +
  381. providerBias
  382. );
  383. }
  384. if (agent === 'explorer') {
  385. const flashAdjustment = flash ? 26 : -10;
  386. const zaiAdjustment = zai47NonFlash ? 2 : zai47Flash ? 6 : 0;
  387. const deepPenalty = deep * -18;
  388. return (
  389. score +
  390. fast * 68 +
  391. toolcall * 28 +
  392. reasoning * 2 +
  393. context * 0.2 +
  394. flashAdjustment +
  395. zaiAdjustment +
  396. deepPenalty +
  397. geminiAdjustment +
  398. chutesAdjustment +
  399. providerBias
  400. );
  401. }
  402. if (agent === 'librarian') {
  403. const flashAdjustment = flash ? -12 : 0;
  404. const zaiAdjustment = zai47NonFlash ? 16 : zai47Flash ? -18 : 0;
  405. return (
  406. score +
  407. context * 30 +
  408. toolcall * 22 +
  409. reasoning * 15 +
  410. output * 10 +
  411. flashAdjustment +
  412. zaiAdjustment +
  413. geminiAdjustment +
  414. chutesAdjustment +
  415. providerBias
  416. );
  417. }
  418. const flashAdjustment = flash ? -18 : 0;
  419. const zaiAdjustment = zai47NonFlash ? 16 : zai47Flash ? -18 : 0;
  420. const nonReasoningFlashPenalty = flash && !model.reasoning ? -16 : 0;
  421. return (
  422. score +
  423. code * 28 +
  424. toolcall * 24 +
  425. fast * 18 +
  426. reasoning * 14 +
  427. output * 8 +
  428. flashAdjustment +
  429. zaiAdjustment +
  430. nonReasoningFlashPenalty +
  431. geminiAdjustment +
  432. chutesAdjustment +
  433. providerBias
  434. );
  435. }
  436. function getExternalSignalBoost(
  437. agent: AgentName,
  438. model: DiscoveredModel,
  439. externalSignals: ExternalSignalMap | undefined,
  440. ): number {
  441. if (!externalSignals) return 0;
  442. const signal = modelLookupKeys(model)
  443. .map((key) => externalSignals[key])
  444. .find((item) => item !== undefined);
  445. if (!signal) return 0;
  446. const qualityScore = signal.qualityScore ?? 0;
  447. const codingScore = signal.codingScore ?? 0;
  448. const latencySeconds = signal.latencySeconds;
  449. const blendedPrice =
  450. signal.inputPricePer1M !== undefined &&
  451. signal.outputPricePer1M !== undefined
  452. ? signal.inputPricePer1M * 0.75 + signal.outputPricePer1M * 0.25
  453. : (signal.inputPricePer1M ?? signal.outputPricePer1M ?? 0);
  454. if (agent === 'explorer') {
  455. const qualityBoost = qualityScore * 0.05;
  456. const codingBoost = codingScore * 0.08;
  457. const latencyPenalty =
  458. typeof latencySeconds === 'number' && Number.isFinite(latencySeconds)
  459. ? Math.min(latencySeconds, 12) * 3.2 +
  460. (latencySeconds > 7 ? 16 : latencySeconds > 4 ? 10 : 0)
  461. : 0;
  462. const pricePenalty = Math.min(blendedPrice, 30) * 0.03;
  463. const qualityFloorPenalty =
  464. qualityScore > 0 && qualityScore < 35 ? (35 - qualityScore) * 0.8 : 0;
  465. const boost =
  466. qualityBoost +
  467. codingBoost -
  468. latencyPenalty -
  469. pricePenalty -
  470. qualityFloorPenalty;
  471. return Math.max(-90, Math.min(25, boost));
  472. }
  473. const qualityBoost = qualityScore * 0.16;
  474. const codingBoost = codingScore * 0.24;
  475. const latencyPenalty =
  476. typeof latencySeconds === 'number' && Number.isFinite(latencySeconds)
  477. ? Math.min(latencySeconds, 25) * 0.22
  478. : 0;
  479. const pricePenalty = Math.min(blendedPrice, 30) * 0.08;
  480. const boost = qualityBoost + codingBoost - latencyPenalty - pricePenalty;
  481. return Math.max(-30, Math.min(45, boost));
  482. }
  483. function rankModels(
  484. models: DiscoveredModel[],
  485. agent: AgentName,
  486. externalSignals?: ExternalSignalMap,
  487. ): DiscoveredModel[] {
  488. const versionRecencyMap = getVersionRecencyMap(models);
  489. return [...models].sort((a, b) => {
  490. const scoreA =
  491. roleScore(agent, a, versionRecencyMap[a.model] ?? 0) +
  492. getExternalSignalBoost(agent, a, externalSignals);
  493. const scoreB =
  494. roleScore(agent, b, versionRecencyMap[b.model] ?? 0) +
  495. getExternalSignalBoost(agent, b, externalSignals);
  496. const scoreDelta = scoreB - scoreA;
  497. if (scoreDelta !== 0) return scoreDelta;
  498. const providerTieBreak = a.providerID.localeCompare(b.providerID);
  499. if (providerTieBreak !== 0) return providerTieBreak;
  500. return a.model.localeCompare(b.model);
  501. });
  502. }
  503. export function rankModelsV1WithBreakdown(
  504. models: DiscoveredModel[],
  505. agent: AgentName,
  506. externalSignals?: ExternalSignalMap,
  507. ): V1RankedScore[] {
  508. const versionRecencyMap = getVersionRecencyMap(models);
  509. return [...models]
  510. .map((model) => {
  511. const base = roleScore(agent, model, versionRecencyMap[model.model] ?? 0);
  512. const boost = getExternalSignalBoost(agent, model, externalSignals);
  513. return {
  514. model: model.model,
  515. baseScore: Math.round(base * 1000) / 1000,
  516. externalSignalBoost: Math.round(boost * 1000) / 1000,
  517. totalScore: Math.round((base + boost) * 1000) / 1000,
  518. };
  519. })
  520. .sort((a, b) => {
  521. if (a.totalScore !== b.totalScore) return b.totalScore - a.totalScore;
  522. return a.model.localeCompare(b.model);
  523. });
  524. }
  525. function combinedScore(
  526. agent: AgentName,
  527. model: DiscoveredModel,
  528. externalSignals?: ExternalSignalMap,
  529. versionRecencyMap?: Record<string, number>,
  530. ): number {
  531. return (
  532. roleScore(agent, model, versionRecencyMap?.[model.model] ?? 0) +
  533. getExternalSignalBoost(agent, model, externalSignals)
  534. );
  535. }
  536. function effectiveEngine(engineVersion: ScoringEngineVersion): 'v1' | 'v2' {
  537. return engineVersion === 'v2' ? 'v2' : 'v1';
  538. }
  539. function scoreForEngine(
  540. engineVersion: ScoringEngineVersion,
  541. agent: AgentName,
  542. model: DiscoveredModel,
  543. externalSignals: ExternalSignalMap | undefined,
  544. versionRecencyMap: Record<string, number>,
  545. ): number {
  546. if (effectiveEngine(engineVersion) === 'v2') {
  547. return scoreCandidateV2(model, agent, externalSignals).totalScore;
  548. }
  549. return combinedScore(agent, model, externalSignals, versionRecencyMap);
  550. }
  551. function selectTopModelsPerProvider(
  552. models: DiscoveredModel[],
  553. engineVersion: ScoringEngineVersion,
  554. externalSignals: ExternalSignalMap | undefined,
  555. versionRecencyMap: Record<string, number>,
  556. ): DiscoveredModel[] {
  557. const byProvider = new Map<string, DiscoveredModel[]>();
  558. for (const model of models) {
  559. const current = byProvider.get(model.providerID) ?? [];
  560. current.push(model);
  561. byProvider.set(model.providerID, current);
  562. }
  563. const selected: DiscoveredModel[] = [];
  564. for (const providerModels of byProvider.values()) {
  565. if (providerModels.length <= 2) {
  566. selected.push(...providerModels);
  567. continue;
  568. }
  569. const ranked = [...providerModels]
  570. .map((model) => {
  571. const total = AGENTS.reduce((sum, agent) => {
  572. return (
  573. sum +
  574. scoreForEngine(
  575. engineVersion,
  576. agent,
  577. model,
  578. externalSignals,
  579. versionRecencyMap,
  580. )
  581. );
  582. }, 0);
  583. return {
  584. model,
  585. score: total / AGENTS.length,
  586. };
  587. })
  588. .sort((a, b) => {
  589. if (a.score !== b.score) return b.score - a.score;
  590. return a.model.model.localeCompare(b.model.model);
  591. })
  592. .slice(0, 2)
  593. .map((entry) => entry.model);
  594. selected.push(...ranked);
  595. }
  596. return selected;
  597. }
  598. function countProviderUsage(
  599. agents: Record<string, { model: string; variant?: string }>,
  600. ): Map<string, number> {
  601. const counts = new Map<string, number>();
  602. for (const assignment of Object.values(agents)) {
  603. const provider = assignment.model.split('/')[0];
  604. if (!provider) continue;
  605. counts.set(provider, (counts.get(provider) ?? 0) + 1);
  606. }
  607. return counts;
  608. }
  609. function rebalanceForSubscriptionMode(
  610. agents: Record<string, { model: string; variant?: string }>,
  611. chains: Record<string, string[]>,
  612. provenance: Record<string, { winnerLayer: string; winnerModel: string }>,
  613. paidProviders: string[],
  614. getRankedModels: (agent: AgentName) => DiscoveredModel[],
  615. getPinnedModelForProvider: (
  616. agent: AgentName,
  617. providerID: string,
  618. ) => string | undefined,
  619. targetByProvider: Record<string, number>,
  620. externalSignals: ExternalSignalMap | undefined,
  621. versionRecencyMap: Record<string, number>,
  622. engineVersion: ScoringEngineVersion,
  623. ): void {
  624. if (paidProviders.length <= 1) return;
  625. const MAX_ALLOWED_SCORE_LOSS = 20;
  626. while (true) {
  627. const providerUsage = countProviderUsage(agents);
  628. const underProviders = paidProviders.filter(
  629. (providerID) =>
  630. (providerUsage.get(providerID) ?? 0) <
  631. (targetByProvider[providerID] ?? 0),
  632. );
  633. const overProviders = paidProviders.filter(
  634. (providerID) =>
  635. (providerUsage.get(providerID) ?? 0) >
  636. (targetByProvider[providerID] ?? 0),
  637. );
  638. if (underProviders.length === 0 || overProviders.length === 0) break;
  639. let bestSwap:
  640. | {
  641. agent: AgentName;
  642. candidate: DiscoveredModel;
  643. loss: number;
  644. }
  645. | undefined;
  646. for (const agent of PRIMARY_ASSIGNMENT_ORDER) {
  647. const currentModelID = agents[agent]?.model;
  648. if (!currentModelID) continue;
  649. const currentProvider = currentModelID.split('/')[0];
  650. if (!currentProvider || !overProviders.includes(currentProvider))
  651. continue;
  652. const ranked = getRankedModels(agent);
  653. const currentModel =
  654. ranked.find((model) => model.model === currentModelID) ??
  655. ranked.find((model) => model.providerID === currentProvider);
  656. if (!currentModel) continue;
  657. const currentScore = scoreForEngine(
  658. engineVersion,
  659. agent,
  660. currentModel,
  661. externalSignals,
  662. versionRecencyMap,
  663. );
  664. for (const underProvider of underProviders) {
  665. const pinned = getPinnedModelForProvider(agent, underProvider);
  666. const candidate =
  667. ranked.find((model) => model.model === pinned) ??
  668. ranked.find((model) => model.providerID === underProvider);
  669. if (!candidate) continue;
  670. const candidateScore = scoreForEngine(
  671. engineVersion,
  672. agent,
  673. candidate,
  674. externalSignals,
  675. versionRecencyMap,
  676. );
  677. const loss = currentScore - candidateScore;
  678. if (loss > MAX_ALLOWED_SCORE_LOSS) continue;
  679. if (!bestSwap || loss < bestSwap.loss) {
  680. bestSwap = { agent, candidate, loss };
  681. }
  682. }
  683. }
  684. if (!bestSwap) break;
  685. agents[bestSwap.agent].model = bestSwap.candidate.model;
  686. chains[bestSwap.agent] = dedupe([
  687. bestSwap.candidate.model,
  688. ...(chains[bestSwap.agent] ?? []),
  689. ]).slice(0, 10);
  690. provenance[bestSwap.agent] = {
  691. winnerLayer: 'provider-fallback-policy',
  692. winnerModel: bestSwap.candidate.model,
  693. };
  694. }
  695. }
  696. function chooseProviderRepresentative(
  697. providerModels: DiscoveredModel[],
  698. agent: AgentName,
  699. externalSignals?: ExternalSignalMap,
  700. versionRecencyMap?: Record<string, number>,
  701. ): DiscoveredModel | null {
  702. if (providerModels.length === 0) return null;
  703. const flashBest = providerModels.find((model) => hasFlashToken(model));
  704. const nonFlashBest = providerModels.find((model) => !hasFlashToken(model));
  705. if (!nonFlashBest) return providerModels[0] ?? null;
  706. if (!flashBest) return nonFlashBest;
  707. const flashScore = combinedScore(
  708. agent,
  709. flashBest,
  710. externalSignals,
  711. versionRecencyMap,
  712. );
  713. const nonFlashScore = combinedScore(
  714. agent,
  715. nonFlashBest,
  716. externalSignals,
  717. versionRecencyMap,
  718. );
  719. const threshold = agent === 'explorer' ? -6 : 12;
  720. return flashScore >= nonFlashScore + threshold ? flashBest : nonFlashBest;
  721. }
  722. function getQualityWindow(agent: AgentName): number {
  723. if (agent === 'oracle' || agent === 'orchestrator') return 12;
  724. if (agent === 'fixer') return 15;
  725. if (agent === 'designer') return 16;
  726. if (agent === 'librarian') return 18;
  727. return 22;
  728. }
  729. function getProviderBundle(
  730. providerModels: DiscoveredModel[],
  731. agent: AgentName,
  732. externalSignals?: ExternalSignalMap,
  733. versionRecencyMap?: Record<string, number>,
  734. ): string[] {
  735. if (providerModels.length === 0) return [];
  736. const representative = chooseProviderRepresentative(
  737. providerModels,
  738. agent,
  739. externalSignals,
  740. versionRecencyMap,
  741. );
  742. if (!representative) return [];
  743. const second = providerModels.find((m) => m.model !== representative.model);
  744. if (!second) return [representative.model];
  745. const score1 = combinedScore(
  746. agent,
  747. representative,
  748. externalSignals,
  749. versionRecencyMap,
  750. );
  751. const score2 = combinedScore(
  752. agent,
  753. second,
  754. externalSignals,
  755. versionRecencyMap,
  756. );
  757. const gap = Math.abs(score1 - score2);
  758. const includeSecond =
  759. representative.providerID === 'chutes' ||
  760. gap <=
  761. (agent === 'oracle' || agent === 'orchestrator'
  762. ? 8
  763. : agent === 'designer' || agent === 'librarian'
  764. ? 12
  765. : agent === 'fixer'
  766. ? 15
  767. : 18);
  768. return includeSecond
  769. ? [representative.model, second.model]
  770. : [representative.model];
  771. }
  772. function selectPrimaryWithDiversity(
  773. candidates: DiscoveredModel[],
  774. agent: AgentName,
  775. providerUsage: Map<string, number>,
  776. targetByProvider: Record<string, number>,
  777. remainingSlots: number,
  778. externalSignals?: ExternalSignalMap,
  779. versionRecencyMap?: Record<string, number>,
  780. ): DiscoveredModel | null {
  781. if (candidates.length === 0) return null;
  782. const candidateScores = candidates.map((model) => {
  783. const usage = providerUsage.get(model.providerID) ?? 0;
  784. const target = targetByProvider[model.providerID] ?? 1;
  785. const softCap = target;
  786. const hardCap = Math.min(target + 1, 4);
  787. const deficit = Math.max(0, target - usage);
  788. const softOverflow = Math.max(0, usage + 1 - softCap);
  789. const hardOverflow = Math.max(0, usage + 1 - hardCap);
  790. const rawScore = combinedScore(
  791. agent,
  792. model,
  793. externalSignals,
  794. versionRecencyMap,
  795. );
  796. const adjustedScore =
  797. rawScore + deficit * 14 - softOverflow * 18 - hardOverflow * 100;
  798. return {
  799. model,
  800. usage,
  801. target,
  802. rawScore,
  803. adjustedScore: Math.round(adjustedScore * 1000) / 1000,
  804. };
  805. });
  806. const bestRaw = Math.max(...candidateScores.map((item) => item.rawScore));
  807. const window = getQualityWindow(agent);
  808. let eligible = candidateScores.filter(
  809. (item) => item.rawScore >= bestRaw - window,
  810. );
  811. const mustFillProviders = Object.entries(targetByProvider)
  812. .filter(([providerID, target]) => {
  813. const usage = providerUsage.get(providerID) ?? 0;
  814. return Math.max(0, target - usage) >= remainingSlots;
  815. })
  816. .map(([providerID]) => providerID);
  817. if (mustFillProviders.length > 0) {
  818. const forced = eligible.filter((item) =>
  819. mustFillProviders.includes(item.model.providerID),
  820. );
  821. if (forced.length > 0) eligible = forced;
  822. }
  823. eligible.sort((a, b) => {
  824. const delta = b.adjustedScore - a.adjustedScore;
  825. if (delta !== 0) return delta;
  826. const ratioA = a.target > 0 ? a.usage / a.target : a.usage;
  827. const ratioB = b.target > 0 ? b.usage / b.target : b.usage;
  828. if (ratioA !== ratioB) return ratioA - ratioB;
  829. if (a.rawScore !== b.rawScore) return b.rawScore - a.rawScore;
  830. const providerTie = a.model.providerID.localeCompare(b.model.providerID);
  831. if (providerTie !== 0) return providerTie;
  832. return a.model.model.localeCompare(b.model.model);
  833. });
  834. let chosen = eligible[0] ?? candidateScores[0];
  835. if (!chosen) return null;
  836. if (chosen.usage >= 2) {
  837. const bestUnused = candidateScores.find((item) => item.usage === 0);
  838. if (bestUnused && bestUnused.adjustedScore >= chosen.adjustedScore - 9) {
  839. chosen = bestUnused;
  840. }
  841. }
  842. if (
  843. agent !== 'explorer' &&
  844. isZai47Model(chosen.model) &&
  845. hasFlashToken(chosen.model)
  846. ) {
  847. const kimiCandidate = candidateScores.find((item) =>
  848. isKimiK25Model(item.model),
  849. );
  850. if (kimiCandidate && kimiCandidate.rawScore >= chosen.rawScore - 2) {
  851. chosen = kimiCandidate;
  852. }
  853. }
  854. return chosen.model;
  855. }
  856. function dedupe(models: Array<string | undefined>): string[] {
  857. const seen = new Set<string>();
  858. const result: string[] = [];
  859. for (const model of models) {
  860. if (!model || seen.has(model)) continue;
  861. seen.add(model);
  862. result.push(model);
  863. }
  864. return result;
  865. }
  866. function finalizeChainWithTail(
  867. prefix: string[],
  868. preferredTail: string | undefined,
  869. ): string[] {
  870. if (!preferredTail) {
  871. return dedupe([...prefix, 'opencode/big-pickle']).slice(0, 10);
  872. }
  873. const withoutTail = prefix
  874. .filter((model) => model !== preferredTail)
  875. .slice(0, 9);
  876. return [...withoutTail, preferredTail];
  877. }
  878. function ensureSyntheticModel(
  879. models: DiscoveredModel[],
  880. fullModelID: string | undefined,
  881. ): DiscoveredModel[] {
  882. if (!fullModelID) return models;
  883. if (models.some((model) => model.model === fullModelID)) return models;
  884. const [providerID, modelID] = fullModelID.split('/');
  885. if (!providerID || !modelID) return models;
  886. return [
  887. ...models,
  888. {
  889. providerID,
  890. model: fullModelID,
  891. name: modelID,
  892. status: 'active',
  893. contextLimit: 200_000,
  894. outputLimit: 32_000,
  895. reasoning: true,
  896. toolcall: true,
  897. attachment: false,
  898. },
  899. ];
  900. }
  901. export function buildDynamicModelPlan(
  902. catalog: DiscoveredModel[],
  903. config: InstallConfig,
  904. externalSignals?: ExternalSignalMap,
  905. options?: {
  906. scoringEngineVersion?: ScoringEngineVersion;
  907. },
  908. ): DynamicModelPlan | null {
  909. const catalogWithSelectedModels = [
  910. config.selectedChutesPrimaryModel,
  911. config.selectedChutesSecondaryModel,
  912. config.selectedOpenCodePrimaryModel,
  913. config.selectedOpenCodeSecondaryModel,
  914. ].reduce((acc, modelID) => ensureSyntheticModel(acc, modelID), catalog);
  915. const enabledProviders = new Set(getEnabledProviders(config));
  916. const providerUniverse = catalogWithSelectedModels.filter((m) => {
  917. if (!enabledProviders.has(m.providerID)) return false;
  918. if (m.providerID === 'chutes' && /qwen/i.test(m.model)) {
  919. return false;
  920. }
  921. return true;
  922. });
  923. const engineVersion =
  924. options?.scoringEngineVersion ?? config.scoringEngineVersion ?? 'v1';
  925. const versionRecencyMap = getVersionRecencyMap(providerUniverse);
  926. const providerCandidates = selectTopModelsPerProvider(
  927. providerUniverse,
  928. engineVersion,
  929. externalSignals,
  930. versionRecencyMap,
  931. );
  932. if (providerCandidates.length === 0) {
  933. return null;
  934. }
  935. const hasPaidProviderEnabled =
  936. config.hasOpenAI ||
  937. config.hasAnthropic ||
  938. config.hasCopilot ||
  939. config.hasZaiPlan ||
  940. config.hasKimi ||
  941. config.hasAntigravity;
  942. const paidProviders = dedupe(
  943. providerCandidates
  944. .map((model) => model.providerID)
  945. .filter((providerID) => providerID !== 'opencode'),
  946. ).sort((a, b) => a.localeCompare(b));
  947. const targetByProvider: Record<string, number> = {};
  948. if (paidProviders.length > 0) {
  949. const baseTarget = Math.floor(AGENTS.length / paidProviders.length);
  950. const extra = AGENTS.length % paidProviders.length;
  951. for (const [index, providerID] of paidProviders.entries()) {
  952. targetByProvider[providerID] = baseTarget + (index < extra ? 1 : 0);
  953. }
  954. }
  955. const providerUsage = new Map<string, number>();
  956. const rankCache = new Map<AgentName, DiscoveredModel[]>();
  957. const shadowDiffs: Record<
  958. string,
  959. { v1TopModel?: string; v2TopModel?: string }
  960. > = {};
  961. const agents: Record<string, { model: string; variant?: string }> = {};
  962. const chains: Record<string, string[]> = {};
  963. const provenance: DynamicModelPlan['provenance'] = {};
  964. const getSelectedChutesForAgent = (agent: AgentName): string | undefined => {
  965. if (!config.hasChutes) return undefined;
  966. return agent === 'explorer' || agent === 'librarian' || agent === 'fixer'
  967. ? (config.selectedChutesSecondaryModel ??
  968. config.selectedChutesPrimaryModel)
  969. : config.selectedChutesPrimaryModel;
  970. };
  971. const getSelectedOpenCodeForAgent = (
  972. agent: AgentName,
  973. ): string | undefined => {
  974. if (!config.useOpenCodeFreeModels) return undefined;
  975. return agent === 'explorer' || agent === 'librarian' || agent === 'fixer'
  976. ? (config.selectedOpenCodeSecondaryModel ??
  977. config.selectedOpenCodePrimaryModel)
  978. : config.selectedOpenCodePrimaryModel;
  979. };
  980. const getPinnedModelForProvider = (
  981. agent: AgentName,
  982. providerID: string,
  983. ): string | undefined => {
  984. if (providerID === 'chutes') return getSelectedChutesForAgent(agent);
  985. if (providerID === 'opencode') return getSelectedOpenCodeForAgent(agent);
  986. return undefined;
  987. };
  988. const getRankedModels = (agent: AgentName): DiscoveredModel[] => {
  989. const cached = rankCache.get(agent);
  990. if (cached) return cached;
  991. const rankedV1 = rankModels(providerCandidates, agent, externalSignals);
  992. if (engineVersion === 'v1') {
  993. rankCache.set(agent, rankedV1);
  994. return rankedV1;
  995. }
  996. const rankedV2 = rankModelsV2(
  997. providerCandidates,
  998. agent,
  999. externalSignals,
  1000. ).map((candidate) => candidate.model);
  1001. if (engineVersion === 'v2-shadow') {
  1002. shadowDiffs[agent] = {
  1003. v1TopModel: rankedV1[0]?.model,
  1004. v2TopModel: rankedV2[0]?.model,
  1005. };
  1006. rankCache.set(agent, rankedV1);
  1007. return rankedV1;
  1008. }
  1009. rankCache.set(agent, rankedV2);
  1010. return rankedV2;
  1011. };
  1012. for (const [agentIndex, agent] of PRIMARY_ASSIGNMENT_ORDER.entries()) {
  1013. const ranked = getRankedModels(agent);
  1014. const primaryPool = hasPaidProviderEnabled
  1015. ? ranked.filter((model) => !FREE_BIASED_PROVIDERS.has(model.providerID))
  1016. : ranked;
  1017. const remainingSlots = PRIMARY_ASSIGNMENT_ORDER.length - agentIndex;
  1018. const primary =
  1019. selectPrimaryWithDiversity(
  1020. primaryPool.length > 0 ? primaryPool : ranked,
  1021. agent,
  1022. providerUsage,
  1023. targetByProvider,
  1024. remainingSlots,
  1025. externalSignals,
  1026. versionRecencyMap,
  1027. ) ?? ranked[0];
  1028. if (!primary) continue;
  1029. providerUsage.set(
  1030. primary.providerID,
  1031. (providerUsage.get(primary.providerID) ?? 0) + 1,
  1032. );
  1033. const providerOrder = dedupe(ranked.map((m) => m.providerID));
  1034. const perProviderBest = providerOrder.flatMap((providerID) => {
  1035. const providerModels = ranked.filter((m) => m.providerID === providerID);
  1036. const pinned = getPinnedModelForProvider(agent, providerID);
  1037. if (pinned && providerModels.some((m) => m.model === pinned)) {
  1038. return [pinned];
  1039. }
  1040. return getProviderBundle(
  1041. providerModels,
  1042. agent,
  1043. externalSignals,
  1044. versionRecencyMap,
  1045. );
  1046. });
  1047. const nonFreePerProviderBest = perProviderBest.filter(
  1048. (model) => !model.startsWith('opencode/'),
  1049. );
  1050. const freePerProviderBest = perProviderBest.filter((model) =>
  1051. model.startsWith('opencode/'),
  1052. );
  1053. const selectedOpencode = getSelectedOpenCodeForAgent(agent);
  1054. const selectedChutes = getSelectedChutesForAgent(agent);
  1055. const chain = dedupe([
  1056. primary.model,
  1057. ...nonFreePerProviderBest,
  1058. selectedChutes,
  1059. selectedOpencode,
  1060. ...freePerProviderBest,
  1061. ]);
  1062. const deterministicFreeTail =
  1063. selectedOpencode ??
  1064. freePerProviderBest[0] ??
  1065. ranked.find((model) => model.model.startsWith('opencode/'))?.model;
  1066. const finalizedChain = finalizeChainWithTail(chain, deterministicFreeTail);
  1067. const providerPolicyChain = dedupe([selectedChutes, selectedOpencode]);
  1068. const systemDefaultModel = selectedOpencode ?? 'opencode/big-pickle';
  1069. const resolved = resolveAgentWithPrecedence({
  1070. agentName: agent,
  1071. dynamicRecommendation: finalizedChain,
  1072. providerFallbackPolicy: providerPolicyChain,
  1073. systemDefault: [systemDefaultModel],
  1074. });
  1075. let finalModel = resolved.model;
  1076. let finalChain = resolved.chain;
  1077. const selectedChutesForAgent = getSelectedChutesForAgent(agent);
  1078. const selectedOpenCodeForAgent = getSelectedOpenCodeForAgent(agent);
  1079. const forceChutes =
  1080. finalModel.startsWith('chutes/') && Boolean(selectedChutesForAgent);
  1081. const forceOpenCode =
  1082. finalModel.startsWith('opencode/') && Boolean(selectedOpenCodeForAgent);
  1083. if (forceOpenCode && selectedOpenCodeForAgent) {
  1084. finalModel = selectedOpenCodeForAgent;
  1085. finalChain = dedupe([selectedOpenCodeForAgent, ...finalChain]);
  1086. }
  1087. if (forceChutes && selectedChutesForAgent) {
  1088. finalModel = selectedChutesForAgent;
  1089. finalChain = dedupe([selectedChutesForAgent, ...finalChain]);
  1090. }
  1091. const wasForced = forceChutes || forceOpenCode;
  1092. agents[agent] = {
  1093. model: finalModel,
  1094. variant: ROLE_VARIANT[agent],
  1095. };
  1096. chains[agent] = finalChain;
  1097. provenance[agent] = {
  1098. winnerLayer: wasForced
  1099. ? 'manual-user-plan'
  1100. : resolved.provenance.winnerLayer,
  1101. winnerModel: finalModel,
  1102. };
  1103. }
  1104. if (hasPaidProviderEnabled) {
  1105. for (const providerID of paidProviders) {
  1106. if ((providerUsage.get(providerID) ?? 0) > 0) continue;
  1107. let bestSwap:
  1108. | {
  1109. agent: AgentName;
  1110. candidateModel: string;
  1111. loss: number;
  1112. }
  1113. | undefined;
  1114. for (const agent of PRIMARY_ASSIGNMENT_ORDER) {
  1115. const currentModel = agents[agent]?.model;
  1116. if (!currentModel) continue;
  1117. const ranked = getRankedModels(agent);
  1118. const pinned = getPinnedModelForProvider(agent, providerID);
  1119. const candidate =
  1120. ranked.find((model) => model.model === pinned) ??
  1121. ranked.find((model) => model.providerID === providerID);
  1122. const current = ranked.find((model) => model.model === currentModel);
  1123. if (!candidate || !current) continue;
  1124. const currentScore = combinedScore(
  1125. agent,
  1126. current,
  1127. externalSignals,
  1128. versionRecencyMap,
  1129. );
  1130. const candidateScore = combinedScore(
  1131. agent,
  1132. candidate,
  1133. externalSignals,
  1134. versionRecencyMap,
  1135. );
  1136. const loss = currentScore - candidateScore;
  1137. if (!bestSwap || loss < bestSwap.loss) {
  1138. bestSwap = {
  1139. agent,
  1140. candidateModel: candidate.model,
  1141. loss,
  1142. };
  1143. }
  1144. }
  1145. if (!bestSwap) continue;
  1146. const existingProvider =
  1147. agents[bestSwap.agent]?.model.split('/')[0] ?? providerID;
  1148. agents[bestSwap.agent].model = bestSwap.candidateModel;
  1149. chains[bestSwap.agent] = dedupe([
  1150. bestSwap.candidateModel,
  1151. ...(chains[bestSwap.agent] ?? []),
  1152. ]).slice(0, 10);
  1153. provenance[bestSwap.agent] = {
  1154. winnerLayer: 'provider-fallback-policy',
  1155. winnerModel: bestSwap.candidateModel,
  1156. };
  1157. providerUsage.set(providerID, (providerUsage.get(providerID) ?? 0) + 1);
  1158. providerUsage.set(
  1159. existingProvider,
  1160. Math.max(0, (providerUsage.get(existingProvider) ?? 1) - 1),
  1161. );
  1162. }
  1163. }
  1164. if (config.balanceProviderUsage && hasPaidProviderEnabled) {
  1165. rebalanceForSubscriptionMode(
  1166. agents,
  1167. chains,
  1168. provenance,
  1169. paidProviders,
  1170. getRankedModels,
  1171. getPinnedModelForProvider,
  1172. targetByProvider,
  1173. externalSignals,
  1174. versionRecencyMap,
  1175. engineVersion,
  1176. );
  1177. }
  1178. if (Object.keys(agents).length === 0) {
  1179. return null;
  1180. }
  1181. return {
  1182. agents,
  1183. chains,
  1184. provenance,
  1185. scoring: {
  1186. engineVersionApplied: engineVersion === 'v2' ? 'v2' : 'v1',
  1187. shadowCompared: engineVersion === 'v2-shadow',
  1188. diffs: engineVersion === 'v2-shadow' ? shadowDiffs : undefined,
  1189. },
  1190. };
  1191. }