فهرست منبع

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.
Kit Langton 1 ماه پیش
والد
کامیت
5e5294c036

+ 2 - 25
packages/llm/src/protocols/anthropic-messages.ts

@@ -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"

+ 1 - 24
packages/llm/src/protocols/openai-compatible-chat.ts

@@ -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"

+ 2 - 25
packages/llm/src/protocols/openai-responses.ts

@@ -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>) =>

+ 12 - 11
packages/llm/src/providers/anthropic.ts

@@ -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 }),
 )

+ 11 - 44
packages/llm/src/providers/openai-compatible.ts

@@ -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)

+ 13 - 16
packages/llm/src/providers/openai.ts

@@ -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

+ 12 - 10
packages/llm/src/providers/openai/codex.ts

@@ -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 }),
 )

+ 8 - 5
packages/llm/test/auth-options.types.ts

@@ -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.

+ 4 - 4
packages/llm/test/exports.test.ts

@@ -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()

+ 15 - 11
packages/llm/test/provider/golden.recorded.test.ts

@@ -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")

+ 0 - 45
packages/llm/test/provider/openai-compatible-chat.test.ts

@@ -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)

+ 37 - 1
packages/llm/test/provider/provider-package.test.ts

@@ -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" },
+              })
+            }),
+          ),
+        ),
+      )
+    }),
+  )
 })