@effect-uai/responses 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/dist/OpenAIDeepResearch.d.mts +54 -0
  2. package/dist/OpenAIDeepResearch.d.mts.map +1 -0
  3. package/dist/OpenAIDeepResearch.mjs +121 -0
  4. package/dist/OpenAIDeepResearch.mjs.map +1 -0
  5. package/dist/OpenAIEmbedding.mjs +1 -1
  6. package/dist/OpenAIEmbedding.mjs.map +1 -1
  7. package/dist/Responses.d.mts +33 -3
  8. package/dist/Responses.d.mts.map +1 -1
  9. package/dist/Responses.mjs +71 -34
  10. package/dist/Responses.mjs.map +1 -1
  11. package/dist/ResponsesTools.d.mts +85 -0
  12. package/dist/ResponsesTools.d.mts.map +1 -0
  13. package/dist/ResponsesTools.mjs +126 -0
  14. package/dist/ResponsesTools.mjs.map +1 -0
  15. package/dist/codec.d.mts +42 -3
  16. package/dist/codec.d.mts.map +1 -1
  17. package/dist/codec.mjs +58 -17
  18. package/dist/codec.mjs.map +1 -1
  19. package/dist/codec.test.d.mts +1 -0
  20. package/dist/codec.test.mjs +93 -0
  21. package/dist/codec.test.mjs.map +1 -0
  22. package/dist/index.d.mts +5 -3
  23. package/dist/index.mjs +5 -3
  24. package/dist/{magic-string.es-PAZgLX4h.mjs → magic-string.es-CaWgF-3p.mjs} +1 -1
  25. package/dist/{magic-string.es-PAZgLX4h.mjs.map → magic-string.es-CaWgF-3p.mjs.map} +1 -1
  26. package/dist/models.d.mts +10 -1
  27. package/dist/models.d.mts.map +1 -1
  28. package/dist/region.test.mjs +1 -13630
  29. package/dist/region.test.mjs.map +1 -1
  30. package/dist/responsesTools.test.d.mts +1 -0
  31. package/dist/responsesTools.test.mjs +138 -0
  32. package/dist/responsesTools.test.mjs.map +1 -0
  33. package/dist/streamEvents.d.mts +142 -0
  34. package/dist/streamEvents.d.mts.map +1 -1
  35. package/dist/streamEvents.mjs +27 -2
  36. package/dist/streamEvents.mjs.map +1 -1
  37. package/dist/test.DNmyFkvJ-CPKukVWS.mjs +13633 -0
  38. package/dist/test.DNmyFkvJ-CPKukVWS.mjs.map +1 -0
  39. package/package.json +12 -4
  40. package/src/OpenAIDeepResearch.ts +266 -0
  41. package/src/OpenAIEmbedding.ts +1 -1
  42. package/src/Responses.ts +117 -36
  43. package/src/ResponsesTools.ts +158 -0
  44. package/src/codec.test.ts +87 -0
  45. package/src/codec.ts +92 -36
  46. package/src/index.ts +2 -0
  47. package/src/models.ts +14 -0
  48. package/src/responsesTools.test.ts +146 -0
  49. package/src/streamEvents.ts +58 -1
@@ -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
- export const WireOutputItem = Schema.Union([WireMessage, WireFunctionCall, WireReasoning])
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
- * If an item carries `providerData` from a prior OpenAI turn, re-emit it
137
- * verbatim - preserves `encrypted_content`, item ids, and any field this
138
- * codec doesn't model.
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 passthrough = (item: HistoryItem): Record<string, unknown> | undefined =>
141
- item.providerData !== undefined &&
142
- typeof item.providerData === "object" &&
143
- item.providerData !== null
144
- ? (item.providerData as Record<string, unknown>)
145
- : undefined
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
- type: "message" as const,
227
- role: m.role,
228
- content: m.content.map(wireMessageContentToBlock),
229
- providerData: m,
230
- }),
231
- function_call: (f) => ({
232
- type: "function_call" as const,
233
- call_id: f.call_id,
234
- name: f.name,
235
- arguments: f.arguments,
236
- providerData: f,
237
- }),
238
- reasoning: (r) => ({
239
- type: "reasoning" as const,
240
- ...(r.id !== undefined && { id: r.id }),
241
- ...(r.summary !== undefined && {
242
- summary: r.summary.map((s) => s.text).join("\n"),
243
- }),
244
- ...(r.encrypted_content !== undefined && { signature: r.encrypted_content }),
245
- providerData: r,
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 ?? []).map(wireItemToItem),
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
+ })