refactor(llm): collapse Endpoint to path-only; require ModelRef.baseURL

Push URL host knowledge from `Endpoint` (route layer) up to `model.baseURL`
(provider helper layer). The route just composes a path onto whatever
host the model already carries.

Endpoint:
- `Endpoint<Body>` shrinks to `{ path }`. No more `default`, `baseURL`,
  or `required` fallback fields.
- `Endpoint.path(value)` replaces the old `Endpoint.baseURL({...})` factory.
- `Endpoint.render()` is now sync — `${model.baseURL}${path}` plus query
  params. No fallback chain, no Effect wrapper.

ModelRef:
- `ModelRef.baseURL: Schema.String` (was optional). Every materialized
  model carries a host.
- `RouteModelInput.baseURL?: string` stays optional so route defaults
  can supply a canonical URL; routes without a default tighten it.

Provider helpers:
- Each protocol exports a `DEFAULT_BASE_URL` constant and bakes it into
  `route.defaults.baseURL`. Provider helpers don't need to set baseURL.
- Azure uses a new `AtLeastOne<T>` helper from `auth-options.ts` to
  require either `resourceName` or `baseURL` at the type level.
- Bedrock provider computes baseURL from `region` at construction time.
- OpenAI-compatible profiles now have required (not optional) `baseURL`
  in their type — all 9 already supplied one.
- The `defaultBaseURL: string | false` knob on protocol endpoint
  factories is gone.

Effects:
- Forgetting baseURL is now caught at compile time (TS) or model
  construction time (modelWithDefaults runtime check), not request time.
- `Endpoint.render` no longer needs Effect wrapping in the transport
  hot path.
This commit is contained in:
Kit Langton
2026-05-07 12:03:28 -04:00
parent 32ad9cbc8c
commit c5e20033ec
30 changed files with 167 additions and 196 deletions
+2 -5
View File
@@ -157,17 +157,14 @@ const FakeProtocol = Protocol.make<FakeBody, string, string, void>({
const FakeAdapter = Route.make({
id: "fake-echo",
protocol: FakeProtocol,
endpoint: Endpoint.baseURL({
default: "https://fake.local",
path: "/v1/echo",
}),
endpoint: Endpoint.path("/v1/echo"),
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" })
const fakeEchoModel = Route.model(FakeAdapter, { provider: "fake-echo", baseURL: "https://fake.local" })
const FakeEcho = Provider.make({
id: ProviderID.make("fake-echo"),
model: (id: string, options: ProviderModelOptions = {}) => fakeEchoModel({ id, ...options }),
@@ -20,6 +20,8 @@ import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./share
import { ToolStream } from "./utils/tool-stream"
const ADAPTER = "anthropic-messages"
export const DEFAULT_BASE_URL = "https://api.anthropic.com/v1"
export const PATH = "/messages"
// =============================================================================
// Request Body Schema
@@ -574,7 +576,7 @@ export const protocol = Protocol.make({
export const route = Route.make({
id: ADAPTER,
protocol,
endpoint: Endpoint.baseURL({ default: "https://api.anthropic.com/v1", path: "/messages" }),
endpoint: Endpoint.path(PATH),
auth: Auth.apiKeyHeader("x-api-key"),
framing: Framing.sse,
headers: () => ({ "anthropic-version": "2023-06-01" }),
@@ -585,6 +587,7 @@ export const route = Route.make({
// =============================================================================
export const model = Route.model(route, {
provider: "anthropic",
baseURL: DEFAULT_BASE_URL,
capabilities: capabilities({
output: { reasoning: true },
tools: { calls: true, streamingInput: true },
@@ -498,13 +498,11 @@ export const protocol = Protocol.make({
export const route = Route.make({
id: ADAPTER,
protocol,
endpoint: Endpoint.baseURL<BedrockConverseBody>({
// Bedrock's URL embeds the region in the host and the validated modelId
// in the path. We reach into the validated body so the URL
// matches the body that gets signed.
default: ({ request }) => `https://bedrock-runtime.${BedrockAuth.region(request)}.amazonaws.com`,
path: ({ body }) => `/model/${encodeURIComponent(body.modelId)}/converse-stream`,
}),
// Bedrock's URL embeds the region in the host (set on `model.baseURL` by
// the provider helper from credentials) and the validated modelId in the
// path. We read the validated body so the URL matches the body that gets
// signed.
endpoint: Endpoint.path<BedrockConverseBody>(({ body }) => `/model/${encodeURIComponent(body.modelId)}/converse-stream`),
auth: BedrockAuth.auth,
framing,
})
@@ -529,8 +527,10 @@ const bedrockModel = Route.model<BedrockConverseModelInput>(
{
mapInput: (input) => {
const { credentials, ...rest } = input
const region = credentials?.region ?? "us-east-1"
return {
...rest,
baseURL: rest.baseURL ?? `https://bedrock-runtime.${region}.amazonaws.com`,
native: nativeCredentials(input.native, credentials),
}
},
+4 -5
View File
@@ -19,6 +19,7 @@ import { JsonObject, optionalArray, ProviderShared } from "./shared"
import { GeminiToolSchema } from "./utils/gemini-tool-schema"
const ADAPTER = "gemini"
export const DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
// =============================================================================
// Request Body Schema
@@ -380,11 +381,8 @@ export const protocol = Protocol.make({
export const route = Route.make({
id: ADAPTER,
protocol,
endpoint: Endpoint.baseURL({
default: "https://generativelanguage.googleapis.com/v1beta",
// Gemini's path embeds the model id and pins SSE framing at the URL level.
path: ({ request }) => `/models/${request.model.id}:streamGenerateContent?alt=sse`,
}),
// Gemini's path embeds the model id and pins SSE framing at the URL level.
endpoint: Endpoint.path(({ request }) => `/models/${request.model.id}:streamGenerateContent?alt=sse`),
auth: Auth.apiKeyHeader("x-goog-api-key"),
framing: Framing.sse,
})
@@ -394,6 +392,7 @@ export const route = Route.make({
// =============================================================================
export const model = Route.model(route, {
provider: "google",
baseURL: DEFAULT_BASE_URL,
capabilities: capabilities({
input: { image: true, audio: true, video: true, pdf: true },
output: { reasoning: true },
+4 -15
View File
@@ -20,8 +20,8 @@ import { OpenAIOptions } from "./utils/openai-options"
import { ToolStream } from "./utils/tool-stream"
const ADAPTER = "openai-chat"
const DEFAULT_BASE_URL = "https://api.openai.com/v1"
const PATH = "/chat/completions"
export const DEFAULT_BASE_URL = "https://api.openai.com/v1"
export const PATH = "/chat/completions"
// =============================================================================
// Request Body Schema
@@ -378,22 +378,10 @@ export const protocol = Protocol.make({
},
})
export const endpoint = (
input: {
readonly defaultBaseURL?: string | false
readonly required?: string
} = {},
) =>
Endpoint.baseURL<OpenAIChatBody>({
default: input.defaultBaseURL === false ? undefined : (input.defaultBaseURL ?? DEFAULT_BASE_URL),
path: PATH,
required: input.required,
})
const encodeBody = Schema.encodeSync(Schema.fromJsonString(OpenAIChatBody))
export const httpTransport = HttpTransport.httpJson({
endpoint: endpoint(),
endpoint: Endpoint.path(PATH),
auth: Auth.bearer(),
framing: Framing.sse,
encodeBody,
@@ -405,6 +393,7 @@ export const route = Route.make({
protocol,
transport: httpTransport,
defaults: {
baseURL: DEFAULT_BASE_URL,
capabilities: capabilities({ tools: { calls: true, streamingInput: true } }),
},
})
@@ -13,19 +13,14 @@ export type OpenAICompatibleChatModelInput = Omit<RouteRoutedModelInput, "baseUR
/**
* Route for non-OpenAI providers that expose an OpenAI Chat-compatible
* `/chat/completions` endpoint. Reuses `OpenAIChat.protocol` end-to-end and
* only overrides:
*
* - the route id (`openai-compatible-chat`) so providers can be resolved
* per-family without colliding with native OpenAI;
* - the endpoint, which requires `model.baseURL` (no provider default).
* overrides only the route id so providers can be resolved per-family without
* colliding with native OpenAI. The model carries the host on `baseURL`,
* supplied by whichever profile/provider helper builds it.
*/
export const route = Route.make({
id: ADAPTER,
protocol: OpenAIChat.protocol,
endpoint: Endpoint.baseURL({
path: "/chat/completions",
required: "OpenAI-compatible Chat requires a baseURL",
}),
endpoint: Endpoint.path("/chat/completions"),
framing: Framing.sse,
})
+4 -15
View File
@@ -21,8 +21,8 @@ import { OpenAIOptions } from "./utils/openai-options"
import { ToolStream } from "./utils/tool-stream"
const ADAPTER = "openai-responses"
const DEFAULT_BASE_URL = "https://api.openai.com/v1"
const PATH = "/responses"
export const DEFAULT_BASE_URL = "https://api.openai.com/v1"
export const PATH = "/responses"
// =============================================================================
// Request Body Schema
@@ -501,25 +501,14 @@ export const protocol = Protocol.make({
},
})
export const endpoint = (
input: {
readonly defaultBaseURL?: string | false
readonly required?: string
} = {},
) =>
Endpoint.baseURL<OpenAIResponsesBody>({
default: input.defaultBaseURL === false ? undefined : (input.defaultBaseURL ?? DEFAULT_BASE_URL),
path: PATH,
required: input.required,
})
const encodeBody = Schema.encodeSync(Schema.fromJsonString(OpenAIResponsesBody))
const transportBase = {
endpoint: endpoint(),
endpoint: Endpoint.path<OpenAIResponsesBody>(PATH),
auth: Auth.bearer(),
encodeBody,
}
const routeDefaults = {
baseURL: DEFAULT_BASE_URL,
capabilities: capabilities({ tools: { calls: true, streamingInput: true } }),
}
+10 -2
View File
@@ -6,15 +6,21 @@ import type { BedrockCredentials } from "../protocols/bedrock-converse"
export const id = ProviderID.make("amazon-bedrock")
export type ModelOptions = Omit<RouteModelInput, "id"> & {
export type ModelOptions = Omit<RouteModelInput, "id" | "baseURL"> & {
readonly apiKey?: string
readonly headers?: Record<string, string>
readonly credentials?: BedrockCredentials
/** AWS region. Defaults to `us-east-1` when neither this nor `credentials.region` is set. */
readonly region?: string
/** Override the computed `https://bedrock-runtime.<region>.amazonaws.com` URL. */
readonly baseURL?: string
}
type ModelInput = ModelOptions & Pick<RouteModelInput, "id">
export const routes = [BedrockConverse.route]
const bedrockBaseURL = (region: string) => `https://bedrock-runtime.${region}.amazonaws.com`
const converseModel = Route.model<ModelInput>(
BedrockConverse.route,
{
@@ -23,9 +29,11 @@ const converseModel = Route.model<ModelInput>(
},
{
mapInput: (input) => {
const { credentials, ...rest } = input
const { credentials, region, baseURL, ...rest } = input
const resolvedRegion = region ?? credentials?.region ?? "us-east-1"
return {
...rest,
baseURL: baseURL ?? bedrockBaseURL(resolvedRegion),
native: BedrockConverse.nativeCredentials(input.native, credentials),
}
},
+1 -1
View File
@@ -7,7 +7,7 @@ export const id = ProviderID.make("anthropic")
export const routes = [AnthropicMessages.route]
export const model = (id: string | ModelID, options: Omit<RouteModelInput, "id"> = {}) =>
export const model = (id: string | ModelID, options: Omit<RouteModelInput, "id" | "baseURL"> & { readonly baseURL?: string } = {}) =>
AnthropicMessages.model({ ...options, id })
export const provider = Provider.make({
+15 -21
View File
@@ -1,5 +1,5 @@
import { Auth } from "../route/auth"
import type { ProviderAuthOption } from "../route/auth-options"
import { type AtLeastOne, type ProviderAuthOption } from "../route/auth-options"
import { Route } from "../route/client"
import type { ModelInput } from "../llm"
import { Provider } from "../provider"
@@ -9,40 +9,33 @@ import * as OpenAIResponses from "../protocols/openai-responses"
import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options"
export const id = ProviderID.make("azure")
const MISSING_BASE_URL = "Azure OpenAI requires resourceName or baseURL"
const routeAuth = Auth.remove("authorization").andThen(Auth.apiKeyHeader("api-key"))
export type ModelOptions = Omit<ModelInput, "id" | "provider" | "route" | "apiKey" | "auth"> &
// Azure needs the customer's resource URL; supply either `resourceName`
// (helper builds the URL) or `baseURL` directly.
type AzureURL = AtLeastOne<{ readonly resourceName: string; readonly baseURL: string }>
export type ModelOptions = AzureURL &
Omit<ModelInput, "id" | "provider" | "route" | "apiKey" | "auth" | "baseURL"> &
ProviderAuthOption<"optional"> & {
readonly resourceName?: string
readonly apiVersion?: string
readonly useCompletionUrls?: boolean
readonly providerOptions?: OpenAIProviderOptionsInput
}
type AzureModelInput = ModelOptions & Pick<ModelInput, "id">
const resourceBaseURL = (resourceName: string | undefined) => {
const resource = resourceName?.trim()
if (!resource) return undefined
return `https://${resource}.openai.azure.com/openai/v1`
}
const resourceBaseURL = (resourceName: string) => `https://${resourceName.trim()}.openai.azure.com/openai/v1`
const responsesRoute = OpenAIResponses.route.with({
id: "azure-openai-responses",
provider: id,
transport: OpenAIResponses.httpTransport.with({
auth: routeAuth,
endpoint: OpenAIResponses.endpoint({ defaultBaseURL: false, required: MISSING_BASE_URL }),
}),
transport: OpenAIResponses.httpTransport.with({ auth: routeAuth }),
})
const chatRoute = OpenAIChat.route.with({
id: "azure-openai-chat",
provider: id,
transport: OpenAIChat.httpTransport.with({
auth: routeAuth,
endpoint: OpenAIChat.endpoint({ defaultBaseURL: false, required: MISSING_BASE_URL }),
}),
transport: OpenAIChat.httpTransport.with({ auth: routeAuth }),
})
export const routes = [responsesRoute, chatRoute]
@@ -59,7 +52,8 @@ const mapInput = (input: AzureModelInput) => {
.orElse(Auth.config("AZURE_OPENAI_API_KEY"))
.pipe(Auth.header("api-key")),
),
baseURL: rest.baseURL ?? resourceBaseURL(resourceName),
// AtLeastOne guarantees at least one is set; baseURL wins if both are.
baseURL: rest.baseURL ?? resourceBaseURL(resourceName!),
queryParams: {
...rest.queryParams,
"api-version": apiVersion ?? rest.queryParams?.["api-version"] ?? "v1",
@@ -70,12 +64,12 @@ const mapInput = (input: AzureModelInput) => {
const chatModel = Route.model<AzureModelInput>(chatRoute, {}, { mapInput })
const responsesModel = Route.model<AzureModelInput>(responsesRoute, {}, { mapInput })
export const responses = (modelID: string | ModelID, options: ModelOptions = {}) =>
export const responses = (modelID: string | ModelID, options: ModelOptions) =>
responsesModel({ ...options, id: modelID })
export const chat = (modelID: string | ModelID, options: ModelOptions = {}) => chatModel({ ...options, id: modelID })
export const chat = (modelID: string | ModelID, options: ModelOptions) => chatModel({ ...options, id: modelID })
export const model = (modelID: string | ModelID, options: ModelOptions = {}) => {
export const model = (modelID: string | ModelID, options: ModelOptions) => {
if (options.useCompletionUrls === true) return chat(modelID, options)
return responses(modelID, options)
}
+5 -3
View File
@@ -8,6 +8,8 @@ import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-opt
export const id = ProviderID.make("github-copilot")
// GitHub Copilot has no canonical public URL — callers (opencode, etc.) must
// supply `baseURL` explicitly.
export type ModelOptions = Omit<ModelInput, "id" | "provider" | "route"> & {
readonly providerOptions?: OpenAIProviderOptionsInput
}
@@ -27,12 +29,12 @@ const mapInput = (input: CopilotModelInput) => withOpenAIOptions(input.id, input
const chatModel = Route.model<CopilotModelInput>(OpenAIChat.route, { provider: id }, { mapInput })
const responsesModel = Route.model<CopilotModelInput>(OpenAIResponses.route, { provider: id }, { mapInput })
export const responses = (modelID: string | ModelID, options: ModelOptions = {}) =>
export const responses = (modelID: string | ModelID, options: ModelOptions) =>
responsesModel({ ...options, id: modelID })
export const chat = (modelID: string | ModelID, options: ModelOptions = {}) => chatModel({ ...options, id: modelID })
export const chat = (modelID: string | ModelID, options: ModelOptions) => chatModel({ ...options, id: modelID })
export const model = (modelID: string | ModelID, options: ModelOptions = {}) => {
export const model = (modelID: string | ModelID, options: ModelOptions) => {
const create = shouldUseResponsesApi(modelID) ? responsesModel : chatModel
return create({ ...options, id: modelID })
}
+1 -1
View File
@@ -7,7 +7,7 @@ export const id = ProviderID.make("google")
export const routes = [Gemini.route]
export const model = (id: string | ModelID, options: Omit<RouteModelInput, "id"> = {}) =>
export const model = (id: string | ModelID, options: Omit<RouteModelInput, "id" | "baseURL"> & { readonly baseURL?: string } = {}) =>
Gemini.model({ ...options, id })
export const provider = Provider.make({
@@ -2,7 +2,7 @@ import type { CapabilitiesInput } from "../llm"
export interface OpenAICompatibleProfile {
readonly provider: string
readonly baseURL?: string
readonly baseURL: string
readonly capabilities?: CapabilitiesInput
}
@@ -28,12 +28,6 @@ export const model = (id: string | ModelID, options: ModelOptions) => {
})
}
const profileBaseURL = (profile: OpenAICompatibleProfile, options: FamilyModelOptions) => {
const baseURL = options.baseURL ?? profile.baseURL
if (baseURL) return baseURL
throw new Error(`OpenAI-compatible profile ${profile.provider} requires a baseURL`)
}
export const profileModel = (
profile: OpenAICompatibleProfile,
id: string | ModelID,
@@ -43,7 +37,7 @@ export const profileModel = (
...options,
id,
provider: profile.provider,
baseURL: profileBaseURL(profile, options),
baseURL: options.baseURL ?? profile.baseURL,
capabilities: options.capabilities ?? profile.capabilities,
})
+2 -1
View File
@@ -15,8 +15,9 @@ export const routes = [OpenAIResponses.route, OpenAIResponses.webSocketRoute, Op
// This provider facade wraps the lower-level Responses and Chat model factories
// with OpenAI-specific conveniences: typed options, API-key sugar, env fallback,
// and default option normalization.
type OpenAIModelInput<ModelInput> = Omit<ModelInput, "apiKey" | "auth"> &
type OpenAIModelInput<ModelInput> = Omit<ModelInput, "apiKey" | "auth" | "baseURL"> &
ProviderAuthOption<"optional"> & {
readonly baseURL?: string
readonly providerOptions?: OpenAIProviderOptionsInput
}
+3 -2
View File
@@ -25,7 +25,8 @@ export type OpenRouterProviderOptionsInput = ProviderOptions & {
readonly openrouter?: OpenRouterOptions
}
export type ModelOptions = Omit<RouteModelInput, "id" | "providerOptions"> & {
export type ModelOptions = Omit<RouteModelInput, "id" | "baseURL" | "providerOptions"> & {
readonly baseURL?: string
readonly providerOptions?: OpenRouterProviderOptionsInput
}
type ModelInput = ModelOptions & Pick<RouteModelInput, "id">
@@ -69,7 +70,7 @@ const bodyOptions = (input: unknown) => {
export const route = Route.make({
id: ADAPTER,
protocol,
endpoint: Endpoint.baseURL({ default: profile.baseURL, path: "/chat/completions" }),
endpoint: Endpoint.path("/chat/completions"),
framing: Framing.sse,
})
+4 -1
View File
@@ -9,7 +9,10 @@ import * as OpenAIResponses from "../protocols/openai-responses"
export const id = ProviderID.make("xai")
export type ModelOptions = Omit<RouteModelInput, "id" | "apiKey" | "auth"> & ProviderAuthOption<"optional">
export type ModelOptions = Omit<RouteModelInput, "id" | "apiKey" | "auth" | "baseURL"> &
ProviderAuthOption<"optional"> & {
readonly baseURL?: string
}
export const routes = [OpenAIResponses.route, OpenAICompatibleChat.route]
+9
View File
@@ -30,6 +30,15 @@ export type ModelArgs<Base, Mode extends ApiKeyMode> = Mode extends "optional"
export type ModelFactory<Base, Mode extends ApiKeyMode, Model> = (id: string, ...args: ModelArgs<Base, Mode>) => Model
/**
* Require at least one of the keys in `T`. Use for option shapes where any
* subset of fields is acceptable but at least one must be present (e.g. Azure
* accepts `resourceName` or `baseURL`).
*/
export type AtLeastOne<T> = {
[K in keyof T]: Required<Pick<T, K>> & Partial<Omit<T, K>>
}[keyof T]
/**
* Standard bearer-auth resolution for providers: honor an explicit `auth`
* override, otherwise resolve `apiKey` (option > config var) and apply it as
+20 -3
View File
@@ -94,11 +94,22 @@ export type ModelRefInput = Omit<
readonly http?: HttpOptionsInput
}
export type RouteModelInput = Omit<ModelRefInput, "provider" | "route">
// `baseURL` is required on `ModelRefInput` (every materialized `ModelRef` has
// a host) but optional at the route-input layers below. The route's `defaults`
// can supply a canonical URL (e.g. OpenAI/Anthropic) so the user's input may
// omit it. Routes without a canonical URL (OpenAI-compatible, GitHub Copilot)
// re-tighten this in their own input type.
export type RouteModelInput = Omit<ModelRefInput, "provider" | "route" | "baseURL"> & {
readonly baseURL?: string
}
export type RouteModelDefaults = Omit<ModelRefInput, "id" | "route">
export type RouteModelDefaults = Omit<ModelRefInput, "id" | "route" | "baseURL"> & {
readonly baseURL?: string
}
export type RouteRoutedModelInput = Omit<ModelRefInput, "route">
export type RouteRoutedModelInput = Omit<ModelRefInput, "route" | "baseURL"> & {
readonly baseURL?: string
}
export type RouteRoutedModelDefaults = Partial<Omit<ModelRefInput, "id" | "provider" | "route">>
@@ -133,6 +144,11 @@ const modelWithDefaults =
const mapped = options.mapInput === undefined ? (input as RouteMappedModelInput) : options.mapInput(input)
const provider = defaults.provider ?? route.provider ?? ("provider" in mapped ? mapped.provider : undefined)
if (!provider) throw new Error(`Route.model(${route.id}) requires a provider`)
const baseURL = mapped.baseURL ?? defaults.baseURL ?? route.defaults.baseURL
if (!baseURL)
throw new Error(
`Route.model(${route.id}) requires a baseURL — supply it via input, defaults, or route defaults`,
)
const generation = mergeGenerationOptions(route.defaults.generation, defaults.generation)
const providerOptions = mergeProviderOptions(route.defaults.providerOptions, defaults.providerOptions)
const http = mergeHttpOptions(httpOptions(route.defaults.http), httpOptions(defaults.http))
@@ -140,6 +156,7 @@ const modelWithDefaults =
...route.defaults,
...defaults,
...mapped,
baseURL,
provider,
route: route.id,
capabilities: mapped.capabilities ?? defaults.capabilities ?? route.defaults.capabilities,
+19 -37
View File
@@ -1,6 +1,5 @@
import { Effect } from "effect"
import type { LLMRequest } from "../schema"
import * as ProviderShared from "../protocols/shared"
import type { LLMError, LLMRequest } from "../schema"
export interface EndpointInput<Body> {
readonly request: LLMRequest
@@ -12,48 +11,31 @@ export type EndpointPart<Body> = string | ((input: EndpointInput<Body>) => strin
/**
* Declarative URL construction for one route.
*
* `Endpoint` is the deployment-side answer to "where does this request go?".
* `render(...)` interprets this data after protocol body construction, so
* dynamic pieces can read the final `LLMRequest` and validated provider body.
* `Endpoint` carries only the path. The host always lives on `model.baseURL`,
* supplied by the provider helper that constructs the model. `render(...)`
* just appends the path (and any `model.queryParams`) to that host.
*
* `path` may be a string or a function of `EndpointInput`, for routes whose
* URL embeds the model id, region, or another body field (e.g. Bedrock,
* Gemini).
*/
export interface Endpoint<Body> {
readonly baseURL?: EndpointPart<Body>
readonly path: EndpointPart<Body>
/** Error message used when neither `model.baseURL` nor `baseURL` is set. */
readonly required?: string
}
/**
* Build a URL from the model's `baseURL` (or a default) plus a path. Appends
* `model.queryParams` so routes that need request-level query params
* (Azure `api-version`, etc.) get them for free.
*
* Both `default` and `path` may be strings or functions of the
* `EndpointInput`, for routes whose URL embeds the model id, region, or
* another body field.
*/
export const baseURL = <Body>(input: {
readonly default?: string | ((input: EndpointInput<Body>) => string)
readonly path: string | ((input: EndpointInput<Body>) => string)
readonly required?: string
}): Endpoint<Body> => ({
baseURL: input.default,
path: input.path,
required: input.required,
})
/** Construct an `Endpoint` from a path string or path function. */
export const path = <Body>(value: EndpointPart<Body>): Endpoint<Body> => ({ path: value })
const renderPart = <Body>(part: EndpointPart<Body> | undefined, input: EndpointInput<Body>) =>
const renderPart = <Body>(part: EndpointPart<Body>, input: EndpointInput<Body>) =>
typeof part === "function" ? part(input) : part
export const render = <Body>(endpoint: Endpoint<Body>, input: EndpointInput<Body>) =>
Effect.gen(function* () {
const base = input.request.model.baseURL ?? renderPart(endpoint.baseURL, input)
if (!base) return yield* ProviderShared.invalidRequest(endpoint.required ?? "Missing baseURL")
const path = renderPart(endpoint.path, input)
const url = new URL(`${ProviderShared.trimBaseUrl(base)}${path}`)
const params = input.request.model.queryParams
if (params) for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value)
return url
})
export const render = <Body>(endpoint: Endpoint<Body>, input: EndpointInput<Body>) => {
const url = new URL(
`${ProviderShared.trimBaseUrl(input.request.model.baseURL)}${renderPart(endpoint.path, input)}`,
)
const params = input.request.model.queryParams
if (params) for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value)
return url
}
export * as Endpoint from "./endpoint"
+1 -1
View File
@@ -48,7 +48,7 @@ const bodyWithOverlay = <Body>(body: Body, request: LLMRequest, encodeBody: (bod
export const jsonRequestParts = <Body>(input: JsonRequestInput<Body>) =>
Effect.gen(function* () {
const url = applyQuery(
(yield* renderEndpoint(input.endpoint, { request: input.request, body: input.body })).toString(),
renderEndpoint(input.endpoint, { request: input.request, body: input.body }).toString(),
input.request.http?.query,
)
const body = yield* bodyWithOverlay(input.body, input.request, input.encodeBody)
+1 -1
View File
@@ -194,7 +194,7 @@ export class ModelRef extends Schema.Class<ModelRef>("LLM.ModelRef")({
id: ModelID,
provider: ProviderID,
route: RouteID,
baseURL: Schema.optional(Schema.String),
baseURL: Schema.String,
/** Provider-specific API key convenience. Provider helpers normalize this into `auth`. */
apiKey: Schema.optional(Schema.String),
/** Optional transport auth policy. Opaque because it may contain functions. */
+5 -4
View File
@@ -44,6 +44,7 @@ const request = LLM.request({
id: "fake-model",
provider: "fake-provider",
route: "fake",
baseURL: "https://fake.local",
}),
prompt: "hello",
})
@@ -78,14 +79,14 @@ const fakeProtocol = Protocol.make<FakeBody, FakeEvent, FakeEvent, void>({
const fake = Route.make({
id: "fake",
protocol: fakeProtocol,
endpoint: Endpoint.baseURL({ default: "https://fake.local", path: "/chat" }),
endpoint: Endpoint.path("/chat"),
framing: fakeFraming,
})
const gemini = Route.make({
id: "gemini-fake",
protocol: fakeProtocol,
endpoint: Endpoint.baseURL({ default: "https://fake.local", path: "/chat" }),
endpoint: Endpoint.path("/chat"),
framing: fakeFraming,
})
@@ -129,7 +130,7 @@ describe("llm route", () => {
Effect.gen(function* () {
const mapped = Route.model<RouteModelInput & { readonly region?: string }>(
fake,
{ provider: "fake-provider" },
{ provider: "fake-provider", baseURL: "https://fake.local" },
{
mapInput: (input) => {
const { region, ...rest } = input
@@ -154,7 +155,7 @@ describe("llm route", () => {
from: () => Effect.succeed({ body: "late-default" }),
},
}),
endpoint: Endpoint.baseURL({ default: "https://fake.local", path: "/chat" }),
endpoint: Endpoint.path("/chat"),
framing: fakeFraming,
}),
).toThrow('Duplicate LLM route id "fake"')
+2
View File
@@ -81,6 +81,7 @@ OpenAI.chat("gpt-4.1-mini", { auth: RuntimeAuth.bearer("oauth-token") })
// @ts-expect-error auth is an override, so OpenAI Chat rejects apiKey with auth.
OpenAI.chat("gpt-4.1-mini", { apiKey: "sk-test", auth: RuntimeAuth.bearer("oauth-token") })
// @ts-expect-error Azure requires at least one of `resourceName` or `baseURL`.
Azure.responses("deployment")
Azure.responses("deployment", { apiKey: "azure-key", resourceName: "resource" })
Azure.responses("deployment", { apiKey: configApiKey, resourceName: "resource" })
@@ -89,6 +90,7 @@ Azure.responses("deployment", { auth: RuntimeAuth.header("api-key", "azure-key")
// @ts-expect-error auth is an override, so Azure rejects apiKey with auth.
Azure.responses("deployment", { apiKey: "azure-key", auth: RuntimeAuth.header("api-key", "override") })
// @ts-expect-error Azure requires at least one of `resourceName` or `baseURL`.
Azure.chat("deployment")
Azure.chat("deployment", { apiKey: "azure-key", resourceName: "resource" })
Azure.chat("deployment", { apiKey: configApiKey, resourceName: "resource" })
+1 -1
View File
@@ -7,7 +7,7 @@ import { it } from "./lib/effect"
const request = LLM.request({
id: "req_auth",
model: LLM.model({ id: "fake-model", provider: "fake", route: "fake" }),
model: LLM.model({ id: "fake-model", provider: "fake", route: "fake", baseURL: "https://fake.local" }),
prompt: "hello",
})
+22 -44
View File
@@ -1,13 +1,12 @@
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMError } from "../src"
import { LLM } from "../src"
import { Endpoint } from "../src/route"
const request = (
input: {
readonly baseURL?: string
readonly baseURL: string
readonly queryParams?: Record<string, string>
} = {},
},
) =>
LLM.request({
model: LLM.model({
@@ -21,59 +20,38 @@ const request = (
})
describe("Endpoint", () => {
test("renders static base URL and path", async () => {
const url = await Effect.runPromise(
Endpoint.render(Endpoint.baseURL({ default: "https://api.example.test/v1/", path: "/chat" }), {
request: request(),
body: {},
}),
)
test("appends a static path to the model's baseURL", () => {
const url = Endpoint.render(Endpoint.path("/chat"), {
request: request({ baseURL: "https://api.example.test/v1/" }),
body: {},
})
expect(url.toString()).toBe("https://api.example.test/v1/chat")
})
test("model baseURL overrides route default and query params are appended", async () => {
const url = await Effect.runPromise(
Endpoint.render(Endpoint.baseURL({ default: "https://api.example.test/v1", path: "/chat?alt=sse" }), {
request: request({
baseURL: "https://custom.example.test/root/",
queryParams: { "api-version": "2026-01-01", alt: "json" },
}),
body: {},
test("model query params are appended to the rendered URL", () => {
const url = Endpoint.render(Endpoint.path("/chat?alt=sse"), {
request: request({
baseURL: "https://custom.example.test/root/",
queryParams: { "api-version": "2026-01-01", alt: "json" },
}),
)
body: {},
})
expect(url.toString()).toBe("https://custom.example.test/root/chat?alt=json&api-version=2026-01-01")
})
test("renders dynamic base URL and final payload path", async () => {
const url = await Effect.runPromise(
Endpoint.render(
Endpoint.baseURL<{ readonly modelId: string }>({
default: () => "https://bedrock-runtime.us-east-1.amazonaws.com",
path: ({ body }) => `/model/${encodeURIComponent(body.modelId)}/converse-stream`,
}),
{
request: request(),
body: { modelId: "us.amazon.nova-micro-v1:0" },
},
),
test("path may be a function of the validated body", () => {
const url = Endpoint.render(
Endpoint.path<{ readonly modelId: string }>(({ body }) => `/model/${encodeURIComponent(body.modelId)}/converse-stream`),
{
request: request({ baseURL: "https://bedrock-runtime.us-east-1.amazonaws.com" }),
body: { modelId: "us.amazon.nova-micro-v1:0" },
},
)
expect(url.toString()).toBe(
"https://bedrock-runtime.us-east-1.amazonaws.com/model/us.amazon.nova-micro-v1%3A0/converse-stream",
)
})
test("fails when no model or route baseURL is available", async () => {
const error = await Effect.runPromise(
Endpoint.render(Endpoint.baseURL({ path: "/chat", required: "test endpoint requires a baseURL" }), {
request: request(),
body: {},
}).pipe(Effect.flip),
)
expect(error).toBeInstanceOf(LLMError)
expect(error.reason).toMatchObject({ _tag: "InvalidRequest", message: "test endpoint requires a baseURL" })
})
})
+6 -5
View File
@@ -6,7 +6,7 @@ describe("llm constructors", () => {
test("builds canonical schema classes from ergonomic input", () => {
const request = LLM.request({
id: "req_1",
model: LLM.model({ id: "fake-model", provider: "fake", route: "openai-chat" }),
model: LLM.model({ id: "fake-model", provider: "fake", route: "openai-chat", baseURL: "https://fake.local" }),
system: "You are concise.",
prompt: "Say hello.",
})
@@ -23,7 +23,7 @@ describe("llm constructors", () => {
test("updates requests without spreading schema class instances", () => {
const base = LLM.request({
id: "req_1",
model: LLM.model({ id: "fake-model", provider: "fake", route: "openai-chat" }),
model: LLM.model({ id: "fake-model", provider: "fake", route: "openai-chat", baseURL: "https://fake.local" }),
prompt: "Say hello.",
})
const updated = LLM.updateRequest(base, {
@@ -44,6 +44,7 @@ describe("llm constructors", () => {
id: "fake-model",
provider: "fake",
route: "openai-chat",
baseURL: "https://fake.local",
generation: { maxTokens: 100, temperature: 1 },
providerOptions: { openai: { store: false, metadata: { model: true } } },
http: { body: { metadata: { model: true } }, headers: { "x-shared": "model" }, query: { model: "1" } },
@@ -66,7 +67,7 @@ describe("llm constructors", () => {
test("updates canonical requests from the request datatype", () => {
const base = LLM.request({
id: "req_1",
model: LLM.model({ id: "fake-model", provider: "fake", route: "openai-chat" }),
model: LLM.model({ id: "fake-model", provider: "fake", route: "openai-chat", baseURL: "https://fake.local" }),
prompt: "Say hello.",
})
const updated = LLMRequest.update(base, { messages: [...base.messages, LLM.assistant("Hi.")] })
@@ -79,7 +80,7 @@ describe("llm constructors", () => {
})
test("updates canonical models from the model datatype", () => {
const base = LLM.model({ id: "fake-model", provider: "fake", route: "openai-chat" })
const base = LLM.model({ id: "fake-model", provider: "fake", route: "openai-chat", baseURL: "https://fake.local" })
const updated = ModelRef.update(base, { route: "openai-responses" })
expect(updated).toBeInstanceOf(ModelRef)
@@ -104,7 +105,7 @@ describe("llm constructors", () => {
expect(LLM.toolChoice("required")).toEqual(new ToolChoice({ type: "required" }))
expect(
LLM.request({
model: LLM.model({ id: "fake-model", provider: "fake", route: "openai-chat" }),
model: LLM.model({ id: "fake-model", provider: "fake", route: "openai-chat", baseURL: "https://fake.local" }),
prompt: "Use tools if needed.",
toolChoice: "required",
}).toolChoice,
+1
View File
@@ -23,6 +23,7 @@ const model = new ModelRef({
id: ModelID.make("fake-model"),
provider: ProviderID.make("fake-provider"),
route: "openai-chat",
baseURL: "https://fake.local",
capabilities,
limits: new ModelLimits({}),
})
+7 -2
View File
@@ -179,17 +179,22 @@ const PROVIDERS: Record<string, ProviderModel> = {
Anthropic.model(String(input.model.api.id), sharedOptions(input, options, { protocol: "anthropic-messages" })),
"@ai-sdk/azure": (input, options) => {
const create = options.useCompletionUrls === true ? Azure.chat : Azure.responses
// Azure requires at least one of `resourceName` or `baseURL`. The user's
// config supplies one of them via opencode's provider settings; if neither
// is set we let Azure's runtime check surface a clear error.
return create(String(input.model.api.id), {
...sharedOptions(input, options, { protocol: azureProtocol(options), providerOptions: openAIOptions(options) }),
resourceName: stringOption(options, "resourceName"),
apiVersion: stringOption(options, "apiVersion"),
})
} as Azure.ModelOptions)
},
"@ai-sdk/baseten": openAICompatibleModel,
"@ai-sdk/cerebras": openAICompatibleModel,
"@ai-sdk/deepinfra": openAICompatibleModel,
"@ai-sdk/fireworks": openAICompatibleModel,
"@ai-sdk/github-copilot": (input, options) =>
// GitHub Copilot has no canonical public URL; the user's opencode config
// is expected to supply `baseURL`. Runtime check kicks in if it's missing.
GitHubCopilot.model(
String(input.model.api.id),
{
@@ -197,7 +202,7 @@ const PROVIDERS: Record<string, ProviderModel> = {
protocol: GitHubCopilot.shouldUseResponsesApi(String(input.model.api.id)) ? "openai-responses" : "openai-chat",
providerOptions: openAIOptions(options),
}),
},
} as GitHubCopilot.ModelOptions,
),
"@ai-sdk/google": (input, options) =>
Google.model(String(input.model.api.id), sharedOptions(input, options, { protocol: "gemini" })),
@@ -7,7 +7,7 @@ const types = (events: ReadonlyArray<{ readonly type: string }>) => events.map((
describe("LLMNativeEvents", () => {
test("synthesizes text and reasoning boundaries around native deltas", () => {
const events = LLMNativeEvents.toSessionEvents([
{ type: "request-start", id: "req_1", model: LLM.model({ id: "gpt-5", provider: "openai", route: "openai-responses" }) },
{ type: "request-start", id: "req_1", model: LLM.model({ id: "gpt-5", provider: "openai", route: "openai-responses", baseURL: "https://api.openai.com/v1" }) },
{ type: "step-start", index: 0 },
{ type: "text-delta", text: "Hello" },
{ type: "text-delta", text: "!" },