add files
Browse files- Dockerfile +7 -3
- R/asa_api_helpers.R +28 -14
- README.md +3 -0
- Tests/api_contract_smoke.R +11 -0
- www/index.html +27 -2
Dockerfile
CHANGED
|
@@ -43,7 +43,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
| 43 |
r-cran-remotes \
|
| 44 |
&& rm -rf /var/lib/apt/lists/*
|
| 45 |
|
| 46 |
-
ARG DOCKERFILE_REV=2026-03-
|
|
|
|
|
|
|
| 47 |
|
| 48 |
RUN echo "asa-api docker revision: ${DOCKERFILE_REV}"; \
|
| 49 |
set -eux; \
|
|
@@ -68,8 +70,10 @@ ARG ASA_SOFTWARE_REF=main
|
|
| 68 |
|
| 69 |
RUN git clone --depth 1 --branch "${ASA_SOFTWARE_REF}" "${ASA_SOFTWARE_REPO}" /opt/asa-software \
|
| 70 |
&& R -q -e "remotes::install_local('/opt/asa-software/asa', dependencies = TRUE, upgrade = 'never')" \
|
| 71 |
-
&&
|
| 72 |
-
&& R -q -e "
|
|
|
|
|
|
|
| 73 |
&& R -q -e "gate <- c('asa','plumber','jsonlite','reticulate'); missing <- gate[!vapply(gate, function(p) requireNamespace(p, quietly = TRUE), logical(1))]; if (length(missing)) stop(sprintf('Post-asa gate failed; missing: %s', paste(missing, collapse = ', ')), call. = FALSE); cat('Post-asa package gate passed\n')" \
|
| 74 |
&& rm -rf /opt/asa-software/.git
|
| 75 |
|
|
|
|
| 43 |
r-cran-remotes \
|
| 44 |
&& rm -rf /var/lib/apt/lists/*
|
| 45 |
|
| 46 |
+
ARG DOCKERFILE_REV=2026-03-10-openssl-hotfix-1
|
| 47 |
+
ARG ASA_CONDA_PYTHON_VERSION=3.12.3
|
| 48 |
+
ARG ASA_CONDA_OPENSSL_VERSION=3.0.13
|
| 49 |
|
| 50 |
RUN echo "asa-api docker revision: ${DOCKERFILE_REV}"; \
|
| 51 |
set -eux; \
|
|
|
|
| 70 |
|
| 71 |
RUN git clone --depth 1 --branch "${ASA_SOFTWARE_REF}" "${ASA_SOFTWARE_REPO}" /opt/asa-software \
|
| 72 |
&& R -q -e "remotes::install_local('/opt/asa-software/asa', dependencies = TRUE, upgrade = 'never')" \
|
| 73 |
+
&& /opt/conda/bin/conda create -n asa_env "python=${ASA_CONDA_PYTHON_VERSION}" "openssl=${ASA_CONDA_OPENSSL_VERSION}" pip setuptools \
|
| 74 |
+
&& R -q -e "asa::build_backend(conda_env='asa_env', python_version='3.12', force=FALSE, check_browser=FALSE, fix_browser=FALSE)" \
|
| 75 |
+
&& /opt/conda/bin/conda run -n asa_env python -c "import ssl, sys; print(sys.version); print(ssl.OPENSSL_VERSION)" \
|
| 76 |
+
&& R -q -e "reticulate::use_condaenv('asa_env', required = TRUE); cfg <- reticulate::py_config(); cat('reticulate py_config python:', cfg[['python']], '\n'); reticulate::py_run_string('import ssl, sys; print(sys.version); print(ssl.OPENSSL_VERSION)')" \
|
| 77 |
&& R -q -e "gate <- c('asa','plumber','jsonlite','reticulate'); missing <- gate[!vapply(gate, function(p) requireNamespace(p, quietly = TRUE), logical(1))]; if (length(missing)) stop(sprintf('Post-asa gate failed; missing: %s', paste(missing, collapse = ', ')), call. = FALSE); cat('Post-asa package gate passed\n')" \
|
| 78 |
&& rm -rf /opt/asa-software/.git
|
| 79 |
|
R/asa_api_helpers.R
CHANGED
|
@@ -651,22 +651,36 @@ asa_api_run_batch <- function(payload) {
|
|
| 651 |
asa_api_health_payload <- function(boot_error = NULL) {
|
| 652 |
asa_installed <- requireNamespace("asa", quietly = TRUE)
|
| 653 |
direct_provider_available <- asa_installed && isTRUE(asa_api_has_run_direct_task())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 654 |
has_boot_error <- is.character(boot_error) && nzchar(trimws(boot_error))
|
| 655 |
tor_health <- asa_api_tor_health()
|
| 656 |
healthy <- asa_installed && !has_boot_error && (!isTRUE(tor_health$tor_enabled) || isTRUE(tor_health$tor_ready))
|
| 657 |
|
| 658 |
-
c(
|
| 659 |
-
|
| 660 |
-
|
| 661 |
-
|
| 662 |
-
|
| 663 |
-
|
| 664 |
-
|
| 665 |
-
|
| 666 |
-
|
| 667 |
-
|
| 668 |
-
|
| 669 |
-
|
| 670 |
-
|
| 671 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 672 |
}
|
|
|
|
| 651 |
asa_api_health_payload <- function(boot_error = NULL) {
|
| 652 |
asa_installed <- requireNamespace("asa", quietly = TRUE)
|
| 653 |
direct_provider_available <- asa_installed && isTRUE(asa_api_has_run_direct_task())
|
| 654 |
+
direct_provider_note <- NULL
|
| 655 |
+
if (!asa_installed) {
|
| 656 |
+
direct_provider_note <- "Direct provider mode unavailable: package `asa` is not installed."
|
| 657 |
+
} else if (!direct_provider_available) {
|
| 658 |
+
direct_provider_note <- "Direct provider mode unavailable: installed `asa` does not provide `run_direct_task`."
|
| 659 |
+
}
|
| 660 |
has_boot_error <- is.character(boot_error) && nzchar(trimws(boot_error))
|
| 661 |
tor_health <- asa_api_tor_health()
|
| 662 |
healthy <- asa_installed && !has_boot_error && (!isTRUE(tor_health$tor_enabled) || isTRUE(tor_health$tor_ready))
|
| 663 |
|
| 664 |
+
c(
|
| 665 |
+
list(
|
| 666 |
+
status = if (healthy) "ok" else "degraded",
|
| 667 |
+
service = "asa-api",
|
| 668 |
+
time_utc = format(Sys.time(), tz = "UTC", usetz = TRUE),
|
| 669 |
+
asa_installed = asa_installed,
|
| 670 |
+
direct_provider_available = direct_provider_available,
|
| 671 |
+
boot_error = if (has_boot_error) boot_error else NULL
|
| 672 |
+
),
|
| 673 |
+
if (!is.null(direct_provider_note)) {
|
| 674 |
+
list(direct_provider_note = direct_provider_note)
|
| 675 |
+
},
|
| 676 |
+
list(
|
| 677 |
+
defaults = list(
|
| 678 |
+
backend = getOption("asa.default_backend", NULL),
|
| 679 |
+
model = getOption("asa.default_model", NULL),
|
| 680 |
+
conda_env = getOption("asa.default_conda_env", "asa_env"),
|
| 681 |
+
use_browser = asa_api_to_bool(Sys.getenv("ASA_USE_BROWSER_DEFAULT", unset = "false"), default = FALSE)
|
| 682 |
+
)
|
| 683 |
+
),
|
| 684 |
+
tor_health
|
| 685 |
+
)
|
| 686 |
}
|
README.md
CHANGED
|
@@ -188,6 +188,7 @@ R dependency strategy in this image:
|
|
| 188 |
- Base image is `rocker/r2u:24.04`.
|
| 189 |
- Core R runtime packages (`plumber`, `jsonlite`, `reticulate`, `remotes`) are installed as binary apt packages (`r-cran-*`), not compiled from CRAN source.
|
| 190 |
- This avoids source-compile failures such as `sodium` -> `plumber` install breaks.
|
|
|
|
| 191 |
- Runtime linker guardrails are set so `reticulate` prefers conda environment libraries (`/opt/conda/envs/asa_env/lib` and `/opt/conda/lib`) to avoid C++ ABI loader mismatches.
|
| 192 |
- Tor is installed in the image and started before the API. If Tor does not become ready, the container exits instead of serving direct search traffic.
|
| 193 |
|
|
@@ -195,6 +196,8 @@ Build args:
|
|
| 195 |
|
| 196 |
- `ASA_SOFTWARE_REPO` (default: `https://github.com/cjerzak/asa-software`)
|
| 197 |
- `ASA_SOFTWARE_REF` (default: `main`)
|
|
|
|
|
|
|
| 198 |
|
| 199 |
Local build:
|
| 200 |
|
|
|
|
| 188 |
- Base image is `rocker/r2u:24.04`.
|
| 189 |
- Core R runtime packages (`plumber`, `jsonlite`, `reticulate`, `remotes`) are installed as binary apt packages (`r-cran-*`), not compiled from CRAN source.
|
| 190 |
- This avoids source-compile failures such as `sodium` -> `plumber` install breaks.
|
| 191 |
+
- The Docker build pre-creates `asa_env` with pinned `python=3.12.3` and `openssl=3.0.13` before calling `asa::build_backend()`, reducing rebuild drift that can break `reticulate` SSL imports on Ubuntu 24.04 / Hugging Face rebuilds.
|
| 192 |
- Runtime linker guardrails are set so `reticulate` prefers conda environment libraries (`/opt/conda/envs/asa_env/lib` and `/opt/conda/lib`) to avoid C++ ABI loader mismatches.
|
| 193 |
- Tor is installed in the image and started before the API. If Tor does not become ready, the container exits instead of serving direct search traffic.
|
| 194 |
|
|
|
|
| 196 |
|
| 197 |
- `ASA_SOFTWARE_REPO` (default: `https://github.com/cjerzak/asa-software`)
|
| 198 |
- `ASA_SOFTWARE_REF` (default: `main`)
|
| 199 |
+
- `ASA_CONDA_PYTHON_VERSION` (default: `3.12.3`)
|
| 200 |
+
- `ASA_CONDA_OPENSSL_VERSION` (default: `3.0.13`)
|
| 201 |
|
| 202 |
Local build:
|
| 203 |
|
Tests/api_contract_smoke.R
CHANGED
|
@@ -115,6 +115,17 @@ assert_true(
|
|
| 115 |
identical(isTRUE(health_payload$direct_provider_available), isTRUE(asa_api_has_run_direct_task())),
|
| 116 |
"Health payload should report direct-provider availability from the same capability check."
|
| 117 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
|
| 119 |
{
|
| 120 |
original_asa <- asa_api_run_single_via_asa
|
|
|
|
| 115 |
identical(isTRUE(health_payload$direct_provider_available), isTRUE(asa_api_has_run_direct_task())),
|
| 116 |
"Health payload should report direct-provider availability from the same capability check."
|
| 117 |
)
|
| 118 |
+
if (isTRUE(health_payload$direct_provider_available)) {
|
| 119 |
+
assert_true(
|
| 120 |
+
is.null(health_payload$direct_provider_note),
|
| 121 |
+
"Health payload should omit the direct-provider note when the capability is available."
|
| 122 |
+
)
|
| 123 |
+
} else {
|
| 124 |
+
assert_true(
|
| 125 |
+
is.character(health_payload$direct_provider_note) && nzchar(trimws(health_payload$direct_provider_note)),
|
| 126 |
+
"Health payload should explain why direct-provider mode is unavailable."
|
| 127 |
+
)
|
| 128 |
+
}
|
| 129 |
|
| 130 |
{
|
| 131 |
original_asa <- asa_api_run_single_via_asa
|
www/index.html
CHANGED
|
@@ -241,6 +241,11 @@
|
|
| 241 |
color: rgba(16, 34, 46, 0.66);
|
| 242 |
}
|
| 243 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 244 |
.api-guide {
|
| 245 |
margin-top: 2px;
|
| 246 |
padding: 12px;
|
|
@@ -270,11 +275,19 @@
|
|
| 270 |
font-weight: 500;
|
| 271 |
}
|
| 272 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 273 |
.toggle input {
|
| 274 |
width: auto;
|
| 275 |
margin: 0;
|
| 276 |
}
|
| 277 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 278 |
.mode-badge {
|
| 279 |
display: inline-flex;
|
| 280 |
align-items: center;
|
|
@@ -359,6 +372,7 @@
|
|
| 359 |
const promptInput = document.getElementById("prompt");
|
| 360 |
const outputFormatInput = document.getElementById("output-format");
|
| 361 |
const useDirectProviderInput = document.getElementById("use-direct-provider");
|
|
|
|
| 362 |
const directProviderNoteEl = document.getElementById("direct-provider-note");
|
| 363 |
const outputPre = document.getElementById("output");
|
| 364 |
const statusEl = document.getElementById("status");
|
|
@@ -381,15 +395,26 @@
|
|
| 381 |
function setDirectProviderAvailability(available, note) {
|
| 382 |
if (available) {
|
| 383 |
useDirectProviderInput.disabled = false;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 384 |
directProviderNoteEl.hidden = true;
|
| 385 |
directProviderNoteEl.textContent = "";
|
|
|
|
| 386 |
return;
|
| 387 |
}
|
| 388 |
|
|
|
|
| 389 |
useDirectProviderInput.checked = false;
|
| 390 |
useDirectProviderInput.disabled = true;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 391 |
directProviderNoteEl.hidden = false;
|
| 392 |
-
directProviderNoteEl.textContent =
|
|
|
|
| 393 |
}
|
| 394 |
|
| 395 |
async function loadCapabilities() {
|
|
@@ -405,7 +430,7 @@
|
|
| 405 |
|
| 406 |
const data = await response.json();
|
| 407 |
if (data && data.direct_provider_available === false) {
|
| 408 |
-
setDirectProviderAvailability(false);
|
| 409 |
} else if (data && data.direct_provider_available === true) {
|
| 410 |
setDirectProviderAvailability(true);
|
| 411 |
}
|
|
|
|
| 241 |
color: rgba(16, 34, 46, 0.66);
|
| 242 |
}
|
| 243 |
|
| 244 |
+
.hint.warn {
|
| 245 |
+
color: var(--err);
|
| 246 |
+
font-weight: 500;
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
.api-guide {
|
| 250 |
margin-top: 2px;
|
| 251 |
padding: 12px;
|
|
|
|
| 275 |
font-weight: 500;
|
| 276 |
}
|
| 277 |
|
| 278 |
+
.toggle.disabled {
|
| 279 |
+
opacity: 0.62;
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
.toggle input {
|
| 283 |
width: auto;
|
| 284 |
margin: 0;
|
| 285 |
}
|
| 286 |
|
| 287 |
+
.toggle input:disabled {
|
| 288 |
+
cursor: not-allowed;
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
.mode-badge {
|
| 292 |
display: inline-flex;
|
| 293 |
align-items: center;
|
|
|
|
| 372 |
const promptInput = document.getElementById("prompt");
|
| 373 |
const outputFormatInput = document.getElementById("output-format");
|
| 374 |
const useDirectProviderInput = document.getElementById("use-direct-provider");
|
| 375 |
+
const directProviderToggleEl = useDirectProviderInput.closest(".toggle");
|
| 376 |
const directProviderNoteEl = document.getElementById("direct-provider-note");
|
| 377 |
const outputPre = document.getElementById("output");
|
| 378 |
const statusEl = document.getElementById("status");
|
|
|
|
| 395 |
function setDirectProviderAvailability(available, note) {
|
| 396 |
if (available) {
|
| 397 |
useDirectProviderInput.disabled = false;
|
| 398 |
+
if (directProviderToggleEl) {
|
| 399 |
+
directProviderToggleEl.classList.remove("disabled");
|
| 400 |
+
directProviderToggleEl.removeAttribute("title");
|
| 401 |
+
}
|
| 402 |
directProviderNoteEl.hidden = true;
|
| 403 |
directProviderNoteEl.textContent = "";
|
| 404 |
+
directProviderNoteEl.classList.remove("warn");
|
| 405 |
return;
|
| 406 |
}
|
| 407 |
|
| 408 |
+
const message = note || "Direct provider mode is unavailable on this server because the installed `asa` build does not provide run_direct_task().";
|
| 409 |
useDirectProviderInput.checked = false;
|
| 410 |
useDirectProviderInput.disabled = true;
|
| 411 |
+
if (directProviderToggleEl) {
|
| 412 |
+
directProviderToggleEl.classList.add("disabled");
|
| 413 |
+
directProviderToggleEl.title = message;
|
| 414 |
+
}
|
| 415 |
directProviderNoteEl.hidden = false;
|
| 416 |
+
directProviderNoteEl.textContent = message;
|
| 417 |
+
directProviderNoteEl.classList.add("warn");
|
| 418 |
}
|
| 419 |
|
| 420 |
async function loadCapabilities() {
|
|
|
|
| 430 |
|
| 431 |
const data = await response.json();
|
| 432 |
if (data && data.direct_provider_available === false) {
|
| 433 |
+
setDirectProviderAvailability(false, data.direct_provider_note);
|
| 434 |
} else if (data && data.direct_provider_available === true) {
|
| 435 |
setDirectProviderAvailability(true);
|
| 436 |
}
|