@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.
- package/dist/OpenAIDeepResearch.d.mts +54 -0
- package/dist/OpenAIDeepResearch.d.mts.map +1 -0
- package/dist/OpenAIDeepResearch.mjs +121 -0
- package/dist/OpenAIDeepResearch.mjs.map +1 -0
- package/dist/OpenAIEmbedding.mjs +1 -1
- package/dist/OpenAIEmbedding.mjs.map +1 -1
- package/dist/Responses.d.mts +33 -3
- package/dist/Responses.d.mts.map +1 -1
- package/dist/Responses.mjs +71 -34
- package/dist/Responses.mjs.map +1 -1
- package/dist/ResponsesTools.d.mts +85 -0
- package/dist/ResponsesTools.d.mts.map +1 -0
- package/dist/ResponsesTools.mjs +126 -0
- package/dist/ResponsesTools.mjs.map +1 -0
- package/dist/codec.d.mts +42 -3
- package/dist/codec.d.mts.map +1 -1
- package/dist/codec.mjs +58 -17
- package/dist/codec.mjs.map +1 -1
- package/dist/codec.test.d.mts +1 -0
- package/dist/codec.test.mjs +93 -0
- package/dist/codec.test.mjs.map +1 -0
- package/dist/index.d.mts +5 -3
- package/dist/index.mjs +5 -3
- package/dist/{magic-string.es-PAZgLX4h.mjs → magic-string.es-CaWgF-3p.mjs} +1 -1
- package/dist/{magic-string.es-PAZgLX4h.mjs.map → magic-string.es-CaWgF-3p.mjs.map} +1 -1
- package/dist/models.d.mts +10 -1
- package/dist/models.d.mts.map +1 -1
- package/dist/region.test.mjs +1 -13630
- package/dist/region.test.mjs.map +1 -1
- package/dist/responsesTools.test.d.mts +1 -0
- package/dist/responsesTools.test.mjs +138 -0
- package/dist/responsesTools.test.mjs.map +1 -0
- package/dist/streamEvents.d.mts +142 -0
- package/dist/streamEvents.d.mts.map +1 -1
- package/dist/streamEvents.mjs +27 -2
- package/dist/streamEvents.mjs.map +1 -1
- package/dist/test.DNmyFkvJ-CPKukVWS.mjs +13633 -0
- package/dist/test.DNmyFkvJ-CPKukVWS.mjs.map +1 -0
- package/package.json +12 -4
- package/src/OpenAIDeepResearch.ts +266 -0
- package/src/OpenAIEmbedding.ts +1 -1
- package/src/Responses.ts +117 -36
- package/src/ResponsesTools.ts +158 -0
- package/src/codec.test.ts +87 -0
- package/src/codec.ts +92 -36
- package/src/index.ts +2 -0
- package/src/models.ts +14 -0
- package/src/responsesTools.test.ts +146 -0
- 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.
|
|
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.
|
|
58
|
+
"effect": "4.0.0-beta.94",
|
|
51
59
|
"typescript": "^6.0.3",
|
|
52
|
-
"@effect-uai/core": "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.
|
|
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
|
+
}
|
package/src/OpenAIEmbedding.ts
CHANGED
|
@@ -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
|
|
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 = (
|
|
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
|
|
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 =>
|
|
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
|
-
|
|
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
|
|
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) =>
|