use std::{
io::{self, IsTerminal, Read, Write},
process::ExitCode,
};
use clap::{Args, Parser, Subcommand, error::ErrorKind};
use edge_completions::{
ApiBaseUrl, AssistantOutput, ChatCompletions, ChatMessage, ChatRequest, Client, Error,
GatewayId, InvalidConfiguration, MaxTokens, ModelId, Temperature,
};
const MAX_PROMPT_BYTES: usize = 64 * 1024;
const USAGE_ERROR_EXIT_CODE: u8 = 2;
#[derive(Debug, Parser)]
#[command(
name = "edge-completions",
version,
about = "Typed Cloudflare chat completions from the command line",
long_about = "Send typed, non-streaming chat-completion requests through Cloudflare.\n\
Credentials are read from CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN; \
KIMI3_ON_CLOUDFLARE_API_KEY remains a token fallback. Provider envelopes and \
credentials are never printed."
)]
struct CommandLine {
#[arg(long, global = true, value_name = "URL")]
base_url: Option<ApiBaseUrl>,
#[arg(long, global = true, value_name = "GATEWAY_ID")]
gateway_id: Option<GatewayId>,
#[command(subcommand)]
command: Command,
}
#[derive(Debug, Subcommand)]
enum Command {
Check,
Chat(ChatArguments),
}
#[derive(Debug, Args)]
struct ChatArguments {
#[arg(value_name = "PROMPT")]
prompt: Option<String>,
#[arg(
short,
long,
value_name = "MODEL",
default_value = "moonshotai/kimi-k3"
)]
model: ModelId,
#[arg(short, long, value_name = "TEXT")]
system: Option<String>,
#[arg(long, value_name = "NUMBER")]
temperature: Option<f32>,
#[arg(long, value_name = "TOKENS")]
max_tokens: Option<u32>,
}
#[derive(Debug, thiserror::Error)]
enum CommandError {
#[error(transparent)]
Sdk(#[from] Error),
#[error(transparent)]
InvalidConfiguration(#[from] InvalidConfiguration),
#[error("provide a non-empty PROMPT argument or pipe a prompt through standard input")]
MissingPrompt,
#[error("prompt exceeds the {limit_bytes}-byte command limit")]
PromptTooLarge { limit_bytes: usize },
#[error("failed to read the prompt from standard input: {source}")]
ReadPrompt {
#[source]
source: io::Error,
},
#[error("failed to initialize the async runtime: {source}")]
Runtime {
#[source]
source: io::Error,
},
#[error("failed to write command output: {source}")]
WriteOutput {
#[source]
source: io::Error,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct Prompt(String);
impl Prompt {
fn new(value: String) -> Result<Self, CommandError> {
if value.trim().is_empty() {
return Err(CommandError::MissingPrompt);
}
if value.len() > MAX_PROMPT_BYTES {
return Err(CommandError::PromptTooLarge {
limit_bytes: MAX_PROMPT_BYTES,
});
}
Ok(Self(value))
}
fn from_argument_or_reader(
argument: Option<String>,
input: impl Read,
input_is_terminal: bool,
) -> Result<Self, CommandError> {
if let Some(value) = argument {
return Self::new(value);
}
if input_is_terminal {
return Err(CommandError::MissingPrompt);
}
let mut value = String::new();
input
.take((MAX_PROMPT_BYTES + 1) as u64)
.read_to_string(&mut value)
.map_err(|source| CommandError::ReadPrompt { source })?;
Self::new(value)
}
fn into_inner(self) -> String {
self.0
}
}
fn main() -> ExitCode {
match CommandLine::try_parse() {
Ok(command_line) => exit_for(run(command_line)),
Err(error) => match emit_parse_result(error) {
Ok(exit_code) => exit_code,
Err(error) => report_error(&error),
},
}
}
fn run(command_line: CommandLine) -> Result<(), CommandError> {
let CommandLine {
base_url,
gateway_id,
command,
} = command_line;
match command {
Command::Check => {
configured_client(base_url, gateway_id)?;
write_line(io::stdout().lock(), "configuration is valid")
}
Command::Chat(mut arguments) => {
let input_is_terminal = io::stdin().is_terminal();
let prompt = Prompt::from_argument_or_reader(
arguments.prompt.take(),
io::stdin().lock(),
input_is_terminal,
)?;
let request = arguments.into_request(prompt)?;
let client = configured_client(base_url, gateway_id)?;
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|source| CommandError::Runtime { source })?;
let answer = runtime.block_on(complete_text(&client, &request))?;
write_line(io::stdout().lock(), &answer)
}
}
}
fn configured_client(
base_url: Option<ApiBaseUrl>,
gateway_id: Option<GatewayId>,
) -> Result<Client, CommandError> {
let mut builder = Client::builder_from_env()?;
if let Some(base_url) = base_url {
builder = builder.base_url(base_url);
}
if let Some(gateway_id) = gateway_id {
builder = builder.gateway_id(gateway_id);
}
Ok(builder.build()?)
}
impl ChatArguments {
fn into_request(self, prompt: Prompt) -> Result<ChatRequest, CommandError> {
let user_message = ChatMessage::user(prompt.into_inner());
let mut builder = match self.system {
Some(system) => ChatRequest::builder(self.model)
.message(ChatMessage::system(system))
.message(user_message),
None => ChatRequest::builder(self.model).message(user_message),
};
if let Some(value) = self.temperature {
builder = builder.temperature(Temperature::new(value)?);
}
if let Some(value) = self.max_tokens {
builder = builder.max_tokens(MaxTokens::new(value)?);
}
Ok(builder.build())
}
}
async fn complete_text<C>(completions: &C, request: &ChatRequest) -> Result<String, CommandError>
where
C: ChatCompletions,
{
let completion = completions.complete(request).await?;
match completion.first_choice()?.message().output() {
AssistantOutput::Text(text) | AssistantOutput::TextAndToolCalls { text, .. } => {
Ok(text.to_owned())
}
AssistantOutput::ToolCalls(_) | AssistantOutput::Empty => Err(Error::MissingContent.into()),
}
}
fn emit_parse_result(error: clap::Error) -> Result<ExitCode, CommandError> {
let exit_code = match error.kind() {
ErrorKind::DisplayHelp | ErrorKind::DisplayVersion => ExitCode::SUCCESS,
_ => ExitCode::from(USAGE_ERROR_EXIT_CODE),
};
let rendered = error.to_string();
if error.use_stderr() {
write_text(io::stderr().lock(), &rendered)?;
} else {
write_text(io::stdout().lock(), &rendered)?;
}
Ok(exit_code)
}
fn exit_for(result: Result<(), CommandError>) -> ExitCode {
match result {
Ok(()) => ExitCode::SUCCESS,
Err(error) => report_error(&error),
}
}
fn report_error(error: &CommandError) -> ExitCode {
let message = format!("edge-completions: {error}\n");
let _write_result = write_text(io::stderr().lock(), &message);
ExitCode::FAILURE
}
fn write_line(output: impl Write, value: &str) -> Result<(), CommandError> {
write_text(output, &format!("{value}\n"))
}
fn write_text(mut output: impl Write, value: &str) -> Result<(), CommandError> {
output
.write_all(value.as_bytes())
.map_err(|source| CommandError::WriteOutput { source })
}
#[cfg(test)]
mod tests {
use std::io::Cursor;
use serde_json::json;
use super::*;
type TestResult = Result<(), Box<dyn std::error::Error>>;
#[test]
fn parses_typed_chat_options() -> TestResult {
let command_line = CommandLine::try_parse_from([
"edge-completions",
"chat",
"Explain typed boundaries",
"--temperature",
"0.4",
"--max-tokens",
"120",
])?;
let Command::Chat(arguments) = command_line.command else {
return Err("chat subcommand was not parsed".into());
};
assert_eq!(arguments.model, ModelId::kimi_k3());
assert_eq!(arguments.temperature, Some(0.4));
assert_eq!(arguments.max_tokens, Some(120));
Ok(())
}
#[test]
fn help_is_a_successful_parse_result() -> TestResult {
let error = match CommandLine::try_parse_from(["edge-completions", "--help"]) {
Ok(_) => return Err("help unexpectedly parsed as an executable command".into()),
Err(error) => error,
};
assert_eq!(error.kind(), ErrorKind::DisplayHelp);
Ok(())
}
#[test]
fn reads_a_piped_prompt_and_builds_a_typed_request() -> TestResult {
let prompt =
Prompt::from_argument_or_reader(None, Cursor::new("Explain typed boundaries"), false)?;
let arguments = ChatArguments {
prompt: None,
model: ModelId::kimi_k3(),
system: Some("Be concise".to_owned()),
temperature: Some(0.5),
max_tokens: Some(80),
};
let request = arguments.into_request(prompt)?;
assert_eq!(
serde_json::to_value(request)?,
json!({
"model": "moonshotai/kimi-k3",
"messages": [
{"role": "system", "content": "Be concise"},
{"role": "user", "content": "Explain typed boundaries"}
],
"temperature": 0.5,
"max_tokens": 80
})
);
Ok(())
}
#[test]
fn rejects_an_oversized_prompt_before_an_api_call() {
let result = Prompt::new("x".repeat(MAX_PROMPT_BYTES + 1));
assert!(matches!(result, Err(CommandError::PromptTooLarge { .. })));
}
#[test]
fn requires_a_prompt_in_an_interactive_terminal() {
let result = Prompt::from_argument_or_reader(None, Cursor::new(Vec::<u8>::new()), true);
assert!(matches!(result, Err(CommandError::MissingPrompt)));
}
}