Skip to content

@effect/ai-openrouter: Reasoning parts are duplicated when streaming a response from OpenRouter #6059

@nvonbulow

Description

@nvonbulow

What version of Effect is running?

3.19.16

What steps can reproduce the bug?

dependencies:
@effect/ai 0.33.2
@effect/ai-openrouter 0.8.2
@effect/platform 0.94.4
@effect/platform-node 0.104.1
effect 3.19.16

import { Chat, Tool, Toolkit } from "@effect/ai"
import { OpenRouterClient, OpenRouterLanguageModel } from "@effect/ai-openrouter"
import { FetchHttpClient } from "@effect/platform"
import { NodeRuntime } from "@effect/platform-node"
import { Config, Console, Effect, Layer, Schema, Stream } from "effect"

const program = Effect.gen(function*() {
  const chat = yield* Chat.empty
  let resp = yield* chat.streamText({
    prompt: "Tell me a dad joke"
  })
    .pipe(
      Stream.runForEach(Console.log)
    )
  const history = yield* chat.exportJson
  yield* Console.log(history)
})

const GlmModel = OpenRouterLanguageModel.model("z-ai/glm-4.7-flash")

const OpenRouterLive = OpenRouterClient.layerConfig({
  apiKey: Config.redacted("OPENROUTER_API_KEY")
}).pipe(Layer.provide(FetchHttpClient.layer))

const AiLayer = Layer.provideMerge(GlmModel, OpenRouterLive)

const main = program.pipe(Effect.provide(AiLayer))

NodeRuntime.runMain(main)

What is the expected behavior?

Each reasoning delta part is only emitted on the stream once

What do you see instead?

Every reasoning delta part is emitted on the stream twice, once with metadata and once without, e.g. Not sure about other part types, but text seems to be unaffected.

{
  type: 'reasoning-delta',
  id: '0',
  delta: ' one',
  metadata: { openrouter: undefined },
  '~effect/ai/Content/Part': '~effect/ai/Content/Part'
}
{
  type: 'reasoning-delta',
  id: '0',
  delta: ' one',
  metadata: { openrouter: { type: 'reasoning', signature: undefined } },
  '~effect/ai/Content/Part': '~effect/ai/Content/Part'
}

When I export the chat history, you can see that each token is duplicated in the reasoning prompt

{"content":[{"role":"user","content":"Tell me a dad joke","options":{}},{"role":"assistant","content":[{"type":"reasoning","text":"11..   ** **AnAnalyzealyze the the Request Request:**:** The The user user wants wants a a \" \"daddad joke joke.\"\n\n.\"\n\n22..   ** **IdentIdentifyify the the Persona Persona/T/Toneone::**\n**\n       * *     \" \"DDadad joke joke\"\" characteristics characteristics:: pun punss,, gro groanan-ind-inducingucing humor humor,, situ situationalational irony irony,, corn cornyy,, wholesome wholesome,, often often involves involves food food or or simple simple word wordplayplay.\n.\n       * *     Tone Tone:: Play Playfulful,, slightly slightly self self-de-deprecprecatingating,, enthusiastic enthusiastic delivery delivery ( (eveneven if if just just text text).\n\n).\n\n33..   ** **BrainBrainstormstorminging Options Options::**\n**\n       * *     * *OptionOption  11 ( (FoodFood):):** Why Why did did the the tomato tomato turn turn red red?? Because Because it it saw saw the the salad salad dressing dressing.\n.\n       * *     * *OptionOption  22 ( (PPunun):):** I I'm'm reading reading a a book book about about anti anti-gr-gravityavity.. It It's's impossible impossible to to put put down down.\n.\n       * *     * *OptionOption  33 ( (WordWordplayplay):):** I I used used to to be be a a baker baker,, but but I I couldn couldn't't make make enough enough dough dough.\n.\n       * *     * *OptionOption  44 ( (ClassicClassic Dad Dad):):** Parallel Parallel lines lines have have so so much much in in common common.. It It’s’s a a shame shame they they’ll’ll never never meet meet.\n.\n       * *     * *OptionOption  55 ( (KnKnockock-k-knocknock):):** Knock Knock knock knock.. Who Who's's there there?? Let Letttuceuce.. Let Letttuceuce who who?? Let Letttuceuce in in,, it it's's cold cold out out here here!\n\n!\n\n44..   ** **SelectSelectinging the the Best Best Option Option::**\n**\n       * *     Option Option  11 is is classic classic.\n.\n       * *     Option Option  22 is is clever clever.\n.\n       * *     Option Option  33 is is a a bit bit of of a a standard standard gro groaneraner.\n.\n       * *     * *SelectionSelection:*:* \" \"ParallelParallel lines lines\"\" is is a a strong strong contender contender because because it it feels feels very very \" \"mathmath teacher teacher dad dad,\",\" but but \" \"ReadingReading a a book book about about anti anti-gr-gravityavity\"\" is is also also excellent excellent.\n.\n       * *     Let Let's's go go with with a a classic classic pun pun that that works works well well in in text text.\n\n.\n\n       * *DraftDraft:*:* Why Why do do sc scubauba divers divers fall fall backwards backwards out out of of the the boat boat?? Because Because if if they they fell fell forwards forwards,, they they'd'd still still be be in in the the boat boat.\n\n.\n\n       * *AlternativeAlternative Draft Draft ( (ShortShorterer/P/Punchunchierier):):** I I'm'm on on a a seafood seafood diet diet.. I I see see food food and and I I eat eat it it.\n\n.\n\n       * *AlternativeAlternative Draft Draft ( (GroGroanan factor factor):):** What What do do you you call call a a fake fake nood noodlele?? An An imp impastaasta.\n\n.\n\n       * *LetLet's's go go with with the the \" \"FakeFake nood noodlele\"\" one one or or the the \" \"SeaSeafoodfood diet diet\"\" one one.. The The \" \"FakeFake nood noodlele\"\" is is a a very very solid solid dad dad joke joke.*.* Let Let's's try try one one more more slightly slightly more more situ situationalational one one.\n\n.\n\n       * *SelectionSelection:*:* \" \"II have have a a ste steplpladderadder.. I I never never knew knew my my real real ladder ladder.\"\n\n.\"\n\n       * *LetLet's's go go with with the the \" \"SeaSeafoodfood diet diet\"\" one one.. It It's's universally universally understood understood and and hits hits the the gro groanan button button perfectly perfectly.*\n\n.*\n\n55..   ** **FinalFinal Polish Polish:**:** Ensure Ensure the the format format clearly clearly states states the the joke joke.\n\n.\n\n       * *OutputOutput:*:* I I'm'm on on a a seafood seafood diet diet.. I I see see food food and and I I eat eat it it.\n\n.\n\n66..   ** **WaitWait,, let let me me give give two two in in case case the the first first one one is is too too short short..**\n**\n       * *     J Jokeoke  11:: I I'm'm on on a a seafood seafood diet diet.. I I see see food food and and I I eat eat it it.\n.\n       * *     J Jokeoke  22:: Parallel Parallel lines lines have have so so much much in in common common.. It It’s’s a a shame shame they they’ll’ll never never meet meet.\n\n.\n\n       * *DecisionDecision:*:* I I'll'll give give one one really really good good one one,, as as usually usually requested requested.\n\n.\n\n       * *ChChosenosen J Jokeoke:*:* \" \"II'm'm on on a a seafood seafood diet diet.. I I see see food food and and I I eat eat it it.\".\"","options":{}},{"type":"text","text":"I'm on a seafood diet. I see food and I eat it.","options":{}}],"options":{}}]}

Additional information

Here is an example SSE event I obtained running this model on openrouter through Curl:

curl https://openrouter.ai/api/v1/chat/completions \
                                                        -N \
                                                        -H "Authorization: Bearer $OPENROUTER_API_KEY" \
                                                        -H "Content-Type: application/json" \
                                                        -d '{
                                                      "model": "z-ai/glm-4.7-flash",
                                                      "stream": true,
                                                      "messages": [
                                                        { "role": "user", "content": "Tell me a dad joke." }
                                                      ]
                                                    }'
data: {"id":"gen-1770899608-YZYzehp0V5DKk6DK7WzY","provider":"Phala","model":"z-ai/glm-4.7-flash","object":"chat.completion.chunk","created":1770899608,"choices":[{"index":0,"delta":{"role":"assistant","content":"","reasoning":" provide","reasoning_details":[{"type":"reasoning.text","text":" provide","format":"unknown","index":0}]},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}

I believe the issue is around this code (starting line 799)

        if (Predicate.isNotNullable(delta.reasoning) && delta.reasoning.length > 0) {
          emitReasoningPart(delta.reasoning)
          // ^^ part is emitted *without* metadata, since it contains the reasoning field
        }

        if (Predicate.isNotNullable(delta.reasoning_details) && delta.reasoning_details.length > 0) {
          for (const detail of delta.reasoning_details) {
            switch (detail.type) {
              // ...
              case "reasoning.text": {
                if (Predicate.isNotUndefined(detail.text) && detail.text.length > 0) {
                  emitReasoningPart(detail.text, {
                    type: "reasoning",
                    signature: detail.signature
                  })
                  // ^^ part is emitted a second time *with* metadata, since it contains reasoning details
                }
                break
              }
            }
          }
        }

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions