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
16 changes: 8 additions & 8 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
"eval": "npx tsx tests/evals/runner.ts"
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.37",
"@anthropic-ai/claude-agent-sdk": "^0.2.92",
"@anthropic-ai/sdk": "^0.71.2",
"@electron-toolkit/utils": "^4.0.0",
"@floating-ui/dom": "^1.7.6",
Expand Down Expand Up @@ -88,10 +88,10 @@
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@types/semver": "^7.7.1",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"@typescript-eslint/eslint-plugin": "^8.57.2",
"@typescript-eslint/parser": "^8.57.2",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"electron": "^39.8.5",
"electron-builder": "^25.1.8",
"electron-vite": "^3.0.0",
Expand Down
14 changes: 10 additions & 4 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -389,14 +389,20 @@ ipcMain.handle("default-mail-app:get-pending", () => {
// Initialize database on startup
const _db = initDatabase();

// Wire up AnthropicService cost tracking
import { setAnthropicServiceDb } from "./services/anthropic-service";
// Wire up AnthropicService cost tracking + LLM backend toggle
import { setAnthropicServiceDb, setLlmBackendFromConfig } from "./services/anthropic-service";
setAnthropicServiceDb(_db);

// If no ANTHROPIC_API_KEY in env (e.g. packaged app with no .env), read from stored config
// so that services using `new Anthropic()` pick it up automatically.
{
const config = getConfig();

// Set LLM backend from persisted config (env var takes precedence inside getLlmBackend())
if (config.llmBackend) {
setLlmBackendFromConfig(config.llmBackend);
}

// If using Anthropic backend: read stored API key into env so `new Anthropic()` picks it up.
// When using claude-sdk backend, API key is not required.
if (!process.env.ANTHROPIC_API_KEY && config.anthropicApiKey) {
process.env.ANTHROPIC_API_KEY = config.anthropicApiKey;
}
Expand Down
6 changes: 5 additions & 1 deletion src/main/ipc/gmail.ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { ipcMain } from "electron";
import { GmailClient } from "../services/gmail-client";
import { saveEmail, getEmailIds, getInboxEmails, getEmail, saveAccount, getAccounts } from "../db";
import { getConfig } from "./settings.ipc";
import { getLlmBackend } from "../services/anthropic-service";
import type { IpcResponse, DashboardEmail } from "../../shared/types";
import { DEMO_INBOX_EMAILS, DEMO_EXPECTED_ANALYSIS } from "../demo/fake-inbox";
import { createLogger } from "../services/logger";
Expand Down Expand Up @@ -70,7 +71,10 @@ export function registerGmailIpc(): void {

try {
const client = new GmailClient();
const hasAnthropicKey = !!(process.env.ANTHROPIC_API_KEY || getConfig().anthropicApiKey);
// Claude SDK backend doesn't need an API key — the subscription handles auth
const hasAnthropicKey =
getLlmBackend() === "claude-sdk" ||
!!(process.env.ANTHROPIC_API_KEY || getConfig().anthropicApiKey);
return {
success: true,
data: {
Expand Down
17 changes: 14 additions & 3 deletions src/main/ipc/settings.ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,12 @@ import {
} from "../../shared/types";
import { resetAnalyzer } from "./analysis.ipc";
import { resetArchiveReadyAnalyzer } from "./archive-ready.ipc";
import { resetClient, getUsageStats, getCallHistory } from "../services/anthropic-service";
import {
resetClient,
getUsageStats,
getCallHistory,
setLlmBackendFromConfig,
} from "../services/anthropic-service";
import { prefetchService } from "../services/prefetch-service";
import { agentCoordinator } from "../agents/agent-coordinator";
import {
Expand Down Expand Up @@ -70,6 +75,7 @@ function getStore(): Store<{ config: Config }> {
},
keyboardBindings: "superhuman" as const,
configVersion: 1,
llmBackend: "anthropic" as const,
},
},
});
Expand Down Expand Up @@ -272,6 +278,11 @@ export function registerSettingsIpc(): void {
});
}

// Propagate LLM backend change
if ("llmBackend" in config && newConfig.llmBackend) {
setLlmBackendFromConfig(newConfig.llmBackend);
}

// Append any new extra PATH directories so they take effect without restart
if ("extraPathDirs" in config) {
const pathEntries = new Set((process.env.PATH || "").split(":"));
Expand All @@ -283,9 +294,9 @@ export function registerSettingsIpc(): void {
}
}

// Reset cached analyzer/service instances when model config or API key changes,
// Reset cached analyzer/service instances when model config, API key, or backend changes,
// since they hold Anthropic client instances that capture the key at construction.
if ("modelConfig" in config || "anthropicApiKey" in config) {
if ("modelConfig" in config || "anthropicApiKey" in config || "llmBackend" in config) {
resetClient();
resetAnalyzer();
resetArchiveReadyAnalyzer();
Expand Down
72 changes: 72 additions & 0 deletions src/main/services/anthropic-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type {
} from "@anthropic-ai/sdk/resources/messages";
import { createLogger } from "./logger";
import { randomUUID } from "crypto";
import { createMessageViaSdk } from "./claude-sdk-service";

const log = createLogger("anthropic");

Expand Down Expand Up @@ -86,6 +87,28 @@ interface CreateOptions {
timeoutMs?: number;
}

// LLM backend toggle — env var takes precedence, then persisted config
export type LlmBackend = "anthropic" | "claude-sdk";

let _configBackend: LlmBackend | null = null;

/**
* Set the LLM backend from persisted config (called during app init).
*/
export function setLlmBackendFromConfig(backend: LlmBackend): void {
_configBackend = backend;
}

/**
* Get the active LLM backend.
* Priority: LLM_BACKEND env var > persisted config > default ("anthropic").
*/
export function getLlmBackend(): LlmBackend {
const envVal = process.env.LLM_BACKEND;
if (envVal === "anthropic" || envVal === "claude-sdk") return envVal;
return _configBackend ?? "anthropic";
}

// Anthropic client — singleton for production, replaceable for testing
let _anthropicClient: Anthropic | null = null;
let _defaultClient: Anthropic | null = null;
Expand Down Expand Up @@ -273,11 +296,17 @@ function getRetryCategory(error: unknown): string | null {

/**
* Create a message using Claude API with retry and cost tracking.
* When LLM_BACKEND=claude-sdk, delegates to the Claude Agent SDK adapter.
*/
export async function createMessage(
params: MessageCreateParamsNonStreaming,
options: CreateOptions,
): Promise<Message> {
// Delegate to Claude Agent SDK if configured
if (getLlmBackend() === "claude-sdk") {
return createMessageViaClaudeSdk(params, options);
}

const { caller, emailId, accountId, timeoutMs } = options;
const model = params.model;
const startTime = Date.now();
Expand Down Expand Up @@ -452,3 +481,46 @@ export function getCallHistory(limit: number = 50): LlmCallRecord[] {
.prepare("SELECT * FROM llm_calls ORDER BY created_at DESC LIMIT ?")
.all(limit) as LlmCallRecord[];
}

/**
* Bridge: delegate createMessage to the Claude Agent SDK adapter,
* then record cost in the same llm_calls table.
*/
async function createMessageViaClaudeSdk(
params: MessageCreateParamsNonStreaming,
options: CreateOptions,
): Promise<Message> {
const { caller, emailId, accountId, timeoutMs } = options;
const model = params.model;
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
const startTime = Date.now();

try {
const { message, durationMs, inputTokens, outputTokens } = await createMessageViaSdk(params, {
caller,
emailId,
accountId,
timeoutMs,
});

// Record to llm_calls for unified cost tracking
recordCall(
model,
caller,
emailId || null,
accountId || null,
inputTokens,
outputTokens,
0, // cache_read — not applicable for SDK
0, // cache_create — not applicable for SDK
durationMs,
true,
null,
);

return message;
} catch (error) {
const errMsg = error instanceof Error ? error.message : String(error);
recordCall(model, caller, emailId || null, accountId || null, 0, 0, 0, 0, Date.now() - startTime, false, errMsg);
throw error;
}
}
Loading