From 622fdfe2f16c97b855a3d8e30d0666459eb5e35d Mon Sep 17 00:00:00 2001 From: Aditya Gupta Date: Thu, 16 Jul 2026 13:21:00 +0530 Subject: [PATCH] feat(llm): Added Anthropic Claude provider --- .../src/lib/simulation/provider-resolver.ts | 8 ++ package.json | 1 + packages/llm/README.md | 38 ++++-- packages/llm/src/config.ts | 1 + packages/llm/src/index.ts | 1 + packages/llm/src/llm.ts | 7 ++ packages/llm/src/provider-manager.ts | 61 ++++++++++ packages/llm/src/providers/anthropic.ts | 114 ++++++++++++++++++ pnpm-lock.yaml | 76 ++++++++++++ 9 files changed, 300 insertions(+), 7 deletions(-) create mode 100644 packages/llm/src/providers/anthropic.ts diff --git a/apps/gui/src/lib/simulation/provider-resolver.ts b/apps/gui/src/lib/simulation/provider-resolver.ts index b7fcc57..bdb22fb 100644 --- a/apps/gui/src/lib/simulation/provider-resolver.ts +++ b/apps/gui/src/lib/simulation/provider-resolver.ts @@ -5,6 +5,7 @@ import { OllamaEmbeddingProvider, ProviderManager, OpenRouterProvider, + AnthropicProvider, GeminiEmbeddingProvider, MockEmbeddingProvider, } from "@omnia/llm"; @@ -67,6 +68,13 @@ function buildLLMProvider(inst: ModelProviderInstance): ILLMProvider { inst.name, inst.maxContext, ); + } else if (inst.providerName === "anthropic") { + return new AnthropicProvider( + inst.apiKey, + inst.modelName, + inst.name, + inst.maxContext, + ); } return new MockLLMProvider([]); } diff --git a/package.json b/package.json index 7d83620..f4d1a3d 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "zod": "^4.4.3" }, "dependencies": { + "@langchain/anthropic": "^0.3.11", "@langchain/google-genai": "^2.2.0", "@langchain/ollama": "^0.2.3", "@langchain/openrouter": "^0.4.3", diff --git a/packages/llm/README.md b/packages/llm/README.md index 249379f..4f0fea8 100644 --- a/packages/llm/README.md +++ b/packages/llm/README.md @@ -258,6 +258,27 @@ When no active row is found for the requested type: Also exports `GeminiEmbeddingProvider` (implements `IEmbeddingProvider`) using the same key resolution pattern but querying for the `"embedding"` type. +### Anthropic Claude — `AnthropicProvider` + +| Property | Value | +| --------------------------- | ------------------------------------------------------ | +| **File** | [`providers/anthropic.ts`](src/providers/anthropic.ts) | +| **Provider ID** | `anthropic` | +| **SDK** | `@langchain/anthropic` (`ChatAnthropic`) | +| **Default Model** | `claude-3-5-sonnet-latest` | +| **Default Embedding Model** | _(none)_ | +| **Default Max Context** | `200000` | +| **Type** | Generative only (no embedding provider) | + +**Key resolution** in the constructor follows this cascade: + +``` +1. Explicit apiKey argument → use it +2. ProviderManager.getActive() → if providerName matches "anthropic" +3. ANTHROPIC_API_KEY env var → final fallback +4. None found → throw Error +``` + ### OpenRouter — `OpenRouterProvider` | Property | Value | @@ -284,21 +305,21 @@ Same three-step key resolution as Gemini (`explicit → ProviderManager → env | **Default Max Context** | `32768` | | **Type** | Generative + Embedding | -Ollama runs **locally** — no API key is required. The `apiKey` field in `ModelProviderInstance` is repurposed to store the Ollama server base URL (default: `http://localhost:11434`). +Ollama runs **locally** — no API key is required. The `endpointUrl` field in `ModelProviderInstance` stores the Ollama server base URL (default: `http://localhost:11434`). **Key resolution** in the constructor: ``` 1. Explicit baseUrl argument → use it 2. ProviderManager.getActive() → if providerName matches "ollama" - (apiKey field = base URL) + (endpointUrl field = base URL) 3. Default → http://localhost:11434 ``` Also exports `OllamaEmbeddingProvider` (implements `IEmbeddingProvider`), which uses the same resolution pattern against the `"embedding"` type instance. The default embedding model is `nomic-embed-text`. > [!TIP] -> To get started: `ollama pull llama3.1` and `ollama pull nomic-embed-text`. Then create a provider instance with `apiKey` = `http://localhost:11434`. +> To get started: `ollama pull llama3.1` and `ollama pull nomic-embed-text`. Then create a provider instance with `endpointUrl` = `http://localhost:11434`. ### Mock — `MockLLMProvider` @@ -358,6 +379,7 @@ The `buildLLMProvider()` and `buildEmbeddingProvider()` functions perform the fi | `"google-genai"` | `GeminiProvider` | `GeminiEmbeddingProvider` | | `"openrouter"` | `OpenRouterProvider` | _(falls through to mock)_ | | `"ollama"` | `OllamaProvider` | `OllamaEmbeddingProvider` | +| `"anthropic"` | `AnthropicProvider` | _(falls through to mock)_ | | _(anything else)_ | `MockLLMProvider` | `MockEmbeddingProvider` | ## Structured Output @@ -380,10 +402,11 @@ This sends the Zod schema to the model as a structured output constraint. The re [`config.ts`](src/config.ts) parses environment variables using Zod: -| Variable | Required | Description | -| -------------------- | -------- | --------------------- | -| `GOOGLE_API_KEY` | No | Google Gemini API key | -| `OPENROUTER_API_KEY` | No | OpenRouter API key | +| Variable | Required | Description | +| -------------------- | -------- | ------------------------ | +| `GOOGLE_API_KEY` | No | Google Gemini API key | +| `OPENROUTER_API_KEY` | No | OpenRouter API key | +| `ANTHROPIC_API_KEY` | No | Anthropic Claude API key | Both are optional because providers can also be configured through the database via the GUI settings page. @@ -400,6 +423,7 @@ packages/llm/ │ ├── google-genai.ts # GeminiProvider + GeminiEmbeddingProvider │ ├── ollama.ts # OllamaProvider + OllamaEmbeddingProvider │ ├── openrouter.ts # OpenRouterProvider +│ ├── anthropic.ts # AnthropicProvider │ └── mock.ts # MockLLMProvider + MockEmbeddingProvider ├── tests/ │ ├── mock.test.ts diff --git a/packages/llm/src/config.ts b/packages/llm/src/config.ts index 0605902..dad9d7b 100644 --- a/packages/llm/src/config.ts +++ b/packages/llm/src/config.ts @@ -3,6 +3,7 @@ import { z } from "zod"; const LLMConfigSchema = z.object({ GOOGLE_API_KEY: z.string().optional(), OPENROUTER_API_KEY: z.string().optional(), + ANTHROPIC_API_KEY: z.string().optional(), }); export const llmConfig = LLMConfigSchema.parse(process.env); diff --git a/packages/llm/src/index.ts b/packages/llm/src/index.ts index a7c747b..bb0aee7 100644 --- a/packages/llm/src/index.ts +++ b/packages/llm/src/index.ts @@ -4,4 +4,5 @@ export * from "./providers/google-genai.js"; export * from "./providers/mock.js"; export * from "./providers/ollama.js"; export * from "./providers/openrouter.js"; +export * from "./providers/anthropic.js"; export * from "./provider-manager.js"; diff --git a/packages/llm/src/llm.ts b/packages/llm/src/llm.ts index ad8a7aa..ea850c9 100644 --- a/packages/llm/src/llm.ts +++ b/packages/llm/src/llm.ts @@ -76,6 +76,13 @@ export const AVAILABLE_PROVIDERS: ModelProviderMeta[] = [ defaultModel: "gemini-2.5-flash", defaultEmbeddingModel: "gemini-embedding-001", }, + { + id: "anthropic", + displayName: "Anthropic Claude", + description: "Official Claude integration using @langchain/anthropic SDK", + defaultModel: "claude-3-5-sonnet-latest", + defaultEmbeddingModel: "", + }, { id: "openrouter", displayName: "OpenRouter", diff --git a/packages/llm/src/provider-manager.ts b/packages/llm/src/provider-manager.ts index 4401990..4fc572d 100644 --- a/packages/llm/src/provider-manager.ts +++ b/packages/llm/src/provider-manager.ts @@ -99,6 +99,7 @@ function getSettingsDb() { if (totalCount.count === 0) { const googleKey = process.env.GOOGLE_API_KEY; const openRouterKey = process.env.OPENROUTER_API_KEY; + const anthropicKey = process.env.ANTHROPIC_API_KEY; let hasInsertedGenerative = false; if (googleKey && googleKey.trim()) { @@ -138,6 +139,29 @@ function getSettingsDb() { ); } + if (anthropicKey && anthropicKey.trim()) { + const id = "provider-default-anthropic"; + const isActive = hasInsertedGenerative ? 0 : 1; + db.prepare( + ` + INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `, + ).run( + id, + "Anthropic (Env)", + "anthropic", + anthropicKey.trim(), + isActive, + "claude-3-5-sonnet-latest", + "generative", + 200000, + ); + if (isActive === 1) { + hasInsertedGenerative = true; + } + } + if (openRouterKey && openRouterKey.trim()) { const id = "provider-default-openrouter"; const isActive = hasInsertedGenerative ? 0 : 1; @@ -391,6 +415,7 @@ export class ProviderManager { if (totalCount.count === 0) { const googleKey = process.env.GOOGLE_API_KEY; const openRouterKey = process.env.OPENROUTER_API_KEY; + const anthropicKey = process.env.ANTHROPIC_API_KEY; let hasInsertedGenerative = false; if (googleKey && googleKey.trim()) { @@ -430,6 +455,29 @@ export class ProviderManager { ); } + if (anthropicKey && anthropicKey.trim()) { + const id = "provider-default-anthropic"; + const isActive = hasInsertedGenerative ? 0 : 1; + db.prepare( + ` + INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `, + ).run( + id, + "Anthropic (Env)", + "anthropic", + anthropicKey.trim(), + isActive, + "claude-3-5-sonnet-latest", + "generative", + 200000, + ); + if (isActive === 1) { + hasInsertedGenerative = true; + } + } + if (openRouterKey && openRouterKey.trim()) { const id = "provider-default-openrouter"; const isActive = hasInsertedGenerative ? 0 : 1; @@ -576,6 +624,19 @@ export class ProviderManager { maxContext: 32768, }; } + const anthropicKey = process.env.ANTHROPIC_API_KEY; + if (anthropicKey && anthropicKey.trim()) { + return { + id: "provider-default-env-fallback", + name: "Anthropic (Env Fallback)", + providerName: "anthropic", + apiKey: anthropicKey.trim(), + isActive: true, + modelName: "claude-3-5-sonnet-latest", + type: "generative", + maxContext: 200000, + }; + } const openRouterKey = process.env.OPENROUTER_API_KEY; if (openRouterKey && openRouterKey.trim()) { return { diff --git a/packages/llm/src/providers/anthropic.ts b/packages/llm/src/providers/anthropic.ts new file mode 100644 index 0000000..03d7ebd --- /dev/null +++ b/packages/llm/src/providers/anthropic.ts @@ -0,0 +1,114 @@ +import { z } from "zod"; +import { ChatAnthropic } from "@langchain/anthropic"; +import { + ILLMProvider, + LLMRequest, + LLMResponse, + LLMCallRecord, +} from "../llm.js"; +import { llmConfig } from "../config.js"; +import { ProviderManager } from "../provider-manager.js"; + +export class AnthropicProvider implements ILLMProvider { + static readonly providerId = "anthropic"; + static readonly displayName = "Anthropic Claude"; + static readonly description = + "Official Claude integration using @langchain/anthropic SDK"; + static readonly defaultModel = "claude-3-5-sonnet-latest"; + + providerName = "Anthropic"; + private model: ChatAnthropic; + private modelNameUsed: string; + private providerInstanceName?: string; + private maxContextUsed?: number; + lastCalls: LLMCallRecord[] = []; + + constructor( + apiKey?: string, + modelName?: string, + providerInstanceName?: string, + maxContext?: number, + ) { + let key = apiKey; + let model = modelName; + this.providerInstanceName = providerInstanceName; + this.maxContextUsed = maxContext; + + if (!key) { + const active = ProviderManager.getActive("generative"); + if (active && active.providerName === AnthropicProvider.providerId) { + key = active.apiKey; + if (!model) { + model = active.modelName; + } + if (!this.providerInstanceName) { + this.providerInstanceName = active.name; + } + if (this.maxContextUsed === undefined) { + this.maxContextUsed = active.maxContext; + } + } + } + + if (!key) { + key = llmConfig.ANTHROPIC_API_KEY; + if (!this.providerInstanceName && key) { + this.providerInstanceName = "Environment Variable"; + } + } + + if (!key) { + throw new Error( + "ANTHROPIC_API_KEY is required to initialize AnthropicProvider", + ); + } + + this.modelNameUsed = model || AnthropicProvider.defaultModel; + this.model = new ChatAnthropic({ + apiKey: key, + model: this.modelNameUsed, + }); + } + + async generateStructuredResponse( + request: LLMRequest, + ): Promise>> { + const structuredModel = this.model.withStructuredOutput(request.schema, { + includeRaw: true, + }); + const result = (await structuredModel.invoke([ + { role: "system", content: request.systemPrompt }, + { role: "user", content: request.userContext }, + ])) as unknown as { + parsed?: z.infer; + raw?: { + usage_metadata?: { + input_tokens?: number; + output_tokens?: number; + total_tokens?: number; + }; + }; + }; + + const parsed = result?.parsed; + const raw = result?.raw; + + const usage = { + inputTokens: raw?.usage_metadata?.input_tokens || 0, + outputTokens: raw?.usage_metadata?.output_tokens || 0, + totalTokens: raw?.usage_metadata?.total_tokens || 0, + modelName: this.modelNameUsed, + providerInstanceName: this.providerInstanceName || "Default", + maxContext: + this.maxContextUsed !== undefined ? this.maxContextUsed : 200000, + }; + + this.lastCalls.push({ + systemPrompt: request.systemPrompt, + userContext: request.userContext, + usage, + }); + + return { success: true, data: parsed, usage }; + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4d9b0b3..63145f6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -260,6 +260,9 @@ settings: importers: .: dependencies: + "@langchain/anthropic": + specifier: ^0.3.11 + version: 0.3.34(zod@4.4.3) "@langchain/google-genai": specifier: ^2.2.0 version: 2.2.0(@langchain/core@1.2.1(openai@6.45.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0)) @@ -564,6 +567,18 @@ packages: integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==, } + "@anthropic-ai/sdk@0.65.0": + resolution: + { + integrity: sha512-zIdPOcrCVEI8t3Di40nH4z9EoeyGZfXbYSvWdDLsB/KkaSYMnEgC7gmcgWu83g2NTn1ZTpbMvpdttWDGGIk6zw==, + } + hasBin: true + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + "@astrojs/compiler-binding-darwin-arm64@0.3.0": resolution: { @@ -2052,6 +2067,15 @@ packages: integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==, } + "@langchain/anthropic@0.3.34": + resolution: + { + integrity: sha512-8bOW1A2VHRCjbzdYElrjxutKNs9NSIxYRGtR+OJWVzluMqoKKh2NmmFrpPizEyqCUEG2tTq5xt6XA1lwfqMJRA==, + } + engines: { node: ">=18" } + peerDependencies: + "@langchain/core": ">=0.3.58 <0.4.0" + "@langchain/core@1.2.1": resolution: { @@ -5697,6 +5721,13 @@ packages: integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==, } + fast-xml-parser@4.5.7: + resolution: + { + integrity: sha512-a6Qh1RMCNbSrU1+sAyAAZH3rTe+OaWJbNZIq0S+ifZciUUOQtlVxBJwoTUE2bYhysmG/RYyI5WJFIKdBahJdrQ==, + } + hasBin: true + fastq@1.20.1: resolution: { @@ -6516,6 +6547,13 @@ packages: integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==, } + json-schema-to-ts@3.1.1: + resolution: + { + integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==, + } + engines: { node: ">=16" } + json-schema-traverse@0.4.1: resolution: { @@ -8642,6 +8680,12 @@ packages: } engines: { node: ">=0.10.0" } + strnum@1.1.2: + resolution: + { + integrity: sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==, + } + style-to-js@1.1.21: resolution: { @@ -8812,6 +8856,12 @@ packages: integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==, } + ts-algebra@2.0.0: + resolution: + { + integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==, + } + ts-api-utils@2.5.0: resolution: { @@ -9487,6 +9537,12 @@ snapshots: package-manager-detector: 1.7.0 tinyexec: 1.2.4 + "@anthropic-ai/sdk@0.65.0(zod@4.4.3)": + dependencies: + json-schema-to-ts: 3.1.1 + optionalDependencies: + zod: 4.4.3 + "@astrojs/compiler-binding-darwin-arm64@0.3.0": optional: true @@ -10367,6 +10423,13 @@ snapshots: "@jridgewell/resolve-uri": 3.1.2 "@jridgewell/sourcemap-codec": 1.5.5 + "@langchain/anthropic@0.3.34(zod@4.4.3)": + dependencies: + "@anthropic-ai/sdk": 0.65.0(zod@4.4.3) + fast-xml-parser: 4.5.7 + transitivePeerDependencies: + - zod + "@langchain/core@1.2.1(openai@6.45.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0)": dependencies: "@cfworker/json-schema": 4.1.1 @@ -12869,6 +12932,10 @@ snapshots: dependencies: fast-string-width: 3.0.2 + fast-xml-parser@4.5.7: + dependencies: + strnum: 1.1.2 + fastq@1.20.1: dependencies: reusify: 1.1.0 @@ -13365,6 +13432,11 @@ snapshots: json-parse-even-better-errors@2.3.1: {} + json-schema-to-ts@3.1.1: + dependencies: + "@babel/runtime": 7.29.7 + ts-algebra: 2.0.0 + json-schema-traverse@0.4.1: {} json-schema-traverse@1.0.0: {} @@ -15052,6 +15124,8 @@ snapshots: strip-json-comments@2.0.1: {} + strnum@1.1.2: {} + style-to-js@1.1.21: dependencies: style-to-object: 1.0.14 @@ -15135,6 +15209,8 @@ snapshots: trough@2.2.0: {} + ts-algebra@2.0.0: {} + ts-api-utils@2.5.0(typescript@6.0.3): dependencies: typescript: 6.0.3