From a7f25e6b141064330179a5ebb89f4a029f7ec954 Mon Sep 17 00:00:00 2001 From: Sebastian Lorenz Date: Fri, 7 Aug 2026 11:15:40 +0000 Subject: [PATCH 1/2] Fix openRouter streamed citation end offsets overwrite start offsets --- .../test/OpenRouterLanguageModel.test.ts | 57 ++++++++++++++++++- 1 file changed, 56 insertions(+), 1 deletion(-) diff --git a/packages/ai/openrouter/test/OpenRouterLanguageModel.test.ts b/packages/ai/openrouter/test/OpenRouterLanguageModel.test.ts index 9ef73aac4ea..332beed23d1 100644 --- a/packages/ai/openrouter/test/OpenRouterLanguageModel.test.ts +++ b/packages/ai/openrouter/test/OpenRouterLanguageModel.test.ts @@ -1,7 +1,7 @@ import { Generated, OpenRouterClient, OpenRouterLanguageModel } from "@effect/ai-openrouter" import { assert, describe, it } from "@effect/vitest" import { deepStrictEqual, strictEqual } from "@effect/vitest/utils" -import { Array, Context, Effect, Layer, Redacted, Ref, Schema } from "effect" +import { Array, Context, Effect, Layer, Redacted, Ref, Schema, Stream } from "effect" import { LanguageModel, Prompt, Tool, Toolkit } from "effect/unstable/ai" import { HttpClient, type HttpClientError, type HttpClientRequest, HttpClientResponse } from "effect/unstable/http" @@ -209,6 +209,44 @@ describe("OpenRouterLanguageModel", () => { }).pipe(Effect.provide(makeTestLayer()))) }) }) + + describe("streamText", () => { + it.effect("preserves streamed citation start and end indexes", () => + Effect.gen(function*() { + const parts = yield* LanguageModel.streamText({ prompt: "cite a source" }).pipe( + Stream.runCollect, + Effect.provide(OpenRouterLanguageModel.model("openai/gpt-4o-mini")), + Effect.provide(makeStreamTestLayer([{ + id: "response-1", + object: "chat.completion.chunk", + model: "openai/gpt-4o-mini", + created: 1, + choices: [{ + index: 0, + delta: { + annotations: [{ + type: "url_citation", + url_citation: { + url: "https://example.com/source", + title: "source", + start_index: 2, + end_index: 9 + } + }] + } + }] + }])) + ) + + const source = globalThis.Array.from(parts).find((part) => part.type === "source") + assert.isDefined(source) + if (source?.type === "source") { + assert.deepStrictEqual(source.metadata, { + openrouter: { startIndex: 2, endIndex: 9 } + }) + } + })) + }) }) // ============================================================================= @@ -300,3 +338,20 @@ const getRequestBody = (request: HttpClientRequest.HttpClientRequest) => } return yield* Effect.die(new Error("Expected Uint8Array body")) }) + +const makeStreamTestLayer = (events: ReadonlyArray) => { + const body = events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("") + "data: [DONE]\n\n" + const httpClient = HttpClient.makeWith( + Effect.fnUntraced(function*(requestEffect) { + const request = yield* requestEffect + return HttpClientResponse.fromWeb(request, new Response(body, { + status: 200, + headers: { "content-type": "text/event-stream" } + })) + }), + Effect.succeed as HttpClient.HttpClient.Preprocess + ) + return OpenRouterClient.layer({ apiKey: Redacted.make("sk-test-key") }).pipe( + Layer.provide(Layer.succeed(HttpClient.HttpClient, httpClient)) + ) +} From cf473f879441e977f0ea4ea4f4987019737839c9 Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Fri, 7 Aug 2026 12:48:56 +0000 Subject: [PATCH 2/2] Fix streamed OpenRouter citation offsets --- .changeset/fair-citations-stream.md | 5 +++++ packages/ai/openrouter/src/OpenRouterLanguageModel.ts | 2 +- .../openrouter/test/OpenRouterLanguageModel.test.ts | 11 +++++++---- 3 files changed, 13 insertions(+), 5 deletions(-) create mode 100644 .changeset/fair-citations-stream.md diff --git a/.changeset/fair-citations-stream.md b/.changeset/fair-citations-stream.md new file mode 100644 index 00000000000..49f8e0ab129 --- /dev/null +++ b/.changeset/fair-citations-stream.md @@ -0,0 +1,5 @@ +--- +"@effect/ai-openrouter": patch +--- + +Preserve start and end offsets for streamed OpenRouter citations. diff --git a/packages/ai/openrouter/src/OpenRouterLanguageModel.ts b/packages/ai/openrouter/src/OpenRouterLanguageModel.ts index 39d08155f8b..1f94a805d80 100644 --- a/packages/ai/openrouter/src/OpenRouterLanguageModel.ts +++ b/packages/ai/openrouter/src/OpenRouterLanguageModel.ts @@ -1361,7 +1361,7 @@ const makeStreamResponse = Effect.fnUntraced( ? { startIndex: annotation.url_citation.start_index } : undefined), ...(Predicate.isNotUndefined(annotation.url_citation.end_index) - ? { startIndex: annotation.url_citation.end_index } + ? { endIndex: annotation.url_citation.end_index } : undefined) } } diff --git a/packages/ai/openrouter/test/OpenRouterLanguageModel.test.ts b/packages/ai/openrouter/test/OpenRouterLanguageModel.test.ts index 332beed23d1..d820d3f440e 100644 --- a/packages/ai/openrouter/test/OpenRouterLanguageModel.test.ts +++ b/packages/ai/openrouter/test/OpenRouterLanguageModel.test.ts @@ -344,10 +344,13 @@ const makeStreamTestLayer = (events: ReadonlyArray) => { const httpClient = HttpClient.makeWith( Effect.fnUntraced(function*(requestEffect) { const request = yield* requestEffect - return HttpClientResponse.fromWeb(request, new Response(body, { - status: 200, - headers: { "content-type": "text/event-stream" } - })) + return HttpClientResponse.fromWeb( + request, + new Response(body, { + status: 200, + headers: { "content-type": "text/event-stream" } + }) + ) }), Effect.succeed as HttpClient.HttpClient.Preprocess )