Adds opt-in `nativeTools?: ReadonlyArray<Tool.Def>` to `LLM.StreamInput`
so callers that route through the native path can attach typed
opencode tool definitions alongside the AI SDK `tools` record. The
gate in `runNative` widens accordingly: a session can use the native
path when it has zero tools (existing behavior) OR when it explicitly
provides `nativeTools` matching its AI SDK `tools` (new opt-in). When
`nativeTools` reaches `LLMNative.request`, the existing
`toolDefinition` converter folds each `Tool.Def` into the request's
`tools` array and the LLM core lowers it onto the wire.
This commit deliberately does NOT include the dispatch loop. A
session that opts in by setting `nativeTools` and that triggers a
`tool-call` from the model will see the call event but no
`tool-result` because the native path has no execute handler yet.
That's why no production caller populates `nativeTools`: phase 2
step 2b will land the dispatch loop and only then will real
production sessions route through here.
What this lays in place:
- `StreamInput.nativeTools` typed against `Tool.Def[]` from `@/tool`.
Aliased to `OpenCodeTool` at the import to dodge a clash with the
AI SDK `Tool` type that the same file already imports.
- The `runNative` gate flips from "no tools allowed" to "either no
tools, or `nativeTools` is supplied". An AI SDK tool count > 0
with `nativeTools` undefined still falls through, so existing
production sessions are unaffected.
- `LLMNative.request` already accepted `tools: ReadonlyArray<Tool.Def>`
and converts via `toolDefinition`. We just forward the input
through; no LLM-bridge change.
Smoke coverage: a new test in `llm-native-stream.test.ts` builds a
typed `Tool.Def` (Effect Schema parameters), routes it through
`LLMNative.request` + `LLMClient.prepare`, and asserts the prepared
Anthropic target carries the tool as an `input_schema` block with
the expected JSON Schema shape. This validates the conversion path
that phase 2 step 2b will exercise from inside `runNative`.
Verification: opencode typecheck clean; 35/0/0 across the three
bridge-area tests (`llm-native.test.ts`, `llm-native-stream.test.ts`,
`llm-bridge.test.ts`).
Adds `test/session/llm-native-stream.test.ts` — one focused test that
proves the end-to-end wire-up `runNative` relies on actually produces
session events from a scripted Anthropic SSE response.
The test stays self-contained:
- Builds a fake Anthropic `Provider.Info` + `Provider.Model` via
`ProviderTest`.
- Builds an `LLMRequest` via `LLMNative.request(...)` from a
`MessageV2.WithParts` user message — the same call shape `runNative`
uses inside `session/llm.ts`.
- Creates an `LLMClient` with the same adapters list + `ProviderPatch.defaults`
list as `runNative`. The adapters are imported directly from
`@opencode-ai/llm`; if `runNative`'s `NATIVE_ADAPTERS` array changes,
this test's `adapters` constant has to follow (commented).
- Provides a single fixed-response HTTP layer that returns a scripted
Anthropic SSE body. The layer helper is inlined (12 lines) rather
than imported from `packages/llm/test/lib/http.ts` so the test
doesn't reach across package boundaries.
- Pipes the LLM stream through `LLMNativeEvents.mapper()` exactly as
`runNative` does (`Stream.flatMap` + lazy `Stream.concat` for
flush), runs it to completion, and asserts the key session events:
`text-start` precedes `text-delta`, `finish-step` carries
`finishReason: "stop"`, and `finish` carries the merged usage totals.
This does NOT test the dispatch gate inside `session/llm.ts`
(`!Flag.OPENCODE_EXPERIMENTAL_LLM_NATIVE`, missing `nativeMessages`,
tools present, non-Anthropic protocol). Those are simple boolean
conditions and don't need separate coverage. It also does not exercise
the production `Service` layer — that's deferred to Phase 2 step 2
(tool support) and Phase 2 step 3 (production caller wiring).
What the test buys: confidence that the conversion pipeline works and
catches regressions in `LLMNative.request`, the LLM adapter set, or
`LLMNativeEvents.mapper` before they would surface in a real session.
Verification: 34/0/0 across the three bridge-area tests
(`llm-native.test.ts` + `llm-native-stream.test.ts` +
`llm-bridge.test.ts`); opencode typecheck clean.
Adds the parallel `runNative()` path inside `session/llm.ts` so a narrow
slice of sessions can flow through `@opencode-ai/llm` instead of the AI
SDK `streamText`. Behavior is gated and shipped off by default; only
callers that opt in see any difference.
The full migration plan (audit gap #4) is parallel-path-with-flag,
prove parity test-by-test, flip default last. This commit is phase 1:
get the wire-up in place behind a flag with one protocol so we can see
whether the design holds before committing to the full migration.
Wire-up summary:
- New flag `OPENCODE_EXPERIMENTAL_LLM_NATIVE` (also enabled by the
umbrella `OPENCODE_EXPERIMENTAL`). Off by default.
- The session-LLM `live` layer now consumes `RequestExecutor.Service`,
and the `defaultLayer` provides `RequestExecutor.defaultLayer` so a
Node fetch HTTP client backs every native stream.
- `runNative(input)` returns `Stream<Event> | undefined`. `undefined`
means "fall through to AI SDK." It returns a real stream only when
every gate passes: the flag is set, the caller populated
`input.nativeMessages` (the bridge needs typed `MessageV2.WithParts`,
not the AI SDK `messages` array), the session has zero tools (Phase
2 will lift this), and the bridge routes the model to a protocol in
`NATIVE_PROTOCOLS`.
- `NATIVE_PROTOCOLS` is a single-entry set today: `anthropic-messages`.
Other adapters are imported and registered with the client so the
Phase 2 expansion is a one-line edit, not an architecture change.
- Stream wiring: client.stream(req) -> Stream.flatMap(event ->
fromIterable(map.map(event))) -> Stream.concat(suspended
fromIterable(map.flush())) -> Stream.provideService(
RequestExecutor.Service, executor). The flush stream is built lazily
with `Stream.unwrap(Effect.sync(...))` so it observes the mapper
final state after every upstream event has been mapped.
- The mapper (`LLMNativeEvents.mapper`) emits AI-SDK-shaped session
events from `LLMEvent` so downstream consumers see one shape.
What this does NOT do (deferred to later phases):
- No tool support on the native path (skipped, falls through).
- No parity harness yet; Phase 2 builds it.
- No production traffic; flag is off by default and no production
caller populates `nativeMessages`.
- No reasoning/cache/multi-modal coverage. Anthropic supports reasoning
and cache via existing patches, so those start working as soon as a
caller routes a real session through.
Verification: opencode typecheck clean, bridge tests still green
(33/0/0 across llm-native.test.ts + llm-bridge.test.ts); LLM package
tests green (123/0/0).
Closes audit gap #3. The bridge now extracts the encrypted reasoning
blob from `MessageV2.ReasoningPart.metadata` and surfaces it on
`LLM.ReasoningPart.encrypted`, where the Anthropic and Bedrock
adapters lower it to the wire — Anthropic emits `thinking.signature`,
Bedrock emits `reasoningContent.reasoningText.signature`. Without
this, multi-turn sessions with reasoning models would lose the
encrypted state on every step and break the chain.
The encrypted blob originates in three different places depending on
how the session was started:
1. AI-SDK Anthropic sessions store it as
`metadata.anthropic.signature` (per AI SDK provider-keyed
convention).
2. AI-SDK OpenAI sessions store it as
`metadata.openai.reasoningEncryptedContent`.
3. Future LLM-native sessions will store it as a top-level
`metadata.encrypted` string (cleanest shape — provider-agnostic,
matches the LLM IR field name).
The new `encryptedReasoning` helper probes all three locations in
order, so existing OpenCode sessions can be served by the LLM-native
path without re-recording reasoning content. The full `metadata`
record continues to flow through to `LLM.ReasoningPart.metadata`
unchanged, preserving any provider-specific fields adapters might
read in the future.
OpenAI Responses encrypted reasoning round-trip is intentionally out
of scope: the LLM-package adapter doesn't yet model reasoning items
in the request body. That's a separate adapter feature requiring new
input-item schema variants and is deferred until needed.
Tests (5 new in llm-native.test.ts):
- AI-SDK Anthropic signature extracted into LLM.ReasoningPart.encrypted.
- End-to-end Anthropic lowering: bridge \u2192 client.prepare \u2192 target with
`thinking.signature` populated correctly.
- AI-SDK OpenAI reasoningEncryptedContent extracted (forward
compatibility — useful when the OpenAI Responses adapter gains
reasoning-item lowering).
- Top-level metadata.encrypted extracted (LLM-native session shape).
- No known key in metadata leaves `encrypted` undefined.
Verified: 33/0/0 across native + bridge tests (was 28; +5 from the
new reasoning extraction tests).
Closes audit gap #2 (FilePart \u2192 MediaPart not implemented).
The bridge now lowers `MessageV2.FilePart` on user messages into
`LLM.MediaPart`, unblocking image and document inputs. The first
pass supports `data:` URLs only — the inline base64 form most
commonly produced by the OpenCode UI for pasted screenshots and
attached files. `http(s):` and `file:` URLs are explicitly
rejected with a clear error so a future fetch / filesystem-read
path can plug in cleanly without regressing safety.
Implementation:
- New `lowerFilePart` helper extracts the base64 payload from a
data URL via a single regex; failure yields a typed
`UnsupportedContentError` carrying both the partType and a
`reason` that includes the offending URL for debuggability.
- New `lowerUserPart` dispatches user-side parts: text \u2192
`LLM.text`, file \u2192 `MediaPart`. Returns identity-empty
for any unsupported part type the static gate would have caught.
- `userMessage` is now `Effect.fnUntraced` so file conversion can
yield typed errors. `lowerMessage` (the per-message dispatcher,
renamed from `messages` to free the local name) cascades the
Effect through the request flow via `Effect.forEach`.
- `supportsPart` static gate now allows `file` parts on user
messages. Assistant messages still reject file parts (the LLM
IR's MediaPart isn't valid in assistant content for any
adapter we ship today).
- `UnsupportedContentError` gains an optional `reason` field that
appends to the canonical message as `<base>: <reason>`. Existing
static-gate failures keep the same shape (no reason).
Tests (3 new, 1 rewritten):
- Image data URL with filename round-trips to MediaPart with
base64-stripped data.
- PDF data URL preserves filename and base64 payload.
- `https:` URL rejected with an error mentioning both the file
partType, the message ID, and the offending URL.
- The pre-existing "fails instead of dropping unsupported native
parts" test now uses a reasoning part on a user message
(reasoning is valid for assistants only) since file parts with
data URLs are no longer rejected by the static gate.
Out of scope, intentional follow-ups:
- HTTP/HTTPS URL fetching (would need HttpClient.HttpClient and a
decision on caching, retries, size limits).
- File path / file:// URL reading (would need FileSystem.FileSystem
and a permission check against the session's working directory).
- File parts on assistant messages (LLM IR doesn't model
assistant-side media; defer until we hit a provider that needs it).
- text/plain and application/x-directory file parts that the
AI-SDK path converts to text inline at message-v2.ts:791 — for
the bridge, those should be converted upstream before reaching
LLMNative.request rather than handled here.
Verified: bun typecheck clean, 28/0/0 across native + bridge
tests (was 21; +7 from the FilePart additions plus the rewritten
unsupported-parts test).
Lift the prompt-cache policy out of OpenCode's bridge and into the
LLM package as a typed, gated patch. The policy mirrors the AI-SDK
applyCaching path (packages/opencode/src/provider/transform.ts:229):
mark the first 2 system parts and the last 2 messages with an
ephemeral cache hint, gated on `model.capabilities.cache.prompt`.
Adapters lower the hint structurally — Anthropic emits
`cache_control: { type: "ephemeral" }` on the marked block,
Bedrock emits a positional `cachePoint: { type: "default" }`
after the marked block (added in 9d7d518ac). The capability gate
keeps non-cache adapters (OpenAI Responses, Gemini, OpenAI-compat
Chat) hint-free.
Why a Patch and not bridge code:
- packages/llm/AGENTS.md TODO explicitly calls for cache hint patches
- Other consumers of @opencode-ai/llm get caching for free
- The bridge stays focused on shape conversion (MessageV2 \u2192 LLMRequest)
- Patches compose via ProviderPatch.defaults (now includes this one)
- The capability gate is a typed predicate, not provider-name matching
Implementation:
- New `cachePromptHints` patch in provider/patch.ts. The
`withCacheOnLastText` helper uses Array.findLastIndex (codebase
idiom) and short-circuits when no text part exists so messages
with only tool-result content are returned identity-equal.
- `EPHEMERAL_CACHE` is a single shared CacheHint instance — no
per-request allocation, preserves `instanceof` for any consumer
that checks class identity.
- Added to `ProviderPatch.defaults` so existing callers that pass
`defaults` get cache support automatically.
Tests (5 new in patch.test.ts):
- Marks first 2 system parts on cache-capable models.
- Marks last text part of last 2 messages.
- Targets the last text part when a message has trailing
non-text content (assistant text + tool-call).
- Returns content unchanged (identity-equal) when no text part
exists, so pure tool-result messages don't allocate.
- No-op when the model does not advertise prompt caching.
Bridge cleanup:
- Removed `applyCachePolicy`, `withCacheOnLastText`,
`updateMessageContent`, `EPHEMERAL_CACHE` from llm-native.ts
(-30 lines of bridge-side cache code).
- Dropped now-unused `CacheHint`, `LLMRequest`, `Message` imports.
- The bridge's only responsibility is now MessageV2 lowering;
callers wire `patches: ProviderPatch.defaults` at client
construction.
OpenCode tests rewritten:
- Old: assert on `request.system[N].cache` (bridge internals).
- New: assert on `prepared.target` after running through
`LLMClient.make({ adapters, patches: ProviderPatch.defaults })
.prepare(request)` — verifies the full lowering end-to-end.
- Anthropic: target.system[0..1] carry `cache_control: ephemeral`,
target.messages[1..2] carry it on the final text block.
- Bedrock: target has `cachePoint` markers after each cached block.
- Non-cache (OpenAI Responses): JSON.stringify(target) contains
none of `cache_control` / `cachePoint` / `ephemeral`.
Verified: bun typecheck clean across both packages, 120/0/0 in LLM
package (was 113; +7 from new patch tests counting parameter
variations), 21/0/0 in OpenCode native+bridge tests.
Implements the AWS Bedrock Converse streaming protocol as the 5th
first-class adapter in @opencode-ai/llm. Single `bedrock-converse`
adapter covers all underlying models (Anthropic, Llama, Mistral,
Cohere, Nova, Titan) since Converse is uniform.
Wire format: messages with text / reasoning / toolUse / toolResult
content blocks, system blocks, inferenceConfig, toolConfig with
toolSpec + toolChoice. Image / document / cache-point content types
are still TODO.
Streaming: AWS event stream binary framing via @smithy/eventstream-codec.
Each frame is decoded then dispatched by `:event-type` header into
the chunk schema. Bedrock splits the finish across `messageStop`
(reason) and `metadata` (usage) — the parser stashes the reason and
emits a single consolidated `request-finish` event when metadata
arrives, with an `onHalt` fallback for truncated streams.
Auth: two paths. Bearer API key (newer) when the consumer sets
`model.headers.authorization = 'Bearer <key>'`. SigV4 signing via
aws4fetch otherwise — credentials live on `model.native.aws_credentials`
and are signed at `toHttp` time so STS-vended tokens are picked up
when the consumer rebuilds the model. The adapter rejects requests
with neither auth path with a clear InvalidRequestError.
Routing: `@ai-sdk/amazon-bedrock` lowers to `bedrock-converse` via
the new `AmazonBedrock` provider routing module; the OpenCode
`llm-bridge.ts` registers it.
Cassette format: response bodies under
`application/vnd.amazon.eventstream` and `application/octet-stream`
content types are now stored as base64 with `bodyEncoding: 'base64'`
on the response snapshot — text round-tripping mangled the CRC32
fields in event-stream frames. Existing cassettes (SSE/JSON) omit
the field and decode as text unchanged.
Tests: 11 deterministic fixtures (prepare / lower messages / lower
tool config / decode text+usage / decode tool calls / decode
reasoning / decode throttling exception / auth path validation /
SigV4 plumbing) + 2 recorded cassettes against live Bedrock
(`us.amazon.nova-micro-v1:0` in us-east-1) for streaming text and
streaming tool calls.
AGENTS.md: documents the Bedrock auth model, binary cassette format,
and updates the protocol coverage / cassette backlog.
Deps: @smithy/eventstream-codec, @smithy/util-utf8, aws4fetch (~40KB
combined; matches AI SDK's approach).
The bash tool description now explicitly states that the temp directory has already been created and exists, preventing agents from unnecessarily trying to create it before use.
- Removed @effect/language-service from both packages/core and packages/opencode tsconfig files and dependencies
- Wrapped mergeDeep calls in config loading and LLM streaming to avoid expensive remeda conditional merge type instantiations in hot paths
- Narrowed Drizzle migrate() overload signature to avoid expensive variance checks during database initialization
These changes reduce TypeScript type-checking overhead and improve startup and runtime performance for config loading, LLM streaming, and database migrations.