mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-30 17:14:40 +08:00
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:
@@ -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()))
|
||||
}),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user