mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 10:30:01 +08:00
1c339219c1
OpenCode Web Search, the default search provider for Console-connected
users, made its own request with no size cap and discarded the body of
failed responses. It now uses the shared response handling, and OpenCode
API error bodies ({ _tag, message }) supply the failure explanation.
Reading a failed response for its explanation is bounded to one second
so a stalled error body still fails promptly.
Also formats the earlier changes with Prettier and tidies the tests.
497 lines
21 KiB
TypeScript
497 lines
21 KiB
TypeScript
import { Duration, Effect, Equal, Option, Schema, Semaphore, Stream } from "effect"
|
|
import type { Scope } from "effect"
|
|
import type { IntegrationOAuthMethodRegistration } from "@opencode/plugin/effect/integration"
|
|
import { define } from "@opencode/plugin/effect/plugin"
|
|
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
|
import { App } from "../../app.js"
|
|
import { Bus } from "../../bus.js"
|
|
import { Credential } from "../../credential.js"
|
|
import { Integration } from "../../integration.js"
|
|
import { IntegrationConnection } from "../../integration/connection.js"
|
|
import { ManagedPolicy } from "../../managed-policy.js"
|
|
import { Provider } from "../../provider.js"
|
|
import { WebSearch } from "../../websearch.js"
|
|
import { WebSearchResponse } from "../websearch/response.js"
|
|
import { ConfigPolicy } from "@opencode/schema/config/policy"
|
|
import { ConfigProvider } from "@opencode/schema/config/provider"
|
|
import { Money } from "@opencode/schema/money"
|
|
|
|
const defaultServer = "https://opencode.ai/console"
|
|
const clientID = "opencode-cli"
|
|
const methodID = Integration.MethodID.make("device")
|
|
const RemoteResponse = Schema.Struct({
|
|
providers: Schema.Record(Schema.String, ConfigProvider.Info),
|
|
websearch: Schema.Struct({
|
|
providerID: WebSearch.ID,
|
|
}).pipe(Schema.optional),
|
|
// Organization policy compiled for the authenticated caller; omitted when there is none.
|
|
experimental: Schema.Struct({
|
|
policies: Schema.Array(ConfigPolicy.Info).pipe(Schema.optional),
|
|
}).pipe(Schema.optional),
|
|
})
|
|
const Device = Schema.Struct({
|
|
device_code: Schema.String,
|
|
user_code: Schema.String,
|
|
verification_uri_complete: Schema.String,
|
|
expires_in: Schema.Number,
|
|
interval: Schema.Number,
|
|
})
|
|
const Token = Schema.Struct({
|
|
access_token: Schema.String,
|
|
refresh_token: Schema.String,
|
|
expires_in: Schema.Number,
|
|
org_id: Schema.optional(Schema.NullOr(Schema.String)),
|
|
})
|
|
const TokenPending = Schema.Struct({ error: Schema.String })
|
|
const DeviceToken = Schema.Union([Token, TokenPending])
|
|
const User = Schema.Struct({ id: Schema.String, email: Schema.String })
|
|
const Org = Schema.Struct({ id: Schema.String, name: Schema.String })
|
|
const decodeWebSearchResponse = Schema.decodeUnknownOption(
|
|
Schema.Struct({ providerID: WebSearch.ID, results: Schema.Array(Schema.Unknown) }),
|
|
)
|
|
|
|
function oauth(http: HttpClient.HttpClient) {
|
|
return {
|
|
integrationID: Integration.ID.make("opencode"),
|
|
method: {
|
|
id: methodID,
|
|
type: "oauth",
|
|
label: "OpenCode Console account",
|
|
form: [
|
|
{
|
|
key: "server",
|
|
type: "string",
|
|
format: "uri",
|
|
hidden: true,
|
|
default: defaultServer,
|
|
},
|
|
],
|
|
},
|
|
authorize: (answer) =>
|
|
Effect.gen(function* () {
|
|
const server = yield* normalizeServer(answer.server ?? defaultServer)
|
|
const device = yield* post(
|
|
http,
|
|
`${server}/auth/device/code`,
|
|
{ client_id: clientID, supports_org_scope: true },
|
|
Device,
|
|
)
|
|
const verification = yield* Effect.try({
|
|
try: () => {
|
|
const url = new URL(device.verification_uri_complete, `${server}/`)
|
|
if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error("expected HTTP(S)")
|
|
return url
|
|
},
|
|
catch: (cause) =>
|
|
new Error(`Invalid device verification URL: ${cause instanceof Error ? cause.message : String(cause)}`),
|
|
})
|
|
return {
|
|
mode: "auto" as const,
|
|
url: verification.href,
|
|
instructions: `Enter code: ${device.user_code}`,
|
|
callback: poll(http, server, device.device_code, Duration.seconds(device.interval)),
|
|
}
|
|
}),
|
|
refresh: (credential) =>
|
|
Effect.gen(function* () {
|
|
const token = yield* post(
|
|
http,
|
|
`${serverUrl(credential)}/auth/device/token`,
|
|
{ grant_type: "refresh_token", refresh_token: credential.refresh, client_id: clientID },
|
|
Token,
|
|
)
|
|
// Persist rotated tokens without depending on discovery requests.
|
|
return {
|
|
...credential,
|
|
access: token.access_token,
|
|
refresh: token.refresh_token,
|
|
expires: Date.now() + token.expires_in * 1000,
|
|
metadata:
|
|
token.org_id == null
|
|
? credential.metadata
|
|
: {
|
|
...credential.metadata,
|
|
orgID: token.org_id,
|
|
orgName:
|
|
credential.metadata?.orgID === token.org_id && typeof credential.metadata.orgName === "string"
|
|
? credential.metadata.orgName
|
|
: token.org_id,
|
|
},
|
|
}
|
|
}),
|
|
label: (credential) => (typeof credential.metadata?.orgName === "string" ? credential.metadata.orgName : undefined),
|
|
} satisfies IntegrationOAuthMethodRegistration
|
|
}
|
|
|
|
export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | ManagedPolicy.Service | Scope.Scope>({
|
|
id: "opencode.provider.opencode",
|
|
effect: Effect.fn(function* (ctx) {
|
|
const bus = yield* Bus.Service
|
|
const client = yield* HttpClient.HttpClient
|
|
// Every request here goes to the Console, which reads the User-Agent to tell which OpenCode a member runs
|
|
// and whether it evaluates the policies it is being sent.
|
|
const http = HttpClient.mapRequest(client, HttpClientRequest.setHeader("User-Agent", App.useragent(ctx.app)))
|
|
const managed = yield* ManagedPolicy.Service
|
|
const loading = Semaphore.makeUnsafe(1)
|
|
type ActiveConnection = Effect.Success<ReturnType<typeof ctx.integration.connection.active>>
|
|
let snapshot: {
|
|
config: typeof RemoteResponse.Type | undefined
|
|
connection: ActiveConnection
|
|
organization: string | undefined
|
|
} = { config: undefined, connection: undefined, organization: undefined }
|
|
|
|
const load = Effect.fn("OpencodePlugin.load")(function* () {
|
|
const connection = yield* ctx.integration.connection.active("opencode")
|
|
if (!connection) return { config: undefined, connection, organization: undefined }
|
|
return yield* ctx.integration.connection.resolve(connection).pipe(
|
|
Effect.flatMap((credential) => {
|
|
if (!credential) return Effect.succeed({ config: undefined, connection, organization: undefined })
|
|
return fetchConfig(http, credential).pipe(
|
|
Effect.map((config) => ({
|
|
config,
|
|
connection,
|
|
organization: typeof credential.metadata?.orgName === "string" ? credential.metadata.orgName : undefined,
|
|
})),
|
|
)
|
|
}),
|
|
Effect.catch((cause) =>
|
|
Effect.logWarning("failed to load OpenCode provider config", { cause }).pipe(
|
|
// A load that fails for the connection already in place keeps its last config: dropping it
|
|
// would lift organization policy while personal credentials keep working.
|
|
Effect.as(
|
|
IntegrationConnection.key(connection) === IntegrationConnection.key(snapshot.connection)
|
|
? { config: snapshot.config, connection, organization: snapshot.organization }
|
|
: { config: undefined, connection, organization: undefined },
|
|
),
|
|
),
|
|
),
|
|
)
|
|
})
|
|
// Statements ride on the snapshot, so a credential switch, disconnect, or 404 replaces them too.
|
|
const publish = (next: typeof snapshot) =>
|
|
managed.set({ statements: next.config?.experimental?.policies ?? [], organization: next.organization })
|
|
|
|
yield* ctx.integration.transform((editor) => {
|
|
editor.update("opencode", (integration) => {
|
|
integration.name = "OpenCode Console"
|
|
})
|
|
editor.method.update(oauth(http))
|
|
editor.method.update({ integrationID: "opencode", method: { type: "key", label: "API key (service account)" } })
|
|
})
|
|
|
|
snapshot = yield* load()
|
|
yield* publish(snapshot)
|
|
yield* ctx.provider.transform((providers) => {
|
|
for (const [providerID, item] of Object.entries(snapshot.config?.providers ?? {})) {
|
|
const source = providers.get(item.canonical ?? providerID)
|
|
providers.update(providerID, (provider) => {
|
|
if (source && source.provider !== provider)
|
|
Object.assign(provider, structuredClone(source.provider), { id: provider.id })
|
|
provider.integrationID = Integration.ID.make("opencode")
|
|
if (item.canonical !== undefined) provider.canonical = item.canonical
|
|
if (item.name !== undefined) provider.name = item.name
|
|
provider.package = item.package ?? provider.package
|
|
provider.settings = Provider.mergeOverlay(
|
|
withoutCredentials(provider.settings),
|
|
withoutCredentials(item.settings),
|
|
)
|
|
provider.headers = Provider.mergeHeaders(provider.headers, item.headers)
|
|
provider.body = Provider.mergeOverlay(provider.body, item.body)
|
|
})
|
|
|
|
for (const [modelID, config] of Object.entries(item.models ?? {})) {
|
|
const base = source?.models.get(config.modelID ?? modelID) ?? source?.models.get(modelID)
|
|
providers.models.update(providerID, modelID, (model) => {
|
|
Object.assign(model, structuredClone(base ?? model))
|
|
if (config.family !== undefined) model.family = config.family
|
|
if (config.name !== undefined) model.name = config.name
|
|
if (config.modelID !== undefined) model.modelID = config.modelID
|
|
if (config.compatibility !== undefined)
|
|
model.compatibility = { ...model.compatibility, ...config.compatibility }
|
|
model.package = config.package ?? (item.package !== undefined ? undefined : model.package)
|
|
if (item.settings?.baseURL !== undefined && model.settings) delete model.settings.baseURL
|
|
if (config.capabilities !== undefined)
|
|
model.capabilities = {
|
|
...config.capabilities,
|
|
input: [...config.capabilities.input],
|
|
output: [...config.capabilities.output],
|
|
}
|
|
model.settings = Provider.mergeOverlay(
|
|
withoutCredentials(model.settings),
|
|
withoutCredentials(config.settings),
|
|
)
|
|
model.headers = Provider.mergeHeaders(model.headers, config.headers)
|
|
model.body = Provider.mergeOverlay(model.body, config.body)
|
|
for (const variant of config.variants ?? []) {
|
|
let existing = model.variants.find((item) => item.id === variant.id)
|
|
if (!existing) {
|
|
existing = { id: variant.id }
|
|
model.variants.push(existing)
|
|
}
|
|
if (variant.settings !== undefined)
|
|
existing.settings = Provider.mergeOverlay(existing.settings, withoutCredentials(variant.settings))
|
|
if (variant.headers !== undefined)
|
|
existing.headers = Provider.mergeHeaders(existing.headers, variant.headers)
|
|
if (variant.body !== undefined) existing.body = Provider.mergeOverlay(existing.body, variant.body)
|
|
}
|
|
if (config.cost !== undefined)
|
|
model.cost = (Array.isArray(config.cost) ? config.cost : [config.cost]).map((cost) => ({
|
|
tier: cost.tier && { ...cost.tier },
|
|
input: cost.input,
|
|
output: cost.output,
|
|
cache: {
|
|
read: cost.cache?.read ?? Money.USDPerMillionTokens.zero,
|
|
write: cost.cache?.write ?? Money.USDPerMillionTokens.zero,
|
|
},
|
|
}))
|
|
model.enabled = !config.disabled
|
|
if (config.limit !== undefined) model.limit = { ...model.limit, ...config.limit }
|
|
})
|
|
}
|
|
const configured = providers.get(providerID)
|
|
if (configured)
|
|
providers.add({
|
|
info: configured.provider,
|
|
models: Array.from(configured.models.values()),
|
|
sourceConnection: snapshot.connection,
|
|
})
|
|
}
|
|
|
|
const item = providers.get(Provider.ID.opencode)
|
|
if (!item) return
|
|
const hasKey = Boolean(process.env.OPENCODE_API_KEY || snapshot.connection || item.provider.settings?.apiKey)
|
|
providers.update(item.provider.id, (provider) => {
|
|
if (!hasKey) {
|
|
provider.activation = "enabled"
|
|
provider.settings = { ...provider.settings, apiKey: "public" }
|
|
}
|
|
})
|
|
})
|
|
yield* ctx.model.transform((models) => {
|
|
const item = models.provider.get(Provider.ID.opencode)
|
|
if (!item) return
|
|
const hasKey = Boolean(
|
|
process.env.OPENCODE_API_KEY ||
|
|
snapshot.connection ||
|
|
(item.provider.settings?.apiKey && item.provider.settings.apiKey !== "public"),
|
|
)
|
|
if (hasKey) return
|
|
for (const model of models.list(item.provider.id)) {
|
|
if (!model.cost.some((cost) => cost.input > 0)) continue
|
|
models.update(item.provider.id, model.id, (draft) => {
|
|
draft.enabled = false
|
|
})
|
|
}
|
|
})
|
|
|
|
yield* ctx.websearch.transform((editor) => {
|
|
const descriptor = snapshot.config?.websearch
|
|
const connection = snapshot.connection
|
|
if (!descriptor || !connection) return
|
|
editor.add({
|
|
id: descriptor.providerID,
|
|
name: "OpenCode Web Search",
|
|
execute: (input) =>
|
|
Effect.gen(function* () {
|
|
const active = yield* ctx.integration.connection.active("opencode")
|
|
if (
|
|
!active ||
|
|
(connection.type === "credential"
|
|
? active.type !== "credential" || active.id !== connection.id
|
|
: active.type !== "env" || active.name !== connection.name)
|
|
) {
|
|
return yield* Effect.fail(new Error("OpenCode Console connection changed"))
|
|
}
|
|
const credential = yield* ctx.integration.connection.resolve(active)
|
|
if (!credential) return yield* Effect.fail(new Error("OpenCode Console is not connected"))
|
|
const metadata = credential.metadata
|
|
const orgID = typeof metadata?.orgID === "string" ? metadata.orgID : undefined
|
|
const token = credential.type === "oauth" ? credential.access : credential.key
|
|
const server = yield* normalizeServer(serverUrl(credential))
|
|
const request = yield* HttpClientRequest.post(`${server}/api/websearch`).pipe(
|
|
HttpClientRequest.acceptJson,
|
|
HttpClientRequest.bearerToken(token),
|
|
HttpClientRequest.setHeaders(orgID ? { "x-org-id": orgID } : {}),
|
|
HttpClientRequest.schemaBodyJson(WebSearch.Input)({
|
|
query: input.query,
|
|
providerID: descriptor.providerID,
|
|
}),
|
|
)
|
|
const body = yield* WebSearchResponse.execute(http, request).pipe(
|
|
Effect.provideService(FetchHttpClient.RequestInit, { redirect: "error" }),
|
|
Effect.scoped,
|
|
Effect.timeoutOrElse({
|
|
duration: Duration.seconds(25),
|
|
orElse: () => Effect.fail(new Error("OpenCode web search request timed out")),
|
|
}),
|
|
)
|
|
const response = WebSearchResponse.json(body.text, body.truncated).pipe(
|
|
Option.flatMap(decodeWebSearchResponse),
|
|
)
|
|
if (Option.isNone(response))
|
|
return yield* Effect.fail(new Error("OpenCode web search returned an invalid response"))
|
|
if (response.value.providerID !== descriptor.providerID) {
|
|
return yield* Effect.fail(
|
|
new Error(
|
|
`OpenCode web search returned provider ${response.value.providerID} instead of ${descriptor.providerID}`,
|
|
),
|
|
)
|
|
}
|
|
return WebSearchResponse.items(WebSearch.Result, response.value.results, body.truncated)
|
|
}),
|
|
})
|
|
editor.default.set(descriptor.providerID)
|
|
})
|
|
|
|
const apply = Effect.fn("OpencodePlugin.apply")(function* (next: typeof snapshot) {
|
|
snapshot = next
|
|
yield* publish(next)
|
|
yield* Effect.all([ctx.provider.reload(), ctx.websearch.reload()], { concurrency: 2, discard: true })
|
|
})
|
|
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(apply)))
|
|
yield* bus.subscribe(Credential.Event.Switched).pipe(
|
|
Stream.filter((event) => event.data.integrationID === Integration.ID.make("opencode")),
|
|
Stream.runForEach(refresh),
|
|
Effect.forkScoped({ startImmediately: true }),
|
|
)
|
|
|
|
// Console config can change independently of local credential activity, so re-fetch
|
|
// periodically and only rebuild the catalog and search providers when the snapshot differs.
|
|
yield* Effect.sleep(Duration.minutes(1)).pipe(
|
|
Effect.andThen(
|
|
loading.withPermit(
|
|
load().pipe(Effect.flatMap((next) => (Equal.equals(snapshot, next) ? Effect.void : apply(next)))),
|
|
),
|
|
),
|
|
Effect.forever,
|
|
Effect.forkScoped,
|
|
)
|
|
}),
|
|
})
|
|
|
|
function fetchConfig(http: HttpClient.HttpClient, value: Credential.Value) {
|
|
const metadata = value.metadata
|
|
const orgID = typeof metadata?.orgID === "string" ? metadata.orgID : undefined
|
|
const token = value.type === "oauth" ? value.access : value.key
|
|
return http
|
|
.execute(
|
|
HttpClientRequest.get(`${serverUrl(value)}/api/v2/config`).pipe(
|
|
HttpClientRequest.acceptJson,
|
|
HttpClientRequest.bearerToken(token),
|
|
HttpClientRequest.setHeaders(orgID ? { "x-org-id": orgID } : {}),
|
|
),
|
|
)
|
|
.pipe(
|
|
Effect.flatMap((response) => {
|
|
if (response.status === 404) return Effect.undefined
|
|
return HttpClientResponse.filterStatusOk(response).pipe(
|
|
Effect.flatMap(HttpClientResponse.schemaBodyJson(RemoteResponse)),
|
|
)
|
|
}),
|
|
)
|
|
}
|
|
|
|
function serverUrl(value: Credential.Value) {
|
|
return typeof value.metadata?.server === "string" ? value.metadata.server : defaultServer
|
|
}
|
|
|
|
function withoutCredentials<Value>(body: Readonly<Record<string, Value>> | undefined) {
|
|
return (
|
|
body &&
|
|
Object.fromEntries(Object.entries(body).filter(([key]) => !["apiKey", "authToken", "accessToken"].includes(key)))
|
|
)
|
|
}
|
|
|
|
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(/\/+$/, "")}`
|
|
},
|
|
catch: (cause) =>
|
|
new Error(`Invalid OpenCode server URL: ${cause instanceof Error ? cause.message : String(cause)}`),
|
|
})
|
|
}
|
|
|
|
function poll(http: HttpClient.HttpClient, server: string, deviceCode: string, interval: Duration.Duration) {
|
|
const loop = (wait: Duration.Duration): Effect.Effect<Credential.OAuth, unknown> =>
|
|
Effect.gen(function* () {
|
|
yield* Effect.sleep(wait)
|
|
const result = yield* post(
|
|
http,
|
|
`${server}/auth/device/token`,
|
|
{
|
|
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
device_code: deviceCode,
|
|
client_id: clientID,
|
|
},
|
|
DeviceToken,
|
|
false,
|
|
)
|
|
if ("access_token" in result) return yield* credential(http, server, result)
|
|
if (result.error === "authorization_pending") return yield* loop(wait)
|
|
if (result.error === "slow_down") {
|
|
return yield* loop(Duration.sum(wait, Duration.seconds(5)))
|
|
}
|
|
return yield* Effect.fail(new Error(`Device authorization failed: ${result.error}`))
|
|
})
|
|
return loop(interval)
|
|
}
|
|
|
|
function credential(http: HttpClient.HttpClient, server: string, token: typeof Token.Type) {
|
|
return Effect.gen(function* () {
|
|
const [user, orgs] = yield* Effect.all(
|
|
[
|
|
get(http, `${server}/api/user`, token.access_token, User),
|
|
get(http, `${server}/api/orgs`, token.access_token, Schema.Array(Org)),
|
|
],
|
|
{ concurrency: 2 },
|
|
)
|
|
const org =
|
|
token.org_id == null
|
|
? orgs.toSorted((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id))[0]
|
|
: orgs.find((org) => org.id === token.org_id)
|
|
if (token.org_id != null && !org) {
|
|
return yield* Effect.fail(new Error(`OpenCode organization not found: ${token.org_id}`))
|
|
}
|
|
return Credential.OAuth.make({
|
|
type: "oauth" as const,
|
|
methodID,
|
|
access: token.access_token,
|
|
refresh: token.refresh_token,
|
|
expires: Date.now() + token.expires_in * 1000,
|
|
metadata: {
|
|
server,
|
|
accountID: user.id,
|
|
email: user.email,
|
|
orgID: org?.id,
|
|
orgName: org?.name,
|
|
},
|
|
})
|
|
})
|
|
}
|
|
|
|
function get<S extends Schema.Top>(http: HttpClient.HttpClient, url: string, token: string, schema: S) {
|
|
return HttpClient.filterStatusOk(http)
|
|
.execute(HttpClientRequest.get(url).pipe(HttpClientRequest.acceptJson, HttpClientRequest.bearerToken(token)))
|
|
.pipe(Effect.flatMap(HttpClientResponse.schemaBodyJson(schema)))
|
|
}
|
|
|
|
function post<S extends Schema.Top>(
|
|
http: HttpClient.HttpClient,
|
|
url: string,
|
|
body: Record<string, string | boolean>,
|
|
schema: S,
|
|
statusOk = true,
|
|
) {
|
|
return HttpClientRequest.post(url).pipe(
|
|
HttpClientRequest.acceptJson,
|
|
HttpClientRequest.schemaBodyJson(Schema.Record(Schema.String, Schema.Union([Schema.String, Schema.Boolean])))(body),
|
|
Effect.flatMap((request) => http.execute(request)),
|
|
Effect.flatMap((response) => (statusOk ? HttpClientResponse.filterStatusOk(response) : Effect.succeed(response))),
|
|
Effect.flatMap(HttpClientResponse.schemaBodyJson(schema)),
|
|
)
|
|
}
|