fix(coding-agent): switched startup model checks to configured auth validation
- Replaced async getApiKey probing with modelRegistry.hasConfiguredAuth during session model restore and fallback selection. - Removed the per-provider key cache and avoided startup getApiKey/network work by checking configured auth synchronously. - Deferred real key retrieval to the request-time resolver while preserving existing model selection flow.
This commit is contained in:
@@ -1166,20 +1166,15 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||
SessionManager.create(cwd, SessionManager.getDefaultSessionDir(cwd, agentDir)),
|
||||
);
|
||||
const providerSessionId = options.providerSessionId ?? sessionManager.getSessionId();
|
||||
const modelApiKeyAvailability = new Map<string, boolean>();
|
||||
const getModelAvailabilityKey = (candidate: Model): string =>
|
||||
`${candidate.provider}\u0000${candidate.baseUrl ?? ""}`;
|
||||
const hasModelApiKey = async (candidate: Model): Promise<boolean> => {
|
||||
const availabilityKey = getModelAvailabilityKey(candidate);
|
||||
const cached = modelApiKeyAvailability.get(availabilityKey);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const hasKey = !!(await modelRegistry.getApiKey(candidate, providerSessionId));
|
||||
modelApiKeyAvailability.set(availabilityKey, hasKey);
|
||||
return hasKey;
|
||||
};
|
||||
// Startup model *selection* only needs to know whether auth is configured for
|
||||
// a candidate's provider — never the resolved key bytes. Use the synchronous,
|
||||
// side-effect-free probe (`hasConfiguredAuth`): it refreshes no OAuth tokens,
|
||||
// executes no `!command` keys, and issues no auth-broker requests. Resolving the
|
||||
// real key here (`getApiKey`) blocks resume on those network paths — a slow or
|
||||
// unreachable OAuth/broker endpoint stalls startup for the full ~10s refresh
|
||||
// timeout per candidate (observed as a hang in `restoreSessionModel`). The real
|
||||
// key is resolved lazily per request via ModelRegistry.resolver.
|
||||
const hasModelAuth = (candidate: Model): boolean => modelRegistry.hasConfiguredAuth(candidate);
|
||||
|
||||
// Load and create secret obfuscator early so resumed session state and prompt warnings
|
||||
// reflect actual loaded secrets, not just the setting toggle.
|
||||
@@ -1228,7 +1223,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||
: [];
|
||||
let restoredSessionModelIndex = -1;
|
||||
if (!hasExplicitModel && !model && sessionModelStrings.length > 0) {
|
||||
await logger.time("restoreSessionModel", async () => {
|
||||
logger.time("restoreSessionModel", () => {
|
||||
let failedSessionModel: string | undefined;
|
||||
for (let i = 0; i < sessionModelStrings.length; i++) {
|
||||
const sessionModelStr = sessionModelStrings[i];
|
||||
@@ -1239,7 +1234,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||
}
|
||||
|
||||
const restoredModel = modelRegistry.find(parsedModel.provider, parsedModel.id);
|
||||
if (restoredModel && (await hasModelApiKey(restoredModel))) {
|
||||
if (restoredModel && hasModelAuth(restoredModel)) {
|
||||
model = restoredModel;
|
||||
restoredSessionModelIndex = i;
|
||||
break;
|
||||
@@ -1867,7 +1862,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||
const parsedModel = parseModelString(sessionModelStr);
|
||||
if (!parsedModel) continue;
|
||||
const restoredModel = modelRegistry.find(parsedModel.provider, parsedModel.id);
|
||||
if (restoredModel && (await hasModelApiKey(restoredModel))) {
|
||||
if (restoredModel && hasModelAuth(restoredModel)) {
|
||||
model = restoredModel;
|
||||
modelFallbackMessage = undefined;
|
||||
restoredSessionModelIndex = i;
|
||||
@@ -1919,7 +1914,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||
const preferred = fallbackCandidates.find(
|
||||
candidate => candidate.provider === provider && candidate.id === defaultId,
|
||||
);
|
||||
if (preferred && (await hasModelApiKey(preferred))) {
|
||||
if (preferred && hasModelAuth(preferred)) {
|
||||
model = preferred;
|
||||
break;
|
||||
}
|
||||
@@ -1927,7 +1922,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||
// Otherwise, first available model with a valid API key.
|
||||
if (!model) {
|
||||
for (const candidate of fallbackCandidates) {
|
||||
if (await hasModelApiKey(candidate)) {
|
||||
if (hasModelAuth(candidate)) {
|
||||
model = candidate;
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -165,6 +165,79 @@ describe("createAgentSession deferred model pattern resolution", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("restores the saved session model without resolving auth over the network", async () => {
|
||||
// Regression: `restoreSessionModel` probed each saved-model candidate with
|
||||
// the async `getApiKey`, which refreshes OAuth tokens and hits the auth
|
||||
// broker. When the broker was unreachable that blocked resume for the full
|
||||
// ~10s refresh timeout — the "Still starting … restoreSessionModel" hang.
|
||||
// Selection now uses the synchronous, side-effect-free `hasConfiguredAuth`
|
||||
// probe; the real key is resolved lazily per request via the resolver.
|
||||
const savedModel = getBundledModel("anthropic", "claude-sonnet-4-5");
|
||||
if (!savedModel) {
|
||||
throw new Error("Expected bundled anthropic default model");
|
||||
}
|
||||
|
||||
const authStorage = await AuthStorage.create(path.join(tempDir, "resume-saved-auth.db"));
|
||||
authStoragesToClose.push(authStorage);
|
||||
authStorage.setRuntimeApiKey(savedModel.provider, "test-key");
|
||||
const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir, "models.yml"));
|
||||
|
||||
const targetSessionFile = path.join(tempDir, "resume-saved-model.jsonl");
|
||||
const timestamp = "2026-06-01T00:00:00.000Z";
|
||||
await Bun.write(
|
||||
targetSessionFile,
|
||||
`${[
|
||||
{ type: "session", version: 3, id: "resume-saved", timestamp, cwd: tempDir },
|
||||
{
|
||||
type: "model_change",
|
||||
id: "default-model",
|
||||
parentId: null,
|
||||
timestamp,
|
||||
model: `${savedModel.provider}/${savedModel.id}`,
|
||||
role: "default",
|
||||
},
|
||||
]
|
||||
.map(entry => JSON.stringify(entry))
|
||||
.join("\n")}\n`,
|
||||
);
|
||||
const sessionManager = await SessionManager.open(targetSessionFile, path.join(tempDir, "resume-saved-sessions"));
|
||||
|
||||
// A rejecting getApiKey stands in for the unreachable broker / hanging
|
||||
// OAuth refresh: if startup awaits it to pick the restore model, it surfaces.
|
||||
const getApiKeySpy = vi
|
||||
.spyOn(modelRegistry, "getApiKey")
|
||||
.mockRejectedValue(new Error("startup model restore must not resolve auth over the network"));
|
||||
|
||||
try {
|
||||
const { session } = await createAgentSession({
|
||||
cwd: tempDir,
|
||||
agentDir: tempDir,
|
||||
authStorage,
|
||||
modelRegistry,
|
||||
sessionManager,
|
||||
settings: Settings.isolated(),
|
||||
disableExtensionDiscovery: true,
|
||||
skills: [],
|
||||
contextFiles: [],
|
||||
promptTemplates: [],
|
||||
slashCommands: [],
|
||||
enableMCP: false,
|
||||
enableLsp: false,
|
||||
skipPythonPreflight: true,
|
||||
});
|
||||
|
||||
try {
|
||||
expect(session.model?.provider).toBe(savedModel.provider);
|
||||
expect(session.model?.id).toBe(savedModel.id);
|
||||
expect(getApiKeySpy).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await session.dispose();
|
||||
}
|
||||
} finally {
|
||||
getApiKeySpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test("prefers the provider default over catalog order in the startup fallback", async () => {
|
||||
// Regression: with an Anthropic key but no configured `default` role and no
|
||||
// session/CLI model, the step-4 startup fallback used to pick the first
|
||||
|
||||
Reference in New Issue
Block a user