From 8e9cd6e6f0dbbbff7e8fc6a8c38792e6aab8cc92 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Wed, 7 Oct 2026 18:54:24 +0200 Subject: [PATCH] feat(appkit): move serving calls to the workspace client request seam Serving invoke, SSE streaming and the typegen OpenAPI fetch now go through the facade's modular request() instead of the legacy SDK. - invoke keeps the legacy query behavior: same request-field whitelist, raw JSON response (no unmarshal, so model-specific fields survive), the served-model-name header merge, and the same JSON parse error. - streamPath uses client.request when present and keeps apiClient.request for caller-supplied legacy clients (public WorkspaceClientLike). - The seam's toApiError now matches the legacy parseErrorFromResponse message/code, and Response carries the reason phrase it needs. @databricks/sdk-modelserving is not used: it has no query method, and its schema getter throws sdk-core ApiError, which the typegen 404/403 branches can't read. The servingEndpoints accessor stays legacy to keep its public type (query/getOpenApi) intact. Co-authored-by: Isaac Signed-off-by: MarioCadenas --- .../appkit/src/connectors/serving/client.ts | 98 ++++++++++-- .../connectors/serving/tests/client.test.ts | 142 +++++++++++++++--- .../agent/tests/run-agent-identity.test.ts | 8 +- .../src/core/tests/appkit-user-scope.test.ts | 10 +- .../src/type-generator/serving/fetcher.ts | 12 +- .../serving/tests/fetcher.test.ts | 55 +++---- .../shared/src/workspace-client/modular.ts | 37 ++++- .../workspace-client/tests/modular.test.ts | 27 ++++ 8 files changed, 306 insertions(+), 83 deletions(-) diff --git a/packages/appkit/src/connectors/serving/client.ts b/packages/appkit/src/connectors/serving/client.ts index 60d3cdae0..3a9b44a80 100644 --- a/packages/appkit/src/connectors/serving/client.ts +++ b/packages/appkit/src/connectors/serving/client.ts @@ -1,13 +1,19 @@ import { createLogger } from "../../logging/logger"; -import type { serving, WorkspaceClient } from "../../workspace-client"; +import type { + serving, + WorkspaceClient, + WorkspaceRequest, +} from "../../workspace-client"; import { contextFromAbortSignal } from "../context"; const logger = createLogger("connectors:serving"); /** * Structural shape of a Databricks SDK client we need for the low-level - * `apiClient.request` call. Lets `streamPath` be reused by adapters that - * don't want a hard dependency on the concrete `WorkspaceClient` type. + * request call. Lets `streamPath` be reused by adapters that don't want a + * hard dependency on the concrete `WorkspaceClient` type. AppKit's own client + * provides `request` (modular transport); a caller-supplied legacy SDK client + * only has `apiClient.request`, which stays supported. */ export interface ApiClientLike { apiClient: { @@ -16,8 +22,29 @@ export interface ApiClientLike { context?: unknown, ): Promise; }; + request?(req: WorkspaceRequest): Promise; } +// The legacy SDK's `servingEndpoints.query` copied only these fields into the +// request body and dropped the rest; kept so invocations send the same payload. +const QUERY_FIELDS = [ + "client_request_id", + "dataframe_records", + "dataframe_split", + "extra_params", + "input", + "inputs", + "instances", + "max_tokens", + "messages", + "n", + "prompt", + "stop", + "stream", + "temperature", + "usage_context", +]; + /** * Transport shim shared by the agent adapters: given a request body, returns * the raw SSE byte stream from a serving / AI-gateway endpoint. Injected at @@ -31,8 +58,12 @@ export type StreamBody = ( ) => Promise>; /** - * Invokes a serving endpoint using the SDK's high-level query API. - * Returns a typed QueryEndpointResponse. + * Invokes a serving endpoint. Returns the endpoint's JSON response as-is + * (model-specific: chat, completions, embeddings, custom), plus the + * `served-model-name` response header when present, like the legacy SDK's + * `servingEndpoints.query`. Sent raw via `client.request` because the modular + * serving SDK has no query method, and a generated unmarshal would strip + * model-specific fields. */ export async function invoke( client: WorkspaceClient, @@ -44,22 +75,44 @@ export async function invoke( logger.debug("Invoking endpoint %s", endpointName); - return client.servingEndpoints.query({ - name: endpointName, - ...cleanBody, - } as serving.QueryEndpointInput); + const payload: Record = {}; + for (const key of QUERY_FIELDS) { + if (Object.hasOwn(cleanBody, key)) payload[key] = cleanBody[key]; + } + + const response = await client.request({ + method: "POST", + path: `/serving-endpoints/${endpointName}/invocations`, + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + }); + + const text = await response.text(); + let json: serving.QueryEndpointResponse; + try { + json = text.length === 0 ? {} : JSON.parse(text); + } catch { + // Same message (and typo) as the legacy SDK. + throw new Error(`Can't parse reponse as JSON: ${text}`); + } + const servedModelName = response.headers.get("served-model-name"); + return servedModelName === null + ? json + : { ...json, "served-model-name": servedModelName }; } /** * POSTs `body` as JSON to an arbitrary workspace API path and returns the raw * SSE byte stream. No parsing is performed — bytes are passed through as-is. * - * Uses the SDK's low-level `apiClient.request({ raw: true })` so callers - * inherit URL resolution, the SDK credential chain (PAT/OAuth/OIDC), and - * any future retries/telemetry baked into the SDK transport. + * Uses the client's `request` (modular transport) when available, else the + * legacy SDK's `apiClient.request({ raw: true })`, so callers inherit URL + * resolution and the SDK credential chain (PAT/OAuth/OIDC). * - * When `signal` is provided it is bridged to the SDK's `Context` / - * `CancellationToken` so aborts cancel the outbound HTTP request. + * When `signal` is provided it aborts the outbound HTTP request. * * @internal * @@ -78,6 +131,23 @@ export async function streamPath( ): Promise> { logger.debug("Streaming from path %s", path); + if (client.request) { + const response = await client.request({ + method: "POST", + path, + headers: { + "Content-Type": "application/json", + Accept: "text/event-stream", + }, + body: JSON.stringify(body), + signal, + }); + if (!response.body) { + throw new Error("Response body is null — streaming not supported"); + } + return response.body; + } + const context = contextFromAbortSignal(signal); const response = (await client.apiClient.request( diff --git a/packages/appkit/src/connectors/serving/tests/client.test.ts b/packages/appkit/src/connectors/serving/tests/client.test.ts index 67be06bbe..9b316080a 100644 --- a/packages/appkit/src/connectors/serving/tests/client.test.ts +++ b/packages/appkit/src/connectors/serving/tests/client.test.ts @@ -6,42 +6,59 @@ import { invoke, stream } from "../client"; function createMockClient(host = "https://test.databricks.com") { return { config: { host }, - servingEndpoints: { - query: vi.fn(), - }, + request: vi.fn(), apiClient: { request: vi.fn(), }, } as any; } +function createLegacyClient() { + return { apiClient: { request: vi.fn() } } as any; +} + +function jsonResponse(body: unknown, headers?: Record) { + return new Response(JSON.stringify(body), { headers }); +} + +function sentBody(client: any) { + return JSON.parse(client.request.mock.calls[0][0].body); +} + describe("Serving Connector", () => { afterEach(() => { vi.restoreAllMocks(); }); describe("invoke", () => { - test("calls servingEndpoints.query with endpoint name and body", async () => { + test("POSTs the body to the endpoint's invocations path", async () => { const client = createMockClient(); const mockResponse = { choices: [{ message: { content: "Hello" } }] }; - client.servingEndpoints.query.mockResolvedValue(mockResponse); + client.request.mockResolvedValue(jsonResponse(mockResponse)); const result = await invoke(client, "my-endpoint", { messages: [{ role: "user", content: "Hi" }], temperature: 0.7, }); - expect(client.servingEndpoints.query).toHaveBeenCalledWith({ - name: "my-endpoint", - messages: [{ role: "user", content: "Hi" }], - temperature: 0.7, + expect(client.request).toHaveBeenCalledWith({ + method: "POST", + path: "/serving-endpoints/my-endpoint/invocations", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + messages: [{ role: "user", content: "Hi" }], + temperature: 0.7, + }), }); expect(result).toEqual(mockResponse); }); test("strips stream property from body", async () => { const client = createMockClient(); - client.servingEndpoints.query.mockResolvedValue({}); + client.request.mockResolvedValue(jsonResponse({})); await invoke(client, "my-endpoint", { messages: [], @@ -49,36 +66,117 @@ describe("Serving Connector", () => { temperature: 0.7, }); - const queryArg = client.servingEndpoints.query.mock.calls[0][0]; + const queryArg = sentBody(client); expect(queryArg.stream).toBeUndefined(); expect(queryArg.temperature).toBe(0.7); }); - test("returns typed QueryEndpointResponse", async () => { + // The legacy SDK's query() copied only its known request fields. + test("sends only the fields the legacy query sent", async () => { + const client = createMockClient(); + client.request.mockResolvedValue(jsonResponse({})); + + await invoke(client, "my-endpoint", { + messages: [], + max_tokens: 5, + top_p: 0.9, + }); + + expect(sentBody(client)).toEqual({ messages: [], max_tokens: 5 }); + }); + + // Responses are model-specific: nothing may be stripped. + test("returns the raw JSON response, unknown fields included", async () => { const client = createMockClient(); const responseData = { choices: [{ message: { content: "Hello" } }], + usage: { prompt_tokens: 3, completion_tokens: 1 }, model: "test-model", + custom_field: { nested: [1, 2] }, }; - client.servingEndpoints.query.mockResolvedValue(responseData); + client.request.mockResolvedValue(jsonResponse(responseData)); const result = await invoke(client, "my-endpoint", { messages: [] }); expect(result).toEqual(responseData); }); - test("propagates SDK errors", async () => { + test("merges the served-model-name header like the legacy query", async () => { const client = createMockClient(); - client.servingEndpoints.query.mockRejectedValue( - new Error("Endpoint not found"), + client.request.mockResolvedValue( + jsonResponse({ predictions: [1] }, { "served-model-name": "m-1" }), ); + const result = await invoke(client, "my-endpoint", { inputs: [1] }); + expect(result).toEqual({ predictions: [1], "served-model-name": "m-1" }); + }); + + test("returns {} for an empty body", async () => { + const client = createMockClient(); + client.request.mockResolvedValue(new Response("")); + + expect(await invoke(client, "my-endpoint", {})).toEqual({}); + }); + + test("throws the legacy message for a non-JSON body", async () => { + const client = createMockClient(); + client.request.mockResolvedValue(new Response("oops")); + + await expect(invoke(client, "my-endpoint", {})).rejects.toThrow( + "Can't parse reponse as JSON: oops", + ); + }); + + test("propagates SDK errors", async () => { + const client = createMockClient(); + client.request.mockRejectedValue(new Error("Endpoint not found")); + await expect( invoke(client, "my-endpoint", { messages: [] }), ).rejects.toThrow("Endpoint not found"); }); }); - describe("stream", () => { + describe("stream via client.request", () => { + test("POSTs with stream: true and returns the response body", async () => { + const client = createMockClient(); + const body = new ReadableStream(); + client.request.mockResolvedValue(new Response(body)); + const controller = new AbortController(); + + const result = await stream( + client, + "my endpoint", + { messages: [], stream: false }, + controller.signal, + ); + + expect(result).toBeInstanceOf(ReadableStream); + expect(client.request).toHaveBeenCalledWith({ + method: "POST", + path: "/serving-endpoints/my%20endpoint/invocations", + headers: { + "Content-Type": "application/json", + Accept: "text/event-stream", + }, + body: JSON.stringify({ messages: [], stream: true }), + signal: controller.signal, + }); + expect(client.apiClient.request).not.toHaveBeenCalled(); + }); + + test("throws when the response has no body", async () => { + const client = createMockClient(); + client.request.mockResolvedValue(new Response(null)); + + await expect( + stream(client, "my-endpoint", { messages: [] }), + ).rejects.toThrow("streaming not supported"); + }); + }); + + // Caller-supplied legacy SDK clients (agents' public `WorkspaceClientLike`) + // have no `request`, so they keep the `apiClient.request` path. + describe("stream via a legacy apiClient", () => { test("returns a ReadableStream from apiClient.request", async () => { const encoder = new TextEncoder(); const mockContents = new ReadableStream({ @@ -88,7 +186,7 @@ describe("Serving Connector", () => { }, }); - const client = createMockClient(); + const client = createLegacyClient(); client.apiClient.request.mockResolvedValue({ contents: mockContents }); const result = await stream(client, "my-endpoint", { messages: [] }); @@ -97,7 +195,7 @@ describe("Serving Connector", () => { }); test("sends stream: true in payload via apiClient.request", async () => { - const client = createMockClient(); + const client = createLegacyClient(); client.apiClient.request.mockResolvedValue({ contents: new ReadableStream(), }); @@ -116,7 +214,7 @@ describe("Serving Connector", () => { }); test("passes SDK Context when AbortSignal is provided", async () => { - const client = createMockClient(); + const client = createLegacyClient(); client.apiClient.request.mockResolvedValue({ contents: new ReadableStream(), }); @@ -133,7 +231,7 @@ describe("Serving Connector", () => { }); test("strips user-provided stream and re-injects", async () => { - const client = createMockClient(); + const client = createLegacyClient(); client.apiClient.request.mockResolvedValue({ contents: new ReadableStream(), }); @@ -148,7 +246,7 @@ describe("Serving Connector", () => { }); test("throws when response has no contents", async () => { - const client = createMockClient(); + const client = createLegacyClient(); client.apiClient.request.mockResolvedValue({ contents: null }); await expect( diff --git a/packages/appkit/src/core/agent/tests/run-agent-identity.test.ts b/packages/appkit/src/core/agent/tests/run-agent-identity.test.ts index 36bbbceb4..12ca5c830 100644 --- a/packages/appkit/src/core/agent/tests/run-agent-identity.test.ts +++ b/packages/appkit/src/core/agent/tests/run-agent-identity.test.ts @@ -195,10 +195,10 @@ describe("standalone caller identity", () => { (options) => { const client = real(options); const caller = options?.token ? "user" : "app"; - client.apiClient.request = (async () => { + client.request = (async () => { used.push(caller); - return { - contents: new ReadableStream({ + return new Response( + new ReadableStream({ start(controller) { controller.enqueue( new TextEncoder().encode( @@ -208,7 +208,7 @@ describe("standalone caller identity", () => { controller.close(); }, }), - }; + ); }) as never; return client; }, diff --git a/packages/appkit/src/core/tests/appkit-user-scope.test.ts b/packages/appkit/src/core/tests/appkit-user-scope.test.ts index 49a48e952..25f074d25 100644 --- a/packages/appkit/src/core/tests/appkit-user-scope.test.ts +++ b/packages/appkit/src/core/tests/appkit-user-scope.test.ts @@ -777,8 +777,8 @@ describe("agents on-behalf-of-user mode", () => { const answer = (caller: string) => (async () => { used.push(caller); - return { - contents: new ReadableStream({ + return new Response( + new ReadableStream({ start(controller) { controller.enqueue( new TextEncoder().encode( @@ -788,17 +788,17 @@ describe("agents on-behalf-of-user mode", () => { controller.close(); }, }), - }; + ); }) as never; // The harness backs the user scope with `client`; the app's own model // client comes from createWorkspaceClient. const client = createMockWorkspaceClient(); - client.apiClient.request = answer("user"); + client.request = answer("user"); const real = workspace.createWorkspaceClient; vi.spyOn(workspace, "createWorkspaceClient").mockImplementation( (options) => { const sp = real(options); - sp.apiClient.request = answer("app"); + sp.request = answer("app"); return sp; }, ); diff --git a/packages/appkit/src/type-generator/serving/fetcher.ts b/packages/appkit/src/type-generator/serving/fetcher.ts index d970c4ed7..dd0714b4b 100644 --- a/packages/appkit/src/type-generator/serving/fetcher.ts +++ b/packages/appkit/src/type-generator/serving/fetcher.ts @@ -41,7 +41,7 @@ export interface OpenApiSchema { } /** - * Fetches the OpenAPI schema for a serving endpoint using the SDK. + * Fetches the OpenAPI schema for a serving endpoint via the workspace client. * Returns null if the endpoint is not found or access is denied. */ export async function fetchOpenApiSchema( @@ -50,11 +50,13 @@ export async function fetchOpenApiSchema( servedModel?: string, ): Promise<{ spec: OpenApiSpec; pathKey: string } | null> { try { - const response = await client.servingEndpoints.getOpenApi({ - name: endpointName, + const response = await client.request({ + method: "GET", + path: `/api/2.0/serving-endpoints/${endpointName}/openapi`, + headers: { Accept: "text/plain" }, }); - if (!response.contents) { + if (!response.body) { logger.warn( "Empty OpenAPI response for '%s', skipping type generation", endpointName, @@ -62,7 +64,7 @@ export async function fetchOpenApiSchema( return null; } - const text = await new Response(response.contents).text(); + const text = await response.text(); const rawSpec: unknown = JSON.parse(text); if ( diff --git a/packages/appkit/src/type-generator/serving/tests/fetcher.test.ts b/packages/appkit/src/type-generator/serving/tests/fetcher.test.ts index 5d88d1fa7..d10abd26c 100644 --- a/packages/appkit/src/type-generator/serving/tests/fetcher.test.ts +++ b/packages/appkit/src/type-generator/serving/tests/fetcher.test.ts @@ -24,13 +24,10 @@ function createReadableStream(data: string): ReadableStream { } function createMockClient(getOpenApiImpl?: (...args: any[]) => any) { - const defaultImpl = async () => ({ - contents: createReadableStream(JSON.stringify(makeValidSpec())), - }); + const defaultImpl = async () => + new Response(createReadableStream(JSON.stringify(makeValidSpec()))); return { - servingEndpoints: { - getOpenApi: vi.fn(getOpenApiImpl ?? defaultImpl), - }, + request: vi.fn(getOpenApiImpl ?? defaultImpl), } as any; } @@ -72,7 +69,7 @@ describe("fetchOpenApiSchema", () => { }); test("returns null when response has no contents", async () => { - const client = createMockClient(async () => ({ contents: undefined })); + const client = createMockClient(async () => new Response(null)); const result = await fetchOpenApiSchema(client, "ep"); expect(result).toBeNull(); }); @@ -81,9 +78,9 @@ describe("fetchOpenApiSchema", () => { const spec = makeValidSpec({ "/serving-endpoints/ep/invocations": { post: { requestBody: {} } }, }); - const client = createMockClient(async () => ({ - contents: createReadableStream(JSON.stringify(spec)), - })); + const client = createMockClient( + async () => new Response(createReadableStream(JSON.stringify(spec))), + ); const result = await fetchOpenApiSchema(client, "ep"); expect(result).not.toBeNull(); @@ -96,9 +93,9 @@ describe("fetchOpenApiSchema", () => { "/serving-endpoints/ep/served-models/gpt4/invocations": { post: {} }, "/serving-endpoints/ep/invocations": { post: {} }, }); - const client = createMockClient(async () => ({ - contents: createReadableStream(JSON.stringify(spec)), - })); + const client = createMockClient( + async () => new Response(createReadableStream(JSON.stringify(spec))), + ); const result = await fetchOpenApiSchema(client, "ep", "gpt4"); expect(result?.pathKey).toBe( @@ -110,40 +107,44 @@ describe("fetchOpenApiSchema", () => { const spec = makeValidSpec({ "/serving-endpoints/ep/invocations": { post: {} }, }); - const client = createMockClient(async () => ({ - contents: createReadableStream(JSON.stringify(spec)), - })); + const client = createMockClient( + async () => new Response(createReadableStream(JSON.stringify(spec))), + ); const result = await fetchOpenApiSchema(client, "ep", "nonexistent-model"); expect(result?.pathKey).toBe("/serving-endpoints/ep/invocations"); }); test("returns null for invalid spec structure (missing paths)", async () => { - const client = createMockClient(async () => ({ - contents: createReadableStream( - JSON.stringify({ openapi: "3.0.0", info: {} }), - ), - })); + const client = createMockClient( + async () => + new Response( + createReadableStream(JSON.stringify({ openapi: "3.0.0", info: {} })), + ), + ); const result = await fetchOpenApiSchema(client, "ep"); expect(result).toBeNull(); }); test("returns null when paths object is empty", async () => { - const client = createMockClient(async () => ({ - contents: createReadableStream(JSON.stringify(makeValidSpec({}))), - })); + const client = createMockClient( + async () => + new Response(createReadableStream(JSON.stringify(makeValidSpec({})))), + ); const result = await fetchOpenApiSchema(client, "ep"); expect(result).toBeNull(); }); - test("calls SDK getOpenApi with correct endpoint name", async () => { + test("GETs the endpoint's openapi path", async () => { const client = createMockClient(); await fetchOpenApiSchema(client, "my-endpoint"); - expect(client.servingEndpoints.getOpenApi).toHaveBeenCalledWith({ - name: "my-endpoint", + expect(client.request).toHaveBeenCalledWith({ + method: "GET", + path: "/api/2.0/serving-endpoints/my-endpoint/openapi", + headers: { Accept: "text/plain" }, }); }); }); diff --git a/packages/shared/src/workspace-client/modular.ts b/packages/shared/src/workspace-client/modular.ts index d00540052..b9135ada4 100644 --- a/packages/shared/src/workspace-client/modular.ts +++ b/packages/shared/src/workspace-client/modular.ts @@ -23,6 +23,8 @@ * the API returns plain `JSON_ARRAY` rows (`[["a", null], ...]`), so the unmarshal * schema rejected every real query result. The patch restores `(string | null)[][]`. */ +import { STATUS_CODES } from "node:http"; + import { type Credentials, newTokenCredentials, @@ -279,6 +281,9 @@ export function buildWorkspaceAuth( const nullBody = [204, 205, 304].includes(res.statusCode); const response = new Response(nullBody ? null : res.body, { status: res.statusCode, + // The modular transport drops the reason phrase; legacy error messages + // include it (see toApiError). + statusText: STATUS_CODES[res.statusCode], headers: res.headers, }); if (!response.ok) throw await toApiError(response); @@ -290,20 +295,40 @@ export function buildWorkspaceAuth( /** * Same error class the legacy `apiClient.request` threw, so existing catch * sites (`instanceof ApiError`, `.statusCode`, `.errorCode`) keep working. + * Message + code follow the legacy `parseErrorFromResponse` so callers that + * surface `err.message` (e.g. the serving plugin's 502 body) are unchanged: + * a body without both `error_code` and `message` (plain text, HTML, or a + * model-specific error JSON) becomes `Response from server () `. */ async function toApiError(response: Response): Promise { const text = await response.text(); - let parsed: { error_code?: string; message?: string } = {}; + let json: + | { error_code?: string; message?: string; error?: string; details?: [] } + | undefined; try { - parsed = JSON.parse(text); + json = JSON.parse(text); } catch { - // Non-JSON error body; fall back to the raw text. + // Non-JSON error body (HTML or plain text). + } + if (json?.message && json.error_code) { + return new ApiError( + json.error || json.message, + json.error_code, + response.status, + json, + json.details ?? [], + ); } + const html = + text.match(/
(.*)<\/pre>/) ??
+    text.match(/(Error \d+.*?)<\/title>/);
   return new ApiError(
-    parsed.message || text || response.statusText,
-    parsed.error_code ?? "UNKNOWN",
+    html
+      ? html[1].trim().replace(/([\s.])*$/, "")
+      : `Response from server (${response.statusText}) ${text}`,
+    response.statusText || "UNKNOWN",
     response.status,
-    undefined,
+    json ?? text,
     [],
   );
 }
diff --git a/packages/shared/src/workspace-client/tests/modular.test.ts b/packages/shared/src/workspace-client/tests/modular.test.ts
index 8577c73ba..113095567 100644
--- a/packages/shared/src/workspace-client/tests/modular.test.ts
+++ b/packages/shared/src/workspace-client/tests/modular.test.ts
@@ -470,4 +470,31 @@ describe("buildWorkspaceAuth (auth + raw-request seam)", () => {
       statusCode: 403,
     });
   });
+
+  // Same message/code as the legacy SDK's parseErrorFromResponse, which the
+  // serving plugin surfaces verbatim in its 502 body.
+  test.each([
+    [
+      "plain text",
+      "rate limited",
+      "Response from server (Bad Request) rate limited",
+    ],
+    [
+      "non-standard JSON",
+      '{"error":"bad"}',
+      'Response from server (Bad Request) {"error":"bad"}',
+    ],
+    ["HTML <pre>", "<html><pre>Bad thing.  </pre></html>", "Bad thing"],
+  ])(
+    "request: a %s error body keeps the legacy message",
+    async (_l, body, message) => {
+      nextResponse.statusCode = 400;
+      nextResponse.body = body;
+      const auth = buildWorkspaceAuth({ host: "https://x", token: "t" });
+      const error = await auth
+        .request({ method: "GET", path: "/api/x" })
+        .catch((e: unknown) => e);
+      expect(error).toMatchObject({ message, statusCode: 400 });
+    },
+  );
 });