mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-14 04:46:23 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b67d64f94 | ||
|
|
79f5080431 | ||
|
|
b70bdad053 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@opencode-ai/core": patch
|
||||
---
|
||||
|
||||
Keep Console model inventories available across restarts using a stored-connection cache, and recover transient fetch failures with scoped retries. Refresh and caching stay inside the Console plugin, preserve existing catalog policy, and do not persist resolved credentials.
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Duration, Effect, Schema, Semaphore, Stream } from "effect"
|
||||
import { Cause, Duration, Effect, Exit, Latch, Option, Schedule, Schema, Stream } from "effect"
|
||||
import type { Scope } from "effect"
|
||||
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/effect/integration"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { Bus } from "../../bus.js"
|
||||
import { Credential } from "../../credential.js"
|
||||
import { Integration } from "../../integration.js"
|
||||
@@ -12,11 +12,31 @@ import { ConfigProviderV1 } from "../../v1/config/provider.js"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { ConfigProviderOptionsV1 } from "../../v1/config/provider-options.js"
|
||||
import { ConfigV1 } from "../../v1/config/config.js"
|
||||
import { isDeepStrictEqual } from "node:util"
|
||||
|
||||
const defaultServer = "https://opencode.ai/console"
|
||||
const clientID = "opencode-cli"
|
||||
const methodID = Integration.MethodID.make("device")
|
||||
const RemoteResponse = Schema.Struct({ config: ConfigV1.Info })
|
||||
const CachedInventory = Schema.fromJsonString(
|
||||
Schema.Record(Schema.String, ConfigProviderV1.Info).check(
|
||||
Schema.makeFilter((providers) =>
|
||||
Object.values(providers).every(
|
||||
(provider) =>
|
||||
cacheableURL(provider.api) &&
|
||||
cacheable(provider.options) &&
|
||||
Object.values(provider.models ?? {}).every(
|
||||
(model) =>
|
||||
cacheableURL(model.provider?.api) &&
|
||||
cacheable(model.options) &&
|
||||
cacheable({ headers: model.headers }) &&
|
||||
Object.values(model.variants ?? {}).every(cacheable),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
const placeholder = /^(?:Bearer )?\{env:[a-z_][a-z_0-9]*\}$/i
|
||||
const Device = Schema.Struct({
|
||||
device_code: Schema.String,
|
||||
user_code: Schema.String,
|
||||
@@ -87,25 +107,6 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const bus = yield* Bus.Service
|
||||
const http = yield* HttpClient.HttpClient
|
||||
const loading = Semaphore.makeUnsafe(1)
|
||||
let connected = false
|
||||
let providers: typeof ConfigV1.Info.Type.provider | undefined
|
||||
|
||||
const load = Effect.fn("OpencodePlugin.load")(function* () {
|
||||
const connection = yield* ctx.integration.connection.active("opencode")
|
||||
const credential = connection
|
||||
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.orElseSucceed(() => undefined))
|
||||
: undefined
|
||||
connected = connection !== undefined
|
||||
providers = credential
|
||||
? yield* fetchProviders(http, credential).pipe(
|
||||
Effect.catch((cause) =>
|
||||
Effect.logWarning("failed to load OpenCode provider config", { cause }).pipe(Effect.as(undefined)),
|
||||
),
|
||||
)
|
||||
: undefined
|
||||
})
|
||||
|
||||
yield* ctx.integration.transform((draft) => {
|
||||
draft.update("opencode", (integration) => {
|
||||
integration.name = "OpenCode"
|
||||
@@ -114,9 +115,59 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
|
||||
draft.method.update({ integrationID: "opencode", method: { type: "key", label: "API key (service account)" } })
|
||||
})
|
||||
|
||||
yield* load()
|
||||
const read = Effect.fn("OpencodePlugin.readCache")(function* () {
|
||||
const connection = yield* ctx.integration.connection.active("opencode")
|
||||
// Stored connection IDs survive token refresh and separate accounts, servers, and organizations.
|
||||
const cached =
|
||||
connection?.type === "credential"
|
||||
? yield* ctx.storage
|
||||
.get(`inventory:${connection.id}`)
|
||||
.pipe(
|
||||
Effect.catchDefect((cause) =>
|
||||
Effect.logWarning("failed to read Console inventory cache", { cause }).pipe(Effect.as(undefined)),
|
||||
),
|
||||
)
|
||||
: undefined
|
||||
return { connection, providers: Option.getOrUndefined(Schema.decodeUnknownOption(CachedInventory)(cached)) }
|
||||
})
|
||||
let inventory = yield* read()
|
||||
const ready = yield* Latch.make()
|
||||
|
||||
const refresh = Effect.fn("OpencodePlugin.refresh")(function* () {
|
||||
// Activation batches transforms; materialize OAuth refresh before resolution, but not before cache restore.
|
||||
if (inventory.connection?.type === "credential") {
|
||||
const registered = yield* ctx.integration
|
||||
.get({ integrationID: Integration.ID.make("opencode") })
|
||||
.pipe(Effect.orElseSucceed(() => undefined))
|
||||
if (!registered?.data?.methods.some((method) => method.type === "oauth" && method.id === methodID))
|
||||
yield* ctx.integration.reload()
|
||||
}
|
||||
const providers = inventory.connection
|
||||
? yield* ctx.integration.connection.resolve(inventory.connection).pipe(
|
||||
Effect.flatMap((credential) =>
|
||||
credential
|
||||
? fetchProviders(http, credential).pipe(Effect.map((providers) => providers ?? {}))
|
||||
: Effect.undefined,
|
||||
),
|
||||
Effect.retry({ while: retryable, times: 2, schedule: Schedule.exponential(200) }),
|
||||
Effect.timeout("5 seconds"),
|
||||
)
|
||||
: undefined
|
||||
if (isDeepStrictEqual(inventory.providers, providers)) return
|
||||
inventory = { connection: inventory.connection, providers }
|
||||
yield* ctx.catalog.reload()
|
||||
if (inventory.connection?.type !== "credential" || providers === undefined) return
|
||||
const cached = Schema.encodeOption(CachedInventory)(providers)
|
||||
yield* (
|
||||
Option.isSome(cached)
|
||||
? ctx.storage.set(`inventory:${inventory.connection.id}`, cached.value)
|
||||
: ctx.storage.remove(`inventory:${inventory.connection.id}`)
|
||||
).pipe(Effect.catchDefect((cause) => Effect.logWarning("failed to persist Console inventory cache", { cause })))
|
||||
})
|
||||
|
||||
yield* ctx.catalog.transform((catalog) => {
|
||||
for (const [providerID, item] of Object.entries(providers ?? {})) {
|
||||
// Later transforms may mutate nested settings; keep the source inventory independent of catalog policy.
|
||||
for (const [providerID, item] of Object.entries(structuredClone(inventory.providers ?? {}))) {
|
||||
catalog.provider.update(providerID, (provider) => {
|
||||
provider.integrationID = Integration.ID.make("opencode")
|
||||
if (item.name !== undefined) provider.name = item.name
|
||||
@@ -176,7 +227,7 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
|
||||
|
||||
const item = catalog.provider.get(Provider.ID.opencode)
|
||||
if (!item) return
|
||||
const hasKey = Boolean(process.env.OPENCODE_API_KEY || connected || item.provider.settings?.apiKey)
|
||||
const hasKey = Boolean(process.env.OPENCODE_API_KEY || inventory.connection || item.provider.settings?.apiKey)
|
||||
catalog.provider.update(item.provider.id, (provider) => {
|
||||
if (!hasKey) {
|
||||
provider.activation = "enabled"
|
||||
@@ -192,15 +243,92 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
|
||||
}
|
||||
})
|
||||
|
||||
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload())))
|
||||
// Switching waits for the previous refresh to stop, so only one worker writes the captured inventory.
|
||||
yield* bus.subscribe(Credential.Event.Switched).pipe(
|
||||
Stream.filter((event) => event.data.integrationID === Integration.ID.make("opencode")),
|
||||
Stream.runForEach(refresh),
|
||||
Stream.prepend([undefined]),
|
||||
Stream.switchMap((event) =>
|
||||
Stream.fromEffect(
|
||||
Effect.gen(function* () {
|
||||
if (event) {
|
||||
inventory = yield* read()
|
||||
yield* ctx.catalog.reload()
|
||||
}
|
||||
if (inventory.providers !== undefined) yield* ready.open
|
||||
yield* refresh().pipe(
|
||||
Effect.tapError((cause) => Effect.logWarning("failed to load OpenCode provider config", { cause })),
|
||||
Effect.onExit((exit) =>
|
||||
Exit.isFailure(exit) && Cause.hasInterrupts(exit.cause) ? Effect.void : ready.open,
|
||||
),
|
||||
Effect.retry({
|
||||
while: retryable,
|
||||
schedule: Schedule.min([Schedule.exponential("5 seconds"), Schedule.spaced("30 seconds")]),
|
||||
}),
|
||||
Effect.ignore,
|
||||
)
|
||||
}),
|
||||
),
|
||||
),
|
||||
Stream.runDrain,
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
yield* ready.await
|
||||
}),
|
||||
})
|
||||
|
||||
function cacheable(value: unknown): boolean {
|
||||
if (Array.isArray(value)) return value.every(cacheable)
|
||||
if (value === null || typeof value !== "object") return true
|
||||
return Object.entries(value).every(([key, item]) => {
|
||||
const name = key.replace(/[-_]/g, "").toLowerCase()
|
||||
if (name === "headers") {
|
||||
if (item === undefined) return true
|
||||
if (item === null || typeof item !== "object" || Array.isArray(item)) return false
|
||||
return Object.entries(item).every(
|
||||
([header, content]) =>
|
||||
typeof content === "string" &&
|
||||
(placeholder.test(content) ||
|
||||
[
|
||||
"x-org-id",
|
||||
"anthropic-version",
|
||||
"anthropic-beta",
|
||||
"content-type",
|
||||
"accept",
|
||||
"openai-organization",
|
||||
"openai-project",
|
||||
].includes(header.toLowerCase())),
|
||||
)
|
||||
}
|
||||
if (
|
||||
/^(?:apiKey|xApiKey|xGoogApiKey|authorization|accessToken|authToken|refreshToken|password|secret|credentials|cookie|setCookie)$/i.test(
|
||||
name,
|
||||
)
|
||||
)
|
||||
return typeof item === "string" && placeholder.test(item)
|
||||
if (name === "baseurl" || name === "enterpriseurl") return typeof item === "string" && cacheableURL(item)
|
||||
return cacheable(item)
|
||||
})
|
||||
}
|
||||
|
||||
function cacheableURL(value: string | undefined) {
|
||||
if (value === undefined) return true
|
||||
const url = URL.parse(value)
|
||||
return url !== null && !url.username && !url.password && !url.search && !url.hash
|
||||
}
|
||||
|
||||
function retryable(cause: unknown): boolean {
|
||||
if (cause instanceof Integration.AuthorizationError) return retryable(cause.cause)
|
||||
if (Cause.isTimeoutError(cause)) return true
|
||||
if (!HttpClientError.isHttpClientError(cause)) return false
|
||||
return (
|
||||
cause.reason._tag === "TransportError" ||
|
||||
(cause.reason._tag === "StatusCodeError" &&
|
||||
(cause.reason.response.status === 408 ||
|
||||
cause.reason.response.status === 429 ||
|
||||
cause.reason.response.status >= 500))
|
||||
)
|
||||
}
|
||||
|
||||
function fetchProviders(http: HttpClient.HttpClient, value: Credential.Value) {
|
||||
const metadata = value.metadata
|
||||
const server = typeof metadata?.server === "string" ? metadata.server : defaultServer
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { OpencodePlugin } from "@opencode-ai/core/plugin/provider/opencode"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { State } from "@opencode-ai/core/state"
|
||||
import { Effect, Exit, Fiber, Schedule, Schema, Scope } from "effect"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
const it = testEffect(PluginTestLayer)
|
||||
const providerID = Provider.ID.make("example")
|
||||
const modelID = Model.ID.make("chat")
|
||||
const hiddenID = Model.ID.make("hidden")
|
||||
const placeholder = "{env:OPENCODE_CONSOLE_TOKEN}"
|
||||
const headers = { Authorization: `Bearer ${placeholder}`, "x-org-id": "org-a" }
|
||||
const variant = {
|
||||
apiKey: placeholder,
|
||||
headers,
|
||||
temperature: 0.2,
|
||||
reasoning: { effort: "high", budget: { tokens: 2048 } },
|
||||
response: { format: { type: "json", required: ["answer", "source"] } },
|
||||
}
|
||||
|
||||
const addPlugin = Effect.fn(function* (set?: (key: string, value: Schema.Json) => Effect.Effect<void>) {
|
||||
const scope = yield* Effect.acquireRelease(Scope.make(), (scope, exit) => Scope.close(scope, exit))
|
||||
const plugin = yield* Plugin.Service
|
||||
const host = yield* PluginHost.make(plugin, OpencodePlugin.id)
|
||||
yield* State.batch(
|
||||
OpencodePlugin.effect(set ? { ...host, storage: { ...host.storage, set } } : host).pipe(Scope.provide(scope)),
|
||||
)
|
||||
return { host, scope }
|
||||
})
|
||||
|
||||
const serve = (fetch: (request: Request) => Response | Promise<Response>) =>
|
||||
Effect.acquireRelease(
|
||||
Effect.sync(() => Bun.serve({ hostname: "127.0.0.1", port: 0, fetch })),
|
||||
(server) => Effect.promise(() => server.stop(true)),
|
||||
)
|
||||
|
||||
const connect = Effect.fn(function* (server: string, key = "fixture-key") {
|
||||
const credentials = yield* Credential.Service
|
||||
return yield* credentials.create({
|
||||
integrationID: Integration.ID.make("opencode"),
|
||||
value: Credential.Key.make({ type: "key", key, metadata: { server, orgID: "org-a" } }),
|
||||
})
|
||||
})
|
||||
|
||||
function eventually<A, E, R>(effect: Effect.Effect<A, E, R>, until: (value: A) => boolean) {
|
||||
return effect.pipe(Effect.repeat({ until, schedule: Schedule.spaced("10 millis") }), Effect.timeout("2 seconds"))
|
||||
}
|
||||
|
||||
function inventory(origin: string, output = 1000, apiKey = placeholder) {
|
||||
return Response.json({
|
||||
config: {
|
||||
provider: {
|
||||
example: {
|
||||
name: "Example Console",
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
api: `${origin}/v1`,
|
||||
options: { apiKey, headers },
|
||||
models: {
|
||||
chat: {
|
||||
name: "Example Chat",
|
||||
family: "example-chat",
|
||||
release_date: "2026-01-02",
|
||||
tool_call: true,
|
||||
modalities: { input: ["text", "image"], output: ["text"] },
|
||||
cost: { input: 1, output: 2, cache_read: 0.1, cache_write: 0.2 },
|
||||
limit: { context: 10000, output },
|
||||
options: { apiKey: placeholder, temperature: 0.5 },
|
||||
variants: { careful: variant },
|
||||
},
|
||||
hidden: { name: "Hidden Chat" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe("OpencodePlugin inventory cache", () => {
|
||||
it.live("restores metadata and nested variants before HTTP completes, then replays catalog policy", () =>
|
||||
Effect.gen(function* () {
|
||||
const gate = { enabled: false }
|
||||
const requested = Promise.withResolvers<void>()
|
||||
const release = Promise.withResolvers<void>()
|
||||
const paths: string[] = []
|
||||
const server = yield* serve(async (request) => {
|
||||
paths.push(new URL(request.url).pathname)
|
||||
if (!gate.enabled) return inventory(new URL(request.url).origin)
|
||||
requested.resolve()
|
||||
await release.promise
|
||||
if (new URL(request.url).pathname === "/auth/device/token")
|
||||
return Response.json({ access_token: "rotated-access", refresh_token: "rotated-refresh", expires_in: 3600 })
|
||||
if (request.headers.get("authorization") !== "Bearer rotated-access")
|
||||
return new Response("Expired credential", { status: 401 })
|
||||
return inventory(new URL(request.url).origin, 2000)
|
||||
})
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => release.resolve()))
|
||||
const credentials = yield* Credential.Service
|
||||
const account = yield* credentials.create({
|
||||
integrationID: Integration.ID.make("opencode"),
|
||||
value: Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
methodID: Integration.MethodID.make("device"),
|
||||
access: "fixture-access-token",
|
||||
refresh: "fixture-refresh-token",
|
||||
expires: Date.now() + 3_600_000,
|
||||
metadata: { server: server.url.origin, accountID: "account-a", orgID: "org-a" },
|
||||
}),
|
||||
})
|
||||
const catalog = yield* Catalog.Service
|
||||
const first = yield* addPlugin()
|
||||
const saved = yield* first.host.storage.scan({ prefix: "" })
|
||||
expect(saved.entries).toHaveLength(1)
|
||||
expect(JSON.stringify(saved)).toContain(placeholder)
|
||||
expect(JSON.stringify(saved)).not.toContain("fixture-access-token")
|
||||
expect(JSON.stringify(saved)).not.toContain("fixture-refresh-token")
|
||||
|
||||
yield* Scope.close(first.scope, Exit.void)
|
||||
expect(yield* catalog.model.available()).toEqual([])
|
||||
if (account.value.type !== "oauth") return yield* Effect.die("Expected OAuth credential")
|
||||
yield* credentials.update(account.id, { value: Credential.OAuth.make({ ...account.value, expires: 0 }) })
|
||||
gate.enabled = true
|
||||
const persisted = Promise.withResolvers<Schema.Json>()
|
||||
yield* addPlugin((_key, value) => Effect.sync(() => persisted.resolve(value))).pipe(Effect.timeout("2 seconds"))
|
||||
yield* Effect.promise(() => requested.promise).pipe(Effect.timeout("2 seconds"))
|
||||
expect(paths.at(-1)).toBe("/auth/device/token")
|
||||
|
||||
expect(yield* catalog.provider.get(providerID)).toMatchObject({
|
||||
name: "Example Console",
|
||||
integrationID: "opencode",
|
||||
package: Provider.aisdk("@ai-sdk/openai-compatible"),
|
||||
settings: { baseURL: `${server.url.origin}/v1` },
|
||||
headers,
|
||||
})
|
||||
const cached = yield* catalog.model.get(providerID, modelID)
|
||||
expect(cached).toMatchObject({
|
||||
name: "Example Chat",
|
||||
family: "example-chat",
|
||||
time: { released: Date.parse("2026-01-02") },
|
||||
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
|
||||
cost: [{ input: 1, output: 2, cache: { read: 0.1, write: 0.2 } }],
|
||||
limit: { context: 10000, output: 1000 },
|
||||
settings: { temperature: 0.5 },
|
||||
})
|
||||
expect(cached?.variants).toEqual([
|
||||
{
|
||||
id: Model.VariantID.make("careful"),
|
||||
headers,
|
||||
settings: { temperature: 0.2, reasoning: variant.reasoning, response: variant.response },
|
||||
},
|
||||
])
|
||||
expect((yield* catalog.model.available()).map((model) => model.id)).toContain(modelID)
|
||||
|
||||
yield* catalog.transform((draft) => {
|
||||
draft.model.remove(providerID, hiddenID)
|
||||
draft.model.update(providerID, modelID, (model) => {
|
||||
model.name = "Policy Chat"
|
||||
const reasoning = model.variants?.find((variant) => variant.id === "careful")?.settings?.reasoning
|
||||
if (typeof reasoning !== "object" || reasoning === null || !("effort" in reasoning))
|
||||
throw new Error("Expected reasoning options")
|
||||
reasoning.effort = "low"
|
||||
})
|
||||
})
|
||||
release.resolve()
|
||||
yield* eventually(catalog.model.get(providerID, modelID), (model) => model?.limit.output === 2000)
|
||||
expect((yield* catalog.model.get(providerID, modelID))?.name).toBe("Policy Chat")
|
||||
expect(yield* catalog.model.get(providerID, hiddenID)).toBeUndefined()
|
||||
expect((yield* catalog.model.available()).map((model) => model.id)).toEqual([modelID])
|
||||
expect(
|
||||
Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown))(yield* Effect.promise(() => persisted.promise)),
|
||||
).toMatchObject({
|
||||
example: {
|
||||
models: { chat: { limit: { output: 2000 }, variants: { careful: { reasoning: { effort: "high" } } } } },
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("isolates accounts, restores the previous cache offline, and cancels obsolete workers", () =>
|
||||
Effect.gen(function* () {
|
||||
const gate = { enabled: false }
|
||||
const release = Promise.withResolvers<void>()
|
||||
const requests: { authorization: string | null; aborted: boolean }[] = []
|
||||
const server = yield* serve(async (request) => {
|
||||
const entry = { authorization: request.headers.get("authorization"), aborted: false }
|
||||
requests.push(entry)
|
||||
request.signal.addEventListener("abort", () => (entry.aborted = true), { once: true })
|
||||
if (gate.enabled) await release.promise
|
||||
return inventory(new URL(request.url).origin)
|
||||
})
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => release.resolve()))
|
||||
const credentials = yield* Credential.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
const account = yield* connect(server.url.origin, "fixture-account-a")
|
||||
const first = yield* addPlugin()
|
||||
expect(JSON.stringify(yield* first.host.storage.scan({ prefix: "" }))).not.toContain("fixture-account-a")
|
||||
yield* Scope.close(first.scope, Exit.void)
|
||||
gate.enabled = true
|
||||
const second = yield* addPlugin().pipe(Effect.timeout("2 seconds"))
|
||||
yield* eventually(
|
||||
Effect.sync(() => requests.length),
|
||||
(count) => count === 2,
|
||||
)
|
||||
expect((yield* catalog.model.get(providerID, modelID))?.name).toBe("Example Chat")
|
||||
|
||||
yield* connect(server.url.origin, "fixture-account-b")
|
||||
yield* eventually(
|
||||
Effect.sync(() => requests.length),
|
||||
(count) => count === 3,
|
||||
)
|
||||
yield* eventually(
|
||||
Effect.sync(() => requests[1]?.aborted),
|
||||
Boolean,
|
||||
)
|
||||
yield* eventually(catalog.model.available(), (models) => models.length === 0)
|
||||
expect(yield* catalog.model.get(providerID, modelID)).toBeUndefined()
|
||||
|
||||
yield* credentials.activate(account.id)
|
||||
yield* eventually(catalog.model.get(providerID, modelID), (model) => model?.name === "Example Chat")
|
||||
yield* eventually(
|
||||
Effect.sync(() => requests.length),
|
||||
(count) => count === 4,
|
||||
)
|
||||
yield* eventually(
|
||||
Effect.sync(() => requests[2]?.aborted),
|
||||
Boolean,
|
||||
)
|
||||
expect(requests.map((request) => request.authorization)).toEqual([
|
||||
"Bearer fixture-account-a",
|
||||
"Bearer fixture-account-a",
|
||||
"Bearer fixture-account-b",
|
||||
"Bearer fixture-account-a",
|
||||
])
|
||||
|
||||
yield* Scope.close(second.scope, Exit.void).pipe(Effect.timeout("2 seconds"))
|
||||
yield* eventually(
|
||||
Effect.sync(() => requests[3]?.aborted),
|
||||
Boolean,
|
||||
)
|
||||
expect(yield* catalog.model.available()).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("publishes fresh inventory even when cache persistence dies", () =>
|
||||
Effect.gen(function* () {
|
||||
const server = yield* serve((request) => inventory(new URL(request.url).origin))
|
||||
yield* connect(server.url.origin)
|
||||
const writes: string[] = []
|
||||
const instance = yield* addPlugin(() =>
|
||||
Effect.sync(() => writes.push("attempted")).pipe(Effect.andThen(Effect.die(new Error("Cache write failed")))),
|
||||
)
|
||||
const catalog = yield* Catalog.Service
|
||||
|
||||
expect(writes).toEqual(["attempted"])
|
||||
expect((yield* catalog.model.get(providerID, modelID))?.name).toBe("Example Chat")
|
||||
expect((yield* catalog.model.available()).map((model) => model.id)).toContain(modelID)
|
||||
expect((yield* instance.host.storage.scan({ prefix: "" })).entries).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("ignores malformed cached data and replaces it with a fresh response", () =>
|
||||
Effect.gen(function* () {
|
||||
const gate = { enabled: false }
|
||||
const requested = Promise.withResolvers<void>()
|
||||
const release = Promise.withResolvers<void>()
|
||||
const server = yield* serve(async (request) => {
|
||||
if (!gate.enabled) return inventory(new URL(request.url).origin)
|
||||
requested.resolve()
|
||||
await release.promise
|
||||
return inventory(new URL(request.url).origin, 2000)
|
||||
})
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => release.resolve()))
|
||||
yield* connect(server.url.origin)
|
||||
const first = yield* addPlugin()
|
||||
const saved = yield* first.host.storage.scan({ prefix: "" })
|
||||
expect(saved.entries).toHaveLength(1)
|
||||
const entry = saved.entries[0]
|
||||
if (!entry) return yield* Effect.die("Expected cached inventory")
|
||||
yield* Scope.close(first.scope, Exit.void)
|
||||
yield* first.host.storage.set(entry.key, "malformed cache")
|
||||
gate.enabled = true
|
||||
const loading = yield* addPlugin().pipe(Effect.forkScoped({ startImmediately: true }))
|
||||
yield* Effect.promise(() => requested.promise).pipe(Effect.timeout("2 seconds"))
|
||||
const catalog = yield* Catalog.Service
|
||||
expect(yield* catalog.model.available()).toEqual([])
|
||||
|
||||
release.resolve()
|
||||
const second = yield* Fiber.join(loading).pipe(Effect.timeout("2 seconds"))
|
||||
expect((yield* catalog.model.get(providerID, modelID))?.limit.output).toBe(2000)
|
||||
const replaced = yield* second.host.storage.scan({ prefix: "" })
|
||||
expect(replaced.entries).toHaveLength(1)
|
||||
expect(replaced.entries[0]?.value).not.toBe("malformed cache")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("uses literal-credential config live without persisting its inventory", () =>
|
||||
Effect.gen(function* () {
|
||||
const server = yield* serve((request) => inventory(new URL(request.url).origin, 1000, "fixture-literal-key"))
|
||||
yield* connect(server.url.origin)
|
||||
const instance = yield* addPlugin()
|
||||
const catalog = yield* Catalog.Service
|
||||
|
||||
expect((yield* catalog.model.get(providerID, modelID))?.name).toBe("Example Chat")
|
||||
expect((yield* catalog.model.available()).map((model) => model.id)).toContain(modelID)
|
||||
expect((yield* instance.host.storage.scan({ prefix: "" })).entries).toEqual([])
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,224 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { OpencodePlugin } from "@opencode-ai/core/plugin/provider/opencode"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { State } from "@opencode-ai/core/state"
|
||||
import { Effect, Fiber, Option, Stream } from "effect"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
const it = testEffect(PluginTestLayer)
|
||||
const providerID = Provider.ID.make("example")
|
||||
const modelID = Model.ID.make("chat")
|
||||
const hiddenID = Model.ID.make("hidden")
|
||||
|
||||
const addPlugin = Effect.fn(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const host = yield* PluginHost.make(plugin, OpencodePlugin.id)
|
||||
yield* State.batch(OpencodePlugin.effect(host))
|
||||
})
|
||||
|
||||
const connect = Effect.fn(function* (respond: (request: Request, attempt: number) => Response | Promise<Response>) {
|
||||
const requests: number[] = []
|
||||
const server = yield* Effect.acquireRelease(
|
||||
Effect.sync(() =>
|
||||
Bun.serve({
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
fetch: (request) => {
|
||||
requests.push(performance.now())
|
||||
return respond(request, requests.length)
|
||||
},
|
||||
}),
|
||||
),
|
||||
(server) => Effect.promise(() => server.stop(true)),
|
||||
)
|
||||
const credentials = yield* Credential.Service
|
||||
yield* credentials.create({
|
||||
integrationID: Integration.ID.make("opencode"),
|
||||
value: Credential.Key.make({ type: "key", key: "test-key", metadata: { server: server.url.origin } }),
|
||||
})
|
||||
return requests
|
||||
})
|
||||
|
||||
const inventory = () =>
|
||||
Response.json({
|
||||
config: {
|
||||
provider: {
|
||||
example: {
|
||||
name: "Example",
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
models: { chat: { name: "Example Chat" }, hidden: { name: "Hidden Chat" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
describe("OpencodePlugin source recovery", () => {
|
||||
it.live("retries an initial 503 before plugin setup completes", () =>
|
||||
Effect.gen(function* () {
|
||||
const requests = yield* connect((_request, attempt) =>
|
||||
attempt === 1 ? new Response("Unavailable", { status: 503 }) : inventory(),
|
||||
)
|
||||
const catalog = yield* Catalog.Service
|
||||
|
||||
yield* addPlugin().pipe(Effect.timeout("6 seconds"))
|
||||
|
||||
expect(requests).toHaveLength(2)
|
||||
expect(requests.at(1)).toBeGreaterThanOrEqual((requests.at(0) ?? Infinity) + 180)
|
||||
expect((yield* catalog.model.get(providerID, modelID))?.name).toBe("Example Chat")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live(
|
||||
"backs off repeated background failures before recovering",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const requests = yield* connect((_request, attempt) =>
|
||||
attempt < 7 ? new Response("Unavailable", { status: 503 }) : inventory(),
|
||||
)
|
||||
const catalog = yield* Catalog.Service
|
||||
const bus = yield* Bus.Service
|
||||
yield* addPlugin()
|
||||
const published = yield* bus
|
||||
.subscribe(Catalog.Event.Updated)
|
||||
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped({ startImmediately: true }))
|
||||
|
||||
yield* Fiber.join(published)
|
||||
|
||||
expect(requests).toHaveLength(7)
|
||||
expect(requests.at(6)).toBeGreaterThanOrEqual((requests.at(5) ?? Infinity) + 9800)
|
||||
expect((yield* catalog.model.get(providerID, modelID))?.name).toBe("Example Chat")
|
||||
}),
|
||||
20_000,
|
||||
)
|
||||
|
||||
it.live(
|
||||
"recovers after setup without changing credentials and replays later catalog policy",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const requested = Promise.withResolvers<void>()
|
||||
const release = Promise.withResolvers<void>()
|
||||
const retried = Promise.withResolvers<void>()
|
||||
const requests = yield* connect(async (_request, attempt) => {
|
||||
if (attempt <= 3) return new Response("Unavailable", { status: 503 })
|
||||
if (attempt > 4) retried.resolve()
|
||||
requested.resolve()
|
||||
await release.promise
|
||||
return inventory()
|
||||
})
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => release.resolve()))
|
||||
const catalog = yield* Catalog.Service
|
||||
const credentials = yield* Credential.Service
|
||||
const bus = yield* Bus.Service
|
||||
const before = yield* credentials.list(Integration.ID.make("opencode"))
|
||||
|
||||
yield* addPlugin().pipe(Effect.timeout("6 seconds"))
|
||||
const ready = performance.now()
|
||||
expect(requests).toHaveLength(3)
|
||||
expect(requests.at(1)).toBeGreaterThanOrEqual((requests.at(0) ?? Infinity) + 180)
|
||||
expect(requests.at(2)).toBeGreaterThanOrEqual((requests.at(1) ?? Infinity) + 380)
|
||||
expect(yield* catalog.model.get(providerID, modelID)).toBeUndefined()
|
||||
yield* catalog.transform((draft) => {
|
||||
draft.model.remove(providerID, hiddenID)
|
||||
if (!draft.model.get(providerID, modelID)) return
|
||||
draft.model.update(providerID, modelID, (model) => {
|
||||
model.name = "Policy Chat"
|
||||
})
|
||||
})
|
||||
|
||||
const published = yield* bus
|
||||
.subscribe(Catalog.Event.Updated)
|
||||
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped({ startImmediately: true }))
|
||||
yield* Effect.promise(() => requested.promise).pipe(Effect.timeout("6 seconds"))
|
||||
expect(requests).toHaveLength(4)
|
||||
expect(requests.at(3)).toBeGreaterThanOrEqual(ready + 4800)
|
||||
expect(yield* catalog.model.get(providerID, modelID)).toBeUndefined()
|
||||
release.resolve()
|
||||
yield* Fiber.join(published).pipe(Effect.timeout("2 seconds"))
|
||||
|
||||
expect((yield* catalog.model.get(providerID, modelID))?.name).toBe("Policy Chat")
|
||||
expect(yield* catalog.model.get(providerID, hiddenID)).toBeUndefined()
|
||||
expect((yield* catalog.model.available()).map((model) => model.id)).toEqual([modelID])
|
||||
expect(yield* credentials.list(Integration.ID.make("opencode"))).toEqual(before)
|
||||
expect(yield* Effect.promise(() => retried.promise).pipe(Effect.timeoutOption("5500 millis"))).toEqual(
|
||||
Option.none(),
|
||||
)
|
||||
}),
|
||||
15_000,
|
||||
)
|
||||
|
||||
Object.entries({
|
||||
"401": () => new Response("Unauthorized", { status: 401 }),
|
||||
"403": () => new Response("Forbidden", { status: 403 }),
|
||||
"schema decode failure": () => Response.json({ config: { provider: false } }),
|
||||
}).forEach(([name, respond]) => {
|
||||
it.live(`does not retry ${name} during initial load`, () =>
|
||||
Effect.gen(function* () {
|
||||
const requests = yield* connect(respond)
|
||||
const catalog = yield* Catalog.Service
|
||||
|
||||
yield* addPlugin()
|
||||
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(yield* catalog.model.get(providerID, modelID)).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it.live(
|
||||
"stops background retries when an exhausted outage becomes nonretryable",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const rejected = Promise.withResolvers<void>()
|
||||
const retried = Promise.withResolvers<void>()
|
||||
const requests = yield* connect((_request, attempt) => {
|
||||
if (attempt <= 3) return new Response("Unavailable", { status: 503 })
|
||||
if (attempt > 4) retried.resolve()
|
||||
rejected.resolve()
|
||||
return new Response("Unauthorized", { status: 401 })
|
||||
})
|
||||
const catalog = yield* Catalog.Service
|
||||
|
||||
yield* addPlugin().pipe(Effect.timeout("6 seconds"))
|
||||
expect(requests).toHaveLength(3)
|
||||
yield* Effect.promise(() => rejected.promise).pipe(Effect.timeout("6 seconds"))
|
||||
expect(requests).toHaveLength(4)
|
||||
expect(yield* Effect.promise(() => retried.promise).pipe(Effect.timeoutOption("5500 millis"))).toEqual(
|
||||
Option.none(),
|
||||
)
|
||||
expect(yield* catalog.model.get(providerID, modelID)).toBeUndefined()
|
||||
}),
|
||||
15_000,
|
||||
)
|
||||
|
||||
it.live(
|
||||
"bounds a pending initial load and aborts its request",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const aborted = Promise.withResolvers<void>()
|
||||
const release = Promise.withResolvers<void>()
|
||||
const requests = yield* connect(async (request) => {
|
||||
request.signal.addEventListener("abort", () => aborted.resolve(), { once: true })
|
||||
await release.promise
|
||||
return inventory()
|
||||
})
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => release.resolve()))
|
||||
const catalog = yield* Catalog.Service
|
||||
|
||||
yield* addPlugin().pipe(Effect.timeout("6 seconds"))
|
||||
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(performance.now()).toBeGreaterThanOrEqual((requests.at(0) ?? Infinity) + 4800)
|
||||
yield* Effect.promise(() => aborted.promise).pipe(Effect.timeout("1 second"))
|
||||
expect(yield* catalog.model.get(providerID, modelID)).toBeUndefined()
|
||||
}),
|
||||
10_000,
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user