1/**
2 * @title Using LanguageModel for text, objects, and streams
3 *
4 * Configure a provider once, then use `LanguageModel` for plain text
5 * generation, schema-validated object generation, and streaming responses.
6 */
7import { AnthropicClient, AnthropicLanguageModel } from "@effect/ai-anthropic"
8import { OpenAiClient, OpenAiLanguageModel } from "@effect/ai-openai"
9import { Config, Context, Effect, ExecutionPlan, Layer, Schema, Stream } from "effect"
10import { AiError, LanguageModel, Model, type Response } from "effect/ai"
11import { FetchHttpClient } from "effect/http"
12import { LaunchPlan } from "./fixtures/domain/LaunchPlan.ts"
13
14// You can use Config to create ai clients
15const AnthropicClientLayer = AnthropicClient.layerConfig({
16 apiKey: Config.Redacted("ANTHROPIC_API_KEY")
17}).pipe(
18 // Providers typically require an HttpClient, but you can choose which one to
19 // use.
20 Layer.provide(FetchHttpClient.layer)
21)
22
23const OpenAiClientLayer = OpenAiClient.layerConfig({
24 apiKey: Config.Redacted("OPENAI_API_KEY")
25}).pipe(
26 Layer.provide(FetchHttpClient.layer)
27)
28
29export class AiWriterError extends Schema.TaggedError<AiWriterError>()("AiWriterError", {
30 // AiErrorReason is a Schema, so we can include it directly in our custom
31 // error schema.
32 reason: AiError.AiErrorReason
33}) {
34 static fromAiError(error: AiError.AiError) {
35 return new AiWriterError({
36 reason: error.reason
37 })
38 }
39}
40
41// You can use `ExecutionPlan` to define a strategy for trying multiple
42// providers with different configurations. In this example, we try a cheaper
43// OpenAI model first, then fall back to a more expensive Anthropic model if the
44// first one fails.
45const DraftPlan = ExecutionPlan.make(
46 {
47 provide: OpenAiLanguageModel.model("gpt-5.2"),
48 // Attempt to use the openai model up to 3 times before falling back to the
49 // anthropic model.
50 attempts: 3
51 },
52 {
53 provide: AnthropicLanguageModel.model("claude-opus-4-6"),
54 attempts: 2
55 }
56)
57
58export class AiWriter extends Context.Service<AiWriter, {
59 draftAnnouncement(product: string): Effect.Effect<{
60 readonly provider: string
61 readonly text: string
62 }, AiWriterError>
63 extractLaunchPlan(notes: string): Effect.Effect<LaunchPlan, AiWriterError>
64 streamReleaseHighlights(version: string): Stream.Stream<string, AiWriterError>
65}>()("docs/AiWriter") {
66 static readonly layer = Layer.effect(
67 AiWriter,
68 Effect.gen(function*() {
69 // Calling `captureRequirements` on an `ExecutionPlan` will move the
70 // requirements of the plan (in this case the ai clients) into the Layer
71 // requirements.
72 const draftsModel = yield* DraftPlan.captureRequirements
73
74 // Use a different model for the launch plan extraction
75 const launchPlanModel = yield* OpenAiLanguageModel.model("gpt-4.1").captureRequirements
76
77 const draftAnnouncement = Effect.fn("AiWriter.draftAnnouncement")(
78 function*(product: string) {
79 const model = yield* LanguageModel.LanguageModel
80 const provider = yield* Model.ProviderName
81 const response = yield* model.generateText({
82 prompt: `Write a short launch announcement for ${product}. ` +
83 "Keep it concise and include one concrete user benefit."
84 })
85
86 // `LanguageModel.generateText` exposes convenience fields so you can
87 // inspect usage and finish reason without parsing content parts.
88 yield* Effect.logInfo(
89 `${provider} finished with ${response.finishReason}. outputTokens=${response.usage.outputTokens.total}`
90 )
91
92 return {
93 provider,
94 text: response.text
95 }
96 },
97 // To apply an `ExecutionPlan`, we use `Effect.withExecutionPlan`
98 Effect.withExecutionPlan(draftsModel),
99 // Map AiError into our custom error type
100 Effect.mapError((error) => AiWriterError.fromAiError(error))
101 )
102
103 const extractLaunchPlan = Effect.fn("AiWriter.extractLaunchPlan")(
104 function*(notes: string) {
105 const model = yield* LanguageModel.LanguageModel
106 const response = yield* model.generateObject({
107 objectName: "launch_plan",
108 prompt:
109 "Convert these notes into a launch plan object with audience, channels, launchDate, summary, and keyRisks:\n" +
110 notes,
111 // The generated object is validated and decoded through this schema.
112 schema: LaunchPlan
113 })
114
115 return response.value
116 },
117 // The .model(...) apis return a Layer that can be used with
118 // Effect.provide
119 Effect.provide(launchPlanModel),
120 // Map AiError into our custom error type
121 Effect.mapError((error) => AiWriterError.fromAiError(error))
122 )
123
124 const streamReleaseHighlights = (version: string) =>
125 LanguageModel.streamText({
126 prompt: `Write release highlights for version ${version} as a short bulleted list.`
127 }).pipe(
128 Stream.filter((part): part is Response.TextDeltaPart => part.type === "text-delta"),
129 Stream.map((part) => part.delta),
130 Stream.provide(launchPlanModel),
131 // Map AiError into our custom error type
132 Stream.mapError((error) => AiWriterError.fromAiError(error))
133 )
134
135 return AiWriter.of({
136 draftAnnouncement,
137 extractLaunchPlan,
138 streamReleaseHighlights
139 })
140 })
141 ).pipe(
142 // This Layer has requirements for both the OpenAI and Anthropic clients,
143 // since the ExecutionPlan includes models from both providers.
144 Layer.provide([OpenAiClientLayer, AnthropicClientLayer])
145 )
146}
147
148// We can now use `AiWriter` like any other Effect service.
149export const program: Effect.Effect<
150 void,
151 AiWriterError,
152 AiWriter
153> = Effect.gen(function*() {
154 const writer = yield* AiWriter
155 yield* writer.draftAnnouncement("Effect Cloud")
156})