diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c25c39e..0b05295 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,6 +53,28 @@ jobs: - run: npm ci - run: npm run check + bun: + name: Check (Bun 1.4.4) + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: "24" + package-manager-cache: false + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: "1.4.4" + - run: npm ci + # Build first, as the Node jobs do: the public-API example resolves this package through dist/. + - run: npm run build + - run: npm run typecheck + - name: Check the judge transport under Bun + run: bun test tests/bun-transport.test.ts + package: name: Package needs: [workflow-lint, check] @@ -171,7 +193,7 @@ jobs: ci: name: CI passed if: ${{ always() }} - needs: [workflow-lint, check, package] + needs: [workflow-lint, check, bun, package] runs-on: ubuntu-latest timeout-minutes: 5 steps: @@ -179,10 +201,12 @@ jobs: env: WORKFLOW_LINT: ${{ needs.workflow-lint.result }} CHECK: ${{ needs.check.result }} + BUN: ${{ needs.bun.result }} PACKAGE: ${{ needs.package.result }} run: | test "$WORKFLOW_LINT" = success test "$CHECK" = success + test "$BUN" = success test "$PACKAGE" = success release: diff --git a/CHANGELOG.md b/CHANGELOG.md index 2424830..67c1080 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Fixed + +- A judge request made under Bun no longer fails with `TypeSafe returned an unreadable or unexpected response.` The SDK buffers a response by taking a reader on its body and releasing it, which Bun counts as using the body, so the SDK's own `response.text()` threw `TypeError: Body already used`. Under Bun the transport now reads the body and hands the SDK a replay of it; under Node the transport handed over is unchanged, whether it is the default one or the caller's own. Verified against a local HTTP server with `@typesafe-ai/sdk` 0.7.0, measured under Bun 1.4.4 and Node 26: one request per judgment, the parsed answers returned, a non-2xx status and its headers reported unchanged, and an aborted request still rejected as aborted. The same test file runs on the Node 22.19, 24, and 26 jobs and under Bun in CI. + ## 0.13.0 ### Changed diff --git a/README.md b/README.md index dd76d82..a826a5e 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ Then, inside Pi: 2. `/typesafe test` sends one built-in sample request and shows the answers. 3. `/typesafe enable` lets the agent call the tool for this session, after you confirm the data notice. -Requires Pi 0.85 or newer and Node.js 22.19 or newer. Usage is billed to your TypeSafe account. For CI or scripts set `TYPESAFE_API_KEY` in the environment instead; it takes precedence over the stored key. Do not paste the key into chat, command arguments, or project files. +Requires Pi 0.85 or newer and Node.js 22.19 or newer. It runs under Bun as well, where Pi ships: there the judge transport replays each response body, because Bun 1.4.4 marks a body used once a reader on it is released. Usage is billed to your TypeSafe account. For CI or scripts set `TYPESAFE_API_KEY` in the environment instead; it takes precedence over the stored key. Do not paste the key into chat, command arguments, or project files. ## The three question types diff --git a/docs/ci-cd.md b/docs/ci-cd.md index 74e2fed..58582d0 100644 --- a/docs/ci-cd.md +++ b/docs/ci-cd.md @@ -15,6 +15,9 @@ this project prepares draft releases but does not automatically publish to npm. package's exports through `dist/`; this also checks the generated declarations from a clean checkout. Native Windows is not in this matrix; the existing credential and usage tests assert POSIX file permissions. +- **Check (Bun 1.4.4)** runs the build, the typecheck, and + `tests/bun-transport.test.ts` under Bun, because Pi ships as a Bun binary; every + test in that file also runs on the Node jobs, so it passes on both runtimes. - **Package** builds an actual npm tarball and installs it outside the checkout with install scripts disabled. It checks root and calibration imports without optional Pi peers, verifies all declared JavaScript and type declaration @@ -22,7 +25,7 @@ this project prepares draft releases but does not automatically publish to npm. versions from `package-lock.json`. It also checks package/lockfile version agreement. - **CI passed** runs even when another job fails or is skipped, and fails unless - all three prerequisites succeeded. This is the stable check to require in the + all four prerequisites succeeded. This is the stable check to require in the `main` branch rules; do not require only **Package**, which can be skipped. No TypeSafe or OpenRouter credentials are needed. Tests do not call either diff --git a/src/client.ts b/src/client.ts index e3a00ac..5baa00a 100644 --- a/src/client.ts +++ b/src/client.ts @@ -59,6 +59,49 @@ async function translateModels(response: Response, backend: BackendConfig): Prom return send(JSON.stringify({ models })); } +/** Runtime detection rather than a config flag: the transport below differs only because Bun reads a body differently. */ +const BUN_VERSION: string | undefined = (process.versions as { bun?: string }).bun; + +/** + * A response with no body, answering `text()` and `json()` from bytes already read. Bun 1.4.4 throws `TypeError: Body + * already used` from the SDK's own `res.text()`: the SDK's buffering step takes a reader on `response.body` and releases + * it, which Bun counts as using the body (measured 28 Oct 2026 on Bun 1.4.4; Node 26 read the body after that step, and + * the Node 22.19, 24, and 26 jobs run the same assertion in CI). + * A plain `Response` built from a string is stamped by Bun on a reader exactly the same way, so the replay carries no + * body for that step to touch. The SDK reads only `status`, `ok`, `headers` and `text()` from a response. + */ +class ReplayedResponse extends Response { + readonly #body: string; + constructor(body: string, init: ResponseInit) { + super(null, init); + this.#body = body; + } + override text(): Promise { + return Promise.resolve(this.#body); + } + override json(): Promise { + return Promise.resolve(JSON.parse(this.#body)); + } +} + +/** + * Read the body here and hand the SDK a response that has none, keeping it readable past the SDK's buffering step. + * Wraps whatever transport is in play — the default, a path rewrite, or the caller's own — because Bun stamps a response + * `translateModels` builds on a reader exactly as it stamps a network one, and two wrappers stacked stay harmless. + */ +function readableTransport(inner: Fetch): Fetch { + return async (input, init) => { + const response = await inner(input, init); + // Read under the caller's signal, so an abort still rejects with its reason instead of a half-read body. + const body = await response.text(); + const headers = new Headers(response.headers); + // The body is replayed, so a copied length or encoding would describe bytes that are no longer on the wire. + headers.delete("content-length"); + headers.delete("content-encoding"); + return new ReplayedResponse(body, { status: response.status, statusText: response.statusText, headers }); + }; +} + export interface TypeSafeOptions { /** Defaults to TYPESAFE_API_KEY, then the key saved by `/typesafe login`; never returned. */ apiKey?: string; @@ -224,7 +267,9 @@ export function createTypeSafe(options: TypeSafeOptions = {}): TypeSafe { ...(options.maxUsdPerDay === undefined ? {} : { maxUsdPerDay: positiveNumber(options.maxUsdPerDay, "maxUsdPerDay") }), }, capsFromEnvironment()); // A backend with its own paths or model list fields gets a transport that rewrites them; the default backend keeps the caller's. - const transport = backend.path !== undefined || backend.modelsPath !== undefined || backend.modelsField !== undefined ? backendFetch(backend, options.fetch) : options.fetch; + const routed = backend.path !== undefined || backend.modelsPath !== undefined || backend.modelsField !== undefined ? backendFetch(backend, options.fetch) : options.fetch; + // One wrapper ahead of both, so the backend's paths and the caller's own transport are covered; on Node the transport stays as it was. + const transport = BUN_VERSION === undefined ? routed : readableTransport(routed ?? fetch); // The caller's input is validated as written, then mapped to the backend's id form; omitting it sends the backend's // own default, which the mapping leaves unchanged. const requested = options.model ?? backend.defaultModel; diff --git a/tests/bun-transport.test.ts b/tests/bun-transport.test.ts new file mode 100644 index 0000000..eb8a049 --- /dev/null +++ b/tests/bun-transport.test.ts @@ -0,0 +1,184 @@ +import assert from "node:assert/strict"; +import { createServer } from "node:http"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import type { AddressInfo } from "node:net"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { after, before, test } from "node:test"; +import { createTypeSafe, noul, TypeSafeIntegrationError } from "../src/index.js"; +import type { BackendEndpoint } from "../src/index.js"; + +// The judge body a Bun runtime turns into `TypeError: Body already used` once the SDK has buffered it. +const JUDGE_BODY = JSON.stringify({ + model: "jev-1.13-free", + answers: { q1: { type: "noul", noul: 0.42 } }, + usage: { input_tokens: 10, output_tokens: 1 }, +}); +const request = { state: "synthetic", questions: { q1: noul("Is this synthetic?") } }; +const apiKey = "ts_fake_key_0123456789abcdef"; +const bunVersion = (process.versions as { bun?: string }).bun; + +interface LocalServer { + readonly host: string; + readonly requests: () => number; + readonly close: () => Promise; +} + +async function localServer(handler: (req: IncomingMessage, res: ServerResponse) => void): Promise { + let requests = 0; + const server = createServer((req, res) => { + requests += 1; + req.resume(); + handler(req, res); + }); + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); + const { port } = server.address() as AddressInfo; + return { + host: `http://127.0.0.1:${port}`, + requests: () => requests, + close: () => new Promise(resolve => { server.closeAllConnections(); server.close(() => resolve()); }), + }; +} + +const backend = (host: string): BackendEndpoint => ({ label: "Local", host, keyEnv: "JUDGE_KEY", defaultModel: "jev-1.13" }); + +/** A response that counts the clones the SDK's buffering step makes, so a test can see whether the raw one was handed over. */ +class CountingResponse extends Response { + clones = 0; + override clone(): Response { + this.clones += 1; + return super.clone(); + } +} + +const savedAgentDir = process.env.PI_CODING_AGENT_DIR; +const savedGlobalFetch = globalThis.fetch; +before(() => { + // Auth state is written next to the key store; keep this run's records out of the developer's own store. + process.env.PI_CODING_AGENT_DIR = mkdtempSync(join(tmpdir(), "pi-typesafe-bun-")); +}); +after(() => { + if (savedAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR; else process.env.PI_CODING_AGENT_DIR = savedAgentDir; + globalThis.fetch = savedGlobalFetch; +}); + +// 1. A judge call with no injected transport, against a real server, on whichever runtime runs this file. + +test("a judge call with no injected transport returns the parsed answers and makes one request", async () => { + const server = await localServer((_req, res) => { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JUDGE_BODY); + }); + try { + const client = createTypeSafe({ apiKey, backend: backend(server.host) }); + const result = await client.evaluate(request); + assert.equal(result.answers.q1.type, "noul"); + assert.equal((result.answers.q1 as { noul: number }).noul, 0.42); + assert.equal(result.model, "jev-1.13-free"); + assert.equal(server.requests(), 1); + } finally { + await server.close(); + } +}); + +// 2. Status and headers survive the body replay, so the SDK reports the backend's own status. + +test("a non-2xx body keeps its status and headers", async () => { + const server = await localServer((_req, res) => { + res.writeHead(429, { "content-type": "application/json", "retry-after": "7" }); + res.end(JSON.stringify({ error: { message: "quota" } })); + }); + try { + const client = createTypeSafe({ apiKey, backend: backend(server.host) }); + const error = await client.evaluate(request).then(() => undefined, (caught: unknown) => caught); + assert.ok(error instanceof TypeSafeIntegrationError); + assert.equal(error.code, "http"); + assert.equal(error.status, 429); + // The Retry-After count reaches the message only if the headers were carried across. + assert.equal(error.message, "TypeSafe returned HTTP 429. Check your account quota and try again later. Retry after 7 seconds. No automatic retry was made."); + assert.equal(server.requests(), 1); + } finally { + await server.close(); + } +}); + +// 3. An abort during body delivery rejects with the abort, not with a half-read body or a timeout. + +test("an aborted request rejects as aborted", async () => { + const server = await localServer((_req, res) => { + res.writeHead(200, { "content-type": "application/json" }); + res.flushHeaders(); + res.write(JUDGE_BODY.slice(0, 20)); + // Never ends: the body stays in flight until the caller aborts. + }); + try { + const client = createTypeSafe({ apiKey, backend: backend(server.host), timeoutMs: 30_000 }); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(new Error("caller gave up")), 100); + const error = await client.evaluate(request, { signal: controller.signal }).then(() => undefined, (caught: unknown) => caught); + clearTimeout(timer); + assert.ok(error instanceof TypeSafeIntegrationError); + assert.equal(error.code, "aborted"); + assert.equal(server.requests(), 1); + } finally { + await server.close(); + } +}); + +// 4. The transport handed over is runtime-dependent: Bun gets this package's replay, Node gets the raw response. + +test("with no fetch option, Node hands the SDK the raw response and Bun hands it a replay", async () => { + const response = new CountingResponse(JUDGE_BODY, { headers: { "content-type": "application/json" } }); + let calls = 0; + globalThis.fetch = async () => { + calls += 1; + return response; + }; + try { + const client = createTypeSafe({ apiKey, backend: backend("http://127.0.0.1:1") }); + const result = await client.evaluate(request); + assert.equal((result.answers.q1 as { noul: number }).noul, 0.42); + assert.equal(calls, 1, "one HTTP request per evaluate"); + // The SDK's buffering step clones the response it is given: on Node that is the caller's own, on Bun the replay is. + assert.equal(response.clones, bunVersion === undefined ? 1 : 0); + } finally { + globalThis.fetch = savedGlobalFetch; + } +}); + +// 5. A caller's own transport, such as a wrapper that reads the body, is wrapped too and stays harmless. + +test("a caller-supplied fetch is called once per request", async () => { + let calls = 0; + const seen: string[] = []; + const client = createTypeSafe({ apiKey, backend: backend("http://127.0.0.1:1"), fetch: async (url, init) => { + calls += 1; + seen.push(String(url)); + assert.equal(new Headers(init?.headers).get("authorization"), `Bearer ${apiKey}`); + return new Response(JUDGE_BODY, { headers: { "content-type": "application/json" } }); + } }); + const result = await client.evaluate(request); + assert.equal((result.answers.q1 as { noul: number }).noul, 0.42); + assert.equal(calls, 1); + assert.deepEqual(seen, ["http://127.0.0.1:1/v1/systemone"]); +}); + +// 6. A backend that renames its model list gets the same readable body: the replay sits ahead of that rewrite too. + +test("a backend that rewrites its model list still returns models", async () => { + let calls = 0; + const client = createTypeSafe({ + apiKey, + backend: { label: "Gateway", host: "https://gw.example.com", modelsPath: "/v1/catalog", modelsField: "data", modelsIdField: "id", keyEnv: "GATEWAY_JEV_KEY", defaultModel: "jev-1.13" }, + fetch: async (url) => { + calls += 1; + if (String(url).endsWith("/v1/catalog")) return new Response(JSON.stringify({ data: [{ id: "jev-1.13" }] }), { headers: { "content-type": "application/json" } }); + return new Response(JSON.stringify({ model: "jev-1.13", answers: { q1: { type: "noul", noul: 0.42 } }, usage: { input_tokens: 10, output_tokens: 1 } }), { headers: { "content-type": "application/json" } }); + }, + }); + assert.deepEqual(await client.listModels(), ["jev-1.13"]); + const result = await client.evaluate(request); + assert.equal((result.answers.q1 as { noul: number }).noul, 0.42); + assert.equal(calls, 2); +});