refactor(core): derive catalog availability from integrations (#32272)

This commit is contained in:
Dax 2026-06-14 08:44:50 -04:00 committed by GitHub
commit 0cf3ee4406
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 622 additions and 509 deletions

View file

@ -10,8 +10,7 @@ import { Location } from "./location"
import { EventV2 } from "./event" import { EventV2 } from "./event"
import { Policy } from "./policy" import { Policy } from "./policy"
import { State } from "./state" import { State } from "./state"
import { Credential } from "./credential" import { Integration } from "./integration"
import { IntegrationSchema } from "./integration/schema"
export type ProviderRecord = { export type ProviderRecord = {
provider: ProviderV2.Info provider: ProviderV2.Info
@ -35,12 +34,7 @@ export class ModelNotFoundError extends Schema.TaggedErrorClass<ModelNotFoundErr
export const PolicyActions = Schema.Literals(["provider.use"]) export const PolicyActions = Schema.Literals(["provider.use"])
export const Event = { export const Event = {
ModelUpdated: EventV2.define({ Updated: EventV2.define({ type: "catalog.updated", schema: {} }),
type: "catalog.model.updated",
schema: {
model: ModelV2.Info,
},
}),
} }
type Data = { type Data = {
@ -96,26 +90,21 @@ export const layer = Layer.effect(
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const events = yield* EventV2.Service const events = yield* EventV2.Service
const policy = yield* Policy.Service const policy = yield* Policy.Service
const credentials = yield* Credential.Service const integrations = yield* Integration.Service
const scope = yield* Scope.Scope const scope = yield* Scope.Scope
const project = (provider: ProviderV2.Info, active: Map<IntegrationSchema.ID, Credential.Stored>) => { const available = (
const credential = active.get(IntegrationSchema.ID.make(provider.id)) provider: ProviderV2.Info,
if (!credential) return provider integration: Integration.Info | undefined,
const body = { ...provider.request.body } connected: boolean,
if (credential.value.type === "key") { ) => {
body.apiKey = credential.value.key if (provider.disabled) return false
Object.assign(body, credential.value.metadata ?? {}) if (typeof provider.request.body.apiKey === "string") return true
} if (connected) return true
if (credential.value.type === "oauth") body.apiKey = credential.value.access return !integration
return new ProviderV2.Info({
...provider,
enabled: { via: "credential", credentialID: credential.id },
request: { ...provider.request, body },
})
} }
const resolve = (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 model.api.type === "native" && !model.api.url && Object.keys(model.api.settings).length === 0
? { ...provider.api, id: model.api.id } ? { ...provider.api, id: model.api.id }
@ -203,18 +192,16 @@ export const layer = Layer.effect(
}, },
finalize: Effect.fn("CatalogV2.finalize")(function* (catalog, reason) { finalize: Effect.fn("CatalogV2.finalize")(function* (catalog, reason) {
if (reason !== "plugin.added") yield* plugin.trigger("catalog.transform", catalog, {}).pipe(Effect.asVoid) if (reason !== "plugin.added") yield* plugin.trigger("catalog.transform", catalog, {}).pipe(Effect.asVoid)
if (!policy.hasStatements()) return if (policy.hasStatements()) {
for (const record of [...catalog.provider.list()]) { for (const record of [...catalog.provider.list()]) {
if ((yield* policy.evaluate("provider.use", record.provider.id, "allow")) === "deny") { if ((yield* policy.evaluate("provider.use", record.provider.id, "allow")) === "deny") {
catalog.provider.remove(record.provider.id) 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( yield* events.subscribe(PluginV2.Event.Added).pipe(
// Plugin registries are location scoped even though the event bus is process scoped. // Plugin registries are location scoped even though the event bus is process scoped.
Stream.filter( Stream.filter(
@ -233,18 +220,23 @@ export const layer = Layer.effect(
provider: { provider: {
get: Effect.fn("CatalogV2.provider.get")(function* (providerID) { get: Effect.fn("CatalogV2.provider.get")(function* (providerID) {
const record = yield* getRecord(providerID) const record = yield* getRecord(providerID)
return project(record.provider, yield* active()) return record.provider
}), }),
all: Effect.fn("CatalogV2.provider.all")(function* () { all: Effect.fn("CatalogV2.provider.all")(function* () {
const credentials = yield* active() return Array.fromIterable(state.get().providers.values()).map((record) => record.provider)
return Array.fromIterable(state.get().providers.values()).map((record) =>
project(record.provider, credentials),
)
}), }),
available: Effect.fn("CatalogV2.provider.available")(function* () { 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 record = yield* getRecord(providerID)
const model = record.models.get(modelID) const model = record.models.get(modelID)
if (!model) return yield* new ModelNotFoundError({ providerID, 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* () { all: Effect.fn("CatalogV2.model.all")(function* () {
const credentials = yield* active()
return pipe( return pipe(
Array.fromIterable(state.get().providers.values()), Array.fromIterable(state.get().providers.values()),
Array.flatMap((record) => { Array.flatMap((record) => {
const provider = project(record.provider, credentials) return Array.fromIterable(record.models.values()).map((model) => projectModel(model, record.provider))
return Array.fromIterable(record.models.values()).map((model) => resolve(model, provider))
}), }),
Array.sortWith((item) => item.time.released.epochMilliseconds, Order.flip(Order.Number)), Array.sortWith((item) => item.time.released.epochMilliseconds, Order.flip(Order.Number)),
) )
}), }),
available: Effect.fn("CatalogV2.model.available")(function* () { available: Effect.fn("CatalogV2.model.available")(function* () {
const providers = new Map((yield* result.provider.all()).map((provider) => [provider.id, provider])) const providers = new Set((yield* result.provider.available()).map((provider) => provider.id))
return (yield* result.model.all()).filter( return (yield* result.model.all()).filter((model) => providers.has(model.providerID) && model.enabled)
(model) => providers.get(model.providerID)?.enabled !== false && model.enabled,
)
}), }),
default: Effect.fn("CatalogV2.model.default")(function* () { default: Effect.fn("CatalogV2.model.default")(function* () {
const defaultModel = state.get().defaultModel const defaultModel = state.get().defaultModel
if (defaultModel) { if (defaultModel) {
const provider = yield* result.provider.get(defaultModel.providerID).pipe(Effect.option) 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) const model = yield* result.model.get(defaultModel.providerID, defaultModel.modelID).pipe(Effect.option)
if (Option.isSome(model) && model.value.enabled) return model 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) { small: Effect.fn("CatalogV2.model.small")(function* (providerID) {
const record = state.get().providers.get(providerID) const record = state.get().providers.get(providerID)
if (!record) return Option.none<ModelV2.Info>() if (!record) return Option.none<ModelV2.Info>()
const provider = project(record.provider, yield* active()) const provider = record.provider
if (providerID === ProviderV2.ID.opencode) { if (providerID === ProviderV2.ID.opencode) {
const gpt5Nano = record.models.get(ModelV2.ID.make("gpt-5-nano")) 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( const candidates = pipe(
@ -327,7 +318,7 @@ export const layer = Layer.effect(
return pipe( return pipe(
items, items,
Array.sortWith((item) => (item.cost / maxCost) * 0.8 + (item.age / maxAge) * 0.2, Order.Number), 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, Array.head,
) )
} }
@ -348,6 +339,7 @@ export const layer = Layer.effect(
const SMALL_MODEL_RE = /\b(nano|flash|lite|mini|haiku|small|fast)\b/ const SMALL_MODEL_RE = /\b(nano|flash|lite|mini|haiku|small|fast)\b/
export const locationLayer = layer.pipe( export const locationLayer = layer.pipe(
Layer.provideMerge(Integration.locationLayer),
Layer.provideMerge(PluginV2.locationLayer), Layer.provideMerge(PluginV2.locationLayer),
Layer.provideMerge(Policy.locationLayer), Layer.provideMerge(Policy.locationLayer),
) )

View file

@ -3,6 +3,7 @@ export * as ConfigProviderPlugin from "./provider"
import { Effect } from "effect" import { Effect } from "effect"
import { Catalog } from "../../catalog" import { Catalog } from "../../catalog"
import { Config } from "../../config" import { Config } from "../../config"
import { Integration } from "../../integration"
import { ModelV2 } from "../../model" import { ModelV2 } from "../../model"
import { ModelRequest } from "../../model-request" import { ModelRequest } from "../../model-request"
import { PluginV2 } from "../../plugin" import { PluginV2 } from "../../plugin"
@ -13,9 +14,33 @@ export const Plugin = PluginV2.define({
effect: Effect.gen(function* () { effect: Effect.gen(function* () {
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
const config = yield* Config.Service const config = yield* Config.Service
const integrations = yield* Integration.Service
const transform = yield* catalog.transform() const transform = yield* catalog.transform()
const integrationTransform = yield* integrations.transform()
const entries = yield* config.entries() const entries = yield* config.entries()
const files = entries.filter((entry): entry is Config.Document => entry.type === "document") 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) => { yield* transform((catalog) => {
const configuredDefault = Config.latest(entries, "model") const configuredDefault = Config.latest(entries, "model")
@ -28,8 +53,6 @@ export const Plugin = PluginV2.define({
const providerID = ProviderV2.ID.make(id) const providerID = ProviderV2.ID.make(id)
catalog.provider.update(providerID, (provider) => { catalog.provider.update(providerID, (provider) => {
if (item.name !== undefined) provider.name = item.name 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.api !== undefined) provider.api = { ...item.api }
if (item.request !== undefined) { if (item.request !== undefined) {
Object.assign(provider.request.headers, item.request.headers) Object.assign(provider.request.headers, item.request.headers)

View file

@ -46,6 +46,8 @@ export interface Interface {
readonly all: () => Effect.Effect<Stored[]> readonly all: () => Effect.Effect<Stored[]>
/** Returns stored credentials belonging to one integration. */ /** Returns stored credentials belonging to one integration. */
readonly list: (integrationID: IntegrationSchema.ID) => Effect.Effect<Stored[]> 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. */ /** Replaces any credential for an integration and returns the new record. */
readonly create: (input: { readonly create: (input: {
readonly integrationID: IntegrationSchema.ID readonly integrationID: IntegrationSchema.ID
@ -99,6 +101,10 @@ export const layer = Layer.effect(
return credential ? [credential] : [] 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) { create: Effect.fn("Credential.create")(function* (input) {
const credential = new Stored({ const credential = new Stored({
id: ID.create(), id: ID.create(),

View file

@ -29,15 +29,16 @@ export const When = Schema.Struct({
}).annotate({ identifier: "Integration.When" }) }).annotate({ identifier: "Integration.When" })
export type When = typeof When.Type export type When = typeof When.Type
export class TextPrompt extends Schema.Class<TextPrompt>("Integration.TextPrompt")({ export const TextPrompt = Schema.Struct({
type: Schema.Literal("text"), type: Schema.Literal("text"),
key: Schema.String, key: Schema.String,
message: Schema.String, message: Schema.String,
placeholder: Schema.optional(Schema.String), placeholder: Schema.optional(Schema.String),
when: Schema.optional(When), 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"), type: Schema.Literal("select"),
key: Schema.String, key: Schema.String,
message: Schema.String, message: Schema.String,
@ -49,27 +50,31 @@ export class SelectPrompt extends Schema.Class<SelectPrompt>("Integration.Select
}), }),
), ),
when: Schema.optional(When), 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 const Prompt = Schema.Union([TextPrompt, SelectPrompt]).pipe(Schema.toTaggedUnion("type"))
export type Prompt = typeof Prompt.Type export type Prompt = typeof Prompt.Type
export class OAuthMethod extends Schema.Class<OAuthMethod>("Integration.OAuthMethod")({ export const OAuthMethod = Schema.Struct({
id: MethodID, id: MethodID,
type: Schema.Literal("oauth"), type: Schema.Literal("oauth"),
label: Schema.String, label: Schema.String,
prompts: Schema.optional(Schema.Array(Prompt)), 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"), type: Schema.Literal("key"),
label: Schema.optional(Schema.String), 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"), type: Schema.Literal("env"),
names: Schema.Array(Schema.String), 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 const Method = Schema.Union([OAuthMethod, KeyMethod, EnvMethod]).pipe(Schema.toTaggedUnion("type"))
export type Method = typeof Method.Type export type Method = typeof Method.Type
@ -197,7 +202,11 @@ export interface Interface {
readonly get: (id: ID) => Effect.Effect<Info | undefined> readonly get: (id: ID) => Effect.Effect<Info | undefined>
/** Returns all integrations with their methods and current connections. */ /** Returns all integrations with their methods and current connections. */
readonly list: () => Effect.Effect<Info[]> 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. */ /** Runs a key method and stores the resulting credential. */
readonly key: (input: { readonly key: (input: {
/** Integration receiving the credential. */ /** Integration receiving the credential. */
@ -218,6 +227,10 @@ export interface Interface {
/** User-facing label for the credential created on completion. */ /** User-facing label for the credential created on completion. */
readonly label?: string readonly label?: string
}) => Effect.Effect<Attempt, AuthorizationError> }) => 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: { readonly attempt: {
/** Returns the current state of an OAuth 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 connections = (entry: Entry, saved: readonly Credential.Stored[]): IntegrationConnection.Info[] => {
const connected = saved.map( const connected = saved.map(
(credential) => (credential) => ({ type: "credential" as const, id: credential.id, label: credential.label }),
new IntegrationConnection.CredentialInfo({ type: "credential", id: credential.id, label: credential.label }),
) )
const detected = entry.methods const detected = entry.methods
.filter((method) => method.type === "env") .filter((method) => method.type === "env")
.flatMap((method) => method.names.filter((name) => process.env[name])) .flatMap((method) => method.names.filter((name) => process.env[name]))
.map( .map((name) => ({ type: "env" as const, name }))
(name, index) =>
new IntegrationConnection.EnvInfo({
type: "env",
name,
}),
)
return [...connected, ...detected] 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[]) => const project = (entry: Entry, saved: readonly Credential.Stored[]) =>
new Info({ new Info({
id: entry.ref.id, id: entry.ref.id,
@ -382,6 +402,7 @@ export const locationLayer = Layer.effect(
? new Credential.OAuth({ ...exit.value, methodID: result.methodID }) ? new Credential.OAuth({ ...exit.value, methodID: result.methodID })
: exit.value, : exit.value,
}) })
yield* events.publish(Event.Updated, {})
} }
yield* close(result.scope) yield* close(result.scope)
}) })
@ -421,8 +442,23 @@ export const locationLayer = Layer.effect(
}), }),
)).toSorted((a, b) => a.name.localeCompare(b.name)) )).toSorted((a, b) => a.name.localeCompare(b.name))
}), }),
connect: { connection: {
key: Effect.fn("Integration.connect.key")(function* (input) { 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 const method = state
.get() .get()
.integrations.get(input.integrationID) .integrations.get(input.integrationID)
@ -433,8 +469,9 @@ export const locationLayer = Layer.effect(
label: input.label, label: input.label,
value: new Credential.Key({ type: "key", key: input.key }), 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) const method = state.get().integrations.get(input.integrationID)?.implementations.get(input.methodID)
if (!method) { if (!method) {
return yield* Effect.die(`OAuth method not found: ${input.integrationID}/${input.methodID}`) return yield* Effect.die(`OAuth method not found: ${input.integrationID}/${input.methodID}`)
@ -474,6 +511,14 @@ export const locationLayer = Layer.effect(
time, 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: { attempt: {
status: Effect.fn("Integration.attempt.status")(function* (attemptID) { status: Effect.fn("Integration.attempt.status")(function* (attemptID) {

View file

@ -3,16 +3,18 @@ export * as IntegrationConnection from "./connection"
import { Schema } from "effect" import { Schema } from "effect"
import { Credential } from "../credential" import { Credential } from "../credential"
export class CredentialInfo extends Schema.Class<CredentialInfo>("Connection.CredentialInfo")({ export const CredentialInfo = Schema.Struct({
type: Schema.Literal("credential"), type: Schema.Literal("credential"),
id: Credential.ID, id: Credential.ID,
label: Schema.String, 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"), type: Schema.Literal("env"),
name: Schema.String, name: Schema.String,
}) {} }).annotate({ identifier: "Connection.EnvInfo" })
export type EnvInfo = typeof EnvInfo.Type
export const Info = Schema.Union([CredentialInfo, EnvInfo]) export const Info = Schema.Union([CredentialInfo, EnvInfo])
.pipe(Schema.toTaggedUnion("type")) .pipe(Schema.toTaggedUnion("type"))

View file

@ -22,7 +22,6 @@ import { AgentPlugin } from "./agent"
import { CommandPlugin } from "./command" import { CommandPlugin } from "./command"
import { SkillPlugin } from "./skill" import { SkillPlugin } from "./skill"
import { ConfigProviderPlugin } from "../config/plugin/provider" import { ConfigProviderPlugin } from "../config/plugin/provider"
import { EnvPlugin } from "./env"
import { ModelsDevPlugin } from "./models-dev" import { ModelsDevPlugin } from "./models-dev"
import { ProviderPlugins } from "./provider" import { ProviderPlugins } from "./provider"
import { SkillV2 } from "../skill" import { SkillV2 } from "../skill"
@ -99,7 +98,6 @@ export const layer = Layer.effect(
}) })
const boot = Effect.gen(function* () { const boot = Effect.gen(function* () {
yield* add(EnvPlugin)
yield* add(AgentPlugin.Plugin) yield* add(AgentPlugin.Plugin)
yield* add(CommandPlugin.Plugin) yield* add(CommandPlugin.Plugin)
yield* add(SkillPlugin.Plugin) yield* add(SkillPlugin.Plugin)

View file

@ -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,
}
})
}
}),
}
}),
})

View file

@ -70,16 +70,11 @@ export const ModelsDevPlugin = PluginV2.define({
integrations.update(integrationID, (integration) => (integration.name = item.name)) integrations.update(integrationID, (integration) => (integration.name = item.name))
integrations.method.update({ integrations.method.update({
integrationID, integrationID,
method: new Integration.KeyMethod({ method: { type: "key" },
type: "key",
}),
}) })
integrations.method.update({ integrations.method.update({
integrationID, integrationID,
method: new Integration.EnvMethod({ method: { type: "env", names: [...item.env] },
type: "env",
names: [...item.env],
}),
}) })
} }
}) })
@ -88,7 +83,6 @@ export const ModelsDevPlugin = PluginV2.define({
const providerID = ProviderV2.ID.make(item.id) const providerID = ProviderV2.ID.make(item.id)
catalog.provider.update(providerID, (provider) => { catalog.provider.update(providerID, (provider) => {
provider.name = item.name provider.name = item.name
provider.env = [...item.env]
provider.api = item.npm provider.api = item.npm
? { ? {
type: "aisdk", type: "aisdk",

View file

@ -1,13 +1,16 @@
import { Effect } from "effect" import { Effect } from "effect"
import { Integration } from "../../integration"
import { PluginV2 } from "../../plugin" import { PluginV2 } from "../../plugin"
export const LLMGatewayPlugin = PluginV2.define({ export const LLMGatewayPlugin = PluginV2.define({
id: PluginV2.ID.make("llmgateway"), id: PluginV2.ID.make("llmgateway"),
effect: Effect.gen(function* () { effect: Effect.gen(function* () {
const integrations = yield* Integration.Service
return { return {
"catalog.transform": Effect.fn(function* (evt) { "catalog.transform": Effect.fn(function* (evt) {
for (const item of evt.provider.list()) { 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.type !== "aisdk") continue
if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue
if (item.provider.api.url !== "https://api.llmgateway.io/v1") continue if (item.provider.api.url !== "https://api.llmgateway.io/v1") continue

View file

@ -32,11 +32,11 @@ const headlessMethodID = Integration.MethodID.make("chatgpt-headless")
export const browser = { export const browser = {
integrationID: Integration.ID.make("openai"), integrationID: Integration.ID.make("openai"),
method: new Integration.OAuthMethod({ method: {
id: browserMethodID, id: browserMethodID,
type: "oauth", type: "oauth",
label: "ChatGPT Pro/Plus (browser)", label: "ChatGPT Pro/Plus (browser)",
}), },
authorize: () => authorize: () =>
Effect.gen(function* () { Effect.gen(function* () {
const pkce = yield* Effect.promise(generatePKCE) const pkce = yield* Effect.promise(generatePKCE)
@ -89,11 +89,11 @@ export const browser = {
export const headless = { export const headless = {
integrationID: Integration.ID.make("openai"), integrationID: Integration.ID.make("openai"),
method: new Integration.OAuthMethod({ method: {
id: headlessMethodID, id: headlessMethodID,
type: "oauth", type: "oauth",
label: "ChatGPT Pro/Plus (headless)", label: "ChatGPT Pro/Plus (headless)",
}), },
authorize: () => authorize: () =>
Effect.gen(function* () { Effect.gen(function* () {
const device = yield* request<{ device_auth_id: string; user_code: string; interval: string }>( const device = yield* request<{ device_auth_id: string; user_code: string; interval: string }>(

View file

@ -1,20 +1,22 @@
import { Effect } from "effect" import { Effect } from "effect"
import { Integration } from "../../integration"
import { PluginV2 } from "../../plugin" import { PluginV2 } from "../../plugin"
import { ProviderV2 } from "../../provider" import { ProviderV2 } from "../../provider"
export const OpencodePlugin = PluginV2.define({ export const OpencodePlugin = PluginV2.define({
id: PluginV2.ID.make("opencode"), id: PluginV2.ID.make("opencode"),
effect: Effect.gen(function* () { effect: Effect.gen(function* () {
const integrations = yield* Integration.Service
let hasKey = false let hasKey = false
return { return {
"catalog.transform": Effect.fn(function* (evt) { "catalog.transform": Effect.fn(function* (evt) {
const item = evt.provider.get(ProviderV2.ID.opencode) const item = evt.provider.get(ProviderV2.ID.opencode)
if (!item) return if (!item) return
const integration = yield* integrations.get(Integration.ID.make(item.provider.id))
hasKey = Boolean( hasKey = Boolean(
process.env.OPENCODE_API_KEY || process.env.OPENCODE_API_KEY ||
item.provider.env.some((env) => process.env[env]) || integration?.connections.length ||
item.provider.request.body.apiKey || item.provider.request.body.apiKey,
(item.provider.enabled && item.provider.enabled.via === "credential"),
) )
evt.provider.update(item.provider.id, (provider) => { evt.provider.update(item.provider.id, (provider) => {
if (!hasKey) provider.request.body.apiKey = "public" if (!hasKey) provider.request.body.apiKey = "public"

View file

@ -2,7 +2,6 @@ export * as ProviderV2 from "./provider"
import { withStatics } from "./schema" import { withStatics } from "./schema"
import { Schema } from "effect" import { Schema } from "effect"
import { Credential } from "./credential"
export const ID = Schema.String.pipe( export const ID = Schema.String.pipe(
Schema.brand("ProviderV2.ID"), Schema.brand("ProviderV2.ID"),
@ -48,22 +47,7 @@ export type Request = typeof Request.Type
export class Info extends Schema.Class<Info>("ProviderV2.Info")({ export class Info extends Schema.Class<Info>("ProviderV2.Info")({
id: ID, id: ID,
name: Schema.String, name: Schema.String,
enabled: Schema.Union([ disabled: Schema.Boolean.pipe(Schema.optional),
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),
api: Api, api: Api,
request: Request, request: Request,
}) { }) {
@ -71,8 +55,6 @@ export class Info extends Schema.Class<Info>("ProviderV2.Info")({
return new Info({ return new Info({
id: providerID, id: providerID,
name: providerID, name: providerID,
enabled: false,
env: [],
api: { api: {
type: "native", type: "native",
settings: {}, settings: {},

View file

@ -8,6 +8,9 @@ import { Auth, type AnyRoute } from "@opencode-ai/llm/route"
import { Context, Effect, Layer, Option, Schema } from "effect" import { Context, Effect, Layer, Option, Schema } from "effect"
import { produce } from "immer" import { produce } from "immer"
import { Catalog } from "../../catalog" import { Catalog } from "../../catalog"
import { Credential } from "../../credential"
import { Integration } from "../../integration"
import { IntegrationConnection } from "../../integration/connection"
import { ModelV2 } from "../../model" import { ModelV2 } from "../../model"
import { ModelRequest } from "../../model-request" import { ModelRequest } from "../../model-request"
import { PluginBoot } from "../../plugin/boot" 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. */ /** 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, 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 const value = model.request.body.apiKey ?? model.api.settings?.apiKey
if (typeof value === "string") return Auth.value(value) 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) => { const withDefaults = (model: ModelV2.Info, route: AnyRoute) => {
@ -83,41 +92,48 @@ const apiName = (model: ModelV2.Info) =>
export const fromCatalogModel = ( export const fromCatalogModel = (
model: ModelV2.Info, model: ModelV2.Info,
provider?: ProviderV2.Info, connection?: IntegrationConnection.Info,
credential?: Credential.Stored,
): Effect.Effect<Model, UnsupportedApiError> => { ): Effect.Effect<Model, UnsupportedApiError> => {
const key = apiKey(model, provider) const resolved =
if (model.api.type === "aisdk" && model.api.package === "@ai-sdk/openai") { 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( return Effect.succeed(
withDefaults(model, OpenAIResponses.route) withDefaults(resolved, OpenAIResponses.route)
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) }) .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( return Effect.succeed(
withDefaults(model, AnthropicMessages.route) withDefaults(resolved, AnthropicMessages.route)
.with({ auth: key === undefined ? Auth.none : Auth.header("x-api-key", key) }) .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( return Effect.succeed(
withDefaults(model, OpenAICompatibleChat.route) withDefaults(resolved, OpenAICompatibleChat.route)
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) }) .with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
.model({ id: model.api.id }), .model({ id: resolved.api.id }),
) )
} }
return Effect.fail( return Effect.fail(
new UnsupportedApiError({ new UnsupportedApiError({
providerID: model.providerID, providerID: resolved.providerID,
modelID: model.id, modelID: resolved.id,
api: apiName(model), api: apiName(resolved),
}), }),
) )
} }
export const resolve = (session: SessionSchema.Info, model: ModelV2.Info, provider?: ProviderV2.Info) => export const resolve = (session: SessionSchema.Info, model: ModelV2.Info) =>
fromCatalogModel(withVariant(model, session.model?.variant), provider) fromCatalogModel(withVariant(model, session.model?.variant))
export const supported = (model: ModelV2.Info) => export const supported = (model: ModelV2.Info) =>
model.api.type === "aisdk" && model.api.type === "aisdk" &&
@ -130,6 +146,8 @@ export const locationLayer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
const credentials = yield* Credential.Service
const integrations = yield* Integration.Service
const boot = yield* PluginBoot.Service const boot = yield* PluginBoot.Service
return Service.of({ return Service.of({
resolve: Effect.fn("SessionRunnerModel.resolve")(function* (session) { 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))) ?? : (Option.getOrUndefined((yield* catalog.model.default()).pipe(Option.filter(supported))) ??
(yield* catalog.model.available()).find(supported)) (yield* catalog.model.available()).find(supported))
if (!selected) return yield* new ModelNotSelectedError({ sessionID: session.id }) 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,
)
}), }),
}) })
}), }),

View file

@ -1,5 +1,5 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { DateTime, Effect, Layer, Option } from "effect" import { DateTime, Effect, Fiber, Layer, Option, Stream } from "effect"
import { Catalog } from "@opencode-ai/core/catalog" import { Catalog } from "@opencode-ai/core/catalog"
import { Integration } from "@opencode-ai/core/integration" import { Integration } from "@opencode-ai/core/integration"
import { Credential } from "@opencode-ai/core/credential" import { Credential } from "@opencode-ai/core/credential"
@ -25,13 +25,29 @@ const it = testEffect(
Layer.provideMerge( Layer.provideMerge(
Layer.mock(Credential.Service)({ Layer.mock(Credential.Service)({
all: () => Effect.succeed([]), all: () => Effect.succeed([]),
list: () => Effect.succeed([]),
}), }),
), ),
), ),
) )
describe("CatalogV2", () => { describe("CatalogV2", () => {
it.effect("projects active credentials without rebuilding catalog state", () => { it.effect("publishes an updated event after catalog changes", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const events = yield* EventV2.Service
const updated = yield* events.subscribe(Catalog.Event.Updated).pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
yield* (yield* catalog.transform())((editor) =>
editor.provider.update(ProviderV2.ID.make("test"), () => {}),
)
expect((yield* Fiber.join(updated)).length).toBe(1)
}),
)
it.effect("derives availability from active credentials without changing provider state", () => {
const integrationID = Integration.ID.make("test") const integrationID = Integration.ID.make("test")
const first = { const first = {
id: Credential.ID.create(), id: Credential.ID.create(),
@ -53,6 +69,7 @@ describe("CatalogV2", () => {
Layer.provideMerge( Layer.provideMerge(
Layer.mock(Credential.Service)({ Layer.mock(Credential.Service)({
all: () => Effect.sync(() => [active]), all: () => Effect.sync(() => [active]),
list: () => Effect.sync(() => [active]),
}), }),
), ),
) )
@ -62,18 +79,44 @@ describe("CatalogV2", () => {
const transform = yield* catalog.transform() const transform = yield* catalog.transform()
yield* transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {})) yield* transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {}))
expect(yield* catalog.provider.get(ProviderV2.ID.make("test"))).toMatchObject({ expect((yield* catalog.provider.available()).map((provider) => provider.id)).toEqual([ProviderV2.ID.make("test")])
enabled: { via: "credential", credentialID: first.id }, expect((yield* catalog.provider.get(ProviderV2.ID.make("test"))).request.body).toEqual({})
request: { body: { apiKey: "first", tenant: "one" } },
})
active = second active = second
expect(yield* catalog.provider.get(ProviderV2.ID.make("test"))).toMatchObject({ expect((yield* catalog.provider.available()).map((provider) => provider.id)).toEqual([ProviderV2.ID.make("test")])
enabled: { via: "credential", credentialID: second.id }, expect((yield* catalog.provider.get(ProviderV2.ID.make("test"))).request.body).toEqual({})
request: { body: { apiKey: "second", tenant: "two" } },
})
}).pipe(Effect.provide(layer)) }).pipe(Effect.provide(layer))
}) })
it.effect("projects environment connections without a catalog plugin", () =>
Effect.acquireUseRelease(
Effect.sync(() => {
const previous = process.env.CATALOG_TEST_API_KEY
process.env.CATALOG_TEST_API_KEY = "secret"
return previous
}),
() =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const integrations = yield* Integration.Service
const providerID = ProviderV2.ID.make("test")
yield* integrations.update((editor) =>
editor.method.update({
integrationID: Integration.ID.make(providerID),
method: { type: "env", names: ["CATALOG_TEST_API_KEY"] },
}),
)
yield* (yield* catalog.transform())((editor) => editor.provider.update(providerID, () => {}))
expect((yield* catalog.provider.available()).map((provider) => provider.id)).toContain(providerID)
}),
(previous) =>
Effect.sync(() => {
if (previous === undefined) delete process.env.CATALOG_TEST_API_KEY
else process.env.CATALOG_TEST_API_KEY = previous
}),
),
)
it.effect("normalizes provider baseURL into api url", () => it.effect("normalizes provider baseURL into api url", () =>
Effect.gen(function* () { Effect.gen(function* () {
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
@ -292,9 +335,7 @@ describe("CatalogV2", () => {
const transform = yield* catalog.transform() const transform = yield* catalog.transform()
yield* transform((catalog) => { yield* transform((catalog) => {
catalog.provider.update(providerID, (provider) => { catalog.provider.update(providerID, () => {})
provider.enabled = { via: "custom", data: {} }
})
catalog.model.update(providerID, ModelV2.ID.make("old"), (model) => { catalog.model.update(providerID, ModelV2.ID.make("old"), (model) => {
model.time.released = DateTime.makeUnsafe(1000) model.time.released = DateTime.makeUnsafe(1000)
}) })
@ -316,9 +357,7 @@ describe("CatalogV2", () => {
const transform = yield* catalog.transform() const transform = yield* catalog.transform()
const models = (catalog: Catalog.Editor) => { const models = (catalog: Catalog.Editor) => {
catalog.provider.update(providerID, (provider) => { catalog.provider.update(providerID, () => {})
provider.enabled = { via: "custom", data: {} }
})
catalog.model.update(providerID, old, (model) => { catalog.model.update(providerID, old, (model) => {
model.time.released = DateTime.makeUnsafe(1000) model.time.released = DateTime.makeUnsafe(1000)
}) })
@ -349,12 +388,10 @@ describe("CatalogV2", () => {
yield* transform((catalog) => { yield* transform((catalog) => {
catalog.provider.update(disabledProvider, (provider) => { catalog.provider.update(disabledProvider, (provider) => {
provider.enabled = false provider.disabled = true
}) })
catalog.model.update(disabledProvider, disabledModel, () => {}) catalog.model.update(disabledProvider, disabledModel, () => {})
catalog.provider.update(enabledProvider, (provider) => { catalog.provider.update(enabledProvider, () => {})
provider.enabled = { via: "custom", data: {} }
})
catalog.model.update(enabledProvider, fallbackModel, () => {}) catalog.model.update(enabledProvider, fallbackModel, () => {})
catalog.model.default.set(disabledProvider, disabledModel) catalog.model.default.set(disabledProvider, disabledModel)
}) })

View file

@ -3,10 +3,11 @@ import { Effect, Option, Schema } from "effect"
import { Catalog } from "@opencode-ai/core/catalog" import { Catalog } from "@opencode-ai/core/catalog"
import { Config } from "@opencode-ai/core/config" import { Config } from "@opencode-ai/core/config"
import { ConfigProviderPlugin } from "@opencode-ai/core/config/plugin/provider" import { ConfigProviderPlugin } from "@opencode-ai/core/config/plugin/provider"
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"
import { ProviderV2 } from "@opencode-ai/core/provider" import { ProviderV2 } from "@opencode-ai/core/provider"
import { it } from "../plugin/provider-helper" import { it, withEnv } from "../plugin/provider-helper"
function request(headers: Record<string, string>, variant?: string) { function request(headers: Record<string, string>, variant?: string) {
return { return {
@ -21,6 +22,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
it.effect("partitions existing model variant bodies without changing config shape", () => it.effect("partitions existing model variant bodies without changing config shape", () =>
Effect.gen(function* () { Effect.gen(function* () {
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
const integrations = yield* Integration.Service
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const providerID = ProviderV2.ID.opencode const providerID = ProviderV2.ID.opencode
const modelID = ModelV2.ID.make("alpha-gpt-next") const modelID = ModelV2.ID.make("alpha-gpt-next")
@ -59,6 +61,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
effect: ConfigProviderPlugin.Plugin.effect.pipe( effect: ConfigProviderPlugin.Plugin.effect.pipe(
Effect.provideService(Config.Service, config), Effect.provideService(Config.Service, config),
Effect.provideService(Catalog.Service, catalog), Effect.provideService(Catalog.Service, catalog),
Effect.provideService(Integration.Service, integrations),
), ),
}) })
@ -80,6 +83,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
it.effect("uses the effective provider package across layered config", () => it.effect("uses the effective provider package across layered config", () =>
Effect.gen(function* () { Effect.gen(function* () {
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
const integrations = yield* Integration.Service
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const providerID = ProviderV2.ID.opencode const providerID = ProviderV2.ID.opencode
const modelID = ModelV2.ID.make("alpha-gpt-next") const modelID = ModelV2.ID.make("alpha-gpt-next")
@ -118,6 +122,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
effect: ConfigProviderPlugin.Plugin.effect.pipe( effect: ConfigProviderPlugin.Plugin.effect.pipe(
Effect.provideService(Config.Service, config), Effect.provideService(Config.Service, config),
Effect.provideService(Catalog.Service, catalog), Effect.provideService(Catalog.Service, catalog),
Effect.provideService(Integration.Service, integrations),
), ),
}) })
@ -131,8 +136,9 @@ describe("ConfigProviderPlugin.Plugin", () => {
) )
it.effect("loads configured providers and applies later model overrides", () => it.effect("loads configured providers and applies later model overrides", () =>
Effect.gen(function* () { withEnv({ CUSTOM_API_KEY: "secret" }, () => Effect.gen(function* () {
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
const integrations = yield* Integration.Service
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const providerID = ProviderV2.ID.make("custom") const providerID = ProviderV2.ID.make("custom")
const modelID = ModelV2.ID.make("chat") const modelID = ModelV2.ID.make("chat")
@ -218,6 +224,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
effect: ConfigProviderPlugin.Plugin.effect.pipe( effect: ConfigProviderPlugin.Plugin.effect.pipe(
Effect.provideService(Config.Service, config), Effect.provideService(Config.Service, config),
Effect.provideService(Catalog.Service, catalog), Effect.provideService(Catalog.Service, catalog),
Effect.provideService(Integration.Service, integrations),
), ),
}) })
@ -225,8 +232,11 @@ describe("ConfigProviderPlugin.Plugin", () => {
const model = yield* catalog.model.get(providerID, modelID) const model = yield* catalog.model.get(providerID, modelID)
expect(Option.getOrUndefined(yield* catalog.model.default())?.id).toBe(ModelV2.ID.make("default")) expect(Option.getOrUndefined(yield* catalog.model.default())?.id).toBe(ModelV2.ID.make("default"))
expect(provider.name).toBe("Renamed") expect(provider.name).toBe("Renamed")
expect(provider.env).toEqual(["CUSTOM_API_KEY"]) expect((yield* integrations.get(Integration.ID.make("custom")))?.methods).toContainEqual(
expect(provider.enabled).toEqual({ via: "custom", data: {} }) { type: "env", names: ["CUSTOM_API_KEY"] },
)
expect((yield* integrations.get(Integration.ID.make("custom")))?.name).toBe("Renamed")
expect(provider.disabled).toBeUndefined()
expect(provider.api).toEqual({ type: "aisdk", package: "custom-sdk", url: "https://example.test" }) expect(provider.api).toEqual({ type: "aisdk", package: "custom-sdk", url: "https://example.test" })
expect(provider.request.headers).toEqual({ first: "first", shared: "last", last: "last" }) expect(provider.request.headers).toEqual({ first: "first", shared: "last", last: "last" })
expect(model.api.id).toBe(ModelV2.ID.make("api-chat")) expect(model.api.id).toBe(ModelV2.ID.make("api-chat"))
@ -243,6 +253,6 @@ describe("ConfigProviderPlugin.Plugin", () => {
]) ])
expect(model.variants[0]?.headers).toEqual({ first: "first", shared: "last", last: "last" }) expect(model.variants[0]?.headers).toEqual({ first: "first", shared: "last", last: "last" })
expect(model.variants[1]?.headers).toEqual({ slow: "slow" }) expect(model.variants[1]?.headers).toEqual({ slow: "slow" })
}), })),
) )
}) })

View file

@ -1,8 +1,7 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Duration, Effect, Exit, Layer, Scope } from "effect" import { Duration, Effect, Exit, Fiber, Layer, Scope, Stream } from "effect"
import * as TestClock from "effect/testing/TestClock" import * as TestClock from "effect/testing/TestClock"
import { Integration } from "@opencode-ai/core/integration" import { Integration } from "@opencode-ai/core/integration"
import { IntegrationConnection } from "@opencode-ai/core/integration/connection"
import { Credential } from "@opencode-ai/core/credential" import { Credential } from "@opencode-ai/core/credential"
import { EventV2 } from "@opencode-ai/core/event" import { EventV2 } from "@opencode-ai/core/event"
import { it } from "./lib/effect" import { it } from "./lib/effect"
@ -25,7 +24,7 @@ function connectionLayer(
}>, }>,
) { ) {
return Integration.locationLayer.pipe( return Integration.locationLayer.pipe(
Layer.provide(EventV2.defaultLayer), Layer.provideMerge(EventV2.defaultLayer),
Layer.provide( Layer.provide(
Layer.mock(Credential.Service)({ Layer.mock(Credential.Service)({
create: (input) => create: (input) =>
@ -103,7 +102,7 @@ describe("Integration", () => {
.update((editor) => .update((editor) =>
editor.method.update({ editor.method.update({
integrationID, integrationID,
method: new Integration.OAuthMethod({ id: methodID, type: "oauth", label: "ChatGPT" }), method: { id: methodID, type: "oauth", label: "ChatGPT" },
authorize, authorize,
}), }),
) )
@ -117,7 +116,7 @@ describe("Integration", () => {
]) ])
editor.method.update({ editor.method.update({
integrationID, integrationID,
method: new Integration.OAuthMethod({ id: methodID, type: "oauth", label: "ChatGPT Override" }), method: { id: methodID, type: "oauth", label: "ChatGPT Override" },
authorize, authorize,
}) })
}) })
@ -140,15 +139,22 @@ describe("Integration", () => {
}> = [] }> = []
return Effect.gen(function* () { return Effect.gen(function* () {
const integrations = yield* Integration.Service const integrations = yield* Integration.Service
const events = yield* EventV2.Service
const integrationID = Integration.ID.make("openai") const integrationID = Integration.ID.make("openai")
yield* integrations.update((editor) => yield* integrations.update((editor) =>
editor.method.update({ editor.method.update({
integrationID, integrationID,
method: new Integration.KeyMethod({ type: "key", label: "API key" }), method: { type: "key", label: "API key" },
}), }),
) )
const updated = yield* events.subscribe(Integration.Event.Updated).pipe(
Stream.take(1),
Stream.runCollect,
Effect.forkScoped,
)
yield* Effect.yieldNow
yield* integrations.connect.key({ yield* integrations.connection.key({
integrationID, integrationID,
key: "secret", key: "secret",
label: "Work", label: "Work",
@ -161,6 +167,7 @@ describe("Integration", () => {
value: new Credential.Key({ type: "key", key: "secret" }), value: new Credential.Key({ type: "key", key: "secret" }),
}, },
]) ])
expect((yield* Fiber.join(updated)).length).toBe(1)
}).pipe(Effect.provide(connectionLayer(created))) }).pipe(Effect.provide(connectionLayer(created)))
}) })
@ -177,7 +184,7 @@ describe("Integration", () => {
yield* integrations.update((editor) => yield* integrations.update((editor) =>
editor.method.update({ editor.method.update({
integrationID, integrationID,
method: new Integration.OAuthMethod({ id: methodID, type: "oauth", label: "ChatGPT" }), method: { id: methodID, type: "oauth", label: "ChatGPT" },
authorize: () => authorize: () =>
Effect.succeed({ Effect.succeed({
mode: "code" as const, mode: "code" as const,
@ -198,7 +205,7 @@ describe("Integration", () => {
}), }),
) )
const attempt = yield* integrations.connect.oauth({ const attempt = yield* integrations.connection.oauth({
integrationID, integrationID,
methodID, methodID,
inputs: {}, inputs: {},
@ -236,7 +243,7 @@ describe("Integration", () => {
yield* integrations.update((editor) => yield* integrations.update((editor) =>
editor.method.update({ editor.method.update({
integrationID, integrationID,
method: new Integration.OAuthMethod({ id: methodID, type: "oauth", label: "ChatGPT" }), method: { id: methodID, type: "oauth", label: "ChatGPT" },
authorize: () => authorize: () =>
Effect.addFinalizer(() => Effect.sync(() => (closed = true))).pipe( Effect.addFinalizer(() => Effect.sync(() => (closed = true))).pipe(
Effect.as({ Effect.as({
@ -249,7 +256,7 @@ describe("Integration", () => {
}), }),
) )
const attempt = yield* integrations.connect.oauth({ integrationID, methodID, inputs: {} }) const attempt = yield* integrations.connection.oauth({ integrationID, methodID, inputs: {} })
expect(yield* integrations.attempt.complete({ attemptID: attempt.attemptID }).pipe(Effect.flip)).toBeInstanceOf( expect(yield* integrations.attempt.complete({ attemptID: attempt.attemptID }).pipe(Effect.flip)).toBeInstanceOf(
Integration.CodeRequiredError, Integration.CodeRequiredError,
) )
@ -273,7 +280,7 @@ describe("Integration", () => {
yield* integrations.update((editor) => yield* integrations.update((editor) =>
editor.method.update({ editor.method.update({
integrationID, integrationID,
method: new Integration.OAuthMethod({ id: methodID, type: "oauth", label: "Browser" }), method: { id: methodID, type: "oauth", label: "Browser" },
authorize: () => authorize: () =>
Effect.succeed({ Effect.succeed({
mode: "auto" as const, mode: "auto" as const,
@ -286,7 +293,7 @@ describe("Integration", () => {
}), }),
) )
const attempt = yield* integrations.connect.oauth({ integrationID, methodID, inputs: {} }) const attempt = yield* integrations.connection.oauth({ integrationID, methodID, inputs: {} })
yield* Effect.yieldNow yield* Effect.yieldNow
expect(yield* integrations.attempt.status(attempt.attemptID)).toEqual({ expect(yield* integrations.attempt.status(attempt.attemptID)).toEqual({
status: "complete", status: "complete",
@ -310,7 +317,7 @@ describe("Integration", () => {
yield* integrations.update((editor) => yield* integrations.update((editor) =>
editor.method.update({ editor.method.update({
integrationID, integrationID,
method: new Integration.OAuthMethod({ id: methodID, type: "oauth", label: "Browser" }), method: { id: methodID, type: "oauth", label: "Browser" },
authorize: () => authorize: () =>
Effect.addFinalizer(() => Effect.sync(() => (closed = true))).pipe( Effect.addFinalizer(() => Effect.sync(() => (closed = true))).pipe(
Effect.as({ Effect.as({
@ -323,7 +330,7 @@ describe("Integration", () => {
}), }),
) )
const attempt = yield* integrations.connect.oauth({ integrationID, methodID, inputs: {} }) const attempt = yield* integrations.connection.oauth({ integrationID, methodID, inputs: {} })
expect(attempt.time.expires - attempt.time.created).toBe(Duration.toMillis(Duration.minutes(10))) expect(attempt.time.expires - attempt.time.created).toBe(Duration.toMillis(Duration.minutes(10)))
yield* TestClock.adjust(Duration.minutes(10)) yield* TestClock.adjust(Duration.minutes(10))
yield* Effect.yieldNow yield* Effect.yieldNow
@ -373,23 +380,28 @@ describe("Integration", () => {
yield* integrations.update((editor) => yield* integrations.update((editor) =>
editor.method.update({ editor.method.update({
integrationID, integrationID,
method: new Integration.EnvMethod({ method: {
type: "env", type: "env",
names: ["INTEGRATION_TEST_ACME_KEY", "INTEGRATION_TEST_ACME_MISSING"], names: ["INTEGRATION_TEST_ACME_KEY", "INTEGRATION_TEST_ACME_MISSING"],
}), },
}), }),
) )
// Stored credentials and detected env vars appear as connections. // Stored credentials and detected env vars appear as connections.
expect((yield* integrations.get(integrationID))?.connections).toEqual([ expect((yield* integrations.get(integrationID))?.connections).toEqual([
new IntegrationConnection.CredentialInfo({ type: "credential", id: rows[0]!.id, label: "Work" }), { type: "credential", id: rows[0]!.id, label: "Work" },
new IntegrationConnection.CredentialInfo({ {
type: "credential", type: "credential",
id: rows[1]!.id, id: rows[1]!.id,
label: "Personal", label: "Personal",
}), },
new IntegrationConnection.EnvInfo({ type: "env", name: "INTEGRATION_TEST_ACME_KEY" }), { type: "env", name: "INTEGRATION_TEST_ACME_KEY" },
]) ])
expect(yield* integrations.connection.forIntegration(integrationID)).toEqual({
type: "credential",
id: rows[1]!.id,
label: "Personal",
})
}).pipe(Effect.provide(projectionLayer)), }).pipe(Effect.provide(projectionLayer)),
(previous) => (previous) =>
Effect.sync(() => { Effect.sync(() => {

View file

@ -28,8 +28,10 @@ const connections = Credential.layer.pipe(
Layer.provide(Database.layerFromPath(":memory:").pipe(Layer.fresh)), Layer.provide(Database.layerFromPath(":memory:").pipe(Layer.fresh)),
Layer.provide(events), Layer.provide(events),
) )
const catalog = Catalog.layer.pipe(Layer.provide(Layer.mergeAll(events, locationLayer, plugins, policy, connections)))
const integrations = Integration.locationLayer.pipe(Layer.provide(events), Layer.provide(connections)) const integrations = Integration.locationLayer.pipe(Layer.provide(events), Layer.provide(connections))
const catalog = Catalog.layer.pipe(
Layer.provide(Layer.mergeAll(events, locationLayer, plugins, policy, connections, integrations)),
)
const layer = Layer.mergeAll( const layer = Layer.mergeAll(
catalog.pipe(Layer.provide(connections)), catalog.pipe(Layer.provide(connections)),
integrations, integrations,
@ -61,11 +63,11 @@ describe("ModelsDevPlugin", () => {
id: Integration.ID.make("acme"), id: Integration.ID.make("acme"),
name: "Acme", name: "Acme",
methods: [ methods: [
new Integration.KeyMethod({ type: "key" }), { type: "key" },
new Integration.EnvMethod({ {
type: "env", type: "env",
names: ["ACME_API_KEY"], names: ["ACME_API_KEY"],
}), },
], ],
connections: [], connections: [],
}), }),

View file

@ -53,6 +53,7 @@ const integrations = Integration.locationLayer.pipe(
Layer.provide( Layer.provide(
Layer.mock(Credential.Service)({ Layer.mock(Credential.Service)({
create: () => Effect.die("unexpected credential creation"), create: () => Effect.die("unexpected credential creation"),
all: () => Effect.succeed([]),
list: () => Effect.succeed([]), list: () => Effect.succeed([]),
}), }),
), ),

View file

@ -1,6 +1,7 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog" import { Catalog } from "@opencode-ai/core/catalog"
import { Integration } from "@opencode-ai/core/integration"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
import { LLMGatewayPlugin } from "@opencode-ai/core/plugin/provider/llmgateway" import { LLMGatewayPlugin } from "@opencode-ai/core/plugin/provider/llmgateway"
@ -8,6 +9,14 @@ import { ProviderV2 } from "@opencode-ai/core/provider"
import { expectPluginRegistered, it, provider } from "./provider-helper" import { expectPluginRegistered, it, provider } from "./provider-helper"
describe("LLMGatewayPlugin", () => { describe("LLMGatewayPlugin", () => {
const add = Effect.fnUntraced(function* (plugin: PluginV2.Interface) {
const integrations = yield* Integration.Service
yield* plugin.add({
...LLMGatewayPlugin,
effect: LLMGatewayPlugin.effect.pipe(Effect.provideService(Integration.Service, integrations)),
})
})
it.effect("is registered so legacy referer headers can be applied", () => it.effect("is registered so legacy referer headers can be applied", () =>
Effect.sync(() => Effect.sync(() =>
expectPluginRegistered( expectPluginRegistered(
@ -21,25 +30,23 @@ describe("LLMGatewayPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* plugin.add(LLMGatewayPlugin) yield* add(plugin)
const integrations = yield* Integration.Service
yield* integrations.update((editor) => {
editor.update(Integration.ID.make("llmgateway"), () => {})
editor.update(Integration.ID.make("openrouter"), () => {})
})
const transform = yield* catalog.transform() const transform = yield* catalog.transform()
yield* transform((catalog) => { yield* transform((catalog) => {
const llmgateway = provider("llmgateway", { const llmgateway = provider("llmgateway", {
enabled: { via: "env", name: "LLMGATEWAY_API_KEY" },
api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.llmgateway.io/v1" }, api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.llmgateway.io/v1" },
request: { headers: { Existing: "value" }, body: {} }, request: { headers: { Existing: "value" }, body: {} },
}) })
catalog.provider.update(llmgateway.id, (draft) => { catalog.provider.update(llmgateway.id, (draft) => {
draft.enabled = llmgateway.enabled
draft.api = llmgateway.api draft.api = llmgateway.api
draft.request = llmgateway.request draft.request = llmgateway.request
}) })
const openrouter = provider("openrouter", { catalog.provider.update(ProviderV2.ID.openrouter, () => {})
enabled: { via: "env", name: "OPENROUTER_API_KEY" },
})
catalog.provider.update(openrouter.id, (draft) => {
draft.enabled = openrouter.enabled
})
}) })
expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).request.headers).toEqual({ expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).request.headers).toEqual({
Existing: "value", Existing: "value",
@ -55,7 +62,7 @@ describe("LLMGatewayPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* plugin.add(LLMGatewayPlugin) yield* add(plugin)
const transform = yield* catalog.transform() const transform = yield* catalog.transform()
yield* transform((catalog) => { yield* transform((catalog) => {
const item = provider("llmgateway", { const item = provider("llmgateway", {
@ -66,7 +73,7 @@ describe("LLMGatewayPlugin", () => {
}) })
}) })
expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).enabled).toBe(false) expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).disabled).toBeUndefined()
expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).request.headers).toEqual({}) expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).request.headers).toEqual({})
}), }),
) )

View file

@ -21,16 +21,16 @@ describe("OpenAIPlugin", () => {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* add(plugin, yield* Integration.Service) yield* add(plugin, yield* Integration.Service)
expect((yield* (yield* Integration.Service).get(Integration.ID.make("openai")))?.methods).toEqual([ expect((yield* (yield* Integration.Service).get(Integration.ID.make("openai")))?.methods).toEqual([
new Integration.OAuthMethod({ {
id: Integration.MethodID.make("chatgpt-browser"), id: Integration.MethodID.make("chatgpt-browser"),
type: "oauth", type: "oauth",
label: "ChatGPT Pro/Plus (browser)", label: "ChatGPT Pro/Plus (browser)",
}), },
new Integration.OAuthMethod({ {
id: Integration.MethodID.make("chatgpt-headless"), id: Integration.MethodID.make("chatgpt-headless"),
type: "oauth", type: "oauth",
label: "ChatGPT Pro/Plus (headless)", label: "ChatGPT Pro/Plus (headless)",
}), },
]) ])
}), }),
) )

View file

@ -3,6 +3,7 @@ import { DateTime, Effect, Layer, Option } from "effect"
import { Catalog } from "@opencode-ai/core/catalog" import { Catalog } from "@opencode-ai/core/catalog"
import { Credential } from "@opencode-ai/core/credential" import { Credential } from "@opencode-ai/core/credential"
import { EventV2 } from "@opencode-ai/core/event" import { EventV2 } from "@opencode-ai/core/event"
import { Integration } from "@opencode-ai/core/integration"
import { Location } from "@opencode-ai/core/location" import { Location } from "@opencode-ai/core/location"
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"
@ -18,13 +19,18 @@ const locationLayer = Layer.succeed(
Location.Service.of(location({ directory: AbsolutePath.make("test") })), Location.Service.of(location({ directory: AbsolutePath.make("test") })),
) )
const pluginWithIntegrations = (integrations: Integration.Interface) => ({
...OpencodePlugin,
effect: OpencodePlugin.effect.pipe(Effect.provideService(Integration.Service, integrations)),
})
describe("OpencodePlugin", () => { describe("OpencodePlugin", () => {
it.effect("uses a public key and disables paid models without credentials", () => it.effect("uses a public key and disables paid models without credentials", () =>
withEnv({ OPENCODE_API_KEY: undefined }, () => withEnv({ OPENCODE_API_KEY: undefined }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* plugin.add(OpencodePlugin) yield* plugin.add(pluginWithIntegrations(yield* Integration.Service))
const transform = yield* catalog.transform() const transform = yield* catalog.transform()
yield* transform((catalog) => { yield* transform((catalog) => {
const item = provider("opencode") const item = provider("opencode")
@ -45,7 +51,7 @@ describe("OpencodePlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* plugin.add(OpencodePlugin) yield* plugin.add(pluginWithIntegrations(yield* Integration.Service))
const transform = yield* catalog.transform() const transform = yield* catalog.transform()
yield* transform((catalog) => { yield* transform((catalog) => {
const item = provider("opencode") const item = provider("opencode")
@ -66,7 +72,7 @@ describe("OpencodePlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* plugin.add(OpencodePlugin) yield* plugin.add(pluginWithIntegrations(yield* Integration.Service))
const transform = yield* catalog.transform() const transform = yield* catalog.transform()
yield* transform((catalog) => { yield* transform((catalog) => {
const item = provider("opencode") const item = provider("opencode")
@ -87,7 +93,7 @@ describe("OpencodePlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* plugin.add(OpencodePlugin) yield* plugin.add(pluginWithIntegrations(yield* Integration.Service))
const transform = yield* catalog.transform() const transform = yield* catalog.transform()
yield* transform((catalog) => { yield* transform((catalog) => {
const item = provider("opencode") const item = provider("opencode")
@ -108,13 +114,18 @@ describe("OpencodePlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* plugin.add(OpencodePlugin) const integrations = yield* Integration.Service
yield* plugin.add(pluginWithIntegrations(integrations))
yield* integrations.update((editor) => {
editor.method.update({
integrationID: Integration.ID.make("opencode"),
method: { type: "env", names: ["CUSTOM_OPENCODE_API_KEY"] },
})
})
const transform = yield* catalog.transform() const transform = yield* catalog.transform()
yield* transform((catalog) => { yield* transform((catalog) => {
const item = provider("opencode", { env: ["CUSTOM_OPENCODE_API_KEY"] }) const item = provider("opencode")
catalog.provider.update(item.id, (draft) => { catalog.provider.update(item.id, () => {})
draft.env = [...item.env]
})
const paid = model("opencode", "paid", { cost: cost(1) }) const paid = model("opencode", "paid", { cost: cost(1) })
catalog.model.update(item.id, paid.id, (draft) => { catalog.model.update(item.id, paid.id, (draft) => {
draft.cost = [...paid.cost] draft.cost = [...paid.cost]
@ -131,7 +142,7 @@ describe("OpencodePlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* plugin.add(OpencodePlugin) yield* plugin.add(pluginWithIntegrations(yield* Integration.Service))
const transform = yield* catalog.transform() const transform = yield* catalog.transform()
yield* transform((catalog) => { yield* transform((catalog) => {
const item = provider("opencode", { const item = provider("opencode", {
@ -154,37 +165,12 @@ describe("OpencodePlugin", () => {
), ),
) )
it.effect("uses auth-enabled providers as credentials", () =>
withEnv({ OPENCODE_API_KEY: undefined }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpencodePlugin)
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const item = provider("opencode", {
enabled: { via: "credential", credentialID: Credential.ID.make("credential") },
})
catalog.provider.update(item.id, (draft) => {
draft.enabled = item.enabled
})
const paid = model("opencode", "paid", { cost: cost(1) })
catalog.model.update(item.id, paid.id, (draft) => {
draft.cost = [...paid.cost]
})
})
expect((yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBeUndefined()
expect((yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true)
}),
),
)
it.effect("ignores non-opencode providers and models", () => it.effect("ignores non-opencode providers and models", () =>
withEnv({ OPENCODE_API_KEY: undefined }, () => withEnv({ OPENCODE_API_KEY: undefined }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* plugin.add(OpencodePlugin) yield* plugin.add(pluginWithIntegrations(yield* Integration.Service))
const transform = yield* catalog.transform() const transform = yield* catalog.transform()
yield* transform((catalog) => { yield* transform((catalog) => {
const item = provider("openai") const item = provider("openai")

View file

@ -3,6 +3,8 @@ import { LLM } from "@opencode-ai/llm"
import { LLMClient } from "@opencode-ai/llm/route" import { LLMClient } from "@opencode-ai/llm/route"
import { ConfigProvider, DateTime, Effect } from "effect" import { ConfigProvider, DateTime, Effect } from "effect"
import { Headers } from "effect/unstable/http" import { Headers } from "effect/unstable/http"
import { Credential } from "@opencode-ai/core/credential"
import { Integration } from "@opencode-ai/core/integration"
import { ModelV2 } from "@opencode-ai/core/model" import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider" import { ProviderV2 } from "@opencode-ai/core/provider"
import { ProjectV2 } from "@opencode-ai/core/project" import { ProjectV2 } from "@opencode-ai/core/project"
@ -45,8 +47,6 @@ const provider = (api: ProviderV2.Info["api"]) =>
new ProviderV2.Info({ new ProviderV2.Info({
id: ProviderV2.ID.make("test-provider"), id: ProviderV2.ID.make("test-provider"),
name: "Test provider", name: "Test provider",
enabled: { via: "env", name: "TEST_PROVIDER_API_KEY" },
env: ["TEST_PROVIDER_API_KEY"],
api, api,
request: { headers: {}, body: {} }, request: { headers: {}, body: {} },
}) })
@ -247,7 +247,7 @@ describe("SessionRunnerModel", () => {
...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }), ...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
request: { headers: {}, body: {}, generation: {}, options: {} }, request: { headers: {}, body: {}, generation: {}, options: {} },
}), }),
provider({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }), { type: "env", name: "TEST_PROVIDER_API_KEY" },
) )
const request = LLM.request({ model: resolved, prompt: "Hello" }) const request = LLM.request({ model: resolved, prompt: "Hello" })
const headers = yield* resolved.route.auth const headers = yield* resolved.route.auth
@ -266,6 +266,35 @@ describe("SessionRunnerModel", () => {
}), }),
) )
it.effect("prefers stored credentials over configured auth", () =>
Effect.gen(function* () {
const credential = new Credential.Stored({
id: Credential.ID.create(),
integrationID: Integration.ID.make("test-provider"),
label: "Work",
value: new Credential.Key({ type: "key", key: "stored-secret", metadata: { tenant: "work" } }),
})
const resolved = yield* SessionRunnerModel.fromCatalogModel(
new ModelV2.Info({
...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
request: { headers: {}, body: { apiKey: "configured-secret" }, generation: {}, options: {} },
}),
{ type: "credential", id: credential.id, label: credential.label },
credential,
)
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(headers.authorization).toBe("Bearer stored-secret")
expect(resolved.route.defaults.http?.body).toEqual({ tenant: "work" })
}),
)
it.effect("rejects catalog APIs without a native route", () => it.effect("rejects catalog APIs without a native route", () =>
Effect.gen(function* () { Effect.gen(function* () {
const failure = yield* SessionRunnerModel.fromCatalogModel( const failure = yield* SessionRunnerModel.fromCatalogModel(

View file

@ -5812,10 +5812,24 @@ export class Credential extends HeyApiClient {
public remove<ThrowOnError extends boolean = false>( public remove<ThrowOnError extends boolean = false>(
parameters: { parameters: {
credentialID: string credentialID: string
location?: {
directory?: string
workspace?: string
}
}, },
options?: Options<never, ThrowOnError>, options?: Options<never, ThrowOnError>,
) { ) {
const params = buildClientParams([parameters], [{ args: [{ in: "path", key: "credentialID" }] }]) const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "path", key: "credentialID" },
{ in: "query", key: "location" },
],
},
],
)
return (options?.client ?? this.client).delete<V2CredentialRemoveResponses, V2CredentialRemoveErrors, ThrowOnError>( return (options?.client ?? this.client).delete<V2CredentialRemoveResponses, V2CredentialRemoveErrors, ThrowOnError>(
{ {
url: "/api/credential/{credentialID}", url: "/api/credential/{credentialID}",
@ -5833,6 +5847,10 @@ export class Credential extends HeyApiClient {
public update<ThrowOnError extends boolean = false>( public update<ThrowOnError extends boolean = false>(
parameters: { parameters: {
credentialID: string credentialID: string
location?: {
directory?: string
workspace?: string
}
label?: string label?: string
}, },
options?: Options<never, ThrowOnError>, options?: Options<never, ThrowOnError>,
@ -5843,6 +5861,7 @@ export class Credential extends HeyApiClient {
{ {
args: [ args: [
{ in: "path", key: "credentialID" }, { in: "path", key: "credentialID" },
{ in: "query", key: "location" },
{ in: "body", key: "label" }, { in: "body", key: "label" },
], ],
}, },

View file

@ -7,7 +7,8 @@ export type ClientOptions = {
export type Event = export type Event =
| EventModelsDevRefreshed | EventModelsDevRefreshed
| EventPluginAdded | EventPluginAdded
| EventCatalogModelUpdated | EventIntegrationUpdated
| EventCatalogUpdated
| EventSessionCreated | EventSessionCreated
| EventSessionUpdated | EventSessionUpdated
| EventSessionDeleted | EventSessionDeleted
@ -52,7 +53,6 @@ export type Event =
| EventInstallationUpdated | EventInstallationUpdated
| EventInstallationUpdateAvailable | EventInstallationUpdateAvailable
| EventFileEdited | EventFileEdited
| EventIntegrationUpdated
| EventPermissionV2Asked | EventPermissionV2Asked
| EventPermissionV2Replied | EventPermissionV2Replied
| EventReferenceUpdated | EventReferenceUpdated
@ -746,9 +746,16 @@ export type GlobalEvent = {
} }
| { | {
id: string id: string
type: "catalog.model.updated" type: "integration.updated"
properties: { properties: {
model: ModelV2Info [key: string]: unknown
}
}
| {
id: string
type: "catalog.updated"
properties: {
[key: string]: unknown
} }
} }
| { | {
@ -1257,13 +1264,6 @@ export type GlobalEvent = {
file: string file: string
} }
} }
| {
id: string
type: "integration.updated"
properties: {
[key: string]: unknown
}
}
| { | {
id: string id: string
type: "permission.v2.asked" type: "permission.v2.asked"
@ -2836,102 +2836,6 @@ export type MoveSessionDestination = {
directory: string directory: string
} }
export type ModelV2Info = {
id: string
providerID: string
family?: string
name: string
api:
| {
id: string
type: "aisdk"
package: string
url?: string
settings?: {
[key: string]: unknown
}
}
| {
id: string
type: "native"
url?: string
settings: {
[key: string]: unknown
}
}
capabilities: {
tools: boolean
input: Array<string>
output: Array<string>
}
request: {
headers: {
[key: string]: string
}
body: {
[key: string]: unknown
}
generation?: {
maxTokens?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
temperature?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
topP?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
topK?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
frequencyPenalty?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
presencePenalty?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
seed?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
stop?: Array<string>
}
options?: {
[key: string]: unknown
}
variant?: string
}
variants: Array<{
id: string
headers: {
[key: string]: string
}
body: {
[key: string]: unknown
}
generation?: {
maxTokens?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
temperature?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
topP?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
topK?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
frequencyPenalty?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
presencePenalty?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
seed?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
stop?: Array<string>
}
options?: {
[key: string]: unknown
}
}>
time: {
released: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
}
cost: Array<{
tier?: {
type: "context"
size: number
}
input: number
output: number
cache: {
read: number
write: number
}
}>
status: "alpha" | "beta" | "deprecated" | "active"
enabled: boolean
limit: {
context: number
input?: number
output: number
}
}
export type LocationRef = { export type LocationRef = {
directory: string directory: string
workspaceID?: string workspaceID?: string
@ -4022,26 +3926,106 @@ export type SessionMessage =
| SessionMessageAssistant | SessionMessageAssistant
| SessionMessageCompaction | SessionMessageCompaction
export type ProviderV2Info = { export type ModelV2Info = {
id: string id: string
providerID: string
family?: string
name: string name: string
enabled: api:
| false
| { | {
via: "env" id: string
name: string type: "aisdk"
} package: string
| { url?: string
via: "credential" settings?: {
credentialID: string
}
| {
via: "custom"
data: {
[key: string]: unknown [key: string]: unknown
} }
} }
env: Array<string> | {
id: string
type: "native"
url?: string
settings: {
[key: string]: unknown
}
}
capabilities: {
tools: boolean
input: Array<string>
output: Array<string>
}
request: {
headers: {
[key: string]: string
}
body: {
[key: string]: unknown
}
generation?: {
maxTokens?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
temperature?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
topP?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
topK?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
frequencyPenalty?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
presencePenalty?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
seed?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
stop?: Array<string>
}
options?: {
[key: string]: unknown
}
variant?: string
}
variants: Array<{
id: string
headers: {
[key: string]: string
}
body: {
[key: string]: unknown
}
generation?: {
maxTokens?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
temperature?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
topP?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
topK?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
frequencyPenalty?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
presencePenalty?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
seed?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
stop?: Array<string>
}
options?: {
[key: string]: unknown
}
}>
time: {
released: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
}
cost: Array<{
tier?: {
type: "context"
size: number
}
input: number
output: number
cache: {
read: number
write: number
}
}>
status: "alpha" | "beta" | "deprecated" | "active"
enabled: boolean
limit: {
context: number
input?: number
output: number
}
}
export type ProviderV2Info = {
id: string
name: string
disabled?: boolean
api: api:
| { | {
type: "aisdk" type: "aisdk"
@ -4248,107 +4232,19 @@ export type EventPluginAdded = {
} }
} }
export type ModelV2Info1 = { export type EventIntegrationUpdated = {
id: string id: string
providerID: string type: "integration.updated"
family?: string properties: {
name: string [key: string]: unknown
api:
| {
id: string
type: "aisdk"
package: string
url?: string
settings?: {
[key: string]: unknown
}
}
| {
id: string
type: "native"
url?: string
settings: {
[key: string]: unknown
}
}
capabilities: {
tools: boolean
input: Array<string>
output: Array<string>
}
request: {
headers: {
[key: string]: string
}
body: {
[key: string]: unknown
}
generation?: {
maxTokens?: number | "NaN" | "Infinity" | "-Infinity"
temperature?: number | "NaN" | "Infinity" | "-Infinity"
topP?: number | "NaN" | "Infinity" | "-Infinity"
topK?: number | "NaN" | "Infinity" | "-Infinity"
frequencyPenalty?: number | "NaN" | "Infinity" | "-Infinity"
presencePenalty?: number | "NaN" | "Infinity" | "-Infinity"
seed?: number | "NaN" | "Infinity" | "-Infinity"
stop?: Array<string>
}
options?: {
[key: string]: unknown
}
variant?: string
}
variants: Array<{
id: string
headers: {
[key: string]: string
}
body: {
[key: string]: unknown
}
generation?: {
maxTokens?: number | "NaN" | "Infinity" | "-Infinity"
temperature?: number | "NaN" | "Infinity" | "-Infinity"
topP?: number | "NaN" | "Infinity" | "-Infinity"
topK?: number | "NaN" | "Infinity" | "-Infinity"
frequencyPenalty?: number | "NaN" | "Infinity" | "-Infinity"
presencePenalty?: number | "NaN" | "Infinity" | "-Infinity"
seed?: number | "NaN" | "Infinity" | "-Infinity"
stop?: Array<string>
}
options?: {
[key: string]: unknown
}
}>
time: {
released: number | "NaN" | "Infinity" | "-Infinity"
}
cost: Array<{
tier?: {
type: "context"
size: number
}
input: number
output: number
cache: {
read: number
write: number
}
}>
status: "alpha" | "beta" | "deprecated" | "active"
enabled: boolean
limit: {
context: number
input?: number
output: number
} }
} }
export type EventCatalogModelUpdated = { export type EventCatalogUpdated = {
id: string id: string
type: "catalog.model.updated" type: "catalog.updated"
properties: { properties: {
model: ModelV2Info1 [key: string]: unknown
} }
} }
@ -4902,14 +4798,6 @@ export type EventFileEdited = {
} }
} }
export type EventIntegrationUpdated = {
id: string
type: "integration.updated"
properties: {
[key: string]: unknown
}
}
export type EventPermissionV2Asked = { export type EventPermissionV2Asked = {
id: string id: string
type: "permission.v2.asked" type: "permission.v2.asked"
@ -10251,7 +10139,12 @@ export type V2CredentialRemoveData = {
path: { path: {
credentialID: string credentialID: string
} }
query?: never query?: {
location?: {
directory?: string
workspace?: string
}
}
url: "/api/credential/{credentialID}" url: "/api/credential/{credentialID}"
} }
@ -10284,7 +10177,12 @@ export type V2CredentialUpdateData = {
path: { path: {
credentialID: string credentialID: string
} }
query?: never query?: {
location?: {
directory?: string
workspace?: string
}
}
url: "/api/credential/{credentialID}" url: "/api/credential/{credentialID}"
} }

View file

@ -1,30 +1,38 @@
import { Credential } from "@opencode-ai/core/credential" import { Credential } from "@opencode-ai/core/credential"
import { Schema } from "effect" import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { LocationMiddleware, LocationQuery, locationQueryOpenApi } from "./location"
export const CredentialGroup = HttpApiGroup.make("server.credential") export const CredentialGroup = HttpApiGroup.make("server.credential")
.add( .add(
HttpApiEndpoint.patch("credential.update", "/api/credential/:credentialID", { HttpApiEndpoint.patch("credential.update", "/api/credential/:credentialID", {
params: { credentialID: Credential.ID }, params: { credentialID: Credential.ID },
query: LocationQuery,
payload: Schema.Struct({ label: Schema.String }), payload: Schema.Struct({ label: Schema.String }),
success: HttpApiSchema.NoContent, success: HttpApiSchema.NoContent,
}).annotateMerge( })
OpenApi.annotations({ .annotateMerge(locationQueryOpenApi)
identifier: "v2.credential.update", .annotateMerge(
summary: "Update credential", OpenApi.annotations({
description: "Update a stored credential label.", identifier: "v2.credential.update",
}), summary: "Update credential",
), description: "Update a stored credential label.",
}),
),
) )
.add( .add(
HttpApiEndpoint.delete("credential.remove", "/api/credential/:credentialID", { HttpApiEndpoint.delete("credential.remove", "/api/credential/:credentialID", {
params: { credentialID: Credential.ID }, params: { credentialID: Credential.ID },
query: LocationQuery,
success: HttpApiSchema.NoContent, success: HttpApiSchema.NoContent,
}).annotateMerge( })
OpenApi.annotations({ .annotateMerge(locationQueryOpenApi)
identifier: "v2.credential.remove", .annotateMerge(
summary: "Remove credential", OpenApi.annotations({
description: "Remove a stored integration credential.", identifier: "v2.credential.remove",
}), summary: "Remove credential",
), description: "Remove a stored integration credential.",
}),
),
) )
.middleware(LocationMiddleware)

View file

@ -1,4 +1,4 @@
import { Credential } from "@opencode-ai/core/credential" import { Integration } from "@opencode-ai/core/integration"
import { Effect } from "effect" import { Effect } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { Api } from "../api" import { Api } from "../api"
@ -8,14 +8,14 @@ export const CredentialHandler = HttpApiBuilder.group(Api, "server.credential",
.handle( .handle(
"credential.update", "credential.update",
Effect.fn(function* (ctx) { Effect.fn(function* (ctx) {
yield* (yield* Credential.Service).update(ctx.params.credentialID, { label: ctx.payload.label }) yield* (yield* Integration.Service).connection.update(ctx.params.credentialID, { label: ctx.payload.label })
return HttpApiSchema.NoContent.make() return HttpApiSchema.NoContent.make()
}), }),
) )
.handle( .handle(
"credential.remove", "credential.remove",
Effect.fn(function* (ctx) { Effect.fn(function* (ctx) {
yield* (yield* Credential.Service).remove(ctx.params.credentialID) yield* (yield* Integration.Service).connection.remove(ctx.params.credentialID)
return HttpApiSchema.NoContent.make() return HttpApiSchema.NoContent.make()
}), }),
), ),

View file

@ -38,7 +38,7 @@ export const IntegrationHandler = HttpApiBuilder.group(Api, "server.integration"
Effect.fn(function* (ctx) { Effect.fn(function* (ctx) {
const service = yield* Integration.Service const service = yield* Integration.Service
yield* authorize( yield* authorize(
service.connect.key({ service.connection.key({
integrationID: ctx.params.integrationID, integrationID: ctx.params.integrationID,
key: ctx.payload.key, key: ctx.payload.key,
label: ctx.payload.label, label: ctx.payload.label,
@ -53,7 +53,7 @@ export const IntegrationHandler = HttpApiBuilder.group(Api, "server.integration"
const service = yield* Integration.Service const service = yield* Integration.Service
return yield* response( return yield* response(
authorize( authorize(
service.connect.oauth({ service.connection.oauth({
integrationID: ctx.params.integrationID, integrationID: ctx.params.integrationID,
methodID: ctx.payload.methodID, methodID: ctx.payload.methodID,
inputs: ctx.payload.inputs, inputs: ctx.payload.inputs,

View file

@ -123,6 +123,12 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
event.subscribe((event, metadata) => { event.subscribe((event, metadata) => {
switch (event.type) { switch (event.type) {
case "catalog.updated":
void Promise.all([
result.location.model.refresh({ directory: metadata.directory, workspaceID: metadata.workspace }),
result.location.provider.refresh({ directory: metadata.directory, workspaceID: metadata.workspace }),
])
break
case "session.next.agent.switched": case "session.next.agent.switched":
message.update(event.properties.sessionID, (draft) => { message.update(event.properties.sessionID, (draft) => {
message.prepend(draft, { message.prepend(draft, {
@ -424,7 +430,11 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
void result.location.reference.refresh() void result.location.reference.refresh()
break break
case "integration.updated": case "integration.updated":
void result.location.integration.refresh({ directory: metadata.directory, workspaceID: metadata.workspace }) void Promise.all([
result.location.integration.refresh({ directory: metadata.directory, workspaceID: metadata.workspace }),
result.location.model.refresh({ directory: metadata.directory, workspaceID: metadata.workspace }),
result.location.provider.refresh({ directory: metadata.directory, workspaceID: metadata.workspace }),
])
break break
} }
}) })

View file

@ -94,14 +94,22 @@ test("refreshes resources into reactive getters", async () => {
test("refreshes integrations after integration updates", async () => { test("refreshes integrations after integration updates", async () => {
const events = createEventSource() const events = createEventSource()
let requests = 0 const requests = { integration: 0, model: 0, provider: 0 }
const calls = createFetch((url) => { const calls = createFetch((url) => {
if (url.pathname === "/api/model") {
requests.model++
return json({ location: { directory, project: { id: "proj_test", directory } }, data: [] })
}
if (url.pathname === "/api/provider") {
requests.provider++
return json({ location: { directory, project: { id: "proj_test", directory } }, data: [] })
}
if (url.pathname !== "/api/integration") return if (url.pathname !== "/api/integration") return
requests++ requests.integration++
return json({ return json({
location: { directory, project: { id: "proj_test", directory } }, location: { directory, project: { id: "proj_test", directory } },
data: data:
requests === 1 requests.integration === 1
? [] ? []
: [ : [
{ {
@ -140,15 +148,53 @@ test("refreshes integrations after integration updates", async () => {
await mounted await mounted
await wait(() => data.location.integration.list() !== undefined) await wait(() => data.location.integration.list() !== undefined)
expect(data.location.integration.list()).toEqual([]) expect(data.location.integration.list()).toEqual([])
const before = { ...requests }
emitEvent(events, { id: "evt_integration", type: "integration.updated", properties: {} }) emitEvent(events, { id: "evt_integration", type: "integration.updated", properties: {} })
await wait(() => data.location.integration.list()?.length === 1) await wait(() => data.location.integration.list()?.length === 1)
await wait(() => requests.model > before.model && requests.provider > before.provider)
expect(data.location.integration.list()?.[0]).toMatchObject({ id: "openai", name: "OpenAI" }) expect(data.location.integration.list()?.[0]).toMatchObject({ id: "openai", name: "OpenAI" })
} finally { } finally {
app.renderer.destroy() app.renderer.destroy()
} }
}) })
test("refreshes effective catalog data after catalog updates", async () => {
const events = createEventSource()
const requests = { model: 0, provider: 0 }
const calls = createFetch((url) => {
if (url.pathname === "/api/model") {
requests.model++
return json({ location: { directory, project: { id: "proj_test", directory } }, data: [] })
}
if (url.pathname === "/api/provider") {
requests.provider++
return json({ location: { directory, project: { id: "proj_test", directory } }, data: [] })
}
})
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
<ProjectProvider>
<DataProvider>
<box />
</DataProvider>
</ProjectProvider>
</SDKProvider>
</TestTuiContexts>
))
try {
await wait(() => requests.model > 0 && requests.provider > 0)
const before = { ...requests }
emitEvent(events, { id: "evt_catalog", type: "catalog.updated", properties: {} })
await wait(() => requests.model > before.model && requests.provider > before.provider)
} finally {
app.renderer.destroy()
}
})
test("refreshes references after updates", async () => { test("refreshes references after updates", async () => {
const events = createEventSource() const events = createEventSource()
let requests = 0 let requests = 0