use polyc_llm::ToolSpec;
use serde_json::json;
pub const TOOL_NAME: &str = "paid_fetch";
pub const RESERVED_HEADER_NAMES: &[&str] = &[
"authorization",
"accept-payment",
"payment-signature",
"payment-receipt",
"host",
"content-length",
"transfer-encoding",
"connection",
];
#[must_use]
pub fn spec() -> ToolSpec {
ToolSpec::new(
TOOL_NAME,
"Fetch a URL (GET by default, or POST with a body) that may be \
402-gated; pays via Tempo machine payments and returns the body \
plus the payment receipt VERBATIM. The \
`receipt` object contains ONLY these fields: status, method, \
timestamp, reference, and (optionally) externalId — report them \
exactly as returned and do NOT invent fields such as amount, source, \
or destination. `reference` is the server's settlement reference; it \
is not necessarily an onchain transaction hash. A top-level \
`explorer_url` is provided: when it is non-null it is a verified link \
to the settlement transaction — use it as-is. When `explorer_url` is \
null, the `reference` is an opaque settlement identifier with no \
onchain explorer link, so do NOT fabricate one or present \
`reference` as a link. When a payment settled, a top-level `payment` \
object reports who actually paid: `amount` (human-readable, e.g. \
\"0.10 USD\"), `account` (the onchain address that paid), `payer`, \
and `explorer` (a link to that account). Attribute the spend to \
`account`, and describe it as the user's own wallet ONLY when \
`payer` is `linked_wallet`. `payer` values: `platform` is a shared \
platform account, not the user's; `deployment` is an account this \
deployment funds, also not the user's. Never claim the payment came \
from the user's wallet unless `payer` is `linked_wallet`. `headers` \
may not set `authorization`, `accept-payment`, `payment-signature`, \
`payment-receipt`, `host`, `content-length`, `transfer-encoding`, or \
`connection` — those are owned by the payment protocol itself and the \
call is refused if set.",
json!({
"type": "object",
"properties": {
"url": {
"type": "string",
"description": "URL to fetch. May answer 402 Payment Required."
},
"max_spend": {
"type": "string",
"description": "Optional ceiling on the amount to pay \
(decimal string, e.g. \"0.10\"). Surfaced to the HITL \
reviewer when paid_fetch is approval-gated."
},
"method": {
"type": "string",
"enum": ["GET", "POST"],
"description": "HTTP method. Defaults to GET."
},
"body": {
"type": "string",
"description": "Request body, sent verbatim. Meaningful with \
POST; ignored for GET."
},
"headers": {
"type": "object",
"additionalProperties": { "type": "string" },
"description": "Extra request headers as name/value pairs. \
Cannot set a header the payment protocol itself owns \
(see the tool description) — the call is refused if one \
is present."
}
},
"required": ["url"],
"additionalProperties": false
}),
)
.titled("Pay for & fetch a web page")
.destructive()
.approval_required()
.open_world()
}
#[derive(Debug, Clone)]
pub struct FetchArgs {
pub url: String,
pub max_spend: Option<String>,
pub method: reqwest::Method,
pub body: Option<String>,
pub headers: Vec<(String, String)>,
}
pub fn parse_args(args_json: &str) -> Result<FetchArgs, String> {
let v: serde_json::Value =
serde_json::from_str(args_json).map_err(|_| "invalid args JSON".to_owned())?;
let url = v
.get("url")
.and_then(serde_json::Value::as_str)
.filter(|s| !s.is_empty())
.ok_or_else(|| "missing 'url' in args".to_owned())?
.to_owned();
let max_spend = v
.get("max_spend")
.and_then(serde_json::Value::as_str)
.filter(|s| !s.is_empty())
.map(str::to_owned);
let method = match v.get("method").and_then(serde_json::Value::as_str) {
None | Some("GET") => reqwest::Method::GET,
Some("POST") => reqwest::Method::POST,
Some(other) => {
return Err(format!(
"unsupported method '{other}' (must be GET or POST)"
));
}
};
let body = v
.get("body")
.and_then(serde_json::Value::as_str)
.map(str::to_owned);
let headers = match v.get("headers") {
None => Vec::new(),
Some(serde_json::Value::Object(map)) => {
let mut out = Vec::with_capacity(map.len());
for (name, value) in map {
let value = value
.as_str()
.ok_or_else(|| format!("header '{name}' value must be a string"))?;
if RESERVED_HEADER_NAMES.contains(&name.to_lowercase().as_str()) {
return Err(format!(
"header '{name}' is reserved by the payment protocol and cannot be set"
));
}
out.push((name.clone(), value.to_owned()));
}
out
}
Some(_) => return Err("'headers' must be a JSON object".to_owned()),
};
Ok(FetchArgs {
url,
max_spend,
method,
body,
headers,
})
}
#[cfg(test)]
mod tests {
#![allow(clippy::pedantic, clippy::nursery, missing_docs)]
use super::*;
#[test]
fn spec_carries_curated_title() {
let spec = spec();
assert_eq!(spec.title.as_deref(), Some("Pay for & fetch a web page"));
}
#[test]
fn spec_has_url_field() {
let spec = spec();
assert_eq!(spec.name, "paid_fetch");
let schema = &spec.schema_json;
let required = schema["required"].as_array().expect("required array");
assert!(required.iter().any(|v| v == "url"), "url must be required");
assert_eq!(schema["properties"]["url"]["type"], "string");
assert!(
schema["properties"].get("max_spend").is_some(),
"max_spend should be advertised"
);
assert!(
!required.iter().any(|v| v == "max_spend"),
"max_spend must be optional"
);
assert_eq!(schema["additionalProperties"], serde_json::json!(false));
}
#[test]
fn spec_description_forbids_inventing_receipt_fields() {
let d = spec().description.to_lowercase();
assert!(
d.contains("verbatim"),
"description must say receipt is verbatim"
);
assert!(
d.contains("do not invent") || d.contains("not invent"),
"description must warn against inventing fields"
);
for f in ["status", "method", "timestamp", "reference"] {
assert!(d.contains(f), "description should name the `{f}` field");
}
}
#[test]
fn spec_description_explains_explorer_url() {
let d = spec().description.to_lowercase();
assert!(
d.contains("explorer_url"),
"description must document the explorer_url field"
);
assert!(
d.contains("verified"),
"description must say a non-null explorer_url is verified"
);
assert!(
d.contains("null"),
"description must explain the null (no-link) case"
);
}
#[test]
fn spec_advertises_method_body_and_headers() {
let spec = spec();
let schema = &spec.schema_json;
let required = schema["required"].as_array().expect("required array");
assert_eq!(
schema["properties"]["method"]["enum"],
serde_json::json!(["GET", "POST"])
);
assert_eq!(schema["properties"]["body"]["type"], "string");
assert_eq!(schema["properties"]["headers"]["type"], "object");
for optional in ["method", "body", "headers"] {
assert!(
!required.iter().any(|v| v == optional),
"{optional} must be optional"
);
}
}
#[test]
fn parse_args_defaults_to_get_with_no_body_or_headers() {
let parsed = parse_args(r#"{"url":"https://svc.test/x"}"#).expect("parses");
assert_eq!(parsed.url, "https://svc.test/x");
assert_eq!(parsed.method, reqwest::Method::GET);
assert!(parsed.body.is_none());
assert!(parsed.headers.is_empty());
}
#[test]
fn parse_args_reads_post_with_body_and_headers() {
let parsed = parse_args(
r#"{"url":"https://svc.test/x","method":"POST","body":"{\"a\":1}","headers":{"x-custom":"1"}}"#,
)
.expect("parses");
assert_eq!(parsed.method, reqwest::Method::POST);
assert_eq!(parsed.body.as_deref(), Some(r#"{"a":1}"#));
assert_eq!(
parsed.headers,
vec![("x-custom".to_owned(), "1".to_owned())]
);
}
#[test]
fn parse_args_rejects_an_unsupported_method() {
let err = parse_args(r#"{"url":"https://svc.test/x","method":"DELETE"}"#)
.expect_err("DELETE must be refused");
assert!(err.contains("DELETE"));
}
#[test]
fn parse_args_rejects_a_reserved_header_case_insensitively() {
for reserved in [
"authorization",
"Authorization",
"PAYMENT-SIGNATURE",
"Host",
] {
let args = format!(r#"{{"url":"https://svc.test/x","headers":{{"{reserved}":"x"}}}}"#);
let err = parse_args(&args).expect_err(&format!("{reserved} must be refused"));
assert!(
err.contains("reserved"),
"expected a reserved-header refusal for {reserved}, got: {err}"
);
}
}
#[test]
fn parse_args_rejects_a_non_string_header_value() {
let err = parse_args(r#"{"url":"https://svc.test/x","headers":{"x":1}}"#)
.expect_err("non-string header value must be refused");
assert!(err.contains("string"));
}
}