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
5 changes: 5 additions & 0 deletions .changeset/free-models-only-provider-config.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Add a per-provider `free_models_only` setting for OpenAI-compatible providers. Set `free_models_only = true` in a provider's `[providers.<id>]` section so that `/refresh-catalog` keeps only free models (ids containing `free`).
4 changes: 2 additions & 2 deletions .specify/feature.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
"feature_directory": "specs/003-private-profile"
}
"feature_directory": "specs/008-free-models-only"
}
1 change: 1 addition & 0 deletions packages/agent-core/src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export const ProviderConfigSchema = z.object({
activeApiKeyId: z.string().optional(),
baseUrl: z.string().optional(),
proxyUrl: z.string().optional(),
free_models_only: z.boolean().optional(),
defaultModel: z.string().optional(),
oauth: OAuthRefSchema.optional(),
env: StringRecordSchema.optional(),
Expand Down
60 changes: 60 additions & 0 deletions packages/agent-core/test/config/schema.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest';

import { ProviderConfigSchema } from '../../src/config/schema';

describe('ProviderConfigSchema — free_models_only', () => {
it('accepts free_models_only: true', () => {
const parsed = ProviderConfigSchema.parse({
type: 'openai',
baseUrl: 'https://openrouter.ai/api/v1',
apiKey: 'sk-or',
free_models_only: true,
});
expect(parsed.free_models_only).toBe(true);
});

it('accepts free_models_only: false', () => {
const parsed = ProviderConfigSchema.parse({
type: 'openai',
apiKey: 'sk-or',
free_models_only: false,
});
expect(parsed.free_models_only).toBe(false);
});

it('accepts an absent free_models_only (undefined, no default)', () => {
const parsed = ProviderConfigSchema.parse({ type: 'openai', apiKey: 'sk-or' });
expect(parsed.free_models_only).toBeUndefined();
});

it('rejects a non-boolean free_models_only (string)', () => {
expect(() =>
ProviderConfigSchema.parse({
type: 'openai',
apiKey: 'sk-or',
free_models_only: 'yes',
}),
).toThrow();
});

it('rejects a non-boolean free_models_only (number)', () => {
expect(() =>
ProviderConfigSchema.parse({
type: 'openai',
apiKey: 'sk-or',
free_models_only: 1,
}),
).toThrow();
});

it('coexists with proxyUrl at the same level', () => {
const parsed = ProviderConfigSchema.parse({
type: 'openai',
apiKey: 'sk-or',
proxyUrl: 'http://localhost:8080',
free_models_only: true,
});
expect(parsed.proxyUrl).toBe('http://localhost:8080');
expect(parsed.free_models_only).toBe(true);
});
});
22 changes: 20 additions & 2 deletions packages/oauth/src/refreshProviderModels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ interface ProviderView {
readonly oauth?: ManagedKimiOAuthRef;
readonly source?: unknown;
readonly env?: unknown;
/** When true, the OpenAI-compatible catalog refresh keeps only free models. */
readonly freeModelsOnly?: boolean;
}

function getActiveProviderApiKey(provider: ProviderView): string | undefined {
Expand Down Expand Up @@ -122,7 +124,10 @@ function readProvider(
): ProviderView | undefined {
const provider = config.providers[providerId];
if (provider === undefined) return undefined;
return provider as ProviderView;
const view = provider as ProviderView;
const rawFree = (provider as Record<string, unknown>)['free_models_only'];
const freeModelsOnly = typeof rawFree === 'boolean' ? rawFree : undefined;
return { ...view, freeModelsOnly };
}

function readModel(
Expand Down Expand Up @@ -865,10 +870,23 @@ export async function refreshProviderCatalog(
});
if (models.length === 0) continue;

// Per-provider opt-in: when `free_models_only` is set, keep only models
// whose id contains "free" (case-insensitive — matches `:free` on
// OpenRouter/kilo and `-free` on opencode). The filter runs before
// enrichment so dropped (paid) models are never looked up or written.
const filteredModels = provider.freeModelsOnly
? models.filter((m) => m.id.toLowerCase().includes('free'))
: models;
if (filteredModels.length === 0) {
// No free models returned: nothing to add. Skip so an existing catalog
// is left untouched (toggle the flag off to restore paid models).
continue;
}

config = await rebaseSelectionAfterFetch(host, config);
const aliasPrefix = `${providerId}/`;
const next = structuredClone(config);
applyOpenAiCompatibleCatalog(next, providerId, models, aliasPrefix);
applyOpenAiCompatibleCatalog(next, providerId, filteredModels, aliasPrefix);
const refreshedAliasKeys = providerRefreshAliasKeys(config, next, providerId, aliasPrefix);
restoreProviderAliases(
next,
Expand Down
Loading
Loading