Skip to content
Merged
Show file tree
Hide file tree
Changes from 19 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
67 changes: 58 additions & 9 deletions apps/daemon/src/connectionTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,6 @@ import {
import { aihubmixHeaders } from './integrations/aihubmix.js';
import type { AgentCliEnvPrefs } from './app-config.js';
import type { RuntimeAgentDef } from './runtimes/types.js';
import { resolveModelForAgent } from './runtimes/models.js';
import { preparePromptFileForAgent, type PreparedPromptFile } from './runtimes/prompt-file.js';
import { configuredAllowedInternalHosts } from './origin-validation.js';
import {
Expand All @@ -77,6 +76,17 @@ import {
} from '@open-design/contracts/api/connectionTest';
import { googleGenerateContentUrl } from './integrations/google-models.js';
import { resolveAmrProfile } from './integrations/vela.js';
import { amrModelLoadingCache } from './runtimes/amr-model-cache.js';
import {
fetchVelaPresetModels,
fetchVelaRemoteModelsWithRetry,
} from './runtimes/defs/amr.js';
import {
getRememberedLiveModels,
preferFreshLiveModels,
resolveDefaultModelFromOptions,
resolveModelForAgent,
} from './runtimes/models.js';

export { validateBaseUrl } from '@open-design/contracts/api/connectionTest';

Expand Down Expand Up @@ -1880,12 +1890,9 @@ function attachAgentStreamHandlers(
child,
prompt,
cwd,
// Same substitution as the chat-run path in server.ts — adapters whose
// CLI rejects the synthetic 'default' (e.g. AMR / vela, which forces
// session/set_model before session/prompt) need the def's first
// concrete fallback id here too, otherwise Test connection deadlocks
// on the same `session/set_model must be called before session/prompt`
// error the chat-run path already handles.
// Same substitution as the chat-run path in server.ts: omitted models can
// resolve to a concrete fallback, while an explicit 'default' is preserved
// so ACP runtimes can use their upstream configured default.
model: resolveModelForAgent(def as never, model ?? null, modelEnv, liveModelScope),
Comment thread
mrcfps marked this conversation as resolved.
mcpServers: [],
send,
Expand Down Expand Up @@ -1963,11 +1970,46 @@ async function prepareOpenCodeConnectionTestCwd(tempDir: string): Promise<void>
}
}

async function resolveConnectionTestModelForAgent(
def: RuntimeAgentDef,
requestedModel: string | null,
env: NodeJS.ProcessEnv,
liveModelScope: string | null,
launchPath?: string | null,
): Promise<string | null> {
const resolved = resolveModelForAgent(def, requestedModel, env, liveModelScope);
if (def.id !== 'amr' || resolved !== 'default' || !launchPath) return resolved;

try {
const cacheKey = JSON.stringify({
Comment thread
mrcfps marked this conversation as resolved.
Outdated
connectionTest: true,
Comment thread
mrcfps marked this conversation as resolved.
Outdated
launchPath,
home: env.HOME ?? env.USERPROFILE ?? '',
openDesignAmrProfile: env.OPEN_DESIGN_AMR_PROFILE ?? '',
velaProfile: env.VELA_PROFILE ?? '',
velaLinkUrl: env.VELA_LINK_URL ?? '',
velaRuntimeKey: env.VELA_RUNTIME_KEY ?? '',
velaOpencodeBin: env.VELA_OPENCODE_BIN ?? '',
});
const catalog = await amrModelLoadingCache.get(cacheKey, {
fetchPreset: () => fetchVelaPresetModels(launchPath, env),
fetchRemote: () => fetchVelaRemoteModelsWithRetry(launchPath, env),
});
const liveModels = preferFreshLiveModels(
catalog.models ?? [],
getRememberedLiveModels(def.id, liveModelScope),
);
return resolveDefaultModelFromOptions(liveModels) ?? resolved;
} catch {
return resolved;
}
}

async function testAgentConnectionInternal(
input: AgentConnectionInput,
): Promise<ConnectionTestResponse> {
const start = Date.now();
const model =
let model =
typeof input.model === 'string' && input.model.trim()
? input.model.trim()
: 'default';
Expand Down Expand Up @@ -2221,6 +2263,13 @@ async function testAgentConnectionInternal(
...baseEnv,
...(mmdRouteLaunchEnv || {}),
}, executableResolution);
model = await resolveConnectionTestModelForAgent(
def,
model,
env,
liveModelScope,
executableResolution.launchPath,
) ?? model;
const auth = await probeAgentAuthStatus(def, executableResolution.launchPath, env);
if (auth?.status === 'missing') {
// Preflight auth probe runs after binary resolution but before the
Expand Down Expand Up @@ -2278,7 +2327,7 @@ async function testAgentConnectionInternal(
child,
SMOKE_PROMPT,
tempDir,
input.model,
model,
env,
liveModelScope,
sink.send,
Expand Down
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
33 changes: 31 additions & 2 deletions apps/daemon/src/routes/vela.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,17 +197,29 @@ export function registerVelaRoutes(app: Express, deps: RegisterVelaRoutesDeps):
string,
Promise<VelaLiveAccount | null>
>();
const inFlightVelaAccountInvalidations = new Set<string>();
function fetchVelaLiveAccountSingleFlight(
accountCacheKey: string,
probe: AmrModelProbe,
options: { invalidateModelsOnPlanChange?: boolean } = {},
): Promise<VelaLiveAccount | null> {
if (options.invalidateModelsOnPlanChange === true) {
inFlightVelaAccountInvalidations.add(accountCacheKey);
}
const existing = inFlightVelaAccountFetches.get(accountCacheKey);
if (existing) return existing;
Comment thread
mrcfps marked this conversation as resolved.
const pending = (async () => {
const previousAccount = peekVelaLiveAccount(accountCacheKey);
amrModelLoadingCache.warm(probe.cacheKey, () =>
fetchVelaRemoteModelsWithRetry(probe.launchPath, probe.env),
);
const account = await fetchVelaBillingSummary(probe.launchPath, probe.env);
if (
inFlightVelaAccountInvalidations.has(accountCacheKey) &&
(!previousAccount || previousAccount.plan !== account.plan)
) {
amrModelLoadingCache.invalidate(probe.cacheKey);
}
setVelaLiveAccount(accountCacheKey, account);
return account;
})()
Expand All @@ -220,6 +232,7 @@ export function registerVelaRoutes(app: Express, deps: RegisterVelaRoutesDeps):
})
.finally(() => {
inFlightVelaAccountFetches.delete(accountCacheKey);
inFlightVelaAccountInvalidations.delete(accountCacheKey);
});
inFlightVelaAccountFetches.set(accountCacheKey, pending);
return pending;
Expand All @@ -242,6 +255,7 @@ export function registerVelaRoutes(app: Express, deps: RegisterVelaRoutesDeps):
try {
const appConfig = await readAppConfig(RUNTIME_DATA_DIR);
const configuredEnv = agentCliEnvForAgent(appConfig.agentCliEnv, 'amr');
const refresh = _req.query.refresh === '1' || _req.query.refresh === 'true';
const status = readVelaLoginStatus(mergeVelaEnv(env, configuredEnv));
if (status.loggedIn) {
// Key the live-account cache by the full credential revision (not just
Expand All @@ -254,7 +268,12 @@ export function registerVelaRoutes(app: Express, deps: RegisterVelaRoutesDeps):
);
const probe = resolveAmrModelProbeForEnv(configuredEnv);
const cachedAccount = peekVelaLiveAccount(accountCacheKey);
if (!cachedAccount) {
if (refresh) {
const liveAccount = await fetchVelaLiveAccountSingleFlight(accountCacheKey, probe, {
invalidateModelsOnPlanChange: true,
});
applyVelaLiveAccount(status, liveAccount);
} else if (!cachedAccount) {
// Cold cache (or a fetch already in flight): BLOCK on the single-flight
// billing fetch so the first open already carries plan/balance. The
// consumers (settings card, inline switcher, avatar) read /status once
Expand All @@ -274,7 +293,9 @@ export function registerVelaRoutes(app: Express, deps: RegisterVelaRoutesDeps):
// next poll once the TTL has lapsed.
applyVelaLiveAccount(status, cachedAccount);
if (shouldRefreshVelaLiveAccount(accountCacheKey)) {
void fetchVelaLiveAccountSingleFlight(accountCacheKey, probe).catch(() => {});
void fetchVelaLiveAccountSingleFlight(accountCacheKey, probe, {
invalidateModelsOnPlanChange: true,
}).catch(() => {});
}
}
}
Expand All @@ -294,6 +315,14 @@ export function registerVelaRoutes(app: Express, deps: RegisterVelaRoutesDeps):
configuredEnv,
refresh,
});
if (refresh) {
Comment thread
mrcfps marked this conversation as resolved.
try {
const modelProbe = resolveAmrModelProbeForEnv(configuredEnv);
amrModelLoadingCache.invalidate(modelProbe.cacheKey);
Comment thread
mrcfps marked this conversation as resolved.
} catch (err) {
console.warn('[amr] model cache invalidation after wallet refresh failed', err);
}
}
res.json(snapshot);
} catch (err) {
res.status(500).json({ error: String(err) });
Expand Down
13 changes: 13 additions & 0 deletions apps/daemon/src/run-failure-classification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -549,6 +549,19 @@ export function classifyRunFailure(
);
}

if (
errorCode === 'AMR_TIER_UPGRADE_REQUIRED' ||
amrFailure?.code === 'AMR_TIER_UPGRADE_REQUIRED'
) {
return classification(
'entitlement_required',
'amr_tier_upgrade_required',
'session_init',
false,
'upgrade',
);
}

if (
errorCode === 'AMR_AUTH_REQUIRED' ||
errorCode === 'AGENT_AUTH_REQUIRED' ||
Expand Down
4 changes: 4 additions & 0 deletions apps/daemon/src/runtimes/amr-model-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ export class AmrModelLoadingCache {
this.startRefresh(this.stateFor(cacheKey), fetchRemote);
}

invalidate(cacheKey: string): void {
this.states.delete(cacheKey);
}

resetForTests(): void {
this.states.clear();
}
Expand Down
32 changes: 25 additions & 7 deletions apps/daemon/src/runtimes/defs/amr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,10 @@ const OPENCODE_MODEL_PRICE_PROVIDER_PRIORITY = [
//
// Model wiring notes:
//
// 1. vela rejects `session/prompt` until `session/set_model` has been
// called, so AMR cannot accept the synthetic `default` model id —
// attachAcpSession skips set_model whenever model === 'default'.
// 1. A concrete AMR model selection is applied through ACP
// `session/set_model`. The synthetic `default` model id intentionally
// skips that call so vela/OpenCode can use the account's configured
// upstream default.
//
// 2. Vela 0.0.1 exposes the current link-supported catalog through
// `vela models`, but that command prints public ids such as
Expand Down Expand Up @@ -164,21 +165,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 +499,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 Expand Up @@ -574,9 +593,8 @@ export const amrAgentDef = {
// surfaces the live Vela catalog instead.
supportsCustomModel: false,
supportsImagePaths: true,
// Daemon-process env override for emergency operator pinning. Normal UI
// selection comes from the live `vela models` catalog and is preflighted
// before spawn.
// Daemon-process env override for emergency operator pinning when no model
// was selected. Explicit UI selections, including `default`, win.
defaultModelEnvVar: 'VELA_DEFAULT_MODEL',
// Vela/OpenCode can spend extended stretches silent while the upstream
// provider is still working. Keep the outer chat watchdog aligned with the
Expand Down
Loading
Loading