Skip to contents

Guidance for coding agents (and humans) working on typesafer, an unofficial R client for the TypeSafe System One API.

References

Layout

File Contents
R/questions.R ts_noul(), ts_choice(), ts_score(): S7 classes, normalization, validators, as_wire() serialization
R/answers.R ts_response and ts_answer_* classes, parse_response(), print methods
R/request.R ts_api_key(), base URL, ts_request() (auth, timeout, retry policy), ts_perform()
R/errors.R Classed error construction from httr2 responses and failures
R/system_one.R system_one() and shared argument checks
R/system_one_df.R system_one_df(): one request per row via httr2::req_perform_parallel()
R/models.R ts_models() (GET /v1/models)
R/columns.R Answer and usage columns shared by system_one_df() and as_tibble(<ts_response>)
R/utils.R abort_input(), ts_json()
data-raw/mocks.R Regenerates the httptest2 fixtures in tests/testthat/mocks/

Commands

Rscript -e 'devtools::document()'   # after changing roxygen comments
Rscript -e 'devtools::test()'       # no API key or network needed
Rscript -e 'devtools::check()'      # must end 0 errors | 0 warnings | 0 notes
Rscript data-raw/mocks.R            # after an intentional serialization change

Don’t edit NAMESPACE or man/ by hand; they’re generated by roxygen2.

devtools::check() and pkgdown need Pandoc to check README.md and NEWS.md. Without it, check reports a note. If Pandoc isn’t on the PATH, use the copy bundled with Quarto, for example RSTUDIO_PANDOC=/Applications/quarto/bin/tools/aarch64 on macOS.

README.md is generated from README.qmd. Edit the .qmd, then run quarto render README.qmd and commit both files. Rendering runs the examples against the live API, so it needs TYPESAFE_API_KEY. The rendered output must never contain the key.

Conventions

  • Dependencies: Imports are httr2, jsonlite, S7, rlang, cli, tibble (plus base stats/utils). Add new ones only with a strong reason; test-only packages go in Suggests.
  • S7: classes are declared with package = "typesafer". Constructors normalize input and raise typesafer_error_input via abort_input(); validators return a string (or NULL) so they also run on @<-. S7::methods_register() is called in .onLoad().
  • Errors: every error inherits from typesafer_error. Use abort_input() for argument problems. HTTP errors are built in http_error_cnd():
    • typesafer_error_auth: missing key or 401
    • typesafer_error_validation: 422, with a details tibble
    • typesafer_error_rate_limit: 429 or 529
    • typesafer_error_server: other 5xx
    • all of the above also inherit typesafer_error_http
    • typesafer_error_connection: no response
    The server’s error body is in the response_body field, because rlang reserves body. Don’t interpolate server text through cli: pre-format the bullets with rlang::format_error_bullets().
  • cli messages: in {?s} pluralization, pass the count explicitly with {cli::qty(length(x))}. Wrap .data as {(.data)}.
  • Shared columns: system_one_df() and as_tibble(<ts_response>) must produce identical columns. Change both through R/columns.R, and keep the test that compares them passing.
  • Defaults that the Python SDK takes from the environment come from helper functions: ts_api_key() (TYPESAFE_API_KEY) and ts_default_model() (TYPESAFE_DEFAULT_MODEL). local_ts_env() clears both, plus TYPESAFE_BASE_URL, so the mock fixtures match.
  • Retries mirror the Python SDK: 408, 429 and 5xx plus connection failures; max_tries = 3; 30s budget; backoff 0.5s doubling up to 5s with 25% jitter; retry-after-ms before retry-after. Keep HTTP error handling on in httr2, because req_perform_parallel() only retries responses it treats as errors.
  • Scores stay on the API’s 0-based scale. Never shift them to 1-based.

JSON gotchas

All request bodies go through ts_json() (auto_unbox = TRUE, null = "null"), sent with httr2::req_body_raw(), so tests can assert the exact bytes.

  • Choice options without a description must serialize as null, never {}. Use x[i] <- list(NULL) to keep them (x[[i]] <- NULL drops them).
  • Score criteria must always be a JSON array: keep them as an unnamed list.
  • Noul criteria keys are the strings "true" and "false".
  • Score probabilities and legend arrive keyed "0", "1", and so on. Order them numerically (by_level()), not lexically, because "10" sorts before "2".

Testing

  • httptest2 fixtures: the mocks/ fixtures are built from the spec examples. File names hash the exact request body, so a “mock not found” failure means the serialization changed. Rerun data-raw/mocks.R only if the change is intended.
  • Mocked errors: use httr2::local_mocked_responses() (see json_response() in helper.R).
  • Retries: httr2 mocking bypasses the retry loop, so retry behavior is tested against a local webfakes server in test-retry.R. Those tests are skipped when webfakes isn’t installed.
  • Live tests: test-live.R calls the real API through skip_if_no_live_api(). They run locally only when TYPESAFE_API_KEY is set, and are skipped on CRAN and CI. Assert structure and clear-cut semantics only, keep the calls few and small, and never print the key.
  • Helpers: call local_ts_env() to use a fake key and the default base URL, and local_no_backoff() to make retries instant.
  • Snapshots: error and print output are snapshot-tested. Review changes in _snaps/ rather than accepting them blindly.

Secrets

  • Never put TYPESAFE_API_KEY, or any part of it, in a shell command, a command-line argument, or printed output. Tools echo commands back. For example, R’s system2() prints the whole command line in a warning when the command exits with a non-zero status.
  • To check whether the key appears somewhere (git history, a rendered README, logs), read the text into R and compare in-process, printing only a boolean or a count: grepl(Sys.getenv("TYPESAFE_API_KEY"), text, fixed = TRUE).
  • After rendering README.qmd, confirm that README.md doesn’t contain the key before committing.

Workflow

  • Make small commits, one per logical unit, with the code, its tests and its regenerated docs together.
  • Don’t push; the maintainer reviews first.
  • Work is done only when devtools::check() reports 0 errors, 0 warnings and 0 notes.