Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion apps/desktop/src/lib/ai/call-small-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ export async function callSmallModel<TResult>({
const attempts: SmallModelAttempt[] = [];

for (const provider of orderProviders(providers, providerOrder)) {
const credentials = provider.resolveCredentials();
const credentials = await provider.resolveCredentials();
if (!credentials) {
attempts.push({
providerId: provider.id,
Expand Down
46 changes: 24 additions & 22 deletions packages/chat/src/server/desktop/auth/anthropic/anthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ export function getCredentialsFromKeychain(): ClaudeCredentials | null {
return null;
}

export function getCredentialsFromAuthStorage(): ClaudeCredentials | null {
export async function getCredentialsFromAuthStorage(): Promise<ClaudeCredentials | null> {
try {
const authStorage = createAuthStorage();
authStorage.reload();
Expand All @@ -187,18 +187,22 @@ export function getCredentialsFromAuthStorage(): ClaudeCredentials | null {
};
}

if (
credential.type === "oauth" &&
typeof credential.access === "string" &&
credential.access.trim().length > 0
) {
if (credential.type === "oauth") {
// mastracode's getApiKey triggers refreshToken() when expires <= now,
// and persists the refreshed credential back into auth storage.
const accessToken = await authStorage.getApiKey(
ANTHROPIC_AUTH_PROVIDER_ID,
);
if (!accessToken || accessToken.trim().length === 0) return null;
authStorage.reload();
const refreshed = authStorage.get(ANTHROPIC_AUTH_PROVIDER_ID);
return {
apiKey: credential.access.trim(),
apiKey: accessToken.trim(),
source: "auth-storage",
kind: "oauth",
expiresAt:
typeof credential.expires === "number"
? credential.expires
refreshed?.type === "oauth" && typeof refreshed.expires === "number"
? refreshed.expires
: undefined,
};
Comment on lines +190 to 207
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 getApiKey called unconditionally for all OAuth credentials

getCredentialsFromAuthStorage calls authStorage.getApiKey() for every OAuth credential regardless of whether it has expired. The PR description states "Resolvers now call authStorage.getApiKey("anthropic") on expired oauth creds", but there is no expiry guard here.

Both chat-service.ts (lines 91–93) and resolveAnthropicCredential.ts (line 103) check expiry before calling getApiKey. Calling it unconditionally means every credential resolution for a still-valid OAuth token goes through getApiKey rather than just reading the already-loaded value from authStorage.get(). If mastracode's getApiKey were to do I/O or a network call for non-expired tokens in a future release, this would add unnecessary latency on every message send.

Aligning with the pattern used in the other two call sites — only calling getApiKey when expires <= Date.now() — would be more consistent and forward-safe.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/chat/src/server/desktop/auth/anthropic/anthropic.ts
Line: 190-207

Comment:
**`getApiKey` called unconditionally for all OAuth credentials**

`getCredentialsFromAuthStorage` calls `authStorage.getApiKey()` for every OAuth credential regardless of whether it has expired. The PR description states "Resolvers now call `authStorage.getApiKey("anthropic")` on **expired** oauth creds", but there is no expiry guard here.

Both `chat-service.ts` (lines 91–93) and `resolveAnthropicCredential.ts` (line 103) check expiry before calling `getApiKey`. Calling it unconditionally means every credential resolution for a still-valid OAuth token goes through `getApiKey` rather than just reading the already-loaded value from `authStorage.get()`. If mastracode's `getApiKey` were to do I/O or a network call for non-expired tokens in a future release, this would add unnecessary latency on every message send.

Aligning with the pattern used in the other two call sites — only calling `getApiKey` when `expires <= Date.now()` — would be more consistent and forward-safe.

How can I resolve this? If you propose a fix, please make it concise.

}
Expand All @@ -209,24 +213,22 @@ export function getCredentialsFromAuthStorage(): ClaudeCredentials | null {
return null;
}

export function getCredentialsFromAnySource(): ClaudeCredentials | null {
const resolvers = [
getCredentialsFromConfig,
getCredentialsFromKeychain,
getCredentialsFromAuthStorage,
];
export async function getCredentialsFromAnySource(): Promise<ClaudeCredentials | null> {
const syncResolvers = [getCredentialsFromConfig, getCredentialsFromKeychain];
let firstExpired: ClaudeCredentials | null = null;

for (const resolve of resolvers) {
for (const resolve of syncResolvers) {
const credential = resolve();
if (!credential) {
continue;
}
if (!isClaudeCredentialExpired(credential)) {
return credential;
}
if (!credential) continue;
if (!isClaudeCredentialExpired(credential)) return credential;
firstExpired ??= credential;
}

const storageCredential = await getCredentialsFromAuthStorage();
if (storageCredential && !isClaudeCredentialExpired(storageCredential)) {
return storageCredential;
}
firstExpired ??= storageCredential ?? null;

return firstExpired;
}
52 changes: 26 additions & 26 deletions packages/chat/src/server/desktop/chat-service/chat-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,8 @@ mock.module("mastracode", () => ({
mock.module("../auth/anthropic", () => ({
getCredentialsFromConfig: () => anthropicConfigCredential,
getCredentialsFromKeychain: () => anthropicKeychainCredential,
getCredentialsFromAnySource: () => null,
getCredentialsFromAuthStorage: () => null,
getCredentialsFromAnySource: async () => null,
getCredentialsFromAuthStorage: async () => null,
getAnthropicProviderOptions: () => ({}),
isClaudeCredentialExpired: (credential: {
kind: "apiKey" | "oauth";
Expand Down Expand Up @@ -243,7 +243,7 @@ describe("ChatService OpenAI auth storage", () => {
}),
);
expect(result.expiresAt).toBe(oauthExpiresAt);
expect(chatService.getAnthropicAuthStatus().method).toBe("oauth");
expect((await chatService.getAnthropicAuthStatus()).method).toBe("oauth");
});

it("switches Anthropic status from oauth to api key when api key is saved", async () => {
Expand All @@ -264,10 +264,10 @@ describe("ChatService OpenAI auth storage", () => {

await chatService.startAnthropicOAuth();
await chatService.completeAnthropicOAuth({ code: "auth-code#state" });
expect(chatService.getAnthropicAuthStatus().method).toBe("oauth");
expect((await chatService.getAnthropicAuthStatus()).method).toBe("oauth");

await chatService.setAnthropicApiKey({ apiKey: " api-key " });
expect(chatService.getAnthropicAuthStatus().method).toBe("api_key");
expect((await chatService.getAnthropicAuthStatus()).method).toBe("api_key");
});

it("prefers a managed Anthropic API key over env-config credentials", async () => {
Expand All @@ -284,28 +284,28 @@ describe("ChatService OpenAI auth storage", () => {
);
expect(process.env.ANTHROPIC_API_KEY).toBeUndefined();
expect(process.env.ANTHROPIC_AUTH_TOKEN).toBeUndefined();
expect(chatService.getAnthropicAuthStatus()).toEqual({
expect(await chatService.getAnthropicAuthStatus()).toEqual({
authenticated: true,
method: "api_key",
source: "managed",
issue: null,
});
});

it("ignores Anthropic runtime env credentials without managed auth", () => {
it("ignores Anthropic runtime env credentials without managed auth", async () => {
const chatService = new ChatService();

process.env.ANTHROPIC_AUTH_TOKEN = "external-oauth-token";

expect(chatService.getAnthropicAuthStatus()).toEqual({
expect(await chatService.getAnthropicAuthStatus()).toEqual({
authenticated: false,
method: null,
source: null,
issue: null,
});
});

it("prefers external Anthropic credentials over managed auth", () => {
it("prefers external Anthropic credentials over managed auth", async () => {
const chatService = new ChatService();

anthropicConfigCredential = {
Expand All @@ -318,15 +318,15 @@ describe("ChatService OpenAI auth storage", () => {
key: "managed-api-key",
});

expect(chatService.getAnthropicAuthStatus()).toEqual({
expect(await chatService.getAnthropicAuthStatus()).toEqual({
authenticated: true,
method: "oauth",
source: "external",
issue: null,
});
});

it("surfaces hidden managed Anthropic OAuth when external Claude auth wins", () => {
it("surfaces hidden managed Anthropic OAuth when external Claude auth wins", async () => {
const chatService = new ChatService();

anthropicConfigCredential = {
Expand All @@ -340,7 +340,7 @@ describe("ChatService OpenAI auth storage", () => {
expires: Date.now() + 60 * 60 * 1000,
});

expect(chatService.getAnthropicAuthStatus()).toEqual({
expect(await chatService.getAnthropicAuthStatus()).toEqual({
authenticated: true,
method: "oauth",
source: "external",
Expand All @@ -349,7 +349,7 @@ describe("ChatService OpenAI auth storage", () => {
});
});

it("prefers managed Anthropic auth over runtime env credentials", () => {
it("prefers managed Anthropic auth over runtime env credentials", async () => {
const chatService = new ChatService();

process.env.ANTHROPIC_AUTH_TOKEN = "external-oauth-token";
Expand All @@ -358,15 +358,15 @@ describe("ChatService OpenAI auth storage", () => {
key: "managed-api-key",
});

expect(chatService.getAnthropicAuthStatus()).toEqual({
expect(await chatService.getAnthropicAuthStatus()).toEqual({
authenticated: true,
method: "api_key",
source: "managed",
issue: null,
});
});

it("marks expired external Anthropic OAuth as expired", () => {
it("marks expired external Anthropic OAuth as expired", async () => {
const chatService = new ChatService();

anthropicConfigCredential = {
Expand All @@ -376,15 +376,15 @@ describe("ChatService OpenAI auth storage", () => {
expiresAt: Date.now() - 1_000,
};

expect(chatService.getAnthropicAuthStatus()).toEqual({
expect(await chatService.getAnthropicAuthStatus()).toEqual({
authenticated: false,
method: "oauth",
source: "external",
issue: "expired",
});
});

it("falls back to managed Anthropic auth when external OAuth is expired", () => {
it("falls back to managed Anthropic auth when external OAuth is expired", async () => {
const chatService = new ChatService();

anthropicConfigCredential = {
Expand All @@ -398,7 +398,7 @@ describe("ChatService OpenAI auth storage", () => {
key: "managed-api-key",
});

expect(chatService.getAnthropicAuthStatus()).toEqual({
expect(await chatService.getAnthropicAuthStatus()).toEqual({
authenticated: true,
method: "api_key",
source: "managed",
Expand All @@ -415,7 +415,7 @@ describe("ChatService OpenAI auth storage", () => {
expires: Date.now() + 60 * 60 * 1000,
});

expect(chatService.getAnthropicAuthStatus()).toEqual({
expect(await chatService.getAnthropicAuthStatus()).toEqual({
authenticated: true,
method: "oauth",
source: "managed",
Expand All @@ -426,7 +426,7 @@ describe("ChatService OpenAI auth storage", () => {
await chatService.disconnectAnthropicOAuth();

expect(fakeAuthStorage.remove).toHaveBeenCalledWith("anthropic");
expect(chatService.getAnthropicAuthStatus()).toEqual({
expect(await chatService.getAnthropicAuthStatus()).toEqual({
authenticated: false,
method: null,
source: null,
Expand Down Expand Up @@ -459,7 +459,7 @@ describe("ChatService OpenAI auth storage", () => {
ANTHROPIC_AUTH_TOKEN: "gateway-token",
},
});
expect(chatService.getAnthropicAuthStatus()).toEqual({
expect(await chatService.getAnthropicAuthStatus()).toEqual({
authenticated: true,
method: "api_key",
source: "managed",
Expand All @@ -474,15 +474,15 @@ describe("ChatService OpenAI auth storage", () => {
access: "oauth-access-token",
expires: Date.now() + 60 * 60 * 1000,
});
expect(chatService.getAnthropicAuthStatus().method).toBe("oauth");
expect((await chatService.getAnthropicAuthStatus()).method).toBe("oauth");

await chatService.setAnthropicEnvConfig({
envText:
"ANTHROPIC_BASE_URL=https://ai-gateway.vercel.sh\nANTHROPIC_AUTH_TOKEN=gateway-token",
});

expect(fakeAuthStorage.remove).toHaveBeenCalledWith("anthropic");
expect(chatService.getAnthropicAuthStatus().method).toBe("api_key");
expect((await chatService.getAnthropicAuthStatus()).method).toBe("api_key");
});

it("persists Anthropic env config without API key/token", async () => {
Expand All @@ -498,7 +498,7 @@ describe("ChatService OpenAI auth storage", () => {
ANTHROPIC_BASE_URL: "https://ai-gateway.vercel.sh",
},
});
expect(chatService.getAnthropicAuthStatus()).toEqual({
expect(await chatService.getAnthropicAuthStatus()).toEqual({
authenticated: false,
method: null,
source: null,
Expand Down Expand Up @@ -526,7 +526,7 @@ describe("ChatService OpenAI auth storage", () => {
type: "api_key",
key: "gateway-token",
});
expect(chatService.getAnthropicAuthStatus()).toEqual({
expect(await chatService.getAnthropicAuthStatus()).toEqual({
authenticated: true,
method: "api_key",
source: "managed",
Expand Down Expand Up @@ -577,7 +577,7 @@ describe("ChatService OpenAI auth storage", () => {
envText: "",
variables: {},
});
expect(chatService.getAnthropicAuthStatus().method).toBeNull();
expect((await chatService.getAnthropicAuthStatus()).method).toBeNull();
});

it("deletes previously applied pass-through env keys when settings change", async () => {
Expand Down
21 changes: 19 additions & 2 deletions packages/chat/src/server/desktop/chat-service/chat-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,11 +78,28 @@ export class ChatService {
);
}

getAnthropicAuthStatus(): AuthStatus {
async getAnthropicAuthStatus(): Promise<AuthStatus> {
const authStorage = this.getAuthStorage();
authStorage.reload();
const storedCredential = authStorage.get(ANTHROPIC_AUTH_PROVIDER_ID);
let storedCredential = authStorage.get(ANTHROPIC_AUTH_PROVIDER_ID);
const hasManagedOAuth = storedCredential?.type === "oauth";

// If managed OAuth is past its expiry, give mastracode a chance to
// refresh it before downgrading status to "expired". Mastracode's
// getApiKey uses the stored refresh token via the anthropic provider.
if (
storedCredential?.type === "oauth" &&
typeof storedCredential.expires === "number" &&
storedCredential.expires <= Date.now()
) {
try {
await authStorage.getApiKey(ANTHROPIC_AUTH_PROVIDER_ID);
authStorage.reload();
storedCredential = authStorage.get(ANTHROPIC_AUTH_PROVIDER_ID);
} catch {
// Refresh failed; fall through to expired-state handling below.
}
Comment on lines +99 to +101
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Avoid silently swallowing Anthropic token refresh failures; log the error context so refresh issues can be diagnosed.

(Based on your team's feedback about handling async errors explicitly and avoiding empty catch blocks.)

View Feedback

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/chat/src/server/desktop/chat-service/chat-service.ts, line 99:

<comment>Avoid silently swallowing Anthropic token refresh failures; log the error context so refresh issues can be diagnosed.

(Based on your team's feedback about handling async errors explicitly and avoiding empty catch blocks.) </comment>

<file context>
@@ -78,11 +78,28 @@ export class ChatService {
+				await authStorage.getApiKey(ANTHROPIC_AUTH_PROVIDER_ID);
+				authStorage.reload();
+				storedCredential = authStorage.get(ANTHROPIC_AUTH_PROVIDER_ID);
+			} catch {
+				// Refresh failed; fall through to expired-state handling below.
+			}
</file context>
Suggested change
} catch {
// Refresh failed; fall through to expired-state handling below.
}
} catch (error) {
console.warn("[chat-service] Failed to refresh Anthropic OAuth token", error);
}
Fix with Cubic

}
Comment on lines +81 to +102
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

mastracode authStorage getApiKey in-flight refresh deduplication

💡 Result:

In MastraCode, authStorage is an instance of the AuthStorage class, created via createAuthStorage and returned by createMastraCode. It manages authentication credentials for AI providers like Anthropic (Claude) and OpenAI, supporting both API keys (stored in auth.json) and OAuth. Key methods and usage from source code (mastracode/src/index.ts): - authStorage.getStoredApiKey(provider): Retrieves a stored API key for a given provider (e.g., 'anthropic', 'openai', MEMORY_GATEWAY_PROVIDER). Used at startup to populate environment variables like ANTHROPIC_API_KEY if not already set. - authStorage.loadStoredApiKeysIntoEnv(providerEnvVars): Loads all stored API keys into process.env. - authStorage.get(providerId): Gets credentials for a provider, distinguishing between {type: 'api_key', key: string} and {type: 'oauth'}. - authStorage.hasStoredApiKey(provider): Checks if a stored API key exists for a provider. - authStorage.isLoggedIn(oauthId): Checks OAuth login status. - authStorage.login(providerId, callbacks) and authStorage.logout(providerId): Handle OAuth flows. API keys are entered interactively via prompts (stored persistently in auth.json), auto-detected from env vars, or loaded from storage. OAuth is used for subscriptions like Claude Max/OpenAI ChatGPT Plus. No explicit documentation or source snippets found for "in-flight refresh" (e.g., OAuth token renewal) or "deduplication" mechanisms in authStorage.getApiKey. Related concepts: - Mastra has past issues with duplicate messages/storage in multi-step/client-side executions (#7843, #9370), fixed via message ID checks and merging logic. - OAuth refresh races noted in similar tools (e.g., Claude Code #24317), suggesting potential need for locking/deduplication in concurrent sessions, but not confirmed in MastraCode authStorage. AuthStorage ensures a single shared instance across providers (tested in create-auth-storage.test.ts), preventing multiple initializations. For API keys, retrieval is synchronous from storage—no refresh needed as they don't expire like OAuth tokens. If implementing custom refresh/deduplication (e.g., for concurrent getApiKey calls), use a singleton pattern, Promise caching, or mutex (e.g., async-mutex) to dedupe in-flight operations: Example pattern: private refreshPromise: Promise | null = null; async getApiKey(provider: string): Promise { if (this.refreshPromise) return this.refreshPromise; // Dedupe in-flight this.refreshPromise = this.refreshStoredOrFetch(provider); try { return await this.refreshPromise; } finally { this.refreshPromise = null; } } Official docs: https://code.mastra.ai/reference, https://code.mastra.ai/configuration. Source: https://github.com/mastra-ai/mastra/tree/main/mastracode/src/auth (inferred from imports).

Citations:


🏁 Script executed:

cd packages/chat && git ls-files src/server/desktop/chat-service/chat-service.ts

Repository: superset-sh/superset

Length of output: 111


🏁 Script executed:

cat -n packages/chat/src/server/desktop/chat-service/chat-service.ts | sed -n '75,110p'

Repository: superset-sh/superset

Length of output: 1724


🌐 Web query:

mastracode authStorage OAuth refresh deduplication 2025 2026

💡 Result:

Short answer: Mastra Code’s auth storage is designed to be a single shared AuthStorage instance (initialized via a dedicated createAuthStorage export), and it doesn’t document a specific “OAuth refresh token deduplication” mechanism in the sources found. Separately, Mastra/SDK-related “deduplication” issues seen in 2025–2026 appear to be about message duplication (not OAuth refresh), while OAuth refresh-token “race/reuse” handling is addressed in other projects (e.g., OpenAI Codex) and generally in auth libraries via refresh token rotation/reuse detection. What I found in Mastra Code (2025–2026) 1) Shared authStorage initialization (Mastra Code) - Mastra Code added/exports a createAuthStorage so auth providers can be initialized without creating a full createMastraCode instance. - The PR notes unit tests asserting createAuthStorage returns a single shared AuthStorage instance and wires it to multiple provider modules. - createMastraCode returns authStorage as an AuthStorage instance, described as “Storage for OAuth credentials”. Sources indicate the focus here is on centralized/one-instance auth storage wiring, not refresh-token request deduplication. 2) OAuth token lifecycle for HTTP MCP servers (Mastra) - Mastra added an authProvider option for HTTP-based MCP servers (merged July/Aug 2025) “to enable OAuth authentication with automatic token refresh.” - A later enhancement issue (Mar 2026) describes the lack of first-class dynamic auth configuration for HTTP MCP servers (static headers only), and mentions adding first-class support; it also references a PR adding OAuth 2.1 authentication for HTTP MCP servers. What I found about “deduplication” vs OAuth refresh - The Mastra issues surfaced in search results for 2025–2026 use “duplicating response messages” terminology and discuss deduping/merging UI messages from storage; this is not OAuth refresh-token deduplication. - For OAuth refresh, I found authoritative guidance from auth systems about refresh token rotation and the security problem of refresh token reuse (race conditions / replay). For example: - Auth.js explicitly notes there is no built-in automatic refresh token rotation and discusses that refresh tokens are usually only usable once, leading to potential race conditions if multiple requests refresh concurrently. - Auth0 describes refresh token rotation and “automatic reuse detection”/invalidating previously-used refresh tokens to prevent replay. - For an explicit “deduplicate/race-guard” approach around OAuth refresh across processes, I found a Codex PR: it fixes “refresh-token races across Codex processes” by doing a guarded reload flow—reloading shared credentials before refresh attempt and comparing persisted vs in-memory snapshots; if persisted credentials changed, it adopts them and skips local refresh; otherwise it refreshes and persists rotated credentials. Conclusion for your query - For Mastra Code: based on the sources found, the clearly documented change in 2025–2026 is centralized shared authStorage initialization (createAuthStorage + shared instance), and OAuth support for HTTP MCP servers via an authProvider (automatic token refresh). - However, I did not find Mastra-specific documentation or code excerpts in these sources that explicitly describe “OAuth refresh deduplication” (e.g., ensuring concurrent refresh calls share one in-flight refresh, or guarding refresh-token reuse across processes) as a named feature. - In general auth systems, dedup/race handling is often implemented via refresh token rotation + reuse detection (Auth0) and by addressing concurrency races (Auth.js mentions the inherent once-only limitation and race conditions). For a concrete guarded approach, the Codex PR provides an example pattern. If you want, I can do a deeper targeted code-level search in the Mastra repository for terms like “refresh_token_reused”, “reuse detection”, “inFlight”, “mutex”, “dedupe”, or “guarded reload” specifically within mastracode and HTTP MCP OAuth flows.

Citations:


🏁 Script executed:

rg "getAnthropicAuthStatus" --type ts --type tsx -B 2 -A 2

Repository: superset-sh/superset

Length of output: 91


🏁 Script executed:

cat -n packages/chat/src/server/desktop/chat-service/chat-service.ts | sed -n '1,80p' | tail -30

Repository: superset-sh/superset

Length of output: 1286


🏁 Script executed:

rg "authStorage\.getApiKey|refresh.*oauth|dedupe|inFlight" packages/chat/src/server/desktop/chat-service/ -A 2

Repository: superset-sh/superset

Length of output: 935


🏁 Script executed:

rg "getAnthropicAuthStatus" -t ts --type-add 'ts:*.ts' -B 3 -A 3

Repository: superset-sh/superset

Length of output: 13673


🏁 Script executed:

cat -n packages/chat/src/server/desktop/chat-service/chat-service.ts | sed -n '100,150p'

Repository: superset-sh/superset

Length of output: 2269


🏁 Script executed:

grep -r "getAnthropicAuthStatus" --include="*.ts" -l | head -10

Repository: superset-sh/superset

Length of output: 301


Ensure getAnthropicAuthStatus doesn't cause status-check delays when OAuth tokens are expired.

The method now performs network I/O (via authStorage.getApiKey) on every call with an expired managed OAuth token. Since it's exposed as a tRPC query and called from the model-providers router (potentially on mount/polling), repeated calls while a refresh endpoint is slow or unavailable could block status checks. The review mentions mastracode's internal deduplication—confirm it handles concurrent refresh calls to prevent request stacking.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/chat/src/server/desktop/chat-service/chat-service.ts` around lines
81 - 102, getAnthropicAuthStatus currently awaits authStorage.getApiKey whenever
a managed OAuth token is expired, which can block tRPC status checks; change it
to avoid synchronous network I/O on the status path by returning the "expired"
status immediately and triggering a background refresh instead. Specifically, in
getAnthropicAuthStatus, when storedCredential.type === "oauth" and
storedCredential.expires <= Date.now(), do not await authStorage.getApiKey;
instead kick off a non-blocking refresh (e.g., call authStorage.getApiKey
without await or schedule a background task) and guard against repeated
refreshes by using an in-memory dedupe (a module-level Map/Promise keyed by
ANTHROPIC_AUTH_PROVIDER_ID) so concurrent calls only trigger one refresh; leave
the storedCredential reloading flow intact for successful refresh completion but
ensure the tRPC response is returned without waiting.

const configCredential = getAnthropicCredentialsFromConfig();
const keychainCredential = getAnthropicCredentialsFromKeychain();
const externalCandidates = [configCredential, keychainCredential].filter(
Expand Down
5 changes: 4 additions & 1 deletion packages/chat/src/server/desktop/small-model/small-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,10 @@ export interface SmallModelCredential {
export interface SmallModelProvider {
id: SmallModelProviderId;
name: string;
resolveCredentials: () => SmallModelCredential | null;
resolveCredentials: () =>
| SmallModelCredential
| null
| Promise<SmallModelCredential | null>;
isSupported: (credentials: SmallModelCredential) => {
supported: boolean;
reason?: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,12 @@ export class LocalModelProvider implements ModelProviderRuntimeResolver {
this.anthropicEnvConfigPath = options?.anthropicEnvConfigPath;
}

private resolveRuntimeEnv(): {
private async resolveRuntimeEnv(): Promise<{
env: Record<string, string>;
cleanupKeys: string[];
hasUsableRuntimeEnv: boolean;
} {
const anthropicCredential = resolveAnthropicCredential();
}> {
const anthropicCredential = await resolveAnthropicCredential();
const openaiCredential = resolveOpenAICredential();
const anthropicEnvConfig = getAnthropicEnvConfig({
configPath: this.anthropicEnvConfigPath,
Expand All @@ -54,11 +54,11 @@ export class LocalModelProvider implements ModelProviderRuntimeResolver {
}

async hasUsableRuntimeEnv(): Promise<boolean> {
return this.resolveRuntimeEnv().hasUsableRuntimeEnv;
return (await this.resolveRuntimeEnv()).hasUsableRuntimeEnv;
}

async prepareRuntimeEnv(): Promise<void> {
const runtimeEnv = this.resolveRuntimeEnv();
const runtimeEnv = await this.resolveRuntimeEnv();
this.currentRuntimeEnv = applyRuntimeEnv(
runtimeEnv.env,
runtimeEnv.cleanupKeys,
Expand Down
Loading
Loading