Compare commits
8 Commits
main
...
cs/codex-n
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1e51171b0 | ||
|
|
f7fc816546 | ||
|
|
9261d75139 | ||
|
|
5373dc9dec | ||
|
|
bf703972c4 | ||
|
|
e28da68cf4 | ||
|
|
160fda3f29 | ||
|
|
21270f900b |
@ -58,6 +58,41 @@ Runtime SecretRef behavior:
|
|||||||
- In auto-detect mode, OpenClaw resolves only the selected provider key. Non-selected provider SecretRefs stay inactive until selected.
|
- 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.
|
- 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
|
## Setting up web search
|
||||||
|
|
||||||
Use `openclaw configure --section web` to set up your API key and choose a provider.
|
Use `openclaw configure --section web` to set up your API key and choose a provider.
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
import type { DiscordAccountConfig } from "../../../src/config/types.js";
|
|
||||||
import {
|
import {
|
||||||
hasConfiguredSecretInput,
|
hasConfiguredSecretInput,
|
||||||
normalizeSecretInputString,
|
normalizeSecretInputString,
|
||||||
} from "openclaw/plugin-sdk/config-runtime";
|
} from "openclaw/plugin-sdk/config-runtime";
|
||||||
|
import type { DiscordAccountConfig } from "../../../src/config/types.js";
|
||||||
import {
|
import {
|
||||||
DEFAULT_ACCOUNT_ID,
|
DEFAULT_ACCOUNT_ID,
|
||||||
normalizeAccountId,
|
normalizeAccountId,
|
||||||
|
|||||||
@ -1,5 +1,3 @@
|
|||||||
import { createPatchedAccountSetupAdapter } from "../../../src/channels/plugins/setup-helpers.js";
|
|
||||||
import { createAllowlistSetupWizardProxy } from "../../../src/channels/plugins/setup-wizard-proxy.js";
|
|
||||||
import type { DiscordGuildEntry } from "openclaw/plugin-sdk/config-runtime";
|
import type { DiscordGuildEntry } from "openclaw/plugin-sdk/config-runtime";
|
||||||
import {
|
import {
|
||||||
applyAccountNameToChannelSection,
|
applyAccountNameToChannelSection,
|
||||||
@ -19,6 +17,8 @@ import {
|
|||||||
type ChannelSetupDmPolicy,
|
type ChannelSetupDmPolicy,
|
||||||
type ChannelSetupWizard,
|
type ChannelSetupWizard,
|
||||||
} from "openclaw/plugin-sdk/setup";
|
} from "openclaw/plugin-sdk/setup";
|
||||||
|
import { createPatchedAccountSetupAdapter } from "../../../src/channels/plugins/setup-helpers.js";
|
||||||
|
import { createAllowlistSetupWizardProxy } from "../../../src/channels/plugins/setup-wizard-proxy.js";
|
||||||
import { formatDocsLink } from "../../../src/terminal/links.js";
|
import { formatDocsLink } from "../../../src/terminal/links.js";
|
||||||
import { inspectDiscordAccount } from "./account-inspect.js";
|
import { inspectDiscordAccount } from "./account-inspect.js";
|
||||||
import { listDiscordAccountIds, resolveDiscordAccount } from "./accounts.js";
|
import { listDiscordAccountIds, resolveDiscordAccount } from "./accounts.js";
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
import { cloneFirstTemplateModel } from "../../src/plugins/provider-model-helpers.js";
|
|
||||||
import type {
|
import type {
|
||||||
ProviderResolveDynamicModelContext,
|
ProviderResolveDynamicModelContext,
|
||||||
ProviderRuntimeModel,
|
ProviderRuntimeModel,
|
||||||
} from "openclaw/plugin-sdk/core";
|
} from "openclaw/plugin-sdk/core";
|
||||||
|
import { cloneFirstTemplateModel } from "../../src/plugins/provider-model-helpers.js";
|
||||||
|
|
||||||
const GEMINI_3_1_PRO_PREFIX = "gemini-3.1-pro";
|
const GEMINI_3_1_PRO_PREFIX = "gemini-3.1-pro";
|
||||||
const GEMINI_3_1_FLASH_PREFIX = "gemini-3.1-flash";
|
const GEMINI_3_1_FLASH_PREFIX = "gemini-3.1-flash";
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { detectBinary } from "../../../src/commands/onboard-helpers.js";
|
|
||||||
import { setSetupChannelEnabled } from "openclaw/plugin-sdk/setup";
|
import { setSetupChannelEnabled } from "openclaw/plugin-sdk/setup";
|
||||||
import type { ChannelSetupWizard } from "openclaw/plugin-sdk/setup";
|
import type { ChannelSetupWizard } from "openclaw/plugin-sdk/setup";
|
||||||
|
import { detectBinary } from "../../../src/commands/onboard-helpers.js";
|
||||||
import { listIMessageAccountIds, resolveIMessageAccount } from "./accounts.js";
|
import { listIMessageAccountIds, resolveIMessageAccount } from "./accounts.js";
|
||||||
import {
|
import {
|
||||||
createIMessageCliPathTextInput,
|
createIMessageCliPathTextInput,
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
import { formatCliCommand } from "../../../src/cli/command-format.js";
|
|
||||||
import {
|
import {
|
||||||
applyAccountNameToChannelSection,
|
applyAccountNameToChannelSection,
|
||||||
DEFAULT_ACCOUNT_ID,
|
DEFAULT_ACCOUNT_ID,
|
||||||
@ -18,6 +17,7 @@ import type {
|
|||||||
ChannelSetupWizard,
|
ChannelSetupWizard,
|
||||||
ChannelSetupWizardTextInput,
|
ChannelSetupWizardTextInput,
|
||||||
} from "openclaw/plugin-sdk/setup";
|
} from "openclaw/plugin-sdk/setup";
|
||||||
|
import { formatCliCommand } from "../../../src/cli/command-format.js";
|
||||||
import { formatDocsLink } from "../../../src/terminal/links.js";
|
import { formatDocsLink } from "../../../src/terminal/links.js";
|
||||||
import {
|
import {
|
||||||
listSignalAccountIds,
|
listSignalAccountIds,
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { detectBinary } from "../../../src/commands/onboard-helpers.js";
|
|
||||||
import { installSignalCli } from "../../../src/commands/signal-install.js";
|
|
||||||
import { setSetupChannelEnabled } from "openclaw/plugin-sdk/setup";
|
import { setSetupChannelEnabled } from "openclaw/plugin-sdk/setup";
|
||||||
import type { ChannelSetupWizard } from "openclaw/plugin-sdk/setup";
|
import type { ChannelSetupWizard } from "openclaw/plugin-sdk/setup";
|
||||||
|
import { detectBinary } from "../../../src/commands/onboard-helpers.js";
|
||||||
|
import { installSignalCli } from "../../../src/commands/signal-install.js";
|
||||||
import { listSignalAccountIds, resolveSignalAccount } from "./accounts.js";
|
import { listSignalAccountIds, resolveSignalAccount } from "./accounts.js";
|
||||||
import {
|
import {
|
||||||
createSignalCliPathTextInput,
|
createSignalCliPathTextInput,
|
||||||
|
|||||||
@ -19,9 +19,9 @@ import {
|
|||||||
type ChannelSetupWizard,
|
type ChannelSetupWizard,
|
||||||
type ChannelSetupWizardAllowFromEntry,
|
type ChannelSetupWizardAllowFromEntry,
|
||||||
} from "openclaw/plugin-sdk/setup";
|
} from "openclaw/plugin-sdk/setup";
|
||||||
import { formatDocsLink } from "../../../src/terminal/links.js";
|
|
||||||
import { createPatchedAccountSetupAdapter } from "../../../src/channels/plugins/setup-helpers.js";
|
import { createPatchedAccountSetupAdapter } from "../../../src/channels/plugins/setup-helpers.js";
|
||||||
import { createAllowlistSetupWizardProxy } from "../../../src/channels/plugins/setup-wizard-proxy.js";
|
import { createAllowlistSetupWizardProxy } from "../../../src/channels/plugins/setup-wizard-proxy.js";
|
||||||
|
import { formatDocsLink } from "../../../src/terminal/links.js";
|
||||||
import { inspectSlackAccount } from "./account-inspect.js";
|
import { inspectSlackAccount } from "./account-inspect.js";
|
||||||
import { listSlackAccountIds, resolveSlackAccount, type ResolvedSlackAccount } from "./accounts.js";
|
import { listSlackAccountIds, resolveSlackAccount, type ResolvedSlackAccount } from "./accounts.js";
|
||||||
import {
|
import {
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
import { formatCliCommand } from "../../../src/cli/command-format.js";
|
|
||||||
import {
|
import {
|
||||||
applyAccountNameToChannelSection,
|
applyAccountNameToChannelSection,
|
||||||
DEFAULT_ACCOUNT_ID,
|
DEFAULT_ACCOUNT_ID,
|
||||||
@ -11,6 +10,7 @@ import {
|
|||||||
type WizardPrompter,
|
type WizardPrompter,
|
||||||
} from "openclaw/plugin-sdk/setup";
|
} from "openclaw/plugin-sdk/setup";
|
||||||
import type { ChannelSetupAdapter, ChannelSetupDmPolicy } from "openclaw/plugin-sdk/setup";
|
import type { ChannelSetupAdapter, ChannelSetupDmPolicy } from "openclaw/plugin-sdk/setup";
|
||||||
|
import { formatCliCommand } from "../../../src/cli/command-format.js";
|
||||||
import { formatDocsLink } from "../../../src/terminal/links.js";
|
import { formatDocsLink } from "../../../src/terminal/links.js";
|
||||||
import { resolveDefaultTelegramAccountId, resolveTelegramAccount } from "./accounts.js";
|
import { resolveDefaultTelegramAccountId, resolveTelegramAccount } from "./accounts.js";
|
||||||
import { fetchTelegramChatId } from "./api-fetch.js";
|
import { fetchTelegramChatId } from "./api-fetch.js";
|
||||||
|
|||||||
@ -1,6 +1,4 @@
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { formatCliCommand } from "../../../src/cli/command-format.js";
|
|
||||||
import type { DmPolicy } from "../../../src/config/types.js";
|
|
||||||
import {
|
import {
|
||||||
DEFAULT_ACCOUNT_ID,
|
DEFAULT_ACCOUNT_ID,
|
||||||
normalizeAccountId,
|
normalizeAccountId,
|
||||||
@ -12,6 +10,8 @@ import {
|
|||||||
type OpenClawConfig,
|
type OpenClawConfig,
|
||||||
} from "openclaw/plugin-sdk/setup";
|
} from "openclaw/plugin-sdk/setup";
|
||||||
import type { ChannelSetupWizard } from "openclaw/plugin-sdk/setup";
|
import type { ChannelSetupWizard } from "openclaw/plugin-sdk/setup";
|
||||||
|
import { formatCliCommand } from "../../../src/cli/command-format.js";
|
||||||
|
import type { DmPolicy } from "../../../src/config/types.js";
|
||||||
import { formatDocsLink } from "../../../src/terminal/links.js";
|
import { formatDocsLink } from "../../../src/terminal/links.js";
|
||||||
import { listWhatsAppAccountIds, resolveWhatsAppAuthDir } from "./accounts.js";
|
import { listWhatsAppAccountIds, resolveWhatsAppAuthDir } from "./accounts.js";
|
||||||
import { loginWeb } from "./login.js";
|
import { loginWeb } from "./login.js";
|
||||||
|
|||||||
270
src/agents/codex-native-web-search.test.ts
Normal file
270
src/agents/codex-native-web-search.test.ts
Normal file
@ -0,0 +1,270 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
buildCodexNativeWebSearchTool,
|
||||||
|
describeCodexNativeWebSearch,
|
||||||
|
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("omits the summary when global web search is disabled", () => {
|
||||||
|
expect(
|
||||||
|
describeCodexNativeWebSearch({
|
||||||
|
tools: {
|
||||||
|
web: {
|
||||||
|
search: {
|
||||||
|
enabled: false,
|
||||||
|
openaiCodex: {
|
||||||
|
enabled: true,
|
||||||
|
mode: "live",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isCodexNativeWebSearchRelevant", () => {
|
||||||
|
it("treats a default model with model-level openai-codex-responses api as relevant", async () => {
|
||||||
|
const { isCodexNativeWebSearchRelevant } = await import("./codex-native-web-search.js");
|
||||||
|
|
||||||
|
expect(
|
||||||
|
isCodexNativeWebSearchRelevant({
|
||||||
|
config: {
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
model: {
|
||||||
|
primary: "gateway/gpt-5.4",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
models: {
|
||||||
|
providers: {
|
||||||
|
gateway: {
|
||||||
|
api: "openai-responses",
|
||||||
|
baseUrl: "https://gateway.example/v1",
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
id: "gpt-5.4",
|
||||||
|
name: "gpt-5.4",
|
||||||
|
api: "openai-codex-responses",
|
||||||
|
reasoning: false,
|
||||||
|
input: ["text"],
|
||||||
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||||
|
contextWindow: 128_000,
|
||||||
|
maxTokens: 16_384,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
307
src/agents/codex-native-web-search.ts
Normal file
307
src/agents/codex-native-web-search.ts
Normal file
@ -0,0 +1,307 @@
|
|||||||
|
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];
|
||||||
|
const configuredModelApi = configuredProvider?.models?.find(
|
||||||
|
(candidate) => candidate.id === defaultModel.model,
|
||||||
|
)?.api;
|
||||||
|
return isCodexNativeSearchEligibleModel({
|
||||||
|
modelProvider: defaultModel.provider,
|
||||||
|
modelApi: configuredModelApi ?? configuredProvider?.api,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function describeCodexNativeWebSearch(
|
||||||
|
config: OpenClawConfig | undefined,
|
||||||
|
): string | undefined {
|
||||||
|
if (config?.tools?.web?.search?.enabled === false) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nativeConfig = resolveCodexNativeWebSearchConfig(config);
|
||||||
|
if (!nativeConfig.enabled) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return `Codex native search: ${nativeConfig.mode} for Codex-capable models`;
|
||||||
|
}
|
||||||
@ -14,7 +14,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { EventEmitter } from "node:events";
|
import { EventEmitter } from "node:events";
|
||||||
import WebSocket from "ws";
|
import WebSocket, { type ClientOptions as WebSocketClientOptions } from "ws";
|
||||||
import { resolveProviderAttributionHeaders } from "./provider-attribution.js";
|
import { resolveProviderAttributionHeaders } from "./provider-attribution.js";
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
@ -268,7 +268,7 @@ export interface OpenAIWebSocketManagerOptions {
|
|||||||
/** Custom backoff delays in ms (default: [1000, 2000, 4000, 8000, 16000]) */
|
/** Custom backoff delays in ms (default: [1000, 2000, 4000, 8000, 16000]) */
|
||||||
backoffDelaysMs?: readonly number[];
|
backoffDelaysMs?: readonly number[];
|
||||||
/** Custom socket factory for tests. */
|
/** Custom socket factory for tests. */
|
||||||
socketFactory?: (url: string, options: ConstructorParameters<typeof WebSocket>[1]) => WebSocket;
|
socketFactory?: (url: string, options: WebSocketClientOptions) => WebSocket;
|
||||||
}
|
}
|
||||||
|
|
||||||
type InternalEvents = {
|
type InternalEvents = {
|
||||||
@ -308,10 +308,7 @@ export class OpenAIWebSocketManager extends EventEmitter<InternalEvents> {
|
|||||||
private readonly wsUrl: string;
|
private readonly wsUrl: string;
|
||||||
private readonly maxRetries: number;
|
private readonly maxRetries: number;
|
||||||
private readonly backoffDelaysMs: readonly number[];
|
private readonly backoffDelaysMs: readonly number[];
|
||||||
private readonly socketFactory: (
|
private readonly socketFactory: (url: string, options: WebSocketClientOptions) => WebSocket;
|
||||||
url: string,
|
|
||||||
options: ConstructorParameters<typeof WebSocket>[1],
|
|
||||||
) => WebSocket;
|
|
||||||
|
|
||||||
constructor(options: OpenAIWebSocketManagerOptions = {}) {
|
constructor(options: OpenAIWebSocketManagerOptions = {}) {
|
||||||
super();
|
super();
|
||||||
|
|||||||
@ -1261,6 +1261,98 @@ describe("applyExtraParamsToAgent", () => {
|
|||||||
expect(calls[0]?.openaiWsWarmup).toBe(false);
|
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", () => {
|
it("lets runtime options override OpenAI default transport", () => {
|
||||||
const { calls, agent } = createOptionsCaptureAgent();
|
const { calls, agent } = createOptionsCaptureAgent();
|
||||||
|
|
||||||
|
|||||||
@ -581,6 +581,7 @@ export async function compactEmbeddedPiSessionDirect(
|
|||||||
abortSignal: runAbortController.signal,
|
abortSignal: runAbortController.signal,
|
||||||
modelProvider: model.provider,
|
modelProvider: model.provider,
|
||||||
modelId,
|
modelId,
|
||||||
|
modelApi: model.api,
|
||||||
modelContextWindowTokens: ctxInfo.tokens,
|
modelContextWindowTokens: ctxInfo.tokens,
|
||||||
modelAuthMode: resolveModelAuthMode(model.provider, params.config),
|
modelAuthMode: resolveModelAuthMode(model.provider, params.config),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -26,6 +26,8 @@ import {
|
|||||||
shouldApplySiliconFlowThinkingOffCompat,
|
shouldApplySiliconFlowThinkingOffCompat,
|
||||||
} from "./moonshot-stream-wrappers.js";
|
} from "./moonshot-stream-wrappers.js";
|
||||||
import {
|
import {
|
||||||
|
createCodexDefaultTransportWrapper,
|
||||||
|
createCodexNativeWebSearchWrapper,
|
||||||
createOpenAIAttributionHeadersWrapper,
|
createOpenAIAttributionHeadersWrapper,
|
||||||
createOpenAIDefaultTransportWrapper,
|
createOpenAIDefaultTransportWrapper,
|
||||||
createOpenAIFastModeWrapper,
|
createOpenAIFastModeWrapper,
|
||||||
@ -277,6 +279,7 @@ export function applyExtraParamsToAgent(
|
|||||||
extraParamsOverride?: Record<string, unknown>,
|
extraParamsOverride?: Record<string, unknown>,
|
||||||
thinkingLevel?: ThinkLevel,
|
thinkingLevel?: ThinkLevel,
|
||||||
agentId?: string,
|
agentId?: string,
|
||||||
|
agentDir?: string,
|
||||||
): void {
|
): void {
|
||||||
const resolvedExtraParams = resolveExtraParams({
|
const resolvedExtraParams = resolveExtraParams({
|
||||||
cfg,
|
cfg,
|
||||||
@ -308,6 +311,9 @@ export function applyExtraParamsToAgent(
|
|||||||
if (provider === "openai") {
|
if (provider === "openai") {
|
||||||
// Default OpenAI Responses to WebSocket-first with transparent SSE fallback.
|
// Default OpenAI Responses to WebSocket-first with transparent SSE fallback.
|
||||||
agent.streamFn = createOpenAIDefaultTransportWrapper(agent.streamFn);
|
agent.streamFn = createOpenAIDefaultTransportWrapper(agent.streamFn);
|
||||||
|
} else {
|
||||||
|
// Default Codex to WebSocket-first when nothing else specifies transport.
|
||||||
|
agent.streamFn = createCodexDefaultTransportWrapper(agent.streamFn);
|
||||||
}
|
}
|
||||||
agent.streamFn = createOpenAIAttributionHeadersWrapper(agent.streamFn);
|
agent.streamFn = createOpenAIAttributionHeadersWrapper(agent.streamFn);
|
||||||
}
|
}
|
||||||
@ -403,6 +409,11 @@ export function applyExtraParamsToAgent(
|
|||||||
agent.streamFn = createOpenAIServiceTierWrapper(agent.streamFn, openAIServiceTier);
|
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.
|
// Work around upstream pi-ai hardcoding `store: false` for Responses API.
|
||||||
// Force `store=true` for direct OpenAI Responses models and auto-enable
|
// Force `store=true` for direct OpenAI Responses models and auto-enable
|
||||||
// server-side compaction for compatible OpenAI Responses payloads.
|
// server-side compaction for compatible OpenAI Responses payloads.
|
||||||
|
|||||||
@ -1,6 +1,11 @@
|
|||||||
import type { StreamFn } from "@mariozechner/pi-agent-core";
|
import type { StreamFn } from "@mariozechner/pi-agent-core";
|
||||||
import type { SimpleStreamOptions } from "@mariozechner/pi-ai";
|
import type { SimpleStreamOptions } from "@mariozechner/pi-ai";
|
||||||
import { streamSimple } 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 { resolveProviderAttributionHeaders } from "../provider-attribution.js";
|
import { resolveProviderAttributionHeaders } from "../provider-attribution.js";
|
||||||
import { log } from "./logger.js";
|
import { log } from "./logger.js";
|
||||||
import { streamWithPayloadPatch } from "./stream-payload-utils.js";
|
import { streamWithPayloadPatch } from "./stream-payload-utils.js";
|
||||||
@ -369,6 +374,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 {
|
export function createCodexDefaultTransportWrapper(baseStreamFn: StreamFn | undefined): StreamFn {
|
||||||
const underlying = baseStreamFn ?? streamSimple;
|
const underlying = baseStreamFn ?? streamSimple;
|
||||||
return (model, context, options) =>
|
return (model, context, options) =>
|
||||||
|
|||||||
@ -1523,6 +1523,7 @@ export async function runEmbeddedAttempt(
|
|||||||
abortSignal: runAbortController.signal,
|
abortSignal: runAbortController.signal,
|
||||||
modelProvider: params.model.provider,
|
modelProvider: params.model.provider,
|
||||||
modelId: params.modelId,
|
modelId: params.modelId,
|
||||||
|
modelApi: params.model.api,
|
||||||
modelContextWindowTokens: params.model.contextWindow,
|
modelContextWindowTokens: params.model.contextWindow,
|
||||||
modelAuthMode: resolveModelAuthMode(params.model.provider, params.config),
|
modelAuthMode: resolveModelAuthMode(params.model.provider, params.config),
|
||||||
currentChannelId: params.currentChannelId,
|
currentChannelId: params.currentChannelId,
|
||||||
@ -1966,6 +1967,7 @@ export async function runEmbeddedAttempt(
|
|||||||
},
|
},
|
||||||
params.thinkLevel,
|
params.thinkLevel,
|
||||||
sessionAgentId,
|
sessionAgentId,
|
||||||
|
agentDir,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (cacheTrace) {
|
if (cacheTrace) {
|
||||||
|
|||||||
@ -39,4 +39,72 @@ describe("applyModelProviderToolPolicy", () => {
|
|||||||
|
|
||||||
expect(toolNames(filtered)).toEqual(["read", "exec"]);
|
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,
|
type ProcessToolDefaults,
|
||||||
} from "./bash-tools.js";
|
} from "./bash-tools.js";
|
||||||
import { listChannelAgentTools } from "./channel-tools.js";
|
import { listChannelAgentTools } from "./channel-tools.js";
|
||||||
|
import { shouldSuppressManagedWebSearchTool } from "./codex-native-web-search.js";
|
||||||
import { resolveImageSanitizationLimits } from "./image-sanitization.js";
|
import { resolveImageSanitizationLimits } from "./image-sanitization.js";
|
||||||
import type { ModelAuthMode } from "./model-auth.js";
|
import type { ModelAuthMode } from "./model-auth.js";
|
||||||
import { createOpenClawTools } from "./openclaw-tools.js";
|
import { createOpenClawTools } from "./openclaw-tools.js";
|
||||||
@ -92,14 +93,32 @@ function applyMessageProviderToolPolicy(
|
|||||||
|
|
||||||
function applyModelProviderToolPolicy(
|
function applyModelProviderToolPolicy(
|
||||||
tools: AnyAgentTool[],
|
tools: AnyAgentTool[],
|
||||||
params?: { modelProvider?: string; modelId?: string },
|
params?: {
|
||||||
|
config?: OpenClawConfig;
|
||||||
|
modelProvider?: string;
|
||||||
|
modelApi?: string;
|
||||||
|
modelId?: string;
|
||||||
|
agentDir?: string;
|
||||||
|
},
|
||||||
): AnyAgentTool[] {
|
): AnyAgentTool[] {
|
||||||
if (!isXaiProvider(params?.modelProvider, params?.modelId)) {
|
if (isXaiProvider(params?.modelProvider, params?.modelId)) {
|
||||||
return tools;
|
// 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.
|
if (
|
||||||
return tools.filter((tool) => !TOOL_DENY_FOR_XAI_PROVIDERS.has(tool.name));
|
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: {
|
function isApplyPatchAllowedForModel(params: {
|
||||||
@ -230,6 +249,8 @@ export function createOpenClawCodingTools(options?: {
|
|||||||
modelProvider?: string;
|
modelProvider?: string;
|
||||||
/** Model id for the current provider (used for model-specific tool gating). */
|
/** Model id for the current provider (used for model-specific tool gating). */
|
||||||
modelId?: string;
|
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). */
|
/** Model context window in tokens (used to scale read-tool output budget). */
|
||||||
modelContextWindowTokens?: number;
|
modelContextWindowTokens?: number;
|
||||||
/**
|
/**
|
||||||
@ -567,8 +588,11 @@ export function createOpenClawCodingTools(options?: {
|
|||||||
options?.messageProvider,
|
options?.messageProvider,
|
||||||
);
|
);
|
||||||
const toolsForModelProvider = applyModelProviderToolPolicy(toolsForMessageProvider, {
|
const toolsForModelProvider = applyModelProviderToolPolicy(toolsForMessageProvider, {
|
||||||
|
config: options?.config,
|
||||||
modelProvider: options?.modelProvider,
|
modelProvider: options?.modelProvider,
|
||||||
|
modelApi: options?.modelApi,
|
||||||
modelId: options?.modelId,
|
modelId: options?.modelId,
|
||||||
|
agentDir: options?.agentDir,
|
||||||
});
|
});
|
||||||
// Security: treat unknown/undefined as unauthorized (opt-in, not opt-out)
|
// Security: treat unknown/undefined as unauthorized (opt-in, not opt-out)
|
||||||
const senderIsOwner = options?.senderIsOwner === true;
|
const senderIsOwner = options?.senderIsOwner === true;
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import { isRemoteEnvironment } from "./oauth-env.js";
|
|||||||
import { createVpsAwareOAuthHandlers } from "./oauth-flow.js";
|
import { createVpsAwareOAuthHandlers } from "./oauth-flow.js";
|
||||||
import { openUrl } from "./onboard-helpers.js";
|
import { openUrl } from "./onboard-helpers.js";
|
||||||
import type { OnboardOptions } from "./onboard-types.js";
|
import type { OnboardOptions } from "./onboard-types.js";
|
||||||
|
import { resolveProviderPostAuthGuidance } from "./provider-auth-guidance.js";
|
||||||
import {
|
import {
|
||||||
applyDefaultModel,
|
applyDefaultModel,
|
||||||
mergeConfigPatch,
|
mergeConfigPatch,
|
||||||
@ -77,7 +78,11 @@ export async function runProviderPluginAuthMethod(params: {
|
|||||||
secretInputMode?: OnboardOptions["secretInputMode"];
|
secretInputMode?: OnboardOptions["secretInputMode"];
|
||||||
allowSecretRefPrompt?: boolean;
|
allowSecretRefPrompt?: boolean;
|
||||||
opts?: Partial<OnboardOptions>;
|
opts?: Partial<OnboardOptions>;
|
||||||
}): Promise<{ config: ApplyAuthChoiceParams["config"]; defaultModel?: string }> {
|
}): Promise<{
|
||||||
|
config: ApplyAuthChoiceParams["config"];
|
||||||
|
defaultModel?: string;
|
||||||
|
profileCount: number;
|
||||||
|
}> {
|
||||||
const agentId = params.agentId ?? resolveDefaultAgentId(params.config);
|
const agentId = params.agentId ?? resolveDefaultAgentId(params.config);
|
||||||
const defaultAgentId = resolveDefaultAgentId(params.config);
|
const defaultAgentId = resolveDefaultAgentId(params.config);
|
||||||
const agentDir =
|
const agentDir =
|
||||||
@ -138,6 +143,7 @@ export async function runProviderPluginAuthMethod(params: {
|
|||||||
return {
|
return {
|
||||||
config: nextConfig,
|
config: nextConfig,
|
||||||
defaultModel: result.defaultModel,
|
defaultModel: result.defaultModel,
|
||||||
|
profileCount: result.profiles.length,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -177,6 +183,11 @@ export async function applyAuthChoiceLoadedPluginProvider(
|
|||||||
});
|
});
|
||||||
|
|
||||||
let nextConfig = applied.config;
|
let nextConfig = applied.config;
|
||||||
|
if (applied.profileCount > 0 || applied.defaultModel) {
|
||||||
|
for (const guidance of resolveProviderPostAuthGuidance(resolved.provider.id)) {
|
||||||
|
await params.prompter.note(guidance.message, guidance.title);
|
||||||
|
}
|
||||||
|
}
|
||||||
let agentModelOverride: string | undefined;
|
let agentModelOverride: string | undefined;
|
||||||
if (applied.defaultModel) {
|
if (applied.defaultModel) {
|
||||||
if (params.setDefaultModel) {
|
if (params.setDefaultModel) {
|
||||||
@ -263,6 +274,11 @@ export async function applyAuthChoicePluginProvider(
|
|||||||
opts: params.opts as ProviderAuthOptionBag | undefined,
|
opts: params.opts as ProviderAuthOptionBag | undefined,
|
||||||
});
|
});
|
||||||
nextConfig = applied.config;
|
nextConfig = applied.config;
|
||||||
|
if (applied.profileCount > 0 || applied.defaultModel) {
|
||||||
|
for (const guidance of resolveProviderPostAuthGuidance(provider.id)) {
|
||||||
|
await params.prompter.note(guidance.message, guidance.title);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let agentModelOverride: string | undefined;
|
let agentModelOverride: string | undefined;
|
||||||
if (applied.defaultModel) {
|
if (applied.defaultModel) {
|
||||||
|
|||||||
@ -282,7 +282,8 @@ describe("applyAuthChoice", () => {
|
|||||||
},
|
},
|
||||||
] as never);
|
] as never);
|
||||||
|
|
||||||
const prompter = createPrompter({});
|
const note = vi.fn(async () => {});
|
||||||
|
const prompter = createPrompter({ note });
|
||||||
const runtime = createExitThrowingRuntime();
|
const runtime = createExitThrowingRuntime();
|
||||||
|
|
||||||
const result = await applyAuthChoice({
|
const result = await applyAuthChoice({
|
||||||
@ -305,6 +306,10 @@ describe("applyAuthChoice", () => {
|
|||||||
access: "access-token",
|
access: "access-token",
|
||||||
email: "user@example.com",
|
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 () => {
|
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";
|
import type { OpenClawConfig } from "../config/config.js";
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
@ -99,6 +99,11 @@ import { WizardCancelledError } from "../wizard/prompts.js";
|
|||||||
import { runConfigureWizard } from "./configure.wizard.js";
|
import { runConfigureWizard } from "./configure.wizard.js";
|
||||||
|
|
||||||
describe("runConfigureWizard", () => {
|
describe("runConfigureWizard", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mocks.ensureControlUiAssetsBuilt.mockResolvedValue({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
it("persists gateway.mode=local when only the run mode is selected", async () => {
|
it("persists gateway.mode=local when only the run mode is selected", async () => {
|
||||||
mocks.readConfigFileSnapshot.mockResolvedValue({
|
mocks.readConfigFileSnapshot.mockResolvedValue({
|
||||||
exists: false,
|
exists: false,
|
||||||
@ -158,4 +163,116 @@ describe("runConfigureWizard", () => {
|
|||||||
|
|
||||||
expect(runtime.exit).toHaveBeenCalledWith(1);
|
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,
|
applySearchKey,
|
||||||
hasKeyInEnv,
|
hasKeyInEnv,
|
||||||
} = await import("./onboard-search.js");
|
} = await import("./onboard-search.js");
|
||||||
|
const { describeCodexNativeWebSearch, isCodexNativeWebSearchRelevant } =
|
||||||
|
await import("../agents/codex-native-web-search.js");
|
||||||
type SP = (typeof SEARCH_PROVIDER_OPTIONS)[number]["value"];
|
type SP = (typeof SEARCH_PROVIDER_OPTIONS)[number]["value"];
|
||||||
const defaultProvider = SEARCH_PROVIDER_OPTIONS[0]?.value;
|
const defaultProvider = SEARCH_PROVIDER_OPTIONS[0]?.value;
|
||||||
if (!defaultProvider) {
|
if (!defaultProvider) {
|
||||||
@ -200,7 +202,7 @@ async function promptWebToolsConfig(
|
|||||||
note(
|
note(
|
||||||
[
|
[
|
||||||
"Web search lets your agent look things up online using the `web_search` tool.",
|
"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",
|
"Docs: https://docs.openclaw.ai/tools/web",
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
"Web search",
|
"Web search",
|
||||||
@ -221,62 +223,137 @@ async function promptWebToolsConfig(
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (enableSearch) {
|
if (enableSearch) {
|
||||||
const providerOptions = SEARCH_PROVIDER_OPTIONS.map((entry) => {
|
const codexRelevant = isCodexNativeWebSearchRelevant({ config: nextConfig });
|
||||||
const configured = hasKeyForProvider(entry.value);
|
let configureManagedProvider = true;
|
||||||
return {
|
if (codexRelevant) {
|
||||||
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 {
|
|
||||||
note(
|
note(
|
||||||
[
|
[
|
||||||
"No key stored yet — web_search won't work until a key is available.",
|
"Codex-capable models can optionally use native Codex web search.",
|
||||||
`Store a key here or set ${envVarNames} in the Gateway environment.`,
|
"Managed web_search still controls non-Codex models.",
|
||||||
`Get your API key at: ${entry.signupUrl}`,
|
"If no managed provider is configured, non-Codex models still rely on provider auto-detect and may have no search available.",
|
||||||
"Docs: https://docs.openclaw.ai/tools/web",
|
...(describeCodexNativeWebSearch(nextConfig)
|
||||||
|
? [describeCodexNativeWebSearch(nextConfig)!]
|
||||||
|
: ["Recommended mode: cached."]),
|
||||||
].join("\n"),
|
].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",
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -216,6 +216,9 @@ describe("modelsAuthLoginCommand", () => {
|
|||||||
expect(runtime.log).toHaveBeenCalledWith(
|
expect(runtime.log).toHaveBeenCalledWith(
|
||||||
"Default model available: openai-codex/gpt-5.4 (use --set-default to apply)",
|
"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 () => {
|
it("applies openai-codex default model when --set-default is used", async () => {
|
||||||
|
|||||||
@ -36,6 +36,7 @@ import { createClackPrompter } from "../../wizard/clack-prompter.js";
|
|||||||
import { isRemoteEnvironment } from "../oauth-env.js";
|
import { isRemoteEnvironment } from "../oauth-env.js";
|
||||||
import { createVpsAwareOAuthHandlers } from "../oauth-flow.js";
|
import { createVpsAwareOAuthHandlers } from "../oauth-flow.js";
|
||||||
import { openUrl } from "../onboard-helpers.js";
|
import { openUrl } from "../onboard-helpers.js";
|
||||||
|
import { resolveProviderPostAuthGuidance } from "../provider-auth-guidance.js";
|
||||||
import {
|
import {
|
||||||
applyDefaultModel,
|
applyDefaultModel,
|
||||||
mergeConfigPatch,
|
mergeConfigPatch,
|
||||||
@ -297,6 +298,14 @@ async function runProviderAuthMethod(params: {
|
|||||||
prompter: params.prompter,
|
prompter: params.prompter,
|
||||||
setDefault: params.setDefault,
|
setDefault: params.setDefault,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (result.profiles.length > 0 || result.defaultModel) {
|
||||||
|
for (const guidance of resolveProviderPostAuthGuidance(params.provider.id)) {
|
||||||
|
if (guidance.runtimeMessage) {
|
||||||
|
params.runtime.log(guidance.runtimeMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function modelsAuthSetupTokenCommand(
|
export async function modelsAuthSetupTokenCommand(
|
||||||
|
|||||||
@ -12,11 +12,21 @@ const runtime: RuntimeEnv = {
|
|||||||
}) as RuntimeEnv["exit"],
|
}) 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;
|
prompter: WizardPrompter;
|
||||||
notes: Array<{ title?: string; message: string }>;
|
notes: Array<{ title?: string; message: string }>;
|
||||||
} {
|
} {
|
||||||
const 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 = {
|
const prompter: WizardPrompter = {
|
||||||
intro: vi.fn(async () => {}),
|
intro: vi.fn(async () => {}),
|
||||||
outro: vi.fn(async () => {}),
|
outro: vi.fn(async () => {}),
|
||||||
@ -24,11 +34,11 @@ function createPrompter(params: { selectValue?: string; textValue?: string }): {
|
|||||||
notes.push({ title, message });
|
notes.push({ title, message });
|
||||||
}),
|
}),
|
||||||
select: vi.fn(
|
select: vi.fn(
|
||||||
async () => params.selectValue ?? "perplexity",
|
async () => selectQueue.shift() ?? params.selectValue ?? "perplexity",
|
||||||
) as unknown as WizardPrompter["select"],
|
) as unknown as WizardPrompter["select"],
|
||||||
multiselect: vi.fn(async () => []) as unknown as WizardPrompter["multiselect"],
|
multiselect: vi.fn(async () => []) as unknown as WizardPrompter["multiselect"],
|
||||||
text: vi.fn(async () => params.textValue ?? ""),
|
text: vi.fn(async () => textQueue.shift() ?? params.textValue ?? ""),
|
||||||
confirm: vi.fn(async () => true),
|
confirm: vi.fn(async () => confirmQueue.shift() ?? params.confirmValue ?? true),
|
||||||
progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })),
|
progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })),
|
||||||
};
|
};
|
||||||
return { prompter, notes };
|
return { prompter, notes };
|
||||||
@ -73,11 +83,12 @@ async function runQuickstartPerplexitySetup(
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("setupSearch", () => {
|
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 cfg: OpenClawConfig = {};
|
||||||
const { prompter } = createPrompter({ selectValue: "__skip__" });
|
const { prompter } = createPrompter({ selectValue: "__skip__" });
|
||||||
const result = await setupSearch(cfg, runtime, prompter);
|
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 () => {
|
it("sets provider and key for perplexity", async () => {
|
||||||
@ -164,7 +175,7 @@ describe("setupSearch", () => {
|
|||||||
});
|
});
|
||||||
const result = await setupSearch(cfg, runtime, prompter);
|
const result = await setupSearch(cfg, runtime, prompter);
|
||||||
expect(result.tools?.web?.search?.provider).toBe("brave");
|
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"));
|
const missingNote = notes.find((n) => n.message.includes("No API key stored"));
|
||||||
expect(missingNote).toBeDefined();
|
expect(missingNote).toBeDefined();
|
||||||
} finally {
|
} finally {
|
||||||
@ -184,11 +195,13 @@ describe("setupSearch", () => {
|
|||||||
expect(result.tools?.web?.search?.enabled).toBe(true);
|
expect(result.tools?.web?.search?.enabled).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("advanced preserves enabled:false when keeping existing key", async () => {
|
it("keeps search disabled when the onboarding toggle is declined", async () => {
|
||||||
const result = await runBlankPerplexityKeyEntry(
|
const cfg = createPerplexityConfig(
|
||||||
"existing-key", // pragma: allowlist secret
|
"existing-key", // pragma: allowlist secret
|
||||||
false,
|
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?.perplexity?.apiKey).toBe("existing-key");
|
||||||
expect(result.tools?.web?.search?.enabled).toBe(false);
|
expect(result.tools?.web?.search?.enabled).toBe(false);
|
||||||
});
|
});
|
||||||
@ -203,11 +216,15 @@ describe("setupSearch", () => {
|
|||||||
expect(prompter.text).not.toHaveBeenCalled();
|
expect(prompter.text).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("quickstart preserves enabled:false when search was intentionally disabled", async () => {
|
it("quickstart keeps search disabled when the onboarding toggle is declined", async () => {
|
||||||
const { result, prompter } = await runQuickstartPerplexitySetup(
|
const cfg = createPerplexityConfig(
|
||||||
"stored-pplx-key", // pragma: allowlist secret
|
"stored-pplx-key", // pragma: allowlist secret
|
||||||
false,
|
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?.provider).toBe("perplexity");
|
||||||
expect(result.tools?.web?.search?.perplexity?.apiKey).toBe("stored-pplx-key");
|
expect(result.tools?.web?.search?.perplexity?.apiKey).toBe("stored-pplx-key");
|
||||||
expect(result.tools?.web?.search?.enabled).toBe(false);
|
expect(result.tools?.web?.search?.enabled).toBe(false);
|
||||||
@ -225,7 +242,7 @@ describe("setupSearch", () => {
|
|||||||
});
|
});
|
||||||
expect(prompter.text).toHaveBeenCalled();
|
expect(prompter.text).toHaveBeenCalled();
|
||||||
expect(result.tools?.web?.search?.provider).toBe("grok");
|
expect(result.tools?.web?.search?.provider).toBe("grok");
|
||||||
expect(result.tools?.web?.search?.enabled).toBeUndefined();
|
expect(result.tools?.web?.search?.enabled).toBe(true);
|
||||||
} finally {
|
} finally {
|
||||||
if (original === undefined) {
|
if (original === undefined) {
|
||||||
delete process.env.XAI_API_KEY;
|
delete process.env.XAI_API_KEY;
|
||||||
@ -344,6 +361,59 @@ describe("setupSearch", () => {
|
|||||||
expect(result.tools?.web?.search?.apiKey).toBe("BSA-plain");
|
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 6 providers in SEARCH_PROVIDER_OPTIONS", () => {
|
it("exports all 6 providers in SEARCH_PROVIDER_OPTIONS", () => {
|
||||||
expect(SEARCH_PROVIDER_OPTIONS).toHaveLength(6);
|
expect(SEARCH_PROVIDER_OPTIONS).toHaveLength(6);
|
||||||
const values = SEARCH_PROVIDER_OPTIONS.map((e) => e.value);
|
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 type { OpenClawConfig } from "../config/config.js";
|
||||||
import {
|
import {
|
||||||
DEFAULT_SECRET_PROVIDER_ALIAS,
|
DEFAULT_SECRET_PROVIDER_ALIAS,
|
||||||
@ -162,7 +166,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 = {
|
export type SetupSearchOptions = {
|
||||||
|
agentId?: string;
|
||||||
|
agentDir?: string;
|
||||||
quickstartDefaults?: boolean;
|
quickstartDefaults?: boolean;
|
||||||
secretInputMode?: SecretInputMode;
|
secretInputMode?: SecretInputMode;
|
||||||
};
|
};
|
||||||
@ -176,16 +206,102 @@ export async function setupSearch(
|
|||||||
await prompter.note(
|
await prompter.note(
|
||||||
[
|
[
|
||||||
"Web search lets your agent look things up online.",
|
"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",
|
"Docs: https://docs.openclaw.ai/tools/web",
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
"Web search",
|
"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 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;
|
const hint = configured ? `${entry.hint} · configured` : entry.hint;
|
||||||
return { value: entry.value, label: entry.label, hint };
|
return { value: entry.value, label: entry.label, hint };
|
||||||
});
|
});
|
||||||
@ -195,7 +311,7 @@ export async function setupSearch(
|
|||||||
return existingProvider;
|
return existingProvider;
|
||||||
}
|
}
|
||||||
const detected = SEARCH_PROVIDER_OPTIONS.find(
|
const detected = SEARCH_PROVIDER_OPTIONS.find(
|
||||||
(e) => hasExistingKey(config, e.value) || hasKeyInEnv(e),
|
(e) => hasExistingKey(nextConfig, e.value) || hasKeyInEnv(e),
|
||||||
);
|
);
|
||||||
if (detected) {
|
if (detected) {
|
||||||
return detected.value;
|
return detected.value;
|
||||||
@ -218,25 +334,25 @@ export async function setupSearch(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (choice === "__skip__") {
|
if (choice === "__skip__") {
|
||||||
return config;
|
return nextConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
const entry = SEARCH_PROVIDER_OPTIONS.find((e) => e.value === choice)!;
|
const entry = SEARCH_PROVIDER_OPTIONS.find((e) => e.value === choice)!;
|
||||||
const existingKey = resolveExistingKey(config, choice);
|
const existingKey = resolveExistingKey(nextConfig, choice);
|
||||||
const keyConfigured = hasExistingKey(config, choice);
|
const keyConfigured = hasExistingKey(nextConfig, choice);
|
||||||
const envAvailable = hasKeyInEnv(entry);
|
const envAvailable = hasKeyInEnv(entry);
|
||||||
|
|
||||||
if (opts?.quickstartDefaults && (keyConfigured || envAvailable)) {
|
if (opts?.quickstartDefaults && (keyConfigured || envAvailable)) {
|
||||||
const result = existingKey
|
const result = existingKey
|
||||||
? applySearchKey(config, choice, existingKey)
|
? applySearchKey(nextConfig, choice, existingKey)
|
||||||
: applyProviderOnly(config, choice);
|
: applyProviderOnly(nextConfig, choice);
|
||||||
return preserveDisabledState(config, result);
|
return preserveDisabledState(nextConfig, result);
|
||||||
}
|
}
|
||||||
|
|
||||||
const useSecretRefMode = opts?.secretInputMode === "ref"; // pragma: allowlist secret
|
const useSecretRefMode = opts?.secretInputMode === "ref"; // pragma: allowlist secret
|
||||||
if (useSecretRefMode) {
|
if (useSecretRefMode) {
|
||||||
if (keyConfigured) {
|
if (keyConfigured) {
|
||||||
return preserveDisabledState(config, applyProviderOnly(config, choice));
|
return preserveDisabledState(nextConfig, applyProviderOnly(nextConfig, choice));
|
||||||
}
|
}
|
||||||
const ref = buildSearchEnvRef(choice);
|
const ref = buildSearchEnvRef(choice);
|
||||||
await prompter.note(
|
await prompter.note(
|
||||||
@ -248,7 +364,7 @@ export async function setupSearch(
|
|||||||
].join("\n"),
|
].join("\n"),
|
||||||
"Web search",
|
"Web search",
|
||||||
);
|
);
|
||||||
return applySearchKey(config, choice, ref);
|
return applySearchKey(nextConfig, choice, ref);
|
||||||
}
|
}
|
||||||
|
|
||||||
const keyInput = await prompter.text({
|
const keyInput = await prompter.text({
|
||||||
@ -263,15 +379,15 @@ export async function setupSearch(
|
|||||||
const key = keyInput?.trim() ?? "";
|
const key = keyInput?.trim() ?? "";
|
||||||
if (key) {
|
if (key) {
|
||||||
const secretInput = resolveSearchSecretInput(choice, key, opts?.secretInputMode);
|
const secretInput = resolveSearchSecretInput(choice, key, opts?.secretInputMode);
|
||||||
return applySearchKey(config, choice, secretInput);
|
return applySearchKey(nextConfig, choice, secretInput);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (existingKey) {
|
if (existingKey) {
|
||||||
return preserveDisabledState(config, applySearchKey(config, choice, existingKey));
|
return preserveDisabledState(nextConfig, applySearchKey(nextConfig, choice, existingKey));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (keyConfigured || envAvailable) {
|
if (keyConfigured || envAvailable) {
|
||||||
return preserveDisabledState(config, applyProviderOnly(config, choice));
|
return preserveDisabledState(nextConfig, applyProviderOnly(nextConfig, choice));
|
||||||
}
|
}
|
||||||
|
|
||||||
await prompter.note(
|
await prompter.note(
|
||||||
@ -284,13 +400,13 @@ export async function setupSearch(
|
|||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...config,
|
...nextConfig,
|
||||||
tools: {
|
tools: {
|
||||||
...config.tools,
|
...nextConfig.tools,
|
||||||
web: {
|
web: {
|
||||||
...config.tools?.web,
|
...nextConfig.tools?.web,
|
||||||
search: {
|
search: {
|
||||||
...config.tools?.web?.search,
|
...nextConfig.tools?.web?.search,
|
||||||
provider: choice,
|
provider: choice,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -36,6 +36,31 @@ export function resolveProviderAuthLoginCommand(params: {
|
|||||||
return formatCliCommand(`openclaw models auth login --provider ${provider.id}`);
|
return formatCliCommand(`openclaw models auth login --provider ${provider.id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ProviderPostAuthGuidance = {
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
runtimeMessage?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function resolveProviderPostAuthGuidance(provider: string): ProviderPostAuthGuidance[] {
|
||||||
|
if (normalizeProviderId(provider) !== "openai-codex") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
title: "Web search",
|
||||||
|
message: [
|
||||||
|
"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"),
|
||||||
|
runtimeMessage:
|
||||||
|
"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 function buildProviderAuthRecoveryHint(params: {
|
export function buildProviderAuthRecoveryHint(params: {
|
||||||
provider: string;
|
provider: string;
|
||||||
config?: OpenClawConfig;
|
config?: OpenClawConfig;
|
||||||
|
|||||||
@ -665,13 +665,30 @@ export const FIELD_HELP: Record<string, string> = {
|
|||||||
"tools.message.crossContext.marker.suffix":
|
"tools.message.crossContext.marker.suffix":
|
||||||
'Text suffix for cross-context markers (supports "{channel}").',
|
'Text suffix for cross-context markers (supports "{channel}").',
|
||||||
"tools.message.broadcast.enabled": "Enable broadcast action (default: true).",
|
"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":
|
"tools.web.search.provider":
|
||||||
'Search provider ("brave", "firecrawl", "gemini", "grok", "kimi", or "perplexity"). Auto-detected from available API keys if omitted.',
|
'Search provider ("brave", "firecrawl", "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.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.maxResults": "Number of results to return (1-10).",
|
||||||
"tools.web.search.timeoutSeconds": "Timeout in seconds for web_search requests.",
|
"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.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":
|
"tools.web.search.brave.mode":
|
||||||
'Brave Search mode: "web" (URL results) or "llm-context" (pre-extracted page content for LLM grounding).',
|
'Brave Search mode: "web" (URL results) or "llm-context" (pre-extracted page content for LLM grounding).',
|
||||||
"tools.web.search.firecrawl.apiKey":
|
"tools.web.search.firecrawl.apiKey":
|
||||||
|
|||||||
@ -220,6 +220,14 @@ export const FIELD_LABELS: Record<string, string> = {
|
|||||||
"tools.web.search.maxResults": "Web Search Max Results",
|
"tools.web.search.maxResults": "Web Search Max Results",
|
||||||
"tools.web.search.timeoutSeconds": "Web Search Timeout (sec)",
|
"tools.web.search.timeoutSeconds": "Web Search Timeout (sec)",
|
||||||
"tools.web.search.cacheTtlMinutes": "Web Search Cache TTL (min)",
|
"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.brave.mode": "Brave Search Mode",
|
||||||
"tools.web.search.firecrawl.apiKey": "Firecrawl Search API Key", // pragma: allowlist secret
|
"tools.web.search.firecrawl.apiKey": "Firecrawl Search API Key", // pragma: allowlist secret
|
||||||
"tools.web.search.firecrawl.baseUrl": "Firecrawl Search Base URL",
|
"tools.web.search.firecrawl.baseUrl": "Firecrawl Search Base URL",
|
||||||
|
|||||||
@ -455,7 +455,7 @@ export type ToolsConfig = {
|
|||||||
byProvider?: Record<string, ToolPolicyConfig>;
|
byProvider?: Record<string, ToolPolicyConfig>;
|
||||||
web?: {
|
web?: {
|
||||||
search?: {
|
search?: {
|
||||||
/** Enable web search tool (default: true when API key is present). */
|
/** Enable managed web_search and optional Codex-native web search. */
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
/** Search provider ("brave", "firecrawl", "gemini", "grok", "kimi", or "perplexity"). */
|
/** Search provider ("brave", "firecrawl", "gemini", "grok", "kimi", or "perplexity"). */
|
||||||
provider?: "brave" | "firecrawl" | "gemini" | "grok" | "kimi" | "perplexity";
|
provider?: "brave" | "firecrawl" | "gemini" | "grok" | "kimi" | "perplexity";
|
||||||
@ -467,6 +467,24 @@ export type ToolsConfig = {
|
|||||||
timeoutSeconds?: number;
|
timeoutSeconds?: number;
|
||||||
/** Cache TTL in minutes for search results. */
|
/** Cache TTL in minutes for search results. */
|
||||||
cacheTtlMinutes?: number;
|
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-specific configuration (used when provider="brave"). */
|
||||||
brave?: {
|
brave?: {
|
||||||
/** Brave Search mode: "web" (standard results) or "llm-context" (pre-extracted page content). Default: "web". */
|
/** 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"]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -260,6 +260,37 @@ export const ToolPolicySchema = ToolPolicyBaseSchema.superRefine((value, ctx) =>
|
|||||||
}
|
}
|
||||||
}).optional();
|
}).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
|
export const ToolsWebSearchSchema = z
|
||||||
.object({
|
.object({
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
@ -277,6 +308,16 @@ export const ToolsWebSearchSchema = z
|
|||||||
maxResults: z.number().int().positive().optional(),
|
maxResults: z.number().int().positive().optional(),
|
||||||
timeoutSeconds: z.number().int().positive().optional(),
|
timeoutSeconds: z.number().int().positive().optional(),
|
||||||
cacheTtlMinutes: z.number().nonnegative().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
|
perplexity: z
|
||||||
.object({
|
.object({
|
||||||
apiKey: SecretInputSchema.optional().register(sensitive),
|
apiKey: SecretInputSchema.optional().register(sensitive),
|
||||||
|
|||||||
@ -13,7 +13,10 @@ export type {
|
|||||||
VideoDescriptionResult,
|
VideoDescriptionResult,
|
||||||
} from "../media-understanding/types.js";
|
} from "../media-understanding/types.js";
|
||||||
|
|
||||||
export { describeImageWithModel, describeImagesWithModel } from "../media-understanding/providers/image.js";
|
export {
|
||||||
|
describeImageWithModel,
|
||||||
|
describeImagesWithModel,
|
||||||
|
} from "../media-understanding/providers/image.js";
|
||||||
export { transcribeOpenAiCompatibleAudio } from "../media-understanding/providers/openai-compatible-audio.js";
|
export { transcribeOpenAiCompatibleAudio } from "../media-understanding/providers/openai-compatible-audio.js";
|
||||||
export {
|
export {
|
||||||
assertOkOrThrowHttpError,
|
assertOkOrThrowHttpError,
|
||||||
|
|||||||
@ -307,4 +307,104 @@ describe("finalizeSetupWizard", () => {
|
|||||||
expect(progressUpdate).toHaveBeenCalledWith("Restarting Gateway service…");
|
expect(progressUpdate).toHaveBeenCalledWith("Restarting Gateway service…");
|
||||||
expect(progressStop).toHaveBeenCalledWith("Gateway service restart scheduled.");
|
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 finalizeSetupWizard({
|
||||||
|
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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not show a Codex native search summary when web search is globally disabled", async () => {
|
||||||
|
const note = vi.fn(async () => {});
|
||||||
|
const prompter = buildWizardPrompter({
|
||||||
|
note,
|
||||||
|
select: vi.fn(async () => "later") as never,
|
||||||
|
confirm: vi.fn(async () => false),
|
||||||
|
});
|
||||||
|
|
||||||
|
await finalizeSetupWizard({
|
||||||
|
flow: "advanced",
|
||||||
|
opts: {
|
||||||
|
acceptRisk: true,
|
||||||
|
authChoice: "skip",
|
||||||
|
installDaemon: false,
|
||||||
|
skipHealth: true,
|
||||||
|
skipUi: true,
|
||||||
|
},
|
||||||
|
baseConfig: {},
|
||||||
|
nextConfig: {
|
||||||
|
tools: {
|
||||||
|
web: {
|
||||||
|
search: {
|
||||||
|
enabled: false,
|
||||||
|
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).not.toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("Codex native search:"),
|
||||||
|
"Codex native search",
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -481,6 +481,8 @@ export async function finalizeSetupWizard(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { describeCodexNativeWebSearch } = await import("../agents/codex-native-web-search.js");
|
||||||
|
const codexNativeSummary = describeCodexNativeWebSearch(nextConfig);
|
||||||
const webSearchProvider = nextConfig.tools?.web?.search?.provider;
|
const webSearchProvider = nextConfig.tools?.web?.search?.provider;
|
||||||
const webSearchEnabled = nextConfig.tools?.web?.search?.enabled;
|
const webSearchEnabled = nextConfig.tools?.web?.search?.enabled;
|
||||||
if (webSearchProvider) {
|
if (webSearchProvider) {
|
||||||
@ -549,6 +551,15 @@ export async function finalizeSetupWizard(
|
|||||||
].join("\n"),
|
].join("\n"),
|
||||||
"Web search",
|
"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 {
|
} else {
|
||||||
await prompter.note(
|
await prompter.note(
|
||||||
[
|
[
|
||||||
@ -562,6 +573,17 @@ export async function finalizeSetupWizard(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
await prompter.note(
|
||||||
'What now: https://openclaw.ai/showcase ("What People Are Building").',
|
'What now: https://openclaw.ai/showcase ("What People Are Building").',
|
||||||
"What now",
|
"What now",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user