dsh-opencode-go 0.1.19 → 0.1.21

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/lib/index.js CHANGED
@@ -988,7 +988,7 @@ function diagnosticURL(raw) {
988
988
  url.hash = "";
989
989
  return url.href;
990
990
  }
991
- async function fetchJsonResponse(url, init, maxBytes, encoding = "identity") {
991
+ async function fetchJsonResponse(url, init, maxBytes, encoding = "identity", fetcher = globalThis.fetch) {
992
992
  if ((init.method ?? "GET").toUpperCase() !== "GET") throw new TypeError("JSON requests must use GET");
993
993
  const headers = new Headers(init.headers);
994
994
  headers.set("accept-encoding", encoding);
@@ -996,7 +996,7 @@ async function fetchJsonResponse(url, init, maxBytes, encoding = "identity") {
996
996
  for (let attempt = 0; ; attempt += 1) {
997
997
  init.signal?.throwIfAborted();
998
998
  try {
999
- const response = await fetch(url, options);
999
+ const response = await fetcher(url, options);
1000
1000
  if (!response.ok) {
1001
1001
  await response.body?.cancel().catch(() => {
1002
1002
  });
@@ -1167,7 +1167,7 @@ function readLiveModelIds(body) {
1167
1167
  }
1168
1168
  return [...new Set(ids)];
1169
1169
  }
1170
- async function fetchLiveModelIds(baseURL) {
1170
+ async function fetchLiveModelIds(baseURL, fetcher) {
1171
1171
  const url = `${baseURL.replace(/\/+$/, "")}/models`;
1172
1172
  const endpoint = diagnosticURL(url);
1173
1173
  let result;
@@ -1177,7 +1177,7 @@ async function fetchLiveModelIds(baseURL) {
1177
1177
  cache: "no-cache",
1178
1178
  headers: { ...attributionHeaders(), accept: "application/json" },
1179
1179
  signal: AbortSignal.timeout(MODELS_FETCH_TIMEOUT_MS)
1180
- }, MODEL_LISTING_MAX_BYTES);
1180
+ }, MODEL_LISTING_MAX_BYTES, "identity", fetcher);
1181
1181
  } catch (error) {
1182
1182
  const detail = error instanceof SyntaxError ? "invalid JSON response" : error instanceof RangeError ? `response exceeds the ${MODEL_LISTING_MAX_BYTES} byte limit` : transportFailure(error);
1183
1183
  const action = error instanceof SyntaxError || error instanceof RangeError ? "read" : "reach";
@@ -1213,12 +1213,13 @@ function buildProvider(baseURL, models) {
1213
1213
  }
1214
1214
  var OpencodeGoCatalog = class {
1215
1215
  constructor(baseURL, refreshMs, onFallback, onOmitted, onRefresh = () => {
1216
- }) {
1216
+ }, fetcher) {
1217
1217
  this.baseURL = baseURL;
1218
1218
  this.refreshMs = refreshMs;
1219
1219
  this.onFallback = onFallback;
1220
1220
  this.onOmitted = onOmitted;
1221
1221
  this.onRefresh = onRefresh;
1222
+ this.fetcher = fetcher;
1222
1223
  }
1223
1224
  served;
1224
1225
  pending;
@@ -1260,7 +1261,7 @@ var OpencodeGoCatalog = class {
1260
1261
  }
1261
1262
  startRefresh(force) {
1262
1263
  const builtin = builtinModels(this.baseURL);
1263
- const listing = settled(fetchLiveModelIds(this.baseURL).then((ids) => ({ ids, updatedAtMs: Date.now() })));
1264
+ const listing = settled(fetchLiveModelIds(this.baseURL, this.fetcher).then((ids) => ({ ids, updatedAtMs: Date.now() })));
1264
1265
  const restored = this.metadata;
1265
1266
  if (!force && this.served === void 0 && restored !== void 0) {
1266
1267
  const status = { metadataLive: false, metadataUpdatedAtMs: this.metadataUpdatedAtMs };
@@ -1304,7 +1305,7 @@ var OpencodeGoCatalog = class {
1304
1305
  },
1305
1306
  cache: "no-cache",
1306
1307
  signal: AbortSignal.timeout(METADATA_FETCH_TIMEOUT_MS)
1307
- }, MODEL_METADATA_MAX_BYTES, "gzip");
1308
+ }, MODEL_METADATA_MAX_BYTES, "gzip", this.fetcher);
1308
1309
  if (response.status === 304 && this.metadata !== void 0) {
1309
1310
  this.metadataUpdatedAtMs = Date.now();
1310
1311
  this.metadataETag = metadataETag(response.headers.get("etag")) ?? this.metadataETag;
@@ -1464,6 +1465,35 @@ import z from "@deepseek-ai/schemastery";
1464
1465
  var USAGE_DISPLAY_MODES = ["auto", "always", "off"];
1465
1466
  var DEFAULT_USAGE_DISPLAY = "auto";
1466
1467
 
1468
+ // src/accounts.ts
1469
+ var MAX_ACCOUNTS = 20;
1470
+ var DEFAULT_ACCOUNT_REF = "OPENCODE_API_KEY";
1471
+ function accountRefOf(settings) {
1472
+ return settings.apiKeyEnv || DEFAULT_ACCOUNT_REF;
1473
+ }
1474
+ function accountsOf(settings) {
1475
+ const ref = accountRefOf(settings);
1476
+ if (settings.accounts?.length === 0) return [];
1477
+ const accounts = settings.accounts ?? [];
1478
+ return accounts.some((account) => account.apiKeyEnv === ref) ? accounts : [
1479
+ { id: `legacy:${ref}`, name: "", apiKeyEnv: ref },
1480
+ ...accounts
1481
+ ];
1482
+ }
1483
+ function assertAccounts(accounts, selectedRef) {
1484
+ if (accounts == null) return;
1485
+ if (!Array.isArray(accounts) || accounts.length > MAX_ACCOUNTS || accounts.length === MAX_ACCOUNTS && selectedRef && !accounts.some((account) => account.apiKeyEnv === selectedRef)) {
1486
+ throw new Error(`OpenCode Go supports at most ${MAX_ACCOUNTS} accounts, including the selected reference`);
1487
+ }
1488
+ const ids = /* @__PURE__ */ new Set();
1489
+ const refs = /* @__PURE__ */ new Set();
1490
+ for (const account of accounts) {
1491
+ if (!account || typeof account.id !== "string" || !account.id || account.id.length > 128 || account.id.startsWith("legacy:") && account.id !== `legacy:${account.apiKeyEnv}` || typeof account.name !== "string" || !account.name.trim() && account.id !== `legacy:${account.apiKeyEnv}` || account.name.length > 80 || typeof account.apiKeyEnv !== "string" || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(account.apiKeyEnv) || ids.has(account.id) || refs.has(account.apiKeyEnv)) throw new Error("Invalid or duplicate OpenCode Go account");
1492
+ ids.add(account.id);
1493
+ refs.add(account.apiKeyEnv);
1494
+ }
1495
+ }
1496
+
1467
1497
  // src/config.ts
1468
1498
  var DEFAULT_API_KEY_ENV = "OPENCODE_API_KEY";
1469
1499
  var DEFAULT_REFRESH_MINUTES = 60;
@@ -1473,7 +1503,14 @@ var fields = {
1473
1503
  usageDisplay: z.union(USAGE_DISPLAY_MODES.map((mode) => z.const(mode))).default(DEFAULT_USAGE_DISPLAY),
1474
1504
  modelVisibility: z.dict(z.boolean().required()).default({}),
1475
1505
  apiKeyEnv: z.string().role("credential-ref").default(DEFAULT_API_KEY_ENV),
1506
+ accounts: z.union([z.const(null), z.array(z.object({
1507
+ id: z.string().required(),
1508
+ name: z.string().required(),
1509
+ apiKeyEnv: z.string().role("credential-ref").required()
1510
+ })).max(MAX_ACCOUNTS)]).default(null),
1511
+ autoSwitch: z.boolean().default(false),
1476
1512
  baseURL: z.string().default(DEFAULT_BASE_URL),
1513
+ proxyURL: z.string().default(""),
1477
1514
  refreshMinutes: z.number().step(1).min(1).max(7 * 24 * 60).default(DEFAULT_REFRESH_MINUTES),
1478
1515
  streamIdleTimeoutMs: z.number().min(Number.MIN_VALUE).max(MAX_TIMER_DELAY_MS).default(DEFAULT_STREAM_IDLE_TIMEOUT_MS),
1479
1516
  maxImages: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER),
@@ -1511,7 +1548,87 @@ function assertBaseURL(raw) {
1511
1548
  return url.toString().replace(/\/+$/, "");
1512
1549
  }
1513
1550
 
1551
+ // src/proxy-url.ts
1552
+ function assertProxyURL(raw) {
1553
+ const value = raw?.trim() ?? "";
1554
+ if (!value) return "";
1555
+ let url;
1556
+ try {
1557
+ url = new URL(value);
1558
+ } catch {
1559
+ throw new Error("OpenCode Go: proxy address must be a valid URL");
1560
+ }
1561
+ if (!["http:", "https:", "socks5:"].includes(url.protocol)) {
1562
+ throw new Error("OpenCode Go: proxy address must use http://, https:// or socks5://");
1563
+ }
1564
+ if (!url.hostname || url.port === "0" || url.pathname !== "" && url.pathname !== "/" || url.search || url.hash) {
1565
+ throw new Error("OpenCode Go: proxy address must contain a host and optional port, without a path, query or fragment");
1566
+ }
1567
+ try {
1568
+ decodeURIComponent(url.username);
1569
+ decodeURIComponent(url.password);
1570
+ } catch {
1571
+ throw new Error("OpenCode Go: proxy credentials contain invalid URL encoding");
1572
+ }
1573
+ return url.href;
1574
+ }
1575
+
1576
+ // src/proxy.ts
1577
+ import { ProxyAgent, Socks5ProxyAgent } from "undici";
1578
+ var ProxyTransport = class {
1579
+ current;
1580
+ retired = /* @__PURE__ */ new Set();
1581
+ disposed = false;
1582
+ forProxy(raw) {
1583
+ const address = assertProxyURL(raw);
1584
+ if (!address) {
1585
+ this.retire();
1586
+ return void 0;
1587
+ }
1588
+ return (input, init) => {
1589
+ if (this.disposed) return Promise.reject(new Error("OpenCode Go proxy transport is disposed"));
1590
+ if (this.current?.address !== address) {
1591
+ const url = new URL(address);
1592
+ const dispatcher = url.protocol === "socks5:" ? new Socks5ProxyAgent(url) : new ProxyAgent(address);
1593
+ this.retire();
1594
+ this.current = { address, dispatcher };
1595
+ }
1596
+ const options = { ...init, dispatcher: this.current.dispatcher };
1597
+ return globalThis.fetch(input, options);
1598
+ };
1599
+ }
1600
+ /** Close replaced pools after their in-flight response bodies have finished. */
1601
+ retire() {
1602
+ if (!this.current) return;
1603
+ const { dispatcher } = this.current;
1604
+ this.current = void 0;
1605
+ this.retired.add(dispatcher);
1606
+ void dispatcher.close().catch(() => dispatcher.destroy()).catch(() => {
1607
+ }).finally(() => {
1608
+ this.retired.delete(dispatcher);
1609
+ });
1610
+ }
1611
+ /** Unloading the mount cancels remaining requests and releases every pool. */
1612
+ async dispose() {
1613
+ this.disposed = true;
1614
+ const dispatchers = [...this.retired, ...this.current ? [this.current.dispatcher] : []];
1615
+ this.current = void 0;
1616
+ this.retired.clear();
1617
+ await Promise.all(dispatchers.map((dispatcher) => dispatcher.destroy().catch(() => {
1618
+ })));
1619
+ }
1620
+ };
1621
+
1514
1622
  // src/adapter.ts
1623
+ function accountFailureReason(failure) {
1624
+ if (failure.code === "QUOTA") return "quota";
1625
+ if (failure.code === "MISSING_CREDENTIAL" || failure.code === "INVALID_CREDENTIAL" || failure.code === "AUTH" && (/\b401\b/.test(failure.message) || /invalid[ _-]?(?:api[ _-]?)?key|incorrect[ _-]?(?:api[ _-]?)?key|expired[ _-]?(?:api[ _-]?)?key/i.test(failure.message))) return "credential";
1626
+ return void 0;
1627
+ }
1628
+ var REJECTED_KEY_TTL_MS = 5 * 6e4;
1629
+ function isGatewayKeyRejection(failure) {
1630
+ return failure.code === "INVALID_CREDENTIAL" || failure.code === "AUTH";
1631
+ }
1515
1632
  function withModelLimit(model, limits) {
1516
1633
  const limit = limits[model.id];
1517
1634
  if (limit == null) return model;
@@ -1528,6 +1645,7 @@ var OpencodeGoAdapter = class extends LlmAdapter {
1528
1645
  constructor(options) {
1529
1646
  super();
1530
1647
  this.options = options;
1648
+ this.transport = options.transport ?? new ProxyTransport();
1531
1649
  }
1532
1650
  /**
1533
1651
  * One catalog instance per endpoint/refresh pair. A settings write that
@@ -1536,6 +1654,12 @@ var OpencodeGoAdapter = class extends LlmAdapter {
1536
1654
  * for the whole refresh interval.
1537
1655
  */
1538
1656
  catalogCache;
1657
+ transport;
1658
+ /** Per-reference rejection deadlines, isolated by the gateway that rejected it. */
1659
+ rejectedKeys = /* @__PURE__ */ new Map();
1660
+ dispose() {
1661
+ return this.transport.dispose();
1662
+ }
1539
1663
  /**
1540
1664
  * The catalog resolver for one configuration, rebuilding on the facts it
1541
1665
  * owns. Public for the plugin's discovery registration, which resolves the
@@ -1544,7 +1668,7 @@ var OpencodeGoAdapter = class extends LlmAdapter {
1544
1668
  * @returns the resolver caching catalog values, independent of deployment limits.
1545
1669
  */
1546
1670
  catalogOf(config) {
1547
- const key = `${config.baseURL}|${String(config.refreshMinutes)}`;
1671
+ const key = JSON.stringify([config.baseURL, config.refreshMinutes, assertProxyURL(config.proxyURL)]);
1548
1672
  if (this.catalogCache?.key !== key) {
1549
1673
  const catalog = new OpencodeGoCatalog(
1550
1674
  assertBaseURL(config.baseURL),
@@ -1557,7 +1681,8 @@ var OpencodeGoAdapter = class extends LlmAdapter {
1557
1681
  }),
1558
1682
  () => {
1559
1683
  if (this.catalogCache?.catalog === catalog) this.options.onCatalogRefresh?.();
1560
- }
1684
+ },
1685
+ this.transport.forProxy(config.proxyURL)
1561
1686
  );
1562
1687
  this.catalogCache = { key, catalog };
1563
1688
  }
@@ -1649,6 +1774,93 @@ var OpencodeGoAdapter = class extends LlmAdapter {
1649
1774
  yield* this.streamWithSnapshot(options, snapshot);
1650
1775
  }
1651
1776
  async *streamWithSnapshot(options, snapshot) {
1777
+ const accounts = accountsOf(snapshot.config);
1778
+ if (accounts.length === 0) throw new LlmError6("OpenCode Go has no accounts", "MISSING_CREDENTIAL");
1779
+ const ordered = snapshot.config.autoSwitch ? [.../* @__PURE__ */ new Set([snapshot.config.apiKeyEnv, ...accounts.map((account) => account.apiKeyEnv)])] : [snapshot.config.apiKeyEnv];
1780
+ const now = Date.now();
1781
+ const gateway = assertBaseURL(snapshot.config.baseURL);
1782
+ const usable = ordered.filter((ref) => {
1783
+ const rejected = this.rejectedKeys.get(ref);
1784
+ const until = rejected?.get(gateway);
1785
+ if (until === void 0) return true;
1786
+ if (now >= until) {
1787
+ rejected.delete(gateway);
1788
+ if (rejected.size === 0) this.rejectedKeys.delete(ref);
1789
+ return true;
1790
+ }
1791
+ return false;
1792
+ });
1793
+ const refs = usable.length > 0 ? usable : ordered;
1794
+ const skippedRef = ordered.find((ref) => !refs.includes(ref));
1795
+ let switchReason;
1796
+ let failedRef;
1797
+ for (let attempt = 0; attempt < refs.length; attempt++) {
1798
+ let emitted = false;
1799
+ let usage;
1800
+ let retry = false;
1801
+ const announce = () => {
1802
+ if (emitted) return;
1803
+ const serving = refs[attempt];
1804
+ if (serving === snapshot.config.apiKeyEnv) this.options.onAccountSwitch?.(void 0, snapshot.config);
1805
+ else this.options.onAccountSwitch?.({
1806
+ // The notice names the account the journey left: the first one that
1807
+ // failed this request, else the first one skipped for a remembered
1808
+ // rejection.
1809
+ fromRef: failedRef ?? skippedRef ?? snapshot.config.apiKeyEnv,
1810
+ toRef: serving,
1811
+ reason: switchReason ?? "credential",
1812
+ at: Date.now()
1813
+ }, snapshot.config);
1814
+ };
1815
+ try {
1816
+ for await (const chunk of this.streamAttempt(options, {
1817
+ ...snapshot,
1818
+ config: { ...snapshot.config, apiKeyEnv: refs[attempt] }
1819
+ })) {
1820
+ if (chunk.type === "usage") {
1821
+ usage = chunk;
1822
+ continue;
1823
+ }
1824
+ if (chunk.type === "finish") {
1825
+ const failure = chunk.reason.kind === "error" ? chunk.reason.failure : void 0;
1826
+ const reason = failure === void 0 ? void 0 : accountFailureReason(failure);
1827
+ if (reason && failure !== void 0 && !emitted && !options.signal?.aborted && !((usage?.usage.totalTokens ?? 0) > 0) && attempt + 1 < refs.length) {
1828
+ if (isGatewayKeyRejection(failure)) this.rememberRejectedKey(refs[attempt], gateway);
1829
+ switchReason ??= reason;
1830
+ failedRef ??= refs[attempt];
1831
+ retry = true;
1832
+ break;
1833
+ }
1834
+ if (chunk.reason.kind === "stop" || chunk.reason.kind === "tool-calls" || chunk.reason.kind === "max-tokens") announce();
1835
+ if (usage) yield usage;
1836
+ yield chunk;
1837
+ return;
1838
+ }
1839
+ announce();
1840
+ emitted = true;
1841
+ yield chunk;
1842
+ }
1843
+ if (!retry) return;
1844
+ } catch (error) {
1845
+ const reason = error instanceof LlmError6 ? accountFailureReason(error) : void 0;
1846
+ if (!reason || emitted || options.signal?.aborted || attempt + 1 >= refs.length) throw error;
1847
+ if (error instanceof LlmError6 && isGatewayKeyRejection(error)) this.rememberRejectedKey(refs[attempt], gateway);
1848
+ switchReason ??= reason;
1849
+ failedRef ??= refs[attempt];
1850
+ }
1851
+ }
1852
+ }
1853
+ rememberRejectedKey(ref, gateway) {
1854
+ const rejected = this.rejectedKeys.get(ref) ?? /* @__PURE__ */ new Map();
1855
+ rejected.set(gateway, Date.now() + REJECTED_KEY_TTL_MS);
1856
+ this.rejectedKeys.set(ref, rejected);
1857
+ }
1858
+ /** A stored change to a reference outranks the gateway's last rejection of it. */
1859
+ forgetRejectedKey(ref) {
1860
+ this.rejectedKeys.delete(ref);
1861
+ }
1862
+ /** One account, one SDK attempt; host recovery still owns failures after output. */
1863
+ async *streamAttempt(options, snapshot) {
1652
1864
  var _stack = [];
1653
1865
  try {
1654
1866
  if (options.stop !== void 0) {
@@ -1691,6 +1903,7 @@ var OpencodeGoAdapter = class extends LlmAdapter {
1691
1903
  const context = imageRequest === void 0 ? toPiContext(options, void 0, this.options.onReplayDegrade) : await toPiContext({ ...options, signal: watchdog.signal }, imageRequest, this.options.onReplayDegrade);
1692
1904
  const events = catalog.provider.streamSimple(withRequestReasoning(model, reasoning), normalizeContext(context), {
1693
1905
  apiKey,
1906
+ fetch: this.transport.forProxy(config.proxyURL),
1694
1907
  ...reasoning === void 0 || reasoning === "off" ? {} : { reasoning },
1695
1908
  ...options.temperature === void 0 ? {} : { temperature: options.temperature },
1696
1909
  ...maxTokens === void 0 ? {} : { maxTokens },
@@ -1753,6 +1966,11 @@ import { RemoteError, TypertRemoteService } from "@deepseek-ai/dsh-typert-protoc
1753
1966
  import { attributionHeaders as attributionHeaders3 } from "@deepseek-ai/dsh-llm";
1754
1967
 
1755
1968
  // src/usage-contract.ts
1969
+ function parseAccountSwitch(value) {
1970
+ const notice = value;
1971
+ if (!notice || typeof notice.fromRef !== "string" || typeof notice.toRef !== "string" || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(notice.fromRef) || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(notice.toRef) || notice.reason !== "quota" && notice.reason !== "credential" || typeof notice.at !== "number" || !Number.isSafeInteger(notice.at) || notice.at < 0 || notice.at > 864e13) throw new Error("Invalid account switch notice");
1972
+ return { fromRef: notice.fromRef, toRef: notice.toRef, reason: notice.reason, at: notice.at };
1973
+ }
1756
1974
  function parseGoUsage(value) {
1757
1975
  if (!value || typeof value !== "object") throw new Error("Invalid OpenCode Go usage response");
1758
1976
  const source = value;
@@ -1763,6 +1981,9 @@ function parseGoUsage(value) {
1763
1981
  }
1764
1982
  result.source = source.source;
1765
1983
  }
1984
+ if (source.lastSwitch !== void 0) {
1985
+ result.lastSwitch = parseAccountSwitch(source.lastSwitch);
1986
+ }
1766
1987
  for (const key of ["rolling", "weekly", "monthly"]) {
1767
1988
  const row = source[key];
1768
1989
  if (!row || row.status !== "ok" && row.status !== "rate-limited" || typeof row.percent !== "number" || !Number.isFinite(row.percent) || row.percent < 0 || typeof row.resetsAt !== "string" || !Number.isFinite(Date.parse(row.resetsAt))) {
@@ -1778,6 +1999,16 @@ var usageCodec = {
1778
1999
  schema: { parse: parseGoUsage },
1779
2000
  create: () => ({ parse: parseGoUsage })
1780
2001
  };
2002
+ var parseRef = (value) => {
2003
+ if (typeof value !== "string" || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) throw new Error("Invalid account reference");
2004
+ return value;
2005
+ };
2006
+ var refCodec = {
2007
+ mode: "strict",
2008
+ typeSymbol: "dsh-opencode-go#AccountRef",
2009
+ schema: { parse: parseRef },
2010
+ create: () => ({ parse: parseRef })
2011
+ };
1781
2012
  var usageRemote = {
1782
2013
  package: "dsh-opencode-go",
1783
2014
  descriptors: [{
@@ -1788,6 +2019,14 @@ var usageRemote = {
1788
2019
  invocation: { kind: "direct" },
1789
2020
  parameters: [],
1790
2021
  result: usageCodec
2022
+ }, {
2023
+ id: "dsh-opencode-go#opencodeGoUsage/readAccount",
2024
+ service: "opencodeGoUsage",
2025
+ namespace: "opencodeGoUsage",
2026
+ method: "readAccount",
2027
+ invocation: { kind: "direct" },
2028
+ parameters: [{ name: "ref", wire: "ref", source: "json", codec: refCodec }],
2029
+ result: usageCodec
1791
2030
  }]
1792
2031
  };
1793
2032
 
@@ -1797,15 +2036,48 @@ var GoUsageService = class extends TypertRemoteService {
1797
2036
  constructor(ctx, options) {
1798
2037
  super(ctx, "opencodeGoUsage");
1799
2038
  this.options = options;
2039
+ this.transport = options.transport ?? new ProxyTransport();
2040
+ if (!options.transport) ctx.effect(() => () => this.transport.dispose());
1800
2041
  }
1801
- identity;
2042
+ identities = /* @__PURE__ */ new Map();
2043
+ pending = /* @__PURE__ */ new Map();
2044
+ transport;
1802
2045
  async read() {
2046
+ try {
2047
+ const usage = await this.readRef(this.options.activeRef?.());
2048
+ const lastSwitch = this.options.lastSwitch?.();
2049
+ return lastSwitch ? { ...usage, lastSwitch } : usage;
2050
+ } catch (error) {
2051
+ const lastSwitch = this.options.lastSwitch?.();
2052
+ if (lastSwitch && error instanceof RemoteError && error.code === "opencode-go/usage-unavailable") {
2053
+ throw new RemoteError(
2054
+ "opencode-go/usage-unavailable",
2055
+ error.message,
2056
+ { ...error.details, lastSwitch },
2057
+ { cause: error }
2058
+ );
2059
+ }
2060
+ throw error;
2061
+ }
2062
+ }
2063
+ async readAccount(ref) {
2064
+ if (!this.options.accountRefs?.().includes(ref)) {
2065
+ throw new RemoteError("gateway/bad-request", "Unknown OpenCode Go account", {});
2066
+ }
2067
+ return this.readRef(ref);
2068
+ }
2069
+ async readRef(ref) {
2070
+ const identityKey = ref ?? "";
2071
+ if (this.options.accountRefs) for (const saved of this.identities.keys()) {
2072
+ if (saved && !this.options.accountRefs().includes(saved)) this.identities.delete(saved);
2073
+ }
1803
2074
  const baseURL = assertBaseURL(this.options.baseURL()).replace(/\/$/, "");
2075
+ const proxyURL = assertProxyURL(this.options.proxyURL?.());
1804
2076
  let key;
1805
2077
  try {
1806
- key = await this.options.resolveApiKey();
2078
+ key = await this.options.resolveApiKey(ref);
1807
2079
  } catch (error) {
1808
- this.identity = void 0;
2080
+ this.identities.delete(identityKey);
1809
2081
  const missing = error instanceof Error && "code" in error && error.code === "MISSING_CREDENTIAL";
1810
2082
  throw new RemoteError("opencode-go/usage-unavailable", missing ? "OpenCode Go API key is not configured" : "Could not resolve the OpenCode Go API key", {
1811
2083
  retryable: !missing,
@@ -1813,16 +2085,29 @@ var GoUsageService = class extends TypertRemoteService {
1813
2085
  }, { cause: error });
1814
2086
  }
1815
2087
  if (!key) {
1816
- this.identity = void 0;
2088
+ this.identities.delete(identityKey);
1817
2089
  throw new RemoteError("opencode-go/usage-unavailable", "OpenCode Go API key is not configured", {
1818
2090
  retryable: false,
1819
2091
  retainPrevious: false
1820
2092
  });
1821
2093
  }
1822
- if (this.identity?.baseURL !== baseURL || this.identity.key !== key) {
1823
- this.identity = { baseURL, key, source: randomUUID3() };
2094
+ let identity = this.identities.get(identityKey);
2095
+ if (identity?.baseURL !== baseURL || identity.proxyURL !== proxyURL || identity.key !== key) {
2096
+ identity = { baseURL, proxyURL, key, source: randomUUID3() };
2097
+ this.identities.set(identityKey, identity);
2098
+ }
2099
+ const { source } = identity;
2100
+ const shared = this.pending.get(source);
2101
+ if (shared) return shared;
2102
+ const pending = this.fetchUsage(baseURL, key, source, this.transport.forProxy(proxyURL));
2103
+ this.pending.set(source, pending);
2104
+ try {
2105
+ return await pending;
2106
+ } finally {
2107
+ this.pending.delete(source);
1824
2108
  }
1825
- const { source } = this.identity;
2109
+ }
2110
+ async fetchUsage(baseURL, key, source, fetcher) {
1826
2111
  const endpoint = diagnosticURL(`${baseURL}/usage`);
1827
2112
  let result;
1828
2113
  try {
@@ -1830,7 +2115,7 @@ var GoUsageService = class extends TypertRemoteService {
1830
2115
  headers: { ...attributionHeaders3(), Authorization: `Bearer ${key}`, Accept: "application/json" },
1831
2116
  signal: AbortSignal.timeout(1e4),
1832
2117
  redirect: "error"
1833
- }, USAGE_MAX_BYTES);
2118
+ }, USAGE_MAX_BYTES, "identity", fetcher);
1834
2119
  } catch (error) {
1835
2120
  const invalid = error instanceof SyntaxError || error instanceof RangeError;
1836
2121
  const detail = error instanceof SyntaxError ? "invalid JSON response" : error instanceof RangeError ? `response exceeds the ${USAGE_MAX_BYTES} byte limit` : transportFailure(error);
@@ -1905,9 +2190,14 @@ function apply(ctx, raw) {
1905
2190
  const config = raw && typeof raw.enabled === "object" ? raw : Config(raw);
1906
2191
  const entry = readConfig(config);
1907
2192
  assertBaseURL(entry.baseURL);
2193
+ assertProxyURL(entry.proxyURL);
2194
+ assertAccounts(entry.accounts, entry.apiKeyEnv);
1908
2195
  let current = () => readConfig(config);
1909
2196
  const resolveApiKey = async (config2 = current()) => {
1910
2197
  const ref = config2.apiKeyEnv;
2198
+ if (!accountsOf(config2).some((account) => account.apiKeyEnv === ref)) {
2199
+ throw new LlmError7("OpenCode Go has no selected account", "MISSING_CREDENTIAL");
2200
+ }
1911
2201
  const credentials = ctx.get("credentials");
1912
2202
  const hit = credentials !== void 0 ? (await credentials.resolve(credentialRef(ref)))?.value : launchEnvironmentOf(ctx).get(ref)?.value;
1913
2203
  if (hit !== void 0 && hit.length > 0) return assertUsableApiKey(hit, name, ref);
@@ -1917,7 +2207,18 @@ function apply(ctx, raw) {
1917
2207
  );
1918
2208
  };
1919
2209
  registerGoRemotes(ctx);
1920
- ctx.plugin(GoUsageService, { baseURL: () => current().baseURL, resolveApiKey });
2210
+ let lastSwitch;
2211
+ const transport = new ProxyTransport();
2212
+ ctx.effect(() => () => transport.dispose());
2213
+ ctx.plugin(GoUsageService, {
2214
+ baseURL: () => current().baseURL,
2215
+ proxyURL: () => current().proxyURL,
2216
+ transport,
2217
+ resolveApiKey: (ref) => resolveApiKey({ ...current(), apiKeyEnv: ref ?? current().apiKeyEnv }),
2218
+ activeRef: () => accountRefOf(current()),
2219
+ accountRefs: () => accountsOf(current()).map((account) => account.apiKeyEnv),
2220
+ lastSwitch: () => lastSwitch
2221
+ });
1921
2222
  const logger = {
1922
2223
  fallback: ({ url, error }) => {
1923
2224
  ctx.logger.warn(`llm-opencode-go: could not refresh ${url}; using last-known model data (${String(error)})`);
@@ -1928,8 +2229,13 @@ function apply(ctx, raw) {
1928
2229
  };
1929
2230
  let registration;
1930
2231
  const adapter = new OpencodeGoAdapter({
2232
+ transport,
1931
2233
  config: () => current(),
1932
2234
  resolveApiKey,
2235
+ onAccountSwitch: (notice, captured) => {
2236
+ const config2 = current();
2237
+ if (config2.baseURL === captured.baseURL && config2.proxyURL === captured.proxyURL && config2.apiKeyEnv === captured.apiKeyEnv && JSON.stringify(config2.accounts) === JSON.stringify(captured.accounts)) lastSwitch = notice;
2238
+ },
1933
2239
  imageAccess: {
1934
2240
  resolveAttachments: () => ctx.get("attachments"),
1935
2241
  resolveImageAccess: (attachments, ref) => resolveImageAttachmentAccess(
@@ -1971,19 +2277,29 @@ function apply(ctx, raw) {
1971
2277
  }
1972
2278
  }
1973
2279
  };
2280
+ let routeCheck = 0;
2281
+ let accountIdentity = "";
1974
2282
  const syncRoute = () => {
2283
+ const check = ++routeCheck;
2284
+ const config2 = current();
2285
+ const identity = JSON.stringify([config2.apiKeyEnv, config2.accounts, config2.baseURL, config2.proxyURL, config2.autoSwitch]);
2286
+ if (identity !== accountIdentity) {
2287
+ accountIdentity = identity;
2288
+ lastSwitch = void 0;
2289
+ }
1975
2290
  const visibility = pickerVisibilityOf();
1976
2291
  if (pickerVisibility !== visibility) {
1977
2292
  pickerVisibility = visibility;
1978
2293
  registration?.replace([PROVIDER_ID]);
1979
2294
  }
1980
2295
  const credentials = ctx.get("credentials");
2296
+ const refs = accountsOf(config2).filter((account) => config2.autoSwitch || account.apiKeyEnv === config2.apiKeyEnv).map((account) => account.apiKeyEnv);
1981
2297
  if (credentials === void 0) {
1982
- applyRoute(launchEnvironmentOf(ctx).get(current().apiKeyEnv)?.value !== void 0);
2298
+ applyRoute(refs.some((ref) => Boolean(launchEnvironmentOf(ctx).get(ref)?.value)));
1983
2299
  return;
1984
2300
  }
1985
- void credentials.describe(credentialRef(current().apiKeyEnv)).then((info) => {
1986
- applyRoute(info.configured);
2301
+ void Promise.all(refs.map((ref) => credentials.describe(credentialRef(ref)))).then((infos) => {
2302
+ if (check === routeCheck) applyRoute(infos.some((info) => info.configured));
1987
2303
  }).catch((error) => {
1988
2304
  ctx.logger.error(`llm-opencode-go: credential describe failed; keeping the previous route state (${String(error)})`);
1989
2305
  });
@@ -2012,6 +2328,8 @@ function apply(ctx, raw) {
2012
2328
  settingsCtx.settings.installSection(ctx, NS, PlainConfig, entry, {
2013
2329
  validate: (value) => {
2014
2330
  assertBaseURL(value.baseURL);
2331
+ assertProxyURL(value.proxyURL);
2332
+ assertAccounts(value.accounts, value.apiKeyEnv);
2015
2333
  },
2016
2334
  setSource: (source) => {
2017
2335
  current = source;
@@ -2023,13 +2341,22 @@ function apply(ctx, raw) {
2023
2341
  });
2024
2342
  ctx.on("internal/config", function(_raw, next) {
2025
2343
  const value = next();
2026
- if (this === ctx.fiber) assertBaseURL(PlainConfig(value).baseURL);
2344
+ if (this === ctx.fiber) {
2345
+ const config2 = PlainConfig(value);
2346
+ assertBaseURL(config2.baseURL);
2347
+ assertProxyURL(config2.proxyURL);
2348
+ assertAccounts(config2.accounts, config2.apiKeyEnv);
2349
+ }
2027
2350
  return value;
2028
2351
  });
2029
2352
  ctx.on("loader/volatile-update", syncRoute);
2030
2353
  ctx.inject(["credentials"], (credentialsCtx) => {
2031
2354
  credentialsCtx.on("credentials/reference-updated", (ref) => {
2032
- if (ref === current().apiKeyEnv) syncRoute();
2355
+ adapter.forgetRejectedKey(ref);
2356
+ if (accountsOf(current()).some((account) => account.apiKeyEnv === ref)) {
2357
+ lastSwitch = void 0;
2358
+ syncRoute();
2359
+ }
2033
2360
  });
2034
2361
  syncRoute();
2035
2362
  });
@@ -2045,6 +2372,7 @@ export {
2045
2372
  PlainConfig,
2046
2373
  apply,
2047
2374
  assertBaseURL,
2375
+ assertProxyURL,
2048
2376
  discoverCatalogModels,
2049
2377
  inject,
2050
2378
  name,
@@ -0,0 +1,26 @@
1
+ /** Client-safe account metadata. Secrets stay in the host credential service. */
2
+ export type GoAccount = {
3
+ id: string;
4
+ name: string;
5
+ apiKeyEnv: string;
6
+ };
7
+ export declare const MAX_ACCOUNTS = 20;
8
+ export declare const ACCOUNT_REF_PREFIX = "DSH_OPENCODE_GO_ACCOUNT_";
9
+ export declare const DEFAULT_ACCOUNT_REF = "OPENCODE_API_KEY";
10
+ export interface AccountSettings {
11
+ apiKeyEnv?: string;
12
+ /** Omission/null preserves legacy credentials; an explicit empty list removes all accounts. */
13
+ accounts?: readonly GoAccount[] | null;
14
+ autoSwitch?: boolean;
15
+ }
16
+ export declare function accountRefOf(settings: AccountSettings): string;
17
+ /** Include an externally selected reference without rewriting existing configuration. */
18
+ export declare function accountsOf(settings: AccountSettings): readonly GoAccount[];
19
+ export declare function assertAccounts(accounts: readonly GoAccount[] | null | undefined, selectedRef?: string): void;
20
+ /** Safe UI notice for a successful request using a fallback account. */
21
+ export interface GoAccountSwitch {
22
+ fromRef: string;
23
+ toRef: string;
24
+ reason: 'quota' | 'credential';
25
+ at: number;
26
+ }