mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-24 16:02:55 +08:00
fix: unify model defaults and credential precedence
This commit is contained in:
@@ -4,4 +4,4 @@
|
||||
"@kilocode/kilo-gateway": patch
|
||||
---
|
||||
|
||||
Use organization model defaults after login and team switching while preserving valid model preferences. Keep unavailable organization catalogs from falling back to public models.
|
||||
Use organization model defaults in VS Code while preserving valid preferences. Return consistent defaults from the CLI provider APIs, respect environment credential overrides, and prevent public-model fallbacks for Org accounts.
|
||||
|
||||
@@ -240,7 +240,7 @@ async function fetchRawKiloModels(options?: {
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 && token && !organizationId && !baseURL.includes("/api/organizations/")) {
|
||||
if (response.status === 401 && token && !organizationId && !finalBaseURL.includes("/api/organizations/")) {
|
||||
return fetchRawKiloModels({})
|
||||
}
|
||||
const kind = response.status === 401 || response.status === 403 ? "unauthorized" : "http"
|
||||
|
||||
@@ -127,6 +127,11 @@ test.each([
|
||||
{ kilocodeOrganizationId: "org-123" },
|
||||
{ kilocodeToken: "expired-token", baseURL: "https://api.kilo.ai/api/organizations/org-123" },
|
||||
{ kilocodeToken: "expired-token", baseURL: "https://gateway.test/api/organizations/org-123" },
|
||||
{ kilocodeToken: "https://gateway.test/api/organizations/org-token:expired-token" },
|
||||
{
|
||||
kilocodeToken: "https://gateway.test/api/organizations/org-token:expired-token",
|
||||
baseURL: "https://api.kilo.ai/api/openrouter",
|
||||
},
|
||||
])("never retries an organization-scoped 401 against the public catalog: %j", async (options) => {
|
||||
const fetch = spyOn(globalThis, "fetch").mockResolvedValue(new Response(null, { status: 401 }))
|
||||
try {
|
||||
|
||||
@@ -63,22 +63,11 @@ export async function fetchProviderData(client: KiloClient, dir: string) {
|
||||
.authStatus({ directory: dir }, { throwOnError: true })
|
||||
.then((r) => r.data)
|
||||
.catch(() => undefined)
|
||||
const recommendation = kiloRequest.then(async (auth) => {
|
||||
if (!auth?.organizationId) return undefined
|
||||
return client.config
|
||||
.providers({ directory: dir }, { throwOnError: true })
|
||||
.then((r) => r.data?.default.kilo)
|
||||
.catch((error: unknown) => {
|
||||
console.warn("[Kilo New] Failed to fetch organization model default:", error)
|
||||
return undefined
|
||||
})
|
||||
})
|
||||
|
||||
const [{ data: response }, authMethods, kiloAuth, recommended] = await Promise.all([
|
||||
const [{ data: response }, authMethods, kiloAuth] = await Promise.all([
|
||||
client.provider.list({ directory: dir }, { throwOnError: true }),
|
||||
authRequest,
|
||||
kiloRequest,
|
||||
recommendation,
|
||||
])
|
||||
const authStates: Record<string, AuthState> = {}
|
||||
const storedKeys: Record<string, StoredProviderKey> = {}
|
||||
@@ -105,7 +94,8 @@ export async function fetchProviderData(client: KiloClient, dir: string) {
|
||||
const defaults = { ...response.default }
|
||||
if (organizationId) {
|
||||
const models = all.find((item) => item.id === KILO_PROVIDER_ID)?.models ?? {}
|
||||
const model = recommended && models[recommended] ? recommended : Object.keys(models).at(0)
|
||||
const recommended = response.default[KILO_PROVIDER_ID]
|
||||
const model = recommended && Object.hasOwn(models, recommended) ? recommended : Object.keys(models).at(0)
|
||||
if (model) defaults[KILO_PROVIDER_ID] = model
|
||||
if (!model) delete defaults[KILO_PROVIDER_ID]
|
||||
}
|
||||
|
||||
@@ -7,9 +7,16 @@ const { KiloProvider } = await import("../../src/KiloProvider")
|
||||
const external = { id: "external", name: "External", models: { model: { id: "model" } } }
|
||||
const catalog = (org: string) => ({
|
||||
data: {
|
||||
all: [{ id: "kilo", name: "Kilo Gateway", models: { [`${org}/model`]: { id: `${org}/model` } } }, external],
|
||||
all: [
|
||||
{
|
||||
id: "kilo",
|
||||
name: "Kilo Gateway",
|
||||
models: { [`${org}/first`]: { id: `${org}/first` }, [`${org}/model`]: { id: `${org}/model` } },
|
||||
},
|
||||
external,
|
||||
],
|
||||
connected: ["kilo", "external"],
|
||||
default: { kilo: "kilo-auto/free", external: "model" },
|
||||
default: { kilo: `${org}/model`, external: "model" },
|
||||
},
|
||||
})
|
||||
|
||||
@@ -32,7 +39,6 @@ function setup(list: () => Promise<ReturnType<typeof catalog>>, org: () => strin
|
||||
config: {
|
||||
get: async (): Promise<{ data: Config }> => ({ data: {} }),
|
||||
overlay: async () => ({ data: {} }),
|
||||
providers: async () => ({ data: { default: { kilo: `${org()}/model` } } }),
|
||||
},
|
||||
global: { config: { get: async () => ({ data: {} }) } },
|
||||
experimental: { capabilities: { get: async () => ({ data: {} }) } },
|
||||
|
||||
@@ -439,37 +439,56 @@ describe("disconnectProvider", () => {
|
||||
|
||||
describe("fetchProviderData", () => {
|
||||
for (const item of [
|
||||
{ name: "uses the allowed organization default", recommended: "org/default", expected: "org/default" },
|
||||
{ name: "uses the allowed organization API default", recommended: "org/default", expected: "org/default" },
|
||||
{ name: "uses the first allowed model when no default exists", recommended: undefined, expected: "org/first" },
|
||||
{ name: "uses the first allowed model when the default is empty", recommended: "", expected: "org/first" },
|
||||
{
|
||||
name: "ignores a default outside the organization catalog",
|
||||
recommended: "kilo-auto/free",
|
||||
expected: "org/first",
|
||||
},
|
||||
{ name: "uses the first allowed model when defaults cannot load", error: true, expected: "org/first" },
|
||||
{ name: "ignores inherited catalog properties", recommended: "toString", expected: "org/first" },
|
||||
{
|
||||
name: "does not invent a default for an empty catalog",
|
||||
empty: true,
|
||||
recommended: "org/default",
|
||||
expected: undefined,
|
||||
},
|
||||
{
|
||||
name: "does not retain a default without a Kilo provider",
|
||||
missing: true,
|
||||
recommended: "org/default",
|
||||
expected: undefined,
|
||||
},
|
||||
]) {
|
||||
it(item.name, async () => {
|
||||
const directories: string[] = []
|
||||
let calls = 0
|
||||
const external = {
|
||||
id: "anthropic",
|
||||
name: "Anthropic",
|
||||
models: { claude: { id: "claude" } },
|
||||
metadata: { priority: 1 },
|
||||
}
|
||||
const client = {
|
||||
provider: {
|
||||
list: async () => ({
|
||||
data: {
|
||||
all: [
|
||||
{
|
||||
id: "kilo",
|
||||
name: "Kilo Gateway",
|
||||
models: item.empty ? {} : { "org/first": { id: "org/first" }, "org/default": { id: "org/default" } },
|
||||
},
|
||||
{ id: "anthropic", name: "Anthropic", models: { claude: { id: "claude" } }, metadata: { priority: 1 } },
|
||||
...(item.missing
|
||||
? []
|
||||
: [
|
||||
{
|
||||
id: "kilo",
|
||||
name: "Kilo Gateway",
|
||||
models: item.empty
|
||||
? {}
|
||||
: { "org/first": { id: "org/first" }, "org/default": { id: "org/default" } },
|
||||
},
|
||||
]),
|
||||
{ ...external, key: "sk-test" },
|
||||
],
|
||||
connected: ["kilo", "anthropic"],
|
||||
default: { kilo: "kilo-auto/free", anthropic: "claude" },
|
||||
connected: item.missing ? ["anthropic"] : ["kilo", "anthropic"],
|
||||
default: { ...(item.recommended === undefined ? {} : { kilo: item.recommended }), anthropic: "claude" },
|
||||
},
|
||||
}),
|
||||
auth: async () => ({ data: {} }),
|
||||
@@ -478,10 +497,9 @@ describe("fetchProviderData", () => {
|
||||
authStatus: async () => ({ data: { authenticated: true, type: "oauth", organizationId: "org" } }),
|
||||
},
|
||||
config: {
|
||||
providers: async (input: { directory: string }) => {
|
||||
directories.push(input.directory)
|
||||
if (item.error) throw new Error("Defaults unavailable")
|
||||
return { data: { default: { kilo: item.recommended, anthropic: "unrelated" } } }
|
||||
providers: async () => {
|
||||
calls++
|
||||
return { data: { default: { kilo: "org/first", anthropic: "unrelated" } } }
|
||||
},
|
||||
},
|
||||
} as unknown as Parameters<typeof fetchProviderData>[0]
|
||||
@@ -489,16 +507,16 @@ describe("fetchProviderData", () => {
|
||||
const result = await fetchProviderData(client, "/workspace")
|
||||
expect(result.response.default.kilo).toBe(item.expected)
|
||||
expect(result.response.default.anthropic).toBe("claude")
|
||||
expect(result.response.all.find((provider) => provider.id === "anthropic")).toMatchObject({
|
||||
metadata: { priority: 1 },
|
||||
})
|
||||
expect(result.response.all.find((provider) => provider.id === "anthropic")).toEqual(external)
|
||||
expect(result.response.connected).toEqual(item.missing ? ["anthropic"] : ["kilo", "anthropic"])
|
||||
expect(result.authStates).toEqual({ kilo: "oauth", anthropic: "api" })
|
||||
expect(result.organizationId).toBe("org")
|
||||
expect(result.ready).toBe(true)
|
||||
expect(directories).toEqual(["/workspace"])
|
||||
expect(calls).toBe(0)
|
||||
})
|
||||
}
|
||||
|
||||
it("distinguishes failed auth context from Personal and removes unverified Kilo models", async () => {
|
||||
it.each([false, true])("removes unverified Kilo data without auth context (failure: %s)", async (fail) => {
|
||||
const client = {
|
||||
provider: {
|
||||
list: async () => ({
|
||||
@@ -515,7 +533,8 @@ describe("fetchProviderData", () => {
|
||||
},
|
||||
kilo: {
|
||||
authStatus: async () => {
|
||||
throw new Error("Context unavailable")
|
||||
if (fail) throw new Error("Context unavailable")
|
||||
return { data: undefined }
|
||||
},
|
||||
},
|
||||
} as unknown as Parameters<typeof fetchProviderData>[0]
|
||||
|
||||
@@ -1,31 +1,50 @@
|
||||
import type { Auth } from "@/auth"
|
||||
import type { Provider } from "@/provider/provider"
|
||||
import { fetchDefaultModel } from "@kilocode/kilo-gateway"
|
||||
import { fetchDefaultModel, getKiloUrlFromToken, KILO_API_BASE } from "@kilocode/kilo-gateway"
|
||||
|
||||
export function organization(
|
||||
options: { kilocodeOrganizationId?: string; baseURL?: string } | undefined,
|
||||
info: Auth.Info | undefined,
|
||||
) {
|
||||
type Options = { kilocodeOrganizationId?: string; baseURL?: string; apiKey?: string; kilocodeToken?: string }
|
||||
|
||||
export function token(options: Options | undefined, info: Auth.Info | undefined) {
|
||||
if (process.env.KILO_API_KEY) return process.env.KILO_API_KEY
|
||||
if (info?.type === "oauth") return info.access
|
||||
if (info?.type === "api") return info.key
|
||||
if (options?.kilocodeToken != null) return options.kilocodeToken
|
||||
return options?.apiKey || undefined
|
||||
}
|
||||
|
||||
function scoped(url: string) {
|
||||
return URL.parse(url)
|
||||
?.pathname.match(/\/api\/organizations\/([^/]+)/)
|
||||
?.at(1)
|
||||
}
|
||||
|
||||
export function organization(options: Options | undefined, info: Auth.Info | undefined) {
|
||||
return (
|
||||
options?.kilocodeOrganizationId ??
|
||||
URL.parse(options?.baseURL ?? "")
|
||||
?.pathname.match(/\/api\/organizations\/([^/]+)/)
|
||||
?.at(1) ??
|
||||
(info?.type === "oauth" ? info.accountId : undefined) ??
|
||||
process.env.KILO_ORG_ID
|
||||
process.env.KILO_ORG_ID ||
|
||||
(info?.type === "oauth" ? info.accountId : undefined) ||
|
||||
options?.kilocodeOrganizationId ||
|
||||
scoped(getKiloUrlFromToken(options?.baseURL ?? "", token(options, info) ?? ""))
|
||||
)
|
||||
}
|
||||
|
||||
export function compatible(options: { baseURL?: string; kilocodeToken?: string; kilocodeOrganizationId?: string }) {
|
||||
const org = scoped(getKiloUrlFromToken(options.baseURL ?? "", options.kilocodeToken ?? ""))
|
||||
return !org || !options.kilocodeOrganizationId || org === options.kilocodeOrganizationId
|
||||
}
|
||||
|
||||
export async function recommend(
|
||||
models: Provider.Info["models"],
|
||||
options: { kilocodeOrganizationId?: string; baseURL?: string; apiKey?: string } | undefined,
|
||||
models: Readonly<Record<string, unknown>>,
|
||||
options: Options | undefined,
|
||||
info: Auth.Info | undefined,
|
||||
known = true,
|
||||
) {
|
||||
const first = Object.keys(models).at(0)
|
||||
if (!first || !known) return first
|
||||
const org = organization(options, info)
|
||||
const stored = info?.type === "oauth" ? info.access : info?.key
|
||||
const token = org ? process.env.KILO_API_KEY || stored || options?.apiKey : stored
|
||||
const fallback = org ? Object.keys(models).at(0) : undefined
|
||||
if (org && !fallback) return undefined
|
||||
const model = await fetchDefaultModel(token, org, fallback)
|
||||
const key = token(options, info)
|
||||
if (!compatible({ baseURL: options?.baseURL, kilocodeToken: key, kilocodeOrganizationId: org })) return undefined
|
||||
const fallback = org ? first : undefined
|
||||
const endpoint = getKiloUrlFromToken(options?.baseURL || KILO_API_BASE, key ?? "")
|
||||
if (URL.parse(endpoint)?.origin !== URL.parse(KILO_API_BASE)?.origin) return fallback
|
||||
const model = await fetchDefaultModel(key, org, fallback)
|
||||
return Object.hasOwn(models, model) ? model : fallback
|
||||
}
|
||||
|
||||
@@ -17,6 +17,9 @@ import type { LanguageModelV3 } from "@ai-sdk/provider"
|
||||
import { mapValues, omit, pickBy } from "remeda"
|
||||
import { reasoningSummary } from "./reasoning-summary"
|
||||
import type { Provider } from "@/provider/provider"
|
||||
import type { Auth } from "@/auth"
|
||||
import type { Config } from "@/config/config"
|
||||
import { organization, token } from "./catalog"
|
||||
|
||||
/** Default timeout (ms) for provider HTTP requests (connection phase). */
|
||||
export const REQUEST_TIMEOUT_MS = 300_000 // 5 minutes
|
||||
@@ -167,6 +170,24 @@ export function patchKiloProviderPrivacy(provider: { options?: Record<string, an
|
||||
provider.options = { ...provider.options, dataCollection: "deny" }
|
||||
}
|
||||
|
||||
export function patchKiloProviderAuth(
|
||||
provider: Provider.Info | undefined,
|
||||
config: Config.Info,
|
||||
info: Auth.Info | undefined,
|
||||
) {
|
||||
if (!provider) return
|
||||
const options = config.provider?.kilo?.options
|
||||
const key = token(options, info)
|
||||
const org = organization(options, info)
|
||||
if (key !== undefined) provider.options.kilocodeToken = key
|
||||
if (org !== undefined) provider.options.kilocodeOrganizationId = org
|
||||
}
|
||||
|
||||
export function publicKiloProvider(provider: Provider.Info): Provider.Info {
|
||||
if (provider.id !== "kilo") return provider
|
||||
return { ...provider, key: undefined, options: omit(provider.options, ["apiKey", "kilocodeToken"]) }
|
||||
}
|
||||
|
||||
export function kiloCustomLoaders(dep: CustomDep): Record<string, CustomLoader> {
|
||||
return {
|
||||
"github-copilot-enterprise": () =>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Context, Deferred, Duration, Effect, Exit, Layer, Schema, Scope } from
|
||||
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { Config } from "../config/config"
|
||||
import { Auth } from "../auth"
|
||||
import { organization } from "@/kilocode/provider/catalog"
|
||||
import { compatible, organization, token } from "@/kilocode/provider/catalog"
|
||||
import type { Provider } from "@opencode-ai/core/models-dev"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
@@ -126,14 +126,9 @@ export const layer: Layer.Layer<
|
||||
|
||||
if (providerID === "kilo") {
|
||||
const item = config.provider?.[providerID]
|
||||
if (item?.options?.apiKey) options.kilocodeToken = item.options.apiKey
|
||||
|
||||
const info = yield* auth.get(providerID)
|
||||
options.kilocodeOrganizationId = organization(item?.options, info)
|
||||
if (info?.type === "api") options.kilocodeToken = info.key
|
||||
if (info?.type === "oauth") options.kilocodeToken = info.access
|
||||
|
||||
if (process.env.KILO_API_KEY) options.kilocodeToken = process.env.KILO_API_KEY
|
||||
options.kilocodeToken = token(item?.options, info)
|
||||
log.debug("auth options resolved", {
|
||||
providerID,
|
||||
hasToken: !!options.kilocodeToken,
|
||||
@@ -176,7 +171,9 @@ export const layer: Layer.Layer<
|
||||
}),
|
||||
),
|
||||
)
|
||||
return yield* fetchModels(providerID, { ...resolved, ...options })
|
||||
const input = { ...resolved, ...options }
|
||||
if (providerID === "kilo" && !compatible(input)) return { models: {}, error: { kind: "schema" as const } }
|
||||
return yield* fetchModels(providerID, input)
|
||||
})
|
||||
|
||||
const key = (providerID: string, options?: Options) => {
|
||||
|
||||
@@ -6,7 +6,7 @@ import * as Core from "@opencode-ai/core/models-dev"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { AI_SDK_PROVIDERS, KILO_OPENROUTER_BASE, PROMPTS } from "@kilocode/kilo-gateway"
|
||||
import { overlay } from "@/kilocode/anaconda-desktop/provider"
|
||||
import { organization } from "@/kilocode/provider/catalog"
|
||||
import { compatible, organization, token } from "@/kilocode/provider/catalog"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" // kilocode_change
|
||||
|
||||
@@ -84,8 +84,9 @@ export const layer: Layer.Layer<Service, never, Core.Service | Config.Service |
|
||||
...(url ? { baseURL: url } : {}),
|
||||
...(org ? { kilocodeOrganizationId: org } : {}),
|
||||
}
|
||||
const fetched = yield* cache.fetch("kilo", fetch).pipe(Effect.catch(() => Effect.succeed({})))
|
||||
const models = org || Object.keys(fetched).length > 0 ? fetched : (fallback?.models ?? {})
|
||||
const valid = compatible({ ...fetch, kilocodeToken: token(opts, info) })
|
||||
const fetched = valid ? yield* cache.fetch("kilo", fetch).pipe(Effect.catch(() => Effect.succeed({}))) : {}
|
||||
const models = !valid || org || Object.keys(fetched).length > 0 ? fetched : (fallback?.models ?? {})
|
||||
providers.kilo = {
|
||||
id: "kilo",
|
||||
name: "Kilo Gateway",
|
||||
@@ -94,7 +95,7 @@ export const layer: Layer.Layer<Service, never, Core.Service | Config.Service |
|
||||
npm: "@kilocode/kilo-gateway",
|
||||
models,
|
||||
}
|
||||
if (!org && Object.keys(fetched).length === 0)
|
||||
if (valid && !org && Object.keys(fetched).length === 0)
|
||||
yield* cache.refresh("kilo", fetch).pipe(Effect.ignore, Effect.forkDetach)
|
||||
yield* addApertis()
|
||||
return providers
|
||||
|
||||
@@ -40,6 +40,8 @@ import {
|
||||
customProviderVariants,
|
||||
patchCustomLoaderResult,
|
||||
patchKiloProviderPrivacy,
|
||||
patchKiloProviderAuth,
|
||||
publicKiloProvider,
|
||||
kiloSmallModelPriority,
|
||||
buildTimeoutSignal,
|
||||
requestTimeout,
|
||||
@@ -1133,7 +1135,7 @@ export function toPublicInfo(provider: Info): Info {
|
||||
return JSON.parse(
|
||||
JSON.stringify(
|
||||
{
|
||||
...provider,
|
||||
...publicKiloProvider(provider), // kilocode_change
|
||||
models: Object.fromEntries(Object.entries(provider.models).filter(([, model]) => Schema.is(Model)(model))),
|
||||
},
|
||||
(_, value) => {
|
||||
@@ -1679,6 +1681,7 @@ const layer = Layer.effect(
|
||||
mergeProvider(providerID, partial)
|
||||
}
|
||||
patchKiloProviderPrivacy(providers[ProviderV2.ID.make("kilo")], cfg) // kilocode_change
|
||||
patchKiloProviderAuth(providers[ProviderV2.ID.make("kilo")], cfg, auths["kilo"]) // kilocode_change
|
||||
|
||||
const gitlab = ProviderV2.ID.make("gitlab")
|
||||
if (discoveryLoaders[gitlab] && providers[gitlab] && isProviderAllowed(gitlab)) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Config } from "@/config/config"
|
||||
// kilocode_change start - preserve Kilo API default model overlay
|
||||
import { recommend } from "@/kilocode/provider/catalog"
|
||||
import { Auth } from "@/auth"
|
||||
import { Option } from "effect"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { filterPromptTrainingModels, nonEmptyProviders } from "@/kilocode/provider/model-filter"
|
||||
@@ -9,7 +10,7 @@ import { filterPromptTrainingModels, nonEmptyProviders } from "@/kilocode/provid
|
||||
import { Provider } from "@/provider/provider"
|
||||
import * as InstanceState from "@/effect/instance-state"
|
||||
import { Effect } from "effect"
|
||||
import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi" // kilocode_change
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import { InstanceHttpApi } from "../api"
|
||||
import { markInstanceForDisposal } from "../lifecycle"
|
||||
|
||||
@@ -17,6 +18,7 @@ export const configHandlers = HttpApiBuilder.group(InstanceHttpApi, "config", (h
|
||||
Effect.gen(function* () {
|
||||
const providerSvc = yield* Provider.Service
|
||||
const configSvc = yield* Config.Service
|
||||
const auth = yield* Auth.Service // kilocode_change
|
||||
|
||||
const get = Effect.fn("ConfigHttpApi.get")(function* () {
|
||||
return yield* configSvc.get()
|
||||
@@ -45,11 +47,15 @@ export const configHandlers = HttpApiBuilder.group(InstanceHttpApi, "config", (h
|
||||
// kilocode_change end
|
||||
|
||||
// kilocode_change start - Fetch default model from Kilo API when the kilo provider is available.
|
||||
if (providers[ProviderV2.ID.kilo]) {
|
||||
const auth = yield* Auth.Service
|
||||
const info = yield* auth.get("kilo").pipe(Effect.mapError(() => new HttpApiError.Unauthorized({}))) // kilocode_change
|
||||
if (defaults[ProviderV2.ID.kilo]) {
|
||||
const info = yield* auth.get("kilo").pipe(Effect.option)
|
||||
const model = yield* Effect.promise(() =>
|
||||
recommend(providers[ProviderV2.ID.kilo].models, config.provider?.kilo?.options, info),
|
||||
recommend(
|
||||
providers[ProviderV2.ID.kilo].models,
|
||||
config.provider?.kilo?.options,
|
||||
Option.getOrUndefined(info),
|
||||
Option.isSome(info),
|
||||
),
|
||||
)
|
||||
if (model && providers[ProviderV2.ID.kilo]?.models[model]) defaults[ProviderV2.ID.kilo] = ModelV2.ID.make(model)
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@ import { Provider } from "@/provider/provider"
|
||||
import { mapValues, pickBy } from "remeda" // kilocode_change
|
||||
import { ModelCache } from "@/provider/model-cache" // kilocode_change
|
||||
import { Auth } from "@/auth" // kilocode_change
|
||||
import { organization } from "@/kilocode/provider/catalog" // kilocode_change
|
||||
import { organization, recommend } from "@/kilocode/provider/catalog" // kilocode_change
|
||||
import { ModelV2 } from "@opencode-ai/core/model" // kilocode_change
|
||||
import { Option } from "effect" // kilocode_change
|
||||
import {
|
||||
disposeAllInstancesAfterProviderAuthCallback,
|
||||
@@ -62,6 +63,7 @@ export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "provider"
|
||||
const connected = yield* provider.list()
|
||||
// kilocode_change start
|
||||
const info = yield* access.get("kilo").pipe(Effect.option)
|
||||
const unavailable = Option.isNone(info) && ("kilo" in filtered || "kilo" in connected)
|
||||
if (Option.isNone(info) || organization(config.provider?.kilo?.options, info.value)) delete filtered.kilo
|
||||
const providers = filterPromptTrainingModels(
|
||||
Object.assign(
|
||||
@@ -70,25 +72,37 @@ export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "provider"
|
||||
),
|
||||
config.hide_prompt_training_models === true,
|
||||
)
|
||||
if (Option.isNone(info)) delete providers.kilo
|
||||
// kilocode_change end
|
||||
// kilocode_change start
|
||||
const failed = yield* cache.failedProviders()
|
||||
// Note: connected only contains providers with non-empty models after Provider.Service.list(),
|
||||
// so failed must be checked explicitly for providers whose fetch returned an error.
|
||||
const failedSet = new Set(failed)
|
||||
if (unavailable) failedSet.add("kilo")
|
||||
const validProviders = pickBy(
|
||||
providers,
|
||||
(item, id) => Object.keys(item.models).length > 0 || id in connected || failedSet.has(id),
|
||||
)
|
||||
const defaults = Provider.defaultModelIDs(pickBy(validProviders, (item) => Object.keys(item.models).length > 0))
|
||||
if (connected[ProviderV2.ID.kilo] && defaults[ProviderV2.ID.kilo]) {
|
||||
const model = yield* Effect.promise(() =>
|
||||
recommend(
|
||||
validProviders.kilo.models,
|
||||
config.provider?.kilo?.options,
|
||||
Option.getOrUndefined(info),
|
||||
Option.isSome(info),
|
||||
),
|
||||
)
|
||||
if (model) defaults[ProviderV2.ID.kilo] = ModelV2.ID.make(model)
|
||||
}
|
||||
return {
|
||||
all: Object.values(validProviders).map((item) => ({
|
||||
...Provider.toPublicInfo(item),
|
||||
metadata: providerMetadata(item.id),
|
||||
})), // kilocode_change
|
||||
default: Provider.defaultModelIDs(pickBy(validProviders, (item) => Object.keys(item.models).length > 0)),
|
||||
connected: Object.keys(connected).filter((id) => id !== "kilo" || Option.isSome(info)),
|
||||
failed,
|
||||
default: defaults,
|
||||
connected: Object.keys(connected),
|
||||
failed: [...failedSet],
|
||||
}
|
||||
// kilocode_change end
|
||||
})
|
||||
|
||||
@@ -135,6 +135,26 @@ function layer(options?: { config?: Config.Info; info?: Auth.Info; fetch?: Model
|
||||
|
||||
const it = testEffect(testInstanceStoreLayer)
|
||||
|
||||
function environment(values: Record<string, string | undefined>) {
|
||||
return Effect.acquireRelease(
|
||||
Effect.sync(() => {
|
||||
const previous = Object.fromEntries(Object.keys(values).map((key) => [key, process.env[key]]))
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
if (value === undefined) delete process.env[key]
|
||||
if (value !== undefined) process.env[key] = value
|
||||
}
|
||||
return previous
|
||||
}),
|
||||
(previous) =>
|
||||
Effect.sync(() => {
|
||||
for (const [key, value] of Object.entries(previous)) {
|
||||
if (value === undefined) delete process.env[key]
|
||||
if (value !== undefined) process.env[key] = value
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
it.live("assembles paid Kilo models without auth", () =>
|
||||
Effect.gen(function* () {
|
||||
const providers = yield* ModelsDev.Service.use((models) => models.get()).pipe(
|
||||
@@ -169,17 +189,7 @@ for (const context of ["config", "oauth", "env", "url"] as const) {
|
||||
for (const outcome of ["empty", "unauthorized", "network", "throw"] as const) {
|
||||
it.live(`keeps ${context} Org ${outcome} catalogs unavailable without public fallback or detached refresh`, () =>
|
||||
Effect.gen(function* () {
|
||||
const env = process.env.KILO_ORG_ID
|
||||
yield* Effect.acquireRelease(
|
||||
Effect.sync(() => {
|
||||
process.env.KILO_ORG_ID = "org-env"
|
||||
}),
|
||||
() =>
|
||||
Effect.sync(() => {
|
||||
if (env === undefined) delete process.env.KILO_ORG_ID
|
||||
else process.env.KILO_ORG_ID = env
|
||||
}),
|
||||
)
|
||||
yield* environment({ KILO_API_KEY: undefined, KILO_ORG_ID: context === "env" ? "org-env" : undefined })
|
||||
const calls: Parameters<ModelCache.KiloModels["fetch"]>[0][] = []
|
||||
const config: Config.Info =
|
||||
context === "config"
|
||||
@@ -195,7 +205,7 @@ for (const context of ["config", "oauth", "env", "url"] as const) {
|
||||
access: "test-token",
|
||||
refresh: "test-refresh",
|
||||
expires: 0,
|
||||
accountId: "org-oauth",
|
||||
...(context === "oauth" ? { accountId: "org-oauth" } : {}),
|
||||
})
|
||||
const fetch: ModelCache.KiloModels["fetch"] = (options) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -216,6 +226,124 @@ for (const context of ["config", "oauth", "env", "url"] as const) {
|
||||
}
|
||||
}
|
||||
|
||||
for (const scenario of [
|
||||
{
|
||||
name: "environment",
|
||||
env: "org-env",
|
||||
account: "org-oauth",
|
||||
configured: "org-config",
|
||||
baseURL: "https://gateway.test",
|
||||
org: "org-env",
|
||||
url: "https://gateway.test/api/organizations/org-env",
|
||||
},
|
||||
{
|
||||
name: "OAuth",
|
||||
env: undefined,
|
||||
account: "org-oauth",
|
||||
configured: "org-config",
|
||||
baseURL: "https://gateway.test",
|
||||
org: "org-oauth",
|
||||
url: "https://gateway.test/api/organizations/org-oauth",
|
||||
},
|
||||
{
|
||||
name: "configured",
|
||||
env: undefined,
|
||||
account: undefined,
|
||||
configured: "org-config",
|
||||
baseURL: "https://gateway.test",
|
||||
org: "org-config",
|
||||
url: "https://gateway.test/api/organizations/org-config",
|
||||
},
|
||||
{
|
||||
name: "scoped URL",
|
||||
env: undefined,
|
||||
account: undefined,
|
||||
configured: undefined,
|
||||
baseURL: "https://gateway.test/api/organizations/org-url",
|
||||
org: "org-url",
|
||||
url: "https://gateway.test/api/organizations/org-url",
|
||||
},
|
||||
]) {
|
||||
it.live(`wrapper and cache use the same ${scenario.name} organization and credentials`, () =>
|
||||
Effect.gen(function* () {
|
||||
yield* environment({ KILO_ORG_ID: scenario.env, KILO_API_KEY: "env-token" })
|
||||
const calls: Parameters<ModelCache.KiloModels["fetch"]>[0][] = []
|
||||
const config: Config.Info = {
|
||||
provider: {
|
||||
kilo: {
|
||||
options: {
|
||||
apiKey: "configured-token",
|
||||
kilocodeOrganizationId: scenario.configured,
|
||||
baseURL: scenario.baseURL,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
const info = new Auth.Oauth({
|
||||
type: "oauth",
|
||||
access: "stored-token",
|
||||
refresh: "refresh",
|
||||
expires: 0,
|
||||
accountId: scenario.account,
|
||||
})
|
||||
const providers = yield* ModelsDev.Service.use((models) => models.get()).pipe(
|
||||
Effect.provide(
|
||||
layer({
|
||||
config,
|
||||
info,
|
||||
fetch: (options) => {
|
||||
calls.push(options)
|
||||
return Effect.succeed({
|
||||
models: { allowed: { id: "allowed", name: "Allowed", limit: { context: 128000, output: 4096 } } },
|
||||
})
|
||||
},
|
||||
}),
|
||||
),
|
||||
provideInstance(process.cwd()),
|
||||
)
|
||||
expect(Object.keys(providers.kilo.models)).toEqual(["allowed"])
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls.at(0)).toMatchObject({
|
||||
kilocodeOrganizationId: scenario.org,
|
||||
kilocodeToken: "env-token",
|
||||
baseURL: scenario.url,
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
it.live("does not serve a warm or public catalog after an Org-scoped URL conflicts with the selected Org", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* environment({ KILO_ORG_ID: "org-env", KILO_API_KEY: "env-token" })
|
||||
const options = { baseURL: "https://gateway.test/api/organizations/org-env" }
|
||||
const calls: Parameters<ModelCache.KiloModels["fetch"]>[0][] = []
|
||||
yield* ModelsDev.Service.use((models) =>
|
||||
Effect.gen(function* () {
|
||||
expect(Object.keys((yield* models.get()).kilo.models)).toEqual(["allowed"])
|
||||
options.baseURL = "https://gateway.test/api/organizations/org-other"
|
||||
expect((yield* models.get()).kilo.models).toEqual({})
|
||||
expect((yield* models.get()).kilo.models).toEqual({})
|
||||
options.baseURL = "https://gateway.test/api/organizations/org-env"
|
||||
expect(Object.keys((yield* models.get()).kilo.models)).toEqual(["allowed"])
|
||||
expect(calls).toHaveLength(1)
|
||||
}),
|
||||
).pipe(
|
||||
Effect.provide(
|
||||
layer({
|
||||
config: { provider: { kilo: { options } } },
|
||||
fetch: (input) => {
|
||||
calls.push(input)
|
||||
return Effect.succeed({
|
||||
models: { allowed: { id: "allowed", name: "Allowed", limit: { context: 128000, output: 4096 } } },
|
||||
})
|
||||
},
|
||||
}),
|
||||
),
|
||||
provideInstance(process.cwd()),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("preserves Personal public snapshot fallback", () =>
|
||||
Effect.gen(function* () {
|
||||
const env = process.env.KILO_ORG_ID
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// 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 { expect } from "bun:test"
|
||||
import { expect, spyOn } from "bun:test"
|
||||
import { Deferred, Effect, Fiber, Layer, Ref } from "effect"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
@@ -10,13 +10,18 @@ import * as Log from "@opencode-ai/core/util/log"
|
||||
Log.init({ print: false })
|
||||
|
||||
import { Auth } from "../../src/auth"
|
||||
import { recommend } from "../../src/kilocode/provider/catalog"
|
||||
import { ModelCache } from "../../src/provider/model-cache"
|
||||
import { TestConfig } from "../fixture/config"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
type Options = Parameters<ModelCache.KiloModels["fetch"]>[0]
|
||||
|
||||
function layer(info: Auth.Info | undefined, captured: Ref.Ref<Options | undefined>) {
|
||||
function layer(
|
||||
info: Auth.Info | undefined,
|
||||
captured: Ref.Ref<Options | undefined>,
|
||||
options: Record<string, string> = {},
|
||||
) {
|
||||
const auth = Layer.mock(Auth.Service)({
|
||||
get: (id) => Effect.succeed(id === "kilo" ? info : undefined),
|
||||
})
|
||||
@@ -40,7 +45,7 @@ function layer(info: Auth.Info | undefined, captured: Ref.Ref<Options | undefine
|
||||
)
|
||||
return Layer.fresh(ModelCache.layer).pipe(
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
Layer.provide(TestConfig.layer()),
|
||||
Layer.provide(TestConfig.layer({ get: () => Effect.succeed({ provider: { kilo: { options } } }) })),
|
||||
Layer.provide(auth),
|
||||
Layer.provide(models),
|
||||
)
|
||||
@@ -48,6 +53,191 @@ function layer(info: Auth.Info | undefined, captured: Ref.Ref<Options | undefine
|
||||
|
||||
const it = testEffect(Layer.empty)
|
||||
|
||||
function environment(values: Record<string, string | undefined>) {
|
||||
return Effect.acquireRelease(
|
||||
Effect.sync(() => {
|
||||
const previous = Object.fromEntries(Object.keys(values).map((key) => [key, process.env[key]]))
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
if (value === undefined) delete process.env[key]
|
||||
if (value !== undefined) process.env[key] = value
|
||||
}
|
||||
return previous
|
||||
}),
|
||||
(previous) =>
|
||||
Effect.sync(() => {
|
||||
for (const [key, value] of Object.entries(previous)) {
|
||||
if (value === undefined) delete process.env[key]
|
||||
if (value !== undefined) process.env[key] = value
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
for (const org of [false, true]) {
|
||||
for (const item of [
|
||||
{ name: "environment", auth: "oauth", key: "env-token", env: "org-env", token: "env-token", org: "org-env" },
|
||||
{ name: "OAuth", auth: "oauth", key: undefined, env: undefined, token: "stored-token", org: "org-stored" },
|
||||
{ name: "API", auth: "api", key: undefined, env: undefined, token: "stored-token", org: "org-config" },
|
||||
{ name: "configured", auth: "none", key: undefined, env: undefined, token: "configured-token", org: "org-config" },
|
||||
{
|
||||
name: "Kilo token",
|
||||
auth: "none",
|
||||
key: undefined,
|
||||
env: undefined,
|
||||
token: "configured-kilo-token",
|
||||
org: "org-config",
|
||||
},
|
||||
{ name: "empty Kilo token", auth: "none", key: undefined, env: undefined, token: "", org: "org-config" },
|
||||
{ name: "empty environment", auth: "oauth", key: "", env: "", token: "stored-token", org: "org-stored" },
|
||||
{ name: "empty stored token", auth: "empty", key: undefined, env: undefined, token: "", org: "org-stored" },
|
||||
]) {
|
||||
it.live(`catalog and default requests share ${item.name} credentials (Org: ${org})`, () =>
|
||||
Effect.gen(function* () {
|
||||
yield* environment({ KILO_API_KEY: item.key, KILO_ORG_ID: org ? item.env : undefined })
|
||||
const captured = yield* Ref.make<Options | undefined>(undefined)
|
||||
const info =
|
||||
item.auth === "api"
|
||||
? new Auth.Api({ type: "api", key: "stored-token" })
|
||||
: item.auth === "none"
|
||||
? undefined
|
||||
: new Auth.Oauth({
|
||||
type: "oauth",
|
||||
access: item.auth === "empty" ? "" : "stored-token",
|
||||
refresh: "stored-refresh",
|
||||
expires: 0,
|
||||
...(org ? { accountId: "org-stored" } : {}),
|
||||
})
|
||||
const options = {
|
||||
apiKey: "configured-token",
|
||||
...(["Kilo token", "empty Kilo token"].includes(item.name) ? { kilocodeToken: item.token } : {}),
|
||||
...(org ? { kilocodeOrganizationId: "org-config" } : {}),
|
||||
}
|
||||
const requests: Array<{ path: string; authorization: string | null }> = []
|
||||
yield* Effect.acquireRelease(
|
||||
Effect.sync(() => {
|
||||
const original = globalThis.fetch
|
||||
return spyOn(globalThis, "fetch").mockImplementation(
|
||||
Object.assign(
|
||||
async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url)
|
||||
const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined))
|
||||
requests.push({ path: url.pathname, authorization: headers.get("authorization") })
|
||||
return Response.json({ defaultModel: "selected", defaultFreeModel: "free" })
|
||||
},
|
||||
{ preconnect: original.preconnect },
|
||||
),
|
||||
)
|
||||
}),
|
||||
(fetch) => Effect.sync(() => fetch.mockRestore()),
|
||||
)
|
||||
yield* ModelCache.Service.use((cache) => cache.fetch("kilo")).pipe(
|
||||
Effect.provide(layer(info, captured, options)),
|
||||
)
|
||||
expect((yield* Ref.get(captured))?.kilocodeToken).toBe(item.token)
|
||||
expect((yield* Ref.get(captured))?.kilocodeOrganizationId).toBe(org ? item.org : undefined)
|
||||
expect(yield* Effect.promise(() => recommend({ first: {}, selected: {}, free: {} }, options, info))).toBe(
|
||||
item.token ? "selected" : "free",
|
||||
)
|
||||
expect(requests).toEqual([
|
||||
{
|
||||
path: org ? `/api/organizations/${item.org}/defaults` : "/api/defaults",
|
||||
authorization: item.token ? `Bearer ${item.token}` : null,
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
it.live("anonymous Personal defaults do not borrow a configured or stored credential", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* environment({ KILO_API_KEY: undefined, KILO_ORG_ID: undefined })
|
||||
const requests: Array<string | null> = []
|
||||
yield* Effect.acquireRelease(
|
||||
Effect.sync(() => {
|
||||
const original = globalThis.fetch
|
||||
return spyOn(globalThis, "fetch").mockImplementation(
|
||||
Object.assign(
|
||||
async (_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
requests.push(new Headers(init?.headers).get("authorization"))
|
||||
return Response.json({ defaultModel: "selected", defaultFreeModel: "free" })
|
||||
},
|
||||
{ preconnect: original.preconnect },
|
||||
),
|
||||
)
|
||||
}),
|
||||
(fetch) => Effect.sync(() => fetch.mockRestore()),
|
||||
)
|
||||
expect(yield* Effect.promise(() => recommend({ selected: {}, free: {} }, undefined, undefined))).toBe("free")
|
||||
expect(requests).toEqual([null])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("explicit fetch credentials override environment values, including explicit clearing", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* environment({ KILO_API_KEY: "env-token", KILO_ORG_ID: "org-env" })
|
||||
const captured = yield* Ref.make<Options | undefined>(undefined)
|
||||
const info = new Auth.Oauth({
|
||||
type: "oauth",
|
||||
access: "stored-token",
|
||||
refresh: "refresh",
|
||||
expires: 0,
|
||||
accountId: "org-stored",
|
||||
})
|
||||
yield* ModelCache.Service.use((cache) =>
|
||||
Effect.gen(function* () {
|
||||
yield* cache.fetch("kilo", { kilocodeToken: "explicit-token", kilocodeOrganizationId: "org-explicit" })
|
||||
expect(yield* Ref.get(captured)).toMatchObject({
|
||||
kilocodeToken: "explicit-token",
|
||||
kilocodeOrganizationId: "org-explicit",
|
||||
})
|
||||
yield* cache.refresh("kilo", { kilocodeToken: undefined, kilocodeOrganizationId: undefined })
|
||||
expect((yield* Ref.get(captured))?.kilocodeToken).toBeUndefined()
|
||||
expect((yield* Ref.get(captured))?.kilocodeOrganizationId).toBeUndefined()
|
||||
yield* cache.refresh("kilo", { kilocodeToken: "", kilocodeOrganizationId: "" })
|
||||
expect((yield* Ref.get(captured))?.kilocodeToken).toBe("")
|
||||
expect((yield* Ref.get(captured))?.kilocodeOrganizationId).toBe("")
|
||||
}),
|
||||
).pipe(Effect.provide(layer(info, captured, { apiKey: "configured-token", kilocodeOrganizationId: "org-config" })))
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("rejects a model endpoint whose pinned Org conflicts with the selected environment Org", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* environment({ KILO_API_KEY: "env-token", KILO_ORG_ID: "org-env" })
|
||||
const captured = yield* Ref.make<Options | undefined>(undefined)
|
||||
yield* ModelCache.Service.use((cache) =>
|
||||
Effect.gen(function* () {
|
||||
expect(yield* cache.fetch("kilo", { baseURL: "https://gateway.test/api/organizations/org-pinned" })).toEqual({})
|
||||
expect(yield* cache.getFailure("kilo")).toEqual({ kind: "schema" })
|
||||
expect(yield* Ref.get(captured)).toBeUndefined()
|
||||
}),
|
||||
).pipe(Effect.provide(layer(undefined, captured)))
|
||||
}),
|
||||
)
|
||||
|
||||
for (const source of ["configured", "token", "personal", "conflict"] as const) {
|
||||
it.live(`does not send ${source} transport credentials to a different defaults service`, () =>
|
||||
Effect.gen(function* () {
|
||||
yield* environment({ KILO_API_KEY: undefined, KILO_ORG_ID: source === "conflict" ? "org-env" : undefined })
|
||||
const url =
|
||||
source === "personal"
|
||||
? "https://gateway.test/api/openrouter"
|
||||
: "https://gateway.test/api/organizations/org-pinned"
|
||||
const info = source === "token" ? new Auth.Api({ type: "api", key: `${url}:private-token` }) : undefined
|
||||
const options = source === "token" ? undefined : { apiKey: "private-token", baseURL: url }
|
||||
const fetch = yield* Effect.acquireRelease(
|
||||
Effect.sync(() => spyOn(globalThis, "fetch").mockRejectedValue(new Error("Unexpected defaults request"))),
|
||||
(fetch) => Effect.sync(() => fetch.mockRestore()),
|
||||
)
|
||||
expect(yield* Effect.promise(() => recommend({ first: {}, selected: {} }, options, info))).toBe(
|
||||
source === "personal" || source === "conflict" ? undefined : "first",
|
||||
)
|
||||
expect(fetch).not.toHaveBeenCalled()
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
it.live("switch invalidation drops warm Personal and delayed prior catalogs", () =>
|
||||
Effect.gen(function* () {
|
||||
const account = yield* Ref.make<string | undefined>(undefined)
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import type { Auth } from "../../src/auth"
|
||||
import { Config } from "../../src/config/config"
|
||||
import { ModelsDev } from "../../src/provider/models"
|
||||
import { Provider } from "../../src/provider/provider"
|
||||
import { TestConfig } from "../fixture/config"
|
||||
import { testInstanceStoreLayer } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(AppNodeBuilder.build(Provider.node))
|
||||
const inference = testEffect(testInstanceStoreLayer)
|
||||
|
||||
const auth = <A, E, R>(value: Record<string, unknown>, effect: Effect.Effect<A, E, R>) =>
|
||||
Effect.acquireUseRelease(
|
||||
@@ -77,3 +84,306 @@ it.instance(
|
||||
),
|
||||
{ config: {} },
|
||||
)
|
||||
|
||||
function environment(values: Record<string, string | undefined>) {
|
||||
return Effect.acquireRelease(
|
||||
Effect.sync(() => {
|
||||
const previous = Object.fromEntries(Object.keys(values).map((key) => [key, process.env[key]]))
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
if (value === undefined) delete process.env[key]
|
||||
if (value !== undefined) process.env[key] = value
|
||||
}
|
||||
return previous
|
||||
}),
|
||||
(previous) =>
|
||||
Effect.sync(() => {
|
||||
for (const [key, value] of Object.entries(previous)) {
|
||||
if (value === undefined) delete process.env[key]
|
||||
if (value !== undefined) process.env[key] = value
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const oauth = {
|
||||
type: "oauth",
|
||||
refresh: "stored-refresh",
|
||||
access: "stored-token",
|
||||
accountId: "stored-org",
|
||||
expires: Date.now() + 3_600_000,
|
||||
} satisfies Auth.Info
|
||||
const configured = {
|
||||
apiKey: "configured-key",
|
||||
kilocodeToken: "configured-token",
|
||||
kilocodeOrganizationId: "configured-org",
|
||||
}
|
||||
const scenarios: {
|
||||
name: string
|
||||
info?: Auth.Info
|
||||
env?: string
|
||||
organization?: string
|
||||
options?: Record<string, string>
|
||||
key: string
|
||||
org?: string
|
||||
token?: string
|
||||
}[] = [
|
||||
{
|
||||
name: "environment over OAuth and config",
|
||||
info: oauth,
|
||||
env: "env-token",
|
||||
organization: "env-org",
|
||||
options: configured,
|
||||
key: "env-token",
|
||||
org: "env-org",
|
||||
token: "env-token",
|
||||
},
|
||||
{
|
||||
name: "environment over saved API and config",
|
||||
info: { type: "api", key: "stored-key" },
|
||||
env: "env-token",
|
||||
organization: "env-org",
|
||||
options: configured,
|
||||
key: "env-token",
|
||||
org: "env-org",
|
||||
token: "env-token",
|
||||
},
|
||||
{
|
||||
name: "OAuth over config",
|
||||
info: oauth,
|
||||
options: configured,
|
||||
key: "stored-token",
|
||||
org: "stored-org",
|
||||
token: "stored-token",
|
||||
},
|
||||
{
|
||||
name: "saved API over config",
|
||||
info: { type: "api", key: "stored-key" },
|
||||
options: configured,
|
||||
key: "stored-key",
|
||||
org: "configured-org",
|
||||
token: "stored-key",
|
||||
},
|
||||
{
|
||||
name: "configured token alias over apiKey",
|
||||
options: configured,
|
||||
key: "configured-token",
|
||||
org: "configured-org",
|
||||
token: "configured-token",
|
||||
},
|
||||
{
|
||||
name: "configured apiKey",
|
||||
options: { apiKey: "configured-key", kilocodeOrganizationId: "configured-org" },
|
||||
key: "configured-key",
|
||||
org: "configured-org",
|
||||
token: "configured-key",
|
||||
},
|
||||
{
|
||||
name: "empty environment falls back to OAuth",
|
||||
info: oauth,
|
||||
env: "",
|
||||
organization: "",
|
||||
options: configured,
|
||||
key: "stored-token",
|
||||
org: "stored-org",
|
||||
token: "stored-token",
|
||||
},
|
||||
{
|
||||
name: "empty OAuth token overrides config",
|
||||
info: { ...oauth, access: "" },
|
||||
options: configured,
|
||||
key: "",
|
||||
org: "stored-org",
|
||||
token: "",
|
||||
},
|
||||
{
|
||||
name: "empty saved API token overrides config",
|
||||
info: { type: "api", key: "" },
|
||||
options: configured,
|
||||
key: "",
|
||||
org: "configured-org",
|
||||
token: "",
|
||||
},
|
||||
{
|
||||
name: "empty configured token overrides apiKey",
|
||||
options: { ...configured, kilocodeToken: "" },
|
||||
key: "",
|
||||
org: "configured-org",
|
||||
token: "",
|
||||
},
|
||||
{ name: "empty configured apiKey remains empty", options: { apiKey: "" }, key: "" },
|
||||
{
|
||||
name: "empty OAuth Org falls back to config",
|
||||
info: { ...oauth, accountId: "" },
|
||||
options: configured,
|
||||
key: "stored-token",
|
||||
org: "configured-org",
|
||||
token: "stored-token",
|
||||
},
|
||||
{
|
||||
name: "environment token keeps OAuth Org",
|
||||
info: oauth,
|
||||
env: "env-token",
|
||||
options: configured,
|
||||
key: "env-token",
|
||||
org: "stored-org",
|
||||
token: "env-token",
|
||||
},
|
||||
{
|
||||
name: "environment Org keeps OAuth token",
|
||||
info: oauth,
|
||||
organization: "env-org",
|
||||
options: configured,
|
||||
key: "stored-token",
|
||||
org: "env-org",
|
||||
token: "stored-token",
|
||||
},
|
||||
{
|
||||
name: "configured URL Org fallback",
|
||||
options: { apiKey: "configured-key", baseURL: "https://gateway.test/api/organizations/url-org" },
|
||||
key: "configured-key",
|
||||
org: "url-org",
|
||||
token: "configured-key",
|
||||
},
|
||||
{
|
||||
name: "token URL Org fallback",
|
||||
options: { kilocodeToken: "https://gateway.test/api/organizations/token-org:configured-token" },
|
||||
key: "https://gateway.test/api/organizations/token-org:configured-token",
|
||||
org: "token-org",
|
||||
token: "https://gateway.test/api/organizations/token-org:configured-token",
|
||||
},
|
||||
{
|
||||
name: "environment without stored or configured credentials",
|
||||
env: "env-token",
|
||||
organization: "env-org",
|
||||
key: "env-token",
|
||||
org: "env-org",
|
||||
token: "env-token",
|
||||
},
|
||||
{ name: "anonymous without credentials", key: "anonymous" },
|
||||
{ name: "empty Org stays personal", options: { kilocodeOrganizationId: "" }, key: "anonymous", org: "" },
|
||||
]
|
||||
|
||||
for (const scenario of scenarios) {
|
||||
inference.instance(`Kilo inference uses ${scenario.name}`, () =>
|
||||
Effect.gen(function* () {
|
||||
yield* environment({
|
||||
KILO_API_KEY: scenario.env,
|
||||
KILO_ORG_ID: scenario.organization,
|
||||
KILO_AUTH_CONTENT: JSON.stringify(scenario.info ? { kilo: scenario.info } : {}),
|
||||
})
|
||||
const calls: Headers[] = []
|
||||
const fetch = async (_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
calls.push(new Headers(init?.headers))
|
||||
return Response.json({ error: { message: "test inference response" } }, { status: 401 })
|
||||
}
|
||||
const config: Config.Info = {
|
||||
provider: {
|
||||
kilo: {
|
||||
npm: "@kilocode/kilo-gateway",
|
||||
env: ["KILO_API_KEY"],
|
||||
options: { ...scenario.options, headers: { "x-custom": "preserved" }, fetch },
|
||||
models: { "test-model": { name: "Test Model", limit: { context: 128000, output: 4096 } } },
|
||||
},
|
||||
},
|
||||
}
|
||||
yield* Effect.gen(function* () {
|
||||
const provider = yield* Provider.Service
|
||||
const item = yield* provider.getProvider(ProviderV2.ID.kilo)
|
||||
expect(item.options.kilocodeToken).toBe(scenario.token)
|
||||
expect(item.options.kilocodeOrganizationId).toBe(scenario.org)
|
||||
expect(item.options.fetch).toBe(fetch)
|
||||
expect(item.options.headers).toEqual({ "x-custom": "preserved" })
|
||||
const output = Provider.toPublicInfo(item)
|
||||
expect(output.key).toBeUndefined()
|
||||
expect(output.options.apiKey).toBeUndefined()
|
||||
expect(output.options.kilocodeToken).toBeUndefined()
|
||||
expect(output.options.headers).toEqual({ "x-custom": "preserved" })
|
||||
expect(item.options.kilocodeToken).toBe(scenario.token)
|
||||
const model = yield* provider.getModel(ProviderV2.ID.kilo, ModelV2.ID.make("test-model"))
|
||||
const language = yield* provider.getLanguage(model)
|
||||
const error = yield* Effect.tryPromise(() =>
|
||||
language.doGenerate({ prompt: [{ role: "user", content: [{ type: "text", text: "test" }] }] }),
|
||||
).pipe(Effect.flip)
|
||||
expect(error.cause).toMatchObject({ message: "test inference response" })
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls.at(0)?.get("authorization")).toBe(`Bearer ${scenario.key}`.trim())
|
||||
expect(calls.at(0)?.get("x-kilocode-organizationid")).toBe(scenario.org || null)
|
||||
expect(calls.at(0)?.get("x-custom")).toBe("preserved")
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
AppNodeBuilder.build(Provider.node, [
|
||||
[Config.node, TestConfig.layer({ get: () => Effect.succeed(config) })],
|
||||
[ModelsDev.node, Layer.mock(ModelsDev.Service)({ get: () => Effect.succeed({}) })],
|
||||
]),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
inference.instance("non-Kilo inference keeps OAuth over environment and configured API keys", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* environment({
|
||||
KILO_API_KEY: "kilo-env-token",
|
||||
KILO_ORG_ID: "kilo-env-org",
|
||||
OPENAI_API_KEY: "openai-env-token",
|
||||
KILO_AUTH_CONTENT: JSON.stringify({ openai: oauth }),
|
||||
})
|
||||
const calls: Headers[] = []
|
||||
yield* Effect.acquireRelease(
|
||||
Effect.sync(() => {
|
||||
const original = globalThis.fetch
|
||||
globalThis.fetch = Object.assign(
|
||||
async (_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
calls.push(new Headers(init?.headers))
|
||||
return Response.json({ error: { message: "test inference response" } }, { status: 401 })
|
||||
},
|
||||
{ preconnect: original.preconnect },
|
||||
)
|
||||
return original
|
||||
}),
|
||||
(original) =>
|
||||
Effect.sync(() => {
|
||||
globalThis.fetch = original
|
||||
}),
|
||||
)
|
||||
const config: Config.Info = {
|
||||
provider: {
|
||||
openai: {
|
||||
npm: "@ai-sdk/openai",
|
||||
env: ["OPENAI_API_KEY"],
|
||||
options: { apiKey: "configured-openai-key", headers: { "x-custom": "preserved" } },
|
||||
models: { "gpt-5": { name: "GPT-5", limit: { context: 128000, output: 4096 } } },
|
||||
},
|
||||
},
|
||||
}
|
||||
yield* Effect.gen(function* () {
|
||||
const provider = yield* Provider.Service
|
||||
const item = yield* provider.getProvider(ProviderV2.ID.openai)
|
||||
expect(item.key).toBeUndefined()
|
||||
expect(item.options.kilocodeToken).toBeUndefined()
|
||||
expect(item.options.kilocodeOrganizationId).toBeUndefined()
|
||||
expect(item.options.apiKey).toBe("configured-openai-key")
|
||||
expect(typeof item.options.fetch).toBe("function")
|
||||
expect(Provider.toPublicInfo(item).options.apiKey).toBe("configured-openai-key")
|
||||
const model = yield* provider.getModel(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5"))
|
||||
const language = yield* provider.getLanguage(model)
|
||||
const error = yield* Effect.tryPromise(() =>
|
||||
language.doGenerate({ prompt: [{ role: "user", content: [{ type: "text", text: "test" }] }] }),
|
||||
).pipe(Effect.flip)
|
||||
expect(error.cause).toMatchObject({ message: "test inference response" })
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls.at(0)?.get("authorization")).toBe("Bearer stored-token")
|
||||
expect(calls.at(0)?.get("chatgpt-account-id")).toBe("stored-org")
|
||||
expect(calls.at(0)?.get("x-kilocode-organizationid")).toBeNull()
|
||||
expect(calls.at(0)?.get("x-custom")).toBe("preserved")
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
AppNodeBuilder.build(Provider.node, [
|
||||
[Config.node, TestConfig.layer({ get: () => Effect.succeed(config) })],
|
||||
[ModelsDev.node, Layer.mock(ModelsDev.Service)({ get: () => Effect.succeed({}) })],
|
||||
]),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -114,32 +114,49 @@ describe("Kilo gateway HttpApi statuses", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
for (const context of ["config", "oauth", "env", "url", "personal", "anonymous", "anonymous-personal"] as const) {
|
||||
const anonymous = context.startsWith("anonymous")
|
||||
const personal = context.endsWith("personal")
|
||||
it.live(`reports ${context} organization context locally without secrets`, () =>
|
||||
for (const context of [
|
||||
{ name: "config", config: true, organization: "org-config" },
|
||||
{ name: "oauth", config: true, oauth: true, organization: "org-oauth" },
|
||||
{ name: "env", config: true, oauth: true, env: true, organization: "org-env" },
|
||||
{ name: "url", url: true, organization: "org-url" },
|
||||
{ name: "oauth over url", url: true, oauth: true, organization: "org-oauth" },
|
||||
{ name: "env over url", url: true, oauth: true, env: true, organization: "org-env" },
|
||||
{ name: "personal" },
|
||||
{ name: "anonymous", anonymous: true, env: true, organization: "org-env" },
|
||||
{ name: "anonymous config", anonymous: true, config: true, organization: "org-config" },
|
||||
{ name: "anonymous url", anonymous: true, url: true, organization: "org-url" },
|
||||
{ name: "anonymous-personal", anonymous: true },
|
||||
] satisfies {
|
||||
name: string
|
||||
config?: boolean
|
||||
oauth?: boolean
|
||||
env?: boolean
|
||||
url?: boolean
|
||||
anonymous?: boolean
|
||||
organization?: string
|
||||
}[]) {
|
||||
it.live(`reports ${context.name} organization context locally without secrets`, () =>
|
||||
Effect.gen(function* () {
|
||||
const previous = { ...state }
|
||||
const env = process.env.KILO_ORG_ID
|
||||
yield* Effect.acquireRelease(
|
||||
Effect.sync(() => {
|
||||
state.config =
|
||||
context === "config"
|
||||
? { provider: { kilo: { options: { kilocodeOrganizationId: "org-config" } } } }
|
||||
: context === "url"
|
||||
? { provider: { kilo: { options: { baseURL: "https://gateway.test/api/organizations/org-url" } } } }
|
||||
: {}
|
||||
state.info = anonymous
|
||||
state.config = context.config
|
||||
? { provider: { kilo: { options: { kilocodeOrganizationId: "org-config" } } } }
|
||||
: context.url
|
||||
? { provider: { kilo: { options: { baseURL: "https://gateway.test/api/organizations/org-url" } } } }
|
||||
: {}
|
||||
state.info = context.anonymous
|
||||
? undefined
|
||||
: new Auth.Oauth({
|
||||
type: "oauth",
|
||||
access: "test-token",
|
||||
refresh: "private-refresh",
|
||||
expires: Date.now() + 3600000,
|
||||
...(["config", "oauth", "url"].includes(context) ? { accountId: "org-oauth" } : {}),
|
||||
...(context.oauth ? { accountId: "org-oauth" } : {}),
|
||||
})
|
||||
if (personal) delete process.env.KILO_ORG_ID
|
||||
else process.env.KILO_ORG_ID = "org-env"
|
||||
if (context.env) process.env.KILO_ORG_ID = "org-env"
|
||||
else delete process.env.KILO_ORG_ID
|
||||
}),
|
||||
() =>
|
||||
Effect.sync(() => {
|
||||
@@ -152,9 +169,9 @@ describe("Kilo gateway HttpApi statuses", () => {
|
||||
const response = yield* HttpClient.get(KiloGatewayPaths.authStatus)
|
||||
expect(response.status).toBe(200)
|
||||
expect(yield* response.json).toEqual({
|
||||
authenticated: !anonymous,
|
||||
...(!anonymous ? { type: "oauth" } : {}),
|
||||
...(!personal ? { organizationId: `org-${anonymous ? "env" : context}` } : {}),
|
||||
authenticated: !context.anonymous,
|
||||
...(!context.anonymous ? { type: "oauth" } : {}),
|
||||
...(context.organization ? { organizationId: context.organization } : {}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { afterEach, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { afterEach, expect, spyOn } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Auth } from "../../../src/auth"
|
||||
import { ModelCache } from "../../../src/provider/model-cache"
|
||||
import { Server } from "../../../src/server/server"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
@@ -8,7 +10,7 @@ import { disposeAllInstances, tmpdir } from "../../fixture/fixture"
|
||||
import { resetDatabase } from "../../fixture/db"
|
||||
import { testEffectShared } from "../../lib/effect"
|
||||
|
||||
const it = testEffectShared(AppNodeBuilder.build(ModelCache.node))
|
||||
const it = testEffectShared(Layer.merge(AppNodeBuilder.build(ModelCache.node), AppNodeBuilder.build(Auth.node)))
|
||||
|
||||
void Log.init({ print: false })
|
||||
|
||||
@@ -48,6 +50,17 @@ for (const scenario of [
|
||||
] as const) {
|
||||
it.live(`keeps Org catalogs and recommendations safe: ${scenario}`, () =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.acquireRelease(
|
||||
Effect.sync(() => {
|
||||
const previous = Flag.KILO_DISABLE_MODELS_FETCH
|
||||
Flag.KILO_DISABLE_MODELS_FETCH = true
|
||||
return previous
|
||||
}),
|
||||
(previous) =>
|
||||
Effect.sync(() => {
|
||||
Flag.KILO_DISABLE_MODELS_FETCH = previous
|
||||
}),
|
||||
)
|
||||
const cache = yield* ModelCache.Service
|
||||
yield* cache.clear("kilo")
|
||||
const env = {
|
||||
@@ -102,7 +115,7 @@ for (const scenario of [
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
if (url.pathname === "/api/organizations/org-config/models") {
|
||||
if (url.pathname === "/api/organizations/org-env/models") {
|
||||
if (scenario === "unauthorized") return new Response(null, { status: 401 })
|
||||
if (scenario === "error") return new Response(null, { status: 500 })
|
||||
if (scenario === "empty") return Response.json({ data: [] })
|
||||
@@ -153,7 +166,7 @@ for (const scenario of [
|
||||
expect(yield* request("/kilo/auth-status", tmp.path)).toEqual({
|
||||
authenticated: true,
|
||||
type: "oauth",
|
||||
organizationId: "org-config",
|
||||
organizationId: "org-env",
|
||||
})
|
||||
const unavailable = ["empty", "error", "unauthorized", "filtered"].includes(scenario)
|
||||
expect(models(all, "all")).toEqual(unavailable ? [] : ["test/private", "test/z-last"])
|
||||
@@ -161,13 +174,44 @@ for (const scenario of [
|
||||
expect(connected.default.kilo).toBe(
|
||||
unavailable ? undefined : scenario === "valid" ? "test/z-last" : "test/private",
|
||||
)
|
||||
expect(all.default.kilo).toBe(connected.default.kilo)
|
||||
expect(connected.default.external).toBe("independent")
|
||||
expect(all.default.external).toBe("independent")
|
||||
expect(all.connected).toContain("external")
|
||||
expect(paths.filter((path) => path.endsWith("/models"))).toEqual(["/api/organizations/org-config/models"])
|
||||
expect(paths.filter((path) => path.endsWith("/models"))).toEqual(["/api/organizations/org-env/models"])
|
||||
expect(paths.filter((path) => path.endsWith("/defaults"))).toEqual(
|
||||
unavailable ? [] : ["/api/organizations/org-config/defaults"],
|
||||
unavailable ? [] : ["/api/organizations/org-env/defaults", "/api/organizations/org-env/defaults"],
|
||||
)
|
||||
if (scenario === "valid") {
|
||||
const auth = yield* Auth.Service
|
||||
yield* Effect.acquireUseRelease(
|
||||
Effect.sync(() =>
|
||||
spyOn(auth, "get").mockImplementation(() =>
|
||||
Effect.fail(new Auth.AuthError({ message: "Cannot read credentials after provider initialization" })),
|
||||
),
|
||||
),
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const retained = yield* request("/provider", tmp.path)
|
||||
const configured = yield* request("/config/providers", tmp.path)
|
||||
expect(models(retained, "all")).toEqual(["test/private", "test/z-last"])
|
||||
expect(models(configured, "providers")).toEqual(["test/private", "test/z-last"])
|
||||
expect(retained.connected).toEqual(all.connected)
|
||||
expect(retained.failed).toEqual(["kilo"])
|
||||
expect(retained.default).toEqual({ external: "independent", kilo: "test/private" })
|
||||
expect(configured.default).toEqual(retained.default)
|
||||
expect(paths.filter((path) => path.endsWith("/defaults"))).toHaveLength(2)
|
||||
expect(paths.filter((path) => path.endsWith("/models"))).toHaveLength(1)
|
||||
}),
|
||||
(spy) => Effect.sync(() => spy.mockRestore()),
|
||||
)
|
||||
const recovered = yield* request("/provider", tmp.path)
|
||||
const configured = yield* request("/config/providers", tmp.path)
|
||||
expect(recovered.default.kilo).toBe("test/z-last")
|
||||
expect(configured.default.kilo).toBe(recovered.default.kilo)
|
||||
expect(recovered.failed).toEqual([])
|
||||
expect(paths.filter((path) => path.endsWith("/defaults"))).toHaveLength(4)
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,7 +11,9 @@ import { Session } from "../../../src/session/session"
|
||||
import { ModelCache } from "../../../src/provider/model-cache"
|
||||
import { Provider } from "../../../src/provider/provider"
|
||||
import { ProviderAuth } from "../../../src/provider/auth"
|
||||
import { ConfigApi } from "../../../src/server/routes/instance/httpapi/groups/config"
|
||||
import { ProviderApi } from "../../../src/server/routes/instance/httpapi/groups/provider"
|
||||
import { configHandlers } from "../../../src/server/routes/instance/httpapi/handlers/config"
|
||||
import { providerHandlers } from "../../../src/server/routes/instance/httpapi/handlers/provider"
|
||||
import { Authorization } from "../../../src/server/routes/instance/httpapi/middleware/authorization"
|
||||
import { InstanceContextMiddleware } from "../../../src/server/routes/instance/httpapi/middleware/instance-context"
|
||||
@@ -23,48 +25,81 @@ import { schemaErrorLayer } from "../../../src/server/routes/instance/httpapi/mi
|
||||
import { TestConfig } from "../../fixture/config"
|
||||
import { testEffect } from "../../lib/effect"
|
||||
|
||||
function catalog(id: string): ModelsDev.Provider {
|
||||
function catalog(id: string, models: string[]): ModelsDev.Provider {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
env: [],
|
||||
models: {
|
||||
model: {
|
||||
id: "model",
|
||||
name: "Model",
|
||||
release_date: "2026-01-01",
|
||||
attachment: false,
|
||||
reasoning: false,
|
||||
temperature: true,
|
||||
tool_call: true,
|
||||
limit: { context: 32000, output: 4096 },
|
||||
},
|
||||
},
|
||||
models: Object.fromEntries(
|
||||
models.map((id) => [
|
||||
id,
|
||||
{
|
||||
id,
|
||||
name: id,
|
||||
release_date: "2026-01-01",
|
||||
attachment: false,
|
||||
reasoning: false,
|
||||
temperature: true,
|
||||
tool_call: true,
|
||||
limit: { context: 32000, output: 4096 },
|
||||
},
|
||||
]),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
const catalogs = { external: catalog("external"), kilo: catalog("kilo") }
|
||||
const catalogs = { external: catalog("external", ["model"]), kilo: catalog("kilo", ["public/leak"]) }
|
||||
const providers = {
|
||||
external: Provider.fromModelsDevProvider(catalogs.external),
|
||||
kilo: Provider.fromModelsDevProvider(catalogs.kilo),
|
||||
kilo: Provider.fromModelsDevProvider(
|
||||
catalog("kilo", ["connected/training", "connected/z-local", "connected/a-remote"]),
|
||||
),
|
||||
}
|
||||
providers.kilo.models["connected/training"].mayTrainOnYourPrompts = true
|
||||
const state = {
|
||||
failure: false,
|
||||
connected: true,
|
||||
disabled: false,
|
||||
excluded: false,
|
||||
initial: false,
|
||||
empty: false,
|
||||
failed: [] as string[],
|
||||
requests: [] as string[],
|
||||
reads: 0,
|
||||
}
|
||||
const state = { failure: false, connected: true }
|
||||
const layer = HttpRouter.serve(
|
||||
HttpApiBuilder.layer(HttpApi.make("opencode-instance").addHttpApi(ProviderApi)).pipe(
|
||||
Layer.provide(providerHandlers),
|
||||
HttpApiBuilder.layer(HttpApi.make("opencode-instance").addHttpApi(ProviderApi).addHttpApi(ConfigApi)).pipe(
|
||||
Layer.provide([providerHandlers, configHandlers]),
|
||||
Layer.provide(schemaErrorLayer),
|
||||
Layer.provide([
|
||||
TestConfig.layer({ get: () => Effect.succeed({ enabled_providers: ["external", "kilo"] }) }),
|
||||
TestConfig.layer({
|
||||
get: () =>
|
||||
Effect.succeed({
|
||||
enabled_providers: state.excluded ? ["external"] : ["external", "kilo"],
|
||||
disabled_providers: state.disabled ? ["kilo"] : [],
|
||||
hide_prompt_training_models: true,
|
||||
}),
|
||||
}),
|
||||
Layer.mock(Provider.Service)({
|
||||
list: () => Effect.succeed(state.connected ? providers : { external: providers.external }),
|
||||
list: () =>
|
||||
state.initial
|
||||
? Effect.die(new Auth.AuthError({ message: "Cannot initialize providers" }))
|
||||
: Effect.succeed(
|
||||
state.connected && !state.disabled && !state.excluded
|
||||
? { ...providers, kilo: { ...providers.kilo, models: state.empty ? {} : providers.kilo.models } }
|
||||
: { external: providers.external },
|
||||
),
|
||||
}),
|
||||
Layer.mock(ProviderAuth.Service)({}),
|
||||
Layer.mock(ModelCache.Service)({ failedProviders: () => Effect.succeed([]) }),
|
||||
Layer.mock(ModelCache.Service)({ failedProviders: () => Effect.succeed(state.failed) }),
|
||||
Layer.mock(Auth.Service)({
|
||||
get: () =>
|
||||
state.failure
|
||||
? Effect.fail(new Auth.AuthError({ message: "Cannot read credentials" }))
|
||||
: Effect.succeed(undefined),
|
||||
Effect.suspend(() => {
|
||||
state.reads++
|
||||
return state.failure
|
||||
? Effect.fail(new Auth.AuthError({ message: "Cannot read credentials" }))
|
||||
: Effect.succeed(undefined)
|
||||
}),
|
||||
}),
|
||||
Layer.succeed(
|
||||
Authorization,
|
||||
@@ -99,60 +134,174 @@ const it = testEffect(layer)
|
||||
function configure(failure: boolean, connected: boolean) {
|
||||
return Effect.acquireRelease(
|
||||
Effect.sync(() => {
|
||||
const previous = { state: { ...state }, env: process.env.KILO_ORG_ID }
|
||||
Object.assign(state, { failure, connected })
|
||||
const previous = {
|
||||
state: { ...state },
|
||||
env: { KILO_ORG_ID: process.env.KILO_ORG_ID, KILO_API_KEY: process.env.KILO_API_KEY },
|
||||
fetch: globalThis.fetch,
|
||||
}
|
||||
Object.assign(state, { failure, connected, requests: [], reads: 0 })
|
||||
delete process.env.KILO_ORG_ID
|
||||
delete process.env.KILO_API_KEY
|
||||
globalThis.fetch = Object.assign(
|
||||
async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url)
|
||||
if (url.pathname.endsWith("/defaults")) {
|
||||
state.requests.push(url.pathname)
|
||||
return Response.json({ defaultModel: "connected/a-remote", defaultFreeModel: "connected/a-remote" })
|
||||
}
|
||||
return previous.fetch(input, init)
|
||||
},
|
||||
{ preconnect: previous.fetch.preconnect },
|
||||
)
|
||||
return previous
|
||||
}),
|
||||
(previous) =>
|
||||
Effect.sync(() => {
|
||||
Object.assign(state, previous.state)
|
||||
if (previous.env === undefined) {
|
||||
delete process.env.KILO_ORG_ID
|
||||
return
|
||||
globalThis.fetch = previous.fetch
|
||||
for (const [key, value] of Object.entries(previous.env)) {
|
||||
if (value === undefined) delete process.env[key]
|
||||
else process.env[key] = value
|
||||
}
|
||||
process.env.KILO_ORG_ID = previous.env
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function request(path: string) {
|
||||
return Effect.gen(function* () {
|
||||
const response = yield* HttpClient.get(path)
|
||||
expect(response.status).toBe(200)
|
||||
return yield* response.json
|
||||
})
|
||||
}
|
||||
|
||||
function record(input: unknown): input is Record<string, unknown> {
|
||||
return typeof input === "object" && input !== null && !Array.isArray(input)
|
||||
}
|
||||
|
||||
function result(input: unknown, key: "all" | "providers") {
|
||||
if (!record(input) || !Array.isArray(input[key])) throw new Error("Expected provider catalog")
|
||||
return input[key].map((provider: unknown) => {
|
||||
if (!record(provider) || typeof provider.id !== "string" || !record(provider.models)) {
|
||||
throw new Error("Expected provider models")
|
||||
}
|
||||
return { id: provider.id, models: Object.keys(provider.models) }
|
||||
})
|
||||
}
|
||||
|
||||
const external = { id: "external", models: ["model"] }
|
||||
const kilo = { id: "kilo", models: ["connected/z-local", "connected/a-remote"] }
|
||||
|
||||
describe("provider catalog authentication failures", () => {
|
||||
for (const connected of [false, true]) {
|
||||
it.live(`keeps other providers available when Kilo auth fails (connected: ${connected})`, () =>
|
||||
it.live(`retains only safe catalogs when Kilo auth fails (connected: ${connected})`, () =>
|
||||
Effect.gen(function* () {
|
||||
yield* configure(true, connected)
|
||||
const response = yield* HttpClient.get("/provider")
|
||||
expect(response.status).toBe(200)
|
||||
const body = yield* response.json
|
||||
expect(body).toMatchObject({
|
||||
all: [{ id: "external" }],
|
||||
default: { external: "model" },
|
||||
connected: ["external"],
|
||||
state.failed = ["existing"]
|
||||
const all = yield* request("/provider")
|
||||
const config = yield* request("/config/providers")
|
||||
expect(result(all, "all")).toEqual(connected ? [external, kilo] : [external])
|
||||
expect(result(config, "providers")).toEqual(connected ? [external, kilo] : [external])
|
||||
const defaults = { external: "model", ...(connected ? { kilo: "connected/z-local" } : {}) }
|
||||
expect(all).toMatchObject({
|
||||
default: defaults,
|
||||
connected: connected ? ["external", "kilo"] : ["external"],
|
||||
failed: ["existing", "kilo"],
|
||||
})
|
||||
expect(JSON.stringify(body)).not.toContain('"kilo"')
|
||||
expect(config).toMatchObject({ default: defaults })
|
||||
expect(JSON.stringify([all, config])).not.toContain("public/leak")
|
||||
expect(state.requests).toEqual([])
|
||||
|
||||
state.failure = false
|
||||
const recovered = yield* HttpClient.get("/provider")
|
||||
expect(recovered.status).toBe(200)
|
||||
expect(yield* recovered.json).toMatchObject({
|
||||
all: [{ id: "external" }, { id: "kilo" }],
|
||||
default: { external: "model", kilo: "model" },
|
||||
const recovered = yield* request("/provider")
|
||||
const configured = yield* request("/config/providers")
|
||||
expect(result(recovered, "all")).toEqual([external, connected ? kilo : { id: "kilo", models: ["public/leak"] }])
|
||||
expect(result(configured, "providers")).toEqual(connected ? [external, kilo] : [external])
|
||||
expect(recovered).toMatchObject({
|
||||
default: { external: "model", kilo: connected ? "connected/a-remote" : "public/leak" },
|
||||
connected: connected ? ["external", "kilo"] : ["external"],
|
||||
failed: ["existing"],
|
||||
})
|
||||
expect(configured).toMatchObject({
|
||||
default: { external: "model", ...(connected ? { kilo: "connected/a-remote" } : {}) },
|
||||
})
|
||||
expect(state.requests).toHaveLength(connected ? 2 : 0)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live(`distinguishes anonymous auth success from failure (connected: ${connected})`, () =>
|
||||
Effect.gen(function* () {
|
||||
yield* configure(false, connected)
|
||||
const all = yield* request("/provider")
|
||||
const config = yield* request("/config/providers")
|
||||
expect(result(all, "all")).toEqual([external, connected ? kilo : { id: "kilo", models: ["public/leak"] }])
|
||||
expect(result(config, "providers")).toEqual(connected ? [external, kilo] : [external])
|
||||
expect(all).toMatchObject({
|
||||
default: { external: "model", kilo: connected ? "connected/a-remote" : "public/leak" },
|
||||
connected: connected ? ["external", "kilo"] : ["external"],
|
||||
failed: [],
|
||||
})
|
||||
expect(config).toMatchObject({
|
||||
default: { external: "model", ...(connected ? { kilo: "connected/a-remote" } : {}) },
|
||||
})
|
||||
expect(state.requests).toHaveLength(connected ? 2 : 0)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
it.live("keeps the Personal catalog when credentials are absent rather than unreadable", () =>
|
||||
it.live("does not duplicate an existing Kilo failure", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* configure(false, false)
|
||||
const response = yield* HttpClient.get("/provider")
|
||||
expect(response.status).toBe(200)
|
||||
expect(yield* response.json).toMatchObject({
|
||||
all: [{ id: "external" }, { id: "kilo" }],
|
||||
default: { external: "model", kilo: "model" },
|
||||
connected: ["external"],
|
||||
})
|
||||
yield* configure(true, true)
|
||||
state.failed = ["kilo", "existing"]
|
||||
expect(yield* request("/provider")).toMatchObject({ failed: ["kilo", "existing"] })
|
||||
expect(state.failed).toEqual(["kilo", "existing"])
|
||||
expect(state.requests).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
for (const restriction of ["disabled", "excluded"] as const) {
|
||||
it.live(`does not flag ${restriction} Kilo when auth fails`, () =>
|
||||
Effect.gen(function* () {
|
||||
yield* configure(true, false)
|
||||
state[restriction] = true
|
||||
state.failed = ["existing"]
|
||||
const all = yield* request("/provider")
|
||||
const config = yield* request("/config/providers")
|
||||
expect(result(all, "all")).toEqual([external])
|
||||
expect(result(config, "providers")).toEqual([external])
|
||||
expect(all).toMatchObject({ default: { external: "model" }, connected: ["external"], failed: ["existing"] })
|
||||
expect(config).toMatchObject({ default: { external: "model" } })
|
||||
expect(state.requests).toEqual([])
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
it.live("keeps empty connected catalogs without an unsafe fallback", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* configure(true, true)
|
||||
state.empty = true
|
||||
const all = yield* request("/provider")
|
||||
const config = yield* request("/config/providers")
|
||||
expect(result(all, "all")).toEqual([external, { id: "kilo", models: [] }])
|
||||
expect(result(config, "providers")).toEqual([external, { id: "kilo", models: [] }])
|
||||
expect(all).toMatchObject({ default: { external: "model" }, connected: ["external", "kilo"], failed: ["kilo"] })
|
||||
expect(config).toMatchObject({ default: { external: "model" } })
|
||||
expect(JSON.stringify([all, config])).not.toContain("public/leak")
|
||||
expect(state.requests).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("does not hide initial provider initialization failure with the public snapshot", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* configure(true, true)
|
||||
state.initial = true
|
||||
for (const path of ["/provider", "/config/providers"]) {
|
||||
const response = yield* HttpClient.get(path)
|
||||
expect(response.status).toBe(500)
|
||||
expect(yield* response.text).not.toContain("public/leak")
|
||||
}
|
||||
expect(state.reads).toBe(0)
|
||||
expect(state.requests).toEqual([])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user