From 1d453073875b2f52a225ebb6049a7bd7996dbb18 Mon Sep 17 00:00:00 2001 From: elpresidank Date: Tue, 2 Jun 2026 08:36:55 -0500 Subject: [PATCH] Migrate Claude provider to Effect AI --- ts/EFFECT_NATIVE_REWRITE_AUDIT.md | 45 ++++- ts/bun.lock | 7 - ts/packages/flow/package.json | 19 +- .../text-completion-providers.test.ts | 31 ++++ .../flow/src/model/text-completion/claude.ts | 170 +++++------------- 5 files changed, 116 insertions(+), 156 deletions(-) diff --git a/ts/EFFECT_NATIVE_REWRITE_AUDIT.md b/ts/EFFECT_NATIVE_REWRITE_AUDIT.md index 41419c04..5f6cb694 100644 --- a/ts/EFFECT_NATIVE_REWRITE_AUDIT.md +++ b/ts/EFFECT_NATIVE_REWRITE_AUDIT.md @@ -17,7 +17,7 @@ adapter and native request/response PubSub slices: | Signal | Count | | --- | ---: | -| `Effect.runPromise` | 170 | +| `Effect.runPromise` | 169 | | `Effect.runPromiseWith` | 0 | | `Effect.cached` | 0 | | `Layer.succeed` | 12 | @@ -150,6 +150,10 @@ Notes: `{ id, value }` envelopes through native `effect/PubSub`, and each request uses a scoped `PubSub.Subscription` plus `Stream.fromSubscription` to wait for its matching response. +- The Claude Effect AI slice moved the Claude provider off the direct + `@anthropic-ai/sdk` wrapper and onto `@effect/ai-anthropic` + `AnthropicLanguageModel` through `makeLanguageModelProvider`. The direct SDK + dependency was removed from `@trustgraph/flow`. - A focused broker-backend scout found no remaining P0 broker runtime rewrite after the producer, NATS, consumer concurrency, rate-limit, and request-response stop slices. `PubSubBackend` remains an intentional @@ -1402,6 +1406,29 @@ Notes: - `cd ts && bun run check:tsgo` - `cd ts/packages/base && bunx --bun vitest run src/__tests__/messaging-runtime.test.ts src/__tests__/request-response.test.ts src/__tests__/flow-spec-runtime.test.ts` +### 2026-06-02: Claude Effect AI Provider Slice + +- Status: migrated and package-verified. +- Completed: + - Replaced the direct `@anthropic-ai/sdk` provider implementation with + `@effect/ai-anthropic` `AnthropicLanguageModel` plus the shared + `makeLanguageModelProvider` adapter. + - Preserved `CLAUDE_KEY`, default model, temperature, max output, processor, + and public `LlmProvider` compatibility behavior. + - Added Claude provider config coverage for `CLAUDE_KEY` env fallback and the + missing-key tagged config error. + - Removed the direct `@anthropic-ai/sdk` dependency and its lockfile entries + from `@trustgraph/flow`. +- Verification: + - `cd ts && bun run check:tsgo` + - `cd ts/packages/flow && bunx --bun vitest run src/__tests__/text-completion-providers.test.ts src/__tests__/text-completion-common.test.ts` + - `cd ts/packages/flow && bun run build` + - `cd ts && bun run check` + - `cd ts && bun run build` + - `cd ts && bun run test` + - `cd ts && bun run lint` + - `git diff --check` + ## Subagent Findings To Preserve - MCP/workbench: @@ -1487,10 +1514,9 @@ Notes: local provider layer-construction cleanup is complete; remaining provider work is adapter/parity work, not `Layer.succeed` cleanup. - The `effect/unstable/ai/LanguageModel` to TrustGraph `LlmProvider` adapter - baseline is complete. The next provider PR should migrate Claude through - that adapter with provider-specific parity tests. Direct OpenAI, Azure, and - OpenAI-compatible swaps are no-ops until Responses-vs-Chat-Completions - parity is proven. + baseline is complete, and Claude now uses `@effect/ai-anthropic` through + that adapter. Direct OpenAI, Azure, and OpenAI-compatible swaps are no-ops + until Responses-vs-Chat-Completions parity is proven. - FalkorDB scoped lifecycle is complete for triples query/store. Use the fakeable client/graph factory pattern from that slice for future storage client tests. @@ -1549,7 +1575,7 @@ Notes: - Treat the legacy consumer facade as a completed compatibility wrapper over `makeEffectConsumerFromPubSub`; do not flag blocking `start()` semantics. -### P2: Effect AI Provider Adapter Cleanup +### No-op: Remaining Effect AI Provider Swaps - TrustGraph evidence: - `ts/packages/flow/src/model/text-completion/*.ts` @@ -1559,10 +1585,12 @@ Notes: - Rewrite shape: - Adapter baseline is complete: `makeLanguageModelProvider` bridges `LanguageModel` into `LlmProvider`. - - Claude is the first plausible provider migration through the adapter. + - Claude migration is complete through `@effect/ai-anthropic`. - Do not directly swap OpenAI, Azure, or OpenAI-compatible providers yet: current TrustGraph code uses Chat Completions/local-server semantics while `@effect/ai-openai` is Responses API backed. + - Do not directly swap Mistral or Ollama until an installed Effect provider + package exists or a parity-backed local Effect wrapper is designed. - Tests: - Provider parity for `LlmResult`, final streaming chunk token counts, 429 mapping, missing-token config failures, and OpenAI-compatible local-server @@ -1603,8 +1631,7 @@ Notes: ## Recommended PR Order -1. Claude provider parity through the Effect AI `LanguageModel` adapter. -2. MCP Effect stdio parity and canonicalization. +1. MCP Effect stdio parity and canonicalization. ## No-Op Rules diff --git a/ts/bun.lock b/ts/bun.lock index 87261004..2a3136ac 100644 --- a/ts/bun.lock +++ b/ts/bun.lock @@ -97,7 +97,6 @@ "name": "@trustgraph/flow", "version": "0.1.0", "dependencies": { - "@anthropic-ai/sdk": "^0.39.0", "@effect/ai-anthropic": "4.0.0-beta.75", "@effect/ai-openai": "4.0.0-beta.75", "@effect/ai-openrouter": "4.0.0-beta.75", @@ -200,8 +199,6 @@ }, }, "packages": { - "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.39.0", "", { "dependencies": { "@types/node": "18.19.130", "@types/node-fetch": "2.6.13", "abort-controller": "3.0.0", "agentkeepalive": "4.6.0", "form-data-encoder": "1.7.2", "formdata-node": "4.4.1", "node-fetch": "2.7.0" } }, "sha512-eMyDIPRZbt1CCLErRCi3exlAvNkBtRe+kW5vvJyef93PmNr/clstYgHhtvmkxN82nlKgzyGPCyGxrm0JQ1ZIdg=="], - "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "7.28.5", "js-tokens": "4.0.0", "picocolors": "1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], "@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="], @@ -1350,8 +1347,6 @@ "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], - "@anthropic-ai/sdk/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "5.26.5" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], - "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], @@ -1394,8 +1389,6 @@ "vite/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], - "@anthropic-ai/sdk/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], - "@trustgraph/base/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "@trustgraph/client/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], diff --git a/ts/packages/flow/package.json b/ts/packages/flow/package.json index 52d6dd58..0c3f1446 100644 --- a/ts/packages/flow/package.json +++ b/ts/packages/flow/package.json @@ -11,16 +11,6 @@ "test": "bunx --bun vitest run" }, "dependencies": { - "@anthropic-ai/sdk": "^0.39.0", - "@fastify/websocket": "^11.0.0", - "@qdrant/js-client-rest": "^1.13.0", - "@trustgraph/base": "workspace:*", - "falkordb": "^5.0.0", - "fastify": "^5.2.0", - "ollama": "^0.6.3", - "@mistralai/mistralai": "^1.0.0", - "@effect/platform-node": "4.0.0-beta.75", - "@effect/platform-node-shared": "4.0.0-beta.75", "@effect/ai-anthropic": "4.0.0-beta.75", "@effect/ai-openai": "4.0.0-beta.75", "@effect/ai-openrouter": "4.0.0-beta.75", @@ -29,10 +19,19 @@ "@effect/opentelemetry": "4.0.0-beta.75", "@effect/platform-browser": "4.0.0-beta.75", "@effect/platform-bun": "4.0.0-beta.75", + "@effect/platform-node": "4.0.0-beta.75", + "@effect/platform-node-shared": "4.0.0-beta.75", "@effect/tsgo": "0.13.0", "@effect/vitest": "4.0.0-beta.75", + "@fastify/websocket": "^11.0.0", + "@mistralai/mistralai": "^1.0.0", "@modelcontextprotocol/sdk": "^1.12.0", + "@qdrant/js-client-rest": "^1.13.0", + "@trustgraph/base": "workspace:*", "effect": "4.0.0-beta.75", + "falkordb": "^5.0.0", + "fastify": "^5.2.0", + "ollama": "^0.6.3", "openai": "^4.85.0", "pdfjs-dist": "^5.6.205" }, diff --git a/ts/packages/flow/src/__tests__/text-completion-providers.test.ts b/ts/packages/flow/src/__tests__/text-completion-providers.test.ts index 58b33af1..301e08e7 100644 --- a/ts/packages/flow/src/__tests__/text-completion-providers.test.ts +++ b/ts/packages/flow/src/__tests__/text-completion-providers.test.ts @@ -83,4 +83,35 @@ describe("text completion provider construction", () => { }); }), ); + + it.effect( + "loads Claude API key from config provider", + Effect.fnUntraced(function* () { + const provider = yield* makeClaudeProviderEffect({ id: "claude" }).pipe( + Effect.provide( + ConfigProvider.layer( + ConfigProvider.fromEnv({ env: { CLAUDE_KEY: "env-key" } }), + ), + ), + ); + + expect(provider.supportsStreaming()).toBe(true); + }), + ); + + it.effect( + "fails missing Claude API key as a tagged config error", + Effect.fnUntraced(function* () { + const error = yield* makeClaudeProviderEffect({ id: "claude" }).pipe( + Effect.flip, + Effect.provide(emptyConfig), + ); + + expect(error).toMatchObject({ + _tag: "TextCompletionConfigError", + provider: "Claude", + key: "CLAUDE_KEY", + }); + }), + ); }); diff --git a/ts/packages/flow/src/model/text-completion/claude.ts b/ts/packages/flow/src/model/text-completion/claude.ts index e7f8e1a6..6aac0856 100644 --- a/ts/packages/flow/src/model/text-completion/claude.ts +++ b/ts/packages/flow/src/model/text-completion/claude.ts @@ -4,7 +4,7 @@ * Python reference: trustgraph-flow/trustgraph/model/text_completion/claude/llm.py */ -import Anthropic from "@anthropic-ai/sdk"; +import { AnthropicClient, AnthropicLanguageModel } from "@effect/ai-anthropic"; import { NodeRuntime } from "@effect/platform-node"; import { makeLlmService, @@ -13,18 +13,14 @@ import { type Llm, type LlmProvider, type ProcessorConfig, - type LlmResult, - type LlmChunk, } from "@trustgraph/base"; -import { Effect, Layer, ManagedRuntime, Stream } from "effect"; +import { Effect, Layer, ManagedRuntime, Redacted } from "effect"; +import { FetchHttpClient } from "effect/unstable/http"; import { - llmStreamPart, + makeLanguageModelProvider, makeTextCompletionLayer, optionalStringConfig, - providerStatusError, requiredString, - streamTextCompletionChunks, - toAsyncGenerator, type TextCompletionConfigError, type TextCompletionRuntimeError, } from "./common.ts"; @@ -43,141 +39,55 @@ type ResolvedClaudeConfig = { readonly apiKey: string; }; -const loadClaudeConfig = Effect.fn("loadClaudeConfig")(function*(config: ClaudeProcessorConfig) { - const apiKey = yield* requiredString( - config.apiKey ?? (yield* optionalStringConfig("Claude", "CLAUDE_KEY")), - "Claude", - "CLAUDE_KEY", - "Claude API key not specified", - ); - - return { - defaultModel: config.model ?? "claude-sonnet-4-20250514", - defaultTemperature: config.temperature ?? 0.0, - maxOutput: config.maxOutput ?? 8192, - apiKey, - } satisfies ResolvedClaudeConfig; -}); - -const mapClaudeError = (error: unknown): TextCompletionRuntimeError => - providerStatusError("Claude", error); - -const makeClaudeProviderFromClient = ( - resolved: ResolvedClaudeConfig, - client: Anthropic, -): LlmProvider => { - const { - defaultModel, - defaultTemperature, - maxOutput, - } = resolved; +const loadClaudeConfig = Effect.fn("loadClaudeConfig")(function* (config: ClaudeProcessorConfig) { + const apiKey = yield* requiredString( + config.apiKey ?? (yield* optionalStringConfig("Claude", "CLAUDE_KEY")), + "Claude", + "CLAUDE_KEY", + "Claude API key not specified", + ); return { - generateContent: ( - system: string, - prompt: string, - model?: string, - temperature?: number, - ): Promise => { - const modelName = model ?? defaultModel; - const temp = temperature ?? defaultTemperature; + defaultModel: config.model ?? "claude-sonnet-4-20250514", + defaultTemperature: config.temperature ?? 0.0, + maxOutput: config.maxOutput ?? 8192, + apiKey, + } satisfies ResolvedClaudeConfig; +}); - return Effect.runPromise( - Effect.tryPromise({ - try: () => - client.messages.create({ - model: modelName, - max_tokens: maxOutput, - temperature: temp, - system, - messages: [ - { role: "user", content: prompt }, - ], - }), - catch: mapClaudeError, - }).pipe( - Effect.map((response): LlmResult => { - const firstContent = response.content[0]; - const text = firstContent?.type === "text" - ? firstContent.text - : ""; - - return { - text, - inToken: response.usage.input_tokens, - outToken: response.usage.output_tokens, - model: modelName, - }; - }), - ), - ); - }, - supportsStreaming: () => true, - generateContentStream: ( - system: string, - prompt: string, - model?: string, - temperature?: number, - ): AsyncGenerator => { - const modelName = model ?? defaultModel; - const temp = temperature ?? defaultTemperature; - - const stream = Stream.fromEffect( - Effect.try({ - try: () => - client.messages.stream({ - model: modelName, - max_tokens: maxOutput, - temperature: temp, - system, - messages: [ - { role: "user", content: prompt }, - ], - }), - catch: mapClaudeError, - }), - ).pipe( - Stream.flatMap((anthropicStream) => - streamTextCompletionChunks(anthropicStream, { - model: modelName, - mapError: mapClaudeError, - extract: (event) => - event.type === "content_block_delta" && event.delta.type === "text_delta" - ? llmStreamPart({ text: event.delta.text }) - : llmStreamPart({}), - finalTokens: Effect.tryPromise({ - try: () => anthropicStream.finalMessage(), - catch: mapClaudeError, - }).pipe( - Effect.map((finalMessage) => ({ - inToken: finalMessage.usage.input_tokens, - outToken: finalMessage.usage.output_tokens, - })), - ), - }) - ), - ); - - return toAsyncGenerator(Stream.toAsyncIterable(stream), mapClaudeError); - }, - } satisfies LlmProvider; -}; +const makeClaudeRuntime = (apiKey: string) => + ManagedRuntime.make( + AnthropicClient.layer({ + apiKey: Redacted.make(apiKey), + }).pipe( + Layer.provide(FetchHttpClient.layer), + ), + ); export function makeClaudeProvider(config: ClaudeProcessorConfig): LlmProvider { return Effect.runSync(makeClaudeProviderEffect(config)); } -export const makeClaudeProviderEffect = Effect.fn("makeClaudeProvider")(function*( +export const makeClaudeProviderEffect = Effect.fn("makeClaudeProvider")(function* ( config: ClaudeProcessorConfig, ) { const resolved = yield* loadClaudeConfig(config); - const client = yield* Effect.try({ - try: () => new Anthropic({ apiKey: resolved.apiKey }), - catch: mapClaudeError, - }); yield* Effect.log("[Claude] LLM service initialized"); - return makeClaudeProviderFromClient(resolved, client); + return makeLanguageModelProvider({ + provider: "Claude", + defaultModel: resolved.defaultModel, + defaultTemperature: resolved.defaultTemperature, + runtime: makeClaudeRuntime(resolved.apiKey), + makeLanguageModel: ({ model, temperature }) => + AnthropicLanguageModel.make({ + model, + config: { + max_tokens: resolved.maxOutput, + temperature, + }, + }), + }); }); export type ClaudeProcessor = ReturnType;