Skip to content

Stream OpenAI and Gemini tokens into :messages mode - #45

Open
twist900 wants to merge 1 commit into
mainfrom
feat/openai-gemini-token-streaming-main
Open

Stream OpenAI and Gemini tokens into :messages mode#45
twist900 wants to merge 1 commit into
mainfrom
feat/openai-gemini-token-streaming-main

Conversation

@twist900

Copy link
Copy Markdown
Collaborator

Why

LangEx.stream(..., modes: [:messages]) is supposed to yield {:message_delta, ...} for every built-in provider. The graph layer already injects :on_token via ChatModel, but only Anthropic read it. OpenAI and Gemini always did a blocking JSON POST, so GPT/Gemini graphs emitted node_start / node_end / :done and never a token.

This is a cherry-pick of 5bfac18 onto main. #43 landed the same feature, but it targeted feat/resumable-member-interrupts after that branch had already been squash-merged as #41. GitHub shows #43 as merged; origin/main still had blocking JSON POSTs for GPT/Gemini.

What

  • LangEx.LLM.OpenAI and LangEx.LLM.Gemini honor :on_token (1-arity) or :stream: true and SSE-stream the body. The public return stays {:ok, %Message.AI{}, usage}.
  • Without those opts the request is unchanged: one JSON completion, no stream key, so invoke/3 and existing batch tests keep working.
  • Tool-call / function-call payloads are assembled into the final Message.AI and are not emitted as content deltas.
  • Gemini thought parts (thought: true) are excluded from content.
  • ChatModel, Graph.Stream, and Pregel are untouched.

How

OpenAI (POST {base_url}/chat/completions, :base_url still works for OpenRouter):

  • Body: stream: true, stream_options: %{include_usage: true} so the last chunk carries usage.
  • SSE: data: {json} lines, terminated by data: [DONE].
  • Content: choices[0].delta.contenton_token. Empty / null first-chunk content is ignored.
  • Tool calls: delta.tool_calls[] assembled by index (id, function.name, string arguments fragments).
  • Usage: last chunk has choices: [] and prompt_tokens / completion_tokens. Intermediate usage: null is ignored.

Gemini (POST .../models/{model}:streamGenerateContent?alt=sse, same x-goog-api-key):

  • Batch URL stays :generateContent. Streaming is the URL, not a stream field in the JSON body.
  • Content: candidates[0].content.parts[].texton_token.
  • Function calls: parts[].functionCall with required name and args as a JSON object; same id / same name across chunks is merged. id is kept when present.
  • Usage: last chunk usageMetadata.promptTokenCount / candidatesTokenCount.

Parsers live in LangEx.LLM.OpenAI.SSE and LangEx.LLM.Gemini.SSE. Transport copies Anthropic: Req :into callback + process-dictionary accumulator.

Test plan

Automated (already green locally: mix compile --warnings-as-errors, mix format --check-formatted, and mix test, 632 tests / 0 failures):

  • OpenAI SSE parser: content deltas assemble "Hello"; empty/null content is not a token; tool-call fragments assemble one ToolCall and never fire on_token; [DONE] ignored; usage from the choices: [] chunk
  • OpenAI adapter: :on_token / :stream: true send stream: true + stream_options.include_usage and set :into; without those opts the body has no stream key; :base_url still used
  • Gemini SSE parser: text + functionCall object merge (same name / same id) + parallel calls in one chunk + usageMetadata; thought parts stay out of content
  • Gemini adapter: streaming hits streamGenerateContent?alt=sse with x-goog-api-key; batch still hits generateContent
  • ChatModel.node(model: "gpt-4o") under LangEx.stream(..., modes: [:messages]) yields two {:message_delta, ...} events before {:done, {:ok, _}}

Manual / live (needs API keys; not in CI):

  • Graph with ChatModel.node(model: "gpt-4o") (or OpenRouter via :base_url) streamed with modes: [:messages] — confirm content chunks appear before :done, and invoke/3 on the same graph still returns a full Message.AI
  • Same for model: "gemini-2.0-flash" — confirm streamGenerateContent traffic, thought text not in the AI message, usage non-zero
  • A tool-calling turn on each provider — confirm argument fragments never show up as :messages deltas, and the final Message.AI.tool_calls is complete

Made with Cursor

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant