pi-openai-toolkit 0.3.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/NOTICE +33 -0
- package/README.md +175 -0
- package/README_zh.md +172 -0
- package/extensions/compaction.ts +1 -0
- package/extensions/web-search.ts +1 -0
- package/package.json +79 -0
- package/src/compact-client.ts +426 -0
- package/src/config.ts +273 -0
- package/src/debug.ts +168 -0
- package/src/details-store.ts +151 -0
- package/src/extension-runtime.ts +537 -0
- package/src/native-fallback.ts +151 -0
- package/src/payload-rewrite.ts +556 -0
- package/src/provider-headers.ts +23 -0
- package/src/remote-v2-client.ts +526 -0
- package/src/request-context-cache.ts +106 -0
- package/src/runtime.ts +283 -0
- package/src/serializer.ts +554 -0
- package/src/supported-environment.ts +17 -0
- package/src/types.ts +333 -0
- package/src/web-search/extension.ts +80 -0
- package/src/web-search/payload.ts +96 -0
- package/src/web-search/prompt.ts +20 -0
- package/src/web-search/types.ts +47 -0
- package/tsconfig.check.json +36 -0
package/src/types.ts
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
|
2
|
+
import type { CompactionEntry, CompactionResult, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
|
|
4
|
+
export const TOOLKIT_ID = "pi-openai-toolkit";
|
|
5
|
+
export const COMPACTION_EXTENSION_ID = `${TOOLKIT_ID}:compaction`;
|
|
6
|
+
export const WEB_SEARCH_EXTENSION_ID = `${TOOLKIT_ID}:web-search`;
|
|
7
|
+
export const DEFAULT_ARTIFACT_ROOT = "~/.pi/agent/artifacts/pi-openai-toolkit/compaction";
|
|
8
|
+
export const REDACTED_VALUE = "[REDACTED]";
|
|
9
|
+
/**
|
|
10
|
+
* APIs the extension knows how to build a `/responses/compact` URL for.
|
|
11
|
+
* `compaction.responsesApis` in config.json may only narrow this set.
|
|
12
|
+
*/
|
|
13
|
+
export const RESPONSES_COMPACT_CAPABLE_APIS = ["openai-responses", "openai-codex-responses"] as const;
|
|
14
|
+
export const LEGACY_NATIVE_COMPACTION_STRATEGY = "openai-native-compact-v1";
|
|
15
|
+
export const REMOTE_V2_COMPACTION_STRATEGY = "openai-remote-compaction-v2";
|
|
16
|
+
export const NATIVE_COMPACTION_STRATEGY = REMOTE_V2_COMPACTION_STRATEGY;
|
|
17
|
+
/**
|
|
18
|
+
* Pi currently requires CompactionResult.summary to be text. This marker is only a
|
|
19
|
+
* replay shim; the provider receives the opaque item from details.compactedWindow.
|
|
20
|
+
*/
|
|
21
|
+
export const NATIVE_COMPACTION_FALLBACK_SUMMARY = "[OpenAI remote v2 opaque compaction checkpoint]";
|
|
22
|
+
|
|
23
|
+
export const THINKING_LEVELS: readonly ThinkingLevel[] = [
|
|
24
|
+
"off",
|
|
25
|
+
"minimal",
|
|
26
|
+
"low",
|
|
27
|
+
"medium",
|
|
28
|
+
"high",
|
|
29
|
+
"xhigh",
|
|
30
|
+
"max",
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
export type DebugArtifactKind =
|
|
34
|
+
| "provider-request"
|
|
35
|
+
| "compact-response"
|
|
36
|
+
| "compaction-event"
|
|
37
|
+
| "lifecycle";
|
|
38
|
+
|
|
39
|
+
export type CompactionConfig = {
|
|
40
|
+
enabled: boolean;
|
|
41
|
+
/**
|
|
42
|
+
* Allow a Responses session whose latest compaction was not created by this extension
|
|
43
|
+
* to restart native compaction from Pi's current serialized session context.
|
|
44
|
+
*/
|
|
45
|
+
allowCompactionContinuityBreak: boolean;
|
|
46
|
+
/**
|
|
47
|
+
* "provider/model-id" used for native-method fallback compaction (non-Responses APIs,
|
|
48
|
+
* or when the compact endpoint fails). Unset = current model via pi's default path.
|
|
49
|
+
*/
|
|
50
|
+
model?: string;
|
|
51
|
+
/** Thinking level passed to pi's native compact() when the fallback model runs. */
|
|
52
|
+
thinkingLevel: ThinkingLevel;
|
|
53
|
+
/** Subset of RESPONSES_COMPACT_CAPABLE_APIS that should use remote compaction. */
|
|
54
|
+
responsesApis: string[];
|
|
55
|
+
notifyOnLoad: boolean;
|
|
56
|
+
debug: boolean;
|
|
57
|
+
logProviderPayloads: boolean;
|
|
58
|
+
logCompactResponses: boolean;
|
|
59
|
+
redactSensitiveData: boolean;
|
|
60
|
+
artifactRoot: string;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export type WebSearchConfig = {
|
|
64
|
+
enabled: boolean;
|
|
65
|
+
/** Exact provider/model keys allowed to use toolkit-native Web Search. */
|
|
66
|
+
models: string[];
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export type ToolkitConfig = {
|
|
70
|
+
compaction: CompactionConfig;
|
|
71
|
+
webSearch: WebSearchConfig;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export type LoadedToolkitConfig = {
|
|
75
|
+
config: ToolkitConfig;
|
|
76
|
+
/** Path of the config file that was applied, if it existed and parsed. */
|
|
77
|
+
source?: string;
|
|
78
|
+
warnings: string[];
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export type ArtifactPaths = {
|
|
82
|
+
rootDir: string;
|
|
83
|
+
sessionDir: string;
|
|
84
|
+
providerRequestsDir: string;
|
|
85
|
+
compactResponsesDir: string;
|
|
86
|
+
compactionDir: string;
|
|
87
|
+
lifecycleDir: string;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export type ArtifactSessionInfo = {
|
|
91
|
+
cwd: string;
|
|
92
|
+
sessionId?: string;
|
|
93
|
+
sessionFile?: string;
|
|
94
|
+
sessionDir?: string;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
export type ArtifactContext = ArtifactSessionInfo | Pick<ExtensionContext, "cwd" | "sessionManager">;
|
|
98
|
+
|
|
99
|
+
export type DebugArtifactEnvelope = {
|
|
100
|
+
extension: string;
|
|
101
|
+
kind: DebugArtifactKind;
|
|
102
|
+
timestamp: string;
|
|
103
|
+
cwd: string;
|
|
104
|
+
sessionId?: string;
|
|
105
|
+
sessionFile?: string;
|
|
106
|
+
sessionDir?: string;
|
|
107
|
+
redaction: {
|
|
108
|
+
enabled: boolean;
|
|
109
|
+
};
|
|
110
|
+
data: unknown;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
export type RedactOptions = {
|
|
114
|
+
placeholder?: string;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
export type NativeCompactionStrategy =
|
|
118
|
+
| typeof LEGACY_NATIVE_COMPACTION_STRATEGY
|
|
119
|
+
| typeof REMOTE_V2_COMPACTION_STRATEGY;
|
|
120
|
+
|
|
121
|
+
export type NativeCompactionRequestMeta = {
|
|
122
|
+
tokensBefore?: number;
|
|
123
|
+
previousSummaryPresent?: boolean;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
export type NativeCompactionIdentity = {
|
|
127
|
+
provider: string;
|
|
128
|
+
api: string;
|
|
129
|
+
model: string;
|
|
130
|
+
baseUrl: string;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
export type NativeCompactionDetails = NativeCompactionIdentity & {
|
|
134
|
+
strategy: NativeCompactionStrategy;
|
|
135
|
+
compactedWindow: unknown[];
|
|
136
|
+
compactResponseId?: string;
|
|
137
|
+
createdAt: string;
|
|
138
|
+
requestMeta?: NativeCompactionRequestMeta;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
export type NativeCompactionEntry = CompactionEntry<NativeCompactionDetails>;
|
|
142
|
+
|
|
143
|
+
export type CreateNativeCompactionDetailsInput = NativeCompactionIdentity & {
|
|
144
|
+
compactedWindow: unknown[];
|
|
145
|
+
compactResponseId?: string;
|
|
146
|
+
createdAt?: string;
|
|
147
|
+
requestMeta?: NativeCompactionRequestMeta;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
export type CreateNativeCompactionResultInput = {
|
|
151
|
+
firstKeptEntryId: string;
|
|
152
|
+
tokensBefore: number;
|
|
153
|
+
details: NativeCompactionDetails;
|
|
154
|
+
/**
|
|
155
|
+
* Summary text extracted from the compact response. Stored as the entry summary so
|
|
156
|
+
* pi's default replay still has real context after switching to an unsupported model.
|
|
157
|
+
*/
|
|
158
|
+
summary?: string;
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
162
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function isNonEmptyString(value: unknown): value is string {
|
|
166
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function isFiniteNonNegativeNumber(value: unknown): value is number {
|
|
170
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function normalizeString(value: string): string {
|
|
174
|
+
return value.trim();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function isStructuredValue(value: unknown): boolean {
|
|
178
|
+
if (
|
|
179
|
+
value === null ||
|
|
180
|
+
typeof value === "string" ||
|
|
181
|
+
typeof value === "number" ||
|
|
182
|
+
typeof value === "boolean"
|
|
183
|
+
) {
|
|
184
|
+
return true;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (Array.isArray(value)) {
|
|
188
|
+
return value.every(isStructuredValue);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (isRecord(value)) {
|
|
192
|
+
return Object.values(value).every(isStructuredValue);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function cloneStructuredValue(value: unknown): unknown {
|
|
199
|
+
if (
|
|
200
|
+
value === null ||
|
|
201
|
+
typeof value === "string" ||
|
|
202
|
+
typeof value === "number" ||
|
|
203
|
+
typeof value === "boolean"
|
|
204
|
+
) {
|
|
205
|
+
return value;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (Array.isArray(value)) {
|
|
209
|
+
return value.map(cloneStructuredValue);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (isRecord(value)) {
|
|
213
|
+
const clone: Record<string, unknown> = {};
|
|
214
|
+
for (const [key, nested] of Object.entries(value)) {
|
|
215
|
+
clone[key] = cloneStructuredValue(nested);
|
|
216
|
+
}
|
|
217
|
+
return clone;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
throw new Error(`Unsupported structured value: ${typeof value}`);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function isCompactedWindowItem(value: unknown): value is Record<string, unknown> {
|
|
224
|
+
return isRecord(value) && Object.values(value).every(isStructuredValue);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function isNativeCompactionRequestMeta(value: unknown): value is NativeCompactionRequestMeta {
|
|
228
|
+
if (!isRecord(value)) {
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const { tokensBefore, previousSummaryPresent } = value;
|
|
233
|
+
if (tokensBefore !== undefined && !isFiniteNonNegativeNumber(tokensBefore)) {
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (previousSummaryPresent !== undefined && typeof previousSummaryPresent !== "boolean") {
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return true;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function isNativeCompactionIdentity(value: unknown): value is NativeCompactionIdentity {
|
|
245
|
+
if (!isRecord(value)) {
|
|
246
|
+
return false;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return (
|
|
250
|
+
isNonEmptyString(value.provider) &&
|
|
251
|
+
isNonEmptyString(value.api) &&
|
|
252
|
+
isNonEmptyString(value.model) &&
|
|
253
|
+
isNonEmptyString(value.baseUrl)
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export function isNativeCompactionDetails(value: unknown): value is NativeCompactionDetails {
|
|
258
|
+
if (!isRecord(value) || !isNativeCompactionIdentity(value)) {
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const candidate = value as Record<string, unknown>;
|
|
263
|
+
return (
|
|
264
|
+
(candidate.strategy === LEGACY_NATIVE_COMPACTION_STRATEGY ||
|
|
265
|
+
candidate.strategy === REMOTE_V2_COMPACTION_STRATEGY) &&
|
|
266
|
+
Array.isArray(candidate.compactedWindow) &&
|
|
267
|
+
candidate.compactedWindow.every(isCompactedWindowItem) &&
|
|
268
|
+
isNonEmptyString(candidate.createdAt) &&
|
|
269
|
+
(candidate.compactResponseId === undefined || isNonEmptyString(candidate.compactResponseId)) &&
|
|
270
|
+
(candidate.requestMeta === undefined || isNativeCompactionRequestMeta(candidate.requestMeta))
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export function isNativeCompactionEntry(value: unknown): value is NativeCompactionEntry {
|
|
275
|
+
return isRecord(value) && value.type === "compaction" && isNativeCompactionDetails(value.details);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export function createNativeCompactionDetails(input: CreateNativeCompactionDetailsInput): NativeCompactionDetails {
|
|
279
|
+
return {
|
|
280
|
+
strategy: NATIVE_COMPACTION_STRATEGY,
|
|
281
|
+
provider: normalizeString(input.provider),
|
|
282
|
+
api: normalizeString(input.api),
|
|
283
|
+
model: normalizeString(input.model),
|
|
284
|
+
baseUrl: normalizeString(input.baseUrl),
|
|
285
|
+
compactedWindow: input.compactedWindow.map((item) => cloneStructuredValue(item)),
|
|
286
|
+
compactResponseId: isNonEmptyString(input.compactResponseId) ? normalizeString(input.compactResponseId) : undefined,
|
|
287
|
+
createdAt: isNonEmptyString(input.createdAt) ? normalizeString(input.createdAt) : new Date().toISOString(),
|
|
288
|
+
requestMeta: input.requestMeta
|
|
289
|
+
? {
|
|
290
|
+
...(input.requestMeta.tokensBefore !== undefined ? { tokensBefore: input.requestMeta.tokensBefore } : {}),
|
|
291
|
+
...(input.requestMeta.previousSummaryPresent !== undefined
|
|
292
|
+
? { previousSummaryPresent: input.requestMeta.previousSummaryPresent }
|
|
293
|
+
: {}),
|
|
294
|
+
}
|
|
295
|
+
: undefined,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export function createNativeCompactionResult(
|
|
300
|
+
input: CreateNativeCompactionResultInput,
|
|
301
|
+
): CompactionResult<NativeCompactionDetails> {
|
|
302
|
+
const summary = input.summary?.trim();
|
|
303
|
+
return {
|
|
304
|
+
summary: summary && summary.length > 0 ? summary : NATIVE_COMPACTION_FALLBACK_SUMMARY,
|
|
305
|
+
firstKeptEntryId: input.firstKeptEntryId,
|
|
306
|
+
tokensBefore: input.tokensBefore,
|
|
307
|
+
details: input.details,
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export const DEFAULT_COMPACTION_CONFIG: CompactionConfig = {
|
|
312
|
+
enabled: true,
|
|
313
|
+
allowCompactionContinuityBreak: false,
|
|
314
|
+
model: undefined,
|
|
315
|
+
thinkingLevel: "off",
|
|
316
|
+
responsesApis: [...RESPONSES_COMPACT_CAPABLE_APIS],
|
|
317
|
+
notifyOnLoad: false,
|
|
318
|
+
debug: false,
|
|
319
|
+
logProviderPayloads: false,
|
|
320
|
+
logCompactResponses: false,
|
|
321
|
+
redactSensitiveData: true,
|
|
322
|
+
artifactRoot: DEFAULT_ARTIFACT_ROOT,
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
export const DEFAULT_WEB_SEARCH_CONFIG: WebSearchConfig = {
|
|
326
|
+
enabled: true,
|
|
327
|
+
models: [],
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
export const DEFAULT_TOOLKIT_CONFIG: ToolkitConfig = {
|
|
331
|
+
compaction: DEFAULT_COMPACTION_CONFIG,
|
|
332
|
+
webSearch: DEFAULT_WEB_SEARCH_CONFIG,
|
|
333
|
+
};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { loadToolkitConfig } from "../config";
|
|
3
|
+
import { transformWebSearchPayload } from "./payload";
|
|
4
|
+
import { appendWebSearchPrompt } from "./prompt";
|
|
5
|
+
import { isWebSearchEnabledForModel, type WebSearchModel } from "./types";
|
|
6
|
+
|
|
7
|
+
function syncLocalWebSearchTool(
|
|
8
|
+
pi: ExtensionAPI,
|
|
9
|
+
model: WebSearchModel | undefined,
|
|
10
|
+
config: Parameters<typeof isWebSearchEnabledForModel>[1],
|
|
11
|
+
state: { wasActiveBeforeOwnership?: boolean; toolkitOwnsTool: boolean },
|
|
12
|
+
): void {
|
|
13
|
+
const eligible = isWebSearchEnabledForModel(model, config);
|
|
14
|
+
const activeTools = pi.getActiveTools();
|
|
15
|
+
|
|
16
|
+
if (eligible) {
|
|
17
|
+
if (!state.toolkitOwnsTool) {
|
|
18
|
+
state.wasActiveBeforeOwnership = activeTools.includes("web_search");
|
|
19
|
+
state.toolkitOwnsTool = true;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (activeTools.includes("web_search")) {
|
|
23
|
+
pi.setActiveTools(activeTools.filter((name) => name !== "web_search"));
|
|
24
|
+
}
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (!state.toolkitOwnsTool) return;
|
|
29
|
+
|
|
30
|
+
if (state.wasActiveBeforeOwnership && !activeTools.includes("web_search")) {
|
|
31
|
+
pi.setActiveTools([...activeTools, "web_search"]);
|
|
32
|
+
}
|
|
33
|
+
state.wasActiveBeforeOwnership = undefined;
|
|
34
|
+
state.toolkitOwnsTool = false;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function registerWebSearchExtension(
|
|
38
|
+
pi: ExtensionAPI,
|
|
39
|
+
loadConfig: typeof loadToolkitConfig = loadToolkitConfig,
|
|
40
|
+
): void {
|
|
41
|
+
const toolState = { wasActiveBeforeOwnership: undefined as boolean | undefined, toolkitOwnsTool: false };
|
|
42
|
+
|
|
43
|
+
pi.on("session_start", (_event, ctx) => {
|
|
44
|
+
const { config } = loadConfig();
|
|
45
|
+
syncLocalWebSearchTool(pi, ctx.model, config.webSearch, toolState);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
pi.on("model_select", (event) => {
|
|
49
|
+
const { config } = loadConfig();
|
|
50
|
+
syncLocalWebSearchTool(pi, event.model, config.webSearch, toolState);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
pi.on("before_agent_start", (event, ctx) => {
|
|
54
|
+
const { config } = loadConfig();
|
|
55
|
+
syncLocalWebSearchTool(pi, ctx.model, config.webSearch, toolState);
|
|
56
|
+
const systemPrompt = appendWebSearchPrompt({
|
|
57
|
+
model: ctx.model,
|
|
58
|
+
config: config.webSearch,
|
|
59
|
+
systemPrompt: event.systemPrompt,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
return systemPrompt === event.systemPrompt ? undefined : { systemPrompt };
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
pi.on("before_provider_request", (event, ctx) => {
|
|
66
|
+
const { config } = loadConfig();
|
|
67
|
+
syncLocalWebSearchTool(pi, ctx.model, config.webSearch, toolState);
|
|
68
|
+
const transformed = transformWebSearchPayload({
|
|
69
|
+
model: ctx.model,
|
|
70
|
+
config: config.webSearch,
|
|
71
|
+
payload: event.payload,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
return transformed.changed ? transformed.payload : undefined;
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export default function webSearchExtension(pi: ExtensionAPI): void {
|
|
79
|
+
registerWebSearchExtension(pi);
|
|
80
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { WebSearchConfig } from "../types";
|
|
2
|
+
import {
|
|
3
|
+
WEB_SEARCH_SOURCE_INCLUDE,
|
|
4
|
+
isWebSearchEnabledForModel,
|
|
5
|
+
type WebSearchModel,
|
|
6
|
+
type WebSearchPayloadTransform,
|
|
7
|
+
} from "./types";
|
|
8
|
+
|
|
9
|
+
type JsonObject = Record<string, unknown>;
|
|
10
|
+
|
|
11
|
+
function isRecord(value: unknown): value is JsonObject {
|
|
12
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function isLocalWebSearchFunction(tool: unknown): boolean {
|
|
16
|
+
return isRecord(tool) && tool.type === "function" && tool.name === "web_search";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function isNativeWebSearchTool(tool: unknown): boolean {
|
|
20
|
+
return isRecord(tool) && (tool.type === "web_search" || tool.type === "web_search_preview");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function transformWebSearchPayload(args: {
|
|
24
|
+
model: WebSearchModel | undefined;
|
|
25
|
+
config: WebSearchConfig;
|
|
26
|
+
payload: unknown;
|
|
27
|
+
}): WebSearchPayloadTransform {
|
|
28
|
+
const { model, config, payload } = args;
|
|
29
|
+
if (!config.enabled) {
|
|
30
|
+
return { payload, outcome: "disabled", changed: false };
|
|
31
|
+
}
|
|
32
|
+
if (!isWebSearchEnabledForModel(model, config)) {
|
|
33
|
+
return { payload, outcome: "unsupported-model", changed: false };
|
|
34
|
+
}
|
|
35
|
+
if (!isRecord(payload)) {
|
|
36
|
+
return { payload, outcome: "non-object-payload", changed: false };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const toolsValue = payload.tools;
|
|
40
|
+
if (toolsValue !== undefined && !Array.isArray(toolsValue)) {
|
|
41
|
+
return { payload, outcome: "invalid-tools", changed: false };
|
|
42
|
+
}
|
|
43
|
+
const tools = toolsValue ?? [];
|
|
44
|
+
const includeValue = payload.include;
|
|
45
|
+
if (includeValue !== undefined && !Array.isArray(includeValue)) {
|
|
46
|
+
return { payload, outcome: "invalid-include", changed: false };
|
|
47
|
+
}
|
|
48
|
+
const include = includeValue ?? [];
|
|
49
|
+
const normalizedTools: unknown[] = [];
|
|
50
|
+
let nativeToolCount = 0;
|
|
51
|
+
let localToolRemoved = false;
|
|
52
|
+
for (const tool of tools) {
|
|
53
|
+
if (isLocalWebSearchFunction(tool)) {
|
|
54
|
+
localToolRemoved = true;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (isNativeWebSearchTool(tool)) {
|
|
58
|
+
nativeToolCount += 1;
|
|
59
|
+
if (nativeToolCount > 1) continue;
|
|
60
|
+
}
|
|
61
|
+
normalizedTools.push(tool);
|
|
62
|
+
}
|
|
63
|
+
const hasNativeTool = nativeToolCount > 0;
|
|
64
|
+
if (!hasNativeTool) {
|
|
65
|
+
normalizedTools.push({ type: "web_search" });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const normalizedInclude: unknown[] = [];
|
|
69
|
+
let sourceIncludeCount = 0;
|
|
70
|
+
for (const item of include) {
|
|
71
|
+
if (item === WEB_SEARCH_SOURCE_INCLUDE) {
|
|
72
|
+
sourceIncludeCount += 1;
|
|
73
|
+
if (sourceIncludeCount > 1) continue;
|
|
74
|
+
}
|
|
75
|
+
normalizedInclude.push(item);
|
|
76
|
+
}
|
|
77
|
+
if (sourceIncludeCount === 0) {
|
|
78
|
+
normalizedInclude.push(WEB_SEARCH_SOURCE_INCLUDE);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const toolsChanged = localToolRemoved || !hasNativeTool || nativeToolCount > 1;
|
|
82
|
+
const includeChanged = sourceIncludeCount !== 1;
|
|
83
|
+
if (!toolsChanged && !includeChanged) {
|
|
84
|
+
return { payload, outcome: "existing-native-tool", changed: false };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
payload: {
|
|
89
|
+
...payload,
|
|
90
|
+
...(toolsChanged ? { tools: normalizedTools } : {}),
|
|
91
|
+
...(includeChanged ? { include: normalizedInclude } : {}),
|
|
92
|
+
},
|
|
93
|
+
outcome: hasNativeTool ? "existing-native-tool" : "injected-native-tool",
|
|
94
|
+
changed: true,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { WebSearchConfig } from "../types";
|
|
2
|
+
import { isWebSearchEnabledForModel, WEB_SEARCH_PROMPT_MARKER, type WebSearchModel } from "./types";
|
|
3
|
+
|
|
4
|
+
export const WEB_SEARCH_PROMPT_SECTION = `${WEB_SEARCH_PROMPT_MARKER}
|
|
5
|
+
## Web Search
|
|
6
|
+
|
|
7
|
+
Native OpenAI Web Search is available for this turn. Use it for current or online information when freshness matters instead of guessing, and cite the returned sources in the answer.`;
|
|
8
|
+
|
|
9
|
+
export function appendWebSearchPrompt(args: {
|
|
10
|
+
model: WebSearchModel | undefined;
|
|
11
|
+
config: WebSearchConfig;
|
|
12
|
+
systemPrompt: string;
|
|
13
|
+
}): string {
|
|
14
|
+
const { model, config, systemPrompt } = args;
|
|
15
|
+
if (!isWebSearchEnabledForModel(model, config) || systemPrompt.includes(WEB_SEARCH_PROMPT_MARKER)) {
|
|
16
|
+
return systemPrompt;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return `${systemPrompt.trimEnd()}\n\n${WEB_SEARCH_PROMPT_SECTION}`;
|
|
20
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { WebSearchConfig } from "../types";
|
|
2
|
+
|
|
3
|
+
export const WEB_SEARCH_CAPABLE_APIS = ["openai-responses", "openai-codex-responses"] as const;
|
|
4
|
+
export const WEB_SEARCH_SOURCE_INCLUDE = "web_search_call.action.sources";
|
|
5
|
+
export const WEB_SEARCH_PROMPT_MARKER = "<!-- pi-openai-toolkit:web-search -->";
|
|
6
|
+
|
|
7
|
+
export type WebSearchCapableApi = (typeof WEB_SEARCH_CAPABLE_APIS)[number];
|
|
8
|
+
|
|
9
|
+
export type WebSearchModel = {
|
|
10
|
+
api?: string;
|
|
11
|
+
provider?: string;
|
|
12
|
+
id?: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type WebSearchPayloadOutcome =
|
|
16
|
+
| "disabled"
|
|
17
|
+
| "unsupported-model"
|
|
18
|
+
| "non-object-payload"
|
|
19
|
+
| "invalid-tools"
|
|
20
|
+
| "invalid-include"
|
|
21
|
+
| "existing-native-tool"
|
|
22
|
+
| "injected-native-tool";
|
|
23
|
+
|
|
24
|
+
export type WebSearchPayloadTransform = {
|
|
25
|
+
payload: unknown;
|
|
26
|
+
outcome: WebSearchPayloadOutcome;
|
|
27
|
+
changed: boolean;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export function getWebSearchModelKey(model: WebSearchModel | undefined): string | undefined {
|
|
31
|
+
if (!model?.provider || !model.id) return undefined;
|
|
32
|
+
return `${model.provider}/${model.id}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function isWebSearchEnabledForModel(
|
|
36
|
+
model: WebSearchModel | undefined,
|
|
37
|
+
config: WebSearchConfig,
|
|
38
|
+
): boolean {
|
|
39
|
+
const modelKey = getWebSearchModelKey(model);
|
|
40
|
+
return (
|
|
41
|
+
config.enabled &&
|
|
42
|
+
typeof model?.api === "string" &&
|
|
43
|
+
(WEB_SEARCH_CAPABLE_APIS as readonly string[]).includes(model.api) &&
|
|
44
|
+
modelKey !== undefined &&
|
|
45
|
+
config.models.includes(modelKey)
|
|
46
|
+
);
|
|
47
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"noEmit": true,
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "Bundler",
|
|
6
|
+
"allowImportingTsExtensions": true,
|
|
7
|
+
"target": "ES2022",
|
|
8
|
+
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
|
9
|
+
"skipLibCheck": true,
|
|
10
|
+
"strict": true,
|
|
11
|
+
"baseUrl": ".",
|
|
12
|
+
"typeRoots": ["./node_modules/@types"]
|
|
13
|
+
},
|
|
14
|
+
"include": [
|
|
15
|
+
"extensions/compaction.ts",
|
|
16
|
+
"extensions/web-search.ts",
|
|
17
|
+
"src/compact-client.ts",
|
|
18
|
+
"src/config.ts",
|
|
19
|
+
"src/debug.ts",
|
|
20
|
+
"src/details-store.ts",
|
|
21
|
+
"src/extension-runtime.ts",
|
|
22
|
+
"src/native-fallback.ts",
|
|
23
|
+
"src/payload-rewrite.ts",
|
|
24
|
+
"src/provider-headers.ts",
|
|
25
|
+
"src/remote-v2-client.ts",
|
|
26
|
+
"src/request-context-cache.ts",
|
|
27
|
+
"src/runtime.ts",
|
|
28
|
+
"src/serializer.ts",
|
|
29
|
+
"src/supported-environment.ts",
|
|
30
|
+
"src/types.ts",
|
|
31
|
+
"src/web-search/extension.ts",
|
|
32
|
+
"src/web-search/payload.ts",
|
|
33
|
+
"src/web-search/prompt.ts",
|
|
34
|
+
"src/web-search/types.ts"
|
|
35
|
+
]
|
|
36
|
+
}
|