@effect-uai/responses 0.1.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/LICENSE +21 -0
- package/README.md +45 -0
- package/dist/chunk-CfYAbeIz.mjs +13 -0
- package/dist/index.d.mts +828 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +525 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +54 -0
- package/src/Responses.ts +316 -0
- package/src/codec.ts +298 -0
- package/src/index.ts +4 -0
- package/src/models.ts +24 -0
- package/src/streamEvents.ts +199 -0
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@effect-uai/responses",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "OpenAI Responses API provider for @effect-uai/core.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"ai",
|
|
7
|
+
"effect",
|
|
8
|
+
"llm",
|
|
9
|
+
"openai",
|
|
10
|
+
"responses-api"
|
|
11
|
+
],
|
|
12
|
+
"homepage": "https://github.com/betalyra/effect-uai/tree/main/packages/providers/responses",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/betalyra/effect-uai/issues"
|
|
15
|
+
},
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"author": "Betalyra",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "https://github.com/betalyra/effect-uai",
|
|
21
|
+
"directory": "packages/providers/responses"
|
|
22
|
+
},
|
|
23
|
+
"type": "module",
|
|
24
|
+
"main": "./dist/index.mjs",
|
|
25
|
+
"types": "./dist/index.d.mts",
|
|
26
|
+
"exports": {
|
|
27
|
+
".": {
|
|
28
|
+
"types": "./dist/index.d.mts",
|
|
29
|
+
"import": "./dist/index.mjs"
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"dist",
|
|
34
|
+
"src",
|
|
35
|
+
"README.md",
|
|
36
|
+
"LICENSE"
|
|
37
|
+
],
|
|
38
|
+
"publishConfig": {
|
|
39
|
+
"access": "public"
|
|
40
|
+
},
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"effect": "4.0.0-beta.57",
|
|
43
|
+
"@effect-uai/core": "^0.1.0"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"effect": "4.0.0-beta.57",
|
|
47
|
+
"typescript": "^6.0.3",
|
|
48
|
+
"@effect-uai/core": "0.1.0"
|
|
49
|
+
},
|
|
50
|
+
"scripts": {
|
|
51
|
+
"build": "tsdown",
|
|
52
|
+
"typecheck": "tsc --noEmit"
|
|
53
|
+
}
|
|
54
|
+
}
|
package/src/Responses.ts
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
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 { matchType } from "@effect-uai/core/Match"
|
|
5
|
+
import * as StructuredFormat from "@effect-uai/core/StructuredFormat"
|
|
6
|
+
import {
|
|
7
|
+
type CommonRequest,
|
|
8
|
+
LanguageModel,
|
|
9
|
+
type LanguageModelService,
|
|
10
|
+
} from "@effect-uai/core/LanguageModel"
|
|
11
|
+
import * as SSE from "@effect-uai/core/SSE"
|
|
12
|
+
import type { TurnEvent } from "@effect-uai/core/Turn"
|
|
13
|
+
import { itemsToInput } from "./codec.js"
|
|
14
|
+
import type { OpenAIModel } from "./models.js"
|
|
15
|
+
import {
|
|
16
|
+
KnownProviderEvent,
|
|
17
|
+
ProviderEvent,
|
|
18
|
+
eventToDeltas,
|
|
19
|
+
makeCallIdLookup,
|
|
20
|
+
} from "./streamEvents.js"
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Public types
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
export interface ResponsesRequest extends Omit<CommonRequest, "model"> {
|
|
27
|
+
/**
|
|
28
|
+
* Narrows `CommonRequest.model` (`string`) to the typed `OpenAIModel`
|
|
29
|
+
* literal union for autocomplete.
|
|
30
|
+
*/
|
|
31
|
+
readonly model: OpenAIModel
|
|
32
|
+
readonly reasoning?: { readonly effort: "low" | "medium" | "high" }
|
|
33
|
+
readonly store?: boolean
|
|
34
|
+
readonly previousResponseId?: string
|
|
35
|
+
readonly instructions?: string
|
|
36
|
+
readonly parallelToolCalls?: boolean
|
|
37
|
+
readonly metadata?: Readonly<Record<string, string>>
|
|
38
|
+
readonly user?: string
|
|
39
|
+
readonly safetyIdentifier?: string
|
|
40
|
+
readonly promptCacheKey?: string
|
|
41
|
+
readonly truncation?: "auto" | "disabled"
|
|
42
|
+
/**
|
|
43
|
+
* Free-form JSON output: model emits valid JSON without schema
|
|
44
|
+
* constraints. Mutually exclusive with `structured` (schema wins if
|
|
45
|
+
* both are set).
|
|
46
|
+
*/
|
|
47
|
+
readonly responseFormat?: { readonly type: "json_object" }
|
|
48
|
+
/**
|
|
49
|
+
* Answer length / level-of-detail hint. GPT-5+ models honour it;
|
|
50
|
+
* earlier models ignore.
|
|
51
|
+
*/
|
|
52
|
+
readonly verbosity?: "low" | "medium" | "high"
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface ResponsesService {
|
|
56
|
+
/**
|
|
57
|
+
* Stream the provider's native event vocabulary (post-SSE-decode).
|
|
58
|
+
* Use this when you need full vendor fidelity. For provider-portable
|
|
59
|
+
* code, use `streamTurn` instead.
|
|
60
|
+
*/
|
|
61
|
+
readonly streamNative: (
|
|
62
|
+
request: ResponsesRequest,
|
|
63
|
+
) => Stream.Stream<ProviderEvent, AiError.AiError>
|
|
64
|
+
/**
|
|
65
|
+
* Stream canonical `TurnEvent`s. Implemented as
|
|
66
|
+
* `streamNative |> toCanonical`.
|
|
67
|
+
*/
|
|
68
|
+
readonly streamTurn: (request: ResponsesRequest) => Stream.Stream<TurnEvent, AiError.AiError>
|
|
69
|
+
/**
|
|
70
|
+
* Project a stream of native `ProviderEvent`s into canonical `TurnEvent`s.
|
|
71
|
+
* Exposed for cases where consumers want to compose with `streamNative`
|
|
72
|
+
* directly (e.g. tap into natives + still get canonical downstream).
|
|
73
|
+
*/
|
|
74
|
+
readonly toCanonical: <E, R>(
|
|
75
|
+
s: Stream.Stream<ProviderEvent, E, R>,
|
|
76
|
+
) => Stream.Stream<TurnEvent, E, R>
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Provider-typed service tag. Yield this when you want Responses-specific
|
|
81
|
+
* options (`reasoning.effort`, `store`, `previousResponseId`) at the call
|
|
82
|
+
* site. Yield the generic `LanguageModel` tag for provider-portable code -
|
|
83
|
+
* both are registered by `layer`.
|
|
84
|
+
*/
|
|
85
|
+
export class Responses extends Context.Service<Responses, ResponsesService>()(
|
|
86
|
+
"@betalyra/effect-uai/providers/responses/Responses",
|
|
87
|
+
) {}
|
|
88
|
+
|
|
89
|
+
export interface Config {
|
|
90
|
+
readonly apiKey: Redacted.Redacted
|
|
91
|
+
readonly baseUrl?: string
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
// Request body
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
const jsonSchemaFormat = (
|
|
99
|
+
format: StructuredFormat.StructuredFormat<unknown>,
|
|
100
|
+
): Record<string, unknown> => ({
|
|
101
|
+
type: "json_schema",
|
|
102
|
+
name: format.name,
|
|
103
|
+
schema: format.schema["~standard"].jsonSchema.input({ target: "draft-2020-12" }),
|
|
104
|
+
...(format.description !== undefined && { description: format.description }),
|
|
105
|
+
...(format.strict !== undefined && { strict: format.strict }),
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
const buildText = (request: ResponsesRequest): Record<string, unknown> | undefined => {
|
|
109
|
+
const format =
|
|
110
|
+
request.structured !== undefined ? jsonSchemaFormat(request.structured) : request.responseFormat
|
|
111
|
+
const text: Record<string, unknown> = {}
|
|
112
|
+
if (format !== undefined) text.format = format
|
|
113
|
+
if (request.verbosity !== undefined) text.verbosity = request.verbosity
|
|
114
|
+
return Object.keys(text).length === 0 ? undefined : text
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const buildBody = (request: ResponsesRequest): Record<string, unknown> => {
|
|
118
|
+
const text = buildText(request)
|
|
119
|
+
return {
|
|
120
|
+
model: request.model,
|
|
121
|
+
input: itemsToInput(request.history),
|
|
122
|
+
stream: true,
|
|
123
|
+
...(request.tools !== undefined &&
|
|
124
|
+
request.tools.length > 0 && {
|
|
125
|
+
tools: request.tools.map((t) => ({
|
|
126
|
+
type: "function",
|
|
127
|
+
name: t.name,
|
|
128
|
+
description: t.description,
|
|
129
|
+
parameters: t.inputSchema,
|
|
130
|
+
...(t.strict !== undefined && { strict: t.strict }),
|
|
131
|
+
})),
|
|
132
|
+
}),
|
|
133
|
+
...(request.toolChoice !== undefined && { tool_choice: request.toolChoice }),
|
|
134
|
+
...(request.temperature !== undefined && { temperature: request.temperature }),
|
|
135
|
+
...(request.maxOutputTokens !== undefined && {
|
|
136
|
+
max_output_tokens: request.maxOutputTokens,
|
|
137
|
+
}),
|
|
138
|
+
...(request.reasoning !== undefined && { reasoning: request.reasoning }),
|
|
139
|
+
...(request.store !== undefined && { store: request.store }),
|
|
140
|
+
...(request.previousResponseId !== undefined && {
|
|
141
|
+
previous_response_id: request.previousResponseId,
|
|
142
|
+
}),
|
|
143
|
+
...(request.instructions !== undefined && { instructions: request.instructions }),
|
|
144
|
+
...(request.topP !== undefined && { top_p: request.topP }),
|
|
145
|
+
...(request.parallelToolCalls !== undefined && {
|
|
146
|
+
parallel_tool_calls: request.parallelToolCalls,
|
|
147
|
+
}),
|
|
148
|
+
...(request.metadata !== undefined && { metadata: request.metadata }),
|
|
149
|
+
...(request.user !== undefined && { user: request.user }),
|
|
150
|
+
...(request.safetyIdentifier !== undefined && {
|
|
151
|
+
safety_identifier: request.safetyIdentifier,
|
|
152
|
+
}),
|
|
153
|
+
...(request.promptCacheKey !== undefined && {
|
|
154
|
+
prompt_cache_key: request.promptCacheKey,
|
|
155
|
+
}),
|
|
156
|
+
...(request.truncation !== undefined && { truncation: request.truncation }),
|
|
157
|
+
...(text !== undefined && { text }),
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
// SSE event → provider event
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
const decodeKnown = Schema.decodeUnknownEffect(KnownProviderEvent)
|
|
166
|
+
|
|
167
|
+
const makeUnknown = (raw: unknown): ProviderEvent => ({ type: "_unknown", raw })
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Lift events that carry a terminal failure signal (`error`,
|
|
171
|
+
* `response.failed`) to a typed `AiError`. Other events are not failures
|
|
172
|
+
* and produce `Option.none`.
|
|
173
|
+
*/
|
|
174
|
+
const eventToError = Match.type<ProviderEvent>().pipe(
|
|
175
|
+
matchType(
|
|
176
|
+
"error",
|
|
177
|
+
(e): AiError.AiError => new AiError.Unavailable({ provider: "responses", raw: e }),
|
|
178
|
+
),
|
|
179
|
+
matchType("response.failed", (e): AiError.AiError => {
|
|
180
|
+
const code = e.response.error?.code
|
|
181
|
+
const message = e.response.error?.message
|
|
182
|
+
return new AiError.GenerationFailed({
|
|
183
|
+
provider: "responses",
|
|
184
|
+
...(code !== undefined && code !== null && { code }),
|
|
185
|
+
...(message !== undefined && message !== null && { message }),
|
|
186
|
+
raw: e,
|
|
187
|
+
})
|
|
188
|
+
}),
|
|
189
|
+
Match.option,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
const sseEventToProviderEvent = (ev: SSE.Event): Effect.Effect<ProviderEvent> =>
|
|
193
|
+
Effect.try({
|
|
194
|
+
try: () => JSON.parse(ev.data) as unknown,
|
|
195
|
+
catch: () => ev.data,
|
|
196
|
+
}).pipe(
|
|
197
|
+
Effect.flatMap((parsed) =>
|
|
198
|
+
decodeKnown(parsed).pipe(Effect.orElseSucceed(() => makeUnknown(parsed))),
|
|
199
|
+
),
|
|
200
|
+
Effect.orElseSucceed(() => makeUnknown(ev.data)),
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
// Service implementation
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
const httpStatusError = (status: number, body: string): AiError.AiError => {
|
|
208
|
+
const provider = "responses"
|
|
209
|
+
const raw = body
|
|
210
|
+
if (status === 429) return new AiError.RateLimited({ provider, raw })
|
|
211
|
+
if (status === 408 || status === 504) return new AiError.Timeout({ provider, raw })
|
|
212
|
+
if (status === 401) return new AiError.AuthFailed({ provider, subtype: "auth", raw })
|
|
213
|
+
if (status === 403) return new AiError.AuthFailed({ provider, subtype: "permission", raw })
|
|
214
|
+
if (status === 402) return new AiError.AuthFailed({ provider, subtype: "billing", raw })
|
|
215
|
+
if (status === 413) return new AiError.ContextLengthExceeded({ provider, raw })
|
|
216
|
+
if (status >= 500) return new AiError.Unavailable({ provider, status, raw })
|
|
217
|
+
return new AiError.InvalidRequest({ provider, raw })
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const buildNativeStream = (cfg: Config) => {
|
|
221
|
+
const url = `${cfg.baseUrl ?? "https://api.openai.com/v1"}/responses`
|
|
222
|
+
return (
|
|
223
|
+
request: ResponsesRequest,
|
|
224
|
+
): Stream.Stream<ProviderEvent, AiError.AiError, HttpClient.HttpClient> =>
|
|
225
|
+
Stream.unwrap(
|
|
226
|
+
Effect.gen(function* () {
|
|
227
|
+
const client = yield* HttpClient.HttpClient
|
|
228
|
+
const httpRequest = HttpClientRequest.post(url).pipe(
|
|
229
|
+
HttpClientRequest.bearerToken(cfg.apiKey),
|
|
230
|
+
HttpClientRequest.bodyJsonUnsafe(buildBody(request)),
|
|
231
|
+
HttpClientRequest.accept("text/event-stream"),
|
|
232
|
+
)
|
|
233
|
+
const response = yield* client
|
|
234
|
+
.execute(httpRequest)
|
|
235
|
+
.pipe(
|
|
236
|
+
Effect.mapError(
|
|
237
|
+
(cause) => new AiError.Unavailable({ provider: "responses", raw: cause }),
|
|
238
|
+
),
|
|
239
|
+
)
|
|
240
|
+
if (response.status >= 400) {
|
|
241
|
+
const body = yield* response.text.pipe(Effect.orElseSucceed(() => ""))
|
|
242
|
+
return Stream.fail(httpStatusError(response.status, body))
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return response.stream.pipe(
|
|
246
|
+
Stream.mapError(
|
|
247
|
+
(cause) => new AiError.Unavailable({ provider: "responses", raw: cause }),
|
|
248
|
+
),
|
|
249
|
+
SSE.fromBytes,
|
|
250
|
+
Stream.mapEffect(sseEventToProviderEvent),
|
|
251
|
+
Stream.flatMap((event) =>
|
|
252
|
+
Option.match(eventToError(event), {
|
|
253
|
+
onNone: () => Stream.succeed(event),
|
|
254
|
+
onSome: Stream.fail,
|
|
255
|
+
}),
|
|
256
|
+
),
|
|
257
|
+
)
|
|
258
|
+
}),
|
|
259
|
+
)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Project a stream of native `ProviderEvent`s into canonical `TurnEvent`s.
|
|
264
|
+
* Threads a fresh `CallIdLookup` per stream so `function_call_arguments.delta`
|
|
265
|
+
* can be tagged with the right `call_id`.
|
|
266
|
+
*/
|
|
267
|
+
export const toCanonical = <E, R>(
|
|
268
|
+
s: Stream.Stream<ProviderEvent, E, R>,
|
|
269
|
+
): Stream.Stream<TurnEvent, E, R> =>
|
|
270
|
+
Stream.unwrap(
|
|
271
|
+
Effect.sync(() => {
|
|
272
|
+
const lookup = makeCallIdLookup()
|
|
273
|
+
return s.pipe(Stream.flatMap((event) => Stream.fromIterable(eventToDeltas(event, lookup))))
|
|
274
|
+
}),
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
// ---------------------------------------------------------------------------
|
|
278
|
+
// Constructors
|
|
279
|
+
// ---------------------------------------------------------------------------
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Build a `ResponsesService` value. For Layer-based setup, prefer `layer`.
|
|
283
|
+
*/
|
|
284
|
+
export const make = (cfg: Config): Effect.Effect<ResponsesService, never, HttpClient.HttpClient> =>
|
|
285
|
+
Effect.map(HttpClient.HttpClient.asEffect(), (client) => {
|
|
286
|
+
const streamNative: ResponsesService["streamNative"] = (request) =>
|
|
287
|
+
buildNativeStream(cfg)(request).pipe(Stream.provideService(HttpClient.HttpClient, client))
|
|
288
|
+
return {
|
|
289
|
+
streamNative,
|
|
290
|
+
streamTurn: (request) => toCanonical(streamNative(request)),
|
|
291
|
+
toCanonical,
|
|
292
|
+
}
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Layer that registers both the provider-specific `Responses` tag and the
|
|
297
|
+
* generic `LanguageModel` tag, sharing one underlying implementation.
|
|
298
|
+
*
|
|
299
|
+
* The generic tag accepts `CommonRequest`; the typed tag accepts the full
|
|
300
|
+
* `ResponsesRequest` surface.
|
|
301
|
+
*/
|
|
302
|
+
export const layer = (
|
|
303
|
+
cfg: Config,
|
|
304
|
+
): Layer.Layer<Responses | LanguageModel, never, HttpClient.HttpClient> => {
|
|
305
|
+
const typed = Layer.effect(Responses, make(cfg))
|
|
306
|
+
const generic = Layer.effect(
|
|
307
|
+
LanguageModel,
|
|
308
|
+
Effect.map(
|
|
309
|
+
make(cfg),
|
|
310
|
+
(s): LanguageModelService => ({
|
|
311
|
+
streamTurn: (request) => s.streamTurn(request as ResponsesRequest),
|
|
312
|
+
}),
|
|
313
|
+
),
|
|
314
|
+
)
|
|
315
|
+
return Layer.merge(typed, generic)
|
|
316
|
+
}
|
package/src/codec.ts
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { Match, Option, Schema } from "effect"
|
|
2
|
+
import type { ContentBlock, Item } from "@effect-uai/core/Items"
|
|
3
|
+
import { matchType } from "@effect-uai/core/Match"
|
|
4
|
+
import type { Turn } from "@effect-uai/core/Turn"
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Wire schemas - minimal subset of the Responses API output we consume.
|
|
8
|
+
// Reference: https://www.openresponses.org/specification
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
const WireUrlCitation = Schema.Struct({
|
|
12
|
+
type: Schema.Literal("url_citation"),
|
|
13
|
+
url: Schema.String,
|
|
14
|
+
start_index: Schema.Number,
|
|
15
|
+
end_index: Schema.Number,
|
|
16
|
+
title: Schema.String,
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
const WireFileCitation = Schema.Struct({
|
|
20
|
+
type: Schema.Literal("file_citation"),
|
|
21
|
+
file_id: Schema.String,
|
|
22
|
+
index: Schema.Number,
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
const WireContainerFileCitation = Schema.Struct({
|
|
26
|
+
type: Schema.Literal("container_file_citation"),
|
|
27
|
+
container_id: Schema.String,
|
|
28
|
+
file_id: Schema.String,
|
|
29
|
+
start_index: Schema.Number,
|
|
30
|
+
end_index: Schema.Number,
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
const WireFilePath = Schema.Struct({
|
|
34
|
+
type: Schema.Literal("file_path"),
|
|
35
|
+
file_id: Schema.String,
|
|
36
|
+
index: Schema.Number,
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
const WireAnnotation = Schema.Union([
|
|
40
|
+
WireUrlCitation,
|
|
41
|
+
WireFileCitation,
|
|
42
|
+
WireContainerFileCitation,
|
|
43
|
+
WireFilePath,
|
|
44
|
+
])
|
|
45
|
+
|
|
46
|
+
const WireOutputTextContent = Schema.Struct({
|
|
47
|
+
type: Schema.Literal("output_text"),
|
|
48
|
+
text: Schema.String,
|
|
49
|
+
annotations: Schema.optional(Schema.NullOr(Schema.Array(WireAnnotation))),
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
const WireRefusalContent = Schema.Struct({
|
|
53
|
+
type: Schema.Literal("refusal"),
|
|
54
|
+
refusal: Schema.String,
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
const WireMessageContent = Schema.Union([WireOutputTextContent, WireRefusalContent])
|
|
58
|
+
|
|
59
|
+
const WireSummaryText = Schema.Struct({
|
|
60
|
+
type: Schema.Literal("summary_text"),
|
|
61
|
+
text: Schema.String,
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
const WireMessage = Schema.Struct({
|
|
65
|
+
type: Schema.Literal("message"),
|
|
66
|
+
id: Schema.optional(Schema.String),
|
|
67
|
+
role: Schema.Literal("assistant"),
|
|
68
|
+
content: Schema.Array(WireMessageContent),
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
const WireFunctionCall = Schema.Struct({
|
|
72
|
+
type: Schema.Literal("function_call"),
|
|
73
|
+
id: Schema.optional(Schema.String),
|
|
74
|
+
call_id: Schema.String,
|
|
75
|
+
name: Schema.String,
|
|
76
|
+
arguments: Schema.String,
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
const WireReasoning = Schema.Struct({
|
|
80
|
+
type: Schema.Literal("reasoning"),
|
|
81
|
+
id: Schema.optional(Schema.String),
|
|
82
|
+
summary: Schema.optional(Schema.Array(WireSummaryText)),
|
|
83
|
+
encrypted_content: Schema.optional(Schema.String),
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
export const WireOutputItem = Schema.Union([WireMessage, WireFunctionCall, WireReasoning])
|
|
87
|
+
export type WireOutputItem = typeof WireOutputItem.Type
|
|
88
|
+
|
|
89
|
+
const WireInputTokensDetails = Schema.Struct({
|
|
90
|
+
cached_tokens: Schema.optional(Schema.NullOr(Schema.Number)),
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
const WireOutputTokensDetails = Schema.Struct({
|
|
94
|
+
reasoning_tokens: Schema.optional(Schema.NullOr(Schema.Number)),
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
const WireUsage = Schema.Struct({
|
|
98
|
+
input_tokens: Schema.optional(Schema.Number),
|
|
99
|
+
output_tokens: Schema.optional(Schema.Number),
|
|
100
|
+
total_tokens: Schema.optional(Schema.Number),
|
|
101
|
+
input_tokens_details: Schema.optional(Schema.NullOr(WireInputTokensDetails)),
|
|
102
|
+
output_tokens_details: Schema.optional(Schema.NullOr(WireOutputTokensDetails)),
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
const WireResponseError = Schema.Struct({
|
|
106
|
+
code: Schema.optional(Schema.NullOr(Schema.String)),
|
|
107
|
+
message: Schema.optional(Schema.NullOr(Schema.String)),
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
// Many Responses-API fields are emitted as explicit `null` rather than
|
|
111
|
+
// missing - `Schema.optional` alone (T | undefined) doesn't cover that, so
|
|
112
|
+
// we lift each through `NullOr` first. Used by `response.completed`,
|
|
113
|
+
// `response.incomplete`, and `response.failed` events; not every field is
|
|
114
|
+
// populated on every event (e.g. `error` only on failed, `incomplete_details`
|
|
115
|
+
// only on incomplete).
|
|
116
|
+
export const WireResponseCompleted = Schema.Struct({
|
|
117
|
+
id: Schema.optional(Schema.NullOr(Schema.String)),
|
|
118
|
+
status: Schema.optional(Schema.NullOr(Schema.String)),
|
|
119
|
+
output: Schema.optional(Schema.NullOr(Schema.Array(WireOutputItem))),
|
|
120
|
+
usage: Schema.optional(Schema.NullOr(WireUsage)),
|
|
121
|
+
incomplete_details: Schema.optional(
|
|
122
|
+
Schema.NullOr(
|
|
123
|
+
Schema.Struct({
|
|
124
|
+
reason: Schema.optional(Schema.NullOr(Schema.String)),
|
|
125
|
+
}),
|
|
126
|
+
),
|
|
127
|
+
),
|
|
128
|
+
error: Schema.optional(Schema.NullOr(WireResponseError)),
|
|
129
|
+
})
|
|
130
|
+
export type WireResponseCompleted = typeof WireResponseCompleted.Type
|
|
131
|
+
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
// History → request body input
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* If an item carries `providerData` from a prior OpenAI turn, re-emit it
|
|
138
|
+
* verbatim - preserves `encrypted_content`, item ids, and any field this
|
|
139
|
+
* codec doesn't model.
|
|
140
|
+
*/
|
|
141
|
+
const passthrough = (item: Item): Record<string, unknown> | undefined =>
|
|
142
|
+
item.providerData !== undefined &&
|
|
143
|
+
typeof item.providerData === "object" &&
|
|
144
|
+
item.providerData !== null
|
|
145
|
+
? (item.providerData as Record<string, unknown>)
|
|
146
|
+
: undefined
|
|
147
|
+
|
|
148
|
+
const contentBlockToInput = Match.type<ContentBlock>().pipe(
|
|
149
|
+
matchType("input_text", (b) => ({ type: "input_text", text: b.text })),
|
|
150
|
+
matchType("input_image", (b) => ({
|
|
151
|
+
type: "input_image",
|
|
152
|
+
image_url:
|
|
153
|
+
b.source._tag === "url"
|
|
154
|
+
? b.source.url
|
|
155
|
+
: `data:${b.source.media_type};base64,${b.source.data}`,
|
|
156
|
+
})),
|
|
157
|
+
matchType("output_text", (b) => ({ type: "output_text", text: b.text })),
|
|
158
|
+
matchType("refusal", (b) => ({ type: "refusal", refusal: b.text })),
|
|
159
|
+
Match.exhaustive,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
const itemToInput = (item: Item): Record<string, unknown> =>
|
|
163
|
+
passthrough(item) ??
|
|
164
|
+
Match.value(item).pipe(
|
|
165
|
+
matchType("message", (m) => ({
|
|
166
|
+
type: "message",
|
|
167
|
+
role: m.role,
|
|
168
|
+
content: m.content.map(contentBlockToInput),
|
|
169
|
+
})),
|
|
170
|
+
matchType("function_call", (f) => ({
|
|
171
|
+
type: "function_call",
|
|
172
|
+
call_id: f.call_id,
|
|
173
|
+
name: f.name,
|
|
174
|
+
arguments: f.arguments,
|
|
175
|
+
})),
|
|
176
|
+
matchType("function_call_output", (o) => ({
|
|
177
|
+
type: "function_call_output",
|
|
178
|
+
call_id: o.call_id,
|
|
179
|
+
output: o.output,
|
|
180
|
+
})),
|
|
181
|
+
matchType("reasoning", (r) => ({
|
|
182
|
+
type: "reasoning",
|
|
183
|
+
...(r.id !== undefined && { id: r.id }),
|
|
184
|
+
...(r.summary !== undefined && {
|
|
185
|
+
summary: [{ type: "summary_text", text: r.summary }],
|
|
186
|
+
}),
|
|
187
|
+
...(r.signature !== undefined && { encrypted_content: r.signature }),
|
|
188
|
+
})),
|
|
189
|
+
Match.exhaustive,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
/** Convert our `Item[]` history into the Responses API `input` array. */
|
|
193
|
+
export const itemsToInput = (items: ReadonlyArray<Item>): ReadonlyArray<Record<string, unknown>> =>
|
|
194
|
+
items.map(itemToInput)
|
|
195
|
+
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
// Wire output items → our Items
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
const wireMessageContentToBlock = Match.type<typeof WireMessageContent.Type>().pipe(
|
|
201
|
+
matchType(
|
|
202
|
+
"output_text",
|
|
203
|
+
(c): ContentBlock => ({
|
|
204
|
+
type: "output_text",
|
|
205
|
+
text: c.text,
|
|
206
|
+
...(c.annotations !== undefined && c.annotations !== null && { annotations: c.annotations }),
|
|
207
|
+
}),
|
|
208
|
+
),
|
|
209
|
+
matchType("refusal", (c): ContentBlock => ({ type: "refusal", text: c.refusal })),
|
|
210
|
+
Match.exhaustive,
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
export const wireItemToItem = (wire: WireOutputItem): Item =>
|
|
214
|
+
Match.value(wire).pipe(
|
|
215
|
+
matchType("message", (m) => ({
|
|
216
|
+
type: "message" as const,
|
|
217
|
+
role: m.role,
|
|
218
|
+
content: m.content.map(wireMessageContentToBlock),
|
|
219
|
+
providerData: m,
|
|
220
|
+
})),
|
|
221
|
+
matchType("function_call", (f) => ({
|
|
222
|
+
type: "function_call" as const,
|
|
223
|
+
call_id: f.call_id,
|
|
224
|
+
name: f.name,
|
|
225
|
+
arguments: f.arguments,
|
|
226
|
+
providerData: f,
|
|
227
|
+
})),
|
|
228
|
+
matchType("reasoning", (r) => ({
|
|
229
|
+
type: "reasoning" as const,
|
|
230
|
+
...(r.id !== undefined && { id: r.id }),
|
|
231
|
+
...(r.summary !== undefined && {
|
|
232
|
+
summary: r.summary.map((s) => s.text).join("\n"),
|
|
233
|
+
}),
|
|
234
|
+
...(r.encrypted_content !== undefined && { signature: r.encrypted_content }),
|
|
235
|
+
providerData: r,
|
|
236
|
+
})),
|
|
237
|
+
Match.exhaustive,
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
// response.completed → Turn
|
|
242
|
+
// ---------------------------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
const reasonToStop = Match.type<string>().pipe(
|
|
245
|
+
Match.when("max_output_tokens", () => "max_tokens" as const),
|
|
246
|
+
Match.when("refusal", () => "refusal" as const),
|
|
247
|
+
Match.when("content_filter", () => "content_filter" as const),
|
|
248
|
+
Match.when("max_tool_calls", () => "max_tool_calls" as const),
|
|
249
|
+
Match.option,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
const hasRefusalContent = (payload: WireResponseCompleted): boolean =>
|
|
253
|
+
(payload.output ?? []).some(
|
|
254
|
+
(i) => i.type === "message" && i.content.some((c) => c.type === "refusal"),
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
const stopReasonFromCompleted = (payload: WireResponseCompleted): Turn["stop_reason"] =>
|
|
258
|
+
Option.match(reasonToStop(payload.incomplete_details?.reason ?? ""), {
|
|
259
|
+
onSome: (s) => s,
|
|
260
|
+
onNone: () =>
|
|
261
|
+
hasRefusalContent(payload)
|
|
262
|
+
? ("refusal" as const)
|
|
263
|
+
: (payload.output ?? []).some((i) => i.type === "function_call")
|
|
264
|
+
? ("tool_calls" as const)
|
|
265
|
+
: ("stop" as const),
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
const cachedTokens = (payload: WireResponseCompleted): number | undefined =>
|
|
269
|
+
payload.usage?.input_tokens_details?.cached_tokens ?? undefined
|
|
270
|
+
|
|
271
|
+
const reasoningTokens = (payload: WireResponseCompleted): number | undefined =>
|
|
272
|
+
payload.usage?.output_tokens_details?.reasoning_tokens ?? undefined
|
|
273
|
+
|
|
274
|
+
export const turnFromCompleted = (payload: WireResponseCompleted): Turn => {
|
|
275
|
+
const cached = cachedTokens(payload)
|
|
276
|
+
const reasoning = reasoningTokens(payload)
|
|
277
|
+
return {
|
|
278
|
+
items: (payload.output ?? []).map(wireItemToItem),
|
|
279
|
+
usage: {
|
|
280
|
+
...(payload.usage?.input_tokens !== undefined && {
|
|
281
|
+
input_tokens: payload.usage.input_tokens,
|
|
282
|
+
}),
|
|
283
|
+
...(payload.usage?.output_tokens !== undefined && {
|
|
284
|
+
output_tokens: payload.usage.output_tokens,
|
|
285
|
+
}),
|
|
286
|
+
...(payload.usage?.total_tokens !== undefined && {
|
|
287
|
+
total_tokens: payload.usage.total_tokens,
|
|
288
|
+
}),
|
|
289
|
+
...(cached !== undefined && {
|
|
290
|
+
input_tokens_details: { cached_tokens: cached },
|
|
291
|
+
}),
|
|
292
|
+
...(reasoning !== undefined && {
|
|
293
|
+
output_tokens_details: { reasoning_tokens: reasoning },
|
|
294
|
+
}),
|
|
295
|
+
},
|
|
296
|
+
stop_reason: stopReasonFromCompleted(payload),
|
|
297
|
+
}
|
|
298
|
+
}
|
package/src/index.ts
ADDED
package/src/models.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Known OpenAI model identifiers usable via the Responses API (as of
|
|
3
|
+
* April 2026). The `(string & {})` tail keeps autocomplete on the literals
|
|
4
|
+
* while still accepting any string, so newly-released models work without
|
|
5
|
+
* an SDK update.
|
|
6
|
+
*
|
|
7
|
+
* Reference: https://developers.openai.com/api/docs/models/all
|
|
8
|
+
*/
|
|
9
|
+
export type OpenAIModel =
|
|
10
|
+
| "gpt-5.5"
|
|
11
|
+
| "gpt-5.5-pro"
|
|
12
|
+
| "gpt-5.4"
|
|
13
|
+
| "gpt-5.4-pro"
|
|
14
|
+
| "gpt-5.4-mini"
|
|
15
|
+
| "gpt-5.4-nano"
|
|
16
|
+
| "gpt-5"
|
|
17
|
+
| "gpt-5-mini"
|
|
18
|
+
| "gpt-5-nano"
|
|
19
|
+
| "gpt-5.3-codex"
|
|
20
|
+
| "gpt-4.1"
|
|
21
|
+
| "gpt-4.1-mini"
|
|
22
|
+
| "gpt-4o-mini"
|
|
23
|
+
// eslint-disable-next-line @typescript-eslint/ban-types
|
|
24
|
+
| (string & {})
|