Skip to main content

vtcode_llm/provider/
decisions.rs

1//! Text-only, single-question Decisions choice contract.
2
3use serde::{Deserialize, Serialize};
4use serde_json::{Value, json};
5use vtcode_commons::tool_types::CompactStr;
6
7use super::{LLMError, Usage};
8
9/// The model supported by the experimental Decisions endpoint.
10pub const DECISIONS_MODEL: &str = "gpt-6-luna";
11
12/// Whether `base_url` is the direct standard OpenAI endpoint Decisions requires.
13///
14/// Shared by the runtime `OpenAIProvider::decisions_eligible` gate and the
15/// `/model` picker's non-I/O eligibility mirror, so the two cannot drift. Empty
16/// input is not standard: callers that treat "no override" as eligible must
17/// filter empties before calling this.
18pub fn is_standard_decisions_endpoint(base_url: &str) -> bool {
19    let Ok(url) = url::Url::parse(base_url) else {
20        return false;
21    };
22    url.scheme() == "https"
23        && url.host_str() == Some("api.openai.com")
24        && url.port_or_known_default() == Some(443)
25        && url.username().is_empty()
26        && url.password().is_none()
27        && matches!(url.path(), "/v1" | "/v1/")
28        && url.query().is_none()
29        && url.fragment().is_none()
30}
31
32/// One supplied text category and its meaning.
33#[derive(Debug, Clone, Serialize)]
34pub struct DecisionChoiceOption {
35    pub value: CompactStr,
36    pub description: CompactStr,
37}
38
39/// Evidence and instructions for exactly one named choice question.
40/// Deliberately omits images, predicates, scores, and generation controls.
41#[derive(Clone)]
42pub struct ChoiceDecisionRequest {
43    pub input: String,
44    pub name: CompactStr,
45    pub instructions: String,
46    pub choices: Vec<DecisionChoiceOption>,
47}
48
49impl ChoiceDecisionRequest {
50    pub(crate) fn payload(&self) -> Result<Value, LLMError> {
51        if self.name.is_empty()
52            || self.choices.len() < 2
53            || self.choices.iter().enumerate().any(|(index, choice)| {
54                choice.value.is_empty() || self.choices[..index].iter().any(|previous| previous.value == choice.value)
55            })
56        {
57            return Err(LLMError::Provider {
58                message: "Invalid Decisions choice question".to_owned(),
59                metadata: None,
60            });
61        }
62        Ok(json!({
63            "model": DECISIONS_MODEL,
64            "input": self.input,
65            "questions": [{"type": "choice", "name": self.name, "instructions": self.instructions, "choices": self.choices}]
66        }))
67    }
68}
69
70/// A probability for one supplied category.
71#[derive(Debug, Clone, Deserialize)]
72pub struct DecisionProbability {
73    pub value: CompactStr,
74    pub probability: f64,
75}
76
77/// A validated choice answer. Confidence is retained without a threshold.
78#[derive(Debug, Clone, Deserialize)]
79pub struct ChoiceDecisionAnswer {
80    pub name: CompactStr,
81    pub choice: CompactStr,
82    pub confidence: f64,
83    pub probabilities: Vec<DecisionProbability>,
84}
85
86/// An inconclusive answer still retains independently parsed billed usage.
87#[derive(Debug, Clone)]
88pub struct ChoiceDecisionResponse {
89    pub answer: Option<ChoiceDecisionAnswer>,
90    pub usage: Option<Usage>,
91}
92
93impl ChoiceDecisionResponse {
94    pub(crate) fn from_payload(payload: &Value, request: &ChoiceDecisionRequest) -> Self {
95        let usage = parse_usage(payload.get("usage"));
96        let answer = (|| {
97            let answers = payload.get("answers")?.as_array()?;
98            if answers.len() != 1 || answers.first()?.get("type")?.as_str()? != "choice" {
99                return None;
100            }
101            let answer: ChoiceDecisionAnswer = serde_json::from_value(answers.first()?.clone()).ok()?;
102            let allowed = |value: &str| request.choices.iter().any(|option| option.value == value);
103            if answer.name != request.name
104                || !allowed(&answer.choice)
105                || !unit_interval(answer.confidence)
106                || answer.probabilities.len() != request.choices.len()
107                || answer.probabilities.iter().enumerate().any(|(index, probability)| {
108                    !allowed(&probability.value)
109                        || !unit_interval(probability.probability)
110                        || answer.probabilities[..index]
111                            .iter()
112                            .any(|previous| previous.value == probability.value)
113                })
114            {
115                return None;
116            }
117            Some(answer)
118        })();
119        Self { answer, usage }
120    }
121}
122
123fn unit_interval(value: f64) -> bool {
124    value.is_finite() && (0.0..=1.0).contains(&value)
125}
126
127fn parse_usage(value: Option<&Value>) -> Option<Usage> {
128    let value = value?;
129    let input = u32::try_from(value.get("input_tokens")?.as_u64()?).ok()?;
130    let output = u32::try_from(value.get("output_tokens")?.as_u64()?).ok()?;
131    let total = u32::try_from(value.get("total_tokens")?.as_u64()?).ok()?;
132    if input.checked_add(output)? != total {
133        return None;
134    }
135    Some(Usage {
136        prompt_tokens: input,
137        completion_tokens: output,
138        total_tokens: total,
139        cached_prompt_tokens: None,
140        cache_creation_tokens: None,
141        cache_read_tokens: None,
142        iterations: None,
143    })
144}
145
146#[cfg(test)]
147mod tests;