Add SkippyBench external eval pack (#865)

* Add SkippyBench external eval pack

* Support structured eval requests in skippy OpenAI serving

* Update skippy smoke structured output expectation

* Guard external eval harness execution

* Strengthen Docker eval preflight

* Preflight external eval runs

* Add external eval harness orchestration
This commit is contained in:
James Dumay 2026-07-15 06:06:23 +10:00 committed by GitHub
parent c39cb3b06c
commit 4f25060ca0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 3512 additions and 150 deletions

View file

@ -9,6 +9,18 @@ metadata:
Use this skill for performance, orchestration, and report-oriented checks.
Use `skippy-correctness` when the question is pass/fail exactness.
All reportable benchmark runs need metrics-server. `run`, `focused-runtime`,
and `local-single` start a collector by default; endpoint-driving commands such
as `chat-corpus` and `eval run` require `--metrics-http` to point at an
already-running metrics-server and should use `--metrics-run-id` matching the
target endpoint's Skippy run id.
Benchmark-managed Skippy server runs must use a release `skippy-server` build.
Run `just release-build` before `run`, `focused-runtime`, `local-single`, or
local split binary benchmarks, and use `target/release/skippy-server` (the
SkippyBench default). Do not use `target/debug/skippy-server` for performance or
full-corpus validation; SkippyBench rejects that path because debug builds can
create false timeout and throughput failures.
## Current Repo Shape
@ -28,3 +40,117 @@ cargo test -p mesh-llm-host-runtime --lib inference::skippy
When benchmark harnesses are imported, keep reporting separate from request-path
serving. Stage runtimes emit telemetry; benchmark/report tooling owns reports.
## External Agent Evals
Use `skippy-bench eval` for external agent/coding benchmark harnesses. The
local SkippyBench corpora are for runtime behavior, cache behavior, transport
stress, and perf regression; they are not the source of agent benchmark claims.
Core pack:
```bash
skippy-bench eval list
skippy-bench eval info terminal-bench
skippy-bench eval sync --pack core
skippy-bench eval doctor
skippy-bench eval run speed-bench \
--base-url http://127.0.0.1:9337/v1 \
--model org/repo:Q4_K_M \
--endpoint-concurrency 1 \
--metrics-http http://127.0.0.1:18080 \
--metrics-run-id run-local-qwen
```
`--timeout-secs` is passed to the native harness as its request/task timeout
where supported. It is not a full-run dataset limit. Use
`--harness-timeout-secs` only when you need a hard wall-clock cap for an
operator/debug run; omit it for canonical full-dataset validation.
`--endpoint-concurrency` must match the target endpoint's
`serve-openai --generation-concurrency` value. SkippyBench keeps native harness
request concurrency equal to that value; adapter-specific request concurrency
overrides such as `SWE_BENCH_PRO_NUM_WORKERS` and
`MCP_ATLAS_COMPLETION_CONCURRENCY` must match it or `eval run` fails before
starting the upstream harness. Do not run multiple LLM workers against a
single-lane Skippy endpoint when validating full corpora.
Core eval ids:
- `speed-bench` — llama.cpp SPEED-Bench client for OpenAI-compatible serving
latency/throughput. Run the upstream qualitative benchmark across all
categories with no Skippy-owned sample limit.
- `terminal-bench` — Terminal-Bench CLI via `terminal-bench-core==0.1.1`.
- `swe-bench-pro` — Scale SWE-Bench Pro OS repo; uses the upstream data and
SWE-agent patch generation/evaluation flow rather than a Skippy-owned mini
benchmark.
- `mcp-atlas` — Scale MCP-Atlas native harness. `eval run` starts the
MCP agent environment and completion service when their localhost ports are
not already live, then runs the upstream completion script with `--no-filter`
so all Hugging Face dataset rows are attempted, plus the upstream scoring
path, without Skippy-specific task limits or `tool_choice` overrides.
Use-case routing:
| Need | Eval | Why |
|---|---|---|
| OpenAI-compatible serving latency, tok/s, and full SPEED-Bench traffic | `speed-bench` | Native SPEED-Bench client over the upstream dataset selection. |
| Terminal agent behavior, shell/task execution, Docker sandbox readiness | `terminal-bench` | Exercises an agent loop that has to operate in a real terminal task environment. |
| Coding-agent patch generation and issue-resolution style prompts | `swe-bench-pro` | Uses upstream SWE-agent instance generation, patch gathering, and `swe_bench_pro_eval.py`. |
| MCP tool-use benchmark flow | `mcp-atlas` | Uses upstream MCP-Atlas completion and scoring scripts with the full Hugging Face dataset. |
| Cache, runtime, transport, split, or mesh performance regression | Built-in SkippyBench `run`, `focused-runtime`, `local-single`, or `chat-corpus` | These are Skippy/runtime benchmarks, not external agent-quality claims. |
Optional future packs are intentionally not wired yet:
- `repo-generation`: NL2RepoBench.
- `tool-expanded`: Toolathlon / Tool-Decathlon.
Keep `sync`/`install` opt-in. Do not make normal `just build` or `cargo build`
download external harnesses, datasets, or Docker images.
Terminal-Bench should be installed with `uv tool install --python 3.12
terminal-bench`; Python 3.14 currently breaks the `tb` Typer CLI. Treat Docker
as ready only when `skippy-bench eval doctor` reports that the daemon can start
a container; `docker info` alone is insufficient. `skippy-bench eval run`
performs the same prerequisite checks before launching a native harness. Do not
add Skippy-owned task filters, dataset limits, compatibility shims,
response-format substitutions, or `tool_choice` overrides to external evals
unless the user explicitly asks for a noncanonical experiment.
For MCP-Atlas scoring, the wrapper defaults `EVAL_LLM_MODEL`,
`EVAL_LLM_BASE_URL`, and `EVAL_LLM_API_KEY` to the same local endpoint/model
used for completion, while preserving caller-provided `EVAL_LLM_*` overrides
for judge-model runs. When validating with a very small local Skippy model, run
completion against the normal compatibility endpoint and point `EVAL_LLM_*` at
a separate strict structured-output scorer endpoint, for example a second
`skippy-server serve-openai --openai-guardrails enforce` process; do not patch
or post-process the MCP scorer. For resumed operator runs, set
`MCP_ATLAS_COMPLETION_OUTPUT_NAME` to an existing upstream
`completion_results/*.csv` basename so the native completion script can reuse
its own processed-row skip behavior, and use `MCP_ATLAS_SCORE_CONCURRENCY` for
the scorer's native `--concurrency` setting.
For SWE-Bench Pro, the wrapper defaults to the official Docker image namespace
with local Docker deployment and local Docker evaluation so the core pack can
run without Modal credentials. It still runs upstream
`helper_code/generate_sweagent_instances.py` for the full dataset, then supplies
SWE-agent with a native `expert_file` instance file for local Docker platform,
entrypoint settings and SWE-agent's standalone Python/SWE-Rex Docker runtime.
Local Docker runs install SWE-agent into a dedicated venv and default
`SWE_BENCH_PRO_SWEREX_SPEC` to `swe-rex[modal]==1.4.0`, which keeps the native
SWE-ReX Docker runtime but includes the upstream
`python:3.11.9-slim-bookworm` builder fix. Modal remains an explicit
environment override and uses the Scale SWE-ReX patch flow. Some official
SWE-Pro base images point pip at an unavailable localhost package mirror; local
Docker runs default `SWE_BENCH_PRO_SWEREX_PIP_INDEX_URL` to
`https://pypi.org/simple` for the derived-image SWE-ReX install. Use
`SWE_BENCH_PRO_PARSE_FUNCTION=thought_action` for local OpenAI-compatible models
that do not emit OpenAI tool calls; this is the upstream SWE-agent local-model
path, not a Skippy dataset or harness rewrite.
For TTFT/FTTT, use metrics-server correlation rather than harness-only timing.
`skippy-bench eval run` and `skippy-bench chat-corpus` create/finalize a
metrics-server run and fail if the metrics report cannot be exported. The
target endpoint must be emitting OTLP for the same run id. Debug telemetry is
required for per-token spans such as `stage.openai_decode_token`; without it,
the JSON report will still include a telemetry block explaining why TTFT/FTTT
was unavailable.

2
Cargo.lock generated
View file

@ -7376,6 +7376,8 @@ version = "0.72.1"
dependencies = [
"anyhow",
"clap",
"csv",
"dirs",
"model-artifact",
"model-hf",
"model-ref",

View file

@ -52,7 +52,22 @@ impl StructuredOutputSpec {
}
fn validate_supported_schema(schema: &Value) -> Result<(), UnsupportedStructuredSchema> {
let definitions = schema.get("$defs").and_then(Value::as_object);
validate_supported_schema_inner(schema, definitions, 0)
}
fn validate_supported_schema_inner(
schema: &Value,
definitions: Option<&Map<String, Value>>,
depth: usize,
) -> Result<(), UnsupportedStructuredSchema> {
if depth > 32 {
return Err(UnsupportedStructuredSchema);
}
let object = schema.as_object().ok_or(UnsupportedStructuredSchema)?;
if let Some(resolved) = resolve_schema_ref(object, definitions) {
return validate_supported_schema_inner(resolved?, definitions, depth + 1);
}
let schema_type = object
.get("type")
.and_then(Value::as_str)
@ -61,8 +76,8 @@ fn validate_supported_schema(schema: &Value) -> Result<(), UnsupportedStructured
reject_unsupported_keywords(object)?;
match schema_type {
"object" => validate_object_schema(object),
"array" => validate_array_schema(object),
"object" => validate_object_schema(object, definitions, depth),
"array" => validate_array_schema(object, definitions, depth),
"string" | "number" | "integer" | "boolean" | "null" => validate_scalar_schema(object),
_ => Err(UnsupportedStructuredSchema),
}
@ -72,7 +87,6 @@ fn reject_unsupported_keywords(
object: &Map<String, Value>,
) -> Result<(), UnsupportedStructuredSchema> {
const UNSUPPORTED_KEYS: &[&str] = &[
"$ref",
"allOf",
"anyOf",
"const",
@ -94,7 +108,12 @@ fn reject_unsupported_keywords(
}
}
fn validate_object_schema(object: &Map<String, Value>) -> Result<(), UnsupportedStructuredSchema> {
fn validate_object_schema(
object: &Map<String, Value>,
definitions: Option<&Map<String, Value>>,
depth: usize,
) -> Result<(), UnsupportedStructuredSchema> {
validate_definitions(object, definitions, depth)?;
let properties = match object.get("properties") {
Some(Value::Object(properties)) => Some(properties),
Some(_) => return Err(UnsupportedStructuredSchema),
@ -116,18 +135,23 @@ fn validate_object_schema(object: &Map<String, Value>) -> Result<(), Unsupported
}
if let Some(properties) = properties {
for schema in properties.values() {
validate_supported_schema(schema)?;
validate_supported_schema_inner(schema, definitions, depth + 1)?;
}
}
Ok(())
}
fn validate_array_schema(object: &Map<String, Value>) -> Result<(), UnsupportedStructuredSchema> {
fn validate_array_schema(
object: &Map<String, Value>,
definitions: Option<&Map<String, Value>>,
depth: usize,
) -> Result<(), UnsupportedStructuredSchema> {
validate_definitions(object, definitions, depth)?;
let items = object.get("items").ok_or(UnsupportedStructuredSchema)?;
if items.is_array() {
return Err(UnsupportedStructuredSchema);
}
validate_supported_schema(items)
validate_supported_schema_inner(items, definitions, depth + 1)
}
fn validate_scalar_schema(object: &Map<String, Value>) -> Result<(), UnsupportedStructuredSchema> {
@ -143,14 +167,30 @@ fn validate_payload_against_schema(
schema: &Value,
payload: &Value,
) -> Result<(), UnsupportedStructuredSchema> {
let definitions = schema.get("$defs").and_then(Value::as_object);
validate_payload_against_schema_inner(schema, payload, definitions, 0)
}
fn validate_payload_against_schema_inner(
schema: &Value,
payload: &Value,
definitions: Option<&Map<String, Value>>,
depth: usize,
) -> Result<(), UnsupportedStructuredSchema> {
if depth > 32 {
return Err(UnsupportedStructuredSchema);
}
let object = schema.as_object().ok_or(UnsupportedStructuredSchema)?;
if let Some(resolved) = resolve_schema_ref(object, definitions) {
return validate_payload_against_schema_inner(resolved?, payload, definitions, depth + 1);
}
match object
.get("type")
.and_then(Value::as_str)
.ok_or(UnsupportedStructuredSchema)?
{
"object" => validate_object_payload(object, payload),
"array" => validate_array_payload(object, payload),
"object" => validate_object_payload(object, payload, definitions, depth),
"array" => validate_array_payload(object, payload, definitions, depth),
"string" => payload
.as_str()
.map(|_| ())
@ -179,6 +219,8 @@ fn validate_payload_against_schema(
fn validate_object_payload(
schema: &Map<String, Value>,
payload: &Value,
definitions: Option<&Map<String, Value>>,
depth: usize,
) -> Result<(), UnsupportedStructuredSchema> {
let payload = payload.as_object().ok_or(UnsupportedStructuredSchema)?;
let properties = schema
@ -203,7 +245,7 @@ fn validate_object_payload(
.unwrap_or(true);
for (key, value) in payload {
if let Some(property_schema) = properties.get(key) {
validate_payload_against_schema(property_schema, value)?;
validate_payload_against_schema_inner(property_schema, value, definitions, depth + 1)?;
} else if !allow_additional {
return Err(UnsupportedStructuredSchema);
}
@ -214,11 +256,135 @@ fn validate_object_payload(
fn validate_array_payload(
schema: &Map<String, Value>,
payload: &Value,
definitions: Option<&Map<String, Value>>,
depth: usize,
) -> Result<(), UnsupportedStructuredSchema> {
let payload = payload.as_array().ok_or(UnsupportedStructuredSchema)?;
let item_schema = schema.get("items").ok_or(UnsupportedStructuredSchema)?;
for item in payload {
validate_payload_against_schema(item_schema, item)?;
validate_payload_against_schema_inner(item_schema, item, definitions, depth + 1)?;
}
Ok(())
}
fn validate_definitions(
object: &Map<String, Value>,
definitions: Option<&Map<String, Value>>,
depth: usize,
) -> Result<(), UnsupportedStructuredSchema> {
let Some(Value::Object(local_definitions)) = object.get("$defs") else {
return Ok(());
};
for definition in local_definitions.values() {
validate_supported_schema_inner(definition, definitions, depth + 1)?;
}
Ok(())
}
fn resolve_schema_ref<'a>(
object: &'a Map<String, Value>,
definitions: Option<&'a Map<String, Value>>,
) -> Option<Result<&'a Value, UnsupportedStructuredSchema>> {
let reference = object.get("$ref")?;
let Some(reference) = reference.as_str() else {
return Some(Err(UnsupportedStructuredSchema));
};
if object
.keys()
.any(|key| key != "$ref" && key != "description" && key != "title")
{
return Some(Err(UnsupportedStructuredSchema));
}
let Some(name) = reference.strip_prefix("#/$defs/") else {
return Some(Err(UnsupportedStructuredSchema));
};
let name = decode_json_pointer_segment(name);
Some(name.and_then(|name| {
definitions
.and_then(|definitions| definitions.get(&name))
.ok_or(UnsupportedStructuredSchema)
}))
}
fn decode_json_pointer_segment(segment: &str) -> Result<String, UnsupportedStructuredSchema> {
let mut decoded = String::with_capacity(segment.len());
let mut chars = segment.chars();
while let Some(ch) = chars.next() {
if ch != '~' {
decoded.push(ch);
continue;
}
match chars.next() {
Some('0') => decoded.push('~'),
Some('1') => decoded.push('/'),
_ => return Err(UnsupportedStructuredSchema),
}
}
Ok(decoded)
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::StructuredOutputSpec;
#[test]
fn json_schema_supports_local_defs_refs() {
let response_format = json!({
"type": "json_schema",
"json_schema": {
"name": "CommandBatchResponse",
"strict": true,
"schema": {
"$defs": {
"Command": {
"type": "object",
"properties": {
"keystrokes": {"type": "string"},
"is_blocking": {"type": "boolean"},
"timeout_sec": {"type": "number"}
},
"required": ["keystrokes", "is_blocking", "timeout_sec"],
"additionalProperties": false
}
},
"type": "object",
"properties": {
"state_analysis": {"type": "string"},
"commands": {
"type": "array",
"items": {"$ref": "#/$defs/Command"}
},
"is_task_complete": {"type": "boolean"}
},
"required": ["state_analysis", "commands", "is_task_complete"],
"additionalProperties": false
}
}
});
let spec =
StructuredOutputSpec::from_response_format_object(response_format.as_object().unwrap())
.expect("local refs should be supported");
spec.validate_payload(&json!({
"state_analysis": "ready",
"commands": [{
"keystrokes": "ls\n",
"is_blocking": true,
"timeout_sec": 1
}],
"is_task_complete": false
}))
.expect("payload should validate through ref");
assert!(
spec.validate_payload(&json!({
"state_analysis": "ready",
"commands": [{"keystrokes": "ls\n"}],
"is_task_complete": false
}))
.is_err()
);
}
}

View file

@ -28,7 +28,10 @@ pub use mesh_llm_guardrails::{
CompactionConfig, CompactionDecision, CompactionOverride, CompactionReport, MESH_COMPACT_FIELD,
MESH_RESPOND_TOOL_NAME,
};
pub use policy::{GuardrailMode, GuardrailPolicy, GuardrailPolicyHandle, StreamingGuardrailMode};
pub use policy::{
GuardrailMode, GuardrailPolicy, GuardrailPolicyHandle, RetryExhaustionMode,
StreamingGuardrailMode,
};
pub use telemetry::GuardrailTelemetrySink;
use self::{

View file

@ -452,6 +452,12 @@ fn classify_tool_call_value(
parser_stage: GuardrailParserStage,
finish_reason: Option<FinishReason>,
) -> ClassifiedGuardrailResponse {
if let Some(classified) =
classify_direct_structured_payload(prepared, value, parser_stage, finish_reason)
{
return classified;
}
let allowed_real_tools = allowed_real_tool_names(prepared);
let allowed_backend_tools = allowed_backend_tool_names(prepared);
let raw_tool_calls = match raw_tool_calls_from_value(value) {
@ -672,6 +678,37 @@ fn classify_tool_call_value(
}
}
fn classify_direct_structured_payload(
prepared: &PreparedGuardrailRequest,
value: &Value,
parser_stage: GuardrailParserStage,
finish_reason: Option<FinishReason>,
) -> Option<ClassifiedGuardrailResponse> {
if prepared.state.request_contract.has_real_tools() {
return None;
}
let spec = prepared.state.request_contract.structured_output_spec()?;
value.as_object()?;
let valid_payload = spec.validate_payload(value).is_ok();
Some(ClassifiedGuardrailResponse {
category: if valid_payload {
GuardrailResponseCategory::ValidSyntheticStructured
} else {
GuardrailResponseCategory::InvalidStructuredPayload
},
parser_stage,
visible_content: None,
tool_calls: None,
synthetic_text: None,
structured_payload: if valid_payload {
Some(value.clone())
} else {
None
},
finish_reason,
})
}
fn raw_tool_calls_from_value(value: &Value) -> Option<Vec<&Value>> {
match value {
Value::Array(entries) => Some(entries.iter().collect()),

View file

@ -386,6 +386,52 @@ fn synthetic_structured_classifies_when_allowed() {
assert_eq!(classified.structured_payload, Some(json!({"answer": 42})));
}
#[test]
fn direct_structured_json_object_classifies_when_allowed() {
let engine = GuardrailEngine::new(enforce_policy());
let prepared = prepared_text_request(
&engine,
json!({
"model": "Qwen3-8B-Q4_K_M",
"messages": [{"role": "user", "content": "json"}],
"response_format": supported_json_schema_response_format()
}),
);
let response = response_with_content("Qwen3-8B-Q4_K_M", r#"{"answer":42}"#);
let classified = engine.classify_response(&prepared, &response);
assert_eq!(
classified.category,
GuardrailResponseCategory::ValidSyntheticStructured
);
assert_eq!(classified.parser_stage, GuardrailParserStage::JsonExact);
assert_eq!(classified.structured_payload, Some(json!({"answer": 42})));
}
#[test]
fn fenced_direct_structured_json_object_classifies_when_allowed() {
let engine = GuardrailEngine::new(enforce_policy());
let prepared = prepared_text_request(
&engine,
json!({
"model": "Qwen3-8B-Q4_K_M",
"messages": [{"role": "user", "content": "json"}],
"response_format": supported_json_schema_response_format()
}),
);
let response = response_with_content("Qwen3-8B-Q4_K_M", "```json\n{\"answer\":42}\n```");
let classified = engine.classify_response(&prepared, &response);
assert_eq!(
classified.category,
GuardrailResponseCategory::ValidSyntheticStructured
);
assert_eq!(classified.parser_stage, GuardrailParserStage::JsonFenced);
assert_eq!(classified.structured_payload, Some(json!({"answer": 42})));
}
#[test]
fn invalid_structured_payload_classifies_without_leaking_arguments() {
let engine = GuardrailEngine::new(enforce_policy());
@ -1019,6 +1065,40 @@ async fn valid_structured_payload_becomes_json_assistant_text() {
);
}
#[tokio::test]
async fn direct_structured_payload_becomes_json_assistant_text() {
let backend = Arc::new(SequencedBackend::new(vec![Ok(response_with_content(
"Qwen3-8B-Q4_K_M",
"```json\n{\"answer\":42}\n```",
))]));
let guarded = GuardedOpenAiBackend::new(
backend,
GuardrailPolicy {
mode: GuardrailMode::Enforce,
apply_to_all_models: true,
..GuardrailPolicy::default()
},
);
let request: ChatCompletionRequest = serde_json::from_value(json!({
"model": "Qwen3-8B-Q4_K_M",
"messages": [{"role": "user", "content": "json"}],
"response_format": supported_json_schema_response_format()
}))
.unwrap();
let response = guarded.chat_completion(request).await.unwrap();
assert_eq!(
response.choices[0].message.content.as_deref(),
Some("{\"answer\":42}")
);
assert!(response.choices[0].message.tool_calls.is_none());
assert_eq!(
response.choices[0].finish_reason,
Some(crate::common::FinishReason::Stop)
);
}
#[tokio::test]
async fn invalid_structured_payload_retries_then_exhaustion_error() {
let backend = Arc::new(SequencedBackend::new(vec![

View file

@ -32,7 +32,8 @@ pub use errors::{OpenAiError, OpenAiErrorKind, already_openai_error, map_upstrea
pub use guardrails::{
CompactingOpenAiBackend, CompactionConfig, CompactionDecision, CompactionOverride,
CompactionReport, GuardedOpenAiBackend, GuardrailMode, GuardrailPolicy, GuardrailPolicyHandle,
GuardrailTelemetrySink, MESH_COMPACT_FIELD, MESH_RESPOND_TOOL_NAME, StreamingGuardrailMode,
GuardrailTelemetrySink, MESH_COMPACT_FIELD, MESH_RESPOND_TOOL_NAME, RetryExhaustionMode,
StreamingGuardrailMode,
};
pub use hooks::{
ChatHookAction, ChatHookOutcome, ChatMediaKind, ChatMediaRef, GenerationHookSignals,

View file

@ -7,6 +7,8 @@ version.workspace = true
[dependencies]
anyhow.workspace = true
clap.workspace = true
csv = "1.4.0"
dirs = "6.0.0"
skippy-protocol = { path = "../skippy-protocol" }
skippy-runtime = { path = "../skippy-runtime" }
skippy-topology = { path = "../skippy-topology" }

View file

@ -52,16 +52,41 @@ skippy-bench local-single --model-path model.gguf --model-id org/repo:Q4_K_M
skippy-bench local-split-binary --model-path model.gguf --model-id org/repo:Q4_K_M
skippy-bench local-split-compare --model-path model.gguf --model-id org/repo:Q4_K_M
skippy-bench local-split-chain-binary --model-path model.gguf --model-id org/repo:Q4_K_M
skippy-bench chat-corpus --base-url http://127.0.0.1:9337/v1 --model org/repo:Q4_K_M --prompt-corpus target/bench-corpora/smoke/corpus.jsonl --max-tokens 64 --stream
skippy-bench chat-corpus --base-url http://127.0.0.1:9337/v1 --model org/repo:Q4_K_M --metrics-http http://127.0.0.1:18080 --metrics-run-id run-local-qwen --prompt-corpus target/bench-corpora/smoke/corpus.jsonl --max-tokens 64 --stream
skippy-bench token-lengths --model-path model.gguf --prompt-corpus target/bench-corpora/long/corpus.jsonl --ctx-size 8192 --generation-limit 512 --output-tsv target/bench-corpora/long/prompt-lengths.tsv
skippy-bench focused-runtime --schema-smoke --hosts host-a,host-b --splits 1 --layer-end 2
skippy-bench eval list
skippy-bench eval sync --pack core
skippy-bench eval run speed-bench --base-url http://127.0.0.1:9337/v1 --model org/repo:Q4_K_M --metrics-http http://127.0.0.1:18080 --metrics-run-id run-local-qwen
```
Benchmark-managed Skippy server runs require a release `skippy-server` binary.
Run `just release-build` before `run`, `focused-runtime`, `local-single`, or
local split binary benchmarks. These commands default to
`target/release/skippy-server` and reject `target/debug/skippy-server` because
debug builds distort throughput and timeout behavior.
The old standalone `kv-stage-integration` and `kv-hit-regression` commands are
intentionally absent. Mesh does not carry the legacy standalone cache sidecar
path; exact cache work should be reintroduced through the embedded runtime and
mesh-owned lifecycle.
Every reportable benchmark path must use metrics-server. `run`, `focused-runtime`,
and `local-single` launch their own collector by default through
`--metrics-server-bin`, `--metrics-http-addr`, and `--metrics-otlp-grpc-addr`.
Endpoint-driving benchmarks (`chat-corpus` and `eval run`) require an existing
metrics-server at `--metrics-http` and fail before running traffic if the run
cannot be created. For correlated server-side TTFT/FTTT, launch the target
Skippy/OpenAI endpoint so it exports OTLP to that collector with the same
`--metrics-run-id`.
```bash
target/debug/metrics-server serve \
--db /tmp/skippy-bench-metrics.duckdb \
--http-addr 127.0.0.1:18080 \
--otlp-grpc-addr 127.0.0.1:14317
```
Benchmark reports carry `model_identity` beside the public `model_id`. The
public id is a coordinate such as `org/repo:Q4_K_M`; when the model path comes
from the Hugging Face cache, that resolved identity is used for stage configs
@ -75,6 +100,131 @@ can compare baseline K/V cache storage against runtime-supported package candida
such as `q8_0`. The experimental TCQ/TurboQuant lane is intentionally not
compiled into mesh-llm.
## External Agent Evals
`skippy-bench eval` manages external benchmark harnesses and points them at an
already-running OpenAI-compatible Skippy or mesh endpoint. External evals are
for agent/coding benchmark claims; the local corpora below remain runtime,
cache, routing, and transport stress traffic.
The current core pack is:
| Eval id | External harness | Default run |
|---|---|---|
| `speed-bench` | llama.cpp `tools/server/bench/speed-bench` | Native SPEED-Bench qualitative run across all categories, no sample limit, `--osl 1024` |
| `terminal-bench` | Terminal-Bench CLI (`tb`) | `terminal-bench-core==0.1.1`, Terminus agent, no task-id filter |
| `swe-bench-pro` | Scale SWE-Bench Pro OS repo | Upstream SWE-agent patch generation, patch gathering, and `swe_bench_pro_eval.py` |
| `mcp-atlas` | Scale MCP-Atlas repo | Native MCP-Atlas completion script with upstream `--no-filter`, plus scoring through auto-started MCP services |
```bash
skippy-bench eval list
skippy-bench eval info terminal-bench
skippy-bench eval sync --pack core
skippy-bench eval doctor
skippy-bench eval run terminal-bench \
--base-url http://127.0.0.1:9337/v1 \
--model org/repo:Q4_K_M \
--metrics-http http://127.0.0.1:18080 \
--metrics-run-id run-local-qwen
```
`--timeout-secs` is forwarded to native harnesses as their request/task timeout
where supported. It is not a SkippyBench dataset limit and does not cap full
canonical runs. Use `--harness-timeout-secs` only when an operator wants a hard
wall-clock cap around the native harness process for debugging or CI guardrails.
`--endpoint-concurrency` declares the target OpenAI endpoint's generation
concurrency and defaults to `1`. SkippyBench keeps each external harness's LLM
request concurrency equal to that value. If an adapter-specific request
concurrency override such as `SWE_BENCH_PRO_NUM_WORKERS` or
`MCP_ATLAS_COMPLETION_CONCURRENCY` is set to a different value, `eval run`
fails before launching the native harness.
`sync` clones or installs the external harnesses into
`~/.cache/mesh-llm/skippy-bench/harnesses/` by default. Use `--cache-root` to
override that location. Use `--dry-run` with `sync` or `run` to inspect the
commands without cloning, pulling Docker images, or launching a benchmark.
Before launching native harness traffic, `eval run` enforces the same required
tool checks as `eval doctor`, including Docker container-start readiness for
Docker-backed evals.
Terminal-Bench is installed through `uv tool install --python 3.12` because the
current `tb` CLI is not compatible with Python 3.14. `eval doctor` checks that
Docker's daemon is reachable and can start a tiny container, not just that the
`docker` CLI exists or that `docker info` returns.
MCP-Atlas starts its Docker agent environment and Python completion service
when ports `1984` and `3000` are not already reachable, waits for readiness,
and cleans up only the services that the run started. The adapter runs the
upstream completion script with `--no-filter` so all Hugging Face dataset rows
are attempted, and without Skippy-specific task limits or `tool_choice`
overrides. By default, the MCP-Atlas scorer uses the same local
OpenAI-compatible endpoint/model as the completion run; set `EVAL_LLM_MODEL`,
`EVAL_LLM_BASE_URL`, and `EVAL_LLM_API_KEY` to use a separate judge model. For
small local Skippy validation models, keep the completion endpoint in normal
compatibility mode and point the scorer override at a strict structured-output
endpoint, for example a second `skippy-server serve-openai
--openai-guardrails enforce` process. The adapter still uses the native scorer
and does not rewrite score data. For operator resumes, set
`MCP_ATLAS_COMPLETION_OUTPUT_NAME` to an existing upstream
`completion_results/*.csv` basename so the native completion script can reuse
its own resume behavior, and set `MCP_ATLAS_SCORE_CONCURRENCY` to the upstream
scorer's `--concurrency` value.
SWE-Bench Pro defaults to the official Docker image namespace (`jefzda`) with
local Docker deployment and local Docker evaluation so the core pack can run
without Modal credentials. The adapter first runs upstream
`helper_code/generate_sweagent_instances.py` for the full dataset, then feeds
SWE-agent a native `expert_file` instance file so local Docker can set the
official image platform, clear image entrypoints, and use SWE-agent's
standalone Python/SWE-Rex Docker runtime. Local Docker runs install SWE-agent
into a dedicated venv and default `SWE_BENCH_PRO_SWEREX_SPEC` to
`swe-rex[modal]==1.4.0`, which preserves SWE-ReX's native Docker runtime while
using the upstream `python:3.11.9-slim-bookworm` builder fix. Modal runs keep
the Scale SWE-Rex patch flow. Some SWE-Pro base images carry a pip index config
for an unavailable localhost mirror, so the local Docker adapter defaults
`SWE_BENCH_PRO_SWEREX_PIP_INDEX_URL` to `https://pypi.org/simple` for the
derived-image SWE-ReX install step. Override
`SWE_BENCH_PRO_DOCKERHUB_USERNAME`,
`SWE_BENCH_PRO_DOCKER_PLATFORM`, `SWE_BENCH_PRO_DEPLOYMENT_TYPE`,
`SWE_BENCH_PRO_NUM_WORKERS`, `SWE_BENCH_PRO_EVAL_WORKERS`, or
`SWE_BENCH_PRO_PARSE_FUNCTION`. Use `SWE_BENCH_PRO_PYTHON` to choose the
SWE-agent venv interpreter and `SWE_BENCH_PRO_SWEREX_SPEC` to override the
SWE-ReX package spec. Use `SWE_BENCH_PRO_SWEREX_PIP_INDEX_URL` to choose the
package index used inside SWE-ReX derived Docker images. Set
`SWE_BENCH_PRO_PARSE_FUNCTION=thought_action` for local OpenAI-compatible
models that do not emit OpenAI tool calls; this is the upstream SWE-agent
local-model path. Set `SWE_BENCH_PRO_USE_LOCAL_DOCKER=0` when running the full
harness in a different environment such as Modal.
Every `eval run` writes `run.json` under the run directory with command status,
raw artifact paths, wall-clock duration, and normalized metrics where the
harness exposes them. `speed-bench` records request counts, latency,
prompt/completion/total token counts, prompt and completion tok/s, and draft
acceptance rate when the server returns llama.cpp-compatible `timings`.
SWE-Bench Pro records OpenAI usage tokens and client-side tok/s when the
upstream flow produces them.
Terminal-Bench records pass rate, resolved/unresolved task counts, token totals
when the agent reports them, and raw harness artifacts. The MCP-Atlas adapter
records wall time, raw completion CSV artifacts, the native scoring output
directory, and CSV task row count.
`eval run` requires metrics-server for every external benchmark. `--metrics-http` defaults to
`http://127.0.0.1:18080`; the command creates a metrics-server run before the
harness starts and fails if that run cannot be created. Pass
`--metrics-run-id` to correlate the eval with the target Skippy/OpenAI endpoint
run id. SkippyBench finalizes and fetches
`/v1/runs/<run-id>/report.json`, stores it as `raw/metrics-report.json`, and
adds a `telemetry` block to `run.json`. When the target emits debug telemetry,
SkippyBench derives TTFT/FTTT from the first request span to the first
`stage.openai_decode_token` span, plus request and generation latency
aggregates. If the target endpoint is not emitting the requested run id, or if
debug token spans are disabled, the telemetry block records that status rather
than filling misleading values.
Optional packs intentionally not wired yet:
| Future pack | Candidate |
|---|---|
| `repo-generation` | NL2RepoBench |
| `tool-expanded` | Toolathlon / Tool-Decathlon |
## Benchmark Corpora
Benchmark corpora are generated from Hugging Face datasets instead of checked
@ -137,6 +287,10 @@ The `long-context` tier keeps a much larger prompt character budget and expands
sampled HF text into long stress packets. It is for 32k context capacity and
transport stress only; do not substitute it for the 8k customer-readiness
baseline or quality/speculation decisions.
The built-in manifest intentionally excludes generic chat, math, summarization,
SQL, and standalone function-calling sources such as OASST, Dolly, GSM8K, XSum,
Spider, and xLAM. Agent/coding claims should use the external eval harnesses
above rather than local prompt sampling.
The manifest records source datasets, resolved revisions, downloaded parquet
files, quotas, generated row counts, seed, generator path, and generator git
commit.
@ -179,6 +333,8 @@ customer-facing benchmark numbers after the stage topology is already running:
skippy-bench chat-corpus \
--base-url http://127.0.0.1:9337/v1 \
--model org/repo:Q4_K_M \
--metrics-http http://127.0.0.1:18080 \
--metrics-run-id run-local-qwen \
--prompt-corpus target/bench-corpora/long/corpus.jsonl \
--max-tokens 512 \
--concurrency-depth 1 \
@ -195,6 +351,11 @@ benchmarks can exercise per-session KV or n-gram history. It records
per-request elapsed time, streaming TTFT when `--stream` is enabled, usage
tokens when the frontend returns them, API error codes, and aggregate
latency/token-rate summaries.
The command creates/finalizes a metrics-server run, writes the raw
metrics-server report beside `--output` by default, adds a telemetry summary to
the chat-corpus JSON report, and fails if the metrics-server report cannot be
created. It also sends stable `x-request-id` headers so matching server spans
can be grouped cleanly when the target endpoint exports the same run id.
Use `--concurrency-depth` for depth sweeps; the effective frontend generation
limit, such as `serve-openai --generation-concurrency`, must still be recorded
beside the result.

View file

@ -118,90 +118,6 @@
"smoke": 2,
"long": 50
}
},
{
"name": "xlam-function-calling",
"dataset": "product-science/xlam-function-calling-60k-raw",
"config": "default",
"split": "train",
"revision": "dfbd3c669354c27f2727870d39a4d86c32381448",
"family": "structured_tool_call",
"adapter": "xlam_tool_call",
"routing_hint": "ngram",
"quota": {
"smoke": 2,
"long": 50
}
},
{
"name": "spider-text2sql",
"dataset": "SuperMax991/spider-text2sql",
"config": "default",
"split": "test",
"revision": "890c458c18c000c0faf995aa62ee04cc91476c98",
"family": "structured_sql",
"adapter": "spider_sql",
"routing_hint": "ngram",
"quota": {
"smoke": 2,
"long": 50
}
},
{
"name": "oasst2-chat",
"dataset": "OpenAssistant/oasst2",
"config": "default",
"split": "train",
"revision": "179dd21fc55192153d94adb0e0ce8f69e222bf75",
"family": "chat_open",
"adapter": "oasst_prompt",
"routing_hint": "baseline",
"quota": {
"smoke": 2,
"long": 50
}
},
{
"name": "dolly-instruct",
"dataset": "databricks/databricks-dolly-15k",
"config": "default",
"split": "train",
"revision": "bdd27f4d94b9c1f951818a7da7fd7aeea5dbff1a",
"family": "instruction_general",
"adapter": "dolly_instruction",
"routing_hint": "draft",
"quota": {
"smoke": 2,
"long": 50
}
},
{
"name": "gsm8k",
"dataset": "openai/gsm8k",
"config": "main",
"split": "test",
"revision": "740312add88f781978c0658806c59bc2815b9866",
"family": "reasoning_math",
"adapter": "gsm8k_reasoning",
"routing_hint": "baseline",
"quota": {
"smoke": 2,
"long": 50
}
},
{
"name": "xsum",
"dataset": "EdinburghNLP/xsum",
"config": "default",
"split": "test",
"revision": "7d4d486c2f8ef850b1a11aead99b894ff3dd7da9",
"family": "summarization",
"adapter": "xsum_summarize",
"routing_hint": "draft",
"quota": {
"smoke": 2,
"long": 50
}
}
]
}

View file

@ -1,12 +1,13 @@
use std::{
fs,
io::{BufRead, BufReader},
path::{Path, PathBuf},
sync::{
Arc, Mutex,
atomic::{AtomicUsize, Ordering},
},
thread,
time::{Duration, Instant},
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
};
use anyhow::{Context, Result};
@ -14,7 +15,10 @@ use reqwest::blocking::Client;
use serde::Serialize;
use serde_json::{Map, Value, json};
use crate::cli::ChatCorpusArgs;
use crate::{
cli::ChatCorpusArgs,
telemetry_report::{self, BenchTelemetry},
};
#[derive(Debug, Clone)]
struct PromptCase {
@ -29,6 +33,9 @@ struct PromptCase {
#[derive(Serialize)]
struct ChatCorpusReport {
run_id: String,
metrics_http: String,
metrics_run_id: String,
base_url: String,
model: String,
endpoint: &'static str,
@ -43,6 +50,7 @@ struct ChatCorpusReport {
sampling: SamplingReport,
results: Vec<ChatCorpusResult>,
summary: ChatCorpusSummary,
telemetry: BenchTelemetry,
}
#[derive(Default, Serialize)]
@ -102,6 +110,29 @@ pub fn chat_corpus(args: ChatCorpusArgs) -> Result<()> {
}
let prompts = Arc::new(prompt_cases(&args)?);
let run_id = args
.run_id
.clone()
.unwrap_or_else(generate_chat_corpus_run_id);
let metrics_run_id = args
.metrics_run_id
.clone()
.unwrap_or_else(|| run_id.clone());
let metrics_http = args.metrics_http.trim_end_matches('/').to_string();
let metrics_report_output = metrics_report_output_path(&args, &metrics_run_id);
let run_config = json!({
"mode": "skippy-bench-chat-corpus",
"run_id": &run_id,
"model": &args.model,
"base_url": &args.base_url,
"stream": args.stream,
"request_count": prompts.len(),
"prompt_corpus": args.prompt_corpus.as_ref().map(|path| path.display().to_string()),
"prompt_limit": args.prompt_limit,
"max_tokens": args.max_tokens,
"concurrency_depth": args.concurrency_depth,
});
telemetry_report::create_run(&metrics_http, &metrics_run_id, &run_config)?;
let client = Client::builder()
.timeout(Duration::from_secs(args.request_timeout_secs))
.build()
@ -139,8 +170,21 @@ pub fn chat_corpus(args: ChatCorpusArgs) -> Result<()> {
.into_inner()
.expect("results mutex poisoned");
results.sort_by_key(|result| result.sequence);
let telemetry_result = telemetry_report::finalize_and_collect(
&metrics_http,
&metrics_run_id,
&metrics_report_output,
);
let telemetry_error = telemetry_result.as_ref().err().map(ToString::to_string);
let telemetry = match telemetry_result {
Ok(telemetry) => telemetry,
Err(error) => telemetry_report::unavailable(&metrics_http, &metrics_run_id, &error),
};
let report = ChatCorpusReport {
run_id,
metrics_http,
metrics_run_id,
base_url: args.base_url.trim_end_matches('/').to_string(),
model: args.model.clone(),
endpoint: "/v1/chat/completions",
@ -165,6 +209,7 @@ pub fn chat_corpus(args: ChatCorpusArgs) -> Result<()> {
},
summary: summarize(&results, total_wall_ms),
results,
telemetry,
};
let json = serde_json::to_vec_pretty(&report)?;
@ -173,6 +218,9 @@ pub fn chat_corpus(args: ChatCorpusArgs) -> Result<()> {
.with_context(|| format!("failed to write {}", output.display()))?;
}
println!("{}", String::from_utf8(json)?);
if let Some(error) = telemetry_error {
anyhow::bail!("metrics-server report unavailable: {error}");
}
Ok(())
}
@ -181,6 +229,7 @@ fn run_case(client: &Client, args: &ChatCorpusArgs, prompt_case: &PromptCase) ->
.session_group
.clone()
.unwrap_or_else(|| format!("{}-{}", args.session_prefix, prompt_case.index));
let request_id = format!("{}-request-{}", args.session_prefix, prompt_case.index);
let started = Instant::now();
let request = request_body(args, prompt_case, &session_id);
let response = client
@ -188,6 +237,7 @@ fn run_case(client: &Client, args: &ChatCorpusArgs, prompt_case: &PromptCase) ->
"{}/chat/completions",
args.base_url.trim_end_matches('/')
))
.header("x-request-id", request_id)
.json(&request)
.send();
match response {
@ -609,10 +659,34 @@ fn rate(tokens: u64, wall_ms: f64) -> Option<f64> {
(tokens > 0 && wall_ms > 0.0).then(|| tokens as f64 / (wall_ms / 1000.0))
}
fn metrics_report_output_path(args: &ChatCorpusArgs, metrics_run_id: &str) -> PathBuf {
if let Some(output) = args.metrics_report_output.as_ref() {
return output.clone();
}
if let Some(output) = args.output.as_ref() {
return sibling_path(output, "metrics-report.json");
}
std::env::temp_dir().join(format!("{metrics_run_id}-metrics-report.json"))
}
fn generate_chat_corpus_run_id() -> String {
let millis = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock before Unix epoch")
.as_millis();
format!("run-chat-corpus-{millis}")
}
fn sibling_path(path: &Path, suffix: &str) -> PathBuf {
let stem = path
.file_stem()
.and_then(|stem| stem.to_str())
.unwrap_or("chat-corpus");
path.with_file_name(format!("{stem}-{suffix}"))
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use super::*;
fn default_args() -> ChatCorpusArgs {
@ -628,6 +702,10 @@ mod tests {
include_usage: true,
request_timeout_secs: 600,
output: None,
metrics_report_output: None,
run_id: None,
metrics_http: "http://127.0.0.1:18080".to_string(),
metrics_run_id: None,
session_prefix: "chat-corpus-test".to_string(),
temperature: None,
top_p: None,

View file

@ -28,9 +28,126 @@ pub enum CommandKind {
TokenLengths(TokenLengthsArgs),
#[command(name = "focused-runtime")]
FocusedRuntime(FocusedRuntimeArgs),
Eval(EvalArgs),
Run(RunArgs),
}
#[derive(Parser)]
pub struct EvalArgs {
#[command(subcommand)]
pub command: EvalCommandKind,
}
#[derive(Subcommand)]
pub enum EvalCommandKind {
List(EvalListArgs),
Info(EvalInfoArgs),
Sync(EvalSyncArgs),
Install(EvalSyncArgs),
Doctor(EvalDoctorArgs),
Run(EvalRunArgs),
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
#[clap(rename_all = "kebab-case")]
pub enum EvalId {
SpeedBench,
TerminalBench,
SweBenchPro,
McpAtlas,
}
impl EvalId {
pub fn as_str(self) -> &'static str {
match self {
Self::SpeedBench => "speed-bench",
Self::TerminalBench => "terminal-bench",
Self::SweBenchPro => "swe-bench-pro",
Self::McpAtlas => "mcp-atlas",
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
#[clap(rename_all = "kebab-case")]
pub enum EvalPack {
Core,
}
#[derive(Parser)]
pub struct EvalListArgs {
#[arg(long)]
pub cache_root: Option<PathBuf>,
#[arg(long)]
pub json: bool,
}
#[derive(Parser)]
pub struct EvalInfoArgs {
pub eval: EvalId,
#[arg(long)]
pub cache_root: Option<PathBuf>,
#[arg(long)]
pub json: bool,
}
#[derive(Parser)]
pub struct EvalSyncArgs {
#[arg(value_enum)]
pub evals: Vec<EvalId>,
#[arg(long, value_enum, default_value_t = EvalPack::Core)]
pub pack: EvalPack,
#[arg(long)]
pub cache_root: Option<PathBuf>,
#[arg(long)]
pub dry_run: bool,
}
#[derive(Parser)]
pub struct EvalDoctorArgs {
#[arg(value_enum)]
pub evals: Vec<EvalId>,
#[arg(long, value_enum, default_value_t = EvalPack::Core)]
pub pack: EvalPack,
#[arg(long)]
pub cache_root: Option<PathBuf>,
#[arg(long)]
pub json: bool,
}
#[derive(Parser)]
pub struct EvalRunArgs {
pub eval: EvalId,
#[arg(long, default_value = "http://127.0.0.1:9337/v1")]
pub base_url: String,
#[arg(long, default_value = DEFAULT_LOCAL_MODEL_ID)]
pub model: String,
#[arg(long, default_value = "skippy-bench")]
pub api_key: String,
#[arg(long)]
pub cache_root: Option<PathBuf>,
#[arg(long)]
pub output_dir: Option<PathBuf>,
#[arg(long, default_value_t = 300)]
pub timeout_secs: u64,
#[arg(long)]
pub harness_timeout_secs: Option<u64>,
#[arg(
long,
default_value_t = 1,
help = "Expected OpenAI endpoint generation concurrency; native harness request concurrency is kept equal to this value."
)]
pub endpoint_concurrency: usize,
#[arg(long)]
pub run_id: Option<String>,
#[arg(long, default_value = "http://127.0.0.1:18080")]
pub metrics_http: String,
#[arg(long)]
pub metrics_run_id: Option<String>,
#[arg(long)]
pub dry_run: bool,
}
#[derive(Clone, Copy, Debug, ValueEnum)]
pub enum FocusedRuntimeScenario {
ColdStartup,
@ -147,6 +264,14 @@ pub struct ChatCorpusArgs {
pub request_timeout_secs: u64,
#[arg(long)]
pub output: Option<PathBuf>,
#[arg(long)]
pub metrics_report_output: Option<PathBuf>,
#[arg(long)]
pub run_id: Option<String>,
#[arg(long, default_value = "http://127.0.0.1:18080")]
pub metrics_http: String,
#[arg(long)]
pub metrics_run_id: Option<String>,
#[arg(long, default_value = "chat-corpus-session")]
pub session_prefix: String,
#[arg(long)]
@ -167,7 +292,7 @@ pub struct ChatCorpusArgs {
pub struct RunArgs {
#[arg(long, default_value = "target/debug/metrics-server")]
pub metrics_server_bin: PathBuf,
#[arg(long, default_value = "target/debug/skippy-server")]
#[arg(long, default_value = "target/release/skippy-server")]
pub stage_server_bin: PathBuf,
#[arg(
long,
@ -300,7 +425,7 @@ pub struct RunArgs {
pub struct LocalSingleArgs {
#[arg(long, default_value = "target/debug/metrics-server")]
pub metrics_server_bin: PathBuf,
#[arg(long, default_value = "target/debug/skippy-server")]
#[arg(long, default_value = "target/release/skippy-server")]
pub stage_server_bin: PathBuf,
#[arg(long)]
pub model_path: PathBuf,
@ -360,7 +485,7 @@ pub struct LocalSplitInprocessArgs {
#[derive(Parser)]
pub struct LocalSplitBinaryArgs {
#[arg(long, default_value = "target/debug/skippy-server")]
#[arg(long, default_value = "target/release/skippy-server")]
pub stage_server_bin: PathBuf,
#[arg(long)]
pub model_path: PathBuf,
@ -388,7 +513,7 @@ pub struct LocalSplitBinaryArgs {
#[derive(Parser)]
pub struct LocalSplitCompareArgs {
#[arg(long, default_value = "target/debug/skippy-server")]
#[arg(long, default_value = "target/release/skippy-server")]
pub stage_server_bin: PathBuf,
#[arg(long)]
pub model_path: PathBuf,
@ -418,7 +543,7 @@ pub struct LocalSplitCompareArgs {
#[derive(Parser)]
pub struct LocalSplitChainBinaryArgs {
#[arg(long, default_value = "target/debug/skippy-server")]
#[arg(long, default_value = "target/release/skippy-server")]
pub stage_server_bin: PathBuf,
#[arg(long)]
pub model_path: PathBuf,

View file

@ -32,7 +32,7 @@ use skippy_topology::{
use crate::{
cli::{DEFAULT_RUN_MAX_NEW_TOKENS, FocusedRuntimeArgs, FocusedRuntimeScenario, RunArgs},
model_identity::model_identity_for_path,
support::{ChildGuard, parse_wire_dtype, retry},
support::{ChildGuard, ensure_release_skippy_server_bin, parse_wire_dtype, retry},
};
struct DistributedRunOutcome {
@ -324,6 +324,7 @@ pub fn run_distributed(args: RunArgs) -> Result<()> {
}
fn run_distributed_collect(args: RunArgs) -> Result<DistributedRunOutcome> {
ensure_release_skippy_server_bin(&args.stage_server_bin)?;
let run_started = Instant::now();
let hosts = parse_hosts(&args.hosts)?;
let ranges = parse_stage_ranges(&args.splits, args.layer_end)?;
@ -1370,14 +1371,16 @@ fn prepare_local_stage(args: &RunArgs, stage: &StageAssignment) -> Result<()> {
stage_remote_topology_path(stage).unwrap_or_else(|_| "<unknown>".to_string())
)
})?;
if args.rsync_model_artifacts
&& let (Some(stage_model), Some(local_model)) = (
args.stage_model.as_ref(),
stage.local_materialized_model_path.as_ref(),
)
{
materialize_stage_model_on_coordinator(stage_model, stage, local_model)?;
if !args.rsync_model_artifacts {
return Ok(());
}
let Some(stage_model) = args.stage_model.as_ref() else {
return Ok(());
};
let Some(local_model) = stage.local_materialized_model_path.as_ref() else {
return Ok(());
};
materialize_stage_model_on_coordinator(stage_model, stage, local_model)?;
Ok(())
}

File diff suppressed because it is too large Load diff

View file

@ -13,7 +13,10 @@ use serde_json::{Value, json};
use crate::{
cli::LocalSingleArgs,
model_identity::model_identity_for_path,
support::{ChildGuard, generate_run_id, retry, temp_config_path, temp_db_path},
support::{
ChildGuard, ensure_release_skippy_server_bin, generate_run_id, retry, temp_config_path,
temp_db_path,
},
};
#[derive(Deserialize)]
@ -39,6 +42,7 @@ pub fn local_single(args: LocalSingleArgs) -> Result<()> {
if args.layer_start >= args.layer_end {
bail!("layer_start must be less than layer_end");
}
ensure_release_skippy_server_bin(&args.stage_server_bin)?;
let client = Client::builder()
.timeout(Duration::from_secs(15))

View file

@ -23,8 +23,8 @@ use crate::{
},
model_identity::model_identity_for_path,
support::{
ChildGuard, activation_width, connect_ready, generate_run_id, parse_wire_dtype,
temp_config_path_for,
ChildGuard, activation_width, connect_ready, ensure_release_skippy_server_bin,
generate_run_id, parse_wire_dtype, temp_config_path_for,
},
};
@ -273,6 +273,7 @@ fn run_binary_split(args: BinarySplitConfig) -> Result<BinarySplitResult> {
if args.split_layer == 0 || args.split_layer >= args.layer_end {
bail!("split_layer must be greater than zero and less than layer_end");
}
ensure_release_skippy_server_bin(&args.stage_server_bin)?;
validate_local_topology_plan(
&args.model_path,
args.layer_end,
@ -453,6 +454,7 @@ fn run_binary_split(args: BinarySplitConfig) -> Result<BinarySplitResult> {
}
fn run_binary_chain(args: LocalSplitChainBinaryArgs) -> Result<BinaryChainResult> {
ensure_release_skippy_server_bin(&args.stage_server_bin)?;
if args.split_layer_1 == 0
|| args.split_layer_1 >= args.split_layer_2
|| args.split_layer_2 >= args.layer_end

View file

@ -1,10 +1,12 @@
mod chat_corpus;
mod cli;
mod distributed;
mod evals;
mod local_single;
mod local_split;
mod model_identity;
mod support;
mod telemetry_report;
mod token_lengths;
mod verify_span_local;
@ -15,6 +17,7 @@ use crate::{
chat_corpus::chat_corpus,
cli::{Cli, CommandKind},
distributed::{focused_runtime, run_distributed},
evals::eval_command,
local_single::local_single,
local_split::{
local_split_binary, local_split_chain_binary, local_split_compare, local_split_inprocess,
@ -34,6 +37,7 @@ fn main() -> Result<()> {
CommandKind::ChatCorpus(args) => chat_corpus(args),
CommandKind::TokenLengths(args) => token_lengths(args),
CommandKind::FocusedRuntime(args) => focused_runtime(args),
CommandKind::Eval(args) => eval_command(args),
CommandKind::Run(args) => run_distributed(args),
}
}

View file

@ -1,6 +1,6 @@
use std::{
net::{SocketAddr, TcpStream},
path::PathBuf,
path::{Path, PathBuf},
process::{Child, Command},
thread,
time::{Duration, SystemTime, UNIX_EPOCH},
@ -49,6 +49,18 @@ pub fn retry(timeout_secs: u64, mut action: impl FnMut() -> Result<()>) -> Resul
Err(last_error.unwrap_or_else(|| anyhow!("timed out")))
}
pub fn ensure_release_skippy_server_bin(path: &Path) -> Result<()> {
let path = path.to_string_lossy();
let debug_path = path.contains("target/debug/skippy-server")
|| path.contains("target\\debug\\skippy-server");
if debug_path {
bail!(
"SkippyBench benchmark-managed skippy-server runs require a release binary; run `just release-build` and use --stage-server-bin target/release/skippy-server"
);
}
Ok(())
}
pub fn connect_ready(addr: SocketAddr, timeout_secs: u64) -> Result<TcpStream> {
let attempts = timeout_secs.saturating_mul(2).max(1);
let mut last_error = None;

View file

@ -0,0 +1,417 @@
use std::{collections::BTreeMap, fs, path::Path, thread, time::Duration};
use anyhow::{Context, Result, anyhow};
use serde::{Deserialize, Serialize};
use serde_json::Value;
const OPENAI_DECODE_TOKEN_SPAN: &str = "stage.openai_decode_token";
#[derive(Default, Serialize)]
pub struct BenchTelemetry {
pub metrics_http: Option<String>,
pub metrics_run_id: String,
pub status: &'static str,
pub detail: Option<String>,
pub report_path: Option<String>,
pub request_count: Option<u64>,
pub span_count: Option<u64>,
pub ttft_ms: Option<TelemetryAggregate>,
pub fttt_ms: Option<TelemetryAggregate>,
pub request_latency_ms: Option<TelemetryAggregate>,
pub generation_latency_ms: Option<TelemetryAggregate>,
}
#[derive(Default, Serialize)]
pub struct TelemetryAggregate {
pub count: usize,
pub min: f64,
pub mean: f64,
pub p50: f64,
pub p95: f64,
pub max: f64,
}
#[derive(Deserialize)]
struct CreateRunResponse {
run_id: String,
}
#[derive(Deserialize)]
struct RunStatusResponse {
status: String,
}
pub fn pending(metrics_http: &str, metrics_run_id: &str) -> BenchTelemetry {
BenchTelemetry {
metrics_http: Some(metrics_http.to_string()),
metrics_run_id: metrics_run_id.to_string(),
status: "pending",
detail: Some("telemetry collection has not run yet".to_string()),
..BenchTelemetry::default()
}
}
pub fn create_run(metrics_http: &str, metrics_run_id: &str, config: &Value) -> Result<()> {
let body = create_run_body(metrics_run_id, config);
let client = reqwest::blocking::Client::new();
let base = metrics_http.trim_end_matches('/');
let response = client
.post(format!("{base}/v1/runs"))
.json(&body)
.send()
.with_context(|| format!("create metrics-server run {metrics_run_id}"))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().unwrap_or_default();
if body.contains("UNIQUE constraint failed: runs.run_id") {
return accept_existing_run(&client, base, metrics_run_id);
}
return Err(anyhow!(
"metrics-server rejected run {metrics_run_id}: HTTP {status}: {body}"
));
}
let response = response
.json::<CreateRunResponse>()
.context("decode metrics-server create-run response")?;
if response.run_id == metrics_run_id {
Ok(())
} else {
Err(anyhow!(
"metrics-server returned unexpected run_id {}",
response.run_id
))
}
}
fn accept_existing_run(
client: &reqwest::blocking::Client,
metrics_http: &str,
metrics_run_id: &str,
) -> Result<()> {
let status = client
.get(format!("{metrics_http}/v1/runs/{metrics_run_id}/status"))
.send()
.with_context(|| format!("read metrics-server run status {metrics_run_id}"))?
.error_for_status()
.with_context(|| format!("metrics-server rejected status for run {metrics_run_id}"))?
.json::<RunStatusResponse>()
.context("decode metrics-server run status response")?;
if status.status == "running" || status.status == "implicit" {
Ok(())
} else {
Err(anyhow!(
"metrics-server run {metrics_run_id} already exists with status {}",
status.status
))
}
}
fn create_run_body(metrics_run_id: &str, config: &Value) -> Value {
let mut body = match config {
Value::Object(object) => object.clone(),
value => {
let mut object = serde_json::Map::new();
object.insert("config".to_string(), value.clone());
object
}
};
body.insert(
"run_id".to_string(),
Value::String(metrics_run_id.to_string()),
);
Value::Object(body)
}
pub fn finalize_and_collect(
metrics_http: &str,
metrics_run_id: &str,
report_path: &Path,
) -> Result<BenchTelemetry> {
let report = fetch_metrics_report(metrics_http, metrics_run_id, report_path)?;
Ok(telemetry_from_report(
metrics_http,
metrics_run_id,
report_path,
report,
))
}
pub fn unavailable(
metrics_http: &str,
metrics_run_id: &str,
error: &anyhow::Error,
) -> BenchTelemetry {
BenchTelemetry {
metrics_http: Some(metrics_http.to_string()),
metrics_run_id: metrics_run_id.to_string(),
status: "unavailable",
detail: Some(error.to_string()),
..BenchTelemetry::default()
}
}
fn fetch_metrics_report(
metrics_http: &str,
metrics_run_id: &str,
report_path: &Path,
) -> Result<Value> {
let client = reqwest::blocking::Client::new();
let base = metrics_http.trim_end_matches('/');
client
.post(format!("{base}/v1/runs/{metrics_run_id}/finalize"))
.send()
.with_context(|| format!("finalize metrics-server run {metrics_run_id}"))?
.error_for_status()
.with_context(|| format!("metrics-server rejected finalize for run {metrics_run_id}"))?;
thread::sleep(Duration::from_millis(250));
let report = client
.get(format!("{base}/v1/runs/{metrics_run_id}/report.json"))
.send()
.with_context(|| format!("fetch metrics report for run {metrics_run_id}"))?
.error_for_status()
.with_context(|| format!("metrics report request failed for run {metrics_run_id}"))?
.json::<Value>()
.context("decode metrics report JSON")?;
if let Some(parent) = report_path.parent() {
fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
}
fs::write(report_path, serde_json::to_vec_pretty(&report)?)
.with_context(|| format!("write {}", report_path.display()))?;
Ok(report)
}
fn telemetry_from_report(
metrics_http: &str,
metrics_run_id: &str,
report_path: &Path,
report: Value,
) -> BenchTelemetry {
let spans = report
.get("spans")
.and_then(Value::as_array)
.map(Vec::as_slice)
.unwrap_or(&[]);
let requests = summarize_telemetry_requests(spans);
let ttft_values = requests
.iter()
.filter_map(TelemetryRequestSummary::ttft_ms)
.collect::<Vec<_>>();
let request_latency_values = requests
.iter()
.filter_map(TelemetryRequestSummary::request_latency_ms)
.collect::<Vec<_>>();
let generation_latency_values = requests
.iter()
.filter_map(|summary| summary.generation_latency_ms)
.collect::<Vec<_>>();
let span_count = report
.get("counts")
.and_then(|counts| counts.get("spans"))
.and_then(Value::as_u64)
.or(Some(spans.len() as u64));
let status = if spans.is_empty() {
"no_spans"
} else if ttft_values.is_empty() {
"no_decode_token_spans"
} else {
"ok"
};
let detail = match status {
"no_spans" => Some(
"metrics report has no spans; target endpoint may not be emitting this run id"
.to_string(),
),
"no_decode_token_spans" => Some(
"metrics report has spans, but no stage.openai_decode_token spans; run endpoint with debug telemetry for server-side TTFT"
.to_string(),
),
_ => None,
};
BenchTelemetry {
metrics_http: Some(metrics_http.to_string()),
metrics_run_id: metrics_run_id.to_string(),
status,
detail,
report_path: Some(report_path.display().to_string()),
request_count: Some(requests.len() as u64),
span_count,
ttft_ms: aggregate(&ttft_values),
fttt_ms: aggregate(&ttft_values),
request_latency_ms: aggregate(&request_latency_values),
generation_latency_ms: aggregate(&generation_latency_values),
}
}
#[derive(Default)]
struct TelemetryRequestSummary {
first_start_unix_nanos: Option<i64>,
last_end_unix_nanos: Option<i64>,
first_decode_token_start_unix_nanos: Option<i64>,
generation_latency_ms: Option<f64>,
}
impl TelemetryRequestSummary {
fn observe_span(&mut self, span: &Value) {
let Some(start) = span.get("start_time_unix_nanos").and_then(Value::as_i64) else {
return;
};
let end = span
.get("end_time_unix_nanos")
.and_then(Value::as_i64)
.unwrap_or(start);
self.first_start_unix_nanos = Some(
self.first_start_unix_nanos
.map(|current| current.min(start))
.unwrap_or(start),
);
self.last_end_unix_nanos = Some(
self.last_end_unix_nanos
.map(|current| current.max(end))
.unwrap_or(end),
);
if span.get("name").and_then(Value::as_str) == Some(OPENAI_DECODE_TOKEN_SPAN) {
self.first_decode_token_start_unix_nanos = Some(
self.first_decode_token_start_unix_nanos
.map(|current| current.min(start))
.unwrap_or(start),
);
}
if span.get("name").and_then(Value::as_str) == Some("stage.openai_generation_summary") {
self.generation_latency_ms = span
.get("attributes")
.and_then(|attrs| attrs.get("llama_stage.elapsed_ms"))
.and_then(Value::as_f64)
.or_else(|| Some(nanos_to_ms(end.saturating_sub(start))));
}
}
fn ttft_ms(&self) -> Option<f64> {
Some(nanos_to_ms(
self.first_decode_token_start_unix_nanos?
.saturating_sub(self.first_start_unix_nanos?),
))
}
fn request_latency_ms(&self) -> Option<f64> {
Some(nanos_to_ms(
self.last_end_unix_nanos?
.saturating_sub(self.first_start_unix_nanos?),
))
}
}
fn summarize_telemetry_requests(spans: &[Value]) -> Vec<TelemetryRequestSummary> {
let mut by_request = BTreeMap::<String, TelemetryRequestSummary>::new();
for span in spans {
let request_id = span.get("request_id").and_then(Value::as_str).or_else(|| {
span.get("attributes")
.and_then(|attrs| attrs.get("llama_stage.request_id"))
.and_then(Value::as_str)
});
if let Some(request_id) = request_id {
by_request
.entry(request_id.to_string())
.or_default()
.observe_span(span);
}
}
by_request.into_values().collect()
}
fn aggregate(values: &[f64]) -> Option<TelemetryAggregate> {
if values.is_empty() {
return None;
}
let mut values = values.to_vec();
values.sort_by(f64::total_cmp);
let sum = values.iter().sum::<f64>();
Some(TelemetryAggregate {
count: values.len(),
min: values[0],
mean: sum / values.len() as f64,
p50: percentile(&values, 0.50),
p95: percentile(&values, 0.95),
max: values[values.len() - 1],
})
}
fn percentile(values: &[f64], percentile: f64) -> f64 {
let index = ((values.len() - 1) as f64 * percentile).round() as usize;
values[index]
}
fn nanos_to_ms(nanos: i64) -> f64 {
nanos as f64 / 1_000_000.0
}
#[cfg(test)]
mod tests {
use std::{
fs,
path::PathBuf,
time::{SystemTime, UNIX_EPOCH},
};
use serde_json::json;
use super::*;
#[test]
fn telemetry_report_extracts_ttft_and_request_latency() {
let run_dir = temp_run_dir("telemetry-metrics");
fs::create_dir_all(run_dir.join("raw")).unwrap();
let report = json!({
"counts": {"spans": 3},
"spans": [
{
"request_id": "req-1",
"name": "stage.openai_tokenize",
"start_time_unix_nanos": 1_000_000_000_i64,
"end_time_unix_nanos": 1_010_000_000_i64,
"attributes": {}
},
{
"request_id": "req-1",
"name": "stage.openai_decode_token",
"start_time_unix_nanos": 1_125_000_000_i64,
"end_time_unix_nanos": 1_130_000_000_i64,
"attributes": {}
},
{
"request_id": "req-1",
"name": "stage.openai_generation_summary",
"start_time_unix_nanos": 1_000_000_000_i64,
"end_time_unix_nanos": 1_250_000_000_i64,
"attributes": {"llama_stage.elapsed_ms": 250.0}
}
]
});
let telemetry = telemetry_from_report(
"http://127.0.0.1:18080",
"run-telemetry",
&run_dir.join("raw/metrics-report.json"),
report,
);
assert_eq!(telemetry.status, "ok");
assert_eq!(telemetry.request_count, Some(1));
assert_eq!(telemetry.span_count, Some(3));
assert_eq!(telemetry.ttft_ms.as_ref().unwrap().mean, 125.0);
assert_eq!(telemetry.request_latency_ms.as_ref().unwrap().mean, 250.0);
assert_eq!(
telemetry.generation_latency_ms.as_ref().unwrap().mean,
250.0
);
let _ = fs::remove_dir_all(run_dir);
}
fn temp_run_dir(label: &str) -> PathBuf {
std::env::temp_dir().join(format!(
"skippy-bench-{label}-{}-{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis()
))
}
}

View file

@ -1,7 +1,7 @@
use std::{net::SocketAddr, path::PathBuf};
use crate::telemetry::TelemetryLevel;
use clap::{Parser, Subcommand};
use clap::{Parser, Subcommand, ValueEnum};
#[derive(Parser)]
#[command(about = "Llama staged-runtime server")]
@ -192,6 +192,20 @@ pub struct ServeOpenAiArgs {
pub telemetry_queue_capacity: usize,
#[arg(long, value_enum, default_value_t = TelemetryLevel::Summary)]
pub telemetry_level: TelemetryLevel,
#[arg(
long = "openai-guardrails",
value_enum,
default_value_t = OpenAiGuardrailsCliMode::Metrics,
help = "OpenAI compatibility guardrail mode for standalone serving: disabled, metrics, or enforce."
)]
pub openai_guardrails: OpenAiGuardrailsCliMode,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
pub enum OpenAiGuardrailsCliMode {
Disabled,
Metrics,
Enforce,
}
#[cfg(test)]
@ -228,5 +242,24 @@ mod tests {
assert_eq!(args.prefill_adaptive_start, 128);
assert_eq!(args.prefill_adaptive_step, 128);
assert_eq!(args.prefill_adaptive_max, 384);
assert_eq!(args.openai_guardrails, OpenAiGuardrailsCliMode::Metrics);
}
#[test]
fn serve_openai_accepts_explicit_guardrail_mode() {
let cli = Cli::try_parse_from([
"skippy-server",
"serve-openai",
"--config",
"stage.json",
"--openai-guardrails",
"enforce",
])
.unwrap();
let Command::ServeOpenAi(args) = cli.command else {
panic!("expected serve-openai command");
};
assert_eq!(args.openai_guardrails, OpenAiGuardrailsCliMode::Enforce);
}
}

View file

@ -29,7 +29,8 @@ use openai_frontend::{
GuardedOpenAiBackend, GuardrailMode, GuardrailPolicy, GuardrailPolicyHandle, MessageContent,
MessageContentPart, ModelId, ModelObject, OpenAiBackend, OpenAiError, OpenAiErrorKind,
OpenAiHookPolicy, OpenAiRequestContext, OpenAiResult, PrefillHookSignals, ReasoningEffort,
StreamingGuardrailMode, Usage, apply_chat_hook_outcome, chat_mesh_hooks_enabled,
RetryExhaustionMode, StreamingGuardrailMode, Usage, apply_chat_hook_outcome,
chat_mesh_hooks_enabled,
};
use serde::Serialize;
use serde_json::{Value, json};
@ -62,7 +63,7 @@ use crate::{
send_client_ready_hello_if_enabled, stage_output_activation_capacity,
write_stage_message_conditioned,
},
cli::ServeOpenAiArgs,
cli::{OpenAiGuardrailsCliMode, ServeOpenAiArgs},
config::{load_json, validate_config},
kv_integration::{KvStageIntegration, proactive_eviction_attrs, proactive_eviction_error_kind},
runtime_state::{RuntimeSessionStats, RuntimeState, load_runtime},
@ -186,7 +187,7 @@ pub async fn serve_openai(args: ServeOpenAiArgs) -> Result<()> {
let decode_batcher = DecodeBatcher::new(runtime.clone(), args.generation_concurrency);
let decode_frame_batcher =
DecodeFrameBatcher::new(runtime.clone(), args.generation_concurrency);
let backend = Arc::new(StageOpenAiBackend {
let backend: Arc<dyn OpenAiBackend> = Arc::new(StageOpenAiBackend {
runtime,
config,
telemetry: telemetry.clone(),
@ -212,6 +213,8 @@ pub async fn serve_openai(args: ServeOpenAiArgs) -> Result<()> {
decode_batcher,
decode_frame_batcher,
});
let backend = OpenAiGuardrailsConfig::for_standalone_mode(args.openai_guardrails)
.wrap_backend_with_context_limit(backend, Some(ctx_size));
let app: Router = instrumented_openai_router(backend, telemetry.clone());
println!(
@ -289,6 +292,37 @@ impl OpenAiGuardrailsConfig {
}
}
pub fn compatibility_for_skippy() -> Self {
Self {
target: OpenAiGuardrailsTarget::Skippy,
policy: GuardrailPolicy {
mode: GuardrailMode::MetricsOnly,
apply_to_all_models: true,
retry_exhaustion_mode: RetryExhaustionMode::PassLastText,
..GuardrailPolicy::default()
}
.into(),
compaction: None,
}
}
pub fn for_standalone_mode(mode: OpenAiGuardrailsCliMode) -> Self {
match mode {
OpenAiGuardrailsCliMode::Disabled => Self::disabled_for_skippy(),
OpenAiGuardrailsCliMode::Metrics => Self::compatibility_for_skippy(),
OpenAiGuardrailsCliMode::Enforce => Self {
target: OpenAiGuardrailsTarget::Skippy,
policy: GuardrailPolicy {
mode: GuardrailMode::Enforce,
apply_to_all_models: true,
..GuardrailPolicy::default()
}
.into(),
compaction: None,
},
}
}
pub fn status(&self) -> OpenAiGuardrailsStatus {
let policy = self.policy.snapshot();
OpenAiGuardrailsStatus {

View file

@ -284,15 +284,6 @@ pub(super) fn ensure_chat_runtime_features_supported(
"chat logprobs are parsed by openai-frontend but not yet implemented by skippy runtime",
));
}
if request
.response_format
.as_ref()
.is_some_and(requires_structured_output)
{
return Err(OpenAiError::unsupported(
"structured output is parsed by openai-frontend but not yet implemented by skippy runtime",
));
}
Ok(())
}
@ -311,14 +302,6 @@ pub(super) fn has_requested_tools(value: &Value) -> bool {
!matches!(value, Value::Array(items) if items.is_empty())
}
pub(super) fn requires_structured_output(value: &Value) -> bool {
value
.as_object()
.and_then(|object| object.get("type"))
.and_then(Value::as_str)
.is_some_and(|format_type| format_type != "text")
}
pub(super) fn ensure_extra_generation_fields_absent(
extra: &std::collections::BTreeMap<String, serde_json::Value>,
) -> OpenAiResult<()> {

View file

@ -651,7 +651,7 @@ fn chat_runtime_feature_guard_allows_noop_parity_fields() {
}
#[test]
fn chat_runtime_feature_guard_rejects_structured_output() {
fn chat_runtime_feature_guard_allows_structured_output_for_guardrails() {
let request: ChatCompletionRequest = serde_json::from_value(json!({
"model": "test",
"messages": [{"role": "user", "content": "hi"}],
@ -662,11 +662,7 @@ fn chat_runtime_feature_guard_rejects_structured_output() {
}))
.unwrap();
let error = ensure_chat_runtime_features_supported(&request).unwrap_err();
assert_eq!(
unsupported_code(error),
Some("unsupported_model_feature".to_string())
);
ensure_chat_runtime_features_supported(&request).unwrap();
}
#[derive(Default)]
@ -780,6 +776,29 @@ async fn guarded_structured_output_is_not_rejected_by_runtime_feature_guard() {
);
}
#[test]
fn standalone_guardrail_modes_have_expected_policies() {
let metrics =
OpenAiGuardrailsConfig::for_standalone_mode(crate::cli::OpenAiGuardrailsCliMode::Metrics)
.status();
assert_eq!(metrics.mode, "metrics");
assert_eq!(metrics.retry_exhaustion, "pass_last_text");
assert_eq!(metrics.small_model_policy, "all");
let enforce =
OpenAiGuardrailsConfig::for_standalone_mode(crate::cli::OpenAiGuardrailsCliMode::Enforce)
.status();
assert_eq!(enforce.mode, "enforce");
assert_eq!(enforce.retry_exhaustion, "error");
assert_eq!(enforce.small_model_policy, "all");
let disabled =
OpenAiGuardrailsConfig::for_standalone_mode(crate::cli::OpenAiGuardrailsCliMode::Disabled)
.status();
assert_eq!(disabled.mode, "disabled");
assert_eq!(disabled.small_model_policy, "small_models_only");
}
#[tokio::test]
async fn compaction_wraps_skippy_backend_even_when_guardrails_are_disabled() {
let backend = Arc::new(StructuredGuardrailRecordingBackend::default());
@ -2227,7 +2246,7 @@ fn explicit_completion_request_values_override_request_defaults() {
}
#[test]
fn request_defaults_do_not_make_structured_output_or_logprobs_executable() {
fn request_defaults_do_not_make_logprobs_executable() {
let mut request: ChatCompletionRequest = serde_json::from_value(json!({
"model": "test",
"messages": [{"role": "user", "content": "hi"}],

View file

@ -644,12 +644,12 @@ openai_structured_status="$(
-H 'content-type: application/json' \
-d "$openai_structured_request"
)"
if [[ "$openai_structured_status" != "400" ]]; then
echo "expected structured-output request to return HTTP 400 until backend support lands, got ${openai_structured_status}" >&2
if [[ "$openai_structured_status" != "200" ]]; then
echo "expected structured-output request to be accepted by the OpenAI compatibility layer, got ${openai_structured_status}" >&2
cat "$openai_structured_response" >&2 || true
exit 1
fi
assert_json "$openai_structured_response" '.error.code == "unsupported_model_feature"'
assert_json "$openai_structured_response" '.choices[0].message.role == "assistant"'
cleanup
SERVER_PID=""