pi-openai-toolkit 0.14.5 → 0.14.7

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 (35) hide show
  1. package/docs/internals.md +42 -21
  2. package/package.json +1 -1
  3. package/src/auto-mode/classifier.ts +165 -165
  4. package/src/auto-mode/policy.ts +146 -146
  5. package/src/auto-mode/prompt.ts +279 -279
  6. package/src/auto-mode/reviewer.ts +325 -325
  7. package/src/auto-mode/transcript.ts +133 -133
  8. package/src/auto-mode/types.ts +137 -137
  9. package/src/codex-astra/effort-planner.ts +175 -175
  10. package/src/codex-astra/extension.ts +122 -122
  11. package/src/context-management/codex-provider.ts +20 -20
  12. package/src/context-management/history-notes.ts +32 -0
  13. package/src/context-management/messages.ts +112 -106
  14. package/src/context-management/namespace-tools.ts +426 -426
  15. package/src/context-management/tool-contract.ts +23 -23
  16. package/src/context-management/tools.ts +279 -311
  17. package/src/context-management/types.ts +7 -0
  18. package/src/context-management/window-budget.ts +83 -83
  19. package/src/context-management/window-manager.ts +60 -51
  20. package/src/context-management/window-request.ts +125 -125
  21. package/src/debug.ts +1 -1
  22. package/src/extension-runtime.ts +34 -9
  23. package/src/image-generation/artifacts.ts +237 -237
  24. package/src/image-generation/client.ts +248 -248
  25. package/src/image-generation/eligibility.ts +18 -18
  26. package/src/image-generation/references.ts +194 -194
  27. package/src/image-generation/render.ts +252 -252
  28. package/src/model-scope.ts +17 -17
  29. package/src/provider-headers.ts +23 -23
  30. package/src/responses-headers.ts +76 -11
  31. package/src/runtime.ts +37 -1
  32. package/src/web-search/extension.ts +80 -80
  33. package/src/web-search/payload.ts +96 -96
  34. package/src/web-search/prompt.ts +20 -20
  35. package/src/web-search/types.ts +45 -45
@@ -1,11 +1,55 @@
1
1
  import type { ProviderHeaders } from "@earendil-works/pi-ai";
2
2
  import { mergeProviderHeaders } from "./provider-headers";
3
3
 
4
+ export const CODEX_AFFINITY_SCOPE = "codex-session-v1" as const;
5
+ export const CODEX_GATEWAY_ORIGINATOR = "codex_cli_rs" as const;
6
+
7
+ /** Headers permitted from Pi/provider configuration onto the gateway hop. */
8
+ export const CODEX_GATEWAY_FORWARD_HEADERS = [
9
+ "accept",
10
+ "content-type",
11
+ "session-id",
12
+ "thread-id",
13
+ "x-parent-session-id",
14
+ "x-codex-parent-thread-id",
15
+ "x-client-request-id",
16
+ "x-codex-model",
17
+ "x-codex-affinity-scope",
18
+ "x-codex-window-id",
19
+ "x-codex-turn-metadata",
20
+ "x-codex-beta-features",
21
+ "x-openai-encrypted-tool-arguments",
22
+ "x-openai-tool-output-truncation-policy",
23
+ "x-openai-internal-codex-responses-lite",
24
+ "version",
25
+ "originator",
26
+ "user-agent",
27
+ ] as const;
28
+
29
+ const CODEX_GATEWAY_FORWARD_HEADER_SET = new Set<string>(CODEX_GATEWAY_FORWARD_HEADERS);
30
+
31
+ export function filterCodexGatewayHeaders(headers: ProviderHeaders | undefined): Record<string, string> {
32
+ const result: Record<string, string> = {};
33
+ for (const [name, value] of Object.entries(headers ?? {})) {
34
+ if (typeof value !== "string" || !CODEX_GATEWAY_FORWARD_HEADER_SET.has(name.toLowerCase())) continue;
35
+ result[name] = value;
36
+ }
37
+ return result;
38
+ }
39
+
40
+ export type CodexAffinity = {
41
+ /** Bare model id used by the gateway route selector, never provider/model. */
42
+ model: string;
43
+ scope: typeof CODEX_AFFINITY_SCOPE;
44
+ };
45
+
4
46
  export type ResponsesHeaderRuntime = {
5
47
  api: string;
6
48
  apiKey: string;
7
49
  headers?: ProviderHeaders;
8
50
  sessionId?: string;
51
+ /** Opt-in gateway Codex routing metadata for synthetic requests. */
52
+ codexAffinity?: CodexAffinity;
9
53
  currentModel: {
10
54
  headers?: ProviderHeaders;
11
55
  };
@@ -39,6 +83,12 @@ function buildCodexUserAgent(): string {
39
83
  return `pi (${platform}; ${arch})`;
40
84
  }
41
85
 
86
+ export function buildCodexCliUserAgent(version: string): string {
87
+ const platform = typeof process !== "undefined" ? process.platform : "browser";
88
+ const arch = typeof process !== "undefined" ? process.arch : "unknown";
89
+ return `codex_cli_rs/${version} (${platform}; ${arch})`;
90
+ }
91
+
42
92
  /**
43
93
  * The Codex backend version-gates model availability on the `version` header
44
94
  * (the pinned @openai/codex client version). `gpt-6-astra` requires >= 0.153.0;
@@ -68,8 +118,9 @@ export function buildResponsesRequestHeaders(
68
118
  runtime: ResponsesHeaderRuntime,
69
119
  options: ResponsesHeaderOptions,
70
120
  ): Headers {
121
+ const mergedHeaders = mergeProviderHeaders(runtime.currentModel.headers, runtime.headers);
71
122
  const headers = new Headers(
72
- mergeProviderHeaders(runtime.currentModel.headers, runtime.headers),
123
+ runtime.codexAffinity ? filterCodexGatewayHeaders(mergedHeaders) : mergedHeaders,
73
124
  );
74
125
  headers.set("accept", options.accept);
75
126
  headers.set("content-type", options.contentType ?? "application/json");
@@ -77,19 +128,33 @@ export function buildResponsesRequestHeaders(
77
128
  headers.set("authorization", `Bearer ${runtime.apiKey}`);
78
129
  }
79
130
 
80
- if (runtime.api === "openai-codex-responses") {
81
- const accountId = extractCodexAccountId(runtime.apiKey);
82
- if (accountId) headers.set("chatgpt-account-id", accountId);
83
- headers.set("originator", "pi");
84
- headers.set("user-agent", buildCodexUserAgent());
85
- headers.set("openai-beta", "responses=experimental");
131
+ const isNativeCodex = runtime.api === "openai-codex-responses";
132
+ const gatewayAffinity = runtime.codexAffinity;
133
+ if (isNativeCodex || gatewayAffinity) {
134
+ if (gatewayAffinity) {
135
+ // A gateway request must use the configured data-plane key, never an
136
+ // inherited OAuth/account header from the model registry.
137
+ headers.set("authorization", `Bearer ${runtime.apiKey}`);
138
+ headers.delete("chatgpt-account-id");
139
+ headers.delete("cookie");
140
+ headers.delete("x-api-key");
141
+ headers.set("originator", CODEX_GATEWAY_ORIGINATOR);
142
+ headers.set("user-agent", buildCodexCliUserAgent(CODEX_CLIENT_VERSION));
143
+ headers.set("x-codex-affinity-scope", gatewayAffinity.scope);
144
+ headers.set("x-codex-model", gatewayAffinity.model);
145
+ } else {
146
+ const accountId = extractCodexAccountId(runtime.apiKey);
147
+ if (accountId) headers.set("chatgpt-account-id", accountId);
148
+ headers.set("originator", "pi");
149
+ headers.set("user-agent", buildCodexUserAgent());
150
+ headers.set("openai-beta", "responses=experimental");
151
+ }
86
152
  // Codex backend gating: a request without a new-enough `version` never
87
153
  // reaches model SKUs that arrived behind the gate (gpt-6-astra).
88
154
  headers.set("version", CODEX_CLIENT_VERSION);
89
- // Prompt-cache affinity rides on the conversation identity: the backend
90
- // keeps a conversation's cache prefix on the node that wrote it, so our
91
- // synthetic compaction requests must present the same id the live
92
- // transcript requests used, or every compact pays a full cache miss.
155
+ // Prompt-cache/session affinity rides on the conversation identity. The
156
+ // gateway path also uses the bare model hint above so NEWapi can route
157
+ // context/compact requests without inspecting their body.
93
158
  const sessionId = clampCodexSessionId(options.sessionId ?? runtime.sessionId);
94
159
  if (sessionId) {
95
160
  // Header names match the installed host's live-request builder
package/src/runtime.ts CHANGED
@@ -1,5 +1,10 @@
1
1
  import type { Api, Model, ProviderHeaders } from "@earendil-works/pi-ai";
2
2
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import { isExactModelAllowed } from "./model-scope";
4
+ import {
5
+ CODEX_AFFINITY_SCOPE,
6
+ type CodexAffinity,
7
+ } from "./responses-headers";
3
8
  import { RESPONSES_COMPACT_CAPABLE_APIS } from "./types";
4
9
 
5
10
  const OPENAI_RESPONSES_PATH = "responses";
@@ -24,6 +29,7 @@ export type NativeCompactionFailureReason =
24
29
  | "model-not-found"
25
30
  | "unsupported-api"
26
31
  | "missing-base-url"
32
+ | "missing-session-id"
27
33
  | "missing-api-key"
28
34
  | "auth-resolution-failed"
29
35
  | "unsupported-payload"
@@ -34,6 +40,8 @@ export type NativeCompactionSupportOptions = {
34
40
  enabled?: boolean;
35
41
  /** Which Responses APIs should use the compact endpoint; defaults to all capable APIs. */
36
42
  responsesApis?: readonly string[];
43
+ /** Exact provider/model keys whose Responses traffic carries gateway Codex affinity metadata. */
44
+ codexGatewayModels?: readonly string[];
37
45
  };
38
46
 
39
47
  export type ResponsesSupportOptions = NativeCompactionSupportOptions;
@@ -60,6 +68,8 @@ export type ResponsesRuntime = {
60
68
  * requests used or the backend treats them as a new conversation.
61
69
  */
62
70
  sessionId?: string;
71
+ /** Opt-in gateway Codex routing metadata for synthetic Responses/compact requests. */
72
+ codexAffinity?: CodexAffinity;
63
73
  currentModel: RuntimeModel;
64
74
  };
65
75
 
@@ -130,6 +140,19 @@ function normalizeConfiguredApis(values: readonly string[] | undefined): Set<str
130
140
  return new Set(values.map((value) => value.trim()).filter((value) => value.length > 0));
131
141
  }
132
142
 
143
+ function resolveCodexGatewayAffinity(
144
+ model: RuntimeModel,
145
+ gatewayModels: readonly string[] | undefined,
146
+ ): CodexAffinity | undefined {
147
+ if (model.api !== "openai-responses" || !isExactModelAllowed(model, gatewayModels ?? [])) {
148
+ return undefined;
149
+ }
150
+ return {
151
+ model: model.id,
152
+ scope: CODEX_AFFINITY_SCOPE,
153
+ };
154
+ }
155
+
133
156
  /** Parse "provider/model-id" (model ids may themselves contain slashes). */
134
157
  export function parseModelSpec(spec: string): ParsedModelSpec | undefined {
135
158
  const trimmed = spec.trim();
@@ -329,6 +352,17 @@ async function resolveNativeCompactionEnvironmentForModel(
329
352
  };
330
353
  }
331
354
 
355
+ const codexAffinity = resolveCodexGatewayAffinity(currentModel, options.codexGatewayModels);
356
+ const sessionId = resolveRuntimeSessionId(ctx);
357
+ if (codexAffinity && !sessionId) {
358
+ return {
359
+ ok: false,
360
+ reason: "missing-session-id",
361
+ ...descriptor,
362
+ baseUrl,
363
+ };
364
+ }
365
+
332
366
  if (!auth.apiKey) {
333
367
  return {
334
368
  ok: false,
@@ -351,7 +385,8 @@ async function resolveNativeCompactionEnvironmentForModel(
351
385
  responsesUrl: buildResponsesUrl(baseUrl, descriptor.api),
352
386
  compactPath: buildCompactPath(descriptor.api),
353
387
  compactUrl: buildCompactUrl(baseUrl, descriptor.api),
354
- sessionId: resolveRuntimeSessionId(ctx),
388
+ sessionId,
389
+ codexAffinity,
355
390
  payload: requestPayload,
356
391
  currentModel,
357
392
  },
@@ -378,6 +413,7 @@ export async function resolveResponsesEnvironment(
378
413
  responsesPath: runtime.responsesPath,
379
414
  responsesUrl: runtime.responsesUrl,
380
415
  sessionId: runtime.sessionId,
416
+ codexAffinity: runtime.codexAffinity,
381
417
  currentModel: runtime.currentModel,
382
418
  },
383
419
  };
@@ -1,80 +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
- }
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
+ }
@@ -1,96 +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
- }
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
+ }
@@ -1,20 +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
- The hosted \`web_search\` tool is part of your tool list for this model. The API executes it server-side: it is a real callable tool even though no client-side function schema or description accompanies it. Call it whenever current or online information matters instead of guessing, cite the returned sources in the answer, and never claim that web search or internet access is unavailable while this section is present.`;
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
- }
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
+ The hosted \`web_search\` tool is part of your tool list for this model. The API executes it server-side: it is a real callable tool even though no client-side function schema or description accompanies it. Call it whenever current or online information matters instead of guessing, cite the returned sources in the answer, and never claim that web search or internet access is unavailable while this section is present.`;
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
+ }