Compare commits

..

1 Commits

Author SHA1 Message Date
Aiden Cline d7c183e988 feat(core): add Cloudflare connection prompts 2026-08-06 13:31:59 -05:00
39 changed files with 289 additions and 225 deletions
@@ -561,7 +561,13 @@ function ProviderConnection(props: {
if (!alive.value) return
dispatch({ type: "auth.error", error: formatError(e, language.t("common.requestFailed")) })
})
return
}
if (method.prompts?.length && !inputs) {
dispatch({ type: "auth.prompt" })
return
}
dispatch({ type: "auth.inputs", inputs: inputs ?? {} })
}
function AuthPromptsView() {
@@ -572,7 +578,7 @@ function ProviderConnection(props: {
const prompts = createMemo(() => {
const value = method()
return value?.type === "oauth" ? (value.prompts ?? []) : []
return value?.prompts ?? []
})
const matches = (prompt: NonNullable<ReturnType<typeof prompts>[number]>, value: Record<string, string>) => {
if (!prompt.when) return true
@@ -820,6 +826,7 @@ function ProviderConnection(props: {
integrationID: props.provider,
location: location(),
key: apiKey,
inputs: store.promptInputs ?? {},
})
await complete()
}
@@ -10,6 +10,7 @@ const statusLabels = {
connected: "mcp.status.connected",
failed: "mcp.status.failed",
needs_auth: "mcp.status.needs_auth",
needs_client_registration: "mcp.status.needs_client_registration",
disabled: "mcp.status.disabled",
} as const
@@ -56,7 +57,7 @@ export const DialogSelectMcp: Component = () => {
}
const error = () => {
const s = mcpStatus()
if (s?.status === "failed") return s.error
if (s?.status === "failed" || s?.status === "needs_client_registration") return s.error
}
const enabled = () => status() === "connected"
return (
@@ -426,7 +426,8 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
"bg-icon-success-base": status() === "connected",
"bg-icon-critical-base": status() === "failed",
"bg-border-weak-base": status() === "disabled",
"bg-icon-warning-base": status() === "needs_auth",
"bg-icon-warning-base":
status() === "needs_auth" || status() === "needs_client_registration",
}}
/>
<span class="flex flex-col min-w-0 flex-1">
@@ -35,6 +35,7 @@ describe("hasNonBlockingServiceIssue", () => {
test("detects MCP failures that do not block chatting", () => {
expect(hasNonBlockingServiceIssue({ mcp: ["failed"], lsp: [] })).toBe(true)
expect(hasNonBlockingServiceIssue({ mcp: ["needs_auth"], lsp: [] })).toBe(true)
expect(hasNonBlockingServiceIssue({ mcp: ["needs_client_registration"], lsp: [] })).toBe(true)
expect(hasNonBlockingServiceIssue({ mcp: ["connected", "pending", "disabled"], lsp: [] })).toBe(false)
})
@@ -47,6 +48,7 @@ describe("hasNonBlockingServiceIssue", () => {
describe("hasServiceNeedingAttention", () => {
test("detects MCP states that need user attention", () => {
expect(hasServiceNeedingAttention({ mcp: ["needs_auth"] })).toBe(true)
expect(hasServiceNeedingAttention({ mcp: ["needs_client_registration"] })).toBe(true)
})
test("ignores states that do not need user attention", () => {
@@ -2,7 +2,7 @@ import type { LspStatus } from "@/types"
import type { McpServer } from "@opencode-ai/client/promise"
export function hasServiceNeedingAttention(input: { mcp: Array<McpServer["status"]["status"]> }) {
return input.mcp.some((status) => status === "needs_auth")
return input.mcp.some((status) => status === "needs_auth" || status === "needs_client_registration")
}
export function hasNonBlockingServiceIssue(input: {
@@ -13,6 +13,7 @@ export async function toggleMcp(input: {
needs_auth: input.authenticate,
disabled: input.connect,
failed: input.connect,
needs_client_registration: input.connect,
}[input.status]()
await input.refresh()
}
@@ -34,6 +34,7 @@ function icon(status: McpServer["status"]) {
case "needs_auth":
return "⚠"
case "failed":
case "needs_client_registration":
return "✗"
default:
return "○"
@@ -44,6 +45,8 @@ function describe(status: McpServer["status"]) {
switch (status.status) {
case "needs_auth":
return "needs authentication"
case "needs_client_registration":
return `needs client registration: ${status.error}`
case "failed":
return `failed: ${status.error}`
default:
+1
View File
@@ -992,6 +992,7 @@ export type Endpoint10_3Input = {
readonly integrationID: Integration.ID
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly key: string
readonly inputs?: { readonly [x: string]: string } | undefined
readonly label?: string | undefined
}
export type Endpoint10_3Output = void
@@ -664,7 +664,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"], inputs: input["inputs"], label: input["label"] },
}).pipe(Effect.mapError(mapClientError)),
)
@@ -963,7 +963,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"], inputs: input["inputs"], label: input["label"] },
successStatus: 204,
declaredStatuses: [400, 401],
empty: true,
+30 -5
View File
@@ -197,8 +197,6 @@ export type IntegrationWhen = { key: string; op: "eq" | "neq"; value: string }
export type IntegrationCommandMethod = { id: string; type: "command"; label: string; command: Array<string> }
export type IntegrationKeyMethod = { type: "key"; label?: string }
export type IntegrationEnvMethod = { type: "env"; names: Array<string> }
export type ConnectionCredentialInfo = { type: "credential"; id: string; label: string }
@@ -259,6 +257,8 @@ export type McpStatusFailed = { status: "failed"; error: string }
export type McpStatusNeedsAuth = { status: "needs_auth" }
export type McpStatusNeedsClientRegistration = { status: "needs_client_registration"; error: string }
export type McpResource = { server: string; name: string; uri: string; description?: string; mimeType?: string }
export type McpResourceTemplate = {
@@ -1259,7 +1259,13 @@ export type ConnectionInfo = ConnectionCredentialInfo | ConnectionEnvInfo
export type McpServer = {
name: string
status: McpStatusConnected | McpStatusPending | McpStatusDisabled | McpStatusFailed | McpStatusNeedsAuth
status:
| McpStatusConnected
| McpStatusPending
| McpStatusDisabled
| McpStatusFailed
| McpStatusNeedsAuth
| McpStatusNeedsClientRegistration
integrationID?: string
}
@@ -1630,6 +1636,12 @@ export type IntegrationOAuthMethod = {
prompts?: Array<IntegrationTextPrompt | IntegrationSelectPrompt>
}
export type IntegrationKeyMethod = {
type: "key"
label?: string
prompts?: Array<IntegrationTextPrompt | IntegrationSelectPrompt>
}
export type FormField =
| FormStringField
| FormNumberField
@@ -3111,8 +3123,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 inputs?: { readonly [x: string]: string } | undefined
readonly label?: string | undefined
}["key"]
readonly inputs?: {
readonly key: string
readonly inputs?: { readonly [x: string]: string } | undefined
readonly label?: string | undefined
}["inputs"]
readonly label?: {
readonly key: string
readonly inputs?: { readonly [x: string]: string } | undefined
readonly label?: string | undefined
}["label"]
}
export type IntegrationConnectKeyOutput = void
-1
View File
@@ -42,6 +42,5 @@ export const migrations = (
import("./migration/20260622202450_simplify_session_input"),
import("./migration/20260804233008_loose_psylocke"),
import("./migration/20260805200742_import_legacy_credentials"),
import("./migration/20260806200000_import_next_credentials"),
])
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
@@ -1,89 +0,0 @@
import path from "node:path"
import { existsSync } from "node:fs"
import { sql } from "drizzle-orm"
import { Effect, Option, Schema } from "effect"
import { Credential } from "@opencode-ai/schema/credential"
import { Global } from "@opencode-ai/util/global"
import type { DatabaseMigration } from "../migration"
const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
const decodeValue = Schema.decodeUnknownOption(Credential.Value)
export default {
id: "20260806200000_import_next_credentials",
up(tx) {
return importNextCredentials(tx, path.join(Global.Path.data, "opencode-next.db"))
},
} satisfies DatabaseMigration.Migration
/**
* The next channel stored credentials in its own `opencode-next.db` before the
* channel databases were consolidated into `opencode.db`. The legacy import
* only reads V1 `auth.json`, so a credential that existed only in the previous
* channel database was silently dropped. Copy those rows over, keeping any
* credential the target database already has for the same integration.
*/
export function importNextCredentials(tx: Parameters<DatabaseMigration.Migration["up"]>[0], sourcePath: string) {
return Effect.gen(function* () {
if (!existsSync(sourcePath)) return
for (const row of yield* readSourceCredentials(sourcePath)) {
const integrationID = typeof row.integration_id === "string" && row.integration_id.length ? row.integration_id : undefined
if (!integrationID) continue
if (typeof row.value !== "string") continue
const json = Option.getOrUndefined(decodeJson(row.value))
if (json === undefined || Option.isNone(decodeValue(json))) continue
if (yield* tx.get(sql`SELECT id FROM credential WHERE integration_id = ${integrationID}`)) continue
const now = Date.now()
yield* tx.run(sql`
INSERT OR IGNORE INTO credential (
id, integration_id, label, value, connector_id, method_id, active, time_created, time_updated
) VALUES (
${typeof row.id === "string" && row.id.length ? row.id : Credential.ID.create()},
${integrationID},
${typeof row.label === "string" && row.label.length ? row.label : "default"},
${row.value},
${typeof row.connector_id === "string" ? row.connector_id : null},
${typeof row.method_id === "string" ? row.method_id : null},
${typeof row.active === "number" ? row.active : null},
${typeof row.time_created === "number" ? row.time_created : now},
${typeof row.time_updated === "number" ? row.time_updated : now}
)
`)
}
})
}
type SourceRow = Record<string, unknown>
// An unreadable or incompatible source database skips the import instead of
// failing the migration and blocking startup; the source is never modified.
function readSourceCredentials(sourcePath: string) {
return Effect.scoped(
Effect.gen(function* () {
const sqlite = yield* Effect.promise(() => import("bun:sqlite"))
const source = yield* Effect.acquireRelease(
Effect.try({
try: () => new sqlite.Database(sourcePath, { readonly: true, strict: true }),
catch: (error) => error,
}),
(database) => Effect.sync(() => database.close()),
)
return yield* Effect.try({
try: () => {
const table = source
.query("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'credential'")
.get()
if (!table) return [] as SourceRow[]
return source.query<SourceRow, []>("SELECT * FROM credential").all()
},
catch: (error) => error,
})
}),
).pipe(
Effect.catch((error) =>
Effect.logWarning("Skipped incompatible opencode-next.db credentials", { path: sourcePath, error }).pipe(
Effect.as([] as SourceRow[]),
),
),
)
}
+7 -1
View File
@@ -175,6 +175,8 @@ export interface Interface extends State.Transformable<Draft> {
readonly integrationID: ID
/** Secret entered by the user. */
readonly key: string
/** Provider-specific values collected before the secret. */
readonly inputs?: Inputs
/** User-facing label for the stored credential. */
readonly label?: string
}) => Effect.Effect<void, AuthorizationError>
@@ -704,7 +706,11 @@ const layer = Layer.effect(
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,
metadata: input.inputs && Object.keys(input.inputs).length > 0 ? input.inputs : undefined,
}),
})
yield* bus.publish(Integration.Event.ConnectionUpdated, { integrationID: input.integrationID })
yield* bus.publish(Integration.Event.Updated, {})
+2 -1
View File
@@ -192,6 +192,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
integration.connection.key({
integrationID: Integration.ID.make(input.integrationID),
key: input.key,
inputs: input.inputs,
label: input.label,
}),
},
@@ -398,7 +399,7 @@ function methodImplementation(input: IntegrationMethodRegistration): Integration
}
return {
integrationID: Integration.ID.make(input.integrationID),
method: { type: "key", label: input.method.label },
method: { type: "key", label: input.method.label, prompts: input.method.prompts },
}
}
@@ -6,6 +6,37 @@ import { define } from "@opencode-ai/plugin/effect/plugin"
export const CloudflareAIGatewayPlugin = define({
id: "opencode.provider.cloudflare-ai-gateway",
effect: Effect.fn(function* (ctx) {
yield* ctx.integration.transform((draft) => {
draft.method.update({
integrationID: "cloudflare-ai-gateway",
method: {
type: "key",
label: "Gateway API token",
prompts: [
...(process.env.CLOUDFLARE_ACCOUNT_ID
? []
: [
{
type: "text" as const,
key: "accountId",
message: "Enter your Cloudflare Account ID",
placeholder: "e.g. 1234567890abcdef1234567890abcdef",
},
]),
...(process.env.CLOUDFLARE_GATEWAY_ID
? []
: [
{
type: "text" as const,
key: "gatewayId",
message: "Enter your Cloudflare AI Gateway ID",
placeholder: "e.g. my-gateway",
},
]),
],
},
})
})
yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) {
@@ -9,6 +9,25 @@ const providerID = Provider.ID.make("cloudflare-workers-ai")
export const CloudflareWorkersAIPlugin = define({
id: "opencode.provider.cloudflare-workers-ai",
effect: Effect.fn(function* (ctx) {
yield* ctx.integration.transform((draft) => {
draft.method.update({
integrationID: providerID,
method: {
type: "key",
label: "API key",
prompts: process.env.CLOUDFLARE_ACCOUNT_ID
? undefined
: [
{
type: "text",
key: "accountId",
message: "Enter your Cloudflare Account ID",
placeholder: "e.g. 1234567890abcdef1234567890abcdef",
},
],
},
})
})
yield* ctx.catalog.transform((evt) => {
const item = evt.provider.get(providerID)
if (!item) return
+1 -1
View File
@@ -221,7 +221,7 @@ export const OpenAIPlugin = define({
}
draft.cost = []
// Match Codex CLI so context consumption and subscription usage stay consistent between clients.
draft.limit = { ...draft.limit, context: 400_000, input: 272_000 }
draft.limit = { ...draft.limit, context: 272_000, input: 272_000 }
})
}
})
+1 -1
View File
@@ -20,7 +20,7 @@ import type { Info } from "../model"
import { SessionUsage } from "./usage"
const DEFAULT_BUFFER = 20_000
const DEFAULT_KEEP_TOKENS = 15_000
const DEFAULT_KEEP_TOKENS = 8_000
const OUTPUT_TOKEN_MAX = 32_000
const TOOL_OUTPUT_MAX_CHARS = 2_000
const SUMMARY_TEMPLATE = `Output exactly the Markdown structure shown inside <template> and keep the section order unchanged. Do not include the <template> tags in your response.
@@ -12,7 +12,6 @@ import { Database } from "@opencode-ai/core/database/database"
import { tmpdir } from "./fixture/tmpdir"
import type { SqlClient } from "effect/unstable/sql/SqlClient"
import { importLegacyCredentials } from "@opencode-ai/core/database/migration/20260805200742_import_legacy_credentials"
import { importNextCredentials } from "@opencode-ai/core/database/migration/20260806200000_import_next_credentials"
const run = <A, E>(effect: Effect.Effect<A, E, SqlClient>) =>
Effect.runPromise(
@@ -165,75 +164,6 @@ describe("DatabaseMigration", () => {
expect(await Bun.file(source).text()).toBe(content)
})
test("imports previous channel database credentials without replacing existing integrations", async () => {
await using tmp = await tmpdir()
const source = path.join(tmp.path, "opencode-next.db")
const { Database: Sqlite } = await import("bun:sqlite")
const sourceDb = new Sqlite(source, { strict: true })
sourceDb.run(`
CREATE TABLE credential (
id text PRIMARY KEY, integration_id text, label text NOT NULL, value text NOT NULL,
connector_id text, method_id text, active integer, time_created integer NOT NULL, time_updated integer NOT NULL
)
`)
const insert = sourceDb.prepare(
"INSERT INTO credential (id, integration_id, label, value, connector_id, method_id, active, time_created, time_updated) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
insert.run("cred_next_opencode", "opencode", "anomaly", JSON.stringify({ type: "key", key: "zen-key" }), null, "console", null, 100, 200)
insert.run(
"cred_next_anthropic",
"anthropic",
"default",
JSON.stringify({ type: "oauth", methodID: "oauth", refresh: "next-refresh", access: "next-access", expires: 456 }),
null,
null,
null,
100,
200,
)
insert.run("cred_next_invalid", "invalid", "default", "not-json", null, null, null, 100, 200)
sourceDb.close()
await run(
Effect.gen(function* () {
const db = yield* makeDb
yield* DatabaseMigration.apply(db)
const now = Date.now()
yield* db.run(sql`
INSERT INTO credential (id, integration_id, label, value, time_created, time_updated)
VALUES ('existing', 'anthropic', 'Existing', ${JSON.stringify({ type: "key", key: "current-key" })}, ${now}, ${now})
`)
yield* db.transaction((tx) => importNextCredentials(tx, source))
// A second run is a no-op because the integration now has a credential.
yield* db.transaction((tx) => importNextCredentials(tx, source))
// A missing source database is a no-op.
yield* db.transaction((tx) => importNextCredentials(tx, path.join(tmp.path, "missing.db")))
expect(
yield* db.all(sql`SELECT id, integration_id, label, value, method_id, time_created FROM credential ORDER BY integration_id`),
).toEqual([
{
id: "existing",
integration_id: "anthropic",
label: "Existing",
value: JSON.stringify({ type: "key", key: "current-key" }),
method_id: null,
time_created: now,
},
{
id: "cred_next_opencode",
integration_id: "opencode",
label: "anomaly",
value: JSON.stringify({ type: "key", key: "zen-key" }),
method_id: "console",
time_created: 100,
},
])
}),
)
})
test("rolls back a failed migration without recording it", async () => {
await run(
Effect.gen(function* () {
+2 -1
View File
@@ -151,6 +151,7 @@ describe("Integration", () => {
yield* integrations.connection.key({
integrationID,
key: "secret",
inputs: { accountId: "account" },
label: "Work",
})
@@ -158,7 +159,7 @@ describe("Integration", () => {
expect.objectContaining({
integrationID,
label: "Work",
value: Credential.Key.make({ type: "key", key: "secret" }),
value: Credential.Key.make({ type: "key", key: "secret", metadata: { accountId: "account" } }),
}),
])
expect((yield* Fiber.join(updated)).length).toBe(1)
@@ -151,9 +151,6 @@ describe("ModelResolver", () => {
http: { body: { custom_extension: { enabled: true } } },
},
})
const prepared = yield* compileRequest(LLM.request({ model: resolved, prompt: "Hello" }))
expect(prepared.body.max_output_tokens).toBeUndefined()
expect(JSON.stringify(prepared.body)).not.toContain("max_output_tokens")
}),
)
@@ -6,6 +6,7 @@ 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 +103,39 @@ mock.module("ai-gateway-provider/providers/unified", () => ({
}))
describe("CloudflareAIGatewayPlugin", () => {
it.effect("prompts for account and gateway IDs when the environment does not provide them", () =>
withEnv(
cloudflareEnv({
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",
prompts: [
{
type: "text",
key: "accountId",
message: "Enter your Cloudflare Account ID",
placeholder: "e.g. 1234567890abcdef1234567890abcdef",
},
{
type: "text",
key: "gatewayId",
message: "Enter your Cloudflare AI Gateway ID",
placeholder: "e.g. my-gateway",
},
],
})
}),
),
)
it.effect("requires account, gateway, and token before creating the unified SDK", () =>
withEnv(
{
@@ -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,28 @@ function cloudflareHeaders(sdk: unknown, modelID = "@cf/model") {
}
describe("CloudflareWorkersAIPlugin", () => {
it.effect("prompts for the account ID when the environment does not provide it", () =>
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",
prompts: [
{
type: "text",
key: "accountId",
message: "Enter your Cloudflare Account ID",
placeholder: "e.g. 1234567890abcdef1234567890abcdef",
},
],
})
}),
),
)
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* () {
@@ -126,7 +126,7 @@ describe("OpenAIPlugin", () => {
const eligible = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5")))
expect(eligible.package).toBe("@opencode-ai/ai/providers/openai")
expect(eligible.cost).toEqual([])
expect(eligible.limit).toEqual({ context: 400_000, input: 272_000, output: 128_000 })
expect(eligible.limit).toEqual({ context: 272_000, input: 272_000, output: 128_000 })
expect(eligible.enabled).toBe(true)
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5-pro"))).enabled).toBe(
false,
@@ -135,14 +135,14 @@ describe("OpenAIPlugin", () => {
false,
)
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.4"))).limit).toEqual({
context: 400_000,
context: 272_000,
input: 272_000,
output: 64_000,
})
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.6"))).enabled).toBe(false)
const gpt56 = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.6-sol")))
expect(gpt56.enabled).toBe(true)
expect(gpt56.limit).toEqual({ context: 400_000, input: 272_000, output: 128_000 })
expect(gpt56.limit).toEqual({ context: 272_000, input: 272_000, output: 128_000 })
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-4.1"))).enabled).toBe(false)
}),
)
+22
View File
@@ -20687,6 +20687,25 @@
],
"additionalProperties": false
},
"Mcp.Status.NeedsClientRegistration": {
"type": "object",
"properties": {
"status": {
"type": "string",
"enum": [
"needs_client_registration"
]
},
"error": {
"type": "string"
}
},
"required": [
"status",
"error"
],
"additionalProperties": false
},
"Mcp.Server": {
"type": "object",
"properties": {
@@ -20709,6 +20728,9 @@
},
{
"$ref": "#/components/schemas/Mcp.Status.NeedsAuth"
},
{
"$ref": "#/components/schemas/Mcp.Status.NeedsClientRegistration"
}
]
},
@@ -59,6 +59,7 @@ export const IntegrationGroup = HttpApiGroup.make("server.integration")
query: LocationQuery,
payload: Schema.Struct({
key: Schema.String,
inputs: Schema.optional(Inputs),
label: Schema.optional(Schema.String),
}),
success: HttpApiSchema.NoContent,
+1
View File
@@ -68,6 +68,7 @@ export interface KeyMethod extends Schema.Schema.Type<typeof KeyMethod> {}
export const KeyMethod = Schema.Struct({
type: Schema.Literal("key"),
label: optional(Schema.String),
prompts: optional(Schema.Array(Prompt)),
}).annotate({ identifier: "Integration.KeyMethod" })
export interface EnvMethod extends Schema.Schema.Type<typeof EnvMethod> {}
+5 -1
View File
@@ -68,9 +68,13 @@ const Failed = Schema.Struct({ status: Schema.Literal("failed"), error: Schema.S
const NeedsAuth = Schema.Struct({ status: Schema.Literal("needs_auth") }).annotate({
identifier: "Mcp.Status.NeedsAuth",
})
const NeedsClientRegistration = Schema.Struct({
status: Schema.Literal("needs_client_registration"),
error: Schema.String,
}).annotate({ identifier: "Mcp.Status.NeedsClientRegistration" })
export type Status = typeof Status.Type
export const Status = Schema.Union([Connected, Pending, Disabled, Failed, NeedsAuth]).pipe(
export const Status = Schema.Union([Connected, Pending, Disabled, Failed, NeedsAuth, NeedsClientRegistration]).pipe(
Schema.toTaggedUnion("status"),
)
@@ -58,6 +58,7 @@ export const IntegrationHandler = HttpApiBuilder.group(Api, "server.integration"
service.connection.key({
integrationID: ctx.params.integrationID,
key: ctx.payload.key,
inputs: ctx.payload.inputs,
label: ctx.payload.label,
}),
)
@@ -6,7 +6,6 @@ import type {
IntegrationOauthConnectOutput,
IntegrationOAuthMethod,
} from "@opencode-ai/client"
import open from "open"
import { createMemo, createSignal, onCleanup, onMount, Show } from "solid-js"
import { useClipboard } from "../context/clipboard"
import { useData } from "../context/data"
@@ -181,7 +180,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 +190,19 @@ 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 inputs = method.prompts?.length ? await promptInputs(dialog, method.prompts) : {}
if (inputs === null) return
dialog.replace(() => (
<KeyMethod integration={integration} method={method} inputs={inputs} onConnected={onConnected} />
))
}
function CommandStarting(props: {
integration: IntegrationInfo
method: Extract<ConnectMethod, { type: "command" }>
@@ -336,6 +348,7 @@ function CommandView(props: { title: string; output: string; message: string })
function KeyMethod(props: {
integration: IntegrationInfo
method: Extract<ConnectMethod, { type: "key" }>
inputs: Record<string, string>
onConnected?: OnIntegrationConnected
}) {
const data = useData()
@@ -356,6 +369,7 @@ function KeyMethod(props: {
integrationID: props.integration.id,
location: location(data),
key,
inputs: props.inputs,
})
.then(() => connected(props.integration, data, dialog, toast, props.onConnected))
.catch((cause) => setError(message(cause)))
@@ -446,19 +460,6 @@ function OAuthAuto(props: {
Keymap.createLayer(() => ({
mode: "modal",
commands: [
{
bind: "o",
title: "Open authorization URL",
group: "Dialog",
run: () => {
open(props.attempt.url).catch(() =>
toast.show({
message: "Could not open the browser. Copy the URL and continue manually.",
variant: "error",
}),
)
},
},
{
bind: "c",
title: "Copy authorization details",
@@ -516,7 +517,6 @@ function OAuthAuto(props: {
instructions={props.attempt.instructions}
message="Waiting for authorization..."
copy
open
/>
)
}
@@ -574,14 +574,7 @@ function OAuthCode(props: {
)
}
function OAuthView(props: {
title: string
url?: string
instructions?: string
message: string
copy?: boolean
open?: boolean
}) {
function OAuthView(props: { title: string; url?: string; instructions?: string; message: string; copy?: boolean }) {
const dialog = useDialog()
const theme = useTheme("elevated")
return (
@@ -605,18 +598,11 @@ function OAuthView(props: {
)}
</Show>
<text fg={theme.text.subdued}>{props.message}</text>
<box flexDirection="row" gap={2}>
<Show when={props.open}>
<text fg={theme.text.default}>
o <span style={{ fg: theme.text.subdued }}>open</span>
</text>
</Show>
<Show when={props.copy}>
<text fg={theme.text.default}>
c <span style={{ fg: theme.text.subdued }}>copy</span>
</text>
</Show>
</box>
<Show when={props.copy}>
<text fg={theme.text.default}>
c <span style={{ fg: theme.text.subdued }}>copy</span>
</text>
</Show>
</box>
)
}
+1 -1
View File
@@ -15,7 +15,7 @@ import { useConfig } from "../config"
import { getScrollAcceleration } from "../util/scroll"
function statusError(status: McpServer["status"]) {
if (status.status === "failed") return status.error
if (status.status === "failed" || status.status === "needs_client_registration") return status.error
return undefined
}
@@ -14,6 +14,7 @@ export function DialogStatus() {
if (status === "connected") return theme.text.feedback.success.default
if (status === "failed") return theme.text.feedback.error.default
if (status === "needs_auth") return theme.text.feedback.warning.default
if (status === "needs_client_registration") return theme.text.feedback.error.default
return theme.text.subdued
}
return (
@@ -45,6 +46,9 @@ export function DialogStatus() {
<Match when={item.status.status === "failed" && item.status}>{(val) => val().error}</Match>
<Match when={item.status.status === "disabled"}>Disabled in configuration</Match>
<Match when={item.status.status === "needs_auth"}>Needs authentication</Match>
<Match when={item.status.status === "needs_client_registration" && item.status}>
{(val) => (val() as { error: string }).error}
</Match>
</Switch>
</span>
</text>
@@ -8,7 +8,13 @@ function View(props: { context: Plugin.Context; sessionID: string }) {
const list = createMemo(() => props.context.data.location.mcp.server.list(session()?.location) ?? [])
const on = createMemo(() => list().filter((item) => item.status.status === "connected").length)
const bad = createMemo(
() => list().filter((item) => item.status.status === "failed" || item.status.status === "needs_auth").length,
() =>
list().filter(
(item) =>
item.status.status === "failed" ||
item.status.status === "needs_auth" ||
item.status.status === "needs_client_registration",
).length,
)
const dot = (status: string) => {
@@ -16,6 +22,7 @@ function View(props: { context: Plugin.Context; sessionID: string }) {
if (status === "failed") return theme.text.feedback.error.default
if (status === "disabled") return theme.text.subdued
if (status === "needs_auth") return theme.text.feedback.warning.default
if (status === "needs_client_registration") return theme.text.feedback.error.default
return theme.text.subdued
}
@@ -58,6 +65,7 @@ function View(props: { context: Plugin.Context; sessionID: string }) {
</Match>
<Match when={item.status.status === "disabled"}>Disabled</Match>
<Match when={item.status.status === "needs_auth"}>Needs auth</Match>
<Match when={item.status.status === "needs_client_registration"}>Needs client ID</Match>
</Switch>
</span>
</text>
+1 -1
View File
@@ -28,7 +28,7 @@ export function Link(props: LinkProps) {
open(props.href).catch(() => {})
}}
>
<a href={props.href}>{displayText}</a>
{displayText}
</text>
)
}
@@ -83,7 +83,7 @@ Add `compaction` to any [OpenCode configuration file](/config):
"auto": true,
"prune": false,
"keep": {
"tokens": 15000
"tokens": 8000
},
"buffer": 20000
}
@@ -94,7 +94,7 @@ Add `compaction` to any [OpenCode configuration file](/config):
| --- | ---: | --- |
| `auto` | `true` | Runs the preflight context-size check. It does not disable manual compaction or one-shot provider-overflow recovery. |
| `prune` | None | Accepted by the V2 schema, but currently has no runtime effect. V2 does not prune old tool outputs in place. |
| `keep.tokens` | `15000` | Approximate number of tokens from the newest serialized conversation context to retain beside the summary. |
| `keep.tokens` | `8000` | Approximate number of tokens from the newest serialized conversation context to retain beside the summary. |
| `buffer` | `20000` | Safety reserve below an explicit input limit. Without one, it is the minimum context reserve and the model output allowance wins when larger. |
`keep.tokens` and `buffer` accept non-negative integers. Larger `keep.tokens`
+1 -1
View File
@@ -329,7 +329,7 @@ Control automatic context compaction and how much recent context it preserves.
"compaction": {
"auto": true,
"keep": {
"tokens": 15000
"tokens": 8000
},
"buffer": 20000
}
+22
View File
@@ -20687,6 +20687,25 @@
],
"additionalProperties": false
},
"Mcp.Status.NeedsClientRegistration": {
"type": "object",
"properties": {
"status": {
"type": "string",
"enum": [
"needs_client_registration"
]
},
"error": {
"type": "string"
}
},
"required": [
"status",
"error"
],
"additionalProperties": false
},
"Mcp.Server": {
"type": "object",
"properties": {
@@ -20709,6 +20728,9 @@
},
{
"$ref": "#/components/schemas/Mcp.Status.NeedsAuth"
},
{
"$ref": "#/components/schemas/Mcp.Status.NeedsClientRegistration"
}
]
},
+22
View File
@@ -20687,6 +20687,25 @@
],
"additionalProperties": false
},
"Mcp.Status.NeedsClientRegistration": {
"type": "object",
"properties": {
"status": {
"type": "string",
"enum": [
"needs_client_registration"
]
},
"error": {
"type": "string"
}
},
"required": [
"status",
"error"
],
"additionalProperties": false
},
"Mcp.Server": {
"type": "object",
"properties": {
@@ -20709,6 +20728,9 @@
},
{
"$ref": "#/components/schemas/Mcp.Status.NeedsAuth"
},
{
"$ref": "#/components/schemas/Mcp.Status.NeedsClientRegistration"
}
]
},