Refactor LLM route-first provider API (#28523)
This commit is contained in:
591
packages/llm/example/call-sites.md
Normal file
591
packages/llm/example/call-sites.md
Normal file
@@ -0,0 +1,591 @@
|
||||
# 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?
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Config, Effect, Formatter, Layer, Schema, Stream } from "effect"
|
||||
import { LLM, LLMClient, Provider, ProviderID, Tool, type ProviderModelOptions } from "@opencode-ai/llm"
|
||||
import { Route, Auth, Endpoint, Framing, Protocol, RequestExecutor } from "@opencode-ai/llm/route"
|
||||
import { LLM, LLMClient, ProviderID, Tool } from "@opencode-ai/llm"
|
||||
import { Route, Auth, Endpoint, Framing, Protocol, RequestExecutor, WebSocketExecutor } from "@opencode-ai/llm/route"
|
||||
import { OpenAI } from "@opencode-ai/llm/providers"
|
||||
|
||||
/**
|
||||
@@ -18,18 +18,18 @@ const apiKey = Config.redacted("OPENAI_API_KEY")
|
||||
|
||||
// 1. Pick a model. The provider helper records provider identity, protocol
|
||||
// choice, capabilities, deployment options, authentication, and defaults.
|
||||
const model = OpenAI.model("gpt-4o-mini", {
|
||||
const model = OpenAI.configure({
|
||||
apiKey,
|
||||
generation: { maxTokens: 160 },
|
||||
providerOptions: {
|
||||
openai: { store: false },
|
||||
},
|
||||
})
|
||||
}).model("gpt-4o-mini")
|
||||
|
||||
// 2. Build a provider-neutral request. This is useful when reusing one request
|
||||
// across generate and stream examples.
|
||||
//
|
||||
// Options can live on both the model and the request:
|
||||
// Options can live on both the configured route/provider facade and the request:
|
||||
//
|
||||
// - `generation`: common controls such as max tokens, temperature, topP/topK,
|
||||
// penalties, seed, and stop sequences.
|
||||
@@ -39,7 +39,7 @@ const model = OpenAI.model("gpt-4o-mini", {
|
||||
// - `http`: last-resort serializable overlays for final request body, headers,
|
||||
// and query params. Prefer typed `providerOptions` when a field is stable.
|
||||
//
|
||||
// Model options are defaults. Request options override them for this call.
|
||||
// Route/provider options are defaults. Request options override them for this call.
|
||||
const request = LLM.request({
|
||||
model,
|
||||
system: "You are concise and practical.",
|
||||
@@ -193,19 +193,22 @@ const FakeProtocol = Protocol.make<FakeBody, string, string, void>({
|
||||
// axes that the protocol deliberately does not know: URL, auth, and framing.
|
||||
const FakeAdapter = Route.make({
|
||||
id: "fake-echo",
|
||||
provider: "fake-echo",
|
||||
protocol: FakeProtocol,
|
||||
endpoint: Endpoint.path("/v1/echo"),
|
||||
endpoint: Endpoint.path("/v1/echo", { baseURL: "https://fake.local" }),
|
||||
auth: Auth.passthrough,
|
||||
framing: Framing.sse,
|
||||
})
|
||||
|
||||
// A provider module exports a Provider definition. The default `model` helper
|
||||
// sets provider identity, protocol id, and the route id resolved by the registry.
|
||||
const fakeEchoModel = Route.model(FakeAdapter, { provider: "fake-echo", baseURL: "https://fake.local" })
|
||||
const FakeEcho = Provider.make({
|
||||
// A provider module exports a configured facade. Configuration happens before
|
||||
// model selection; model selectors accept ids only.
|
||||
const FakeEcho = {
|
||||
id: ProviderID.make("fake-echo"),
|
||||
model: (id: string, options: ProviderModelOptions = {}) => fakeEchoModel({ id, ...options }),
|
||||
})
|
||||
configure: () => ({
|
||||
id: ProviderID.make("fake-echo"),
|
||||
model: (id: string) => FakeAdapter.model({ id }),
|
||||
}),
|
||||
}
|
||||
|
||||
// `LLMClient.prepare` is the lower-level inspection hook: it compiles through
|
||||
// body conversion, validation, endpoint, auth, and HTTP construction without
|
||||
@@ -213,7 +216,7 @@ const FakeEcho = Provider.make({
|
||||
const inspectFakeProvider = Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
LLM.request({
|
||||
model: FakeEcho.model("tiny-echo"),
|
||||
model: FakeEcho.configure().model("tiny-echo"),
|
||||
prompt: "Show me the provider pipeline.",
|
||||
}),
|
||||
)
|
||||
@@ -227,7 +230,8 @@ const inspectFakeProvider = Effect.gen(function* () {
|
||||
// enabled at a time so the tutorial can demonstrate generate, prepare, stream,
|
||||
// or tool-loop behavior without spending tokens on every example.
|
||||
const requestExecutorLayer = RequestExecutor.defaultLayer
|
||||
const llmClientLayer = LLMClient.layer.pipe(Layer.provide(requestExecutorLayer))
|
||||
const llmDeps = Layer.mergeAll(requestExecutorLayer, WebSocketExecutor.layer)
|
||||
const llmClientLayer = LLMClient.layer.pipe(Layer.provide(llmDeps))
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
// yield* generateOnce
|
||||
@@ -237,6 +241,6 @@ const program = Effect.gen(function* () {
|
||||
// yield* generateStructuredObject
|
||||
// yield* generateDynamicObject.pipe(Effect.andThen((response) => Effect.sync(() => console.log(response.object))))
|
||||
yield* streamWithTools
|
||||
}).pipe(Effect.provide(Layer.mergeAll(requestExecutorLayer, llmClientLayer)))
|
||||
}).pipe(Effect.provide(Layer.mergeAll(llmDeps, llmClientLayer)))
|
||||
|
||||
Effect.runPromise(program)
|
||||
|
||||
Reference in New Issue
Block a user