-- SPDX-FileCopyrightText: © 2026 Vladimir Zorin -- SPDX-License-Identifier: LicenseRef-OWL-1.0-or-later -- Licensed under OWL v1.0+. See LICENSE. local json = require("cjson.safe") local agent_error = require("agent_smith.error") local http_ok, http = pcall(require, "http") local client_ok, client = pcall(require, "agent_smith.llm.client") local M = {} local ORIGIN = "llm" local short_name = function(model_id) local parts = {} for part in model_id:gmatch("[^/]+") do parts[#parts + 1] = part end if #parts >= 3 then return table.concat(parts, "/", 2) end return model_id end local to_number = function(value) if type(value) == "number" then return value end if type(value) == "string" and value ~= "" then local numeric = tonumber(value) if numeric then return numeric end end return nil end local extract_numeric_field = function(...) for i = 1, select("#", ...) do local numeric = to_number(select(i, ...)) if numeric ~= nil then return numeric end end return nil end local normalize_descriptor = function(entry) if type(entry) ~= "table" then return nil end local model_id = entry.id if type(model_id) ~= "string" or model_id == "" then return nil end local metadata = type(entry.metadata) == "table" and entry.metadata or nil local top_level = entry return { id = model_id, name = short_name(model_id), context_window = extract_numeric_field( top_level.context_window, top_level.max_context_window, metadata and metadata.context_window, metadata and metadata.max_context_window, metadata and metadata.max_input_tokens, metadata and metadata.input_tokens, metadata and metadata.num_ctx, metadata and metadata.num_context_tokens ), max_output_tokens = extract_numeric_field( top_level.max_output_tokens, top_level.max_completion_tokens, top_level.output_tokens, metadata and metadata.max_output_tokens, metadata and metadata.max_completion_tokens, metadata and metadata.output_tokens, metadata and metadata.completion_tokens, metadata and metadata.max_tokens, metadata and metadata.num_predict ), raw = entry, } end local normalize_catalog = function(decoded) if type(decoded) ~= "table" then return nil, agent_error.new({ kind = agent_error.kind.schema, code = "invalid_models_payload", message = "models response must be a JSON object", origin = ORIGIN, }) end local data = decoded.data if type(data) ~= "table" then return nil, agent_error.new({ kind = agent_error.kind.schema, code = "invalid_models_payload", message = "models response must contain a data array", origin = ORIGIN, }) end local models = {} for _, entry in ipairs(data) do local descriptor = normalize_descriptor(entry) if descriptor ~= nil then models[#models + 1] = descriptor end end if #models == 0 then return nil, agent_error.new({ kind = agent_error.kind.schema, code = "invalid_models_payload", message = "models response contained no usable entries", origin = ORIGIN, }) end return models end local fetch = function(cfg) if not http_ok then return nil, http end if not client_ok then return nil, client end cfg = cfg or {} local endpoint, endpoint_err = client.build_endpoint(cfg, "/models") if not endpoint then return nil, endpoint_err end local headers = client.build_headers(cfg) headers.accept = "application/json" headers["content-type"] = nil local resp, err = http.request(endpoint, { method = "GET", headers = headers, }, cfg.timeout) if not resp then return nil, client.normalize_error(err, { kind = agent_error.kind.transport, code = "models_request_failed", message = "failed to fetch models", origin = ORIGIN, data = { attempted_endpoints = { endpoint } }, }) end if type(resp.status) ~= "number" then return nil, agent_error.new({ kind = agent_error.kind.protocol, code = "invalid_models_response", message = "models response missing status", origin = ORIGIN, data = { response = resp, attempted_endpoints = { endpoint } }, }) end if resp.status >= 200 and resp.status < 300 then local decoded, decode_err = json.decode(resp.body) if type(decoded) ~= "table" then return nil, agent_error.new({ kind = agent_error.kind.protocol, code = "invalid_models_json", message = "failed to decode models response", origin = ORIGIN, cause = decode_err, data = { body = resp.body, attempted_endpoints = { endpoint } }, }) end return normalize_catalog(decoded) end return nil, client.normalize_error({ status = resp.status, message = "models request failed with status " .. tostring(resp.status), body = resp.body, }, { kind = agent_error.kind.protocol, code = "models_bad_status", message = "failed to fetch models", origin = ORIGIN, data = { status = resp.status, body = resp.body, attempted_endpoints = { endpoint }, }, }) end M.short_name = short_name M.normalize_descriptor = normalize_descriptor M.normalize_catalog = normalize_catalog M.fetch = fetch return M