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 9ef73aac4ea..d820d3f440e 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,23 @@ 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)) + ) +}