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
9 changes: 8 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
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.6.8",
"version": "6.7.0",
"description": "OrbCode CLI — agentic coding in your terminal, powered by Axon models by MatterAI",
"type": "module",
"bin": {
Expand Down
47 changes: 44 additions & 3 deletions src/api/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,32 @@ export const BUILTIN_AXON_MODELS: Record<string, AxonModel> = {
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,
},
};

/**
Expand All @@ -221,19 +247,34 @@ export const AXON_MODELS: Record<string, AxonModel> = {
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");
}
Expand Down
52 changes: 37 additions & 15 deletions src/core/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
Expand Down Expand Up @@ -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<void> {
if (this.sessionStarted) return
this.sessionStarted = true
Expand Down Expand Up @@ -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" })
Expand Down Expand Up @@ -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)
Expand Down
10 changes: 8 additions & 2 deletions src/headless.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import {
canUse400kContext,
canUseLumenModels,
getModel,
is400kAxonModel,
isLumenAxonModel,
isValidAxonModel,
usesAiSdk,
} from "./api/models.js"
Expand Down Expand Up @@ -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)
}
Expand Down
30 changes: 25 additions & 5 deletions src/ui/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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",
Expand All @@ -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) => {
Expand Down Expand Up @@ -2029,6 +2048,7 @@ export function App({
<ModelPicker
currentId={settings.model}
canUse400k={has400kAccess}
canUseLumen={hasLumenAccess}
onSelect={(modelId) => {
setModelPickerOpen(false);
switchModel(modelId);
Expand Down
13 changes: 10 additions & 3 deletions src/ui/components/ModelPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -11,6 +11,7 @@ const CONTEXT_WINDOW_ORDER = [200000, 400000]
interface ModelPickerProps {
currentId: string
canUse400k: boolean
canUseLumen: boolean
onSelect: (modelId: string) => void
onCancel: () => void
}
Expand All @@ -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
Expand Down Expand Up @@ -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 =
Expand All @@ -100,6 +106,7 @@ export function ModelPicker({ currentId, canUse400k, onSelect, onCancel }: Model
{index + 1}. {model.name}
{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>}
</Text>
{isSelected && (
<Box paddingLeft={5}>
Expand Down
34 changes: 34 additions & 0 deletions test/models.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
});
Loading