docs(opencode): rewrite migration plan as outline, lead with model handle phase
This commit is contained in:
@@ -1,259 +1,130 @@
|
||||
# AI SDK → `@opencode-ai/llm` Migration
|
||||
|
||||
## Problem
|
||||
## Goal
|
||||
|
||||
`opencode` currently runs every model call through Vercel's AI SDK (`ai`, `@ai-sdk/<vendor>`, plus a few third-party SDK adapters). Over time the in-house `@opencode-ai/llm` library has matured into a clean, Effect-Schema-first replacement: routes, protocols, transports, body schemas, typed events, tool runtime — all of it.
|
||||
Move opencode off Vercel's AI SDK (`ai`, `@ai-sdk/<vendor>`, third-party SDK adapters) onto our in-house `@opencode-ai/llm`.
|
||||
|
||||
We want to move opencode off the AI SDK without a flag day. The end state is the AI SDK gone from `opencode`'s `package.json` and every model call going through `@opencode-ai/llm`. The journey is incremental, behind a feature flag, with telemetry-driven rollout per provider.
|
||||
End state: `ai` and `@ai-sdk/*` removed from `package.json`. Every model call goes through `@opencode-ai/llm`.
|
||||
|
||||
This document captures the current architecture, the target architecture, and the phased plan to get from one to the other.
|
||||
No flag day. Each phase is shippable, no behavior change unless explicitly noted.
|
||||
|
||||
## Today: how opencode integrates the AI SDK
|
||||
## Today
|
||||
|
||||
### Boundary surface
|
||||
- `provider/provider.ts` — `Provider.Service.getLanguage(model): LanguageModelV3`. Returns the AI SDK's executable runtime model. `BUNDLED_PROVIDERS` dynamically imports each `@ai-sdk/<vendor>` package.
|
||||
- `session/llm.ts` — `LLM.Service.stream(input) → Stream<Event>`. The only file that calls `streamText` / `wrapLanguageModel`. Has a gated `runNative` path that uses `@opencode-ai/llm` end-to-end (via `session/llm-native.ts`, `llm-native-events.ts`, `llm-native-tools.ts`, `provider/llm-bridge.ts`). Native is currently behind `OPENCODE_EXPERIMENTAL_LLM_NATIVE` and only enabled for `anthropic-messages`.
|
||||
- AI SDK types leak into 11+ files outside `session/llm.ts`: `provider/transform.ts` (~1200 lines of message rewriting), `session/message-v2.ts` (~1221 lines, branches on `model.api.npm`), `session/prompt.ts`, `session/llm-native-tools.ts`, `agent/agent.ts`, `mcp/index.ts`, `provider/sdk/copilot/*` (a fork of `@ai-sdk/openai-compatible`), and others.
|
||||
|
||||
Two layers do the heavy lifting:
|
||||
## Plan
|
||||
|
||||
- **`provider/provider.ts`** — `BUNDLED_PROVIDERS` map dynamically `import()`s each `@ai-sdk/<vendor>` package. `Provider.Service.getLanguage(model)` returns a `LanguageModelV3` from `@ai-sdk/provider`. Custom per-provider quirks (auth, OAuth, Vertex, Copilot, Gateway, SSE-timeout via `wrapSSE`) live here.
|
||||
- **`session/llm.ts`** — the **only** file that calls `streamText` / `wrapLanguageModel`. `LLM.Service.stream(input) → Stream<Event>` is the seam everything above speaks to. `Event` is the AI SDK `streamText.fullStream` element type re-exported as opencode's session event vocabulary.
|
||||
### Phase 1 — `Provider.getModelHandle`: discriminated-union return type
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ session/prompt.ts │
|
||||
│ agent/agent.ts │
|
||||
│ session/processor.ts │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌──────────────────────────────────────────────────┐ │
|
||||
│ │ LLM.Service.stream(input) → Stream<Event> │ │
|
||||
│ │ (session/llm.ts) │ │
|
||||
│ └──────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌──────────────────────────────────────────────────┐ │
|
||||
│ │ prepare() — system msgs, plugins, │ │
|
||||
│ │ headers, tool resolution │ │
|
||||
│ │ run() — streamText(...) │ │
|
||||
│ │ runNative() — gated experimental path │ │
|
||||
│ └──────────────────────────────────────────────────┘ │
|
||||
│ │ │ │
|
||||
│ ▼ ▼ │
|
||||
│ ┌─────────────────────┐ ┌─────────────────────────┐ │
|
||||
│ │ AI SDK │ │ @opencode-ai/llm │ │
|
||||
│ │ streamText({...}) │ │ LLMClient.stream(...) │ │
|
||||
│ │ + GitLab WS quirks │ │ via │ │
|
||||
│ │ + OAuth quirks │ │ llm-native.ts + │ │
|
||||
│ │ + ProviderTransform│ │ llm-native-events.ts │ │
|
||||
│ │ │ │ + llm-native-tools.ts │ │
|
||||
│ └─────────────────────┘ └─────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
The first move. Tiny surface change, makes the rest of the migration possible.
|
||||
|
||||
Today `getLanguage` returns `LanguageModelV3` (an AI SDK runtime object). We can't just swap it for `ModelRef` because that's a description, not an executable.
|
||||
|
||||
Add a new method `getModelHandle` returning a discriminated union:
|
||||
|
||||
```ts
|
||||
type ModelHandle =
|
||||
| { kind: "ai-sdk", language: LanguageModelV3 }
|
||||
| { kind: "native", ref: ModelRef }
|
||||
|
||||
Provider.Service.getModelHandle(model): Effect<ModelHandle, NoSuchModelError>
|
||||
```
|
||||
|
||||
At the top, the API is already a single service. The mess is **below** that line — in 11+ files where AI SDK types leak.
|
||||
Phase 1 is intentionally a parallel addition. Existing `getLanguage` keeps working; new code consumes `getModelHandle`. The union is the migration vehicle — it's deliberately ugly so it's obvious it's temporary. Once AI SDK is gone, the union collapses to `{ ref: ModelRef }`.
|
||||
|
||||
### Trace one `streamText` call
|
||||
Steps:
|
||||
|
||||
1. `session/prompt.ts:1597` calls `handle.process({ user, agent, system, messages, tools, nativeTools, nativeMessages, model, ... })`.
|
||||
2. `processor.create` → `processor.process` → `llm.stream(streamInput)` (`session/processor.ts:670`).
|
||||
3. `LLM.Service.stream` (`session/llm.ts:592`):
|
||||
- `prepare(request)` — resolves `LanguageModelV3` via `Provider.getLanguage`, builds system messages, applies `Plugin.trigger("chat.params"/"chat.headers")`, runs `ProviderTransform.providerOptions/options/temperature/...`, filters tools through `Permission`, may inject `_noop` stub tool for LiteLLM/Copilot.
|
||||
- `runNative(request, prepared)` — returns a `Stream` if the gate passes, else `undefined`.
|
||||
- `run(request, prepared)` — `streamText({ model: wrapLanguageModel({ model, middleware: [{ transformParams: ProviderTransform.message }] }), tools, providerOptions, ... })`.
|
||||
4. `Stream.fromAsyncIterable(result.fullStream)` is consumed by `processor.handleEvent` (switch on `text-start` / `tool-call` / `finish-step` / etc.) which writes `MessageV2.Part`s back into the session store.
|
||||
1. Add `getModelHandle` to `Provider.Service` (parallel to `getLanguage`). The native arm calls into `provider/llm-bridge.ts:toModelRef`. The AI SDK arm wraps `getLanguage`.
|
||||
2. Move AI SDK plumbing (`BUNDLED_PROVIDERS`, dynamic imports) to a new `provider/sdk-resolver.ts`. `provider/provider.ts` consumes it.
|
||||
3. Switch the *one* caller in `session/llm.ts` to consume `getModelHandle`. The fork it does today (`runNative` vs `run`) becomes a switch on `handle.kind`.
|
||||
|
||||
### Existing native path (gated, partial)
|
||||
After Phase 1: backend choice is encoded in the return type, not in a per-request gate.
|
||||
|
||||
A second backend already runs behind `OPENCODE_EXPERIMENTAL_LLM_NATIVE`. It uses `@opencode-ai/llm` end to end. Three small files hold all the conversion:
|
||||
### Phase 2 — Decouple AI SDK types from the rest of opencode
|
||||
|
||||
- `session/llm-native.ts` — `MessageV2.WithParts[] → LLMRequest`. Handles message lowering, cache hint placement, tool-definition lowering. Errors on unsupported content / model.
|
||||
- `session/llm-native-events.ts` — stateful per-stream `mapper()` that converts `LLMEvent → SessionEvent` (the AI SDK fullStream shape opencode already speaks). Tracks open IDs so `*-end` events can synthesize on stream close.
|
||||
- `session/llm-native-tools.ts` — multi-round client-side tool dispatch loop. Forks each `tool-call` event into a fiber, runs the AI SDK `tool.execute(...)`, injects synthetic `tool-result`/`tool-error` `LLMEvent` back into the stream, drives subsequent rounds.
|
||||
- `provider/llm-bridge.ts` — `Provider.Model → LLM.ModelRef`, dispatching on `model.api.npm`.
|
||||
Pull AI SDK imports out of every file that isn't `session/llm.ts` or `provider/sdk-resolver.ts`. No behavior change.
|
||||
|
||||
### What blocks `runNative` today
|
||||
In rough order of pain:
|
||||
|
||||
Every condition below must hold for a request to take the native path. Anything else falls through to AI SDK:
|
||||
1. `provider/error.ts` — opencode-owned `ProviderError` shape `{ status, message, isRetryable, providerID, responseBody }`. Adapter constructors `fromAPICallError(e)` and `fromLLMError(e)`. Removes `APICallError` from `acp/agent.ts`.
|
||||
2. `session/prompt.ts:resolveTools` — `Tool.Def` becomes the canonical tool type. Convert to AI SDK `Tool` lazily inside the AI SDK adapter, not eagerly here. Drops `tool` / `jsonSchema` / `asSchema` imports from prompt.ts.
|
||||
3. `session/message-v2.ts` — add `toLLMMessagesEffect` parallel to `toModelMessagesEffect`. Both convert from the same `MessageV2.WithParts[]` source. Reuse `session/llm-native.ts`.
|
||||
4. `session/session.ts` — replace `ProviderMetadata` / `LanguageModelUsage` imports with opencode-owned types. Cosmetic but removes the leak.
|
||||
5. `mcp/index.ts` — emit `Tool.Def` alongside the existing `dynamicTool`. Once both exist, the native gate can keep MCP tools.
|
||||
6. `agent/agent.ts:generateObject/streamObject` — keep on AI SDK for now (structured output isn't on `@opencode-ai/llm` yet); isolate to one `LLM.generateObject(input, schema)` Service method so the AI SDK call site is in one place.
|
||||
|
||||
### Phase 3 — Lift `prepare()` out of `session/llm.ts`
|
||||
|
||||
`prepare()` is backend-agnostic: system messages, plugin hooks (`chat.params`, `chat.headers`), tool resolution, header building. Today it's mixed in with `run()` (the AI SDK call). Lift to `session/llm-prepare.ts`. Both backends consume the result.
|
||||
|
||||
Pure refactor. No behavior change.
|
||||
|
||||
### Phase 4 — Split `LLM.Service.live` into two layers
|
||||
|
||||
```
|
||||
Flag.OPENCODE_EXPERIMENTAL_LLM_NATIVE === true
|
||||
&& nativeMessages provided (caller populated MessageV2.WithParts)
|
||||
&& retries === 0
|
||||
&& experimental.openTelemetry === false
|
||||
&& prepared.params.options is empty (no provider-specific knobs)
|
||||
&& every AI-SDK tool key has a matching nativeTools entry
|
||||
&& LLMNative.request didn't throw UnsupportedContentError / UnsupportedModelError
|
||||
&& model.route ∈ NATIVE_ROUTES // currently {"anthropic-messages"} only
|
||||
session/backends/ai-sdk.ts — current run() extracted
|
||||
session/backends/native.ts — current runNative() extracted, no gate
|
||||
```
|
||||
|
||||
## Where the spaghetti actually is
|
||||
`LLM.Service.layer` selects based on a single config flag at construction:
|
||||
|
||||
The integration is "spaghetti" not at the top boundary (which is already a clean Service), but in the type leakage **below** that boundary.
|
||||
|
||||
### AI SDK type leakage outside `session/llm.ts`
|
||||
|
||||
| File | Leaked AI SDK types | Why |
|
||||
|---|---|---|
|
||||
| `provider/provider.ts` | `LanguageModelV3`, `Provider as SDK`, `NoSuchModelError` | `getLanguage` returns `LanguageModelV3`; `BUNDLED_PROVIDERS` returns AI SDK factories |
|
||||
| `provider/transform.ts` (~1200 lines) | `ModelMessage`, `JSONSchema7` | All `ProviderTransform.message/options/providerOptions/...` operate on `ModelMessage[]` |
|
||||
| `provider/error.ts` | `APICallError` | Provider-specific error classification on AI SDK error shape |
|
||||
| `session/message-v2.ts` (~1221 lines) | `APICallError`, `convertToModelMessages`, `LoadAPIKeyError`, `ModelMessage`, `UIMessage` | `MessageV2.toModelMessagesEffect` converts V2-parts → AI SDK `ModelMessage[]`, branches on `model.api.npm` |
|
||||
| `session/prompt.ts` | `Tool`, `tool`, `jsonSchema`, `ToolExecutionOptions`, `asSchema`, `JSONSchema7` | `resolveTools` builds AI SDK `Tool` record; `createStructuredOutputTool` builds `tool({...})` |
|
||||
| `session/llm-native-tools.ts` | `Tool`, `ToolExecutionOptions` | Native multi-round dispatcher invokes AI SDK `tool.execute(...)` at the leaves |
|
||||
| `session/session.ts` | `ProviderMetadata`, `LanguageModelUsage` | Type leakage on stored session shapes |
|
||||
| `agent/agent.ts` | `generateObject`, `streamObject`, `ModelMessage` | `Agent.generate` is a separate AI SDK call site for structured-output config generation |
|
||||
| `acp/agent.ts` | `LoadAPIKeyError` | error classification only |
|
||||
| `mcp/index.ts` | `dynamicTool`, `Tool`, `jsonSchema`, `JSONSchema7` | MCP tools are exclusively AI SDK shape today |
|
||||
|
||||
### Provider-specific transforms scattered
|
||||
|
||||
- `provider/transform.ts` (1200 lines) — message rewriting, `providerOptions` remapping, DeepSeek reasoning fixup, Anthropic empty-content filter, cache key handling.
|
||||
- `session/message-v2.ts:746-750` — branches on `model.api.npm` for cache-on/off detection.
|
||||
- `provider/llm-bridge.ts:130-137` — capabilities derived from `protocol` string.
|
||||
- `session/llm.ts:175-189` — `isWorkflow` / `isOpenaiOauth` message-shaping branches.
|
||||
|
||||
### `provider/sdk/copilot/*` — a private fork
|
||||
|
||||
This subdirectory is a fork of `@ai-sdk/openai-compatible` adapted for GitHub Copilot (chat + responses endpoints, custom tool prep, custom error mapping). Lazy-loaded only for `@ai-sdk/github-copilot`. Its responsibilities — protocol selection, tool lowering, error mapping — already exist in `@opencode-ai/llm/providers/github-copilot`. Once Copilot is stable on the native path, the entire subdirectory deletes.
|
||||
|
||||
### MessageV2 ↔ AI SDK duplication
|
||||
|
||||
`session/message-v2.ts:toModelMessagesEffect` and `session/llm-native.ts` both convert `MessageV2.WithParts[]`. One produces `ModelMessage[]` (AI SDK), the other produces `LLM.Message[]` (native). Both are largely complete; they diverge on cache markers, provider-executed tools, file-URL handling, synthetic-tail message support.
|
||||
|
||||
## Target architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ session/prompt.ts, agent/agent.ts, ... │
|
||||
│ Speak only opencode-owned types: │
|
||||
│ - Tool.Def (not AI SDK Tool) │
|
||||
│ - ProviderError (not APICallError) │
|
||||
│ - SessionEvent (named, not fullStream type alias) │
|
||||
│ - MessageV2.WithParts │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌──────────────────────────────────────────────────┐ │
|
||||
│ │ LLM.Service.stream(input) → Stream<SessionEvent>│ │
|
||||
│ └──────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌──────────────────────────────────────────────────┐ │
|
||||
│ │ prepare() — backend-agnostic │ │
|
||||
│ │ (session/llm-prepare.ts) │ │
|
||||
│ │ • system messages │ │
|
||||
│ │ • plugin hooks (chat.params, chat.headers) │ │
|
||||
│ │ • tool resolution (Tool.Def) │ │
|
||||
│ │ • header building │ │
|
||||
│ └──────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ (one flag, one decision) │
|
||||
│ │
|
||||
│ Config.experimental.llmBackend ∈ {"ai-sdk","native"} │
|
||||
│ │
|
||||
│ ┌──────────────────────┴──────────────────────┐ │
|
||||
│ ▼ ▼ │
|
||||
│ ┌────────────────────┐ ┌──────────────┐ │
|
||||
│ │ Service.aiSdkLayer │ │ Service. │ │
|
||||
│ │ session/backends/ │ │ nativeLayer │ │
|
||||
│ │ ai-sdk.ts │ │ session/ │ │
|
||||
│ │ • streamText │ │ backends/ │ │
|
||||
│ │ • GitLab WS quirks │ │ native.ts │ │
|
||||
│ │ • OAuth quirks │ │ • LLMClient. │ │
|
||||
│ └────────────────────┘ │ stream │ │
|
||||
│ │ • mapper() │ │
|
||||
│ │ • runWith │ │
|
||||
│ │ Tools │ │
|
||||
│ └──────────────┘ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```ts
|
||||
Config.experimental?.llmBackend ?? "ai-sdk" // "ai-sdk" | "native"
|
||||
```
|
||||
|
||||
The flag lives at **layer construction time**. No per-request gate. Either backend handles every request opencode sends.
|
||||
One decision point. No per-request gate. The decision is global. Drop `NATIVE_ROUTES` allowlist and `runNative`'s gate conditions; they were guards for a half-built path that's about to be all-or-nothing.
|
||||
|
||||
## Phased migration
|
||||
### Phase 5 — Native parity
|
||||
|
||||
### Phase A — Decouple
|
||||
What `@opencode-ai/llm` needs before native can be the default:
|
||||
|
||||
Pull AI SDK types out of every non-`session/llm.ts` module. **No behavior change.** Each step is a small refactor with green tests at the end.
|
||||
|
||||
1. **`provider/provider.ts`** — stop returning `LanguageModelV3` from `getLanguage`. Introduce `Provider.getModelHandle(model): { kind: "ai-sdk", model: LanguageModelV3 } | { kind: "native", ref: ModelRef }`. AI SDK plumbing moves into `provider/sdk-resolver.ts` (new file). `BUNDLED_PROVIDERS` moves there.
|
||||
2. **`provider/error.ts`** — opencode-owned `ProviderError` shape `{ status, message, isRetryable, providerID, responseBody }`. Adapter constructors `fromAPICallError(e)` and `fromLLMError(e: LLMError)`. Removes `APICallError` import from `acp/agent.ts` and most of `provider/error.ts`.
|
||||
3. **`session/message-v2.ts`** — add `toLLMMessagesEffect` parallel to `toModelMessagesEffect`. Both produced from the same `MessageV2.WithParts[]`. Reuse `session/llm-native.ts` lowering. `ModelMessage` storage shapes (`session.ts:7`) become opencode-owned types.
|
||||
4. **`session/prompt.ts:resolveTools`** — `Tool.Def` is the canonical tool type. Convert `Tool.Def → AI SDK Tool` lazily inside the AI SDK adapter, not eagerly here. Removes `tool` / `jsonSchema` / `asSchema` imports.
|
||||
5. **`mcp/index.ts`** — add MCP → `Tool.Def` lowering alongside `dynamicTool`. Once both shapes exist, native gate can keep MCP tools.
|
||||
6. **`agent/agent.ts:generateObject/streamObject`** — keep on AI SDK for now (structured output isn't on `@opencode-ai/llm` yet); isolate to `LLM.generateObject(input, schema)` Service method so the AI SDK call site is in one place.
|
||||
|
||||
### Phase B — Service-level swap
|
||||
|
||||
Rewrite `session/llm.ts` so the backend is selected once, at layer construction.
|
||||
|
||||
1. Keep `Interface.stream: (input: StreamInput) => Stream.Stream<SessionEvent, unknown>` as the public surface (already opencode-owned).
|
||||
2. Split `live` into two layers:
|
||||
- `Service.aiSdkLayer` — current `prepare/run` extracted, wraps `streamText` + GitLab/OpenAI-OAuth quirks + monkey-patching.
|
||||
- `Service.nativeLayer` — current `runNative` extracted, calls `llmClient.stream` via `LLMNativeTools.runWithTools`. Translates events with `LLMNativeEvents.mapper`.
|
||||
3. `defaultLayer` selects based on a single `Config.experimental?.llmBackend ?? "ai-sdk"`. **One decision point. No per-request gate.**
|
||||
4. The `prepare` function is **shared infrastructure**, not AI-SDK-specific. Lift to `session/llm-prepare.ts`. Both backends consume the resulting `PreparedStream`.
|
||||
|
||||
### Phase C — Native parity
|
||||
|
||||
What `@opencode-ai/llm` needs:
|
||||
|
||||
- **Drop the `NATIVE_ROUTES` allowlist**. Add per-route stabilization tests. Order: anthropic-messages (done) → bedrock-converse → openai-responses → openai-chat / openai-compatible-chat → gemini → openrouter-chat.
|
||||
- **Provider options pass-through**. `LLMRequest` carries opaque per-request `providerOptions`; each protocol lowers what it knows. Or move all known options (reasoning effort, prompt cache key, text verbosity, OpenRouter usage/reasoning) onto `LLM.ModelRef` (mostly done in `llm-bridge.ts`) so per-request options become unnecessary.
|
||||
- **Retry support** in `RequestExecutor` subsuming `streamText({ maxRetries })`.
|
||||
- **OpenTelemetry tracing** in `RequestExecutor`, gated by the same config flag.
|
||||
- **MCP tool support**. Either teach MCP to emit `Tool.Def`, or teach `LLMNativeTools.runWithTools` to dispatch raw AI SDK tools (it already does — `tools: Record<string, Tool>`).
|
||||
- **Structured output**. Either port `generateObject` semantics onto `@opencode-ai/llm`, or keep AI SDK as the structured-output fallback indefinitely.
|
||||
- **GitLab workflow provider**. Custom WebSocket transport with server-side tool execution. Write a `@opencode-ai/llm` route + transport for it (the existing `WebSocketTransport.json` precedent applies).
|
||||
- Per-route stabilization tests (anthropic-messages → bedrock-converse → openai-responses → openai-chat / openai-compatible-chat → gemini → openrouter-chat).
|
||||
- Provider options pass-through. Either accept opaque per-request `providerOptions` in `LLMRequest` and lower per protocol, or move all known options (reasoning effort, prompt cache key, text verbosity, OpenRouter usage/reasoning) onto `LLM.ModelRef`.
|
||||
- Retry support in `RequestExecutor` subsuming `streamText({ maxRetries })`.
|
||||
- OpenTelemetry tracing in `RequestExecutor`, gated by config.
|
||||
- MCP tool dispatch on the native path (likely already works — `runWithTools` accepts AI SDK `Tool`).
|
||||
- Structured output: either port `generateObject` semantics, or keep AI SDK as the structured-output fallback indefinitely.
|
||||
- GitLab workflow provider: custom WebSocket transport with server-side tool execution. Write a `@opencode-ai/llm` route + transport (the existing `WebSocketTransport.json` precedent applies).
|
||||
|
||||
What opencode-side adapter still needs:
|
||||
|
||||
- `experimental_repairToolCall` lowercase fixup → middleware in the native path.
|
||||
- `_noop` stub tool injection for LiteLLM/Copilot proxies → either move to `@opencode-ai/llm/providers/openai-compatible` profile, or keep in `prepare`.
|
||||
- `_noop` stub tool injection for LiteLLM/Copilot proxies → either to `@opencode-ai/llm/providers/openai-compatible` profile or kept in `prepare`.
|
||||
- OpenAI OAuth `instructions` quirk → encode on the OpenAI provider in `@opencode-ai/llm`.
|
||||
|
||||
### Phase D — Flag-driven rollout
|
||||
### Phase 6 — Per-provider rollout
|
||||
|
||||
- Default `ai-sdk`. Internal/CI runs `native`.
|
||||
- Per-provider opt-in: `Config.experimental.llmBackend.providers = ["anthropic", "bedrock"]` so we can flip Anthropic to native while leaving openai-compatible on AI SDK.
|
||||
- Telemetry compares finish reasons, token usage, latency, error rates per session.
|
||||
- Soak each provider until the comparison is boring.
|
||||
- Default flag stays `ai-sdk`. Internal/CI runs `native`.
|
||||
- Per-provider opt-in: `Config.experimental.llmBackend.providers = ["anthropic", "bedrock"]`.
|
||||
- Telemetry compares finish reasons, token usage, latency, error rates. Soak each provider until comparison is boring.
|
||||
|
||||
### Phase E — Delete the AI SDK
|
||||
|
||||
Once native covers all routes + structured output:
|
||||
### Phase 7 — Delete the AI SDK
|
||||
|
||||
1. Delete `provider/sdk/copilot/*` — replaced by `@opencode-ai/llm/providers/github-copilot`.
|
||||
2. Shrink `provider/transform.ts` to opencode-policy bits only (max output tokens, temperature defaults, topK). The provider-specific message rewriting lives in protocol lowering inside `@opencode-ai/llm`.
|
||||
3. Delete `BUNDLED_PROVIDERS` from `provider/provider.ts`. `getLanguage` removed.
|
||||
4. Delete `session/llm.ts:run` and the `streamText` call. Keep `stream` and `prepare`.
|
||||
5. Remove `ai`, `@ai-sdk/*`, `@openrouter/ai-sdk-provider`, `gitlab-ai-provider`, `venice-ai-sdk-provider` from `package.json`.
|
||||
6. Convert `Event = streamText.fullStream` element type to a named `LLM.SessionEvent` schema.
|
||||
2. Shrink `provider/transform.ts` to opencode-policy bits only (max output tokens, temperature defaults, topK). Provider-specific message rewriting lives in protocol lowering inside `@opencode-ai/llm`.
|
||||
3. Delete `BUNDLED_PROVIDERS` and `provider/sdk-resolver.ts`. `getLanguage` removed.
|
||||
4. Collapse the `ModelHandle` discriminated union to `{ ref: ModelRef }` (or simplify back to a metadata-only Provider).
|
||||
5. Delete `session/llm.ts:run` (the `streamText` call) and `session/backends/ai-sdk.ts`. `LLM.Service` is the native path.
|
||||
6. Remove `ai`, `@ai-sdk/*`, `@openrouter/ai-sdk-provider`, `gitlab-ai-provider`, `venice-ai-sdk-provider` from `package.json`.
|
||||
7. Convert `Event = streamText.fullStream` element type to a named `LLM.SessionEvent` schema.
|
||||
|
||||
## Suggested execution order
|
||||
## Order to execute
|
||||
|
||||
1. **Now** — lift `prepare` into a shared module; make `LLM.Service` interface fully opencode-typed (Phase A.1, A.2, B.1–B.2). Low risk, no behavior change.
|
||||
2. **Next** — drop `NATIVE_ROUTES` allowlist; flip stabilization tests on per-route in `@opencode-ai/llm`. Add per-provider native opt-in flag (Phase B.3, D partial).
|
||||
3. **Then** — MCP + structured output + retry/OTel parity (Phase C). These unblock most real sessions.
|
||||
4. **Then** — GitLab workflow + Copilot. These eliminate the largest forks.
|
||||
5. **Finally** — flip default, soak, delete AI SDK (Phase E).
|
||||
1. Phase 1 (model handle) — small, mechanical, unlocks everything.
|
||||
2. Phase 2 (decouple types) — most of the actual work, but each step is a clean PR.
|
||||
3. Phase 3 (lift prepare) — small, pure refactor.
|
||||
4. Phase 4 (split layers) — flips the architecture even if native isn't ready yet.
|
||||
5. Phase 5 (parity) — the real grind. Item-by-item.
|
||||
6. Phase 6 (rollout) — per-provider, telemetry-gated.
|
||||
7. Phase 7 (delete) — celebratory.
|
||||
|
||||
## Key files to touch first
|
||||
## Risks
|
||||
|
||||
- `packages/opencode/src/session/llm.ts` — split `live` into two layers; extract `prepare`.
|
||||
- `packages/opencode/src/provider/provider.ts` — split AI SDK plumbing into `provider/sdk-resolver.ts`; narrow `Service.Interface`.
|
||||
- `packages/opencode/src/provider/error.ts` — opencode-owned `ProviderError` shape.
|
||||
- `packages/opencode/src/session/message-v2.ts` — add `toLLMMessagesEffect`; eliminate `@ai-sdk/*` branches.
|
||||
- `packages/opencode/src/session/prompt.ts` — `Tool.Def` as canonical, not AI SDK `tool()`.
|
||||
- `packages/opencode/src/session/llm-native.ts` and `llm-native-events.ts` — already clean, become *the* path.
|
||||
- `packages/opencode/src/provider/llm-bridge.ts` — extend with anything currently in `ProviderTransform.providerOptions` that doesn't already have a `ProviderOptions` mapping.
|
||||
- `packages/llm/src/providers/*.ts` — ensure each provider exposes the per-request options that `provider/transform.ts:providerOptions` produces.
|
||||
|
||||
## Risks and open questions
|
||||
|
||||
- **Telemetry parity.** Today AI SDK emits OTel spans for every model call. Native path has no equivalent. We need parity before flag-flipping or rollout is blind.
|
||||
- **Token usage normalization.** Each protocol's `mapUsage` produces an `LLM.Usage`; AI SDK produces `LanguageModelUsage`. The shapes are similar but not identical (cache write tokens, reasoning tokens). Audit before flipping.
|
||||
- **Provider-executed tools.** Anthropic `web_search`/`code_execution`/`web_fetch` and OpenAI Responses hosted tools work end-to-end on the native path. Verify on a recorded scenario per provider before promoting.
|
||||
- **Tool.Def vs AI SDK `Tool`.** The decision to canonicalize on `Tool.Def` ripples through `prompt.ts`, `mcp/index.ts`, `agent/agent.ts`. Keep both shapes alive during Phase A; choose the cutover point deliberately.
|
||||
- **`session/message-v2.ts` is huge.** 1221 lines of conversion logic. The `toLLMMessagesEffect` addition is non-trivial; plan a dedicated PR.
|
||||
- **GitLab workflow.** It's a custom WebSocket protocol with custom tool execution / approval flow. Re-implementing it as a `@opencode-ai/llm` route is its own design exercise.
|
||||
- **Structured output.** `generateObject` in `agent/agent.ts` may be the longest-lived AI SDK call site if we don't add structured-output support to `@opencode-ai/llm` first.
|
||||
- **Telemetry parity.** AI SDK emits OTel spans for every model call. Native path has no equivalent. Block flag-flipping until parity.
|
||||
- **Token usage normalization.** `LLM.Usage` and `LanguageModelUsage` are similar but not identical (cache write tokens, reasoning tokens). Audit before flipping.
|
||||
- **Provider-executed tools.** Anthropic `web_search`/`code_execution`/`web_fetch` and OpenAI Responses hosted tools work end-to-end on the native path. Verify per provider on a recorded scenario before promoting.
|
||||
- **`Tool.Def` cutover.** Canonicalizing on `Tool.Def` ripples through `prompt.ts`, `mcp/index.ts`, `agent/agent.ts`. Keep both shapes alive during Phase 2; choose the cutover point deliberately.
|
||||
- **GitLab workflow.** Custom WebSocket protocol with custom tool execution / approval flow. Re-implementing it as a `@opencode-ai/llm` route is its own design exercise.
|
||||
- **Structured output.** `agent/agent.ts:generateObject` may be the longest-lived AI SDK call site if we don't add structured-output support to `@opencode-ai/llm` first.
|
||||
|
||||
Reference in New Issue
Block a user