Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## [v6.8.6] - 2026-09-15

### Fixed

- **Plan-aware default model.** `getKilocodeDefaultModel` no longer returns the hardcoded `openRouterDefaultModelId`. It now resolves the default from the live MatterAI catalog (`/v1/web/models`): free accounts get the entry the backend flags `freePlan`, every other plan gets the first catalog entry (index 0, ordered by the catalog's `sortOrder`). The account plan comes from `/axoncode/profile`; a failed profile fetch is treated as free, and any catalog failure still falls back to `openRouterDefaultModelId`. The stale-model reset paths in `ClineProvider` now pass the KiloCode token and organization id so they resolve the same plan-aware default instead of the hardcoded one.

## [v6.8.5] - 2026-09-04

### Added
Expand Down
137 changes: 96 additions & 41 deletions src/api/providers/kilocode/getKilocodeDefaultModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,52 +9,104 @@ type KilocodeToken = string

type OrganizationId = string

const cache = new Map<string, Promise<string>>()
const cache = new Map<string, { promise: Promise<string>; expiresAt: number }>()

const defaultsSchema = z.object({
defaultModel: z.string().nullish(),
// The default follows the account plan, so a cached value must expire: a plan
// change (or a catalog edit) has to be picked up without an extension reload.
const CACHE_TTL_MS = 10 * 60 * 1000

const catalogSchema = z.object({
data: z.array(
z.object({
id: z.string(),
freePlan: z.boolean().nullish(),
}),
),
})

const profileSchema = z.object({
plan: z.string().nullish(),
tieredUsage: z.object({ plan: z.string().nullish() }).nullish(),
})

const fetcher = fetchWithTimeout(5000)

const CATALOG_URL = "https://api.matterai.so/v1/web/models"
const PROFILE_URL = "https://api.matterai.so/axoncode/profile"

/** Whether an AxonCode plan string is the free tier (a missing plan counts as free). */
function isFreePlan(plan?: string | null): boolean {
const normalized = plan?.trim().toLowerCase() ?? ""
return normalized === "" || normalized === "free"
}

/**
* Resolves the default model from the live MatterAI catalog: free accounts get
* the entry the backend flags `freePlan`, every other plan gets the first entry
* the backend serves (index 0, ordered by the catalog's `sortOrder`).
*/
async function fetchCatalogDefaultModel(
kilocodeToken: KilocodeToken,
providerSettings?: ProviderSettings,
): Promise<string> {
const headers: Record<string, string> = {
...DEFAULT_HEADERS,
Authorization: `Bearer ${kilocodeToken}`,
}

// Add X-KILOCODE-TESTER: SUPPRESS header if the setting is enabled
if (
providerSettings?.kilocodeTesterWarningsDisabledUntil &&
providerSettings.kilocodeTesterWarningsDisabledUntil > Date.now()
) {
headers["X-KILOCODE-TESTER"] = "SUPPRESS"
}

const [catalogResponse, profileResponse] = await Promise.all([
fetcher(getKiloUrlFromToken(CATALOG_URL, kilocodeToken), { headers }),
fetcher(getKiloUrlFromToken(PROFILE_URL, kilocodeToken), { headers }),
])

if (!catalogResponse.ok) {
throw new Error(`Fetching model catalog from ${CATALOG_URL} failed: ${catalogResponse.status}`)
}

const catalog = await catalogSchema.parseAsync(await catalogResponse.json())
const models = catalog.data.filter((model) => !model.id.startsWith("axon-"))
if (models.length === 0) {
throw new Error(`Model catalog from ${CATALOG_URL} was empty`)
}

// A failed profile fetch must not block the default: an unknown plan is
// treated as free, which still resolves to a usable catalog entry.
const plan = profileResponse.ok
? ((await profileSchema.parseAsync(await profileResponse.json())).plan ?? undefined)
: undefined
Comment on lines +82 to +84

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Logic Error / Error Handling

Issue: The comment above this code states "A failed profile fetch must not block the default", but that guarantee only covers a non-OK HTTP status. If profileResponse.ok is true but the body is malformed (invalid JSON, or a shape that fails profileSchema — e.g. tieredUsage present with a non-object value), parseAsync/json() throws, which propagates out of fetchCatalogDefaultModel, gets caught in fetchKilocodeDefaultModel, and silently discards the successfully fetched catalog in favor of the hardcoded openRouterDefaultModelId fallback.

Fix: Wrap the profile parse in a try/catch so any profile failure (status, JSON, or schema) degrades to plan = undefined (treated as free) instead of throwing away the catalog result.

Impact: The catalog-derived default is always used when the catalog fetch succeeds, matching the documented intent and avoiding unnecessary fallbacks to the hardcoded model on transient profile API glitches.

Suggested change
const plan = profileResponse.ok
? ((await profileSchema.parseAsync(await profileResponse.json())).plan ?? undefined)
: undefined
let plan: string | undefined
if (profileResponse.ok) {
try {
plan = (await profileSchema.parseAsync(await profileResponse.json())).plan ?? undefined
} catch {
plan = undefined
}
}


if (isFreePlan(plan)) {
const freeModel = models.find((model) => model.freePlan === true)
if (freeModel) {
console.info(`Default model for the free plan: ${freeModel.id}`)
return freeModel.id
}
}

console.info(`Default model from ${CATALOG_URL}: ${models[0]!.id}`)
return models[0]!.id
}

async function fetchKilocodeDefaultModel(
_kilocodeToken: KilocodeToken,
kilocodeToken: KilocodeToken,
_organizationId?: OrganizationId,
_providerSettings?: ProviderSettings,
providerSettings?: ProviderSettings,
): Promise<string> {
return openRouterDefaultModelId
// try {
// const path = organizationId ? `/organizations/${organizationId}/defaults` : `/defaults`
// const url = getKiloUrlFromToken(`https://api.matterai.so${path}`, kilocodeToken)

// const headers: Record<string, string> = {
// ...DEFAULT_HEADERS,
// Authorization: `Bearer ${kilocodeToken}`,
// }

// // Add X-KILOCODE-TESTER: SUPPRESS header if the setting is enabled
// if (
// providerSettings?.kilocodeTesterWarningsDisabledUntil &&
// providerSettings.kilocodeTesterWarningsDisabledUntil > Date.now()
// ) {
// headers["X-KILOCODE-TESTER"] = "SUPPRESS"
// }

// const response = await fetcher(url, { headers })
// if (!response.ok) {
// throw new Error(`Fetching default model from ${url} failed: ${response.status}`)
// }
// const defaultModel = (await defaultsSchema.parseAsync(await response.json())).defaultModel
// if (!defaultModel) {
// throw new Error(`Default model from ${url} was empty`)
// }
// console.info(`Fetched default model from ${url}: ${defaultModel}`)
// return defaultModel
// } catch (err) {
// console.error("Failed to get default model", err)
// TelemetryService.instance.captureException(err, { context: "getKilocodeDefaultModel" })
// return openRouterDefaultModelId
// }
try {
return await fetchCatalogDefaultModel(kilocodeToken, providerSettings)
} catch (err) {
console.error("Failed to get default model", err)
TelemetryService.instance.captureException(err, { context: "getKilocodeDefaultModel" })
return openRouterDefaultModelId
}
}

export async function getKilocodeDefaultModel(
Expand All @@ -71,9 +123,12 @@ export async function getKilocodeDefaultModel(
testerSuppressed: providerSettings?.kilocodeTesterWarningsDisabledUntil,
})
let defaultModelPromise = cache.get(key)
if (!defaultModelPromise) {
defaultModelPromise = fetchKilocodeDefaultModel(kilocodeToken, organizationId, providerSettings)
if (!defaultModelPromise || defaultModelPromise.expiresAt <= Date.now()) {
defaultModelPromise = {
promise: fetchKilocodeDefaultModel(kilocodeToken, organizationId, providerSettings),
expiresAt: Date.now() + CACHE_TTL_MS,
}
cache.set(key, defaultModelPromise)
}
return await defaultModelPromise
return await defaultModelPromise.promise
}
10 changes: 8 additions & 2 deletions src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2573,7 +2573,10 @@ ${prompt}
if (apiConfiguration?.apiProvider === "kilocode" && apiConfiguration?.kilocodeModel) {
if (!isValidKilocodeModel(apiConfiguration.kilocodeModel)) {
const staleModel = apiConfiguration.kilocodeModel
const defaultModel = await getKilocodeDefaultModel()
const defaultModel = await getKilocodeDefaultModel(
apiConfiguration.kilocodeToken,
apiConfiguration.kilocodeOrganizationId,
)
const updatedConfig = { ...apiConfiguration, kilocodeModel: defaultModel }
await this.contextProxy.setProviderSettings(updatedConfig)
mergedApiConfiguration = { ...mergedApiConfiguration, kilocodeModel: defaultModel }
Expand Down Expand Up @@ -2802,7 +2805,10 @@ ${prompt}
if (providerSettings?.apiProvider === "kilocode" && providerSettings?.kilocodeModel) {
if (!isValidKilocodeModel(providerSettings.kilocodeModel)) {
const staleModel = providerSettings.kilocodeModel
const defaultModel = await getKilocodeDefaultModel()
const defaultModel = await getKilocodeDefaultModel(
providerSettings.kilocodeToken,
providerSettings.kilocodeOrganizationId,
)
providerSettings.kilocodeModel = defaultModel
await this.contextProxy.setProviderSettings(providerSettings)
this.log(
Expand Down
2 changes: 1 addition & 1 deletion src/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"displayName": "%extension.displayName%",
"description": "%extension.description%",
"publisher": "matterai",
"version": "6.8.5",
"version": "6.8.6",
"icon": "assets/icons/matterai-ic.png",
"galleryBanner": {
"color": "#FFFFFF",
Expand Down
Loading