Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
84 changes: 45 additions & 39 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"; 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,55 @@ 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.ok) {
throw new Error(`Failed to fetch models: ${response.status} ${response.statusText}`)
}
if (response instanceof Error) {
return { models: {}, error: { kind: "network" } }
}

const json = await response.json()
if (!response.ok) {
// 401 with auth credentials: fall back to unauthenticated public endpoint
if (response.status === 401 && (token || organizationId)) {
return fetchKiloModels({})
}
return { models: {}, error: { kind: "unauthorized", status: response.status } }
}

// Validate response schema
const result = openRouterModelsResponseSchema.safeParse(json)
const json = await response.json()
Comment thread
catrielmuller marked this conversation as resolved.
Outdated

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
5 changes: 1 addition & 4 deletions packages/kilo-gateway/src/api/modes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,23 +77,20 @@ 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 []
}

const modes = parsed.data.modes
cache.set(organizationId, { modes, timestamp: Date.now() })
return modes
} catch (err) {
console.warn("[Kilo Gateway] Error fetching organization modes:", err)
} catch {
return []
}
}
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
107 changes: 107 additions & 0 deletions packages/kilo-gateway/test/api/models.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
// 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=unauthorized on non-401 HTTP error without auth", 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("unauthorized")
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)
})
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