Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
65 changes: 55 additions & 10 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 @@ -76,7 +75,19 @@ import {
type ProviderTestRequest,
} from '@open-design/contracts/api/connectionTest';
import { googleGenerateContentUrl } from './integrations/google-models.js';
import { resolveAmrProfile } from './integrations/vela.js';
import { readVelaCredentialRevision, resolveAmrProfile } from './integrations/vela.js';
import { amrModelLoadingCache } from './runtimes/amr-model-cache.js';
import { buildAmrModelCacheKey } from './runtimes/amr-model-probe.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 +1891,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 +1971,41 @@ 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 = buildAmrModelCacheKey({
launchPath,
env,
credentialRevision: readVelaCredentialRevision(env),
});
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 +2259,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 +2323,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
43 changes: 34 additions & 9 deletions apps/daemon/src/routes/vela.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
velaWalletSnapshotReader,
} from '../integrations/vela-wallet.js';
import { amrModelLoadingCache } from '../runtimes/amr-model-cache.js';
import { buildAmrModelCacheKey } from '../runtimes/amr-model-probe.js';
import {
fetchVelaBillingSummary,
fetchVelaPresetModels,
Expand Down Expand Up @@ -169,14 +170,9 @@ export function registerVelaRoutes(app: Express, deps: RegisterVelaRoutesDeps):
agentLaunch,
);
const credentialRevision = readVelaCredentialRevision(env, configuredEnv);
const cacheKey = JSON.stringify({
const cacheKey = buildAmrModelCacheKey({
launchPath,
home: spawnEnv.HOME ?? spawnEnv.USERPROFILE ?? '',
openDesignAmrProfile: spawnEnv.OPEN_DESIGN_AMR_PROFILE ?? '',
velaProfile: spawnEnv.VELA_PROFILE ?? '',
velaLinkUrl: spawnEnv.VELA_LINK_URL ?? '',
velaRuntimeKey: spawnEnv.VELA_RUNTIME_KEY ?? '',
velaOpencodeBin: spawnEnv.VELA_OPENCODE_BIN ?? '',
env: spawnEnv,
credentialRevision,
});
return { launchPath, env: spawnEnv, configuredEnv, cacheKey };
Expand All @@ -197,17 +193,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 +228,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 +251,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 +264,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 +289,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 +311,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
33 changes: 26 additions & 7 deletions apps/daemon/src/runtimes/amr-model-probe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,37 @@ import {
} from '../agents.js';
import { agentCliEnvForAgent, type readAppConfig } from '../app-config.js';
import { readVelaCredentialRevision } from '../integrations/vela.js';
import type { VelaCredentialRevision } from '../integrations/vela.js';

export interface ResolveAmrModelProbeDeps {
dataDir: string;
env: NodeJS.ProcessEnv;
readAppConfig: typeof readAppConfig;
}

export interface BuildAmrModelCacheKeyInput {
launchPath: string;
env: NodeJS.ProcessEnv;
credentialRevision: VelaCredentialRevision;
}

export function buildAmrModelCacheKey({
launchPath,
env,
credentialRevision,
}: BuildAmrModelCacheKeyInput): string {
return JSON.stringify({
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 ?? '',
credentialRevision,
});
}

export async function resolveAmrModelProbe({
dataDir,
env: baseEnv,
Expand All @@ -38,14 +62,9 @@ export async function resolveAmrModelProbe({
agentLaunch,
);
const credentialRevision = readVelaCredentialRevision(baseEnv, configuredEnv);
const cacheKey = JSON.stringify({
const cacheKey = buildAmrModelCacheKey({
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 ?? '',
env,
credentialRevision,
});
return { launchPath, env, configuredEnv, cacheKey };
Expand Down
Loading
Loading