@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
|
@@ -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))
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { HistoryItem } from "@effect-uai/core/Items"
|
|
2
|
+
import { describe, expect, it } from "vitest"
|
|
3
|
+
import { itemsToInput, wireItemToItem } from "./codec.js"
|
|
4
|
+
|
|
5
|
+
describe("providerData round-trip", () => {
|
|
6
|
+
it("re-emits a stashed item verbatim, preserving fields our shape drops", () => {
|
|
7
|
+
// `id` and `encrypted_content` cannot be reconstructed from a Reasoning
|
|
8
|
+
// item, so the round-trip has to go through the stash.
|
|
9
|
+
const items = wireItemToItem({
|
|
10
|
+
type: "reasoning",
|
|
11
|
+
id: "rs_1",
|
|
12
|
+
encrypted_content: "opaque-state",
|
|
13
|
+
})
|
|
14
|
+
expect(itemsToInput(items)).toEqual([
|
|
15
|
+
{ type: "reasoning", id: "rs_1", encrypted_content: "opaque-state" },
|
|
16
|
+
])
|
|
17
|
+
})
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
describe("providerData is a shared slot", () => {
|
|
21
|
+
// Regression: the passthrough used to re-emit the whole `providerData`
|
|
22
|
+
// object as the wire item. An item that had been through another provider
|
|
23
|
+
// first was sent as that provider's data, losing the real content.
|
|
24
|
+
it("ignores another provider's data and encodes the item normally", () => {
|
|
25
|
+
const history: ReadonlyArray<HistoryItem> = [
|
|
26
|
+
{
|
|
27
|
+
type: "message",
|
|
28
|
+
role: "user",
|
|
29
|
+
content: [{ type: "input_text", text: "hello world" }],
|
|
30
|
+
providerData: { gemini: { thoughtSignature: "sig-abc" } },
|
|
31
|
+
},
|
|
32
|
+
]
|
|
33
|
+
expect(itemsToInput(history)).toEqual([
|
|
34
|
+
{ type: "message", role: "user", content: [{ type: "input_text", text: "hello world" }] },
|
|
35
|
+
])
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it("does not send a foreign function_call as junk", () => {
|
|
39
|
+
const history: ReadonlyArray<HistoryItem> = [
|
|
40
|
+
{
|
|
41
|
+
type: "function_call",
|
|
42
|
+
call_id: "c1",
|
|
43
|
+
name: "get_weather",
|
|
44
|
+
arguments: '{"city":"Lisbon"}',
|
|
45
|
+
providerData: { gemini: { id: "g1", thoughtSignature: "sig-abc" } },
|
|
46
|
+
},
|
|
47
|
+
]
|
|
48
|
+
expect(itemsToInput(history)).toEqual([
|
|
49
|
+
{
|
|
50
|
+
type: "function_call",
|
|
51
|
+
call_id: "c1",
|
|
52
|
+
name: "get_weather",
|
|
53
|
+
arguments: '{"city":"Lisbon"}',
|
|
54
|
+
},
|
|
55
|
+
])
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it("re-emits only our key when an item carries several providers' data", () => {
|
|
59
|
+
const history: ReadonlyArray<HistoryItem> = [
|
|
60
|
+
{
|
|
61
|
+
type: "reasoning",
|
|
62
|
+
signature: "opaque-state",
|
|
63
|
+
providerData: {
|
|
64
|
+
responses: { type: "reasoning", id: "rs_1", encrypted_content: "opaque-state" },
|
|
65
|
+
gemini: { thoughtSignature: "sig-abc" },
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
]
|
|
69
|
+
expect(itemsToInput(history)).toEqual([
|
|
70
|
+
{ type: "reasoning", id: "rs_1", encrypted_content: "opaque-state" },
|
|
71
|
+
])
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it("falls back to a normal encode when the stashed value is not one of ours", () => {
|
|
75
|
+
const history: ReadonlyArray<HistoryItem> = [
|
|
76
|
+
{
|
|
77
|
+
type: "message",
|
|
78
|
+
role: "user",
|
|
79
|
+
content: [{ type: "input_text", text: "hi" }],
|
|
80
|
+
providerData: { responses: { type: "not_an_item_we_model" } },
|
|
81
|
+
},
|
|
82
|
+
]
|
|
83
|
+
expect(itemsToInput(history)).toEqual([
|
|
84
|
+
{ type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] },
|
|
85
|
+
])
|
|
86
|
+
})
|
|
87
|
+
})
|
package/src/codec.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Encoding, Match, Option, Schema } from "effect"
|
|
1
|
+
import { Encoding, Match, Option, Result, Schema, pipe } from "effect"
|
|
2
2
|
import type { ContentBlock, InputImage, HistoryItem } from "@effect-uai/core/Items"
|
|
3
3
|
import type { Turn } from "@effect-uai/core/Turn"
|
|
4
4
|
|
|
@@ -35,7 +35,7 @@ const WireFilePath = Schema.Struct({
|
|
|
35
35
|
index: Schema.Number,
|
|
36
36
|
})
|
|
37
37
|
|
|
38
|
-
const WireAnnotation = Schema.Union([
|
|
38
|
+
export const WireAnnotation = Schema.Union([
|
|
39
39
|
WireUrlCitation,
|
|
40
40
|
WireFileCitation,
|
|
41
41
|
WireContainerFileCitation,
|
|
@@ -82,7 +82,33 @@ const WireReasoning = Schema.Struct({
|
|
|
82
82
|
encrypted_content: Schema.optional(Schema.String),
|
|
83
83
|
})
|
|
84
84
|
|
|
85
|
-
|
|
85
|
+
// Provider-hosted tool call items (web search, code interpreter, file search).
|
|
86
|
+
// The provider executes them, not us — we model them only so a grounded
|
|
87
|
+
// `response.completed` still decodes and assembles a Turn; they are dropped
|
|
88
|
+
// from the turn's items in Phase 1. Surfacing them is a follow-up.
|
|
89
|
+
const WireWebSearchCall = Schema.Struct({
|
|
90
|
+
type: Schema.Literal("web_search_call"),
|
|
91
|
+
id: Schema.optional(Schema.String),
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
const WireCodeInterpreterCall = Schema.Struct({
|
|
95
|
+
type: Schema.Literal("code_interpreter_call"),
|
|
96
|
+
id: Schema.optional(Schema.String),
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
const WireFileSearchCall = Schema.Struct({
|
|
100
|
+
type: Schema.Literal("file_search_call"),
|
|
101
|
+
id: Schema.optional(Schema.String),
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
export const WireOutputItem = Schema.Union([
|
|
105
|
+
WireMessage,
|
|
106
|
+
WireFunctionCall,
|
|
107
|
+
WireReasoning,
|
|
108
|
+
WireWebSearchCall,
|
|
109
|
+
WireCodeInterpreterCall,
|
|
110
|
+
WireFileSearchCall,
|
|
111
|
+
])
|
|
86
112
|
export type WireOutputItem = typeof WireOutputItem.Type
|
|
87
113
|
|
|
88
114
|
const WireInputTokensDetails = Schema.Struct({
|
|
@@ -133,16 +159,36 @@ export type WireResponseCompleted = typeof WireResponseCompleted.Type
|
|
|
133
159
|
// ---------------------------------------------------------------------------
|
|
134
160
|
|
|
135
161
|
/**
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
162
|
+
* The item shapes we stash for round-tripping. Narrower than
|
|
163
|
+
* `WireOutputItem`: the provider-hosted call variants are dropped from the
|
|
164
|
+
* turn, so they are never stored. Composed from the internal `Wire*` structs,
|
|
165
|
+
* which keeps the wire model out of the package's public surface.
|
|
139
166
|
*/
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
167
|
+
const StoredItem = Schema.Union([WireMessage, WireFunctionCall, WireReasoning])
|
|
168
|
+
type StoredItem = typeof StoredItem.Type
|
|
169
|
+
|
|
170
|
+
const ProviderDataResponses = Schema.Struct({ responses: StoredItem })
|
|
171
|
+
const decodeResponsesData = Schema.decodeUnknownResult(ProviderDataResponses)
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* If an item carries `providerData.responses` from a prior turn on this API,
|
|
175
|
+
* re-emit it verbatim - preserves `encrypted_content` and item ids, which a
|
|
176
|
+
* fresh encode from our `HistoryItem` shape cannot reconstruct.
|
|
177
|
+
*
|
|
178
|
+
* Namespaced because `providerData` is a shared slot: an item routed through
|
|
179
|
+
* another provider first (dynamic fallback) carries that provider's key, and
|
|
180
|
+
* re-emitting the whole object would send that provider's data as the wire
|
|
181
|
+
* item and drop the real content. Anything that fails to decode is not ours,
|
|
182
|
+
* so we fall back to encoding the item normally.
|
|
183
|
+
*/
|
|
184
|
+
const passthrough = (item: HistoryItem): StoredItem | undefined =>
|
|
185
|
+
pipe(
|
|
186
|
+
decodeResponsesData(item.providerData),
|
|
187
|
+
Result.match({
|
|
188
|
+
onSuccess: (data) => data.responses,
|
|
189
|
+
onFailure: (): StoredItem | undefined => undefined,
|
|
190
|
+
}),
|
|
191
|
+
)
|
|
146
192
|
|
|
147
193
|
/**
|
|
148
194
|
* OpenAI's `input_image` content block carries a single `image_url` field;
|
|
@@ -219,31 +265,41 @@ const wireMessageContentToBlock = Match.type<typeof WireMessageContent.Type>().p
|
|
|
219
265
|
}),
|
|
220
266
|
)
|
|
221
267
|
|
|
222
|
-
export const wireItemToItem = (wire: WireOutputItem): HistoryItem =>
|
|
268
|
+
export const wireItemToItem = (wire: WireOutputItem): ReadonlyArray<HistoryItem> =>
|
|
223
269
|
Match.value(wire).pipe(
|
|
224
270
|
Match.discriminatorsExhaustive("type")({
|
|
225
|
-
message: (m) =>
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
271
|
+
message: (m): ReadonlyArray<HistoryItem> => [
|
|
272
|
+
{
|
|
273
|
+
type: "message" as const,
|
|
274
|
+
role: m.role,
|
|
275
|
+
content: m.content.map(wireMessageContentToBlock),
|
|
276
|
+
providerData: { responses: m },
|
|
277
|
+
},
|
|
278
|
+
],
|
|
279
|
+
function_call: (f): ReadonlyArray<HistoryItem> => [
|
|
280
|
+
{
|
|
281
|
+
type: "function_call" as const,
|
|
282
|
+
call_id: f.call_id,
|
|
283
|
+
name: f.name,
|
|
284
|
+
arguments: f.arguments,
|
|
285
|
+
providerData: { responses: f },
|
|
286
|
+
},
|
|
287
|
+
],
|
|
288
|
+
reasoning: (r): ReadonlyArray<HistoryItem> => [
|
|
289
|
+
{
|
|
290
|
+
type: "reasoning" as const,
|
|
291
|
+
...(r.id !== undefined && { id: r.id }),
|
|
292
|
+
...(r.summary !== undefined && {
|
|
293
|
+
summary: r.summary.map((s) => s.text).join("\n"),
|
|
294
|
+
}),
|
|
295
|
+
...(r.encrypted_content !== undefined && { signature: r.encrypted_content }),
|
|
296
|
+
providerData: { responses: r },
|
|
297
|
+
},
|
|
298
|
+
],
|
|
299
|
+
// Hosted-tool calls are provider-executed; drop from the turn (Phase 1).
|
|
300
|
+
web_search_call: (): ReadonlyArray<HistoryItem> => [],
|
|
301
|
+
code_interpreter_call: (): ReadonlyArray<HistoryItem> => [],
|
|
302
|
+
file_search_call: (): ReadonlyArray<HistoryItem> => [],
|
|
247
303
|
}),
|
|
248
304
|
)
|
|
249
305
|
|
|
@@ -285,7 +341,7 @@ export const turnFromCompleted = (payload: WireResponseCompleted): Turn => {
|
|
|
285
341
|
const cached = cachedTokens(payload)
|
|
286
342
|
const reasoning = reasoningTokens(payload)
|
|
287
343
|
return {
|
|
288
|
-
items: (payload.output ?? []).
|
|
344
|
+
items: (payload.output ?? []).flatMap(wireItemToItem),
|
|
289
345
|
usage: {
|
|
290
346
|
...(payload.usage?.input_tokens !== undefined && {
|
|
291
347
|
input_tokens: payload.usage.input_tokens,
|
package/src/index.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
export * as Responses from "./Responses.js"
|
|
2
|
+
export * as ResponsesTools from "./ResponsesTools.js"
|
|
3
|
+
export * as OpenAIDeepResearch from "./OpenAIDeepResearch.js"
|
|
2
4
|
export * as OpenAIEmbedding from "./OpenAIEmbedding.js"
|
|
3
5
|
export * as codec from "./codec.js"
|
|
4
6
|
export * as streamEvents from "./streamEvents.js"
|
package/src/models.ts
CHANGED
|
@@ -23,6 +23,20 @@ export type OpenAIModel =
|
|
|
23
23
|
// eslint-disable-next-line @typescript-eslint/ban-types
|
|
24
24
|
| (string & {})
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* OpenAI deep-research model / agent ids, usable via the Responses API with
|
|
28
|
+
* `background: true`. `o3-deep-research` is the comprehensive tier;
|
|
29
|
+
* `o4-mini-deep-research` is faster and cheaper. The `(string & {})` tail keeps
|
|
30
|
+
* autocomplete while accepting newer ids without an SDK update.
|
|
31
|
+
*
|
|
32
|
+
* Reference: https://developers.openai.com/api/docs/guides/deep-research
|
|
33
|
+
*/
|
|
34
|
+
export type OpenAIDeepResearchModel =
|
|
35
|
+
| "o3-deep-research"
|
|
36
|
+
| "o4-mini-deep-research"
|
|
37
|
+
// eslint-disable-next-line @typescript-eslint/ban-types
|
|
38
|
+
| (string & {})
|
|
39
|
+
|
|
26
40
|
/**
|
|
27
41
|
* Known OpenAI embedding model identifiers. The `-3-` line is still the
|
|
28
42
|
* current general-purpose lineup; `dimensions` parameter supports
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { Result, Schema } from "effect"
|
|
2
|
+
import * as Tool from "@effect-uai/core/Tool"
|
|
3
|
+
import { providerToolsOf } from "@effect-uai/core/Tool"
|
|
4
|
+
import { describe, expect, it } from "vitest"
|
|
5
|
+
import { KnownProviderEvent, eventToDeltas, makeCallIdLookup } from "./streamEvents.js"
|
|
6
|
+
import {
|
|
7
|
+
codeInterpreterTool,
|
|
8
|
+
fileSearchTool,
|
|
9
|
+
renderProviderTools,
|
|
10
|
+
webSearchTool,
|
|
11
|
+
} from "./ResponsesTools.js"
|
|
12
|
+
|
|
13
|
+
const wireFor = (toolkit: Record<string, Tool.AnyTool>) =>
|
|
14
|
+
Result.getOrThrow(renderProviderTools(providerToolsOf(toolkit)))
|
|
15
|
+
|
|
16
|
+
describe("Responses provider tool rendering", () => {
|
|
17
|
+
it("maps web_search options onto the native wire fields", () => {
|
|
18
|
+
expect(
|
|
19
|
+
wireFor({
|
|
20
|
+
web_search: webSearchTool({
|
|
21
|
+
searchContextSize: "high",
|
|
22
|
+
filters: { allowedDomains: ["example.com"], blockedDomains: ["spam.com"] },
|
|
23
|
+
userLocation: { city: "Berlin", country: "DE" },
|
|
24
|
+
}),
|
|
25
|
+
}),
|
|
26
|
+
).toEqual([
|
|
27
|
+
{
|
|
28
|
+
type: "web_search",
|
|
29
|
+
filters: { allowed_domains: ["example.com"], blocked_domains: ["spam.com"] },
|
|
30
|
+
user_location: { type: "approximate", city: "Berlin", country: "DE" },
|
|
31
|
+
search_context_size: "high",
|
|
32
|
+
},
|
|
33
|
+
])
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it("maps code_interpreter to an auto container (with file ids when given)", () => {
|
|
37
|
+
expect(wireFor({ ci: codeInterpreterTool() })).toEqual([
|
|
38
|
+
{ type: "code_interpreter", container: { type: "auto" } },
|
|
39
|
+
])
|
|
40
|
+
expect(wireFor({ ci: codeInterpreterTool({ fileIds: ["file_1"] }) })).toEqual([
|
|
41
|
+
{ type: "code_interpreter", container: { type: "auto", file_ids: ["file_1"] } },
|
|
42
|
+
])
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it("maps file_search to its vector store ids", () => {
|
|
46
|
+
expect(wireFor({ fs: fileSearchTool({ vectorStoreIds: ["vs_1", "vs_2"] }) })).toEqual([
|
|
47
|
+
{ type: "file_search", vector_store_ids: ["vs_1", "vs_2"] },
|
|
48
|
+
])
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it("fails on a tool hosted by a different provider", () => {
|
|
52
|
+
const foreign = Tool.provider({
|
|
53
|
+
name: "google_search",
|
|
54
|
+
description: "Gemini grounding.",
|
|
55
|
+
inputSchema: Tool.noInput,
|
|
56
|
+
provider: "gemini",
|
|
57
|
+
config: { kind: "google_search" },
|
|
58
|
+
})
|
|
59
|
+
expect(Result.isFailure(renderProviderTools(providerToolsOf({ foreign })))).toBe(true)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it("fails on an unrecognized config", () => {
|
|
63
|
+
const bogus = Tool.provider({
|
|
64
|
+
name: "mystery",
|
|
65
|
+
description: "Unknown Responses tool.",
|
|
66
|
+
inputSchema: Tool.noInput,
|
|
67
|
+
provider: "openai",
|
|
68
|
+
config: { kind: "telepathy" },
|
|
69
|
+
})
|
|
70
|
+
expect(Result.isFailure(renderProviderTools(providerToolsOf({ bogus })))).toBe(true)
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
describe("Responses hosted-tool response tolerance", () => {
|
|
75
|
+
// The real regression: a hosted-tool output item in `response.completed` must
|
|
76
|
+
// not break the event decode, or `TurnComplete` would never fire. Decoding
|
|
77
|
+
// through the actual event schema (throws on failure) and projecting to
|
|
78
|
+
// TurnEvents proves the grounded text still assembles into a turn.
|
|
79
|
+
const decodeEvent = Schema.decodeUnknownSync(KnownProviderEvent)
|
|
80
|
+
|
|
81
|
+
it("emits TurnComplete with just the text answer for a grounded completion", () => {
|
|
82
|
+
const event = decodeEvent({
|
|
83
|
+
type: "response.completed",
|
|
84
|
+
response: {
|
|
85
|
+
status: "completed",
|
|
86
|
+
output: [
|
|
87
|
+
{ type: "web_search_call", id: "ws_1", status: "completed" },
|
|
88
|
+
{
|
|
89
|
+
type: "message",
|
|
90
|
+
role: "assistant",
|
|
91
|
+
content: [{ type: "output_text", text: "Grounded answer." }],
|
|
92
|
+
},
|
|
93
|
+
],
|
|
94
|
+
},
|
|
95
|
+
})
|
|
96
|
+
const deltas = eventToDeltas(event, makeCallIdLookup())
|
|
97
|
+
const complete = deltas.find((d) => d._tag === "TurnComplete")
|
|
98
|
+
expect(complete).toBeDefined()
|
|
99
|
+
const turn = complete?._tag === "TurnComplete" ? complete.turn : undefined
|
|
100
|
+
expect(turn?.stop_reason).toBe("stop")
|
|
101
|
+
expect(turn?.items).toEqual([
|
|
102
|
+
{
|
|
103
|
+
type: "message",
|
|
104
|
+
role: "assistant",
|
|
105
|
+
content: [{ type: "output_text", text: "Grounded answer." }],
|
|
106
|
+
providerData: expect.anything(),
|
|
107
|
+
},
|
|
108
|
+
])
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
// The new streamed-citation path: a real `annotation.added` payload (with the
|
|
112
|
+
// wire's positional fields) must decode and project to a `CitationAdded`
|
|
113
|
+
// carrying a domain `Annotation`. This is what makes native-grounding
|
|
114
|
+
// citations stream instead of only landing on the final turn.
|
|
115
|
+
it("projects a streamed url_citation to CitationAdded", () => {
|
|
116
|
+
const deltas = eventToDeltas(
|
|
117
|
+
decodeEvent({
|
|
118
|
+
type: "response.output_text.annotation.added",
|
|
119
|
+
item_id: "msg_1",
|
|
120
|
+
output_index: 0,
|
|
121
|
+
content_index: 0,
|
|
122
|
+
annotation_index: 0,
|
|
123
|
+
annotation: {
|
|
124
|
+
type: "url_citation",
|
|
125
|
+
url: "https://example.com/a",
|
|
126
|
+
title: "A",
|
|
127
|
+
start_index: 10,
|
|
128
|
+
end_index: 20,
|
|
129
|
+
},
|
|
130
|
+
}),
|
|
131
|
+
makeCallIdLookup(),
|
|
132
|
+
)
|
|
133
|
+
expect(deltas).toEqual([
|
|
134
|
+
{
|
|
135
|
+
_tag: "CitationAdded",
|
|
136
|
+
annotation: {
|
|
137
|
+
type: "url_citation",
|
|
138
|
+
url: "https://example.com/a",
|
|
139
|
+
title: "A",
|
|
140
|
+
start_index: 10,
|
|
141
|
+
end_index: 20,
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
])
|
|
145
|
+
})
|
|
146
|
+
})
|