mirror of
https://github.com/anomalyco/opencode.git
synced 2026-10-24 19:50:00 +00:00
733 lines
30 KiB
TypeScript
733 lines
30 KiB
TypeScript
import { describe, expect } from "bun:test"
|
|
import { LanguageModel } from "@opencode/ai"
|
|
import { OpenAIChat } from "@opencode/ai/protocols"
|
|
import { Effect, Fiber, Layer, Ref, Stream } from "effect"
|
|
import { Integration } from "@opencode/core/integration"
|
|
import { Credential } from "@opencode/core/credential"
|
|
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
|
|
import { LayerNode } from "@opencode/util/effect/layer-node"
|
|
import { Bus } from "@opencode/core/bus"
|
|
import { Location } from "@opencode/core/location"
|
|
import { Model } from "@opencode/core/model"
|
|
import { ModelResolver } from "@opencode/core/model-resolver"
|
|
import { Provider } from "@opencode/core/provider"
|
|
import { AbsolutePath } from "@opencode/core/schema"
|
|
import { Money } from "@opencode/schema/money"
|
|
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 modelLayer = AppNodeBuilder.build(
|
|
LayerNode.group([Provider.node, Model.node, Bus.node, Credential.node, Integration.node]),
|
|
[Location.node.replace(locationLayer)],
|
|
)
|
|
const it = testEffect(modelLayer)
|
|
|
|
describe("Provider and Model", () => {
|
|
;["variant", "empty-key", "metadata", "aisdk"].forEach((path) =>
|
|
it.effect(`keeps nested model values editable after ${path} model resolution`, () =>
|
|
Effect.gen(function* () {
|
|
const providers = yield* Provider.Service
|
|
const models = yield* Model.Service
|
|
const providerID = Provider.ID.make("resolve-fixture")
|
|
const modelID = Model.ID.make("fixture-model")
|
|
yield* providers.transform((editor) => editor.update(providerID, () => {}))
|
|
yield* models.transform((editor) =>
|
|
editor.update(providerID, modelID, (model) => {
|
|
model.package = path === "aisdk" ? Provider.aisdk("@ai-sdk/fixture") : "@opencode/ai/providers/openai"
|
|
model.settings = {
|
|
apiKey: path === "empty-key" ? "" : "fixture-key",
|
|
baseURL: "https://fixture.example/v1",
|
|
}
|
|
model.variants = [{ id: Model.VariantID.make("high"), body: { reasoning: { effort: "high" } } }]
|
|
}),
|
|
)
|
|
const selected = required(yield* models.get(providerID, modelID))
|
|
if (path === "variant") yield* ModelResolver.withVariant(selected, Model.VariantID.make("high"))
|
|
if (path !== "variant")
|
|
yield* ModelResolver.fromCatalogModel(
|
|
selected,
|
|
path === "metadata"
|
|
? Credential.Key.make({ type: "key", key: "fixture-key", metadata: { tenant: "fixture" } })
|
|
: undefined,
|
|
{
|
|
loadAISDK: () =>
|
|
Effect.succeed(LanguageModel.make({ id: modelID, provider: providerID, route: OpenAIChat.route })),
|
|
},
|
|
)
|
|
|
|
yield* models.transform((editor) =>
|
|
editor.update(providerID, modelID, (model) => {
|
|
model.limit.context = 100_000
|
|
model.capabilities.tools = false
|
|
model.variants.push({ id: Model.VariantID.make("other") })
|
|
}),
|
|
)
|
|
expect(required(yield* models.get(providerID, modelID))).toMatchObject({
|
|
limit: { context: 100_000 },
|
|
capabilities: { tools: false },
|
|
variants: [{ id: "high" }, { id: "other" }],
|
|
})
|
|
}),
|
|
),
|
|
)
|
|
|
|
it.effect("publishes an updated event after provider changes", () =>
|
|
Effect.gen(function* () {
|
|
const providers = yield* Provider.Service
|
|
const bus = yield* Bus.Service
|
|
const updated = yield* bus
|
|
.subscribe(Provider.Event.Updated)
|
|
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
|
yield* Effect.yieldNow
|
|
|
|
yield* providers.transform((editor) => editor.update(Provider.ID.make("test"), () => {}))
|
|
|
|
expect((yield* Fiber.join(updated)).length).toBe(1)
|
|
}),
|
|
)
|
|
|
|
it.effect("preserves provider identity when updating new and existing providers", () =>
|
|
Effect.gen(function* () {
|
|
const providers = yield* Provider.Service
|
|
const providerID = Provider.ID.make("original")
|
|
const renamed = Provider.ID.make("renamed")
|
|
yield* providers.transform((editor) => {
|
|
editor.update(providerID, (provider) => {
|
|
provider.id = renamed
|
|
provider.name = "Created"
|
|
})
|
|
expect(editor.get(providerID)?.provider.id).toBe(providerID)
|
|
editor.update(providerID, (provider) => {
|
|
provider.id = renamed
|
|
provider.name = "Updated"
|
|
})
|
|
})
|
|
|
|
expect(yield* providers.get(providerID)).toMatchObject({ id: providerID, name: "Updated" })
|
|
expect(yield* providers.get(renamed)).toBeUndefined()
|
|
expect((yield* providers.all()).map((provider) => provider.id)).toEqual([providerID])
|
|
|
|
yield* providers.reload()
|
|
expect(yield* providers.get(providerID)).toMatchObject({ id: providerID, name: "Updated" })
|
|
}),
|
|
)
|
|
|
|
it.effect("derives availability from active credentials without changing provider state", () => {
|
|
const integrationID = Integration.ID.make("test")
|
|
const localProviderLayer = Layer.fresh(
|
|
AppNodeBuilder.build(LayerNode.group([Provider.node, Credential.node]), [Location.node.replace(locationLayer)]),
|
|
)
|
|
|
|
return Effect.gen(function* () {
|
|
const providers = yield* Provider.Service
|
|
const credentials = yield* Credential.Service
|
|
yield* providers.transform((editor) => editor.update(Provider.ID.make("test"), () => {}))
|
|
yield* credentials.create({
|
|
integrationID,
|
|
label: "First",
|
|
value: Credential.Key.make({ type: "key", key: "first", metadata: { tenant: "one" } }),
|
|
})
|
|
|
|
expect((yield* providers.available()).map((provider) => provider.id)).toEqual([Provider.ID.make("test")])
|
|
expect(required(yield* providers.get(Provider.ID.make("test"))).body).toBeUndefined()
|
|
yield* credentials.create({
|
|
integrationID,
|
|
label: "Second",
|
|
value: Credential.Key.make({ type: "key", key: "second", metadata: { tenant: "two" } }),
|
|
})
|
|
expect((yield* providers.available()).map((provider) => provider.id)).toEqual([Provider.ID.make("test")])
|
|
expect(required(yield* providers.get(Provider.ID.make("test"))).body).toBeUndefined()
|
|
}).pipe(Effect.scoped, Effect.provide(localProviderLayer))
|
|
})
|
|
|
|
it.effect("reuses the model catalog across credential switches", () =>
|
|
Effect.gen(function* () {
|
|
const providers = yield* Provider.Service
|
|
const models = yield* Model.Service
|
|
const integrations = yield* Integration.Service
|
|
const credentials = yield* Credential.Service
|
|
const bus = yield* Bus.Service
|
|
const providerID = Provider.ID.make("switchable")
|
|
const integrationID = Integration.ID.make(providerID)
|
|
yield* integrations.transform((editor) => editor.update(integrationID, () => {}))
|
|
yield* providers.transform((editor) =>
|
|
editor.add({
|
|
info: Provider.Info.empty(providerID),
|
|
models: [Model.Info.default(providerID, Model.ID.make("chat"))],
|
|
}),
|
|
)
|
|
expect(yield* models.available()).toEqual([])
|
|
const log = yield* Ref.make<string[]>([])
|
|
yield* bus.subscribe().pipe(
|
|
Stream.runForEach((event) => Ref.update(log, (types) => [...types, event.type])),
|
|
Effect.forkScoped({ startImmediately: true }),
|
|
)
|
|
yield* Effect.yieldNow
|
|
const updates = Ref.get(log).pipe(
|
|
Effect.map((types) => types.filter((type) => type === Model.Event.Updated.type).length),
|
|
)
|
|
|
|
const first = yield* credentials.create({
|
|
integrationID,
|
|
value: Credential.Key.make({ type: "key", key: "first" }),
|
|
})
|
|
const materialized = yield* models.available()
|
|
expect(materialized).toHaveLength(1)
|
|
// Credential events reach Model on other fibers; let the connect land before switching.
|
|
yield* settle(updates.pipe(Effect.map((count) => count >= 1)))
|
|
|
|
const second = yield* credentials.create({
|
|
integrationID,
|
|
value: Credential.Key.make({ type: "key", key: "second" }),
|
|
})
|
|
expect(yield* models.available()).toBe(materialized)
|
|
yield* credentials.activate(first.id)
|
|
expect(yield* models.available()).toBe(materialized)
|
|
yield* credentials.remove(first.id)
|
|
expect(yield* models.available()).toBe(materialized)
|
|
|
|
// Disconnecting is a real change whose model.updated follows every earlier one in the log,
|
|
// so once it has arrived the total shows whether any switch above published as well.
|
|
yield* credentials.remove(second.id)
|
|
expect(yield* models.available()).toEqual([])
|
|
yield* settle(
|
|
Ref.get(log).pipe(
|
|
Effect.map(
|
|
(types) =>
|
|
types.lastIndexOf(Model.Event.Updated.type) > types.lastIndexOf(Credential.Event.Updated.type),
|
|
),
|
|
),
|
|
)
|
|
expect(yield* updates).toBe(2)
|
|
}),
|
|
)
|
|
|
|
it.effect("persists direct edits to models returned by list and get", () =>
|
|
Effect.gen(function* () {
|
|
const providers = yield* Provider.Service
|
|
const models = yield* Model.Service
|
|
const providerID = Provider.ID.make("direct")
|
|
const listed = Model.ID.make("listed")
|
|
const fetched = Model.ID.make("fetched")
|
|
const definitions = [Model.Info.default(providerID, listed), Model.Info.default(providerID, fetched)]
|
|
yield* providers.transform((editor) =>
|
|
editor.add({ info: { ...Provider.Info.empty(providerID), activation: "enabled" }, models: definitions }),
|
|
)
|
|
yield* models.transform((editor) => {
|
|
editor.list(providerID).forEach((model) => {
|
|
model.limit.context = 4096
|
|
})
|
|
required(editor.get(providerID, fetched)).capabilities.input.push("pdf")
|
|
})
|
|
|
|
expect(yield* models.get(providerID, listed)).toMatchObject({
|
|
limit: { context: 4096 },
|
|
capabilities: { input: ["text", "image"] },
|
|
})
|
|
expect(yield* models.get(providerID, fetched)).toMatchObject({
|
|
limit: { context: 4096 },
|
|
capabilities: { input: ["text", "image", "pdf"] },
|
|
})
|
|
expect(definitions.map((model) => model.limit.context)).toEqual([200_000, 200_000])
|
|
}),
|
|
)
|
|
|
|
it.effect("gives foreign definitions the registering provider's identity", () =>
|
|
Effect.gen(function* () {
|
|
const providers = yield* Provider.Service
|
|
const models = yield* Model.Service
|
|
const source = Provider.ID.make("source")
|
|
const mirror = Provider.ID.make("mirror")
|
|
const modelID = Model.ID.make("chat")
|
|
const definitions = [Model.Info.default(source, modelID)]
|
|
yield* providers.transform((editor) => {
|
|
editor.add({ info: { ...Provider.Info.empty(source), activation: "enabled" }, models: definitions })
|
|
editor.add({ info: { ...Provider.Info.empty(mirror), activation: "enabled" }, models: definitions })
|
|
})
|
|
|
|
expect((yield* models.available()).map((model) => model.providerID).toSorted()).toEqual([mirror, source])
|
|
expect(yield* models.get(mirror, modelID)).toMatchObject({ id: modelID, providerID: mirror })
|
|
expect((yield* providers.snapshot()).records.get(source)?.models.get(modelID)).toBe(definitions[0])
|
|
}),
|
|
)
|
|
|
|
it.effect("keeps materialized models when another provider becomes available", () =>
|
|
Effect.gen(function* () {
|
|
const providers = yield* Provider.Service
|
|
const models = yield* Model.Service
|
|
const integrations = yield* Integration.Service
|
|
const credentials = yield* Credential.Service
|
|
const existing = Provider.ID.make("existing")
|
|
const added = Provider.ID.make("added")
|
|
const edited = Model.ID.make("edited")
|
|
const untouched = Model.ID.make("untouched")
|
|
yield* integrations.transform((editor) => editor.update(Integration.ID.make(added), () => {}))
|
|
yield* providers.transform((editor) => {
|
|
editor.add({
|
|
info: { ...Provider.Info.empty(existing), activation: "enabled" },
|
|
models: [Model.Info.default(existing, edited), Model.Info.default(existing, untouched)],
|
|
})
|
|
editor.add({ info: Provider.Info.empty(added), models: [Model.Info.default(added, Model.ID.make("chat"))] })
|
|
})
|
|
yield* models.transform((editor) =>
|
|
editor.update(existing, edited, (model) => {
|
|
model.limit.context = 1
|
|
}),
|
|
)
|
|
const before = required(yield* models.get(existing, untouched))
|
|
|
|
yield* credentials.create({
|
|
integrationID: Integration.ID.make(added),
|
|
value: Credential.Key.make({ type: "key", key: "secret" }),
|
|
})
|
|
expect((yield* models.available()).map((model) => model.providerID).toSorted()).toEqual([
|
|
added,
|
|
existing,
|
|
existing,
|
|
])
|
|
expect(yield* models.get(existing, untouched)).toBe(before)
|
|
expect(yield* models.get(existing, edited)).toMatchObject({ limit: { context: 1 } })
|
|
}),
|
|
)
|
|
|
|
it.effect("derives availability from a provider's integration", () => {
|
|
const integrationID = Integration.ID.make("gateway")
|
|
const providerID = Provider.ID.make("remote")
|
|
const localProviderLayer = Layer.fresh(
|
|
AppNodeBuilder.build(LayerNode.group([Provider.node, Credential.node, Integration.node]), [
|
|
Location.node.replace(locationLayer),
|
|
]),
|
|
)
|
|
|
|
return Effect.gen(function* () {
|
|
const providers = yield* Provider.Service
|
|
const integrations = yield* Integration.Service
|
|
yield* integrations.transform((editor) => editor.update(integrationID, () => {}))
|
|
yield* providers.transform((editor) =>
|
|
editor.update(providerID, (provider) => {
|
|
provider.integrationID = integrationID
|
|
}),
|
|
)
|
|
expect(yield* providers.available()).toEqual([])
|
|
|
|
const credentials = yield* Credential.Service
|
|
yield* credentials.create({
|
|
integrationID,
|
|
value: Credential.Key.make({ type: "key", key: "secret" }),
|
|
})
|
|
|
|
expect((yield* providers.available()).map((provider) => provider.id)).toEqual([providerID])
|
|
}).pipe(Effect.scoped, Effect.provide(localProviderLayer))
|
|
})
|
|
|
|
it.effect("makes an explicitly enabled provider available without a connection", () => {
|
|
const integrationID = Integration.ID.make("gateway")
|
|
const providerID = Provider.ID.make("remote")
|
|
const localProviderLayer = Layer.fresh(
|
|
AppNodeBuilder.build(LayerNode.group([Provider.node, Credential.node, Integration.node]), [
|
|
Location.node.replace(locationLayer),
|
|
]),
|
|
)
|
|
|
|
return Effect.gen(function* () {
|
|
const providers = yield* Provider.Service
|
|
const integrations = yield* Integration.Service
|
|
yield* integrations.transform((editor) => editor.update(integrationID, () => {}))
|
|
yield* providers.transform((editor) =>
|
|
editor.update(providerID, (provider) => {
|
|
provider.integrationID = integrationID
|
|
provider.settings = { baseURL: "https://gateway.example.com/v1" }
|
|
}),
|
|
)
|
|
expect(yield* providers.available()).toEqual([])
|
|
|
|
yield* providers.transform((editor) =>
|
|
editor.update(providerID, (provider) => {
|
|
provider.activation = "enabled"
|
|
}),
|
|
)
|
|
expect((yield* providers.available()).map((provider) => provider.id)).toEqual([providerID])
|
|
}).pipe(Effect.scoped, Effect.provide(localProviderLayer))
|
|
})
|
|
|
|
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 providers = yield* Provider.Service
|
|
const integrations = yield* Integration.Service
|
|
const providerID = Provider.ID.make("test")
|
|
yield* integrations.transform((editor) =>
|
|
editor.method.update({
|
|
integrationID: Integration.ID.make(providerID),
|
|
method: { type: "env", names: ["CATALOG_TEST_API_KEY"] },
|
|
}),
|
|
)
|
|
yield* providers.transform((editor) => editor.update(providerID, () => {}))
|
|
|
|
expect((yield* providers.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("stores provider package settings", () =>
|
|
Effect.gen(function* () {
|
|
const providers = yield* Provider.Service
|
|
const providerID = Provider.ID.make("test")
|
|
yield* providers.transform((editor) =>
|
|
editor.update(providerID, (provider) => {
|
|
provider.package = Provider.aisdk("custom-sdk")
|
|
provider.settings = { baseURL: "https://override.example.com" }
|
|
}),
|
|
)
|
|
|
|
expect(required(yield* providers.get(providerID))).toMatchObject({
|
|
package: Provider.aisdk("custom-sdk"),
|
|
settings: { baseURL: "https://override.example.com" },
|
|
})
|
|
}),
|
|
)
|
|
|
|
it.effect("uses model package settings over provider settings", () =>
|
|
Effect.gen(function* () {
|
|
const providers = yield* Provider.Service
|
|
const models = yield* Model.Service
|
|
const providerID = Provider.ID.make("test")
|
|
const modelID = Model.ID.make("model")
|
|
yield* providers.transform((editor) => {
|
|
editor.update(providerID, (provider) => {
|
|
provider.package = Provider.aisdk("custom-sdk")
|
|
provider.settings = { baseURL: "https://provider.example.com" }
|
|
})
|
|
})
|
|
yield* models.transform((editor) => {
|
|
editor.update(providerID, modelID, (model) => {
|
|
model.modelID = Model.ID.make("upstream-model")
|
|
model.package = Provider.aisdk("custom-sdk")
|
|
model.settings = { baseURL: "https://override.example.com" }
|
|
})
|
|
})
|
|
|
|
expect(required(yield* models.get(providerID, modelID))).toMatchObject({
|
|
modelID: Model.ID.make("upstream-model"),
|
|
package: Provider.aisdk("custom-sdk"),
|
|
settings: { baseURL: "https://override.example.com" },
|
|
})
|
|
}),
|
|
)
|
|
|
|
it.effect("resolves default model package settings from the provider", () =>
|
|
Effect.gen(function* () {
|
|
const providers = yield* Provider.Service
|
|
const models = yield* Model.Service
|
|
const providerID = Provider.ID.make("test")
|
|
const modelID = Model.ID.make("model")
|
|
yield* providers.transform((editor) => {
|
|
editor.update(providerID, (provider) => {
|
|
provider.package = Provider.aisdk("custom-sdk")
|
|
provider.settings = { baseURL: "https://provider.example.com" }
|
|
})
|
|
editor.models.update(providerID, modelID, () => {})
|
|
})
|
|
|
|
expect(required(yield* models.get(providerID, modelID))).toMatchObject({
|
|
package: Provider.aisdk("custom-sdk"),
|
|
settings: { baseURL: "https://provider.example.com" },
|
|
})
|
|
}),
|
|
)
|
|
|
|
it.effect("rewrites AI SDK packages and their options on write", () =>
|
|
Effect.gen(function* () {
|
|
const providers = yield* Provider.Service
|
|
const models = yield* Model.Service
|
|
const providerID = Provider.ID.make("bedrock")
|
|
const modelID = Model.ID.make("claude")
|
|
yield* providers.transform((editor) => {
|
|
editor.update(providerID, (provider) => {
|
|
provider.package = Provider.aisdk("@ai-sdk/amazon-bedrock")
|
|
provider.settings = { region: "us-east-1", extraBody: { tag: "provider" } }
|
|
})
|
|
editor.models.update(providerID, modelID, () => {})
|
|
editor.update(Provider.ID.make("gitlab"), (provider) => {
|
|
provider.package = Provider.aisdk("@ai-sdk/gitlab")
|
|
provider.settings = { extraBody: { kept: true } }
|
|
})
|
|
})
|
|
yield* models.transform((editor) => {
|
|
editor.update(providerID, modelID, (model) => {
|
|
model.modelID = Model.ID.make("anthropic.claude-opus-4-8")
|
|
model.package = Provider.aisdk("@ai-sdk/amazon-bedrock")
|
|
model.settings = { reasoningConfig: { type: "adaptive", maxReasoningEffort: "high" } }
|
|
model.variants = [
|
|
{
|
|
id: Model.VariantID.make("max"),
|
|
settings: { reasoningConfig: { type: "adaptive", maxReasoningEffort: "max" } },
|
|
},
|
|
]
|
|
})
|
|
})
|
|
|
|
expect(required(yield* providers.get(providerID))).toMatchObject({
|
|
package: "@opencode/ai/providers/amazon-bedrock",
|
|
settings: { region: "us-east-1" },
|
|
body: { tag: "provider" },
|
|
})
|
|
const model = required(yield* models.get(providerID, modelID))
|
|
expect(model.package).toBe("@opencode/ai/providers/amazon-bedrock")
|
|
expect(model.settings).not.toHaveProperty("reasoningConfig")
|
|
expect(model.body).toMatchObject({
|
|
additionalModelRequestFields: { thinking: { type: "adaptive" }, output_config: { effort: "high" } },
|
|
})
|
|
expect(model.variants).toEqual([
|
|
{
|
|
id: Model.VariantID.make("max"),
|
|
settings: {},
|
|
body: { additionalModelRequestFields: { thinking: { type: "adaptive" }, output_config: { effort: "max" } } },
|
|
},
|
|
])
|
|
expect(required(yield* providers.get(Provider.ID.make("gitlab")))).toMatchObject({
|
|
package: Provider.aisdk("@ai-sdk/gitlab"),
|
|
settings: { extraBody: { kept: true } },
|
|
})
|
|
}),
|
|
)
|
|
|
|
it.effect("keeps settings scoped while resolving request overlay merges", () =>
|
|
Effect.gen(function* () {
|
|
const providers = yield* Provider.Service
|
|
const models = yield* Model.Service
|
|
const providerID = Provider.ID.make("test")
|
|
const modelID = Model.ID.make("model")
|
|
yield* providers.transform((editor) => {
|
|
editor.update(providerID, (provider) => {
|
|
provider.settings = { provider: true, shared: "provider" }
|
|
provider.headers = { provider: "provider", shared: "provider" }
|
|
provider.body = { provider: true, shared: "provider" }
|
|
})
|
|
})
|
|
yield* models.transform((editor) => {
|
|
editor.update(providerID, modelID, (model) => {
|
|
model.settings = { model: true, shared: "model" }
|
|
model.headers = { model: "model", shared: "model" }
|
|
model.body = { model: true, shared: "model" }
|
|
})
|
|
})
|
|
|
|
const model = required(yield* models.get(providerID, modelID))
|
|
expect((yield* providers.get(providerID))?.settings).toEqual({ provider: true, shared: "provider" })
|
|
expect(model.settings).toEqual({ provider: true, shared: "model", model: true })
|
|
expect(model.headers).toEqual({ provider: "provider", shared: "model", model: "model" })
|
|
expect(model.body).toEqual({ provider: true, shared: "model", model: true })
|
|
}),
|
|
)
|
|
|
|
it.effect("falls back to newest available model when no default is configured", () =>
|
|
Effect.gen(function* () {
|
|
const providers = yield* Provider.Service
|
|
const models = yield* Model.Service
|
|
const providerID = Provider.ID.make("test")
|
|
yield* providers.transform((editor) => {
|
|
editor.update(providerID, () => {})
|
|
editor.models.update(providerID, Model.ID.make("old"), (model) => {
|
|
model.time.released = 1000
|
|
})
|
|
editor.models.update(providerID, Model.ID.make("new"), (model) => {
|
|
model.time.released = 2000
|
|
})
|
|
editor.models.update(providerID, Model.ID.make("newest-video"), (model) => {
|
|
model.capabilities.input = ["text", "image"]
|
|
model.capabilities.output = ["video"]
|
|
model.time.released = 3000
|
|
})
|
|
editor.models.update(providerID, Model.ID.make("newest-stt"), (model) => {
|
|
model.capabilities.input = ["audio"]
|
|
model.capabilities.output = ["text"]
|
|
model.time.released = 4000
|
|
})
|
|
})
|
|
|
|
expect((yield* models.default())?.id).toMatch("new")
|
|
}),
|
|
)
|
|
|
|
it.effect("defaults to big-pickle when only free OpenCode models are available", () =>
|
|
Effect.gen(function* () {
|
|
const providers = yield* Provider.Service
|
|
const models = yield* Model.Service
|
|
yield* providers.transform((editor) => {
|
|
editor.update(Provider.ID.opencode, () => {})
|
|
;["muse-spark-1.3-contributor-free", "new-free", "deepseek-v4.1-flash-free", "big-pickle"].forEach(
|
|
(id, index) =>
|
|
editor.models.update(Provider.ID.opencode, Model.ID.make(id), (model) => {
|
|
model.time.released = 4000 - index * 1000
|
|
}),
|
|
)
|
|
})
|
|
|
|
expect((yield* models.available()).map((model) => model.id)).toEqual([
|
|
Model.ID.make("big-pickle"),
|
|
Model.ID.make("deepseek-v4.1-flash-free"),
|
|
Model.ID.make("muse-spark-1.3-contributor-free"),
|
|
Model.ID.make("new-free"),
|
|
])
|
|
expect((yield* models.default())?.id).toBe(Model.ID.make("big-pickle"))
|
|
}),
|
|
)
|
|
|
|
it.effect("keeps the newest default when a paid model is available", () =>
|
|
Effect.gen(function* () {
|
|
const providers = yield* Provider.Service
|
|
const models = yield* Model.Service
|
|
yield* providers.transform((editor) => {
|
|
editor.update(Provider.ID.opencode, () => {})
|
|
editor.models.update(Provider.ID.opencode, Model.ID.make("big-pickle"), (model) => {
|
|
model.time.released = 1000
|
|
})
|
|
editor.models.update(Provider.ID.opencode, Model.ID.make("paid"), (model) => {
|
|
model.time.released = 2000
|
|
model.cost = [
|
|
{
|
|
input: Money.USDPerMillionTokens.make(1),
|
|
output: Money.USDPerMillionTokens.make(1),
|
|
cache: { read: Money.USDPerMillionTokens.zero, write: Money.USDPerMillionTokens.zero },
|
|
},
|
|
]
|
|
})
|
|
})
|
|
|
|
expect((yield* models.default())?.id).toBe(Model.ID.make("paid"))
|
|
}),
|
|
)
|
|
|
|
it.effect("uses a transform-provided default model until that transform is replaced", () =>
|
|
Effect.gen(function* () {
|
|
const providers = yield* Provider.Service
|
|
const models = yield* Model.Service
|
|
const providerID = Provider.ID.make("test")
|
|
const old = Model.ID.make("old")
|
|
const newest = Model.ID.make("new")
|
|
yield* providers.transform((editor) => {
|
|
editor.update(providerID, () => {})
|
|
editor.models.update(providerID, old, (model) => {
|
|
model.time.released = 1000
|
|
})
|
|
editor.models.update(providerID, newest, (model) => {
|
|
model.time.released = 2000
|
|
})
|
|
})
|
|
|
|
let configured = true
|
|
yield* models.transform((editor) => {
|
|
if (configured) editor.default.set(providerID, old)
|
|
})
|
|
expect((yield* models.default())?.id).toBe(old)
|
|
|
|
configured = false
|
|
yield* models.reload()
|
|
expect((yield* models.default())?.id).toBe(newest)
|
|
}),
|
|
)
|
|
|
|
it.effect("ignores a configured default on a disabled provider", () =>
|
|
Effect.gen(function* () {
|
|
const providers = yield* Provider.Service
|
|
const models = yield* Model.Service
|
|
const disabledProvider = Provider.ID.make("disabled")
|
|
const enabledProvider = Provider.ID.make("enabled")
|
|
const disabledModel = Model.ID.make("configured")
|
|
const fallbackModel = Model.ID.make("fallback")
|
|
yield* providers.transform((editor) => {
|
|
editor.update(disabledProvider, (provider) => {
|
|
provider.activation = "disabled"
|
|
})
|
|
editor.models.update(disabledProvider, disabledModel, () => {})
|
|
editor.update(enabledProvider, () => {})
|
|
editor.models.update(enabledProvider, fallbackModel, () => {})
|
|
})
|
|
yield* models.transform((editor) => editor.default.set(disabledProvider, disabledModel))
|
|
|
|
expect(yield* models.default()).toMatchObject({
|
|
providerID: enabledProvider,
|
|
id: fallbackModel,
|
|
})
|
|
}),
|
|
)
|
|
|
|
it.effect("small model uses the newest release in the first matching family", () =>
|
|
Effect.gen(function* () {
|
|
const providers = yield* Provider.Service
|
|
const models = yield* Model.Service
|
|
const providerID = Provider.ID.make("test")
|
|
yield* providers.transform((editor) => {
|
|
editor.update(providerID, () => {})
|
|
editor.models.update(providerID, Model.ID.make("newer-flash"), (model) => {
|
|
model.family = Model.Family.make("gemini-flash")
|
|
model.capabilities.input = ["text"]
|
|
model.capabilities.output = ["text"]
|
|
model.time.released = 3000
|
|
})
|
|
editor.models.update(providerID, Model.ID.make("older-luna"), (model) => {
|
|
model.family = Model.Family.make("gpt-luna")
|
|
model.capabilities.input = ["text"]
|
|
model.capabilities.output = ["text"]
|
|
model.time.released = 1000
|
|
})
|
|
editor.models.update(providerID, Model.ID.make("newer-luna"), (model) => {
|
|
model.family = Model.Family.make("gpt-luna")
|
|
model.capabilities.input = ["text"]
|
|
model.capabilities.output = ["text"]
|
|
model.time.released = 2000
|
|
})
|
|
})
|
|
|
|
expect((yield* models.small(providerID))?.id).toBe(Model.ID.make("newer-luna"))
|
|
}),
|
|
)
|
|
|
|
it.effect("small model returns undefined without a matching family", () =>
|
|
Effect.gen(function* () {
|
|
const providers = yield* Provider.Service
|
|
const models = yield* Model.Service
|
|
const providerID = Provider.ID.make("test")
|
|
yield* providers.transform((editor) => {
|
|
editor.update(providerID, () => {})
|
|
editor.models.update(providerID, Model.ID.make("large"), (model) => {
|
|
model.family = Model.Family.make("gpt")
|
|
model.capabilities.input = ["text"]
|
|
model.capabilities.output = ["text"]
|
|
})
|
|
})
|
|
|
|
expect(yield* models.small(providerID)).toBeUndefined()
|
|
}),
|
|
)
|
|
})
|
|
|
|
// Bus subscribers run on their own fibers, so give them turns until the condition holds.
|
|
const settle = Effect.fnUntraced(function* (condition: Effect.Effect<boolean>) {
|
|
for (let attempt = 0; attempt < 100; attempt++) {
|
|
if (yield* condition) return
|
|
yield* Effect.yieldNow
|
|
}
|
|
return yield* Effect.die("Timed out waiting for catalog events")
|
|
})
|