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:
parent
911fb7063a
commit
38398fd450
22 changed files with 634 additions and 114 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>) =>
|
||||
|
|
|
|||
18
packages/llm/src/provider-package.ts
Normal file
18
packages/llm/src/provider-package.ts
Normal 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
|
||||
|
|
@ -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,
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
20
packages/llm/src/providers/openai/codex.ts
Normal file
20
packages/llm/src/providers/openai/codex.ts
Normal 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,
|
||||
}),
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue