Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
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
45 changes: 43 additions & 2 deletions apps/daemon/src/integrations/vela-errors.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
export type AmrAccountErrorCode = 'AMR_AUTH_REQUIRED' | 'AMR_INSUFFICIENT_BALANCE';
export type AmrAccountErrorCode =
| 'AMR_AUTH_REQUIRED'
| 'AMR_INSUFFICIENT_BALANCE'
| 'AMR_TIER_UPGRADE_REQUIRED';

export interface AmrAccountFailure {
code: AmrAccountErrorCode;
message: string;
action: 'relogin' | 'recharge';
action: 'relogin' | 'recharge' | 'upgrade';
actionUrl?: string;
}

Expand All @@ -27,6 +30,12 @@ const AMR_AUTH_REQUIRED_MESSAGE =
const AMR_INSUFFICIENT_BALANCE_MESSAGE =
`AMR Cloud reported insufficient balance for this model. Recharge your AMR wallet at ${DEFAULT_AMR_RECHARGE_URL}, then retry this run.`;

const AMR_TIER_UPGRADE_REQUIRED_MESSAGE =
'Your current AMR plan does not include this model or request type. Upgrade your AMR plan, or switch to an available model and retry.';

const AMR_TIER_REQUEST_KIND_NOT_ENTITLED_MESSAGE =
'Your current AMR plan does not include this request type yet. Upgrade your AMR plan, or switch to a supported model and retry.';

function normalizeFailureText(text: string): string {
return String(text || '').toLowerCase();
}
Expand Down Expand Up @@ -74,6 +83,22 @@ export function classifyAmrAccountFailureDetails(details: unknown): AmrAccountFa
};
}

if (code === 'tier_model_not_entitled') {
return {
code: 'AMR_TIER_UPGRADE_REQUIRED',
Comment thread
mrcfps marked this conversation as resolved.
Comment thread
mrcfps marked this conversation as resolved.
Comment thread
mrcfps marked this conversation as resolved.
message: AMR_TIER_UPGRADE_REQUIRED_MESSAGE,
action: 'upgrade',
};
}

if (code === 'tier_request_kind_not_entitled') {
return {
code: 'AMR_TIER_UPGRADE_REQUIRED',
message: AMR_TIER_REQUEST_KIND_NOT_ENTITLED_MESSAGE,
action: 'upgrade',
};
}

return null;
}

Expand Down Expand Up @@ -114,6 +139,22 @@ export function classifyAmrAccountFailure(text: string): AmrAccountFailure | nul
};
}

if (value.includes('tier_model_not_entitled')) {
return {
code: 'AMR_TIER_UPGRADE_REQUIRED',
message: AMR_TIER_UPGRADE_REQUIRED_MESSAGE,
action: 'upgrade',
};
}

if (value.includes('tier_request_kind_not_entitled')) {
return {
code: 'AMR_TIER_UPGRADE_REQUIRED',
message: AMR_TIER_REQUEST_KIND_NOT_ENTITLED_MESSAGE,
action: 'upgrade',
};
}

if (
value.includes('auth_required') ||
value.includes('authentication required') ||
Expand Down
2 changes: 2 additions & 0 deletions apps/daemon/src/integrations/vela.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ const AMR_ENTRY_SOURCES: ReadonlySet<TrackingAmrEntrySource> = new Set([
'handoff_amr_website',
'chat_error_authorize_retry',
'chat_error_recharge',
'chat_error_upgrade',
'chat_balance_gate_upgrade',
'home_balance_gate_upgrade',
'chat_low_balance_warn_recharge',
Expand Down Expand Up @@ -80,6 +81,7 @@ const AMR_ENTRY_SOURCE_PAGE_BY_SOURCE: Record<
handoff_amr_website: 'artifact',
chat_error_authorize_retry: 'chat_panel',
chat_error_recharge: 'chat_panel',
chat_error_upgrade: 'chat_panel',
chat_balance_gate_upgrade: 'chat_panel',
home_balance_gate_upgrade: 'home',
chat_low_balance_warn_recharge: 'chat_panel',
Expand Down
20 changes: 19 additions & 1 deletion apps/daemon/src/runtimes/defs/amr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,21 +164,39 @@ function withVelaModelPriceFields(
model: RuntimeModelOption,
item: unknown,
): RuntimeModelOption {
const enabled = extractOptionalBoolean(item, ['enabled']);
const isDefault = extractOptionalBoolean(item, ['default']);
const inputPriceUsdPerMillion = extractInputPriceUsdPerMillion(item);
const outputPriceUsdPerMillion = extractOutputPriceUsdPerMillion(item);
if (
enabled === undefined &&
isDefault === undefined &&
inputPriceUsdPerMillion === undefined &&
outputPriceUsdPerMillion === undefined
) {
return model;
}
return {
...model,
...(enabled === undefined ? {} : { enabled }),
Comment thread
mrcfps marked this conversation as resolved.
...(isDefault === undefined ? {} : { default: isDefault }),
...(inputPriceUsdPerMillion === undefined ? {} : { inputPriceUsdPerMillion }),
...(outputPriceUsdPerMillion === undefined ? {} : { outputPriceUsdPerMillion }),
};
}

function extractOptionalBoolean(
item: unknown,
keys: string[],
): boolean | undefined {
if (!isRecord(item)) return undefined;
for (const key of keys) {
const value = item[key];
if (typeof value === 'boolean') return value;
}
return undefined;
}

function extractInputPriceUsdPerMillion(item: unknown): number | undefined {
if (!isRecord(item)) return undefined;
const direct = firstFinitePrice([
Expand Down Expand Up @@ -480,7 +498,7 @@ export async function fetchVelaRemoteModelsWithRetry(
let lastError: unknown = null;
for (let attempt = 0; attempt <= AMR_MODELS_RETRY_DELAYS_MS.length; attempt += 1) {
try {
const { stdout } = await execAgentFile(resolvedBin, ['model', 'list', '--format', 'json'], {
const { stdout } = await execAgentFile(resolvedBin, ['model', 'list', '--all', '--format', 'json'], {
Comment thread
mrcfps marked this conversation as resolved.
Comment thread
mrcfps marked this conversation as resolved.
env,
timeout: AMR_MODELS_TIMEOUT_MS,
maxBuffer: 1024 * 1024,
Expand Down
28 changes: 22 additions & 6 deletions apps/daemon/src/runtimes/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,16 @@ function liveModelCacheKey(agentId: string, scope?: string | null): string {

export function rememberLiveModels(agentId: string, models: RuntimeModelOption[], scope?: string | null) {
if (!Array.isArray(models)) return;
const ids = models
const orderedModels = [...models].sort((a, b) => {
const aDisabled = a?.enabled === false;
const bDisabled = b?.enabled === false;
if (aDisabled !== bDisabled) return aDisabled ? 1 : -1;
const aDefault = a?.default === true;
const bDefault = b?.default === true;
if (aDefault !== bDefault) return aDefault ? -1 : 1;
return 0;
});
const ids = orderedModels
.map((m) => m && m.id)
.filter((id) => typeof id === 'string');
Comment thread
mrcfps marked this conversation as resolved.
Outdated
Comment thread
mrcfps marked this conversation as resolved.
Outdated
const key = liveModelCacheKey(agentId, scope);
Expand All @@ -31,6 +40,14 @@ export function rememberLiveModels(agentId: string, models: RuntimeModelOption[]
liveModelOrder.set(key, ids);
}

export function resolveDefaultModelFromOptions(
models: RuntimeModelOption[],
): string | null {
const candidates = models.filter((model) => model?.id && model.enabled !== false);
const defaultModel = candidates.find((model) => model.default === true);
return defaultModel?.id ?? candidates[0]?.id ?? null;
}

export function getRememberedLiveModels(agentId: string, scope?: string | null): RuntimeModelOption[] {
const ids = liveModelOrder.get(liveModelCacheKey(agentId, scope)) ?? [];
return ids.map((id) => ({ id, label: id }));
Expand Down Expand Up @@ -81,12 +98,11 @@ export function resolveModelForAgent(
}
const fallbacks = Array.isArray(def.fallbackModels) ? def.fallbackModels : [];
if (fallbacks.some((m) => m.id === 'default')) return resolved;
const liveModels = liveModelOrder.get(liveModelCacheKey(def.id, liveModelScope)) ?? [];
const firstLive = liveModels[0];
if (firstLive) return firstLive;
const liveModels = getRememberedLiveModels(def.id, liveModelScope);
const defaultLive = resolveDefaultModelFromOptions(liveModels);
if (defaultLive) return defaultLive;
if (fallbacks.length === 0) return resolved;
const firstFallback = fallbacks[0];
return firstFallback ? firstFallback.id : resolved;
return resolveDefaultModelFromOptions(fallbacks) ?? resolved;
}

// Permit user-typed model ids that didn't appear in either the live
Expand Down
2 changes: 2 additions & 0 deletions apps/daemon/src/runtimes/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ export type RuntimeEnv = NodeJS.ProcessEnv | Record<string, string>;
export type RuntimeModelOption = {
id: string;
label: string;
enabled?: boolean;
default?: boolean;
inputPriceUsdPerMillion?: number;
outputPriceUsdPerMillion?: number;
};
Expand Down
28 changes: 24 additions & 4 deletions apps/daemon/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ import {
getRememberedLiveModels,
preferFreshLiveModels,
rememberLiveModels,
resolveDefaultModelFromOptions,
resolveModelForAgent,
} from './runtimes/models.js';
import { loadMmdRouteLaunchEnv } from './runtimes/mmd-routes.js';
Expand Down Expand Up @@ -4593,6 +4594,11 @@ export async function startServer({
process.env,
requestedLiveModelScope,
);
const hasDefaultModelEnvOverride = Boolean(
def.defaultModelEnvVar &&
typeof process.env[def.defaultModelEnvVar] === 'string' &&
process.env[def.defaultModelEnvVar]?.trim(),
);
const safeReasoning =
typeof reasoning === 'string' && Array.isArray(def.reasoningOptions)
? (def.reasoningOptions.find((r) => r.id === reasoning)?.id ?? null)
Expand All @@ -4618,8 +4624,17 @@ export async function startServer({
const resumeModelIds = new Set(resumeLiveModels.map((c) => c?.id).filter(Boolean));
const askedForDefault =
typeof model !== 'string' || !model.trim() || model.trim().toLowerCase() === 'default';
if (!safeModel || safeModel === 'default' || (askedForDefault && !resumeModelIds.has(safeModel))) {
safeModel = resumeLiveModels[0]?.id ?? safeModel ?? null;
const defaultRunModel = resolveDefaultModelFromOptions(resumeLiveModels);
if (
!safeModel ||
safeModel === 'default' ||
(
askedForDefault &&
!hasDefaultModelEnvOverride &&
(!resumeModelIds.has(safeModel) || safeModel !== defaultRunModel)
)
) {
safeModel = defaultRunModel ?? safeModel ?? null;
agentOptions.model = safeModel;
}
} catch {
Expand Down Expand Up @@ -5396,12 +5411,17 @@ export async function startServer({
typeof model !== 'string' ||
!model.trim() ||
model.trim().toLowerCase() === 'default';
const defaultRunModel = resolveDefaultModelFromOptions(liveModels);
if (
Comment thread
mrcfps marked this conversation as resolved.
!safeModel ||
safeModel === 'default' ||
(userAskedForDefault && !liveModelIds.has(safeModel))
(
userAskedForDefault &&
!hasDefaultModelEnvOverride &&
(!liveModelIds.has(safeModel) || safeModel !== defaultRunModel)
)
) {
safeModel = liveModels[0]?.id ?? safeModel ?? null;
safeModel = defaultRunModel ?? safeModel ?? null;
Comment thread
mrcfps marked this conversation as resolved.
Outdated
agentOptions.model = safeModel;
}
if (liveModelIds.size === 0) {
Expand Down
13 changes: 10 additions & 3 deletions apps/daemon/tests/amr-acp-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,9 +219,14 @@ describe('AMR runtime def', () => {
const models = parseVelaModelJson(JSON.stringify({
source: 'remote',
data: [
{ id: 'public_model_kimi_k2_7_code' },
{ id: 'public_model_kimi_k2_7_code', enabled: false },
{ id: 'public_model_deepseek_v3_2' },
{ id: 'deepseek-v4-flash', cost: { input: 0.14, output: 0.28 } },
{
id: 'deepseek-v4-flash',
enabled: true,
default: true,
cost: { input: 0.14, output: 0.28 },
},
{ id: 'gpt-image-2' },
{ id: 'deepseek-v4-flash' },
],
Expand All @@ -230,11 +235,13 @@ describe('AMR runtime def', () => {
{
id: 'deepseek-v4-flash',
label: 'deepseek-v4-flash',
enabled: true,
default: true,
inputPriceUsdPerMillion: 0.14,
outputPriceUsdPerMillion: 0.28,
},
{ id: 'deepseek-v3.2', label: 'deepseek-v3.2' },
{ id: 'kimi-k2.7-code', label: 'kimi-k2.7-code' },
{ id: 'kimi-k2.7-code', label: 'kimi-k2.7-code', enabled: false },
]);
expect(models.map((m) => m.id)).not.toContain('gpt-image-2');
expect(models.map((m) => m.id)).not.toContain('public_model_kimi_k2_7_code');
Expand Down
6 changes: 3 additions & 3 deletions apps/daemon/tests/fixtures/fake-vela.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* Fake vela CLI used by AMR integration tests. Routes by the first argv:
*
* `vela model preset --format json` → prints the local AMR picker seed.
* `vela model list --format json` → prints the authoritative remote
* `vela model list --all --format json` → prints the authoritative remote
* AMR model catalog.
*
* `vela login` → writes ~/.amr/config.json (the
Expand Down Expand Up @@ -44,7 +44,7 @@
* FAKE_VELA_PROMPT_ERROR – when set, session/prompt returns a JSON-RPC error
* FAKE_VELA_MODELS – newline-separated `vela models` stdout
* FAKE_VELA_MODEL_PRESET_JSON – JSON stdout for `model preset --format json`
* FAKE_VELA_MODEL_LIST_JSON – JSON stdout for `model list --format json`
* FAKE_VELA_MODEL_LIST_JSON – JSON stdout for `model list --all --format json`
* FAKE_VELA_REQUIRE_SET_MODEL – strict gate (default on); set to '0' to
* accept session/prompt without prior
* session/set_model (legacy behaviour)
Expand Down Expand Up @@ -472,7 +472,7 @@ if (argv[2] === 'billing' && argv[3] === 'summary') {
}
}

if (argv[2] === 'model' && argv[4] === '--format' && argv[5] === 'json') {
if (argv[2] === 'model' && argv.includes('--format') && argv.includes('json')) {
if (argv[3] === 'preset') {
stdout.write(`${env.FAKE_VELA_MODEL_PRESET_JSON || DEFAULT_MODEL_PRESET_JSON}\n`);
exit(0);
Expand Down
46 changes: 46 additions & 0 deletions apps/daemon/tests/integrations/vela-errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,32 @@ describe('AMR account failure classification', () => {
});
});

it('classifies structured tier_model_not_entitled details as an upgrade-required AMR error', () => {
const failure = classifyAmrAccountFailureDetails({
kind: 'opencode_prompt_error',
code: 'tier_model_not_entitled',
});

expect(failure).toMatchObject({
code: 'AMR_TIER_UPGRADE_REQUIRED',
action: 'upgrade',
});
expect(failure?.message).toContain('does not include this model');
});

it('classifies structured tier_request_kind_not_entitled details as an upgrade-required AMR error', () => {
const failure = classifyAmrAccountFailureDetails({
kind: 'opencode_prompt_error',
code: 'tier_request_kind_not_entitled',
});

expect(failure).toMatchObject({
code: 'AMR_TIER_UPGRADE_REQUIRED',
action: 'upgrade',
});
expect(failure?.message).toContain('request type');
});

it('does not classify unrelated structured ACP details as AMR balance errors', () => {
expect(classifyAmrAccountFailureDetails({
kind: 'opencode_prompt_error',
Expand Down Expand Up @@ -109,6 +135,26 @@ describe('AMR account failure classification', () => {
});
});

it('classifies raw tier entitlement error codes into user-friendly upgrade copy', () => {
expect(
classifyAmrAccountFailure(
'HTTP 403 [code=tier_model_not_entitled] model access denied for current tier',
),
).toMatchObject({
code: 'AMR_TIER_UPGRADE_REQUIRED',
action: 'upgrade',
});

expect(
classifyAmrAccountFailure(
'HTTP 403 [code=tier_request_kind_not_entitled] image generation is not allowed for current tier',
),
).toMatchObject({
code: 'AMR_TIER_UPGRADE_REQUIRED',
action: 'upgrade',
});
});

it('classifies 429 wallet balance payloads as AMR balance errors', () => {
const failure = classifyAmrAccountFailure(
'HTTP 429 Too Many Requests: quota exceeded because wallet balance is empty',
Expand Down
Loading
Loading