Skip to content

Commit e3acc6a

Browse files
heskewclaude
andcommitted
review fixes: structural test, URL-parsed native-OpenAI check, bounded-reader hardening, total tool-arg cap
Gemini review findings adjudicated and applied: the analyticsTable schema test asserts on the exported attribute list instead of regexing the source; #isNativeOpenAI parses the URL hostname (port and spoofed-suffix safe); readBoundedJson wraps stream errors in the backend error class, cancels the body before throwing on the cap, and reuses a module-level TextDecoder; an 8 MiB total tool-argument cap bounds the accumulator maps across entries in all three streaming parsers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent f8d2253 commit e3acc6a

7 files changed

Lines changed: 234 additions & 89 deletions

File tree

components/anthropic/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,8 @@ const MAX_UPSTREAM_ERROR_MESSAGE_CHARS = 500;
6565
// `index` from content_block_start events; a hostile upstream can open unbounded
6666
// entries if content_block_stop never arrives for earlier indices.
6767
const MAX_TOOL_CALL_ACCUMULATOR_ENTRIES = 128;
68+
// Total tool-call argument chars across all content blocks in one stream.
69+
const MAX_TOTAL_TOOL_CALL_ARGS_CHARS = 8 * 1024 * 1024; // 8 MiB
6870

6971
const log = harperLogger.forComponent('anthropic').conditional;
7072

@@ -144,6 +146,7 @@ export class AnthropicBackend implements ModelBackend {
144146
// partial strings.
145147
const toolBuf = new Map<number, AnthropicToolCallAccumulator>();
146148
let finalFinishReason: GenerateResult['finishReason'] | undefined;
149+
let totalArgChars = 0;
147150

148151
for await (const event of readSse(res.body)) {
149152
const chunk: GenerateChunk = {};
@@ -196,6 +199,12 @@ export class AnthropicBackend implements ModelBackend {
196199
`Anthropic tool-call arguments exceed ${MAX_TOOL_CALL_ARGS_CHARS} chars (index ${event.index})`
197200
);
198201
}
202+
totalArgChars += event.delta.partial_json.length;
203+
if (totalArgChars > MAX_TOTAL_TOOL_CALL_ARGS_CHARS) {
204+
throw new AnthropicBackendError(
205+
`Anthropic tool-call arguments exceed total stream cap of ${MAX_TOTAL_TOOL_CALL_ARGS_CHARS} chars`
206+
);
207+
}
199208
acc.argumentsBuf += event.delta.partial_json;
200209
}
201210
}

components/bedrock/index.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ const MAX_TOOL_CALL_ARGS_CHARS = 1 << 20;
5050
// `index` values could otherwise allocate unbounded map entries if
5151
// content_block_stop events never arrive. Matches the cap in the direct backends.
5252
const MAX_TOOL_CALL_ACCUMULATOR_ENTRIES = 128;
53+
// Total tool-call argument chars across all content blocks in one stream.
54+
const MAX_TOTAL_TOOL_CALL_ARGS_CHARS = 8 * 1024 * 1024; // 8 MiB
5355

5456
const log = harperLogger.forComponent('bedrock').conditional;
5557

@@ -371,7 +373,12 @@ function rejectNovaModel(modelId: string): void {
371373
}
372374
}
373375

374-
function buildGenerateBody(family: Family, modelId: string, input: GenerateInput, opts: BackendOpts<GenerateOpts>): object {
376+
function buildGenerateBody(
377+
family: Family,
378+
modelId: string,
379+
input: GenerateInput,
380+
opts: BackendOpts<GenerateOpts>
381+
): object {
375382
if (family === 'anthropic') return buildAnthropicBody(input, opts);
376383
if (family === 'meta') return buildLlamaBody(input, opts);
377384
if (family === 'amazon') {
@@ -551,6 +558,7 @@ async function* parseAnthropicStream(
551558
const decoder = new TextDecoder('utf-8');
552559
const toolBuf = new Map<number, { id: string; name: string; argumentsBuf: string }>();
553560
let finalFinishReason: GenerateResult['finishReason'] | undefined;
561+
let totalArgChars = 0;
554562

555563
for await (const event of body) {
556564
if (!event.chunk?.bytes) continue;
@@ -607,6 +615,12 @@ async function* parseAnthropicStream(
607615
`Bedrock tool-call arguments exceed ${MAX_TOOL_CALL_ARGS_CHARS} chars (index ${index})`
608616
);
609617
}
618+
totalArgChars += delta.partial_json.length;
619+
if (totalArgChars > MAX_TOTAL_TOOL_CALL_ARGS_CHARS) {
620+
throw new BedrockBackendError(
621+
`Bedrock tool-call arguments exceed total stream cap of ${MAX_TOTAL_TOOL_CALL_ARGS_CHARS} chars`
622+
);
623+
}
610624
acc.argumentsBuf += delta.partial_json;
611625
}
612626
}

components/openai/index.ts

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,10 @@ const MAX_UPSTREAM_ERROR_MESSAGE_CHARS = 500;
6767
// upstream can allocate unbounded map entries (one per index value). Real
6868
// responses use single-digit counts.
6969
const MAX_TOOL_CALL_ACCUMULATOR_ENTRIES = 128;
70+
// Total tool-call argument chars across all entries in one stream. The per-entry
71+
// cap (1 MiB) plus the 128-entry cap still allows ~128 MiB accumulated; this cap
72+
// keeps any single stream well-bounded. Real responses use tens of KB.
73+
const MAX_TOTAL_TOOL_CALL_ARGS_CHARS = 8 * 1024 * 1024; // 8 MiB
7074

7175
const log = harperLogger.forComponent('openai').conditional;
7276

@@ -115,7 +119,11 @@ export class OpenAIBackend implements ModelBackend {
115119
constructor(config: OpenAIBackendConfig = {}, fetchImpl: typeof fetch = fetch) {
116120
this.#apiKey = requireCredential(config.apiKey, 'OpenAI', 'apiKey', OpenAIBackendError);
117121
this.#baseUrl = normalizeOrigin(config.baseUrl, { host: DEFAULT_BASE_URL, secure: true });
118-
this.#isNativeOpenAI = this.#baseUrl.startsWith('https://api.openai.com');
122+
try {
123+
this.#isNativeOpenAI = new URL(this.#baseUrl).hostname === 'api.openai.com';
124+
} catch {
125+
this.#isNativeOpenAI = false;
126+
}
119127
this.#defaultModel = config.model;
120128
this.#organization = config.organization;
121129
this.#requestTimeoutMs = config.requestTimeoutMs;
@@ -196,6 +204,7 @@ export class OpenAIBackend implements ModelBackend {
196204
// never a partial string.
197205
const toolBuf = new Map<number, ToolCallAccumulator>();
198206
let finalFinishReason: GenerateResult['finishReason'] | undefined;
207+
let totalArgChars = 0;
199208

200209
for await (const event of readSse(res.body)) {
201210
const choice = event.choices?.[0];
@@ -207,7 +216,7 @@ export class OpenAIBackend implements ModelBackend {
207216
}
208217
if (Array.isArray(delta?.tool_calls)) {
209218
for (const tcDelta of delta.tool_calls) {
210-
accumulateToolCallDelta(toolBuf, tcDelta);
219+
totalArgChars = accumulateToolCallDelta(toolBuf, tcDelta, totalArgChars);
211220
}
212221
}
213222
if (choice.finish_reason) {
@@ -448,7 +457,11 @@ interface ToolCallAccumulator {
448457
argumentsBuf: string;
449458
}
450459

451-
function accumulateToolCallDelta(buf: Map<number, ToolCallAccumulator>, delta: OpenAIToolCallDelta): void {
460+
function accumulateToolCallDelta(
461+
buf: Map<number, ToolCallAccumulator>,
462+
delta: OpenAIToolCallDelta,
463+
totalArgChars: number
464+
): number {
452465
const index = typeof delta.index === 'number' ? delta.index : 0;
453466
let acc = buf.get(index);
454467
if (!acc) {
@@ -465,16 +478,23 @@ function accumulateToolCallDelta(buf: Map<number, ToolCallAccumulator>, delta: O
465478
if (delta.id) acc.id = delta.id;
466479
if (delta.function?.name) acc.name = delta.function.name;
467480
if (typeof delta.function?.arguments === 'string') {
468-
// Defend against an unbounded accumulator: the per-event SSE buffer cap
469-
// stops a single oversize event, but tool-call arguments are *built up*
470-
// across many sub-cap events. Throw before V8 hits string-length limits.
481+
// Per-entry cap: the per-event SSE buffer cap stops a single oversize event,
482+
// but tool-call arguments are *built up* across many sub-cap events.
471483
if (acc.argumentsBuf.length + delta.function.arguments.length > MAX_TOOL_CALL_ARGS_CHARS) {
472484
throw new OpenAIBackendError(
473485
`OpenAI tool-call arguments exceed ${MAX_TOOL_CALL_ARGS_CHARS} chars (index ${index})`
474486
);
475487
}
488+
// Total-stream cap: 128 entries each at 1 MiB still allows ~128 MiB accumulated.
489+
totalArgChars += delta.function.arguments.length;
490+
if (totalArgChars > MAX_TOTAL_TOOL_CALL_ARGS_CHARS) {
491+
throw new OpenAIBackendError(
492+
`OpenAI tool-call arguments exceed total stream cap of ${MAX_TOTAL_TOOL_CALL_ARGS_CHARS} chars`
493+
);
494+
}
476495
acc.argumentsBuf += delta.function.arguments;
477496
}
497+
return totalArgChars;
478498
}
479499

480500
function flushToolCallBuffer(buf: Map<number, ToolCallAccumulator>): Partial<ToolCall>[] {

resources/models/analyticsTable.ts

Lines changed: 31 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,7 @@ const log = harperLogger.forComponent('models').conditional;
77
const DEFAULT_FLUSH_INTERVAL_MS = 10_000; // 10s
88
const DEFAULT_MAX_BUFFER_SIZE = 1000;
99
const DEFAULT_CLEANUP_INTERVAL_MS = 60 * 60 * 1000; // 1h
10-
// 90-day default tuned for billing windows. Operator-tunable config key will land
11-
// in Phase 2 alongside the YAML→registry bootstrapper (Harper's `getConfigValue`
12-
// only reads keys registered in `CONFIG_PARAM_MAP`, so we defer config plumbing
13-
// until the first real backend ships and the key has a documented owner).
10+
// 90-day default tuned for billing windows.
1411
const DEFAULT_RETENTION_MS = 90 * 24 * 60 * 60 * 1000;
1512

1613
/**
@@ -41,6 +38,35 @@ interface BufferedRecord extends ModelCallRecord {
4138
}
4239

4340
let _table: any;
41+
42+
/**
43+
* Schema attributes for `hdb_model_calls`. Exported so tests can assert on
44+
* the structural shape (e.g. no spurious `indexed: true`) without parsing
45+
* TypeScript source.
46+
*
47+
* flush() writes via tbl.primaryStore.put and cleanup() removes via
48+
* primaryStore.remove — both bypass updateIndices, so secondary indexes
49+
* would stay permanently empty. Match hdb_raw_analytics (write.ts) which
50+
* intentionally omits `indexed` from all non-PK attributes for the same reason.
51+
*/
52+
export const MODEL_CALL_ATTRIBUTES = [
53+
{ name: 'id', isPrimaryKey: true },
54+
{ name: 'tenant', type: 'string' },
55+
{ name: 'app', type: 'string' },
56+
{ name: 'model', type: 'string' },
57+
{ name: 'backend', type: 'string' },
58+
{ name: 'method', type: 'string' },
59+
{ name: 'adapter', type: 'string' },
60+
{ name: 'conversation_id', type: 'string' },
61+
{ name: 'prompt_tokens', type: 'number' },
62+
{ name: 'completion_tokens', type: 'number' },
63+
{ name: 'embedding_tokens', type: 'number' },
64+
{ name: 'gpu_ms', type: 'number' },
65+
{ name: 'latency_ms', type: 'number' },
66+
{ name: 'success', type: 'boolean' },
67+
{ name: 'error_code', type: 'string' },
68+
];
69+
4470
/**
4571
* Lazy-getter for `hdb_model_calls`. Matches the convention used by
4672
* `getRawAnalyticsTable()` / `getAnalyticsTable()` in
@@ -54,27 +80,7 @@ export function getModelCallsTable(): any {
5480
database: 'system',
5581
audit: true,
5682
trackDeletes: false,
57-
attributes: [
58-
// flush() writes via tbl.primaryStore.put and cleanup() removes via
59-
// primaryStore.remove — both bypass updateIndices, so secondary indexes
60-
// would stay permanently empty. Match hdb_raw_analytics (write.ts) which
61-
// intentionally omits `indexed` from all non-PK attributes for the same reason.
62-
{ name: 'id', isPrimaryKey: true },
63-
{ name: 'tenant', type: 'string' },
64-
{ name: 'app', type: 'string' },
65-
{ name: 'model', type: 'string' },
66-
{ name: 'backend', type: 'string' },
67-
{ name: 'method', type: 'string' },
68-
{ name: 'adapter', type: 'string' },
69-
{ name: 'conversation_id', type: 'string' },
70-
{ name: 'prompt_tokens', type: 'number' },
71-
{ name: 'completion_tokens', type: 'number' },
72-
{ name: 'embedding_tokens', type: 'number' },
73-
{ name: 'gpu_ms', type: 'number' },
74-
{ name: 'latency_ms', type: 'number' },
75-
{ name: 'success', type: 'boolean' },
76-
{ name: 'error_code', type: 'string' },
77-
],
83+
attributes: MODEL_CALL_ATTRIBUTES,
7884
});
7985
return _table;
8086
}

resources/models/backendHelpers.ts

Lines changed: 37 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@ export function assignFiniteTokenCount(
5454
export const MAX_RESPONSE_BODY_BYTES = 64 << 20; // 64 MiB
5555
export const MAX_ERROR_BODY_BYTES = 256 << 10; // 256 KiB
5656

57+
// Module-level TextDecoder avoids per-call allocation in the streaming read path.
58+
const BODY_DECODER = new TextDecoder('utf-8');
59+
5760
/**
5861
* Read at most `maxBytes` from `res.body`, then JSON.parse. Throws the
5962
* caller's error class — never a bare `SyntaxError` or `RangeError` — so
@@ -74,29 +77,42 @@ export async function readBoundedJson<T>(
7477
}
7578
const chunks: Uint8Array[] = [];
7679
let totalBytes = 0;
77-
for await (const chunk of res.body as unknown as AsyncIterable<Uint8Array>) {
78-
totalBytes += chunk.byteLength;
79-
if (totalBytes > maxBytes) {
80-
throw new Err(
81-
`${endpoint} response body exceeds ${maxBytes}-byte limit (received >${totalBytes} bytes); ` +
82-
'rejecting to prevent unbounded memory use'
83-
);
80+
try {
81+
for await (const chunk of res.body as unknown as AsyncIterable<Uint8Array>) {
82+
totalBytes += chunk.byteLength;
83+
if (totalBytes > maxBytes) {
84+
// Release the connection promptly before throwing.
85+
res.body?.cancel?.().catch(() => {});
86+
throw new Err(
87+
`${endpoint} response body exceeds ${maxBytes}-byte limit (received >${totalBytes} bytes); ` +
88+
'rejecting to prevent unbounded memory use'
89+
);
90+
}
91+
chunks.push(chunk);
8492
}
85-
chunks.push(chunk);
93+
} catch (err) {
94+
// Re-wrap unexpected stream errors (network abort, socket reset, etc.)
95+
// so callers always see the backend's Err class, not a raw DOMException
96+
// or Node stream error. Don't rewrap errors we already constructed above.
97+
if (err instanceof Err) throw err;
98+
throw new Err(`${endpoint} stream error while reading response body: ${(err as Error)?.message ?? err}`);
8699
}
87-
const merged = totalBytes === 0 ? '' : new TextDecoder('utf-8').decode(
88-
chunks.length === 1
89-
? chunks[0]
90-
: (() => {
91-
const buf = new Uint8Array(totalBytes);
92-
let offset = 0;
93-
for (const c of chunks) {
94-
buf.set(c, offset);
95-
offset += c.byteLength;
96-
}
97-
return buf;
98-
})()
99-
);
100+
const merged =
101+
totalBytes === 0
102+
? ''
103+
: BODY_DECODER.decode(
104+
chunks.length === 1
105+
? chunks[0]
106+
: (() => {
107+
const buf = new Uint8Array(totalBytes);
108+
let offset = 0;
109+
for (const c of chunks) {
110+
buf.set(c, offset);
111+
offset += c.byteLength;
112+
}
113+
return buf;
114+
})()
115+
);
100116
try {
101117
return JSON.parse(merged) as T;
102118
} catch {

0 commit comments

Comments
 (0)