Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
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
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
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
42 changes: 41 additions & 1 deletion apps/web/src/components/AvatarMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { AgentIcon } from './AgentIcon';
import { PlanBadge } from './PlanBadge';
import { RemixIcon } from './RemixIcon';
import { orderAgentsWithOpenDesignFirst } from './agentOrdering';
import { defaultAgentModelId, effectiveAgentModelChoice } from './agentModelSelection';
import { SearchableModelSelect } from './modelOptions';
import type { AgentInfo, AppConfig, ExecMode, ProviderModelOption } from '../types';
import { SUGGESTED_MODELS_BY_PROTOCOL } from '../state/apiProtocols';
Expand Down Expand Up @@ -246,8 +247,9 @@ export function AvatarMenu({
// hasn't touched the picker yet so the labels don't read as empty.
const currentChoice =
(config.agentId && config.agentModels?.[config.agentId]) || {};
const normalizedCurrentChoice = effectiveAgentModelChoice(currentAgent, currentChoice) ?? currentChoice;
const currentModelId =
currentChoice.model ?? currentAgent?.models?.[0]?.id ?? null;
normalizedCurrentChoice.model ?? defaultAgentModelId(currentAgent);
const currentReasoningId =
currentChoice.reasoning ?? currentAgent?.reasoningOptions?.[0]?.id ?? null;
const currentModelLabel = currentAgent?.models?.find(
Expand Down Expand Up @@ -533,6 +535,44 @@ export function AvatarMenu({
searchInputTestId="avatar-model-search"
popoverTestId="avatar-model-popover"
minSearchableOptions={5}
disabledOptionHint={
currentAgent.id === 'amr'
? (option) =>
option.enabled === false
? t('settings.amrModelUpgradeHint')
: null
: undefined
}
onDisabledOptionUpgrade={
currentAgent.id === 'amr'
? () => {
const attribution = recordAmrEntry(
analytics.track,
'avatar_amr_upgrade',
new Date(),
{
metricsConsent:
config.telemetry?.metrics === true,
},
);
const deviceId = amrHandoffDeviceId({
metricsConsent:
config.telemetry?.metrics === true,
resolvedDeviceId: getResolvedDeviceId(),
installationId: config.installationId,
});
window.open(
attributedAmrUrl(
amrPlansUrl,
attribution,
deviceId,
),
'_blank',
'noopener,noreferrer',
);
}
: undefined
}
/>
</label>
) : null}
Expand Down
6 changes: 4 additions & 2 deletions apps/web/src/components/EntryShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ import { ONBOARDING_ARTIFACT_CHIP_IDS } from './home-hero/chips';
import { homeHeroChipLabel } from './home-hero/chip-labels';
import type { PluginUseAction } from './plugins-home/useActions';
import { Icon } from './Icon';
import { defaultAgentModelId, effectiveAgentModelChoice } from './agentModelSelection';
import { AgentIcon } from './AgentIcon';
import {
getModelCapabilityTag,
Expand Down Expand Up @@ -1417,7 +1418,8 @@ function OnboardingView({
const amrSelectedAndSignedOut = runtime === 'amr' && !amrSignedIn;
const selectedAgent = visibleAgents.find((agent) => agent.id === config.agentId) ?? null;
const selectedAgentChoice = selectedAgent ? (config.agentModels?.[selectedAgent.id] ?? {}) : {};
const selectedAgentTestModel = selectedAgentChoice.model ?? selectedAgent?.models?.[0]?.id ?? '';
const normalizedSelectedAgentChoice = effectiveAgentModelChoice(selectedAgent, selectedAgentChoice) ?? selectedAgentChoice;
const selectedAgentTestModel = normalizedSelectedAgentChoice.model ?? defaultAgentModelId(selectedAgent) ?? '';
const selectedAgentTestReasoning = selectedAgentChoice.reasoning ?? '';
const agentTestInputKey = [
selectedAgent?.id ?? '',
Expand Down Expand Up @@ -2650,7 +2652,7 @@ function OnboardingView({
daemonLive={daemonLive}
selectedAgentId={config.agentId}
selectedAgent={selectedAgent}
selectedModel={selectedAgentChoice.model ?? selectedAgent?.models?.[0]?.id ?? ''}
selectedModel={normalizedSelectedAgentChoice.model ?? defaultAgentModelId(selectedAgent) ?? ''}
modelOptions={agentModelOptions}
scanStatus={cliScanStatus}
onRefresh={() => void scanCliAgents()}
Expand Down
8 changes: 8 additions & 0 deletions apps/web/src/components/Icon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export type IconName =
| 'layout'
| 'lightbulb'
| 'link'
| 'lock'
| 'log-out'
| 'integrations-filled'
| 'maximize'
Expand Down Expand Up @@ -466,6 +467,13 @@ export function Icon({ name, size = 14, strokeWidth = 1.6, ...rest }: Props) {
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 1 0 7.07 7.07l1.71-1.71" />
</svg>
);
case 'lock':
return (
<svg {...common}>
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
);
case 'integrations-filled':
return (
<svg {...common} fill="currentColor" stroke="none">
Expand Down
Loading
Loading