feat: kilo provider & kilo auth plugin
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user