@goodandready/dsh-opencode-provider 0.5.1 → 0.5.3

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
@@ -17,7 +17,7 @@ const DEFAULT_GO_API_BASE = "https://opencode.ai/inference/go";
17
17
  const DEFAULT_GO_ZEN_BASE = "https://opencode.ai/zen/go/v1";
18
18
  const DEFAULT_USAGE_URL = "https://opencode.ai/zen/go/v1/usage";
19
19
  const DEFAULT_AUTH_ENTRY = "opencode-go";
20
- const DEFAULT_USER_AGENT = "dsh-opencode-provider/0.5.0";
20
+ const DEFAULT_USER_AGENT = "dsh-opencode-provider/0.5.3";
21
21
  const DEFAULT_CONTEXT_WINDOW = 262144;
22
22
  const DEFAULT_MAX_TOKENS = 32768;
23
23
  const DEFAULT_GENERATE_MAX_TOKENS = 32768;
@@ -56,6 +56,8 @@ const Config = z.object({
56
56
  pattern: z.string(),
57
57
  account: z.string()
58
58
  })).default([])),
59
+ quotaFallbackModel: markVolatile(z.string().default("")),
60
+ stickySession: markVolatile(z.boolean().default(true)),
59
61
  activeAccount: markVolatile(z.string().default("")),
60
62
  showSidebarQuota: markVolatile(z.boolean().default(false)),
61
63
  userAgent: markVolatile(z.string().default(DEFAULT_USER_AGENT))
@@ -141,6 +143,8 @@ function resolveConfig(config) {
141
143
  ...typeof config.maxOutputTokens === "number" && Number.isInteger(config.maxOutputTokens) && config.maxOutputTokens >= 0 ? { maxOutputTokens: config.maxOutputTokens } : {},
142
144
  accounts: Array.isArray(config.accounts) ? config.accounts : [],
143
145
  modelAccountRules: modelAccountRuleList(config.modelAccountRules),
146
+ quotaFallbackModel: typeof config.quotaFallbackModel === "string" ? config.quotaFallbackModel.trim() : "",
147
+ stickySession: config.stickySession !== false,
144
148
  activeAccount: typeof config.activeAccount === "string" ? config.activeAccount : "",
145
149
  showSidebarQuota: config.showSidebarQuota === true,
146
150
  userAgent: typeof config.userAgent === "string" && config.userAgent !== "" ? config.userAgent : DEFAULT_USER_AGENT
@@ -1242,6 +1246,17 @@ function offloadPlaceholder(ref) {
1242
1246
  return `[image omitted: ${ref.name ?? ref.attachmentId}]`;
1243
1247
  }
1244
1248
  //#endregion
1249
+ //#region src/files.ts
1250
+ /**
1251
+ * Model-facing handle for a file when the adapter receives a raw FileBlock.
1252
+ * The harness normally projects files to text with an execution-world path
1253
+ * before dispatch; this is the safe fallback for direct adapter calls.
1254
+ */
1255
+ function filePlaceholder(ref) {
1256
+ const digest = String(ref.attachmentId).slice(7, 15);
1257
+ return `[File ${JSON.stringify(ref.name)} (${ref.bytes} bytes, sha256:${digest}) was uploaded, but the current execution environment cannot access a readable path. Report that limitation if its contents are needed; do not claim to have read it.]`;
1258
+ }
1259
+ //#endregion
1245
1260
  //#region src/tool-schema.ts
1246
1261
  /**
1247
1262
  * Normalize a tool's parameters schema to the object root the gateway
@@ -1354,7 +1369,7 @@ function textOnly$3(content) {
1354
1369
  const parts = [];
1355
1370
  for (const block of content) if (block.type === "text") parts.push(block.text);
1356
1371
  else if (block.type === "image") parts.push(offloadPlaceholder(block.attachment));
1357
- else if (block.type === "file") parts.push("[file omitted: tool-result files are not supported]");
1372
+ else if (block.type === "file") parts.push(filePlaceholder(block.attachment));
1358
1373
  return parts.join("");
1359
1374
  }
1360
1375
  function userContent(content, images) {
@@ -1386,7 +1401,10 @@ function userContent(content, images) {
1386
1401
  type: "image_url",
1387
1402
  image_url: { url: image.dataUrl }
1388
1403
  });
1389
- } else if (block.type === "file") throw new LlmError("llm-opencode: file attachments are not supported yet.", "UNSUPPORTED_CONTENT");
1404
+ } else if (block.type === "file") parts.push({
1405
+ type: "text",
1406
+ text: filePlaceholder(block.attachment)
1407
+ });
1390
1408
  if (!hasImage) return parts.map((part) => part.type === "text" ? part.text : "").join("");
1391
1409
  return parts;
1392
1410
  }
@@ -1506,22 +1524,26 @@ function requestLogPath() {
1506
1524
  return join(home, "storages", "dsh-opencode", "requests.jsonl");
1507
1525
  }
1508
1526
  let queue = Promise.resolve();
1509
- let directoryReady = false;
1527
+ let readyDirectory = "";
1510
1528
  let appends = 0;
1511
- function recordRequest(provider, model, at = Date.now(), purpose) {
1529
+ function recordRequest(provider, model, at = Date.now(), purpose, account, reason) {
1512
1530
  const line = `${JSON.stringify({
1513
1531
  at,
1514
1532
  provider,
1515
1533
  model,
1516
- ...purpose === void 0 ? {} : { purpose }
1534
+ ...purpose === void 0 ? {} : { purpose },
1535
+ ...account === void 0 ? {} : { account },
1536
+ ...reason === void 0 ? {} : { reason }
1517
1537
  })}\n`;
1518
1538
  queue = queue.then(async () => {
1519
1539
  try {
1520
- if (!directoryReady) {
1521
- await mkdir(dirname(requestLogPath()), { recursive: true });
1522
- directoryReady = true;
1540
+ const path = requestLogPath();
1541
+ const directory = dirname(path);
1542
+ if (readyDirectory !== directory) {
1543
+ await mkdir(directory, { recursive: true });
1544
+ readyDirectory = directory;
1523
1545
  }
1524
- await appendFile(requestLogPath(), line, "utf8");
1546
+ await appendFile(path, line, "utf8");
1525
1547
  appends += 1;
1526
1548
  if (appends % COMPACT_EVERY === 0) await compactRequestLog();
1527
1549
  } catch {}
@@ -1542,6 +1564,7 @@ async function readRequestHeatmap(days, now = Date.now()) {
1542
1564
  const hours = Array.from({ length: 24 }, () => 0);
1543
1565
  const grid = Array.from({ length: 7 }, () => Array.from({ length: 24 }, () => 0));
1544
1566
  const models = /* @__PURE__ */ new Map();
1567
+ const accounts = /* @__PURE__ */ new Map();
1545
1568
  const seen = /* @__PURE__ */ new Set();
1546
1569
  let total = 0;
1547
1570
  let firstAt = null;
@@ -1557,7 +1580,8 @@ async function readRequestHeatmap(days, now = Date.now()) {
1557
1580
  grid,
1558
1581
  firstAt,
1559
1582
  lastAt,
1560
- models: []
1583
+ models: [],
1584
+ accounts: []
1561
1585
  };
1562
1586
  }
1563
1587
  for (const line of text.split("\n")) {
@@ -1575,7 +1599,8 @@ async function readRequestHeatmap(days, now = Date.now()) {
1575
1599
  if (purpose === "session-title" || purpose === "compaction") continue;
1576
1600
  const provider = typeof parsed.provider === "string" ? parsed.provider : "";
1577
1601
  const model = typeof parsed.model === "string" ? parsed.model : "";
1578
- const key = `${at}|${provider}|${model}`;
1602
+ const account = typeof parsed.account === "string" && parsed.account !== "" ? parsed.account : "unknown";
1603
+ const key = `${at}|${provider}|${model}|${account}`;
1579
1604
  if (seen.has(key)) continue;
1580
1605
  seen.add(key);
1581
1606
  const date = new Date(at);
@@ -1602,6 +1627,20 @@ async function readRequestHeatmap(days, now = Date.now()) {
1602
1627
  const modelRow = entry.grid[day];
1603
1628
  if (modelRow !== void 0) modelRow[hour] = (modelRow[hour] ?? 0) + 1;
1604
1629
  }
1630
+ let accountEntry = accounts.get(account);
1631
+ if (accountEntry === void 0) {
1632
+ accountEntry = {
1633
+ account,
1634
+ total: 0,
1635
+ hours: Array.from({ length: 24 }, () => 0),
1636
+ grid: Array.from({ length: 7 }, () => Array.from({ length: 24 }, () => 0))
1637
+ };
1638
+ accounts.set(account, accountEntry);
1639
+ }
1640
+ accountEntry.total += 1;
1641
+ accountEntry.hours[hour] = (accountEntry.hours[hour] ?? 0) + 1;
1642
+ const accountRow = accountEntry.grid[day];
1643
+ if (accountRow !== void 0) accountRow[hour] = (accountRow[hour] ?? 0) + 1;
1605
1644
  total += 1;
1606
1645
  if (firstAt === null || at < firstAt) firstAt = at;
1607
1646
  if (lastAt === null || at > lastAt) lastAt = at;
@@ -1613,7 +1652,8 @@ async function readRequestHeatmap(days, now = Date.now()) {
1613
1652
  grid,
1614
1653
  firstAt,
1615
1654
  lastAt,
1616
- models: [...models.values()].sort((a, b) => b.total - a.total || a.model.localeCompare(b.model))
1655
+ models: [...models.values()].sort((a, b) => b.total - a.total || a.model.localeCompare(b.model)),
1656
+ accounts: [...accounts.values()].sort((a, b) => b.total - a.total || a.account.localeCompare(b.account))
1617
1657
  };
1618
1658
  }
1619
1659
  //#endregion
@@ -1775,7 +1815,7 @@ function textOnly$2(content) {
1775
1815
  const parts = [];
1776
1816
  for (const block of content) if (block.type === "text") parts.push(block.text);
1777
1817
  else if (block.type === "image") parts.push(offloadPlaceholder(block.attachment));
1778
- else if (block.type === "file") parts.push("[file omitted: tool-result files are not supported]");
1818
+ else if (block.type === "file") parts.push(filePlaceholder(block.attachment));
1779
1819
  return parts.join("");
1780
1820
  }
1781
1821
  function contentParts(content, images) {
@@ -1810,7 +1850,10 @@ function contentParts(content, images) {
1810
1850
  data: image.base64
1811
1851
  }
1812
1852
  });
1813
- } else if (block.type === "file") throw new LlmError("llm-opencode: file attachments are not supported yet.", "UNSUPPORTED_CONTENT");
1853
+ } else if (block.type === "file") parts.push({
1854
+ type: "text",
1855
+ text: filePlaceholder(block.attachment)
1856
+ });
1814
1857
  return parts.length === 0 ? [{
1815
1858
  type: "text",
1816
1859
  text: ""
@@ -2151,7 +2194,7 @@ function textOnly$1(content) {
2151
2194
  const parts = [];
2152
2195
  for (const block of content) if (block.type === "text") parts.push(block.text);
2153
2196
  else if (block.type === "image") parts.push(offloadPlaceholder(block.attachment));
2154
- else if (block.type === "file") parts.push("[file omitted: tool-result files are not supported]");
2197
+ else if (block.type === "file") parts.push(filePlaceholder(block.attachment));
2155
2198
  return parts.join("");
2156
2199
  }
2157
2200
  function imageParts(block, images) {
@@ -2171,7 +2214,7 @@ function userParts$1(content, images) {
2171
2214
  for (const block of content) if (block.type === "text") {
2172
2215
  if (block.text !== "") parts.push({ text: block.text });
2173
2216
  } else if (block.type === "image") parts.push(...imageParts(block, images));
2174
- else if (block.type === "file") throw new LlmError("llm-opencode: file attachments are not supported yet.", "UNSUPPORTED_CONTENT");
2217
+ else if (block.type === "file") parts.push({ text: filePlaceholder(block.attachment) });
2175
2218
  return parts.length === 0 ? [{ text: "" }] : parts;
2176
2219
  }
2177
2220
  function replayMeta(message) {
@@ -2691,7 +2734,7 @@ function textOnly(content) {
2691
2734
  const parts = [];
2692
2735
  for (const block of content) if (block.type === "text") parts.push(block.text);
2693
2736
  else if (block.type === "image") parts.push(offloadPlaceholder(block.attachment));
2694
- else if (block.type === "file") parts.push("[file omitted: tool-result files are not supported]");
2737
+ else if (block.type === "file") parts.push(filePlaceholder(block.attachment));
2695
2738
  return parts.join("");
2696
2739
  }
2697
2740
  function userParts(content, images) {
@@ -2722,7 +2765,10 @@ function userParts(content, images) {
2722
2765
  type: "input_image",
2723
2766
  image_url: image.dataUrl
2724
2767
  });
2725
- } else if (block.type === "file") throw new LlmError("llm-opencode: file attachments are not supported yet.", "UNSUPPORTED_CONTENT");
2768
+ } else if (block.type === "file") parts.push({
2769
+ type: "input_text",
2770
+ text: filePlaceholder(block.attachment)
2771
+ });
2726
2772
  return parts.length === 0 ? [{
2727
2773
  type: "input_text",
2728
2774
  text: ""
@@ -2997,6 +3043,8 @@ var OpenCodeAdapter = class extends LlmAdapter {
2997
3043
  cooldownWrites = Promise.resolve();
2998
3044
  /** key fingerprint → cooldown deadline and the rejection that caused it. */
2999
3045
  cooldowns = /* @__PURE__ */ new Map();
3046
+ sticky = /* @__PURE__ */ new Map();
3047
+ routing = [];
3000
3048
  warnedTools = false;
3001
3049
  constructor(deps) {
3002
3050
  super();
@@ -3048,6 +3096,10 @@ var OpenCodeAdapter = class extends LlmAdapter {
3048
3096
  };
3049
3097
  });
3050
3098
  }
3099
+ /** Recent routing decisions, newest first; no keys or prompt content. */
3100
+ routingTrace() {
3101
+ return [...this.routing];
3102
+ }
3051
3103
  async resolveModel(provider, model) {
3052
3104
  const config = this.deps.config();
3053
3105
  const meta = effectiveModel(model, config);
@@ -3067,14 +3119,31 @@ var OpenCodeAdapter = class extends LlmAdapter {
3067
3119
  };
3068
3120
  }
3069
3121
  stream(options) {
3070
- return this.run(options);
3122
+ return this.run(options, false);
3071
3123
  }
3072
- async *run(options) {
3073
- (this.deps.recordRequest ?? ((provider, model, purpose) => recordRequest(provider, model, Date.now(), purpose)))(options.provider, options.model, options.purpose);
3124
+ async *run(options, fallback) {
3125
+ const record = this.deps.recordRequest ?? ((provider, model, purpose, account, reason) => recordRequest(provider, model, Date.now(), purpose, account, reason));
3074
3126
  const config = this.deps.config();
3075
3127
  const model = effectiveModel(options.model, config);
3076
- const keys = orderKeysForModel(this.deps.resolveKeys !== void 0 ? await this.deps.resolveKeys(options.provider) : [await this.deps.resolveKey(options.provider)], options.model, config.modelAccountRules);
3077
3128
  const sessionId = openCodeSessionId(options);
3129
+ const rawKeys = orderKeysForModel(this.deps.resolveKeys !== void 0 ? await this.deps.resolveKeys(options.provider) : [await this.deps.resolveKey(options.provider)], options.model, config.modelAccountRules);
3130
+ const rule = matchModelAccount(options.model, config.modelAccountRules);
3131
+ let keys = rawKeys;
3132
+ let reason = rule === void 0 ? "default" : "rule";
3133
+ if (fallback) reason = "quota-fallback";
3134
+ else if (config.activeAccount !== "") reason = "pinned";
3135
+ else if (config.stickySession && rule === void 0) {
3136
+ const previous = this.sticky.get(sessionId);
3137
+ if (previous !== void 0) {
3138
+ const preferred = rawKeys.find((entry) => this.keyId(entry.key) === previous.fingerprint);
3139
+ if (preferred !== void 0) {
3140
+ keys = [preferred, ...rawKeys.filter((entry) => entry !== preferred)];
3141
+ reason = "sticky";
3142
+ }
3143
+ }
3144
+ }
3145
+ const initial = keys.find((entry) => fallback || !this.coolingDown(entry.key)) ?? keys[0];
3146
+ if (initial !== void 0) record(options.provider, options.model, options.purpose, initial.label ?? "default", reason);
3078
3147
  const url = endpointFor(options.provider, model.transport, options.model, config);
3079
3148
  let images;
3080
3149
  if (hasImages(options)) {
@@ -3096,8 +3165,11 @@ var OpenCodeAdapter = class extends LlmAdapter {
3096
3165
  }
3097
3166
  const body = model.transport === "openai-chat" ? buildChatBody(bodyOptions, model, config, images) : model.transport === "anthropic" ? buildAnthropicBody(bodyOptions, model, config, images) : model.transport === "openai-responses" ? buildResponsesBody(bodyOptions, model, config, images) : buildGoogleBody(bodyOptions, model, config, images);
3098
3167
  let lastError;
3168
+ let lastAccount = initial?.label ?? "default";
3099
3169
  for (const [index, entry] of keys.entries()) {
3100
- if (this.coolingDown(entry.key)) continue;
3170
+ if (!fallback && this.coolingDown(entry.key)) continue;
3171
+ lastAccount = entry.label ?? "default";
3172
+ const attemptReason = index === 0 ? reason : "rotation";
3101
3173
  let started = false;
3102
3174
  let responseFailure = false;
3103
3175
  try {
@@ -3108,12 +3180,22 @@ var OpenCodeAdapter = class extends LlmAdapter {
3108
3180
  lastError = error;
3109
3181
  const canRotate = index < keys.length - 1;
3110
3182
  if (error.code === "INVALID_CREDENTIAL" || error.code === "RATE_LIMIT" || error.code === "QUOTA") {
3111
- this.markCooldown(entry.key, response.status, response.headers, error.code);
3183
+ if (!fallback) this.markCooldown(entry.key, response.status, response.headers, error.code);
3112
3184
  responseFailure = true;
3113
3185
  if (canRotate) continue;
3114
3186
  }
3115
3187
  throw error;
3116
3188
  }
3189
+ this.rememberSticky(sessionId, options, entry);
3190
+ this.pushTrace({
3191
+ at: Date.now(),
3192
+ provider: options.provider,
3193
+ model: options.model,
3194
+ account: entry.label ?? "default",
3195
+ reason: attemptReason,
3196
+ fallback,
3197
+ ok: true
3198
+ });
3117
3199
  for await (const chunk of this.parse(response, model.transport, config, options.signal)) {
3118
3200
  started = true;
3119
3201
  yield chunk;
@@ -3124,11 +3206,53 @@ var OpenCodeAdapter = class extends LlmAdapter {
3124
3206
  const classified = error instanceof LlmError ? error : new LlmError(error instanceof Error ? error.message : "OpenCode request failed", "TRANSPORT", { cause: error });
3125
3207
  lastError = classified;
3126
3208
  const transient = this.isTransient(classified);
3127
- if (transient && !responseFailure) this.markCooldown(entry.key, 0);
3128
- if (!(index < keys.length - 1 && transient)) throw classified;
3209
+ if (transient && !responseFailure && !fallback) this.markCooldown(entry.key, 0);
3210
+ if (!(index < keys.length - 1 && transient)) {
3211
+ if (!fallback && config.quotaFallbackModel !== "" && options.provider === "opencode-go" && classified.code === "QUOTA") break;
3212
+ throw classified;
3213
+ }
3129
3214
  }
3130
3215
  }
3131
- throw lastError ?? new LlmError("llm-opencode: every configured account is cooling down after provider rejections", "RATE_LIMIT");
3216
+ const finalError = lastError ?? new LlmError("llm-opencode: every configured account is cooling down after provider rejections", "RATE_LIMIT");
3217
+ this.pushTrace({
3218
+ at: Date.now(),
3219
+ provider: options.provider,
3220
+ model: options.model,
3221
+ account: lastAccount,
3222
+ reason,
3223
+ fallback,
3224
+ ok: false,
3225
+ error: finalError.code
3226
+ });
3227
+ if (!fallback && config.quotaFallbackModel !== "" && options.provider === "opencode-go" && (lastError === void 0 || lastError.code === "QUOTA")) try {
3228
+ yield* this.run({
3229
+ ...options,
3230
+ provider: "opencode",
3231
+ model: config.quotaFallbackModel
3232
+ }, true);
3233
+ return;
3234
+ } catch {
3235
+ throw finalError;
3236
+ }
3237
+ throw finalError;
3238
+ }
3239
+ rememberSticky(sessionId, options, entry) {
3240
+ if (!this.deps.config().stickySession) return;
3241
+ if (!this.sticky.has(sessionId) && this.sticky.size >= 500) {
3242
+ const oldest = this.sticky.keys().next().value;
3243
+ if (oldest !== void 0) this.sticky.delete(oldest);
3244
+ }
3245
+ this.sticky.set(sessionId, {
3246
+ fingerprint: this.keyId(entry.key),
3247
+ label: entry.label ?? "default",
3248
+ provider: options.provider,
3249
+ model: options.model,
3250
+ at: Date.now()
3251
+ });
3252
+ }
3253
+ pushTrace(entry) {
3254
+ this.routing.unshift(entry);
3255
+ if (this.routing.length > 20) this.routing.length = 20;
3132
3256
  }
3133
3257
  parse(response, transport, config, signal) {
3134
3258
  if (transport === "anthropic") return parseAnthropicStream(response, config.streamIdleTimeoutMs, signal);
@@ -4165,6 +4289,8 @@ function publicConfig(config) {
4165
4289
  pattern: rule.pattern,
4166
4290
  account: rule.account
4167
4291
  })),
4292
+ quotaFallbackModel: config.quotaFallbackModel,
4293
+ stickySession: config.stickySession,
4168
4294
  activeAccount: config.activeAccount,
4169
4295
  showSidebarQuota: config.showSidebarQuota,
4170
4296
  userAgent: config.userAgent
@@ -4349,6 +4475,8 @@ function configOps(patch) {
4349
4475
  if ("maxOutputTokens" in patch) set("maxOutputTokens", patch.maxOutputTokens);
4350
4476
  if ("accounts" in patch) set("accounts", cleanAccounts(patch.accounts));
4351
4477
  if ("modelAccountRules" in patch) set("modelAccountRules", cleanModelAccountRules(patch.modelAccountRules));
4478
+ if ("quotaFallbackModel" in patch) set("quotaFallbackModel", patch.quotaFallbackModel);
4479
+ if ("stickySession" in patch) set("stickySession", patch.stickySession === true);
4352
4480
  if ("activeAccount" in patch) set("activeAccount", patch.activeAccount);
4353
4481
  if ("showSidebarQuota" in patch) set("showSidebarQuota", patch.showSidebarQuota === true);
4354
4482
  if ("userAgent" in patch) set("userAgent", patch.userAgent);
@@ -4451,6 +4579,7 @@ async function buildState(ctx, deps) {
4451
4579
  accounts,
4452
4580
  models,
4453
4581
  usage: usage ?? null,
4582
+ routing: deps.adapter.routingTrace(),
4454
4583
  auth: {
4455
4584
  entry: config.openCodeAuthEntry,
4456
4585
  hasStoredKey: await readOpenCodeAuthKey(config.openCodeAuthEntry) !== void 0,
@@ -4608,15 +4737,17 @@ function applyRoutes(ctx, web, deps) {
4608
4737
  try {
4609
4738
  const body = await readJson(req);
4610
4739
  const entry = typeof body.entry === "string" && body.entry.trim() !== "" ? body.entry.trim() : deps.config().openCodeAuthEntry;
4740
+ const ref = typeof body.ref === "string" && body.ref.trim() !== "" ? body.ref.trim() : deps.config().apiKeyEnv;
4611
4741
  const key = await readOpenCodeAuthKey(entry);
4612
4742
  if (key === void 0) {
4613
4743
  json(res, 404, { error: `no key in auth.json entry "${entry}"` });
4614
4744
  return;
4615
4745
  }
4616
- await credentials.set(credentialRef(deps.config().apiKeyEnv), key);
4746
+ await credentials.set(credentialRef(ref), key);
4617
4747
  json(res, 200, {
4618
4748
  ok: true,
4619
- entry
4749
+ entry,
4750
+ ref
4620
4751
  });
4621
4752
  } catch (error) {
4622
4753
  json(res, 500, { error: error instanceof Error ? error.message : "import failed" });
@@ -4805,6 +4936,27 @@ function buildOpenCodeTuiSection(deps) {
4805
4936
  value: text.trim() === "true"
4806
4937
  })
4807
4938
  },
4939
+ {
4940
+ path: ["quotaFallbackModel"],
4941
+ group: "models",
4942
+ kind: "text",
4943
+ label: "Quota fallback model",
4944
+ hint: "Free model id used once after a Go quota error; empty disables it.",
4945
+ format: () => deps.quotaFallbackModel(),
4946
+ parse: setOrClear
4947
+ },
4948
+ {
4949
+ path: ["stickySession"],
4950
+ group: "advanced",
4951
+ kind: "boolean",
4952
+ label: "Sticky account per conversation",
4953
+ hint: "Prefer the last successful account for the same conversation.",
4954
+ format: (value) => String(value === void 0 ? deps.stickySession() : value !== false),
4955
+ parse: (text) => ({
4956
+ kind: "set",
4957
+ value: text.trim() === "true"
4958
+ })
4959
+ },
4808
4960
  {
4809
4961
  path: ["activeAccount"],
4810
4962
  group: "advanced",
@@ -4874,7 +5026,9 @@ function applyOpenCodeTuiSettings(ctx, deps) {
4874
5026
  accounts: deps.accountSlots().map((slot) => [slot.id, slot.label]),
4875
5027
  visibleModels: deps.visibleModels(),
4876
5028
  activeAccount: deps.activeAccount(),
4877
- filterModelsByPlan: deps.filterModelsByPlan()
5029
+ filterModelsByPlan: deps.filterModelsByPlan(),
5030
+ quotaFallbackModel: deps.quotaFallbackModel(),
5031
+ stickySession: deps.stickySession()
4878
5032
  });
4879
5033
  let lastSignature = signature();
4880
5034
  const refresh = () => {
@@ -5053,7 +5207,9 @@ function apply(ctx, config) {
5053
5207
  }))],
5054
5208
  visibleModels: () => current().visibleModels,
5055
5209
  activeAccount: () => current().activeAccount,
5056
- filterModelsByPlan: () => current().filterModelsByPlan
5210
+ filterModelsByPlan: () => current().filterModelsByPlan,
5211
+ quotaFallbackModel: () => current().quotaFallbackModel,
5212
+ stickySession: () => current().stickySession
5057
5213
  });
5058
5214
  });
5059
5215
  ctx.inject(["webServer"], (webCtx) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-opencode-provider",
3
- "version": "0.5.1",
3
+ "version": "0.5.3",
4
4
  "description": "OpenCode / OpenCode Go LLM provider plugin for DeepSeek Harness (dsh).",
5
5
  "license": "MIT",
6
6
  "type": "module",