Skip to content
Closed
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: 24 additions & 2 deletions src/images/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { fetchWithResetRetry, prepareSameTarget429Wait } from "../lib/upstream-r
import { rateLimitRetryDelayMs } from "../providers/key-failover";
import {
isTranslatorBudgetExceededError,
TRANSLATOR_MAX_CALL_ARGUMENT_BYTES,
TRANSLATOR_MAX_TURN_BYTES,
TranslatorBudgetExceededError,
} from "../lib/translator-budget";
Expand Down Expand Up @@ -110,6 +111,7 @@ function scanEventsForImageCall(events: AdapterEvent[], toolNames: Set<string>):
const passthrough: AdapterEvent[] = [];
let hasRealToolCall = false;
let pending: { name: string; id: string; argsBuf: string; events: AdapterEvent[] } | null = null;
let pendingArgsBytes = 0;
const flushPending = (): void => {
if (!pending) return;
if (toolNames.has(pending.name)) {
Expand All @@ -121,12 +123,17 @@ function scanEventsForImageCall(events: AdapterEvent[], toolNames: Set<string>):
hasRealToolCall = true;
}
pending = null;
pendingArgsBytes = 0;
};
for (const e of events) {
if (e.type === "tool_call_start") {
flushPending();
pending = { name: e.name, id: e.id, argsBuf: "", events: [e] };
} else if (e.type === "tool_call_delta" && pending) {
pendingArgsBytes += Buffer.byteLength(e.arguments);
if (pendingArgsBytes > TRANSLATOR_MAX_CALL_ARGUMENT_BYTES) {
throw new TranslatorBudgetExceededError("tool_args", TRANSLATOR_MAX_CALL_ARGUMENT_BYTES);
}
pending.argsBuf += e.arguments;
pending.events.push(e);
} else if (e.type === "tool_call_end" && pending) {
Expand All @@ -138,6 +145,7 @@ function scanEventsForImageCall(events: AdapterEvent[], toolNames: Set<string>):
hasRealToolCall = true;
}
pending = null;
pendingArgsBytes = 0;
} else {
flushPending();
passthrough.push(e);
Expand Down Expand Up @@ -285,6 +293,18 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise<Respons
let paidVideoCalls = 0;
let hiddenUsage: OcxUsage | undefined;

const appendBoundedIterationEvent = (events: AdapterEvent[], event: AdapterEvent, currentBytes: number): number => {
// The bridge must retain a complete semantic iteration before deciding whether synthetic
// media calls are safe to hide. Bound that unavoidable copy independently of upstream byte
// progress so a provider cannot keep the inactivity watchdog alive while exhausting memory.
const nextBytes = currentBytes + Buffer.byteLength(JSON.stringify(event)) + 1;
if (nextBytes > TRANSLATOR_MAX_TURN_BYTES) {
throw new TranslatorBudgetExceededError("retained_collectors", TRANSLATOR_MAX_TURN_BYTES);
}
events.push(event);
return nextBytes;
};

const addUsage = (a: OcxUsage | undefined, b: OcxUsage | undefined): OcxUsage | undefined => {
if (!a) return b;
if (!b) return a;
Expand Down Expand Up @@ -418,12 +438,13 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise<Respons
queue.close();
});
const events: AdapterEvent[] = [];
let eventBytes = 0;
try {
idle.reset();
for await (const event of queue.stream()) {
if (timedOut) break;
idle.reset();
events.push(event);
eventBytes = appendBoundedIterationEvent(events, event, eventBytes);
}
} finally {
idle.cancel();
Expand Down Expand Up @@ -611,6 +632,7 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise<Respons
// semantic output remains buffered for safe scanning.
const consumeIterationEvents = async function* (prepared: IterationResponse): AsyncGenerator<AdapterEvent, IterationSplit> {
const events: AdapterEvent[] = [];
let eventBytes = 0;
try {
const parse = prepared.responseAdapter.parseStream.bind(prepared.responseAdapter);
for await (const event of parseStreamWithProgress(prepared.response, parse, {
Expand All @@ -619,7 +641,7 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise<Respons
translatorBudget,
})) {
if (event.type === "heartbeat") yield event;
else events.push(event);
else eventBytes = appendBoundedIterationEvent(events, event, eventBytes);
}
} catch (error) {
if (isTranslatorBudgetExceededError(error)) throw error;
Expand Down
26 changes: 26 additions & 0 deletions tests/images/loop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { AdapterEvent, OcxParsedRequest } from "../../src/types";
import type { ImageBridgePlan, ImageCallResult } from "../../src/images/types";
import type { ImageBridgeDeps } from "../../src/images/loop";
import { createTestTranslatorBudget } from "../helpers/translator-budget";
import { TRANSLATOR_MAX_CALL_ARGUMENT_BYTES, TRANSLATOR_MAX_TURN_BYTES } from "../../src/lib/translator-budget";

const PREV_HOME = process.env.OPENCODEX_HOME;
let runWithImageBridgeProduction: typeof import("../../src/images/loop")["runWithImageBridge"];
Expand Down Expand Up @@ -119,6 +120,31 @@ describe("runWithImageBridge", () => {
expect(sse).not.toContain("event: response.completed");
});

test("bounds buffered semantic output even while the provider keeps making progress", async () => {
const sse = await runAndGetSSE([[
{ type: "text_delta", text: "x".repeat(TRANSLATOR_MAX_TURN_BYTES) },
{ type: "done" },
]]);

expect(sse).toContain("event: response.failed");
expect(sse).toContain('"code":"translation_buffer_limit"');
expect(sse).not.toContain("event: response.completed");
});

test("bounds synthetic media tool arguments before concatenating further deltas", async () => {
const sse = await runAndGetSSE([[
{ type: "tool_call_start", id: "oversized", name: "image_gen" },
{ type: "tool_call_delta", arguments: "x".repeat(TRANSLATOR_MAX_CALL_ARGUMENT_BYTES) },
{ type: "tool_call_delta", arguments: "x" },
{ type: "tool_call_end" },
{ type: "done" },
]]);

expect(sse).toContain("event: response.failed");
expect(sse).toContain('"code":"translation_buffer_limit"');
expect(sse).not.toContain("event: response.completed");
});

test("no image tool call → passthrough text + done", async () => {
const sse = await runAndGetSSE([
[{ type: "text_delta", text: "hello world" }, { type: "done" }],
Expand Down
Loading