openrouter-batch 0.1.2

OpenRouter Batch API client with structured-output and embeddings support, live-wire verified
Documentation
//! Structured-submission regression tests against the live provider rules
//! observed 2026-08-30 (`.agents/skills/openrouter-api/`): custom_id is
//! capped at 64 characters and the `reasoning` field is only sent on demand.

use openrouter_batch::{
    BatchObject, BatchTransport, Message, StructuredParams, SubmitBatch, TransportError,
    submit_structured,
};
use parking_lot::Mutex;
use serde_json::json;
use std::sync::Arc;

/// Records the submitted batch without any network access.
struct CapturingTransport {
    submitted: Arc<Mutex<Option<SubmitBatch>>>,
}

#[async_trait::async_trait]
impl BatchTransport for CapturingTransport {
    async fn submit(
        &self,
        batch: &SubmitBatch,
        _idempotency_key: &str,
    ) -> Result<BatchObject, TransportError> {
        *self.submitted.lock() = Some(batch.clone());
        Ok(serde_json::from_value(json!({
            "id": "batch_1", "status": "validating", "created_at": 1_000
        }))
        .unwrap())
    }

    async fn get(&self, _batch_id: &str) -> Result<BatchObject, TransportError> {
        panic!("no poll expected in this test");
    }
}

fn params(disable_reasoning: bool) -> StructuredParams {
    StructuredParams {
        schema: json!({"type": "object"}),
        schema_name: "reply".to_string(),
        max_tokens: 100,
        disable_reasoning,
        require_parameters: false,
        reasoning_max_tokens: None,
    }
}

fn messages() -> Vec<Message> {
    vec![Message::user("Say OK")]
}

/// Regression (live 2026-08-30): the provider rejects the whole batch with
/// 422 when any custom_id exceeds 64 characters. The facade must catch the
/// overlong key locally, before anything reaches the wire.
#[tokio::test]
async fn overlong_custom_id_fails_without_submitting() {
    let submitted = Arc::new(Mutex::new(None));
    let transport = CapturingTransport {
        submitted: submitted.clone(),
    };
    let long_key = "a".repeat(65);
    let error = submit_structured(
        &transport,
        "z-ai/glm-5.3-flash:batch",
        "idempotency-1",
        &params(false),
        vec![(long_key, messages())],
    )
    .await
    .unwrap_err();
    assert!(error.to_string().contains("64-character cap"), "{error}");
    assert!(!error.retryable(), "a key-scheme fix is the only remedy");
    assert!(
        submitted.lock().is_none(),
        "no batch may reach the transport"
    );
}

/// A 64-character key is exactly at the provider's limit and must submit.
#[tokio::test]
async fn sixty_four_character_custom_id_submits() {
    let submitted = Arc::new(Mutex::new(None));
    let transport = CapturingTransport {
        submitted: submitted.clone(),
    };
    submit_structured(
        &transport,
        "z-ai/glm-5.3-flash:batch",
        "idempotency-1",
        &params(false),
        vec![("a".repeat(64), messages())],
    )
    .await
    .unwrap();
    assert!(submitted.lock().is_some());
}

/// Regression (live 2026-08-30): z-ai/glm-5.3-flash makes reasoning
/// mandatory and fails every item when the body carries
/// `"reasoning": {"enabled": false}`. The field is opt-in.
#[tokio::test]
async fn reasoning_field_is_omitted_unless_requested() {
    let submitted = Arc::new(Mutex::new(None));
    let transport = CapturingTransport {
        submitted: submitted.clone(),
    };
    submit_structured(
        &transport,
        "z-ai/glm-5.3-flash:batch",
        "idempotency-1",
        &params(false),
        vec![("req-1".to_string(), messages())],
    )
    .await
    .unwrap();
    let batch = submitted.lock().clone().unwrap();
    let body = &batch.requests()[0].body;
    assert!(body.get("reasoning").is_none(), "{body}");

    submit_structured(
        &transport,
        "z-ai/glm-5.3-flash:batch",
        "idempotency-2",
        &params(true),
        vec![("req-1".to_string(), messages())],
    )
    .await
    .unwrap();
    let batch = submitted.lock().clone().unwrap();
    let body = &batch.requests()[0].body;
    assert_eq!(body["reasoning"], json!({"enabled": false}), "{body}");
}