@s-berdia/opencode-router 1.1.1 → 1.2.0

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/index.js CHANGED
@@ -1069,6 +1069,7 @@ function loadCatalog(file = catalogCacheFile()) {
1069
1069
 
1070
1070
  // src/scoring.ts
1071
1071
  var FIVE_HOUR_SHARE = 0.2;
1072
+ var WEEK_SHARE = 0.5;
1072
1073
  var FREE_ROLE_BONUS = 6;
1073
1074
  var DEFAULT_UNVETTED_PENALTY = 8;
1074
1075
  function modelClass(model) {
@@ -1581,6 +1582,13 @@ function rankRole(catalog, roleName, table, cfg, feeds) {
1581
1582
  chosen.push(entry);
1582
1583
  }
1583
1584
  }
1585
+ if (cfg.plan !== "free" && !chosen.some((entry) => entry.model.free)) {
1586
+ const free = scored.find((entry) => entry.model.free);
1587
+ if (free) {
1588
+ if (chosen.length >= depth && chosen.length > 1) chosen.pop();
1589
+ chosen.push(free);
1590
+ }
1591
+ }
1584
1592
  return chosen.map((entry, index) => ({
1585
1593
  rank: index + 1,
1586
1594
  providerID: entry.model.providerID,
@@ -1642,13 +1650,22 @@ async function buildRoutingTable(opts = {}) {
1642
1650
  };
1643
1651
  return { table, cfg, scoreTable, catalog };
1644
1652
  }
1645
- function pickForRole(table, role, isHealthy) {
1653
+ function pickForRole(table, role, isUsable) {
1646
1654
  const chain = table.active[role];
1647
1655
  if (!chain || chain.length === 0) return void 0;
1648
- return chain.find((entry) => isHealthy(entry.id)) ?? chain[0];
1656
+ return chain.find(isUsable) ?? chain[0];
1649
1657
  }
1650
1658
  function healthCheck(result, ledger) {
1651
- return (modelId) => ledger.isHealthy(modelId, monthlyCapFor(modelId, result.scoreTable, result.cfg.plan), result.cfg.softThreshold);
1659
+ const allowanceOk = ledger.allowanceAvailable(result.cfg.softThreshold);
1660
+ return (entry) => !ledger.inCooldown(entry.id) && (allowanceOk || entry.class !== "subscription");
1661
+ }
1662
+ function usesAllowance(result, modelId) {
1663
+ const cap = monthlyCapFor(modelId, result.scoreTable, result.cfg.plan);
1664
+ return cap !== null && cap > 0;
1665
+ }
1666
+ function allowanceShare(result, modelId, costUSD) {
1667
+ const cap = monthlyCapFor(modelId, result.scoreTable, result.cfg.plan);
1668
+ return cap !== null && cap > 0 ? costUSD / cap : 0;
1652
1669
  }
1653
1670
 
1654
1671
  // src/ledger.ts
@@ -1658,7 +1675,6 @@ var DAY = 24 * HOUR;
1658
1675
  var WINDOW_5H = 5 * HOUR;
1659
1676
  var WINDOW_WEEK = 7 * DAY;
1660
1677
  var WINDOW_MONTH = 30 * DAY;
1661
- var MONTHLY_LIMIT = 0.95;
1662
1678
  function emptyState(now) {
1663
1679
  return {
1664
1680
  cost5h: 0,
@@ -1741,7 +1757,8 @@ var LedgerStore = class {
1741
1757
  }
1742
1758
  return this.roll(entry, now);
1743
1759
  }
1744
- record(modelId, costUSD, tokens) {
1760
+ /** `allowanceShare`: the cost divided by the model's monthly limit; 0 for a model outside the Go allowance. */
1761
+ record(modelId, costUSD, tokens, allowanceShare2 = 0) {
1745
1762
  const state = this.state(modelId);
1746
1763
  state.cost5h += costUSD;
1747
1764
  state.costWeek += costUSD;
@@ -1750,8 +1767,23 @@ var LedgerStore = class {
1750
1767
  state.tokensWeek += tokens;
1751
1768
  state.tokensMonth += tokens;
1752
1769
  state.lastUsedAt = this.now();
1770
+ if (allowanceShare2 > 0) this.addShare(allowanceShare2);
1753
1771
  this.save();
1754
1772
  }
1773
+ /** Call after sync() or state(), never before: a reload would drop changes to the old data. */
1774
+ sharedAllowance() {
1775
+ if (!this.data.allowance || !Array.isArray(this.data.allowance.buckets)) this.data.allowance = { buckets: [] };
1776
+ return this.data.allowance;
1777
+ }
1778
+ addShare(share) {
1779
+ const allowance = this.sharedAllowance();
1780
+ const now = this.now();
1781
+ const hour = Math.floor(now / HOUR) * HOUR;
1782
+ allowance.buckets = allowance.buckets.filter(([start]) => start + HOUR > now - WINDOW_MONTH);
1783
+ const bucket = allowance.buckets.find(([start]) => start === hour);
1784
+ if (bucket) bucket[1] += share;
1785
+ else allowance.buckets.push([hour, share]);
1786
+ }
1755
1787
  cooldown(modelId, durationMs, reason) {
1756
1788
  const state = this.state(modelId);
1757
1789
  state.cooldownUntil = this.now() + durationMs;
@@ -1763,15 +1795,46 @@ var LedgerStore = class {
1763
1795
  state.cooldownUntil = void 0;
1764
1796
  this.save();
1765
1797
  }
1766
- usage(modelId, monthlyCapUSD) {
1767
- return usageOf(this.state(modelId), monthlyCapUSD);
1798
+ inCooldown(modelId) {
1799
+ const until = this.state(modelId).cooldownUntil;
1800
+ return until !== void 0 && this.now() < until;
1768
1801
  }
1769
- /** Healthy = not cooling down, under the soft threshold for 5 hours, and under the monthly limit. */
1770
- isHealthy(modelId, monthlyCapUSD, softThreshold) {
1802
+ /** After a usage-limit error: every model that uses the shared allowance is limited. */
1803
+ cooldownAllowance(durationMs, reason) {
1804
+ this.sync();
1805
+ const allowance = this.sharedAllowance();
1806
+ allowance.cooldownUntil = this.now() + durationMs;
1807
+ if (reason) allowance.lastError = reason;
1808
+ this.save();
1809
+ }
1810
+ usage(modelId) {
1771
1811
  const state = this.state(modelId);
1772
- if (state.cooldownUntil && this.now() < state.cooldownUntil) return false;
1773
- const u = usageOf(state, monthlyCapUSD);
1774
- return u.pct5h < softThreshold && u.pctMonth < MONTHLY_LIMIT;
1812
+ return {
1813
+ cost5h: state.cost5h,
1814
+ costMonth: state.costMonth,
1815
+ tokens5h: state.tokens5h,
1816
+ tokensMonth: state.tokensMonth,
1817
+ cooldownUntil: state.cooldownUntil ?? null
1818
+ };
1819
+ }
1820
+ /** Rolling use of the shared allowance in the last 5 hours, 7 days, and 30 days. */
1821
+ allowance() {
1822
+ this.sync();
1823
+ const allowance = this.sharedAllowance();
1824
+ const now = this.now();
1825
+ const cooldownUntil = allowance.cooldownUntil !== void 0 && now < allowance.cooldownUntil ? allowance.cooldownUntil : null;
1826
+ return {
1827
+ pct5h: windowShare(allowance.buckets, now, WINDOW_5H) / FIVE_HOUR_SHARE,
1828
+ pctWeek: windowShare(allowance.buckets, now, WINDOW_WEEK) / WEEK_SHARE,
1829
+ pctMonth: windowShare(allowance.buckets, now, WINDOW_MONTH),
1830
+ cooldownUntil,
1831
+ cooldownReason: cooldownUntil === null ? null : allowance.lastError ?? null
1832
+ };
1833
+ }
1834
+ /** True when no window has reached `softThreshold` and no usage-limit cooldown is active. */
1835
+ allowanceAvailable(softThreshold) {
1836
+ const u = this.allowance();
1837
+ return u.cooldownUntil === null && u.pct5h < softThreshold && u.pctWeek < softThreshold && u.pctMonth < softThreshold;
1775
1838
  }
1776
1839
  snapshot() {
1777
1840
  return structuredClone(this.data);
@@ -1782,17 +1845,16 @@ var LedgerStore = class {
1782
1845
  this.loadedMtime = mtimeOf(this.file);
1783
1846
  }
1784
1847
  };
1785
- function usageOf(state, monthlyCapUSD) {
1786
- const cap5h = monthlyCapUSD === null ? null : monthlyCapUSD * FIVE_HOUR_SHARE;
1787
- return {
1788
- cost5h: state.cost5h,
1789
- costMonth: state.costMonth,
1790
- tokens5h: state.tokens5h,
1791
- tokensMonth: state.tokensMonth,
1792
- pct5h: cap5h === null || cap5h <= 0 ? 0 : state.cost5h / cap5h,
1793
- pctMonth: monthlyCapUSD === null || monthlyCapUSD <= 0 ? 0 : state.costMonth / monthlyCapUSD,
1794
- cooldownUntil: state.cooldownUntil ?? null
1795
- };
1848
+ function windowShare(buckets, now, windowMs) {
1849
+ const from = now - windowMs;
1850
+ let sum = 0;
1851
+ for (const [start, share] of buckets) {
1852
+ const end = Math.min(start + HOUR, now);
1853
+ if (end <= from || start > now) continue;
1854
+ const span = end - start;
1855
+ sum += span <= 0 ? share : share * (end - Math.max(start, from)) / span;
1856
+ }
1857
+ return sum;
1796
1858
  }
1797
1859
 
1798
1860
  // src/apply.ts
@@ -1942,14 +2004,21 @@ var TTL_MS = 15e3;
1942
2004
  function errorMessage(error) {
1943
2005
  return error instanceof Error ? error.message : String(error);
1944
2006
  }
2007
+ function errorData(error) {
2008
+ if (!error || typeof error !== "object") return { status: 0, message: "" };
2009
+ const data = error.data ?? {};
2010
+ return {
2011
+ status: Number(data.statusCode ?? data.status ?? 0),
2012
+ message: String(data.message ?? data.responseBody ?? "").toLowerCase()
2013
+ };
2014
+ }
2015
+ function isUsageLimitError(error) {
2016
+ const { status, message } = errorData(error);
2017
+ return status === 402 || /usage limit|limit (?:has been |was )?reached|quota|insufficient|exceeded your/.test(message);
2018
+ }
1945
2019
  function isRateLimitError(error) {
1946
- if (!error || typeof error !== "object") return false;
1947
- const rec = error;
1948
- const data = rec.data ?? {};
1949
- const status = Number(data.statusCode ?? data.status ?? 0);
1950
- const message = String(data.message ?? data.responseBody ?? "").toLowerCase();
1951
- if (status === 429 || status === 402) return true;
1952
- return /rate.?limit|quota|too many requests|insufficient|capacity|overloaded/.test(message);
2020
+ const { status, message } = errorData(error);
2021
+ return status === 429 || isUsageLimitError(error) || /rate.?limit|too many requests|capacity|overloaded/.test(message);
1953
2022
  }
1954
2023
  function isAuthError(error) {
1955
2024
  return typeof error === "object" && error !== null && String(error.name ?? "").includes("ProviderAuthError");
@@ -1985,6 +2054,13 @@ var OpencodeRouter = async (input) => {
1985
2054
  writeApplyMarker({ ok: false, detail: `hot-apply failed: ${errorMessage(error)}` });
1986
2055
  }
1987
2056
  }
2057
+ async function coolDown(modelId, error, reason) {
2058
+ if (isUsageLimitError(error) && usesAllowance(await ensure(), modelId)) {
2059
+ ledger.cooldownAllowance(RATE_LIMIT_COOLDOWN_MS, "Go usage limit");
2060
+ } else if (isRateLimitError(error)) {
2061
+ ledger.cooldown(modelId, RATE_LIMIT_COOLDOWN_MS, reason);
2062
+ }
2063
+ }
1988
2064
  function rememberMessage(id) {
1989
2065
  recordedMessages.add(id);
1990
2066
  if (recordedMessages.size > MAX_TRACKED_MESSAGES) {
@@ -2057,15 +2133,15 @@ var OpencodeRouter = async (input) => {
2057
2133
  const id = `${info.providerID}/${info.modelID}`;
2058
2134
  sessionModels.set(info.sessionID, id);
2059
2135
  const tokens = (info.tokens?.input ?? 0) + (info.tokens?.output ?? 0) + (info.tokens?.cache?.read ?? 0) + (info.tokens?.cache?.write ?? 0);
2136
+ const result = await ensure();
2060
2137
  let cost = info.cost ?? 0;
2061
2138
  if (cost === 0 && tokens > 0) {
2062
- await ensure();
2063
2139
  const blended = blendedById.get(id) ?? 0;
2064
2140
  if (blended > 0) cost = tokens / 1e6 * blended;
2065
2141
  }
2066
- ledger.record(id, cost, tokens);
2067
- if (isRateLimitError(info.error)) ledger.cooldown(id, RATE_LIMIT_COOLDOWN_MS, "rate limit / quota");
2068
- else if (isAuthError(info.error)) ledger.cooldown(id, AUTH_COOLDOWN_MS, "auth error");
2142
+ ledger.record(id, cost, tokens, allowanceShare(result, id, cost));
2143
+ if (isAuthError(info.error)) ledger.cooldown(id, AUTH_COOLDOWN_MS, "auth error");
2144
+ else await coolDown(id, info.error, "rate limit / quota");
2069
2145
  } else if (event.type === "session.created" && !updateToastShown) {
2070
2146
  updateToastShown = true;
2071
2147
  void pendingUpdate.then((update) => {
@@ -2078,7 +2154,7 @@ var OpencodeRouter = async (input) => {
2078
2154
  } else if (event.type === "session.error") {
2079
2155
  const { sessionID, error } = event.properties;
2080
2156
  const id = sessionID ? sessionModels.get(sessionID) : void 0;
2081
- if (id && isRateLimitError(error)) ledger.cooldown(id, RATE_LIMIT_COOLDOWN_MS, "session rate limit");
2157
+ if (id) await coolDown(id, error, "session rate limit");
2082
2158
  }
2083
2159
  } catch {
2084
2160
  }