-- SPDX-FileCopyrightText: © 2026 Vladimir Zorin -- SPDX-License-Identifier: LicenseRef-OWL-1.0-or-later -- Licensed under OWL v1.0+. See LICENSE. --- Default web_search backend backed by the Linkup /search endpoint. -- See https://docs.linkup.so/pages/documentation/endpoints/search/reference -- The API key is read from the LINKUP_API_KEY env var by default. local http = require("http") local json = require("cjson.safe") local M = {} local DEFAULT_ENDPOINT = "https://api.linkup.so/v1/search" local DEFAULT_DEPTH = "standard" local DEFAULT_OUTPUT_TYPE = "sourcedAnswer" local DEFAULT_API_KEY_ENV = "LINKUP_API_KEY" local function non_empty_string(value) return type(value) == "string" and value ~= "" end local function non_empty_array(value) return type(value) == "table" and #value > 0 end -- Map Linkup sources ({name, url, favicon, snippet}) onto web_search's -- expected source shape. local function normalize_sources(sources) local out = {} if type(sources) ~= "table" then return out end for _, src in ipairs(sources) do if type(src) == "table" then out[#out + 1] = { title = src.name, url = src.url, snippet = src.snippet, } end end return out end --- Construct a search backend function. -- @param opts table optional overrides: api_key, api_key_env, endpoint, -- depth, output_type, max_results, timeout -- @return function search(query, search_opts) -> (result, err) function M.new(opts) opts = opts or {} local api_key_env = non_empty_string(opts.api_key_env) and opts.api_key_env or DEFAULT_API_KEY_ENV local endpoint = non_empty_string(opts.endpoint) and opts.endpoint or DEFAULT_ENDPOINT local depth = non_empty_string(opts.depth) and opts.depth or DEFAULT_DEPTH local output_type = non_empty_string(opts.output_type) and opts.output_type or DEFAULT_OUTPUT_TYPE return function(query, search_opts) search_opts = search_opts or {} local api_key = opts.api_key if not non_empty_string(api_key) then api_key = os.getenv(api_key_env) end if not non_empty_string(api_key) then return nil, api_key_env .. " is not set" end local body = { q = query, depth = depth, outputType = output_type, } if non_empty_array(search_opts.include_domains) then body.includeDomains = search_opts.include_domains end if non_empty_array(search_opts.exclude_domains) then body.excludeDomains = search_opts.exclude_domains end if type(opts.max_results) == "number" then body.maxResults = opts.max_results end local ok_encode, encoded = pcall(json.encode, body) if not ok_encode or type(encoded) ~= "string" then return nil, "failed to encode search request" end local resp, req_err = http.request(endpoint, { method = "POST", body = encoded, headers = { authorization = "Bearer " .. api_key, ["content-type"] = "application/json", accept = "application/json", }, }, opts.timeout) if not resp then return nil, "search request failed: " .. tostring(req_err or "unknown error") end if type(resp.status) ~= "number" then return nil, "search response missing status" end if resp.status < 200 or resp.status >= 300 then local detail = non_empty_string(resp.body) and resp.body or "" return nil, "search request failed with status " .. tostring(resp.status) .. " " .. detail end local payload = json.decode(resp.body or "") if type(payload) ~= "table" then return nil, "failed to decode search response" end return { answer = payload.answer, sources = normalize_sources(payload.sources), metadata = { depth = depth, output_type = output_type, status = resp.status, }, } end end return M