diff --git a/CHANGELOG.md b/CHANGELOG.md index e18ef7c..9e37f62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/package.json b/package.json index a0ca24d..af00d46 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/api/models.ts b/src/api/models.ts index 41cfe22..e353467 100644 --- a/src/api/models.ts +++ b/src/api/models.ts @@ -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, "") ?? ""; @@ -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-") || @@ -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"); } diff --git a/src/headless.ts b/src/headless.ts index 794b5a8..ca7b38d 100644 --- a/src/headless.ts +++ b/src/headless.ts @@ -1,8 +1,10 @@ import { canUse400kContext, + canUseEidoProModels, canUseLumenModels, getModel, is400kAxonModel, + isEidoProAxonModel, isLumenAxonModel, isValidAxonModel, usesAiSdk, @@ -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) diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 9483c13..9f160e0 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -27,10 +27,12 @@ import { BUILTIN_AXON_MODELS, DEFAULT_MODEL_ID, canUse400kContext, + canUseEidoProModels, canUseLumenModels, get200kAxonFallback, getModel, is400kAxonModel, + isEidoProAxonModel, isLumenAxonModel, isValidAxonModel, } from "../api/models.js"; @@ -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). @@ -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", @@ -850,7 +860,7 @@ export function App({ text: `Model switched to ${getModel(modelId).name}`, }); }, - [has400kAccess, hasLumenAccess, pushRow], + [has400kAccess, hasEidoProAccess, hasLumenAccess, pushRow], ); useEffect(() => { @@ -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) => { @@ -2048,6 +2065,7 @@ export function App({ { setModelPickerOpen(false); diff --git a/src/ui/components/ModelPicker.tsx b/src/ui/components/ModelPicker.tsx index 8aeeba4..661bd26 100644 --- a/src/ui/components/ModelPicker.tsx +++ b/src/ui/components/ModelPicker.tsx @@ -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 @@ -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 @@ -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 = @@ -103,10 +133,10 @@ export function ModelPicker({ currentId, canUse400k, canUseLumen, onSelect, onCa {isSelected ? "❯ " : " "} - {index + 1}. {model.name} + {index + 1}. {displayName(model)} {isCurrent && ✓ current} · {formatPrice(model)} - {showPlanBadge && · Pro Plus and Ultra only} + {showPlanBadge && · {planBadgeText}} {isSelected && ( diff --git a/test/models.test.ts b/test/models.test.ts index 2bfe46c..e8923c7 100644 --- a/test/models.test.ts +++ b/test/models.test.ts @@ -5,10 +5,12 @@ import { BUILTIN_AXON_MODELS, DEFAULT_MODEL_ID, canUse400kContext, + canUseEidoProModels, canUseLumenModels, get200kAxonFallback, getGatewayModelId, is400kAxonModel, + isEidoProAxonModel, isLumenAxonModel, } from "../src/api/models.js"; @@ -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); +});