@goodandready/dsh-opencode-provider 0.5.21 → 0.5.23
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 +15 -0
- package/README.md +10 -6
- package/README.ru.md +6 -4
- package/README.zh.md +6 -4
- package/lib/client.js +205 -97
- package/lib/index.d.ts +11 -1
- package/lib/index.js +163 -27
- package/package.json +129 -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;
|
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.23";
|
|
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), {
|
|
@@ -4562,6 +4672,7 @@ function publicConfig(config) {
|
|
|
4562
4672
|
openCodeAuthEntry: config.openCodeAuthEntry,
|
|
4563
4673
|
requestTimeoutMs: config.requestTimeoutMs,
|
|
4564
4674
|
streamIdleTimeoutMs: config.streamIdleTimeoutMs,
|
|
4675
|
+
proxyUrl: config.proxyUrl,
|
|
4565
4676
|
visibleModels: config.visibleModels,
|
|
4566
4677
|
modelVisibility: config.modelVisibility,
|
|
4567
4678
|
filterModelsByPlan: config.filterModelsByPlan,
|
|
@@ -4569,6 +4680,7 @@ function publicConfig(config) {
|
|
|
4569
4680
|
contextWindow: config.contextWindow ?? null,
|
|
4570
4681
|
contextWindowOverrides: config.contextWindowOverrides,
|
|
4571
4682
|
maxOutputTokens: config.maxOutputTokens ?? null,
|
|
4683
|
+
maxOutputTokensOverrides: config.maxOutputTokensOverrides,
|
|
4572
4684
|
accounts: config.accounts.map((account) => ({
|
|
4573
4685
|
label: typeof account.label === "string" ? account.label : "",
|
|
4574
4686
|
apiKeyEnv: typeof account.apiKeyEnv === "string" ? account.apiKeyEnv : ""
|
|
@@ -4600,6 +4712,7 @@ function cleanAccounts(value) {
|
|
|
4600
4712
|
const label = typeof record.label === "string" ? record.label.trim() : "";
|
|
4601
4713
|
const apiKeyEnv = typeof record.apiKeyEnv === "string" ? record.apiKeyEnv.trim() : "";
|
|
4602
4714
|
if (label === "" && apiKeyEnv === "") continue;
|
|
4715
|
+
if (out.length >= 20) break;
|
|
4603
4716
|
out.push({
|
|
4604
4717
|
label: label === "" ? `account-${out.length + 2}` : label,
|
|
4605
4718
|
apiKeyEnv
|
|
@@ -4638,6 +4751,12 @@ function cleanNumberRecord(value) {
|
|
|
4638
4751
|
for (const [key, entry] of Object.entries(value)) if (typeof entry === "number" && Number.isInteger(entry) && entry > 0) out[key] = entry;
|
|
4639
4752
|
return out;
|
|
4640
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
|
+
}
|
|
4641
4760
|
function cleanTransports(value) {
|
|
4642
4761
|
if (typeof value !== "object" || value === null || Array.isArray(value)) return {};
|
|
4643
4762
|
const out = {};
|
|
@@ -4747,6 +4866,7 @@ function configOps(patch) {
|
|
|
4747
4866
|
if ("openCodeAuthEntry" in patch) set("openCodeAuthEntry", patch.openCodeAuthEntry);
|
|
4748
4867
|
if ("requestTimeoutMs" in patch) set("requestTimeoutMs", patch.requestTimeoutMs);
|
|
4749
4868
|
if ("streamIdleTimeoutMs" in patch) set("streamIdleTimeoutMs", patch.streamIdleTimeoutMs);
|
|
4869
|
+
if ("proxyUrl" in patch) set("proxyUrl", normalizeProxyUrl(typeof patch.proxyUrl === "string" ? patch.proxyUrl : ""));
|
|
4750
4870
|
if ("visibleModels" in patch) set("visibleModels", cleanStringArray(patch.visibleModels));
|
|
4751
4871
|
if ("modelVisibility" in patch) set("modelVisibility", cleanBooleanRecord(patch.modelVisibility));
|
|
4752
4872
|
if ("filterModelsByPlan" in patch) set("filterModelsByPlan", patch.filterModelsByPlan === true);
|
|
@@ -4754,6 +4874,7 @@ function configOps(patch) {
|
|
|
4754
4874
|
if ("contextWindow" in patch) set("contextWindow", patch.contextWindow);
|
|
4755
4875
|
if ("contextWindowOverrides" in patch) set("contextWindowOverrides", cleanNumberRecord(patch.contextWindowOverrides));
|
|
4756
4876
|
if ("maxOutputTokens" in patch) set("maxOutputTokens", patch.maxOutputTokens);
|
|
4877
|
+
if ("maxOutputTokensOverrides" in patch) set("maxOutputTokensOverrides", cleanNonNegativeNumberRecord(patch.maxOutputTokensOverrides));
|
|
4757
4878
|
if ("accounts" in patch) set("accounts", cleanAccounts(patch.accounts));
|
|
4758
4879
|
if ("modelAccountRules" in patch) set("modelAccountRules", cleanModelAccountRules(patch.modelAccountRules));
|
|
4759
4880
|
if ("quotaFallbackModel" in patch) set("quotaFallbackModel", patch.quotaFallbackModel);
|
|
@@ -5244,6 +5365,15 @@ function buildOpenCodeTuiSection(deps) {
|
|
|
5244
5365
|
placeholder: "https://opencode.ai/zen/go/v1/usage",
|
|
5245
5366
|
parse: setOrClear
|
|
5246
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
|
+
},
|
|
5247
5377
|
{
|
|
5248
5378
|
path: ["visibleModels"],
|
|
5249
5379
|
group: "models",
|
|
@@ -5526,14 +5656,19 @@ const inject = ["llm"];
|
|
|
5526
5656
|
function apply(ctx, config) {
|
|
5527
5657
|
const current = () => resolveConfig(unwrapVolatileConfig(config));
|
|
5528
5658
|
const keys = createKeyPool(ctx, current);
|
|
5529
|
-
const
|
|
5659
|
+
const proxy = createProxyFetch(() => current().proxyUrl);
|
|
5660
|
+
const usage = createUsageReader(current, (provider) => keys.resolve(provider), proxy.fetch);
|
|
5530
5661
|
const adapter = new OpenCodeAdapter({
|
|
5531
5662
|
config: current,
|
|
5532
5663
|
resolveKey: (provider) => keys.resolve(provider),
|
|
5533
5664
|
resolveKeys: (provider) => keys.resolveAll(provider),
|
|
5534
5665
|
resolveAttachments: () => ctx.get("attachments"),
|
|
5666
|
+
fetchImpl: proxy.fetch,
|
|
5535
5667
|
warn: (message) => ctx.logger("llm-opencode").warn(message)
|
|
5536
5668
|
});
|
|
5669
|
+
ctx.effect(() => () => {
|
|
5670
|
+
proxy.dispose();
|
|
5671
|
+
});
|
|
5537
5672
|
ctx.llm.registerConfigurableProviders([{
|
|
5538
5673
|
provider: CONSOLE_PROVIDER,
|
|
5539
5674
|
displayName: "OpenCode (plugin)",
|
|
@@ -5572,6 +5707,7 @@ function apply(ctx, config) {
|
|
|
5572
5707
|
label: typeof account.label === "string" && account.label.trim() !== "" ? account.label.trim() : `account-${index + 2}`
|
|
5573
5708
|
}))],
|
|
5574
5709
|
visibleModels: () => current().visibleModels,
|
|
5710
|
+
proxyUrl: () => current().proxyUrl,
|
|
5575
5711
|
activeAccount: () => current().activeAccount,
|
|
5576
5712
|
filterModelsByPlan: () => current().filterModelsByPlan,
|
|
5577
5713
|
quotaFallbackModel: () => current().quotaFallbackModel,
|