RFC: Virtualize the LLM provider interface behind a normalized Adapter in ChatBase #1679
Replies: 4 comments 1 reply
🤖 Internal: Discord sync markerAuto-managed by the Discord notification workflow. Stores the linked Discord message ID and forum thread ID. Do not edit or delete. |
|
Proposed resolutions for the open questions — sharing what I'd propose so we can lock direction before I start (feedback welcome, @Rod-Christensen): 1. Where the adapter's 2. 3. Module location → a dedicated If these look right, I'll start the single implementation on that basis. |
|
Adding the interim stop-the-bleeding piece here for visibility, so it lines up with this RFC. Short-term (mine): I am gating extended thinking off at the node in llm_anthropic ( Defense-in-depth: the flatten in #1659 is parked as a draft. It stays as the safety net for when thinking is turned back on. Long-term: this RFC (the normalized adapter) is the real fix. When it lands, the node gate goes away (thinking is re-enabled behind the adapter, which round-trips reasoning correctly and keeps provider shapes out of the base), and the flatten either merges or is subsumed by the adapter. So the sequence is: disable now (#1681) so cloud is unblocked, keep #1659 as the net, then your adapter replaces both. Flagging in case anyone hits Anthropic reasoning models in the meantime, they will get no thinking until the adapter is in. This is just my read of how the interim and the RFC fit together, so please correct me if I have the sequencing wrong or if the gate should live somewhere else. |
|
@dylan-savage — agreed on landing it as one cohesive change, and the Adapter/Event shape. Before I wire the agent side, the part I want your read on is exactly Rod's caution: when do we replay thinking, and how do we keep it controlled. A few things from the agent code that shape the answer:
On control (Rod's danger) I think we need an explicit policy, not always-on:
On multi I see two scopes with different risk:
My inclination: land multi-turn-within-agent first (behind the toggle), keep cross-node as the deliberate next step once the control model is proven. Does that match your scoping, or do you want cross-node in from the start? Scope check: |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
RFC: Virtualize the LLM provider interface behind a normalized Adapter in
ChatBaseStatus: Proposal · Author: Ariel Vernaza · Area:
packages/ai(LLM chat layer)1. Objective — what we want to solve
ChatBasecurrently speaks each provider's native wire shape directly. That coupling produced the recent production incident (#1658 / #1659) and blocks correct multi-turn reasoning. Concretely:isinstance(content, list), on Anthropicthinking/text/signatureblocks, on OpenAI Responses events, onreasoning_content, onbetas, etc. Any new provider quirk becomes a base-class change.expectJson(agent) path returned it verbatim →'list' object has no attribute 'strip', or leaked rawthinkingblocks. (Root cause: the base handles provider content shapes at all.)signature/redacted_thinking; OpenAIencrypted_contentreasoning items). Today history is flattened to a text block and reasoning is discarded, so multi-turn reasoning is degraded/incorrect.build_anthropic_thinking_kwargsblanket-rejects all Haiku (so Haiku 4.5 silently gets none) and sendstype:"enabled"to adaptive-only models (Sonnet 5 / Fable →400).Goal: virtualize the OpenAI/Anthropic (and every other) provider behind a single normalized interface, and make
ChatBaseconsume only that interface. Providers become interchangeable; the base never touches a provider-native shape again.Goals
text/thinking/doneevents.2. How it works today
Entry:
LLMBase._question(llm_base.py:40) callschat.chat(question, on_chunk, on_finish, on_reasoning_chunk).Question.getPrompt()(question.py:524) flattensQuestion.historyinto a### Conversation History:text block (question.py:598-603).Dispatch: everything funnels through the monolithic
chat_string(chat.py:516-680), a 4-way branch, each branch provider-aware:flowchart TD A["LLMBase.ask / _question"] --> B["Question.getPrompt()<br/>flattens history → single string"] B --> C["ChatBase.chat_string<br/>(monolithic 4-way dispatch)"] C -->|"reasoning + Responses"| D["OpenAI Responses API<br/>responses.create(stream=True)"] C -->|"native registry"| E["Anthropic Messages<br/>messages.create(stream=True)"] C -->|"native registry"| F["openai_compat<br/>chat.completions(stream=True)"] C -->|"else / fallback"| G["LangChain .stream() / .invoke()"] D --> K["reasoning → on_reasoning_chunk<br/>(SSE 'thinking' lane, then discarded)"] E --> K F --> K D --> H{"content shape?"} E --> H F --> H G --> H H -->|"str (OpenAI-style)"| I["answer text ✅"] H -->|"list of typed blocks (Anthropic)"| J["returned verbatim →<br/>💥 crash / raw-block leak on agents"]Provider-leak points in the base today:
chat.py(Responsesresponses.create+response.*events;isinstance(content, list);thinking/text/signature/reasoningblock handling; rawopenai.OpenAIclient build;_make_think_tag_splitterfor Ollama<think>), andllm_native_stream.py(betas, adaptive/enabledthinking,_NATIVE_CREATE_KEYS,reasoning_content,messages.create/beta.messages.create). Thinking vs text is separated by two callbacks (on_chunk/on_reasoning_chunk). Reasoning is emitted to the UI and dropped — only the visible text is returned.3. How it will work — normalized Adapter
Introduce a provider-agnostic streaming interface. Every provider implements it;
ChatBaseconsumes only it.Contract:
Event("thinking"|"text", text=...)for display, in order.Event("done", items=[...])exactly once, last.itemsis provider-native and OPAQUE: appended to the adapter'shistoryverbatim. Never inspected, edited, reordered, or reserialized — both providers reject or silently misbehave on tampered reasoning state.flowchart TD A["LLMBase.ask / _question"] --> B["ChatBase (thin)<br/>drives the Adapter"] B --> C["Adapter.stream(user_text)<br/>→ Iterator[Event]"] C --> D["AnthropicAdapter<br/>messages.stream()"] C --> E["OpenAIAdapter<br/>responses.stream()"] C --> F["LangChainAdapter<br/>(the other ~18 providers)"] D --> G["Event('thinking') → SSE 'thinking' lane"] E --> G F --> G D --> H["Event('text') → answer string (always str)"] E --> H F --> H D --> I["Event('done', items=OPAQUE)<br/>→ append to history verbatim"] E --> I F --> I I --> J[("Structured conversation state<br/>reasoning signatures / encrypted items preserved")]ChatBasebecomes thin: create the right adapter, driveadapter.stream(...), routetext→answer,thinking→the existing SSE lane (llm_base.py:59), and ondoneappenditemsto the adapter'shistory. The non-streaming /expectJsonpath simply drains the stream keepingtextevents → always a plain string. The crash disappears structurally: the base never sees a provider content shape.The three adapters
AnthropicAdapter—client.messages.stream(...);thinking_delta→thinking,text_delta→text;get_final_message()gives the opaque assembledcontent(intactsignature,redacted_thinking). The per-model thinking mode (adaptive vs extended+budget vs none, Haiku 4.5 correctness) lives here, replacing the stalebuild_anthropic_thinking_kwargs.OpenAIAdapter—client.responses.stream(..., store=False);reasoning_summary_text.delta/reasoning_text.delta→thinking,output_text.delta→text;get_final_response().outputitems (withencrypted_content) are the opaqueitems.LangChainAdapter— wraps the existingChatAnthropic/ChatOpenAI/etc. so the other ~18 providers emit the sameEventstream. Itsdone.itemsis just the plain-text turn. This normalizes everything without rewriting 20 drivers to raw SDKs.4. What changes, concretely (single implementation)
We land this as one cohesive change, not phased:
packages/ai/src/ai/common/llm_adapter.py—Event,AdapterProtocol,AnthropicAdapter,OpenAIAdapter,LangChainAdapter.ChatBase— replace thechat_stringmonolith (chat.py:516-680), the native-stream registry, and_chat's rawresults.contentreturn with: build/hold anAdapter, drive itsEventstream, collecttext, forwardthinking, persistdone.items. Delete the provider-shape branches from the base.llm_native_stream.py— its handlers/_NATIVE_CREATE_KEYS/build_anthropic_thinking_kwargsmove into the adapters; the module shrinks to shared helpers (or is absorbed).Question/LLMBaseswitch fromgetPrompt() -> strto handing the adapter a structured message/turn so the adapter ownshistoryand can round-trip opaqueitems. This is a contained Python change in theclient-pythonSDK's PydanticQuestion/QuestionHistorymodels (rocketride/schema/question.py) — no engine change:QuestionHistorycarries the opaqueitems, andgetPromptstops flattening assistant reasoning to text. Single-turn works with the current flat prompt today; multi-turn is this follow-up.llm_anthropic,llm_openai, …) — their__init__selects the adapter instead of wiring_native_stream_provider;bag['chat']unchanged,getChatfactory (chat.py:743) unchanged..messages.stream()/.responses.stream()+get_final_message()/get_final_response()(today we use.create(stream=True)+ manual assembly). These are exactly what hand us the intact opaqueitems.5. What it resolves
text. (Edward's flatten fix: flatten Anthropic typed-block content on the non-streaming path #1659 stays as the stopgap until this lands.)itemsround-trip verbatim; caching & signatures preserved.AnthropicAdapter, aligned to Anthropic's current model table.6. Risks & mitigations
thinking/textsplit and node API; golden-path tests per provider (chat, agent/expectJson, streaming, non-streaming).chat()/ask()signatures; adapters default to single-turn if no structured history is supplied, so existing single-shot callers are unaffected.LangChainAdapterguarantees the 18 non-reasoning providers keep working unchanged.stream()errors surface as today (retry/map_exception); on a mid-stream failure nothing is appended tohistory, so state stays consistent (mirrors the referencesse()contract).7. Open questions for discussion
historylive across turns — on the node instance, or threaded throughQuestion?Eventstream to the node layer directly (replacing the two callbacks), or keep callbacks as a thin shim over the stream for now?llm_adapter.pyvs folding intochat.py.All reactions