refactor(core): derive catalog availability from integrations (#32272)
This commit is contained in:
parent
4810df0a71
commit
0cf3ee4406
29 changed files with 622 additions and 509 deletions
|
|
@ -10,8 +10,7 @@ import { Location } from "./location"
|
|||
import { EventV2 } from "./event"
|
||||
import { Policy } from "./policy"
|
||||
import { State } from "./state"
|
||||
import { Credential } from "./credential"
|
||||
import { IntegrationSchema } from "./integration/schema"
|
||||
import { Integration } from "./integration"
|
||||
|
||||
export type ProviderRecord = {
|
||||
provider: ProviderV2.Info
|
||||
|
|
@ -35,12 +34,7 @@ export class ModelNotFoundError extends Schema.TaggedErrorClass<ModelNotFoundErr
|
|||
export const PolicyActions = Schema.Literals(["provider.use"])
|
||||
|
||||
export const Event = {
|
||||
ModelUpdated: EventV2.define({
|
||||
type: "catalog.model.updated",
|
||||
schema: {
|
||||
model: ModelV2.Info,
|
||||
},
|
||||
}),
|
||||
Updated: EventV2.define({ type: "catalog.updated", schema: {} }),
|
||||
}
|
||||
|
||||
type Data = {
|
||||
|
|
@ -96,26 +90,21 @@ export const layer = Layer.effect(
|
|||
const plugin = yield* PluginV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
const policy = yield* Policy.Service
|
||||
const credentials = yield* Credential.Service
|
||||
const integrations = yield* Integration.Service
|
||||
const scope = yield* Scope.Scope
|
||||
|
||||
const project = (provider: ProviderV2.Info, active: Map<IntegrationSchema.ID, Credential.Stored>) => {
|
||||
const credential = active.get(IntegrationSchema.ID.make(provider.id))
|
||||
if (!credential) return provider
|
||||
const body = { ...provider.request.body }
|
||||
if (credential.value.type === "key") {
|
||||
body.apiKey = credential.value.key
|
||||
Object.assign(body, credential.value.metadata ?? {})
|
||||
}
|
||||
if (credential.value.type === "oauth") body.apiKey = credential.value.access
|
||||
return new ProviderV2.Info({
|
||||
...provider,
|
||||
enabled: { via: "credential", credentialID: credential.id },
|
||||
request: { ...provider.request, body },
|
||||
})
|
||||
const available = (
|
||||
provider: ProviderV2.Info,
|
||||
integration: Integration.Info | undefined,
|
||||
connected: boolean,
|
||||
) => {
|
||||
if (provider.disabled) return false
|
||||
if (typeof provider.request.body.apiKey === "string") return true
|
||||
if (connected) return true
|
||||
return !integration
|
||||
}
|
||||
|
||||
const resolve = (model: ModelV2.Info, provider: ProviderV2.Info) => {
|
||||
const projectModel = (model: ModelV2.Info, provider: ProviderV2.Info) => {
|
||||
const api =
|
||||
model.api.type === "native" && !model.api.url && Object.keys(model.api.settings).length === 0
|
||||
? { ...provider.api, id: model.api.id }
|
||||
|
|
@ -203,18 +192,16 @@ export const layer = Layer.effect(
|
|||
},
|
||||
finalize: Effect.fn("CatalogV2.finalize")(function* (catalog, reason) {
|
||||
if (reason !== "plugin.added") yield* plugin.trigger("catalog.transform", catalog, {}).pipe(Effect.asVoid)
|
||||
if (!policy.hasStatements()) return
|
||||
for (const record of [...catalog.provider.list()]) {
|
||||
if ((yield* policy.evaluate("provider.use", record.provider.id, "allow")) === "deny") {
|
||||
catalog.provider.remove(record.provider.id)
|
||||
if (policy.hasStatements()) {
|
||||
for (const record of [...catalog.provider.list()]) {
|
||||
if ((yield* policy.evaluate("provider.use", record.provider.id, "allow")) === "deny") {
|
||||
catalog.provider.remove(record.provider.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
yield* events.publish(Event.Updated, {})
|
||||
}),
|
||||
})
|
||||
const active = Effect.fn("CatalogV2.active")(function* () {
|
||||
return new Map((yield* credentials.all()).map((credential) => [credential.integrationID, credential]))
|
||||
})
|
||||
|
||||
yield* events.subscribe(PluginV2.Event.Added).pipe(
|
||||
// Plugin registries are location scoped even though the event bus is process scoped.
|
||||
Stream.filter(
|
||||
|
|
@ -233,18 +220,23 @@ export const layer = Layer.effect(
|
|||
provider: {
|
||||
get: Effect.fn("CatalogV2.provider.get")(function* (providerID) {
|
||||
const record = yield* getRecord(providerID)
|
||||
return project(record.provider, yield* active())
|
||||
return record.provider
|
||||
}),
|
||||
|
||||
all: Effect.fn("CatalogV2.provider.all")(function* () {
|
||||
const credentials = yield* active()
|
||||
return Array.fromIterable(state.get().providers.values()).map((record) =>
|
||||
project(record.provider, credentials),
|
||||
)
|
||||
return Array.fromIterable(state.get().providers.values()).map((record) => record.provider)
|
||||
}),
|
||||
|
||||
available: Effect.fn("CatalogV2.provider.available")(function* () {
|
||||
return (yield* result.provider.all()).filter((provider) => provider.enabled)
|
||||
const active = new Map((yield* integrations.list()).map((integration) => [integration.id, integration]))
|
||||
const connections = yield* integrations.connection.list()
|
||||
return (yield* result.provider.all()).filter((provider) =>
|
||||
available(
|
||||
provider,
|
||||
active.get(Integration.ID.make(provider.id)),
|
||||
connections.has(Integration.ID.make(provider.id)),
|
||||
),
|
||||
)
|
||||
}),
|
||||
},
|
||||
|
||||
|
|
@ -253,33 +245,32 @@ export const layer = Layer.effect(
|
|||
const record = yield* getRecord(providerID)
|
||||
const model = record.models.get(modelID)
|
||||
if (!model) return yield* new ModelNotFoundError({ providerID, modelID })
|
||||
return resolve(model, project(record.provider, yield* active()))
|
||||
return projectModel(model, record.provider)
|
||||
}),
|
||||
|
||||
all: Effect.fn("CatalogV2.model.all")(function* () {
|
||||
const credentials = yield* active()
|
||||
return pipe(
|
||||
Array.fromIterable(state.get().providers.values()),
|
||||
Array.flatMap((record) => {
|
||||
const provider = project(record.provider, credentials)
|
||||
return Array.fromIterable(record.models.values()).map((model) => resolve(model, provider))
|
||||
return Array.fromIterable(record.models.values()).map((model) => projectModel(model, record.provider))
|
||||
}),
|
||||
Array.sortWith((item) => item.time.released.epochMilliseconds, Order.flip(Order.Number)),
|
||||
)
|
||||
}),
|
||||
|
||||
available: Effect.fn("CatalogV2.model.available")(function* () {
|
||||
const providers = new Map((yield* result.provider.all()).map((provider) => [provider.id, provider]))
|
||||
return (yield* result.model.all()).filter(
|
||||
(model) => providers.get(model.providerID)?.enabled !== false && model.enabled,
|
||||
)
|
||||
const providers = new Set((yield* result.provider.available()).map((provider) => provider.id))
|
||||
return (yield* result.model.all()).filter((model) => providers.has(model.providerID) && model.enabled)
|
||||
}),
|
||||
|
||||
default: Effect.fn("CatalogV2.model.default")(function* () {
|
||||
const defaultModel = state.get().defaultModel
|
||||
if (defaultModel) {
|
||||
const provider = yield* result.provider.get(defaultModel.providerID).pipe(Effect.option)
|
||||
if (Option.isSome(provider) && provider.value.enabled !== false) {
|
||||
if (
|
||||
Option.isSome(provider) &&
|
||||
(yield* result.provider.available()).some((item) => item.id === provider.value.id)
|
||||
) {
|
||||
const model = yield* result.model.get(defaultModel.providerID, defaultModel.modelID).pipe(Effect.option)
|
||||
if (Option.isSome(model) && model.value.enabled) return model
|
||||
}
|
||||
|
|
@ -295,11 +286,11 @@ export const layer = Layer.effect(
|
|||
small: Effect.fn("CatalogV2.model.small")(function* (providerID) {
|
||||
const record = state.get().providers.get(providerID)
|
||||
if (!record) return Option.none<ModelV2.Info>()
|
||||
const provider = project(record.provider, yield* active())
|
||||
const provider = record.provider
|
||||
|
||||
if (providerID === ProviderV2.ID.opencode) {
|
||||
const gpt5Nano = record.models.get(ModelV2.ID.make("gpt-5-nano"))
|
||||
if (gpt5Nano?.enabled && gpt5Nano.status === "active") return Option.some(resolve(gpt5Nano, provider))
|
||||
if (gpt5Nano?.enabled && gpt5Nano.status === "active") return Option.some(projectModel(gpt5Nano, provider))
|
||||
}
|
||||
|
||||
const candidates = pipe(
|
||||
|
|
@ -327,7 +318,7 @@ export const layer = Layer.effect(
|
|||
return pipe(
|
||||
items,
|
||||
Array.sortWith((item) => (item.cost / maxCost) * 0.8 + (item.age / maxAge) * 0.2, Order.Number),
|
||||
Array.map((item) => resolve(item.model, provider)),
|
||||
Array.map((item) => projectModel(item.model, provider)),
|
||||
Array.head,
|
||||
)
|
||||
}
|
||||
|
|
@ -348,6 +339,7 @@ export const layer = Layer.effect(
|
|||
const SMALL_MODEL_RE = /\b(nano|flash|lite|mini|haiku|small|fast)\b/
|
||||
|
||||
export const locationLayer = layer.pipe(
|
||||
Layer.provideMerge(Integration.locationLayer),
|
||||
Layer.provideMerge(PluginV2.locationLayer),
|
||||
Layer.provideMerge(Policy.locationLayer),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ export * as ConfigProviderPlugin from "./provider"
|
|||
import { Effect } from "effect"
|
||||
import { Catalog } from "../../catalog"
|
||||
import { Config } from "../../config"
|
||||
import { Integration } from "../../integration"
|
||||
import { ModelV2 } from "../../model"
|
||||
import { ModelRequest } from "../../model-request"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
|
|
@ -13,9 +14,33 @@ export const Plugin = PluginV2.define({
|
|||
effect: Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const config = yield* Config.Service
|
||||
const integrations = yield* Integration.Service
|
||||
const transform = yield* catalog.transform()
|
||||
const integrationTransform = yield* integrations.transform()
|
||||
const entries = yield* config.entries()
|
||||
const files = entries.filter((entry): entry is Config.Document => entry.type === "document")
|
||||
const configuredIntegrations = new Set(
|
||||
files.flatMap((file) =>
|
||||
Object.entries(file.info.providers ?? {}).flatMap(([id, provider]) => (provider.env === undefined ? [] : [id])),
|
||||
),
|
||||
)
|
||||
yield* integrationTransform((integrations) => {
|
||||
for (const file of files) {
|
||||
for (const [id, item] of Object.entries(file.info.providers ?? {})) {
|
||||
const integrationID = Integration.ID.make(id)
|
||||
if (!configuredIntegrations.has(id) && !integrations.get(integrationID)) continue
|
||||
integrations.update(integrationID, (integration) => {
|
||||
integration.name = item.name ?? integration.name
|
||||
})
|
||||
if (item.env !== undefined) {
|
||||
integrations.method.update({
|
||||
integrationID,
|
||||
method: { type: "env", names: [...item.env] },
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
yield* transform((catalog) => {
|
||||
const configuredDefault = Config.latest(entries, "model")
|
||||
|
|
@ -28,8 +53,6 @@ export const Plugin = PluginV2.define({
|
|||
const providerID = ProviderV2.ID.make(id)
|
||||
catalog.provider.update(providerID, (provider) => {
|
||||
if (item.name !== undefined) provider.name = item.name
|
||||
if (item.env !== undefined) provider.env = [...item.env]
|
||||
provider.enabled = { via: "custom", data: {} }
|
||||
if (item.api !== undefined) provider.api = { ...item.api }
|
||||
if (item.request !== undefined) {
|
||||
Object.assign(provider.request.headers, item.request.headers)
|
||||
|
|
|
|||
|
|
@ -46,6 +46,8 @@ export interface Interface {
|
|||
readonly all: () => Effect.Effect<Stored[]>
|
||||
/** Returns stored credentials belonging to one integration. */
|
||||
readonly list: (integrationID: IntegrationSchema.ID) => Effect.Effect<Stored[]>
|
||||
/** Returns one stored credential by ID. */
|
||||
readonly get: (id: ID) => Effect.Effect<Stored | undefined>
|
||||
/** Replaces any credential for an integration and returns the new record. */
|
||||
readonly create: (input: {
|
||||
readonly integrationID: IntegrationSchema.ID
|
||||
|
|
@ -99,6 +101,10 @@ export const layer = Layer.effect(
|
|||
return credential ? [credential] : []
|
||||
})
|
||||
}),
|
||||
get: Effect.fn("Credential.get")(function* (id) {
|
||||
const row = yield* db.select().from(CredentialTable).where(eq(CredentialTable.id, id)).get().pipe(Effect.orDie)
|
||||
return row ? stored(row) : undefined
|
||||
}),
|
||||
create: Effect.fn("Credential.create")(function* (input) {
|
||||
const credential = new Stored({
|
||||
id: ID.create(),
|
||||
|
|
|
|||
|
|
@ -29,15 +29,16 @@ export const When = Schema.Struct({
|
|||
}).annotate({ identifier: "Integration.When" })
|
||||
export type When = typeof When.Type
|
||||
|
||||
export class TextPrompt extends Schema.Class<TextPrompt>("Integration.TextPrompt")({
|
||||
export const TextPrompt = Schema.Struct({
|
||||
type: Schema.Literal("text"),
|
||||
key: Schema.String,
|
||||
message: Schema.String,
|
||||
placeholder: Schema.optional(Schema.String),
|
||||
when: Schema.optional(When),
|
||||
}) {}
|
||||
}).annotate({ identifier: "Integration.TextPrompt" })
|
||||
export type TextPrompt = typeof TextPrompt.Type
|
||||
|
||||
export class SelectPrompt extends Schema.Class<SelectPrompt>("Integration.SelectPrompt")({
|
||||
export const SelectPrompt = Schema.Struct({
|
||||
type: Schema.Literal("select"),
|
||||
key: Schema.String,
|
||||
message: Schema.String,
|
||||
|
|
@ -49,27 +50,31 @@ export class SelectPrompt extends Schema.Class<SelectPrompt>("Integration.Select
|
|||
}),
|
||||
),
|
||||
when: Schema.optional(When),
|
||||
}) {}
|
||||
}).annotate({ identifier: "Integration.SelectPrompt" })
|
||||
export type SelectPrompt = typeof SelectPrompt.Type
|
||||
|
||||
export const Prompt = Schema.Union([TextPrompt, SelectPrompt]).pipe(Schema.toTaggedUnion("type"))
|
||||
export type Prompt = typeof Prompt.Type
|
||||
|
||||
export class OAuthMethod extends Schema.Class<OAuthMethod>("Integration.OAuthMethod")({
|
||||
export const OAuthMethod = Schema.Struct({
|
||||
id: MethodID,
|
||||
type: Schema.Literal("oauth"),
|
||||
label: Schema.String,
|
||||
prompts: Schema.optional(Schema.Array(Prompt)),
|
||||
}) {}
|
||||
}).annotate({ identifier: "Integration.OAuthMethod" })
|
||||
export type OAuthMethod = typeof OAuthMethod.Type
|
||||
|
||||
export class KeyMethod extends Schema.Class<KeyMethod>("Integration.KeyMethod")({
|
||||
export const KeyMethod = Schema.Struct({
|
||||
type: Schema.Literal("key"),
|
||||
label: Schema.optional(Schema.String),
|
||||
}) {}
|
||||
}).annotate({ identifier: "Integration.KeyMethod" })
|
||||
export type KeyMethod = typeof KeyMethod.Type
|
||||
|
||||
export class EnvMethod extends Schema.Class<EnvMethod>("Integration.EnvMethod")({
|
||||
export const EnvMethod = Schema.Struct({
|
||||
type: Schema.Literal("env"),
|
||||
names: Schema.Array(Schema.String),
|
||||
}) {}
|
||||
}).annotate({ identifier: "Integration.EnvMethod" })
|
||||
export type EnvMethod = typeof EnvMethod.Type
|
||||
|
||||
export const Method = Schema.Union([OAuthMethod, KeyMethod, EnvMethod]).pipe(Schema.toTaggedUnion("type"))
|
||||
export type Method = typeof Method.Type
|
||||
|
|
@ -197,7 +202,11 @@ export interface Interface {
|
|||
readonly get: (id: ID) => Effect.Effect<Info | undefined>
|
||||
/** Returns all integrations with their methods and current connections. */
|
||||
readonly list: () => Effect.Effect<Info[]>
|
||||
readonly connect: {
|
||||
readonly connection: {
|
||||
/** Returns active connections for every registered or credential-backed integration. */
|
||||
readonly list: () => Effect.Effect<Map<ID, IntegrationConnection.Info>>
|
||||
/** Returns the active connection for one integration. */
|
||||
readonly forIntegration: (id: ID) => Effect.Effect<IntegrationConnection.Info | undefined>
|
||||
/** Runs a key method and stores the resulting credential. */
|
||||
readonly key: (input: {
|
||||
/** Integration receiving the credential. */
|
||||
|
|
@ -218,6 +227,10 @@ export interface Interface {
|
|||
/** User-facing label for the credential created on completion. */
|
||||
readonly label?: string
|
||||
}) => Effect.Effect<Attempt, AuthorizationError>
|
||||
/** Updates a stored credential exposed as a connection. */
|
||||
readonly update: (credentialID: Credential.ID, updates: Partial<Pick<Credential.Stored, "label">>) => Effect.Effect<void>
|
||||
/** Removes a stored credential connection. */
|
||||
readonly remove: (credentialID: Credential.ID) => Effect.Effect<void>
|
||||
}
|
||||
readonly attempt: {
|
||||
/** Returns the current state of an OAuth attempt. */
|
||||
|
|
@ -327,22 +340,29 @@ export const locationLayer = Layer.effect(
|
|||
|
||||
const connections = (entry: Entry, saved: readonly Credential.Stored[]): IntegrationConnection.Info[] => {
|
||||
const connected = saved.map(
|
||||
(credential) =>
|
||||
new IntegrationConnection.CredentialInfo({ type: "credential", id: credential.id, label: credential.label }),
|
||||
(credential) => ({ type: "credential" as const, id: credential.id, label: credential.label }),
|
||||
)
|
||||
const detected = entry.methods
|
||||
.filter((method) => method.type === "env")
|
||||
.flatMap((method) => method.names.filter((name) => process.env[name]))
|
||||
.map(
|
||||
(name, index) =>
|
||||
new IntegrationConnection.EnvInfo({
|
||||
type: "env",
|
||||
name,
|
||||
}),
|
||||
)
|
||||
.map((name) => ({ type: "env" as const, name }))
|
||||
return [...connected, ...detected]
|
||||
}
|
||||
|
||||
const activeConnection = (
|
||||
entry: Entry | undefined,
|
||||
saved: readonly Credential.Stored[],
|
||||
): IntegrationConnection.Info | undefined => {
|
||||
const credential = saved.at(-1)
|
||||
if (credential) return { type: "credential", id: credential.id, label: credential.label }
|
||||
if (!entry) return
|
||||
const name = entry.methods
|
||||
.filter((method) => method.type === "env")
|
||||
.flatMap((method) => method.names)
|
||||
.find((name) => process.env[name])
|
||||
if (name) return { type: "env", name }
|
||||
}
|
||||
|
||||
const project = (entry: Entry, saved: readonly Credential.Stored[]) =>
|
||||
new Info({
|
||||
id: entry.ref.id,
|
||||
|
|
@ -382,6 +402,7 @@ export const locationLayer = Layer.effect(
|
|||
? new Credential.OAuth({ ...exit.value, methodID: result.methodID })
|
||||
: exit.value,
|
||||
})
|
||||
yield* events.publish(Event.Updated, {})
|
||||
}
|
||||
yield* close(result.scope)
|
||||
})
|
||||
|
|
@ -421,8 +442,23 @@ export const locationLayer = Layer.effect(
|
|||
}),
|
||||
)).toSorted((a, b) => a.name.localeCompare(b.name))
|
||||
}),
|
||||
connect: {
|
||||
key: Effect.fn("Integration.connect.key")(function* (input) {
|
||||
connection: {
|
||||
list: Effect.fn("Integration.connection.list")(function* () {
|
||||
const saved = Map.groupBy(yield* credentials.all(), (credential) => credential.integrationID)
|
||||
return new Map(
|
||||
new Set([...state.get().integrations.keys(), ...saved.keys()])
|
||||
.values()
|
||||
.flatMap((id) => {
|
||||
const connection = activeConnection(state.get().integrations.get(id), saved.get(id) ?? [])
|
||||
return connection ? [[id, connection] as const] : []
|
||||
}),
|
||||
)
|
||||
}),
|
||||
forIntegration: Effect.fn("Integration.connection.forIntegration")(function* (id) {
|
||||
const entry = state.get().integrations.get(id)
|
||||
return activeConnection(entry, yield* credentials.list(id))
|
||||
}),
|
||||
key: Effect.fn("Integration.connection.key")(function* (input) {
|
||||
const method = state
|
||||
.get()
|
||||
.integrations.get(input.integrationID)
|
||||
|
|
@ -433,8 +469,9 @@ export const locationLayer = Layer.effect(
|
|||
label: input.label,
|
||||
value: new Credential.Key({ type: "key", key: input.key }),
|
||||
})
|
||||
yield* events.publish(Event.Updated, {})
|
||||
}),
|
||||
oauth: Effect.fn("Integration.connect.oauth")(function* (input) {
|
||||
oauth: Effect.fn("Integration.connection.oauth")(function* (input) {
|
||||
const method = state.get().integrations.get(input.integrationID)?.implementations.get(input.methodID)
|
||||
if (!method) {
|
||||
return yield* Effect.die(`OAuth method not found: ${input.integrationID}/${input.methodID}`)
|
||||
|
|
@ -474,6 +511,14 @@ export const locationLayer = Layer.effect(
|
|||
time,
|
||||
})
|
||||
}),
|
||||
update: Effect.fn("Integration.connection.update")(function* (credentialID, updates) {
|
||||
yield* credentials.update(credentialID, updates)
|
||||
yield* events.publish(Event.Updated, {})
|
||||
}),
|
||||
remove: Effect.fn("Integration.connection.remove")(function* (credentialID) {
|
||||
yield* credentials.remove(credentialID)
|
||||
yield* events.publish(Event.Updated, {})
|
||||
}),
|
||||
},
|
||||
attempt: {
|
||||
status: Effect.fn("Integration.attempt.status")(function* (attemptID) {
|
||||
|
|
|
|||
|
|
@ -3,16 +3,18 @@ export * as IntegrationConnection from "./connection"
|
|||
import { Schema } from "effect"
|
||||
import { Credential } from "../credential"
|
||||
|
||||
export class CredentialInfo extends Schema.Class<CredentialInfo>("Connection.CredentialInfo")({
|
||||
export const CredentialInfo = Schema.Struct({
|
||||
type: Schema.Literal("credential"),
|
||||
id: Credential.ID,
|
||||
label: Schema.String,
|
||||
}) {}
|
||||
}).annotate({ identifier: "Connection.CredentialInfo" })
|
||||
export type CredentialInfo = typeof CredentialInfo.Type
|
||||
|
||||
export class EnvInfo extends Schema.Class<EnvInfo>("Connection.EnvInfo")({
|
||||
export const EnvInfo = Schema.Struct({
|
||||
type: Schema.Literal("env"),
|
||||
name: Schema.String,
|
||||
}) {}
|
||||
}).annotate({ identifier: "Connection.EnvInfo" })
|
||||
export type EnvInfo = typeof EnvInfo.Type
|
||||
|
||||
export const Info = Schema.Union([CredentialInfo, EnvInfo])
|
||||
.pipe(Schema.toTaggedUnion("type"))
|
||||
|
|
|
|||
|
|
@ -22,7 +22,6 @@ import { AgentPlugin } from "./agent"
|
|||
import { CommandPlugin } from "./command"
|
||||
import { SkillPlugin } from "./skill"
|
||||
import { ConfigProviderPlugin } from "../config/plugin/provider"
|
||||
import { EnvPlugin } from "./env"
|
||||
import { ModelsDevPlugin } from "./models-dev"
|
||||
import { ProviderPlugins } from "./provider"
|
||||
import { SkillV2 } from "../skill"
|
||||
|
|
@ -99,7 +98,6 @@ export const layer = Layer.effect(
|
|||
})
|
||||
|
||||
const boot = Effect.gen(function* () {
|
||||
yield* add(EnvPlugin)
|
||||
yield* add(AgentPlugin.Plugin)
|
||||
yield* add(CommandPlugin.Plugin)
|
||||
yield* add(SkillPlugin.Plugin)
|
||||
|
|
|
|||
|
|
@ -1,22 +0,0 @@
|
|||
import { Effect } from "effect"
|
||||
import { PluginV2 } from "../plugin"
|
||||
|
||||
export const EnvPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("env"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
for (const item of evt.provider.list()) {
|
||||
const key = item.provider.env.find((env) => process.env[env])
|
||||
if (!key) continue
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
provider.enabled = {
|
||||
via: "env",
|
||||
name: key,
|
||||
}
|
||||
})
|
||||
}
|
||||
}),
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
|
@ -70,16 +70,11 @@ export const ModelsDevPlugin = PluginV2.define({
|
|||
integrations.update(integrationID, (integration) => (integration.name = item.name))
|
||||
integrations.method.update({
|
||||
integrationID,
|
||||
method: new Integration.KeyMethod({
|
||||
type: "key",
|
||||
}),
|
||||
method: { type: "key" },
|
||||
})
|
||||
integrations.method.update({
|
||||
integrationID,
|
||||
method: new Integration.EnvMethod({
|
||||
type: "env",
|
||||
names: [...item.env],
|
||||
}),
|
||||
method: { type: "env", names: [...item.env] },
|
||||
})
|
||||
}
|
||||
})
|
||||
|
|
@ -88,7 +83,6 @@ export const ModelsDevPlugin = PluginV2.define({
|
|||
const providerID = ProviderV2.ID.make(item.id)
|
||||
catalog.provider.update(providerID, (provider) => {
|
||||
provider.name = item.name
|
||||
provider.env = [...item.env]
|
||||
provider.api = item.npm
|
||||
? {
|
||||
type: "aisdk",
|
||||
|
|
|
|||
|
|
@ -1,13 +1,16 @@
|
|||
import { Effect } from "effect"
|
||||
import { Integration } from "../../integration"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
|
||||
export const LLMGatewayPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("llmgateway"),
|
||||
effect: Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
return {
|
||||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.enabled === false) continue
|
||||
if (item.provider.disabled) continue
|
||||
if (!(yield* integrations.get(Integration.ID.make(item.provider.id)))) continue
|
||||
if (item.provider.api.type !== "aisdk") continue
|
||||
if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue
|
||||
if (item.provider.api.url !== "https://api.llmgateway.io/v1") continue
|
||||
|
|
|
|||
|
|
@ -32,11 +32,11 @@ const headlessMethodID = Integration.MethodID.make("chatgpt-headless")
|
|||
|
||||
export const browser = {
|
||||
integrationID: Integration.ID.make("openai"),
|
||||
method: new Integration.OAuthMethod({
|
||||
method: {
|
||||
id: browserMethodID,
|
||||
type: "oauth",
|
||||
label: "ChatGPT Pro/Plus (browser)",
|
||||
}),
|
||||
},
|
||||
authorize: () =>
|
||||
Effect.gen(function* () {
|
||||
const pkce = yield* Effect.promise(generatePKCE)
|
||||
|
|
@ -89,11 +89,11 @@ export const browser = {
|
|||
|
||||
export const headless = {
|
||||
integrationID: Integration.ID.make("openai"),
|
||||
method: new Integration.OAuthMethod({
|
||||
method: {
|
||||
id: headlessMethodID,
|
||||
type: "oauth",
|
||||
label: "ChatGPT Pro/Plus (headless)",
|
||||
}),
|
||||
},
|
||||
authorize: () =>
|
||||
Effect.gen(function* () {
|
||||
const device = yield* request<{ device_auth_id: string; user_code: string; interval: string }>(
|
||||
|
|
|
|||
|
|
@ -1,20 +1,22 @@
|
|||
import { Effect } from "effect"
|
||||
import { Integration } from "../../integration"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
export const OpencodePlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("opencode"),
|
||||
effect: Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
let hasKey = false
|
||||
return {
|
||||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
const item = evt.provider.get(ProviderV2.ID.opencode)
|
||||
if (!item) return
|
||||
const integration = yield* integrations.get(Integration.ID.make(item.provider.id))
|
||||
hasKey = Boolean(
|
||||
process.env.OPENCODE_API_KEY ||
|
||||
item.provider.env.some((env) => process.env[env]) ||
|
||||
item.provider.request.body.apiKey ||
|
||||
(item.provider.enabled && item.provider.enabled.via === "credential"),
|
||||
integration?.connections.length ||
|
||||
item.provider.request.body.apiKey,
|
||||
)
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
if (!hasKey) provider.request.body.apiKey = "public"
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ export * as ProviderV2 from "./provider"
|
|||
|
||||
import { withStatics } from "./schema"
|
||||
import { Schema } from "effect"
|
||||
import { Credential } from "./credential"
|
||||
|
||||
export const ID = Schema.String.pipe(
|
||||
Schema.brand("ProviderV2.ID"),
|
||||
|
|
@ -48,22 +47,7 @@ export type Request = typeof Request.Type
|
|||
export class Info extends Schema.Class<Info>("ProviderV2.Info")({
|
||||
id: ID,
|
||||
name: Schema.String,
|
||||
enabled: Schema.Union([
|
||||
Schema.Literal(false),
|
||||
Schema.Struct({
|
||||
via: Schema.Literal("env"),
|
||||
name: Schema.String,
|
||||
}),
|
||||
Schema.Struct({
|
||||
via: Schema.Literal("credential"),
|
||||
credentialID: Credential.ID,
|
||||
}),
|
||||
Schema.Struct({
|
||||
via: Schema.Literal("custom"),
|
||||
data: Schema.Record(Schema.String, Schema.Any),
|
||||
}),
|
||||
]),
|
||||
env: Schema.String.pipe(Schema.Array),
|
||||
disabled: Schema.Boolean.pipe(Schema.optional),
|
||||
api: Api,
|
||||
request: Request,
|
||||
}) {
|
||||
|
|
@ -71,8 +55,6 @@ export class Info extends Schema.Class<Info>("ProviderV2.Info")({
|
|||
return new Info({
|
||||
id: providerID,
|
||||
name: providerID,
|
||||
enabled: false,
|
||||
env: [],
|
||||
api: {
|
||||
type: "native",
|
||||
settings: {},
|
||||
|
|
|
|||
|
|
@ -8,6 +8,9 @@ import { Auth, type AnyRoute } from "@opencode-ai/llm/route"
|
|||
import { Context, Effect, Layer, Option, Schema } from "effect"
|
||||
import { produce } from "immer"
|
||||
import { Catalog } from "../../catalog"
|
||||
import { Credential } from "../../credential"
|
||||
import { Integration } from "../../integration"
|
||||
import { IntegrationConnection } from "../../integration/connection"
|
||||
import { ModelV2 } from "../../model"
|
||||
import { ModelRequest } from "../../model-request"
|
||||
import { PluginBoot } from "../../plugin/boot"
|
||||
|
|
@ -45,10 +48,16 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
|
|||
/** Test or embedding seam for supplying a model resolver directly. */
|
||||
export const layerWith = (resolve: Interface["resolve"]) => Layer.succeed(Service, Service.of({ resolve }))
|
||||
|
||||
const apiKey = (model: ModelV2.Info, provider?: ProviderV2.Info) => {
|
||||
const apiKey = (
|
||||
model: ModelV2.Info,
|
||||
connection?: IntegrationConnection.Info,
|
||||
credential?: Credential.Stored,
|
||||
) => {
|
||||
if (credential?.value.type === "key") return Auth.value(credential.value.key)
|
||||
if (credential?.value.type === "oauth") return Auth.value(credential.value.access)
|
||||
const value = model.request.body.apiKey ?? model.api.settings?.apiKey
|
||||
if (typeof value === "string") return Auth.value(value)
|
||||
return provider?.enabled !== false && provider?.enabled.via === "env" ? Auth.config(provider.enabled.name) : undefined
|
||||
return connection?.type === "env" ? Auth.config(connection.name) : undefined
|
||||
}
|
||||
|
||||
const withDefaults = (model: ModelV2.Info, route: AnyRoute) => {
|
||||
|
|
@ -83,41 +92,48 @@ const apiName = (model: ModelV2.Info) =>
|
|||
|
||||
export const fromCatalogModel = (
|
||||
model: ModelV2.Info,
|
||||
provider?: ProviderV2.Info,
|
||||
connection?: IntegrationConnection.Info,
|
||||
credential?: Credential.Stored,
|
||||
): Effect.Effect<Model, UnsupportedApiError> => {
|
||||
const key = apiKey(model, provider)
|
||||
if (model.api.type === "aisdk" && model.api.package === "@ai-sdk/openai") {
|
||||
const resolved =
|
||||
credential?.value.metadata === undefined
|
||||
? model
|
||||
: produce(model, (draft) => {
|
||||
Object.assign(draft.request.body, credential.value.metadata)
|
||||
})
|
||||
const key = apiKey(resolved, connection, credential)
|
||||
if (resolved.api.type === "aisdk" && resolved.api.package === "@ai-sdk/openai") {
|
||||
return Effect.succeed(
|
||||
withDefaults(model, OpenAIResponses.route)
|
||||
withDefaults(resolved, OpenAIResponses.route)
|
||||
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
|
||||
.model({ id: model.api.id }),
|
||||
.model({ id: resolved.api.id }),
|
||||
)
|
||||
}
|
||||
if (model.api.type === "aisdk" && model.api.package === "@ai-sdk/anthropic") {
|
||||
if (resolved.api.type === "aisdk" && resolved.api.package === "@ai-sdk/anthropic") {
|
||||
return Effect.succeed(
|
||||
withDefaults(model, AnthropicMessages.route)
|
||||
withDefaults(resolved, AnthropicMessages.route)
|
||||
.with({ auth: key === undefined ? Auth.none : Auth.header("x-api-key", key) })
|
||||
.model({ id: model.api.id }),
|
||||
.model({ id: resolved.api.id }),
|
||||
)
|
||||
}
|
||||
if (model.api.type === "aisdk" && model.api.package === "@ai-sdk/openai-compatible" && model.api.url) {
|
||||
if (resolved.api.type === "aisdk" && resolved.api.package === "@ai-sdk/openai-compatible" && resolved.api.url) {
|
||||
return Effect.succeed(
|
||||
withDefaults(model, OpenAICompatibleChat.route)
|
||||
withDefaults(resolved, OpenAICompatibleChat.route)
|
||||
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
|
||||
.model({ id: model.api.id }),
|
||||
.model({ id: resolved.api.id }),
|
||||
)
|
||||
}
|
||||
return Effect.fail(
|
||||
new UnsupportedApiError({
|
||||
providerID: model.providerID,
|
||||
modelID: model.id,
|
||||
api: apiName(model),
|
||||
providerID: resolved.providerID,
|
||||
modelID: resolved.id,
|
||||
api: apiName(resolved),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export const resolve = (session: SessionSchema.Info, model: ModelV2.Info, provider?: ProviderV2.Info) =>
|
||||
fromCatalogModel(withVariant(model, session.model?.variant), provider)
|
||||
export const resolve = (session: SessionSchema.Info, model: ModelV2.Info) =>
|
||||
fromCatalogModel(withVariant(model, session.model?.variant))
|
||||
|
||||
export const supported = (model: ModelV2.Info) =>
|
||||
model.api.type === "aisdk" &&
|
||||
|
|
@ -130,6 +146,8 @@ export const locationLayer = Layer.effect(
|
|||
Service,
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const credentials = yield* Credential.Service
|
||||
const integrations = yield* Integration.Service
|
||||
const boot = yield* PluginBoot.Service
|
||||
return Service.of({
|
||||
resolve: Effect.fn("SessionRunnerModel.resolve")(function* (session) {
|
||||
|
|
@ -140,7 +158,12 @@ export const locationLayer = Layer.effect(
|
|||
: (Option.getOrUndefined((yield* catalog.model.default()).pipe(Option.filter(supported))) ??
|
||||
(yield* catalog.model.available()).find(supported))
|
||||
if (!selected) return yield* new ModelNotSelectedError({ sessionID: session.id })
|
||||
return yield* resolve(session, selected, yield* catalog.provider.get(selected.providerID))
|
||||
const connection = yield* integrations.connection.forIntegration(Integration.ID.make(selected.providerID))
|
||||
return yield* fromCatalogModel(
|
||||
withVariant(selected, session.model?.variant),
|
||||
connection,
|
||||
connection?.type === "credential" ? yield* credentials.get(connection.id) : undefined,
|
||||
)
|
||||
}),
|
||||
})
|
||||
}),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue