diff --git a/.changeset/azure-provider-settings.md b/.changeset/azure-provider-settings.md new file mode 100644 index 00000000000..7bd22e1e156 --- /dev/null +++ b/.changeset/azure-provider-settings.md @@ -0,0 +1,6 @@ +--- +"@kilocode/cli": patch +"kilo-code": patch +--- + +Support configuring Azure OpenAI resource names or endpoint URLs from the provider settings flow, and document using the native Azure provider for GPT-5 family deployments. diff --git a/packages/kilo-docs/pages/ai-providers/openai-compatible.md b/packages/kilo-docs/pages/ai-providers/openai-compatible.md index 89551016dcd..02af740576e 100644 --- a/packages/kilo-docs/pages/ai-providers/openai-compatible.md +++ b/packages/kilo-docs/pages/ai-providers/openai-compatible.md @@ -1,6 +1,6 @@ --- title: "Using OpenAI-Compatible Providers with Kilo Code" -description: "Connect any OpenAI-compatible API endpoint to Kilo Code, including Azure OpenAI, Together AI, and self-hosted servers." +description: "Connect OpenAI-compatible API endpoints like Together AI, Perplexity, and self-hosted servers." sidebar_label: OpenAI Compatible --- @@ -14,6 +14,12 @@ Kilo Code supports a wide range of AI model providers that offer APIs compatible This document focuses on setting up providers _other than_ the official OpenAI API (which has its own [dedicated configuration page](/docs/ai-providers/openai)). +{% callout type="warning" %} +Do not use a custom OpenAI-compatible provider for Azure OpenAI GPT-5 deployments. Azure GPT-5 rejects the `max_tokens` parameter used by generic OpenAI-compatible providers and requires Azure-specific handling. + +Use Kilo Code's native `azure` provider instead. If your Azure deployment name differs from the model name you select in Kilo, map it with the model `id` field in `kilo.json`. +{% /callout %} + ## General Configuration {% tabs %} @@ -21,7 +27,7 @@ This document focuses on setting up providers _other than_ the official OpenAI A The key to using an OpenAI-compatible provider is to configure two main settings: -1. **Base URL:** This is the API endpoint for the provider. It will _not_ be `https://api.openai.com/v1` (that's for the official OpenAI API). +1. **Base URL:** This is the API endpoint for the provider. It will _not_ be `https://api.openai.com/v1` (that's for the official OpenAI API). For Azure OpenAI GPT-5, do not enter your Azure endpoint here. Configure the native `azure` provider instead. 2. **API Key:** This is the secret key you obtain from the provider. 3. **Model ID:** This is the model name of the specific model. @@ -53,7 +59,7 @@ You'll find these settings in the Kilo Code settings panel (click the {% codicon - **Provider ID** — A unique identifier (e.g., `my-provider`). - **Display name** — A human-readable name shown in the UI. -- **Base URL** — The provider's OpenAI-compatible API endpoint (e.g., `https://api.your-provider.com/v1`). Kilo auto-fetches available models when a valid URL is entered. +- **Base URL** — The provider's OpenAI-compatible API endpoint (e.g., `https://api.your-provider.com/v1`). Kilo auto-fetches available models when a valid URL is entered. For Azure OpenAI GPT-5, use the native `azure` provider instead. - **API key** — Your API key. Optional — leave empty if authentication is handled via headers. - **Models** — Add models manually or select from the auto-fetched list (see [Automatic Model Detection](#automatic-model-detection) below). - **Headers** (optional) — Custom HTTP headers as key-value pairs. @@ -114,7 +120,7 @@ Then set your default model using the `provider-id/model-id` format: **Configuration fields:** - **`models`** — A map of model IDs to model definitions. Each model should include a `name` and `limit` with `context` and `output` token counts. If `limit.context` or `limit.output` is omitted, it defaults to `0`, which limits context management. -- **`options.baseURL`** — The base URL of your OpenAI-compatible API endpoint. +- **`options.baseURL`** — The base URL of your OpenAI-compatible API endpoint. For Azure OpenAI GPT-5, configure `provider.azure` instead. - **`options.apiKey`** — Your API key. Use any non-empty string (e.g., `"none"`) if the provider doesn't require authentication. You can also set the API key via an environment variable instead of putting it in the config file. Use the `env` field to specify which variable to read: @@ -172,6 +178,7 @@ This enhancement allows you to: - **"Invalid API Key":** Double-check that you've entered the API key correctly. - **"Model Not Found":** Make sure you're using a valid model ID for your chosen provider. - **Connection Errors:** Verify the Base URL is correct and that your provider's API is accessible. +- **Azure GPT-5 rejects `max_tokens`:** Azure GPT-5 deployments must use Kilo Code's native `azure` provider. Generic OpenAI-compatible custom providers send `max_tokens`, which Azure GPT-5 rejects because it expects `max_completion_tokens`. - **Unexpected Results:** If you're getting unexpected results, try a different model. By using an OpenAI-compatible provider, you can leverage the flexibility of Kilo Code with a wider range of AI models. Remember to always consult your provider's documentation for the most accurate and up-to-date information. diff --git a/packages/kilo-docs/pages/ai-providers/openai.md b/packages/kilo-docs/pages/ai-providers/openai.md index f100680ccfa..3025b09a4ba 100644 --- a/packages/kilo-docs/pages/ai-providers/openai.md +++ b/packages/kilo-docs/pages/ai-providers/openai.md @@ -71,4 +71,4 @@ Then set your default model: ## Tips and Notes - **Pricing:** Refer to the [OpenAI Pricing](https://openai.com/pricing) page for details on model costs. -- **Azure OpenAI Service:** If you'd like to use the Azure OpenAI service, please see our section on [OpenAI-compatible](/docs/ai-providers/openai-compatible) providers. +- **Azure OpenAI Service:** Use Kilo Code's native `azure` provider for Azure OpenAI, especially GPT-5 deployments. Do not configure Azure GPT-5 through a generic [OpenAI-compatible](/docs/ai-providers/openai-compatible) custom provider. diff --git a/packages/kilo-docs/pages/code-with-ai/agents/custom-models.md b/packages/kilo-docs/pages/code-with-ai/agents/custom-models.md index 5d84f04f8d2..a8b035662c7 100644 --- a/packages/kilo-docs/pages/code-with-ai/agents/custom-models.md +++ b/packages/kilo-docs/pages/code-with-ai/agents/custom-models.md @@ -309,6 +309,38 @@ If the model key in your config differs from what the provider expects, use the Here `my-local-llama` is the key you use in your config and model picker, while `meta-llama-3.1-8b-instruct` is the actual model identifier sent to the LM Studio API. +For Azure OpenAI, use the native `azure` provider and set `id` to your Azure deployment name when it differs from the model key. Do not configure Azure GPT-5 family deployments under `openai-compatible`, because that provider sends `max_tokens` and Azure GPT-5 expects `max_completion_tokens`. + +```jsonc +{ + "$schema": "https://app.kilo.ai/config.json", + "model": "azure/gpt-5.5", + "provider": { + "azure": { + "options": { + "apiKey": "{env:AZURE_API_KEY}", + "resourceName": "my-azure-resource", + }, + "models": { + "gpt-5.5": { + "id": "my-gpt-5-5-deployment", + "name": "GPT-5.5 on Azure", + "reasoning": true, + "tool_call": true, + "temperature": false, + "limit": { + "context": 400000, + "output": 128000, + }, + }, + }, + }, + }, +} +``` + +Here `azure/gpt-5.5` is the model you select in Kilo Code, while `my-gpt-5-5-deployment` is the Azure deployment name sent to Azure. If you prefer to configure the full Azure endpoint instead of a resource name, replace `resourceName` with `baseURL`, for example `"baseURL": "https://my-resource.openai.azure.com/openai"`. If both are configured, Kilo Code uses `baseURL` and ignores `resourceName` to avoid sending conflicting Azure SDK options. + ## Model Loading Priority When Kilo starts, it resolves the active model in this order: diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index d64da66be40..b56184a0474 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -1733,7 +1733,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper const keyChanged = msg.apiKeyChanged === true const code = typeof msg.code === "string" ? msg.code : undefined const config = msg.config && typeof msg.config === "object" ? (msg.config as Record) : undefined - if (msg.type === "connectProvider" && key) return connectProviderAction(ctx, rid, pid, key) + const metadata = + msg.metadata && typeof msg.metadata === "object" ? (msg.metadata as Record) : undefined + if (msg.type === "connectProvider" && key) return connectProviderAction(ctx, rid, pid, key, metadata) if (msg.type === "authorizeProviderOAuth") return authorizeOAuthAction(ctx, rid, pid, method) if (msg.type === "completeProviderOAuth") return completeOAuthAction(ctx, rid, pid, method, code) if (msg.type === "disconnectProvider") return disconnectProviderAction(ctx, rid, pid, this.cachedConfigMessage, set) diff --git a/packages/kilo-vscode/src/provider-actions.ts b/packages/kilo-vscode/src/provider-actions.ts index f95e3ba8609..cae5ba1e0c8 100644 --- a/packages/kilo-vscode/src/provider-actions.ts +++ b/packages/kilo-vscode/src/provider-actions.ts @@ -146,6 +146,7 @@ export function computeDefaultSelection( type PostMessage = (message: unknown) => void type GetErrorMessage = (error: unknown) => string type SetCachedConfig = (msg: unknown) => void +type AuthMetadata = Record interface ActionContext { client: KiloClient @@ -178,6 +179,14 @@ function validateID( return null } +function cleanMetadata(input?: Record): AuthMetadata | undefined { + const entries = Object.entries(input ?? {}) + .map(([key, value]) => [key, typeof value === "string" ? value.trim() : ""] as const) + .filter(([key, value]) => key !== "" && value !== "") + if (entries.length === 0) return undefined + return Object.fromEntries(entries) +} + async function configs(ctx: ActionContext) { const [{ data: global }, { data: merged }] = await Promise.all([ ctx.client.global.config.get({ throwOnError: true }), @@ -244,11 +253,19 @@ async function enableConfigured(ctx: ActionContext, id: string, config: Config) await saveGlobal(ctx, { disabled_providers: disabled }) } -export async function connectProvider(ctx: ActionContext, requestId: string, providerID: string, apiKey: string) { +export async function connectProvider( + ctx: ActionContext, + requestId: string, + providerID: string, + apiKey: string, + metadata?: Record, +) { const id = validateID(ctx, requestId, providerID, "connect") if (!id) return try { - await ctx.client.auth.set({ providerID: id, auth: { type: "api", key: apiKey } }, { throwOnError: true }) + const meta = cleanMetadata(metadata) + const auth = meta ? { type: "api" as const, key: apiKey, metadata: meta } : { type: "api" as const, key: apiKey } + await ctx.client.auth.set({ providerID: id, auth }, { throwOnError: true }) await ctx.disposeGlobal(`provider connect (${id})`) await ctx.fetchAndSendProviders() ctx.postMessage({ type: "providerConnected", requestId, providerID: id }) diff --git a/packages/kilo-vscode/tests/unit/provider-actions-save.test.ts b/packages/kilo-vscode/tests/unit/provider-actions-save.test.ts index dc1d97f27ca..375a44efdcf 100644 --- a/packages/kilo-vscode/tests/unit/provider-actions-save.test.ts +++ b/packages/kilo-vscode/tests/unit/provider-actions-save.test.ts @@ -1,11 +1,11 @@ import { describe, expect, it } from "bun:test" -import { disconnectProvider, fetchProviderData, saveCustomProvider } from "../../src/provider-actions" +import { connectProvider, disconnectProvider, fetchProviderData, saveCustomProvider } from "../../src/provider-actions" type ExistingGlobal = { disabled_providers?: string[]; provider?: Record } function createCtx(existing: ExistingGlobal = { disabled_providers: [] }, merged: ExistingGlobal = existing) { const calls = { - set: [] as Array<{ providerID: string; auth: { type: string; key: string } }>, + set: [] as Array<{ providerID: string; auth: { type: string; key: string; metadata?: Record } }>, remove: [] as Array<{ providerID: string }>, posts: [] as unknown[], config: [] as Array<{ config: Record }>, @@ -18,7 +18,10 @@ function createCtx(existing: ExistingGlobal = { disabled_providers: [] }, merged const ctx = { client: { auth: { - set: async (input: { providerID: string; auth: { type: string; key: string } }) => { + set: async (input: { + providerID: string + auth: { type: string; key: string; metadata?: Record } + }) => { calls.set.push(input) return { data: true } }, @@ -117,6 +120,54 @@ describe("disconnectProvider", () => { }) }) +describe("connectProvider", () => { + it("stores api auth metadata from provider prompts", async () => { + const { ctx, calls } = createCtx() + + await connectProvider(ctx, "req", "azure", "sk-test", { + resourceName: " my-resource ", + empty: " ", + }) + + expect(calls.set).toEqual([ + { + providerID: "azure", + auth: { + type: "api", + key: "sk-test", + metadata: { resourceName: "my-resource" }, + }, + }, + ]) + expect(calls.refresh).toBe(1) + expect(calls.posts).toContainEqual({ type: "providerConnected", requestId: "req", providerID: "azure" }) + }) + + it("stores azure endpoint URL metadata from provider prompts", async () => { + const { ctx, calls } = createCtx() + + await connectProvider(ctx, "req", "azure", "sk-test", { + endpointType: "baseURL", + baseURL: " https://my-resource.openai.azure.com/openai ", + resourceName: " ", + }) + + expect(calls.set).toEqual([ + { + providerID: "azure", + auth: { + type: "api", + key: "sk-test", + metadata: { + endpointType: "baseURL", + baseURL: "https://my-resource.openai.azure.com/openai", + }, + }, + }, + ]) + }) +}) + describe("saveCustomProvider", () => { it("preserves auth when the api key field is unchanged", async () => { const { ctx, calls, setCachedConfig } = createCtx() diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/ProviderConnectDialog.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/ProviderConnectDialog.tsx index e0f4ace7e14..6dbfd0174ef 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/ProviderConnectDialog.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/ProviderConnectDialog.tsx @@ -1,6 +1,7 @@ import { Button } from "@kilocode/kilo-ui/button" import { useDialog } from "@kilocode/kilo-ui/context/dialog" import { Dialog } from "@kilocode/kilo-ui/dialog" +import { Select } from "@kilocode/kilo-ui/select" import { Spinner } from "@kilocode/kilo-ui/spinner" import { TextField } from "@kilocode/kilo-ui/text-field" import { showToast } from "@kilocode/kilo-ui/toast" @@ -22,9 +23,12 @@ interface ViewState { authorization?: ProviderAuthAuthorization phase?: "authorizing" | "connecting" error?: string + field?: string failed?: string } +type Prompt = NonNullable[number] + function fallbackMethods(label: string): ProviderAuthMethod[] { return [{ type: "api", label }] } @@ -38,6 +42,14 @@ function formatError(value: unknown, fallback: string): string { return fallback } +function visible(prompt: Prompt, values: Record) { + const rule = prompt.when + if (!rule) return true + const value = values[rule.key] ?? "" + if (rule.op === "eq") return value === rule.value + return value !== rule.value +} + const ProviderConnectDialog: Component = (props) => { const dialog = useDialog() const language = useLanguage() @@ -60,6 +72,56 @@ const ProviderConnectDialog: Component = (props) => return index === undefined ? undefined : methods()[index] }) + function promptLabel(prompt: Prompt) { + if (props.providerID === "azure" && prompt.key === "endpointType") { + return language.t("provider.connect.azure.endpointType.label") + } + if (props.providerID === "azure" && prompt.key === "resourceName") { + return language.t("provider.connect.azure.resourceName.label") + } + if (props.providerID === "azure" && prompt.key === "baseURL") { + return language.t("provider.connect.azure.baseURL.label") + } + return prompt.message + } + + function promptPlaceholder(prompt: Prompt) { + if (props.providerID === "azure" && prompt.key === "resourceName") { + return language.t("provider.connect.azure.resourceName.placeholder") + } + if (props.providerID === "azure" && prompt.key === "baseURL") { + return language.t("provider.connect.azure.baseURL.placeholder") + } + if (prompt.type === "text") return prompt.placeholder + return undefined + } + + function optionLabel(prompt: Prompt, option: { label: string; value: string; hint?: string }) { + if (props.providerID === "azure" && prompt.key === "endpointType" && option.value === "resourceName") { + return language.t("provider.connect.azure.endpointType.resourceName.label") + } + if (props.providerID === "azure" && prompt.key === "endpointType" && option.value === "baseURL") { + return language.t("provider.connect.azure.endpointType.baseURL.label") + } + return option.label + } + + function optionHint(prompt: Prompt, option: { label: string; value: string; hint?: string }) { + if (props.providerID === "azure" && prompt.key === "endpointType" && option.value === "resourceName") { + return language.t("provider.connect.azure.endpointType.resourceName.hint") + } + if (props.providerID === "azure" && prompt.key === "endpointType" && option.value === "baseURL") { + return language.t("provider.connect.azure.endpointType.baseURL.hint") + } + return option.hint + } + + function optionText(prompt: Prompt, option: { label: string; value: string; hint?: string }) { + const label = optionLabel(prompt, option) + const hint = optionHint(prompt, option) + return hint ? `${label} (${hint})` : label + } + onCleanup(action.dispose) onMount(() => { @@ -78,16 +140,26 @@ const ProviderConnectDialog: Component = (props) => authorization: undefined, phase: undefined, error: undefined, + field: undefined, failed: undefined, }) } + function back() { + if (methods().length === 1) { + dialog.close() + return + } + reset() + } + function fail(message: string) { const failed = state.authorization?.method === "auto" || state.phase === "authorizing" setState({ ...state, phase: undefined, error: failed ? undefined : message, + field: undefined, failed: failed ? message : undefined, }) } @@ -110,6 +182,7 @@ const ProviderConnectDialog: Component = (props) => authorization: undefined, phase: current?.type === "oauth" ? "authorizing" : undefined, error: undefined, + field: undefined, failed: undefined, }) if (current?.type !== "oauth") return @@ -135,11 +208,12 @@ const ProviderConnectDialog: Component = (props) => ) } - function connect(apiKey: string) { + function connect(apiKey: string, metadata?: Record) { setState({ ...state, phase: "connecting", error: undefined, + field: undefined, failed: undefined, }) action.send( @@ -147,6 +221,7 @@ const ProviderConnectDialog: Component = (props) => type: "connectProvider", providerID: props.providerID, apiKey, + metadata, }, { onConnected: succeed, @@ -163,6 +238,7 @@ const ProviderConnectDialog: Component = (props) => ...state, phase: "connecting", error: undefined, + field: undefined, failed: undefined, }) action.send( @@ -205,15 +281,30 @@ const ProviderConnectDialog: Component = (props) => const ApiView: Component = () => { const [value, setValue] = createSignal("") + const [fields, setFields] = createStore>({}) + const prompts = createMemo(() => method()?.prompts?.filter((prompt) => visible(prompt, fields)) ?? []) function submit(e: SubmitEvent) { e.preventDefault() const apiKey = value().trim() if (!apiKey) { - setState({ ...state, error: language.t("provider.connect.apiKey.required") }) + setState({ ...state, error: language.t("provider.connect.apiKey.required"), field: "apiKey" }) return } - connect(apiKey) + const metadata: Record = {} + for (const prompt of prompts()) { + const field = (fields[prompt.key] ?? "").trim() + if (!field) { + setState({ + ...state, + error: language.t("provider.connect.prompt.required", { field: promptLabel(prompt) }), + field: prompt.key, + }) + return + } + metadata[prompt.key] = field + } + connect(apiKey, Object.keys(metadata).length > 0 ? metadata : undefined) } return ( @@ -232,11 +323,65 @@ const ProviderConnectDialog: Component = (props) => placeholder={language.t("provider.connect.apiKey.placeholder")} value={value()} onChange={setValue} - validationState={state.error ? "invalid" : undefined} - error={state.error} + validationState={state.field === "apiKey" ? "invalid" : undefined} + error={state.field === "apiKey" ? state.error : undefined} /> + + {(prompt) => ( + + + setFields(prompt.key, next)} + validationState={state.field === prompt.key ? "invalid" : undefined} + error={state.field === prompt.key ? state.error : undefined} + /> + + +
+ +