diff --git a/packages/core/src/catalog.ts b/packages/core/src/catalog.ts index 0799f8fb0044..eb6667bd9c51 100644 --- a/packages/core/src/catalog.ts +++ b/packages/core/src/catalog.ts @@ -10,8 +10,7 @@ import { Location } from "./location" import { EventV2 } from "./event" import { Policy } from "./policy" import { State } from "./state" -import { Credential } from "./credential" -import { IntegrationSchema } from "./integration/schema" +import { Integration } from "./integration" export type ProviderRecord = { provider: ProviderV2.Info @@ -35,12 +34,7 @@ export class ModelNotFoundError extends Schema.TaggedErrorClass) => { - const credential = active.get(IntegrationSchema.ID.make(provider.id)) - if (!credential) return provider - const body = { ...provider.request.body } - if (credential.value.type === "key") { - body.apiKey = credential.value.key - Object.assign(body, credential.value.metadata ?? {}) - } - if (credential.value.type === "oauth") body.apiKey = credential.value.access - return new ProviderV2.Info({ - ...provider, - enabled: { via: "credential", credentialID: credential.id }, - request: { ...provider.request, body }, - }) + const available = ( + provider: ProviderV2.Info, + integration: Integration.Info | undefined, + connected: boolean, + ) => { + if (provider.disabled) return false + if (typeof provider.request.body.apiKey === "string") return true + if (connected) return true + return !integration } - const resolve = (model: ModelV2.Info, provider: ProviderV2.Info) => { + const projectModel = (model: ModelV2.Info, provider: ProviderV2.Info) => { const api = model.api.type === "native" && !model.api.url && Object.keys(model.api.settings).length === 0 ? { ...provider.api, id: model.api.id } @@ -203,18 +192,16 @@ export const layer = Layer.effect( }, finalize: Effect.fn("CatalogV2.finalize")(function* (catalog, reason) { if (reason !== "plugin.added") yield* plugin.trigger("catalog.transform", catalog, {}).pipe(Effect.asVoid) - if (!policy.hasStatements()) return - for (const record of [...catalog.provider.list()]) { - if ((yield* policy.evaluate("provider.use", record.provider.id, "allow")) === "deny") { - catalog.provider.remove(record.provider.id) + if (policy.hasStatements()) { + for (const record of [...catalog.provider.list()]) { + if ((yield* policy.evaluate("provider.use", record.provider.id, "allow")) === "deny") { + catalog.provider.remove(record.provider.id) + } } } + yield* events.publish(Event.Updated, {}) }), }) - const active = Effect.fn("CatalogV2.active")(function* () { - return new Map((yield* credentials.all()).map((credential) => [credential.integrationID, credential])) - }) - yield* events.subscribe(PluginV2.Event.Added).pipe( // Plugin registries are location scoped even though the event bus is process scoped. Stream.filter( @@ -233,18 +220,23 @@ export const layer = Layer.effect( provider: { get: Effect.fn("CatalogV2.provider.get")(function* (providerID) { const record = yield* getRecord(providerID) - return project(record.provider, yield* active()) + return record.provider }), all: Effect.fn("CatalogV2.provider.all")(function* () { - const credentials = yield* active() - return Array.fromIterable(state.get().providers.values()).map((record) => - project(record.provider, credentials), - ) + return Array.fromIterable(state.get().providers.values()).map((record) => record.provider) }), available: Effect.fn("CatalogV2.provider.available")(function* () { - return (yield* result.provider.all()).filter((provider) => provider.enabled) + const active = new Map((yield* integrations.list()).map((integration) => [integration.id, integration])) + const connections = yield* integrations.connection.list() + return (yield* result.provider.all()).filter((provider) => + available( + provider, + active.get(Integration.ID.make(provider.id)), + connections.has(Integration.ID.make(provider.id)), + ), + ) }), }, @@ -253,33 +245,32 @@ export const layer = Layer.effect( const record = yield* getRecord(providerID) const model = record.models.get(modelID) if (!model) return yield* new ModelNotFoundError({ providerID, modelID }) - return resolve(model, project(record.provider, yield* active())) + return projectModel(model, record.provider) }), all: Effect.fn("CatalogV2.model.all")(function* () { - const credentials = yield* active() return pipe( Array.fromIterable(state.get().providers.values()), Array.flatMap((record) => { - const provider = project(record.provider, credentials) - return Array.fromIterable(record.models.values()).map((model) => resolve(model, provider)) + return Array.fromIterable(record.models.values()).map((model) => projectModel(model, record.provider)) }), Array.sortWith((item) => item.time.released.epochMilliseconds, Order.flip(Order.Number)), ) }), available: Effect.fn("CatalogV2.model.available")(function* () { - const providers = new Map((yield* result.provider.all()).map((provider) => [provider.id, provider])) - return (yield* result.model.all()).filter( - (model) => providers.get(model.providerID)?.enabled !== false && model.enabled, - ) + const providers = new Set((yield* result.provider.available()).map((provider) => provider.id)) + return (yield* result.model.all()).filter((model) => providers.has(model.providerID) && model.enabled) }), default: Effect.fn("CatalogV2.model.default")(function* () { const defaultModel = state.get().defaultModel if (defaultModel) { const provider = yield* result.provider.get(defaultModel.providerID).pipe(Effect.option) - if (Option.isSome(provider) && provider.value.enabled !== false) { + if ( + Option.isSome(provider) && + (yield* result.provider.available()).some((item) => item.id === provider.value.id) + ) { const model = yield* result.model.get(defaultModel.providerID, defaultModel.modelID).pipe(Effect.option) if (Option.isSome(model) && model.value.enabled) return model } @@ -295,11 +286,11 @@ export const layer = Layer.effect( small: Effect.fn("CatalogV2.model.small")(function* (providerID) { const record = state.get().providers.get(providerID) if (!record) return Option.none() - const provider = project(record.provider, yield* active()) + const provider = record.provider if (providerID === ProviderV2.ID.opencode) { const gpt5Nano = record.models.get(ModelV2.ID.make("gpt-5-nano")) - if (gpt5Nano?.enabled && gpt5Nano.status === "active") return Option.some(resolve(gpt5Nano, provider)) + if (gpt5Nano?.enabled && gpt5Nano.status === "active") return Option.some(projectModel(gpt5Nano, provider)) } const candidates = pipe( @@ -327,7 +318,7 @@ export const layer = Layer.effect( return pipe( items, Array.sortWith((item) => (item.cost / maxCost) * 0.8 + (item.age / maxAge) * 0.2, Order.Number), - Array.map((item) => resolve(item.model, provider)), + Array.map((item) => projectModel(item.model, provider)), Array.head, ) } @@ -348,6 +339,7 @@ export const layer = Layer.effect( const SMALL_MODEL_RE = /\b(nano|flash|lite|mini|haiku|small|fast)\b/ export const locationLayer = layer.pipe( + Layer.provideMerge(Integration.locationLayer), Layer.provideMerge(PluginV2.locationLayer), Layer.provideMerge(Policy.locationLayer), ) diff --git a/packages/core/src/config/plugin/provider.ts b/packages/core/src/config/plugin/provider.ts index 0d31b32d08fd..47a3712e3ad4 100644 --- a/packages/core/src/config/plugin/provider.ts +++ b/packages/core/src/config/plugin/provider.ts @@ -3,6 +3,7 @@ export * as ConfigProviderPlugin from "./provider" import { Effect } from "effect" import { Catalog } from "../../catalog" import { Config } from "../../config" +import { Integration } from "../../integration" import { ModelV2 } from "../../model" import { ModelRequest } from "../../model-request" import { PluginV2 } from "../../plugin" @@ -13,9 +14,33 @@ export const Plugin = PluginV2.define({ effect: Effect.gen(function* () { const catalog = yield* Catalog.Service const config = yield* Config.Service + const integrations = yield* Integration.Service const transform = yield* catalog.transform() + const integrationTransform = yield* integrations.transform() const entries = yield* config.entries() const files = entries.filter((entry): entry is Config.Document => entry.type === "document") + const configuredIntegrations = new Set( + files.flatMap((file) => + Object.entries(file.info.providers ?? {}).flatMap(([id, provider]) => (provider.env === undefined ? [] : [id])), + ), + ) + yield* integrationTransform((integrations) => { + for (const file of files) { + for (const [id, item] of Object.entries(file.info.providers ?? {})) { + const integrationID = Integration.ID.make(id) + if (!configuredIntegrations.has(id) && !integrations.get(integrationID)) continue + integrations.update(integrationID, (integration) => { + integration.name = item.name ?? integration.name + }) + if (item.env !== undefined) { + integrations.method.update({ + integrationID, + method: { type: "env", names: [...item.env] }, + }) + } + } + } + }) yield* transform((catalog) => { const configuredDefault = Config.latest(entries, "model") @@ -28,8 +53,6 @@ export const Plugin = PluginV2.define({ const providerID = ProviderV2.ID.make(id) catalog.provider.update(providerID, (provider) => { if (item.name !== undefined) provider.name = item.name - if (item.env !== undefined) provider.env = [...item.env] - provider.enabled = { via: "custom", data: {} } if (item.api !== undefined) provider.api = { ...item.api } if (item.request !== undefined) { Object.assign(provider.request.headers, item.request.headers) diff --git a/packages/core/src/credential.ts b/packages/core/src/credential.ts index 77bece2776f0..937ec4a51a7a 100644 --- a/packages/core/src/credential.ts +++ b/packages/core/src/credential.ts @@ -46,6 +46,8 @@ export interface Interface { readonly all: () => Effect.Effect /** Returns stored credentials belonging to one integration. */ readonly list: (integrationID: IntegrationSchema.ID) => Effect.Effect + /** Returns one stored credential by ID. */ + readonly get: (id: ID) => Effect.Effect /** Replaces any credential for an integration and returns the new record. */ readonly create: (input: { readonly integrationID: IntegrationSchema.ID @@ -99,6 +101,10 @@ export const layer = Layer.effect( return credential ? [credential] : [] }) }), + get: Effect.fn("Credential.get")(function* (id) { + const row = yield* db.select().from(CredentialTable).where(eq(CredentialTable.id, id)).get().pipe(Effect.orDie) + return row ? stored(row) : undefined + }), create: Effect.fn("Credential.create")(function* (input) { const credential = new Stored({ id: ID.create(), diff --git a/packages/core/src/integration.ts b/packages/core/src/integration.ts index a4bb3fd58ef5..fca6fa7990c9 100644 --- a/packages/core/src/integration.ts +++ b/packages/core/src/integration.ts @@ -29,15 +29,16 @@ export const When = Schema.Struct({ }).annotate({ identifier: "Integration.When" }) export type When = typeof When.Type -export class TextPrompt extends Schema.Class("Integration.TextPrompt")({ +export const TextPrompt = Schema.Struct({ type: Schema.Literal("text"), key: Schema.String, message: Schema.String, placeholder: Schema.optional(Schema.String), when: Schema.optional(When), -}) {} +}).annotate({ identifier: "Integration.TextPrompt" }) +export type TextPrompt = typeof TextPrompt.Type -export class SelectPrompt extends Schema.Class("Integration.SelectPrompt")({ +export const SelectPrompt = Schema.Struct({ type: Schema.Literal("select"), key: Schema.String, message: Schema.String, @@ -49,27 +50,31 @@ export class SelectPrompt extends Schema.Class("Integration.Select }), ), when: Schema.optional(When), -}) {} +}).annotate({ identifier: "Integration.SelectPrompt" }) +export type SelectPrompt = typeof SelectPrompt.Type export const Prompt = Schema.Union([TextPrompt, SelectPrompt]).pipe(Schema.toTaggedUnion("type")) export type Prompt = typeof Prompt.Type -export class OAuthMethod extends Schema.Class("Integration.OAuthMethod")({ +export const OAuthMethod = Schema.Struct({ id: MethodID, type: Schema.Literal("oauth"), label: Schema.String, prompts: Schema.optional(Schema.Array(Prompt)), -}) {} +}).annotate({ identifier: "Integration.OAuthMethod" }) +export type OAuthMethod = typeof OAuthMethod.Type -export class KeyMethod extends Schema.Class("Integration.KeyMethod")({ +export const KeyMethod = Schema.Struct({ type: Schema.Literal("key"), label: Schema.optional(Schema.String), -}) {} +}).annotate({ identifier: "Integration.KeyMethod" }) +export type KeyMethod = typeof KeyMethod.Type -export class EnvMethod extends Schema.Class("Integration.EnvMethod")({ +export const EnvMethod = Schema.Struct({ type: Schema.Literal("env"), names: Schema.Array(Schema.String), -}) {} +}).annotate({ identifier: "Integration.EnvMethod" }) +export type EnvMethod = typeof EnvMethod.Type export const Method = Schema.Union([OAuthMethod, KeyMethod, EnvMethod]).pipe(Schema.toTaggedUnion("type")) export type Method = typeof Method.Type @@ -197,7 +202,11 @@ export interface Interface { readonly get: (id: ID) => Effect.Effect /** Returns all integrations with their methods and current connections. */ readonly list: () => Effect.Effect - readonly connect: { + readonly connection: { + /** Returns active connections for every registered or credential-backed integration. */ + readonly list: () => Effect.Effect> + /** Returns the active connection for one integration. */ + readonly forIntegration: (id: ID) => Effect.Effect /** Runs a key method and stores the resulting credential. */ readonly key: (input: { /** Integration receiving the credential. */ @@ -218,6 +227,10 @@ export interface Interface { /** User-facing label for the credential created on completion. */ readonly label?: string }) => Effect.Effect + /** Updates a stored credential exposed as a connection. */ + readonly update: (credentialID: Credential.ID, updates: Partial>) => Effect.Effect + /** Removes a stored credential connection. */ + readonly remove: (credentialID: Credential.ID) => Effect.Effect } readonly attempt: { /** Returns the current state of an OAuth attempt. */ @@ -327,22 +340,29 @@ export const locationLayer = Layer.effect( const connections = (entry: Entry, saved: readonly Credential.Stored[]): IntegrationConnection.Info[] => { const connected = saved.map( - (credential) => - new IntegrationConnection.CredentialInfo({ type: "credential", id: credential.id, label: credential.label }), + (credential) => ({ type: "credential" as const, id: credential.id, label: credential.label }), ) const detected = entry.methods .filter((method) => method.type === "env") .flatMap((method) => method.names.filter((name) => process.env[name])) - .map( - (name, index) => - new IntegrationConnection.EnvInfo({ - type: "env", - name, - }), - ) + .map((name) => ({ type: "env" as const, name })) return [...connected, ...detected] } + const activeConnection = ( + entry: Entry | undefined, + saved: readonly Credential.Stored[], + ): IntegrationConnection.Info | undefined => { + const credential = saved.at(-1) + if (credential) return { type: "credential", id: credential.id, label: credential.label } + if (!entry) return + const name = entry.methods + .filter((method) => method.type === "env") + .flatMap((method) => method.names) + .find((name) => process.env[name]) + if (name) return { type: "env", name } + } + const project = (entry: Entry, saved: readonly Credential.Stored[]) => new Info({ id: entry.ref.id, @@ -382,6 +402,7 @@ export const locationLayer = Layer.effect( ? new Credential.OAuth({ ...exit.value, methodID: result.methodID }) : exit.value, }) + yield* events.publish(Event.Updated, {}) } yield* close(result.scope) }) @@ -421,8 +442,23 @@ export const locationLayer = Layer.effect( }), )).toSorted((a, b) => a.name.localeCompare(b.name)) }), - connect: { - key: Effect.fn("Integration.connect.key")(function* (input) { + connection: { + list: Effect.fn("Integration.connection.list")(function* () { + const saved = Map.groupBy(yield* credentials.all(), (credential) => credential.integrationID) + return new Map( + new Set([...state.get().integrations.keys(), ...saved.keys()]) + .values() + .flatMap((id) => { + const connection = activeConnection(state.get().integrations.get(id), saved.get(id) ?? []) + return connection ? [[id, connection] as const] : [] + }), + ) + }), + forIntegration: Effect.fn("Integration.connection.forIntegration")(function* (id) { + const entry = state.get().integrations.get(id) + return activeConnection(entry, yield* credentials.list(id)) + }), + key: Effect.fn("Integration.connection.key")(function* (input) { const method = state .get() .integrations.get(input.integrationID) @@ -433,8 +469,9 @@ export const locationLayer = Layer.effect( label: input.label, value: new Credential.Key({ type: "key", key: input.key }), }) + yield* events.publish(Event.Updated, {}) }), - oauth: Effect.fn("Integration.connect.oauth")(function* (input) { + oauth: Effect.fn("Integration.connection.oauth")(function* (input) { const method = state.get().integrations.get(input.integrationID)?.implementations.get(input.methodID) if (!method) { return yield* Effect.die(`OAuth method not found: ${input.integrationID}/${input.methodID}`) @@ -474,6 +511,14 @@ export const locationLayer = Layer.effect( time, }) }), + update: Effect.fn("Integration.connection.update")(function* (credentialID, updates) { + yield* credentials.update(credentialID, updates) + yield* events.publish(Event.Updated, {}) + }), + remove: Effect.fn("Integration.connection.remove")(function* (credentialID) { + yield* credentials.remove(credentialID) + yield* events.publish(Event.Updated, {}) + }), }, attempt: { status: Effect.fn("Integration.attempt.status")(function* (attemptID) { diff --git a/packages/core/src/integration/connection.ts b/packages/core/src/integration/connection.ts index a190ebf78c0e..200cf265800a 100644 --- a/packages/core/src/integration/connection.ts +++ b/packages/core/src/integration/connection.ts @@ -3,16 +3,18 @@ export * as IntegrationConnection from "./connection" import { Schema } from "effect" import { Credential } from "../credential" -export class CredentialInfo extends Schema.Class("Connection.CredentialInfo")({ +export const CredentialInfo = Schema.Struct({ type: Schema.Literal("credential"), id: Credential.ID, label: Schema.String, -}) {} +}).annotate({ identifier: "Connection.CredentialInfo" }) +export type CredentialInfo = typeof CredentialInfo.Type -export class EnvInfo extends Schema.Class("Connection.EnvInfo")({ +export const EnvInfo = Schema.Struct({ type: Schema.Literal("env"), name: Schema.String, -}) {} +}).annotate({ identifier: "Connection.EnvInfo" }) +export type EnvInfo = typeof EnvInfo.Type export const Info = Schema.Union([CredentialInfo, EnvInfo]) .pipe(Schema.toTaggedUnion("type")) diff --git a/packages/core/src/plugin/boot.ts b/packages/core/src/plugin/boot.ts index dac25a2489c0..cc7b0c247a9e 100644 --- a/packages/core/src/plugin/boot.ts +++ b/packages/core/src/plugin/boot.ts @@ -22,7 +22,6 @@ import { AgentPlugin } from "./agent" import { CommandPlugin } from "./command" import { SkillPlugin } from "./skill" import { ConfigProviderPlugin } from "../config/plugin/provider" -import { EnvPlugin } from "./env" import { ModelsDevPlugin } from "./models-dev" import { ProviderPlugins } from "./provider" import { SkillV2 } from "../skill" @@ -99,7 +98,6 @@ export const layer = Layer.effect( }) const boot = Effect.gen(function* () { - yield* add(EnvPlugin) yield* add(AgentPlugin.Plugin) yield* add(CommandPlugin.Plugin) yield* add(SkillPlugin.Plugin) diff --git a/packages/core/src/plugin/env.ts b/packages/core/src/plugin/env.ts deleted file mode 100644 index 35e6981a40c1..000000000000 --- a/packages/core/src/plugin/env.ts +++ /dev/null @@ -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, - } - }) - } - }), - } - }), -}) diff --git a/packages/core/src/plugin/models-dev.ts b/packages/core/src/plugin/models-dev.ts index e34c11f78893..a212d013ad10 100644 --- a/packages/core/src/plugin/models-dev.ts +++ b/packages/core/src/plugin/models-dev.ts @@ -70,16 +70,11 @@ export const ModelsDevPlugin = PluginV2.define({ integrations.update(integrationID, (integration) => (integration.name = item.name)) integrations.method.update({ integrationID, - method: new Integration.KeyMethod({ - type: "key", - }), + method: { type: "key" }, }) integrations.method.update({ integrationID, - method: new Integration.EnvMethod({ - type: "env", - names: [...item.env], - }), + method: { type: "env", names: [...item.env] }, }) } }) @@ -88,7 +83,6 @@ export const ModelsDevPlugin = PluginV2.define({ const providerID = ProviderV2.ID.make(item.id) catalog.provider.update(providerID, (provider) => { provider.name = item.name - provider.env = [...item.env] provider.api = item.npm ? { type: "aisdk", diff --git a/packages/core/src/plugin/provider/llmgateway.ts b/packages/core/src/plugin/provider/llmgateway.ts index 8261830f7e55..613f589ba5aa 100644 --- a/packages/core/src/plugin/provider/llmgateway.ts +++ b/packages/core/src/plugin/provider/llmgateway.ts @@ -1,13 +1,16 @@ import { Effect } from "effect" +import { Integration } from "../../integration" import { PluginV2 } from "../../plugin" export const LLMGatewayPlugin = PluginV2.define({ id: PluginV2.ID.make("llmgateway"), effect: Effect.gen(function* () { + const integrations = yield* Integration.Service return { "catalog.transform": Effect.fn(function* (evt) { for (const item of evt.provider.list()) { - if (item.provider.enabled === false) continue + if (item.provider.disabled) continue + if (!(yield* integrations.get(Integration.ID.make(item.provider.id)))) continue if (item.provider.api.type !== "aisdk") continue if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue if (item.provider.api.url !== "https://api.llmgateway.io/v1") continue diff --git a/packages/core/src/plugin/provider/openai-auth.ts b/packages/core/src/plugin/provider/openai-auth.ts index 787e33194af9..a654f56e621b 100644 --- a/packages/core/src/plugin/provider/openai-auth.ts +++ b/packages/core/src/plugin/provider/openai-auth.ts @@ -32,11 +32,11 @@ const headlessMethodID = Integration.MethodID.make("chatgpt-headless") export const browser = { integrationID: Integration.ID.make("openai"), - method: new Integration.OAuthMethod({ + method: { id: browserMethodID, type: "oauth", label: "ChatGPT Pro/Plus (browser)", - }), + }, authorize: () => Effect.gen(function* () { const pkce = yield* Effect.promise(generatePKCE) @@ -89,11 +89,11 @@ export const browser = { export const headless = { integrationID: Integration.ID.make("openai"), - method: new Integration.OAuthMethod({ + method: { id: headlessMethodID, type: "oauth", label: "ChatGPT Pro/Plus (headless)", - }), + }, authorize: () => Effect.gen(function* () { const device = yield* request<{ device_auth_id: string; user_code: string; interval: string }>( diff --git a/packages/core/src/plugin/provider/opencode.ts b/packages/core/src/plugin/provider/opencode.ts index 67fd7816a3fa..327a3e12806c 100644 --- a/packages/core/src/plugin/provider/opencode.ts +++ b/packages/core/src/plugin/provider/opencode.ts @@ -1,20 +1,22 @@ import { Effect } from "effect" +import { Integration } from "../../integration" import { PluginV2 } from "../../plugin" import { ProviderV2 } from "../../provider" export const OpencodePlugin = PluginV2.define({ id: PluginV2.ID.make("opencode"), effect: Effect.gen(function* () { + const integrations = yield* Integration.Service let hasKey = false return { "catalog.transform": Effect.fn(function* (evt) { const item = evt.provider.get(ProviderV2.ID.opencode) if (!item) return + const integration = yield* integrations.get(Integration.ID.make(item.provider.id)) hasKey = Boolean( process.env.OPENCODE_API_KEY || - item.provider.env.some((env) => process.env[env]) || - item.provider.request.body.apiKey || - (item.provider.enabled && item.provider.enabled.via === "credential"), + integration?.connections.length || + item.provider.request.body.apiKey, ) evt.provider.update(item.provider.id, (provider) => { if (!hasKey) provider.request.body.apiKey = "public" diff --git a/packages/core/src/provider.ts b/packages/core/src/provider.ts index 7379e08881c3..3f5424a47f36 100644 --- a/packages/core/src/provider.ts +++ b/packages/core/src/provider.ts @@ -2,7 +2,6 @@ export * as ProviderV2 from "./provider" import { withStatics } from "./schema" import { Schema } from "effect" -import { Credential } from "./credential" export const ID = Schema.String.pipe( Schema.brand("ProviderV2.ID"), @@ -48,22 +47,7 @@ export type Request = typeof Request.Type export class Info extends Schema.Class("ProviderV2.Info")({ id: ID, name: Schema.String, - enabled: Schema.Union([ - Schema.Literal(false), - Schema.Struct({ - via: Schema.Literal("env"), - name: Schema.String, - }), - Schema.Struct({ - via: Schema.Literal("credential"), - credentialID: Credential.ID, - }), - Schema.Struct({ - via: Schema.Literal("custom"), - data: Schema.Record(Schema.String, Schema.Any), - }), - ]), - env: Schema.String.pipe(Schema.Array), + disabled: Schema.Boolean.pipe(Schema.optional), api: Api, request: Request, }) { @@ -71,8 +55,6 @@ export class Info extends Schema.Class("ProviderV2.Info")({ return new Info({ id: providerID, name: providerID, - enabled: false, - env: [], api: { type: "native", settings: {}, diff --git a/packages/core/src/session/runner/model.ts b/packages/core/src/session/runner/model.ts index 27bd15ec8d40..32ea867dd026 100644 --- a/packages/core/src/session/runner/model.ts +++ b/packages/core/src/session/runner/model.ts @@ -8,6 +8,9 @@ import { Auth, type AnyRoute } from "@opencode-ai/llm/route" import { Context, Effect, Layer, Option, Schema } from "effect" import { produce } from "immer" import { Catalog } from "../../catalog" +import { Credential } from "../../credential" +import { Integration } from "../../integration" +import { IntegrationConnection } from "../../integration/connection" import { ModelV2 } from "../../model" import { ModelRequest } from "../../model-request" import { PluginBoot } from "../../plugin/boot" @@ -45,10 +48,16 @@ export class Service extends Context.Service()("@opencode/v2 /** Test or embedding seam for supplying a model resolver directly. */ export const layerWith = (resolve: Interface["resolve"]) => Layer.succeed(Service, Service.of({ resolve })) -const apiKey = (model: ModelV2.Info, provider?: ProviderV2.Info) => { +const apiKey = ( + model: ModelV2.Info, + connection?: IntegrationConnection.Info, + credential?: Credential.Stored, +) => { + if (credential?.value.type === "key") return Auth.value(credential.value.key) + if (credential?.value.type === "oauth") return Auth.value(credential.value.access) const value = model.request.body.apiKey ?? model.api.settings?.apiKey if (typeof value === "string") return Auth.value(value) - return provider?.enabled !== false && provider?.enabled.via === "env" ? Auth.config(provider.enabled.name) : undefined + return connection?.type === "env" ? Auth.config(connection.name) : undefined } const withDefaults = (model: ModelV2.Info, route: AnyRoute) => { @@ -83,41 +92,48 @@ const apiName = (model: ModelV2.Info) => export const fromCatalogModel = ( model: ModelV2.Info, - provider?: ProviderV2.Info, + connection?: IntegrationConnection.Info, + credential?: Credential.Stored, ): Effect.Effect => { - const key = apiKey(model, provider) - if (model.api.type === "aisdk" && model.api.package === "@ai-sdk/openai") { + const resolved = + credential?.value.metadata === undefined + ? model + : produce(model, (draft) => { + Object.assign(draft.request.body, credential.value.metadata) + }) + const key = apiKey(resolved, connection, credential) + if (resolved.api.type === "aisdk" && resolved.api.package === "@ai-sdk/openai") { return Effect.succeed( - withDefaults(model, OpenAIResponses.route) + withDefaults(resolved, OpenAIResponses.route) .with({ auth: key === undefined ? Auth.none : Auth.bearer(key) }) - .model({ id: model.api.id }), + .model({ id: resolved.api.id }), ) } - if (model.api.type === "aisdk" && model.api.package === "@ai-sdk/anthropic") { + if (resolved.api.type === "aisdk" && resolved.api.package === "@ai-sdk/anthropic") { return Effect.succeed( - withDefaults(model, AnthropicMessages.route) + withDefaults(resolved, AnthropicMessages.route) .with({ auth: key === undefined ? Auth.none : Auth.header("x-api-key", key) }) - .model({ id: model.api.id }), + .model({ id: resolved.api.id }), ) } - if (model.api.type === "aisdk" && model.api.package === "@ai-sdk/openai-compatible" && model.api.url) { + if (resolved.api.type === "aisdk" && resolved.api.package === "@ai-sdk/openai-compatible" && resolved.api.url) { return Effect.succeed( - withDefaults(model, OpenAICompatibleChat.route) + withDefaults(resolved, OpenAICompatibleChat.route) .with({ auth: key === undefined ? Auth.none : Auth.bearer(key) }) - .model({ id: model.api.id }), + .model({ id: resolved.api.id }), ) } return Effect.fail( new UnsupportedApiError({ - providerID: model.providerID, - modelID: model.id, - api: apiName(model), + providerID: resolved.providerID, + modelID: resolved.id, + api: apiName(resolved), }), ) } -export const resolve = (session: SessionSchema.Info, model: ModelV2.Info, provider?: ProviderV2.Info) => - fromCatalogModel(withVariant(model, session.model?.variant), provider) +export const resolve = (session: SessionSchema.Info, model: ModelV2.Info) => + fromCatalogModel(withVariant(model, session.model?.variant)) export const supported = (model: ModelV2.Info) => model.api.type === "aisdk" && @@ -130,6 +146,8 @@ export const locationLayer = Layer.effect( Service, Effect.gen(function* () { const catalog = yield* Catalog.Service + const credentials = yield* Credential.Service + const integrations = yield* Integration.Service const boot = yield* PluginBoot.Service return Service.of({ resolve: Effect.fn("SessionRunnerModel.resolve")(function* (session) { @@ -140,7 +158,12 @@ export const locationLayer = Layer.effect( : (Option.getOrUndefined((yield* catalog.model.default()).pipe(Option.filter(supported))) ?? (yield* catalog.model.available()).find(supported)) if (!selected) return yield* new ModelNotSelectedError({ sessionID: session.id }) - return yield* resolve(session, selected, yield* catalog.provider.get(selected.providerID)) + const connection = yield* integrations.connection.forIntegration(Integration.ID.make(selected.providerID)) + return yield* fromCatalogModel( + withVariant(selected, session.model?.variant), + connection, + connection?.type === "credential" ? yield* credentials.get(connection.id) : undefined, + ) }), }) }), diff --git a/packages/core/test/catalog.test.ts b/packages/core/test/catalog.test.ts index 585ebed93337..1c5b6310d0da 100644 --- a/packages/core/test/catalog.test.ts +++ b/packages/core/test/catalog.test.ts @@ -1,5 +1,5 @@ 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 { Integration } from "@opencode-ai/core/integration" import { Credential } from "@opencode-ai/core/credential" @@ -25,13 +25,29 @@ const it = testEffect( Layer.provideMerge( Layer.mock(Credential.Service)({ all: () => Effect.succeed([]), + list: () => Effect.succeed([]), }), ), ), ) 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 first = { id: Credential.ID.create(), @@ -53,6 +69,7 @@ describe("CatalogV2", () => { Layer.provideMerge( Layer.mock(Credential.Service)({ all: () => Effect.sync(() => [active]), + list: () => Effect.sync(() => [active]), }), ), ) @@ -62,18 +79,44 @@ describe("CatalogV2", () => { const transform = yield* catalog.transform() yield* transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {})) - expect(yield* catalog.provider.get(ProviderV2.ID.make("test"))).toMatchObject({ - enabled: { via: "credential", credentialID: first.id }, - request: { body: { apiKey: "first", tenant: "one" } }, - }) + expect((yield* catalog.provider.available()).map((provider) => provider.id)).toEqual([ProviderV2.ID.make("test")]) + expect((yield* catalog.provider.get(ProviderV2.ID.make("test"))).request.body).toEqual({}) active = second - expect(yield* catalog.provider.get(ProviderV2.ID.make("test"))).toMatchObject({ - enabled: { via: "credential", credentialID: second.id }, - request: { body: { apiKey: "second", tenant: "two" } }, - }) + expect((yield* catalog.provider.available()).map((provider) => provider.id)).toEqual([ProviderV2.ID.make("test")]) + expect((yield* catalog.provider.get(ProviderV2.ID.make("test"))).request.body).toEqual({}) }).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", () => Effect.gen(function* () { const catalog = yield* Catalog.Service @@ -292,9 +335,7 @@ describe("CatalogV2", () => { const transform = yield* catalog.transform() yield* transform((catalog) => { - catalog.provider.update(providerID, (provider) => { - provider.enabled = { via: "custom", data: {} } - }) + catalog.provider.update(providerID, () => {}) catalog.model.update(providerID, ModelV2.ID.make("old"), (model) => { model.time.released = DateTime.makeUnsafe(1000) }) @@ -316,9 +357,7 @@ describe("CatalogV2", () => { const transform = yield* catalog.transform() const models = (catalog: Catalog.Editor) => { - catalog.provider.update(providerID, (provider) => { - provider.enabled = { via: "custom", data: {} } - }) + catalog.provider.update(providerID, () => {}) catalog.model.update(providerID, old, (model) => { model.time.released = DateTime.makeUnsafe(1000) }) @@ -349,12 +388,10 @@ describe("CatalogV2", () => { yield* transform((catalog) => { catalog.provider.update(disabledProvider, (provider) => { - provider.enabled = false + provider.disabled = true }) catalog.model.update(disabledProvider, disabledModel, () => {}) - catalog.provider.update(enabledProvider, (provider) => { - provider.enabled = { via: "custom", data: {} } - }) + catalog.provider.update(enabledProvider, () => {}) catalog.model.update(enabledProvider, fallbackModel, () => {}) catalog.model.default.set(disabledProvider, disabledModel) }) diff --git a/packages/core/test/config/provider.test.ts b/packages/core/test/config/provider.test.ts index 6a2510bab8c9..c2ed72b20ccc 100644 --- a/packages/core/test/config/provider.test.ts +++ b/packages/core/test/config/provider.test.ts @@ -3,10 +3,11 @@ import { Effect, Option, Schema } from "effect" import { Catalog } from "@opencode-ai/core/catalog" import { Config } from "@opencode-ai/core/config" import { ConfigProviderPlugin } from "@opencode-ai/core/config/plugin/provider" +import { Integration } from "@opencode-ai/core/integration" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" import { ProviderV2 } from "@opencode-ai/core/provider" -import { it } from "../plugin/provider-helper" +import { it, withEnv } from "../plugin/provider-helper" function request(headers: Record, variant?: string) { return { @@ -21,6 +22,7 @@ describe("ConfigProviderPlugin.Plugin", () => { it.effect("partitions existing model variant bodies without changing config shape", () => Effect.gen(function* () { const catalog = yield* Catalog.Service + const integrations = yield* Integration.Service const plugin = yield* PluginV2.Service const providerID = ProviderV2.ID.opencode const modelID = ModelV2.ID.make("alpha-gpt-next") @@ -59,6 +61,7 @@ describe("ConfigProviderPlugin.Plugin", () => { effect: ConfigProviderPlugin.Plugin.effect.pipe( Effect.provideService(Config.Service, config), 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", () => Effect.gen(function* () { const catalog = yield* Catalog.Service + const integrations = yield* Integration.Service const plugin = yield* PluginV2.Service const providerID = ProviderV2.ID.opencode const modelID = ModelV2.ID.make("alpha-gpt-next") @@ -118,6 +122,7 @@ describe("ConfigProviderPlugin.Plugin", () => { effect: ConfigProviderPlugin.Plugin.effect.pipe( Effect.provideService(Config.Service, config), 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", () => - Effect.gen(function* () { + withEnv({ CUSTOM_API_KEY: "secret" }, () => Effect.gen(function* () { const catalog = yield* Catalog.Service + const integrations = yield* Integration.Service const plugin = yield* PluginV2.Service const providerID = ProviderV2.ID.make("custom") const modelID = ModelV2.ID.make("chat") @@ -218,6 +224,7 @@ describe("ConfigProviderPlugin.Plugin", () => { effect: ConfigProviderPlugin.Plugin.effect.pipe( Effect.provideService(Config.Service, config), 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) expect(Option.getOrUndefined(yield* catalog.model.default())?.id).toBe(ModelV2.ID.make("default")) expect(provider.name).toBe("Renamed") - expect(provider.env).toEqual(["CUSTOM_API_KEY"]) - expect(provider.enabled).toEqual({ via: "custom", data: {} }) + expect((yield* integrations.get(Integration.ID.make("custom")))?.methods).toContainEqual( + { 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.request.headers).toEqual({ first: "first", shared: "last", last: "last" }) 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[1]?.headers).toEqual({ slow: "slow" }) - }), + })), ) }) diff --git a/packages/core/test/integration.test.ts b/packages/core/test/integration.test.ts index fa05e23d95eb..9cae48c0e526 100644 --- a/packages/core/test/integration.test.ts +++ b/packages/core/test/integration.test.ts @@ -1,8 +1,7 @@ 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 { Integration } from "@opencode-ai/core/integration" -import { IntegrationConnection } from "@opencode-ai/core/integration/connection" import { Credential } from "@opencode-ai/core/credential" import { EventV2 } from "@opencode-ai/core/event" import { it } from "./lib/effect" @@ -25,7 +24,7 @@ function connectionLayer( }>, ) { return Integration.locationLayer.pipe( - Layer.provide(EventV2.defaultLayer), + Layer.provideMerge(EventV2.defaultLayer), Layer.provide( Layer.mock(Credential.Service)({ create: (input) => @@ -103,7 +102,7 @@ describe("Integration", () => { .update((editor) => editor.method.update({ integrationID, - method: new Integration.OAuthMethod({ id: methodID, type: "oauth", label: "ChatGPT" }), + method: { id: methodID, type: "oauth", label: "ChatGPT" }, authorize, }), ) @@ -117,7 +116,7 @@ describe("Integration", () => { ]) editor.method.update({ integrationID, - method: new Integration.OAuthMethod({ id: methodID, type: "oauth", label: "ChatGPT Override" }), + method: { id: methodID, type: "oauth", label: "ChatGPT Override" }, authorize, }) }) @@ -140,15 +139,22 @@ describe("Integration", () => { }> = [] return Effect.gen(function* () { const integrations = yield* Integration.Service + const events = yield* EventV2.Service const integrationID = Integration.ID.make("openai") yield* integrations.update((editor) => editor.method.update({ 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, key: "secret", label: "Work", @@ -161,6 +167,7 @@ describe("Integration", () => { value: new Credential.Key({ type: "key", key: "secret" }), }, ]) + expect((yield* Fiber.join(updated)).length).toBe(1) }).pipe(Effect.provide(connectionLayer(created))) }) @@ -177,7 +184,7 @@ describe("Integration", () => { yield* integrations.update((editor) => editor.method.update({ integrationID, - method: new Integration.OAuthMethod({ id: methodID, type: "oauth", label: "ChatGPT" }), + method: { id: methodID, type: "oauth", label: "ChatGPT" }, authorize: () => Effect.succeed({ mode: "code" as const, @@ -198,7 +205,7 @@ describe("Integration", () => { }), ) - const attempt = yield* integrations.connect.oauth({ + const attempt = yield* integrations.connection.oauth({ integrationID, methodID, inputs: {}, @@ -236,7 +243,7 @@ describe("Integration", () => { yield* integrations.update((editor) => editor.method.update({ integrationID, - method: new Integration.OAuthMethod({ id: methodID, type: "oauth", label: "ChatGPT" }), + method: { id: methodID, type: "oauth", label: "ChatGPT" }, authorize: () => Effect.addFinalizer(() => Effect.sync(() => (closed = true))).pipe( 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( Integration.CodeRequiredError, ) @@ -273,7 +280,7 @@ describe("Integration", () => { yield* integrations.update((editor) => editor.method.update({ integrationID, - method: new Integration.OAuthMethod({ id: methodID, type: "oauth", label: "Browser" }), + method: { id: methodID, type: "oauth", label: "Browser" }, authorize: () => Effect.succeed({ 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 expect(yield* integrations.attempt.status(attempt.attemptID)).toEqual({ status: "complete", @@ -310,7 +317,7 @@ describe("Integration", () => { yield* integrations.update((editor) => editor.method.update({ integrationID, - method: new Integration.OAuthMethod({ id: methodID, type: "oauth", label: "Browser" }), + method: { id: methodID, type: "oauth", label: "Browser" }, authorize: () => Effect.addFinalizer(() => Effect.sync(() => (closed = true))).pipe( 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))) yield* TestClock.adjust(Duration.minutes(10)) yield* Effect.yieldNow @@ -373,23 +380,28 @@ describe("Integration", () => { yield* integrations.update((editor) => editor.method.update({ integrationID, - method: new Integration.EnvMethod({ + method: { type: "env", names: ["INTEGRATION_TEST_ACME_KEY", "INTEGRATION_TEST_ACME_MISSING"], - }), + }, }), ) // Stored credentials and detected env vars appear as connections. expect((yield* integrations.get(integrationID))?.connections).toEqual([ - new IntegrationConnection.CredentialInfo({ type: "credential", id: rows[0]!.id, label: "Work" }), - new IntegrationConnection.CredentialInfo({ + { type: "credential", id: rows[0]!.id, label: "Work" }, + { type: "credential", id: rows[1]!.id, 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)), (previous) => Effect.sync(() => { diff --git a/packages/core/test/plugin/models-dev.test.ts b/packages/core/test/plugin/models-dev.test.ts index 37e7baae4928..236985dac1cf 100644 --- a/packages/core/test/plugin/models-dev.test.ts +++ b/packages/core/test/plugin/models-dev.test.ts @@ -28,8 +28,10 @@ const connections = Credential.layer.pipe( Layer.provide(Database.layerFromPath(":memory:").pipe(Layer.fresh)), 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 catalog = Catalog.layer.pipe( + Layer.provide(Layer.mergeAll(events, locationLayer, plugins, policy, connections, integrations)), +) const layer = Layer.mergeAll( catalog.pipe(Layer.provide(connections)), integrations, @@ -61,11 +63,11 @@ describe("ModelsDevPlugin", () => { id: Integration.ID.make("acme"), name: "Acme", methods: [ - new Integration.KeyMethod({ type: "key" }), - new Integration.EnvMethod({ + { type: "key" }, + { type: "env", names: ["ACME_API_KEY"], - }), + }, ], connections: [], }), diff --git a/packages/core/test/plugin/provider-helper.ts b/packages/core/test/plugin/provider-helper.ts index f9334480d1fc..c15928435bcd 100644 --- a/packages/core/test/plugin/provider-helper.ts +++ b/packages/core/test/plugin/provider-helper.ts @@ -53,6 +53,7 @@ const integrations = Integration.locationLayer.pipe( Layer.provide( Layer.mock(Credential.Service)({ create: () => Effect.die("unexpected credential creation"), + all: () => Effect.succeed([]), list: () => Effect.succeed([]), }), ), diff --git a/packages/core/test/plugin/provider-llmgateway.test.ts b/packages/core/test/plugin/provider-llmgateway.test.ts index 561f2701e17f..39a643e348a0 100644 --- a/packages/core/test/plugin/provider-llmgateway.test.ts +++ b/packages/core/test/plugin/provider-llmgateway.test.ts @@ -1,6 +1,7 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" +import { Integration } from "@opencode-ai/core/integration" import { PluginV2 } from "@opencode-ai/core/plugin" import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" 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" 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", () => Effect.sync(() => expectPluginRegistered( @@ -21,25 +30,23 @@ describe("LLMGatewayPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.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() yield* transform((catalog) => { 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" }, request: { headers: { Existing: "value" }, body: {} }, }) catalog.provider.update(llmgateway.id, (draft) => { - draft.enabled = llmgateway.enabled draft.api = llmgateway.api draft.request = llmgateway.request }) - const openrouter = provider("openrouter", { - enabled: { via: "env", name: "OPENROUTER_API_KEY" }, - }) - catalog.provider.update(openrouter.id, (draft) => { - draft.enabled = openrouter.enabled - }) + catalog.provider.update(ProviderV2.ID.openrouter, () => {}) }) expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).request.headers).toEqual({ Existing: "value", @@ -55,7 +62,7 @@ describe("LLMGatewayPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(LLMGatewayPlugin) + yield* add(plugin) const transform = yield* catalog.transform() yield* transform((catalog) => { 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({}) }), ) diff --git a/packages/core/test/plugin/provider-openai.test.ts b/packages/core/test/plugin/provider-openai.test.ts index a317ba4bf7d2..d30b585b961f 100644 --- a/packages/core/test/plugin/provider-openai.test.ts +++ b/packages/core/test/plugin/provider-openai.test.ts @@ -21,16 +21,16 @@ describe("OpenAIPlugin", () => { const plugin = yield* PluginV2.Service yield* add(plugin, yield* Integration.Service) expect((yield* (yield* Integration.Service).get(Integration.ID.make("openai")))?.methods).toEqual([ - new Integration.OAuthMethod({ + { id: Integration.MethodID.make("chatgpt-browser"), type: "oauth", label: "ChatGPT Pro/Plus (browser)", - }), - new Integration.OAuthMethod({ + }, + { id: Integration.MethodID.make("chatgpt-headless"), type: "oauth", label: "ChatGPT Pro/Plus (headless)", - }), + }, ]) }), ) diff --git a/packages/core/test/plugin/provider-opencode.test.ts b/packages/core/test/plugin/provider-opencode.test.ts index d12f04c0c942..01cabf358116 100644 --- a/packages/core/test/plugin/provider-opencode.test.ts +++ b/packages/core/test/plugin/provider-opencode.test.ts @@ -3,6 +3,7 @@ import { DateTime, Effect, Layer, Option } from "effect" import { Catalog } from "@opencode-ai/core/catalog" import { Credential } from "@opencode-ai/core/credential" import { EventV2 } from "@opencode-ai/core/event" +import { Integration } from "@opencode-ai/core/integration" import { Location } from "@opencode-ai/core/location" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" @@ -18,13 +19,18 @@ const locationLayer = Layer.succeed( 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", () => { it.effect("uses a public key and disables paid models without credentials", () => withEnv({ OPENCODE_API_KEY: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(OpencodePlugin) + yield* plugin.add(pluginWithIntegrations(yield* Integration.Service)) const transform = yield* catalog.transform() yield* transform((catalog) => { const item = provider("opencode") @@ -45,7 +51,7 @@ describe("OpencodePlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(OpencodePlugin) + yield* plugin.add(pluginWithIntegrations(yield* Integration.Service)) const transform = yield* catalog.transform() yield* transform((catalog) => { const item = provider("opencode") @@ -66,7 +72,7 @@ describe("OpencodePlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(OpencodePlugin) + yield* plugin.add(pluginWithIntegrations(yield* Integration.Service)) const transform = yield* catalog.transform() yield* transform((catalog) => { const item = provider("opencode") @@ -87,7 +93,7 @@ describe("OpencodePlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(OpencodePlugin) + yield* plugin.add(pluginWithIntegrations(yield* Integration.Service)) const transform = yield* catalog.transform() yield* transform((catalog) => { const item = provider("opencode") @@ -108,13 +114,18 @@ describe("OpencodePlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.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() yield* transform((catalog) => { - const item = provider("opencode", { env: ["CUSTOM_OPENCODE_API_KEY"] }) - catalog.provider.update(item.id, (draft) => { - draft.env = [...item.env] - }) + const item = provider("opencode") + catalog.provider.update(item.id, () => {}) const paid = model("opencode", "paid", { cost: cost(1) }) catalog.model.update(item.id, paid.id, (draft) => { draft.cost = [...paid.cost] @@ -131,7 +142,7 @@ describe("OpencodePlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(OpencodePlugin) + yield* plugin.add(pluginWithIntegrations(yield* Integration.Service)) const transform = yield* catalog.transform() yield* transform((catalog) => { 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", () => withEnv({ OPENCODE_API_KEY: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(OpencodePlugin) + yield* plugin.add(pluginWithIntegrations(yield* Integration.Service)) const transform = yield* catalog.transform() yield* transform((catalog) => { const item = provider("openai") diff --git a/packages/core/test/session-runner-model.test.ts b/packages/core/test/session-runner-model.test.ts index c50e2f855af9..e1f5e32b7529 100644 --- a/packages/core/test/session-runner-model.test.ts +++ b/packages/core/test/session-runner-model.test.ts @@ -3,6 +3,8 @@ import { LLM } from "@opencode-ai/llm" import { LLMClient } from "@opencode-ai/llm/route" import { ConfigProvider, DateTime, Effect } from "effect" 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 { ProviderV2 } from "@opencode-ai/core/provider" import { ProjectV2 } from "@opencode-ai/core/project" @@ -45,8 +47,6 @@ const provider = (api: ProviderV2.Info["api"]) => new ProviderV2.Info({ id: ProviderV2.ID.make("test-provider"), name: "Test provider", - enabled: { via: "env", name: "TEST_PROVIDER_API_KEY" }, - env: ["TEST_PROVIDER_API_KEY"], api, request: { headers: {}, body: {} }, }) @@ -247,7 +247,7 @@ describe("SessionRunnerModel", () => { ...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }), 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 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", () => Effect.gen(function* () { const failure = yield* SessionRunnerModel.fromCatalogModel( diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index b01c8fd04619..4d801e486834 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -5798,10 +5798,24 @@ export class Credential extends HeyApiClient { public remove( parameters: { credentialID: string + location?: { + directory?: string + workspace?: string + } }, options?: Options, ) { - 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( { url: "/api/credential/{credentialID}", @@ -5819,6 +5833,10 @@ export class Credential extends HeyApiClient { public update( parameters: { credentialID: string + location?: { + directory?: string + workspace?: string + } label?: string }, options?: Options, @@ -5829,6 +5847,7 @@ export class Credential extends HeyApiClient { { args: [ { in: "path", key: "credentialID" }, + { in: "query", key: "location" }, { in: "body", key: "label" }, ], }, diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 334f5d469cf3..1ad951759c8a 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -7,7 +7,8 @@ export type ClientOptions = { export type Event = | EventModelsDevRefreshed | EventPluginAdded - | EventCatalogModelUpdated + | EventIntegrationUpdated + | EventCatalogUpdated | EventSessionCreated | EventSessionUpdated | EventSessionDeleted @@ -52,7 +53,6 @@ export type Event = | EventInstallationUpdated | EventInstallationUpdateAvailable | EventFileEdited - | EventIntegrationUpdated | EventPermissionV2Asked | EventPermissionV2Replied | EventReferenceUpdated @@ -745,9 +745,16 @@ export type GlobalEvent = { } | { id: string - type: "catalog.model.updated" + type: "integration.updated" + properties: { + [key: string]: unknown + } + } + | { + id: string + type: "catalog.updated" properties: { - model: ModelV2Info + [key: string]: unknown } } | { @@ -1256,13 +1263,6 @@ export type GlobalEvent = { file: string } } - | { - id: string - type: "integration.updated" - properties: { - [key: string]: unknown - } - } | { id: string type: "permission.v2.asked" @@ -2830,102 +2830,6 @@ export type MoveSessionDestination = { 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 - output: Array - } - 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 - } - 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 - } - 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 = { directory: string workspaceID?: string @@ -4016,26 +3920,106 @@ export type SessionMessage = | SessionMessageAssistant | SessionMessageCompaction -export type ProviderV2Info = { +export type ModelV2Info = { id: string + providerID: string + family?: string name: string - enabled: - | false - | { - via: "env" - name: string - } + api: | { - via: "credential" - credentialID: string + id: string + type: "aisdk" + package: string + url?: string + settings?: { + [key: string]: unknown + } } | { - via: "custom" - data: { + id: string + type: "native" + url?: string + settings: { [key: string]: unknown } } - env: Array + capabilities: { + tools: boolean + input: Array + output: Array + } + 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 + } + 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 + } + 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: | { type: "aisdk" @@ -4242,107 +4226,19 @@ export type EventPluginAdded = { } } -export type ModelV2Info1 = { +export type EventIntegrationUpdated = { 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 - output: Array - } - 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 - } - 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 - } - 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 + type: "integration.updated" + properties: { + [key: string]: unknown } } -export type EventCatalogModelUpdated = { +export type EventCatalogUpdated = { id: string - type: "catalog.model.updated" + type: "catalog.updated" properties: { - model: ModelV2Info1 + [key: string]: unknown } } @@ -4896,14 +4792,6 @@ export type EventFileEdited = { } } -export type EventIntegrationUpdated = { - id: string - type: "integration.updated" - properties: { - [key: string]: unknown - } -} - export type EventPermissionV2Asked = { id: string type: "permission.v2.asked" @@ -10245,7 +10133,12 @@ export type V2CredentialRemoveData = { path: { credentialID: string } - query?: never + query?: { + location?: { + directory?: string + workspace?: string + } + } url: "/api/credential/{credentialID}" } @@ -10278,7 +10171,12 @@ export type V2CredentialUpdateData = { path: { credentialID: string } - query?: never + query?: { + location?: { + directory?: string + workspace?: string + } + } url: "/api/credential/{credentialID}" } diff --git a/packages/server/src/groups/credential.ts b/packages/server/src/groups/credential.ts index 648553a3e094..b6e21ca30b7c 100644 --- a/packages/server/src/groups/credential.ts +++ b/packages/server/src/groups/credential.ts @@ -1,30 +1,38 @@ import { Credential } from "@opencode-ai/core/credential" import { Schema } from "effect" import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" +import { LocationMiddleware, LocationQuery, locationQueryOpenApi } from "./location" export const CredentialGroup = HttpApiGroup.make("server.credential") .add( HttpApiEndpoint.patch("credential.update", "/api/credential/:credentialID", { params: { credentialID: Credential.ID }, + query: LocationQuery, payload: Schema.Struct({ label: Schema.String }), success: HttpApiSchema.NoContent, - }).annotateMerge( - OpenApi.annotations({ - identifier: "v2.credential.update", - summary: "Update credential", - description: "Update a stored credential label.", - }), - ), + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.credential.update", + summary: "Update credential", + description: "Update a stored credential label.", + }), + ), ) .add( HttpApiEndpoint.delete("credential.remove", "/api/credential/:credentialID", { params: { credentialID: Credential.ID }, + query: LocationQuery, success: HttpApiSchema.NoContent, - }).annotateMerge( - OpenApi.annotations({ - identifier: "v2.credential.remove", - summary: "Remove credential", - description: "Remove a stored integration credential.", - }), - ), + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.credential.remove", + summary: "Remove credential", + description: "Remove a stored integration credential.", + }), + ), ) + .middleware(LocationMiddleware) diff --git a/packages/server/src/handlers/credential.ts b/packages/server/src/handlers/credential.ts index 7203060f2896..7e138a5d5a20 100644 --- a/packages/server/src/handlers/credential.ts +++ b/packages/server/src/handlers/credential.ts @@ -1,4 +1,4 @@ -import { Credential } from "@opencode-ai/core/credential" +import { Integration } from "@opencode-ai/core/integration" import { Effect } from "effect" import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" import { Api } from "../api" @@ -8,14 +8,14 @@ export const CredentialHandler = HttpApiBuilder.group(Api, "server.credential", .handle( "credential.update", 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() }), ) .handle( "credential.remove", 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() }), ), diff --git a/packages/server/src/handlers/integration.ts b/packages/server/src/handlers/integration.ts index 70cfbfa68bc0..d7c651e847a6 100644 --- a/packages/server/src/handlers/integration.ts +++ b/packages/server/src/handlers/integration.ts @@ -38,7 +38,7 @@ export const IntegrationHandler = HttpApiBuilder.group(Api, "server.integration" Effect.fn(function* (ctx) { const service = yield* Integration.Service yield* authorize( - service.connect.key({ + service.connection.key({ integrationID: ctx.params.integrationID, key: ctx.payload.key, label: ctx.payload.label, @@ -53,7 +53,7 @@ export const IntegrationHandler = HttpApiBuilder.group(Api, "server.integration" const service = yield* Integration.Service return yield* response( authorize( - service.connect.oauth({ + service.connection.oauth({ integrationID: ctx.params.integrationID, methodID: ctx.payload.methodID, inputs: ctx.payload.inputs, diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index 55e1d750ae53..bea6e001a43b 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -123,6 +123,12 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ event.subscribe((event, metadata) => { 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": message.update(event.properties.sessionID, (draft) => { message.prepend(draft, { @@ -424,7 +430,11 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ void result.location.reference.refresh() break 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 } }) diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index 2e5f4eae3aff..92894620acce 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -94,14 +94,22 @@ test("refreshes resources into reactive getters", async () => { test("refreshes integrations after integration updates", async () => { const events = createEventSource() - let requests = 0 + const requests = { integration: 0, 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: [] }) + } if (url.pathname !== "/api/integration") return - requests++ + requests.integration++ return json({ location: { directory, project: { id: "proj_test", directory } }, data: - requests === 1 + requests.integration === 1 ? [] : [ { @@ -140,15 +148,53 @@ test("refreshes integrations after integration updates", async () => { await mounted await wait(() => data.location.integration.list() !== undefined) expect(data.location.integration.list()).toEqual([]) + const before = { ...requests } emitEvent(events, { id: "evt_integration", type: "integration.updated", properties: {} }) 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" }) } finally { 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(() => ( + + + + + + + + + + )) + + 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 () => { const events = createEventSource() let requests = 0