@s-berdia/opencode-router 1.1.0 → 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/README.md +36 -11
- package/USAGE.md +73 -19
- package/dist/bin.js +116 -44
- package/dist/bin.js.map +1 -1
- package/dist/index.js +111 -35
- package/dist/index.js.map +1 -1
- package/package.json +9 -4
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,
|
|
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(
|
|
1656
|
+
return chain.find(isUsable) ?? chain[0];
|
|
1649
1657
|
}
|
|
1650
1658
|
function healthCheck(result, ledger) {
|
|
1651
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1767
|
-
|
|
1798
|
+
inCooldown(modelId) {
|
|
1799
|
+
const until = this.state(modelId).cooldownUntil;
|
|
1800
|
+
return until !== void 0 && this.now() < until;
|
|
1768
1801
|
}
|
|
1769
|
-
/**
|
|
1770
|
-
|
|
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
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
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
|
|
1786
|
-
const
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
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
|
-
|
|
1947
|
-
|
|
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 (
|
|
2068
|
-
else
|
|
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
|
|
2157
|
+
if (id) await coolDown(id, error, "session rate limit");
|
|
2082
2158
|
}
|
|
2083
2159
|
} catch {
|
|
2084
2160
|
}
|