Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -987,8 +987,8 @@ declaration until they have a complete handler and execution path.
(`CodexNamespaceHandler`), and `tool_search.rs` (`ToolSearchHandler`). Their calls
are returned for the client to resolve; the gateway does not execute them.
- **Gateway-owned / built-in** tools implement both traits: see `web_search/mod.rs`
(`WebSearchHandler`, backed by the configured `WebSearchProvider` in `web_search/you.rs`
or `web_search/brave.rs`) and `mcp/handler.rs` (`McpHandler`, backed
(`WebSearchHandler`, backed by the configured `WebSearchProvider` in `web_search/you.rs`,
`web_search/brave.rs`, or `web_search/searxng.rs`) and `mcp/handler.rs` (`McpHandler`, backed
by `mcp/client.rs`'s MCP protocol client and `mcp/pool.rs`'s connection pool). They
have no client translator association because the gateway owns their execution and
public lifecycle.
Expand Down
49 changes: 22 additions & 27 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,20 @@ All notable changes to Agentic API are documented here.

### Added

- Verified image preservation through the Responses gateway end to end (#253): integration coverage for mixed
text/image ordering, multiple images per turn, client-executed `view_image` tool output, `previous_response_id`
continuation, `conversation_id` rehydration, stateless `store: false` proxying, and compaction of retained
image-bearing user messages, over both the HTTP and WebSocket transports.
- Recorded paired image cassettes — client → OpenAI as the reference and client → gateway → vLLM serving
`Qwen/Qwen2.5-VL-3B-Instruct` — for a text-and-image message, two interleaved images, a `previous_response_id`
follow-up, and a client-executed tool returning an image through a structured `function_call_output`, each
streaming and non-streaming. Replay coverage compares request shape, completed-response structure, the streaming
event lifecycle, and the history the gateway forwards on continuation; model wording is never compared (#253).
The cassette recorder accepts `--input-file` for the first of several turns and sends a tool handler's list of
content parts as a structured output array.
- Added automated Docker Hub release and nightly container publishing with 30-day nightly tag retention (#322).
- Added SearXNG as a selectable backend for the gateway-owned `web_search` tool (#326, Phase 3 of #291). Select it
with `AGENTIC_WEB_SEARCH_PROVIDER=searxng` or `[web_search] provider = "searxng"` and point
`AGENTIC_WEB_SEARCH_BASE_URL` or `[web_search] base_url` at a self-hosted instance; the endpoint is mandatory
(an absolute `http(s)` URL without a query or fragment, sub-path mounts allowed) and the server refuses to start
without it. No API key is needed; `SEARXNG_API_KEY` (or the variable named by
`api_key_env`) is sent as a `Bearer` token only when set. Web and news results come from one
`format=json&categories=general,news` request per query, split by category. The gateway adapts the shared tool
contract: `allowed_domains` / `blocked_domains` and the model's `include_domains` / `exclude_domains` are enforced
client-side on a label boundary, `count` is applied client-side after filtering, `freshness` maps to `time_range`
(date ranges are ignored), `language` is normalized to SearXNG's `xx` / `xx-YY` form, `safesearch` maps to
`0` / `1` / `2`, and `country` plus the You.com-specific arguments are ignored. A `403` is reported as the JSON
format being disabled, and a `429` explains SearXNG's bot-detection limiter, which rejects the gateway's
`Accept-Encoding`-free requests unless its address is on `pass_ip`; neither is retried. Each SearXNG `metadata[]`
entry carries `"provider": "searxng"`. Concurrency inherits `max_concurrent_gateway_calls`.
- Added typed per-model input-modality overrides to `config.toml`
(`[models."<served-model-id>"] input_modalities = ["text", "image"]`), validated at startup:
unknown modality names, empty lists, duplicates, and image-only lists are rejected with the
Expand All @@ -39,21 +41,14 @@ All notable changes to Agentic API are documented here.

### Changed

- Modeled `refusal` as an assistant-history content part so OpenAI-style history replays through the typed
Responses executor instead of being rejected as unmodeled (#253).
- Changed Rust input-content APIs (#263): `InputTextContent`, `InputImageContent`, and `InputFileContent` now retain
unmodeled fields in `extra`. Use `InputTextContent::new(text)` or supply `extra: Default::default()` when migrating
struct literals. `InputContent` gains `Refusal(RefusalContent)` and replaces the unit `Unknown` variant with
`Unknown(String)`; update exhaustive matches and constructors. `Unknown` cannot be serialized and typed execution
rejects it with the original content type in the error. Existing content-type re-export paths are preserved.
- Rust `agentic_core::config::Config` struct literals must now provide `responses: ResponsesConfig::default()`
(or validated custom limits). `ExecutionContext::new` keeps its signature and defaults; use
`ExecutionContext::with_responses_config` to override them. `ExecuteRequest::with_max_stream_event_bytes` and
`GatewayStreamAccumulator::with_max_stream_event_bytes` add explicit delivery limits; existing constructors and
`call_inference` remain available, with `inference::call_inference_limited` exposing a custom SSE-line limit.
- Response-size failures now use `ExecutorError::ResourceLimitExceeded { limit, max_bytes }`; `ResourceLimit` is
re-exported from `agentic_core::executor`. Callers classifying size failures should handle this typed variant
instead of inspecting error messages (#304).
- `WebSearchProviderKind` gains a `Searxng` variant (`"searxng"`) with no default endpoint, `SEARXNG_API_KEY` as
its conventional key variable, and no provider concurrency ceiling. `WebSearchProviderKind::ALL` grows from
`[Self; 2]` to `[Self; 3]` (it enumerates every selectable provider and will grow again with each one); iterating
it is unaffected, but code that destructured or annotated the fixed length must be updated.
`agentic_core::tool::SEARXNG_BASE_URL_HINT` and `validate_searxng_base_url` carry the operator-facing rules for
the mandatory endpoint (absolute `http(s)` URL with a host and no query or fragment). The shared
`null_as_default` and `read_response_limited` helpers moved from `web_search/mod.rs` to `web_search/provider.rs`
(crate-private, re-exported unchanged).
- Modeled the Codex model catalog and the upstream model listing as typed Rust structs instead of
untyped JSON, and reported an undecodable upstream `/v1/models` payload as `502` rather than
serving it as an empty catalog (#252).
Expand Down
60 changes: 53 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ flowchart LR
## ✨ Key Features

- 🔄 **Stateful conversations**: the server manages history via `previous_response_id`. No client-side message tracking, no replaying full transcripts.
- 🛠️ **Server-side tool execution**: an explicit tool-ownership model (gateway / client / provider) decides exactly what runs where. Web search ships today via [You.com](https://you.com) or [Brave Search](https://brave.com/search/api/), and the model executes multi-step tool chains automatically.
- 🛠️ **Server-side tool execution**: an explicit tool-ownership model (gateway / client / provider) decides exactly what runs where. Web search ships today via [You.com](https://you.com), [Brave Search](https://brave.com/search/api/), or a self-hosted [SearXNG](https://docs.searxng.org/) instance, and the model executes multi-step tool chains automatically.
- 📡 **Every transport**: non-streaming HTTP, server-sent events for token streaming, and full **WebSocket** support for interactive clients.
- 🧰 **Codex-ready**: accepts Codex-shaped Responses traffic out of the box, preserving the tool declarations and response item shapes Codex depends on.
- 🏃 **Background execution**: fire-and-forget requests that keep processing server-side.
Expand Down Expand Up @@ -193,6 +193,14 @@ AGENTIC_WEB_SEARCH_PROVIDER=brave BRAVE_API_KEY=<your-brave-api-key> \
cargo run -p agentic-server -- --llm-api-base http://0.0.0.0:5050
```

Prefer a self-hosted backend? Point the gateway at your own [SearXNG](https://docs.searxng.org/) instance instead;
no API key is needed, only its URL:

```bash
AGENTIC_WEB_SEARCH_PROVIDER=searxng AGENTIC_WEB_SEARCH_BASE_URL=http://127.0.0.1:8080 \
cargo run -p agentic-server -- --llm-api-base http://0.0.0.0:5050
```

The default database is `~/.agentic-api/agentic_api.db`, so running an installed binary does not create state in the
current directory. Set `AGENTIC_API_HOME` to an absolute directory to move both the default database and user
configuration, or set `DATABASE_URL`/`--db-url` to select a different database.
Expand Down Expand Up @@ -226,12 +234,12 @@ llm_api_base = "http://127.0.0.1:5050"
# database_url = "postgresql://agentic-api@localhost/agentic_api"

[web_search]
# Search backend for the gateway-owned web_search tool: "you" (default) or "brave".
# Search backend for the gateway-owned web_search tool: "you" (default), "brave", or "searxng".
provider = "you"
base_url = "https://api.ydc-index.io"
api_key_env = "YOU_API_KEY"
# Concurrent provider requests inside one batched web-search call; unset uses
# the provider default (Brave: 1, You.com: max_concurrent_gateway_calls).
# the provider default (Brave: 1, You.com and SearXNG: max_concurrent_gateway_calls).
# max_concurrent_queries = 1

[mcp]
Expand Down Expand Up @@ -340,9 +348,9 @@ every provider.
| Setting | Environment variable | `config.toml` key | Default |
| :--- | :--- | :--- | :--- |
| Provider | `AGENTIC_WEB_SEARCH_PROVIDER` | `[web_search] provider` | `you` |
| API key | variable named by `api_key_env` | `[web_search] api_key_env` | `YOU_API_KEY` / `BRAVE_API_KEY` |
| Endpoint | `AGENTIC_WEB_SEARCH_BASE_URL` (or `YOU_API_BASE_URL` for You.com) | `[web_search] base_url` | none for You.com; `https://api.search.brave.com` for Brave |
| Concurrent queries | `AGENTIC_WEB_SEARCH_MAX_CONCURRENT_QUERIES` | `[web_search] max_concurrent_queries` | You.com inherits `max_concurrent_gateway_calls`; Brave `1` |
| API key | variable named by `api_key_env` | `[web_search] api_key_env` | `YOU_API_KEY` / `BRAVE_API_KEY` / `SEARXNG_API_KEY` (optional) |
| Endpoint | `AGENTIC_WEB_SEARCH_BASE_URL` (or `YOU_API_BASE_URL` for You.com) | `[web_search] base_url` | none for You.com; `https://api.search.brave.com` for Brave; **required** for SearXNG |
| Concurrent queries | `AGENTIC_WEB_SEARCH_MAX_CONCURRENT_QUERIES` | `[web_search] max_concurrent_queries` | You.com and SearXNG inherit `max_concurrent_gateway_calls`; Brave `1` |

**You.com** (`provider = "you"`) is the default and behaves exactly as before: domain filters are applied by the
provider, `count` accepts 1–100, and the You.com-specific `livecrawl`, `livecrawl_formats`, `crawl_timeout`, and
Expand Down Expand Up @@ -374,6 +382,44 @@ provider = "brave"
api_key_env = "BRAVE_API_KEY"
```

**SearXNG** (`provider = "searxng"`) runs against a [self-hosted SearXNG](https://docs.searxng.org/admin/installation.html)
instance: the gateway talks only to your instance, no API key is needed, and no search vendor sees your
deployment. Note that SearXNG itself forwards each query to the engines enabled in its `settings.yml`, so for a
fully air-gapped setup restrict it to internal or offline engines. The endpoint is mandatory: the server refuses to
start when `searxng` is selected without `AGENTIC_WEB_SEARCH_BASE_URL` or `[web_search] base_url` (an absolute
`http(s)` URL without a query or fragment; a sub-path such as `http://host/searxng` is fine). Two instance settings
matter:

- The JSON output format must be enabled: add `json` to `search.formats` in SearXNG's `settings.yml`
(`formats: [html, json]`). Without it SearXNG answers `403`, which the failed `web_search_call` explains.
- If the instance runs with `server.limiter: true` (the default in the official `searxng-docker` template), its bot
detection rejects requests that lack `Accept-Encoding: gzip`, which the gateway deliberately never sends. Add the
gateway's address to `botdetection.ip_lists.pass_ip` in `limiter.toml`, or disable the limiter for an internal
instance; otherwise every search fails with `429`.

The gateway adapts the shared tool contract to SearXNG:

- Web and news results come from one `categories=general,news` request per query, split by each hit's category.
- `allowed_domains` / `blocked_domains` (and the model's `include_domains` / `exclude_domains`) are enforced by the
gateway after the response arrives; `count` is applied by the gateway after filtering, since SearXNG has no
result-count parameter. Without `count` or `search_context_size` every hit the instance returned is passed on.
- `freshness` maps to `time_range=day|week|month|year`; a `YYYY-MM-DDtoYYYY-MM-DD` range has no SearXNG equivalent
and is ignored. `language` is normalized to SearXNG's `xx` / `xx-YY` form (`zh-Hans` becomes `zh`); `safesearch`
maps to `0` / `1` / `2`.
- `country` and the You.com-specific arguments are ignored (logged at debug level).
- Each per-query `metadata[]` entry carries `"provider": "searxng"`.
- Concurrency inherits `max_concurrent_gateway_calls`; lower `max_concurrent_queries` for a small instance. Rate
limits (`429`) fail that `web_search_call` without an automatic retry.

If the instance sits behind an authenticating reverse proxy, set `SEARXNG_API_KEY` (or the variable named by
`api_key_env`) and the gateway sends it as a `Bearer` token. Example:

```toml
[web_search]
provider = "searxng"
base_url = "http://searxng.internal:8080"
```

Restrict the file to the service account (for example, `chmod 600 ~/.agentic-api/config.toml`), especially if you add
credentialed `database_url`, MCP headers, or stdio MCP environment values. Prefer `DATABASE_URL`, referenced API-key
environment variables, and a deployment secret manager for secrets.
Expand Down Expand Up @@ -473,7 +519,7 @@ Claude Code's own tools (Bash, Edit, Read, …) stay **client-owned** — Claude

Current Claude Code versions declare Anthropic's native `web_search_20250305` server tool. Agentic API translates that
declaration for the upstream model and executes the resulting search server-side against the configured search backend
(You.com or Brave Search, see [Web search providers](#web-search-providers)); no MCP server or tool alias is required:
(You.com, Brave Search, or SearXNG, see [Web search providers](#web-search-providers)); no MCP server or tool alias is required:

```bash
YOU_API_KEY=<you.com-key> YOU_API_BASE_URL=<you.com-base-url> \
Expand Down
2 changes: 2 additions & 0 deletions clippy.toml
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
msrv = "1.85"
# Product names that are not Rust identifiers and need no backticks in docs.
doc-valid-idents = ["SearXNG", ".."]
31 changes: 25 additions & 6 deletions crates/agentic-server-core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -168,28 +168,31 @@ pub enum WebSearchProviderKind {
#[default]
You,
Brave,
Searxng,
}

impl WebSearchProviderKind {
/// Every selectable provider, in the order operator-facing messages list them.
pub const ALL: [Self; 2] = [Self::You, Self::Brave];
pub const ALL: [Self; 3] = [Self::You, Self::Brave, Self::Searxng];

/// Environment variable that conventionally carries this provider's API key.
#[must_use]
pub const fn default_api_key_env(self) -> &'static str {
match self {
Self::You => "YOU_API_KEY",
Self::Brave => "BRAVE_API_KEY",
Self::Searxng => "SEARXNG_API_KEY",
}
}

/// Endpoint used when neither the environment nor the configuration file
/// sets one. You.com has no default so a deployment that fails today keeps
/// failing the same way (#291 Q2).
/// failing the same way (#291 Q2); SearXNG is self-hosted, so its endpoint
/// is mandatory and never defaulted.
#[must_use]
pub const fn default_base_url(self) -> Option<&'static str> {
match self {
Self::You => None,
Self::You | Self::Searxng => None,
Self::Brave => Some("https://api.search.brave.com"),
}
}
Expand All @@ -200,7 +203,7 @@ impl WebSearchProviderKind {
#[must_use]
pub const fn default_max_concurrent_queries(self) -> Option<NonZeroUsize> {
match self {
Self::You => None,
Self::You | Self::Searxng => None,
Self::Brave => Some(DEFAULT_BRAVE_MAX_CONCURRENT_QUERIES),
}
}
Expand All @@ -211,15 +214,17 @@ impl WebSearchProviderKind {
match self {
Self::You => "You.com",
Self::Brave => "Brave Search",
Self::Searxng => "SearXNG",
}
}

/// Configuration label (`you`, `brave`) matching the serialized form.
/// Configuration label (`you`, `brave`, `searxng`) matching the serialized form.
#[must_use]
pub const fn config_name(self) -> &'static str {
match self {
Self::You => "you",
Self::Brave => "brave",
Self::Searxng => "searxng",
}
}

Expand Down Expand Up @@ -518,6 +523,12 @@ mod tests {
NonZeroUsize::new(1)
);
assert!(!WebSearchProviderKind::Brave.is_you());

assert_eq!(WebSearchProviderKind::Searxng.to_string(), "SearXNG");
assert_eq!(WebSearchProviderKind::Searxng.default_api_key_env(), "SEARXNG_API_KEY");
assert_eq!(WebSearchProviderKind::Searxng.default_base_url(), None);
assert_eq!(WebSearchProviderKind::Searxng.default_max_concurrent_queries(), None);
assert!(!WebSearchProviderKind::Searxng.is_you());
}

#[test]
Expand All @@ -532,16 +543,24 @@ mod tests {
"you".parse::<WebSearchProviderKind>().unwrap(),
WebSearchProviderKind::You
);
assert_eq!(
" SearXNG ".parse::<WebSearchProviderKind>().unwrap(),
WebSearchProviderKind::Searxng
);
let error = "bing".parse::<WebSearchProviderKind>().unwrap_err();
assert_eq!(
error.to_string(),
"unknown web_search provider \"bing\"; expected one of: you, brave"
"unknown web_search provider \"bing\"; expected one of: you, brave, searxng"
);

assert_eq!(
serde_json::to_string(&WebSearchProviderKind::Brave).unwrap(),
"\"brave\""
);
assert_eq!(
serde_json::to_string(&WebSearchProviderKind::Searxng).unwrap(),
"\"searxng\""
);
assert_eq!(
serde_json::from_str::<WebSearchProviderKind>("\"you\"").unwrap(),
WebSearchProviderKind::You
Expand Down
1 change: 1 addition & 0 deletions crates/agentic-server-core/src/tool/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,4 @@ pub use shell::ShellHandler;
pub(crate) use tool_search::ToolSearchMetadata;
pub use tool_search::{ToolSearchHandler, ToolSearchState};
pub use web_search::WebSearchHandler;
pub use web_search::searxng::{SEARXNG_BASE_URL_HINT, validate_searxng_base_url};
4 changes: 2 additions & 2 deletions crates/agentic-server-core/src/tool/web_search/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -211,8 +211,8 @@ pub(crate) fn clean_vec(values: Option<&[String]>) -> Option<Vec<String>> {
/// (label boundary), compared case-insensitively after IDNA normalization. A
/// URL without a parseable host cannot be checked, so it is rejected whenever
/// any allowlist or blocklist is active (fail closed). You.com filters
/// server-side, so this is not applied on that path; Brave Search applies it
/// to every result section.
/// server-side, so this is not applied on that path; Brave Search and SearXNG
/// apply it to every result section.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct DomainFilter {
include: Vec<String>,
Expand Down
Loading
Loading