| import { describe, expect } from "bun:test" |
| import { Effect, Fiber, Layer, Stream } from "effect" |
| import { Catalog } from "@opencode-ai/core/catalog" |
| import { Integration } from "@opencode-ai/core/integration" |
| import { Credential } from "@opencode-ai/core/credential" |
| import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" |
| import { LayerNode } from "@opencode-ai/core/effect/layer-node" |
| import { EventV2 } from "@opencode-ai/core/event" |
| import { Location } from "@opencode-ai/core/location" |
| import { ModelV2 } from "@opencode-ai/core/model" |
| import { Policy } from "@opencode-ai/core/policy" |
| import { ProviderV2 } from "@opencode-ai/core/provider" |
| import { AbsolutePath } from "@opencode-ai/core/schema" |
| import { location } from "./fixture/location" |
| import { testEffect } from "./lib/effect" |
|
|
| function required<T>(value: T | undefined): T { |
| if (value === undefined) throw new Error("Expected value") |
| return value |
| } |
|
|
| const locationLayer = Layer.succeed( |
| Location.Service, |
| Location.Service.of(location({ directory: AbsolutePath.make("test") })), |
| ) |
| const catalogLayer = AppNodeBuilder.build( |
| LayerNode.group([Catalog.node, EventV2.node, Credential.node, Integration.node, Policy.node]), |
| [[Location.node, locationLayer]], |
| ) |
| const it = testEffect(catalogLayer) |
|
|
| describe("CatalogV2", () => { |
| it.effect("publishes an updated event after catalog changes", () => |
| Effect.gen(function* () { |
| const catalog = yield* Catalog.Service |
| const events = yield* EventV2.Service |
| const updated = yield* events |
| .subscribe(Catalog.Event.Updated) |
| .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped) |
| yield* Effect.yieldNow |
|
|
| yield* catalog.transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {})) |
|
|
| expect((yield* Fiber.join(updated)).length).toBe(1) |
| }), |
| ) |
|
|
| it.effect("derives availability from active credentials without changing provider state", () => { |
| const integrationID = Integration.ID.make("test") |
| const localCatalogLayer = Layer.fresh( |
| AppNodeBuilder.build(LayerNode.group([Catalog.node, Credential.node]), [[Location.node, locationLayer]]), |
| ) |
|
|
| return Effect.gen(function* () { |
| const catalog = yield* Catalog.Service |
| const credentials = yield* Credential.Service |
| yield* catalog.transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {})) |
| yield* credentials.create({ |
| integrationID, |
| label: "First", |
| value: Credential.Key.make({ type: "key", key: "first", metadata: { tenant: "one" } }), |
| }) |
|
|
| expect((yield* catalog.provider.available()).map((provider) => provider.id)).toEqual([ProviderV2.ID.make("test")]) |
| expect(required(yield* catalog.provider.get(ProviderV2.ID.make("test"))).request.body).toEqual({}) |
| yield* credentials.create({ |
| integrationID, |
| label: "Second", |
| value: Credential.Key.make({ type: "key", key: "second", metadata: { tenant: "two" } }), |
| }) |
| expect((yield* catalog.provider.available()).map((provider) => provider.id)).toEqual([ProviderV2.ID.make("test")]) |
| expect(required(yield* catalog.provider.get(ProviderV2.ID.make("test"))).request.body).toEqual({}) |
| }).pipe(Effect.provide(localCatalogLayer)) |
| }) |
|
|
| it.effect("derives availability from a provider's integration", () => { |
| const integrationID = Integration.ID.make("gateway") |
| const providerID = ProviderV2.ID.make("remote") |
| const localCatalogLayer = Layer.fresh( |
| AppNodeBuilder.build(LayerNode.group([Catalog.node, Credential.node, Integration.node]), [ |
| [Location.node, locationLayer], |
| ]), |
| ) |
|
|
| return Effect.gen(function* () { |
| const catalog = yield* Catalog.Service |
| yield* (yield* Integration.Service).transform((editor) => editor.update(integrationID, () => {})) |
| yield* catalog.transform((editor) => |
| editor.provider.update(providerID, (provider) => { |
| provider.integrationID = integrationID |
| }), |
| ) |
| expect(yield* catalog.provider.available()).toEqual([]) |
|
|
| yield* (yield* Credential.Service).create({ |
| integrationID, |
| value: Credential.Key.make({ type: "key", key: "secret" }), |
| }) |
|
|
| expect((yield* catalog.provider.available()).map((provider) => provider.id)).toEqual([providerID]) |
| }).pipe(Effect.provide(localCatalogLayer)) |
| }) |
|
|
| it.effect("projects environment connections without a catalog plugin", () => |
| Effect.acquireUseRelease( |
| Effect.sync(() => { |
| const previous = process.env.CATALOG_TEST_API_KEY |
| process.env.CATALOG_TEST_API_KEY = "secret" |
| return previous |
| }), |
| () => |
| Effect.gen(function* () { |
| const catalog = yield* Catalog.Service |
| const integrations = yield* Integration.Service |
| const providerID = ProviderV2.ID.make("test") |
| yield* integrations.transform((editor) => |
| editor.method.update({ |
| integrationID: Integration.ID.make(providerID), |
| method: { type: "env", names: ["CATALOG_TEST_API_KEY"] }, |
| }), |
| ) |
| yield* catalog.transform((editor) => editor.provider.update(providerID, () => {})) |
|
|
| expect((yield* catalog.provider.available()).map((provider) => provider.id)).toContain(providerID) |
| }), |
| (previous) => |
| Effect.sync(() => { |
| if (previous === undefined) delete process.env.CATALOG_TEST_API_KEY |
| else process.env.CATALOG_TEST_API_KEY = previous |
| }), |
| ), |
| ) |
|
|
| it.effect("normalizes provider baseURL into api url", () => |
| Effect.gen(function* () { |
| const catalog = yield* Catalog.Service |
| const providerID = ProviderV2.ID.make("test") |
| yield* catalog.transform((catalog) => |
| catalog.provider.update(providerID, (provider) => { |
| provider.api = { |
| type: "aisdk", |
| package: "@ai-sdk/openai-compatible", |
| url: "https://default.example.com", |
| } |
| provider.request.body.baseURL = "https://override.example.com" |
| }), |
| ) |
|
|
| expect(required(yield* catalog.provider.get(providerID)).api).toEqual({ |
| type: "aisdk", |
| package: "@ai-sdk/openai-compatible", |
| url: "https://override.example.com", |
| }) |
| }), |
| ) |
|
|
| it.effect("normalizes model baseURL into api url", () => |
| Effect.gen(function* () { |
| const catalog = yield* Catalog.Service |
| const providerID = ProviderV2.ID.make("test") |
| const modelID = ModelV2.ID.make("model") |
| yield* catalog.transform((catalog) => { |
| catalog.provider.update(providerID, (provider) => { |
| provider.api = { |
| type: "aisdk", |
| package: "@ai-sdk/openai-compatible", |
| url: "https://provider.example.com", |
| } |
| }) |
| catalog.model.update(providerID, modelID, (model) => { |
| model.api = { |
| id: modelID, |
| type: "aisdk", |
| package: "@ai-sdk/openai-compatible", |
| url: "https://model.example.com", |
| } |
| model.request.body.baseURL = "https://override.example.com" |
| }) |
| }) |
|
|
| expect(required(yield* catalog.model.get(providerID, modelID)).api).toEqual({ |
| id: modelID, |
| type: "aisdk", |
| package: "@ai-sdk/openai-compatible", |
| url: "https://override.example.com", |
| settings: {}, |
| }) |
| }), |
| ) |
|
|
| it.effect("resolves default model api from provider api", () => |
| Effect.gen(function* () { |
| const catalog = yield* Catalog.Service |
| const providerID = ProviderV2.ID.make("test") |
| const modelID = ModelV2.ID.make("model") |
| yield* catalog.transform((catalog) => { |
| catalog.provider.update(providerID, (provider) => { |
| provider.api = { |
| type: "aisdk", |
| package: "@ai-sdk/openai-compatible", |
| url: "https://provider.example.com", |
| } |
| }) |
| catalog.model.update(providerID, modelID, () => {}) |
| }) |
|
|
| expect(required(yield* catalog.model.get(providerID, modelID)).api).toEqual({ |
| id: modelID, |
| type: "aisdk", |
| package: "@ai-sdk/openai-compatible", |
| url: "https://provider.example.com", |
| }) |
| }), |
| ) |
|
|
| it.effect("resolves provider and model request merges", () => |
| Effect.gen(function* () { |
| const catalog = yield* Catalog.Service |
| const providerID = ProviderV2.ID.make("test") |
| const modelID = ModelV2.ID.make("model") |
| yield* catalog.transform((catalog) => { |
| catalog.provider.update(providerID, (provider) => { |
| provider.request.headers.provider = "provider" |
| provider.request.headers.shared = "provider" |
| provider.request.body.provider = true |
| }) |
| catalog.model.update(providerID, modelID, (model) => { |
| model.request.headers.model = "model" |
| model.request.headers.shared = "model" |
| model.request.body.model = true |
| model.request.body.request = true |
| model.request.body.shared = "model" |
| }) |
| }) |
|
|
| const model = required(yield* catalog.model.get(providerID, modelID)) |
| expect(model.request.headers).toEqual({ provider: "provider", shared: "model", model: "model" }) |
| expect(model.request.body).toEqual({ provider: true, model: true, request: true, shared: "model" }) |
| }), |
| ) |
|
|
| it.effect("falls back to newest available model when no default is configured", () => |
| Effect.gen(function* () { |
| const catalog = yield* Catalog.Service |
| const providerID = ProviderV2.ID.make("test") |
| yield* catalog.transform((catalog) => { |
| catalog.provider.update(providerID, () => {}) |
| catalog.model.update(providerID, ModelV2.ID.make("old"), (model) => { |
| model.time.released = 1000 |
| }) |
| catalog.model.update(providerID, ModelV2.ID.make("new"), (model) => { |
| model.time.released = 2000 |
| }) |
| }) |
|
|
| expect((yield* catalog.model.default())?.id).toMatch("new") |
| }), |
| ) |
|
|
| it.effect("uses a transform-provided default model until that transform is replaced", () => |
| Effect.gen(function* () { |
| const catalog = yield* Catalog.Service |
| const providerID = ProviderV2.ID.make("test") |
| const old = ModelV2.ID.make("old") |
| const newest = ModelV2.ID.make("new") |
| const models = (catalog: Catalog.Draft) => { |
| catalog.provider.update(providerID, () => {}) |
| catalog.model.update(providerID, old, (model) => { |
| model.time.released = 1000 |
| }) |
| catalog.model.update(providerID, newest, (model) => { |
| model.time.released = 2000 |
| }) |
| } |
|
|
| let configured = true |
| yield* catalog.transform((catalog) => { |
| models(catalog) |
| if (configured) catalog.model.default.set(providerID, old) |
| }) |
| expect((yield* catalog.model.default())?.id).toBe(old) |
|
|
| configured = false |
| yield* catalog.reload() |
| expect((yield* catalog.model.default())?.id).toBe(newest) |
| }), |
| ) |
|
|
| it.effect("ignores a configured default on a disabled provider", () => |
| Effect.gen(function* () { |
| const catalog = yield* Catalog.Service |
| const disabledProvider = ProviderV2.ID.make("disabled") |
| const enabledProvider = ProviderV2.ID.make("enabled") |
| const disabledModel = ModelV2.ID.make("configured") |
| const fallbackModel = ModelV2.ID.make("fallback") |
| yield* catalog.transform((catalog) => { |
| catalog.provider.update(disabledProvider, (provider) => { |
| provider.disabled = true |
| }) |
| catalog.model.update(disabledProvider, disabledModel, () => {}) |
| catalog.provider.update(enabledProvider, () => {}) |
| catalog.model.update(enabledProvider, fallbackModel, () => {}) |
| catalog.model.default.set(disabledProvider, disabledModel) |
| }) |
|
|
| expect(yield* catalog.model.default()).toMatchObject({ |
| providerID: enabledProvider, |
| id: fallbackModel, |
| }) |
| }), |
| ) |
|
|
| it.effect("small model prefers small keyword candidates before cost scoring", () => |
| Effect.gen(function* () { |
| const catalog = yield* Catalog.Service |
| const providerID = ProviderV2.ID.make("test") |
| yield* catalog.transform((catalog) => { |
| catalog.provider.update(providerID, () => {}) |
| catalog.model.update(providerID, ModelV2.ID.make("cheap-large"), (model) => { |
| model.capabilities.input = ["text"] |
| model.capabilities.output = ["text"] |
| model.cost = [{ input: 1, output: 1, cache: { read: 0, write: 0 } }] |
| model.time.released = Date.now() |
| }) |
| catalog.model.update(providerID, ModelV2.ID.make("expensive-mini"), (model) => { |
| model.capabilities.input = ["text"] |
| model.capabilities.output = ["text"] |
| model.cost = [{ input: 10, output: 10, cache: { read: 0, write: 0 } }] |
| model.time.released = Date.now() |
| }) |
| }) |
|
|
| expect((yield* catalog.model.small(providerID))?.id).toMatch("expensive-mini") |
| }), |
| ) |
|
|
| it.effect("removes providers denied by policy after loading", () => |
| Effect.gen(function* () { |
| const catalog = yield* Catalog.Service |
| const policy = yield* Policy.Service |
| const providerID = ProviderV2.ID.make("blocked") |
| yield* policy.load([new Policy.Info({ effect: "deny", action: "provider.use", resource: "blocked" })]) |
| yield* catalog.transform((catalog) => { |
| catalog.provider.update(providerID, () => {}) |
| catalog.model.update(providerID, ModelV2.ID.make("model"), () => {}) |
| }) |
|
|
| expect(yield* catalog.provider.all()).toEqual([]) |
| expect(yield* catalog.model.all()).toEqual([]) |
| expect(yield* catalog.provider.get(providerID)).toBeUndefined() |
| }), |
| ) |
| }) |
|
|