@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/CHANGELOG.md +14 -0
- package/README.md +7 -5
- package/README.ru.md +6 -4
- package/README.zh.md +6 -4
- package/lib/client.js +211 -97
- package/lib/index.d.ts +13 -1
- package/lib/index.js +188 -30
- package/package.json +128 -125
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.
|
|
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
|
-
|
|
1014
|
-
if (
|
|
1015
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
2640
|
-
if (
|
|
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
|
|
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
|
|
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
|
|
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
|
|
3057
|
-
const live =
|
|
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
|
-
|
|
3060
|
-
if (
|
|
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
|
|
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({
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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,
|