vtcode-llm 0.181.3

LLM provider abstraction, client implementations, and streaming for VT Code
Documentation
use super::*;

#[tokio::test]
async fn generate_sends_inline_compaction_and_parses_compaction_response() {
    use wiremock::matchers::{body_partial_json, header, method, path};
    use wiremock::{Mock, MockServer, ResponseTemplate};

    let server = MockServer::start().await;
    Mock::given(method("POST"))
        .and(path("/v1/messages"))
        .and(header("anthropic-beta", "compact-2026-01-12"))
        .and(body_partial_json(json!({
            "context_management": {
                "edits": [{"type": "compact_20260112"}]
            }
        })))
        .respond_with(ResponseTemplate::new(200).set_body_json(json!({
            "content": [{"type": "compaction", "content": "opaque summary"}],
            "stop_reason": "compaction"
        })))
        .expect(1)
        .mount(&server)
        .await;

    let provider = AnthropicProvider::new_with_client(
        "test-key".to_string(),
        models::CLAUDE_SONNET_5.to_string(),
        reqwest::Client::builder().no_proxy().build().expect("test client should build"),
        format!("{}/v1", server.uri()),
        vtcode_config::TimeoutsConfig::default(),
    );
    let response = LLMProvider::generate(
        &provider,
        LLMRequest {
            model: models::CLAUDE_SONNET_5.to_string(),
            messages: vec![Message::user("compact this".to_string())].into(),
            context_management: Some(json!({
                "edits": [{
                    "type": "compact_20260112",
                    "trigger": {"type": "input_tokens", "value": 50_000},
                    "pause_after_compaction": true
                }]
            })),
            ..Default::default()
        },
    )
    .await
    .expect("inline compaction request should succeed");

    assert!(matches!(response.finish_reason, crate::provider::FinishReason::Pause));
    assert_eq!(response.compaction.as_deref(), Some("opaque summary"));
}
#[tokio::test]
async fn stream_preserves_anthropic_compaction_block_and_iteration_usage() {
    use wiremock::matchers::{body_partial_json, method, path};
    use wiremock::{Mock, MockServer, ResponseTemplate};

    let server = MockServer::start().await;
    let body = concat!(
        "data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_1\",\"role\":\"assistant\",\"content\":[],\"model\":\"claude-sonnet-5\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":10,\"output_tokens\":0}}}\n\n",
        "data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"compaction\",\"content\":null,\"signature\":\"signed-summary\"}}\n\n",
        "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"compaction_delta\",\"content\":null,\"encrypted_content\":\"opaque-extension\"}}\n\n",
        "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"compaction_delta\",\"content\":\"opaque \"}}\n\n",
        "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"compaction_delta\",\"content\":\"summary\"}}\n\n",
        "data: {\"type\":\"content_block_stop\",\"index\":0}\n\n",
        "data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"compaction\",\"stop_sequence\":null},\"usage\":{\"input_tokens\":null,\"output_tokens\":0,\"iterations\":[{\"type\":\"compaction\",\"input_tokens\":50,\"output_tokens\":5},{\"type\":\"message\",\"model\":null,\"input_tokens\":10,\"output_tokens\":0},{\"type\":\"advisor_message\",\"model\":\"claude-opus-5\",\"input_tokens\":7,\"output_tokens\":2}]}}\n\n",
        "data: {\"type\":\"message_stop\"}\n\n",
    );

    Mock::given(method("POST"))
        .and(path("/v1/messages"))
        .and(body_partial_json(json!({"stream": true})))
        .respond_with(ResponseTemplate::new(200).set_body_raw(body, "text/event-stream"))
        .expect(1)
        .mount(&server)
        .await;

    let provider = AnthropicProvider::new_with_client(
        "test-key".to_string(),
        models::CLAUDE_SONNET_5.to_string(),
        reqwest::Client::builder().no_proxy().build().expect("test client should build"),
        format!("{}/v1", server.uri()),
        vtcode_config::TimeoutsConfig::default(),
    );
    let mut stream = LLMProvider::stream(
        &provider,
        LLMRequest {
            model: models::CLAUDE_SONNET_5.to_string(),
            messages: vec![Message::user("continue after compaction".to_string())].into(),
            ..Default::default()
        },
    )
    .await
    .expect("stream request should succeed");

    let mut completed = None;
    while let Some(event) = stream.next().await {
        match event.expect("stream event") {
            LLMStreamEvent::Completed { response } => completed = Some(*response),
            LLMStreamEvent::Token { .. }
            | LLMStreamEvent::Reasoning { .. }
            | LLMStreamEvent::ReasoningSignature { .. }
            | LLMStreamEvent::ReasoningStage { .. } => {}
        }
    }

    let response = completed.expect("completed stream response");
    assert_eq!(response.compaction.as_deref(), Some("opaque summary"));
    let details = response.reasoning_details.expect("compaction detail");
    assert_eq!(details.len(), 1);
    let detail: serde_json::Value = serde_json::from_str(&details[0]).expect("serialized compaction detail");
    assert_eq!(detail["content"], "opaque summary");
    assert_eq!(detail["signature"], "signed-summary");
    assert_eq!(detail["encrypted_content"], "opaque-extension");

    let usage = response.usage.expect("usage");
    let totals = usage.billable_totals();
    assert_eq!(totals.prompt_tokens, 67);
    assert_eq!(totals.completion_tokens, 7);
}
#[tokio::test]
async fn stream_carries_refusal_stop_details_from_message_delta() {
    use wiremock::matchers::{body_partial_json, method, path};
    use wiremock::{Mock, MockServer, ResponseTemplate};

    let server = MockServer::start().await;
    let body = concat!(
        "data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_1\",\"role\":\"assistant\",\"content\":[],\"model\":\"claude-sonnet-5\",\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":10,\"output_tokens\":0}}}\n\n",
        "data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"refusal\",\"stop_sequence\":null,\"stop_details\":{\"type\":\"refusal\",\"category\":\"cyber\",\"explanation\":\"declined\",\"fallback_credit_token\":\"credit-1\",\"fallback_has_prefill_claim\":true}},\"usage\":{\"output_tokens\":0}}\n\n",
        "data: {\"type\":\"message_stop\"}\n\n",
    );

    Mock::given(method("POST"))
        .and(path("/v1/messages"))
        .and(body_partial_json(json!({"stream": true})))
        .respond_with(ResponseTemplate::new(200).set_body_raw(body, "text/event-stream"))
        .expect(1)
        .mount(&server)
        .await;

    let provider = AnthropicProvider::new_with_client(
        "test-key".to_string(),
        models::CLAUDE_SONNET_5.to_string(),
        reqwest::Client::builder().no_proxy().build().expect("test client should build"),
        format!("{}/v1", server.uri()),
        vtcode_config::TimeoutsConfig::default(),
    );
    let mut stream = LLMProvider::stream(
        &provider,
        LLMRequest {
            model: models::CLAUDE_SONNET_5.to_string(),
            messages: vec![Message::user("hello".to_string())].into(),
            ..Default::default()
        },
    )
    .await
    .expect("stream request should succeed");

    let mut completed = None;
    while let Some(event) = stream.next().await {
        if let LLMStreamEvent::Completed { response } = event.expect("stream event") {
            completed = Some(*response);
        }
    }

    let response = completed.expect("completed stream response");
    assert!(matches!(response.finish_reason, crate::provider::FinishReason::Refusal));
    let details = response.reasoning_details.expect("stop_details detail");
    assert_eq!(details.len(), 1);
    let detail: serde_json::Value = serde_json::from_str(&details[0]).expect("serialized stop_details");
    assert_eq!(detail["type"], "stop_details");
    assert_eq!(detail["category"], "cyber");
    assert_eq!(detail["explanation"], "declined");
    assert_eq!(detail["fallback_credit_token"], "credit-1");
    assert_eq!(detail["fallback_has_prefill_claim"], true);
}
#[tokio::test]
async fn stream_records_interleaved_block_order_for_replay() {
    use wiremock::matchers::{method, path};
    use wiremock::{Mock, MockServer, ResponseTemplate};

    let server = MockServer::start().await;
    let body = concat!(
        "data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_1\",\"role\":\"assistant\",\"content\":[],\"model\":\"claude-opus-5-5\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":10,\"output_tokens\":0}}}\n\n",
        "data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"thinking\",\"thinking\":\"\"}}\n\n",
        "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"signature_delta\",\"signature\":\"sig-1\"}}\n\n",
        "data: {\"type\":\"content_block_stop\",\"index\":0}\n\n",
        "data: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n",
        "data: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\"Reading \"}}\n\n",
        "data: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\"the parser.\"}}\n\n",
        "data: {\"type\":\"content_block_stop\",\"index\":1}\n\n",
        "data: {\"type\":\"content_block_start\",\"index\":2,\"content_block\":{\"type\":\"thinking\",\"thinking\":\"\"}}\n\n",
        "data: {\"type\":\"content_block_delta\",\"index\":2,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"Checking entry.\"}}\n\n",
        "data: {\"type\":\"content_block_delta\",\"index\":2,\"delta\":{\"type\":\"signature_delta\",\"signature\":\"sig-2\"}}\n\n",
        "data: {\"type\":\"content_block_stop\",\"index\":2}\n\n",
        "data: {\"type\":\"content_block_start\",\"index\":3,\"content_block\":{\"type\":\"tool_use\",\"id\":\"toolu_1\",\"name\":\"read_file\",\"input\":{}}}\n\n",
        "data: {\"type\":\"content_block_delta\",\"index\":3,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"path\\\":\\\"src/parser.rs\\\"}\"}}\n\n",
        "data: {\"type\":\"content_block_stop\",\"index\":3}\n\n",
        "data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\",\"stop_sequence\":null},\"usage\":{\"output_tokens\":12}}\n\n",
        "data: {\"type\":\"message_stop\"}\n\n",
    );

    Mock::given(method("POST"))
        .and(path("/v1/messages"))
        .respond_with(ResponseTemplate::new(200).set_body_raw(body, "text/event-stream"))
        .expect(1)
        .mount(&server)
        .await;

    let model = models::anthropic::CLAUDE_OPUS_5_5;
    let provider = AnthropicProvider::new_with_client(
        "test-key".to_string(),
        model.to_string(),
        reqwest::Client::builder().no_proxy().build().expect("test client should build"),
        format!("{}/v1", server.uri()),
        vtcode_config::TimeoutsConfig::default(),
    );
    let mut stream = LLMProvider::stream(
        &provider,
        LLMRequest {
            model: model.to_string(),
            messages: vec![Message::user("fix the parser".to_string())].into(),
            ..Default::default()
        },
    )
    .await
    .expect("stream request should succeed");

    let mut completed = None;
    while let Some(event) = stream.next().await {
        if let LLMStreamEvent::Completed { response } = event.expect("stream event") {
            completed = Some(*response);
        }
    }
    let response = completed.expect("completed stream response");
    let tool_calls = response.tool_calls.clone().expect("tool calls");
    let details = response
        .reasoning_details
        .clone()
        .map(|details| details.into_iter().map(serde_json::Value::String).collect());
    let assistant = Message::assistant_with_tools_and_reasoning(
        response.content.clone().expect("text content"),
        tool_calls,
        details,
    );

    let replay = LLMRequest {
        model: model.to_string(),
        messages: vec![
            Message::user("fix the parser".to_string()),
            assistant,
            Message::tool_response("toolu_1".to_string(), "fn parse() {}".to_string()),
        ]
        .into(),
        ..Default::default()
    };
    let payload = provider.convert_to_anthropic_format(&replay).expect("payload conversion");
    assert_eq!(
        payload["messages"][1]["content"],
        json!([
            { "type": "thinking", "thinking": "", "signature": "sig-1" },
            { "type": "text", "text": "Reading the parser." },
            { "type": "thinking", "thinking": "Checking entry.", "signature": "sig-2" },
            { "type": "tool_use", "id": "toolu_1", "name": "read_file", "input": { "path": "src/parser.rs" } }
        ])
    );
}
#[tokio::test]
async fn stream_mid_output_fallback_drops_declined_thinking_and_tool_use() {
    use wiremock::matchers::{method, path};
    use wiremock::{Mock, MockServer, ResponseTemplate};

    let server = MockServer::start().await;
    let body = concat!(
        "data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_1\",\"role\":\"assistant\",\"content\":[],\"model\":\"claude-fable-5-1\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":10,\"output_tokens\":0}}}\n\n",
        "data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"thinking\",\"thinking\":\"\"}}\n\n",
        "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"Refused model reasoning.\"}}\n\n",
        "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"signature_delta\",\"signature\":\"sig-1\"}}\n\n",
        "data: {\"type\":\"content_block_stop\",\"index\":0}\n\n",
        "data: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n",
        "data: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\"Checking. \"}}\n\n",
        "data: {\"type\":\"content_block_stop\",\"index\":1}\n\n",
        "data: {\"type\":\"content_block_start\",\"index\":2,\"content_block\":{\"type\":\"tool_use\",\"id\":\"toolu_declined\",\"name\":\"read_file\",\"input\":{}}}\n\n",
        "data: {\"type\":\"content_block_delta\",\"index\":2,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"path\\\":\\\"src/\"}}\n\n",
        "data: {\"type\":\"content_block_start\",\"index\":3,\"content_block\":{\"type\":\"fallback\",\"from\":{\"model\":\"claude-fable-5-1\"},\"to\":{\"model\":\"claude-opus-4-8\"}}}\n\n",
        "data: {\"type\":\"content_block_stop\",\"index\":3}\n\n",
        "data: {\"type\":\"content_block_start\",\"index\":4,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n",
        "data: {\"type\":\"content_block_delta\",\"index\":4,\"delta\":{\"type\":\"text_delta\",\"text\":\"Here is the answer.\"}}\n\n",
        "data: {\"type\":\"content_block_stop\",\"index\":4}\n\n",
        "data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null},\"usage\":{\"output_tokens\":12}}\n\n",
        "data: {\"type\":\"message_stop\"}\n\n",
    );

    Mock::given(method("POST"))
        .and(path("/v1/messages"))
        .respond_with(ResponseTemplate::new(200).set_body_raw(body, "text/event-stream"))
        .expect(1)
        .mount(&server)
        .await;

    let model = models::anthropic::CLAUDE_OPUS_5_5;
    let provider = AnthropicProvider::new_with_client(
        "test-key".to_string(),
        model.to_string(),
        reqwest::Client::builder().no_proxy().build().expect("test client should build"),
        format!("{}/v1", server.uri()),
        vtcode_config::TimeoutsConfig::default(),
    );
    let mut stream = LLMProvider::stream(
        &provider,
        LLMRequest {
            model: model.to_string(),
            messages: vec![Message::user("fix the parser".to_string())].into(),
            ..Default::default()
        },
    )
    .await
    .expect("stream request should succeed");

    let mut completed = None;
    while let Some(event) = stream.next().await {
        if let LLMStreamEvent::Completed { response } = event.expect("stream event") {
            completed = Some(*response);
        }
    }
    let response = completed.expect("completed stream response");
    assert!(response.tool_calls.is_none(), "declined tool_use must not run");
    assert_eq!(response.content.as_deref(), Some("Checking. Here is the answer."));
    let raw_details = response.reasoning_details.clone().expect("details");
    let details: Vec<serde_json::Value> = raw_details
        .iter()
        .map(|detail| serde_json::from_str(detail).expect("detail json"))
        .collect();
    assert!(details.iter().all(|detail| detail["type"] != "thinking"));
    assert!(details.iter().any(|detail| {
        detail["type"] == "fallback"
            && detail["from"]["model"] == "claude-fable-5-1"
            && detail["to"]["model"] == "claude-opus-4-8"
    }));

    let assistant = Message::assistant(response.content.clone().expect("text content"))
        .with_reasoning_details(Some(raw_details.into_iter().map(serde_json::Value::String).collect()));
    let replay = LLMRequest {
        model: model.to_string(),
        messages: vec![
            Message::user("fix the parser".to_string()),
            assistant,
            Message::user("thanks".to_string()),
        ]
        .into(),
        ..Default::default()
    };
    let payload = provider.convert_to_anthropic_format(&replay).expect("payload conversion");
    assert_eq!(
        payload["messages"][1]["content"],
        json!([
            { "type": "text", "text": "Checking. " },
            { "type": "text", "text": "Here is the answer." }
        ])
    );
}