From d62bede2f79c7cbccf6272eae357c62d0c29051b Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 5 May 2026 18:05:46 -0400 Subject: [PATCH] test(llm): cover public export surface --- packages/llm/TOUR.md | 72 +++++++++++++++++++++---------- packages/llm/package.json | 20 +++++++-- packages/llm/test/exports.test.ts | 36 ++++++++++++++++ 3 files changed, 102 insertions(+), 26 deletions(-) create mode 100644 packages/llm/test/exports.test.ts diff --git a/packages/llm/TOUR.md b/packages/llm/TOUR.md index 04b707b5c2..d4f3fe0a3c 100644 --- a/packages/llm/TOUR.md +++ b/packages/llm/TOUR.md @@ -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 + -> LLMResponse + +Zoom into adapter.parse(...): + +HttpClientResponse.stream + -> Framing + -> Frame + -> protocol.chunk + -> Chunk + -> protocol.process(State, Chunk) + -> LLMEvent[] + -> Stream +``` + 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 = const chunks: Stream.Stream = 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 const initialState: State = protocol.initial() const eventBatches: Stream.Stream, ProviderChunkError> = chunks.pipe( @@ -246,19 +278,6 @@ const collected: { readonly events: ReadonlyArray; 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 { } ``` -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 diff --git a/packages/llm/package.json b/packages/llm/package.json index f2c9ab7779..06ef5a1f45 100644 --- a/packages/llm/package.json +++ b/packages/llm/package.json @@ -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", diff --git a/packages/llm/test/exports.test.ts b/packages/llm/test/exports.test.ts new file mode 100644 index 0000000000..b222908321 --- /dev/null +++ b/packages/llm/test/exports.test.ts @@ -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) + }) +})