diff --git a/src/main/services/provider-service.ts b/src/main/services/provider-service.ts index 2a008c66..d501ab31 100644 --- a/src/main/services/provider-service.ts +++ b/src/main/services/provider-service.ts @@ -17,6 +17,7 @@ import { ProviderResponseRunner, type ProviderResponseCreateInput } from "./prov import type { ToolTurnFn } from "./provider-tool-turn"; import { stopRun } from "./run-control"; import type { ModelCheckResult } from "../../shared/desktop-api"; +import { resolveTurnCredential } from "./provider-tool-turns/credential-resolver"; import { AUTH_STATE_CONFIGURED, AUTH_STATE_REVOKED, @@ -177,6 +178,8 @@ export class ProviderService { if (!input.orgId || !input.providerId) throw new Error("A credential is required to list provider models."); const provider = loadConfiguredProvider(this.db, input.orgId, input.providerId); const adapter = requireAdapter(this.adapters, provider.provider_key, provider.auth_type); + // Renew an expired sign-in the way a run does, so the list and the runs agree on whether the account works. + await resolveTurnCredential({ authBroker: this.authBroker, integrationKeys: this.integrationKeys, orgId: input.orgId, provider }); const credential = loadCredential(this.authBroker, input.orgId, provider); const authProfile = adapter.parseAuthProfile(credential.secret); return adapter.listModels({ authProfile, model: provider.model }); diff --git a/tests/unit/credential-refresh-race.test.ts b/tests/unit/credential-refresh-race.test.ts index f47b8b79..25b8afbf 100644 --- a/tests/unit/credential-refresh-race.test.ts +++ b/tests/unit/credential-refresh-race.test.ts @@ -66,3 +66,32 @@ describe("an expired sign-in is refreshed once, however many runs need it", () = h.connection.close(); }); }); + +describe("model lists use the same renewal path as runs", () => { + test("listing a signed-in provider's models refreshes an expired sign-in first, as a run would", async () => { + const tokenCalls: string[] = []; + const modelAuth: string[] = []; + vi.stubGlobal("fetch", async (url: string, init: RequestInit) => { + if (String(url).includes("/v1/models")) { + const auth = new Headers(init.headers).get("authorization") ?? ""; + modelAuth.push(auth); + if (auth !== "Bearer access-1") return new Response(JSON.stringify({ error: { type: "authentication_error", message: "expired" } }), { status: 401, headers: { "content-type": "application/json" } }); + return new Response(JSON.stringify({ data: [{ id: "claude-sonnet-4-5", display_name: "Sonnet" }], has_more: false }), { status: 200, headers: { "content-type": "application/json" } }); + } + tokenCalls.push((JSON.parse(String(init.body)) as { refresh_token: string }).refresh_token); + return new Response(JSON.stringify({ access_token: "access-1", refresh_token: "refresh-1", expires_in: 3600 }), { status: 200, headers: { "content-type": "application/json" } }); + }); + const connection = new DatabaseConnection(join(tmpdir(), `ac-list-${randomUUID()}.db`)); + const providers = new ProviderService(connection.db, providerAdapters, vault); + let orgId = ""; + let providerId = ""; + connection.transaction(() => { + orgId = new OrganizationService(connection.db).createOrganization({ organizationName: "Acme", employeeName: "Dana", mission: "m", goal: "g" }); + providerId = (connection.db.prepare("select id from providers where org_id = ? and provider_key = 'claude-pro'").get(orgId) as { id: string }).id; + providers.saveCredential({ orgId, providerId, secret: JSON.stringify({ access: "access-0", refresh: "refresh-0", expires: 0 }), model: "claude-sonnet-4-5" }); + }); + expect((await providers.listModels({ providerKey: "claude-pro", orgId, providerId })).map((model) => model.id)).toEqual(["claude-sonnet-4-5"]); + expect([tokenCalls, modelAuth]).toEqual([["refresh-0"], ["Bearer access-1"]]); + connection.close(); + }); +});