mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-27 20:16:17 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3387eb70df | ||
|
|
68ae314190 | ||
|
|
3b707c5a1d | ||
|
|
5093b8d92a |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@opencode-ai/core": patch
|
||||
---
|
||||
|
||||
Refresh Console model inventories after Session moves into cached Locations and retry missing selected models once, without bypassing destination policy. Retain cached inventory on same-account refresh failures.
|
||||
@@ -1,7 +1,7 @@
|
||||
export * as Catalog from "./catalog.js"
|
||||
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Array, Context, Effect, Layer, Order, pipe } from "effect"
|
||||
import { Array, Context, Effect, Layer, Order, pipe, Scope, Semaphore } from "effect"
|
||||
import { Catalog } from "@opencode-ai/schema/catalog"
|
||||
import { Model } from "./model.js"
|
||||
import { Provider } from "./provider.js"
|
||||
@@ -42,6 +42,9 @@ export type Draft = {
|
||||
}
|
||||
|
||||
export interface Interface extends State.Transformable<Draft> {
|
||||
/** Internal sources update captured data and report whether ordered transforms need replaying. */
|
||||
readonly onRefresh: (source: () => Effect.Effect<boolean>) => Effect.Effect<void, never, Scope.Scope>
|
||||
readonly refresh: () => Effect.Effect<void>
|
||||
readonly provider: {
|
||||
readonly get: (providerID: Provider.ID) => Effect.Effect<Provider.Info | undefined>
|
||||
readonly all: () => Effect.Effect<Provider.Info[]>
|
||||
@@ -63,6 +66,8 @@ const layer = Layer.effect(
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const integrations = yield* Integration.Service
|
||||
const sources = new Set<() => Effect.Effect<boolean>>()
|
||||
const refreshing = Semaphore.makeUnsafe(1)
|
||||
|
||||
const available = (provider: Provider.Info, integration: Integration.Info | undefined) => {
|
||||
if (provider.activation === "disabled") return false
|
||||
@@ -138,9 +143,27 @@ const layer = Layer.effect(
|
||||
yield* bus.publish(Catalog.Event.Updated, {})
|
||||
}),
|
||||
})
|
||||
const refresh = Effect.fn("Catalog.refresh")(() =>
|
||||
refreshing.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const changed = yield* Effect.forEach(sources, (source) => source())
|
||||
// Keep the permit until captured changes are visible, even if the caller is interrupted.
|
||||
if (changed.some(Boolean)) yield* state.reload().pipe(Effect.uninterruptible)
|
||||
}),
|
||||
),
|
||||
)
|
||||
const result: Interface = {
|
||||
transform: state.transform,
|
||||
reload: state.reload,
|
||||
onRefresh: (source) =>
|
||||
Effect.acquireRelease(
|
||||
Effect.sync(() => sources.add(source)),
|
||||
() =>
|
||||
Effect.sync(() => {
|
||||
sources.delete(source)
|
||||
}),
|
||||
).pipe(Effect.asVoid),
|
||||
refresh,
|
||||
|
||||
provider: {
|
||||
get: Effect.fn("Catalog.provider.get")(function* (providerID) {
|
||||
|
||||
@@ -38,6 +38,7 @@ import { SessionRunnerLLM } from "./session/runner/llm.js"
|
||||
import { SessionRunnerModel } from "./session/runner/model.js"
|
||||
import { SessionModelTransport } from "./session/model-transport.js"
|
||||
import { SessionCompaction } from "./session/compaction.js"
|
||||
import { SessionContext } from "./session/context.js"
|
||||
import { SessionTitle } from "./session/title.js"
|
||||
import { Skill } from "./skill.js"
|
||||
import { SkillInstructions } from "./skill/instructions.js"
|
||||
@@ -96,6 +97,7 @@ const locationServiceNodes = [
|
||||
ReadToolFileSystem.node,
|
||||
McpTool.node,
|
||||
SessionInstructions.node,
|
||||
SessionContext.node,
|
||||
SessionRunnerModel.node,
|
||||
SessionModelTransport.node,
|
||||
SessionCompaction.node,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Duration, Effect, Schema, Semaphore, Stream } from "effect"
|
||||
import { Duration, Effect, 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 { Bus } from "../../bus.js"
|
||||
import { Catalog } from "../../catalog.js"
|
||||
import { Credential } from "../../credential.js"
|
||||
import { Integration } from "../../integration.js"
|
||||
import { Model } from "../../model.js"
|
||||
@@ -12,6 +13,8 @@ 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 { SessionEvent } from "../../session/event.js"
|
||||
import { isDeepStrictEqual } from "node:util"
|
||||
|
||||
const defaultServer = "https://opencode.ai/console"
|
||||
const clientID = "opencode-cli"
|
||||
@@ -82,28 +85,34 @@ function oauth(http: HttpClient.HttpClient) {
|
||||
} satisfies IntegrationOAuthMethodRegistration
|
||||
}
|
||||
|
||||
export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope.Scope>({
|
||||
export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Catalog.Service | Scope.Scope>({
|
||||
id: "opencode.provider.opencode",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const bus = yield* Bus.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
const http = yield* HttpClient.HttpClient
|
||||
const loading = Semaphore.makeUnsafe(1)
|
||||
let connected = false
|
||||
let providers: typeof ConfigV1.Info.Type.provider | undefined
|
||||
let source: string | 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(
|
||||
const id = connection?.type === "credential" ? connection.id : connection?.name
|
||||
const next = connection
|
||||
? yield* ctx.integration.connection.resolve(connection).pipe(
|
||||
Effect.flatMap((credential) => (credential ? fetchProviders(http, credential) : Effect.undefined)),
|
||||
Effect.timeout("5 seconds"),
|
||||
Effect.catch((cause) =>
|
||||
Effect.logWarning("failed to load OpenCode provider config", { cause }).pipe(Effect.as(undefined)),
|
||||
// Retain inventory through same-account outages, never across an account switch.
|
||||
Effect.logWarning("failed to load OpenCode provider config", { cause }).pipe(
|
||||
Effect.as(id === source ? providers : undefined),
|
||||
),
|
||||
),
|
||||
)
|
||||
: undefined
|
||||
const changed = source !== id || !isDeepStrictEqual(providers, next)
|
||||
source = id
|
||||
providers = next
|
||||
return changed
|
||||
})
|
||||
|
||||
yield* ctx.integration.transform((draft) => {
|
||||
@@ -176,7 +185,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 || source !== undefined || item.provider.settings?.apiKey)
|
||||
catalog.provider.update(item.provider.id, (provider) => {
|
||||
if (!hasKey) {
|
||||
provider.activation = "enabled"
|
||||
@@ -192,10 +201,15 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
|
||||
}
|
||||
})
|
||||
|
||||
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload())))
|
||||
yield* bus.subscribe(Credential.Event.Switched).pipe(
|
||||
Stream.filter((event) => event.data.integrationID === Integration.ID.make("opencode")),
|
||||
Stream.runForEach(refresh),
|
||||
yield* catalog.onRefresh(load)
|
||||
yield* bus.subscribe([Credential.Event.Switched, SessionEvent.Moved]).pipe(
|
||||
Stream.filter((event) =>
|
||||
event.type === "credential.switched"
|
||||
? event.data.integrationID === Integration.ID.make("opencode")
|
||||
: event.data.location.directory === ctx.location.directory &&
|
||||
event.data.location.workspaceID === ctx.location.workspaceID,
|
||||
),
|
||||
Stream.runForEach(() => catalog.refresh()),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
}),
|
||||
|
||||
@@ -91,7 +91,15 @@ const layer = Layer.effect(
|
||||
const store = yield* SessionStore.Service
|
||||
const registry = yield* Tool.Service
|
||||
|
||||
const resolveModel = (session: SessionSchema.Info) => models.resolve(session, catalog.model.available)
|
||||
const resolveModel = Effect.fn("SessionContext.resolveModel")(function* (session: SessionSchema.Info) {
|
||||
const resolve = models.resolve(session, catalog.model.available)
|
||||
// Retry once against a refreshed inventory without bypassing Location policy.
|
||||
return yield* resolve.pipe(
|
||||
Effect.catchTag("SessionRunnerModel.ModelUnavailableError", () =>
|
||||
catalog.refresh().pipe(Effect.andThen(resolve)),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const selectTitle = Effect.fn("SessionContext.selectTitle")(function* (session: SessionSchema.Info) {
|
||||
const agent = yield* agents.get(Agent.ID.make("title"))
|
||||
|
||||
@@ -30,6 +30,70 @@ const catalogLayer = AppNodeBuilder.build(
|
||||
const it = testEffect(catalogLayer)
|
||||
|
||||
describe("Catalog", () => {
|
||||
it.effect("refreshes scoped sources and replays only changed snapshots", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const source = { fresh: false, calls: 0 }
|
||||
yield* catalog.transform((draft) => {
|
||||
if (!source.fresh) return
|
||||
draft.model.update(Provider.ID.make("example"), Model.ID.make("chat"), () => {})
|
||||
draft.model.default.set(Provider.ID.make("example"), Model.ID.make("chat"))
|
||||
})
|
||||
yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
yield* catalog.onRefresh(() =>
|
||||
Effect.sync(() => {
|
||||
source.calls++
|
||||
if (source.fresh) return false
|
||||
source.fresh = true
|
||||
return true
|
||||
}),
|
||||
)
|
||||
expect(yield* catalog.model.default()).toBeUndefined()
|
||||
const refresh = yield* catalog.refresh().pipe(Effect.forkScoped)
|
||||
yield* TestClock.adjust("1 second")
|
||||
yield* Fiber.join(refresh)
|
||||
expect((yield* catalog.model.default())?.id).toBe(Model.ID.make("chat"))
|
||||
expect(source.calls).toBe(1)
|
||||
yield* catalog.refresh()
|
||||
expect(source.calls).toBe(2)
|
||||
}),
|
||||
)
|
||||
yield* catalog.refresh()
|
||||
expect(source.calls).toBe(2)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("finishes replay before releasing an interrupted refresh", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const source = { fresh: false }
|
||||
yield* catalog.transform((draft) => {
|
||||
if (source.fresh) draft.model.update(Provider.ID.make("example"), Model.ID.make("chat"), () => {})
|
||||
})
|
||||
yield* catalog.onRefresh(() =>
|
||||
Effect.sync(() => {
|
||||
if (source.fresh) return false
|
||||
source.fresh = true
|
||||
return true
|
||||
}),
|
||||
)
|
||||
const refresh = yield* catalog.refresh().pipe(Effect.forkScoped({ startImmediately: true }))
|
||||
yield* TestClock.adjust("0 millis")
|
||||
const interrupted = yield* Fiber.interrupt(refresh).pipe(Effect.forkScoped({ startImmediately: true }))
|
||||
yield* TestClock.adjust("0 millis")
|
||||
const retry = yield* catalog.refresh().pipe(Effect.forkScoped({ startImmediately: true }))
|
||||
yield* TestClock.adjust("0 millis")
|
||||
expect(retry.pollUnsafe()).toBeUndefined()
|
||||
yield* TestClock.adjust("1 second")
|
||||
yield* Fiber.join(interrupted)
|
||||
yield* Fiber.join(retry)
|
||||
expect((yield* catalog.model.get(Provider.ID.make("example"), Model.ID.make("chat")))?.id).toBe(
|
||||
Model.ID.make("chat"),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("publishes an updated event after catalog changes", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Effect, Fiber, Stream } from "effect"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-services"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { SessionContext } from "@opencode-ai/core/session/context"
|
||||
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { tempGlobalLayer } from "./fixture/global"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Bus.node, Credential.node, Session.node, LocationServiceMap.node]), [
|
||||
[Global.node, tempGlobalLayer],
|
||||
]),
|
||||
)
|
||||
|
||||
it.live(
|
||||
"keeps a Console model available after moving into a cached Location",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const directories = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => Promise.all([tmpdir(), tmpdir()] as const)),
|
||||
(dirs) => Effect.promise(() => Promise.all(dirs.map((dir) => dir[Symbol.asyncDispose]()))),
|
||||
)
|
||||
const destination = Location.Ref.make({ directory: AbsolutePath.make(directories[0].path) })
|
||||
const source = Location.Ref.make({ directory: AbsolutePath.make(directories[1].path) })
|
||||
const inventory = { published: false, name: "Example Chat", requests: 0 }
|
||||
const requested = Promise.withResolvers<void>()
|
||||
const release = Promise.withResolvers<void>()
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => release.resolve()))
|
||||
const server = yield* Effect.acquireRelease(
|
||||
Effect.sync(() =>
|
||||
Bun.serve({
|
||||
port: 0,
|
||||
fetch: async (request) => {
|
||||
inventory.requests++
|
||||
if (inventory.requests === 3) {
|
||||
requested.resolve()
|
||||
await release.promise
|
||||
}
|
||||
return Response.json({
|
||||
config: {
|
||||
provider: {
|
||||
"example-console": {
|
||||
npm: "@ai-sdk/openai",
|
||||
api: `${new URL(request.url).origin}/v1`,
|
||||
models: inventory.published
|
||||
? { "example-chat": { name: inventory.name, variants: { swift: {} } } }
|
||||
: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
},
|
||||
}),
|
||||
),
|
||||
(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: "fixture-key", metadata: { server: server.url.origin } }),
|
||||
})
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const ready = Effect.gen(function* () {
|
||||
const plugins = yield* PluginSupervisor.Service
|
||||
yield* plugins.flush
|
||||
return yield* Catalog.Service
|
||||
})
|
||||
const cached = yield* ready.pipe(Effect.provide(locations.get(destination)))
|
||||
expect(inventory.requests).toBe(1)
|
||||
inventory.published = true
|
||||
yield* ready.pipe(Effect.provide(locations.get(source)))
|
||||
expect(inventory.requests).toBe(2)
|
||||
|
||||
const session = yield* Session.Service
|
||||
const model = Model.Ref.make({
|
||||
providerID: Provider.ID.make("example-console"),
|
||||
id: Model.ID.make("example-chat"),
|
||||
variant: Model.VariantID.make("swift"),
|
||||
})
|
||||
const created = yield* session.create({
|
||||
location: source,
|
||||
model,
|
||||
})
|
||||
const resolve = Effect.gen(function* () {
|
||||
const current = yield* session.get(created.id)
|
||||
return yield* Effect.gen(function* () {
|
||||
const plugins = yield* PluginSupervisor.Service
|
||||
yield* plugins.flush
|
||||
const context = yield* SessionContext.Service
|
||||
return yield* context.resolveModel(current)
|
||||
}).pipe(Effect.provide(locations.get(current.location)))
|
||||
})
|
||||
expect((yield* resolve).ref).toEqual(model)
|
||||
const bus = yield* Bus.Service
|
||||
const updated = yield* bus.subscribe(Catalog.Event.Updated).pipe(
|
||||
Stream.filter((event) => event.location?.directory === destination.directory),
|
||||
Stream.take(1),
|
||||
Stream.mapEffect((event) => cached.model.available().pipe(Effect.map((models) => ({ event, models })))),
|
||||
Stream.runCollect,
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
yield* session.move({ sessionID: created.id, directory: destination.directory })
|
||||
yield* session.resume(created.id)
|
||||
expect((yield* session.get(created.id)).location).toEqual(destination)
|
||||
// The idle move starts the fetch; a lookup during that fetch must wait for replay, not reject the old snapshot.
|
||||
yield* Effect.promise(() => requested.promise).pipe(Effect.timeout("2 seconds"))
|
||||
const continuation = yield* resolve.pipe(Effect.forkScoped({ startImmediately: true }))
|
||||
yield* Effect.yieldNow
|
||||
expect(continuation.pollUnsafe()).toBeUndefined()
|
||||
release.resolve()
|
||||
const updates = yield* Fiber.join(updated).pipe(Effect.timeout("2 seconds"))
|
||||
expect(updates[0]?.event.location).toEqual(destination)
|
||||
expect(updates[0]?.models.map((item) => item.id)).toContain(model.id)
|
||||
expect((yield* Fiber.join(continuation)).ref).toEqual(model)
|
||||
expect(yield* ready.pipe(Effect.provide(locations.get(destination)))).toBe(cached)
|
||||
expect(inventory.requests).toBe(4)
|
||||
|
||||
yield* cached.transform((draft) =>
|
||||
draft.model.update(model.providerID, model.id, (item) => {
|
||||
item.enabled = false
|
||||
}),
|
||||
)
|
||||
inventory.name = "Updated Example Chat"
|
||||
expect(yield* resolve.pipe(Effect.flip)).toBeInstanceOf(SessionRunnerModel.ModelUnavailableError)
|
||||
expect((yield* cached.model.get(model.providerID, model.id))?.name).toBe(inventory.name)
|
||||
expect(inventory.requests).toBe(5)
|
||||
}),
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Fiber, Stream } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
@@ -360,6 +360,127 @@ describe("OpencodePlugin", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("loads the new account after a switch during an in-flight refresh", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const credentials = yield* Credential.Service
|
||||
const requested = Promise.withResolvers<void>()
|
||||
const release = Promise.withResolvers<void>()
|
||||
const started = Promise.withResolvers<void>()
|
||||
const completed = Promise.withResolvers<void>()
|
||||
const requests: string[] = []
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => release.resolve()))
|
||||
const server = yield* Effect.acquireRelease(
|
||||
Effect.sync(() =>
|
||||
Bun.serve({
|
||||
port: 0,
|
||||
fetch: async (request) => {
|
||||
const account = request.headers.get("authorization") === "Bearer account-a" ? "account-a" : "account-b"
|
||||
requests.push(account)
|
||||
if (requests.length === 2) {
|
||||
requested.resolve()
|
||||
await release.promise
|
||||
}
|
||||
return Response.json({
|
||||
config: { provider: { example: { models: { [account]: { name: account } } } } },
|
||||
})
|
||||
},
|
||||
}),
|
||||
),
|
||||
(server) => Effect.promise(() => server.stop(true)),
|
||||
)
|
||||
const create = (key: string) =>
|
||||
credentials.create({
|
||||
integrationID: Integration.ID.make("opencode"),
|
||||
value: Credential.Key.make({ type: "key", key, metadata: { server: server.url.origin } }),
|
||||
})
|
||||
yield* create("account-a")
|
||||
// Observe the event caller entering and leaving the real refresh boundary.
|
||||
yield* addPlugin().pipe(
|
||||
Effect.provideService(Catalog.Service, {
|
||||
...catalog,
|
||||
refresh: () =>
|
||||
Effect.gen(function* () {
|
||||
started.resolve()
|
||||
yield* catalog.refresh()
|
||||
completed.resolve()
|
||||
}),
|
||||
}),
|
||||
)
|
||||
const refresh = yield* catalog.refresh().pipe(Effect.forkScoped({ startImmediately: true }))
|
||||
yield* Effect.promise(() => requested.promise)
|
||||
yield* create("account-b")
|
||||
yield* Effect.promise(() => started.promise)
|
||||
release.resolve()
|
||||
yield* Fiber.join(refresh)
|
||||
yield* Effect.promise(() => completed.promise)
|
||||
expect(requests).toEqual(["account-a", "account-a", "account-b"])
|
||||
expect((yield* catalog.model.available()).map((model) => model.id)).toEqual([Model.ID.make("account-b")])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("retains same-account inventory on failure but clears it after a failed account switch", () =>
|
||||
Effect.gen(function* () {
|
||||
const inventory = { fail: false, requests: 0 }
|
||||
const server = yield* Effect.acquireRelease(
|
||||
Effect.sync(() =>
|
||||
Bun.serve({
|
||||
port: 0,
|
||||
fetch: () => {
|
||||
inventory.requests++
|
||||
if (inventory.fail) return new Response("Unavailable", { status: 503 })
|
||||
return Response.json({
|
||||
config: { provider: { example: { models: { chat: { name: "Example Chat" } } } } },
|
||||
})
|
||||
},
|
||||
}),
|
||||
),
|
||||
(server) => Effect.promise(() => server.stop(true)),
|
||||
)
|
||||
const credentials = yield* Credential.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
const bus = yield* Bus.Service
|
||||
yield* credentials.create({
|
||||
integrationID: Integration.ID.make("opencode"),
|
||||
value: Credential.Key.make({ type: "key", key: "first", metadata: { server: server.url.origin } }),
|
||||
})
|
||||
yield* addPlugin()
|
||||
const updates: number[] = []
|
||||
yield* bus.subscribe(Catalog.Event.Updated).pipe(
|
||||
Stream.runForEach((event) =>
|
||||
Effect.sync(() => {
|
||||
updates.push(event.created)
|
||||
}),
|
||||
),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
yield* catalog.model.available()
|
||||
expect(inventory.requests).toBe(1)
|
||||
yield* catalog.refresh()
|
||||
expect(inventory.requests).toBe(2)
|
||||
expect(updates).toEqual([])
|
||||
|
||||
inventory.fail = true
|
||||
yield* catalog.refresh()
|
||||
expect((yield* catalog.model.get(Provider.ID.make("example"), Model.ID.make("chat")))?.name).toBe("Example Chat")
|
||||
expect(updates).toEqual([])
|
||||
|
||||
yield* credentials.create({
|
||||
integrationID: Integration.ID.make("opencode"),
|
||||
value: Credential.Key.make({ type: "key", key: "second", metadata: { server: server.url.origin } }),
|
||||
})
|
||||
yield* eventually(
|
||||
catalog.model.get(Provider.ID.make("example"), Model.ID.make("chat")),
|
||||
(model) => model === undefined,
|
||||
)
|
||||
expect(inventory.requests).toBe(4)
|
||||
inventory.fail = false
|
||||
yield* catalog.refresh()
|
||||
expect((yield* catalog.model.get(Provider.ID.make("example"), Model.ID.make("chat")))?.name).toBe("Example Chat")
|
||||
expect(inventory.requests).toBe(5)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses a public key and disables paid models without credentials", () =>
|
||||
withEnv({ OPENCODE_API_KEY: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
Reference in New Issue
Block a user