xbp 10.39.0

XBP is a zero-config build pack that can also interact with proxies, kafka, sockets, synthetic monitors.
use reqwest::header::{AUTHORIZATION, CONTENT_TYPE};
use serde_json::{json, Value as JsonValue};

pub(crate) const OPENROUTER_URL: &str = "https://openrouter.ai/api/v1/chat/completions";
pub(crate) const DEFAULT_MODEL: &str = "openai/gpt-4o-mini";
pub(crate) const DEFAULT_COMMIT_SYSTEM_PROMPT: &str = r#"You generate one git commit message for the current worktree.

Return strict JSON only. No markdown, no code fences, no explanation.

Schema:
{
  "type": "feat|fix|docs|refactor|chore|test|build|ci|perf|style|revert",
  "scope": "short lowercase scope or null",
  "description": "imperative summary under 72 chars, no trailing period",
  "body": ["optional paragraph", "optional paragraph"],
  "breaking_change": false,
  "breaking_description": "required only when breaking_change=true",
  "footers": ["optional footer line"]
}

Rules:
- Follow Conventional Commits 1.0.0 exactly.
- Prefer feat for new capabilities, fix for bug repair, docs for docs-only changes, refactor for internal restructuring without behavior change, and ci/build/test/perf/style/chore when clearly better.
- Use a scope only when it is clearly anchored in the codebase.
- The description must be specific to the changed behavior or module, not generic.
- Prefer concrete module or behavior words from the worktree summary over vague nouns like support, changes, updates, or improvements.
- If the diff mostly restructures existing code paths or extracts helpers, prefer refactor instead of feat.
- Mention a breaking change only when the diff clearly changes a public or operator-facing contract.
- Use the provided file list, symbol list, and diff excerpt. Do not invent files, functions, types, or breaking changes.
- Paths under terminals/ are IDE agent session logs, not product code. Never describe them as features, flows, capabilities, or supported functionality. terminals/.next-id is an IDE session counter, not application configuration; never describe updating it as a feature. Prefer chore or omit them from the summary when they are accidental noise.
- When a body is warranted, use 1-2 short paragraphs: first what changed concretely, then the impact or intent. Omit the body instead of writing filler."#;
pub(crate) const DEFAULT_RELEASE_NOTES_SYSTEM_PROMPT: &str = r#"You are generating structured release notes for a GitHub release.

Return strict JSON only. No markdown, no code fences, no explanation.

Schema:
{
  "sections": [
    {
      "title": "string",
      "summary": "one short paragraph for the section",
      "bullets": [
        {
          "commit_shas": ["shortsha1", "shortsha2"],
          "summary": "one concise user-facing bullet"
        }
      ]
    }
  ]
}

Target rendered markdown shape:
# [release-title](release-url)
## What's Changed
Comparing changes since [previous-tag](previous-release-url).
### Section Title
Short paragraph summary for the section.
- [sha1](commit-url), [sha2](commit-url) Concise user-facing summary
- [#123](pull-url) Pull request title
- [SUI-1234](linear-url) Linear issue title
## Install package version
```bash
# Exactly one copyable install command (or cargo-dist shell/ps1 installers when used)
cargo install package@version
```

Rules:
- Do not invent install commands or package names; the host CLI injects Install sections automatically after What's Changed.
- Never emit dual npm/pnpm or crates/npm alternative blocks for the same package.

- Focus on what changed for users or operators, not on mirroring the git log.
- Combine thematically similar commits into one bullet. Do not create one bullet per commit when several commits describe the same user-visible change.
- Specifically collapse repeated chat-related commits, deleted-message persistence commits, switch-component commits, upload UTF-8 fix commits, and Athena migration commits into one bullet each when they appear.
- Prefer section titles like Cases & Communication, Reliability, Athena Migration, Authentication & Security, Forms Platform, Administration, User Interface, and Documentation & Tooling when they fit.
- Use every provided short SHA exactly once across all bullets unless a commit is obviously trivial maintenance.
- Use only short SHAs from the provided list. Never invent SHAs.
- Keep sections readable: usually 3 to 7 sections, with 1 to 4 bullets each.
- Keep section summaries concise and high-level. Keep bullet summaries concise and user-facing."#;

#[derive(Debug, Clone, Copy, Default)]
pub(crate) struct CompletionOptions {
    max_tokens: Option<u32>,
    temperature: Option<f32>,
    json_object: bool,
    provider_sort: Option<&'static str>,
    require_parameters: bool,
}

impl CompletionOptions {
    pub(crate) const fn fast_json(max_tokens: u32) -> Self {
        Self {
            max_tokens: Some(max_tokens),
            temperature: Some(0.0),
            json_object: true,
            provider_sort: Some("latency"),
            require_parameters: true,
        }
    }
}

fn build_chat_messages(system_prompt: Option<&str>, prompt: &str) -> Vec<JsonValue> {
    let mut messages = Vec::new();

    if let Some(system_prompt) = system_prompt
        .map(str::trim)
        .filter(|value| !value.is_empty())
    {
        messages.push(json!({
            "role": "system",
            "content": system_prompt,
        }));
    }

    messages.push(json!({
        "role": "user",
        "content": prompt,
    }));

    messages
}

fn build_completion_request(
    model: &str,
    system_prompt: Option<&str>,
    prompt: &str,
    options: CompletionOptions,
) -> JsonValue {
    let mut body = json!({
        "model": model,
        "messages": build_chat_messages(system_prompt, prompt),
        "stream": false,
    });
    let object = body
        .as_object_mut()
        .expect("completion request body must be a JSON object");

    if let Some(max_tokens) = options.max_tokens {
        object.insert("max_tokens".to_string(), json!(max_tokens));
    }
    if let Some(temperature) = options.temperature {
        object.insert("temperature".to_string(), json!(temperature));
    }
    if options.json_object {
        object.insert(
            "response_format".to_string(),
            json!({ "type": "json_object" }),
        );
    }
    if options.provider_sort.is_some() || options.require_parameters {
        object.insert(
            "provider".to_string(),
            json!({
                "sort": options.provider_sort,
                "require_parameters": options.require_parameters,
            }),
        );
    }

    body
}

pub(crate) async fn complete_prompt(
    api_key: &str,
    model: &str,
    system_prompt: Option<&str>,
    prompt: &str,
    title: Option<&str>,
) -> Option<String> {
    complete_prompt_with_options(
        api_key,
        model,
        system_prompt,
        prompt,
        title,
        CompletionOptions::default(),
    )
    .await
}

pub(crate) async fn complete_prompt_with_options(
    api_key: &str,
    model: &str,
    system_prompt: Option<&str>,
    prompt: &str,
    title: Option<&str>,
    options: CompletionOptions,
) -> Option<String> {
    let trimmed_api_key = api_key.trim();
    if trimmed_api_key.is_empty() {
        return None;
    }

    let mut request = reqwest::Client::new()
        .post(OPENROUTER_URL)
        .header(AUTHORIZATION, format!("Bearer {}", trimmed_api_key))
        .header(CONTENT_TYPE, "application/json");

    if let Some(title) = title.map(str::trim).filter(|value| !value.is_empty()) {
        request = request.header("X-OpenRouter-Title", title);
    }

    let response = request
        .json(&build_completion_request(
            model,
            system_prompt,
            prompt,
            options,
        ))
        .send()
        .await
        .ok()?;

    if !response.status().is_success() {
        return None;
    }

    let data: serde_json::Value = response.json().await.ok()?;
    data.get("choices")?
        .as_array()?
        .first()?
        .get("message")?
        .get("content")?
        .as_str()
        .map(|content| content.trim().to_string())
}

pub(crate) async fn complete_user_prompt(
    api_key: &str,
    model: &str,
    prompt: &str,
    title: Option<&str>,
) -> Option<String> {
    complete_prompt(api_key, model, None, prompt, title).await
}

#[cfg(test)]
mod tests {
    use super::{build_chat_messages, build_completion_request, CompletionOptions};

    #[test]
    fn build_chat_messages_includes_system_prompt_when_present() {
        let messages = build_chat_messages(Some("System prompt"), "User prompt");

        assert_eq!(messages.len(), 2);
        assert_eq!(messages[0]["role"], "system");
        assert_eq!(messages[0]["content"], "System prompt");
        assert_eq!(messages[1]["role"], "user");
        assert_eq!(messages[1]["content"], "User prompt");
    }

    #[test]
    fn build_chat_messages_omits_blank_system_prompt() {
        let messages = build_chat_messages(Some("   "), "User prompt");

        assert_eq!(messages.len(), 1);
        assert_eq!(messages[0]["role"], "user");
        assert_eq!(messages[0]["content"], "User prompt");
    }

    #[test]
    fn default_completion_request_preserves_generic_behavior() {
        let body = build_completion_request(
            "example/model",
            Some("System prompt"),
            "User prompt",
            CompletionOptions::default(),
        );

        assert_eq!(body["model"], "example/model");
        assert_eq!(body["stream"], false);
        assert!(body.get("max_tokens").is_none());
        assert!(body.get("temperature").is_none());
        assert!(body.get("response_format").is_none());
        assert!(body.get("provider").is_none());
    }

    #[test]
    fn fast_json_request_caps_output_and_prioritizes_latency() {
        let body = build_completion_request(
            "example/model",
            Some("System prompt"),
            "User prompt",
            CompletionOptions::fast_json(320),
        );

        assert_eq!(body["max_tokens"], 320);
        assert_eq!(body["temperature"], 0.0);
        assert_eq!(body["response_format"]["type"], "json_object");
        assert_eq!(body["provider"]["sort"], "latency");
        assert_eq!(body["provider"]["require_parameters"], true);
    }
}