oy-cli 0.10.4

Local AI coding CLI for inspecting, editing, running commands, and auditing repositories
Documentation
use super::*;
use crate::llm::{GenerationOptions, ModelRoute, Protocol, RouteAuth, ToolResultContent};

fn request() -> LlmRequest {
    LlmRequest {
        route: ModelRoute {
            protocol: Protocol::Gemini,
            model: "gemini-2.5-flash".to_string(),
            base_url: Some("https://generativelanguage.googleapis.com/v1beta".to_string()),
            auth: RouteAuth::Header {
                name: "x-goog-api-key".to_string(),
                value: "secret".to_string(),
            },
            query_params: None,
            additional_params: None,
            default_output_tokens: None,
        },
        system_prompt: "You are concise.".to_string(),
        system_cache: None,
        messages: vec![Message::user_text("Say hello.")],
        tools: vec![ToolSpec {
            name: "lookup".to_string(),
            description: "Lookup data".to_string(),
            parameters: json!({
                "type": "object",
                "required": ["status", "missing"],
                "properties": {
                    "status": {"type": "integer", "enum": [1, 2]},
                    "tags": {"type": "array"},
                    "name": {"type": "string", "properties": {"ignored": {"type": "string"}}, "required": ["ignored"]},
                    "metadata": {"type": "object"}
                }
            }),
            cache: None,
        }],
        max_turns: 4,
        tool_choice: Some(ToolChoice::Tool {
            name: "lookup".to_string(),
        }),
        generation: Some(GenerationOptions {
            max_tokens: Some(20),
            temperature: Some(0.0),
            top_p: Some(0.9),
            top_k: Some(40),
            stop: Some(vec!["END".to_string()]),
            ..Default::default()
        }),
        cache: None,
    }
}

#[test]
fn request_body_lowers_messages_tools_and_generation() {
    let body = request_body(&request()).unwrap();

    assert_eq!(
        body,
        json!({
            "contents": [{"role": "user", "parts": [{"text": "Say hello."}]}],
            "systemInstruction": {"parts": [{"text": "You are concise."}]},
            "tools": [{"functionDeclarations": [{
                "name": "lookup",
                "description": "Lookup data",
                "parameters": {
                    "type": "object",
                    "required": ["status"],
                    "properties": {
                        "status": {"type": "string", "enum": ["1", "2"]},
                        "tags": {"type": "array", "items": {"type": "string"}},
                        "name": {"type": "string"},
                        "metadata": {"type": "object"}
                    }
                }
            }]}],
            "toolConfig": {"functionCallingConfig": {"mode": "ANY", "allowedFunctionNames": ["lookup"]}},
            "generationConfig": {
                "maxOutputTokens": 20,
                "temperature": 0.0,
                "topP": 0.9,
                "topK": 40,
                "stopSequences": ["END"]
            }
        })
    );
}

#[test]
fn request_body_lowers_tool_result_with_function_name() {
    let mut request = request();
    request.messages = vec![
        Message::Assistant {
            id: None,
            content: vec![MessageContent::ToolCall {
                id: "tool_0".to_string(),
                call_id: Some("tool_0".to_string()),
                name: "lookup".to_string(),
                arguments: json!({"query": "weather"}),
                signature: Some("sig-a".to_string()),
                additional_params: None,
            }],
        },
        Message::User {
            content: vec![MessageContent::ToolResult {
                id: "lookup".to_string(),
                call_id: None,
                content: vec![ToolResultContent::Text {
                    text: "sunny".to_string(),
                }],
                cache: None,
            }],
        },
    ];

    let body = request_body(&request).unwrap();

    assert_eq!(
        body["contents"],
        json!([
            {"role": "model", "parts": [{"functionCall": {"name": "lookup", "args": {"query": "weather"}}, "thoughtSignature": "sig-a"}]},
            {"role": "user", "parts": [{"functionResponse": {"name": "lookup", "response": {"name": "lookup", "content": "sunny"}}}]}
        ])
    );
}

#[test]
fn request_body_omits_tools_when_tool_choice_is_none() {
    let mut request = request();
    request.tool_choice = Some(ToolChoice::None);

    let body = request_body(&request).unwrap();

    assert!(body.get("tools").is_none());
    assert!(body.get("toolConfig").is_none());
}

#[test]
fn stream_parser_maps_text_reasoning_tool_usage_and_finish() {
    let mut state = StreamState::default();
    let mut events = Vec::new();
    events.extend(parse_stream_event(&mut state, &json!({
        "candidates": [{"content": {"role": "model", "parts": [{"text": "thinking", "thought": true}]}}]
    })).unwrap());
    events.extend(parse_stream_event(&mut state, &json!({
        "candidates": [{"content": {"role": "model", "parts": [{"text": "Hello"}, {"functionCall": {"name": "lookup", "args": {"query": "weather"}}, "thoughtSignature": "sig-a"}]}, "finishReason": "STOP"}],
        "usageMetadata": {"promptTokenCount": 5, "candidatesTokenCount": 2, "thoughtsTokenCount": 1, "cachedContentTokenCount": 1, "totalTokenCount": 8}
    })).unwrap());
    events.extend(finish_stream(&mut state).unwrap());

    assert!(matches!(events[0], LlmEvent::ReasoningDelta { ref text } if text == "thinking"));
    assert!(matches!(events[1], LlmEvent::TextDelta { ref text } if text == "Hello"));
    let call = events
        .iter()
        .find_map(|event| match event {
            LlmEvent::ToolCall { call, .. } => Some(call),
            _ => None,
        })
        .unwrap();
    assert_eq!(call.call_id, "tool_0");
    assert_eq!(call.arguments_value().unwrap(), json!({"query": "weather"}));
    assert_eq!(call.signature.as_deref(), Some("sig-a"));
    assert!(events.iter().any(|event| matches!(
        event,
        LlmEvent::StepFinish {
            reason: FinishReason::ToolCalls,
            usage: Some(Usage {
                input_tokens: Some(5),
                output_tokens: Some(3),
                cache_read_input_tokens: Some(1),
                reasoning_tokens: Some(1),
                total_tokens: Some(8),
                ..
            }),
        }
    )));
}

#[test]
fn endpoint_path_matches_opencode_gemini_route() {
    assert_eq!(
        endpoint_path("gemini-2.5-flash"),
        "models/gemini-2.5-flash:streamGenerateContent"
    );
}