diff --git a/packages/llm/ARCHITECTURE.layered.md b/packages/llm/ARCHITECTURE.layered.md
new file mode 100644
index 0000000000..7ce6055276
--- /dev/null
+++ b/packages/llm/ARCHITECTURE.layered.md
@@ -0,0 +1,334 @@
+# LLM Architecture
+
+This package has one public shape:
+
+```ts
+const model = OpenAI.model("gpt-4o-mini", { apiKey })
+const response = yield* LLM.generate({ model, prompt: "Say hello." })
+```
+
+Everything below explains how that stays simple while still supporting OpenAI, Anthropic, Gemini, Bedrock, OpenRouter, Azure, local OpenAI-compatible gateways, provider quirks, hosted tools, cache hints, and request replay.
+
+Read this as layers. Stop when the next layer is not relevant to your task.
+
+| Layer | Use it when... |
+| --- | --- |
+| 1. Public API | You are writing application code or examples. |
+| 2. Model Routing | You need to understand why provider, model, and protocol are separate. |
+| 3. Request Lifecycle | You are debugging what happens after `LLM.generate`. |
+| 4. Provider Composition | You are wiring a new deployment or protocol. |
+| 5. Provider Patches | You are preserving provider-specific behavior without polluting common schemas. |
+| 6. Design Tradeoffs | You are relating this to AI SDK or OpenCode's current provider stack. |
+
+## 1. Public API
+
+Most code should live here.
+
+```ts
+import { Effect, Layer } from "effect"
+import { LLM, RequestExecutor } from "@opencode-ai/llm"
+import { OpenAI } from "@opencode-ai/llm/providers"
+
+const model = OpenAI.model("gpt-4o-mini", {
+ apiKey: Bun.env.OPENAI_API_KEY,
+})
+
+const program = Effect.gen(function* () {
+ const response = yield* LLM.generate({
+ model,
+ prompt: "Say hello.",
+ })
+
+ console.log(response.text)
+}).pipe(
+ Effect.provide(Layer.mergeAll(
+ LLM.layer({ providers: [OpenAI] }),
+ RequestExecutor.defaultLayer,
+ )),
+)
+```
+
+The public rule is:
+
+```txt
+provider helper -> model reference -> LLM.generate / LLM.stream
+```
+
+Provider helpers should feel boring at use sites.
+
+```ts
+OpenAI.model("gpt-4o-mini", { apiKey })
+Anthropic.model("claude-3-5-sonnet-latest", { apiKey })
+Google.model("gemini-2.0-flash", { apiKey })
+OpenRouter.model("openai/gpt-4o-mini", { apiKey })
+OpenAICompatible.model("gpt-4o-mini", {
+ name: "local-gateway",
+ baseURL: "http://localhost:11434/v1",
+})
+```
+
+For OpenAI, `OpenAI.model(...)` means Responses. Use `OpenAI.chat(...)` only when you specifically need Chat Completions.
+
+
+Hidden implementation details
+
+The call site does not name adapters, protocols, endpoints, auth, framing, patches, target payloads, or stream parsers.
+
+Those are runtime concerns. They should be inspectable and composable, but not required for normal use.
+
+
+## 2. Model Routing
+
+A model reference is a route card. It says which model to call, which provider owns the deployment, and which wire protocol can talk to it.
+
+```txt
+OpenAI.model("gpt-4o-mini", { apiKey })
+ -> provider: openai
+ -> protocol: openai-responses
+ -> id: gpt-4o-mini
+
+OpenRouter.model("openai/gpt-4o-mini", { apiKey })
+ -> provider: openrouter
+ -> protocol: openai-compatible-chat
+ -> id: openai/gpt-4o-mini
+
+OpenAICompatible.model("gpt-4o-mini", { name: "local-gateway", baseURL })
+ -> provider: local-gateway
+ -> protocol: openai-compatible-chat
+ -> id: gpt-4o-mini
+```
+
+This split is the core design choice.
+
+| Concept | Question it answers |
+| --- | --- |
+| `provider` | Who is the deployment or product surface? |
+| `protocol` | Which request/response shape should the runtime use? |
+| `id` | Which model/deployment id should be sent? |
+| `baseURL` | Where should HTTP go? |
+| `apiKey`, `headers`, `queryParams`, `native` | What deployment-specific transport data is needed? |
+| `capabilities`, `limits` | What normalized features and constraints should callers see? |
+
+Provider identity and wire protocol often differ. OpenRouter is not OpenAI, but many OpenRouter models speak enough OpenAI Chat shape to reuse the OpenAI Chat protocol.
+
+
+Conceptual ModelRef shape
+
+```ts
+type ModelRef = {
+ id: ModelID
+ provider: ProviderID
+ protocol: ProtocolID
+ baseURL?: string
+ apiKey?: string
+ headers?: Record
+ queryParams?: Record
+ capabilities: ModelCapabilities
+ limits: ModelLimits
+ native?: Record
+}
+```
+
+`ModelRef` is not a provider client. It does not send requests. It is the stable, serializable description of what should be called.
+
+
+## 3. Request Lifecycle
+
+At runtime, every request follows the same path.
+
+```txt
+LLM.generate({ model, prompt })
+ -> LLM.request(...)
+ -> LLMClient
+ -> adapter selected by model.protocol
+ -> provider-native target payload
+ -> HttpClientRequest
+ -> RequestExecutor
+ -> provider response stream
+ -> LLMEvent stream
+ -> LLMResponse
+```
+
+The high-level API hides that pipeline.
+
+```ts
+const response = yield* LLM.generate({
+ model: OpenAI.model("gpt-4o-mini", { apiKey }),
+ prompt: "Say hello.",
+})
+```
+
+The lower-level runtime sees this shape.
+
+```ts
+const request = LLM.request({
+ model,
+ prompt: "Say hello.",
+})
+
+const client = LLMClient.make({
+ adapters: [OpenAIResponses.adapter, OpenAIChat.adapter],
+ patches: ProviderPatch.defaults,
+})
+
+const response = yield* client.generate(request)
+```
+
+
+Adapter pipeline
+
+The adapter is selected by `request.model.protocol`.
+
+```ts
+const adapter = adapters.get(request.model.protocol)
+const draft = adapter.prepare(request)
+const patched = applyTargetPatches(draft)
+const target = adapter.validate(patched)
+const http = adapter.toHttp(target)
+const response = yield* RequestExecutor.execute(http)
+const events = adapter.parse(response)
+```
+
+`generate` collects the same `LLMEvent` stream that `stream` exposes incrementally.
+
+
+## 4. Provider Composition
+
+Provider behavior is split across reusable layers instead of one large provider class.
+
+```txt
+Provider helper
+ creates ModelRef values
+
+Provider module
+ exports adapters and helper constructors
+
+Adapter
+ composes Protocol + Endpoint + Auth + Framing
+
+Protocol
+ owns provider-native request and stream semantics
+```
+
+The composition rule is:
+
+```txt
+Adapter = Protocol + Endpoint + Auth + Framing
+```
+
+OpenAI Chat is a normal adapter composition.
+
+```ts
+export const adapter = Adapter.make({
+ id: "openai-chat",
+ protocol: OpenAIChat.protocol,
+ endpoint: Endpoint.baseURL({
+ default: "https://api.openai.com/v1",
+ path: "/chat/completions",
+ }),
+ auth: Auth.openAI,
+ framing: Framing.sse,
+})
+```
+
+OpenAI-compatible Chat is the same protocol with different deployment axes.
+
+```txt
+OpenAI-compatible Chat adapter
+ = OpenAIChat.protocol
+ + required baseURL endpoint
+ + bearer auth
+ + SSE framing
+```
+
+That is why these can share implementation without pretending they are the same provider.
+
+```ts
+OpenAI.chat("gpt-4o-mini", { apiKey })
+OpenRouter.model("openai/gpt-4o-mini", { apiKey })
+OpenAICompatible.model("gpt-4o-mini", { name: "local-gateway", baseURL })
+```
+
+
+Layer responsibilities
+
+| Layer | Owns |
+| --- | --- |
+| Provider helper | Public constructor, defaults, provider identity, model capabilities, limits. |
+| Provider module | Exported adapters and helpers passed to `LLM.layer({ providers })`. |
+| Adapter | Runtime registration and composition. |
+| Protocol | Request lowering, target schema, chunk schema, stream state machine. |
+| Endpoint | URL construction, base URL, path, query params, deployment routing. |
+| Auth | Bearer tokens, API-key headers, SigV4, future IAM/AAD signing. |
+| Framing | Bytes to frames before protocol parsing, usually SSE. |
+
+
+
+When to add what
+
+| Need | Add |
+| --- | --- |
+| A new hosted product speaks an existing protocol | Provider helper plus adapter composition. |
+| A provider has a unique request/response shape | New protocol plus adapter composition. |
+| A provider has the same protocol but different auth | Reuse protocol, add auth axis. |
+| A provider has the same protocol but different URL rules | Reuse protocol, add endpoint axis. |
+| A provider streams non-SSE frames | Reuse or add protocol, add framing axis. |
+| A model needs a one-off body tweak | Patch, not a common schema field. |
+
+
+## 5. Provider Patches
+
+Patches are named, traceable provider/model transformations inspired by OpenCode's existing `ProviderTransform` layer.
+
+Use a patch when behavior is real but not universal enough to belong in the common request schema.
+
+```txt
+cache.prompt-hints
+anthropic.scrub-tool-call-ids
+target.openai-chat.include-usage
+```
+
+Each patch has an id, phase, predicate, and reason. Applied patches appear in `patchTrace`.
+
+Patches are not a routing mechanism. Adapter selection happens from the original `request.model`; request patches may change payload details, but changing `model.provider`, `model.id`, or `model.protocol` is rejected. If a call needs a different provider, model, or protocol, construct a different model handle before building the request.
+
+The rule is:
+
+```txt
+Common request shape stays small.
+Provider quirks stay named and auditable.
+Model routing stays explicit at the call site.
+```
+
+Good patch candidates include cache hint lowering, model-specific reasoning fields, OpenAI-compatible message cleanup, hosted-tool shape differences, metadata extraction, and provider option namespacing.
+
+Bad patch candidates are behaviors that every provider supports the same way. Those belong in the common request model.
+
+## 6. Design Tradeoffs
+
+AI SDK has an excellent use-site shape.
+
+```ts
+openai("gpt-4o-mini")
+openai.chat("gpt-4o-mini")
+createOpenAICompatible({ baseURL })("gpt-4o-mini")
+```
+
+This package keeps the use-site shape familiar.
+
+```ts
+OpenAI.model("gpt-4o-mini", { apiKey })
+OpenAI.chat("gpt-4o-mini", { apiKey })
+OpenAICompatible.model("gpt-4o-mini", { name, baseURL, apiKey })
+```
+
+The difference is below the public API.
+
+| Concern | AI SDK | This package |
+| --- | --- | --- |
+| Use site | Provider creates runnable model object. | Provider creates `ModelRef`; `LLM` runtime runs it. |
+| Provider implementation | Usually provider-package-specific language model classes. | Protocol, endpoint, auth, framing, and patches are separate axes. |
+| OpenAI-compatible reuse | Dedicated OpenAI-compatible implementation. | Reuses `OpenAIChat.protocol` with different deployment axes. |
+| Debug/replay/parity | Mostly hidden behind provider implementation. | Exposed through request lowering, patches, adapters, and events. |
+
+The tradeoff is intentional. The public API should feel small. The internals should be inspectable enough for OpenCode to preserve provider parity, replay HTTP, diff native payloads, and migrate provider-by-provider without cloning whole adapter classes.
diff --git a/packages/llm/ARCHITECTURE.use-site-to-internals.md b/packages/llm/ARCHITECTURE.use-site-to-internals.md
new file mode 100644
index 0000000000..3d6bd8e73f
--- /dev/null
+++ b/packages/llm/ARCHITECTURE.use-site-to-internals.md
@@ -0,0 +1,336 @@
+# LLM Architecture
+
+This package has one public shape:
+
+```ts
+const model = OpenAI.model("gpt-4o-mini", { apiKey })
+const response = yield * LLM.generate({ model, prompt: "Say hello." })
+```
+
+Everything below explains how that stays simple while still supporting OpenAI, Anthropic, Gemini, Bedrock, OpenRouter, Azure, local OpenAI-compatible gateways, provider quirks, hosted tools, cache hints, and request replay.
+
+Read from top to bottom. Stop when the next section is deeper than your task requires.
+
+| Section | Use it when... |
+| ------------------------------- | ------------------------------------------------------------------------------- |
+| 1. The API You Use | You are writing application code or examples. |
+| 2. What A Model Reference Means | You need to understand why provider, model, and protocol are separate. |
+| 3. What Happens At Runtime | You are debugging what happens after `LLM.generate`. |
+| 4. How Providers Are Built | You are wiring a new deployment or protocol. |
+| 5. How Quirks Are Handled | You are preserving provider-specific behavior without polluting common schemas. |
+| 6. Why This Design | You are relating this to AI SDK or OpenCode's current provider stack. |
+
+## 1. The API You Use
+
+Most code should live here.
+
+```ts
+import { Effect, Layer } from "effect"
+import { LLM, RequestExecutor } from "@opencode-ai/llm"
+import { OpenAI } from "@opencode-ai/llm/providers"
+
+const model = OpenAI.model("gpt-4o-mini", {
+ apiKey: Bun.env.OPENAI_API_KEY,
+})
+
+const program = Effect.gen(function* () {
+ const response = yield* LLM.generate({
+ model,
+ prompt: "Say hello.",
+ })
+
+ console.log(response.text)
+}).pipe(Effect.provide(Layer.mergeAll(LLM.layer({ providers: [OpenAI] }), RequestExecutor.defaultLayer)))
+```
+
+The public rule is:
+
+```txt
+provider helper -> model reference -> LLM.generate / LLM.stream
+```
+
+Provider helpers should feel boring at use sites.
+
+```ts
+OpenAI.model("gpt-4o-mini", { apiKey })
+Anthropic.model("claude-3-5-sonnet-latest", { apiKey })
+Google.model("gemini-2.0-flash", { apiKey })
+OpenRouter.model("openai/gpt-4o-mini", { apiKey })
+OpenAICompatible.model("gpt-4o-mini", {
+ name: "local-gateway",
+ baseURL: "http://localhost:11434/v1",
+})
+```
+
+For OpenAI, `OpenAI.model(...)` means Responses. Use `OpenAI.chat(...)` only when you specifically need Chat Completions.
+
+
+What this section hides
+
+The call site does not name adapters, protocols, endpoints, auth, framing, patches, target payloads, or stream parsers.
+
+Those are runtime concerns. They should be inspectable and composable, but not required for normal use.
+
+
+
+## 2. What A Model Reference Means
+
+A model reference is a route card. It says which model to call, which provider owns the deployment, and which wire protocol can talk to it.
+
+```ts
+OpenAI.model("gpt-4o-mini", { apiKey })
+ -> provider: openai
+ -> protocol: openai-responses
+ -> id: gpt-4o-mini
+
+OpenRouter.model("openai/gpt-4o-mini", { apiKey })
+ -> provider: openrouter
+ -> protocol: openai-compatible-chat
+ -> id: openai/gpt-4o-mini
+
+OpenAICompatible.model("gpt-4o-mini", { name: "local-gateway", baseURL })
+ -> provider: local-gateway
+ -> protocol: openai-compatible-chat
+ -> id: gpt-4o-mini
+```
+
+This split is the core design choice.
+
+| Concept | Question it answers |
+| -------------------------------------------- | ------------------------------------------------------------ |
+| `provider` | Who is the deployment or product surface? |
+| `protocol` | Which request/response shape should the runtime use? |
+| `id` | Which model/deployment id should be sent? |
+| `baseURL` | Where should HTTP go? |
+| `apiKey`, `headers`, `queryParams`, `native` | What deployment-specific transport data is needed? |
+| `capabilities`, `limits` | What normalized features and constraints should callers see? |
+
+Provider identity and wire protocol often differ. OpenRouter is not OpenAI, but many OpenRouter models speak enough OpenAI Chat shape to reuse the OpenAI Chat protocol.
+
+
+Conceptual ModelRef shape
+
+```ts
+type ModelRef = {
+ id: ModelID
+ provider: ProviderID
+ protocol: ProtocolID
+ baseURL?: string
+ apiKey?: string
+ headers?: Record
+ queryParams?: Record
+ capabilities: ModelCapabilities
+ limits: ModelLimits
+ native?: Record
+}
+```
+
+`ModelRef` is not a provider client. It does not send requests. It is the stable, serializable description of what should be called.
+
+
+
+## 3. What Happens At Runtime
+
+At runtime, every request follows the same path.
+
+```txt
+LLM.generate({ model, prompt })
+ -> LLM.request(...)
+ -> LLMClient
+ -> adapter selected by model.protocol
+ -> provider-native target payload
+ -> HttpClientRequest
+ -> RequestExecutor
+ -> provider response stream
+ -> LLMEvent stream
+ -> LLMResponse
+```
+
+The high-level API hides that pipeline.
+
+```ts
+const response =
+ yield *
+ LLM.generate({
+ model: OpenAI.model("gpt-4o-mini", { apiKey }),
+ prompt: "Say hello.",
+ })
+```
+
+The lower-level runtime sees this shape.
+
+```ts
+const request = LLM.request({
+ model,
+ prompt: "Say hello.",
+})
+
+const client = LLMClient.make({
+ adapters: [OpenAIResponses.adapter, OpenAIChat.adapter],
+ patches: ProviderPatch.defaults,
+})
+
+const response = yield * client.generate(request)
+```
+
+
+Adapter pipeline
+
+The adapter is selected by `request.model.protocol`.
+
+```ts
+const adapter = adapters.get(request.model.protocol)
+const draft = adapter.prepare(request)
+const patched = applyTargetPatches(draft)
+const target = adapter.validate(patched)
+const http = adapter.toHttp(target)
+const response = yield * RequestExecutor.execute(http)
+const events = adapter.parse(response)
+```
+
+`generate` collects the same `LLMEvent` stream that `stream` exposes incrementally.
+
+
+
+## 4. How Providers Are Built
+
+Provider behavior is split across reusable layers instead of one large provider class.
+
+```txt
+Provider helper
+ creates ModelRef values
+
+Provider module
+ exports adapters and helper constructors
+
+Adapter
+ composes Protocol + Endpoint + Auth + Framing
+
+Protocol
+ owns provider-native request and stream semantics
+```
+
+The composition rule is:
+
+```txt
+Adapter = Protocol + Endpoint + Auth + Framing
+```
+
+OpenAI Chat is a normal adapter composition.
+
+```ts
+export const adapter = Adapter.make({
+ id: "openai-chat",
+ protocol: OpenAIChat.protocol,
+ endpoint: Endpoint.baseURL({
+ default: "https://api.openai.com/v1",
+ path: "/chat/completions",
+ }),
+ auth: Auth.openAI,
+ framing: Framing.sse,
+})
+```
+
+OpenAI-compatible Chat is the same protocol with different deployment axes.
+
+```txt
+OpenAI-compatible Chat adapter
+ = OpenAIChat.protocol
+ + required baseURL endpoint
+ + bearer auth
+ + SSE framing
+```
+
+That is why these can share implementation without pretending they are the same provider.
+
+```ts
+OpenAI.chat("gpt-4o-mini", { apiKey })
+OpenRouter.model("openai/gpt-4o-mini", { apiKey })
+OpenAICompatible.model("gpt-4o-mini", { name: "local-gateway", baseURL })
+```
+
+
+Layer responsibilities
+
+| Layer | Owns |
+| --------------- | ----------------------------------------------------------------------------------------- |
+| Provider helper | Public constructor, defaults, provider identity, model capabilities, limits. |
+| Provider module | Exported adapters and helpers passed to `LLM.layer({ providers })`. |
+| Adapter | Runtime registration and composition. |
+| Protocol | Request lowering, target schema, chunk schema, stream state machine. |
+| Endpoint | URL construction, base URL, path, query params, deployment routing. |
+| Auth | Bearer tokens, API-key headers, SigV4, future IAM/AAD signing. |
+| Framing | Bytes to frames before protocol parsing, usually SSE. |
+
+
+
+
+When to add what
+
+| Need | Add |
+| -------------------------------------------------------- | ----------------------------------------- |
+| A new hosted product speaks an existing protocol | Provider helper plus adapter composition. |
+| A provider has a unique request/response shape | New protocol plus adapter composition. |
+| A provider has the same protocol but different auth | Reuse protocol, add auth axis. |
+| A provider has the same protocol but different URL rules | Reuse protocol, add endpoint axis. |
+| A provider streams non-SSE frames | Reuse or add protocol, add framing axis. |
+| A model needs a one-off body tweak | Patch, not a common schema field. |
+
+
+
+## 5. How Quirks Are Handled
+
+Patches are named, traceable provider/model transformations inspired by OpenCode's existing `ProviderTransform` layer.
+
+Use a patch when behavior is real but not universal enough to belong in the common request schema.
+
+```txt
+cache.prompt-hints
+anthropic.scrub-tool-call-ids
+target.openai-chat.include-usage
+```
+
+Each patch has an id, phase, predicate, and reason. Applied patches appear in `patchTrace`.
+
+Patches are not a routing mechanism. Adapter selection happens from the original `request.model`; request patches may change payload details, but changing `model.provider`, `model.id`, or `model.protocol` is rejected. If a call needs a different provider, model, or protocol, construct a different model handle before building the request.
+
+The rule is:
+
+```txt
+Common request shape stays small.
+Provider quirks stay named and auditable.
+Model routing stays explicit at the call site.
+```
+
+Good patch candidates include cache hint lowering, model-specific reasoning fields, OpenAI-compatible message cleanup, hosted-tool shape differences, metadata extraction, and provider option namespacing.
+
+Bad patch candidates are behaviors that every provider supports the same way. Those belong in the common request model.
+
+## 6. Why This Design
+
+AI SDK has an excellent use-site shape.
+
+```ts
+openai("gpt-4o-mini")
+openai.chat("gpt-4o-mini")
+createOpenAICompatible({ baseURL })("gpt-4o-mini")
+```
+
+This package keeps the use-site shape familiar.
+
+```ts
+OpenAI.model("gpt-4o-mini", { apiKey })
+OpenAI.chat("gpt-4o-mini", { apiKey })
+OpenAICompatible.model("gpt-4o-mini", { name, baseURL, apiKey })
+```
+
+The difference is below the public API.
+
+| Concern | AI SDK | This package |
+| ----------------------- | --------------------------------------------------------- | ----------------------------------------------------------------- |
+| Use site | Provider creates runnable model object. | Provider creates `ModelRef`; `LLM` runtime runs it. |
+| Provider implementation | Usually provider-package-specific language model classes. | Protocol, endpoint, auth, framing, and patches are separate axes. |
+| OpenAI-compatible reuse | Dedicated OpenAI-compatible implementation. | Reuses `OpenAIChat.protocol` with different deployment axes. |
+| Debug/replay/parity | Mostly hidden behind provider implementation. | Exposed through request lowering, patches, adapters, and events. |
+
+The tradeoff is intentional. The public API should feel small. The internals should be inspectable enough for OpenCode to preserve provider parity, replay HTTP, diff native payloads, and migrate provider-by-provider without cloning whole adapter classes.
diff --git a/packages/llm/PROPOSAL.openai-compatible-wrappers.md b/packages/llm/PROPOSAL.openai-compatible-wrappers.md
new file mode 100644
index 0000000000..dd5c610e82
--- /dev/null
+++ b/packages/llm/PROPOSAL.openai-compatible-wrappers.md
@@ -0,0 +1,229 @@
+# Proposal: OpenAI-Compatible Thin Wrappers
+
+## Summary
+
+Keep `OpenAICompatibleChat` as the shared implementation for providers that expose `/chat/completions`, but distinguish three levels of provider support:
+
+| Level | Use When | Example |
+| --- | --- | --- |
+| Profile | Provider only needs `provider`, `baseURL`, capabilities, and resolver defaults. | DeepSeek text/tool basics, TogetherAI, Cerebras, Fireworks. |
+| Thin wrapper | Provider speaks OpenAI Chat shape but needs named options, patches, capability defaults, metadata extraction, or provider-defined tools. | Mistral, Groq, Perplexity. |
+| Dedicated protocol | Request lowering or stream parsing stops being OpenAI Chat-compatible. | Not justified for these providers yet. |
+
+The important rule: do not clone `OpenAIChat.protocol` for provider wrappers unless cassettes prove the wire format has diverged. A thin wrapper should reuse the shared protocol and adapter machinery, then add only provider policy.
+
+## Current Shape
+
+Today the generic adapter is already deep and reusable:
+
+```ts
+// src/provider/openai-compatible-chat.ts
+export const adapter = Adapter.make({
+ id: "openai-compatible-chat",
+ protocol: OpenAIChat.protocol,
+ protocolId: "openai-compatible-chat",
+ endpoint: Endpoint.baseURL({
+ path: "/chat/completions",
+ required: "OpenAI-compatible Chat requires a baseURL",
+ }),
+ framing: Framing.sse,
+})
+```
+
+Provider profiles are data:
+
+```ts
+// src/provider/openai-compatible-profile.ts
+export const profiles = {
+ baseten: { provider: "baseten", baseURL: "https://inference.baseten.co/v1" },
+ cerebras: { provider: "cerebras", baseURL: "https://api.cerebras.ai/v1" },
+ deepinfra: { provider: "deepinfra", baseURL: "https://api.deepinfra.com/v1/openai" },
+ deepseek: { provider: "deepseek", baseURL: "https://api.deepseek.com/v1" },
+ fireworks: { provider: "fireworks", baseURL: "https://api.fireworks.ai/inference/v1" },
+ openrouter: { provider: "openrouter", baseURL: "https://openrouter.ai/api/v1" },
+ togetherai: { provider: "togetherai", baseURL: "https://api.together.xyz/v1" },
+}
+```
+
+Current direct call site:
+
+```ts
+const model = OpenAICompatibleChat.deepseek({
+ id: "deepseek-chat",
+ apiKey: process.env.DEEPSEEK_API_KEY,
+})
+
+const llm = LLMClient.make({ adapters: [OpenAICompatibleChat.adapter] })
+```
+
+Current generic call site:
+
+```ts
+const model = OpenAICompatible.model("moonshot-v1-8k", {
+ provider: "moonshot",
+ baseURL: "https://api.moonshot.ai/v1",
+ apiKey: process.env.MOONSHOT_API_KEY,
+})
+
+const llm = LLMClient.make({ adapters: OpenAICompatible.adapters })
+```
+
+Current OpenCode bridge shape:
+
+```ts
+const resolved = OpenAICompatibleProfiles.resolve("deepseek")
+// provider: "deepseek"
+// protocol: "openai-compatible-chat"
+// baseURL: "https://api.deepseek.com/v1"
+```
+
+Current default patches already contain provider-specific OpenAI-compatible policy:
+
+```ts
+ProviderPatch.scrubMistralToolIds
+ProviderPatch.repairMistralToolResultUserSequence
+ProviderPatch.addDeepSeekEmptyReasoning
+ProviderPatch.moveOpenAICompatibleReasoningToNative
+ProviderPatch.sanitizeMoonshotToolSchema
+ProviderPatch.addOpenAICompatibleModalities
+```
+
+That is the right direction, but Mistral/Groq/Perplexity need a named home if they grow more than one or two patch entries.
+
+## AI SDK Comparison
+
+AI SDK has a generic `@ai-sdk/openai-compatible` provider, but it does not implement Mistral, Groq, Perplexity, or xAI chat by simply configuring that generic provider.
+
+| Provider | AI SDK Shape | Why It Is Not Generic Only |
+| --- | --- | --- |
+| Mistral | Dedicated `MistralChatLanguageModel`. | `safe_prompt`, document limits, structured-output defaults, strict JSON schema, and special tool-choice mapping. |
+| Groq | Dedicated `GroqChatLanguageModel`. | `reasoning_format`, `reasoning_effort`, `service_tier`, `parallel_tool_calls`, and provider-defined `browser_search`. |
+| Perplexity | Dedicated `PerplexityLanguageModel`. | Citations, images, citation token usage, search query usage, provider option passthrough. |
+| xAI | Dedicated `XaiChatLanguageModel`. | Search parameters, reasoning effort, xAI-specific tools/options; AI SDK only reuses OpenAI-compatible for xAI image generation. |
+
+The lesson is not “copy AI SDK and create full dedicated adapters.” The lesson is that these providers have real named policy. In this package, named policy should start as thin wrappers over `OpenAICompatibleChat`.
+
+## Proposed Shape
+
+A thin wrapper is a provider-local module that reuses the common OpenAI-compatible adapter and protocol, then exports provider-specific model helpers, resolver, and patches.
+
+Example Mistral wrapper:
+
+```ts
+// src/provider/mistral.ts
+export const profile = {
+ provider: "mistral",
+ baseURL: "https://api.mistral.ai/v1",
+ capabilities: capabilities({ tools: { calls: true, streamingInput: true } }),
+} satisfies OpenAICompatibleProfile
+
+export const model = (input: ProviderFamilyModelInput) =>
+ OpenAICompatibleChat.profileModel(profile, input)
+
+export const chat = model
+
+export const patches = [
+ ProviderPatch.scrubMistralToolIds,
+ ProviderPatch.repairMistralToolResultUserSequence,
+ mistralToolChoicePatch,
+ mistralStructuredOutputPatch,
+]
+
+export const adapters = [
+ OpenAICompatibleChat.adapter.withPatches([mistralIncludeUsage]),
+]
+
+export const resolver = OpenAICompatibleProfiles.resolverFor(profile)
+
+export * as Mistral from "./mistral"
+```
+
+The direct call site becomes named and discoverable:
+
+```ts
+const model = Mistral.chat({
+ id: "mistral-large-latest",
+ apiKey: process.env.MISTRAL_API_KEY,
+})
+
+const llm = LLMClient.make({
+ adapters: Mistral.adapters,
+ patches: ProviderPatch.defaults,
+})
+```
+
+The existing generic call site still works for unwrapped providers:
+
+```ts
+const model = OpenAICompatible.model("some-model", {
+ provider: "some-provider",
+ baseURL: "https://api.some-provider.test/v1",
+ apiKey,
+})
+```
+
+OpenCode resolver call sites become clearer:
+
+```ts
+Mistral.resolver.resolve(ProviderResolver.input("mistral-large-latest", "mistral", {}))
+// provider: "mistral"
+// protocol: "openai-compatible-chat"
+// baseURL: "https://api.mistral.ai/v1"
+```
+
+## Provider Recommendations
+
+| Provider | Today | Proposed Next Step | Reason |
+| --- | --- | --- | --- |
+| DeepSeek | Profile plus default reasoning patches. | Keep profile for now. | Current cassettes cover basic text; policy is still small and shared. |
+| TogetherAI | Profile. | Keep profile. | No named provider policy yet beyond base URL. |
+| Mistral | No profile helper yet, but default Mistral patches exist. | Add thin wrapper. | Policy already exists and AI SDK has enough Mistral-specific behavior to justify a named home. |
+| Groq | No profile helper yet. | Start as profile or thin wrapper with only base URL; promote when reasoning/browser-search lands. | Basic OpenAI-compatible flow should work, but provider-defined tools and reasoning options need a wrapper. |
+| Perplexity | No profile helper yet. | Add thin wrapper if citations/sources matter; otherwise start as profile for text only. | The value of Perplexity is source/search metadata, not just text. |
+| xAI/Grok | Resolver currently points to `openai-responses`. | Keep separate from generic profiles. | xAI search/reasoning behavior is provider policy, and AI SDK treats chat as dedicated. |
+
+## Why This Is Better Than Adding More Profiles Only
+
+Profiles are excellent for base URL defaults. They become muddy when they need provider policy:
+
+```ts
+profiles.mistral = {
+ provider: "mistral",
+ baseURL: "https://api.mistral.ai/v1",
+ patches: [...], // not a profile anymore
+ options: {...}, // starts becoming a provider module
+ metadata: extract..., // definitely not profile data
+}
+```
+
+Keeping profiles as data preserves their simplicity. Thin wrappers are where behavior belongs.
+
+## Why This Is Better Than Dedicated Protocols Now
+
+A dedicated protocol would duplicate the OpenAI Chat target schema, message lowering, SSE framing, tool-call parsing, usage mapping, and finish mapping before we know those providers require it.
+
+Thin wrappers keep one source of truth:
+
+```ts
+OpenAIChat.protocol
+ -> OpenAICompatibleChat.adapter
+ -> Mistral/Groq/Perplexity wrapper policy
+```
+
+If a recorded cassette later shows a provider emits incompatible stream chunks, that is the moment to split the protocol.
+
+## Implementation Plan
+
+1. Add `src/provider/mistral.ts` as the first thin wrapper because Mistral policy already exists in `ProviderPatch.defaults`.
+2. Add Mistral to exports and provider resolver tests.
+3. Add a recorded Mistral text cassette and tool cassette.
+4. Only then decide whether Mistral needs target patches for tool-choice or structured-output behavior.
+5. Add Groq as a profile first, unless we immediately implement reasoning/browser-search options.
+6. Add Perplexity as a thin wrapper when source/citation events or metadata are modeled.
+
+## Open Questions
+
+- Should provider wrapper modules export `adapters` or rely on callers using `OpenAICompatible.adapters`?
+- Should wrapper-specific patches be included in `ProviderPatch.defaults`, or should wrappers export a `patches` list for explicit opt-in?
+- Do Perplexity citations become common `source` events/content, provider-native metadata, or both?
+- Should xAI continue routing to `openai-responses`, or should we add an xAI Chat wrapper when we add xAI cassettes?
diff --git a/packages/llm/PROPOSAL.patch-pipeline.md b/packages/llm/PROPOSAL.patch-pipeline.md
new file mode 100644
index 0000000000..ad2c08e27f
--- /dev/null
+++ b/packages/llm/PROPOSAL.patch-pipeline.md
@@ -0,0 +1,444 @@
+# Proposal: Patch Pipeline
+
+## Summary
+
+Patch behaviour is currently split between the generic patch primitives in `src/patch.ts` and the request compilation flow in `src/adapter.ts`. This proposal introduces a patch pipeline module that owns the patch lifecycle in one place.
+
+The pipeline is created once by `LLMClient.make(...)` with the client patch set. Each request then flows through that same pipeline instance. Adapter-local target patches are still supplied per selected Adapter because they vary by route.
+
+The goal is to make patch ordering, context refresh, route invariants, tool-schema handling, target patching, stream patching, and trace assembly one deep module instead of implicit knowledge inside `LLMClient.compile(...)`.
+
+## Current Shape
+
+Patch definitions are small values:
+
+```ts
+// src/patch.ts
+export interface Patch {
+ readonly id: string
+ readonly phase: PatchPhase
+ readonly reason: string
+ readonly order?: number
+ readonly when: (context: PatchContext) => boolean
+ readonly apply: (value: A, context: PatchContext) => A
+}
+```
+
+`Patch.plan(...)` handles one phase:
+
+```ts
+export function plan(input: {
+ readonly phase: PatchPhase
+ readonly context: PatchContext
+ readonly patches: ReadonlyArray>
+}): PatchPlan {
+ const patches = input.patches
+ .filter((patch) => patch.phase === input.phase && patch.when(input.context))
+ .toSorted((left, right) => (left.order ?? 0) - (right.order ?? 0) || left.id.localeCompare(right.id))
+
+ return {
+ phase: input.phase,
+ patches,
+ trace: patches.map((patch) => new PatchTrace({ id: patch.id, phase: patch.phase, reason: patch.reason })),
+ apply: (value) => patches.reduce((next, patch) => patch.apply(next, input.context), value),
+ }
+}
+```
+
+The lifecycle is embedded in `LLMClient.compile(...)`:
+
+```ts
+const requestPlan = plan({ phase: "request", context: context({ request }), patches: registry.request })
+const requestAfterRequestPatches = requestPlan.apply(request)
+yield* ensureSameRoute(request.model, requestAfterRequestPatches.model)
+
+const promptPlan = plan({ phase: "prompt", context: context({ request: requestAfterRequestPatches }), patches: registry.prompt })
+const requestBeforeToolPatches = promptPlan.apply(requestAfterRequestPatches)
+yield* ensureSameRoute(request.model, requestBeforeToolPatches.model)
+
+const toolSchemaPlan = plan({ phase: "tool-schema", context: context({ request: requestBeforeToolPatches }), patches: registry.toolSchema })
+const patchedRequest = requestBeforeToolPatches.tools.length === 0 || toolSchemaPlan.patches.length === 0
+ ? requestBeforeToolPatches
+ : new LLMRequest({ ...requestBeforeToolPatches, tools: requestBeforeToolPatches.tools.map(toolSchemaPlan.apply) })
+
+const candidate = yield* adapter.prepare(patchedRequest)
+const targetPlan = plan({ phase: "target", context: context({ request: patchedRequest }), patches: [...adapter.patches, ...registry.target] })
+const target = yield* adapter.validate(targetPlan.apply(candidate))
+const patchTrace = [...requestPlan.trace, ...promptPlan.trace, ...toolSchemaPlan.trace, ...targetPlan.trace]
+```
+
+Stream patches are another single-phase plan later in `stream(...)`:
+
+```ts
+const streamPlan = plan({ phase: "stream", context: context({ request: compiled.request }), patches: registry.stream })
+const events = compiled.adapter.parse(response, { request: compiled.request, patchTrace: compiled.patchTrace })
+return streamPlan.patches.length === 0 ? events : events.pipe(Stream.map(streamPlan.apply))
+```
+
+## Current Patch Phase Usage
+
+The runtime supports five phases today:
+
+- `request`
+- `prompt`
+- `tool-schema`
+- `target`
+- `stream`
+
+Built-in default provider policy currently uses only `prompt` through `ProviderPatch.defaults`.
+
+Built-in provider modules use `target` for opt-in adapter-local patches such as `OpenAIChat.includeUsage` and `OpenAICompatibleChat.includeUsage`.
+
+`request`, `tool-schema`, and `stream` are real runtime seams, but today they are used by tests and consumers rather than by default package policy.
+
+That is still enough to justify one lifecycle module. The runtime already has all five seams; the problem is that their ordering and interactions are owned by `LLMClient` instead of by a patch pipeline.
+
+## Problem
+
+`Patch.plan(...)` is shallow. Its Interface is almost as complex as its Implementation: callers still choose the phase, build the context, remember ordering semantics, apply the plan, stitch traces, and decide when the context must be refreshed.
+
+The deep behaviour is not in the patch module. It is spread across `LLMClient.compile(...)`:
+
+- Adapter selection happens against the original request before request-shaped patches run.
+- Request patches must run before prompt patches.
+- Prompt patches must see the request after request patches.
+- Request and prompt patches must not reroute `model.provider`, `model.id`, or `model.protocol`.
+- Tool-schema patches apply to every tool definition, but only when tools exist and patches matched.
+- Tool-schema trace appears once per matched patch, not once per tool.
+- Target patches run after Adapter lowering because they speak provider-native target shape.
+- Adapter-local target patches and client registry target patches are combined, then ordered by patch `order` and `id`.
+- Adapter validation runs after target patches, but validation logic remains owned by the Adapter.
+- Trace order must match lifecycle order.
+- Stream patches run after Adapter parsing, but use the compiled request as context.
+
+This hurts locality. A bug in patch ordering or context refresh requires reading `src/patch.ts`, `src/adapter.ts`, provider patches, and tests. The rules are not discoverable from the patch Interface.
+
+The deletion test shows the problem. Deleting `Patch.plan(...)` would not remove much complexity; callers could inline the filter/sort/reduce. Deleting the lifecycle code in `LLMClient.compile(...)` would make the complexity reappear anywhere requests need to be compiled correctly. That lifecycle is the module earning its keep, but it does not have its own seam.
+
+## Proposed Shape
+
+Introduce a patch pipeline module that closes over the client patch set once:
+
+```ts
+const pipeline = PatchPipeline.make(options.patches)
+```
+
+`PatchPipeline.make(...)` accepts the same patch inputs `LLMClient` accepts today:
+
+```ts
+PatchPipeline.make(options.patches)
+PatchPipeline.make(ProviderPatch.defaults)
+PatchPipeline.make(Patch.registry([...]))
+```
+
+The pipeline instance is immutable and reused for each request handled by that `LLMClient`.
+
+```ts
+export interface PatchPipeline {
+ readonly patchRequest: (request: LLMRequest) => Effect.Effect
+ readonly patchTarget: (input: PatchTargetInput) => Effect.Effect, LLMError>
+ readonly patchStreamEvents: (input: PatchStreamInput) => Stream.Stream
+}
+```
+
+The names should stay patch-focused. Avoid `prepareRequest` and `prepareTarget` because `LLMClient.prepare`, `Adapter.prepare`, and Protocol lowering already use prepare terminology.
+
+One possible state shape:
+
+```ts
+export interface PatchedRequest {
+ readonly original: LLMRequest
+ readonly request: LLMRequest
+ readonly trace: ReadonlyArray
+}
+
+export interface PatchTargetInput {
+ readonly state: PatchedRequest
+ readonly target: Target
+ readonly adapterPatches: ReadonlyArray>
+ readonly validateTarget: (target: Target) => Effect.Effect
+}
+
+export interface PatchedTarget {
+ readonly request: LLMRequest
+ readonly target: Target
+ readonly trace: ReadonlyArray
+}
+```
+
+Then `LLMClient.compile(...)` becomes routing plus Adapter orchestration:
+
+```ts
+const pipeline = PatchPipeline.make(options.patches)
+
+const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest) {
+ const adapter = adapters.get(request.model.protocol) ?? modelAdapters.get(request.model)
+ if (!adapter) return yield* noAdapter(request.model)
+
+ const patchedRequest = yield* pipeline.patchRequest(request)
+ const candidate = yield* adapter.prepare(patchedRequest.request)
+ const patchedTarget = yield* pipeline.patchTarget({
+ state: patchedRequest,
+ target: candidate,
+ adapterPatches: adapter.patches,
+ validateTarget: adapter.validate,
+ })
+
+ const http = yield* adapter.toHttp(patchedTarget.target, {
+ request: patchedTarget.request,
+ patchTrace: patchedTarget.trace,
+ })
+
+ return {
+ request: patchedTarget.request,
+ adapter,
+ target: patchedTarget.target,
+ http,
+ patchTrace: patchedTarget.trace,
+ }
+})
+```
+
+Stream patching also moves behind the same module, but only after Adapter parsing:
+
+```ts
+const events = compiled.adapter.parse(response, {
+ request: compiled.request,
+ patchTrace: compiled.patchTrace,
+})
+
+return pipeline.patchStreamEvents({
+ request: compiled.request,
+ events,
+})
+```
+
+This is the important cleanup: `LLMClient` no longer hand-assembles phase plans, context refresh, route protection, target patch ordering, validation timing, stream patch mapping, or patch trace concatenation.
+
+## Performance And Simplicity
+
+This design should be at least as performant as the current shape, and likely a little better, because patches generally live at client construction time rather than changing per request.
+
+Today, every request rebuilds phase plans:
+
+```ts
+plan({ phase: "request", context, patches: registry.request })
+plan({ phase: "prompt", context, patches: registry.prompt })
+plan({ phase: "tool-schema", context, patches: registry.toolSchema })
+plan({ phase: "target", context, patches: [...adapter.patches, ...registry.target] })
+```
+
+Each plan filters and sorts its phase patches. That cost is tiny compared with an LLM request, but it is still repeated work and repeated code.
+
+The patch pipeline can precompile the client-level patch set once:
+
+```ts
+const pipeline = PatchPipeline.make(options.patches)
+```
+
+At construction time, the pipeline can:
+
+- Normalize `undefined`, a patch array, or a `PatchRegistry` into one internal shape.
+- Group patches by phase.
+- Sort each client-level phase by `order` and `id` once.
+- Store empty-phase fast paths so requests with no patches avoid allocation-heavy plan construction.
+
+Per request, the pipeline still must evaluate `when(context)` predicates because predicates depend on the current request, model, protocol, metadata, tools, and provider. That part cannot be safely precompiled away unless a future patch type declares itself unconditional.
+
+Target patches are slightly different because adapter-local target patches vary by selected Adapter. Keep the first version simple:
+
+```ts
+pipeline.patchTarget({
+ state,
+ target,
+ adapterPatches: adapter.patches,
+ validateTarget: adapter.validate,
+})
+```
+
+The pipeline can combine already-sorted client target patches with adapter patches and apply the same ordering rule. If target patch counts ever become large, the pipeline can cache the sorted merged target patch list in a `WeakMap` keyed by the Adapter or by the adapter patch array. That is an internal Implementation optimization; the Interface does not need to expose it.
+
+The important simplicity win is bigger than the micro-performance win. `LLMClient` would stop describing the patch algorithm in five places. The pipeline becomes a reusable compiled patch lifecycle: one small Interface, one place to optimize, one place to test.
+
+## What The Module Owns
+
+The patch pipeline module should own:
+
+- Normalizing `PatchRegistry | ReadonlyArray | undefined` into a registry.
+- Building fresh `PatchContext` after each request-shaped phase.
+- Running request patches before prompt patches.
+- Enforcing that request-shaped patches do not change `model.provider`, `model.id`, or `model.protocol`.
+- Running tool-schema patches against every tool definition only when tools exist and patches matched.
+- Emitting tool-schema trace once per matched patch, not once per tool.
+- Combining request, prompt, tool-schema, and target traces in lifecycle order.
+- Combining adapter-local target patches with client registry target patches and applying the shared patch ordering rule.
+- Invoking Adapter target validation after target patches.
+- Applying stream patches to parsed `LLMEvent` streams with the compiled request context.
+
+It should not own:
+
+- Adapter lookup.
+- Protocol lowering via `adapter.prepare(...)`.
+- Target validation Implementation.
+- HTTP request construction.
+- Provider-specific patch definitions.
+- Provider stream parsing.
+
+Those remain behind the Adapter, Protocol, Endpoint, Auth, Framing, ProviderPatch, and RequestExecutor modules.
+
+## How This Cleans Up Code Elsewhere
+
+`src/adapter.ts` gets smaller and more navigable:
+
+- `normalizeRegistry(...)` moves out.
+- `ensureSameRoute(...)` moves out.
+- `compile(...)` stops constructing four separate plans.
+- `compile(...)` stops manually refreshing contexts.
+- `compile(...)` stops manually deciding when tool-schema traces count.
+- `compile(...)` stops manually concatenating patch traces.
+- `stream(...)` stops manually planning stream patches.
+
+`src/patch.ts` becomes clearer:
+
+- Patch constructors and predicates remain the primitive Interface.
+- `plan(...)` can stay as an internal or low-level single-phase helper.
+- Lifecycle semantics move to `src/patch-pipeline.ts` instead of being implied by Adapter tests.
+
+Provider patch modules stay focused:
+
+- `ProviderPatch.defaults` remains a list of provider facts.
+- Provider-specific patches do not need to know lifecycle ordering.
+- Adapter-local target patches keep living on the selected Adapter.
+
+Tests get better locality:
+
+- Patch primitive tests stay in `patch.test.ts`.
+- Patch lifecycle tests move to `patch-pipeline.test.ts`.
+- Adapter tests keep only Adapter responsibilities and one end-to-end smoke test that `LLMClient` invokes the pipeline.
+
+## Why This Is Deepening
+
+The patch pipeline would be a deeper module because a small Interface hides a larger amount of behaviour.
+
+Current Interface:
+
+```ts
+plan({ phase, context, patches }).apply(value)
+```
+
+That Interface is shallow because the caller must know the lifecycle.
+
+Proposed Interface:
+
+```ts
+const pipeline = PatchPipeline.make(options.patches)
+const request = yield* pipeline.patchRequest(input)
+const target = yield* pipeline.patchTarget({ state: request, target, adapterPatches, validateTarget })
+const events = pipeline.patchStreamEvents({ request: target.request, events })
+```
+
+That Interface is deeper because callers get ordering, context refresh, route protection, tool-schema handling, target patch composition, validation timing, stream mapping, and trace assembly without knowing each step.
+
+## Principles
+
+### Module
+
+Today, the real patch lifecycle is an unnamed module embedded in `LLMClient.compile(...)`. Naming it as a patch pipeline module gives it one Interface and one Implementation.
+
+### Interface
+
+The Interface becomes the test surface. Tests should ask what the pipeline guarantees: request patches run before prompt patches, contexts refresh, route changes fail, target patches trace after tool-schema patches, validation runs after target patches, and stream patches see the compiled request.
+
+### Depth
+
+The module becomes deep because callers learn a small lifecycle Interface instead of the full phase choreography. More behaviour sits behind less required knowledge.
+
+### Seam
+
+The seam moves from scattered calls to `plan(...)` into the patch pipeline Interface. The existing patch Interface remains the seam where provider-specific patch behaviour enters the lifecycle.
+
+### Adapter
+
+Provider-specific patches are Adapters at the patch seam: each concrete patch satisfies the patch Interface. Adapter-local target patches remain local to the selected Adapter, but the pipeline owns how those patches combine with client registry target patches.
+
+### Leverage
+
+Callers get more leverage because `LLMClient`, tests, and future request-compilation paths can reuse one lifecycle. A fix to context refresh or route protection pays back everywhere.
+
+### Locality
+
+Maintainers get more locality because patch bugs concentrate in the patch pipeline Implementation. Provider patches can stay focused on provider facts instead of lifecycle rules.
+
+### Deletion Test
+
+Deleting the current `plan(...)` helper removes only a small filter/sort/reduce. Deleting the proposed patch pipeline would make lifecycle complexity reappear in `LLMClient`, tests, and any future compilation path. That means the proposed module earns its keep.
+
+### One Adapter = Hypothetical Seam, Two Adapters = Real Seam
+
+This proposal does not add a speculative seam with fake alternative implementations. It deepens an existing real seam: many provider patches already satisfy the patch Interface, and adapter-local plus client registry target patches already vary across providers and call sites. The missing piece is locality for the lifecycle that applies those Adapters.
+
+## Benefits
+
+Locality improves because lifecycle rules live in one module instead of being embedded in request compilation.
+
+Leverage improves because every provider patch and every client path gets the same ordering, trace, validation timing, and route-invariant behaviour.
+
+Tests improve because the patch pipeline Interface becomes the test surface. Instead of constructing fake protocols, fake adapters, fake framing, and scripted HTTP flows to verify patch lifecycle behaviour, tests can exercise the lifecycle directly.
+
+Useful tests:
+
+- Adapter selection happens before request patches.
+- Request patches run before prompt patches.
+- Prompt patch predicates see the request after request patches.
+- Request-shaped patches cannot change `model.provider`, `model.id`, or `model.protocol`.
+- Tool-schema patches are skipped when there are no tools.
+- Tool-schema traces appear only when tool-schema patches ran.
+- Tool-schema trace appears once per matched patch, not once per tool.
+- Adapter target patches and client registry target patches follow the shared patch ordering rule.
+- Target validation runs after target patches.
+- Stream patches see the compiled request, not the original request.
+- Pipeline construction accepts `undefined`, a patch array, or a `PatchRegistry`.
+
+## What Not To Do Yet
+
+Do not change the public patch definition shape unless the pipeline proves it needs a missing field.
+
+Do not create a full plugin system for patch ordering.
+
+Do not move provider-specific patch logic into the pipeline.
+
+Do not make target patch typing more ambitious in this step; target patches are already typed at adapter construction sites and erased in the registry.
+
+Do not move Adapter lookup, Protocol lowering, HTTP construction, or stream parsing into the pipeline.
+
+Do not change provider behaviour while extracting the lifecycle.
+
+## Migration Plan
+
+1. Add `src/patch-pipeline.ts` with the lifecycle Implementation and focused tests.
+2. Keep `Patch.plan(...)` public during migration and use it internally inside the pipeline.
+3. Move `normalizeRegistry(...)` and `ensureSameRoute(...)` from `src/adapter.ts` into the pipeline module.
+4. Add `patchRequest(...)` that runs request, prompt, and tool-schema phases and returns a carried request state.
+5. Add `patchTarget(...)` that applies adapter-local target patches, client registry target patches, Adapter validation, and returns a carried target state with combined trace.
+6. Add `patchStreamEvents(...)` that applies stream patches to parsed `LLMEvent` streams.
+7. Add `test/patch-pipeline.test.ts` with lifecycle tests before changing `LLMClient`.
+8. Replace handwritten phase choreography in `LLMClient.compile(...)` and `LLMClient.stream(...)` with the pipeline.
+9. Keep one adapter-level smoke test proving `LLMClient` invokes patches end-to-end.
+10. Move or delete adapter-level lifecycle tests that are now covered by patch pipeline tests.
+11. Decide later whether `Patch.plan(...)` remains public or becomes internal.
+
+## Open Questions
+
+Should `Patch.plan(...)` remain public as a low-level primitive, or should the patch pipeline become the only exported lifecycle Interface?
+
+Should stream patches be part of the same pipeline module from the first extraction, or should the first extraction focus only on request-to-target compilation?
+
+Should the pipeline return one combined trace array, or should it preserve phase-grouped traces internally for better debugging while exposing one ordered trace to callers?
+
+Should route protection apply only after request and prompt phases, or should the pipeline also assert that target and stream phases cannot observe changed route state?
+
+Should target patch ordering keep the current global `order`/`id` rule across adapter-local and client registry patches, or should adapter-local target patches get an explicit ordering band before client registry target patches?
+
+## Recommendation
+
+Do this before adding more provider-specific patches. The current shape is already correct enough to extract safely, and the next set of provider quirks will make patch ordering and conversation-shape rules more important. A patch pipeline module would turn implicit lifecycle knowledge into a deep Interface with better locality, better leverage, and a clearer test surface.
diff --git a/packages/llm/PROPOSAL.provider-profiles.md b/packages/llm/PROPOSAL.provider-profiles.md
deleted file mode 100644
index 59b767d09c..0000000000
--- a/packages/llm/PROPOSAL.provider-profiles.md
+++ /dev/null
@@ -1,223 +0,0 @@
-# Proposal: Provider Profiles
-
-## Summary
-
-OpenAI-compatible provider knowledge is currently split across provider data, model helpers, resolver wiring, public provider wrappers, and tests. This proposal introduces a provider profile module that owns the facts for each OpenAI-compatible provider in one place.
-
-The goal is to make adding or changing an OpenAI-compatible provider a one-profile edit instead of a small hunt across modules.
-
-## Current Shape
-
-Provider defaults live here:
-
-```ts
-// src/provider/openai-compatible-profile.ts
-export const profiles = {
- baseten: { provider: "baseten", baseURL: "https://inference.baseten.co/v1" },
- cerebras: { provider: "cerebras", baseURL: "https://api.cerebras.ai/v1" },
- deepinfra: { provider: "deepinfra", baseURL: "https://api.deepinfra.com/v1/openai" },
- deepseek: { provider: "deepseek", baseURL: "https://api.deepseek.com/v1" },
- fireworks: { provider: "fireworks", baseURL: "https://api.fireworks.ai/inference/v1" },
- togetherai: { provider: "togetherai", baseURL: "https://api.together.xyz/v1" },
-}
-```
-
-Model helpers live in another module:
-
-```ts
-// src/provider/openai-compatible-chat.ts
-export const deepseek = (input) => familyModel(families.deepseek, input)
-export const togetherai = (input) => familyModel(families.togetherai, input)
-```
-
-Resolver behavior is also derived in `openai-compatible-family.ts`:
-
-```ts
-const resolutions = Object.fromEntries(
- Object.values(families).map((family) => [
- family.provider,
- ProviderResolver.make(family.provider, "openai-compatible-chat", { baseURL: family.baseURL }),
- ]),
-)
-```
-
-OpenRouter has a separate wrapper that repeats the same shape:
-
-```ts
-// src/provider/openrouter.ts
-const baseURL = "https://openrouter.ai/api/v1"
-
-export const resolver = ProviderResolver.fixed("openrouter", "openai-compatible-chat", {
- baseURL,
-})
-
-export const model = (id, options = {}) =>
- OpenAICompatible.model(id, {
- ...options,
- provider: "openrouter",
- baseURL: options.baseURL ?? baseURL,
- })
-```
-
-Each piece is small, but the provider concept is scattered.
-
-## Problem
-
-The OpenAI-compatible provider module is shallow. Its interface gives callers a few helpers, but its implementation does not own the full provider concept.
-
-To answer "what does DeepSeek mean in this package?" a maintainer has to inspect multiple places:
-
-- `openai-compatible-family.ts` for id and base URL.
-- `openai-compatible-chat.ts` for model helper behavior and capabilities.
-- `provider-resolver.test.ts` for bridge expectations.
-- Provider-specific wrapper modules like `openrouter.ts` to see which providers are special-cased.
-- Patch TODOs in `AGENTS.md` to know which providers may need custom options or cleanup.
-
-This hurts locality. Adding Mistral, Groq, Perplexity, Cohere, or more OpenAI-compatible families will likely spread more provider facts across the same modules.
-
-## Proposed Shape
-
-Introduce provider profiles:
-
-```ts
-export interface OpenAICompatibleProfile {
- readonly provider: string
- readonly baseURL?: string
- readonly displayName?: string
- readonly capabilities?: LLM.CapabilitiesInput
- readonly resolver?: Partial>
- readonly modelDefaults?: Partial>
-}
-```
-
-Then define profiles in one module:
-
-```ts
-export const profiles = {
- deepseek: {
- provider: "deepseek",
- baseURL: "https://api.deepseek.com/v1",
- capabilities: { tools: { calls: true, streamingInput: true } },
- },
- togetherai: {
- provider: "togetherai",
- baseURL: "https://api.together.xyz/v1",
- },
- openrouter: {
- provider: "openrouter",
- baseURL: "https://openrouter.ai/api/v1",
- },
-} as const satisfies Record
-```
-
-The profile module owns the basic observations:
-
-```ts
-export const byProvider = Object.fromEntries(
- Object.values(profiles).map((profile) => [profile.provider, profile]),
-)
-
-export const resolve = (provider: string) => {
- const profile = byProvider[provider]
- return ProviderResolver.make(provider, "openai-compatible-chat", {
- baseURL: profile?.baseURL,
- capabilities: profile?.capabilities,
- ...profile?.resolver,
- })
-}
-
-export const model = (profile: OpenAICompatibleProfile, id: string, options = {}) =>
- OpenAICompatibleChat.model({
- ...profile.modelDefaults,
- ...options,
- id,
- provider: profile.provider,
- baseURL: options.baseURL ?? profile.baseURL,
- })
-```
-
-Provider wrappers become tiny aliases over profiles:
-
-```ts
-// src/provider/openrouter.ts
-export const profile = OpenAICompatibleProfiles.profiles.openrouter
-export const resolver = OpenAICompatibleProfiles.resolverFor(profile)
-export const adapters = [OpenAICompatibleChat.adapter]
-export const model = (id: string, options = {}) => OpenAICompatibleProfiles.model(profile, id, options)
-export const chat = model
-```
-
-Family helpers become profile-derived:
-
-```ts
-export const deepseek = (id: string, options = {}) =>
- OpenAICompatibleProfiles.model(OpenAICompatibleProfiles.profiles.deepseek, id, options)
-```
-
-## Why This Is Deepening
-
-The provider profile module would be a deeper module because a small interface hides a larger set of provider facts.
-
-The interface is the profile table plus a few observations:
-
-```ts
-OpenAICompatibleProfiles.resolve(provider)
-OpenAICompatibleProfiles.model(profile, id, options)
-OpenAICompatibleProfiles.byProvider[provider]
-```
-
-The implementation hides base URL defaults, resolver construction, default capabilities, model helper construction, and future provider-specific option defaults.
-
-The deletion test says this module would earn its keep. If deleted, the provider facts would spread back into resolver code, wrapper modules, model helpers, and tests.
-
-## Benefits
-
-Locality improves because one provider profile owns the provider's base URL, default capabilities, resolver behavior, and model defaults.
-
-Leverage improves because adding a provider like Mistral or Groq starts as one profile entry. If it later needs a thin wrapper or dedicated patch, that decision is attached to the profile instead of being rediscovered across files.
-
-Tests improve because provider behavior can be tested at the profile interface:
-
-```ts
-expect(OpenAICompatibleProfiles.resolve("deepseek")).toMatchObject({
- provider: "deepseek",
- protocol: "openai-compatible-chat",
- baseURL: "https://api.deepseek.com/v1",
-})
-```
-
-The wrapper tests can shrink because they no longer need to prove the same base URL wiring repeatedly.
-
-## What Not To Do Yet
-
-Do not turn profiles into a full plugin system.
-
-Do not add arbitrary route predicates or ranking.
-
-Do not pre-design every future provider quirk.
-
-Do not move non-OpenAI-compatible providers into this table.
-
-The first version should only consolidate facts that already exist: provider id, base URL, resolver defaults, model defaults, and capabilities.
-
-## Migration Plan
-
-1. Rename or replace `openai-compatible-family.ts` with `openai-compatible-profile.ts`.
-2. Move the existing `families` entries into `profiles` without changing behavior.
-3. Add profile helpers for `resolve`, `resolverFor`, and `model`.
-4. Update `openai-compatible-chat.ts` family helpers to use profiles.
-5. Update `openrouter.ts` to use an OpenRouter profile.
-6. Keep current public helper names such as `OpenAICompatibleChat.deepseek(...)` and `OpenRouter.model(...)`.
-7. Update resolver tests to assert through the profile interface.
-
-## Open Questions
-
-Should OpenRouter live in the OpenAI-compatible profile table even though it has a first-class public provider wrapper?
-
-Should profiles include patch defaults later, or should patches remain entirely separate until a provider has concrete behavior to trace?
-
-Should Mistral/Groq/Perplexity/Cohere start as profiles, or should they wait until recorded cassettes show whether they need thin dedicated wrappers?
-
-## Recommendation
-
-Do this as a small consolidation before adding more OpenAI-compatible providers. The module is likely to pay for itself immediately because the next provider decisions already need a single place to record what each provider is: generic compatible, compatible with quirks, or deserving a thin wrapper.
diff --git a/packages/llm/TODO.provider-transform-parity.md b/packages/llm/TODO.provider-transform-parity.md
new file mode 100644
index 0000000000..e402995991
--- /dev/null
+++ b/packages/llm/TODO.provider-transform-parity.md
@@ -0,0 +1,146 @@
+# Provider Transform Parity TODO
+
+This tracks OpenCode behavior from `packages/opencode/src/provider/transform.ts` that is not fully represented in `packages/llm` yet.
+
+Patches are the right seam when the behavior is a provider/model quirk that mutates request history, tool schemas, target bodies, or stream events. Do not add fields to the common request model just to carry one provider's native option.
+
+## Ported Or Covered
+
+- Empty Anthropic/Bedrock content cleanup: `ProviderPatch.removeEmptyAnthropicContent`.
+- Claude tool id scrub: `ProviderPatch.scrubClaudeToolIds`.
+- Mistral/Devstral tool id scrub: `ProviderPatch.scrubMistralToolIds`.
+- Anthropic assistant `tool_use` ordering repair: `ProviderPatch.repairAnthropicToolUseOrder`.
+- Mistral `tool -> user` sequence repair: `ProviderPatch.repairMistralToolResultUserSequence`.
+- DeepSeek empty reasoning replay: `ProviderPatch.addDeepSeekEmptyReasoning` plus OpenAI-compatible native `reasoning_content` lowering.
+- OpenAI-compatible reasoning history replay: `ProviderPatch.moveOpenAICompatibleReasoningToNative`.
+- Unsupported user media fallback: `ProviderPatch.unsupportedMediaFallback`.
+- Moonshot/Kimi schema sanitizer: `ProviderPatch.sanitizeMoonshotToolSchema`.
+- Prompt cache hint placement: `ProviderPatch.cachePromptHints`.
+- Gemini schema sanitizer/projector: handled inside `Gemini.protocol` because Gemini has a distinct schema dialect.
+- OpenAI Chat/OpenAI-compatible streaming usage: adapter-local target patches.
+
+## Not Fully Ported
+
+### Provider Option Namespacing
+
+OpenCode behavior:
+
+- `ProviderTransform.providerOptions(...)` maps option bags into SDK namespaces like `openai`, `azure`, `gateway`, `openrouter`, `bedrock`, or model-derived Gateway upstream slugs.
+- Azure currently writes both `{ openai: options, azure: options }` because different AI SDK code paths read different namespaces.
+- Gateway splits `gateway` routing/caching controls from upstream model options.
+
+Native status:
+
+- Not ported as a general system.
+- The native OpenCode bridge currently falls back when prepared provider options are non-empty.
+
+Likely shape:
+
+- Target patches for provider-native body knobs when the adapter target has a real field.
+- Bridge-level lowering for opaque OpenCode provider options until each option has a typed native destination.
+
+### `options(...)` Defaults
+
+OpenCode behavior includes many default body/provider options:
+
+- `store: false` for OpenAI, Azure, and GitHub Copilot.
+- `promptCacheKey` / `prompt_cache_key` from session id for OpenAI, Azure, Venice, OpenRouter, and some opencode-hosted models.
+- OpenRouter/Gateway usage inclusion.
+- Google/Gemini `thinkingConfig` defaults.
+- Anthropic/Kimi default `thinking` budget.
+- Alibaba `enable_thinking` for reasoning models.
+- GPT-5 default `reasoningEffort`, `reasoningSummary`, encrypted-content `include`, and `textVerbosity`.
+- Baseten/opencode `chat_template_args.enable_thinking`.
+- Z.ai/Zhipu `thinking.clear_thinking`.
+- Gateway caching controls.
+
+Native status:
+
+- Partially represented by common `request.reasoning`, `request.cache`, and adapter-specific cache lowering.
+- Most provider-native default knobs are not ported.
+
+Likely shape:
+
+- Adapter-local target patches where the target schema can express the option.
+- New target fields only when the provider actually accepts them.
+- Avoid a generic `providerOptions` escape hatch unless the bridge still needs temporary fallback behavior.
+
+### Reasoning Variants
+
+OpenCode behavior:
+
+- `ProviderTransform.variants(...)` maps named effort presets (`low`, `high`, `max`, etc.) to provider-native option objects.
+- The mapping differs by OpenAI, Azure, Anthropic, Bedrock, Gemini, Gateway, OpenRouter, Copilot, Groq, Mistral, xAI, and generic OpenAI-compatible providers.
+- Some models deliberately return no variants despite advertising reasoning.
+
+Native status:
+
+- Common `ReasoningIntent` has `enabled`, `effort`, `summary`, and `encryptedContent`.
+- Provider-specific target mappings are incomplete.
+
+Likely shape:
+
+- Keep the common intent small.
+- Add provider/model target patches that translate `request.reasoning` into each adapter target's native fields.
+- Add tests per provider family because invalid reasoning fields are common provider rejection causes.
+
+### Sampling Defaults
+
+OpenCode behavior:
+
+- `temperature(model)` returns defaults for Qwen, Claude, Gemini, GLM, Minimax, and Kimi variants.
+- `topP(model)` returns defaults for Qwen, Minimax, Gemini, and Kimi variants.
+- `topK(model)` returns defaults for Minimax and Gemini.
+
+Native status:
+
+- Common `generation` supports `temperature` and `topP` only when the caller sets them.
+- `topK` is not currently a common generation field.
+- Model-specific defaults are not ported.
+
+Likely shape:
+
+- Request or target patches that fill unset generation fields for specific models.
+- Add `topK` only when enough adapters support it or when a specific adapter target needs it.
+
+### Small Model Options
+
+OpenCode behavior:
+
+- `smallOptions(model)` disables or minimizes reasoning for summarization/small requests.
+- Examples: OpenAI `reasoningEffort: minimal/low`, Google `thinkingBudget: 0`, OpenRouter/Gateway reasoning disabled, Venice `disableThinking`.
+
+Native status:
+
+- Not ported.
+- The native API does not currently distinguish regular requests from “small” internal requests at the LLM package boundary.
+
+Likely shape:
+
+- First define how OpenCode marks a request as small in `LLMRequest` or bridge metadata.
+- Then use target patches keyed on that marker and provider/model.
+
+### Interleaved Reasoning Field Variants
+
+OpenCode behavior:
+
+- Some OpenAI-compatible providers replay assistant reasoning under provider-native fields such as `reasoning_content` or `reasoning_details`.
+- OpenRouter is excluded in the old transform for this path.
+
+Native status:
+
+- `reasoning_content` is covered for OpenAI-compatible Chat.
+- Other field names like `reasoning_details` are not modeled yet.
+
+Likely shape:
+
+- Store the chosen field in model profile/native metadata.
+- A prompt patch moves common reasoning parts into that provider-native field.
+- The OpenAI-compatible target schema/lowerer emits the selected field.
+
+## Suggested Order
+
+1. Add target patches for high-confidence OpenAI/OpenAI-compatible defaults that already have target fields.
+2. Add provider-family reasoning mapping tests before porting more variants.
+3. Define the bridge marker for “small” requests before implementing `smallOptions` parity.
+4. Keep provider option namespacing in the bridge until individual native destinations are known.
diff --git a/packages/llm/src/provider/openai-chat.ts b/packages/llm/src/provider/openai-chat.ts
index 86778ffba9..ae06ad947d 100644
--- a/packages/llm/src/provider/openai-chat.ts
+++ b/packages/llm/src/provider/openai-chat.ts
@@ -52,6 +52,7 @@ const OpenAIChatMessage = Schema.Union([
role: Schema.Literal("assistant"),
content: Schema.NullOr(Schema.String),
tool_calls: Schema.optional(Schema.Array(OpenAIChatAssistantToolCall)),
+ reasoning_content: Schema.optional(Schema.String),
}),
Schema.Struct({ role: Schema.Literal("tool"), tool_call_id: Schema.String, content: Schema.String }),
])
@@ -171,6 +172,9 @@ const lowerToolCall = (part: ToolCallPart): OpenAIChatAssistantToolCall => ({
},
})
+const isRecord = (value: unknown): value is Record =>
+ typeof value === "object" && value !== null && !Array.isArray(value)
+
const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request: LLMRequest) {
const system: OpenAIChatMessage[] =
request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }]
@@ -205,6 +209,9 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
role: "assistant",
content: content.length === 0 ? null : ProviderShared.joinText(content),
tool_calls: toolCalls.length === 0 ? undefined : toolCalls,
+ reasoning_content: isRecord(message.native?.openaiCompatible) && typeof message.native.openaiCompatible.reasoning_content === "string"
+ ? message.native.openaiCompatible.reasoning_content
+ : undefined,
})
continue
}
diff --git a/packages/llm/src/provider/patch.ts b/packages/llm/src/provider/patch.ts
index 754d4f0e1b..e1404838c4 100644
--- a/packages/llm/src/provider/patch.ts
+++ b/packages/llm/src/provider/patch.ts
@@ -1,6 +1,25 @@
import { Model, Patch, predicate } from "../patch"
import { CacheHint } from "../schema"
-import type { ContentPart, LLMRequest } from "../schema"
+import type { ContentPart, JsonSchema, LLMRequest, Message, ToolDefinition } from "../schema"
+
+const mimeToModality = (mime: string) => {
+ if (mime.startsWith("image/")) return "image"
+ if (mime.startsWith("audio/")) return "audio"
+ if (mime.startsWith("video/")) return "video"
+ if (mime === "application/pdf") return "pdf"
+ return undefined
+}
+
+const isRecord = (value: unknown): value is Record =>
+ typeof value === "object" && value !== null && !Array.isArray(value)
+
+const sanitizeMoonshotSchema = (value: unknown): unknown => {
+ if (!isRecord(value)) return Array.isArray(value) ? value.map(sanitizeMoonshotSchema) : value
+ if (typeof value.$ref === "string") return { $ref: value.$ref }
+ const result = Object.fromEntries(Object.entries(value).map(([key, item]) => [key, sanitizeMoonshotSchema(item)]))
+ if (Array.isArray(result.items)) result.items = result.items[0] ?? {}
+ return result
+}
const removeEmptyParts = (content: ReadonlyArray) =>
content.filter((part) => (part.type === "text" || part.type === "reasoning" ? part.text !== "" : true))
@@ -43,6 +62,114 @@ export const scrubMistralToolIds = Patch.prompt("mistral.scrub-tool-call-ids", {
apply: (request) => rewriteToolIds(request, (id) => id.replace(/[^a-zA-Z0-9]/g, "").slice(0, 9).padEnd(9, "0")),
})
+export const repairAnthropicToolUseOrder = Patch.prompt("anthropic.repair-tool-use-order", {
+ reason: "Anthropic rejects assistant turns where tool_use blocks are followed by non-tool content",
+ when: Model.provider("anthropic").or(Model.provider("google-vertex-anthropic"), Model.idIncludes("claude")),
+ apply: (request) => ({
+ ...request,
+ messages: request.messages.flatMap((message): ReadonlyArray => {
+ if (message.role !== "assistant") return [message]
+ const firstToolCall = message.content.findIndex((part) => part.type === "tool-call")
+ if (firstToolCall === -1) return [message]
+ if (!message.content.slice(firstToolCall).some((part) => part.type !== "tool-call")) return [message]
+ return [
+ { ...message, content: message.content.filter((part) => part.type !== "tool-call") },
+ { ...message, content: message.content.filter((part) => part.type === "tool-call") },
+ ]
+ }),
+ }),
+})
+
+export const repairMistralToolResultUserSequence = Patch.prompt("mistral.repair-tool-user-sequence", {
+ reason: "Mistral rejects tool messages followed immediately by user messages",
+ when: Model.provider("mistral").or(Model.idIncludes("mistral"), Model.idIncludes("devstral")),
+ apply: (request) => ({
+ ...request,
+ messages: request.messages.flatMap((message, index) =>
+ message.role === "tool" && request.messages[index + 1]?.role === "user"
+ ? [message, { role: "assistant" as const, content: [{ type: "text" as const, text: "Done." }] }]
+ : [message],
+ ),
+ }),
+})
+
+export const addDeepSeekEmptyReasoning = Patch.prompt("deepseek.empty-reasoning-replay", {
+ reason: "DeepSeek expects assistant history to carry reasoning_content, even when empty",
+ when: Model.idIncludes("deepseek"),
+ apply: (request) => ({
+ ...request,
+ messages: request.messages.map((message) => {
+ if (message.role !== "assistant") return message
+ if (message.content.some((part) => part.type === "reasoning")) return message
+ return {
+ ...message,
+ native: {
+ ...message.native,
+ openaiCompatible: {
+ ...(isRecord(message.native?.openaiCompatible) ? message.native.openaiCompatible : {}),
+ reasoning_content: "",
+ },
+ },
+ }
+ }),
+ }),
+})
+
+export const moveOpenAICompatibleReasoningToNative = Patch.prompt("openai-compatible.reasoning-native-field", {
+ reason: "OpenAI-compatible reasoning providers replay reasoning in provider-native assistant fields",
+ when: Model.protocol("openai-compatible-chat"),
+ apply: (request) => ({
+ ...request,
+ messages: request.messages.map((message) => {
+ if (message.role !== "assistant") return message
+ const reasoning = message.content.filter((part) => part.type === "reasoning").map((part) => part.text).join("")
+ if (reasoning === "") return message
+ return {
+ ...message,
+ content: message.content.filter((part) => part.type !== "reasoning"),
+ native: {
+ ...message.native,
+ openaiCompatible: {
+ ...(isRecord(message.native?.openaiCompatible) ? message.native.openaiCompatible : {}),
+ reasoning_content: reasoning,
+ },
+ },
+ }
+ }),
+ }),
+})
+
+export const unsupportedMediaFallback = Patch.prompt("capabilities.unsupported-media-fallback", {
+ reason: "turn unsupported user media into model-visible error text instead of provider request failures",
+ apply: (request) => ({
+ ...request,
+ messages: request.messages.map((message) => {
+ if (message.role !== "user") return message
+ return {
+ ...message,
+ content: message.content.map((part): ContentPart => {
+ if (part.type !== "media") return part
+ const modality = mimeToModality(part.mediaType)
+ if (!modality || request.model.capabilities.input[modality]) return part
+ return {
+ type: "text",
+ text: `ERROR: Cannot read ${part.filename ? `"${part.filename}"` : modality} (this model does not support ${modality} input). Inform the user.`,
+ }
+ }),
+ }
+ }),
+ }),
+})
+
+export const sanitizeMoonshotToolSchema = Patch.toolSchema("moonshot.schema", {
+ reason: "Moonshot/Kimi rejects $ref sibling keywords and tuple-style array items",
+ when: Model.provider("moonshotai").or(Model.idIncludes("kimi")),
+ apply: (tool): ToolDefinition => ({
+ ...tool,
+ inputSchema: sanitizeMoonshotSchema(tool.inputSchema) as JsonSchema,
+ }),
+})
+
// Single shared CacheHint instance — the cache patch reuses this one object
// across every marked part. Adapters lower CacheHint structurally
// (`cache?.type === "ephemeral"`) so reference equality is incidental, but
@@ -82,9 +209,15 @@ export const cachePromptHints = Patch.prompt("cache.prompt-hints", {
})
export const defaults = [
+ unsupportedMediaFallback,
removeEmptyAnthropicContent,
scrubClaudeToolIds,
scrubMistralToolIds,
+ repairAnthropicToolUseOrder,
+ repairMistralToolResultUserSequence,
+ moveOpenAICompatibleReasoningToNative,
+ addDeepSeekEmptyReasoning,
+ sanitizeMoonshotToolSchema,
cachePromptHints,
]
diff --git a/packages/llm/test/patch.test.ts b/packages/llm/test/patch.test.ts
index 3e0069f10d..a8f054b7f0 100644
--- a/packages/llm/test/patch.test.ts
+++ b/packages/llm/test/patch.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test"
-import { LLM, ProviderPatch } from "../src"
+import { Effect } from "effect"
+import { AnthropicMessages, LLM, LLMClient, OpenAICompatibleChat, ProviderPatch } from "../src"
import { Model, Patch, context, plan } from "../src/patch"
const request = LLM.request({
@@ -106,6 +107,157 @@ describe("llm patch", () => {
expect(output.messages[1]?.content[0]).toMatchObject({ type: "tool-result", id: "callbadva" })
})
+ test("repairs Anthropic assistant turns with tool calls before text", () => {
+ const input = LLM.request({
+ id: "anthropic_tool_order",
+ model: LLM.model({ id: "claude-sonnet", provider: "anthropic", protocol: "anthropic-messages" }),
+ messages: [
+ LLM.assistant([
+ LLM.toolCall({ id: "call_1", name: "lookup", input: {} }),
+ { type: "text", text: "I will check." },
+ ]),
+ ],
+ })
+ const output = plan({
+ phase: "prompt",
+ context: context({ request: input }),
+ patches: [ProviderPatch.repairAnthropicToolUseOrder],
+ }).apply(input)
+
+ expect(output.messages).toHaveLength(2)
+ expect(output.messages[0]?.content).toEqual([{ type: "text", text: "I will check." }])
+ expect(output.messages[1]?.content).toEqual([LLM.toolCall({ id: "call_1", name: "lookup", input: {} })])
+ })
+
+ test("repairs Mistral tool messages followed by user messages", () => {
+ const input = LLM.request({
+ id: "mistral_tool_user",
+ model: LLM.model({ id: "devstral-small", provider: "mistral", protocol: "openai-chat" }),
+ messages: [
+ LLM.toolMessage({ id: "call_1", name: "lookup", result: "ok", resultType: "text" }),
+ LLM.user("next question"),
+ ],
+ })
+ const output = plan({
+ phase: "prompt",
+ context: context({ request: input }),
+ patches: [ProviderPatch.repairMistralToolResultUserSequence],
+ }).apply(input)
+
+ expect(output.messages.map((message) => message.role)).toEqual(["tool", "assistant", "user"])
+ expect(output.messages[1]?.content).toEqual([{ type: "text", text: "Done." }])
+ })
+
+ test("adds empty DeepSeek reasoning replay blocks", () => {
+ const input = LLM.request({
+ id: "deepseek_reasoning",
+ model: LLM.model({ id: "deepseek-reasoner", provider: "deepseek", protocol: "openai-compatible-chat" }),
+ messages: [LLM.assistant("answer")],
+ })
+ const output = plan({
+ phase: "prompt",
+ context: context({ request: input }),
+ patches: [ProviderPatch.addDeepSeekEmptyReasoning],
+ }).apply(input)
+
+ expect(output.messages[0]?.content).toEqual([{ type: "text", text: "answer" }])
+ expect(output.messages[0]?.native).toEqual({ openaiCompatible: { reasoning_content: "" } })
+ })
+
+ test("turns unsupported user media into model-visible text", () => {
+ const input = LLM.request({
+ id: "unsupported_media",
+ model: LLM.model({ id: "text-only", provider: "openai", protocol: "openai-chat" }),
+ messages: [
+ LLM.user({ type: "media", mediaType: "image/png", data: "abc", filename: "diagram.png" }),
+ ],
+ })
+ const output = plan({
+ phase: "prompt",
+ context: context({ request: input }),
+ patches: [ProviderPatch.unsupportedMediaFallback],
+ }).apply(input)
+
+ expect(output.messages[0]?.content).toEqual([
+ {
+ type: "text",
+ text: 'ERROR: Cannot read "diagram.png" (this model does not support image input). Inform the user.',
+ },
+ ])
+ })
+
+ test("sanitizes Moonshot/Kimi tool schemas", () => {
+ const input = LLM.request({
+ id: "moonshot_schema",
+ model: LLM.model({ id: "kimi-k2", provider: "moonshotai", protocol: "openai-compatible-chat" }),
+ tools: [
+ {
+ name: "lookup",
+ description: "Lookup",
+ inputSchema: {
+ type: "object",
+ properties: {
+ item: { $ref: "#/$defs/Item", description: "should be stripped" },
+ tuple: { type: "array", items: [{ type: "string" }, { type: "number" }] },
+ },
+ },
+ },
+ ],
+ })
+ const output = plan({
+ phase: "tool-schema",
+ context: context({ request: input }),
+ patches: [ProviderPatch.sanitizeMoonshotToolSchema],
+ }).apply(input.tools[0])
+
+ expect(output.inputSchema.properties).toEqual({
+ item: { $ref: "#/$defs/Item" },
+ tuple: { type: "array", items: { type: "string" } },
+ })
+ })
+
+ test("default patches compile invalid Anthropic tool-use ordering into valid target order", () => {
+ const prepared = Effect.runSync(
+ LLMClient.make({ adapters: [AnthropicMessages.adapter], patches: ProviderPatch.defaults }).prepare(
+ LLM.request({
+ id: "anthropic_default_tool_order",
+ model: AnthropicMessages.model({ id: "claude-sonnet" }),
+ messages: [
+ LLM.assistant([
+ LLM.toolCall({ id: "call_1", name: "lookup", input: {} }),
+ { type: "text", text: "after tool" },
+ ]),
+ ],
+ }),
+ ),
+ )
+
+ expect(prepared.target).toMatchObject({
+ messages: [
+ { role: "assistant", content: [{ type: "text", text: "after tool" }] },
+ { role: "assistant", content: [{ type: "tool_use", id: "call_1", name: "lookup", input: {} }] },
+ ],
+ })
+ expect(prepared.patchTrace.map((item) => item.id)).toContain("prompt.anthropic.repair-tool-use-order")
+ })
+
+ test("default patches compile DeepSeek reasoning replay into OpenAI-compatible native field", () => {
+ const prepared = Effect.runSync(
+ LLMClient.make({ adapters: [OpenAICompatibleChat.adapter], patches: ProviderPatch.defaults }).prepare(
+ LLM.request({
+ id: "deepseek_default_reasoning",
+ model: OpenAICompatibleChat.deepseek({ id: "deepseek-reasoner" }),
+ messages: [LLM.assistant("answer")],
+ }),
+ ),
+ )
+
+ expect(prepared.target).toMatchObject({
+ messages: [{ role: "assistant", content: "answer", reasoning_content: "" }],
+ })
+ expect(prepared.patchTrace.map((item) => item.id)).toContain("prompt.deepseek.empty-reasoning-replay")
+ })
+
// Cache hint policy: mark first-2 system + last-2 messages with ephemeral
// cache hints, gated on `model.capabilities.cache.prompt`. Adapters
// (Anthropic, Bedrock) lower the hint to `cache_control` / `cachePoint`.