Stream OpenAI and Gemini tokens into :messages mode - #45
Open
twist900 wants to merge 1 commit into
Open
Conversation
Graph streaming already forwarded :on_token as {:message_delta, ...},
but only Anthropic honored it. SSE-stream GPT and Gemini the same way
so every built-in provider yields content chunks, while batch JSON
stays the default when neither :on_token nor :stream is set.
Co-authored-by: Cursor <cursoragent@cursor.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
LangEx.stream(..., modes: [:messages])is supposed to yield{:message_delta, ...}for every built-in provider. The graph layer already injects:on_tokenviaChatModel, but only Anthropic read it. OpenAI and Gemini always did a blocking JSON POST, so GPT/Gemini graphs emittednode_start/node_end/:doneand never a token.This is a cherry-pick of
5bfac18ontomain. #43 landed the same feature, but it targetedfeat/resumable-member-interruptsafter that branch had already been squash-merged as #41. GitHub shows #43 as merged;origin/mainstill had blocking JSON POSTs for GPT/Gemini.What
LangEx.LLM.OpenAIandLangEx.LLM.Geminihonor:on_token(1-arity) or:stream: trueand SSE-stream the body. The public return stays{:ok, %Message.AI{}, usage}.streamkey, soinvoke/3and existing batch tests keep working.Message.AIand are not emitted as content deltas.thought: true) are excluded from content.ChatModel,Graph.Stream, and Pregel are untouched.How
OpenAI (
POST {base_url}/chat/completions,:base_urlstill works for OpenRouter):stream: true,stream_options: %{include_usage: true}so the last chunk carries usage.data: {json}lines, terminated bydata: [DONE].choices[0].delta.content→on_token. Empty / null first-chunk content is ignored.delta.tool_calls[]assembled byindex(id,function.name, stringargumentsfragments).choices: []andprompt_tokens/completion_tokens. Intermediateusage: nullis ignored.Gemini (
POST .../models/{model}:streamGenerateContent?alt=sse, samex-goog-api-key)::generateContent. Streaming is the URL, not astreamfield in the JSON body.candidates[0].content.parts[].text→on_token.parts[].functionCallwith requirednameandargsas a JSON object; sameid/ same name across chunks is merged.idis kept when present.usageMetadata.promptTokenCount/candidatesTokenCount.Parsers live in
LangEx.LLM.OpenAI.SSEandLangEx.LLM.Gemini.SSE. Transport copies Anthropic: Req:intocallback + process-dictionary accumulator.Test plan
Automated (already green locally:
mix compile --warnings-as-errors,mix format --check-formatted, andmix test, 632 tests / 0 failures):"Hello"; empty/nullcontent is not a token; tool-call fragments assemble oneToolCalland never fireon_token;[DONE]ignored; usage from thechoices: []chunk:on_token/:stream: truesendstream: true+stream_options.include_usageand set:into; without those opts the body has nostreamkey;:base_urlstill usedfunctionCallobject merge (same name / same id) + parallel calls in one chunk +usageMetadata; thought parts stay out of contentstreamGenerateContent?alt=ssewithx-goog-api-key; batch still hitsgenerateContentChatModel.node(model: "gpt-4o")underLangEx.stream(..., modes: [:messages])yields two{:message_delta, ...}events before{:done, {:ok, _}}Manual / live (needs API keys; not in CI):
ChatModel.node(model: "gpt-4o")(or OpenRouter via:base_url) streamed withmodes: [:messages]— confirm content chunks appear before:done, andinvoke/3on the same graph still returns a fullMessage.AImodel: "gemini-2.0-flash"— confirmstreamGenerateContenttraffic, thought text not in the AI message, usage non-zero:messagesdeltas, and the finalMessage.AI.tool_callsis completeMade with Cursor