diff --git a/src/adapter.ts b/src/adapter.ts index d6e3323..916f8cc 100644 --- a/src/adapter.ts +++ b/src/adapter.ts @@ -601,6 +601,13 @@ export interface CommandCodeConnectionOptions { * keep the full catalog visible. Set false to always list every model. */ filterModelsByPlan?: boolean + /** + * Visible-model allowlist: catalog model ids shown in pickers. Empty or + * unset means "show everything"; applies after the subscription-tier filter. + * The settings page persists it; the catalog endpoint serves the full + * catalog regardless so the page can always offer every model. + */ + visibleModels?: string[] | undefined /** * Optional protocol override. `'auto'` (default) uses billing/cache plus * Provider API fallback; `'cli'` forces `/alpha/generate`; `'openai'` forces @@ -799,8 +806,26 @@ export class CommandCodeAdapter { + override async listModels( + provider: string, + opts?: { unfiltered?: boolean }, + ): Promise { const catalog = await this.loadCatalog() + const toInfo = (model: (typeof catalog)[number]) => { + const vision = KNOWN_IMAGE_MODELS.has(model.id) + return { + provider, + id: model.id, + name: `${model.name} (CC)`, + // The picker renders `description` under the model name: plan tier, + // active deal, Image marker for Vision models, and context window. + description: capabilityDescription(model.id, model.contextWindow), + inputModalities: vision ? (['text', 'image'] as const) : (['text'] as const), + } + } + // Unfiltered: the settings page's catalog endpoint serves the full catalog + // so the filter editor can always offer every model. + if (opts?.unfiltered === true) return catalog.map(toInfo).sort(compareByPlan) // Plan filter: hide models above the account's subscription tier. Fails // open — a billing-fetch problem, an unknown plan, or a positive // on-demand balance all keep the full catalog visible, and the server @@ -809,20 +834,15 @@ export class CommandCodeAdapter 0 + ? new Set(visible.filter((id) => typeof id === 'string' && id !== '')) + : undefined return catalog .filter((model) => modelVisibleInPlan(model.id, access)) - .map((model) => { - const vision = KNOWN_IMAGE_MODELS.has(model.id) - return { - provider, - id: model.id, - name: `${model.name} (CC)`, - // The picker renders `description` under the model name: plan tier, - // active deal, Image marker for Vision models, and context window. - description: capabilityDescription(model.id, model.contextWindow), - inputModalities: vision ? (['text', 'image'] as const) : (['text'] as const), - } - }) + .filter((model) => allow === undefined || allow.has(model.id)) + .map(toInfo) // The picker renders rows in the order returned: sort by plan tier // (Go first, … Provider last) so the models a Go-plan user can actually // use lead the list, then alphabetically within each tier. diff --git a/src/client/index.ts b/src/client/index.ts index f5c45ef..33e1dc5 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -387,6 +387,8 @@ function applyClientSurfaces( removeRule: (id: string) => controller.removeRule(id), editRuleModels: (id: string, ids: string[]) => controller.editRuleModels(id, ids), editRuleAccount: (id: string, text: string) => controller.editRuleAccount(id, text), + editVisibleModels: (ids: string[]) => controller.editVisibleModels(ids), + clearVisibleModels: () => controller.clearVisibleModels(), }) ctx.slots.inject('settings.section', () => ctx.slots.register({ diff --git a/src/client/locales.ts b/src/client/locales.ts index 6b85c56..8e931bc 100644 --- a/src/client/locales.ts +++ b/src/client/locales.ts @@ -62,6 +62,11 @@ export type SettingsCommandCodeKey = | 'ruleModelCount' | 'ruleAccount' | 'ruleHint' + | 'visibleModelsTitle' + | 'visibleModelsHint' + | 'visibleModelsPick' + | 'visibleModelsCount' + | 'visibleModelsShowAll' | 'overridden' | 'reset' | 'invalidNumber' @@ -186,6 +191,11 @@ export const zh: Record = { ruleModelCount: '已选 {count} 个模型', ruleAccount: '目标账户', ruleHint: '从下拉列表勾选要路由的模型(可多选),再选择目标账户。', + visibleModelsTitle: '显示的模型', + visibleModelsHint: '只在模型选择器中显示勾选的模型;不勾选则显示全部。保存后下次打开选择器即生效。', + visibleModelsPick: '选择要显示的模型…', + visibleModelsCount: '已选 {count} 个模型', + visibleModelsShowAll: '显示全部', overridden: '已覆盖', reset: '重置', invalidNumber: '无效数字', @@ -323,6 +333,12 @@ export const en: Record = { ruleModelCount: '{count} model(s) selected', ruleAccount: 'Target account', ruleHint: 'Check the models to route from the dropdown (multi-select), then pick the target account.', + visibleModelsTitle: 'Visible models', + visibleModelsHint: 'Show only the checked models in model pickers; unchecked shows all.' + + ' Applies the next time a picker opens after saving.', + visibleModelsPick: 'Select models to show…', + visibleModelsCount: '{count} model(s) selected', + visibleModelsShowAll: 'Show all', overridden: 'Overridden', reset: 'Reset', invalidNumber: 'Invalid number', diff --git a/src/client/section.tsx b/src/client/section.tsx index c44c063..ed655d4 100644 --- a/src/client/section.tsx +++ b/src/client/section.tsx @@ -53,6 +53,8 @@ export interface CommandCodeSettingsProps { removeRule(id: string): void editRuleModels(id: string, ids: string[]): void editRuleAccount(id: string, text: string): void + editVisibleModels(ids: string[]): void + clearVisibleModels(): void } /** The section fields folded into the collapsible Advanced card. */ @@ -961,6 +963,48 @@ function RulesCard({ t, state, disabled, onAdd, onRemove, onModels, onAccount }: ) } +/** The visible-model filter card: an allowlist over the catalog. Empty = show all. */ +function VisibleModelsCard({ t, state, disabled, onSelect, onClear }: { + t: Translate + state: SettingsPageState + disabled: boolean + onSelect(ids: string[]): void + onClear(): void +}) { + const count = state.visibleModels.length + const pickT: Translate = (key, params) => { + if (key === 'ruleModelPick') return t('visibleModelsPick') + if (key === 'ruleModelCount') return t('visibleModelsCount', params) + return t(key, params) + } + return ( +
+
+
+ + + {count > 0 ? ( + + ) : null} + +
+

{t('visibleModelsHint')}

+ {state.catalogFailed ?

{t('rulesCatalogFailed')}

: null} + +
+
+ ) +} + /** * Show the "Saved ✓" affordance for a short window after each accepted save. * The controller only counts saves (`savedCount`); the flash timing lives @@ -1049,6 +1093,13 @@ export function CommandCodeSettingsPage(props: CommandCodeSettingsProps) { onModels={props.editRuleModels} onAccount={props.editRuleAccount} /> +
() /** Stored routing rule rows staged for removal. */ private readonly removedRuleIds = new Set() + /** Staged visible-model allowlist (undefined = no draft). */ + private visibleModelsDraft: string[] | undefined = undefined /** The catalog the rule editor offers (Host-side). */ private catalogModels: CatalogModelOption[] = [] private catalogFailed = false @@ -455,9 +459,10 @@ export class CommandCodeSettingsController { accounts, accountsRemoving: [...this.removedRefs], rules: this.effectiveRules(), + visibleModels: this.effectiveVisibleModels(), catalogModels: this.catalogModels, catalogFailed: this.catalogFailed, - dirty: plan.length > 0 || this.accountsDirty() || this.rulesDirty(), + dirty: plan.length > 0 || this.accountsDirty() || this.rulesDirty() || this.visibleModelsDirty(), invalid: plan.some((item) => item.run === undefined), saving: this.saving, failed: this.failed, @@ -615,10 +620,11 @@ export class CommandCodeSettingsController { /** Discard every staged edit. */ discard(): void { - if (this.staged.size === 0 && !this.accountsStaged() && !this.rulesStaged() && !this.failed) return + if (this.staged.size === 0 && !this.accountsStaged() && !this.rulesStaged() && !this.visibleModelsStaged() && !this.failed) return this.staged.clear() this.clearAccountStaging() this.clearRuleStaging() + this.clearVisibleModelsStaging() this.failed = false this.publish() } @@ -637,7 +643,8 @@ export class CommandCodeSettingsController { const plan = this.plan() const accountRuns = this.accountPlan() const ruleRuns = this.rulesPlan() - if ((plan.length === 0 && accountRuns.length === 0 && ruleRuns.length === 0) || this.saving) return + const visibleRuns = this.visibleModelsPlan() + if ((plan.length === 0 && accountRuns.length === 0 && ruleRuns.length === 0 && visibleRuns.length === 0) || this.saving) return const runs: Array<() => Promise> = [] for (const item of plan) { if (item.run === undefined) return @@ -651,7 +658,7 @@ export class CommandCodeSettingsController { // write failed silently; the accounts list itself writes last. Stop at // the first failure: running later writes after a failed one would // persist a partial state the staged drafts no longer describe. - for (const run of [...runs, ...accountRuns, ...ruleRuns]) { + for (const run of [...runs, ...accountRuns, ...ruleRuns, ...visibleRuns]) { if (!(await run())) { landed = false break @@ -664,6 +671,7 @@ export class CommandCodeSettingsController { this.staged.clear() this.clearAccountStaging() this.clearRuleStaging() + this.clearVisibleModelsStaging() } else { // A failed save may still have landed earlier writes (e.g. the accounts // list made it while a key write did not). Reconcile the staging with @@ -671,6 +679,7 @@ export class CommandCodeSettingsController { // AND staged-for-addition (which a retry would persist twice). this.reconcileAccountStaging() this.reconcileRuleStaging() + this.reconcileVisibleModelsStaging() } this.publish() } @@ -1078,6 +1087,69 @@ export class CommandCodeSettingsController { } } + /** The stored visible-model allowlist (`visibleModels`); empty = show all. */ + private storedVisibleModels(): string[] { + const raw = this.scope.getSnapshot().value?.visibleModels + if (!Array.isArray(raw)) return [] + return raw.filter((m): m is string => typeof m === 'string' && m !== '') + } + + /** Effective visible-model allowlist: staged draft or stored value. */ + private effectiveVisibleModels(): string[] { + return this.visibleModelsDraft ?? this.storedVisibleModels() + } + + /** Whether the staged visible-model selection differs from stored. */ + private visibleModelsDirty(): boolean { + return this.visibleModelsDraft !== undefined + && !sameModels(this.visibleModelsDraft, this.storedVisibleModels()) + } + + /** Whether any visible-model staging exists. */ + private visibleModelsStaged(): boolean { + return this.visibleModelsDraft !== undefined + } + + /** Reset the visible-model staged edit. */ + private clearVisibleModelsStaging(): void { + this.visibleModelsDraft = undefined + } + + /** Drop visible-model staging the stored section already reflects. */ + private reconcileVisibleModelsStaging(): void { + if (this.visibleModelsDraft !== undefined + && sameModels(this.visibleModelsDraft, this.storedVisibleModels())) { + this.visibleModelsDraft = undefined + } + } + + /** The visible-model writes a save performs (empty when nothing staged). */ + private visibleModelsPlan(): Array<() => Promise> { + if (!this.visibleModelsDirty()) return [] + return [() => this.writeVisibleModels()] + } + + /** Persist the staged visible-model allowlist into the settings section. */ + private async writeVisibleModels(): Promise { + const list = this.visibleModelsDraft ?? [] + await this.scope.set('visibleModels', list) + return sameModels(this.storedVisibleModels(), list) + } + + /** Stage the visible-model allowlist (multi-select). */ + editVisibleModels(models: string[]): void { + this.visibleModelsDraft = [...models] + this.failed = false + this.publish() + } + + /** Stage "show all models" (clears the allowlist). */ + clearVisibleModels(): void { + this.visibleModelsDraft = [] + this.failed = false + this.publish() + } + /** The routing-rule writes a save performs (empty when nothing staged). */ private rulesPlan(): Array<() => Promise> { if (!this.rulesDirty()) return [] diff --git a/src/index.ts b/src/index.ts index d6842ce..e1a4a89 100644 --- a/src/index.ts +++ b/src/index.ts @@ -162,6 +162,12 @@ export interface Config { * full catalog visible). Set false to always list every model. */ filterModelsByPlan?: boolean + /** + * Visible-model allowlist: catalog model ids shown in pickers. Empty or + * unset means "show everything". Persisted by the settings page's model + * filter card; applies after the subscription-tier filter. + */ + visibleModels?: string[] /** * Extra accounts for multi-account rotation. The top-level * `apiKey`/`apiKeyEnv` (plus the CLI auth file) always form the first @@ -224,6 +230,7 @@ export const Config: z = z.object({ requestTimeoutMs: z.number().min(1).max(MAX_TIMER_DELAY_MS), streamIdleTimeoutMs: z.number().min(1).max(MAX_TIMER_DELAY_MS), filterModelsByPlan: z.boolean(), + visibleModels: z.array(z.string()), webSearch: z.boolean().default(true), accounts: z.array(z.object({ label: z.string(), @@ -258,6 +265,9 @@ export function resolveAdapterOptions(config: Config): ResolvedCommandCodeOption requestTimeoutMs: config.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS, streamIdleTimeoutMs: config.streamIdleTimeoutMs ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS, filterModelsByPlan: config.filterModelsByPlan ?? true, + visibleModels: Array.isArray(config.visibleModels) + ? config.visibleModels.filter((id) => typeof id === 'string' && id !== '') + : undefined, } } @@ -477,7 +487,7 @@ export function apply(ctx: Context, config: Config): void { // from the adapter's cached/fetched catalog (sorted for picking), so the // browser never calls the Command Code API directly. const catalogForRules = async (): Promise => { - const models = await adapter.listModels(PROVIDER) + const models = await adapter.listModels(PROVIDER, { unfiltered: true }) return { models: models.map((model) => ({ id: model.id, name: model.name.replace(/\s*\(CC\)$/, '') })), } diff --git a/tests/adapter.test.ts b/tests/adapter.test.ts index bf519f1..bdd1835 100644 --- a/tests/adapter.test.ts +++ b/tests/adapter.test.ts @@ -621,6 +621,72 @@ test('listModels() caches the billing access across picker loads', async () => { assert.deepEqual(ids.sort(), ['claude-sonnet-5', 'deepseek/deepseek-v4-pro', 'some-future-model']) }) +// Visible-model allowlist (listModels narrows the picker; unfiltered serves the page catalog) +test('listModels() narrows the picker to visibleModels after the plan filter', async () => { + const { fetchImpl } = fetchRouting({ + '/provider/v1/models': { status: 200, body: PLAN_FILTER_CATALOG }, + ...subscriptionStubs('individual-go', 'active'), + '/alpha/billing/credits': { status: 200, body: billingBody(undefined, 0, 0) }, + }) + const adapter = makeAdapter({ + fetchImpl, + options: () => ({ + apiBase: 'https://api.commandcode.ai', + workingDir: '/tmp/project', + modelsCachePath: '/tmp/cc-models-cache.json', + requestTimeoutMs: DEFAULT_REQUEST_TIMEOUT_MS, + streamIdleTimeoutMs: DEFAULT_STREAM_IDLE_TIMEOUT_MS, + visibleModels: ['deepseek/deepseek-v4-pro', 'some-future-model', 'not-a-model'], + }), + }) + const ids = (await adapter.listModels('commandcode')).map((m) => m.id) + assert.deepEqual(ids.sort(), ['deepseek/deepseek-v4-pro', 'some-future-model']) +}) + +test('listModels() shows everything when visibleModels is empty or unset', async () => { + for (const visibleModels of [undefined, []] as const) { + const { fetchImpl } = fetchRouting({ + '/provider/v1/models': { status: 200, body: PLAN_FILTER_CATALOG }, + ...subscriptionStubs('individual-go', 'active'), + '/alpha/billing/credits': { status: 200, body: billingBody(undefined, 0, 0) }, + }) + const adapter = makeAdapter({ + fetchImpl, + options: () => ({ + apiBase: 'https://api.commandcode.ai', + workingDir: '/tmp/project', + modelsCachePath: '/tmp/cc-models-cache.json', + requestTimeoutMs: DEFAULT_REQUEST_TIMEOUT_MS, + streamIdleTimeoutMs: DEFAULT_STREAM_IDLE_TIMEOUT_MS, + ...(visibleModels === undefined ? {} : { visibleModels: [...visibleModels] }), + }), + }) + const ids = (await adapter.listModels('commandcode')).map((m) => m.id) + assert.deepEqual(ids.sort(), ['deepseek/deepseek-v4-pro', 'some-future-model']) + } +}) + +test('listModels({ unfiltered: true }) serves the full catalog for the page editor', async () => { + const { fetchImpl } = fetchRouting({ + '/provider/v1/models': { status: 200, body: PLAN_FILTER_CATALOG }, + ...subscriptionStubs('individual-go', 'active'), + '/alpha/billing/credits': { status: 200, body: billingBody(undefined, 0, 0) }, + }) + const adapter = makeAdapter({ + fetchImpl, + options: () => ({ + apiBase: 'https://api.commandcode.ai', + workingDir: '/tmp/project', + modelsCachePath: '/tmp/cc-models-cache.json', + requestTimeoutMs: DEFAULT_REQUEST_TIMEOUT_MS, + streamIdleTimeoutMs: DEFAULT_STREAM_IDLE_TIMEOUT_MS, + visibleModels: ['deepseek/deepseek-v4-pro'], + }), + }) + const ids = (await adapter.listModels('commandcode', { unfiltered: true })).map((m) => m.id) + assert.equal(ids.length, PLAN_FILTER_CATALOG.data.length) +}) + test('modelVisibleInPlan() fails open on every uncertainty', async () => { const { modelVisibleInPlan } = await import('../src/capabilities.ts') // No billing data at all -> visible.