feat: finish donor-led custom endpoint integration
This commit is contained in:
@@ -26,7 +26,6 @@ import {
|
||||
buildOpenAiCompatibleProvider,
|
||||
discoverModels,
|
||||
mergeManualModels,
|
||||
syncCustomProvider,
|
||||
} from "../src/server/custom-provider.ts";
|
||||
import { piModels } from "../src/agent/models.ts";
|
||||
import { getSupportedThinkingLevels } from "@earendil-works/pi-ai";
|
||||
@@ -92,13 +91,13 @@ describe("sanitizeUserSettings — customProvider", () => {
|
||||
|
||||
// ─── per-model pins: the read side + THE prune law ───────────────────────────
|
||||
describe("sanitizeUserSettings / sanitizeCustomModelOverrides — pins", () => {
|
||||
it("keeps well-formed pins, clamps token counts up to the floor, keeps reasoning:false", () => {
|
||||
it("keeps all positive integer pins and reasoning:false", () => {
|
||||
const out = sanitizeUserSettings({
|
||||
customProvider: {
|
||||
baseUrl: "https://h/v1",
|
||||
overrides: {
|
||||
llama: { context: 131072, maxTokens: 8192, reasoning: true },
|
||||
qwen: { context: 10 }, // under the floor → clamped up (clamp-never-reject on read)
|
||||
qwen: { context: 10 }, // Small positive budgets are preserved.
|
||||
quiet: { reasoning: false }, // a real pin, not an absent one
|
||||
junk: { context: "big", maxTokens: -1, reasoning: "yes" }, // every field junk
|
||||
" ": { context: 4096 }, // no usable id
|
||||
@@ -108,7 +107,7 @@ describe("sanitizeUserSettings / sanitizeCustomModelOverrides — pins", () => {
|
||||
});
|
||||
assert.deepEqual(out?.customProvider?.overrides, {
|
||||
llama: { context: 131072, maxTokens: 8192, reasoning: true },
|
||||
qwen: { context: CUSTOM_MODEL_PIN_FLOOR },
|
||||
qwen: { context: 10 },
|
||||
quiet: { reasoning: false },
|
||||
});
|
||||
});
|
||||
@@ -281,11 +280,11 @@ describe("buildOpenAiCompatibleProvider / syncCustomProvider", () => {
|
||||
assert.deepEqual(getSupportedThinkingLevels(plain!), ["off"]);
|
||||
});
|
||||
|
||||
it("registers on a usable sync and removes when the base URL clears", () => {
|
||||
syncCustomProvider({ baseUrl: "http://h/v1", models: [{ id: "live" }] });
|
||||
it("registers the native provider in the shared collection", () => {
|
||||
piModels().setProvider(buildOpenAiCompatibleProvider({ baseUrl: "http://h/v1", models: [{ id: "live" }] }));
|
||||
assert.ok(piModels().getProvider(CUSTOM_PROVIDER.id), "provider registered");
|
||||
assert.ok(piModels().getModel(CUSTOM_PROVIDER.id, "live"), "model resolvable");
|
||||
syncCustomProvider({ baseUrl: "", models: [{ id: "live" }] });
|
||||
piModels().deleteProvider(CUSTOM_PROVIDER.id);
|
||||
assert.equal(piModels().getProvider(CUSTOM_PROVIDER.id), undefined, "provider removed");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { after, beforeEach, test, mock } from "node:test";
|
||||
|
||||
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "lh-custom-boundaries-"));
|
||||
const originalEnv = process.env;
|
||||
const originalFetch = globalThis.fetch;
|
||||
process.env = { ...(originalEnv.NODE_TEST_CONTEXT ? { NODE_TEST_CONTEXT: originalEnv.NODE_TEST_CONTEXT } : {}), ...(originalEnv.NODE_OPTIONS ? { NODE_OPTIONS: originalEnv.NODE_OPTIONS } : {}), NODE_ENV: "test", HOME: scratch, LH_DATA_DIR: scratch, LH_AUTH_FILE: path.join(scratch, "auth.json"), LH_IGNORE_ENV_KEYS: "1" };
|
||||
const route = await import("../app/api/custom-provider/route.ts");
|
||||
const keys = await import("../src/server/keys.ts");
|
||||
const { authEnvRecord } = await import("../src/core/auth-env.ts");
|
||||
const { CUSTOM_PROVIDER } = await import("../src/core/auth-keys.ts");
|
||||
const { authorizeFixture } = await import("./helpers/custom-provider-auth.ts");
|
||||
const { readCustomAuth } = await import("../src/store/auth.ts");
|
||||
const { persistCustomAuth } = await import("../src/server/custom-provider-auth.ts");
|
||||
const settingsFile = path.join(scratch, "settings.json");
|
||||
const seed = (cp: import("../src/core/settings.ts").CustomProviderSettings = { baseUrl: "http://old/v1", authMode: "keyless", models: [{ id: "m" }] }, key?: string) => fs.writeFileSync(settingsFile, JSON.stringify({ version: 1, customProvider: authorizeFixture(cp, key) }));
|
||||
const setFileKey = (key: string) => { const auth = readCustomAuth(); persistCustomAuth({ ...auth.binding!, fileKeyBaseUrl: auth.binding!.baseUrl }, key); };
|
||||
const current = () => JSON.parse(fs.readFileSync(settingsFile, "utf8"));
|
||||
const get = (inspect = false) => route.GET(new Request(`http://localhost/api/custom-provider${inspect ? "?authMode=apiKey" : ""}`));
|
||||
function mutate(body: unknown, method = "POST", headers: Record<string, string> = {}) {
|
||||
return route.POST(new Request("http://localhost/api/custom-provider", { method, headers: { origin: "http://localhost", "content-type": "application/json", "if-match": get().headers.get("etag")!, ...headers }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) }));
|
||||
}
|
||||
let requests: { url: string; authorization: string | null }[];
|
||||
beforeEach(() => {
|
||||
mock.restoreAll();
|
||||
process.env.LH_IGNORE_ENV_KEYS = "1";
|
||||
delete process.env.LH_DESKTOP_SERVER; delete process.env.LH_DESKTOP_API_SECRET;
|
||||
fs.writeFileSync(path.join(scratch, "auth.json"), "{}"); seed();
|
||||
requests = [];
|
||||
globalThis.fetch = async (url, options) => {
|
||||
requests.push({ url: String(url), authorization: new Headers(options?.headers).get("authorization") });
|
||||
assert.equal(options?.redirect, "manual");
|
||||
assert.ok(options?.signal instanceof AbortSignal);
|
||||
return Response.json({ data: [{ id: "m" }] });
|
||||
};
|
||||
});
|
||||
after(() => { mock.restoreAll(); process.env = originalEnv; globalThis.fetch = originalFetch; fs.rmSync(scratch, { recursive: true, force: true }); });
|
||||
|
||||
test("keyless probe never forwards a stored key, and clearing removes authentication", async () => {
|
||||
setFileKey("private-old");
|
||||
assert.equal((await mutate({ baseUrl: "http://new/v1", authMode: "keyless", key: "do-not-use-this" })).status, 200);
|
||||
assert.equal(requests[0]?.authorization, null);
|
||||
assert.equal(keys.keyValue(CUSTOM_PROVIDER.envVar), undefined);
|
||||
assert.equal(current().customProvider.authMode, "keyless");
|
||||
});
|
||||
test("new keys have no arbitrary minimum; the exact probed key is stored", async () => {
|
||||
assert.equal((await mutate({ baseUrl: "http://new/v1", authMode: "apiKey", key: " k " })).status, 200);
|
||||
assert.equal(requests[0]?.authorization, "Bearer k");
|
||||
assert.equal(keys.keyValue(CUSTOM_PROVIDER.envVar), "k");
|
||||
const body = await get().text(); assert.ok(!body.includes("Bearer"));
|
||||
});
|
||||
test("changing destination cannot silently reuse an existing credential", async () => {
|
||||
setFileKey("private-old");
|
||||
const payload = { baseUrl: "http://new/v1", authMode: "apiKey" };
|
||||
assert.equal((await mutate(payload)).status, 400); assert.equal(requests.length, 0);
|
||||
assert.equal((await mutate({ ...payload, reuseKey: true })).status, 200);
|
||||
assert.equal(requests[0]?.authorization, "Bearer private-old");
|
||||
});
|
||||
test("same-URL unbound development key needs existing explicit reuse consent", async () => {
|
||||
seed({ baseUrl: "http://old/v1", authMode: "apiKey", models: [{ id: "m" }] }, "legacy-key");
|
||||
const { CUSTOM_AUTH_BINDING } = await import("../src/core/custom-provider-auth.ts");
|
||||
const file = path.join(scratch, "auth.json"); const map = JSON.parse(fs.readFileSync(file, "utf8")); delete map[CUSTOM_AUTH_BINDING]; fs.writeFileSync(file, JSON.stringify(map));
|
||||
assert.equal((await get().json()).keyReuseRequired, true);
|
||||
assert.equal((await mutate({ baseUrl: "http://old/v1", authMode: "apiKey" })).status, 400); assert.equal(requests.length, 0);
|
||||
assert.equal((await mutate({ baseUrl: "http://old/v1", authMode: "apiKey", reuseKey: true })).status, 200);
|
||||
assert.equal(requests[0]?.authorization, "Bearer legacy-key"); assert.equal((await get().json()).keyReuseRequired, false);
|
||||
});
|
||||
test("generic custom/reserved key writes cannot bypass the atomic binding seam", async () => {
|
||||
const ordinary = await import("../app/api/keys/[id]/route.ts");
|
||||
const { CUSTOM_AUTH_BINDING } = await import("../src/core/custom-provider-auth.ts");
|
||||
const { authStore } = await import("../src/store/auth.ts");
|
||||
const before = fs.readFileSync(path.join(scratch, "auth.json"), "utf8");
|
||||
for (const id of [CUSTOM_PROVIDER.envVar, CUSTOM_AUTH_BINDING]) {
|
||||
const context = { params: Promise.resolve({ id }) };
|
||||
assert.equal((await ordinary.PUT(new Request("http://localhost", { method: "PUT", body: JSON.stringify({ value: "bypass" }) }), context)).status, 404);
|
||||
assert.equal((await ordinary.DELETE(new Request("http://localhost", { method: "DELETE" }), context)).status, 404);
|
||||
assert.equal(keys.saveKey(id, "bypass").ok, false); assert.throws(() => keys.deleteKey(id));
|
||||
assert.throws(() => authStore.setAuthVar(id, "bypass")); assert.throws(() => authStore.deleteAuthVar(id));
|
||||
}
|
||||
assert.equal(fs.readFileSync(path.join(scratch, "auth.json"), "utf8"), before);
|
||||
assert.ok(!keys.keyStatuses().some(row => row.id === CUSTOM_AUTH_BINDING)); assert.equal(process.env[CUSTOM_AUTH_BINDING], undefined);
|
||||
});
|
||||
test("same target can retain its effective key", async () => {
|
||||
seed({ baseUrl: "http://old/v1", authMode: "apiKey", models: [{ id: "m" }] }, "retained");
|
||||
assert.equal((await mutate({ baseUrl: "http://old/v1", authMode: "apiKey" })).status, 200);
|
||||
assert.equal(requests[0]?.authorization, "Bearer retained");
|
||||
});
|
||||
test("environment ownership is shown; consent uses the environment key in probe and inference", async () => {
|
||||
process.env.LH_IGNORE_ENV_KEYS = "0";
|
||||
process.env[CUSTOM_PROVIDER.envVar] = "environment-secret";
|
||||
authEnvRecord().realEnv.add(CUSTOM_PROVIDER.envVar);
|
||||
try {
|
||||
assert.equal((await get(true).json()).keySource, "env");
|
||||
assert.equal((await mutate({ baseUrl: "http://new/v1", authMode: "apiKey", key: "shadowed" })).status, 400);
|
||||
assert.equal((await mutate({ baseUrl: "http://new/v1", authMode: "apiKey", reuseKey: true })).status, 200);
|
||||
assert.equal(requests[0]?.authorization, "Bearer environment-secret");
|
||||
assert.equal(keys.keyValue(CUSTOM_PROVIDER.envVar), "environment-secret");
|
||||
assert.ok(!(await get().text()).includes("environment-secret"));
|
||||
assert.equal((await mutate({ baseUrl: "http://other/v1", authMode: "keyless" })).status, 200);
|
||||
assert.equal(requests.at(-1)?.authorization, null);
|
||||
} finally { authEnvRecord().realEnv.delete(CUSTOM_PROVIDER.envVar); delete process.env[CUSTOM_PROVIDER.envVar]; }
|
||||
});
|
||||
test("cross-origin/opaque/missing-origin and non-JSON writes cannot probe or mutate", async () => {
|
||||
for (const origin of ["http://evil", "null", ""]) assert.equal((await mutate({ baseUrl: "http://new", authMode: "keyless" }, "POST", { origin })).status, 403);
|
||||
assert.equal((await mutate({}, "POST", { "content-type": "text/plain" })).status, 415);
|
||||
assert.equal((await mutate({}, "POST", { "sec-fetch-site": "cross-site" })).status, 403);
|
||||
assert.equal(requests.length, 0);
|
||||
});
|
||||
test("standalone internal URL uses the addressed Host origin, never forwarded origins", async () => {
|
||||
const headers = { "content-type": "application/json", "if-match": get().headers.get("etag")!, host: "127.0.0.1:3317", origin: "http://127.0.0.1:3317" };
|
||||
assert.equal((await route.DELETE(new Request("http://localhost:3317/api/custom-provider", { method: "DELETE", headers }))).status, 200);
|
||||
assert.equal((await mutate({}, "DELETE", { "x-forwarded-host": "evil", origin: "http://evil" })).status, 403);
|
||||
});
|
||||
test("empty DELETE streams normalize consistently without accepting malformed JSON", async () => {
|
||||
const headers = { "content-type": "application/json", "if-match": get().headers.get("etag")!, origin: "http://localhost" };
|
||||
const init: RequestInit & { duplex: "half" } = { method: "DELETE", headers, body: new ReadableStream({ start(controller) { controller.close(); } }), duplex: "half" };
|
||||
assert.equal((await route.DELETE(new Request("http://localhost/api/custom-provider", init))).status, 200);
|
||||
assert.equal((await route.DELETE(new Request("http://localhost/api/custom-provider", { method: "DELETE", headers: { ...headers, "if-match": get().headers.get("etag")! }, body: "{" }))).status, 400);
|
||||
assert.equal((await route.POST(new Request("http://localhost/api/custom-provider", { method: "POST", headers, body: "" }))).status, 400);
|
||||
});
|
||||
test("desktop capability remains mandatory in desktop mode", async () => {
|
||||
process.env.LH_DESKTOP_SERVER = "1"; process.env.LH_DESKTOP_API_SECRET = "x".repeat(64);
|
||||
assert.equal((await mutate({}, "DELETE")).status, 403);
|
||||
assert.equal((await mutate(undefined, "DELETE", { origin: "null", "x-learnharness-desktop": "x".repeat(64) })).status, 200);
|
||||
});
|
||||
test("forbidden URL components are rejected before probing", async () => {
|
||||
for (const baseUrl of ["ftp://h", "https://user:pass@h/v1", "http://h/v1?q=secret", "http://h/v1#x", "http://h/v1?", "http://h/v1#"]) assert.equal((await mutate({ baseUrl, authMode: "keyless" })).status, 400);
|
||||
assert.equal(requests.length, 0);
|
||||
});
|
||||
test("redirects/auth failures/model and byte limits cannot be bypassed with typed IDs", async () => {
|
||||
for (const answer of [
|
||||
() => new Response(null, { status: 302, headers: { location: "http://other" } }),
|
||||
() => new Response("private response", { status: 401 }),
|
||||
() => new Response("private response", { status: 403 }),
|
||||
() => Response.json({ data: Array(10001).fill({ id: "m" }) }),
|
||||
() => new Response("x".repeat(8 * 1024 * 1024 + 1)),
|
||||
() => new Response("{}", { headers: { "content-length": String(8 * 1024 * 1024 + 1) } }),
|
||||
]) {
|
||||
globalThis.fetch = async () => answer();
|
||||
const before = current();
|
||||
const res = await mutate({ baseUrl: "http://h/v1", authMode: "keyless", manualModels: "m" });
|
||||
assert.equal(res.status, 502); assert.deepEqual(current(), before);
|
||||
assert.ok(!(await res.text()).includes("private response"));
|
||||
}
|
||||
});
|
||||
test("write size bounds are explicit for streamed bodies", async () => {
|
||||
assert.equal((await mutate({ baseUrl: "x".repeat(65536), authMode: "keyless" })).status, 413);
|
||||
assert.equal((await mutate({ padding: "x".repeat(65536) }, "DELETE")).status, 413);
|
||||
assert.equal(requests.length, 0);
|
||||
});
|
||||
test("stale save, pin edit and disconnect all return reload guidance without writing", async () => {
|
||||
const etag = get().headers.get("etag")!;
|
||||
seed({ baseUrl: "http://other/v1", authMode: "keyless", models: [{ id: "different" }] });
|
||||
const before = current();
|
||||
for (const method of ["POST", "PATCH", "DELETE"]) {
|
||||
const res = await mutate({ baseUrl: "http://h", authMode: "keyless", overrides: {} }, method, { "if-match": etag });
|
||||
assert.equal(res.status, 409); assert.match(await res.text(), /Reload/);
|
||||
}
|
||||
assert.equal(requests.length, 0); assert.deepEqual(current(), before);
|
||||
});
|
||||
test("after discovery preserves unrelated edits and rejects intervening custom edits", async () => {
|
||||
globalThis.fetch = async () => { fs.writeFileSync(settingsFile, JSON.stringify({ ...current(), appearance: { mode: "dark" }, claudeCode: { enabled: true } })); return Response.json({ data: [{ id: "m" }] }); };
|
||||
assert.equal((await mutate({ baseUrl: "http://h", authMode: "keyless" })).status, 200);
|
||||
assert.deepEqual(current().appearance, { mode: "dark" }); assert.equal(current().claudeCode.enabled, true);
|
||||
globalThis.fetch = async () => { seed({ baseUrl: "http://changed", authMode: "keyless", models: [] }); return Response.json({ data: [{ id: "m" }] }); };
|
||||
assert.equal((await mutate({ baseUrl: "http://h", authMode: "keyless" })).status, 409);
|
||||
assert.equal(current().customProvider.baseUrl, "http://changed");
|
||||
});
|
||||
test("after discovery rejects a changed effective key", async () => {
|
||||
seed({ baseUrl: "http://old/v1", authMode: "apiKey", models: [{ id: "m" }] }, "before");
|
||||
globalThis.fetch = async () => { setFileKey("rotated"); return Response.json({ data: [{ id: "m" }] }); };
|
||||
assert.equal((await mutate({ baseUrl: "http://old/v1", authMode: "apiKey" })).status, 409);
|
||||
assert.equal(keys.keyValue(CUSTOM_PROVIDER.envVar), "rotated");
|
||||
});
|
||||
test("offline pins accept 512/1, retain support declaration, and do not probe", async () => {
|
||||
const res = await mutate({ overrides: { m: { context: 1, maxTokens: 512, reasoning: false } } }, "PATCH");
|
||||
assert.equal(res.status, 200); assert.equal(requests.length, 0);
|
||||
assert.deepEqual(current().customProvider.overrides.m, { context: 1, maxTokens: 512, reasoning: false });
|
||||
});
|
||||
test("settings-write failure restores previous key/config and reports failure", async () => {
|
||||
setFileKey("old-key");
|
||||
const before = current();
|
||||
const write = fs.writeFileSync;
|
||||
mock.method(fs, "writeFileSync", (...args: Parameters<typeof write>) => { if (args[0] === settingsFile) throw new Error("private-io-details"); return write(...args); });
|
||||
const res = await mutate({ baseUrl: "http://new", authMode: "apiKey", key: "new-key" });
|
||||
assert.equal(res.status, 500); assert.ok(!(await res.text()).includes("private-io-details"));
|
||||
assert.equal(keys.keyValue(CUSTOM_PROVIDER.envVar), "old-key"); assert.deepEqual(current(), before);
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
/** Native Pi payloads with synthetic transport and owned scratch files only. */
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { after, beforeEach, test } from "node:test";
|
||||
import { createCanvas } from "@napi-rs/canvas";
|
||||
import { normalizeContext, fauxAssistantMessage, fauxToolCall, type Message } from "@earendil-works/pi-ai";
|
||||
|
||||
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "lh-custom-lifecycle-"));
|
||||
const originalEnv = process.env;
|
||||
const originalFetch = globalThis.fetch;
|
||||
process.env = { ...(originalEnv.NODE_TEST_CONTEXT ? { NODE_TEST_CONTEXT: originalEnv.NODE_TEST_CONTEXT } : {}), ...(originalEnv.NODE_OPTIONS ? { NODE_OPTIONS: originalEnv.NODE_OPTIONS } : {}), NODE_ENV: "test", HOME: scratch, LH_DATA_DIR: scratch, LH_AUTH_FILE: path.join(scratch, "auth.json"), LH_IGNORE_ENV_KEYS: "1" };
|
||||
const { writeSettings, readSettings } = await import("../src/store/settings.ts");
|
||||
const { piEngineForSpec, piModels } = await import("../src/agent/models.ts");
|
||||
await import("../src/server/keys.ts");
|
||||
const { readCustomAuth } = await import("../src/store/auth.ts");
|
||||
const { persistCustomAuth, customAuthState } = await import("../src/server/custom-provider-auth.ts");
|
||||
const { authorizeFixture } = await import("./helpers/custom-provider-auth.ts");
|
||||
const { modelCatalog, catalogContextLimits } = await import("../src/server/model-catalog.ts");
|
||||
const { callCost } = await import("../src/server/pricing.ts");
|
||||
const { createReadTool } = await import("../src/agent/tools/read.ts");
|
||||
const { installCustomProviderSource } = await import("../src/server/custom-provider.ts");
|
||||
let requests: { url: string; auth: string | null; body: Record<string, unknown> }[];
|
||||
function configure(baseUrl = "http://first/v1", reasoning = false, maxTokens = 512, authMode: "keyless" | "apiKey" = "apiKey") {
|
||||
const key = authMode === "apiKey" ? readCustomAuth().fileKey() ?? "first-key" : undefined;
|
||||
writeSettings({ version: 1, customProvider: authorizeFixture({ baseUrl, authMode, models: [{ id: "m", context: 16384, maxTokens, reasoning }] }, key) });
|
||||
}
|
||||
function answer() {
|
||||
return new Response(`data: ${JSON.stringify({ id: "fake", choices: [{ index: 0, delta: { role: "assistant", content: "ok" }, finish_reason: null }] })}\n\ndata: ${JSON.stringify({ choices: [{ index: 0, delta: {}, finish_reason: "stop" }], usage: { prompt_tokens: 1, completion_tokens: 1 } })}\n\ndata: [DONE]\n\n`, { headers: { "content-type": "text/event-stream" } });
|
||||
}
|
||||
function capture(url: unknown, init?: RequestInit) {
|
||||
requests.push({ url: String(url), auth: new Headers(init?.headers).get("authorization"), body: JSON.parse(String(init?.body)) });
|
||||
}
|
||||
const context = (messages: Message[] = [{ role: "user", content: "synthetic", timestamp: 0 }]) => normalizeContext({ messages });
|
||||
const run = (engine: ReturnType<typeof piEngineForSpec>, messages?: Message[]) => engine.streamFn(engine.model, context(messages), {}).result();
|
||||
beforeEach(() => {
|
||||
requests = []; fs.writeFileSync(path.join(scratch, "auth.json"), "{}"); configure();
|
||||
globalThis.fetch = async (url, init) => { capture(url, init); return answer(); };
|
||||
});
|
||||
after(() => { process.env = originalEnv; globalThis.fetch = originalFetch; fs.rmSync(scratch, { recursive: true, force: true }); });
|
||||
|
||||
test("native requests revalidate endpoint/model/key/limits with no fallback and retain in-flight setup", async () => {
|
||||
const engine = piEngineForSpec({ provider: "custom", id: "m" });
|
||||
let started!: () => void; const ready = new Promise<void>((r) => { started = r; });
|
||||
let release!: () => void;
|
||||
globalThis.fetch = async (url, init) => { capture(url, init); started(); return new Promise<Response>((resolve) => { release = () => resolve(answer()); }); };
|
||||
const inFlight = run(engine); await ready;
|
||||
configure("http://second/v1", false, 1024); persistCustomAuth(readCustomAuth().binding, "second-key");
|
||||
globalThis.fetch = async (url, init) => { capture(url, init); return answer(); };
|
||||
assert.equal((await run(engine)).stopReason, "stop");
|
||||
release(); assert.equal((await inFlight).stopReason, "stop");
|
||||
assert.match(requests[0]!.url, /^http:\/\/first\/v1\/chat\/completions$/);
|
||||
assert.equal(requests[0]!.auth, "Bearer first-key"); assert.equal(requests[0]!.body.max_completion_tokens, 512);
|
||||
assert.match(requests[1]!.url, /^http:\/\/second\/v1\/chat\/completions$/);
|
||||
assert.equal(requests[1]!.auth, "Bearer second-key"); assert.equal(requests[1]!.body.max_completion_tokens, 1024);
|
||||
writeSettings({ version: 1, customProvider: { ...readSettings()!.customProvider, models: [{ id: "other" }] } });
|
||||
assert.ok(piModels().getProvider("custom"), "provider stays authorized while this model is removed");
|
||||
assert.equal((await run(engine)).stopReason, "error");
|
||||
writeSettings({ version: 1 }); assert.equal((await run(engine)).stopReason, "error");
|
||||
assert.equal(requests.length, 2);
|
||||
assert.ok(piModels().getProvider("openai"), "native route survives custom removal");
|
||||
});
|
||||
test("current key changes without configuration edits are reflected on the next operation", async () => {
|
||||
const engine = piEngineForSpec({ provider: "custom", id: "m" });
|
||||
await run(engine); persistCustomAuth(readCustomAuth().binding, "rotated-key"); await run(engine);
|
||||
assert.deepEqual(requests.map((r) => r.auth), ["Bearer first-key", "Bearer rotated-key"]);
|
||||
});
|
||||
test("original saved thinking preference recovers after capability expansion and contracts again", async () => {
|
||||
const engine = piEngineForSpec({ provider: "custom", id: "m", thinkingLevel: "high" });
|
||||
assert.equal(engine.level, "off");
|
||||
await run(engine); configure("http://first/v1", true); await run(engine); configure(); await run(engine);
|
||||
assert.deepEqual(requests.map((r) => r.body.reasoning_effort), [undefined, "high", undefined]);
|
||||
});
|
||||
test("unset preference reselects the existing default against current reasoning metadata", async () => {
|
||||
const engine = piEngineForSpec({ provider: "custom", id: "m" });
|
||||
configure("http://first/v1", true); await run(engine);
|
||||
assert.equal(requests[0]!.body.reasoning_effort, "medium");
|
||||
configure(); await run(engine); assert.equal(requests[1]!.body.reasoning_effort, undefined);
|
||||
});
|
||||
test("catalog capabilities remain reported and prices unknown despite runtime image permission", () => {
|
||||
const engine = piEngineForSpec({ provider: "custom", id: "m" });
|
||||
assert.deepEqual(engine.model.input, ["text", "image"]);
|
||||
const dto = modelCatalog().models.find((m) => m.provider === "custom")!;
|
||||
assert.deepEqual(dto.input, ["text"]); assert.equal(dto.cost, null);
|
||||
assert.equal(callCost({ inputTokens: 100, outputTokens: 100 }, "custom:m"), null);
|
||||
assert.equal(catalogContextLimits().get("custom:m"), 16384);
|
||||
});
|
||||
const image = { type: "image" as const, data: createCanvas(1, 1).toBuffer("image/png").toString("base64"), mimeType: "image/png" };
|
||||
test("actual native encoder preserves USER and tool-result images under missing metadata, including after reload", async () => {
|
||||
const engine = piEngineForSpec({ provider: "custom", id: "m" });
|
||||
const messages: Message[] = [
|
||||
{ role: "user", content: [{ type: "text", text: "look" }, image], timestamp: 0 },
|
||||
fauxAssistantMessage([fauxToolCall("read", {}, { id: "read-1" })], { stopReason: "toolUse" }),
|
||||
{ role: "toolResult", toolCallId: "read-1", toolName: "read", content: [image], isError: false, timestamp: 1 },
|
||||
];
|
||||
await run(engine, messages); configure("http://second/v1"); await run(engine, messages);
|
||||
for (const request of requests) {
|
||||
const body = JSON.stringify(request.body.messages);
|
||||
assert.equal((body.match(/"type":"image_url"/g) ?? []).length, 2);
|
||||
assert.equal((body.match(/data:image\/png;base64/g) ?? []).length, 2);
|
||||
assert.ok(!body.includes("image omitted"));
|
||||
}
|
||||
assert.deepEqual(piModels().getModel("custom", "m")!.input, ["text"]);
|
||||
});
|
||||
test("teacher-bound read returns image without contradictory omission note", async () => {
|
||||
const file = path.join(scratch, "synthetic.png"); fs.writeFileSync(file, Buffer.from(image.data, "base64"));
|
||||
const engine = piEngineForSpec({ provider: "custom", id: "m" });
|
||||
const result = await createReadTool(engine.model).execute("read-1", { path: file });
|
||||
assert.ok(result.content.some((block) => block.type === "image"));
|
||||
assert.ok(!JSON.stringify(result).includes("image will be omitted"));
|
||||
});
|
||||
test("backend image rejection surfaces once, never retries without images", async () => {
|
||||
globalThis.fetch = async (url, init) => { capture(url, init); return Response.json({ error: { message: "images rejected by backend" } }, { status: 400 }); };
|
||||
const result = await run(piEngineForSpec({ provider: "custom", id: "m" }), [{ role: "user", content: [image], timestamp: 0 }]);
|
||||
assert.equal(result.stopReason, "error"); assert.match(result.errorMessage ?? "", /images rejected/); assert.equal(requests.length, 1);
|
||||
});
|
||||
test("existing engine refuses nonce mismatch on EVERY operation, without falling back to env", async () => {
|
||||
const engine = piEngineForSpec({ provider: "custom", id: "m" }); await run(engine);
|
||||
persistCustomAuth({ ...readCustomAuth().binding!, id: "b".repeat(32) }, "different-key");
|
||||
assert.equal((await run(engine)).stopReason, "error"); assert.equal(requests.length, 1);
|
||||
});
|
||||
test("keyless publication never reads the credential lookup", async () => {
|
||||
configure("http://first/v1", false, 512, "keyless");
|
||||
let credentialLookups = 0;
|
||||
installCustomProviderSource((inspect, cp) => { if (inspect) credentialLookups++; return customAuthState(inspect, cp); });
|
||||
try { assert.equal((await run(piEngineForSpec({ provider: "custom", id: "m" }))).stopReason, "stop"); assert.equal(credentialLookups, 0); assert.equal(requests[0]!.auth, "Bearer not-needed"); }
|
||||
finally { installCustomProviderSource(); }
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
/** No real endpoints/accounts. Actual process death and cold startup, not HMR. */
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { after, test } from "node:test";
|
||||
import { CUSTOM_AUTH_BINDING } from "../src/core/custom-provider-auth.ts";
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "lh-custom-restart-"));
|
||||
after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const base = "http://first.invalid/v1";
|
||||
function profile() {
|
||||
const home = fs.mkdtempSync(path.join(root, "case-"));
|
||||
const id = "a".repeat(32);
|
||||
fs.writeFileSync(path.join(home, "settings.json"), JSON.stringify({ version: 1, appearance: { mode: "dark" }, customProvider: { baseUrl: base, authMode: "apiKey", credentialBindingId: id, models: [{ id: "m" }] } }));
|
||||
fs.writeFileSync(path.join(home, "auth.json"), JSON.stringify({ CUSTOM_OPENAI_API_KEY: "fixture-old-key", OTHER_PROVIDER_KEY: "untouched", future: { preserve: true }, [CUSTOM_AUTH_BINDING]: JSON.stringify({ version: 1, id, baseUrl: base, authMode: "apiKey", fileKeyBaseUrl: base }) }), { mode: 0o600 });
|
||||
return home;
|
||||
}
|
||||
function child(home: string, mode: "mutate" | "inspect", action = "key", fault = "none", envKey?: string) {
|
||||
const result = spawnSync(process.execPath, ["--import", "tsx", "tests/helpers/custom-provider-crash.ts", mode, action, fault], { cwd: process.cwd(), env: { PATH: process.env.PATH, HOME: home, USERPROFILE: home, NODE_ENV: "test", LH_DATA_DIR: home, LH_AUTH_FILE: path.join(home, "auth.json"), LH_IGNORE_ENV_KEYS: "0", ...(envKey ? { CUSTOM_OPENAI_API_KEY: envKey } : {}) }, encoding: "utf8", timeout: 15000 });
|
||||
assert.ifError(result.error);
|
||||
assert.ok(result.status === 0 || result.status === 77, `${result.status}: ${result.stderr}`);
|
||||
return { exit: result.status, data: result.stdout.trim() ? JSON.parse(result.stdout.trim().split("\n").at(-1)!) : undefined };
|
||||
}
|
||||
function closed(home: string, envKey?: string) {
|
||||
const { data } = child(home, "inspect", "key", "none", envKey);
|
||||
assert.equal(data.enabled, false); assert.deepEqual(data.requests, []); assert.equal(data.metadataInEnv, false);
|
||||
assert.ok(!data.statusIds.includes(CUSTOM_AUTH_BINDING));
|
||||
}
|
||||
test("cold restart refuses the new auth/old URL gap and double-I/O failure, with or without env", () => {
|
||||
for (const fault of ["after-auth", "double-io"]) {
|
||||
const home = profile(); const result = child(home, "mutate", "key", fault);
|
||||
if (fault === "after-auth") assert.equal(result.exit, 77); else assert.equal(result.data.status, 500);
|
||||
closed(home); closed(home, "fixture-real-env");
|
||||
const auth = JSON.parse(fs.readFileSync(path.join(home, "auth.json"), "utf8"));
|
||||
assert.equal(auth.OTHER_PROVIDER_KEY, "untouched"); assert.deepEqual(auth.future, { preserve: true });
|
||||
}
|
||||
});
|
||||
test("failed auth publication retains old matching setup; landed settings plus restored auth fails closed", () => {
|
||||
const home = profile(); assert.equal(child(home, "mutate", "key", "auth-before-rename").data.status, 500);
|
||||
const old = child(home, "inspect").data; assert.equal(old.enabled, true); assert.equal(old.requests[0].url, base + "/chat/completions"); assert.equal(old.requests[0].authorization, "Bearer fixture-old-key");
|
||||
const other = profile(); assert.equal(child(other, "mutate", "key", "settings-landed").data.status, 500); closed(other);
|
||||
});
|
||||
test("clear/disconnect half commits stay closed across restart even with genuine env keys", () => {
|
||||
for (const action of ["keyless", "disconnect"]) {
|
||||
for (const fault of ["after-auth", "double-io"]) {
|
||||
const home = profile(); child(home, "mutate", action, fault, "fixture-real-env"); closed(home, "fixture-real-env");
|
||||
}
|
||||
}
|
||||
});
|
||||
test("successful keyless checks only binding; disconnect tombstone blocks latent env after restart", () => {
|
||||
const home = profile(); assert.equal(child(home, "mutate", "keyless", "none", "fixture-real-env").data.status, 200);
|
||||
const state = child(home, "inspect", "key", "none", "fixture-real-env").data; assert.equal(state.enabled, true); assert.equal(state.requests[0].authorization, "Bearer not-needed");
|
||||
assert.equal(child(home, "mutate", "disconnect", "none", "fixture-real-env").data.status, 200); closed(home, "fixture-real-env");
|
||||
});
|
||||
test("env reuse never reauthorizes a shadowed file key for another destination", () => {
|
||||
const home = profile(); assert.equal(child(home, "mutate", "envMove", "none", "fixture-real-env").data.status, 200);
|
||||
const live = child(home, "inspect", "key", "none", "fixture-real-env").data; assert.equal(live.enabled, true); assert.equal(live.requests[0].url, "http://second.invalid/v1/chat/completions"); assert.equal(live.requests[0].authorization, "Bearer fixture-real-env");
|
||||
closed(home);
|
||||
const auth = JSON.parse(fs.readFileSync(path.join(home, "auth.json"), "utf8")); assert.equal(JSON.parse(auth[CUSTOM_AUTH_BINDING]).fileKeyBaseUrl, base); assert.equal(auth.CUSTOM_OPENAI_API_KEY, "fixture-old-key");
|
||||
assert.equal(child(home, "mutate", "reuse").data.status, 200); assert.equal(child(home, "inspect").data.requests[0].authorization, "Bearer fixture-old-key");
|
||||
});
|
||||
test("unbound development records require explicit manual reauthorization, never automatic migration", () => {
|
||||
const home = profile(); const file = path.join(home, "auth.json"); const auth = JSON.parse(fs.readFileSync(file, "utf8")); delete auth[CUSTOM_AUTH_BINDING]; fs.writeFileSync(file, JSON.stringify(auth));
|
||||
closed(home); closed(home, "fixture-real-env");
|
||||
assert.equal(child(home, "mutate", "reuse").data.status, 200); assert.equal(child(home, "inspect").data.enabled, true);
|
||||
});
|
||||
test("malformed/unreadable private snapshots are never rewritten or used, unrelated bytes survive", () => {
|
||||
for (const raw of ['{"OTHER_PROVIDER_KEY":"do-not-erase",', '[]', JSON.stringify({ OTHER_PROVIDER_KEY: "do-not-erase", [CUSTOM_AUTH_BINDING]: "not-json" })]) {
|
||||
const home = profile(); const file = path.join(home, "auth.json"); fs.writeFileSync(file, raw);
|
||||
closed(home, "fixture-real-env"); assert.equal(child(home, "mutate").data.status, 500); assert.equal(fs.readFileSync(file, "utf8"), raw);
|
||||
}
|
||||
const home = profile(); const file = path.join(home, "auth.json"); const before = fs.readFileSync(file, "utf8");
|
||||
assert.equal(child(home, "mutate", "key", "unreadable").data.status, 500); assert.equal(fs.readFileSync(file, "utf8"), before);
|
||||
assert.equal(child(home, "inspect", "key", "unreadable", "fixture-real-env").data.enabled, false);
|
||||
});
|
||||
test("missing auth file initializes normally with explicit new key, preserving other settings", () => {
|
||||
const home = profile(); fs.unlinkSync(path.join(home, "auth.json")); closed(home);
|
||||
assert.equal(child(home, "mutate").data.status, 200); const live = child(home, "inspect").data; assert.equal(live.enabled, true); assert.equal(live.requests[0].authorization, "Bearer fixture-new-key");
|
||||
assert.deepEqual(JSON.parse(fs.readFileSync(path.join(home, "settings.json"), "utf8")).appearance, { mode: "dark" });
|
||||
});
|
||||
@@ -37,6 +37,7 @@ const CONNECTED = {
|
||||
version: 1,
|
||||
customProvider: {
|
||||
baseUrl: BASE,
|
||||
authMode: "keyless",
|
||||
models: [{ id: "disc-1", context: 131072 }, { id: "disc-2" }],
|
||||
overrides: { "disc-1": { context: 200000 }, "disc-2": { maxTokens: 8192 } },
|
||||
manualModels: ["m1"],
|
||||
@@ -59,8 +60,8 @@ function post(payload: unknown): Promise<Response> {
|
||||
return route.POST(
|
||||
new Request("http://localhost/api/custom-provider", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
headers: { "content-type": "application/json", origin: "http://localhost", "if-match": route.GET(new Request("http://localhost/api/custom-provider")).headers.get("etag")! },
|
||||
body: JSON.stringify({ authMode: payload && typeof payload === "object" && "key" in payload ? "apiKey" : "keyless", ...payload as object }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,362 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { after, describe, it } from "node:test";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { createModels, normalizeContext, type SimpleStreamOptions } from "@earendil-works/pi-ai";
|
||||
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
||||
import { parseCustomProviders, resolveCustomValue } from "../src/core/custom-providers.ts";
|
||||
import { createCustomProvider, customProviderApis } from "../src/agent/custom-providers.ts";
|
||||
import { CLAUDE_CODE_PROVIDER } from "../src/core/contracts/claude-code.ts";
|
||||
|
||||
const zero = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
||||
function model(id = "chat", extra = {}) {
|
||||
return { id, contextWindow: 8192, maxTokens: 1024, cost: zero, ...extra };
|
||||
}
|
||||
function config(extra = {}, models = [model()]) {
|
||||
return { providers: { gateway: { baseUrl: "http://example.invalid/custom/v1", api: "openai-completions", apiKey: "dummy", models, ...extra } } };
|
||||
}
|
||||
function parse(value: unknown) { return parseCustomProviders(JSON.stringify(value), new Set(["openai", "chatgpt"]), customProviderApis); }
|
||||
|
||||
const temp = fs.mkdtempSync(path.join(os.tmpdir(), "lh-custom-providers-"));
|
||||
process.env.LH_DATA_DIR = temp;
|
||||
process.env.LH_AUTH_FILE = path.join(temp, "auth.json");
|
||||
process.env.LH_IGNORE_ENV_KEYS = "1";
|
||||
fs.writeFileSync(process.env.LH_AUTH_FILE, JSON.stringify({ GW_KEY: "first-key", GW_HEADER: "first-header" }));
|
||||
const { CUSTOM_MODELS_FILE, SETTINGS_FILE } = await import("../config.ts");
|
||||
const { modelCatalog, validateModelsSettings, clampModelsLevels, catalogContextLimits } = await import("../src/server/model-catalog.ts");
|
||||
const { piModels, piEngineForSpec } = await import("../src/agent/models.ts");
|
||||
const { providerKeyPresent, keyValue } = await import("../src/server/keys.ts");
|
||||
const { authStore } = await import("../src/store/auth.ts");
|
||||
const { CustomProviderSource, customProviderConfigured, installCustomProviderSource } = await import("../src/server/custom-providers.ts");
|
||||
const { callCost } = await import("../src/server/pricing.ts");
|
||||
after(() => fs.rmSync(temp, { recursive: true, force: true }));
|
||||
|
||||
function save(value: unknown) { fs.writeFileSync(CUSTOM_MODELS_FILE, JSON.stringify(value)); }
|
||||
function sseText(text = "ok") {
|
||||
const chunks = [
|
||||
{ choices: [{ index: 0, delta: { role: "assistant", content: text }, finish_reason: null }] },
|
||||
{ choices: [{ index: 0, delta: {}, finish_reason: "stop" }], usage: { prompt_tokens: 10, completion_tokens: 2, total_tokens: 12 } },
|
||||
];
|
||||
return new Response(chunks.map((chunk) => `data: ${JSON.stringify({ id: "test", object: "chat.completion.chunk", created: 1, model: "chat", ...chunk })}\n\n`).join("") + "data: [DONE]\n\n", { headers: { "content-type": "text/event-stream" } });
|
||||
}
|
||||
|
||||
describe("declarative custom-provider configuration", () => {
|
||||
it("uses Pi metadata and provider/model inheritance without rewriting base URLs", () => {
|
||||
const [parsed] = parse(config({ compat: { supportsStore: false, chatTemplateKwargs: { a: true } } }, [model("named", {
|
||||
name: "Named model", reasoning: true, input: ["text", "image"], thinkingLevelMap: { high: "high", xhigh: null },
|
||||
compat: { maxTokensField: "max_tokens", chatTemplateKwargs: { b: false } },
|
||||
samplingParams: { temperature: 0.5 }, samplingParamsByThinkingLevel: { high: { top_p: 0.9 } },
|
||||
})]));
|
||||
assert.equal(parsed!.models[0]!.baseUrl, "http://example.invalid/custom/v1");
|
||||
assert.equal(parsed!.models[0]!.provider, "gateway");
|
||||
assert.equal(parsed!.models[0]!.name, "Named model");
|
||||
assert.deepEqual(parsed!.models[0]!.input, ["text", "image"]);
|
||||
assert.deepEqual(parsed!.models[0]!.compat, { supportsStore: false, maxTokensField: "max_tokens", chatTemplateKwargs: { a: true, b: false } });
|
||||
assert.deepEqual(parsed!.models[0]!.samplingParamsByThinkingLevel, { high: { top_p: 0.9 } });
|
||||
});
|
||||
it("accepts Pi line comments, trailing commas and BOM without corrupting strings", () => {
|
||||
const text = `\uFEFF${JSON.stringify(config()).replace('"providers":', '// comment\n"providers":').replace(/}$/, ',}')}`;
|
||||
assert.equal(parseCustomProviders(text, new Set(), customProviderApis)[0]!.models[0]!.baseUrl, "http://example.invalid/custom/v1");
|
||||
});
|
||||
it("registers multiple providers and mixed native APIs without fixed backend IDs", () => {
|
||||
const value = config();
|
||||
const raw = { providers: { ...value.providers, other: { ...value.providers.gateway, models: [model("chat"), model("anthropic", { api: "anthropic-messages", baseUrl: "https://example.invalid/anthropic" })] } } };
|
||||
const models = createModels();
|
||||
for (const entry of parse(raw)) models.setProvider(createCustomProvider(entry, () => undefined));
|
||||
assert.equal(models.getProviders().length, 2);
|
||||
assert.equal(models.getModel("other", "anthropic")!.api, "anthropic-messages");
|
||||
assert.equal(models.getModel("other", "anthropic")!.baseUrl, "https://example.invalid/anthropic");
|
||||
});
|
||||
it("requires explicit finite limits and nonnegative costs, instead of guessing", () => {
|
||||
for (const field of ["contextWindow", "maxTokens", "cost"]) {
|
||||
const m: Record<string, unknown> = model(); delete m[field];
|
||||
assert.throws(() => parse(config({}, [m as ReturnType<typeof model>])));
|
||||
}
|
||||
assert.throws(() => parse(config({}, [model("chat", { contextWindow: -1 })])));
|
||||
assert.throws(() => parse(config({}, [model("chat", { cost: { ...zero, output: -1 } })])));
|
||||
assert.throws(() => parseCustomProviders(JSON.stringify(config()).replace('"contextWindow":8192', '"contextWindow":1e999'), new Set(), customProviderApis));
|
||||
});
|
||||
it("rejects collisions, duplicate IDs, unsupported protocols, executable fields and invalid URLs", () => {
|
||||
for (const id of ["openai", "chatgpt", "", "has:colon"]) assert.throws(() => parse({ providers: { [id]: config().providers.gateway } }));
|
||||
assert.throws(() => parse(config({}, [model(), model()])));
|
||||
assert.throws(() => parse(config({ api: "unknown-api" })));
|
||||
assert.throws(() => parse(config({ oauth: "radius" })));
|
||||
assert.throws(() => parse(config({ modelOverrides: {} })));
|
||||
assert.throws(() => parse(config({ apiKey: "!touch /never-execute" })));
|
||||
assert.throws(() => parse(config({ headers: { "x-key": "!secret-command" } })));
|
||||
assert.throws(() => parse(config({}, [model("chat", { headers: { Authorization: "!command" } })])));
|
||||
for (const baseUrl of ["file:///tmp/model", "https://secret@example.invalid", "http://example.invalid/#fragment"]) assert.throws(() => parse(config({ baseUrl })));
|
||||
});
|
||||
it("matches Pi value interpolation and escapes, without recursive expansion or commands", () => {
|
||||
const lookup = (key: string) => ({ KEY: "secret", DATA: "!never-executed" })[key as "KEY"];
|
||||
assert.equal(resolveCustomValue("Bearer ${KEY}/$KEY", lookup), "Bearer secret/secret");
|
||||
assert.equal(resolveCustomValue("$$KEY $!literal", lookup), "$KEY !literal");
|
||||
assert.equal(resolveCustomValue("$MISSING", lookup), undefined);
|
||||
assert.equal(resolveCustomValue("$DATA", lookup), "!never-executed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("custom providers in the existing application wiring", () => {
|
||||
it("is empty before configuration and never creates a configuration file", () => {
|
||||
assert.equal(fs.existsSync(CUSTOM_MODELS_FILE), false);
|
||||
assert.equal(modelCatalog().customProvidersError, undefined);
|
||||
assert.equal(fs.existsSync(CUSTOM_MODELS_FILE), false);
|
||||
});
|
||||
it("projects into existing pickers, validation, context limits and Pi pricing", async () => {
|
||||
save(config({ apiKey: "$GW_KEY", headers: { "x-gateway": "$GW_HEADER" } }, [model("chat", { cost: { ...zero, input: 2, output: 4 } })]));
|
||||
const catalog = modelCatalog();
|
||||
assert.ok(catalog.providers.find((p) => p.id === "gateway")?.keyPresent);
|
||||
assert.ok(catalog.models.some((m) => m.provider === "gateway" && m.id === "chat"));
|
||||
assert.equal(JSON.stringify(catalog).includes("first-key"), false);
|
||||
assert.equal(JSON.stringify(catalog).includes("first-header"), false);
|
||||
assert.equal(validateModelsSettings({ strong: { model: { provider: "gateway", id: "chat" } } }).ok, true);
|
||||
assert.equal(catalogContextLimits().get("gateway:chat"), 8192);
|
||||
assert.ok(Math.abs(callCost({ inputTokens: 100, outputTokens: 50 }, "gateway:chat")! - 0.0004) < 1e-12);
|
||||
assert.equal(providerKeyPresent("gateway"), true);
|
||||
assert.equal((await piModels().getAuth("gateway"))!.auth.apiKey, "first-key");
|
||||
});
|
||||
it("uses current app key precedence and marks unresolved credentials/headers unavailable", () => {
|
||||
process.env.LH_IGNORE_ENV_KEYS = "0";
|
||||
process.env.GW_KEY = "real-env-key";
|
||||
try { assert.equal(keyValue("GW_KEY"), "real-env-key"); }
|
||||
finally { process.env.LH_IGNORE_ENV_KEYS = "1"; delete process.env.GW_KEY; }
|
||||
save(config({ apiKey: "$MISSING_KEY" }));
|
||||
assert.equal(providerKeyPresent("gateway"), false);
|
||||
assert.equal(modelCatalog().models.some((m) => m.provider === "gateway"), false);
|
||||
save(config({ headers: { "x-required": "$MISSING_HEADER" } }));
|
||||
assert.equal(providerKeyPresent("gateway"), false);
|
||||
save(config({ apiKey: undefined }));
|
||||
assert.equal(providerKeyPresent("gateway"), false, "Pi-style keyless endpoints need an explicit dummy key");
|
||||
});
|
||||
it("revalidates every request of one engine while leaving an in-flight HTTP operation alone", async () => {
|
||||
save(config({ apiKey: "$GW_KEY", headers: { "x-gateway": "$GW_HEADER" }, compat: { supportsStore: false, supportsDeveloperRole: false, supportsReasoningEffort: false, maxTokensField: "max_tokens" } }));
|
||||
const engine = piEngineForSpec({ provider: "gateway", id: "chat" });
|
||||
const context = normalizeContext({ messages: [{ role: "user", content: "hello", timestamp: 1 }] });
|
||||
let started!: () => void, release!: (response: Response) => void;
|
||||
const requestStarted = new Promise<void>((resolve) => { started = resolve; });
|
||||
const firstResponse = new Promise<Response>((resolve) => { release = resolve; });
|
||||
const requests: { url: string; headers: Headers; body: Record<string, unknown> }[] = [];
|
||||
const original = globalThis.fetch;
|
||||
globalThis.fetch = async (input, init) => {
|
||||
const request = input instanceof Request ? input : new Request(input, init);
|
||||
requests.push({ url: request.url, headers: request.headers, body: await request.json() as Record<string, unknown> });
|
||||
if (requests.length === 1) { started(); return firstResponse; }
|
||||
return sseText("héllo 🌍");
|
||||
};
|
||||
const pending = engine.streamFn(engine.model, context);
|
||||
try {
|
||||
await requestStarted;
|
||||
authStore.setAuthVar("GW_KEY", "second-key");
|
||||
authStore.setAuthVar("GW_HEADER", "second-header");
|
||||
save(config({ baseUrl: "https://new.example.invalid/inference/v1", apiKey: "$GW_KEY", headers: { "x-gateway": "$GW_HEADER" }, compat: { supportsStore: false, maxTokensField: "max_tokens" } }, [model("chat", { maxTokens: 512 })]));
|
||||
const second = await engine.streamFn(engine.model, context).result();
|
||||
assert.equal(second.stopReason, "stop");
|
||||
assert.deepEqual(second.content, [{ type: "text", text: "héllo 🌍" }]);
|
||||
assert.equal(second.usage.input, 10);
|
||||
assert.equal(second.usage.output, 2);
|
||||
// Credential-only edits must also take effect on this same engine.
|
||||
authStore.setAuthVar("GW_KEY", "third-key");
|
||||
assert.equal((await engine.streamFn(engine.model, context).result()).stopReason, "stop");
|
||||
// None of these cases may dispatch using the engine's old model/key.
|
||||
save(config({ apiKey: "$MISSING_KEY" }));
|
||||
assert.equal((await engine.streamFn(engine.model, context).result()).stopReason, "error");
|
||||
save(config({}, [model("other-model")]));
|
||||
assert.equal((await engine.streamFn(engine.model, context).result()).stopReason, "error");
|
||||
fs.writeFileSync(CUSTOM_MODELS_FILE, "invalid configuration");
|
||||
assert.equal((await engine.streamFn(engine.model, context).result()).stopReason, "error");
|
||||
fs.rmSync(CUSTOM_MODELS_FILE);
|
||||
assert.equal((await engine.streamFn(engine.model, context).result()).stopReason, "error");
|
||||
release(sseText("original request finished"));
|
||||
const first = await pending.result();
|
||||
assert.equal(first.stopReason, "stop");
|
||||
assert.deepEqual(first.content, [{ type: "text", text: "original request finished" }]);
|
||||
} finally {
|
||||
release(sseText());
|
||||
await pending.result();
|
||||
globalThis.fetch = original;
|
||||
}
|
||||
assert.equal(requests.length, 3, "unavailable configurations must not cause additional transport calls");
|
||||
assert.equal(requests[0]!.url, "http://example.invalid/custom/v1/chat/completions");
|
||||
assert.equal(requests[0]!.headers.get("authorization"), "Bearer first-key");
|
||||
assert.equal(requests[0]!.headers.get("x-gateway"), "first-header");
|
||||
assert.equal(requests[1]!.url, "https://new.example.invalid/inference/v1/chat/completions");
|
||||
assert.equal(requests[1]!.headers.get("authorization"), "Bearer second-key");
|
||||
assert.equal(requests[1]!.headers.get("x-gateway"), "second-header");
|
||||
assert.equal(requests[2]!.headers.get("authorization"), "Bearer third-key");
|
||||
assert.equal(requests[0]!.body.model, "chat");
|
||||
assert.equal("store" in requests[0]!.body, false);
|
||||
assert.equal(requests[0]!.body.max_tokens, 1024);
|
||||
assert.equal(requests[1]!.body.max_tokens, 512, "current Pi model metadata, not the stale engine model, controls the request");
|
||||
});
|
||||
const thinking = (...levels: ThinkingLevel[]) => ({
|
||||
reasoning: true,
|
||||
// Pi treats omitted levels through high as supported; mark every
|
||||
// unselected level explicitly unsupported for these narrow fixtures.
|
||||
thinkingLevelMap: { off: null, minimal: null, low: null, medium: null, high: null, xhigh: null, max: null, ...Object.fromEntries(levels.map((level) => [level, level])) },
|
||||
});
|
||||
const thinkingCases: { name: string; preference?: ThinkingLevel; initial: object; updated: object; before: ThinkingLevel; after: ThinkingLevel }[] = [
|
||||
{ name: "recovers saved max after high-only metadata expands", preference: "max", initial: thinking("high"), updated: thinking("high", "max"), before: "high", after: "max" },
|
||||
{ name: "recovers saved max after reasoning-off metadata expands", preference: "max", initial: { reasoning: false }, updated: thinking("high", "max"), before: "off", after: "max" },
|
||||
{ name: "selects the existing medium-first default after reasoning becomes supported", initial: { reasoning: false }, updated: thinking("low", "medium", "high"), before: "off", after: "medium" },
|
||||
{ name: "reselects the existing low-before-high default against updated metadata", initial: thinking("high"), updated: thinking("low", "high"), before: "high", after: "low" },
|
||||
{ name: "clamps saved max when supported thinking contracts", preference: "max", initial: thinking("high", "max"), updated: thinking("high"), before: "max", after: "high" },
|
||||
{ name: "omits reasoning when the model loses thinking support", preference: "max", initial: thinking("high", "max"), updated: { reasoning: false }, before: "max", after: "off" },
|
||||
];
|
||||
for (const scenario of thinkingCases) it(scenario.name, async () => {
|
||||
const definition = (metadata: object) => config({ compat: { supportsReasoningEffort: true, thinkingFormat: "openai" } }, [model("chat", metadata)]);
|
||||
save(definition(scenario.initial));
|
||||
const engine = piEngineForSpec({ provider: "gateway", id: "chat", thinkingLevel: scenario.preference });
|
||||
assert.equal(engine.level, scenario.before);
|
||||
const requests: Record<string, unknown>[] = [];
|
||||
const original = globalThis.fetch;
|
||||
globalThis.fetch = async (input, init) => {
|
||||
const request = input instanceof Request ? input : new Request(input, init);
|
||||
requests.push(await request.json() as Record<string, unknown>);
|
||||
return sseText();
|
||||
};
|
||||
try {
|
||||
const context = normalizeContext({ messages: [{ role: "user", content: "hello", timestamp: 1 }] });
|
||||
// A long-lived agent still forwards its initial thinking setting.
|
||||
const options: SimpleStreamOptions = { reasoning: engine.level === "off" ? undefined : engine.level };
|
||||
assert.equal((await engine.streamFn(engine.model, context, options).result()).stopReason, "stop");
|
||||
save(definition(scenario.updated));
|
||||
assert.equal((await engine.streamFn(engine.model, context, options).result()).stopReason, "stop");
|
||||
} finally { globalThis.fetch = original; }
|
||||
assert.equal(requests.length, 2);
|
||||
for (const [index, level] of [scenario.before, scenario.after].entries()) {
|
||||
assert.equal(requests[index]!.reasoning_effort, level === "off" ? undefined : level);
|
||||
if (level === "off") assert.equal("reasoning_effort" in requests[index]!, false);
|
||||
}
|
||||
});
|
||||
it("disables custom routes on malformed edits without removing built-ins or saved choices", () => {
|
||||
fs.writeFileSync(SETTINGS_FILE, JSON.stringify({ version: 1, models: { strong: { model: { provider: "gateway", id: "chat" }, thinkingLevel: "high" } } }));
|
||||
const before = fs.readFileSync(SETTINGS_FILE, "utf8");
|
||||
fs.writeFileSync(CUSTOM_MODELS_FILE, '{ secret-token-do-not-log');
|
||||
const catalog = modelCatalog();
|
||||
assert.ok(catalog.customProvidersError);
|
||||
assert.equal(catalog.customProvidersError!.includes("secret-token"), false);
|
||||
assert.equal(piModels().getProvider("gateway"), undefined);
|
||||
assert.ok(piModels().getProvider("openai"));
|
||||
assert.deepEqual(catalog.slots.strong!.model, { provider: "gateway", id: "chat" });
|
||||
const stored = { strong: { model: { provider: "gateway", id: "chat" }, thinkingLevel: "high" as const } };
|
||||
const repaired = { strong: { ...stored.strong, thinkingLevel: "max" as const }, cheap: { model: { provider: "openai", id: piModels().getModels("openai")[0]!.id } } };
|
||||
assert.equal(validateModelsSettings(repaired, stored).ok, true, "an unavailable custom pin must not prevent independent slot repair");
|
||||
assert.equal(clampModelsLevels(repaired).strong!.thinkingLevel, "max", "preserve thinking while custom metadata is unavailable");
|
||||
assert.equal(validateModelsSettings({ cheap: stored.strong }, stored).ok, false, "unchanged identity preservation is same-slot only");
|
||||
assert.equal(fs.readFileSync(SETTINGS_FILE, "utf8"), before);
|
||||
assert.throws(() => piEngineForSpec({ provider: "gateway", id: "chat" }), /catalog has no model/);
|
||||
save(config());
|
||||
assert.equal(modelCatalog().customProvidersError, undefined);
|
||||
assert.ok(piModels().getProvider("gateway"));
|
||||
fs.rmSync(CUSTOM_MODELS_FILE);
|
||||
assert.equal(modelCatalog().models.some((m) => m.provider === "gateway"), false);
|
||||
assert.equal(modelCatalog().customProvidersError, undefined);
|
||||
assert.equal(fs.readFileSync(SETTINGS_FILE, "utf8"), before);
|
||||
});
|
||||
it("refreshes credentials without a file edit, preserving native header precedence and snapshots", async () => {
|
||||
const values = new Map([["KEY", "one"], ["HEADER", "first"]]);
|
||||
const source = new CustomProviderSource(() => ({ text: JSON.stringify(config({ apiKey: "$KEY", authHeader: true, headers: { "x-shared": "$HEADER" } }, [model("chat", { headers: { "x-shared": "model-${HEADER}" } })])) }), (name) => values.get(name));
|
||||
const models = createModels();
|
||||
source.sync(models);
|
||||
const captured = createModels();
|
||||
captured.setProvider(models.getProvider("gateway")!);
|
||||
values.set("KEY", "two"); values.set("HEADER", "second");
|
||||
source.sync(models);
|
||||
const current = (await models.getAuth(models.getModel("gateway", "chat")!))!.auth;
|
||||
const previous = (await captured.getAuth(captured.getModel("gateway", "chat")!))!.auth;
|
||||
assert.equal(current.apiKey, "two");
|
||||
assert.equal(current.headers!["x-shared"], "model-second");
|
||||
assert.equal(current.headers!.Authorization, "Bearer two");
|
||||
assert.equal(previous.apiKey, "one");
|
||||
assert.equal(previous.headers!["x-shared"], "model-first");
|
||||
});
|
||||
it("rejects built-in, subscription and native CLI collisions without replacing their providers", () => {
|
||||
for (const id of ["openai", "github-copilot", "chatgpt", "github-copilot-subscription", "xai-subscription", CLAUDE_CODE_PROVIDER]) {
|
||||
const existing = piModels().getProvider("openai")!;
|
||||
const nativeCli = piModels().getProvider(CLAUDE_CODE_PROVIDER)!;
|
||||
const models = createModels(); models.setProvider(existing); models.setProvider(nativeCli);
|
||||
const source = new CustomProviderSource(() => ({ text: JSON.stringify({ providers: { [id]: config().providers.gateway } }) }), () => undefined);
|
||||
source.sync(models);
|
||||
assert.ok(source.getError());
|
||||
assert.equal(models.getProvider("openai"), existing);
|
||||
assert.equal(models.getProvider(CLAUDE_CODE_PROVIDER), nativeCli);
|
||||
assert.equal(models.getProviders().length, 2);
|
||||
}
|
||||
});
|
||||
it("cannot bypass CLI opt-in or replace/remove the native provider across custom configuration reloads", (t) => {
|
||||
const nativeCli = piModels().getProvider(CLAUDE_CODE_PROVIDER)!;
|
||||
assert.ok(nativeCli);
|
||||
const aliases = piModels().getModels(CLAUDE_CODE_PROVIDER);
|
||||
const savedConfig = fs.existsSync(CUSTOM_MODELS_FILE) ? fs.readFileSync(CUSTOM_MODELS_FILE, "utf8") : undefined;
|
||||
const savedSettings = fs.existsSync(SETTINGS_FILE) ? fs.readFileSync(SETTINGS_FILE, "utf8") : undefined;
|
||||
t.after(() => {
|
||||
if (savedConfig === undefined) fs.rmSync(CUSTOM_MODELS_FILE, { force: true }); else fs.writeFileSync(CUSTOM_MODELS_FILE, savedConfig);
|
||||
if (savedSettings === undefined) fs.rmSync(SETTINGS_FILE, { force: true }); else fs.writeFileSync(SETTINGS_FILE, savedSettings);
|
||||
piModels();
|
||||
});
|
||||
fs.writeFileSync(SETTINGS_FILE, JSON.stringify({ version: 1, claudeCode: { enabled: false } }));
|
||||
const collision = { providers: { [CLAUDE_CODE_PROVIDER]: config().providers.gateway } };
|
||||
for (const [value, error] of [
|
||||
[collision, true], // Initial collision must never enter registered/ready.
|
||||
[config(), false], // A normal custom route still works.
|
||||
[collision, true], // Invalid reload retires custom routes, not native ones.
|
||||
["malformed configuration", true],
|
||||
[undefined, false], // Removing the file must not delete the native CLI.
|
||||
[config(), false],
|
||||
[undefined, false],
|
||||
] as const) {
|
||||
if (value === undefined) fs.rmSync(CUSTOM_MODELS_FILE, { force: true });
|
||||
else if (typeof value === "string") fs.writeFileSync(CUSTOM_MODELS_FILE, value);
|
||||
else save(value);
|
||||
const catalog = modelCatalog();
|
||||
assert.equal(Boolean(catalog.customProvidersError), error);
|
||||
assert.equal(customProviderConfigured(CLAUDE_CODE_PROVIDER), undefined);
|
||||
assert.equal(providerKeyPresent(CLAUDE_CODE_PROVIDER), false, "custom auth must never bypass disabled CLI admission");
|
||||
assert.equal(piModels().getProvider(CLAUDE_CODE_PROVIDER), nativeCli, "preserve the native provider identity, not a reconstructed substitute");
|
||||
assert.deepEqual(piModels().getModels(CLAUDE_CODE_PROVIDER), aliases);
|
||||
assert.equal(catalog.models.some((m) => m.provider === CLAUDE_CODE_PROVIDER), false);
|
||||
}
|
||||
});
|
||||
it("fails closed across partial publication and recovers without leaking internal errors", () => {
|
||||
const text = JSON.stringify({ providers: { ...config().providers, second: config().providers.gateway } });
|
||||
const source = new CustomProviderSource(() => ({ text }), () => undefined);
|
||||
const models = createModels();
|
||||
const builtin = piModels().getProvider("openai")!;
|
||||
models.setProvider(builtin);
|
||||
const publish = models.setProvider.bind(models);
|
||||
models.setProvider = (provider) => {
|
||||
if (provider.id === "second") throw new Error("private-value-do-not-log");
|
||||
publish(provider);
|
||||
};
|
||||
source.sync(models);
|
||||
assert.equal(models.getProvider("gateway"), undefined);
|
||||
assert.equal(models.getProvider("second"), undefined);
|
||||
assert.equal(models.getProvider("openai"), builtin);
|
||||
assert.equal(source.getError()!.includes("private-value"), false);
|
||||
models.setProvider = publish;
|
||||
source.sync(models);
|
||||
assert.ok(models.getProvider("gateway"));
|
||||
assert.ok(models.getProvider("second"));
|
||||
assert.equal(source.getError(), undefined);
|
||||
});
|
||||
it("retires old custom routes when server configuration bootstrap is reinstalled", () => {
|
||||
save(config());
|
||||
assert.ok(piModels().getProvider("gateway"));
|
||||
fs.rmSync(CUSTOM_MODELS_FILE);
|
||||
installCustomProviderSource(keyValue);
|
||||
assert.equal(piModels().getProvider("gateway"), undefined);
|
||||
assert.ok(piModels().getProvider("openai"));
|
||||
});
|
||||
it("treats unreadable config as unavailable and recovers without a restart", () => {
|
||||
let unreadable = true;
|
||||
const source = new CustomProviderSource(() => unreadable ? { error: "Unable to read models.json." } : { text: JSON.stringify(config()) }, () => undefined);
|
||||
const models = createModels();
|
||||
source.sync(models);
|
||||
assert.equal(source.getError(), "Unable to read models.json.");
|
||||
unreadable = false;
|
||||
source.sync(models);
|
||||
assert.ok(models.getProvider("gateway"));
|
||||
assert.equal(source.getError(), undefined);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
/** Synthetic authorized records, imported only after each test's isolated env. */
|
||||
import { randomBytes } from "node:crypto";
|
||||
import type { CustomProviderSettings } from "../../src/core/settings.ts";
|
||||
import { persistCustomAuth } from "../../src/server/custom-provider-auth.ts";
|
||||
|
||||
export function authorizeFixture(cp: CustomProviderSettings, fileKey?: string): CustomProviderSettings {
|
||||
const id = randomBytes(16).toString("hex");
|
||||
persistCustomAuth({ version: 1, id, baseUrl: cp.baseUrl, authMode: cp.authMode ?? "keyless", ...(fileKey ? { fileKeyBaseUrl: cp.baseUrl } : {}) }, fileKey);
|
||||
return { ...cp, credentialBindingId: id };
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/** Child-process fixture: owned profile only; every upstream fetch is synthetic. */
|
||||
import fs from "node:fs";
|
||||
import { AUTH_FILE, SETTINGS_FILE } from "../../config.ts";
|
||||
const [mode, action = "key", fault = "none"] = process.argv.slice(2);
|
||||
const requests: { url: string; authorization: string | null }[] = [];
|
||||
globalThis.fetch = async (url, init) => {
|
||||
requests.push({ url: String(url), authorization: new Headers(init?.headers).get("authorization") });
|
||||
if (String(url).endsWith("/models")) return Response.json({ data: [{ id: "m" }] });
|
||||
return new Response('data: {"choices":[{"index":0,"delta":{"role":"assistant","content":"ok"},"finish_reason":null}]}\n\ndata: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}\n\ndata: [DONE]\n\n', { headers: { "content-type": "text/event-stream" } });
|
||||
};
|
||||
const rename = fs.renameSync;
|
||||
const write = fs.writeFileSync;
|
||||
const read = fs.readFileSync;
|
||||
let authRenames = 0;
|
||||
fs.renameSync = (...args: Parameters<typeof rename>) => {
|
||||
if (args[1] === AUTH_FILE) {
|
||||
authRenames++;
|
||||
if (fault === "auth-before-rename" || (fault === "double-io" && authRenames > 1)) throw new Error("fixture auth I/O failure");
|
||||
rename(...args);
|
||||
if (fault === "after-auth") process.exit(77);
|
||||
return;
|
||||
}
|
||||
return rename(...args);
|
||||
};
|
||||
fs.writeFileSync = (...args: Parameters<typeof write>) => {
|
||||
if (args[0] === SETTINGS_FILE && fault === "double-io") throw new Error("fixture settings I/O failure");
|
||||
if (args[0] === SETTINGS_FILE && fault === "settings-landed") { write(...args); throw new Error("fixture ambiguous settings completion"); }
|
||||
return write(...args);
|
||||
};
|
||||
fs.readFileSync = ((...args: Parameters<typeof read>) => {
|
||||
if (args[0] === AUTH_FILE && fault === "unreadable") throw Object.assign(new Error("fixture unreadable auth"), { code: "EACCES" });
|
||||
return read(...args);
|
||||
}) as typeof read;
|
||||
await import("../../src/server/keys.ts");
|
||||
if (mode === "inspect") {
|
||||
const { piModels, piEngineForSpec } = await import("../../src/agent/models.ts");
|
||||
const { normalizeContext } = await import("@earendil-works/pi-ai");
|
||||
const enabled = Boolean(piModels().getProvider("custom"));
|
||||
let reason: string | undefined;
|
||||
try {
|
||||
const engine = piEngineForSpec({ provider: "custom", id: "m" });
|
||||
reason = (await engine.streamFn(engine.model, normalizeContext({ messages: [{ role: "user", content: "fixture", timestamp: 0 }] }), {}).result()).stopReason;
|
||||
} catch { reason = "refused"; }
|
||||
const { keyStatuses } = await import("../../src/server/keys.ts");
|
||||
console.log(JSON.stringify({ enabled, reason, requests, statusIds: keyStatuses().map(row => row.id), metadataInEnv: process.env.__learnharness_custom_provider_binding !== undefined }));
|
||||
} else {
|
||||
const route = await import("../../app/api/custom-provider/route.ts");
|
||||
const get = route.GET(new Request("http://localhost/api/custom-provider"));
|
||||
const payload = action === "keyless" ? { baseUrl: "http://second.invalid/v1", authMode: "keyless" }
|
||||
: action === "envMove" ? { baseUrl: "http://second.invalid/v1", authMode: "apiKey", reuseKey: true }
|
||||
: action === "reuse" ? { baseUrl: "http://first.invalid/v1", authMode: "apiKey", reuseKey: true }
|
||||
: { baseUrl: "http://second.invalid/v1", authMode: "apiKey", key: "fixture-new-key" };
|
||||
const request = new Request("http://localhost/api/custom-provider", { method: action === "disconnect" ? "DELETE" : "POST", headers: { origin: "http://localhost", "content-type": "application/json", "if-match": get.headers.get("etag")! }, body: JSON.stringify(payload) });
|
||||
const result = await route.POST(request);
|
||||
console.log(JSON.stringify({ status: result.status, body: await result.json(), requests }));
|
||||
}
|
||||
Reference in New Issue
Block a user