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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [6.7.2] - 2026-08-02

### Changed

- **Axon Eido 3 Pro is gated to Pro and above plans.** The model picker now
locks the Eido 3 Pro 200k option on the free plan with a "Pro and above
only" badge, and keeps the 400k option on Pro Plus and Ultra. Selecting an
Eido 3 Pro model on an ineligible plan emits an error row, headless mode
exits with a descriptive message, and a stored Eido 3 Pro selection on an
ineligible plan auto-falls back to the default Eido model.

## [6.7.1] - 2026-07-31

### Added
Expand Down
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.7.1",
"version": "6.7.2",
"description": "OrbCode CLI — agentic coding in your terminal, powered by Axon models by MatterAI",
"type": "module",
"bin": {
Expand Down
10 changes: 10 additions & 0 deletions src/api/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,7 @@ export const DEFAULT_MODEL_ID = "axon-eido-3-code-mini-200k";

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"]);

function normalizePlan(plan?: string): string {
return plan?.toLowerCase().replace(/[^a-z0-9]/g, "") ?? "";
Expand All @@ -263,6 +264,11 @@ export function canUseLumenModels(plan?: string): boolean {
return LUMEN_MODEL_PLANS.has(normalizePlan(plan));
}

/** Whether an account plan includes the Axon Eido 3 Pro models. */
export function canUseEidoProModels(plan?: string): boolean {
return EIDO_PRO_MODEL_PLANS.has(normalizePlan(plan));
}

export function is400kAxonModel(modelId: string): boolean {
return (
(modelId.startsWith("axon-eido-3-code-") ||
Expand All @@ -275,6 +281,10 @@ export function isLumenAxonModel(modelId: string): boolean {
return modelId.startsWith("axon-lumen-");
}

export function isEidoProAxonModel(modelId: string): boolean {
return modelId.startsWith("axon-eido-3-code-pro");
}

export function get200kAxonFallback(modelId: string): string {
return modelId.replace(/-400k$/, "-200k");
}
Expand Down
13 changes: 12 additions & 1 deletion src/headless.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import {
canUse400kContext,
canUseEidoProModels,
canUseLumenModels,
getModel,
is400kAxonModel,
isEidoProAxonModel,
isLumenAxonModel,
isValidAxonModel,
usesAiSdk,
Expand Down Expand Up @@ -42,13 +44,22 @@ export async function runHeadless(
process.exit(1)
}

if (token && (isLumenAxonModel(settings.model) || is400kAxonModel(settings.model))) {
if (
token &&
(isLumenAxonModel(settings.model) ||
isEidoProAxonModel(settings.model) ||
is400kAxonModel(settings.model))
) {
const profile = await fetchProfile(token)
const plan = profile.plan ?? profile.tieredUsage?.plan
if (isLumenAxonModel(settings.model) && !canUseLumenModels(plan)) {
console.error("Axon Lumen models are only available on Pro Plus and Ultra plans.")
process.exit(1)
}
if (isEidoProAxonModel(settings.model) && !canUseEidoProModels(plan)) {
console.error("Axon Eido 3 Pro models are only available on Pro and above plans.")
process.exit(1)
}
if (is400kAxonModel(settings.model) && !canUse400kContext(plan)) {
console.error("400k context is only available on Pro Plus and Ultra plans. Use the matching -200k model.")
process.exit(1)
Expand Down
22 changes: 20 additions & 2 deletions src/ui/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,12 @@ import {
BUILTIN_AXON_MODELS,
DEFAULT_MODEL_ID,
canUse400kContext,
canUseEidoProModels,
canUseLumenModels,
get200kAxonFallback,
getModel,
is400kAxonModel,
isEidoProAxonModel,
isLumenAxonModel,
isValidAxonModel,
} from "../api/models.js";
Expand Down Expand Up @@ -456,6 +458,7 @@ export function App({
} | null>(null);
const activePlan = usage?.plan ?? usage?.tieredUsage?.plan;
const has400kAccess = canUse400kContext(activePlan);
const hasEidoProAccess = canUseEidoProModels(activePlan);
const hasLumenAccess = canUseLumenModels(activePlan);

// Refresh plan/usage from /axoncode/profile (shown below the chat box).
Expand Down Expand Up @@ -834,6 +837,13 @@ export function App({
});
return;
}
if (isEidoProAxonModel(modelId) && !hasEidoProAccess) {
pushRow({
kind: "error",
text: "Axon Eido 3 Pro models are only available on Pro and above plans.",
});
return;
}
if (is400kAxonModel(modelId) && !has400kAccess) {
pushRow({
kind: "error",
Expand All @@ -850,7 +860,7 @@ export function App({
text: `Model switched to ${getModel(modelId).name}`,
});
},
[has400kAccess, hasLumenAccess, pushRow],
[has400kAccess, hasEidoProAccess, hasLumenAccess, pushRow],
);

useEffect(() => {
Expand All @@ -864,10 +874,17 @@ export function App({
switchModel(DEFAULT_MODEL_ID);
return;
}
// A stored Eido Pro selection on a plan without access falls back to the
// default Eido model; its 400k variant is covered by the Eido Pro check
// first since the 200k Eido Pro fallback would still be locked.
if (isEidoProAxonModel(settings.model) && !hasEidoProAccess) {
switchModel(DEFAULT_MODEL_ID);
return;
}
if (!has400kAccess && is400kAxonModel(settings.model)) {
switchModel(get200kAxonFallback(settings.model));
}
}, [activePlan, has400kAccess, hasLumenAccess, settings.model, switchModel]);
}, [activePlan, has400kAccess, hasEidoProAccess, hasLumenAccess, settings.model, switchModel]);

const switchTheme = useCallback(
(mode: OrbCodeThemeMode) => {
Expand Down Expand Up @@ -2048,6 +2065,7 @@ export function App({
<ModelPicker
currentId={settings.model}
canUse400k={has400kAccess}
canUseEidoPro={hasEidoProAccess}
canUseLumen={hasLumenAccess}
onSelect={(modelId) => {
setModelPickerOpen(false);
Expand Down
50 changes: 40 additions & 10 deletions src/ui/components/ModelPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,29 @@ import React, { useState } from "react"
import { Box, Text, useInput } from "../primitives.js"

import { COLORS } from "../../branding.js"
import { BUILTIN_AXON_MODELS, isLumenAxonModel, type AxonModel } from "../../api/models.js"
import { BUILTIN_AXON_MODELS, isEidoProAxonModel, isLumenAxonModel, type AxonModel } from "../../api/models.js"
import { PopoverBox } from "./PopoverBox.js"

const VISIBLE_ROWS = 6
const CONTEXT_WINDOW_ORDER = [200000, 400000]
// Display order within a context-window group: Flash, Mini, Pro, Lumen.
const DISPLAY_ORDER = [
"axon-eido-3-flash",
"axon-eido-3-code-mini",
"axon-eido-3-code-pro",
"axon-lumen-4-code",
]
function displayRank(modelId: string): number {
for (let i = 0; i < DISPLAY_ORDER.length; i += 1) {
if (modelId === DISPLAY_ORDER[i] || modelId.startsWith(`${DISPLAY_ORDER[i]}-`)) return i
}
return DISPLAY_ORDER.length
}

interface ModelPickerProps {
currentId: string
canUse400k: boolean
canUseEidoPro: boolean
canUseLumen: boolean
onSelect: (modelId: string) => void
onCancel: () => void
Expand All @@ -22,15 +36,28 @@ function formatPrice(model: AxonModel): string {
return `${perMillion(model.inputPrice)} in / ${perMillion(model.outputPrice)} out per 1M tokens`
}

export function ModelPicker({ currentId, canUse400k, canUseLumen, onSelect, onCancel }: ModelPickerProps) {
// The context-window header above each group already disambiguates 200k vs
// 400k, so strip the "(NNNK context)" suffix from the row label to avoid
// repeating it. The canonical `model.name` keeps the suffix for the StatusBar
// and /status where the context window isn't shown separately.
function displayName(model: AxonModel): string {
return model.name.replace(/\s*\(\d+K context\)$/, "")
}

export function ModelPicker({ currentId, canUse400k, canUseEidoPro, canUseLumen, onSelect, onCancel }: ModelPickerProps) {
const models = Object.values(BUILTIN_AXON_MODELS).sort((a, b) => {
const aIndex = CONTEXT_WINDOW_ORDER.indexOf(a.contextWindow)
const bIndex = CONTEXT_WINDOW_ORDER.indexOf(b.contextWindow)
return (aIndex === -1 ? CONTEXT_WINDOW_ORDER.length : aIndex) -
(bIndex === -1 ? CONTEXT_WINDOW_ORDER.length : bIndex)
if (aIndex !== bIndex) {
return (aIndex === -1 ? CONTEXT_WINDOW_ORDER.length : aIndex) -
(bIndex === -1 ? CONTEXT_WINDOW_ORDER.length : bIndex)
}
return displayRank(a.id) - displayRank(b.id)
})
const isLocked = (model: AxonModel) =>
(model.contextWindow === 400000 && !canUse400k) || (isLumenAxonModel(model.id) && !canUseLumen)
(model.contextWindow === 400000 && !canUse400k) ||
(isLumenAxonModel(model.id) && !canUseLumen) ||
(isEidoProAxonModel(model.id) && !canUseEidoPro)
const nextSelectableIndex = (from: number, direction: 1 | -1) => {
for (let offset = 1; offset <= models.length; offset += 1) {
const candidate = (from + direction * offset + models.length) % models.length
Expand Down Expand Up @@ -82,9 +109,12 @@ export function ModelPicker({ currentId, canUse400k, canUseLumen, onSelect, onCa
const isCurrent = model.id === currentId
const locked = isLocked(model)
// The 400k group header already carries the plan note, so only badge
// Lumen rows whose lock isn't explained by the context header.
const showPlanBadge =
isLumenAxonModel(model.id) && !canUseLumen && !(model.contextWindow === 400000 && !canUse400k)
// rows whose lock isn't explained by the context header.
const planRestricted =
(isLumenAxonModel(model.id) && !canUseLumen) ||
(isEidoProAxonModel(model.id) && !canUseEidoPro)
const showPlanBadge = planRestricted && !(model.contextWindow === 400000 && !canUse400k)
const planBadgeText = isLumenAxonModel(model.id) ? "Pro Plus and Ultra only" : "Pro and above only"
const showContextHeader = i === 0 || visible[i - 1].contextWindow !== model.contextWindow
const contextLabel = `Context: ${model.contextWindow / 1000}k`
const contextAccessLabel =
Expand All @@ -103,10 +133,10 @@ export function ModelPicker({ currentId, canUse400k, canUseLumen, onSelect, onCa
<Box flexDirection="column">
<Text color={locked ? COLORS.dim : isSelected ? COLORS.accent : undefined}>
{isSelected ? "❯ " : " "}
{index + 1}. {model.name}
{index + 1}. {displayName(model)}
{isCurrent && <Text color={COLORS.success}> ✓ current</Text>}
<Text color={COLORS.dim}> · {formatPrice(model)}</Text>
{showPlanBadge && <Text color={COLORS.dim}> · Pro Plus and Ultra only</Text>}
{showPlanBadge && <Text color={COLORS.dim}> · {planBadgeText}</Text>}
</Text>
{isSelected && (
<Box paddingLeft={5}>
Expand Down
18 changes: 18 additions & 0 deletions test/models.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ import {
BUILTIN_AXON_MODELS,
DEFAULT_MODEL_ID,
canUse400kContext,
canUseEidoProModels,
canUseLumenModels,
get200kAxonFallback,
getGatewayModelId,
is400kAxonModel,
isEidoProAxonModel,
isLumenAxonModel,
} from "../src/api/models.js";

Expand Down Expand Up @@ -87,3 +89,19 @@ test("isLumenAxonModel identifies every Lumen variant", () => {
assert.equal(isLumenAxonModel("axon-eido-3-code-pro-200k"), false);
assert.equal(isLumenAxonModel("axon-eido-3-flash"), false);
});

test("Eido 3 Pro models are limited to Pro and above plans", () => {
for (const plan of ["Pro", "pro", "Pro Plus", "pro_plus", "ULTRA"]) {
assert.equal(canUseEidoProModels(plan), true);
}
for (const plan of [undefined, "free", "Enterprise"]) {
assert.equal(canUseEidoProModels(plan), false);
}
});

test("isEidoProAxonModel identifies every Eido Pro variant", () => {
assert.equal(isEidoProAxonModel("axon-eido-3-code-pro-200k"), true);
assert.equal(isEidoProAxonModel("axon-eido-3-code-pro-400k"), true);
assert.equal(isEidoProAxonModel("axon-eido-3-code-mini-200k"), false);
assert.equal(isEidoProAxonModel("axon-lumen-4-code-200k"), false);
});
Loading