test(opencode): replace mock.module stubs with Effect layer injection in kilo tests (#10615)

Extract KiloModelsService from model-cache to enable dependency injection
and rewrite test files to use Layer-based test harnesses instead of
process-wide mock.module() calls that caused flaky cross-test pollution
on CI.
This commit is contained in:
Imanol Maiztegui
2026-05-27 15:42:15 +02:00
committed by GitHub
parent 1a36cfbb10
commit 8bc05fa9f5
6 changed files with 412 additions and 508 deletions
+21 -2
View File
@@ -13,6 +13,19 @@ type Options = { -readonly [K in keyof KiloOptions]?: KiloOptions[K] } & { apiKe
type Failure = NonNullable<KiloModelsResult["error"]>
type Result = { readonly models: Models; readonly error?: Failure }
type View = { models?: Models; timestamp?: number }
export interface KiloModels {
readonly fetch: (options: KiloOptions) => Effect.Effect<KiloModelsResult, unknown>
}
export class KiloModelsService extends Context.Service<KiloModelsService, KiloModels>()(
"@kilocode/ModelCache/KiloModels",
) {}
export const kiloModelsLayer = Layer.succeed(
KiloModelsService,
KiloModelsService.of({ fetch: (options) => Effect.tryPromise(() => fetchKiloModels(options)) }),
)
type Cell = {
readonly providerID: string
readonly view: View
@@ -38,11 +51,16 @@ const ApertisItem = Schema.Struct({ id: Schema.String, owned_by: Schema.optional
const ApertisResponse = Schema.Struct({ data: Schema.optional(Schema.Array(ApertisItem)) })
type ApertisItem = Schema.Schema.Type<typeof ApertisItem>
export const layer: Layer.Layer<Service, never, Auth.Service | Config.Service | HttpClient.HttpClient> = Layer.effect(
export const layer: Layer.Layer<
Service,
never,
Auth.Service | Config.Service | KiloModelsService | HttpClient.HttpClient
> = Layer.effect(
Service,
Effect.gen(function* () {
const auth = yield* Auth.Service
const cfg = yield* Config.Service
const kilo = yield* KiloModelsService
const http = yield* HttpClient.HttpClient
const cells = new Map<string, Cell>()
const active = new Map<string, Cell>()
@@ -140,7 +158,7 @@ export const layer: Layer.Layer<Service, never, Auth.Service | Config.Service |
})
const fetchModels = (providerID: string, options: Options): Effect.Effect<Result, unknown> => {
if (providerID === "kilo") return Effect.tryPromise(() => fetchKiloModels(options))
if (providerID === "kilo") return kilo.fetch(options)
if (providerID === "apertis") return fetchApertisModels(options).pipe(Effect.map((models) => ({ models })))
log.debug("provider not implemented", { providerID })
return Effect.succeed({ models: {} })
@@ -261,6 +279,7 @@ export const defaultLayer = layer.pipe(
Layer.provide(FetchHttpClient.layer),
Layer.provide(Auth.defaultLayer),
Layer.provide(Config.defaultLayer),
Layer.provide(kiloModelsLayer),
)
export * as ModelCache from "./model-cache"
@@ -1,174 +1,139 @@
// kilocode_change - new file
//
// Tests that the kilo custom loader keeps paid models visible without authentication.
// Mocks fetchKiloModels from @kilocode/kilo-gateway to avoid real network
// calls (which fail on Windows CI).
// Tests that unauthenticated Kilo models are assembled with paid models and autoloaded anonymously.
import { test, expect, mock } from "bun:test"
import path from "path"
import { unlink } from "fs/promises"
// Bun's mock.module() is process-wide and permanent — it replaces the module
// for ALL test files in the same runner process. To avoid breaking other tests
// that import @kilocode/kilo-gateway, we spread the real exports and only
// override fetchKiloModels with a stub that returns both free and paid models.
const real = await import("@kilocode/kilo-gateway")
mock.module("@kilocode/kilo-gateway", () => ({
...real,
fetchKiloModels: async () => ({
models: {
"free-model": {
id: "free-model",
name: "Free Model",
cost: { input: 0, output: 0 },
limit: { context: 128000, output: 4096 },
},
"paid-model": {
id: "paid-model",
name: "Paid Model",
cost: { input: 1.0, output: 2.0 },
limit: { context: 128000, output: 4096 },
},
},
}),
}))
import { tmpdir } from "../fixture/fixture"
import { Global } from "@opencode-ai/core/global"
import { WithInstance } from "../../src/project/with-instance"
import { Provider } from "../../src/provider/provider"
import { ProviderID } from "../../src/provider/schema"
import { Filesystem } from "../../src/util/filesystem"
import { expect } from "bun:test"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Effect, Layer } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import { kiloCustomLoaders } from "../../src/kilocode/provider/provider"
import { Auth } from "../../src/auth"
import { ModelCache } from "../../src/provider/model-cache"
import { AppRuntime } from "../../src/effect/app-runtime"
import { ModelsDev } from "../../src/provider/models"
import { Provider } from "../../src/provider/provider"
import { TestConfig } from "../fixture/config"
import { testEffect } from "../lib/effect"
const clear = (id: string) => AppRuntime.runPromise(ModelCache.Service.use((cache) => cache.clear(id)))
function paid(providers: Awaited<ReturnType<typeof Provider.list>>) {
const item = providers[ProviderID.kilo]
expect(item).toBeDefined()
return Object.values(item.models).filter((model) => model.cost.input > 0).length
const input = {
id: "kilo",
env: ["KILO_API_KEY"],
models: {
"free-model": {
id: "free-model",
name: "Free Model",
cost: { input: 0, output: 0 },
limit: { context: 128000, output: 4096 },
},
"paid-model": {
id: "paid-model",
name: "Paid Model",
cost: { input: 1, output: 2 },
limit: { context: 128000, output: 4096 },
},
},
}
const authPath = path.join(Global.Path.data, "auth.json")
const seed: Record<string, ModelsDev.Provider> = {
apertis: {
id: "apertis",
name: "Apertis",
env: ["APERTIS_API_KEY"],
models: {},
},
}
test("kilo loader keeps paid models without auth and when config apiKey is present", async () => {
// Persisted auth from other tests and ModelCache's TTL map must not affect this test.
const prev = await Filesystem.readText(authPath).catch(() => undefined)
const auth = Layer.mock(Auth.Service)({
get: () => Effect.succeed(undefined),
})
try {
await Filesystem.write(authPath, JSON.stringify({}))
await clear("kilo")
await using base = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "kilo.json"),
JSON.stringify({
$schema: "https://app.kilo.ai/config.json",
}),
)
},
const files = Layer.effect(
AppFileSystem.Service,
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
return AppFileSystem.Service.of({
...fs,
readJson: () => Effect.succeed(seed),
stat: () => fs.stat(import.meta.path),
})
}),
).pipe(Layer.provide(AppFileSystem.defaultLayer))
const none = await WithInstance.provide({
directory: base.path,
fn: async () => paid(await Provider.list()),
})
function load(data?: { auth?: object; config?: object; env?: Record<string, string | undefined> }) {
return kiloCustomLoaders({
auth: () => Effect.succeed(data?.auth),
config: () => Effect.succeed(data?.config ?? {}),
env: () => Effect.succeed(data?.env ?? {}),
get: () => Effect.succeed(undefined),
}).kilo(input)
}
await using keyed = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "kilo.json"),
JSON.stringify({
$schema: "https://app.kilo.ai/config.json",
provider: {
kilo: {
options: {
apiKey: "test-key",
},
},
function layer() {
const cfg = TestConfig.layer()
const models = Layer.succeed(
ModelCache.KiloModelsService,
ModelCache.KiloModelsService.of({
fetch: () =>
Effect.succeed({
models: {
"paid-model": {
id: "paid-model",
name: "Paid Model",
cost: { input: 1, output: 2 },
limit: { context: 128000, output: 4096 },
},
}),
)
},
},
}),
}),
)
const cache = Layer.fresh(ModelCache.layer).pipe(
Layer.provide(FetchHttpClient.layer),
Layer.provide(cfg),
Layer.provide(auth),
Layer.provide(models),
)
return Layer.fresh(ModelsDev.layer).pipe(
Layer.provide(FetchHttpClient.layer),
Layer.provide(files),
Layer.provide(cfg),
Layer.provide(auth),
Layer.provide(cache),
)
}
const it = testEffect(Layer.empty)
it.live("assembles paid Kilo models without auth", () =>
Effect.gen(function* () {
const providers = yield* ModelsDev.Service.use((models) => models.get()).pipe(Effect.provide(layer()))
const kilo = Provider.fromModelsDevProvider(providers.kilo)
expect(kilo.models["paid-model"]).toMatchObject({
id: "paid-model",
providerID: "kilo",
cost: { input: 1, output: 2 },
})
}),
)
const count = await WithInstance.provide({
directory: keyed.path,
fn: async () => paid(await Provider.list()),
})
it.effect("enables a paid catalog anonymously without auth", () =>
Effect.gen(function* () {
const result = yield* load()
expect(result.autoload).toBe(true)
expect(result.options).toEqual({ apiKey: "anonymous" })
}),
)
expect(none).toBeGreaterThan(0)
expect(count).toBeGreaterThan(0)
} finally {
if (prev !== undefined) {
await Filesystem.write(authPath, prev)
}
if (prev === undefined) {
await unlink(authPath).catch(() => undefined)
}
}
})
it.effect("enables a paid catalog when config apiKey is present", () =>
Effect.gen(function* () {
const result = yield* load({ config: { provider: { kilo: { options: { apiKey: "test-key" } } } } })
expect(result.autoload).toBe(true)
expect(result.options).toEqual({})
}),
)
test("kilo loader keeps paid models without auth and when auth exists", async () => {
const prev = await Filesystem.readText(authPath).catch(() => undefined)
try {
await Filesystem.write(authPath, JSON.stringify({}))
await clear("kilo")
await using base = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "kilo.json"),
JSON.stringify({
$schema: "https://app.kilo.ai/config.json",
}),
)
},
})
const none = await WithInstance.provide({
directory: base.path,
fn: async () => paid(await Provider.list()),
})
await using keyed = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "kilo.json"),
JSON.stringify({
$schema: "https://app.kilo.ai/config.json",
}),
)
},
})
await Filesystem.write(
authPath,
JSON.stringify({
kilo: {
type: "api",
key: "test-key",
},
}),
)
const count = await WithInstance.provide({
directory: keyed.path,
fn: async () => paid(await Provider.list()),
})
expect(none).toBeGreaterThan(0)
expect(count).toBeGreaterThan(0)
} finally {
if (prev !== undefined) {
await Filesystem.write(authPath, prev)
}
if (prev === undefined) {
await unlink(authPath).catch(() => undefined)
}
}
})
it.effect("enables a paid catalog when auth exists", () =>
Effect.gen(function* () {
const result = yield* load({ auth: { type: "api", key: "test-key" } })
expect(result.autoload).toBe(true)
expect(result.options).toEqual({})
}),
)
@@ -1,60 +1,53 @@
// kilocode_change - new file
// Integration: when fetchKiloModels returns a 401 error result, ModelCache
// surfaces the failure and caches empty models (allowing re-auth via /connect).
// The real 401-fallback unit test lives in packages/kilo-gateway/test/api/models.test.ts.
// When the injected Kilo models source returns a 401 error result, ModelCache surfaces
// the failure and caches empty models (allowing re-auth via /connect).
// The real fetchKiloModels 401-fallback unit test lives in packages/kilo-gateway/test/api/models.test.ts.
import { test, expect, mock } from "bun:test"
import path from "path"
import { expect } from "bun:test"
import { Effect, Layer } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import * as Log from "@opencode-ai/core/util/log"
Log.init({ print: false })
// Simulate a 401 typed error result from the gateway
mock.module("@kilocode/kilo-gateway", () => ({
fetchKiloModels: async () => ({
models: {},
error: { kind: "unauthorized", status: 401 },
}),
KILO_OPENROUTER_BASE: "https://api.kilo.ai/api/openrouter",
}))
mock.module("opencode-copilot-auth", () => ({ default: () => ({}) }))
mock.module("opencode-anthropic-auth", () => ({ default: () => ({}) }))
mock.module("@gitlab/opencode-gitlab-auth", () => ({ default: () => ({}) }))
import { tmpdir } from "../fixture/fixture"
import { WithInstance } from "../../src/project/with-instance"
import { Auth } from "../../src/auth"
import { ModelCache } from "../../src/provider/model-cache"
import { AppRuntime } from "../../src/effect/app-runtime"
import { TestConfig } from "../fixture/config"
import { testEffect } from "../lib/effect"
const clear = (id: string) => AppRuntime.runPromise(ModelCache.Service.use((cache) => cache.clear(id)))
const fetch = (id: string) => AppRuntime.runPromise(ModelCache.Service.use((cache) => cache.fetch(id)))
const failed = () => AppRuntime.runPromise(ModelCache.Service.use((cache) => cache.failedProviders()))
const failure = (id: string) => AppRuntime.runPromise(ModelCache.Service.use((cache) => cache.getFailure(id)))
const get = (id: string) => AppRuntime.runPromise(ModelCache.Service.use((cache) => cache.get(id)))
const CONFIG = JSON.stringify({ $schema: "https://app.kilo.ai/config.json" })
async function withInstance<T>(fn: () => Promise<T>): Promise<T> {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "kilo.json"), CONFIG)
},
})
return WithInstance.provide({ directory: tmp.path, fn })
}
test("401 from gateway sets provider as failed in ModelCache", async () => {
await clear("kilo")
await withInstance(() => fetch("kilo"))
expect(await failed()).toContain("kilo")
expect(await failure("kilo")).toMatchObject({ kind: "unauthorized", status: 401 })
const auth = Layer.mock(Auth.Service)({
get: () => Effect.succeed(undefined),
})
test("401 from gateway caches empty models (not undefined)", async () => {
await clear("kilo")
await withInstance(() => fetch("kilo"))
const cached = await get("kilo")
expect(cached).toBeDefined()
expect(Object.keys(cached!)).toHaveLength(0)
})
const models = Layer.succeed(
ModelCache.KiloModelsService,
ModelCache.KiloModelsService.of({
fetch: () => Effect.succeed({ models: {}, error: { kind: "unauthorized", status: 401 } }),
}),
)
const layer = Layer.fresh(ModelCache.layer).pipe(
Layer.provide(FetchHttpClient.layer),
Layer.provide(TestConfig.layer()),
Layer.provide(auth),
Layer.provide(models),
)
const it = testEffect(layer)
it.live("401 from Kilo models sets provider as failed in ModelCache", () =>
Effect.gen(function* () {
const cache = yield* ModelCache.Service
yield* cache.fetch("kilo")
expect(yield* cache.failedProviders()).toContain("kilo")
expect(yield* cache.getFailure("kilo")).toMatchObject({ kind: "unauthorized", status: 401 })
}),
)
it.live("401 from Kilo models caches empty models (not undefined)", () =>
Effect.gen(function* () {
const cache = yield* ModelCache.Service
yield* cache.fetch("kilo")
expect(yield* cache.get("kilo")).toEqual({})
}),
)
@@ -40,6 +40,7 @@ function layer(
Layer.provide(Layer.succeed(HttpClient.HttpClient, http)),
Layer.provide(cfg),
Layer.provide(access),
Layer.provide(ModelCache.kiloModelsLayer),
)
}
@@ -2,213 +2,111 @@
// When a user logs in via OAuth and selects an enterprise organization, the model fetch
// should use the organization-specific endpoint, not the personal endpoint.
import { test, expect, mock } from "bun:test"
import { unlink } from "fs/promises"
import path from "path"
import { Global } from "@opencode-ai/core/global"
import { expect } from "bun:test"
import { Effect, Layer, Ref } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import * as Log from "@opencode-ai/core/util/log"
Log.init({ print: false })
// Capture the options passed to fetchKiloModels
let captured: any = undefined
mock.module("@kilocode/kilo-gateway", () => ({
fetchKiloModels: async (options: any) => {
captured = options
return {
models: {
"test-model": {
id: "test-model",
name: "Test Model",
cost: { input: 0.001, output: 0.002 },
limit: { context: 128000, output: 4096 },
},
},
}
},
KILO_OPENROUTER_BASE: "https://api.kilo.ai/api/openrouter",
}))
// Mock default plugins to prevent actual installations during tests
const mockPlugin = () => ({})
mock.module("opencode-copilot-auth", () => ({ default: mockPlugin }))
mock.module("opencode-anthropic-auth", () => ({ default: mockPlugin }))
mock.module("@gitlab/opencode-gitlab-auth", () => ({ default: mockPlugin }))
import { tmpdir } from "../fixture/fixture"
import { WithInstance } from "../../src/project/with-instance"
import { Filesystem } from "../../src/util/filesystem"
import { Auth } from "../../src/auth"
import { ModelCache } from "../../src/provider/model-cache"
import { AppRuntime } from "../../src/effect/app-runtime"
import { TestConfig } from "../fixture/config"
import { testEffect } from "../lib/effect"
const clear = (id: string) => AppRuntime.runPromise(ModelCache.Service.use((cache) => cache.clear(id)))
const fetch = (id: string) => AppRuntime.runPromise(ModelCache.Service.use((cache) => cache.fetch(id)))
const get = (id: string) => AppRuntime.runPromise(ModelCache.Service.use((cache) => cache.get(id)))
type Options = Parameters<ModelCache.KiloModels["fetch"]>[0]
const authPath = path.join(Global.Path.data, "auth.json")
test("model fetch uses accountId from OAuth auth as kilocodeOrganizationId", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://app.kilo.ai/config.json",
}),
)
},
function layer(info: Auth.Info | undefined, captured: Ref.Ref<Options | undefined>) {
const auth = Layer.mock(Auth.Service)({
get: (id) => Effect.succeed(id === "kilo" ? info : undefined),
})
// Simulate an OAuth login where user selected an enterprise organization
const prev = await Filesystem.readText(authPath).catch(() => undefined)
const models = Layer.succeed(
ModelCache.KiloModelsService,
ModelCache.KiloModelsService.of({
fetch: (options) =>
Ref.set(captured, options).pipe(
Effect.as({
models: {
"test-model": {
id: "test-model",
name: "Test Model",
cost: { input: 0.001, output: 0.002 },
limit: { context: 128000, output: 4096 },
},
},
}),
),
}),
)
return Layer.fresh(ModelCache.layer).pipe(
Layer.provide(FetchHttpClient.layer),
Layer.provide(TestConfig.layer()),
Layer.provide(auth),
Layer.provide(models),
)
}
try {
await Filesystem.write(
authPath,
JSON.stringify({
kilo: {
type: "oauth",
access: "test-oauth-token",
refresh: "test-refresh-token",
expires: Date.now() + 3600000,
accountId: "org-enterprise-123",
},
}),
)
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
// Reset captured and cache
captured = undefined
await clear("kilo")
const it = testEffect(Layer.empty)
// Trigger model fetch through the cache
await fetch("kilo")
// The fetchKiloModels call should have received the organization ID
expect(captured).toBeDefined()
expect(captured.kilocodeToken).toBe("test-oauth-token")
expect(captured.kilocodeOrganizationId).toBe("org-enterprise-123")
},
it.live("model fetch uses accountId from OAuth auth as kilocodeOrganizationId", () =>
Effect.gen(function* () {
const captured = yield* Ref.make<Options | undefined>(undefined)
const info = new Auth.Oauth({
type: "oauth",
access: "test-oauth-token",
refresh: "test-refresh-token",
expires: Date.now() + 3600000,
accountId: "org-enterprise-123",
})
} finally {
if (prev !== undefined) {
await Filesystem.write(authPath, prev)
}
if (prev === undefined) {
await unlink(authPath).catch(() => undefined)
}
}
})
test("model fetch without OAuth accountId does not set kilocodeOrganizationId", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://app.kilo.ai/config.json",
}),
)
},
})
// Simulate an OAuth login for a personal account (no accountId)
const prev = await Filesystem.readText(authPath).catch(() => undefined)
try {
await Filesystem.write(
authPath,
JSON.stringify({
kilo: {
type: "oauth",
access: "test-personal-token",
refresh: "test-refresh-token",
expires: Date.now() + 3600000,
},
}),
)
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
captured = undefined
await clear("kilo")
await fetch("kilo")
expect(captured).toBeDefined()
expect(captured.kilocodeToken).toBe("test-personal-token")
expect(captured.kilocodeOrganizationId).toBeUndefined()
},
yield* ModelCache.Service.use((cache) => cache.fetch("kilo")).pipe(Effect.provide(layer(info, captured)))
expect(yield* Ref.get(captured)).toMatchObject({
kilocodeToken: "test-oauth-token",
kilocodeOrganizationId: "org-enterprise-123",
})
} finally {
if (prev !== undefined) {
await Filesystem.write(authPath, prev)
}
if (prev === undefined) {
await unlink(authPath).catch(() => undefined)
}
}
})
}),
)
test("ModelCache.clear removes cached entry so next fetch hits the network", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://app.kilo.ai/config.json",
}),
)
},
})
const prev = await Filesystem.readText(authPath).catch(() => undefined)
try {
await Filesystem.write(
authPath,
JSON.stringify({
kilo: {
type: "oauth",
access: "token-clear-test",
refresh: "refresh-clear",
expires: Date.now() + 3600000,
accountId: "org-clear",
},
}),
)
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
// Populate cache
captured = undefined
await clear("kilo")
await fetch("kilo")
expect(captured).toBeDefined()
// Verify cache is populated — second fetch should NOT call fetchKiloModels
captured = undefined
await fetch("kilo")
expect(captured).toBeUndefined()
expect(await get("kilo")).toBeDefined()
// Clear the cache
await clear("kilo")
// get() should return undefined after clear
expect(await get("kilo")).toBeUndefined()
// Next fetch should call fetchKiloModels again
captured = undefined
await fetch("kilo")
expect(captured).toBeDefined()
},
it.live("model fetch without OAuth accountId does not set kilocodeOrganizationId", () =>
Effect.gen(function* () {
const captured = yield* Ref.make<Options | undefined>(undefined)
const info = new Auth.Oauth({
type: "oauth",
access: "test-personal-token",
refresh: "test-refresh-token",
expires: Date.now() + 3600000,
})
} finally {
if (prev !== undefined) {
await Filesystem.write(authPath, prev)
}
if (prev === undefined) {
await unlink(authPath).catch(() => undefined)
}
}
})
yield* ModelCache.Service.use((cache) => cache.fetch("kilo")).pipe(Effect.provide(layer(info, captured)))
expect(yield* Ref.get(captured)).toMatchObject({ kilocodeToken: "test-personal-token" })
expect((yield* Ref.get(captured))?.kilocodeOrganizationId).toBeUndefined()
}),
)
it.live("ModelCache.clear removes cached entry so next fetch hits the network", () =>
Effect.gen(function* () {
const captured = yield* Ref.make<Options | undefined>(undefined)
const info = new Auth.Oauth({
type: "oauth",
access: "token-clear-test",
refresh: "refresh-clear",
expires: Date.now() + 3600000,
accountId: "org-clear",
})
yield* ModelCache.Service.use((cache) =>
Effect.gen(function* () {
yield* cache.fetch("kilo")
expect(yield* Ref.get(captured)).toBeDefined()
yield* Ref.set(captured, undefined)
yield* cache.fetch("kilo")
expect(yield* Ref.get(captured)).toBeUndefined()
expect(yield* cache.get("kilo")).toBeDefined()
yield* cache.clear("kilo")
expect(yield* cache.get("kilo")).toBeUndefined()
yield* cache.fetch("kilo")
expect(yield* Ref.get(captured)).toBeDefined()
}),
).pipe(Effect.provide(layer(info, captured)))
}),
)
@@ -4,124 +4,152 @@
// 2. ModelCache.getFailure() returns the typed error for a failed provider.
// 3. Clear removes failure state.
import { beforeEach, test, expect, mock } from "bun:test"
import { Effect } from "effect"
import path from "path"
import { beforeEach, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import * as Log from "@opencode-ai/core/util/log"
Log.init({ print: false })
// Stub fetchKiloModels to return controlled typed results.
let stubbedResult: { models: Record<string, any>; error?: { kind: string; status?: number } } = { models: {} }
let stubbedError: Error | undefined
mock.module("@kilocode/kilo-gateway", () => ({
fetchKiloModels: async () => {
if (stubbedError) throw stubbedError
return stubbedResult
},
KILO_OPENROUTER_BASE: "https://api.kilo.ai/api/openrouter",
}))
mock.module("opencode-copilot-auth", () => ({ default: () => ({}) }))
mock.module("opencode-anthropic-auth", () => ({ default: () => ({}) }))
mock.module("@gitlab/opencode-gitlab-auth", () => ({ default: () => ({}) }))
import { tmpdir } from "../fixture/fixture"
import { WithInstance } from "../../src/project/with-instance"
import { Auth } from "../../src/auth"
import { ModelCache } from "../../src/provider/model-cache"
import { AppRuntime } from "../../src/effect/app-runtime"
import type { Provider } from "../../src/provider/models"
import { TestConfig } from "../fixture/config"
import { testEffect } from "../lib/effect"
const clear = (id: string) => AppRuntime.runPromise(ModelCache.Service.use((cache) => cache.clear(id)))
const fetch = (id: string) => AppRuntime.runPromise(ModelCache.Service.use((cache) => cache.fetch(id)))
const failed = () => AppRuntime.runPromise(ModelCache.Service.use((cache) => cache.failedProviders()))
const failure = (id: string) => AppRuntime.runPromise(ModelCache.Service.use((cache) => cache.getFailure(id)))
type Failure = { kind: "unauthorized" | "network" | "schema" | "http"; status?: number }
type Result = { models: Provider["models"]; error?: Failure }
const CONFIG = JSON.stringify({ $schema: "https://app.kilo.ai/config.json" })
let result: Result = { models: {} }
let error: Error | undefined
async function withInstance<T>(fn: () => Promise<T>): Promise<T> {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "kilo.json"), CONFIG)
},
})
return WithInstance.provide({ directory: tmp.path, fn })
const auth = Layer.mock(Auth.Service)({
get: () => Effect.succeed(undefined),
})
function layer() {
const models = Layer.succeed(
ModelCache.KiloModelsService,
ModelCache.KiloModelsService.of({
fetch: () => (error ? Effect.fail(error) : Effect.succeed(result)),
}),
)
return Layer.fresh(ModelCache.layer).pipe(
Layer.provide(FetchHttpClient.layer),
Layer.provide(TestConfig.layer()),
Layer.provide(auth),
Layer.provide(models),
)
}
const it = testEffect(Layer.empty)
beforeEach(() => {
stubbedError = undefined
result = { models: {} }
error = undefined
})
test("failedProviders returns empty array when no fetch has occurred", async () => {
await clear("kilo")
expect(await failed()).not.toContain("kilo")
})
it.live("failedProviders returns empty array when no fetch has occurred", () =>
ModelCache.Service.use((cache) =>
Effect.gen(function* () {
expect(yield* cache.failedProviders()).not.toContain("kilo")
}),
).pipe(Effect.provide(layer())),
)
test("getFailure returns undefined when fetch succeeds", async () => {
stubbedResult = {
models: {
"test/model": {
id: "test/model",
name: "Test",
cost: { input: 1, output: 2 },
limit: { context: 128000, output: 4096 },
it.live("getFailure returns undefined when fetch succeeds", () =>
Effect.gen(function* () {
result = {
models: {
"test/model": {
id: "test/model",
name: "Test",
attachment: false,
reasoning: false,
release_date: "",
temperature: true,
tool_call: true,
cost: { input: 1, output: 2 },
limit: { context: 128000, output: 4096 },
},
},
},
}
await clear("kilo")
await withInstance(() => fetch("kilo"))
expect(await failure("kilo")).toBeUndefined()
expect(await failed()).not.toContain("kilo")
})
}
yield* ModelCache.Service.use((cache) =>
Effect.gen(function* () {
yield* cache.fetch("kilo")
expect(yield* cache.getFailure("kilo")).toBeUndefined()
expect(yield* cache.failedProviders()).not.toContain("kilo")
}),
).pipe(Effect.provide(layer()))
}),
)
test("failedProviders includes provider after auth error", async () => {
stubbedResult = { models: {}, error: { kind: "unauthorized", status: 401 } }
await clear("kilo")
await withInstance(() => fetch("kilo"))
expect(await failed()).toContain("kilo")
expect(await failure("kilo")).toMatchObject({ kind: "unauthorized", status: 401 })
})
it.live("failedProviders includes provider after auth error", () =>
Effect.gen(function* () {
result = { models: {}, error: { kind: "unauthorized", status: 401 } }
yield* ModelCache.Service.use((cache) =>
Effect.gen(function* () {
yield* cache.fetch("kilo")
expect(yield* cache.failedProviders()).toContain("kilo")
expect(yield* cache.getFailure("kilo")).toMatchObject({ kind: "unauthorized", status: 401 })
}),
).pipe(Effect.provide(layer()))
}),
)
test("gateway rejection remains recoverable through the Effect error channel", async () => {
stubbedError = new Error("gateway failed")
await clear("kilo")
const models = await withInstance(() =>
AppRuntime.runPromise(
ModelCache.Service.use((cache) => cache.fetch("kilo").pipe(Effect.catch(() => Effect.succeed({})))),
),
)
expect(models).toEqual({})
})
it.live("gateway rejection remains recoverable through the Effect error channel", () =>
Effect.gen(function* () {
error = new Error("gateway failed")
yield* ModelCache.Service.use((cache) =>
Effect.gen(function* () {
const models = yield* cache.fetch("kilo").pipe(Effect.catch(() => Effect.succeed({})))
expect(models).toEqual({})
}),
).pipe(Effect.provide(layer()))
}),
)
test("clear removes failure state", async () => {
stubbedResult = { models: {}, error: { kind: "network" } }
await clear("kilo")
await withInstance(() => fetch("kilo"))
expect(await failed()).toContain("kilo")
it.live("clear removes failure state", () =>
Effect.gen(function* () {
result = { models: {}, error: { kind: "network" } }
yield* ModelCache.Service.use((cache) =>
Effect.gen(function* () {
yield* cache.fetch("kilo")
expect(yield* cache.failedProviders()).toContain("kilo")
yield* cache.clear("kilo")
expect(yield* cache.failedProviders()).not.toContain("kilo")
expect(yield* cache.getFailure("kilo")).toBeUndefined()
}),
).pipe(Effect.provide(layer()))
}),
)
await clear("kilo")
expect(await failed()).not.toContain("kilo")
expect(await failure("kilo")).toBeUndefined()
})
test("failure state is cleared when subsequent fetch succeeds", async () => {
stubbedResult = { models: {}, error: { kind: "unauthorized", status: 401 } }
await clear("kilo")
await withInstance(() => fetch("kilo"))
expect(await failed()).toContain("kilo")
stubbedResult = {
models: {
"test/model": {
id: "test/model",
name: "Test",
cost: { input: 1, output: 2 },
limit: { context: 128000, output: 4096 },
},
},
}
await clear("kilo")
await withInstance(() => fetch("kilo"))
expect(await failed()).not.toContain("kilo")
expect(await failure("kilo")).toBeUndefined()
})
it.live("failure state is cleared when subsequent refresh succeeds", () =>
Effect.gen(function* () {
result = { models: {}, error: { kind: "unauthorized", status: 401 } }
yield* ModelCache.Service.use((cache) =>
Effect.gen(function* () {
yield* cache.fetch("kilo")
expect(yield* cache.failedProviders()).toContain("kilo")
result = {
models: {
"test/model": {
id: "test/model",
name: "Test",
attachment: false,
reasoning: false,
release_date: "",
temperature: true,
tool_call: true,
cost: { input: 1, output: 2 },
limit: { context: 128000, output: 4096 },
},
},
}
yield* cache.refresh("kilo")
expect(yield* cache.failedProviders()).not.toContain("kilo")
expect(yield* cache.getFailure("kilo")).toBeUndefined()
}),
).pipe(Effect.provide(layer()))
}),
)