Bläddra i källkod

refactor(core): replace integration prompts with forms (#40997)

Aiden Cline 1 dag sedan
förälder
incheckning
9bc4eea79a
46 ändrade filer med 1230 tillägg och 548 borttagningar
  1. 0 71
      packages/ai/src/providers/github-copilot.ts
  2. 0 1
      packages/ai/src/providers/index.ts
  3. 0 5
      packages/ai/test/auth-options.types.ts
  4. 0 18
      packages/ai/test/exports.test.ts
  5. 0 13
      packages/ai/test/provider-options/github-copilot.types.ts
  6. 64 52
      packages/app/src/components/dialog-connect-provider.tsx
  7. 1 2
      packages/app/src/context/server-sync.tsx
  8. 1 1
      packages/cli/src/commands/handlers/console/login.ts
  9. 1 1
      packages/cli/src/commands/handlers/mcp/auth.ts
  10. 3 2
      packages/client/src/effect/api/api.ts
  11. 2 2
      packages/client/src/effect/generated/client.ts
  12. 2 2
      packages/client/src/promise/generated/client.ts
  13. 70 80
      packages/client/src/promise/generated/types.ts
  14. 43 0
      packages/client/test/promise.test.ts
  15. 5 5
      packages/core/src/form.ts
  16. 10 31
      packages/core/src/github-copilot/responses/convert-to-openai-responses-input.ts
  17. 1 1
      packages/core/src/github-copilot/responses/openai-responses-api-types.ts
  18. 67 8
      packages/core/src/github-copilot/responses/openai-responses-language-model.ts
  19. 27 22
      packages/core/src/integration.ts
  20. 3 1
      packages/core/src/model-resolver.ts
  21. 8 7
      packages/core/src/plugin/host.ts
  22. 13 7
      packages/core/src/plugin/promise.ts
  23. 26 0
      packages/core/src/plugin/provider/azure.ts
  24. 44 1
      packages/core/src/plugin/provider/cloudflare-ai-gateway.ts
  25. 26 0
      packages/core/src/plugin/provider/cloudflare-workers-ai.ts
  26. 15 0
      packages/core/src/plugin/provider/configured.ts
  27. 54 44
      packages/core/src/plugin/provider/github-copilot.ts
  28. 4 3
      packages/core/src/plugin/provider/opencode.ts
  29. 203 9
      packages/core/test/github-copilot/openai-responses-language-model.test.ts
  30. 19 8
      packages/core/test/integration.test.ts
  31. 6 2
      packages/core/test/model-resolver.test.ts
  32. 9 35
      packages/core/test/plugin/host.ts
  33. 32 0
      packages/core/test/plugin/provider-azure.test.ts
  34. 29 0
      packages/core/test/plugin/provider-cloudflare-ai-gateway.test.ts
  35. 36 0
      packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts
  36. 51 2
      packages/core/test/plugin/provider-github-copilot.test.ts
  37. 17 2
      packages/core/test/plugin/provider-opencode.test.ts
  38. 4 1
      packages/core/test/plugin/websearch.test.ts
  39. 0 5
      packages/core/test/shared-schema.test.ts
  40. 34 10
      packages/plugin/src/effect/integration.ts
  41. 38 11
      packages/plugin/src/promise/integration.ts
  42. 3 3
      packages/protocol/src/groups/integration.ts
  43. 2 0
      packages/schema/src/credential.ts
  44. 3 38
      packages/schema/src/integration.ts
  45. 2 1
      packages/server/src/handlers/integration.ts
  46. 252 41
      packages/tui/src/component/dialog-integration.tsx

+ 0 - 71
packages/ai/src/providers/github-copilot.ts

@@ -1,71 +0,0 @@
-import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
-import type { RouteDefaultsInput } from "../route/client"
-import { ProviderID, type ModelID } from "../schema"
-import * as OpenAIChat from "../protocols/openai-chat"
-import * as OpenAIResponses from "../protocols/openai-responses"
-import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options"
-
-export const id = ProviderID.make("github-copilot")
-
-// GitHub Copilot has no canonical public URL — callers (opencode, etc.) must
-// supply `baseURL` explicitly.
-export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
-  ProviderAuthOption<"optional"> & {
-    readonly baseURL: string
-    readonly endpoint?: "chat" | "responses"
-    readonly providerOptions?: OpenAIProviderOptionsInput
-  }
-
-export const shouldUseResponsesApi = (modelID: string | ModelID, endpoint?: LanguageModelOptions["endpoint"]) => {
-  if (endpoint) return endpoint === "responses"
-  const model = String(modelID)
-  const match = /^gpt-(\d+)/.exec(model)
-  if (!match) return false
-  return Number(match[1]) >= 5 && !model.startsWith("gpt-5-mini")
-}
-
-export const routes = [OpenAIResponses.route, OpenAIChat.route]
-
-const chatRoute = OpenAIChat.route.with({ provider: id })
-const responsesRoute = OpenAIResponses.route.with({ provider: id })
-
-const defaults = (options: LanguageModelOptions) => {
-  const { apiKey: _, auth: _auth, baseURL: _baseURL, endpoint: _endpoint, ...rest } = options
-  return rest
-}
-
-const configuredResponsesRoute = (options: LanguageModelOptions) =>
-  responsesRoute.with({
-    endpoint: { baseURL: options.baseURL },
-    auth: AuthOptions.bearer(options, []),
-  })
-
-const configuredChatRoute = (options: LanguageModelOptions) =>
-  chatRoute.with({
-    endpoint: { baseURL: options.baseURL },
-    auth: AuthOptions.bearer(options, []),
-  })
-
-export const configure = (options: LanguageModelOptions) => {
-  const responsesRoute = configuredResponsesRoute(options)
-  const chatRoute = configuredChatRoute(options)
-  const responses = (modelID: string | ModelID) =>
-    responsesRoute
-      .with(withOpenAIOptions(modelID, defaults(options)))
-      .model<OpenAIProviderOptionsInput>({ id: modelID })
-  const chat = (modelID: string | ModelID) =>
-    chatRoute.with(withOpenAIOptions(modelID, defaults(options))).model<OpenAIProviderOptionsInput>({ id: modelID })
-  return {
-    id,
-    model: (modelID: string | ModelID) =>
-      shouldUseResponsesApi(modelID, options.endpoint) ? responses(modelID) : chat(modelID),
-    responses,
-    chat,
-    configure,
-  }
-}
-
-export const provider = {
-  id,
-  configure,
-}

+ 0 - 1
packages/ai/src/providers/index.ts

@@ -5,7 +5,6 @@ export * as AmazonBedrockMantle from "./amazon-bedrock-mantle"
 export * as Azure from "./azure"
 export * as Cloudflare from "./cloudflare"
 export { CloudflareAIGateway, CloudflareWorkersAI } from "./cloudflare"
-export * as GitHubCopilot from "./github-copilot"
 export * as Google from "./google"
 export * as GoogleVertex from "./google-vertex"
 export * as GoogleVertexChat from "./google-vertex-chat"

+ 0 - 5
packages/ai/test/auth-options.types.ts

@@ -7,7 +7,6 @@ import * as Anthropic from "../src/providers/anthropic"
 import * as AnthropicCompatible from "../src/providers/anthropic-compatible"
 import * as Azure from "../src/providers/azure"
 import * as Cloudflare from "../src/providers/cloudflare"
-import * as GitHubCopilot from "../src/providers/github-copilot"
 import * as Google from "../src/providers/google"
 import * as GoogleVertex from "../src/providers/google-vertex"
 import * as GoogleVertexChat from "../src/providers/google-vertex-chat"
@@ -270,7 +269,3 @@ OpenAICompatible.deepseek.configure({ apiKey: "deepseek-key" }).model("deepseek-
 Cloudflare.CloudflareWorkersAI.configure({ accountId: "account", apiKey: "cf-key" }).model("@cf/meta/llama")
 // @ts-expect-error Cloudflare Workers AI model selectors only accept model ids.
 Cloudflare.CloudflareWorkersAI.configure({ accountId: "account", apiKey: "cf-key" }).model("@cf/meta/llama", {})
-
-GitHubCopilot.configure({ baseURL: "https://copilot.test", apiKey: "copilot-key" }).model("gpt-4.1")
-// @ts-expect-error GitHub Copilot model selectors only accept model ids.
-GitHubCopilot.configure({ baseURL: "https://copilot.test", apiKey: "copilot-key" }).model("gpt-4.1", {})

+ 0 - 18
packages/ai/test/exports.test.ts

@@ -10,7 +10,6 @@ import {
   OpenRouter,
   XAI,
 } from "@opencode-ai/ai/providers"
-import * as GitHubCopilot from "@opencode-ai/ai/providers/github-copilot"
 import {
   OpenAIChat,
   OpenAICompatibleChat,
@@ -60,23 +59,6 @@ describe("public exports", () => {
     expect(XAI.provider.chat).toBe(XAI.chat)
     expect(XAI.configure({ apiKey: "fixture" }).responses("grok-4.3").route.id).toBe("openai-responses")
     expect(XAI.configure({ apiKey: "fixture" }).chat("grok-4.3").route.id).toBe("openai-compatible-chat")
-    expect(
-      GitHubCopilot.configure({ baseURL: "https://api.githubcopilot.test", apiKey: "fixture" }).model,
-    ).toBeFunction()
-    expect(
-      GitHubCopilot.configure({
-        baseURL: "https://api.githubcopilot.test",
-        apiKey: "fixture",
-        endpoint: "responses",
-      }).model("mai-code-1-flash-picker").route.id,
-    ).toBe("openai-responses")
-    expect(
-      GitHubCopilot.configure({
-        baseURL: "https://api.githubcopilot.test",
-        apiKey: "fixture",
-        endpoint: "chat",
-      }).model("gpt-5").route.id,
-    ).toBe("openai-chat")
   })
 
   test("protocol barrels expose supported low-level routes", () => {

+ 0 - 13
packages/ai/test/provider-options/github-copilot.types.ts

@@ -1,13 +0,0 @@
-import { LLM } from "../../src"
-import { GitHubCopilot } from "../../src/providers"
-
-const model = GitHubCopilot.configure({ baseURL: "https://example.com" }).model("gpt-5")
-
-LLM.request({ model, prompt: "Hello", providerOptions: { openai: { reasoningSummary: "auto" } } })
-
-LLM.request({
-  model,
-  prompt: "Hello",
-  // @ts-expect-error Copilot reasoning summaries use the OpenAI union.
-  providerOptions: { openai: { reasoningSummary: "full" } },
-})

+ 64 - 52
packages/app/src/components/dialog-connect-provider.tsx

@@ -1,4 +1,4 @@
-import type { IntegrationMethod, IntegrationOauthConnectOutput } from "@opencode-ai/client/promise"
+import type { FormAnswer, IntegrationMethod, IntegrationOauthConnectOutput } from "@opencode-ai/client/promise"
 import { Button } from "@opencode-ai/ui/button"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { Dialog } from "@opencode-ai/ui/dialog"
@@ -40,6 +40,8 @@ import { decode64 } from "@/utils/base64"
 
 const CUSTOM_ID = "_custom"
 type ConnectMethod = Extract<IntegrationMethod, { type: "key" | "oauth" }>
+type IntegrationForm = NonNullable<ConnectMethod["form"]>[number]
+type StringForm = Extract<IntegrationForm, { type: "string" }>
 
 export function useProviderConnectController(options: { onBack?: () => void } = {}) {
   const [store, setStore] = createStore({ selected: undefined as string | undefined })
@@ -434,16 +436,16 @@ function ProviderConnection(props: {
   const [store, setStore] = createStore({
     methodIndex: undefined as undefined | number,
     authorization: undefined as undefined | IntegrationOauthConnectOutput["data"],
-    promptInputs: undefined as undefined | Record<string, string>,
-    state: "pending" as undefined | "pending" | "complete" | "error" | "prompt",
+    formAnswer: undefined as FormAnswer | undefined,
+    state: "pending" as undefined | "pending" | "complete" | "error" | "form",
     error: undefined as string | undefined,
   })
 
   type Action =
     | { type: "method.select"; index: number }
     | { type: "method.reset" }
-    | { type: "auth.prompt" }
-    | { type: "auth.inputs"; inputs: Record<string, string> }
+    | { type: "auth.form" }
+    | { type: "auth.answer"; answer: FormAnswer | undefined }
     | { type: "auth.pending" }
     | { type: "auth.complete"; authorization: IntegrationOauthConnectOutput["data"] }
     | { type: "auth.error"; error: string }
@@ -454,7 +456,7 @@ function ProviderConnection(props: {
         if (action.type === "method.select") {
           draft.methodIndex = action.index
           draft.authorization = undefined
-          draft.promptInputs = undefined
+          draft.formAnswer = undefined
           draft.state = undefined
           draft.error = undefined
           return
@@ -462,18 +464,18 @@ function ProviderConnection(props: {
         if (action.type === "method.reset") {
           draft.methodIndex = undefined
           draft.authorization = undefined
-          draft.promptInputs = undefined
+          draft.formAnswer = undefined
           draft.state = undefined
           draft.error = undefined
           return
         }
-        if (action.type === "auth.prompt") {
-          draft.state = "prompt"
+        if (action.type === "auth.form") {
+          draft.state = "form"
           draft.error = undefined
           return
         }
-        if (action.type === "auth.inputs") {
-          draft.promptInputs = action.inputs
+        if (action.type === "auth.answer") {
+          draft.formAnswer = action.answer
           draft.state = undefined
           draft.error = undefined
           return
@@ -531,7 +533,7 @@ function ProviderConnection(props: {
     return fallback
   }
 
-  async function selectMethod(index: number, inputs?: Record<string, string>) {
+  async function selectMethod(index: number, answer?: FormAnswer) {
     if (timer.current !== undefined) {
       clearTimeout(timer.current)
       timer.current = undefined
@@ -540,9 +542,17 @@ function ProviderConnection(props: {
     const method = methods()[index]
     dispatch({ type: "method.select", index })
 
+    if (method.form?.length && !answer) {
+      dispatch({ type: "auth.form" })
+      return
+    }
+    if (method.type === "key") {
+      dispatch({ type: "auth.answer", answer })
+      return
+    }
     if (method.type === "oauth") {
-      if (method.prompts?.length && !inputs) {
-        dispatch({ type: "auth.prompt" })
+      if (method.form?.some((field) => field.type !== "string")) {
+        dispatch({ type: "auth.error", error: "This authentication form contains unsupported fields" })
         return
       }
       dispatch({ type: "auth.pending" })
@@ -550,7 +560,7 @@ function ProviderConnection(props: {
         .api.integration.oauth.connect({
           integrationID: props.provider,
           methodID: method.id,
-          inputs: inputs ?? {},
+          ...(answer ? { answer } : {}),
           location: location(),
         })
         .then((x) => {
@@ -564,41 +574,42 @@ function ProviderConnection(props: {
     }
   }
 
-  function AuthPromptsView() {
+  function AuthFormView() {
     const [formStore, setFormStore] = createStore({
       value: {} as Record<string, string>,
       index: 0,
     })
 
-    const prompts = createMemo(() => {
+    const fields = createMemo<StringForm[]>(() => {
       const value = method()
-      return value?.type === "oauth" ? (value.prompts ?? []) : []
+      return (value?.form ?? []).flatMap((field) => (field.type === "string" ? [field] : []))
     })
-    const matches = (prompt: NonNullable<ReturnType<typeof prompts>[number]>, value: Record<string, string>) => {
-      if (!prompt.when) return true
-      const actual = value[prompt.when.key]
-      if (actual === undefined) return false
-      return prompt.when.op === "eq" ? actual === prompt.when.value : actual !== prompt.when.value
+    const matches = (field: StringForm, value: Record<string, string>) => {
+      return (field.when ?? []).every((condition) => {
+        const actual = value[condition.key]
+        if (actual === undefined) return false
+        return condition.op === "eq" ? actual === condition.value : actual !== condition.value
+      })
     }
     const current = createMemo(() => {
-      const all = prompts()
-      const index = all.findIndex((prompt, index) => index >= formStore.index && matches(prompt, formStore.value))
+      const all = fields()
+      const index = all.findIndex((field, index) => index >= formStore.index && matches(field, formStore.value))
       if (index === -1) return
       return {
         index,
-        prompt: all[index],
+        field: all[index],
       }
     })
     const valid = createMemo(() => {
       const item = current()
-      if (!item || item.prompt.type !== "text") return false
-      const value = formStore.value[item.prompt.key] ?? ""
-      return value.trim().length > 0
+      if (!item || item.field.options) return false
+      if (!item.field.required) return true
+      return (formStore.value[item.field.key] ?? "").trim().length > 0
     })
 
     async function next(index: number, value: Record<string, string>) {
       if (store.methodIndex === undefined) return
-      const next = prompts().findIndex((prompt, i) => i > index && matches(prompt, value))
+      const next = fields().findIndex((field, i) => i > index && matches(field, value))
       if (next !== -1) {
         setFormStore("index", next)
         return
@@ -609,60 +620,60 @@ function ProviderConnection(props: {
     async function handleSubmit(e: SubmitEvent) {
       e.preventDefault()
       const item = current()
-      if (!item || item.prompt.type !== "text") return
+      if (!item || item.field.options) return
       if (!valid()) return
       await next(item.index, formStore.value)
     }
 
     const item = () => current()
     const text = createMemo(() => {
-      const prompt = item()?.prompt
-      if (!prompt || prompt.type !== "text") return
-      return prompt
+      const field = item()?.field
+      if (!field || field.options) return
+      return field
     })
     const select = createMemo(() => {
-      const prompt = item()?.prompt
-      if (!prompt || prompt.type !== "select") return
-      return prompt
+      const field = item()?.field
+      if (!field?.options) return
+      return field
     })
 
     return (
       <form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
         <Switch>
-          <Match when={item()?.prompt.type === "text"}>
+          <Match when={item()?.field.options === undefined}>
             <TextField
               type="text"
-              label={text()?.message ?? ""}
+              label={text()?.title ?? ""}
               placeholder={text()?.placeholder}
               value={text() ? (formStore.value[text()!.key] ?? "") : ""}
               onChange={(value) => {
-                const prompt = text()
-                if (!prompt) return
-                setFormStore("value", prompt.key, value)
+                const field = text()
+                if (!field) return
+                setFormStore("value", field.key, value)
               }}
             />
             <Button class="w-auto" type="submit" size="large" variant="primary" disabled={!valid()}>
               {language.t("common.continue")}
             </Button>
           </Match>
-          <Match when={item()?.prompt.type === "select"}>
+          <Match when={item()?.field.options !== undefined}>
             <div class="w-full flex flex-col gap-1.5">
-              <div class="text-14-regular text-text-base">{select()?.message}</div>
+              <div class="text-14-regular text-text-base">{select()?.title}</div>
               <div>
                 <List
                   class="px-3"
                   items={select()?.options ?? []}
                   key={(x) => x.value}
-                  current={select()?.options.find((x) => x.value === formStore.value[select()!.key])}
+                  current={select()?.options?.find((x) => x.value === formStore.value[select()!.key])}
                   onSelect={(value) => {
                     if (!value) return
-                    const prompt = select()
-                    if (!prompt) return
+                    const field = select()
+                    if (!field) return
                     const nextValue = {
                       ...formStore.value,
-                      [prompt.key]: value.value,
+                      [field.key]: value.value,
                     }
-                    setFormStore("value", prompt.key, value.value)
+                    setFormStore("value", field.key, value.value)
                     void next(item()!.index, nextValue)
                   }}
                 >
@@ -672,7 +683,7 @@ function ProviderConnection(props: {
                         <div class="w-2.5 h-0.5 ml-0 bg-icon-strong-base hidden" data-slot="list-item-extra-icon" />
                       </div>
                       <span>{option.label}</span>
-                      <span class="text-14-regular text-text-weak">{option.hint}</span>
+                      <span class="text-14-regular text-text-weak">{option.description}</span>
                     </div>
                   )}
                 </List>
@@ -820,6 +831,7 @@ function ProviderConnection(props: {
         integrationID: props.provider,
         location: location(),
         key: apiKey,
+        ...(store.formAnswer ? { answer: store.formAnswer } : {}),
       })
       await complete()
     }
@@ -1143,8 +1155,8 @@ function ProviderConnection(props: {
                 </div>
               </div>
             </Match>
-            <Match when={store.state === "prompt"}>
-              <AuthPromptsView />
+            <Match when={store.state === "form"}>
+              <AuthFormView />
             </Match>
             <Match when={store.state === "error"}>
               <div class="text-14-regular text-text-base">

+ 1 - 2
packages/app/src/context/server-sync.tsx

@@ -662,13 +662,12 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
               integrationID: server.integrationID,
               location: { directory: key },
             })
-            const method = integration.data?.methods.find((item) => item.type === "oauth" && !item.prompts?.length)
+            const method = integration.data?.methods.find((item) => item.type === "oauth" && !item.form?.length)
             if (!method || method.type !== "oauth")
               throw new Error(`MCP server ${name} requires an interactive authentication form`)
             const attempt = await serverSDK.api.integration.oauth.connect({
               integrationID: server.integrationID,
               methodID: method.id,
-              inputs: {},
               location: { directory: key },
             })
             platform.openLink(attempt.data.url)

+ 1 - 1
packages/cli/src/commands/handlers/console/login.ts

@@ -50,7 +50,7 @@ const login = Effect.fn("cli.console.login.run")(function* (timeline: TimelineHo
       {
         integrationID,
         methodID: method.id,
-        inputs: server ? { server } : {},
+        ...(server ? { answer: { server } } : {}),
         location,
       },
       { signal },

+ 1 - 1
packages/cli/src/commands/handlers/mcp/auth.ts

@@ -32,7 +32,7 @@ export default Runtime.handler(
       return yield* Effect.fail(new Error(`MCP server "${input.name}" is not an OAuth-capable remote server`))
 
     const started = yield* Effect.promise(() =>
-      client.integration.oauth.connect({ integrationID: integration.id, methodID: method.id, inputs: {}, location }),
+      client.integration.oauth.connect({ integrationID: integration.id, methodID: method.id, location }),
     )
     const attempt = started.data
     if (attempt.mode === "code")

+ 3 - 2
packages/client/src/effect/api/api.ts

@@ -23,9 +23,9 @@ import type { Shell } from "@opencode-ai/schema/shell"
 import type { DateTime } from "effect"
 import type { Provider } from "@opencode-ai/schema/provider"
 import type { Integration } from "@opencode-ai/schema/integration"
+import type { Form } from "@opencode-ai/schema/form"
 import type { Mcp } from "@opencode-ai/schema/mcp"
 import type { Credential } from "@opencode-ai/schema/credential"
-import type { Form } from "@opencode-ai/schema/form"
 import type { Permission } from "@opencode-ai/schema/permission"
 import type { PermissionSaved } from "@opencode-ai/schema/permission-saved"
 import type { FileSystem } from "@opencode-ai/schema/filesystem"
@@ -1054,6 +1054,7 @@ export type Endpoint10_3Input = {
   readonly integrationID: Integration.ID
   readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
   readonly key: string
+  readonly answer?: Form.Answer | undefined
   readonly label?: string | undefined
 }
 export type Endpoint10_3Output = void
@@ -1065,7 +1066,7 @@ export type Endpoint10_4Input = {
   readonly integrationID: Integration.ID
   readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
   readonly methodID: Integration.MethodID
-  readonly inputs: { readonly [x: string]: string }
+  readonly answer?: Form.Answer | undefined
   readonly label?: string | undefined
 }
 export type Endpoint10_4Output = { readonly location: Location.Info; readonly data: Integration.Attempt }

+ 2 - 2
packages/client/src/effect/generated/client.ts

@@ -717,7 +717,7 @@ const Endpoint10_3 = (raw: RawClient["server.integration"]) => (input: Endpoint1
     raw["integration.connect.key"]({
       params: { integrationID: input["integrationID"] },
       query: { location: input["location"] },
-      payload: { key: input["key"], label: input["label"] },
+      payload: { key: input["key"], answer: input["answer"], label: input["label"] },
     }).pipe(Effect.mapError(mapClientError)),
   )
 
@@ -726,7 +726,7 @@ const Endpoint10_4 = (raw: RawClient["server.integration"]) => (input: Endpoint1
     raw["integration.oauth.connect"]({
       params: { integrationID: input["integrationID"] },
       query: { location: input["location"] },
-      payload: { methodID: input["methodID"], inputs: input["inputs"], label: input["label"] },
+      payload: { methodID: input["methodID"], answer: input["answer"], label: input["label"] },
     }).pipe(Effect.mapError(mapClientError)),
   )
 

+ 2 - 2
packages/client/src/promise/generated/client.ts

@@ -1032,7 +1032,7 @@ export function make(options: ClientOptions) {
               method: "POST",
               path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/key`,
               query: { location: input["location"] },
-              body: { key: input["key"], label: input["label"] },
+              body: { key: input["key"], answer: input["answer"], label: input["label"] },
               successStatus: 204,
               declaredStatuses: [400, 401],
               empty: true,
@@ -1047,7 +1047,7 @@ export function make(options: ClientOptions) {
               method: "POST",
               path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/oauth`,
               query: { location: input["location"] },
-              body: { methodID: input["methodID"], inputs: input["inputs"], label: input["label"] },
+              body: { methodID: input["methodID"], answer: input["answer"], label: input["label"] },
               successStatus: 200,
               declaredStatuses: [400, 401],
               empty: false,

+ 70 - 80
packages/client/src/promise/generated/types.ts

@@ -195,11 +195,17 @@ export type ProviderInfo = {
   body?: { [x: string]: any }
 }
 
-export type IntegrationWhen = { key: string; op: "eq" | "neq"; value: string }
+export type FormWhen = {
+  key: string
+  op: "eq" | "neq"
+  value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean
+}
 
-export type IntegrationCommandMethod = { id: string; type: "command"; label: string; command: Array<string> }
+export type FormOption = { value: string; label: string; description?: string }
 
-export type IntegrationKeyMethod = { type: "key"; label?: string }
+export type FormExternalField = { key: string; type: "external"; url: string; title?: string; description?: string }
+
+export type IntegrationCommandMethod = { id: string; type: "command"; label: string; command: Array<string> }
 
 export type IntegrationEnvMethod = { type: "env"; names: Array<string> }
 
@@ -285,16 +291,6 @@ export type ProjectDirectory = { directory: string; strategy?: string }
 
 export type FormMetadata = { [x: string]: JsonValue }
 
-export type FormWhen = {
-  key: string
-  op: "eq" | "neq"
-  value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean
-}
-
-export type FormOption = { value: string; label: string; description?: string }
-
-export type FormExternalField = { key: string; type: "external"; url: string; title?: string; description?: string }
-
 export type FormValue = string | number | boolean | Array<string>
 
 export type PermissionSource = { type: "tool"; messageID: string; id: string }
@@ -1277,45 +1273,6 @@ export type ModelCost = {
   cache: { read: MoneyUSDPerMillionTokens; write: MoneyUSDPerMillionTokens }
 }
 
-export type IntegrationTextPrompt = {
-  type: "text"
-  key: string
-  message: string
-  placeholder?: string
-  when?: IntegrationWhen
-}
-
-export type IntegrationSelectPrompt = {
-  type: "select"
-  key: string
-  message: string
-  options: Array<{ label: string; value: string; hint?: string }>
-  when?: IntegrationWhen
-}
-
-export type ConnectionInfo = ConnectionCredentialInfo | ConnectionEnvInfo
-
-export type McpServer = {
-  name: string
-  status: McpStatusConnected | McpStatusPending | McpStatusDisabled | McpStatusFailed | McpStatusNeedsAuth
-  integrationID?: string
-}
-
-export type McpResourceCatalog = { resources: Array<McpResource>; templates: Array<McpResourceTemplate> }
-
-export type Project = {
-  id: string
-  canonical: string
-  vcs?: ProjectVcs
-  name?: string
-  icon?: ProjectIcon
-  commands?: ProjectCommands
-  time: ProjectTime
-  sandboxes: Array<string>
-}
-
-export type ProjectDirectories = Array<ProjectDirectory>
-
 export type FormNumberField = {
   key: string
   title?: string
@@ -1381,6 +1338,29 @@ export type FormMultiselectField = {
   default?: Array<string>
 }
 
+export type ConnectionInfo = ConnectionCredentialInfo | ConnectionEnvInfo
+
+export type McpServer = {
+  name: string
+  status: McpStatusConnected | McpStatusPending | McpStatusDisabled | McpStatusFailed | McpStatusNeedsAuth
+  integrationID?: string
+}
+
+export type McpResourceCatalog = { resources: Array<McpResource>; templates: Array<McpResourceTemplate> }
+
+export type Project = {
+  id: string
+  canonical: string
+  vcs?: ProjectVcs
+  name?: string
+  icon?: ProjectIcon
+  commands?: ProjectCommands
+  time: ProjectTime
+  sandboxes: Array<string>
+}
+
+export type ProjectDirectories = Array<ProjectDirectory>
+
 export type FormAnswer = { [x: string]: FormValue }
 
 export type PermissionRequest = {
@@ -1664,13 +1644,6 @@ export type ModelInfo = {
   limit: { context: number; input?: number; output: number }
 }
 
-export type IntegrationOAuthMethod = {
-  id: string
-  type: "oauth"
-  label: string
-  prompts?: Array<IntegrationTextPrompt | IntegrationSelectPrompt>
-}
-
 export type FormField =
   | FormStringField
   | FormNumberField
@@ -1924,15 +1897,9 @@ export type SessionMessageAssistantTool = {
   time: { created: number; ran?: number; completed?: number }
 }
 
-export type IntegrationMethod =
-  | IntegrationOAuthMethod
-  | IntegrationCommandMethod
-  | IntegrationKeyMethod
-  | IntegrationEnvMethod
-
 export type FormFields = [FormField, ...Array<FormField>]
 
-export type FormFields1 = [FormField1, ...Array<FormField1>]
+export type FormFields3 = [FormField1, ...Array<FormField1>]
 
 export type SessionPendingInfo = SessionPendingUser | SessionPendingSynthetic | SessionPendingCompaction
 
@@ -1954,16 +1921,13 @@ export type SessionMessageAssistant = {
   retry?: SessionMessageAssistantRetry
 }
 
-export type IntegrationInfo = {
-  id: string
-  name: string
-  methods: Array<IntegrationMethod>
-  connections: Array<ConnectionInfo>
-}
+export type IntegrationOAuthMethod = { id: string; type: "oauth"; label: string; form?: FormFields }
+
+export type IntegrationKeyMethod = { type: "key"; label?: string; form?: FormFields }
 
 export type FormInfo = { id: string; sessionID: string; title: string; metadata?: FormMetadata; fields: FormFields }
 
-export type FormInfo1 = { id: string; sessionID: string; title: string; metadata?: FormMetadata1; fields: FormFields1 }
+export type FormInfo1 = { id: string; sessionID: string; title: string; metadata?: FormMetadata1; fields: FormFields3 }
 
 export type SessionInputAdmitted = {
   id: string
@@ -1986,6 +1950,12 @@ export type SessionMessageInfo =
   | SessionMessageAssistant
   | SessionMessageCompaction
 
+export type IntegrationMethod =
+  | IntegrationOAuthMethod
+  | IntegrationCommandMethod
+  | IntegrationKeyMethod
+  | IntegrationEnvMethod
+
 export type FormCreated = {
   id: string
   created: number
@@ -2046,6 +2016,13 @@ export type SessionMessagesResponse = {
   cursor: { previous?: string | null; next?: string | null }
 }
 
+export type IntegrationInfo = {
+  id: string
+  name: string
+  methods: Array<IntegrationMethod>
+  connections: Array<ConnectionInfo>
+}
+
 export type V2Event =
   | ModelsDevRefreshed
   | IntegrationUpdated
@@ -4045,8 +4022,21 @@ export type IntegrationConnectKeyInput = {
   readonly location?: {
     readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
   }["location"]
-  readonly key: { readonly key: string; readonly label?: string | undefined }["key"]
-  readonly label?: { readonly key: string; readonly label?: string | undefined }["label"]
+  readonly key: {
+    readonly key: string
+    readonly answer?: { readonly [x: string]: string | number | boolean | ReadonlyArray<string> } | undefined
+    readonly label?: string | undefined
+  }["key"]
+  readonly answer?: {
+    readonly key: string
+    readonly answer?: { readonly [x: string]: string | number | boolean | ReadonlyArray<string> } | undefined
+    readonly label?: string | undefined
+  }["answer"]
+  readonly label?: {
+    readonly key: string
+    readonly answer?: { readonly [x: string]: string | number | boolean | ReadonlyArray<string> } | undefined
+    readonly label?: string | undefined
+  }["label"]
 }
 
 export type IntegrationConnectKeyOutput = void
@@ -4058,17 +4048,17 @@ export type IntegrationOauthConnectInput = {
   }["location"]
   readonly methodID: {
     readonly methodID: string
-    readonly inputs: { readonly [x: string]: string }
+    readonly answer?: { readonly [x: string]: string | number | boolean | ReadonlyArray<string> } | undefined
     readonly label?: string | undefined
   }["methodID"]
-  readonly inputs: {
+  readonly answer?: {
     readonly methodID: string
-    readonly inputs: { readonly [x: string]: string }
+    readonly answer?: { readonly [x: string]: string | number | boolean | ReadonlyArray<string> } | undefined
     readonly label?: string | undefined
-  }["inputs"]
+  }["answer"]
   readonly label?: {
     readonly methodID: string
-    readonly inputs: { readonly [x: string]: string }
+    readonly answer?: { readonly [x: string]: string | number | boolean | ReadonlyArray<string> } | undefined
     readonly label?: string | undefined
   }["label"]
 }

+ 43 - 0
packages/client/test/promise.test.ts

@@ -148,6 +148,49 @@ test("experimental wellknown integration add uses the public HTTP contract", asy
   expect(await request?.json()).toEqual({ url: "https://example.com" })
 })
 
+test("integration connections optionally submit a form answer", async () => {
+  const requests: Request[] = []
+  const client = OpenCode.make({
+    baseUrl: "http://localhost:3000",
+    fetch: async (input, init) => {
+      const request = input instanceof Request ? input : new Request(input, init)
+      requests.push(request)
+      if (request.url.endsWith("/connect/key")) return new Response(null, { status: 204 })
+      return Response.json({
+        location: { directory: "/tmp/project", project: { id: "proj_test", directory: "/tmp/project" } },
+        data: {
+          attemptID: "con_test",
+          url: "https://example.com/authorize",
+          instructions: "Authorize",
+          mode: "auto",
+          time: { created: 1, expires: 2 },
+        },
+      })
+    },
+  })
+
+  await client.integration.connect.key({
+    integrationID: "cloudflare-workers-ai",
+    key: "secret",
+    answer: { accountId: "account" },
+  })
+  await client.integration.oauth.connect({
+    integrationID: "github-copilot",
+    methodID: "device",
+    answer: { deploymentType: "enterprise", enabled: true, scopes: ["read:user"] },
+  })
+  await client.integration.connect.key({ integrationID: "openai", key: "secret" })
+  await client.integration.oauth.connect({ integrationID: "openai", methodID: "device" })
+
+  expect(await requests[0].json()).toEqual({ key: "secret", answer: { accountId: "account" } })
+  expect(await requests[1].json()).toEqual({
+    methodID: "device",
+    answer: { deploymentType: "enterprise", enabled: true, scopes: ["read:user"] },
+  })
+  expect(await requests[2].json()).toEqual({ key: "secret" })
+  expect(await requests[3].json()).toEqual({ methodID: "device" })
+})
+
 test("health.stop sends exact replacement identity", async () => {
   let request: Request | undefined
   const client = OpenCode.make({

+ 5 - 5
packages/core/src/form.ts

@@ -180,7 +180,7 @@ export const layer = Layer.effect(
         Effect.gen(function* () {
           const entry = yield* find(input.id)
           if (entry.state.status !== "pending") return yield* new AlreadySettledError({ id: input.id })
-          const invalid = validateAnswer(entry.form, input.answer)
+          const invalid = validateAnswer(entry.form.fields, input.answer)
           if (invalid) return yield* new InvalidAnswerError({ id: input.id, message: invalid })
           const next: TerminalState = { status: "answered", answer: input.answer }
           yield* bus.publish(Form.Event.Replied, {
@@ -227,12 +227,12 @@ export const locationLayer = layer
 
 export const node = makeLocationNode({ service: Service, layer, deps: [Bus.node] })
 
-function validateAnswer(form: Info, answer: Answer) {
-  const fields = new Map(form.fields.map((field) => [field.key, field] as const))
+export function validateAnswer(form: ReadonlyArray<Form.Field>, answer: Answer) {
+  const fields = new Map(form.map((field) => [field.key, field] as const))
   for (const key of Object.keys(answer)) {
     if (!fields.has(key)) return `Unknown form field: ${key}`
   }
-  for (const field of form.fields) {
+  for (const field of form) {
     const value = answer[field.key]
     if (field.type === "external") {
       if (value !== true) return `External form field must be acknowledged: ${field.key}`
@@ -268,7 +268,7 @@ function matches(when: Form.When, value: Form.Value | undefined) {
 // carry a value matching that field's type, and use a declared option when the field's options
 // are closed. Rejecting these at creation surfaces authoring mistakes to the caller instead of
 // silently never matching.
-function validateFields(fields: ReadonlyArray<Form.Field>) {
+export function validateFields(fields: ReadonlyArray<Form.Field>) {
   if (fields.length === 0) return "Form must have at least one field"
   const earlier = new Map<string, InputField>()
   const keys = new Set<string>()

+ 10 - 31
packages/core/src/github-copilot/responses/convert-to-openai-responses-input.ts

@@ -127,7 +127,7 @@ export async function convertToOpenAIResponsesInput({
               input.push({
                 role: "assistant",
                 content: [{ type: "output_text", text: part.text }],
-                id: (part.providerOptions?.copilot?.itemId as string) ?? undefined,
+                id: store ? ((part.providerOptions?.copilot?.itemId as string) ?? undefined) : undefined,
               })
               break
             }
@@ -143,7 +143,7 @@ export async function convertToOpenAIResponsesInput({
                 input.push({
                   type: "local_shell_call",
                   call_id: part.toolCallId,
-                  id: (part.providerOptions?.copilot?.itemId as string) ?? undefined,
+                  id: store ? ((part.providerOptions?.copilot?.itemId as string) ?? undefined) : undefined,
                   action: {
                     type: "exec",
                     command: parsedInput.action.command,
@@ -162,7 +162,7 @@ export async function convertToOpenAIResponsesInput({
                 call_id: part.toolCallId,
                 name: part.toolName,
                 arguments: JSON.stringify(part.input),
-                id: (part.providerOptions?.copilot?.itemId as string) ?? undefined,
+                id: store ? ((part.providerOptions?.copilot?.itemId as string) ?? undefined) : undefined,
               })
               break
             }
@@ -206,35 +206,14 @@ export async function convertToOpenAIResponsesInput({
                       summary: [],
                     }
                   }
-                } else {
-                  const summaryParts: Array<{
-                    type: "summary_text"
-                    text: string
-                  }> = []
-
-                  if (part.text.length > 0) {
-                    summaryParts.push({
-                      type: "summary_text",
-                      text: part.text,
-                    })
-                  } else if (reasoningMessage !== undefined) {
-                    warnings.push({
-                      type: "other",
-                      message: `Cannot append empty reasoning part to existing reasoning sequence. Skipping reasoning part: ${JSON.stringify(part)}.`,
-                    })
-                  }
-
-                  if (reasoningMessage === undefined) {
-                    reasoningMessages[reasoningId] = {
-                      type: "reasoning",
-                      id: reasoningId,
-                      encrypted_content: providerOptions?.reasoningEncryptedContent,
-                      summary: summaryParts,
-                    }
-                    input.push(reasoningMessages[reasoningId])
-                  } else {
-                    reasoningMessage.summary.push(...summaryParts)
+                } else if (providerOptions?.reasoningEncryptedContent != null && reasoningMessage === undefined) {
+                  reasoningMessages[reasoningId] = {
+                    type: "reasoning",
+                    id: reasoningId,
+                    encrypted_content: providerOptions.reasoningEncryptedContent,
+                    summary: [],
                   }
+                  input.push(reasoningMessages[reasoningId])
                 }
               } else {
                 warnings.push({

+ 1 - 1
packages/core/src/github-copilot/responses/openai-responses-api-types.ts

@@ -71,7 +71,7 @@ export type OpenAIResponsesComputerCall = {
 
 export type OpenAIResponsesLocalShellCall = {
   type: "local_shell_call"
-  id: string
+  id?: string
   call_id: string
   action: {
     type: "exec"

+ 67 - 8
packages/core/src/github-copilot/responses/openai-responses-language-model.ts

@@ -198,12 +198,13 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
       providerOptions,
       schema: openaiResponsesProviderOptionsSchema,
     })
+    const store = openaiOptions?.store ?? false
 
     const { input, warnings: inputWarnings } = await convertToOpenAIResponsesInput({
       prompt,
       systemMessageMode: modelConfig.systemMessageMode,
       fileIdPrefixes: this.config.fileIdPrefixes,
-      store: openaiOptions?.store ?? true,
+      store,
       hasLocalShellTool: hasOpenAITool("openai.local_shell"),
     })
 
@@ -214,9 +215,12 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
     let include: OpenAIResponsesIncludeOptions = openaiOptions?.include
 
     function addInclude(key: OpenAIResponsesIncludeValue) {
+      if (include?.includes(key)) return
       include = include != null ? [...include, key] : [key]
     }
 
+    addInclude("reasoning.encrypted_content")
+
     function hasOpenAITool(id: string) {
       return tools?.find((tool) => tool.type === "provider" && tool.id === id) != null
     }
@@ -282,7 +286,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
       metadata: openaiOptions?.metadata,
       parallel_tool_calls: openaiOptions?.parallelToolCalls,
       previous_response_id: openaiOptions?.previousResponseId,
-      store: openaiOptions?.store,
+      store,
       user: openaiOptions?.user,
       instructions: openaiOptions?.instructions,
       service_tier: openaiOptions?.serviceTier,
@@ -840,7 +844,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
       {
         canonicalId: string // the item.id from output_item.added
         encryptedContent?: string | null
-        summaryParts: number[]
+        summaryParts: Record<number, "active" | "can-conclude" | "concluded">
       }
     > = {}
 
@@ -960,10 +964,14 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
                   },
                 })
               } else if (isResponseOutputItemAddedReasoningChunk(value)) {
+                if (activeReasoning[value.output_index]) {
+                  currentReasoningOutputIndex = value.output_index
+                  return
+                }
                 activeReasoning[value.output_index] = {
                   canonicalId: value.item.id,
                   encryptedContent: value.item.encrypted_content,
-                  summaryParts: [0],
+                  summaryParts: { 0: "active" },
                 }
                 currentReasoningOutputIndex = value.output_index
 
@@ -1117,13 +1125,14 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
               } else if (isResponseOutputItemDoneReasoningChunk(value)) {
                 const activeReasoningPart = activeReasoning[value.output_index]
                 if (activeReasoningPart) {
-                  for (const summaryIndex of activeReasoningPart.summaryParts) {
+                  for (const [summaryIndex, status] of Object.entries(activeReasoningPart.summaryParts)) {
+                    if (status === "concluded") continue
                     controller.enqueue({
                       type: "reasoning-end",
                       id: `${activeReasoningPart.canonicalId}:${summaryIndex}`,
                       providerMetadata: {
                         copilot: {
-                          itemId: activeReasoningPart.canonicalId,
+                          itemId: value.item.id,
                           reasoningEncryptedContent: value.item.encrypted_content ?? null,
                         },
                       },
@@ -1228,8 +1237,19 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
                 currentReasoningOutputIndex !== null ? activeReasoning[currentReasoningOutputIndex] : null
 
               // the first reasoning start is pushed in isResponseOutputItemAddedReasoningChunk.
-              if (activeItem && value.summary_index > 0) {
-                activeItem.summaryParts.push(value.summary_index)
+              if (activeItem && value.summary_index > 0 && activeItem.summaryParts[value.summary_index] === undefined) {
+                for (const [summaryIndex, status] of Object.entries(activeItem.summaryParts)) {
+                  if (status !== "can-conclude") continue
+                  controller.enqueue({
+                    type: "reasoning-end",
+                    id: `${activeItem.canonicalId}:${summaryIndex}`,
+                    providerMetadata: {
+                      copilot: { itemId: activeItem.canonicalId },
+                    },
+                  })
+                  activeItem.summaryParts[Number(summaryIndex)] = "concluded"
+                }
+                activeItem.summaryParts[value.summary_index] = "active"
 
                 controller.enqueue({
                   type: "reasoning-start",
@@ -1242,6 +1262,22 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
                   },
                 })
               }
+            } else if (isResponseReasoningSummaryPartDoneChunk(value)) {
+              const activeItem =
+                currentReasoningOutputIndex !== null ? activeReasoning[currentReasoningOutputIndex] : null
+              if (!activeItem || activeItem.summaryParts[value.summary_index] !== "active") return
+              if (body.store === false) {
+                activeItem.summaryParts[value.summary_index] = "can-conclude"
+                return
+              }
+              controller.enqueue({
+                type: "reasoning-end",
+                id: `${activeItem.canonicalId}:${value.summary_index}`,
+                providerMetadata: {
+                  copilot: { itemId: activeItem.canonicalId },
+                },
+              })
+              activeItem.summaryParts[value.summary_index] = "concluded"
             } else if (isResponseReasoningSummaryTextDeltaChunk(value)) {
               const activeItem =
                 currentReasoningOutputIndex !== null ? activeReasoning[currentReasoningOutputIndex] : null
@@ -1304,6 +1340,16 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
               controller.enqueue({ type: "text-end", id: currentTextId })
               currentTextId = null
             }
+            for (const activeItem of Object.values(activeReasoning)) {
+              for (const [summaryIndex, status] of Object.entries(activeItem.summaryParts)) {
+                if (status === "concluded") continue
+                controller.enqueue({
+                  type: "reasoning-end",
+                  id: `${activeItem.canonicalId}:${summaryIndex}`,
+                  providerMetadata: { copilot: { itemId: activeItem.canonicalId } },
+                })
+              }
+            }
 
             const providerMetadata: SharedV3ProviderMetadata = {
               copilot: {
@@ -1552,6 +1598,12 @@ const responseReasoningSummaryTextDeltaSchema = z.object({
   delta: z.string(),
 })
 
+const responseReasoningSummaryPartDoneSchema = z.object({
+  type: z.literal("response.reasoning_summary_part.done"),
+  item_id: z.string(),
+  summary_index: z.number(),
+})
+
 const openaiResponsesChunkSchema = z.union([
   textDeltaChunkSchema,
   responseFinishedChunkSchema,
@@ -1564,6 +1616,7 @@ const openaiResponsesChunkSchema = z.union([
   responseCodeInterpreterCallCodeDoneSchema,
   responseAnnotationAddedSchema,
   responseReasoningSummaryPartAddedSchema,
+  responseReasoningSummaryPartDoneSchema,
   responseReasoningSummaryTextDeltaSchema,
   errorChunkSchema,
   z.object({ type: z.string() }).loose(), // fallback for unknown chunks
@@ -1652,6 +1705,12 @@ function isResponseReasoningSummaryPartAddedChunk(
   return chunk.type === "response.reasoning_summary_part.added"
 }
 
+function isResponseReasoningSummaryPartDoneChunk(
+  chunk: z.infer<typeof openaiResponsesChunkSchema>,
+): chunk is z.infer<typeof responseReasoningSummaryPartDoneSchema> {
+  return chunk.type === "response.reasoning_summary_part.done"
+}
+
 function isResponseReasoningSummaryTextDeltaChunk(
   chunk: z.infer<typeof openaiResponsesChunkSchema>,
 ): chunk is z.infer<typeof responseReasoningSummaryTextDeltaSchema> {

+ 27 - 22
packages/core/src/integration.ts

@@ -24,6 +24,7 @@ import { Bus } from "./bus"
 import { IntegrationConnection } from "./integration/connection"
 import { AppProcess } from "@opencode-ai/util/process"
 import { ChildProcess } from "effect/unstable/process"
+import { Form } from "./form"
 
 export const ID = Integration.ID
 export type ID = Integration.ID
@@ -34,18 +35,6 @@ export type MethodID = Integration.MethodID
 export const AttemptID = Integration.AttemptID
 export type AttemptID = typeof AttemptID.Type
 
-export const When = Integration.When
-export type When = Integration.When
-
-export const TextPrompt = Integration.TextPrompt
-export type TextPrompt = Integration.TextPrompt
-
-export const SelectPrompt = Integration.SelectPrompt
-export type SelectPrompt = Integration.SelectPrompt
-
-export const Prompt = Integration.Prompt
-export type Prompt = Integration.Prompt
-
 export const OAuthMethod = Integration.OAuthMethod
 export type OAuthMethod = Integration.OAuthMethod
 
@@ -64,9 +53,6 @@ export type Method = Integration.Method
 export const Info = Integration.Info
 export type Info = Integration.Info
 
-export const Inputs = Integration.Inputs
-export type Inputs = Integration.Inputs
-
 export type OAuthAuthorization = {
   readonly url: string
   readonly instructions: string
@@ -85,7 +71,7 @@ export type OAuthAuthorization = {
 export interface OAuthImplementation {
   readonly integrationID: ID
   readonly method: OAuthMethod
-  readonly authorize: (inputs: Inputs) => Effect.Effect<OAuthAuthorization, unknown, Scope.Scope>
+  readonly authorize: (answer: Form.Answer) => Effect.Effect<OAuthAuthorization, unknown, Scope.Scope>
   readonly refresh?: (credential: Credential.OAuth) => Effect.Effect<Credential.OAuth, unknown>
   readonly label?: (credential: Credential.OAuth) => string | undefined
 }
@@ -175,6 +161,8 @@ export interface Interface extends State.Transformable<Draft> {
       readonly integrationID: ID
       /** Secret entered by the user. */
       readonly key: string
+      /** Values collected from the method's form fields. */
+      readonly answer?: Form.Answer
       /** User-facing label for the stored credential. */
       readonly label?: string
     }) => Effect.Effect<void, AuthorizationError>
@@ -191,7 +179,7 @@ export interface Interface extends State.Transformable<Draft> {
     readonly connect: (input: {
       readonly integrationID: ID
       readonly methodID: MethodID
-      readonly inputs: Inputs
+      readonly answer?: Form.Answer
       readonly label?: string
     }) => Effect.Effect<Attempt, AuthorizationError>
     /** Returns the current state of an OAuth attempt. */
@@ -356,7 +344,7 @@ const layer = Layer.effect(
       return [...credentials, ...env]
     }
 
-    const project = (entry: Entry, connections: IntegrationConnection.Info[]) =>
+    const project = (entry: Entry, connections: IntegrationConnection.Info[]): Info =>
       Info.make({
         id: entry.ref.id,
         name: entry.ref.name,
@@ -547,15 +535,20 @@ const layer = Layer.effect(
     const connectOAuth = Effect.fn("Integration.oauth.connect")(function* (input: {
       readonly integrationID: ID
       readonly methodID: MethodID
-      readonly inputs: Inputs
+      readonly answer?: Form.Answer
       readonly label?: string
     }) {
       const method = state.get().integrations.get(input.integrationID)?.implementations.get(input.methodID)
       if (!method) {
         return yield* Effect.die(new Error(`OAuth method not found: ${input.integrationID}/${input.methodID}`))
       }
+      const answer = input.answer ?? {}
+      if (method.method.form) {
+        const invalid = Form.validateFields(method.method.form) ?? Form.validateAnswer(method.method.form, answer)
+        if (invalid) return yield* new AuthorizationError({ cause: new Error(invalid) })
+      }
       const attemptScope = yield* Scope.fork(scope)
-      const authorization = yield* authorize(method.authorize(input.inputs)).pipe(
+      const authorization = yield* authorize(method.authorize(answer)).pipe(
         Scope.provide(attemptScope),
         Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(attemptScope, exit) : Effect.void)),
       )
@@ -699,12 +692,24 @@ const layer = Layer.effect(
           const method = state
             .get()
             .integrations.get(input.integrationID)
-            ?.methods.some((method) => method.type === "key")
+            ?.methods.find((method) => method.type === "key")
           if (!method) return yield* Effect.die(new Error(`Key method not found: ${input.integrationID}`))
+          const answer = input.answer ?? {}
+          if (method.type === "key" && method.form) {
+            const invalid = Form.validateFields(method.form) ?? Form.validateAnswer(method.form, answer)
+            if (invalid) return yield* new AuthorizationError({ cause: new Error(invalid) })
+          }
+          if (method.type === "key" && !method.form && Object.keys(answer).length > 0) {
+            return yield* new AuthorizationError({ cause: new Error("Key method does not accept a form answer") })
+          }
           yield* credentials.create({
             integrationID: input.integrationID,
             label: input.label,
-            value: Credential.Key.make({ type: "key", key: input.key }),
+            value: Credential.Key.make({
+              type: "key",
+              key: input.key,
+              ...(Object.keys(answer).length > 0 ? { configuration: answer } : {}),
+            }),
           })
           yield* bus.publish(Integration.Event.ConnectionUpdated, { integrationID: input.integrationID })
           yield* bus.publish(Integration.Event.Updated, {})

+ 3 - 1
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)))

+ 8 - 7
packages/core/src/plugin/host.ts

@@ -190,6 +190,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
           integration.connection.key({
             integrationID: Integration.ID.make(input.integrationID),
             key: input.key,
+            answer: input.answer,
             label: input.label,
           }),
       },
@@ -199,7 +200,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
             integration.oauth.connect({
               integrationID: Integration.ID.make(input.integrationID),
               methodID: Integration.MethodID.make(input.methodID),
-              inputs: input.inputs,
+              answer: input.answer,
               label: input.label,
             }),
           ),
@@ -260,7 +261,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
             update: (id, update) => draft.update(Integration.ID.make(id), update),
             remove: (id) => draft.remove(Integration.ID.make(id)),
             method: {
-              list: (id) => mutable(draft.method.list(Integration.ID.make(id))),
+              list: (id) => draft.method.list(Integration.ID.make(id)),
               update: (input) => draft.method.update(methodImplementation(input)),
               remove: (id, method) =>
                 draft.method.remove(Integration.ID.make(id), Schema.decodeUnknownSync(Integration.Method)(method)),
@@ -363,8 +364,8 @@ function methodImplementation(input: IntegrationMethodRegistration): Integration
     return {
       integrationID: Integration.ID.make(input.integrationID),
       method: { ...input.method, id: Integration.MethodID.make(input.method.id) },
-      authorize: (inputs) =>
-        input.authorize(inputs).pipe(
+      authorize: (answer) =>
+        input.authorize(answer).pipe(
           Effect.map((authorization) => {
             if (authorization.mode === "auto") {
               return {
@@ -385,18 +386,18 @@ function methodImplementation(input: IntegrationMethodRegistration): Integration
   if (input.method.type === "env") {
     return {
       integrationID: Integration.ID.make(input.integrationID),
-      method: { type: "env", names: input.method.names },
+      method: input.method,
     }
   }
   if (input.method.type === "command") {
     return {
       integrationID: Integration.ID.make(input.integrationID),
-      method: Schema.decodeUnknownSync(Integration.CommandMethod)(input.method),
+      method: { ...input.method, id: Integration.MethodID.make(input.method.id) },
     }
   }
   return {
     integrationID: Integration.ID.make(input.integrationID),
-    method: { type: "key", label: input.method.label },
+    method: input.method,
   }
 }
 

+ 13 - 7
packages/core/src/plugin/promise.ts

@@ -180,8 +180,8 @@ export function fromPromise(plugin: Plugin) {
                         const refresh = input.refresh
                         draft.method.update({
                           ...input,
-                          authorize: (inputs) =>
-                            Effect.promise(() => input.authorize(inputs)).pipe(
+                          authorize: (answer) =>
+                            Effect.promise(() => input.authorize(answer)).pipe(
                               Effect.map((authorization) =>
                                 authorization.mode === "auto"
                                   ? {
@@ -362,11 +362,17 @@ type Wire<Value> = unknown extends Value
     ? Value
     : Value extends DateTime.DateTime
       ? number
-      : Value extends ReadonlyArray<infer Item>
-        ? Array<Wire<Item>>
-        : Value extends object
-          ? { -readonly [Key in keyof Value]: Wire<Value[Key]> }
-          : Value
+      : Value extends readonly [infer Head, ...infer Tail]
+        ? [Wire<Head>, ...WireTuple<Tail>]
+        : Value extends ReadonlyArray<infer Item>
+          ? Array<Wire<Item>>
+          : Value extends object
+            ? { -readonly [Key in keyof Value]: Wire<Value[Key]> }
+            : Value
+
+type WireTuple<Value extends ReadonlyArray<unknown>> = {
+  -readonly [Key in keyof Value]: Wire<Value[Key]>
+}
 
 function wire<Value>(value: Value): Wire<Value>
 function wire(value: unknown): unknown {

+ 26 - 0
packages/core/src/plugin/provider/azure.ts

@@ -1,6 +1,9 @@
 import { Effect } from "effect"
 import { define } from "@opencode-ai/plugin/effect/plugin"
+import { Form } from "@opencode-ai/schema/form"
 import { Provider } from "../../provider"
+import { iife } from "../../util/iife"
+import { configuredSettings } from "./configured"
 
 function selectLanguage(sdk: any, modelID: string, useChat: boolean) {
   if (useChat && sdk.chat) return sdk.chat(modelID)
@@ -13,6 +16,29 @@ 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)
+    const form = iife(() => {
+      if (resolveResourceName(configured) || typeof configured?.baseURL === "string") return
+      return Form.Fields.make([
+        {
+          type: "string",
+          key: "resourceName",
+          title: "Enter Azure Resource Name",
+          placeholder: "e.g. my-models",
+          required: true,
+        },
+      ])
+    })
+    yield* ctx.integration.transform((draft) => {
+      draft.method.update({
+        integrationID: Provider.ID.azure,
+        method: {
+          type: "key",
+          label: "API key",
+          form,
+        },
+      })
+    })
     yield* ctx.catalog.transform((evt) => {
       for (const item of evt.provider.list()) {
         if (item.provider.id !== Provider.ID.azure && Provider.packageName(item.provider.package) !== "@ai-sdk/azure")

+ 44 - 1
packages/core/src/plugin/provider/cloudflare-ai-gateway.ts

@@ -2,10 +2,53 @@ import os from "os"
 import { App } from "../../app"
 import { Effect, Option, Schema } from "effect"
 import { define } from "@opencode-ai/plugin/effect/plugin"
+import { Form } from "@opencode-ai/schema/form"
+import { Provider } from "../../provider"
+import { iife } from "../../util/iife"
+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 form = iife(() => {
+      if (typeof configured?.baseURL === "string") return
+      const accountId = process.env.CLOUDFLARE_ACCOUNT_ID || stringOption(configured ?? {}, "accountId")
+      const gatewayId =
+        process.env.CLOUDFLARE_GATEWAY_ID ||
+        stringOption(configured ?? {}, "gatewayId") ||
+        stringOption(configured ?? {}, "gateway")
+      if (accountId && gatewayId) return
+      const accountIdForm = Form.StringField.make({
+        type: "string",
+        key: "accountId",
+        title: "Enter your Cloudflare Account ID",
+        placeholder: "e.g. 1234567890abcdef1234567890abcdef",
+        required: true,
+      })
+      const gatewayIdForm = Form.StringField.make({
+        type: "string",
+        key: "gatewayId",
+        title: "Enter your Cloudflare AI Gateway ID",
+        placeholder: "e.g. my-gateway",
+        required: true,
+      })
+      if (accountId) return Form.Fields.make([gatewayIdForm])
+      if (gatewayId) return Form.Fields.make([accountIdForm])
+      return Form.Fields.make([accountIdForm, gatewayIdForm])
+    })
+    yield* ctx.integration.transform((draft) => {
+      draft.method.update({
+        integrationID: providerID,
+        method: {
+          type: "key",
+          label: "Gateway API token",
+          form,
+        },
+      })
+    })
     yield* ctx.aisdk.hook(
       "sdk",
       Effect.fn(function* (evt) {
@@ -46,7 +89,7 @@ const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
 
 function gatewayConfig(options: Record<string, unknown>): GatewayConfig | undefined {
   const accountId = process.env.CLOUDFLARE_ACCOUNT_ID ?? stringOption(options, "accountId")
-  // Credential projection copies key metadata into options. The prompt stores the
+  // Credential projection copies key metadata into options. The form stores the
   // gateway as gatewayId, while older config examples may use gateway.
   const gatewayId =
     process.env.CLOUDFLARE_GATEWAY_ID ?? stringOption(options, "gatewayId") ?? stringOption(options, "gateway")

+ 26 - 0
packages/core/src/plugin/provider/cloudflare-workers-ai.ts

@@ -2,13 +2,39 @@ import os from "os"
 import { App } from "../../app"
 import { Effect } from "effect"
 import { define } from "@opencode-ai/plugin/effect/plugin"
+import { Form } from "@opencode-ai/schema/form"
 import { Provider } from "../../provider"
+import { iife } from "../../util/iife"
+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)
+    const form = iife(() => {
+      if (typeof configured?.baseURL === "string" || resolveAccountId(configured ?? {})) return
+      return Form.Fields.make([
+        {
+          type: "string",
+          key: "accountId",
+          title: "Enter your Cloudflare Account ID",
+          placeholder: "e.g. 1234567890abcdef1234567890abcdef",
+          required: true,
+        },
+      ])
+    })
+    yield* ctx.integration.transform((draft) => {
+      draft.method.update({
+        integrationID: providerID,
+        method: {
+          type: "key",
+          label: "API key",
+          form,
+        },
+      })
+    })
     yield* ctx.catalog.transform((evt) => {
       const item = evt.provider.get(providerID)
       if (!item) return

+ 15 - 0
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)
+})

+ 54 - 44
packages/core/src/plugin/provider/github-copilot.ts

@@ -1,5 +1,4 @@
 import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/effect/integration"
-import { shouldUseResponsesApi } from "@opencode-ai/ai/providers/github-copilot"
 import { Effect, Option, Schema, Semaphore, Stream } from "effect"
 import { Catalog } from "../../catalog"
 import { Credential } from "../../credential"
@@ -47,30 +46,33 @@ const oauth = (app: App.Info) =>
       id: methodID,
       type: "oauth",
       label: "Login with GitHub Copilot",
-      prompts: [
+      form: [
         {
-          type: "select",
+          type: "string",
           key: "deploymentType",
-          message: "Select GitHub deployment type",
+          title: "Select GitHub deployment type",
+          required: true,
           options: [
-            { label: "GitHub.com", value: "github.com", hint: "Public" },
-            { label: "GitHub Enterprise", value: "enterprise", hint: "Data residency or self-hosted" },
+            { label: "GitHub.com", value: "github.com", description: "Public" },
+            { label: "GitHub Enterprise", value: "enterprise", description: "Data residency or self-hosted" },
           ],
         },
         {
-          type: "text",
+          type: "string",
           key: "enterpriseUrl",
-          message: "Enter your GitHub Enterprise URL or domain",
+          title: "Enter your GitHub Enterprise URL or domain",
           placeholder: "company.ghe.com or https://company.ghe.com",
-          when: { key: "deploymentType", op: "eq", value: "enterprise" },
+          required: true,
+          when: [{ key: "deploymentType", op: "eq", value: "enterprise" }],
         },
       ],
     },
-    authorize: (inputs) =>
+    authorize: (answer) =>
       Effect.gen(function* () {
-        const enterprise = inputs.deploymentType === "enterprise"
-        if (enterprise && !inputs.enterpriseUrl) return yield* Effect.fail(new Error("Enterprise URL is required"))
-        const domain = enterprise ? normalizeDomain(inputs.enterpriseUrl ?? "") : "github.com"
+        const enterprise = answer.deploymentType === "enterprise"
+        const enterpriseUrl = typeof answer.enterpriseUrl === "string" ? answer.enterpriseUrl : undefined
+        if (enterprise && !enterpriseUrl) return yield* Effect.fail(new Error("Enterprise URL is required"))
+        const domain = enterprise ? normalizeDomain(enterpriseUrl ?? "") : "github.com"
         const urls = oauthURLs(domain)
         const device = yield* request(urls.device, {
           method: "POST",
@@ -188,6 +190,7 @@ export const GithubCopilotPlugin = define({
     })
 
     yield* ctx.integration.transform((draft) => {
+      draft.method.remove("github-copilot", { type: "key" })
       draft.method.update(oauth(ctx.app))
     })
     yield* ctx.catalog.transform((evt) => {
@@ -226,26 +229,26 @@ export const GithubCopilotPlugin = define({
       "sdk",
       Effect.fn(function* (evt) {
         if (evt.model.providerID !== Provider.ID.githubCopilot) return
-        if (evt.package !== "@ai-sdk/github-copilot" && evt.package !== "@ai-sdk/anthropic") return
+        if (evt.package !== "@ai-sdk/github-copilot") return
         evt.options.fetch = copilotFetch(
           typeof evt.options.apiKey === "string" ? evt.options.apiKey : undefined,
           evt.options.fetch,
-          evt.package === "@ai-sdk/anthropic",
           ctx.app,
         )
-        if (evt.package === "@ai-sdk/anthropic") {
-          evt.options.headers = {
-            ...evt.options.headers,
-            "anthropic-beta": "interleaved-thinking-2025-05-14",
-          }
-          const mod = yield* Effect.promise(() => import("@ai-sdk/anthropic"))
-          evt.sdk = mod.createAnthropic(evt.options)
-          return
-        }
         const mod = yield* Effect.promise(() => import("../../github-copilot/copilot-provider"))
         evt.sdk = mod.createOpenaiCompatible(evt.options)
       }),
     )
+    yield* ctx.session.hook("http.request", (evt) =>
+      Effect.gen(function* () {
+        if (evt.model.providerID !== Provider.ID.githubCopilot) return
+        const token = evt.request.headers.get("x-api-key")
+        if (!token) return
+        const text = yield* Effect.promise(() => evt.request.clone().text())
+        const body = Option.getOrUndefined(decodeBody(text))
+        applyHeaders(evt.request.headers, token, ctx.app, requestMetadata(evt.request.url, body), true)
+      }),
+    )
     yield* ctx.aisdk.hook(
       "language",
       Effect.fn(function* (evt) {
@@ -263,7 +266,9 @@ export const GithubCopilotPlugin = define({
           return
         }
         const id = evt.model.modelID ?? evt.model.id
-        evt.language = shouldUseResponsesApi(id) ? evt.sdk.responses(id) : evt.sdk.chat(id)
+        const match = /^gpt-(\d+)/.exec(id)
+        evt.language =
+          match && Number(match[1]) >= 5 && !id.startsWith("gpt-5-mini") ? evt.sdk.responses(id) : evt.sdk.chat(id)
       }),
     )
   }),
@@ -312,34 +317,39 @@ function request(url: string, init: RequestInit) {
 
 type Fetch = (input: Parameters<typeof fetch>[0], init?: RequestInit) => Promise<Response>
 
-export function copilotFetch(
-  token: string | undefined,
-  upstream: Fetch | undefined,
-  anthropic: boolean,
-  app: App.Info,
-): Fetch {
+export function copilotFetch(token: string | undefined, upstream: Fetch | undefined, app: App.Info): Fetch {
   const send = upstream ?? fetch
   return async (input, init) => {
     const requestHeaders = new Headers(init?.headers)
-    if (token) {
-      requestHeaders.delete("authorization")
-      requestHeaders.delete("x-api-key")
-      requestHeaders.set("Authorization", `Bearer ${token}`)
-    }
-    requestHeaders.set("User-Agent", App.useragent(app))
-    requestHeaders.set("Openai-Intent", "conversation-edits")
-    requestHeaders.set("X-GitHub-Api-Version", apiVersion)
-    if (anthropic) requestHeaders.set("anthropic-beta", "interleaved-thinking-2025-05-14")
-
     const url = input instanceof URL ? input.href : typeof input === "string" ? input : input.url
     const body = typeof init?.body === "string" ? Option.getOrUndefined(decodeBody(init.body)) : undefined
-    const metadata = requestMetadata(url, body)
-    requestHeaders.set("x-initiator", metadata.agent ? "agent" : "user")
-    if (metadata.vision) requestHeaders.set("Copilot-Vision-Request", "true")
+    applyHeaders(requestHeaders, token, app, requestMetadata(url, body), false)
     return send(input, { ...init, headers: requestHeaders })
   }
 }
 
+function applyHeaders(
+  headers: Headers,
+  token: string | undefined,
+  app: App.Info,
+  metadata: RequestMetadata,
+  anthropic: boolean,
+) {
+  if (token) {
+    headers.delete("authorization")
+    headers.delete("x-api-key")
+    headers.set("Authorization", `Bearer ${token}`)
+  }
+  headers.set("User-Agent", App.useragent(app))
+  headers.set("Openai-Intent", "conversation-edits")
+  headers.set("X-GitHub-Api-Version", apiVersion)
+  headers.set("x-initiator", metadata.agent ? "agent" : "user")
+  if (metadata.vision) headers.set("Copilot-Vision-Request", "true")
+  if (anthropic) headers.set("anthropic-beta", "interleaved-thinking-2025-05-14")
+}
+
+type RequestMetadata = ReturnType<typeof requestMetadata>
+
 function requestMetadata(url: string, body: unknown) {
   if (!record(body)) return { agent: false, vision: false }
   if (Array.isArray(body.input)) {

+ 4 - 3
packages/core/src/plugin/provider/opencode.ts

@@ -43,9 +43,9 @@ function oauth(http: HttpClient.HttpClient) {
       type: "oauth",
       label: "OpenCode Console account",
     },
-    authorize: (inputs) =>
+    authorize: (answer) =>
       Effect.gen(function* () {
-        const server = yield* normalizeServer(inputs.server ?? defaultServer)
+        const server = yield* normalizeServer(answer.server ?? defaultServer)
         const device = yield* post(http, `${server}/auth/device/code`, { client_id: clientID }, Device)
         const verification = URL.canParse(device.verification_uri_complete)
           ? new URL(device.verification_uri_complete)
@@ -226,9 +226,10 @@ function withoutCredentials(body: Readonly<Record<string, unknown>> | undefined)
   return Object.fromEntries(Object.entries(body ?? {}).filter(([key]) => key !== "apiKey" && key !== "headers"))
 }
 
-function normalizeServer(input: string) {
+function normalizeServer(input: unknown) {
   return Effect.try({
     try: () => {
+      if (typeof input !== "string") throw new Error("expected string")
       const url = new URL(input)
       if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error("expected HTTP(S)")
       return `${url.origin}${url.pathname.replace(/\/+$/, "")}`

+ 203 - 9
packages/core/test/github-copilot/openai-responses-language-model.test.ts

@@ -1,7 +1,7 @@
 import { OpenAIResponsesLanguageModel } from "@opencode-ai/core/github-copilot/responses/openai-responses-language-model"
 import { convertToOpenAIResponsesInput } from "@opencode-ai/core/github-copilot/responses/convert-to-openai-responses-input"
 import { describe, test, expect, mock } from "bun:test"
-import type { LanguageModelV3Prompt } from "@ai-sdk/provider"
+import type { LanguageModelV3Prompt, LanguageModelV3StreamPart } from "@ai-sdk/provider"
 
 const TEST_PROMPT: LanguageModelV3Prompt = [{ role: "user", content: [{ type: "text", text: "Hello" }] }]
 
@@ -11,6 +11,16 @@ function createMockFetch(body: unknown) {
   )
 }
 
+function createStreamFetch(events: ReadonlyArray<Record<string, unknown>>) {
+  return mock(
+    async () =>
+      new Response(events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""), {
+        status: 200,
+        headers: { "Content-Type": "text/event-stream" },
+      }),
+  )
+}
+
 function createModel(fetchFn: ReturnType<typeof mock>) {
   return new OpenAIResponsesLanguageModel("test-model", {
     provider: "copilot",
@@ -77,10 +87,173 @@ describe("doGenerate", () => {
     expect(providerMetadata?.copilot?.responseId).toBe("resp_1")
     expect(providerMetadata?.openai).toBeUndefined()
   })
+
+  test("defaults to stateless encrypted reasoning and keeps previousResponseId opt-in", async () => {
+    const requests: Array<Record<string, unknown>> = []
+    const fetchFn = mock(async (_input: Parameters<typeof fetch>[0], init?: RequestInit) => {
+      requests.push(JSON.parse(init?.body as string))
+      return new Response(
+        JSON.stringify({
+          id: "resp_1",
+          created_at: 0,
+          model: "gpt-5.5",
+          output: [],
+          usage: { input_tokens: 1, output_tokens: 1 },
+        }),
+        { status: 200, headers: { "Content-Type": "application/json" } },
+      )
+    })
+    const model = createModel(fetchFn)
+
+    await model.doGenerate({ prompt: TEST_PROMPT, includeRawChunks: false } as any)
+    await model.doGenerate({
+      prompt: TEST_PROMPT,
+      includeRawChunks: false,
+      providerOptions: { copilot: { previousResponseId: "resp_previous", store: false } },
+    } as any)
+    await model.doGenerate({
+      prompt: TEST_PROMPT,
+      includeRawChunks: false,
+      providerOptions: { copilot: { store: true } },
+    } as any)
+
+    expect(requests[0]?.previous_response_id).toBeUndefined()
+    expect(requests[0]?.store).toBe(false)
+    expect(requests[0]?.include).toEqual(["reasoning.encrypted_content"])
+    expect(requests[1]?.previous_response_id).toBe("resp_previous")
+    expect(requests[1]?.store).toBe(false)
+    expect(requests[1]?.include).toEqual(["reasoning.encrypted_content"])
+    expect(requests[2]?.store).toBe(true)
+    expect(requests[2]?.include).toEqual(["reasoning.encrypted_content"])
+  })
+})
+
+describe("doStream", () => {
+  test("streams sequential Copilot reasoning summary blocks", async () => {
+    const model = createModel(
+      createStreamFetch([
+        {
+          type: "response.output_item.added",
+          output_index: 0,
+          item: { type: "reasoning", id: "rs_1", encrypted_content: null },
+        },
+        {
+          type: "response.output_item.added",
+          output_index: 0,
+          item: { type: "reasoning", id: "rs_rotated", encrypted_content: null },
+        },
+        { type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 0 },
+        { type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 0, delta: "First" },
+        { type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 0 },
+        { type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 1 },
+        { type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 1 },
+        { type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 1, delta: "Second" },
+        { type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 1 },
+        {
+          type: "response.output_item.done",
+          output_index: 0,
+          item: { type: "reasoning", id: "rs_rotated", encrypted_content: "encrypted-state" },
+        },
+      ]),
+    )
+    const result = await model.doStream({
+      prompt: TEST_PROMPT,
+      includeRawChunks: false,
+      providerOptions: { copilot: { store: false } },
+    } as any)
+    const reader = result.stream.getReader()
+    const events: LanguageModelV3StreamPart[] = []
+    while (true) {
+      const item = await reader.read()
+      if (item.done) break
+      if (item.value.type.startsWith("reasoning-")) events.push(item.value)
+    }
+
+    expect(events).toMatchObject([
+      {
+        type: "reasoning-start",
+        id: "rs_1:0",
+        providerMetadata: { copilot: { itemId: "rs_1", reasoningEncryptedContent: null } },
+      },
+      { type: "reasoning-delta", id: "rs_1:0", delta: "First" },
+      { type: "reasoning-end", id: "rs_1:0", providerMetadata: { copilot: { itemId: "rs_1" } } },
+      {
+        type: "reasoning-start",
+        id: "rs_1:1",
+        providerMetadata: { copilot: { itemId: "rs_1", reasoningEncryptedContent: null } },
+      },
+      { type: "reasoning-delta", id: "rs_1:1", delta: "Second" },
+      {
+        type: "reasoning-end",
+        id: "rs_1:1",
+        providerMetadata: { copilot: { itemId: "rs_rotated", reasoningEncryptedContent: "encrypted-state" } },
+      },
+    ])
+
+    const deltas = new Map(
+      events.filter((event) => event.type === "reasoning-delta").map((event) => [event.id, event.delta] as const),
+    )
+    const { input } = await convertToOpenAIResponsesInput({
+      prompt: [
+        {
+          role: "assistant",
+          content: events
+            .filter((event) => event.type === "reasoning-end")
+            .map((event) => ({
+              type: "reasoning" as const,
+              text: deltas.get(event.id) ?? "",
+              providerOptions: event.providerMetadata,
+            })),
+        },
+      ],
+      systemMessageMode: "system",
+      store: false,
+    })
+    expect(input).toEqual([
+      {
+        type: "reasoning",
+        id: "rs_rotated",
+        encrypted_content: "encrypted-state",
+        summary: [],
+      },
+    ])
+  })
+
+  test("closes reasoning when a Copilot stream ends before output_item.done", async () => {
+    const model = createModel(
+      createStreamFetch([
+        {
+          type: "response.output_item.added",
+          output_index: 0,
+          item: { type: "reasoning", id: "rs_1", encrypted_content: null },
+        },
+        { type: "response.reasoning_summary_text.delta", item_id: "rs_rotated", summary_index: 0, delta: "First" },
+      ]),
+    )
+    const result = await model.doStream({
+      prompt: TEST_PROMPT,
+      includeRawChunks: false,
+      providerOptions: { copilot: { store: false } },
+    } as any)
+    const reader = result.stream.getReader()
+    const events: LanguageModelV3StreamPart[] = []
+    while (true) {
+      const item = await reader.read()
+      if (item.done) break
+      if (item.value.type.startsWith("reasoning-")) events.push(item.value)
+    }
+
+    expect(events.map((event) => event.type)).toEqual(["reasoning-start", "reasoning-delta", "reasoning-end"])
+    expect(events.at(-1)).toMatchObject({
+      type: "reasoning-end",
+      id: "rs_1:0",
+      providerMetadata: { copilot: { itemId: "rs_1" } },
+    })
+  })
 })
 
 describe("convertToOpenAIResponsesInput", () => {
-  test("echoes a stale tool-call itemId from the copilot namespace as the function_call id", async () => {
+  test("omits response item IDs from stateless function calls", async () => {
     const { input } = await convertToOpenAIResponsesInput({
       prompt: [
         {
@@ -106,12 +279,11 @@ describe("convertToOpenAIResponsesInput", () => {
         call_id: "call_1",
         name: "bash",
         arguments: JSON.stringify({ command: "ls" }),
-        id: "fc_999",
       },
     ])
   })
 
-  test("omits the function_call id once the stale copilot itemId has been stripped", async () => {
+  test("preserves response item IDs for stored function calls", async () => {
     const { input } = await convertToOpenAIResponsesInput({
       prompt: [
         {
@@ -122,16 +294,16 @@ describe("convertToOpenAIResponsesInput", () => {
               toolCallId: "call_1",
               toolName: "bash",
               input: { command: "ls" },
-              providerOptions: {},
+              providerOptions: { copilot: { itemId: "fc_999" } },
             },
           ],
         },
       ],
       systemMessageMode: "system",
-      store: false,
+      store: true,
     })
 
-    expect((input[0] as any).id).toBeUndefined()
+    expect((input[0] as any).id).toBe("fc_999")
   })
 
   test("preserves reasoning items keyed by the copilot namespace instead of dropping them", async () => {
@@ -158,12 +330,34 @@ describe("convertToOpenAIResponsesInput", () => {
         type: "reasoning",
         id: "rs_1",
         encrypted_content: "enc_1",
-        summary: [{ type: "summary_text", text: "thinking..." }],
+        summary: [],
       },
     ])
   })
 
-  test("drops reasoning items with no copilot itemId and warns, as before", async () => {
+  test("drops encrypted reasoning with no completed copilot itemId", async () => {
+    const { input, warnings } = await convertToOpenAIResponsesInput({
+      prompt: [
+        {
+          role: "assistant",
+          content: [
+            {
+              type: "reasoning",
+              text: "thinking...",
+              providerOptions: { copilot: { reasoningEncryptedContent: "enc_1" } },
+            },
+          ],
+        },
+      ],
+      systemMessageMode: "system",
+      store: false,
+    })
+
+    expect(input).toEqual([])
+    expect(warnings).toHaveLength(1)
+  })
+
+  test("drops reasoning with neither a copilot itemId nor encrypted content", async () => {
     const { input, warnings } = await convertToOpenAIResponsesInput({
       prompt: [
         {

+ 19 - 8
packages/core/test/integration.test.ts

@@ -140,7 +140,11 @@ describe("Integration", () => {
       yield* integrations.transform((editor) =>
         editor.method.update({
           integrationID,
-          method: { type: "key", label: "API key" },
+          method: {
+            type: "key",
+            label: "API key",
+            form: [{ type: "string", key: "accountId", title: "Account ID", required: true }],
+          },
         }),
       )
       const updated = yield* bus
@@ -148,9 +152,17 @@ describe("Integration", () => {
         .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
       yield* Effect.yieldNow
 
+      expect(
+        yield* integrations.connection.key({ integrationID, key: "secret" }).pipe(
+          Effect.flip,
+          Effect.map((error) => error.cause),
+        ),
+      ).toEqual(expect.objectContaining({ message: "Missing required form field: accountId" }))
+
       yield* integrations.connection.key({
         integrationID,
         key: "secret",
+        answer: { accountId: "account" },
         label: "Work",
       })
 
@@ -158,7 +170,7 @@ describe("Integration", () => {
         expect.objectContaining({
           integrationID,
           label: "Work",
-          value: Credential.Key.make({ type: "key", key: "secret" }),
+          value: Credential.Key.make({ type: "key", key: "secret", configuration: { accountId: "account" } }),
         }),
       ])
       expect((yield* Fiber.join(updated)).length).toBe(1)
@@ -243,7 +255,6 @@ describe("Integration", () => {
       const attempt = yield* integrations.oauth.connect({
         integrationID,
         methodID,
-        inputs: {},
         label: "Personal",
       })
       expect(attempt.mode).toBe("code")
@@ -289,7 +300,7 @@ describe("Integration", () => {
         }),
       )
 
-      const attempt = yield* integrations.oauth.connect({ integrationID, methodID, inputs: {} })
+      const attempt = yield* integrations.oauth.connect({ integrationID, methodID })
       expect(
         yield* integrations.oauth.complete({ integrationID, attemptID: attempt.attemptID }).pipe(Effect.flip),
       ).toBeInstanceOf(Integration.CodeRequiredError)
@@ -327,7 +338,7 @@ describe("Integration", () => {
         }),
       )
 
-      const attempt = yield* integrations.oauth.connect({ integrationID, methodID, inputs: {} })
+      const attempt = yield* integrations.oauth.connect({ integrationID, methodID })
       yield* Effect.yieldNow
       expect(yield* integrations.oauth.status({ integrationID, attemptID: attempt.attemptID })).toEqual({
         status: "complete",
@@ -365,7 +376,7 @@ describe("Integration", () => {
         }),
       )
 
-      const attempt = yield* integrations.oauth.connect({ integrationID, methodID, inputs: {} })
+      const attempt = yield* integrations.oauth.connect({ integrationID, methodID })
       const exit = yield* integrations.oauth
         .complete({ integrationID, attemptID: attempt.attemptID, code: "1234" })
         .pipe(Effect.exit)
@@ -401,7 +412,7 @@ describe("Integration", () => {
         }),
       )
 
-      const attempt = yield* integrations.oauth.connect({ integrationID, methodID, inputs: {} })
+      const attempt = yield* integrations.oauth.connect({ integrationID, methodID })
       expect(attempt.time.expires - attempt.time.created).toBe(Duration.toMillis(Duration.minutes(10)))
       yield* TestClock.adjust(Duration.minutes(10))
       yield* Effect.yieldNow
@@ -442,7 +453,7 @@ describe("Integration", () => {
             }),
           )
 
-          const attempt = yield* integrations.oauth.connect({ integrationID, methodID, inputs: {} })
+          const attempt = yield* integrations.oauth.connect({ integrationID, methodID })
           expect(attempt.time).toEqual({ created, expires: expiresAt })
         })
       })

+ 6 - 2
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 },
               })

+ 9 - 35
packages/core/test/plugin/host.ts

@@ -1,5 +1,5 @@
 import { Plugin } from "@opencode-ai/plugin/effect"
-import type { IntegrationMethodRegistration } from "@opencode-ai/plugin/effect/integration"
+import type { IntegrationMethod, IntegrationMethodRegistration } from "@opencode-ai/plugin/effect/integration"
 import { Agent } from "@opencode-ai/core/agent"
 import { Catalog } from "@opencode-ai/core/catalog"
 import { Credential } from "@opencode-ai/core/credential"
@@ -15,7 +15,6 @@ import { Effect, Stream } from "effect"
 type Overrides = Partial<Omit<Plugin.Context, "options" | "session">> & {
   readonly session?: Partial<Plugin.Context["session"]>
 }
-
 export function host(overrides: Overrides = {}): Plugin.Context {
   return {
     app: overrides.app ?? { name: "test", version: "test", channel: "test" },
@@ -278,7 +277,7 @@ export function integrationHost(integration: Integration.Interface): Plugin.Cont
           update: (id, update) => draft.update(Integration.ID.make(id), update),
           remove: (id) => draft.remove(Integration.ID.make(id)),
           method: {
-            list: (id) => draft.method.list(Integration.ID.make(id)).map(method),
+            list: (id) => draft.method.list(Integration.ID.make(id)),
             update: (input) => {
               if ("authorize" in input) {
                 const methodID = Integration.MethodID.make(input.method.id)
@@ -286,8 +285,8 @@ export function integrationHost(integration: Integration.Interface): Plugin.Cont
                 draft.method.update({
                   integrationID: Integration.ID.make(input.integrationID),
                   method: { ...input.method, id: methodID },
-                  authorize: (inputs) =>
-                    input.authorize(inputs).pipe(
+                  authorize: (answer) =>
+                    input.authorize(answer).pipe(
                       Effect.map((authorization) => {
                         if (authorization.mode === "auto") {
                           return {
@@ -336,7 +335,7 @@ export function integrationHost(integration: Integration.Interface): Plugin.Cont
               if (input.method.type === "env") {
                 draft.method.update({
                   integrationID: Integration.ID.make(input.integrationID),
-                  method: { ...input.method, names: [...input.method.names] },
+                  method: input.method,
                 })
                 return
               }
@@ -346,7 +345,6 @@ export function integrationHost(integration: Integration.Interface): Plugin.Cont
                   method: {
                     ...input.method,
                     id: Integration.MethodID.make(input.method.id),
-                    command: [...input.method.command],
                   },
                 })
                 return
@@ -401,35 +399,11 @@ function oauthCredential(value: Credential.OAuth) {
   return Credential.OAuth.make({ ...value, methodID: Integration.MethodID.make(value.methodID) })
 }
 
-function method(value: Integration.Method) {
-  if (value.type === "env") return { type: value.type, names: [...value.names] }
-  if (value.type === "key") return { type: value.type, label: value.label }
-  if (value.type === "command") return { ...value, command: [...value.command] }
-  return {
-    type: value.type,
-    id: value.id,
-    label: value.label,
-    prompts: value.prompts?.map((prompt) => {
-      if (prompt.type === "text") return { ...prompt }
-      return { ...prompt, options: prompt.options.map((option) => ({ ...option })) }
-    }),
-  }
-}
-
-function internalMethod(value: IntegrationMethodRegistration["method"]): Integration.Method {
-  if (value.type === "env") return value
-  if (value.type === "key") return value
-  if (value.type === "command") {
-    return {
-      ...value,
-      id: Integration.MethodID.make(value.id),
-      command: [...value.command],
-    }
-  }
-  return {
-    ...value,
-    id: Integration.MethodID.make(value.id),
+function internalMethod(value: IntegrationMethod): Integration.Method {
+  if (value.type === "oauth" || value.type === "command") {
+    return { ...value, id: Integration.MethodID.make(value.id) }
   }
+  return value
 }
 
 function agentInfo(value: Agent.Info) {

+ 32 - 0
packages/core/test/plugin/provider-azure.test.ts

@@ -8,6 +8,7 @@ import { Plugin } from "@opencode-ai/core/plugin"
 import { PluginHost } from "@opencode-ai/core/plugin/host"
 import { AzurePlugin } from "@opencode-ai/core/plugin/provider/azure"
 import { Provider } from "@opencode-ai/core/provider"
+import { Integration } from "@opencode-ai/core/integration"
 import { testEffect } from "../lib/effect"
 import { PluginTestLayer } from "./fixture"
 
@@ -60,6 +61,27 @@ function fakeSelectorSdk(calls: string[]) {
 }
 
 describe("AzurePlugin", () => {
+  it.effect("registers a resource name form when the environment does not provide one", () =>
+    withEnv({ AZURE_RESOURCE_NAME: undefined, AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: undefined }, () =>
+      Effect.gen(function* () {
+        yield* addPlugin()
+        expect((yield* (yield* Integration.Service).get(Integration.ID.make("azure")))?.methods).toContainEqual({
+          type: "key",
+          label: "API key",
+          form: [
+            {
+              type: "string",
+              key: "resourceName",
+              title: "Enter Azure Resource Name",
+              placeholder: "e.g. my-models",
+              required: true,
+            },
+          ],
+        })
+      }),
+    ),
+  )
+
   it.effect("resolves resourceName from env", () =>
     withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () =>
       Effect.gen(function* () {
@@ -195,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")),

+ 29 - 0
packages/core/test/plugin/provider-cloudflare-ai-gateway.test.ts

@@ -1,11 +1,13 @@
 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"
 import { CloudflareAIGatewayPlugin } from "@opencode-ai/core/plugin/provider/cloudflare-ai-gateway"
 import { Provider } from "@opencode-ai/core/provider"
+import { Integration } from "@opencode-ai/core/integration"
 import { testEffect } from "../lib/effect"
 import { PluginTestLayer } from "./fixture"
 
@@ -102,6 +104,24 @@ mock.module("ai-gateway-provider/providers/unified", () => ({
 }))
 
 describe("CloudflareAIGatewayPlugin", () => {
+  it.effect("registers account and gateway forms when the environment does not provide them", () =>
+    withEnv({ CLOUDFLARE_ACCOUNT_ID: undefined, CLOUDFLARE_GATEWAY_ID: undefined }, () =>
+      Effect.gen(function* () {
+        yield* addPlugin()
+        expect(
+          (yield* (yield* Integration.Service).get(Integration.ID.make("cloudflare-ai-gateway")))?.methods,
+        ).toContainEqual({
+          type: "key",
+          label: "Gateway API token",
+          form: [
+            expect.objectContaining({ type: "string", key: "accountId", required: true }),
+            expect.objectContaining({ type: "string", key: "gatewayId", required: true }),
+          ],
+        })
+      }),
+    ),
+  )
+
   it.effect("requires account, gateway, and token before creating the unified SDK", () =>
     withEnv(
       {
@@ -357,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({

+ 36 - 0
packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts

@@ -7,6 +7,7 @@ import { Plugin } from "@opencode-ai/core/plugin"
 import { PluginHost } from "@opencode-ai/core/plugin/host"
 import { CloudflareWorkersAIPlugin } from "@opencode-ai/core/plugin/provider/cloudflare-workers-ai"
 import { Provider } from "@opencode-ai/core/provider"
+import { Integration } from "@opencode-ai/core/integration"
 import type { LanguageModelV3 } from "@ai-sdk/provider"
 import { testEffect } from "../lib/effect"
 import { PluginTestLayer } from "./fixture"
@@ -79,6 +80,29 @@ function cloudflareHeaders(sdk: unknown, modelID = "@cf/model") {
 }
 
 describe("CloudflareWorkersAIPlugin", () => {
+  it.effect("registers an account form when the environment does not provide one", () =>
+    withEnv({ CLOUDFLARE_ACCOUNT_ID: undefined }, () =>
+      Effect.gen(function* () {
+        yield* addPlugin()
+        expect(
+          (yield* (yield* Integration.Service).get(Integration.ID.make("cloudflare-workers-ai")))?.methods,
+        ).toContainEqual({
+          type: "key",
+          label: "API key",
+          form: [
+            {
+              type: "string",
+              key: "accountId",
+              title: "Enter your Cloudflare Account ID",
+              placeholder: "e.g. 1234567890abcdef1234567890abcdef",
+              required: true,
+            },
+          ],
+        })
+      }),
+    ),
+  )
+
   it.effect("maps account ID to endpoint URL and creates an OpenAI-compatible SDK", () =>
     withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () =>
       Effect.gen(function* () {
@@ -91,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({
@@ -135,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")),

+ 51 - 2
packages/core/test/plugin/provider-github-copilot.test.ts

@@ -1,11 +1,14 @@
 import { AISDK } from "@opencode-ai/core/aisdk"
 import { App } from "@opencode-ai/core/app"
+import { Agent } from "@opencode-ai/schema/agent"
+import { Session } from "@opencode-ai/schema/session"
 import { describe, expect, test } 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"
+import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
 import { copilotBaseURL, copilotFetch, GithubCopilotPlugin } from "@opencode-ai/core/plugin/provider/github-copilot"
 import { Provider } from "@opencode-ai/core/provider"
 import { Integration } from "@opencode-ai/core/integration"
@@ -57,11 +60,37 @@ describe("GithubCopilotPlugin", () => {
         id: Integration.MethodID.make("device"),
         type: "oauth",
         label: "Login with GitHub Copilot",
-        prompts: expect.any(Array),
+        form: expect.any(Array),
       })
     }),
   )
 
+  it.effect("removes the generic key method", () =>
+    Effect.gen(function* () {
+      const integrations = yield* Integration.Service
+      yield* integrations.transform((draft) => {
+        draft.method.update({
+          integrationID: Integration.ID.make("github-copilot"),
+          method: { type: "key" },
+        })
+        draft.method.update({
+          integrationID: Integration.ID.make("github-copilot"),
+          method: { type: "env", names: ["GITHUB_TOKEN"] },
+        })
+      })
+      yield* addPlugin()
+      expect((yield* integrations.get(Integration.ID.make("github-copilot")))?.methods).toEqual([
+        { type: "env", names: ["GITHUB_TOKEN"] },
+        {
+          id: Integration.MethodID.make("device"),
+          type: "oauth",
+          label: "Login with GitHub Copilot",
+          form: expect.any(Array),
+        },
+      ])
+    }),
+  )
+
   it.live("adds Copilot authentication and request metadata headers", () =>
     Effect.gen(function* () {
       const requests: Headers[] = []
@@ -71,7 +100,6 @@ describe("GithubCopilotPlugin", () => {
           requests.push(new Headers(init?.headers))
           return Response.json({ ok: true })
         },
-        false,
         App.make({ name: "test", version: "1.2.3", channel: "beta" }),
       )
       yield* Effect.promise(() =>
@@ -92,6 +120,27 @@ describe("GithubCopilotPlugin", () => {
     }),
   )
 
+  it.effect("adds Copilot authentication to native Anthropic requests", () =>
+    Effect.gen(function* () {
+      yield* addPlugin()
+      const event = yield* (yield* PluginHooks.Service).trigger("session", "http.request", {
+        sessionID: Session.ID.make("ses_test"),
+        agent: Agent.ID.make("build"),
+        model: Model.Ref.make({ providerID: Provider.ID.githubCopilot, id: Model.ID.make("claude-sonnet-4.5") }),
+        request: new Request("https://api.githubcopilot.com/v1/messages", {
+          method: "POST",
+          headers: { "Content-Type": "application/json", "x-api-key": "token" },
+          body: JSON.stringify({ messages: [{ role: "user", content: [{ type: "text", text: "hello" }] }] }),
+        }),
+      })
+      expect(event.request.headers.get("authorization")).toBe("Bearer token")
+      expect(event.request.headers.has("x-api-key")).toBe(false)
+      expect(event.request.headers.get("x-initiator")).toBe("user")
+      expect(event.request.headers.get("anthropic-beta")).toBe("interleaved-thinking-2025-05-14")
+      expect(event.request.headers.get("x-github-api-version")).toBe("2026-06-01")
+    }),
+  )
+
   it.effect("creates the bundled Copilot SDK for the GitHub Copilot package", () =>
     Effect.gen(function* () {
       const plugin = yield* Plugin.Service

+ 17 - 2
packages/core/test/plugin/provider-opencode.test.ts

@@ -128,7 +128,7 @@ describe("OpencodePlugin", () => {
           const attempt = yield* integrations.oauth.connect({
             integrationID,
             methodID: Integration.MethodID.make("device"),
-            inputs: { server: `${server.url.origin}/console///?ignored=true#ignored` },
+            answer: { server: `${server.url.origin}/console///?ignored=true#ignored` },
           })
           expect(attempt.url).toBe(`${server.url.origin}/verify`)
           yield* eventually(
@@ -155,7 +155,7 @@ describe("OpencodePlugin", () => {
         .connect({
           integrationID: Integration.ID.make("opencode"),
           methodID: Integration.MethodID.make("device"),
-          inputs: { server: "ftp://console.example.com" },
+          answer: { server: "ftp://console.example.com" },
         })
         .pipe(Effect.flip)
       expect(error).toBeInstanceOf(Integration.AuthorizationError)
@@ -163,6 +163,21 @@ describe("OpencodePlugin", () => {
     }),
   )
 
+  it.effect("rejects non-string OpenCode servers", () =>
+    Effect.gen(function* () {
+      yield* addPlugin()
+      const error = yield* (yield* Integration.Service).oauth
+        .connect({
+          integrationID: Integration.ID.make("opencode"),
+          methodID: Integration.MethodID.make("device"),
+          answer: { server: true },
+        })
+        .pipe(Effect.flip)
+      expect(error).toBeInstanceOf(Integration.AuthorizationError)
+      expect(String(error.cause)).toContain("Invalid OpenCode server URL: expected string")
+    }),
+  )
+
   it.live("loads providers and models from the connected OpenCode server", () =>
     Effect.acquireUseRelease(
       Effect.sync(() => {

+ 4 - 1
packages/core/test/plugin/websearch.test.ts

@@ -129,7 +129,10 @@ describe("built-in web search providers", () => {
       yield* WebSearchParallel.Plugin.effect(
         host({ integration: integrationHost(integrations), websearch: webSearchHost(websearch) }),
       )
-      yield* integrations.connection.key({ integrationID: Integration.ID.make("parallel"), key: "parallel-secret" })
+      yield* integrations.connection.key({
+        integrationID: Integration.ID.make("parallel"),
+        key: "parallel-secret",
+      })
 
       const output = yield* websearch.query({
         query: "effect layers",

+ 0 - 5
packages/core/test/shared-schema.test.ts

@@ -90,15 +90,10 @@ test("Core reuses the canonical shared schemas", async () => {
     [coreFileSystem.Match, FileSystem.Match],
     [coreIntegration.ID, Integration.ID],
     [coreIntegration.MethodID, Integration.MethodID],
-    [coreIntegration.When, Integration.When],
-    [coreIntegration.TextPrompt, Integration.TextPrompt],
-    [coreIntegration.SelectPrompt, Integration.SelectPrompt],
-    [coreIntegration.Prompt, Integration.Prompt],
     [coreIntegration.OAuthMethod, Integration.OAuthMethod],
     [coreIntegration.KeyMethod, Integration.KeyMethod],
     [coreIntegration.EnvMethod, Integration.EnvMethod],
     [coreIntegration.Method, Integration.Method],
-    [coreIntegration.Inputs, Integration.Inputs],
     [coreIntegration.Ref, Integration.Ref],
     [coreLocation.Ref, Location.Ref],
     [coreAI.ProviderMetadata, AI.ProviderMetadata],

+ 34 - 10
packages/plugin/src/effect/integration.ts

@@ -1,19 +1,43 @@
-import type {
-  ConnectionInfo,
-  IntegrationCommandMethod,
-  IntegrationEnvMethod,
-  IntegrationKeyMethod,
-  IntegrationMethod,
-  IntegrationOAuthMethod,
-} from "@opencode-ai/client"
+import type { ConnectionInfo } from "@opencode-ai/client"
 import type { IntegrationApi } from "@opencode-ai/client/effect/api"
 import { Credential } from "@opencode-ai/schema/credential"
+import { Form } from "@opencode-ai/schema/form"
 import type { Effect, Scope } from "effect"
 import type { Transform } from "./registration.js"
 
-type IntegrationInputs = Record<string, string>
 type IntegrationRef = { id: string; name: string }
 
+export interface IntegrationOAuthMethod {
+  readonly id: string
+  readonly type: "oauth"
+  readonly label: string
+  readonly form?: Form.Fields
+}
+
+export interface IntegrationCommandMethod {
+  readonly id: string
+  readonly type: "command"
+  readonly label: string
+  readonly command: ReadonlyArray<string>
+}
+
+export interface IntegrationKeyMethod {
+  readonly type: "key"
+  readonly label?: string
+  readonly form?: Form.Fields
+}
+
+export interface IntegrationEnvMethod {
+  readonly type: "env"
+  readonly names: ReadonlyArray<string>
+}
+
+export type IntegrationMethod =
+  | IntegrationOAuthMethod
+  | IntegrationCommandMethod
+  | IntegrationKeyMethod
+  | IntegrationEnvMethod
+
 export type IntegrationOAuthAuthorization = {
   readonly url: string
   readonly instructions: string
@@ -31,7 +55,7 @@ export type IntegrationOAuthAuthorization = {
 export type IntegrationOAuthMethodRegistration = {
   readonly integrationID: string
   readonly method: IntegrationOAuthMethod
-  readonly authorize: (inputs: IntegrationInputs) => Effect.Effect<IntegrationOAuthAuthorization, unknown, Scope.Scope>
+  readonly authorize: (answer: Form.Answer) => Effect.Effect<IntegrationOAuthAuthorization, unknown, Scope.Scope>
   readonly refresh?: (credential: Credential.OAuth) => Effect.Effect<Credential.OAuth, unknown>
   readonly label?: (credential: Credential.OAuth) => string | undefined
 }

+ 38 - 11
packages/plugin/src/promise/integration.ts

@@ -1,18 +1,42 @@
-import type {
-  ConnectionInfo,
-  IntegrationCommandMethod,
-  IntegrationEnvMethod,
-  IntegrationKeyMethod,
-  IntegrationMethod,
-  IntegrationOAuthMethod,
-} from "@opencode-ai/client"
+import type { ConnectionInfo } from "@opencode-ai/client"
 import type { IntegrationApi } from "@opencode-ai/client/promise/api"
 import { Credential } from "@opencode-ai/schema/credential"
+import { Form } from "@opencode-ai/schema/form"
 import type { Transform } from "./registration.js"
 
-type IntegrationInputs = Record<string, string>
 type IntegrationRef = { id: string; name: string }
 
+export interface IntegrationOAuthMethod {
+  readonly id: string
+  readonly type: "oauth"
+  readonly label: string
+  readonly form?: Form.Fields
+}
+
+export interface IntegrationCommandMethod {
+  readonly id: string
+  readonly type: "command"
+  readonly label: string
+  readonly command: ReadonlyArray<string>
+}
+
+export interface IntegrationKeyMethod {
+  readonly type: "key"
+  readonly label?: string
+  readonly form?: Form.Fields
+}
+
+export interface IntegrationEnvMethod {
+  readonly type: "env"
+  readonly names: ReadonlyArray<string>
+}
+
+export type IntegrationMethod =
+  | IntegrationOAuthMethod
+  | IntegrationCommandMethod
+  | IntegrationKeyMethod
+  | IntegrationEnvMethod
+
 export type IntegrationOAuthAuthorization = {
   readonly url: string
   readonly instructions: string
@@ -31,7 +55,7 @@ export type IntegrationOAuthAuthorization = {
 export type IntegrationOAuthMethodRegistration = {
   readonly integrationID: string
   readonly method: IntegrationOAuthMethod
-  readonly authorize: (inputs: IntegrationInputs) => Promise<IntegrationOAuthAuthorization>
+  readonly authorize: (answer: Form.Answer) => Promise<IntegrationOAuthAuthorization>
   readonly refresh?: (credential: Credential.OAuth) => Promise<Credential.OAuth>
   readonly label?: (credential: Credential.OAuth) => string | undefined
 }
@@ -39,7 +63,10 @@ export type IntegrationOAuthMethodRegistration = {
 export type IntegrationMethodRegistration =
   | IntegrationOAuthMethodRegistration
   | { readonly integrationID: string; readonly method: IntegrationCommandMethod }
-  | { readonly integrationID: string; readonly method: IntegrationKeyMethod }
+  | {
+      readonly integrationID: string
+      readonly method: IntegrationKeyMethod
+    }
   | { readonly integrationID: string; readonly method: IntegrationEnvMethod }
 
 export interface IntegrationDraft {

+ 3 - 3
packages/protocol/src/groups/integration.ts

@@ -1,12 +1,11 @@
 import { Integration } from "@opencode-ai/schema/integration"
 import { Location } from "@opencode-ai/schema/location"
+import { Form } from "@opencode-ai/schema/form"
 import { Schema } from "effect"
 import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
 import { InvalidRequestError } from "../errors.js"
 import { LocationQuery, locationQueryOpenApi } from "./location.js"
 
-const Inputs = Schema.Record(Schema.String, Schema.String)
-
 export const IntegrationGroup = HttpApiGroup.make("server.integration")
   .add(
     HttpApiEndpoint.get("integration.list", "/api/integration", {
@@ -59,6 +58,7 @@ export const IntegrationGroup = HttpApiGroup.make("server.integration")
       query: LocationQuery,
       payload: Schema.Struct({
         key: Schema.String,
+        answer: Schema.optional(Form.Answer),
         label: Schema.optional(Schema.String),
       }),
       success: HttpApiSchema.NoContent,
@@ -79,7 +79,7 @@ export const IntegrationGroup = HttpApiGroup.make("server.integration")
       query: LocationQuery,
       payload: Schema.Struct({
         methodID: Integration.MethodID,
-        inputs: Inputs,
+        answer: Schema.optional(Form.Answer),
         label: Schema.optional(Schema.String),
       }),
       success: Location.response(Integration.Attempt),

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

+ 3 - 38
packages/schema/src/integration.ts

@@ -7,6 +7,7 @@ import { Connection } from "./connection.js"
 import { ascending } from "./identifier.js"
 import { statics } from "./schema.js"
 import { IntegrationID, IntegrationMethodID } from "./integration-id.js"
+import { Form } from "./form.js"
 
 export const ID = IntegrationID
 export type ID = typeof ID.Type
@@ -14,46 +15,12 @@ export type ID = typeof ID.Type
 export const MethodID = IntegrationMethodID
 export type MethodID = typeof MethodID.Type
 
-export interface When extends Schema.Schema.Type<typeof When> {}
-export const When = Schema.Struct({
-  key: Schema.String,
-  op: Schema.Literals(["eq", "neq"]),
-  value: Schema.String,
-}).annotate({ identifier: "Integration.When" })
-
-export interface TextPrompt extends Schema.Schema.Type<typeof TextPrompt> {}
-export const TextPrompt = Schema.Struct({
-  type: Schema.Literal("text"),
-  key: Schema.String,
-  message: Schema.String,
-  placeholder: optional(Schema.String),
-  when: optional(When),
-}).annotate({ identifier: "Integration.TextPrompt" })
-
-export interface SelectPrompt extends Schema.Schema.Type<typeof SelectPrompt> {}
-export const SelectPrompt = Schema.Struct({
-  type: Schema.Literal("select"),
-  key: Schema.String,
-  message: Schema.String,
-  options: Schema.Array(
-    Schema.Struct({
-      label: Schema.String,
-      value: Schema.String,
-      hint: optional(Schema.String),
-    }),
-  ),
-  when: optional(When),
-}).annotate({ identifier: "Integration.SelectPrompt" })
-
-export const Prompt = Schema.Union([TextPrompt, SelectPrompt]).pipe(Schema.toTaggedUnion("type"))
-export type Prompt = typeof Prompt.Type
-
 export interface OAuthMethod extends Schema.Schema.Type<typeof OAuthMethod> {}
 export const OAuthMethod = Schema.Struct({
   id: MethodID,
   type: Schema.Literal("oauth"),
   label: Schema.String,
-  prompts: optional(Schema.Array(Prompt)),
+  form: optional(Form.Fields),
 }).annotate({ identifier: "Integration.OAuthMethod" })
 
 export interface CommandMethod extends Schema.Schema.Type<typeof CommandMethod> {}
@@ -68,6 +35,7 @@ export interface KeyMethod extends Schema.Schema.Type<typeof KeyMethod> {}
 export const KeyMethod = Schema.Struct({
   type: Schema.Literal("key"),
   label: optional(Schema.String),
+  form: optional(Form.Fields),
 }).annotate({ identifier: "Integration.KeyMethod" })
 
 export interface EnvMethod extends Schema.Schema.Type<typeof EnvMethod> {}
@@ -81,9 +49,6 @@ export const Method = Schema.Union([OAuthMethod, CommandMethod, KeyMethod, EnvMe
   .annotate({ identifier: "Integration.Method" })
 export type Method = typeof Method.Type
 
-export const Inputs = Schema.Record(Schema.String, Schema.String).annotate({ identifier: "Integration.Inputs" })
-export type Inputs = typeof Inputs.Type
-
 const Updated = ephemeral({
   type: "integration.updated",
   schema: {},

+ 2 - 1
packages/server/src/handlers/integration.ts

@@ -58,6 +58,7 @@ export const IntegrationHandler = HttpApiBuilder.group(Api, "server.integration"
             service.connection.key({
               integrationID: ctx.params.integrationID,
               key: ctx.payload.key,
+              answer: ctx.payload.answer,
               label: ctx.payload.label,
             }),
           )
@@ -73,7 +74,7 @@ export const IntegrationHandler = HttpApiBuilder.group(Api, "server.integration"
               service.oauth.connect({
                 integrationID: ctx.params.integrationID,
                 methodID: ctx.payload.methodID,
-                inputs: ctx.payload.inputs,
+                answer: ctx.payload.answer,
                 label: ctx.payload.label,
               }),
             ),

+ 252 - 41
packages/tui/src/component/dialog-integration.tsx

@@ -5,6 +5,10 @@ import type {
   IntegrationInfo,
   IntegrationOauthConnectOutput,
   IntegrationOAuthMethod,
+  FormAnswer,
+  FormField,
+  FormFields,
+  FormValue,
 } from "@opencode-ai/client"
 import open from "open"
 import { createMemo, createSignal, onCleanup, onMount, Show } from "solid-js"
@@ -18,6 +22,7 @@ import { DialogPrompt } from "../ui/dialog-prompt"
 import { DialogSelect } from "../ui/dialog-select"
 import { Link } from "../ui/link"
 import { useToast } from "../ui/toast"
+import { formLabel, formToggleMultiselect, formValidateValue, type FormAnswerField } from "../util/form"
 
 const INTEGRATION_PRIORITY: Record<string, number> = {
   opencode: 0,
@@ -32,6 +37,10 @@ type ConnectMethod = Exclude<IntegrationInfo["methods"][number], { type: "env" }
 type IntegrationAttempt = IntegrationOauthConnectOutput["data"]
 type CommandAttempt = IntegrationCommandConnectOutput["data"]
 type OnIntegrationConnected = (providerID?: string) => void
+const CANCELLED = Symbol("cancelled")
+const CUSTOM = Symbol("custom")
+const OPEN = Symbol("open")
+const SUBMIT = Symbol("submit")
 
 export function integrationOptions(list: IntegrationInfo[]) {
   return list.toSorted(
@@ -181,7 +190,7 @@ function openMethod(
   onConnected?: OnIntegrationConnected,
 ) {
   if (method.type === "key") {
-    dialog.replace(() => <KeyMethod integration={integration} method={method} onConnected={onConnected} />)
+    void beginKey(integration, method, dialog, onConnected)
     return
   }
   if (method.type === "command") {
@@ -191,6 +200,21 @@ function openMethod(
   void beginOAuth(integration, method, dialog, onConnected)
 }
 
+async function beginKey(
+  integration: IntegrationInfo,
+  method: Extract<ConnectMethod, { type: "key" }>,
+  dialog: ReturnType<typeof useDialog>,
+  onConnected?: OnIntegrationConnected,
+) {
+  const answer = method.form
+    ? await formAnswer(dialog, method.label ?? `Connect ${integration.name}`, method.form)
+    : undefined
+  if (answer === null) return
+  dialog.replace(() => (
+    <KeyMethod integration={integration} method={method} answer={answer} onConnected={onConnected} />
+  ))
+}
+
 function CommandStarting(props: {
   integration: IntegrationInfo
   method: Extract<ConnectMethod, { type: "command" }>
@@ -336,6 +360,7 @@ function CommandView(props: { title: string; output: string; message: string })
 function KeyMethod(props: {
   integration: IntegrationInfo
   method: Extract<ConnectMethod, { type: "key" }>
+  answer?: FormAnswer
   onConnected?: OnIntegrationConnected
 }) {
   const data = useData()
@@ -356,6 +381,7 @@ function KeyMethod(props: {
             integrationID: props.integration.id,
             location: location(data),
             key,
+            ...(props.answer ? { answer: props.answer } : {}),
           })
           .then(() => connected(props.integration, data, dialog, toast, props.onConnected))
           .catch((cause) => setError(message(cause)))
@@ -373,17 +399,17 @@ async function beginOAuth(
   dialog: ReturnType<typeof useDialog>,
   onConnected?: OnIntegrationConnected,
 ) {
-  const inputs = method.prompts?.length ? await promptInputs(dialog, method.prompts) : {}
-  if (inputs === null) return
+  const answer = method.form ? await formAnswer(dialog, method.label, method.form) : undefined
+  if (answer === null) return
   dialog.replace(() => (
-    <OAuthStarting integration={integration} method={method} inputs={inputs} onConnected={onConnected} />
+    <OAuthStarting integration={integration} method={method} answer={answer} onConnected={onConnected} />
   ))
 }
 
 function OAuthStarting(props: {
   integration: IntegrationInfo
   method: IntegrationOAuthMethod
-  inputs: Record<string, string>
+  answer?: FormAnswer
   onConnected?: OnIntegrationConnected
 }) {
   const data = useData()
@@ -397,7 +423,7 @@ function OAuthStarting(props: {
         integrationID: props.integration.id,
         location: location(data),
         methodID: props.method.id,
-        inputs: props.inputs,
+        ...(props.answer ? { answer: props.answer } : {}),
       })
       .then((result) => {
         if (result.data.mode === "code") {
@@ -621,49 +647,234 @@ function OAuthView(props: {
   )
 }
 
-async function promptInputs(
+async function formAnswer(dialog: ReturnType<typeof useDialog>, title: string, fields: FormFields) {
+  const answer: FormAnswer = {}
+  for (const field of fields) {
+    if (!active(field, answer)) continue
+    const value = await fieldAnswer(dialog, title, field)
+    if (value === CANCELLED) return null
+    if (value !== undefined) answer[field.key] = value
+  }
+  return answer
+}
+
+function active(field: FormField, answer: FormAnswer) {
+  if (field.type === "external" || !field.when) return true
+  return field.when.every((when) => {
+    const value = answer[when.key]
+    if (value === undefined) return false
+    const hit = Array.isArray(value) ? value.includes(String(when.value)) : value === when.value
+    return when.op === "eq" ? hit : !hit
+  })
+}
+
+function fieldAnswer(
   dialog: ReturnType<typeof useDialog>,
-  prompts: NonNullable<IntegrationOAuthMethod["prompts"]>,
-) {
-  const inputs: Record<string, string> = {}
-  for (const prompt of prompts) {
-    if (prompt.when) {
-      const value = inputs[prompt.when.key]
-      if (value === undefined) continue
-      const matches = prompt.when.op === "eq" ? value === prompt.when.value : value !== prompt.when.value
-      if (!matches) continue
-    }
-    if (prompt.type === "select") {
-      const value = await new Promise<string | null>((resolve) => {
-        dialog.replace(
-          () => (
-            <DialogSelect
-              title={prompt.message}
-              options={prompt.options.map((option) => ({
-                title: option.label,
-                value: option.value,
-                description: option.hint,
-              }))}
-              onSelect={(option) => resolve(option.value)}
-            />
-          ),
-          () => resolve(null),
+  title: string,
+  field: FormField,
+): Promise<FormValue | undefined | typeof CANCELLED> {
+  if (field.type === "external") return externalAnswer(dialog, title, field)
+  if (field.type === "multiselect") return multiselectAnswer(dialog, title, field)
+  if (field.type === "boolean" || (field.type === "string" && field.options)) {
+    return selectAnswer(dialog, title, field)
+  }
+  return textAnswer(dialog, title, field)
+}
+
+async function selectAnswer(
+  dialog: ReturnType<typeof useDialog>,
+  title: string,
+  field: Extract<FormAnswerField, { type: "boolean" | "string" }>,
+): Promise<FormValue | undefined | typeof CANCELLED> {
+  const options =
+    field.type === "boolean"
+      ? field.default === false
+        ? [
+            { title: "No", value: false as FormValue },
+            { title: "Yes", value: true as FormValue },
+          ]
+        : [
+            { title: "Yes", value: true as FormValue },
+            { title: "No", value: false as FormValue },
+          ]
+      : (field.options ?? []).map((option) => ({
+          title: option.label,
+          value: option.value as FormValue,
+          description: option.description,
+        }))
+  const choice = await new Promise<FormValue | typeof CUSTOM | undefined | typeof CANCELLED>((resolve) => {
+    dialog.replace(
+      () => (
+        <DialogSelect<FormValue | typeof CUSTOM | undefined>
+          title={formLabel(field) || title}
+          options={[
+            ...options,
+            ...(field.type === "string" && field.custom
+              ? [{ title: "Type your own answer", value: CUSTOM as typeof CUSTOM }]
+              : []),
+            ...(!field.required ? [{ title: "Skip", value: undefined }] : []),
+          ]}
+          current={field.type === "string" ? field.default : undefined}
+          onSelect={(option) => resolve(option.value)}
+        />
+      ),
+      () => resolve(CANCELLED),
+    )
+  })
+  if (choice === CUSTOM) {
+    if (field.type !== "string") return CANCELLED
+    return textAnswer(dialog, title, field, "")
+  }
+  return choice
+}
+
+function textAnswer(
+  dialog: ReturnType<typeof useDialog>,
+  title: string,
+  field: Extract<FormAnswerField, { type: "string" | "number" | "integer" }>,
+  initial = field.default === undefined ? undefined : String(field.default),
+): Promise<FormValue | undefined | typeof CANCELLED> {
+  return new Promise<FormValue | undefined | typeof CANCELLED>((resolve) => {
+    dialog.replace(
+      () => {
+        const theme = useTheme("elevated")
+        const [error, setError] = createSignal<string>()
+        return (
+          <DialogPrompt
+            title={formLabel(field) || title}
+            placeholder={field.type === "string" ? field.placeholder : undefined}
+            value={initial}
+            onConfirm={(input) => {
+              const text = input.trim()
+              const value = text === "" && !field.required ? undefined : field.type === "string" ? text : Number(text)
+              const invalid = formValidateValue(field, value)
+              if (invalid) {
+                setError(invalid)
+                return
+              }
+              resolve(value)
+            }}
+            description={() => (
+              <box gap={1}>
+                <Show when={field.description}>
+                  {(description) => <text fg={theme.text.subdued}>{description()}</text>}
+                </Show>
+                <Show when={error()}>{(value) => <text fg={theme.text.feedback.error.default}>{value()}</text>}</Show>
+              </box>
+            )}
+          />
         )
-      })
-      if (value === null) return null
-      inputs[prompt.key] = value
+      },
+      () => resolve(CANCELLED),
+    )
+  })
+}
+
+async function multiselectAnswer(
+  dialog: ReturnType<typeof useDialog>,
+  title: string,
+  field: Extract<FormAnswerField, { type: "multiselect" }>,
+): Promise<FormValue | typeof CANCELLED> {
+  const selected = field.default ? [...field.default] : []
+  while (true) {
+    const invalid = formValidateValue(field, selected)
+    const choice = await new Promise<string | typeof CUSTOM | typeof SUBMIT | typeof CANCELLED>((resolve) => {
+      dialog.replace(
+        () => (
+          <DialogSelect<string | typeof CUSTOM | typeof SUBMIT>
+            title={formLabel(field) || title}
+            options={[
+              ...field.options.map((option) => ({
+                title: `[${selected.includes(option.value) ? "x" : " "}] ${option.label}`,
+                value: option.value,
+                description: option.description,
+                disabled:
+                  !selected.includes(option.value) && field.maxItems !== undefined && selected.length >= field.maxItems,
+              })),
+              ...(field.custom ? [{ title: "Type your own answer", value: CUSTOM as typeof CUSTOM }] : []),
+              {
+                title: "Continue",
+                value: SUBMIT as typeof SUBMIT,
+                description: invalid,
+                disabled: invalid !== undefined,
+              },
+            ]}
+            onSelect={(option) => resolve(option.value)}
+          />
+        ),
+        () => resolve(CANCELLED),
+      )
+    })
+    if (choice === CANCELLED) return CANCELLED
+    if (choice === SUBMIT) return selected
+    if (choice === CUSTOM) {
+      const value = await customAnswer(dialog, title, field)
+      if (value === CANCELLED) return CANCELLED
+      if (value && !selected.includes(value)) selected.push(value)
       continue
     }
-    const value = await new Promise<string | null>((resolve) => {
+    selected.splice(0, selected.length, ...formToggleMultiselect(selected, choice))
+  }
+}
+
+function customAnswer(
+  dialog: ReturnType<typeof useDialog>,
+  title: string,
+  field: Extract<FormAnswerField, { type: "multiselect" }>,
+): Promise<string | typeof CANCELLED> {
+  return new Promise<string | typeof CANCELLED>((resolve) => {
+    dialog.replace(
+      () => (
+        <DialogPrompt
+          title={formLabel(field) || title}
+          placeholder="Type your own answer"
+          onConfirm={(value) => {
+            if (value) resolve(value)
+          }}
+        />
+      ),
+      () => resolve(CANCELLED),
+    )
+  })
+}
+
+async function externalAnswer(
+  dialog: ReturnType<typeof useDialog>,
+  title: string,
+  field: Extract<FormField, { type: "external" }>,
+): Promise<true | typeof CANCELLED> {
+  let opened = false
+  while (true) {
+    const choice = await new Promise<true | typeof OPEN | typeof CANCELLED>((resolve) => {
+      dialog.replace(
+        () => (
+          <DialogSelect<true | typeof OPEN>
+            title={formLabel(field) || title}
+            options={[
+              { title: opened ? "Open link again" : "Open link", value: OPEN as typeof OPEN, description: field.url },
+              { title: "I finished", value: true as const, description: field.description, disabled: !opened },
+            ]}
+            onSelect={(option) => resolve(option.value)}
+          />
+        ),
+        () => resolve(CANCELLED),
+      )
+    })
+    if (choice === CANCELLED) return CANCELLED
+    if (choice === true) return true
+    const result = await new Promise<boolean | typeof CANCELLED>((resolve) => {
       dialog.replace(
-        () => <DialogPrompt title={prompt.message} placeholder={prompt.placeholder} onConfirm={resolve} />,
-        () => resolve(null),
+        () => <OAuthView title={formLabel(field) || title} message="Opening link..." />,
+        () => resolve(CANCELLED),
+      )
+      void open(field.url).then(
+        () => resolve(true),
+        () => resolve(false),
       )
     })
-    if (value === null) return null
-    inputs[prompt.key] = value
+    if (result === CANCELLED) return CANCELLED
+    opened ||= result
   }
-  return inputs
 }
 
 async function connected(