Просмотр исходного кода

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

Dax 2 месяцев назад
Родитель
Сommit
0cf3ee4406
29 измененных файлов с 622 добавлено и 509 удалено
  1. 42 50
      packages/core/src/catalog.ts
  2. 25 2
      packages/core/src/config/plugin/provider.ts
  3. 6 0
      packages/core/src/credential.ts
  4. 68 23
      packages/core/src/integration.ts
  5. 6 4
      packages/core/src/integration/connection.ts
  6. 0 2
      packages/core/src/plugin/boot.ts
  7. 0 22
      packages/core/src/plugin/env.ts
  8. 2 8
      packages/core/src/plugin/models-dev.ts
  9. 4 1
      packages/core/src/plugin/provider/llmgateway.ts
  10. 4 4
      packages/core/src/plugin/provider/openai-auth.ts
  11. 5 3
      packages/core/src/plugin/provider/opencode.ts
  12. 1 19
      packages/core/src/provider.ts
  13. 42 19
      packages/core/src/session/runner/model.ts
  14. 57 20
      packages/core/test/catalog.test.ts
  15. 15 5
      packages/core/test/config/provider.test.ts
  16. 33 21
      packages/core/test/integration.test.ts
  17. 6 4
      packages/core/test/plugin/models-dev.test.ts
  18. 1 0
      packages/core/test/plugin/provider-helper.ts
  19. 18 11
      packages/core/test/plugin/provider-llmgateway.test.ts
  20. 4 4
      packages/core/test/plugin/provider-openai.test.ts
  21. 22 36
      packages/core/test/plugin/provider-opencode.test.ts
  22. 32 3
      packages/core/test/session-runner-model.test.ts
  23. 20 1
      packages/sdk/js/src/v2/gen/sdk.gen.ts
  24. 122 224
      packages/sdk/js/src/v2/gen/types.gen.ts
  25. 22 14
      packages/server/src/groups/credential.ts
  26. 3 3
      packages/server/src/handlers/credential.ts
  27. 2 2
      packages/server/src/handlers/integration.ts
  28. 11 1
      packages/tui/src/context/data.tsx
  29. 49 3
      packages/tui/test/cli/tui/data.test.tsx

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

+ 25 - 2
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)

+ 6 - 0
packages/core/src/credential.ts

@@ -46,6 +46,8 @@ export interface Interface {
   readonly all: () => Effect.Effect<Stored[]>
   /** Returns stored credentials belonging to one integration. */
   readonly list: (integrationID: IntegrationSchema.ID) => Effect.Effect<Stored[]>
+  /** Returns one stored credential by ID. */
+  readonly get: (id: ID) => Effect.Effect<Stored | undefined>
   /** Replaces any credential for an integration and returns the new record. */
   readonly create: (input: {
     readonly integrationID: IntegrationSchema.ID
@@ -99,6 +101,10 @@ export const layer = Layer.effect(
           return credential ? [credential] : []
         })
       }),
+      get: Effect.fn("Credential.get")(function* (id) {
+        const row = yield* db.select().from(CredentialTable).where(eq(CredentialTable.id, id)).get().pipe(Effect.orDie)
+        return row ? stored(row) : undefined
+      }),
       create: Effect.fn("Credential.create")(function* (input) {
         const credential = new Stored({
           id: ID.create(),

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

+ 6 - 4
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<CredentialInfo>("Connection.CredentialInfo")({
+export const CredentialInfo = Schema.Struct({
   type: Schema.Literal("credential"),
   id: Credential.ID,
   label: Schema.String,
-}) {}
+}).annotate({ identifier: "Connection.CredentialInfo" })
+export type CredentialInfo = typeof CredentialInfo.Type
 
-export class EnvInfo extends Schema.Class<EnvInfo>("Connection.EnvInfo")({
+export const EnvInfo = Schema.Struct({
   type: Schema.Literal("env"),
   name: Schema.String,
-}) {}
+}).annotate({ identifier: "Connection.EnvInfo" })
+export type EnvInfo = typeof EnvInfo.Type
 
 export const Info = Schema.Union([CredentialInfo, EnvInfo])
   .pipe(Schema.toTaggedUnion("type"))

+ 0 - 2
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)

+ 0 - 22
packages/core/src/plugin/env.ts

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

+ 2 - 8
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",

+ 4 - 1
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

+ 4 - 4
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 }>(

+ 5 - 3
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"

+ 1 - 19
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<Info>("ProviderV2.Info")({
   id: ID,
   name: Schema.String,
-  enabled: Schema.Union([
-    Schema.Literal(false),
-    Schema.Struct({
-      via: Schema.Literal("env"),
-      name: Schema.String,
-    }),
-    Schema.Struct({
-      via: Schema.Literal("credential"),
-      credentialID: Credential.ID,
-    }),
-    Schema.Struct({
-      via: Schema.Literal("custom"),
-      data: Schema.Record(Schema.String, Schema.Any),
-    }),
-  ]),
-  env: Schema.String.pipe(Schema.Array),
+  disabled: Schema.Boolean.pipe(Schema.optional),
   api: Api,
   request: Request,
 }) {
@@ -71,8 +55,6 @@ export class Info extends Schema.Class<Info>("ProviderV2.Info")({
     return new Info({
       id: providerID,
       name: providerID,
-      enabled: false,
-      env: [],
       api: {
         type: "native",
         settings: {},

+ 42 - 19
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<Service, Interface>()("@opencode/v2
 /** Test or embedding seam for supplying a model resolver directly. */
 export const layerWith = (resolve: Interface["resolve"]) => Layer.succeed(Service, Service.of({ resolve }))
 
-const apiKey = (model: ModelV2.Info, provider?: ProviderV2.Info) => {
+const apiKey = (
+  model: ModelV2.Info,
+  connection?: IntegrationConnection.Info,
+  credential?: Credential.Stored,
+) => {
+  if (credential?.value.type === "key") return Auth.value(credential.value.key)
+  if (credential?.value.type === "oauth") return Auth.value(credential.value.access)
   const value = model.request.body.apiKey ?? model.api.settings?.apiKey
   if (typeof value === "string") return Auth.value(value)
-  return provider?.enabled !== false && provider?.enabled.via === "env" ? Auth.config(provider.enabled.name) : undefined
+  return connection?.type === "env" ? Auth.config(connection.name) : undefined
 }
 
 const withDefaults = (model: ModelV2.Info, route: AnyRoute) => {
@@ -83,41 +92,48 @@ const apiName = (model: ModelV2.Info) =>
 
 export const fromCatalogModel = (
   model: ModelV2.Info,
-  provider?: ProviderV2.Info,
+  connection?: IntegrationConnection.Info,
+  credential?: Credential.Stored,
 ): Effect.Effect<Model, UnsupportedApiError> => {
-  const key = apiKey(model, provider)
-  if (model.api.type === "aisdk" && model.api.package === "@ai-sdk/openai") {
+  const resolved =
+    credential?.value.metadata === undefined
+      ? model
+      : produce(model, (draft) => {
+          Object.assign(draft.request.body, credential.value.metadata)
+        })
+  const key = apiKey(resolved, connection, credential)
+  if (resolved.api.type === "aisdk" && resolved.api.package === "@ai-sdk/openai") {
     return Effect.succeed(
-      withDefaults(model, OpenAIResponses.route)
+      withDefaults(resolved, OpenAIResponses.route)
         .with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
-        .model({ id: model.api.id }),
+        .model({ id: resolved.api.id }),
     )
   }
-  if (model.api.type === "aisdk" && model.api.package === "@ai-sdk/anthropic") {
+  if (resolved.api.type === "aisdk" && resolved.api.package === "@ai-sdk/anthropic") {
     return Effect.succeed(
-      withDefaults(model, AnthropicMessages.route)
+      withDefaults(resolved, AnthropicMessages.route)
         .with({ auth: key === undefined ? Auth.none : Auth.header("x-api-key", key) })
-        .model({ id: model.api.id }),
+        .model({ id: resolved.api.id }),
     )
   }
-  if (model.api.type === "aisdk" && model.api.package === "@ai-sdk/openai-compatible" && model.api.url) {
+  if (resolved.api.type === "aisdk" && resolved.api.package === "@ai-sdk/openai-compatible" && resolved.api.url) {
     return Effect.succeed(
-      withDefaults(model, OpenAICompatibleChat.route)
+      withDefaults(resolved, OpenAICompatibleChat.route)
         .with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
-        .model({ id: model.api.id }),
+        .model({ id: resolved.api.id }),
     )
   }
   return Effect.fail(
     new UnsupportedApiError({
-      providerID: model.providerID,
-      modelID: model.id,
-      api: apiName(model),
+      providerID: resolved.providerID,
+      modelID: resolved.id,
+      api: apiName(resolved),
     }),
   )
 }
 
-export const resolve = (session: SessionSchema.Info, model: ModelV2.Info, provider?: ProviderV2.Info) =>
-  fromCatalogModel(withVariant(model, session.model?.variant), provider)
+export const resolve = (session: SessionSchema.Info, model: ModelV2.Info) =>
+  fromCatalogModel(withVariant(model, session.model?.variant))
 
 export const supported = (model: ModelV2.Info) =>
   model.api.type === "aisdk" &&
@@ -130,6 +146,8 @@ export const locationLayer = Layer.effect(
   Service,
   Effect.gen(function* () {
     const catalog = yield* Catalog.Service
+    const credentials = yield* Credential.Service
+    const integrations = yield* Integration.Service
     const boot = yield* PluginBoot.Service
     return Service.of({
       resolve: Effect.fn("SessionRunnerModel.resolve")(function* (session) {
@@ -140,7 +158,12 @@ export const locationLayer = Layer.effect(
           : (Option.getOrUndefined((yield* catalog.model.default()).pipe(Option.filter(supported))) ??
             (yield* catalog.model.available()).find(supported))
         if (!selected) return yield* new ModelNotSelectedError({ sessionID: session.id })
-        return yield* resolve(session, selected, yield* catalog.provider.get(selected.providerID))
+        const connection = yield* integrations.connection.forIntegration(Integration.ID.make(selected.providerID))
+        return yield* fromCatalogModel(
+          withVariant(selected, session.model?.variant),
+          connection,
+          connection?.type === "credential" ? yield* credentials.get(connection.id) : undefined,
+        )
       }),
     })
   }),

+ 57 - 20
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)
       })

+ 15 - 5
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<string, string>, 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" })
-    }),
+    })),
   )
 })

+ 33 - 21
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(() => {

+ 6 - 4
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: [],
             }),

+ 1 - 0
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([]),
     }),
   ),

+ 18 - 11
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({})
     }),
   )

+ 4 - 4
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)",
-        }),
+        },
       ])
     }),
   )

+ 22 - 36
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")

+ 32 - 3
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(

+ 20 - 1
packages/sdk/js/src/v2/gen/sdk.gen.ts

@@ -5812,10 +5812,24 @@ export class Credential extends HeyApiClient {
   public remove<ThrowOnError extends boolean = false>(
     parameters: {
       credentialID: string
+      location?: {
+        directory?: string
+        workspace?: string
+      }
     },
     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>(
       {
         url: "/api/credential/{credentialID}",
@@ -5833,6 +5847,10 @@ export class Credential extends HeyApiClient {
   public update<ThrowOnError extends boolean = false>(
     parameters: {
       credentialID: string
+      location?: {
+        directory?: string
+        workspace?: string
+      }
       label?: string
     },
     options?: Options<never, ThrowOnError>,
@@ -5843,6 +5861,7 @@ export class Credential extends HeyApiClient {
         {
           args: [
             { in: "path", key: "credentialID" },
+            { in: "query", key: "location" },
             { in: "body", key: "label" },
           ],
         },

+ 122 - 224
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
@@ -746,9 +746,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
         }
       }
     | {
@@ -1257,13 +1264,6 @@ export type GlobalEvent = {
           file: string
         }
       }
-    | {
-        id: string
-        type: "integration.updated"
-        properties: {
-          [key: string]: unknown
-        }
-      }
     | {
         id: string
         type: "permission.v2.asked"
@@ -2836,102 +2836,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<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 = {
   directory: string
   workspaceID?: string
@@ -4022,26 +3926,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<string>
+  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:
     | {
         type: "aisdk"
@@ -4248,107 +4232,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<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
+  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
   }
 }
 
@@ -4902,14 +4798,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"
@@ -10251,7 +10139,12 @@ export type V2CredentialRemoveData = {
   path: {
     credentialID: string
   }
-  query?: never
+  query?: {
+    location?: {
+      directory?: string
+      workspace?: string
+    }
+  }
   url: "/api/credential/{credentialID}"
 }
 
@@ -10284,7 +10177,12 @@ export type V2CredentialUpdateData = {
   path: {
     credentialID: string
   }
-  query?: never
+  query?: {
+    location?: {
+      directory?: string
+      workspace?: string
+    }
+  }
   url: "/api/credential/{credentialID}"
 }
 

+ 22 - 14
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)

+ 3 - 3
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()
       }),
     ),

+ 2 - 2
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,

+ 11 - 1
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
       }
     })

+ 49 - 3
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(() => (
+    <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 () => {
   const events = createEventSource()
   let requests = 0