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
5 changes: 5 additions & 0 deletions .changeset/curvy-streams-watch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@openrouter/agent": patch
---

Detect streamed Responses API results by readable stream behavior instead of constructor names or unsupported adapters.
20 changes: 7 additions & 13 deletions packages/agent/src/lib/model-result.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,25 +92,22 @@ function isRecord(value: unknown): value is Record<string, unknown> {
}

/**
* Type guard for stream event with toReadableStream method
* Checks constructor name, prototype, and method availability
* Type guard for stream event responses
* Checks constructor name and readable stream behavior
*/
function isEventStream(value: unknown): value is EventStream<models.StreamEvents> {
if (value === null || typeof value !== 'object') {
return false;
}

// Check constructor name for EventStream
const constructorName = Object.getPrototypeOf(value)?.constructor?.name;
if (constructorName === 'EventStream') {
if (typeof ReadableStream !== 'undefined' && value instanceof ReadableStream) {
return true;
}

// Fallback: check for toReadableStream method (may be on prototype)
const maybeStream = value as {
toReadableStream?: unknown;
getReader?: unknown;
};
return typeof maybeStream.toReadableStream === 'function';
return typeof maybeStream.getReader === 'function';
}

export interface GetResponseOptions<
Expand Down Expand Up @@ -432,14 +429,11 @@ export class ModelResult<

/**
* Type guard to check if a value is a non-streaming response
* Only requires 'output' field and absence of 'toReadableStream' method
* Only requires 'output' field and absence of readable stream behavior
*/
private isNonStreamingResponse(value: unknown): value is models.OpenResponsesResult {
return (
value !== null &&
typeof value === 'object' &&
'output' in value &&
!('toReadableStream' in value)
value !== null && typeof value === 'object' && 'output' in value && !isEventStream(value)
);
}

Expand Down
111 changes: 111 additions & 0 deletions packages/agent/tests/unit/model-result-stream-detection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import type { OpenRouterCore } from '@openrouter/sdk/core';
import type * as models from '@openrouter/sdk/models';
import { beforeEach, describe, expect, it, vi } from 'vitest';

const mockBetaResponsesSend = vi.hoisted(() => vi.fn());

vi.mock('@openrouter/sdk/funcs/betaResponsesSend', () => ({
betaResponsesSend: mockBetaResponsesSend,
}));

import { ModelResult } from '../../src/lib/model-result.js';

function makeResponse(): models.OpenResponsesResult {
return {
id: 'resp_test_stream_detection',
object: 'response',
createdAt: 0,
model: 'test-model',
status: 'completed',
completedAt: 0,
output: [
{
id: 'msg_test_stream_detection',
type: 'message',
role: 'assistant',
status: 'completed',
content: [
{
type: 'output_text',
text: 'ok',
annotations: [],
},
],
},
],
error: null,
incompleteDetails: null,
temperature: null,
topP: null,
presencePenalty: null,
frequencyPenalty: null,
metadata: null,
instructions: null,
tools: [],
toolChoice: 'auto',
parallelToolCalls: false,
} as models.OpenResponsesResult;
}

function makeCompletedStream(
response: models.OpenResponsesResult,
): ReadableStream<models.StreamEvents> {
return new ReadableStream<models.StreamEvents>({
start(controller) {
controller.enqueue({
type: 'response.completed',
response,
sequenceNumber: 0,
} as models.StreamEvents);
controller.close();
},
});
}

function makeResult(): ModelResult<[]> {
return new ModelResult({
request: {
model: 'test-model',
input: 'hello',
},
client: {} as OpenRouterCore,
tools: [],
});
}

describe('ModelResult stream detection', () => {
beforeEach(() => {
mockBetaResponsesSend.mockReset();
});

it('accepts ReadableStream responses whose constructor name is not EventStream', async () => {
const response = makeResponse();
const stream = makeCompletedStream(response);

expect(Object.getPrototypeOf(stream)?.constructor?.name).not.toBe('EventStream');
expect(stream).toBeInstanceOf(ReadableStream);

mockBetaResponsesSend.mockResolvedValue({
ok: true,
value: stream,
});

await expect(makeResult().getResponse()).resolves.toEqual(response);
});

it('rejects toReadableStream-only adapters before ReusableReadableStream reads them', async () => {
const response = makeResponse();
const adapter = {
toReadableStream: () => makeCompletedStream(response),
};

expect('getReader' in adapter).toBe(false);

mockBetaResponsesSend.mockResolvedValue({
ok: true,
value: adapter,
});

await expect(makeResult().getResponse()).rejects.toThrow('Unexpected response type from API');
});
});
Loading