From dbba30656cc14f158a35a5763b721c8cc77c540f Mon Sep 17 00:00:00 2001 From: tibz Date: Wed, 8 Apr 2026 18:27:48 +0200 Subject: [PATCH 1/9] feat(proconnect): add proconnect button - update cursor when hovering proconnect and login buttons --- playground/app/app.py | 1 + .../app/features/auth/components/forms.py | 34 +++++++++++++++++++ playground/assets/proconnect.css | 26 ++++++++++++++ 3 files changed, 61 insertions(+) create mode 100644 playground/assets/proconnect.css diff --git a/playground/app/app.py b/playground/app/app.py index d128123cf..70a0713f8 100644 --- a/playground/app/app.py +++ b/playground/app/app.py @@ -122,6 +122,7 @@ def providers() -> rx.Component: # Create the app with theme configuration app = rx.App( + stylesheets=["proconnect.css"], theme=rx.theme( has_background=configuration.settings.playground_theme_has_background, accent_color=configuration.settings.playground_theme_accent_color, diff --git a/playground/app/features/auth/components/forms.py b/playground/app/features/auth/components/forms.py index 6074291c2..b5cde16e6 100644 --- a/playground/app/features/auth/components/forms.py +++ b/playground/app/features/auth/components/forms.py @@ -61,10 +61,44 @@ def login_form() -> rx.Component: width="100%", loading=AuthState.is_loading, disabled=AuthState.is_loading, + cursor="pointer", ), spacing="4", width="100%", ), + rx.vstack( + rx.hstack( + rx.separator(flex_grow="1"), + rx.text("ou", size="1", color=rx.color("gray", 9)), + rx.separator(flex_grow="1"), + width="100%", + align="center", + spacing="2", + ), + rx.center( + rx.el.button( + rx.el.span( + "S'identifier avec ProConnect", + class_name="proconnect-sr-only", + ), + class_name="proconnect-button", + type="button", + ), + width="100%", + ), + rx.center( + rx.el.a( + "Qu'est-ce que ProConnect ?", + href="https://www.proconnect.gouv.fr/", + target="_blank", + rel="noopener noreferrer", + ), + width="100%", + ), + spacing="3", + width="100%", + padding_top="1em", + ), spacing="0", width="100%", ), diff --git a/playground/assets/proconnect.css b/playground/assets/proconnect.css new file mode 100644 index 000000000..ed73828e3 --- /dev/null +++ b/playground/assets/proconnect.css @@ -0,0 +1,26 @@ +.proconnect-sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border-width: 0; +} + +.proconnect-button { + background-color: transparent !important; + background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPScyMTEnIGhlaWdodD0nNTgnIGZpbGw9J25vbmUnPjxwYXRoIGZpbGw9JyMwMDAwOTEnIGQ9J00wIDBoMjExdjU4SDB6Jy8+PHBhdGggZmlsbD0nI2ZmZicgZD0nbTY5Ljk4NiAyNi4zNjggMS4xNTYtMS4wNzFjLjgzMyAxLjA1NCAxLjgxOSAxLjU5OCAyLjk0MSAxLjU5OCAxLjI5MiAwIDIuMDQtLjgxNiAyLjA0LTEuOTA0IDAtMi41NS01LjYyNy0yLjI0NC01LjYyNy02LjAzNSAwLTEuNzM0IDEuNDI4LTMuMTk2IDMuNDUxLTMuMTk2IDEuNjgzIDAgMi45MDcuNzY1IDMuNzkxIDEuOTM4bC0xLjE5IDEuMDM3Yy0uNjk3LTEuMDAzLTEuNTQ3LTEuNTQ3LTIuNTg0LTEuNTQ3LTEuMTA1IDAtMS44MzYuNzQ4LTEuODM2IDEuNzM0IDAgMi41NjcgNS42MjcgMi4yNDQgNS42MjcgNi4wNTIgMCAyLjAyMy0xLjU4MSAzLjM0OS0zLjY1NSAzLjM0OS0xLjc2OCAwLTMuMDc3LS42NjMtNC4xMTQtMS45NTVabTEwLjgxNy01LjcxMkg3OS40NmwxLjQ0NS00LjU1NmgxLjY0OWwtMS43NTEgNC41NTZabTQuODE4LTMuNDUxYy0uNTYgMC0xLjAyLS40NTktMS4wMi0xLjAyYTEuMDIgMS4wMiAwIDAgMSAxLjAyLTEuMDAzYy41NjEgMCAxLjAwMy40NTkgMS4wMDMgMS4wMDMgMCAuNTYxLS40NDIgMS4wMi0xLjAwMyAxLjAyWk04NC44OTEgMjh2LTguNTY4aDEuNDQ0VjI4SDg0Ljg5Wm0zLjc2Ny00LjI4NGMwLTIuNDk5IDEuNzE3LTQuNjI0IDQuNDAzLTQuNjI0IDEuMjQxIDAgMi4yNjEuNDU5IDMuMDQzIDEuMjkyVjE1LjI1aDEuNDQ1VjI4aC0xLjQ0NXYtLjk1MmMtLjc4Mi44MzMtMS44MDIgMS4yOTItMy4wNDMgMS4yOTItMi42ODYgMC00LjQwMy0yLjEyNS00LjQwMy00LjYyNFptMS41MyAwYzAgMS44MTkgMS4yMjQgMy4yNjQgMy4wNDMgMy4yNjQgMS4xOSAwIDIuMjEtLjU3OCAyLjg3My0xLjU5OFYyMi4wNWMtLjY4LTEuMDM3LTEuNy0xLjU5OC0yLjg3My0xLjU5OC0xLjgxOSAwLTMuMDQzIDEuNDQ1LTMuMDQzIDMuMjY0Wm0xOC4wMjMgMi44NzNjLS43OTkgMS4wNzEtMi4wNzQgMS43NTEtMy42NzIgMS43NTEtMi44OSAwLTQuNjc1LTIuMTI1LTQuNjc1LTQuNjI0IDAtMi42MDEgMS42NjYtNC42MjQgNC4zMTgtNC42MjQgMi4zMjkgMCAzLjg0MiAxLjU4MSAzLjg0MiAzLjcyMyAwIC4zNC0uMDUxLjY4LS4xMDIuOTE4aC02LjU2MnYuMDM0YzAgMS44ODcgMS4yOTIgMy4yNjQgMy4yMTMgMy4yNjQgMS4wODggMCAyLjAwNi0uNTEgMi41NjctMS4yNzVsMS4wNzEuODMzWm0tNC4wMTItNi4yNTZjLTEuMzk0IDAtMi4zOC43ODItMi43MiAyLjI2MWg1LjA4M2MtLjA1MS0xLjI0MS0uOTUyLTIuMjYxLTIuMzYzLTIuMjYxWk0xMTAuNDczIDI4di04LjU2OGgxLjQ0NXYuOTY5Yy42OTctLjc2NSAxLjU4MS0xLjMwOSAyLjg1Ni0xLjMwOSAxLjkyMSAwIDMuMzQ5IDEuMjkyIDMuMzQ5IDMuNzIzVjI4aC0xLjQ2MnYtNS4xMzRjMC0xLjUzLS44NS0yLjQxNC0yLjE3Ni0yLjQxNC0xLjI0MSAwLTIuMDIzLjcxNC0yLjU2NyAxLjYxNVYyOGgtMS40NDVabTExLjA1Mi0yLjg3M3YtNC4zNjloLTEuNjE1di0xLjMyNmgxLjYxNVYxNy4yOWgxLjQ2MnYyLjE0MmgyLjk3NXYxLjMyNmgtMi45NzV2NC4zNjljMCAxLjM0My42OCAxLjcxNyAxLjcxNyAxLjcxNy41NjEgMCAuOTUyLS4wNjggMS4yNzUtLjIwNHYxLjI5MmMtLjQwOC4xNy0uODY3LjIzOC0xLjQ3OS4yMzgtMS45MDQgMC0yLjk3NS0uOTUyLTIuOTc1LTMuMDQzWm03LjM3Ny03LjkyMmMtLjU2MSAwLTEuMDItLjQ1OS0xLjAyLTEuMDJhMS4wMiAxLjAyIDAgMCAxIDEuMDItMS4wMDNjLjU2MSAwIDEuMDAzLjQ1OSAxLjAwMyAxLjAwMyAwIC41NjEtLjQ0MiAxLjAyLTEuMDAzIDEuMDJaTTEyOC4xNzEgMjh2LTguNTY4aDEuNDQ1VjI4aC0xLjQ0NVptMy4zNzctOC41NjhoMS42MTV2LTEuMDU0YzAtMS44MzYgMS4yMDctMy4xMjggMy4wNDMtMy4xMjguOTUyIDAgMS43LjM0IDIuMjEuODMzbC0uOTAxIDEuMDU0YTEuNjMzIDEuNjMzIDAgMCAwLTEuMjkyLS41NzhjLS45MzUgMC0xLjU5OC42OC0xLjU5OCAxLjc4NXYxLjA4OGgyLjk3NXYxLjMyNmgtMi45NzVWMjhoLTEuNDYydi03LjI0MmgtMS42MTV2LTEuMzI2Wm04LjU0My0yLjIyN2MtLjU2MSAwLTEuMDItLjQ1OS0xLjAyLTEuMDJhMS4wMiAxLjAyIDAgMCAxIDEuMDItMS4wMDNjLjU2MSAwIDEuMDAzLjQ1OSAxLjAwMyAxLjAwMyAwIC41NjEtLjQ0MiAxLjAyLTEuMDAzIDEuMDJaTTEzOS4zNiAyOHYtOC41NjhoMS40NDVWMjhoLTEuNDQ1Wm0xMi4xMTUtMS40MTFjLS43OTkgMS4wNzEtMi4wNzQgMS43NTEtMy42NzIgMS43NTEtMi44OSAwLTQuNjc1LTIuMTI1LTQuNjc1LTQuNjI0IDAtMi42MDEgMS42NjYtNC42MjQgNC4zMTgtNC42MjQgMi4zMjkgMCAzLjg0MiAxLjU4MSAzLjg0MiAzLjcyMyAwIC4zNC0uMDUxLjY4LS4xMDIuOTE4aC02LjU2MnYuMDM0YzAgMS44ODcgMS4yOTIgMy4yNjQgMy4yMTMgMy4yNjQgMS4wODggMCAyLjAwNi0uNTEgMi41NjctMS4yNzVsMS4wNzEuODMzWm0tNC4wMTItNi4yNTZjLTEuMzk0IDAtMi4zOC43ODItMi43MiAyLjI2MWg1LjA4M2MtLjA1MS0xLjI0MS0uOTUyLTIuMjYxLTIuMzYzLTIuMjYxWk0xNTMuNzM3IDI4di04LjU2OGgxLjQ0NXYxLjA3MWMuNjI5LS43NDggMS40MTEtMS4yNDEgMi40OTktMS4yNDEuMjcyIDAgLjUyNy4wMzQuNzMxLjEwMnYxLjQ5NmEzLjEwNSAzLjEwNSAwIDAgMC0uODUtLjExOWMtMS4xMjIgMC0xLjg1My41NzgtMi4zOCAxLjQ0NVYyOGgtMS40NDVabTEzLjY4NS4zNGMtMS42ODMgMC0yLjgyMi0uOTUyLTIuODIyLTIuNDQ4IDAtMS4zMjYuOTg2LTIuMjc4IDIuODIyLTIuNTY3bDIuODczLS40NzZ2LS41OTVjMC0xLjE5LS44NS0xLjg3LTIuMDU3LTEuODctMS4wMDMgMC0xLjgzNi40NDItMi4zMjkgMS4xOWwtMS4wODgtLjgzM2MuNzQ4LTEuMDIgMS45NTUtMS42NDkgMy40NTEtMS42NDkgMi4xNzYgMCAzLjQ2OCAxLjI3NSAzLjQ2OCAzLjE2MlYyOGgtMS40NDV2LTEuMDg4Yy0uNjQ2LjkwMS0xLjcxNyAxLjQyOC0yLjg3MyAxLjQyOFptLTEuMzc3LTIuNDk5YzAgLjczMS42MjkgMS4yOTIgMS42MTUgMS4yOTIgMS4xMzkgMCAyLjA0LS41OTUgMi42MzUtMS41ODFWMjMuOTJsLTIuNTMzLjQ0MmMtMS4xOS4xODctMS43MTcuNzMxLTEuNzE3IDEuNDc5Wm03LjI1Mi02LjQwOWgxLjU2NGwyLjczNyA3LjA1NSAyLjczNy03LjA1NWgxLjU2NEwxNzguNTUgMjhoLTEuOTA0bC0zLjM0OS04LjU2OFptMTcuODU2IDcuMTU3Yy0uNzk5IDEuMDcxLTIuMDc0IDEuNzUxLTMuNjcyIDEuNzUxLTIuODkgMC00LjY3NS0yLjEyNS00LjY3NS00LjYyNCAwLTIuNjAxIDEuNjY2LTQuNjI0IDQuMzE4LTQuNjI0IDIuMzI5IDAgMy44NDIgMS41ODEgMy44NDIgMy43MjMgMCAuMzQtLjA1MS42OC0uMTAyLjkxOGgtNi41NjJ2LjAzNGMwIDEuODg3IDEuMjkyIDMuMjY0IDMuMjEzIDMuMjY0IDEuMDg4IDAgMi4wMDYtLjUxIDIuNTY3LTEuMjc1bDEuMDcxLjgzM1ptLTQuMDEyLTYuMjU2Yy0xLjM5NCAwLTIuMzguNzgyLTIuNzIgMi4yNjFoNS4wODNjLS4wNTEtMS4yNDEtLjk1Mi0yLjI2MS0yLjM2My0yLjI2MVptMTAuMTg1IDYuNjQ3YzEuMDU0IDAgMS45MDQtLjUxIDIuNDMxLTEuMjc1bDEuMTU2Ljg4NGMtLjc5OSAxLjA3MS0yLjA0IDEuNzUxLTMuNjA0IDEuNzUxLTIuODM5IDAtNC42NTgtMi4xMjUtNC42NTgtNC42MjQgMC0yLjQ5OSAxLjgxOS00LjYyNCA0LjY1OC00LjYyNCAxLjU0NyAwIDIuODA1LjY5NyAzLjYwNCAxLjc1MWwtMS4xNTYuODg0YTIuOTI1IDIuOTI1IDAgMCAwLTIuNDQ4LTEuMjc1Yy0xLjgzNiAwLTMuMTQ1IDEuNDQ1LTMuMTQ1IDMuMjY0IDAgMS44MzYgMS4zMDkgMy4yNjQgMy4xNjIgMy4yNjRaTTcwLjg1NCA0NVYzMi40aDQuMTU4YzIuNzcyIDAgNC40NjQgMS40MjIgNC40NjQgMy43NjIgMCAyLjMyMi0xLjY5MiAzLjc0NC00LjQ2NCAzLjc0NEg3My40MVY0NWgtMi41NTZabTQuMjY2LTEwLjQyMmgtMS43MXYzLjE1aDEuNzFjMS4wOCAwIDEuNzI4LS41NzYgMS43MjgtMS42MDIgMC0uOTU0LS42NDgtMS41NDgtMS43MjgtMS41NDhaTTgxLjI0OSA0NXYtOS4wNzJoMi4yODZ2LjljLjU5NC0uNjEyIDEuMzY4LTEuMDggMi4zOTQtMS4wOC4zMDYgMCAuNTc2LjA1NC43OTIuMTI2djIuMzk0YTMuOTM4IDMuOTM4IDAgMCAwLTEuMDA4LS4xMjZjLTEuMTE2IDAtMS44MzYuNjEyLTIuMTc4IDEuMTdWNDVoLTIuMjg2Wm0xMS4zODYtOS40MzJjMi45NTIgMCA0Ljk2OCAyLjE3OCA0Ljk2OCA0Ljg5NnMtMi4wMTYgNC44OTYtNC45NjggNC44OTYtNC45NjgtMi4xNzgtNC45NjgtNC44OTYgMi4wMTYtNC44OTYgNC45NjgtNC44OTZabS4wMzYgNy42MzJjMS40NTggMCAyLjU1Ni0xLjE3IDIuNTU2LTIuNzM2IDAtMS41ODQtMS4wOTgtMi43MzYtMi41NTYtMi43MzYtMS41MTIgMC0yLjYyOCAxLjE1Mi0yLjYyOCAyLjczNiAwIDEuNTg0IDEuMTE2IDIuNzM2IDIuNjI4IDIuNzM2Wm0xMy4xNzItLjIzNGMxLjQ0IDAgMi41NzQtLjcwMiAzLjI5NC0xLjcyOGwyLjAxNiAxLjU0OGMtMS4xNTIgMS41NjYtMy4wMjQgMi41NzQtNS4zMSAyLjU3NC0zLjk3OCAwLTYuNjk2LTMuMDYtNi42OTYtNi42NnMyLjcxOC02LjY2IDYuNjk2LTYuNjZjMi4yODYgMCA0LjE1OCAxLjAyNiA1LjMxIDIuNTU2bC0yLjAxNiAxLjU2NmMtLjcyLTEuMDI2LTEuODU0LTEuNzI4LTMuMjk0LTEuNzI4LTIuMzc2IDAtNC4wNjggMS44NTQtNC4wNjggNC4yNjZzMS42OTIgNC4yNjYgNC4wNjggNC4yNjZabTExLjM2Ni03LjM5OGMyLjk1MiAwIDQuOTY4IDIuMTc4IDQuOTY4IDQuODk2cy0yLjAxNiA0Ljg5Ni00Ljk2OCA0Ljg5Ni00Ljk2OC0yLjE3OC00Ljk2OC00Ljg5NiAyLjAxNi00Ljg5NiA0Ljk2OC00Ljg5NlptLjAzNiA3LjYzMmMxLjQ1OCAwIDIuNTU2LTEuMTcgMi41NTYtMi43MzYgMC0xLjU4NC0xLjA5OC0yLjczNi0yLjU1Ni0yLjczNi0xLjUxMiAwLTIuNjI4IDEuMTUyLTIuNjI4IDIuNzM2IDAgMS41ODQgMS4xMTYgMi43MzYgMi42MjggMi43MzZabTcuMDE4IDEuOHYtOS4wNzJoMi4yODZ2LjcyYy42My0uNjEyIDEuNDc2LTEuMDggMi42ODItMS4wOCAxLjk2MiAwIDMuNTI4IDEuMzUgMy41MjggNC4wMzJWNDVoLTIuMzIydi01LjMxYzAtMS4yMDYtLjY2Ni0xLjk2Mi0xLjc4Mi0xLjk2Mi0xLjE1MiAwLTEuNzY0Ljc3NC0yLjEwNiAxLjM1VjQ1aC0yLjI4NlptMTEuMDkxIDB2LTkuMDcyaDIuMjg2di43MmMuNjMtLjYxMiAxLjQ3Ni0xLjA4IDIuNjgyLTEuMDggMS45NjIgMCAzLjUyOCAxLjM1IDMuNTI4IDQuMDMyVjQ1aC0yLjMyMnYtNS4zMWMwLTEuMjA2LS42NjYtMS45NjItMS43ODItMS45NjItMS4xNTIgMC0xLjc2NC43NzQtMi4xMDYgMS4zNVY0NWgtMi4yODZabTE5LjQ0NC0xLjQ3NmMtLjg0NiAxLjEzNC0yLjI1IDEuODM2LTMuOTYgMS44MzYtMy4yMjIgMC01LjA0LTIuMjUtNS4wNC00Ljg5NiAwLTIuNjgyIDEuNjkyLTQuODk2IDQuNjYyLTQuODk2IDIuNTIgMCA0LjE3NiAxLjY5MiA0LjE3NiA0LjA2OCAwIC41MDQtLjA3Mi45OS0uMTQ0IDEuMjk2aC02LjM1NGMuMTQ0IDEuNDk0IDEuMTg4IDIuMzc2IDIuNzM2IDIuMzc2Ljk5IDAgMS44LS40MzIgMi4yODYtMS4wOGwxLjYzOCAxLjI5NlptLTQuMzM4LTYuMDQ4Yy0xLjExNiAwLTEuODcyLjU0LTIuMTc4IDEuNzI4aDQuMDg2Yy0uMDM2LS45LS43MDItMS43MjgtMS45MDgtMS43MjhabTEwLjY5NiA1LjcyNGMuODgyIDAgMS41ODQtLjQzMiAyLjAxNi0xLjA2MmwxLjgxOCAxLjM4NmMtLjg0NiAxLjExNi0yLjE3OCAxLjgzNi0zLjgzNCAxLjgzNi0zLjEzMiAwLTUuMDA0LTIuMjUtNS4wMDQtNC44OTZzMS44NzItNC44OTYgNS4wMDQtNC44OTZjMS42NTYgMCAyLjk4OC43MiAzLjgzNCAxLjgzNmwtMS44MTggMS4zODZjLS40MzItLjYzLTEuMTE2LTEuMDYyLTIuMDUyLTEuMDYyLTEuNDk0IDAtMi41OTIgMS4xNTItMi41OTIgMi43MzYgMCAxLjYwMiAxLjA5OCAyLjczNiAyLjYyOCAyLjczNlptNi4yMDQtMS41MTJ2LTMuNjcyaC0xLjY5MnYtMi4wODhoMS42OTJWMzMuNjZoMi4zMDR2Mi4yNjhoMi43NzJ2Mi4wODhoLTIuNzcydjMuNjcyYzAgMS4wMDguNTQgMS40MDQgMS40NCAxLjQwNC42MyAwIDEuMDQ0LS4wNzIgMS4zNS0uMTk4djEuOTk4Yy0uNDUuMTk4LS45OS4yODgtMS43NDYuMjg4LTIuMjY4IDAtMy4zNDgtMS4yNzgtMy4zNDgtMy40OTJaJy8+PHBhdGggZmlsbD0nIzAwMDA5MScgZD0nTTQ2Ljk5MiAxOS4wOTggMzEuOTk4IDEwLjQybC0xNC45OTQgOC43NmEuNjA2LjYwNiAwIDAgMC0uMzA2LjUyNXYxNi45NDhhLjY2Ni42NjYgMCAwIDAgLjMwNi41MjRsMTQuOTkyIDguNiAxNC45OTQtOC43MDZhLjY2Ni42NjYgMCAwIDAgLjMwNi0uNTI0VjE5LjYyNmEuNjA0LjYwNCAwIDAgMC0uMzA0LS41MjhaJy8+PHBhdGggZmlsbD0nI0ZDQzYzQScgZD0nbTI2LjY0MSAxOS41OTgtNS4wMjkgOC42MjgtNC41NTctOS4xNzUgNS4zOS0zLjExMyA0LjQ4OSAzLjE2LS4yOTMuNVptMjAuNjU2IDE2Ljk4VjE5LjYyYS42LjYgMCAwIDAtLjMwNi0uNTIzTDMxLjk5OCAxMC40MicvPjxwYXRoIGZpbGw9JyMwMDYzQ0InIGQ9J00xNi43IDM2LjU3OCAzMiAxMC40MnYzNS4zNjJsLTE0Ljk5Ni04LjYwNWEuNjY1LjY2NSAwIDAgMS0uMzA2LS41MjRWMTkuNzA2bC4wMDIgMTYuODcyWm0yNC42NjktMjAuNzM1IDUuNDU4IDMuMTU1LTQuNDg5IDkuMTUtNS4zODctOS4yMzYgNC40MTgtMy4wN1onLz48cGF0aCBmaWxsPScjZmZmJyBkPSdtNTEuNjA2IDE2LjMwMy0xOS4xOS0xMS4wMmEuOTMzLjkzMyAwIDAgMC0uODMyIDBsLTE5LjE5IDExLjAyYS44ODcuODg3IDAgMCAwLS4zOTQuNjk1djIyYS44ODUuODg1IDAgMCAwIC4zOTQuN2wxOS4xODkgMTEuMDJhLjkzMi45MzIgMCAwIDAgLjgzMiAwbDE5LjE5MS0xMS4wMmEuODg2Ljg4NiAwIDAgMCAuMzk0LS43di0yMmEuODg3Ljg4NyAwIDAgMC0uMzk0LS42OTVaTTIyLjc4OSAzNC4wNTloLjA3OWMtLjA0MiAwLS4wNzkuMDA3LS4wNzkuMDUgMCAuMS4xNTEgMCAuMi4xYS45MTIuOTEyIDAgMCAwLS42MjkuMjc2YzAgLjA1LjEuMDUuMTUxLjA1LS4wNzUuMS0uMjI2LjA1LS4yNzcuMTUyYS4xNzYuMTc2IDAgMCAwIC4xLjA1Yy0uMDUgMC0uMSAwLS4xLjA1di4xNTJjLS4xMjYgMC0uMTc2LjEtLjI3Ny4xNS4yLjE1Mi4zMjcgMCAuNTI4IDAtLjUyOC4yLS45NTYuNDc5LTEuNDg0LjYzLS4xIDAgMCAuMTUtLjEuMTUuMTUxLjEuMjI3LS4wNS4zNzctLjA1LS42NTQuMzc4LTEuMzMzLjctMi4wMzcgMS4xMzNhLjM1MS4zNTEgMCAwIDAtLjEuMmgtLjJjLS4xLjA1LS4wNS4xNzYtLjE1MS4yNzcuMjI2LjE1LjUtLjIuNjU0IDAgLjA1IDAtLjEuMDUtLjIuMDUtLjA1IDAtLjA1LjEtLjEuMWgtLjE1NGMtLjEuMDc1LS4yLjEyNi0uMi4yNzZhLjIyLjIyIDAgMCAwLS4yMjYuMSA5LjAzMSA5LjAzMSAwIDAgMCAzLjE0NC0uNTc4IDcuNjgzIDcuNjgzIDAgMCAwIDIuMDg4LTEuNTYuMTc2LjE3NiAwIDAgMSAuMDUuMWMtLjE0Ny40MzctLjQzLjgxNi0uODA2IDEuMDgtLjI3Ny4xNTItLjQ3OC4zNzgtLjcuNDc5YTQuMDU3IDQuMDU3IDAgMCAwLS40MjguMjc2Yy0uNjMyLjE5Ny0xLjI4MS4zMzUtMS45MzkuNDEybC0uMzA1LjA0NGMtLjIyNS4wMzMtLjQ0OS4wNjktLjY3MS4xMDhsLTEuOTkzLTEuMTM4YS42NDcuNjQ3IDAgMCAxLS4yODgtLjQxMS41Ny41NyAwIDAgMCAuMDk0LS4wNjMuMjY2LjI2NiAwIDAgMC0uMTEzLS4wNzF2LS42NWExMi43ODIgMTIuNzgyIDAgMCAwIDMuMDM4LS45NDIgOC43NDYgOC43NDYgMCAwIDAtMy4wMzctMS4zNDN2LTEuNTE1YTExLjY3IDExLjY3IDAgMCAxIDEuNjM5LjM5MiA2LjQyIDYuNDIgMCAwIDEgMS4xODIuNTc4Yy4xNDcuMTQuMzA3LjI2Ny40NzguMzc3YS45MS45MSAwIDAgMCAuOC4wNWguMzNhMy45NjEgMy45NjEgMCAwIDAgMS45MzctLjkwNWMwIC4wNS4wNS4wNS4xLjA1YTMuNjI5IDMuNjI5IDAgMCAxLS40MjggMS4xMzJjLjAwMy4wNS0uMDQ4LjE1Mi4wNTMuMjAyWm0yLjgxNyAzLjU3Yy4yNTEtLjEuNC0uMjc2LjYyOS0uMzc2LS4wNS4wNS0uMDUuMTUtLjEuMmEzLjY5OSAzLjY5OSAwIDAgMC0uNTI4LjQgMTUuOTY1IDE1Ljk2NSAwIDAgMC0xLjU4NSAxLjYxYy0uMjUyLjMtLjUyOC41NzgtLjguODU1LS4wOTYuMDktLjIuMTcyLS4zMS4yNDVsLTIuNTI3LTEuNDVjLjM2LjAzLjcyMS4wMTMgMS4wNzYtLjA1My4yOTQtLjA4My41OC0uMTkyLjg1NS0uMzI3di4xYy43LS4yNzcgMS4yMzItLjkwNiAxLjkzNy0xLjEzMi4wMjUgMCAuMTI2LjEuMjI2LjA1YTEuODgzIDEuODgzIDAgMCAxIDEuNTA5LS43YzAgLjA1IDAgLjEuMDUuMWguMDI1Yy0uMTUxLjEyNi0uMzI3LjI1LS41LjM3Ny0uMDU3LjA1Mi0uMDA3LjEwMi4wNDMuMTAyWm0tOC45MDgtNi4xNjN2LS4xODZhNS44MTcgNS44MTcgMCAwIDEgMS41ODgtLjE4OCAxLjUyIDEuNTIgMCAwIDEgLjQ3OCAwIDUuODYgNS44NiAwIDAgMC0yLjA2Ni4zNzRabTMwLjYgNS4wODhhLjY2NS42NjUgMCAwIDEtLjMwNi41MjRsLTEwLjA3OSA1Ljg1YTMyLjI5NiAzMi4yOTYgMCAwIDEtMy40MDgtMS4xODQgMi44MjYgMi44MjYgMCAwIDEtLjA1LTIuMjQ1Yy4wOC0uMzA4LjE5OC0uNjA1LjM1Mi0uODgzLjAyNS0uMDI1LjA1LS4wNS4wNS0uMDc2YS4wMjUuMDI1IDAgMCAwIC4wMjUtLjAyNSA0LjMyIDQuMzIgMCAwIDEgLjM3Ny0uNTU1bC4wMTUtLjAxNS4wMi0uMDIxLjAxNS0uMDE1YzAtLjAyNS4wMjUtLjA1LjA1LS4wNzYuMDI1LS4wNTEuMDc1LS4wNzYuMS0uMTI2LjE3Ni0uMTg2LjM3LS4zNTQuNTc5LS41LjIxMy0uMDc3LjQzMS0uMTM2LjY1NC0uMTc3LjgxMS4wNiAxLjYxNy4xNyAyLjQxNS4zMjhhLjc1Mi43NTIgMCAwIDEgLjI3Ny4xYy4zMDEuMDU5LjYxMi4wNDEuOTA1LS4wNWExLjEzNyAxLjEzNyAwIDAgMCAuODU1LS43MDYgMS4yMTIgMS4yMTIgMCAwIDAgLjA1LTEuMDZjLS4xNzgtLjI3NS0uMDEzLS40MzYuMTgxLS41OWwuMDY4LS4wNTRjLjA4Ni0uMDYxLjE2NC0uMTM0LjIzMS0uMjE2LjEyNi0uMjUyLS4xLS40LS4xNTEtLjYzLS4wNS0uMS0uMjI2LS4wNS0uMzI3LS4yLjM1Mi0uMTUxLjg1NS0uNDMuNjI5LS44NTctLjE1MS0uMjI3LS4zNzctLjYzLS4xLS44NTcuMzUyLS4yLjg1NS0uMTUxIDEuMDA2LS40OGExLjEzNyAxLjEzNyAwIDAgMC0uMjkyLTEuMDg0bC0uMDc1LS4xMDhhNC43NTQgNC43NTQgMCAwIDEtLjIxMS0uMzIgNi45MDUgNi45MDUgMCAwIDAtLjUyOC0uNzU3IDQuMjk3IDQuMjk3IDAgMCAxLS41MjgtMS4wMWMtLjE1MS0uMzc3LjA1LS43MDUuMDUtMS4wODNhNi4zNDcgNi4zNDcgMCAwIDAtLjMyNy0yLjE0NGMtLjEyNi0uMzUzLS4xNzYtLjczMS0uMzI3LTEuMDZhMS4xMiAxLjEyIDAgMCAwLS4yMjYtLjU4LjM3NC4zNzQgMCAwIDEgMC0uMzI3Yy4yMDUtLjE0NS4zOTktLjMwNS41NzktLjQ4YS41NjcuNTY3IDAgMCAwLS4yLS43MDVjLS4zMjctLjE1MS0uMy4zMjgtLjUyOC40MjloLS4xNTFjLS4wNS0uMTI2LjA1LS4xNzcuMTUxLS4yNzcgMC0uMDUgMC0uMTUxLS4wNS0uMTUxLS4yIDAtLjM3Ny0uMDUxLS40MjgtLjE1MWEzLjk1NyAzLjk1NyAwIDAgMC0xLjg2MS0xLjI4NmMuMTg4LjA1OC4zODIuMDkxLjU3OS4xLjMzOC4wNzEuNjkuMDM2IDEuMDA2LS4xLjIyNy0uMDc2LjI3Ny0uNDguMzc3LS43MDZhLjguOCAwIDAgMC0uMTUxLS42MzEgMi4xOSAyLjE5IDAgMCAwLS45MDYtLjc1NiA5LjEzIDkuMTMgMCAwIDEtLjY3OS0uMzUzLjk1Ni45NTYgMCAwIDAtLjI1MS0uMTI2Yy0yLjk2NS0xLjQ4NS05LjA2OS0uMi05LjUzNCAwaC0uMDA5YTguMjU0IDguMjU0IDAgMCAwLTEuMjQ5LjQ3NSAzLjkyMiAzLjkyMiAwIDAgMC0yLjM2NSAyLjQ2NSAzLjgzIDMuODMgMCAwIDAtMS4zMzMgMS41MDljLS40MjguOC0xLjA1NiAxLjUwOS0uOTU2IDIuNDE0LjEuNzguMjc3IDEuNDg0LjQyOCAyLjI4OS4wNDMuMjcyLjExLjU0LjIuOC4xLjI3NiAwIC42MjkuMTUxLjg1NS4wNzUuMTUuMDI1LjMyNy4yMjcuNDI4di4yYy4wNS4wNS4wNS4xLjE1MS4xdi4yYy40MzUuNDIzLjgwNy45MDYgMS4xMDcgMS40MzQuMS4yNzYtLjQ3OC4xNS0uNy4wNWE1Ljk3NyA1Ljk3NyAwIDAgMS0xLjEzMi0uOTU2LjE3Ni4xNzYgMCAwIDAtLjA1MS4xYy4yLjM1Mi45MDYuNzguNTI4IDEuMDA2LS4yLjEtLjQyOC0uMTUxLS42MjkuMDUtLjA1LjA3NiAwIC4xNzcgMCAuMjc3LS4yNzctLjItLjU3OC0uMS0uODU1LS4yLS4yLS4wNS0uMjUyLS40MjctLjQ3OC0uNDI3YTE1LjE5MSAxNS4xOTEgMCAwIDAtMS44MTEtLjMyNyAxNS4xNDQgMTUuMTQ0IDAgMCAwLTEuNzM5LS4xNlYxOS43MDdhLjYwNi42MDYgMCAwIDEgLjMwNi0uNTI0bDE0Ljk4Ny04Ljc2MSAxNC45OTQgOC42NzdhLjYwNS42MDUgMCAwIDEgLjMwNi41MjR2MTYuOTMyWm0tNy45NTQtOC4yNjFhLjMyNS4zMjUgMCAwIDEtLjI4Mi4xNDkgMi44NCAyLjg0IDAgMCAwLS4yODIuMjczYy4xIDAgMCAuMTQ5LjEuMTQ5LS4yMDUuMjIzLjA3Ny42OTQtLjIwNS43OTMtLjM3LjA5OS0uNzU4LjA5OS0xLjEyNyAwYS43MjcuNzI3IDAgMCAxIC4xNjctLjAxNmguMDg1YS4zODIuMzgyIDAgMCAwIC4zMzctLjEzMnYtLjJjMC0uMDUtLjA1MS0uMDUtLjEtLjA1YS4xNi4xNiAwIDAgMS0uMS4wNS4yMjMuMjIzIDAgMCAwLS4xNTQtLjIuODA2LjgwNiAwIDAgMS0uNzE4LS4yNzMuNjcuNjcgMCAwIDEgLjQzNi0uMDVjLjEyOCAwIC4wNzctLjIyMy4yMzEtLjMyMmguMTU0Yy4zMDctLjM3Mi44NzEtLjQ3MS45NzQtLjg0MyAwLS4xLS4yODItLjEtLjQ4Ny0uMTVhMi4yNiAyLjI2IDAgMCAwLS44Mi4wNWMtLjM2LjA1LS43MTIuMTQyLTEuMDUxLjI3NC4yOC0uMjA2LjU5Mi0uMzY1LjkyMy0uNDcxLjIzMi0uMDkuNDczLS4xNTcuNzE4LS4ybC4xMzItLjAyNi4xMzMtLjAyN2EuOTcuOTcgMCAwIDEgLjU1NiAwYy4yMzEuMS42MTUuMS42NjYuMjQ4LjEuMjczLS4xNTQuNTQ1LS40MzUuNzQ0LS4wNTcuMDguMTQ5LjEzNS4xNDkuMjNaJy8+PHJlY3Qgd2lkdGg9JzI5LjU2JyBoZWlnaHQ9JzEzLjMwMicgeD0nMzcnIHk9JzUnIGZpbGw9JyNGQ0M2M0EnIHJ4PScyJy8+PHBhdGggZmlsbD0nIzE2MTYxNicgZD0nTTM5LjU2MiAxNi4xNjhWNy4zMTZoMi45MjFjLjk3IDAgMS43MzIuMjM2IDIuMjg5LjcwOC41NjUuNDcyLjg0NyAxLjExNy44NDcgMS45MzUgMCAuODEtLjI4MiAxLjQ1LS44NDcgMS45MjItLjU1Ny40NzItMS4zMi43MDgtMi4yODkuNzA4aC0xLjEyNXYzLjU3OWgtMS43OTZabTIuOTk3LTcuMzIyaC0xLjIwMXYyLjIxM2gxLjJjLjM4IDAgLjY3NS0uMDk3Ljg4Ni0uMjkuMjItLjE5NS4zMjktLjQ3My4zMjktLjgzNiAwLS4zMzctLjExLS42MDItLjMyOS0uNzk2LS4yMS0uMTk0LS41MDYtLjI5MS0uODg1LS4yOTFaTTQ3LjIzIDE2LjE2OFY3LjMxNmgyLjcwN2MuOTcgMCAxLjczNi4yMzYgMi4zMDEuNzA4LjU2NS40NzIuODQ3IDEuMTE3Ljg0NyAxLjkzNSAwIC41My0uMTI2Ljk5NS0uMzc5IDEuMzktLjI0NC4zODktLjU5LjY4OC0xLjAzNy44OTlsMi43ODIgMy45MmgtMi4xNWwtMi4zNTItMy41NzloLS45MjN2My41NzloLTEuNzk1Wm0yLjgwOC03LjMyMmgtMS4wMTJ2Mi4yMTNoMS4wMTJjLjM4IDAgLjY3NC0uMDk3Ljg4NS0uMjkuMjEtLjE5NS4zMTYtLjQ3My4zMTYtLjgzNiAwLS4zMzctLjEwNS0uNjAyLS4zMTYtLjc5Ni0uMjEtLjE5NC0uNTA2LS4yOTEtLjg4NS0uMjkxWk01OS41NDkgNy4wNjNjLjY5IDAgMS4zMjMuMTI2IDEuODk2LjM4LjU4Mi4yNTIgMS4wOC41OSAxLjQ5MiAxLjAxMS40MTQuNDIxLjczNC45MTkuOTYyIDEuNDkyLjIyNy41NjUuMzQxIDEuMTY0LjM0MSAxLjc5NiAwIC42MzItLjExNCAxLjIzNS0uMzQxIDEuODA4YTQuNDg1IDQuNDg1IDAgMCAxLS45NjIgMS40OGMtLjQxMy40MjEtLjkxLjc1OC0xLjQ5MiAxLjAxMWE0LjY0OCA0LjY0OCAwIDAgMS0xLjg5Ni4zOCA0LjczOCA0LjczOCAwIDAgMS0zLjQwMi0xLjM5MSA0LjQ4NCA0LjQ4NCAwIDAgMS0uOTYxLTEuNDggNC44NTUgNC44NTUgMCAwIDEtLjM0Mi0xLjgwOGMwLS42MzMuMTE0LTEuMjMxLjM0Mi0xLjc5Ni4yMjctLjU3My41NDgtMS4wNy45NjEtMS40OTIuNDEzLS40MjIuOTEtLjc1OSAxLjQ5Mi0xLjAxMmE0LjczNyA0LjczNyAwIDAgMSAxLjkxLS4zNzlabTAgNy42NzZhMi44IDIuOCAwIDAgMCAxLjEzOC0uMjI4Yy4zNTQtLjE2LjY1My0uMzcuODk4LS42MzIuMjUyLS4yNy40NS0uNTg2LjU5NC0uOTQ5YTMuMjcgMy4yNyAwIDAgMCAuMjE1LTEuMTg4IDMuMTcgMy4xNyAwIDAgMC0uMjE1LTEuMTc2IDIuNzkxIDIuNzkxIDAgMCAwLS41OTUtLjk0OSAyLjU0OCAyLjU0OCAwIDAgMC0uODk3LS42MzIgMi42NzMgMi42NzMgMCAwIDAtMS4xMzgtLjI0Yy0uNDEzIDAtLjc5Ny4wOC0xLjE1MS4yNGEyLjY3OCAyLjY3OCAwIDAgMC0uOTEuNjMyIDIuODk5IDIuODk5IDAgMCAwLS41ODIuOTQ5IDMuMTcgMy4xNyAwIDAgMC0uMjE1IDEuMTc2YzAgLjQyMS4wNzEuODE3LjIxNSAxLjE4OC4xNDMuMzYzLjMzNy42NzkuNTgxLjk0OS4yNTMuMjYxLjU1Ny40NzIuOTEuNjMyLjM1NS4xNTIuNzM5LjIyOCAxLjE1Mi4yMjhaJy8+PC9zdmc+"); + background-position: 50% 50%; + background-repeat: no-repeat; + width: 214px; + height: 56px; + border: none; + cursor: pointer !important; +} + +.proconnect-button:hover { + background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPScyMTEnIGhlaWdodD0nNTgnIGZpbGw9J25vbmUnPjxnIGNsaXAtcGF0aD0ndXJsKCNhKSc+PHBhdGggZmlsbD0nIzEyMTJGRicgZD0nTTIxMSAwSDB2NThoMjExVjBaJy8+PHBhdGggZmlsbD0nI2ZmZicgZD0nbTY5Ljk4NiAyNi4zNjggMS4xNTYtMS4wNzFjLjgzMyAxLjA1NCAxLjgxOSAxLjU5OCAyLjk0MSAxLjU5OCAxLjI5MiAwIDIuMDQtLjgxNiAyLjA0LTEuOTA0IDAtMi41NS01LjYyNy0yLjI0NC01LjYyNy02LjAzNSAwLTEuNzM0IDEuNDI4LTMuMTk2IDMuNDUxLTMuMTk2IDEuNjgzIDAgMi45MDcuNzY1IDMuNzkxIDEuOTM4bC0xLjE5IDEuMDM3Yy0uNjk3LTEuMDAzLTEuNTQ3LTEuNTQ3LTIuNTg0LTEuNTQ3LTEuMTA1IDAtMS44MzYuNzQ4LTEuODM2IDEuNzM0IDAgMi41NjcgNS42MjcgMi4yNDQgNS42MjcgNi4wNTIgMCAyLjAyMy0xLjU4MSAzLjM0OS0zLjY1NSAzLjM0OS0xLjc2OCAwLTMuMDc3LS42NjMtNC4xMTQtMS45NTVabTEwLjgxNy01LjcxMkg3OS40NmwxLjQ0NS00LjU1NmgxLjY0OWwtMS43NTEgNC41NTZabTQuODE4LTMuNDUxYy0uNTYgMC0xLjAyLS40NTktMS4wMi0xLjAyYTEuMDIgMS4wMiAwIDAgMSAxLjAyLTEuMDAzYy41NjEgMCAxLjAwMy40NTkgMS4wMDMgMS4wMDMgMCAuNTYxLS40NDIgMS4wMi0xLjAwMyAxLjAyWk04NC44OTEgMjh2LTguNTY4aDEuNDQ0VjI4SDg0Ljg5Wm0zLjc2Ny00LjI4NGMwLTIuNDk5IDEuNzE3LTQuNjI0IDQuNDAzLTQuNjI0IDEuMjQxIDAgMi4yNjEuNDU5IDMuMDQzIDEuMjkyVjE1LjI1aDEuNDQ1VjI4aC0xLjQ0NXYtLjk1MmMtLjc4Mi44MzMtMS44MDIgMS4yOTItMy4wNDMgMS4yOTItMi42ODYgMC00LjQwMy0yLjEyNS00LjQwMy00LjYyNFptMS41MyAwYzAgMS44MTkgMS4yMjQgMy4yNjQgMy4wNDMgMy4yNjQgMS4xOSAwIDIuMjEtLjU3OCAyLjg3My0xLjU5OFYyMi4wNWMtLjY4LTEuMDM3LTEuNy0xLjU5OC0yLjg3My0xLjU5OC0xLjgxOSAwLTMuMDQzIDEuNDQ1LTMuMDQzIDMuMjY0Wm0xOC4wMjMgMi44NzNjLS43OTkgMS4wNzEtMi4wNzQgMS43NTEtMy42NzIgMS43NTEtMi44OSAwLTQuNjc1LTIuMTI1LTQuNjc1LTQuNjI0IDAtMi42MDEgMS42NjYtNC42MjQgNC4zMTgtNC42MjQgMi4zMjkgMCAzLjg0MiAxLjU4MSAzLjg0MiAzLjcyMyAwIC4zNC0uMDUxLjY4LS4xMDIuOTE4aC02LjU2MnYuMDM0YzAgMS44ODcgMS4yOTIgMy4yNjQgMy4yMTMgMy4yNjQgMS4wODggMCAyLjAwNi0uNTEgMi41NjctMS4yNzVsMS4wNzEuODMzWm0tNC4wMTItNi4yNTZjLTEuMzk0IDAtMi4zOC43ODItMi43MiAyLjI2MWg1LjA4M2MtLjA1MS0xLjI0MS0uOTUyLTIuMjYxLTIuMzYzLTIuMjYxWk0xMTAuNDczIDI4di04LjU2OGgxLjQ0NXYuOTY5Yy42OTctLjc2NSAxLjU4MS0xLjMwOSAyLjg1Ni0xLjMwOSAxLjkyMSAwIDMuMzQ5IDEuMjkyIDMuMzQ5IDMuNzIzVjI4aC0xLjQ2MnYtNS4xMzRjMC0xLjUzLS44NS0yLjQxNC0yLjE3Ni0yLjQxNC0xLjI0MSAwLTIuMDIzLjcxNC0yLjU2NyAxLjYxNVYyOGgtMS40NDVabTExLjA1Mi0yLjg3M3YtNC4zNjloLTEuNjE1di0xLjMyNmgxLjYxNVYxNy4yOWgxLjQ2MnYyLjE0MmgyLjk3NXYxLjMyNmgtMi45NzV2NC4zNjljMCAxLjM0My42OCAxLjcxNyAxLjcxNyAxLjcxNy41NjEgMCAuOTUyLS4wNjggMS4yNzUtLjIwNHYxLjI5MmMtLjQwOC4xNy0uODY3LjIzOC0xLjQ3OS4yMzgtMS45MDQgMC0yLjk3NS0uOTUyLTIuOTc1LTMuMDQzWm03LjM3Ny03LjkyMmMtLjU2MSAwLTEuMDItLjQ1OS0xLjAyLTEuMDJhMS4wMiAxLjAyIDAgMCAxIDEuMDItMS4wMDNjLjU2MSAwIDEuMDAzLjQ1OSAxLjAwMyAxLjAwMyAwIC41NjEtLjQ0MiAxLjAyLTEuMDAzIDEuMDJaTTEyOC4xNzEgMjh2LTguNTY4aDEuNDQ1VjI4aC0xLjQ0NVptMy4zNzctOC41NjhoMS42MTV2LTEuMDU0YzAtMS44MzYgMS4yMDctMy4xMjggMy4wNDMtMy4xMjguOTUyIDAgMS43LjM0IDIuMjEuODMzbC0uOTAxIDEuMDU0YTEuNjMzIDEuNjMzIDAgMCAwLTEuMjkyLS41NzhjLS45MzUgMC0xLjU5OC42OC0xLjU5OCAxLjc4NXYxLjA4OGgyLjk3NXYxLjMyNmgtMi45NzVWMjhoLTEuNDYydi03LjI0MmgtMS42MTV2LTEuMzI2Wm04LjU0My0yLjIyN2MtLjU2MSAwLTEuMDItLjQ1OS0xLjAyLTEuMDJhMS4wMiAxLjAyIDAgMCAxIDEuMDItMS4wMDNjLjU2MSAwIDEuMDAzLjQ1OSAxLjAwMyAxLjAwMyAwIC41NjEtLjQ0MiAxLjAyLTEuMDAzIDEuMDJaTTEzOS4zNiAyOHYtOC41NjhoMS40NDVWMjhoLTEuNDQ1Wm0xMi4xMTUtMS40MTFjLS43OTkgMS4wNzEtMi4wNzQgMS43NTEtMy42NzIgMS43NTEtMi44OSAwLTQuNjc1LTIuMTI1LTQuNjc1LTQuNjI0IDAtMi42MDEgMS42NjYtNC42MjQgNC4zMTgtNC42MjQgMi4zMjkgMCAzLjg0MiAxLjU4MSAzLjg0MiAzLjcyMyAwIC4zNC0uMDUxLjY4LS4xMDIuOTE4aC02LjU2MnYuMDM0YzAgMS44ODcgMS4yOTIgMy4yNjQgMy4yMTMgMy4yNjQgMS4wODggMCAyLjAwNi0uNTEgMi41NjctMS4yNzVsMS4wNzEuODMzWm0tNC4wMTItNi4yNTZjLTEuMzk0IDAtMi4zOC43ODItMi43MiAyLjI2MWg1LjA4M2MtLjA1MS0xLjI0MS0uOTUyLTIuMjYxLTIuMzYzLTIuMjYxWk0xNTMuNzM3IDI4di04LjU2OGgxLjQ0NXYxLjA3MWMuNjI5LS43NDggMS40MTEtMS4yNDEgMi40OTktMS4yNDEuMjcyIDAgLjUyNy4wMzQuNzMxLjEwMnYxLjQ5NmEzLjEwNSAzLjEwNSAwIDAgMC0uODUtLjExOWMtMS4xMjIgMC0xLjg1My41NzgtMi4zOCAxLjQ0NVYyOGgtMS40NDVabTEzLjY4NS4zNGMtMS42ODMgMC0yLjgyMi0uOTUyLTIuODIyLTIuNDQ4IDAtMS4zMjYuOTg2LTIuMjc4IDIuODIyLTIuNTY3bDIuODczLS40NzZ2LS41OTVjMC0xLjE5LS44NS0xLjg3LTIuMDU3LTEuODctMS4wMDMgMC0xLjgzNi40NDItMi4zMjkgMS4xOWwtMS4wODgtLjgzM2MuNzQ4LTEuMDIgMS45NTUtMS42NDkgMy40NTEtMS42NDkgMi4xNzYgMCAzLjQ2OCAxLjI3NSAzLjQ2OCAzLjE2MlYyOGgtMS40NDV2LTEuMDg4Yy0uNjQ2LjkwMS0xLjcxNyAxLjQyOC0yLjg3MyAxLjQyOFptLTEuMzc3LTIuNDk5YzAgLjczMS42MjkgMS4yOTIgMS42MTUgMS4yOTIgMS4xMzkgMCAyLjA0LS41OTUgMi42MzUtMS41ODFWMjMuOTJsLTIuNTMzLjQ0MmMtMS4xOS4xODctMS43MTcuNzMxLTEuNzE3IDEuNDc5Wm03LjI1Mi02LjQwOWgxLjU2NGwyLjczNyA3LjA1NSAyLjczNy03LjA1NWgxLjU2NEwxNzguNTUgMjhoLTEuOTA0bC0zLjM0OS04LjU2OFptMTcuODU2IDcuMTU3Yy0uNzk5IDEuMDcxLTIuMDc0IDEuNzUxLTMuNjcyIDEuNzUxLTIuODkgMC00LjY3NS0yLjEyNS00LjY3NS00LjYyNCAwLTIuNjAxIDEuNjY2LTQuNjI0IDQuMzE4LTQuNjI0IDIuMzI5IDAgMy44NDIgMS41ODEgMy44NDIgMy43MjMgMCAuMzQtLjA1MS42OC0uMTAyLjkxOGgtNi41NjJ2LjAzNGMwIDEuODg3IDEuMjkyIDMuMjY0IDMuMjEzIDMuMjY0IDEuMDg4IDAgMi4wMDYtLjUxIDIuNTY3LTEuMjc1bDEuMDcxLjgzM1ptLTQuMDEyLTYuMjU2Yy0xLjM5NCAwLTIuMzguNzgyLTIuNzIgMi4yNjFoNS4wODNjLS4wNTEtMS4yNDEtLjk1Mi0yLjI2MS0yLjM2My0yLjI2MVptMTAuMTg1IDYuNjQ3YzEuMDU0IDAgMS45MDQtLjUxIDIuNDMxLTEuMjc1bDEuMTU2Ljg4NGMtLjc5OSAxLjA3MS0yLjA0IDEuNzUxLTMuNjA0IDEuNzUxLTIuODM5IDAtNC42NTgtMi4xMjUtNC42NTgtNC42MjQgMC0yLjQ5OSAxLjgxOS00LjYyNCA0LjY1OC00LjYyNCAxLjU0NyAwIDIuODA1LjY5NyAzLjYwNCAxLjc1MWwtMS4xNTYuODg0YTIuOTI1IDIuOTI1IDAgMCAwLTIuNDQ4LTEuMjc1Yy0xLjgzNiAwLTMuMTQ1IDEuNDQ1LTMuMTQ1IDMuMjY0IDAgMS44MzYgMS4zMDkgMy4yNjQgMy4xNjIgMy4yNjRaTTcwLjg1NCA0NVYzMi40aDQuMTU4YzIuNzcyIDAgNC40NjQgMS40MjIgNC40NjQgMy43NjIgMCAyLjMyMi0xLjY5MiAzLjc0NC00LjQ2NCAzLjc0NEg3My40MVY0NWgtMi41NTZabTQuMjY2LTEwLjQyMmgtMS43MXYzLjE1aDEuNzFjMS4wOCAwIDEuNzI4LS41NzYgMS43MjgtMS42MDIgMC0uOTU0LS42NDgtMS41NDgtMS43MjgtMS41NDhaTTgxLjI0OSA0NXYtOS4wNzJoMi4yODZ2LjljLjU5NC0uNjEyIDEuMzY4LTEuMDggMi4zOTQtMS4wOC4zMDYgMCAuNTc2LjA1NC43OTIuMTI2djIuMzk0YTMuOTM4IDMuOTM4IDAgMCAwLTEuMDA4LS4xMjZjLTEuMTE2IDAtMS44MzYuNjEyLTIuMTc4IDEuMTdWNDVoLTIuMjg2Wm0xMS4zODYtOS40MzJjMi45NTIgMCA0Ljk2OCAyLjE3OCA0Ljk2OCA0Ljg5NnMtMi4wMTYgNC44OTYtNC45NjggNC44OTYtNC45NjgtMi4xNzgtNC45NjgtNC44OTYgMi4wMTYtNC44OTYgNC45NjgtNC44OTZabS4wMzYgNy42MzJjMS40NTggMCAyLjU1Ni0xLjE3IDIuNTU2LTIuNzM2IDAtMS41ODQtMS4wOTgtMi43MzYtMi41NTYtMi43MzYtMS41MTIgMC0yLjYyOCAxLjE1Mi0yLjYyOCAyLjczNiAwIDEuNTg0IDEuMTE2IDIuNzM2IDIuNjI4IDIuNzM2Wm0xMy4xNzItLjIzNGMxLjQ0IDAgMi41NzQtLjcwMiAzLjI5NC0xLjcyOGwyLjAxNiAxLjU0OGMtMS4xNTIgMS41NjYtMy4wMjQgMi41NzQtNS4zMSAyLjU3NC0zLjk3OCAwLTYuNjk2LTMuMDYtNi42OTYtNi42NnMyLjcxOC02LjY2IDYuNjk2LTYuNjZjMi4yODYgMCA0LjE1OCAxLjAyNiA1LjMxIDIuNTU2bC0yLjAxNiAxLjU2NmMtLjcyLTEuMDI2LTEuODU0LTEuNzI4LTMuMjk0LTEuNzI4LTIuMzc2IDAtNC4wNjggMS44NTQtNC4wNjggNC4yNjZzMS42OTIgNC4yNjYgNC4wNjggNC4yNjZabTExLjM2Ni03LjM5OGMyLjk1MiAwIDQuOTY4IDIuMTc4IDQuOTY4IDQuODk2cy0yLjAxNiA0Ljg5Ni00Ljk2OCA0Ljg5Ni00Ljk2OC0yLjE3OC00Ljk2OC00Ljg5NiAyLjAxNi00Ljg5NiA0Ljk2OC00Ljg5NlptLjAzNiA3LjYzMmMxLjQ1OCAwIDIuNTU2LTEuMTcgMi41NTYtMi43MzYgMC0xLjU4NC0xLjA5OC0yLjczNi0yLjU1Ni0yLjczNi0xLjUxMiAwLTIuNjI4IDEuMTUyLTIuNjI4IDIuNzM2IDAgMS41ODQgMS4xMTYgMi43MzYgMi42MjggMi43MzZabTcuMDE4IDEuOHYtOS4wNzJoMi4yODZ2LjcyYy42My0uNjEyIDEuNDc2LTEuMDggMi42ODItMS4wOCAxLjk2MiAwIDMuNTI4IDEuMzUgMy41MjggNC4wMzJWNDVoLTIuMzIydi01LjMxYzAtMS4yMDYtLjY2Ni0xLjk2Mi0xLjc4Mi0xLjk2Mi0xLjE1MiAwLTEuNzY0Ljc3NC0yLjEwNiAxLjM1VjQ1aC0yLjI4NlptMTEuMDkxIDB2LTkuMDcyaDIuMjg2di43MmMuNjMtLjYxMiAxLjQ3Ni0xLjA4IDIuNjgyLTEuMDggMS45NjIgMCAzLjUyOCAxLjM1IDMuNTI4IDQuMDMyVjQ1aC0yLjMyMnYtNS4zMWMwLTEuMjA2LS42NjYtMS45NjItMS43ODItMS45NjItMS4xNTIgMC0xLjc2NC43NzQtMi4xMDYgMS4zNVY0NWgtMi4yODZabTE5LjQ0NC0xLjQ3NmMtLjg0NiAxLjEzNC0yLjI1IDEuODM2LTMuOTYgMS44MzYtMy4yMjIgMC01LjA0LTIuMjUtNS4wNC00Ljg5NiAwLTIuNjgyIDEuNjkyLTQuODk2IDQuNjYyLTQuODk2IDIuNTIgMCA0LjE3NiAxLjY5MiA0LjE3NiA0LjA2OCAwIC41MDQtLjA3Mi45OS0uMTQ0IDEuMjk2aC02LjM1NGMuMTQ0IDEuNDk0IDEuMTg4IDIuMzc2IDIuNzM2IDIuMzc2Ljk5IDAgMS44LS40MzIgMi4yODYtMS4wOGwxLjYzOCAxLjI5NlptLTQuMzM4LTYuMDQ4Yy0xLjExNiAwLTEuODcyLjU0LTIuMTc4IDEuNzI4aDQuMDg2Yy0uMDM2LS45LS43MDItMS43MjgtMS45MDgtMS43MjhabTEwLjY5NiA1LjcyNGMuODgyIDAgMS41ODQtLjQzMiAyLjAxNi0xLjA2MmwxLjgxOCAxLjM4NmMtLjg0NiAxLjExNi0yLjE3OCAxLjgzNi0zLjgzNCAxLjgzNi0zLjEzMiAwLTUuMDA0LTIuMjUtNS4wMDQtNC44OTZzMS44NzItNC44OTYgNS4wMDQtNC44OTZjMS42NTYgMCAyLjk4OC43MiAzLjgzNCAxLjgzNmwtMS44MTggMS4zODZjLS40MzItLjYzLTEuMTE2LTEuMDYyLTIuMDUyLTEuMDYyLTEuNDk0IDAtMi41OTIgMS4xNTItMi41OTIgMi43MzYgMCAxLjYwMiAxLjA5OCAyLjczNiAyLjYyOCAyLjczNlptNi4yMDQtMS41MTJ2LTMuNjcyaC0xLjY5MnYtMi4wODhoMS42OTJWMzMuNjZoMi4zMDR2Mi4yNjhoMi43NzJ2Mi4wODhoLTIuNzcydjMuNjcyYzAgMS4wMDguNTQgMS40MDQgMS40NCAxLjQwNC42MyAwIDEuMDQ0LS4wNzIgMS4zNS0uMTk4djEuOTk4Yy0uNDUuMTk4LS45OS4yODgtMS43NDYuMjg4LTIuMjY4IDAtMy4zNDgtMS4yNzgtMy4zNDgtMy40OTJaJy8+PHBhdGggZmlsbD0nIzAwMDA5MScgZD0nTTQ2Ljk5MiAxOS4wOTggMzEuOTk4IDEwLjQybC0xNC45OTQgOC43NmEuNjA2LjYwNiAwIDAgMC0uMzA2LjUyNXYxNi45NDhhLjY2Ni42NjYgMCAwIDAgLjMwNi41MjRsMTQuOTkyIDguNiAxNC45OTQtOC43MDZhLjY2Ni42NjYgMCAwIDAgLjMwNi0uNTI0VjE5LjYyNmEuNjA0LjYwNCAwIDAgMC0uMzA0LS41MjhaJy8+PHBhdGggZmlsbD0nI0ZDQzYzQScgZD0nbTI2LjY0MSAxOS41OTgtNS4wMjkgOC42MjgtNC41NTctOS4xNzUgNS4zOS0zLjExMyA0LjQ4OSAzLjE2LS4yOTMuNVptMjAuNjU2IDE2Ljk4VjE5LjYyYS42LjYgMCAwIDAtLjMwNi0uNTIzTDMxLjk5OCAxMC40MicvPjxwYXRoIGZpbGw9JyMwMDYzQ0InIGQ9J00xNi43IDM2LjU3OCAzMiAxMC40MnYzNS4zNjJsLTE0Ljk5Ni04LjYwNWEuNjY1LjY2NSAwIDAgMS0uMzA2LS41MjRWMTkuNzA2bC4wMDIgMTYuODcyWm0yNC42NjktMjAuNzM1IDUuNDU4IDMuMTU1LTQuNDg5IDkuMTUtNS4zODctOS4yMzYgNC40MTgtMy4wN1onLz48cGF0aCBmaWxsPScjZmZmJyBkPSdtNTEuNjA2IDE2LjMwMy0xOS4xOS0xMS4wMmEuOTMzLjkzMyAwIDAgMC0uODMyIDBsLTE5LjE5IDExLjAyYS44ODcuODg3IDAgMCAwLS4zOTQuNjk1djIyYS44ODUuODg1IDAgMCAwIC4zOTQuN2wxOS4xODkgMTEuMDJhLjkzMi45MzIgMCAwIDAgLjgzMiAwbDE5LjE5MS0xMS4wMmEuODg2Ljg4NiAwIDAgMCAuMzk0LS43di0yMmEuODg3Ljg4NyAwIDAgMC0uMzk0LS42OTVaTTIyLjc4OSAzNC4wNTloLjA3OWMtLjA0MiAwLS4wNzkuMDA3LS4wNzkuMDUgMCAuMS4xNTEgMCAuMi4xYS45MTIuOTEyIDAgMCAwLS42MjkuMjc2YzAgLjA1LjEuMDUuMTUxLjA1LS4wNzUuMS0uMjI2LjA1LS4yNzcuMTUyYS4xNzYuMTc2IDAgMCAwIC4xLjA1Yy0uMDUgMC0uMSAwLS4xLjA1di4xNTJjLS4xMjYgMC0uMTc2LjEtLjI3Ny4xNS4yLjE1Mi4zMjcgMCAuNTI4IDAtLjUyOC4yLS45NTYuNDc5LTEuNDg0LjYzLS4xIDAgMCAuMTUtLjEuMTUuMTUxLjEuMjI3LS4wNS4zNzctLjA1LS42NTQuMzc4LTEuMzMzLjctMi4wMzcgMS4xMzNhLjM1MS4zNTEgMCAwIDAtLjEuMmgtLjJjLS4xLjA1LS4wNS4xNzYtLjE1MS4yNzcuMjI2LjE1LjUtLjIuNjU0IDAgLjA1IDAtLjEuMDUtLjIuMDUtLjA1IDAtLjA1LjEtLjEuMWgtLjE1NGMtLjEuMDc1LS4yLjEyNi0uMi4yNzZhLjIyLjIyIDAgMCAwLS4yMjYuMSA5LjAzMSA5LjAzMSAwIDAgMCAzLjE0NC0uNTc4IDcuNjgzIDcuNjgzIDAgMCAwIDIuMDg4LTEuNTYuMTc2LjE3NiAwIDAgMSAuMDUuMWMtLjE0Ny40MzctLjQzLjgxNi0uODA2IDEuMDgtLjI3Ny4xNTItLjQ3OC4zNzgtLjcuNDc5YTQuMDU3IDQuMDU3IDAgMCAwLS40MjguMjc2Yy0uNjMyLjE5Ny0xLjI4MS4zMzUtMS45MzkuNDEybC0uMzA1LjA0NGMtLjIyNS4wMzMtLjQ0OS4wNjktLjY3MS4xMDhsLTEuOTkzLTEuMTM4YS42NDcuNjQ3IDAgMCAxLS4yODgtLjQxMS41Ny41NyAwIDAgMCAuMDk0LS4wNjMuMjY2LjI2NiAwIDAgMC0uMTEzLS4wNzF2LS42NWExMi43ODIgMTIuNzgyIDAgMCAwIDMuMDM4LS45NDIgOC43NDYgOC43NDYgMCAwIDAtMy4wMzctMS4zNDN2LTEuNTE1YTExLjY3IDExLjY3IDAgMCAxIDEuNjM5LjM5MiA2LjQyIDYuNDIgMCAwIDEgMS4xODIuNTc4Yy4xNDcuMTQuMzA3LjI2Ny40NzguMzc3YS45MS45MSAwIDAgMCAuOC4wNWguMzNhMy45NjEgMy45NjEgMCAwIDAgMS45MzctLjkwNWMwIC4wNS4wNS4wNS4xLjA1YTMuNjI5IDMuNjI5IDAgMCAxLS40MjggMS4xMzJjLjAwMy4wNS0uMDQ4LjE1Mi4wNTMuMjAyWm0yLjgxNyAzLjU3Yy4yNTEtLjEuNC0uMjc2LjYyOS0uMzc2LS4wNS4wNS0uMDUuMTUtLjEuMmEzLjY5OSAzLjY5OSAwIDAgMC0uNTI4LjQgMTUuOTY1IDE1Ljk2NSAwIDAgMC0xLjU4NSAxLjYxYy0uMjUyLjMtLjUyOC41NzgtLjguODU1LS4wOTYuMDktLjIuMTcyLS4zMS4yNDVsLTIuNTI3LTEuNDVjLjM2LjAzLjcyMS4wMTMgMS4wNzYtLjA1My4yOTQtLjA4My41OC0uMTkyLjg1NS0uMzI3di4xYy43LS4yNzcgMS4yMzItLjkwNiAxLjkzNy0xLjEzMi4wMjUgMCAuMTI2LjEuMjI2LjA1YTEuODgzIDEuODgzIDAgMCAxIDEuNTA5LS43YzAgLjA1IDAgLjEuMDUuMWguMDI1Yy0uMTUxLjEyNi0uMzI3LjI1LS41LjM3Ny0uMDU3LjA1Mi0uMDA3LjEwMi4wNDMuMTAyWm0tOC45MDgtNi4xNjN2LS4xODZhNS44MTcgNS44MTcgMCAwIDEgMS41ODgtLjE4OCAxLjUyIDEuNTIgMCAwIDEgLjQ3OCAwIDUuODYgNS44NiAwIDAgMC0yLjA2Ni4zNzRabTMwLjYgNS4wODhhLjY2NS42NjUgMCAwIDEtLjMwNi41MjRsLTEwLjA3OSA1Ljg1YTMyLjI5NiAzMi4yOTYgMCAwIDEtMy40MDgtMS4xODQgMi44MjYgMi44MjYgMCAwIDEtLjA1LTIuMjQ1Yy4wOC0uMzA4LjE5OC0uNjA1LjM1Mi0uODgzLjAyNS0uMDI1LjA1LS4wNS4wNS0uMDc2YS4wMjUuMDI1IDAgMCAwIC4wMjUtLjAyNSA0LjMyIDQuMzIgMCAwIDEgLjM3Ny0uNTU1bC4wMTUtLjAxNS4wMi0uMDIxLjAxNS0uMDE1YzAtLjAyNS4wMjUtLjA1LjA1LS4wNzYuMDI1LS4wNTEuMDc1LS4wNzYuMS0uMTI2LjE3Ni0uMTg2LjM3LS4zNTQuNTc5LS41LjIxMy0uMDc3LjQzMS0uMTM2LjY1NC0uMTc3LjgxMS4wNiAxLjYxNy4xNyAyLjQxNS4zMjhhLjc1Mi43NTIgMCAwIDEgLjI3Ny4xYy4zMDEuMDU5LjYxMi4wNDEuOTA1LS4wNWExLjEzNyAxLjEzNyAwIDAgMCAuODU1LS43MDYgMS4yMTIgMS4yMTIgMCAwIDAgLjA1LTEuMDZjLS4xNzgtLjI3NS0uMDEzLS40MzYuMTgxLS41OWwuMDY4LS4wNTRjLjA4Ni0uMDYxLjE2NC0uMTM0LjIzMS0uMjE2LjEyNi0uMjUyLS4xLS40LS4xNTEtLjYzLS4wNS0uMS0uMjI2LS4wNS0uMzI3LS4yLjM1Mi0uMTUxLjg1NS0uNDMuNjI5LS44NTctLjE1MS0uMjI3LS4zNzctLjYzLS4xLS44NTcuMzUyLS4yLjg1NS0uMTUxIDEuMDA2LS40OGExLjEzNyAxLjEzNyAwIDAgMC0uMjkyLTEuMDg0bC0uMDc1LS4xMDhhNC43NTQgNC43NTQgMCAwIDEtLjIxMS0uMzIgNi45MDUgNi45MDUgMCAwIDAtLjUyOC0uNzU3IDQuMjk3IDQuMjk3IDAgMCAxLS41MjgtMS4wMWMtLjE1MS0uMzc3LjA1LS43MDUuMDUtMS4wODNhNi4zNDcgNi4zNDcgMCAwIDAtLjMyNy0yLjE0NGMtLjEyNi0uMzUzLS4xNzYtLjczMS0uMzI3LTEuMDZhMS4xMiAxLjEyIDAgMCAwLS4yMjYtLjU4LjM3NC4zNzQgMCAwIDEgMC0uMzI3Yy4yMDUtLjE0NS4zOTktLjMwNS41NzktLjQ4YS41NjcuNTY3IDAgMCAwLS4yLS43MDVjLS4zMjctLjE1MS0uMy4zMjgtLjUyOC40MjloLS4xNTFjLS4wNS0uMTI2LjA1LS4xNzcuMTUxLS4yNzcgMC0uMDUgMC0uMTUxLS4wNS0uMTUxLS4yIDAtLjM3Ny0uMDUxLS40MjgtLjE1MWEzLjk1NyAzLjk1NyAwIDAgMC0xLjg2MS0xLjI4NmMuMTg4LjA1OC4zODIuMDkxLjU3OS4xLjMzOC4wNzEuNjkuMDM2IDEuMDA2LS4xLjIyNy0uMDc2LjI3Ny0uNDguMzc3LS43MDZhLjguOCAwIDAgMC0uMTUxLS42MzEgMi4xOSAyLjE5IDAgMCAwLS45MDYtLjc1NiA5LjEzIDkuMTMgMCAwIDEtLjY3OS0uMzUzLjk1Ni45NTYgMCAwIDAtLjI1MS0uMTI2Yy0yLjk2NS0xLjQ4NS05LjA2OS0uMi05LjUzNCAwaC0uMDA5YTguMjU0IDguMjU0IDAgMCAwLTEuMjQ5LjQ3NSAzLjkyMiAzLjkyMiAwIDAgMC0yLjM2NSAyLjQ2NSAzLjgzIDMuODMgMCAwIDAtMS4zMzMgMS41MDljLS40MjguOC0xLjA1NiAxLjUwOS0uOTU2IDIuNDE0LjEuNzguMjc3IDEuNDg0LjQyOCAyLjI4OS4wNDMuMjcyLjExLjU0LjIuOC4xLjI3NiAwIC42MjkuMTUxLjg1NS4wNzUuMTUuMDI1LjMyNy4yMjcuNDI4di4yYy4wNS4wNS4wNS4xLjE1MS4xdi4yYy40MzUuNDIzLjgwNy45MDYgMS4xMDcgMS40MzQuMS4yNzYtLjQ3OC4xNS0uNy4wNWE1Ljk3NyA1Ljk3NyAwIDAgMS0xLjEzMi0uOTU2LjE3Ni4xNzYgMCAwIDAtLjA1MS4xYy4yLjM1Mi45MDYuNzguNTI4IDEuMDA2LS4yLjEtLjQyOC0uMTUxLS42MjkuMDUtLjA1LjA3NiAwIC4xNzcgMCAuMjc3LS4yNzctLjItLjU3OC0uMS0uODU1LS4yLS4yLS4wNS0uMjUyLS40MjctLjQ3OC0uNDI3YTE1LjE5MSAxNS4xOTEgMCAwIDAtMS44MTEtLjMyNyAxNS4xNDQgMTUuMTQ0IDAgMCAwLTEuNzM5LS4xNlYxOS43MDdhLjYwNi42MDYgMCAwIDEgLjMwNi0uNTI0bDE0Ljk4Ny04Ljc2MSAxNC45OTQgOC42NzdhLjYwNS42MDUgMCAwIDEgLjMwNi41MjR2MTYuOTMyWm0tNy45NTQtOC4yNjFhLjMyNS4zMjUgMCAwIDEtLjI4Mi4xNDkgMi44NCAyLjg0IDAgMCAwLS4yODIuMjczYy4xIDAgMCAuMTQ5LjEuMTQ5LS4yMDUuMjIzLjA3Ny42OTQtLjIwNS43OTMtLjM3LjA5OS0uNzU4LjA5OS0xLjEyNyAwYS43MjcuNzI3IDAgMCAxIC4xNjctLjAxNmguMDg1YS4zODIuMzgyIDAgMCAwIC4zMzctLjEzMnYtLjJjMC0uMDUtLjA1MS0uMDUtLjEtLjA1YS4xNi4xNiAwIDAgMS0uMS4wNS4yMjMuMjIzIDAgMCAwLS4xNTQtLjIuODA2LjgwNiAwIDAgMS0uNzE4LS4yNzMuNjcuNjcgMCAwIDEgLjQzNi0uMDVjLjEyOCAwIC4wNzctLjIyMy4yMzEtLjMyMmguMTU0Yy4zMDctLjM3Mi44NzEtLjQ3MS45NzQtLjg0MyAwLS4xLS4yODItLjEtLjQ4Ny0uMTVhMi4yNiAyLjI2IDAgMCAwLS44Mi4wNWMtLjM2LjA1LS43MTIuMTQyLTEuMDUxLjI3NC4yOC0uMjA2LjU5Mi0uMzY1LjkyMy0uNDcxLjIzMi0uMDkuNDczLS4xNTcuNzE4LS4ybC4xMzItLjAyNi4xMzMtLjAyN2EuOTcuOTcgMCAwIDEgLjU1NiAwYy4yMzEuMS42MTUuMS42NjYuMjQ4LjEuMjczLS4xNTQuNTQ1LS40MzUuNzQ0LS4wNTcuMDguMTQ5LjEzNS4xNDkuMjNaJy8+PHBhdGggZmlsbD0nI0ZDQzYzQScgZD0nTTY0LjU2IDVIMzlhMiAyIDAgMCAwLTIgMnY5LjMwMmEyIDIgMCAwIDAgMiAyaDI1LjU2YTIgMiAwIDAgMCAyLTJWN2EyIDIgMCAwIDAtMi0yWicvPjxwYXRoIGZpbGw9JyMxNjE2MTYnIGQ9J00zOS41NjIgMTYuMTY4VjcuMzE2aDIuOTIxYy45NyAwIDEuNzMyLjIzNiAyLjI4OS43MDguNTY1LjQ3Mi44NDcgMS4xMTcuODQ3IDEuOTM1IDAgLjgxLS4yODIgMS40NS0uODQ3IDEuOTIyLS41NTcuNDcyLTEuMzIuNzA4LTIuMjg5LjcwOGgtMS4xMjV2My41NzloLTEuNzk2Wm0yLjk5Ny03LjMyMmgtMS4yMDF2Mi4yMTNoMS4yYy4zOCAwIC42NzUtLjA5Ny44ODYtLjI5LjIyLS4xOTUuMzI5LS40NzMuMzI5LS44MzYgMC0uMzM3LS4xMS0uNjAyLS4zMjktLjc5Ni0uMjEtLjE5NC0uNTA2LS4yOTEtLjg4NS0uMjkxWk00Ny4yMyAxNi4xNjhWNy4zMTZoMi43MDdjLjk3IDAgMS43MzYuMjM2IDIuMzAxLjcwOC41NjUuNDcyLjg0NyAxLjExNy44NDcgMS45MzUgMCAuNTMtLjEyNi45OTUtLjM3OSAxLjM5LS4yNDQuMzg5LS41OS42ODgtMS4wMzcuODk5bDIuNzgyIDMuOTJoLTIuMTVsLTIuMzUyLTMuNTc5aC0uOTIzdjMuNTc5aC0xLjc5NVptMi44MDgtNy4zMjJoLTEuMDEydjIuMjEzaDEuMDEyYy4zOCAwIC42NzQtLjA5Ny44ODUtLjI5LjIxLS4xOTUuMzE2LS40NzMuMzE2LS44MzYgMC0uMzM3LS4xMDUtLjYwMi0uMzE2LS43OTYtLjIxLS4xOTQtLjUwNi0uMjkxLS44ODUtLjI5MVpNNTkuNTQ5IDcuMDYzYy42OSAwIDEuMzIzLjEyNiAxLjg5Ni4zOC41ODIuMjUyIDEuMDguNTkgMS40OTIgMS4wMTEuNDE0LjQyMS43MzQuOTE5Ljk2MiAxLjQ5Mi4yMjcuNTY1LjM0MSAxLjE2NC4zNDEgMS43OTYgMCAuNjMyLS4xMTQgMS4yMzUtLjM0MSAxLjgwOGE0LjQ4NSA0LjQ4NSAwIDAgMS0uOTYyIDEuNDhjLS40MTMuNDIxLS45MS43NTgtMS40OTIgMS4wMTFhNC42NDggNC42NDggMCAwIDEtMS44OTYuMzggNC43MzggNC43MzggMCAwIDEtMy40MDItMS4zOTEgNC40ODQgNC40ODQgMCAwIDEtLjk2MS0xLjQ4IDQuODU1IDQuODU1IDAgMCAxLS4zNDItMS44MDhjMC0uNjMzLjExNC0xLjIzMS4zNDItMS43OTYuMjI3LS41NzMuNTQ4LTEuMDcuOTYxLTEuNDkyLjQxMy0uNDIyLjkxLS43NTkgMS40OTItMS4wMTJhNC43MzcgNC43MzcgMCAwIDEgMS45MS0uMzc5Wm0wIDcuNjc2YTIuOCAyLjggMCAwIDAgMS4xMzgtLjIyOGMuMzU0LS4xNi42NTMtLjM3Ljg5OC0uNjMyLjI1Mi0uMjcuNDUtLjU4Ni41OTQtLjk0OWEzLjI3IDMuMjcgMCAwIDAgLjIxNS0xLjE4OCAzLjE3IDMuMTcgMCAwIDAtLjIxNS0xLjE3NiAyLjc5MSAyLjc5MSAwIDAgMC0uNTk1LS45NDkgMi41NDggMi41NDggMCAwIDAtLjg5Ny0uNjMyIDIuNjczIDIuNjczIDAgMCAwLTEuMTM4LS4yNGMtLjQxMyAwLS43OTcuMDgtMS4xNTEuMjRhMi42NzggMi42NzggMCAwIDAtLjkxLjYzMiAyLjg5OSAyLjg5OSAwIDAgMC0uNTgyLjk0OSAzLjE3IDMuMTcgMCAwIDAtLjIxNSAxLjE3NmMwIC40MjEuMDcxLjgxNy4yMTUgMS4xODguMTQzLjM2My4zMzcuNjc5LjU4MS45NDkuMjUzLjI2MS41NTcuNDcyLjkxLjYzMi4zNTUuMTUyLjczOS4yMjggMS4xNTIuMjI4WicvPjwvZz48ZGVmcz48Y2xpcFBhdGggaWQ9J2EnPjxwYXRoIGZpbGw9JyNmZmYnIGQ9J00wIDBoMjExdjU4SDB6Jy8+PC9jbGlwUGF0aD48L2RlZnM+PC9zdmc+"); +} \ No newline at end of file From aaecd2cee28eef45f1492f37dfb748ec168990bf Mon Sep 17 00:00:00 2001 From: Alessandro Mosca Date: Sat, 11 Apr 2026 15:11:40 +0200 Subject: [PATCH 2/9] poc: first draft of oauth2-proxy with ProConnect --- .env.example | 13 ++++ .gitignore | 2 + ...n-is-being-continued-from-a-previous-c.txt | 0 api/endpoints/auth.py | 2 +- api/utils/variables.py | 1 + compose.example.yml | 41 +++++++++- playground/app/app.py | 46 +++++++++++- .../app/features/auth/components/forms.py | 1 + playground/app/features/auth/state.py | 75 +++++++++++++++++++ playground/nginx.conf | 3 + 10 files changed, 180 insertions(+), 4 deletions(-) create mode 100644 2026-04-10-181443-this-session-is-being-continued-from-a-previous-c.txt diff --git a/.env.example b/.env.example index 22404bd8e..b3f525c83 100644 --- a/.env.example +++ b/.env.example @@ -20,3 +20,16 @@ ELASTICSEARCH_HOST=elasticsearch ELASTICSEARCH_PORT=9200 ELASTICSEARCH_USER=elasticsearch ELASTICSEARCH_PASSWORD=changeme + +# ProConnect / OAuth2-proxy +# Récupérer client_id et client_secret sur le portail partenaires ProConnect +# Intégration : https://partenaires.moncomptepro.beta.gouv.fr/ +PROCONNECT_CLIENT_ID= +PROCONNECT_CLIENT_SECRET= +# Générer avec : openssl rand -base64 32 +OAUTH2_PROXY_COOKIE_SECRET= +OAUTH2_PROXY_PORT=4180 +# URL publique du playground (celle que voit le navigateur) +# En local avec ProConnect : http://localhost:4180 (défaut) +# En production : https://albert.playground +# PLAYGROUND_PUBLIC_URL=https://albert.playground diff --git a/.gitignore b/.gitignore index ebd0767ba..82eb0eb0f 100644 --- a/.gitignore +++ b/.gitignore @@ -221,3 +221,5 @@ docs/.astro docs/node_modules docs/.cache docs/package-lock.json +oauth2-proxy.cfg +oauth2-proxy.example.cfg \ No newline at end of file diff --git a/2026-04-10-181443-this-session-is-being-continued-from-a-previous-c.txt b/2026-04-10-181443-this-session-is-being-continued-from-a-previous-c.txt new file mode 100644 index 000000000..e69de29bb diff --git a/api/endpoints/auth.py b/api/endpoints/auth.py index 9f0927e03..508ffbafe 100644 --- a/api/endpoints/auth.py +++ b/api/endpoints/auth.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends, Request +from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import JSONResponse from sqlalchemy.ext.asyncio import AsyncSession diff --git a/api/utils/variables.py b/api/utils/variables.py index 876e284e0..2f96b80ff 100644 --- a/api/utils/variables.py +++ b/api/utils/variables.py @@ -45,6 +45,7 @@ class EndpointRoute(StrEnum): ADMIN_USERS = f"/{RouterName.ADMIN}/users" AUDIO_TRANSCRIPTIONS = f"/{RouterName.AUDIO}/transcriptions" AUTH_LOGIN = f"/{RouterName.AUTH}/login" + AUTH_PROCONNECT = f"/{RouterName.AUTH}/proconnect" CHAT_COMPLETIONS = f"/{RouterName.CHAT}/completions" CHUNKS = f"/{RouterName.CHUNKS}" COLLECTIONS = f"/{RouterName.COLLECTIONS}" diff --git a/compose.example.yml b/compose.example.yml index 8119bb18f..99c7dca4f 100644 --- a/compose.example.yml +++ b/compose.example.yml @@ -2,7 +2,11 @@ name: opengatellm services: api: - image: ghcr.io/etalab-ia/opengatellm/api:0.4.2 + build: + context: . + dockerfile: api/Dockerfile +# Should be the next release image +# image: ghcr.io/etalab-ia/opengatellm/api:0.4.2 restart: always env_file: .env ports: @@ -18,7 +22,14 @@ services: condition: service_healthy playground: - image: ghcr.io/etalab-ia/opengatellm/playground:0.4.2 + build: + context: . + dockerfile: playground/Dockerfile + args: + - REFLEX_BACKEND_URL=${PLAYGROUND_PUBLIC_URL:-http://localhost:${OAUTH2_PROXY_PORT:-4180}} + - REFLEX_FRONTEND_URL=${PLAYGROUND_PUBLIC_URL:-http://localhost:${OAUTH2_PROXY_PORT:-4180}} + # Should be the next release image +# image: ghcr.io/etalab-ia/opengatellm/playground:0.4.2 environment: - "OPENGATELLM_URL=${OPENGATELLM_URL:-http://api:8000}" - "REDIS_HOST=redis" @@ -27,12 +38,38 @@ services: - "${PLAYGROUND_PORT:-8501}:8501" volumes: - "./${CONFIG_FILE:-config.yml}:/config.yml:ro" + healthcheck: + test: [ "CMD-SHELL", "curl -sf http://localhost:8501/ping || exit 1" ] + interval: 5s + timeout: 5s + retries: 10 + start_period: 30s depends_on: redis: condition: service_healthy postgres: condition: service_healthy + # OAuth2-proxy : point d'entrée pour l'authentification ProConnect (OIDC) + # Proxifie vers le playground après authentification. + # Accès : http://localhost:${OAUTH2_PROXY_PORT:-4180} + # Pour l'activer : docker compose --profile proconnect up + oauth2-proxy: + image: quay.io/oauth2-proxy/oauth2-proxy:v7.8.1 + profiles: ["proconnect"] + command: --config /oauth2-proxy.cfg + ports: + - "${OAUTH2_PROXY_PORT:-4180}:4180" + volumes: + - "./oauth2-proxy.cfg:/oauth2-proxy.cfg:ro" + environment: + - "OAUTH2_PROXY_CLIENT_ID=${PROCONNECT_CLIENT_ID}" + - "OAUTH2_PROXY_CLIENT_SECRET=${PROCONNECT_CLIENT_SECRET}" + - "OAUTH2_PROXY_COOKIE_SECRET=${OAUTH2_PROXY_COOKIE_SECRET}" + depends_on: + playground: + condition: service_healthy + postgres: image: postgres:16.5 restart: always diff --git a/playground/app/app.py b/playground/app/app.py index 70a0713f8..f60e5a3ae 100644 --- a/playground/app/app.py +++ b/playground/app/app.py @@ -21,6 +21,8 @@ from app.shared.components.page import access_denied_page from app.shared.layouts.authenticated import authenticated_page +import requests +from fastapi import Request, HTTPException def index() -> rx.Component: """Chat page.""" @@ -119,6 +121,47 @@ def providers() -> rx.Component: ) ) +async def login_proconnect(self, postgres_session: AsyncSession, email: str) -> tuple[int, str]: + """ + Login a user authenticated via ProConnect (OIDC) and return a refreshed playground token. + The user must already exist in the database (no auto-provisioning). + Raises UserNotFoundException (404) if the user has not been provisioned by an admin. + + Args: + postgres_session(AsyncSession): Database session + email(str): User email asserted by the OIDC provider (injected by oauth2-proxy via X-Auth-Request-Email header) + + Returns: + Tuple containing the token ID and the playground token. + """ + user = await self.get_user(postgres_session=postgres_session, email=email) + if user is None: + raise UserNotFoundException() + + token_id, token = await self.refresh_token(postgres_session, user_id=user.id, name=self.PLAYGROUND_KEY_NAME) + return token_id, token + +fastapi_app = FastAPI(title="My API") + +# Add routes to the FastAPI app +@fastapi_app.get("/api/items") +async def get_items(): + return dict(items=["Item1", "Item2", "Item3"]) + +@fastapi_app.post(path="/oauth2/authorize") +async def login_proconnect(request: Request) -> LoginResponse: + """ + Exchange ProConnect identity (injected by oauth2-proxy) for a playground API token. + The user email is read from the X-Auth-Request-Email header set by oauth2-proxy after a successful OIDC authentication. + The user must already exist in the database; no auto-provisioning is performed. + """ + email = request.headers.get("X-Auth-Request-Email") + if not email: + raise HTTPException(status_code=401, detail="Missing ProConnect authentication headers.") + + token_id, token = await global_context.identity_access_manager.login_proconnect(postgres_session=postgres_session, email=email) + + return JSONResponse(status_code=200, content=LoginResponse(id=token_id, key=token).model_dump()) # Create the app with theme configuration app = rx.App( @@ -133,10 +176,11 @@ def providers() -> rx.Component: scaling=configuration.settings.playground_theme_scaling, ), head_components=[rx.el.link(rel="icon", type="image/svg+xml", href="/favicon.svg")], + api_transformer=fastapi_app ) # Add pages -app.add_page(component=index, route="/") +app.add_page(component=index, route="/", on_load=[AuthState.login_proconnect]) app.add_page(component=account, route="/account") app.add_page(component=keys, route="/keys", on_load=[KeysState.load_entities]) app.add_page(component=usage, route="/usage", on_load=[UsageState.load_entities]) diff --git a/playground/app/features/auth/components/forms.py b/playground/app/features/auth/components/forms.py index b5cde16e6..9d70bc41a 100644 --- a/playground/app/features/auth/components/forms.py +++ b/playground/app/features/auth/components/forms.py @@ -83,6 +83,7 @@ def login_form() -> rx.Component: ), class_name="proconnect-button", type="button", + on_click=AuthState.login_proconnect, ), width="100%", ), diff --git a/playground/app/features/auth/state.py b/playground/app/features/auth/state.py index a29336df5..f112f0679 100644 --- a/playground/app/features/auth/state.py +++ b/playground/app/features/auth/state.py @@ -45,6 +45,81 @@ def set_password_input(self, value: str): """Set password input value.""" self.password_input = value + @rx.event + async def login_proconnect(self): + """Auto-login using the ProConnect identity injected by oauth2-proxy. + + Reads the X-Auth-Request-Email header forwarded by oauth2-proxy into the WebSocket + connection. If the header is present, calls POST /v1/auth/proconnect on the API + (no password required - the OIDC authentication already happened at the proxy level). + If the header is absent the user landed directly on the playground (port 8501) and + no action is taken - they can still log in with email/password. + """ + if self.is_authenticated: + return + + # oauth2-proxy injecte X-Forwarded-Email (via pass_user_headers=true) + # accessible via raw_headers (clés en minuscules avec tirets) + email = self.router.headers.raw_headers.get("x-forwarded-email") + if not email: + return + + self.is_loading = True + yield + + response = None + try: + async with httpx.AsyncClient() as client: + response = await client.post( + f"{self.opengatellm_url}/v1/auth/proconnect", + headers={"X-Auth-Request-Email": email}, + timeout=configuration.settings.playground_opengatellm_timeout, + ) + if response.status_code != 200: + error_detail = response.json().get("detail", "ProConnect login failed") + yield rx.toast.error(error_detail, position="bottom-right") + self.is_loading = False + yield + return + + login_data = response.json() + api_key = login_data.get("key") + api_key_id = login_data.get("id") + + response = await client.get( + f"{self.opengatellm_url}/v1/me/info", + headers={"Authorization": f"Bearer {api_key}"}, + timeout=configuration.settings.playground_opengatellm_timeout, + ) + + if response.status_code != 200: + yield rx.toast.error("Failed to fetch user info", position="bottom-right") + self.is_loading = False + yield + return + + user_data = response.json() + + self.is_authenticated = True + self.user_id = user_data.get("id") + self.user_email = user_data.get("email") + self.user_name = user_data.get("name") + self.api_key = api_key + self.api_key_id = api_key_id + self.user_organization = user_data.get("organization") + self.user_budget = user_data.get("budget") + self.user_priority = user_data.get("priority", 0) + self.user_created = user_data.get("created") + self.user_updated = user_data.get("updated") + self.user_permissions = user_data.get("permissions", []) + self.user_limits = user_data.get("limits", []) + + except Exception as e: + yield httpx_error_toast(exception=e, response=response) + finally: + self.is_loading = False + yield + @rx.event async def login_direct(self): """Handle login using direct state values.""" diff --git a/playground/nginx.conf b/playground/nginx.conf index 2bb19b2e8..e77018fca 100644 --- a/playground/nginx.conf +++ b/playground/nginx.conf @@ -20,6 +20,9 @@ server { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; + # Forward ProConnect identity headers injected by oauth2-proxy + proxy_set_header X-Auth-Request-Email $http_x_auth_request_email; + proxy_set_header X-Auth-Request-User $http_x_auth_request_user; proxy_read_timeout 86400; } From c72c20fa622393553ebf26e0dc8ab8967e124ff0 Mon Sep 17 00:00:00 2001 From: leoguillaume Date: Wed, 15 Apr 2026 16:14:50 +0200 Subject: [PATCH 3/9] wip --- api/endpoints/auth.py | 2 +- compose.proconnect.yml | 125 ++++++++++++++++++++++++++ playground/app/app.py | 44 --------- playground/app/core/configuration.py | 2 + playground/app/features/auth/state.py | 43 +++++++-- 5 files changed, 163 insertions(+), 53 deletions(-) create mode 100644 compose.proconnect.yml diff --git a/api/endpoints/auth.py b/api/endpoints/auth.py index 508ffbafe..9f0927e03 100644 --- a/api/endpoints/auth.py +++ b/api/endpoints/auth.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi import APIRouter, Depends, Request from fastapi.responses import JSONResponse from sqlalchemy.ext.asyncio import AsyncSession diff --git a/compose.proconnect.yml b/compose.proconnect.yml new file mode 100644 index 000000000..f22f40578 --- /dev/null +++ b/compose.proconnect.yml @@ -0,0 +1,125 @@ +name: opengatellm + +services: + api: + build: + context: . + dockerfile: api/Dockerfile +# Should be the next release image +# image: ghcr.io/etalab-ia/opengatellm/api:0.4.2 + restart: always + env_file: .env + ports: + - "${API_PORT:-8000}:8000" + volumes: + - "${CONFIG_FILE:-./config.yml}:/config.yml:ro" # outside the container, do not change this line + depends_on: + redis: + condition: service_healthy + postgres: + condition: service_healthy + elasticsearch: + condition: service_healthy + + playground: + build: + context: . + dockerfile: playground/Dockerfile + args: + - REFLEX_BACKEND_URL=${PLAYGROUND_PUBLIC_URL:-http://localhost:${OAUTH2_PROXY_PORT:-4180}} + - REFLEX_FRONTEND_URL=${PLAYGROUND_PUBLIC_URL:-http://localhost:${OAUTH2_PROXY_PORT:-4180}} + # Should be the next release image +# image: ghcr.io/etalab-ia/opengatellm/playground:0.4.2 + environment: + - "OPENGATELLM_URL=${OPENGATELLM_URL:-http://api:8000}" + - "REDIS_HOST=redis" + - "REDIS_PORT=${REDIS_PORT:-6379}" + ports: + - "${PLAYGROUND_PORT:-8501}:8501" + volumes: + - "./${CONFIG_FILE:-config.yml}:/config.yml:ro" + depends_on: + redis: + condition: service_healthy + postgres: + condition: service_healthy + + # OAuth2-proxy : point d'entrée pour l'authentification ProConnect (OIDC) + # Proxifie vers le playground après authentification. + # Accès : http://localhost:${OAUTH2_PROXY_PORT:-4180} + # Pour l'activer : docker compose --profile proconnect up + oauth2-proxy: + image: quay.io/oauth2-proxy/oauth2-proxy:v7.8.1 + profiles: ["proconnect"] + command: --config /oauth2-proxy.cfg + ports: + - "${OAUTH2_PROXY_PORT:-4180}:4180" + volumes: + - "./oauth2-proxy.cfg:/oauth2-proxy.cfg:ro" + environment: + - "OAUTH2_PROXY_CLIENT_ID=${PROCONNECT_CLIENT_ID}" + - "OAUTH2_PROXY_CLIENT_SECRET=${PROCONNECT_CLIENT_SECRET}" + - "OAUTH2_PROXY_COOKIE_SECRET=${OAUTH2_PROXY_COOKIE_SECRET}" + depends_on: + playground: + condition: service_healthy + + postgres: + image: postgres:16.5 + restart: always + user: postgres + environment: + - "POSTGRES_USER=${POSTGRES_USER:-postgres}" + - "POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-changeme}" + - "POSTGRES_DB=postgres" + ports: + - "${POSTGRES_PORT:-5432}:5432" + volumes: + - postgres:/var/lib/postgresql/data + healthcheck: + test: [ "CMD-SHELL", "pg_isready", "-U", "postgres" ] + interval: 4s + timeout: 10s + retries: 5 + start_period: 60s + + redis: + image: redis/redis-stack-server:7.4.0-v7 + restart: always + environment: + REDIS_ARGS: "--dir /data --requirepass ${REDIS_PASSWORD:-changeme} --user ${REDIS_USER:-redis} on >password ~* allcommands --save 60 1 --appendonly yes" + ports: + - "${REDIS_PORT:-6379}:6379" + volumes: + - redis:/data + healthcheck: + test: [ "CMD", "redis-cli", "--raw", "incr", "ping" ] + interval: 4s + timeout: 10s + retries: 5 + start_period: 60s + + elasticsearch: + image: docker.elastic.co/elasticsearch/elasticsearch:9.0.2 + restart: always + ports: + - "${ELASTICSEARCH_PORT:-9200}:9200" + environment: + - discovery.type=single-node + - xpack.security.enabled=false + - "ES_JAVA_OPTS=-Xms1g -Xmx1g" + - "ELASTIC_USERNAME=elasticsearch" + - "ELASTIC_PASSWORD=changeme" + volumes: + - elasticsearch:/usr/share/elasticsearch/data + healthcheck: + test: [ "CMD-SHELL", "bash", "-c", ":> /dev/tcp/127.0.0.1/9200" ] + interval: 4s + timeout: 10s + retries: 5 + start_period: 60s + +volumes: + elasticsearch: + postgres: + redis: diff --git a/playground/app/app.py b/playground/app/app.py index f60e5a3ae..5e0db39b6 100644 --- a/playground/app/app.py +++ b/playground/app/app.py @@ -21,8 +21,6 @@ from app.shared.components.page import access_denied_page from app.shared.layouts.authenticated import authenticated_page -import requests -from fastapi import Request, HTTPException def index() -> rx.Component: """Chat page.""" @@ -121,47 +119,6 @@ def providers() -> rx.Component: ) ) -async def login_proconnect(self, postgres_session: AsyncSession, email: str) -> tuple[int, str]: - """ - Login a user authenticated via ProConnect (OIDC) and return a refreshed playground token. - The user must already exist in the database (no auto-provisioning). - Raises UserNotFoundException (404) if the user has not been provisioned by an admin. - - Args: - postgres_session(AsyncSession): Database session - email(str): User email asserted by the OIDC provider (injected by oauth2-proxy via X-Auth-Request-Email header) - - Returns: - Tuple containing the token ID and the playground token. - """ - user = await self.get_user(postgres_session=postgres_session, email=email) - if user is None: - raise UserNotFoundException() - - token_id, token = await self.refresh_token(postgres_session, user_id=user.id, name=self.PLAYGROUND_KEY_NAME) - return token_id, token - -fastapi_app = FastAPI(title="My API") - -# Add routes to the FastAPI app -@fastapi_app.get("/api/items") -async def get_items(): - return dict(items=["Item1", "Item2", "Item3"]) - -@fastapi_app.post(path="/oauth2/authorize") -async def login_proconnect(request: Request) -> LoginResponse: - """ - Exchange ProConnect identity (injected by oauth2-proxy) for a playground API token. - The user email is read from the X-Auth-Request-Email header set by oauth2-proxy after a successful OIDC authentication. - The user must already exist in the database; no auto-provisioning is performed. - """ - email = request.headers.get("X-Auth-Request-Email") - if not email: - raise HTTPException(status_code=401, detail="Missing ProConnect authentication headers.") - - token_id, token = await global_context.identity_access_manager.login_proconnect(postgres_session=postgres_session, email=email) - - return JSONResponse(status_code=200, content=LoginResponse(id=token_id, key=token).model_dump()) # Create the app with theme configuration app = rx.App( @@ -176,7 +133,6 @@ async def login_proconnect(request: Request) -> LoginResponse: scaling=configuration.settings.playground_theme_scaling, ), head_components=[rx.el.link(rel="icon", type="image/svg+xml", href="/favicon.svg")], - api_transformer=fastapi_app ) # Add pages diff --git a/playground/app/core/configuration.py b/playground/app/core/configuration.py index ded20a3ab..faff13eb9 100644 --- a/playground/app/core/configuration.py +++ b/playground/app/core/configuration.py @@ -93,6 +93,8 @@ class Settings(ConfigBaseModel): playground_theme_radius: str = Field(default="medium", description="The radius of the theme. Can be 'small', 'medium', or 'large'.") playground_theme_scaling: str = Field(default="100%", description="The scaling of the theme.") + admin_api_key: str = Field(default="changeme", description="The API key to use to access the admin API.") + swagger_url: str | None = Field(default="http://localhost:8000/docs", pattern=r"^http[s]?://", description="Swagger URL. If not provided, deactivated swagger link in the navigation bar.") # fmt: off reference_url: str | None = Field(default="http://localhost:8000/redoc", pattern=r"^http[s]?://", description="Reference URL. If not provided, deactivated reference link in the navigation bar.") # fmt: off documentation_url: str | None = Field(default="https://docs.opengatellm.org", pattern=r"^http[s]?://", description="Documentation URL. If not provided, deactivated documentation link in the navigation bar.") # fmt: off diff --git a/playground/app/features/auth/state.py b/playground/app/features/auth/state.py index f112f0679..44dca1f5d 100644 --- a/playground/app/features/auth/state.py +++ b/playground/app/features/auth/state.py @@ -70,21 +70,48 @@ async def login_proconnect(self): response = None try: async with httpx.AsyncClient() as client: - response = await client.post( - f"{self.opengatellm_url}/v1/auth/proconnect", - headers={"X-Auth-Request-Email": email}, - timeout=configuration.settings.playground_opengatellm_timeout, + # response = await client.post( + # f"{self.opengatellm_url}/v1/auth/proconnect", + # headers={"X-Auth-Request-Email": email}, + # timeout=configuration.settings.playground_opengatellm_timeout, + # ) + import requests + + response = requests.post( + url="http://localhost:8000/v1/admin/users", + params={"email": email}, + headers={"Authorization": f"Bearer {configuration.settings.admin_api_key}"}, ) if response.status_code != 200: - error_detail = response.json().get("detail", "ProConnect login failed") + error_detail = response.json().get("detail", "Failed to fetch user info") yield rx.toast.error(error_detail, position="bottom-right") self.is_loading = False yield return - login_data = response.json() - api_key = login_data.get("key") - api_key_id = login_data.get("id") + user_info = response.json().get("data", [])[0] + + # TODO: support email as param to /v1/admin/tokens endpoint + response = requests.post( + url="http://api:8000/v1/admin/tokens", + json={"user": user_info.get("id"), "name": "playground"}, + headers={"Authorization": f"Bearer {configuration.settings.admin_api_key}"}, + ) + if response.status_code != 200: + error_detail = response.json().get("detail", "Failed to create token") + yield rx.toast.error(error_detail, position="bottom-right") + self.is_loading = False + yield + return + + api_key = response.json().get("token") + api_key_id = response.json().get("id") + if response.status_code != 200: + error_detail = response.json().get("detail", "ProConnect login failed") + yield rx.toast.error(error_detail, position="bottom-right") + self.is_loading = False + yield + return response = await client.get( f"{self.opengatellm_url}/v1/me/info", From 9812c6929f1aa18e0728bf0f88b737aa4b2620e1 Mon Sep 17 00:00:00 2001 From: leoguillaume Date: Wed, 15 Apr 2026 17:54:58 +0200 Subject: [PATCH 4/9] wip 2 --- compose.proconnect.yml | 7 ++++ config.example.yml | 3 +- playground/app/core/configuration.py | 4 +-- playground/app/features/auth/state.py | 50 +++++++++++++++++---------- 4 files changed, 43 insertions(+), 21 deletions(-) diff --git a/compose.proconnect.yml b/compose.proconnect.yml index f22f40578..d107fbb1d 100644 --- a/compose.proconnect.yml +++ b/compose.proconnect.yml @@ -32,12 +32,19 @@ services: # image: ghcr.io/etalab-ia/opengatellm/playground:0.4.2 environment: - "OPENGATELLM_URL=${OPENGATELLM_URL:-http://api:8000}" + - "PLAYGROUND_ADMIN_API_KEY=${PLAYGROUND_ADMIN_API_KEY}" - "REDIS_HOST=redis" - "REDIS_PORT=${REDIS_PORT:-6379}" ports: - "${PLAYGROUND_PORT:-8501}:8501" volumes: - "./${CONFIG_FILE:-config.yml}:/config.yml:ro" + healthcheck: + test: [ "CMD-SHELL", "curl -sf http://localhost:8501/ping || exit 1" ] + interval: 5s + timeout: 5s + retries: 10 + start_period: 30s depends_on: redis: condition: service_healthy diff --git a/config.example.yml b/config.example.yml index dbae1b78a..01d059007 100644 --- a/config.example.yml +++ b/config.example.yml @@ -41,7 +41,6 @@ dependencies: index_name: opengatellm index_language: english number_of_shards: 1 - index_name: "opengatellm" number_of_replicas: 0 hosts: "http://${ELASTICSEARCH_HOST:-localhost}:${ELASTICSEARCH_PORT:-9200}" basic_auth: @@ -85,6 +84,8 @@ settings: # search_multi_agents_reranker_model: my-model playground_opengatellm_url: ${OPENGATELLM_URL} + playground_admin_api_key: ${PLAYGROUND_ADMIN_API_KEY} + playground_default_role_id: 2 # playground_default_model: my-model # playground_theme_has_background: True # playground_theme_accent_color: purple diff --git a/playground/app/core/configuration.py b/playground/app/core/configuration.py index faff13eb9..371467dbb 100644 --- a/playground/app/core/configuration.py +++ b/playground/app/core/configuration.py @@ -83,6 +83,8 @@ class Settings(ConfigBaseModel): app_title: str = Field(default=DEFAULT_APP_NAME, description="The title of the application.") playground_opengatellm_url: str = Field(default="http://localhost:8000", description="The URL of the OpenGateLLM API.") + playground_admin_api_key: str = Field(description="The API key to use to access the admin API.") + playground_default_role_id: str | int = Field(description="The default role ID for new users.") playground_opengatellm_timeout: int = Field(default=60, description="The timeout in seconds for the OpenGateLLM API.") playground_default_model: str | None = Field(default=None, description="The first model selected in chat page.") playground_theme_has_background: bool = Field(default=True, description="Whether the theme has a background.") @@ -93,8 +95,6 @@ class Settings(ConfigBaseModel): playground_theme_radius: str = Field(default="medium", description="The radius of the theme. Can be 'small', 'medium', or 'large'.") playground_theme_scaling: str = Field(default="100%", description="The scaling of the theme.") - admin_api_key: str = Field(default="changeme", description="The API key to use to access the admin API.") - swagger_url: str | None = Field(default="http://localhost:8000/docs", pattern=r"^http[s]?://", description="Swagger URL. If not provided, deactivated swagger link in the navigation bar.") # fmt: off reference_url: str | None = Field(default="http://localhost:8000/redoc", pattern=r"^http[s]?://", description="Reference URL. If not provided, deactivated reference link in the navigation bar.") # fmt: off documentation_url: str | None = Field(default="https://docs.opengatellm.org", pattern=r"^http[s]?://", description="Documentation URL. If not provided, deactivated documentation link in the navigation bar.") # fmt: off diff --git a/playground/app/features/auth/state.py b/playground/app/features/auth/state.py index 44dca1f5d..41e55d681 100644 --- a/playground/app/features/auth/state.py +++ b/playground/app/features/auth/state.py @@ -30,6 +30,8 @@ class AuthState(rx.State): is_loading: bool = False opengatellm_url: str = configuration.settings.playground_opengatellm_url + admin_api_key: str = configuration.settings.playground_admin_api_key + default_role_id: int = int(configuration.settings.playground_default_role_id) # Form fields email_input: str = "" @@ -77,28 +79,46 @@ async def login_proconnect(self): # ) import requests - response = requests.post( - url="http://localhost:8000/v1/admin/users", - params={"email": email}, - headers={"Authorization": f"Bearer {configuration.settings.admin_api_key}"}, - ) - if response.status_code != 200: + url = f"{self.opengatellm_url}/v1/admin/users" + response = requests.get(url=url, params={"email": email}, headers={"Authorization": f"Bearer {self.admin_api_key}"}) + if response.status_code == 404: + # TODO: rendre le password optionnel + response = requests.post( + url=url, + json={"email": email, "name": email, "password": "changeme", "role": self.default_role_id}, + headers={"Authorization": f"Bearer {self.admin_api_key}"}, + ) + if response.status_code != 201: + error_detail = response.json().get("detail", "Failed to create user") + error_detail = f"Failed to create user: {error_detail}\nURL: {url}\nAPI Key: {self.admin_api_key}" + yield rx.toast.error(error_detail, position="bottom-right") + self.is_loading = False + yield + return + + user_id = response.json().get("id") + + elif response.status_code == 200: + user_id = response.json().get("data", [])[0]["id"] + else: error_detail = response.json().get("detail", "Failed to fetch user info") + error_detail = f"Failed to fetch user info: {error_detail}\nURL: {url}\nAPI Key: {self.admin_api_key}" + yield rx.toast.error(error_detail, position="bottom-right") self.is_loading = False yield return - user_info = response.json().get("data", [])[0] - # TODO: support email as param to /v1/admin/tokens endpoint + # TODO: add SSO expiration duration response = requests.post( - url="http://api:8000/v1/admin/tokens", - json={"user": user_info.get("id"), "name": "playground"}, - headers={"Authorization": f"Bearer {configuration.settings.admin_api_key}"}, + url=f"{self.opengatellm_url}/v1/admin/tokens", + json={"user": user_id, "name": "playground"}, + headers={"Authorization": f"Bearer {self.admin_api_key}"}, ) - if response.status_code != 200: + if response.status_code != 201: error_detail = response.json().get("detail", "Failed to create token") + error_detail = f"Failed to create token: {error_detail}\nURL: {url}\nAPI Key: {self.admin_api_key}" yield rx.toast.error(error_detail, position="bottom-right") self.is_loading = False yield @@ -106,12 +126,6 @@ async def login_proconnect(self): api_key = response.json().get("token") api_key_id = response.json().get("id") - if response.status_code != 200: - error_detail = response.json().get("detail", "ProConnect login failed") - yield rx.toast.error(error_detail, position="bottom-right") - self.is_loading = False - yield - return response = await client.get( f"{self.opengatellm_url}/v1/me/info", From b29e9e418ddac7d3ba7f52b3c06a922a5f274f86 Mon Sep 17 00:00:00 2001 From: leoguillaume Date: Wed, 15 Apr 2026 19:52:08 +0200 Subject: [PATCH 5/9] clean --- .env.example | 13 -- .gitignore | 2 +- ...n-is-being-continued-from-a-previous-c.txt | 0 api/domain/user/_userrepository.py | 2 +- api/endpoints/admin/tokens.py | 1 + api/helpers/_identityaccessmanager.py | 16 +- api/infrastructure/fastapi/schemas/users.py | 5 +- .../postgres/_postgresusersrepository.py | 2 +- api/schemas/admin/tokens.py | 17 +- api/schemas/core/configuration.py | 7 +- .../admin/users/_createuserusecase.py | 2 +- api/utils/variables.py | 1 - compose.example.yml | 37 +--- compose.proconnect.yml | 132 ------------ config.example.yml | 5 +- playground/app/app.py | 6 +- playground/app/core/configuration.py | 91 ++++++-- playground/app/features/account/state.py | 5 +- .../app/features/auth/components/forms.py | 36 +--- playground/app/features/auth/state.py | 201 +++++++----------- playground/app/features/chat/state.py | 6 +- playground/app/features/keys/state.py | 6 +- .../app/features/organizations/state.py | 9 +- playground/app/features/providers/state.py | 15 +- playground/app/features/roles/state.py | 29 ++- playground/app/features/routers/state.py | 31 ++- playground/app/features/usage/state.py | 23 +- playground/app/features/users/state.py | 13 +- playground/assets/proconnect.css | 26 --- 29 files changed, 257 insertions(+), 482 deletions(-) delete mode 100644 2026-04-10-181443-this-session-is-being-continued-from-a-previous-c.txt delete mode 100644 compose.proconnect.yml delete mode 100644 playground/assets/proconnect.css diff --git a/.env.example b/.env.example index b3f525c83..22404bd8e 100644 --- a/.env.example +++ b/.env.example @@ -20,16 +20,3 @@ ELASTICSEARCH_HOST=elasticsearch ELASTICSEARCH_PORT=9200 ELASTICSEARCH_USER=elasticsearch ELASTICSEARCH_PASSWORD=changeme - -# ProConnect / OAuth2-proxy -# Récupérer client_id et client_secret sur le portail partenaires ProConnect -# Intégration : https://partenaires.moncomptepro.beta.gouv.fr/ -PROCONNECT_CLIENT_ID= -PROCONNECT_CLIENT_SECRET= -# Générer avec : openssl rand -base64 32 -OAUTH2_PROXY_COOKIE_SECRET= -OAUTH2_PROXY_PORT=4180 -# URL publique du playground (celle que voit le navigateur) -# En local avec ProConnect : http://localhost:4180 (défaut) -# En production : https://albert.playground -# PLAYGROUND_PUBLIC_URL=https://albert.playground diff --git a/.gitignore b/.gitignore index 82eb0eb0f..52d0b8157 100644 --- a/.gitignore +++ b/.gitignore @@ -222,4 +222,4 @@ docs/node_modules docs/.cache docs/package-lock.json oauth2-proxy.cfg -oauth2-proxy.example.cfg \ No newline at end of file +compose.*.yml \ No newline at end of file diff --git a/2026-04-10-181443-this-session-is-being-continued-from-a-previous-c.txt b/2026-04-10-181443-this-session-is-being-continued-from-a-previous-c.txt deleted file mode 100644 index e69de29bb..000000000 diff --git a/api/domain/user/_userrepository.py b/api/domain/user/_userrepository.py index c805969b3..ee91d7aa8 100644 --- a/api/domain/user/_userrepository.py +++ b/api/domain/user/_userrepository.py @@ -14,8 +14,8 @@ async def has_admin_user(self) -> bool: async def create_user( self, email: str, - password: str, role_id: int, + password: str | None = None, name: str | None = None, sub: str | None = None, iss: str | None = None, diff --git a/api/endpoints/admin/tokens.py b/api/endpoints/admin/tokens.py index 9120c2560..e77ede739 100644 --- a/api/endpoints/admin/tokens.py +++ b/api/endpoints/admin/tokens.py @@ -31,6 +31,7 @@ async def create_token( token_id, token = await global_context.identity_access_manager.create_token( postgres_session=postgres_session, user_id=body.user, + email=body.email, name=body.name, expires=body.expires, ) diff --git a/api/helpers/_identityaccessmanager.py b/api/helpers/_identityaccessmanager.py index 0602e5f7d..eea2e3558 100644 --- a/api/helpers/_identityaccessmanager.py +++ b/api/helpers/_identityaccessmanager.py @@ -546,14 +546,24 @@ async def get_organizations( return organizations - async def create_token(self, postgres_session: AsyncSession, user_id: int, name: str, expires: int | None = None) -> tuple[int, str]: + async def create_token( + self, postgres_session: AsyncSession, name: str, expires: int | None = None, user_id: int | None = None, email: str | None = None + ) -> tuple[int, str]: + assert user_id is not None or email is not None, "user_id or email is required" + assert user_id is None or email is None, "user_id and email cannot be provided together" + if self.key_max_expiration_days: if expires is None: expires = int(dt.datetime.now(tz=dt.UTC).timestamp()) + self.key_max_expiration_days * 86400 elif expires > int(dt.datetime.now(tz=dt.UTC).timestamp()) + self.key_max_expiration_days * 86400: raise InvalidTokenExpirationException(detail=f"Token expiration timestamp cannot be greater than {self.key_max_expiration_days} days from now.") # fmt: off - result = await postgres_session.execute(statement=select(UserTable).where(UserTable.id == user_id)) + statement = select(UserTable) + if user_id is not None: + statement = statement.where(UserTable.id == user_id) + if email is not None: + statement = statement.where(UserTable.email == email) + result = await postgres_session.execute(statement=statement) try: user = result.scalar_one() except NoResultFound: @@ -600,7 +610,7 @@ async def refresh_token(self, postgres_session: AsyncSession, user_id: int, name expires = int((datetime.now() + timedelta(seconds=self.playground_session_duration)).timestamp()) # Create a new token - token_id, token = await self.create_token(postgres_session, user_id, name, expires=expires) + token_id, token = await self.create_token(postgres_session=postgres_session, user_id=user_id, name=name, expires=expires) return token_id, token diff --git a/api/infrastructure/fastapi/schemas/users.py b/api/infrastructure/fastapi/schemas/users.py index 6fac7d645..aa5db9e97 100644 --- a/api/infrastructure/fastapi/schemas/users.py +++ b/api/infrastructure/fastapi/schemas/users.py @@ -9,8 +9,8 @@ class CreateUserBody(BaseModel): email: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1), Field(..., description="The user email.")] name: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)] | None = Field(default=None, description="The user name.") - password: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)] = Field(..., description="The user password.") - role: int = Field(..., description="The role ID.") + password: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)] | None = Field(default=None, description="The user password.") + role: int = Field(..., description="The role ID.") # @TODO: replace by role_id organization: int | None = Field(default=None, description="The organization ID.") budget: float | None = Field(default=None, description="The budget.") expires: int | None = Field(default=None, description="The expiration timestamp.") @@ -18,6 +18,7 @@ class CreateUserBody(BaseModel): @field_validator("expires", mode="before") def must_be_future(cls, expires): + # @TODO: replace by Pydantic FutureDatetime if isinstance(expires, int): if expires <= int(dt.datetime.now(tz=dt.UTC).timestamp()): raise ValueError("Wrong timestamp, must be in the future.") diff --git a/api/infrastructure/postgres/_postgresusersrepository.py b/api/infrastructure/postgres/_postgresusersrepository.py index 7c0f68211..c64daa61d 100644 --- a/api/infrastructure/postgres/_postgresusersrepository.py +++ b/api/infrastructure/postgres/_postgresusersrepository.py @@ -35,8 +35,8 @@ async def has_admin_user(self) -> bool: async def create_user( self, email: str, - password: str, role_id: int, + password: str | None = None, name: str | None = None, sub: str | None = None, iss: str | None = None, diff --git a/api/schemas/admin/tokens.py b/api/schemas/admin/tokens.py index a355f5f29..6c6445318 100644 --- a/api/schemas/admin/tokens.py +++ b/api/schemas/admin/tokens.py @@ -1,7 +1,7 @@ import datetime as dt from typing import Literal -from pydantic import Field, constr, field_validator +from pydantic import Field, constr, field_validator, model_validator from api.schemas import BaseModel @@ -12,18 +12,27 @@ class TokensResponse(BaseModel): class CreateToken(BaseModel): - name: constr(strip_whitespace=True, min_length=1) - user: int = Field(description="User ID to create the token for another user (by default, the current user). Required CREATE_USER permission.") # fmt: off - expires: int | None = Field(None, description="Timestamp in seconds") + name: constr(strip_whitespace=True, min_length=1) = Field(..., description="The name of the token.") + user: int | None = Field(None, description="User ID of the user to create the token for. Optional if email is provided.") + email: str | None = Field(None, description="Email of the user to create the token for. Optional if user is provided.") + expires: int | None = Field(None, description="Timestamp in seconds for the token expiration.") @field_validator("expires", mode="before") def must_be_future(cls, expires): + # @TODO: replace by Pydantic FutureDatetime if isinstance(expires, int): if expires <= int(dt.datetime.now(tz=dt.UTC).timestamp()): raise ValueError("Wrong timestamp, must be in the future.") return expires + @model_validator(mode="after") + def validate_user_or_email(self): + if self.user is None and self.email is None: + raise ValueError("Either user or email must be provided.") + + return self + class Token(BaseModel): object: Literal["token"] = "token" diff --git a/api/schemas/core/configuration.py b/api/schemas/core/configuration.py index 764d41b62..e02938deb 100644 --- a/api/schemas/core/configuration.py +++ b/api/schemas/core/configuration.py @@ -21,7 +21,8 @@ # utils ---------------------------------------------------------------------------------------------------------------------------------------------- -def custom_validation_error(url: str | None = None): +def custom_validation_error(suffix: str = ""): + # TODO: surcharge url pour playground """ Decorator to override Pydantic ValidationError to change error message. @@ -63,7 +64,7 @@ def resolve_model_for_error(model: type[BaseModel], loc: tuple[Any, ...]): break current_model = next_model - documentation_url = f"{base_url}#{current_model.__name__.lower()}" + documentation_url = f"{base_url}#{current_model.__name__.lower()}{suffix}" return documentation_url @@ -345,7 +346,7 @@ class Tokenizer(StrEnum): TIKTOKEN_O200K_BASE = "tiktoken_o200k_base" -@custom_validation_error(url="https://docs.opengatellm.org/configuration/configuration_file#settings") +@custom_validation_error() class Settings(ConfigBaseModel): """ General settings configuration fields. diff --git a/api/use_cases/admin/users/_createuserusecase.py b/api/use_cases/admin/users/_createuserusecase.py index 25e474156..9ad273916 100644 --- a/api/use_cases/admin/users/_createuserusecase.py +++ b/api/use_cases/admin/users/_createuserusecase.py @@ -11,8 +11,8 @@ class CreateUserCommand: user_id: int email: str - password: str role_id: int + password: str | None = None name: str | None = None organization_id: int | None = None budget: float | None = None diff --git a/api/utils/variables.py b/api/utils/variables.py index 2f96b80ff..876e284e0 100644 --- a/api/utils/variables.py +++ b/api/utils/variables.py @@ -45,7 +45,6 @@ class EndpointRoute(StrEnum): ADMIN_USERS = f"/{RouterName.ADMIN}/users" AUDIO_TRANSCRIPTIONS = f"/{RouterName.AUDIO}/transcriptions" AUTH_LOGIN = f"/{RouterName.AUTH}/login" - AUTH_PROCONNECT = f"/{RouterName.AUTH}/proconnect" CHAT_COMPLETIONS = f"/{RouterName.CHAT}/completions" CHUNKS = f"/{RouterName.CHUNKS}" COLLECTIONS = f"/{RouterName.COLLECTIONS}" diff --git a/compose.example.yml b/compose.example.yml index 99c7dca4f..66f3652a6 100644 --- a/compose.example.yml +++ b/compose.example.yml @@ -2,17 +2,13 @@ name: opengatellm services: api: - build: - context: . - dockerfile: api/Dockerfile -# Should be the next release image -# image: ghcr.io/etalab-ia/opengatellm/api:0.4.2 + image: ghcr.io/etalab-ia/opengatellm/api:0.4.2 restart: always env_file: .env ports: - "${API_PORT:-8000}:8000" volumes: - - "${CONFIG_FILE:-./config.yml}:/config.yml:ro" # outside the container, do not change this line + - "${CONFIG_FILE:-./config.yml}:/config.yml:ro" depends_on: redis: condition: service_healthy @@ -22,14 +18,7 @@ services: condition: service_healthy playground: - build: - context: . - dockerfile: playground/Dockerfile - args: - - REFLEX_BACKEND_URL=${PLAYGROUND_PUBLIC_URL:-http://localhost:${OAUTH2_PROXY_PORT:-4180}} - - REFLEX_FRONTEND_URL=${PLAYGROUND_PUBLIC_URL:-http://localhost:${OAUTH2_PROXY_PORT:-4180}} - # Should be the next release image -# image: ghcr.io/etalab-ia/opengatellm/playground:0.4.2 + image: ghcr.io/etalab-ia/opengatellm/playground:0.4.2 environment: - "OPENGATELLM_URL=${OPENGATELLM_URL:-http://api:8000}" - "REDIS_HOST=redis" @@ -50,26 +39,6 @@ services: postgres: condition: service_healthy - # OAuth2-proxy : point d'entrée pour l'authentification ProConnect (OIDC) - # Proxifie vers le playground après authentification. - # Accès : http://localhost:${OAUTH2_PROXY_PORT:-4180} - # Pour l'activer : docker compose --profile proconnect up - oauth2-proxy: - image: quay.io/oauth2-proxy/oauth2-proxy:v7.8.1 - profiles: ["proconnect"] - command: --config /oauth2-proxy.cfg - ports: - - "${OAUTH2_PROXY_PORT:-4180}:4180" - volumes: - - "./oauth2-proxy.cfg:/oauth2-proxy.cfg:ro" - environment: - - "OAUTH2_PROXY_CLIENT_ID=${PROCONNECT_CLIENT_ID}" - - "OAUTH2_PROXY_CLIENT_SECRET=${PROCONNECT_CLIENT_SECRET}" - - "OAUTH2_PROXY_COOKIE_SECRET=${OAUTH2_PROXY_COOKIE_SECRET}" - depends_on: - playground: - condition: service_healthy - postgres: image: postgres:16.5 restart: always diff --git a/compose.proconnect.yml b/compose.proconnect.yml deleted file mode 100644 index d107fbb1d..000000000 --- a/compose.proconnect.yml +++ /dev/null @@ -1,132 +0,0 @@ -name: opengatellm - -services: - api: - build: - context: . - dockerfile: api/Dockerfile -# Should be the next release image -# image: ghcr.io/etalab-ia/opengatellm/api:0.4.2 - restart: always - env_file: .env - ports: - - "${API_PORT:-8000}:8000" - volumes: - - "${CONFIG_FILE:-./config.yml}:/config.yml:ro" # outside the container, do not change this line - depends_on: - redis: - condition: service_healthy - postgres: - condition: service_healthy - elasticsearch: - condition: service_healthy - - playground: - build: - context: . - dockerfile: playground/Dockerfile - args: - - REFLEX_BACKEND_URL=${PLAYGROUND_PUBLIC_URL:-http://localhost:${OAUTH2_PROXY_PORT:-4180}} - - REFLEX_FRONTEND_URL=${PLAYGROUND_PUBLIC_URL:-http://localhost:${OAUTH2_PROXY_PORT:-4180}} - # Should be the next release image -# image: ghcr.io/etalab-ia/opengatellm/playground:0.4.2 - environment: - - "OPENGATELLM_URL=${OPENGATELLM_URL:-http://api:8000}" - - "PLAYGROUND_ADMIN_API_KEY=${PLAYGROUND_ADMIN_API_KEY}" - - "REDIS_HOST=redis" - - "REDIS_PORT=${REDIS_PORT:-6379}" - ports: - - "${PLAYGROUND_PORT:-8501}:8501" - volumes: - - "./${CONFIG_FILE:-config.yml}:/config.yml:ro" - healthcheck: - test: [ "CMD-SHELL", "curl -sf http://localhost:8501/ping || exit 1" ] - interval: 5s - timeout: 5s - retries: 10 - start_period: 30s - depends_on: - redis: - condition: service_healthy - postgres: - condition: service_healthy - - # OAuth2-proxy : point d'entrée pour l'authentification ProConnect (OIDC) - # Proxifie vers le playground après authentification. - # Accès : http://localhost:${OAUTH2_PROXY_PORT:-4180} - # Pour l'activer : docker compose --profile proconnect up - oauth2-proxy: - image: quay.io/oauth2-proxy/oauth2-proxy:v7.8.1 - profiles: ["proconnect"] - command: --config /oauth2-proxy.cfg - ports: - - "${OAUTH2_PROXY_PORT:-4180}:4180" - volumes: - - "./oauth2-proxy.cfg:/oauth2-proxy.cfg:ro" - environment: - - "OAUTH2_PROXY_CLIENT_ID=${PROCONNECT_CLIENT_ID}" - - "OAUTH2_PROXY_CLIENT_SECRET=${PROCONNECT_CLIENT_SECRET}" - - "OAUTH2_PROXY_COOKIE_SECRET=${OAUTH2_PROXY_COOKIE_SECRET}" - depends_on: - playground: - condition: service_healthy - - postgres: - image: postgres:16.5 - restart: always - user: postgres - environment: - - "POSTGRES_USER=${POSTGRES_USER:-postgres}" - - "POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-changeme}" - - "POSTGRES_DB=postgres" - ports: - - "${POSTGRES_PORT:-5432}:5432" - volumes: - - postgres:/var/lib/postgresql/data - healthcheck: - test: [ "CMD-SHELL", "pg_isready", "-U", "postgres" ] - interval: 4s - timeout: 10s - retries: 5 - start_period: 60s - - redis: - image: redis/redis-stack-server:7.4.0-v7 - restart: always - environment: - REDIS_ARGS: "--dir /data --requirepass ${REDIS_PASSWORD:-changeme} --user ${REDIS_USER:-redis} on >password ~* allcommands --save 60 1 --appendonly yes" - ports: - - "${REDIS_PORT:-6379}:6379" - volumes: - - redis:/data - healthcheck: - test: [ "CMD", "redis-cli", "--raw", "incr", "ping" ] - interval: 4s - timeout: 10s - retries: 5 - start_period: 60s - - elasticsearch: - image: docker.elastic.co/elasticsearch/elasticsearch:9.0.2 - restart: always - ports: - - "${ELASTICSEARCH_PORT:-9200}:9200" - environment: - - discovery.type=single-node - - xpack.security.enabled=false - - "ES_JAVA_OPTS=-Xms1g -Xmx1g" - - "ELASTIC_USERNAME=elasticsearch" - - "ELASTIC_PASSWORD=changeme" - volumes: - - elasticsearch:/usr/share/elasticsearch/data - healthcheck: - test: [ "CMD-SHELL", "bash", "-c", ":> /dev/tcp/127.0.0.1/9200" ] - interval: 4s - timeout: 10s - retries: 5 - start_period: 60s - -volumes: - elasticsearch: - postgres: - redis: diff --git a/config.example.yml b/config.example.yml index 01d059007..88e799b02 100644 --- a/config.example.yml +++ b/config.example.yml @@ -84,8 +84,9 @@ settings: # search_multi_agents_reranker_model: my-model playground_opengatellm_url: ${OPENGATELLM_URL} - playground_admin_api_key: ${PLAYGROUND_ADMIN_API_KEY} - playground_default_role_id: 2 + # playground_sso_enabled: False + # playground_sso_opengatellm_admin_api_key: ${SSO_OPENGATELLM_ADMIN_API_KEY} + # playground_sso_opengatellm_default_role_id: 1 # playground_default_model: my-model # playground_theme_has_background: True # playground_theme_accent_color: purple diff --git a/playground/app/app.py b/playground/app/app.py index 5e0db39b6..ba3be48d6 100644 --- a/playground/app/app.py +++ b/playground/app/app.py @@ -122,7 +122,6 @@ def providers() -> rx.Component: # Create the app with theme configuration app = rx.App( - stylesheets=["proconnect.css"], theme=rx.theme( has_background=configuration.settings.playground_theme_has_background, accent_color=configuration.settings.playground_theme_accent_color, @@ -135,8 +134,11 @@ def providers() -> rx.Component: head_components=[rx.el.link(rel="icon", type="image/svg+xml", href="/favicon.svg")], ) + # Add pages -app.add_page(component=index, route="/", on_load=[AuthState.login_proconnect]) +sso_login = [AuthState.sso_login] if configuration.settings.playground_sso_enabled else [] + +app.add_page(component=index, route="/", on_load=sso_login) app.add_page(component=account, route="/account") app.add_page(component=keys, route="/keys", on_load=[KeysState.load_entities]) app.add_page(component=usage, route="/usage", on_load=[UsageState.load_entities]) diff --git a/playground/app/core/configuration.py b/playground/app/core/configuration.py index 371467dbb..993acdfc8 100644 --- a/playground/app/core/configuration.py +++ b/playground/app/core/configuration.py @@ -2,7 +2,7 @@ import logging import os import re -from typing import Any +from typing import Any, get_args, get_origin from pydantic import BaseModel, ConfigDict, Field, constr, field_validator, model_validator from pydantic import ValidationError as PydanticValidationError @@ -12,7 +12,7 @@ from app.core.variables import DEFAULT_APP_NAME -def custom_validation_error(url: str | None = None): +def custom_validation_error(suffix: str = ""): """ Decorator to override Pydantic ValidationError to change error message. @@ -21,25 +21,50 @@ def custom_validation_error(url: str | None = None): """ class ValidationError(Exception): - def __init__(self, exc: PydanticValidationError, cls: BaseModel, url: str): + def __init__( + self, exc: PydanticValidationError, cls: BaseModel, base_url: str = "https://docs.opengatellm.org/configuration/configuration_file" + ): super().__init__() - error_count = exc.error_count() error_content = exc.errors() - message = f"{error_count} validation error for {cls.__name__}\n" + def resolve_model_for_error(model: type[BaseModel], loc: tuple[Any, ...]): + current_model = model + documentation_url = base_url + + for idx, part in enumerate(loc): + if not isinstance(part, str): + continue + if part not in current_model.__pydantic_fields__: + break + + field_info = current_model.__pydantic_fields__[part] + + annotation = field_info.annotation + next_model = None + origin = get_origin(annotation) + args = get_args(annotation) + candidates = args if origin is not None else (annotation,) + + for candidate in candidates: + if isinstance(candidate, type) and issubclass(candidate, BaseModel): + next_model = candidate + break + + if next_model is None: + break + + current_model = next_model + documentation_url = f"{base_url}#{current_model.__name__.lower()}{suffix}" + + return documentation_url + + message = str(exc) for error in error_content: - url = url or error["url"] - if error["type"] == "assertion_error": - message += f"{error['msg']}\n" - else: - if len(error["loc"]) > 0: - message += f"{error['loc'][0]}\n" - message += f" {error["msg"]} [type={error["type"]}, input_value={error.get("input", "")}, input_type={type(error.get("input")).__name__}]\n" # fmt: off - if len(error["loc"]) > 0: - description = cls.__pydantic_fields__[error["loc"][0]].description - if description: - message += f"\n {description}\n" - message += f" For further information visit {url}\n\n" + loc = tuple(error.get("loc", ())) + documentation_url = resolve_model_for_error(cls, loc) + original_line = f" For further information visit {error['url']}" + replacement_line = f" For further information visit {documentation_url}" + message = message.replace(original_line, replacement_line, 1) self.message = message @@ -54,7 +79,7 @@ def new_init(self, **data): try: original_init(self, **data) except PydanticValidationError as e: - raise ValidationError(exc=e, cls=cls, url=url) from None # hide previous traceback + raise ValidationError(exc=e, cls=cls) from None # hide previous traceback cls.__init__ = new_init return cls @@ -66,26 +91,29 @@ class ConfigBaseModel(BaseModel): model_config = ConfigDict(extra="allow") -@custom_validation_error(url="https://docs.opengatellm.org/configuration/configuration_file#redisdependency-1") +@custom_validation_error(suffix="-1") class RedisDependency(ConfigBaseModel): url: constr(strip_whitespace=True, min_length=1) = Field(..., pattern=r"^redis://", description="Redis connection url.", examples=["redis://:changeme@localhost:6379"]) # fmt: off -@custom_validation_error(url="https://docs.opengatellm.org/configuration/configuration_file#dependencies-1") +@custom_validation_error(suffix="-1") class Dependencies(ConfigBaseModel): redis: RedisDependency | None = Field(default=None, description="Set the Redis connection url to use as stage manager. See https://reflex.dev/docs/api-reference/config/ for more information.") # fmt: off -@custom_validation_error(url="https://docs.opengatellm.org/configuration/configuration_file#settings-1") +@custom_validation_error(suffix="-1") class Settings(ConfigBaseModel): auth_key_max_expiration_days: int | None = Field(default=None, ge=1, description="Maximum number of days for a token to be valid.") # fmt: off routing_max_priority: int = Field(default=10, ge=0, description="Maximum allowed priority in routing tasks.") # fmt: off app_title: str = Field(default=DEFAULT_APP_NAME, description="The title of the application.") playground_opengatellm_url: str = Field(default="http://localhost:8000", description="The URL of the OpenGateLLM API.") - playground_admin_api_key: str = Field(description="The API key to use to access the admin API.") - playground_default_role_id: str | int = Field(description="The default role ID for new users.") playground_opengatellm_timeout: int = Field(default=60, description="The timeout in seconds for the OpenGateLLM API.") + + playground_sso_enabled: bool = Field(default=False, description="Whether SSO is enabled.") + playground_sso_opengatellm_admin_api_key: str | None = Field(default=None, description="To activate SSO, set OpenGateLLM API key with ADMIN permissions to create users and tokens.") # fmt: off + playground_sso_opengatellm_default_role_id: int | None = Field(default=None, description="To activate SSO, set the default role ID of OpenGateLLM API for new users.") # fmt: off + playground_default_model: str | None = Field(default=None, description="The first model selected in chat page.") playground_theme_has_background: bool = Field(default=True, description="Whether the theme has a background.") playground_theme_accent_color: str = Field(default="purple", description="The primary color used for default buttons, typography, backgrounds, etc. See available colors at https://www.radix-ui.com/colors.") # fmt: off @@ -99,6 +127,15 @@ class Settings(ConfigBaseModel): reference_url: str | None = Field(default="http://localhost:8000/redoc", pattern=r"^http[s]?://", description="Reference URL. If not provided, deactivated reference link in the navigation bar.") # fmt: off documentation_url: str | None = Field(default="https://docs.opengatellm.org", pattern=r"^http[s]?://", description="Documentation URL. If not provided, deactivated documentation link in the navigation bar.") # fmt: off + @model_validator(mode="after") + def validate_sso_enabled(self): + if self.playground_sso_enabled: + if self.playground_sso_opengatellm_admin_api_key is None: + raise ValueError("SSO is enabled but no OpenGateLLM API key with ADMIN permissions is provided.") + if self.playground_sso_opengatellm_default_role_id is None: + raise ValueError("SSO is enabled but no default role ID is provided.") + return self + class ConfigFile(ConfigBaseModel): """ @@ -132,6 +169,14 @@ def setup_config(cls, values) -> Any: file_content = cls.replace_environment_variables(file_content="".join(uncommented_lines)) config = ConfigFile(**yaml.safe_load(stream=file_content)) + try: + default_role_id = config.settings.playground_sso_opengatellm_default_role_id + if default_role_id is not None: + default_role_id = int(default_role_id) + config.settings.playground_sso_opengatellm_default_role_id = default_role_id + except ValueError: + raise ValueError("For SSO to be enabled, default role ID must be an integer.") + values.dependencies = config.dependencies values.settings = config.settings diff --git a/playground/app/features/account/state.py b/playground/app/features/account/state.py index f218eb97c..0abad9179 100644 --- a/playground/app/features/account/state.py +++ b/playground/app/features/account/state.py @@ -3,7 +3,6 @@ import httpx import reflex as rx -from app.core.configuration import configuration from app.features.auth.state import AuthState from app.shared.components.toasts import httpx_error_toast @@ -67,7 +66,7 @@ async def change_password(self): url=f"{self.opengatellm_url}/v1/me/info", headers={"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"}, json={"current_password": self.current_password, "password": self.new_password}, - timeout=configuration.settings.playground_opengatellm_timeout, + timeout=self.opengatellm_timeout, ) response.raise_for_status() @@ -104,7 +103,7 @@ async def update_name(self): url=f"{self.opengatellm_url}/v1/me/info", headers={"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"}, json={"name": self.edit_name.strip()}, - timeout=configuration.settings.playground_opengatellm_timeout, + timeout=self.opengatellm_timeout, ) response.raise_for_status() diff --git a/playground/app/features/auth/components/forms.py b/playground/app/features/auth/components/forms.py index 9d70bc41a..cf9a5d577 100644 --- a/playground/app/features/auth/components/forms.py +++ b/playground/app/features/auth/components/forms.py @@ -57,7 +57,7 @@ def login_form() -> rx.Component: ), rx.button( "Sign In", - on_click=AuthState.login_direct, + on_click=AuthState.basic_login, width="100%", loading=AuthState.is_loading, disabled=AuthState.is_loading, @@ -66,40 +66,6 @@ def login_form() -> rx.Component: spacing="4", width="100%", ), - rx.vstack( - rx.hstack( - rx.separator(flex_grow="1"), - rx.text("ou", size="1", color=rx.color("gray", 9)), - rx.separator(flex_grow="1"), - width="100%", - align="center", - spacing="2", - ), - rx.center( - rx.el.button( - rx.el.span( - "S'identifier avec ProConnect", - class_name="proconnect-sr-only", - ), - class_name="proconnect-button", - type="button", - on_click=AuthState.login_proconnect, - ), - width="100%", - ), - rx.center( - rx.el.a( - "Qu'est-ce que ProConnect ?", - href="https://www.proconnect.gouv.fr/", - target="_blank", - rel="noopener noreferrer", - ), - width="100%", - ), - spacing="3", - width="100%", - padding_top="1em", - ), spacing="0", width="100%", ), diff --git a/playground/app/features/auth/state.py b/playground/app/features/auth/state.py index 41e55d681..43458a13a 100644 --- a/playground/app/features/auth/state.py +++ b/playground/app/features/auth/state.py @@ -30,8 +30,9 @@ class AuthState(rx.State): is_loading: bool = False opengatellm_url: str = configuration.settings.playground_opengatellm_url - admin_api_key: str = configuration.settings.playground_admin_api_key - default_role_id: int = int(configuration.settings.playground_default_role_id) + opengatellm_timeout: int = configuration.settings.playground_opengatellm_timeout + sso_opengatellm_admin_api_key: str | None = configuration.settings.playground_sso_opengatellm_admin_api_key + sso_opengatellm_default_role_id: int | None = configuration.settings.playground_sso_opengatellm_default_role_id # Form fields email_input: str = "" @@ -47,23 +48,47 @@ def set_password_input(self, value: str): """Set password input value.""" self.password_input = value + async def _login(self, client: httpx.AsyncClient, email: str, password: str): + response = await client.post( + url=f"{self.opengatellm_url}/v1/auth/login", + json={"email": email, "password": password}, + timeout=self.opengatellm_timeout, + ) + return response + + async def _create_api_key(self, client: httpx.AsyncClient, email: str): + response = await client.post( + url=f"{self.opengatellm_url}/v1/admin/tokens", + json={"email": email, "name": "playground"}, + headers={"Authorization": f"Bearer {self.sso_opengatellm_admin_api_key}"}, + timeout=self.opengatellm_timeout, + ) + return response + + async def _create_user(self, client: httpx.AsyncClient, email: str): + response = await client.post( + url=f"{self.opengatellm_url}/v1/admin/users", + json={"email": email, "name": email, "role": self.sso_opengatellm_default_role_id}, + headers={"Authorization": f"Bearer {self.sso_opengatellm_admin_api_key}"}, + timeout=self.opengatellm_timeout, + ) + return response + + async def _get_user_info(self, client: httpx.AsyncClient, api_key: str): + response = await client.get( + url=f"{self.opengatellm_url}/v1/me/info", + headers={"Authorization": f"Bearer {api_key}"}, + timeout=self.opengatellm_timeout, + ) + return response + @rx.event - async def login_proconnect(self): - """Auto-login using the ProConnect identity injected by oauth2-proxy. - - Reads the X-Auth-Request-Email header forwarded by oauth2-proxy into the WebSocket - connection. If the header is present, calls POST /v1/auth/proconnect on the API - (no password required - the OIDC authentication already happened at the proxy level). - If the header is absent the user landed directly on the playground (port 8501) and - no action is taken - they can still log in with email/password. - """ - if self.is_authenticated: - return + async def basic_login(self): + email = self.email_input.strip() + password = self.password_input.strip() - # oauth2-proxy injecte X-Forwarded-Email (via pass_user_headers=true) - # accessible via raw_headers (clés en minuscules avec tirets) - email = self.router.headers.raw_headers.get("x-forwarded-email") - if not email: + if not email or not password: + yield rx.toast.warning("Email and password are required", position="bottom-right") return self.is_loading = True @@ -72,75 +97,19 @@ async def login_proconnect(self): response = None try: async with httpx.AsyncClient() as client: - # response = await client.post( - # f"{self.opengatellm_url}/v1/auth/proconnect", - # headers={"X-Auth-Request-Email": email}, - # timeout=configuration.settings.playground_opengatellm_timeout, - # ) - import requests - - url = f"{self.opengatellm_url}/v1/admin/users" - response = requests.get(url=url, params={"email": email}, headers={"Authorization": f"Bearer {self.admin_api_key}"}) - if response.status_code == 404: - # TODO: rendre le password optionnel - response = requests.post( - url=url, - json={"email": email, "name": email, "password": "changeme", "role": self.default_role_id}, - headers={"Authorization": f"Bearer {self.admin_api_key}"}, - ) - if response.status_code != 201: - error_detail = response.json().get("detail", "Failed to create user") - error_detail = f"Failed to create user: {error_detail}\nURL: {url}\nAPI Key: {self.admin_api_key}" - yield rx.toast.error(error_detail, position="bottom-right") - self.is_loading = False - yield - return - - user_id = response.json().get("id") - - elif response.status_code == 200: - user_id = response.json().get("data", [])[0]["id"] - else: - error_detail = response.json().get("detail", "Failed to fetch user info") - error_detail = f"Failed to fetch user info: {error_detail}\nURL: {url}\nAPI Key: {self.admin_api_key}" - - yield rx.toast.error(error_detail, position="bottom-right") - self.is_loading = False - yield - return - - # TODO: support email as param to /v1/admin/tokens endpoint - # TODO: add SSO expiration duration - response = requests.post( - url=f"{self.opengatellm_url}/v1/admin/tokens", - json={"user": user_id, "name": "playground"}, - headers={"Authorization": f"Bearer {self.admin_api_key}"}, - ) - if response.status_code != 201: - error_detail = response.json().get("detail", "Failed to create token") - error_detail = f"Failed to create token: {error_detail}\nURL: {url}\nAPI Key: {self.admin_api_key}" - yield rx.toast.error(error_detail, position="bottom-right") - self.is_loading = False - yield - return - - api_key = response.json().get("token") + # Create API key + response = await self._login(client=client, email=email, password=password) + response.raise_for_status() + api_key = response.json().get("key") api_key_id = response.json().get("id") - response = await client.get( - f"{self.opengatellm_url}/v1/me/info", - headers={"Authorization": f"Bearer {api_key}"}, - timeout=configuration.settings.playground_opengatellm_timeout, - ) - - if response.status_code != 200: - yield rx.toast.error("Failed to fetch user info", position="bottom-right") - self.is_loading = False - yield - return + # Get user info + response = await self._get_user_info(client=client, api_key=api_key) + response.raise_for_status() user_data = response.json() + # Update state self.is_authenticated = True self.user_id = user_data.get("id") self.user_email = user_data.get("email") @@ -155,6 +124,14 @@ async def login_proconnect(self): self.user_permissions = user_data.get("permissions", []) self.user_limits = user_data.get("limits", []) + yield rx.toast.success("Successfully logged in!", position="bottom-right") + yield + + # Load models after successful login (if ChatState) + if hasattr(self, "load_models"): + async for _ in self.load_models(): + yield + except Exception as e: yield httpx_error_toast(exception=e, response=response) finally: @@ -162,13 +139,12 @@ async def login_proconnect(self): yield @rx.event - async def login_direct(self): - """Handle login using direct state values.""" - email = self.email_input.strip() - password = self.password_input.strip() + async def sso_login(self): + if self.is_authenticated: + return - if not email or not password: - yield rx.toast.warning("Email and password are required", position="bottom-right") + email = self.router.headers.raw_headers.get("x-forwarded-email") + if not email: return self.is_loading = True @@ -177,36 +153,23 @@ async def login_direct(self): response = None try: async with httpx.AsyncClient() as client: - # Login to get API key - response = await client.post( - f"{self.opengatellm_url}/v1/auth/login", - json={"email": email, "password": password}, - timeout=configuration.settings.playground_opengatellm_timeout, - ) - if response.status_code != 200: - error_detail = response.json().get("detail", "Login failed") - yield rx.toast.error(error_detail, position="bottom-right") - self.is_loading = False - yield - return - - login_data = response.json() - api_key = login_data.get("key") - api_key_id = login_data.get("id") + # Create API key + response = await self._create_api_key(client=client, email=email) - # Get user info - response = await client.get( - f"{self.opengatellm_url}/v1/me/info", - headers={"Authorization": f"Bearer {api_key}"}, - timeout=configuration.settings.playground_opengatellm_timeout, - ) - - if response.status_code != 200: - yield rx.toast.error("Failed to fetch user info", position="bottom-right") - self.is_loading = False - yield - return + if response.status_code == 404: + response = await self._create_user(client=client, email=email) + response.raise_for_status() + + response = await self._create_api_key(client=client, email=email) + response.raise_for_status() + response.raise_for_status() + api_key = response.json().get("token") + api_key_id = response.json().get("id") + + # Get user info + response = await self._get_user_info(client=client, api_key=api_key) + response.raise_for_status() user_data = response.json() # Update state @@ -224,14 +187,6 @@ async def login_direct(self): self.user_permissions = user_data.get("permissions", []) self.user_limits = user_data.get("limits", []) - yield rx.toast.success("Successfully logged in!", position="bottom-right") - yield - - # Load models after successful login (if ChatState) - if hasattr(self, "load_models"): - async for _ in self.load_models(): - yield - except Exception as e: yield httpx_error_toast(exception=e, response=response) finally: diff --git a/playground/app/features/chat/state.py b/playground/app/features/chat/state.py index 121b2d804..53d4e82e2 100644 --- a/playground/app/features/chat/state.py +++ b/playground/app/features/chat/state.py @@ -61,7 +61,7 @@ async def load_models(self): response = await client.get( f"{self.opengatellm_url}/v1/models", headers={"Authorization": f"Bearer {self.api_key}"}, - timeout=configuration.settings.playground_opengatellm_timeout, + timeout=self.opengatellm_timeout, ) response.raise_for_status() data = response.json() @@ -204,7 +204,7 @@ async def api_process_question(self, question: str): "Content-Type": "application/json", }, json=payload, - timeout=configuration.settings.playground_opengatellm_timeout, + timeout=self.opengatellm_timeout, ) as response: if response.status_code != 200: error_text = await response.aread() @@ -242,7 +242,7 @@ async def api_process_question(self, question: str): "Content-Type": "application/json", }, json=payload, - timeout=configuration.settings.playground_opengatellm_timeout, + timeout=self.opengatellm_timeout, ) if response.status_code != 200: diff --git a/playground/app/features/keys/state.py b/playground/app/features/keys/state.py index 7c7128512..cc8df19ca 100644 --- a/playground/app/features/keys/state.py +++ b/playground/app/features/keys/state.py @@ -56,7 +56,7 @@ async def load_entities(self): url=f"{self.opengatellm_url}/v1/me/keys", params=params, headers={"Authorization": f"Bearer {self.api_key}"}, - timeout=configuration.settings.playground_opengatellm_timeout, + timeout=self.opengatellm_timeout, ) response.raise_for_status() @@ -106,7 +106,7 @@ async def delete_entity(self): response = await client.delete( url=f"{self.opengatellm_url}/v1/me/keys/{self.entity_to_delete.id}", headers={"Authorization": f"Bearer {self.api_key}"}, - timeout=configuration.settings.playground_opengatellm_timeout, + timeout=self.opengatellm_timeout, ) response.raise_for_status() @@ -184,7 +184,7 @@ async def create_entity(self): url=f"{self.opengatellm_url}/v1/me/keys", json=payload, headers={"Authorization": f"Bearer {self.api_key}"}, - timeout=configuration.settings.playground_opengatellm_timeout, + timeout=self.opengatellm_timeout, ) response.raise_for_status() data = response.json() diff --git a/playground/app/features/organizations/state.py b/playground/app/features/organizations/state.py index 897d0d7b0..16e02bc90 100644 --- a/playground/app/features/organizations/state.py +++ b/playground/app/features/organizations/state.py @@ -3,7 +3,6 @@ import httpx import reflex as rx -from app.core.configuration import configuration from app.features.organizations.models import Organization from app.shared.components.toasts import httpx_error_toast from app.shared.states.entity_state import EntityState @@ -56,7 +55,7 @@ async def load_entities(self): url=f"{self.opengatellm_url}/v1/admin/organizations", params=params, headers={"Authorization": f"Bearer {self.api_key}"}, - timeout=configuration.settings.playground_opengatellm_timeout, + timeout=self.opengatellm_timeout, ) response.raise_for_status() @@ -105,7 +104,7 @@ async def delete_entity(self): response = await client.delete( url=f"{self.opengatellm_url}/v1/admin/organizations/{self.entity_to_delete.id}", headers={"Authorization": f"Bearer {self.api_key}"}, - timeout=configuration.settings.playground_opengatellm_timeout, + timeout=self.opengatellm_timeout, ) response.raise_for_status() @@ -152,7 +151,7 @@ async def create_entity(self): url=f"{self.opengatellm_url}/v1/admin/organizations", json=payload, headers={"Authorization": f"Bearer {self.api_key}"}, - timeout=configuration.settings.playground_opengatellm_timeout, + timeout=self.opengatellm_timeout, ) response.raise_for_status() @@ -210,7 +209,7 @@ async def edit_entity(self): url=f"{self.opengatellm_url}/v1/admin/organizations/{self.entity.id}", json=payload, headers={"Authorization": f"Bearer {self.api_key}"}, - timeout=configuration.settings.playground_opengatellm_timeout, + timeout=self.opengatellm_timeout, ) response.raise_for_status() diff --git a/playground/app/features/providers/state.py b/playground/app/features/providers/state.py index 89d63a6de..3097cb9ca 100644 --- a/playground/app/features/providers/state.py +++ b/playground/app/features/providers/state.py @@ -4,7 +4,6 @@ import pycountry import reflex as rx -from app.core.configuration import configuration from app.features.providers.models import Provider from app.shared.components.toasts import httpx_error_toast from app.shared.states.entity_state import EntityState @@ -118,7 +117,7 @@ async def load_entities(self): url=f"{self.opengatellm_url}/v1/admin/routers", params={"offset": offset, "limit": 100}, headers={"Authorization": f"Bearer {self.api_key}"}, - timeout=configuration.settings.playground_opengatellm_timeout, + timeout=self.opengatellm_timeout, ) response.raise_for_status() @@ -134,7 +133,7 @@ async def load_entities(self): f"{self.opengatellm_url}/v1/admin/providers", params=params, headers={"Authorization": f"Bearer {self.api_key}"}, - timeout=configuration.settings.playground_opengatellm_timeout, + timeout=self.opengatellm_timeout, ) response.raise_for_status() @@ -146,7 +145,7 @@ async def load_entities(self): response = await client.get( url=f"{self.opengatellm_url}/v1/admin/users/{provider['user_id']}", headers={"Authorization": f"Bearer {self.api_key}"}, - timeout=configuration.settings.playground_opengatellm_timeout, + timeout=self.opengatellm_timeout, ) if response.status_code == 404: self.provider_owners[provider["user_id"]] = "Master" @@ -160,7 +159,7 @@ async def load_entities(self): response = await client.get( url=f"{self.opengatellm_url}/v1/admin/routers/{provider['router_id']}", headers={"Authorization": f"Bearer {self.api_key}"}, - timeout=configuration.settings.playground_opengatellm_timeout, + timeout=self.opengatellm_timeout, ) if response.status_code == 200: @@ -213,7 +212,7 @@ async def delete_entity(self): response = await client.delete( url=f"{self.opengatellm_url}/v1/admin/providers/{self.entity_to_delete.id}", headers={"Authorization": f"Bearer {self.api_key}"}, - timeout=configuration.settings.playground_opengatellm_timeout, + timeout=self.opengatellm_timeout, ) response.raise_for_status() @@ -285,7 +284,7 @@ async def create_entity(self): url=f"{self.opengatellm_url}/v1/admin/providers", headers={"Authorization": f"Bearer {self.api_key}"}, json=payload, - timeout=configuration.settings.playground_opengatellm_timeout, + timeout=self.opengatellm_timeout, ) response.raise_for_status() @@ -351,7 +350,7 @@ async def edit_entity(self): url=f"{self.opengatellm_url}/v1/admin/providers/{self.entity.id}", json=payload, headers={"Authorization": f"Bearer {self.api_key}"}, - timeout=configuration.settings.playground_opengatellm_timeout, + timeout=self.opengatellm_timeout, ) response.raise_for_status() diff --git a/playground/app/features/roles/state.py b/playground/app/features/roles/state.py index 5ea2ed4cf..54709794a 100644 --- a/playground/app/features/roles/state.py +++ b/playground/app/features/roles/state.py @@ -4,7 +4,6 @@ import httpx import reflex as rx -from app.core.configuration import configuration from app.features.roles.models import Role from app.shared.components.toasts import httpx_error_toast from app.shared.states.entity_state import EntityState @@ -89,7 +88,7 @@ async def load_entities(self): f"{self.opengatellm_url}/v1/admin/routers", params={"offset": offset, "limit": limit}, headers={"Authorization": f"Bearer {self.api_key}"}, - timeout=configuration.settings.playground_opengatellm_timeout, + timeout=self.opengatellm_timeout, ) response.raise_for_status() @@ -110,7 +109,7 @@ async def load_entities(self): "order_direction": self.order_direction_value, }, headers={"Authorization": f"Bearer {self.api_key}"}, - timeout=configuration.settings.playground_opengatellm_timeout, + timeout=self.opengatellm_timeout, ) response.raise_for_status() @@ -160,7 +159,7 @@ async def delete_entity(self): response = await client.delete( url=f"{self.opengatellm_url}/v1/admin/roles/{self.entity_to_delete.id}", headers={"Authorization": f"Bearer {self.api_key}"}, - timeout=configuration.settings.playground_opengatellm_timeout, + timeout=self.opengatellm_timeout, ) response.raise_for_status() @@ -189,14 +188,12 @@ async def delete_limit(self, role: Role, router: str): if limit["router"] == router: continue - limits.extend( - [ - {"router_id": self.routers_dict[limit["router"]], "type": "rpm", "value": limit["rpm"]}, - {"router_id": self.routers_dict[limit["router"]], "type": "rpd", "value": limit["rpd"]}, - {"router_id": self.routers_dict[limit["router"]], "type": "tpm", "value": limit["tpm"]}, - {"router_id": self.routers_dict[limit["router"]], "type": "tpd", "value": limit["tpd"]}, - ] - ) + limits.extend([ + {"router_id": self.routers_dict[limit["router"]], "type": "rpm", "value": limit["rpm"]}, + {"router_id": self.routers_dict[limit["router"]], "type": "rpd", "value": limit["rpd"]}, + {"router_id": self.routers_dict[limit["router"]], "type": "tpm", "value": limit["tpm"]}, + {"router_id": self.routers_dict[limit["router"]], "type": "tpd", "value": limit["tpd"]}, + ]) yield @@ -208,7 +205,7 @@ async def delete_limit(self, role: Role, router: str): url=f"{self.opengatellm_url}/v1/admin/roles/{role.id}", json=payload, headers={"Authorization": f"Bearer {self.api_key}"}, - timeout=configuration.settings.playground_opengatellm_timeout, + timeout=self.opengatellm_timeout, ) response.raise_for_status() @@ -286,7 +283,7 @@ async def create_entity(self): url=f"{self.opengatellm_url}/v1/admin/roles", json=payload, headers={"Authorization": f"Bearer {self.api_key}"}, - timeout=configuration.settings.playground_opengatellm_timeout, + timeout=self.opengatellm_timeout, ) response.raise_for_status() @@ -326,7 +323,7 @@ async def create_limit(self, role: Role): f"{self.opengatellm_url}/v1/admin/roles/{role.id}", json=payload, headers={"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"}, - timeout=configuration.settings.playground_opengatellm_timeout, + timeout=self.opengatellm_timeout, ) response.raise_for_status() @@ -416,7 +413,7 @@ async def edit_entity(self): url=f"{self.opengatellm_url}/v1/admin/roles/{self.entity.id}", json=payload, headers={"Authorization": f"Bearer {self.api_key}"}, - timeout=configuration.settings.playground_opengatellm_timeout, + timeout=self.opengatellm_timeout, ) response.raise_for_status() diff --git a/playground/app/features/routers/state.py b/playground/app/features/routers/state.py index ee923c415..6ba648cba 100644 --- a/playground/app/features/routers/state.py +++ b/playground/app/features/routers/state.py @@ -3,7 +3,6 @@ import httpx import reflex as rx -from app.core.configuration import configuration from app.features.routers.models import Router from app.shared.components.toasts import httpx_error_toast from app.shared.states.entity_state import EntityState @@ -15,16 +14,14 @@ class RoutersState(EntityState): @rx.var def router_types_list(self) -> list[str]: """Get list of router types.""" - return sorted( - [ - "image-to-text", - "image-text-to-text", - "automatic-speech-recognition", - "text-embeddings-inference", - "text-generation", - "text-classification", - ] - ) + return sorted([ + "image-to-text", + "image-text-to-text", + "automatic-speech-recognition", + "text-embeddings-inference", + "text-generation", + "text-classification", + ]) @rx.var def router_load_balancing_strategies_list(self) -> list[str]: @@ -88,7 +85,7 @@ async def load_entities(self): f"{self.opengatellm_url}/v1/admin/routers", params=params, headers={"Authorization": f"Bearer {self.api_key}"}, - timeout=configuration.settings.playground_opengatellm_timeout, + timeout=self.opengatellm_timeout, ) response.raise_for_status() @@ -99,9 +96,9 @@ async def load_entities(self): if router["user_id"] not in self.router_owners: async with httpx.AsyncClient() as client: response = await client.get( - url=f"{self.opengatellm_url}/v1/admin/users/{router["user_id"]}", + url=f"{self.opengatellm_url}/v1/admin/users/{router['user_id']}", headers={"Authorization": f"Bearer {self.api_key}"}, - timeout=configuration.settings.playground_opengatellm_timeout, + timeout=self.opengatellm_timeout, ) if response.status_code == 404: self.router_owners[router["user_id"]] = "Master" @@ -152,7 +149,7 @@ async def delete_entity(self): response = await client.delete( url=f"{self.opengatellm_url}/v1/admin/routers/{self.entity_to_delete.id}", headers={"Authorization": f"Bearer {self.api_key}"}, - timeout=configuration.settings.playground_opengatellm_timeout, + timeout=self.opengatellm_timeout, ) response.raise_for_status() @@ -217,7 +214,7 @@ async def create_entity(self): url=f"{self.opengatellm_url}/v1/admin/routers", json=payload, headers={"Authorization": f"Bearer {self.api_key}"}, - timeout=configuration.settings.playground_opengatellm_timeout, + timeout=self.opengatellm_timeout, ) response.raise_for_status() @@ -285,7 +282,7 @@ async def edit_entity(self): url=f"{self.opengatellm_url}/v1/admin/routers/{self.entity.id}", json=payload, headers={"Authorization": f"Bearer {self.api_key}"}, - timeout=configuration.settings.playground_opengatellm_timeout, + timeout=self.opengatellm_timeout, ) response.raise_for_status() diff --git a/playground/app/features/usage/state.py b/playground/app/features/usage/state.py index 40e579e82..766ffefcb 100644 --- a/playground/app/features/usage/state.py +++ b/playground/app/features/usage/state.py @@ -6,7 +6,6 @@ import httpx import reflex as rx -from app.core.configuration import configuration from app.features.usage.models import Usage from app.shared.components.toasts import httpx_error_toast from app.shared.states.entity_state import EntityState @@ -64,7 +63,7 @@ async def load_entities(self): url=f"{self.opengatellm_url}/v1/me/usage", params=params, headers={"Authorization": f"Bearer {self.api_key}"}, - timeout=configuration.settings.playground_opengatellm_timeout, + timeout=self.opengatellm_timeout, ) response.raise_for_status() data = response.json() @@ -82,17 +81,15 @@ async def load_entities(self): def usage_rows(self) -> list[dict[str, Any]]: rows: list[dict[str, Any]] = [] for row in self.entities: - rows.append( - { - "date": row.created, - "endpoint": row.endpoint, - "key": row.key, - "model": row.model, - "tokens": "" if row.total_tokens == 0 else f"{row.prompt_tokens} → {row.completion_tokens}", - "cost": "" if row.cost == 0.0 or row.cost is None else f"{row.cost:.4f}", - "kgCO2eq": "" if row.kgco2eq is None else f"{round(row.kgco2eq, 5)}", - } - ) + rows.append({ + "date": row.created, + "endpoint": row.endpoint, + "key": row.key, + "model": row.model, + "tokens": "" if row.total_tokens == 0 else f"{row.prompt_tokens} → {row.completion_tokens}", + "cost": "" if row.cost == 0.0 or row.cost is None else f"{row.cost:.4f}", + "kgCO2eq": "" if row.kgco2eq is None else f"{round(row.kgco2eq, 5)}", + }) return rows ############################################################ diff --git a/playground/app/features/users/state.py b/playground/app/features/users/state.py index 409c8c28a..f6db0561b 100644 --- a/playground/app/features/users/state.py +++ b/playground/app/features/users/state.py @@ -3,7 +3,6 @@ import httpx import reflex as rx -from app.core.configuration import configuration from app.features.users.models import User from app.shared.components.toasts import httpx_error_toast from app.shared.states.entity_state import EntityState @@ -99,7 +98,7 @@ async def load_entities(self): response = await client.get( url=f"{self.opengatellm_url}/v1/admin/roles", headers={"Authorization": f"Bearer {self.api_key}"}, - timeout=configuration.settings.playground_opengatellm_timeout, + timeout=self.opengatellm_timeout, ) response.raise_for_status() @@ -121,7 +120,7 @@ async def load_entities(self): url=f"{self.opengatellm_url}/v1/admin/organizations", params={"offset": offset, "limit": 100}, headers={"Authorization": f"Bearer {self.api_key}"}, - timeout=configuration.settings.playground_opengatellm_timeout, + timeout=self.opengatellm_timeout, ) response.raise_for_status() @@ -138,7 +137,7 @@ async def load_entities(self): url=f"{self.opengatellm_url}/v1/admin/users", params=params, headers={"Authorization": f"Bearer {self.api_key}"}, - timeout=configuration.settings.playground_opengatellm_timeout, + timeout=self.opengatellm_timeout, ) response.raise_for_status() @@ -187,7 +186,7 @@ async def delete_entity(self): response = await client.delete( url=f"{self.opengatellm_url}/v1/admin/users/{self.entity_to_delete.id}", headers={"Authorization": f"Bearer {self.api_key}"}, - timeout=configuration.settings.playground_opengatellm_timeout, + timeout=self.opengatellm_timeout, ) response.raise_for_status() @@ -272,7 +271,7 @@ async def create_entity(self): url=f"{self.opengatellm_url}/v1/admin/users", json=payload, headers={"Authorization": f"Bearer {self.api_key}"}, - timeout=configuration.settings.playground_opengatellm_timeout, + timeout=self.opengatellm_timeout, ) response.raise_for_status() @@ -347,7 +346,7 @@ async def edit_entity(self): url=f"{self.opengatellm_url}/v1/admin/users/{self.entity.id}", json=payload, headers={"Authorization": f"Bearer {self.api_key}"}, - timeout=configuration.settings.playground_opengatellm_timeout, + timeout=self.opengatellm_timeout, ) response.raise_for_status() diff --git a/playground/assets/proconnect.css b/playground/assets/proconnect.css deleted file mode 100644 index ed73828e3..000000000 --- a/playground/assets/proconnect.css +++ /dev/null @@ -1,26 +0,0 @@ -.proconnect-sr-only { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - white-space: nowrap; - border-width: 0; -} - -.proconnect-button { - background-color: transparent !important; - background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPScyMTEnIGhlaWdodD0nNTgnIGZpbGw9J25vbmUnPjxwYXRoIGZpbGw9JyMwMDAwOTEnIGQ9J00wIDBoMjExdjU4SDB6Jy8+PHBhdGggZmlsbD0nI2ZmZicgZD0nbTY5Ljk4NiAyNi4zNjggMS4xNTYtMS4wNzFjLjgzMyAxLjA1NCAxLjgxOSAxLjU5OCAyLjk0MSAxLjU5OCAxLjI5MiAwIDIuMDQtLjgxNiAyLjA0LTEuOTA0IDAtMi41NS01LjYyNy0yLjI0NC01LjYyNy02LjAzNSAwLTEuNzM0IDEuNDI4LTMuMTk2IDMuNDUxLTMuMTk2IDEuNjgzIDAgMi45MDcuNzY1IDMuNzkxIDEuOTM4bC0xLjE5IDEuMDM3Yy0uNjk3LTEuMDAzLTEuNTQ3LTEuNTQ3LTIuNTg0LTEuNTQ3LTEuMTA1IDAtMS44MzYuNzQ4LTEuODM2IDEuNzM0IDAgMi41NjcgNS42MjcgMi4yNDQgNS42MjcgNi4wNTIgMCAyLjAyMy0xLjU4MSAzLjM0OS0zLjY1NSAzLjM0OS0xLjc2OCAwLTMuMDc3LS42NjMtNC4xMTQtMS45NTVabTEwLjgxNy01LjcxMkg3OS40NmwxLjQ0NS00LjU1NmgxLjY0OWwtMS43NTEgNC41NTZabTQuODE4LTMuNDUxYy0uNTYgMC0xLjAyLS40NTktMS4wMi0xLjAyYTEuMDIgMS4wMiAwIDAgMSAxLjAyLTEuMDAzYy41NjEgMCAxLjAwMy40NTkgMS4wMDMgMS4wMDMgMCAuNTYxLS40NDIgMS4wMi0xLjAwMyAxLjAyWk04NC44OTEgMjh2LTguNTY4aDEuNDQ0VjI4SDg0Ljg5Wm0zLjc2Ny00LjI4NGMwLTIuNDk5IDEuNzE3LTQuNjI0IDQuNDAzLTQuNjI0IDEuMjQxIDAgMi4yNjEuNDU5IDMuMDQzIDEuMjkyVjE1LjI1aDEuNDQ1VjI4aC0xLjQ0NXYtLjk1MmMtLjc4Mi44MzMtMS44MDIgMS4yOTItMy4wNDMgMS4yOTItMi42ODYgMC00LjQwMy0yLjEyNS00LjQwMy00LjYyNFptMS41MyAwYzAgMS44MTkgMS4yMjQgMy4yNjQgMy4wNDMgMy4yNjQgMS4xOSAwIDIuMjEtLjU3OCAyLjg3My0xLjU5OFYyMi4wNWMtLjY4LTEuMDM3LTEuNy0xLjU5OC0yLjg3My0xLjU5OC0xLjgxOSAwLTMuMDQzIDEuNDQ1LTMuMDQzIDMuMjY0Wm0xOC4wMjMgMi44NzNjLS43OTkgMS4wNzEtMi4wNzQgMS43NTEtMy42NzIgMS43NTEtMi44OSAwLTQuNjc1LTIuMTI1LTQuNjc1LTQuNjI0IDAtMi42MDEgMS42NjYtNC42MjQgNC4zMTgtNC42MjQgMi4zMjkgMCAzLjg0MiAxLjU4MSAzLjg0MiAzLjcyMyAwIC4zNC0uMDUxLjY4LS4xMDIuOTE4aC02LjU2MnYuMDM0YzAgMS44ODcgMS4yOTIgMy4yNjQgMy4yMTMgMy4yNjQgMS4wODggMCAyLjAwNi0uNTEgMi41NjctMS4yNzVsMS4wNzEuODMzWm0tNC4wMTItNi4yNTZjLTEuMzk0IDAtMi4zOC43ODItMi43MiAyLjI2MWg1LjA4M2MtLjA1MS0xLjI0MS0uOTUyLTIuMjYxLTIuMzYzLTIuMjYxWk0xMTAuNDczIDI4di04LjU2OGgxLjQ0NXYuOTY5Yy42OTctLjc2NSAxLjU4MS0xLjMwOSAyLjg1Ni0xLjMwOSAxLjkyMSAwIDMuMzQ5IDEuMjkyIDMuMzQ5IDMuNzIzVjI4aC0xLjQ2MnYtNS4xMzRjMC0xLjUzLS44NS0yLjQxNC0yLjE3Ni0yLjQxNC0xLjI0MSAwLTIuMDIzLjcxNC0yLjU2NyAxLjYxNVYyOGgtMS40NDVabTExLjA1Mi0yLjg3M3YtNC4zNjloLTEuNjE1di0xLjMyNmgxLjYxNVYxNy4yOWgxLjQ2MnYyLjE0MmgyLjk3NXYxLjMyNmgtMi45NzV2NC4zNjljMCAxLjM0My42OCAxLjcxNyAxLjcxNyAxLjcxNy41NjEgMCAuOTUyLS4wNjggMS4yNzUtLjIwNHYxLjI5MmMtLjQwOC4xNy0uODY3LjIzOC0xLjQ3OS4yMzgtMS45MDQgMC0yLjk3NS0uOTUyLTIuOTc1LTMuMDQzWm03LjM3Ny03LjkyMmMtLjU2MSAwLTEuMDItLjQ1OS0xLjAyLTEuMDJhMS4wMiAxLjAyIDAgMCAxIDEuMDItMS4wMDNjLjU2MSAwIDEuMDAzLjQ1OSAxLjAwMyAxLjAwMyAwIC41NjEtLjQ0MiAxLjAyLTEuMDAzIDEuMDJaTTEyOC4xNzEgMjh2LTguNTY4aDEuNDQ1VjI4aC0xLjQ0NVptMy4zNzctOC41NjhoMS42MTV2LTEuMDU0YzAtMS44MzYgMS4yMDctMy4xMjggMy4wNDMtMy4xMjguOTUyIDAgMS43LjM0IDIuMjEuODMzbC0uOTAxIDEuMDU0YTEuNjMzIDEuNjMzIDAgMCAwLTEuMjkyLS41NzhjLS45MzUgMC0xLjU5OC42OC0xLjU5OCAxLjc4NXYxLjA4OGgyLjk3NXYxLjMyNmgtMi45NzVWMjhoLTEuNDYydi03LjI0MmgtMS42MTV2LTEuMzI2Wm04LjU0My0yLjIyN2MtLjU2MSAwLTEuMDItLjQ1OS0xLjAyLTEuMDJhMS4wMiAxLjAyIDAgMCAxIDEuMDItMS4wMDNjLjU2MSAwIDEuMDAzLjQ1OSAxLjAwMyAxLjAwMyAwIC41NjEtLjQ0MiAxLjAyLTEuMDAzIDEuMDJaTTEzOS4zNiAyOHYtOC41NjhoMS40NDVWMjhoLTEuNDQ1Wm0xMi4xMTUtMS40MTFjLS43OTkgMS4wNzEtMi4wNzQgMS43NTEtMy42NzIgMS43NTEtMi44OSAwLTQuNjc1LTIuMTI1LTQuNjc1LTQuNjI0IDAtMi42MDEgMS42NjYtNC42MjQgNC4zMTgtNC42MjQgMi4zMjkgMCAzLjg0MiAxLjU4MSAzLjg0MiAzLjcyMyAwIC4zNC0uMDUxLjY4LS4xMDIuOTE4aC02LjU2MnYuMDM0YzAgMS44ODcgMS4yOTIgMy4yNjQgMy4yMTMgMy4yNjQgMS4wODggMCAyLjAwNi0uNTEgMi41NjctMS4yNzVsMS4wNzEuODMzWm0tNC4wMTItNi4yNTZjLTEuMzk0IDAtMi4zOC43ODItMi43MiAyLjI2MWg1LjA4M2MtLjA1MS0xLjI0MS0uOTUyLTIuMjYxLTIuMzYzLTIuMjYxWk0xNTMuNzM3IDI4di04LjU2OGgxLjQ0NXYxLjA3MWMuNjI5LS43NDggMS40MTEtMS4yNDEgMi40OTktMS4yNDEuMjcyIDAgLjUyNy4wMzQuNzMxLjEwMnYxLjQ5NmEzLjEwNSAzLjEwNSAwIDAgMC0uODUtLjExOWMtMS4xMjIgMC0xLjg1My41NzgtMi4zOCAxLjQ0NVYyOGgtMS40NDVabTEzLjY4NS4zNGMtMS42ODMgMC0yLjgyMi0uOTUyLTIuODIyLTIuNDQ4IDAtMS4zMjYuOTg2LTIuMjc4IDIuODIyLTIuNTY3bDIuODczLS40NzZ2LS41OTVjMC0xLjE5LS44NS0xLjg3LTIuMDU3LTEuODctMS4wMDMgMC0xLjgzNi40NDItMi4zMjkgMS4xOWwtMS4wODgtLjgzM2MuNzQ4LTEuMDIgMS45NTUtMS42NDkgMy40NTEtMS42NDkgMi4xNzYgMCAzLjQ2OCAxLjI3NSAzLjQ2OCAzLjE2MlYyOGgtMS40NDV2LTEuMDg4Yy0uNjQ2LjkwMS0xLjcxNyAxLjQyOC0yLjg3MyAxLjQyOFptLTEuMzc3LTIuNDk5YzAgLjczMS42MjkgMS4yOTIgMS42MTUgMS4yOTIgMS4xMzkgMCAyLjA0LS41OTUgMi42MzUtMS41ODFWMjMuOTJsLTIuNTMzLjQ0MmMtMS4xOS4xODctMS43MTcuNzMxLTEuNzE3IDEuNDc5Wm03LjI1Mi02LjQwOWgxLjU2NGwyLjczNyA3LjA1NSAyLjczNy03LjA1NWgxLjU2NEwxNzguNTUgMjhoLTEuOTA0bC0zLjM0OS04LjU2OFptMTcuODU2IDcuMTU3Yy0uNzk5IDEuMDcxLTIuMDc0IDEuNzUxLTMuNjcyIDEuNzUxLTIuODkgMC00LjY3NS0yLjEyNS00LjY3NS00LjYyNCAwLTIuNjAxIDEuNjY2LTQuNjI0IDQuMzE4LTQuNjI0IDIuMzI5IDAgMy44NDIgMS41ODEgMy44NDIgMy43MjMgMCAuMzQtLjA1MS42OC0uMTAyLjkxOGgtNi41NjJ2LjAzNGMwIDEuODg3IDEuMjkyIDMuMjY0IDMuMjEzIDMuMjY0IDEuMDg4IDAgMi4wMDYtLjUxIDIuNTY3LTEuMjc1bDEuMDcxLjgzM1ptLTQuMDEyLTYuMjU2Yy0xLjM5NCAwLTIuMzguNzgyLTIuNzIgMi4yNjFoNS4wODNjLS4wNTEtMS4yNDEtLjk1Mi0yLjI2MS0yLjM2My0yLjI2MVptMTAuMTg1IDYuNjQ3YzEuMDU0IDAgMS45MDQtLjUxIDIuNDMxLTEuMjc1bDEuMTU2Ljg4NGMtLjc5OSAxLjA3MS0yLjA0IDEuNzUxLTMuNjA0IDEuNzUxLTIuODM5IDAtNC42NTgtMi4xMjUtNC42NTgtNC42MjQgMC0yLjQ5OSAxLjgxOS00LjYyNCA0LjY1OC00LjYyNCAxLjU0NyAwIDIuODA1LjY5NyAzLjYwNCAxLjc1MWwtMS4xNTYuODg0YTIuOTI1IDIuOTI1IDAgMCAwLTIuNDQ4LTEuMjc1Yy0xLjgzNiAwLTMuMTQ1IDEuNDQ1LTMuMTQ1IDMuMjY0IDAgMS44MzYgMS4zMDkgMy4yNjQgMy4xNjIgMy4yNjRaTTcwLjg1NCA0NVYzMi40aDQuMTU4YzIuNzcyIDAgNC40NjQgMS40MjIgNC40NjQgMy43NjIgMCAyLjMyMi0xLjY5MiAzLjc0NC00LjQ2NCAzLjc0NEg3My40MVY0NWgtMi41NTZabTQuMjY2LTEwLjQyMmgtMS43MXYzLjE1aDEuNzFjMS4wOCAwIDEuNzI4LS41NzYgMS43MjgtMS42MDIgMC0uOTU0LS42NDgtMS41NDgtMS43MjgtMS41NDhaTTgxLjI0OSA0NXYtOS4wNzJoMi4yODZ2LjljLjU5NC0uNjEyIDEuMzY4LTEuMDggMi4zOTQtMS4wOC4zMDYgMCAuNTc2LjA1NC43OTIuMTI2djIuMzk0YTMuOTM4IDMuOTM4IDAgMCAwLTEuMDA4LS4xMjZjLTEuMTE2IDAtMS44MzYuNjEyLTIuMTc4IDEuMTdWNDVoLTIuMjg2Wm0xMS4zODYtOS40MzJjMi45NTIgMCA0Ljk2OCAyLjE3OCA0Ljk2OCA0Ljg5NnMtMi4wMTYgNC44OTYtNC45NjggNC44OTYtNC45NjgtMi4xNzgtNC45NjgtNC44OTYgMi4wMTYtNC44OTYgNC45NjgtNC44OTZabS4wMzYgNy42MzJjMS40NTggMCAyLjU1Ni0xLjE3IDIuNTU2LTIuNzM2IDAtMS41ODQtMS4wOTgtMi43MzYtMi41NTYtMi43MzYtMS41MTIgMC0yLjYyOCAxLjE1Mi0yLjYyOCAyLjczNiAwIDEuNTg0IDEuMTE2IDIuNzM2IDIuNjI4IDIuNzM2Wm0xMy4xNzItLjIzNGMxLjQ0IDAgMi41NzQtLjcwMiAzLjI5NC0xLjcyOGwyLjAxNiAxLjU0OGMtMS4xNTIgMS41NjYtMy4wMjQgMi41NzQtNS4zMSAyLjU3NC0zLjk3OCAwLTYuNjk2LTMuMDYtNi42OTYtNi42NnMyLjcxOC02LjY2IDYuNjk2LTYuNjZjMi4yODYgMCA0LjE1OCAxLjAyNiA1LjMxIDIuNTU2bC0yLjAxNiAxLjU2NmMtLjcyLTEuMDI2LTEuODU0LTEuNzI4LTMuMjk0LTEuNzI4LTIuMzc2IDAtNC4wNjggMS44NTQtNC4wNjggNC4yNjZzMS42OTIgNC4yNjYgNC4wNjggNC4yNjZabTExLjM2Ni03LjM5OGMyLjk1MiAwIDQuOTY4IDIuMTc4IDQuOTY4IDQuODk2cy0yLjAxNiA0Ljg5Ni00Ljk2OCA0Ljg5Ni00Ljk2OC0yLjE3OC00Ljk2OC00Ljg5NiAyLjAxNi00Ljg5NiA0Ljk2OC00Ljg5NlptLjAzNiA3LjYzMmMxLjQ1OCAwIDIuNTU2LTEuMTcgMi41NTYtMi43MzYgMC0xLjU4NC0xLjA5OC0yLjczNi0yLjU1Ni0yLjczNi0xLjUxMiAwLTIuNjI4IDEuMTUyLTIuNjI4IDIuNzM2IDAgMS41ODQgMS4xMTYgMi43MzYgMi42MjggMi43MzZabTcuMDE4IDEuOHYtOS4wNzJoMi4yODZ2LjcyYy42My0uNjEyIDEuNDc2LTEuMDggMi42ODItMS4wOCAxLjk2MiAwIDMuNTI4IDEuMzUgMy41MjggNC4wMzJWNDVoLTIuMzIydi01LjMxYzAtMS4yMDYtLjY2Ni0xLjk2Mi0xLjc4Mi0xLjk2Mi0xLjE1MiAwLTEuNzY0Ljc3NC0yLjEwNiAxLjM1VjQ1aC0yLjI4NlptMTEuMDkxIDB2LTkuMDcyaDIuMjg2di43MmMuNjMtLjYxMiAxLjQ3Ni0xLjA4IDIuNjgyLTEuMDggMS45NjIgMCAzLjUyOCAxLjM1IDMuNTI4IDQuMDMyVjQ1aC0yLjMyMnYtNS4zMWMwLTEuMjA2LS42NjYtMS45NjItMS43ODItMS45NjItMS4xNTIgMC0xLjc2NC43NzQtMi4xMDYgMS4zNVY0NWgtMi4yODZabTE5LjQ0NC0xLjQ3NmMtLjg0NiAxLjEzNC0yLjI1IDEuODM2LTMuOTYgMS44MzYtMy4yMjIgMC01LjA0LTIuMjUtNS4wNC00Ljg5NiAwLTIuNjgyIDEuNjkyLTQuODk2IDQuNjYyLTQuODk2IDIuNTIgMCA0LjE3NiAxLjY5MiA0LjE3NiA0LjA2OCAwIC41MDQtLjA3Mi45OS0uMTQ0IDEuMjk2aC02LjM1NGMuMTQ0IDEuNDk0IDEuMTg4IDIuMzc2IDIuNzM2IDIuMzc2Ljk5IDAgMS44LS40MzIgMi4yODYtMS4wOGwxLjYzOCAxLjI5NlptLTQuMzM4LTYuMDQ4Yy0xLjExNiAwLTEuODcyLjU0LTIuMTc4IDEuNzI4aDQuMDg2Yy0uMDM2LS45LS43MDItMS43MjgtMS45MDgtMS43MjhabTEwLjY5NiA1LjcyNGMuODgyIDAgMS41ODQtLjQzMiAyLjAxNi0xLjA2MmwxLjgxOCAxLjM4NmMtLjg0NiAxLjExNi0yLjE3OCAxLjgzNi0zLjgzNCAxLjgzNi0zLjEzMiAwLTUuMDA0LTIuMjUtNS4wMDQtNC44OTZzMS44NzItNC44OTYgNS4wMDQtNC44OTZjMS42NTYgMCAyLjk4OC43MiAzLjgzNCAxLjgzNmwtMS44MTggMS4zODZjLS40MzItLjYzLTEuMTE2LTEuMDYyLTIuMDUyLTEuMDYyLTEuNDk0IDAtMi41OTIgMS4xNTItMi41OTIgMi43MzYgMCAxLjYwMiAxLjA5OCAyLjczNiAyLjYyOCAyLjczNlptNi4yMDQtMS41MTJ2LTMuNjcyaC0xLjY5MnYtMi4wODhoMS42OTJWMzMuNjZoMi4zMDR2Mi4yNjhoMi43NzJ2Mi4wODhoLTIuNzcydjMuNjcyYzAgMS4wMDguNTQgMS40MDQgMS40NCAxLjQwNC42MyAwIDEuMDQ0LS4wNzIgMS4zNS0uMTk4djEuOTk4Yy0uNDUuMTk4LS45OS4yODgtMS43NDYuMjg4LTIuMjY4IDAtMy4zNDgtMS4yNzgtMy4zNDgtMy40OTJaJy8+PHBhdGggZmlsbD0nIzAwMDA5MScgZD0nTTQ2Ljk5MiAxOS4wOTggMzEuOTk4IDEwLjQybC0xNC45OTQgOC43NmEuNjA2LjYwNiAwIDAgMC0uMzA2LjUyNXYxNi45NDhhLjY2Ni42NjYgMCAwIDAgLjMwNi41MjRsMTQuOTkyIDguNiAxNC45OTQtOC43MDZhLjY2Ni42NjYgMCAwIDAgLjMwNi0uNTI0VjE5LjYyNmEuNjA0LjYwNCAwIDAgMC0uMzA0LS41MjhaJy8+PHBhdGggZmlsbD0nI0ZDQzYzQScgZD0nbTI2LjY0MSAxOS41OTgtNS4wMjkgOC42MjgtNC41NTctOS4xNzUgNS4zOS0zLjExMyA0LjQ4OSAzLjE2LS4yOTMuNVptMjAuNjU2IDE2Ljk4VjE5LjYyYS42LjYgMCAwIDAtLjMwNi0uNTIzTDMxLjk5OCAxMC40MicvPjxwYXRoIGZpbGw9JyMwMDYzQ0InIGQ9J00xNi43IDM2LjU3OCAzMiAxMC40MnYzNS4zNjJsLTE0Ljk5Ni04LjYwNWEuNjY1LjY2NSAwIDAgMS0uMzA2LS41MjRWMTkuNzA2bC4wMDIgMTYuODcyWm0yNC42NjktMjAuNzM1IDUuNDU4IDMuMTU1LTQuNDg5IDkuMTUtNS4zODctOS4yMzYgNC40MTgtMy4wN1onLz48cGF0aCBmaWxsPScjZmZmJyBkPSdtNTEuNjA2IDE2LjMwMy0xOS4xOS0xMS4wMmEuOTMzLjkzMyAwIDAgMC0uODMyIDBsLTE5LjE5IDExLjAyYS44ODcuODg3IDAgMCAwLS4zOTQuNjk1djIyYS44ODUuODg1IDAgMCAwIC4zOTQuN2wxOS4xODkgMTEuMDJhLjkzMi45MzIgMCAwIDAgLjgzMiAwbDE5LjE5MS0xMS4wMmEuODg2Ljg4NiAwIDAgMCAuMzk0LS43di0yMmEuODg3Ljg4NyAwIDAgMC0uMzk0LS42OTVaTTIyLjc4OSAzNC4wNTloLjA3OWMtLjA0MiAwLS4wNzkuMDA3LS4wNzkuMDUgMCAuMS4xNTEgMCAuMi4xYS45MTIuOTEyIDAgMCAwLS42MjkuMjc2YzAgLjA1LjEuMDUuMTUxLjA1LS4wNzUuMS0uMjI2LjA1LS4yNzcuMTUyYS4xNzYuMTc2IDAgMCAwIC4xLjA1Yy0uMDUgMC0uMSAwLS4xLjA1di4xNTJjLS4xMjYgMC0uMTc2LjEtLjI3Ny4xNS4yLjE1Mi4zMjcgMCAuNTI4IDAtLjUyOC4yLS45NTYuNDc5LTEuNDg0LjYzLS4xIDAgMCAuMTUtLjEuMTUuMTUxLjEuMjI3LS4wNS4zNzctLjA1LS42NTQuMzc4LTEuMzMzLjctMi4wMzcgMS4xMzNhLjM1MS4zNTEgMCAwIDAtLjEuMmgtLjJjLS4xLjA1LS4wNS4xNzYtLjE1MS4yNzcuMjI2LjE1LjUtLjIuNjU0IDAgLjA1IDAtLjEuMDUtLjIuMDUtLjA1IDAtLjA1LjEtLjEuMWgtLjE1NGMtLjEuMDc1LS4yLjEyNi0uMi4yNzZhLjIyLjIyIDAgMCAwLS4yMjYuMSA5LjAzMSA5LjAzMSAwIDAgMCAzLjE0NC0uNTc4IDcuNjgzIDcuNjgzIDAgMCAwIDIuMDg4LTEuNTYuMTc2LjE3NiAwIDAgMSAuMDUuMWMtLjE0Ny40MzctLjQzLjgxNi0uODA2IDEuMDgtLjI3Ny4xNTItLjQ3OC4zNzgtLjcuNDc5YTQuMDU3IDQuMDU3IDAgMCAwLS40MjguMjc2Yy0uNjMyLjE5Ny0xLjI4MS4zMzUtMS45MzkuNDEybC0uMzA1LjA0NGMtLjIyNS4wMzMtLjQ0OS4wNjktLjY3MS4xMDhsLTEuOTkzLTEuMTM4YS42NDcuNjQ3IDAgMCAxLS4yODgtLjQxMS41Ny41NyAwIDAgMCAuMDk0LS4wNjMuMjY2LjI2NiAwIDAgMC0uMTEzLS4wNzF2LS42NWExMi43ODIgMTIuNzgyIDAgMCAwIDMuMDM4LS45NDIgOC43NDYgOC43NDYgMCAwIDAtMy4wMzctMS4zNDN2LTEuNTE1YTExLjY3IDExLjY3IDAgMCAxIDEuNjM5LjM5MiA2LjQyIDYuNDIgMCAwIDEgMS4xODIuNTc4Yy4xNDcuMTQuMzA3LjI2Ny40NzguMzc3YS45MS45MSAwIDAgMCAuOC4wNWguMzNhMy45NjEgMy45NjEgMCAwIDAgMS45MzctLjkwNWMwIC4wNS4wNS4wNS4xLjA1YTMuNjI5IDMuNjI5IDAgMCAxLS40MjggMS4xMzJjLjAwMy4wNS0uMDQ4LjE1Mi4wNTMuMjAyWm0yLjgxNyAzLjU3Yy4yNTEtLjEuNC0uMjc2LjYyOS0uMzc2LS4wNS4wNS0uMDUuMTUtLjEuMmEzLjY5OSAzLjY5OSAwIDAgMC0uNTI4LjQgMTUuOTY1IDE1Ljk2NSAwIDAgMC0xLjU4NSAxLjYxYy0uMjUyLjMtLjUyOC41NzgtLjguODU1LS4wOTYuMDktLjIuMTcyLS4zMS4yNDVsLTIuNTI3LTEuNDVjLjM2LjAzLjcyMS4wMTMgMS4wNzYtLjA1My4yOTQtLjA4My41OC0uMTkyLjg1NS0uMzI3di4xYy43LS4yNzcgMS4yMzItLjkwNiAxLjkzNy0xLjEzMi4wMjUgMCAuMTI2LjEuMjI2LjA1YTEuODgzIDEuODgzIDAgMCAxIDEuNTA5LS43YzAgLjA1IDAgLjEuMDUuMWguMDI1Yy0uMTUxLjEyNi0uMzI3LjI1LS41LjM3Ny0uMDU3LjA1Mi0uMDA3LjEwMi4wNDMuMTAyWm0tOC45MDgtNi4xNjN2LS4xODZhNS44MTcgNS44MTcgMCAwIDEgMS41ODgtLjE4OCAxLjUyIDEuNTIgMCAwIDEgLjQ3OCAwIDUuODYgNS44NiAwIDAgMC0yLjA2Ni4zNzRabTMwLjYgNS4wODhhLjY2NS42NjUgMCAwIDEtLjMwNi41MjRsLTEwLjA3OSA1Ljg1YTMyLjI5NiAzMi4yOTYgMCAwIDEtMy40MDgtMS4xODQgMi44MjYgMi44MjYgMCAwIDEtLjA1LTIuMjQ1Yy4wOC0uMzA4LjE5OC0uNjA1LjM1Mi0uODgzLjAyNS0uMDI1LjA1LS4wNS4wNS0uMDc2YS4wMjUuMDI1IDAgMCAwIC4wMjUtLjAyNSA0LjMyIDQuMzIgMCAwIDEgLjM3Ny0uNTU1bC4wMTUtLjAxNS4wMi0uMDIxLjAxNS0uMDE1YzAtLjAyNS4wMjUtLjA1LjA1LS4wNzYuMDI1LS4wNTEuMDc1LS4wNzYuMS0uMTI2LjE3Ni0uMTg2LjM3LS4zNTQuNTc5LS41LjIxMy0uMDc3LjQzMS0uMTM2LjY1NC0uMTc3LjgxMS4wNiAxLjYxNy4xNyAyLjQxNS4zMjhhLjc1Mi43NTIgMCAwIDEgLjI3Ny4xYy4zMDEuMDU5LjYxMi4wNDEuOTA1LS4wNWExLjEzNyAxLjEzNyAwIDAgMCAuODU1LS43MDYgMS4yMTIgMS4yMTIgMCAwIDAgLjA1LTEuMDZjLS4xNzgtLjI3NS0uMDEzLS40MzYuMTgxLS41OWwuMDY4LS4wNTRjLjA4Ni0uMDYxLjE2NC0uMTM0LjIzMS0uMjE2LjEyNi0uMjUyLS4xLS40LS4xNTEtLjYzLS4wNS0uMS0uMjI2LS4wNS0uMzI3LS4yLjM1Mi0uMTUxLjg1NS0uNDMuNjI5LS44NTctLjE1MS0uMjI3LS4zNzctLjYzLS4xLS44NTcuMzUyLS4yLjg1NS0uMTUxIDEuMDA2LS40OGExLjEzNyAxLjEzNyAwIDAgMC0uMjkyLTEuMDg0bC0uMDc1LS4xMDhhNC43NTQgNC43NTQgMCAwIDEtLjIxMS0uMzIgNi45MDUgNi45MDUgMCAwIDAtLjUyOC0uNzU3IDQuMjk3IDQuMjk3IDAgMCAxLS41MjgtMS4wMWMtLjE1MS0uMzc3LjA1LS43MDUuMDUtMS4wODNhNi4zNDcgNi4zNDcgMCAwIDAtLjMyNy0yLjE0NGMtLjEyNi0uMzUzLS4xNzYtLjczMS0uMzI3LTEuMDZhMS4xMiAxLjEyIDAgMCAwLS4yMjYtLjU4LjM3NC4zNzQgMCAwIDEgMC0uMzI3Yy4yMDUtLjE0NS4zOTktLjMwNS41NzktLjQ4YS41NjcuNTY3IDAgMCAwLS4yLS43MDVjLS4zMjctLjE1MS0uMy4zMjgtLjUyOC40MjloLS4xNTFjLS4wNS0uMTI2LjA1LS4xNzcuMTUxLS4yNzcgMC0uMDUgMC0uMTUxLS4wNS0uMTUxLS4yIDAtLjM3Ny0uMDUxLS40MjgtLjE1MWEzLjk1NyAzLjk1NyAwIDAgMC0xLjg2MS0xLjI4NmMuMTg4LjA1OC4zODIuMDkxLjU3OS4xLjMzOC4wNzEuNjkuMDM2IDEuMDA2LS4xLjIyNy0uMDc2LjI3Ny0uNDguMzc3LS43MDZhLjguOCAwIDAgMC0uMTUxLS42MzEgMi4xOSAyLjE5IDAgMCAwLS45MDYtLjc1NiA5LjEzIDkuMTMgMCAwIDEtLjY3OS0uMzUzLjk1Ni45NTYgMCAwIDAtLjI1MS0uMTI2Yy0yLjk2NS0xLjQ4NS05LjA2OS0uMi05LjUzNCAwaC0uMDA5YTguMjU0IDguMjU0IDAgMCAwLTEuMjQ5LjQ3NSAzLjkyMiAzLjkyMiAwIDAgMC0yLjM2NSAyLjQ2NSAzLjgzIDMuODMgMCAwIDAtMS4zMzMgMS41MDljLS40MjguOC0xLjA1NiAxLjUwOS0uOTU2IDIuNDE0LjEuNzguMjc3IDEuNDg0LjQyOCAyLjI4OS4wNDMuMjcyLjExLjU0LjIuOC4xLjI3NiAwIC42MjkuMTUxLjg1NS4wNzUuMTUuMDI1LjMyNy4yMjcuNDI4di4yYy4wNS4wNS4wNS4xLjE1MS4xdi4yYy40MzUuNDIzLjgwNy45MDYgMS4xMDcgMS40MzQuMS4yNzYtLjQ3OC4xNS0uNy4wNWE1Ljk3NyA1Ljk3NyAwIDAgMS0xLjEzMi0uOTU2LjE3Ni4xNzYgMCAwIDAtLjA1MS4xYy4yLjM1Mi45MDYuNzguNTI4IDEuMDA2LS4yLjEtLjQyOC0uMTUxLS42MjkuMDUtLjA1LjA3NiAwIC4xNzcgMCAuMjc3LS4yNzctLjItLjU3OC0uMS0uODU1LS4yLS4yLS4wNS0uMjUyLS40MjctLjQ3OC0uNDI3YTE1LjE5MSAxNS4xOTEgMCAwIDAtMS44MTEtLjMyNyAxNS4xNDQgMTUuMTQ0IDAgMCAwLTEuNzM5LS4xNlYxOS43MDdhLjYwNi42MDYgMCAwIDEgLjMwNi0uNTI0bDE0Ljk4Ny04Ljc2MSAxNC45OTQgOC42NzdhLjYwNS42MDUgMCAwIDEgLjMwNi41MjR2MTYuOTMyWm0tNy45NTQtOC4yNjFhLjMyNS4zMjUgMCAwIDEtLjI4Mi4xNDkgMi44NCAyLjg0IDAgMCAwLS4yODIuMjczYy4xIDAgMCAuMTQ5LjEuMTQ5LS4yMDUuMjIzLjA3Ny42OTQtLjIwNS43OTMtLjM3LjA5OS0uNzU4LjA5OS0xLjEyNyAwYS43MjcuNzI3IDAgMCAxIC4xNjctLjAxNmguMDg1YS4zODIuMzgyIDAgMCAwIC4zMzctLjEzMnYtLjJjMC0uMDUtLjA1MS0uMDUtLjEtLjA1YS4xNi4xNiAwIDAgMS0uMS4wNS4yMjMuMjIzIDAgMCAwLS4xNTQtLjIuODA2LjgwNiAwIDAgMS0uNzE4LS4yNzMuNjcuNjcgMCAwIDEgLjQzNi0uMDVjLjEyOCAwIC4wNzctLjIyMy4yMzEtLjMyMmguMTU0Yy4zMDctLjM3Mi44NzEtLjQ3MS45NzQtLjg0MyAwLS4xLS4yODItLjEtLjQ4Ny0uMTVhMi4yNiAyLjI2IDAgMCAwLS44Mi4wNWMtLjM2LjA1LS43MTIuMTQyLTEuMDUxLjI3NC4yOC0uMjA2LjU5Mi0uMzY1LjkyMy0uNDcxLjIzMi0uMDkuNDczLS4xNTcuNzE4LS4ybC4xMzItLjAyNi4xMzMtLjAyN2EuOTcuOTcgMCAwIDEgLjU1NiAwYy4yMzEuMS42MTUuMS42NjYuMjQ4LjEuMjczLS4xNTQuNTQ1LS40MzUuNzQ0LS4wNTcuMDguMTQ5LjEzNS4xNDkuMjNaJy8+PHJlY3Qgd2lkdGg9JzI5LjU2JyBoZWlnaHQ9JzEzLjMwMicgeD0nMzcnIHk9JzUnIGZpbGw9JyNGQ0M2M0EnIHJ4PScyJy8+PHBhdGggZmlsbD0nIzE2MTYxNicgZD0nTTM5LjU2MiAxNi4xNjhWNy4zMTZoMi45MjFjLjk3IDAgMS43MzIuMjM2IDIuMjg5LjcwOC41NjUuNDcyLjg0NyAxLjExNy44NDcgMS45MzUgMCAuODEtLjI4MiAxLjQ1LS44NDcgMS45MjItLjU1Ny40NzItMS4zMi43MDgtMi4yODkuNzA4aC0xLjEyNXYzLjU3OWgtMS43OTZabTIuOTk3LTcuMzIyaC0xLjIwMXYyLjIxM2gxLjJjLjM4IDAgLjY3NS0uMDk3Ljg4Ni0uMjkuMjItLjE5NS4zMjktLjQ3My4zMjktLjgzNiAwLS4zMzctLjExLS42MDItLjMyOS0uNzk2LS4yMS0uMTk0LS41MDYtLjI5MS0uODg1LS4yOTFaTTQ3LjIzIDE2LjE2OFY3LjMxNmgyLjcwN2MuOTcgMCAxLjczNi4yMzYgMi4zMDEuNzA4LjU2NS40NzIuODQ3IDEuMTE3Ljg0NyAxLjkzNSAwIC41My0uMTI2Ljk5NS0uMzc5IDEuMzktLjI0NC4zODktLjU5LjY4OC0xLjAzNy44OTlsMi43ODIgMy45MmgtMi4xNWwtMi4zNTItMy41NzloLS45MjN2My41NzloLTEuNzk1Wm0yLjgwOC03LjMyMmgtMS4wMTJ2Mi4yMTNoMS4wMTJjLjM4IDAgLjY3NC0uMDk3Ljg4NS0uMjkuMjEtLjE5NS4zMTYtLjQ3My4zMTYtLjgzNiAwLS4zMzctLjEwNS0uNjAyLS4zMTYtLjc5Ni0uMjEtLjE5NC0uNTA2LS4yOTEtLjg4NS0uMjkxWk01OS41NDkgNy4wNjNjLjY5IDAgMS4zMjMuMTI2IDEuODk2LjM4LjU4Mi4yNTIgMS4wOC41OSAxLjQ5MiAxLjAxMS40MTQuNDIxLjczNC45MTkuOTYyIDEuNDkyLjIyNy41NjUuMzQxIDEuMTY0LjM0MSAxLjc5NiAwIC42MzItLjExNCAxLjIzNS0uMzQxIDEuODA4YTQuNDg1IDQuNDg1IDAgMCAxLS45NjIgMS40OGMtLjQxMy40MjEtLjkxLjc1OC0xLjQ5MiAxLjAxMWE0LjY0OCA0LjY0OCAwIDAgMS0xLjg5Ni4zOCA0LjczOCA0LjczOCAwIDAgMS0zLjQwMi0xLjM5MSA0LjQ4NCA0LjQ4NCAwIDAgMS0uOTYxLTEuNDggNC44NTUgNC44NTUgMCAwIDEtLjM0Mi0xLjgwOGMwLS42MzMuMTE0LTEuMjMxLjM0Mi0xLjc5Ni4yMjctLjU3My41NDgtMS4wNy45NjEtMS40OTIuNDEzLS40MjIuOTEtLjc1OSAxLjQ5Mi0xLjAxMmE0LjczNyA0LjczNyAwIDAgMSAxLjkxLS4zNzlabTAgNy42NzZhMi44IDIuOCAwIDAgMCAxLjEzOC0uMjI4Yy4zNTQtLjE2LjY1My0uMzcuODk4LS42MzIuMjUyLS4yNy40NS0uNTg2LjU5NC0uOTQ5YTMuMjcgMy4yNyAwIDAgMCAuMjE1LTEuMTg4IDMuMTcgMy4xNyAwIDAgMC0uMjE1LTEuMTc2IDIuNzkxIDIuNzkxIDAgMCAwLS41OTUtLjk0OSAyLjU0OCAyLjU0OCAwIDAgMC0uODk3LS42MzIgMi42NzMgMi42NzMgMCAwIDAtMS4xMzgtLjI0Yy0uNDEzIDAtLjc5Ny4wOC0xLjE1MS4yNGEyLjY3OCAyLjY3OCAwIDAgMC0uOTEuNjMyIDIuODk5IDIuODk5IDAgMCAwLS41ODIuOTQ5IDMuMTcgMy4xNyAwIDAgMC0uMjE1IDEuMTc2YzAgLjQyMS4wNzEuODE3LjIxNSAxLjE4OC4xNDMuMzYzLjMzNy42NzkuNTgxLjk0OS4yNTMuMjYxLjU1Ny40NzIuOTEuNjMyLjM1NS4xNTIuNzM5LjIyOCAxLjE1Mi4yMjhaJy8+PC9zdmc+"); - background-position: 50% 50%; - background-repeat: no-repeat; - width: 214px; - height: 56px; - border: none; - cursor: pointer !important; -} - -.proconnect-button:hover { - background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPScyMTEnIGhlaWdodD0nNTgnIGZpbGw9J25vbmUnPjxnIGNsaXAtcGF0aD0ndXJsKCNhKSc+PHBhdGggZmlsbD0nIzEyMTJGRicgZD0nTTIxMSAwSDB2NThoMjExVjBaJy8+PHBhdGggZmlsbD0nI2ZmZicgZD0nbTY5Ljk4NiAyNi4zNjggMS4xNTYtMS4wNzFjLjgzMyAxLjA1NCAxLjgxOSAxLjU5OCAyLjk0MSAxLjU5OCAxLjI5MiAwIDIuMDQtLjgxNiAyLjA0LTEuOTA0IDAtMi41NS01LjYyNy0yLjI0NC01LjYyNy02LjAzNSAwLTEuNzM0IDEuNDI4LTMuMTk2IDMuNDUxLTMuMTk2IDEuNjgzIDAgMi45MDcuNzY1IDMuNzkxIDEuOTM4bC0xLjE5IDEuMDM3Yy0uNjk3LTEuMDAzLTEuNTQ3LTEuNTQ3LTIuNTg0LTEuNTQ3LTEuMTA1IDAtMS44MzYuNzQ4LTEuODM2IDEuNzM0IDAgMi41NjcgNS42MjcgMi4yNDQgNS42MjcgNi4wNTIgMCAyLjAyMy0xLjU4MSAzLjM0OS0zLjY1NSAzLjM0OS0xLjc2OCAwLTMuMDc3LS42NjMtNC4xMTQtMS45NTVabTEwLjgxNy01LjcxMkg3OS40NmwxLjQ0NS00LjU1NmgxLjY0OWwtMS43NTEgNC41NTZabTQuODE4LTMuNDUxYy0uNTYgMC0xLjAyLS40NTktMS4wMi0xLjAyYTEuMDIgMS4wMiAwIDAgMSAxLjAyLTEuMDAzYy41NjEgMCAxLjAwMy40NTkgMS4wMDMgMS4wMDMgMCAuNTYxLS40NDIgMS4wMi0xLjAwMyAxLjAyWk04NC44OTEgMjh2LTguNTY4aDEuNDQ0VjI4SDg0Ljg5Wm0zLjc2Ny00LjI4NGMwLTIuNDk5IDEuNzE3LTQuNjI0IDQuNDAzLTQuNjI0IDEuMjQxIDAgMi4yNjEuNDU5IDMuMDQzIDEuMjkyVjE1LjI1aDEuNDQ1VjI4aC0xLjQ0NXYtLjk1MmMtLjc4Mi44MzMtMS44MDIgMS4yOTItMy4wNDMgMS4yOTItMi42ODYgMC00LjQwMy0yLjEyNS00LjQwMy00LjYyNFptMS41MyAwYzAgMS44MTkgMS4yMjQgMy4yNjQgMy4wNDMgMy4yNjQgMS4xOSAwIDIuMjEtLjU3OCAyLjg3My0xLjU5OFYyMi4wNWMtLjY4LTEuMDM3LTEuNy0xLjU5OC0yLjg3My0xLjU5OC0xLjgxOSAwLTMuMDQzIDEuNDQ1LTMuMDQzIDMuMjY0Wm0xOC4wMjMgMi44NzNjLS43OTkgMS4wNzEtMi4wNzQgMS43NTEtMy42NzIgMS43NTEtMi44OSAwLTQuNjc1LTIuMTI1LTQuNjc1LTQuNjI0IDAtMi42MDEgMS42NjYtNC42MjQgNC4zMTgtNC42MjQgMi4zMjkgMCAzLjg0MiAxLjU4MSAzLjg0MiAzLjcyMyAwIC4zNC0uMDUxLjY4LS4xMDIuOTE4aC02LjU2MnYuMDM0YzAgMS44ODcgMS4yOTIgMy4yNjQgMy4yMTMgMy4yNjQgMS4wODggMCAyLjAwNi0uNTEgMi41NjctMS4yNzVsMS4wNzEuODMzWm0tNC4wMTItNi4yNTZjLTEuMzk0IDAtMi4zOC43ODItMi43MiAyLjI2MWg1LjA4M2MtLjA1MS0xLjI0MS0uOTUyLTIuMjYxLTIuMzYzLTIuMjYxWk0xMTAuNDczIDI4di04LjU2OGgxLjQ0NXYuOTY5Yy42OTctLjc2NSAxLjU4MS0xLjMwOSAyLjg1Ni0xLjMwOSAxLjkyMSAwIDMuMzQ5IDEuMjkyIDMuMzQ5IDMuNzIzVjI4aC0xLjQ2MnYtNS4xMzRjMC0xLjUzLS44NS0yLjQxNC0yLjE3Ni0yLjQxNC0xLjI0MSAwLTIuMDIzLjcxNC0yLjU2NyAxLjYxNVYyOGgtMS40NDVabTExLjA1Mi0yLjg3M3YtNC4zNjloLTEuNjE1di0xLjMyNmgxLjYxNVYxNy4yOWgxLjQ2MnYyLjE0MmgyLjk3NXYxLjMyNmgtMi45NzV2NC4zNjljMCAxLjM0My42OCAxLjcxNyAxLjcxNyAxLjcxNy41NjEgMCAuOTUyLS4wNjggMS4yNzUtLjIwNHYxLjI5MmMtLjQwOC4xNy0uODY3LjIzOC0xLjQ3OS4yMzgtMS45MDQgMC0yLjk3NS0uOTUyLTIuOTc1LTMuMDQzWm03LjM3Ny03LjkyMmMtLjU2MSAwLTEuMDItLjQ1OS0xLjAyLTEuMDJhMS4wMiAxLjAyIDAgMCAxIDEuMDItMS4wMDNjLjU2MSAwIDEuMDAzLjQ1OSAxLjAwMyAxLjAwMyAwIC41NjEtLjQ0MiAxLjAyLTEuMDAzIDEuMDJaTTEyOC4xNzEgMjh2LTguNTY4aDEuNDQ1VjI4aC0xLjQ0NVptMy4zNzctOC41NjhoMS42MTV2LTEuMDU0YzAtMS44MzYgMS4yMDctMy4xMjggMy4wNDMtMy4xMjguOTUyIDAgMS43LjM0IDIuMjEuODMzbC0uOTAxIDEuMDU0YTEuNjMzIDEuNjMzIDAgMCAwLTEuMjkyLS41NzhjLS45MzUgMC0xLjU5OC42OC0xLjU5OCAxLjc4NXYxLjA4OGgyLjk3NXYxLjMyNmgtMi45NzVWMjhoLTEuNDYydi03LjI0MmgtMS42MTV2LTEuMzI2Wm04LjU0My0yLjIyN2MtLjU2MSAwLTEuMDItLjQ1OS0xLjAyLTEuMDJhMS4wMiAxLjAyIDAgMCAxIDEuMDItMS4wMDNjLjU2MSAwIDEuMDAzLjQ1OSAxLjAwMyAxLjAwMyAwIC41NjEtLjQ0MiAxLjAyLTEuMDAzIDEuMDJaTTEzOS4zNiAyOHYtOC41NjhoMS40NDVWMjhoLTEuNDQ1Wm0xMi4xMTUtMS40MTFjLS43OTkgMS4wNzEtMi4wNzQgMS43NTEtMy42NzIgMS43NTEtMi44OSAwLTQuNjc1LTIuMTI1LTQuNjc1LTQuNjI0IDAtMi42MDEgMS42NjYtNC42MjQgNC4zMTgtNC42MjQgMi4zMjkgMCAzLjg0MiAxLjU4MSAzLjg0MiAzLjcyMyAwIC4zNC0uMDUxLjY4LS4xMDIuOTE4aC02LjU2MnYuMDM0YzAgMS44ODcgMS4yOTIgMy4yNjQgMy4yMTMgMy4yNjQgMS4wODggMCAyLjAwNi0uNTEgMi41NjctMS4yNzVsMS4wNzEuODMzWm0tNC4wMTItNi4yNTZjLTEuMzk0IDAtMi4zOC43ODItMi43MiAyLjI2MWg1LjA4M2MtLjA1MS0xLjI0MS0uOTUyLTIuMjYxLTIuMzYzLTIuMjYxWk0xNTMuNzM3IDI4di04LjU2OGgxLjQ0NXYxLjA3MWMuNjI5LS43NDggMS40MTEtMS4yNDEgMi40OTktMS4yNDEuMjcyIDAgLjUyNy4wMzQuNzMxLjEwMnYxLjQ5NmEzLjEwNSAzLjEwNSAwIDAgMC0uODUtLjExOWMtMS4xMjIgMC0xLjg1My41NzgtMi4zOCAxLjQ0NVYyOGgtMS40NDVabTEzLjY4NS4zNGMtMS42ODMgMC0yLjgyMi0uOTUyLTIuODIyLTIuNDQ4IDAtMS4zMjYuOTg2LTIuMjc4IDIuODIyLTIuNTY3bDIuODczLS40NzZ2LS41OTVjMC0xLjE5LS44NS0xLjg3LTIuMDU3LTEuODctMS4wMDMgMC0xLjgzNi40NDItMi4zMjkgMS4xOWwtMS4wODgtLjgzM2MuNzQ4LTEuMDIgMS45NTUtMS42NDkgMy40NTEtMS42NDkgMi4xNzYgMCAzLjQ2OCAxLjI3NSAzLjQ2OCAzLjE2MlYyOGgtMS40NDV2LTEuMDg4Yy0uNjQ2LjkwMS0xLjcxNyAxLjQyOC0yLjg3MyAxLjQyOFptLTEuMzc3LTIuNDk5YzAgLjczMS42MjkgMS4yOTIgMS42MTUgMS4yOTIgMS4xMzkgMCAyLjA0LS41OTUgMi42MzUtMS41ODFWMjMuOTJsLTIuNTMzLjQ0MmMtMS4xOS4xODctMS43MTcuNzMxLTEuNzE3IDEuNDc5Wm03LjI1Mi02LjQwOWgxLjU2NGwyLjczNyA3LjA1NSAyLjczNy03LjA1NWgxLjU2NEwxNzguNTUgMjhoLTEuOTA0bC0zLjM0OS04LjU2OFptMTcuODU2IDcuMTU3Yy0uNzk5IDEuMDcxLTIuMDc0IDEuNzUxLTMuNjcyIDEuNzUxLTIuODkgMC00LjY3NS0yLjEyNS00LjY3NS00LjYyNCAwLTIuNjAxIDEuNjY2LTQuNjI0IDQuMzE4LTQuNjI0IDIuMzI5IDAgMy44NDIgMS41ODEgMy44NDIgMy43MjMgMCAuMzQtLjA1MS42OC0uMTAyLjkxOGgtNi41NjJ2LjAzNGMwIDEuODg3IDEuMjkyIDMuMjY0IDMuMjEzIDMuMjY0IDEuMDg4IDAgMi4wMDYtLjUxIDIuNTY3LTEuMjc1bDEuMDcxLjgzM1ptLTQuMDEyLTYuMjU2Yy0xLjM5NCAwLTIuMzguNzgyLTIuNzIgMi4yNjFoNS4wODNjLS4wNTEtMS4yNDEtLjk1Mi0yLjI2MS0yLjM2My0yLjI2MVptMTAuMTg1IDYuNjQ3YzEuMDU0IDAgMS45MDQtLjUxIDIuNDMxLTEuMjc1bDEuMTU2Ljg4NGMtLjc5OSAxLjA3MS0yLjA0IDEuNzUxLTMuNjA0IDEuNzUxLTIuODM5IDAtNC42NTgtMi4xMjUtNC42NTgtNC42MjQgMC0yLjQ5OSAxLjgxOS00LjYyNCA0LjY1OC00LjYyNCAxLjU0NyAwIDIuODA1LjY5NyAzLjYwNCAxLjc1MWwtMS4xNTYuODg0YTIuOTI1IDIuOTI1IDAgMCAwLTIuNDQ4LTEuMjc1Yy0xLjgzNiAwLTMuMTQ1IDEuNDQ1LTMuMTQ1IDMuMjY0IDAgMS44MzYgMS4zMDkgMy4yNjQgMy4xNjIgMy4yNjRaTTcwLjg1NCA0NVYzMi40aDQuMTU4YzIuNzcyIDAgNC40NjQgMS40MjIgNC40NjQgMy43NjIgMCAyLjMyMi0xLjY5MiAzLjc0NC00LjQ2NCAzLjc0NEg3My40MVY0NWgtMi41NTZabTQuMjY2LTEwLjQyMmgtMS43MXYzLjE1aDEuNzFjMS4wOCAwIDEuNzI4LS41NzYgMS43MjgtMS42MDIgMC0uOTU0LS42NDgtMS41NDgtMS43MjgtMS41NDhaTTgxLjI0OSA0NXYtOS4wNzJoMi4yODZ2LjljLjU5NC0uNjEyIDEuMzY4LTEuMDggMi4zOTQtMS4wOC4zMDYgMCAuNTc2LjA1NC43OTIuMTI2djIuMzk0YTMuOTM4IDMuOTM4IDAgMCAwLTEuMDA4LS4xMjZjLTEuMTE2IDAtMS44MzYuNjEyLTIuMTc4IDEuMTdWNDVoLTIuMjg2Wm0xMS4zODYtOS40MzJjMi45NTIgMCA0Ljk2OCAyLjE3OCA0Ljk2OCA0Ljg5NnMtMi4wMTYgNC44OTYtNC45NjggNC44OTYtNC45NjgtMi4xNzgtNC45NjgtNC44OTYgMi4wMTYtNC44OTYgNC45NjgtNC44OTZabS4wMzYgNy42MzJjMS40NTggMCAyLjU1Ni0xLjE3IDIuNTU2LTIuNzM2IDAtMS41ODQtMS4wOTgtMi43MzYtMi41NTYtMi43MzYtMS41MTIgMC0yLjYyOCAxLjE1Mi0yLjYyOCAyLjczNiAwIDEuNTg0IDEuMTE2IDIuNzM2IDIuNjI4IDIuNzM2Wm0xMy4xNzItLjIzNGMxLjQ0IDAgMi41NzQtLjcwMiAzLjI5NC0xLjcyOGwyLjAxNiAxLjU0OGMtMS4xNTIgMS41NjYtMy4wMjQgMi41NzQtNS4zMSAyLjU3NC0zLjk3OCAwLTYuNjk2LTMuMDYtNi42OTYtNi42NnMyLjcxOC02LjY2IDYuNjk2LTYuNjZjMi4yODYgMCA0LjE1OCAxLjAyNiA1LjMxIDIuNTU2bC0yLjAxNiAxLjU2NmMtLjcyLTEuMDI2LTEuODU0LTEuNzI4LTMuMjk0LTEuNzI4LTIuMzc2IDAtNC4wNjggMS44NTQtNC4wNjggNC4yNjZzMS42OTIgNC4yNjYgNC4wNjggNC4yNjZabTExLjM2Ni03LjM5OGMyLjk1MiAwIDQuOTY4IDIuMTc4IDQuOTY4IDQuODk2cy0yLjAxNiA0Ljg5Ni00Ljk2OCA0Ljg5Ni00Ljk2OC0yLjE3OC00Ljk2OC00Ljg5NiAyLjAxNi00Ljg5NiA0Ljk2OC00Ljg5NlptLjAzNiA3LjYzMmMxLjQ1OCAwIDIuNTU2LTEuMTcgMi41NTYtMi43MzYgMC0xLjU4NC0xLjA5OC0yLjczNi0yLjU1Ni0yLjczNi0xLjUxMiAwLTIuNjI4IDEuMTUyLTIuNjI4IDIuNzM2IDAgMS41ODQgMS4xMTYgMi43MzYgMi42MjggMi43MzZabTcuMDE4IDEuOHYtOS4wNzJoMi4yODZ2LjcyYy42My0uNjEyIDEuNDc2LTEuMDggMi42ODItMS4wOCAxLjk2MiAwIDMuNTI4IDEuMzUgMy41MjggNC4wMzJWNDVoLTIuMzIydi01LjMxYzAtMS4yMDYtLjY2Ni0xLjk2Mi0xLjc4Mi0xLjk2Mi0xLjE1MiAwLTEuNzY0Ljc3NC0yLjEwNiAxLjM1VjQ1aC0yLjI4NlptMTEuMDkxIDB2LTkuMDcyaDIuMjg2di43MmMuNjMtLjYxMiAxLjQ3Ni0xLjA4IDIuNjgyLTEuMDggMS45NjIgMCAzLjUyOCAxLjM1IDMuNTI4IDQuMDMyVjQ1aC0yLjMyMnYtNS4zMWMwLTEuMjA2LS42NjYtMS45NjItMS43ODItMS45NjItMS4xNTIgMC0xLjc2NC43NzQtMi4xMDYgMS4zNVY0NWgtMi4yODZabTE5LjQ0NC0xLjQ3NmMtLjg0NiAxLjEzNC0yLjI1IDEuODM2LTMuOTYgMS44MzYtMy4yMjIgMC01LjA0LTIuMjUtNS4wNC00Ljg5NiAwLTIuNjgyIDEuNjkyLTQuODk2IDQuNjYyLTQuODk2IDIuNTIgMCA0LjE3NiAxLjY5MiA0LjE3NiA0LjA2OCAwIC41MDQtLjA3Mi45OS0uMTQ0IDEuMjk2aC02LjM1NGMuMTQ0IDEuNDk0IDEuMTg4IDIuMzc2IDIuNzM2IDIuMzc2Ljk5IDAgMS44LS40MzIgMi4yODYtMS4wOGwxLjYzOCAxLjI5NlptLTQuMzM4LTYuMDQ4Yy0xLjExNiAwLTEuODcyLjU0LTIuMTc4IDEuNzI4aDQuMDg2Yy0uMDM2LS45LS43MDItMS43MjgtMS45MDgtMS43MjhabTEwLjY5NiA1LjcyNGMuODgyIDAgMS41ODQtLjQzMiAyLjAxNi0xLjA2MmwxLjgxOCAxLjM4NmMtLjg0NiAxLjExNi0yLjE3OCAxLjgzNi0zLjgzNCAxLjgzNi0zLjEzMiAwLTUuMDA0LTIuMjUtNS4wMDQtNC44OTZzMS44NzItNC44OTYgNS4wMDQtNC44OTZjMS42NTYgMCAyLjk4OC43MiAzLjgzNCAxLjgzNmwtMS44MTggMS4zODZjLS40MzItLjYzLTEuMTE2LTEuMDYyLTIuMDUyLTEuMDYyLTEuNDk0IDAtMi41OTIgMS4xNTItMi41OTIgMi43MzYgMCAxLjYwMiAxLjA5OCAyLjczNiAyLjYyOCAyLjczNlptNi4yMDQtMS41MTJ2LTMuNjcyaC0xLjY5MnYtMi4wODhoMS42OTJWMzMuNjZoMi4zMDR2Mi4yNjhoMi43NzJ2Mi4wODhoLTIuNzcydjMuNjcyYzAgMS4wMDguNTQgMS40MDQgMS40NCAxLjQwNC42MyAwIDEuMDQ0LS4wNzIgMS4zNS0uMTk4djEuOTk4Yy0uNDUuMTk4LS45OS4yODgtMS43NDYuMjg4LTIuMjY4IDAtMy4zNDgtMS4yNzgtMy4zNDgtMy40OTJaJy8+PHBhdGggZmlsbD0nIzAwMDA5MScgZD0nTTQ2Ljk5MiAxOS4wOTggMzEuOTk4IDEwLjQybC0xNC45OTQgOC43NmEuNjA2LjYwNiAwIDAgMC0uMzA2LjUyNXYxNi45NDhhLjY2Ni42NjYgMCAwIDAgLjMwNi41MjRsMTQuOTkyIDguNiAxNC45OTQtOC43MDZhLjY2Ni42NjYgMCAwIDAgLjMwNi0uNTI0VjE5LjYyNmEuNjA0LjYwNCAwIDAgMC0uMzA0LS41MjhaJy8+PHBhdGggZmlsbD0nI0ZDQzYzQScgZD0nbTI2LjY0MSAxOS41OTgtNS4wMjkgOC42MjgtNC41NTctOS4xNzUgNS4zOS0zLjExMyA0LjQ4OSAzLjE2LS4yOTMuNVptMjAuNjU2IDE2Ljk4VjE5LjYyYS42LjYgMCAwIDAtLjMwNi0uNTIzTDMxLjk5OCAxMC40MicvPjxwYXRoIGZpbGw9JyMwMDYzQ0InIGQ9J00xNi43IDM2LjU3OCAzMiAxMC40MnYzNS4zNjJsLTE0Ljk5Ni04LjYwNWEuNjY1LjY2NSAwIDAgMS0uMzA2LS41MjRWMTkuNzA2bC4wMDIgMTYuODcyWm0yNC42NjktMjAuNzM1IDUuNDU4IDMuMTU1LTQuNDg5IDkuMTUtNS4zODctOS4yMzYgNC40MTgtMy4wN1onLz48cGF0aCBmaWxsPScjZmZmJyBkPSdtNTEuNjA2IDE2LjMwMy0xOS4xOS0xMS4wMmEuOTMzLjkzMyAwIDAgMC0uODMyIDBsLTE5LjE5IDExLjAyYS44ODcuODg3IDAgMCAwLS4zOTQuNjk1djIyYS44ODUuODg1IDAgMCAwIC4zOTQuN2wxOS4xODkgMTEuMDJhLjkzMi45MzIgMCAwIDAgLjgzMiAwbDE5LjE5MS0xMS4wMmEuODg2Ljg4NiAwIDAgMCAuMzk0LS43di0yMmEuODg3Ljg4NyAwIDAgMC0uMzk0LS42OTVaTTIyLjc4OSAzNC4wNTloLjA3OWMtLjA0MiAwLS4wNzkuMDA3LS4wNzkuMDUgMCAuMS4xNTEgMCAuMi4xYS45MTIuOTEyIDAgMCAwLS42MjkuMjc2YzAgLjA1LjEuMDUuMTUxLjA1LS4wNzUuMS0uMjI2LjA1LS4yNzcuMTUyYS4xNzYuMTc2IDAgMCAwIC4xLjA1Yy0uMDUgMC0uMSAwLS4xLjA1di4xNTJjLS4xMjYgMC0uMTc2LjEtLjI3Ny4xNS4yLjE1Mi4zMjcgMCAuNTI4IDAtLjUyOC4yLS45NTYuNDc5LTEuNDg0LjYzLS4xIDAgMCAuMTUtLjEuMTUuMTUxLjEuMjI3LS4wNS4zNzctLjA1LS42NTQuMzc4LTEuMzMzLjctMi4wMzcgMS4xMzNhLjM1MS4zNTEgMCAwIDAtLjEuMmgtLjJjLS4xLjA1LS4wNS4xNzYtLjE1MS4yNzcuMjI2LjE1LjUtLjIuNjU0IDAgLjA1IDAtLjEuMDUtLjIuMDUtLjA1IDAtLjA1LjEtLjEuMWgtLjE1NGMtLjEuMDc1LS4yLjEyNi0uMi4yNzZhLjIyLjIyIDAgMCAwLS4yMjYuMSA5LjAzMSA5LjAzMSAwIDAgMCAzLjE0NC0uNTc4IDcuNjgzIDcuNjgzIDAgMCAwIDIuMDg4LTEuNTYuMTc2LjE3NiAwIDAgMSAuMDUuMWMtLjE0Ny40MzctLjQzLjgxNi0uODA2IDEuMDgtLjI3Ny4xNTItLjQ3OC4zNzgtLjcuNDc5YTQuMDU3IDQuMDU3IDAgMCAwLS40MjguMjc2Yy0uNjMyLjE5Ny0xLjI4MS4zMzUtMS45MzkuNDEybC0uMzA1LjA0NGMtLjIyNS4wMzMtLjQ0OS4wNjktLjY3MS4xMDhsLTEuOTkzLTEuMTM4YS42NDcuNjQ3IDAgMCAxLS4yODgtLjQxMS41Ny41NyAwIDAgMCAuMDk0LS4wNjMuMjY2LjI2NiAwIDAgMC0uMTEzLS4wNzF2LS42NWExMi43ODIgMTIuNzgyIDAgMCAwIDMuMDM4LS45NDIgOC43NDYgOC43NDYgMCAwIDAtMy4wMzctMS4zNDN2LTEuNTE1YTExLjY3IDExLjY3IDAgMCAxIDEuNjM5LjM5MiA2LjQyIDYuNDIgMCAwIDEgMS4xODIuNTc4Yy4xNDcuMTQuMzA3LjI2Ny40NzguMzc3YS45MS45MSAwIDAgMCAuOC4wNWguMzNhMy45NjEgMy45NjEgMCAwIDAgMS45MzctLjkwNWMwIC4wNS4wNS4wNS4xLjA1YTMuNjI5IDMuNjI5IDAgMCAxLS40MjggMS4xMzJjLjAwMy4wNS0uMDQ4LjE1Mi4wNTMuMjAyWm0yLjgxNyAzLjU3Yy4yNTEtLjEuNC0uMjc2LjYyOS0uMzc2LS4wNS4wNS0uMDUuMTUtLjEuMmEzLjY5OSAzLjY5OSAwIDAgMC0uNTI4LjQgMTUuOTY1IDE1Ljk2NSAwIDAgMC0xLjU4NSAxLjYxYy0uMjUyLjMtLjUyOC41NzgtLjguODU1LS4wOTYuMDktLjIuMTcyLS4zMS4yNDVsLTIuNTI3LTEuNDVjLjM2LjAzLjcyMS4wMTMgMS4wNzYtLjA1My4yOTQtLjA4My41OC0uMTkyLjg1NS0uMzI3di4xYy43LS4yNzcgMS4yMzItLjkwNiAxLjkzNy0xLjEzMi4wMjUgMCAuMTI2LjEuMjI2LjA1YTEuODgzIDEuODgzIDAgMCAxIDEuNTA5LS43YzAgLjA1IDAgLjEuMDUuMWguMDI1Yy0uMTUxLjEyNi0uMzI3LjI1LS41LjM3Ny0uMDU3LjA1Mi0uMDA3LjEwMi4wNDMuMTAyWm0tOC45MDgtNi4xNjN2LS4xODZhNS44MTcgNS44MTcgMCAwIDEgMS41ODgtLjE4OCAxLjUyIDEuNTIgMCAwIDEgLjQ3OCAwIDUuODYgNS44NiAwIDAgMC0yLjA2Ni4zNzRabTMwLjYgNS4wODhhLjY2NS42NjUgMCAwIDEtLjMwNi41MjRsLTEwLjA3OSA1Ljg1YTMyLjI5NiAzMi4yOTYgMCAwIDEtMy40MDgtMS4xODQgMi44MjYgMi44MjYgMCAwIDEtLjA1LTIuMjQ1Yy4wOC0uMzA4LjE5OC0uNjA1LjM1Mi0uODgzLjAyNS0uMDI1LjA1LS4wNS4wNS0uMDc2YS4wMjUuMDI1IDAgMCAwIC4wMjUtLjAyNSA0LjMyIDQuMzIgMCAwIDEgLjM3Ny0uNTU1bC4wMTUtLjAxNS4wMi0uMDIxLjAxNS0uMDE1YzAtLjAyNS4wMjUtLjA1LjA1LS4wNzYuMDI1LS4wNTEuMDc1LS4wNzYuMS0uMTI2LjE3Ni0uMTg2LjM3LS4zNTQuNTc5LS41LjIxMy0uMDc3LjQzMS0uMTM2LjY1NC0uMTc3LjgxMS4wNiAxLjYxNy4xNyAyLjQxNS4zMjhhLjc1Mi43NTIgMCAwIDEgLjI3Ny4xYy4zMDEuMDU5LjYxMi4wNDEuOTA1LS4wNWExLjEzNyAxLjEzNyAwIDAgMCAuODU1LS43MDYgMS4yMTIgMS4yMTIgMCAwIDAgLjA1LTEuMDZjLS4xNzgtLjI3NS0uMDEzLS40MzYuMTgxLS41OWwuMDY4LS4wNTRjLjA4Ni0uMDYxLjE2NC0uMTM0LjIzMS0uMjE2LjEyNi0uMjUyLS4xLS40LS4xNTEtLjYzLS4wNS0uMS0uMjI2LS4wNS0uMzI3LS4yLjM1Mi0uMTUxLjg1NS0uNDMuNjI5LS44NTctLjE1MS0uMjI3LS4zNzctLjYzLS4xLS44NTcuMzUyLS4yLjg1NS0uMTUxIDEuMDA2LS40OGExLjEzNyAxLjEzNyAwIDAgMC0uMjkyLTEuMDg0bC0uMDc1LS4xMDhhNC43NTQgNC43NTQgMCAwIDEtLjIxMS0uMzIgNi45MDUgNi45MDUgMCAwIDAtLjUyOC0uNzU3IDQuMjk3IDQuMjk3IDAgMCAxLS41MjgtMS4wMWMtLjE1MS0uMzc3LjA1LS43MDUuMDUtMS4wODNhNi4zNDcgNi4zNDcgMCAwIDAtLjMyNy0yLjE0NGMtLjEyNi0uMzUzLS4xNzYtLjczMS0uMzI3LTEuMDZhMS4xMiAxLjEyIDAgMCAwLS4yMjYtLjU4LjM3NC4zNzQgMCAwIDEgMC0uMzI3Yy4yMDUtLjE0NS4zOTktLjMwNS41NzktLjQ4YS41NjcuNTY3IDAgMCAwLS4yLS43MDVjLS4zMjctLjE1MS0uMy4zMjgtLjUyOC40MjloLS4xNTFjLS4wNS0uMTI2LjA1LS4xNzcuMTUxLS4yNzcgMC0uMDUgMC0uMTUxLS4wNS0uMTUxLS4yIDAtLjM3Ny0uMDUxLS40MjgtLjE1MWEzLjk1NyAzLjk1NyAwIDAgMC0xLjg2MS0xLjI4NmMuMTg4LjA1OC4zODIuMDkxLjU3OS4xLjMzOC4wNzEuNjkuMDM2IDEuMDA2LS4xLjIyNy0uMDc2LjI3Ny0uNDguMzc3LS43MDZhLjguOCAwIDAgMC0uMTUxLS42MzEgMi4xOSAyLjE5IDAgMCAwLS45MDYtLjc1NiA5LjEzIDkuMTMgMCAwIDEtLjY3OS0uMzUzLjk1Ni45NTYgMCAwIDAtLjI1MS0uMTI2Yy0yLjk2NS0xLjQ4NS05LjA2OS0uMi05LjUzNCAwaC0uMDA5YTguMjU0IDguMjU0IDAgMCAwLTEuMjQ5LjQ3NSAzLjkyMiAzLjkyMiAwIDAgMC0yLjM2NSAyLjQ2NSAzLjgzIDMuODMgMCAwIDAtMS4zMzMgMS41MDljLS40MjguOC0xLjA1NiAxLjUwOS0uOTU2IDIuNDE0LjEuNzguMjc3IDEuNDg0LjQyOCAyLjI4OS4wNDMuMjcyLjExLjU0LjIuOC4xLjI3NiAwIC42MjkuMTUxLjg1NS4wNzUuMTUuMDI1LjMyNy4yMjcuNDI4di4yYy4wNS4wNS4wNS4xLjE1MS4xdi4yYy40MzUuNDIzLjgwNy45MDYgMS4xMDcgMS40MzQuMS4yNzYtLjQ3OC4xNS0uNy4wNWE1Ljk3NyA1Ljk3NyAwIDAgMS0xLjEzMi0uOTU2LjE3Ni4xNzYgMCAwIDAtLjA1MS4xYy4yLjM1Mi45MDYuNzguNTI4IDEuMDA2LS4yLjEtLjQyOC0uMTUxLS42MjkuMDUtLjA1LjA3NiAwIC4xNzcgMCAuMjc3LS4yNzctLjItLjU3OC0uMS0uODU1LS4yLS4yLS4wNS0uMjUyLS40MjctLjQ3OC0uNDI3YTE1LjE5MSAxNS4xOTEgMCAwIDAtMS44MTEtLjMyNyAxNS4xNDQgMTUuMTQ0IDAgMCAwLTEuNzM5LS4xNlYxOS43MDdhLjYwNi42MDYgMCAwIDEgLjMwNi0uNTI0bDE0Ljk4Ny04Ljc2MSAxNC45OTQgOC42NzdhLjYwNS42MDUgMCAwIDEgLjMwNi41MjR2MTYuOTMyWm0tNy45NTQtOC4yNjFhLjMyNS4zMjUgMCAwIDEtLjI4Mi4xNDkgMi44NCAyLjg0IDAgMCAwLS4yODIuMjczYy4xIDAgMCAuMTQ5LjEuMTQ5LS4yMDUuMjIzLjA3Ny42OTQtLjIwNS43OTMtLjM3LjA5OS0uNzU4LjA5OS0xLjEyNyAwYS43MjcuNzI3IDAgMCAxIC4xNjctLjAxNmguMDg1YS4zODIuMzgyIDAgMCAwIC4zMzctLjEzMnYtLjJjMC0uMDUtLjA1MS0uMDUtLjEtLjA1YS4xNi4xNiAwIDAgMS0uMS4wNS4yMjMuMjIzIDAgMCAwLS4xNTQtLjIuODA2LjgwNiAwIDAgMS0uNzE4LS4yNzMuNjcuNjcgMCAwIDEgLjQzNi0uMDVjLjEyOCAwIC4wNzctLjIyMy4yMzEtLjMyMmguMTU0Yy4zMDctLjM3Mi44NzEtLjQ3MS45NzQtLjg0MyAwLS4xLS4yODItLjEtLjQ4Ny0uMTVhMi4yNiAyLjI2IDAgMCAwLS44Mi4wNWMtLjM2LjA1LS43MTIuMTQyLTEuMDUxLjI3NC4yOC0uMjA2LjU5Mi0uMzY1LjkyMy0uNDcxLjIzMi0uMDkuNDczLS4xNTcuNzE4LS4ybC4xMzItLjAyNi4xMzMtLjAyN2EuOTcuOTcgMCAwIDEgLjU1NiAwYy4yMzEuMS42MTUuMS42NjYuMjQ4LjEuMjczLS4xNTQuNTQ1LS40MzUuNzQ0LS4wNTcuMDguMTQ5LjEzNS4xNDkuMjNaJy8+PHBhdGggZmlsbD0nI0ZDQzYzQScgZD0nTTY0LjU2IDVIMzlhMiAyIDAgMCAwLTIgMnY5LjMwMmEyIDIgMCAwIDAgMiAyaDI1LjU2YTIgMiAwIDAgMCAyLTJWN2EyIDIgMCAwIDAtMi0yWicvPjxwYXRoIGZpbGw9JyMxNjE2MTYnIGQ9J00zOS41NjIgMTYuMTY4VjcuMzE2aDIuOTIxYy45NyAwIDEuNzMyLjIzNiAyLjI4OS43MDguNTY1LjQ3Mi44NDcgMS4xMTcuODQ3IDEuOTM1IDAgLjgxLS4yODIgMS40NS0uODQ3IDEuOTIyLS41NTcuNDcyLTEuMzIuNzA4LTIuMjg5LjcwOGgtMS4xMjV2My41NzloLTEuNzk2Wm0yLjk5Ny03LjMyMmgtMS4yMDF2Mi4yMTNoMS4yYy4zOCAwIC42NzUtLjA5Ny44ODYtLjI5LjIyLS4xOTUuMzI5LS40NzMuMzI5LS44MzYgMC0uMzM3LS4xMS0uNjAyLS4zMjktLjc5Ni0uMjEtLjE5NC0uNTA2LS4yOTEtLjg4NS0uMjkxWk00Ny4yMyAxNi4xNjhWNy4zMTZoMi43MDdjLjk3IDAgMS43MzYuMjM2IDIuMzAxLjcwOC41NjUuNDcyLjg0NyAxLjExNy44NDcgMS45MzUgMCAuNTMtLjEyNi45OTUtLjM3OSAxLjM5LS4yNDQuMzg5LS41OS42ODgtMS4wMzcuODk5bDIuNzgyIDMuOTJoLTIuMTVsLTIuMzUyLTMuNTc5aC0uOTIzdjMuNTc5aC0xLjc5NVptMi44MDgtNy4zMjJoLTEuMDEydjIuMjEzaDEuMDEyYy4zOCAwIC42NzQtLjA5Ny44ODUtLjI5LjIxLS4xOTUuMzE2LS40NzMuMzE2LS44MzYgMC0uMzM3LS4xMDUtLjYwMi0uMzE2LS43OTYtLjIxLS4xOTQtLjUwNi0uMjkxLS44ODUtLjI5MVpNNTkuNTQ5IDcuMDYzYy42OSAwIDEuMzIzLjEyNiAxLjg5Ni4zOC41ODIuMjUyIDEuMDguNTkgMS40OTIgMS4wMTEuNDE0LjQyMS43MzQuOTE5Ljk2MiAxLjQ5Mi4yMjcuNTY1LjM0MSAxLjE2NC4zNDEgMS43OTYgMCAuNjMyLS4xMTQgMS4yMzUtLjM0MSAxLjgwOGE0LjQ4NSA0LjQ4NSAwIDAgMS0uOTYyIDEuNDhjLS40MTMuNDIxLS45MS43NTgtMS40OTIgMS4wMTFhNC42NDggNC42NDggMCAwIDEtMS44OTYuMzggNC43MzggNC43MzggMCAwIDEtMy40MDItMS4zOTEgNC40ODQgNC40ODQgMCAwIDEtLjk2MS0xLjQ4IDQuODU1IDQuODU1IDAgMCAxLS4zNDItMS44MDhjMC0uNjMzLjExNC0xLjIzMS4zNDItMS43OTYuMjI3LS41NzMuNTQ4LTEuMDcuOTYxLTEuNDkyLjQxMy0uNDIyLjkxLS43NTkgMS40OTItMS4wMTJhNC43MzcgNC43MzcgMCAwIDEgMS45MS0uMzc5Wm0wIDcuNjc2YTIuOCAyLjggMCAwIDAgMS4xMzgtLjIyOGMuMzU0LS4xNi42NTMtLjM3Ljg5OC0uNjMyLjI1Mi0uMjcuNDUtLjU4Ni41OTQtLjk0OWEzLjI3IDMuMjcgMCAwIDAgLjIxNS0xLjE4OCAzLjE3IDMuMTcgMCAwIDAtLjIxNS0xLjE3NiAyLjc5MSAyLjc5MSAwIDAgMC0uNTk1LS45NDkgMi41NDggMi41NDggMCAwIDAtLjg5Ny0uNjMyIDIuNjczIDIuNjczIDAgMCAwLTEuMTM4LS4yNGMtLjQxMyAwLS43OTcuMDgtMS4xNTEuMjRhMi42NzggMi42NzggMCAwIDAtLjkxLjYzMiAyLjg5OSAyLjg5OSAwIDAgMC0uNTgyLjk0OSAzLjE3IDMuMTcgMCAwIDAtLjIxNSAxLjE3NmMwIC40MjEuMDcxLjgxNy4yMTUgMS4xODguMTQzLjM2My4zMzcuNjc5LjU4MS45NDkuMjUzLjI2MS41NTcuNDcyLjkxLjYzMi4zNTUuMTUyLjczOS4yMjggMS4xNTIuMjI4WicvPjwvZz48ZGVmcz48Y2xpcFBhdGggaWQ9J2EnPjxwYXRoIGZpbGw9JyNmZmYnIGQ9J00wIDBoMjExdjU4SDB6Jy8+PC9jbGlwUGF0aD48L2RlZnM+PC9zdmc+"); -} \ No newline at end of file From d8681b43a921d226a065d829c7f62e5b018f288e Mon Sep 17 00:00:00 2001 From: Alessandro Mosca Date: Tue, 21 Apr 2026 17:34:05 +0200 Subject: [PATCH 6/9] feat(proconnect): add sso_logout --- playground/app/core/configuration.py | 7 ++++-- playground/app/features/auth/state.py | 36 +++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/playground/app/core/configuration.py b/playground/app/core/configuration.py index 993acdfc8..663e6ceab 100644 --- a/playground/app/core/configuration.py +++ b/playground/app/core/configuration.py @@ -114,6 +114,9 @@ class Settings(ConfigBaseModel): playground_sso_opengatellm_admin_api_key: str | None = Field(default=None, description="To activate SSO, set OpenGateLLM API key with ADMIN permissions to create users and tokens.") # fmt: off playground_sso_opengatellm_default_role_id: int | None = Field(default=None, description="To activate SSO, set the default role ID of OpenGateLLM API for new users.") # fmt: off + playground_sso_oauth2_proxy_url: str = Field(default="http://localhost:4180", description="The proxy url for SSO.") + playground_sso_provider_logout_url: str | None = Field(default= None, description="The logout url for SSO.") + playground_default_model: str | None = Field(default=None, description="The first model selected in chat page.") playground_theme_has_background: bool = Field(default=True, description="Whether the theme has a background.") playground_theme_accent_color: str = Field(default="purple", description="The primary color used for default buttons, typography, backgrounds, etc. See available colors at https://www.radix-ui.com/colors.") # fmt: off @@ -134,8 +137,8 @@ def validate_sso_enabled(self): raise ValueError("SSO is enabled but no OpenGateLLM API key with ADMIN permissions is provided.") if self.playground_sso_opengatellm_default_role_id is None: raise ValueError("SSO is enabled but no default role ID is provided.") - return self - + if self.playground_sso_provider_logout_url is None: + raise ValueError("SSO is enabled but no logout url is provided.") class ConfigFile(ConfigBaseModel): """ diff --git a/playground/app/features/auth/state.py b/playground/app/features/auth/state.py index 43458a13a..af821a65f 100644 --- a/playground/app/features/auth/state.py +++ b/playground/app/features/auth/state.py @@ -1,5 +1,6 @@ import httpx import reflex as rx +from urllib3.contrib.emscripten import response from app.core.configuration import configuration from app.shared.components.toasts import httpx_error_toast @@ -33,6 +34,8 @@ class AuthState(rx.State): opengatellm_timeout: int = configuration.settings.playground_opengatellm_timeout sso_opengatellm_admin_api_key: str | None = configuration.settings.playground_sso_opengatellm_admin_api_key sso_opengatellm_default_role_id: int | None = configuration.settings.playground_sso_opengatellm_default_role_id + sso_oauth2_proxy_url: str = configuration.settings.playground_sso_oauth2_proxy_url + sso_provider_logout_url: str = configuration.settings.playground_sso_provider_logout_url # Form fields email_input: str = "" @@ -219,3 +222,36 @@ def logout(self): self.user_updated = None self.user_permissions = [] self.user_limits = [] + + @rx.event + async def sso_logout(self): + """Handle logout.""" + try: + async with httpx.AsyncClient() as client: + response = await client.get( + url=f"{self.sso_oauth2_proxy_url}/oauth2/sign_out", + params={"rd" : self.sso_provider_logout_url}, + timeout=self.opengatellm_timeout, + ) + response.raise_for_status() + + self.is_authenticated = False + self.user_id = None + self.user_email = None + self.user_name = None + self.api_key = None + self.api_key_id = None + self.user_organization = None + self.user_budget = None + self.user_priority = None + self.user_created = None + self.user_updated = None + self.user_permissions = [] + self.user_limits = [] + + except Exception as e: + yield httpx_error_toast(exception=e, response=response) + + finally: + self.is_loading = False + yield From 9f7a733879d78f66624a021744db5ce43c859f1f Mon Sep 17 00:00:00 2001 From: Alessandro Mosca Date: Wed, 22 Apr 2026 11:33:06 +0200 Subject: [PATCH 7/9] feat(sso): add logout --- playground/app/core/configuration.py | 6 ++- playground/app/features/auth/state.py | 50 +++++++------------ .../navigation/components/sidebars.py | 6 ++- 3 files changed, 29 insertions(+), 33 deletions(-) diff --git a/playground/app/core/configuration.py b/playground/app/core/configuration.py index 663e6ceab..770d11a2d 100644 --- a/playground/app/core/configuration.py +++ b/playground/app/core/configuration.py @@ -138,7 +138,10 @@ def validate_sso_enabled(self): if self.playground_sso_opengatellm_default_role_id is None: raise ValueError("SSO is enabled but no default role ID is provided.") if self.playground_sso_provider_logout_url is None: - raise ValueError("SSO is enabled but no logout url is provided.") + raise ValueError("SSO is enabled but no provider logout url is provided.") + if self.playground_sso_oauth2_proxy_url is None: + raise ValueError("SSO is enabled but no oauth2 proxy url is provided.") + return self class ConfigFile(ConfigBaseModel): """ @@ -164,6 +167,7 @@ def config_file_exists(cls, config_file): return config_file @model_validator(mode="after") + @classmethod def setup_config(cls, values) -> Any: with open(file=values.config_file) as file: lines = file.readlines() diff --git a/playground/app/features/auth/state.py b/playground/app/features/auth/state.py index af821a65f..4cb47b83c 100644 --- a/playground/app/features/auth/state.py +++ b/playground/app/features/auth/state.py @@ -1,6 +1,5 @@ import httpx import reflex as rx -from urllib3.contrib.emscripten import response from app.core.configuration import configuration from app.shared.components.toasts import httpx_error_toast @@ -32,6 +31,7 @@ class AuthState(rx.State): opengatellm_url: str = configuration.settings.playground_opengatellm_url opengatellm_timeout: int = configuration.settings.playground_opengatellm_timeout + sso_enabled: bool = configuration.settings.playground_sso_enabled sso_opengatellm_admin_api_key: str | None = configuration.settings.playground_sso_opengatellm_admin_api_key sso_opengatellm_default_role_id: int | None = configuration.settings.playground_sso_opengatellm_default_role_id sso_oauth2_proxy_url: str = configuration.settings.playground_sso_oauth2_proxy_url @@ -224,34 +224,22 @@ def logout(self): self.user_limits = [] @rx.event - async def sso_logout(self): - """Handle logout.""" - try: - async with httpx.AsyncClient() as client: - response = await client.get( - url=f"{self.sso_oauth2_proxy_url}/oauth2/sign_out", - params={"rd" : self.sso_provider_logout_url}, - timeout=self.opengatellm_timeout, - ) - response.raise_for_status() - - self.is_authenticated = False - self.user_id = None - self.user_email = None - self.user_name = None - self.api_key = None - self.api_key_id = None - self.user_organization = None - self.user_budget = None - self.user_priority = None - self.user_created = None - self.user_updated = None - self.user_permissions = [] - self.user_limits = [] - - except Exception as e: - yield httpx_error_toast(exception=e, response=response) + def sso_logout(self): + """Handle SSO logout by redirecting the browser to oauth2-proxy sign_out. - finally: - self.is_loading = False - yield + oauth2-proxy clears the session cookie then redirects to the provider logout URL. + """ + self.is_authenticated = False + self.user_id = None + self.user_email = None + self.user_name = None + self.api_key = None + self.api_key_id = None + self.user_organization = None + self.user_budget = None + self.user_priority = None + self.user_created = None + self.user_updated = None + self.user_permissions = [] + self.user_limits = [] + return rx.redirect(f"{self.sso_oauth2_proxy_url}/oauth2/sign_out?rd={self.sso_provider_logout_url}") diff --git a/playground/app/features/navigation/components/sidebars.py b/playground/app/features/navigation/components/sidebars.py index 899b8d9bc..189e51066 100644 --- a/playground/app/features/navigation/components/sidebars.py +++ b/playground/app/features/navigation/components/sidebars.py @@ -103,7 +103,11 @@ def navigation_sidebar() -> rx.Component: rx.button( rx.icon("log-out", size=16), "Logout", - on_click=AuthState.logout, + on_click=rx.cond( + AuthState.sso_enabled, + AuthState.sso_logout, + AuthState.logout, + ), variant="soft", color_scheme="red", width="100%", From a90ad933fb46a7f74963455983437623499086aa Mon Sep 17 00:00:00 2001 From: leoguillaume Date: Thu, 23 Apr 2026 15:50:40 +0200 Subject: [PATCH 8/9] wip --- .gitignore | 1 + api/schemas/core/configuration.py | 9 ++-- playground/app/core/configuration.py | 50 ++++++++++--------- playground/app/features/auth/state.py | 12 +++-- .../navigation/components/sidebars.py | 4 +- scripts/docs/configuration_header.md | 1 + .../generate_configuration_documentation.py | 18 +++++++ 7 files changed, 61 insertions(+), 34 deletions(-) diff --git a/.gitignore b/.gitignore index 52d0b8157..d5bbd089a 100644 --- a/.gitignore +++ b/.gitignore @@ -216,6 +216,7 @@ playground/.gitignore playground/requirements.txt run.sh .claude +.cursor bruno docs/.astro docs/node_modules diff --git a/api/schemas/core/configuration.py b/api/schemas/core/configuration.py index e02938deb..9569a8f17 100644 --- a/api/schemas/core/configuration.py +++ b/api/schemas/core/configuration.py @@ -22,7 +22,6 @@ def custom_validation_error(suffix: str = ""): - # TODO: surcharge url pour playground """ Decorator to override Pydantic ValidationError to change error message. @@ -270,10 +269,10 @@ class EmptyDependency(ConfigBaseModel): @custom_validation_error() class Dependencies(ConfigBaseModel): - albert: AlbertDependency | None = Field(default=None, description="**[DEPRECATED]** See the [AlbertDependency section](#albertdependency) for more information.") # fmt: off - celery: CeleryDependency | None = Field(default=None, description="**[DEPRECATED]** See the [CeleryDependency section](#celerydependency) for more information.") # fmt: off + albert: AlbertDependency | None = Field(default=None, description="**[DEPRECATED]** See the [AlbertDependency section](#albertdependency) for more information.", json_schema_extra={"deprecated": True}) # fmt: off + celery: CeleryDependency | None = Field(default=None, description="**[DEPRECATED]** See the [CeleryDependency section](#celerydependency) for more information.", json_schema_extra={"deprecated": True}) # fmt: off elasticsearch: ElasticsearchDependency | None = Field(default=None, description="See the [ElasticsearchDependency section](#elasticsearchdependency) for more information.") # fmt: off - marker: MarkerDependency | None = Field(default=None, description="**[DEPRECATED]** See the [MarkerDependency section](#markerdependency) for more information.") # fmt: off + marker: MarkerDependency | None = Field(default=None, description="**[DEPRECATED]** See the [MarkerDependency section](#markerdependency) for more information.", json_schema_extra={"deprecated": True}) # fmt: off postgres: PostgresDependency = Field(..., description="See the [PostgresDependency section](#postgresdependency) for more information.") # fmt: off redis: RedisDependency = Field(..., description="See the [RedisDependency section](#redisdependency) for more information.") # fmt: off sentry: SentryDependency | None = Field(default=None, description="See the [SentryDependency section](#sentrydependency) for more information.") # fmt: off @@ -382,7 +381,7 @@ class Settings(ConfigBaseModel): swagger_redoc_url: str = Field(default="/redoc", pattern=r"^/", description="Redoc URL of swagger UI, see https://fastapi.tiangolo.com/tutorial/metadata for more information.") # fmt: off # auth - auth_master_key: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)] = Field(default="changeme", description="[DEPRECATED] Master key for the API. It should be a random string with at least 32 characters. This key has all permissions and cannot be modified or deleted. This key is used to create the first role and the first user.") # fmt: off + auth_master_key: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)] = Field(default="changeme", description="[DEPRECATED] Master key for the API. It should be a random string with at least 32 characters. This key has all permissions and cannot be modified or deleted. This key is used to create the first role and the first user.", json_schema_extra={"deprecated": True}) # fmt: off auth_secret_key: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)] | None = Field(default=None, description="Secret key for the API. It should be a random string with at least 32 characters. This key is used to encrypt user tokens, watch out if you modify the secret key, you'll need to update all user API keys. If not provided, the master key will be used.") # fmt: off auth_default_username: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)] = Field(default="admin", description="Username of the admin user created at startup.") # fmt: off auth_default_password: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)] = Field(default="changeme", description="Password of the admin user created at startup.") # fmt: off diff --git a/playground/app/core/configuration.py b/playground/app/core/configuration.py index 770d11a2d..851e57099 100644 --- a/playground/app/core/configuration.py +++ b/playground/app/core/configuration.py @@ -2,9 +2,9 @@ import logging import os import re -from typing import Any, get_args, get_origin +from typing import Annotated, Any, Literal, get_args, get_origin -from pydantic import BaseModel, ConfigDict, Field, constr, field_validator, model_validator +from pydantic import BaseModel, ConfigDict, Field, StringConstraints, constr, field_validator, model_validator from pydantic import ValidationError as PydanticValidationError from pydantic_settings import BaseSettings import yaml @@ -102,22 +102,15 @@ class Dependencies(ConfigBaseModel): @custom_validation_error(suffix="-1") -class Settings(ConfigBaseModel): +class Settings(BaseModel): auth_key_max_expiration_days: int | None = Field(default=None, ge=1, description="Maximum number of days for a token to be valid.") # fmt: off routing_max_priority: int = Field(default=10, ge=0, description="Maximum allowed priority in routing tasks.") # fmt: off app_title: str = Field(default=DEFAULT_APP_NAME, description="The title of the application.") playground_opengatellm_url: str = Field(default="http://localhost:8000", description="The URL of the OpenGateLLM API.") playground_opengatellm_timeout: int = Field(default=60, description="The timeout in seconds for the OpenGateLLM API.") - - playground_sso_enabled: bool = Field(default=False, description="Whether SSO is enabled.") - playground_sso_opengatellm_admin_api_key: str | None = Field(default=None, description="To activate SSO, set OpenGateLLM API key with ADMIN permissions to create users and tokens.") # fmt: off - playground_sso_opengatellm_default_role_id: int | None = Field(default=None, description="To activate SSO, set the default role ID of OpenGateLLM API for new users.") # fmt: off - - playground_sso_oauth2_proxy_url: str = Field(default="http://localhost:4180", description="The proxy url for SSO.") - playground_sso_provider_logout_url: str | None = Field(default= None, description="The logout url for SSO.") - playground_default_model: str | None = Field(default=None, description="The first model selected in chat page.") + playground_theme_has_background: bool = Field(default=True, description="Whether the theme has a background.") playground_theme_accent_color: str = Field(default="purple", description="The primary color used for default buttons, typography, backgrounds, etc. See available colors at https://www.radix-ui.com/colors.") # fmt: off playground_theme_appearance: str = Field(default="light", description="The appearance of the theme.") @@ -130,18 +123,20 @@ class Settings(ConfigBaseModel): reference_url: str | None = Field(default="http://localhost:8000/redoc", pattern=r"^http[s]?://", description="Reference URL. If not provided, deactivated reference link in the navigation bar.") # fmt: off documentation_url: str | None = Field(default="https://docs.opengatellm.org", pattern=r"^http[s]?://", description="Documentation URL. If not provided, deactivated documentation link in the navigation bar.") # fmt: off - @model_validator(mode="after") - def validate_sso_enabled(self): - if self.playground_sso_enabled: - if self.playground_sso_opengatellm_admin_api_key is None: - raise ValueError("SSO is enabled but no OpenGateLLM API key with ADMIN permissions is provided.") - if self.playground_sso_opengatellm_default_role_id is None: - raise ValueError("SSO is enabled but no default role ID is provided.") - if self.playground_sso_provider_logout_url is None: - raise ValueError("SSO is enabled but no provider logout url is provided.") - if self.playground_sso_oauth2_proxy_url is None: - raise ValueError("SSO is enabled but no oauth2 proxy url is provided.") - return self + +class SettingsWithoutSSO(Settings): + playground_sso_enabled: Literal[False] = Field(default=False, description="Whether SSO is enabled.") + playground_sso_opengatellm_admin_api_key: Any = Field(default=None, description="To activate SSO, set OpenGateLLM API key with ADMIN permissions to create users and tokens.") # fmt: off + playground_sso_opengatellm_default_role_id: Any = Field(default=None, description="To activate SSO, set the default role ID of OpenGateLLM API for new users.") # fmt: off + playground_sso_provider_logout_url: Any = Field(default=None, description="The logout url for SSO.") + + +class SettingsWithSSO(Settings): + playground_sso_enabled: Literal[True] = Field(default=True, description="Whether SSO is enabled.") + playground_sso_opengatellm_admin_api_key: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)] = Field(description="To activate SSO, set OpenGateLLM API key with ADMIN permissions to create users and tokens.") # fmt: off + playground_sso_opengatellm_default_role_id: Annotated[int, Field(ge=0, description="To activate SSO, set the default role ID of OpenGateLLM API for new users.")] = Field(description="To activate SSO, set the default role ID of OpenGateLLM API for new users.") # fmt: off + playground_sso_provider_logout_url: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)] = Field(description="The logout url for SSO.") # fmt: off + class ConfigFile(ConfigBaseModel): """ @@ -153,7 +148,14 @@ class ConfigFile(ConfigBaseModel): """ dependencies: Dependencies = Field(default_factory=Dependencies, description="Dependencies used by the playground.") # fmt: off - settings: Settings = Field(default_factory=Settings, description="General settings configuration fields. Some fields are common to the API and the playground.") # fmt: off + settings: Annotated[SettingsWithoutSSO | SettingsWithSSO, Field(discriminator="playground_sso_enabled", default_factory=SettingsWithoutSSO, description="General settings configuration fields. Some fields are common to the API and the playground.")] # fmt: off + + @model_validator(mode="before") + @classmethod + def normalize(cls, data: Any) -> Any: + if isinstance(data, dict) and isinstance(data.get("settings"), dict): + data["settings"].setdefault("playground_sso_enabled", False) + return data class Configuration(BaseSettings): diff --git a/playground/app/features/auth/state.py b/playground/app/features/auth/state.py index 4cb47b83c..9dbe7c175 100644 --- a/playground/app/features/auth/state.py +++ b/playground/app/features/auth/state.py @@ -1,3 +1,5 @@ +from urllib.parse import quote + import httpx import reflex as rx @@ -34,7 +36,6 @@ class AuthState(rx.State): sso_enabled: bool = configuration.settings.playground_sso_enabled sso_opengatellm_admin_api_key: str | None = configuration.settings.playground_sso_opengatellm_admin_api_key sso_opengatellm_default_role_id: int | None = configuration.settings.playground_sso_opengatellm_default_role_id - sso_oauth2_proxy_url: str = configuration.settings.playground_sso_oauth2_proxy_url sso_provider_logout_url: str = configuration.settings.playground_sso_provider_logout_url # Form fields @@ -207,7 +208,7 @@ def is_master(self) -> bool: return self.user_id == 0 @rx.event - def logout(self): + def basic_logout(self): """Handle logout.""" self.is_authenticated = False self.user_id = None @@ -242,4 +243,9 @@ def sso_logout(self): self.user_updated = None self.user_permissions = [] self.user_limits = [] - return rx.redirect(f"{self.sso_oauth2_proxy_url}/oauth2/sign_out?rd={self.sso_provider_logout_url}") + + # return rx.redirect(rd) + # client_id = "557aea18a617ec6a06260ec42015f26251d671f3914a7312e6b168dc4e4f738e" + # url = f"https://fca.integ01.dev-agentconnect.fr/api/v2/session/end?client_id={client_id}&post_logout_redirect_uri=http%3A%2F%2Flocalhost:4180/oauth2/sign_in" + # return rx.redirect(urljoin(base=self.sso_oauth2_proxy_url, url=f"/oauth2/sign_out?rd={rd}")) + return rx.redirect(quote(self.sso_provider_logout_url, safe="")) diff --git a/playground/app/features/navigation/components/sidebars.py b/playground/app/features/navigation/components/sidebars.py index 189e51066..adb50442f 100644 --- a/playground/app/features/navigation/components/sidebars.py +++ b/playground/app/features/navigation/components/sidebars.py @@ -106,7 +106,7 @@ def navigation_sidebar() -> rx.Component: on_click=rx.cond( AuthState.sso_enabled, AuthState.sso_logout, - AuthState.logout, + AuthState.basic_logout, ), variant="soft", color_scheme="red", @@ -127,7 +127,7 @@ def navigation_sidebar() -> rx.Component: width="250px", height="94%", background_color=rx.color("mauve", 2), - border_right=f"1px solid {rx.color("mauve", 3)}", + border_right=f"1px solid {rx.color('mauve', 3)}", position="fixed", left="0", top="65px", diff --git a/scripts/docs/configuration_header.md b/scripts/docs/configuration_header.md index ac9f43e1b..dc04a2d76 100644 --- a/scripts/docs/configuration_header.md +++ b/scripts/docs/configuration_header.md @@ -4,6 +4,7 @@ sidebar: label: "[lucide:file-text] Configuration file" order: 0 --- +import { Tabs } from '@astrojs/starlight/components'; OpenGateLLM requires configuring a configuration file. This defines models, dependencies, and settings parameters. Playground and API need a configuration file (could be the same file), see [API configuration](#api-configuration) and [Playground configuration](#playground-configuration). diff --git a/scripts/docs/generate_configuration_documentation.py b/scripts/docs/generate_configuration_documentation.py index 0c7ed0ca3..fa2c6e41d 100644 --- a/scripts/docs/generate_configuration_documentation.py +++ b/scripts/docs/generate_configuration_documentation.py @@ -26,6 +26,7 @@ def get_documentation_data(title: str, data: list, properties: dict, defs: dict, for property in sorted(properties): description = properties[property].get("description", "") description = description.replace("|", "\\|") + default = properties[property].get("default", properties[property].get("default", "**required**")) default = f"`{default}`" if default != "**required**" else "**required**" @@ -35,6 +36,23 @@ def get_documentation_data(title: str, data: list, properties: dict, defs: dict, if "anyOf" in properties[property]: properties[property].update(properties[property]["anyOf"][0]) + if "oneOf" in properties[property]: + for one_of in properties[property]["oneOf"]: + ref_key = one_of.get("$ref").split("/")[-1] + ref = defs[ref_key] + type = ref.get("type", "") + values = ref.get("enum", []) + + if "properties" in ref: + data = get_documentation_data( + title=ref_key, + data=data, + properties=ref["properties"], + defs=defs, + header=ref.get("description"), + level=level + 1, + ) + if "$ref" in properties[property]: ref_key = properties[property]["$ref"].split("/")[-1] ref = defs[ref_key] From 7e45d8b8c07d2706ef49ba0aa6d5b3fa9096ced9 Mon Sep 17 00:00:00 2001 From: leoguillaume Date: Thu, 23 Apr 2026 16:33:36 +0200 Subject: [PATCH 9/9] wip --- api/schemas/core/configuration.py | 16 +- .../docs/configuration/configuration_file.md | 375 --------------- .../docs/configuration/configuration_file.mdx | 454 ++++++++++++++++++ scripts/docs/configuration_header.md | 2 +- .../generate_configuration_documentation.py | 355 +++++++++----- 5 files changed, 690 insertions(+), 512 deletions(-) delete mode 100644 docs/src/content/docs/configuration/configuration_file.md create mode 100644 docs/src/content/docs/configuration/configuration_file.mdx diff --git a/api/schemas/core/configuration.py b/api/schemas/core/configuration.py index 9569a8f17..6d342c77a 100644 --- a/api/schemas/core/configuration.py +++ b/api/schemas/core/configuration.py @@ -145,8 +145,6 @@ class Model(ConfigBaseModel): serve the same type of model (text-generation or text-embeddings-inference, etc.). We recommend that all providers of a model serve exactly the same model, otherwise users may receive responses of varying quality. For embedding models, the API verifies that all providers output vectors of the same dimension. You can define the load balancing strategy between the model's providers. By default, it is random. - - For more information to configure model providers, see the [ModelProvider section](#modelprovider). """ name: constr(strip_whitespace=True, min_length=1, max_length=64) = Field(..., description="Unique name exposed to clients when selecting the model.", examples=["gpt-4o"]) # fmt: off @@ -269,13 +267,13 @@ class EmptyDependency(ConfigBaseModel): @custom_validation_error() class Dependencies(ConfigBaseModel): - albert: AlbertDependency | None = Field(default=None, description="**[DEPRECATED]** See the [AlbertDependency section](#albertdependency) for more information.", json_schema_extra={"deprecated": True}) # fmt: off - celery: CeleryDependency | None = Field(default=None, description="**[DEPRECATED]** See the [CeleryDependency section](#celerydependency) for more information.", json_schema_extra={"deprecated": True}) # fmt: off - elasticsearch: ElasticsearchDependency | None = Field(default=None, description="See the [ElasticsearchDependency section](#elasticsearchdependency) for more information.") # fmt: off - marker: MarkerDependency | None = Field(default=None, description="**[DEPRECATED]** See the [MarkerDependency section](#markerdependency) for more information.", json_schema_extra={"deprecated": True}) # fmt: off - postgres: PostgresDependency = Field(..., description="See the [PostgresDependency section](#postgresdependency) for more information.") # fmt: off - redis: RedisDependency = Field(..., description="See the [RedisDependency section](#redisdependency) for more information.") # fmt: off - sentry: SentryDependency | None = Field(default=None, description="See the [SentryDependency section](#sentrydependency) for more information.") # fmt: off + albert: AlbertDependency | None = Field(default=None, json_schema_extra={"deprecated": True}) # fmt: off + celery: CeleryDependency | None = Field(default=None, json_schema_extra={"deprecated": True}) # fmt: off + elasticsearch: ElasticsearchDependency | None = Field(default=None, description="Elasticsearch is an optional dependency of OpenGateLLM. Elasticsearch is used as a vector store. If this dependency is provided, all documents endpoint are enabled.") # fmt: off + marker: MarkerDependency | None = Field(default=None, json_schema_extra={"deprecated": True}) # fmt: off + postgres: PostgresDependency = Field(..., description="Postgres is a required dependency of OpenGateLLM to store API data.") # fmt: off + redis: RedisDependency = Field(..., description="Redis is a required dependency of OpenGateLLM to store rate limiting counters and performance metrics.") # fmt: off + sentry: SentryDependency | None = Field(default=None, description="Sentry is an optional dependency of OpenGateLLM. Sentry helps you identify, diagnose, and fix errors in real-time.") # fmt: off @model_validator(mode="after") def complete_celery(self): diff --git a/docs/src/content/docs/configuration/configuration_file.md b/docs/src/content/docs/configuration/configuration_file.md deleted file mode 100644 index cd4ae32ec..000000000 --- a/docs/src/content/docs/configuration/configuration_file.md +++ /dev/null @@ -1,375 +0,0 @@ ---- -title: Configuration file -sidebar: - label: "[lucide:file-text] Configuration file" - order: 0 ---- - -OpenGateLLM requires configuring a configuration file. This defines models, dependencies, and settings parameters. Playground and API need a configuration file (could be the same file), see [API configuration](#api-configuration) and [Playground configuration](#playground-configuration). - -By default, the configuration file must be `./config.yml` file. - -You can change the configuration file by setting the `CONFIG_FILE` environment variable. - -## Secrets - -You can pass environment variables in configuration file with pattern `${ENV_VARIABLE_NAME}`. All environment variables will be loaded in the configuration file. - -**Example** - -```yaml -models: - [...] - - name: my-language-model - type: text-generation - providers: - - type: openai - url: https://api.openai.com - key: ${OPENAI_API_KEY} - model_name: gpt-4o-mini -``` - -## Example - -The following is an example of configuration file: - -```yaml -# ----------------------------------- models ------------------------------------ -models: - - name: albert-testbed - type: text-generation - # aliases: ["model-alias"] - # owned_by: Me - # load_balancing_strategy: shuffle - # cost_prompt_tokens: 0.10 - # cost_completion_tokens: 0.10 - providers: - - type: vllm - url: http://albert-testbed.etalab.gouv.fr:8000 - # key: sk-xxx - model_name: "gemma3:1b" - # timeout: 60 - # model_hosting_zone: FRA - # model_total_params: 8 - # model_active_params: 8 - -# -------------------------------- dependencies --------------------------------- -dependencies: - postgres: # required - url: postgresql+asyncpg://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-changeme}@${POSTGRES_HOST:-localhost}:${POSTGRES_PORT:-5432}/postgres - echo: False - pool_size: 5 - connect_args: - server_settings: - statement_timeout: "120s" - command_timeout: 60 - - redis: # required - url: redis://:${REDIS_PASSWORD:-changeme}@${REDIS_HOST:-localhost}:${REDIS_PORT:-6379} - max_connections: 200 - socket_connect_timeout: 5 - retry_on_timeout: True - health_check_interval: 30 - decode_responses: False - socket_keepalive: True - - elasticsearch: # optional - index_name: opengatellm - index_language: english - number_of_shards: 1 - index_name: "opengatellm" - number_of_replicas: 0 - hosts: "http://${ELASTICSEARCH_HOST:-localhost}:${ELASTICSEARCH_PORT:-9200}" - basic_auth: - - "elastic" - - ${ELASTICSEARCH_PASSWORD} - - # sentry: - # dsn: ${SENTRY_DSN} - -# ---------------------------------- settings ----------------------------------- -settings: - # disabled_routers: ["admin", "audio"] - # hidden_routers: ["auth"] - # usage_tokenizer: tiktoken_gpt2 - # app_title: My OpenGateLLM API - - # log_level: INFO - # log_format: [%(asctime)s][%(process)d:%(name)s][%(levelname)s] %(client_ip)s - %(message)s - - swagger_version: 0.4.2 - # swagger_contact_url: https://github.com/etalab-ia/OpenGateLLM - # swagger_contact_email: john.doe@example.com - # swagger_docs_url: /docs - # swagger_redoc_url: /redoc - - auth_master_key: changeme # DEPRECATED, use auth_secret_key instead - auth_secret_key: changeme - - auth_default_username: admin - auth_default_password: changeme - - # rate_limiting_strategy: fixed_window - - # monitoring_sentry_enabled: True - # monitoring_postgres_enabled: True - # monitoring_prometheus_enabled: True - - # vector_store_model: my-model - - # search_multi_agents_synthesis_model: my-model - # search_multi_agents_reranker_model: my-model - - playground_opengatellm_url: ${OPENGATELLM_URL} - # playground_default_model: my-model - # playground_theme_has_background: True - # playground_theme_accent_color: purple - # playground_theme_appearance: dark - # playground_theme_gray_color: gray - # playground_theme_panel_background: solid - # playground_theme_radius: medium - # playground_theme_scaling: 100% - -``` - -## API configuration -Configuration file is composed of 3 sections, models: -- `models`: to declare models API exposed to the API. -- `dependencies`: to declare both required plugins for the API (e.g. PostgreSQL, Redis) and optional ones (e.g. Elasticsearch). -- `settings`: to configure the API. - -:::warnings -We don't recommend to use the configuration file to declare models, prefer to use the API to declare models, by endpoints or on the Playground UI (see [Models configuration](/getting-started/models/)). -::: -

- -| Attribute | Type | Description | Default | Values | Examples | -| --- | --- | --- | --- | --- | --- | -| dependencies | object | Dependencies used by the API. For details of configuration, see the [Dependencies section](#dependencies). | **required** | | | -| models | array | Models used by the API. For details of configuration, see the [Model section](#model). | **required** | | | -| settings | object | For details of configuration, see the [Settings section](#settings). | **required** | | | - -

- -### Settings -General settings configuration fields. -

- -| Attribute | Type | Description | Default | Values | Examples | -| --- | --- | --- | --- | --- | --- | -| app_title | string | Display title of your API in swagger UI, see https://fastapi.tiangolo.com/tutorial/metadata for more information. | `OpenGateLLM` | | `My API` | -| auth_key_max_expiration_days | integer | Maximum number of days for a new API key to be valid. | `None` | | | -| auth_master_key | string | Master key for the API. It should be a random string with at least 32 characters. This key has all permissions and cannot be modified or deleted. This key is used to create the first role and the first user. This key is also used to encrypt user tokens, watch out if you modify the master key, you'll need to update all user API keys. | `changeme` | | | -| auth_playground_session_duration | integer | Duration of the playground postgres_session in seconds. | `3600` | | | -| disabled_routers | array | Disabled routers to limits services of the API. | `[]` | • `admin`

• `audio`

• `auth`

• `chat`

• `chunks`

• `collections`

• `documents`

• `embeddings`

• ... | `['embeddings']` | -| document_parsing_max_concurrent | integer | Maximum number of concurrent document parsing tasks per worker. | `10` | | | -| front_url | string | Front-end URL for the application. | `http://localhost:8501` | | | -| hidden_routers | array | Routers are enabled but hidden in the swagger and the documentation of the API. | `[]` | • `admin`

• `audio`

• `auth`

• `chat`

• `chunks`

• `collections`

• `documents`

• `embeddings`

• ... | `['admin']` | -| log_format | string | Logging format of the API. | `[%(asctime)s][%(process)d:%(name)s][%(levelname)s] %(client_ip)s - %(message)s` | | | -| log_level | string | Logging level of the API. | `INFO` | • `DEBUG`

• `INFO`

• `WARNING`

• `ERROR`

• `CRITICAL` | | -| monitoring_postgres_enabled | boolean | If true, the log usage will be written in the PostgreSQL database. | `True` | | | -| monitoring_prometheus_enabled | boolean | If true, Prometheus metrics will be exposed in the `/metrics` endpoint. | `True` | | | -| rate_limiting_strategy | string | Rate limiting strategy for the API. | `fixed_window` | • `moving_window`

• `fixed_window`

• `sliding_window` | | -| routing_max_priority | integer | Maximum allowed priority in routing tasks. | `4` | | | -| routing_max_retries | integer | Maximum number of retries for routing tasks. | `3` | | | -| routing_retry_countdown | integer | Number of seconds before retrying a failed routing task. | `3` | | | -| session_secret_key | string | Secret key for postgres_session middleware. If not provided, the master key will be used. | `None` | | `knBnU1foGtBEwnOGTOmszldbSwSYLTcE6bdibC8bPGM` | -| swagger_contact | object | Contact informations of the API in swagger UI, see https://fastapi.tiangolo.com/tutorial/metadata for more information. | `None` | | | -| swagger_description | string | Display description of your API in swagger UI, see https://fastapi.tiangolo.com/tutorial/metadata for more information. | `[See documentation](https://github.com/etalab-ia/opengatellm/blob/main/README.md)` | | `[See documentation](https://github.com/etalab-ia/opengatellm/blob/main/README.md)` | -| swagger_docs_url | string | Docs URL of swagger UI, see https://fastapi.tiangolo.com/tutorial/metadata for more information. | `/docs` | | | -| swagger_license_info | object | Licence informations of the API in swagger UI, see https://fastapi.tiangolo.com/tutorial/metadata for more information. | `{'name': 'MIT Licence', 'identifier': 'MIT', 'url': 'https://raw.githubusercontent.com/etalab-ia/opengatellm/refs/heads/main/LICENSE'}` | | | -| swagger_openapi_tags | array | OpenAPI tags of the API in swagger UI, see https://fastapi.tiangolo.com/tutorial/metadata for more information. | `[]` | | | -| swagger_openapi_url | string | OpenAPI URL of swagger UI, see https://fastapi.tiangolo.com/tutorial/metadata for more information. | `/openapi.json` | | | -| swagger_redoc_url | string | Redoc URL of swagger UI, see https://fastapi.tiangolo.com/tutorial/metadata for more information. | `/redoc` | | | -| swagger_summary | string | Display summary of your API in swagger UI, see https://fastapi.tiangolo.com/tutorial/metadata for more information. | `OpenGateLLM connect to your models. You can configuration this swagger UI in the configuration file, like hide routes or change the title.` | | `My API description.` | -| swagger_terms_of_service | string | A URL to the Terms of Service for the API in swagger UI. If provided, this has to be a URL. | `None` | | `https://example.com/terms-of-service` | -| swagger_version | string | Display version of your API in swagger UI, see https://fastapi.tiangolo.com/tutorial/metadata for more information. | `latest` | | `2.5.0` | -| usage_tokenizer | string | Tokenizer used to compute usage of the API. | `tiktoken_gpt2` | • `tiktoken_gpt2`

• `tiktoken_r50k_base`

• `tiktoken_p50k_base`

• `tiktoken_p50k_edit`

• `tiktoken_cl100k_base`

• `tiktoken_o200k_base` | | -| vector_store_model | string | Model used to vectorize the text in the vector store database. Is required if a vector store dependency is provided (Elasticsearch). This model must be defined in the `models` section and have type `text-embeddings-inference`. | `None` | | | - -

- -### Model -In the models section, you define a list of models. Each model is a set of API providers for that model. Users will access the models specified in -this section using their *name*. Load balancing is performed between the different providers of the requested model. All providers in a model must -serve the same type of model (text-generation or text-embeddings-inference, etc.). We recommend that all providers of a model serve exactly the same -model, otherwise users may receive responses of varying quality. For embedding models, the API verifies that all providers output vectors of the -same dimension. You can define the load balancing strategy between the model's providers. By default, it is random. - -For more information to configure model providers, see the [ModelProvider section](#modelprovider). -

- -| Attribute | Type | Description | Default | Values | Examples | -| --- | --- | --- | --- | --- | --- | -| aliases | array | Aliases of the model. It will be used to identify the model by users. | `[]` | | `['model-alias', 'model-alias-2']` | -| cost_completion_tokens | number | Model costs completion tokens for user budget computation. The cost is by 1M tokens. Set to `0.0` to disable budget computation for this model. | `0.0` | | `0.1` | -| cost_prompt_tokens | number | Model costs prompt tokens for user budget computation. The cost is by 1M tokens. | `0.0` | | `0.1` | -| load_balancing_strategy | string | Routing strategy for load balancing between providers of the model. | `shuffle` | • `shuffle`

• `least_busy` | `least_busy` | -| name | string | Unique name exposed to clients when selecting the model. | **required** | | `gpt-4o` | -| providers | array | API providers of the model. If there are multiple providers, the model will be load balanced between them according to the routing strategy. The different models have to the same type. For details of configuration, see the [ModelProvider section](#modelprovider). | **required** | | | -| type | string | Type of the model. It will be used to identify the model type. | **required** | • `automatic-speech-recognition`

• `image-text-to-text`

• `image-to-text`

• `text-embeddings-inference`

• `text-generation`

• `text-classification` | `text-generation` | - -

- -#### ModelProvider -| Attribute | Type | Description | Default | Values | Examples | -| --- | --- | --- | --- | --- | --- | -| key | string | Model provider API key. | `None` | | `sk-1234567890` | -| model_active_params | integer | Active params of the model in billions of parameters for carbon footprint computation. For more information, see https://ecologits.ai | `0` | | `8` | -| model_hosting_zone | string | Model hosting zone using ISO 3166-1 alpha-3 code format (e.g., `WOR` for World, `FRA` for France, `USA` for United States). This determines the electricity mix used for carbon intensity calculations. For more information, see https://ecologits.ai | `WOR` | • `ABW`

• `AFG`

• `AGO`

• `AIA`

• `ALA`

• `ALB`

• `AND`

• `ARE`

• ... | `WOR` | -| model_name | string | Model name from the model provider. | **required** | | `gpt-4o` | -| model_total_params | integer | Total params of the model in billions of parameters for carbon footprint computation. For more information, see https://ecologits.ai | `0` | | `8` | -| qos_limit | number | The value to use for the quality of service. Depends of the metric, the value can be a percentile, a threshold, etc. | `None` | | `0.5` | -| qos_metric | string | The metric to use for the quality of service. If not provided, no QoS policy is applied. | `None` | • `ttft`

• `latency`

• `inflight`

• `performance` | `inflight` | -| timeout | integer | Timeout for the model provider requests, after user receive an 500 error (model is too busy). | `300` | | `10` | -| type | string | Model provider type. | **required** | • `albert`

• `openai`

• `mistral`

• `tei`

• `vllm` | `openai` | -| url | string | Model provider API url. The url must only contain the domain name (without `/v1` suffix for example). Depends of the model provider type, the url can be optional (Albert, OpenAI). | `None` | | `https://api.openai.com` | - -

- -### Dependencies -| Attribute | Type | Description | Default | Values | Examples | -| --- | --- | --- | --- | --- | --- | -| albert | object | **[DEPRECATED]** See the [AlbertDependency section](#albertdependency) for more information. For details of configuration, see the [AlbertDependency section](#albertdependency). | `None` | | | -| celery | object | **[DEPRECATED]** See the [CeleryDependency section](#celerydependency) for more information. For details of configuration, see the [CeleryDependency section](#celerydependency). | `None` | | | -| elasticsearch | object | See the [ElasticsearchDependency section](#elasticsearchdependency) for more information. For details of configuration, see the [ElasticsearchDependency section](#elasticsearchdependency). | `None` | | | -| marker | object | **[DEPRECATED]** See the [MarkerDependency section](#markerdependency) for more information. For details of configuration, see the [MarkerDependency section](#markerdependency). | `None` | | | -| postgres | object | See the [PostgresDependency section](#postgresdependency) for more information. For details of configuration, see the [PostgresDependency section](#postgresdependency). | **required** | | | -| redis | object | See the [RedisDependency section](#redisdependency) for more information. For details of configuration, see the [RedisDependency section](#redisdependency). | **required** | | | -| sentry | object | See the [SentryDependency section](#sentrydependency) for more information. For details of configuration, see the [SentryDependency section](#sentrydependency). | `None` | | | - -

- -#### SentryDependency -Sentry is an optional dependency of OpenGateLLM. Sentry helps you identify, diagnose, and fix errors in real-time. -In this section, you can pass all sentry python SDK arguments, see https://docs.sentry.io/platforms/python/configuration/options/ for more information. -

- - -

- -#### RedisDependency -Redis is a required dependency of OpenGateLLM. Redis is used to store rate limiting counters and performance metrics. -Pass all `from_url()` method arguments of `redis.asyncio.connection.ConnectionPool` class, see https://redis.readthedocs.io/en/stable/connections.html#redis.asyncio.connection.ConnectionPool.from_url for more information. -

- -| Attribute | Type | Description | Default | Values | Examples | -| --- | --- | --- | --- | --- | --- | -| url | string | Redis connection url. | **required** | | `redis://:changeme@localhost:6379` | - -

- -#### PostgresDependency -Postgres is a required dependency of OpenGateLLM. In this section, you can pass all postgres python SDK arguments, see https://github.com/etalab-ia/opengatellm/blob/main/docs/dependencies/postgres.md for more information. -Only the `url` argument is required. The connection URL must use the asynchronous scheme, `postgresql+asyncpg://`. If you provide a standard `postgresql://` URL, it will be automatically converted to use asyncpg. -

- -| Attribute | Type | Description | Default | Values | Examples | -| --- | --- | --- | --- | --- | --- | -| url | string | PostgreSQL connection url. | **required** | | `postgresql+asyncpg://postgres:changeme@localhost:5432/postgres` | - -

- -#### MarkerDependency -**[DEPRECATED]** -

- -| Attribute | Type | Description | Default | Values | Examples | -| --- | --- | --- | --- | --- | --- | -| headers | object | Marker API request headers. | `{}` | | ``{'Authorization': 'Bearer my-api-key'}`` | -| timeout | integer | Timeout for the Marker API requests. | `300` | | `10` | -| url | string | Marker API url. | **required** | | | - -

- -#### ElasticsearchDependency -Elasticsearch is an optional dependency of OpenGateLLM. Elasticsearch is used as a vector store. If this dependency is provided, all documents endpoint are enabled. -Pass all arguments of `elasticsearch.Elasticsearch` class, see https://elasticsearch-py.readthedocs.io/en/latest/api/elasticsearch.html for more information. -Other arguments declared below are used to configure the Elasticsearch index. -

- -| Attribute | Type | Description | Default | Values | Examples | -| --- | --- | --- | --- | --- | --- | -| index_language | string | Language of the Elasticsearch index. | `english` | • `english`

• `french`

• `german`

• `italian`

• `portuguese`

• `spanish`

• `swedish` | `english` | -| index_name | string | Name of the Elasticsearch index. | `opengatellm` | | `my_index` | -| number_of_replicas | integer | Number of replicas for the Elasticsearch index. | `1` | | `1` | -| number_of_shards | integer | Number of shards for the Elasticsearch index. | `24` | | `1` | - -

- -#### CeleryDependency -**[DEPRECATED]** -

- -| Attribute | Type | Description | Default | Values | Examples | -| --- | --- | --- | --- | --- | --- | -| broker_url | string | Celery broker url like Redis (redis://) or RabbitMQ (amqp://). If not provided, use redis dependency as broker. | `None` | | | -| enable_utc | boolean | Enable UTC. | `True` | | `True` | -| result_backend | string | Celery result backend url. If not provided, use redis dependency as result backend. | `None` | | | -| timezone | string | Timezone. | `UTC` | | `UTC` | - -

- -#### AlbertDependency -**[DEPRECATED]** -

- -| Attribute | Type | Description | Default | Values | Examples | -| --- | --- | --- | --- | --- | --- | -| headers | object | Albert API request headers. | `{}` | | ``{'Authorization': 'Bearer my-api-key'}`` | -| timeout | integer | Timeout for the Albert API requests. | `300` | | `10` | -| url | string | Albert API url. | `https://albert.api.etalab.gouv.fr` | | | - -

- -## Playground configuration -The following parameters allow you to configure the Playground application. The configuration file can be shared with the API, as the sections are -identical and compatible. Some parameters are common to both the API and the Playground (for example, `app_title`). - -For Plagroud deployment, some environment variables are required to be set, like Reflex backend URL. See -[Environment variables](/configuration/environment_variable/#playground) for more information. -

- -| Attribute | Type | Description | Default | Values | Examples | -| --- | --- | --- | --- | --- | --- | -| dependencies | object | Dependencies used by the playground. For details of configuration, see the [Dependencies section](#dependencies). | **required** | | | -| settings | object | General settings configuration fields. Some fields are common to the API and the playground. For details of configuration, see the [Settings section](#settings). | **required** | | | - -

- -### Settings -| Attribute | Type | Description | Default | Values | Examples | -| --- | --- | --- | --- | --- | --- | -| app_title | string | The title of the application. | `OpenGateLLM` | | | -| auth_key_max_expiration_days | integer | Maximum number of days for a token to be valid. | `None` | | | -| documentation_url | string | Documentation URL. If not provided, deactivated documentation link in the navigation bar. | `https://docs.opengatellm.org` | | | -| playground_default_model | string | The first model selected in chat page. | `None` | | | -| playground_opengatellm_timeout | integer | The timeout in seconds for the OpenGateLLM API. | `60` | | | -| playground_opengatellm_url | string | The URL of the OpenGateLLM API. | `http://localhost:8000` | | | -| playground_theme_accent_color | string | The primary color used for default buttons, typography, backgrounds, etc. See available colors at https://www.radix-ui.com/colors. | `purple` | | | -| playground_theme_appearance | string | The appearance of the theme. | `light` | | | -| playground_theme_gray_color | string | The secondary color used for default buttons, typography, backgrounds, etc. See available colors at https://www.radix-ui.com/colors. | `gray` | | | -| playground_theme_has_background | boolean | Whether the theme has a background. | `True` | | | -| playground_theme_panel_background | string | Whether panel backgrounds are translucent: 'solid' \| 'translucent'. | `solid` | | | -| playground_theme_radius | string | The radius of the theme. Can be 'small', 'medium', or 'large'. | `medium` | | | -| playground_theme_scaling | string | The scaling of the theme. | `100%` | | | -| reference_url | string | Reference URL. If not provided, deactivated reference link in the navigation bar. | `http://localhost:8000/redoc` | | | -| routing_max_priority | integer | Maximum allowed priority in routing tasks. | `10` | | | -| swagger_url | string | Swagger URL. If not provided, deactivated swagger link in the navigation bar. | `http://localhost:8000/docs` | | | - -

- -### Dependencies -| Attribute | Type | Description | Default | Values | Examples | -| --- | --- | --- | --- | --- | --- | -| redis | object | Set the Redis connection url to use as stage manager. See https://reflex.dev/docs/api-reference/config/ for more information. For details of configuration, see the [RedisDependency section](#redisdependency). | `None` | | | - -

- -#### RedisDependency -| Attribute | Type | Description | Default | Values | Examples | -| --- | --- | --- | --- | --- | --- | -| url | string | Redis connection url. | **required** | | `redis://:changeme@localhost:6379` | - -

- diff --git a/docs/src/content/docs/configuration/configuration_file.mdx b/docs/src/content/docs/configuration/configuration_file.mdx new file mode 100644 index 000000000..baca95fb2 --- /dev/null +++ b/docs/src/content/docs/configuration/configuration_file.mdx @@ -0,0 +1,454 @@ +--- +title: Configuration file +sidebar: + label: "[lucide:file-text] Configuration file" + order: 0 +--- +import { Tabs, TabItem } from '@astrojs/starlight/components'; + +OpenGateLLM requires configuring a configuration file. This defines models, dependencies, and settings parameters. Playground and API need a configuration file (could be the same file), see [API configuration](#api-configuration) and [Playground configuration](#playground-configuration). + +By default, the configuration file must be `./config.yml` file. + +You can change the configuration file by setting the `CONFIG_FILE` environment variable. + +## Secrets + +You can pass environment variables in configuration file with pattern `${ENV_VARIABLE_NAME}`. All environment variables will be loaded in the configuration file. + +**Example** + +```yaml +models: + [...] + - name: my-language-model + type: text-generation + providers: + - type: openai + url: https://api.openai.com + key: ${OPENAI_API_KEY} + model_name: gpt-4o-mini +``` + +## Example + +The following is an example of configuration file: + +```yaml +# ----------------------------------- models ------------------------------------ +models: + - name: albert-testbed + type: text-generation + # aliases: ["model-alias"] + # owned_by: Me + # load_balancing_strategy: shuffle + # cost_prompt_tokens: 0.10 + # cost_completion_tokens: 0.10 + providers: + - type: vllm + url: http://albert-testbed.etalab.gouv.fr:8000 + # key: sk-xxx + model_name: "gemma3:1b" + # timeout: 60 + # model_hosting_zone: FRA + # model_total_params: 8 + # model_active_params: 8 + +# -------------------------------- dependencies --------------------------------- +dependencies: + postgres: # required + url: postgresql+asyncpg://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-changeme}@${POSTGRES_HOST:-localhost}:${POSTGRES_PORT:-5432}/postgres + echo: False + pool_size: 5 + connect_args: + server_settings: + statement_timeout: "120s" + command_timeout: 60 + + redis: # required + url: redis://:${REDIS_PASSWORD:-changeme}@${REDIS_HOST:-localhost}:${REDIS_PORT:-6379} + max_connections: 200 + socket_connect_timeout: 5 + retry_on_timeout: True + health_check_interval: 30 + decode_responses: False + socket_keepalive: True + + elasticsearch: # optional + index_name: opengatellm + index_language: english + number_of_shards: 1 + number_of_replicas: 0 + hosts: "http://${ELASTICSEARCH_HOST:-localhost}:${ELASTICSEARCH_PORT:-9200}" + basic_auth: + - "elastic" + - ${ELASTICSEARCH_PASSWORD} + + # sentry: + # dsn: ${SENTRY_DSN} + +# ---------------------------------- settings ----------------------------------- +settings: + # disabled_routers: ["admin", "audio"] + # hidden_routers: ["auth"] + # usage_tokenizer: tiktoken_gpt2 + # app_title: My OpenGateLLM API + + # log_level: INFO + # log_format: [%(asctime)s][%(process)d:%(name)s][%(levelname)s] %(client_ip)s - %(message)s + + swagger_version: 0.4.2 + # swagger_contact_url: https://github.com/etalab-ia/OpenGateLLM + # swagger_contact_email: john.doe@example.com + # swagger_docs_url: /docs + # swagger_redoc_url: /redoc + + auth_master_key: changeme # DEPRECATED, use auth_secret_key instead + auth_secret_key: changeme + + auth_default_username: admin + auth_default_password: changeme + + # rate_limiting_strategy: fixed_window + + # monitoring_sentry_enabled: True + # monitoring_postgres_enabled: True + # monitoring_prometheus_enabled: True + + # vector_store_model: my-model + + # search_multi_agents_synthesis_model: my-model + # search_multi_agents_reranker_model: my-model + + playground_opengatellm_url: ${OPENGATELLM_URL} + # playground_sso_enabled: False + # playground_sso_opengatellm_admin_api_key: ${SSO_OPENGATELLM_ADMIN_API_KEY} + # playground_sso_opengatellm_default_role_id: 1 + # playground_default_model: my-model + # playground_theme_has_background: True + # playground_theme_accent_color: purple + # playground_theme_appearance: dark + # playground_theme_gray_color: gray + # playground_theme_panel_background: solid + # playground_theme_radius: medium + # playground_theme_scaling: 100% + +``` + +## API configuration + +Configuration file is composed of 3 sections, models: +- `models`: to declare models API exposed to the API. +- `dependencies`: to declare both required plugins for the API (e.g. PostgreSQL, Redis) and optional ones (e.g. Elasticsearch). +- `settings`: to configure the API. + +:::warnings +We don't recommend to use the configuration file to declare models, prefer to use the API to declare models, by endpoints or on the Playground UI (see [Models configuration](/getting-started/models/)). +::: + +

+ +| Attribute | Type | Description | Default | Values | Examples | +|:-------------|:-------|:-----------------------------------------------------------------------------------------------------------|:-------------|:---------|:-----------| +| models | array | Models used by the API. For details of configuration, see the [Model section](#model). | **required** | | | +| dependencies | | Dependencies used by the API. For details of configuration, see the [Dependencies section](#dependencies). | **required** | | | +| settings | | For details of configuration, see the [Settings section](#settings). | **required** | | | + +

+ + + + +### Model + +In the models section, you define a list of models. Each model is a set of API providers for that model. Users will access the models specified in +this section using their *name*. Load balancing is performed between the different providers of the requested model. All providers in a model must +serve the same type of model (text-generation or text-embeddings-inference, etc.). We recommend that all providers of a model serve exactly the same +model, otherwise users may receive responses of varying quality. For embedding models, the API verifies that all providers output vectors of the +same dimension. You can define the load balancing strategy between the model's providers. By default, it is random. + +

+ +| Attribute | Type | Description | Default | Values | Examples | +|:------------------------|:-------|:------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:-------------|:--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:---------------------------------| +| name | string | Unique name exposed to clients when selecting the model. | **required** | | gpt-4o | +| type | string | Type of the model. It will be used to identify the model type. | **required** | • image-to-text

• automatic-speech-recognition

• text-embeddings-inference

• text-classification

• text-generation

• image-text-to-text | text-generation | +| aliases | array | Aliases of the model. It will be used to identify the model by users. | [] | | ['model-alias', 'model-alias-2'] | +| load_balancing_strategy | string | Routing strategy for load balancing between providers of the model. | shuffle | • least_busy

• shuffle | least_busy | +| cost_prompt_tokens | number | Model costs prompt tokens for user budget computation. The cost is by 1M tokens. | 0.0 | | 0.1 | +| cost_completion_tokens | number | Model costs completion tokens for user budget computation. The cost is by 1M tokens. Set to `0.0` to disable budget computation for this model. | 0.0 | | 0.1 | +| providers | array | API providers of the model. If there are multiple providers, the model will be load balanced between them according to the routing strategy. The different models have to the same type. For details of configuration, see the [ModelProvider section](#modelprovider). | **required** | | | + +

+ +#### ModelProvider + + + +

+ +| Attribute | Type | Description | Default | Values | Examples | +|:--------------------|:-------------|:-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:-------------|:----------------------------------------------------------------------------------------------------------------------|:-----------------------| +| type | string | Model provider type. | **required** | • openai

• tei

• mistral

• albert

• vllm | openai | +| url | null, string | Model provider API url. The url must only contain the domain name (without `/v1` suffix for example). Depends of the model provider type, the url can be optional (Albert, OpenAI). | None | | https://api.openai.com | +| key | null, string | Model provider API key. | None | | sk-1234567890 | +| timeout | integer | Timeout for the model provider requests, after user receive an 500 error (model is too busy). | 300 | | 10 | +| model_name | string | Model name from the model provider. | **required** | | gpt-4o | +| model_hosting_zone | string | Model hosting zone using ISO 3166-1 alpha-3 code format (e.g., `WOR` for World, `FRA` for France, `USA` for United States). This determines the electricity mix used for carbon intensity calculations. For more information, see https://ecologits.ai | WOR | • JEY

• UMI

• ZWE

• NAM

• PER

• PRI

• GRD

• AUT

• ... | WOR | +| model_total_params | integer | Total params of the model in billions of parameters for carbon footprint computation. For more information, see https://ecologits.ai | 0 | | 8 | +| model_active_params | integer | Active params of the model in billions of parameters for carbon footprint computation. For more information, see https://ecologits.ai | 0 | | 8 | +| qos_metric | null, string | The metric to use for the quality of service. If not provided, no QoS policy is applied. | None | • inflight

• ttft

• performance

• latency | inflight | +| qos_limit | null, number | The value to use for the quality of service. Depends of the metric, the value can be a percentile, a threshold, etc. | None | | 0.5 | + +

+ +
+ + +### Dependencies + + + +

+ +| Attribute | Type | Description | Default | Values | Examples | +|:--------------|:-------|:------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:-------------|:---------|:-----------| +| elasticsearch | null | Elasticsearch is an optional dependency of OpenGateLLM. Elasticsearch is used as a vector store. If this dependency is provided, all documents endpoint are enabled. For details of configuration, see the [ElasticsearchDependency section](#elasticsearchdependency). | None | | | +| postgres | | Postgres is a required dependency of OpenGateLLM to store API data. For details of configuration, see the [PostgresDependency section](#postgresdependency). | **required** | | | +| redis | | Redis is a required dependency of OpenGateLLM to store rate limiting counters and performance metrics. For details of configuration, see the [RedisDependency section](#redisdependency). | **required** | | | +| sentry | null | Sentry is an optional dependency of OpenGateLLM. Sentry helps you identify, diagnose, and fix errors in real-time. For details of configuration, see the [SentryDependency section](#sentrydependency). | None | | | + +

+ + + + +#### ElasticsearchDependency + +Elasticsearch is an optional dependency of OpenGateLLM. Elasticsearch is used as a vector store. If this dependency is provided, all documents endpoint are enabled. +Pass all arguments of `elasticsearch.Elasticsearch` class, see https://elasticsearch-py.readthedocs.io/en/latest/api/elasticsearch.html for more information. +Other arguments declared below are used to configure the Elasticsearch index. + +

+ +| Attribute | Type | Description | Default | Values | Examples | +|:-------------------|:--------|:-----------------------------------------------------------------------------------------------------------------------------------------------------------------------|:------------|:-----------------------------------------------------------------------------------------------------------------------|:-----------| +| index_name | string | Name of the Elasticsearch index. | opengatellm | | my_index | +| index_language | string | The language of the Elasticsearch index, composed by the value, the stopwords and the stemmer. | english | • spanish

• french

• german

• swedish

• english

• portuguese

• italian | english | +| | | For more information about stemmer, see https://www.elastic.co/docs/reference/text-analysis/analysis-stemmer-tokenfilter#analysis-stemmer-tokenfilter-configure-parms. | | | | +| number_of_shards | integer | Number of shards for the Elasticsearch index. | 24 | | 1 | +| number_of_replicas | integer | Number of replicas for the Elasticsearch index. | 1 | | 1 | + +

+ +
+ + +#### PostgresDependency + +Postgres is a required dependency of OpenGateLLM. In this section, you can pass all postgres python SDK arguments, see https://github.com/etalab-ia/opengatellm/blob/main/docs/dependencies/postgres.md for more information. +Only the `url` argument is required. The connection URL must use the asynchronous scheme, `postgresql+asyncpg://`. If you provide a standard `postgresql://` URL, it will be automatically converted to use asyncpg. + +

+ +| Attribute | Type | Description | Default | Values | Examples | +|:------------|:-------|:---------------------------|:-------------|:---------|:---------------------------------------------------------------| +| url | string | PostgreSQL connection url. | **required** | | postgresql+asyncpg://postgres:changeme@localhost:5432/postgres | + +

+ +
+ + +#### RedisDependency + +Redis is a required dependency of OpenGateLLM. Redis is used to store rate limiting counters and performance metrics. +Pass all `from_url()` method arguments of `redis.asyncio.connection.ConnectionPool` class, see https://redis.readthedocs.io/en/stable/connections.html#redis.asyncio.connection.ConnectionPool.from_url for more information. + +

+ +| Attribute | Type | Description | Default | Values | Examples | +|:------------|:-------|:----------------------|:-------------|:---------|:---------------------------------| +| url | string | Redis connection url. | **required** | | redis://:changeme@localhost:6379 | + +

+ +
+ + +#### SentryDependency + +Sentry is an optional dependency of OpenGateLLM. Sentry helps you identify, diagnose, and fix errors in real-time. +In this section, you can pass all sentry python SDK arguments, see https://docs.sentry.io/platforms/python/configuration/options/ for more information. + +

+ +**No settings.** + +

+ +
+
+ +
+ + +### Settings + +General settings configuration fields. + +

+ +| Attribute | Type | Description | Default | Values | Examples | +|:---------------------------------|:--------------|:----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:-------------------------------------------------------------------------------------------------------------------------------------------|:--------------------------------------------------------------------------------------------------------------------------------------------------------------------|:----------------------------------------------------------------------------------| +| disabled_routers | array | Disabled routers to limits services of the API. | [] | • auth

• collections

• chunks

• monitoring

• parse

• embeddings

• me

• audio

• ... | ['embeddings'] | +| hidden_routers | array | Routers are enabled but hidden in the swagger and the documentation of the API. | [] | • auth

• collections

• chunks

• monitoring

• parse

• embeddings

• me

• audio

• ... | ['admin'] | +| app_title | string | Display title of your API in swagger UI, see https://fastapi.tiangolo.com/tutorial/metadata for more information. | OpenGateLLM | | My API | +| routing_max_retries | integer | Maximum number of retries for routing tasks. | 3 | | | +| routing_retry_countdown | integer | Number of seconds before retrying a failed routing task. | 3 | | | +| routing_max_priority | integer | Maximum allowed priority in routing tasks. | 4 | | | +| usage_tokenizer | string | Tokenizer used to compute usage of the API. | tiktoken_gpt2 | • tiktoken_r50k_base

• tiktoken_gpt2

• tiktoken_cl100k_base

• tiktoken_p50k_base

• tiktoken_p50k_edit

• tiktoken_o200k_base | | +| log_level | string | Logging level of the API. | INFO | • ERROR

• DEBUG

• WARNING

• CRITICAL

• INFO | | +| log_format | string | Logging format of the API. | [%(asctime)s][%(process)d:%(name)s][%(levelname)s] %(client_ip)s - %(message)s | | | +| swagger_summary | string | Display summary of your API in swagger UI, see https://fastapi.tiangolo.com/tutorial/metadata for more information. | OpenGateLLM connect to your models. You can configuration this swagger UI in the configuration file, like hide routes or change the title. | | My API description. | +| swagger_version | string | Display version of your API in swagger UI, see https://fastapi.tiangolo.com/tutorial/metadata for more information. | latest | | 2.5.0 | +| swagger_description | string | Display description of your API in swagger UI, see https://fastapi.tiangolo.com/tutorial/metadata for more information. | [See documentation](https://github.com/etalab-ia/opengatellm/blob/main/README.md) | | [See documentation](https://github.com/etalab-ia/opengatellm/blob/main/README.md) | +| swagger_contact | null, object | Contact informations of the API in swagger UI, see https://fastapi.tiangolo.com/tutorial/metadata for more information. | None | | | +| swagger_license_info | object | Licence informations of the API in swagger UI, see https://fastapi.tiangolo.com/tutorial/metadata for more information. | \{'name': 'MIT Licence', 'identifier': 'MIT', 'url': 'https://raw.githubusercontent.com/etalab-ia/opengatellm/refs/heads/main/LICENSE'\} | | | +| swagger_terms_of_service | null, string | A URL to the Terms of Service for the API in swagger UI. If provided, this has to be a URL. | None | | https://example.com/terms-of-service | +| swagger_openapi_tags | array | OpenAPI tags of the API in swagger UI, see https://fastapi.tiangolo.com/tutorial/metadata for more information. | [] | | | +| swagger_openapi_url | string | OpenAPI URL of swagger UI, see https://fastapi.tiangolo.com/tutorial/metadata for more information. | /openapi.json | | | +| swagger_docs_url | string | Docs URL of swagger UI, see https://fastapi.tiangolo.com/tutorial/metadata for more information. | /docs | | | +| swagger_redoc_url | string | Redoc URL of swagger UI, see https://fastapi.tiangolo.com/tutorial/metadata for more information. | /redoc | | | +| auth_secret_key | null, string | Secret key for the API. It should be a random string with at least 32 characters. This key is used to encrypt user tokens, watch out if you modify the secret key, you'll need to update all user API keys. If not provided, the master key will be used. | None | | | +| auth_default_username | string | Username of the admin user created at startup. | admin | | | +| auth_default_password | string | Password of the admin user created at startup. | changeme | | | +| auth_key_max_expiration_days | null, integer | Maximum number of days for a new API key to be valid. | None | | | +| auth_playground_session_duration | integer | Duration of the playground postgres_session in seconds. | 3600 | | | +| rate_limiting_strategy | string | Rate limiting strategy for the API. | fixed_window | • fixed_window

• moving_window

• sliding_window | | +| monitoring_postgres_enabled | boolean | If true, the log usage will be written in the PostgreSQL database. | True | | | +| monitoring_prometheus_enabled | boolean | If true, Prometheus metrics will be exposed in the `/metrics` endpoint. | True | | | +| vector_store_model | null, string | Model used to vectorize the text in the vector store database. Is required if a vector store dependency is provided (Elasticsearch). This model must be defined in the `models` section and have type `text-embeddings-inference`. | None | | | +| document_parsing_max_concurrent | integer | Maximum number of concurrent document parsing tasks per worker. | 10 | | | +| front_url | string | Front-end URL for the application. | http://localhost:8501 | | | + +

+ +
+
+ +## Playground configuration + +The following parameters allow you to configure the Playground application. The configuration file can be shared with the API, as the sections are +identical and compatible. Some parameters are common to both the API and the Playground (for example, `app_title`). + +For Plagroud deployment, some environment variables are required to be set, like Reflex backend URL. See +[Environment variables](/configuration/environment_variable/#playground) for more information. + +

+ +| Attribute | Type | Description | Default | Values | Examples | +|:-------------|:-------|:-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:-------------|:---------|:-----------| +| dependencies | | Dependencies used by the playground. For details of configuration, see the [Dependencies section](#dependencies). | **required** | | | +| settings | | General settings configuration fields. Some fields are common to the API and the playground. For details of configuration, see the [SettingsWithoutSSO section](#settingswithoutsso). For details of configuration, see the [SettingsWithSSO section](#settingswithsso). | **required** | | | + +

+ + + + +### Dependencies + + + +

+ +| Attribute | Type | Description | Default | Values | Examples | +|:------------|:-------|:-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:----------|:---------|:-----------| +| redis | null | Set the Redis connection url to use as stage manager. See https://reflex.dev/docs/api-reference/config/ for more information. For details of configuration, see the [RedisDependency section](#redisdependency). | None | | | + +

+ +#### RedisDependency + + + +

+ +| Attribute | Type | Description | Default | Values | Examples | +|:------------|:-------|:----------------------|:-------------|:---------|:---------------------------------| +| url | string | Redis connection url. | **required** | | redis://:changeme@localhost:6379 | + +

+ +
+ + +### SettingsWithoutSSO + + + +

+ +| Attribute | Type | Description | Default | Values | Examples | +|:-------------------------------------------|:--------------|:-------------------------------------------------------------------------------------------------------------------------------------|:-----------------------------|:---------|:-----------| +| auth_key_max_expiration_days | null, integer | Maximum number of days for a token to be valid. | None | | | +| routing_max_priority | integer | Maximum allowed priority in routing tasks. | 10 | | | +| app_title | string | The title of the application. | OpenGateLLM | | | +| playground_opengatellm_url | string | The URL of the OpenGateLLM API. | http://localhost:8000 | | | +| playground_opengatellm_timeout | integer | The timeout in seconds for the OpenGateLLM API. | 60 | | | +| playground_default_model | null, string | The first model selected in chat page. | None | | | +| playground_theme_has_background | boolean | Whether the theme has a background. | True | | | +| playground_theme_accent_color | string | The primary color used for default buttons, typography, backgrounds, etc. See available colors at https://www.radix-ui.com/colors. | purple | | | +| playground_theme_appearance | string | The appearance of the theme. | light | | | +| playground_theme_gray_color | string | The secondary color used for default buttons, typography, backgrounds, etc. See available colors at https://www.radix-ui.com/colors. | gray | | | +| playground_theme_panel_background | string | Whether panel backgrounds are translucent: 'solid' \| 'translucent'. | solid | | | +| playground_theme_radius | string | The radius of the theme. Can be 'small', 'medium', or 'large'. | medium | | | +| playground_theme_scaling | string | The scaling of the theme. | 100% | | | +| swagger_url | null, string | Swagger URL. If not provided, deactivated swagger link in the navigation bar. | http://localhost:8000/docs | | | +| reference_url | null, string | Reference URL. If not provided, deactivated reference link in the navigation bar. | http://localhost:8000/redoc | | | +| documentation_url | null, string | Documentation URL. If not provided, deactivated documentation link in the navigation bar. | https://docs.opengatellm.org | | | +| playground_sso_enabled | boolean | Whether SSO is enabled. | False | | | +| playground_sso_opengatellm_admin_api_key | | To activate SSO, set OpenGateLLM API key with ADMIN permissions to create users and tokens. | None | | | +| playground_sso_opengatellm_default_role_id | | To activate SSO, set the default role ID of OpenGateLLM API for new users. | None | | | +| playground_sso_provider_logout_url | | The logout url for SSO. | None | | | + +

+ +
+ + +### SettingsWithSSO + + + +

+ +| Attribute | Type | Description | Default | Values | Examples | +|:-------------------------------------------|:--------------|:-------------------------------------------------------------------------------------------------------------------------------------|:-----------------------------|:---------|:-----------| +| auth_key_max_expiration_days | null, integer | Maximum number of days for a token to be valid. | None | | | +| routing_max_priority | integer | Maximum allowed priority in routing tasks. | 10 | | | +| app_title | string | The title of the application. | OpenGateLLM | | | +| playground_opengatellm_url | string | The URL of the OpenGateLLM API. | http://localhost:8000 | | | +| playground_opengatellm_timeout | integer | The timeout in seconds for the OpenGateLLM API. | 60 | | | +| playground_default_model | null, string | The first model selected in chat page. | None | | | +| playground_theme_has_background | boolean | Whether the theme has a background. | True | | | +| playground_theme_accent_color | string | The primary color used for default buttons, typography, backgrounds, etc. See available colors at https://www.radix-ui.com/colors. | purple | | | +| playground_theme_appearance | string | The appearance of the theme. | light | | | +| playground_theme_gray_color | string | The secondary color used for default buttons, typography, backgrounds, etc. See available colors at https://www.radix-ui.com/colors. | gray | | | +| playground_theme_panel_background | string | Whether panel backgrounds are translucent: 'solid' \| 'translucent'. | solid | | | +| playground_theme_radius | string | The radius of the theme. Can be 'small', 'medium', or 'large'. | medium | | | +| playground_theme_scaling | string | The scaling of the theme. | 100% | | | +| swagger_url | null, string | Swagger URL. If not provided, deactivated swagger link in the navigation bar. | http://localhost:8000/docs | | | +| reference_url | null, string | Reference URL. If not provided, deactivated reference link in the navigation bar. | http://localhost:8000/redoc | | | +| documentation_url | null, string | Documentation URL. If not provided, deactivated documentation link in the navigation bar. | https://docs.opengatellm.org | | | +| playground_sso_enabled | boolean | Whether SSO is enabled. | True | | | +| playground_sso_opengatellm_admin_api_key | string | To activate SSO, set OpenGateLLM API key with ADMIN permissions to create users and tokens. | **required** | | | +| playground_sso_opengatellm_default_role_id | integer | To activate SSO, set the default role ID of OpenGateLLM API for new users. | **required** | | | +| playground_sso_provider_logout_url | string | The logout url for SSO. | **required** | | | + +

+ +
+
+ diff --git a/scripts/docs/configuration_header.md b/scripts/docs/configuration_header.md index dc04a2d76..b5cdbdf24 100644 --- a/scripts/docs/configuration_header.md +++ b/scripts/docs/configuration_header.md @@ -4,7 +4,7 @@ sidebar: label: "[lucide:file-text] Configuration file" order: 0 --- -import { Tabs } from '@astrojs/starlight/components'; +import { Tabs, TabItem } from '@astrojs/starlight/components'; OpenGateLLM requires configuring a configuration file. This defines models, dependencies, and settings parameters. Playground and API need a configuration file (could be the same file), see [API configuration](#api-configuration) and [Playground configuration](#playground-configuration). diff --git a/scripts/docs/generate_configuration_documentation.py b/scripts/docs/generate_configuration_documentation.py index fa2c6e41d..a77ebfc40 100644 --- a/scripts/docs/generate_configuration_documentation.py +++ b/scripts/docs/generate_configuration_documentation.py @@ -2,6 +2,9 @@ import os import sys +from pydantic import BaseModel, Field +from tabulate import tabulate + PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) sys.path.insert(0, PROJECT_ROOT) sys.path.insert(0, os.path.join(PROJECT_ROOT, "playground")) @@ -11,142 +14,245 @@ from app.core.configuration import ConfigFile as PlaygroundConfigFile # noqa: E402 # type: ignore parser = argparse.ArgumentParser() -parser.add_argument("--output", type=str, default=os.path.join("./docs/src/content/docs/configuration/configuration_file.md")) - - -def convert_field_to_string_if_dict(field): - if isinstance(field, dict): - return "`" + str(field) + "`" - return field - - -def get_documentation_data(title: str, data: list, properties: dict, defs: dict, header: str = "", level: int = 1): - # attribute, type, description, required, default, values, examples - table = list() - for property in sorted(properties): - description = properties[property].get("description", "") - description = description.replace("|", "\\|") - - default = properties[property].get("default", properties[property].get("default", "**required**")) - default = f"`{default}`" if default != "**required**" else "**required**" - - examples = convert_field_to_string_if_dict(properties[property].get("examples", [""])[0]) - examples = f"`{examples}`" if examples != "" else "" - - if "anyOf" in properties[property]: - properties[property].update(properties[property]["anyOf"][0]) - - if "oneOf" in properties[property]: - for one_of in properties[property]["oneOf"]: - ref_key = one_of.get("$ref").split("/")[-1] - ref = defs[ref_key] - type = ref.get("type", "") - values = ref.get("enum", []) - - if "properties" in ref: - data = get_documentation_data( - title=ref_key, - data=data, - properties=ref["properties"], - defs=defs, - header=ref.get("description"), - level=level + 1, - ) - - if "$ref" in properties[property]: - ref_key = properties[property]["$ref"].split("/")[-1] - ref = defs[ref_key] - type = ref.get("type", "") - values = ref.get("enum", []) - - # neested object section, get the data from the nested section - if "properties" in ref: - data = get_documentation_data( - title=ref_key, - data=data, - properties=ref["properties"], - defs=defs, - header=ref.get("description"), - level=level + 1, - ) - description += f" For details of configuration, see the [{ref_key} section](#{ref_key.lower().replace(' ', '-')})." +parser.add_argument("--output", type=str, default=os.path.join("./docs/src/content/docs/configuration/configuration_file.mdx")) - else: - type = properties[property].get("type", "") - values = properties[property].get("enum", []) - if type == "array" and "$ref" in properties[property]["items"]: - ref_key = properties[property]["items"]["$ref"].split("/")[-1] - ref = defs[ref_key] +class Row(BaseModel): + attribute: str + types: list[str] + description: str + default: str + values: list + examples: list - # neested array section, get the data from the nested section - if "properties" in ref: - data = get_documentation_data( - title=ref_key, - data=data, - properties=ref["properties"], - defs=defs, - header=ref.get("description"), - level=level + 1, - ) - description += f" For details of configuration, see the [{ref_key} section](#{ref_key.lower().replace(' ', '-')})." - else: - values = ref.get("enum", []) - values = [f"`{value}`" for value in values] - table.append([property, type, description, default, values, examples]) +class Table(BaseModel): + title: str + description: str + rows: list[Row] + tables: list["Table"] = Field(default_factory=list) # recursive field - data.append({"title": title, "table": table, "level": level, "header": header}) - return data +def get_description(property: dict, ref_keys: list[str]): + description = property.get("description", "") + for ref_key in ref_keys: + description += f" For details of configuration, see the [{ref_key} section](#{ref_key.lower().replace(' ', '-')})." + return description -def get_example_configuration(config_example: str): - data = f""" -## Example -The following is an example of configuration file: +def get_default(property: dict): + default = property.get("default", "required") + default = str(default) + return default -```yaml -{config_example} -``` -""" +def get_attribute(property: dict): + return property.get("title", "") + + +def get_types(property: dict): + type = property.get("type") + types = [] if type is None else [type] + if "anyOf" in property: + for any_of in property["anyOf"]: + if "type" in any_of: + types.append(any_of.get("type")) + + return list(set(types)) + + +def get_values(property: dict): + values = property.get("enum", []) + if "anyOf" in property: + for any_of in property["anyOf"]: + if "enum" in any_of: + values.extend(any_of.get("enum", [])) + + elif property.get("type") == "array": + values.extend(property.get("items", {}).get("enum", [])) + + elif "oneOf" in property: + for one_of in property["oneOf"]: + values.extend(one_of.get("enum", [])) + + return list(set(values)) - return data +def get_examples(property: dict): + return property.get("examples", []) -def convert_to_markdown(data: list): - markdown = "" - for item in reversed(data): - markdown += f"{'#' * (item['level'] + 1)} {item['title']}\n" - if item["header"]: - markdown += f"{item['header']}\n

\n\n" - if len(item["table"]) > 0: - markdown += "| Attribute | Type | Description | Default | Values | Examples |\n" - markdown += "| --- | --- | --- | --- | --- | --- |\n" - for row in item["table"]: - if len(row[4]) > 10: - row[4] = "• " + "

• ".join(row[4][:8]) + "

• ..." - elif len(row[4]) > 0: - row[4] = "• " + "

• ".join(row[4]) +def replace_enum_ref_by_enum_schema_and_extract_ref_keys(property: dict, enum_schemas: dict) -> tuple[dict, list[str]]: + def _extract_key(ref: str) -> str: + return ref.split("/")[-1] + + ref_keys = [] + if "$ref" in property: + if property["$ref"] in enum_schemas: + property.update(enum_schemas[property["$ref"]]) + property.pop("$ref") + else: + ref_key = _extract_key(ref=property["$ref"]) + ref_keys.append(ref_key) + + elif property.get("type") == "array" and "$ref" in property["items"]: + if property["items"]["$ref"] in enum_schemas: + property["items"] = enum_schemas[property["items"]["$ref"]] + else: + ref_key = _extract_key(ref=property["items"]["$ref"]) + ref_keys.append(ref_key) + + elif "anyOf" in property: + for i, any_of in enumerate(property["anyOf"]): + if "$ref" in any_of: + if any_of["$ref"] in enum_schemas: + property["anyOf"][i] = enum_schemas[any_of["$ref"]] + else: + ref_key = _extract_key(ref=any_of["$ref"]) + ref_keys.append(ref_key) + + elif "oneOf" in property: + for i, one_of in enumerate(property["oneOf"]): + if "$ref" in one_of: + if one_of["$ref"] in enum_schemas: + property["oneOf"][i] = enum_schemas[one_of["$ref"]] else: - row[4] = "" + ref_key = _extract_key(ref=one_of["$ref"]) + ref_keys.append(ref_key) + + return property, ref_keys + + +def build_row(attribute: str, property: dict, ref_keys: list[str]): + description = get_description(property=property, ref_keys=ref_keys) + default = get_default(property=property) + types = get_types(property=property) + values = get_values(property=property) + examples = get_examples(property=property) + row = Row(attribute=attribute, types=types, description=description, default=default, values=values, examples=examples) + + return row + + +def parse_schema(table: Table, properties: dict, defs: dict, enum_schemas: dict): + for attribute, property in properties.items(): + if property.get("deprecated", False): + continue + + property, ref_keys = replace_enum_ref_by_enum_schema_and_extract_ref_keys(property=property, enum_schemas=enum_schemas) + row = build_row(attribute=attribute, property=property, ref_keys=ref_keys) + table.rows.append(row) + + for ref_key in ref_keys: + if "properties" in defs[ref_key]: + sub_table = Table(title=defs[ref_key].get("title", ""), description=get_description(defs[ref_key], ref_keys=[]), rows=[], tables=[]) + sub_table = parse_schema(table=sub_table, properties=defs[ref_key]["properties"], defs=defs, enum_schemas=enum_schemas) + table.tables.append(sub_table) + + return table + + +def handle_acorn(text: str) -> str: + text = text.replace("{", "\\{") + text = text.replace("}", "\\}") + return text + - markdown += "| " + " | ".join(str(cell) for cell in row) + " |\n" +def format_examples(examples: list) -> str: + if len(examples) > 0: + example = str(examples[0]) + example = handle_acorn(text=example) + return example + else: + return "" - elif item["header"] == "": - markdown += "No settings." - markdown += "\n

\n\n" +def format_description(description: str): + description = handle_acorn(text=description) + return description.replace("|", "\\|") + + +def format_default(default: str) -> str: + default = handle_acorn(text=default) + default = default if default != "required" else "**required**" + return default + + +def format_values(values: list) -> str: + values = [handle_acorn(text=value) for value in values] + if len(values) > 10: + return "• " + "

• ".join(values[:8]) + "

• ..." + elif len(values) > 0: + return "• " + "

• ".join(values) + else: + return "" + + +def format_types(types: list) -> str: + types = [handle_acorn(text=type) for type in types] + return ", ".join(types) + + +def format_row(row: Row): + row = row.model_dump() + row["description"] = format_description(row["description"]) + row["default"] = format_default(row["default"]) + row["values"] = format_values(row["values"]) + row["examples"] = format_examples(row["examples"]) + row["types"] = format_types(row["types"]) + row = [value for key, value in row.items()] + + return row + + +def convert_to_markdown(table: Table, markdown: str = "", level: int = 1): + breakline_small = "\n\n" + breakline_large = "\n\n

\n\n" + level += 1 + markdown += f"{'#' * level} {table.title}{breakline_small}" + markdown += f"{table.description}{breakline_large}" + + if len(table.rows) == 0: + markdown += f"**No settings.**{breakline_large}" + return markdown + + md_table = tabulate( + tabular_data=[format_row(row) for row in table.rows], + headers=["Attribute", "Type", "Description", "Default", "Values", "Examples"], + tablefmt="pipe", + ) + markdown += f"{md_table}{breakline_large}" + + if table.tables: + markdown += "\n" if len(table.tables) > 1 else "" + for sub_table in table.tables: + markdown += f'\n\n' if len(table.tables) > 1 else "" + markdown = convert_to_markdown(table=sub_table, markdown=markdown, level=level) + markdown += "\n" if len(table.tables) > 1 else "" + markdown += f"{breakline_small}" if len(table.tables) > 1 else "" + + return markdown + + +def get_example_configuration(config_example: str): + markdown = f""" +## Example + +The following is an example of configuration file: + +```yaml +{config_example} +``` + +""" return markdown if __name__ == "__main__": args = parser.parse_args() - assert args.output.endswith(".md"), f"Output file must end with .md ({args.output})" + assert args.output.endswith(".mdx"), f"Output file must end with .md ({args.output})" assert os.path.exists(os.path.dirname(args.output)), f"Output directory does not exist ({os.path.dirname(args.output)})" with open(file=os.path.join("./scripts/docs/configuration_header.md")) as f: @@ -157,27 +263,22 @@ def convert_to_markdown(data: list): with open(file=os.path.join("config.example.yml")) as f: config_example = f.read() f.close() + markdown += get_example_configuration(config_example=config_example) schema = ApiConfigFile.model_json_schema() - api_data = get_documentation_data( - title="API configuration", - data=[], - properties=schema["properties"], - header=schema.get("description", ""), - defs=schema["$defs"], - ) - markdown += convert_to_markdown(data=api_data) + table = Table(title="API configuration", description=schema.get("description", ""), rows=[], tables=[]) + enum_schemas = {f"#/$defs/{attribute}": schema["$defs"][attribute] for attribute in schema["$defs"] if "enum" in schema["$defs"][attribute]} + + table = parse_schema(table=table, properties=schema["properties"], defs=schema["$defs"], enum_schemas=enum_schemas) + markdown += convert_to_markdown(table=table) schema = PlaygroundConfigFile.model_json_schema() - playground_data = get_documentation_data( - title="Playground configuration", - data=[], - properties=schema["properties"], - header=schema.get("description", ""), - defs=schema["$defs"], - ) - markdown += convert_to_markdown(data=playground_data) + table = Table(title="Playground configuration", description=schema.get("description", ""), rows=[], tables=[]) + enum_schemas = {f"#/$defs/{attribute}": schema["$defs"][attribute] for attribute in schema["$defs"] if "enum" in schema["$defs"][attribute]} + + table = parse_schema(table=table, properties=schema["properties"], defs=schema["$defs"], enum_schemas=enum_schemas) + markdown += convert_to_markdown(table=table) with open(file=args.output, mode="w") as f: f.write(markdown)