@effect-uai/responses 0.2.0 → 0.4.0

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.
@@ -0,0 +1,170 @@
1
+ import { t as __exportAll } from "./chunk-CfYAbeIz.mjs";
2
+ import { WireOutputItem, WireResponseCompleted, turnFromCompleted } from "./codec.mjs";
3
+ import { Match, Schema } from "effect";
4
+ //#region src/streamEvents.ts
5
+ var streamEvents_exports = /* @__PURE__ */ __exportAll({
6
+ KnownProviderEvent: () => KnownProviderEvent,
7
+ ProviderEvent: () => ProviderEvent,
8
+ eventToDeltas: () => eventToDeltas,
9
+ makeCallIdLookup: () => makeCallIdLookup
10
+ });
11
+ const OutputItemAdded = Schema.Struct({
12
+ type: Schema.Literal("response.output_item.added"),
13
+ item: WireOutputItem
14
+ });
15
+ const OutputTextDelta = Schema.Struct({
16
+ type: Schema.Literal("response.output_text.delta"),
17
+ delta: Schema.String
18
+ });
19
+ const FunctionArgsDelta = Schema.Struct({
20
+ type: Schema.Literal("response.function_call_arguments.delta"),
21
+ item_id: Schema.String,
22
+ delta: Schema.String
23
+ });
24
+ const ReasoningTextDelta = Schema.Struct({
25
+ type: Schema.Literal("response.reasoning_text.delta"),
26
+ delta: Schema.String
27
+ });
28
+ const ReasoningSummaryDelta = Schema.Struct({
29
+ type: Schema.Literal("response.reasoning_summary_text.delta"),
30
+ delta: Schema.String
31
+ });
32
+ const RefusalDelta = Schema.Struct({
33
+ type: Schema.Literal("response.refusal.delta"),
34
+ delta: Schema.String
35
+ });
36
+ const RefusalDone = Schema.Struct({
37
+ type: Schema.Literal("response.refusal.done"),
38
+ refusal: Schema.optional(Schema.String)
39
+ });
40
+ const Completed = Schema.Struct({
41
+ type: Schema.Literal("response.completed"),
42
+ response: WireResponseCompleted
43
+ });
44
+ const Incomplete = Schema.Struct({
45
+ type: Schema.Literal("response.incomplete"),
46
+ response: WireResponseCompleted
47
+ });
48
+ const Failed = Schema.Struct({
49
+ type: Schema.Literal("response.failed"),
50
+ response: WireResponseCompleted
51
+ });
52
+ const ErrorEvent = Schema.Struct({
53
+ type: Schema.Literal("error"),
54
+ message: Schema.optional(Schema.String),
55
+ code: Schema.optional(Schema.String)
56
+ });
57
+ /**
58
+ * Catch-all variant for wire events that fail to decode against any known
59
+ * schema, plus events that fail to JSON-parse. The decoder never produces
60
+ * this directly - it's synthesized by `sseEventToProviderEvent` when
61
+ * `decodeKnown` fails.
62
+ */
63
+ const Unknown = Schema.Struct({
64
+ type: Schema.Literal("_unknown"),
65
+ raw: Schema.Unknown
66
+ });
67
+ /**
68
+ * Internal: union of variants we actually know how to decode from the wire.
69
+ * Used as the decode target; failures are caught and re-emitted as `Unknown`.
70
+ */
71
+ const KnownProviderEvent = Schema.Union([
72
+ OutputItemAdded,
73
+ OutputTextDelta,
74
+ FunctionArgsDelta,
75
+ ReasoningTextDelta,
76
+ ReasoningSummaryDelta,
77
+ RefusalDelta,
78
+ RefusalDone,
79
+ Completed,
80
+ Incomplete,
81
+ Failed,
82
+ ErrorEvent
83
+ ]);
84
+ /**
85
+ * Public: every event the native stream can emit. Discriminated on `type`.
86
+ * The `_unknown` branch closes the cardinality so downstream `Match.exhaustive`
87
+ * cannot silently miss a wire event we didn't model.
88
+ */
89
+ const ProviderEvent = Schema.Union([
90
+ OutputItemAdded,
91
+ OutputTextDelta,
92
+ FunctionArgsDelta,
93
+ ReasoningTextDelta,
94
+ ReasoningSummaryDelta,
95
+ RefusalDelta,
96
+ RefusalDone,
97
+ Completed,
98
+ Incomplete,
99
+ Failed,
100
+ ErrorEvent,
101
+ Unknown
102
+ ]);
103
+ const makeCallIdLookup = () => {
104
+ const map = /* @__PURE__ */ new Map();
105
+ return {
106
+ resolve: (id) => map.get(id),
107
+ remember: (id, callId) => {
108
+ map.set(id, callId);
109
+ }
110
+ };
111
+ };
112
+ /**
113
+ * Translate a decoded provider event into zero-or-more `TurnEvent`s.
114
+ * Mutates `lookup` to record `(item_id → call_id)` from `output_item.added`
115
+ * so subsequent `function_call_arguments.delta` events can be tagged with
116
+ * the right call id.
117
+ */
118
+ const eventToDeltas = (event, lookup) => Match.value(event).pipe(Match.discriminatorsExhaustive("type")({
119
+ "response.output_item.added": ({ item }) => {
120
+ if (item.type !== "function_call") return [];
121
+ if (item.id !== void 0) lookup.remember(item.id, item.call_id);
122
+ return [{
123
+ type: "tool_call_start",
124
+ call_id: item.call_id,
125
+ name: item.name
126
+ }];
127
+ },
128
+ "response.output_text.delta": ({ delta }) => [{
129
+ type: "text_delta",
130
+ text: delta
131
+ }],
132
+ "response.function_call_arguments.delta": ({ item_id, delta }) => {
133
+ const call_id = lookup.resolve(item_id);
134
+ return call_id === void 0 ? [] : [{
135
+ type: "tool_call_args_delta",
136
+ call_id,
137
+ delta
138
+ }];
139
+ },
140
+ "response.reasoning_text.delta": ({ delta }) => [{
141
+ type: "reasoning_delta",
142
+ text: delta,
143
+ kind: "trace"
144
+ }],
145
+ "response.reasoning_summary_text.delta": ({ delta }) => [{
146
+ type: "reasoning_delta",
147
+ text: delta,
148
+ kind: "summary"
149
+ }],
150
+ "response.refusal.delta": ({ delta }) => [{
151
+ type: "refusal_delta",
152
+ text: delta
153
+ }],
154
+ "response.refusal.done": () => [],
155
+ "response.completed": ({ response }) => [{
156
+ type: "turn_complete",
157
+ turn: turnFromCompleted(response)
158
+ }],
159
+ "response.incomplete": ({ response }) => [{
160
+ type: "turn_complete",
161
+ turn: turnFromCompleted(response)
162
+ }],
163
+ "response.failed": () => [],
164
+ error: () => [],
165
+ _unknown: () => []
166
+ }));
167
+ //#endregion
168
+ export { KnownProviderEvent, ProviderEvent, eventToDeltas, makeCallIdLookup, streamEvents_exports as t };
169
+
170
+ //# sourceMappingURL=streamEvents.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"streamEvents.mjs","names":[],"sources":["../src/streamEvents.ts"],"sourcesContent":["import { Match, Schema } from \"effect\"\nimport type { TurnEvent } from \"@effect-uai/core/Turn\"\nimport { WireOutputItem, WireResponseCompleted, turnFromCompleted } from \"./codec.js\"\n\n// ---------------------------------------------------------------------------\n// Schemas for the SSE event payloads we care about. The Responses API ships\n// many event types; we model only the ones that map to a `TurnEvent`. All\n// others are silently ignored at the dispatch site.\n// ---------------------------------------------------------------------------\n\nconst OutputItemAdded = Schema.Struct({\n type: Schema.Literal(\"response.output_item.added\"),\n item: WireOutputItem,\n})\n\nconst OutputTextDelta = Schema.Struct({\n type: Schema.Literal(\"response.output_text.delta\"),\n delta: Schema.String,\n})\n\nconst FunctionArgsDelta = Schema.Struct({\n type: Schema.Literal(\"response.function_call_arguments.delta\"),\n item_id: Schema.String,\n delta: Schema.String,\n})\n\nconst ReasoningTextDelta = Schema.Struct({\n type: Schema.Literal(\"response.reasoning_text.delta\"),\n delta: Schema.String,\n})\n\nconst ReasoningSummaryDelta = Schema.Struct({\n type: Schema.Literal(\"response.reasoning_summary_text.delta\"),\n delta: Schema.String,\n})\n\nconst RefusalDelta = Schema.Struct({\n type: Schema.Literal(\"response.refusal.delta\"),\n delta: Schema.String,\n})\n\nconst RefusalDone = Schema.Struct({\n type: Schema.Literal(\"response.refusal.done\"),\n refusal: Schema.optional(Schema.String),\n})\n\nconst Completed = Schema.Struct({\n type: Schema.Literal(\"response.completed\"),\n response: WireResponseCompleted,\n})\n\nconst Incomplete = Schema.Struct({\n type: Schema.Literal(\"response.incomplete\"),\n response: WireResponseCompleted,\n})\n\nconst Failed = Schema.Struct({\n type: Schema.Literal(\"response.failed\"),\n response: WireResponseCompleted,\n})\n\nconst ErrorEvent = Schema.Struct({\n type: Schema.Literal(\"error\"),\n message: Schema.optional(Schema.String),\n code: Schema.optional(Schema.String),\n})\n\n/**\n * Catch-all variant for wire events that fail to decode against any known\n * schema, plus events that fail to JSON-parse. The decoder never produces\n * this directly - it's synthesized by `sseEventToProviderEvent` when\n * `decodeKnown` fails.\n */\nconst Unknown = Schema.Struct({\n type: Schema.Literal(\"_unknown\"),\n raw: Schema.Unknown,\n})\n\n/**\n * Internal: union of variants we actually know how to decode from the wire.\n * Used as the decode target; failures are caught and re-emitted as `Unknown`.\n */\nexport const KnownProviderEvent = Schema.Union([\n OutputItemAdded,\n OutputTextDelta,\n FunctionArgsDelta,\n ReasoningTextDelta,\n ReasoningSummaryDelta,\n RefusalDelta,\n RefusalDone,\n Completed,\n Incomplete,\n Failed,\n ErrorEvent,\n])\n\n/**\n * Public: every event the native stream can emit. Discriminated on `type`.\n * The `_unknown` branch closes the cardinality so downstream `Match.exhaustive`\n * cannot silently miss a wire event we didn't model.\n */\nexport const ProviderEvent = Schema.Union([\n OutputItemAdded,\n OutputTextDelta,\n FunctionArgsDelta,\n ReasoningTextDelta,\n ReasoningSummaryDelta,\n RefusalDelta,\n RefusalDone,\n Completed,\n Incomplete,\n Failed,\n ErrorEvent,\n Unknown,\n])\nexport type ProviderEvent = typeof ProviderEvent.Type\n\n// `call_id` lookup for `function_call_arguments.delta`. The SSE event\n// references the *item id*, not the call id; we keep a small map populated\n// from the `output_item.added` events.\nexport type CallIdLookup = {\n readonly resolve: (itemId: string) => string | undefined\n readonly remember: (itemId: string, callId: string) => void\n}\n\nexport const makeCallIdLookup = (): CallIdLookup => {\n const map = new Map<string, string>()\n return {\n resolve: (id) => map.get(id),\n remember: (id, callId) => {\n map.set(id, callId)\n },\n }\n}\n\n/**\n * Translate a decoded provider event into zero-or-more `TurnEvent`s.\n * Mutates `lookup` to record `(item_id → call_id)` from `output_item.added`\n * so subsequent `function_call_arguments.delta` events can be tagged with\n * the right call id.\n */\nexport const eventToDeltas = (\n event: ProviderEvent,\n lookup: CallIdLookup,\n): ReadonlyArray<TurnEvent> =>\n Match.value(event).pipe(\n Match.discriminatorsExhaustive(\"type\")({\n \"response.output_item.added\": ({ item }) => {\n if (item.type !== \"function_call\") return []\n if (item.id !== undefined) lookup.remember(item.id, item.call_id)\n return [\n {\n type: \"tool_call_start\" as const,\n call_id: item.call_id,\n name: item.name,\n },\n ]\n },\n \"response.output_text.delta\": ({ delta }) => [{ type: \"text_delta\" as const, text: delta }],\n \"response.function_call_arguments.delta\": ({ item_id, delta }) => {\n const call_id = lookup.resolve(item_id)\n return call_id === undefined\n ? []\n : [{ type: \"tool_call_args_delta\" as const, call_id, delta }]\n },\n \"response.reasoning_text.delta\": ({ delta }) => [\n { type: \"reasoning_delta\" as const, text: delta, kind: \"trace\" as const },\n ],\n \"response.reasoning_summary_text.delta\": ({ delta }) => [\n { type: \"reasoning_delta\" as const, text: delta, kind: \"summary\" as const },\n ],\n \"response.refusal.delta\": ({ delta }) => [{ type: \"refusal_delta\" as const, text: delta }],\n // Terminal marker for the refusal stream; the deltas already covered\n // the text, so no canonical event is emitted here.\n \"response.refusal.done\": () => [],\n \"response.completed\": ({ response }) => [\n { type: \"turn_complete\" as const, turn: turnFromCompleted(response) },\n ],\n // Incomplete = the model stopped early but produced a usable partial\n // turn (max_tokens, content_filter, max_tool_calls, refusal). Same\n // payload shape as `response.completed`; the stop_reason carries the\n // reason.\n \"response.incomplete\": ({ response }) => [\n { type: \"turn_complete\" as const, turn: turnFromCompleted(response) },\n ],\n // Failed and error are surfaced separately as `AiError` by the stream\n // pipeline, not as canonical events.\n \"response.failed\": () => [],\n error: () => [],\n // No silent drops: unknown wire events flow through `streamNative` but\n // produce no canonical delta. Step 3 (canonical `other` event) replaces\n // this with a forwarded `other` delta on `TurnEvent`.\n _unknown: () => [],\n }),\n )\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effect-uai/responses",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "OpenAI Responses API provider for @effect-uai/core.",
5
5
  "keywords": [
6
6
  "ai",
@@ -20,6 +20,12 @@
20
20
  "url": "https://github.com/betalyra/effect-uai",
21
21
  "directory": "packages/providers/responses"
22
22
  },
23
+ "files": [
24
+ "dist",
25
+ "src",
26
+ "README.md",
27
+ "LICENSE"
28
+ ],
23
29
  "type": "module",
24
30
  "main": "./dist/index.mjs",
25
31
  "types": "./dist/index.d.mts",
@@ -27,25 +33,27 @@
27
33
  ".": {
28
34
  "types": "./dist/index.d.mts",
29
35
  "import": "./dist/index.mjs"
36
+ },
37
+ "./Responses": {
38
+ "types": "./dist/Responses.d.mts",
39
+ "import": "./dist/Responses.mjs"
40
+ },
41
+ "./OpenAIEmbedding": {
42
+ "types": "./dist/OpenAIEmbedding.d.mts",
43
+ "import": "./dist/OpenAIEmbedding.mjs"
30
44
  }
31
45
  },
32
- "files": [
33
- "dist",
34
- "src",
35
- "README.md",
36
- "LICENSE"
37
- ],
38
46
  "publishConfig": {
39
47
  "access": "public"
40
48
  },
41
- "peerDependencies": {
42
- "effect": "4.0.0-beta.57",
43
- "@effect-uai/core": "^0.2.0"
44
- },
45
49
  "devDependencies": {
46
50
  "effect": "4.0.0-beta.57",
47
51
  "typescript": "^6.0.3",
48
- "@effect-uai/core": "0.2.0"
52
+ "@effect-uai/core": "0.4.0"
53
+ },
54
+ "peerDependencies": {
55
+ "@effect-uai/core": ">=0.2.0 <1",
56
+ "effect": "4.0.0-beta.57"
49
57
  },
50
58
  "scripts": {
51
59
  "build": "tsdown",
@@ -0,0 +1,275 @@
1
+ import { Context, Effect, Layer, Redacted, Schema } from "effect"
2
+ import { HttpClient, HttpClientRequest } from "effect/unstable/http"
3
+ import * as AiError from "@effect-uai/core/AiError"
4
+ import type { Embedding, EmbedContentPart, EmbedInput, Usage } from "@effect-uai/core/Embedding"
5
+ import {
6
+ type CommonEmbedManyRequest,
7
+ type CommonEmbedRequest,
8
+ EmbeddingModel,
9
+ type EmbeddingModelService,
10
+ type EmbedManyResponse,
11
+ type EmbedResponse,
12
+ } from "@effect-uai/core/EmbeddingModel"
13
+ import type { OpenAIEmbeddingModel } from "./models.js"
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Public types
17
+ // ---------------------------------------------------------------------------
18
+
19
+ /**
20
+ * OpenAI's embedding API has no task-type semantics, so `task` is omitted
21
+ * from the request type intentionally - passing it would be a compile
22
+ * error. The generic `EmbeddingModel` registration accepts and silently
23
+ * ignores `task` from `CommonEmbedRequest`.
24
+ */
25
+ export type OpenAIEmbedRequest = Omit<CommonEmbedRequest, "model" | "task"> & {
26
+ /** Narrows `CommonEmbedRequest.model` to the typed OpenAI union. */
27
+ readonly model: OpenAIEmbeddingModel
28
+ }
29
+
30
+ export type OpenAIEmbedManyRequest = Omit<OpenAIEmbedRequest, "input"> & {
31
+ readonly inputs: ReadonlyArray<EmbedInput>
32
+ }
33
+
34
+ export type OpenAIEmbeddingService = {
35
+ readonly embed: (request: OpenAIEmbedRequest) => Effect.Effect<EmbedResponse, AiError.AiError>
36
+ readonly embedMany: (
37
+ request: OpenAIEmbedManyRequest,
38
+ ) => Effect.Effect<EmbedManyResponse, AiError.AiError>
39
+ }
40
+
41
+ /**
42
+ * Provider-typed service tag. Yield this for OpenAI-typed embedding
43
+ * requests; yield the generic `EmbeddingModel` tag for provider-portable
44
+ * code. Both are registered by `layer`.
45
+ */
46
+ export class OpenAIEmbedding extends Context.Service<OpenAIEmbedding, OpenAIEmbeddingService>()(
47
+ "@betalyra/effect-uai/providers/responses/OpenAIEmbedding",
48
+ ) {}
49
+
50
+ export type Config = {
51
+ readonly apiKey: Redacted.Redacted
52
+ readonly baseUrl?: string
53
+ }
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // Codec - input flattening to the single string OpenAI expects
57
+ // ---------------------------------------------------------------------------
58
+
59
+ const imageRejected = (param: string): AiError.AiError =>
60
+ new AiError.InvalidRequest({
61
+ provider: "openai",
62
+ param,
63
+ raw: "OpenAI embeddings are text-only; pass a string or { text }",
64
+ })
65
+
66
+ const partToText = (
67
+ param: string,
68
+ part: EmbedContentPart,
69
+ ): Effect.Effect<string, AiError.AiError> =>
70
+ "text" in part ? Effect.succeed(part.text) : Effect.fail(imageRejected(param))
71
+
72
+ /**
73
+ * OpenAI accepts a single string per input. Pure-text `content[]` is
74
+ * concatenated with newlines (treats them as paragraphs of one document);
75
+ * any image part fails with `InvalidRequest`.
76
+ */
77
+ const inputToString = (input: EmbedInput): Effect.Effect<string, AiError.AiError> => {
78
+ if (typeof input === "string") return Effect.succeed(input)
79
+ if ("text" in input) return Effect.succeed(input.text)
80
+ if ("image" in input) return Effect.fail(imageRejected("input.image"))
81
+ return Effect.forEach(input.content, (p, i) => partToText(`input.content[${i}].image`, p)).pipe(
82
+ Effect.map((texts) => texts.join("\n")),
83
+ )
84
+ }
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // Codec - request body
88
+ // ---------------------------------------------------------------------------
89
+
90
+ type WireBody = {
91
+ readonly model: string
92
+ readonly input: string | ReadonlyArray<string>
93
+ readonly dimensions?: number
94
+ readonly encoding_format?: "float" | "base64"
95
+ }
96
+
97
+ const buildSingleBody = (request: OpenAIEmbedRequest): Effect.Effect<WireBody, AiError.AiError> =>
98
+ inputToString(request.input).pipe(
99
+ Effect.map((input) => ({
100
+ model: request.model,
101
+ input,
102
+ ...(request.dimensions !== undefined && { dimensions: request.dimensions }),
103
+ })),
104
+ )
105
+
106
+ const buildBatchBody = (
107
+ request: OpenAIEmbedManyRequest,
108
+ ): Effect.Effect<WireBody, AiError.AiError> =>
109
+ Effect.forEach(request.inputs, inputToString).pipe(
110
+ Effect.map((inputs) => ({
111
+ model: request.model,
112
+ input: inputs,
113
+ ...(request.dimensions !== undefined && { dimensions: request.dimensions }),
114
+ })),
115
+ )
116
+
117
+ // ---------------------------------------------------------------------------
118
+ // Codec - response
119
+ // ---------------------------------------------------------------------------
120
+
121
+ const WireEmbeddingItem = Schema.Struct({
122
+ embedding: Schema.Array(Schema.Number),
123
+ index: Schema.Number,
124
+ })
125
+
126
+ const WireUsage = Schema.Struct({
127
+ prompt_tokens: Schema.Number,
128
+ total_tokens: Schema.Number,
129
+ })
130
+
131
+ const WireResponse = Schema.Struct({
132
+ data: Schema.Array(WireEmbeddingItem),
133
+ model: Schema.String,
134
+ usage: WireUsage,
135
+ })
136
+
137
+ const valuesToEmbedding = (values: ReadonlyArray<number>): Embedding => ({
138
+ _tag: "float32",
139
+ vector: Float32Array.from(values),
140
+ })
141
+
142
+ // OpenAI returns `data[]` unsorted in theory; sort by `index` so caller
143
+ // gets results in the order they passed inputs.
144
+ const orderedEmbeddings = (
145
+ data: ReadonlyArray<{ readonly embedding: ReadonlyArray<number>; readonly index: number }>,
146
+ ): ReadonlyArray<Embedding> =>
147
+ [...data].sort((a, b) => a.index - b.index).map((item) => valuesToEmbedding(item.embedding))
148
+
149
+ // ---------------------------------------------------------------------------
150
+ // HTTP errors
151
+ // ---------------------------------------------------------------------------
152
+
153
+ const httpStatusError = (status: number, body: string): AiError.AiError => {
154
+ const provider = "openai"
155
+ const raw = body
156
+ if (status === 429) return new AiError.RateLimited({ provider, raw })
157
+ if (status === 408 || status === 504) return new AiError.Timeout({ provider, raw })
158
+ if (status === 401) return new AiError.AuthFailed({ provider, subtype: "auth", raw })
159
+ if (status === 403) return new AiError.AuthFailed({ provider, subtype: "permission", raw })
160
+ if (status === 402) return new AiError.AuthFailed({ provider, subtype: "billing", raw })
161
+ if (status === 413) return new AiError.ContextLengthExceeded({ provider, raw })
162
+ if (status >= 500) return new AiError.Unavailable({ provider, status, raw })
163
+ return new AiError.InvalidRequest({ provider, raw })
164
+ }
165
+
166
+ const transportFailure = (cause: unknown): AiError.AiError =>
167
+ new AiError.Unavailable({ provider: "openai", raw: cause })
168
+
169
+ // ---------------------------------------------------------------------------
170
+ // HTTP plumbing
171
+ // ---------------------------------------------------------------------------
172
+
173
+ const baseUrl = (cfg: Config): string => cfg.baseUrl ?? "https://api.openai.com/v1"
174
+
175
+ const postEmbed = (
176
+ cfg: Config,
177
+ body: WireBody,
178
+ ): Effect.Effect<typeof WireResponse.Type, AiError.AiError, HttpClient.HttpClient> =>
179
+ Effect.gen(function* () {
180
+ const client = yield* HttpClient.HttpClient
181
+ const httpRequest = HttpClientRequest.post(`${baseUrl(cfg)}/embeddings`).pipe(
182
+ HttpClientRequest.bearerToken(cfg.apiKey),
183
+ HttpClientRequest.bodyJsonUnsafe(body),
184
+ )
185
+ const response = yield* client.execute(httpRequest).pipe(Effect.mapError(transportFailure))
186
+ if (response.status >= 400) {
187
+ const text = yield* response.text.pipe(Effect.orElseSucceed(() => ""))
188
+ return yield* Effect.fail(httpStatusError(response.status, text))
189
+ }
190
+ const json = yield* response.json.pipe(Effect.mapError(transportFailure))
191
+ return yield* Schema.decodeUnknownEffect(WireResponse)(json).pipe(
192
+ Effect.mapError(transportFailure),
193
+ )
194
+ })
195
+
196
+ // ---------------------------------------------------------------------------
197
+ // Service implementation
198
+ // ---------------------------------------------------------------------------
199
+
200
+ const usageOf = (u: typeof WireUsage.Type): Usage => ({ inputTokens: u.prompt_tokens })
201
+
202
+ const embedImpl =
203
+ (cfg: Config) =>
204
+ (
205
+ request: OpenAIEmbedRequest,
206
+ ): Effect.Effect<EmbedResponse, AiError.AiError, HttpClient.HttpClient> =>
207
+ buildSingleBody(request).pipe(
208
+ Effect.flatMap((body) => postEmbed(cfg, body)),
209
+ Effect.flatMap((decoded) => {
210
+ const first = decoded.data[0]
211
+ if (first === undefined) {
212
+ return Effect.fail(transportFailure("OpenAI returned empty `data` array"))
213
+ }
214
+ return Effect.succeed<EmbedResponse>({
215
+ embedding: valuesToEmbedding(first.embedding),
216
+ usage: usageOf(decoded.usage),
217
+ })
218
+ }),
219
+ )
220
+
221
+ const embedManyImpl =
222
+ (cfg: Config) =>
223
+ (
224
+ request: OpenAIEmbedManyRequest,
225
+ ): Effect.Effect<EmbedManyResponse, AiError.AiError, HttpClient.HttpClient> =>
226
+ buildBatchBody(request).pipe(
227
+ Effect.flatMap((body) => postEmbed(cfg, body)),
228
+ Effect.map(
229
+ (decoded): EmbedManyResponse => ({
230
+ embeddings: orderedEmbeddings(decoded.data),
231
+ usage: usageOf(decoded.usage),
232
+ }),
233
+ ),
234
+ )
235
+
236
+ // ---------------------------------------------------------------------------
237
+ // Constructors
238
+ // ---------------------------------------------------------------------------
239
+
240
+ /**
241
+ * Build an `OpenAIEmbeddingService` value. For Layer-based setup, prefer
242
+ * `layer`.
243
+ */
244
+ export const make = (
245
+ cfg: Config,
246
+ ): Effect.Effect<OpenAIEmbeddingService, never, HttpClient.HttpClient> =>
247
+ Effect.map(HttpClient.HttpClient.asEffect(), (client) => ({
248
+ embed: (request) =>
249
+ embedImpl(cfg)(request).pipe(Effect.provideService(HttpClient.HttpClient, client)),
250
+ embedMany: (request) =>
251
+ embedManyImpl(cfg)(request).pipe(Effect.provideService(HttpClient.HttpClient, client)),
252
+ }))
253
+
254
+ /**
255
+ * Layer that registers both the provider-specific `OpenAIEmbedding` tag
256
+ * and the generic `EmbeddingModel` tag, sharing one underlying
257
+ * implementation. The generic registration trusts the caller; invalid
258
+ * encodings produce an `InvalidRequest` from OpenAI's API.
259
+ */
260
+ export const layer = (
261
+ cfg: Config,
262
+ ): Layer.Layer<OpenAIEmbedding | EmbeddingModel, never, HttpClient.HttpClient> => {
263
+ const typed = Layer.effect(OpenAIEmbedding, make(cfg))
264
+ const generic = Layer.effect(
265
+ EmbeddingModel,
266
+ Effect.map(
267
+ make(cfg),
268
+ (s): EmbeddingModelService => ({
269
+ embed: (req: CommonEmbedRequest) => s.embed(req as OpenAIEmbedRequest),
270
+ embedMany: (req: CommonEmbedManyRequest) => s.embedMany(req as OpenAIEmbedManyRequest),
271
+ }),
272
+ ),
273
+ )
274
+ return Layer.merge(typed, generic)
275
+ }
package/src/Responses.ts CHANGED
@@ -1,7 +1,6 @@
1
1
  import { Context, Effect, Layer, Match, Option, Redacted, Schema, Stream } from "effect"
2
2
  import { HttpClient, HttpClientRequest } from "effect/unstable/http"
3
3
  import * as AiError from "@effect-uai/core/AiError"
4
- import { matchType } from "@effect-uai/core/Match"
5
4
  import * as StructuredFormat from "@effect-uai/core/StructuredFormat"
6
5
  import {
7
6
  type CommonRequest,
@@ -23,7 +22,7 @@ import {
23
22
  // Public types
24
23
  // ---------------------------------------------------------------------------
25
24
 
26
- export interface ResponsesRequest extends Omit<CommonRequest, "model"> {
25
+ export type ResponsesRequest = Omit<CommonRequest, "model"> & {
27
26
  /**
28
27
  * Narrows `CommonRequest.model` (`string`) to the typed `OpenAIModel`
29
28
  * literal union for autocomplete.
@@ -52,7 +51,7 @@ export interface ResponsesRequest extends Omit<CommonRequest, "model"> {
52
51
  readonly verbosity?: "low" | "medium" | "high"
53
52
  }
54
53
 
55
- export interface ResponsesService {
54
+ export type ResponsesService = {
56
55
  /**
57
56
  * Stream the provider's native event vocabulary (post-SSE-decode).
58
57
  * Use this when you need full vendor fidelity. For provider-portable
@@ -86,7 +85,7 @@ export class Responses extends Context.Service<Responses, ResponsesService>()(
86
85
  "@betalyra/effect-uai/providers/responses/Responses",
87
86
  ) {}
88
87
 
89
- export interface Config {
88
+ export type Config = {
90
89
  readonly apiKey: Redacted.Redacted
91
90
  readonly baseUrl?: string
92
91
  }
@@ -172,19 +171,18 @@ const makeUnknown = (raw: unknown): ProviderEvent => ({ type: "_unknown", raw })
172
171
  * and produce `Option.none`.
173
172
  */
174
173
  const eventToError = Match.type<ProviderEvent>().pipe(
175
- matchType(
176
- "error",
177
- (e): AiError.AiError => new AiError.Unavailable({ provider: "responses", raw: e }),
178
- ),
179
- matchType("response.failed", (e): AiError.AiError => {
180
- const code = e.response.error?.code
181
- const message = e.response.error?.message
182
- return new AiError.GenerationFailed({
183
- provider: "responses",
184
- ...(code !== undefined && code !== null && { code }),
185
- ...(message !== undefined && message !== null && { message }),
186
- raw: e,
187
- })
174
+ Match.discriminators("type")({
175
+ error: (e): AiError.AiError => new AiError.Unavailable({ provider: "responses", raw: e }),
176
+ "response.failed": (e): AiError.AiError => {
177
+ const code = e.response.error?.code
178
+ const message = e.response.error?.message
179
+ return new AiError.GenerationFailed({
180
+ provider: "responses",
181
+ ...(code !== undefined && code !== null && { code }),
182
+ ...(message !== undefined && message !== null && { message }),
183
+ raw: e,
184
+ })
185
+ },
188
186
  }),
189
187
  Match.option,
190
188
  )