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
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
35 changes: 35 additions & 0 deletions src/api/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -274,6 +276,14 @@ export const DEFAULT_MODEL_ID = "zai/glm-5.3-flash";
*/
const managedModelIds = new Set<string>(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"]);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -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) {
Expand Down
11 changes: 11 additions & 0 deletions src/headless.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
canUseEidoProModels,
canUseLumenModels,
fetchDynamicModels,
getDefaultModelId,
getModel,
is400kAxonModel,
isEidoBaseAxonModel,
Expand Down Expand Up @@ -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}". ` +
Expand Down
39 changes: 30 additions & 9 deletions src/ui/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
canUseLumenModels,
fetchDynamicModels,
get232kAxonFallback,
getDefaultModelId,
getModel,
is400kAxonModel,
isEidoBaseAxonModel,
Expand Down Expand Up @@ -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);
Expand All @@ -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(() => {});
}, []);

Expand Down Expand Up @@ -882,7 +890,7 @@ export function App({
);

const switchModel = useCallback(
(modelId: string) => {
(modelId: string, options?: { silent?: boolean }) => {
if (isLumenAxonModel(modelId) && !hasLumenAccess) {
pushRow({
kind: "error",
Expand Down Expand Up @@ -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;
Expand Down