From 71cbb22f72cbed874fb15078f0b716129fdf1d82 Mon Sep 17 00:00:00 2001 From: code-crusher Date: Tue, 15 Sep 2026 17:56:09 +0530 Subject: [PATCH] release: v6.8.6 Plan-aware default model: free plans default to the catalog's freePlan entry, paid plans to the first catalog entry (index 0). --- CHANGELOG.md | 6 ++++++ package-lock.json | 4 ++-- package.json | 2 +- src/api/models.ts | 35 +++++++++++++++++++++++++++++++++++ src/headless.ts | 11 +++++++++++ src/ui/App.tsx | 39 ++++++++++++++++++++++++++++++--------- 6 files changed, 85 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 379002f..4a41969 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [6.8.6] - 2026-09-15 + +### Fixed + +- **Plan-aware default model.** The default model is now resolved from the live catalog instead of the hardcoded `DEFAULT_MODEL_ID`: free accounts default to the catalog entry the backend flags `freePlan`, every other plan to the first catalog entry (index 0, ordered by the catalog's `sortOrder`). `fetchDynamicModels` records the catalog order and each model's `freePlan` flag, and the new `getDefaultModelId(plan)` helper resolves the default. The TUI applies it once the catalog and the account plan have both loaded — only while the selection is still the untouched default, so an explicit pick is never overwritten — and headless mode applies it when no `--model` / `MATTERAI_MODEL` was requested. + ## [6.8.5] - 2026-09-08 ### Added diff --git a/package-lock.json b/package-lock.json index b931489..e384de3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@matterailab/orbcode", - "version": "6.8.2", + "version": "6.8.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@matterailab/orbcode", - "version": "6.8.2", + "version": "6.8.6", "license": "MIT", "dependencies": { "@ai-sdk/anthropic": "^3.0.85", diff --git a/package.json b/package.json index 2388958..39434e2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@matterailab/orbcode", - "version": "6.8.5", + "version": "6.8.6", "description": "OrbCode CLI — agentic coding in your terminal, by MatterAI", "type": "module", "bin": { diff --git a/src/api/models.ts b/src/api/models.ts index 997a0c0..87b7837 100644 --- a/src/api/models.ts +++ b/src/api/models.ts @@ -46,6 +46,8 @@ export interface AxonModel { iconUrl?: string; /** Plan-pool cost multiplier from the backend catalog (e.g. 4 = 4x plan cost). */ costMultiplier?: number; + /** True when the backend catalog marks this model as the free-plan model. */ + freePlan?: boolean; /** * Which transport serves this model. Absent (or "matterai"/"axon") routes * through the MatterAI gateway (OpenAI `/chat/completions`). Any other value @@ -274,6 +276,14 @@ export const DEFAULT_MODEL_ID = "zai/glm-5.3-flash"; */ const managedModelIds = new Set(Object.keys(BUILTIN_AXON_MODELS)); +/** + * Model ids in backend catalog order (the order `/v1/models` returns, i.e. + * `sortOrder` then `created`). Index 0 is the default for paid plans; the entry + * flagged `freePlan` is the default for free plans. Empty until a successful + * dynamic fetch, so the static fallback is used before then. + */ +let catalogOrder: string[] = []; + const EXTENDED_CONTEXT_PLANS = new Set(["proplus", "ultra"]); const LUMEN_MODEL_PLANS = new Set(["proplus", "ultra"]); const EIDO_PRO_MODEL_PLANS = new Set(["pro", "proplus", "ultra"]); @@ -394,6 +404,29 @@ export function getModel(modelId: string): AxonModel { return AXON_MODELS[modelId] ?? AXON_MODELS[DEFAULT_MODEL_ID]; } +/** Whether an AxonCode plan string is the free tier (a missing plan counts as free). */ +export function isFreePlan(plan?: string): boolean { + const normalized = plan?.trim().toLowerCase() ?? ""; + return normalized === "" || normalized === "free"; +} + +/** + * Default model for the live 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`). Falls back to + * DEFAULT_MODEL_ID until a catalog fetch succeeds. + */ +export function getDefaultModelId(plan?: string): string { + if (catalogOrder.length === 0) return DEFAULT_MODEL_ID; + if (isFreePlan(plan)) { + const freeModelId = catalogOrder.find( + (id) => BUILTIN_AXON_MODELS[id]?.freePlan === true, + ); + if (freeModelId) return freeModelId; + } + return catalogOrder[0]!; +} + /** Resolve a local context-window option to the model ID understood by the gateway. */ export function getGatewayModelId(model: AxonModel): string { return model.gatewayModelId ?? model.id; @@ -458,6 +491,7 @@ export async function fetchDynamicModels( ? item.pricing.completion : 0, free: false, + freePlan: item.freePlan === true, iconUrl: typeof item.iconUrl === "string" ? item.iconUrl : undefined, costMultiplier: typeof item.costMultiplier === "number" ? item.costMultiplier : undefined, @@ -470,6 +504,7 @@ export async function fetchDynamicModels( // don't linger in the picker next to their replacement. The user's current // selection is never pruned — a transient backend gap shouldn't swap it. if (fetched.length > 0) { + catalogOrder = fetched.map((model) => model.id); const fetchedIds = new Set(fetched.map((model) => model.id)); const currentModel = loadSettingsModel() for (const id of managedModelIds) { diff --git a/src/headless.ts b/src/headless.ts index 72a3dd6..69600a2 100644 --- a/src/headless.ts +++ b/src/headless.ts @@ -6,6 +6,7 @@ import { canUseEidoProModels, canUseLumenModels, fetchDynamicModels, + getDefaultModelId, getModel, is400kAxonModel, isEidoBaseAxonModel, @@ -36,6 +37,16 @@ export async function runHeadless( // An unknown --model (or MATTERAI_MODEL) silently resolves to the default; say // so on stderr instead of quietly running a different model than requested. const requestedModel = process.env.MATTERAI_MODEL + + // No explicit model requested and the stored one is still the static default: + // resolve the plan-aware default from the live catalog (free plans get the + // catalog's free model, paid plans the first catalog entry). + if (token && !requestedModel && settings.model === DEFAULT_MODEL_ID) { + const profile = await fetchProfile(token).catch(() => null) + const plan = profile?.plan ?? profile?.tieredUsage?.plan + const preferred = getDefaultModelId(plan) + if (preferred !== settings.model) settings.model = preferred + } if (requestedModel && !isValidAxonModel(requestedModel)) { process.stderr.write( `warning: unknown model "${requestedModel}"; using "${settings.model}". ` + diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 1f2a8f4..959653e 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -36,6 +36,7 @@ import { canUseLumenModels, fetchDynamicModels, get232kAxonFallback, + getDefaultModelId, getModel, is400kAxonModel, isEidoBaseAxonModel, @@ -503,6 +504,10 @@ export function App({ usagePercentage?: number; tieredUsage?: import("../auth/auth.js").AxonCodeTieredUsage; } | null>(null); + // Set once the dynamic catalog and the account plan have loaded, so the + // plan-aware default model is only resolved against real data. + const [catalogReady, setCatalogReady] = useState(false); + const [planLoaded, setPlanLoaded] = useState(false); const activePlan = usage?.plan ?? usage?.tieredUsage?.plan; const has400kAccess = canUse400kContext(activePlan); const hasEidoBaseAccess = canUseEidoBaseModels(activePlan); @@ -514,15 +519,18 @@ export function App({ const refreshUsage = useCallback(() => { const token = getAuthToken(loadSettings()); if (!token) return; - fetchDynamicModels(token).catch(() => {}); + fetchDynamicModels(token) + .then(() => setCatalogReady(true)) + .catch(() => {}); fetchProfile(token) - .then((profile) => + .then((profile) => { setUsage({ plan: profile.plan, usagePercentage: profile.usagePercentage, tieredUsage: profile.tieredUsage, - }), - ) + }); + setPlanLoaded(true); + }) .catch(() => {}); }, []); @@ -882,7 +890,7 @@ export function App({ ); const switchModel = useCallback( - (modelId: string) => { + (modelId: string, options?: { silent?: boolean }) => { if (isLumenAxonModel(modelId) && !hasLumenAccess) { pushRow({ kind: "error", @@ -926,14 +934,27 @@ export function App({ next[headerIndex] = updatedHeader; return next; }); - pushRow({ - kind: "info", - text: `Model switched to ${getModel(modelId).name}`, - }); + if (!options?.silent) { + pushRow({ + kind: "info", + text: `Model switched to ${getModel(modelId).name}`, + }); + } }, [has400kAccess, hasEidoBaseAccess, hasEidoProAccess, hasLumenAccess, pushRow], ); + // Resolve the plan-aware default once the catalog and the account plan are + // both known: free plans default to the catalog's free model, paid plans to + // the first catalog entry. Only an untouched static default is re-resolved — + // an explicit user pick is never overwritten. + useEffect(() => { + if (!catalogReady || !planLoaded) return; + if (settings.model !== DEFAULT_MODEL_ID) return; + const preferred = getDefaultModelId(activePlan); + if (preferred !== settings.model) switchModel(preferred, { silent: true }); + }, [activePlan, catalogReady, planLoaded, settings.model, switchModel]); + useEffect(() => { if (!activePlan) { return;