diff --git a/.changeset/grok-xhigh-effort-gate.md b/.changeset/grok-xhigh-effort-gate.md new file mode 100644 index 000000000000..ad0ee340dca0 --- /dev/null +++ b/.changeset/grok-xhigh-effort-gate.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Stop offering OpenAI-only `none`/`xhigh` reasoning-effort variants for non-OpenAI models routed through the OpenAI provider npm (e.g. Grok on an OpenAI-compatible base URL), which the upstream provider rejects with a 400. Only gpt-5-family models now receive the rollout-gated tiers. diff --git a/packages/opencode/src/kilocode/provider/provider.ts b/packages/opencode/src/kilocode/provider/provider.ts index e0ae755451ed..5f84502eba53 100644 --- a/packages/opencode/src/kilocode/provider/provider.ts +++ b/packages/opencode/src/kilocode/provider/provider.ts @@ -16,6 +16,7 @@ import { Effect, Schema } from "effect" import type { LanguageModelV3 } from "@ai-sdk/provider" import { mapValues, omit, pickBy } from "remeda" import { reasoningSummary } from "./reasoning-summary" +import { gpt5FamilyReasoningTiers } from "./transform" import type { Provider } from "@/provider/provider" import type { Auth } from "@/auth" import type { Config } from "@/config/config" @@ -102,6 +103,7 @@ export function patchConfigModel(cfg: any, existing: any) { const CUSTOM_PROVIDER_PACKAGES = new Set(["@ai-sdk/openai-compatible", "@ai-sdk/openai", "@ai-sdk/anthropic"]) const FALLBACK_EFFORTS = ["none", "low", "medium", "high", "xhigh", "max"] +const WIDELY_SUPPORTED_FALLBACK_EFFORTS = ["low", "medium", "high"] type Variants = NonNullable type Generate = (model: Provider.Model) => Variants @@ -113,8 +115,15 @@ export function customProviderVariants(model: Provider.Model, npm: unknown, gene if (Object.keys(variants).length > 0) return variants if (!model.capabilities.reasoning || !supported) return variants + // The gpt-5 rollout tiers (`none`, `xhigh`) are only accepted by gpt-5-family + // models. A custom provider id that never reaches openaiReasoningEfforts + // (e.g. qwen/deepseek/minimax/glm/kimi short-circuited to {} in variants()) + // must not regain those tiers through this fallback (issue #13342). + const efforts = + npm === "@ai-sdk/openai" && !gpt5FamilyReasoningTiers(model.api.id) ? WIDELY_SUPPORTED_FALLBACK_EFFORTS : FALLBACK_EFFORTS + return Object.fromEntries( - FALLBACK_EFFORTS.map((effort) => { + efforts.map((effort) => { if (npm === "@ai-sdk/anthropic") { return [effort, effort === "none" ? { thinking: { type: "disabled" } } : { effort }] } diff --git a/packages/opencode/src/kilocode/provider/transform.ts b/packages/opencode/src/kilocode/provider/transform.ts new file mode 100644 index 000000000000..eb449f5414c3 --- /dev/null +++ b/packages/opencode/src/kilocode/provider/transform.ts @@ -0,0 +1,60 @@ +// Kilo-specific reasoning-effort gate for shared ProviderTransform. +// +// OpenAI rolled out the `none` and `xhigh` reasoning_effort tiers on fixed +// dates. Only gpt-5-family models accept them: non-OpenAI models routed +// through @ai-sdk/openai on a compatible base URL (e.g. Grok via api.x.ai) +// reject them, which surfaced as offering an unusable `xhigh` variant +// (issue #13342). + +const GPT5_FAMILY_PREFIX_RE = /(?:^|\/|\.|-)gpt-5(?:[.-]|$)/ + +/** + * True when the model id is a gpt-5 family member that may receive OpenAI's + * rollout-gated reasoning tiers (`none`, `xhigh`). + * + * Matches bare ("gpt-5.5"), slash-prefixed ("openai/gpt-5.6"), dot-prefixed + * ("openai.gpt-5.5", Bedrock Mantle), and hyphen-prefixed deployment ids + * ("azure-openai--gpt-5.4", SAP AI Core) while still excluding lookalikes + * such as "gpt-50" and "gpt-5o". + */ +export function gpt5FamilyReasoningTiers(id: string) { + return GPT5_FAMILY_PREFIX_RE.test(id.toLowerCase()) +} + +type EndpointModel = { + providerID: string + api: { url?: string } + options?: Record +} + +/** + * True when providerID "openai" genuinely serves first-party OpenAI models. + * A baseURL override to a third party (e.g. xAI) under that id serves + * non-OpenAI models that reject the rollout-gated tiers (issue #13342), so the + * exemption follows the effective endpoint, not the id alone. Model options + * shadow provider options, matching resolveSDK/getModel precedence; with no + * endpoint information the first-party default is preserved for forward + * compatibility. + */ +export function isFirstPartyOpenAIEndpoint(model: EndpointModel, options: Record): boolean { + if (model.providerID !== "openai") return false + const override = + model.options?.["providerEndpointOverride"] ?? + model.options?.["endpoint"] ?? + model.options?.["baseURL"] ?? + options["providerEndpointOverride"] ?? + options["endpoint"] ?? + options["baseURL"] + const endpoint = typeof override === "string" && override !== "" ? override : model.api.url + if (typeof endpoint !== "string" || endpoint === "") return true + let host: string + try { + host = new URL(endpoint).hostname.toLowerCase() + } catch { + // A configured but unexpanded endpoint ({env:...}, ${...}) cannot be + // proven first-party. Gate conservatively: offering tiers the endpoint may + // reject is worse than omitting tiers a first-party endpoint would accept. + return false + } + return host === "openai.com" || host.endsWith(".openai.com") +} diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 73e63769e56b..cd47a0cd2efd 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -50,6 +50,7 @@ import { wrapFirstByte, } from "@/kilocode/provider/provider" import * as ModelsRefresh from "@/kilocode/provider/models-refresh" +import { gpt5FamilyReasoningTiers, isFirstPartyOpenAIEndpoint } from "@/kilocode/provider/transform" // kilocode_change - endpoint-aware reasoning-tier gating import { bedrockAuth, providerKey, vertexAuth, vertexCredentials, vertexOptions } from "@/kilocode/provider/cloud-auth" // kilocode_change end import { ProviderError } from "./error" @@ -1635,10 +1636,13 @@ const layer = Layer.effect( ...patchKiloConfigModel(model, existingModel), // kilocode_change } // kilocode_change start + // Effective endpoint for reasoning-tier gating: provider options shadowed by + // model options, matching resolveSDK/getModel precedence. + const endpointOptions = { ...parsed.options, ...parsedModel.options } const baseGenerate = (m: typeof parsedModel) => existingModel?.api.npm === m.api.npm - ? (existingModel.variants ?? ProviderTransform.variants(m)) - : ProviderTransform.variants(m) + ? (existingModel.variants ?? ProviderTransform.variants(m, endpointOptions)) + : ProviderTransform.variants(m, endpointOptions) const generated = customProviderVariants( parsedModel, model.provider?.npm ?? provider.npm, @@ -1801,9 +1805,24 @@ const layer = Layer.effect( ) delete provider.models[modelID] - if (model.variants === undefined) { - model.variants = mapValues(ProviderTransform.variants(model), (v) => v) + // kilocode_change start - effective endpoint for reasoning-tier gating + // (provider options shadowed by model options, matching resolveSDK/getModel + // precedence). Variants baked at catalog time predate endpoint overrides, so a + // rerouted provider keeps a stale first-party exemption: none/xhigh on a + // non-gpt-5 id could only come from that exemption, which the override + // invalidates (issue #13342). Regenerate under the effective endpoint; + // explicit config variants re-apply below. + const endpointOptions = { ...provider.options, ...model.options } + if ( + model.variants === undefined || + (model.api.npm === "@ai-sdk/openai" && + (model.variants.none !== undefined || model.variants.xhigh !== undefined) && + !gpt5FamilyReasoningTiers(model.api.id) && + !isFirstPartyOpenAIEndpoint(model, endpointOptions)) + ) { + model.variants = mapValues(ProviderTransform.variants(model, endpointOptions), (v) => v) } + // kilocode_change end const configVariants = configProvider?.models?.[modelID]?.variants if (configVariants && model.variants) { diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index d2cfaa6b89ad..d712fc565d79 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -7,6 +7,7 @@ import { iife } from "@/util/iife" import { kiloProviderOptions } from "@/kilocode/provider-options" import { isLing } from "@/kilocode/model-match" // kilocode_change import { reasoningSummary } from "@/kilocode/provider/reasoning-summary" // kilocode_change +import { gpt5FamilyReasoningTiers, isFirstPartyOpenAIEndpoint } from "@/kilocode/provider/transform" // kilocode_change type Modality = NonNullable["input"][number] @@ -715,7 +716,10 @@ function gpt5ChatReasoningEfforts(apiId: string) { // Computes the reasoning_effort tiers an OpenAI (or OpenAI-compatible upstream // routed through it, e.g. cf-ai-gateway) model exposes. Effort order: weakest // to strongest. -function openaiReasoningEfforts(apiId: string, releaseDate: string) { +// kilocode_change start - isFirstPartyOpenAI marks transports that only serve first-party OpenAI +// models (native Azure), where the deployment/model key may be opaque and the family gate must not apply +function openaiReasoningEfforts(apiId: string, releaseDate: string, isFirstPartyOpenAI = false) { + // kilocode_change end const id = apiId.toLowerCase() if (id.includes("deep-research")) return ["medium"] const chatEfforts = gpt5ChatReasoningEfforts(id) @@ -727,6 +731,11 @@ function openaiReasoningEfforts(apiId: string, releaseDate: string) { // GPT-5.1 replaced GPT-5's `minimal` effort with `none`; GPT-5.2+ // additionally accepts `xhigh`. Model pages list the supported subset. if (versionedEfforts) return versionedEfforts + // kilocode_change start - `none`/`xhigh` are OpenAI rollout tiers only gpt-5-family + // models accept; non-OpenAI models on an OpenAI-compatible base URL (e.g. Grok + // via api.x.ai) reject them (issue #13342). See gpt5FamilyReasoningTiers. + if (!isFirstPartyOpenAI && !gpt5FamilyReasoningTiers(id)) return WIDELY_SUPPORTED_EFFORTS + // kilocode_change end const efforts = [...WIDELY_SUPPORTED_EFFORTS] if (GPT5_FAMILY_RE.test(id)) efforts.unshift("minimal") if (releaseDate >= OPENAI_NONE_EFFORT_RELEASE_DATE) efforts.unshift("none") @@ -849,7 +858,12 @@ function googleThinkingVariants(model: Provider.Model): Record> { +// kilocode_change start - options carries the effective endpoint for reasoning-tier gating +export function variants( + model: Provider.Model, + options: Record = {}, +): Record> { +// kilocode_change end // kilocode_change start if ( ["@kilocode/kilo-gateway", "@ai-sdk/openai-compatible"].includes(model.api.npm) && @@ -1077,7 +1091,7 @@ export function variants(model: Provider.Model): Record [ + openaiReasoningEfforts(id, model.release_date, true).map((effort) => [ // kilocode_change - native Azure serves first-party OpenAI only effort, { reasoningEffort: effort, @@ -1101,7 +1115,11 @@ export function variants(model: Provider.Model): Record [ effort, diff --git a/packages/opencode/test/kilocode/provider-reasoning-options.test.ts b/packages/opencode/test/kilocode/provider-reasoning-options.test.ts index 06f4fd952cbe..87ba7a64a9d7 100644 --- a/packages/opencode/test/kilocode/provider-reasoning-options.test.ts +++ b/packages/opencode/test/kilocode/provider-reasoning-options.test.ts @@ -142,8 +142,9 @@ describe("ProviderTransform.reasoningVariants - models.dev reasoning_options", ( describe("custom provider fallback reasoning efforts", () => { const efforts = ["none", "low", "medium", "high", "xhigh", "max"] + const gated = ["low", "medium", "high"] - for (const npm of ["@ai-sdk/openai-compatible", "@ai-sdk/openai", "@ai-sdk/anthropic"]) { + for (const npm of ["@ai-sdk/openai-compatible", "@ai-sdk/anthropic"]) { test(`${npm} exposes broad efforts after heuristics fail`, () => { const model = mockModel({ id: "qwen-custom", api: { id: "qwen-custom", url: "https://api.test.com", npm } }) const generated = ProviderTransform.variants({ ...model, variants: {} }) @@ -162,6 +163,26 @@ describe("custom provider fallback reasoning efforts", () => { }) } + test("@ai-sdk/openai gates rollout tiers for non-gpt-5 ids (issue #13342)", () => { + const npm = "@ai-sdk/openai" + const model = mockModel({ id: "qwen-custom", api: { id: "qwen-custom", url: "https://api.test.com", npm } }) + expect(ProviderTransform.variants({ ...model, variants: {} })).toEqual({}) + + const result = customProviderVariants(model, npm, ProviderTransform.variants) + expect(Object.keys(result)).toEqual(gated) + expect(result.low?.reasoningEffort).toBe("low") + expect(result.high?.reasoningEffort).toBe("high") + }) + + test("@ai-sdk/openai keeps rollout tiers for gpt-5 ids", () => { + const npm = "@ai-sdk/openai" + const model = mockModel({ id: "gpt-5.5", api: { id: "gpt-5.5", url: "https://api.test.com", npm } }) + const result = customProviderVariants(model, npm, () => ({})) + expect(Object.keys(result)).toEqual(efforts) + expect(result.none?.reasoningEffort).toBe("none") + expect(result.xhigh?.reasoningEffort).toBe("xhigh") + }) + for (const id of ["glm-custom", "kimi-custom", "minimax-custom"]) { test(`openai-compatible custom provider uses broad efforts for ${id}`, () => { const npm = "@ai-sdk/openai-compatible" diff --git a/packages/opencode/test/kilocode/provider/transform.test.ts b/packages/opencode/test/kilocode/provider/transform.test.ts new file mode 100644 index 000000000000..a7eec99ee6bd --- /dev/null +++ b/packages/opencode/test/kilocode/provider/transform.test.ts @@ -0,0 +1,231 @@ +import { describe, expect, test } from "bun:test" +import { ProviderTransform } from "@/provider/transform" + +// Kilo-specific reasoning-effort gate regression coverage (issue #13342). +// +// OpenAI's `none`/`xhigh` reasoning_effort tiers are rollout-gated and only +// gpt-5-family models accept them. Non-OpenAI models routed through the OpenAI +// npm on a compatible base URL (e.g. Grok via api.x.ai) reject them, so Kilo +// must not offer those tiers to non-gpt-5 models. See +// src/kilocode/provider/transform.ts. + +function model(overrides: Record = {}): any { + return { + id: "test/test-model", + providerID: "test", + api: { + id: "test-model", + url: "https://api.test.com", + npm: "@ai-sdk/openai", + }, + name: "Test Model", + capabilities: { + temperature: true, + reasoning: true, + attachment: true, + toolcall: true, + input: { text: true, audio: false, image: true, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + cost: { + input: 0.001, + output: 0.002, + cache: { read: 0.0001, write: 0.0002 }, + }, + limit: { + context: 200_000, + output: 64_000, + }, + status: "active", + options: {}, + headers: {}, + release_date: "2024-01-01", + ...overrides, + } +} + +describe("ProviderTransform.variants - gpt-5 rollout-tier gate", () => { + test("grok-4.6 on the OpenAI npm does not get OpenAI rollout-gated none/xhigh tiers", () => { + const result = ProviderTransform.variants( + model({ + id: "grok-4.6", + providerID: "xai", + api: { id: "grok-4.6", url: "https://api.x.ai", npm: "@ai-sdk/openai" }, + release_date: "2026-08-12", + }), + ) + expect(Object.keys(result)).toEqual(["low", "medium", "high"]) + expect(result.medium).toEqual({ + reasoningEffort: "medium", + reasoningSummary: "auto", + include: ["reasoning.encrypted_content"], + }) + }) + + test("unknown reasoning models on the OpenAI npm keep only widely supported efforts", () => { + const result = ProviderTransform.variants( + model({ + id: "my-compatible-model", + providerID: "custom", + api: { id: "my-compatible-model", url: "https://api.custom.com", npm: "@ai-sdk/openai" }, + release_date: "2026-08-12", + }), + ) + expect(Object.keys(result)).toEqual(["low", "medium", "high"]) + }) + + test("gpt-5 lookalikes stay out of the gpt-5 family", () => { + for (const id of ["gpt-50", "gpt-5o"]) { + const result = ProviderTransform.variants( + model({ + id, + api: { id, url: "https://api.openai.com", npm: "@ai-sdk/openai" }, + release_date: "2026-08-12", + }), + ) + expect(Object.keys(result)).toEqual(["low", "medium", "high"]) + } + }) + + test("dot-prefixed gpt-5 id (Bedrock Mantle) keeps rollout-gated tiers", () => { + const result = ProviderTransform.variants( + model({ + id: "openai.gpt-5.5", + providerID: "amazon-bedrock", + api: { + id: "openai.gpt-5.5", + url: "https://bedrock-mantle.us-east-2.api.aws/openai/v1", + npm: "@ai-sdk/amazon-bedrock/mantle", + }, + release_date: "2026-04-23", + }), + ) + expect(Object.keys(result)).toEqual(["none", "low", "medium", "high", "xhigh"]) + }) + + test("hyphen-prefixed gpt-5 deployment id (SAP AI Core) keeps rollout-gated tiers", () => { + const result = ProviderTransform.variants( + model({ + id: "azure-openai--gpt-5.4", + providerID: "sap-ai-core", + api: { id: "azure-openai--gpt-5.4", url: "https://api.ai.sap", npm: "@jerome-benoit/sap-ai-provider-v2" }, + release_date: "2026-01-15", + }), + ) + expect(Object.keys(result)).toEqual(["none", "low", "medium", "high", "xhigh"]) + }) + + test("native azure with an opaque deployment key keeps rollout-gated tiers", () => { + const result = ProviderTransform.variants( + model({ + id: "production", + providerID: "azure", + api: { id: "production", url: "", npm: "@ai-sdk/azure" }, + release_date: "2026-08-01", + }), + ) + expect(Object.keys(result)).toEqual(["none", "low", "medium", "high", "xhigh"]) + }) + + test("native azure with a gpt-5.5 model key and separate deployment id keeps rollout-gated tiers", () => { + const result = ProviderTransform.variants( + model({ + id: "gpt-5.5", + providerID: "azure", + api: { id: "my-gpt-5-5-deployment", url: "", npm: "@ai-sdk/azure" }, + release_date: "2026-08-01", + }), + ) + expect(Object.keys(result)).toEqual(["none", "low", "medium", "high", "xhigh"]) + }) + + test("first-party openai with a post-cutoff id keeps rollout tiers via release-date logic", () => { + const result = ProviderTransform.variants( + model({ + id: "gpt-6", + providerID: "openai", + api: { id: "gpt-6", url: "https://api.openai.com", npm: "@ai-sdk/openai" }, + release_date: "2027-01-01", + }), + ) + expect(Object.keys(result)).toEqual(["none", "low", "medium", "high", "xhigh"]) + }) + + test("third-party openai-compatible with a post-cutoff id stays gated to widely supported efforts", () => { + const result = ProviderTransform.variants( + model({ + id: "gpt-6", + providerID: "custom", + api: { id: "gpt-6", url: "https://api.custom.com", npm: "@ai-sdk/openai" }, + release_date: "2027-01-01", + }), + ) + expect(Object.keys(result)).toEqual(["low", "medium", "high"]) + }) + + test("openai id with an xAI baseURL override loses rollout tiers for non-GPT models", () => { + const result = ProviderTransform.variants( + model({ + id: "grok-4.6", + providerID: "openai", + api: { id: "grok-4.6", url: "https://api.openai.com", npm: "@ai-sdk/openai" }, + release_date: "2026-08-12", + }), + { baseURL: "https://api.x.ai/v1" }, + ) + expect(Object.keys(result)).toEqual(["low", "medium", "high"]) + }) + + test("openai id with a first-party baseURL keeps rollout tiers", () => { + const result = ProviderTransform.variants( + model({ + id: "gpt-6", + providerID: "openai", + api: { id: "gpt-6", url: "https://api.openai.com", npm: "@ai-sdk/openai" }, + release_date: "2027-01-01", + }), + { baseURL: "https://api.openai.com/v1" }, + ) + expect(Object.keys(result)).toEqual(["none", "low", "medium", "high", "xhigh"]) + }) + + test("openai id with a third-party catalog api url stays gated without an override", () => { + const result = ProviderTransform.variants( + model({ + id: "grok-4.6", + providerID: "openai", + api: { id: "grok-4.6", url: "https://api.x.ai/v1", npm: "@ai-sdk/openai" }, + release_date: "2026-08-12", + }), + ) + expect(Object.keys(result)).toEqual(["low", "medium", "high"]) + }) + + test("model-level baseURL shadows a first-party provider baseURL", () => { + const result = ProviderTransform.variants( + model({ + id: "grok-4.6", + providerID: "openai", + api: { id: "grok-4.6", url: "https://api.openai.com", npm: "@ai-sdk/openai" }, + options: { baseURL: "https://api.x.ai/v1" }, + release_date: "2026-08-12", + }), + { baseURL: "https://api.openai.com/v1" }, + ) + expect(Object.keys(result)).toEqual(["low", "medium", "high"]) + }) + + test("unexpanded baseURL placeholder is gated conservatively", () => { + const result = ProviderTransform.variants( + model({ + id: "grok-4.6", + providerID: "openai", + api: { id: "grok-4.6", url: "https://api.openai.com", npm: "@ai-sdk/openai" }, + release_date: "2026-08-12", + }), + { baseURL: "https://{env:XAI_HOST}/v1" }, + ) + expect(Object.keys(result)).toEqual(["low", "medium", "high"]) + }) +})