Runs controlled psycholinguistic, psychological, cognitive, and
educational experiments with large language models. Registry-based
configuration separates models, providers, and application programming
interfaces so that compatible services can share experimental designs.
Functions support randomized and factorial trials, multi-turn conversations,
response and reasoning capture, timing and token measurements, and structured
result export. For methodological context on using language models as
experimental participants, see Dillion et al. (2023)
PsyLingLLM is an R package for controlled psychological, psycholinguistic, cognitive, and educational experiments with large language models (LLMs). It helps researchers present the same materials across models and providers while collecting responses, reasoning fields, timing, token usage, request IDs, trial status, and logs in a consistent data structure.
Version 0.4 introduces a Registry v2 runtime architecture. Model resolution, request construction, HTTP transport, streaming, response parsing, and result normalization are now separate responsibilities. Existing experiment functions and Registry v1 user files remain supported.
LLMs are increasingly used as experimental participants, stimulus generators, and computational comparison systems in psycholinguistics, psychology, cognitive science, and education. A controlled study needs more than a single prompt: researchers must preserve materials, instructions, model settings, trial order, conversation history, timing, responses, reasoning fields, usage, errors, and provider metadata.
PsyLingLLM provides one experiment layer for those tasks. The same material table can be presented to different models while the Registry translates the provider-specific API contract. This separation lets experiment scripts remain stable when a model moves to another endpoint or when a provider adds a new protocol version.
PsyLingLLM supports:
The package standardizes execution and measurement. It does not make hosted models deterministic, and it does not turn a small demonstration into evidence of human-like cognition. Reproducible research still requires adequate materials, repetitions, controls, model-version records, and statistical analysis.
PsyLingLLM is designed around user-managed model registration.
The effective precedence is:
Runtime arguments > User registry > System registry > Package defaults
A model appearing in the bundled registry does not guarantee that the upstream model is available to every account. The system registry is a starting point; the user registry remains authoritative for an individual experiment.
After CRAN publication, install the released package with:
install.packages("PsyLingLLM")
Until then, install the development version from GitHub:
install.packages("remotes")
remotes::install_github("HanMingPsy/PsyLingLLM-R")
library(PsyLingLLM)
packageVersion("PsyLingLLM")
PsyLingLLM requires R 4.0 or later.
An experiment needs three pieces of provider information:
api_url for a proxy, workspace, cloud deployment, or local server.For a bundled official preset, users normally provide only the Registry model key and API key. Model IDs and account availability remain provider-specific; check the upstream account catalog if a preset returns a model-not-found error.
Do not put API keys in an experiment table, Registry YAML file, README, or
version-controlled script. For non-interactive work, environment variables are
a simple option. Add values to a user-level .Renviron file and restart R:
DEEPSEEK_API_KEY=your_key_here
OPENAI_API_KEY=your_key_here
For desktop use, the optional keyring package can read credentials from the
operating system credential store:
# Environment variable
deepseek_key <- Sys.getenv("DEEPSEEK_API_KEY")
# Optional alternative; PsyLingLLM does not manage the keyring itself
# deepseek_key <- keyring::key_get("PsyLingLLM", "deepseek")
Load a reproducible material file bundled with the package and run a small experiment:
library(PsyLingLLM)
garden_path_file <- system.file(
"extdata",
"garden_path_sentences.csv",
package = "PsyLingLLM"
)
garden_path <- readr::read_csv(
garden_path_file,
show_col_types = FALSE
)
yn_system_prompt <- paste(
"You are a participant in a psychology experiment.",
"Your task is to answer the following questions with ONLY a single character: Y for Yes or N for No.",
"Do not provide any other text, explanation, or punctuation.",
sep = "\n"
)
result <- trial_experiment(
model_key = "deepseek-flash",
api_key = deepseek_key,
data = garden_path[1:2, ],
system_content = yn_system_prompt,
optionals = list(
max_tokens = 64L,
thinking = list(type = "disabled")
),
stream = FALSE,
random = FALSE,
delay = 0
)
result[c(
"Item", "Condition", "Response", "TrialStatus",
"PromptTokens", "CompletionTokens"
)]
The bundled DeepSeek preset provides its official endpoint. Supply api_url
when a workspace, proxy, local server, or private deployment uses a different
request URL.
A typical PsyLingLLM study follows this sequence:
Material table
-> experiment design and trial ordering
-> Registry model/interface resolution
-> provider-native request construction
-> HTTP or streaming transport
-> response and reasoning parsing
-> standardized experiment rows and log files
-> descriptive or inferential analysis
Keep the experimental manipulation in the material table or factorial design, and keep provider details in the Registry or runtime arguments. This makes it possible to rerun the same design with another provider without rewriting the experiment logic.
Before a large or costly run:
trial_experiment() executes each row in a material table. Material is the
required content column. An optional row-level TrialPrompt takes precedence
over the global trial_prompt argument.
The first experiment from the earlier PsyLingLLM README is retained as a multilingual UTF-8 demonstration. The materials are now stored in a package file instead of being duplicated inside the analysis script:
| Item | Language | Material |
|---|---|---|
| 1 | English | The cat sat on the ____. |
| 2 | Chinese (Simplified) | 这只猫咪坐在____上。 |
| 3 | French | Le chat était assis sur le ____. |
| 4 | Spanish | El gato estaba sentado en el ____. |
| 5 | German | Die Katze saß auf dem ____. |
| 6 | Italian | Il gatto era seduto sul ____. |
| 7 | Japanese | ネコが____の上に座っていました。 |
| 8 | Korean | 고양이가 ____ 위에 앉아 있었습니다. |
| 9 | Portuguese | O gato estava sentado no ____. |
| 10 | Swedish | Katten satt på ____. |
| 11 | Russian | Кот сидел на ____. |
multilingual_file <- system.file(
"extdata",
"multilingual_completion.csv",
package = "PsyLingLLM"
)
multilingual_data <- readr::read_csv(
multilingual_file,
show_col_types = FALSE
)
multilingual_result <- trial_experiment(
model_key = "deepseek-flash",
api_key = Sys.getenv("DEEPSEEK_API_KEY"),
data = multilingual_data,
system_content = paste(
"You are participating in a multilingual sentence-completion experiment.",
"Follow the requested output format exactly."
),
optionals = list(
max_tokens = 128L,
thinking = list(type = "disabled")
),
stream = FALSE,
repeats = 1,
random = FALSE,
delay = 0
)
multilingual_result[c(
"Item", "ConditionLanguage", "Material",
"Response", "TrialStatus"
)]
For an initial endpoint check, use data = multilingual_data[1:2, ] before
running all languages. The example verifies that multilingual text survives the
material, request, response, and result-file path. It is not by itself a fair
cross-language benchmark: comparable scoring requires language-specific target
definitions, tokenization records, sufficient items, and human-validated
coding rules.
The bundled demonstration presents four Garden Path sentences and two clear controls. It illustrates how to combine categorical judgments with streaming latency and token measurements. The small material set is a software example, not a validated experimental norm.
| Item | Condition | Material | Target |
|---|---|---|---|
| 1 | GardenPath | The old man the boats. | No |
| 2 | GardenPath | The horse raced past the barn fell. | No |
| 3 | GardenPath | Fat people eat accumulates. | No |
| 4 | GardenPath | The man whistling tunes pianos. | No |
| 5 | Control | Birds are singing in the garden. | Yes |
| 6 | Control | The children played football after school. | Yes |
The system message constrains the observable answer to one character. When the
selected interface exposes a separate reasoning channel, Think can still be
recorded independently from Response.
garden_path_result <- trial_experiment(
model_key = "deepseek-flash",
api_key = Sys.getenv("DEEPSEEK_API_KEY"),
data = garden_path,
system_content = yn_system_prompt,
optionals = list(
max_tokens = 1024L,
thinking = list(type = "enabled"),
reasoning_effort = "high"
),
stream = TRUE,
repeats = 1,
random = FALSE,
delay = 0
)
garden_path_result$Response <- toupper(
trimws(garden_path_result$Response)
)
stopifnot(
all(garden_path_result$TrialStatus == "SUCCESS"),
all(garden_path_result$Response %in% c("Y", "N"))
)
repeats duplicates the complete material table before execution. Set a random
seed when randomized trial order must be reproducible:
set.seed(2026)
The provider-native thinking object enables DeepSeek thinking mode;
reasoning_effort controls its effort. These fields are sent without renaming.
They are protocol-specific, so consult the provider documentation before using
them with another interface.
Summarize response latency, token use, and the probability of a Y judgment:
library(dplyr)
summary_result <- garden_path_result %>%
group_by(Condition) %>%
summarise(
n = n(),
mean_FTL = mean(FirstTokenLatency, na.rm = TRUE),
mean_RT = mean(TotalResponseTime, na.rm = TRUE),
mean_tokens = mean(CompletionTokens, na.rm = TRUE),
Y_prob = mean(Response == "Y", na.rm = TRUE),
.groups = "drop"
)
print(summary_result)
A live run with deepseek-flash on September 20, 2026 produced the following
result. Timing and model output can vary across runs and provider revisions.
| Condition | n | mean_FTL | mean_RT | mean_tokens | Y_prob |
|---|---|---|---|---|---|
| Control | 2 | 0.234 | 1.024 | 37.50 | 1.00 |
| GardenPath | 4 | 0.208 | 2.280 | 252.75 | 0.00 |
All six trials completed successfully, returned exactly one Y or N, and
included a non-empty reasoning field, token usage, and request ID. No trial
reached the configured 1,024-token completion limit.
The optional ggplot2 package can visualize the four descriptive measures:
library(ggplot2)
plot_bar <- function(data, y_var, y_label, title) {
ggplot(data, aes(x = Condition, y = .data[[y_var]], fill = Condition)) +
geom_col(width = 0.6, color = "black") +
labs(title = title, x = "Condition", y = y_label) +
theme_minimal(base_size = 14) +
theme(
legend.position = "none",
plot.title = element_text(hjust = 0.5, face = "bold")
)
}
plot_bar(
summary_result,
"mean_RT",
"Mean total response time (s)",
"Mean total response time by condition"
)
plot_bar(
summary_result,
"mean_FTL",
"Mean first-token latency (s)",
"Mean first-token latency by condition"
)
plot_bar(
summary_result,
"mean_tokens",
"Mean completion tokens",
"Mean completion tokens by condition"
)
ggplot(summary_result, aes(x = Condition, y = Y_prob, group = 1)) +
geom_line(color = "#2c7fb8", linewidth = 1.2) +
geom_point(size = 3, color = "#2c7fb8") +
scale_y_continuous(
limits = c(0, 1),
labels = function(x) paste0(round(100 * x), "%")
) +
labs(
title = "Probability of responding Y by condition",
x = "Condition",
y = "Probability of Y"
) +
theme_minimal(base_size = 14) +
theme(plot.title = element_text(hjust = 0.5, face = "bold"))
The following figures were generated from the live result shown above:




In this run, Garden Path items required more total time and more completion tokens than Control items, while first-token latency was similar and slightly lower for Garden Path items. The model rejected every Garden Path item and accepted both Control items. These values demonstrate the experimental and measurement workflow; six trials are not enough to establish a psycholinguistic effect or a human-like processing pattern. A confirmatory study should use more items, repetitions, models, and an appropriate statistical analysis.
Related psycholinguistic work includes Ferreira and Henderson (1991) on recovery from garden-path misanalysis and Christianson et al. (2001) on the persistence of thematic interpretations. These references motivate the paradigm; they do not validate the six-item demonstration or the model result.
During execution, the console displays the current trial, total number of trials, completion percentage, estimated time remaining, and model key. A typical progress line has this form:
[====================--------------------] 50% Trial 3/6 - ETA: 00:05 - deepseek-flash
When output_path = NULL, experiment functions create timestamped results and
logs under the platform-specific directory returned by
tools::R_user_dir("PsyLingLLM", "data"), inside its results subdirectory.
For example:
deepseek-flash_20260920_132306.csv
deepseek-flash_20260920_132306.log
output_path can be a full CSV/XLSX filename or a directory. A directory gets
an automatically generated filename. overwrite = FALSE protects an existing
explicit output file. Tests and package examples should instead use
tempdir() so they do not write persistent user files. When a full path or
directory is supplied, PsyLingLLM does not initialize the default result
directory.
trial_experiment() arguments| Argument | Purpose and behavior |
|---|---|
model_key |
Registry key for the model or deployment |
generation_interface |
Friendly interface label or exact versioned interface ID. When omitted or NULL, use the model's Registry default; an explicit value forces that interface. The displayed "chat" function default is retained for signature compatibility |
api_key |
Runtime credential; never store it in materials or Registry YAML |
api_url |
Optional complete endpoint override for a proxy, workspace, deployment, or local server |
data |
Data frame or tibble containing Material; use a list-generation helper to load CSV/XLSX first |
trial_prompt |
Global task instruction; a non-empty row-level TrialPrompt takes precedence |
system_content |
Global system instruction, subject to the selected protocol's role support |
assistant_content |
Static few-shot or seed assistant messages placed before trial history |
optionals |
Named list of provider-native request parameters; names and nested values are sent without cross-provider translation |
role_mapping |
Optional override for Registry role labels |
stream |
FALSE by default for trial_experiment(); TRUE enables streaming when the interface supports it |
timeout |
Per-request limit in seconds; default 120 |
repeats |
Number of copies of the complete material table |
random |
Whether to shuffle the expanded trial order |
delay |
Pause in seconds after each request |
output_path |
Result filename or directory; NULL uses the timestamped user results directory |
overwrite |
Whether an existing explicit result file may be replaced |
return_raw |
Retains one redacted provider RAW object per result row in the returned object's raw list-column. Ordinary CSV and Excel result tables remain flat |
raw_output_path |
Optional .rds path used with return_raw = TRUE for automatic lossless archival. Excel output then adds compact RawIndex and RawMetadata sheets that link rows to the RDS archive |
Omitting generation_interface lets each model use its declared Registry
default. For example, the bundled Registry routes gpt-5.6-luna through
OpenAI Responses, gpt-4o through OpenAI Chat Completions, and
deepseek-flash through the DeepSeek Chat interface. Existing scripts that
explicitly pass "chat", "responses", or a versioned interface ID continue
to force that route. Legacy Registry v1 models with multiple interfaces retain
the historical Chat fallback when no unambiguous default is available.
trial_experiment() and factorial_trial_experiment() default optionals to
NULL, so they do not inject Registry optional defaults. The conversation
functions preserve the full three-state contract: omitting optionals uses
Registry defaults, explicit NULL suppresses them, and a named list sends only
the supplied values. Direct llm_caller() behavior is described later.
The sentence-completion materials are also available as CSV and XLSX files:
completion_file <- system.file(
"extdata",
"Sentence_Completion.csv",
package = "PsyLingLLM"
)
completion_data <- readr::read_csv(
completion_file,
show_col_types = FALSE
)
completion_result <- trial_experiment(
model_key = "deepseek-flash",
api_key = Sys.getenv("DEEPSEEK_API_KEY"),
data = completion_data,
trial_prompt = "Complete the blank with one word only.",
optionals = list(max_tokens = 32L),
stream = FALSE,
random = FALSE,
delay = 0
)
The completion file contains one row per stimulus. Repetition and randomization are added by the experiment function, while all source columns remain attached to the resulting rows. For a controlled completion study, define in advance whether scoring uses the full returned sentence, the completed word, a semantic category, or human coding; do not infer the scoring rule after observing model responses.
Use llm_caller() when a trial table is unnecessary:
response <- llm_caller(
model_key = "gpt-5.6-luna",
api_key = Sys.getenv("OPENAI_API_KEY"),
material = "Reply with the word OK only.",
optionals = list(
max_output_tokens = 64L,
reasoning = list(effort = "low")
),
stream = FALSE
)
response$answer
response$thinking
response$usage
The normalized caller result contains:
| Field | Meaning |
|---|---|
status |
Public runtime status; compatibility failures use 599 |
answer |
Final semantic answer text |
thinking |
Separate reasoning content when declared and returned |
usage |
Prompt/completion usage and request ID when supplied by the provider |
streaming |
Effective transport mode |
first_token_latency |
Time to the first streamed content token when available |
error |
Structured provider or transport evidence on failure |
raw |
Redacted request/response diagnostics only when return_raw = TRUE |
Use llm_caller() for endpoint smoke tests, adapter diagnostics, and one-off
requests. Use an experiment function when rows, repetitions, randomization,
logs, and standardized result files are part of the research workflow.
factorial_trial_experiment() expands carrier materials across all combinations
of user-defined factors, realizes each condition with a fill function, and then
runs the resulting trial table through the same standardized runtime. This is
useful when the experimental manipulation should vary while the surrounding
lexical material stays controlled.
The bundled factorial demonstration is a 2 x 2 agreement-attraction design:
AttractorNumber: singular or plural.Agreement: match or mismatch with the singular head noun.The carrier sentences use noun(s) and {VERB} placeholders. The custom
fill_method realizes each condition before the API call.
With four carrier items, two attractor-number levels, two agreement levels,
one repetition, and no critical-word table, the design contains
4 x 2 x 2 x 1 = 16 trials.
factorial_file <- system.file(
"extdata",
"factorial_design.csv",
package = "PsyLingLLM"
)
factorial_data <- readr::read_csv(
factorial_file,
show_col_types = FALSE
)
fill_agreement <- function(condition, material, word) {
stimulus <- sub(
"\\(s\\)",
if (condition$AttractorNumber == "plural") "s" else "",
material
)
verb <- if (condition$Agreement == "match") "is" else "are"
sub("{VERB}", verb, stimulus, fixed = TRUE)
}
factorial_preview <- generate_llm_factorial_experiment_list(
data = factorial_data,
factors = list(
AttractorNumber = c("singular", "plural"),
Agreement = c("match", "mismatch")
),
repeats = 1,
random = FALSE
)
stopifnot(nrow(factorial_preview) == 16L)
factorial_result <- factorial_trial_experiment(
model_key = "deepseek-flash",
api_key = Sys.getenv("DEEPSEEK_API_KEY"),
data = factorial_data,
factors = list(
AttractorNumber = c("singular", "plural"),
Agreement = c("match", "mismatch")
),
fill_method = fill_agreement,
optionals = list(max_tokens = 32L),
stream = FALSE,
random = TRUE,
delay = 0
)
factorial_result[c(
"Item", "AttractorNumber", "Agreement", "Stimulus",
"ConditionLabel", "Response"
)]
Before making any API request, inspect the expanded design with
generate_llm_factorial_experiment_list() if required. When fill_method is
omitted, the built-in filler supports {CW}, [CW], __CW__, and ____
critical-word placeholders.
Factorial output retains the factor columns and adds Stimulus and
ConditionLabel, so the result can be grouped without reconstructing the
experimental design:
factorial_summary <- factorial_result %>%
group_by(AttractorNumber, Agreement) %>%
summarise(
mean_rating = mean(as.numeric(Response), na.rm = TRUE),
mean_RT = mean(TotalResponseTime, na.rm = TRUE),
.groups = "drop"
)
Before interpreting the means, inspect response-format violations and missing trials. For confirmatory work, analyze item and model variability rather than treating the expanded rows as independent observations without qualification.
conversation_experiment() groups rows by ConversationId, sorts them by
Turn, and carries structured user/assistant history forward. The bundled
example measures free recall and source memory after an encoding turn.
Unlike independent trials, later turns can depend on earlier model responses.
This supports research on memory, priming, adaptation, consistency, and
sequential dependence. It also changes the unit of randomization: preserve turn
order inside each conversation, and treat a complete ConversationId sequence
as the coherent experimental unit.
The bundled data contain two three-turn conversations:
| ConversationId | Turn | Condition | Task |
|---|---|---|---|
memory-1 |
1 | encoding |
Remember lantern and reply READY |
memory-1 |
2 | free_recall |
Recall the target word |
memory-1 |
3 | source_memory |
Judge whether it was described as living |
memory-2 |
1 | encoding |
Remember meadow and reply READY |
memory-2 |
2 | free_recall |
Recall the target word |
memory-2 |
3 | source_memory |
Judge whether it was described as a place |
conversation_file <- system.file(
"extdata",
"conversation_experiment.csv",
package = "PsyLingLLM"
)
conversation_data <- readr::read_csv(
conversation_file,
show_col_types = FALSE
)
conversation_result <- conversation_experiment(
model_key = "deepseek-flash",
api_key = Sys.getenv("DEEPSEEK_API_KEY"),
data = conversation_data,
system_content = paste(
"You are participating in a memory experiment.",
"Follow the requested response format exactly."
),
optionals = list(max_tokens = 64L),
history_mode = "all",
max_history_turns = 2,
stream = FALSE,
random = FALSE,
delay = 0
)
History options:
history_mode = "all" retains earlier turns, optionally limited by
max_history_turns.history_mode = "last" retains only the most recently completed turn.Every completed turn contributes one user message and one assistant message.
Static few-shot messages can be supplied through assistant_content as
character values or structured message objects.
Conversation output keeps the ordinary response, reasoning, timing, token,
status, and request-ID fields and adds AssistantContext, HistoryMode, and
HistoryUsedMsgs. The adaptive function additionally records the serialized
request messages. Because context grows across turns, token use and latency are
not directly comparable to isolated trials unless the analysis controls for
prompt length and history policy.
Inspect recall and source judgments without discarding failed turns:
conversation_result %>%
select(
ConversationId, Turn, Condition, Target,
Response, PromptTokens, CompletionTokens, TrialStatus
)
repeats repeats complete conversations rather than independently duplicating
and mixing turns. When random = TRUE, verify the generated conversation order
before a costly run.
adaptive_feedback_experiment() calls a user-defined function after
each successful response. The callback receives the response, current row, and
a context list. It can select the next prompt and attach decision metadata.
The bundled demonstration adjusts prime-number difficulty after each answer:
| ConversationId | StartNumber | Initial question |
|---|---|---|
prime-1 |
17 | Is 17 a prime number? |
prime-2 |
21 | Is 21 a prime number? |
adaptive_file <- system.file(
"extdata",
"adaptive_feedback.csv",
package = "PsyLingLLM"
)
adaptive_data <- readr::read_csv(
adaptive_file,
show_col_types = FALSE
)
is_prime <- function(x) {
if (x < 2L) return(FALSE)
if (x < 4L) return(TRUE)
if (x %% 2L == 0L) return(FALSE)
upper <- floor(sqrt(x))
if (upper < 3L) return(TRUE)
!any(x %% seq.int(3L, upper, by = 2L) == 0L)
}
prime_feedback <- function(response, row, context) {
number <- as.integer(sub(
".*?(\\d+).*",
"\\1",
row$TrialPrompt[[1]]
))
says_yes <- grepl("^\\s*yes\\b", response, ignore.case = TRUE)
correct <- identical(says_yes, is_prime(number))
next_number <- if (correct) number + 4L else max(2L, number - 2L)
list(
name = if (correct) "correct" else "incorrect",
next_prompt = paste(
"Is", next_number, "a prime number? Answer YES or NO."
),
meta = list(
number = number,
correct = correct,
next_number = next_number
)
)
}
adaptive_result <- adaptive_feedback_experiment(
model_key = "deepseek-flash",
api_key = Sys.getenv("DEEPSEEK_API_KEY"),
data = adaptive_data,
feedback_fn = prime_feedback,
apply_mode = "insert_dynamic",
max_turns = 5,
optionals = list(max_tokens = 16L),
stream = FALSE,
random = FALSE,
delay = 0
)
With apply_mode = "replace_next", feedback replaces the next planned prompt
without inserting a row. With apply_mode = "insert_dynamic", a new turn may
be appended until max_turns is reached.
The callback contract is deliberately small:
response is the normalized answer from the completed turn;row is the current experiment row;context contains the evolving conversation state;next_prompt determines the next prompt when a change is requested;name labels the feedback decision;meta records analysis variables such as correctness and next difficulty.Return NULL when no adaptation should be applied. Keep max_turns finite for
dynamic insertion, validate callback outputs, and make the rule deterministic
or record its random seed. Provider errors and timeouts must not be scored as
participant answers; inspect TrialStatus before using the response in a
feedback decision.
Feedback execution is auditable through FeedbackDecision, FeedbackStatus,
FeedbackApplied, FeedbackNextPrompt, FeedbackMeta, and
FeedbackMessage. Callback errors are recorded as FeedbackStatus = "ERROR"
without changing a successful provider response into a failed trial.
multi_model_experiment() runs the same trial table through multiple Registry
models. The required model-table column is model_key. Optional columns
include generation_interface, api_key, api_url, stream,
system_content, assistant_content, optionals, role_mapping, and
output_path.
Use this layer when the scientific comparison requires identical materials, trial-generation rules, and result columns across models. Provider-native parameters can differ by row; they are not forced into a universal parameter vocabulary.
models <- data.frame(
model_key = c("deepseek-flash", "gpt-5.6-luna"),
generation_interface = c("chat", "responses"),
api_key = c(
Sys.getenv("DEEPSEEK_API_KEY"),
Sys.getenv("OPENAI_API_KEY")
),
stringsAsFactors = FALSE
)
models$optionals <- list(
list(max_tokens = 64L),
list(
max_output_tokens = 64L,
reasoning = list(effort = "low")
)
)
comparison <- multi_model_experiment(
models = models,
data = garden_path[1:2, ],
random = FALSE,
delay = 0,
output_dir = tempdir(),
combined_output_path = file.path(
tempdir(),
"combined-results.csv"
)
)
The combined result includes ModelKey. A failure for one model does not stop
the remaining model rows from being attempted. Never save real credentials in
a CSV or XLSX model table intended for sharing.
The in-memory model table used above is equivalent to:
| model_key | generation_interface | optionals concept |
|---|---|---|
deepseek-flash |
chat |
max_tokens |
gpt-5.6-luna |
responses |
max_output_tokens, nested reasoning |
The names differ because each row follows its provider's wire contract. A cross-model study should record those differences rather than silently mapping one setting to another.
output_dir stores per-model experiment files. combined_output_path writes a
single row-bound CSV with ModelKey for comparison. A simple descriptive
summary is:
model_summary <- comparison %>%
group_by(ModelKey, Condition) %>%
summarise(
successful_trials = sum(TrialStatus == "SUCCESS"),
mean_RT = mean(TotalResponseTime, na.rm = TRUE),
mean_tokens = mean(CompletionTokens, na.rm = TRUE),
.groups = "drop"
)
Do not compare latency across providers without recording region, endpoint, transport mode, rate limiting, and execution time. Those deployment variables can dominate model-computation differences.
Experiment data can be supplied as a data frame. The list-generation helpers also accept CSV and XLS/XLSX paths:
trials <- generate_llm_experiment_list(
data = garden_path_file,
repeats = 2,
random = TRUE
)
Supported material sources:
| Source | Reader | Notes |
|---|---|---|
| R data frame | used directly | Best for programmatically generated or preprocessed materials |
| CSV | readr::read_csv() |
Character columns are checked and converted toward UTF-8 when needed |
| XLS/XLSX | readxl::read_excel() |
Useful when a collaborator maintains materials in Excel |
Material is required. Item is generated when absent. A row-level
TrialPrompt is retained; otherwise the global trial_prompt is added. Columns
whose names begin with Condition or condition are placed near the core
identifiers, and all remaining custom columns are preserved. This allows target
answers, list assignments, lexical measures, and preregistered exclusions to
travel with every result row.
For reproducible randomization, set and record the seed before generating the trial list:
set.seed(2026)
trials <- generate_llm_experiment_list(
data = garden_path_file,
repeats = 2,
random = TRUE,
save_path = file.path(tempdir(), "garden-path-trials.csv")
)
Core output fields include:
| Field | Meaning |
|---|---|
Run, Item |
Execution and material identifiers |
TrialPrompt, Material |
Prompt and stimulus used for the trial |
Response |
Normalized final answer |
Think |
Separate reasoning field when declared and returned |
ModelName |
Registry model key |
TotalResponseTime |
Complete request duration in seconds |
FirstTokenLatency |
Time to first streamed token when available |
PromptTokens, CompletionTokens |
Provider-reported usage when available |
TrialStatus |
SUCCESS, ERROR, or TIMEOUT |
ResponseStatus |
Normalized response outcome such as OK, EMPTY_RESPONSE, or PARSE_ERROR |
Streaming |
Whether streaming transport was used |
Timestamp, RequestID |
Completion time and provider request identifier |
Original experimental-condition columns remain in the result. Conversation functions additionally record request messages and conversation history.
Status should be part of every analysis filter:
table(garden_path_result$TrialStatus, useNA = "ifany")
analysis_data <- garden_path_result %>%
filter(TrialStatus == "SUCCESS", Response %in% c("Y", "N"))
Do not silently convert ERROR, TIMEOUT, blank answers, or response-format
violations into substantive judgments. Report exclusions and retry rules before
comparing conditions.
Save an already constructed result table explicitly with:
save_experiment_results(
garden_path_result,
output_path = file.path(tempdir(), "garden-path-results.xlsx"),
model = "deepseek-flash",
overwrite = TRUE
)
CSV output uses an Excel-compatible UTF-8 encoding. XLS/XLSX output uses
writexl. Package-defined result columns remain present even when every value
is unavailable, so streaming and non-streaming files retain a stable schema.
Other non-standard columns containing only missing or empty values may be
removed; keep the in-memory object when every input column must be preserved.
The main user-facing workflow functions are:
| Function | Use |
|---|---|
trial_experiment() |
Independent or repeated row-wise trials |
factorial_trial_experiment() |
Cross factors, realize carrier materials, and run the expanded design |
conversation_experiment() |
Ordered multi-turn conversations with retained history |
adaptive_feedback_experiment() |
Adaptive conversations controlled by a callback |
conversation_experiment_with_feedback() |
Backward-compatible alias for adaptive_feedback_experiment() |
multi_model_experiment() |
Run one trial design across Registry model rows |
llm_caller() |
Make one normalized request without an experiment table |
get_trial_raw() |
Retrieve one row-aligned RAW record from an in-memory result or RDS archive |
extract_process_metrics() |
Create one provider-neutral process-metric summary row per trial |
extract_token_probabilities() |
Convert supported token-logprob evidence into a long analysis table |
generate_llm_experiment_list() |
Inspect, repeat, randomize, and optionally save an ordinary trial list |
generate_llm_factorial_experiment_list() |
Inspect and save a factorial expansion before API calls |
save_experiment_results() |
Save a result data frame as CSV or Excel |
load_registry() |
Inspect the effective flat compatibility view |
get_registry_entry() |
Resolve one public model/interface entry |
get_model_config() |
Obtain compatibility configuration used by existing callers |
get_registry_path() |
Locate the user Registry file |
llm_register() |
Probe a live endpoint and prepare a registration candidate |
validate_registry_schema() |
Validate Registry v1/v2 structure before runtime |
Run ?function_name in R for the formal argument reference. Registration also
exports lower-level diagnostic helpers for advanced maintainers; ordinary
experiments should use the high-level functions above.
The default user registry path is:
get_registry_path()
# file.path(tools::R_user_dir("PsyLingLLM", "config"),
# "model_registry.yaml")
PsyLingLLM also reads the historical
~/.psylingllm/model_registry.yaml location without moving or rewriting it.
When both files exist, entries from the standard R_user_dir() location take
precedence over entries from the historical location.
Inspect the effective public registry view and one resolved interface:
registry <- load_registry()
names(registry)
entry <- get_registry_entry(
model_key = "deepseek-flash",
generation_interface = "chat"
)
entry$provider
entry$input$default_url
entry$input$optional_defaults
entry$streaming
config <- get_model_config(
model_key = "deepseek-flash",
generation_interface = "chat"
)
load_registry() continues to expose a flat Registry v1-compatible public
view. Internally, runtime resolution uses a validated canonical configuration.
Invalid YAML, unsupported schema versions, unknown adapters, and broken
cross-references fail before an API request is sent.
Registry v1 user files remain readable and are never rewritten automatically. A native Registry v2 user file is also accepted, but it must currently be a self-contained bundle whose provider, interface, capability, and model references all resolve inside that document. Partial v2 overlays that refer to system-only interfaces are not supported.
The automatic registration writer currently writes Registry v1 entries only.
It rejects an existing Registry v2 bundle instead of creating a mixed v1/v2
document. Edit a custom v2 bundle explicitly and run
validate_registry_schema() before using it.
User/system precedence is applied to complete model entries. If a user file defines the same model key as the system registry, the user entry replaces the system entry; their interface definitions are not recursively combined.
Registry v2 separates four concepts:
A new model that uses an existing protocol should normally need only Registry
configuration. It should not require a model-name branch in llm_caller().
Models can expose multiple interfaces. Select a friendly compatibility label or the exact versioned interface ID:
responses_result <- llm_caller(
model_key = "deepseek-flash",
generation_interface = "deepseek-responses-v1",
api_key = Sys.getenv("DEEPSEEK_API_KEY"),
material = "Reply with OK only.",
optionals = list(max_output_tokens = 64L),
stream = FALSE
)
PsyLingLLM does not define a universal cross-provider parameter vocabulary. Use the exact wire names and nested values documented for the selected provider and interface. These names are not automatically translated:
max_tokens
max_completion_tokens
max_output_tokens
Unknown provider parameters produce a warning but are still transmitted. This allows newly introduced upstream parameters to work before Registry help text is updated. The provider remains authoritative for invalid names and values.
Direct llm_caller() calls preserve a three-state optionals contract:
| Call form | Behavior |
|---|---|
Omit optionals |
Use Registry defaults when present |
optionals = NULL |
Send no Registry optional defaults |
optionals = list(...) |
Send only the supplied values |
llm_caller() also accepts provider parameters through trailing ...:
response <- llm_caller(
model_key = "gpt-5.6-luna",
api_key = Sys.getenv("OPENAI_API_KEY"),
material = "Give one word describing calm water.",
optionals = NULL,
max_output_tokens = 64L,
reasoning = list(effort = "low")
)
If both forms contain the same name, the value in ... wins and a warning is
emitted. Experiment functions do not expose trailing ...; supply their
provider-native values through optionals.
Protocol-owned fields such as model, messages, input, instructions,
and system cannot be replaced through optional parameters. api_url,
stream, timeout, and return_raw remain runtime controls.
There are two practical registration routes in 0.4:
llm_register() as an advanced discovery assistant.The second route is often preferable when the provider publishes a stable, OpenAI-compatible response contract.
llm_register() performs live requests. It probes non-streaming and streaming
responses, ranks candidate answer/reasoning paths, constructs a standardized
request template, and performs a second retrievability check.
registration <- llm_register(
url = "https://api.deepseek.com/chat/completions",
provider = "official",
headers = list(
"Content-Type" = "application/json",
"Authorization" = "Bearer ${API_KEY}"
),
body = list(
model = "deepseek-flash",
messages = list(
list(role = "user", content = "${CONTENT}")
),
stream = TRUE
),
api_key = Sys.getenv("DEEPSEEK_API_KEY"),
content_value = "Reply with OK only.",
generation_interface = "chat",
optional_defaults = list(max_tokens = 64L),
stream_param = "stream",
auto_register = FALSE
)
In an interactive session, auto_register = FALSE displays a preview and asks
whether it should be saved. auto_register = TRUE writes the inferred entry to
the user registry without that confirmation. Inspect the preview before using
automatic persistence.
Automatic path ranking is evidence, not proof of semantic correctness. Treat
registration diagnostics as sensitive request metadata, never publish an
unreviewed report, and test the resulting entry before using it in an
experiment. The Registry must retain ${API_KEY} as a placeholder rather than
the real credential.
For a single custom or proxy endpoint, create or edit the file returned by
get_registry_path(). A minimal OpenAI-compatible example is:
my-model@custom:
chat:
provider: custom
reasoning: false
input:
headers:
Content-Type: application/json
Authorization: Bearer ${API_KEY}
body:
model: provider-model-id
messages:
- role: ${ROLE}
content: ${CONTENT}
${PARAMETER}: ${VALUE}
optional_defaults:
max_tokens:
value: 128
type: numeric
role_mapping:
system: system
user: user
assistant: assistant
output:
respond_path: 'list("choices..message.content")'
id_path: 'list("id")'
token_usage_path:
prompt: 'list("usage","prompt_tokens")'
completion: 'list("usage","completion_tokens")'
streaming:
enabled: true
delta_path: 'list("choices..delta.content")'
param_name: stream
Do not copy response paths from this example unless the real provider response has the same structure. Do not put credentials in YAML.
Non-official registrations use keys such as my-model@custom,
my-model@proxy, or my-model@local. Supply the actual endpoint at runtime:
custom_result <- llm_caller(
model_key = "my-model@custom",
generation_interface = "chat",
api_url = "https://provider.example/v1/chat/completions",
api_key = Sys.getenv("CUSTOM_API_KEY"),
material = "Reply with OK only.",
optionals = list(max_tokens = 32L),
stream = FALSE
)
To replace an outdated bundled preset, define the same model key in the user registry. Because precedence applies to the complete model entry, include every interface that the experiment needs.
Validate in increasing order of cost:
custom_entry <- get_registry_entry(
"my-model@custom",
generation_interface = "chat"
)
custom_entry$input$headers
custom_entry$output
custom_entry$streaming
smoke_data <- garden_path[1, , drop = FALSE]
smoke_result <- trial_experiment(
model_key = "my-model@custom",
generation_interface = "chat",
api_url = "https://provider.example/v1/chat/completions",
api_key = Sys.getenv("CUSTOM_API_KEY"),
data = smoke_data,
optionals = list(max_tokens = 32L),
stream = FALSE,
random = FALSE,
delay = 0,
output_path = tempdir()
)
stopifnot(smoke_result$TrialStatus[[1]] == "SUCCESS")
Repeat the smoke test after changing a model ID, endpoint, API version, authentication rule, request parameter, streaming format, or response path.
The system registry is a starter catalog and protocol reference. It does not replace the user registry.
| Level | Meaning |
|---|---|
| Live verified | The maintained entry completed real non-stream and stream tests on the recorded date |
| Offline verified | Schema, resolution, request generation, mock transport, parsing, and normalization are covered without a provider account |
| Deployment template | Correctness depends on the user's endpoint, deployment, installed model, or server configuration |
| Experimental | The upstream compatibility contract or account catalog remains unverified |
| Provider or deployment | Bundled protocol | 0.4 status | Verification scope |
|---|---|---|---|
| OpenAI | Responses | Live verified | gpt-5.6-luna; non-stream and stream |
| DeepSeek | Chat Completions, Responses | Live verified | deepseek-flash; non-stream, stream, two-turn history, and bounded adaptive feedback |
| Qwen | Chat Completions, Responses | Live verified | qwen3.8-flash; workspace endpoint override, non-stream and stream, plus trial_experiment() |
| Anthropic | Messages | Offline verified | Native request/parser contract; live compatibility also passed through a Qwen Anthropic-compatible endpoint, not an official Anthropic account |
| Gemini | OpenAI-compatible Chat | Offline verified (beta) | Basic answer and streaming contract; thought-summary extraction is not claimed |
| Mistral | Chat Completions | Offline verified | Rolling model aliases still require an account availability check |
| xAI | Responses | Offline verified | Native nested reasoning request shape; no xAI credential used |
| Groq, Kimi, GLM, Qianfan | OpenAI-compatible Chat | Offline verified | Provider-native parameters pass through unchanged; no live credential used |
| Volcengine Ark | Responses | Offline verified | Request and typed response contract; no live credential used |
| Azure OpenAI | Responses | Deployment template | Requires a complete deployment URL and matching deployment name |
| Ollama, vLLM | OpenAI-compatible Chat/Responses | Deployment template | Depends on server version, installed model, chat template, and served model name |
| Meta Llama API | /compat/v1 Chat compatibility |
Experimental | Account catalog and compatibility response shape remain unverified |
The maintained protocol paths were live-verified on 2026-09-17. The production DeepSeek conversation and adaptive experiment paths were rechecked on 2026-10-04. Check the upstream account catalog and maintain a user entry when a model name, endpoint, authentication rule, or protocol changes.
Registry v2 keeps experiment code independent from provider wire formats:
trial_experiment() / conversation_experiment() / other experiment functions
|
v
llm_caller()
orchestration only
|
+----------------+----------------+
| | |
v v v
Registry resolver Request builder Result normalizer
| | ^
| v |
+----------> Transport ----------+
|
stream/non-stream decoder
|
v
Response parser
Responsibilities are deliberately separated:
llm_caller() coordinates those components without model-name branches.A new model that shares an existing protocol should therefore require a Registry model entry, not a new runtime branch. A genuinely new protocol needs one reusable component set plus deterministic fixtures and transport tests.
Key package files follow the same boundary:
R/registry_loader.R Registry source loading and precedence
R/registry_resolver.R Canonical model/interface resolution
R/runtime_request_builder.R Provider request construction
R/runtime_transport.R HTTP and streaming transport
R/runtime_response_parser.R Typed response parsing
R/runtime_result.R Standard result normalization
R/llm_caller.R Public orchestration entry point
inst/registry/ Bundled Registry v2 configuration
inst/extdata/ Reproducible example materials
The exact internal filenames may evolve, but the responsibility boundaries and public experiment contract are the stable design. User YAML can select only registered component IDs; it cannot name and execute arbitrary R functions.
PsyLingLLM preserves provider evidence when available, including HTTP status,
message, provider error type, parameter, provider code, response body, response
headers, and request ID. For backward compatibility, the public result status
remains 599 for transport and provider failures; inspect error$code for the
original provider status.
if (!is.null(response$error)) {
response$status
response$error$code
response$error$message
response$error$request_id
}
Experiment functions convert these outcomes into TrialStatus = "ERROR" or
"TIMEOUT" and continue where possible.
Runtime diagnostics redact secret-like fields, but return_raw = TRUE may
still contain submitted research material and provider-generated content.
trial_experiment(), factorial_trial_experiment(),
conversation_experiment(), adaptive_feedback_experiment(), and
multi_model_experiment() retain these records in a raw list-column with one
element per result row.
result <- trial_experiment(
model_key = "deepseek-flash",
api_key = Sys.getenv("DEEPSEEK_API_KEY"),
data = data.frame(Material = "Reply with Y or N."),
output_path = "experiment.xlsx",
return_raw = TRUE,
raw_output_path = "experiment-with-raw.rds"
)
stopifnot(length(result$raw) == nrow(result))
archived_result <- readRDS("experiment-with-raw.rds")
first_raw_record <- get_trial_raw(archived_result, trial = 1L)
process_metrics <- extract_process_metrics(archived_result)
token_probabilities <- extract_token_probabilities(archived_result)
CSV output never serializes the nested raw column. XLSX output keeps ordinary
trial data in Results. When raw_output_path is supplied, the workbook also
contains RawIndex, which links result rows to RAW records, and RawMetadata,
which identifies the associated RDS archive. Provider payloads are not copied
into spreadsheet cells; the RDS object is the lossless archival form.
Automatic RDS persistence is optional. Existing workflows may omit
raw_output_path and continue to call
saveRDS(result, "experiment-with-raw.rds") explicitly. get_trial_raw()
retrieves one archived record, while extract_process_metrics() and
extract_token_probabilities() create provider-neutral analysis tables from
supported RAW fields. Missing or unrequested metrics remain unavailable rather
than being inferred. Standard result columns such as FirstTokenLatency
remain present with NA when unavailable.
The package does not automatically request provider log probabilities and does
not create task-specific measures such as LogProbY, LogProbN, or
YNLogOdds. Request provider-native fields explicitly through optionals or
..., then derive task-specific contrasts from the preserved evidence using a
documented analysis rule. Do not publish RAW output without review.
Process evidence is extracted from the row-aligned RAW records rather than added automatically to the ordinary result table. First request the fields supported by the selected provider and interface. For example, an OpenAI-compatible Chat interface may accept:
result <- trial_experiment(
model_key = "deepseek-flash",
api_key = Sys.getenv("DEEPSEEK_API_KEY"),
data = data.frame(Material = "Reply with Y or N."),
return_raw = TRUE,
raw_output_path = "experiment-with-raw.rds",
optionals = list(
max_tokens = 512L,
logprobs = TRUE,
top_logprobs = 20L
)
)
Provider-native parameters are forwarded without renaming. Their names, allowed values, and availability depend on the resolved interface; PsyLingLLM does not silently enable log probabilities or substitute one provider's parameter for another.
Create one standardized summary row per result row with:
process_metrics <- extract_process_metrics(result)
process_metrics[c(
"ResultRow", "RequestID", "ReasoningTokens",
"ReasoningTokensAvailable", "AnswerToken", "AnswerLogProb",
"LogprobsAvailable", "ProcessMetricsStatus"
)]
ReasoningTokensAvailable and LogprobsAvailable distinguish unavailable
evidence from a measured zero. Unsupported, unrequested, missing, or malformed
fields remain NA or unavailable; they are never fabricated.
Convert supported token-level evidence to a provider-neutral long table with:
token_probabilities <- extract_token_probabilities(result)
token_probabilities[c(
"ResultRow", "RequestID", "Channel", "TokenPosition",
"CandidateRank", "Selected", "Token", "TokenBytes",
"LogProb", "Probability"
)]
Each row represents a generated token or a returned alternative candidate.
Selected identifies the generated token, Channel distinguishes answer and
reasoning evidence when the provider exposes both, and TokenBytes preserves
provider byte metadata as a portable text field. Probability is
exp(LogProb) and is not renormalized over the returned top-k candidates. If
log probabilities were not requested or are unavailable, the extractor returns
an empty table rather than inventing candidate probabilities.
These extractors are task-neutral. Contrasts such as Y versus N, A versus B, or multilingual response sets should be derived explicitly from the long table with documented matching and censoring rules. A candidate absent from the provider's returned top-k set is missing or censored evidence, not probability zero. Keep the RDS archive for later re-analysis; spreadsheets contain only ordinary results plus linkage metadata, not the full process tables or nested provider payload.
For a reproducible experiment, record:
The bundled files in inst/extdata reproduce the documented materials and
trial construction. Real model output is not guaranteed to be byte-identical
because providers may update hosted model weights and serving behavior.
Treat each API call as an observation generated by a model, protocol, deployment, prompt, decoding configuration, and execution environment. A model name alone is not a complete experimental condition.
Before data collection, specify:
FirstTokenLatency measures transport-visible latency to the first streamed
content event. It is not a direct cognitive reaction time. TotalResponseTime
includes network and serving effects. CompletionTokens follows the provider's
usage definition, which may include reasoning tokens that are not present in
the final answer. Interpret all three measures with the relevant protocol and
deployment metadata.
Reasoning fields can be useful for debugging and exploratory coding, but they are provider-generated text rather than guaranteed faithful traces of internal computation. Protect them as research data and avoid treating their presence as proof of a psychological mechanism.
The Garden Path demonstration cites:
Inspect get_registry_path() and the corresponding user entry first. A user
entry with the same model key replaces the bundled model entry. Remove or
rename the user override only after backing it up; PsyLingLLM does not modify it
automatically.
Update the user Registry model ID or pass the new provider-native parameter unchanged. Do not substitute a similarly named parameter from another API generation. Run the registration smoke tests again after the update.
Confirm whether api_url expects the complete request URL or a base URL. For a
custom Registry v1 entry, pass the complete endpoint used by the registered
interface.
If the network requires an HTTP proxy, configure it explicitly for the current R process according to local policy:
Sys.setenv(
http_proxy = "http://127.0.0.1:7897",
https_proxy = "http://127.0.0.1:7897"
)
Do not hard-code shared proxy credentials in a script or Registry file. A
provider timeout, TLS error, or proxy failure is different from a valid
provider HTTP error; inspect the normalized error evidence before changing
request parameters.
Normal package tests, examples, installation, and loading do not require API credentials or Internet access. Real-provider tests are explicit opt-in smoke tests and are not run during routine CRAN checks.
For package development, live tests require both provider credentials and the
explicit PSYLINGLLM_LIVE_API_TESTS=true opt-in. Do not enable this variable in
routine checks or automated CRAN-facing jobs.
When extending a provider, first add deterministic fixtures and mock-transport coverage. Run a short, user-approved live test separately after the offline contract passes.
When reporting an API problem, include the Registry key, interface ID, provider HTTP status, and a redacted error object. Never include an API key.
PsyLingLLM is distributed under the MIT License. See LICENSE.