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
+38
View File
@@ -254,6 +254,38 @@
"typescript": "catalog:",
},
},
"packages/kilo-auth-plugin": {
"name": "@opencode-ai/kilo-auth-plugin",
"version": "1.0.0",
"dependencies": {
"@clack/prompts": "1.0.0-alpha.1",
"@opencode-ai/kilo-provider": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"open": "10.1.2",
},
"devDependencies": {
"@tsconfig/node22": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"typescript": "catalog:",
},
},
"packages/kilo-provider": {
"name": "@opencode-ai/kilo-provider",
"version": "1.0.0",
"dependencies": {
"@openrouter/ai-sdk-provider": "1.5.2",
"ai": "catalog:",
"zod": "catalog:",
},
"devDependencies": {
"@tsconfig/node22": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"typescript": "catalog:",
},
},
"packages/opencode": {
"name": "opencode",
"version": "1.1.36",
@@ -291,6 +323,8 @@
"@octokit/graphql": "9.0.2",
"@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:",
"@opencode-ai/kilo-auth-plugin": "workspace:*",
"@opencode-ai/kilo-provider": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
@@ -1207,6 +1241,10 @@
"@opencode-ai/function": ["@opencode-ai/function@workspace:packages/function"],
"@opencode-ai/kilo-auth-plugin": ["@opencode-ai/kilo-auth-plugin@workspace:packages/kilo-auth-plugin"],
"@opencode-ai/kilo-provider": ["@opencode-ai/kilo-provider@workspace:packages/kilo-provider"],
"@opencode-ai/plugin": ["@opencode-ai/plugin@workspace:packages/plugin"],
"@opencode-ai/script": ["@opencode-ai/script@workspace:packages/script"],
+60
View File
@@ -0,0 +1,60 @@
# @opencode-ai/kilo-auth-plugin
Authentication plugin for Kilo Gateway integration with OpenCode.
## Overview
This plugin provides device authorization flow for authenticating with Kilo Gateway, making it appear as an authentication option in `opencode auth login`.
## Features
- **Device Authorization Flow**: OAuth-style device flow for secure authentication
- **Organization Support**: Select between personal account and organization accounts
- **Default Model Fetching**: Automatically fetches and configures default model settings
- **Progress Display**: Visual feedback during the authorization process
## Architecture
The plugin consists of:
- **polling.ts**: Generic polling utilities with timeout and progress tracking
- **device-auth.ts**: Complete device authorization flow implementation
- **profile.ts**: Profile and organization fetching/selection
- **index.ts**: Plugin registration with OpenCode
## API Endpoints
The plugin communicates with the following Kilo Gateway endpoints:
- `POST /api/device-auth/codes` - Initiate device authorization
- `GET /api/device-auth/codes/{code}` - Poll authorization status
- `GET /api/profile` - Fetch user profile and organizations
- `GET /api/defaults` - Fetch default model configuration
- `GET /api/organizations/{id}/defaults` - Fetch org-specific defaults
## Usage
The plugin is automatically registered as an internal plugin in OpenCode. When users run:
```bash
opencode auth login
```
"Kilo Gateway (Device Authorization)" will appear as the first authentication option.
## Development
```bash
# Install dependencies
bun install
# Type check
bun run typecheck
# Build
bun run build
```
## Integration
This plugin works in tandem with [`@opencode-ai/kilo-provider`](../kilo-provider) to provide complete Kilo Gateway integration with OpenCode.
+39
View File
@@ -0,0 +1,39 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/kilo-auth-plugin",
"version": "1.0.0",
"type": "module",
"license": "MIT",
"description": "KiloCode authentication plugin for OpenCode with device authorization flow",
"keywords": [
"kilo",
"kilocode",
"opencode",
"auth",
"plugin",
"device-auth"
],
"exports": {
".": "./src/index.ts"
},
"files": [
"dist"
],
"scripts": {
"typecheck": "tsgo --noEmit",
"build": "tsc"
},
"dependencies": {
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/kilo-provider": "workspace:*",
"@clack/prompts": "1.0.0-alpha.1",
"open": "10.1.2"
},
"devDependencies": {
"@tsconfig/node22": "catalog:",
"@types/node": "catalog:",
"typescript": "catalog:",
"@typescript/native-preview": "catalog:"
}
}
@@ -0,0 +1,16 @@
/**
* Kilo Auth Plugin Configuration Constants
* Centralized configuration for all API endpoints and settings
*/
/** Base URL for Kilo API */
export const KILO_API_BASE = "https://api.kilo.ai"
/** Device auth polling interval in milliseconds */
export const POLL_INTERVAL_MS = 3000
/** Default model to use as fallback */
export const DEFAULT_MODEL = "anthropic/claude-sonnet-4"
/** Token expiration duration in milliseconds (1 year) */
export const TOKEN_EXPIRATION_MS = 365 * 24 * 60 * 60 * 1000
@@ -0,0 +1,183 @@
import open from "open"
import { spinner } from "@clack/prompts"
import type { DeviceAuthInitiateResponse, DeviceAuthPollResponse } from "./types.js"
import { poll, formatTimeRemaining } from "./polling.js"
import { getKiloProfile, getKiloDefaultModel, promptOrganizationSelection } from "./profile.js"
import { KILO_API_BASE, POLL_INTERVAL_MS } from "./constants.js"
/**
* Initiate device authorization flow
* @returns Device authorization details
* @throws Error if initiation fails
*/
async function initiateDeviceAuth(): Promise<DeviceAuthInitiateResponse> {
const response = await fetch(`${KILO_API_BASE}/api/device-auth/codes`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
})
if (!response.ok) {
if (response.status === 429) {
throw new Error("Too many pending authorization requests. Please try again later.")
}
throw new Error(`Failed to initiate device authorization: ${response.status}`)
}
const data = await response.json()
return data as DeviceAuthInitiateResponse
}
/**
* Poll for device authorization status
* @param code The verification code
* @returns Poll response with status and optional token
* @throws Error if polling fails
*/
async function pollDeviceAuth(code: string): Promise<DeviceAuthPollResponse> {
const response = await fetch(`${KILO_API_BASE}/api/device-auth/codes/${code}`)
if (response.status === 202) {
// Still pending
return { status: "pending" }
}
if (response.status === 403) {
// Denied by user
return { status: "denied" }
}
if (response.status === 410) {
// Code expired
return { status: "expired" }
}
if (!response.ok) {
throw new Error(`Failed to poll device authorization: ${response.status}`)
}
const data = await response.json()
return data as DeviceAuthPollResponse
}
export interface DeviceAuthResult {
token: string
organizationId?: string
model: string
}
/**
* Execute the device authorization flow
* @returns Authentication result with token, org ID, and model
* @throws Error if authentication fails
*/
export async function authenticateWithDeviceAuth(): Promise<DeviceAuthResult> {
console.log("\n🔐 Starting browser-based authentication...\n")
// Step 1: Initiate device auth
const s = spinner()
s.start("Initiating device authorization")
let authData: DeviceAuthInitiateResponse
authData = await initiateDeviceAuth()
const { code, verificationUrl, expiresIn } = authData
s.stop("Device authorization initiated")
// Step 2: Display instructions and open browser
console.log("\n📋 Verification Details:")
console.log(` URL: ${verificationUrl}`)
console.log(` Code: ${code}`)
console.log(` Expires: ${Math.floor(expiresIn / 60)}:${String(expiresIn % 60).padStart(2, "0")}\n`)
console.log("Opening browser for authentication...")
// Open browser
await open(verificationUrl).catch((err) => {
console.log("\n⚠️ Could not open browser automatically. Please open the URL manually.")
console.error(err)
})
// Step 3: Poll for authorization
const startTime = Date.now()
const maxAttempts = Math.ceil((expiresIn * 1000) / POLL_INTERVAL_MS)
s.start("Waiting for authorization")
let token: string
let userEmail: string
const result = await poll<DeviceAuthPollResponse>({
interval: POLL_INTERVAL_MS,
maxAttempts,
pollFn: async () => {
const pollResult = await pollDeviceAuth(code)
// Update progress display
const timeRemaining = formatTimeRemaining(startTime, expiresIn)
s.message(`Waiting for authorization (${timeRemaining} remaining)`)
if (pollResult.status === "approved") {
// Success!
return {
continue: false,
data: pollResult,
}
}
if (pollResult.status === "denied") {
return {
continue: false,
error: new Error("Authorization denied by user"),
}
}
if (pollResult.status === "expired") {
return {
continue: false,
error: new Error("Authorization code expired"),
}
}
// Still pending, continue polling
return {
continue: true,
}
},
})
if (!result.token || !result.userEmail) {
s.stop("Authentication failed")
throw new Error("Invalid response from authorization server")
}
token = result.token
userEmail = result.userEmail
s.stop(`✓ Authenticated as ${userEmail}`)
// Step 4: Fetch profile to get organizations
s.start("Fetching profile")
const profileData = await getKiloProfile(token)
s.stop("Profile fetched")
// Step 5: Prompt for organization selection
let organizationId: string | undefined
if (profileData.organizations && profileData.organizations.length > 0) {
console.log() // Add spacing
organizationId = await promptOrganizationSelection(profileData.organizations)
}
// Step 6: Fetch default model
s.start("Fetching default model")
const model = await getKiloDefaultModel(token, organizationId)
s.stop(`Default model: ${model}`)
// Step 7: Return auth result
return {
token,
organizationId,
model,
}
}
+77
View File
@@ -0,0 +1,77 @@
import type { Plugin } from "@opencode-ai/plugin"
import { authenticateWithDeviceAuth } from "./device-auth.js"
import { KILO_API_BASE, TOKEN_EXPIRATION_MS } from "./constants.js"
/**
* Kilo Gateway Authentication Plugin
*
* Provides device authorization flow for Kilo Gateway
* to integrate with OpenCode's auth system.
*/
export const KiloAuthPlugin: Plugin = async (ctx) => {
return {
auth: {
provider: "kilo",
async loader(getAuth, providerInfo) {
// Get the stored auth
const auth = await getAuth()
if (!auth) return {}
// For API auth, the key is the token directly
if (auth.type === "api") {
return {
kilocodeToken: auth.key,
}
}
// For OAuth auth, access token contains the Kilo token
// The accountId field is in OpenCode's Auth type but not exposed to SDK
// so we access it as a property on the auth object
if (auth.type === "oauth") {
const result: Record<string, string> = {
kilocodeToken: auth.access,
}
// accountId is present in OpenCode's OAuth schema but not in SDK's
const maybeAccountId = (auth as any).accountId
if (maybeAccountId) {
result.kilocodeOrganizationId = maybeAccountId
}
return result
}
return {}
},
methods: [
{
type: "oauth",
label: "Kilo Gateway (Device Authorization)",
async authorize() {
// Execute the device auth flow
const result = await authenticateWithDeviceAuth()
// Return in the format expected by OpenCode
return {
url: KILO_API_BASE,
instructions: "Authenticated successfully with Kilo Gateway",
method: "auto",
async callback() {
// Store using OAuth format to include organization ID
// accountId field stores the organization ID
return {
type: "success",
provider: "kilo",
refresh: result.token, // Store token here too for redundancy
access: result.token, // Primary token storage
expires: Date.now() + TOKEN_EXPIRATION_MS,
...(result.organizationId && { accountId: result.organizationId }),
}
},
}
},
},
],
},
}
}
export default KiloAuthPlugin
+47
View File
@@ -0,0 +1,47 @@
import type { PollOptions, PollResult } from "./types.js"
/**
* Generic polling utility with timeout and progress tracking
* @param options Polling configuration options
* @returns The data from the successful poll result
* @throws Error if polling times out or fails
*/
export async function poll<T>(options: PollOptions<T>): Promise<T> {
const { interval, maxAttempts, pollFn } = options
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
// Wait before polling (except first attempt)
if (attempt > 1) {
await new Promise((resolve) => setTimeout(resolve, interval))
}
const result: PollResult<T> = await pollFn()
// If polling should stop
if (!result.continue) {
if (result.error) {
throw result.error
}
if (!result.data) {
throw new Error("Polling stopped without data")
}
return result.data
}
}
throw new Error("Polling timeout: Maximum attempts reached")
}
/**
* Calculate time remaining in a human-readable format
* @param startTime Start time in milliseconds
* @param expiresIn Total expiration time in seconds
* @returns Formatted time string (e.g., "9:45")
*/
export function formatTimeRemaining(startTime: number, expiresIn: number): string {
const elapsed = Math.floor((Date.now() - startTime) / 1000)
const remaining = Math.max(0, expiresIn - elapsed)
const minutes = Math.floor(remaining / 60)
const seconds = remaining % 60
return `${minutes}:${seconds.toString().padStart(2, "0")}`
}
+92
View File
@@ -0,0 +1,92 @@
import { select } from "@clack/prompts"
import type { KilocodeProfile, Organization } from "./types.js"
import { KILO_API_BASE, DEFAULT_MODEL } from "./constants.js"
/**
* Fetch user profile data from Kilo API
* @param token - The Kilo API token
* @returns Profile data including user info and organizations
* @throws Error if request fails
*/
export async function getKiloProfile(token: string): Promise<KilocodeProfile> {
const response = await fetch(`${KILO_API_BASE}/api/profile`, {
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
})
if (!response.ok) {
if (response.status === 401 || response.status === 403) {
throw new Error("Invalid token")
}
throw new Error(`Failed to fetch profile: ${response.status}`)
}
const data = await response.json()
return data as KilocodeProfile
}
/**
* Fetch the default model from Kilo API
* @param token - The Kilo API token
* @param organizationId - Optional organization ID for org-specific defaults
* @returns The default model ID, or falls back to a default on error
*/
export async function getKiloDefaultModel(token: string, organizationId?: string): Promise<string> {
const path = organizationId ? `/api/organizations/${organizationId}/defaults` : `/api/defaults`
const url = `${KILO_API_BASE}${path}`
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
})
if (!response.ok) {
console.warn(`Failed to fetch default model, using fallback: ${DEFAULT_MODEL}`)
return DEFAULT_MODEL
}
const data = await response.json()
const defaultModel = data.defaultModel
if (!defaultModel) {
console.warn(`No default model returned, using fallback: ${DEFAULT_MODEL}`)
return DEFAULT_MODEL
}
return defaultModel
}
/**
* Prompt user to select an organization or personal account
* @param organizations List of organizations the user belongs to
* @returns Organization ID or undefined for personal account
*/
export async function promptOrganizationSelection(organizations: Organization[]): Promise<string | undefined> {
if (!organizations || organizations.length === 0) {
return undefined
}
const choices = [
{ label: "Personal Account", value: "personal", hint: "Use your personal account" },
...organizations.map((org) => ({
label: org.name,
value: org.id,
hint: `Organization`,
})),
]
const result = await select({
message: "Select account",
options: choices,
})
if (result === "personal") {
return undefined
}
return result as string
}
+33
View File
@@ -0,0 +1,33 @@
export interface DeviceAuthInitiateResponse {
code: string
verificationUrl: string
expiresIn: number
}
export interface DeviceAuthPollResponse {
status: "pending" | "approved" | "denied" | "expired"
token?: string
userEmail?: string
}
export interface Organization {
id: string
name: string
}
export interface KilocodeProfile {
email: string
organizations?: Organization[]
}
export interface PollOptions<T> {
interval: number
maxAttempts: number
pollFn: () => Promise<PollResult<T>>
}
export interface PollResult<T> {
continue: boolean
data?: T
error?: Error
}
+12
View File
@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig.json",
"extends": "@tsconfig/node22/tsconfig.json",
"compilerOptions": {
"outDir": "dist",
"module": "preserve",
"declaration": true,
"moduleResolution": "bundler",
"lib": ["es2022", "dom", "dom.iterable"]
},
"include": ["src"]
}
+4
View File
@@ -0,0 +1,4 @@
node_modules/
dist/
*.log
.DS_Store
+139
View File
@@ -0,0 +1,139 @@
# @opencode-ai/kilo-provider
KiloCode provider for OpenCode AI SDK. This package provides a custom AI provider that wraps the OpenRouter SDK with KiloCode-specific configuration including custom authentication, headers, and base URL.
## Installation
```bash
bun install @opencode-ai/kilo-provider
```
## Usage
### Basic Usage
```typescript
import { createKilo } from "@opencode-ai/kilo-provider"
const provider = createKilo({
kilocodeToken: process.env.KILOCODE_API_KEY,
})
const model = provider.languageModel("anthropic/claude-sonnet-4")
```
### With Organization ID
```typescript
const provider = createKilo({
kilocodeToken: process.env.KILOCODE_API_KEY,
kilocodeOrganizationId: "org-123",
})
```
### Custom Base URL
```typescript
const provider = createKilo({
kilocodeToken: process.env.KILOCODE_API_KEY,
baseURL: "https://custom.kilo.ai/api/",
})
```
### With Custom Headers
```typescript
const provider = createKilo({
kilocodeToken: process.env.KILOCODE_API_KEY,
headers: {
"X-Custom-Header": "value",
},
})
```
## Configuration in OpenCode
Add the kilo provider to your `.opencode/config.json`:
```json
{
"provider": {
"kilo": {
"name": "KiloCode",
"env": ["KILOCODE_API_KEY", "KILO_API_KEY"],
"api": "https://api.kilo.ai/api/openrouter/",
"npm": "@opencode-ai/kilo-provider",
"options": {
"kilocodeToken": "your-token-here",
"kilocodeOrganizationId": "org-123"
}
}
},
"model": "kilo/anthropic/claude-sonnet-4"
}
```
## Environment Variables
The provider supports these environment variables:
- `KILOCODE_API_KEY` or `KILO_API_KEY` - API authentication token
- `KILOCODE_ORGANIZATION_ID` - Organization ID for multi-tenant setups
- `KILOCODE_API_URL` - Custom API base URL (defaults to https://api.kilo.ai/api/)
- `KILOCODE_EDITOR_NAME` - Custom editor name for tracking (defaults to "opencode")
## Features
- ✅ KiloCode API endpoint integration
- ✅ Custom authentication via `kilocodeToken`
- ✅ Custom headers (X-KILOCODE-ORGANIZATIONID, X-KILOCODE-TASKID, etc.)
- ✅ Auto-loading based on authentication availability
- ✅ Anonymous mode for free models when no auth provided
- ✅ Based on OpenRouter SDK with KiloCode configuration
## API
### `createKilo(options)`
Creates a KiloCode provider instance.
**Options:**
- `kilocodeToken?: string` - KiloCode authentication token
- `kilocodeOrganizationId?: string` - Organization ID for multi-tenant setups
- `kilocodeModel?: string` - Model ID to use
- `openRouterSpecificProvider?: string` - Specific OpenRouter provider to use
- `baseURL?: string` - Base URL for the KiloCode API
- `headers?: Record<string, string>` - Custom headers to include
- `apiKey?: string` - API key (alternative to kilocodeToken)
- `name?: string` - Provider name for identification
- `fetch?: typeof fetch` - Custom fetch function
- `timeout?: number | false` - Request timeout in milliseconds
### `kiloCustomLoader(provider)`
Custom loader function for the kilo provider. Used internally by OpenCode's provider system.
### `buildKiloHeaders(metadata, options)`
Build KiloCode-specific headers from metadata and options.
### `getEditorNameHeader()`
Get editor name header value. Defaults to "opencode".
### `getKiloUrlFromToken(defaultUrl, token)`
Parse KiloCode URL from token.
### `isValidKilocodeToken(token)`
Validate KiloCode token format.
### `getApiKey(options)`
Get API key from options or environment.
## License
MIT
+37
View File
@@ -0,0 +1,37 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/kilo-provider",
"version": "1.0.0",
"type": "module",
"license": "MIT",
"description": "KiloCode provider for OpenCode AI SDK",
"keywords": [
"kilo",
"kilocode",
"opencode",
"ai",
"llm",
"provider"
],
"exports": {
".": "./src/index.ts"
},
"files": [
"dist"
],
"scripts": {
"typecheck": "tsgo --noEmit",
"build": "tsc"
},
"dependencies": {
"@openrouter/ai-sdk-provider": "1.5.2",
"ai": "catalog:",
"zod": "catalog:"
},
"devDependencies": {
"@tsconfig/node22": "catalog:",
"@types/node": "catalog:",
"typescript": "catalog:",
"@typescript/native-preview": "catalog:"
}
}
+38
View File
@@ -0,0 +1,38 @@
/**
* Parse KiloCode URL from token
* Some tokens contain encoded base URL information
*/
export function getKiloUrlFromToken(defaultUrl: string, token: string): string {
// If token contains URL information, extract it
// This is a simplified version - adjust based on actual token format
if (!token) return defaultUrl
try {
// Check if token has URL prefix (format: "url:base64token")
const parts = token.split(":")
if (parts.length > 1 && parts[0].startsWith("http")) {
return parts[0]
}
} catch (e) {
// If parsing fails, return default
}
return defaultUrl
}
/**
* Validate KiloCode token format
*/
export function isValidKilocodeToken(token: string): boolean {
if (!token || typeof token !== "string") return false
// Basic validation - adjust based on actual token requirements
return token.length > 10
}
/**
* Get API key from options or environment
*/
export function getApiKey(options: { kilocodeToken?: string; apiKey?: string }): string | undefined {
return options.kilocodeToken ?? options.apiKey
}
+43
View File
@@ -0,0 +1,43 @@
/**
* Kilo Provider Configuration Constants
* Centralized configuration for all API endpoints, headers, and settings
*/
/** Base URL for Kilo API */
export const KILO_API_BASE = "https://api.kilo.ai/api/"
/** Default base URL for OpenRouter-compatible endpoint */
export const KILO_OPENROUTER_BASE = "https://api.kilo.ai/api/openrouter"
/** User-Agent header value for requests */
export const USER_AGENT = "opencode-kilo-provider"
/** Content-Type header value for requests */
export const CONTENT_TYPE = "application/json"
/** Default provider name */
export const DEFAULT_PROVIDER_NAME = "kilo"
/** Default API key for anonymous requests */
export const ANONYMOUS_API_KEY = "anonymous"
/** Fetch timeout for model requests in milliseconds (10 seconds) */
export const MODELS_FETCH_TIMEOUT_MS = 10 * 1000
/**
* Header constants for KiloCode API requests
*/
export const HEADER_ORGANIZATIONID = "X-KILOCODE-ORGANIZATIONID"
export const HEADER_TASKID = "X-KILOCODE-TASKID"
export const HEADER_PROJECTID = "X-KILOCODE-PROJECTID"
export const HEADER_TESTER = "X-KILOCODE-TESTER"
export const HEADER_EDITORNAME = "X-KILOCODE-EDITORNAME"
/** Default editor name value */
export const DEFAULT_EDITOR_NAME = "opencode"
/** Environment variable name for custom editor name */
export const ENV_EDITOR_NAME = "KILOCODE_EDITOR_NAME"
/** Tester header value for suppressing warnings */
export const TESTER_SUPPRESS_VALUE = "SUPPRESS"
+72
View File
@@ -0,0 +1,72 @@
import {
HEADER_ORGANIZATIONID,
HEADER_TASKID,
HEADER_PROJECTID,
HEADER_TESTER,
HEADER_EDITORNAME,
USER_AGENT,
CONTENT_TYPE,
DEFAULT_EDITOR_NAME,
ENV_EDITOR_NAME,
TESTER_SUPPRESS_VALUE,
} from "./constants"
/**
* Header constants for KiloCode API requests
* @deprecated Use HEADER_* constants from constants.ts instead
*/
export const X_KILOCODE_ORGANIZATIONID = HEADER_ORGANIZATIONID
export const X_KILOCODE_TASKID = HEADER_TASKID
export const X_KILOCODE_PROJECTID = HEADER_PROJECTID
export const X_KILOCODE_TESTER = HEADER_TESTER
export const X_KILOCODE_EDITORNAME = HEADER_EDITORNAME
/**
* Default headers for KiloCode requests
*/
export const DEFAULT_HEADERS = {
"User-Agent": USER_AGENT,
"Content-Type": CONTENT_TYPE,
}
/**
* Get editor name header value
* Defaults to "opencode" but can be customized
*/
export function getEditorNameHeader(): string {
return process.env[ENV_EDITOR_NAME] ?? DEFAULT_EDITOR_NAME
}
/**
* Build KiloCode-specific headers from metadata and options
*/
export function buildKiloHeaders(
metadata?: { taskId?: string; projectId?: string },
options?: {
kilocodeOrganizationId?: string
kilocodeTesterWarningsDisabledUntil?: number
},
): Record<string, string> {
const headers: Record<string, string> = {
[X_KILOCODE_EDITORNAME]: getEditorNameHeader(),
}
if (metadata?.taskId) {
headers[X_KILOCODE_TASKID] = metadata.taskId
}
if (options?.kilocodeOrganizationId) {
headers[X_KILOCODE_ORGANIZATIONID] = options.kilocodeOrganizationId
if (metadata?.projectId) {
headers[X_KILOCODE_PROJECTID] = metadata.projectId
}
}
// Add X-KILOCODE-TESTER: SUPPRESS header if the setting is enabled
if (options?.kilocodeTesterWarningsDisabledUntil && options.kilocodeTesterWarningsDisabledUntil > Date.now()) {
headers[X_KILOCODE_TESTER] = TESTER_SUPPRESS_VALUE
}
return headers
}
+50
View File
@@ -0,0 +1,50 @@
/**
* @opencode-ai/kilo-provider
*
* KiloCode provider for OpenCode AI SDK
*
* This package provides a KiloCode-specific AI provider that wraps
* the OpenRouter SDK with custom authentication, headers, and configuration.
*
* @example
* ```typescript
* import { createKilo } from "@opencode-ai/kilo-provider"
*
* const provider = createKilo({
* kilocodeToken: process.env.KILOCODE_API_KEY,
* kilocodeOrganizationId: "org-123"
* })
*
* const model = provider.languageModel("anthropic/claude-sonnet-4")
* ```
*/
export { createKilo } from "./provider"
export { createKiloDebug } from "./provider-debug"
export { kiloCustomLoader } from "./loader"
export { buildKiloHeaders, getEditorNameHeader } from "./headers"
export { getKiloUrlFromToken, isValidKilocodeToken, getApiKey } from "./auth"
export { fetchKiloModels } from "./models"
export type { KiloProviderOptions, KiloMetadata, CustomLoaderResult, ProviderInfo } from "./types"
// Export constants for external use
export {
KILO_API_BASE,
KILO_OPENROUTER_BASE,
USER_AGENT,
CONTENT_TYPE,
DEFAULT_PROVIDER_NAME,
ANONYMOUS_API_KEY,
MODELS_FETCH_TIMEOUT_MS,
HEADER_ORGANIZATIONID,
HEADER_TASKID,
HEADER_PROJECTID,
HEADER_TESTER,
HEADER_EDITORNAME,
DEFAULT_EDITOR_NAME,
ENV_EDITOR_NAME,
TESTER_SUPPRESS_VALUE,
} from "./constants"
// Re-export types from OpenRouter for convenience
export type { LanguageModelV2 } from "@openrouter/ai-sdk-provider"
+66
View File
@@ -0,0 +1,66 @@
import type { CustomLoaderResult, ProviderInfo } from "./types"
/**
* Custom loader function for the kilo provider
*
* This function is called by OpenCode's provider system to determine
* if the kilo provider should be auto-loaded and what options to use.
*
* @param provider - Provider information from the models database
* @returns Loader result with autoload status and options
*/
export async function kiloCustomLoader(provider: ProviderInfo): Promise<CustomLoaderResult> {
// Check if we have authentication
const hasKey = await checkAuthentication(provider)
// Handle empty models case
if (!provider.models || Object.keys(provider.models).length === 0) {
console.log("[kilo-provider] No models available, autoload: false")
return {
autoload: false,
options: hasKey ? {} : { apiKey: "anonymous" },
}
}
// Log initial model count
const initialCount = Object.keys(provider.models).length
console.log(`[kilo-provider] Loaded ${initialCount} models, hasAuth: ${hasKey}`)
// If no key, remove paid models
if (!hasKey) {
for (const [key, value] of Object.entries(provider.models)) {
if (value.cost?.input > 0 || value.cost?.output > 0) {
delete provider.models[key]
}
}
const freeCount = Object.keys(provider.models).length
console.log(
`[kilo-provider] Filtered to ${freeCount} free models (removed ${initialCount - freeCount} paid models)`,
)
}
const autoload = Object.keys(provider.models).length > 0
console.log(`[kilo-provider] Autoload: ${autoload}`)
return {
autoload,
options: hasKey ? {} : { apiKey: "anonymous" },
}
}
/**
* Check if authentication is available from multiple sources
*/
async function checkAuthentication(provider: ProviderInfo): Promise<boolean> {
// Check 1: Provider configuration
if (provider.options?.apiKey || provider.options?.kilocodeToken) {
return true
}
// Check 2: Provider key
if (provider.key) {
return true
}
return false
}
+218
View File
@@ -0,0 +1,218 @@
import { z } from "zod"
import { getKiloUrlFromToken } from "./auth"
import { DEFAULT_HEADERS } from "./headers"
import { KILO_OPENROUTER_BASE, MODELS_FETCH_TIMEOUT_MS } from "./constants"
/**
* OpenRouter model schema
*/
const openRouterArchitectureSchema = z.object({
input_modalities: z.array(z.string()).nullish(),
output_modalities: z.array(z.string()).nullish(),
tokenizer: z.string().nullish(),
})
const openRouterPricingSchema = z.object({
prompt: z.string().nullish(),
completion: z.string().nullish(),
input_cache_write: z.string().nullish(),
input_cache_read: z.string().nullish(),
})
const openRouterModelSchema = z.object({
id: z.string(),
name: z.string(),
description: z.string().optional(),
context_length: z.number(),
max_completion_tokens: z.number().nullish(),
pricing: openRouterPricingSchema.optional(),
architecture: openRouterArchitectureSchema.optional(),
top_provider: z.object({ max_completion_tokens: z.number().nullish() }).optional(),
supported_parameters: z.array(z.string()).optional(),
})
const openRouterModelsResponseSchema = z.object({
data: z.array(openRouterModelSchema),
})
type OpenRouterModel = z.infer<typeof openRouterModelSchema>
/**
* Parse API price string to number (e.g. "0.00001" -> 0.00001)
*/
function parseApiPrice(price: string | null | undefined): number | undefined {
if (!price) return undefined
const parsed = parseFloat(price)
return isNaN(parsed) ? undefined : parsed
}
/**
* Fetch models from Kilo API (OpenRouter-compatible endpoint)
*
* @param options - Configuration options
* @returns Record of models in ModelsDev.Model format
*/
export async function fetchKiloModels(options?: {
kilocodeToken?: string
kilocodeOrganizationId?: string
baseURL?: string
}): Promise<Record<string, any>> {
const token = options?.kilocodeToken
const organizationId = options?.kilocodeOrganizationId
// Construct base URL
const defaultBaseURL = organizationId
? `https://api.kilo.ai/api/organizations/${organizationId}`
: KILO_OPENROUTER_BASE
const baseURL = options?.baseURL ?? defaultBaseURL
// Transform URL with token if available
const finalBaseURL = token ? getKiloUrlFromToken(baseURL, token) : baseURL
// Construct models endpoint
const modelsURL = `${finalBaseURL}/models`
try {
// Fetch models with timeout
const response = await fetch(modelsURL, {
headers: {
...DEFAULT_HEADERS,
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
signal: AbortSignal.timeout(MODELS_FETCH_TIMEOUT_MS),
})
if (!response.ok) {
throw new Error(`Failed to fetch models: ${response.status} ${response.statusText}`)
}
const json = await response.json()
// Validate response schema
const result = openRouterModelsResponseSchema.safeParse(json)
if (!result.success) {
console.error("Kilo models response validation failed:", result.error.format())
return {}
}
// Transform models to ModelsDev.Model format
const models: Record<string, any> = {}
for (const model of result.data.data) {
// Skip image generation models
if (model.architecture?.output_modalities?.includes("image")) {
continue
}
const transformedModel = transformToModelDevFormat(model)
models[model.id] = transformedModel
}
return models
} catch (error) {
console.error("Error fetching Kilo models:", error)
return {}
}
}
/**
* Transform OpenRouter model to ModelsDev.Model format
*/
function transformToModelDevFormat(model: OpenRouterModel): any {
const inputModalities = model.architecture?.input_modalities || []
const outputModalities = model.architecture?.output_modalities || []
const supportedParameters = model.supported_parameters || []
// Parse pricing
const inputPrice = parseApiPrice(model.pricing?.prompt)
const outputPrice = parseApiPrice(model.pricing?.completion)
const cacheWritePrice = parseApiPrice(model.pricing?.input_cache_write)
const cacheReadPrice = parseApiPrice(model.pricing?.input_cache_read)
// Determine capabilities
const supportsImages = inputModalities.includes("image")
const supportsTools = supportedParameters.includes("tools")
const supportsReasoning = supportedParameters.includes("reasoning")
const supportsTemperature = supportedParameters.includes("temperature")
// Calculate max output tokens
const maxOutputTokens =
model.top_provider?.max_completion_tokens || model.max_completion_tokens || Math.ceil(model.context_length * 0.2)
return {
id: model.id,
name: model.name,
family: extractFamily(model.id),
release_date: new Date().toISOString().split("T")[0], // Default to today
attachment: supportsImages,
reasoning: supportsReasoning,
temperature: supportsTemperature,
tool_call: supportsTools,
...(inputPrice !== undefined &&
outputPrice !== undefined && {
cost: {
input: inputPrice,
output: outputPrice,
...(cacheReadPrice !== undefined && { cache_read: cacheReadPrice }),
...(cacheWritePrice !== undefined && { cache_write: cacheWritePrice }),
},
}),
limit: {
context: model.context_length,
output: maxOutputTokens,
},
...((inputModalities.length > 0 || outputModalities.length > 0) && {
modalities: {
input: mapModalities(inputModalities),
output: mapModalities(outputModalities),
},
}),
options: {
...(model.description && { description: model.description }),
},
}
}
/**
* Extract family name from model ID
* e.g., "anthropic/claude-3-opus" -> "claude"
*/
function extractFamily(modelId: string): string | undefined {
const parts = modelId.split("/")
if (parts.length < 2) return undefined
const modelName = parts[1]
// Try to extract family from common patterns
if (modelName.includes("claude")) return "claude"
if (modelName.includes("gpt")) return "gpt"
if (modelName.includes("gemini")) return "gemini"
if (modelName.includes("llama")) return "llama"
if (modelName.includes("mistral")) return "mistral"
return undefined
}
/**
* Map OpenRouter modalities to ModelsDev modalities
*/
function mapModalities(modalities: string[]): Array<"text" | "audio" | "image" | "video" | "pdf"> {
const result: Array<"text" | "audio" | "image" | "video" | "pdf"> = []
for (const modality of modalities) {
if (modality === "text") result.push("text")
if (modality === "image") result.push("image")
if (modality === "audio") result.push("audio")
if (modality === "video") result.push("video")
if (modality === "pdf") result.push("pdf")
}
// Always include text if not present
if (!result.includes("text")) {
result.unshift("text")
}
return result
}
@@ -0,0 +1,109 @@
import { createOpenRouter } from "@openrouter/ai-sdk-provider"
import type { Provider as SDK } from "ai"
import type { KiloProviderOptions } from "./types"
import { getKiloUrlFromToken, getApiKey } from "./auth"
import { buildKiloHeaders, DEFAULT_HEADERS } from "./headers"
import { KILO_API_BASE, ANONYMOUS_API_KEY } from "./constants"
/**
* Debug version of createKilo with extensive logging
*/
export function createKiloDebug(options: KiloProviderOptions = {}): SDK {
console.log("\n🔍 [KILO DEBUG] Creating Kilo Provider")
console.log("📋 [KILO DEBUG] Options received:", JSON.stringify(options, null, 2))
// Get API key from options or environment
const apiKey = getApiKey(options)
console.log("🔑 [KILO DEBUG] API Key extracted:")
console.log(" - Source:", options.kilocodeToken ? "kilocodeToken" : options.apiKey ? "apiKey" : "none")
console.log(" - Value:", apiKey ? `${apiKey.substring(0, 8)}...${apiKey.substring(apiKey.length - 8)}` : "MISSING!")
// Determine base URL
const baseApiUrl = getKiloUrlFromToken(options.baseURL ?? KILO_API_BASE, apiKey ?? "")
console.log("🌐 [KILO DEBUG] Base URL resolved:", baseApiUrl)
// Build OpenRouter URL - only append /openrouter/ if not already present
const openRouterUrl = baseApiUrl.includes("/openrouter")
? baseApiUrl
: baseApiUrl.endsWith("/")
? `${baseApiUrl}openrouter/`
: `${baseApiUrl}/openrouter/`
console.log("🔗 [KILO DEBUG] OpenRouter URL:", openRouterUrl)
// Merge custom headers with defaults
const customHeaders = {
...DEFAULT_HEADERS,
...buildKiloHeaders(undefined, {
kilocodeOrganizationId: options.kilocodeOrganizationId,
kilocodeTesterWarningsDisabledUntil: undefined,
}),
...options.headers,
}
console.log("📝 [KILO DEBUG] Custom headers:", JSON.stringify(customHeaders, null, 2))
// Create custom fetch wrapper to add dynamic headers
const originalFetch = options.fetch ?? fetch
const wrappedFetch: typeof fetch = async (input, init) => {
console.log("\n🚀 [KILO DEBUG] Making request:")
console.log(" - URL:", String(input))
console.log(" - Method:", init?.method || "GET")
const headers = new Headers(init?.headers)
// Add custom headers
Object.entries(customHeaders).forEach(([key, value]) => {
headers.set(key, value)
})
// Add authorization if API key exists
if (apiKey) {
const authValue = `Bearer ${apiKey}`
headers.set("Authorization", authValue)
console.log(
" - Authorization header set:",
`Bearer ${apiKey.substring(0, 8)}...${apiKey.substring(apiKey.length - 8)}`,
)
} else {
console.log(" ⚠️ - NO AUTHORIZATION HEADER! API key is missing")
}
console.log(" - Headers being sent:")
headers.forEach((value, key) => {
if (key.toLowerCase() === "authorization") {
console.log(` ${key}: ${value.substring(0, 20)}...`)
} else {
console.log(` ${key}: ${value}`)
}
})
const response = await originalFetch(input, {
...init,
headers,
})
console.log(" - Response status:", response.status, response.statusText)
if (!response.ok) {
const responseText = await response.text()
console.log(" ❌ - Error response:", responseText)
// Re-create response since we consumed the body
return new Response(responseText, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
})
}
return response
}
console.log("✅ [KILO DEBUG] Creating OpenRouter provider with configuration\n")
// Create OpenRouter provider with KiloCode configuration
return createOpenRouter({
baseURL: openRouterUrl,
apiKey: apiKey ?? ANONYMOUS_API_KEY,
headers: customHeaders,
fetch: wrappedFetch,
})
}
+76
View File
@@ -0,0 +1,76 @@
import { createOpenRouter } from "@openrouter/ai-sdk-provider"
import type { Provider as SDK } from "ai"
import type { KiloProviderOptions } from "./types"
import { getKiloUrlFromToken, getApiKey } from "./auth"
import { buildKiloHeaders, DEFAULT_HEADERS } from "./headers"
import { KILO_API_BASE, DEFAULT_PROVIDER_NAME, ANONYMOUS_API_KEY } from "./constants"
/**
* Create a KiloCode provider instance
*
* This provider wraps the OpenRouter SDK with KiloCode-specific configuration
* including custom authentication, headers, and base URL.
*
* @example
* ```typescript
* const provider = createKilo({
* kilocodeToken: "your-token-here",
* kilocodeOrganizationId: "org-123"
* })
*
* const model = provider.languageModel("anthropic/claude-sonnet-4")
* ```
*/
export function createKilo(options: KiloProviderOptions = {}): SDK {
// Get API key from options or environment
const apiKey = getApiKey(options)
// Determine base URL
const baseApiUrl = getKiloUrlFromToken(options.baseURL ?? KILO_API_BASE, apiKey ?? "")
// Build OpenRouter URL - only append /openrouter/ if not already present
const openRouterUrl = baseApiUrl.includes("/openrouter")
? baseApiUrl
: baseApiUrl.endsWith("/")
? `${baseApiUrl}openrouter/`
: `${baseApiUrl}/openrouter/`
// Merge custom headers with defaults
const customHeaders = {
...DEFAULT_HEADERS,
...buildKiloHeaders(undefined, {
kilocodeOrganizationId: options.kilocodeOrganizationId,
kilocodeTesterWarningsDisabledUntil: undefined,
}),
...options.headers,
}
// Create custom fetch wrapper to add dynamic headers
const originalFetch = options.fetch ?? fetch
const wrappedFetch: typeof fetch = async (input, init) => {
const headers = new Headers(init?.headers)
// Add custom headers
Object.entries(customHeaders).forEach(([key, value]) => {
headers.set(key, value)
})
// Add authorization if API key exists
if (apiKey) {
headers.set("Authorization", `Bearer ${apiKey}`)
}
return originalFetch(input, {
...init,
headers,
})
}
// Create OpenRouter provider with KiloCode configuration
return createOpenRouter({
baseURL: openRouterUrl,
apiKey: apiKey ?? ANONYMOUS_API_KEY,
headers: customHeaders,
fetch: wrappedFetch,
})
}
+111
View File
@@ -0,0 +1,111 @@
import type { Provider as SDK } from "ai"
import type { LanguageModelV2 } from "@openrouter/ai-sdk-provider"
/**
* Options for creating a Kilo provider instance
*/
export interface KiloProviderOptions {
/**
* KiloCode authentication token
*/
kilocodeToken?: string
/**
* Organization ID for multi-tenant setups
*/
kilocodeOrganizationId?: string
/**
* Model ID to use (e.g., "anthropic/claude-sonnet-4")
*/
kilocodeModel?: string
/**
* Specific OpenRouter provider to use
*/
openRouterSpecificProvider?: string
/**
* Base URL for the KiloCode API
* @default "https://api.kilo.ai/api/openrouter/"
*/
baseURL?: string
/**
* Custom headers to include in requests
*/
headers?: Record<string, string>
/**
* API key (alternative to kilocodeToken)
*/
apiKey?: string
/**
* Provider name for identification
*/
name?: string
/**
* Custom fetch function
*/
fetch?: typeof fetch
/**
* Request timeout in milliseconds
*/
timeout?: number | false
}
/**
* Metadata for API requests
*/
export interface KiloMetadata {
/**
* Task ID for tracking
*/
taskId?: string
/**
* Project ID for organization tracking
*/
projectId?: string
/**
* Mode of operation (e.g., "code", "chat")
*/
mode?: string
}
/**
* Custom loader return type
*/
export interface CustomLoaderResult {
/**
* Whether to automatically load this provider
*/
autoload: boolean
/**
* Custom function to get a model instance
*/
getModel?: (sdk: SDK, modelID: string, options?: Record<string, any>) => Promise<LanguageModelV2>
/**
* Options to merge with provider configuration
*/
options?: Record<string, any>
}
/**
* Provider info type (minimal definition needed for loader)
*/
export interface ProviderInfo {
id: string
name: string
source: "env" | "config" | "custom" | "api"
env: string[]
key?: string
options: Record<string, any>
models: Record<string, any>
}
+12
View File
@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig.json",
"extends": "@tsconfig/node22/tsconfig.json",
"compilerOptions": {
"outDir": "dist",
"module": "preserve",
"declaration": true,
"moduleResolution": "bundler",
"lib": ["es2022", "dom", "dom.iterable"]
},
"include": ["src"]
}
+2
View File
@@ -77,6 +77,8 @@
"@octokit/graphql": "9.0.2",
"@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:",
"@opencode-ai/kilo-auth-plugin": "workspace:*",
"@opencode-ai/kilo-provider": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
+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(