@effect-uai/responses 0.10.0 → 0.12.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 (49) 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 +33 -3
  8. package/dist/Responses.d.mts.map +1 -1
  9. package/dist/Responses.mjs +71 -34
  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 +58 -17
  18. package/dist/codec.mjs.map +1 -1
  19. package/dist/codec.test.d.mts +1 -0
  20. package/dist/codec.test.mjs +93 -0
  21. package/dist/codec.test.mjs.map +1 -0
  22. package/dist/index.d.mts +5 -3
  23. package/dist/index.mjs +5 -3
  24. package/dist/{magic-string.es-PAZgLX4h.mjs → magic-string.es-CaWgF-3p.mjs} +1 -1
  25. package/dist/{magic-string.es-PAZgLX4h.mjs.map → magic-string.es-CaWgF-3p.mjs.map} +1 -1
  26. package/dist/models.d.mts +10 -1
  27. package/dist/models.d.mts.map +1 -1
  28. package/dist/region.test.mjs +1 -13630
  29. package/dist/region.test.mjs.map +1 -1
  30. package/dist/responsesTools.test.d.mts +1 -0
  31. package/dist/responsesTools.test.mjs +138 -0
  32. package/dist/responsesTools.test.mjs.map +1 -0
  33. package/dist/streamEvents.d.mts +142 -0
  34. package/dist/streamEvents.d.mts.map +1 -1
  35. package/dist/streamEvents.mjs +27 -2
  36. package/dist/streamEvents.mjs.map +1 -1
  37. package/dist/test.DNmyFkvJ-CPKukVWS.mjs +13633 -0
  38. package/dist/test.DNmyFkvJ-CPKukVWS.mjs.map +1 -0
  39. package/package.json +12 -4
  40. package/src/OpenAIDeepResearch.ts +266 -0
  41. package/src/OpenAIEmbedding.ts +1 -1
  42. package/src/Responses.ts +117 -36
  43. package/src/ResponsesTools.ts +158 -0
  44. package/src/codec.test.ts +87 -0
  45. package/src/codec.ts +92 -36
  46. package/src/index.ts +2 -0
  47. package/src/models.ts +14 -0
  48. package/src/responsesTools.test.ts +146 -0
  49. 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.10.0",
3
+ "version": "0.12.0",
4
4
  "description": "OpenAI Responses API provider for @effect-uai/core.",
5
5
  "keywords": [
6
6
  "ai",
@@ -38,6 +38,14 @@
38
38
  "types": "./dist/Responses.d.mts",
39
39
  "import": "./dist/Responses.mjs"
40
40
  },
41
+ "./ResponsesTools": {
42
+ "types": "./dist/ResponsesTools.d.mts",
43
+ "import": "./dist/ResponsesTools.mjs"
44
+ },
45
+ "./OpenAIDeepResearch": {
46
+ "types": "./dist/OpenAIDeepResearch.d.mts",
47
+ "import": "./dist/OpenAIDeepResearch.mjs"
48
+ },
41
49
  "./OpenAIEmbedding": {
42
50
  "types": "./dist/OpenAIEmbedding.d.mts",
43
51
  "import": "./dist/OpenAIEmbedding.mjs"
@@ -47,13 +55,13 @@
47
55
  "access": "public"
48
56
  },
49
57
  "devDependencies": {
50
- "effect": "4.0.0-beta.57",
58
+ "effect": "4.0.0-beta.94",
51
59
  "typescript": "^6.0.3",
52
- "@effect-uai/core": "0.10.0"
60
+ "@effect-uai/core": "0.12.0"
53
61
  },
54
62
  "peerDependencies": {
55
63
  "@effect-uai/core": ">=0.2.0 <1",
56
- "effect": "4.0.0-beta.57"
64
+ "effect": ">=4.0.0-beta.94 <5.0.0"
57
65
  },
58
66
  "scripts": {
59
67
  "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 {
@@ -41,6 +51,20 @@ export type ResponsesRequest = Omit<CommonRequest, "model"> & {
41
51
  readonly safetyIdentifier?: string
42
52
  readonly promptCacheKey?: string
43
53
  readonly truncation?: "auto" | "disabled"
54
+ /**
55
+ * Vendor-specific body fields merged into the request as-is.
56
+ *
57
+ * The Responses dialect is spoken by gateways as well as by OpenAI, and a
58
+ * gateway carries its own parameters — Requesty's `{ requesty: { auto_cache:
59
+ * true } }`, for example. Modelling each one would tie this provider to a
60
+ * catalogue of gateways it cannot see; passing them through keeps the typed
61
+ * surface OpenAI's and the untyped surface open.
62
+ *
63
+ * Merged over the built body, so a field named here wins over the one this
64
+ * provider would have sent. Per-request keys win over the layer's
65
+ * {@link Config.extraBody}.
66
+ */
67
+ readonly extraBody?: Readonly<Record<string, unknown>>
44
68
  /**
45
69
  * Free-form JSON output: model emits valid JSON without schema
46
70
  * constraints. Mutually exclusive with `structured` (schema wins if
@@ -98,6 +122,12 @@ export type Config = {
98
122
  readonly apiKey: Redacted.Redacted
99
123
  readonly baseUrl?: string
100
124
  readonly region?: OpenAiRegion
125
+ /**
126
+ * Vendor-specific body fields sent on every request from this layer — the
127
+ * place for a gateway's standing configuration, so call sites do not repeat
128
+ * it. Overridden per request by {@link ResponsesRequest.extraBody}.
129
+ */
130
+ readonly extraBody?: Readonly<Record<string, unknown>>
101
131
  }
102
132
 
103
133
  // ---------------------------------------------------------------------------
@@ -123,22 +153,25 @@ const buildText = (request: ResponsesRequest): Record<string, unknown> | undefin
123
153
  return Object.keys(text).length === 0 ? undefined : text
124
154
  }
125
155
 
126
- const buildBody = (request: ResponsesRequest): Record<string, unknown> => {
156
+ const buildBody = (
157
+ request: ResponsesRequest,
158
+ providerToolWire: ReadonlyArray<Record<string, unknown>>,
159
+ cfg: Config,
160
+ ): Record<string, unknown> => {
127
161
  const text = buildText(request)
128
- const tools = descriptorsOf(request.tools)
162
+ const functionTools = descriptorsOf(request.tools).map((t) => ({
163
+ type: "function",
164
+ name: t.name,
165
+ description: t.description,
166
+ parameters: t.inputSchema,
167
+ ...(t.strict !== undefined && { strict: t.strict }),
168
+ }))
169
+ const tools = [...functionTools, ...providerToolWire]
129
170
  return {
130
171
  model: request.model,
131
172
  input: itemsToInput(request.history),
132
173
  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
- }),
174
+ ...(tools.length > 0 && { tools }),
142
175
  ...(request.toolChoice !== undefined && { tool_choice: request.toolChoice }),
143
176
  ...(request.temperature !== undefined && { temperature: request.temperature }),
144
177
  ...(request.maxOutputTokens !== undefined && {
@@ -164,6 +197,8 @@ const buildBody = (request: ResponsesRequest): Record<string, unknown> => {
164
197
  }),
165
198
  ...(request.truncation !== undefined && { truncation: request.truncation }),
166
199
  ...(text !== undefined && { text }),
200
+ ...cfg.extraBody,
201
+ ...request.extraBody,
167
202
  }
168
203
  }
169
204
 
@@ -182,7 +217,26 @@ const makeUnknown = (raw: unknown): ProviderEvent => ({ type: "_unknown", raw })
182
217
  */
183
218
  const eventToError = Match.type<ProviderEvent>().pipe(
184
219
  Match.discriminators("type")({
185
- error: (e): AiError.AiError => new AiError.Unavailable({ provider: "responses", raw: e }),
220
+ error: (e): AiError.AiError => {
221
+ const detail = e.error
222
+ const message = detail?.message ?? e.message
223
+ const code = detail?.code ?? e.code
224
+ const param = detail?.param
225
+ // A top-level SSE `error` is usually a request rejection (bad model,
226
+ // missing access, malformed body): non-retryable, so `InvalidRequest`.
227
+ return detail?.type === "invalid_request_error"
228
+ ? new AiError.InvalidRequest({
229
+ provider: "responses",
230
+ ...(param != null && { param }),
231
+ raw: e,
232
+ })
233
+ : new AiError.GenerationFailed({
234
+ provider: "responses",
235
+ ...(code != null && { code }),
236
+ ...(message != null && { message }),
237
+ raw: e,
238
+ })
239
+ },
186
240
  "response.failed": (e): AiError.AiError => {
187
241
  const code = e.response.error?.code
188
242
  const message = e.response.error?.message
@@ -211,7 +265,7 @@ const sseEventToProviderEvent = (ev: SSE.Event): Effect.Effect<ProviderEvent> =>
211
265
  // Service implementation
212
266
  // ---------------------------------------------------------------------------
213
267
 
214
- const httpStatusError = (status: number, body: string): AiError.AiError => {
268
+ export const httpStatusError = (status: number, body: string): AiError.AiError => {
215
269
  const provider = "responses"
216
270
  const raw = body
217
271
  if (status === 429) return new AiError.RateLimited({ provider, raw })
@@ -224,6 +278,36 @@ const httpStatusError = (status: number, body: string): AiError.AiError => {
224
278
  return new AiError.InvalidRequest({ provider, raw })
225
279
  }
226
280
 
281
+ /**
282
+ * Decode an already-executed `/responses` HTTP response into the native
283
+ * `ProviderEvent` stream: fail on a >=400 status, else SSE-decode the body and
284
+ * lift terminal `error` / `response.failed` events to a typed `AiError`. Shared
285
+ * by the streaming turn path (POST) and the deep-research resume stream (GET
286
+ * `?stream=true`).
287
+ */
288
+ export const providerEventsOfResponse = (
289
+ response: HttpClientResponse.HttpClientResponse,
290
+ ): Stream.Stream<ProviderEvent, AiError.AiError> =>
291
+ Stream.unwrap(
292
+ Effect.gen(function* () {
293
+ if (response.status >= 400) {
294
+ const body = yield* response.text.pipe(Effect.orElseSucceed(() => ""))
295
+ return Stream.fail(httpStatusError(response.status, body))
296
+ }
297
+ return response.stream.pipe(
298
+ Stream.mapError((cause) => new AiError.Unavailable({ provider: "responses", raw: cause })),
299
+ SSE.fromBytes,
300
+ Stream.mapEffect(sseEventToProviderEvent),
301
+ Stream.flatMap((event) =>
302
+ Option.match(eventToError(event), {
303
+ onNone: () => Stream.succeed(event),
304
+ onSome: Stream.fail,
305
+ }),
306
+ ),
307
+ )
308
+ }),
309
+ )
310
+
227
311
  const buildNativeStream = (cfg: Config) => {
228
312
  const url = `${resolveHost(cfg)}/responses`
229
313
  return (
@@ -232,9 +316,23 @@ const buildNativeStream = (cfg: Config) => {
232
316
  Stream.unwrap(
233
317
  Effect.gen(function* () {
234
318
  const client = yield* HttpClient.HttpClient
319
+ const providerToolWire = yield* Result.match(
320
+ renderProviderTools(providerToolsOf(request.tools)),
321
+ {
322
+ onFailure: (e) =>
323
+ Effect.fail(
324
+ new AiError.Unsupported({
325
+ provider: "responses",
326
+ capability: e.capability,
327
+ reason: e.reason,
328
+ }),
329
+ ),
330
+ onSuccess: (wire) => Effect.succeed(wire),
331
+ },
332
+ )
235
333
  const httpRequest = HttpClientRequest.post(url).pipe(
236
334
  HttpClientRequest.bearerToken(cfg.apiKey),
237
- HttpClientRequest.bodyJsonUnsafe(buildBody(request)),
335
+ HttpClientRequest.bodyJsonUnsafe(buildBody(request, providerToolWire, cfg)),
238
336
  HttpClientRequest.accept("text/event-stream"),
239
337
  )
240
338
  const response = yield* client
@@ -244,24 +342,7 @@ const buildNativeStream = (cfg: Config) => {
244
342
  (cause) => new AiError.Unavailable({ provider: "responses", raw: cause }),
245
343
  ),
246
344
  )
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
- )
345
+ return providerEventsOfResponse(response)
265
346
  }),
266
347
  )
267
348
  }
@@ -289,7 +370,7 @@ export const toCanonical = <E, R>(
289
370
  * Build a `ResponsesService` value. For Layer-based setup, prefer `layer`.
290
371
  */
291
372
  export const make = (cfg: Config): Effect.Effect<ResponsesService, never, HttpClient.HttpClient> =>
292
- Effect.map(HttpClient.HttpClient.asEffect(), (client) => {
373
+ Effect.map(HttpClient.HttpClient, (client) => {
293
374
  const streamNative: ResponsesService["streamNative"] = (request) =>
294
375
  buildNativeStream(cfg)(request).pipe(Stream.provideService(HttpClient.HttpClient, client))
295
376
  const streamTurn: ResponsesService["streamTurn"] = (request) =>