@effect/ai-openai 0.11.0 → 0.11.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/OpenAiEmbeddings/package.json +6 -0
  2. package/OpenAiTelemetry/package.json +6 -0
  3. package/dist/cjs/Generated.js +1910 -316
  4. package/dist/cjs/Generated.js.map +1 -1
  5. package/dist/cjs/OpenAiClient.js +88 -60
  6. package/dist/cjs/OpenAiClient.js.map +1 -1
  7. package/dist/cjs/OpenAiCompletions.js +82 -6
  8. package/dist/cjs/OpenAiCompletions.js.map +1 -1
  9. package/dist/cjs/OpenAiEmbeddings.js +95 -0
  10. package/dist/cjs/OpenAiEmbeddings.js.map +1 -0
  11. package/dist/cjs/OpenAiTelemetry.js +39 -0
  12. package/dist/cjs/OpenAiTelemetry.js.map +1 -0
  13. package/dist/cjs/index.js +5 -1
  14. package/dist/dts/Generated.d.ts +2789 -517
  15. package/dist/dts/Generated.d.ts.map +1 -1
  16. package/dist/dts/OpenAiClient.d.ts +15 -1
  17. package/dist/dts/OpenAiClient.d.ts.map +1 -1
  18. package/dist/dts/OpenAiCompletions.d.ts +8 -2
  19. package/dist/dts/OpenAiCompletions.d.ts.map +1 -1
  20. package/dist/dts/OpenAiConfig.d.ts +12 -1
  21. package/dist/dts/OpenAiConfig.d.ts.map +1 -1
  22. package/dist/dts/OpenAiEmbeddings.d.ts +51 -0
  23. package/dist/dts/OpenAiEmbeddings.d.ts.map +1 -0
  24. package/dist/dts/OpenAiTelemetry.d.ts +107 -0
  25. package/dist/dts/OpenAiTelemetry.d.ts.map +1 -0
  26. package/dist/dts/index.d.ts +8 -0
  27. package/dist/dts/index.d.ts.map +1 -1
  28. package/dist/esm/Generated.js +1612 -311
  29. package/dist/esm/Generated.js.map +1 -1
  30. package/dist/esm/OpenAiClient.js +88 -60
  31. package/dist/esm/OpenAiClient.js.map +1 -1
  32. package/dist/esm/OpenAiCompletions.js +82 -6
  33. package/dist/esm/OpenAiCompletions.js.map +1 -1
  34. package/dist/esm/OpenAiEmbeddings.js +83 -0
  35. package/dist/esm/OpenAiEmbeddings.js.map +1 -0
  36. package/dist/esm/OpenAiTelemetry.js +30 -0
  37. package/dist/esm/OpenAiTelemetry.js.map +1 -0
  38. package/dist/esm/index.js +8 -0
  39. package/dist/esm/index.js.map +1 -1
  40. package/package.json +22 -6
  41. package/src/Generated.ts +1892 -398
  42. package/src/OpenAiClient.ts +118 -67
  43. package/src/OpenAiCompletions.ts +108 -14
  44. package/src/OpenAiEmbeddings.ts +149 -0
  45. package/src/OpenAiTelemetry.ts +159 -0
  46. package/src/index.ts +10 -0
@@ -90,62 +90,86 @@ export const make = (options: {
90
90
  Stream.map((event) => JSON.parse(event.data) as A)
91
91
  )
92
92
  const stream = (request: StreamCompletionRequest) =>
93
- streamRequest<RawCompletionChunk>(HttpClientRequest.post("/chat/completions", {
94
- body: HttpBody.unsafeJson({
95
- ...request,
96
- stream: true
97
- })
98
- })).pipe(
99
- Stream.mapAccum(new Map<number, ContentPart | Array<ToolCallPart>>(), (acc, chunk) => {
100
- const parts: Array<StreamChunkPart> = []
101
- for (let i = 0; i < chunk.choices.length; i++) {
102
- const choice = chunk.choices[i]
103
- if ("content" in choice.delta && typeof choice.delta.content === "string") {
104
- let part = acc.get(choice.index) as ContentPart | undefined
105
- part = {
106
- _tag: "Content",
107
- content: choice.delta.content
108
- }
109
- acc.set(choice.index, part)
110
- parts.push(part)
111
- } else if ("tool_calls" in choice.delta && Array.isArray(choice.delta.tool_calls)) {
112
- const parts = (acc.get(choice.index) ?? []) as Array<ToolCallPart>
113
- for (const toolCall of choice.delta.tool_calls) {
114
- const part = parts[toolCall.index]
115
- const toolPart = part?._tag === "ToolCall" ?
116
- {
117
- ...part,
118
- arguments: part.arguments + toolCall.function.arguments
119
- } :
120
- {
121
- _tag: "ToolCall",
122
- ...toolCall,
123
- ...toolCall.function,
124
- role: choice.delta.role!
125
- } as any
126
- parts[toolCall.index] = toolPart
93
+ Stream.suspend(() => {
94
+ const finishReasons: Array<string> = []
95
+ return streamRequest<RawCompletionChunk>(HttpClientRequest.post("/chat/completions", {
96
+ body: HttpBody.unsafeJson({
97
+ ...request,
98
+ stream: true,
99
+ stream_options: { include_usage: true }
100
+ })
101
+ })).pipe(
102
+ Stream.mapAccum(new Map<number, ContentPart | Array<ToolCallPart>>(), (acc, chunk) => {
103
+ const parts: Array<StreamChunkPart> = []
104
+ if (chunk.usage !== null) {
105
+ parts.push({
106
+ _tag: "Usage",
107
+ id: chunk.id,
108
+ model: chunk.model,
109
+ inputTokens: chunk.usage.prompt_tokens,
110
+ outputTokens: chunk.usage.completion_tokens,
111
+ finishReasons,
112
+ systemFingerprint: chunk.system_fingerprint,
113
+ serviceTier: chunk.service_tier
114
+ })
115
+ }
116
+ for (let i = 0; i < chunk.choices.length; i++) {
117
+ const choice = chunk.choices[i]
118
+ if (choice.finish_reason !== null) {
119
+ finishReasons.push(choice.finish_reason)
127
120
  }
128
- acc.set(choice.index, parts)
129
- } else if (choice.finish_reason === "tool_calls") {
130
- const toolParts = acc.get(choice.index) as Array<ToolCallPart>
131
- for (const part of toolParts) {
132
- try {
133
- const args = JSON.parse(part.arguments as string)
134
- parts.push({
135
- _tag: "ToolCall",
136
- id: part.id,
137
- name: part.name,
138
- arguments: args
139
- })
140
- // eslint-disable-next-line no-empty
141
- } catch {}
121
+ if ("content" in choice.delta && typeof choice.delta.content === "string") {
122
+ let part = acc.get(choice.index) as ContentPart | undefined
123
+ part = {
124
+ _tag: "Content",
125
+ content: choice.delta.content
126
+ }
127
+ acc.set(choice.index, part)
128
+ parts.push(part)
129
+ } else if ("tool_calls" in choice.delta && Array.isArray(choice.delta.tool_calls)) {
130
+ const parts = (acc.get(choice.index) ?? []) as Array<ToolCallPart>
131
+ for (const toolCall of choice.delta.tool_calls) {
132
+ const part = parts[toolCall.index]
133
+ const toolPart = part?._tag === "ToolCall" ?
134
+ {
135
+ ...part,
136
+ arguments: part.arguments + toolCall.function.arguments
137
+ } :
138
+ {
139
+ _tag: "ToolCall",
140
+ ...toolCall,
141
+ ...toolCall.function,
142
+ role: choice.delta.role!
143
+ } as any
144
+ parts[toolCall.index] = toolPart
145
+ }
146
+ acc.set(choice.index, parts)
147
+ } else if (choice.finish_reason === "tool_calls") {
148
+ const toolParts = acc.get(choice.index) as Array<ToolCallPart>
149
+ for (const part of toolParts) {
150
+ try {
151
+ const args = JSON.parse(part.arguments as string)
152
+ parts.push({
153
+ _tag: "ToolCall",
154
+ id: part.id,
155
+ name: part.name,
156
+ arguments: args
157
+ })
158
+ // eslint-disable-next-line no-empty
159
+ } catch {}
160
+ }
142
161
  }
143
162
  }
144
- }
145
- return [acc, parts.length === 0 ? Option.none() : Option.some(new StreamChunk({ parts }))]
146
- }),
147
- Stream.filterMap(identity)
148
- )
163
+ return [
164
+ acc,
165
+ parts.length === 0
166
+ ? Option.none()
167
+ : Option.some(new StreamChunk({ parts }))
168
+ ]
169
+ }),
170
+ Stream.filterMap(identity)
171
+ )
172
+ })
149
173
  return OpenAiClient.of({ client, streamRequest, stream })
150
174
  })
151
175
 
@@ -202,6 +226,13 @@ interface RawCompletionChunk {
202
226
  }
203
227
  >
204
228
  readonly system_fingerprint: string
229
+ readonly service_tier: string
230
+ readonly usage: RawUsage | null
231
+ }
232
+
233
+ interface RawUsage {
234
+ readonly prompt_tokens: number
235
+ readonly completion_tokens: number
205
236
  }
206
237
 
207
238
  type RawDelta = {
@@ -254,19 +285,24 @@ export class StreamChunk extends Data.Class<{
254
285
  })
255
286
  }
256
287
  const part = this.parts[0]
257
- return part._tag === "Content" ?
258
- AiResponse.AiResponse.fromText({
259
- role: AiRole.model,
260
- content: part.content
261
- }) :
262
- new AiResponse.AiResponse({
263
- role: AiRole.model,
264
- parts: Chunk.of(AiResponse.ToolCallPart.fromUnknown({
265
- id: part.id,
266
- name: part.name,
267
- params: part.arguments
268
- }))
269
- })
288
+ switch (part._tag) {
289
+ case "Content":
290
+ return AiResponse.AiResponse.fromText({
291
+ role: AiRole.model,
292
+ content: part.content
293
+ })
294
+ case "ToolCall":
295
+ return new AiResponse.AiResponse({
296
+ role: AiRole.model,
297
+ parts: Chunk.of(AiResponse.ToolCallPart.fromUnknown({
298
+ id: part.id,
299
+ name: part.name,
300
+ params: part.arguments
301
+ }))
302
+ })
303
+ case "Usage":
304
+ return AiResponse.AiResponse.empty
305
+ }
270
306
  }
271
307
  }
272
308
 
@@ -274,7 +310,7 @@ export class StreamChunk extends Data.Class<{
274
310
  * @since 1.0.0
275
311
  * @category models
276
312
  */
277
- export type StreamChunkPart = ContentPart | ToolCallPart
313
+ export type StreamChunkPart = ContentPart | ToolCallPart | UsagePart
278
314
 
279
315
  /**
280
316
  * @since 1.0.0
@@ -296,3 +332,18 @@ export interface ToolCallPart {
296
332
  readonly name: string
297
333
  readonly arguments: unknown
298
334
  }
335
+
336
+ /**
337
+ * @since 1.0.0
338
+ * @category models
339
+ */
340
+ export interface UsagePart {
341
+ readonly _tag: "Usage"
342
+ readonly id: string
343
+ readonly model: string
344
+ readonly inputTokens: number
345
+ readonly outputTokens: number
346
+ readonly finishReasons: ReadonlyArray<string>
347
+ readonly systemFingerprint: string
348
+ readonly serviceTier: string | null
349
+ }
@@ -11,14 +11,24 @@ import * as Arr from "effect/Array"
11
11
  import * as Effect from "effect/Effect"
12
12
  import * as Layer from "effect/Layer"
13
13
  import type * as Option from "effect/Option"
14
+ import * as Predicate from "effect/Predicate"
14
15
  import * as Stream from "effect/Stream"
16
+ import type { Span } from "effect/Tracer"
15
17
  import type * as Generated from "./Generated.js"
18
+ import type { StreamChunk } from "./OpenAiClient.js"
16
19
  import { OpenAiClient } from "./OpenAiClient.js"
17
20
  import { OpenAiConfig } from "./OpenAiConfig.js"
21
+ import { addGenAIAnnotations } from "./OpenAiTelemetry.js"
18
22
  import * as OpenAiTokenizer from "./OpenAiTokenizer.js"
19
23
 
24
+ /**
25
+ * @since 1.0.0
26
+ * @category models
27
+ */
28
+ export type Model = typeof Generated.CreateChatCompletionRequestModel.Encoded
29
+
20
30
  const make = (options: {
21
- readonly model: string
31
+ readonly model: (string & {}) | Model
22
32
  }) =>
23
33
  Effect.gen(function*() {
24
34
  const client = yield* OpenAiClient
@@ -66,9 +76,20 @@ const make = (options: {
66
76
  }
67
77
 
68
78
  return yield* Completions.make({
69
- create(options) {
79
+ create({ span, ...options }) {
70
80
  return makeRequest(options).pipe(
81
+ Effect.tap((request) => annotateRequest(span, request)),
71
82
  Effect.flatMap(client.client.createChatCompletion),
83
+ Effect.tap((response) => annotateChatResponse(span, response)),
84
+ Effect.flatMap((response) =>
85
+ makeResponse(
86
+ response,
87
+ "create",
88
+ options.tools.length === 1 && options.tools[0].structured
89
+ ? options.tools[0]
90
+ : undefined
91
+ )
92
+ ),
72
93
  Effect.catchAll((cause) =>
73
94
  Effect.fail(
74
95
  new AiError({
@@ -78,20 +99,19 @@ const make = (options: {
78
99
  cause
79
100
  })
80
101
  )
81
- ),
82
- Effect.flatMap((response) =>
83
- makeResponse(
84
- response,
85
- "create",
86
- options.tools.length === 1 && options.tools[0].structured ? options.tools[0] : undefined
87
- )
88
102
  )
89
103
  )
90
104
  },
91
- stream(options) {
105
+ stream({ span, ...options }) {
92
106
  return makeRequest(options).pipe(
107
+ Effect.tap((request) => annotateRequest(span, request)),
93
108
  Effect.map(client.stream),
94
109
  Stream.unwrap,
110
+ Stream.tap((response) => {
111
+ annotateStreamResponse(span, response)
112
+ return Effect.void
113
+ }),
114
+ Stream.map((response) => response.asAiResponse),
95
115
  Stream.catchAll((cause) =>
96
116
  Effect.fail(
97
117
  new AiError({
@@ -101,8 +121,7 @@ const make = (options: {
101
121
  cause
102
122
  })
103
123
  )
104
- ),
105
- Stream.map((response) => response.asAiResponse)
124
+ )
106
125
  )
107
126
  }
108
127
  })
@@ -113,7 +132,7 @@ const make = (options: {
113
132
  * @category layers
114
133
  */
115
134
  export const layerCompletions = (options: {
116
- readonly model: string
135
+ readonly model: (string & {}) | Model
117
136
  }): Layer.Layer<Completions.Completions, never, OpenAiClient> => Layer.effect(Completions.Completions, make(options))
118
137
 
119
138
  /**
@@ -121,7 +140,7 @@ export const layerCompletions = (options: {
121
140
  * @category layers
122
141
  */
123
142
  export const layer = (options: {
124
- readonly model: string
143
+ readonly model: (string & {}) | Model
125
144
  }): Layer.Layer<Completions.Completions | Tokenizer.Tokenizer, never, OpenAiClient> =>
126
145
  Layer.merge(layerCompletions(options), OpenAiTokenizer.layer(options))
127
146
 
@@ -291,3 +310,78 @@ const makeSystemMessage = (content: string): typeof Generated.ChatCompletionRequ
291
310
  }
292
311
 
293
312
  const safeName = (name: string) => name.replace(/[^a-zA-Z0-9_-]/g, "_").replace(/_+/, "_")
313
+
314
+ const annotateRequest = (
315
+ span: Span,
316
+ request: typeof Generated.CreateChatCompletionRequest.Encoded
317
+ ): void => {
318
+ addGenAIAnnotations(span, {
319
+ system: "openai",
320
+ operation: { name: "chat" },
321
+ request: {
322
+ model: request.model,
323
+ temperature: request.temperature,
324
+ topP: request.top_p,
325
+ maxTokens: request.max_tokens,
326
+ stopSequences: Arr.ensure(request.stop).filter(Predicate.isNotNullable),
327
+ frequencyPenalty: request.frequency_penalty,
328
+ presencePenalty: request.presence_penalty,
329
+ seed: request.seed
330
+ },
331
+ openai: {
332
+ request: {
333
+ responseFormat: request.response_format?.type,
334
+ serviceTier: request.service_tier
335
+ }
336
+ }
337
+ })
338
+ }
339
+
340
+ const annotateChatResponse = (
341
+ span: Span,
342
+ response: Generated.CreateChatCompletionResponse
343
+ ): void => {
344
+ addGenAIAnnotations(span, {
345
+ response: {
346
+ id: response.id,
347
+ model: response.model,
348
+ finishReasons: response.choices.map((choice) => choice.finish_reason)
349
+ },
350
+ usage: {
351
+ inputTokens: response.usage?.prompt_tokens,
352
+ outputTokens: response.usage?.completion_tokens
353
+ },
354
+ openai: {
355
+ response: {
356
+ systemFingerprint: response.system_fingerprint,
357
+ serviceTier: response.service_tier
358
+ }
359
+ }
360
+ })
361
+ }
362
+
363
+ const annotateStreamResponse = (
364
+ span: Span,
365
+ response: StreamChunk
366
+ ) => {
367
+ const usage = response.parts.find((part) => part._tag === "Usage")
368
+ if (Predicate.isNotNullable(usage)) {
369
+ addGenAIAnnotations(span, {
370
+ response: {
371
+ id: usage.id,
372
+ model: usage.model,
373
+ finishReasons: usage.finishReasons
374
+ },
375
+ usage: {
376
+ inputTokens: usage.inputTokens,
377
+ outputTokens: usage.outputTokens
378
+ },
379
+ openai: {
380
+ response: {
381
+ systemFingerprint: usage.systemFingerprint,
382
+ serviceTier: usage.serviceTier
383
+ }
384
+ }
385
+ })
386
+ }
387
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * @since 1.0.0
3
+ */
4
+ import { AiError } from "@effect/ai/AiError"
5
+ import * as Embeddings from "@effect/ai/Embeddings"
6
+ import * as Context from "effect/Context"
7
+ import type * as Duration from "effect/Duration"
8
+ import * as Effect from "effect/Effect"
9
+ import * as Layer from "effect/Layer"
10
+ import type { Simplify } from "effect/Types"
11
+ import type * as Generated from "./Generated.js"
12
+ import { OpenAiClient } from "./OpenAiClient.js"
13
+
14
+ /**
15
+ * @since 1.0.0
16
+ * @category models
17
+ */
18
+ export type Model = typeof Generated.CreateEmbeddingRequestModel.Encoded
19
+
20
+ /**
21
+ * @since 1.0.0
22
+ * @category tags
23
+ */
24
+ export class OpenAiEmbeddingsConfig extends Context.Tag("@effect/ai-openai/OpenAiEmbeddings/Config")<
25
+ OpenAiEmbeddingsConfig,
26
+ Simplify<
27
+ Partial<
28
+ Omit<
29
+ typeof Generated.CreateEmbeddingRequest.Encoded,
30
+ "input"
31
+ >
32
+ >
33
+ >
34
+ >() {
35
+ /**
36
+ * @since 1.0.0
37
+ */
38
+ static readonly getOrUndefined: Effect.Effect<typeof OpenAiEmbeddingsConfig.Service | undefined> = Effect.map(
39
+ Effect.context<never>(),
40
+ (context) => context.unsafeMap.get(OpenAiEmbeddingsConfig.key)
41
+ )
42
+ }
43
+
44
+ const makeRequest = (
45
+ client: OpenAiClient.Service,
46
+ input: ReadonlyArray<string>,
47
+ parentConfig: typeof OpenAiEmbeddingsConfig.Service | undefined,
48
+ options: {
49
+ readonly model: string
50
+ readonly maxBatchSize?: number
51
+ readonly cache?: {
52
+ readonly capacity: number
53
+ readonly timeToLive: Duration.DurationInput
54
+ }
55
+ }
56
+ ) =>
57
+ Effect.context<never>().pipe(
58
+ Effect.flatMap((context) => {
59
+ const localConfig = context.unsafeMap.get(OpenAiEmbeddingsConfig.key)
60
+ return client.client.createEmbedding({
61
+ input,
62
+ model: options.model,
63
+ ...parentConfig,
64
+ ...localConfig
65
+ })
66
+ }),
67
+ Effect.map((response) =>
68
+ response.data.map(({ embedding, index }) => ({
69
+ embeddings: embedding as Array<number>,
70
+ index
71
+ }))
72
+ ),
73
+ Effect.mapError((cause) => {
74
+ const common = {
75
+ module: "OpenAiEmbeddings",
76
+ method: "embed",
77
+ cause
78
+ }
79
+ if (cause._tag === "ParseError") {
80
+ return new AiError({
81
+ description: "Malformed input detected in request",
82
+ ...common
83
+ })
84
+ }
85
+ return new AiError({
86
+ description: "An error occurred with the OpenAI API",
87
+ ...common
88
+ })
89
+ })
90
+ )
91
+
92
+ const make = Effect.fnUntraced(function*(options: {
93
+ readonly model: (string & {}) | Model
94
+ readonly maxBatchSize?: number
95
+ readonly cache?: {
96
+ readonly capacity: number
97
+ readonly timeToLive: Duration.DurationInput
98
+ }
99
+ }) {
100
+ const client = yield* OpenAiClient
101
+ const parentConfig = yield* OpenAiEmbeddingsConfig.getOrUndefined
102
+ return yield* Embeddings.make({
103
+ cache: options.cache,
104
+ maxBatchSize: options.maxBatchSize ?? 2048,
105
+ embedMany(input) {
106
+ return makeRequest(client, input, parentConfig, options)
107
+ }
108
+ })
109
+ })
110
+
111
+ const makeDataLoader = Effect.fnUntraced(function*(options: {
112
+ readonly model: (string & {}) | Model
113
+ readonly window: Duration.DurationInput
114
+ readonly maxBatchSize?: number
115
+ }) {
116
+ const client = yield* OpenAiClient
117
+ const parentConfig = yield* OpenAiEmbeddingsConfig.getOrUndefined
118
+ return yield* Embeddings.makeDataLoader({
119
+ window: options.window,
120
+ maxBatchSize: options.maxBatchSize ?? 2048,
121
+ embedMany(input) {
122
+ return makeRequest(client, input, parentConfig, options)
123
+ }
124
+ })
125
+ })
126
+
127
+ /**
128
+ * @since 1.0.0
129
+ * @category layers
130
+ */
131
+ export const layer = (options: {
132
+ readonly model: (string & {}) | Model
133
+ readonly maxBatchSize?: number
134
+ readonly cache?: {
135
+ readonly capacity: number
136
+ readonly timeToLive: Duration.DurationInput
137
+ }
138
+ }): Layer.Layer<Embeddings.Embeddings, never, OpenAiClient> => Layer.effect(Embeddings.Embeddings, make(options))
139
+
140
+ /**
141
+ * @since 1.0.0
142
+ * @category layers
143
+ */
144
+ export const layerDataLoader = (options: {
145
+ readonly model: (string & {}) | Model
146
+ readonly window: Duration.DurationInput
147
+ readonly maxBatchSize?: number
148
+ }): Layer.Layer<Embeddings.Embeddings, never, OpenAiClient> =>
149
+ Layer.scoped(Embeddings.Embeddings, makeDataLoader(options))