Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -171,18 +193,20 @@ 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:
- name: Require every check to pass
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:
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 4 additions & 1 deletion docs/ci-cd.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,17 @@ 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
files, then checks the extension, UI helpers, and Pi loader with the peer
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
Expand Down
47 changes: 46 additions & 1 deletion src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
return Promise.resolve(this.#body);
}
override json(): Promise<unknown> {
return Promise.resolve<unknown>(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;
Expand Down Expand Up @@ -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;
Expand Down
184 changes: 184 additions & 0 deletions tests/bun-transport.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>;
}

async function localServer(handler: (req: IncomingMessage, res: ServerResponse) => void): Promise<LocalServer> {
let requests = 0;
const server = createServer((req, res) => {
requests += 1;
req.resume();
handler(req, res);
});
await new Promise<void>(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<void>(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);
});
Loading