test(llm): cover public export surface
This commit is contained in:
+50
-22
@@ -6,6 +6,15 @@ The short version: the public API is small, providers are built from composable
|
||||
|
||||
Use this as a code-reading path. Open the linked files in order and skim the referenced sections.
|
||||
|
||||
## Tour Index
|
||||
|
||||
- **Use-site shape**: Sections 1-2 show the public API and canonical request model.
|
||||
- **Request lifecycle**: Sections 3-4 name the main runtime pieces and follow one request through compile, HTTP, parse, and collect.
|
||||
- **Provider internals**: Sections 5-8 explain protocols, adapter composition, provider helpers, and transforms.
|
||||
- **Tools and streams**: Sections 9-10 show tool-loop behavior and provider-specific parser examples.
|
||||
- **Testing story**: Sections 11-13 cover deterministic fixtures, recorded cassettes, and recording commands.
|
||||
- **Wrap-up paths**: Sections 14-15 summarize the design payoff and suggest shorter reading paths for demos.
|
||||
|
||||
## 1. Start With The Use Site
|
||||
|
||||
Start with the runnable tutorial: [`example/tutorial.ts`](./example/tutorial.ts).
|
||||
@@ -82,6 +91,28 @@ At runtime, the flow is easier to read as a sequence of value transformations. T
|
||||
- The main request path: caller input becomes a provider HTTP request, then normalized events.
|
||||
- The parser zoom-in: `adapter.parse(...)` hides response framing, chunk decoding, and stream state.
|
||||
|
||||
```text
|
||||
RequestInput
|
||||
-> LLMRequest
|
||||
-> TransformedRequest
|
||||
-> provider Payload
|
||||
-> HttpClientRequest
|
||||
-> HttpClientResponse
|
||||
-> Stream<LLMEvent>
|
||||
-> LLMResponse
|
||||
|
||||
Zoom into adapter.parse(...):
|
||||
|
||||
HttpClientResponse.stream
|
||||
-> Framing
|
||||
-> Frame
|
||||
-> protocol.chunk
|
||||
-> Chunk
|
||||
-> protocol.process(State, Chunk)
|
||||
-> LLMEvent[]
|
||||
-> Stream<LLMEvent>
|
||||
```
|
||||
|
||||
The snippet below is pseudo-code. It shows resolved values at each boundary, not the `Effect` wrappers used by the implementation.
|
||||
|
||||
```ts
|
||||
@@ -219,8 +250,9 @@ const decodeChunk: (frame: Frame) => Effect.Effect<Chunk, ProviderChunkError> =
|
||||
|
||||
const chunks: Stream.Stream<Chunk, ProviderChunkError> = frames.pipe(Stream.mapEffect(decodeChunk))
|
||||
|
||||
// Protocol.process is the stream parser state machine. `State` carries whatever
|
||||
// memory this API needs between chunks, such as partial text or tool arguments.
|
||||
// Protocol.process is where provider events become LLMEvents.
|
||||
// Example: OpenAI may stream one tool call over several chunks; `State` holds
|
||||
// the partial argument JSON until the final chunk emits one `tool-call` event.
|
||||
// State + Chunk -> State + ReadonlyArray<LLMEvent>
|
||||
const initialState: State = protocol.initial()
|
||||
const eventBatches: Stream.Stream<ReadonlyArray<LLMEvent>, ProviderChunkError> = chunks.pipe(
|
||||
@@ -246,19 +278,6 @@ const collected: { readonly events: ReadonlyArray<LLMEvent>; readonly usage?: Us
|
||||
const response: LLMResponse = new LLMResponse(collected)
|
||||
```
|
||||
|
||||
The important translation points are:
|
||||
|
||||
- `LLM.request(input)` turns ergonomic caller input into canonical `LLMRequest`.
|
||||
- `client.prepare(request)`, `client.stream(request)`, and `client.generate(request)` hand the canonical request to the lower-level runtime.
|
||||
- `transformPipeline.transformRequest(request)` applies request, prompt, and tool-schema transforms.
|
||||
- `adapter.toPayload(transformedRequest.request)` turns canonical `LLMRequest` into provider-native payload.
|
||||
- `transformPipeline.transformPayload(...)` applies adapter-local payload transforms and validates with `adapter.payloadSchema`.
|
||||
- `adapter.toHttp(payload, context)` turns provider-native payload into `HttpClientRequest`.
|
||||
- `Framing` turns response bytes into protocol frames.
|
||||
- `protocol.chunk` turns frames into provider-native chunks.
|
||||
- `protocol.process(state, chunk)` turns provider-native chunks into common `LLMEvent`s.
|
||||
- `LLM.generate` turns the event stream into `LLMResponse`.
|
||||
|
||||
The useful lower-level seam is `LLMClient.prepare`: it compiles the entire provider request without sending it. That makes request-shape tests cheap and makes demos easy because you can show exactly what would be sent. It is intentionally not part of the top-level `LLM` convenience API.
|
||||
|
||||
See examples in [`test/provider/openai-chat.test.ts`](./test/provider/openai-chat.test.ts) and [`test/provider/openai-responses.test.ts`](./test/provider/openai-responses.test.ts).
|
||||
@@ -293,7 +312,7 @@ interface Protocol<Payload, Frame, Chunk, State> {
|
||||
}
|
||||
```
|
||||
|
||||
Read those generics as the parser pipeline:
|
||||
Read those generics as the same parser zoom-in from Section 4:
|
||||
|
||||
- `Payload`: the provider-native JSON body after request conversion and adapter-local payload transforms.
|
||||
- `Frame`: one response unit after byte framing, such as an SSE `data:` string or a Bedrock event-stream object.
|
||||
@@ -308,7 +327,7 @@ The main protocol implementations are:
|
||||
- Gemini GenerateContent: [`src/protocols/gemini.ts`](./src/protocols/gemini.ts)
|
||||
- Bedrock Converse: [`src/protocols/bedrock-converse.ts`](./src/protocols/bedrock-converse.ts)
|
||||
|
||||
The protocol files are intentionally sectioned the same way:
|
||||
The protocol files are sectioned the same way:
|
||||
|
||||
```ts
|
||||
Public Model Input
|
||||
@@ -319,7 +338,7 @@ Protocol And Adapter
|
||||
Model Helper
|
||||
```
|
||||
|
||||
That layout makes each protocol readable as a story: what does the wire payload look like, how do common requests turn into it, how do provider stream chunks become common events, and how is the runnable adapter assembled?
|
||||
That layout keeps the same story in each file: wire payload, request lowering, stream parsing, and adapter assembly.
|
||||
|
||||
## 6. Adapter Composition Is Where The Reuse Shows Up
|
||||
|
||||
@@ -329,6 +348,17 @@ The adapter composition rule is:
|
||||
Adapter = Protocol + Endpoint + Auth + Framing
|
||||
```
|
||||
|
||||
```text
|
||||
+-------------------+
|
||||
| Protocol | request lowering + stream parsing
|
||||
+-------------------+
|
||||
|
|
||||
+----------+ +---------v---------+ +------+ +---------+
|
||||
| Endpoint | --> | Adapter | <-- | Auth | <-- | Framing |
|
||||
+----------+ +-------------------+ +------+ +---------+
|
||||
URL runnable route headers bytes -> frames
|
||||
```
|
||||
|
||||
The pieces live in these files:
|
||||
|
||||
- Protocol contract: [`src/protocol.ts`](./src/protocol.ts)
|
||||
@@ -515,9 +545,7 @@ What is worth showing:
|
||||
|
||||
The common event model is what makes this work across providers. Providers emit `tool-input-delta`, `tool-call`, `tool-result`, and `request-finish` events; the runtime consumes those events and decides whether another model round is needed.
|
||||
|
||||
## 10. Stream Parsers Are Small State Machines
|
||||
|
||||
Each protocol's stream parser turns provider-native chunks into common events.
|
||||
## 10. Stream Parser Examples
|
||||
|
||||
Examples worth reading:
|
||||
|
||||
@@ -527,7 +555,7 @@ Examples worth reading:
|
||||
- [`src/protocols/gemini.ts`](./src/protocols/gemini.ts) converts Gemini parts into text, reasoning, and tool-call events.
|
||||
- [`src/protocols/bedrock-converse.ts`](./src/protocols/bedrock-converse.ts) parses AWS event-stream frames and waits for metadata to emit finish with usage.
|
||||
|
||||
This is the part where provider APIs differ the most. The normalized result is still one `LLMEvent` stream.
|
||||
This is where provider APIs differ the most, behind the same normalized `LLMEvent` stream.
|
||||
|
||||
## 11. Deterministic Tests Cover The Parser Edge Cases
|
||||
|
||||
|
||||
@@ -13,11 +13,23 @@
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./providers": "./src/providers.ts",
|
||||
"./providers/*": "./src/providers/*.ts",
|
||||
"./providers/amazon-bedrock": "./src/providers/amazon-bedrock.ts",
|
||||
"./providers/anthropic": "./src/providers/anthropic.ts",
|
||||
"./providers/azure": "./src/providers/azure.ts",
|
||||
"./providers/github-copilot": "./src/providers/github-copilot.ts",
|
||||
"./providers/google": "./src/providers/google.ts",
|
||||
"./providers/openai": "./src/providers/openai.ts",
|
||||
"./providers/openai-compatible": "./src/providers/openai-compatible.ts",
|
||||
"./providers/openrouter": "./src/providers/openrouter.ts",
|
||||
"./providers/xai": "./src/providers/xai.ts",
|
||||
"./protocols": "./src/protocols.ts",
|
||||
"./protocols/*": "./src/protocols/*.ts",
|
||||
"./provider-transform": "./src/provider-transform.ts",
|
||||
"./*": "./src/*.ts"
|
||||
"./protocols/anthropic-messages": "./src/protocols/anthropic-messages.ts",
|
||||
"./protocols/bedrock-converse": "./src/protocols/bedrock-converse.ts",
|
||||
"./protocols/gemini": "./src/protocols/gemini.ts",
|
||||
"./protocols/openai-chat": "./src/protocols/openai-chat.ts",
|
||||
"./protocols/openai-compatible-chat": "./src/protocols/openai-compatible-chat.ts",
|
||||
"./protocols/openai-responses": "./src/protocols/openai-responses.ts",
|
||||
"./provider-transform": "./src/provider-transform.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@clack/prompts": "1.0.0-alpha.1",
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Adapter, LLM, LLMClient, ProviderTransform, Protocol, Transform } from "@opencode-ai/llm"
|
||||
import { OpenAI, OpenAICompatible, OpenRouter } from "@opencode-ai/llm/providers"
|
||||
import * as GitHubCopilot from "@opencode-ai/llm/providers/github-copilot"
|
||||
import { OpenAIChat, OpenAICompatibleChat, OpenAIResponses } from "@opencode-ai/llm/protocols"
|
||||
import * as AnthropicMessages from "@opencode-ai/llm/protocols/anthropic-messages"
|
||||
import * as ProviderTransformSubpath from "@opencode-ai/llm/provider-transform"
|
||||
|
||||
describe("public exports", () => {
|
||||
test("root exposes core runtime and transform APIs", () => {
|
||||
expect(Adapter.make).toBeFunction()
|
||||
expect(LLM.generate).toBeFunction()
|
||||
expect(LLMClient.make).toBeFunction()
|
||||
expect(Protocol.define).toBeFunction()
|
||||
expect(Transform.prompt).toBeFunction()
|
||||
expect(ProviderTransform.defaults.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test("provider barrels expose user-facing facades", () => {
|
||||
expect(OpenAI.model).toBeFunction()
|
||||
expect(OpenAICompatible.deepseek.model).toBeFunction()
|
||||
expect(OpenRouter.model).toBeFunction()
|
||||
expect(GitHubCopilot.model).toBeFunction()
|
||||
})
|
||||
|
||||
test("protocol barrels expose supported low-level adapters", () => {
|
||||
expect(OpenAIChat.adapter.id).toBe("openai-chat")
|
||||
expect(OpenAICompatibleChat.adapter.id).toBe("openai-compatible-chat")
|
||||
expect(OpenAIResponses.adapter.id).toBe("openai-responses")
|
||||
expect(AnthropicMessages.adapter.id).toBe("anthropic-messages")
|
||||
})
|
||||
|
||||
test("provider-transform subpath exposes transform defaults", () => {
|
||||
expect(ProviderTransformSubpath.defaults).toBe(ProviderTransform.defaults)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user