diff --git a/backend/migrations/20230718090041_add_openai_resource_path.down.sql b/backend/migrations/20230718090041_add_openai_resource_path.down.sql new file mode 100644 index 0000000000..a824ccd976 --- /dev/null +++ b/backend/migrations/20230718090041_add_openai_resource_path.down.sql @@ -0,0 +1,3 @@ +-- Add down migration script here +ALTER TABLE workspace_settings DROP COLUMN openai_resource_path; +ALTER TABLE workspace_settings ADD COLUMN openai_key VARCHAR(255); \ No newline at end of file diff --git a/backend/migrations/20230718090041_add_openai_resource_path.up.sql b/backend/migrations/20230718090041_add_openai_resource_path.up.sql new file mode 100644 index 0000000000..aad46026ef --- /dev/null +++ b/backend/migrations/20230718090041_add_openai_resource_path.up.sql @@ -0,0 +1,3 @@ +-- Add up migration script here +ALTER TABLE workspace_settings ADD COLUMN openai_resource_path VARCHAR(1000); +ALTER TABLE workspace_settings DROP COLUMN openai_key; \ No newline at end of file diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index d148aad582..ee605013af 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -971,7 +971,7 @@ paths: type: string deploy_to: type: string - openai_key: + openai_resource_path: type: string error_handler: type: string @@ -1126,24 +1126,24 @@ paths: text/plain: schema: type: string - - /w/{workspace}/workspaces/edit_openai_key: + + /w/{workspace}/workspaces/edit_openai_resource_path: post: - summary: edit OpenAI key - operationId: editOpenaiKey + summary: edit OpenAI resource path + operationId: editOpenaiResourcePath tags: - workspace parameters: - $ref: "#/components/parameters/WorkspaceId" requestBody: - description: WorkspaceOpenAIKey + description: WorkspaceOpenaiResourcePath required: true content: application/json: schema: type: object properties: - openai_key: + openai_resource_path: type: string responses: "200": @@ -1153,10 +1153,10 @@ paths: schema: type: string - /w/{workspace}/workspaces/exists_openai_key: + /w/{workspace}/workspaces/exists_openai_resource_path: get: - summary: OpenAI key exists - operationId: existsOpenaiKey + summary: OpenAI resource path exists + operationId: existsOpenaiResourcePath tags: - workspace parameters: diff --git a/backend/windmill-api/src/openai.rs b/backend/windmill-api/src/openai.rs index 4e4bbc917d..3bc9b4b7ce 100644 --- a/backend/windmill-api/src/openai.rs +++ b/backend/windmill-api/src/openai.rs @@ -1,4 +1,4 @@ -use crate::{db::DB, users::Authed, HTTP_CLIENT}; +use crate::{db::DB, users::Authed, variables::build_crypt, HTTP_CLIENT}; use axum::{ body::{Bytes, StreamBody}, @@ -8,19 +8,23 @@ use axum::{ routing::post, Router, }; +use magic_crypt::MagicCryptTrait; use windmill_audit::{audit_log, ActionKind}; use windmill_common::error::{to_anyhow, Error}; +use serde::Deserialize; + pub fn workspaced_service() -> Router { let router = Router::new().route("/proxy/*openai_path", post(proxy)); router } -struct OpenAIKey { - openai_key: Option, +#[derive(Deserialize)] +struct OpenaiResource { + api_key: String, + organisation: Option, } - async fn proxy( authed: Authed, Extension(db): Extension, @@ -28,35 +32,80 @@ async fn proxy( body: Bytes, ) -> impl IntoResponse { let mut tx = db.begin().await?; - let settings = sqlx::query_as!( - OpenAIKey, - "SELECT openai_key FROM workspace_settings WHERE workspace_id = $1", + let openai_resource_path = sqlx::query_scalar!( + "SELECT openai_resource_path FROM workspace_settings WHERE workspace_id = $1", &w_id ) .fetch_one(&mut *tx) - .await - .map_err(|e| Error::InternalErr(format!("getting openai_key: {e}")))?; + .await?; tx.commit().await?; - let openai_key = match settings.openai_key { - Some(key) => key, - None => { - return Err(Error::BadRequest( - "openai_key is not set for this workspace".to_string(), - )) - } + if openai_resource_path.is_none() { + return Err(Error::InternalErr( + "OpenAI resource not configured".to_string(), + )); + } + + let openai_resource_path = openai_resource_path.unwrap(); + + tx = db.begin().await?; + let resource = sqlx::query_scalar!( + "SELECT value + FROM resource + WHERE path = $1 AND workspace_id = $2", + &openai_resource_path, + &w_id + ) + .fetch_one(&mut *tx) + .await?; + tx.commit().await?; + + if resource.is_none() { + return Err(Error::InternalErr( + "OpenAI resource missing value".to_string(), + )); + } + + let mut resource: OpenaiResource = serde_json::from_value(resource.unwrap()) + .map_err(|e| Error::InternalErr(format!("validating openai resource {e}")))?; + + let openai_api_key_path = if resource.api_key.starts_with("$var:") { + resource.api_key.strip_prefix("$var:").unwrap().to_string() + } else { + return Err(Error::InternalErr( + "OpenAI resource api key must be a variable".to_string(), + )); }; - let resp = HTTP_CLIENT + tx = db.begin().await?; + resource.api_key = sqlx::query_scalar!( + "SELECT value + FROM variable + WHERE path = $1 AND workspace_id = $2", + &openai_api_key_path, + &w_id + ) + .fetch_one(&mut *tx) + .await?; + let mc = build_crypt(&mut tx, &w_id).await?; + tx.commit().await?; + resource.api_key = mc + .decrypt_base64_to_string(resource.api_key) + .map_err(|e| Error::InternalErr(e.to_string()))?; + + let mut request = HTTP_CLIENT .post(String::from("https://api.openai.com/v1/") + &openai_path) .header("content-type", "application/json") - .header("authorization", format!("Bearer {}", openai_key)) - .body(body) - .send() - .await - .map_err(to_anyhow)?; + .header("authorization", format!("Bearer {}", resource.api_key)) + .body(body); - let mut tx = db.begin().await?; + if resource.organisation.is_some() { + request = request.header("OpenAI-Organization", resource.organisation.unwrap()); + } + + let resp = request.send().await.map_err(to_anyhow)?; + + tx = db.begin().await?; audit_log( &mut *tx, &authed.username, diff --git a/backend/windmill-api/src/workspaces.rs b/backend/windmill-api/src/workspaces.rs index 0a15396af9..55191b79be 100644 --- a/backend/windmill-api/src/workspaces.rs +++ b/backend/windmill-api/src/workspaces.rs @@ -67,8 +67,8 @@ pub fn workspaced_service() -> Router { .route("/edit_deploy_to", post(edit_deploy_to)) .route("/tarball", get(tarball_workspace)) .route("/premium_info", get(premium_info)) - .route("/edit_openai_key", post(edit_openai_key)) - .route("/exists_openai_key", get(exists_openai_key) ) + .route("/edit_openai_resource_path", post(edit_openai_resource_path)) + .route("/exists_openai_resource_path", get(exists_openai_resource_path) ) .route("/edit_error_handler", post(edit_error_handler)); #[cfg(feature = "enterprise")] @@ -116,7 +116,7 @@ pub struct WorkspaceSettings { pub plan: Option, pub webhook: Option, pub deploy_to: Option, - pub openai_key: Option, + pub openai_resource_path: Option, pub error_handler: Option, } @@ -158,8 +158,8 @@ struct EditWebhook { } #[derive(Deserialize)] -struct EditOpenAIKey { - openai_key: Option, +struct EditOpenaiResourcePath { + openai_resource_path: Option, } #[derive(Deserialize)] @@ -661,28 +661,28 @@ async fn edit_webhook( Ok(format!("Edit webhook for workspace {}", &w_id)) } -async fn edit_openai_key( +async fn edit_openai_resource_path( authed: Authed, Extension(db): Extension, Path(w_id): Path, Authed { is_admin, username, .. }: Authed, - Json(eo): Json, + Json(eo): Json, ) -> Result { require_admin(is_admin, &username)?; let mut tx = db.begin().await?; - if let Some(openai_key) = &eo.openai_key { + if let Some(openai_resource_path) = &eo.openai_resource_path { sqlx::query!( - "UPDATE workspace_settings SET openai_key = $1 WHERE workspace_id = $2", - openai_key, + "UPDATE workspace_settings SET openai_resource_path = $1 WHERE workspace_id = $2", + openai_resource_path, &w_id ) .execute(&mut *tx) .await?; } else { sqlx::query!( - "UPDATE workspace_settings SET openai_key = NULL WHERE workspace_id = $1", + "UPDATE workspace_settings SET openai_resource_path = NULL WHERE workspace_id = $1", &w_id, ) .execute(&mut *tx) @@ -691,35 +691,35 @@ async fn edit_openai_key( audit_log( &mut *tx, &authed.username, - "workspaces.edit_openai_key", + "workspaces.edit_openai_resource_path", ActionKind::Update, &w_id, Some(&authed.email), - Some([("openai_key", &format!("{:?}", eo.openai_key)[..])].into()), + Some([("openai_resource_path", &format!("{:?}", eo.openai_resource_path)[..])].into()), ) .await?; tx.commit().await?; - Ok(format!("Edit openai_key for workspace {}", &w_id)) + Ok(format!("Edit openai_resource_path for workspace {}", &w_id)) } -async fn exists_openai_key( +async fn exists_openai_resource_path( Extension(db): Extension, Path(w_id): Path, ) -> JsonResult { let mut tx = db.begin().await?; - let openai_key = sqlx::query_scalar!( - "SELECT openai_key FROM workspace_settings WHERE workspace_id = $1", + let openai_resource_path = sqlx::query_scalar!( + "SELECT openai_resource_path FROM workspace_settings WHERE workspace_id = $1", &w_id ) .fetch_one(&mut *tx) .await - .map_err(|e| Error::InternalErr(format!("getting openai_key: {e}")))?; + .map_err(|e| Error::InternalErr(format!("getting openai_resource_path: {e}")))?; tx.commit().await?; - let exists = openai_key.is_some(); + let exists = openai_resource_path.is_some(); Ok(Json(exists)) } diff --git a/frontend/src/lib/components/Password.svelte b/frontend/src/lib/components/Password.svelte index c79de9e378..7632efe8b6 100644 --- a/frontend/src/lib/components/Password.svelte +++ b/frontend/src/lib/components/Password.svelte @@ -30,7 +30,7 @@
- + {#if error} - {#if openAIAvailable} + {#if openaiAvailable}
{#if generatedCode}
diff --git a/frontend/src/lib/components/codeGen/ScriptGen.svelte b/frontend/src/lib/components/codeGen/ScriptGen.svelte index 4fa71fd8d6..a4159afa3c 100644 --- a/frontend/src/lib/components/codeGen/ScriptGen.svelte +++ b/frontend/src/lib/components/codeGen/ScriptGen.svelte @@ -9,7 +9,7 @@ import Popup from '../common/popup/Popup.svelte' import { fade } from 'svelte/transition' import { Icon } from 'svelte-awesome' - import { existsOpenaiKeyStore } from '$lib/stores' + import { existsOpenaiResourcePath } from '$lib/stores' import type DiffEditor from '../DiffEditor.svelte' import { scriptLangToEditorLang } from '$lib/scripts' import type { Selection } from 'monaco-editor/esm/vs/editor/editor.api' @@ -25,7 +25,7 @@ // state let funcDesc: string = '' let genLoading: boolean = false - let openAIAvailable: boolean | undefined = undefined + let openaiAvailable: boolean | undefined = undefined let button: HTMLButtonElement | undefined let input: HTMLInputElement | undefined let generatedCode = '' @@ -33,6 +33,9 @@ let isEdit = false async function onGenerate() { + if (funcDesc.length <= 0) { + return + } try { // close popup ^^ const elem = document.activeElement as HTMLElement @@ -75,14 +78,11 @@ generatedCode = '' } - async function checkIfOpenAIAvailable(lang: SupportedLanguage | 'frontend') { - try { - const exists = $existsOpenaiKeyStore - openAIAvailable = exists && SUPPORTED_LANGUAGES.has(lang) - } catch (err) { - console.error(err) - sendUserToast('Failed to check if OpenAI is available', true) - } + function checkIfOpenaiAvailable( + lang: SupportedLanguage | 'frontend', + existsOpenaiResourcePath: boolean + ) { + openaiAvailable = existsOpenaiResourcePath && SUPPORTED_LANGUAGES.has(lang) } function showDiff() { @@ -106,7 +106,7 @@ }) } - $: checkIfOpenAIAvailable(lang) + $: checkIfOpenaiAvailable(lang, $existsOpenaiResourcePath) $: input?.focus() @@ -118,7 +118,7 @@ $: selection && (isEdit = !selection.isEmpty()) -{#if openAIAvailable} +{#if openaiAvailable} {#if generatedCode} {#if inlineScript}
@@ -210,7 +210,7 @@ bind:value={funcDesc} class="!w-auto grow" on:keypress={({ key }) => { - if (key === 'Enter') { + if (key === 'Enter' && funcDesc.length > 0) { onGenerate() } }} @@ -225,6 +225,7 @@ btnClasses="!p-1 !w-[34px] !ml-1" aria-label="Generate" on:click={onGenerate} + disabled={funcDesc.length <= 0} > diff --git a/frontend/src/lib/components/codeGen/lib.ts b/frontend/src/lib/components/codeGen/lib.ts index 4f1c1ed854..7f958a5fca 100644 --- a/frontend/src/lib/components/codeGen/lib.ts +++ b/frontend/src/lib/components/codeGen/lib.ts @@ -2,7 +2,7 @@ import { OpenAI } from 'openai' import { OpenAPI } from '../../gen/core/OpenAPI' import { ResourceService, Script, WorkspaceService } from '../../gen' -import { existsOpenaiKeyStore, workspaceStore } from '$lib/stores' +import { existsOpenaiResourcePath, workspaceStore } from '$lib/stores' import { formatResourceTypes } from './utils' import { scriptLangToEditorLang } from '$lib/scripts' @@ -45,10 +45,10 @@ workspaceStore.subscribe(async (value) => { workspace = value if (workspace) { try { - existsOpenaiKeyStore.set(await WorkspaceService.existsOpenaiKey({ workspace })) + existsOpenaiResourcePath.set(await WorkspaceService.existsOpenaiResourcePath({ workspace })) } catch (err) { - existsOpenaiKeyStore.set(false) - console.error('Could not get if openai key exists') + existsOpenaiResourcePath.set(false) + console.error('Could not get if OpenAI resource exists') } } }) diff --git a/frontend/src/lib/stores.ts b/frontend/src/lib/stores.ts index ad124c5871..4aaa4dee63 100644 --- a/frontend/src/lib/stores.ts +++ b/frontend/src/lib/stores.ts @@ -65,7 +65,7 @@ export const hubScripts = writable< }> | undefined >(undefined) -export const existsOpenaiKeyStore = writable(false) +export const existsOpenaiResourcePath = writable(false) export function switchWorkspace(workspace: string | undefined) { localStorage.removeItem('flow') diff --git a/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte b/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte index c860904718..868d50f507 100644 --- a/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte @@ -10,6 +10,7 @@ import DeployToSetting from '$lib/components/DeployToSetting.svelte' import InviteUser from '$lib/components/InviteUser.svelte' import PageHeader from '$lib/components/PageHeader.svelte' + import ResourcePicker from '$lib/components/ResourcePicker.svelte' import ScriptPicker from '$lib/components/ScriptPicker.svelte' import SearchItems from '$lib/components/SearchItems.svelte' import Slider from '$lib/components/Slider.svelte' @@ -27,7 +28,7 @@ } from '$lib/gen' import { enterpriseLicense, - existsOpenaiKeyStore, + existsOpenaiResourcePath, superadmin, userStore, usersWorkspaceStore, @@ -54,10 +55,10 @@ let customer_id: string | undefined = undefined let webhook: string | undefined = undefined let workspaceToDeployTo: string | undefined = undefined - let openAIKey: string | undefined = undefined let errorHandlerInitialPath: string let errorHandlerScriptPath: string let errorHandlerItemKind: 'script' = 'script' + let openaiResourceInitialPath: string | undefined = undefined let tab = ($page.url.searchParams.get('tab') as | 'users' @@ -127,22 +128,23 @@ } } - async function editOpenAIKey(): Promise { + async function editOpenaiResourcePath(openaiResourcePath: string): Promise { // in JS, an empty string is also falsy - if (openAIKey) { - await WorkspaceService.editOpenaiKey({ + openaiResourceInitialPath = openaiResourcePath + if (openaiResourcePath) { + await WorkspaceService.editOpenaiResourcePath({ workspace: $workspaceStore!, - requestBody: { openai_key: openAIKey } + requestBody: { openai_resource_path: openaiResourcePath } }) - existsOpenaiKeyStore.set(true) - sendUserToast('OpenAI key set') + existsOpenaiResourcePath.set(true) + sendUserToast('OpenAI resource set') } else { - await WorkspaceService.editOpenaiKey({ + await WorkspaceService.editOpenaiResourcePath({ workspace: $workspaceStore!, - requestBody: { openai_key: undefined } + requestBody: { openai_resource_path: undefined } }) - existsOpenaiKeyStore.set(false) - sendUserToast(`OpenAI key removed`) + existsOpenaiResourcePath.set(false) + sendUserToast(`OpenAI resource removed`) } } @@ -160,7 +162,7 @@ customer_id = settings.customer_id workspaceToDeployTo = settings.deploy_to webhook = settings.webhook - openAIKey = settings.openai_key + openaiResourceInitialPath = settings.openai_resource_path errorHandlerScriptPath = (settings.error_handler ?? '').split('/').slice(1).join('/') errorHandlerInitialPath = errorHandlerScriptPath } @@ -300,7 +302,7 @@
OpenAI Credentials Windmill AI Beta
@@ -888,15 +890,22 @@
{:else if tab == 'openai'} - +
Enter your OpenAI api key to unlock Windmill's AI features! -
-
- - + >Select an OpenAI resource to unlock Windmill AI features!
+
+ {#key openaiResourceInitialPath} + { + editOpenaiResourcePath(ev.detail) + }} + /> + {/key}
{/if} {:else}