LLMClient.prepare(request) returned a PreparedRequest with target: unknown.
Callers building debug UIs / request previews / plan rendering had to cast
target to the adapter's native shape at every read.
Adds PreparedRequestOf<Target> in schema and a generic Target = unknown
parameter on LLMClient.prepare so callers can opt in to a typed view:
const prepared = yield* client.prepare<OpenAIChatTarget>(request)
prepared.target.model // typed
prepared.target.messages // typed
The runtime payload is unchanged — the adapter still emits target: unknown
and the consumer asserts the shape they expect from the configured adapter.
The cast lives at the public boundary in adapter.ts; everything else stays
honest about runtime types.
Existing callers without the type argument still get target: unknown and
nothing breaks. Test in openai-chat.test.ts proves the narrowing at the
type level.
Schema.toTaggedUnion('type') already provides LLMEvent.guards but uses
kebab-case bracket access (LLMEvent.guards['tool-call']). Adds an LLMEvent.is
namespace with camelCase aliases that delegate to the same guards, so
consumers can write events.filter(LLMEvent.is.toolCall) instead of
events.filter(LLMEvent.guards['tool-call']).
Migrated all callsites in src/llm.ts and the two test files for consistency.
LLMEvent.guards / .match / .cases / .isAnyOf remain available for callers
who want the Effect-canonical API.
After the auth-axis migration, the OpenCode bridge consults this enum
solely to decide whether to read `provider.key` and stamp it on
`model.apiKey`. The bearer / anthropic-api-key / google-api-key
distinctions used to control which header the bridge wrote; that is now
the adapter's Auth axis's job.
Three of four variants were write-only after the migration. Collapse to:
- 'key' — provider needs an API key
- 'none' — provider does not (e.g. local)
Updated all six provider resolvers and the resolver test fixtures.
Two cleanups to make the adapter constructor surface honest about what is
canonical and what is an escape hatch:
- Adapter.compose existed to override pieces of an existing adapter, used
by OpenAI-compatible Chat before the four-axis migration. After the
migration nothing references it; OpenAI-compatible Chat composes via
fromProtocol({ protocol: OpenAIChat.protocol, ... }) instead. Delete
the function and its ComposeInput type.
- Adapter.define is the lower-level escape hatch for adapters whose
behavior genuinely cannot fit the Protocol/Endpoint/Auth/Framing model.
Its name implied it was the canonical entry point. Renamed to
Adapter.unsafe so the four-axis Adapter.fromProtocol(...) reads as the
obvious primary path and the escape hatch carries its escape semantics
in its name.
Updated test fixtures in adapter.test.ts and the AGENTS.md guidance.
The optional 'provider' field on Adapter / AdapterInput / FromProtocolInput
existed as a registry filter: requests with a different model.provider could
not find adapters that set it. After the four-axis migration no adapter
needed it (and an earlier pass removed it from the five migrated providers
because setting it broke session/llm-native tests).
Drop the field entirely and collapse the registry to a single-tier protocol
lookup. If a future deployment genuinely needs to be scoped (e.g. an
Azure-only OpenAI Responses adapter), reintroduce as 'scopedTo' with an
explicit name. Solve when needed, not before.
Also drops the test that exercised the now-removed two-tier lookup
('prefers provider-specific adapters over protocol fallbacks').
The commit that promoted queryParams to a typed ModelRef field updated
the implementation but left two JSDoc/doc references pointing at the old
model.native.queryParams path.
Promotes queryParams to a first-class ModelRef field used by Endpoint.baseURL,
so deployment-level URL query params (Azure api-version, OpenAI-compatible
provider knobs) live in a typed home instead of an opaque `native` bag.
Also removes write-only dead fields from `native`:
- openaiCompatibleProvider (set by family helper, never read)
- opencodeProviderID, opencodeModelID (set by opencode bridge + native session
builder, never read)
- npm (set by opencode bridge, never read)
After this commit `model.native` only carries genuinely provider-specific
opaque options that no other adapter cares about (Bedrock's aws_credentials
+ aws_region for SigV4). Drops the now-dead ProviderShared.queryParams
helper. Updates AGENTS.md doc on native is implicit through the new schema
JSDoc.
The two paths are independent: `model.apiKey` produces a synchronous
Bearer auth, while AWS credentials need an effectful sigv4 sign.
Hoist the bearer path out of `Effect.gen` and reuse `Auth.bearer`
directly, keeping the SigV4 path as a focused `Effect.gen` that owns
the credential lookup, signing, and header merge.
Inlines the now single-use `headersForSigning` and `signed` setup.
After the apiKey migration, every adapter explicitly specified `auth`,
and three of them (OpenAI Chat, OpenAI Responses, OpenAI-compatible Chat)
all wrote `auth: Auth.bearer`. `Auth.bearer` is a no-op when
`model.apiKey` is unset, so making it the default is strictly safer than
the previous `Auth.passthrough` default — bearer-style adapters drop
their explicit `auth` line, and adapters that need a different scheme
opt out via `Auth.apiKeyHeader(...)` (Anthropic, Gemini) or a custom
`Auth` (Bedrock SigV4 + Bearer).
Update doc comments on `fromProtocol.auth`, `Auth` type, and
`packages/llm/AGENTS.md` to reflect the new default.
Add an optional `apiKey` field to `ModelRef` so authentication is no
longer baked into `model.headers` at construction time. Each provider
adapter now passes an `Auth` to `Adapter.fromProtocol` that reads
`request.model.apiKey` per request:
- OpenAI Chat / Responses / OpenAI-compatible Chat: `Auth.bearer`
- Anthropic Messages: `Auth.apiKeyHeader("x-api-key")`
- Gemini: `Auth.apiKeyHeader("x-goog-api-key")`
- Bedrock Converse: custom auth that uses `apiKey` for Bearer auth
and falls back to SigV4 with AWS credentials
The `model()` constructors no longer fold the API key into
`model.headers`. The OpenCode bridge sets `apiKey` directly instead of
building auth headers via the now-deleted `authHeader` helper. Test
assertions move from `headers: { authorization: "Bearer ..." }` to
`apiKey: "..."`.
Both helpers had the same shape: read `request.model.apiKey`, no-op if
absent, otherwise merge a one-key header object. Lift that into a tiny
`fromApiKey(from)` helper and define both in terms of it.
The public surface (`Auth.bearer`, `Auth.apiKeyHeader`) is unchanged.
Removes the provider field from the five migrated Adapter.fromProtocol
calls. Setting provider scopes the adapter in the registry so requests
must use the same provider id, which broke session/llm-native tests
that build models with provider 'amazon-bedrock' against the
bedrock-converse adapter.
Adapters should stay protocol-only by default and only set provider
when the deployment is genuinely scoped (e.g. an Azure-only adapter
that does not work for native OpenAI). Restoring the original
protocol-only registration.
Per style guide, single-use values should be inlined. Each adapter had
a module-private constant used exactly once in its Adapter.fromProtocol
call. Inlining removes 5 named constants (4 DEFAULT_BASE_URL + 1
defaultBaseURL + ANTHROPIC_VERSION) without loss of clarity — the
string literal appears at the point of use.
After migration to Adapter.fromProtocol, the sse() convenience wrapper
and withQuery() URL builder are no longer called anywhere — Framing.sse
and Endpoint.baseURL handle their responsibilities directly. Also
inlines two exported-but-unused test constants (helloPrompt,
weatherPrompt) per style guide.
Updates the AGENTS.md adapter section to describe the four orthogonal
axes that make up an adapter today (Protocol + Endpoint + Auth + Framing)
and the canonical Adapter.fromProtocol composition. Adds a folder layout
overview so the dependency direction (provider/* imports protocol/auth/
endpoint/framing, never the other way) is visible.
Extracts a Protocol implementation per provider and wires the adapter
through Adapter.fromProtocol with explicit Endpoint, Auth, and Framing:
- OpenAI Responses — Endpoint.baseURL with /responses path.
- Anthropic Messages — adds anthropic-version header via the headers slot.
- Gemini — endpoint embeds the model id and pins ?alt=sse at the URL level.
- Bedrock Converse — keeps SigV4-or-Bearer auth as a typed Auth function;
AWS event-stream framing is a typed Framing value alongside the protocol;
Endpoint.baseURL gains a function-typed default so the URL host can carry
the per-request region.
Recorded replay byte-identical across all six adapters; full provider
suite 83 pass, full llm suite 122 pass, opencode typecheck clean.
Extracts OpenAIChat.protocol so that:
- openai-chat is now a four-line Adapter.fromProtocol composition over
the protocol, the OpenAI base URL, default passthrough auth, and SSE
framing.
- openai-compatible-chat reuses OpenAIChat.protocol verbatim. The whole
adapter is one Adapter.fromProtocol call that pins protocolId to
openai-compatible-chat and requires a caller-supplied baseURL.
Bug fixes in OpenAIChat.protocol now propagate to DeepSeek, TogetherAI,
Cerebras, Baseten, Fireworks, DeepInfra, and any future OpenAI-compatible
deployment without touching their files. Recorded replay byte-identical.
Introduces the four orthogonal axes that an LLM adapter is composed of:
- Protocol — semantic API contract (lowering, validation, encoding,
parsing). Examples: OpenAI Chat, Anthropic Messages, Bedrock Converse.
- Endpoint — URL construction (baseURL + path + query params).
- Auth — per-request transport authentication. Defaults to passthrough
for adapters whose auth header is baked into model.headers.
- Framing — byte stream to frames (SSE today; AWS event stream next).
Adds Adapter.fromProtocol(...) which composes these into the existing
AdapterDefinition shape so LLMClient.make(...) and the runtime registry
do not change. Existing adapters keep working through Adapter.define
until they migrate one at a time.
Frees up the Protocol name for the upcoming Protocol implementation type
that owns request lowering, target validation, and stream parsing as a
single composable unit. Field names on ModelRef and Adapter stay as
'protocol' since they carry the string discriminator value.
Wires the prompt-side tool resolver to also surface opencode-native
`Tool.Def[]` alongside the AI SDK record it already builds. With
`OPENCODE_EXPERIMENTAL_LLM_NATIVE=1` set, real production sessions
that satisfy the gate now stream through `LLMNativeTools.runWithTools`
instead of `streamText` — the LLM-native path goes from
"plumbing-only" to "actually used."
Changes:
- `prompt.ts:resolveTools` collects `Tool.Def[]` from the registry
loop and tracks a feasibility flag. MCP tools (which only have AI
SDK shape) flip the flag off; the synthesized `StructuredOutput`
tool that the json_schema branch injects also flips it. The return
shape becomes `{ tools, nativeTools }` where `nativeTools` is
`undefined` whenever any non-registry tool source contributes —
callers fall through to the AI SDK path automatically. The
registry path stays in sync because every `tools[item.id] =
tool({...})` is paired with a `nativeTools.push(item)` at the same
loop iteration.
- The single caller (`prompt.ts:1396`) destructures the new shape
and passes `nativeTools` through to `handle.process(...)`. The
json_schema branch sets `nativeTools = undefined` after injecting
`StructuredOutput` so the gate falls through for structured-output
sessions.
- `runNative` (in `session/llm.ts`) gains two safety nets that work
regardless of caller behavior:
1. Coverage check: if AI SDK tools are non-empty, every key must
have a matching `Tool.Def` in `nativeTools`. A partial set
falls through. Defends against future callers that might
emit a partial native list.
2. Filter parity: `runNative` now calls the existing
`resolveTools(input)` (the in-file permission/user-disabled
filter) and intersects its keys with `nativeTools`, then
feeds the filtered AI SDK record to the dispatcher and the
filtered native list to `LLMNative.request`. Without this,
sessions could see permission-disabled tools advertised on
one path but not the other.
- The dispatch path uses the filtered AI SDK tools record as the
execute table: `LLMNativeTools.runWithTools({ tools:
filteredAITools, ... })`. Tool definitions sent to the model are
the filtered native list. Every tool the model sees can dispatch.
What this enables: a session opted into the experimental flag, with
a clean toolset (registry-only, no MCP, no structured output),
running an Anthropic model, now exercises the streaming-dispatch
loop end-to-end. Tool calls fire as soon as the model finishes
streaming each tool's input; results land in the stream the moment
each handler resolves. Multi-round behavior matches phase 2 step 2b.
What this still does NOT do (deferred to step 4):
- Parity test harness comparing native vs AI SDK event sequences for
the same scripted session. Until that lands, broader confidence
comes from running real sessions with the flag set.
- MCP support on the native path. Sessions with MCP servers
configured stay on AI SDK indefinitely.
- Native support for the synthesized `StructuredOutput` tool.
Verification: opencode typecheck clean for `src/session/*` (the
TUI-side errors visible in the working tree are Kit's parallel
work, untouched here); bridge area tests 36/0/0 across
`llm-native.test.ts` + `llm-native-stream.test.ts` +
`llm-bridge.test.ts`; `prompt.test.ts` still 47/0/0 (no regression
from the resolveTools shape change).
Lands the streaming-dispatch tool loop for the LLM-native path. When
the gate-passing session has `nativeTools` populated, the native
runner forks an AI SDK `tool.execute(...)` the moment a `tool-call`
event arrives mid-stream and injects a synthetic `tool-result` event
back into the same stream when the handler resolves. Long-running
tools no longer block subsequent tool-call streaming; the user sees
each result land as soon as that specific handler completes.
The driver loops across rounds: when a round ends with `reason:
"tool-calls"` AND the dispatchers produced at least one result, the
runner builds a continuation `LLMRequest` (assistant message echoing
text/reasoning/tool-call content + tool messages with results) and
recurses. Stops on a non-`tool-calls` finish, when `maxSteps`
(default 10, mirrors `ToolRuntime.run`) is reached, or when the
underlying scope is interrupted.
New file `session/llm-native-tools.ts`:
- `runWithTools({ client, request, tools, abort, maxSteps? })` is the
public entry point. Returns a `Stream<LLMEvent, LLMError,
RequestExecutor.Service>` of merged model events + synthetic tool
results, ready to flow through `LLMNativeEvents.mapper` for
consumption by the existing session processor.
- `runOneRound` is the internal building block. It opens an unbounded
`Queue<LLMEvent, LLMError | Cause.Done>`, forks a producer that
streams the model and pushes each event to the queue, and forks a
dispatcher (via a scope-bound `FiberSet`) for every
non-provider-executed `tool-call`. Each dispatcher's result is
pushed back into the same queue. After the model stream completes,
the producer awaits `FiberSet.awaitEmpty` and ends the queue;
consumers see end-of-stream. A `Deferred<RoundState>` resolves
alongside so the multi-round driver can decide whether to recurse.
- `dispatchTool` wraps the AI SDK `tool.execute(input, { toolCallId,
messages, abortSignal })` call. Unknown-tool and execute-throws
paths produce `tool-error` events instead of failing the stream
(mirrors `ToolRuntime.run`'s defect-vs-recoverable boundary), so
the model can self-correct on the next round.
Wired into `runNative` (`session/llm.ts`): when `input.nativeTools`
is non-empty, the upstream becomes `LLMNativeTools.runWithTools(...)`
instead of `nativeClient.stream(...)`; the AI SDK `tools` record
flows in as the dispatch table. Zero-tool sessions still take the
direct-stream path (one round, no dispatch overhead).
Mapper update (`session/llm-native-events.ts`): `tool-result` events
whose `result.value` matches the opencode `Tool.ExecuteResult` shape
(`{ output: string, title?: string, metadata?: object }`) now flow
through to the AI-SDK-shaped session event with their `title` and
`metadata` preserved. Provider-executed and synthetic results that
don't match still fall back to `stringifyResult`. Without this, the
session processor would see every native tool result as
`{ title: "", metadata: {}, output: <JSON of the whole record> }`.
Smoke test (`test/session/llm-native-stream.test.ts`): scripts a
two-round Anthropic SSE backend — round 1 issues a `lookup` tool
call, round 2 replies with text after the tool result feeds back.
Asserts the full event sequence threads through `runWithTools`,
the dispatcher, and the mapper:
- `tool-call` event has the streamed JSON input parsed.
- `tool-result` event carries the `ExecuteResult` shape with
`title` + `output` populated (proving the mapper update works).
- Round 2 text-delta arrives after the synthetic tool-result.
- Final `finish` event has `finishReason: "stop"` (loop terminated).
What this still does NOT do (deferred to step 3):
- No production caller populates `nativeTools` yet; that's the
`prompt.ts:resolveTools` change. Until that lands, the gate keeps
every real session on the AI SDK path.
- No parity harness comparing native + AI SDK event sequences for
the same scripted session. That's step 4.
Verification: opencode typecheck clean; 36/0/0 across the three
bridge-area tests; 125/0/0 across the LLM package.
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).
Five review findings; all small, all independent.
H2: Bedrock used raw `JSON.parse` and `JSON.stringify` despite the
package rule against ad-hoc JSON encoders. The in-loop parse on each
event-stream frame goes through `ProviderShared.parseJson` (yielded
inside `Effect.gen`); the `decodeChunk` error fallback uses
`ProviderShared.encodeJson` instead of `JSON.stringify` for the raw
field on `ProviderChunkError`. No behavior change — just channels
JSON through the shared Schema-driven codec.
H3: `BedrockConverse.toHttp` built a `baseHeaders` record with
`content-type: application/json` and passed it through both auth
paths. The bearer path called `jsonPost` with the raw model headers
(no manual content-type), the SigV4 path used `baseHeaders` plus the
signed result. Two paths produced subtly different header sets and
both relied on `jsonPost` overwriting/adding the same content-type
key. Simplify: drop the unused bearer-side construction; rename the
SigV4 input to `headersForSigning` and document why content-type
must be present at signing time (signature covers it).
M4: Lift `isRecord` from `gemini.ts` into `ProviderShared.isRecord`
so adapters share one definition. The duplicates in `llm.ts` (LLM IR
layer) and `llm-native.ts` (OpenCode bridge) stay where they are —
those are at different layers and importing from `provider/` would
invert the dependency direction. Net effect: the provider layer
goes from 2 copies to 1.
L8: `TransportError` lost everything but the message string.
Surface the originating reason tag (`Timeout` / `TransportError` /
`ResponseError` / `RequestError`) and the request URL when
available, both as optional Schema fields. Consumers that don't
care keep getting the same `message` rendering; consumers that do
can finally render "timed out connecting to https://..." instead
of "HTTP transport failed".
M9 + L3: Two dead branches. Anthropic's `processChunk` had
`?? ""` fallbacks for `partial_json` after an early-return guard
already proved it non-empty. OpenAI Chat's `mapFinishReason` had
`if (reason === undefined || reason === null) return "unknown"`
followed by `return "unknown"` — both branches went to the same
place. Drop the unreachable code.
120 LLM-package tests + 33 OpenCode bridge tests still green.
Three review findings collapsed into one ProviderShared pass.
M1: Five adapters duplicated the same six-line block:
const ChunkJson = Schema.fromJsonString(Chunk)
const TargetJson = Schema.fromJsonString(Target)
const decodeChunkSync = Schema.decodeUnknownSync(ChunkJson)
const encodeTarget = Schema.encodeSync(TargetJson)
const decodeTarget = Schema.decodeUnknownEffect(Draft.pipe(Schema.decodeTo(Target)))
const decodeChunk = (data) => Effect.try({...chunkError(...)})
Lift it into `ProviderShared.codecs({ adapter, draft, target, chunk,
chunkErrorMessage })` returning `{ encodeTarget, decodeTarget,
decodeChunk }`. The result drops directly into `Adapter.define`'s
`validate` field (uses `validateWith` internally to map parse errors
to InvalidRequestError). Adopted in OpenAI Chat, OpenAI Responses,
Anthropic Messages, and Gemini. Bedrock has a custom event-stream
`decodeChunk` that takes `unknown` (not `string`) so it keeps its
inline codecs.
M2: Four adapters defined an identical `ToolAccumulator` interface
(`{ readonly id: string; readonly name: string; readonly input:
string }`). Lift to `ProviderShared.ToolAccumulator`. Anthropic
extends it locally with `providerExecuted` for hosted tools.
M3: The five `mapUsage` implementations had subtly different
`totalTokens` policies — OpenAI Chat passed through whatever the
provider sent, OpenAI Responses unconditionally summed inputs and
output (publishing `totalTokens: 0` when both were `undefined`),
Anthropic and Gemini guarded with conditionals, Bedrock used a
`(...) || undefined` falsy fallback. Add `ProviderShared.totalTokens`
with one rule: prefer provider-supplied total, else sum inputs and
outputs only when at least one is defined, else `undefined`. Fixes
the OpenAI Responses `totalTokens: 0` bug.
M6: Anthropic's `mergeUsage` recomputed `totalTokens` from the merged
input/output via two nested ?? chains and a conditional sum.
Simplified to use the same totalTokens helper, with `inputTokens` and
`outputTokens` extracted as locals so the merge is one ?? per field
and the comment explains why merging exists (Anthropic emits usage
on `message_start` and `message_delta`).
No behavior changes other than the OpenAI Responses fix; existing
tests pass unchanged. 120 LLM-package tests + 33 OpenCode bridge
tests green.
Two issues from the review of the LLM package's six adapters.
H1: Inconsistent apiKey precedence. Five of six adapters spread the
caller's headers first then set the auth header (apiKey wins), but
`OpenAICompatibleChat.model` did the opposite (caller headers won).
That meant a user passing both `apiKey` and `headers.authorization`
would get auth from a different source depending on which adapter
they routed through. Flip the OpenAI-compatible adapter to match the
rest, and add a comment documenting the rule: apiKey wins, callers
who want their own auth header should omit `apiKey` entirely.
H4: Gemini tool-schema sanitization was split across two functions
that both ran on every Gemini request — `convertJsonSchema` in the
adapter (lossy projection: drop empty objects, derive nullable from
type-array, allowlist of preserved keys, recursive properties/items)
and `sanitizeGeminiSchemaNode` registered as a default `tool-schema`
patch (fix-up: integer enums to strings, dangling required filtering,
untyped array typing, scalar property stripping). Both passes only
ran on Gemini models; debugging a tool schema rejection meant
checking both files.
Fold the patch's rules into the adapter as `sanitizeToolSchemaNode`,
running before the existing projection step (renamed
`projectToolSchemaNode`). Compose them in `convertToolSchema` and use
that in `lowerTool`. Delete the patch from `provider/patch.ts` and
`ProviderPatch.defaults`. The behavior is unchanged — same input,
same output — but the rules now live in one file with a header
comment explaining the two concerns.
The matching test in `gemini.test.ts` no longer needs to opt into a
patch list; it now asserts the adapter alone produces the sanitized
shape.
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.