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);
}
}