@effect-uai/responses 0.10.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.
- 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 +13 -3
- package/dist/Responses.d.mts.map +1 -1
- package/dist/Responses.mjs +68 -33
- 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 +28 -9
- package/dist/codec.mjs.map +1 -1
- 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 +8 -4
- package/src/OpenAIDeepResearch.ts +266 -0
- package/src/OpenAIEmbedding.ts +1 -1
- package/src/Responses.ts +94 -36
- package/src/ResponsesTools.ts +158 -0
- package/src/codec.ts +62 -26
- 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.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.
|
|
54
|
+
"effect": "4.0.0-beta.94",
|
|
51
55
|
"typescript": "^6.0.3",
|
|
52
|
-
"@effect-uai/core": "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.
|
|
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
|
+
}
|
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 {
|
|
@@ -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 = (
|
|
136
|
+
const buildBody = (
|
|
137
|
+
request: ResponsesRequest,
|
|
138
|
+
providerToolWire: ReadonlyArray<Record<string, unknown>>,
|
|
139
|
+
): Record<string, unknown> => {
|
|
127
140
|
const text = buildText(request)
|
|
128
|
-
const
|
|
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 =>
|
|
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
|
-
|
|
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
|
|
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))
|