diff --git a/.changeset/curvy-streams-watch.md b/.changeset/curvy-streams-watch.md new file mode 100644 index 0000000..17d5e16 --- /dev/null +++ b/.changeset/curvy-streams-watch.md @@ -0,0 +1,5 @@ +--- +"@openrouter/agent": patch +--- + +Detect streamed Responses API results by readable stream behavior instead of constructor names or unsupported adapters. diff --git a/packages/agent/src/lib/model-result.ts b/packages/agent/src/lib/model-result.ts index c187f72..e706fe0 100644 --- a/packages/agent/src/lib/model-result.ts +++ b/packages/agent/src/lib/model-result.ts @@ -92,25 +92,22 @@ function isRecord(value: unknown): value is Record { } /** - * 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 { 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< @@ -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) ); } diff --git a/packages/agent/tests/unit/model-result-stream-detection.test.ts b/packages/agent/tests/unit/model-result-stream-detection.test.ts new file mode 100644 index 0000000..ab43938 --- /dev/null +++ b/packages/agent/tests/unit/model-result-stream-detection.test.ts @@ -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 { + return new ReadableStream({ + 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'); + }); +});