pennyrouter 0.3.7 → 0.3.10
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/README.md +38 -0
- package/package.json +1 -1
- package/src/cli.js +27 -7
- package/src/cli.test.js +46 -0
- package/src/harnesses/opencode.js +50 -22
- package/src/harnesses/opencode.test.js +35 -1
package/README.md
CHANGED
|
@@ -23,6 +23,44 @@ npx pennyrouter status
|
|
|
23
23
|
Installation opens a browser and requires login to the PennyRouter account that should own
|
|
24
24
|
the new machine key. CLI installs no longer create anonymous ghost accounts.
|
|
25
25
|
|
|
26
|
+
## Private and enterprise gateways
|
|
27
|
+
|
|
28
|
+
Installing against a self-hosted gateway is its own flow, with no browser login: the gateway
|
|
29
|
+
keeps its own accounts, so the key is minted there.
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
npx pennyrouter install --private-gateway https://gateway.example.internal
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
It asks for the gateway URL (unless given) and your email, and mints a key against that
|
|
36
|
+
gateway. Installing on a second machine with the same email adds a key to the same account.
|
|
37
|
+
Where an administrator issues keys instead, pass `--private-key pr-...` and no email is
|
|
38
|
+
asked for. Self-service minting works only where the operator enabled it
|
|
39
|
+
(`PENNYROUTER_OPEN_PROVISIONING`); otherwise install says so.
|
|
40
|
+
|
|
41
|
+
To hand a whole team one file, put the shared answers in JSON and leave out the per-person
|
|
42
|
+
ones:
|
|
43
|
+
|
|
44
|
+
```json
|
|
45
|
+
{
|
|
46
|
+
"harnesses": ["claude-code", "codex"],
|
|
47
|
+
"gatewayBaseUrl": "https://gateway.example.internal",
|
|
48
|
+
"claudeFunding": ["anthropic-proxy"],
|
|
49
|
+
"proxyBaseUrl": "https://litellm.example.internal",
|
|
50
|
+
"codexFunding": ["openai-proxy"],
|
|
51
|
+
"openaiProxyBaseUrl": "https://litellm.example.internal"
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
npx pennyrouter install --config team-install.json
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Each person is prompted for their email and their proxy key; everything else is prefilled.
|
|
60
|
+
Any field left out of the file is prompted for, and an unattended run that cannot prompt
|
|
61
|
+
refuses by name rather than installing a half-configured harness. Add `email`, `proxyApiKey`
|
|
62
|
+
and `openaiProxyApiKey` to the file to skip the prompts entirely.
|
|
63
|
+
|
|
26
64
|
With no `--harness` flag, the interactive installer shows Claude Code, Codex, and OpenCode. Detected
|
|
27
65
|
tools start checked. The additional legacy integrations remain available through an explicit
|
|
28
66
|
`--harness <id>` selection or `--all`.
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
import { clineHarness } from "./harnesses/cline.js";
|
|
21
21
|
import { codeGptHarness } from "./harnesses/codegpt.js";
|
|
22
22
|
import { codexHarness, detectCodexChatgptAuth } from "./harnesses/codex.js";
|
|
23
|
-
import { opencodeHarness } from "./harnesses/opencode.js";
|
|
23
|
+
import { fetchOpenCodeModels, opencodeHarness } from "./harnesses/opencode.js";
|
|
24
24
|
import { windsurfHarness } from "./harnesses/windsurf.js";
|
|
25
25
|
import { zedHarness } from "./harnesses/zed.js";
|
|
26
26
|
import {
|
|
@@ -285,8 +285,13 @@ export function applyConfigFile(flags) {
|
|
|
285
285
|
for (const option of INSTALL_MANIFEST.funding_groups[group].options) {
|
|
286
286
|
if (!chosen.has(option.id)) continue;
|
|
287
287
|
for (const field of option.fields) {
|
|
288
|
-
|
|
289
|
-
|
|
288
|
+
// Only copy what the file actually carries. Writing "" for an absent field would
|
|
289
|
+
// make it look supplied-but-empty, which reads as "this option was configured" to
|
|
290
|
+
// the provider selection below and skips the prompt that would have filled it --
|
|
291
|
+
// exactly what a shared file with the secrets left out depends on.
|
|
292
|
+
const value = String(config[field.config_key] ?? "").trim();
|
|
293
|
+
if (value && flags[field.config_key] === undefined) {
|
|
294
|
+
flags[field.config_key] = value;
|
|
290
295
|
}
|
|
291
296
|
}
|
|
292
297
|
}
|
|
@@ -751,12 +756,15 @@ export async function install(flags = {}) {
|
|
|
751
756
|
|
|
752
757
|
let record;
|
|
753
758
|
if (!supportsPennyCommand || makeDefault) {
|
|
759
|
+
const opencodeModels = harness.id === "opencode" && flags.privateGatewayFlow
|
|
760
|
+
? await fetchOpenCodeModels(gatewayBaseUrl, claim.api_key, { requireFresh: true })
|
|
761
|
+
: null;
|
|
754
762
|
record = harness.id === "claude-code" && claudeSubscriptionAuth
|
|
755
763
|
? await harness.installStoredToken({
|
|
756
764
|
apiKey: claim.api_key, gatewayBaseUrl, plan, existingRecord,
|
|
757
765
|
})
|
|
758
766
|
: await harness.install({
|
|
759
|
-
apiKey: claim.api_key, gatewayBaseUrl, plan, existingRecord,
|
|
767
|
+
apiKey: claim.api_key, gatewayBaseUrl, plan, existingRecord, models: opencodeModels,
|
|
760
768
|
});
|
|
761
769
|
} else {
|
|
762
770
|
if (hadGlobalRouting) {
|
|
@@ -896,7 +904,7 @@ function mcpTargets(flags) {
|
|
|
896
904
|
async function resolvePrivateGatewayUrl(flags) {
|
|
897
905
|
let raw = (flags.gatewayBaseUrl || "").trim();
|
|
898
906
|
if (!raw) {
|
|
899
|
-
if (flags.yes || !input.isTTY || !output.isTTY) {
|
|
907
|
+
if ((flags.yes && !flags.fromConfig) || !input.isTTY || !output.isTTY) {
|
|
900
908
|
throw new Error(
|
|
901
909
|
"--private-gateway needs the gateway URL in a non-interactive install: "
|
|
902
910
|
+ "`--private-gateway https://gateway.example.com`.",
|
|
@@ -931,7 +939,9 @@ async function obtainPrivateGatewayKey(flags, gatewayBaseUrl) {
|
|
|
931
939
|
|
|
932
940
|
let email = (flags.email || "").trim();
|
|
933
941
|
if (!email) {
|
|
934
|
-
|
|
942
|
+
// A shared --config file names the gateway but not who is installing, so the email is
|
|
943
|
+
// still asked for even though --config implies --yes.
|
|
944
|
+
if ((flags.yes && !flags.fromConfig) || !input.isTTY || !output.isTTY) {
|
|
935
945
|
throw new Error(
|
|
936
946
|
"--private-gateway needs an email in a non-interactive install: `--email you@example.com` "
|
|
937
947
|
+ "(or pass an admin-issued `--private-key pr-...`).",
|
|
@@ -1290,6 +1300,12 @@ async function selectClaudeFundingProviders(flags, selected) {
|
|
|
1290
1300
|
&& (flags.proxyApiKey || process.env.PENNYROUTER_PROXY_API_KEY)) {
|
|
1291
1301
|
supplied.add("anthropic-proxy");
|
|
1292
1302
|
}
|
|
1303
|
+
// A --config file states its providers outright, and that choice stands whether or not the
|
|
1304
|
+
// file also carried their secrets: a file shared with a team deliberately omits those, and
|
|
1305
|
+
// configureUserFunding prompts for what is missing (or refuses, with no TTY to prompt on).
|
|
1306
|
+
// Without this, an option whose secret was left out is silently dropped and the install
|
|
1307
|
+
// completes having configured nothing.
|
|
1308
|
+
for (const id of flags.configClaudeFunding || []) supplied.add(id);
|
|
1293
1309
|
|
|
1294
1310
|
// Explicit flags are the automation selector. Interactive installs always default to the
|
|
1295
1311
|
// subscription alone; environment keys are used if the user checks their provider.
|
|
@@ -1317,6 +1333,7 @@ async function selectCodexFundingProviders(flags, selected) {
|
|
|
1317
1333
|
if ((flags.openaiProxyBaseUrl || process.env.PENNYROUTER_OPENAI_PROXY_BASE_URL)
|
|
1318
1334
|
&& (flags.openaiProxyApiKey || process.env.PENNYROUTER_OPENAI_PROXY_API_KEY)) supplied.add("openai-proxy");
|
|
1319
1335
|
if (flags.openrouterApiKey || process.env.OPENROUTER_API_KEY) supplied.add("openrouter");
|
|
1336
|
+
for (const id of flags.configCodexFunding || []) supplied.add(id);
|
|
1320
1337
|
const hasChatgptLogin = await detectCodexChatgptAuth();
|
|
1321
1338
|
if (hasChatgptLogin) supplied.add("subscription");
|
|
1322
1339
|
|
|
@@ -1393,7 +1410,10 @@ async function configureUserFunding(flags, selected, claudeAnthropicAuth,
|
|
|
1393
1410
|
claudeProviders = new Set(), codexProviders = new Set()) {
|
|
1394
1411
|
const wantsClaude = selected.some((harness) => harness.id === "claude-code");
|
|
1395
1412
|
const wantsCodex = selected.some((harness) => harness.id === "codex");
|
|
1396
|
-
|
|
1413
|
+
// --config drives --yes to skip the question screens, but a file meant to be shared with a
|
|
1414
|
+
// team deliberately carries no secrets: the fields it leaves out are still asked for here,
|
|
1415
|
+
// the same way the subscription sign-in below stays interactive under --config.
|
|
1416
|
+
const interactive = (!flags.yes || flags.fromConfig) && input.isTTY && output.isTTY;
|
|
1397
1417
|
const credentials = [];
|
|
1398
1418
|
|
|
1399
1419
|
let anthropicApiKey = String(
|
package/src/cli.test.js
CHANGED
|
@@ -305,3 +305,49 @@ test("--private-gateway does not consume a following flag as its URL", async ()
|
|
|
305
305
|
/needs the gateway URL/,
|
|
306
306
|
);
|
|
307
307
|
});
|
|
308
|
+
|
|
309
|
+
// A --config file meant to be shared with a team carries no secrets and does not know who is
|
|
310
|
+
// installing. --config implies --yes, so the omitted fields are prompted for when there is a
|
|
311
|
+
// TTY; with no TTY the install must REFUSE. It previously completed here having configured no
|
|
312
|
+
// credential at all, because a funding option whose secret was absent was silently dropped.
|
|
313
|
+
test("a shared --config without its secret refuses rather than installing nothing", async () => {
|
|
314
|
+
// Isolate the install state: against a real one where this harness is already installed
|
|
315
|
+
// with this funding, install takes its "nothing changed" refresh path and never reaches
|
|
316
|
+
// credential collection.
|
|
317
|
+
const stateDir = mkdtempSync(join(tmpdir(), "pennyrouter-state-"));
|
|
318
|
+
const priorState = process.env.XDG_STATE_HOME;
|
|
319
|
+
process.env.XDG_STATE_HOME = stateDir;
|
|
320
|
+
try {
|
|
321
|
+
await assert.rejects(
|
|
322
|
+
() => withConfigFile({
|
|
323
|
+
harnesses: ["claude-code"],
|
|
324
|
+
gatewayBaseUrl: "https://gateway.example.com",
|
|
325
|
+
claudeFunding: ["anthropic-proxy"],
|
|
326
|
+
proxyBaseUrl: "https://proxy.example.com",
|
|
327
|
+
privateKey: "pr-test",
|
|
328
|
+
}, (path) => captureLogs(() => main(["install", "--config", path]))),
|
|
329
|
+
/--proxy-base-url and --proxy-api-key were not both provided/,
|
|
330
|
+
);
|
|
331
|
+
} finally {
|
|
332
|
+
if (priorState === undefined) delete process.env.XDG_STATE_HOME;
|
|
333
|
+
else process.env.XDG_STATE_HOME = priorState;
|
|
334
|
+
rmSync(stateDir, { recursive: true, force: true });
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
// The config names its providers; that choice stands even when the file omits their secrets.
|
|
339
|
+
test("--config funding choices survive a missing secret", async () => {
|
|
340
|
+
await withConfigFile({
|
|
341
|
+
harnesses: ["claude-code"],
|
|
342
|
+
claudeFunding: ["anthropic-proxy"],
|
|
343
|
+
proxyBaseUrl: "https://proxy.example.com",
|
|
344
|
+
}, (path) => {
|
|
345
|
+
const flags = { configPath: path, args: [] };
|
|
346
|
+
applyConfigFile(flags);
|
|
347
|
+
assert.deepEqual([...flags.configClaudeFunding], ["anthropic-proxy"]);
|
|
348
|
+
// Absent fields stay undefined so the prompt path can fill them; "" would read as
|
|
349
|
+
// supplied-but-empty and skip it.
|
|
350
|
+
assert.equal(flags.proxyApiKey, undefined);
|
|
351
|
+
assert.equal(flags.proxyBaseUrl, "https://proxy.example.com");
|
|
352
|
+
});
|
|
353
|
+
});
|
|
@@ -8,14 +8,17 @@ import {
|
|
|
8
8
|
readText,
|
|
9
9
|
writeJsonFile,
|
|
10
10
|
} from "../state.js";
|
|
11
|
+
import { unlink } from "node:fs/promises";
|
|
11
12
|
const JSONC_PATH = "~/.config/opencode/opencode.jsonc";
|
|
12
13
|
const JSON_PATH = "~/.config/opencode/opencode.json";
|
|
13
14
|
const MODEL_STATE_PATH = "~/.local/state/opencode/model.json";
|
|
15
|
+
const MODEL_CACHE_PATH = "~/.cache/opencode/models.json";
|
|
14
16
|
export const OPENCODE_MODEL_CHOICES = [
|
|
15
|
-
{ id: "pennyrouter/penny-core:premium", name: "Penny Core" },
|
|
16
|
-
{ id: "pennyrouter/penny-speed:standard", name: "Penny Speed" },
|
|
17
|
-
{ id: "pennyrouter/penny-power:premium", name: "Penny Power" },
|
|
18
17
|
{ id: "pennyrouter/auto", name: "PennyRouter Auto" },
|
|
18
|
+
{ id: "claude-haiku-4-5-20251001", name: "Haiku 4.5" },
|
|
19
|
+
{ id: "claude-sonnet-5", name: "Sonnet 5" },
|
|
20
|
+
{ id: "claude-opus-5", name: "Opus 5" },
|
|
21
|
+
{ id: "claude-fable-5", name: "Fable 5" },
|
|
19
22
|
{ id: "claude-penny-deepseek-v4-flash", name: "DeepSeek V4 Flash" },
|
|
20
23
|
{ id: "claude-penny-glm-5-3", name: "GLM 5.3" },
|
|
21
24
|
{ id: "claude-penny-gpt-5-6-luna", name: "GPT-5.6 Luna" },
|
|
@@ -25,6 +28,16 @@ export const OPENCODE_MODEL_CHOICES = [
|
|
|
25
28
|
{ id: "claude-penny-minimax-m3", name: "MiniMax M3" },
|
|
26
29
|
{ id: "claude-penny-nemotron-ultra-free", name: "Nemotron 3 Ultra (free)" },
|
|
27
30
|
{ id: "claude-penny-solar-pro4", name: "Solar Pro 4" },
|
|
31
|
+
{ id: "gpt-5.6-sol", name: "GPT 5.6 Sol (PennyRouter)" },
|
|
32
|
+
{ id: "gpt-5.6-terra", name: "GPT 5.6 Terra (PennyRouter)" },
|
|
33
|
+
{ id: "gpt-5.6-luna", name: "GPT 5.6 Luna (PennyRouter)" },
|
|
34
|
+
{ id: "gpt-5.5", name: "GPT 5.5 (PennyRouter)" },
|
|
35
|
+
{ id: "gpt-5.4", name: "GPT 5.4 (PennyRouter)" },
|
|
36
|
+
{ id: "gpt-5.4-mini", name: "GPT 5.4 Mini (PennyRouter)" },
|
|
37
|
+
{ id: "gpt-penny-auto", name: "PennyRouter Auto (GPT)" },
|
|
38
|
+
{ id: "gpt-penny-deepseek", name: "DeepSeek V4 Flash (PennyRouter)" },
|
|
39
|
+
{ id: "gpt-penny-glm", name: "GLM 5.3 (PennyRouter)" },
|
|
40
|
+
{ id: "gpt-penny-grok", name: "Grok 4.6 (PennyRouter)" },
|
|
28
41
|
];
|
|
29
42
|
|
|
30
43
|
export const opencodeHarness = {
|
|
@@ -42,8 +55,8 @@ export const opencodeHarness = {
|
|
|
42
55
|
summary: [
|
|
43
56
|
`write ${configPath}`,
|
|
44
57
|
"merge PennyRouter provider without replacing existing providers",
|
|
45
|
-
"publish
|
|
46
|
-
`reset ${MODEL_STATE_PATH} so opencode rediscovers configured models`,
|
|
58
|
+
"publish PennyRouter Auto and the available Claude/GPT model pins",
|
|
59
|
+
`reset ${MODEL_STATE_PATH} and ${MODEL_CACHE_PATH} so opencode rediscovers configured models`,
|
|
47
60
|
],
|
|
48
61
|
};
|
|
49
62
|
},
|
|
@@ -90,6 +103,10 @@ export const opencodeHarness = {
|
|
|
90
103
|
}
|
|
91
104
|
}
|
|
92
105
|
await writeJsonFile(MODEL_STATE_PATH, model);
|
|
106
|
+
// OpenCode keeps a separate generated registry cache. Clearing it is necessary when a
|
|
107
|
+
// provider's model map changes; otherwise the picker can continue showing retired models
|
|
108
|
+
// even though opencode.json has been rewritten correctly.
|
|
109
|
+
await removeGeneratedModelCache();
|
|
93
110
|
|
|
94
111
|
return {
|
|
95
112
|
harness: "opencode",
|
|
@@ -129,6 +146,14 @@ export const opencodeHarness = {
|
|
|
129
146
|
},
|
|
130
147
|
};
|
|
131
148
|
|
|
149
|
+
async function removeGeneratedModelCache() {
|
|
150
|
+
try {
|
|
151
|
+
await unlink(expandHome(MODEL_CACHE_PATH));
|
|
152
|
+
} catch (error) {
|
|
153
|
+
if (error.code !== "ENOENT") throw error;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
132
157
|
async function readConfig(path) {
|
|
133
158
|
const text = await readText(path, "");
|
|
134
159
|
if (!text.trim()) return {};
|
|
@@ -183,27 +208,30 @@ export function opencodeModelId(modelId) {
|
|
|
183
208
|
return modelId;
|
|
184
209
|
}
|
|
185
210
|
|
|
186
|
-
export async function fetchOpenCodeModels(gatewayBaseUrl) {
|
|
211
|
+
export async function fetchOpenCodeModels(gatewayBaseUrl, apiKey, { requireFresh = false } = {}) {
|
|
187
212
|
try {
|
|
188
|
-
const
|
|
213
|
+
const headers = apiKey
|
|
214
|
+
? { Authorization: `Bearer ${apiKey}`, "x-api-key": apiKey }
|
|
215
|
+
: undefined;
|
|
216
|
+
const response = await fetch(`${String(gatewayBaseUrl).replace(/\/+$/, "")}/v1/models`, { headers });
|
|
189
217
|
if (!response.ok) throw new Error(`model catalog returned ${response.status}`);
|
|
190
218
|
const payload = await response.json();
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
...(model?.description ? { description: model.description } : {}),
|
|
203
|
-
};
|
|
204
|
-
});
|
|
219
|
+
const models = Array.isArray(payload?.data)
|
|
220
|
+
? payload.data
|
|
221
|
+
.filter((model) => typeof model?.id === "string")
|
|
222
|
+
// Bundles are a legacy OpenCode surface. Auto is the one intentional exception.
|
|
223
|
+
.filter((model) => !model.id.startsWith("pennyrouter/") || model.id === "pennyrouter/auto")
|
|
224
|
+
.map((model) => ({
|
|
225
|
+
id: model.id,
|
|
226
|
+
name: model.display_name || model.name || openCodeModelName(model.id),
|
|
227
|
+
...(model.description ? { description: model.description } : {}),
|
|
228
|
+
}))
|
|
229
|
+
: [];
|
|
205
230
|
if (models.length > 0) return models;
|
|
206
|
-
} catch {
|
|
231
|
+
} catch (error) {
|
|
232
|
+
if (requireFresh) throw new Error(`Could not fetch the private gateway model catalog: ${error.message}`);
|
|
233
|
+
}
|
|
234
|
+
if (requireFresh) throw new Error("The private gateway returned no usable OpenCode models.");
|
|
207
235
|
return OPENCODE_MODEL_CHOICES;
|
|
208
236
|
}
|
|
209
237
|
|
|
@@ -3,13 +3,19 @@ import fs from "node:fs";
|
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
fetchOpenCodeModels,
|
|
8
|
+
opencodeHarness,
|
|
9
|
+
opencodeModelId,
|
|
10
|
+
OPENCODE_MODEL_CHOICES,
|
|
11
|
+
} from "./opencode.js";
|
|
7
12
|
|
|
8
13
|
const ENV_KEYS = ["HOME", "XDG_STATE_HOME"];
|
|
9
14
|
|
|
10
15
|
async function main() {
|
|
11
16
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "pr-opencode-test-"));
|
|
12
17
|
const previous = Object.fromEntries(ENV_KEYS.map((key) => [key, process.env[key]]));
|
|
18
|
+
const previousFetch = globalThis.fetch;
|
|
13
19
|
const home = path.join(root, "home");
|
|
14
20
|
const configPath = path.join(home, ".config", "opencode", "opencode.jsonc");
|
|
15
21
|
const modelStatePath = path.join(home, ".local", "state", "opencode", "model.json");
|
|
@@ -35,6 +41,33 @@ async function main() {
|
|
|
35
41
|
try {
|
|
36
42
|
assert.equal(opencodeModelId("pennyrouter/auto"), "pennyrouter/auto");
|
|
37
43
|
assert.equal(opencodeModelId("custom/model-a"), "custom/model-a");
|
|
44
|
+
|
|
45
|
+
let request;
|
|
46
|
+
globalThis.fetch = async (url, options) => {
|
|
47
|
+
request = { url, options };
|
|
48
|
+
return {
|
|
49
|
+
ok: true,
|
|
50
|
+
async json() {
|
|
51
|
+
return { data: [
|
|
52
|
+
{ id: "pennyrouter/auto", display_name: "Auto from private gateway" },
|
|
53
|
+
{ id: "pennyrouter/penny-core:premium", display_name: "Legacy bundle" },
|
|
54
|
+
{ id: "claude-sonnet-5", display_name: "Private Sonnet", description: "Fresh" },
|
|
55
|
+
] };
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
};
|
|
59
|
+
assert.deepEqual(await fetchOpenCodeModels(
|
|
60
|
+
"https://private.example/", "pr-test", { requireFresh: true },
|
|
61
|
+
), [
|
|
62
|
+
{ id: "pennyrouter/auto", name: "Auto from private gateway" },
|
|
63
|
+
{ id: "claude-sonnet-5", name: "Private Sonnet", description: "Fresh" },
|
|
64
|
+
]);
|
|
65
|
+
assert.equal(request.url, "https://private.example/v1/models");
|
|
66
|
+
assert.deepEqual(request.options.headers, {
|
|
67
|
+
Authorization: "Bearer pr-test",
|
|
68
|
+
"x-api-key": "pr-test",
|
|
69
|
+
});
|
|
70
|
+
|
|
38
71
|
const plan = await opencodeHarness.planInstall();
|
|
39
72
|
const record = await opencodeHarness.install({
|
|
40
73
|
apiKey: "pr-test",
|
|
@@ -63,6 +96,7 @@ async function main() {
|
|
|
63
96
|
if (previous[key] === undefined) delete process.env[key];
|
|
64
97
|
else process.env[key] = previous[key];
|
|
65
98
|
}
|
|
99
|
+
globalThis.fetch = previousFetch;
|
|
66
100
|
fs.rmSync(root, { recursive: true, force: true });
|
|
67
101
|
}
|
|
68
102
|
|