diff --git a/CHANGELOG.md b/CHANGELOG.md index eede058..fd2fcb1 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). +## [6.7.0] - 2026-07-29 + +### 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 @@ -850,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": { 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); +});