diff --git a/.changeset/deny-gateway-data-collection.md b/.changeset/deny-gateway-data-collection.md new file mode 100644 index 000000000000..48286252fa00 --- /dev/null +++ b/.changeset/deny-gateway-data-collection.md @@ -0,0 +1,6 @@ +--- +"@kilocode/cli": patch +"@kilocode/kilo-gateway": patch +--- + +Deny provider data collection for Kilo Gateway requests when prompt-training models are hidden. diff --git a/packages/kilo-gateway/src/provider.ts b/packages/kilo-gateway/src/provider.ts index b0a54bded8e0..1f52b09c2d69 100644 --- a/packages/kilo-gateway/src/provider.ts +++ b/packages/kilo-gateway/src/provider.ts @@ -9,7 +9,7 @@ import { getApiKey } from "./auth/token.js" import { buildKiloHeaders, getDefaultHeaders } from "./headers.js" import { ANONYMOUS_API_KEY } from "./api/constants.js" import { resolveKiloOpenRouterBaseUrl } from "./api/url.js" -import { sanitizeResponsesBody } from "./responses.js" +import { transformRequestBody } from "./responses.js" export function buildRequestHeaders(defaultHeaders: Record, requestHeaders?: HeadersInit): Headers { const headers = new Headers(defaultHeaders) @@ -55,7 +55,7 @@ export function createKilo(options: KiloProviderOptions = {}): KiloProvider { const originalFetch = options.fetch ?? fetch const wrappedFetch = async (input: string | URL | Request, init?: RequestInit) => { const headers = buildRequestHeaders(customHeaders, init?.headers) - const body = sanitizeResponsesBody(input, init?.body) + const body = transformRequestBody(input, init?.body, options.dataCollection) // Add authorization if API key exists if (apiKey) { diff --git a/packages/kilo-gateway/src/responses.ts b/packages/kilo-gateway/src/responses.ts index 3697c5cf775e..5ba68266edc4 100644 --- a/packages/kilo-gateway/src/responses.ts +++ b/packages/kilo-gateway/src/responses.ts @@ -28,8 +28,13 @@ function strip(input: unknown[]) { return { kept, changed } } -export function sanitizeResponsesBody(input: string | URL | Request, body: BodyInit | null | undefined) { - if (!endpoint(input)) return body +export function transformRequestBody( + input: string | URL | Request, + body: BodyInit | null | undefined, + value?: "allow" | "deny", +) { + const responses = endpoint(input) + if (!responses && !value) return body if (typeof body !== "string") return body const data = (() => { @@ -40,10 +45,14 @@ export function sanitizeResponsesBody(input: string | URL | Request, body: BodyI } })() if (!record(data)) return body - if (data.store === true) return body - if (!Array.isArray(data.input)) return body - const result = strip(data.input) - if (!result.changed) return body - return JSON.stringify({ ...data, input: result.kept }) + const result = responses && data.store !== true && Array.isArray(data.input) ? strip(data.input) : undefined + if (!result?.changed && !value) return body + + const provider = record(data.provider) ? data.provider : {} + return JSON.stringify({ + ...data, + ...(result?.changed ? { input: result.kept } : {}), + ...(value ? { provider: { ...provider, data_collection: value } } : {}), + }) } diff --git a/packages/kilo-gateway/src/types.ts b/packages/kilo-gateway/src/types.ts index e9e8080f02c8..ab214d3274c5 100644 --- a/packages/kilo-gateway/src/types.ts +++ b/packages/kilo-gateway/src/types.ts @@ -95,6 +95,11 @@ export interface KiloProviderOptions { */ name?: string + /** + * Data collection preference for upstream provider routing + */ + dataCollection?: "allow" | "deny" + /** * Custom fetch function */ diff --git a/packages/kilo-gateway/test/responses.test.ts b/packages/kilo-gateway/test/responses.test.ts index 49520300f506..9d73d65c7816 100644 --- a/packages/kilo-gateway/test/responses.test.ts +++ b/packages/kilo-gateway/test/responses.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { sanitizeResponsesBody } from "../src/responses" +import { transformRequestBody } from "../src/responses" describe("Responses request sanitization", () => { test("strips item ids when storage is disabled", () => { @@ -30,7 +30,7 @@ describe("Responses request sanitization", () => { ], }) - const result = sanitizeResponsesBody("https://api.kilo.ai/api/openrouter/responses", body) + const result = transformRequestBody("https://api.kilo.ai/api/openrouter/responses", body) const data = JSON.parse(result as string) expect(data.input).toHaveLength(3) @@ -60,19 +60,19 @@ describe("Responses request sanitization", () => { ], }) - expect(sanitizeResponsesBody("https://api.kilo.ai/api/openrouter/responses", body)).toBe(body) + expect(transformRequestBody("https://api.kilo.ai/api/openrouter/responses", body)).toBe(body) }) test("leaves non-responses requests unchanged", () => { const body = "not json" - expect(sanitizeResponsesBody("https://api.kilo.ai/api/openrouter/chat/completions", body)).toBe(body) + expect(transformRequestBody("https://api.kilo.ai/api/openrouter/chat/completions", body)).toBe(body) }) test("leaves invalid responses JSON unchanged", () => { const body = "not json" - expect(sanitizeResponsesBody("https://api.kilo.ai/api/openrouter/responses", body)).toBe(body) + expect(transformRequestBody("https://api.kilo.ai/api/openrouter/responses", body)).toBe(body) }) test("matches relative responses paths without a placeholder host", () => { @@ -86,9 +86,32 @@ describe("Responses request sanitization", () => { }, ], }) - const result = sanitizeResponsesBody("/api/openrouter/responses?stream=true", body) + const result = transformRequestBody("/api/openrouter/responses?stream=true", body) const data = JSON.parse(result as string) expect(data.input[0].id).toBeUndefined() }) + + test("sanitizes responses and denies data collection in one transform", () => { + const body = JSON.stringify({ + input: [{ type: "message", role: "assistant", id: "msg_tmp_123" }], + provider: { order: ["anthropic"] }, + }) + const result = transformRequestBody("https://api.kilo.ai/api/openrouter/responses", body, "deny") + + expect(JSON.parse(result as string)).toEqual({ + input: [{ type: "message", role: "assistant" }], + provider: { order: ["anthropic"], data_collection: "deny" }, + }) + }) + + test("denies data collection for non-responses requests", () => { + const body = JSON.stringify({ model: "anthropic/claude-sonnet-4" }) + const result = transformRequestBody("https://api.kilo.ai/api/openrouter/chat/completions", body, "deny") + + expect(JSON.parse(result as string)).toEqual({ + model: "anthropic/claude-sonnet-4", + provider: { data_collection: "deny" }, + }) + }) }) diff --git a/packages/opencode/src/kilocode/provider/provider.ts b/packages/opencode/src/kilocode/provider/provider.ts index cb35d737ac6f..6bd6d0e9bfd4 100644 --- a/packages/opencode/src/kilocode/provider/provider.ts +++ b/packages/opencode/src/kilocode/provider/provider.ts @@ -115,6 +115,11 @@ function useLanguageModel(sdk: any) { return sdk.responses === undefined && sdk.chat === undefined } +export function patchKiloProviderPrivacy(provider: { options?: Record } | undefined, config: any) { + if (!provider || config.hide_prompt_training_models !== true) return + provider.options = { ...provider.options, dataCollection: "deny" } +} + export function kiloCustomLoaders(dep: CustomDep): Record { return { "github-copilot-enterprise": () => @@ -129,10 +134,11 @@ export function kiloCustomLoaders(dep: CustomDep): Record kilo: Effect.fnUntraced(function* (input: any) { const env = yield* dep.env() + const config = yield* dep.config() const hasKey = yield* Effect.gen(function* () { if (input.env.some((item: string) => env[item])) return true if (yield* dep.auth(input.id)) return true - if ((yield* dep.config()).provider?.["kilo"]?.options?.apiKey) return true + if (config.provider?.["kilo"]?.options?.apiKey) return true return false }) @@ -140,6 +146,9 @@ export function kiloCustomLoaders(dep: CustomDep): Record if (env.KILO_ORG_ID) { options.kilocodeOrganizationId = env.KILO_ORG_ID } + if (config.hide_prompt_training_models === true) { + options.dataCollection = "deny" + } if (!hasKey) { options.apiKey = "anonymous" } diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 6d3962cfa57a..38ec0465d596 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -35,6 +35,7 @@ import { patchModelsDevModel as patchKiloModel, patchConfigModel as patchKiloConfigModel, patchCustomLoaderResult, + patchKiloProviderPrivacy, kiloSmallModelPriority, buildTimeoutSignal, REQUEST_TIMEOUT_MS, @@ -1489,6 +1490,7 @@ export const layer = Layer.effect( if (provider.options) partial.options = provider.options mergeProvider(providerID, partial) } + patchKiloProviderPrivacy(providers[ProviderID.make("kilo")], cfg) // kilocode_change const gitlab = ProviderID.make("gitlab") if (discoveryLoaders[gitlab] && providers[gitlab] && isProviderAllowed(gitlab)) { diff --git a/packages/opencode/test/kilocode/kilo-loader-auth.test.ts b/packages/opencode/test/kilocode/kilo-loader-auth.test.ts index 26567407aad1..8060dc44628e 100644 --- a/packages/opencode/test/kilocode/kilo-loader-auth.test.ts +++ b/packages/opencode/test/kilocode/kilo-loader-auth.test.ts @@ -7,7 +7,7 @@ import { ModelsDev } from "../../src/provider/models" import * as CoreModels from "@opencode-ai/core/models" import { Effect, Layer } from "effect" import { FetchHttpClient } from "effect/unstable/http" -import { kiloCustomLoaders } from "../../src/kilocode/provider/provider" +import { kiloCustomLoaders, patchKiloProviderPrivacy } from "../../src/kilocode/provider/provider" import { Auth } from "../../src/auth" import { ModelCache } from "../../src/provider/model-cache" import { Provider } from "../../src/provider/provider" @@ -165,6 +165,21 @@ it.effect("enables a paid catalog when config apiKey is present", () => }), ) +it.effect("denies provider data collection when prompt-training models are hidden", () => + Effect.gen(function* () { + const result = yield* load({ config: { hide_prompt_training_models: true } }) + expect(result.options).toEqual({ apiKey: "anonymous", dataCollection: "deny" }) + }), +) + +it.effect("keeps data collection denied after configured options are applied", () => + Effect.sync(() => { + const provider = { options: { dataCollection: "allow", baseURL: "https://api.kilo.ai" } } + patchKiloProviderPrivacy(provider, { hide_prompt_training_models: true }) + expect(provider.options).toEqual({ dataCollection: "deny", baseURL: "https://api.kilo.ai" }) + }), +) + it.effect("enables a paid catalog when auth exists", () => Effect.gen(function* () { const result = yield* load({ auth: { type: "api", key: "test-key" } })