From 297ca87cd23663a7a9d59d01c249be4f336a25a7 Mon Sep 17 00:00:00 2001 From: "matterai-app[bot]" Date: Wed, 29 Jul 2026 09:59:43 +0530 Subject: [PATCH 1/2] feat(models): add Axon Lumen 4 Code model support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the Axon Lumen 4 Code model in 200K and 400K context variants (axon-lumen-4-code-200k, axon-lumen-4-code-400k) with plan-gated access restricted to Pro Plus and Ultra subscriptions. - Define Lumen model entries in BUILTIN_AXON_MODELS with pricing and context window metadata - Add canUseLumenModels() and isLumenAxonModel() helpers alongside a shared normalizePlan() utility - Update UI model picker to lock Lumen models with a plan badge when the account tier doesn't include them - Apply automatic fallback to the default Eido model when a stored Lumen selection is loaded on an ineligible plan - Gate Lumen model usage in headless/CLI mode with a descriptive error message and exit - Extend is400kAxonModel() to cover Lumen 400k variants, and wire get200kAxonFallback() accordingly - Add tests for Lumen model definitions, plan restrictions, and identification helpers Also includes error message sanitization in agent.ts — introducing streamErrorLabel() and sanitizeErrorMessage() to prevent raw HTML or oversized upstream error bodies from leaking into the transcript UI. Applies the new sanitizer in the stream retry loop, the interrupt handler, and the compaction error path. --- CHANGELOG.md | 6 ++++ src/api/models.ts | 47 ++++++++++++++++++++++++++-- src/core/agent.ts | 52 ++++++++++++++++++++++--------- src/headless.ts | 10 ++++-- src/ui/App.tsx | 30 +++++++++++++++--- src/ui/components/ModelPicker.tsx | 13 ++++++-- test/models.test.ts | 34 ++++++++++++++++++++ 7 files changed, 164 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eede058..06e6578 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ 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). +## [Unreleased] + +### Added + +- **Axon Lumen 4 Code model support.** Added support for `axon-lumen-4-code` in 200K and 400K context variants (`axon-lumen-4-code-200k` and `axon-lumen-4-code-400k`). Lumen models are available on Pro Plus and Ultra plans. + ## [6.6.8] - 2026-07-25 ### Changed diff --git a/src/api/models.ts b/src/api/models.ts index 7016574..41cfe22 100644 --- a/src/api/models.ts +++ b/src/api/models.ts @@ -205,6 +205,32 @@ export const BUILTIN_AXON_MODELS: Record = { outputPrice: 0.0000045, free: false, }, + "axon-lumen-4-code-200k": { + id: "axon-lumen-4-code-200k", + gatewayModelId: "axon-lumen-4-code", + name: "Axon Lumen 4 (200K context)", + description: + "Axon Lumen 4 Code is the ultra-intelligent frontier model for complex agentic coding tasks and general intelligence.", + contextWindow: 200000, + maxOutputTokens: 128000, + supportsImages: true, + inputPrice: 0.000005, + outputPrice: 0.000025, + free: false, + }, + "axon-lumen-4-code-400k": { + id: "axon-lumen-4-code-400k", + gatewayModelId: "axon-lumen-4-code", + name: "Axon Lumen 4 (400K context)", + description: + "Axon Lumen 4 Code is the ultra-intelligent frontier model for complex agentic coding tasks and general intelligence.", + contextWindow: 400000, + maxOutputTokens: 128000, + supportsImages: true, + inputPrice: 0.000005, + outputPrice: 0.000025, + free: false, + }, }; /** @@ -221,19 +247,34 @@ export const AXON_MODELS: Record = { 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"]); + +function normalizePlan(plan?: string): string { + return plan?.toLowerCase().replace(/[^a-z0-9]/g, "") ?? ""; +} /** Whether an account plan includes Axon's 400k context options. */ export function canUse400kContext(plan?: string): boolean { - const normalizedPlan = plan?.toLowerCase().replace(/[^a-z0-9]/g, "") ?? ""; - return EXTENDED_CONTEXT_PLANS.has(normalizedPlan); + return EXTENDED_CONTEXT_PLANS.has(normalizePlan(plan)); +} + +/** Whether an account plan includes the Axon Lumen models. */ +export function canUseLumenModels(plan?: string): boolean { + return LUMEN_MODEL_PLANS.has(normalizePlan(plan)); } export function is400kAxonModel(modelId: string): boolean { return ( - modelId.startsWith("axon-eido-3-code-") && modelId.endsWith("-400k") + (modelId.startsWith("axon-eido-3-code-") || + modelId.startsWith("axon-lumen-4-code-")) && + modelId.endsWith("-400k") ); } +export function isLumenAxonModel(modelId: string): boolean { + return modelId.startsWith("axon-lumen-"); +} + export function get200kAxonFallback(modelId: string): string { return modelId.replace(/-400k$/, "-200k"); } diff --git a/src/core/agent.ts b/src/core/agent.ts index 6971513..e17d832 100644 --- a/src/core/agent.ts +++ b/src/core/agent.ts @@ -61,6 +61,28 @@ function retryBackoffMs(attempt: number): number { return Math.min(500 * 2 ** attempt, 8000) } +/** Short, user-facing label for a stream failure. Upstream error bodies can be + * raw HTML (e.g. an nginx 502 page), so never surface `error.message` — only + * the numeric HTTP status when one is available. */ +function streamErrorLabel(error: unknown): string { + const err = error as { status?: number; code?: number | string } + const status = Number(err?.status ?? err?.code) + return Number.isFinite(status) && status !== 0 ? `Connection failed (${status})` : "Connection failed" +} + +/** Error message safe to show in the transcript. Upstream gateways often answer + * failures with raw HTML pages or huge multi-line bodies; collapse those to a + * clean status label instead of dumping markup into the UI. */ +function sanitizeErrorMessage(error: unknown): string { + const err = error as { status?: number; code?: number | string; message?: unknown } + const message = String(err?.message ?? error ?? "Unknown error") + const clean = message.replace(/\s+/g, " ").trim() + const looksLikeHtml = /<\/?[a-z][^>]*>/i.test(clean) + if (!looksLikeHtml && clean.length <= 200) return clean + const status = Number(err?.status ?? err?.code) + return Number.isFinite(status) && status !== 0 ? `Request failed (${status})` : "Request failed" +} + /** Sleep that settles early (rejecting with AbortError) if the signal fires, so * a user interrupt isn't stuck waiting out a retry backoff. */ function interruptibleDelay(ms: number, signal: AbortSignal): Promise { @@ -749,19 +771,19 @@ User time zone: ${timeZone}, UTC${timeZoneOffsetStr}` role: "user", content: "System reminder: The user interrupted this response before it finished.", }) - } else { - onEvent({ type: "error", message: (error as Error).message }) - } - } finally { - this.abortController = undefined - this.recordTranscriptEvent({ type: "turn-end" }) - this.persist() - onEvent({ type: "turn-end" }) + } else { + onEvent({ type: "error", message: sanitizeErrorMessage(error) }) } + } finally { + this.abortController = undefined + this.recordTranscriptEvent({ type: "turn-end" }) + this.persist() + onEvent({ type: "turn-end" }) } +} - /** Fire SessionStart once per instance, stashing any injected context for - * the first user message. */ +/** Fire SessionStart once per instance, stashing any injected context for + * the first user message. */ private async maybeFireSessionStart(): Promise { if (this.sessionStarted) return this.sessionStarted = true @@ -908,10 +930,10 @@ User time zone: ${timeZone}, UTC${timeZoneOffsetStr}` } } catch (error) { if ((error as Error).name === "AbortError" || signal.aborted) { - onEvent({ type: "error", message: "Compaction interrupted; history left unchanged." }) - } else { - onEvent({ type: "error", message: (error as Error).message }) - } + onEvent({ type: "error", message: "Compaction interrupted; history left unchanged." }) + } else { + onEvent({ type: "error", message: sanitizeErrorMessage(error) }) + } } finally { this.abortController = undefined this.recordTranscriptEvent({ type: "turn-end" }) @@ -954,7 +976,7 @@ User time zone: ${timeZone}, UTC${timeZoneOffsetStr}` const delayMs = retryBackoffMs(attempt) this.options.callbacks.onEvent({ type: "system", - message: `Connection to the model failed (${(error as Error).message}). Retrying ${attempt + 1}/${MAX_STREAM_RETRIES} in ${Math.ceil(delayMs / 1000)}s…`, + message: `${streamErrorLabel(error)}. Retrying ${attempt + 1}/${MAX_STREAM_RETRIES} in ${Math.ceil(delayMs / 1000)}s…`, isError: false, }) await interruptibleDelay(delayMs, signal) diff --git a/src/headless.ts b/src/headless.ts index e57f899..794b5a8 100644 --- a/src/headless.ts +++ b/src/headless.ts @@ -1,7 +1,9 @@ import { canUse400kContext, + canUseLumenModels, getModel, is400kAxonModel, + isLumenAxonModel, isValidAxonModel, usesAiSdk, } from "./api/models.js" @@ -40,10 +42,14 @@ export async function runHeadless( process.exit(1) } - if (token && is400kAxonModel(settings.model)) { + if (token && (isLumenAxonModel(settings.model) || is400kAxonModel(settings.model))) { const profile = await fetchProfile(token) const plan = profile.plan ?? profile.tieredUsage?.plan - if (!canUse400kContext(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 (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 bccb866..9483c13 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -25,10 +25,13 @@ import { } from "../branding.js"; import { BUILTIN_AXON_MODELS, + DEFAULT_MODEL_ID, canUse400kContext, + canUseLumenModels, get200kAxonFallback, getModel, is400kAxonModel, + isLumenAxonModel, isValidAxonModel, } from "../api/models.js"; import { LoginView } from "./LoginView.js"; @@ -453,6 +456,7 @@ export function App({ } | null>(null); const activePlan = usage?.plan ?? usage?.tieredUsage?.plan; const has400kAccess = canUse400kContext(activePlan); + const hasLumenAccess = canUseLumenModels(activePlan); // Refresh plan/usage from /axoncode/profile (shown below the chat box). const refreshUsage = useCallback(() => { @@ -823,6 +827,13 @@ export function App({ const switchModel = useCallback( (modelId: string) => { + if (isLumenAxonModel(modelId) && !hasLumenAccess) { + pushRow({ + kind: "error", + text: "Axon Lumen models are only available on Pro Plus and Ultra plans.", + }); + return; + } if (is400kAxonModel(modelId) && !has400kAccess) { pushRow({ kind: "error", @@ -839,16 +850,24 @@ export function App({ text: `Model switched to ${getModel(modelId).name}`, }); }, - [has400kAccess, pushRow], + [has400kAccess, hasLumenAccess, pushRow], ); useEffect(() => { - if (!activePlan || has400kAccess || !is400kAxonModel(settings.model)) { + if (!activePlan) { return; } - - switchModel(get200kAxonFallback(settings.model)); - }, [activePlan, has400kAccess, settings.model, switchModel]); + // A stored Lumen selection on a plan without access falls back to the + // default Eido model; its 400k variant is covered by the Lumen check + // first since the 200k Lumen fallback would still be locked. + if (isLumenAxonModel(settings.model) && !hasLumenAccess) { + switchModel(DEFAULT_MODEL_ID); + return; + } + if (!has400kAccess && is400kAxonModel(settings.model)) { + switchModel(get200kAxonFallback(settings.model)); + } + }, [activePlan, has400kAccess, hasLumenAccess, settings.model, switchModel]); const switchTheme = useCallback( (mode: OrbCodeThemeMode) => { @@ -2029,6 +2048,7 @@ export function App({ { setModelPickerOpen(false); switchModel(modelId); diff --git a/src/ui/components/ModelPicker.tsx b/src/ui/components/ModelPicker.tsx index 5bf10f7..8aeeba4 100644 --- a/src/ui/components/ModelPicker.tsx +++ b/src/ui/components/ModelPicker.tsx @@ -2,7 +2,7 @@ import React, { useState } from "react" import { Box, Text, useInput } from "../primitives.js" import { COLORS } from "../../branding.js" -import { BUILTIN_AXON_MODELS, type AxonModel } from "../../api/models.js" +import { BUILTIN_AXON_MODELS, isLumenAxonModel, type AxonModel } from "../../api/models.js" import { PopoverBox } from "./PopoverBox.js" const VISIBLE_ROWS = 6 @@ -11,6 +11,7 @@ const CONTEXT_WINDOW_ORDER = [200000, 400000] interface ModelPickerProps { currentId: string canUse400k: boolean + canUseLumen: boolean onSelect: (modelId: string) => void onCancel: () => void } @@ -21,14 +22,15 @@ function formatPrice(model: AxonModel): string { return `${perMillion(model.inputPrice)} in / ${perMillion(model.outputPrice)} out per 1M tokens` } -export function ModelPicker({ currentId, canUse400k, onSelect, onCancel }: ModelPickerProps) { +export function ModelPicker({ currentId, canUse400k, 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) }) - const isLocked = (model: AxonModel) => model.contextWindow === 400000 && !canUse400k + const isLocked = (model: AxonModel) => + (model.contextWindow === 400000 && !canUse400k) || (isLumenAxonModel(model.id) && !canUseLumen) 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 @@ -79,6 +81,10 @@ export function ModelPicker({ currentId, canUse400k, onSelect, onCancel }: Model const isSelected = index === selected 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) const showContextHeader = i === 0 || visible[i - 1].contextWindow !== model.contextWindow const contextLabel = `Context: ${model.contextWindow / 1000}k` const contextAccessLabel = @@ -100,6 +106,7 @@ export function ModelPicker({ currentId, canUse400k, onSelect, onCancel }: Model {index + 1}. {model.name} {isCurrent && ✓ current} · {formatPrice(model)} + {showPlanBadge && · Pro Plus and Ultra only} {isSelected && ( diff --git a/test/models.test.ts b/test/models.test.ts index 742d521..2bfe46c 100644 --- a/test/models.test.ts +++ b/test/models.test.ts @@ -5,9 +5,11 @@ import { BUILTIN_AXON_MODELS, DEFAULT_MODEL_ID, canUse400kContext, + canUseLumenModels, get200kAxonFallback, getGatewayModelId, is400kAxonModel, + isLumenAxonModel, } from "../src/api/models.js"; for (const tier of ["pro", "mini"] as const) { @@ -45,11 +47,43 @@ test("400k context is limited to Pro Plus and Ultra plans", () => { } }); +test("Axon Lumen 4 exposes 200k and 400k local options", () => { + const baseId = "axon-lumen-4-code"; + const model200k = BUILTIN_AXON_MODELS[`${baseId}-200k`]; + const model400k = BUILTIN_AXON_MODELS[`${baseId}-400k`]; + + assert.equal(model200k.contextWindow, 200000); + assert.equal(model400k.contextWindow, 400000); + assert.equal(getGatewayModelId(model200k), baseId); + assert.equal(getGatewayModelId(model400k), baseId); +}); + test("restricted Axon models map to their 200k variants", () => { assert.equal(is400kAxonModel("axon-eido-3-code-mini-400k"), true); + assert.equal(is400kAxonModel("axon-lumen-4-code-400k"), true); assert.equal(is400kAxonModel("third-party-model-400k"), false); + assert.equal( + get200kAxonFallback("axon-lumen-4-code-400k"), + "axon-lumen-4-code-200k", + ); assert.equal( get200kAxonFallback("axon-eido-3-code-pro-400k"), "axon-eido-3-code-pro-200k", ); }); + +test("Lumen models are limited to Pro Plus and Ultra plans", () => { + for (const plan of ["Pro Plus", "pro_plus", "pro-plus", "ULTRA"]) { + assert.equal(canUseLumenModels(plan), true); + } + for (const plan of [undefined, "free", "Pro", "Enterprise"]) { + assert.equal(canUseLumenModels(plan), false); + } +}); + +test("isLumenAxonModel identifies every Lumen variant", () => { + assert.equal(isLumenAxonModel("axon-lumen-4-code-200k"), true); + assert.equal(isLumenAxonModel("axon-lumen-4-code-400k"), true); + assert.equal(isLumenAxonModel("axon-eido-3-code-pro-200k"), false); + assert.equal(isLumenAxonModel("axon-eido-3-flash"), false); +}); From 822de8da940ce472564793b1e85c3745e27b0b00 Mon Sep 17 00:00:00 2001 From: "matterai-app[bot]" Date: Wed, 29 Jul 2026 10:06:00 +0530 Subject: [PATCH 2/2] release: v6.7.0 --- CHANGELOG.md | 5 +++-- package.json | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 06e6578..fd2fcb1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ 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). -## [Unreleased] +## [6.7.0] - 2026-07-29 ### Added @@ -856,7 +856,8 @@ non-interactive mode. - Cross-platform shell detection and path handling in `execute_command` (Windows vs POSIX, `cmd` vs `bash`, etc.). -[Unreleased]: https://github.com/MatterAIOrg/OrbCode/compare/v6.6.7...HEAD +[Unreleased]: https://github.com/MatterAIOrg/OrbCode/compare/v6.7.0...HEAD +[6.7.0]: https://github.com/MatterAIOrg/OrbCode/compare/v6.6.8...v6.7.0 [6.6.7]: https://github.com/MatterAIOrg/OrbCode/compare/v0.6.0...v6.6.7 [0.6.0]: https://github.com/MatterAIOrg/OrbCode/compare/v0.5.10...v0.6.0 [0.5.10]: https://github.com/MatterAIOrg/OrbCode/compare/v0.5.8...v0.5.10 diff --git a/package.json b/package.json index 1e7320e..002a080 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@matterailab/orbcode", - "version": "6.6.8", + "version": "6.7.0", "description": "OrbCode CLI — agentic coding in your terminal, powered by Axon models by MatterAI", "type": "module", "bin": {