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
2 changes: 1 addition & 1 deletion docs/http-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,6 @@ The response contains the final text and the thread identifier:
}
```

Request bodies are limited to one megabyte and prompt text is limited to 100,000 characters. Deployments exposed beyond loopback should add authentication, authorization, rate limits, and TLS at the edge.
Request bodies are limited to one megabyte and prompt text is limited to 100,000 characters. Malformed JSON and blank or invalid input return `400`, and oversized bodies return `413`. Deployments exposed beyond loopback should add authentication, authorization, rate limits, and TLS at the edge.

`POST /v1/chat/stream` accepts the same body and returns server-sent events. `text` events carry model chunks, followed by one `done` event containing the thread identifier. Errors that occur after streaming begins are delivered as `error` events.
32 changes: 27 additions & 5 deletions src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,22 +7,44 @@ import { runAgent, streamAgent } from "./runner.js";
import { toError } from "./errors.js";

const requestSchema = z.object({
input: z.string().min(1).max(100_000),
input: z
.string()
.max(100_000)
.refine((value) => value.trim().length > 0, "Input must not be empty"),
threadId: z.string().min(1).max(200).optional(),
});

export type HttpRuntime = { server: Server; close: () => Promise<void> };

class RequestError extends Error {
constructor(
public readonly status: number,
message: string,
) {
super(message);
this.name = "RequestError";
}
}

async function readJson(request: IncomingMessage, limit = 1_000_000): Promise<unknown> {
const chunks: Buffer[] = [];
let size = 0;
for await (const chunk of request) {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array);
size += buffer.length;
if (size > limit) throw new Error("Request body is too large");
if (size > limit) throw new RequestError(413, "Request body is too large");
chunks.push(buffer);
}
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
try {
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
} catch {
throw new RequestError(400, "Request body must be valid JSON");
}
}

function errorStatus(error: Error): number {
if (error instanceof RequestError) return error.status;
return error.name === "ZodError" ? 400 : 500;
}

function sendJson(response: ServerResponse, status: number, body: unknown): void {
Expand Down Expand Up @@ -78,7 +100,7 @@ export async function startHttp(
const error = toError(cause);
logger.warn({ error }, "streaming agent request failed");
if (!response.headersSent) {
sendJson(response, error.name === "ZodError" ? 400 : 500, { error: error.message });
sendJson(response, errorStatus(error), { error: error.message });
} else {
response.write(`event: error\ndata: ${JSON.stringify({ error: error.message })}\n\n`);
response.end();
Expand All @@ -102,7 +124,7 @@ export async function startHttp(
} catch (cause) {
const error = toError(cause);
logger.warn({ error }, "agent request failed");
sendJson(response, error.name === "ZodError" ? 400 : 500, { error: error.message });
sendJson(response, errorStatus(error), { error: error.message });
}
}

Expand Down
18 changes: 18 additions & 0 deletions tests/http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,4 +103,22 @@ describe("agent HTTP API", () => {
expect(signal?.aborted).toBe(true);
});
});

it("answers malformed, blank, and oversized requests with client errors", async () => {
const config = loadConfig({ AGENT_PORT: "41422", AGENT_LOG_LEVEL: "silent" });
const invoke = vi.fn();
runtime = await startHttp(
config,
{ invoke } as unknown as ImpossibleAgent,
pino({ level: "silent" }),
);
const post = (path: string, body: string) =>
fetch(`http://127.0.0.1:41422${path}`, { method: "POST", body });
for (const path of ["/v1/chat", "/v1/chat/stream"]) {
expect((await post(path, "{not json")).status).toBe(400);
expect((await post(path, JSON.stringify({ input: " " }))).status).toBe(400);
expect((await post(path, JSON.stringify({ input: "x".repeat(1_000_001) }))).status).toBe(413);
}
expect(invoke).not.toHaveBeenCalled();
});
});
Loading