Buckets:
| # LLM Call Site Sketches | |
| Scratchpad for examples first, abstractions second. Current direction: routes | |
| execute, provider facades organize configured route sets, and models carry route | |
| values directly. | |
| ## Conversation Summary | |
| Kit and Aidan want provider-specific LLM behavior to move out of opencode's AI | |
| SDK transform path and into `packages/llm` where possible. The goal is not a big | |
| generic transform layer; the goal is small composable route definitions backed by | |
| recorded golden tests. | |
| Things to keep testing against: | |
| - Cache placement: `cache: "auto"`, manual cache breakpoints, provider cache usage. | |
| - Images: golden image tests for providers/protocols that claim image support. | |
| - Reasoning: canonical reasoning parts/events versus provider-native knobs. | |
| - Auth: bearer, custom headers, multiple credentials, query auth, SigV4, OAuth, no auth. | |
| - OpenAI-compatible providers: DeepSeek, Together, Groq, Alibaba/DashScope, custom routers. | |
| - Provider switching: stale signatures, encrypted reasoning, provider metadata, incompatible parts. | |
| - Error quality: typed errors instead of generic SDK/server failures. | |
| ## Final Guide: Routes Execute, Providers Organize | |
| Do not introduce a first-class `Deployment` abstraction unless it gains real | |
| semantics. Provider facades are ergonomic configured route groups, not execution | |
| registries. The executable/composable thing is still a route. Do not make route | |
| construction publish to a global registry; models should carry their route value | |
| directly. | |
| Keep durable identity separate from runtime capability: | |
| - Durable identity is small serializable data like `{ providerID, modelID }` for | |
| config, sessions, logs, and catalogs. | |
| - Runtime capability is a `Model` with a route value, protocol, transport, auth, | |
| and defaults. It is allowed to contain functions and schemas. | |
| - If persisted identity needs to become executable, resolve it through an app | |
| boundary first. Do not make `LLMRequest` recover behavior from a global route | |
| side table. | |
| Keep unconfigured behavior values as values, not factories. A transport like | |
| `HttpTransport.sseJson` should be a reusable immutable value. Use a function only | |
| when the caller supplies options or when construction needs fresh state. | |
| Use constants to remove repetition before inventing abstractions. Provider ids | |
| are branded once per provider facade and reused across routes; a plain exported | |
| object is enough for the provider-facing API unless a helper earns its keep by | |
| removing repeated route projection. | |
| Expose default configured provider instances, and put provider-specific setup on | |
| `.configure(...)`. Model selectors stay pure: `model(id)`, `responses(id)`, | |
| `chat(id)`, etc. Endpoint/auth/resource/api-version configuration happens before | |
| model selection, not as a second argument to model selection. | |
| Use provider/product facades consistently: | |
| - One coherent provider/product config surface gets one top-level facade. | |
| - APIs/model kinds that share that config are methods on the facade. | |
| - Different products with different required config get separate top-level | |
| facades, not a shared namespace with unrelated children. | |
| - Default facades are exposed only when concrete defaults or lazy env/credential | |
| defaults make the facade valid. | |
| Examples: | |
| ```ts | |
| OpenAI.responses("gpt-4o") | |
| OpenAI.chat("gpt-4o") | |
| OpenAI.responsesWebSocket("gpt-4o") | |
| Azure.configure({ resourceName, apiKey }).responses("my-deployment") | |
| AmazonBedrock.configure({ region, credentials }).model("anthropic.claude-3-5-sonnet-20241022-v2:0") | |
| CloudflareAIGateway.configure({ accountId, gatewayId, gatewayApiKey, apiKey }).model("openai/gpt-4o") | |
| CloudflareWorkersAI.configure({ accountId, apiKey }).model("@cf/meta/llama-3.1-8b-instruct") | |
| OpenAICompatible.configure({ | |
| provider: "custom", | |
| baseURL: "https://custom.example/v1", | |
| auth: Auth.bearer(apiKey), | |
| }).model("custom-model") | |
| ``` | |
| Standardize the provider facade contract before abstracting construction. A | |
| plain object is enough at first; add a helper only if repeated route projection | |
| starts hiding the real provider-specific config. | |
| `Route.with(...)` patch semantics should be boring and explicit: | |
| - Omitted fields inherit from the original route. | |
| - `endpoint` patches merge with the existing endpoint, so overriding `baseURL` | |
| keeps the existing `path`. | |
| - `endpoint.query` merges by default; later values win. | |
| - `auth` replaces. | |
| - `headers` merge by default; undefined values are omitted. | |
| - `id` is optional in patches. Route ids are diagnostic/provider API labels, not | |
| global runtime registry keys. | |
| 1. **Route** | |
| - route id | |
| - provider id | |
| - protocol | |
| - body schema | |
| - body builder | |
| - stream event schema | |
| - parser/state machine | |
| - transport | |
| - method / IO shape | |
| - framing | |
| - request preparation | |
| - constants when unconfigured; functions only when configured | |
| - endpoint | |
| - base URL | |
| - static path | |
| - body/model-derived path | |
| - query params | |
| - auth | |
| - bearer | |
| - custom header | |
| - multiple credentials | |
| - SigV4 | |
| - none | |
| - defaults | |
| - headers | |
| - generation defaults | |
| - provider options | |
| - limits | |
| 2. **Provider Facade** | |
| - default configured provider instance | |
| - provider-specific `.configure(...)` | |
| - plain object/function facade over one or more routes | |
| - top-level export only when it represents one coherent config surface | |
| - no passive `Provider.make(...)` wrapper unless it gains runtime behavior | |
| 3. **Model Selector** | |
| - route/provider-owned selector | |
| - accepts model id only | |
| - returns executable models | |
| - does not accept endpoint/auth/deployment overrides | |
| 4. **Model** | |
| - model id | |
| - route value | |
| - provider id | |
| - configured route value at selection time | |
| 5. **LLM Request** | |
| - model | |
| - messages/tools | |
| - generation/cache/reasoning/response-format options | |
| - request-level HTTP overlays for per-request headers/query/body additions, | |
| not provider endpoint/auth reconfiguration | |
| 6. **Compile** | |
| - read route from model | |
| - merge route defaults and request overrides | |
| - build final URL from route endpoint | |
| - apply auth from the configured route | |
| - build body with protocol | |
| - execute with transport and parse with protocol | |
| ## Provider Facade Shape | |
| The provider abstraction is a facade over configured routes, not the runtime | |
| execution mechanism: | |
| ```ts | |
| type ProviderFacade<APIs, Config> = { | |
| readonly id: ProviderID | |
| readonly model: (id: string) => Model | |
| readonly configure: (input?: Config) => ProviderFacade<APIs, Config> | |
| } & APIs | |
| ``` | |
| Manual construction is fine and should be the default until duplication earns a | |
| helper: | |
| ```ts | |
| export const OpenAI = { | |
| id: openAIProvider, | |
| model: openAIResponses.model, | |
| responses: openAIResponses.model, | |
| chat: openAIChat.model, | |
| configure: configureOpenAI, | |
| } satisfies ProviderFacade< | |
| { | |
| responses: (id: string) => Model | |
| chat: (id: string) => Model | |
| }, | |
| OpenAIConfig | |
| > | |
| ``` | |
| If several providers repeat the same projection from route values to model | |
| methods, the helper can stay deliberately tiny: | |
| ```ts | |
| const configureOpenAI = (input: OpenAIConfig = {}) => | |
| Provider.define({ | |
| id: openAIProvider, | |
| routes: { | |
| responses: openAIResponses.with(openAIConfig(input)), | |
| chat: openAIChat.with(openAIConfig(input)), | |
| }, | |
| default: "responses", | |
| configure: configureOpenAI, | |
| }) | |
| export const OpenAI = configureOpenAI() | |
| ``` | |
| `Provider.define(...)` would only project route methods and preserve types: | |
| ```ts | |
| OpenAI.model("gpt-4o") | |
| OpenAI.responses("gpt-4o") | |
| OpenAI.chat("gpt-4o") | |
| OpenAI.configure({ apiKey }).responses("gpt-4o") | |
| ``` | |
| It must not register routes, select routes dynamically, or participate in | |
| execution. Execution still reads the route value carried by the model. | |
| ## Ideal Call Sites | |
| Define concrete routes for a native provider, then project them through a | |
| provider facade: | |
| ```ts | |
| const openAIProvider = ProviderID.make("openai") | |
| const openAIResponses = Route.make({ | |
| id: "openai-responses", | |
| provider: openAIProvider, | |
| protocol: OpenAIResponses.protocol, | |
| transport: HttpTransport.sseJson, | |
| endpoint: { | |
| baseURL: "https://api.openai.com/v1", | |
| path: "/responses", | |
| }, | |
| auth: Auth.envBearer("OPENAI_API_KEY"), | |
| }) | |
| const openAIChat = Route.make({ | |
| id: "openai-chat", | |
| provider: openAIProvider, | |
| protocol: OpenAIChat.protocol, | |
| transport: HttpTransport.sseJson, | |
| endpoint: { | |
| baseURL: "https://api.openai.com/v1", | |
| path: "/chat/completions", | |
| }, | |
| auth: Auth.envBearer("OPENAI_API_KEY"), | |
| }) | |
| const openAIResponsesWebSocket = openAIResponses.with({ | |
| id: "openai-responses-websocket", | |
| transport: WebSocketTransport.json, | |
| }) | |
| const openAIConfig = (input: OpenAIConfig) => ({ | |
| endpoint: input.endpoint, | |
| auth: input.auth ?? (input.apiKey ? Auth.bearer(input.apiKey) : undefined), | |
| headers: { | |
| "OpenAI-Organization": input.organization, | |
| "OpenAI-Project": input.project, | |
| }, | |
| }) | |
| const configureOpenAI = (input: OpenAIConfig = {}) => { | |
| const responses = openAIResponses.with(openAIConfig(input)) | |
| const responsesWebSocket = openAIResponsesWebSocket.with(openAIConfig(input)) | |
| const chat = openAIChat.with(openAIConfig(input)) | |
| return { | |
| id: openAIProvider, | |
| responses: responses.model, | |
| responsesWebSocket: responsesWebSocket.model, | |
| chat: chat.model, | |
| model: responses.model, | |
| configure: configureOpenAI, | |
| } | |
| } | |
| export const OpenAI = configureOpenAI() | |
| ``` | |
| Specialize it functionally for concrete providers: | |
| ```ts | |
| const deepSeekProvider = ProviderID.make("deepseek") | |
| const deepseekChat = openAIChat.with({ | |
| id: "deepseek-chat", | |
| provider: deepSeekProvider, | |
| endpoint: { | |
| baseURL: "https://api.deepseek.com/v1", | |
| }, | |
| auth: Auth.envBearer("DEEPSEEK_API_KEY"), | |
| }) | |
| const configureDeepSeek = (input: OpenAICompatibleConfig = {}) => { | |
| const route = deepseekChat.with({ | |
| endpoint: input.endpoint, | |
| auth: input.auth ?? (input.apiKey ? Auth.bearer(input.apiKey) : undefined), | |
| }) | |
| return { | |
| id: deepSeekProvider, | |
| model: route.model, | |
| configure: configureDeepSeek, | |
| } | |
| } | |
| export const DeepSeek = { | |
| id: deepSeekProvider, | |
| model: deepseekChat.model, | |
| configure: configureDeepSeek, | |
| } | |
| ``` | |
| Provider-specific configuration happens before model selection: | |
| ```ts | |
| const deepseek = DeepSeek.configure({ | |
| endpoint: { | |
| baseURL: "https://proxy.example.com/v1", | |
| }, | |
| auth: Auth.bearer(apiKey), | |
| }) | |
| const model = deepseek.model("deepseek-chat") | |
| ``` | |
| Final request call site stays boring: | |
| ```ts | |
| const response = | |
| yield * | |
| LLM.generate( | |
| LLM.request({ | |
| model: DeepSeek.model("deepseek-chat"), | |
| prompt: "Hello.", | |
| }), | |
| ) | |
| ``` | |
| HTTP versus WebSocket is represented as named route selectors, not as model or | |
| request overrides. Same protocol, different transport, different route: | |
| ```ts | |
| OpenAI.responses("gpt-4o") | |
| OpenAI.responsesWebSocket("gpt-4o") | |
| ``` | |
| The client should not require a different public layer just because a selected | |
| route uses WebSocket. Use one `LLMClient.layer` with HTTP and WebSocket runtime | |
| capabilities available; routes that do not need WebSocket simply never touch it. | |
| If a WebSocket route is selected in an environment without WebSocket support, | |
| fail with a typed transport configuration error. | |
| Azure is a route specialization with auth/path/default changes plus input | |
| mapping. The public API configures the Azure resource once, then selects | |
| deployment ids with pure model selectors: | |
| ```ts | |
| const azureProvider = ProviderID.make("azure") | |
| const azureResponses = openAIResponses.with({ | |
| id: "azure-openai-responses", | |
| provider: azureProvider, | |
| auth: Auth.envHeader("api-key", "AZURE_OPENAI_API_KEY"), | |
| }) | |
| const configureAzure = (input: AzureConfig = {}) => { | |
| const route = azureResponses.with({ | |
| endpoint: { | |
| baseURL: | |
| input.baseURL ?? | |
| Endpoint.envBaseURL( | |
| "AZURE_RESOURCE_NAME", | |
| (resourceName) => `https://${resourceName}.openai.azure.com/openai/v1`, | |
| ), | |
| query: { "api-version": input.apiVersion ?? "v1" }, | |
| }, | |
| auth: input.apiKey ? Auth.header("api-key", input.apiKey) : Auth.envHeader("api-key", "AZURE_OPENAI_API_KEY"), | |
| }) | |
| return { | |
| id: azureProvider, | |
| model: route.model, | |
| responses: route.model, | |
| configure: configureAzure, | |
| } | |
| } | |
| export const Azure = configureAzure() | |
| const azure = Azure.configure({ | |
| resourceName: "my-resource", | |
| apiVersion: "v1", | |
| }) | |
| const model = azure.responses("my-deployment") | |
| ``` | |
| Default provider facades are only valid when required configuration has a lazy | |
| default source. `Azure.responses("my-deployment")` can be valid if endpoint | |
| resolution reads `AZURE_RESOURCE_NAME` lazily and fails with a typed | |
| configuration error when missing. If a provider has no sensible lazy default, | |
| do not expose a default model selector; expose only a configured entrypoint. | |
| Cloudflare AI Gateway and Workers AI are separate product facades because their | |
| configuration surfaces differ. Do not make a root `Cloudflare.configure(...)` | |
| pretend there is one coherent Cloudflare provider configuration: | |
| ```ts | |
| const cloudflareProvider = ProviderID.make("cloudflare-ai-gateway") | |
| const cloudflareOpenAIChat = openAIChat.with({ | |
| id: "cloudflare-ai-gateway-openai-chat", | |
| provider: cloudflareProvider, | |
| auth: Auth.bearerHeader("cf-aig-authorization").andThen(Auth.bearer()), | |
| }) | |
| const configureCloudflareAIGateway = (input: CloudflareAIGatewayConfig) => { | |
| const route = cloudflareOpenAIChat.with({ | |
| endpoint: { | |
| baseURL: `https://gateway.ai.cloudflare.com/v1/${input.accountId}/${input.gatewayId}/openai`, | |
| }, | |
| auth: Auth.bearerHeader("cf-aig-authorization", input.gatewayApiKey).andThen(Auth.bearer(input.apiKey)), | |
| }) | |
| return { | |
| id: cloudflareProvider, | |
| model: (modelID: string) => route.model({ id: modelID }), | |
| configure: configureCloudflareAIGateway, | |
| } | |
| } | |
| export const CloudflareAIGateway = { | |
| id: cloudflareProvider, | |
| configure: configureCloudflareAIGateway, | |
| } | |
| const gateway = CloudflareAIGateway.configure({ | |
| accountId: "account", | |
| gatewayId: "gateway", | |
| gatewayApiKey, | |
| apiKey, | |
| }) | |
| const model = gateway.model("openai/gpt-4o") | |
| ``` | |
| If a Cloudflare product gains a full lazy env default, it can expose a direct | |
| selector too. Until then, omitting `CloudflareAIGateway.model(...)` makes missing | |
| account/gateway configuration unrepresentable. | |
| opencode's dynamic runtime should construct executable models at its app | |
| boundary instead of exposing a giant unstructured public model constructor or a | |
| generic dynamic resolver: | |
| ```ts | |
| const model = | |
| providerID === "azure" | |
| ? Azure.configure(resolvedAzureConfig).responses(apiModelID) | |
| : endpoint.websocket | |
| ? OpenAI.responsesWebSocket(apiModelID) | |
| : OpenAI.responses(apiModelID) | |
| ``` | |
| That boundary can branch on durable config/catalog metadata and call typed | |
| provider APIs directly. Transport selection belongs there too: map metadata like | |
| `endpoint.websocket` to `OpenAI.responsesWebSocket(apiModelID)`; otherwise use | |
| the normal `OpenAI.responses(apiModelID)` route. The client runtime only executes | |
| the route carried by the model. | |
| ## Competitive Shape | |
| This follows the strongest parts of adjacent libraries: | |
| - AI SDK: configured provider instances expose provider-specific model methods. | |
| - Effect AI: executable models carry provider requirements and can be resolved by | |
| an app boundary. | |
| - LiteLLM/opencode config: dynamic `providerID/modelID` branching belongs at the | |
| app boundary, not in the typed public provider API or a global runtime | |
| resolver. | |
| - LangChain/LlamaIndex: constructor-style config plus model id is convenient, | |
| but we avoid making model selection also configure endpoint/auth. | |
| The chosen split is: | |
| ```txt | |
| Route = execution mechanics | |
| Provider facade = configured route group | |
| Model = selected executable model carrying route value | |
| App boundary = explicit durable-config -> typed-provider call | |
| ``` | |
| ## What This Removes | |
| - No `Provider.make(...)` as a core abstraction. | |
| - No `Provider.make(...)` wrapper just to bind an id to model functions. Use a | |
| branded provider id constant and a plain exported provider facade. | |
| - No `Deployment.define(...)` unless future examples force it. | |
| - No global route registry as the normal execution path. | |
| - No import side effects required before a model can execute. | |
| - No duplicate `provider.id` object when selected models already carry provider | |
| id. | |
| - No `model(id, overrides)` escape hatch. Model selection takes the model id; | |
| endpoint/auth/deployment customization happens by configuring the route first. | |
| - No transport override on model/request. HTTP SSE versus WebSocket is a named | |
| route selector such as `responses` versus `responsesWebSocket`. | |
| - No separate public `LLMClient.layerWithWebSocket`. The runtime should expose one | |
| client layer with the available transport capabilities. | |
| - No executable `ModelRef`. The executable handle is `Model`; durable model | |
| identity stays separate and cannot execute on its own. | |
| ## Implementation Todo | |
| - [x] Replace the current executable `ModelRef` with `Model`. | |
| - [x] Change `Model.route` to carry a route value, not a `RouteID` string. | |
| - [ ] Keep a separate durable model identity type for persisted/session/catalog | |
| data, likely `{ providerID, modelID }`, and make it clear that it cannot | |
| execute without resolver context. | |
| - [x] Change route model selectors so `route.model(id)` returns an executable | |
| model with the route value attached, not a globally registered route id. | |
| - [x] Remove the standalone `Route.model(route, defaults, mapInput)` helper; | |
| configured route instances own model selection. | |
| - [x] Remove endpoint/auth escape hatches from route model selection; callers must | |
| configure endpoint/auth through `route.with(...)` or provider facades before | |
| calling `.model(...)`. | |
| - [x] Remove request-shaping defaults from `Model`; selected models now carry only | |
| id, provider, and configured route while defaults live on routes or requests. | |
| - [x] Rework `LLMClient.prepare` / `stream` / `generate` to read | |
| `request.model.route` directly instead of calling `registeredRoute(...)`. | |
| - [x] Remove `Route.make(...)` global registration from the normal execution | |
| path; keep route ids only as diagnostics/provider API labels. | |
| - [x] Model endpoint as `{ baseURL, path, query }` on routes, then remove the | |
| current split where host/query live on the model and path lives in route | |
| transport setup. | |
| - [x] Define `Route.with(...)` with explicit patch semantics for endpoint merge, | |
| query merge, header merge, auth replacement, and optional diagnostic id. | |
| - [x] Make unconfigured transports reusable constants such as | |
| `HttpTransport.sseJson`; keep transport functions only for configured/fresh | |
| state construction. | |
| - [x] Collapse the public WebSocket runtime split so one `LLMClient.layer` | |
| exposes available transport capabilities and selected routes fail with typed | |
| transport config errors when a required capability is missing. | |
| - [x] Convert OpenAI provider APIs to provider-facade shape: | |
| `OpenAI.configure(config).responses(id)`, `.chat(id)`, and | |
| `.responsesWebSocket(id)`. | |
| - [x] Convert Azure to a configured facade where resource/base URL/api version | |
| setup happens before selecting deployment ids. | |
| - [x] Split Cloudflare products into separate facades such as | |
| `CloudflareAIGateway` and `CloudflareWorkersAI`; do not expose a shared root | |
| config surface unless one product actually exists. | |
| - [x] Migrate remaining built-in provider facades one at a time so configuration | |
| happens before model selection and selectors accept only ids: | |
| xAI, GitHub Copilot, OpenRouter, OpenAI-compatible families, Anthropic, | |
| Google/Gemini, and Amazon Bedrock now use configured facades such as | |
| `Provider.configure(options).model(id)` with named selectors where needed. | |
| - [ ] Decide whether a tiny `Provider.define(...)` helper is warranted after two | |
| or three provider conversions; start with plain objects if duplication is not | |
| yet painful. | |
| - [x] Update `packages/opencode/src/session/llm/native-request.ts` to construct | |
| executable models at the session boundary with explicit provider facade | |
| calls, mapping catalog metadata such as `endpoint.websocket` to the correct | |
| named route selector. | |
| - [ ] Update tests so direct route/provider tests assert route values are carried | |
| by executable models, and opencode/native tests assert boundary-based route | |
| selection. | |
| - [ ] Remove compatibility exports or stale docs only after internal call sites | |
| are migrated; do not keep duplicate constructor paths without an external | |
| compatibility need. | |
| ## Open Questions | |
| - Default facades with required setup: should providers like Azure and Bedrock | |
| expose default model selectors only when all required setup has lazy env or | |
| credential-chain defaults? If not, omit the default selector so missing config | |
| is impossible at the type/API level. | |
| - Lazy endpoint/auth values: should `Endpoint.envBaseURL(...)` and env-backed | |
| auth produce typed configuration/authentication errors at compile/prepare time | |
| or only when executing the transport? | |
| - `Route.with(...)` clearing semantics: endpoint/query/header patches merge by | |
| default, but what is the explicit way to remove an inherited value? | |
| - Provider facade helper: keep plain objects until duplication hurts, or add a | |
| tiny `Provider.define(...)` immediately to enforce shape and method projection? | |
| - Auth shape: should auth stay as today's composable `Auth`, or split into an | |
| auth placement/strategy and credential sources? | |
| - Naming: is `baseURL` still the right endpoint field name, or should it be | |
| `origin` / `urlPrefix` to clarify that route `path` is appended? | |
Xet Storage Details
- Size:
- 21.7 kB
- Xet hash:
- fe8fc56ad9fd1b8093a7ae4315f18f3936c88a425e6862316d6e9d02f78036b2
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.