From 00a6fa709cd2c4967bc33d17d67765e9d92b824e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Catriel=20M=C3=BCller?= Date: Mon, 26 Jan 2026 10:55:26 +0100 Subject: [PATCH] feat: kilo provider & kilo auth plugin --- bun.lock | 38 +++ packages/kilo-auth-plugin/README.md | 60 +++++ packages/kilo-auth-plugin/package.json | 39 ++++ packages/kilo-auth-plugin/src/constants.ts | 16 ++ packages/kilo-auth-plugin/src/device-auth.ts | 183 +++++++++++++++ packages/kilo-auth-plugin/src/index.ts | 77 +++++++ packages/kilo-auth-plugin/src/polling.ts | 47 ++++ packages/kilo-auth-plugin/src/profile.ts | 92 ++++++++ packages/kilo-auth-plugin/src/types.ts | 33 +++ packages/kilo-auth-plugin/tsconfig.json | 12 + packages/kilo-provider/.gitignore | 4 + packages/kilo-provider/README.md | 139 +++++++++++ packages/kilo-provider/package.json | 37 +++ packages/kilo-provider/src/auth.ts | 38 +++ packages/kilo-provider/src/constants.ts | 43 ++++ packages/kilo-provider/src/headers.ts | 72 ++++++ packages/kilo-provider/src/index.ts | 50 ++++ packages/kilo-provider/src/loader.ts | 66 ++++++ packages/kilo-provider/src/models.ts | 218 ++++++++++++++++++ packages/kilo-provider/src/provider-debug.ts | 109 +++++++++ packages/kilo-provider/src/provider.ts | 76 ++++++ packages/kilo-provider/src/types.ts | 111 +++++++++ packages/kilo-provider/tsconfig.json | 12 + packages/opencode/package.json | 2 + packages/opencode/src/cli/cmd/auth.ts | 18 +- packages/opencode/src/plugin/index.ts | 3 +- packages/opencode/src/provider/model-cache.ts | 217 +++++++++++++++++ packages/opencode/src/provider/models.ts | 26 ++- packages/opencode/src/provider/provider.ts | 27 +++ .../opencode/src/server/routes/provider.ts | 13 +- 30 files changed, 1866 insertions(+), 12 deletions(-) create mode 100644 packages/kilo-auth-plugin/README.md create mode 100644 packages/kilo-auth-plugin/package.json create mode 100644 packages/kilo-auth-plugin/src/constants.ts create mode 100644 packages/kilo-auth-plugin/src/device-auth.ts create mode 100644 packages/kilo-auth-plugin/src/index.ts create mode 100644 packages/kilo-auth-plugin/src/polling.ts create mode 100644 packages/kilo-auth-plugin/src/profile.ts create mode 100644 packages/kilo-auth-plugin/src/types.ts create mode 100644 packages/kilo-auth-plugin/tsconfig.json create mode 100644 packages/kilo-provider/.gitignore create mode 100644 packages/kilo-provider/README.md create mode 100644 packages/kilo-provider/package.json create mode 100644 packages/kilo-provider/src/auth.ts create mode 100644 packages/kilo-provider/src/constants.ts create mode 100644 packages/kilo-provider/src/headers.ts create mode 100644 packages/kilo-provider/src/index.ts create mode 100644 packages/kilo-provider/src/loader.ts create mode 100644 packages/kilo-provider/src/models.ts create mode 100644 packages/kilo-provider/src/provider-debug.ts create mode 100644 packages/kilo-provider/src/provider.ts create mode 100644 packages/kilo-provider/src/types.ts create mode 100644 packages/kilo-provider/tsconfig.json create mode 100644 packages/opencode/src/provider/model-cache.ts diff --git a/bun.lock b/bun.lock index d9eeddd61..023456ecb 100644 --- a/bun.lock +++ b/bun.lock @@ -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"], diff --git a/packages/kilo-auth-plugin/README.md b/packages/kilo-auth-plugin/README.md new file mode 100644 index 000000000..9f4de15d2 --- /dev/null +++ b/packages/kilo-auth-plugin/README.md @@ -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. diff --git a/packages/kilo-auth-plugin/package.json b/packages/kilo-auth-plugin/package.json new file mode 100644 index 000000000..caf7bdd86 --- /dev/null +++ b/packages/kilo-auth-plugin/package.json @@ -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:" + } +} diff --git a/packages/kilo-auth-plugin/src/constants.ts b/packages/kilo-auth-plugin/src/constants.ts new file mode 100644 index 000000000..44a91434e --- /dev/null +++ b/packages/kilo-auth-plugin/src/constants.ts @@ -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 diff --git a/packages/kilo-auth-plugin/src/device-auth.ts b/packages/kilo-auth-plugin/src/device-auth.ts new file mode 100644 index 000000000..4093c25ad --- /dev/null +++ b/packages/kilo-auth-plugin/src/device-auth.ts @@ -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 { + 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 { + 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 { + 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({ + 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, + } +} diff --git a/packages/kilo-auth-plugin/src/index.ts b/packages/kilo-auth-plugin/src/index.ts new file mode 100644 index 000000000..6018bfb22 --- /dev/null +++ b/packages/kilo-auth-plugin/src/index.ts @@ -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 = { + 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 diff --git a/packages/kilo-auth-plugin/src/polling.ts b/packages/kilo-auth-plugin/src/polling.ts new file mode 100644 index 000000000..66dfc1dce --- /dev/null +++ b/packages/kilo-auth-plugin/src/polling.ts @@ -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(options: PollOptions): Promise { + 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 = 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")}` +} diff --git a/packages/kilo-auth-plugin/src/profile.ts b/packages/kilo-auth-plugin/src/profile.ts new file mode 100644 index 000000000..f3714319d --- /dev/null +++ b/packages/kilo-auth-plugin/src/profile.ts @@ -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 { + 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 { + 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 { + 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 +} diff --git a/packages/kilo-auth-plugin/src/types.ts b/packages/kilo-auth-plugin/src/types.ts new file mode 100644 index 000000000..f9c0cfbd8 --- /dev/null +++ b/packages/kilo-auth-plugin/src/types.ts @@ -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 { + interval: number + maxAttempts: number + pollFn: () => Promise> +} + +export interface PollResult { + continue: boolean + data?: T + error?: Error +} diff --git a/packages/kilo-auth-plugin/tsconfig.json b/packages/kilo-auth-plugin/tsconfig.json new file mode 100644 index 000000000..58072c81c --- /dev/null +++ b/packages/kilo-auth-plugin/tsconfig.json @@ -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"] +} diff --git a/packages/kilo-provider/.gitignore b/packages/kilo-provider/.gitignore new file mode 100644 index 000000000..dd6e803c7 --- /dev/null +++ b/packages/kilo-provider/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +*.log +.DS_Store diff --git a/packages/kilo-provider/README.md b/packages/kilo-provider/README.md new file mode 100644 index 000000000..5fcc8fe9f --- /dev/null +++ b/packages/kilo-provider/README.md @@ -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` - 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 diff --git a/packages/kilo-provider/package.json b/packages/kilo-provider/package.json new file mode 100644 index 000000000..873257969 --- /dev/null +++ b/packages/kilo-provider/package.json @@ -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:" + } +} diff --git a/packages/kilo-provider/src/auth.ts b/packages/kilo-provider/src/auth.ts new file mode 100644 index 000000000..0759a74cc --- /dev/null +++ b/packages/kilo-provider/src/auth.ts @@ -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 +} diff --git a/packages/kilo-provider/src/constants.ts b/packages/kilo-provider/src/constants.ts new file mode 100644 index 000000000..f5215ef4e --- /dev/null +++ b/packages/kilo-provider/src/constants.ts @@ -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" diff --git a/packages/kilo-provider/src/headers.ts b/packages/kilo-provider/src/headers.ts new file mode 100644 index 000000000..5acc3de61 --- /dev/null +++ b/packages/kilo-provider/src/headers.ts @@ -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 { + const headers: Record = { + [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 +} diff --git a/packages/kilo-provider/src/index.ts b/packages/kilo-provider/src/index.ts new file mode 100644 index 000000000..8abcdd9e3 --- /dev/null +++ b/packages/kilo-provider/src/index.ts @@ -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" diff --git a/packages/kilo-provider/src/loader.ts b/packages/kilo-provider/src/loader.ts new file mode 100644 index 000000000..ada503dd0 --- /dev/null +++ b/packages/kilo-provider/src/loader.ts @@ -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 { + // 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 { + // Check 1: Provider configuration + if (provider.options?.apiKey || provider.options?.kilocodeToken) { + return true + } + + // Check 2: Provider key + if (provider.key) { + return true + } + + return false +} diff --git a/packages/kilo-provider/src/models.ts b/packages/kilo-provider/src/models.ts new file mode 100644 index 000000000..c086eb3fc --- /dev/null +++ b/packages/kilo-provider/src/models.ts @@ -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 + +/** + * 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> { + 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 = {} + + 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 +} diff --git a/packages/kilo-provider/src/provider-debug.ts b/packages/kilo-provider/src/provider-debug.ts new file mode 100644 index 000000000..aacb238bf --- /dev/null +++ b/packages/kilo-provider/src/provider-debug.ts @@ -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, + }) +} diff --git a/packages/kilo-provider/src/provider.ts b/packages/kilo-provider/src/provider.ts new file mode 100644 index 000000000..89a717c3e --- /dev/null +++ b/packages/kilo-provider/src/provider.ts @@ -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, + }) +} diff --git a/packages/kilo-provider/src/types.ts b/packages/kilo-provider/src/types.ts new file mode 100644 index 000000000..63a03295f --- /dev/null +++ b/packages/kilo-provider/src/types.ts @@ -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 + + /** + * 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) => Promise + + /** + * Options to merge with provider configuration + */ + options?: Record +} + +/** + * 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 + models: Record +} diff --git a/packages/kilo-provider/tsconfig.json b/packages/kilo-provider/tsconfig.json new file mode 100644 index 000000000..58072c81c --- /dev/null +++ b/packages/kilo-provider/tsconfig.json @@ -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"] +} diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 31c9d0ea7..93d8dcb8a 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -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:*", diff --git a/packages/opencode/src/cli/cmd/auth.ts b/packages/opencode/src/cli/cmd/auth.ts index bbaecfd8c..7976ed542 100644 --- a/packages/opencode/src/cli/cmd/auth.ts +++ b/packages/opencode/src/cli/cmd/auth.ts @@ -268,15 +268,18 @@ export const AuthLoginCommand = cmd({ return filtered }) + // kilocode_change start const priority: Record = { - 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", diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 691fff4b2..bc502af21 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -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({ diff --git a/packages/opencode/src/provider/model-cache.ts b/packages/opencode/src/provider/model-cache.ts new file mode 100644 index 000000000..df485c3b5 --- /dev/null +++ b/packages/opencode/src/provider/model-cache.ts @@ -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 + timestamp: number + } + >() + + const TTL = 5 * 60 * 1000 // 5 minutes + const inFlightRefresh = new Map>>() + + /** + * 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 | 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> { + // 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> { + // 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> { + 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 { + 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 + } +} diff --git a/packages/opencode/src/provider/models.ts b/packages/opencode/src/provider/models.ts index 82794f35b..6a14c97cc 100644 --- a/packages/opencode/src/provider/models.ts +++ b/packages/opencode/src/provider/models.ts @@ -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 + // kilocode_change start + const providers = result as Record + + // 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() { diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index fdd4ccdfb..ce8b6e0bb 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -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 diff --git a/packages/opencode/src/server/routes/provider.ts b/packages/opencode/src/server/routes/provider.ts index 872b48be7..25081da9b 100644 --- a/packages/opencode/src/server/routes/provider.ts +++ b/packages/opencode/src/server/routes/provider.ts @@ -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(