diff --git a/.github/workflows/build-push.yml b/.github/workflows/build-push.yml index 11e0ffd7..38cc9eda 100644 --- a/.github/workflows/build-push.yml +++ b/.github/workflows/build-push.yml @@ -34,25 +34,29 @@ jobs: uses: actions/checkout@v4 - name: Log in to the Container registry - uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1 + uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY_GH }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 - name: Extract metadata (tags, labels) for Docker id: meta - uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7 + uses: docker/metadata-action@v5 with: images: ${{ matrix.image }} - name: Build and push Docker images id: push - uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4 + uses: docker/build-push-action@v6 with: context: ${{ matrix.context }} file: ${{ matrix.dockerfile }} push: true + platforms: linux/amd64,linux/arm64 tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} diff --git a/README.md b/README.md index ca9aa4eb..c020d7a1 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ The easiest way to get started is with a generous free tier on our managed platf Start local version with docker compose. ```sh -git clone git@github.com:lmnr-ai/lmnr +git clone https://github.com/lmnr-ai/lmnr cd lmnr docker compose up ``` diff --git a/app-server/Cargo.lock b/app-server/Cargo.lock index 90dc168a..d099a486 100644 --- a/app-server/Cargo.lock +++ b/app-server/Cargo.lock @@ -476,6 +476,7 @@ dependencies = [ "serde-jsonlines", "serde_json", "serde_repr", + "sha3", "sqlx", "thiserror", "tiktoken-rs", @@ -3034,6 +3035,15 @@ dependencies = [ "libc", ] +[[package]] +name = "keccak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc2af9a1119c51f12a14607e783cb977bde58bc069ff0c3da1095e635d70654" +dependencies = [ + "cpufeatures", +] + [[package]] name = "language-tags" version = "0.3.2" @@ -4587,6 +4597,16 @@ dependencies = [ "digest", ] +[[package]] +name = "sha3" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60" +dependencies = [ + "digest", + "keccak", +] + [[package]] name = "sharded-slab" version = "0.1.7" diff --git a/app-server/Cargo.toml b/app-server/Cargo.toml index a6cd35a6..65def21e 100644 --- a/app-server/Cargo.toml +++ b/app-server/Cargo.toml @@ -64,6 +64,7 @@ time = "0.3.36" rustls = { version = "0.23.12", features = ["ring"] } serde_repr = "0.1.19" num_cpus = "1.16.0" +sha3 = "0.10.8" [build-dependencies] tonic-build = "0.12.3" diff --git a/app-server/src/api/utils.rs b/app-server/src/api/utils.rs index 9bfbda20..ca16595e 100644 --- a/app-server/src/api/utils.rs +++ b/app-server/src/api/utils.rs @@ -1,9 +1,11 @@ use std::sync::Arc; +use sqlx::PgPool; use uuid::Uuid; use crate::db::pipelines::PipelineVersion; use crate::pipeline::utils::get_target_pipeline_version_cache_key; +use crate::routes::api_keys::hash_api_key; use crate::routes::error; use crate::{ cache::Cache, @@ -37,3 +39,25 @@ pub async fn query_target_pipeline_version( } } } + +pub async fn get_api_key_from_raw_value( + pool: &PgPool, + cache: Arc, + raw_api_key: String, +) -> anyhow::Result { + let api_key_hash = hash_api_key(&raw_api_key); + let cache_res = cache + .get::(&api_key_hash) + .await; + match cache_res { + Ok(Some(api_key)) => Ok(api_key), + Ok(None) | Err(_) => { + let api_key = db::api_keys::get_api_key(pool, &api_key_hash).await?; + let _ = cache + .insert::(api_key_hash, &api_key) + .await; + + Ok(api_key) + } + } +} diff --git a/app-server/src/auth/mod.rs b/app-server/src/auth/mod.rs index 6115f3b4..501dd758 100644 --- a/app-server/src/auth/mod.rs +++ b/app-server/src/auth/mod.rs @@ -11,8 +11,9 @@ use actix_web::{FromRequest, HttpMessage, HttpRequest}; use actix_web_httpauth::extractors::bearer::{BearerAuth, Config}; use actix_web_httpauth::extractors::AuthenticationError; +use crate::api::utils::get_api_key_from_raw_value; use crate::cache::Cache; -use crate::db::api_keys::{get_api_key, ProjectApiKey}; +use crate::db::api_keys::ProjectApiKey; use crate::db::user::{get_user_from_api_key, User}; use crate::db::DB; @@ -96,7 +97,7 @@ pub async fn project_validator( .unwrap() .into_inner(); - match get_api_key(&db.pool, &credentials.token().to_string(), cache.clone()).await { + match get_api_key_from_raw_value(&db.pool, cache, credentials.token().to_string()).await { Ok(api_key) => { req.extensions_mut().insert(api_key); Ok(req) diff --git a/app-server/src/cache/cache.rs b/app-server/src/cache/cache.rs index e35b649d..cf81bca5 100644 --- a/app-server/src/cache/cache.rs +++ b/app-server/src/cache/cache.rs @@ -4,8 +4,6 @@ use std::result::Result; use std::sync::Arc; use async_trait::async_trait; -use sqlx::postgres::PgRow; -use sqlx::FromRow; #[derive(thiserror::Error, Debug)] pub enum CacheError { @@ -25,7 +23,7 @@ pub trait CacheTrait: Sync + Send { #[async_trait] impl CacheTrait for moka::future::Cache where - T: for<'a> FromRow<'a, PgRow> + 'static + Send + Sync + Clone, + T: 'static + Send + Sync + Clone, { async fn get(&self, key: &str) -> Option> { self.get(key) diff --git a/app-server/src/db/api_keys.rs b/app-server/src/db/api_keys.rs index e4fd1cf9..5b10970c 100644 --- a/app-server/src/db/api_keys.rs +++ b/app-server/src/db/api_keys.rs @@ -1,48 +1,57 @@ -use std::sync::Arc; - use anyhow::Result; -use serde::{Deserialize, Serialize}; +use serde::Serialize; use sqlx::{FromRow, PgPool}; use uuid::Uuid; -use crate::cache::Cache; - -#[derive(Debug, Clone, Deserialize, Serialize, FromRow)] -#[serde(rename_all = "camelCase")] +#[derive(Debug, Clone, FromRow)] pub struct ProjectApiKey { - pub value: String, pub project_id: Uuid, pub name: Option, + pub hash: String, + pub shorthand: String, } -pub async fn create_project_api_key( - db: &PgPool, - api_key: &ProjectApiKey, - cache: Arc, -) -> Result<()> { - sqlx::query("INSERT INTO project_api_keys (value, project_id, name) VALUES ($1, $2, $3);") - .bind(&api_key.value) - .bind(api_key.project_id) - .bind(&api_key.name) - .execute(db) - .await?; +#[derive(Serialize, FromRow)] +pub struct ProjectApiKeyResponse { + pub id: Uuid, + pub project_id: Uuid, + pub name: Option, + pub shorthand: String, +} - let _ = cache - .insert::(api_key.value.clone(), api_key) - .await; +pub async fn create_project_api_key( + pool: &PgPool, + project_id: &Uuid, + name: &Option, + hash: &String, + shorthand: &String, +) -> Result { + let key_info = sqlx::query_as::<_, ProjectApiKey>( + "INSERT + INTO project_api_keys (shorthand, project_id, name, hash) + VALUES ($1, $2, $3, $4) + RETURNING id, project_id, name, hash, shorthand", + ) + .bind(&shorthand) + .bind(&project_id) + .bind(&name) + .bind(&hash) + .fetch_one(pool) + .await?; - Ok(()) + Ok(key_info) } pub async fn get_api_keys_for_project( db: &PgPool, project_id: &Uuid, -) -> Result> { - let api_keys = sqlx::query_as::<_, ProjectApiKey>( +) -> Result> { + let api_keys = sqlx::query_as::<_, ProjectApiKeyResponse>( "SELECT - project_api_keys.value, project_api_keys.project_id, - project_api_keys.name + project_api_keys.name, + project_api_keys.id, + project_api_keys.shorthand FROM project_api_keys WHERE @@ -55,50 +64,47 @@ pub async fn get_api_keys_for_project( Ok(api_keys) } -pub async fn get_api_key( - db: &PgPool, - api_key: &String, - cache: Arc, -) -> Result { - let cache_res = cache.get::(api_key).await; - match cache_res { - Ok(Some(api_key)) => return Ok(api_key), - Ok(None) => {} - Err(e) => log::error!("Error getting project API key from cache: {}", e), - } - +pub async fn get_api_key(pool: &PgPool, hash: &String) -> Result { let api_key = match sqlx::query_as::<_, ProjectApiKey>( "SELECT - project_api_keys.value, + project_api_keys.hash, project_api_keys.project_id, - project_api_keys.name + project_api_keys.name, + project_api_keys.id, + project_api_keys.shorthand FROM project_api_keys WHERE - project_api_keys.value = $1", + project_api_keys.hash = $1", ) - .bind(api_key) - .fetch_optional(db) + .bind(hash) + .fetch_optional(pool) .await { Ok(None) => Err(anyhow::anyhow!("invalid project API key")), - Ok(Some(api_key_meta)) => { - let _ = cache - .insert::(api_key.clone(), &api_key_meta) - .await; - Ok(api_key_meta) - } + Ok(Some(api_key)) => Ok(api_key), Err(e) => Err(e.into()), }?; Ok(api_key) } -pub async fn delete_api_key(pool: &PgPool, api_key: &String, project_id: &Uuid) -> Result<()> { - sqlx::query("DELETE FROM project_api_keys WHERE value = $1 AND project_id = $2") - .bind(api_key) - .bind(project_id) - .execute(pool) - .await?; - Ok(()) +#[derive(FromRow)] +struct ProjectApiKeyHash { + hash: String, +} + +pub async fn delete_api_key(pool: &PgPool, id: &Uuid, project_id: &Uuid) -> Result { + let row = sqlx::query_as::<_, ProjectApiKeyHash>( + "DELETE + FROM project_api_keys + WHERE id = $1 AND project_id = $2 + RETURNING hash", + ) + .bind(id) + .bind(project_id) + .fetch_one(pool) + .await?; + + Ok(row.hash) } diff --git a/app-server/src/db/trace.rs b/app-server/src/db/trace.rs index 858b4ff3..2797725b 100644 --- a/app-server/src/db/trace.rs +++ b/app-server/src/db/trace.rs @@ -246,36 +246,32 @@ fn add_traces_info_expression( Ok(()) } -fn add_matching_spans_query( +fn add_text_join( query: &mut QueryBuilder, date_range: &Option, - text_search_filter: Option, + text_search_filter: &String, ) -> Result<()> { query.push( " - matching_spans_trace_ids AS ( + JOIN ( SELECT DISTINCT trace_id - FROM spans - WHERE 1=1 - ", + FROM spans + WHERE ", ); + query + .push("(input::TEXT ILIKE ") + .push_bind(format!("%{text_search_filter}%")) + .push(" OR output::TEXT ILIKE ") + .push_bind(format!("%{text_search_filter}%")) + .push(" OR name::TEXT ILIKE ") + .push_bind(format!("%{text_search_filter}%")) + .push(" OR attributes::TEXT ILIKE ") + .push_bind(format!("%{text_search_filter}%")) + .push(")"); add_date_range_to_query(query, date_range, "start_time", Some("end_time"))?; - if let Some(text_search_filter) = text_search_filter { - query - .push(" AND (input::TEXT ILIKE ") - .push_bind(format!("%{text_search_filter}%")) - .push(" OR output::TEXT ILIKE ") - .push_bind(format!("%{text_search_filter}%")) - .push(" OR name::TEXT ILIKE ") - .push_bind(format!("%{text_search_filter}%")) - .push(" OR attributes::TEXT ILIKE ") - .push_bind(format!("%{text_search_filter}%")) - .push(")"); - }; - - query.push(")"); + query.push(") matching_spans ON traces_info.id = matching_spans.trace_id"); Ok(()) } @@ -417,8 +413,6 @@ pub async fn get_traces( let mut query = QueryBuilder::::new("WITH "); add_traces_info_expression(&mut query, date_range, project_id)?; query.push(", "); - add_matching_spans_query(&mut query, date_range, text_search_filter)?; - query.push(", "); query.push(TRACE_EVENTS_EXPRESSION); query.push( @@ -447,10 +441,12 @@ pub async fn get_traces( parent_span_type, status FROM traces_info - JOIN matching_spans_trace_ids ON traces_info.id = matching_spans_trace_ids.trace_id - LEFT JOIN trace_events ON trace_events.trace_id = traces_info.id - WHERE project_id = ", + LEFT JOIN trace_events ON trace_events.trace_id = traces_info.id ", ); + if let Some(search) = text_search_filter { + add_text_join(&mut query, date_range, &search)?; + } + query.push(" WHERE project_id = "); query.push_bind(project_id); add_filters_to_traces_query(&mut query, &filters); @@ -480,18 +476,19 @@ pub async fn count_traces( let mut query = QueryBuilder::::new("WITH "); add_traces_info_expression(&mut query, date_range, project_id)?; query.push(", "); - add_matching_spans_query(&mut query, date_range, text_search_filter)?; - query.push(", "); query.push(TRACE_EVENTS_EXPRESSION); query.push( " SELECT COUNT(DISTINCT(id)) as total_count FROM traces_info - JOIN matching_spans_trace_ids ON traces_info.id = matching_spans_trace_ids.trace_id LEFT JOIN trace_events ON trace_events.trace_id = traces_info.id - WHERE project_id = ", + ", ); + if let Some(search) = text_search_filter { + add_text_join(&mut query, date_range, &search)?; + } + query.push(" WHERE project_id = "); query.push_bind(project_id); add_filters_to_traces_query(&mut query, &filters); @@ -555,10 +552,14 @@ pub async fn get_single_trace(pool: &PgPool, id: Uuid) -> Result { #[serde(rename_all = "camelCase")] pub struct Session { pub id: String, + pub input_token_count: i64, + pub output_token_count: i64, pub total_token_count: i64, pub start_time: DateTime, pub end_time: DateTime, pub duration: f64, + pub input_cost: f64, + pub output_cost: f64, pub cost: f64, pub trace_count: i64, } @@ -575,10 +576,14 @@ pub async fn get_sessions( "SELECT session_id as id, count(id)::int8 as trace_count, + sum(input_token_count)::int8 as input_token_count, + sum(output_token_count)::int8 as output_token_count, sum(total_token_count)::int8 as total_token_count, min(start_time) as start_time, max(end_time) as end_time, sum(extract(epoch from (end_time - start_time)))::float8 as duration, + sum(input_cost)::float8 as input_cost, + sum(output_cost)::float8 as output_cost, sum(cost)::float8 as cost FROM traces WHERE session_id is not null and project_id = ", diff --git a/app-server/src/routes/api_keys.rs b/app-server/src/routes/api_keys.rs index c365e13b..4e2c04a8 100644 --- a/app-server/src/routes/api_keys.rs +++ b/app-server/src/routes/api_keys.rs @@ -1,15 +1,26 @@ use crate::db::{self, api_keys::ProjectApiKey, utils::generate_random_key, DB}; use actix_web::{delete, get, post, web, HttpResponse}; +use serde::{Deserialize, Serialize}; +use sha3::{Digest, Sha3_256}; use uuid::Uuid; use super::ResponseResult; use crate::cache::Cache; -#[derive(serde::Deserialize)] +#[derive(Deserialize)] struct CreateProjectApiKeyRequest { name: Option, } +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct CreateProjectApiKeyResponse { + value: String, + project_id: Uuid, + name: Option, + shorthand: String, +} + #[post("api-keys")] async fn create_project_api_key( project_id: web::Path, @@ -18,17 +29,27 @@ async fn create_project_api_key( cache: web::Data, ) -> ResponseResult { let req = req.into_inner(); + let project_id = project_id.into_inner(); - // TODO: value should be a hash of genereted key - let api_key = ProjectApiKey { - value: generate_random_key(), - project_id: project_id.into_inner(), - name: req.name, - }; + let value = generate_random_key(); + let shorthand = format!("{}...{}", &value[..4], &value[value.len() - 4..]); + + let hash = hash_api_key(&value); - db::api_keys::create_project_api_key(&db.pool, &api_key, cache.into_inner()).await?; + let key = + db::api_keys::create_project_api_key(&db.pool, &project_id, &req.name, &hash, &shorthand) + .await?; - Ok(HttpResponse::Ok().json(api_key)) + let _ = cache.insert::(key.hash.clone(), &key).await; + + let response = CreateProjectApiKeyResponse { + value, + project_id, + name: key.name, + shorthand: key.shorthand, + }; + + Ok(HttpResponse::Ok().json(response)) } #[get("api-keys")] @@ -44,7 +65,7 @@ async fn get_api_keys_for_project( #[derive(serde::Deserialize)] #[serde(rename_all = "camelCase")] struct DeleteProjectApiKeyRequest { - api_key: String, + id: Uuid, } #[delete("api-keys")] @@ -55,9 +76,16 @@ async fn revoke_project_api_key( cache: web::Data, ) -> ResponseResult { let req = req.into_inner(); - let _ = cache.remove::(&req.api_key).await; - db::api_keys::delete_api_key(&db.pool, &req.api_key, &project_id).await?; + let hash = db::api_keys::delete_api_key(&db.pool, &req.id, &project_id).await?; + + let _ = cache.remove::(&hash).await; Ok(HttpResponse::Ok().finish()) } + +pub fn hash_api_key(api_key: &str) -> String { + let mut hasher = Sha3_256::new(); + hasher.update(api_key.as_bytes()); + format!("{:x}", hasher.finalize()) +} diff --git a/app-server/src/traces/grpc_service.rs b/app-server/src/traces/grpc_service.rs index 9e26dcf9..717945f6 100644 --- a/app-server/src/traces/grpc_service.rs +++ b/app-server/src/traces/grpc_service.rs @@ -4,11 +4,9 @@ use lapin::Connection; use sqlx::PgPool; use crate::{ + api::utils::get_api_key_from_raw_value, cache::Cache, - db::{ - api_keys::{get_api_key, ProjectApiKey}, - DB, - }, + db::{api_keys::ProjectApiKey, DB}, opentelemetry::opentelemetry::proto::collector::trace::v1::{ trace_service_server::TraceService, ExportTraceServiceRequest, ExportTraceServiceResponse, }, @@ -58,11 +56,11 @@ impl TraceService for ProcessTracesService { async fn authenticate_request( metadata: &tonic::metadata::MetadataMap, - db: &PgPool, + pool: &PgPool, cache: Arc, ) -> anyhow::Result { let token = extract_bearer_token(metadata)?; - get_api_key(db, &token, cache).await + get_api_key_from_raw_value(pool, cache, token).await } fn extract_bearer_token(metadata: &tonic::metadata::MetadataMap) -> anyhow::Result { diff --git a/app-server/src/traces/span_attributes.rs b/app-server/src/traces/span_attributes.rs index 1fb9c265..d8c913be 100644 --- a/app-server/src/traces/span_attributes.rs +++ b/app-server/src/traces/span_attributes.rs @@ -10,7 +10,7 @@ pub const GEN_AI_OUTPUT_TOKENS: &str = "gen_ai.usage.output_tokens"; pub const GEN_AI_PROMPT_TOKENS: &str = "gen_ai.usage.prompt_tokens"; pub const GEN_AI_COMPLETION_TOKENS: &str = "gen_ai.usage.completion_tokens"; -// pub const GEN_AI_TOTAL_TOKENS: &str = "gen_ai.usage.total_tokens"; +pub const GEN_AI_TOTAL_TOKENS: &str = "llm.usage.total_tokens"; pub const GEN_AI_REQUEST_MODEL: &str = "gen_ai.request.model"; pub const GEN_AI_RESPONSE_MODEL: &str = "gen_ai.response.model"; // pub const GEN_AI_REQUEST_IS_STREAM: &str = "gen_ai.request.is_stream"; diff --git a/app-server/src/traces/spans.rs b/app-server/src/traces/spans.rs index deb931d6..dfe26755 100644 --- a/app-server/src/traces/spans.rs +++ b/app-server/src/traces/spans.rs @@ -20,7 +20,7 @@ use super::span_attributes::{ ASSOCIATION_PROPERTIES_PREFIX, GEN_AI_COMPLETION_TOKENS, GEN_AI_INPUT_COST, GEN_AI_INPUT_TOKENS, GEN_AI_OUTPUT_COST, GEN_AI_OUTPUT_TOKENS, GEN_AI_PROMPT_TOKENS, GEN_AI_REQUEST_MODEL, GEN_AI_RESPONSE_MODEL, GEN_AI_SYSTEM, GEN_AI_TOTAL_COST, - LLM_NODE_RENDERED_PROMPT, SPAN_PATH, SPAN_TYPE, + GEN_AI_TOTAL_TOKENS, LLM_NODE_RENDERED_PROMPT, SPAN_PATH, SPAN_TYPE, }; const INPUT_ATTRIBUTE_NAME: &str = "lmnr.span.input"; @@ -432,7 +432,7 @@ impl Span { trace_id, parent_span_id: Some(parent_span_id), name: message.node_name.clone(), - attributes: span_attributes_from_data(message.meta_log.clone(), span_path), + attributes: span_attributes_from_meta_log(message.meta_log.clone(), span_path), input: Some(serde_json::to_value(input_values).unwrap()), output: Some(message.value.clone().into()), span_type: match message.node_type.as_str() { @@ -448,7 +448,7 @@ impl Span { } } -fn span_attributes_from_data(meta_log: Option, span_path: String) -> Value { +fn span_attributes_from_meta_log(meta_log: Option, span_path: String) -> Value { let mut attributes = HashMap::new(); if let Some(MetaLog::LLM(llm_log)) = meta_log { @@ -460,6 +460,10 @@ fn span_attributes_from_data(meta_log: Option, span_path: String) -> Va GEN_AI_OUTPUT_TOKENS.to_string(), json!(llm_log.output_token_count), ); + attributes.insert( + GEN_AI_TOTAL_TOKENS.to_string(), + json!(llm_log.total_token_count), + ); attributes.insert(GEN_AI_RESPONSE_MODEL.to_string(), json!(llm_log.model)); attributes.insert(GEN_AI_SYSTEM.to_string(), json!(llm_log.provider)); attributes.insert( diff --git a/docker-compose.yml b/docker-compose.yml index b5728d19..8e6aacb8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -61,6 +61,7 @@ services: - QDRANT_URL=http://qdrant:6334 - COHERE_ENDPOINT=https://api.cohere.ai/v1/embed - COHERE_API_KEY=${COHERE_API_KEY} + pull_policy: always postgres: build: @@ -71,7 +72,7 @@ services: POSTGRES_DB: ${POSTGRES_DB} container_name: postgres ports: - - "5432:5432" + - "5433:5433" volumes: - type: volume source: postgres-data @@ -91,6 +92,7 @@ services: condition: service_healthy clickhouse: condition: service_started + pull_policy: always environment: - PORT=8000 - GRPC_PORT=8001 @@ -106,6 +108,7 @@ services: ports: - "3000:3000" env_file: ./frontend/.env.local.example + pull_policy: always environment: - PORT=3000 - BACKEND_URL=http://app-server:8000 diff --git a/frontend/app/sign-in/page.tsx b/frontend/app/sign-in/page.tsx index 5a7f8fc9..aba10b3e 100644 --- a/frontend/app/sign-in/page.tsx +++ b/frontend/app/sign-in/page.tsx @@ -3,6 +3,8 @@ import logo from '@/assets/logo/laminar_light.svg'; import Image from 'next/image'; import { redirect } from 'next/navigation'; import { DefaultSignInButton } from '@/components/sign-in/dummy-signin'; +import { GoogleSignInButton } from '@/components/sign-in/google-signin'; +import { GitHubSignInButton } from '@/components/sign-in/github-signin'; export default async function SignInPage({ params, @@ -26,6 +28,18 @@ export default async function SignInPage({

Start building next-gen AI apps now.

+ {process.env.AUTH_GITHUB_ID && process.env.AUTH_GITHUB_SECRET && + <> + or + + + } + {process.env.AUTH_GOOGLE_ID && process.env.AUTH_GOOGLE_SECRET && + <> + or + + + } ); diff --git a/frontend/app/workspace/[workspaceId]/page.tsx b/frontend/app/workspace/[workspaceId]/page.tsx index b94822fa..4a405c79 100644 --- a/frontend/app/workspace/[workspaceId]/page.tsx +++ b/frontend/app/workspace/[workspaceId]/page.tsx @@ -41,7 +41,12 @@ export default async function WorkspacePage( return ( - +
diff --git a/frontend/components/settings/project-api-keys.tsx b/frontend/components/settings/project-api-keys.tsx index 1eda908c..4a581b2f 100644 --- a/frontend/components/settings/project-api-keys.tsx +++ b/frontend/components/settings/project-api-keys.tsx @@ -1,5 +1,5 @@ -import { ProjectApiKey } from '@/lib/api-keys/types'; -import { Button } from '../ui/button'; +import { GenerateProjectApiKeyResponse, ProjectApiKey } from "@/lib/api-keys/types"; +import { Button } from "../ui/button"; import { Dialog, DialogContent, @@ -8,14 +8,13 @@ import { DialogTitle, DialogTrigger } from '@/components/ui/dialog'; -import { Copy, Plus } from 'lucide-react'; -import { Label } from '../ui/label'; -import { Input } from '../ui/input'; -import { useCallback, useState } from 'react'; -import { useProjectContext } from '@/contexts/project-context'; -import { useToast } from '@/lib/hooks/use-toast'; -import DeleteProject from './delete-project'; -import RevokeDialog from './revoke-dialog'; +import { Copy, Plus } from "lucide-react"; +import { Label } from "../ui/label"; +import { Input } from "../ui/input"; +import { useCallback, useState } from "react"; +import { useProjectContext } from "@/contexts/project-context"; +import { useToast } from "@/lib/hooks/use-toast"; +import RevokeDialog from "./revoke-dialog"; interface ApiKeysProps { apiKeys: ProjectApiKey[] @@ -26,23 +25,24 @@ export default function ProjectApiKeys({ apiKeys }: ApiKeysProps) { const [isGenerateKeyDialogOpen, setIsGenerateKeyDialogOpen] = useState(false); const [projectApiKeys, setProjectApiKeys] = useState(apiKeys); const [newApiKeyName, setNewApiKeyName] = useState(''); + const [newApiKey, setNewApiKey] = useState(null); + const [isGenerated, setIsGenerated] = useState(false); const { projectId } = useProjectContext(); - const { toast } = useToast(); const generateNewAPIKey = useCallback(async (newName: string) => { const res = await fetch(`/api/projects/${projectId}/api-keys`, { method: 'POST', body: JSON.stringify({ name: newName }) }); - await res.json(); + const newKey = await res.json() as GenerateProjectApiKeyResponse; - getProjectApiKeys(); + setNewApiKey(newKey); }, []); - const deleteApiKey = useCallback(async (apiKeyVal: string) => { + const deleteApiKey = useCallback(async (id: string) => { const res = await fetch(`/api/projects/${projectId}/api-keys`, { method: 'DELETE', - body: JSON.stringify({ apiKey: apiKeyVal }) + body: JSON.stringify({ id: id }) }); await res.text(); @@ -68,6 +68,8 @@ export default function ProjectApiKeys({ apiKeys }: ApiKeysProps) { { setIsGenerateKeyDialogOpen(!isGenerateKeyDialogOpen); setNewApiKeyName(''); + setNewApiKey(null); + setIsGenerated(false); }}> - + isGenerated && newApiKey && e.preventDefault()} + > - Generate API key + {isGenerated && newApiKey ? 'API key generated' : 'Generate API key'} -
- - setNewApiKeyName(e.target.value)} - /> -
- - - + { + isGenerated && newApiKey + ? { setIsGenerateKeyDialogOpen(false); getProjectApiKeys();}} + /> + : { + generateNewAPIKey(newApiKeyName); + setIsGenerated(true); + }} + onNameChange={(name) => setNewApiKeyName(name)} + /> + }
@@ -106,23 +108,12 @@ export default function ProjectApiKeys({ apiKeys }: ApiKeysProps) { @@ -134,3 +125,77 @@ export default function ProjectApiKeys({ apiKeys }: ApiKeysProps) { ); } + +function GenerateKeyDialogContent({ + onClick, + onNameChange, +}: { + onClick: () => void + onNameChange: (name: string) => void +}) { + return ( + <> +
+ + onNameChange(e.target.value)} + /> +
+ + + + + ); +} + +function DisplayKeyDialogContent({ + apiKey, + onClose +}: { + apiKey: GenerateProjectApiKeyResponse + onClose?: () => void +}) { + const { toast } = useToast(); + return ( + <> +
+

For security reasons, you will not be able to see this key again. Make sure to copy and save it somewhere safe.

+
+ + +
+
+ + + + + ); +} diff --git a/frontend/components/settings/revoke-dialog.tsx b/frontend/components/settings/revoke-dialog.tsx index ab0bf47c..14adb3b8 100644 --- a/frontend/components/settings/revoke-dialog.tsx +++ b/frontend/components/settings/revoke-dialog.tsx @@ -1,18 +1,18 @@ -import { ProjectApiKey } from '@/lib/api-keys/types'; -import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '../ui/dialog'; -import { useState } from 'react'; -import { Button } from '../ui/button'; -import { Loader, Trash2 } from 'lucide-react'; -import { Label } from '../ui/label'; -import { cn } from '@/lib/utils'; +import { ProjectApiKey } from "@/lib/api-keys/types"; +import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "../ui/dialog"; +import { useState } from "react"; +import { Button } from "../ui/button"; +import { Loader, Trash2 } from "lucide-react"; +import { Label } from "../ui/label"; +import { cn } from "@/lib/utils"; interface RevokeApiKeyDialogProps { - obj: { name?: string, value: string }; + apiKey: ProjectApiKey; entity: string; - onRevoke: (value: string) => Promise; + onRevoke: (id: string) => Promise; } -export default function RevokeDialog({ obj, onRevoke, entity }: RevokeApiKeyDialogProps) { +export default function RevokeDialog({ apiKey, onRevoke, entity }: RevokeApiKeyDialogProps) { const [isOpen, setIsOpen] = useState(false); const [isLoading, setIsLoading] = useState(false); return ( @@ -26,11 +26,11 @@ export default function RevokeDialog({ obj, onRevoke, entity }: RevokeApiKeyDial Revoke {entity} - + ); } diff --git a/frontend/components/sign-in/github-signin.tsx b/frontend/components/sign-in/github-signin.tsx new file mode 100644 index 00000000..3dc7a97d --- /dev/null +++ b/frontend/components/sign-in/github-signin.tsx @@ -0,0 +1,50 @@ +'use client'; + +import * as React from 'react'; +import { signIn } from 'next-auth/react'; + +import { cn } from '@/lib/utils'; +import { Button, type ButtonProps } from '@/components/ui/button'; +import { IconGitHub, IconSpinner } from '@/components/ui/icons'; + +interface GitHubSignInButtonProps extends ButtonProps { + showGithubIcon?: boolean + text?: string + callbackUrl: string +} + +export function GitHubSignInButton({ + text = 'Continue with GitHub', + callbackUrl, + showGithubIcon = true, + className, + ...props +}: GitHubSignInButtonProps) { + const [isLoading, setIsLoading] = React.useState(false); + return ( + + ); +} diff --git a/frontend/components/sign-in/google-signin.tsx b/frontend/components/sign-in/google-signin.tsx new file mode 100644 index 00000000..49cf3cc8 --- /dev/null +++ b/frontend/components/sign-in/google-signin.tsx @@ -0,0 +1,50 @@ +'use client'; + +import * as React from 'react'; +import { signIn } from 'next-auth/react'; + +import { cn } from '@/lib/utils'; +import { Button, type ButtonProps } from '@/components/ui/button'; +import { IconSpinner } from '@/components/ui/icons'; +import google from '@/assets/logo/google.svg'; +import Image from 'next/image'; + +interface GoogleSignInButtonProps extends ButtonProps { + showIcon?: boolean + text?: string + callbackUrl: string +} + +export function GoogleSignInButton({ + text = 'Continue with Google', + callbackUrl, + className, + ...props +}: GoogleSignInButtonProps) { + const [isLoading, setIsLoading] = React.useState(false); + return ( + + ); +} diff --git a/frontend/components/traces/traces-table-sessions-view.tsx b/frontend/components/traces/traces-table-sessions-view.tsx index 85dd388d..08fdbc1f 100644 --- a/frontend/components/traces/traces-table-sessions-view.tsx +++ b/frontend/components/traces/traces-table-sessions-view.tsx @@ -141,14 +141,34 @@ export default function SessionsTable({ onRowClick }: SessionsTableProps) { }, header: 'Duration', }, + { + accessorFn: (row) => "$" + row.data.inputCost?.toFixed(5), + header: 'Input cost', + id: 'input_cost' + }, + { + accessorFn: (row) => "$" + row.data.outputCost?.toFixed(5), + header: 'Output cost', + id: 'output_cost' + }, { accessorFn: (row) => '$' + row.data.cost?.toFixed(5), header: 'Cost', id: 'cost' }, + { + accessorFn: (row) => row.data.inputTokenCount, + header: 'Input token count', + id: 'input_token_count' + }, + { + accessorFn: (row) => row.data.outputTokenCount, + header: 'Output token count', + id: 'output_token_count' + }, { accessorFn: (row) => row.data.totalTokenCount, - header: 'Token Count', + header: 'Token count', id: 'total_token_count' }, { diff --git a/frontend/components/workspace/workspace-header.tsx b/frontend/components/workspace/workspace-header.tsx deleted file mode 100644 index 82f06e1c..00000000 --- a/frontend/components/workspace/workspace-header.tsx +++ /dev/null @@ -1,15 +0,0 @@ -interface workspaceHeaderProps { - workspaceName: string -} - -export default function WorkspaceHeader({ workspaceName }: workspaceHeaderProps) { - return ( -
-
- workspaces -
/
-
{workspaceName}
-
-
- ); -} diff --git a/frontend/components/workspace/workspace.tsx b/frontend/components/workspace/workspace.tsx index 0caab8b4..1aee66d5 100644 --- a/frontend/components/workspace/workspace.tsx +++ b/frontend/components/workspace/workspace.tsx @@ -1,7 +1,8 @@ 'use client'; import { WorkspaceWithUsers } from '@/lib/workspaces/types'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs'; +import Link from 'next/link'; +import { Label } from '../ui/label'; interface WorkspaceProps { workspace: WorkspaceWithUsers; @@ -14,7 +15,11 @@ export default function WorkspaceComponent({ }: WorkspaceProps) { return (
- {workspace.name} +
); } diff --git a/frontend/lib/api-keys/types.ts b/frontend/lib/api-keys/types.ts index e54606c3..c04ac790 100644 --- a/frontend/lib/api-keys/types.ts +++ b/frontend/lib/api-keys/types.ts @@ -1,5 +1,13 @@ export type ProjectApiKey = { - value: string - projectId: string - name?: string + shorthand: string + projectId: string + name?: string + id: string +} + +export type GenerateProjectApiKeyResponse = { + value: string + name?: string + projectId: string + shorthand: string } diff --git a/frontend/lib/auth.ts b/frontend/lib/auth.ts index 103f8895..3378274d 100644 --- a/frontend/lib/auth.ts +++ b/frontend/lib/auth.ts @@ -1,5 +1,7 @@ import type { DefaultSession, NextAuthOptions, User } from 'next-auth'; import CredentialsProvider from 'next-auth/providers/credentials'; +import GithubProvider from 'next-auth/providers/github'; +import GoogleProvider from 'next-auth/providers/google'; import { fetcher } from './utils'; import jwt from 'jsonwebtoken'; @@ -25,24 +27,43 @@ declare module 'next-auth/jwt' { } } -export const authOptions: NextAuthOptions = { - providers: [ - CredentialsProvider({ - id: 'email', - name: 'Email', - credentials: { - email: { label: 'Email', type: 'email', placeholder: 'username@example.com' }, - name: { label: 'Name', type: 'text', placeholder: 'username' } - }, - async authorize(credentials, req) { - if (!credentials?.email) { - return null; - } - const user = { id: credentials.email, name: credentials.name, email: credentials.email } as User; - return user; +let providers = []; + +if (process.env.AUTH_GITHUB_ID && process.env.AUTH_GITHUB_SECRET) { + providers.push(GithubProvider({ + clientId: process.env.AUTH_GITHUB_ID!, + clientSecret: process.env.AUTH_GITHUB_SECRET! + })); +} + +if (process.env.AUTH_GOOGLE_ID && process.env.AUTH_GOOGLE_SECRET) { + providers.push(GoogleProvider({ + clientId: process.env.AUTH_GOOGLE_ID, + clientSecret: process.env.AUTH_GOOGLE_SECRET + })); +} + +// this is pushed always, but TypeScript complains if it is added to array at initialization +providers.push( + CredentialsProvider({ + id: 'email', + name: 'Email', + credentials: { + email: { label: 'Email', type: 'email', placeholder: 'username@example.com' }, + name: { label: 'Name', type: 'text', placeholder: 'username' } + }, + async authorize(credentials, req) { + if (!credentials?.email) { + return null; } - }), - ], + const user = { id: credentials.email, name: credentials.name, email: credentials.email } as User; + return user; + } + }), +); + +export const authOptions: NextAuthOptions = { + providers, session: { strategy: 'jwt' }, diff --git a/frontend/lib/traces/types.ts b/frontend/lib/traces/types.ts index ed9b88ed..f99d20cd 100644 --- a/frontend/lib/traces/types.ts +++ b/frontend/lib/traces/types.ts @@ -1,5 +1,5 @@ -import { Event } from '../events/types'; -import { GraphMessagePreview } from '../pipeline/types'; +import { Event } from "../events/types"; +import { GraphMessagePreview } from "../pipeline/types"; export type TraceMessages = { [key: string]: GraphMessagePreview } @@ -33,11 +33,11 @@ export type SpanLabel = { } export enum SpanType { - DEFAULT = 'DEFAULT', - LLM = 'LLM', - EXECUTOR = 'EXECUTOR', - EVALUATOR = 'EVALUATOR', - EVALUATION = 'EVALUATION', + DEFAULT = "DEFAULT", + LLM = "LLM", + EXECUTOR = "EXECUTOR", + EVALUATOR = "EVALUATOR", + EVALUATION = "EVALUATION", } export type Span = { @@ -125,10 +125,14 @@ export type TraceMetricDatapoint = { export type SessionPreview = { id: string; traceCount: number; + inputCost: number; + outputCost: number; cost: number; startTime: string; endTime: string; duration: number; + inputTokenCount: number; + outputTokenCount: number; totalTokenCount: number; } diff --git a/postgres/supabase/migrations/20241014200050_remote_schema.sql b/postgres/supabase/migrations/20241014200050_remote_schema.sql new file mode 100644 index 00000000..c3c7d001 --- /dev/null +++ b/postgres/supabase/migrations/20241014200050_remote_schema.sql @@ -0,0 +1,23 @@ +alter table "public"."labels" drop constraint "labels_class_id_fkey"; + +alter table "public"."projects" drop constraint "projects_workspace_id_fkey"; + +alter table "public"."labels" drop constraint "labels_span_id_class_id_user_id_key"; + +alter table "public"."project_api_keys" drop constraint "project_api_keys_pkey"; + +drop index if exists "public"."project_api_keys_pkey"; + +alter table "public"."project_api_keys" add column "hash" text not null default ''::text; + +alter table "public"."project_api_keys" add column "id" uuid not null default gen_random_uuid(); + +alter table "public"."project_api_keys" add column "shorthand" text not null default ''::text; + +alter table "public"."project_api_keys" alter column "value" set default ''::text; + +CREATE UNIQUE INDEX project_api_keys_pkey ON public.project_api_keys USING btree (id); + +alter table "public"."project_api_keys" add constraint "project_api_keys_pkey" PRIMARY KEY using index "project_api_keys_pkey"; + +ALTER TABLE "public"."workspaces" ALTER COLUMN "tier_id" SET DEFAULT 0::bigint;
{apiKey.name} -
{apiKey.value.slice(0, 4)} ... {apiKey.value.slice(-4)}
+
{apiKey.shorthand}
- - + +