ADR 0012: Adopt the corespine LLM seam — chat / ChatCompletion over create_message / ProviderResponse
Move the LLMProvider Protocol to the shared family core (corespine) and adopt the OpenAI chat-completions shape; add an optional active TPM throttle via corespine RateLimitedProvider.
Status: accepted · Date: 2026-06-20
Immutable record. Exempt from drift tracking (no
covers). Supersede, don't edit.
Relates to 0009 (framework/dependency policy) and 0011 (adopting the python-project-standard). Shipped in rag-spine 0.3.0.
Context
RAGSpine has always isolated every external SDK behind a typed Protocol, with a
zero-SDK offline core and a default MockProvider (ADRs 0005 /
0009). The LLMProvider seam was defined inside ragspine
(agent/llm_provider.py) with a ragspine-specific method:
def create_message(*, system, messages, tools) -> ProviderResponse: ...Two pressures converged:
- A family of sibling packages now shares the same primitives. ragspine,
spineagent(multi-agent orchestration), and the thin shared corecorespineall need to talk to an LLM through one provider contract. Duplicating a bespoke provider Protocol per package would fragment the contract and the test seams. corespine is the family's deliberately thin shared core for exactly these domain-neutral primitives. - The bespoke
create_message(*, system, messages, tools) -> ProviderResponseshape was gratuitously non-standard. Pulling the system prompt out as a separate argument and inventing aProviderResponseenvelope diverged from the widely-understood OpenAI chat-completions shape that real SDKs (and every adapter author's mental model) already speak — friction for no benefit.
Separately, the only rate-limit defense was the SDK's passive max_retries 429 backoff,
which reacts after a limit is hit. A client that wants to stay under a contractual TPM
ceiling had no in-library knob.
Decision
Move the LLMProvider Protocol to corespine and adopt the OpenAI chat-completions shape.
ragspine takes a hard corespine>=0.1.0 runtime dependency (its only non-optional one) and
re-exports the seam from agent/llm_provider.py, so existing import sites
(from ragspine.agent.llm_provider import LLMProvider, MockProvider, AnthropicProvider) keep
working. Concretely:
- The single method becomes
chat(messages: list[dict[str, Any]], *, tools: list[dict[str, Any]] | None = None) -> ChatCompletion. The system prompt is just the first{"role": "system", ...}message — no separate argument. ProviderResponseis replaced by corespine'sChatCompletionenvelope:choices(each aChoicewrapping aResponseMessagewithcontent+tool_calls), plususage,model, andid— all frozen dataclasses, the standard chat-completions shape.AnthropicProviderkeeps lazy-importing theanthropicSDK but now maps the OpenAI-shapedmessages/toolsonto the Anthropic Messages API and the response back into aChatCompletion.ProviderErrorbecomes acorespine.CorespineErrorsubclass.- The offline
MockProviderand the tool-use loop (provider.chat(messages, tools=tools)) are updated in lock-step; the anti-fabrication guard is untouched (it never trusted provider prose for the number).
Add an optional active TPM throttle. A new RAGSPINE_TOKENS_PER_MINUTE env var (int,
default 0 = off) makes build_provider wrap the constructed provider in corespine's
RateLimitedProvider — a sliding-window soft limit that blocks before a chat call when the
window has no budget. It is complementary to the SDK's passive max_retries: one stays
under the ceiling proactively, the other recovers from a breach.
Alternatives considered (rejected)
- Keep the bespoke
create_message/ProviderResponseseam in ragspine. Rejected — it fragments the contract across the family and stays needlessly non-standard. The seam is a textbook domain-neutral primitive that belongs in the shared core. - Define the seam in ragspine and have siblings depend on ragspine. Rejected — it inverts the dependency direction (a RAG package would become a dependency of a generic agent package). corespine is the agreed sink for shared primitives; the arrows point into it.
- Rely only on SDK
max_retriesfor rate limiting. Rejected — passive backoff cannot keep a client under a contractual TPM ceiling; the two mechanisms are complementary, not redundant.
Consequences
- Breaking for custom providers. Anyone who implemented
create_message(*, system, messages, tools) -> ProviderResponsemust migrate tochat(messages, *, tools=None) -> ChatCompletion(import the dataclasses fromcorespine, or via the re-export inragspine.agent.llm_provider). The Extension points guide carries a worked OpenAI example in the new shape. - ragspine gains exactly one new non-optional dependency (
corespine>=0.1.0, which itself has emptydependencies). The offline-first, zero-heavy-SDK default path is preserved. - The provider contract is now shared across the Spine family, so a provider written for one package works for all of them.
RAGSPINE_TOKENS_PER_MINUTEis the one new config surface; everything else is internal.
ADR 0011: Adopt the python-project-standard; migrate to src/ layout, keep the rest
Adopt the house standard as governing philosophy; align the one bounded structural invariant (src/ragspine/ layout) and accept four documented divergences.
ADR 0013: Dify workflow YAML → pure-Python compiler + static optimization advisor
Compile a Dify workflow .yml into framework-free imperative Python through a de-Dify-ized IR (parse → IR → codegen), with an eight-rule static optimization advisor — PyYAML behind a new [dify] extra, fully synchronous, zero new core dependency.