Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions packages/core/src/flag/flag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,7 @@ export const Flag = {

// Experimental
KILO_EXPERIMENTAL,
KILO_EXPERIMENTAL_FILEWATCHER: Config.boolean("KILO_EXPERIMENTAL_FILEWATCHER").pipe(
Config.withDefault(false),
),
KILO_EXPERIMENTAL_FILEWATCHER: Config.boolean("KILO_EXPERIMENTAL_FILEWATCHER").pipe(Config.withDefault(false)),
KILO_EXPERIMENTAL_DISABLE_FILEWATCHER: Config.boolean("KILO_EXPERIMENTAL_DISABLE_FILEWATCHER").pipe(
Config.withDefault(false),
),
Expand Down
11 changes: 5 additions & 6 deletions packages/kilo-docs/components/FlowDiagram/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,11 @@ export function FlowDiagram({ name, height = "400px" }: { name: string; height?:
const [cssLoaded, setCssLoaded] = useState(false)

useEffect(() => {
Promise.all([
import("@xyflow/react"),
import("@xyflow/react/dist/style.css").then(() => setCssLoaded(true)),
]).then(([xyflow]) => {
setMod(xyflow)
})
Promise.all([import("@xyflow/react"), import("@xyflow/react/dist/style.css").then(() => setCssLoaded(true))]).then(
([xyflow]) => {
setMod(xyflow)
},
)
}, [])

const diagram = diagrams[name]
Expand Down
87 changes: 49 additions & 38 deletions packages/kilo-gateway/src/api/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ import { getKiloUrlFromToken } from "../auth/token.js"
import { getDefaultHeaders, buildKiloHeaders } from "../headers.js"
import { KILO_API_BASE, KILO_OPENROUTER_BASE, MODELS_FETCH_TIMEOUT_MS, PROMPTS, AI_SDK_PROVIDERS } from "./constants.js"

export type KiloModelsResult = {
models: Record<string, any>
error?: { kind: "unauthorized" | "network" | "schema" | "http"; status?: number }
}

/**
* OpenRouter model schema
*/
Expand Down Expand Up @@ -63,13 +68,13 @@ function parseApiPrice(price: string | null | undefined): number | undefined {
* Fetch models from Kilo API (OpenRouter-compatible endpoint)
*
* @param options - Configuration options
* @returns Record of models in ModelsDev.Model format
* @returns Typed result with models and optional error info
*/
export async function fetchKiloModels(options?: {
kilocodeToken?: string
kilocodeOrganizationId?: string
baseURL?: string
}): Promise<Record<string, any>> {
}): Promise<KiloModelsResult> {
const token = options?.kilocodeToken
const organizationId = options?.kilocodeOrganizationId

Expand All @@ -84,54 +89,60 @@ export async function fetchKiloModels(options?: {
// Construct models endpoint
const modelsURL = `${finalBaseURL}/models`

try {
// Fetch models with timeout
const response = await fetch(modelsURL, {
headers: {
...getDefaultHeaders(),
...buildKiloHeaders(undefined, { kilocodeOrganizationId: organizationId }),
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
signal: AbortSignal.timeout(MODELS_FETCH_TIMEOUT_MS),
})
const response = await fetch(modelsURL, {
headers: {
...getDefaultHeaders(),
...buildKiloHeaders(undefined, { kilocodeOrganizationId: organizationId }),
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
signal: AbortSignal.timeout(MODELS_FETCH_TIMEOUT_MS),
}).catch((err: unknown) => err as Error)

if (response instanceof Error) {
return { models: {}, error: { kind: "network" } }
}

if (!response.ok) {
throw new Error(`Failed to fetch models: ${response.status} ${response.statusText}`)
if (!response.ok) {
// 401 with auth credentials: fall back to unauthenticated public endpoint
if (response.status === 401 && (token || organizationId)) {
return fetchKiloModels({})
}
const kind = response.status === 401 || response.status === 403 ? "unauthorized" : "http"
return { models: {}, error: { kind, status: response.status } }
}

const json = await response.json()
const json = await response.json().catch(() => null)

// Validate response schema
const result = openRouterModelsResponseSchema.safeParse(json)
if (json === null) {
return { models: {}, error: { kind: "schema" } }
}

if (!result.success) {
console.error("Kilo models response validation failed:", result.error.format())
return {}
}
// Validate response schema
const result = openRouterModelsResponseSchema.safeParse(json)

// Transform models to ModelsDev.Model format
const models: Record<string, any> = {}
if (!result.success) {
return { models: {}, error: { kind: "schema" } }
}

for (const model of result.data.data) {
// Skip image generation models
if (model.architecture?.output_modalities?.includes("image")) {
continue
}
// Transform models to ModelsDev.Model format
const models: Record<string, any> = {}

// Skip models that don't support tools — Kilo requires tool calling
if (!model.supported_parameters?.includes("tools")) {
continue
}
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
// Skip models that don't support tools — Kilo requires tool calling
if (!model.supported_parameters?.includes("tools")) {
continue
}

return models
} catch (error) {
console.error("Error fetching Kilo models:", error)
return {}
const transformedModel = transformToModelDevFormat(model)
models[model.id] = transformedModel
}

return { models }
}

/**
Expand Down
2 changes: 0 additions & 2 deletions packages/kilo-gateway/src/api/modes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,15 +77,13 @@ export async function fetchOrganizationModes(token: string, organizationId: stri
})

if (!response.ok) {
console.warn(`[Kilo Gateway] Failed to fetch organization modes: ${response.status}`)
return []
}

const json = await response.json()
const parsed = ResponseSchema.safeParse(json)

if (!parsed.success) {
console.warn("[Kilo Gateway] Organization modes response validation failed:", parsed.error.format())
return []
}

Expand Down
2 changes: 1 addition & 1 deletion packages/kilo-gateway/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export {
getKiloDefaultModel,
promptOrganizationSelection,
} from "./api/profile.js"
export { fetchKiloModels } from "./api/models.js"
export { fetchKiloModels, type KiloModelsResult } from "./api/models.js"
export {
EMPTY_KILO_EMBEDDING_MODEL_CATALOG,
fetchKiloEmbeddingModelCatalog,
Expand Down
124 changes: 124 additions & 0 deletions packages/kilo-gateway/test/api/models.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
// Verifies fetchKiloModels typed result and 401 fallback behaviour.

import { test, expect } from "bun:test"
import { fetchKiloModels } from "../../src/api/models.js"

const VALID_RESPONSE = JSON.stringify({
data: [
{
id: "test/model-a",
name: "Test Model A",
context_length: 128000,
max_completion_tokens: 16384,
architecture: {
input_modalities: ["text"],
output_modalities: ["text"],
},
supported_parameters: ["tools", "temperature"],
},
],
})

function stubFetch(fn: (input: string | URL | Request, init?: RequestInit) => Promise<Response>) {
;(globalThis as any).fetch = fn
}

test("returns empty models and error when both auth and public requests return 401", async () => {
const orig = globalThis.fetch
stubFetch(async () => new Response("Unauthorized", { status: 401, statusText: "Unauthorized" }))

const result = await fetchKiloModels({ kilocodeToken: "bad-token" })

;(globalThis as any).fetch = orig

expect(result.models).toEqual({})
expect(result.error).toBeDefined()
})

test("falls back to public endpoint on 401 and returns models", async () => {
const orig = globalThis.fetch
let callCount = 0

stubFetch(async () => {
callCount++
if (callCount === 1) {
return new Response("Unauthorized", { status: 401, statusText: "Unauthorized" })
}
return new Response(VALID_RESPONSE, {
status: 200,
headers: { "content-type": "application/json" },
})
})

const result = await fetchKiloModels({
kilocodeToken: "expired-token",
kilocodeOrganizationId: "org-123",
})

;(globalThis as any).fetch = orig

expect(callCount).toBe(2)
expect(result.error).toBeUndefined()
expect(Object.keys(result.models).length).toBeGreaterThan(0)
})

test("returns error with kind=network on fetch exception", async () => {
const orig = globalThis.fetch
stubFetch(async () => {
throw new Error("network error")
})

const result = await fetchKiloModels({})

;(globalThis as any).fetch = orig

expect(result.models).toEqual({})
expect(result.error?.kind).toBe("network")
})

test("returns error with kind=http on non-auth HTTP error (e.g. 500)", async () => {
const orig = globalThis.fetch
stubFetch(async () => new Response("Server Error", { status: 500, statusText: "Internal Server Error" }))

const result = await fetchKiloModels({})

;(globalThis as any).fetch = orig

expect(result.models).toEqual({})
expect(result.error?.kind).toBe("http")
expect(result.error?.status).toBe(500)
})

test("returns models without error on success", async () => {
const orig = globalThis.fetch
stubFetch(async () =>
new Response(VALID_RESPONSE, {
status: 200,
headers: { "content-type": "application/json" },
}),
)

const result = await fetchKiloModels({})

;(globalThis as any).fetch = orig

expect(result.error).toBeUndefined()
expect(Object.keys(result.models).length).toBeGreaterThan(0)
})

test("returns error with kind=schema when response body is invalid JSON", async () => {
const orig = globalThis.fetch
stubFetch(async () =>
new Response("not valid json{{{{", {
status: 200,
headers: { "content-type": "application/json" },
}),
)

const result = await fetchKiloModels({})

;(globalThis as any).fetch = orig

expect(result.models).toEqual({})
expect(result.error?.kind).toBe("schema")
})
3 changes: 2 additions & 1 deletion packages/kilo-indexing/src/indexing/config-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,8 @@ export class CodeIndexConfigManager {
// LanceDB doesn't need a qdrant URL; qdrant does
const hasStore = isLancedb || !!qdrant

if (provider === "kilo") return !!(this.kiloOptions?.apiKey && this.modelId && this.currentModelDimension && hasStore)
if (provider === "kilo")
return !!(this.kiloOptions?.apiKey && this.modelId && this.currentModelDimension && hasStore)
if (provider === "openai") return !!(this.openAiOptions?.apiKey && hasStore)
if (provider === "ollama") return !!(this.ollamaOptions?.baseUrl && hasStore)
if (provider === "openai-compatible")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ const WithSessions: ParentComponent<{ sessions?: typeof mockSessions }> = (props
session_diff: {},
message: {},
part: {},
provider: { all: [], connected: [] as string[], default: {} as any },
provider: { all: [], connected: [] as string[], default: {} as any, failed: [] as string[] },
}}
directory="/project/"
>
Expand Down
4 changes: 4 additions & 0 deletions packages/opencode/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,7 @@ Hono-based HTTP server with OpenAPI spec generation. SSE for real-time events. W
## Providers and Models

Uses the **Vercel AI SDK** as the abstraction layer. Providers are loaded from a bundled map or dynamically installed at runtime. Models come from models.dev (external API), cached locally.

## Fork Isolation Rule

`opencode/` is a fork of upstream opencode. When a change must touch a shared upstream file, extract the Kilo-specific logic into a mirror file under `src/kilocode/<same/path>.ts` (tests under `test/kilocode/<same/path>.test.ts`) and call into it from the upstream file behind a single `kilocode_change` marker. Example: a Kilo override for `src/cli/cmd/tui/component/dialog-provider.tsx` lives at `src/kilocode/cli/cmd/tui/component/dialog-provider.tsx`. Avoid inlining Kilo-specific logic directly into shared upstream files. Files and directories whose path contains `kilocode` never need `kilocode_change` markers.
1 change: 0 additions & 1 deletion packages/opencode/src/cli/cmd/models.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@

import { EOL } from "os"
import { Effect } from "effect"
import { Provider } from "@/provider/provider"
Expand Down
10 changes: 8 additions & 2 deletions packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,20 @@ export function createDialogProviderOptions() {
map((provider) => {
const consoleManaged = isConsoleManagedProvider(sync.data.console_state.consoleManagedProviders, provider.id)
const connected = sync.data.provider_next.connected.includes(provider.id)
// kilocode_change start
const failed = sync.data.provider_next.failed ?? []
const failedGutter = KiloProvider.renderGutter(provider.id, failed, theme)
const failedDesc = KiloProvider.failedDescription(provider.id, failed)
const baseDesc = KiloProvider.PROVIDER_DESCRIPTIONS[provider.id]
// kilocode_change end

return {
title: KiloProvider.PROVIDER_TITLES[provider.id] ?? provider.name, // kilocode_change
value: provider.id,
description: KiloProvider.PROVIDER_DESCRIPTIONS[provider.id], // kilocode_change
description: failedDesc ?? baseDesc, // kilocode_change
footer: consoleManaged ? sync.data.console_state.activeOrgName : undefined,
category: provider.id in PROVIDER_PRIORITY ? "Popular" : "Other",
gutter: connected && onboarded() ? () => <text fg={theme.success}>✓</text> : undefined,
gutter: failedGutter ?? (connected && onboarded() ? () => <text fg={theme.success}>✓</text> : undefined), // kilocode_change
async onSelect() {
if (consoleManaged) return

Expand Down
1 change: 1 addition & 0 deletions packages/opencode/src/cli/cmd/tui/context/sync.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
all: [],
default: {},
connected: [],
failed: [], // kilocode_change
},
console_state: emptyConsoleState,
provider_auth: {},
Expand Down
8 changes: 1 addition & 7 deletions packages/opencode/src/kilocode/agent/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -432,13 +432,7 @@ export function patchAgents(
description: "Get answers and explanations without making changes to the codebase.",
prompt: PROMPT_ASK,
options: {},
permission: Permission.merge(
defaults,
askGuard(kilo.mcpRules),
user,
askEditGuard(),
denies(user),
),
permission: Permission.merge(defaults, askGuard(kilo.mcpRules), user, askEditGuard(), denies(user)),
mode: "primary",
native: true,
}
Expand Down
Loading
Loading