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:
can1357
2026-06-13 16:49:01 +02:00
parent 82d3502897
commit bb2c018db9
2 changed files with 87 additions and 19 deletions
+14 -19
View File
@@ -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