refactor(llm): collapse model construction to provider packages and routes

Provider packages are now the only module-level model constructors; the
protocol-level model wrappers added earlier on this branch had one call
site each and duplicated the Settings vocabulary, so packages call
route.with(...).model({id}) directly. Deletes provider facades with no
importers outside llm tests; configure stays for V1 native-request.
This commit is contained in:
Kit Langton 2026-07-03 14:25:29 -04:00
commit 5e5294c036
12 changed files with 117 additions and 221 deletions

View file

@ -1,13 +1,11 @@
import { Effect, Schema } from "effect"
import { Route, type RoutePatch } from "../route/client"
import { Auth, type Auth as AuthDef } from "../route/auth"
import { Route } from "../route/client"
import { Auth } from "../route/auth"
import { Endpoint } from "../route/endpoint"
import { Framing } from "../route/framing"
import { Protocol } from "../route/protocol"
import {
LLMEvent,
type Model,
type ProviderOptions,
Usage,
type CacheHint,
type FinishReason,
@ -854,25 +852,4 @@ export const route = Route.make({
headers: () => ({ "anthropic-version": "2023-06-01" }),
})
export interface ModelConfig {
readonly auth: AuthDef
readonly baseURL?: string
readonly headers?: Readonly<Record<string, string>>
readonly providerOptions?: ProviderOptions
readonly body?: Readonly<Record<string, unknown>>
readonly limits?: { readonly context: number; readonly output: number }
}
export const model = (id: string, config: ModelConfig): Model =>
route
.with({
auth: config.auth,
endpoint: config.baseURL === undefined ? undefined : { baseURL: config.baseURL },
headers: config.headers,
providerOptions: config.providerOptions,
http: config.body === undefined ? undefined : { body: config.body },
limits: config.limits,
} satisfies RoutePatch<AnthropicMessagesBody, unknown>)
.model({ id })
export * as AnthropicMessages from "./anthropic-messages"

View file

@ -1,8 +1,6 @@
import { Route, type RoutePatch, type RouteRoutedModelInput } from "../route/client"
import type { Auth as AuthDef } from "../route/auth"
import { Route, type RouteRoutedModelInput } from "../route/client"
import { Endpoint } from "../route/endpoint"
import { Framing } from "../route/framing"
import type { Model, ProviderOptions } from "../schema"
import * as OpenAIChat from "./openai-chat"
const ADAPTER = "openai-compatible-chat"
@ -23,25 +21,4 @@ export const route = Route.make({
framing: Framing.sse,
})
export interface ModelConfig {
readonly auth: AuthDef
readonly baseURL?: string
readonly headers?: Readonly<Record<string, string>>
readonly providerOptions?: ProviderOptions
readonly body?: Readonly<Record<string, unknown>>
readonly limits?: { readonly context: number; readonly output: number }
}
export const model = (id: string, config: ModelConfig): Model =>
route
.with({
auth: config.auth,
endpoint: config.baseURL === undefined ? undefined : { baseURL: config.baseURL },
headers: config.headers,
providerOptions: config.providerOptions,
http: config.body === undefined ? undefined : { body: config.body },
limits: config.limits,
} satisfies RoutePatch<OpenAIChat.OpenAIChatBody, unknown>)
.model({ id, provider: "openai-compatible" })
export * as OpenAICompatibleChat from "./openai-compatible-chat"

View file

@ -1,13 +1,11 @@
import { Effect, Schema } from "effect"
import { Route, type RoutePatch } from "../route/client"
import { Auth, type Auth as AuthDef } from "../route/auth"
import { Route } from "../route/client"
import { Auth } from "../route/auth"
import { Endpoint } from "../route/endpoint"
import { HttpTransport, WebSocketTransport } from "../route/transport"
import { Protocol } from "../route/protocol"
import {
LLMEvent,
type Model,
type ProviderOptions,
Usage,
type FinishReason,
type JsonSchema,
@ -993,27 +991,6 @@ export const route = Route.make({
defaults: { providerOptions: { openai: { store: false } } },
})
export interface ModelConfig {
readonly auth: AuthDef
readonly baseURL?: string
readonly headers?: Readonly<Record<string, string>>
readonly providerOptions?: ProviderOptions
readonly body?: Readonly<Record<string, unknown>>
readonly limits?: { readonly context: number; readonly output: number }
}
export const model = (id: string, config: ModelConfig): Model =>
route
.with({
auth: config.auth,
endpoint: config.baseURL === undefined ? undefined : { baseURL: config.baseURL },
headers: config.headers,
providerOptions: config.providerOptions,
http: config.body === undefined ? undefined : { body: config.body },
limits: config.limits,
} satisfies RoutePatch<OpenAIResponsesBody, unknown>)
.model({ id })
const decodeWebSocketMessage = ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenAIResponsesWebSocketMessage))
const webSocketMessage = (body: OpenAIResponsesBody | Record<string, unknown>) =>

View file

@ -3,13 +3,13 @@ import { Auth } from "../route/auth"
import type { ProviderAuthOption } from "../route/auth-options"
import { ProviderID, type ModelID } from "../schema"
import { ProviderPackage } from "../provider-package"
import * as AnthropicMessages from "../protocols/anthropic-messages"
import { AnthropicMessages } from "../protocols/anthropic-messages"
export const id = ProviderID.make("anthropic")
export const routes = [AnthropicMessages.route]
export type Config = RouteDefaultsInput & ProviderAuthOption<"optional"> & { readonly baseURL?: string }
type Config = RouteDefaultsInput & ProviderAuthOption<"optional"> & { readonly baseURL?: string }
export interface AnthropicSettings extends ProviderPackage.Settings {}
@ -34,14 +34,15 @@ export const configure = (input: Config = {}) => {
}
}
export const provider = configure()
export const model = ProviderPackage.define((modelID, settings: AnthropicSettings) =>
AnthropicMessages.model(modelID, {
auth: settings.apiKey === undefined ? Auth.none : Auth.header("x-api-key", settings.apiKey),
baseURL: settings.baseURL,
headers: settings.headers,
providerOptions: settings.providerOptions === undefined ? undefined : { anthropic: settings.providerOptions },
body: settings.body,
limits: settings.limits,
}),
AnthropicMessages.route
.with({
auth: settings.apiKey === undefined ? Auth.none : Auth.header("x-api-key", settings.apiKey),
endpoint: { baseURL: settings.baseURL },
headers: settings.headers,
providerOptions: settings.providerOptions && { anthropic: settings.providerOptions },
http: { body: settings.body },
limits: settings.limits,
})
.model({ id: modelID }),
)

View file

@ -1,10 +1,9 @@
import { ProviderID, type ModelID } from "../schema"
import * as OpenAICompatibleChat from "../protocols/openai-compatible-chat"
import { OpenAICompatibleChat } from "../protocols/openai-compatible-chat"
import type { RouteDefaultsInput } from "../route/client"
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
import { Auth } from "../route/auth"
import { ProviderPackage } from "../provider-package"
import { profiles, type OpenAICompatibleProfile } from "./openai-compatible-profile"
export const id = ProviderID.make("openai-compatible")
@ -14,11 +13,6 @@ type GenericModelOptions = RouteDefaultsInput &
readonly baseURL: string
}
export type FamilyModelOptions = RouteDefaultsInput &
ProviderAuthOption<"optional"> & {
readonly baseURL?: string
}
export interface OpenAICompatibleSettings extends ProviderPackage.Settings {}
export const routes = [OpenAICompatibleChat.route]
@ -39,42 +33,15 @@ export const configure = (input: GenericModelOptions) => {
}
}
const define = (profile: OpenAICompatibleProfile) => {
const configureProfile = (input: FamilyModelOptions = {}) => {
const facade = configure({
...input,
baseURL: input.baseURL ?? profile.baseURL,
provider: profile.provider,
})
return {
id: ProviderID.make(profile.provider),
model: facade.model,
configure: configureProfile,
}
}
return configureProfile()
}
export const provider = {
id,
configure,
}
export const model = ProviderPackage.define((modelID, settings: OpenAICompatibleSettings) =>
OpenAICompatibleChat.model(modelID, {
auth: settings.apiKey === undefined ? Auth.none : Auth.bearer(settings.apiKey),
baseURL: settings.baseURL,
headers: settings.headers,
providerOptions: settings.providerOptions === undefined ? undefined : { openai: settings.providerOptions },
body: settings.body,
limits: settings.limits,
}),
OpenAICompatibleChat.route
.with({
auth: settings.apiKey === undefined ? Auth.none : Auth.bearer(settings.apiKey),
endpoint: { baseURL: settings.baseURL },
headers: settings.headers,
providerOptions: settings.providerOptions && { openai: settings.providerOptions },
http: { body: settings.body },
limits: settings.limits,
})
.model({ id: modelID, provider: "openai-compatible" }),
)
export const baseten = define(profiles.baseten)
export const cerebras = define(profiles.cerebras)
export const deepinfra = define(profiles.deepinfra)
export const deepseek = define(profiles.deepseek)
export const fireworks = define(profiles.fireworks)
export const groq = define(profiles.groq)
export const togetherai = define(profiles.togetherai)

View file

@ -3,8 +3,8 @@ import { Auth } from "../route/auth"
import type { Route, RouteDefaultsInput } from "../route/client"
import { ProviderID, type ModelID } from "../schema"
import { ProviderPackage } from "../provider-package"
import * as OpenAIChat from "../protocols/openai-chat"
import * as OpenAIResponses from "../protocols/openai-responses"
import { OpenAIChat } from "../protocols/openai-chat"
import { OpenAIResponses } from "../protocols/openai-responses"
import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options"
export type { OpenAIOptionsInput, OpenAIResponseIncludable } from "./openai-options"
@ -16,7 +16,7 @@ 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.
export type Config = RouteDefaultsInput &
type Config = RouteDefaultsInput &
ProviderAuthOption<"optional"> & {
readonly baseURL?: string
readonly queryParams?: Record<string, string>
@ -59,18 +59,15 @@ export const configure = (input: Config = {}) => {
}
}
export const provider = configure()
export const model = ProviderPackage.define((modelID, settings: OpenAISettings) =>
OpenAIResponses.model(modelID, {
auth: settings.apiKey === undefined ? Auth.none : Auth.bearer(settings.apiKey),
baseURL: settings.baseURL,
headers: settings.headers,
providerOptions: settings.providerOptions === undefined ? undefined : { openai: settings.providerOptions },
body: settings.body,
limits: settings.limits,
}),
OpenAIResponses.route
.with({
auth: settings.apiKey === undefined ? Auth.none : Auth.bearer(settings.apiKey),
endpoint: { baseURL: settings.baseURL },
headers: settings.headers,
providerOptions: settings.providerOptions && { openai: settings.providerOptions },
http: { body: settings.body },
limits: settings.limits,
})
.model({ id: modelID }),
)
export const responses = provider.responses
export const responsesWebSocket = provider.responsesWebSocket
export const chat = provider.chat

View file

@ -7,14 +7,16 @@ export interface OpenAICodexSettings extends ProviderPackage.Settings {
}
export const model = ProviderPackage.define((modelID, settings: OpenAICodexSettings) =>
OpenAIResponses.model(modelID, {
auth: (settings.apiKey === undefined ? Auth.none : Auth.bearer(settings.apiKey)).andThen(
settings.accountID === undefined ? Auth.none : Auth.headers({ "chatgpt-account-id": settings.accountID }),
),
baseURL: "https://chatgpt.com/backend-api/codex",
headers: settings.headers,
providerOptions: settings.providerOptions === undefined ? undefined : { openai: settings.providerOptions },
body: settings.body,
limits: settings.limits,
}),
OpenAIResponses.route
.with({
auth: (settings.apiKey === undefined ? Auth.none : Auth.bearer(settings.apiKey)).andThen(
settings.accountID === undefined ? Auth.none : Auth.headers({ "chatgpt-account-id": settings.accountID }),
),
endpoint: { baseURL: "https://chatgpt.com/backend-api/codex" },
headers: settings.headers,
providerOptions: settings.providerOptions && { openai: settings.providerOptions },
http: { body: settings.body },
limits: settings.limits,
})
.model({ id: modelID }),
)

View file

@ -67,7 +67,6 @@ requiredAuthModel("custom-model", {})
// @ts-expect-error auth is an override, so apiKey cannot be supplied with it.
requiredAuthModel("custom-model", { apiKey: "key", auth })
OpenAI.responses("gpt-4.1-mini")
OpenAI.configure({}).responses("gpt-4.1-mini")
OpenAI.configure({ apiKey: "sk-test" }).responses("gpt-4.1-mini")
OpenAI.configure({ apiKey: configApiKey }).responses("gpt-4.1-mini")
@ -99,7 +98,6 @@ OpenAI.configure({ providerOptions: { openai: { store: "false" } } })
// @ts-expect-error auth is an override, so OpenAI rejects apiKey with auth.
OpenAI.configure({ apiKey: "sk-test", auth: RuntimeAuth.bearer("oauth-token") })
OpenAI.chat("gpt-4.1-mini")
OpenAI.configure({ apiKey: "sk-test" }).chat("gpt-4.1-mini")
OpenAI.configure({ apiKey: configApiKey }).chat("gpt-4.1-mini")
OpenAI.configure({ auth: RuntimeAuth.bearer("oauth-token") }).chat("gpt-4.1-mini")
@ -155,9 +153,14 @@ XAI.configure({ apiKey: "xai-key" }).responses("grok-4", {})
// @ts-expect-error xAI Chat selectors only accept model ids.
XAI.configure({ apiKey: "xai-key" }).chat("grok-4", {})
OpenAICompatible.deepseek.configure({ apiKey: "deepseek-key" }).model("deepseek-chat")
// @ts-expect-error OpenAI-compatible family selectors only accept model ids.
OpenAICompatible.deepseek.configure({ apiKey: "deepseek-key" }).model("deepseek-chat", {})
OpenAICompatible.configure({
apiKey: "deepseek-key",
provider: "deepseek",
baseURL: "https://api.deepseek.com/v1",
}).model("deepseek-chat")
OpenAICompatible.configure({ apiKey: "deepseek-key", provider: "deepseek", baseURL: "https://api.deepseek.com/v1" })
// @ts-expect-error OpenAI-compatible model selectors only accept model ids.
.model("deepseek-chat", {})
Cloudflare.CloudflareWorkersAI.configure({ accountId: "account", apiKey: "cf-key" }).model("@cf/meta/llama")
// @ts-expect-error Cloudflare Workers AI model selectors only accept model ids.

View file

@ -34,12 +34,12 @@ describe("public exports", () => {
test("provider barrels expose user-facing facades", () => {
expect(OpenAI.model).toBeFunction()
expect(OpenAI.provider.model).toBeFunction()
expect(OpenAI.provider.responses).toBe(OpenAI.responses)
expect(OpenAI.provider.responsesWebSocket).toBe(OpenAI.responsesWebSocket)
expect(OpenAI.configure({ apiKey: "fixture" }).responses).toBeFunction()
expect(OpenAICodex.model).toBeFunction()
expect(OpenAICompatible.deepseek.model).toBeFunction()
expect(OpenAICompatible.model).toBeFunction()
expect(
OpenAICompatible.configure({ baseURL: "https://api.compatible.test/v1", apiKey: "fixture" }).model,
).toBeFunction()
expect(CloudflareAIGateway.configure).toBeFunction()
expect(CloudflareAIGateway.configure({ accountId: "fixture", gatewayApiKey: "fixture" }).model).toBeFunction()
expect(CloudflareWorkersAI.configure).toBeFunction()

View file

@ -39,17 +39,21 @@ const cloudflareAIGatewayWorkers = cloudflareAIGateway.model("workers-ai/@cf/met
const cloudflareAIGatewayWorkersTools = cloudflareAIGateway.model("workers-ai/@cf/openai/gpt-oss-20b")
const cloudflareWorkersAI = cloudflareWorkers.model("@cf/meta/llama-3.1-8b-instruct")
const cloudflareWorkersAITools = cloudflareWorkers.model("@cf/openai/gpt-oss-20b")
const deepseek = OpenAICompatible.deepseek
.configure({ apiKey: process.env.DEEPSEEK_API_KEY ?? "fixture" })
.model("deepseek-chat")
const together = OpenAICompatible.togetherai
.configure({
apiKey: process.env.TOGETHER_AI_API_KEY ?? "fixture",
})
.model("meta-llama/Llama-3.3-70B-Instruct-Turbo")
const groq = OpenAICompatible.groq
.configure({ apiKey: process.env.GROQ_API_KEY ?? "fixture" })
.model("llama-3.3-70b-versatile")
const deepseek = OpenAICompatible.configure({
apiKey: process.env.DEEPSEEK_API_KEY ?? "fixture",
provider: "deepseek",
baseURL: "https://api.deepseek.com/v1",
}).model("deepseek-chat")
const together = OpenAICompatible.configure({
apiKey: process.env.TOGETHER_AI_API_KEY ?? "fixture",
provider: "togetherai",
baseURL: "https://api.together.xyz/v1",
}).model("meta-llama/Llama-3.3-70B-Instruct-Turbo")
const groq = OpenAICompatible.configure({
apiKey: process.env.GROQ_API_KEY ?? "fixture",
provider: "groq",
baseURL: "https://api.groq.com/openai/v1",
}).model("llama-3.3-70b-versatile")
const openRouter = OpenRouter.configure({ apiKey: process.env.OPENROUTER_API_KEY ?? "fixture" })
const openrouter = openRouter.model("openai/gpt-4o-mini")
const openrouterGpt55 = openRouter.model("openai/gpt-5.5")

View file

@ -3,7 +3,6 @@ import { Effect, Schema } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { LLM, Message, ToolCallPart } from "../../src"
import { Auth, LLMClient } from "../../src/route"
import * as OpenAICompatible from "../../src/providers/openai-compatible"
import * as OpenAICompatibleChat from "../../src/protocols/openai-compatible-chat"
import { it } from "../lib/effect"
import { dynamicResponse } from "../lib/http"
@ -40,15 +39,6 @@ const usageChunk = (usage: object) => ({
usage,
})
const providerFamilies = [
["baseten", OpenAICompatible.baseten, "https://inference.baseten.co/v1"],
["cerebras", OpenAICompatible.cerebras, "https://api.cerebras.ai/v1"],
["deepinfra", OpenAICompatible.deepinfra, "https://api.deepinfra.com/v1/openai"],
["deepseek", OpenAICompatible.deepseek, "https://api.deepseek.com/v1"],
["fireworks", OpenAICompatible.fireworks, "https://api.fireworks.ai/inference/v1"],
["togetherai", OpenAICompatible.togetherai, "https://api.together.xyz/v1"],
] as const
describe("OpenAI-compatible Chat route", () => {
it.effect("prepares generic Chat target", () =>
Effect.gen(function* () {
@ -90,41 +80,6 @@ describe("OpenAI-compatible Chat route", () => {
}),
)
it.effect("provides model helpers for compatible provider families", () =>
Effect.gen(function* () {
expect(
providerFamilies.map(([provider, family]) => {
const model = family.configure({ apiKey: "test-key" }).model(`${provider}-model`)
return {
id: String(model.id),
provider: String(model.provider),
route: model.route.id,
baseURL: model.route.endpoint.baseURL,
}
}),
).toEqual(
providerFamilies.map(([provider, _, baseURL]) => ({
id: `${provider}-model`,
provider,
route: "openai-compatible-chat",
baseURL,
})),
)
const custom = OpenAICompatible.deepseek
.configure({
apiKey: "test-key",
baseURL: "https://custom.deepseek.test/v1",
})
.model("deepseek-chat")
expect(custom).toMatchObject({
provider: "deepseek",
route: { id: "openai-compatible-chat" },
})
expect(custom.route.endpoint.baseURL).toBe("https://custom.deepseek.test/v1")
}),
)
it.effect("matches AI SDK compatible basic request body fixture", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(request)

View file

@ -2,7 +2,7 @@ import { describe, expect } from "bun:test"
import { Effect, Schema } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { LLM, LLMClient } from "../../src"
import { Anthropic, OpenAI, OpenAICodex } from "../../src/providers"
import { Anthropic, OpenAI, OpenAICodex, OpenAICompatible } from "../../src/providers"
import { it } from "../lib/effect"
import { dynamicResponse } from "../lib/http"
import { sseEvents } from "../lib/sse"
@ -117,4 +117,40 @@ describe("provider package contract", () => {
)
}),
)
it.effect("builds OpenAI-compatible Chat models from flat settings", () =>
Effect.gen(function* () {
yield* LLMClient.generate(
requestFor(
OpenAICompatible.model("compatible-x", {
apiKey: "compatible-key",
baseURL: "https://api.compatible.test/v1",
body: { user: "provider-package" },
providerOptions: { serviceTier: "priority" },
}),
),
).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
const body = decodeJsonRecord(input.text)
expect(web.url).toBe("https://api.compatible.test/v1/chat/completions")
expect(web.headers.get("authorization")).toBe("Bearer compatible-key")
expect(body).toMatchObject({
model: "compatible-x",
user: "provider-package",
stream: true,
})
expect(body).not.toHaveProperty("apiKey")
return input.respond(sseEvents({ choices: [{ delta: {}, finish_reason: "stop" }], usage: null }), {
headers: { "content-type": "text/event-stream" },
})
}),
),
),
)
}),
)
})