Codex: add native web search for embedded Pi runs
This commit is contained in:
parent
c30cabcca4
commit
21270f900b
@ -55,6 +55,41 @@ Runtime SecretRef behavior:
|
||||
- In auto-detect mode, OpenClaw resolves only the selected provider key. Non-selected provider SecretRefs stay inactive until selected.
|
||||
- If the selected provider SecretRef is unresolved and no provider env fallback exists, startup/reload fails fast.
|
||||
|
||||
## Native Codex web search
|
||||
|
||||
Codex-capable models can optionally use the provider-native Responses `web_search` tool instead of OpenClaw's managed `web_search` function.
|
||||
|
||||
- Configure it under `tools.web.search.openaiCodex`
|
||||
- It only activates for Codex-capable models (`openai-codex/*` or providers using `api: "openai-codex-responses"`)
|
||||
- Managed `web_search` still applies to non-Codex models
|
||||
- `mode: "cached"` is the default and recommended setting
|
||||
- `tools.web.search.enabled: false` disables both managed and native search
|
||||
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
enabled: true,
|
||||
openaiCodex: {
|
||||
enabled: true,
|
||||
mode: "cached",
|
||||
allowedDomains: ["example.com"],
|
||||
contextSize: "high",
|
||||
userLocation: {
|
||||
country: "US",
|
||||
city: "New York",
|
||||
timezone: "America/New_York",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
If native Codex search is enabled but the current model is not Codex-capable, OpenClaw keeps the normal managed `web_search` behavior.
|
||||
|
||||
## Setting up web search
|
||||
|
||||
Use `openclaw configure --section web` to set up your API key and choose a provider.
|
||||
|
||||
211
src/agents/codex-native-web-search.test.ts
Normal file
211
src/agents/codex-native-web-search.test.ts
Normal file
@ -0,0 +1,211 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildCodexNativeWebSearchTool,
|
||||
patchCodexNativeWebSearchPayload,
|
||||
resolveCodexNativeSearchActivation,
|
||||
resolveCodexNativeWebSearchConfig,
|
||||
shouldSuppressManagedWebSearchTool,
|
||||
} from "./codex-native-web-search.js";
|
||||
|
||||
const baseConfig = {
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
enabled: true,
|
||||
openaiCodex: {
|
||||
enabled: true,
|
||||
mode: "cached",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
describe("resolveCodexNativeSearchActivation", () => {
|
||||
it("returns managed_only when native Codex search is disabled", () => {
|
||||
const result = resolveCodexNativeSearchActivation({
|
||||
config: { tools: { web: { search: { enabled: true } } } },
|
||||
modelProvider: "openai-codex",
|
||||
modelApi: "openai-codex-responses",
|
||||
});
|
||||
|
||||
expect(result.state).toBe("managed_only");
|
||||
expect(result.inactiveReason).toBe("codex_not_enabled");
|
||||
});
|
||||
|
||||
it("returns managed_only for non-eligible models", () => {
|
||||
const result = resolveCodexNativeSearchActivation({
|
||||
config: baseConfig,
|
||||
modelProvider: "openai",
|
||||
modelApi: "openai-responses",
|
||||
});
|
||||
|
||||
expect(result.state).toBe("managed_only");
|
||||
expect(result.inactiveReason).toBe("model_not_eligible");
|
||||
});
|
||||
|
||||
it("activates for direct openai-codex when auth exists", () => {
|
||||
const result = resolveCodexNativeSearchActivation({
|
||||
config: {
|
||||
...baseConfig,
|
||||
auth: {
|
||||
profiles: {
|
||||
"openai-codex:default": {
|
||||
provider: "openai-codex",
|
||||
mode: "oauth",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
modelProvider: "openai-codex",
|
||||
modelApi: "openai-codex-responses",
|
||||
});
|
||||
|
||||
expect(result.state).toBe("native_active");
|
||||
expect(result.codexMode).toBe("cached");
|
||||
});
|
||||
|
||||
it("falls back to managed_only when direct openai-codex auth is missing", () => {
|
||||
const result = resolveCodexNativeSearchActivation({
|
||||
config: baseConfig,
|
||||
modelProvider: "openai-codex",
|
||||
modelApi: "openai-codex-responses",
|
||||
});
|
||||
|
||||
expect(result.state).toBe("managed_only");
|
||||
expect(result.inactiveReason).toBe("codex_auth_missing");
|
||||
});
|
||||
|
||||
it("activates for api-compatible openai-codex-responses providers without separate Codex auth", () => {
|
||||
const result = resolveCodexNativeSearchActivation({
|
||||
config: baseConfig,
|
||||
modelProvider: "gateway",
|
||||
modelApi: "openai-codex-responses",
|
||||
});
|
||||
|
||||
expect(result.state).toBe("native_active");
|
||||
});
|
||||
|
||||
it("keeps all search disabled when global web search is disabled", () => {
|
||||
const result = resolveCodexNativeSearchActivation({
|
||||
config: {
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
enabled: false,
|
||||
openaiCodex: { enabled: true, mode: "live" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
modelProvider: "openai-codex",
|
||||
modelApi: "openai-codex-responses",
|
||||
});
|
||||
|
||||
expect(result.state).toBe("managed_only");
|
||||
expect(result.inactiveReason).toBe("globally_disabled");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Codex native web-search payload helpers", () => {
|
||||
it("normalizes optional config values", () => {
|
||||
const result = resolveCodexNativeWebSearchConfig({
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
openaiCodex: {
|
||||
enabled: true,
|
||||
allowedDomains: [" example.com ", "example.com", ""],
|
||||
contextSize: "high",
|
||||
userLocation: {
|
||||
country: " US ",
|
||||
city: " New York ",
|
||||
timezone: "America/New_York",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
enabled: true,
|
||||
mode: "cached",
|
||||
allowedDomains: ["example.com"],
|
||||
contextSize: "high",
|
||||
userLocation: {
|
||||
country: "US",
|
||||
city: "New York",
|
||||
timezone: "America/New_York",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("builds the native Responses web_search tool", () => {
|
||||
expect(
|
||||
buildCodexNativeWebSearchTool({
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
openaiCodex: {
|
||||
enabled: true,
|
||||
mode: "live",
|
||||
allowedDomains: ["example.com"],
|
||||
contextSize: "medium",
|
||||
userLocation: { country: "US" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
type: "web_search",
|
||||
external_web_access: true,
|
||||
filters: { allowed_domains: ["example.com"] },
|
||||
search_context_size: "medium",
|
||||
user_location: {
|
||||
type: "approximate",
|
||||
country: "US",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("injects native web_search into provider payloads", () => {
|
||||
const payload: Record<string, unknown> = { tools: [{ type: "function", name: "read" }] };
|
||||
const result = patchCodexNativeWebSearchPayload({ payload, config: baseConfig });
|
||||
|
||||
expect(result.status).toBe("injected");
|
||||
expect(payload.tools).toEqual([
|
||||
{ type: "function", name: "read" },
|
||||
{ type: "web_search", external_web_access: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not inject a duplicate native web_search tool", () => {
|
||||
const payload: Record<string, unknown> = { tools: [{ type: "web_search" }] };
|
||||
const result = patchCodexNativeWebSearchPayload({ payload, config: baseConfig });
|
||||
|
||||
expect(result.status).toBe("native_tool_already_present");
|
||||
expect(payload.tools).toEqual([{ type: "web_search" }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldSuppressManagedWebSearchTool", () => {
|
||||
it("suppresses managed web_search only when native Codex search is active", () => {
|
||||
expect(
|
||||
shouldSuppressManagedWebSearchTool({
|
||||
config: baseConfig,
|
||||
modelProvider: "gateway",
|
||||
modelApi: "openai-codex-responses",
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
shouldSuppressManagedWebSearchTool({
|
||||
config: baseConfig,
|
||||
modelProvider: "openai",
|
||||
modelApi: "openai-responses",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
300
src/agents/codex-native-web-search.ts
Normal file
300
src/agents/codex-native-web-search.ts
Normal file
@ -0,0 +1,300 @@
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { ensureAuthProfileStore, listProfilesForProvider } from "./auth-profiles.js";
|
||||
import { resolveDefaultModelForAgent } from "./model-selection.js";
|
||||
|
||||
export type CodexNativeSearchMode = "cached" | "live";
|
||||
export type CodexNativeSearchContextSize = "low" | "medium" | "high";
|
||||
|
||||
export type CodexNativeSearchUserLocation = {
|
||||
country?: string;
|
||||
region?: string;
|
||||
city?: string;
|
||||
timezone?: string;
|
||||
};
|
||||
|
||||
export type ResolvedCodexNativeWebSearchConfig = {
|
||||
enabled: boolean;
|
||||
mode: CodexNativeSearchMode;
|
||||
allowedDomains?: string[];
|
||||
contextSize?: CodexNativeSearchContextSize;
|
||||
userLocation?: CodexNativeSearchUserLocation;
|
||||
};
|
||||
|
||||
export type CodexNativeSearchActivation = {
|
||||
globalWebSearchEnabled: boolean;
|
||||
codexNativeEnabled: boolean;
|
||||
codexMode: CodexNativeSearchMode;
|
||||
nativeEligible: boolean;
|
||||
hasRequiredAuth: boolean;
|
||||
state: "managed_only" | "native_active";
|
||||
inactiveReason?:
|
||||
| "globally_disabled"
|
||||
| "codex_not_enabled"
|
||||
| "model_not_eligible"
|
||||
| "codex_auth_missing";
|
||||
};
|
||||
|
||||
export type CodexNativeSearchPayloadPatchResult = {
|
||||
status: "payload_not_object" | "native_tool_already_present" | "injected";
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function trimToUndefined(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function normalizeAllowedDomains(value: unknown): string[] | undefined {
|
||||
if (!Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const deduped = [
|
||||
...new Set(
|
||||
value
|
||||
.map((entry) => trimToUndefined(entry))
|
||||
.filter((entry): entry is string => typeof entry === "string"),
|
||||
),
|
||||
];
|
||||
return deduped.length > 0 ? deduped : undefined;
|
||||
}
|
||||
|
||||
function normalizeContextSize(value: unknown): CodexNativeSearchContextSize | undefined {
|
||||
if (value === "low" || value === "medium" || value === "high") {
|
||||
return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeMode(value: unknown): CodexNativeSearchMode {
|
||||
return value === "live" ? "live" : "cached";
|
||||
}
|
||||
|
||||
function normalizeUserLocation(value: unknown): CodexNativeSearchUserLocation | undefined {
|
||||
if (!isRecord(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const location = {
|
||||
country: trimToUndefined(value.country),
|
||||
region: trimToUndefined(value.region),
|
||||
city: trimToUndefined(value.city),
|
||||
timezone: trimToUndefined(value.timezone),
|
||||
};
|
||||
return location.country || location.region || location.city || location.timezone
|
||||
? location
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function resolveCodexNativeWebSearchConfig(
|
||||
config: OpenClawConfig | undefined,
|
||||
): ResolvedCodexNativeWebSearchConfig {
|
||||
const nativeConfig = config?.tools?.web?.search?.openaiCodex;
|
||||
return {
|
||||
enabled: nativeConfig?.enabled === true,
|
||||
mode: normalizeMode(nativeConfig?.mode),
|
||||
allowedDomains: normalizeAllowedDomains(nativeConfig?.allowedDomains),
|
||||
contextSize: normalizeContextSize(nativeConfig?.contextSize),
|
||||
userLocation: normalizeUserLocation(nativeConfig?.userLocation),
|
||||
};
|
||||
}
|
||||
|
||||
export function isCodexNativeSearchEligibleModel(params: {
|
||||
modelProvider?: string;
|
||||
modelApi?: string;
|
||||
}): boolean {
|
||||
return params.modelProvider === "openai-codex" || params.modelApi === "openai-codex-responses";
|
||||
}
|
||||
|
||||
export function hasCodexNativeWebSearchTool(tools: unknown): boolean {
|
||||
if (!Array.isArray(tools)) {
|
||||
return false;
|
||||
}
|
||||
return tools.some(
|
||||
(tool) => isRecord(tool) && typeof tool.type === "string" && tool.type === "web_search",
|
||||
);
|
||||
}
|
||||
|
||||
export function hasAvailableCodexAuth(params: {
|
||||
config?: OpenClawConfig;
|
||||
agentDir?: string;
|
||||
}): boolean {
|
||||
if (params.agentDir) {
|
||||
try {
|
||||
if (
|
||||
listProfilesForProvider(ensureAuthProfileStore(params.agentDir), "openai-codex").length > 0
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// Fall back to config-based detection below.
|
||||
}
|
||||
}
|
||||
|
||||
return Object.values(params.config?.auth?.profiles ?? {}).some(
|
||||
(profile) => isRecord(profile) && profile.provider === "openai-codex",
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveCodexNativeSearchActivation(params: {
|
||||
config?: OpenClawConfig;
|
||||
modelProvider?: string;
|
||||
modelApi?: string;
|
||||
agentDir?: string;
|
||||
}): CodexNativeSearchActivation {
|
||||
const globalWebSearchEnabled = params.config?.tools?.web?.search?.enabled !== false;
|
||||
const codexConfig = resolveCodexNativeWebSearchConfig(params.config);
|
||||
const nativeEligible = isCodexNativeSearchEligibleModel(params);
|
||||
const hasRequiredAuth = params.modelProvider !== "openai-codex" || hasAvailableCodexAuth(params);
|
||||
|
||||
if (!globalWebSearchEnabled) {
|
||||
return {
|
||||
globalWebSearchEnabled,
|
||||
codexNativeEnabled: codexConfig.enabled,
|
||||
codexMode: codexConfig.mode,
|
||||
nativeEligible,
|
||||
hasRequiredAuth,
|
||||
state: "managed_only",
|
||||
inactiveReason: "globally_disabled",
|
||||
};
|
||||
}
|
||||
|
||||
if (!codexConfig.enabled) {
|
||||
return {
|
||||
globalWebSearchEnabled,
|
||||
codexNativeEnabled: false,
|
||||
codexMode: codexConfig.mode,
|
||||
nativeEligible,
|
||||
hasRequiredAuth,
|
||||
state: "managed_only",
|
||||
inactiveReason: "codex_not_enabled",
|
||||
};
|
||||
}
|
||||
|
||||
if (!nativeEligible) {
|
||||
return {
|
||||
globalWebSearchEnabled,
|
||||
codexNativeEnabled: true,
|
||||
codexMode: codexConfig.mode,
|
||||
nativeEligible: false,
|
||||
hasRequiredAuth,
|
||||
state: "managed_only",
|
||||
inactiveReason: "model_not_eligible",
|
||||
};
|
||||
}
|
||||
|
||||
if (!hasRequiredAuth) {
|
||||
return {
|
||||
globalWebSearchEnabled,
|
||||
codexNativeEnabled: true,
|
||||
codexMode: codexConfig.mode,
|
||||
nativeEligible: true,
|
||||
hasRequiredAuth: false,
|
||||
state: "managed_only",
|
||||
inactiveReason: "codex_auth_missing",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
globalWebSearchEnabled,
|
||||
codexNativeEnabled: true,
|
||||
codexMode: codexConfig.mode,
|
||||
nativeEligible: true,
|
||||
hasRequiredAuth: true,
|
||||
state: "native_active",
|
||||
};
|
||||
}
|
||||
|
||||
export function buildCodexNativeWebSearchTool(
|
||||
config: OpenClawConfig | undefined,
|
||||
): Record<string, unknown> {
|
||||
const nativeConfig = resolveCodexNativeWebSearchConfig(config);
|
||||
const tool: Record<string, unknown> = {
|
||||
type: "web_search",
|
||||
external_web_access: nativeConfig.mode === "live",
|
||||
};
|
||||
|
||||
if (nativeConfig.allowedDomains) {
|
||||
tool.filters = {
|
||||
allowed_domains: nativeConfig.allowedDomains,
|
||||
};
|
||||
}
|
||||
|
||||
if (nativeConfig.contextSize) {
|
||||
tool.search_context_size = nativeConfig.contextSize;
|
||||
}
|
||||
|
||||
if (nativeConfig.userLocation) {
|
||||
tool.user_location = {
|
||||
type: "approximate",
|
||||
...nativeConfig.userLocation,
|
||||
};
|
||||
}
|
||||
|
||||
return tool;
|
||||
}
|
||||
|
||||
export function patchCodexNativeWebSearchPayload(params: {
|
||||
payload: unknown;
|
||||
config?: OpenClawConfig;
|
||||
}): CodexNativeSearchPayloadPatchResult {
|
||||
if (!isRecord(params.payload)) {
|
||||
return { status: "payload_not_object" };
|
||||
}
|
||||
|
||||
const payload = params.payload;
|
||||
if (hasCodexNativeWebSearchTool(payload.tools)) {
|
||||
return { status: "native_tool_already_present" };
|
||||
}
|
||||
|
||||
const tools = Array.isArray(payload.tools) ? [...payload.tools] : [];
|
||||
tools.push(buildCodexNativeWebSearchTool(params.config));
|
||||
payload.tools = tools;
|
||||
return { status: "injected" };
|
||||
}
|
||||
|
||||
export function shouldSuppressManagedWebSearchTool(params: {
|
||||
config?: OpenClawConfig;
|
||||
modelProvider?: string;
|
||||
modelApi?: string;
|
||||
agentDir?: string;
|
||||
}): boolean {
|
||||
return resolveCodexNativeSearchActivation(params).state === "native_active";
|
||||
}
|
||||
|
||||
export function isCodexNativeWebSearchRelevant(params: {
|
||||
config: OpenClawConfig;
|
||||
agentId?: string;
|
||||
agentDir?: string;
|
||||
}): boolean {
|
||||
if (resolveCodexNativeWebSearchConfig(params.config).enabled) {
|
||||
return true;
|
||||
}
|
||||
if (hasAvailableCodexAuth(params)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const defaultModel = resolveDefaultModelForAgent({
|
||||
cfg: params.config,
|
||||
agentId: params.agentId,
|
||||
});
|
||||
const configuredProvider = params.config.models?.providers?.[defaultModel.provider];
|
||||
return isCodexNativeSearchEligibleModel({
|
||||
modelProvider: defaultModel.provider,
|
||||
modelApi: configuredProvider?.api,
|
||||
});
|
||||
}
|
||||
|
||||
export function describeCodexNativeWebSearch(
|
||||
config: OpenClawConfig | undefined,
|
||||
): string | undefined {
|
||||
const nativeConfig = resolveCodexNativeWebSearchConfig(config);
|
||||
if (!nativeConfig.enabled) {
|
||||
return undefined;
|
||||
}
|
||||
return `Codex native search: ${nativeConfig.mode} for Codex-capable models`;
|
||||
}
|
||||
@ -1193,6 +1193,98 @@ describe("applyExtraParamsToAgent", () => {
|
||||
expect(calls[0]?.openaiWsWarmup).toBe(false);
|
||||
});
|
||||
|
||||
it("injects native Codex web_search for api-compatible Responses models", () => {
|
||||
const payload = runResponsesPayloadMutationCase({
|
||||
applyProvider: "gateway",
|
||||
applyModelId: "gpt-5.4",
|
||||
cfg: {
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
enabled: true,
|
||||
openaiCodex: {
|
||||
enabled: true,
|
||||
mode: "live",
|
||||
allowedDomains: ["example.com"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
model: {
|
||||
api: "openai-codex-responses",
|
||||
provider: "gateway",
|
||||
id: "gpt-5.4",
|
||||
} as Model<"openai-codex-responses">,
|
||||
payload: { tools: [{ type: "function", name: "read" }] },
|
||||
});
|
||||
|
||||
expect(payload.tools).toEqual([
|
||||
{ type: "function", name: "read" },
|
||||
{
|
||||
type: "web_search",
|
||||
external_web_access: true,
|
||||
filters: { allowed_domains: ["example.com"] },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not inject duplicate native Codex web_search tools", () => {
|
||||
const payload = runResponsesPayloadMutationCase({
|
||||
applyProvider: "gateway",
|
||||
applyModelId: "gpt-5.4",
|
||||
cfg: {
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
enabled: true,
|
||||
openaiCodex: {
|
||||
enabled: true,
|
||||
mode: "cached",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
model: {
|
||||
api: "openai-codex-responses",
|
||||
provider: "gateway",
|
||||
id: "gpt-5.4",
|
||||
} as Model<"openai-codex-responses">,
|
||||
payload: { tools: [{ type: "web_search" }] },
|
||||
});
|
||||
|
||||
expect(payload.tools).toEqual([{ type: "web_search" }]);
|
||||
});
|
||||
|
||||
it("keeps payload unchanged when Codex native search is inactive", () => {
|
||||
const payload = runResponsesPayloadMutationCase({
|
||||
applyProvider: "openai",
|
||||
applyModelId: "gpt-5",
|
||||
cfg: {
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
enabled: true,
|
||||
openaiCodex: {
|
||||
enabled: true,
|
||||
mode: "cached",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
model: {
|
||||
api: "openai-responses",
|
||||
provider: "openai",
|
||||
id: "gpt-5",
|
||||
} as Model<"openai-responses">,
|
||||
payload: { tools: [{ type: "function", name: "read" }] },
|
||||
});
|
||||
|
||||
expect(payload.tools).toEqual([{ type: "function", name: "read" }]);
|
||||
});
|
||||
|
||||
it("lets runtime options override OpenAI default transport", () => {
|
||||
const { calls, agent } = createOptionsCaptureAgent();
|
||||
|
||||
|
||||
@ -552,6 +552,7 @@ export async function compactEmbeddedPiSessionDirect(
|
||||
abortSignal: runAbortController.signal,
|
||||
modelProvider: model.provider,
|
||||
modelId,
|
||||
modelApi: model.api,
|
||||
modelContextWindowTokens: ctxInfo.tokens,
|
||||
modelAuthMode: resolveModelAuthMode(model.provider, params.config),
|
||||
});
|
||||
|
||||
@ -23,6 +23,7 @@ import {
|
||||
} from "./moonshot-stream-wrappers.js";
|
||||
import {
|
||||
createCodexDefaultTransportWrapper,
|
||||
createCodexNativeWebSearchWrapper,
|
||||
createOpenAIDefaultTransportWrapper,
|
||||
createOpenAIFastModeWrapper,
|
||||
createOpenAIResponsesContextManagementWrapper,
|
||||
@ -335,6 +336,7 @@ export function applyExtraParamsToAgent(
|
||||
extraParamsOverride?: Record<string, unknown>,
|
||||
thinkingLevel?: ThinkLevel,
|
||||
agentId?: string,
|
||||
agentDir?: string,
|
||||
): void {
|
||||
const resolvedExtraParams = resolveExtraParams({
|
||||
cfg,
|
||||
@ -459,6 +461,11 @@ export function applyExtraParamsToAgent(
|
||||
agent.streamFn = createOpenAIServiceTierWrapper(agent.streamFn, openAIServiceTier);
|
||||
}
|
||||
|
||||
agent.streamFn = createCodexNativeWebSearchWrapper(agent.streamFn, {
|
||||
config: cfg,
|
||||
agentDir,
|
||||
});
|
||||
|
||||
// Work around upstream pi-ai hardcoding `store: false` for Responses API.
|
||||
// Force `store=true` for direct OpenAI Responses models and auto-enable
|
||||
// server-side compaction for compatible OpenAI Responses payloads.
|
||||
|
||||
@ -1,6 +1,11 @@
|
||||
import type { StreamFn } from "@mariozechner/pi-agent-core";
|
||||
import type { SimpleStreamOptions } from "@mariozechner/pi-ai";
|
||||
import { streamSimple } from "@mariozechner/pi-ai";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import {
|
||||
patchCodexNativeWebSearchPayload,
|
||||
resolveCodexNativeSearchActivation,
|
||||
} from "../codex-native-web-search.js";
|
||||
import { log } from "./logger.js";
|
||||
import { streamWithPayloadPatch } from "./stream-payload-utils.js";
|
||||
|
||||
@ -334,6 +339,59 @@ export function createOpenAIServiceTierWrapper(
|
||||
};
|
||||
}
|
||||
|
||||
export function createCodexNativeWebSearchWrapper(
|
||||
baseStreamFn: StreamFn | undefined,
|
||||
params: { config?: OpenClawConfig; agentDir?: string },
|
||||
): StreamFn {
|
||||
const underlying = baseStreamFn ?? streamSimple;
|
||||
return (model, context, options) => {
|
||||
const activation = resolveCodexNativeSearchActivation({
|
||||
config: params.config,
|
||||
modelProvider: typeof model.provider === "string" ? model.provider : undefined,
|
||||
modelApi: typeof model.api === "string" ? model.api : undefined,
|
||||
agentDir: params.agentDir,
|
||||
});
|
||||
|
||||
if (activation.state !== "native_active") {
|
||||
if (activation.codexNativeEnabled) {
|
||||
log.debug(
|
||||
`skipping Codex native web search (${activation.inactiveReason ?? "inactive"}) for ${String(
|
||||
model.provider ?? "unknown",
|
||||
)}/${String(model.id ?? "unknown")}`,
|
||||
);
|
||||
}
|
||||
return underlying(model, context, options);
|
||||
}
|
||||
|
||||
log.debug(
|
||||
`activating Codex native web search (${activation.codexMode}) for ${String(
|
||||
model.provider ?? "unknown",
|
||||
)}/${String(model.id ?? "unknown")}`,
|
||||
);
|
||||
|
||||
const originalOnPayload = options?.onPayload;
|
||||
return underlying(model, context, {
|
||||
...options,
|
||||
onPayload: (payload) => {
|
||||
const result = patchCodexNativeWebSearchPayload({
|
||||
payload,
|
||||
config: params.config,
|
||||
});
|
||||
if (result.status === "payload_not_object") {
|
||||
log.debug(
|
||||
"Skipping Codex native web search injection because provider payload is not an object",
|
||||
);
|
||||
} else if (result.status === "native_tool_already_present") {
|
||||
log.debug("Codex native web search tool already present in provider payload");
|
||||
} else if (result.status === "injected") {
|
||||
log.debug("Injected Codex native web search tool into provider payload");
|
||||
}
|
||||
return originalOnPayload?.(payload, model);
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function createCodexDefaultTransportWrapper(baseStreamFn: StreamFn | undefined): StreamFn {
|
||||
const underlying = baseStreamFn ?? streamSimple;
|
||||
return (model, context, options) =>
|
||||
|
||||
@ -1512,6 +1512,7 @@ export async function runEmbeddedAttempt(
|
||||
abortSignal: runAbortController.signal,
|
||||
modelProvider: params.model.provider,
|
||||
modelId: params.modelId,
|
||||
modelApi: params.model.api,
|
||||
modelContextWindowTokens: params.model.contextWindow,
|
||||
modelAuthMode: resolveModelAuthMode(params.model.provider, params.config),
|
||||
currentChannelId: params.currentChannelId,
|
||||
@ -1938,6 +1939,7 @@ export async function runEmbeddedAttempt(
|
||||
},
|
||||
params.thinkLevel,
|
||||
sessionAgentId,
|
||||
agentDir,
|
||||
);
|
||||
|
||||
if (cacheTrace) {
|
||||
|
||||
@ -39,4 +39,72 @@ describe("applyModelProviderToolPolicy", () => {
|
||||
|
||||
expect(toolNames(filtered)).toEqual(["read", "exec"]);
|
||||
});
|
||||
|
||||
it("removes managed web_search when native Codex search is active", () => {
|
||||
const filtered = __testing.applyModelProviderToolPolicy(baseTools, {
|
||||
config: {
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
enabled: true,
|
||||
openaiCodex: { enabled: true, mode: "cached" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
modelProvider: "gateway",
|
||||
modelApi: "openai-codex-responses",
|
||||
modelId: "gpt-5.4",
|
||||
});
|
||||
|
||||
expect(toolNames(filtered)).toEqual(["read", "exec"]);
|
||||
});
|
||||
|
||||
it("removes managed web_search for direct Codex models when auth is available", () => {
|
||||
const filtered = __testing.applyModelProviderToolPolicy(baseTools, {
|
||||
config: {
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
enabled: true,
|
||||
openaiCodex: { enabled: true, mode: "cached" },
|
||||
},
|
||||
},
|
||||
},
|
||||
auth: {
|
||||
profiles: {
|
||||
"openai-codex:default": {
|
||||
provider: "openai-codex",
|
||||
mode: "oauth",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
modelProvider: "openai-codex",
|
||||
modelApi: "openai-codex-responses",
|
||||
modelId: "gpt-5.4",
|
||||
});
|
||||
|
||||
expect(toolNames(filtered)).toEqual(["read", "exec"]);
|
||||
});
|
||||
|
||||
it("keeps managed web_search when Codex native search cannot activate", () => {
|
||||
const filtered = __testing.applyModelProviderToolPolicy(baseTools, {
|
||||
config: {
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
enabled: true,
|
||||
openaiCodex: { enabled: true, mode: "cached" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
modelProvider: "openai-codex",
|
||||
modelApi: "openai-codex-responses",
|
||||
modelId: "gpt-5.4",
|
||||
});
|
||||
|
||||
expect(toolNames(filtered)).toEqual(["read", "web_search", "exec"]);
|
||||
});
|
||||
});
|
||||
|
||||
@ -15,6 +15,7 @@ import {
|
||||
type ProcessToolDefaults,
|
||||
} from "./bash-tools.js";
|
||||
import { listChannelAgentTools } from "./channel-tools.js";
|
||||
import { shouldSuppressManagedWebSearchTool } from "./codex-native-web-search.js";
|
||||
import { resolveImageSanitizationLimits } from "./image-sanitization.js";
|
||||
import type { ModelAuthMode } from "./model-auth.js";
|
||||
import { createOpenClawTools } from "./openclaw-tools.js";
|
||||
@ -92,14 +93,32 @@ function applyMessageProviderToolPolicy(
|
||||
|
||||
function applyModelProviderToolPolicy(
|
||||
tools: AnyAgentTool[],
|
||||
params?: { modelProvider?: string; modelId?: string },
|
||||
params?: {
|
||||
config?: OpenClawConfig;
|
||||
modelProvider?: string;
|
||||
modelApi?: string;
|
||||
modelId?: string;
|
||||
agentDir?: string;
|
||||
},
|
||||
): AnyAgentTool[] {
|
||||
if (!isXaiProvider(params?.modelProvider, params?.modelId)) {
|
||||
return tools;
|
||||
if (isXaiProvider(params?.modelProvider, params?.modelId)) {
|
||||
// xAI/Grok providers expose a native web_search tool; sending OpenClaw's
|
||||
// web_search alongside it causes duplicate-name request failures.
|
||||
return tools.filter((tool) => !TOOL_DENY_FOR_XAI_PROVIDERS.has(tool.name));
|
||||
}
|
||||
// xAI/Grok providers expose a native web_search tool; sending OpenClaw's
|
||||
// web_search alongside it causes duplicate-name request failures.
|
||||
return tools.filter((tool) => !TOOL_DENY_FOR_XAI_PROVIDERS.has(tool.name));
|
||||
|
||||
if (
|
||||
shouldSuppressManagedWebSearchTool({
|
||||
config: params?.config,
|
||||
modelProvider: params?.modelProvider,
|
||||
modelApi: params?.modelApi,
|
||||
agentDir: params?.agentDir,
|
||||
})
|
||||
) {
|
||||
return tools.filter((tool) => tool.name !== "web_search");
|
||||
}
|
||||
|
||||
return tools;
|
||||
}
|
||||
|
||||
function isApplyPatchAllowedForModel(params: {
|
||||
@ -230,6 +249,8 @@ export function createOpenClawCodingTools(options?: {
|
||||
modelProvider?: string;
|
||||
/** Model id for the current provider (used for model-specific tool gating). */
|
||||
modelId?: string;
|
||||
/** Model API for the current provider (used for provider-native tool arbitration). */
|
||||
modelApi?: string;
|
||||
/** Model context window in tokens (used to scale read-tool output budget). */
|
||||
modelContextWindowTokens?: number;
|
||||
/**
|
||||
@ -562,8 +583,11 @@ export function createOpenClawCodingTools(options?: {
|
||||
options?.messageProvider,
|
||||
);
|
||||
const toolsForModelProvider = applyModelProviderToolPolicy(toolsForMessageProvider, {
|
||||
config: options?.config,
|
||||
modelProvider: options?.modelProvider,
|
||||
modelApi: options?.modelApi,
|
||||
modelId: options?.modelId,
|
||||
agentDir: options?.agentDir,
|
||||
});
|
||||
// Security: treat unknown/undefined as unauthorized (opt-in, not opt-out)
|
||||
const senderIsOwner = options?.senderIsOwner === true;
|
||||
|
||||
@ -115,6 +115,15 @@ export async function applyAuthChoiceOpenAI(
|
||||
agentModelOverride = OPENAI_CODEX_DEFAULT_MODEL;
|
||||
await noteAgentModel(OPENAI_CODEX_DEFAULT_MODEL);
|
||||
}
|
||||
await params.prompter.note(
|
||||
[
|
||||
"Codex-capable models can optionally use native Codex web search.",
|
||||
"Enable it with openclaw configure --section web.",
|
||||
"Recommended mode: cached.",
|
||||
"Docs: https://docs.openclaw.ai/tools/web",
|
||||
].join("\n"),
|
||||
"Web search",
|
||||
);
|
||||
}
|
||||
return { config: nextConfig, agentModelOverride };
|
||||
}
|
||||
|
||||
@ -164,7 +164,8 @@ describe("applyAuthChoice", () => {
|
||||
expires: Date.now() + 60_000,
|
||||
});
|
||||
|
||||
const prompter = createPrompter({});
|
||||
const note = vi.fn(async () => {});
|
||||
const prompter = createPrompter({ note });
|
||||
const runtime = createExitThrowingRuntime();
|
||||
|
||||
const result = await applyAuthChoice({
|
||||
@ -187,6 +188,10 @@ describe("applyAuthChoice", () => {
|
||||
access: "access-token",
|
||||
email: "user@example.com",
|
||||
});
|
||||
expect(note).toHaveBeenCalledWith(
|
||||
expect.stringContaining("native Codex web search"),
|
||||
"Web search",
|
||||
);
|
||||
});
|
||||
|
||||
it("prompts and writes provider API key for common providers", async () => {
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
@ -99,6 +99,11 @@ import { WizardCancelledError } from "../wizard/prompts.js";
|
||||
import { runConfigureWizard } from "./configure.wizard.js";
|
||||
|
||||
describe("runConfigureWizard", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.ensureControlUiAssetsBuilt.mockResolvedValue({ ok: true });
|
||||
});
|
||||
|
||||
it("persists gateway.mode=local when only the run mode is selected", async () => {
|
||||
mocks.readConfigFileSnapshot.mockResolvedValue({
|
||||
exists: false,
|
||||
@ -158,4 +163,116 @@ describe("runConfigureWizard", () => {
|
||||
|
||||
expect(runtime.exit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it("can enable native Codex search without configuring a managed provider", async () => {
|
||||
mocks.readConfigFileSnapshot.mockResolvedValue({
|
||||
exists: false,
|
||||
valid: true,
|
||||
config: {
|
||||
auth: {
|
||||
profiles: {
|
||||
"openai-codex:default": {
|
||||
provider: "openai-codex",
|
||||
mode: "oauth",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
issues: [],
|
||||
});
|
||||
mocks.resolveGatewayPort.mockReturnValue(18789);
|
||||
mocks.probeGatewayReachable.mockResolvedValue({ ok: false });
|
||||
mocks.resolveControlUiLinks.mockReturnValue({ wsUrl: "ws://127.0.0.1:18789" });
|
||||
mocks.summarizeExistingConfig.mockReturnValue("");
|
||||
mocks.createClackPrompter.mockReturnValue({});
|
||||
mocks.clackSelect.mockResolvedValueOnce("local").mockResolvedValueOnce("cached");
|
||||
mocks.clackConfirm
|
||||
.mockResolvedValueOnce(true)
|
||||
.mockResolvedValueOnce(true)
|
||||
.mockResolvedValueOnce(false)
|
||||
.mockResolvedValueOnce(true);
|
||||
mocks.clackText.mockResolvedValue("");
|
||||
|
||||
await runConfigureWizard(
|
||||
{ command: "configure", sections: ["web"] },
|
||||
{
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
},
|
||||
);
|
||||
|
||||
expect(mocks.writeConfigFile).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
tools: expect.objectContaining({
|
||||
web: expect.objectContaining({
|
||||
search: expect.objectContaining({
|
||||
enabled: true,
|
||||
openaiCodex: expect.objectContaining({
|
||||
enabled: true,
|
||||
mode: "cached",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("persists openaiCodex.enabled=false when the toggle is disabled", async () => {
|
||||
mocks.readConfigFileSnapshot.mockResolvedValue({
|
||||
exists: false,
|
||||
valid: true,
|
||||
config: {
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
enabled: true,
|
||||
openaiCodex: {
|
||||
enabled: true,
|
||||
mode: "live",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
issues: [],
|
||||
});
|
||||
mocks.resolveGatewayPort.mockReturnValue(18789);
|
||||
mocks.probeGatewayReachable.mockResolvedValue({ ok: false });
|
||||
mocks.resolveControlUiLinks.mockReturnValue({ wsUrl: "ws://127.0.0.1:18789" });
|
||||
mocks.summarizeExistingConfig.mockReturnValue("");
|
||||
mocks.createClackPrompter.mockReturnValue({});
|
||||
mocks.clackSelect.mockResolvedValueOnce("local").mockResolvedValueOnce("brave");
|
||||
mocks.clackConfirm
|
||||
.mockResolvedValueOnce(true)
|
||||
.mockResolvedValueOnce(false)
|
||||
.mockResolvedValueOnce(true);
|
||||
mocks.clackText.mockResolvedValue("");
|
||||
|
||||
await runConfigureWizard(
|
||||
{ command: "configure", sections: ["web"] },
|
||||
{
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
},
|
||||
);
|
||||
|
||||
expect(mocks.writeConfigFile).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
tools: expect.objectContaining({
|
||||
web: expect.objectContaining({
|
||||
search: expect.objectContaining({
|
||||
enabled: true,
|
||||
openaiCodex: expect.objectContaining({
|
||||
enabled: false,
|
||||
mode: "live",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -173,6 +173,8 @@ async function promptWebToolsConfig(
|
||||
applySearchKey,
|
||||
hasKeyInEnv,
|
||||
} = await import("./onboard-search.js");
|
||||
const { describeCodexNativeWebSearch, isCodexNativeWebSearchRelevant } =
|
||||
await import("../agents/codex-native-web-search.js");
|
||||
type SP = (typeof SEARCH_PROVIDER_OPTIONS)[number]["value"];
|
||||
|
||||
const hasKeyForProvider = (provider: string): boolean => {
|
||||
@ -197,7 +199,7 @@ async function promptWebToolsConfig(
|
||||
note(
|
||||
[
|
||||
"Web search lets your agent look things up online using the `web_search` tool.",
|
||||
"Choose a provider and paste your API key.",
|
||||
"Choose a managed provider now, and Codex-capable models can also use native Codex web search.",
|
||||
"Docs: https://docs.openclaw.ai/tools/web",
|
||||
].join("\n"),
|
||||
"Web search",
|
||||
@ -218,62 +220,137 @@ async function promptWebToolsConfig(
|
||||
};
|
||||
|
||||
if (enableSearch) {
|
||||
const providerOptions = SEARCH_PROVIDER_OPTIONS.map((entry) => {
|
||||
const configured = hasKeyForProvider(entry.value);
|
||||
return {
|
||||
value: entry.value,
|
||||
label: entry.label,
|
||||
hint: configured ? `${entry.hint} · configured` : entry.hint,
|
||||
};
|
||||
});
|
||||
|
||||
const providerChoice = guardCancel(
|
||||
await select({
|
||||
message: "Choose web search provider",
|
||||
options: providerOptions,
|
||||
initialValue: existingProvider,
|
||||
}),
|
||||
runtime,
|
||||
);
|
||||
|
||||
nextSearch = { ...nextSearch, provider: providerChoice };
|
||||
|
||||
const entry = SEARCH_PROVIDER_OPTIONS.find((e) => e.value === providerChoice)!;
|
||||
const existingKey = resolveExistingKey(nextConfig, providerChoice as SP);
|
||||
const keyConfigured = hasExistingKey(nextConfig, providerChoice as SP);
|
||||
const envAvailable = entry.envKeys.some((k) => Boolean(process.env[k]?.trim()));
|
||||
const envVarNames = entry.envKeys.join(" / ");
|
||||
|
||||
const keyInput = guardCancel(
|
||||
await text({
|
||||
message: keyConfigured
|
||||
? envAvailable
|
||||
? `${entry.label} API key (leave blank to keep current or use ${envVarNames})`
|
||||
: `${entry.label} API key (leave blank to keep current)`
|
||||
: envAvailable
|
||||
? `${entry.label} API key (paste it here; leave blank to use ${envVarNames})`
|
||||
: `${entry.label} API key`,
|
||||
placeholder: keyConfigured ? "Leave blank to keep current" : entry.placeholder,
|
||||
}),
|
||||
runtime,
|
||||
);
|
||||
const key = String(keyInput ?? "").trim();
|
||||
|
||||
if (key || existingKey) {
|
||||
const applied = applySearchKey(nextConfig, providerChoice as SP, (key || existingKey)!);
|
||||
nextSearch = { ...applied.tools?.web?.search };
|
||||
} else if (keyConfigured || envAvailable) {
|
||||
nextSearch = { ...nextSearch };
|
||||
} else {
|
||||
const codexRelevant = isCodexNativeWebSearchRelevant({ config: nextConfig });
|
||||
let configureManagedProvider = true;
|
||||
if (codexRelevant) {
|
||||
note(
|
||||
[
|
||||
"No key stored yet — web_search won't work until a key is available.",
|
||||
`Store a key here or set ${envVarNames} in the Gateway environment.`,
|
||||
`Get your API key at: ${entry.signupUrl}`,
|
||||
"Docs: https://docs.openclaw.ai/tools/web",
|
||||
"Codex-capable models can optionally use native Codex web search.",
|
||||
"Managed web_search still controls non-Codex models.",
|
||||
"If no managed provider is configured, non-Codex models still rely on provider auto-detect and may have no search available.",
|
||||
...(describeCodexNativeWebSearch(nextConfig)
|
||||
? [describeCodexNativeWebSearch(nextConfig)!]
|
||||
: ["Recommended mode: cached."]),
|
||||
].join("\n"),
|
||||
"Web search",
|
||||
"Codex native search",
|
||||
);
|
||||
const enableCodexNative = guardCancel(
|
||||
await confirm({
|
||||
message: "Enable native Codex web search for Codex-capable models?",
|
||||
initialValue: existingSearch?.openaiCodex?.enabled === true,
|
||||
}),
|
||||
runtime,
|
||||
);
|
||||
if (enableCodexNative) {
|
||||
const codexMode = guardCancel(
|
||||
await select({
|
||||
message: "Codex native web search mode",
|
||||
options: [
|
||||
{
|
||||
value: "cached",
|
||||
label: "cached (recommended)",
|
||||
hint: "Uses cached web content",
|
||||
},
|
||||
{
|
||||
value: "live",
|
||||
label: "live",
|
||||
hint: "Allows live external web access",
|
||||
},
|
||||
],
|
||||
initialValue: existingSearch?.openaiCodex?.mode ?? "cached",
|
||||
}),
|
||||
runtime,
|
||||
);
|
||||
nextSearch = {
|
||||
...nextSearch,
|
||||
openaiCodex: {
|
||||
...existingSearch?.openaiCodex,
|
||||
enabled: true,
|
||||
mode: codexMode,
|
||||
},
|
||||
};
|
||||
configureManagedProvider = guardCancel(
|
||||
await confirm({
|
||||
message: "Configure or change a managed web search provider now?",
|
||||
initialValue: Boolean(existingProvider),
|
||||
}),
|
||||
runtime,
|
||||
);
|
||||
} else {
|
||||
nextSearch = {
|
||||
...nextSearch,
|
||||
openaiCodex: {
|
||||
...existingSearch?.openaiCodex,
|
||||
enabled: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (configureManagedProvider) {
|
||||
const providerOptions = SEARCH_PROVIDER_OPTIONS.map((entry) => {
|
||||
const configured = hasKeyForProvider(entry.value);
|
||||
return {
|
||||
value: entry.value,
|
||||
label: entry.label,
|
||||
hint: configured ? `${entry.hint} · configured` : entry.hint,
|
||||
};
|
||||
});
|
||||
|
||||
const providerChoice = guardCancel(
|
||||
await select({
|
||||
message: "Choose web search provider",
|
||||
options: providerOptions,
|
||||
initialValue: existingProvider,
|
||||
}),
|
||||
runtime,
|
||||
);
|
||||
|
||||
nextSearch = { ...nextSearch, provider: providerChoice };
|
||||
|
||||
const entry = SEARCH_PROVIDER_OPTIONS.find((e) => e.value === providerChoice)!;
|
||||
const existingKey = resolveExistingKey(nextConfig, providerChoice as SP);
|
||||
const keyConfigured = hasExistingKey(nextConfig, providerChoice as SP);
|
||||
const envAvailable = entry.envKeys.some((k) => Boolean(process.env[k]?.trim()));
|
||||
const envVarNames = entry.envKeys.join(" / ");
|
||||
|
||||
const keyInput = guardCancel(
|
||||
await text({
|
||||
message: keyConfigured
|
||||
? envAvailable
|
||||
? `${entry.label} API key (leave blank to keep current or use ${envVarNames})`
|
||||
: `${entry.label} API key (leave blank to keep current)`
|
||||
: envAvailable
|
||||
? `${entry.label} API key (paste it here; leave blank to use ${envVarNames})`
|
||||
: `${entry.label} API key`,
|
||||
placeholder: keyConfigured ? "Leave blank to keep current" : entry.placeholder,
|
||||
}),
|
||||
runtime,
|
||||
);
|
||||
const key = String(keyInput ?? "").trim();
|
||||
|
||||
if (key || existingKey) {
|
||||
const applied = applySearchKey(nextConfig, providerChoice as SP, (key || existingKey)!);
|
||||
nextSearch = {
|
||||
...applied.tools?.web?.search,
|
||||
openaiCodex: {
|
||||
...existingSearch?.openaiCodex,
|
||||
...(nextSearch.openaiCodex as Record<string, unknown> | undefined),
|
||||
},
|
||||
};
|
||||
} else if (keyConfigured || envAvailable) {
|
||||
nextSearch = { ...nextSearch };
|
||||
} else {
|
||||
note(
|
||||
[
|
||||
"No key stored yet — web_search won't work until a key is available.",
|
||||
`Store a key here or set ${envVarNames} in the Gateway environment.`,
|
||||
`Get your API key at: ${entry.signupUrl}`,
|
||||
"Docs: https://docs.openclaw.ai/tools/web",
|
||||
].join("\n"),
|
||||
"Web search",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -185,6 +185,9 @@ describe("modelsAuthLoginCommand", () => {
|
||||
expect(runtime.log).toHaveBeenCalledWith(
|
||||
"Default model available: openai-codex/gpt-5.4 (use --set-default to apply)",
|
||||
);
|
||||
expect(runtime.log).toHaveBeenCalledWith(
|
||||
"Tip: Codex-capable models can use native Codex web search. Enable it with openclaw configure --section web (recommended mode: cached). Docs: https://docs.openclaw.ai/tools/web",
|
||||
);
|
||||
});
|
||||
|
||||
it("applies openai-codex default model when --set-default is used", async () => {
|
||||
|
||||
@ -340,6 +340,9 @@ async function runBuiltInOpenAICodexLogin(params: {
|
||||
`Default model available: ${OPENAI_CODEX_DEFAULT_MODEL} (use --set-default to apply)`,
|
||||
);
|
||||
}
|
||||
params.runtime.log(
|
||||
"Tip: Codex-capable models can use native Codex web search. Enable it with openclaw configure --section web (recommended mode: cached). Docs: https://docs.openclaw.ai/tools/web",
|
||||
);
|
||||
}
|
||||
|
||||
export async function modelsAuthLoginCommand(opts: LoginOptions, runtime: RuntimeEnv) {
|
||||
|
||||
@ -12,11 +12,21 @@ const runtime: RuntimeEnv = {
|
||||
}) as RuntimeEnv["exit"],
|
||||
};
|
||||
|
||||
function createPrompter(params: { selectValue?: string; textValue?: string }): {
|
||||
function createPrompter(params: {
|
||||
selectValue?: string;
|
||||
selectValues?: string[];
|
||||
textValue?: string;
|
||||
textValues?: string[];
|
||||
confirmValue?: boolean;
|
||||
confirmValues?: boolean[];
|
||||
}): {
|
||||
prompter: WizardPrompter;
|
||||
notes: Array<{ title?: string; message: string }>;
|
||||
} {
|
||||
const notes: Array<{ title?: string; message: string }> = [];
|
||||
const selectQueue = [...(params.selectValues ?? [])];
|
||||
const textQueue = [...(params.textValues ?? [])];
|
||||
const confirmQueue = [...(params.confirmValues ?? [])];
|
||||
const prompter: WizardPrompter = {
|
||||
intro: vi.fn(async () => {}),
|
||||
outro: vi.fn(async () => {}),
|
||||
@ -24,11 +34,11 @@ function createPrompter(params: { selectValue?: string; textValue?: string }): {
|
||||
notes.push({ title, message });
|
||||
}),
|
||||
select: vi.fn(
|
||||
async () => params.selectValue ?? "perplexity",
|
||||
async () => selectQueue.shift() ?? params.selectValue ?? "perplexity",
|
||||
) as unknown as WizardPrompter["select"],
|
||||
multiselect: vi.fn(async () => []) as unknown as WizardPrompter["multiselect"],
|
||||
text: vi.fn(async () => params.textValue ?? ""),
|
||||
confirm: vi.fn(async () => true),
|
||||
text: vi.fn(async () => textQueue.shift() ?? params.textValue ?? ""),
|
||||
confirm: vi.fn(async () => confirmQueue.shift() ?? params.confirmValue ?? true),
|
||||
progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })),
|
||||
};
|
||||
return { prompter, notes };
|
||||
@ -73,11 +83,12 @@ async function runQuickstartPerplexitySetup(
|
||||
}
|
||||
|
||||
describe("setupSearch", () => {
|
||||
it("returns config unchanged when user skips", async () => {
|
||||
it("lets users skip provider setup after enabling web_search", async () => {
|
||||
const cfg: OpenClawConfig = {};
|
||||
const { prompter } = createPrompter({ selectValue: "__skip__" });
|
||||
const result = await setupSearch(cfg, runtime, prompter);
|
||||
expect(result).toBe(cfg);
|
||||
expect(result.tools?.web?.search?.enabled).toBe(true);
|
||||
expect(result.tools?.web?.search?.provider).toBeUndefined();
|
||||
});
|
||||
|
||||
it("sets provider and key for perplexity", async () => {
|
||||
@ -151,7 +162,7 @@ describe("setupSearch", () => {
|
||||
});
|
||||
const result = await setupSearch(cfg, runtime, prompter);
|
||||
expect(result.tools?.web?.search?.provider).toBe("brave");
|
||||
expect(result.tools?.web?.search?.enabled).toBeUndefined();
|
||||
expect(result.tools?.web?.search?.enabled).toBe(true);
|
||||
const missingNote = notes.find((n) => n.message.includes("No API key stored"));
|
||||
expect(missingNote).toBeDefined();
|
||||
} finally {
|
||||
@ -171,11 +182,13 @@ describe("setupSearch", () => {
|
||||
expect(result.tools?.web?.search?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("advanced preserves enabled:false when keeping existing key", async () => {
|
||||
const result = await runBlankPerplexityKeyEntry(
|
||||
it("keeps search disabled when the onboarding toggle is declined", async () => {
|
||||
const cfg = createPerplexityConfig(
|
||||
"existing-key", // pragma: allowlist secret
|
||||
false,
|
||||
);
|
||||
const { prompter } = createPrompter({ confirmValue: false });
|
||||
const result = await setupSearch(cfg, runtime, prompter);
|
||||
expect(result.tools?.web?.search?.perplexity?.apiKey).toBe("existing-key");
|
||||
expect(result.tools?.web?.search?.enabled).toBe(false);
|
||||
});
|
||||
@ -190,11 +203,15 @@ describe("setupSearch", () => {
|
||||
expect(prompter.text).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("quickstart preserves enabled:false when search was intentionally disabled", async () => {
|
||||
const { result, prompter } = await runQuickstartPerplexitySetup(
|
||||
it("quickstart keeps search disabled when the onboarding toggle is declined", async () => {
|
||||
const cfg = createPerplexityConfig(
|
||||
"stored-pplx-key", // pragma: allowlist secret
|
||||
false,
|
||||
);
|
||||
const { prompter } = createPrompter({ confirmValue: false });
|
||||
const result = await setupSearch(cfg, runtime, prompter, {
|
||||
quickstartDefaults: true,
|
||||
});
|
||||
expect(result.tools?.web?.search?.provider).toBe("perplexity");
|
||||
expect(result.tools?.web?.search?.perplexity?.apiKey).toBe("stored-pplx-key");
|
||||
expect(result.tools?.web?.search?.enabled).toBe(false);
|
||||
@ -212,7 +229,7 @@ describe("setupSearch", () => {
|
||||
});
|
||||
expect(prompter.text).toHaveBeenCalled();
|
||||
expect(result.tools?.web?.search?.provider).toBe("grok");
|
||||
expect(result.tools?.web?.search?.enabled).toBeUndefined();
|
||||
expect(result.tools?.web?.search?.enabled).toBe(true);
|
||||
} finally {
|
||||
if (original === undefined) {
|
||||
delete process.env.XAI_API_KEY;
|
||||
@ -283,6 +300,59 @@ describe("setupSearch", () => {
|
||||
expect(result.tools?.web?.search?.apiKey).toBe("BSA-plain");
|
||||
});
|
||||
|
||||
it("can enable native Codex search without forcing managed provider setup", async () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
auth: {
|
||||
profiles: {
|
||||
"openai-codex:default": {
|
||||
provider: "openai-codex",
|
||||
mode: "oauth",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const { prompter } = createPrompter({
|
||||
confirmValues: [true, true, false],
|
||||
selectValues: ["cached"],
|
||||
});
|
||||
|
||||
const result = await setupSearch(cfg, runtime, prompter);
|
||||
|
||||
expect(result.tools?.web?.search?.enabled).toBe(true);
|
||||
expect(result.tools?.web?.search?.openaiCodex).toEqual({
|
||||
enabled: true,
|
||||
mode: "cached",
|
||||
});
|
||||
expect(result.tools?.web?.search?.provider).toBeUndefined();
|
||||
});
|
||||
|
||||
it("still supports managed provider setup for Codex users", async () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
auth: {
|
||||
profiles: {
|
||||
"openai-codex:default": {
|
||||
provider: "openai-codex",
|
||||
mode: "oauth",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const { prompter } = createPrompter({
|
||||
confirmValues: [true, true, true],
|
||||
selectValues: ["live", "brave"],
|
||||
textValue: "BSA-test-key",
|
||||
});
|
||||
|
||||
const result = await setupSearch(cfg, runtime, prompter);
|
||||
|
||||
expect(result.tools?.web?.search?.provider).toBe("brave");
|
||||
expect(result.tools?.web?.search?.apiKey).toBe("BSA-test-key");
|
||||
expect(result.tools?.web?.search?.openaiCodex).toEqual({
|
||||
enabled: true,
|
||||
mode: "live",
|
||||
});
|
||||
});
|
||||
|
||||
it("exports all 5 providers in SEARCH_PROVIDER_OPTIONS", () => {
|
||||
expect(SEARCH_PROVIDER_OPTIONS).toHaveLength(5);
|
||||
const values = SEARCH_PROVIDER_OPTIONS.map((e) => e.value);
|
||||
|
||||
@ -1,3 +1,7 @@
|
||||
import {
|
||||
describeCodexNativeWebSearch,
|
||||
isCodexNativeWebSearchRelevant,
|
||||
} from "../agents/codex-native-web-search.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import {
|
||||
DEFAULT_SECRET_PROVIDER_ALIAS,
|
||||
@ -184,7 +188,33 @@ function preserveDisabledState(original: OpenClawConfig, result: OpenClawConfig)
|
||||
};
|
||||
}
|
||||
|
||||
function applyCodexNativeSearchConfig(
|
||||
config: OpenClawConfig,
|
||||
params: { enabled: boolean; mode?: "cached" | "live" },
|
||||
): OpenClawConfig {
|
||||
return {
|
||||
...config,
|
||||
tools: {
|
||||
...config.tools,
|
||||
web: {
|
||||
...config.tools?.web,
|
||||
search: {
|
||||
...config.tools?.web?.search,
|
||||
enabled: params.enabled ? true : config.tools?.web?.search?.enabled,
|
||||
openaiCodex: {
|
||||
...config.tools?.web?.search?.openaiCodex,
|
||||
enabled: params.enabled,
|
||||
...(params.mode ? { mode: params.mode } : {}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type SetupSearchOptions = {
|
||||
agentId?: string;
|
||||
agentDir?: string;
|
||||
quickstartDefaults?: boolean;
|
||||
secretInputMode?: SecretInputMode;
|
||||
};
|
||||
@ -198,16 +228,102 @@ export async function setupSearch(
|
||||
await prompter.note(
|
||||
[
|
||||
"Web search lets your agent look things up online.",
|
||||
"Choose a provider and paste your API key.",
|
||||
"You can configure a managed provider now, and Codex-capable models can also use native Codex web search.",
|
||||
"Docs: https://docs.openclaw.ai/tools/web",
|
||||
].join("\n"),
|
||||
"Web search",
|
||||
);
|
||||
|
||||
const existingProvider = config.tools?.web?.search?.provider;
|
||||
const enableSearch = await prompter.confirm({
|
||||
message: "Enable web_search?",
|
||||
initialValue: config.tools?.web?.search?.enabled !== false,
|
||||
});
|
||||
if (!enableSearch) {
|
||||
return {
|
||||
...config,
|
||||
tools: {
|
||||
...config.tools,
|
||||
web: {
|
||||
...config.tools?.web,
|
||||
search: {
|
||||
...config.tools?.web?.search,
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
let nextConfig: OpenClawConfig = {
|
||||
...config,
|
||||
tools: {
|
||||
...config.tools,
|
||||
web: {
|
||||
...config.tools?.web,
|
||||
search: {
|
||||
...config.tools?.web?.search,
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const codexRelevant = isCodexNativeWebSearchRelevant({
|
||||
config: nextConfig,
|
||||
agentId: opts?.agentId,
|
||||
agentDir: opts?.agentDir,
|
||||
});
|
||||
if (codexRelevant) {
|
||||
const currentNativeSummary = describeCodexNativeWebSearch(nextConfig);
|
||||
await prompter.note(
|
||||
[
|
||||
"Codex-capable models can optionally use native Codex web search.",
|
||||
"This does not replace managed web_search for other models.",
|
||||
"If you skip managed provider setup, non-Codex models still rely on provider auto-detect and may have no search available.",
|
||||
...(currentNativeSummary ? [currentNativeSummary] : ["Recommended mode: cached."]),
|
||||
].join("\n"),
|
||||
"Codex native search",
|
||||
);
|
||||
const enableCodexNative = await prompter.confirm({
|
||||
message: "Enable native Codex web search for Codex-capable models?",
|
||||
initialValue: config.tools?.web?.search?.openaiCodex?.enabled === true,
|
||||
});
|
||||
if (enableCodexNative) {
|
||||
const codexMode = await prompter.select<"cached" | "live">({
|
||||
message: "Codex native web search mode",
|
||||
options: [
|
||||
{
|
||||
value: "cached",
|
||||
label: "cached (recommended)",
|
||||
hint: "Uses cached web content",
|
||||
},
|
||||
{
|
||||
value: "live",
|
||||
label: "live",
|
||||
hint: "Allows live external web access",
|
||||
},
|
||||
],
|
||||
initialValue: config.tools?.web?.search?.openaiCodex?.mode ?? "cached",
|
||||
});
|
||||
nextConfig = applyCodexNativeSearchConfig(nextConfig, {
|
||||
enabled: true,
|
||||
mode: codexMode,
|
||||
});
|
||||
const configureManagedProvider = await prompter.confirm({
|
||||
message: "Configure a managed web search provider now?",
|
||||
initialValue: Boolean(config.tools?.web?.search?.provider),
|
||||
});
|
||||
if (!configureManagedProvider) {
|
||||
return nextConfig;
|
||||
}
|
||||
} else {
|
||||
nextConfig = applyCodexNativeSearchConfig(nextConfig, { enabled: false });
|
||||
}
|
||||
}
|
||||
|
||||
const existingProvider = nextConfig.tools?.web?.search?.provider;
|
||||
|
||||
const options = SEARCH_PROVIDER_OPTIONS.map((entry) => {
|
||||
const configured = hasExistingKey(config, entry.value) || hasKeyInEnv(entry);
|
||||
const configured = hasExistingKey(nextConfig, entry.value) || hasKeyInEnv(entry);
|
||||
const hint = configured ? `${entry.hint} · configured` : entry.hint;
|
||||
return { value: entry.value, label: entry.label, hint };
|
||||
});
|
||||
@ -217,7 +333,7 @@ export async function setupSearch(
|
||||
return existingProvider;
|
||||
}
|
||||
const detected = SEARCH_PROVIDER_OPTIONS.find(
|
||||
(e) => hasExistingKey(config, e.value) || hasKeyInEnv(e),
|
||||
(e) => hasExistingKey(nextConfig, e.value) || hasKeyInEnv(e),
|
||||
);
|
||||
if (detected) {
|
||||
return detected.value;
|
||||
@ -240,25 +356,25 @@ export async function setupSearch(
|
||||
});
|
||||
|
||||
if (choice === "__skip__") {
|
||||
return config;
|
||||
return nextConfig;
|
||||
}
|
||||
|
||||
const entry = SEARCH_PROVIDER_OPTIONS.find((e) => e.value === choice)!;
|
||||
const existingKey = resolveExistingKey(config, choice);
|
||||
const keyConfigured = hasExistingKey(config, choice);
|
||||
const existingKey = resolveExistingKey(nextConfig, choice);
|
||||
const keyConfigured = hasExistingKey(nextConfig, choice);
|
||||
const envAvailable = hasKeyInEnv(entry);
|
||||
|
||||
if (opts?.quickstartDefaults && (keyConfigured || envAvailable)) {
|
||||
const result = existingKey
|
||||
? applySearchKey(config, choice, existingKey)
|
||||
: applyProviderOnly(config, choice);
|
||||
return preserveDisabledState(config, result);
|
||||
? applySearchKey(nextConfig, choice, existingKey)
|
||||
: applyProviderOnly(nextConfig, choice);
|
||||
return preserveDisabledState(nextConfig, result);
|
||||
}
|
||||
|
||||
const useSecretRefMode = opts?.secretInputMode === "ref"; // pragma: allowlist secret
|
||||
if (useSecretRefMode) {
|
||||
if (keyConfigured) {
|
||||
return preserveDisabledState(config, applyProviderOnly(config, choice));
|
||||
return preserveDisabledState(nextConfig, applyProviderOnly(nextConfig, choice));
|
||||
}
|
||||
const ref = buildSearchEnvRef(choice);
|
||||
await prompter.note(
|
||||
@ -270,7 +386,7 @@ export async function setupSearch(
|
||||
].join("\n"),
|
||||
"Web search",
|
||||
);
|
||||
return applySearchKey(config, choice, ref);
|
||||
return applySearchKey(nextConfig, choice, ref);
|
||||
}
|
||||
|
||||
const keyInput = await prompter.text({
|
||||
@ -285,15 +401,15 @@ export async function setupSearch(
|
||||
const key = keyInput?.trim() ?? "";
|
||||
if (key) {
|
||||
const secretInput = resolveSearchSecretInput(choice, key, opts?.secretInputMode);
|
||||
return applySearchKey(config, choice, secretInput);
|
||||
return applySearchKey(nextConfig, choice, secretInput);
|
||||
}
|
||||
|
||||
if (existingKey) {
|
||||
return preserveDisabledState(config, applySearchKey(config, choice, existingKey));
|
||||
return preserveDisabledState(nextConfig, applySearchKey(nextConfig, choice, existingKey));
|
||||
}
|
||||
|
||||
if (keyConfigured || envAvailable) {
|
||||
return preserveDisabledState(config, applyProviderOnly(config, choice));
|
||||
return preserveDisabledState(nextConfig, applyProviderOnly(nextConfig, choice));
|
||||
}
|
||||
|
||||
await prompter.note(
|
||||
@ -306,13 +422,13 @@ export async function setupSearch(
|
||||
);
|
||||
|
||||
return {
|
||||
...config,
|
||||
...nextConfig,
|
||||
tools: {
|
||||
...config.tools,
|
||||
...nextConfig.tools,
|
||||
web: {
|
||||
...config.tools?.web,
|
||||
...nextConfig.tools?.web,
|
||||
search: {
|
||||
...config.tools?.web?.search,
|
||||
...nextConfig.tools?.web?.search,
|
||||
provider: choice,
|
||||
},
|
||||
},
|
||||
|
||||
@ -659,13 +659,30 @@ export const FIELD_HELP: Record<string, string> = {
|
||||
"tools.message.crossContext.marker.suffix":
|
||||
'Text suffix for cross-context markers (supports "{channel}").',
|
||||
"tools.message.broadcast.enabled": "Enable broadcast action (default: true).",
|
||||
"tools.web.search.enabled": "Enable the web_search tool (requires a provider API key).",
|
||||
"tools.web.search.enabled":
|
||||
"Enable managed web_search and optional Codex-native search for eligible models.",
|
||||
"tools.web.search.provider":
|
||||
'Search provider ("brave", "gemini", "grok", "kimi", or "perplexity"). Auto-detected from available API keys if omitted.',
|
||||
"tools.web.search.apiKey": "Brave Search API key (fallback: BRAVE_API_KEY env var).",
|
||||
"tools.web.search.maxResults": "Number of results to return (1-10).",
|
||||
"tools.web.search.timeoutSeconds": "Timeout in seconds for web_search requests.",
|
||||
"tools.web.search.cacheTtlMinutes": "Cache TTL in minutes for web_search results.",
|
||||
"tools.web.search.openaiCodex.enabled":
|
||||
"Enable native Codex web search for Codex-capable models.",
|
||||
"tools.web.search.openaiCodex.mode":
|
||||
'Native Codex web search mode: "cached" (default) or "live".',
|
||||
"tools.web.search.openaiCodex.allowedDomains":
|
||||
"Optional domain allowlist passed to the native Codex web_search tool.",
|
||||
"tools.web.search.openaiCodex.contextSize":
|
||||
'Native Codex search context size hint: "low", "medium", or "high".',
|
||||
"tools.web.search.openaiCodex.userLocation.country":
|
||||
"Approximate country sent to native Codex web search.",
|
||||
"tools.web.search.openaiCodex.userLocation.region":
|
||||
"Approximate region/state sent to native Codex web search.",
|
||||
"tools.web.search.openaiCodex.userLocation.city":
|
||||
"Approximate city sent to native Codex web search.",
|
||||
"tools.web.search.openaiCodex.userLocation.timezone":
|
||||
"Approximate timezone sent to native Codex web search.",
|
||||
"tools.web.search.brave.mode":
|
||||
'Brave Search mode: "web" (URL results) or "llm-context" (pre-extracted page content for LLM grounding).',
|
||||
"tools.web.search.gemini.apiKey":
|
||||
|
||||
@ -218,6 +218,14 @@ export const FIELD_LABELS: Record<string, string> = {
|
||||
"tools.web.search.maxResults": "Web Search Max Results",
|
||||
"tools.web.search.timeoutSeconds": "Web Search Timeout (sec)",
|
||||
"tools.web.search.cacheTtlMinutes": "Web Search Cache TTL (min)",
|
||||
"tools.web.search.openaiCodex.enabled": "Enable Native Codex Web Search",
|
||||
"tools.web.search.openaiCodex.mode": "Codex Web Search Mode",
|
||||
"tools.web.search.openaiCodex.allowedDomains": "Codex Allowed Domains",
|
||||
"tools.web.search.openaiCodex.contextSize": "Codex Search Context Size",
|
||||
"tools.web.search.openaiCodex.userLocation.country": "Codex User Country",
|
||||
"tools.web.search.openaiCodex.userLocation.region": "Codex User Region",
|
||||
"tools.web.search.openaiCodex.userLocation.city": "Codex User City",
|
||||
"tools.web.search.openaiCodex.userLocation.timezone": "Codex User Timezone",
|
||||
"tools.web.search.brave.mode": "Brave Search Mode",
|
||||
"tools.web.search.gemini.apiKey": "Gemini Search API Key", // pragma: allowlist secret
|
||||
"tools.web.search.gemini.model": "Gemini Search Model",
|
||||
|
||||
@ -455,7 +455,7 @@ export type ToolsConfig = {
|
||||
byProvider?: Record<string, ToolPolicyConfig>;
|
||||
web?: {
|
||||
search?: {
|
||||
/** Enable web search tool (default: true when API key is present). */
|
||||
/** Enable managed web_search and optional Codex-native web search. */
|
||||
enabled?: boolean;
|
||||
/** Search provider ("brave", "gemini", "grok", "kimi", or "perplexity"). */
|
||||
provider?: "brave" | "gemini" | "grok" | "kimi" | "perplexity";
|
||||
@ -467,6 +467,24 @@ export type ToolsConfig = {
|
||||
timeoutSeconds?: number;
|
||||
/** Cache TTL in minutes for search results. */
|
||||
cacheTtlMinutes?: number;
|
||||
/** Optional native Codex web search for Codex-capable models. */
|
||||
openaiCodex?: {
|
||||
/** Enable native Codex web search for eligible models. */
|
||||
enabled?: boolean;
|
||||
/** Use cached or live external web access. Default: "cached". */
|
||||
mode?: "cached" | "live";
|
||||
/** Optional allowlist of domains passed to the native Codex tool. */
|
||||
allowedDomains?: string[];
|
||||
/** Optional Codex native search context size hint. */
|
||||
contextSize?: "low" | "medium" | "high";
|
||||
/** Optional approximate user location passed to the native Codex tool. */
|
||||
userLocation?: {
|
||||
country?: string;
|
||||
region?: string;
|
||||
city?: string;
|
||||
timezone?: string;
|
||||
};
|
||||
};
|
||||
/** Brave-specific configuration (used when provider="brave"). */
|
||||
brave?: {
|
||||
/** Brave Search mode: "web" (standard results) or "llm-context" (pre-extracted page content). Default: "web". */
|
||||
|
||||
75
src/config/web-search-codex-config.test.ts
Normal file
75
src/config/web-search-codex-config.test.ts
Normal file
@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { validateConfigObjectRaw } from "./validation.js";
|
||||
|
||||
describe("web search Codex native config validation", () => {
|
||||
it("accepts tools.web.search.openaiCodex", () => {
|
||||
const result = validateConfigObjectRaw({
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
enabled: true,
|
||||
openaiCodex: {
|
||||
enabled: true,
|
||||
mode: "cached",
|
||||
allowedDomains: ["example.com"],
|
||||
contextSize: "medium",
|
||||
userLocation: {
|
||||
country: "US",
|
||||
city: "New York",
|
||||
timezone: "America/New_York",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects invalid openaiCodex.mode", () => {
|
||||
const result = validateConfigObjectRaw({
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
openaiCodex: {
|
||||
enabled: true,
|
||||
mode: "realtime",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
const issue = result.issues.find(
|
||||
(entry) => entry.path === "tools.web.search.openaiCodex.mode",
|
||||
);
|
||||
expect(issue?.allowedValues).toEqual(["cached", "live"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects invalid openaiCodex.contextSize", () => {
|
||||
const result = validateConfigObjectRaw({
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
openaiCodex: {
|
||||
enabled: true,
|
||||
contextSize: "huge",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
const issue = result.issues.find(
|
||||
(entry) => entry.path === "tools.web.search.openaiCodex.contextSize",
|
||||
);
|
||||
expect(issue?.allowedValues).toEqual(["low", "medium", "high"]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -259,6 +259,37 @@ export const ToolPolicySchema = ToolPolicyBaseSchema.superRefine((value, ctx) =>
|
||||
}
|
||||
}).optional();
|
||||
|
||||
const TrimmedOptionalConfigStringSchema = z.preprocess((value) => {
|
||||
if (typeof value !== "string") {
|
||||
return value;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}, z.string().optional());
|
||||
|
||||
const CodexAllowedDomainsSchema = z
|
||||
.array(z.string())
|
||||
.transform((values) => {
|
||||
const deduped = [
|
||||
...new Set(values.map((value) => value.trim()).filter((value) => value.length > 0)),
|
||||
];
|
||||
return deduped.length > 0 ? deduped : undefined;
|
||||
})
|
||||
.optional();
|
||||
|
||||
const CodexUserLocationSchema = z
|
||||
.object({
|
||||
country: TrimmedOptionalConfigStringSchema,
|
||||
region: TrimmedOptionalConfigStringSchema,
|
||||
city: TrimmedOptionalConfigStringSchema,
|
||||
timezone: TrimmedOptionalConfigStringSchema,
|
||||
})
|
||||
.strict()
|
||||
.transform((value) => {
|
||||
return value.country || value.region || value.city || value.timezone ? value : undefined;
|
||||
})
|
||||
.optional();
|
||||
|
||||
export const ToolsWebSearchSchema = z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
@ -275,6 +306,16 @@ export const ToolsWebSearchSchema = z
|
||||
maxResults: z.number().int().positive().optional(),
|
||||
timeoutSeconds: z.number().int().positive().optional(),
|
||||
cacheTtlMinutes: z.number().nonnegative().optional(),
|
||||
openaiCodex: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
mode: z.union([z.literal("cached"), z.literal("live")]).optional(),
|
||||
allowedDomains: CodexAllowedDomainsSchema,
|
||||
contextSize: z.union([z.literal("low"), z.literal("medium"), z.literal("high")]).optional(),
|
||||
userLocation: CodexUserLocationSchema,
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
perplexity: z
|
||||
.object({
|
||||
apiKey: SecretInputSchema.optional().register(sensitive),
|
||||
|
||||
@ -307,4 +307,54 @@ describe("finalizeOnboardingWizard", () => {
|
||||
expect(progressUpdate).toHaveBeenCalledWith("Restarting Gateway service…");
|
||||
expect(progressStop).toHaveBeenCalledWith("Gateway service restart scheduled.");
|
||||
});
|
||||
|
||||
it("shows a Codex native search summary when configured", async () => {
|
||||
const note = vi.fn(async () => {});
|
||||
const prompter = buildWizardPrompter({
|
||||
note,
|
||||
select: vi.fn(async () => "later") as never,
|
||||
confirm: vi.fn(async () => false),
|
||||
});
|
||||
|
||||
await finalizeOnboardingWizard({
|
||||
flow: "advanced",
|
||||
opts: {
|
||||
acceptRisk: true,
|
||||
authChoice: "skip",
|
||||
installDaemon: false,
|
||||
skipHealth: true,
|
||||
skipUi: true,
|
||||
},
|
||||
baseConfig: {},
|
||||
nextConfig: {
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
enabled: true,
|
||||
openaiCodex: {
|
||||
enabled: true,
|
||||
mode: "cached",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
workspaceDir: "/tmp",
|
||||
settings: {
|
||||
port: 18789,
|
||||
bind: "loopback",
|
||||
authMode: "token",
|
||||
gatewayToken: undefined,
|
||||
tailscaleMode: "off",
|
||||
tailscaleResetOnExit: false,
|
||||
},
|
||||
prompter,
|
||||
runtime: createRuntime(),
|
||||
});
|
||||
|
||||
expect(note).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Codex native search: cached for Codex-capable models"),
|
||||
"Codex native search",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -481,6 +481,8 @@ export async function finalizeOnboardingWizard(
|
||||
);
|
||||
}
|
||||
|
||||
const { describeCodexNativeWebSearch } = await import("../agents/codex-native-web-search.js");
|
||||
const codexNativeSummary = describeCodexNativeWebSearch(nextConfig);
|
||||
const webSearchProvider = nextConfig.tools?.web?.search?.provider;
|
||||
const webSearchEnabled = nextConfig.tools?.web?.search?.enabled;
|
||||
if (webSearchProvider) {
|
||||
@ -549,6 +551,15 @@ export async function finalizeOnboardingWizard(
|
||||
].join("\n"),
|
||||
"Web search",
|
||||
);
|
||||
} else if (codexNativeSummary) {
|
||||
await prompter.note(
|
||||
[
|
||||
"Managed web search provider was skipped.",
|
||||
codexNativeSummary,
|
||||
"Docs: https://docs.openclaw.ai/tools/web",
|
||||
].join("\n"),
|
||||
"Web search",
|
||||
);
|
||||
} else {
|
||||
await prompter.note(
|
||||
[
|
||||
@ -562,6 +573,17 @@ export async function finalizeOnboardingWizard(
|
||||
}
|
||||
}
|
||||
|
||||
if (codexNativeSummary) {
|
||||
await prompter.note(
|
||||
[
|
||||
codexNativeSummary,
|
||||
"Used only for Codex-capable models.",
|
||||
"Docs: https://docs.openclaw.ai/tools/web",
|
||||
].join("\n"),
|
||||
"Codex native search",
|
||||
);
|
||||
}
|
||||
|
||||
await prompter.note(
|
||||
'What now: https://openclaw.ai/showcase ("What People Are Building").',
|
||||
"What now",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user