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
+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"]
}