packages/llm/example/call-sites.md
Scratchpad for examples first, abstractions second. Current direction: routes execute, provider facades organize configured route sets, and models carry route values directly.
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: "auto", manual cache breakpoints, provider cache usage.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:
{ providerID, modelID } for
config, sessions, logs, and catalogs.Model with a route value, protocol, transport, auth,
and defaults. It is allowed to contain functions and schemas.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:
Examples:
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:
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..configure(...)Provider.make(...) wrapper unless it gains runtime behaviorThe provider abstraction is a facade over configured routes, not the runtime execution mechanism:
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:
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:
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:
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.
Define concrete routes for a native provider, then project them through a provider facade:
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:
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:
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:
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:
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:
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:
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:
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.
This follows the strongest parts of adjacent libraries:
providerID/modelID branching belongs at the
app boundary, not in the typed public provider API or a global runtime
resolver.The chosen split is:
Route = execution mechanics
Provider facade = configured route group
Model = selected executable model carrying route value
App boundary = explicit durable-config -> typed-provider call
Provider.make(...) as a core abstraction.Provider.make(...) wrapper just to bind an id to model functions. Use a
branded provider id constant and a plain exported provider facade.Deployment.define(...) unless future examples force it.provider.id object when selected models already carry provider
id.model(id, overrides) escape hatch. Model selection takes the model id;
endpoint/auth/deployment customization happens by configuring the route first.responses versus responsesWebSocket.LLMClient.layerWithWebSocket. The runtime should expose one
client layer with the available transport capabilities.ModelRef. The executable handle is Model; durable model
identity stays separate and cannot execute on its own.ModelRef with Model.Model.route to carry a route value, not a RouteID string.{ providerID, modelID }, and make it clear that it cannot
execute without resolver context.route.model(id) returns an executable
model with the route value attached, not a globally registered route id.Route.model(route, defaults, mapInput) helper;
configured route instances own model selection.route.with(...) or provider facades before
calling .model(...).Model; selected models now carry only
id, provider, and configured route while defaults live on routes or requests.LLMClient.prepare / stream / generate to read
request.model.route directly instead of calling registeredRoute(...).Route.make(...) global registration from the normal execution
path; keep route ids only as diagnostics/provider API labels.{ baseURL, path, query } on routes, then remove the
current split where host/query live on the model and path lives in route
transport setup.Route.with(...) with explicit patch semantics for endpoint merge,
query merge, header merge, auth replacement, and optional diagnostic id.HttpTransport.sseJson; keep transport functions only for configured/fresh
state construction.LLMClient.layer
exposes available transport capabilities and selected routes fail with typed
transport config errors when a required capability is missing.OpenAI.configure(config).responses(id), .chat(id), and
.responsesWebSocket(id).CloudflareAIGateway and CloudflareWorkersAI; do not expose a shared root
config surface unless one product actually exists.Provider.configure(options).model(id) with named selectors where needed.Provider.define(...) helper is warranted after two
or three provider conversions; start with plain objects if duplication is not
yet painful.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.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.define(...) immediately to enforce shape and method projection?Auth, or split into an
auth placement/strategy and credential sources?baseURL still the right endpoint field name, or should it be
origin / urlPrefix to clarify that route path is appended?