@goodandready/dsh-opencode-provider 0.5.20 → 0.5.22

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.d.ts CHANGED
@@ -36,6 +36,8 @@ interface Config {
36
36
  requestTimeoutMs?: number;
37
37
  /** Milliseconds a stream may stall; defaults to 300s. */
38
38
  streamIdleTimeoutMs?: number;
39
+ /** HTTP/HTTPS/SOCKS5 proxy for inference, catalog and usage requests. */
40
+ proxyUrl?: string;
39
41
  /** Visible-model allowlist; an empty list means "show everything". */
40
42
  visibleModels?: string[];
41
43
  /** Per-model visibility overrides; wins over `visibleModels`. */
@@ -50,6 +52,8 @@ interface Config {
50
52
  contextWindowOverrides?: Record<string, number>;
51
53
  /** Per-turn output cap; `0` omits `max_tokens`. */
52
54
  maxOutputTokens?: number;
55
+ /** Per-model output caps; `0` omits `max_tokens` for that model. */
56
+ maxOutputTokensOverrides?: Record<string, number>;
53
57
  /** Extra accounts for rotation (parsed now, used by the account pool later). */
54
58
  accounts?: AccountConfig[];
55
59
  /** Preferred account per model glob; other accounts remain fallbacks. */
@@ -76,6 +80,7 @@ interface ResolvedConfig {
76
80
  openCodeAuthEntry: string;
77
81
  requestTimeoutMs: number;
78
82
  streamIdleTimeoutMs: number;
83
+ proxyUrl: string;
79
84
  visibleModels: readonly string[];
80
85
  modelVisibility: Readonly<Record<string, boolean>>;
81
86
  filterModelsByPlan: boolean;
@@ -83,6 +88,7 @@ interface ResolvedConfig {
83
88
  contextWindow?: number;
84
89
  contextWindowOverrides: Readonly<Record<string, number>>;
85
90
  maxOutputTokens?: number;
91
+ maxOutputTokensOverrides: Readonly<Record<string, number>>;
86
92
  accounts: readonly AccountConfig[];
87
93
  modelAccountRules: readonly ModelAccountRule[];
88
94
  quotaFallbackModel: string;
@@ -148,7 +154,7 @@ interface RequestHeatmap {
148
154
  models: ModelRequestHeatmap[];
149
155
  accounts: AccountRequestHeatmap[];
150
156
  }
151
- type RoutingReason = 'rule' | 'sticky' | 'pinned' | 'default' | 'rotation' | 'quota-fallback';
157
+ type RoutingReason = 'rule' | 'sticky' | 'pinned' | 'promoted' | 'default' | 'rotation' | 'quota-fallback';
152
158
  interface RoutingTraceEntry {
153
159
  readonly at: number;
154
160
  readonly provider: string;
@@ -186,6 +192,8 @@ declare class OpenCodeAdapter extends LlmAdapter {
186
192
  /** key + model → short soft rejection; other models stay usable. */
187
193
  private readonly modelCooldowns;
188
194
  private readonly sticky;
195
+ /** provider → fingerprint of the account that last served a request. */
196
+ private readonly lastGood;
189
197
  private readonly routing;
190
198
  private warnedTools;
191
199
  constructor(deps: OpenCodeAdapterDeps);
@@ -200,6 +208,8 @@ declare class OpenCodeAdapter extends LlmAdapter {
200
208
  stream(options: GenerateOptions): AsyncIterable<StreamChunk>;
201
209
  private run;
202
210
  private rememberSticky;
211
+ /** Promote the account that actually served the request on the next call. */
212
+ private rememberGood;
203
213
  /** Await pending sticky persistence writes (tests and shutdown). */
204
214
  flushSticky(): Promise<void>;
205
215
  private loadSticky;
@@ -352,6 +362,8 @@ interface AnalyticsReport {
352
362
  summary: AnalyticsSummary;
353
363
  days: DayUsageRow[];
354
364
  sessions: SessionUsageRow[];
365
+ /** True when the projection scan hit a cap or skipped unreadable files. */
366
+ partial?: boolean;
355
367
  }
356
368
  interface RouteDeps {
357
369
  readonly config: () => ResolvedConfig;
package/lib/index.js CHANGED
@@ -5,11 +5,74 @@ import { homedir, networkInterfaces } from "node:os";
5
5
  import { basename, dirname, isAbsolute, join, resolve } from "node:path";
6
6
  import { LlmAdapter, LlmError, ReasoningEffortId, ToolCallId, attributionHeaders, resolveRetryPolicy } from "@deepseek-ai/dsh-llm";
7
7
  import z from "@deepseek-ai/schemastery";
8
+ import { ProxyAgent, Socks5ProxyAgent } from "undici";
8
9
  import { credentialRef } from "@deepseek-ai/dsh-credentials";
9
10
  import { launchEnvironmentOf } from "@deepseek-ai/dsh-launch-environment";
10
11
  import { spawn } from "node:child_process";
11
12
  import { fileURLToPath } from "node:url";
12
13
  import { isIP } from "node:net";
14
+ // src/proxy.ts
15
+ /**
16
+ * Proxy addresses are deliberately credential-free: the settings API returns
17
+ * the configured address to the browser, so embedding a password would leak it.
18
+ */
19
+ function normalizeProxyUrl(raw) {
20
+ const value = raw?.trim() ?? "";
21
+ if (value === "") return "";
22
+ let url;
23
+ try {
24
+ url = new URL(value);
25
+ } catch {
26
+ throw new Error("proxy address must be a valid URL");
27
+ }
28
+ if (![
29
+ "http:",
30
+ "https:",
31
+ "socks5:"
32
+ ].includes(url.protocol)) throw new Error("proxy address must use http://, https:// or socks5://");
33
+ if (url.hostname === "" || url.port === "0" || url.pathname !== "" && url.pathname !== "/" || url.search !== "" || url.hash !== "") throw new Error("proxy address must contain a host and optional port, without a path, query or fragment");
34
+ if (url.username !== "" || url.password !== "") throw new Error("proxy credentials are not supported; use a local proxy without authentication");
35
+ return url.href;
36
+ }
37
+ /**
38
+ * One fetch wrapper for inference, catalog and usage. Dispatchers are shared by
39
+ * address so a live settings change reuses pools instead of leaking them.
40
+ */
41
+ function createProxyFetch(address, baseFetch = fetch) {
42
+ const agents = /* @__PURE__ */ new Map();
43
+ let disposed = false;
44
+ const wrapped = (async (input, init) => {
45
+ if (disposed) throw new Error("proxy transport is disposed");
46
+ const proxy = normalizeProxyUrl(address());
47
+ if (proxy === "") return baseFetch(input, init);
48
+ let agent = agents.get(proxy);
49
+ if (agent === void 0) {
50
+ agent = new URL(proxy).protocol === "socks5:" ? new Socks5ProxyAgent(proxy) : new ProxyAgent(proxy);
51
+ agents.set(proxy, agent);
52
+ if (agents.size > 8) {
53
+ const oldest = agents.keys().next().value;
54
+ if (oldest !== void 0 && oldest !== proxy) {
55
+ const retired = agents.get(oldest);
56
+ agents.delete(oldest);
57
+ retired?.close().catch(() => retired?.destroy());
58
+ }
59
+ }
60
+ }
61
+ return baseFetch(input, {
62
+ ...init,
63
+ dispatcher: agent
64
+ });
65
+ });
66
+ return {
67
+ fetch: wrapped,
68
+ async dispose() {
69
+ disposed = true;
70
+ const open = [...agents.values()];
71
+ agents.clear();
72
+ await Promise.all(open.map((agent) => agent.close().catch(() => agent.destroy())));
73
+ }
74
+ };
75
+ }
13
76
  // src/config.ts
14
77
  const NS = "llm-opencode";
15
78
  const DEFAULT_API_KEY_ENV = "OPENCODE_API_KEY";
@@ -18,7 +81,7 @@ const DEFAULT_GO_API_BASE = "https://opencode.ai/inference/go";
18
81
  const DEFAULT_GO_ZEN_BASE = "https://opencode.ai/zen/go/v1";
19
82
  const DEFAULT_USAGE_URL = "https://opencode.ai/zen/go/v1/usage";
20
83
  const DEFAULT_AUTH_ENTRY = "opencode-go";
21
- const DEFAULT_USER_AGENT = "dsh-opencode-provider/0.5.20";
84
+ const DEFAULT_USER_AGENT = "dsh-opencode-provider/0.5.22";
22
85
  const DEFAULT_CONTEXT_WINDOW = 262144;
23
86
  const DEFAULT_MAX_TOKENS = 32768;
24
87
  const DEFAULT_GENERATE_MAX_TOKENS = 32768;
@@ -36,6 +99,7 @@ const Config = z.object({
36
99
  openCodeAuthEntry: markVolatile(z.string().default(DEFAULT_AUTH_ENTRY)),
37
100
  requestTimeoutMs: markVolatile(z.number().min(1).max(2147483647).default(3e5)),
38
101
  streamIdleTimeoutMs: markVolatile(z.number().min(1).max(2147483647).default(3e5)),
102
+ proxyUrl: markVolatile(z.string().default("")),
39
103
  visibleModels: markVolatile(z.array(z.string()).default([])),
40
104
  modelVisibility: markVolatile(z.dict(z.boolean()).default({})),
41
105
  filterModelsByPlan: markVolatile(z.boolean().default(true)),
@@ -48,11 +112,12 @@ const Config = z.object({
48
112
  contextWindow: markVolatile(z.number().min(1).max(1e7).step(1)),
49
113
  contextWindowOverrides: markVolatile(z.dict(z.number().min(1).max(1e7).step(1)).default({})),
50
114
  maxOutputTokens: markVolatile(z.number().min(0).max(1e7).step(1)),
115
+ maxOutputTokensOverrides: markVolatile(z.dict(z.number().min(0).max(1e7).step(1)).default({})),
51
116
  accounts: markVolatile(z.array(z.object({
52
117
  label: z.string(),
53
118
  apiKeyEnv: z.string().role("credential-ref"),
54
119
  apiKey: z.string().role("secret")
55
- })).default([])),
120
+ })).max(20).default([])),
56
121
  modelAccountRules: markVolatile(z.array(z.object({
57
122
  pattern: z.string(),
58
123
  account: z.string()
@@ -88,6 +153,12 @@ function numberRecord(value) {
88
153
  for (const [key, entry] of Object.entries(value)) if (typeof entry === "number" && Number.isInteger(entry) && entry > 0) out[key] = entry;
89
154
  return out;
90
155
  }
156
+ function nonNegativeNumberRecord(value) {
157
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return {};
158
+ const out = {};
159
+ for (const [key, entry] of Object.entries(value)) if (typeof entry === "number" && Number.isInteger(entry) && entry >= 0) out[key] = entry;
160
+ return out;
161
+ }
91
162
  function booleanRecord(value) {
92
163
  if (typeof value !== "object" || value === null || Array.isArray(value)) return {};
93
164
  const out = {};
@@ -135,6 +206,7 @@ function resolveConfig(config) {
135
206
  openCodeAuthEntry: typeof config.openCodeAuthEntry === "string" && config.openCodeAuthEntry !== "" ? config.openCodeAuthEntry : DEFAULT_AUTH_ENTRY,
136
207
  requestTimeoutMs: positiveInt(config.requestTimeoutMs, 3e5),
137
208
  streamIdleTimeoutMs: positiveInt(config.streamIdleTimeoutMs, 3e5),
209
+ proxyUrl: normalizeProxyUrl(config.proxyUrl),
138
210
  visibleModels: Array.isArray(config.visibleModels) ? config.visibleModels.filter((id) => typeof id === "string" && id !== "") : [],
139
211
  modelVisibility: booleanRecord(config.modelVisibility),
140
212
  filterModelsByPlan: config.filterModelsByPlan !== false,
@@ -142,6 +214,7 @@ function resolveConfig(config) {
142
214
  ...typeof config.contextWindow === "number" && Number.isInteger(config.contextWindow) && config.contextWindow > 0 ? { contextWindow: config.contextWindow } : {},
143
215
  contextWindowOverrides: numberRecord(config.contextWindowOverrides),
144
216
  ...typeof config.maxOutputTokens === "number" && Number.isInteger(config.maxOutputTokens) && config.maxOutputTokens >= 0 ? { maxOutputTokens: config.maxOutputTokens } : {},
217
+ maxOutputTokensOverrides: nonNegativeNumberRecord(config.maxOutputTokensOverrides),
145
218
  accounts: Array.isArray(config.accounts) ? config.accounts : [],
146
219
  modelAccountRules: modelAccountRuleList(config.modelAccountRules),
147
220
  quotaFallbackModel: typeof config.quotaFallbackModel === "string" ? config.quotaFallbackModel.trim() : "",
@@ -1010,9 +1083,10 @@ function effectiveModel(id, config) {
1010
1083
  /** Effective per-request output default; `0` means "omit the field". */
1011
1084
  function effectiveMaxTokens(meta, config) {
1012
1085
  const ceiling = Math.min(meta.maxTokens, DEFAULT_GENERATE_MAX_TOKENS);
1013
- if (config.maxOutputTokens === void 0) return ceiling;
1014
- if (config.maxOutputTokens === 0) return void 0;
1015
- return Math.min(ceiling, config.maxOutputTokens);
1086
+ const configured = config.maxOutputTokensOverrides[meta.id] ?? config.maxOutputTokens;
1087
+ if (configured === void 0) return ceiling;
1088
+ if (configured === 0) return void 0;
1089
+ return Math.min(ceiling, configured);
1016
1090
  }
1017
1091
  function modelVisible(id, config) {
1018
1092
  const override = config.modelVisibility[id];
@@ -1866,6 +1940,17 @@ async function* sseEvents(body, idleTimeoutMs, signal) {
1866
1940
  reader.releaseLock();
1867
1941
  }
1868
1942
  }
1943
+ // src/transports/tool-call.ts
1944
+ /** Wire fields are untrusted JSON: never forward null/numeric ids or names. */
1945
+ function toolCallId(value, fallback) {
1946
+ return typeof value === "string" && value !== "" ? value : fallback;
1947
+ }
1948
+ function toolCallName(value) {
1949
+ return typeof value === "string" ? value : "";
1950
+ }
1951
+ function toolCallArguments(value) {
1952
+ return typeof value === "string" ? value : "";
1953
+ }
1869
1954
  // src/transports/anthropic.ts
1870
1955
  const EFFORT_LEVELS$1 = /* @__PURE__ */ new Set([
1871
1956
  "low",
@@ -2076,8 +2161,8 @@ async function* parseAnthropicStream(response, idleTimeoutMs, signal) {
2076
2161
  dshIndex,
2077
2162
  kind: "tool-call",
2078
2163
  text: "",
2079
- id: source.id ?? `call_${dshIndex}`,
2080
- name: source.name ?? "",
2164
+ id: toolCallId(source.id, `call_${dshIndex}`),
2165
+ name: toolCallName(source.name),
2081
2166
  args: ""
2082
2167
  };
2083
2168
  sawContent = true;
@@ -2424,7 +2509,7 @@ async function* parseGoogleStream(response, idleTimeoutMs, signal) {
2424
2509
  if (part.functionCall !== void 0) {
2425
2510
  const dshIndex = nextIndex++;
2426
2511
  const id = typeof part.functionCall.id === "string" && part.functionCall.id !== "" ? part.functionCall.id : `call_${toolCounter++}`;
2427
- const name = part.functionCall.name ?? "";
2512
+ const name = toolCallName(part.functionCall.name);
2428
2513
  const args = JSON.stringify(part.functionCall.args ?? {});
2429
2514
  hasTools = true;
2430
2515
  sawContent = true;
@@ -2629,15 +2714,17 @@ async function* parseChatStream(response, idleTimeoutMs, signal) {
2629
2714
  if (entry === void 0) {
2630
2715
  entry = {
2631
2716
  dshIndex: nextIndex++,
2632
- id: toolCall.id ?? `call_${key}`,
2717
+ id: toolCallId(toolCall.id, `call_${key}`),
2633
2718
  name: "",
2634
2719
  args: "",
2635
2720
  started: false
2636
2721
  };
2637
2722
  toolCalls.set(key, entry);
2638
2723
  }
2639
- if (toolCall.id !== void 0 && toolCall.id !== "") entry.id = toolCall.id;
2640
- if (toolCall.function?.name !== void 0 && toolCall.function.name !== "") entry.name = toolCall.function.name;
2724
+ const id = toolCallId(toolCall.id, "");
2725
+ if (id !== "") entry.id = id;
2726
+ const name = toolCallName(toolCall.function?.name);
2727
+ if (name !== "") entry.name = name;
2641
2728
  if (!entry.started) {
2642
2729
  entry.started = true;
2643
2730
  yield {
@@ -2646,7 +2733,7 @@ async function* parseChatStream(response, idleTimeoutMs, signal) {
2646
2733
  blockType: "tool-call"
2647
2734
  };
2648
2735
  }
2649
- const argumentsDelta = toolCall.function?.arguments ?? "";
2736
+ const argumentsDelta = toolCallArguments(toolCall.function?.arguments);
2650
2737
  if (argumentsDelta !== "") entry.args += argumentsDelta;
2651
2738
  sawContent = true;
2652
2739
  yield {
@@ -2756,9 +2843,9 @@ async function* parseCompletion(chunk) {
2756
2843
  sawContent = true;
2757
2844
  }
2758
2845
  for (const call of message?.tool_calls ?? []) {
2759
- const id = ToolCallId(call.id ?? `call_${index}`);
2760
- const name = call.function?.name ?? "";
2761
- const args = call.function?.arguments ?? "{}";
2846
+ const id = ToolCallId(toolCallId(call.id, `call_${index}`));
2847
+ const name = toolCallName(call.function?.name);
2848
+ const args = toolCallArguments(call.function?.arguments) || "{}";
2762
2849
  yield {
2763
2850
  type: "block-start",
2764
2851
  index,
@@ -2973,12 +3060,12 @@ async function* parseResponsesStream(response, idleTimeoutMs, signal) {
2973
3060
  };
2974
3061
  }
2975
3062
  } else if (item?.type === "function_call") {
2976
- const key = item.id ?? item.call_id ?? `tool_${event.output_index ?? tools.size}`;
3063
+ const key = toolCallId(item.id, toolCallId(item.call_id, `tool_${event.output_index ?? tools.size}`));
2977
3064
  const live = {
2978
3065
  dshIndex: nextIndex++,
2979
- id: item.call_id ?? item.id ?? `call_${tools.size}`,
2980
- name: item.name ?? "",
2981
- args: item.arguments ?? ""
3066
+ id: toolCallId(item.call_id, toolCallId(item.id, `call_${tools.size}`)),
3067
+ name: toolCallName(item.name),
3068
+ args: toolCallArguments(item.arguments)
2982
3069
  };
2983
3070
  tools.set(key, live);
2984
3071
  hasTools = true;
@@ -3053,11 +3140,13 @@ async function* parseResponsesStream(response, idleTimeoutMs, signal) {
3053
3140
  if (type === "response.output_item.done") {
3054
3141
  const item = event.item;
3055
3142
  if (item?.type === "function_call") {
3056
- const key = item.id ?? item.call_id;
3057
- const live = key === void 0 ? void 0 : tools.get(key);
3143
+ const id = toolCallId(item.id, toolCallId(item.call_id, ""));
3144
+ const live = id === "" ? void 0 : tools.get(id);
3058
3145
  if (live !== void 0) {
3059
- if (item.name !== void 0) live.name = item.name;
3060
- if (item.arguments !== void 0) live.args = item.arguments;
3146
+ const name = toolCallName(item.name);
3147
+ if (name !== "") live.name = name;
3148
+ const args = toolCallArguments(item.arguments);
3149
+ if (args !== "") live.args = args;
3061
3150
  }
3062
3151
  }
3063
3152
  continue;
@@ -3127,6 +3216,8 @@ var OpenCodeAdapter = class extends LlmAdapter {
3127
3216
  /** key + model → short soft rejection; other models stay usable. */
3128
3217
  modelCooldowns = /* @__PURE__ */ new Map();
3129
3218
  sticky = /* @__PURE__ */ new Map();
3219
+ /** provider → fingerprint of the account that last served a request. */
3220
+ lastGood = /* @__PURE__ */ new Map();
3130
3221
  routing = [];
3131
3222
  warnedTools = false;
3132
3223
  constructor(deps) {
@@ -3227,6 +3318,12 @@ var OpenCodeAdapter = class extends LlmAdapter {
3227
3318
  reason = "sticky";
3228
3319
  }
3229
3320
  }
3321
+ } else if (rule === void 0 && this.lastGood.has(options.provider)) {
3322
+ const preferred = rawKeys.find((entry) => this.keyId(entry.key) === this.lastGood.get(options.provider));
3323
+ if (preferred !== void 0 && rawKeys[0] !== preferred) {
3324
+ keys = [preferred, ...rawKeys.filter((entry) => entry !== preferred)];
3325
+ reason = "promoted";
3326
+ }
3230
3327
  }
3231
3328
  const eligible = fallback ? keys : keys.filter((entry) => !this.coolingDown(entry.key) && !this.modelCoolingDown(entry.key, options.model));
3232
3329
  const ordered = eligible.length > 0 ? eligible : keys;
@@ -3298,6 +3395,7 @@ var OpenCodeAdapter = class extends LlmAdapter {
3298
3395
  }
3299
3396
  logAttempt(true);
3300
3397
  this.rememberSticky(sessionId, options, entry);
3398
+ this.rememberGood(options.provider, entry);
3301
3399
  this.pushTrace({
3302
3400
  at: Date.now(),
3303
3401
  provider: options.provider,
@@ -3377,13 +3475,22 @@ var OpenCodeAdapter = class extends LlmAdapter {
3377
3475
  });
3378
3476
  this.persistSticky();
3379
3477
  }
3478
+ /** Promote the account that actually served the request on the next call. */
3479
+ rememberGood(provider, entry) {
3480
+ this.lastGood.set(provider, this.keyId(entry.key));
3481
+ this.persistSticky();
3482
+ }
3380
3483
  /** Await pending sticky persistence writes (tests and shutdown). */
3381
3484
  async flushSticky() {
3382
3485
  await this.stickyWrites;
3383
3486
  }
3384
3487
  loadSticky() {
3385
3488
  try {
3386
- const entries = JSON.parse(readFileSync(this.stickyPath, "utf8")).sticky;
3489
+ const parsed = JSON.parse(readFileSync(this.stickyPath, "utf8"));
3490
+ if (typeof parsed.lastGood === "object" && parsed.lastGood !== null && !Array.isArray(parsed.lastGood)) {
3491
+ for (const [provider, fingerprint] of Object.entries(parsed.lastGood)) if (typeof fingerprint === "string" && fingerprint !== "") this.lastGood.set(provider, fingerprint);
3492
+ }
3493
+ const entries = parsed.sticky;
3387
3494
  if (typeof entries !== "object" || entries === null || Array.isArray(entries)) return;
3388
3495
  for (const [sessionId, value] of Object.entries(entries)) {
3389
3496
  if (typeof value !== "object" || value === null || Array.isArray(value)) continue;
@@ -3419,7 +3526,10 @@ var OpenCodeAdapter = class extends LlmAdapter {
3419
3526
  }
3420
3527
  persistSticky() {
3421
3528
  this.pruneSticky();
3422
- const payload = JSON.stringify({ sticky: Object.fromEntries(this.sticky) });
3529
+ const payload = JSON.stringify({
3530
+ sticky: Object.fromEntries(this.sticky),
3531
+ lastGood: Object.fromEntries(this.lastGood)
3532
+ });
3423
3533
  this.stickyWrites = this.stickyWrites.then(async () => {
3424
3534
  await mkdir(dirname(this.stickyPath), { recursive: true });
3425
3535
  await writeFile(this.stickyPath, payload, "utf8");
@@ -3588,7 +3698,7 @@ var OpenCodeAdapter = class extends LlmAdapter {
3588
3698
  delayMs = Number.isFinite(seconds) && seconds > 0 ? seconds * 1e3 : 6e4;
3589
3699
  reason = "rate-limit";
3590
3700
  } else if (status === 401 || status === 403) {
3591
- delayMs = 864e5;
3701
+ delayMs = 3e5;
3592
3702
  reason = "invalid-credential";
3593
3703
  }
3594
3704
  this.cooldowns.set(this.keyId(key), {
@@ -4502,8 +4612,25 @@ async function readModelAnalytics(days = 30) {
4502
4612
  if (cached !== void 0 && Date.now() - cached.at < CACHE_TTL_MS) return cached.report;
4503
4613
  const dir = projectionDirectory();
4504
4614
  const sessions = [];
4615
+ let partial = false;
4505
4616
  try {
4506
- const files = (await readdir(dir, { withFileTypes: true })).filter((entry) => entry.isFile() && entry.name.endsWith(".json")).slice(0, MAX_FILES);
4617
+ let files = (await readdir(dir, { withFileTypes: true })).filter((entry) => entry.isFile() && entry.name.endsWith(".json"));
4618
+ if (files.length > MAX_FILES) {
4619
+ files = (await Promise.all(files.map(async (entry) => {
4620
+ try {
4621
+ return {
4622
+ entry,
4623
+ at: (await stat(join(dir, entry.name))).mtimeMs
4624
+ };
4625
+ } catch {
4626
+ return {
4627
+ entry,
4628
+ at: 0
4629
+ };
4630
+ }
4631
+ }))).sort((left, right) => right.at - left.at).slice(0, MAX_FILES).map((item) => item.entry);
4632
+ partial = true;
4633
+ }
4507
4634
  let cursor = 0;
4508
4635
  const workers = Array.from({ length: Math.min(16, files.length) }, async () => {
4509
4636
  while (true) {
@@ -4517,12 +4644,17 @@ async function readModelAnalytics(days = 30) {
4517
4644
  meta.id = entry.name.replace(/\.json$/i, "");
4518
4645
  sessions.push(meta);
4519
4646
  }
4520
- } catch {}
4647
+ } catch {
4648
+ partial = true;
4649
+ }
4521
4650
  }
4522
4651
  });
4523
4652
  await Promise.all(workers);
4524
- } catch {}
4653
+ } catch {
4654
+ partial = true;
4655
+ }
4525
4656
  const report = aggregateSessions(sessions, clamped);
4657
+ report.partial = partial;
4526
4658
  cache.set(clamped, {
4527
4659
  at: Date.now(),
4528
4660
  report
@@ -4540,6 +4672,7 @@ function publicConfig(config) {
4540
4672
  openCodeAuthEntry: config.openCodeAuthEntry,
4541
4673
  requestTimeoutMs: config.requestTimeoutMs,
4542
4674
  streamIdleTimeoutMs: config.streamIdleTimeoutMs,
4675
+ proxyUrl: config.proxyUrl,
4543
4676
  visibleModels: config.visibleModels,
4544
4677
  modelVisibility: config.modelVisibility,
4545
4678
  filterModelsByPlan: config.filterModelsByPlan,
@@ -4547,6 +4680,7 @@ function publicConfig(config) {
4547
4680
  contextWindow: config.contextWindow ?? null,
4548
4681
  contextWindowOverrides: config.contextWindowOverrides,
4549
4682
  maxOutputTokens: config.maxOutputTokens ?? null,
4683
+ maxOutputTokensOverrides: config.maxOutputTokensOverrides,
4550
4684
  accounts: config.accounts.map((account) => ({
4551
4685
  label: typeof account.label === "string" ? account.label : "",
4552
4686
  apiKeyEnv: typeof account.apiKeyEnv === "string" ? account.apiKeyEnv : ""
@@ -4578,6 +4712,7 @@ function cleanAccounts(value) {
4578
4712
  const label = typeof record.label === "string" ? record.label.trim() : "";
4579
4713
  const apiKeyEnv = typeof record.apiKeyEnv === "string" ? record.apiKeyEnv.trim() : "";
4580
4714
  if (label === "" && apiKeyEnv === "") continue;
4715
+ if (out.length >= 20) break;
4581
4716
  out.push({
4582
4717
  label: label === "" ? `account-${out.length + 2}` : label,
4583
4718
  apiKeyEnv
@@ -4616,6 +4751,12 @@ function cleanNumberRecord(value) {
4616
4751
  for (const [key, entry] of Object.entries(value)) if (typeof entry === "number" && Number.isInteger(entry) && entry > 0) out[key] = entry;
4617
4752
  return out;
4618
4753
  }
4754
+ function cleanNonNegativeNumberRecord(value) {
4755
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return {};
4756
+ const out = {};
4757
+ for (const [key, entry] of Object.entries(value)) if (typeof entry === "number" && Number.isInteger(entry) && entry >= 0) out[key] = entry;
4758
+ return out;
4759
+ }
4619
4760
  function cleanTransports(value) {
4620
4761
  if (typeof value !== "object" || value === null || Array.isArray(value)) return {};
4621
4762
  const out = {};
@@ -4725,6 +4866,7 @@ function configOps(patch) {
4725
4866
  if ("openCodeAuthEntry" in patch) set("openCodeAuthEntry", patch.openCodeAuthEntry);
4726
4867
  if ("requestTimeoutMs" in patch) set("requestTimeoutMs", patch.requestTimeoutMs);
4727
4868
  if ("streamIdleTimeoutMs" in patch) set("streamIdleTimeoutMs", patch.streamIdleTimeoutMs);
4869
+ if ("proxyUrl" in patch) set("proxyUrl", normalizeProxyUrl(typeof patch.proxyUrl === "string" ? patch.proxyUrl : ""));
4728
4870
  if ("visibleModels" in patch) set("visibleModels", cleanStringArray(patch.visibleModels));
4729
4871
  if ("modelVisibility" in patch) set("modelVisibility", cleanBooleanRecord(patch.modelVisibility));
4730
4872
  if ("filterModelsByPlan" in patch) set("filterModelsByPlan", patch.filterModelsByPlan === true);
@@ -4732,6 +4874,7 @@ function configOps(patch) {
4732
4874
  if ("contextWindow" in patch) set("contextWindow", patch.contextWindow);
4733
4875
  if ("contextWindowOverrides" in patch) set("contextWindowOverrides", cleanNumberRecord(patch.contextWindowOverrides));
4734
4876
  if ("maxOutputTokens" in patch) set("maxOutputTokens", patch.maxOutputTokens);
4877
+ if ("maxOutputTokensOverrides" in patch) set("maxOutputTokensOverrides", cleanNonNegativeNumberRecord(patch.maxOutputTokensOverrides));
4735
4878
  if ("accounts" in patch) set("accounts", cleanAccounts(patch.accounts));
4736
4879
  if ("modelAccountRules" in patch) set("modelAccountRules", cleanModelAccountRules(patch.modelAccountRules));
4737
4880
  if ("quotaFallbackModel" in patch) set("quotaFallbackModel", patch.quotaFallbackModel);
@@ -5222,6 +5365,15 @@ function buildOpenCodeTuiSection(deps) {
5222
5365
  placeholder: "https://opencode.ai/zen/go/v1/usage",
5223
5366
  parse: setOrClear
5224
5367
  },
5368
+ {
5369
+ path: ["proxyUrl"],
5370
+ group: "connection",
5371
+ kind: "text",
5372
+ label: "Proxy URL",
5373
+ hint: "HTTP, HTTPS or SOCKS5; empty disables it.",
5374
+ format: () => deps.proxyUrl(),
5375
+ parse: setOrClear
5376
+ },
5225
5377
  {
5226
5378
  path: ["visibleModels"],
5227
5379
  group: "models",
@@ -5504,14 +5656,19 @@ const inject = ["llm"];
5504
5656
  function apply(ctx, config) {
5505
5657
  const current = () => resolveConfig(unwrapVolatileConfig(config));
5506
5658
  const keys = createKeyPool(ctx, current);
5507
- const usage = createUsageReader(current, (provider) => keys.resolve(provider));
5659
+ const proxy = createProxyFetch(() => current().proxyUrl);
5660
+ const usage = createUsageReader(current, (provider) => keys.resolve(provider), proxy.fetch);
5508
5661
  const adapter = new OpenCodeAdapter({
5509
5662
  config: current,
5510
5663
  resolveKey: (provider) => keys.resolve(provider),
5511
5664
  resolveKeys: (provider) => keys.resolveAll(provider),
5512
5665
  resolveAttachments: () => ctx.get("attachments"),
5666
+ fetchImpl: proxy.fetch,
5513
5667
  warn: (message) => ctx.logger("llm-opencode").warn(message)
5514
5668
  });
5669
+ ctx.effect(() => () => {
5670
+ proxy.dispose();
5671
+ });
5515
5672
  ctx.llm.registerConfigurableProviders([{
5516
5673
  provider: CONSOLE_PROVIDER,
5517
5674
  displayName: "OpenCode (plugin)",
@@ -5550,6 +5707,7 @@ function apply(ctx, config) {
5550
5707
  label: typeof account.label === "string" && account.label.trim() !== "" ? account.label.trim() : `account-${index + 2}`
5551
5708
  }))],
5552
5709
  visibleModels: () => current().visibleModels,
5710
+ proxyUrl: () => current().proxyUrl,
5553
5711
  activeAccount: () => current().activeAccount,
5554
5712
  filterModelsByPlan: () => current().filterModelsByPlan,
5555
5713
  quotaFallbackModel: () => current().quotaFallbackModel,