Compare commits
5 commits
dev
...
provider-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f019bb3abf | ||
|
|
781165c884 | ||
|
|
5e5294c036 | ||
|
|
759db0d172 | ||
|
|
38398fd450 |
24 changed files with 679 additions and 219 deletions
|
|
@ -1955,6 +1955,7 @@ export type ModelsListOutput = {
|
||||||
| {
|
| {
|
||||||
readonly id: string
|
readonly id: string
|
||||||
readonly type: "native"
|
readonly type: "native"
|
||||||
|
readonly package?: string
|
||||||
readonly url?: string
|
readonly url?: string
|
||||||
readonly settings: { readonly [x: string]: JsonValue }
|
readonly settings: { readonly [x: string]: JsonValue }
|
||||||
}
|
}
|
||||||
|
|
@ -2010,7 +2011,12 @@ export type ProvidersListOutput = {
|
||||||
readonly url?: string
|
readonly url?: string
|
||||||
readonly settings?: { readonly [x: string]: JsonValue }
|
readonly settings?: { readonly [x: string]: JsonValue }
|
||||||
}
|
}
|
||||||
| { readonly type: "native"; readonly url?: string; readonly settings: { readonly [x: string]: JsonValue } }
|
| {
|
||||||
|
readonly type: "native"
|
||||||
|
readonly package?: string
|
||||||
|
readonly url?: string
|
||||||
|
readonly settings: { readonly [x: string]: JsonValue }
|
||||||
|
}
|
||||||
readonly request: {
|
readonly request: {
|
||||||
readonly headers: { readonly [x: string]: string }
|
readonly headers: { readonly [x: string]: string }
|
||||||
readonly body: { readonly [x: string]: JsonValue }
|
readonly body: { readonly [x: string]: JsonValue }
|
||||||
|
|
@ -2043,7 +2049,12 @@ export type ProvidersGetOutput = {
|
||||||
readonly url?: string
|
readonly url?: string
|
||||||
readonly settings?: { readonly [x: string]: JsonValue }
|
readonly settings?: { readonly [x: string]: JsonValue }
|
||||||
}
|
}
|
||||||
| { readonly type: "native"; readonly url?: string; readonly settings: { readonly [x: string]: JsonValue } }
|
| {
|
||||||
|
readonly type: "native"
|
||||||
|
readonly package?: string
|
||||||
|
readonly url?: string
|
||||||
|
readonly settings: { readonly [x: string]: JsonValue }
|
||||||
|
}
|
||||||
readonly request: {
|
readonly request: {
|
||||||
readonly headers: { readonly [x: string]: string }
|
readonly headers: { readonly [x: string]: string }
|
||||||
readonly body: { readonly [x: string]: JsonValue }
|
readonly body: { readonly [x: string]: JsonValue }
|
||||||
|
|
|
||||||
|
|
@ -77,7 +77,12 @@ const layer = Layer.effect(
|
||||||
|
|
||||||
const projectModel = (model: ModelV2.Info, provider: ProviderV2.Info) => {
|
const projectModel = (model: ModelV2.Info, provider: ProviderV2.Info) => {
|
||||||
const api =
|
const api =
|
||||||
model.api.type === "native" && !model.api.url && Object.keys(model.api.settings).length === 0
|
// A native api with a package is explicitly targeted; only package-less,
|
||||||
|
// settings-less native apis are placeholders that inherit the provider api.
|
||||||
|
model.api.type === "native" &&
|
||||||
|
model.api.package === undefined &&
|
||||||
|
!model.api.url &&
|
||||||
|
Object.keys(model.api.settings).length === 0
|
||||||
? { ...provider.api, id: model.api.id }
|
? { ...provider.api, id: model.api.id }
|
||||||
: model.api.type === "aisdk" && provider.api.type === "aisdk" && !model.api.url
|
: model.api.type === "aisdk" && provider.api.type === "aisdk" && !model.api.url
|
||||||
? { ...model.api, url: provider.api.url, settings: { ...provider.api.settings, ...model.api.settings } }
|
? { ...model.api, url: provider.api.url, settings: { ...provider.api.settings, ...model.api.settings } }
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
import { createServer } from "node:http"
|
import { createServer } from "node:http"
|
||||||
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration"
|
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration"
|
||||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||||
import { Deferred, Effect } from "effect"
|
import { Deferred, Effect, Stream } from "effect"
|
||||||
import type { Scope } from "effect"
|
import type { Scope } from "effect"
|
||||||
import { Credential } from "../../credential"
|
import { Credential } from "../../credential"
|
||||||
|
import { EventV2 } from "../../event"
|
||||||
import { InstallationVersion } from "../../installation/version"
|
import { InstallationVersion } from "../../installation/version"
|
||||||
import { Integration } from "../../integration"
|
import { Integration } from "../../integration"
|
||||||
import { ModelV2 } from "../../model"
|
import { ModelV2 } from "../../model"
|
||||||
|
|
@ -17,6 +18,7 @@ const callbackPort = 1455
|
||||||
const pollingSafetyMargin = 3000
|
const pollingSafetyMargin = 3000
|
||||||
const browserMethodID = Integration.MethodID.make("chatgpt-browser")
|
const browserMethodID = Integration.MethodID.make("chatgpt-browser")
|
||||||
const headlessMethodID = Integration.MethodID.make("chatgpt-headless")
|
const headlessMethodID = Integration.MethodID.make("chatgpt-headless")
|
||||||
|
const codexPackage = "@opencode-ai/llm/providers/openai/codex"
|
||||||
|
|
||||||
type Pkce = {
|
type Pkce = {
|
||||||
verifier: string
|
verifier: string
|
||||||
|
|
@ -154,24 +156,55 @@ const headless = {
|
||||||
export const OpenAIPlugin = define({
|
export const OpenAIPlugin = define({
|
||||||
id: "openai",
|
id: "openai",
|
||||||
effect: Effect.fn(function* (ctx) {
|
effect: Effect.fn(function* (ctx) {
|
||||||
|
const events = yield* EventV2.Service
|
||||||
yield* ctx.integration.transform((draft) => {
|
yield* ctx.integration.transform((draft) => {
|
||||||
draft.method.update(browser)
|
draft.method.update(browser)
|
||||||
draft.method.update(headless)
|
draft.method.update(headless)
|
||||||
})
|
})
|
||||||
yield* ctx.catalog.transform(
|
yield* ctx.catalog.transform(
|
||||||
Effect.fn(function* (evt) {
|
Effect.fn(function* (evt) {
|
||||||
|
const connection = yield* ctx.integration.connection.active("openai")
|
||||||
|
const credential = connection
|
||||||
|
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||||
|
: undefined
|
||||||
|
const chatgpt = isChatGPT(credential)
|
||||||
for (const item of evt.provider.list()) {
|
for (const item of evt.provider.list()) {
|
||||||
if (item.provider.api.type !== "aisdk") continue
|
if (item.provider.api.type !== "aisdk") continue
|
||||||
if (item.provider.api.package !== "@ai-sdk/openai") continue
|
if (item.provider.api.package !== "@ai-sdk/openai") continue
|
||||||
if (!item.models.has(ModelV2.ID.make("gpt-5-chat-latest"))) continue
|
if (item.models.has(ModelV2.ID.make("gpt-5-chat-latest"))) {
|
||||||
evt.model.update(item.provider.id, ModelV2.ID.make("gpt-5-chat-latest"), (model) => {
|
evt.model.update(item.provider.id, ModelV2.ID.make("gpt-5-chat-latest"), (model) => {
|
||||||
// OpenAIPlugin sends OpenAI models through Responses; this alias is a
|
// OpenAIPlugin sends OpenAI models through Responses; this alias is a
|
||||||
// chat-completions-only model, so hide it only from OpenAI's catalog.
|
// chat-completions-only model, so hide it only from OpenAI's catalog.
|
||||||
model.enabled = false
|
model.enabled = false
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
if (!chatgpt) continue
|
||||||
|
for (const model of item.models.values()) {
|
||||||
|
evt.model.update(item.provider.id, model.id, (draft) => {
|
||||||
|
if (!eligible(draft.api.id)) {
|
||||||
|
draft.enabled = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
draft.cost = draft.cost.map((cost) => ({ ...cost, input: 0, output: 0, cache: { read: 0, write: 0 } }))
|
||||||
|
draft.api = {
|
||||||
|
type: "native",
|
||||||
|
id: draft.api.id,
|
||||||
|
package: codexPackage,
|
||||||
|
settings:
|
||||||
|
draft.api.type === "aisdk"
|
||||||
|
? { ...item.provider.api.settings, ...draft.api.settings }
|
||||||
|
: draft.api.settings,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
yield* events.subscribe(Integration.Event.ConnectionUpdated).pipe(
|
||||||
|
Stream.filter((event) => event.data.integrationID === Integration.ID.make("openai")),
|
||||||
|
Stream.runForEach(() => ctx.catalog.reload()),
|
||||||
|
Effect.forkScoped({ startImmediately: true }),
|
||||||
|
)
|
||||||
yield* ctx.aisdk.sdk(
|
yield* ctx.aisdk.sdk(
|
||||||
Effect.fn(function* (evt) {
|
Effect.fn(function* (evt) {
|
||||||
if (evt.package !== "@ai-sdk/openai") return
|
if (evt.package !== "@ai-sdk/openai") return
|
||||||
|
|
@ -269,16 +302,34 @@ function authorizeURL(redirect: string, pkce: Pkce, state: string) {
|
||||||
codex_cli_simplified_flow: "true",
|
codex_cli_simplified_flow: "true",
|
||||||
state,
|
state,
|
||||||
originator: "opencode",
|
originator: "opencode",
|
||||||
})}`
|
}).toString()}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractAccountID(tokens: TokenResponse) {
|
function extractAccountID(tokens: TokenResponse) {
|
||||||
return claim(tokens.id_token) ?? claim(tokens.access_token)
|
return claim(tokens.id_token) ?? claim(tokens.access_token)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isChatGPT(credential: { readonly type: string; readonly methodID?: string } | undefined) {
|
||||||
|
return (
|
||||||
|
credential?.type === "oauth" &&
|
||||||
|
(credential.methodID === browserMethodID || credential.methodID === headlessMethodID)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const chatgptAllowed = new Set(["gpt-5.5", "gpt-5.3-codex-spark", "gpt-5.4", "gpt-5.4-mini"])
|
||||||
|
const chatgptDisallowed = new Set(["gpt-5.5-pro"])
|
||||||
|
|
||||||
|
/** Which API model ids a ChatGPT subscription may call through the codex backend. */
|
||||||
|
function eligible(apiID: string) {
|
||||||
|
if (chatgptAllowed.has(apiID)) return true
|
||||||
|
if (chatgptDisallowed.has(apiID)) return false
|
||||||
|
const match = apiID.match(/^gpt-(\d+\.\d+)/)
|
||||||
|
return match ? Number.parseFloat(match[1]) > 5.4 : false
|
||||||
|
}
|
||||||
|
|
||||||
function claim(token: string) {
|
function claim(token: string) {
|
||||||
const part = token.split(".")[1]
|
const part = token.split(".")[1]
|
||||||
if (!part) return
|
if (!part) return undefined
|
||||||
try {
|
try {
|
||||||
const claims = JSON.parse(Buffer.from(part, "base64url").toString()) as Claims
|
const claims = JSON.parse(Buffer.from(part, "base64url").toString()) as Claims
|
||||||
return (
|
return (
|
||||||
|
|
@ -287,6 +338,6 @@ function claim(token: string) {
|
||||||
claims.organizations?.[0]?.id
|
claims.organizations?.[0]?.id
|
||||||
)
|
)
|
||||||
} catch {
|
} catch {
|
||||||
return
|
return undefined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
export * as ProviderV2 from "./provider"
|
export * as ProviderV2 from "./provider"
|
||||||
|
|
||||||
import { Types } from "effect"
|
import { Effect, Schema, Types } from "effect"
|
||||||
import { Provider } from "@opencode-ai/schema/provider"
|
import { Provider } from "@opencode-ai/schema/provider"
|
||||||
|
import { ProviderPackage } from "@opencode-ai/llm/provider-package"
|
||||||
|
import { Anthropic, OpenAI, OpenAICodex, OpenAICompatible } from "@opencode-ai/llm/providers"
|
||||||
|
|
||||||
export const ID = Provider.ID
|
export const ID = Provider.ID
|
||||||
export type ID = typeof ID.Type
|
export type ID = typeof ID.Type
|
||||||
|
|
@ -23,3 +25,44 @@ export const Info = Provider.Info
|
||||||
export type Info = Provider.Info
|
export type Info = Provider.Info
|
||||||
|
|
||||||
export type MutableInfo = Omit<Types.DeepMutable<Info>, "api"> & { api: MutableApi }
|
export type MutableInfo = Omit<Types.DeepMutable<Info>, "api"> & { api: MutableApi }
|
||||||
|
|
||||||
|
export class PackageLoadError extends Schema.TaggedErrorClass<PackageLoadError>()("ProviderV2.PackageLoadError", {
|
||||||
|
specifier: Schema.String,
|
||||||
|
reason: Schema.String,
|
||||||
|
}) {
|
||||||
|
override get message() {
|
||||||
|
return `Failed to load provider package ${this.specifier}: ${this.reason}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type PackageModule = { readonly model: ProviderPackage.Definition["model"] }
|
||||||
|
|
||||||
|
const builtins: Record<string, PackageModule> = {
|
||||||
|
"@opencode-ai/llm/providers/openai": OpenAI,
|
||||||
|
"@opencode-ai/llm/providers/anthropic": Anthropic,
|
||||||
|
"@opencode-ai/llm/providers/openai-compatible": OpenAICompatible,
|
||||||
|
"@opencode-ai/llm/providers/openai/codex": OpenAICodex,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const loadPackage = (
|
||||||
|
specifier: string,
|
||||||
|
): Effect.Effect<ProviderPackage.Definition["model"], PackageLoadError> => {
|
||||||
|
const builtin = builtins[specifier]
|
||||||
|
if (builtin) return Effect.succeed(builtin.model)
|
||||||
|
return Effect.tryPromise({
|
||||||
|
try: () => import(specifier),
|
||||||
|
catch: (cause) =>
|
||||||
|
new PackageLoadError({ specifier, reason: cause instanceof Error ? cause.message : String(cause) }),
|
||||||
|
}).pipe(
|
||||||
|
Effect.flatMap((module) => {
|
||||||
|
if (hasModel(module)) return Effect.succeed(module.model)
|
||||||
|
return Effect.fail(new PackageLoadError({ specifier, reason: "missing model export" }))
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const load = loadPackage
|
||||||
|
|
||||||
|
function hasModel(module: unknown): module is PackageModule {
|
||||||
|
return typeof module === "object" && module !== null && "model" in module && typeof module.model === "function"
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,8 @@
|
||||||
export * as SessionRunnerModel from "./model"
|
export * as SessionRunnerModel from "./model"
|
||||||
|
|
||||||
import { makeLocationNode } from "../../effect/app-node"
|
import { makeLocationNode } from "../../effect/app-node"
|
||||||
import { type Model } from "@opencode-ai/llm"
|
import { Model } from "@opencode-ai/llm"
|
||||||
import * as AnthropicMessages from "@opencode-ai/llm/protocols/anthropic-messages"
|
import { Context, Effect, Layer, Predicate, Schema } from "effect"
|
||||||
import * as OpenAICompatibleChat from "@opencode-ai/llm/protocols/openai-compatible-chat"
|
|
||||||
import * as OpenAIResponses from "@opencode-ai/llm/protocols/openai-responses"
|
|
||||||
import { Auth, type AnyRoute } from "@opencode-ai/llm/route"
|
|
||||||
import { Context, Effect, Layer, Schema } from "effect"
|
|
||||||
import { produce } from "immer"
|
import { produce } from "immer"
|
||||||
import { Catalog } from "../../catalog"
|
import { Catalog } from "../../catalog"
|
||||||
import { Credential } from "../../credential"
|
import { Credential } from "../../credential"
|
||||||
|
|
@ -69,6 +65,7 @@ export type Error =
|
||||||
| ModelUnavailableError
|
| ModelUnavailableError
|
||||||
| VariantUnavailableError
|
| VariantUnavailableError
|
||||||
| UnsupportedApiError
|
| UnsupportedApiError
|
||||||
|
| ProviderV2.PackageLoadError
|
||||||
| Integration.AuthorizationError
|
| Integration.AuthorizationError
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
|
|
@ -80,27 +77,6 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
|
||||||
/** Test or embedding seam for supplying a model resolver directly. */
|
/** Test or embedding seam for supplying a model resolver directly. */
|
||||||
export const layerWith = (resolve: Interface["resolve"]) => Layer.succeed(Service, Service.of({ resolve }))
|
export const layerWith = (resolve: Interface["resolve"]) => Layer.succeed(Service, Service.of({ resolve }))
|
||||||
|
|
||||||
const apiKey = (model: ModelV2.Info, credential?: Credential.Value) => {
|
|
||||||
if (credential?.type === "key") return Auth.value(credential.key)
|
|
||||||
if (credential?.type === "oauth") return Auth.value(credential.access)
|
|
||||||
const value = model.request.body.apiKey ?? model.api.settings?.apiKey
|
|
||||||
if (typeof value === "string") return Auth.value(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
const withDefaults = (model: ModelV2.Info, route: AnyRoute) => {
|
|
||||||
const body = model.request.body
|
|
||||||
const httpBody = Object.hasOwn(body, "apiKey")
|
|
||||||
? Object.fromEntries(Object.entries(body).filter(([key]) => key !== "apiKey"))
|
|
||||||
: body
|
|
||||||
return route.with({
|
|
||||||
provider: model.providerID,
|
|
||||||
endpoint: model.api.url === undefined ? undefined : { baseURL: model.api.url },
|
|
||||||
headers: model.request.headers,
|
|
||||||
http: { body: httpBody },
|
|
||||||
limits: { context: model.limit.context, output: model.limit.output },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const withVariant = (
|
const withVariant = (
|
||||||
model: ModelV2.Info,
|
model: ModelV2.Info,
|
||||||
variantID: ModelV2.VariantID | undefined,
|
variantID: ModelV2.VariantID | undefined,
|
||||||
|
|
@ -131,52 +107,95 @@ const apiName = (model: ModelV2.Info) =>
|
||||||
export const fromCatalogModel = (
|
export const fromCatalogModel = (
|
||||||
model: ModelV2.Info,
|
model: ModelV2.Info,
|
||||||
credential?: Credential.Value,
|
credential?: Credential.Value,
|
||||||
): Effect.Effect<Model, UnsupportedApiError> => {
|
): Effect.Effect<Model, UnsupportedApiError | ProviderV2.PackageLoadError> => {
|
||||||
const resolved =
|
const resolved =
|
||||||
credential?.type !== "key" || credential.metadata === undefined
|
credential?.type !== "key" || credential.metadata === undefined
|
||||||
? model
|
? model
|
||||||
: produce(model, (draft) => {
|
: produce(model, (draft) => {
|
||||||
Object.assign(draft.request.body, credential.metadata)
|
Object.assign(draft.request.body, credential.metadata)
|
||||||
})
|
})
|
||||||
const key = apiKey(resolved, credential)
|
return packageSpecifier(resolved).pipe(
|
||||||
if (resolved.api.type === "aisdk" && resolved.api.package === "@ai-sdk/openai") {
|
Effect.flatMap((specifier) =>
|
||||||
return Effect.succeed(
|
ProviderV2.loadPackage(specifier).pipe(
|
||||||
withDefaults(resolved, OpenAIResponses.route)
|
Effect.map((load) => {
|
||||||
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
|
const selected = load(resolved.api.id ?? resolved.id, {
|
||||||
.model({ id: resolved.api.id }),
|
...resolved.api.settings,
|
||||||
)
|
...(credential?.type === "oauth" ? credential.metadata : undefined),
|
||||||
}
|
baseURL: resolved.api.url ?? settingsString(resolved.api.settings, "baseURL"),
|
||||||
if (resolved.api.type === "aisdk" && resolved.api.package === "@ai-sdk/anthropic") {
|
apiKey: apiKey(resolved, credential),
|
||||||
return Effect.succeed(
|
providerOptions: requestSettings(resolved.request),
|
||||||
withDefaults(resolved, AnthropicMessages.route)
|
headers: resolved.request.headers,
|
||||||
.with({ auth: key === undefined ? Auth.none : Auth.header("x-api-key", key) })
|
body: stripApiKey(resolved.request.body),
|
||||||
.model({ id: resolved.api.id }),
|
limits: { context: resolved.limit.context, output: resolved.limit.output },
|
||||||
)
|
})
|
||||||
}
|
return Model.update(selected, {
|
||||||
if (resolved.api.type === "aisdk" && resolved.api.package === "@ai-sdk/openai-compatible" && resolved.api.url) {
|
provider: resolved.providerID,
|
||||||
return Effect.succeed(
|
route: selected.route.with({ provider: resolved.providerID }),
|
||||||
withDefaults(resolved, OpenAICompatibleChat.route)
|
})
|
||||||
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
|
|
||||||
.model({ id: resolved.api.id }),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return Effect.fail(
|
|
||||||
new UnsupportedApiError({
|
|
||||||
providerID: resolved.providerID,
|
|
||||||
modelID: resolved.id,
|
|
||||||
api: apiName(resolved),
|
|
||||||
}),
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const resolve = (session: SessionSchema.Info, model: ModelV2.Info, credential?: Credential.Value) =>
|
export const resolve = (session: SessionSchema.Info, model: ModelV2.Info, credential?: Credential.Value) =>
|
||||||
withVariant(model, session.model?.variant).pipe(Effect.flatMap((model) => fromCatalogModel(model, credential)))
|
withVariant(model, session.model?.variant).pipe(Effect.flatMap((model) => fromCatalogModel(model, credential)))
|
||||||
|
|
||||||
|
/** Legacy aisdk catalog entries dispatch to the equivalent native provider packages. */
|
||||||
|
const aisdkPackages: Record<string, string | undefined> = {
|
||||||
|
"@ai-sdk/openai": "@opencode-ai/llm/providers/openai",
|
||||||
|
"@ai-sdk/anthropic": "@opencode-ai/llm/providers/anthropic",
|
||||||
|
"@ai-sdk/openai-compatible": "@opencode-ai/llm/providers/openai-compatible",
|
||||||
|
}
|
||||||
|
|
||||||
export const supported = (model: ModelV2.Info) =>
|
export const supported = (model: ModelV2.Info) =>
|
||||||
model.api.type === "aisdk" &&
|
(model.api.type === "native" && model.api.package !== undefined) ||
|
||||||
(model.api.package === "@ai-sdk/openai" ||
|
(model.api.type === "aisdk" &&
|
||||||
model.api.package === "@ai-sdk/anthropic" ||
|
aisdkPackages[model.api.package] !== undefined &&
|
||||||
(model.api.package === "@ai-sdk/openai-compatible" && model.api.url !== undefined))
|
// The openai-compatible package has no default endpoint; a URL is required.
|
||||||
|
(model.api.package !== "@ai-sdk/openai-compatible" || model.api.url !== undefined))
|
||||||
|
|
||||||
|
const packageSpecifier = (model: ModelV2.Info): Effect.Effect<string, UnsupportedApiError> => {
|
||||||
|
if (supported(model)) {
|
||||||
|
if (model.api.type === "native" && model.api.package !== undefined) return Effect.succeed(model.api.package)
|
||||||
|
const specifier = model.api.type === "aisdk" ? aisdkPackages[model.api.package] : undefined
|
||||||
|
if (specifier !== undefined) return Effect.succeed(specifier)
|
||||||
|
}
|
||||||
|
return Effect.fail(
|
||||||
|
new UnsupportedApiError({
|
||||||
|
providerID: model.providerID,
|
||||||
|
modelID: model.id,
|
||||||
|
api: apiName(model),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiKey = (model: ModelV2.Info, credential?: Credential.Value) => {
|
||||||
|
if (credential?.type === "key") return credential.key
|
||||||
|
if (credential?.type === "oauth") return credential.access
|
||||||
|
const value = model.request.body.apiKey ?? model.api.settings?.apiKey
|
||||||
|
if (typeof value === "string") return value
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const stripApiKey = (body: ModelV2.Info["request"]["body"]) => {
|
||||||
|
if (!Object.hasOwn(body, "apiKey")) return body
|
||||||
|
return Object.fromEntries(Object.entries(body).filter(([key]) => key !== "apiKey"))
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestSettings = (request: ModelV2.Info["request"]) => {
|
||||||
|
if (!("settings" in request)) return undefined
|
||||||
|
const settings = request.settings
|
||||||
|
if (!Predicate.isReadonlyObject(settings)) return undefined
|
||||||
|
if (Object.keys(settings).length === 0) return undefined
|
||||||
|
return settings
|
||||||
|
}
|
||||||
|
|
||||||
|
const settingsString = (settings: ModelV2.Info["api"]["settings"], key: string) => {
|
||||||
|
const value = settings?.[key]
|
||||||
|
if (typeof value === "string") return value
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
/** Resolves models from the catalog belonging to the current Location runtime. */
|
/** Resolves models from the catalog belonging to the current Location runtime. */
|
||||||
export const locationLayer = Layer.effect(
|
export const locationLayer = Layer.effect(
|
||||||
|
|
|
||||||
|
|
@ -213,6 +213,40 @@ describe("CatalogV2", () => {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("keeps an explicit native package api over the provider api", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const catalog = yield* Catalog.Service
|
||||||
|
const providerID = ProviderV2.ID.make("test")
|
||||||
|
const modelID = ModelV2.ID.make("model")
|
||||||
|
yield* catalog.transform((catalog) => {
|
||||||
|
catalog.provider.update(providerID, (provider) => {
|
||||||
|
provider.api = {
|
||||||
|
type: "aisdk",
|
||||||
|
package: "@ai-sdk/openai",
|
||||||
|
}
|
||||||
|
})
|
||||||
|
catalog.model.update(providerID, modelID, (model) => {
|
||||||
|
// Plugins retarget models at explicit native packages (e.g. ChatGPT
|
||||||
|
// subscription models at the codex package); empty settings must not
|
||||||
|
// demote the api back to the provider placeholder rule.
|
||||||
|
model.api = {
|
||||||
|
type: "native",
|
||||||
|
id: model.api.id,
|
||||||
|
package: "@opencode-ai/llm/providers/openai/codex",
|
||||||
|
settings: {},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(required(yield* catalog.model.get(providerID, modelID)).api).toEqual({
|
||||||
|
id: modelID,
|
||||||
|
type: "native",
|
||||||
|
package: "@opencode-ai/llm/providers/openai/codex",
|
||||||
|
settings: {},
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("resolves provider and model request merges", () =>
|
it.effect("resolves provider and model request merges", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { describe, expect } from "bun:test"
|
||||||
import type { LanguageModelV3 } from "@ai-sdk/provider"
|
import type { LanguageModelV3 } from "@ai-sdk/provider"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { Catalog } from "@opencode-ai/core/catalog"
|
import { Catalog } from "@opencode-ai/core/catalog"
|
||||||
|
import { Credential } from "@opencode-ai/core/credential"
|
||||||
import { Integration } from "@opencode-ai/core/integration"
|
import { Integration } from "@opencode-ai/core/integration"
|
||||||
import { ModelV2 } from "@opencode-ai/core/model"
|
import { ModelV2 } from "@opencode-ai/core/model"
|
||||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||||
|
|
@ -16,7 +17,6 @@ const it = testEffect(PluginTestLayer)
|
||||||
|
|
||||||
const addPlugin = Effect.fn(function* () {
|
const addPlugin = Effect.fn(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
const aisdk = yield* AISDK.Service
|
|
||||||
const host = yield* PluginHost.make(plugin)
|
const host = yield* PluginHost.make(plugin)
|
||||||
const integrations = yield* Integration.Service
|
const integrations = yield* Integration.Service
|
||||||
yield* OpenAIPlugin.effect(host).pipe(Effect.provideService(Integration.Service, integrations))
|
yield* OpenAIPlugin.effect(host).pipe(Effect.provideService(Integration.Service, integrations))
|
||||||
|
|
@ -61,7 +61,6 @@ describe("OpenAIPlugin", () => {
|
||||||
|
|
||||||
it.effect("creates an OpenAI SDK for @ai-sdk/openai using the provider ID as SDK name", () =>
|
it.effect("creates an OpenAI SDK for @ai-sdk/openai using the provider ID as SDK name", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
|
||||||
const aisdk = yield* AISDK.Service
|
const aisdk = yield* AISDK.Service
|
||||||
yield* addPlugin()
|
yield* addPlugin()
|
||||||
const result = yield* aisdk.runSDK({
|
const result = yield* aisdk.runSDK({
|
||||||
|
|
@ -78,7 +77,6 @@ describe("OpenAIPlugin", () => {
|
||||||
|
|
||||||
it.effect("ignores non-OpenAI SDK packages", () =>
|
it.effect("ignores non-OpenAI SDK packages", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
|
||||||
const aisdk = yield* AISDK.Service
|
const aisdk = yield* AISDK.Service
|
||||||
yield* addPlugin()
|
yield* addPlugin()
|
||||||
const result = yield* aisdk.runSDK({
|
const result = yield* aisdk.runSDK({
|
||||||
|
|
@ -95,7 +93,6 @@ describe("OpenAIPlugin", () => {
|
||||||
|
|
||||||
it.effect("uses the Responses API for language models", () =>
|
it.effect("uses the Responses API for language models", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
|
||||||
const aisdk = yield* AISDK.Service
|
const aisdk = yield* AISDK.Service
|
||||||
const calls: string[] = []
|
const calls: string[] = []
|
||||||
yield* addPlugin()
|
yield* addPlugin()
|
||||||
|
|
@ -114,7 +111,6 @@ describe("OpenAIPlugin", () => {
|
||||||
|
|
||||||
it.effect("ignores non-OpenAI providers", () =>
|
it.effect("ignores non-OpenAI providers", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
|
||||||
const aisdk = yield* AISDK.Service
|
const aisdk = yield* AISDK.Service
|
||||||
const calls: string[] = []
|
const calls: string[] = []
|
||||||
yield* addPlugin()
|
yield* addPlugin()
|
||||||
|
|
@ -173,4 +169,54 @@ describe("OpenAIPlugin", () => {
|
||||||
).toBe(true)
|
).toBe(true)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("routes eligible models to the codex package for ChatGPT connections", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const catalog = yield* Catalog.Service
|
||||||
|
const credentials = yield* Credential.Service
|
||||||
|
yield* catalog.transform((catalog) => {
|
||||||
|
const item = ProviderV2.Info.make({
|
||||||
|
...ProviderV2.Info.empty(ProviderV2.ID.openai),
|
||||||
|
api: { type: "aisdk", package: "@ai-sdk/openai", settings: { store: false } },
|
||||||
|
})
|
||||||
|
catalog.provider.update(item.id, (draft) => {
|
||||||
|
draft.api = item.api
|
||||||
|
})
|
||||||
|
catalog.model.update(item.id, ModelV2.ID.make("gpt-5.3-codex-spark"), (draft) => {
|
||||||
|
draft.api = { id: ModelV2.ID.make("gpt-5.3-codex-spark"), type: "aisdk", package: "@ai-sdk/openai" }
|
||||||
|
draft.cost = [{ input: 1, output: 2, cache: { read: 3, write: 4 } }]
|
||||||
|
})
|
||||||
|
catalog.model.update(item.id, ModelV2.ID.make("gpt-5"), (draft) => {
|
||||||
|
draft.api = { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "@ai-sdk/openai" }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
yield* credentials.create({
|
||||||
|
integrationID: Integration.ID.make("openai"),
|
||||||
|
value: Credential.OAuth.make({
|
||||||
|
type: "oauth",
|
||||||
|
methodID: Integration.MethodID.make("chatgpt-browser"),
|
||||||
|
access: "access",
|
||||||
|
refresh: "refresh",
|
||||||
|
expires: Date.now() + 60_000,
|
||||||
|
metadata: { accountID: "account-123" },
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
yield* addPlugin()
|
||||||
|
|
||||||
|
expect(
|
||||||
|
required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5.3-codex-spark"))),
|
||||||
|
).toMatchObject({
|
||||||
|
enabled: true,
|
||||||
|
api: {
|
||||||
|
type: "native",
|
||||||
|
id: "gpt-5.3-codex-spark",
|
||||||
|
package: "@opencode-ai/llm/providers/openai/codex",
|
||||||
|
settings: { store: false },
|
||||||
|
},
|
||||||
|
cost: [{ input: 0, output: 0, cache: { read: 0, write: 0 } }],
|
||||||
|
})
|
||||||
|
expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5"))).enabled).toBe(false)
|
||||||
|
}),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,12 @@ type Api =
|
||||||
readonly url?: string
|
readonly url?: string
|
||||||
readonly settings?: Record<string, unknown>
|
readonly settings?: Record<string, unknown>
|
||||||
}
|
}
|
||||||
| { readonly type: "native"; readonly url?: string; readonly settings: Record<string, unknown> }
|
| {
|
||||||
|
readonly type: "native"
|
||||||
|
readonly package?: string
|
||||||
|
readonly url?: string
|
||||||
|
readonly settings: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
const model = (api: Api, variants: ModelV2.Info["variants"] = []) =>
|
const model = (api: Api, variants: ModelV2.Info["variants"] = []) =>
|
||||||
ModelV2.Info.make({
|
ModelV2.Info.make({
|
||||||
|
|
@ -269,6 +274,71 @@ describe("SessionRunnerModel", () => {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("maps native provider package models into bearer-authenticated routes", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const resolved = yield* SessionRunnerModel.fromCatalogModel(
|
||||||
|
ModelV2.Info.make({
|
||||||
|
...model({
|
||||||
|
type: "native",
|
||||||
|
package: "@opencode-ai/llm/providers/openai",
|
||||||
|
url: "https://openai.example/v1",
|
||||||
|
settings: {},
|
||||||
|
}),
|
||||||
|
request: { headers: {}, body: {} },
|
||||||
|
}),
|
||||||
|
Credential.Key.make({ type: "key", key: "secret" }),
|
||||||
|
)
|
||||||
|
const headers = yield* resolved.route.auth.apply({
|
||||||
|
request: LLM.request({ model: resolved, prompt: "Hello" }),
|
||||||
|
method: "POST",
|
||||||
|
url: "https://openai.example/v1/responses",
|
||||||
|
body: "{}",
|
||||||
|
headers: Headers.empty,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(resolved.route).toMatchObject({
|
||||||
|
id: "openai-responses",
|
||||||
|
endpoint: { baseURL: "https://openai.example/v1" },
|
||||||
|
})
|
||||||
|
expect(headers.authorization).toBe("Bearer secret")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("routes native ChatGPT OAuth credentials to the codex backend", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const resolved = yield* SessionRunnerModel.fromCatalogModel(
|
||||||
|
ModelV2.Info.make({
|
||||||
|
...model({
|
||||||
|
type: "native",
|
||||||
|
package: "@opencode-ai/llm/providers/openai/codex",
|
||||||
|
settings: {},
|
||||||
|
}),
|
||||||
|
request: { headers: {}, body: {} },
|
||||||
|
}),
|
||||||
|
Credential.OAuth.make({
|
||||||
|
type: "oauth",
|
||||||
|
methodID: Integration.MethodID.make("chatgpt-browser"),
|
||||||
|
access: "oauth-token",
|
||||||
|
refresh: "refresh",
|
||||||
|
expires: Date.now() + 60_000,
|
||||||
|
metadata: { accountID: "account-123" },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const request = LLM.request({ model: resolved, prompt: "Hello" })
|
||||||
|
const headers = yield* resolved.route.auth.apply({
|
||||||
|
request,
|
||||||
|
method: "POST",
|
||||||
|
url: "https://chatgpt.com/backend-api/codex/responses",
|
||||||
|
body: "{}",
|
||||||
|
headers: Headers.empty,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(resolved.route.endpoint.baseURL).toBe("https://chatgpt.com/backend-api/codex")
|
||||||
|
expect(headers.authorization).toBe("Bearer oauth-token")
|
||||||
|
expect(headers["chatgpt-account-id"]).toBe("account-123")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("prefers stored credentials over configured auth", () =>
|
it.effect("prefers stored credentials over configured auth", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const credential = Credential.Key.make({ type: "key", key: "stored-secret", metadata: { tenant: "work" } })
|
const credential = Credential.Key.make({ type: "key", key: "stored-secret", metadata: { tenant: "work" } })
|
||||||
|
|
@ -342,6 +412,11 @@ describe("SessionRunnerModel", () => {
|
||||||
),
|
),
|
||||||
).toBe(false)
|
).toBe(false)
|
||||||
expect(SessionRunnerModel.supported(model({ type: "native", settings: {} }))).toBe(false)
|
expect(SessionRunnerModel.supported(model({ type: "native", settings: {} }))).toBe(false)
|
||||||
|
expect(
|
||||||
|
SessionRunnerModel.supported(
|
||||||
|
model({ type: "native", package: "@opencode-ai/llm/providers/openai", settings: {} }),
|
||||||
|
),
|
||||||
|
).toBe(true)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
".": "./src/index.ts",
|
".": "./src/index.ts",
|
||||||
"./route": "./src/route/index.ts",
|
"./route": "./src/route/index.ts",
|
||||||
"./provider": "./src/provider.ts",
|
"./provider": "./src/provider.ts",
|
||||||
|
"./provider-package": "./src/provider-package.ts",
|
||||||
"./providers": "./src/providers/index.ts",
|
"./providers": "./src/providers/index.ts",
|
||||||
"./providers/amazon-bedrock": "./src/providers/amazon-bedrock.ts",
|
"./providers/amazon-bedrock": "./src/providers/amazon-bedrock.ts",
|
||||||
"./providers/anthropic": "./src/providers/anthropic.ts",
|
"./providers/anthropic": "./src/providers/anthropic.ts",
|
||||||
|
|
@ -22,6 +23,7 @@
|
||||||
"./providers/github-copilot": "./src/providers/github-copilot.ts",
|
"./providers/github-copilot": "./src/providers/github-copilot.ts",
|
||||||
"./providers/google": "./src/providers/google.ts",
|
"./providers/google": "./src/providers/google.ts",
|
||||||
"./providers/openai": "./src/providers/openai.ts",
|
"./providers/openai": "./src/providers/openai.ts",
|
||||||
|
"./providers/openai/codex": "./src/providers/openai/codex.ts",
|
||||||
"./providers/openai-compatible": "./src/providers/openai-compatible.ts",
|
"./providers/openai-compatible": "./src/providers/openai-compatible.ts",
|
||||||
"./providers/openai-compatible-profile": "./src/providers/openai-compatible-profile.ts",
|
"./providers/openai-compatible-profile": "./src/providers/openai-compatible-profile.ts",
|
||||||
"./providers/openrouter": "./src/providers/openrouter.ts",
|
"./providers/openrouter": "./src/providers/openrouter.ts",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
export { LLMClient } from "./route/client"
|
export { LLMClient } from "./route/client"
|
||||||
export { Auth } from "./route/auth"
|
export { Auth } from "./route/auth"
|
||||||
export { Provider } from "./provider"
|
export { Provider } from "./provider"
|
||||||
|
export { ProviderPackage } from "./provider-package"
|
||||||
export { isContextOverflow, isContextOverflowFailure } from "./provider-error"
|
export { isContextOverflow, isContextOverflowFailure } from "./provider-error"
|
||||||
export type {
|
export type {
|
||||||
RouteModelInput,
|
RouteModelInput,
|
||||||
|
|
|
||||||
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,13 +2,16 @@ import type { RouteDefaultsInput } from "../route/client"
|
||||||
import { Auth } from "../route/auth"
|
import { Auth } from "../route/auth"
|
||||||
import type { ProviderAuthOption } from "../route/auth-options"
|
import type { ProviderAuthOption } from "../route/auth-options"
|
||||||
import { ProviderID, type ModelID } from "../schema"
|
import { ProviderID, type ModelID } from "../schema"
|
||||||
import * as AnthropicMessages from "../protocols/anthropic-messages"
|
import { ProviderPackage } from "../provider-package"
|
||||||
|
import { AnthropicMessages } from "../protocols/anthropic-messages"
|
||||||
|
|
||||||
export const id = ProviderID.make("anthropic")
|
export const id = ProviderID.make("anthropic")
|
||||||
|
|
||||||
export const routes = [AnthropicMessages.route]
|
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 {}
|
||||||
|
|
||||||
const auth = (options: ProviderAuthOption<"optional">) => {
|
const auth = (options: ProviderAuthOption<"optional">) => {
|
||||||
if ("auth" in options && options.auth) return options.auth
|
if ("auth" in options && options.auth) return options.auth
|
||||||
|
|
@ -31,5 +34,15 @@ export const configure = (input: Config = {}) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const provider = configure()
|
export const model = ProviderPackage.define((modelID, settings: AnthropicSettings) =>
|
||||||
export const model = provider.model
|
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 }),
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ export { CloudflareAIGateway, CloudflareWorkersAI } from "./cloudflare"
|
||||||
export * as GitHubCopilot from "./github-copilot"
|
export * as GitHubCopilot from "./github-copilot"
|
||||||
export * as Google from "./google"
|
export * as Google from "./google"
|
||||||
export * as OpenAI from "./openai"
|
export * as OpenAI from "./openai"
|
||||||
|
export * as OpenAICodex from "./openai/codex"
|
||||||
export * as OpenAICompatible from "./openai-compatible"
|
export * as OpenAICompatible from "./openai-compatible"
|
||||||
export * as OpenRouter from "./openrouter"
|
export * as OpenRouter from "./openrouter"
|
||||||
export * as XAI from "./xai"
|
export * as XAI from "./xai"
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import { ProviderID, type ModelID } from "../schema"
|
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 type { RouteDefaultsInput } from "../route/client"
|
||||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
|
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
|
||||||
import { profiles, type OpenAICompatibleProfile } from "./openai-compatible-profile"
|
import { Auth } from "../route/auth"
|
||||||
|
import { ProviderPackage } from "../provider-package"
|
||||||
|
|
||||||
export const id = ProviderID.make("openai-compatible")
|
export const id = ProviderID.make("openai-compatible")
|
||||||
|
|
||||||
|
|
@ -12,10 +13,7 @@ type GenericModelOptions = RouteDefaultsInput &
|
||||||
readonly baseURL: string
|
readonly baseURL: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FamilyModelOptions = RouteDefaultsInput &
|
export interface OpenAICompatibleSettings extends ProviderPackage.Settings {}
|
||||||
ProviderAuthOption<"optional"> & {
|
|
||||||
readonly baseURL?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const routes = [OpenAICompatibleChat.route]
|
export const routes = [OpenAICompatibleChat.route]
|
||||||
|
|
||||||
|
|
@ -35,31 +33,15 @@ export const configure = (input: GenericModelOptions) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const define = (profile: OpenAICompatibleProfile) => {
|
export const model = ProviderPackage.define((modelID, settings: OpenAICompatibleSettings) =>
|
||||||
const configureProfile = (input: FamilyModelOptions = {}) => {
|
OpenAICompatibleChat.route
|
||||||
const facade = configure({
|
.with({
|
||||||
...input,
|
auth: settings.apiKey === undefined ? Auth.none : Auth.bearer(settings.apiKey),
|
||||||
baseURL: input.baseURL ?? profile.baseURL,
|
endpoint: { baseURL: settings.baseURL },
|
||||||
provider: profile.provider,
|
headers: settings.headers,
|
||||||
|
providerOptions: settings.providerOptions && { openai: settings.providerOptions },
|
||||||
|
http: { body: settings.body },
|
||||||
|
limits: settings.limits,
|
||||||
})
|
})
|
||||||
return {
|
.model({ id: modelID, provider: "openai-compatible" }),
|
||||||
id: ProviderID.make(profile.provider),
|
)
|
||||||
model: facade.model,
|
|
||||||
configure: configureProfile,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return configureProfile()
|
|
||||||
}
|
|
||||||
|
|
||||||
export const provider = {
|
|
||||||
id,
|
|
||||||
configure,
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
|
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
|
||||||
|
import { Auth } from "../route/auth"
|
||||||
import type { Route, RouteDefaultsInput } from "../route/client"
|
import type { Route, RouteDefaultsInput } from "../route/client"
|
||||||
import { ProviderID, type ModelID } from "../schema"
|
import { ProviderID, type ModelID } from "../schema"
|
||||||
import * as OpenAIChat from "../protocols/openai-chat"
|
import { ProviderPackage } from "../provider-package"
|
||||||
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"
|
import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options"
|
||||||
|
|
||||||
export type { OpenAIOptionsInput, OpenAIResponseIncludable } from "./openai-options"
|
export type { OpenAIOptionsInput, OpenAIResponseIncludable } from "./openai-options"
|
||||||
|
|
@ -14,13 +16,15 @@ export const routes = [OpenAIResponses.route, OpenAIResponses.webSocketRoute, Op
|
||||||
// This provider facade wraps the lower-level Responses and Chat model factories
|
// This provider facade wraps the lower-level Responses and Chat model factories
|
||||||
// with OpenAI-specific conveniences: typed options, API-key sugar, env fallback,
|
// with OpenAI-specific conveniences: typed options, API-key sugar, env fallback,
|
||||||
// and default option normalization.
|
// and default option normalization.
|
||||||
export type Config = RouteDefaultsInput &
|
type Config = RouteDefaultsInput &
|
||||||
ProviderAuthOption<"optional"> & {
|
ProviderAuthOption<"optional"> & {
|
||||||
readonly baseURL?: string
|
readonly baseURL?: string
|
||||||
readonly queryParams?: Record<string, string>
|
readonly queryParams?: Record<string, string>
|
||||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface OpenAISettings extends ProviderPackage.Settings {}
|
||||||
|
|
||||||
const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "OPENAI_API_KEY")
|
const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "OPENAI_API_KEY")
|
||||||
|
|
||||||
const defaults = (input: Config) => {
|
const defaults = (input: Config) => {
|
||||||
|
|
@ -55,9 +59,15 @@ export const configure = (input: Config = {}) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const provider = configure()
|
export const model = ProviderPackage.define((modelID, settings: OpenAISettings) =>
|
||||||
|
OpenAIResponses.route
|
||||||
export const model = provider.model
|
.with({
|
||||||
export const responses = provider.responses
|
auth: settings.apiKey === undefined ? Auth.none : Auth.bearer(settings.apiKey),
|
||||||
export const responsesWebSocket = provider.responsesWebSocket
|
endpoint: { baseURL: settings.baseURL },
|
||||||
export const chat = provider.chat
|
headers: settings.headers,
|
||||||
|
providerOptions: settings.providerOptions && { openai: settings.providerOptions },
|
||||||
|
http: { body: settings.body },
|
||||||
|
limits: settings.limits,
|
||||||
|
})
|
||||||
|
.model({ id: modelID }),
|
||||||
|
)
|
||||||
|
|
|
||||||
22
packages/llm/src/providers/openai/codex.ts
Normal file
22
packages/llm/src/providers/openai/codex.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
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.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 }),
|
||||||
|
)
|
||||||
|
|
@ -42,13 +42,9 @@ export type MediaPart = Schema.Schema.Type<typeof MediaPart>
|
||||||
|
|
||||||
export { ToolContent, ToolFileContent, ToolTextContent }
|
export { ToolContent, ToolFileContent, ToolTextContent }
|
||||||
|
|
||||||
const isToolResultValue = (value: unknown): value is ToolResultValue =>
|
// Standalone schema const so the derived type does not participate in the
|
||||||
isRecord(value) &&
|
// Object.assign self-reference below; keeps checking order-independent.
|
||||||
(value.type === "text" || value.type === "json" || value.type === "error" || value.type === "content") &&
|
const toolResultValueSchema = Schema.Union([
|
||||||
"value" in value
|
|
||||||
|
|
||||||
export const ToolResultValue = Object.assign(
|
|
||||||
Schema.Union([
|
|
||||||
Schema.Struct({
|
Schema.Struct({
|
||||||
type: Schema.Literal("json"),
|
type: Schema.Literal("json"),
|
||||||
value: Schema.Unknown,
|
value: Schema.Unknown,
|
||||||
|
|
@ -65,17 +61,22 @@ export const ToolResultValue = Object.assign(
|
||||||
type: Schema.Literal("content"),
|
type: Schema.Literal("content"),
|
||||||
value: Schema.Array(ToolContent),
|
value: Schema.Array(ToolContent),
|
||||||
}),
|
}),
|
||||||
]).annotate({ identifier: "LLM.ToolResult" }),
|
]).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(toolResultValueSchema, {
|
||||||
is: isToolResultValue,
|
is: isToolResultValue,
|
||||||
make: (value: unknown, type: ToolResultValue["type"] = "json"): ToolResultValue => {
|
make: (value: unknown, type: ToolResultValue["type"] = "json"): ToolResultValue => {
|
||||||
if (isToolResultValue(value)) return value
|
if (isToolResultValue(value)) return value
|
||||||
if (type === "content") return { type, value: Array.isArray(value) ? value : [] }
|
if (type === "content") return { type, value: Array.isArray(value) ? value : [] }
|
||||||
return { type, value }
|
return { type, value }
|
||||||
},
|
},
|
||||||
},
|
})
|
||||||
)
|
export type ToolResultValue = Schema.Schema.Type<typeof toolResultValueSchema>
|
||||||
export type ToolResultValue = Schema.Schema.Type<typeof ToolResultValue>
|
|
||||||
|
|
||||||
export interface ToolOutput {
|
export interface ToolOutput {
|
||||||
readonly structured: unknown
|
readonly structured: unknown
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,6 @@ requiredAuthModel("custom-model", {})
|
||||||
// @ts-expect-error auth is an override, so apiKey cannot be supplied with it.
|
// @ts-expect-error auth is an override, so apiKey cannot be supplied with it.
|
||||||
requiredAuthModel("custom-model", { apiKey: "key", auth })
|
requiredAuthModel("custom-model", { apiKey: "key", auth })
|
||||||
|
|
||||||
OpenAI.responses("gpt-4.1-mini")
|
|
||||||
OpenAI.configure({}).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: "sk-test" }).responses("gpt-4.1-mini")
|
||||||
OpenAI.configure({ apiKey: configApiKey }).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.
|
// @ts-expect-error auth is an override, so OpenAI rejects apiKey with auth.
|
||||||
OpenAI.configure({ apiKey: "sk-test", auth: RuntimeAuth.bearer("oauth-token") })
|
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: "sk-test" }).chat("gpt-4.1-mini")
|
||||||
OpenAI.configure({ apiKey: configApiKey }).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")
|
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.
|
// @ts-expect-error xAI Chat selectors only accept model ids.
|
||||||
XAI.configure({ apiKey: "xai-key" }).chat("grok-4", {})
|
XAI.configure({ apiKey: "xai-key" }).chat("grok-4", {})
|
||||||
|
|
||||||
OpenAICompatible.deepseek.configure({ apiKey: "deepseek-key" }).model("deepseek-chat")
|
OpenAICompatible.configure({
|
||||||
// @ts-expect-error OpenAI-compatible family selectors only accept model ids.
|
apiKey: "deepseek-key",
|
||||||
OpenAICompatible.deepseek.configure({ apiKey: "deepseek-key" }).model("deepseek-chat", {})
|
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")
|
Cloudflare.CloudflareWorkersAI.configure({ accountId: "account", apiKey: "cf-key" }).model("@cf/meta/llama")
|
||||||
// @ts-expect-error Cloudflare Workers AI model selectors only accept model ids.
|
// @ts-expect-error Cloudflare Workers AI model selectors only accept model ids.
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { LLM, LLMClient, Provider } from "@opencode-ai/llm"
|
import { LLM, LLMClient, Provider, ProviderPackage } from "@opencode-ai/llm"
|
||||||
import { Route, Protocol } from "@opencode-ai/llm/route"
|
import { Route, Protocol } from "@opencode-ai/llm/route"
|
||||||
import { Provider as ProviderSubpath } from "@opencode-ai/llm/provider"
|
import { Provider as ProviderSubpath } from "@opencode-ai/llm/provider"
|
||||||
|
import { ProviderPackage as ProviderPackageSubpath } from "@opencode-ai/llm/provider-package"
|
||||||
import {
|
import {
|
||||||
CloudflareAIGateway,
|
CloudflareAIGateway,
|
||||||
CloudflareWorkersAI,
|
CloudflareWorkersAI,
|
||||||
OpenAI,
|
OpenAI,
|
||||||
|
OpenAICodex,
|
||||||
OpenAICompatible,
|
OpenAICompatible,
|
||||||
OpenRouter,
|
OpenRouter,
|
||||||
XAI,
|
XAI,
|
||||||
|
|
@ -21,6 +23,8 @@ describe("public exports", () => {
|
||||||
expect(LLMClient.layer).toBeDefined()
|
expect(LLMClient.layer).toBeDefined()
|
||||||
expect(Provider.make).toBeFunction()
|
expect(Provider.make).toBeFunction()
|
||||||
expect(ProviderSubpath.make).toBe(Provider.make)
|
expect(ProviderSubpath.make).toBe(Provider.make)
|
||||||
|
expect(ProviderPackage.define).toBeFunction()
|
||||||
|
expect(ProviderPackageSubpath.define).toBe(ProviderPackage.define)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("route barrel exposes route-authoring APIs", () => {
|
test("route barrel exposes route-authoring APIs", () => {
|
||||||
|
|
@ -30,11 +34,12 @@ describe("public exports", () => {
|
||||||
|
|
||||||
test("provider barrels expose user-facing facades", () => {
|
test("provider barrels expose user-facing facades", () => {
|
||||||
expect(OpenAI.model).toBeFunction()
|
expect(OpenAI.model).toBeFunction()
|
||||||
expect(OpenAI.provider.model).toBe(OpenAI.model)
|
|
||||||
expect(OpenAI.provider.responses).toBe(OpenAI.responses)
|
|
||||||
expect(OpenAI.provider.responsesWebSocket).toBe(OpenAI.responsesWebSocket)
|
|
||||||
expect(OpenAI.configure({ apiKey: "fixture" }).responses).toBeFunction()
|
expect(OpenAI.configure({ apiKey: "fixture" }).responses).toBeFunction()
|
||||||
expect(OpenAICompatible.deepseek.model).toBeFunction()
|
expect(OpenAICodex.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).toBeFunction()
|
||||||
expect(CloudflareAIGateway.configure({ accountId: "fixture", gatewayApiKey: "fixture" }).model).toBeFunction()
|
expect(CloudflareAIGateway.configure({ accountId: "fixture", gatewayApiKey: "fixture" }).model).toBeFunction()
|
||||||
expect(CloudflareWorkersAI.configure).toBeFunction()
|
expect(CloudflareWorkersAI.configure).toBeFunction()
|
||||||
|
|
|
||||||
|
|
@ -39,17 +39,21 @@ const cloudflareAIGatewayWorkers = cloudflareAIGateway.model("workers-ai/@cf/met
|
||||||
const cloudflareAIGatewayWorkersTools = cloudflareAIGateway.model("workers-ai/@cf/openai/gpt-oss-20b")
|
const cloudflareAIGatewayWorkersTools = cloudflareAIGateway.model("workers-ai/@cf/openai/gpt-oss-20b")
|
||||||
const cloudflareWorkersAI = cloudflareWorkers.model("@cf/meta/llama-3.1-8b-instruct")
|
const cloudflareWorkersAI = cloudflareWorkers.model("@cf/meta/llama-3.1-8b-instruct")
|
||||||
const cloudflareWorkersAITools = cloudflareWorkers.model("@cf/openai/gpt-oss-20b")
|
const cloudflareWorkersAITools = cloudflareWorkers.model("@cf/openai/gpt-oss-20b")
|
||||||
const deepseek = OpenAICompatible.deepseek
|
const deepseek = OpenAICompatible.configure({
|
||||||
.configure({ apiKey: process.env.DEEPSEEK_API_KEY ?? "fixture" })
|
apiKey: process.env.DEEPSEEK_API_KEY ?? "fixture",
|
||||||
.model("deepseek-chat")
|
provider: "deepseek",
|
||||||
const together = OpenAICompatible.togetherai
|
baseURL: "https://api.deepseek.com/v1",
|
||||||
.configure({
|
}).model("deepseek-chat")
|
||||||
|
const together = OpenAICompatible.configure({
|
||||||
apiKey: process.env.TOGETHER_AI_API_KEY ?? "fixture",
|
apiKey: process.env.TOGETHER_AI_API_KEY ?? "fixture",
|
||||||
})
|
provider: "togetherai",
|
||||||
.model("meta-llama/Llama-3.3-70B-Instruct-Turbo")
|
baseURL: "https://api.together.xyz/v1",
|
||||||
const groq = OpenAICompatible.groq
|
}).model("meta-llama/Llama-3.3-70B-Instruct-Turbo")
|
||||||
.configure({ apiKey: process.env.GROQ_API_KEY ?? "fixture" })
|
const groq = OpenAICompatible.configure({
|
||||||
.model("llama-3.3-70b-versatile")
|
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.configure({ apiKey: process.env.OPENROUTER_API_KEY ?? "fixture" })
|
||||||
const openrouter = openRouter.model("openai/gpt-4o-mini")
|
const openrouter = openRouter.model("openai/gpt-4o-mini")
|
||||||
const openrouterGpt55 = openRouter.model("openai/gpt-5.5")
|
const openrouterGpt55 = openRouter.model("openai/gpt-5.5")
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import { Effect, Schema } from "effect"
|
||||||
import { HttpClientRequest } from "effect/unstable/http"
|
import { HttpClientRequest } from "effect/unstable/http"
|
||||||
import { LLM, Message, ToolCallPart } from "../../src"
|
import { LLM, Message, ToolCallPart } from "../../src"
|
||||||
import { Auth, LLMClient } from "../../src/route"
|
import { Auth, LLMClient } from "../../src/route"
|
||||||
import * as OpenAICompatible from "../../src/providers/openai-compatible"
|
|
||||||
import * as OpenAICompatibleChat from "../../src/protocols/openai-compatible-chat"
|
import * as OpenAICompatibleChat from "../../src/protocols/openai-compatible-chat"
|
||||||
import { it } from "../lib/effect"
|
import { it } from "../lib/effect"
|
||||||
import { dynamicResponse } from "../lib/http"
|
import { dynamicResponse } from "../lib/http"
|
||||||
|
|
@ -40,15 +39,6 @@ const usageChunk = (usage: object) => ({
|
||||||
usage,
|
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", () => {
|
describe("OpenAI-compatible Chat route", () => {
|
||||||
it.effect("prepares generic Chat target", () =>
|
it.effect("prepares generic Chat target", () =>
|
||||||
Effect.gen(function* () {
|
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", () =>
|
it.effect("matches AI SDK compatible basic request body fixture", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const prepared = yield* LLMClient.prepare(request)
|
const prepared = yield* LLMClient.prepare(request)
|
||||||
|
|
|
||||||
156
packages/llm/test/provider/provider-package.test.ts
Normal file
156
packages/llm/test/provider/provider-package.test.ts
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
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, OpenAICompatible } from "../../src/providers"
|
||||||
|
import { it } from "../lib/effect"
|
||||||
|
import { dynamicResponse } from "../lib/http"
|
||||||
|
import { sseEvents } from "../lib/sse"
|
||||||
|
|
||||||
|
const JsonRecord = Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown))
|
||||||
|
const decodeJsonRecord = Schema.decodeUnknownSync(JsonRecord)
|
||||||
|
|
||||||
|
const requestFor = (model: ReturnType<typeof OpenAI.model>) =>
|
||||||
|
LLM.request({
|
||||||
|
model,
|
||||||
|
prompt: "Say hello.",
|
||||||
|
cache: "none",
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("provider package contract", () => {
|
||||||
|
it.effect("builds OpenAI Responses models from flat settings", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* LLMClient.generate(
|
||||||
|
requestFor(
|
||||||
|
OpenAI.model("gpt-x", {
|
||||||
|
apiKey: "sk-test",
|
||||||
|
headers: { "x-package": "openai" },
|
||||||
|
body: { metadata: { source: "package" } },
|
||||||
|
providerOptions: { store: true },
|
||||||
|
limits: { context: 100, output: 20 },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).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.headers.get("authorization")).toBe("Bearer sk-test")
|
||||||
|
expect(web.headers.get("x-package")).toBe("openai")
|
||||||
|
expect(body).toMatchObject({
|
||||||
|
model: "gpt-x",
|
||||||
|
metadata: { source: "package" },
|
||||||
|
store: true,
|
||||||
|
stream: true,
|
||||||
|
})
|
||||||
|
expect(body).not.toHaveProperty("apiKey")
|
||||||
|
return input.respond(sseEvents({ type: "response.completed", response: {} }), {
|
||||||
|
headers: { "content-type": "text/event-stream" },
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("builds Codex models against the ChatGPT Codex endpoint with optional account header", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* LLMClient.generate(
|
||||||
|
requestFor(OpenAICodex.model("gpt-5-codex", { apiKey: "oauth-token", accountID: "account-123" })),
|
||||||
|
).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
dynamicResponse((input) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
|
||||||
|
|
||||||
|
expect(web.url.startsWith("https://chatgpt.com/backend-api/codex")).toBe(true)
|
||||||
|
expect(web.headers.get("authorization")).toBe("Bearer oauth-token")
|
||||||
|
expect(web.headers.get("chatgpt-account-id")).toBe("account-123")
|
||||||
|
return input.respond(sseEvents({ type: "response.completed", response: {} }), {
|
||||||
|
headers: { "content-type": "text/event-stream" },
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
yield* LLMClient.generate(requestFor(OpenAICodex.model("gpt-5-codex", { apiKey: "oauth-token" }))).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
dynamicResponse((input) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
|
||||||
|
|
||||||
|
expect(web.url.startsWith("https://chatgpt.com/backend-api/codex")).toBe(true)
|
||||||
|
expect(web.headers.get("authorization")).toBe("Bearer oauth-token")
|
||||||
|
expect(web.headers.get("chatgpt-account-id")).toBeNull()
|
||||||
|
return input.respond(sseEvents({ type: "response.completed", response: {} }), {
|
||||||
|
headers: { "content-type": "text/event-stream" },
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("builds Anthropic Messages models with x-api-key auth", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* LLMClient.generate(requestFor(Anthropic.model("claude-x", { apiKey: "anthropic-key" }))).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.headers.get("x-api-key")).toBe("anthropic-key")
|
||||||
|
expect(body).toMatchObject({ model: "claude-x", stream: true })
|
||||||
|
return input.respond(
|
||||||
|
sseEvents({ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } }),
|
||||||
|
{ headers: { "content-type": "text/event-stream" } },
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
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" },
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
@ -34,6 +34,7 @@ export const AISDK = Schema.Struct({
|
||||||
export interface Native extends Schema.Schema.Type<typeof Native> {}
|
export interface Native extends Schema.Schema.Type<typeof Native> {}
|
||||||
export const Native = Schema.Struct({
|
export const Native = Schema.Struct({
|
||||||
type: Schema.Literal("native"),
|
type: Schema.Literal("native"),
|
||||||
|
package: Schema.String.pipe(optional),
|
||||||
url: Schema.String.pipe(optional),
|
url: Schema.String.pipe(optional),
|
||||||
settings: Schema.Record(Schema.String, Schema.Unknown),
|
settings: Schema.Record(Schema.String, Schema.Unknown),
|
||||||
}).annotate({ identifier: "Provider.Native" })
|
}).annotate({ identifier: "Provider.Native" })
|
||||||
|
|
|
||||||
|
|
@ -4779,6 +4779,7 @@ export type ModelApi =
|
||||||
| {
|
| {
|
||||||
id: string
|
id: string
|
||||||
type: "native"
|
type: "native"
|
||||||
|
package?: string
|
||||||
url?: string
|
url?: string
|
||||||
settings: {
|
settings: {
|
||||||
[key: string]: unknown
|
[key: string]: unknown
|
||||||
|
|
@ -4853,6 +4854,7 @@ export type ProviderAisdk = {
|
||||||
|
|
||||||
export type ProviderNative = {
|
export type ProviderNative = {
|
||||||
type: "native"
|
type: "native"
|
||||||
|
package?: string
|
||||||
url?: string
|
url?: string
|
||||||
settings: {
|
settings: {
|
||||||
[key: string]: unknown
|
[key: string]: unknown
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue