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
46 changes: 33 additions & 13 deletions src/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -799,8 +806,26 @@ export class CommandCodeAdapter<C extends CommandCodeConnectionOptions = Command
return this.catalog
}

override async listModels(provider: string): Promise<readonly LlmModelInfo[]> {
override async listModels(
provider: string,
opts?: { unfiltered?: boolean },
): Promise<readonly LlmModelInfo[]> {
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
Expand All @@ -809,20 +834,15 @@ export class CommandCodeAdapter<C extends CommandCodeConnectionOptions = Command
const access = this.deps.options().filterModelsByPlan === false
? undefined
: await this.loadBillingAccess()
// Visible-model allowlist: empty/unset means "show everything".
const visible = this.deps.options().visibleModels
const allow = Array.isArray(visible) && visible.length > 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.
Expand Down
2 changes: 2 additions & 0 deletions src/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
16 changes: 16 additions & 0 deletions src/client/locales.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,11 @@ export type SettingsCommandCodeKey =
| 'ruleModelCount'
| 'ruleAccount'
| 'ruleHint'
| 'visibleModelsTitle'
| 'visibleModelsHint'
| 'visibleModelsPick'
| 'visibleModelsCount'
| 'visibleModelsShowAll'
| 'overridden'
| 'reset'
| 'invalidNumber'
Expand Down Expand Up @@ -186,6 +191,11 @@ export const zh: Record<SettingsCommandCodeKey, string> = {
ruleModelCount: '已选 {count} 个模型',
ruleAccount: '目标账户',
ruleHint: '从下拉列表勾选要路由的模型(可多选),再选择目标账户。',
visibleModelsTitle: '显示的模型',
visibleModelsHint: '只在模型选择器中显示勾选的模型;不勾选则显示全部。保存后下次打开选择器即生效。',
visibleModelsPick: '选择要显示的模型…',
visibleModelsCount: '已选 {count} 个模型',
visibleModelsShowAll: '显示全部',
overridden: '已覆盖',
reset: '重置',
invalidNumber: '无效数字',
Expand Down Expand Up @@ -323,6 +333,12 @@ export const en: Record<SettingsCommandCodeKey, string> = {
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',
Expand Down
51 changes: 51 additions & 0 deletions src/client/section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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<SettingsCommandCodeKey>
state: SettingsPageState
disabled: boolean
onSelect(ids: string[]): void
onClear(): void
}) {
const count = state.visibleModels.length
const pickT: Translate<SettingsCommandCodeKey> = (key, params) => {
if (key === 'ruleModelPick') return t('visibleModelsPick')
if (key === 'ruleModelCount') return t('visibleModelsCount', params)
return t(key, params)
}
return (
<div className="cc-card" aria-label={t('visibleModelsTitle')}>
<div className="cc-field">
<div className="cc-fieldHead">
<label className="cc-label">{t('visibleModelsTitle')}</label>
<span className="cc-badges">
{count > 0 ? (
<button type="button" className="cc-reset" disabled={disabled} onClick={onClear}>
{t('visibleModelsShowAll')}
</button>
) : null}
</span>
</div>
<p className="cc-hint">{t('visibleModelsHint')}</p>
{state.catalogFailed ? <p className="cc-invalid">{t('rulesCatalogFailed')}</p> : null}
<ModelMultiSelect
id="cc-visible-models"
selected={state.visibleModels}
catalog={state.catalogModels}
disabled={disabled}
t={pickT}
onSelect={onSelect}
/>
</div>
</div>
)
}

/**
* Show the "Saved ✓" affordance for a short window after each accepted save.
* The controller only counts saves (`savedCount`); the flash timing lives
Expand Down Expand Up @@ -1049,6 +1093,13 @@ export function CommandCodeSettingsPage(props: CommandCodeSettingsProps) {
onModels={props.editRuleModels}
onAccount={props.editRuleAccount}
/>
<VisibleModelsCard
t={t}
state={state}
disabled={disabled}
onSelect={props.editVisibleModels}
onClear={props.clearVisibleModels}
/>
<div className="cc-card">
<SecretKeyField
label={t('apiKey')}
Expand Down
80 changes: 76 additions & 4 deletions src/client/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,8 @@ export interface SettingsPageState {
accountsRemoving: string[]
/** Model → account routing rules, in list order (first match wins). */
rules: RuleItemState[]
/** Effective visible-model allowlist: staged draft or stored value. Empty = show all. */
visibleModels: string[]
/** The catalog the rule editor offers (Host-side, empty until loaded). */
catalogModels: CatalogModelOption[]
/** Whether the catalog fetch failed (rule editor falls back to typing). */
Expand Down Expand Up @@ -352,6 +354,8 @@ export class CommandCodeSettingsController {
private readonly ruleDrafts = new Map<string, { models: string[]; account: string }>()
/** Stored routing rule rows staged for removal. */
private readonly removedRuleIds = new Set<string>()
/** 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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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()
}
Expand All @@ -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<boolean>> = []
for (const item of plan) {
if (item.run === undefined) return
Expand All @@ -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
Expand All @@ -664,13 +671,15 @@ 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
// the stored section so a landed account is not simultaneously stored
// AND staged-for-addition (which a retry would persist twice).
this.reconcileAccountStaging()
this.reconcileRuleStaging()
this.reconcileVisibleModelsStaging()
}
this.publish()
}
Expand Down Expand Up @@ -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<boolean>> {
if (!this.visibleModelsDirty()) return []
return [() => this.writeVisibleModels()]
}

/** Persist the staged visible-model allowlist into the settings section. */
private async writeVisibleModels(): Promise<boolean> {
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<boolean>> {
if (!this.rulesDirty()) return []
Expand Down
12 changes: 11 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -224,6 +230,7 @@ export const Config: z<Config> = 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(),
Expand Down Expand Up @@ -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,
}
}

Expand Down Expand Up @@ -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<CommandCodeCatalog> => {
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\)$/, '') })),
}
Expand Down
Loading