feat: Add Kilo Gateway provider (#20212)

* feat: Add Kilo Gateway provider

Add support for Kilo Gateway as a model provider, similar to OpenRouter.
Kilo Gateway provides a unified API that routes requests to many models
behind a single endpoint and API key.

Changes:
- Add kilocode provider option to auth-choice and onboarding flows
- Add KILOCODE_API_KEY environment variable support
- Add kilocode/ model prefix handling in model-auth and extra-params
- Add provider documentation in docs/providers/kilocode.md
- Update model-providers.md with Kilo Gateway section
- Add design doc for the integration

* kilocode: add provider tests and normalize onboard auth-choice registration

* kilocode: register in resolveImplicitProviders so models appear in provider filter

* kilocode: update base URL from /api/openrouter/ to /api/gateway/

* docs: fix formatting in kilocode docs

* fix: address PR review — remove kilocode from cacheRetention, fix stale model refs and CLI name in docs, fix TS2742

* docs: fix stale refs in design doc — Moltbot to OpenClaw, MoltbotConfig to OpenClawConfig, remove extra-params section, fix doc path

* fix: use resolveAgentModelPrimaryValue for AgentModelConfig union type

---------

Co-authored-by: Mark IJbema <mark@kilocode.ai>
This commit is contained in:
John Fawcett
2026-02-23 17:29:27 -06:00
committed by GitHub
parent ddb7ec99a8
commit 13f32e2f7d
23 changed files with 1020 additions and 1 deletions

View File

@@ -322,6 +322,7 @@ export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null {
qianfan: "QIANFAN_API_KEY",
ollama: "OLLAMA_API_KEY",
vllm: "VLLM_API_KEY",
kilocode: "KILOCODE_API_KEY",
};
const envVar = envMap[normalized];
if (!envVar) {

View File

@@ -0,0 +1,49 @@
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { captureEnv } from "../test-utils/env.js";
import { buildKilocodeProvider, resolveImplicitProviders } from "./models-config.providers.js";
describe("Kilo Gateway implicit provider", () => {
it("should include kilocode when KILOCODE_API_KEY is configured", async () => {
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
const envSnapshot = captureEnv(["KILOCODE_API_KEY"]);
process.env.KILOCODE_API_KEY = "test-key";
try {
const providers = await resolveImplicitProviders({ agentDir });
expect(providers?.kilocode).toBeDefined();
expect(providers?.kilocode?.models?.length).toBeGreaterThan(0);
} finally {
envSnapshot.restore();
}
});
it("should not include kilocode when no API key is configured", async () => {
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
const envSnapshot = captureEnv(["KILOCODE_API_KEY"]);
delete process.env.KILOCODE_API_KEY;
try {
const providers = await resolveImplicitProviders({ agentDir });
expect(providers?.kilocode).toBeUndefined();
} finally {
envSnapshot.restore();
}
});
it("should build kilocode provider with correct configuration", () => {
const provider = buildKilocodeProvider();
expect(provider.baseUrl).toBe("https://api.kilo.ai/api/gateway/");
expect(provider.api).toBe("openai-completions");
expect(provider.models).toBeDefined();
expect(provider.models.length).toBeGreaterThan(0);
});
it("should include the default kilocode model", () => {
const provider = buildKilocodeProvider();
const modelIds = provider.models.map((m) => m.id);
expect(modelIds).toContain("anthropic/claude-opus-4.6");
});
});

View File

@@ -764,6 +764,36 @@ export function buildNvidiaProvider(): ProviderConfig {
};
}
// Kilo Gateway provider
const KILOCODE_BASE_URL = "https://api.kilo.ai/api/gateway/";
const KILOCODE_DEFAULT_MODEL_ID = "anthropic/claude-opus-4.6";
const KILOCODE_DEFAULT_CONTEXT_WINDOW = 200000;
const KILOCODE_DEFAULT_MAX_TOKENS = 8192;
const KILOCODE_DEFAULT_COST = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
};
export function buildKilocodeProvider(): ProviderConfig {
return {
baseUrl: KILOCODE_BASE_URL,
api: "openai-completions",
models: [
{
id: KILOCODE_DEFAULT_MODEL_ID,
name: "Claude Opus 4.6",
reasoning: true,
input: ["text", "image"],
cost: KILOCODE_DEFAULT_COST,
contextWindow: KILOCODE_DEFAULT_CONTEXT_WINDOW,
maxTokens: KILOCODE_DEFAULT_MAX_TOKENS,
},
],
};
}
export async function resolveImplicitProviders(params: {
agentDir: string;
explicitProviders?: Record<string, ProviderConfig> | null;
@@ -951,6 +981,13 @@ export async function resolveImplicitProviders(params: {
providers.nvidia = { ...buildNvidiaProvider(), apiKey: nvidiaKey };
}
const kilocodeKey =
resolveEnvApiKeyVarName("kilocode") ??
resolveApiKeyFromProfiles({ provider: "kilocode", store: authStore });
if (kilocodeKey) {
providers.kilocode = { ...buildKilocodeProvider(), apiKey: kilocodeKey };
}
return providers;
}

View File

@@ -29,6 +29,9 @@ export function isCacheTtlEligibleProvider(provider: string, modelId: string): b
if (normalizedProvider === "openrouter" && isOpenRouterCacheTtlModel(normalizedModelId)) {
return true;
}
if (normalizedProvider === "kilocode" && normalizedModelId.startsWith("anthropic/")) {
return true;
}
return false;
}

View File

@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { isCacheTtlEligibleProvider } from "./cache-ttl.js";
describe("kilocode cache-ttl eligibility", () => {
it("is eligible when model starts with anthropic/", () => {
expect(isCacheTtlEligibleProvider("kilocode", "anthropic/claude-opus-4.6")).toBe(true);
});
it("is eligible with other anthropic models", () => {
expect(isCacheTtlEligibleProvider("kilocode", "anthropic/claude-sonnet-4")).toBe(true);
});
it("is not eligible for non-anthropic models on kilocode", () => {
expect(isCacheTtlEligibleProvider("kilocode", "openai/gpt-5")).toBe(false);
});
it("is case-insensitive for provider name", () => {
expect(isCacheTtlEligibleProvider("Kilocode", "anthropic/claude-opus-4.6")).toBe(true);
expect(isCacheTtlEligibleProvider("KILOCODE", "Anthropic/claude-opus-4.6")).toBe(true);
});
});

View File

@@ -91,7 +91,7 @@ export function resolveTranscriptPolicy(params: {
!OPENAI_COMPAT_TURN_MERGE_EXCLUDED_PROVIDERS.has(provider);
const isMistral = isMistralModel({ provider, modelId });
const isOpenRouterGemini =
(provider === "openrouter" || provider === "opencode") &&
(provider === "openrouter" || provider === "opencode" || provider === "kilocode") &&
modelId.toLowerCase().includes("gemini");
const isCopilotClaude = provider === "github-copilot" && modelId.toLowerCase().includes("claude");