feat(llm): provider packages own model request construction

Provider packages in @opencode-ai/llm/providers/* now implement a uniform
ProviderPackage contract: model(modelID, settings) => Model. SessionRunnerModel
becomes provider-agnostic: it resolves a package specifier, folds catalog
settings, credentials, and transport overlays into one Settings object, and
delegates request construction to the package.

- llm: add ProviderPackage (Settings, Definition, define), flat model(id,
  config) constructors on the openai-responses, anthropic-messages, and
  openai-compatible-chat protocols, contract-shaped model exports on the
  openai, anthropic, and openai-compatible providers, and a new
  providers/openai/codex entry point that targets the ChatGPT codex backend
  and sets the chatgpt-account-id header from settings.accountID.
- schema: Provider.Native gains optional package.
- core: SessionRunnerModel loads packages through a static built-in map
  (dynamic import for foreign specifiers) and applies one settings fold;
  the ChatGPT conditional is deleted from the runner. The OpenAI plugin's
  catalog transform now assigns the codex package to eligible models when a
  ChatGPT connection is active, alongside the existing eligibility and cost
  rewrites.
- llm schema: hoist ToolResultValue union out of its Object.assign self
  reference; the previous shape only typechecked under lucky check ordering
  and broke under core's typecheck with the new import graph.

Closes #34765
This commit is contained in:
Kit Langton 2026-07-03 14:01:17 -04:00
commit 38398fd450
22 changed files with 634 additions and 114 deletions

View file

@ -1,6 +1,7 @@
export { LLMClient } from "./route/client"
export { Auth } from "./route/auth"
export { Provider } from "./provider"
export { ProviderPackage } from "./provider-package"
export { isContextOverflow, isContextOverflowFailure } from "./provider-error"
export type {
RouteModelInput,

View file

@ -1,11 +1,13 @@
import { Effect, Schema } from "effect"
import { Route } from "../route/client"
import { Auth } from "../route/auth"
import { Route, type RoutePatch } from "../route/client"
import { Auth, type Auth as AuthDef } 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,
@ -852,4 +854,25 @@ 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,6 +1,8 @@
import { Route, type RouteRoutedModelInput } from "../route/client"
import { Route, type RoutePatch, type RouteRoutedModelInput } from "../route/client"
import type { Auth as AuthDef } from "../route/auth"
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"
@ -21,4 +23,25 @@ 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,11 +1,13 @@
import { Effect, Schema } from "effect"
import { Route } from "../route/client"
import { Auth } from "../route/auth"
import { Route, type RoutePatch } from "../route/client"
import { Auth, type Auth as AuthDef } 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,
@ -991,6 +993,27 @@ 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

@ -0,0 +1,18 @@
export * as ProviderPackage from "./provider-package"
import type { Model } from "./schema"
export interface Settings extends Readonly<Record<string, unknown>> {
readonly apiKey?: string
readonly baseURL?: string
readonly providerOptions?: Readonly<Record<string, unknown>>
readonly headers?: Readonly<Record<string, string>>
readonly body?: Readonly<Record<string, unknown>>
readonly limits?: { readonly context: number; readonly output: number }
}
export interface Definition<S extends Settings = Settings> {
readonly model: (modelID: string, settings: S) => Model
}
export const define = <S extends Settings = Settings>(model: (modelID: string, settings: S) => Model) => model

View file

@ -2,6 +2,7 @@ import type { RouteDefaultsInput } from "../route/client"
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"
export const id = ProviderID.make("anthropic")
@ -10,6 +11,8 @@ export const routes = [AnthropicMessages.route]
export type Config = RouteDefaultsInput & ProviderAuthOption<"optional"> & { readonly baseURL?: string }
export interface AnthropicSettings extends ProviderPackage.Settings {}
const auth = (options: ProviderAuthOption<"optional">) => {
if ("auth" in options && options.auth) return options.auth
return Auth.optional("apiKey" in options ? options.apiKey : undefined, "apiKey")
@ -32,4 +35,13 @@ export const configure = (input: Config = {}) => {
}
export const provider = configure()
export const model = provider.model
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,
}),
)

View file

@ -6,6 +6,7 @@ export { CloudflareAIGateway, CloudflareWorkersAI } from "./cloudflare"
export * as GitHubCopilot from "./github-copilot"
export * as Google from "./google"
export * as OpenAI from "./openai"
export * as OpenAICodex from "./openai/codex"
export * as OpenAICompatible from "./openai-compatible"
export * as OpenRouter from "./openrouter"
export * as XAI from "./xai"

View file

@ -2,6 +2,8 @@ import { ProviderID, type ModelID } from "../schema"
import * as 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")
@ -17,6 +19,8 @@ export type FamilyModelOptions = RouteDefaultsInput &
readonly baseURL?: string
}
export interface OpenAICompatibleSettings extends ProviderPackage.Settings {}
export const routes = [OpenAICompatibleChat.route]
export const configure = (input: GenericModelOptions) => {
@ -56,6 +60,17 @@ export const provider = {
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,
}),
)
export const baseten = define(profiles.baseten)
export const cerebras = define(profiles.cerebras)
export const deepinfra = define(profiles.deepinfra)

View file

@ -1,6 +1,8 @@
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
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 { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options"
@ -21,6 +23,8 @@ export type Config = RouteDefaultsInput &
readonly providerOptions?: OpenAIProviderOptionsInput
}
export interface OpenAISettings extends ProviderPackage.Settings {}
const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "OPENAI_API_KEY")
const defaults = (input: Config) => {
@ -57,7 +61,16 @@ export const configure = (input: Config = {}) => {
export const provider = configure()
export const model = provider.model
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,
}),
)
export const responses = provider.responses
export const responsesWebSocket = provider.responsesWebSocket
export const chat = provider.chat

View file

@ -0,0 +1,20 @@
import { Auth } from "../../route/auth"
import { ProviderPackage } from "../../provider-package"
import { OpenAIResponses } from "../../protocols/openai-responses"
export interface OpenAICodexSettings extends ProviderPackage.Settings {
readonly accountID?: string
}
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,
}),
)

View file

@ -42,40 +42,41 @@ export type MediaPart = Schema.Schema.Type<typeof MediaPart>
export { ToolContent, ToolFileContent, ToolTextContent }
// Standalone schema const so the derived type does not participate in the
// Object.assign self-reference below; keeps checking order-independent.
const toolResultValueSchema = Schema.Union([
Schema.Struct({
type: Schema.Literal("json"),
value: Schema.Unknown,
}),
Schema.Struct({
type: Schema.Literal("text"),
value: Schema.Unknown,
}),
Schema.Struct({
type: Schema.Literal("error"),
value: Schema.Unknown,
}),
Schema.Struct({
type: Schema.Literal("content"),
value: Schema.Array(ToolContent),
}),
]).annotate({ identifier: "LLM.ToolResult" })
const isToolResultValue = (value: unknown): value is ToolResultValue =>
isRecord(value) &&
(value.type === "text" || value.type === "json" || value.type === "error" || value.type === "content") &&
"value" in value
export const ToolResultValue = Object.assign(
Schema.Union([
Schema.Struct({
type: Schema.Literal("json"),
value: Schema.Unknown,
}),
Schema.Struct({
type: Schema.Literal("text"),
value: Schema.Unknown,
}),
Schema.Struct({
type: Schema.Literal("error"),
value: Schema.Unknown,
}),
Schema.Struct({
type: Schema.Literal("content"),
value: Schema.Array(ToolContent),
}),
]).annotate({ identifier: "LLM.ToolResult" }),
{
is: isToolResultValue,
make: (value: unknown, type: ToolResultValue["type"] = "json"): ToolResultValue => {
if (isToolResultValue(value)) return value
if (type === "content") return { type, value: Array.isArray(value) ? value : [] }
return { type, value }
},
export const ToolResultValue = Object.assign(toolResultValueSchema, {
is: isToolResultValue,
make: (value: unknown, type: ToolResultValue["type"] = "json"): ToolResultValue => {
if (isToolResultValue(value)) return value
if (type === "content") return { type, value: Array.isArray(value) ? value : [] }
return { type, value }
},
)
export type ToolResultValue = Schema.Schema.Type<typeof ToolResultValue>
})
export type ToolResultValue = Schema.Schema.Type<typeof toolResultValueSchema>
export interface ToolOutput {
readonly structured: unknown