diff --git a/packages/core/src/integration.ts b/packages/core/src/integration.ts index 479586924c..22e622e5be 100644 --- a/packages/core/src/integration.ts +++ b/packages/core/src/integration.ts @@ -707,7 +707,7 @@ const layer = Layer.effect( value: Credential.Key.make({ type: "key", key: input.key, - metadata: Object.keys(input.answers).length > 0 ? input.answers : undefined, + ...(Object.keys(input.answers).length > 0 ? { configuration: input.answers } : {}), }), }) yield* bus.publish(Integration.Event.ConnectionUpdated, { integrationID: input.integrationID }) diff --git a/packages/core/src/model-resolver.ts b/packages/core/src/model-resolver.ts index fbf345a02a..0f9424fdb9 100644 --- a/packages/core/src/model-resolver.ts +++ b/packages/core/src/model-resolver.ts @@ -149,6 +149,7 @@ export const fromCatalogModel = ( }) const packageName = Provider.packageName(resolved.package) const key = apiKey(resolved, credential) + const configuration = credential?.type === "key" ? credential.configuration : undefined if (Provider.isAISDK(resolved.package) && packageName === "@ai-sdk/openai") { return Effect.succeed( @@ -175,7 +176,7 @@ export const fromCatalogModel = ( .model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }), ) } - const configured = { ...resolved.settings, ...credential?.metadata } + const configured = { ...resolved.settings, ...credential?.metadata, ...configuration } const mapping = Provider.isAISDK(resolved.package) ? AISDKNative.map({ packageName, @@ -190,6 +191,7 @@ export const fromCatalogModel = ( draft.settings = Provider.mergeOverlay(draft.settings, { ...nativeCredentialSettings(resolved.package ?? "", credential), ...credential?.metadata, + ...configuration, }) }) return dependencies.loadAISDK(runtime).pipe(Effect.mapError(() => unsupported(resolved))) diff --git a/packages/core/src/plugin/provider/azure.ts b/packages/core/src/plugin/provider/azure.ts index 346632be4c..26492df993 100644 --- a/packages/core/src/plugin/provider/azure.ts +++ b/packages/core/src/plugin/provider/azure.ts @@ -1,6 +1,7 @@ import { Effect } from "effect" import { define } from "@opencode-ai/plugin/effect/plugin" import { Provider } from "../../provider" +import { configuredSettings } from "./configured" function selectLanguage(sdk: any, modelID: string, useChat: boolean) { if (useChat && sdk.chat) return sdk.chat(modelID) @@ -13,13 +14,14 @@ function selectLanguage(sdk: any, modelID: string, useChat: boolean) { export const AzurePlugin = define({ id: "opencode.provider.azure", effect: Effect.fn(function* (ctx) { + const configured = yield* configuredSettings(Provider.ID.azure) yield* ctx.integration.transform((draft) => { draft.method.update({ integrationID: Provider.ID.azure, method: { type: "key", label: "API key", - ...(resolveResourceName(undefined) + ...(resolveResourceName(configured) || typeof configured?.baseURL === "string" ? {} : { forms: [ diff --git a/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts b/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts index bbe78bbc45..8906bff9a3 100644 --- a/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts +++ b/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts @@ -2,13 +2,19 @@ import os from "os" import { App } from "../../app" import { Effect, Option, Schema } from "effect" import { define } from "@opencode-ai/plugin/effect/plugin" +import { Provider } from "../../provider" +import { configuredSettings } from "./configured" + +const providerID = Provider.ID.make("cloudflare-ai-gateway") export const CloudflareAIGatewayPlugin = define({ id: "opencode.provider.cloudflare-ai-gateway", effect: Effect.fn(function* (ctx) { + const configured = yield* configuredSettings(providerID) + const complete = typeof configured?.baseURL === "string" yield* ctx.integration.transform((draft) => { const forms = [ - ...(process.env.CLOUDFLARE_ACCOUNT_ID + ...(complete || process.env.CLOUDFLARE_ACCOUNT_ID || stringOption(configured ?? {}, "accountId") ? [] : [ { @@ -19,7 +25,10 @@ export const CloudflareAIGatewayPlugin = define({ required: true, }, ]), - ...(process.env.CLOUDFLARE_GATEWAY_ID + ...(complete || + process.env.CLOUDFLARE_GATEWAY_ID || + stringOption(configured ?? {}, "gatewayId") || + stringOption(configured ?? {}, "gateway") ? [] : [ { @@ -32,7 +41,7 @@ export const CloudflareAIGatewayPlugin = define({ ]), ] draft.method.update({ - integrationID: "cloudflare-ai-gateway", + integrationID: providerID, method: { type: "key", label: "Gateway API token", diff --git a/packages/core/src/plugin/provider/cloudflare-workers-ai.ts b/packages/core/src/plugin/provider/cloudflare-workers-ai.ts index 2e82b3b92f..5dac794e23 100644 --- a/packages/core/src/plugin/provider/cloudflare-workers-ai.ts +++ b/packages/core/src/plugin/provider/cloudflare-workers-ai.ts @@ -3,19 +3,21 @@ import { App } from "../../app" import { Effect } from "effect" import { define } from "@opencode-ai/plugin/effect/plugin" import { Provider } from "../../provider" +import { configuredSettings } from "./configured" const providerID = Provider.ID.make("cloudflare-workers-ai") export const CloudflareWorkersAIPlugin = define({ id: "opencode.provider.cloudflare-workers-ai", effect: Effect.fn(function* (ctx) { + const configured = yield* configuredSettings(providerID) yield* ctx.integration.transform((draft) => { draft.method.update({ integrationID: providerID, method: { type: "key", label: "API key", - ...(process.env.CLOUDFLARE_ACCOUNT_ID + ...(typeof configured?.baseURL === "string" || resolveAccountId(configured ?? {}) ? {} : { forms: [ diff --git a/packages/core/src/plugin/provider/configured.ts b/packages/core/src/plugin/provider/configured.ts new file mode 100644 index 0000000000..fb21a9818b --- /dev/null +++ b/packages/core/src/plugin/provider/configured.ts @@ -0,0 +1,15 @@ +import { Effect, Option } from "effect" +import type { Document } from "@opencode-ai/schema/config" +import { Catalog } from "../../catalog" +import { Config } from "../../config" +import { Provider } from "../../provider" + +export const configuredSettings = Effect.fn("ProviderPlugin.configuredSettings")(function* (id: Provider.ID) { + const catalog = yield* Catalog.Service + const current = (yield* catalog.provider.get(id))?.settings + const service = yield* Effect.serviceOption(Config.Service) + const entries = Option.isSome(service) ? yield* service.value.entries() : [] + return entries + .filter((entry): entry is Document => entry.type === "document") + .reduce((settings, entry) => Provider.mergeOverlay(settings, entry.info.providers?.[id]?.settings), current) +}) diff --git a/packages/core/test/integration.test.ts b/packages/core/test/integration.test.ts index 3ff894f977..48dd027c71 100644 --- a/packages/core/test/integration.test.ts +++ b/packages/core/test/integration.test.ts @@ -170,7 +170,7 @@ describe("Integration", () => { expect.objectContaining({ integrationID, label: "Work", - value: Credential.Key.make({ type: "key", key: "secret", metadata: { accountId: "account" } }), + value: Credential.Key.make({ type: "key", key: "secret", configuration: { accountId: "account" } }), }), ]) expect((yield* Fiber.join(updated)).length).toBe(1) diff --git a/packages/core/test/model-resolver.test.ts b/packages/core/test/model-resolver.test.ts index 66ad981815..2c3a3d6af1 100644 --- a/packages/core/test/model-resolver.test.ts +++ b/packages/core/test/model-resolver.test.ts @@ -736,7 +736,11 @@ describe("ModelResolver", () => { headers: { "x-aisdk": "header" }, body: { custom: true }, }), - Credential.Key.make({ type: "key", key: "fallback-secret" }), + Credential.Key.make({ + type: "key", + key: "fallback-secret", + configuration: { accountId: "account" }, + }), { loadAISDK: (runtime) => Effect.sync(() => { @@ -745,7 +749,7 @@ describe("ModelResolver", () => { modelID: "mistral-api-model", providerID: "test-provider", package: Provider.aisdk("@ai-sdk/mistral"), - settings: { project: "test", apiKey: "fallback-secret" }, + settings: { project: "test", apiKey: "fallback-secret", accountId: "account" }, headers: { "x-aisdk": "header" }, body: { custom: true }, }) diff --git a/packages/core/test/plugin/provider-azure.test.ts b/packages/core/test/plugin/provider-azure.test.ts index 1ef0110eee..27085618ec 100644 --- a/packages/core/test/plugin/provider-azure.test.ts +++ b/packages/core/test/plugin/provider-azure.test.ts @@ -217,7 +217,17 @@ describe("AzurePlugin", () => { Effect.gen(function* () { const plugin = yield* Plugin.Service const aisdk = yield* AISDK.Service + const catalog = yield* Catalog.Service + yield* catalog.transform((catalog) => + catalog.provider.update(Provider.ID.azure, (provider) => { + provider.settings = { ...provider.settings, baseURL: "https://proxy.example.com/openai" } + }), + ) yield* addPlugin() + expect((yield* (yield* Integration.Service).get(Integration.ID.make("azure")))?.methods).toContainEqual({ + type: "key", + label: "API key", + }) const result = yield* aisdk.runSDK({ model: Model.Info.make({ ...Model.Info.default(Provider.ID.azure, Model.ID.make("deployment")), diff --git a/packages/core/test/plugin/provider-cloudflare-ai-gateway.test.ts b/packages/core/test/plugin/provider-cloudflare-ai-gateway.test.ts index b515f98192..38afff6831 100644 --- a/packages/core/test/plugin/provider-cloudflare-ai-gateway.test.ts +++ b/packages/core/test/plugin/provider-cloudflare-ai-gateway.test.ts @@ -1,6 +1,7 @@ import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect, mock } from "bun:test" import { Effect } from "effect" +import { Catalog } from "@opencode-ai/core/catalog" import { Model } from "@opencode-ai/core/model" import { Plugin } from "@opencode-ai/core/plugin" import { PluginHost } from "@opencode-ai/core/plugin/host" @@ -376,7 +377,16 @@ describe("CloudflareAIGatewayPlugin", () => { resetCalls() const plugin = yield* Plugin.Service const aisdk = yield* AISDK.Service + const catalog = yield* Catalog.Service + yield* catalog.transform((catalog) => + catalog.provider.update(Provider.ID.make("cloudflare-ai-gateway"), (provider) => { + provider.settings = { ...provider.settings, baseURL: "https://proxy.example/v1" } + }), + ) yield* addPlugin() + expect( + (yield* (yield* Integration.Service).get(Integration.ID.make("cloudflare-ai-gateway")))?.methods, + ).toContainEqual({ type: "key", label: "Gateway API token" }) const result = yield* aisdk.runSDK({ model: Model.Info.make({ diff --git a/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts b/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts index 6347ea5335..6f7a15b86e 100644 --- a/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts +++ b/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts @@ -115,6 +115,9 @@ describe("CloudflareWorkersAIPlugin", () => { }), ) yield* addPlugin() + expect( + (yield* (yield* Integration.Service).get(Integration.ID.make("cloudflare-workers-ai")))?.methods, + ).toContainEqual({ type: "key", label: "API key" }) const provider = required(yield* catalog.provider.get(Provider.ID.make("cloudflare-workers-ai"))) const sdk = yield* aisdk.runSDK({ model: Model.Info.make({ @@ -159,7 +162,16 @@ describe("CloudflareWorkersAIPlugin", () => { Effect.gen(function* () { const plugin = yield* Plugin.Service const aisdk = yield* AISDK.Service + const catalog = yield* Catalog.Service + yield* catalog.transform((catalog) => + catalog.provider.update(Provider.ID.make("cloudflare-workers-ai"), (provider) => { + provider.settings = { ...provider.settings, baseURL: "https://proxy.example/v1" } + }), + ) yield* addPlugin() + expect( + (yield* (yield* Integration.Service).get(Integration.ID.make("cloudflare-workers-ai")))?.methods, + ).toContainEqual({ type: "key", label: "API key" }) const result = yield* aisdk.runSDK({ model: Model.Info.make({ ...Model.Info.default(Provider.ID.make("cloudflare-workers-ai"), Model.ID.make("@cf/model")), diff --git a/packages/schema/src/credential.ts b/packages/schema/src/credential.ts index 0d2892020e..0c4fb8d55f 100644 --- a/packages/schema/src/credential.ts +++ b/packages/schema/src/credential.ts @@ -5,6 +5,7 @@ import { optional } from "./schema.js" import { IntegrationMethodID } from "./integration-id.js" import { ascending } from "./identifier.js" import { NonNegativeInt, statics } from "./schema.js" +import { Form } from "./form.js" export const ID = Schema.String.pipe( Schema.brand("Credential.ID"), @@ -27,6 +28,7 @@ export const Key = Schema.Struct({ type: Schema.Literal("key"), key: Schema.String, metadata: optional(Schema.Record(Schema.String, Schema.Unknown)), + configuration: optional(Form.Answer), }).annotate({ identifier: "Credential.Key" }) export const Value = Schema.Union([OAuth, Key]) diff --git a/packages/tui/src/component/dialog-integration.tsx b/packages/tui/src/component/dialog-integration.tsx index 5b2942a296..fc589865d1 100644 --- a/packages/tui/src/component/dialog-integration.tsx +++ b/packages/tui/src/component/dialog-integration.tsx @@ -642,7 +642,6 @@ function OAuthView(props: { } async function formAnswers(dialog: ReturnType, title: string, forms: FormFields) { - dialog.setSize("large") return new Promise((resolve) => { dialog.replace( () => ( @@ -657,6 +656,7 @@ async function formAnswers(dialog: ReturnType, title: string, ), () => resolve(null), ) + dialog.setSize("large") }) }