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
162 changes: 162 additions & 0 deletions api/_lib/__tests__/ai-provider-response-limit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
// @vitest-environment node
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { APICallError, DownloadError, generateText } from 'ai';
import { createOpenAI } from '@ai-sdk/openai';

const GATEWAY_BASE = 'https://fixture-gateway.example.invalid/v1';
const FIXTURE_TEXT = 'Synthetic board response';
const FIXTURE_ERROR = 'Synthetic gateway rejected the request';

// The upstream SDK's default is 2 GiB, not an application-sized memory cap.
// Advertise a larger response while delivering a small valid stream. This
// proves early header rejection/cancellation without allocating 2 GiB; it does
// not exercise the cumulative no-Content-Length threshold.
const DECLARED_OVER_LIMIT_BYTES = 2 * 1024 * 1024 * 1024 + 1;

type Adapter = 'chat' | 'responses';

function successfulBody(adapter: Adapter) {
if (adapter === 'chat') {
return {
id: 'chat_fixture',
object: 'chat.completion',
created: 1,
model: 'fixture-model',
choices: [{
index: 0,
message: { role: 'assistant', content: FIXTURE_TEXT },
finish_reason: 'stop',
logprobs: null,
}],
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
};
}

return {
id: 'response_fixture',
object: 'response',
created_at: 1,
model: 'fixture-model',
status: 'completed',
output: [{
type: 'message',
id: 'message_fixture',
status: 'completed',
role: 'assistant',
content: [{ type: 'output_text', text: FIXTURE_TEXT, annotations: [] }],
}],
usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 },
};
}

function createGatewayFixture(adapter: Adapter, { error = false, oversized = false } = {}) {
const body = error
? { error: { message: FIXTURE_ERROR, type: 'fixture_error', code: 'fixture_error' } }
: successfulBody(adapter);
const bytes = new TextEncoder().encode(JSON.stringify(body));
const streamState = { deliveredBytes: 0, cancelled: false, completed: false };
let offset = 0;
const bodyStream = new ReadableStream<Uint8Array>({
pull(controller) {
if (offset === bytes.length) {
streamState.completed = true;
controller.close();
return;
}
const chunk = bytes.slice(offset, offset + 29);
offset += chunk.length;
streamState.deliveredBytes += chunk.length;
controller.enqueue(chunk);
},
cancel() {
streamState.cancelled = true;
},
}, { highWaterMark: 0 });

const expectedUrl = `${GATEWAY_BASE}/${adapter === 'chat' ? 'chat/completions' : 'responses'}`;
const fetchFixture = vi.fn<typeof fetch>(async (input, init) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
expect(url).toBe(expectedUrl);
expect(init?.method).toBe('POST');
expect(JSON.parse(String(init?.body)).model).toBe('fixture-model');
return new Response(bodyStream, {
status: error ? 400 : 200,
headers: {
'content-type': 'application/json',
...(oversized ? { 'content-length': String(DECLARED_OVER_LIMIT_BYTES) } : {}),
},
});
});
const gateway = createOpenAI({
baseURL: GATEWAY_BASE,
apiKey: 'synthetic-fixture-key',
fetch: fetchFixture,
});

return {
generate: () => generateText({
// These are the same public adapter modes used by the command and board-
// template endpoints. No SDK parser/size helper or generation is mocked.
model: adapter === 'chat' ? gateway.chat('fixture-model') : gateway('fixture-model'),
prompt: 'Synthetic prompt; no real provider is contacted.',
maxRetries: 0,
}),
fetchFixture,
streamState,
bodyBytes: bytes.length,
};
}

beforeEach(() => {
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('Unexpected network request'));
});

afterEach(() => {
vi.restoreAllMocks();
});

describe.each(['chat', 'responses'] as const)('actual OpenAI %s adapter response size guard', (adapter) => {
it('accepts ordinary successful streamed JSON', async () => {
const fixture = createGatewayFixture(adapter);

const result = await fixture.generate();

expect(result.text).toBe(FIXTURE_TEXT);
expect(fixture.fetchFixture).toHaveBeenCalledTimes(1);
expect(fixture.streamState.deliveredBytes).toBe(fixture.bodyBytes);
expect(fixture.streamState.completed).toBe(true);
expect(globalThis.fetch).not.toHaveBeenCalled();
});

it('preserves an ordinary streamed upstream JSON error', async () => {
const fixture = createGatewayFixture(adapter, { error: true });

await expect(fixture.generate()).rejects.toMatchObject({
message: FIXTURE_ERROR,
statusCode: 400,
});

expect(fixture.fetchFixture).toHaveBeenCalledTimes(1);
expect(fixture.streamState.deliveredBytes).toBe(fixture.bodyBytes);
expect(fixture.streamState.completed).toBe(true);
expect(globalThis.fetch).not.toHaveBeenCalled();
});

it.each([false, true])('rejects and cancels declared oversized streamed JSON before reading (error=%s)', async (error) => {
const fixture = createGatewayFixture(adapter, { error, oversized: true });

await expect(fixture.generate()).rejects.toSatisfy((caught: unknown) => {
expect(APICallError.isInstance(caught)).toBe(true);
const cause = (caught as APICallError).cause;
expect(DownloadError.isInstance(cause)).toBe(true);
expect((cause as DownloadError).message).toContain('exceeded maximum size');
return true;
});

expect(fixture.fetchFixture).toHaveBeenCalledTimes(1);
expect(fixture.streamState.deliveredBytes).toBe(0);
expect(fixture.streamState.cancelled).toBe(true);
expect(fixture.streamState.completed).toBe(false);
expect(globalThis.fetch).not.toHaveBeenCalled();
});
});
60 changes: 60 additions & 0 deletions docs/qa/v0.8/ai-response-limit/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# AI provider JSON response-reader verification

Baseline: `63efb43fe37ddc312a9b76b7ee1f875901389e7f` (PR #41), October 2, 2026. Candidate branch: `codex/gauntlet-ai-response-limit`. Changes are limited to root AI dependencies, one actual-adapter regression test, and this evidence. Application endpoints and MCP dependencies are unchanged.

The application's authenticated command and board-template endpoints call `generateText` through OpenAI Chat and Responses adapters configured for the Vercel AI Gateway. Their old `@ai-sdk/provider-utils` JSON/error readers are affected by [GHSA-866g-f22w-33x8](https://github.com/advisories/GHSA-866g-f22w-33x8). The upstream response is not a request-selected URL; this verification does not establish attacker control of a production gateway response.

The candidate preserves caret declarations for `ai` 6.0.214 and `@ai-sdk/openai` 3.0.77. Its lock resolves provider-utils 4.0.33, gateway 3.0.139, provider 3.0.12, OIDC 3.2.0 and eventsource-parser 3.1.1. These seven resolved package changes belong to the reviewed AI chain. No override or bulk dependency update is included.

## What the patch bounds

The [maintainer backport](https://github.com/vercel/ai/commit/b30e43a) routes successful JSON and error-body reads through the bounded response reader and cancels a body rejected by its declared size. The reader's default is **2 GiB** (`2147483648` bytes). This is the upstream SDK limit, not a small application memory cap. The public `createOpenAI` and `generateText` settings inspected for these versions provide no supported lower JSON-response byte limit; `experimental_download` concerns files in prompts. No custom runtime fetch wrapper is added.

The test advertises `Content-Length: 2147483649` while supplying a small valid JSON body in a real chunked `ReadableStream`. The declaration intentionally exceeds the actual fixture body. It proves the early header check without allocating or transferring 2 GiB. It does not prove cumulative rejection for a response without that header, or that a function can safely buffer the SDK's default maximum.

## Actual-adapter before and after

[`ai-provider-response-limit.test.ts`](../../../../api/_lib/__tests__/ai-provider-response-limit.test.ts) uses the public `createOpenAI` and `generateText` path in both adapter modes used by the endpoints. Only transport is replaced with an injected synthetic gateway fetch. The SDK's parsers, error handlers, bounded reader and generation remain real. Global fetch rejects unexpected network use, retries are disabled, and every case verifies one injected fetch and no global fetch.

| Gate | Baseline dependency tree | Candidate dependency tree |
| --- | --- | --- |
| Ordinary successful streamed JSON, Chat and Responses | 2 passed | 2 passed |
| Ordinary streamed JSON errors retain message and HTTP status | 2 passed | 2 passed |
| Declared oversized success is rejected before body consumption | 2 expected failures: ordinary generation resolved | 2 passed |
| Declared oversized error is rejected for size before consumption | 2 expected failures: no DownloadError cause | 2 passed |
| Total | 4 passed, 4 expected failures | 8 passed |

All four candidate guard cases assert that an `APICallError` contains the SDK `DownloadError`, zero bytes were delivered, the unread stream was cancelled, and it did not complete. The ordinary controls consume all small fixture bytes and complete. The exact same test SHA-256 was used for both dependency trees. No fixture correction or test relaxation was needed between runs.

Both trees were installed only in this isolated worktree with a private npm cache, no node_modules symlink, and install scripts disabled. Node 25.6.0, npm 11.8.0 and Vitest 4.1.4 were used. The focused command was identical apart from the report destination:

```sh
npm ci --ignore-scripts --no-audit --no-fund --cache /private/tmp/zeroboard-ai-response-limit-npm-cache
./node_modules/.bin/vitest run api/_lib/__tests__/ai-provider-response-limit.test.ts --maxWorkers=1 --reporter=default --reporter=json --outputFile.json=<phase-result.json>
```

The baseline package files were copied from the base commit, and the prepared candidate package files were restored before its installation. [baseline.json](baseline.json) and [candidate.json](candidate.json) retain every case's result; [receipt.json](receipt.json) records source/lock hashes, exact installed versions and log hashes. Raw logs are retained outside the repository in `outputs/gauntlet/eleventh-ai-response-limit-*-test.log`.

## Node 20 compatibility gates

The next released gates used Node **20.20.2**, matching the root-provided CI runtime, with the same private candidate node_modules. All 33 cases across the new adapter regression, existing command-resolution tests and existing AI-assistant tests passed with one worker. Nonincremental app and API/node TypeScript checks and ESLint on the new test passed.

The production build imported the original `vite.config.ts`, retaining its plugins and settings while assigning a private cache/output directory. It used disposable Supabase URL/public-key values, transformed 2,403 modules and completed in 11.54 seconds. It printed the existing Browserslist age notice and large-chunk advisory; no unrelated update or chunk refactor was made. Build artifact hashes and the exact commands are in [receipt.json](receipt.json).

```sh
NODE20=/private/tmp/zeroboard-ai-ci-runtime/node_modules/node/bin/node
$NODE20 node_modules/vitest/vitest.mjs run api/_lib/__tests__/ai-provider-response-limit.test.ts api/_lib/__tests__/resolve-commands.test.ts src/components/ai/__tests__/AIAssistant.test.tsx --maxWorkers=1
$NODE20 node_modules/typescript/bin/tsc --project tsconfig.app.json --incremental false --noEmit
$NODE20 node_modules/typescript/bin/tsc --project tsconfig.node.json --incremental false --noEmit
$NODE20 node_modules/eslint/bin/eslint.js api/_lib/__tests__/ai-provider-response-limit.test.ts
```

[compatibility.json](compatibility.json) records all 33 case results. This candidate check supplements the unchanged Node 25 before/after proof above; the two runtime scopes remain explicit.

## Refreshed production audit

`npm audit --omit=dev --json` with the private cache removed exactly the four AI-family affected package nodes: `ai`, `@ai-sdk/openai`, `@ai-sdk/gateway` and `@ai-sdk/provider-utils`. The affected-node total fell from 10 to 6, with no added or changed remaining findings. [audit.json](audit.json) and [audit-diff.json](audit-diff.json) retain the public results and comparison. Audit exit code remains 1 because the existing Lodash, React Router and Resend/Svix/UUID findings remain; this is not a claim of zero production advisories.

Those remaining package versions and application code are unchanged. The earlier source assessment found no application use of the affected Lodash template/path operations or UUID output-buffer APIs, no Framework/RSC/SSR router deployment, and no untrusted application navigation/redirect target matching the router advisories. Their findings are preserved for separate review rather than folded into this patch. The audit comparison establishes removal of the scoped AI advisory, not proof against every possible dependency exposure.

Full unit, broad browser and MCP suites remain held for the separate shared-host gate. No live AI request, production credential, UI verification or deployment is claimed.
40 changes: 40 additions & 0 deletions docs/qa/v0.8/ai-response-limit/audit-diff.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
{
"before": {
"info": 0,
"low": 4,
"moderate": 3,
"high": 3,
"critical": 0,
"total": 10
},
"after": {
"info": 0,
"low": 0,
"moderate": 3,
"high": 3,
"critical": 0,
"total": 6
},
"removedPackageNodes": [
"@ai-sdk/gateway",
"@ai-sdk/openai",
"@ai-sdk/provider-utils",
"ai"
],
"addedPackageNodes": [],
"remainingChangedPackageNodes": [],
"remainingPackageNodes": [
"lodash",
"react-router",
"react-router-dom",
"resend",
"svix",
"uuid"
],
"packageHashes": {
"package.json": "d861c93b64a2d769a403d93ad1601d83e82bb31c01ea4e53cfb8b328b69ec18b",
"package-lock.json": "9c79e68c03958a2c5d40fda9a180ee705024295e1f8ccfc1acc9a77d7999ea95"
},
"auditExitCode": 1,
"assessment": "The four AI-family nodes are removed; six existing unrelated package nodes remain. No new findings appeared. Prior source reachability assessment applies because the application and these packages are unchanged. The nonzero audit result is not a zero-vulnerability claim and does not negate the scoped AI advisory removal."
}
Loading
Loading