ferrin-spec 0.2.0

Ferrin provider specification: model traits, prompt and content types, stream parts, shared errors.
Documentation
use pretty_assertions::assert_eq;
use serde_json::json;

use ferrin_spec::Headers;
use ferrin_spec::PromptMessage;
use ferrin_spec::language_model::CallOptions;
use ferrin_spec::language_model::CallOptionsRecord;
use ferrin_spec::language_model::ReasoningEffort;
use ferrin_spec::language_model::ResponseFormat;
use ferrin_spec::language_model::ToolChoice;

#[test]
fn reasoning_effort_uses_expected_wire_names() {
    let all = [
        (ReasoningEffort::ProviderDefault, "provider-default"),
        (ReasoningEffort::None, "none"),
        (ReasoningEffort::Minimal, "minimal"),
        (ReasoningEffort::Low, "low"),
        (ReasoningEffort::Medium, "medium"),
        (ReasoningEffort::High, "high"),
        (ReasoningEffort::XHigh, "xhigh"),
    ];
    for (effort, name) in all {
        assert_eq!(serde_json::to_value(effort).unwrap(), json!(name));
        assert_eq!(effort.as_str(), name);
        let parsed: ReasoningEffort = serde_json::from_value(json!(name)).unwrap();
        assert_eq!(parsed, effort);
    }
}

#[test]
fn tool_choice_and_response_format_are_tagged() {
    assert_eq!(
        serde_json::to_value(ToolChoice::tool("weather")).unwrap(),
        json!({ "type": "tool", "tool_name": "weather" })
    );
    assert_eq!(
        serde_json::to_value(ToolChoice::None).unwrap(),
        json!({ "type": "none" })
    );
    assert_eq!(
        serde_json::to_value(ResponseFormat::json(json!({ "type": "object" }))).unwrap(),
        json!({ "type": "json", "schema": { "type": "object" } })
    );
    assert_eq!(
        serde_json::to_value(ResponseFormat::Text).unwrap(),
        json!({ "type": "text" })
    );
}

#[test]
fn recordable_round_trips_and_masks_headers() {
    let mut options = CallOptions::new(vec![PromptMessage::system("be brief")]);
    options.max_output_tokens = Some(10);
    options.headers = Headers::new().with("authorization", "Bearer x");
    options.tool_choice = Some(ToolChoice::Auto);

    let record = options.to_recordable();
    let value = serde_json::to_value(&record).unwrap();
    assert_eq!(
        value,
        json!({
            "prompt": [{ "role": "system", "content": "be brief" }],
            "max_output_tokens": 10,
            "tool_choice": { "type": "auto" },
            "reasoning": "provider-default",
            "headers": { "authorization": "***" },
        })
    );

    let parsed: CallOptionsRecord = serde_json::from_value(json!({
        "prompt": [{ "role": "system", "content": "be brief" }],
        "max_output_tokens": 10,
        "tool_choice": { "type": "auto" },
    }))
    .unwrap();
    let restored = CallOptions::from(parsed);
    assert_eq!(restored.max_output_tokens, Some(10));
    assert_eq!(restored.prompt, options.prompt);
    assert!(restored.headers.is_empty());
}