mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-07 17:49:53 -04:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dbccc5f2c3 | |||
| c66d84169a | |||
| 7dbe8c4c13 | |||
| 8864b01d0b | |||
| ec95b27308 |
@@ -561,13 +561,7 @@ 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() {
|
||||
@@ -578,7 +572,7 @@ function ProviderConnection(props: {
|
||||
|
||||
const prompts = createMemo(() => {
|
||||
const value = method()
|
||||
return value?.prompts ?? []
|
||||
return value?.type === "oauth" ? (value.prompts ?? []) : []
|
||||
})
|
||||
const matches = (prompt: NonNullable<ReturnType<typeof prompts>[number]>, value: Record<string, string>) => {
|
||||
if (!prompt.when) return true
|
||||
@@ -826,7 +820,6 @@ function ProviderConnection(props: {
|
||||
integrationID: props.provider,
|
||||
location: location(),
|
||||
key: apiKey,
|
||||
inputs: store.promptInputs ?? {},
|
||||
})
|
||||
await complete()
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ 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
|
||||
|
||||
@@ -57,7 +56,7 @@ export const DialogSelectMcp: Component = () => {
|
||||
}
|
||||
const error = () => {
|
||||
const s = mcpStatus()
|
||||
if (s?.status === "failed" || s?.status === "needs_client_registration") return s.error
|
||||
if (s?.status === "failed") return s.error
|
||||
}
|
||||
const enabled = () => status() === "connected"
|
||||
return (
|
||||
|
||||
@@ -426,8 +426,7 @@ 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" || status() === "needs_client_registration",
|
||||
"bg-icon-warning-base": status() === "needs_auth",
|
||||
}}
|
||||
/>
|
||||
<span class="flex flex-col min-w-0 flex-1">
|
||||
|
||||
@@ -35,7 +35,6 @@ 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)
|
||||
})
|
||||
|
||||
@@ -48,7 +47,6 @@ 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" || status === "needs_client_registration")
|
||||
return input.mcp.some((status) => status === "needs_auth")
|
||||
}
|
||||
|
||||
export function hasNonBlockingServiceIssue(input: {
|
||||
|
||||
@@ -13,7 +13,6 @@ 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,7 +34,6 @@ function icon(status: McpServer["status"]) {
|
||||
case "needs_auth":
|
||||
return "⚠"
|
||||
case "failed":
|
||||
case "needs_client_registration":
|
||||
return "✗"
|
||||
default:
|
||||
return "○"
|
||||
@@ -45,8 +44,6 @@ 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:
|
||||
|
||||
@@ -992,7 +992,6 @@ 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"], inputs: input["inputs"], label: input["label"] },
|
||||
payload: { key: input["key"], 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"], inputs: input["inputs"], label: input["label"] },
|
||||
body: { key: input["key"], label: input["label"] },
|
||||
successStatus: 204,
|
||||
declaredStatuses: [400, 401],
|
||||
empty: true,
|
||||
|
||||
@@ -197,6 +197,8 @@ 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 }
|
||||
@@ -257,8 +259,6 @@ 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,13 +1259,7 @@ export type ConnectionInfo = ConnectionCredentialInfo | ConnectionEnvInfo
|
||||
|
||||
export type McpServer = {
|
||||
name: string
|
||||
status:
|
||||
| McpStatusConnected
|
||||
| McpStatusPending
|
||||
| McpStatusDisabled
|
||||
| McpStatusFailed
|
||||
| McpStatusNeedsAuth
|
||||
| McpStatusNeedsClientRegistration
|
||||
status: McpStatusConnected | McpStatusPending | McpStatusDisabled | McpStatusFailed | McpStatusNeedsAuth
|
||||
integrationID?: string
|
||||
}
|
||||
|
||||
@@ -1636,12 +1630,6 @@ export type IntegrationOAuthMethod = {
|
||||
prompts?: Array<IntegrationTextPrompt | IntegrationSelectPrompt>
|
||||
}
|
||||
|
||||
export type IntegrationKeyMethod = {
|
||||
type: "key"
|
||||
label?: string
|
||||
prompts?: Array<IntegrationTextPrompt | IntegrationSelectPrompt>
|
||||
}
|
||||
|
||||
export type FormField =
|
||||
| FormStringField
|
||||
| FormNumberField
|
||||
@@ -3123,21 +3111,8 @@ export type IntegrationConnectKeyInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
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"]
|
||||
readonly key: { readonly key: string; readonly label?: string | undefined }["key"]
|
||||
readonly label?: { readonly key: string; readonly label?: string | undefined }["label"]
|
||||
}
|
||||
|
||||
export type IntegrationConnectKeyOutput = void
|
||||
|
||||
@@ -416,11 +416,14 @@ export function configured(options?: Options) {
|
||||
function publish<D extends Event.Definition>(definition: D, data: Event.Data<D>, options?: PublishOptions) {
|
||||
return Effect.gen(function* () {
|
||||
const serviceLocation = Option.getOrUndefined(yield* Effect.serviceOption(Location.Service))
|
||||
const location =
|
||||
options?.location ??
|
||||
(serviceLocation
|
||||
? { directory: serviceLocation.directory, workspaceID: serviceLocation.workspaceID }
|
||||
: undefined)
|
||||
// Global definitions describe location-independent facts. Never tag
|
||||
// them, so location-filtered subscribers in every location observe them.
|
||||
const location = definition.global
|
||||
? undefined
|
||||
: (options?.location ??
|
||||
(serviceLocation
|
||||
? { directory: serviceLocation.directory, workspaceID: serviceLocation.workspaceID }
|
||||
: undefined))
|
||||
return yield* publishEvent(
|
||||
definition,
|
||||
{
|
||||
|
||||
@@ -175,8 +175,6 @@ 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>
|
||||
@@ -706,11 +704,7 @@ const layer = Layer.effect(
|
||||
yield* credentials.create({
|
||||
integrationID: input.integrationID,
|
||||
label: input.label,
|
||||
value: Credential.Key.make({
|
||||
type: "key",
|
||||
key: input.key,
|
||||
metadata: input.inputs && Object.keys(input.inputs).length > 0 ? input.inputs : undefined,
|
||||
}),
|
||||
value: Credential.Key.make({ type: "key", key: input.key }),
|
||||
})
|
||||
yield* bus.publish(Integration.Event.ConnectionUpdated, { integrationID: input.integrationID })
|
||||
yield* bus.publish(Integration.Event.Updated, {})
|
||||
|
||||
@@ -192,7 +192,6 @@ 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,
|
||||
}),
|
||||
},
|
||||
@@ -399,7 +398,7 @@ function methodImplementation(input: IntegrationMethodRegistration): Integration
|
||||
}
|
||||
return {
|
||||
integrationID: Integration.ID.make(input.integrationID),
|
||||
method: { type: "key", label: input.method.label, prompts: input.method.prompts },
|
||||
method: { type: "key", label: input.method.label },
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,37 +6,6 @@ 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,25 +9,6 @@ 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
|
||||
|
||||
@@ -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: 272_000, input: 272_000 }
|
||||
draft.limit = { ...draft.limit, context: 400_000, input: 272_000 }
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -20,7 +20,7 @@ import type { Info } from "../model"
|
||||
import { SessionUsage } from "./usage"
|
||||
|
||||
const DEFAULT_BUFFER = 20_000
|
||||
const DEFAULT_KEEP_TOKENS = 8_000
|
||||
const DEFAULT_KEEP_TOKENS = 15_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.
|
||||
|
||||
@@ -75,6 +75,13 @@ const CountMessage = Bus.ephemeral({
|
||||
count: Schema.Number,
|
||||
},
|
||||
})
|
||||
const GlobalFact = Bus.ephemeral({
|
||||
type: "test.global.fact",
|
||||
global: true,
|
||||
schema: {
|
||||
text: Schema.String,
|
||||
},
|
||||
})
|
||||
|
||||
const VersionedMessage = Bus.durable({
|
||||
type: "test.versioned",
|
||||
@@ -153,6 +160,31 @@ describe("Bus", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("publishes global definitions untagged so subscribers in other locations observe them", () =>
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const elsewhere = Location.Service.of(
|
||||
location({ directory: AbsolutePath.make("elsewhere"), workspaceID: Workspace.ID.make("wrk_other") }),
|
||||
)
|
||||
const fiber = yield* bus
|
||||
.subscribe([Message, GlobalFact])
|
||||
.pipe(
|
||||
Stream.take(1),
|
||||
Stream.runCollect,
|
||||
Effect.provideService(Location.Service, elsewhere),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
yield* Effect.yieldNow
|
||||
|
||||
// Location-tagged events stay invisible to other locations; the global fact reaches them.
|
||||
yield* bus.publish(Message, { text: "tagged" })
|
||||
const event = yield* bus.publish(GlobalFact, { text: "everywhere" })
|
||||
|
||||
expect(event).not.toHaveProperty("location")
|
||||
expect(Array.from(yield* Fiber.join(fiber))).toEqual([event])
|
||||
}),
|
||||
)
|
||||
|
||||
itWithoutLocation.effect("omits location when no location is available", () =>
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
|
||||
@@ -151,7 +151,6 @@ describe("Integration", () => {
|
||||
yield* integrations.connection.key({
|
||||
integrationID,
|
||||
key: "secret",
|
||||
inputs: { accountId: "account" },
|
||||
label: "Work",
|
||||
})
|
||||
|
||||
@@ -159,7 +158,7 @@ describe("Integration", () => {
|
||||
expect.objectContaining({
|
||||
integrationID,
|
||||
label: "Work",
|
||||
value: Credential.Key.make({ type: "key", key: "secret", metadata: { accountId: "account" } }),
|
||||
value: Credential.Key.make({ type: "key", key: "secret" }),
|
||||
}),
|
||||
])
|
||||
expect((yield* Fiber.join(updated)).length).toBe(1)
|
||||
|
||||
@@ -151,6 +151,9 @@ 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,7 +6,6 @@ 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"
|
||||
|
||||
@@ -103,39 +102,6 @@ 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,7 +7,6 @@ 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"
|
||||
@@ -80,28 +79,6 @@ 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: 272_000, input: 272_000, output: 128_000 })
|
||||
expect(eligible.limit).toEqual({ context: 400_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: 272_000,
|
||||
context: 400_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: 272_000, input: 272_000, output: 128_000 })
|
||||
expect(gpt56.limit).toEqual({ context: 400_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)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -20687,25 +20687,6 @@
|
||||
],
|
||||
"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": {
|
||||
@@ -20728,9 +20709,6 @@
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Mcp.Status.NeedsAuth"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Mcp.Status.NeedsClientRegistration"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -59,7 +59,6 @@ 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,
|
||||
|
||||
@@ -37,6 +37,7 @@ export type DurableDefinition<
|
||||
readonly version: number
|
||||
readonly aggregate: string
|
||||
}
|
||||
readonly global?: never
|
||||
readonly data: DataSchema
|
||||
}
|
||||
|
||||
@@ -47,6 +48,8 @@ export type EphemeralDefinition<
|
||||
readonly type: Type
|
||||
readonly durability: "ephemeral"
|
||||
readonly durable?: never
|
||||
/** Global events describe location-independent facts: they are published untagged and reach every location. */
|
||||
readonly global?: boolean
|
||||
readonly data: DataSchema
|
||||
}
|
||||
|
||||
@@ -77,13 +80,14 @@ type Input<Type extends string, Fields extends Readonly<Record<PropertyKey, Sche
|
||||
readonly version: number
|
||||
readonly aggregate: string
|
||||
}
|
||||
readonly global?: boolean
|
||||
readonly schema: Fields
|
||||
}
|
||||
|
||||
export function durable<
|
||||
const Type extends string,
|
||||
const Fields extends Readonly<Record<PropertyKey, Schema.Codec<unknown, unknown>>>,
|
||||
>(input: Input<Type, Fields> & { readonly durable: NonNullable<Input<Type, Fields>["durable"]> }) {
|
||||
>(input: Omit<Input<Type, Fields>, "global"> & { readonly durable: NonNullable<Input<Type, Fields>["durable"]> }) {
|
||||
const data = Schema.Struct(input.schema)
|
||||
const durable = Schema.Struct({
|
||||
aggregateID: DurableEnvelope.fields.aggregateID,
|
||||
@@ -137,6 +141,7 @@ export function ephemeral<
|
||||
type: input.type,
|
||||
durability: "ephemeral" as const,
|
||||
durable: undefined,
|
||||
global: input.global === true,
|
||||
data,
|
||||
})),
|
||||
) satisfies EphemeralDefinition<Type, typeof data>
|
||||
|
||||
@@ -68,7 +68,6 @@ 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> {}
|
||||
@@ -89,8 +88,12 @@ const Updated = ephemeral({
|
||||
type: "integration.updated",
|
||||
schema: {},
|
||||
})
|
||||
// Credentials live in one global store shared by every location, so a
|
||||
// connection change is a location-independent fact: publish it globally so
|
||||
// every active location refreshes its provider catalog.
|
||||
const ConnectionUpdated = ephemeral({
|
||||
type: "integration.connection.updated",
|
||||
global: true,
|
||||
schema: { integrationID: ID },
|
||||
})
|
||||
export const Event = { Updated, ConnectionUpdated, Definitions: inventory(Updated, ConnectionUpdated) }
|
||||
|
||||
@@ -68,13 +68,9 @@ 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, NeedsClientRegistration]).pipe(
|
||||
export const Status = Schema.Union([Connected, Pending, Disabled, Failed, NeedsAuth]).pipe(
|
||||
Schema.toTaggedUnion("status"),
|
||||
)
|
||||
|
||||
|
||||
@@ -58,7 +58,6 @@ 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,6 +6,7 @@ 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"
|
||||
@@ -180,7 +181,7 @@ function openMethod(
|
||||
onConnected?: OnIntegrationConnected,
|
||||
) {
|
||||
if (method.type === "key") {
|
||||
void beginKey(integration, method, dialog, onConnected)
|
||||
dialog.replace(() => <KeyMethod integration={integration} method={method} onConnected={onConnected} />)
|
||||
return
|
||||
}
|
||||
if (method.type === "command") {
|
||||
@@ -190,19 +191,6 @@ 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" }>
|
||||
@@ -348,7 +336,6 @@ 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()
|
||||
@@ -369,7 +356,6 @@ 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)))
|
||||
@@ -460,6 +446,19 @@ 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",
|
||||
@@ -517,6 +516,7 @@ function OAuthAuto(props: {
|
||||
instructions={props.attempt.instructions}
|
||||
message="Waiting for authorization..."
|
||||
copy
|
||||
open
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -574,7 +574,14 @@ function OAuthCode(props: {
|
||||
)
|
||||
}
|
||||
|
||||
function OAuthView(props: { title: string; url?: string; instructions?: string; message: string; copy?: boolean }) {
|
||||
function OAuthView(props: {
|
||||
title: string
|
||||
url?: string
|
||||
instructions?: string
|
||||
message: string
|
||||
copy?: boolean
|
||||
open?: boolean
|
||||
}) {
|
||||
const dialog = useDialog()
|
||||
const theme = useTheme("elevated")
|
||||
return (
|
||||
@@ -598,11 +605,18 @@ function OAuthView(props: { title: string; url?: string; instructions?: string;
|
||||
)}
|
||||
</Show>
|
||||
<text fg={theme.text.subdued}>{props.message}</text>
|
||||
<Show when={props.copy}>
|
||||
<text fg={theme.text.default}>
|
||||
c <span style={{ fg: theme.text.subdued }}>copy</span>
|
||||
</text>
|
||||
</Show>
|
||||
<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>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import { useConfig } from "../config"
|
||||
import { getScrollAcceleration } from "../util/scroll"
|
||||
|
||||
function statusError(status: McpServer["status"]) {
|
||||
if (status.status === "failed" || status.status === "needs_client_registration") return status.error
|
||||
if (status.status === "failed") return status.error
|
||||
return undefined
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ 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 (
|
||||
@@ -46,9 +45,6 @@ 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,13 +8,7 @@ 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" ||
|
||||
item.status.status === "needs_client_registration",
|
||||
).length,
|
||||
() => list().filter((item) => item.status.status === "failed" || item.status.status === "needs_auth").length,
|
||||
)
|
||||
|
||||
const dot = (status: string) => {
|
||||
@@ -22,7 +16,6 @@ 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
|
||||
}
|
||||
|
||||
@@ -65,7 +58,6 @@ 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>
|
||||
|
||||
@@ -28,7 +28,7 @@ export function Link(props: LinkProps) {
|
||||
open(props.href).catch(() => {})
|
||||
}}
|
||||
>
|
||||
{displayText}
|
||||
<a href={props.href}>{displayText}</a>
|
||||
</text>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ Add `compaction` to any [OpenCode configuration file](/config):
|
||||
"auto": true,
|
||||
"prune": false,
|
||||
"keep": {
|
||||
"tokens": 8000
|
||||
"tokens": 15000
|
||||
},
|
||||
"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` | `8000` | Approximate number of tokens from the newest serialized conversation context to retain beside the summary. |
|
||||
| `keep.tokens` | `15000` | 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`
|
||||
|
||||
@@ -329,7 +329,7 @@ Control automatic context compaction and how much recent context it preserves.
|
||||
"compaction": {
|
||||
"auto": true,
|
||||
"keep": {
|
||||
"tokens": 8000
|
||||
"tokens": 15000
|
||||
},
|
||||
"buffer": 20000
|
||||
}
|
||||
|
||||
@@ -20687,25 +20687,6 @@
|
||||
],
|
||||
"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": {
|
||||
@@ -20728,9 +20709,6 @@
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Mcp.Status.NeedsAuth"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Mcp.Status.NeedsClientRegistration"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -20687,25 +20687,6 @@
|
||||
],
|
||||
"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": {
|
||||
@@ -20728,9 +20709,6 @@
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Mcp.Status.NeedsAuth"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Mcp.Status.NeedsClientRegistration"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user