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