mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-25 08:32:11 +03:00
fix(providers): reject reserved provider prefixes on compatible-node create/update
A compatible node created with prefix "tokenrouter" was silently unreachable: the runtime model resolver (src/sse/services/model.ts) skips compatible-node lookup for built-in registry ids/aliases, so "tokenrouter/qwen/..." routed to the built-in tokenrouter provider and failed with "No active credentials for provider: tokenrouter" even though the node itself worked when addressed by its internal id. Reject reserved prefixes at the write path instead: - new shared module src/shared/constants/reservedProviderPrefixes.ts (REGISTRY ids + aliases, case-sensitive, built lazily) — single source of truth consumed by both the runtime guard and the validation schemas so they can never drift apart - createProviderNodeSchema / updateProviderNodeSchema now reject reserved prefixes with a clear message naming the colliding prefix - src/sse/services/model.ts consumes the shared module; runtime behavior is byte-for-byte unchanged (verified e2e) Set semantics mirror the old inline guard exactly: manual alias ids outside REGISTRY (xiaomi/llamacpp/aq) do not intercept nodes at runtime and stay allowed; mixed-case input (TokenRouter) does not collide with the exact-match runtime lookup either.
This commit is contained in:
70
src/shared/constants/reservedProviderPrefixes.ts
Normal file
70
src/shared/constants/reservedProviderPrefixes.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
// Reserved provider prefixes — single source of truth shared by:
|
||||
//
|
||||
// 1. The runtime model resolver guard (src/sse/services/model.ts): user-defined
|
||||
// compatible-node prefixes must not be allowed to shadow built-in provider
|
||||
// ids/aliases, otherwise a node with prefix="cf" would hijack cloudflare-ai
|
||||
// requests (ported from upstream 9router 047fdc89).
|
||||
// 2. The write-path validation schemas (createProviderNodeSchema /
|
||||
// updateProviderNodeSchema in src/shared/validation/schemas/provider.ts):
|
||||
// a prefix that the runtime will never honor must be rejected at creation
|
||||
// time with a clear message instead of silently routing to the built-in
|
||||
// provider (tokenrouter bug: "No active credentials for provider:
|
||||
// tokenrouter" despite a fully configured compatible node).
|
||||
//
|
||||
// Semantics (mirror the original inline runtime guard exactly):
|
||||
// - REGISTRY entry ids + aliases only. Manual alias ids outside REGISTRY
|
||||
// (xiaomi/llamacpp/aq) do NOT intercept nodes at runtime and are therefore
|
||||
// deliberately NOT reserved — including them would cause false-positive
|
||||
// rejections.
|
||||
// - Case-sensitive: mixed-case input like "TokenRouter" does not collide with
|
||||
// the runtime lookup (`Set.has` is exact-match), so it stays allowed.
|
||||
import { REGISTRY } from "@omniroute/open-sse/config/providerRegistry.ts";
|
||||
|
||||
let _reserved: Set<string> | null = null;
|
||||
|
||||
function buildReservedProviderPrefixes(): Set<string> {
|
||||
if (_reserved) return _reserved;
|
||||
const reserved = new Set<string>();
|
||||
for (const entry of Object.values(REGISTRY)) {
|
||||
if (entry?.id) reserved.add(entry.id);
|
||||
if (entry?.alias) reserved.add(entry.alias);
|
||||
}
|
||||
_reserved = reserved;
|
||||
return reserved;
|
||||
}
|
||||
|
||||
/**
|
||||
* All reserved provider prefixes (REGISTRY ids + aliases). Built lazily so the
|
||||
* registry is only walked once per process.
|
||||
*/
|
||||
export function getReservedProviderPrefixes(): ReadonlySet<string> {
|
||||
return buildReservedProviderPrefixes();
|
||||
}
|
||||
|
||||
/**
|
||||
* Number of unique reserved prefixes (ids + aliases deduplicated). Exposed for
|
||||
* tests/docs so counts are measured, not memorized.
|
||||
*/
|
||||
export const RESERVED_PREFIX_COUNT = buildReservedProviderPrefixes().size;
|
||||
|
||||
/**
|
||||
* Frozen snapshot of the reserved set (test/documentation convenience). Prefer
|
||||
* `isReservedProviderPrefix` / `getReservedProviderPrefixes` on hot paths.
|
||||
*/
|
||||
export const RESERVED_PROVIDER_PREFIXES: ReadonlySet<string> = getReservedProviderPrefixes();
|
||||
|
||||
/**
|
||||
* True when `value` is a reserved provider prefix. Non-strings are never
|
||||
* reserved (mirrors the runtime guard's typeof check).
|
||||
*/
|
||||
export function isReservedProviderPrefix(value: unknown): boolean {
|
||||
return typeof value === "string" && buildReservedProviderPrefixes().has(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zod-friendly rejection message for a reserved prefix. Names the colliding
|
||||
* prefix and tells the operator what to pick instead.
|
||||
*/
|
||||
export function reservedProviderPrefixMessage(value: string): string {
|
||||
return `"${value}" is a reserved provider prefix — choose a different prefix (reserved ids/aliases cannot be used for custom nodes because requests like <prefix>/model would always route to the built-in provider)`;
|
||||
}
|
||||
@@ -14,6 +14,10 @@ import {
|
||||
} from "@/shared/constants/upstreamHeaders";
|
||||
import { MAX_TIMER_TIMEOUT_MS } from "@/shared/utils/runtimeTimeouts";
|
||||
import { validateProviderSpecificData } from "@/shared/validation/providerSpecificData";
|
||||
import {
|
||||
isReservedProviderPrefix,
|
||||
reservedProviderPrefixMessage,
|
||||
} from "@/shared/constants/reservedProviderPrefixes";
|
||||
|
||||
import {
|
||||
upstreamHeadersRecordSchema,
|
||||
@@ -367,6 +371,17 @@ export const createProviderNodeSchema = z
|
||||
message: "Prefix is required",
|
||||
path: ["prefix"],
|
||||
});
|
||||
} else if (isReservedProviderPrefix(value.prefix.trim())) {
|
||||
// Reserved-prefix guard (tokenrouter bug): the runtime model resolver skips
|
||||
// compatible-node lookup for built-in registry ids/aliases, so a node
|
||||
// created with such a prefix could never be reached by it and silently
|
||||
// routed requests to the built-in provider instead. Reject at the write
|
||||
// path. Case-sensitive to match the runtime guard exactly.
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: reservedProviderPrefixMessage(value.prefix.trim()),
|
||||
path: ["prefix"],
|
||||
});
|
||||
}
|
||||
if (nodeType === "openai-compatible" && !value.apiType) {
|
||||
ctx.addIssue({
|
||||
@@ -377,27 +392,40 @@ export const createProviderNodeSchema = z
|
||||
}
|
||||
});
|
||||
|
||||
export const updateProviderNodeSchema = z.object({
|
||||
name: z.string().trim().min(1, "Name is required"),
|
||||
prefix: z.string().trim().min(1, "Prefix is required"),
|
||||
apiType: z
|
||||
.enum([
|
||||
"chat",
|
||||
"responses",
|
||||
"embeddings",
|
||||
"audio-transcriptions",
|
||||
"audio-speech",
|
||||
"images-generations",
|
||||
])
|
||||
.optional(),
|
||||
baseUrl: z.string().trim().min(1, "Base URL is required"),
|
||||
chatPath: z.string().trim().startsWith("/").max(500).optional().or(z.literal("")),
|
||||
modelsPath: z.string().trim().startsWith("/").max(500).optional().or(z.literal("")),
|
||||
// #2166: same optional remote icon URL as createProviderNodeSchema — empty string
|
||||
// clears a previously stored custom icon.
|
||||
iconUrl: providerNodeIconUrlSchema,
|
||||
customHeaders: customHeadersSchema,
|
||||
});
|
||||
export const updateProviderNodeSchema = z
|
||||
.object({
|
||||
name: z.string().trim().min(1, "Name is required"),
|
||||
prefix: z.string().trim().min(1, "Prefix is required"),
|
||||
apiType: z
|
||||
.enum([
|
||||
"chat",
|
||||
"responses",
|
||||
"embeddings",
|
||||
"audio-transcriptions",
|
||||
"audio-speech",
|
||||
"images-generations",
|
||||
])
|
||||
.optional(),
|
||||
baseUrl: z.string().trim().min(1, "Base URL is required"),
|
||||
chatPath: z.string().trim().startsWith("/").max(500).optional().or(z.literal("")),
|
||||
modelsPath: z.string().trim().startsWith("/").max(500).optional().or(z.literal("")),
|
||||
// #2166: same optional remote icon URL as createProviderNodeSchema — empty string
|
||||
// clears a previously stored custom icon.
|
||||
iconUrl: providerNodeIconUrlSchema,
|
||||
customHeaders: customHeadersSchema,
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
// Reserved-prefix guard (tokenrouter bug) — same rationale as the guard in
|
||||
// createProviderNodeSchema: renaming a node's prefix onto a built-in
|
||||
// registry id/alias would make it unreachable via that prefix.
|
||||
if (isReservedProviderPrefix(value.prefix)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: reservedProviderPrefixMessage(value.prefix),
|
||||
path: ["prefix"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export const providerNodeValidateSchema = z.object({
|
||||
baseUrl: z.string().trim().min(1, "Base URL and API key required"),
|
||||
|
||||
@@ -20,29 +20,10 @@ import {
|
||||
import { getLearnedReasoningEffortForModel } from "@omniroute/open-sse/services/learnedReasoningEffortCaps.ts";
|
||||
import { REGISTRY } from "@omniroute/open-sse/config/providerRegistry.ts";
|
||||
import { getRegisteredProviderEffortBaseModelId } from "@omniroute/open-sse/utils/registeredEffortVariants.ts";
|
||||
import { getReservedProviderPrefixes } from "@/shared/constants/reservedProviderPrefixes";
|
||||
|
||||
export { parseModel, stripContextWindowSuffix };
|
||||
|
||||
/**
|
||||
* Reserved provider prefixes — built-in provider ids + aliases. User-defined
|
||||
* compatible-node prefixes must not be allowed to shadow these, otherwise a
|
||||
* node with prefix="cf" would hijack cloudflare-ai requests (and similar for
|
||||
* every built-in provider). Ported from upstream 9router 047fdc89.
|
||||
*
|
||||
* Built lazily so the registry is only walked once per process.
|
||||
*/
|
||||
let _reservedProviderPrefixes: Set<string> | null = null;
|
||||
function getReservedProviderPrefixes(): Set<string> {
|
||||
if (_reservedProviderPrefixes) return _reservedProviderPrefixes;
|
||||
const reserved = new Set<string>();
|
||||
for (const entry of Object.values(REGISTRY)) {
|
||||
if (entry?.id) reserved.add(entry.id);
|
||||
if (entry?.alias) reserved.add(entry.alias);
|
||||
}
|
||||
_reservedProviderPrefixes = reserved;
|
||||
return reserved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold `settings.wildcardAliases` ({pattern,target}[]) — the store the Settings
|
||||
* UI's "Wildcard Pattern" mode writes to (ModelAliasesUnified.tsx::addWildcardAlias
|
||||
@@ -460,9 +441,11 @@ export async function getModelInfo(modelStr) {
|
||||
// node prefix lookup so the request still routes to the built-in provider.
|
||||
// Internal UUID-prefixed node ids (e.g. "openai-compatible-responses-...")
|
||||
// are never in the reserved set, so the #2778 combo path still works.
|
||||
// Ported from upstream 9router 047fdc89.
|
||||
const reserved = getReservedProviderPrefixes();
|
||||
const isReservedPrefix = typeof prefixToCheck === "string" && reserved.has(prefixToCheck);
|
||||
// Ported from upstream 9router 047fdc89. Set shared with the write-path
|
||||
// validation guard (src/shared/constants/reservedProviderPrefixes.ts) so
|
||||
// both sides can never drift apart.
|
||||
const isReservedPrefix =
|
||||
typeof prefixToCheck === "string" && getReservedProviderPrefixes().has(prefixToCheck);
|
||||
|
||||
if (!isReservedPrefix) {
|
||||
// Check OpenAI Compatible nodes
|
||||
|
||||
254
tests/unit/provider-node-reserved-prefix.test.ts
Normal file
254
tests/unit/provider-node-reserved-prefix.test.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
// Reserved provider prefixes — compatible-node prefix guard (TDD, tokenrouter bug).
|
||||
//
|
||||
// Bug: an operator-created openai-compatible node with prefix "tokenrouter" was
|
||||
// accepted at creation time, but the runtime model resolver
|
||||
// (src/sse/services/model.ts) treats built-in registry ids/aliases as reserved
|
||||
// and skips the node lookup — so `tokenrouter/qwen/...` routed to the BUILT-IN
|
||||
// tokenrouter provider ("No active credentials for provider: tokenrouter")
|
||||
// instead of the operator's node. The same node addressed by its internal id
|
||||
// worked fine. Fix: reject reserved prefixes at the write path (node
|
||||
// create/update schemas) so the misconfiguration can no longer be created.
|
||||
//
|
||||
// The reserved set is shared between the runtime guard and the validation
|
||||
// schemas via src/shared/constants/reservedProviderPrefixes.ts (single source of
|
||||
// truth). Set semantics mirror the old inline guard exactly:
|
||||
// - REGISTRY entry ids + aliases only;
|
||||
// - case-sensitive (mixed-case "TokenRouter" does NOT collide at runtime);
|
||||
// - manual alias ids that live outside REGISTRY (xiaomi/llamacpp/aq) are NOT
|
||||
// included — verified they do not intercept nodes at runtime.
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-reserved-prefix-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const providerNodesRoute = await import("../../src/app/api/provider-nodes/route.ts");
|
||||
const providerNodesIdRoute = await import("../../src/app/api/provider-nodes/[id]/route.ts");
|
||||
const { createProviderNodeSchema, updateProviderNodeSchema } =
|
||||
await import("../../src/shared/validation/schemas.ts");
|
||||
const { RESERVED_PROVIDER_PREFIXES, isReservedProviderPrefix, RESERVED_PREFIX_COUNT } =
|
||||
await import("../../src/shared/constants/reservedProviderPrefixes.ts");
|
||||
|
||||
async function resetStorage() {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
// Minimal response-body shapes (no `any` — new eslint violations must be fixed,
|
||||
// not suppressed). `unknown` fields are narrowed through helpers before use.
|
||||
type ValidationDetail = { field: string; message: string };
|
||||
type ValidationBody = { error?: { details?: ValidationDetail[] } };
|
||||
type NodeBody = { node?: { id?: string; prefix?: string } };
|
||||
|
||||
function asValidationBody(value: unknown): ValidationBody {
|
||||
return value && typeof value === "object" ? (value as ValidationBody) : {};
|
||||
}
|
||||
|
||||
function asNodeBody(value: unknown): NodeBody {
|
||||
return value && typeof value === "object" ? (value as NodeBody) : {};
|
||||
}
|
||||
|
||||
function findPrefixDetail(body: unknown): ValidationDetail | undefined {
|
||||
const details = asValidationBody(body).error?.details ?? [];
|
||||
return details.find((d) => d.field === "prefix");
|
||||
}
|
||||
|
||||
function makeCreateRequest(body: Record<string, unknown>) {
|
||||
return new Request("http://localhost/api/provider-nodes", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
function makeUpdateRequest(id: string, body: Record<string, unknown>) {
|
||||
return new Request(`http://localhost/api/provider-nodes/${id}`, {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await resetStorage();
|
||||
});
|
||||
|
||||
test.after(async () => {
|
||||
await resetStorage();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// ──── Shared module ────
|
||||
|
||||
test("shared set contains REGISTRY ids and aliases (tokenrouter + trk)", () => {
|
||||
assert.equal(RESERVED_PROVIDER_PREFIXES.has("tokenrouter"), true);
|
||||
assert.equal(RESERVED_PROVIDER_PREFIXES.has("trk"), true);
|
||||
});
|
||||
|
||||
test("shared set is case-sensitive like the runtime guard", () => {
|
||||
assert.equal(isReservedProviderPrefix("TokenRouter"), false);
|
||||
assert.equal(isReservedProviderPrefix("TOKENROUTER"), false);
|
||||
assert.equal(isReservedProviderPrefix("tokenrouter"), true);
|
||||
});
|
||||
|
||||
test("shared set excludes manual aliases that never intercept nodes at runtime", () => {
|
||||
// Verified against src/sse/services/model.ts behavior: xiaomi/llamacpp/aq are
|
||||
// not REGISTRY members and do NOT shadow compatible nodes, so rejecting them
|
||||
// would be a false positive.
|
||||
assert.equal(RESERVED_PROVIDER_PREFIXES.has("qwen"), false);
|
||||
assert.equal(RESERVED_PROVIDER_PREFIXES.has("xiaomi"), false);
|
||||
assert.equal(RESERVED_PROVIDER_PREFIXES.has("llamacpp"), false);
|
||||
assert.equal(RESERVED_PROVIDER_PREFIXES.has("aq"), false);
|
||||
});
|
||||
|
||||
test("shared set size matches full REGISTRY scan (329 unique prefixes)", () => {
|
||||
assert.equal(RESERVED_PREFIX_COUNT, 329);
|
||||
});
|
||||
|
||||
test("isReservedProviderPrefix rejects non-string input", () => {
|
||||
assert.equal(isReservedProviderPrefix(undefined), false);
|
||||
assert.equal(isReservedProviderPrefix(null), false);
|
||||
assert.equal(isReservedProviderPrefix(42), false);
|
||||
});
|
||||
|
||||
// ──── Schema-level guard ────
|
||||
|
||||
test("createProviderNodeSchema rejects reserved prefix 'tokenrouter'", () => {
|
||||
const result = createProviderNodeSchema.safeParse({
|
||||
name: "TokenRouter Node",
|
||||
prefix: "tokenrouter",
|
||||
apiType: "chat",
|
||||
baseUrl: "https://api.tokenrouter.com/v1",
|
||||
});
|
||||
assert.equal(result.success, false);
|
||||
if (!result.success) {
|
||||
const prefixIssue = result.error.issues.find((i) => i.path[0] === "prefix");
|
||||
assert.ok(prefixIssue, "expected a 'prefix' issue");
|
||||
assert.match(prefixIssue.message, /reserved/i);
|
||||
assert.match(prefixIssue.message, /tokenrouter/);
|
||||
}
|
||||
});
|
||||
|
||||
test("createProviderNodeSchema rejects reserved alias 'trk'", () => {
|
||||
const result = createProviderNodeSchema.safeParse({
|
||||
name: "TRK Node",
|
||||
prefix: "trk",
|
||||
apiType: "chat",
|
||||
});
|
||||
assert.equal(result.success, false);
|
||||
});
|
||||
|
||||
test("createProviderNodeSchema accepts mixed-case 'TokenRouter' (no runtime collision)", () => {
|
||||
const result = createProviderNodeSchema.safeParse({
|
||||
name: "Case Test",
|
||||
prefix: "TokenRouter",
|
||||
apiType: "chat",
|
||||
});
|
||||
assert.equal(result.success, true);
|
||||
});
|
||||
|
||||
test("createProviderNodeSchema accepts non-reserved prefixes", () => {
|
||||
for (const prefix of ["my-gateway", "llamacpp", "aq", "xiaomi"]) {
|
||||
const result = createProviderNodeSchema.safeParse({
|
||||
name: "Free Prefix",
|
||||
prefix,
|
||||
apiType: "chat",
|
||||
});
|
||||
assert.equal(result.success, true, `prefix "${prefix}" should be accepted`);
|
||||
}
|
||||
});
|
||||
|
||||
test("updateProviderNodeSchema rejects reserved prefix", () => {
|
||||
const result = updateProviderNodeSchema.safeParse({
|
||||
name: "Renamed",
|
||||
prefix: "openai",
|
||||
});
|
||||
assert.equal(result.success, false);
|
||||
});
|
||||
|
||||
test("updateProviderNodeSchema accepts non-reserved prefix", () => {
|
||||
const result = updateProviderNodeSchema.safeParse({
|
||||
name: "Renamed",
|
||||
prefix: "still-fine",
|
||||
baseUrl: "https://renamed.example.com/v1",
|
||||
});
|
||||
assert.equal(result.success, true);
|
||||
});
|
||||
|
||||
// ──── Route-level guard (POST /api/provider-nodes) ────
|
||||
|
||||
test("provider nodes route returns 400 with prefix issue for reserved prefix", async () => {
|
||||
const response = await providerNodesRoute.POST(
|
||||
makeCreateRequest({
|
||||
name: "TokenRouter Node",
|
||||
prefix: "tokenrouter",
|
||||
apiType: "chat",
|
||||
baseUrl: "https://api.tokenrouter.com/v1",
|
||||
})
|
||||
);
|
||||
assert.equal(response.status, 400);
|
||||
const detail = findPrefixDetail(await response.json());
|
||||
assert.ok(detail, "expected a prefix validation detail");
|
||||
assert.match(detail.message, /reserved/i);
|
||||
});
|
||||
|
||||
test("provider nodes route still creates non-reserved nodes", async () => {
|
||||
const response = await providerNodesRoute.POST(
|
||||
makeCreateRequest({
|
||||
name: "Good Node",
|
||||
prefix: "good-node",
|
||||
apiType: "chat",
|
||||
baseUrl: "https://good.example.com/v1",
|
||||
})
|
||||
);
|
||||
assert.equal(response.status, 201);
|
||||
const body = asNodeBody(await response.json());
|
||||
assert.equal(body.node?.prefix, "good-node");
|
||||
});
|
||||
|
||||
// ──── Route-level guard (PUT /api/provider-nodes/[id]) ────
|
||||
|
||||
test("provider nodes update route rejects renaming prefix to a reserved one", async () => {
|
||||
const createResponse = await providerNodesRoute.POST(
|
||||
makeCreateRequest({
|
||||
name: "Original Node",
|
||||
prefix: "original-prefix",
|
||||
apiType: "chat",
|
||||
baseUrl: "https://original.example.com/v1",
|
||||
})
|
||||
);
|
||||
const created = asNodeBody(await createResponse.json());
|
||||
const nodeId = created.node?.id ?? "";
|
||||
|
||||
const updateResponse = await providerNodesIdRoute.PUT(
|
||||
makeUpdateRequest(nodeId, {
|
||||
name: "Hijacked",
|
||||
prefix: "anthropic",
|
||||
baseUrl: "https://hijack.example.com/v1",
|
||||
}),
|
||||
{ params: Promise.resolve({ id: nodeId }) }
|
||||
);
|
||||
assert.equal(updateResponse.status, 400);
|
||||
const detail = findPrefixDetail(await updateResponse.json());
|
||||
assert.ok(detail, "expected a prefix validation detail");
|
||||
assert.match(detail.message, /reserved/i);
|
||||
|
||||
// The node keeps its original prefix.
|
||||
const after = await providerNodesIdRoute.PUT(
|
||||
makeUpdateRequest(nodeId, {
|
||||
name: "Still Original",
|
||||
prefix: "original-prefix",
|
||||
apiType: "chat",
|
||||
baseUrl: "https://original.example.com/v1",
|
||||
}),
|
||||
{ params: Promise.resolve({ id: nodeId }) }
|
||||
);
|
||||
assert.equal(after.status, 200);
|
||||
const afterBody = asNodeBody(await after.json());
|
||||
assert.equal(afterBody.node?.prefix, "original-prefix");
|
||||
});
|
||||
Reference in New Issue
Block a user