Migrate Claude provider to Effect AI

This commit is contained in:
elpresidank 2026-06-02 08:36:55 -05:00
parent 24a2447cc3
commit 1d45307387
5 changed files with 116 additions and 156 deletions

View file

@ -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

View file

@ -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=="],

View file

@ -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"
},

View file

@ -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",
});
}),
);
});

View file

@ -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<LlmResult> => {
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<LlmChunk> => {
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<typeof makeClaudeProcessor>;