feat(core): flatten provider config and load native packages (#35563)
Co-authored-by: Dax Raad <d@ironbay.co>
This commit is contained in:
parent
d603eed5a1
commit
e57d9ca390
107 changed files with 2593 additions and 1984 deletions
|
|
@ -213,7 +213,10 @@ const layer = Layer.effect(
|
|||
]
|
||||
.filter((part): part is string => part !== undefined && part.length > 0)
|
||||
.map(SystemPart.make),
|
||||
messages: [...toLLMMessages(context, model), ...(isLastStep ? [Message.assistant(MAX_STEPS_PROMPT)] : [])],
|
||||
messages: [
|
||||
...toLLMMessages(context, resolved.ref),
|
||||
...(isLastStep ? [Message.assistant(MAX_STEPS_PROMPT)] : []),
|
||||
],
|
||||
tools: toolMaterialization?.definitions ?? [],
|
||||
toolChoice: isLastStep ? "none" : undefined,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
export * as SessionRunnerModel from "./model"
|
||||
|
||||
import { makeLocationNode } from "../../effect/app-node"
|
||||
import { type Model } from "@opencode-ai/llm"
|
||||
import { Model } from "@opencode-ai/llm"
|
||||
// ast-grep-ignore: no-star-import
|
||||
import * as AnthropicMessages from "@opencode-ai/llm/protocols/anthropic-messages"
|
||||
// ast-grep-ignore: no-star-import
|
||||
|
|
@ -11,10 +11,12 @@ 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 { AISDK } from "../../aisdk"
|
||||
import { Catalog } from "../../catalog"
|
||||
import { Credential } from "../../credential"
|
||||
import { Integration } from "../../integration"
|
||||
import { ModelV2 } from "../../model"
|
||||
import { Npm } from "../../npm"
|
||||
import { OpenAICodex } from "../../plugin/provider/openai-codex"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
import { SessionSchema } from "../schema"
|
||||
|
|
@ -55,16 +57,16 @@ export class VariantUnavailableError extends Schema.TaggedErrorClass<VariantUnav
|
|||
}
|
||||
}
|
||||
|
||||
export class UnsupportedApiError extends Schema.TaggedErrorClass<UnsupportedApiError>()(
|
||||
"SessionRunnerModel.UnsupportedApiError",
|
||||
export class UnsupportedPackageError extends Schema.TaggedErrorClass<UnsupportedPackageError>()(
|
||||
"SessionRunnerModel.UnsupportedPackageError",
|
||||
{
|
||||
providerID: ProviderV2.ID,
|
||||
modelID: ModelV2.ID,
|
||||
api: Schema.String,
|
||||
package: Schema.String,
|
||||
},
|
||||
) {
|
||||
override get message() {
|
||||
return `Unsupported API for ${this.providerID}/${this.modelID}: ${this.api}`
|
||||
return `Unsupported package for ${this.providerID}/${this.modelID}: ${this.package}`
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -72,7 +74,7 @@ export type Error =
|
|||
| ModelNotSelectedError
|
||||
| ModelUnavailableError
|
||||
| VariantUnavailableError
|
||||
| UnsupportedApiError
|
||||
| UnsupportedPackageError
|
||||
| Integration.AuthorizationError
|
||||
|
||||
export interface Resolved {
|
||||
|
|
@ -104,41 +106,50 @@ export const resolved = (model: Model, variant?: ModelV2.VariantID): Resolved =>
|
|||
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
|
||||
const value = model.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({
|
||||
const withDefaults = (model: ModelV2.Info, route: AnyRoute) =>
|
||||
route.with({
|
||||
provider: model.providerID,
|
||||
endpoint: model.api.url === undefined ? undefined : { baseURL: model.api.url },
|
||||
headers: model.request.headers,
|
||||
endpoint: typeof model.settings?.baseURL === "string" ? { baseURL: model.settings.baseURL } : undefined,
|
||||
headers: providerHeaders(model),
|
||||
providerOptions: providerOptions(model),
|
||||
http: { body: httpBody },
|
||||
http: model.body === undefined ? undefined : { body: model.body },
|
||||
limits: { context: model.limit.context, output: model.limit.output },
|
||||
})
|
||||
|
||||
const providerHeaders = (model: ModelV2.Info) => {
|
||||
const packageName = ProviderV2.packageName(model.package)
|
||||
const generated = new Map<string, string>()
|
||||
if (packageName === "@ai-sdk/openai" && typeof model.settings?.organization === "string")
|
||||
generated.set("OpenAI-Organization", model.settings.organization)
|
||||
if (packageName === "@ai-sdk/openai" && typeof model.settings?.project === "string")
|
||||
generated.set("OpenAI-Project", model.settings.project)
|
||||
if (packageName === "@ai-sdk/anthropic" && typeof model.settings?.authToken === "string")
|
||||
generated.set("Authorization", `Bearer ${model.settings.authToken}`)
|
||||
return ProviderV2.mergeHeaders(generated.size === 0 ? undefined : Object.fromEntries(generated), model.headers)
|
||||
}
|
||||
|
||||
const providerOptions = (
|
||||
model: ModelV2.Info,
|
||||
): { readonly [key: string]: { readonly [key: string]: unknown } } | undefined => {
|
||||
if (Object.keys(model.request.settings).length === 0) return undefined
|
||||
if (model.api.type !== "aisdk") return undefined
|
||||
if (model.api.package === "@ai-sdk/openai") return { openai: model.request.settings }
|
||||
if (model.api.package === "@ai-sdk/anthropic") return { anthropic: model.request.settings }
|
||||
if (model.api.package === "@ai-sdk/openai-compatible") return { openai: model.request.settings }
|
||||
if (!ProviderV2.isAISDK(model.package) || model.settings === undefined) return undefined
|
||||
const { apiKey: _, baseURL: _baseURL, ...settings } = model.settings
|
||||
if (Object.keys(settings).length === 0) return undefined
|
||||
const packageName = ProviderV2.packageName(model.package)
|
||||
if (packageName === "@ai-sdk/openai") return { openai: settings }
|
||||
if (packageName === "@ai-sdk/anthropic") return { anthropic: settings }
|
||||
if (packageName === "@ai-sdk/openai-compatible") return { openai: settings }
|
||||
}
|
||||
|
||||
export const withVariant = (
|
||||
model: ModelV2.Info,
|
||||
variantID: ModelV2.VariantID | undefined,
|
||||
): Effect.Effect<ModelV2.Info, VariantUnavailableError> => {
|
||||
const id = variantID === "default" || variantID === undefined ? model.request.variant : variantID
|
||||
const variant = model.variants.find((item) => item.id === id)
|
||||
const id = variantID === "default" ? undefined : variantID
|
||||
const variant = model.variants?.find((item) => item.id === id)
|
||||
if (!variant && variantID !== undefined && variantID !== "default")
|
||||
return Effect.fail(
|
||||
new VariantUnavailableError({
|
||||
|
|
@ -150,81 +161,140 @@ export const withVariant = (
|
|||
return Effect.succeed(
|
||||
variant
|
||||
? produce(model, (draft) => {
|
||||
Object.assign(draft.request.settings, variant.settings)
|
||||
Object.assign(draft.request.headers, variant.headers)
|
||||
Object.assign(draft.request.body, variant.body)
|
||||
draft.settings = ProviderV2.mergeOverlay(draft.settings, variant.settings)
|
||||
draft.headers = ProviderV2.mergeHeaders(draft.headers, variant.headers)
|
||||
draft.body = ProviderV2.mergeOverlay(draft.body, variant.body)
|
||||
})
|
||||
: model,
|
||||
)
|
||||
}
|
||||
|
||||
const apiName = (model: ModelV2.Info) =>
|
||||
model.api.type === "aisdk" ? `${model.api.type}:${model.api.package}` : model.api.type
|
||||
export interface Dependencies {
|
||||
readonly loadPackage?: (specifier: string) => Effect.Effect<ProviderV2.ProviderPackage, ProviderV2.LoadError>
|
||||
readonly loadAISDK?: (model: ModelV2.Info) => Effect.Effect<Model, AISDK.InitError>
|
||||
}
|
||||
|
||||
export const fromCatalogModel = (
|
||||
model: ModelV2.Info,
|
||||
credential?: Credential.Value,
|
||||
): Effect.Effect<Model, UnsupportedApiError> => {
|
||||
dependencies: Dependencies = {},
|
||||
): Effect.Effect<Model, UnsupportedPackageError> => {
|
||||
const resolved =
|
||||
credential?.type !== "key" || credential.metadata === undefined
|
||||
? model
|
||||
: produce(model, (draft) => {
|
||||
Object.assign(draft.request.body, credential.metadata)
|
||||
draft.body = ProviderV2.mergeOverlay(draft.body, credential.metadata)
|
||||
})
|
||||
const packageName = ProviderV2.packageName(resolved.package)
|
||||
const key = apiKey(resolved, credential)
|
||||
if (resolved.api.type === "aisdk" && resolved.api.package === "@ai-sdk/openai") {
|
||||
// ChatGPT-plan OAuth tokens are not API-key credentials: the public API rejects
|
||||
// them, so requests must target the codex backend with the account header.
|
||||
if (OpenAICodex.isChatGPT(credential)) {
|
||||
const account = OpenAICodex.accountID(credential)
|
||||
return Effect.succeed(
|
||||
withDefaults(resolved, OpenAIResponses.route)
|
||||
.with({
|
||||
endpoint: { baseURL: OpenAICodex.baseURL },
|
||||
auth: (key === undefined ? Auth.none : Auth.bearer(key)).andThen(
|
||||
account === undefined ? Auth.none : Auth.headers({ "chatgpt-account-id": account }),
|
||||
),
|
||||
})
|
||||
.model({ id: resolved.api.id }),
|
||||
)
|
||||
}
|
||||
|
||||
if (
|
||||
OpenAICodex.isChatGPT(credential) &&
|
||||
!ProviderV2.isAISDK(resolved.package) &&
|
||||
isNativeOpenAI(resolved.package)
|
||||
) {
|
||||
return Effect.succeed(codexModel(resolved, credential, key))
|
||||
}
|
||||
|
||||
if (ProviderV2.isAISDK(resolved.package) && packageName === "@ai-sdk/openai") {
|
||||
if (OpenAICodex.isChatGPT(credential)) return Effect.succeed(codexModel(resolved, credential, key))
|
||||
return Effect.succeed(
|
||||
withDefaults(resolved, OpenAIResponses.route)
|
||||
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
|
||||
.model({ id: resolved.api.id }),
|
||||
.model({ id: resolved.modelID ?? resolved.id }),
|
||||
)
|
||||
}
|
||||
if (resolved.api.type === "aisdk" && resolved.api.package === "@ai-sdk/anthropic") {
|
||||
if (ProviderV2.isAISDK(resolved.package) && packageName === "@ai-sdk/anthropic") {
|
||||
return Effect.succeed(
|
||||
withDefaults(resolved, AnthropicMessages.route)
|
||||
.with({ auth: key === undefined ? Auth.none : Auth.header("x-api-key", key) })
|
||||
.model({ id: resolved.api.id }),
|
||||
.model({ id: resolved.modelID ?? resolved.id }),
|
||||
)
|
||||
}
|
||||
if (resolved.api.type === "aisdk" && resolved.api.package === "@ai-sdk/openai-compatible" && resolved.api.url) {
|
||||
if (
|
||||
ProviderV2.isAISDK(resolved.package) &&
|
||||
packageName === "@ai-sdk/openai-compatible" &&
|
||||
typeof resolved.settings?.baseURL === "string"
|
||||
) {
|
||||
return Effect.succeed(
|
||||
withDefaults(resolved, OpenAICompatibleChat.route)
|
||||
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
|
||||
.model({ id: resolved.api.id }),
|
||||
.model({ id: resolved.modelID ?? resolved.id }),
|
||||
)
|
||||
}
|
||||
return Effect.fail(
|
||||
new UnsupportedApiError({
|
||||
providerID: resolved.providerID,
|
||||
modelID: resolved.id,
|
||||
api: apiName(resolved),
|
||||
}),
|
||||
)
|
||||
if (ProviderV2.isAISDK(resolved.package)) {
|
||||
if (!dependencies.loadAISDK) return Effect.fail(unsupported(resolved))
|
||||
const runtime = produce(resolved, (draft) => {
|
||||
draft.settings = ProviderV2.mergeOverlay(draft.settings, {
|
||||
...(credential?.type === "key" ? { apiKey: credential.key } : {}),
|
||||
...(credential?.type === "oauth" ? { apiKey: credential.access } : {}),
|
||||
...credential?.metadata,
|
||||
})
|
||||
})
|
||||
return dependencies.loadAISDK(runtime).pipe(Effect.mapError(() => unsupported(resolved)))
|
||||
}
|
||||
if (!resolved.package) return Effect.fail(unsupported(resolved))
|
||||
|
||||
const specifier = resolved.package
|
||||
return Effect.gen(function* () {
|
||||
const module = yield* (dependencies.loadPackage ?? ProviderV2.loadPackage)(specifier).pipe(
|
||||
Effect.mapError(() => unsupported(resolved)),
|
||||
)
|
||||
const settings = {
|
||||
...resolved.settings,
|
||||
...(credential?.type === "key" ? { apiKey: credential.key } : {}),
|
||||
...(credential?.type === "oauth" ? { apiKey: credential.access } : {}),
|
||||
...credential?.metadata,
|
||||
headers: resolved.headers,
|
||||
body: resolved.body,
|
||||
limits: { context: resolved.limit.context, output: resolved.limit.output },
|
||||
}
|
||||
return yield* Effect.try({
|
||||
try: () =>
|
||||
Model.update(module.model(resolved.modelID ?? resolved.id, settings), { provider: resolved.providerID }),
|
||||
catch: () => unsupported(resolved),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export const resolve = (session: SessionSchema.Info, model: ModelV2.Info, credential?: Credential.Value) =>
|
||||
withVariant(model, session.model?.variant).pipe(Effect.flatMap((model) => fromCatalogModel(model, credential)))
|
||||
const isNativeOpenAI = (packageName: string | undefined) =>
|
||||
packageName === "@opencode-ai/llm/providers/openai" ||
|
||||
packageName?.startsWith("@opencode-ai/llm/providers/openai/") === true
|
||||
|
||||
export const supported = (model: ModelV2.Info) =>
|
||||
model.api.type === "aisdk" &&
|
||||
(model.api.package === "@ai-sdk/openai" ||
|
||||
model.api.package === "@ai-sdk/anthropic" ||
|
||||
(model.api.package === "@ai-sdk/openai-compatible" && model.api.url !== undefined))
|
||||
const codexModel = (
|
||||
model: ModelV2.Info,
|
||||
credential: Credential.Value | undefined,
|
||||
key: ReturnType<typeof Auth.value> | undefined,
|
||||
) => {
|
||||
const account = OpenAICodex.accountID(credential)
|
||||
return withDefaults(model, OpenAIResponses.route)
|
||||
.with({
|
||||
endpoint: { baseURL: OpenAICodex.baseURL },
|
||||
auth: (key === undefined ? Auth.none : Auth.bearer(key)).andThen(
|
||||
account === undefined ? Auth.none : Auth.headers({ "chatgpt-account-id": account }),
|
||||
),
|
||||
})
|
||||
.model({ id: model.modelID ?? model.id })
|
||||
}
|
||||
|
||||
const unsupported = (model: ModelV2.Info) =>
|
||||
new UnsupportedPackageError({
|
||||
providerID: model.providerID,
|
||||
modelID: model.id,
|
||||
package: model.package ?? "unknown",
|
||||
})
|
||||
|
||||
export const resolve = (
|
||||
session: SessionSchema.Info,
|
||||
model: ModelV2.Info,
|
||||
credential?: Credential.Value,
|
||||
dependencies?: Dependencies,
|
||||
) =>
|
||||
withVariant(model, session.model?.variant).pipe(
|
||||
Effect.flatMap((model) => fromCatalogModel(model, credential, dependencies)),
|
||||
)
|
||||
|
||||
export const supported = (model: ModelV2.Info) => Boolean(model.package)
|
||||
|
||||
/** Resolves models from the catalog belonging to the current Location runtime. */
|
||||
const layer = Layer.effect(
|
||||
|
|
@ -232,6 +302,8 @@ const layer = Layer.effect(
|
|||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const integrations = yield* Integration.Service
|
||||
const npm = yield* Npm.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
return Service.of({
|
||||
resolve: Effect.fn("SessionRunnerModel.resolve")(function* (session) {
|
||||
// Location plugins populate and filter the catalog asynchronously during layer startup.
|
||||
|
|
@ -257,6 +329,10 @@ const layer = Layer.effect(
|
|||
session,
|
||||
selected,
|
||||
connection ? yield* integrations.connection.resolve(connection) : undefined,
|
||||
{
|
||||
loadPackage: (specifier) => ProviderV2.loadPackage(specifier, npm),
|
||||
loadAISDK: (model) => aisdk.model(model),
|
||||
},
|
||||
)
|
||||
return {
|
||||
model,
|
||||
|
|
@ -271,4 +347,8 @@ const layer = Layer.effect(
|
|||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [Catalog.node, Integration.node] })
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [Catalog.node, Integration.node, Npm.node, AISDK.node],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -4,10 +4,10 @@ import {
|
|||
ToolOutput,
|
||||
ToolResultPart,
|
||||
type ContentPart,
|
||||
type Model,
|
||||
type ProviderMetadata,
|
||||
} from "@opencode-ai/llm"
|
||||
import { Option, Schema } from "effect"
|
||||
import type { ModelV2 } from "../../model"
|
||||
import { SessionMessage } from "../message"
|
||||
import type { FileAttachment } from "@opencode-ai/schema/prompt"
|
||||
|
||||
|
|
@ -88,9 +88,9 @@ const toolResult = (tool: SessionMessage.AssistantTool, providerMetadata: Provid
|
|||
}
|
||||
}
|
||||
|
||||
const assistant = (message: SessionMessage.Assistant, model: Model) => {
|
||||
const assistant = (message: SessionMessage.Assistant, model: ModelV2.Ref) => {
|
||||
const sameModel =
|
||||
String(message.model.providerID) === String(model.provider) && String(message.model.id) === String(model.id)
|
||||
String(message.model.providerID) === String(model.providerID) && String(message.model.id) === String(model.id)
|
||||
const reuseProviderMetadata = sameModel && message.error === undefined
|
||||
const content = message.content.flatMap((item): ContentPart[] => {
|
||||
if (item.type === "text") return [{ type: "text", text: item.text }]
|
||||
|
|
@ -133,7 +133,7 @@ const assistant = (message: SessionMessage.Assistant, model: Model) => {
|
|||
]
|
||||
}
|
||||
|
||||
function toLLMMessage(message: SessionMessage.Message, model: Model): Message[] {
|
||||
function toLLMMessage(message: SessionMessage.Message, model: ModelV2.Ref): Message[] {
|
||||
switch (message.type) {
|
||||
case "agent-switched":
|
||||
case "model-switched":
|
||||
|
|
@ -197,5 +197,5 @@ ${message.recent}
|
|||
}
|
||||
|
||||
/** Translate projected V2 Session history into canonical @opencode-ai/llm context. */
|
||||
export const toLLMMessages = (messages: readonly SessionMessage.Message[], model: Model) =>
|
||||
export const toLLMMessages = (messages: readonly SessionMessage.Message[], model: ModelV2.Ref) =>
|
||||
messages.flatMap((message) => toLLMMessage(message, model))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue