@effect-uai/responses 0.9.0 → 0.11.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.
Files changed (45) hide show
  1. package/dist/OpenAIDeepResearch.d.mts +54 -0
  2. package/dist/OpenAIDeepResearch.d.mts.map +1 -0
  3. package/dist/OpenAIDeepResearch.mjs +121 -0
  4. package/dist/OpenAIDeepResearch.mjs.map +1 -0
  5. package/dist/OpenAIEmbedding.mjs +1 -1
  6. package/dist/OpenAIEmbedding.mjs.map +1 -1
  7. package/dist/Responses.d.mts +13 -3
  8. package/dist/Responses.d.mts.map +1 -1
  9. package/dist/Responses.mjs +68 -33
  10. package/dist/Responses.mjs.map +1 -1
  11. package/dist/ResponsesTools.d.mts +85 -0
  12. package/dist/ResponsesTools.d.mts.map +1 -0
  13. package/dist/ResponsesTools.mjs +126 -0
  14. package/dist/ResponsesTools.mjs.map +1 -0
  15. package/dist/codec.d.mts +42 -3
  16. package/dist/codec.d.mts.map +1 -1
  17. package/dist/codec.mjs +28 -9
  18. package/dist/codec.mjs.map +1 -1
  19. package/dist/index.d.mts +5 -3
  20. package/dist/index.mjs +5 -3
  21. package/dist/{magic-string.es-PAZgLX4h.mjs → magic-string.es-CaWgF-3p.mjs} +1 -1
  22. package/dist/{magic-string.es-PAZgLX4h.mjs.map → magic-string.es-CaWgF-3p.mjs.map} +1 -1
  23. package/dist/models.d.mts +10 -1
  24. package/dist/models.d.mts.map +1 -1
  25. package/dist/region.test.mjs +1 -13630
  26. package/dist/region.test.mjs.map +1 -1
  27. package/dist/responsesTools.test.d.mts +1 -0
  28. package/dist/responsesTools.test.mjs +138 -0
  29. package/dist/responsesTools.test.mjs.map +1 -0
  30. package/dist/streamEvents.d.mts +142 -0
  31. package/dist/streamEvents.d.mts.map +1 -1
  32. package/dist/streamEvents.mjs +27 -2
  33. package/dist/streamEvents.mjs.map +1 -1
  34. package/dist/test.DNmyFkvJ-CPKukVWS.mjs +13633 -0
  35. package/dist/test.DNmyFkvJ-CPKukVWS.mjs.map +1 -0
  36. package/package.json +8 -4
  37. package/src/OpenAIDeepResearch.ts +266 -0
  38. package/src/OpenAIEmbedding.ts +1 -1
  39. package/src/Responses.ts +94 -36
  40. package/src/ResponsesTools.ts +158 -0
  41. package/src/codec.ts +62 -26
  42. package/src/index.ts +2 -0
  43. package/src/models.ts +14 -0
  44. package/src/responsesTools.test.ts +146 -0
  45. package/src/streamEvents.ts +58 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effect-uai/responses",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "description": "OpenAI Responses API provider for @effect-uai/core.",
5
5
  "keywords": [
6
6
  "ai",
@@ -38,6 +38,10 @@
38
38
  "types": "./dist/Responses.d.mts",
39
39
  "import": "./dist/Responses.mjs"
40
40
  },
41
+ "./OpenAIDeepResearch": {
42
+ "types": "./dist/OpenAIDeepResearch.d.mts",
43
+ "import": "./dist/OpenAIDeepResearch.mjs"
44
+ },
41
45
  "./OpenAIEmbedding": {
42
46
  "types": "./dist/OpenAIEmbedding.d.mts",
43
47
  "import": "./dist/OpenAIEmbedding.mjs"
@@ -47,13 +51,13 @@
47
51
  "access": "public"
48
52
  },
49
53
  "devDependencies": {
50
- "effect": "4.0.0-beta.57",
54
+ "effect": "4.0.0-beta.94",
51
55
  "typescript": "^6.0.3",
52
- "@effect-uai/core": "0.9.0"
56
+ "@effect-uai/core": "0.11.0"
53
57
  },
54
58
  "peerDependencies": {
55
59
  "@effect-uai/core": ">=0.2.0 <1",
56
- "effect": "4.0.0-beta.57"
60
+ "effect": ">=4.0.0-beta.94 <5.0.0"
57
61
  },
58
62
  "scripts": {
59
63
  "build": "tsdown",
@@ -0,0 +1,266 @@
1
+ import { Context, Effect, Layer, Match, Option, Redacted, Schema, Stream } from "effect"
2
+ import { HttpClient, HttpClientRequest } from "effect/unstable/http"
3
+ import * as AiError from "@effect-uai/core/AiError"
4
+ import {
5
+ DeepResearch,
6
+ type DeepResearchService,
7
+ type DeepResearchServiceShape,
8
+ fromJob,
9
+ type ResearchJobOps,
10
+ } from "@effect-uai/core/DeepResearch"
11
+ import * as Job from "@effect-uai/core/Job"
12
+ import type { ResearchJobRef, ResearchRequest, ResearchState } from "@effect-uai/core/Research"
13
+ import type { Turn, TurnEvent } from "@effect-uai/core/Turn"
14
+ import { WireResponseCompleted, turnFromCompleted, itemsToInput } from "./codec.js"
15
+ import type { OpenAIDeepResearchModel } from "./models.js"
16
+ import { type OpenAiRegion, resolveHost } from "./region.js"
17
+ import { httpStatusError, providerEventsOfResponse, toCanonical } from "./Responses.js"
18
+ import type { ProviderEvent } from "./streamEvents.js"
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Public types
22
+ // ---------------------------------------------------------------------------
23
+
24
+ /**
25
+ * OpenAI-typed research request. Narrows `model` to the deep-research ids and
26
+ * adds `maxSearches` (mapped to `max_tool_calls`) and a `reasoning` hint.
27
+ * Provider-portable code uses the generic `DeepResearch` tag instead.
28
+ */
29
+ export type OpenAIResearchRequest = Omit<ResearchRequest, "model"> & {
30
+ readonly model?: OpenAIDeepResearchModel
31
+ /** Cost/latency cap on the agent's tool calls. Maps to `max_tool_calls`. */
32
+ readonly maxSearches?: number
33
+ /** Reasoning-effort hint; `summary: "auto"` streams reasoning summaries. */
34
+ readonly reasoning?: {
35
+ readonly effort?: "low" | "medium" | "high"
36
+ readonly summary?: "auto" | "concise" | "detailed"
37
+ }
38
+ }
39
+
40
+ export type OpenAIDeepResearchService = DeepResearchServiceShape<OpenAIResearchRequest>
41
+
42
+ /**
43
+ * Provider-typed tag. Yield this for the OpenAI model / `maxSearches` knobs;
44
+ * yield the generic `DeepResearch` tag for provider-portable code. The
45
+ * background job is detachable and streams live over the resumable Responses
46
+ * SSE, so `streamFrom` is a real event stream (not the synthesized fallback) and
47
+ * a persisted ref survives a restart.
48
+ */
49
+ export class OpenAIDeepResearch extends Context.Service<
50
+ OpenAIDeepResearch,
51
+ OpenAIDeepResearchService
52
+ >()("@betalyra/effect-uai/providers/responses/OpenAIDeepResearch") {}
53
+
54
+ export type Config = {
55
+ readonly apiKey: Redacted.Redacted
56
+ readonly baseUrl?: string
57
+ readonly region?: OpenAiRegion
58
+ /** Poll cadence / overall timeout for the derived poll loops. */
59
+ readonly job?: Job.JobConfig
60
+ }
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // Wire codec
64
+ // ---------------------------------------------------------------------------
65
+
66
+ const decodeResponse = Schema.decodeUnknownEffect(WireResponseCompleted)
67
+
68
+ // Any early lifecycle event (`response.created` / `response.queued` / ...)
69
+ // carries the response object; only its id matters here. These events are not
70
+ // in the modeled `ProviderEvent` union, so they arrive as `_unknown`.
71
+ const WireCreated = Schema.Struct({ response: Schema.Struct({ id: Schema.String }) })
72
+ const decodeCreated = Schema.decodeUnknownEffect(WireCreated)
73
+
74
+ const responseIdOf = (event: ProviderEvent): Effect.Effect<Option.Option<string>> =>
75
+ event.type === "_unknown"
76
+ ? decodeCreated(event.raw).pipe(
77
+ Effect.map((wire) => Option.some(wire.response.id)),
78
+ Effect.orElseSucceed(() => Option.none<string>()),
79
+ )
80
+ : Effect.succeedNone
81
+
82
+ // Map a Responses object's `status` onto the generic `JobState`. `completed`
83
+ // and `incomplete` both carry a usable turn (incomplete = stopped early with a
84
+ // partial answer, same shape); `failed` / `cancelled` are terminal failures.
85
+ // An unknown / absent status keeps the loop polling (bounded by the timeout).
86
+ export const jobStateOf = (wire: WireResponseCompleted): ResearchState =>
87
+ Match.value(wire.status ?? "").pipe(
88
+ Match.when("queued", (): ResearchState => ({ _tag: "Pending" })),
89
+ Match.when("in_progress", (): ResearchState => ({ _tag: "Running" })),
90
+ Match.whenOr(
91
+ "completed",
92
+ "incomplete",
93
+ (): ResearchState => ({ _tag: "Succeeded", result: turnFromCompleted(wire) }),
94
+ ),
95
+ Match.whenOr(
96
+ "failed",
97
+ "cancelled",
98
+ (): ResearchState => ({
99
+ _tag: "Failed",
100
+ ...(wire.error?.message != null && { reason: wire.error.message }),
101
+ raw: wire,
102
+ }),
103
+ ),
104
+ Match.orElse((): ResearchState => ({ _tag: "Running" })),
105
+ )
106
+
107
+ // ---------------------------------------------------------------------------
108
+ // HTTP
109
+ // ---------------------------------------------------------------------------
110
+
111
+ const transportFailure = (cause: unknown): AiError.AiError =>
112
+ new AiError.Unavailable({ provider: "responses", raw: cause })
113
+
114
+ // Deep research requires at least one data-source tool; default to web search.
115
+ const submitBody = (request: OpenAIResearchRequest): Record<string, unknown> => ({
116
+ model: request.model ?? "o3-deep-research",
117
+ input: itemsToInput(request.history),
118
+ background: true,
119
+ store: true,
120
+ tools: [{ type: "web_search_preview" }],
121
+ ...(request.maxSearches !== undefined && { max_tool_calls: request.maxSearches }),
122
+ ...(request.reasoning !== undefined && { reasoning: request.reasoning }),
123
+ })
124
+
125
+ // The job is created with `stream: true` on top of `background: true`: OpenAI
126
+ // only exposes a resumable event feed on responses *created* streaming, so this
127
+ // keeps every submitted ref attachable via `streamFrom`. Only the first SSE
128
+ // event is read (for the response id); the connection is then dropped and the
129
+ // background job keeps running server-side.
130
+ const submitJob = (
131
+ cfg: Config,
132
+ request: OpenAIResearchRequest,
133
+ ): Effect.Effect<ResearchJobRef, AiError.AiError, HttpClient.HttpClient> =>
134
+ Effect.gen(function* () {
135
+ const client = yield* HttpClient.HttpClient
136
+ const httpRequest = HttpClientRequest.post(`${resolveHost(cfg)}/responses`).pipe(
137
+ HttpClientRequest.bearerToken(cfg.apiKey),
138
+ HttpClientRequest.accept("text/event-stream"),
139
+ HttpClientRequest.bodyJsonUnsafe({ ...submitBody(request), stream: true }),
140
+ )
141
+ const response = yield* client.execute(httpRequest).pipe(Effect.mapError(transportFailure))
142
+ const id = yield* providerEventsOfResponse(response).pipe(
143
+ Stream.mapEffect(responseIdOf),
144
+ Stream.filter(Option.isSome),
145
+ Stream.map((some) => some.value),
146
+ Stream.runHead,
147
+ )
148
+ return yield* Option.match(id, {
149
+ onNone: () =>
150
+ Effect.fail(
151
+ new AiError.GenerationFailed({
152
+ provider: "responses",
153
+ message: "streaming create ended without a response id",
154
+ raw: submitBody(request),
155
+ }),
156
+ ),
157
+ onSome: (value) => Effect.succeed(Job.jobRef<Turn>("responses", value)),
158
+ })
159
+ })
160
+
161
+ const pollJob = (
162
+ cfg: Config,
163
+ ref: ResearchJobRef,
164
+ ): Effect.Effect<ResearchState, AiError.AiError, HttpClient.HttpClient> =>
165
+ Effect.gen(function* () {
166
+ const client = yield* HttpClient.HttpClient
167
+ const httpRequest = HttpClientRequest.get(`${resolveHost(cfg)}/responses/${ref.id}`).pipe(
168
+ HttpClientRequest.bearerToken(cfg.apiKey),
169
+ )
170
+ const response = yield* client.execute(httpRequest).pipe(Effect.mapError(transportFailure))
171
+ if (response.status >= 400) {
172
+ const text = yield* response.text.pipe(Effect.orElseSucceed(() => ""))
173
+ return yield* httpStatusError(response.status, text)
174
+ }
175
+ const json = yield* response.json.pipe(Effect.mapError(transportFailure))
176
+ const wire = yield* decodeResponse(json).pipe(Effect.mapError(transportFailure))
177
+ return jobStateOf(wire)
178
+ })
179
+
180
+ const cancelJob = (
181
+ cfg: Config,
182
+ ref: ResearchJobRef,
183
+ ): Effect.Effect<void, AiError.AiError, HttpClient.HttpClient> =>
184
+ Effect.gen(function* () {
185
+ const client = yield* HttpClient.HttpClient
186
+ const httpRequest = HttpClientRequest.post(
187
+ `${resolveHost(cfg)}/responses/${ref.id}/cancel`,
188
+ ).pipe(HttpClientRequest.bearerToken(cfg.apiKey))
189
+ const response = yield* client.execute(httpRequest).pipe(Effect.mapError(transportFailure))
190
+ if (response.status >= 400) {
191
+ const text = yield* response.text.pipe(Effect.orElseSucceed(() => ""))
192
+ return yield* httpStatusError(response.status, text)
193
+ }
194
+ })
195
+
196
+ // Attach a live event stream to a detached job via the resumable Responses SSE,
197
+ // reusing the shared decode pipeline and the canonical `TurnEvent` projection.
198
+ const streamFromRef = (
199
+ cfg: Config,
200
+ ref: ResearchJobRef,
201
+ ): Stream.Stream<TurnEvent, AiError.AiError, HttpClient.HttpClient> =>
202
+ Stream.unwrap(
203
+ Effect.gen(function* () {
204
+ const client = yield* HttpClient.HttpClient
205
+ const httpRequest = HttpClientRequest.get(
206
+ `${resolveHost(cfg)}/responses/${ref.id}?stream=true`,
207
+ ).pipe(
208
+ HttpClientRequest.bearerToken(cfg.apiKey),
209
+ HttpClientRequest.accept("text/event-stream"),
210
+ )
211
+ const response = yield* client.execute(httpRequest).pipe(Effect.mapError(transportFailure))
212
+ return toCanonical(providerEventsOfResponse(response))
213
+ }),
214
+ )
215
+
216
+ // ---------------------------------------------------------------------------
217
+ // Constructors
218
+ // ---------------------------------------------------------------------------
219
+
220
+ // The job ops. OpenAI streams live over the resumable Responses SSE, so it
221
+ // supplies a real `streamFrom` (not the synthesized fallback). Since `submit`
222
+ // creates the job streaming, `fromJob`'s default `researchStream`
223
+ // (submit-then-attach) streams real events too.
224
+ const jobOps = (
225
+ cfg: Config,
226
+ client: HttpClient.HttpClient,
227
+ ): ResearchJobOps<OpenAIResearchRequest> => {
228
+ const withClient = <A>(e: Effect.Effect<A, AiError.AiError, HttpClient.HttpClient>) =>
229
+ Effect.provideService(e, HttpClient.HttpClient, client)
230
+ const withClientStream = <A>(s: Stream.Stream<A, AiError.AiError, HttpClient.HttpClient>) =>
231
+ Stream.provideService(s, HttpClient.HttpClient, client)
232
+ return {
233
+ submit: (request) => withClient(submitJob(cfg, request)),
234
+ poll: (ref) => withClient(pollJob(cfg, ref)),
235
+ cancel: (ref) => withClient(cancelJob(cfg, ref)),
236
+ streamFrom: (ref) => withClientStream(streamFromRef(cfg, ref)),
237
+ }
238
+ }
239
+
240
+ /** Build an `OpenAIDeepResearchService`. For Layer setup, prefer {@link layer}. */
241
+ export const make = (
242
+ cfg: Config,
243
+ ): Effect.Effect<OpenAIDeepResearchService, never, HttpClient.HttpClient> =>
244
+ Effect.map(HttpClient.HttpClient, (client) => fromJob(jobOps(cfg, client), cfg.job))
245
+
246
+ /**
247
+ * Layer registering the provider-typed `OpenAIDeepResearch` tag and the generic
248
+ * `DeepResearch` tag. No capability markers: the whole surface (detach, live
249
+ * stream, cancel) is universal.
250
+ */
251
+ export const layer = (
252
+ cfg: Config,
253
+ ): Layer.Layer<OpenAIDeepResearch | DeepResearch, never, HttpClient.HttpClient> => {
254
+ const typed = Layer.effect(OpenAIDeepResearch, make(cfg))
255
+ const generic = Layer.effect(
256
+ DeepResearch,
257
+ Effect.map(HttpClient.HttpClient, (client): DeepResearchService => {
258
+ const ops = jobOps(cfg, client)
259
+ return fromJob<ResearchRequest>(
260
+ { ...ops, submit: (request) => ops.submit(request as OpenAIResearchRequest) },
261
+ cfg.job,
262
+ )
263
+ }),
264
+ )
265
+ return Layer.merge(typed, generic)
266
+ }
@@ -260,7 +260,7 @@ const embedManyImpl =
260
260
  export const make = (
261
261
  cfg: Config,
262
262
  ): Effect.Effect<OpenAIEmbeddingService, never, HttpClient.HttpClient> =>
263
- Effect.map(HttpClient.HttpClient.asEffect(), (client) => ({
263
+ Effect.map(HttpClient.HttpClient, (client) => ({
264
264
  embed: (request) =>
265
265
  embedImpl(cfg)(request).pipe(Effect.provideService(HttpClient.HttpClient, client)),
266
266
  embedMany: (request) =>
package/src/Responses.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { Context, Effect, Layer, Match, Option, Redacted, Schema, Stream } from "effect"
2
- import { HttpClient, HttpClientRequest } from "effect/unstable/http"
1
+ import { Context, Effect, Layer, Match, Option, Redacted, Result, Schema, Stream } from "effect"
2
+ import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
3
3
  import * as AiError from "@effect-uai/core/AiError"
4
4
  import * as StructuredFormat from "@effect-uai/core/StructuredFormat"
5
5
  import {
@@ -9,9 +9,19 @@ import {
9
9
  turnFromStream,
10
10
  } from "@effect-uai/core/LanguageModel"
11
11
  import * as SSE from "@effect-uai/core/SSE"
12
- import { descriptorsOf } from "@effect-uai/core/Tool"
12
+ import { descriptorsOf, providerToolsOf } from "@effect-uai/core/Tool"
13
13
  import type { Turn, TurnEvent } from "@effect-uai/core/Turn"
14
14
  import { itemsToInput } from "./codec.js"
15
+ import { renderProviderTools } from "./ResponsesTools.js"
16
+
17
+ export {
18
+ codeInterpreterTool,
19
+ fileSearchTool,
20
+ webSearchTool,
21
+ type WebSearchOptions,
22
+ type WebSearchFilters,
23
+ type UserLocation,
24
+ } from "./ResponsesTools.js"
15
25
  import type { OpenAIModel } from "./models.js"
16
26
  import { type OpenAiRegion, resolveHost } from "./region.js"
17
27
  import {
@@ -123,22 +133,24 @@ const buildText = (request: ResponsesRequest): Record<string, unknown> | undefin
123
133
  return Object.keys(text).length === 0 ? undefined : text
124
134
  }
125
135
 
126
- const buildBody = (request: ResponsesRequest): Record<string, unknown> => {
136
+ const buildBody = (
137
+ request: ResponsesRequest,
138
+ providerToolWire: ReadonlyArray<Record<string, unknown>>,
139
+ ): Record<string, unknown> => {
127
140
  const text = buildText(request)
128
- const tools = descriptorsOf(request.tools)
141
+ const functionTools = descriptorsOf(request.tools).map((t) => ({
142
+ type: "function",
143
+ name: t.name,
144
+ description: t.description,
145
+ parameters: t.inputSchema,
146
+ ...(t.strict !== undefined && { strict: t.strict }),
147
+ }))
148
+ const tools = [...functionTools, ...providerToolWire]
129
149
  return {
130
150
  model: request.model,
131
151
  input: itemsToInput(request.history),
132
152
  stream: true,
133
- ...(tools.length > 0 && {
134
- tools: tools.map((t) => ({
135
- type: "function",
136
- name: t.name,
137
- description: t.description,
138
- parameters: t.inputSchema,
139
- ...(t.strict !== undefined && { strict: t.strict }),
140
- })),
141
- }),
153
+ ...(tools.length > 0 && { tools }),
142
154
  ...(request.toolChoice !== undefined && { tool_choice: request.toolChoice }),
143
155
  ...(request.temperature !== undefined && { temperature: request.temperature }),
144
156
  ...(request.maxOutputTokens !== undefined && {
@@ -182,7 +194,26 @@ const makeUnknown = (raw: unknown): ProviderEvent => ({ type: "_unknown", raw })
182
194
  */
183
195
  const eventToError = Match.type<ProviderEvent>().pipe(
184
196
  Match.discriminators("type")({
185
- error: (e): AiError.AiError => new AiError.Unavailable({ provider: "responses", raw: e }),
197
+ error: (e): AiError.AiError => {
198
+ const detail = e.error
199
+ const message = detail?.message ?? e.message
200
+ const code = detail?.code ?? e.code
201
+ const param = detail?.param
202
+ // A top-level SSE `error` is usually a request rejection (bad model,
203
+ // missing access, malformed body): non-retryable, so `InvalidRequest`.
204
+ return detail?.type === "invalid_request_error"
205
+ ? new AiError.InvalidRequest({
206
+ provider: "responses",
207
+ ...(param != null && { param }),
208
+ raw: e,
209
+ })
210
+ : new AiError.GenerationFailed({
211
+ provider: "responses",
212
+ ...(code != null && { code }),
213
+ ...(message != null && { message }),
214
+ raw: e,
215
+ })
216
+ },
186
217
  "response.failed": (e): AiError.AiError => {
187
218
  const code = e.response.error?.code
188
219
  const message = e.response.error?.message
@@ -211,7 +242,7 @@ const sseEventToProviderEvent = (ev: SSE.Event): Effect.Effect<ProviderEvent> =>
211
242
  // Service implementation
212
243
  // ---------------------------------------------------------------------------
213
244
 
214
- const httpStatusError = (status: number, body: string): AiError.AiError => {
245
+ export const httpStatusError = (status: number, body: string): AiError.AiError => {
215
246
  const provider = "responses"
216
247
  const raw = body
217
248
  if (status === 429) return new AiError.RateLimited({ provider, raw })
@@ -224,6 +255,36 @@ const httpStatusError = (status: number, body: string): AiError.AiError => {
224
255
  return new AiError.InvalidRequest({ provider, raw })
225
256
  }
226
257
 
258
+ /**
259
+ * Decode an already-executed `/responses` HTTP response into the native
260
+ * `ProviderEvent` stream: fail on a >=400 status, else SSE-decode the body and
261
+ * lift terminal `error` / `response.failed` events to a typed `AiError`. Shared
262
+ * by the streaming turn path (POST) and the deep-research resume stream (GET
263
+ * `?stream=true`).
264
+ */
265
+ export const providerEventsOfResponse = (
266
+ response: HttpClientResponse.HttpClientResponse,
267
+ ): Stream.Stream<ProviderEvent, AiError.AiError> =>
268
+ Stream.unwrap(
269
+ Effect.gen(function* () {
270
+ if (response.status >= 400) {
271
+ const body = yield* response.text.pipe(Effect.orElseSucceed(() => ""))
272
+ return Stream.fail(httpStatusError(response.status, body))
273
+ }
274
+ return response.stream.pipe(
275
+ Stream.mapError((cause) => new AiError.Unavailable({ provider: "responses", raw: cause })),
276
+ SSE.fromBytes,
277
+ Stream.mapEffect(sseEventToProviderEvent),
278
+ Stream.flatMap((event) =>
279
+ Option.match(eventToError(event), {
280
+ onNone: () => Stream.succeed(event),
281
+ onSome: Stream.fail,
282
+ }),
283
+ ),
284
+ )
285
+ }),
286
+ )
287
+
227
288
  const buildNativeStream = (cfg: Config) => {
228
289
  const url = `${resolveHost(cfg)}/responses`
229
290
  return (
@@ -232,9 +293,23 @@ const buildNativeStream = (cfg: Config) => {
232
293
  Stream.unwrap(
233
294
  Effect.gen(function* () {
234
295
  const client = yield* HttpClient.HttpClient
296
+ const providerToolWire = yield* Result.match(
297
+ renderProviderTools(providerToolsOf(request.tools)),
298
+ {
299
+ onFailure: (e) =>
300
+ Effect.fail(
301
+ new AiError.Unsupported({
302
+ provider: "responses",
303
+ capability: e.capability,
304
+ reason: e.reason,
305
+ }),
306
+ ),
307
+ onSuccess: (wire) => Effect.succeed(wire),
308
+ },
309
+ )
235
310
  const httpRequest = HttpClientRequest.post(url).pipe(
236
311
  HttpClientRequest.bearerToken(cfg.apiKey),
237
- HttpClientRequest.bodyJsonUnsafe(buildBody(request)),
312
+ HttpClientRequest.bodyJsonUnsafe(buildBody(request, providerToolWire)),
238
313
  HttpClientRequest.accept("text/event-stream"),
239
314
  )
240
315
  const response = yield* client
@@ -244,24 +319,7 @@ const buildNativeStream = (cfg: Config) => {
244
319
  (cause) => new AiError.Unavailable({ provider: "responses", raw: cause }),
245
320
  ),
246
321
  )
247
- if (response.status >= 400) {
248
- const body = yield* response.text.pipe(Effect.orElseSucceed(() => ""))
249
- return Stream.fail(httpStatusError(response.status, body))
250
- }
251
-
252
- return response.stream.pipe(
253
- Stream.mapError(
254
- (cause) => new AiError.Unavailable({ provider: "responses", raw: cause }),
255
- ),
256
- SSE.fromBytes,
257
- Stream.mapEffect(sseEventToProviderEvent),
258
- Stream.flatMap((event) =>
259
- Option.match(eventToError(event), {
260
- onNone: () => Stream.succeed(event),
261
- onSome: Stream.fail,
262
- }),
263
- ),
264
- )
322
+ return providerEventsOfResponse(response)
265
323
  }),
266
324
  )
267
325
  }
@@ -289,7 +347,7 @@ export const toCanonical = <E, R>(
289
347
  * Build a `ResponsesService` value. For Layer-based setup, prefer `layer`.
290
348
  */
291
349
  export const make = (cfg: Config): Effect.Effect<ResponsesService, never, HttpClient.HttpClient> =>
292
- Effect.map(HttpClient.HttpClient.asEffect(), (client) => {
350
+ Effect.map(HttpClient.HttpClient, (client) => {
293
351
  const streamNative: ResponsesService["streamNative"] = (request) =>
294
352
  buildNativeStream(cfg)(request).pipe(Stream.provideService(HttpClient.HttpClient, client))
295
353
  const streamTurn: ResponsesService["streamTurn"] = (request) =>
@@ -0,0 +1,158 @@
1
+ import { Match, Result, Schema } from "effect"
2
+ import * as Tool from "@effect-uai/core/Tool"
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // OpenAI Responses provider-hosted tools. Each is a `Tool.provider` whose
6
+ // `config` carries the wire `type` string plus its options; the renderer maps
7
+ // it to a native entry appended to the `tools` array alongside function tools.
8
+ // The `inputSchema` is never sent (provider tools are partitioned out of the
9
+ // function declarations), so `Tool.noInput` suffices.
10
+ // Reference: https://developers.openai.com/api/docs/guides/tools
11
+ // ---------------------------------------------------------------------------
12
+
13
+ const UserLocation = Schema.Struct({
14
+ city: Schema.optional(Schema.String),
15
+ region: Schema.optional(Schema.String),
16
+ country: Schema.optional(Schema.String),
17
+ timezone: Schema.optional(Schema.String),
18
+ })
19
+ export type UserLocation = typeof UserLocation.Type
20
+
21
+ const WebSearchFilters = Schema.Struct({
22
+ allowedDomains: Schema.optional(Schema.Array(Schema.String)),
23
+ blockedDomains: Schema.optional(Schema.Array(Schema.String)),
24
+ })
25
+ export type WebSearchFilters = typeof WebSearchFilters.Type
26
+
27
+ const WebSearchConfig = Schema.Struct({
28
+ kind: Schema.Literal("web_search"),
29
+ filters: Schema.optional(WebSearchFilters),
30
+ userLocation: Schema.optional(UserLocation),
31
+ searchContextSize: Schema.optional(Schema.Literals(["low", "medium", "high"])),
32
+ })
33
+
34
+ const CodeInterpreterConfig = Schema.Struct({
35
+ kind: Schema.Literal("code_interpreter"),
36
+ fileIds: Schema.optional(Schema.Array(Schema.String)),
37
+ })
38
+
39
+ const FileSearchConfig = Schema.Struct({
40
+ kind: Schema.Literal("file_search"),
41
+ vectorStoreIds: Schema.Array(Schema.String),
42
+ })
43
+
44
+ /** Discriminated config for a Responses hosted tool. */
45
+ export const ResponsesToolConfig = Schema.Union([
46
+ WebSearchConfig,
47
+ CodeInterpreterConfig,
48
+ FileSearchConfig,
49
+ ])
50
+ export type ResponsesToolConfig = typeof ResponsesToolConfig.Type
51
+
52
+ export type WebSearchOptions = {
53
+ readonly filters?: WebSearchFilters
54
+ readonly userLocation?: UserLocation
55
+ readonly searchContextSize?: "low" | "medium" | "high"
56
+ }
57
+
58
+ /** OpenAI-hosted web search. */
59
+ export const webSearchTool = (opts: WebSearchOptions = {}) =>
60
+ Tool.provider({
61
+ name: "web_search",
62
+ description: "Search the web, executed by OpenAI.",
63
+ inputSchema: Tool.noInput,
64
+ provider: "openai",
65
+ config: { kind: "web_search", ...opts } as const,
66
+ })
67
+
68
+ /** OpenAI-hosted code interpreter (Python sandbox). */
69
+ export const codeInterpreterTool = (opts: { readonly fileIds?: ReadonlyArray<string> } = {}) =>
70
+ Tool.provider({
71
+ name: "code_interpreter",
72
+ description: "Run Python in a sandbox, executed by OpenAI.",
73
+ inputSchema: Tool.noInput,
74
+ provider: "openai",
75
+ config: { kind: "code_interpreter", ...opts } as const,
76
+ })
77
+
78
+ /** OpenAI-hosted file search over the given vector stores. */
79
+ export const fileSearchTool = (opts: { readonly vectorStoreIds: ReadonlyArray<string> }) =>
80
+ Tool.provider({
81
+ name: "file_search",
82
+ description: "Retrieve from uploaded files, executed by OpenAI.",
83
+ inputSchema: Tool.noInput,
84
+ provider: "openai",
85
+ config: { kind: "file_search", vectorStoreIds: opts.vectorStoreIds } as const,
86
+ })
87
+
88
+ /**
89
+ * A provider tool this adapter cannot render — a foreign `provider` or an
90
+ * unrecognized `config`. Carried as data so `Responses.ts` can lift it into a
91
+ * typed `AiError.Unsupported`.
92
+ */
93
+ export type ToolRenderError = {
94
+ readonly capability: string
95
+ readonly reason: string
96
+ }
97
+
98
+ const filtersWire = (f: WebSearchFilters): Record<string, unknown> => ({
99
+ ...(f.allowedDomains !== undefined && { allowed_domains: f.allowedDomains }),
100
+ ...(f.blockedDomains !== undefined && { blocked_domains: f.blockedDomains }),
101
+ })
102
+
103
+ const userLocationWire = (loc: UserLocation): Record<string, unknown> => ({
104
+ type: "approximate",
105
+ ...(loc.city !== undefined && { city: loc.city }),
106
+ ...(loc.region !== undefined && { region: loc.region }),
107
+ ...(loc.country !== undefined && { country: loc.country }),
108
+ ...(loc.timezone !== undefined && { timezone: loc.timezone }),
109
+ })
110
+
111
+ const configToWire = Match.type<ResponsesToolConfig>().pipe(
112
+ Match.discriminatorsExhaustive("kind")({
113
+ web_search: (c): Record<string, unknown> => ({
114
+ type: "web_search",
115
+ ...(c.filters !== undefined && { filters: filtersWire(c.filters) }),
116
+ ...(c.userLocation !== undefined && { user_location: userLocationWire(c.userLocation) }),
117
+ ...(c.searchContextSize !== undefined && { search_context_size: c.searchContextSize }),
118
+ }),
119
+ code_interpreter: (c): Record<string, unknown> => ({
120
+ type: "code_interpreter",
121
+ container: c.fileIds !== undefined ? { type: "auto", file_ids: c.fileIds } : { type: "auto" },
122
+ }),
123
+ file_search: (c): Record<string, unknown> => ({
124
+ type: "file_search",
125
+ vector_store_ids: c.vectorStoreIds,
126
+ }),
127
+ }),
128
+ )
129
+
130
+ const decodeConfig = Schema.decodeUnknownResult(ResponsesToolConfig)
131
+
132
+ const renderOne = (
133
+ tool: Tool.ProviderTool<string, any, string, any>,
134
+ ): Result.Result<Record<string, unknown>, ToolRenderError> =>
135
+ tool.provider !== "openai"
136
+ ? Result.fail({
137
+ capability: `providerTool:${tool.provider}`,
138
+ reason: `tool "${tool.name}" is hosted by "${tool.provider}", not openai`,
139
+ })
140
+ : decodeConfig(tool.config).pipe(
141
+ Result.map(configToWire),
142
+ Result.mapError(
143
+ (): ToolRenderError => ({
144
+ capability: "providerTool",
145
+ reason: `unrecognized Responses provider tool config on "${tool.name}"`,
146
+ }),
147
+ ),
148
+ )
149
+
150
+ /**
151
+ * Render a toolkit's Responses provider tools to native `tools` entries. Fails
152
+ * with a `ToolRenderError` on the first foreign or unrecognized tool rather
153
+ * than dropping it.
154
+ */
155
+ export const renderProviderTools = (
156
+ tools: ReadonlyArray<Tool.ProviderTool<string, any, string, any>>,
157
+ ): Result.Result<ReadonlyArray<Record<string, unknown>>, ToolRenderError> =>
158
+ Result.all(tools.map(renderOne))