feat: kilo provider & kilo auth plugin

This commit is contained in:
Catriel Müller
2026-01-26 10:55:26 +01:00
parent 17e6190fcf
commit 00a6fa709c
30 changed files with 1866 additions and 12 deletions
+11 -7
View File
@@ -268,15 +268,18 @@ export const AuthLoginCommand = cmd({
return filtered
})
// kilocode_change start
const priority: Record<string, number> = {
opencode: 0,
anthropic: 1,
"github-copilot": 2,
openai: 3,
google: 4,
openrouter: 5,
vercel: 6,
kilo: 0,
opencode: 1,
anthropic: 2,
"github-copilot": 3,
openai: 4,
google: 5,
openrouter: 6,
vercel: 7,
}
// kilocode_change end
let provider = await prompts.autocomplete({
message: "Select provider",
maxItems: 8,
@@ -292,6 +295,7 @@ export const AuthLoginCommand = cmd({
label: x.name,
value: x.id,
hint: {
kilo: "recommended", // kilocode_change
opencode: "recommended",
anthropic: "Claude Max or API key",
openai: "ChatGPT Plus/Pro or API key",
+2 -1
View File
@@ -11,6 +11,7 @@ import { CodexAuthPlugin } from "./codex"
import { Session } from "../session"
import { NamedError } from "@opencode-ai/util/error"
import { CopilotAuthPlugin } from "./copilot"
import { KiloAuthPlugin } from "@opencode-ai/kilo-auth-plugin" // kilocode_change
export namespace Plugin {
const log = Log.create({ service: "plugin" })
@@ -18,7 +19,7 @@ export namespace Plugin {
const BUILTIN = ["opencode-anthropic-auth@0.0.10", "@gitlab/opencode-gitlab-auth@1.3.2"]
// Built-in plugins that are directly imported (not installed from npm)
const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin, CopilotAuthPlugin]
const INTERNAL_PLUGINS: PluginInstance[] = [KiloAuthPlugin, CodexAuthPlugin, CopilotAuthPlugin] // kilocode_change
const state = Instance.state(async () => {
const client = createOpencodeClient({
@@ -0,0 +1,217 @@
// kilocode_change new file
import { fetchKiloModels } from "@opencode-ai/kilo-provider"
import { Config } from "../config/config"
import { Auth } from "../auth"
import { Env } from "../env"
import { Log } from "../util/log"
export namespace ModelCache {
const log = Log.create({ service: "model-cache" })
// Cache structure
const cache = new Map<
string,
{
models: Record<string, any>
timestamp: number
}
>()
const TTL = 5 * 60 * 1000 // 5 minutes
const inFlightRefresh = new Map<string, Promise<Record<string, any>>>()
/**
* Get cached models if available and not expired
* @param providerID - Provider identifier (e.g., "kilo")
* @returns Cached models or undefined if cache miss or expired
*/
export function get(providerID: string): Record<string, any> | undefined {
const cached = cache.get(providerID)
if (!cached) {
log.debug("cache miss", { providerID })
return undefined
}
const now = Date.now()
const age = now - cached.timestamp
if (age > TTL) {
log.debug("cache expired", { providerID, age })
cache.delete(providerID)
return undefined
}
log.debug("cache hit", { providerID, age })
return cached.models
}
/**
* Fetch models with cache-first approach
* @param providerID - Provider identifier
* @param options - Provider options
* @returns Models from cache or freshly fetched
*/
export async function fetch(providerID: string, options?: any): Promise<Record<string, any>> {
// Check cache first
const cached = get(providerID)
if (cached) {
return cached
}
// Cache miss - fetch models
log.info("fetching models", { providerID })
try {
const authOptions = await getAuthOptions(providerID)
const mergedOptions = { ...authOptions, ...options }
const models = await fetchModels(providerID, mergedOptions)
// Store in cache
cache.set(providerID, {
models,
timestamp: Date.now(),
})
log.info("models fetched and cached", { providerID, count: Object.keys(models).length })
return models
} catch (error) {
log.error("failed to fetch models", { providerID, error })
return {}
}
}
/**
* Force refresh models (bypass cache)
* Uses atomic refresh pattern to prevent race conditions
* @param providerID - Provider identifier
* @param options - Provider options
* @returns Freshly fetched models
*/
export async function refresh(providerID: string, options?: any): Promise<Record<string, any>> {
// Check if refresh already in progress
const existing = inFlightRefresh.get(providerID)
if (existing) {
log.debug("refresh already in progress, returning existing promise", { providerID })
return existing
}
// Create new refresh promise
const refreshPromise = (async () => {
log.info("refreshing models", { providerID })
try {
const authOptions = await getAuthOptions(providerID)
const mergedOptions = { ...authOptions, ...options }
const models = await fetchModels(providerID, mergedOptions)
// Update cache with new models
cache.set(providerID, {
models,
timestamp: Date.now(),
})
log.info("models refreshed", { providerID, count: Object.keys(models).length })
return models
} catch (error) {
log.error("failed to refresh models", { providerID, error })
// Return existing cache or empty object
const cached = cache.get(providerID)
if (cached) {
log.debug("returning stale cache after refresh failure", { providerID })
return cached.models
}
return {}
}
})()
// Track in-flight refresh
inFlightRefresh.set(providerID, refreshPromise)
try {
return await refreshPromise
} finally {
// Clean up in-flight tracking
inFlightRefresh.delete(providerID)
}
}
/**
* Clear cached models for a provider
* @param providerID - Provider identifier
*/
export function clear(providerID: string): void {
const deleted = cache.delete(providerID)
if (deleted) {
log.info("cache cleared", { providerID })
} else {
log.debug("no cache to clear", { providerID })
}
}
/**
* Fetch models based on provider type
* @param providerID - Provider identifier
* @param options - Provider options
* @returns Fetched models
*/
async function fetchModels(providerID: string, options: any): Promise<Record<string, any>> {
if (providerID === "kilo") {
return fetchKiloModels(options)
}
// Other providers not implemented yet
log.debug("provider not implemented", { providerID })
return {}
}
/**
* Get authentication options from multiple sources
* Priority: Config > Auth > Env
* @param providerID - Provider identifier
* @returns Options object with authentication credentials
*/
async function getAuthOptions(providerID: string): Promise<any> {
const options: any = {}
if (providerID === "kilo") {
// Get from Config
const config = await Config.get()
const providerConfig = config.provider?.[providerID]
if (providerConfig?.options?.apiKey) {
options.kilocodeToken = providerConfig.options.apiKey
}
// Get from Auth
const auth = await Auth.get(providerID)
if (auth) {
if (auth.type === "api") {
options.kilocodeToken = auth.key
} else if (auth.type === "oauth") {
options.kilocodeToken = auth.access
}
}
// Get from Env
const env = Env.all()
if (env.KILOCODE_TOKEN) {
options.kilocodeToken = env.KILOCODE_TOKEN
}
if (env.KILOCODE_ORGANIZATION_ID) {
options.kilocodeOrganizationId = env.KILOCODE_ORGANIZATION_ID
}
log.debug("auth options resolved", {
providerID,
hasToken: !!options.kilocodeToken,
hasOrganizationId: !!options.kilocodeOrganizationId,
})
}
return options
}
}
+25 -1
View File
@@ -5,6 +5,7 @@ import z from "zod"
import { Installation } from "../installation"
import { Flag } from "../flag/flag"
import { lazy } from "@/util/lazy"
import { ModelCache } from "./model-cache" // kilocode_change
// Try to import bundled snapshot (generated at build time)
// Falls back to undefined in dev mode when snapshot doesn't exist
@@ -100,7 +101,30 @@ export namespace ModelsDev {
export async function get() {
const result = await Data()
return result as Record<string, Provider>
// kilocode_change start
const providers = result as Record<string, Provider>
// Inject kilo provider with dynamic model fetching
if (!providers["kilo"]) {
const kiloModels = await ModelCache.fetch("kilo").catch(() => ({}))
providers["kilo"] = {
id: "kilo",
name: "Kilo Gateway",
env: [],
api: "https://api.kilo.ai/api/openrouter/",
npm: "@opencode-ai/kilo-provider",
models: kiloModels,
}
// Trigger background refresh if models are empty or stale
if (Object.keys(kiloModels).length === 0) {
ModelCache.refresh("kilo").catch(() => {})
}
}
return providers
// kilocode_change end
}
export async function refresh() {
@@ -25,6 +25,7 @@ import { createOpenAI } from "@ai-sdk/openai"
import { createOpenAICompatible } from "@ai-sdk/openai-compatible"
import { createOpenRouter, type LanguageModelV2 } from "@openrouter/ai-sdk-provider"
import { createOpenaiCompatible as createGitHubCopilotOpenAICompatible } from "./sdk/openai-compatible/src"
import { createKilo } from "@opencode-ai/kilo-provider" // kilocode_change
import { createXai } from "@ai-sdk/xai"
import { createMistral } from "@ai-sdk/mistral"
import { createGroq } from "@ai-sdk/groq"
@@ -63,6 +64,7 @@ export namespace Provider {
"@ai-sdk/openai": createOpenAI,
"@ai-sdk/openai-compatible": createOpenAICompatible,
"@openrouter/ai-sdk-provider": createOpenRouter,
"@opencode-ai/kilo-provider": createKilo, // kilocode_change
"@ai-sdk/xai": createXai,
"@ai-sdk/mistral": createMistral,
"@ai-sdk/groq": createGroq,
@@ -504,6 +506,31 @@ export namespace Provider {
},
}
},
// kilocode_change start
kilo: async (input) => {
const hasKey = await (async () => {
const env = Env.all()
if (input.env.some((item) => env[item])) return true
if (await Auth.get(input.id)) return true
const config = await Config.get()
if (config.provider?.["kilo"]?.options?.apiKey) return true
if (config.provider?.["kilo"]?.options?.kilocodeToken) return true
return false
})()
if (!hasKey) {
for (const [key, value] of Object.entries(input.models)) {
if (value.cost.input === 0) continue
delete input.models[key]
}
}
return {
autoload: Object.keys(input.models).length > 0,
options: hasKey ? {} : { apiKey: "anonymous" },
}
},
// kilocode_change end
}
export const Model = z
@@ -5,7 +5,7 @@ import { Config } from "../../config/config"
import { Provider } from "../../provider/provider"
import { ModelsDev } from "../../provider/models"
import { ProviderAuth } from "../../provider/auth"
import { mapValues } from "remeda"
import { mapValues, pickBy } from "remeda" // kilocode_change
import { errors } from "../error"
import { lazy } from "../../util/lazy"
@@ -52,11 +52,18 @@ export const ProviderRoutes = lazy(() =>
mapValues(filteredProviders, (x) => Provider.fromModelsDevProvider(x)),
connected,
)
// kilocode_change start: Filter out providers with no models to prevent crashes
const validProviders = pickBy(providers, (item) => Object.keys(item.models).length > 0)
return c.json({
all: Object.values(providers),
default: mapValues(providers, (item) => Provider.sort(Object.values(item.models))[0].id),
all: Object.values(validProviders),
default: mapValues(validProviders, (item) => {
const sorted = Provider.sort(Object.values(item.models))
return sorted[0]?.id ?? ""
}),
connected: Object.keys(connected),
})
// kilocode_change end
},
)
.get(