-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
11 changed files
with
275 additions
and
238 deletions.
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,2 +1,19 @@ | ||
COMMIT_SHA := `git rev-parse HEAD` | ||
|
||
dev: | ||
cargo watch -w src/ -x run | ||
|
||
docker-build: | ||
docker build --build-arg COMMIT_SHA="{{COMMIT_SHA}}" . | ||
|
||
docker-build-quiet: | ||
docker build --build-arg COMMIT_SHA="{{COMMIT_SHA}}" --quiet . | ||
|
||
docker-run: | ||
docker run --env OPENAI_API_KEY --publish 8080:8080 "$(just docker-build-quiet)" | ||
|
||
deploy: | ||
fly deploy --env COMMIT_SHA="$(git rev-parse HEAD)" | ||
fly deploy --build-arg | ||
|
||
logs: | ||
fly logs |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,26 @@ | ||
use std::env; | ||
|
||
use tokio::process::Command; | ||
|
||
pub fn print() { | ||
Command::new("env") | ||
.spawn() | ||
.expect("env command failed to start"); | ||
} | ||
|
||
pub fn commit_sha() -> String { | ||
env::var("COMMIT_SHA").unwrap_or_else(|_| "unknown".to_string()) | ||
} | ||
|
||
pub fn docker() -> bool { | ||
let var = env::var("DOCKER"); | ||
var.is_ok() && var.unwrap() == "true" | ||
} | ||
|
||
pub fn http_basic_auth_password() -> Option<String> { | ||
env::var("HTTP_BASIC_AUTH_PASSWORD").ok() | ||
} | ||
|
||
pub fn openai_api_key() -> Option<String> { | ||
env::var("OPENAI_API_KEY").ok() | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,214 +1,27 @@ | ||
pub use self::error::{Error, Result}; | ||
use std::net::SocketAddr; | ||
|
||
use axum::Server; | ||
|
||
use axum::{ | ||
body::StreamBody, | ||
http::{header, Uri}, | ||
response::IntoResponse, | ||
routing::{get, get_service}, | ||
Json, Router, Server, | ||
}; | ||
use axum_extra::middleware::option_layer; | ||
use eventsource_stream::Eventsource; | ||
use futures::future; | ||
use futures::{StreamExt, TryStreamExt}; | ||
use serde::{Deserialize, Serialize}; | ||
use std::{ | ||
env, | ||
io::{self, Write}, | ||
net::SocketAddr, | ||
time::SystemTime, | ||
}; | ||
use tokio::signal; | ||
use tower_http::{services::ServeDir, validate_request::ValidateRequestHeaderLayer}; | ||
pub use self::error::{Error, Result}; | ||
|
||
mod env; | ||
mod error; | ||
mod signal; | ||
mod web; | ||
|
||
#[tokio::main] | ||
async fn main() { | ||
println!("Starting server..."); | ||
|
||
let addr = SocketAddr::from(([0, 0, 0, 0], 8080)); | ||
Server::bind(&addr) | ||
.serve(app().into_make_service()) | ||
.with_graceful_shutdown(shutdown_signal()) | ||
.await | ||
.expect("server should serve"); | ||
} | ||
|
||
fn app() -> Router { | ||
let auth = option_layer( | ||
env::var("HTTP_BASIC_AUTH_PASSWORD") | ||
.ok() | ||
.map(|password| ValidateRequestHeaderLayer::basic("user", &password)), | ||
); | ||
|
||
Router::new().route("/health", get(health)).nest_service( | ||
"/", | ||
// Handle GET. | ||
get_service( | ||
// Serve static files from "public". | ||
ServeDir::new("public") | ||
// When static file missing use handler. | ||
.fallback(get(handler)), | ||
) | ||
// Other methods use handler. | ||
.post(handler) | ||
.patch(handler) | ||
.put(handler) | ||
.delete(handler) | ||
// Apply HTTP basic auth. | ||
.layer(auth), | ||
) | ||
} | ||
|
||
async fn shutdown_signal() { | ||
let ctrl_c = async { | ||
signal::ctrl_c() | ||
.await | ||
.expect("failed to install Ctrl+C handler"); | ||
}; | ||
|
||
let terminate = async { | ||
signal::unix::signal(signal::unix::SignalKind::terminate()) | ||
.expect("failed to install signal handler") | ||
.recv() | ||
.await; | ||
}; | ||
|
||
tokio::select! { | ||
_ = ctrl_c => {}, | ||
_ = terminate => {}, | ||
} | ||
|
||
println!("signal received, starting graceful shutdown"); | ||
} | ||
|
||
#[derive(Debug, Serialize)] | ||
struct HealthBody { | ||
time: u64, | ||
commit_sha: String, | ||
basic_auth_enabled: bool, | ||
} | ||
|
||
#[derive(Debug, Serialize)] | ||
struct ChatCompletionsBody { | ||
model: String, | ||
stream: bool, | ||
messages: Vec<Message>, | ||
} | ||
|
||
#[derive(Debug, Serialize)] | ||
struct Message { | ||
role: String, | ||
content: String, | ||
} | ||
|
||
#[derive(Debug, Deserialize)] | ||
struct Event { | ||
choices: Vec<Choice>, | ||
} | ||
|
||
#[derive(Debug, Deserialize)] | ||
struct Choice { | ||
delta: Delta, | ||
} | ||
|
||
#[derive(Debug, Deserialize)] | ||
struct Delta { | ||
content: String, | ||
} | ||
|
||
async fn health() -> Result<impl IntoResponse> { | ||
println!("\n----------"); | ||
println!("Health"); | ||
println!("----------"); | ||
|
||
env::var("OPENAI_API_KEY").map_err(|_| Error::EnvironmentError)?; | ||
|
||
let time = SystemTime::now() | ||
.duration_since(SystemTime::UNIX_EPOCH) | ||
.map_err(|_| Error::SystemTimeError)? | ||
.as_secs(); | ||
let commit_sha = env::var("COMMIT_SHA").unwrap_or_else(|_| "unknown".to_string()); | ||
let basic_auth_enabled = env::var("HTTP_BASIC_AUTH_PASSWORD").is_ok(); | ||
|
||
Ok(Json(HealthBody { | ||
time, | ||
basic_auth_enabled, | ||
commit_sha, | ||
})) | ||
} | ||
|
||
async fn handler(uri: Uri) -> Result<impl IntoResponse> { | ||
println!("\n----------"); | ||
println!("Fetching: {uri}"); | ||
println!("----------"); | ||
|
||
let prompt = r#" | ||
Output a valid HTML document for the webpage that could be located at the URL path provided by the user. Include general navigation anchor tags as well as relative anchor tags to other related pages. Include a minimal amount of inline styles to improve the look of the page. Make the text content quite long with a decent amount of interesting content. Do not use any dummy text on the page. | ||
Start the reponse with the following exact characters: | ||
<!doctype html> | ||
<html>"#; | ||
|
||
let body = ChatCompletionsBody { | ||
model: "gpt-3.5-turbo".to_string(), | ||
stream: true, | ||
messages: vec![ | ||
Message { | ||
role: "system".to_string(), | ||
content: prompt.to_string(), | ||
}, | ||
Message { | ||
role: "user".to_string(), | ||
content: uri.to_string(), | ||
}, | ||
], | ||
let addr = if env::docker() { | ||
SocketAddr::from(([0, 0, 0, 0], 8080)) | ||
} else { | ||
SocketAddr::from(([127, 0, 0, 1], 8080)) | ||
}; | ||
|
||
let stream = reqwest::Client::new() | ||
.post("https://api.openai.com/v1/chat/completions") | ||
.header("content-type", "application/json") | ||
.header( | ||
"authorization", | ||
&format!( | ||
"Bearer {}", | ||
env::var("OPENAI_API_KEY").map_err(|_| Error::EnvironmentError)? | ||
), | ||
) | ||
.body(serde_json::to_string(&body).map_err(|_| Error::SerializationError)?) | ||
.send() | ||
Server::bind(&addr) | ||
.serve(web::app().into_make_service()) | ||
.with_graceful_shutdown(signal::shutdown()) | ||
.await | ||
.map_err(|_| Error::RequestError)? | ||
.bytes_stream() | ||
.eventsource() | ||
.map(|r| match r { | ||
Ok(e) => { | ||
serde_json::from_str::<Event>(&e.data).map_err(|_| Error::DeserializationError) | ||
} | ||
_ => Err(Error::StreamError), | ||
}) | ||
// Discard errors (will most likely be `Error::JsonError`). | ||
.filter(|r| future::ready(r.is_ok())) | ||
.map_ok(|event| { | ||
let content = event | ||
.choices | ||
.into_iter() | ||
.next() | ||
.expect("event should have at least one choice") | ||
.delta | ||
.content; | ||
|
||
// Debug log. | ||
print!("{}", content); | ||
let _ = io::stdout().flush(); | ||
|
||
content | ||
}); | ||
|
||
Ok(( | ||
[(header::CONTENT_TYPE, "text/html")], | ||
StreamBody::new(stream), | ||
)) | ||
.expect("server should serve"); | ||
} |
Oops, something went wrong.