diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index caf474c..e35a4a6 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -15,6 +15,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + lfs: true - uses: pnpm/action-setup@v4 with: version: 10 @@ -33,6 +35,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + lfs: true - uses: pnpm/action-setup@v4 with: version: 10 @@ -65,3 +69,22 @@ jobs: poetry run pyright cp ../sample.env ../../.env poetry run alembic revision + + publish-gh-pages: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + lfs: true + - uses: pnpm/action-setup@v4 + with: + version: 10 + - uses: actions/setup-node@v4 + with: + node-version: 23 + cache: 'pnpm' + cache-dependency-path: 'docs/pnpm-lock.yaml' + - name: Publish + run: .github/gh-pages-deploy.sh + env: + ACCESS_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/README.md b/README.md index 1818da2..601410f 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ To run Tero locally: 1. Generate an OpenAI API key or an Azure OpenAI endpoint and key. 2. Clone this repository. Make sure you have git-lfs installed to get all the files properly. -3. Copy `src/sample.env` to `.env` in the project root and set `OPENAI_KEY` or `AZURE_OPENAI_KEY` and `AZURE_OPENAI_ENDPOINT`. +3. Copy `src/sample.env` to `.env` in the project root and set `OPENAI_API_KEY` or `AZURE_API_KEYS` and `AZURE_ENDPOINTS`. 4. Start the app and dependencies with `docker compose up -d`. 5. Open `http://localhost:8000` and log in with username `test` and password `test`. diff --git a/devbox.json b/devbox.json index bf1e044..92a1cb3 100644 --- a/devbox.json +++ b/devbox.json @@ -9,10 +9,10 @@ }, // use this since 23.7.0 builds locally and takes a long time "nodejs": "23.6.1", - "pnpm": "latest" + "pnpm": "latest" }, "env": { - "VENV_DIR": "$DEVBOX_PROJECT_ROOT/src/backend/.venv", + "VENV_DIR": "$DEVBOX_PROJECT_ROOT/src/backend/.venv", "DEVBOX_PYPROJECT_DIR": "$DEVBOX_PROJECT_ROOT/src/backend" }, "shell": { @@ -70,10 +70,6 @@ "[ ! -e browser-copilot.zip ] || rm browser-copilot.zip", "zip -r browser-copilot.zip *" ], - "update-python": [ - "cd src/backend", - "poetry update" - ], "check": [ "cd src/backend", "poetry run pyright" @@ -91,5 +87,4 @@ ] } } -} - +} \ No newline at end of file diff --git a/docker/keycloak/realms.json b/docker/keycloak/realms.json index 3f08a80..bfd3bc8 100644 --- a/docker/keycloak/realms.json +++ b/docker/keycloak/realms.json @@ -1,1839 +1,2269 @@ { - "id" : "273de3f0-ff19-4ae0-ad49-71653b81f13c", - "realm" : "tero", - "notBefore" : 0, - "defaultSignatureAlgorithm" : "RS256", - "revokeRefreshToken" : false, - "refreshTokenMaxReuse" : 0, - "accessTokenLifespan" : 300, - "accessTokenLifespanForImplicitFlow" : 900, - "ssoSessionIdleTimeout" : 1800, - "ssoSessionMaxLifespan" : 36000, - "ssoSessionIdleTimeoutRememberMe" : 0, - "ssoSessionMaxLifespanRememberMe" : 0, - "offlineSessionIdleTimeout" : 2592000, - "offlineSessionMaxLifespanEnabled" : false, - "offlineSessionMaxLifespan" : 5184000, - "clientSessionIdleTimeout" : 0, - "clientSessionMaxLifespan" : 0, - "clientOfflineSessionIdleTimeout" : 0, - "clientOfflineSessionMaxLifespan" : 0, - "accessCodeLifespan" : 60, - "accessCodeLifespanUserAction" : 300, - "accessCodeLifespanLogin" : 1800, - "actionTokenGeneratedByAdminLifespan" : 43200, - "actionTokenGeneratedByUserLifespan" : 300, - "oauth2DeviceCodeLifespan" : 600, - "oauth2DevicePollingInterval" : 5, - "enabled" : true, - "sslRequired" : "none", - "registrationAllowed" : false, - "registrationEmailAsUsername" : false, - "rememberMe" : false, - "verifyEmail" : false, - "loginWithEmailAllowed" : true, - "duplicateEmailsAllowed" : false, - "resetPasswordAllowed" : false, - "editUsernameAllowed" : false, - "bruteForceProtected" : false, - "permanentLockout" : false, - "maxFailureWaitSeconds" : 900, - "minimumQuickLoginWaitSeconds" : 60, - "waitIncrementSeconds" : 60, - "quickLoginCheckMilliSeconds" : 1000, - "maxDeltaTimeSeconds" : 43200, - "failureFactor" : 30, - "roles" : { - "realm" : [ { - "id" : "0b558ede-220d-4509-975e-b2d8d0ea28df", - "name" : "default-roles-tero", - "description" : "${role_default-roles}", - "composite" : true, - "composites" : { - "realm" : [ "offline_access", "uma_authorization" ], - "client" : { - "account" : [ "view-profile", "manage-account" ] + "id": "273de3f0-ff19-4ae0-ad49-71653b81f13c", + "realm": "tero", + "notBefore": 0, + "defaultSignatureAlgorithm": "RS256", + "revokeRefreshToken": false, + "refreshTokenMaxReuse": 0, + "accessTokenLifespan": 300, + "accessTokenLifespanForImplicitFlow": 900, + "ssoSessionIdleTimeout": 1800, + "ssoSessionMaxLifespan": 36000, + "ssoSessionIdleTimeoutRememberMe": 0, + "ssoSessionMaxLifespanRememberMe": 0, + "offlineSessionIdleTimeout": 2592000, + "offlineSessionMaxLifespanEnabled": false, + "offlineSessionMaxLifespan": 5184000, + "clientSessionIdleTimeout": 0, + "clientSessionMaxLifespan": 0, + "clientOfflineSessionIdleTimeout": 0, + "clientOfflineSessionMaxLifespan": 0, + "accessCodeLifespan": 60, + "accessCodeLifespanUserAction": 300, + "accessCodeLifespanLogin": 1800, + "actionTokenGeneratedByAdminLifespan": 43200, + "actionTokenGeneratedByUserLifespan": 300, + "oauth2DeviceCodeLifespan": 600, + "oauth2DevicePollingInterval": 5, + "enabled": true, + "sslRequired": "none", + "registrationAllowed": false, + "registrationEmailAsUsername": false, + "rememberMe": false, + "verifyEmail": false, + "loginWithEmailAllowed": true, + "duplicateEmailsAllowed": false, + "resetPasswordAllowed": false, + "editUsernameAllowed": false, + "bruteForceProtected": false, + "permanentLockout": false, + "maxFailureWaitSeconds": 900, + "minimumQuickLoginWaitSeconds": 60, + "waitIncrementSeconds": 60, + "quickLoginCheckMilliSeconds": 1000, + "maxDeltaTimeSeconds": 43200, + "failureFactor": 30, + "roles": { + "realm": [ + { + "id": "0b558ede-220d-4509-975e-b2d8d0ea28df", + "name": "default-roles-tero", + "description": "${role_default-roles}", + "composite": true, + "composites": { + "realm": [ + "offline_access", + "uma_authorization" + ], + "client": { + "account": [ + "view-profile", + "manage-account" + ] + } + }, + "clientRole": false, + "containerId": "273de3f0-ff19-4ae0-ad49-71653b81f13c", + "attributes": {} + }, + { + "id": "d8a9c83b-43dd-4bb2-b8a5-994d4fa48a8b", + "name": "offline_access", + "description": "${role_offline-access}", + "composite": false, + "clientRole": false, + "containerId": "273de3f0-ff19-4ae0-ad49-71653b81f13c", + "attributes": {} + }, + { + "id": "0b1b69de-ae45-4758-979b-30a8ac81ec25", + "name": "uma_authorization", + "description": "${role_uma_authorization}", + "composite": false, + "clientRole": false, + "containerId": "273de3f0-ff19-4ae0-ad49-71653b81f13c", + "attributes": {} + } + ], + "client": { + "realm-management": [ + { + "id": "51bbef93-a705-4215-ba09-36d5b8269fad", + "name": "query-groups", + "description": "${role_query-groups}", + "composite": false, + "clientRole": true, + "containerId": "4980696d-df2e-4c6c-9e71-a309f3cea564", + "attributes": {} + }, + { + "id": "d272d9bc-574d-4b20-9bb5-2d5826b2e894", + "name": "view-users", + "description": "${role_view-users}", + "composite": true, + "composites": { + "client": { + "realm-management": [ + "query-groups", + "query-users" + ] + } + }, + "clientRole": true, + "containerId": "4980696d-df2e-4c6c-9e71-a309f3cea564", + "attributes": {} + }, + { + "id": "cc986d85-d45c-4942-ba15-e656db78c1fb", + "name": "manage-authorization", + "description": "${role_manage-authorization}", + "composite": false, + "clientRole": true, + "containerId": "4980696d-df2e-4c6c-9e71-a309f3cea564", + "attributes": {} + }, + { + "id": "bde5c9e4-25a9-4185-8aea-72d41ca39218", + "name": "manage-users", + "description": "${role_manage-users}", + "composite": false, + "clientRole": true, + "containerId": "4980696d-df2e-4c6c-9e71-a309f3cea564", + "attributes": {} + }, + { + "id": "ef3d0059-c869-48fa-99aa-f0f1a5c331a2", + "name": "query-realms", + "description": "${role_query-realms}", + "composite": false, + "clientRole": true, + "containerId": "4980696d-df2e-4c6c-9e71-a309f3cea564", + "attributes": {} + }, + { + "id": "83548f52-58b3-4f4c-8616-8daf484d4cf0", + "name": "view-identity-providers", + "description": "${role_view-identity-providers}", + "composite": false, + "clientRole": true, + "containerId": "4980696d-df2e-4c6c-9e71-a309f3cea564", + "attributes": {} + }, + { + "id": "ae79b6bc-2a5a-4ec6-952c-d342f81f1bc5", + "name": "view-clients", + "description": "${role_view-clients}", + "composite": true, + "composites": { + "client": { + "realm-management": [ + "query-clients" + ] + } + }, + "clientRole": true, + "containerId": "4980696d-df2e-4c6c-9e71-a309f3cea564", + "attributes": {} + }, + { + "id": "4723de70-68f8-46ba-b52a-496a16703f83", + "name": "manage-clients", + "description": "${role_manage-clients}", + "composite": false, + "clientRole": true, + "containerId": "4980696d-df2e-4c6c-9e71-a309f3cea564", + "attributes": {} + }, + { + "id": "63de3ec4-c2c2-4ee9-b8f4-49b6bbf96a02", + "name": "query-clients", + "description": "${role_query-clients}", + "composite": false, + "clientRole": true, + "containerId": "4980696d-df2e-4c6c-9e71-a309f3cea564", + "attributes": {} + }, + { + "id": "7f754c8b-2378-4e88-a828-d1cbd59ddb4f", + "name": "view-authorization", + "description": "${role_view-authorization}", + "composite": false, + "clientRole": true, + "containerId": "4980696d-df2e-4c6c-9e71-a309f3cea564", + "attributes": {} + }, + { + "id": "9948cd5c-54ad-4a5b-9007-596659a54181", + "name": "manage-realm", + "description": "${role_manage-realm}", + "composite": false, + "clientRole": true, + "containerId": "4980696d-df2e-4c6c-9e71-a309f3cea564", + "attributes": {} + }, + { + "id": "0baf79e2-660a-4214-b9b2-2ba958a82fd2", + "name": "view-realm", + "description": "${role_view-realm}", + "composite": false, + "clientRole": true, + "containerId": "4980696d-df2e-4c6c-9e71-a309f3cea564", + "attributes": {} + }, + { + "id": "1229931a-90cc-4f8b-9372-a90d4dec59cf", + "name": "create-client", + "description": "${role_create-client}", + "composite": false, + "clientRole": true, + "containerId": "4980696d-df2e-4c6c-9e71-a309f3cea564", + "attributes": {} + }, + { + "id": "fb587d77-1da1-428a-9fbe-e6034770103d", + "name": "manage-events", + "description": "${role_manage-events}", + "composite": false, + "clientRole": true, + "containerId": "4980696d-df2e-4c6c-9e71-a309f3cea564", + "attributes": {} + }, + { + "id": "6b16815d-0fb9-437d-98e5-8dc8338a3fbe", + "name": "impersonation", + "description": "${role_impersonation}", + "composite": false, + "clientRole": true, + "containerId": "4980696d-df2e-4c6c-9e71-a309f3cea564", + "attributes": {} + }, + { + "id": "e4352eb7-1321-4e6f-ad54-f394ce025cfe", + "name": "view-events", + "description": "${role_view-events}", + "composite": false, + "clientRole": true, + "containerId": "4980696d-df2e-4c6c-9e71-a309f3cea564", + "attributes": {} + }, + { + "id": "4e5465c5-652e-4dda-83c1-dcb324b8a30f", + "name": "query-users", + "description": "${role_query-users}", + "composite": false, + "clientRole": true, + "containerId": "4980696d-df2e-4c6c-9e71-a309f3cea564", + "attributes": {} + }, + { + "id": "9b30f86e-1b4d-4608-946b-25f13e44355c", + "name": "manage-identity-providers", + "description": "${role_manage-identity-providers}", + "composite": false, + "clientRole": true, + "containerId": "4980696d-df2e-4c6c-9e71-a309f3cea564", + "attributes": {} + }, + { + "id": "345b4a61-81be-413a-8053-53e418e092ae", + "name": "realm-admin", + "description": "${role_realm-admin}", + "composite": true, + "composites": { + "client": { + "realm-management": [ + "query-groups", + "view-users", + "manage-authorization", + "manage-users", + "query-realms", + "view-identity-providers", + "view-clients", + "manage-clients", + "query-clients", + "view-authorization", + "view-realm", + "manage-realm", + "create-client", + "manage-events", + "impersonation", + "view-events", + "query-users", + "manage-identity-providers" + ] + } + }, + "clientRole": true, + "containerId": "4980696d-df2e-4c6c-9e71-a309f3cea564", + "attributes": {} } + ], + "security-admin-console": [], + "admin-cli": [], + "account-console": [], + "broker": [ + { + "id": "34c754c7-d880-48cd-a523-cf59e58face1", + "name": "read-token", + "description": "${role_read-token}", + "composite": false, + "clientRole": true, + "containerId": "74578d5c-1e68-47fa-965a-dc3537dff931", + "attributes": {} + } + ], + "account": [ + { + "id": "08901577-1d99-47f3-b572-75a20eb5b44b", + "name": "view-applications", + "description": "${role_view-applications}", + "composite": false, + "clientRole": true, + "containerId": "b05630aa-4d97-4c33-b0d2-d3d22e1ed083", + "attributes": {} + }, + { + "id": "2aacc695-690b-4fd2-a1b6-2ea8d7d24f34", + "name": "view-consent", + "description": "${role_view-consent}", + "composite": false, + "clientRole": true, + "containerId": "b05630aa-4d97-4c33-b0d2-d3d22e1ed083", + "attributes": {} + }, + { + "id": "ca5ad6e9-b411-4814-ad85-2ced1ed90618", + "name": "view-profile", + "description": "${role_view-profile}", + "composite": false, + "clientRole": true, + "containerId": "b05630aa-4d97-4c33-b0d2-d3d22e1ed083", + "attributes": {} + }, + { + "id": "19e8fea2-b120-4ee4-99cb-696925438e60", + "name": "view-groups", + "description": "${role_view-groups}", + "composite": false, + "clientRole": true, + "containerId": "b05630aa-4d97-4c33-b0d2-d3d22e1ed083", + "attributes": {} + }, + { + "id": "25487335-625c-498a-890d-3cd6d6931883", + "name": "manage-account-links", + "description": "${role_manage-account-links}", + "composite": false, + "clientRole": true, + "containerId": "b05630aa-4d97-4c33-b0d2-d3d22e1ed083", + "attributes": {} + }, + { + "id": "3bde057e-e82e-46f6-ad74-47d1f0c65ceb", + "name": "manage-consent", + "description": "${role_manage-consent}", + "composite": true, + "composites": { + "client": { + "account": [ + "view-consent" + ] + } + }, + "clientRole": true, + "containerId": "b05630aa-4d97-4c33-b0d2-d3d22e1ed083", + "attributes": {} + }, + { + "id": "5c89f945-0ec7-4dbb-8c85-0221bf501a0f", + "name": "delete-account", + "description": "${role_delete-account}", + "composite": false, + "clientRole": true, + "containerId": "b05630aa-4d97-4c33-b0d2-d3d22e1ed083", + "attributes": {} + }, + { + "id": "186c5e07-3500-4f15-a7d4-a750c8bd3fc9", + "name": "manage-account", + "description": "${role_manage-account}", + "composite": true, + "composites": { + "client": { + "account": [ + "manage-account-links" + ] + } + }, + "clientRole": true, + "containerId": "b05630aa-4d97-4c33-b0d2-d3d22e1ed083", + "attributes": {} + } + ], + "tero": [] + } + }, + "groups": [], + "defaultRole": { + "id": "0b558ede-220d-4509-975e-b2d8d0ea28df", + "name": "default-roles-tero", + "description": "${role_default-roles}", + "composite": true, + "clientRole": false, + "containerId": "273de3f0-ff19-4ae0-ad49-71653b81f13c" + }, + "requiredCredentials": [ + "password" + ], + "otpPolicyType": "totp", + "otpPolicyAlgorithm": "HmacSHA1", + "otpPolicyInitialCounter": 0, + "otpPolicyDigits": 6, + "otpPolicyLookAheadWindow": 1, + "otpPolicyPeriod": 30, + "otpPolicyCodeReusable": false, + "otpSupportedApplications": [ + "totpAppFreeOTPName", + "totpAppGoogleName", + "totpAppMicrosoftAuthenticatorName" + ], + "localizationTexts": {}, + "webAuthnPolicyRpEntityName": "keycloak", + "webAuthnPolicySignatureAlgorithms": [ + "ES256" + ], + "webAuthnPolicyRpId": "", + "webAuthnPolicyAttestationConveyancePreference": "not specified", + "webAuthnPolicyAuthenticatorAttachment": "not specified", + "webAuthnPolicyRequireResidentKey": "not specified", + "webAuthnPolicyUserVerificationRequirement": "not specified", + "webAuthnPolicyCreateTimeout": 0, + "webAuthnPolicyAvoidSameAuthenticatorRegister": false, + "webAuthnPolicyAcceptableAaguids": [], + "webAuthnPolicyExtraOrigins": [], + "webAuthnPolicyPasswordlessRpEntityName": "keycloak", + "webAuthnPolicyPasswordlessSignatureAlgorithms": [ + "ES256" + ], + "webAuthnPolicyPasswordlessRpId": "", + "webAuthnPolicyPasswordlessAttestationConveyancePreference": "not specified", + "webAuthnPolicyPasswordlessAuthenticatorAttachment": "not specified", + "webAuthnPolicyPasswordlessRequireResidentKey": "not specified", + "webAuthnPolicyPasswordlessUserVerificationRequirement": "not specified", + "webAuthnPolicyPasswordlessCreateTimeout": 0, + "webAuthnPolicyPasswordlessAvoidSameAuthenticatorRegister": false, + "webAuthnPolicyPasswordlessAcceptableAaguids": [], + "webAuthnPolicyPasswordlessExtraOrigins": [], + "users": [ + { + "id": "a40b9050-134b-4db4-a0f3-e3e0a770ab4e", + "createdTimestamp": 1701877682925, + "username": "test", + "enabled": true, + "totp": false, + "emailVerified": false, + "firstName": "Test", + "lastName": "User", + "email": "test@test.com", + "credentials": [ + { + "id": "e4d20608-2b38-4b32-983e-542ef91e661a", + "type": "password", + "userLabel": "My password", + "createdDate": 1701877715786, + "secretData": "{\"value\":\"PJwOe6D+TRtsVsdHawaR7r4kmPaIaHD8vCrCVtA+c1A=\",\"salt\":\"bpCIY4koNg/qebE2QcYTcA==\",\"additionalParameters\":{}}", + "credentialData": "{\"hashIterations\":27500,\"algorithm\":\"pbkdf2-sha256\",\"additionalParameters\":{}}" + } + ], + "disableableCredentialTypes": [], + "requiredActions": [], + "realmRoles": [ + "default-roles-tero" + ], + "clientRoles": {}, + "notBefore": 0, + "groups": [] + } + ], + "scopeMappings": [ + { + "clientScope": "offline_access", + "roles": [ + "offline_access" + ] + } + ], + "clientScopeMappings": { + "account": [ + { + "client": "account-console", + "roles": [ + "manage-account", + "view-groups" + ] + } + ] + }, + "clients": [ + { + "id": "b05630aa-4d97-4c33-b0d2-d3d22e1ed083", + "clientId": "account", + "name": "${client_account}", + "rootUrl": "${authBaseUrl}", + "baseUrl": "/realms/tero/account/", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [ + "/realms/tero/account/*" + ], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "post.logout.redirect.uris": "+" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "acr", + "profile", + "roles", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "054743c4-26e8-4933-b1d1-78bc20609118", + "clientId": "account-console", + "name": "${client_account-console}", + "rootUrl": "${authBaseUrl}", + "baseUrl": "/realms/tero/account/", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [ + "/realms/tero/account/*" + ], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "post.logout.redirect.uris": "+", + "pkce.code.challenge.method": "S256" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "protocolMappers": [ + { + "id": "ac72faad-54e1-44f6-b1a3-6573706b373f", + "name": "audience resolve", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-resolve-mapper", + "consentRequired": false, + "config": {} + } + ], + "defaultClientScopes": [ + "web-origins", + "acr", + "profile", + "roles", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "5e18136c-8eb3-4faf-bfb2-dcfd0db4f30a", + "clientId": "admin-cli", + "name": "${client_admin-cli}", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": false, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "post.logout.redirect.uris": "+" }, - "clientRole" : false, - "containerId" : "273de3f0-ff19-4ae0-ad49-71653b81f13c", - "attributes" : { } - }, { - "id" : "d8a9c83b-43dd-4bb2-b8a5-994d4fa48a8b", - "name" : "offline_access", - "description" : "${role_offline-access}", - "composite" : false, - "clientRole" : false, - "containerId" : "273de3f0-ff19-4ae0-ad49-71653b81f13c", - "attributes" : { } - }, { - "id" : "0b1b69de-ae45-4758-979b-30a8ac81ec25", - "name" : "uma_authorization", - "description" : "${role_uma_authorization}", - "composite" : false, - "clientRole" : false, - "containerId" : "273de3f0-ff19-4ae0-ad49-71653b81f13c", - "attributes" : { } - } ], - "client" : { - "realm-management" : [ { - "id" : "51bbef93-a705-4215-ba09-36d5b8269fad", - "name" : "query-groups", - "description" : "${role_query-groups}", - "composite" : false, - "clientRole" : true, - "containerId" : "4980696d-df2e-4c6c-9e71-a309f3cea564", - "attributes" : { } - }, { - "id" : "d272d9bc-574d-4b20-9bb5-2d5826b2e894", - "name" : "view-users", - "description" : "${role_view-users}", - "composite" : true, - "composites" : { - "client" : { - "realm-management" : [ "query-groups", "query-users" ] + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "acr", + "profile", + "roles", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "74578d5c-1e68-47fa-965a-dc3537dff931", + "clientId": "broker", + "name": "${client_broker}", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": true, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "post.logout.redirect.uris": "+" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "acr", + "profile", + "roles", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "ac18b665-a3f8-4458-b907-b40177b51ad9", + "clientId": "tero", + "name": "", + "description": "", + "rootUrl": "", + "adminUrl": "", + "baseUrl": "", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [ + "http://localhost:5173/*", + "http://localhost:8000/*", + "https://fimbaimlephkekbpfdidmkenhjddimak.chromiumapp.org/*" + ], + "webOrigins": [ + "http://localhost:5173", + "http://localhost:8000", + "https://fimbaimlephkekbpfdidmkenhjddimak.chromiumapp.org/*" + ], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": true, + "protocol": "openid-connect", + "attributes": { + "oidc.ciba.grant.enabled": "false", + "backchannel.logout.session.required": "true", + "post.logout.redirect.uris": "+", + "oauth2.device.authorization.grant.enabled": "false", + "display.on.consent.screen": "false", + "backchannel.logout.revoke.offline.tokens": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": -1, + "defaultClientScopes": [ + "web-origins", + "acr", + "profile", + "roles", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "4980696d-df2e-4c6c-9e71-a309f3cea564", + "clientId": "realm-management", + "name": "${client_realm-management}", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": true, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "post.logout.redirect.uris": "+" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "acr", + "profile", + "roles", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "801a5fa8-6849-4740-a893-f23c48e6f01a", + "clientId": "security-admin-console", + "name": "${client_security-admin-console}", + "rootUrl": "${authAdminUrl}", + "baseUrl": "/admin/tero/console/", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [ + "/admin/tero/console/*" + ], + "webOrigins": [ + "+" + ], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "post.logout.redirect.uris": "+", + "pkce.code.challenge.method": "S256" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "protocolMappers": [ + { + "id": "b3d5afbe-b810-4c7f-82a7-605b2578b645", + "name": "locale", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "locale", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "locale", + "jsonType.label": "String" + } + } + ], + "defaultClientScopes": [ + "web-origins", + "acr", + "profile", + "roles", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + } + ], + "clientScopes": [ + { + "id": "d4420861-0c3d-4c74-8d81-476adbde7566", + "name": "email", + "description": "OpenID Connect built-in scope: email", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${emailScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "9162ec92-5572-4c1f-a270-f42faae83705", + "name": "email verified", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "emailVerified", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "email_verified", + "jsonType.label": "boolean" } }, - "clientRole" : true, - "containerId" : "4980696d-df2e-4c6c-9e71-a309f3cea564", - "attributes" : { } - }, { - "id" : "cc986d85-d45c-4942-ba15-e656db78c1fb", - "name" : "manage-authorization", - "description" : "${role_manage-authorization}", - "composite" : false, - "clientRole" : true, - "containerId" : "4980696d-df2e-4c6c-9e71-a309f3cea564", - "attributes" : { } - }, { - "id" : "bde5c9e4-25a9-4185-8aea-72d41ca39218", - "name" : "manage-users", - "description" : "${role_manage-users}", - "composite" : false, - "clientRole" : true, - "containerId" : "4980696d-df2e-4c6c-9e71-a309f3cea564", - "attributes" : { } - }, { - "id" : "ef3d0059-c869-48fa-99aa-f0f1a5c331a2", - "name" : "query-realms", - "description" : "${role_query-realms}", - "composite" : false, - "clientRole" : true, - "containerId" : "4980696d-df2e-4c6c-9e71-a309f3cea564", - "attributes" : { } - }, { - "id" : "83548f52-58b3-4f4c-8616-8daf484d4cf0", - "name" : "view-identity-providers", - "description" : "${role_view-identity-providers}", - "composite" : false, - "clientRole" : true, - "containerId" : "4980696d-df2e-4c6c-9e71-a309f3cea564", - "attributes" : { } - }, { - "id" : "ae79b6bc-2a5a-4ec6-952c-d342f81f1bc5", - "name" : "view-clients", - "description" : "${role_view-clients}", - "composite" : true, - "composites" : { - "client" : { - "realm-management" : [ "query-clients" ] + { + "id": "d3b8b2f6-f4bc-445e-a99a-c2f899ba53e2", + "name": "email", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "email", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "email", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "8f69cfca-c2f3-4ec9-87c5-7d27293bcd1f", + "name": "address", + "description": "OpenID Connect built-in scope: address", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${addressScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "6b3b8c68-0712-40f2-bc8b-f953a6b99c98", + "name": "address", + "protocol": "openid-connect", + "protocolMapper": "oidc-address-mapper", + "consentRequired": false, + "config": { + "user.attribute.formatted": "formatted", + "user.attribute.country": "country", + "introspection.token.claim": "true", + "user.attribute.postal_code": "postal_code", + "userinfo.token.claim": "true", + "user.attribute.street": "street", + "id.token.claim": "true", + "user.attribute.region": "region", + "access.token.claim": "true", + "user.attribute.locality": "locality" + } + } + ] + }, + { + "id": "dc3a2425-8ed8-4a42-aef8-1cdd9a0d4f98", + "name": "microprofile-jwt", + "description": "Microprofile - JWT built-in scope", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "id": "36f4c361-6e2b-478a-8b7e-b5b0020732d4", + "name": "groups", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "multivalued": "true", + "userinfo.token.claim": "true", + "user.attribute": "foo", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "groups", + "jsonType.label": "String" + } + }, + { + "id": "3f536e4f-77b8-42b8-972f-dafcb50db89a", + "name": "upn", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "username", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "upn", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "e6232474-489d-4b9f-8fc5-2049497517f0", + "name": "acr", + "description": "OpenID Connect scope for add acr (authentication context class reference) to the token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "id": "5755c449-8305-4d81-8aca-cb4affb9138b", + "name": "acr loa level", + "protocol": "openid-connect", + "protocolMapper": "oidc-acr-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "true", + "introspection.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + } + ] + }, + { + "id": "92a5e571-a097-4e41-a25a-ceaa3dafd3fa", + "name": "role_list", + "description": "SAML role list", + "protocol": "saml", + "attributes": { + "consent.screen.text": "${samlRoleListScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "b464f3db-0dff-4c1f-bbee-bac061447234", + "name": "role list", + "protocol": "saml", + "protocolMapper": "saml-role-list-mapper", + "consentRequired": false, + "config": { + "single": "false", + "attribute.nameformat": "Basic", + "attribute.name": "Role" + } + } + ] + }, + { + "id": "e42d7848-11e3-4d0b-87b1-d25c2e7a58f1", + "name": "phone", + "description": "OpenID Connect built-in scope: phone", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${phoneScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "f96cb6f1-e27f-416c-ba14-cdb2079898d4", + "name": "phone number", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "phoneNumber", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "phone_number", + "jsonType.label": "String" + } + }, + { + "id": "9313e616-a814-43d7-b6b5-769fbac8967a", + "name": "phone number verified", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "phoneNumberVerified", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "phone_number_verified", + "jsonType.label": "boolean" + } + } + ] + }, + { + "id": "60c455be-529d-4ff8-a61b-6fc2e0c99c37", + "name": "offline_access", + "description": "OpenID Connect built-in scope: offline_access", + "protocol": "openid-connect", + "attributes": { + "consent.screen.text": "${offlineAccessScopeConsentText}", + "display.on.consent.screen": "true" + } + }, + { + "id": "40756e65-4688-4ab1-9c82-3ca8cb3e759b", + "name": "profile", + "description": "OpenID Connect built-in scope: profile", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${profileScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "d925872c-1a89-4987-8e45-dc50b824d83d", + "name": "website", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "website", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "website", + "jsonType.label": "String" + } + }, + { + "id": "5a6e05d8-b9f2-4878-8720-18f3fe797d65", + "name": "gender", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "gender", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "gender", + "jsonType.label": "String" + } + }, + { + "id": "09a2dc82-c16e-4b17-b942-f949971de94a", + "name": "nickname", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "nickname", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "nickname", + "jsonType.label": "String" + } + }, + { + "id": "0f532612-53e0-4965-9c50-52e02682826a", + "name": "picture", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "picture", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "picture", + "jsonType.label": "String" + } + }, + { + "id": "f4207f0a-a9c7-48a0-b31b-69fd42706f93", + "name": "family name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "lastName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "family_name", + "jsonType.label": "String" + } + }, + { + "id": "0746f537-e18c-4e3a-b58e-0f82e337cae1", + "name": "username", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "username", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "preferred_username", + "jsonType.label": "String" + } + }, + { + "id": "1165b387-a12f-4a1c-b905-a698646d1d35", + "name": "full name", + "protocol": "openid-connect", + "protocolMapper": "oidc-full-name-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "true", + "introspection.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + }, + { + "id": "c6efbfe4-2e28-41f5-aaf3-67cc4f632ace", + "name": "updated at", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "updatedAt", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "updated_at", + "jsonType.label": "long" + } + }, + { + "id": "e9b2480b-0be7-49c8-af81-6504235083be", + "name": "profile", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "profile", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "profile", + "jsonType.label": "String" } }, - "clientRole" : true, - "containerId" : "4980696d-df2e-4c6c-9e71-a309f3cea564", - "attributes" : { } - }, { - "id" : "4723de70-68f8-46ba-b52a-496a16703f83", - "name" : "manage-clients", - "description" : "${role_manage-clients}", - "composite" : false, - "clientRole" : true, - "containerId" : "4980696d-df2e-4c6c-9e71-a309f3cea564", - "attributes" : { } - }, { - "id" : "63de3ec4-c2c2-4ee9-b8f4-49b6bbf96a02", - "name" : "query-clients", - "description" : "${role_query-clients}", - "composite" : false, - "clientRole" : true, - "containerId" : "4980696d-df2e-4c6c-9e71-a309f3cea564", - "attributes" : { } - }, { - "id" : "7f754c8b-2378-4e88-a828-d1cbd59ddb4f", - "name" : "view-authorization", - "description" : "${role_view-authorization}", - "composite" : false, - "clientRole" : true, - "containerId" : "4980696d-df2e-4c6c-9e71-a309f3cea564", - "attributes" : { } - }, { - "id" : "9948cd5c-54ad-4a5b-9007-596659a54181", - "name" : "manage-realm", - "description" : "${role_manage-realm}", - "composite" : false, - "clientRole" : true, - "containerId" : "4980696d-df2e-4c6c-9e71-a309f3cea564", - "attributes" : { } - }, { - "id" : "0baf79e2-660a-4214-b9b2-2ba958a82fd2", - "name" : "view-realm", - "description" : "${role_view-realm}", - "composite" : false, - "clientRole" : true, - "containerId" : "4980696d-df2e-4c6c-9e71-a309f3cea564", - "attributes" : { } - }, { - "id" : "1229931a-90cc-4f8b-9372-a90d4dec59cf", - "name" : "create-client", - "description" : "${role_create-client}", - "composite" : false, - "clientRole" : true, - "containerId" : "4980696d-df2e-4c6c-9e71-a309f3cea564", - "attributes" : { } - }, { - "id" : "fb587d77-1da1-428a-9fbe-e6034770103d", - "name" : "manage-events", - "description" : "${role_manage-events}", - "composite" : false, - "clientRole" : true, - "containerId" : "4980696d-df2e-4c6c-9e71-a309f3cea564", - "attributes" : { } - }, { - "id" : "6b16815d-0fb9-437d-98e5-8dc8338a3fbe", - "name" : "impersonation", - "description" : "${role_impersonation}", - "composite" : false, - "clientRole" : true, - "containerId" : "4980696d-df2e-4c6c-9e71-a309f3cea564", - "attributes" : { } - }, { - "id" : "e4352eb7-1321-4e6f-ad54-f394ce025cfe", - "name" : "view-events", - "description" : "${role_view-events}", - "composite" : false, - "clientRole" : true, - "containerId" : "4980696d-df2e-4c6c-9e71-a309f3cea564", - "attributes" : { } - }, { - "id" : "4e5465c5-652e-4dda-83c1-dcb324b8a30f", - "name" : "query-users", - "description" : "${role_query-users}", - "composite" : false, - "clientRole" : true, - "containerId" : "4980696d-df2e-4c6c-9e71-a309f3cea564", - "attributes" : { } - }, { - "id" : "9b30f86e-1b4d-4608-946b-25f13e44355c", - "name" : "manage-identity-providers", - "description" : "${role_manage-identity-providers}", - "composite" : false, - "clientRole" : true, - "containerId" : "4980696d-df2e-4c6c-9e71-a309f3cea564", - "attributes" : { } - }, { - "id" : "345b4a61-81be-413a-8053-53e418e092ae", - "name" : "realm-admin", - "description" : "${role_realm-admin}", - "composite" : true, - "composites" : { - "client" : { - "realm-management" : [ "query-groups", "view-users", "manage-authorization", "manage-users", "query-realms", "view-identity-providers", "view-clients", "manage-clients", "query-clients", "view-authorization", "view-realm", "manage-realm", "create-client", "manage-events", "impersonation", "view-events", "query-users", "manage-identity-providers" ] + { + "id": "e28afe43-a442-474a-a1ac-7c1d58c397bc", + "name": "zoneinfo", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "zoneinfo", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "zoneinfo", + "jsonType.label": "String" } }, - "clientRole" : true, - "containerId" : "4980696d-df2e-4c6c-9e71-a309f3cea564", - "attributes" : { } - } ], - "security-admin-console" : [ ], - "admin-cli" : [ ], - "account-console" : [ ], - "broker" : [ { - "id" : "34c754c7-d880-48cd-a523-cf59e58face1", - "name" : "read-token", - "description" : "${role_read-token}", - "composite" : false, - "clientRole" : true, - "containerId" : "74578d5c-1e68-47fa-965a-dc3537dff931", - "attributes" : { } - } ], - "account" : [ { - "id" : "08901577-1d99-47f3-b572-75a20eb5b44b", - "name" : "view-applications", - "description" : "${role_view-applications}", - "composite" : false, - "clientRole" : true, - "containerId" : "b05630aa-4d97-4c33-b0d2-d3d22e1ed083", - "attributes" : { } - }, { - "id" : "2aacc695-690b-4fd2-a1b6-2ea8d7d24f34", - "name" : "view-consent", - "description" : "${role_view-consent}", - "composite" : false, - "clientRole" : true, - "containerId" : "b05630aa-4d97-4c33-b0d2-d3d22e1ed083", - "attributes" : { } - }, { - "id" : "ca5ad6e9-b411-4814-ad85-2ced1ed90618", - "name" : "view-profile", - "description" : "${role_view-profile}", - "composite" : false, - "clientRole" : true, - "containerId" : "b05630aa-4d97-4c33-b0d2-d3d22e1ed083", - "attributes" : { } - }, { - "id" : "19e8fea2-b120-4ee4-99cb-696925438e60", - "name" : "view-groups", - "description" : "${role_view-groups}", - "composite" : false, - "clientRole" : true, - "containerId" : "b05630aa-4d97-4c33-b0d2-d3d22e1ed083", - "attributes" : { } - }, { - "id" : "25487335-625c-498a-890d-3cd6d6931883", - "name" : "manage-account-links", - "description" : "${role_manage-account-links}", - "composite" : false, - "clientRole" : true, - "containerId" : "b05630aa-4d97-4c33-b0d2-d3d22e1ed083", - "attributes" : { } - }, { - "id" : "3bde057e-e82e-46f6-ad74-47d1f0c65ceb", - "name" : "manage-consent", - "description" : "${role_manage-consent}", - "composite" : true, - "composites" : { - "client" : { - "account" : [ "view-consent" ] + { + "id": "c0f42261-8086-412e-9ac9-3b145a51855a", + "name": "locale", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "locale", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "locale", + "jsonType.label": "String" } }, - "clientRole" : true, - "containerId" : "b05630aa-4d97-4c33-b0d2-d3d22e1ed083", - "attributes" : { } - }, { - "id" : "5c89f945-0ec7-4dbb-8c85-0221bf501a0f", - "name" : "delete-account", - "description" : "${role_delete-account}", - "composite" : false, - "clientRole" : true, - "containerId" : "b05630aa-4d97-4c33-b0d2-d3d22e1ed083", - "attributes" : { } - }, { - "id" : "186c5e07-3500-4f15-a7d4-a750c8bd3fc9", - "name" : "manage-account", - "description" : "${role_manage-account}", - "composite" : true, - "composites" : { - "client" : { - "account" : [ "manage-account-links" ] + { + "id": "2854a086-c04a-467f-866e-c55b6b0e2ce7", + "name": "middle name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "middleName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "middle_name", + "jsonType.label": "String" } }, - "clientRole" : true, - "containerId" : "b05630aa-4d97-4c33-b0d2-d3d22e1ed083", - "attributes" : { } - } ], - "tero" : [ ] + { + "id": "dc5aeba1-79df-4da4-9e5f-0e577523f2e6", + "name": "birthdate", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "birthdate", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "birthdate", + "jsonType.label": "String" + } + }, + { + "id": "66dad60b-d9ca-4a69-88f4-042d3e9a454e", + "name": "given name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "firstName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "given_name", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "3d5bdcc9-b70d-4bb9-b184-ea783b7c5f05", + "name": "roles", + "description": "OpenID Connect scope for add user roles to the access token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "true", + "consent.screen.text": "${rolesScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "dfb405f8-2c2c-4d63-9773-f933da3ce470", + "name": "audience resolve", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-resolve-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "access.token.claim": "true" + } + }, + { + "id": "75ecc5ef-fc2e-425f-b87d-5c1b7ba6b28f", + "name": "realm roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "multivalued": "true", + "user.attribute": "foo", + "access.token.claim": "true", + "claim.name": "realm_access.roles", + "jsonType.label": "String" + } + }, + { + "id": "1b1c9a36-b55c-4bf2-86de-df15457ae158", + "name": "client roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-client-role-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "multivalued": "true", + "user.attribute": "foo", + "access.token.claim": "true", + "claim.name": "resource_access.${client_id}.roles", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "b25051f0-a638-4aa2-b7a6-55d460a4d140", + "name": "web-origins", + "description": "OpenID Connect scope for add allowed web origins to the access token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "false", + "consent.screen.text": "" + }, + "protocolMappers": [ + { + "id": "66cc777a-d243-4e25-93bd-abdc3535590f", + "name": "allowed web origins", + "protocol": "openid-connect", + "protocolMapper": "oidc-allowed-origins-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "access.token.claim": "true" + } + } + ] } + ], + "defaultDefaultClientScopes": [ + "role_list", + "profile", + "email", + "roles", + "web-origins", + "acr" + ], + "defaultOptionalClientScopes": [ + "offline_access", + "address", + "phone", + "microprofile-jwt" + ], + "browserSecurityHeaders": { + "contentSecurityPolicyReportOnly": "", + "xContentTypeOptions": "nosniff", + "referrerPolicy": "no-referrer", + "xRobotsTag": "none", + "xFrameOptions": "SAMEORIGIN", + "contentSecurityPolicy": "frame-src 'self'; frame-ancestors 'self'; object-src 'none';", + "xXSSProtection": "1; mode=block", + "strictTransportSecurity": "max-age=31536000; includeSubDomains" }, - "groups" : [ ], - "defaultRole" : { - "id" : "0b558ede-220d-4509-975e-b2d8d0ea28df", - "name" : "default-roles-tero", - "description" : "${role_default-roles}", - "composite" : true, - "clientRole" : false, - "containerId" : "273de3f0-ff19-4ae0-ad49-71653b81f13c" - }, - "requiredCredentials" : [ "password" ], - "otpPolicyType" : "totp", - "otpPolicyAlgorithm" : "HmacSHA1", - "otpPolicyInitialCounter" : 0, - "otpPolicyDigits" : 6, - "otpPolicyLookAheadWindow" : 1, - "otpPolicyPeriod" : 30, - "otpPolicyCodeReusable" : false, - "otpSupportedApplications" : [ "totpAppFreeOTPName", "totpAppGoogleName", "totpAppMicrosoftAuthenticatorName" ], - "localizationTexts" : { }, - "webAuthnPolicyRpEntityName" : "keycloak", - "webAuthnPolicySignatureAlgorithms" : [ "ES256" ], - "webAuthnPolicyRpId" : "", - "webAuthnPolicyAttestationConveyancePreference" : "not specified", - "webAuthnPolicyAuthenticatorAttachment" : "not specified", - "webAuthnPolicyRequireResidentKey" : "not specified", - "webAuthnPolicyUserVerificationRequirement" : "not specified", - "webAuthnPolicyCreateTimeout" : 0, - "webAuthnPolicyAvoidSameAuthenticatorRegister" : false, - "webAuthnPolicyAcceptableAaguids" : [ ], - "webAuthnPolicyExtraOrigins" : [ ], - "webAuthnPolicyPasswordlessRpEntityName" : "keycloak", - "webAuthnPolicyPasswordlessSignatureAlgorithms" : [ "ES256" ], - "webAuthnPolicyPasswordlessRpId" : "", - "webAuthnPolicyPasswordlessAttestationConveyancePreference" : "not specified", - "webAuthnPolicyPasswordlessAuthenticatorAttachment" : "not specified", - "webAuthnPolicyPasswordlessRequireResidentKey" : "not specified", - "webAuthnPolicyPasswordlessUserVerificationRequirement" : "not specified", - "webAuthnPolicyPasswordlessCreateTimeout" : 0, - "webAuthnPolicyPasswordlessAvoidSameAuthenticatorRegister" : false, - "webAuthnPolicyPasswordlessAcceptableAaguids" : [ ], - "webAuthnPolicyPasswordlessExtraOrigins" : [ ], - "users" : [ { - "id" : "a40b9050-134b-4db4-a0f3-e3e0a770ab4e", - "createdTimestamp" : 1701877682925, - "username" : "test", - "enabled" : true, - "totp" : false, - "emailVerified" : false, - "firstName" : "Test", - "lastName" : "User", - "email" : "test@test.com", - "credentials" : [ { - "id" : "e4d20608-2b38-4b32-983e-542ef91e661a", - "type" : "password", - "userLabel" : "My password", - "createdDate" : 1701877715786, - "secretData" : "{\"value\":\"PJwOe6D+TRtsVsdHawaR7r4kmPaIaHD8vCrCVtA+c1A=\",\"salt\":\"bpCIY4koNg/qebE2QcYTcA==\",\"additionalParameters\":{}}", - "credentialData" : "{\"hashIterations\":27500,\"algorithm\":\"pbkdf2-sha256\",\"additionalParameters\":{}}" - } ], - "disableableCredentialTypes" : [ ], - "requiredActions" : [ ], - "realmRoles" : [ "default-roles-tero" ], - "clientRoles" : {}, - "notBefore" : 0, - "groups" : [ ] - } ], - "scopeMappings" : [ { - "clientScope" : "offline_access", - "roles" : [ "offline_access" ] - } ], - "clientScopeMappings" : { - "account" : [ { - "client" : "account-console", - "roles" : [ "manage-account", "view-groups" ] - } ] + "smtpServer": {}, + "eventsEnabled": false, + "eventsListeners": [ + "jboss-logging" + ], + "enabledEventTypes": [], + "adminEventsEnabled": false, + "adminEventsDetailsEnabled": false, + "identityProviders": [], + "identityProviderMappers": [], + "components": { + "org.keycloak.services.clientregistration.policy.ClientRegistrationPolicy": [ + { + "id": "87a5362a-49cc-41f9-95c9-4d8218929970", + "name": "Full Scope Disabled", + "providerId": "scope", + "subType": "anonymous", + "subComponents": {}, + "config": {} + }, + { + "id": "bfd01fa9-4e3b-46d8-a600-68014ee5fe42", + "name": "Max Clients Limit", + "providerId": "max-clients", + "subType": "anonymous", + "subComponents": {}, + "config": { + "max-clients": [ + "200" + ] + } + }, + { + "id": "b38fb902-20be-4274-9106-73cabb6d8872", + "name": "Allowed Client Scopes", + "providerId": "allowed-client-templates", + "subType": "anonymous", + "subComponents": {}, + "config": { + "allow-default-scopes": [ + "true" + ] + } + }, + { + "id": "df7d9df2-a766-4ccf-9446-23a959d895b3", + "name": "Allowed Protocol Mapper Types", + "providerId": "allowed-protocol-mappers", + "subType": "anonymous", + "subComponents": {}, + "config": { + "allowed-protocol-mapper-types": [ + "oidc-usermodel-attribute-mapper", + "saml-role-list-mapper", + "oidc-address-mapper", + "oidc-sha256-pairwise-sub-mapper", + "saml-user-attribute-mapper", + "oidc-usermodel-property-mapper", + "saml-user-property-mapper", + "oidc-full-name-mapper" + ] + } + }, + { + "id": "27cb51a1-dc60-4235-9925-05e8e91871b4", + "name": "Allowed Client Scopes", + "providerId": "allowed-client-templates", + "subType": "authenticated", + "subComponents": {}, + "config": { + "allow-default-scopes": [ + "true" + ] + } + }, + { + "id": "7f564db1-7ffd-44da-8d40-d452818740a4", + "name": "Allowed Protocol Mapper Types", + "providerId": "allowed-protocol-mappers", + "subType": "authenticated", + "subComponents": {}, + "config": { + "allowed-protocol-mapper-types": [ + "oidc-sha256-pairwise-sub-mapper", + "saml-user-property-mapper", + "oidc-address-mapper", + "oidc-usermodel-property-mapper", + "saml-user-attribute-mapper", + "oidc-usermodel-attribute-mapper", + "saml-role-list-mapper", + "oidc-full-name-mapper" + ] + } + }, + { + "id": "e9d329c3-1151-427e-bcf8-2132fc0ccf86", + "name": "Trusted Hosts", + "providerId": "trusted-hosts", + "subType": "anonymous", + "subComponents": {}, + "config": { + "host-sending-registration-request-must-match": [ + "false" + ], + "client-uris-must-match": [ + "true" + ], + "trusted-hosts": [ + "anysphere.cursor-retrieval", + "code.visualstudio.com", + "*.vscode.dev", + "localhost", + "127.0.0.1" + ] + } + }, + { + "id": "cc5b7e36-ed37-49b2-9032-a1a7499ed396", + "name": "Consent Required", + "providerId": "consent-required", + "subType": "anonymous", + "subComponents": {}, + "config": {} + } + ], + "org.keycloak.keys.KeyProvider": [ + { + "id": "402895d2-005b-4213-9267-59e8fad501a1", + "name": "rsa-enc-generated", + "providerId": "rsa-enc-generated", + "subComponents": {}, + "config": { + "privateKey": [ + "MIIEpAIBAAKCAQEA0tEISDGIuxkxU+zRTWUblYIBNYGuIeQvKXSRYECn6ME2j3KlcfjOLhz+USom1Jo8LnjyYkIs8ZcNjDj/vhw3NXrvyYFYF+9NAH1pa9hZfQYrO9JBYqC5t4XhWiyLfs0fev3Tf+gY4mpAVm1KdW+q4i7wroeX5dt/BBbjxTmEaBAn2GJMXx1ruJnlAMQHWQC041n3GN1ysNIc2A7XTpmm+wxcm03YWVe36z2cz4swHp1o9HXh//sEue8dxUuzQWd+s638PEXa1+uR/KHlmXrBB+fC8vOw/dRoBfUVMuYetWTlz3DwdDLGvDBAXuZCkZYCJamELIZSamczXkBKDTlAcQIDAQABAoIBAAECO4rUOS4uAMNWSips41d74QNvcf9wWafA3/OI3lTPMd04rvZ55TX+xlpp5s1/HxkY0+No+NE/crLlpexJXcaYJpQSq0goCWNedkCWxIIu40o8vLqFqtsoMpZtc/hrb60KuwuXtyDENzfCpkfOvOT//6pSH0WxXxehqr2A7jOCQrgcUHfCfFl8q60IJtfrhERhPxs3ItvJSAAJGm00qPS4whKzODcmIUq92IHh9HSDGkgs/K5vFztz6rswuwi3wfHENCSbyu0j57vKOtDu9AhW3y6gwXD8LwF5EPkRc44VWqZnALLOL54oyWi3R3husiM+k4+FuEXu9GLXo6kTWX0CgYEA+JBkv512gGhUdcKj+3NM7NugdABm2eIBrdQiFFp+hHiqwDMwg9Oc7iTmc/Ia+30uigtLKv90doOePj5Hy1zeYLUZBwUwVCTM4vKerZlJsthTJbjAKwJwaZWTkfC8eW3eOwmHBU1u8M3u7IeAozOxPnQckgxAS3Y4GIAqXuJguR0CgYEA2R+Ngc+i4lsSWLH8gAkOcyTmFWSYjU5TbmqikiCYc6FuxbxNSvglx+gbf1mOrgZGj37jIIOrxM4szXrNBp3it6gP09RJrWVcsd+F9JXGjAIT80la20zbiBLZBOIqObG6c7sbs+5TYBdseKTVG+UAzc+U3EaMz2I1PS9LLzoTmGUCgYB15Kaka58FEHbe087LOMjHnvPfkUE3HocFV5RCaxmO41y5hI4COKA6I65aV/6MQbeNKgYhAsDOZWbsxsVuo0GmRL72IXPmtP2otsKkPAxEk238ekBLJgEDUzqHAdOjFIVPIxmzXiK8fDBSZ4KP5bivkorqin0ETbIVjNSL5HtT0QKBgQCg3z4LxnqbWHsZeJbrjspECjzn8OcPG8+5ag0WVExgsGXQ4JosR/xGR/XHv+V1j3TMcWl799NXOKP9g1VR573J8h34B7ynWwj5SfKIrEi2B/wcMGe/QQ0Pn1doxOIgaU0K3sHB6X2hHvnh0c+MoXqdA4b6RtOh/NQRh28fiNpn+QKBgQDKJBZlTqMR1SqXX9mnX1dL3hAQVEfRHmdVAUD3ZVN0tTjaWiaOCFD/fNdALgPQhPpKQaiS8s+21Og5K+lF+LM6VO0msbFzmk9JRiw/mhSMKO0n/ZjkQE602tghr9XTyHsfIq++xLAC8LNjIWO9Z4uH9QzYhxbPBN1RSAQN1qx2IQ==" + ], + "keyUse": [ + "ENC" + ], + "certificate": [ + "MIICrTCCAZUCBgGMP8/wRTANBgkqhkiG9w0BAQsFADAaMRgwFgYDVQQDDA9icm93c2VyLWNvcGlsb3QwHhcNMjMxMjA2MTU0NTQxWhcNMzMxMjA2MTU0NzIxWjAaMRgwFgYDVQQDDA9icm93c2VyLWNvcGlsb3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDS0QhIMYi7GTFT7NFNZRuVggE1ga4h5C8pdJFgQKfowTaPcqVx+M4uHP5RKibUmjwuePJiQizxlw2MOP++HDc1eu/JgVgX700AfWlr2Fl9Bis70kFioLm3heFaLIt+zR96/dN/6BjiakBWbUp1b6riLvCuh5fl238EFuPFOYRoECfYYkxfHWu4meUAxAdZALTjWfcY3XKw0hzYDtdOmab7DFybTdhZV7frPZzPizAenWj0deH/+wS57x3FS7NBZ36zrfw8RdrX65H8oeWZesEH58Ly87D91GgF9RUy5h61ZOXPcPB0Msa8MEBe5kKRlgIlqYQshlJqZzNeQEoNOUBxAgMBAAEwDQYJKoZIhvcNAQELBQADggEBAHQ9GWduniZtxHGegBztNDkwwBdWZQ6EipBVKSEsmjs/87pt4y/HPyDl0TU1BfeaaT0yCTnoTzGqHLpoCoIo/5iBmUj6MGcHaEEAoVD7HugmZYdAcAps59IbeJpTP32u1pWE5TwsHtED+198r3Cht31fGHM/JZD4IjVWL3VxQU779RE6DpOOvLbBRw+nEEAxylX+moGzCRHmZutvBrsrWdhYeESS+wF0sxc7E6mWv2eChIPTGqF02rYUjY+mrHYzlsJrAxRqOS4219e9Kie31vnjnhVDdtPbWvos4h1/FXEhDGMhHPcv294wjWuK8uYiAgu9IJD6/zEBb/30MsTnPL0=" + ], + "priority": [ + "100" + ], + "algorithm": [ + "RSA-OAEP" + ] + } + }, + { + "id": "a9f08de4-587e-422e-8a52-f7eb2cc2dcb0", + "name": "rsa-generated", + "providerId": "rsa-generated", + "subComponents": {}, + "config": { + "privateKey": [ + "MIIEpAIBAAKCAQEA0XK2AvwcFZvGqPOEEyU9tVVOJF1rVQK7/B9UAWNWzLmT8l7rH9rQHRdevL6v1tO0Km7ruiXbveJ0vUF5lYrYavjADJJQXFDHy73wIJWC4PCS19O10uXroam2KxuSWWkqe2Tm25au48OF5VAZmhOxLnwVFXkt/rw7acUzWd3dkopxp8NUxNbcPtWbrNS1tr1krYOI2vT8aNXZZaFdFsfITz8mOWFl0DqW70BbZv8V4C5qpu5CzoMUSN+c8D/FiKeuQgxOPiS/WO4SmGh6Urs6jGIQKIcaAtg3NOWLeP8XFNVBkvdlQHQPGn0bVFx1HuWjJMplCsIz9biCrj1RfDdH5wIDAQABAoIBABiiQT4AoNz5wVfFrFcEHknhiptEUYdiFvIETUEMifzyJrBu8YCBn9CMGxxf4RaHN7115kuygDHJHKnVtZMdDW8nao8P9lulNJqF8GQksYv7P4oa4Fu5pwkQiNhxGbliYRi6OVzCUDeBm5Ho5dn1TvWEqoYoBnzbbrF1/CAptBGz+EoJ4GoFHd101mo2nL2QulmE2eHPCpIODjXd4giI9n9EIBmjJFagUot1DJC03OTZXNdeDszI4CzjsSsYLJDGHMoI0WgL8vLYs1kMB/rYppVebmv4fDc750YiUKC6MbG0hGaFWQMpza+IWv1l0PpqirsAF8x1wk9CGmUwbCYNVkECgYEA558+pkNwcxvYWNULWVTfxuIsza6fxmwCdDdWm2UYrHQjJr8AX5gVPKwOidTTKCWXybR+nv149keTNIBWaWHf7Nc+8mXpUaOO2KQ11V6qLuFZA0ndp0YuSxvzQKdfeL5oEwojVpoFWvMFL6vqwHqb7lcKujrP1DqMZS6YO9NAGckCgYEA534EiKWd8gS+KYASdmovuEoacAZwsrJG1QrReAPzbOLcQGH1v+IrsMEmII7NjX1FItldPBmV+a29qOjCck5HxSHQkNKbgwEbiIrehE9QGfLfebnmJ2vBOGr+jjSAlZKqnvTozBNobn82vaJOa/cznYEARZAgpXFtbAkuzfjkLC8CgYArn3NWLwdjto/VkzJS/cgzle9oQYY4Aamop695Dt5JxInGR1zTpDoDtkf6r4mhWwsuYv8iBI0enTZdQfqEWHmrCpMBZi4+QParWKoG6JBWyfxQwT2svmwDm10CBUPW4s2JIHStX864ZWLJqrBI1g6+IciUcHUp/GjquY7UXaIJ+QKBgQDS70QoU2kRb3rri9TG68khzvw6GdQ1MDdUxu/JwSfdjvYNAHYSa39OJyGbxyPMClql/5RyQAoloUfRko4j4+qH2WEXpaCohajWCVvrCe4+Rs2VOGxcfVZqFyxu3a5RHHy2LQm3cvPUw7xYnX2B6ZWxritWN5dXyXxgVhm8+07GZwKBgQDlUIu5p3IJudWySUPnGKZoxXj5XVeoXspL0jOCNxj3yNY+aU2Rw2PC5Bcx8bjboC+FKoX3MXQrWH91JXuzrfd6YROSt+S8h3YxZ2HBOofmw154vOhlFm8nYiz9wqvzsRsmHQkviGuKz1MaRxCCSznNXsSbGDbK1P7wHMC1dAW9gg==" + ], + "keyUse": [ + "SIG" + ], + "certificate": [ + "MIICrTCCAZUCBgGMP8/vvTANBgkqhkiG9w0BAQsFADAaMRgwFgYDVQQDDA9icm93c2VyLWNvcGlsb3QwHhcNMjMxMjA2MTU0NTQxWhcNMzMxMjA2MTU0NzIxWjAaMRgwFgYDVQQDDA9icm93c2VyLWNvcGlsb3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDRcrYC/BwVm8ao84QTJT21VU4kXWtVArv8H1QBY1bMuZPyXusf2tAdF168vq/W07Qqbuu6Jdu94nS9QXmVithq+MAMklBcUMfLvfAglYLg8JLX07XS5euhqbYrG5JZaSp7ZObblq7jw4XlUBmaE7EufBUVeS3+vDtpxTNZ3d2SinGnw1TE1tw+1Zus1LW2vWStg4ja9Pxo1dlloV0Wx8hPPyY5YWXQOpbvQFtm/xXgLmqm7kLOgxRI35zwP8WIp65CDE4+JL9Y7hKYaHpSuzqMYhAohxoC2Dc05Yt4/xcU1UGS92VAdA8afRtUXHUe5aMkymUKwjP1uIKuPVF8N0fnAgMBAAEwDQYJKoZIhvcNAQELBQADggEBAJDPx6fPH2pSx771fOdi8pVFvqgULkQJ5CPJg3TRtFCeVk4AtPEPbrxhJzn5rhnHeCTV660yW2TLeCJmiB4xjXXfNdeSJ8p7tciNWy+fV/tP6r3oekDZGPtuY/udIHim6aALe/qhZTz6ty3bBxZc08Zv9+4eY3r+EebAdX09Ajc4N9FfV/HvhrMMG89z87gMKkVlkwzxK34p19wxuj0IUP2eKqyL3QZq1UOPnI8gNGzrlF/WjJxSH5OVbyB7njRHOJyLceFz7g9sHGCS6YjNTmRZTZrkY8t/G6zxiFXJnqJnfLtpy/oAxDD7QO67WZdk+f/0oIn9/GUqCl6ZqhiV9CA=" + ], + "priority": [ + "100" + ] + } + }, + { + "id": "5836c60a-35ea-4765-9f0d-4b78c7bde056", + "name": "aes-generated", + "providerId": "aes-generated", + "subComponents": {}, + "config": { + "kid": [ + "b70afe42-8ab2-4bbb-9267-933bda0c62ac" + ], + "secret": [ + "LrT7j0wfuf833XULPBUO2A" + ], + "priority": [ + "100" + ] + } + }, + { + "id": "c68e405d-82cb-4dd4-9159-69c760030f39", + "name": "hmac-generated", + "providerId": "hmac-generated", + "subComponents": {}, + "config": { + "kid": [ + "eeb8505f-2045-4010-acf8-7ccffbaedeb5" + ], + "secret": [ + "unWTa3Nji_JEsFkb4Un3pmnZL7rIzjIDo-V96GJwk1KZjCvTMaId_hlOL2QE4v_QsUalU_kLG6oCxOJNMVaQ1A" + ], + "priority": [ + "100" + ], + "algorithm": [ + "HS256" + ] + } + } + ] }, - "clients" : [ { - "id" : "b05630aa-4d97-4c33-b0d2-d3d22e1ed083", - "clientId" : "account", - "name" : "${client_account}", - "rootUrl" : "${authBaseUrl}", - "baseUrl" : "/realms/tero/account/", - "surrogateAuthRequired" : false, - "enabled" : true, - "alwaysDisplayInConsole" : false, - "clientAuthenticatorType" : "client-secret", - "redirectUris" : [ "/realms/tero/account/*" ], - "webOrigins" : [ ], - "notBefore" : 0, - "bearerOnly" : false, - "consentRequired" : false, - "standardFlowEnabled" : true, - "implicitFlowEnabled" : false, - "directAccessGrantsEnabled" : false, - "serviceAccountsEnabled" : false, - "publicClient" : true, - "frontchannelLogout" : false, - "protocol" : "openid-connect", - "attributes" : { - "post.logout.redirect.uris" : "+" + "internationalizationEnabled": false, + "supportedLocales": [], + "authenticationFlows": [ + { + "id": "36386363-fe80-4316-a973-c3cd5d65eab8", + "alias": "Account verification options", + "description": "Method with which to verity the existing account", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-email-verification", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "ALTERNATIVE", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "Verify Existing Account by Re-authentication", + "userSetupAllowed": false + } + ] }, - "authenticationFlowBindingOverrides" : { }, - "fullScopeAllowed" : false, - "nodeReRegistrationTimeout" : 0, - "defaultClientScopes" : [ "web-origins", "acr", "profile", "roles", "email" ], - "optionalClientScopes" : [ "address", "phone", "offline_access", "microprofile-jwt" ] - }, { - "id" : "054743c4-26e8-4933-b1d1-78bc20609118", - "clientId" : "account-console", - "name" : "${client_account-console}", - "rootUrl" : "${authBaseUrl}", - "baseUrl" : "/realms/tero/account/", - "surrogateAuthRequired" : false, - "enabled" : true, - "alwaysDisplayInConsole" : false, - "clientAuthenticatorType" : "client-secret", - "redirectUris" : [ "/realms/tero/account/*" ], - "webOrigins" : [ ], - "notBefore" : 0, - "bearerOnly" : false, - "consentRequired" : false, - "standardFlowEnabled" : true, - "implicitFlowEnabled" : false, - "directAccessGrantsEnabled" : false, - "serviceAccountsEnabled" : false, - "publicClient" : true, - "frontchannelLogout" : false, - "protocol" : "openid-connect", - "attributes" : { - "post.logout.redirect.uris" : "+", - "pkce.code.challenge.method" : "S256" + { + "id": "b04561e0-7184-493c-bd8f-db10fda77d68", + "alias": "Browser - Conditional OTP", + "description": "Flow to determine if the OTP is required for the authentication", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "auth-otp-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] }, - "authenticationFlowBindingOverrides" : { }, - "fullScopeAllowed" : false, - "nodeReRegistrationTimeout" : 0, - "protocolMappers" : [ { - "id" : "ac72faad-54e1-44f6-b1a3-6573706b373f", - "name" : "audience resolve", - "protocol" : "openid-connect", - "protocolMapper" : "oidc-audience-resolve-mapper", - "consentRequired" : false, - "config" : { } - } ], - "defaultClientScopes" : [ "web-origins", "acr", "profile", "roles", "email" ], - "optionalClientScopes" : [ "address", "phone", "offline_access", "microprofile-jwt" ] - }, { - "id" : "5e18136c-8eb3-4faf-bfb2-dcfd0db4f30a", - "clientId" : "admin-cli", - "name" : "${client_admin-cli}", - "surrogateAuthRequired" : false, - "enabled" : true, - "alwaysDisplayInConsole" : false, - "clientAuthenticatorType" : "client-secret", - "redirectUris" : [ ], - "webOrigins" : [ ], - "notBefore" : 0, - "bearerOnly" : false, - "consentRequired" : false, - "standardFlowEnabled" : false, - "implicitFlowEnabled" : false, - "directAccessGrantsEnabled" : true, - "serviceAccountsEnabled" : false, - "publicClient" : true, - "frontchannelLogout" : false, - "protocol" : "openid-connect", - "attributes" : { - "post.logout.redirect.uris" : "+" + { + "id": "6fd3a7ec-c72d-41ae-94e4-4d723af27ab5", + "alias": "Direct Grant - Conditional OTP", + "description": "Flow to determine if the OTP is required for the authentication", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "direct-grant-validate-otp", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] }, - "authenticationFlowBindingOverrides" : { }, - "fullScopeAllowed" : false, - "nodeReRegistrationTimeout" : 0, - "defaultClientScopes" : [ "web-origins", "acr", "profile", "roles", "email" ], - "optionalClientScopes" : [ "address", "phone", "offline_access", "microprofile-jwt" ] - }, { - "id" : "74578d5c-1e68-47fa-965a-dc3537dff931", - "clientId" : "broker", - "name" : "${client_broker}", - "surrogateAuthRequired" : false, - "enabled" : true, - "alwaysDisplayInConsole" : false, - "clientAuthenticatorType" : "client-secret", - "redirectUris" : [ ], - "webOrigins" : [ ], - "notBefore" : 0, - "bearerOnly" : true, - "consentRequired" : false, - "standardFlowEnabled" : true, - "implicitFlowEnabled" : false, - "directAccessGrantsEnabled" : false, - "serviceAccountsEnabled" : false, - "publicClient" : false, - "frontchannelLogout" : false, - "protocol" : "openid-connect", - "attributes" : { - "post.logout.redirect.uris" : "+" + { + "id": "ea5aa33f-f7ff-4634-a0e7-cc1827ac8449", + "alias": "First broker login - Conditional OTP", + "description": "Flow to determine if the OTP is required for the authentication", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "auth-otp-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] }, - "authenticationFlowBindingOverrides" : { }, - "fullScopeAllowed" : false, - "nodeReRegistrationTimeout" : 0, - "defaultClientScopes" : [ "web-origins", "acr", "profile", "roles", "email" ], - "optionalClientScopes" : [ "address", "phone", "offline_access", "microprofile-jwt" ] - }, { - "id" : "ac18b665-a3f8-4458-b907-b40177b51ad9", - "clientId" : "tero", - "name" : "", - "description" : "", - "rootUrl" : "", - "adminUrl" : "", - "baseUrl" : "", - "surrogateAuthRequired" : false, - "enabled" : true, - "alwaysDisplayInConsole" : false, - "clientAuthenticatorType" : "client-secret", - "redirectUris" : [ "http://localhost:5173/*", "http://localhost:8000/*", "https://fimbaimlephkekbpfdidmkenhjddimak.chromiumapp.org/*" ], - "webOrigins" : [ "http://localhost:5173", "http://localhost:8000", "https://fimbaimlephkekbpfdidmkenhjddimak.chromiumapp.org/*" ], - "notBefore" : 0, - "bearerOnly" : false, - "consentRequired" : false, - "standardFlowEnabled" : true, - "implicitFlowEnabled" : false, - "directAccessGrantsEnabled" : true, - "serviceAccountsEnabled" : false, - "publicClient" : true, - "frontchannelLogout" : true, - "protocol" : "openid-connect", - "attributes" : { - "oidc.ciba.grant.enabled" : "false", - "backchannel.logout.session.required" : "true", - "post.logout.redirect.uris" : "+", - "oauth2.device.authorization.grant.enabled" : "false", - "display.on.consent.screen" : "false", - "backchannel.logout.revoke.offline.tokens" : "false" + { + "id": "117da68f-f6fc-42e7-98b9-67bfbf2aa7c8", + "alias": "Handle Existing Account", + "description": "Handle what to do if there is existing account with same email/username like authenticated identity provider", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-confirm-link", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "Account verification options", + "userSetupAllowed": false + } + ] }, - "authenticationFlowBindingOverrides" : { }, - "fullScopeAllowed" : true, - "nodeReRegistrationTimeout" : -1, - "defaultClientScopes" : [ "web-origins", "acr", "profile", "roles", "email" ], - "optionalClientScopes" : [ "address", "phone", "offline_access", "microprofile-jwt" ] - }, - { - "id" : "4980696d-df2e-4c6c-9e71-a309f3cea564", - "clientId" : "realm-management", - "name" : "${client_realm-management}", - "surrogateAuthRequired" : false, - "enabled" : true, - "alwaysDisplayInConsole" : false, - "clientAuthenticatorType" : "client-secret", - "redirectUris" : [ ], - "webOrigins" : [ ], - "notBefore" : 0, - "bearerOnly" : true, - "consentRequired" : false, - "standardFlowEnabled" : true, - "implicitFlowEnabled" : false, - "directAccessGrantsEnabled" : false, - "serviceAccountsEnabled" : false, - "publicClient" : false, - "frontchannelLogout" : false, - "protocol" : "openid-connect", - "attributes" : { - "post.logout.redirect.uris" : "+" + { + "id": "60228507-340c-4ee7-8797-0c2b82f01062", + "alias": "Reset - Conditional OTP", + "description": "Flow to determine if the OTP should be reset or not. Set to REQUIRED to force.", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "reset-otp", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] }, - "authenticationFlowBindingOverrides" : { }, - "fullScopeAllowed" : false, - "nodeReRegistrationTimeout" : 0, - "defaultClientScopes" : [ "web-origins", "acr", "profile", "roles", "email" ], - "optionalClientScopes" : [ "address", "phone", "offline_access", "microprofile-jwt" ] - }, { - "id" : "801a5fa8-6849-4740-a893-f23c48e6f01a", - "clientId" : "security-admin-console", - "name" : "${client_security-admin-console}", - "rootUrl" : "${authAdminUrl}", - "baseUrl" : "/admin/tero/console/", - "surrogateAuthRequired" : false, - "enabled" : true, - "alwaysDisplayInConsole" : false, - "clientAuthenticatorType" : "client-secret", - "redirectUris" : [ "/admin/tero/console/*" ], - "webOrigins" : [ "+" ], - "notBefore" : 0, - "bearerOnly" : false, - "consentRequired" : false, - "standardFlowEnabled" : true, - "implicitFlowEnabled" : false, - "directAccessGrantsEnabled" : false, - "serviceAccountsEnabled" : false, - "publicClient" : true, - "frontchannelLogout" : false, - "protocol" : "openid-connect", - "attributes" : { - "post.logout.redirect.uris" : "+", - "pkce.code.challenge.method" : "S256" + { + "id": "422ae00d-c8cd-458f-9e13-5cdadb3befd7", + "alias": "User creation or linking", + "description": "Flow for the existing/non-existing user alternatives", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticatorConfig": "create unique user config", + "authenticator": "idp-create-user-if-unique", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "ALTERNATIVE", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "Handle Existing Account", + "userSetupAllowed": false + } + ] }, - "authenticationFlowBindingOverrides" : { }, - "fullScopeAllowed" : false, - "nodeReRegistrationTimeout" : 0, - "protocolMappers" : [ { - "id" : "b3d5afbe-b810-4c7f-82a7-605b2578b645", - "name" : "locale", - "protocol" : "openid-connect", - "protocolMapper" : "oidc-usermodel-attribute-mapper", - "consentRequired" : false, - "config" : { - "introspection.token.claim" : "true", - "userinfo.token.claim" : "true", - "user.attribute" : "locale", - "id.token.claim" : "true", - "access.token.claim" : "true", - "claim.name" : "locale", - "jsonType.label" : "String" - } - } ], - "defaultClientScopes" : [ "web-origins", "acr", "profile", "roles", "email" ], - "optionalClientScopes" : [ "address", "phone", "offline_access", "microprofile-jwt" ] - } ], - "clientScopes" : [ { - "id" : "d4420861-0c3d-4c74-8d81-476adbde7566", - "name" : "email", - "description" : "OpenID Connect built-in scope: email", - "protocol" : "openid-connect", - "attributes" : { - "include.in.token.scope" : "true", - "display.on.consent.screen" : "true", - "consent.screen.text" : "${emailScopeConsentText}" + { + "id": "dcbb9e66-3978-4f8b-b0eb-bb27f596d378", + "alias": "Verify Existing Account by Re-authentication", + "description": "Reauthentication of existing account", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-username-password-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "First broker login - Conditional OTP", + "userSetupAllowed": false + } + ] }, - "protocolMappers" : [ { - "id" : "9162ec92-5572-4c1f-a270-f42faae83705", - "name" : "email verified", - "protocol" : "openid-connect", - "protocolMapper" : "oidc-usermodel-property-mapper", - "consentRequired" : false, - "config" : { - "introspection.token.claim" : "true", - "userinfo.token.claim" : "true", - "user.attribute" : "emailVerified", - "id.token.claim" : "true", - "access.token.claim" : "true", - "claim.name" : "email_verified", - "jsonType.label" : "boolean" - } - }, { - "id" : "d3b8b2f6-f4bc-445e-a99a-c2f899ba53e2", - "name" : "email", - "protocol" : "openid-connect", - "protocolMapper" : "oidc-usermodel-attribute-mapper", - "consentRequired" : false, - "config" : { - "introspection.token.claim" : "true", - "userinfo.token.claim" : "true", - "user.attribute" : "email", - "id.token.claim" : "true", - "access.token.claim" : "true", - "claim.name" : "email", - "jsonType.label" : "String" - } - } ] - }, { - "id" : "8f69cfca-c2f3-4ec9-87c5-7d27293bcd1f", - "name" : "address", - "description" : "OpenID Connect built-in scope: address", - "protocol" : "openid-connect", - "attributes" : { - "include.in.token.scope" : "true", - "display.on.consent.screen" : "true", - "consent.screen.text" : "${addressScopeConsentText}" + { + "id": "764403d2-ca9f-4343-a807-7882fc11990b", + "alias": "browser", + "description": "browser based authentication", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "auth-cookie", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "auth-spnego", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "identity-provider-redirector", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 25, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "ALTERNATIVE", + "priority": 30, + "autheticatorFlow": true, + "flowAlias": "forms", + "userSetupAllowed": false + } + ] }, - "protocolMappers" : [ { - "id" : "6b3b8c68-0712-40f2-bc8b-f953a6b99c98", - "name" : "address", - "protocol" : "openid-connect", - "protocolMapper" : "oidc-address-mapper", - "consentRequired" : false, - "config" : { - "user.attribute.formatted" : "formatted", - "user.attribute.country" : "country", - "introspection.token.claim" : "true", - "user.attribute.postal_code" : "postal_code", - "userinfo.token.claim" : "true", - "user.attribute.street" : "street", - "id.token.claim" : "true", - "user.attribute.region" : "region", - "access.token.claim" : "true", - "user.attribute.locality" : "locality" - } - } ] - }, { - "id" : "dc3a2425-8ed8-4a42-aef8-1cdd9a0d4f98", - "name" : "microprofile-jwt", - "description" : "Microprofile - JWT built-in scope", - "protocol" : "openid-connect", - "attributes" : { - "include.in.token.scope" : "true", - "display.on.consent.screen" : "false" + { + "id": "2ff79a36-a4a7-42f8-8a47-0712f90bbe23", + "alias": "clients", + "description": "Base authentication for clients", + "providerId": "client-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "client-secret", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "client-jwt", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "client-secret-jwt", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 30, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "client-x509", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 40, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] }, - "protocolMappers" : [ { - "id" : "36f4c361-6e2b-478a-8b7e-b5b0020732d4", - "name" : "groups", - "protocol" : "openid-connect", - "protocolMapper" : "oidc-usermodel-realm-role-mapper", - "consentRequired" : false, - "config" : { - "introspection.token.claim" : "true", - "multivalued" : "true", - "userinfo.token.claim" : "true", - "user.attribute" : "foo", - "id.token.claim" : "true", - "access.token.claim" : "true", - "claim.name" : "groups", - "jsonType.label" : "String" - } - }, { - "id" : "3f536e4f-77b8-42b8-972f-dafcb50db89a", - "name" : "upn", - "protocol" : "openid-connect", - "protocolMapper" : "oidc-usermodel-attribute-mapper", - "consentRequired" : false, - "config" : { - "introspection.token.claim" : "true", - "userinfo.token.claim" : "true", - "user.attribute" : "username", - "id.token.claim" : "true", - "access.token.claim" : "true", - "claim.name" : "upn", - "jsonType.label" : "String" - } - } ] - }, { - "id" : "e6232474-489d-4b9f-8fc5-2049497517f0", - "name" : "acr", - "description" : "OpenID Connect scope for add acr (authentication context class reference) to the token", - "protocol" : "openid-connect", - "attributes" : { - "include.in.token.scope" : "false", - "display.on.consent.screen" : "false" + { + "id": "1728276a-51ac-4351-9284-9210489927c2", + "alias": "direct grant", + "description": "OpenID Connect Resource Owner Grant", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "direct-grant-validate-username", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "direct-grant-validate-password", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 30, + "autheticatorFlow": true, + "flowAlias": "Direct Grant - Conditional OTP", + "userSetupAllowed": false + } + ] }, - "protocolMappers" : [ { - "id" : "5755c449-8305-4d81-8aca-cb4affb9138b", - "name" : "acr loa level", - "protocol" : "openid-connect", - "protocolMapper" : "oidc-acr-mapper", - "consentRequired" : false, - "config" : { - "id.token.claim" : "true", - "introspection.token.claim" : "true", - "access.token.claim" : "true", - "userinfo.token.claim" : "true" - } - } ] - }, { - "id" : "92a5e571-a097-4e41-a25a-ceaa3dafd3fa", - "name" : "role_list", - "description" : "SAML role list", - "protocol" : "saml", - "attributes" : { - "consent.screen.text" : "${samlRoleListScopeConsentText}", - "display.on.consent.screen" : "true" + { + "id": "b1844279-6ba5-497d-a15e-224475a73a44", + "alias": "docker auth", + "description": "Used by Docker clients to authenticate against the IDP", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "docker-http-basic-authenticator", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] }, - "protocolMappers" : [ { - "id" : "b464f3db-0dff-4c1f-bbee-bac061447234", - "name" : "role list", - "protocol" : "saml", - "protocolMapper" : "saml-role-list-mapper", - "consentRequired" : false, - "config" : { - "single" : "false", - "attribute.nameformat" : "Basic", - "attribute.name" : "Role" - } - } ] - }, { - "id" : "e42d7848-11e3-4d0b-87b1-d25c2e7a58f1", - "name" : "phone", - "description" : "OpenID Connect built-in scope: phone", - "protocol" : "openid-connect", - "attributes" : { - "include.in.token.scope" : "true", - "display.on.consent.screen" : "true", - "consent.screen.text" : "${phoneScopeConsentText}" + { + "id": "a4bc0dc0-2be1-4a1a-b9e5-64492c077bb2", + "alias": "first broker login", + "description": "Actions taken after first broker login with identity provider account, which is not yet linked to any Keycloak account", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticatorConfig": "review profile config", + "authenticator": "idp-review-profile", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "User creation or linking", + "userSetupAllowed": false + } + ] }, - "protocolMappers" : [ { - "id" : "f96cb6f1-e27f-416c-ba14-cdb2079898d4", - "name" : "phone number", - "protocol" : "openid-connect", - "protocolMapper" : "oidc-usermodel-attribute-mapper", - "consentRequired" : false, - "config" : { - "introspection.token.claim" : "true", - "userinfo.token.claim" : "true", - "user.attribute" : "phoneNumber", - "id.token.claim" : "true", - "access.token.claim" : "true", - "claim.name" : "phone_number", - "jsonType.label" : "String" - } - }, { - "id" : "9313e616-a814-43d7-b6b5-769fbac8967a", - "name" : "phone number verified", - "protocol" : "openid-connect", - "protocolMapper" : "oidc-usermodel-attribute-mapper", - "consentRequired" : false, - "config" : { - "introspection.token.claim" : "true", - "userinfo.token.claim" : "true", - "user.attribute" : "phoneNumberVerified", - "id.token.claim" : "true", - "access.token.claim" : "true", - "claim.name" : "phone_number_verified", - "jsonType.label" : "boolean" - } - } ] - }, { - "id" : "60c455be-529d-4ff8-a61b-6fc2e0c99c37", - "name" : "offline_access", - "description" : "OpenID Connect built-in scope: offline_access", - "protocol" : "openid-connect", - "attributes" : { - "consent.screen.text" : "${offlineAccessScopeConsentText}", - "display.on.consent.screen" : "true" - } - }, { - "id" : "40756e65-4688-4ab1-9c82-3ca8cb3e759b", - "name" : "profile", - "description" : "OpenID Connect built-in scope: profile", - "protocol" : "openid-connect", - "attributes" : { - "include.in.token.scope" : "true", - "display.on.consent.screen" : "true", - "consent.screen.text" : "${profileScopeConsentText}" + { + "id": "50fdbb82-459b-4085-9170-5c123007624a", + "alias": "forms", + "description": "Username, password, otp and other auth forms.", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "auth-username-password-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "Browser - Conditional OTP", + "userSetupAllowed": false + } + ] }, - "protocolMappers" : [ { - "id" : "d925872c-1a89-4987-8e45-dc50b824d83d", - "name" : "website", - "protocol" : "openid-connect", - "protocolMapper" : "oidc-usermodel-attribute-mapper", - "consentRequired" : false, - "config" : { - "introspection.token.claim" : "true", - "userinfo.token.claim" : "true", - "user.attribute" : "website", - "id.token.claim" : "true", - "access.token.claim" : "true", - "claim.name" : "website", - "jsonType.label" : "String" - } - }, { - "id" : "5a6e05d8-b9f2-4878-8720-18f3fe797d65", - "name" : "gender", - "protocol" : "openid-connect", - "protocolMapper" : "oidc-usermodel-attribute-mapper", - "consentRequired" : false, - "config" : { - "introspection.token.claim" : "true", - "userinfo.token.claim" : "true", - "user.attribute" : "gender", - "id.token.claim" : "true", - "access.token.claim" : "true", - "claim.name" : "gender", - "jsonType.label" : "String" - } - }, { - "id" : "09a2dc82-c16e-4b17-b942-f949971de94a", - "name" : "nickname", - "protocol" : "openid-connect", - "protocolMapper" : "oidc-usermodel-attribute-mapper", - "consentRequired" : false, - "config" : { - "introspection.token.claim" : "true", - "userinfo.token.claim" : "true", - "user.attribute" : "nickname", - "id.token.claim" : "true", - "access.token.claim" : "true", - "claim.name" : "nickname", - "jsonType.label" : "String" - } - }, { - "id" : "0f532612-53e0-4965-9c50-52e02682826a", - "name" : "picture", - "protocol" : "openid-connect", - "protocolMapper" : "oidc-usermodel-attribute-mapper", - "consentRequired" : false, - "config" : { - "introspection.token.claim" : "true", - "userinfo.token.claim" : "true", - "user.attribute" : "picture", - "id.token.claim" : "true", - "access.token.claim" : "true", - "claim.name" : "picture", - "jsonType.label" : "String" - } - }, { - "id" : "f4207f0a-a9c7-48a0-b31b-69fd42706f93", - "name" : "family name", - "protocol" : "openid-connect", - "protocolMapper" : "oidc-usermodel-attribute-mapper", - "consentRequired" : false, - "config" : { - "introspection.token.claim" : "true", - "userinfo.token.claim" : "true", - "user.attribute" : "lastName", - "id.token.claim" : "true", - "access.token.claim" : "true", - "claim.name" : "family_name", - "jsonType.label" : "String" - } - }, { - "id" : "0746f537-e18c-4e3a-b58e-0f82e337cae1", - "name" : "username", - "protocol" : "openid-connect", - "protocolMapper" : "oidc-usermodel-attribute-mapper", - "consentRequired" : false, - "config" : { - "introspection.token.claim" : "true", - "userinfo.token.claim" : "true", - "user.attribute" : "username", - "id.token.claim" : "true", - "access.token.claim" : "true", - "claim.name" : "preferred_username", - "jsonType.label" : "String" - } - }, { - "id" : "1165b387-a12f-4a1c-b905-a698646d1d35", - "name" : "full name", - "protocol" : "openid-connect", - "protocolMapper" : "oidc-full-name-mapper", - "consentRequired" : false, - "config" : { - "id.token.claim" : "true", - "introspection.token.claim" : "true", - "access.token.claim" : "true", - "userinfo.token.claim" : "true" - } - }, { - "id" : "c6efbfe4-2e28-41f5-aaf3-67cc4f632ace", - "name" : "updated at", - "protocol" : "openid-connect", - "protocolMapper" : "oidc-usermodel-attribute-mapper", - "consentRequired" : false, - "config" : { - "introspection.token.claim" : "true", - "userinfo.token.claim" : "true", - "user.attribute" : "updatedAt", - "id.token.claim" : "true", - "access.token.claim" : "true", - "claim.name" : "updated_at", - "jsonType.label" : "long" - } - }, { - "id" : "e9b2480b-0be7-49c8-af81-6504235083be", - "name" : "profile", - "protocol" : "openid-connect", - "protocolMapper" : "oidc-usermodel-attribute-mapper", - "consentRequired" : false, - "config" : { - "introspection.token.claim" : "true", - "userinfo.token.claim" : "true", - "user.attribute" : "profile", - "id.token.claim" : "true", - "access.token.claim" : "true", - "claim.name" : "profile", - "jsonType.label" : "String" - } - }, { - "id" : "e28afe43-a442-474a-a1ac-7c1d58c397bc", - "name" : "zoneinfo", - "protocol" : "openid-connect", - "protocolMapper" : "oidc-usermodel-attribute-mapper", - "consentRequired" : false, - "config" : { - "introspection.token.claim" : "true", - "userinfo.token.claim" : "true", - "user.attribute" : "zoneinfo", - "id.token.claim" : "true", - "access.token.claim" : "true", - "claim.name" : "zoneinfo", - "jsonType.label" : "String" - } - }, { - "id" : "c0f42261-8086-412e-9ac9-3b145a51855a", - "name" : "locale", - "protocol" : "openid-connect", - "protocolMapper" : "oidc-usermodel-attribute-mapper", - "consentRequired" : false, - "config" : { - "introspection.token.claim" : "true", - "userinfo.token.claim" : "true", - "user.attribute" : "locale", - "id.token.claim" : "true", - "access.token.claim" : "true", - "claim.name" : "locale", - "jsonType.label" : "String" - } - }, { - "id" : "2854a086-c04a-467f-866e-c55b6b0e2ce7", - "name" : "middle name", - "protocol" : "openid-connect", - "protocolMapper" : "oidc-usermodel-attribute-mapper", - "consentRequired" : false, - "config" : { - "introspection.token.claim" : "true", - "userinfo.token.claim" : "true", - "user.attribute" : "middleName", - "id.token.claim" : "true", - "access.token.claim" : "true", - "claim.name" : "middle_name", - "jsonType.label" : "String" - } - }, { - "id" : "dc5aeba1-79df-4da4-9e5f-0e577523f2e6", - "name" : "birthdate", - "protocol" : "openid-connect", - "protocolMapper" : "oidc-usermodel-attribute-mapper", - "consentRequired" : false, - "config" : { - "introspection.token.claim" : "true", - "userinfo.token.claim" : "true", - "user.attribute" : "birthdate", - "id.token.claim" : "true", - "access.token.claim" : "true", - "claim.name" : "birthdate", - "jsonType.label" : "String" - } - }, { - "id" : "66dad60b-d9ca-4a69-88f4-042d3e9a454e", - "name" : "given name", - "protocol" : "openid-connect", - "protocolMapper" : "oidc-usermodel-attribute-mapper", - "consentRequired" : false, - "config" : { - "introspection.token.claim" : "true", - "userinfo.token.claim" : "true", - "user.attribute" : "firstName", - "id.token.claim" : "true", - "access.token.claim" : "true", - "claim.name" : "given_name", - "jsonType.label" : "String" - } - } ] - }, { - "id" : "3d5bdcc9-b70d-4bb9-b184-ea783b7c5f05", - "name" : "roles", - "description" : "OpenID Connect scope for add user roles to the access token", - "protocol" : "openid-connect", - "attributes" : { - "include.in.token.scope" : "false", - "display.on.consent.screen" : "true", - "consent.screen.text" : "${rolesScopeConsentText}" + { + "id": "364f2af5-aaa8-4090-bca1-a924cc1db5ec", + "alias": "registration", + "description": "registration flow", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "registration-page-form", + "authenticatorFlow": true, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": true, + "flowAlias": "registration form", + "userSetupAllowed": false + } + ] }, - "protocolMappers" : [ { - "id" : "dfb405f8-2c2c-4d63-9773-f933da3ce470", - "name" : "audience resolve", - "protocol" : "openid-connect", - "protocolMapper" : "oidc-audience-resolve-mapper", - "consentRequired" : false, - "config" : { - "introspection.token.claim" : "true", - "access.token.claim" : "true" - } - }, { - "id" : "75ecc5ef-fc2e-425f-b87d-5c1b7ba6b28f", - "name" : "realm roles", - "protocol" : "openid-connect", - "protocolMapper" : "oidc-usermodel-realm-role-mapper", - "consentRequired" : false, - "config" : { - "introspection.token.claim" : "true", - "multivalued" : "true", - "user.attribute" : "foo", - "access.token.claim" : "true", - "claim.name" : "realm_access.roles", - "jsonType.label" : "String" - } - }, { - "id" : "1b1c9a36-b55c-4bf2-86de-df15457ae158", - "name" : "client roles", - "protocol" : "openid-connect", - "protocolMapper" : "oidc-usermodel-client-role-mapper", - "consentRequired" : false, - "config" : { - "introspection.token.claim" : "true", - "multivalued" : "true", - "user.attribute" : "foo", - "access.token.claim" : "true", - "claim.name" : "resource_access.${client_id}.roles", - "jsonType.label" : "String" - } - } ] - }, { - "id" : "b25051f0-a638-4aa2-b7a6-55d460a4d140", - "name" : "web-origins", - "description" : "OpenID Connect scope for add allowed web origins to the access token", - "protocol" : "openid-connect", - "attributes" : { - "include.in.token.scope" : "false", - "display.on.consent.screen" : "false", - "consent.screen.text" : "" + { + "id": "506b6e53-26ac-4caf-b353-c72b023942c5", + "alias": "registration form", + "description": "registration form", + "providerId": "form-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "registration-user-creation", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "registration-password-action", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 50, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "registration-recaptcha-action", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 60, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] }, - "protocolMappers" : [ { - "id" : "66cc777a-d243-4e25-93bd-abdc3535590f", - "name" : "allowed web origins", - "protocol" : "openid-connect", - "protocolMapper" : "oidc-allowed-origins-mapper", - "consentRequired" : false, - "config" : { - "introspection.token.claim" : "true", - "access.token.claim" : "true" - } - } ] - } ], - "defaultDefaultClientScopes" : [ "role_list", "profile", "email", "roles", "web-origins", "acr" ], - "defaultOptionalClientScopes" : [ "offline_access", "address", "phone", "microprofile-jwt" ], - "browserSecurityHeaders" : { - "contentSecurityPolicyReportOnly" : "", - "xContentTypeOptions" : "nosniff", - "referrerPolicy" : "no-referrer", - "xRobotsTag" : "none", - "xFrameOptions" : "SAMEORIGIN", - "contentSecurityPolicy" : "frame-src 'self'; frame-ancestors 'self'; object-src 'none';", - "xXSSProtection" : "1; mode=block", - "strictTransportSecurity" : "max-age=31536000; includeSubDomains" - }, - "smtpServer" : { }, - "eventsEnabled" : false, - "eventsListeners" : [ "jboss-logging" ], - "enabledEventTypes" : [ ], - "adminEventsEnabled" : false, - "adminEventsDetailsEnabled" : false, - "identityProviders" : [ ], - "identityProviderMappers" : [ ], - "components" : { - "org.keycloak.services.clientregistration.policy.ClientRegistrationPolicy" : [ { - "id" : "87a5362a-49cc-41f9-95c9-4d8218929970", - "name" : "Full Scope Disabled", - "providerId" : "scope", - "subType" : "anonymous", - "subComponents" : { }, - "config" : { } - }, { - "id" : "bfd01fa9-4e3b-46d8-a600-68014ee5fe42", - "name" : "Max Clients Limit", - "providerId" : "max-clients", - "subType" : "anonymous", - "subComponents" : { }, - "config" : { - "max-clients" : [ "200" ] - } - }, { - "id" : "b38fb902-20be-4274-9106-73cabb6d8872", - "name" : "Allowed Client Scopes", - "providerId" : "allowed-client-templates", - "subType" : "anonymous", - "subComponents" : { }, - "config" : { - "allow-default-scopes" : [ "true" ] - } - }, { - "id" : "df7d9df2-a766-4ccf-9446-23a959d895b3", - "name" : "Allowed Protocol Mapper Types", - "providerId" : "allowed-protocol-mappers", - "subType" : "anonymous", - "subComponents" : { }, - "config" : { - "allowed-protocol-mapper-types" : [ "oidc-usermodel-attribute-mapper", "saml-role-list-mapper", "oidc-address-mapper", "oidc-sha256-pairwise-sub-mapper", "saml-user-attribute-mapper", "oidc-usermodel-property-mapper", "saml-user-property-mapper", "oidc-full-name-mapper" ] - } - }, { - "id" : "27cb51a1-dc60-4235-9925-05e8e91871b4", - "name" : "Allowed Client Scopes", - "providerId" : "allowed-client-templates", - "subType" : "authenticated", - "subComponents" : { }, - "config" : { - "allow-default-scopes" : [ "true" ] - } - }, { - "id" : "7f564db1-7ffd-44da-8d40-d452818740a4", - "name" : "Allowed Protocol Mapper Types", - "providerId" : "allowed-protocol-mappers", - "subType" : "authenticated", - "subComponents" : { }, - "config" : { - "allowed-protocol-mapper-types" : [ "oidc-sha256-pairwise-sub-mapper", "saml-user-property-mapper", "oidc-address-mapper", "oidc-usermodel-property-mapper", "saml-user-attribute-mapper", "oidc-usermodel-attribute-mapper", "saml-role-list-mapper", "oidc-full-name-mapper" ] - } - }, { - "id" : "e9d329c3-1151-427e-bcf8-2132fc0ccf86", - "name" : "Trusted Hosts", - "providerId" : "trusted-hosts", - "subType" : "anonymous", - "subComponents" : { }, - "config" : { - "host-sending-registration-request-must-match" : [ "false" ], - "client-uris-must-match" : [ "true" ], - "trusted-hosts" : [ "anysphere.cursor-retrieval", "code.visualstudio.com", "*.vscode.dev", "localhost", "127.0.0.1" ] - } - }, { - "id" : "cc5b7e36-ed37-49b2-9032-a1a7499ed396", - "name" : "Consent Required", - "providerId" : "consent-required", - "subType" : "anonymous", - "subComponents" : { }, - "config" : { } - } ], - "org.keycloak.keys.KeyProvider" : [ { - "id" : "402895d2-005b-4213-9267-59e8fad501a1", - "name" : "rsa-enc-generated", - "providerId" : "rsa-enc-generated", - "subComponents" : { }, - "config" : { - "privateKey" : [ "MIIEpAIBAAKCAQEA0tEISDGIuxkxU+zRTWUblYIBNYGuIeQvKXSRYECn6ME2j3KlcfjOLhz+USom1Jo8LnjyYkIs8ZcNjDj/vhw3NXrvyYFYF+9NAH1pa9hZfQYrO9JBYqC5t4XhWiyLfs0fev3Tf+gY4mpAVm1KdW+q4i7wroeX5dt/BBbjxTmEaBAn2GJMXx1ruJnlAMQHWQC041n3GN1ysNIc2A7XTpmm+wxcm03YWVe36z2cz4swHp1o9HXh//sEue8dxUuzQWd+s638PEXa1+uR/KHlmXrBB+fC8vOw/dRoBfUVMuYetWTlz3DwdDLGvDBAXuZCkZYCJamELIZSamczXkBKDTlAcQIDAQABAoIBAAECO4rUOS4uAMNWSips41d74QNvcf9wWafA3/OI3lTPMd04rvZ55TX+xlpp5s1/HxkY0+No+NE/crLlpexJXcaYJpQSq0goCWNedkCWxIIu40o8vLqFqtsoMpZtc/hrb60KuwuXtyDENzfCpkfOvOT//6pSH0WxXxehqr2A7jOCQrgcUHfCfFl8q60IJtfrhERhPxs3ItvJSAAJGm00qPS4whKzODcmIUq92IHh9HSDGkgs/K5vFztz6rswuwi3wfHENCSbyu0j57vKOtDu9AhW3y6gwXD8LwF5EPkRc44VWqZnALLOL54oyWi3R3husiM+k4+FuEXu9GLXo6kTWX0CgYEA+JBkv512gGhUdcKj+3NM7NugdABm2eIBrdQiFFp+hHiqwDMwg9Oc7iTmc/Ia+30uigtLKv90doOePj5Hy1zeYLUZBwUwVCTM4vKerZlJsthTJbjAKwJwaZWTkfC8eW3eOwmHBU1u8M3u7IeAozOxPnQckgxAS3Y4GIAqXuJguR0CgYEA2R+Ngc+i4lsSWLH8gAkOcyTmFWSYjU5TbmqikiCYc6FuxbxNSvglx+gbf1mOrgZGj37jIIOrxM4szXrNBp3it6gP09RJrWVcsd+F9JXGjAIT80la20zbiBLZBOIqObG6c7sbs+5TYBdseKTVG+UAzc+U3EaMz2I1PS9LLzoTmGUCgYB15Kaka58FEHbe087LOMjHnvPfkUE3HocFV5RCaxmO41y5hI4COKA6I65aV/6MQbeNKgYhAsDOZWbsxsVuo0GmRL72IXPmtP2otsKkPAxEk238ekBLJgEDUzqHAdOjFIVPIxmzXiK8fDBSZ4KP5bivkorqin0ETbIVjNSL5HtT0QKBgQCg3z4LxnqbWHsZeJbrjspECjzn8OcPG8+5ag0WVExgsGXQ4JosR/xGR/XHv+V1j3TMcWl799NXOKP9g1VR573J8h34B7ynWwj5SfKIrEi2B/wcMGe/QQ0Pn1doxOIgaU0K3sHB6X2hHvnh0c+MoXqdA4b6RtOh/NQRh28fiNpn+QKBgQDKJBZlTqMR1SqXX9mnX1dL3hAQVEfRHmdVAUD3ZVN0tTjaWiaOCFD/fNdALgPQhPpKQaiS8s+21Og5K+lF+LM6VO0msbFzmk9JRiw/mhSMKO0n/ZjkQE602tghr9XTyHsfIq++xLAC8LNjIWO9Z4uH9QzYhxbPBN1RSAQN1qx2IQ==" ], - "keyUse" : [ "ENC" ], - "certificate" : [ "MIICrTCCAZUCBgGMP8/wRTANBgkqhkiG9w0BAQsFADAaMRgwFgYDVQQDDA9icm93c2VyLWNvcGlsb3QwHhcNMjMxMjA2MTU0NTQxWhcNMzMxMjA2MTU0NzIxWjAaMRgwFgYDVQQDDA9icm93c2VyLWNvcGlsb3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDS0QhIMYi7GTFT7NFNZRuVggE1ga4h5C8pdJFgQKfowTaPcqVx+M4uHP5RKibUmjwuePJiQizxlw2MOP++HDc1eu/JgVgX700AfWlr2Fl9Bis70kFioLm3heFaLIt+zR96/dN/6BjiakBWbUp1b6riLvCuh5fl238EFuPFOYRoECfYYkxfHWu4meUAxAdZALTjWfcY3XKw0hzYDtdOmab7DFybTdhZV7frPZzPizAenWj0deH/+wS57x3FS7NBZ36zrfw8RdrX65H8oeWZesEH58Ly87D91GgF9RUy5h61ZOXPcPB0Msa8MEBe5kKRlgIlqYQshlJqZzNeQEoNOUBxAgMBAAEwDQYJKoZIhvcNAQELBQADggEBAHQ9GWduniZtxHGegBztNDkwwBdWZQ6EipBVKSEsmjs/87pt4y/HPyDl0TU1BfeaaT0yCTnoTzGqHLpoCoIo/5iBmUj6MGcHaEEAoVD7HugmZYdAcAps59IbeJpTP32u1pWE5TwsHtED+198r3Cht31fGHM/JZD4IjVWL3VxQU779RE6DpOOvLbBRw+nEEAxylX+moGzCRHmZutvBrsrWdhYeESS+wF0sxc7E6mWv2eChIPTGqF02rYUjY+mrHYzlsJrAxRqOS4219e9Kie31vnjnhVDdtPbWvos4h1/FXEhDGMhHPcv294wjWuK8uYiAgu9IJD6/zEBb/30MsTnPL0=" ], - "priority" : [ "100" ], - "algorithm" : [ "RSA-OAEP" ] - } - }, { - "id" : "a9f08de4-587e-422e-8a52-f7eb2cc2dcb0", - "name" : "rsa-generated", - "providerId" : "rsa-generated", - "subComponents" : { }, - "config" : { - "privateKey" : [ "MIIEpAIBAAKCAQEA0XK2AvwcFZvGqPOEEyU9tVVOJF1rVQK7/B9UAWNWzLmT8l7rH9rQHRdevL6v1tO0Km7ruiXbveJ0vUF5lYrYavjADJJQXFDHy73wIJWC4PCS19O10uXroam2KxuSWWkqe2Tm25au48OF5VAZmhOxLnwVFXkt/rw7acUzWd3dkopxp8NUxNbcPtWbrNS1tr1krYOI2vT8aNXZZaFdFsfITz8mOWFl0DqW70BbZv8V4C5qpu5CzoMUSN+c8D/FiKeuQgxOPiS/WO4SmGh6Urs6jGIQKIcaAtg3NOWLeP8XFNVBkvdlQHQPGn0bVFx1HuWjJMplCsIz9biCrj1RfDdH5wIDAQABAoIBABiiQT4AoNz5wVfFrFcEHknhiptEUYdiFvIETUEMifzyJrBu8YCBn9CMGxxf4RaHN7115kuygDHJHKnVtZMdDW8nao8P9lulNJqF8GQksYv7P4oa4Fu5pwkQiNhxGbliYRi6OVzCUDeBm5Ho5dn1TvWEqoYoBnzbbrF1/CAptBGz+EoJ4GoFHd101mo2nL2QulmE2eHPCpIODjXd4giI9n9EIBmjJFagUot1DJC03OTZXNdeDszI4CzjsSsYLJDGHMoI0WgL8vLYs1kMB/rYppVebmv4fDc750YiUKC6MbG0hGaFWQMpza+IWv1l0PpqirsAF8x1wk9CGmUwbCYNVkECgYEA558+pkNwcxvYWNULWVTfxuIsza6fxmwCdDdWm2UYrHQjJr8AX5gVPKwOidTTKCWXybR+nv149keTNIBWaWHf7Nc+8mXpUaOO2KQ11V6qLuFZA0ndp0YuSxvzQKdfeL5oEwojVpoFWvMFL6vqwHqb7lcKujrP1DqMZS6YO9NAGckCgYEA534EiKWd8gS+KYASdmovuEoacAZwsrJG1QrReAPzbOLcQGH1v+IrsMEmII7NjX1FItldPBmV+a29qOjCck5HxSHQkNKbgwEbiIrehE9QGfLfebnmJ2vBOGr+jjSAlZKqnvTozBNobn82vaJOa/cznYEARZAgpXFtbAkuzfjkLC8CgYArn3NWLwdjto/VkzJS/cgzle9oQYY4Aamop695Dt5JxInGR1zTpDoDtkf6r4mhWwsuYv8iBI0enTZdQfqEWHmrCpMBZi4+QParWKoG6JBWyfxQwT2svmwDm10CBUPW4s2JIHStX864ZWLJqrBI1g6+IciUcHUp/GjquY7UXaIJ+QKBgQDS70QoU2kRb3rri9TG68khzvw6GdQ1MDdUxu/JwSfdjvYNAHYSa39OJyGbxyPMClql/5RyQAoloUfRko4j4+qH2WEXpaCohajWCVvrCe4+Rs2VOGxcfVZqFyxu3a5RHHy2LQm3cvPUw7xYnX2B6ZWxritWN5dXyXxgVhm8+07GZwKBgQDlUIu5p3IJudWySUPnGKZoxXj5XVeoXspL0jOCNxj3yNY+aU2Rw2PC5Bcx8bjboC+FKoX3MXQrWH91JXuzrfd6YROSt+S8h3YxZ2HBOofmw154vOhlFm8nYiz9wqvzsRsmHQkviGuKz1MaRxCCSznNXsSbGDbK1P7wHMC1dAW9gg==" ], - "keyUse" : [ "SIG" ], - "certificate" : [ "MIICrTCCAZUCBgGMP8/vvTANBgkqhkiG9w0BAQsFADAaMRgwFgYDVQQDDA9icm93c2VyLWNvcGlsb3QwHhcNMjMxMjA2MTU0NTQxWhcNMzMxMjA2MTU0NzIxWjAaMRgwFgYDVQQDDA9icm93c2VyLWNvcGlsb3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDRcrYC/BwVm8ao84QTJT21VU4kXWtVArv8H1QBY1bMuZPyXusf2tAdF168vq/W07Qqbuu6Jdu94nS9QXmVithq+MAMklBcUMfLvfAglYLg8JLX07XS5euhqbYrG5JZaSp7ZObblq7jw4XlUBmaE7EufBUVeS3+vDtpxTNZ3d2SinGnw1TE1tw+1Zus1LW2vWStg4ja9Pxo1dlloV0Wx8hPPyY5YWXQOpbvQFtm/xXgLmqm7kLOgxRI35zwP8WIp65CDE4+JL9Y7hKYaHpSuzqMYhAohxoC2Dc05Yt4/xcU1UGS92VAdA8afRtUXHUe5aMkymUKwjP1uIKuPVF8N0fnAgMBAAEwDQYJKoZIhvcNAQELBQADggEBAJDPx6fPH2pSx771fOdi8pVFvqgULkQJ5CPJg3TRtFCeVk4AtPEPbrxhJzn5rhnHeCTV660yW2TLeCJmiB4xjXXfNdeSJ8p7tciNWy+fV/tP6r3oekDZGPtuY/udIHim6aALe/qhZTz6ty3bBxZc08Zv9+4eY3r+EebAdX09Ajc4N9FfV/HvhrMMG89z87gMKkVlkwzxK34p19wxuj0IUP2eKqyL3QZq1UOPnI8gNGzrlF/WjJxSH5OVbyB7njRHOJyLceFz7g9sHGCS6YjNTmRZTZrkY8t/G6zxiFXJnqJnfLtpy/oAxDD7QO67WZdk+f/0oIn9/GUqCl6ZqhiV9CA=" ], - "priority" : [ "100" ] - } - }, { - "id" : "5836c60a-35ea-4765-9f0d-4b78c7bde056", - "name" : "aes-generated", - "providerId" : "aes-generated", - "subComponents" : { }, - "config" : { - "kid" : [ "b70afe42-8ab2-4bbb-9267-933bda0c62ac" ], - "secret" : [ "LrT7j0wfuf833XULPBUO2A" ], - "priority" : [ "100" ] + { + "id": "7e33f0b8-7436-413e-a3f3-13c7c9bb5013", + "alias": "reset credentials", + "description": "Reset credentials for a user if they forgot their password or something", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "reset-credentials-choose-user", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "reset-credential-email", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "reset-password", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 30, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 40, + "autheticatorFlow": true, + "flowAlias": "Reset - Conditional OTP", + "userSetupAllowed": false + } + ] + }, + { + "id": "85606fab-ab75-4812-af7b-198ab6f6b7e9", + "alias": "saml ecp", + "description": "SAML ECP Profile Authentication Flow", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "http-basic-authenticator", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + } + ], + "authenticatorConfig": [ + { + "id": "fcaef904-fb09-47c1-8b5e-ddb128c90821", + "alias": "create unique user config", + "config": { + "require.password.update.after.registration": "false" } - }, { - "id" : "c68e405d-82cb-4dd4-9159-69c760030f39", - "name" : "hmac-generated", - "providerId" : "hmac-generated", - "subComponents" : { }, - "config" : { - "kid" : [ "eeb8505f-2045-4010-acf8-7ccffbaedeb5" ], - "secret" : [ "unWTa3Nji_JEsFkb4Un3pmnZL7rIzjIDo-V96GJwk1KZjCvTMaId_hlOL2QE4v_QsUalU_kLG6oCxOJNMVaQ1A" ], - "priority" : [ "100" ], - "algorithm" : [ "HS256" ] + }, + { + "id": "01083120-20c9-445e-8f78-10cdd8842eb4", + "alias": "review profile config", + "config": { + "update.profile.on.first.login": "missing" } - } ] - }, - "internationalizationEnabled" : false, - "supportedLocales" : [ ], - "authenticationFlows" : [ { - "id" : "36386363-fe80-4316-a973-c3cd5d65eab8", - "alias" : "Account verification options", - "description" : "Method with which to verity the existing account", - "providerId" : "basic-flow", - "topLevel" : false, - "builtIn" : true, - "authenticationExecutions" : [ { - "authenticator" : "idp-email-verification", - "authenticatorFlow" : false, - "requirement" : "ALTERNATIVE", - "priority" : 10, - "autheticatorFlow" : false, - "userSetupAllowed" : false - }, { - "authenticatorFlow" : true, - "requirement" : "ALTERNATIVE", - "priority" : 20, - "autheticatorFlow" : true, - "flowAlias" : "Verify Existing Account by Re-authentication", - "userSetupAllowed" : false - } ] - }, { - "id" : "b04561e0-7184-493c-bd8f-db10fda77d68", - "alias" : "Browser - Conditional OTP", - "description" : "Flow to determine if the OTP is required for the authentication", - "providerId" : "basic-flow", - "topLevel" : false, - "builtIn" : true, - "authenticationExecutions" : [ { - "authenticator" : "conditional-user-configured", - "authenticatorFlow" : false, - "requirement" : "REQUIRED", - "priority" : 10, - "autheticatorFlow" : false, - "userSetupAllowed" : false - }, { - "authenticator" : "auth-otp-form", - "authenticatorFlow" : false, - "requirement" : "REQUIRED", - "priority" : 20, - "autheticatorFlow" : false, - "userSetupAllowed" : false - } ] - }, { - "id" : "6fd3a7ec-c72d-41ae-94e4-4d723af27ab5", - "alias" : "Direct Grant - Conditional OTP", - "description" : "Flow to determine if the OTP is required for the authentication", - "providerId" : "basic-flow", - "topLevel" : false, - "builtIn" : true, - "authenticationExecutions" : [ { - "authenticator" : "conditional-user-configured", - "authenticatorFlow" : false, - "requirement" : "REQUIRED", - "priority" : 10, - "autheticatorFlow" : false, - "userSetupAllowed" : false - }, { - "authenticator" : "direct-grant-validate-otp", - "authenticatorFlow" : false, - "requirement" : "REQUIRED", - "priority" : 20, - "autheticatorFlow" : false, - "userSetupAllowed" : false - } ] - }, { - "id" : "ea5aa33f-f7ff-4634-a0e7-cc1827ac8449", - "alias" : "First broker login - Conditional OTP", - "description" : "Flow to determine if the OTP is required for the authentication", - "providerId" : "basic-flow", - "topLevel" : false, - "builtIn" : true, - "authenticationExecutions" : [ { - "authenticator" : "conditional-user-configured", - "authenticatorFlow" : false, - "requirement" : "REQUIRED", - "priority" : 10, - "autheticatorFlow" : false, - "userSetupAllowed" : false - }, { - "authenticator" : "auth-otp-form", - "authenticatorFlow" : false, - "requirement" : "REQUIRED", - "priority" : 20, - "autheticatorFlow" : false, - "userSetupAllowed" : false - } ] - }, { - "id" : "117da68f-f6fc-42e7-98b9-67bfbf2aa7c8", - "alias" : "Handle Existing Account", - "description" : "Handle what to do if there is existing account with same email/username like authenticated identity provider", - "providerId" : "basic-flow", - "topLevel" : false, - "builtIn" : true, - "authenticationExecutions" : [ { - "authenticator" : "idp-confirm-link", - "authenticatorFlow" : false, - "requirement" : "REQUIRED", - "priority" : 10, - "autheticatorFlow" : false, - "userSetupAllowed" : false - }, { - "authenticatorFlow" : true, - "requirement" : "REQUIRED", - "priority" : 20, - "autheticatorFlow" : true, - "flowAlias" : "Account verification options", - "userSetupAllowed" : false - } ] - }, { - "id" : "60228507-340c-4ee7-8797-0c2b82f01062", - "alias" : "Reset - Conditional OTP", - "description" : "Flow to determine if the OTP should be reset or not. Set to REQUIRED to force.", - "providerId" : "basic-flow", - "topLevel" : false, - "builtIn" : true, - "authenticationExecutions" : [ { - "authenticator" : "conditional-user-configured", - "authenticatorFlow" : false, - "requirement" : "REQUIRED", - "priority" : 10, - "autheticatorFlow" : false, - "userSetupAllowed" : false - }, { - "authenticator" : "reset-otp", - "authenticatorFlow" : false, - "requirement" : "REQUIRED", - "priority" : 20, - "autheticatorFlow" : false, - "userSetupAllowed" : false - } ] - }, { - "id" : "422ae00d-c8cd-458f-9e13-5cdadb3befd7", - "alias" : "User creation or linking", - "description" : "Flow for the existing/non-existing user alternatives", - "providerId" : "basic-flow", - "topLevel" : false, - "builtIn" : true, - "authenticationExecutions" : [ { - "authenticatorConfig" : "create unique user config", - "authenticator" : "idp-create-user-if-unique", - "authenticatorFlow" : false, - "requirement" : "ALTERNATIVE", - "priority" : 10, - "autheticatorFlow" : false, - "userSetupAllowed" : false - }, { - "authenticatorFlow" : true, - "requirement" : "ALTERNATIVE", - "priority" : 20, - "autheticatorFlow" : true, - "flowAlias" : "Handle Existing Account", - "userSetupAllowed" : false - } ] - }, { - "id" : "dcbb9e66-3978-4f8b-b0eb-bb27f596d378", - "alias" : "Verify Existing Account by Re-authentication", - "description" : "Reauthentication of existing account", - "providerId" : "basic-flow", - "topLevel" : false, - "builtIn" : true, - "authenticationExecutions" : [ { - "authenticator" : "idp-username-password-form", - "authenticatorFlow" : false, - "requirement" : "REQUIRED", - "priority" : 10, - "autheticatorFlow" : false, - "userSetupAllowed" : false - }, { - "authenticatorFlow" : true, - "requirement" : "CONDITIONAL", - "priority" : 20, - "autheticatorFlow" : true, - "flowAlias" : "First broker login - Conditional OTP", - "userSetupAllowed" : false - } ] - }, { - "id" : "764403d2-ca9f-4343-a807-7882fc11990b", - "alias" : "browser", - "description" : "browser based authentication", - "providerId" : "basic-flow", - "topLevel" : true, - "builtIn" : true, - "authenticationExecutions" : [ { - "authenticator" : "auth-cookie", - "authenticatorFlow" : false, - "requirement" : "ALTERNATIVE", - "priority" : 10, - "autheticatorFlow" : false, - "userSetupAllowed" : false - }, { - "authenticator" : "auth-spnego", - "authenticatorFlow" : false, - "requirement" : "DISABLED", - "priority" : 20, - "autheticatorFlow" : false, - "userSetupAllowed" : false - }, { - "authenticator" : "identity-provider-redirector", - "authenticatorFlow" : false, - "requirement" : "ALTERNATIVE", - "priority" : 25, - "autheticatorFlow" : false, - "userSetupAllowed" : false - }, { - "authenticatorFlow" : true, - "requirement" : "ALTERNATIVE", - "priority" : 30, - "autheticatorFlow" : true, - "flowAlias" : "forms", - "userSetupAllowed" : false - } ] - }, { - "id" : "2ff79a36-a4a7-42f8-8a47-0712f90bbe23", - "alias" : "clients", - "description" : "Base authentication for clients", - "providerId" : "client-flow", - "topLevel" : true, - "builtIn" : true, - "authenticationExecutions" : [ { - "authenticator" : "client-secret", - "authenticatorFlow" : false, - "requirement" : "ALTERNATIVE", - "priority" : 10, - "autheticatorFlow" : false, - "userSetupAllowed" : false - }, { - "authenticator" : "client-jwt", - "authenticatorFlow" : false, - "requirement" : "ALTERNATIVE", - "priority" : 20, - "autheticatorFlow" : false, - "userSetupAllowed" : false - }, { - "authenticator" : "client-secret-jwt", - "authenticatorFlow" : false, - "requirement" : "ALTERNATIVE", - "priority" : 30, - "autheticatorFlow" : false, - "userSetupAllowed" : false - }, { - "authenticator" : "client-x509", - "authenticatorFlow" : false, - "requirement" : "ALTERNATIVE", - "priority" : 40, - "autheticatorFlow" : false, - "userSetupAllowed" : false - } ] - }, { - "id" : "1728276a-51ac-4351-9284-9210489927c2", - "alias" : "direct grant", - "description" : "OpenID Connect Resource Owner Grant", - "providerId" : "basic-flow", - "topLevel" : true, - "builtIn" : true, - "authenticationExecutions" : [ { - "authenticator" : "direct-grant-validate-username", - "authenticatorFlow" : false, - "requirement" : "REQUIRED", - "priority" : 10, - "autheticatorFlow" : false, - "userSetupAllowed" : false - }, { - "authenticator" : "direct-grant-validate-password", - "authenticatorFlow" : false, - "requirement" : "REQUIRED", - "priority" : 20, - "autheticatorFlow" : false, - "userSetupAllowed" : false - }, { - "authenticatorFlow" : true, - "requirement" : "CONDITIONAL", - "priority" : 30, - "autheticatorFlow" : true, - "flowAlias" : "Direct Grant - Conditional OTP", - "userSetupAllowed" : false - } ] - }, { - "id" : "b1844279-6ba5-497d-a15e-224475a73a44", - "alias" : "docker auth", - "description" : "Used by Docker clients to authenticate against the IDP", - "providerId" : "basic-flow", - "topLevel" : true, - "builtIn" : true, - "authenticationExecutions" : [ { - "authenticator" : "docker-http-basic-authenticator", - "authenticatorFlow" : false, - "requirement" : "REQUIRED", - "priority" : 10, - "autheticatorFlow" : false, - "userSetupAllowed" : false - } ] - }, { - "id" : "a4bc0dc0-2be1-4a1a-b9e5-64492c077bb2", - "alias" : "first broker login", - "description" : "Actions taken after first broker login with identity provider account, which is not yet linked to any Keycloak account", - "providerId" : "basic-flow", - "topLevel" : true, - "builtIn" : true, - "authenticationExecutions" : [ { - "authenticatorConfig" : "review profile config", - "authenticator" : "idp-review-profile", - "authenticatorFlow" : false, - "requirement" : "REQUIRED", - "priority" : 10, - "autheticatorFlow" : false, - "userSetupAllowed" : false - }, { - "authenticatorFlow" : true, - "requirement" : "REQUIRED", - "priority" : 20, - "autheticatorFlow" : true, - "flowAlias" : "User creation or linking", - "userSetupAllowed" : false - } ] - }, { - "id" : "50fdbb82-459b-4085-9170-5c123007624a", - "alias" : "forms", - "description" : "Username, password, otp and other auth forms.", - "providerId" : "basic-flow", - "topLevel" : false, - "builtIn" : true, - "authenticationExecutions" : [ { - "authenticator" : "auth-username-password-form", - "authenticatorFlow" : false, - "requirement" : "REQUIRED", - "priority" : 10, - "autheticatorFlow" : false, - "userSetupAllowed" : false - }, { - "authenticatorFlow" : true, - "requirement" : "CONDITIONAL", - "priority" : 20, - "autheticatorFlow" : true, - "flowAlias" : "Browser - Conditional OTP", - "userSetupAllowed" : false - } ] - }, { - "id" : "364f2af5-aaa8-4090-bca1-a924cc1db5ec", - "alias" : "registration", - "description" : "registration flow", - "providerId" : "basic-flow", - "topLevel" : true, - "builtIn" : true, - "authenticationExecutions" : [ { - "authenticator" : "registration-page-form", - "authenticatorFlow" : true, - "requirement" : "REQUIRED", - "priority" : 10, - "autheticatorFlow" : true, - "flowAlias" : "registration form", - "userSetupAllowed" : false - } ] - }, { - "id" : "506b6e53-26ac-4caf-b353-c72b023942c5", - "alias" : "registration form", - "description" : "registration form", - "providerId" : "form-flow", - "topLevel" : false, - "builtIn" : true, - "authenticationExecutions" : [ { - "authenticator" : "registration-user-creation", - "authenticatorFlow" : false, - "requirement" : "REQUIRED", - "priority" : 20, - "autheticatorFlow" : false, - "userSetupAllowed" : false - }, { - "authenticator" : "registration-password-action", - "authenticatorFlow" : false, - "requirement" : "REQUIRED", - "priority" : 50, - "autheticatorFlow" : false, - "userSetupAllowed" : false - }, { - "authenticator" : "registration-recaptcha-action", - "authenticatorFlow" : false, - "requirement" : "DISABLED", - "priority" : 60, - "autheticatorFlow" : false, - "userSetupAllowed" : false - } ] - }, { - "id" : "7e33f0b8-7436-413e-a3f3-13c7c9bb5013", - "alias" : "reset credentials", - "description" : "Reset credentials for a user if they forgot their password or something", - "providerId" : "basic-flow", - "topLevel" : true, - "builtIn" : true, - "authenticationExecutions" : [ { - "authenticator" : "reset-credentials-choose-user", - "authenticatorFlow" : false, - "requirement" : "REQUIRED", - "priority" : 10, - "autheticatorFlow" : false, - "userSetupAllowed" : false - }, { - "authenticator" : "reset-credential-email", - "authenticatorFlow" : false, - "requirement" : "REQUIRED", - "priority" : 20, - "autheticatorFlow" : false, - "userSetupAllowed" : false - }, { - "authenticator" : "reset-password", - "authenticatorFlow" : false, - "requirement" : "REQUIRED", - "priority" : 30, - "autheticatorFlow" : false, - "userSetupAllowed" : false - }, { - "authenticatorFlow" : true, - "requirement" : "CONDITIONAL", - "priority" : 40, - "autheticatorFlow" : true, - "flowAlias" : "Reset - Conditional OTP", - "userSetupAllowed" : false - } ] - }, { - "id" : "85606fab-ab75-4812-af7b-198ab6f6b7e9", - "alias" : "saml ecp", - "description" : "SAML ECP Profile Authentication Flow", - "providerId" : "basic-flow", - "topLevel" : true, - "builtIn" : true, - "authenticationExecutions" : [ { - "authenticator" : "http-basic-authenticator", - "authenticatorFlow" : false, - "requirement" : "REQUIRED", - "priority" : 10, - "autheticatorFlow" : false, - "userSetupAllowed" : false - } ] - } ], - "authenticatorConfig" : [ { - "id" : "fcaef904-fb09-47c1-8b5e-ddb128c90821", - "alias" : "create unique user config", - "config" : { - "require.password.update.after.registration" : "false" } - }, { - "id" : "01083120-20c9-445e-8f78-10cdd8842eb4", - "alias" : "review profile config", - "config" : { - "update.profile.on.first.login" : "missing" + ], + "requiredActions": [ + { + "alias": "CONFIGURE_TOTP", + "name": "Configure OTP", + "providerId": "CONFIGURE_TOTP", + "enabled": true, + "defaultAction": false, + "priority": 10, + "config": {} + }, + { + "alias": "TERMS_AND_CONDITIONS", + "name": "Terms and Conditions", + "providerId": "TERMS_AND_CONDITIONS", + "enabled": false, + "defaultAction": false, + "priority": 20, + "config": {} + }, + { + "alias": "UPDATE_PASSWORD", + "name": "Update Password", + "providerId": "UPDATE_PASSWORD", + "enabled": true, + "defaultAction": false, + "priority": 30, + "config": {} + }, + { + "alias": "UPDATE_PROFILE", + "name": "Update Profile", + "providerId": "UPDATE_PROFILE", + "enabled": true, + "defaultAction": false, + "priority": 40, + "config": {} + }, + { + "alias": "VERIFY_EMAIL", + "name": "Verify Email", + "providerId": "VERIFY_EMAIL", + "enabled": true, + "defaultAction": false, + "priority": 50, + "config": {} + }, + { + "alias": "delete_account", + "name": "Delete Account", + "providerId": "delete_account", + "enabled": false, + "defaultAction": false, + "priority": 60, + "config": {} + }, + { + "alias": "webauthn-register", + "name": "Webauthn Register", + "providerId": "webauthn-register", + "enabled": true, + "defaultAction": false, + "priority": 70, + "config": {} + }, + { + "alias": "webauthn-register-passwordless", + "name": "Webauthn Register Passwordless", + "providerId": "webauthn-register-passwordless", + "enabled": true, + "defaultAction": false, + "priority": 80, + "config": {} + }, + { + "alias": "update_user_locale", + "name": "Update User Locale", + "providerId": "update_user_locale", + "enabled": true, + "defaultAction": false, + "priority": 1000, + "config": {} } - } ], - "requiredActions" : [ { - "alias" : "CONFIGURE_TOTP", - "name" : "Configure OTP", - "providerId" : "CONFIGURE_TOTP", - "enabled" : true, - "defaultAction" : false, - "priority" : 10, - "config" : { } - }, { - "alias" : "TERMS_AND_CONDITIONS", - "name" : "Terms and Conditions", - "providerId" : "TERMS_AND_CONDITIONS", - "enabled" : false, - "defaultAction" : false, - "priority" : 20, - "config" : { } - }, { - "alias" : "UPDATE_PASSWORD", - "name" : "Update Password", - "providerId" : "UPDATE_PASSWORD", - "enabled" : true, - "defaultAction" : false, - "priority" : 30, - "config" : { } - }, { - "alias" : "UPDATE_PROFILE", - "name" : "Update Profile", - "providerId" : "UPDATE_PROFILE", - "enabled" : true, - "defaultAction" : false, - "priority" : 40, - "config" : { } - }, { - "alias" : "VERIFY_EMAIL", - "name" : "Verify Email", - "providerId" : "VERIFY_EMAIL", - "enabled" : true, - "defaultAction" : false, - "priority" : 50, - "config" : { } - }, { - "alias" : "delete_account", - "name" : "Delete Account", - "providerId" : "delete_account", - "enabled" : false, - "defaultAction" : false, - "priority" : 60, - "config" : { } - }, { - "alias" : "webauthn-register", - "name" : "Webauthn Register", - "providerId" : "webauthn-register", - "enabled" : true, - "defaultAction" : false, - "priority" : 70, - "config" : { } - }, { - "alias" : "webauthn-register-passwordless", - "name" : "Webauthn Register Passwordless", - "providerId" : "webauthn-register-passwordless", - "enabled" : true, - "defaultAction" : false, - "priority" : 80, - "config" : { } - }, { - "alias" : "update_user_locale", - "name" : "Update User Locale", - "providerId" : "update_user_locale", - "enabled" : true, - "defaultAction" : false, - "priority" : 1000, - "config" : { } - } ], - "browserFlow" : "browser", - "registrationFlow" : "registration", - "directGrantFlow" : "direct grant", - "resetCredentialsFlow" : "reset credentials", - "clientAuthenticationFlow" : "clients", - "dockerAuthenticationFlow" : "docker auth", - "attributes" : { - "cibaBackchannelTokenDeliveryMode" : "poll", - "cibaAuthRequestedUserHint" : "login_hint", - "clientOfflineSessionMaxLifespan" : "0", - "oauth2DevicePollingInterval" : "5", - "clientSessionIdleTimeout" : "0", - "actionTokenGeneratedByUserLifespan-execute-actions" : "", - "actionTokenGeneratedByUserLifespan-verify-email" : "", - "clientOfflineSessionIdleTimeout" : "0", - "actionTokenGeneratedByUserLifespan-reset-credentials" : "", - "cibaInterval" : "5", - "realmReusableOtpCode" : "false", - "cibaExpiresIn" : "120", - "oauth2DeviceCodeLifespan" : "600", - "actionTokenGeneratedByUserLifespan-idp-verify-account-via-email" : "", - "parRequestUriLifespan" : "60", - "clientSessionMaxLifespan" : "0", - "shortVerificationUri" : "" + ], + "browserFlow": "browser", + "registrationFlow": "registration", + "directGrantFlow": "direct grant", + "resetCredentialsFlow": "reset credentials", + "clientAuthenticationFlow": "clients", + "dockerAuthenticationFlow": "docker auth", + "attributes": { + "cibaBackchannelTokenDeliveryMode": "poll", + "cibaAuthRequestedUserHint": "login_hint", + "clientOfflineSessionMaxLifespan": "0", + "oauth2DevicePollingInterval": "5", + "clientSessionIdleTimeout": "0", + "actionTokenGeneratedByUserLifespan-execute-actions": "", + "actionTokenGeneratedByUserLifespan-verify-email": "", + "clientOfflineSessionIdleTimeout": "0", + "actionTokenGeneratedByUserLifespan-reset-credentials": "", + "cibaInterval": "5", + "realmReusableOtpCode": "false", + "cibaExpiresIn": "120", + "oauth2DeviceCodeLifespan": "600", + "actionTokenGeneratedByUserLifespan-idp-verify-account-via-email": "", + "parRequestUriLifespan": "60", + "clientSessionMaxLifespan": "0", + "shortVerificationUri": "" }, - "keycloakVersion" : "23.0.1", - "userManagedAccessAllowed" : false, - "clientProfiles" : { - "profiles" : [ ] + "keycloakVersion": "23.0.1", + "userManagedAccessAllowed": false, + "clientProfiles": { + "profiles": [] }, - "clientPolicies" : { - "policies" : [ ] + "clientPolicies": { + "policies": [] } -} +} \ No newline at end of file diff --git a/docs/guide/quickstart.md b/docs/guide/quickstart.md index c3e1d1a..c809a6d 100644 --- a/docs/guide/quickstart.md +++ b/docs/guide/quickstart.md @@ -10,7 +10,7 @@ The easiest way to try Tero is through the online demo. Request access at `https 1. Clone the [repository](https://github.com/abstracta/tero). Make sure you have git-lfs installed to get all the files properly. 2. Generate an OpenAI API key or an Azure OpenAI endpoint and key. -3. Copy `src/sample.env` to `.env` and set `OPENAI_KEY` or `AZURE_OPENAI_KEY` and `AZURE_OPENAI_ENDPOINT`. +3. Copy `src/sample.env` to `.env` and set `OPENAI_API_KEY` or `AZURE_API_KEYS` and `AZURE_ENDPOINTS`. 4. Start the app and dependencies: ```bash diff --git a/src/Dockerfile b/src/Dockerfile index 7c6b7e1..4225b33 100644 --- a/src/Dockerfile +++ b/src/Dockerfile @@ -29,8 +29,8 @@ RUN poetry config installer.max-workers 10 WORKDIR /usr/src/app ENV PYTHONPATH=/usr/src/app -COPY docker/wait-for-it.sh wait-for-it.sh +COPY docker/wait-for-it.sh wait-for-it.sh COPY backend/pyproject.toml backend/poetry.lock ./ RUN poetry install --no-root diff --git a/src/backend/alembic/env.py b/src/backend/alembic/env.py index bea2ec7..9926521 100644 --- a/src/backend/alembic/env.py +++ b/src/backend/alembic/env.py @@ -9,6 +9,7 @@ # need to add following type ignore to avoid intellij removing imports when organizing them, and warnings in vscode from tero.agents.domain import * # type: ignore +from tero.agents.evaluators.domain import * # type: ignore from tero.agents.prompts.domain import * # type: ignore from tero.ai_models.domain import * # type: ignore from tero.core.env import env diff --git a/src/backend/alembic/versions/20251016-4c775a5d6fdc-test_case_suite.py b/src/backend/alembic/versions/20251016-4c775a5d6fdc-test_case_suite.py index ccc820d..21c9904 100644 --- a/src/backend/alembic/versions/20251016-4c775a5d6fdc-test_case_suite.py +++ b/src/backend/alembic/versions/20251016-4c775a5d6fdc-test_case_suite.py @@ -20,21 +20,21 @@ def upgrade() -> None: sa.Enum('RUNNING', 'SUCCESS', 'FAILURE', name='testsuiterunstatus').create(op.get_bind()) - op.create_table('test_suite_run', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('agent_id', sa.Integer(), nullable=False), - sa.Column('status', postgresql.ENUM('RUNNING', 'SUCCESS', 'FAILURE', name='testsuiterunstatus', - create_type=False), nullable=False), - sa.Column('executed_at', sa.DateTime(), nullable=False), - sa.Column('completed_at', sa.DateTime(), nullable=True), - sa.Column('total_tests', sa.Integer(), nullable=False), - sa.Column('passed_tests', sa.Integer(), nullable=False), - sa.Column('failed_tests', sa.Integer(), nullable=False), - sa.Column('error_tests', sa.Integer(), nullable=False), - sa.Column('skipped_tests', sa.Integer(), nullable=False), - sa.ForeignKeyConstraint(['agent_id'], ['agent.id'], ), - sa.PrimaryKeyConstraint('id') - ) + op.create_table( + 'test_suite_run', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('agent_id', sa.Integer(), nullable=False), + sa.Column('status', postgresql.ENUM('RUNNING', 'SUCCESS', 'FAILURE', name='testsuiterunstatus', + create_type=False), nullable=False), + sa.Column('executed_at', sa.DateTime(), nullable=False), + sa.Column('completed_at', sa.DateTime(), nullable=True), + sa.Column('total_tests', sa.Integer(), nullable=False), + sa.Column('passed_tests', sa.Integer(), nullable=False), + sa.Column('failed_tests', sa.Integer(), nullable=False), + sa.Column('error_tests', sa.Integer(), nullable=False), + sa.Column('skipped_tests', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['agent_id'], ['agent.id'], ), + sa.PrimaryKeyConstraint('id')) op.create_index('ix_test_suite_run_agent_id_executed_at', 'test_suite_run', ['agent_id', 'executed_at'], unique=False) op.drop_constraint('test_case_result_pkey', 'test_case_result', type_='primary') diff --git a/src/backend/alembic/versions/20251026-35ed17ab9ea1-configure_evaluator.py b/src/backend/alembic/versions/20251026-35ed17ab9ea1-configure_evaluator.py new file mode 100644 index 0000000..90883f4 --- /dev/null +++ b/src/backend/alembic/versions/20251026-35ed17ab9ea1-configure_evaluator.py @@ -0,0 +1,49 @@ +"""configure_evaluator + +Revision ID: 35ed17ab9ea1 +Revises: f688dc4a2a9a +Create Date: 2025-10-26 19:39:36.275516 + +""" +import sqlalchemy as sa +import sqlmodel +from typing import Sequence, Union +from alembic import op +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = '35ed17ab9ea1' +down_revision: Union[str, None] = 'f688dc4a2a9a' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table('evaluator', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('model_id', sqlmodel.AutoString(), nullable=False), + sa.Column('temperature', postgresql.ENUM('CREATIVE', 'NEUTRAL', 'PRECISE', name='llmtemperature', create_type=False), nullable=False), + sa.Column('reasoning_effort', postgresql.ENUM('LOW', 'MEDIUM', 'HIGH', name='reasoningeffort', create_type=False), nullable=False), + sa.Column('prompt', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['model_id'], ['llm_model.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index('ix_evaluator_model_id', 'evaluator', ['model_id'], unique=False) + op.add_column('agent', sa.Column('evaluator_id', sa.Integer(), nullable=True)) + op.create_foreign_key('fk_agent_evaluator_id', 'agent', 'evaluator', ['evaluator_id'], ['id']) + op.add_column('test_case', sa.Column('evaluator_id', sa.Integer(), nullable=True)) + op.create_foreign_key('fk_test_case_evaluator_id', 'test_case', 'evaluator', ['evaluator_id'], ['id']) + + op.add_column('test_case_result', sa.Column('evaluator_analysis', sa.Text(), nullable=True)) + + +def downgrade() -> None: + op.drop_column('test_case_result', 'evaluator_analysis') + + op.drop_constraint('fk_test_case_evaluator_id', 'test_case', type_='foreignkey') + op.drop_column('test_case', 'evaluator_id') + op.drop_constraint('fk_agent_evaluator_id', 'agent', type_='foreignkey') + op.drop_column('agent', 'evaluator_id') + op.drop_index('ix_evaluator_model_id', table_name='evaluator') + op.drop_table('evaluator') diff --git a/src/backend/alembic/versions/20251029-9b4a5352b79c-test_case_result_name.py b/src/backend/alembic/versions/20251029-9b4a5352b79c-test_case_result_name.py new file mode 100644 index 0000000..eff1fba --- /dev/null +++ b/src/backend/alembic/versions/20251029-9b4a5352b79c-test_case_result_name.py @@ -0,0 +1,33 @@ +"""test_case_result_name + +Revision ID: 9b4a5352b79c +Revises: 35ed17ab9ea1 +Create Date: 2025-10-29 12:26:46.957655 + +""" +import sqlalchemy as sa +import sqlmodel +from typing import Sequence, Union +from alembic import op + + +# revision identifiers, used by Alembic. +revision: str = '9b4a5352b79c' +down_revision: Union[str, None] = '35ed17ab9ea1' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column('test_case_result', sa.Column('test_case_name', sqlmodel.AutoString(), nullable=True)) + + op.execute(""" + UPDATE test_case_result + SET test_case_name = thread.name + FROM thread + WHERE thread.id = test_case_result.test_case_id + """) + + +def downgrade() -> None: + op.drop_column('test_case_result', 'test_case_name') diff --git a/src/backend/alembic/versions/20251107-43b41d53f861-make_test_case_id_nullable.py b/src/backend/alembic/versions/20251107-43b41d53f861-make_test_case_id_nullable.py new file mode 100644 index 0000000..fd74f6e --- /dev/null +++ b/src/backend/alembic/versions/20251107-43b41d53f861-make_test_case_id_nullable.py @@ -0,0 +1,33 @@ +"""make_test_case_id_nullable + +Revision ID: 43b41d53f861 +Revises: 9b4a5352b79c +Create Date: 2025-11-07 11:35:15.117154 + +""" +import sqlalchemy as sa +from typing import Sequence, Union +from alembic import op + + +# revision identifiers, used by Alembic. +revision: str = '43b41d53f861' +down_revision: Union[str, None] = '9b4a5352b79c' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.alter_column('test_case_result', 'test_case_id', + existing_type=sa.INTEGER(), + nullable=True) + + +def downgrade() -> None: + op.execute(""" + DELETE FROM test_case_result + WHERE test_case_id IS NULL + """) + op.alter_column('test_case_result', 'test_case_id', + existing_type=sa.INTEGER(), + nullable=False) diff --git a/src/backend/alembic/versions/20251117-f688dc4a2a9a-fix_global_team_name.py b/src/backend/alembic/versions/20251117-f688dc4a2a9a-fix_global_team_name.py new file mode 100644 index 0000000..88fb265 --- /dev/null +++ b/src/backend/alembic/versions/20251117-f688dc4a2a9a-fix_global_team_name.py @@ -0,0 +1,32 @@ +"""fix-global-team-name + +Revision ID: f688dc4a2a9a +Revises: 07fd1bcafad1 +Create Date: 2025-11-17 14:10:08.496924 + +""" +from typing import Sequence, Union +from alembic import op + + +# revision identifiers, used by Alembic. +revision: str = 'f688dc4a2a9a' +down_revision: Union[str, None] = '07fd1bcafad1' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute(""" + UPDATE team + SET name = 'Global' + WHERE id = 1 + """) + + +def downgrade() -> None: + op.execute(""" + UPDATE team + SET name = 'Default' + WHERE id = 1 + """) diff --git a/src/backend/alembic/versions/20251119-897df88a29c9-threadmessage_status_update.py b/src/backend/alembic/versions/20251119-897df88a29c9-threadmessage_status_update.py new file mode 100644 index 0000000..da2a865 --- /dev/null +++ b/src/backend/alembic/versions/20251119-897df88a29c9-threadmessage_status_update.py @@ -0,0 +1,25 @@ +"""threadmessage-status-update + +Revision ID: 897df88a29c9 +Revises: 43b41d53f861 +Create Date: 2025-11-19 13:43:35.798312 + +""" +import sqlalchemy as sa +from typing import Sequence, Union +from alembic import op + + +# revision identifiers, used by Alembic. +revision: str = '897df88a29c9' +down_revision: Union[str, None] = '43b41d53f861' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column('thread_message', sa.Column('status_updates', sa.JSON(), nullable=True)) + + +def downgrade() -> None: + op.drop_column('thread_message', 'status_updates') diff --git a/src/backend/alembic/versions/20251122-8259088e28cf-remove_client_info_user_id.py b/src/backend/alembic/versions/20251122-8259088e28cf-remove_client_info_user_id.py new file mode 100644 index 0000000..e4104e3 --- /dev/null +++ b/src/backend/alembic/versions/20251122-8259088e28cf-remove_client_info_user_id.py @@ -0,0 +1,25 @@ +"""remove_client_info_user_id + +Revision ID: 8259088e28cf +Revises: 897df88a29c9 +Create Date: 2025-11-22 11:43:38.610593 + +""" +import sqlalchemy as sa +from typing import Sequence, Union +from alembic import op + + +# revision identifiers, used by Alembic. +revision: str = '8259088e28cf' +down_revision: Union[str, None] = '897df88a29c9' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.drop_column('tool_oauth_client_info', 'user_id') + + +def downgrade() -> None: + op.add_column('tool_oauth_client_info', sa.Column('user_id', sa.INTEGER(), autoincrement=False, nullable=False)) diff --git a/src/backend/alembic/versions/20251124-6733defeb310-add_team_editor_role.py b/src/backend/alembic/versions/20251124-6733defeb310-add_team_editor_role.py new file mode 100644 index 0000000..f8bb989 --- /dev/null +++ b/src/backend/alembic/versions/20251124-6733defeb310-add_team_editor_role.py @@ -0,0 +1,36 @@ +"""add_team_editor_role + +Revision ID: 6733defeb310 +Revises: 8259088e28cf +Create Date: 2025-11-24 14:37:28.062059 + +""" +from typing import Sequence, Union +from alembic import op +from alembic_postgresql_enum import TableReference + +# revision identifiers, used by Alembic. +revision: str = '6733defeb310' +down_revision: Union[str, None] = '8259088e28cf' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.sync_enum_values( # type: ignore + enum_schema='public', + enum_name='role', + new_values=['TEAM_OWNER', 'TEAM_MEMBER', 'TEAM_EDITOR'], + affected_columns=[TableReference(table_schema='public', table_name='team_role', column_name='role')], + enum_values_to_rename=[], + ) + + +def downgrade() -> None: + op.sync_enum_values( # type: ignore + enum_schema='public', + enum_name='role', + new_values=['TEAM_OWNER', 'TEAM_MEMBER'], + affected_columns=[TableReference(table_schema='public', table_name='team_role', column_name='role')], + enum_values_to_rename=[], + ) diff --git a/src/backend/alembic/versions/20251124-c759e1cf2817-background_suite_run.py b/src/backend/alembic/versions/20251124-c759e1cf2817-background_suite_run.py new file mode 100644 index 0000000..2a2dcbd --- /dev/null +++ b/src/backend/alembic/versions/20251124-c759e1cf2817-background_suite_run.py @@ -0,0 +1,132 @@ +"""background_suite_run + +Revision ID: c759e1cf2817 +Revises: 6733defeb310 +Create Date: 2025-11-24 15:54:42.406436 + +""" + +import sqlalchemy as sa +import sqlmodel +from typing import Sequence, Union +from alembic import op +from alembic_postgresql_enum import TableReference + +# revision identifiers, used by Alembic. +revision: str = "c759e1cf2817" +down_revision: Union[str, None] = "6733defeb310" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "test_suite_run_event", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("test_suite_run_id", sa.Integer(), nullable=False), + sa.Column("type", sqlmodel.AutoString(), nullable=False), + sa.Column("data", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint( + ["test_suite_run_id"], + ["test_suite_run.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + "ix_test_suite_run_event_test_suite_run_id_created_at", + "test_suite_run_event", + ["test_suite_run_id", "created_at"], + unique=False, + ) + op.sync_enum_values( # type: ignore[attr-defined] + enum_schema="public", + enum_name="testsuiterunstatus", + new_values=["RUNNING", "SUCCESS", "FAILURE", "CANCELLING"], + affected_columns=[ + TableReference( + table_schema="public", table_name="test_suite_run", column_name="status" + ) + ], + enum_values_to_rename=[], + ) + + op.execute( + """ + CREATE OR REPLACE FUNCTION notify_test_suite_run_event() RETURNS TRIGGER AS $$ + BEGIN + PERFORM pg_notify( + 'test_suite_events', + json_build_object( + 'suite_run_id', NEW.test_suite_run_id, + 'event_id', NEW.id + )::text + ); + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + """ + ) + + op.execute( + """ + CREATE TRIGGER after_insert_test_suite_run_event + AFTER INSERT ON test_suite_run_event + FOR EACH ROW EXECUTE FUNCTION notify_test_suite_run_event(); + """ + ) + + op.execute( + """ + CREATE OR REPLACE FUNCTION notify_test_suite_run_status() RETURNS TRIGGER AS $$ + BEGIN + PERFORM pg_notify( + 'test_suite_status', + json_build_object( + 'suite_run_id', NEW.id, + 'status', NEW.status + )::text + ); + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + """ + ) + + op.execute( + """ + CREATE TRIGGER after_update_test_suite_run_status + AFTER UPDATE OF status ON test_suite_run + FOR EACH ROW + WHEN (OLD.status IS DISTINCT FROM NEW.status) + EXECUTE FUNCTION notify_test_suite_run_status(); + """ + ) + + +def downgrade() -> None: + op.execute( + "DROP TRIGGER IF EXISTS after_update_test_suite_run_status ON test_suite_run" + ) + op.execute("DROP FUNCTION IF EXISTS notify_test_suite_run_status()") + op.execute( + "DROP TRIGGER IF EXISTS after_insert_test_suite_run_event ON test_suite_run_event" + ) + op.execute("DROP FUNCTION IF EXISTS notify_test_suite_run_event()") + + op.sync_enum_values( # type: ignore[attr-defined] + enum_schema="public", + enum_name="testsuiterunstatus", + new_values=["RUNNING", "SUCCESS", "FAILURE"], + affected_columns=[ + TableReference( + table_schema="public", table_name="test_suite_run", column_name="status" + ) + ], + enum_values_to_rename=[], + ) + op.drop_index( + "ix_test_suite_run_event_test_suite_run_id_created_at", + table_name="test_suite_run_event", + ) + op.drop_table("test_suite_run_event") diff --git a/src/backend/alembic/versions/20251204-4f3f4b5477dd-optional_oauth_client_secret.py b/src/backend/alembic/versions/20251204-4f3f4b5477dd-optional_oauth_client_secret.py new file mode 100644 index 0000000..aa778f1 --- /dev/null +++ b/src/backend/alembic/versions/20251204-4f3f4b5477dd-optional_oauth_client_secret.py @@ -0,0 +1,30 @@ +"""optional_oauth_client_secret + +Revision ID: 4f3f4b5477dd +Revises: c759e1cf2817 +Create Date: 2025-12-04 10:29:06.934279 + +""" +import sqlalchemy as sa +from typing import Sequence, Union +from alembic import op + + +# revision identifiers, used by Alembic. +revision: str = '4f3f4b5477dd' +down_revision: Union[str, None] = 'c759e1cf2817' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.alter_column('tool_oauth_client_info', 'client_secret', + existing_type=sa.VARCHAR(), + nullable=True) + + +def downgrade() -> None: + op.execute("UPDATE tool_oauth_client_info SET client_secret = '' WHERE client_secret IS NULL") + op.alter_column('tool_oauth_client_info', 'client_secret', + existing_type=sa.VARCHAR(), + nullable=False) diff --git a/src/backend/poetry.lock b/src/backend/poetry.lock index 23d73f5..9ec97f8 100644 --- a/src/backend/poetry.lock +++ b/src/backend/poetry.lock @@ -2,14 +2,14 @@ [[package]] name = "aiofiles" -version = "24.1.0" +version = "25.1.0" description = "File support for asyncio." optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["main"] files = [ - {file = "aiofiles-24.1.0-py3-none-any.whl", hash = "sha256:b4ec55f4195e3eb5d7abd1bf7e061763e864dd4954231fb8539a0ef8bb8260e5"}, - {file = "aiofiles-24.1.0.tar.gz", hash = "sha256:22a075c9e5a3810f0c2e48f3008c94d68c65d763b9b03857924c99e57355166c"}, + {file = "aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695"}, + {file = "aiofiles-25.1.0.tar.gz", hash = "sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2"}, ] [[package]] @@ -184,14 +184,14 @@ typing-extensions = {version = ">=4.2", markers = "python_version < \"3.13\""} [[package]] name = "aiosmtplib" -version = "4.0.2" +version = "5.0.0" description = "asyncio SMTP client" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["main"] files = [ - {file = "aiosmtplib-4.0.2-py3-none-any.whl", hash = "sha256:72491f96e6de035c28d29870186782eccb2f651db9c5f8a32c9db689327f5742"}, - {file = "aiosmtplib-4.0.2.tar.gz", hash = "sha256:f0b4933e7270a8be2b588761e5b12b7334c11890ee91987c2fb057e72f566da6"}, + {file = "aiosmtplib-5.0.0-py3-none-any.whl", hash = "sha256:95eb0f81189780845363ab0627e7f130bca2d0060d46cd3eeb459f066eb7df32"}, + {file = "aiosmtplib-5.0.0.tar.gz", hash = "sha256:514ac11c31cb767c764077eb3c2eb2ae48df6f63f1e847aeb36119c4fc42b52d"}, ] [package.extras] @@ -219,14 +219,14 @@ docs = ["sphinx (==8.1.3)", "sphinx-mdinclude (==0.6.1)"] [[package]] name = "alembic" -version = "1.17.1" +version = "1.17.2" description = "A database migration tool for SQLAlchemy." optional = false python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "alembic-1.17.1-py3-none-any.whl", hash = "sha256:cbc2386e60f89608bb63f30d2d6cc66c7aaed1fe105bd862828600e5ad167023"}, - {file = "alembic-1.17.1.tar.gz", hash = "sha256:8a289f6778262df31571d29cca4c7fbacd2f0f582ea0816f4c399b6da7528486"}, + {file = "alembic-1.17.2-py3-none-any.whl", hash = "sha256:f483dd1fe93f6c5d49217055e4d15b905b425b6af906746abb35b69c1996c4e6"}, + {file = "alembic-1.17.2.tar.gz", hash = "sha256:bbe9751705c5e0f14877f02d46c53d10885e377e3d90eda810a016f9baa19e8e"}, ] [package.dependencies] @@ -285,6 +285,76 @@ typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} [package.extras] trio = ["trio (>=0.31.0)"] +[[package]] +name = "asyncpg" +version = "0.31.0" +description = "An asyncio PostgreSQL driver" +optional = false +python-versions = ">=3.9.0" +groups = ["main"] +files = [ + {file = "asyncpg-0.31.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:831712dd3cf117eec68575a9b50da711893fd63ebe277fc155ecae1c6c9f0f61"}, + {file = "asyncpg-0.31.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0b17c89312c2f4ccea222a3a6571f7df65d4ba2c0e803339bfc7bed46a96d3be"}, + {file = "asyncpg-0.31.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3faa62f997db0c9add34504a68ac2c342cfee4d57a0c3062fcf0d86c7f9cb1e8"}, + {file = "asyncpg-0.31.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ea599d45c361dfbf398cb67da7fd052affa556a401482d3ff1ee99bd68808a1"}, + {file = "asyncpg-0.31.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:795416369c3d284e1837461909f58418ad22b305f955e625a4b3a2521d80a5f3"}, + {file = "asyncpg-0.31.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a8d758dac9d2e723e173d286ef5e574f0b350ec00e9186fce84d0fc5f6a8e6b8"}, + {file = "asyncpg-0.31.0-cp310-cp310-win32.whl", hash = "sha256:2d076d42eb583601179efa246c5d7ae44614b4144bc1c7a683ad1222814ed095"}, + {file = "asyncpg-0.31.0-cp310-cp310-win_amd64.whl", hash = "sha256:9ea33213ac044171f4cac23740bed9a3805abae10e7025314cfbd725ec670540"}, + {file = "asyncpg-0.31.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:eee690960e8ab85063ba93af2ce128c0f52fd655fdff9fdb1a28df01329f031d"}, + {file = "asyncpg-0.31.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2657204552b75f8288de08ca60faf4a99a65deef3a71d1467454123205a88fab"}, + {file = "asyncpg-0.31.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a429e842a3a4b4ea240ea52d7fe3f82d5149853249306f7ff166cb9948faa46c"}, + {file = "asyncpg-0.31.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0807be46c32c963ae40d329b3a686356e417f674c976c07fa49f1b30303f109"}, + {file = "asyncpg-0.31.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e5d5098f63beeae93512ee513d4c0c53dc12e9aa2b7a1af5a81cddf93fe4e4da"}, + {file = "asyncpg-0.31.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37fc6c00a814e18eef51833545d1891cac9aa69140598bb076b4cd29b3e010b9"}, + {file = "asyncpg-0.31.0-cp311-cp311-win32.whl", hash = "sha256:5a4af56edf82a701aece93190cc4e094d2df7d33f6e915c222fb09efbb5afc24"}, + {file = "asyncpg-0.31.0-cp311-cp311-win_amd64.whl", hash = "sha256:480c4befbdf079c14c9ca43c8c5e1fe8b6296c96f1f927158d4f1e750aacc047"}, + {file = "asyncpg-0.31.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b44c31e1efc1c15188ef183f287c728e2046abb1d26af4d20858215d50d91fad"}, + {file = "asyncpg-0.31.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0c89ccf741c067614c9b5fc7f1fc6f3b61ab05ae4aaa966e6fd6b93097c7d20d"}, + {file = "asyncpg-0.31.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:12b3b2e39dc5470abd5e98c8d3373e4b1d1234d9fbdedf538798b2c13c64460a"}, + {file = "asyncpg-0.31.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:aad7a33913fb8bcb5454313377cc330fbb19a0cd5faa7272407d8a0c4257b671"}, + {file = "asyncpg-0.31.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3df118d94f46d85b2e434fd62c84cb66d5834d5a890725fe625f498e72e4d5ec"}, + {file = "asyncpg-0.31.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bd5b6efff3c17c3202d4b37189969acf8927438a238c6257f66be3c426beba20"}, + {file = "asyncpg-0.31.0-cp312-cp312-win32.whl", hash = "sha256:027eaa61361ec735926566f995d959ade4796f6a49d3bde17e5134b9964f9ba8"}, + {file = "asyncpg-0.31.0-cp312-cp312-win_amd64.whl", hash = "sha256:72d6bdcbc93d608a1158f17932de2321f68b1a967a13e014998db87a72ed3186"}, + {file = "asyncpg-0.31.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c204fab1b91e08b0f47e90a75d1b3c62174dab21f670ad6c5d0f243a228f015b"}, + {file = "asyncpg-0.31.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:54a64f91839ba59008eccf7aad2e93d6e3de688d796f35803235ea1c4898ae1e"}, + {file = "asyncpg-0.31.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0e0822b1038dc7253b337b0f3f676cadc4ac31b126c5d42691c39691962e403"}, + {file = "asyncpg-0.31.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bef056aa502ee34204c161c72ca1f3c274917596877f825968368b2c33f585f4"}, + {file = "asyncpg-0.31.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0bfbcc5b7ffcd9b75ab1558f00db2ae07db9c80637ad1b2469c43df79d7a5ae2"}, + {file = "asyncpg-0.31.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22bc525ebbdc24d1261ecbf6f504998244d4e3be1721784b5f64664d61fbe602"}, + {file = "asyncpg-0.31.0-cp313-cp313-win32.whl", hash = "sha256:f890de5e1e4f7e14023619399a471ce4b71f5418cd67a51853b9910fdfa73696"}, + {file = "asyncpg-0.31.0-cp313-cp313-win_amd64.whl", hash = "sha256:dc5f2fa9916f292e5c5c8b2ac2813763bcd7f58e130055b4ad8a0531314201ab"}, + {file = "asyncpg-0.31.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f6b56b91bb0ffc328c4e3ed113136cddd9deefdf5f79ab448598b9772831df44"}, + {file = "asyncpg-0.31.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:334dec28cf20d7f5bb9e45b39546ddf247f8042a690bff9b9573d00086e69cb5"}, + {file = "asyncpg-0.31.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98cc158c53f46de7bb677fd20c417e264fc02b36d901cc2a43bd6cb0dc6dbfd2"}, + {file = "asyncpg-0.31.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9322b563e2661a52e3cdbc93eed3be7748b289f792e0011cb2720d278b366ce2"}, + {file = "asyncpg-0.31.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19857a358fc811d82227449b7ca40afb46e75b33eb8897240c3839dd8b744218"}, + {file = "asyncpg-0.31.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ba5f8886e850882ff2c2ace5732300e99193823e8107e2c53ef01c1ebfa1e85d"}, + {file = "asyncpg-0.31.0-cp314-cp314-win32.whl", hash = "sha256:cea3a0b2a14f95834cee29432e4ddc399b95700eb1d51bbc5bfee8f31fa07b2b"}, + {file = "asyncpg-0.31.0-cp314-cp314-win_amd64.whl", hash = "sha256:04d19392716af6b029411a0264d92093b6e5e8285ae97a39957b9a9c14ea72be"}, + {file = "asyncpg-0.31.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bdb957706da132e982cc6856bb2f7b740603472b54c3ebc77fe60ea3e57e1bd2"}, + {file = "asyncpg-0.31.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6d11b198111a72f47154fa03b85799f9be63701e068b43f84ac25da0bda9cb31"}, + {file = "asyncpg-0.31.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18c83b03bc0d1b23e6230f5bf8d4f217dc9bc08644ce0502a9d91dc9e634a9c7"}, + {file = "asyncpg-0.31.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e009abc333464ff18b8f6fd146addffd9aaf63e79aa3bb40ab7a4c332d0c5e9e"}, + {file = "asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3b1fbcb0e396a5ca435a8826a87e5c2c2cc0c8c68eb6fadf82168056b0e53a8c"}, + {file = "asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8df714dba348efcc162d2adf02d213e5fab1bd9f557e1305633e851a61814a7a"}, + {file = "asyncpg-0.31.0-cp314-cp314t-win32.whl", hash = "sha256:1b41f1afb1033f2b44f3234993b15096ddc9cd71b21a42dbd87fc6a57b43d65d"}, + {file = "asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3"}, + {file = "asyncpg-0.31.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ebb3cde58321a1f89ce41812be3f2a98dddedc1e76d0838aba1d724f1e4e1a95"}, + {file = "asyncpg-0.31.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e6974f36eb9a224d8fb428bcf66bd411aa12cf57c2967463178149e73d4de366"}, + {file = "asyncpg-0.31.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bc2b685f400ceae428f79f78b58110470d7b4466929a7f78d455964b17ad1008"}, + {file = "asyncpg-0.31.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bb223567dea5f47c45d347f2bde5486be8d9f40339f27217adb3fb1c3be51298"}, + {file = "asyncpg-0.31.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:22be6e02381bab3101cd502d9297ac71e2f966c86e20e78caead9934c98a8af6"}, + {file = "asyncpg-0.31.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:37a58919cfef2448a920df00d1b2f821762d17194d0dbf355d6dde8d952c04f9"}, + {file = "asyncpg-0.31.0-cp39-cp39-win32.whl", hash = "sha256:c1a9c5b71d2371a2290bc93336cd05ba4ec781683cab292adbddc084f89443c6"}, + {file = "asyncpg-0.31.0-cp39-cp39-win_amd64.whl", hash = "sha256:c1e1ab5bc65373d92dd749d7308c5b26fb2dc0fbe5d3bf68a32b676aa3bcd24a"}, + {file = "asyncpg-0.31.0.tar.gz", hash = "sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735"}, +] + +[package.extras] +gssauth = ["gssapi ; platform_system != \"Windows\"", "sspilib ; platform_system == \"Windows\""] + [[package]] name = "attrs" version = "25.4.0" @@ -684,62 +754,79 @@ markers = {main = "platform_system == \"Windows\"", dev = "sys_platform == \"win [[package]] name = "cryptography" -version = "45.0.7" +version = "46.0.3" description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." optional = false -python-versions = "!=3.9.0,!=3.9.1,>=3.7" -groups = ["main"] -files = [ - {file = "cryptography-45.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:3be4f21c6245930688bd9e162829480de027f8bf962ede33d4f8ba7d67a00cee"}, - {file = "cryptography-45.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:67285f8a611b0ebc0857ced2081e30302909f571a46bfa7a3cc0ad303fe015c6"}, - {file = "cryptography-45.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:577470e39e60a6cd7780793202e63536026d9b8641de011ed9d8174da9ca5339"}, - {file = "cryptography-45.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4bd3e5c4b9682bc112d634f2c6ccc6736ed3635fc3319ac2bb11d768cc5a00d8"}, - {file = "cryptography-45.0.7-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:465ccac9d70115cd4de7186e60cfe989de73f7bb23e8a7aa45af18f7412e75bf"}, - {file = "cryptography-45.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:16ede8a4f7929b4b7ff3642eba2bf79aa1d71f24ab6ee443935c0d269b6bc513"}, - {file = "cryptography-45.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8978132287a9d3ad6b54fcd1e08548033cc09dc6aacacb6c004c73c3eb5d3ac3"}, - {file = "cryptography-45.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b6a0e535baec27b528cb07a119f321ac024592388c5681a5ced167ae98e9fff3"}, - {file = "cryptography-45.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a24ee598d10befaec178efdff6054bc4d7e883f615bfbcd08126a0f4931c83a6"}, - {file = "cryptography-45.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:fa26fa54c0a9384c27fcdc905a2fb7d60ac6e47d14bc2692145f2b3b1e2cfdbd"}, - {file = "cryptography-45.0.7-cp311-abi3-win32.whl", hash = "sha256:bef32a5e327bd8e5af915d3416ffefdbe65ed975b646b3805be81b23580b57b8"}, - {file = "cryptography-45.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:3808e6b2e5f0b46d981c24d79648e5c25c35e59902ea4391a0dcb3e667bf7443"}, - {file = "cryptography-45.0.7-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:bfb4c801f65dd61cedfc61a83732327fafbac55a47282e6f26f073ca7a41c3b2"}, - {file = "cryptography-45.0.7-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:81823935e2f8d476707e85a78a405953a03ef7b7b4f55f93f7c2d9680e5e0691"}, - {file = "cryptography-45.0.7-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3994c809c17fc570c2af12c9b840d7cea85a9fd3e5c0e0491f4fa3c029216d59"}, - {file = "cryptography-45.0.7-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dad43797959a74103cb59c5dac71409f9c27d34c8a05921341fb64ea8ccb1dd4"}, - {file = "cryptography-45.0.7-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ce7a453385e4c4693985b4a4a3533e041558851eae061a58a5405363b098fcd3"}, - {file = "cryptography-45.0.7-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b04f85ac3a90c227b6e5890acb0edbaf3140938dbecf07bff618bf3638578cf1"}, - {file = "cryptography-45.0.7-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:48c41a44ef8b8c2e80ca4527ee81daa4c527df3ecbc9423c41a420a9559d0e27"}, - {file = "cryptography-45.0.7-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:f3df7b3d0f91b88b2106031fd995802a2e9ae13e02c36c1fc075b43f420f3a17"}, - {file = "cryptography-45.0.7-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:dd342f085542f6eb894ca00ef70236ea46070c8a13824c6bde0dfdcd36065b9b"}, - {file = "cryptography-45.0.7-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1993a1bb7e4eccfb922b6cd414f072e08ff5816702a0bdb8941c247a6b1b287c"}, - {file = "cryptography-45.0.7-cp37-abi3-win32.whl", hash = "sha256:18fcf70f243fe07252dcb1b268a687f2358025ce32f9f88028ca5c364b123ef5"}, - {file = "cryptography-45.0.7-cp37-abi3-win_amd64.whl", hash = "sha256:7285a89df4900ed3bfaad5679b1e668cb4b38a8de1ccbfc84b05f34512da0a90"}, - {file = "cryptography-45.0.7-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:de58755d723e86175756f463f2f0bddd45cc36fbd62601228a3f8761c9f58252"}, - {file = "cryptography-45.0.7-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:a20e442e917889d1a6b3c570c9e3fa2fdc398c20868abcea268ea33c024c4083"}, - {file = "cryptography-45.0.7-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:258e0dff86d1d891169b5af222d362468a9570e2532923088658aa866eb11130"}, - {file = "cryptography-45.0.7-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d97cf502abe2ab9eff8bd5e4aca274da8d06dd3ef08b759a8d6143f4ad65d4b4"}, - {file = "cryptography-45.0.7-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:c987dad82e8c65ebc985f5dae5e74a3beda9d0a2a4daf8a1115f3772b59e5141"}, - {file = "cryptography-45.0.7-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:c13b1e3afd29a5b3b2656257f14669ca8fa8d7956d509926f0b130b600b50ab7"}, - {file = "cryptography-45.0.7-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:4a862753b36620af6fc54209264f92c716367f2f0ff4624952276a6bbd18cbde"}, - {file = "cryptography-45.0.7-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:06ce84dc14df0bf6ea84666f958e6080cdb6fe1231be2a51f3fc1267d9f3fb34"}, - {file = "cryptography-45.0.7-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:d0c5c6bac22b177bf8da7435d9d27a6834ee130309749d162b26c3105c0795a9"}, - {file = "cryptography-45.0.7-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:2f641b64acc00811da98df63df7d59fd4706c0df449da71cb7ac39a0732b40ae"}, - {file = "cryptography-45.0.7-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:f5414a788ecc6ee6bc58560e85ca624258a55ca434884445440a810796ea0e0b"}, - {file = "cryptography-45.0.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:1f3d56f73595376f4244646dd5c5870c14c196949807be39e79e7bd9bac3da63"}, - {file = "cryptography-45.0.7.tar.gz", hash = "sha256:4b1654dfc64ea479c242508eb8c724044f1e964a47d1d1cacc5132292d851971"}, +python-versions = "!=3.9.0,!=3.9.1,>=3.8" +groups = ["main"] +files = [ + {file = "cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a"}, + {file = "cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc"}, + {file = "cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d"}, + {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb"}, + {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849"}, + {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8"}, + {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec"}, + {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91"}, + {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e"}, + {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926"}, + {file = "cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71"}, + {file = "cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac"}, + {file = "cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018"}, + {file = "cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb"}, + {file = "cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c"}, + {file = "cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217"}, + {file = "cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5"}, + {file = "cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715"}, + {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54"}, + {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459"}, + {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422"}, + {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7"}, + {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044"}, + {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665"}, + {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3"}, + {file = "cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20"}, + {file = "cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de"}, + {file = "cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914"}, + {file = "cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db"}, + {file = "cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21"}, + {file = "cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936"}, + {file = "cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683"}, + {file = "cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d"}, + {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0"}, + {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc"}, + {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3"}, + {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971"}, + {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac"}, + {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04"}, + {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506"}, + {file = "cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963"}, + {file = "cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4"}, + {file = "cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df"}, + {file = "cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f"}, + {file = "cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372"}, + {file = "cryptography-46.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a23582810fedb8c0bc47524558fb6c56aac3fc252cb306072fd2815da2a47c32"}, + {file = "cryptography-46.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:e7aec276d68421f9574040c26e2a7c3771060bc0cff408bae1dcb19d3ab1e63c"}, + {file = "cryptography-46.0.3-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7ce938a99998ed3c8aa7e7272dca1a610401ede816d36d0693907d863b10d9ea"}, + {file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:191bb60a7be5e6f54e30ba16fdfae78ad3a342a0599eb4193ba88e3f3d6e185b"}, + {file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c70cc23f12726be8f8bc72e41d5065d77e4515efae3690326764ea1b07845cfb"}, + {file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:9394673a9f4de09e28b5356e7fff97d778f8abad85c9d5ac4a4b7e25a0de7717"}, + {file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:94cd0549accc38d1494e1f8de71eca837d0509d0d44bf11d158524b0e12cebf9"}, + {file = "cryptography-46.0.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6b5063083824e5509fdba180721d55909ffacccc8adbec85268b48439423d78c"}, + {file = "cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1"}, ] [package.dependencies] -cffi = {version = ">=1.14", markers = "platform_python_implementation != \"PyPy\""} +cffi = {version = ">=2.0.0", markers = "python_full_version >= \"3.9.0\" and platform_python_implementation != \"PyPy\""} [package.extras] -docs = ["sphinx (>=5.3.0)", "sphinx-inline-tabs ; python_full_version >= \"3.8.0\"", "sphinx-rtd-theme (>=3.0.0) ; python_full_version >= \"3.8.0\""] +docs = ["sphinx (>=5.3.0)", "sphinx-inline-tabs", "sphinx-rtd-theme (>=3.0.0)"] docstest = ["pyenchant (>=3)", "readme-renderer (>=30.0)", "sphinxcontrib-spelling (>=7.3.1)"] -nox = ["nox (>=2024.4.15)", "nox[uv] (>=2024.3.2) ; python_full_version >= \"3.8.0\""] -pep8test = ["check-sdist ; python_full_version >= \"3.8.0\"", "click (>=8.0.1)", "mypy (>=1.4)", "ruff (>=0.3.6)"] +nox = ["nox[uv] (>=2024.4.15)"] +pep8test = ["check-sdist", "click (>=8.0.1)", "mypy (>=1.14)", "ruff (>=0.11.11)"] sdist = ["build (>=1.0.0)"] ssh = ["bcrypt (>=3.1.5)"] -test = ["certifi (>=2024)", "cryptography-vectors (==45.0.7)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"] +test = ["certifi (>=2024)", "cryptography-vectors (==46.0.3)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"] test-randomorder = ["pytest-randomly"] [[package]] @@ -1324,6 +1411,7 @@ files = [ {file = "greenlet-3.2.4-cp39-cp39-win_amd64.whl", hash = "sha256:d2e685ade4dafd447ede19c31277a224a239a0a1a4eca4e6390efedf20260cfb"}, {file = "greenlet-3.2.4.tar.gz", hash = "sha256:0dca0d95ff849f9a364385f36ab49f50065d76964944638be9691e1832e9f86d"}, ] +markers = {dev = "python_version == \"3.12\""} [package.extras] docs = ["Sphinx", "furo"] @@ -1849,30 +1937,25 @@ referencing = ">=0.31.0" [[package]] name = "langchain" -version = "0.3.27" +version = "1.1.0" description = "Building applications with LLMs through composability" optional = false -python-versions = "<4.0,>=3.9" +python-versions = "<4.0.0,>=3.10.0" groups = ["main"] files = [ - {file = "langchain-0.3.27-py3-none-any.whl", hash = "sha256:7b20c4f338826acb148d885b20a73a16e410ede9ee4f19bb02011852d5f98798"}, - {file = "langchain-0.3.27.tar.gz", hash = "sha256:aa6f1e6274ff055d0fd36254176770f356ed0a8994297d1df47df341953cec62"}, + {file = "langchain-1.1.0-py3-none-any.whl", hash = "sha256:af080f3a4a779bfa5925de7aacb6dfab83249d4aab9a08f7aa7b9bec3766d8ea"}, + {file = "langchain-1.1.0.tar.gz", hash = "sha256:583c892f59873c0329dbe04169fb3234ac794c50780e7c6fb62a61c7b86a981b"}, ] [package.dependencies] -langchain-core = ">=0.3.72,<1.0.0" -langchain-text-splitters = ">=0.3.9,<1.0.0" -langsmith = ">=0.1.17" +langchain-core = ">=1.1.0,<2.0.0" +langgraph = ">=1.0.2,<1.1.0" pydantic = ">=2.7.4,<3.0.0" -PyYAML = ">=5.3" -requests = ">=2,<3" -SQLAlchemy = ">=1.4,<3" [package.extras] anthropic = ["langchain-anthropic"] aws = ["langchain-aws"] azure-ai = ["langchain-azure-ai"] -cohere = ["langchain-cohere"] community = ["langchain-community"] deepseek = ["langchain-deepseek"] fireworks = ["langchain-fireworks"] @@ -1889,43 +1972,79 @@ xai = ["langchain-xai"] [[package]] name = "langchain-aws" -version = "0.2.35" +version = "1.1.0" description = "An integration package connecting AWS and LangChain" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["main"] files = [ - {file = "langchain_aws-0.2.35-py3-none-any.whl", hash = "sha256:8ddb10f3c29f6d52bcbaa4d7f4f56462acf01f608adc7c70f41e5a476899a6bc"}, - {file = "langchain_aws-0.2.35.tar.gz", hash = "sha256:45793a34fe45d365f4292cc768db74669ca24601d2c5da1ac6f44403750d70af"}, + {file = "langchain_aws-1.1.0-py3-none-any.whl", hash = "sha256:8ec074615b42839e035354063717374c32c63f5028ef5221ba073fd5f3ef5e37"}, + {file = "langchain_aws-1.1.0.tar.gz", hash = "sha256:1e2f8570328eae4907c3cf7e900dc68d8034ddc865d9dc96823c9f9d8cccb901"}, ] [package.dependencies] -boto3 = ">=1.39.7" -langchain-core = ">=0.3.76,<0.4.0" -numpy = {version = ">=1.26.0,<3", markers = "python_version >= \"3.12\""} -pydantic = ">=2.10.0,<3" +boto3 = ">=1.40.19" +langchain-core = ">=1.1.0" +numpy = {version = ">=2.3.2,<3", markers = "python_version >= \"3.12\""} +pydantic = ">=2.10.6,<3" [package.extras] tools = ["beautifulsoup4 (>=4.13.4)", "bedrock-agentcore (>=0.1.0) ; python_version >= \"3.10\"", "playwright (>=1.53.0)"] +[[package]] +name = "langchain-classic" +version = "1.0.0" +description = "Building applications with LLMs through composability" +optional = false +python-versions = "<4.0.0,>=3.10.0" +groups = ["main"] +files = [ + {file = "langchain_classic-1.0.0-py3-none-any.whl", hash = "sha256:97f71f150c10123f5511c08873f030e35ede52311d729a7688c721b4e1e01f33"}, + {file = "langchain_classic-1.0.0.tar.gz", hash = "sha256:a63655609254ebc36d660eb5ad7c06c778b2e6733c615ffdac3eac4fbe2b12c5"}, +] + +[package.dependencies] +langchain-core = ">=1.0.0,<2.0.0" +langchain-text-splitters = ">=1.0.0,<2.0.0" +langsmith = ">=0.1.17,<1.0.0" +pydantic = ">=2.7.4,<3.0.0" +pyyaml = ">=5.3.0,<7.0.0" +requests = ">=2.0.0,<3.0.0" +sqlalchemy = ">=1.4.0,<3.0.0" + +[package.extras] +anthropic = ["langchain-anthropic"] +aws = ["langchain-aws"] +deepseek = ["langchain-deepseek"] +fireworks = ["langchain-fireworks"] +google-genai = ["langchain-google-genai"] +google-vertexai = ["langchain-google-vertexai"] +groq = ["langchain-groq"] +mistralai = ["langchain-mistralai"] +ollama = ["langchain-ollama"] +openai = ["langchain-openai"] +perplexity = ["langchain-perplexity"] +together = ["langchain-together"] +xai = ["langchain-xai"] + [[package]] name = "langchain-community" -version = "0.3.31" +version = "0.4.1" description = "Community contributed LangChain integrations." optional = false -python-versions = "<4.0.0,>=3.9.0" +python-versions = "<4.0.0,>=3.10.0" groups = ["main"] files = [ - {file = "langchain_community-0.3.31-py3-none-any.whl", hash = "sha256:1c727e3ebbacd4d891b07bd440647668001cea3e39cbe732499ad655ec5cb569"}, - {file = "langchain_community-0.3.31.tar.gz", hash = "sha256:250e4c1041539130f6d6ac6f9386cb018354eafccd917b01a4cff1950b80fd81"}, + {file = "langchain_community-0.4.1-py3-none-any.whl", hash = "sha256:2135abb2c7748a35c84613108f7ebf30f8505b18c3c18305ffaecfc7651f6c6a"}, + {file = "langchain_community-0.4.1.tar.gz", hash = "sha256:f3b211832728ee89f169ddce8579b80a085222ddb4f4ed445a46e977d17b1e85"}, ] [package.dependencies] aiohttp = ">=3.8.3,<4.0.0" dataclasses-json = ">=0.6.7,<0.7.0" httpx-sse = ">=0.4.0,<1.0.0" -langchain = ">=0.3.27,<2.0.0" -langchain-core = ">=0.3.78,<2.0.0" +langchain-classic = ">=1.0.0,<2.0.0" +langchain-core = ">=1.0.1,<2.0.0" langsmith = ">=0.1.125,<1.0.0" numpy = [ {version = ">=1.26.2", markers = "python_version < \"3.13\""}, @@ -1939,14 +2058,14 @@ tenacity = ">=8.1.0,<8.4.0 || >8.4.0,<10.0.0" [[package]] name = "langchain-core" -version = "0.3.79" +version = "1.1.0" description = "Building applications with LLMs through composability" optional = false -python-versions = "<4.0.0,>=3.9.0" +python-versions = "<4.0.0,>=3.10.0" groups = ["main"] files = [ - {file = "langchain_core-0.3.79-py3-none-any.whl", hash = "sha256:92045bfda3e741f8018e1356f83be203ec601561c6a7becfefe85be5ddc58fdb"}, - {file = "langchain_core-0.3.79.tar.gz", hash = "sha256:024ba54a346dd9b13fb8b2342e0c83d0111e7f26fa01f545ada23ad772b55a60"}, + {file = "langchain_core-1.1.0-py3-none-any.whl", hash = "sha256:2c9f27dadc6d21ed4aa46506a37a56e6a7e2d2f9141922dc5c251ba921822ee6"}, + {file = "langchain_core-1.1.0.tar.gz", hash = "sha256:2b76a82d427922c8bc51c08404af4fc2a29e9f161dfe2297cb05091e810201e7"}, ] [package.dependencies] @@ -1954,63 +2073,62 @@ jsonpatch = ">=1.33.0,<2.0.0" langsmith = ">=0.3.45,<1.0.0" packaging = ">=23.2.0,<26.0.0" pydantic = ">=2.7.4,<3.0.0" -PyYAML = ">=5.3.0,<7.0.0" +pyyaml = ">=5.3.0,<7.0.0" tenacity = ">=8.1.0,<8.4.0 || >8.4.0,<10.0.0" typing-extensions = ">=4.7.0,<5.0.0" [[package]] name = "langchain-google-community" -version = "2.0.10" +version = "3.0.1" description = "An integration package connecting miscellaneous Google's products and LangChain" optional = false -python-versions = ">=3.9" +python-versions = "<3.14.0,>=3.10.0" groups = ["main"] files = [ - {file = "langchain_google_community-2.0.10-py3-none-any.whl", hash = "sha256:9afb3eba04359670ba5797efc2ed8a1749a3de706a17c21f85173b31e4b5474a"}, - {file = "langchain_google_community-2.0.10.tar.gz", hash = "sha256:5476adfa3b64cc2ce52c1fd514d0b1eab8775c4416a9dc555423f387fafd842f"}, + {file = "langchain_google_community-3.0.1-py3-none-any.whl", hash = "sha256:b75737f87e47ea102bd77877932fe4e34b0a85f50e9d587a391a14e35f053ffd"}, + {file = "langchain_google_community-3.0.1.tar.gz", hash = "sha256:b086c2b7d732872b93d99d6a0d17285d0028519ca0cbffb5b17c99eef295d65c"}, ] [package.dependencies] -google-api-core = ">=2.25,<3" -google-api-python-client = ">=2.161,<3" -google-cloud-core = ">=2.4.3,<3" -google-cloud-modelarmor = ">=0.2.8" -grpcio = ">=1.74,<2" -langchain-community = ">=0.3,<1" -langchain-core = ">=0.3,<1" +google-api-core = ">=2.25.0,<3.0.0" +google-api-python-client = ">=2.161.0,<3.0.0" +google-cloud-core = ">=2.4.3,<3.0.0" +google-cloud-modelarmor = ">=0.2.8,<1.0.0" +grpcio = ">=1.74.0,<2.0.0" +langchain-community = ">=0.4.0,<2.0.0" +langchain-core = ">=1.0.0,<2.0.0" [package.extras] -bigquery = ["google-cloud-bigquery (>=3.21,<4)"] -calendar = ["google-auth (>=2.36,<3)", "google-auth-oauthlib (>=1.2,<2)"] -docai = ["gapic-google-longrunning (>=0.11.2,<1)", "google-cloud-contentwarehouse (>=0.7.7,<1)", "google-cloud-documentai (>=2.26,<3)", "google-cloud-documentai-toolbox (>=0.13.3a0,<1)"] -drive = ["google-auth-httplib2 (>=0.2,<1)", "google-auth-oauthlib (>=1.2,<2)"] -featurestore = ["db-dtypes (>=1.2.0,<2)", "google-cloud-aiplatform (>=1.56.0,<2)", "google-cloud-bigquery-storage (>=2.6.0,<3)", "pandas (>=1.0.0) ; python_version < \"3.12\"", "pandas (>=2.0.0,<3.0) ; python_version >= \"3.12\"", "pyarrow (>=6.0.1)", "pydantic (>=2.7.4,<3)"] -gcs = ["google-cloud-storage (>=2.16,<3)"] -gmail = ["beautifulsoup4 (>=4.12.3,<5)", "google-auth-httplib2 (>=0.2,<1)", "google-auth-oauthlib (>=1.2,<2)"] -places = ["googlemaps (>=4.10,<5)"] -speech = ["google-cloud-speech (>=2.26,<3)"] -texttospeech = ["google-cloud-texttospeech (>=2.16.3,<3)"] -translate = ["google-cloud-translate (>=3.15.3,<4)"] -vertexaisearch = ["google-cloud-discoveryengine (>=0.11.14,<1)"] -vision = ["google-cloud-vision (>=3.7.2,<4)"] +calendar = ["google-auth (>=2.36.0,<3.0.0)", "google-auth-oauthlib (>=1.2.0,<2.0.0)"] +docai = ["gapic-google-longrunning (>=0.11.2,<1.0.0)", "google-cloud-contentwarehouse (>=0.7.7,<1.0.0)", "google-cloud-documentai (>=2.26.0,<3.0.0)"] +drive = ["google-auth-httplib2 (>=0.2.0,<1.0.0)", "google-auth-oauthlib (>=1.2.0,<2.0.0)"] +featurestore = ["db-dtypes (>=1.2.0,<2.0.0)", "google-cloud-aiplatform (>=1.56.0,<2.0.0)", "google-cloud-bigquery-storage (>=2.6.0,<3.0.0)", "pandas (>=1.0.0) ; python_version < \"3.12\"", "pandas (>=2.0.0,<3.0.0) ; python_version >= \"3.12\"", "pyarrow (>=6.0.1)", "pydantic (>=2.7.4,<3.0.0)"] +gcs = ["google-cloud-storage (>=2.16.0,<4.0.0)"] +gmail = ["beautifulsoup4 (>=4.12.3,<5.0.0)", "google-auth-httplib2 (>=0.2.0,<1.0.0)", "google-auth-oauthlib (>=1.2.0,<2.0.0)"] +places = ["googlemaps (>=4.10.0,<5.0.0)"] +speech = ["google-cloud-speech (>=2.26.0,<3.0.0)"] +texttospeech = ["google-cloud-texttospeech (>=2.16.3,<3.0.0)"] +translate = ["google-cloud-translate (>=3.15.3,<4.0.0)"] +vertexaisearch = ["google-cloud-discoveryengine (>=0.11.14,<1.0.0)"] +vision = ["google-cloud-vision (>=3.7.2,<4.0.0)"] [[package]] name = "langchain-google-genai" -version = "2.1.12" +version = "3.2.0" description = "An integration package connecting Google's genai package and LangChain" optional = false -python-versions = ">=3.9" +python-versions = "<4.0.0,>=3.10.0" groups = ["main"] files = [ - {file = "langchain_google_genai-2.1.12-py3-none-any.whl", hash = "sha256:4c07630419a8fbe7a2ec512c6dea68289663bfe7d5fae0ba431d2cd59a0d0880"}, - {file = "langchain_google_genai-2.1.12.tar.gz", hash = "sha256:4a98371e545eb97fcdf483086a4aebbb8eceeb9597ca5a9c4c35e92f4fbbd271"}, + {file = "langchain_google_genai-3.2.0-py3-none-any.whl", hash = "sha256:689fc159d4623a184678e24771f6d52373e983a8fc8d342e44352aaf28e9445d"}, + {file = "langchain_google_genai-3.2.0.tar.gz", hash = "sha256:1fa620ea9c655a37537e95438857c423e1a3599b5a665b8dd87064c76ee95b72"}, ] [package.dependencies] -filetype = ">=1.2,<2" -google-ai-generativelanguage = ">=0.7,<1" -langchain-core = ">=0.3.75" -pydantic = ">=2,<3" +filetype = ">=1.2.0,<2.0.0" +google-ai-generativelanguage = ">=0.9.0,<1.0.0" +langchain-core = ">=1.1.0,<2.0.0" +pydantic = ">=2.0.0,<3.0.0" [[package]] name = "langchain-mcp-adapters" @@ -2031,40 +2149,41 @@ typing-extensions = ">=4.14.0" [[package]] name = "langchain-openai" -version = "0.3.35" +version = "1.1.0" description = "An integration package connecting OpenAI and LangChain" optional = false -python-versions = "<4.0.0,>=3.9.0" +python-versions = "<4.0.0,>=3.10.0" groups = ["main"] files = [ - {file = "langchain_openai-0.3.35-py3-none-any.whl", hash = "sha256:76d5707e6e81fd461d33964ad618bd326cb661a1975cef7c1cb0703576bdada5"}, - {file = "langchain_openai-0.3.35.tar.gz", hash = "sha256:fa985fd041c3809da256a040c98e8a43e91c6d165b96dcfeb770d8bd457bf76f"}, + {file = "langchain_openai-1.1.0-py3-none-any.whl", hash = "sha256:243bb345d0260ea1326c2b6ac2237ec29f082ab457c59e9306bac349df4577e8"}, + {file = "langchain_openai-1.1.0.tar.gz", hash = "sha256:9a33280c2e8315d013d64e6b15e583be347beb0d0f281755c335ae504ad0c184"}, ] [package.dependencies] -langchain-core = ">=0.3.78,<1.0.0" -openai = ">=1.104.2,<3.0.0" +langchain-core = ">=1.1.0,<2.0.0" +openai = ">=1.109.1,<3.0.0" tiktoken = ">=0.7.0,<1.0.0" [[package]] name = "langchain-postgres" -version = "0.0.13" +version = "0.0.16" description = "An integration package connecting Postgres and LangChain" optional = false -python-versions = "<4.0,>=3.9" +python-versions = ">=3.9" groups = ["main"] files = [ - {file = "langchain_postgres-0.0.13-py3-none-any.whl", hash = "sha256:91cb4e62862b1a1f36cdf8462e34990bc112d5824dfb738cab9ca6577cb27cee"}, - {file = "langchain_postgres-0.0.13.tar.gz", hash = "sha256:3a23f95aaeca9bf03af63cf6b9ef1381b6d2a83605179d307a6606b05e335ab1"}, + {file = "langchain_postgres-0.0.16-py3-none-any.whl", hash = "sha256:a7375cf9fc9b6965efc207dbcc959424e96b8ffe75d5ced6055676d2613f8d37"}, + {file = "langchain_postgres-0.0.16.tar.gz", hash = "sha256:d09aa4ea77ee8600a9ff64de9c185fb558aa388c816c7be04dd4559c878530b7"}, ] [package.dependencies] -langchain-core = ">=0.2.13,<0.4.0" -numpy = ">=1.21" -pgvector = "<0.4" -psycopg = ">=3,<4" -psycopg-pool = ">=3.2.1,<4.0.0" -sqlalchemy = ">=2,<3" +asyncpg = ">=0.30.0" +langchain-core = ">=0.2.13,<2.0" +numpy = ">=1.21,<3" +pgvector = ">=0.2.5,<0.4" +psycopg = {version = ">=3,<4", extras = ["binary"]} +psycopg-pool = ">=3.2.1,<4" +sqlalchemy = {version = ">=2,<3", extras = ["asyncio"]} [[package]] name = "langchain-tavily" @@ -2086,38 +2205,38 @@ requests = ">=2.32.3,<3.0.0" [[package]] name = "langchain-text-splitters" -version = "0.3.11" +version = "1.0.0" description = "LangChain text splitting utilities" optional = false -python-versions = ">=3.9" +python-versions = "<4.0.0,>=3.10.0" groups = ["main"] files = [ - {file = "langchain_text_splitters-0.3.11-py3-none-any.whl", hash = "sha256:cf079131166a487f1372c8ab5d0bfaa6c0a4291733d9c43a34a16ac9bcd6a393"}, - {file = "langchain_text_splitters-0.3.11.tar.gz", hash = "sha256:7a50a04ada9a133bbabb80731df7f6ddac51bc9f1b9cab7fa09304d71d38a6cc"}, + {file = "langchain_text_splitters-1.0.0-py3-none-any.whl", hash = "sha256:f00c8219d3468f2c5bd951b708b6a7dd9bc3c62d0cfb83124c377f7170f33b2e"}, + {file = "langchain_text_splitters-1.0.0.tar.gz", hash = "sha256:d8580a20ad7ed10b432feb273e5758b2cc0902d094919629cec0e1ad691a6744"}, ] [package.dependencies] -langchain-core = ">=0.3.75,<2.0.0" +langchain-core = ">=1.0.0,<2.0.0" [[package]] name = "langgraph" -version = "0.4.5" +version = "1.0.4" description = "Building stateful, multi-actor applications with LLMs" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["main"] files = [ - {file = "langgraph-0.4.5-py3-none-any.whl", hash = "sha256:73f36caae55137c2bdb2a6c59661f0ae29c1516a0d1f4ad4975ad3862865a979"}, - {file = "langgraph-0.4.5.tar.gz", hash = "sha256:08a8c6577b09cda4e0c16712e762927f00930dabbc7fe235562985ad85891349"}, + {file = "langgraph-1.0.4-py3-none-any.whl", hash = "sha256:b1a835ceb0a8d69b9db48075e1939e28b1ad70ee23fa3fa8f90149904778bacf"}, + {file = "langgraph-1.0.4.tar.gz", hash = "sha256:86d08e25d7244340f59c5200fa69fdd11066aa999b3164b531e2a20036fac156"}, ] [package.dependencies] -langchain-core = {version = ">=0.1", markers = "python_version < \"4.0\""} -langgraph-checkpoint = ">=2.0.26,<3.0.0" -langgraph-prebuilt = {version = ">=0.1.8", markers = "python_version < \"4.0\""} -langgraph-sdk = {version = ">=0.1.42", markers = "python_version < \"4.0\""} +langchain-core = ">=0.1" +langgraph-checkpoint = ">=2.1.0,<4.0.0" +langgraph-prebuilt = ">=1.0.2,<1.1.0" +langgraph-sdk = ">=0.2.2,<0.3.0" pydantic = ">=2.7.4" -xxhash = ">=3.5.0,<4.0.0" +xxhash = ">=3.5.0" [[package]] name = "langgraph-checkpoint" @@ -2137,19 +2256,19 @@ ormsgpack = ">=1.10.0" [[package]] name = "langgraph-prebuilt" -version = "0.1.8" +version = "1.0.5" description = "Library with high-level APIs for creating and executing LangGraph agents and tools." optional = false -python-versions = "<4.0.0,>=3.9.0" +python-versions = ">=3.10" groups = ["main"] files = [ - {file = "langgraph_prebuilt-0.1.8-py3-none-any.whl", hash = "sha256:ae97b828ae00be2cefec503423aa782e1bff165e9b94592e224da132f2526968"}, - {file = "langgraph_prebuilt-0.1.8.tar.gz", hash = "sha256:4de7659151829b2b955b6798df6800e580e617782c15c2c5b29b139697491831"}, + {file = "langgraph_prebuilt-1.0.5-py3-none-any.whl", hash = "sha256:22369563e1848862ace53fbc11b027c28dd04a9ac39314633bb95f2a7e258496"}, + {file = "langgraph_prebuilt-1.0.5.tar.gz", hash = "sha256:85802675ad778cc7240fd02d47db1e0b59c0c86d8369447d77ce47623845db2d"}, ] [package.dependencies] -langchain-core = ">=0.2.43,<0.3.0 || >0.3.0,<0.3.1 || >0.3.1,<0.3.2 || >0.3.2,<0.3.3 || >0.3.3,<0.3.4 || >0.3.4,<0.3.5 || >0.3.5,<0.3.6 || >0.3.6,<0.3.7 || >0.3.7,<0.3.8 || >0.3.8,<0.3.9 || >0.3.9,<0.3.10 || >0.3.10,<0.3.11 || >0.3.11,<0.3.12 || >0.3.12,<0.3.13 || >0.3.13,<0.3.14 || >0.3.14,<0.3.15 || >0.3.15,<0.3.16 || >0.3.16,<0.3.17 || >0.3.17,<0.3.18 || >0.3.18,<0.3.19 || >0.3.19,<0.3.20 || >0.3.20,<0.3.21 || >0.3.21,<0.3.22 || >0.3.22,<0.4.0" -langgraph-checkpoint = ">=2.0.10,<3.0.0" +langchain-core = ">=1.0.0" +langgraph-checkpoint = ">=2.1.0,<4.0.0" [[package]] name = "langgraph-sdk" @@ -2361,14 +2480,14 @@ tests = ["pytest", "simplejson"] [[package]] name = "mcp" -version = "1.19.0" +version = "1.22.0" description = "Model Context Protocol SDK" optional = false python-versions = ">=3.10" groups = ["main"] files = [ - {file = "mcp-1.19.0-py3-none-any.whl", hash = "sha256:f5907fe1c0167255f916718f376d05f09a830a215327a3ccdd5ec8a519f2e572"}, - {file = "mcp-1.19.0.tar.gz", hash = "sha256:213de0d3cd63f71bc08ffe9cc8d4409cc87acffd383f6195d2ce0457c021b5c1"}, + {file = "mcp-1.22.0-py3-none-any.whl", hash = "sha256:bed758e24df1ed6846989c909ba4e3df339a27b4f30f1b8b627862a4bade4e98"}, + {file = "mcp-1.22.0.tar.gz", hash = "sha256:769b9ac90ed42134375b19e777a2858ca300f95f2e800982b3e2be62dfc0ba01"}, ] [package.dependencies] @@ -2378,10 +2497,13 @@ httpx-sse = ">=0.4" jsonschema = ">=4.20.0" pydantic = ">=2.11.0,<3.0.0" pydantic-settings = ">=2.5.2" +pyjwt = {version = ">=2.10.1", extras = ["crypto"]} python-multipart = ">=0.0.9" pywin32 = {version = ">=310", markers = "sys_platform == \"win32\""} sse-starlette = ">=1.6.1" starlette = ">=0.27" +typing-extensions = ">=4.9.0" +typing-inspection = ">=0.4.1" uvicorn = {version = ">=0.31.1", markers = "sys_platform != \"emscripten\""} [package.extras] @@ -2667,28 +2789,28 @@ files = [ [[package]] name = "openai" -version = "1.109.1" +version = "2.8.1" description = "The official Python library for the openai API" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["main"] files = [ - {file = "openai-1.109.1-py3-none-any.whl", hash = "sha256:6bcaf57086cf59159b8e27447e4e7dd019db5d29a438072fbd49c290c7e65315"}, - {file = "openai-1.109.1.tar.gz", hash = "sha256:d173ed8dbca665892a6db099b4a2dfac624f94d20a93f46eb0b56aae940ed869"}, + {file = "openai-2.8.1-py3-none-any.whl", hash = "sha256:c6c3b5a04994734386e8dad3c00a393f56d3b68a27cd2e8acae91a59e4122463"}, + {file = "openai-2.8.1.tar.gz", hash = "sha256:cb1b79eef6e809f6da326a7ef6038719e35aa944c42d081807bfa1be8060f15f"}, ] [package.dependencies] anyio = ">=3.5.0,<5" distro = ">=1.7.0,<2" httpx = ">=0.23.0,<1" -jiter = ">=0.4.0,<1" +jiter = ">=0.10.0,<1" pydantic = ">=1.9.0,<3" sniffio = "*" tqdm = ">4" typing-extensions = ">=4.11,<5" [package.extras] -aiohttp = ["aiohttp", "httpx-aiohttp (>=0.1.8)"] +aiohttp = ["aiohttp", "httpx-aiohttp (>=0.1.9)"] datalib = ["numpy (>=1)", "pandas (>=1.2.3)", "pandas-stubs (>=1.1.0.11)"] realtime = ["websockets (>=13,<16)"] voice-helpers = ["numpy (>=2.0.2)", "sounddevice (>=0.5.1)"] @@ -2921,127 +3043,111 @@ numpy = "*" [[package]] name = "pillow" -version = "11.3.0" -description = "Python Imaging Library (Fork)" +version = "12.0.0" +description = "Python Imaging Library (fork)" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["main"] files = [ - {file = "pillow-11.3.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:1b9c17fd4ace828b3003dfd1e30bff24863e0eb59b535e8f80194d9cc7ecf860"}, - {file = "pillow-11.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:65dc69160114cdd0ca0f35cb434633c75e8e7fad4cf855177a05bf38678f73ad"}, - {file = "pillow-11.3.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7107195ddc914f656c7fc8e4a5e1c25f32e9236ea3ea860f257b0436011fddd0"}, - {file = "pillow-11.3.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc3e831b563b3114baac7ec2ee86819eb03caa1a2cef0b481a5675b59c4fe23b"}, - {file = "pillow-11.3.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f1f182ebd2303acf8c380a54f615ec883322593320a9b00438eb842c1f37ae50"}, - {file = "pillow-11.3.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4445fa62e15936a028672fd48c4c11a66d641d2c05726c7ec1f8ba6a572036ae"}, - {file = "pillow-11.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:71f511f6b3b91dd543282477be45a033e4845a40278fa8dcdbfdb07109bf18f9"}, - {file = "pillow-11.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:040a5b691b0713e1f6cbe222e0f4f74cd233421e105850ae3b3c0ceda520f42e"}, - {file = "pillow-11.3.0-cp310-cp310-win32.whl", hash = "sha256:89bd777bc6624fe4115e9fac3352c79ed60f3bb18651420635f26e643e3dd1f6"}, - {file = "pillow-11.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:19d2ff547c75b8e3ff46f4d9ef969a06c30ab2d4263a9e287733aa8b2429ce8f"}, - {file = "pillow-11.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:819931d25e57b513242859ce1876c58c59dc31587847bf74cfe06b2e0cb22d2f"}, - {file = "pillow-11.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:1cd110edf822773368b396281a2293aeb91c90a2db00d78ea43e7e861631b722"}, - {file = "pillow-11.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9c412fddd1b77a75aa904615ebaa6001f169b26fd467b4be93aded278266b288"}, - {file = "pillow-11.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1aa4de119a0ecac0a34a9c8bde33f34022e2e8f99104e47a3ca392fd60e37d"}, - {file = "pillow-11.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:91da1d88226663594e3f6b4b8c3c8d85bd504117d043740a8e0ec449087cc494"}, - {file = "pillow-11.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:643f189248837533073c405ec2f0bb250ba54598cf80e8c1e043381a60632f58"}, - {file = "pillow-11.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:106064daa23a745510dabce1d84f29137a37224831d88eb4ce94bb187b1d7e5f"}, - {file = "pillow-11.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd8ff254faf15591e724dc7c4ddb6bf4793efcbe13802a4ae3e863cd300b493e"}, - {file = "pillow-11.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:932c754c2d51ad2b2271fd01c3d121daaa35e27efae2a616f77bf164bc0b3e94"}, - {file = "pillow-11.3.0-cp311-cp311-win32.whl", hash = "sha256:b4b8f3efc8d530a1544e5962bd6b403d5f7fe8b9e08227c6b255f98ad82b4ba0"}, - {file = "pillow-11.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:1a992e86b0dd7aeb1f053cd506508c0999d710a8f07b4c791c63843fc6a807ac"}, - {file = "pillow-11.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:30807c931ff7c095620fe04448e2c2fc673fcbb1ffe2a7da3fb39613489b1ddd"}, - {file = "pillow-11.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fdae223722da47b024b867c1ea0be64e0df702c5e0a60e27daad39bf960dd1e4"}, - {file = "pillow-11.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:921bd305b10e82b4d1f5e802b6850677f965d8394203d182f078873851dada69"}, - {file = "pillow-11.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb76541cba2f958032d79d143b98a3a6b3ea87f0959bbe256c0b5e416599fd5d"}, - {file = "pillow-11.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67172f2944ebba3d4a7b54f2e95c786a3a50c21b88456329314caaa28cda70f6"}, - {file = "pillow-11.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f07ed9f56a3b9b5f49d3661dc9607484e85c67e27f3e8be2c7d28ca032fec7"}, - {file = "pillow-11.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:676b2815362456b5b3216b4fd5bd89d362100dc6f4945154ff172e206a22c024"}, - {file = "pillow-11.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3e184b2f26ff146363dd07bde8b711833d7b0202e27d13540bfe2e35a323a809"}, - {file = "pillow-11.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6be31e3fc9a621e071bc17bb7de63b85cbe0bfae91bb0363c893cbe67247780d"}, - {file = "pillow-11.3.0-cp312-cp312-win32.whl", hash = "sha256:7b161756381f0918e05e7cb8a371fff367e807770f8fe92ecb20d905d0e1c149"}, - {file = "pillow-11.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a6444696fce635783440b7f7a9fc24b3ad10a9ea3f0ab66c5905be1c19ccf17d"}, - {file = "pillow-11.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:2aceea54f957dd4448264f9bf40875da0415c83eb85f55069d89c0ed436e3542"}, - {file = "pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1c627742b539bba4309df89171356fcb3cc5a9178355b2727d1b74a6cf155fbd"}, - {file = "pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:30b7c02f3899d10f13d7a48163c8969e4e653f8b43416d23d13d1bbfdc93b9f8"}, - {file = "pillow-11.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7859a4cc7c9295f5838015d8cc0a9c215b77e43d07a25e460f35cf516df8626f"}, - {file = "pillow-11.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec1ee50470b0d050984394423d96325b744d55c701a439d2bd66089bff963d3c"}, - {file = "pillow-11.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7db51d222548ccfd274e4572fdbf3e810a5e66b00608862f947b163e613b67dd"}, - {file = "pillow-11.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2d6fcc902a24ac74495df63faad1884282239265c6839a0a6416d33faedfae7e"}, - {file = "pillow-11.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0f5d8f4a08090c6d6d578351a2b91acf519a54986c055af27e7a93feae6d3f1"}, - {file = "pillow-11.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c37d8ba9411d6003bba9e518db0db0c58a680ab9fe5179f040b0463644bc9805"}, - {file = "pillow-11.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13f87d581e71d9189ab21fe0efb5a23e9f28552d5be6979e84001d3b8505abe8"}, - {file = "pillow-11.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:023f6d2d11784a465f09fd09a34b150ea4672e85fb3d05931d89f373ab14abb2"}, - {file = "pillow-11.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:45dfc51ac5975b938e9809451c51734124e73b04d0f0ac621649821a63852e7b"}, - {file = "pillow-11.3.0-cp313-cp313-win32.whl", hash = "sha256:a4d336baed65d50d37b88ca5b60c0fa9d81e3a87d4a7930d3880d1624d5b31f3"}, - {file = "pillow-11.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bce5c4fd0921f99d2e858dc4d4d64193407e1b99478bc5cacecba2311abde51"}, - {file = "pillow-11.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:1904e1264881f682f02b7f8167935cce37bc97db457f8e7849dc3a6a52b99580"}, - {file = "pillow-11.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4c834a3921375c48ee6b9624061076bc0a32a60b5532b322cc0ea64e639dd50e"}, - {file = "pillow-11.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5e05688ccef30ea69b9317a9ead994b93975104a677a36a8ed8106be9260aa6d"}, - {file = "pillow-11.3.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1019b04af07fc0163e2810167918cb5add8d74674b6267616021ab558dc98ced"}, - {file = "pillow-11.3.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f944255db153ebb2b19c51fe85dd99ef0ce494123f21b9db4877ffdfc5590c7c"}, - {file = "pillow-11.3.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f85acb69adf2aaee8b7da124efebbdb959a104db34d3a2cb0f3793dbae422a8"}, - {file = "pillow-11.3.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05f6ecbeff5005399bb48d198f098a9b4b6bdf27b8487c7f38ca16eeb070cd59"}, - {file = "pillow-11.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a7bc6e6fd0395bc052f16b1a8670859964dbd7003bd0af2ff08342eb6e442cfe"}, - {file = "pillow-11.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:83e1b0161c9d148125083a35c1c5a89db5b7054834fd4387499e06552035236c"}, - {file = "pillow-11.3.0-cp313-cp313t-win32.whl", hash = "sha256:2a3117c06b8fb646639dce83694f2f9eac405472713fcb1ae887469c0d4f6788"}, - {file = "pillow-11.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:857844335c95bea93fb39e0fa2726b4d9d758850b34075a7e3ff4f4fa3aa3b31"}, - {file = "pillow-11.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:8797edc41f3e8536ae4b10897ee2f637235c94f27404cac7297f7b607dd0716e"}, - {file = "pillow-11.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d9da3df5f9ea2a89b81bb6087177fb1f4d1c7146d583a3fe5c672c0d94e55e12"}, - {file = "pillow-11.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0b275ff9b04df7b640c59ec5a3cb113eefd3795a8df80bac69646ef699c6981a"}, - {file = "pillow-11.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0743841cabd3dba6a83f38a92672cccbd69af56e3e91777b0ee7f4dba4385632"}, - {file = "pillow-11.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2465a69cf967b8b49ee1b96d76718cd98c4e925414ead59fdf75cf0fd07df673"}, - {file = "pillow-11.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41742638139424703b4d01665b807c6468e23e699e8e90cffefe291c5832b027"}, - {file = "pillow-11.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93efb0b4de7e340d99057415c749175e24c8864302369e05914682ba642e5d77"}, - {file = "pillow-11.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7966e38dcd0fa11ca390aed7c6f20454443581d758242023cf36fcb319b1a874"}, - {file = "pillow-11.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:98a9afa7b9007c67ed84c57c9e0ad86a6000da96eaa638e4f8abe5b65ff83f0a"}, - {file = "pillow-11.3.0-cp314-cp314-win32.whl", hash = "sha256:02a723e6bf909e7cea0dac1b0e0310be9d7650cd66222a5f1c571455c0a45214"}, - {file = "pillow-11.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:a418486160228f64dd9e9efcd132679b7a02a5f22c982c78b6fc7dab3fefb635"}, - {file = "pillow-11.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:155658efb5e044669c08896c0c44231c5e9abcaadbc5cd3648df2f7c0b96b9a6"}, - {file = "pillow-11.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:59a03cdf019efbfeeed910bf79c7c93255c3d54bc45898ac2a4140071b02b4ae"}, - {file = "pillow-11.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f8a5827f84d973d8636e9dc5764af4f0cf2318d26744b3d902931701b0d46653"}, - {file = "pillow-11.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ee92f2fd10f4adc4b43d07ec5e779932b4eb3dbfbc34790ada5a6669bc095aa6"}, - {file = "pillow-11.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c96d333dcf42d01f47b37e0979b6bd73ec91eae18614864622d9b87bbd5bbf36"}, - {file = "pillow-11.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c96f993ab8c98460cd0c001447bff6194403e8b1d7e149ade5f00594918128b"}, - {file = "pillow-11.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41342b64afeba938edb034d122b2dda5db2139b9a4af999729ba8818e0056477"}, - {file = "pillow-11.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:068d9c39a2d1b358eb9f245ce7ab1b5c3246c7c8c7d9ba58cfa5b43146c06e50"}, - {file = "pillow-11.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a1bc6ba083b145187f648b667e05a2534ecc4b9f2784c2cbe3089e44868f2b9b"}, - {file = "pillow-11.3.0-cp314-cp314t-win32.whl", hash = "sha256:118ca10c0d60b06d006be10a501fd6bbdfef559251ed31b794668ed569c87e12"}, - {file = "pillow-11.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8924748b688aa210d79883357d102cd64690e56b923a186f35a82cbc10f997db"}, - {file = "pillow-11.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:79ea0d14d3ebad43ec77ad5272e6ff9bba5b679ef73375ea760261207fa8e0aa"}, - {file = "pillow-11.3.0-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:48d254f8a4c776de343051023eb61ffe818299eeac478da55227d96e241de53f"}, - {file = "pillow-11.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:7aee118e30a4cf54fdd873bd3a29de51e29105ab11f9aad8c32123f58c8f8081"}, - {file = "pillow-11.3.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:23cff760a9049c502721bdb743a7cb3e03365fafcdfc2ef9784610714166e5a4"}, - {file = "pillow-11.3.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6359a3bc43f57d5b375d1ad54a0074318a0844d11b76abccf478c37c986d3cfc"}, - {file = "pillow-11.3.0-cp39-cp39-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:092c80c76635f5ecb10f3f83d76716165c96f5229addbd1ec2bdbbda7d496e06"}, - {file = "pillow-11.3.0-cp39-cp39-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cadc9e0ea0a2431124cde7e1697106471fc4c1da01530e679b2391c37d3fbb3a"}, - {file = "pillow-11.3.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:6a418691000f2a418c9135a7cf0d797c1bb7d9a485e61fe8e7722845b95ef978"}, - {file = "pillow-11.3.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:97afb3a00b65cc0804d1c7abddbf090a81eaac02768af58cbdcaaa0a931e0b6d"}, - {file = "pillow-11.3.0-cp39-cp39-win32.whl", hash = "sha256:ea944117a7974ae78059fcc1800e5d3295172bb97035c0c1d9345fca1419da71"}, - {file = "pillow-11.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:e5c5858ad8ec655450a7c7df532e9842cf8df7cc349df7225c60d5d348c8aada"}, - {file = "pillow-11.3.0-cp39-cp39-win_arm64.whl", hash = "sha256:6abdbfd3aea42be05702a8dd98832329c167ee84400a1d1f61ab11437f1717eb"}, - {file = "pillow-11.3.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:3cee80663f29e3843b68199b9d6f4f54bd1d4a6b59bdd91bceefc51238bcb967"}, - {file = "pillow-11.3.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:b5f56c3f344f2ccaf0dd875d3e180f631dc60a51b314295a3e681fe8cf851fbe"}, - {file = "pillow-11.3.0-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e67d793d180c9df62f1f40aee3accca4829d3794c95098887edc18af4b8b780c"}, - {file = "pillow-11.3.0-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d000f46e2917c705e9fb93a3606ee4a819d1e3aa7a9b442f6444f07e77cf5e25"}, - {file = "pillow-11.3.0-pp310-pypy310_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:527b37216b6ac3a12d7838dc3bd75208ec57c1c6d11ef01902266a5a0c14fc27"}, - {file = "pillow-11.3.0-pp310-pypy310_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:be5463ac478b623b9dd3937afd7fb7ab3d79dd290a28e2b6df292dc75063eb8a"}, - {file = "pillow-11.3.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:8dc70ca24c110503e16918a658b869019126ecfe03109b754c402daff12b3d9f"}, - {file = "pillow-11.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7c8ec7a017ad1bd562f93dbd8505763e688d388cde6e4a010ae1486916e713e6"}, - {file = "pillow-11.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9ab6ae226de48019caa8074894544af5b53a117ccb9d3b3dcb2871464c829438"}, - {file = "pillow-11.3.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fe27fb049cdcca11f11a7bfda64043c37b30e6b91f10cb5bab275806c32f6ab3"}, - {file = "pillow-11.3.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:465b9e8844e3c3519a983d58b80be3f668e2a7a5db97f2784e7079fbc9f9822c"}, - {file = "pillow-11.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5418b53c0d59b3824d05e029669efa023bbef0f3e92e75ec8428f3799487f361"}, - {file = "pillow-11.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:504b6f59505f08ae014f724b6207ff6222662aab5cc9542577fb084ed0676ac7"}, - {file = "pillow-11.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c84d689db21a1c397d001aa08241044aa2069e7587b398c8cc63020390b1c1b8"}, - {file = "pillow-11.3.0.tar.gz", hash = "sha256:3828ee7586cd0b2091b6209e5ad53e20d0649bbe87164a459d0676e035e8f523"}, + {file = "pillow-12.0.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:3adfb466bbc544b926d50fe8f4a4e6abd8c6bffd28a26177594e6e9b2b76572b"}, + {file = "pillow-12.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1ac11e8ea4f611c3c0147424eae514028b5e9077dd99ab91e1bd7bc33ff145e1"}, + {file = "pillow-12.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d49e2314c373f4c2b39446fb1a45ed333c850e09d0c59ac79b72eb3b95397363"}, + {file = "pillow-12.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c7b2a63fd6d5246349f3d3f37b14430d73ee7e8173154461785e43036ffa96ca"}, + {file = "pillow-12.0.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d64317d2587c70324b79861babb9c09f71fbb780bad212018874b2c013d8600e"}, + {file = "pillow-12.0.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d77153e14b709fd8b8af6f66a3afbb9ed6e9fc5ccf0b6b7e1ced7b036a228782"}, + {file = "pillow-12.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:32ed80ea8a90ee3e6fa08c21e2e091bba6eda8eccc83dbc34c95169507a91f10"}, + {file = "pillow-12.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c828a1ae702fc712978bda0320ba1b9893d99be0badf2647f693cc01cf0f04fa"}, + {file = "pillow-12.0.0-cp310-cp310-win32.whl", hash = "sha256:bd87e140e45399c818fac4247880b9ce719e4783d767e030a883a970be632275"}, + {file = "pillow-12.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:455247ac8a4cfb7b9bc45b7e432d10421aea9fc2e74d285ba4072688a74c2e9d"}, + {file = "pillow-12.0.0-cp310-cp310-win_arm64.whl", hash = "sha256:6ace95230bfb7cd79ef66caa064bbe2f2a1e63d93471c3a2e1f1348d9f22d6b7"}, + {file = "pillow-12.0.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:0fd00cac9c03256c8b2ff58f162ebcd2587ad3e1f2e397eab718c47e24d231cc"}, + {file = "pillow-12.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3475b96f5908b3b16c47533daaa87380c491357d197564e0ba34ae75c0f3257"}, + {file = "pillow-12.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:110486b79f2d112cf6add83b28b627e369219388f64ef2f960fef9ebaf54c642"}, + {file = "pillow-12.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5269cc1caeedb67e6f7269a42014f381f45e2e7cd42d834ede3c703a1d915fe3"}, + {file = "pillow-12.0.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa5129de4e174daccbc59d0a3b6d20eaf24417d59851c07ebb37aeb02947987c"}, + {file = "pillow-12.0.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bee2a6db3a7242ea309aa7ee8e2780726fed67ff4e5b40169f2c940e7eb09227"}, + {file = "pillow-12.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:90387104ee8400a7b4598253b4c406f8958f59fcf983a6cea2b50d59f7d63d0b"}, + {file = "pillow-12.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bc91a56697869546d1b8f0a3ff35224557ae7f881050e99f615e0119bf934b4e"}, + {file = "pillow-12.0.0-cp311-cp311-win32.whl", hash = "sha256:27f95b12453d165099c84f8a8bfdfd46b9e4bda9e0e4b65f0635430027f55739"}, + {file = "pillow-12.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:b583dc9070312190192631373c6c8ed277254aa6e6084b74bdd0a6d3b221608e"}, + {file = "pillow-12.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:759de84a33be3b178a64c8ba28ad5c135900359e85fb662bc6e403ad4407791d"}, + {file = "pillow-12.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:53561a4ddc36facb432fae7a9d8afbfaf94795414f5cdc5fc52f28c1dca90371"}, + {file = "pillow-12.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:71db6b4c1653045dacc1585c1b0d184004f0d7e694c7b34ac165ca70c0838082"}, + {file = "pillow-12.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2fa5f0b6716fc88f11380b88b31fe591a06c6315e955c096c35715788b339e3f"}, + {file = "pillow-12.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:82240051c6ca513c616f7f9da06e871f61bfd7805f566275841af15015b8f98d"}, + {file = "pillow-12.0.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55f818bd74fe2f11d4d7cbc65880a843c4075e0ac7226bc1a23261dbea531953"}, + {file = "pillow-12.0.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b87843e225e74576437fd5b6a4c2205d422754f84a06942cfaf1dc32243e45a8"}, + {file = "pillow-12.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c607c90ba67533e1b2355b821fef6764d1dd2cbe26b8c1005ae84f7aea25ff79"}, + {file = "pillow-12.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:21f241bdd5080a15bc86d3466a9f6074a9c2c2b314100dd896ac81ee6db2f1ba"}, + {file = "pillow-12.0.0-cp312-cp312-win32.whl", hash = "sha256:dd333073e0cacdc3089525c7df7d39b211bcdf31fc2824e49d01c6b6187b07d0"}, + {file = "pillow-12.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:9fe611163f6303d1619bbcb653540a4d60f9e55e622d60a3108be0d5b441017a"}, + {file = "pillow-12.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:7dfb439562f234f7d57b1ac6bc8fe7f838a4bd49c79230e0f6a1da93e82f1fad"}, + {file = "pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0869154a2d0546545cde61d1789a6524319fc1897d9ee31218eae7a60ccc5643"}, + {file = "pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a7921c5a6d31b3d756ec980f2f47c0cfdbce0fc48c22a39347a895f41f4a6ea4"}, + {file = "pillow-12.0.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1ee80a59f6ce048ae13cda1abf7fbd2a34ab9ee7d401c46be3ca685d1999a399"}, + {file = "pillow-12.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c50f36a62a22d350c96e49ad02d0da41dbd17ddc2e29750dbdba4323f85eb4a5"}, + {file = "pillow-12.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5193fde9a5f23c331ea26d0cf171fbf67e3f247585f50c08b3e205c7aeb4589b"}, + {file = "pillow-12.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bde737cff1a975b70652b62d626f7785e0480918dece11e8fef3c0cf057351c3"}, + {file = "pillow-12.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a6597ff2b61d121172f5844b53f21467f7082f5fb385a9a29c01414463f93b07"}, + {file = "pillow-12.0.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b817e7035ea7f6b942c13aa03bb554fc44fea70838ea21f8eb31c638326584e"}, + {file = "pillow-12.0.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4f1231b7dec408e8670264ce63e9c71409d9583dd21d32c163e25213ee2a344"}, + {file = "pillow-12.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e51b71417049ad6ab14c49608b4a24d8fb3fe605e5dfabfe523b58064dc3d27"}, + {file = "pillow-12.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d120c38a42c234dc9a8c5de7ceaaf899cf33561956acb4941653f8bdc657aa79"}, + {file = "pillow-12.0.0-cp313-cp313-win32.whl", hash = "sha256:4cc6b3b2efff105c6a1656cfe59da4fdde2cda9af1c5e0b58529b24525d0a098"}, + {file = "pillow-12.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:4cf7fed4b4580601c4345ceb5d4cbf5a980d030fd5ad07c4d2ec589f95f09905"}, + {file = "pillow-12.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:9f0b04c6b8584c2c193babcccc908b38ed29524b29dd464bc8801bf10d746a3a"}, + {file = "pillow-12.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7fa22993bac7b77b78cae22bad1e2a987ddf0d9015c63358032f84a53f23cdc3"}, + {file = "pillow-12.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f135c702ac42262573fe9714dfe99c944b4ba307af5eb507abef1667e2cbbced"}, + {file = "pillow-12.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c85de1136429c524e55cfa4e033b4a7940ac5c8ee4d9401cc2d1bf48154bbc7b"}, + {file = "pillow-12.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:38df9b4bfd3db902c9c2bd369bcacaf9d935b2fff73709429d95cc41554f7b3d"}, + {file = "pillow-12.0.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d87ef5795da03d742bf49439f9ca4d027cde49c82c5371ba52464aee266699a"}, + {file = "pillow-12.0.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aff9e4d82d082ff9513bdd6acd4f5bd359f5b2c870907d2b0a9c5e10d40c88fe"}, + {file = "pillow-12.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:8d8ca2b210ada074d57fcee40c30446c9562e542fc46aedc19baf758a93532ee"}, + {file = "pillow-12.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:99a7f72fb6249302aa62245680754862a44179b545ded638cf1fef59befb57ef"}, + {file = "pillow-12.0.0-cp313-cp313t-win32.whl", hash = "sha256:4078242472387600b2ce8d93ade8899c12bf33fa89e55ec89fe126e9d6d5d9e9"}, + {file = "pillow-12.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2c54c1a783d6d60595d3514f0efe9b37c8808746a66920315bfd34a938d7994b"}, + {file = "pillow-12.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:26d9f7d2b604cd23aba3e9faf795787456ac25634d82cd060556998e39c6fa47"}, + {file = "pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:beeae3f27f62308f1ddbcfb0690bf44b10732f2ef43758f169d5e9303165d3f9"}, + {file = "pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d4827615da15cd59784ce39d3388275ec093ae3ee8d7f0c089b76fa87af756c2"}, + {file = "pillow-12.0.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:3e42edad50b6909089750e65c91aa09aaf1e0a71310d383f11321b27c224ed8a"}, + {file = "pillow-12.0.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e5d8efac84c9afcb40914ab49ba063d94f5dbdf5066db4482c66a992f47a3a3b"}, + {file = "pillow-12.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:266cd5f2b63ff316d5a1bba46268e603c9caf5606d44f38c2873c380950576ad"}, + {file = "pillow-12.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58eea5ebe51504057dd95c5b77d21700b77615ab0243d8152793dc00eb4faf01"}, + {file = "pillow-12.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f13711b1a5ba512d647a0e4ba79280d3a9a045aaf7e0cc6fbe96b91d4cdf6b0c"}, + {file = "pillow-12.0.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6846bd2d116ff42cba6b646edf5bf61d37e5cbd256425fa089fee4ff5c07a99e"}, + {file = "pillow-12.0.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c98fa880d695de164b4135a52fd2e9cd7b7c90a9d8ac5e9e443a24a95ef9248e"}, + {file = "pillow-12.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa3ed2a29a9e9d2d488b4da81dcb54720ac3104a20bf0bd273f1e4648aff5af9"}, + {file = "pillow-12.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d034140032870024e6b9892c692fe2968493790dd57208b2c37e3fb35f6df3ab"}, + {file = "pillow-12.0.0-cp314-cp314-win32.whl", hash = "sha256:1b1b133e6e16105f524a8dec491e0586d072948ce15c9b914e41cdadd209052b"}, + {file = "pillow-12.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:8dc232e39d409036af549c86f24aed8273a40ffa459981146829a324e0848b4b"}, + {file = "pillow-12.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:d52610d51e265a51518692045e372a4c363056130d922a7351429ac9f27e70b0"}, + {file = "pillow-12.0.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1979f4566bb96c1e50a62d9831e2ea2d1211761e5662afc545fa766f996632f6"}, + {file = "pillow-12.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b2e4b27a6e15b04832fe9bf292b94b5ca156016bbc1ea9c2c20098a0320d6cf6"}, + {file = "pillow-12.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fb3096c30df99fd01c7bf8e544f392103d0795b9f98ba71a8054bcbf56b255f1"}, + {file = "pillow-12.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7438839e9e053ef79f7112c881cef684013855016f928b168b81ed5835f3e75e"}, + {file = "pillow-12.0.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d5c411a8eaa2299322b647cd932586b1427367fd3184ffbb8f7a219ea2041ca"}, + {file = "pillow-12.0.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7e091d464ac59d2c7ad8e7e08105eaf9dafbc3883fd7265ffccc2baad6ac925"}, + {file = "pillow-12.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:792a2c0be4dcc18af9d4a2dfd8a11a17d5e25274a1062b0ec1c2d79c76f3e7f8"}, + {file = "pillow-12.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:afbefa430092f71a9593a99ab6a4e7538bc9eabbf7bf94f91510d3503943edc4"}, + {file = "pillow-12.0.0-cp314-cp314t-win32.whl", hash = "sha256:3830c769decf88f1289680a59d4f4c46c72573446352e2befec9a8512104fa52"}, + {file = "pillow-12.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:905b0365b210c73afb0ebe9101a32572152dfd1c144c7e28968a331b9217b94a"}, + {file = "pillow-12.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:99353a06902c2e43b43e8ff74ee65a7d90307d82370604746738a1e0661ccca7"}, + {file = "pillow-12.0.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b22bd8c974942477156be55a768f7aa37c46904c175be4e158b6a86e3a6b7ca8"}, + {file = "pillow-12.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:805ebf596939e48dbb2e4922a1d3852cfc25c38160751ce02da93058b48d252a"}, + {file = "pillow-12.0.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cae81479f77420d217def5f54b5b9d279804d17e982e0f2fa19b1d1e14ab5197"}, + {file = "pillow-12.0.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeaefa96c768fc66818730b952a862235d68825c178f1b3ffd4efd7ad2edcb7c"}, + {file = "pillow-12.0.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09f2d0abef9e4e2f349305a4f8cc784a8a6c2f58a8c4892eea13b10a943bd26e"}, + {file = "pillow-12.0.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bdee52571a343d721fb2eb3b090a82d959ff37fc631e3f70422e0c2e029f3e76"}, + {file = "pillow-12.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b290fd8aa38422444d4b50d579de197557f182ef1068b75f5aa8558638b8d0a5"}, + {file = "pillow-12.0.0.tar.gz", hash = "sha256:87d4f8125c9988bfbed67af47dd7a953e2fc7b0cc1e7800ec6d2080d490bb353"}, ] [package.extras] docs = ["furo", "olefile", "sphinx (>=8.2)", "sphinx-autobuild", "sphinx-copybutton", "sphinx-inline-tabs", "sphinxext-opengraph"] fpx = ["olefile"] mic = ["olefile"] -test-arrow = ["pyarrow"] -tests = ["check-manifest", "coverage (>=7.4.2)", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout", "pytest-xdist", "trove-classifiers (>=2024.10.12)"] -typing = ["typing-extensions ; python_version < \"3.10\""] +test-arrow = ["arro3-compute", "arro3-core", "nanoarrow", "pyarrow"] +tests = ["check-manifest", "coverage (>=7.4.2)", "defusedxml", "markdown2", "olefile", "packaging", "pyroma (>=5)", "pytest", "pytest-cov", "pytest-timeout", "pytest-xdist", "trove-classifiers (>=2024.10.12)"] xmp = ["defusedxml"] [[package]] @@ -3574,6 +3680,27 @@ files = [ [package.extras] windows-terminal = ["colorama (>=0.4.6)"] +[[package]] +name = "pyjwt" +version = "2.10.1" +description = "JSON Web Token implementation in Python" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb"}, + {file = "pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953"}, +] + +[package.dependencies] +cryptography = {version = ">=3.4.0", optional = true, markers = "extra == \"crypto\""} + +[package.extras] +crypto = ["cryptography (>=3.4.0)"] +dev = ["coverage[toml] (==5.0.4)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=6.0.0,<7.0.0)", "sphinx", "sphinx-rtd-theme", "zope.interface"] +docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"] +tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"] + [[package]] name = "pyparsing" version = "3.2.5" @@ -3591,14 +3718,14 @@ diagrams = ["jinja2", "railroad-diagrams"] [[package]] name = "pypdf" -version = "5.9.0" +version = "6.4.0" description = "A pure-python PDF library capable of splitting, merging, cropping, and transforming PDF files" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["main"] files = [ - {file = "pypdf-5.9.0-py3-none-any.whl", hash = "sha256:be10a4c54202f46d9daceaa8788be07aa8cd5ea8c25c529c50dd509206382c35"}, - {file = "pypdf-5.9.0.tar.gz", hash = "sha256:30f67a614d558e495e1fbb157ba58c1de91ffc1718f5e0dfeb82a029233890a1"}, + {file = "pypdf-6.4.0-py3-none-any.whl", hash = "sha256:55ab9837ed97fd7fcc5c131d52fcc2223bc5c6b8a1488bbf7c0e27f1f0023a79"}, + {file = "pypdf-6.4.0.tar.gz", hash = "sha256:4769d471f8ddc3341193ecc5d6560fa44cf8cd0abfabf21af4e195cc0c224072"}, ] [package.extras] @@ -3655,20 +3782,20 @@ nodejs = ["nodejs-wheel-binaries"] [[package]] name = "pytest" -version = "8.4.2" +version = "9.0.1" description = "pytest: simple powerful testing with Python" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79"}, - {file = "pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01"}, + {file = "pytest-9.0.1-py3-none-any.whl", hash = "sha256:67be0030d194df2dfa7b556f2e56fb3c3315bd5c8822c6951162b92b32ce7dad"}, + {file = "pytest-9.0.1.tar.gz", hash = "sha256:3e9c069ea73583e255c3b21cf46b8d3c56f6e3a1a8f6da94ccb0fcf57b9d73c8"}, ] [package.dependencies] colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} -iniconfig = ">=1" -packaging = ">=20" +iniconfig = ">=1.0.1" +packaging = ">=22" pluggy = ">=1.5,<2" pygments = ">=2.7.2" @@ -3677,18 +3804,19 @@ dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests [[package]] name = "pytest-asyncio" -version = "0.26.0" +version = "1.3.0" description = "Pytest support for asyncio" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "pytest_asyncio-0.26.0-py3-none-any.whl", hash = "sha256:7b51ed894f4fbea1340262bdae5135797ebbe21d8638978e35d31c6d19f72fb0"}, - {file = "pytest_asyncio-0.26.0.tar.gz", hash = "sha256:c4df2a697648241ff39e7f0e4a73050b03f123f760673956cf0d72a4990e312f"}, + {file = "pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5"}, + {file = "pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5"}, ] [package.dependencies] -pytest = ">=8.2,<9" +pytest = ">=8.2,<10" +typing-extensions = {version = ">=4.12", markers = "python_version < \"3.13\""} [package.extras] docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)"] @@ -4402,7 +4530,7 @@ files = [ ] [package.dependencies] -greenlet = {version = ">=1", markers = "platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\""} +greenlet = {version = ">=1", optional = true, markers = "platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\" or extra == \"asyncio\""} typing-extensions = ">=4.6.0" [package.extras] @@ -4464,14 +4592,14 @@ doc = ["sphinx"] [[package]] name = "sse-starlette" -version = "2.4.1" +version = "3.0.3" description = "SSE plugin for Starlette" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "sse_starlette-2.4.1-py3-none-any.whl", hash = "sha256:08b77ea898ab1a13a428b2b6f73cfe6d0e607a7b4e15b9bb23e4a37b087fd39a"}, - {file = "sse_starlette-2.4.1.tar.gz", hash = "sha256:7c8a800a1ca343e9165fc06bbda45c78e4c6166320707ae30b416c42da070926"}, + {file = "sse_starlette-3.0.3-py3-none-any.whl", hash = "sha256:af5bf5a6f3933df1d9c7f8539633dc8444ca6a97ab2e2a7cd3b6e431ac03a431"}, + {file = "sse_starlette-3.0.3.tar.gz", hash = "sha256:88cfb08747e16200ea990c8ca876b03910a23b547ab3bd764c0d8eb81019b971"}, ] [package.dependencies] @@ -4479,7 +4607,7 @@ anyio = ">=4.7.0" [package.extras] daphne = ["daphne (>=4.2.0)"] -examples = ["aiosqlite (>=0.21.0)", "fastapi (>=0.115.12)", "sqlalchemy[asyncio,examples] (>=2.0.41)", "starlette (>=0.41.3)", "uvicorn (>=0.34.0)"] +examples = ["aiosqlite (>=0.21.0)", "fastapi (>=0.115.12)", "sqlalchemy[asyncio] (>=2.0.41)", "starlette (>=0.49.1)", "uvicorn (>=0.34.0)"] granian = ["granian (>=2.3.1)"] uvicorn = ["uvicorn (>=0.34.0)"] @@ -4534,14 +4662,14 @@ test = ["pytest", "tornado (>=4.5)", "typeguard"] [[package]] name = "testcontainers" -version = "4.13.2" +version = "4.13.3" description = "Python library for throwaway instances of anything that can run in a Docker container" optional = false -python-versions = "<4.0,>=3.9.2" +python-versions = ">=3.9.2" groups = ["dev"] files = [ - {file = "testcontainers-4.13.2-py3-none-any.whl", hash = "sha256:0209baf8f4274b568cde95bef2cadf7b1d33b375321f793790462e235cd684ee"}, - {file = "testcontainers-4.13.2.tar.gz", hash = "sha256:2315f1e21b059427a9d11e8921f85fef322fbe0d50749bcca4eaa11271708ba4"}, + {file = "testcontainers-4.13.3-py3-none-any.whl", hash = "sha256:063278c4805ffa6dd85e56648a9da3036939e6c0ac1001e851c9276b19b05970"}, + {file = "testcontainers-4.13.3.tar.gz", hash = "sha256:9d82a7052c9a53c58b69e1dc31da8e7a715e8b3ec1c4df5027561b47e2efe646"}, ] [package.dependencies] @@ -4556,26 +4684,25 @@ arangodb = ["python-arango (>=7.8,<8.0)"] aws = ["boto3", "httpx"] azurite = ["azure-storage-blob (>=12.19,<13.0)"] chroma = ["chromadb-client (>=1.0.0,<2.0.0)"] -clickhouse = ["clickhouse-driver"] cosmosdb = ["azure-cosmos"] db2 = ["ibm_db_sa ; platform_machine != \"aarch64\" and platform_machine != \"arm64\"", "sqlalchemy"] generic = ["httpx", "redis"] google = ["google-cloud-datastore (>=2)", "google-cloud-pubsub (>=2)"] influxdb = ["influxdb", "influxdb-client"] -k3s = ["kubernetes", "pyyaml"] +k3s = ["kubernetes", "pyyaml (>=6.0.3)"] keycloak = ["python-keycloak"] localstack = ["boto3"] mailpit = ["cryptography"] minio = ["minio"] mongodb = ["pymongo"] -mssql = ["pymssql ; platform_machine != \"arm64\" or python_version >= \"3.10\"", "sqlalchemy"] +mssql = ["pymssql (>=2.3.9) ; platform_machine != \"arm64\" or python_version >= \"3.10\"", "sqlalchemy"] mysql = ["pymysql[rsa]", "sqlalchemy"] nats = ["nats-py"] neo4j = ["neo4j"] openfga = ["openfga-sdk ; python_version >= \"3.10\""] -opensearch = ["opensearch-py"] -oracle = ["oracledb", "sqlalchemy"] -oracle-free = ["oracledb", "sqlalchemy"] +opensearch = ["opensearch-py ; python_version < \"4.0\""] +oracle = ["oracledb (>=3.4.1)", "sqlalchemy"] +oracle-free = ["oracledb (>=3.4.1)", "sqlalchemy"] qdrant = ["qdrant-client"] rabbitmq = ["pika"] redis = ["redis"] @@ -5496,4 +5623,4 @@ cffi = ["cffi (>=1.17,<2.0) ; platform_python_implementation != \"PyPy\" and pyt [metadata] lock-version = "2.1" python-versions = ">=3.12,<3.14" -content-hash = "3276ce57b42f3caf50cb51c08de80466dc5315c01aa1a02766a4841c5b51742f" +content-hash = "669dc5a88a8a24d2431371aee0f8795561008f01566007d0f891ac8d62459c2a" diff --git a/src/backend/pyproject.toml b/src/backend/pyproject.toml index 0e2dff1..b8b31d5 100644 --- a/src/backend/pyproject.toml +++ b/src/backend/pyproject.toml @@ -5,10 +5,10 @@ description = "" authors = [] requires-python = ">=3.12,<3.14" dependencies = [ - "fastapi (>=0.115.8,<0.116.0)", - "uvicorn (>=0.34.0,<0.35.0)", - "openai (>=1.63.2,<2.0.0)", - "sse-starlette (>=2.2.1,<3.0.0)", + "fastapi (>=0.115.8,<0.122.0)", + "uvicorn (>=0.34.0,<0.39.0)", + "openai (>=2.8.1,<3.0.0)", + "sse-starlette (>=3.0.3,<4.0.0)", "pydantic (>=2.10.6,<3.0.0)", "psycopg[binary] (>=3.2.4,<4.0.0)", "sqlalchemy (>=2.0.38,<3.0.0)", @@ -16,42 +16,40 @@ dependencies = [ "python-jose (>=3.4.0,<4.0.0)", "aiohttp (>=3.11.12,<4.0.0)", "sqlmodel (>=0.0.22,<0.1.0)", - "langchain-openai (>=0.3.6,<0.4.0)", - "langchain-core (>=0.3.38,<0.4.0)", - "langchain (>=0.3.19,<0.4.0)", - "aiofiles (>=24.1.0,<25.0.0)", + "langchain-openai (>=1.0.3,<2.0.0)", + "langchain-core (>=1.0.7,<2.0.0)", + "langchain (>=1.0.8,<2.0.0)", + "aiofiles (>=25.1.0,<26.0.0)", "jsonschema (>=4.23.0,<5.0.0)", "python-multipart (>=0.0.20,<0.0.21)", - "langchain-postgres (>=0.0.13,<0.0.14)", + "langchain-postgres (>=0.0.13,<0.0.17)", "chardet (>=5.2.0,<6.0.0)", "pydantic-settings (>=2.9.1,<3.0.0)", - "langgraph (>=0.4.3,<0.5.0)", - # Set langgraph-prebuilt to fixed version because newer versions have conflicts with current implementation - "langgraph-prebuilt (>=0.1.8,<0.2.0)", - "pillow (>=11.2.1,<12.0.0)", + "langgraph (>=1.0.3,<2.0.0)", + "pillow (>=12.0.0,<13.0.0)", "langchain-mcp-adapters (>=0.1.1,<0.2.0)", - "cryptography (>=45.0.3,<46.0.0)", + "cryptography (>=46.0.3,<47.0.0)", "azure-ai-documentintelligence (>=1.0.2,<2.0.0)", "tabulate (>=0.9.0,<0.10.0)", "openpyxl (>=3.1.5,<4.0.0)", - "pypdf (>=5.6.0,<6.0.0)", - "langchain-community (>=0.3.24,<0.4.0)", + "pypdf (>=6.3.0,<7.0.0)", + "langchain-community (>=0.4.1,<0.5.0)", "langchain-tavily (>=0.2.4,<0.3.0)", - "langchain-google-community (>=2.0.7,<3.0.0)", + "langchain-google-community (>=3.0.1,<4.0.0)", "beautifulsoup4 (>=4.13.4,<5.0.0)", - "langchain-aws (>=0.2.27,<0.3.0)", + "langchain-aws (>=1.0.0,<2.0.0)", "boto3 (>=1.39.4,<2.0.0)", - "langchain-google-genai (>=2.1.8,<3.0.0)", + "langchain-google-genai (>=3.1.0,<4.0.0)", "fastapi-mcp (>=0.4.0,<1.0.0)", "jinja2 (>=3.1.6,<4.0.0)", - "aiosmtplib (>=4.0.2,<5.0.0)", + "aiosmtplib (>=5.0.0,<6.0.0)", "xlrd (>=2.0.2,<3.0.0)", "openevals (>=0.1.0,<0.2.0)", "json-schema-to-pydantic (>=0.4.3,<0.5.0)", "transformers (>=4.57.1,<5.0.0)", "python-slugify (>=8.0.4,<9.0.0)", # Set mcp to fixed version because newer versions have conflicts with current implementation - "mcp (>=1.19.0,<1.20.0)", + "mcp (>=1.22.0,<1.23.0)", "pypdfium2 (>=5.0.0,<6.0.0)" ] @@ -61,12 +59,12 @@ requires = ["poetry-core>=2.0.0,<3.0.0"] build-backend = "poetry.core.masonry.api" [tool.poetry.group.dev.dependencies] -alembic = "^1.14.1" -pytest = "^8.3.4" -pytest-asyncio = "^0.26.0" +alembic = "^1.17.2" +pytest = "^9.0.1" +pytest-asyncio = "^1.3.0" aiosqlite = "^0.21.0" sqlparse = "^0.5.3" freezegun = "^1.5.1" -testcontainers = {extras = ["postgres"], version = "^4.9.2"} +testcontainers = {extras = ["postgres"], version = "^4.13.3"} pyright = "^1.1.400" -alembic-postgresql-enum = "^1.7.0" +alembic-postgresql-enum = "^1.8.0" diff --git a/src/backend/tero/agents/api.py b/src/backend/tero/agents/api.py index b7cef6d..065ff93 100644 --- a/src/backend/tero/agents/api.py +++ b/src/backend/tero/agents/api.py @@ -7,6 +7,7 @@ from fastapi.responses import StreamingResponse from sqlmodel.ext.asyncio.session import AsyncSession +from ..core import repos as repos_module from ..core.api import BASE_PATH from ..core.auth import get_current_user from ..core.domain import CamelCaseModel @@ -17,26 +18,24 @@ from ..files.file_quota import QuotaExceededError from ..files.parser import add_encoding_to_content_type from ..files.repos import FileRepository -from ..threads.domain import Thread, ThreadMessage -from ..threads.repos import ThreadRepository, ThreadMessageRepository +from ..teams.domain import GLOBAL_TEAM_ID, Role from ..tools.core import AgentTool from ..tools.oauth import ToolOAuthRequest, build_tool_oauth_request_http_exception from ..tools.repos import ToolRepository -from ..tools.docs.domain import DocToolFile -from ..tools.docs.repos import DocToolFileRepository from ..users.domain import User +from ..users.repos import UserRepository from . import field_generation, distribution from .domain import AgentListItem, Agent, AgentUpdate, AgentToolConfig, AutomaticAgentField, PublicAgent +from .evaluators.repos import EvaluatorRepository from .prompts.repos import AgentPromptRepository from .repos import AgentRepository, AgentToolConfigRepository, AgentToolConfigFileRepository +from .test_cases.clone import clone_test_case from .test_cases.repos import TestCaseRepository -from .test_cases.domain import TestCase from .tool_file import upload_tool_file logger = logging.getLogger(__name__) router = APIRouter() - AGENTS_PATH = f"{BASE_PATH}/agents" _DEFAULT_FILE_NAME = "uploaded-file" DEFAULT_SYSTEM_PROMPT = """You are a helpful AI assistant. @@ -45,6 +44,7 @@ Answer in the same language as the user. Use markdown to format your responses. You can include code blocks, tables, plantuml diagrams code blocks, echarts configuration code blocks and any standard markdown format""" + class AgentSort(Enum): LAST_UPDATE = "LAST_UPDATE" ACTIVE_USERS = "ACTIVE_USERS" @@ -72,7 +72,7 @@ async def find_agents( @router.get(f"{AGENTS_PATH}/default") -async def find_default_agent(user: Annotated[User, Depends(get_current_user)], +async def find_default_agent(user: Annotated[User, Depends(get_current_user)], db: Annotated[AsyncSession, Depends(get_db)] ) -> PublicAgent: agent = await AgentRepository(db).find_default_agent() @@ -120,6 +120,13 @@ async def new_agent(user: Annotated[User, Depends(get_current_user)], async def update_agent(agent_id: int, updated: AgentUpdate, user: Annotated[User, Depends(get_current_user)], db: Annotated[AsyncSession, Depends(get_db)]) -> PublicAgent: agent = await find_editable_agent(agent_id, user, db) + + if updated.team_id == GLOBAL_TEAM_ID and env.disable_publish_global and not any(tr.role in [Role.TEAM_OWNER, Role.TEAM_EDITOR] and tr.team_id == GLOBAL_TEAM_ID for tr in user.team_roles): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Global team members cannot publish to global team" + ) + agent.update_with(updated) ret = await AgentRepository(db).update(agent) if updated.publish_prompts: @@ -176,7 +183,7 @@ async def configure_agent_tool(agent_id: int, tool_config: PublicAgentTool, except Exception: logger.error(f"Invalid tool configuration {agent_id} {tool_config.tool_id} {tool_config.config}", exc_info=True) raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid tool configuration") - + def _find_agent_tool(tool_id: str) -> Optional[AgentTool]: return ToolRepository().find_by_id(tool_id) @@ -231,7 +238,7 @@ async def _find_configured_agent_tool(agent_id: int, tool_id: str, user: User, d @router.post(AGENT_TOOL_FILES_PATH, status_code=status.HTTP_202_ACCEPTED) async def upload_agent_tool_file(agent_id: int, tool_id: str, file: UploadFile, - user: Annotated[User, Depends(get_current_user)], db: Annotated[AsyncSession, Depends(get_db)], + user: Annotated[User, Depends(get_current_user)], db: Annotated[AsyncSession, Depends(get_db)], background_tasks: BackgroundTasks) -> FileMetadata: tool = await _find_editable_configured_agent_tool(agent_id, tool_id, user, db) f = File( @@ -262,42 +269,21 @@ async def download_agent_tool_file(agent_id: int, tool_id: str, file_id: int, ret = await AgentToolConfigFileRepository(db).find_with_content_by_ids(agent_id, tool_id, file_id) return build_file_download_response(ret) -class PublicDocToolFile(FileMetadataWithContent, CamelCaseModel): - description: str - - @staticmethod - def build(file_metadata: FileMetadataWithContent, doc_tool_file: DocToolFile) -> 'PublicDocToolFile': - return PublicDocToolFile( - id=file_metadata.id, - name=file_metadata.name, - content_type=file_metadata.content_type, - user_id=file_metadata.user_id, - timestamp=file_metadata.timestamp, - status=file_metadata.status, - description=doc_tool_file.description, - processed_content=file_metadata.processed_content, - file_processor=file_metadata.file_processor - ) - @router.get(AGENT_TOOL_FILE_PATH) async def find_agent_doc_tool_file(agent_id: int, tool_id: str, file_id: int, user: Annotated[User, Depends(get_current_user)], - db: Annotated[AsyncSession, Depends(get_db)]) -> PublicDocToolFile: + db: Annotated[AsyncSession, Depends(get_db)]) -> FileMetadataWithContent: await _find_configured_agent_tool(agent_id, tool_id, user, db) file_obj = await AgentToolConfigFileRepository(db).find_by_ids(agent_id, tool_id, file_id) if not file_obj: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found") - file_metadata = FileMetadataWithContent.from_file(file_obj) - doc_tool_file = await DocToolFileRepository(db).find_by_agent_id_and_file_id(agent_id, file_id) - if not doc_tool_file: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Doc tool file not found") - return PublicDocToolFile.build(file_metadata, doc_tool_file) + return FileMetadataWithContent.from_file(file_obj) @router.put(AGENT_TOOL_FILE_PATH, status_code=status.HTTP_202_ACCEPTED) async def update_agent_tool_file(agent_id: int, tool_id: str, file_id: int, file: UploadFile, - user: Annotated[User, Depends(get_current_user)], db: Annotated[AsyncSession, Depends(get_db)], + user: Annotated[User, Depends(get_current_user)], db: Annotated[AsyncSession, Depends(get_db)], background_tasks: BackgroundTasks) -> FileMetadata: tool = await _find_editable_configured_agent_tool(agent_id, tool_id, user, db) f = await _find_agent_tool_file(agent_id, tool_id, file_id, db) @@ -311,7 +297,9 @@ async def update_agent_tool_file(agent_id: int, tool_id: str, file_id: int, file ) f.update_with(update) await FileRepository(db).update(f) - background_tasks.add_task(_update_tool_file, f, tool, user, db) + # Pass IDs instead of objects to avoid session conflicts + # The background task will create its own session and re-fetch the entities + background_tasks.add_task(_update_tool_file, f.id, tool.id, agent_id, user.id, tool.config) return FileMetadata.from_file(f) @@ -322,21 +310,28 @@ async def _find_agent_tool_file(agent_id: int, tool_id: str, file_id: int, db: A return ret -async def _update_tool_file(file: File, tool: AgentTool, user: User, db: AsyncSession): - try: - await tool.update_file(file, user) - file.status = FileStatus.PROCESSED - except QuotaExceededError: - file.status = FileStatus.QUOTA_EXCEEDED - logger.error(f"Quota exceeded for user {file.user_id} when updating tool file {file.id} {file.name}") - except Exception as e: - file.status = FileStatus.ERROR - logger.error(f"Error updating tool file {file.id} {file.name} {e}", exc_info=True) - finally: - await FileRepository(db).update(file) - - -@router.delete(AGENT_TOOL_FILE_PATH, status_code=status.HTTP_204_NO_CONTENT) +async def _update_tool_file(file_id: int, tool_id: str, agent_id: int, user_id: int, tool_config: dict): + async with AsyncSession(repos_module.engine, expire_on_commit=False) as db: + file = cast(File, await FileRepository(db).find_by_id(file_id)) + user = cast(User, await UserRepository(db).find_by_id(user_id)) + agent = cast(Agent, await AgentRepository(db).find_by_id(agent_id)) + tool = cast(AgentTool, ToolRepository().find_by_id(tool_id)) + tool.configure(agent, user_id, tool_config, db) + + try: + await tool.update_file(file, user) + file.status = FileStatus.PROCESSED + except QuotaExceededError: + file.status = FileStatus.QUOTA_EXCEEDED + logger.warning(f"Quota exceeded for user {user_id} when updating tool file {file_id} {file.name}") + except Exception as e: + file.status = FileStatus.ERROR + logger.error(f"Error updating tool file {file_id} {file.name} {e}", exc_info=True) + finally: + await FileRepository(db).update(file) + + +@router.delete(AGENT_TOOL_FILE_PATH, status_code=status.HTTP_204_NO_CONTENT) async def delete_agent_tool_file(agent_id: int, tool_id: str, file_id: int, user: Annotated[User, Depends(get_current_user)], db: Annotated[AsyncSession, Depends(get_db)]): tool = await _find_editable_configured_agent_tool(agent_id, tool_id, user, db) @@ -355,12 +350,13 @@ async def clone_agent(agent_id: int, user: Annotated[User, Depends(get_current_u await _clone_agent_prompts(agent_id, cloned_agent.id, user.id, db) await _clone_agent_tools(agent_id, cloned_agent.id, user.id, db) await _clone_agent_test_cases(agent_id, cloned_agent.id, user.id, db) + await _clone_agent_evaluator(agent, cloned_agent, db) return PublicAgent.from_agent(cloned_agent, True) async def _clone_agent_prompts(agent_id: int, cloned_agent_id: int, user_id: int, db: AsyncSession) -> None: - prompt_repo = AgentPromptRepository(db) + prompt_repo = AgentPromptRepository(db) prompts = await prompt_repo.find_user_agent_prompts(user_id, agent_id) if prompts: @@ -389,40 +385,23 @@ async def _clone_agent_tools(agent_id: int, cloned_agent_id: int, user_id: int, async def _clone_agent_test_cases(agent_id: int, cloned_agent_id: int, user_id: int, db: AsyncSession) -> None: test_case_repo = TestCaseRepository(db) test_cases = await test_case_repo.find_by_agent(agent_id) - + if not test_cases: return - - thread_repo = ThreadRepository(db) - thread_message_repo = ThreadMessageRepository(db) - + for test_case in test_cases: - cloned_thread = await thread_repo.add( - Thread( - agent_id=cloned_agent_id, - user_id=user_id, - is_test_case=True, - name=test_case.thread.name - ) - ) - - await test_case_repo.save( - TestCase( - thread_id=cloned_thread.id, - agent_id=cloned_agent_id - ) - ) - - original_messages = await thread_message_repo.find_by_thread_id(test_case.thread_id) - for message in original_messages: - await thread_message_repo.add( - ThreadMessage( - thread_id=cloned_thread.id, - origin=message.origin, - text=message.text, - timestamp=message.timestamp - ) - ) + await clone_test_case(test_case, cloned_agent_id, user_id, db) + + +async def _clone_agent_evaluator(agent: Agent, cloned_agent: Agent, db: AsyncSession) -> None: + evaluator_repo = EvaluatorRepository(db) + evaluator = await evaluator_repo.find_by_id(agent.evaluator_id) if agent.evaluator_id else None + if not evaluator: + return + + cloned_evaluator = await evaluator_repo.save(evaluator.clone()) + cloned_agent.evaluator_id = cloned_evaluator.id + await AgentRepository(db).update(cloned_agent) @router.get(f"{AGENT_PATH}/dist") diff --git a/src/backend/tero/agents/assets/agent-template.md b/src/backend/tero/agents/assets/agent-template.md index 50655c5..6071eff 100644 --- a/src/backend/tero/agents/assets/agent-template.md +++ b/src/backend/tero/agents/assets/agent-template.md @@ -78,6 +78,27 @@ By {{ author }} {% endfor %} +{% endif %} +{% if evaluator %} +## Tests Evaluator + +| | | +|-|-| +| Model | `{{ evaluator.model_name }}` | +{% for key, value in evaluator.model_config.items() %} +| {{ key }} | `{{ value }}` | +{% endfor %} + +### Instructions + +
+ +```` +{{ evaluator.prompt }} +```` + +
+ {% endif %} {% if tests %} ## Tests @@ -92,6 +113,25 @@ By {{ author }} ```` {% endfor %} +{% if test.evaluator %} +### Test Evaluator + +| | | +|-|-| +| Model | `{{ test.evaluator.model_name }}` | +{% for key, value in test.evaluator.model_config.items() %} +| {{ key }} | `{{ value }}` | +{% endfor %} + +#### Instructions + +
+```` +{{ test.evaluator.prompt }} +```` +
+ +{% endif %} {% endfor %} diff --git a/src/backend/tero/agents/distribution.py b/src/backend/tero/agents/distribution.py index 6f71d05..8264f72 100644 --- a/src/backend/tero/agents/distribution.py +++ b/src/backend/tero/agents/distribution.py @@ -10,7 +10,6 @@ import aiofiles from fastapi.background import BackgroundTasks from jinja2 import Environment, FileSystemLoader -from jinja2.nodes import Name from PIL import Image from pydantic import BaseModel from slugify import slugify @@ -27,12 +26,15 @@ from ..tools.repos import ToolRepository from ..users.domain import User from .domain import Agent, AgentUpdate, AgentToolConfig, LlmTemperature, ReasoningEffort +from .evaluators.domain import Evaluator +from .evaluators.repos import EvaluatorRepository from .prompts.domain import AgentPrompt from .prompts.repos import AgentPromptRepository from .repos import AgentRepository, AgentToolConfigRepository, AgentToolConfigFileRepository from .template_parser import JinjaTemplateParser from .test_cases.domain import TestCase from .test_cases.repos import TestCaseRepository +from .test_cases.runner import EVALUATOR_DEFAULT_TEMPERATURE, EVALUATOR_DEFAULT_REASONING_EFFORT from .tool_file import upload_tool_file @@ -82,11 +84,12 @@ async def _generate_agent_markdown(agent: Agent, tools: List[ToolInfo], user_id: system_prompt=agent.system_prompt, icon=agent.icon, model_name=agent.model.name, - model_config=_format_model_config(agent), + model_config=_format_model_config(agent.temperature, agent.reasoning_effort, agent.model.model_type), conversation_starters=[_format_prompt(p) for p in prompts if p.starter], user_prompts=[_format_prompt(p) for p in prompts if not p.starter], tools=[_format_tool(tool) for tool in tools], - tests=[await _format_test(test, db) for test in await TestCaseRepository(db).find_by_agent(agent.id)] + tests=[await _format_test(test, db) for test in await TestCaseRepository(db).find_by_agent(agent.id)], + evaluator=await _format_agent_evaluator(agent, db) ) @@ -94,9 +97,9 @@ def _build_jinja_env() -> Environment: return Environment(loader=FileSystemLoader(solve_asset_path('.', __file__)), trim_blocks=True, lstrip_blocks=True) -def _format_model_config(agent: Agent) -> dict: - return {"Temperature": agent.temperature.value.capitalize()} if agent.model.model_type == LlmModelType.CHAT \ - else {"Reasoning": agent.reasoning_effort.value.capitalize()} +def _format_model_config(temperature: LlmTemperature, reasoning_effort: ReasoningEffort, model_type: LlmModelType) -> dict: + return {"Temperature": temperature.value.capitalize()} if model_type == LlmModelType.CHAT \ + else {"Reasoning": reasoning_effort.value.capitalize()} def _format_prompt(prompt: AgentPrompt) -> dict: @@ -127,7 +130,29 @@ async def _format_test(test_case: TestCase, db: AsyncSession) -> dict: messages = await ThreadMessageRepository(db).find_by_thread_id(test_case.thread_id) return { "name": test_case.thread.name, - "messages": messages + "messages": messages, + "evaluator": await _format_test_evaluator(test_case, db) + } + + +async def _format_agent_evaluator(agent: Agent, db: AsyncSession) -> dict: + evaluator = await EvaluatorRepository(db).find_by_id(agent.evaluator_id) if agent.evaluator_id else None + return await _format_evaluator(evaluator, db) + + +async def _format_test_evaluator(test_case: TestCase, db: AsyncSession) -> dict: + evaluator = await EvaluatorRepository(db).find_by_id(test_case.evaluator_id) if test_case.evaluator_id else None + return await _format_evaluator(evaluator, db) + + +async def _format_evaluator(evaluator: Optional[Evaluator], db: AsyncSession) -> dict: + if not evaluator: + return {} + evaluator_model = cast(LlmModel, await AiModelRepository(db).find_by_id(evaluator.model_id)) + return { + "model_name": evaluator_model.name, + "model_config": _format_model_config(evaluator.temperature, evaluator.reasoning_effort, evaluator_model.model_type), + "prompt": evaluator.prompt } @@ -151,7 +176,7 @@ def _create_icon_with_background(icon_bytes: bytes, bg_color: str) -> bytes: async def update_agent_from_zip(agent: Agent, zip_content: bytes, user: User, db: AsyncSession, background_tasks: BackgroundTasks) -> Agent: - with ZipFile(BytesIO(zip_content), metadata_encoding='utf-8') as zip_file: + with _open_zip_file(zip_content) as zip_file: found_root_folder = [ name.rsplit('/', 1)[0] for name in zip_file.namelist() if name.endswith('/agent.md') ] # supporting zip without root folder in case users zip the folder contents and not the folder itself root_folder = f"{found_root_folder[0]}/" if found_root_folder else "" @@ -172,6 +197,20 @@ async def update_agent_from_zip(agent: Agent, zip_content: bytes, user: User, db return agent +def _open_zip_file(zip_content: bytes) -> ZipFile: + zip_bytes = BytesIO(zip_content) + try: + # we test with utf-8 encoding in case the file was zipped in mac since python zip encoding auto detection does not + # work when zip contains files with special characters (like ñ) on their names + ret = ZipFile(zip_bytes, metadata_encoding='utf-8') + ret.namelist() # Test if metadata can be decoded + return ret + except (UnicodeDecodeError, Exception): + zip_bytes.seek(0) + # since some zip files might not use utf-8 encoding, we fallback to python zip encoding auto detection when utf-8 decoding fails + return ZipFile(zip_bytes) + + async def _find_tools(parsed_tools: List[Dict[str, Any]]) -> Dict[str, AgentTool]: ret = {} for parsed in parsed_tools: @@ -202,10 +241,24 @@ async def _update_agent(agent: Agent, parsed: Dict[str, Any], zip_file: ZipFile, if icon_path in zip_file.namelist(): update.icon = base64.b64encode(zip_file.read(icon_path)).decode('utf-8') + if parsed.get('evaluator'): + agent.evaluator_id = await _create_new_evaluator(parsed['evaluator'], db) + agent.update_with(update) agent = await AgentRepository(db).update(agent) +async def _create_new_evaluator(evaluator_dict: Dict[str, Any], db: AsyncSession) -> int: + model = await _find_model_by_name(evaluator_dict['model_name'], db) + evaluator = await EvaluatorRepository(db).save(Evaluator( + model_id=model.id, + temperature=LlmTemperature[evaluator_dict['model_config']['Temperature'].upper()] if model.model_type == LlmModelType.CHAT else EVALUATOR_DEFAULT_TEMPERATURE, + reasoning_effort=ReasoningEffort[evaluator_dict['model_config']['Reasoning'].upper()] if model.model_type == LlmModelType.REASONING else EVALUATOR_DEFAULT_REASONING_EFFORT, + prompt=evaluator_dict['prompt'] + )) + return evaluator.id + + async def _find_model_by_name(model_name: str, db: AsyncSession) -> LlmModel: ret = [model for model in await AiModelRepository(db).find_all() if model.name == model_name] if not ret: @@ -261,7 +314,7 @@ async def _remove_tool(tc: AgentToolConfig, user_id: int, db: AsyncSession): async def _update_tool(tc: AgentToolConfig, new_config: Dict[str, Any], tool: AgentTool, zip_file: ZipFile, root_folder: str, user: User, db: AsyncSession, background_tasks: BackgroundTasks): await _configure_parsed_tool(tc.tool_id, new_config, tc.agent, tc, tool, user, db) existing_files = {f.name: f for f in await AgentToolConfigFileRepository(db).find_by_agent_id_and_tool_id(tc.agent_id, tc.tool_id)} - new_files = await _parse_new_files(tc.tool_id, new_config.get('files', {}), zip_file, root_folder, user) + new_files = _parse_new_files(tc.tool_id, zip_file, root_folder, user) for file_name, file in existing_files.items(): if not file_name in new_files: @@ -320,16 +373,17 @@ def _parse_config_value(value: Any, schema: dict, key: str, tool_id: str) -> Any raise ValueError(f"Invalid type '{schema_type}' while parsing tool '{tool_id}' config '{key}'") -async def _parse_new_files(tool_id: str, files: Dict[str, str], zip_file: ZipFile, root_folder: str, user: User) -> Dict[str, File]: - return {name: _parse_new_file(tool_id, name, zip_file, root_folder, user) for name in files.keys()} +def _parse_new_files(tool_id: str, zip_file: ZipFile, root_folder: str, user: User) -> Dict[str, File]: + return {path.rsplit("/", 1)[1]: _parse_new_file(path, zip_file, user) for path in zip_file.namelist() if path.startswith(f"{root_folder}{tool_id}/") and path != f"{root_folder}{tool_id}/"} -def _parse_new_file(tool_id: str, file_name: str, zip_file: ZipFile, root_folder: str, user: User) -> File: +def _parse_new_file(file_path: str, zip_file: ZipFile, user: User) -> File: + file_name = file_path.rsplit("/", 1)[1] return File( name=file_name, content_type=mimetypes.guess_type(file_name)[0] or "", user_id=user.id, - content=zip_file.read(f"{root_folder}{tool_id}/{file_name}"), + content=zip_file.read(file_path), status=FileStatus.PENDING ) @@ -347,7 +401,7 @@ async def _update_tool_file(file: File, new_file: File, tc: AgentToolConfig, too async def _configure_new_tool(tool_id: str, new_config: Dict[str, Any], agent: Agent,tool: AgentTool, zip_file: ZipFile, root_folder: str, user: User, db: AsyncSession, background_tasks: BackgroundTasks): await _configure_parsed_tool(tool_id, new_config, agent, None, tool, user, db) - files = await _parse_new_files(tool_id, new_config.get('files', {}), zip_file, root_folder, user) + files = _parse_new_files(tool_id, zip_file, root_folder, user) for file in files.values(): await upload_tool_file(file, tool, agent.id, user, db, background_tasks) @@ -372,9 +426,11 @@ async def _add_new_test(agent_id: int, test: Dict[str, Any], user_id: int, db: A is_test_case=True, name=test['name'] )) + evaluator_id = await _create_new_evaluator(test['evaluator'], db) if test.get('evaluator') else None await TestCaseRepository(db).save(TestCase( thread_id=thread.id, - agent_id=agent_id + agent_id=agent_id, + evaluator_id=evaluator_id )) for i, msg in enumerate(test['messages']): origin = ThreadMessageOrigin.USER if i % 2 == 0 else ThreadMessageOrigin.AGENT diff --git a/src/backend/tero/agents/domain.py b/src/backend/tero/agents/domain.py index db1712e..284917f 100644 --- a/src/backend/tero/agents/domain.py +++ b/src/backend/tero/agents/domain.py @@ -1,23 +1,25 @@ import abc import base64 -import re from datetime import datetime, timezone from enum import Enum +import re from typing import Any, Optional, cast +import sqlalchemy as sa from sqlmodel import Field, Relationship, Index from sqlmodel import SQLModel, Column, Text, JSON -import sqlalchemy as sa -from ..ai_models.domain import LlmModel, LlmModelType +from ..ai_models.domain import LlmModel, LlmModelType, LlmTemperature, ReasoningEffort from ..core.env import env from ..core.domain import CamelCaseModel -from ..users.domain import User, UserListItem from ..teams.domain import Role, Team +from ..users.domain import User, UserListItem + NAME_MAX_LENGTH = 30 CLONE_SUFFIX = "copy" + class BaseAgent(CamelCaseModel, abc.ABC): id: int = Field(primary_key=True, default=None) name: Optional[str] = Field(max_length=NAME_MAX_LENGTH, default=None) @@ -29,21 +31,6 @@ def set_default_name(self): self.name = f"Agent #{self.id}" -class LlmTemperature(Enum): - CREATIVE = 'CREATIVE' - NEUTRAL = 'NEUTRAL' - PRECISE = 'PRECISE' - - def get_float(self): - return env.temperatures[self.value] - - -class ReasoningEffort(Enum): - LOW = 'LOW' - MEDIUM = 'MEDIUM' - HIGH = 'HIGH' - - class AgentUpdate(CamelCaseModel): name: Optional[str] = None description: Optional[str] = None @@ -72,6 +59,7 @@ class Agent(BaseAgent, table=True): reasoning_effort: ReasoningEffort = ReasoningEffort.LOW team_id: Optional[int] = Field(default=None, foreign_key="team.id") team: Optional[Team] = Relationship() + evaluator_id: Optional[int] = Field(default=None, foreign_key="evaluator.id") def update_with(self, update: AgentUpdate): update_dict = update.model_dump(exclude_none=True) @@ -92,7 +80,10 @@ def is_visible_by(self, user: User) -> bool: return self.user_id == user.id or user.is_member_of(cast(int, self.team_id)) def is_editable_by(self, user: User) -> bool: - return self.user_id == user.id or any(tr.role == Role.TEAM_OWNER and cast(Team, tr.team).id == self.team_id for tr in user.team_roles) + return self.user_id == user.id or any( + tr.role in [Role.TEAM_OWNER, Role.TEAM_EDITOR] and cast(Team, tr.team).id == self.team_id + for tr in user.team_roles + ) def clone(self, user_id: int) -> "Agent": base_name = (self.name or "").strip() diff --git a/src/backend/tero/agents/evaluators/__init__.py b/src/backend/tero/agents/evaluators/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/backend/tero/agents/evaluators/api.py b/src/backend/tero/agents/evaluators/api.py new file mode 100644 index 0000000..47684a4 --- /dev/null +++ b/src/backend/tero/agents/evaluators/api.py @@ -0,0 +1,115 @@ +from typing import Annotated, cast + +from fastapi import APIRouter, Depends +from sqlmodel.ext.asyncio.session import AsyncSession + +from ...core.auth import get_current_user +from ...core.env import env +from ...core.repos import get_db +from ...users.domain import User +from ..api import AGENT_PATH, find_agent_by_id, find_editable_agent +from ..repos import AgentRepository +from ..test_cases.api import TEST_CASE_PATH, find_test_case_by_id +from ..test_cases.repos import TestCaseRepository +from ..test_cases.runner import ( + EVALUATOR_DEFAULT_REASONING_EFFORT, + EVALUATOR_DEFAULT_TEMPERATURE, + EVALUATOR_HUMAN_MESSAGE, +) +from .domain import Evaluator, PublicEvaluator +from .repos import EvaluatorRepository + + +router = APIRouter() +AGENT_EVALUATOR_PATH = f"{AGENT_PATH}/evaluator" + + +@router.get(AGENT_EVALUATOR_PATH) +async def find_agent_evaluator(agent_id: int, user: Annotated[User, Depends(get_current_user)], + db: Annotated[AsyncSession, Depends(get_db)]) -> PublicEvaluator: + agent = await find_agent_by_id(agent_id, user, db) + evaluator = await EvaluatorRepository(db).find_by_id(agent.evaluator_id) if agent.evaluator_id else None + if evaluator: + return PublicEvaluator.from_evaluator(evaluator) + else: + return PublicEvaluator( + model_id=cast(str, env.internal_evaluator_model), + temperature=EVALUATOR_DEFAULT_TEMPERATURE, + reasoning_effort=EVALUATOR_DEFAULT_REASONING_EFFORT, + prompt=EVALUATOR_HUMAN_MESSAGE + ) + + +@router.put(AGENT_EVALUATOR_PATH) +async def save_agent_evaluator( + agent_id: int, + public_evaluator: PublicEvaluator, + user: Annotated[User, Depends(get_current_user)], + db: Annotated[AsyncSession, Depends(get_db)] +) -> PublicEvaluator: + agent = await find_editable_agent(agent_id, user, db) + evaluator_repo = EvaluatorRepository(db) + + if agent.evaluator_id: + evaluator = cast(Evaluator, await evaluator_repo.find_by_id(agent.evaluator_id)) + evaluator.update_with(public_evaluator) + evaluator = await evaluator_repo.save(evaluator) + return PublicEvaluator.from_evaluator(evaluator) + + evaluator = Evaluator( + model_id=public_evaluator.model_id, + temperature=public_evaluator.temperature, + reasoning_effort=public_evaluator.reasoning_effort, + prompt=public_evaluator.prompt + ) + evaluator = await evaluator_repo.save(evaluator) + + agent.evaluator_id = evaluator.id + await AgentRepository(db).update(agent) + + return PublicEvaluator.from_evaluator(evaluator) + + +TEST_CASE_EVALUATOR_PATH = f"{TEST_CASE_PATH}/evaluator" + + +@router.get(TEST_CASE_EVALUATOR_PATH) +async def find_test_case_evaluator(agent_id: int, test_case_id: int, user: Annotated[User, Depends(get_current_user)], + db: Annotated[AsyncSession, Depends(get_db)]) -> PublicEvaluator: + test_case = await find_test_case_by_id(test_case_id, agent_id, user, db) + evaluator = await EvaluatorRepository(db).find_by_id(test_case.evaluator_id) if test_case.evaluator_id else None + if evaluator: + return PublicEvaluator.from_evaluator(evaluator) + else: + return await find_agent_evaluator(agent_id, user, db) + + +@router.put(TEST_CASE_EVALUATOR_PATH) +async def save_test_case_evaluator( + agent_id: int, + test_case_id: int, + public_evaluator: PublicEvaluator, + user: Annotated[User, Depends(get_current_user)], + db: Annotated[AsyncSession, Depends(get_db)] +) -> PublicEvaluator: + test_case = await find_test_case_by_id(test_case_id, agent_id, user, db) + evaluator_repo = EvaluatorRepository(db) + + if test_case.evaluator_id: + evaluator = cast(Evaluator, await evaluator_repo.find_by_id(test_case.evaluator_id)) + evaluator.update_with(public_evaluator) + evaluator = await evaluator_repo.save(evaluator) + return PublicEvaluator.from_evaluator(evaluator) + + evaluator = Evaluator( + model_id=public_evaluator.model_id, + temperature=public_evaluator.temperature, + reasoning_effort=public_evaluator.reasoning_effort, + prompt=public_evaluator.prompt + ) + evaluator = await evaluator_repo.save(evaluator) + + test_case.evaluator_id = evaluator.id + await TestCaseRepository(db).save(test_case) + + return PublicEvaluator.from_evaluator(evaluator) diff --git a/src/backend/tero/agents/evaluators/domain.py b/src/backend/tero/agents/evaluators/domain.py new file mode 100644 index 0000000..7a6b9a4 --- /dev/null +++ b/src/backend/tero/agents/evaluators/domain.py @@ -0,0 +1,50 @@ +from datetime import datetime, timezone +from typing import Any + +from sqlmodel import Column, Field, Index, SQLModel, Text + +from ...ai_models.domain import LlmTemperature, ReasoningEffort +from ...core.domain import CamelCaseModel + + +class Evaluator(SQLModel, table=True): + __tablename__: Any = "evaluator" + __table_args__ = ( + Index('ix_evaluator_model_id', 'model_id'), + ) + id: int = Field(primary_key=True, default=None) + model_id: str = Field(foreign_key="llm_model.id") + temperature: LlmTemperature = LlmTemperature.NEUTRAL + reasoning_effort: ReasoningEffort = ReasoningEffort.MEDIUM + prompt: str = Field(sa_column=Column(Text)) + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + + def update_with(self, update: 'PublicEvaluator'): + self.model_id = update.model_id + self.temperature = update.temperature + self.reasoning_effort = update.reasoning_effort + self.prompt = update.prompt + + def clone(self) -> 'Evaluator': + return Evaluator( + model_id=self.model_id, + temperature=self.temperature, + reasoning_effort=self.reasoning_effort, + prompt=self.prompt + ) + + +class PublicEvaluator(CamelCaseModel): + model_id: str + temperature: LlmTemperature = LlmTemperature.NEUTRAL + reasoning_effort: ReasoningEffort = ReasoningEffort.MEDIUM + prompt: str + + @staticmethod + def from_evaluator(evaluator: Evaluator) -> 'PublicEvaluator': + return PublicEvaluator( + model_id=evaluator.model_id, + temperature=evaluator.temperature, + reasoning_effort=evaluator.reasoning_effort, + prompt=evaluator.prompt + ) diff --git a/src/backend/tero/agents/evaluators/repos.py b/src/backend/tero/agents/evaluators/repos.py new file mode 100644 index 0000000..ee60f42 --- /dev/null +++ b/src/backend/tero/agents/evaluators/repos.py @@ -0,0 +1,22 @@ +from typing import Optional + +from sqlmodel import select +from sqlmodel.ext.asyncio.session import AsyncSession + +from .domain import Evaluator + + +class EvaluatorRepository: + + def __init__(self, db: AsyncSession): + self._db = db + + async def find_by_id(self, id: int) -> Optional[Evaluator]: + ret = await self._db.exec(select(Evaluator).where(Evaluator.id == id)) + return ret.one_or_none() + + async def save(self, evaluator: Evaluator) -> Evaluator: + evaluator = await self._db.merge(evaluator) + await self._db.commit() + await self._db.refresh(evaluator) + return evaluator diff --git a/src/backend/tero/agents/prompts/api.py b/src/backend/tero/agents/prompts/api.py index 2e99e44..dee45de 100644 --- a/src/backend/tero/agents/prompts/api.py +++ b/src/backend/tero/agents/prompts/api.py @@ -7,8 +7,8 @@ from ...core.api import BASE_PATH from ...core.auth import get_current_user from ...core.repos import get_db -from ...users.domain import User from ...teams.domain import Role, Team +from ...users.domain import User from ..api import find_agent_by_id from ..domain import Agent from .domain import AgentPrompt, AgentPromptCreate, AgentPromptPublic, AgentPromptUpdate @@ -16,7 +16,6 @@ logger = logging.getLogger(__name__) router = APIRouter() - AGENT_PROMPTS_PATH = f"{BASE_PATH}/agents/{{agent_id}}/prompts" AGENT_PROMPT_PATH = f"{BASE_PATH}/agents/{{agent_id}}/prompts/{{prompt_id}}" @@ -38,7 +37,15 @@ def _map_public_prompt(agent: Agent, user: User, prompt: AgentPrompt): def _is_editable_prompt(prompt: AgentPrompt, agent: Agent, user: User) -> bool: - return prompt.user_id == user.id or (prompt.shared and (agent.user_id == user.id or (agent.team_id is not None and any(tr.role == Role.TEAM_OWNER and cast(Team, tr.team).id == agent.team_id for tr in user.team_roles)))) + return prompt.user_id == user.id or ( + prompt.shared and ( + agent.user_id == user.id or + (agent.team_id is not None and any( + tr.role in [Role.TEAM_OWNER, Role.TEAM_EDITOR] and cast(Team, tr.team).id == agent.team_id + for tr in user.team_roles + )) + ) + ) @router.post(AGENT_PROMPTS_PATH, response_model=AgentPromptPublic, status_code=status.HTTP_201_CREATED) diff --git a/src/backend/tero/agents/repos.py b/src/backend/tero/agents/repos.py index 441a445..14ced57 100644 --- a/src/backend/tero/agents/repos.py +++ b/src/backend/tero/agents/repos.py @@ -10,10 +10,10 @@ from ..core.env import env from ..core.repos import attr, scalar from ..files.domain import File +from ..teams.domain import GLOBAL_TEAM_ID, TeamRoleStatus from ..threads.domain import Thread, ThreadMessage from ..usage.domain import Usage from ..users.domain import User -from ..teams.domain import GLOBAL_TEAM_ID, TeamRoleStatus from .domain import AgentListItem, Agent, UserAgent, AgentToolConfig, AgentToolConfigFile diff --git a/src/backend/tero/agents/test_cases/api.py b/src/backend/tero/agents/test_cases/api.py index 7977041..59e225d 100644 --- a/src/backend/tero/agents/test_cases/api.py +++ b/src/backend/tero/agents/test_cases/api.py @@ -1,27 +1,36 @@ -import logging +import asyncio +from contextlib import AsyncExitStack from datetime import datetime, timezone -from typing import Annotated, List, cast +import logging +import re +from typing import Annotated, List, Optional, cast -from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks +from fastapi import APIRouter, Depends, HTTPException, status from fastapi.responses import StreamingResponse from sqlmodel.ext.asyncio.session import AsyncSession +from sse_starlette.event import ServerSentEvent from ...core.auth import get_current_user from ...core.repos import get_db from ...users.domain import User +from ...threads.domain import Thread, ThreadMessage, ThreadMessageOrigin, ThreadMessagePublic, MAX_THREAD_NAME_LENGTH +from ...threads.engine import AgentEngine from ...threads.repos import ThreadMessageRepository, ThreadRepository -from ...threads.domain import Thread, ThreadMessage, ThreadMessagePublic -from ..api import find_editable_agent, AGENT_PATH -from .domain import TestCase, PublicTestCase, NewTestCaseMessage, UpdateTestCaseMessage, UpdateTestCase, TestSuiteRun, TestSuiteRunStatus, RunTestSuiteRequest, TestCaseResult -from .repos import TestCaseRepository, TestCaseResultRepository, TestSuiteRunRepository -from .runner import cleanup_orphaned_suite_run, TestCaseRunner - - -TEST_CASES_PATH = f"{AGENT_PATH}/tests" +from ...tools.oauth import ToolOAuthRequest, build_tool_oauth_request_http_exception +from ..api import AGENT_PATH, find_editable_agent +from ..domain import Agent, CLONE_SUFFIX +from ..repos import AgentRepository +from .clone import clone_test_case +from .domain import TestCase, PublicTestCase, NewTestCaseMessage, UpdateTestCaseMessage, UpdateTestCase, TestSuiteRun, TestSuiteRunStatus, RunTestSuiteRequest, TestCaseResult, TestSuiteEventType +from .events import listen_for_suite_run_events, monitor_cancellation +from .name_generation import generate_test_case_name +from .repos import TestCaseRepository, TestCaseResultRepository, TestSuiteRunRepository, TestSuiteRunEventRepository +from .runner import BackgroundTestSuiteRunner logger = logging.getLogger(__name__) router = APIRouter() +TEST_CASES_PATH = f"{AGENT_PATH}/tests" @router.get(TEST_CASES_PATH, response_model=List[PublicTestCase]) @@ -37,6 +46,11 @@ async def add_test_case(agent_id: int, user: Annotated[User, Depends(get_current db: Annotated[AsyncSession, Depends(get_db)]) -> TestCase: agent = await find_editable_agent(agent_id, user, db) test_cases_repo = TestCaseRepository(db) + + empty_test_case = await test_cases_repo.find_empty_test_case(agent.id) + if empty_test_case: + return empty_test_case + test_cases = await test_cases_repo.find_by_agent(agent.id) thread = await ThreadRepository(db).add( Thread( @@ -66,17 +80,17 @@ async def get_test_suite_runs(agent_id: int, user: Annotated[User, Depends(get_c @router.post(TEST_SUITE_RUNS_PATH, status_code=status.HTTP_201_CREATED) async def run_test_suite( - agent_id: int, + agent_id: int, request: RunTestSuiteRequest, user: Annotated[User, Depends(get_current_user)], - db: Annotated[AsyncSession, Depends(get_db)], - background_tasks: BackgroundTasks -) -> StreamingResponse: + db: Annotated[AsyncSession, Depends(get_db)] +) -> TestSuiteRun: agent = await find_editable_agent(agent_id, user, db) + all_test_cases = await TestCaseRepository(db).find_by_agent(agent.id) if not all_test_cases: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST) - + if request.test_case_ids is not None: test_case_ids_set = set(request.test_case_ids) test_cases_to_run = [tc for tc in all_test_cases if tc.thread_id in test_case_ids_set] @@ -84,12 +98,20 @@ async def run_test_suite( raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST) else: test_cases_to_run = all_test_cases - + suite_repo = TestSuiteRunRepository(db) last_suite = await suite_repo.find_latest_by_agent_id(agent.id) if last_suite and last_suite.status == TestSuiteRunStatus.RUNNING: raise HTTPException(status_code=status.HTTP_409_CONFLICT) - + + # Initialize engine to trigger any tool authentication requirements before creating suite run + try: + engine = AgentEngine(agent, user.id, db) + async with AsyncExitStack() as stack: + await engine.load_tools(stack) + except ToolOAuthRequest as e: + raise build_tool_oauth_request_http_exception(e) + suite_run = await suite_repo.add(TestSuiteRun( agent_id=agent.id, status=TestSuiteRunStatus.RUNNING, @@ -99,13 +121,34 @@ async def run_test_suite( error_tests=0, skipped_tests=0 )) - - background_tasks.add_task(cleanup_orphaned_suite_run, suite_run.id, agent.id) - - return StreamingResponse( - TestCaseRunner(db).run_test_suite_stream(agent, all_test_cases, test_cases_to_run, user.id, suite_run), - media_type="text/event-stream", - ) + + stop_event = asyncio.Event() + runner = BackgroundTestSuiteRunner() + all_test_case_ids = [tc.thread_id for tc in all_test_cases] + test_case_ids_to_run = [tc.thread_id for tc in test_cases_to_run] + + async def run_wrapper(): + monitor_task = asyncio.create_task( + monitor_cancellation(suite_run.id, agent.id, stop_event) + ) + try: + await runner.run( + agent.id, + all_test_case_ids, + test_case_ids_to_run, + user.id, + suite_run.id, + stop_event + ) + finally: + stop_event.set() + try: + await monitor_task + except Exception: + pass + + asyncio.create_task(run_wrapper()) + return suite_run TEST_SUITE_RUN_PATH = f"{TEST_SUITE_RUNS_PATH}/{{suite_run_id}}" @@ -125,6 +168,70 @@ async def _find_test_suite_run(suite_run_id: int, agent_id: int, user: User, db: return suite_run +@router.delete(TEST_SUITE_RUN_PATH, status_code=status.HTTP_204_NO_CONTENT) +async def delete_test_suite_run(agent_id: int, suite_run_id: int, user: Annotated[User, Depends(get_current_user)], + db: Annotated[AsyncSession, Depends(get_db)]): + suite_run = await _find_test_suite_run(suite_run_id, agent_id, user, db) + await TestSuiteRunRepository(db).delete(suite_run) + + +@router.post(f"{TEST_SUITE_RUN_PATH}/stop", status_code=status.HTTP_200_OK) +async def stop_test_suite_run( + agent_id: int, + suite_run_id: int, + user: Annotated[User, Depends(get_current_user)], + db: Annotated[AsyncSession, Depends(get_db)]): + suite_run = await _find_test_suite_run(suite_run_id, agent_id, user, db) + if suite_run.status != TestSuiteRunStatus.RUNNING: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="suiteRunNotRunning") + + suite_run.status = TestSuiteRunStatus.CANCELLING + await TestSuiteRunRepository(db).save(suite_run) + + +@router.get(f"{TEST_SUITE_RUN_PATH}/stream") +async def stream_test_suite_updates( + agent_id: int, + suite_run_id: int, + user: Annotated[User, Depends(get_current_user)], + db: Annotated[AsyncSession, Depends(get_db)] +) -> StreamingResponse: + await _find_test_suite_run(suite_run_id, agent_id, user, db) + + async def event_generator(): + events_repo = TestSuiteRunEventRepository(db) + last_event_id = None + stop_event = asyncio.Event() + + try: + async for notification in listen_for_suite_run_events(suite_run_id, stop_event): + if notification is None: + events = await events_repo.find_current_test_events(suite_run_id) + else: + events = await events_repo.find_by_suite_run(suite_run_id, after_id=last_event_id) + + for db_event in events: + yield ServerSentEvent( + event=db_event.type, + data=db_event.data + ).encode() + last_event_id = db_event.id + if db_event.type in [TestSuiteEventType.COMPLETE.value, TestSuiteEventType.ERROR.value]: + stop_event.set() + return + + except asyncio.CancelledError: + stop_event.set() + except Exception: + logger.exception(f"Error streaming events for suite {suite_run_id}") + stop_event.set() + + return StreamingResponse( + event_generator(), + media_type="text/event-stream" + ) + + TEST_SUITE_RUN_RESULTS_PATH = f"{TEST_SUITE_RUN_PATH}/results" @@ -140,20 +247,20 @@ async def get_test_suite_run_results(agent_id: int, suite_run_id: int, user: Ann @router.get(TEST_SUITE_RUN_RESULT_MESSAGE_PATH, response_model=List[ThreadMessagePublic]) async def get_test_suite_run_result_messages( - agent_id: int, - suite_run_id: int, + agent_id: int, + suite_run_id: int, result_id: int, user: Annotated[User, Depends(get_current_user)], db: Annotated[AsyncSession, Depends(get_db)] ) -> List[ThreadMessage]: await _find_test_suite_run(suite_run_id, agent_id, user, db) - + results_repo = TestCaseResultRepository(db) result = await results_repo.find_by_id_and_suite_run_id(result_id, suite_run_id) - + if result is None or result.thread_id is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) - + return await ThreadMessageRepository(db).find_by_thread_id(result.thread_id) @@ -163,10 +270,10 @@ async def get_test_suite_run_result_messages( @router.get(TEST_CASE_PATH, response_model=PublicTestCase) async def find_test_case(agent_id: int, test_case_id: int, user: Annotated[User, Depends(get_current_user)], db: Annotated[AsyncSession, Depends(get_db)]) -> TestCase: - return await _find_test_case(test_case_id, agent_id, user, db) + return await find_test_case_by_id(test_case_id, agent_id, user, db) -async def _find_test_case(test_case_id: int, agent_id: int, user: User, db: Annotated[AsyncSession, Depends(get_db)]) -> TestCase: +async def find_test_case_by_id(test_case_id: int, agent_id: int, user: User, db: AsyncSession) -> TestCase: await find_editable_agent(agent_id, user, db) test_case = await TestCaseRepository(db).find_by_id(test_case_id, agent_id) if test_case is None: @@ -174,11 +281,43 @@ async def _find_test_case(test_case_id: int, agent_id: int, user: User, db: Anno return test_case +TEST_CASE_CLONE_PATH = f"{TEST_CASE_PATH}/clone" + + +@router.post(TEST_CASE_CLONE_PATH, response_model=PublicTestCase, status_code=status.HTTP_201_CREATED) +async def clone_test_case_endpoint( + agent_id: int, + test_case_id: int, + user: Annotated[User, Depends(get_current_user)], + db: Annotated[AsyncSession, Depends(get_db)] +) -> TestCase: + original_test_case = await find_test_case_by_id(test_case_id, agent_id, user, db) + clone_name = _build_cloned_test_case_name(original_test_case.thread.name) + return await clone_test_case(original_test_case, agent_id, user.id, db, test_case_name=clone_name) + + +def _build_cloned_test_case_name(original_name: Optional[str]) -> str: + base_name = (original_name or "Test Case").strip() + match = re.search(r"^(.*?)\s*\(copy(?: (\d+))?\)$", base_name) + + if match: + name_prefix = match.group(1).strip() + copy_number = int(match.group(2)) + 1 if match.group(2) else 2 + suffix = f"({CLONE_SUFFIX} {copy_number})" + else: + name_prefix = base_name + suffix = f"({CLONE_SUFFIX})" + + truncated_prefix = name_prefix[:(MAX_THREAD_NAME_LENGTH - len(suffix) - 1)].rstrip() + + return f"{truncated_prefix} {suffix}".strip() + + @router.put(TEST_CASE_PATH, response_model=PublicTestCase) async def update_test_case(agent_id: int, test_case_id: int, update: UpdateTestCase, user: Annotated[User, Depends(get_current_user)], db: Annotated[AsyncSession, Depends(get_db)]) -> TestCase: - test_case = await _find_test_case(test_case_id, agent_id, user, db) + test_case = await find_test_case_by_id(test_case_id, agent_id, user, db) test_case.thread.name = update.name await ThreadRepository(db).update(test_case.thread) return test_case @@ -186,8 +325,8 @@ async def update_test_case(agent_id: int, test_case_id: int, update: UpdateTestC @router.delete(TEST_CASE_PATH, status_code=status.HTTP_204_NO_CONTENT) async def delete_test_case(agent_id: int, test_case_id: int, user: Annotated[User, Depends(get_current_user)], - db: Annotated[AsyncSession, Depends(get_db)]) -> None: - test_case = await _find_test_case(test_case_id, agent_id, user, db) + db: Annotated[AsyncSession, Depends(get_db)]): + test_case = await find_test_case_by_id(test_case_id, agent_id, user, db) await TestCaseRepository(db).delete(test_case) @@ -197,15 +336,21 @@ async def delete_test_case(agent_id: int, test_case_id: int, user: Annotated[Use @router.get(TEST_CASE_MESSAGES_PATH, response_model=List[ThreadMessagePublic]) async def find_messages(agent_id: int, test_case_id: int, user: Annotated[User, Depends(get_current_user)], db: Annotated[AsyncSession, Depends(get_db)]) -> List[ThreadMessage]: - test_case = await _find_test_case(test_case_id, agent_id, user, db) + test_case = await find_test_case_by_id(test_case_id, agent_id, user, db) return await ThreadMessageRepository(db).find_by_thread_id(test_case.thread_id) @router.post(TEST_CASE_MESSAGES_PATH, response_model=ThreadMessagePublic) async def add_message(agent_id: int, test_case_id: int, message: NewTestCaseMessage, user: Annotated[User, Depends(get_current_user)], db: Annotated[AsyncSession, Depends(get_db)]) -> ThreadMessage: - test_case = await _find_test_case(test_case_id, agent_id, user, db) + test_case = await find_test_case_by_id(test_case_id, agent_id, user, db) repo = ThreadMessageRepository(db) + + last_message = await repo.find_last_by_thread_id(test_case.thread_id) + last_message_origin = last_message.origin if last_message else ThreadMessageOrigin.AGENT + if not last_message_origin != message.origin: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST) + added_message = await repo.add(ThreadMessage( thread_id=test_case.thread_id, text=message.text, @@ -213,16 +358,41 @@ async def add_message(agent_id: int, test_case_id: int, message: NewTestCaseMess )) test_case.last_update = added_message.timestamp await TestCaseRepository(db).save(test_case) + await _generate_test_case_name(test_case, user, db) return added_message +async def _generate_test_case_name(test_case: TestCase, user: User, db: AsyncSession): + if not test_case.is_default_name(): + return + + messages = await ThreadMessageRepository(db).find_by_thread_id(test_case.thread_id) + user_message = next((m for m in messages if m.origin == ThreadMessageOrigin.USER and m.text and m.text.strip()), None) + agent_message = next((m for m in messages if m.origin == ThreadMessageOrigin.AGENT and m.text and m.text.strip()), None) + if not user_message or not agent_message: + return + + agent = cast(Agent, await AgentRepository(db).find_by_id(test_case.agent_id)) + try: + generated_name = await generate_test_case_name(agent, user_message.text.strip(), agent_message.text.strip(), user.id, db) + except Exception: + logger.warning("Failed to generate test case name for test case %s", test_case.thread_id, exc_info=True) + return + + trimmed_name = generated_name.strip() + if not trimmed_name or trimmed_name == test_case.thread.name: + return + test_case.thread.name = trimmed_name + await ThreadRepository(db).update(test_case.thread) + + TEST_CASE_MESSAGE_PATH = f"{TEST_CASE_MESSAGES_PATH}/{{message_id}}" @router.put(TEST_CASE_MESSAGE_PATH, response_model=ThreadMessagePublic) async def update_message(agent_id: int, test_case_id: int, message_id: int, message: UpdateTestCaseMessage, user: Annotated[User, Depends(get_current_user)], db: Annotated[AsyncSession, Depends(get_db)]) -> ThreadMessage: - test_case = await _find_test_case(test_case_id, agent_id, user, db) + test_case = await find_test_case_by_id(test_case_id, agent_id, user, db) repo = ThreadMessageRepository(db) test_case_message = await repo.find_by_id(message_id) if test_case_message is None or test_case_message.thread_id != test_case.thread_id: diff --git a/src/backend/tero/agents/test_cases/clone.py b/src/backend/tero/agents/test_cases/clone.py new file mode 100644 index 0000000..1b0c500 --- /dev/null +++ b/src/backend/tero/agents/test_cases/clone.py @@ -0,0 +1,57 @@ +from datetime import datetime, timezone +from typing import Optional + +from sqlmodel.ext.asyncio.session import AsyncSession + +from ...threads.domain import Thread, ThreadMessage +from ...threads.repos import ThreadRepository, ThreadMessageRepository +from ..evaluators.repos import EvaluatorRepository +from .domain import TestCase +from .repos import TestCaseRepository + + +async def clone_test_case( + original_test_case: TestCase, + target_agent_id: int, + user_id: int, + db: AsyncSession, + test_case_name: Optional[str] = None +) -> TestCase: + thread_name = test_case_name if test_case_name is not None else original_test_case.thread.name + + cloned_thread = await ThreadRepository(db).add( + Thread( + agent_id=target_agent_id, + user_id=user_id, + is_test_case=True, + name=thread_name, + creation=datetime.now(timezone.utc) + ) + ) + + cloned_test_case = TestCase( + thread_id=cloned_thread.id, + agent_id=target_agent_id, + last_update=datetime.now(timezone.utc) + ) + + if original_test_case.evaluator_id: + evaluator_repo = EvaluatorRepository(db) + original_evaluator = await evaluator_repo.find_by_id(original_test_case.evaluator_id) + if original_evaluator: + cloned_evaluator = await evaluator_repo.save(original_evaluator.clone()) + cloned_test_case.evaluator_id = cloned_evaluator.id + + cloned_test_case = await TestCaseRepository(db).save(cloned_test_case) + + thread_message_repo = ThreadMessageRepository(db) + original_messages = await thread_message_repo.find_by_thread_id(original_test_case.thread_id) + for message in original_messages: + await thread_message_repo.add(ThreadMessage( + thread_id=cloned_thread.id, + origin=message.origin, + text=message.text, + timestamp=message.timestamp, + )) + + return cloned_test_case diff --git a/src/backend/tero/agents/test_cases/domain.py b/src/backend/tero/agents/test_cases/domain.py index 8ab83c4..d826858 100644 --- a/src/backend/tero/agents/test_cases/domain.py +++ b/src/backend/tero/agents/test_cases/domain.py @@ -2,8 +2,8 @@ from enum import Enum from typing import Any, Optional, List -from pydantic import BaseModel -from sqlmodel import SQLModel, Field, Relationship, Index +from pydantic import BaseModel, field_serializer +from sqlmodel import SQLModel, Field, Relationship, Index, Column, Text from ...core.domain import CamelCaseModel from ...threads.domain import Thread @@ -17,9 +17,13 @@ class TestCase(SQLModel, table=True): ) thread_id: int = Field(foreign_key="thread.id", primary_key=True) agent_id: int = Field(foreign_key="agent.id") + evaluator_id: Optional[int] = Field(default=None, foreign_key="evaluator.id") last_update: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) thread: Thread = Relationship() + def is_default_name(self) -> bool: + return not self.thread.name or self.thread.name.strip().lower().startswith("test case #") + class TestCaseResultStatus(Enum): PENDING = "PENDING" @@ -34,6 +38,8 @@ class TestSuiteRunStatus(Enum): RUNNING = "RUNNING" SUCCESS = "SUCCESS" FAILURE = "FAILURE" + CANCELLING = "CANCELLING" + class TestCaseEventType(Enum): PHASE = "phase" @@ -47,7 +53,6 @@ class TestCaseEventType(Enum): class TestSuiteEventType(Enum): - START = "suite.start" TEST_START = "suite.test.start" TEST_COMPLETE = "suite.test.complete" COMPLETE = "suite.complete" @@ -71,6 +76,18 @@ class TestSuiteRun(CamelCaseModel, table=True): skipped_tests: int = Field(default=0) +class TestSuiteRunEvent(CamelCaseModel, table=True): + __tablename__ : Any = "test_suite_run_event" + __table_args__ = ( + Index('ix_test_suite_run_event_test_suite_run_id_created_at', 'test_suite_run_id', 'created_at'), + ) + id: int = Field(primary_key=True, default=None) + test_suite_run_id: int = Field(foreign_key="test_suite_run.id") + type: str = Field(default="") + data: str = Field(default="{}", sa_column=Column(Text)) + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + + class TestCaseResult(CamelCaseModel, table=True): __tablename__ : Any = "test_case_result" __table_args__ = ( @@ -78,10 +95,12 @@ class TestCaseResult(CamelCaseModel, table=True): ) id: int = Field(primary_key=True, default=None) thread_id: Optional[int] = Field(default=None, foreign_key="thread.id") - test_case_id: int = Field(foreign_key="test_case.thread_id") + test_case_id: Optional[int] = Field(default=None, foreign_key="test_case.thread_id") test_suite_run_id: Optional[int] = Field(default=None, foreign_key="test_suite_run.id") status: TestCaseResultStatus = Field(default=TestCaseResultStatus.PENDING) + evaluator_analysis: Optional[str] = Field(default=None, sa_column=Column(Text)) executed_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + test_case_name: Optional[str] = Field(default=None) class PublicTestCase(CamelCaseModel): @@ -89,6 +108,13 @@ class PublicTestCase(CamelCaseModel): thread: Thread last_update: datetime + @field_serializer('last_update') + def serialize_last_update(self, value: datetime) -> str: + if value.tzinfo is not None: + value = value.astimezone(timezone.utc) + naive_value = value.replace(tzinfo=None) + return naive_value.isoformat() + class NewTestCaseMessage(BaseModel): text: str diff --git a/src/backend/tero/agents/test_cases/events.py b/src/backend/tero/agents/test_cases/events.py new file mode 100644 index 0000000..475a5a1 --- /dev/null +++ b/src/backend/tero/agents/test_cases/events.py @@ -0,0 +1,118 @@ +import asyncio +from dataclasses import dataclass +import json +import logging +from typing import AsyncIterator, Callable, Dict, Any, Optional, TypeVar + +import psycopg +from psycopg import sql +from sqlmodel.ext.asyncio.session import AsyncSession + +from ...core import repos +from .domain import TestSuiteRunStatus +from .repos import TestSuiteRunRepository + + +logger = logging.getLogger(__name__) +T = TypeVar('T') + + +@dataclass +class SuiteRunEventNotification: + suite_run_id: int + event_id: int + + +@dataclass +class SuiteStatusChange: + suite_run_id: int + status: TestSuiteRunStatus + + +def _get_db_url() -> str: + # SQLModel has no support for PostgreSQL LISTEN/NOTIFY, so we use psycopg directly. + # psycopg requires a plain PostgreSQL URL without SQLAlchemy's async driver prefixes. + url = repos.engine.url.render_as_string(hide_password=False) + return url.replace("+psycopg", "").replace("+asyncpg", "") + + +async def _listen_to_channel( + channel: str, + stop_event: asyncio.Event, + transform: Callable[[Dict[str, Any]], T], + timeout +) -> AsyncIterator[Optional[T]]: + db_url = _get_db_url() + + try: + async with await psycopg.AsyncConnection.connect(db_url, autocommit=True) as conn: + await conn.execute(sql.SQL("LISTEN {}").format(sql.Identifier(channel))) + + yield None # Signal that connection is established + + while not stop_event.is_set(): + try: + async for notify in conn.notifies(timeout=timeout): + yield transform(json.loads(notify.payload)) + except asyncio.TimeoutError: + # Timeout allows checking stop_event periodically + continue + except StopAsyncIteration: + break + except Exception: + logger.warning(f"Error in LISTEN/NOTIFY loop for {channel}", exc_info=True) + + +async def listen_for_suite_run_events( + suite_run_id: int, + stop_event: asyncio.Event, +) -> AsyncIterator[Optional[SuiteRunEventNotification]]: + def transform(payload: Dict[str, Any]) -> SuiteRunEventNotification: + return SuiteRunEventNotification( + suite_run_id=payload["suite_run_id"], + event_id=payload["event_id"] + ) + + async for event in _listen_to_channel("test_suite_events", stop_event, transform, 1): + if event is None or event.suite_run_id == suite_run_id: + yield event + + +async def listen_for_suite_run_status_changes( + suite_run_id: int, + stop_event: asyncio.Event, +) -> AsyncIterator[Optional[SuiteStatusChange]]: + def transform(payload: Dict[str, Any]) -> SuiteStatusChange: + return SuiteStatusChange( + suite_run_id=payload["suite_run_id"], + status=TestSuiteRunStatus(payload["status"]) + ) + + async for status_change in _listen_to_channel("test_suite_status", stop_event, transform, 1): + if status_change is None or status_change.suite_run_id == suite_run_id: + yield status_change + + +async def monitor_cancellation(suite_run_id: int, agent_id: int, stop_event: asyncio.Event) -> None: + while not stop_event.is_set(): + try: + async with AsyncSession(repos.engine) as session: + suite_run = await TestSuiteRunRepository(session).find_by_id_and_agent_id(suite_run_id, agent_id) + if suite_run and suite_run.status == TestSuiteRunStatus.CANCELLING: + stop_event.set() + return + elif not suite_run or suite_run.status != TestSuiteRunStatus.RUNNING: + return + + async for status_change in listen_for_suite_run_status_changes(suite_run_id, stop_event): + if status_change is None: + continue + elif status_change.status == TestSuiteRunStatus.CANCELLING: + stop_event.set() + return + elif status_change.status != TestSuiteRunStatus.RUNNING: + return + except Exception: + # DB or LISTEN/NOTIFY connection may fail; wait before retrying to avoid tight loops + logger.exception(f"Error monitoring cancellation for suite {suite_run_id}") + await asyncio.sleep(2) diff --git a/src/backend/tero/agents/test_cases/name_generation.py b/src/backend/tero/agents/test_cases/name_generation.py new file mode 100644 index 0000000..9d6c002 --- /dev/null +++ b/src/backend/tero/agents/test_cases/name_generation.py @@ -0,0 +1,59 @@ +from typing import cast + +from langchain_core.messages import AIMessage, HumanMessage +from sqlmodel.ext.asyncio.session import AsyncSession + +from ...ai_models import ai_factory +from ...ai_models.repos import AiModelRepository +from ...core.env import env +from ...threads.domain import MAX_THREAD_NAME_LENGTH +from ...usage.domain import MessageUsage +from ...usage.repos import UsageRepository +from ..domain import Agent + + +async def generate_test_case_name( + agent: Agent, + user_message: str, + agent_message: str, + user_id: int, + db: AsyncSession, +) -> str: + model = await AiModelRepository(db).find_by_id(env.internal_generator_model) + if not model: + raise RuntimeError("Internal generator model not found") + + message_usage = MessageUsage(user_id=user_id, agent_id=agent.id, model_id=model.id) + try: + prompt = _build_prompt(agent, user_message, agent_message) + llm = ai_factory.build_chat_model(model.id, env.internal_generator_temperature) + response = await llm.ainvoke([HumanMessage(prompt)]) + response = cast(AIMessage, response) + message_usage.increment_with_metadata(response.usage_metadata, model) + content = cast(str, response.content).strip() + if len(content) > MAX_THREAD_NAME_LENGTH: + content = content[:MAX_THREAD_NAME_LENGTH] + return content + finally: + await UsageRepository(db).add(message_usage) + + +def _build_prompt(agent: Agent, user_message: str, agent_message: str) -> str: + agent_name_fragment = ( + f"Agent name: {agent.name}\n" if agent.name else "" + ) + agent_description_fragment = ( + f"Agent description: {agent.description}\n" if agent.description else "" + ) + + return ( + "You generate descriptive test case titles.\n" + "Craft a concise, human-friendly title (40 characters or fewer when possible) " + "that highlights the capability or behavior the user message is asking for. " + "Focus on the topic being exercised rather than referencing validations or agent responses.\n" + "Only respond with the title, without quotes or additional text, and do not include the agent name or description.\n\n" + f"{agent_name_fragment}" + f"{agent_description_fragment}" + f"User message: {user_message}\n" + f"Expected agent response: {agent_message}" + ) diff --git a/src/backend/tero/agents/test_cases/repos.py b/src/backend/tero/agents/test_cases/repos.py index 5194bbf..4a54104 100644 --- a/src/backend/tero/agents/test_cases/repos.py +++ b/src/backend/tero/agents/test_cases/repos.py @@ -1,13 +1,13 @@ -from typing import List, Optional +from typing import List, Optional, cast from sqlalchemy.orm import selectinload -from sqlmodel import select, delete, and_, col, func +from sqlmodel import select, delete, update, and_, col, func from sqlmodel.ext.asyncio.session import AsyncSession from ...core.repos import attr, scalar -from ...threads.domain import Thread +from ...threads.domain import Thread, ThreadMessage from ...threads.repos import ThreadRepository -from .domain import TestCase, TestCaseResult, TestSuiteRun +from .domain import TestCase, TestCaseResult, TestSuiteRun, TestSuiteRunEvent, TestSuiteEventType class TestCaseRepository: @@ -34,6 +34,21 @@ async def find_by_agent(self, agent_id: int) -> List[TestCase]: ret = await self._db.exec(stmt) return list(ret.all()) + async def find_empty_test_case(self, agent_id: int) -> Optional[TestCase]: + stmt = ( + select(TestCase) + .join(Thread) + .outerjoin(ThreadMessage, and_(ThreadMessage.thread_id == Thread.id)) + .where(TestCase.agent_id == agent_id) + .group_by(col(TestCase.thread_id), col(Thread.creation)) + .having(func.count(col(ThreadMessage.id)) == 0) + .order_by(col(Thread.creation).asc()) + .limit(1) + .options(selectinload(attr(TestCase.thread))) + ) + ret = await self._db.exec(stmt) + return ret.one_or_none() + async def save(self, test_case: TestCase) -> TestCase: test_case = await self._db.merge(test_case) await self._db.commit() @@ -41,26 +56,17 @@ async def save(self, test_case: TestCase) -> TestCase: return test_case async def delete(self, test_case: TestCase) -> None: - result_stmt = select(TestCaseResult).where(and_(TestCaseResult.test_case_id == test_case.thread_id)) - result = await self._db.exec(result_stmt) - test_case_results = list(result.all()) - - delete_results_stmt = scalar(delete(TestCaseResult).where(and_(TestCaseResult.test_case_id == test_case.thread_id))) - await self._db.exec(delete_results_stmt) - - delete_test_case_stmt = scalar(delete(TestCase).where(and_(TestCase.thread_id == test_case.thread_id))) - await self._db.exec(delete_test_case_stmt) - - thread_repo = ThreadRepository(self._db) - - await thread_repo.delete(test_case.thread) - - for test_case_result in test_case_results: - execution_thread_stmt = select(Thread).where(Thread.id == test_case_result.thread_id) - execution_thread_result = await self._db.exec(execution_thread_stmt) - execution_thread = execution_thread_result.one_or_none() - if execution_thread: - await thread_repo.delete(execution_thread) + await self._db.exec(scalar( + update(TestCaseResult) + .where(and_(TestCaseResult.test_case_id == test_case.thread_id)) + .values(test_case_id=None) + )) + + await self._db.exec(scalar( + delete(TestCase).where(and_(TestCase.thread_id == test_case.thread_id)) + )) + + await ThreadRepository(self._db).delete(test_case.thread) class TestCaseResultRepository: @@ -86,7 +92,7 @@ async def find_by_suite_run_id(self, suite_run_id: int) -> List[TestCaseResult]: stmt = ( select(TestCaseResult) .where(TestCaseResult.test_suite_run_id == suite_run_id) - .order_by(col(TestCaseResult.executed_at).desc(), col(TestCaseResult.id).desc()) + .order_by(col(TestCaseResult.executed_at).asc(), col(TestCaseResult.id).asc()) ) ret = await self._db.exec(stmt) return list(ret.all()) @@ -133,3 +139,73 @@ async def save(self, suite_run: TestSuiteRun) -> TestSuiteRun: suite_run = await self._db.merge(suite_run) await self._db.commit() return suite_run + + async def delete(self, suite_run: TestSuiteRun) -> None: + result_stmt = select(TestCaseResult).where(TestCaseResult.test_suite_run_id == suite_run.id) + result = await self._db.exec(result_stmt) + test_case_results = list(result.all()) + + thread_repo = ThreadRepository(self._db) + for test_case_result in test_case_results: + if test_case_result.thread_id: + execution_thread_stmt = select(Thread).where(Thread.id == test_case_result.thread_id) + execution_thread_result = await self._db.exec(execution_thread_stmt) + execution_thread = cast(Thread, execution_thread_result.one_or_none()) + await thread_repo.delete(execution_thread) + + delete_results_stmt = scalar(delete(TestCaseResult).where(and_(TestCaseResult.test_suite_run_id == suite_run.id))) + await self._db.exec(delete_results_stmt) + + delete_suite_run_stmt = scalar(delete(TestSuiteRun).where(and_(TestSuiteRun.id == suite_run.id))) + await self._db.exec(delete_suite_run_stmt) + + await self._db.commit() + + +class TestSuiteRunEventRepository: + def __init__(self, db: AsyncSession): + self._db = db + + async def add(self, event: TestSuiteRunEvent) -> TestSuiteRunEvent: + self._db.add(event) + await self._db.commit() + await self._db.refresh(event) + return event + + async def find_by_suite_run(self, suite_run_id: int, after_id: Optional[int] = None) -> List[TestSuiteRunEvent]: + stmt = ( + select(TestSuiteRunEvent) + .where(TestSuiteRunEvent.test_suite_run_id == suite_run_id) + ) + if after_id: + stmt = stmt.where(TestSuiteRunEvent.id > after_id) + + stmt = stmt.order_by(col(TestSuiteRunEvent.id).asc()) + ret = await self._db.exec(stmt) + return list(ret.all()) + + async def find_current_test_events(self, suite_run_id: int) -> List[TestSuiteRunEvent]: + subq = ( + select(func.max(TestSuiteRunEvent.id)) + .where(TestSuiteRunEvent.test_suite_run_id == suite_run_id) + .where(TestSuiteRunEvent.type == TestSuiteEventType.TEST_START.value) + ) + last_start_id_result = await self._db.exec(subq) + last_start_id = last_start_id_result.one_or_none() + + if not last_start_id: + return [] + + stmt = ( + select(TestSuiteRunEvent) + .where(TestSuiteRunEvent.test_suite_run_id == suite_run_id) + .where(TestSuiteRunEvent.id >= last_start_id) + .order_by(col(TestSuiteRunEvent.id).asc()) + ) + ret = await self._db.exec(stmt) + return list(ret.all()) + + async def delete_by_suite_run_id(self, suite_run_id: int) -> None: + stmt = scalar(delete(TestSuiteRunEvent).where(and_(TestSuiteRunEvent.test_suite_run_id == suite_run_id))) + await self._db.exec(stmt) + await self._db.commit() diff --git a/src/backend/tero/agents/test_cases/runner.py b/src/backend/tero/agents/test_cases/runner.py index c3a0852..60617ea 100644 --- a/src/backend/tero/agents/test_cases/runner.py +++ b/src/backend/tero/agents/test_cases/runner.py @@ -2,7 +2,7 @@ from datetime import datetime, timezone import json import logging -from typing import List, cast, Any, AsyncIterator, Tuple +from typing import List, cast, Any, AsyncIterator, Tuple, Optional, Dict, Coroutine from langchain_core.callbacks import AsyncCallbackHandler from langchain_core.messages.ai import UsageMetadata @@ -11,52 +11,50 @@ from openevals.llm import create_async_llm_as_judge from openevals.types import EvaluatorResult from sqlmodel.ext.asyncio.session import AsyncSession -from sse_starlette.event import ServerSentEvent +from ...core import repos as repos_module from ...core.env import env +from ...agents.evaluators.domain import Evaluator +from ...agents.evaluators.repos import EvaluatorRepository from ...ai_models import ai_factory +from ...ai_models.domain import LlmModel, LlmModelType, LlmTemperature, ReasoningEffort from ...ai_models.repos import AiModelRepository -from ...ai_models.domain import LlmModel -from ...usage.domain import MessageUsage from ...threads.domain import ThreadMessage, Thread, ThreadMessageOrigin, AgentMessageEvent, AgentActionEvent -from ...threads.repos import ThreadMessageRepository, ThreadRepository from ...threads.engine import AgentEngine +from ...threads.repos import ThreadMessageRepository, ThreadRepository +from ...usage.domain import MessageUsage from ...usage.repos import UsageRepository -from ...core.repos import engine from ..domain import Agent -from .domain import TestCase, TestCaseResultStatus, TestCaseEventType, TestCaseResult, TestSuiteRun, TestSuiteEventType, TestSuiteRunStatus -from .repos import TestCaseResultRepository, TestSuiteRunRepository +from ..repos import AgentRepository +from .domain import TestCase, TestCaseResultStatus, TestCaseEventType, TestCaseResult, TestSuiteRun, TestSuiteEventType, TestSuiteRunStatus, TestSuiteRunEvent +from .repos import TestCaseResultRepository, TestSuiteRunRepository, TestSuiteRunEventRepository, TestCaseRepository -logger = logging.getLogger(__name__) -TEST_CASE_EVALUATOR_PROMPT = ChatPromptTemplate( - [ - ("system", "You are an expert evaluator assessing whether the actual output from an AI agent matches the expected output for a given test case."), - ("human", """ -Compare the actual output with the expected output based on these criteria: -1. Semantic equivalence - Does the actual output convey the same meaning as the expected output? -2. Completeness - Does the actual output contain all key information from the expected output? -3. Accuracy - Is the actual output factually correct when compared to the expected output? +logger = logging.getLogger(__name__) +EVALUATOR_HUMAN_MESSAGE = """ +Compare the actual output with the reference output based on these criteria: +1. Semantic equivalence - Does the actual output convey the same meaning as the reference output? +2. Completeness - Does the actual output contain all key information from the reference output? +3. Accuracy - Is the actual output factually correct when compared to the reference output? 4. Relevance - Does the actual output appropriately address the input? -5. Conciseness - Does the actual output avoid including extra information not present in the expected output? If the expected output is concise the response should also be concise for example if the expected output is "Agent response" the actual output should also be "Agent response" or similar. +5. Conciseness - Does the actual output avoid including extra information not present in the reference output? If the reference output is concise the response should also be concise for example if the reference output is "Agent response" the actual output should also be "Agent response" or similar. Be lenient with minor differences in wording, formatting, or style. Focus on whether the core meaning and key information match. Be strict about factual errors, missing critical information, or extraneous details that go beyond the expected output. -Respond with 'Y' if the actual output sufficiently matches the expected output, or 'N' if there are significant discrepancies. Then provide a brief explanation. +Respond with 'Y' if the actual output sufficiently matches the reference output, or 'N' if there are significant discrepancies. Then provide a brief explanation. Input: {{inputs}} -Expected Output: +Reference Output: {{reference_outputs}} Actual Output: {{outputs}} - -""")], - template_format="mustache" -) +""" +EVALUATOR_DEFAULT_TEMPERATURE = LlmTemperature.NEUTRAL +EVALUATOR_DEFAULT_REASONING_EFFORT = ReasoningEffort.MEDIUM class EvaluatorUsageTrackingCallback(AsyncCallbackHandler): @@ -75,7 +73,7 @@ async def on_llm_end(self, response: LLMResult, **kwargs) -> None: total_tokens=token_usage.get('total_tokens', 0) ), self.model) return - + # Fallback: try to extract from generation_info if response.generations: for generation_list in response.generations: @@ -91,33 +89,49 @@ async def on_llm_end(self, response: LLMResult, **kwargs) -> None: return -class TestCaseRunner: +class BackgroundTestSuiteRunner: + + async def _broadcast_event(self, db: AsyncSession, suite_run_id: int, event_type: str, data: Dict[str, Any]) -> None: + repo = TestSuiteRunEventRepository(db) + await repo.add(TestSuiteRunEvent( + test_suite_run_id=suite_run_id, + type=event_type, + data=json.dumps(data) + )) - def __init__(self, db: AsyncSession): - self._db = db - - async def run_test_case_stream(self, test_case: TestCase, agent: Agent, user_id: int, result: TestCaseResult) -> AsyncIterator[Tuple[TestCaseEventType, Any]]: - results_repo = TestCaseResultRepository(self._db) + def _build_test_case_evaluator_prompt(self, evaluator: Optional[Evaluator]) -> ChatPromptTemplate: + human_message = evaluator.prompt if evaluator else EVALUATOR_HUMAN_MESSAGE + return ChatPromptTemplate( + [("system", "You are an expert evaluator assessing whether the actual output from an AI agent matches the expected output for a given test case."), + ("human", human_message)], + template_format="mustache" + ) + + async def _mark_as_skipped(self, result: TestCaseResult, repo: TestCaseResultRepository) -> Tuple[TestCaseEventType, Dict[str, Any]]: + result.status = TestCaseResultStatus.SKIPPED + result.evaluator_analysis = None + await repo.save(result) + return (TestCaseEventType.PHASE, { + "phase": "completed", + "status": TestCaseResultStatus.SKIPPED.value, + "evaluation": None + }) + + async def _run_test_case_stream(self, db: AsyncSession, test_case: TestCase, agent: Agent, user_id: int, result: TestCaseResult, stop_event: asyncio.Event) -> AsyncIterator[Tuple[TestCaseEventType, Any]]: + results_repo = TestCaseResultRepository(db) try: - messages = await ThreadMessageRepository(self._db).find_by_thread_id(test_case.thread_id) + thread_message_repo = ThreadMessageRepository(db) + messages = await thread_message_repo.find_by_thread_id(test_case.thread_id) if not messages: - result.status = TestCaseResultStatus.SKIPPED - result = await results_repo.save(result) - yield (TestCaseEventType.METADATA, { "testCaseId": test_case.thread_id, "resultId": result.id, }) - - yield (TestCaseEventType.PHASE, { - "phase": "completed", - "status": TestCaseResultStatus.SKIPPED.value, - "evaluation": None - }) + yield await self._mark_as_skipped(result, results_repo) return - execution_thread = await ThreadRepository(self._db).add(Thread( + execution_thread = await ThreadRepository(db).add(Thread( agent_id=agent.id, user_id=user_id, is_test_case=True @@ -132,37 +146,66 @@ async def run_test_case_stream(self, test_case: TestCase, agent: Agent, user_id: "resultId": result.id, }) - user_input = messages[0].text - expected_output = messages[1].text if len(messages) > 1 else "" - - yield (TestCaseEventType.PHASE, {"phase": "executing"}) - - actual_output = "" - async for event_type, content in self._execute_agent_with_input_stream(agent, user_input, user_id, execution_thread.id): - if event_type == TestCaseEventType.AGENT_MESSAGE_COMPLETE: - actual_output += content["text"] - yield (event_type, content) - elif event_type in [TestCaseEventType.USER_MESSAGE, TestCaseEventType.AGENT_MESSAGE_START, - TestCaseEventType.AGENT_MESSAGE_CHUNK, TestCaseEventType.EXECUTION_STATUS]: - yield (event_type, content) + for i in range(0, len(messages), 2): + user_input = messages[i].text + expected_output = messages[i + 1].text if len(messages) > i + 1 else "" + execution_messages = await thread_message_repo.find_by_thread_id(execution_thread.id) - yield (TestCaseEventType.PHASE, {"phase": "evaluating"}) + yield (TestCaseEventType.PHASE, {"phase": "executing"}) - evaluation_result = await self._evaluate_test_case_result( - user_input, expected_output, actual_output, user_id, agent - ) + actual_output = "" - final_status = TestCaseResultStatus.SUCCESS if evaluation_result else TestCaseResultStatus.FAILURE - - result.status = final_status + async for event_type, content in self._execute_agent_with_input_stream( + db, agent, user_input, user_id, execution_thread.id, execution_messages, stop_event + ): + if event_type == TestCaseEventType.AGENT_MESSAGE_COMPLETE: + actual_output = content["text"] + yield (event_type, content) + elif event_type in [TestCaseEventType.USER_MESSAGE, TestCaseEventType.AGENT_MESSAGE_START, + TestCaseEventType.AGENT_MESSAGE_CHUNK, TestCaseEventType.EXECUTION_STATUS]: + yield (event_type, content) + + if stop_event.is_set(): + yield await self._mark_as_skipped(result, results_repo) + return + + yield (TestCaseEventType.PHASE, {"phase": "evaluating"}) + + evaluation_result = await self._evaluate_test_case_result( + db, user_input, expected_output, actual_output, user_id, agent, test_case, stop_event + ) + + if evaluation_result is None: + yield await self._mark_as_skipped(result, results_repo) + return + + evaluation_result_status = cast(bool, evaluation_result.get("score")) + evaluation_result_analysis = (evaluation_result.get("comment") or "").replace("Thus, the score should be: SCORE_YOU_ASSIGN.", "") + + if not evaluation_result_status: + result.status = TestCaseResultStatus.FAILURE + result.evaluator_analysis = evaluation_result_analysis + await results_repo.save(result) + + yield (TestCaseEventType.PHASE, { + "phase": "completed", + "status": TestCaseResultStatus.FAILURE.value, + "evaluation": { + "passed": False, + } + }) + return + + result.status = TestCaseResultStatus.SUCCESS + result.evaluator_analysis = None await results_repo.save(result) yield (TestCaseEventType.PHASE, { "phase": "completed", - "status": final_status.value, + "status": TestCaseResultStatus.SUCCESS.value, "evaluation": { - "passed": evaluation_result, + "passed": True, } }) @@ -178,26 +221,26 @@ async def run_test_case_stream(self, test_case: TestCase, agent: Agent, user_id: "status": TestCaseResultStatus.ERROR.value, "evaluation": None }) - - async def _execute_agent_with_input_stream(self, agent: Agent, user_input: str, user_id: int, thread_id: int) -> AsyncIterator[Tuple[TestCaseEventType, Any]]: - thread_message_repo = ThreadMessageRepository(self._db) + + async def _execute_agent_with_input_stream(self, db: AsyncSession, agent: Agent, user_input: str, user_id: int, thread_id: int, previous_messages: List[ThreadMessage], stop_event: asyncio.Event) -> AsyncIterator[Tuple[TestCaseEventType, Any]]: + thread_message_repo = ThreadMessageRepository(db) input_message = await thread_message_repo.add(ThreadMessage( text=user_input, origin=ThreadMessageOrigin.USER, timestamp=datetime.now(timezone.utc), thread_id=thread_id )) - + yield (TestCaseEventType.USER_MESSAGE, { "id": input_message.id, "text": input_message.text, }) - + input_message_usage = MessageUsage(user_id=user_id, agent_id=agent.id, model_id=agent.model_id, message_id=input_message.id) - engine = AgentEngine(agent, user_id, self._db) - + engine = AgentEngine(agent, user_id, db) + response_message = await thread_message_repo.add(ThreadMessage( - text="", + text="", origin=ThreadMessageOrigin.AGENT, timestamp=datetime.now(timezone.utc), thread_id=thread_id, @@ -207,9 +250,10 @@ async def _execute_agent_with_input_stream(self, agent: Agent, user_input: str, yield (TestCaseEventType.AGENT_MESSAGE_START, { "id": response_message.id }) - + + messages_for_engine = previous_messages + [input_message] complete_response = "" - async for event in engine.answer([input_message], input_message_usage, asyncio.Event()): + async for event in engine.answer(messages_for_engine, input_message_usage, stop_event): if isinstance(event, AgentActionEvent): yield (TestCaseEventType.EXECUTION_STATUS, event) elif isinstance(event, AgentMessageEvent): @@ -218,180 +262,269 @@ async def _execute_agent_with_input_stream(self, agent: Agent, user_input: str, "id": response_message.id, "chunk": event.content }) - + response_message.text = complete_response response_message.timestamp = datetime.now(timezone.utc) await thread_message_repo.update(response_message) - await UsageRepository(self._db).add(input_message_usage) - + await UsageRepository(db).add(input_message_usage) + yield (TestCaseEventType.AGENT_MESSAGE_COMPLETE, { "id": response_message.id, "text": complete_response, }) - + + async def _find_evaluator(self, db: AsyncSession, agent: Agent, test_case: TestCase) -> Optional[Evaluator]: + evaluator_repo = EvaluatorRepository(db) + test_case_evaluator = await evaluator_repo.find_by_id(test_case.evaluator_id) if test_case.evaluator_id else None + return test_case_evaluator if test_case_evaluator else await evaluator_repo.find_by_id(agent.evaluator_id) if agent.evaluator_id else None + + def _get_evaluator_temperature(self, llm_model: LlmModel, evaluator: Optional[Evaluator]) -> Optional[float]: + temperature = evaluator.temperature.get_float() if evaluator else EVALUATOR_DEFAULT_TEMPERATURE.get_float() + return temperature if llm_model.model_type == LlmModelType.CHAT else None + + def _get_evaluator_reasoning_effort(self, llm_model: LlmModel, evaluator: Optional[Evaluator]) -> Optional[str]: + reasoning_effort = evaluator.reasoning_effort.value.lower() if evaluator else EVALUATOR_DEFAULT_REASONING_EFFORT.value.lower() + return reasoning_effort if llm_model.model_type == LlmModelType.REASONING else None + async def _evaluate_test_case_result( - self, - user_input: str, - expected_output: str, + self, + db: AsyncSession, + user_input: str, + expected_output: str, actual_output: str, user_id: int, agent: Agent, - ) -> bool: - ai_model_repo = AiModelRepository(self._db) - evaluator_model = cast(LlmModel, await ai_model_repo.find_by_id(cast(str, env.internal_evaluator_model))) - + test_case: TestCase, + stop_event: asyncio.Event + ) -> Optional[EvaluatorResult]: + llm_model_repo = AiModelRepository(db) + evaluator = await self._find_evaluator(db, agent, test_case) + evaluator_model = cast(LlmModel, await llm_model_repo.find_by_id(evaluator.model_id if evaluator else cast(str, env.internal_evaluator_model))) evaluator_usage = MessageUsage(user_id=user_id, agent_id=agent.id, model_id=evaluator_model.id, message_id=None) usage_callback = EvaluatorUsageTrackingCallback(evaluator_usage, evaluator_model) - + judge_llm = ai_factory.build_chat_model( evaluator_model.id, - temperature=env.internal_evaluator_temperature + temperature=self._get_evaluator_temperature(evaluator_model, evaluator), + reasoning_effort=self._get_evaluator_reasoning_effort(evaluator_model, evaluator) ) judge_llm.callbacks.append(usage_callback) - - evaluator = create_async_llm_as_judge( - prompt=TEST_CASE_EVALUATOR_PROMPT, + judge = create_async_llm_as_judge( + prompt=self._build_test_case_evaluator_prompt(evaluator), judge=judge_llm, ) - - evaluation_result = await evaluator( + + judge_coroutine = judge( inputs=user_input, outputs=actual_output, reference_outputs=expected_output ) - - await UsageRepository(self._db).add(evaluator_usage) - - result_obj = cast(EvaluatorResult, evaluation_result) - return cast(bool, result_obj.get("score")) - async def run_test_suite_stream( + judge_task = asyncio.create_task(cast(Coroutine[Any, Any, EvaluatorResult | list[EvaluatorResult]], judge_coroutine)) + stop_task = asyncio.create_task(stop_event.wait()) + + done, _ = await asyncio.wait( + [judge_task, stop_task], + return_when=asyncio.FIRST_COMPLETED + ) + + if stop_task in done: + judge_task.cancel() + try: + await judge_task + except asyncio.CancelledError: + pass + return None + + stop_task.cancel() + try: + await stop_task + except asyncio.CancelledError: + pass + + evaluation_result = await judge_task + + await UsageRepository(db).add(evaluator_usage) + + return cast(EvaluatorResult, evaluation_result) + + async def run( self, - agent: Agent, - all_test_cases: List[TestCase], - test_cases_to_run: List[TestCase], + agent_id: int, + all_test_case_ids: List[int], + test_case_ids_to_run: List[int], user_id: int, - suite_run: TestSuiteRun, - ) -> AsyncIterator[bytes]: - suite_repo = TestSuiteRunRepository(self._db) - results_repo = TestCaseResultRepository(self._db) + suite_run_id: int, + stop_event: asyncio.Event, + ) -> None: + async with AsyncSession(repos_module.engine, expire_on_commit=False) as db: + try: + agent_repo = AgentRepository(db) + suite_repo = TestSuiteRunRepository(db) + test_case_repo = TestCaseRepository(db) + results_repo = TestCaseResultRepository(db) + + agent = cast(Agent, await agent_repo.find_by_id(agent_id)) + suite_run = cast(TestSuiteRun, await suite_repo.find_by_id_and_agent_id(suite_run_id, agent_id)) + all_test_cases = [cast(TestCase, await test_case_repo.find_by_id(tc_id, agent_id)) for tc_id in all_test_case_ids] + test_cases_to_run_objs = [tc for tc in all_test_cases if tc.thread_id in test_case_ids_to_run] + + pending_results: dict[int, TestCaseResult] = {} + for test_case in all_test_cases: + test_case_name = test_case.thread.name if test_case.thread else None + if test_case.thread_id in test_case_ids_to_run: + pending_result = await results_repo.save(TestCaseResult( + test_case_id=test_case.thread_id, + test_suite_run_id=suite_run.id, + status=TestCaseResultStatus.PENDING, + test_case_name=test_case_name + )) + pending_results[test_case.thread_id] = pending_result + else: + await results_repo.save(TestCaseResult( + test_case_id=test_case.thread_id, + test_suite_run_id=suite_run.id, + status=TestCaseResultStatus.SKIPPED, + test_case_name=test_case_name + )) + + passed = 0 + failed = 0 + errors = 0 + skipped = len(all_test_cases) - len(test_cases_to_run_objs) + + for index, test_case in enumerate(test_cases_to_run_objs): + if stop_event.is_set(): + async for event_type, event_data, counts_as_skip in self._skip_remaining_tests_background( + db, + test_cases_to_run_objs[index:], + pending_results, + results_repo + ): + if counts_as_skip: + skipped += 1 + await self._broadcast_event(db, suite_run.id, event_type, event_data) + break + + result = pending_results[test_case.thread_id] + await self._broadcast_event(db, suite_run.id, TestSuiteEventType.TEST_START.value, { + "testCaseId": test_case.thread_id, + "resultId": result.id + }) + + async for event_type, content in self._run_test_case_stream( + db, test_case, agent, user_id, pending_results[test_case.thread_id], stop_event + ): + if event_type == TestCaseEventType.PHASE and content.get("phase") == "completed": + status_value = content.get("status") + if status_value == TestCaseResultStatus.SUCCESS.value: + passed += 1 + elif status_value == TestCaseResultStatus.FAILURE.value: + failed += 1 + elif status_value == TestCaseResultStatus.SKIPPED.value: + skipped += 1 + elif status_value == TestCaseResultStatus.ERROR.value: + errors += 1 + else: + skipped += 1 + + await self._broadcast_event(db, suite_run.id, f"suite.test.{event_type.value}", + content.model_dump() if event_type == TestCaseEventType.EXECUTION_STATUS else content + ) + + await self._broadcast_event(db, suite_run.id, TestSuiteEventType.TEST_COMPLETE.value, { + "testCaseId": test_case.thread_id, + "resultId": result.id, + "status": result.status.value, + "evaluation": { + "analysis": result.evaluator_analysis + } + }) + + suite_run.completed_at = datetime.now(timezone.utc) + suite_run.total_tests = len(all_test_cases) + suite_run.passed_tests = passed + suite_run.failed_tests = failed + suite_run.error_tests = errors + suite_run.skipped_tests = skipped + suite_run.status = TestSuiteRunStatus.FAILURE if errors > 0 or failed > 0 or stop_event.is_set() else TestSuiteRunStatus.SUCCESS + + suite_run = await suite_repo.save(suite_run) + + await self._broadcast_event(db, suite_run.id, TestSuiteEventType.COMPLETE.value, { + "suiteRunId": suite_run.id, + "status": suite_run.status.value, + "totalTests": suite_run.total_tests, + "passed": suite_run.passed_tests, + "failed": suite_run.failed_tests, + "errors": suite_run.error_tests, + "skipped": suite_run.skipped_tests + }) - try: - test_case_ids_to_run = {tc.thread_id for tc in test_cases_to_run} - - pending_results: dict[int, TestCaseResult] = {} - for test_case in all_test_cases: - if test_case.thread_id in test_case_ids_to_run: - pending_result = await results_repo.save(TestCaseResult( - test_case_id=test_case.thread_id, - test_suite_run_id=suite_run.id, - status=TestCaseResultStatus.PENDING - )) - pending_results[test_case.thread_id] = pending_result - else: - await results_repo.save(TestCaseResult( - test_case_id=test_case.thread_id, - test_suite_run_id=suite_run.id, - status=TestCaseResultStatus.SKIPPED - )) - - yield ServerSentEvent(event=TestSuiteEventType.START.value, data=json.dumps({ - "suiteRunId": suite_run.id - })).encode() - - passed = 0 - failed = 0 - errors = 0 - skipped = len(all_test_cases) - len(test_cases_to_run) - - for test_case in test_cases_to_run: - result = pending_results[test_case.thread_id] - yield ServerSentEvent(event=TestSuiteEventType.TEST_START.value, data=json.dumps({ - "testCaseId": test_case.thread_id, - "resultId": result.id - })).encode() + except Exception: + logger.exception(f"Error running test suite for agent {agent_id}") + await self._cancel_suite_run(suite_run_id, agent_id, db) + await self._broadcast_event(db, suite_run_id, TestSuiteEventType.ERROR.value, {}) + finally: + await TestSuiteRunEventRepository(db).delete_by_suite_run_id(suite_run_id) - async for event_type, content in self.run_test_case_stream( - test_case, agent, user_id, pending_results[test_case.thread_id] - ): - if event_type == TestCaseEventType.PHASE and content.get("phase") == "completed": - status_value = content.get("status") - if status_value == TestCaseResultStatus.SUCCESS.value: - passed += 1 - elif status_value == TestCaseResultStatus.FAILURE.value: - failed += 1 - elif status_value == TestCaseResultStatus.SKIPPED.value: - skipped += 1 - else: - errors += 1 - - yield ServerSentEvent(event=f"suite.test.{event_type.value}", data=json.dumps(content.model_dump() if event_type == TestCaseEventType.EXECUTION_STATUS else content)).encode() - - yield ServerSentEvent(event=TestSuiteEventType.TEST_COMPLETE.value, data=json.dumps({ - "testCaseId": test_case.thread_id, - "resultId": result.id, - "status": result.status.value - })).encode() - - suite_run.completed_at = datetime.now(timezone.utc) - suite_run.total_tests = len(all_test_cases) - suite_run.passed_tests = passed - suite_run.failed_tests = failed - suite_run.error_tests = errors - suite_run.skipped_tests = skipped - suite_run.status = TestSuiteRunStatus.FAILURE if errors > 0 or failed > 0 else TestSuiteRunStatus.SUCCESS - - suite_run = await suite_repo.save(suite_run) - - yield ServerSentEvent(event=TestSuiteEventType.COMPLETE.value, data=json.dumps({ - "suiteRunId": suite_run.id, - "status": suite_run.status.value, - "totalTests": suite_run.total_tests, - "passed": suite_run.passed_tests, - "failed": suite_run.failed_tests, - "errors": suite_run.error_tests, - "skipped": suite_run.skipped_tests - })).encode() + async def _skip_remaining_tests_background( + self, + db: AsyncSession, + remaining_tests: List[TestCase], + pending_results: Dict[int, TestCaseResult], + results_repo: TestCaseResultRepository + ) -> AsyncIterator[Tuple[str, Dict[str, Any], bool]]: + for test_case in remaining_tests: + result = pending_results.get(test_case.thread_id) + if not result: + continue + result.status = TestCaseResultStatus.SKIPPED + result.evaluator_analysis = None + await results_repo.save(result) - except Exception: - logger.exception(f"Error streaming test suite for agent {agent.id}") - await cleanup_orphaned_suite_run(suite_run.id, agent.id) - suite_run.status = TestSuiteRunStatus.FAILURE - suite_run.completed_at = datetime.now(timezone.utc) - suite_run = await suite_repo.save(suite_run) - yield ServerSentEvent(event=TestSuiteEventType.ERROR.value, data=json.dumps({})).encode() - - -# Cleanup running or pending results for a suite in case of suite error or connection closed -# Has its own db session to be able to run on a background task -async def cleanup_orphaned_suite_run(suite_run_id: int, agent_id: int): - async with AsyncSession(engine, expire_on_commit=False) as db: + yield (f"suite.test.{TestCaseEventType.PHASE.value}", { + "phase": "completed", + "status": result.status.value, + "evaluation": None + }, True) + + yield (TestSuiteEventType.TEST_COMPLETE.value, { + "testCaseId": test_case.thread_id, + "resultId": result.id, + "status": result.status.value, + "evaluation": { + "analysis": result.evaluator_analysis + } + }, False) + + async def _cancel_suite_run(self, suite_run_id: int, agent_id: int, db: AsyncSession) -> None: try: results_repo = TestCaseResultRepository(db) suite_repo = TestSuiteRunRepository(db) - + current_suite = await suite_repo.find_by_id_and_agent_id(suite_run_id, agent_id) if not current_suite or current_suite.status != TestSuiteRunStatus.RUNNING: return - + all_results = await results_repo.find_by_suite_run_id(suite_run_id) for result in all_results: if result.status in [TestCaseResultStatus.PENDING, TestCaseResultStatus.RUNNING]: result.status = TestCaseResultStatus.SKIPPED db.add(result) - + current_suite.status = TestSuiteRunStatus.FAILURE current_suite.completed_at = datetime.now(timezone.utc) - + current_suite.error_tests = sum(1 for r in all_results if r.status == TestCaseResultStatus.ERROR) current_suite.passed_tests = sum(1 for r in all_results if r.status == TestCaseResultStatus.SUCCESS) current_suite.failed_tests = sum(1 for r in all_results if r.status == TestCaseResultStatus.FAILURE) current_suite.skipped_tests = sum(1 for r in all_results if r.status == TestCaseResultStatus.SKIPPED) - + db.add(current_suite) - + await db.commit() - + except Exception as error: logger.exception(f"Error during background cleanup for suite {suite_run_id}: {error}") try: diff --git a/src/backend/tero/agents/tool_file.py b/src/backend/tero/agents/tool_file.py index d1f9749..0300345 100644 --- a/src/backend/tero/agents/tool_file.py +++ b/src/backend/tero/agents/tool_file.py @@ -1,16 +1,20 @@ import logging +from typing import cast from sqlmodel.ext.asyncio.session import AsyncSession from fastapi.background import BackgroundTasks +from ..core import repos as repos_module from ..files.domain import File, FileStatus, FileMetadata from ..files.file_quota import QuotaExceededError from ..files.parser import add_encoding_to_content_type from ..files.repos import FileRepository from ..tools.core import AgentTool +from ..tools.repos import ToolRepository from ..users.domain import User -from .domain import AgentToolConfigFile -from .repos import AgentToolConfigFileRepository +from ..users.repos import UserRepository +from .domain import AgentToolConfigFile, Agent +from .repos import AgentToolConfigFileRepository, AgentRepository logger = logging.getLogger(__name__) @@ -20,19 +24,28 @@ async def upload_tool_file(file: File, tool: AgentTool, agent_id: int, user: Use file.content_type = add_encoding_to_content_type(file.content_type, file.content) file = await FileRepository(db).add(file) await AgentToolConfigFileRepository(db).add(AgentToolConfigFile(agent_id=agent_id, tool_id=tool.id, file_id=file.id)) - background_tasks.add_task(_add_tool_file, file, user, tool, db) + # Pass file_id instead of file object to avoid session conflicts + # The background task will create its own session and re-fetch the file + background_tasks.add_task(_add_tool_file, file.id, user.id, tool.id, agent_id, tool.config) return FileMetadata.from_file(file) -async def _add_tool_file(f: File, user: User, tool: AgentTool, db: AsyncSession): - try: - await tool.add_file(f, user) - f.status = FileStatus.PROCESSED - except QuotaExceededError: - f.status = FileStatus.QUOTA_EXCEEDED - logger.error(f"Quota exceeded for user {user.id} when adding tool file {f.id} {f.name}") - except Exception as e: - f.status = FileStatus.ERROR - logger.error(f"Error adding tool file {f.id} {f.name} {e}", exc_info=True) - finally: - await FileRepository(db).update(f) +async def _add_tool_file(file_id: int, user_id: int, tool_id: str, agent_id: int, tool_config: dict): + async with AsyncSession(repos_module.engine, expire_on_commit=False) as db: + f = cast(File, await FileRepository(db).find_by_id(file_id)) + user = cast(User, await UserRepository(db).find_by_id(user_id)) + agent = cast(Agent, await AgentRepository(db).find_by_id(agent_id)) + tool = cast(AgentTool, ToolRepository().find_by_id(tool_id)) + tool.configure(agent, user_id, tool_config, db) + + try: + await tool.add_file(f, user) + f.status = FileStatus.PROCESSED + except QuotaExceededError: + f.status = FileStatus.QUOTA_EXCEEDED + logger.error(f"Quota exceeded for user {user_id} when adding tool file {file_id} {f.name}") + except Exception as e: + f.status = FileStatus.ERROR + logger.error(f"Error adding tool file {file_id} {f.name} {e}", exc_info=True) + finally: + await FileRepository(db).update(f) diff --git a/src/backend/tero/ai_models/ai_factory.py b/src/backend/tero/ai_models/ai_factory.py index ea1a0b2..70e1dec 100644 --- a/src/backend/tero/ai_models/ai_factory.py +++ b/src/backend/tero/ai_models/ai_factory.py @@ -18,6 +18,7 @@ if env.google_api_key: providers.append(GoogleProvider()) + def get_provider(model: str) -> AiModelProvider: for provider in providers: if provider.supports_model(model): @@ -35,5 +36,3 @@ def build_chat_model(model: str, temperature: Optional[float]=None, reasoning_ef def build_streaming_chat_model(model: str, temperature: Optional[float]=None, reasoning_effort: Optional[str]=None) -> Any: return get_provider(model).build_streaming_chat_model(model, temperature, reasoning_effort) - - \ No newline at end of file diff --git a/src/backend/tero/ai_models/aws_provider.py b/src/backend/tero/ai_models/aws_provider.py index 3b7d703..ca499df 100644 --- a/src/backend/tero/ai_models/aws_provider.py +++ b/src/backend/tero/ai_models/aws_provider.py @@ -1,7 +1,5 @@ -import io from typing import Optional - import boto3 from langchain_aws import ChatBedrockConverse from langchain_core.language_models.chat_models import BaseChatModel diff --git a/src/backend/tero/ai_models/azure_provider.py b/src/backend/tero/ai_models/azure_provider.py index d125729..baafc51 100644 --- a/src/backend/tero/ai_models/azure_provider.py +++ b/src/backend/tero/ai_models/azure_provider.py @@ -47,9 +47,9 @@ def build_embedding(self, model: str) -> AzureOpenAIEmbeddings: deployment = env.azure_model_deployments[model] return AzureOpenAIEmbeddings( azure_endpoint=env.azure_endpoints[deployment.endpoint_index], - azure_deployment=deployment, + azure_deployment=deployment.deployment_name, api_version=env.azure_api_version, - api_key=env.azure_endpoints[deployment.endpoint_index]) + api_key=env.azure_api_keys[deployment.endpoint_index]) class ReasoningTokenCountingAzureChatOpenAI(AzureChatOpenAI): diff --git a/src/backend/tero/ai_models/domain.py b/src/backend/tero/ai_models/domain.py index f4c0bdb..4ded5ab 100644 --- a/src/backend/tero/ai_models/domain.py +++ b/src/backend/tero/ai_models/domain.py @@ -43,6 +43,21 @@ def is_basic(self) -> bool: return self.id in env.agent_basic_models +class LlmTemperature(Enum): + CREATIVE = 'CREATIVE' + NEUTRAL = 'NEUTRAL' + PRECISE = 'PRECISE' + + def get_float(self): + return env.temperatures[self.value] + + +class ReasoningEffort(Enum): + LOW = 'LOW' + MEDIUM = 'MEDIUM' + HIGH = 'HIGH' + + class AiModelProvider(ABC): def build_chat_model(self, model: str, temperature: Optional[float]=None, reasoning_effort: Optional[str] = None) -> BaseChatModel: diff --git a/src/backend/tero/ai_models/google_provider.py b/src/backend/tero/ai_models/google_provider.py index e51b088..e93ffaf 100644 --- a/src/backend/tero/ai_models/google_provider.py +++ b/src/backend/tero/ai_models/google_provider.py @@ -1,7 +1,5 @@ -import io from typing import Optional -from langchain_core.embeddings import Embeddings from langchain_core.language_models.chat_models import BaseChatModel from langchain_google_genai import ChatGoogleGenerativeAI diff --git a/src/backend/tero/ai_models/openai_provider.py b/src/backend/tero/ai_models/openai_provider.py index 9019f27..675e40d 100644 --- a/src/backend/tero/ai_models/openai_provider.py +++ b/src/backend/tero/ai_models/openai_provider.py @@ -38,6 +38,7 @@ def build_embedding(self, model: str) -> Embeddings: api_key=env.openai_api_key, model=env.openai_model_id_mapping[model]) + class ReasoningTokenCountingChatOpenAI(ChatOpenAI): # we override this method which is the one used by get_num_tokens_from_messages to count the tokens @@ -45,7 +46,7 @@ def _get_encoding_model(self) -> tuple[str, tiktoken.Encoding]: return get_encoding_model(self.model_name, lambda: ChatOpenAI._get_encoding_model(self)) -def get_encoding_model(model_name: str, default: Callable[[], tuple[str, tiktoken.Encoding]]) -> tuple[str, tiktoken.Encoding]: +def get_encoding_model(model_name: Optional[str], default: Callable[[], tuple[str, tiktoken.Encoding]]) -> tuple[str, tiktoken.Encoding]: if model_name and model_name.startswith("o"): # we return gpt-4o for o- series since it is supported by existing implementation of get_num_tokens_from_messages return "gpt-4o", tiktoken.get_encoding("o200k_base") diff --git a/src/backend/tero/api.py b/src/backend/tero/api.py index e82e155..3c3a2ae 100644 --- a/src/backend/tero/api.py +++ b/src/backend/tero/api.py @@ -8,12 +8,13 @@ from fastapi.staticfiles import StaticFiles from .agents.api import router as agents_router +from .agents.evaluators.api import router as evaluators_router from .agents.prompts.api import router as agents_prompts_router from .agents.test_cases.api import router as test_cases_router from .ai_models.api import router as ai_models_router -from .core.env import env from .core.api import BASE_PATH from .core.domain import CamelCaseModel +from .core.env import env from .external_agents.api import router as external_agents_router from .mcp_server import setup_mcp_server from .teams.api import router as teams_router @@ -42,13 +43,14 @@ def filter(self, record): app.add_middleware(GZipMiddleware) if env.frontend_path: app.mount("/assets", StaticFiles(directory=os.path.join(env.frontend_path, "assets")), name="assets") - setup_mcp_server(app) + def _should_serve_frontend(path: str) -> bool: api_paths = ["/api", "/assets", "/mcp", "/.well-known/", "/resources/"] return not any(path.startswith(prefix) for prefix in api_paths) and path != "/manifest.json" + @app.middleware("http") async def frontend_router(request: Request, call_next) -> Response: if env.frontend_path and _should_serve_frontend(request.url.path): @@ -82,6 +84,7 @@ class Manifest(CamelCaseModel): id: str contact_email: str auth: ManifestAuthConfig + disable_publish_global: bool @app.get("/manifest.json") @@ -92,6 +95,7 @@ async def manifest() -> Manifest: auth=ManifestAuthConfig( url=env.frontend_openid_url or env.openid_url, client_id=env.openid_client_id, scope=env.openid_scope ), + disable_publish_global=env.disable_publish_global or False ) @@ -110,6 +114,7 @@ async def health_check(): users_router, teams_router, external_agents_router, - test_cases_router + test_cases_router, + evaluators_router ]: app.include_router(router) diff --git a/src/backend/tero/core/auth.py b/src/backend/tero/core/auth.py index ceb99c4..b7365ef 100644 --- a/src/backend/tero/core/auth.py +++ b/src/backend/tero/core/auth.py @@ -64,7 +64,7 @@ async def _update_keys(self): async with self._http_cli.get(jwks_uri) as ret_resp: ret_resp.raise_for_status() self._keys = await ret_resp.json() - self.keys_last_update = datetime.datetime.now(datetime.UTC) + self._last_update = datetime.datetime.now(datetime.UTC) async def get_http_cli() -> AsyncGenerator[aiohttp.ClientSession, None]: @@ -95,12 +95,6 @@ async def _decode_token(token: str, openid_config: Annotated[OpenIdConfig, Depen async def get_current_user(token: Annotated[Optional[str], Depends(auth_scheme)], open_id_config: Annotated[Optional[OpenIdConfig], Depends(_get_openid_config)], db: Annotated[AsyncSession, Depends(get_db)]) -> User: - if config_url is None: - user = await UserRepository(db).find_by_username('test') - if user is None: - raise _build_auth_exception() - return user - try: if not token or not open_id_config: logger.warning("No token or open_id_config could be found") diff --git a/src/backend/tero/core/env.py b/src/backend/tero/core/env.py index 9cab641..df4cb87 100644 --- a/src/backend/tero/core/env.py +++ b/src/backend/tero/core/env.py @@ -1,19 +1,22 @@ +import logging import os -from typing import List, Optional - import re +from typing import List, Optional from pydantic import SecretStr, field_validator, BaseModel, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict +logger = logging.getLogger(__name__) + + class AzureModelDeployment(BaseModel): deployment_name: str endpoint_index: int class Settings(BaseSettings): - model_config = SettingsConfigDict(enable_decoding=False) + model_config = SettingsConfigDict(enable_decoding=False, extra="allow") db_url : str secret_encryption_key : SecretStr @@ -24,6 +27,7 @@ class Settings(BaseSettings): openid_client_id : str openid_scope : str allowed_users : list[str] = [] + disable_publish_global : Optional[bool] = False contact_email : str azure_app_insights_connection : Optional[str] = None azure_endpoints : list[str] @@ -38,7 +42,6 @@ class Settings(BaseSettings): internal_generator_model : str internal_generator_temperature : float internal_evaluator_model : Optional[str] = None - internal_evaluator_temperature : Optional[float] = None agent_default_model : Optional[str] = None agent_basic_models : List[str] default_agent_name : str @@ -105,7 +108,12 @@ def decode_list(cls, v: str) -> list[str]: def set_defaults(self): self.agent_default_model = self.agent_default_model or self.internal_generator_model self.internal_evaluator_model = self.internal_evaluator_model or self.internal_generator_model - self.internal_evaluator_temperature = self.internal_evaluator_temperature or self.internal_generator_temperature + return self + + @model_validator(mode="after") + def warn_extra_fields(self): + if self.__pydantic_extra__: + logger.warning(f"Ignoring unexpected variables found in .env file: {', '.join(self.__pydantic_extra__.keys())}. Please review if you mistyped the variable name or if the variable name has changed in a recent version of the application.") return self diff --git a/src/backend/tero/external_agents/api.py b/src/backend/tero/external_agents/api.py index 31816b4..c1fd844 100644 --- a/src/backend/tero/external_agents/api.py +++ b/src/backend/tero/external_agents/api.py @@ -13,7 +13,6 @@ logger = logging.getLogger(__name__) router = APIRouter() - EXTERNAL_AGENTS_PATH = f"{BASE_PATH}/external-agents" diff --git a/src/backend/tero/files/file_processor.py b/src/backend/tero/files/file_processor.py index d187455..af0fe0e 100644 --- a/src/backend/tero/files/file_processor.py +++ b/src/backend/tero/files/file_processor.py @@ -16,6 +16,7 @@ logger = logging.getLogger(__name__) + def get_encoding(content_type: Optional[str]) -> str: charset_param = '; charset=' encoding = content_type.split(charset_param, 1)[1] if content_type and charset_param in content_type else 'utf-8' @@ -38,7 +39,17 @@ def supports(self, file: File) -> bool: def extract_text(self, file: File, file_quota: FileQuota) -> str: encoding = get_encoding(file.content_type) - return file.content.decode(encoding) + try: + return file.content.decode(encoding) + except (UnicodeDecodeError, LookupError): + logger.warning(f"Failed to decode {file.name} with {encoding}. Trying fallback encodings.", exc_info=True) + for fallback_encoding in [ e for e in ['utf-8', 'latin-1', 'cp1252'] if e != encoding]: + try: + return file.content.decode(fallback_encoding) + except (UnicodeDecodeError, LookupError): + continue + logger.warning(f"All encodings failed for {file.name}, using {encoding} with error replacement") + return file.content.decode(encoding, errors='replace') class Sheet(ABC): @@ -174,4 +185,4 @@ def extract_text(self, file: File, file_quota: FileQuota) -> str: logger.error(f"Invalid image file {file.name}: {e}") raise ValueError(f"Invalid image file: {file.name}") - return f"Image file: {file.name}" \ No newline at end of file + return f"Image file: {file.name}" diff --git a/src/backend/tero/files/file_quota.py b/src/backend/tero/files/file_quota.py index 69cad1e..15fbaed 100644 --- a/src/backend/tero/files/file_quota.py +++ b/src/backend/tero/files/file_quota.py @@ -11,9 +11,11 @@ logger = logging.getLogger(__name__) + class QuotaExceededError(Exception): pass + class CurrentQuota: def __init__(self, current_usage: float, user_quota: float): self.current_usage = current_usage @@ -36,5 +38,3 @@ def has_reached_token_limit(self, current_content: str) -> bool: def has_reached_quota_limit(self) -> bool: return self.current_quota.current_usage + self.pdf_parsing_usage.usd_cost > self.current_quota.user_quota - - \ No newline at end of file diff --git a/src/backend/tero/files/parser.py b/src/backend/tero/files/parser.py index 0d8c0ac..65d7ee9 100644 --- a/src/backend/tero/files/parser.py +++ b/src/backend/tero/files/parser.py @@ -8,12 +8,15 @@ from ..files.file_processor import BaseFileProcessor, PlainTextFileProcessor, XlsxFileProcessor, XlsFileProcessor, BasicPdfFileProcessor, EnhancedPdfFileProcessor, ImageFileProcessor from ..files.file_quota import FileQuota + logger = logging.getLogger(__name__) + class UnsupportedFileError(Exception): def __init__(self, file_name: str): super().__init__(f"Unsupported file type: {file_name}") + def add_encoding_to_content_type(content_type: Optional[str], content: bytes) -> str: # add the encoding to the content type so later on it can be used (for exammple in tools file processing) and is avaible to frontend for proper file visualization if content_type and content_type.startswith('text/') and not 'charset=' in content_type: @@ -22,6 +25,7 @@ def add_encoding_to_content_type(content_type: Optional[str], content: bytes) -> content_type = f"{content_type}; charset={encoding.lower()}" return content_type or "application/octet-stream" + def find_file_processor(file: File) -> BaseFileProcessor: processors = [ PlainTextFileProcessor(), @@ -35,6 +39,7 @@ def find_file_processor(file: File) -> BaseFileProcessor: raise UnsupportedFileError(file.name) return found + async def extract_file_text(file: File, file_quota: FileQuota) -> str: processor = find_file_processor(file) return await asyncio.to_thread(processor.extract_text, file, file_quota) diff --git a/src/backend/tero/files/pdf_processor.py b/src/backend/tero/files/pdf_processor.py index 3eca8e5..fe05f20 100644 --- a/src/backend/tero/files/pdf_processor.py +++ b/src/backend/tero/files/pdf_processor.py @@ -16,11 +16,12 @@ from ..files.domain import File from ..files.file_quota import FileQuota, QuotaExceededError -logger = logging.getLogger(__name__) +logger = logging.getLogger(__name__) T = TypeVar('T', bound='BoundedElement') PAGES_CHUNK_SIZE = 50 + @dataclass class BoundingBox: x: float @@ -45,6 +46,7 @@ def from_polygon(cls, polygon: list) -> 'BoundingBox | None': def contains(self, other: 'BoundingBox') -> bool: return (other.y >= self.y and other.y + other.height <= self.y + self.height) + @dataclass class BoundedElement(Generic[T]): content: str @@ -56,6 +58,7 @@ class BoundedElement(Generic[T]): def create(cls: type[T], content: str, y: float, height: float, bbox: Optional[BoundingBox] = None) -> T: return cls(content=content, y=y, height=height, bbox=bbox) + @dataclass class BoundedParagraph(BoundedElement['BoundedParagraph']): @classmethod @@ -76,6 +79,7 @@ def from_paragraph(cls, paragraph: dict) -> Optional['BoundedParagraph']: else: return cls.create(content=content, y=0.0, height=0.0, bbox=None) + @dataclass class BoundedTable(BoundedElement['BoundedTable']): @classmethod @@ -177,6 +181,7 @@ def _write_pdf_chunk(self, content: bytes, start_page: int, end_page: int) -> by logger.warning(f"Failed to write PDF chunk {start_page}-{end_page}: {e}. Using original content.") return content + class BasicPDFProcessor(BasePDFProcessor): def extract_content(self, upload_file: File, file_quota: FileQuota) -> str: @@ -214,6 +219,7 @@ def _process_with_pypdfium2(self, pdf_chunk: bytes, start_page_offset: int = 0) def _clean_pypdfium2_content(self, content: str) -> str: return content.replace("\r", "").strip() + class EnhancedPDFProcessor(BasePDFProcessor): def __init__(self, endpoint: str, key: str): @@ -299,4 +305,3 @@ def _create_page_elements(self, paragraphs: list, tables: list) -> list[BoundedE def _combine_elements_content(self, elements: list[BoundedElement]) -> str: elements.sort(key=lambda x: x.y) return "\n".join(element.content for element in elements) - \ No newline at end of file diff --git a/src/backend/tero/teams/api.py b/src/backend/tero/teams/api.py index c584288..5fc7e06 100644 --- a/src/backend/tero/teams/api.py +++ b/src/backend/tero/teams/api.py @@ -6,16 +6,16 @@ from ..agents.repos import AgentRepository from ..core.api import BASE_PATH from ..core.auth import get_current_user -from ..core.repos import get_db from ..core.env import env +from ..core.repos import get_db from ..users.domain import User from ..users.repos import UserRepository from .domain import GLOBAL_TEAM_ID, AddUsersToTeam, Role, TeamRole, TeamRoleStatus,\ TeamRoleUpdate, TeamUser, TeamCreate, Team, TeamUpdate from .repos import TeamRepository -router = APIRouter() +router = APIRouter() TEAMS_PATH = f"{BASE_PATH}/teams" TEAM_USERS_PATH = f"{TEAMS_PATH}/{{team_id}}/users" TEAM_PATH = f"{TEAMS_PATH}/{{team_id}}" diff --git a/src/backend/tero/teams/domain.py b/src/backend/tero/teams/domain.py index a84e073..64f8b8f 100644 --- a/src/backend/tero/teams/domain.py +++ b/src/backend/tero/teams/domain.py @@ -6,12 +6,15 @@ from ..core.domain import CamelCaseModel + MY_TEAM_ID = 0 GLOBAL_TEAM_ID = 1 + class Role(str, Enum): TEAM_OWNER = "owner" TEAM_MEMBER = "member" + TEAM_EDITOR = "editor" class Team(SQLModel, table=True): @@ -23,6 +26,7 @@ class Team(SQLModel, table=True): class TeamRoleUpdate(BaseModel): role: Role + class TeamRoleStatus(str, Enum): ACCEPTED = "accepted" PENDING = "pending" @@ -53,6 +57,7 @@ class AddUsersToTeam(CamelCaseModel): username: str role: Role + class TeamUser(CamelCaseModel): id: int username: str @@ -61,8 +66,10 @@ class TeamUser(CamelCaseModel): role_status: TeamRoleStatus verified: bool = False + class TeamUpdate(CamelCaseModel): name: str + class TeamCreate(TeamUpdate): users: Optional[List[AddUsersToTeam]] = None diff --git a/src/backend/tero/teams/repos.py b/src/backend/tero/teams/repos.py index 4f9d26d..44e287d 100644 --- a/src/backend/tero/teams/repos.py +++ b/src/backend/tero/teams/repos.py @@ -1,13 +1,14 @@ from typing import List, Optional +from sqlalchemy.orm import selectinload from sqlmodel import select, delete, and_, col from sqlmodel.ext.asyncio.session import AsyncSession -from sqlalchemy.orm import selectinload from ..core.repos import scalar, attr from ..users.domain import User from .domain import Team, TeamRole, TeamRoleStatus, TeamUser, Role, GLOBAL_TEAM_ID + class TeamRepository: def __init__(self, db: AsyncSession): diff --git a/src/backend/tero/threads/api.py b/src/backend/tero/threads/api.py index f8d1b79..aaa34b0 100644 --- a/src/backend/tero/threads/api.py +++ b/src/backend/tero/threads/api.py @@ -20,25 +20,24 @@ from ..core.repos import get_db from ..files.api import build_file_download_response from ..files.domain import File, FileStatus, FileMetadata, FileProcessor, FileMetadataWithContent -from ..files.parser import add_encoding_to_content_type, extract_file_text from ..files.file_quota import FileQuota, CurrentQuota, QuotaExceededError +from ..files.parser import add_encoding_to_content_type, extract_file_text from ..files.repos import FileRepository from ..tools.oauth import ToolOAuthRequest, build_tool_oauth_request_http_exception -from ..usage.domain import Usage, UsageType, MessageUsage +from ..usage.domain import Usage, UsageType, MessageUsage from ..usage.repos import UsageRepository from ..users.domain import User from .domain import ThreadListItem, Thread, ThreadMessage, ThreadMessageOrigin, ThreadUpdate,\ ThreadMessagePublic, ThreadMessageFile, ThreadMessageUpdate, AgentActionEvent, AgentFileEvent,\ AgentMessageEvent, ThreadTranscriptionResult from .engine import build_thread_name, AgentEngine -from .time_saved_estimation import estimate_minutes_saved from .repos import ThreadRepository, ThreadMessageRepository, ThreadMessageFileRepository +from .time_saved_estimation import estimate_minutes_saved -THREADS_PATH = f"{BASE_PATH}/threads" - logger = logging.getLogger(__name__) router = APIRouter() +THREADS_PATH = f"{BASE_PATH}/threads" active_streaming_connections: dict[int, asyncio.Event] = {} @@ -137,7 +136,7 @@ async def add_message(thread_id: int, request: Request, user: Annotated[User, De current_usage = await UsageRepository(db).find_current_month_user_usage_usd(user.id) if current_usage >= user.monthly_usd_limit: raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, detail="quotaExceeded") - + form = await request.form() message_text = cast(str, form.get("text", "")) message_origin = cast(str, form.get("origin", "USER")) @@ -155,7 +154,7 @@ async def add_message(thread_id: int, request: Request, user: Annotated[User, De engine = AgentEngine(thread.agent, user.id, db) async with AsyncExitStack() as stack: await engine.load_tools(stack) - + initial_thread_message = ThreadMessage( thread_id=thread.id, text=message_text, @@ -164,7 +163,7 @@ async def add_message(thread_id: int, request: Request, user: Annotated[User, De ) repo = ThreadMessageRepository(db) user_message = await repo.add(initial_thread_message) - + await _attach_existing_files_to_message(existing_files, user_message, db) await _handle_file_contents(files, user_message, user, thread, engine, db) user_message = await repo.refresh_with_files(user_message) @@ -175,7 +174,7 @@ async def add_message(thread_id: int, request: Request, user: Annotated[User, De ) except ToolOAuthRequest as e: raise build_tool_oauth_request_http_exception(e) - + except QuotaExceededError: raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, detail="quotaExceeded") @@ -214,8 +213,8 @@ async def _handle_file_contents(files: List[UploadFile], user_message: ThreadMes file.processed_content = await extract_file_text(file, file_quota) file.status = FileStatus.PROCESSED saved_file = await file_repo.add(file) - await ThreadMessageFileRepository(db).add(ThreadMessageFile(thread_message_id=user_message.id, file_id=saved_file.id)) - + await ThreadMessageFileRepository(db).add(ThreadMessageFile(thread_message_id=user_message.id, file_id=saved_file.id)) + finally: await UsageRepository(db).add(pdf_parsing_usage) @@ -224,7 +223,7 @@ async def _agent_response(message: ThreadMessage, thread: Thread, user_id: int, -> AsyncIterator[bytes]: message_usage = None yield ServerSentEvent(event="userMessage", data=json.dumps({ - "id": message.id, + "id": message.id, "files": [FileMetadata.from_file(f.file).model_dump(mode="json", by_alias=True) for f in message.files if f.file] })).encode() try: @@ -234,16 +233,19 @@ async def _agent_response(message: ThreadMessage, thread: Thread, user_id: int, repo = ThreadMessageRepository(db) message_usage = MessageUsage(user_id=user_id, agent_id=thread.agent_id, model_id=thread.agent.model_id, message_id=message.id) thread_messages = await repo.find_previous_messages(message) - + if len(thread_messages) == 0: thread.name = await build_thread_name(message.text, message_usage, db) await ThreadRepository(db).update(thread) - + answer_stream = AgentEngine(thread.agent, user_id, db).answer([*thread_messages, message], message_usage, stop_event) complete_answer = "" files: List[FileMetadata] = [] + status_updates: List[AgentActionEvent] = [] + async for event in answer_stream: if isinstance(event, AgentActionEvent): + status_updates.append(event) payload = json.dumps(event.model_dump(mode="json", by_alias=True)) yield ServerSentEvent(event="status", data=payload).encode() elif isinstance(event, AgentFileEvent): @@ -272,11 +274,12 @@ async def _agent_response(message: ThreadMessage, thread: Thread, user_id: int, origin=ThreadMessageOrigin.AGENT, parent_id=message.id, minutes_saved=minutes_saved, - stopped=stop_event.is_set() + stopped=stop_event.is_set(), + status_updates=[event.model_dump(mode="json", by_alias=True) for event in status_updates] if status_updates else None )) for f in files: await ThreadMessageFileRepository(db).add(ThreadMessageFile(thread_message_id=answer.id, file_id=f.id)) - + yield ServerSentEvent(event="metadata", data=json.dumps({ "answerMessageId": answer.id, "files": [f.model_dump(mode="json", by_alias=True) for f in files], @@ -284,7 +287,7 @@ async def _agent_response(message: ThreadMessage, thread: Thread, user_id: int, "stopped": answer.stopped })).encode() except Exception: - logger.exception("Problem answering message") + logger.exception(f"Problem answering message in thread {thread.id}") yield ServerSentEvent(event="error").encode() finally: await UsageRepository(db).add(message_usage) diff --git a/src/backend/tero/threads/domain.py b/src/backend/tero/threads/domain.py index 4809777..8aa2a2a 100644 --- a/src/backend/tero/threads/domain.py +++ b/src/backend/tero/threads/domain.py @@ -3,16 +3,19 @@ from enum import Enum from typing import Any, Optional, List, Union +from pydantic import field_serializer from sqlalchemy import Column, Text -from sqlmodel import SQLModel, Field, Relationship, Index +from sqlmodel import SQLModel, Field, Relationship, Index, JSON from ..agents.domain import Agent, AgentListItem from ..core.domain import CamelCaseModel from ..files.domain import FileMetadata, File from ..users.domain import User + MAX_THREAD_NAME_LENGTH = 80 + class BaseThread(SQLModel, abc.ABC): id: int = Field(primary_key=True, default=None) name: Optional[str] = Field(max_length=MAX_THREAD_NAME_LENGTH, default=None) @@ -42,6 +45,14 @@ def update_with(self, update: ThreadUpdate): def set_default_name(self): self.name = f"Chat #{self.id}" + @field_serializer('creation') + def serialize_creation(self, value: datetime) -> str: + if value.tzinfo is not None: + value = value.astimezone(timezone.utc) + naive_value = value.replace(tzinfo=None) + return naive_value.isoformat() + + class ThreadListItem(BaseThread, CamelCaseModel): agent: AgentListItem creation: Optional[datetime] = None @@ -88,7 +99,7 @@ class ThreadMessage(CamelCaseModel, table=True): minutes_saved: Optional[int] = None feedback_text: Optional[str] = None has_positive_feedback: Optional[bool] = None - + status_updates: Optional[List] = Field(default=None, sa_column=Column(JSON)) files: List["ThreadMessageFile"] = Relationship(back_populates="thread_message") def update_with(self, update: ThreadMessageUpdate): @@ -119,6 +130,7 @@ class ThreadMessagePublic(CamelCaseModel, table=False): feedback_text: Optional[str] = None has_positive_feedback: Optional[bool] = None stopped: bool = False + status_updates: Optional[List] = None @staticmethod def from_message(message: ThreadMessage) -> 'ThreadMessagePublic': diff --git a/src/backend/tero/threads/engine.py b/src/backend/tero/threads/engine.py index 0523826..7141830 100644 --- a/src/backend/tero/threads/engine.py +++ b/src/backend/tero/threads/engine.py @@ -19,8 +19,8 @@ _is_message_type, _first_max_tokens, ) -from langchain_core.utils.function_calling import convert_to_openai_tool from langchain_core.tools import tool, BaseTool +from langchain_core.utils.function_calling import convert_to_openai_tool from langgraph.prebuilt import create_react_agent from sqlmodel.ext.asyncio.session import AsyncSession @@ -29,9 +29,9 @@ from ..ai_models import ai_factory from ..ai_models.repos import AiModelRepository from ..core.env import env -from ..usage.domain import MessageUsage from ..tools.core import AgentTool, AgentToolMetadata from ..tools.repos import ToolRepository +from ..usage.domain import MessageUsage from .domain import ThreadMessage, ThreadMessageOrigin, MAX_THREAD_NAME_LENGTH, AgentEvent, AgentActionEvent, AgentFileEvent, AgentMessageEvent, AgentAction diff --git a/src/backend/tero/threads/repos.py b/src/backend/tero/threads/repos.py index c597d60..1fd6f2c 100644 --- a/src/backend/tero/threads/repos.py +++ b/src/backend/tero/threads/repos.py @@ -117,7 +117,7 @@ async def refresh_with_files(self, thread_message: ThreadMessage) -> ThreadMessa )) ret = await self._db.exec(stmt) return ret.one() - + async def update(self, thread_message: ThreadMessage): self._db.add(thread_message) await self._db.commit() @@ -133,6 +133,16 @@ async def find_by_thread_id(self, thread_id: int) -> List[ThreadMessage]: ret = await self._db.exec(stmt) return list(ret.all()) + async def find_last_by_thread_id(self, thread_id: int) -> Optional[ThreadMessage]: + stmt = ( + select(ThreadMessage) + .where(ThreadMessage.thread_id == thread_id) + .order_by(col(ThreadMessage.timestamp).desc()) + .limit(1) + ) + ret = await self._db.exec(stmt) + return ret.first() + async def find_previous_messages(self, message: ThreadMessage) -> List[ThreadMessage]: parents: List[ThreadMessage] = [] current = message @@ -170,14 +180,14 @@ async def find_feedback_messages(self, agent_id: int, user_id: int, limit: int) [Thread.agent_id == agent_id], [Thread.user_id == user_id], ] - + for strategy_conditions in strategies: if len(messages) >= limit: break conditions = strategy_conditions + [~col(ThreadMessage.id).in_([msg.id for msg in messages])] if messages else [] messages.extend(await self._find_feedback_messages_with_conditions(conditions, limit - len(messages))) - + return messages[:limit] async def _find_feedback_messages_with_conditions(self, additional_conditions, limit: int) -> List[ThreadMessage]: @@ -194,7 +204,7 @@ async def _find_feedback_messages_with_conditions(self, additional_conditions, l .order_by(col(ThreadMessage.timestamp).desc()) .limit(limit) ) - + ret = await self._db.exec(stmt) return [item for message, parent in ret.all() for item in [parent, message]] @@ -220,7 +230,7 @@ async def add(self, thread_message_file: ThreadMessageFile) -> ThreadMessageFile await self._db.commit() await self._db.refresh(thread_message_file) return thread_message_file - + async def find_by_thread_id_and_file_id(self, thread_id: int, file_id: int) -> Optional[ThreadMessageFile]: stmt = (select(ThreadMessageFile) .join(ThreadMessage, and_(ThreadMessageFile.thread_message_id == ThreadMessage.id, ThreadMessage.thread_id == thread_id)) diff --git a/src/backend/tero/threads/time_saved_estimation.py b/src/backend/tero/threads/time_saved_estimation.py index 09dbf6e..60095fd 100644 --- a/src/backend/tero/threads/time_saved_estimation.py +++ b/src/backend/tero/threads/time_saved_estimation.py @@ -1,5 +1,5 @@ -from typing import List, cast import logging +from typing import List, cast from langchain_core.messages import ( SystemMessage, @@ -13,15 +13,15 @@ ) from sqlmodel.ext.asyncio.session import AsyncSession -from ..agents.domain import LlmTemperature from ..ai_models import ai_factory +from ..ai_models.domain import LlmTemperature, LlmModel from ..ai_models.repos import AiModelRepository -from ..ai_models.domain import LlmModel -from ..threads.repos import ThreadMessageRepository from ..core.env import env +from ..threads.repos import ThreadMessageRepository from ..usage.domain import MessageUsage from .domain import Thread, ThreadMessage, ThreadMessageOrigin + logger = logging.getLogger(__name__) diff --git a/src/backend/tero/tools/api.py b/src/backend/tero/tools/api.py index 346674f..e914bc3 100644 --- a/src/backend/tero/tools/api.py +++ b/src/backend/tero/tools/api.py @@ -8,9 +8,9 @@ from ..core.api import BASE_PATH from ..core.auth import get_current_user from ..core.repos import get_db -from ..tools.oauth import ToolOAuthCallbackError, ToolAuthCallback, ToolOAuthRepository from ..users.domain import User from .core import AgentTool +from .oauth import ToolOAuthCallbackError, ToolAuthCallback, ToolOAuthRepository, ToolOAuthState from .repos import ToolRepository @@ -25,20 +25,15 @@ async def find_agent_tools(_: Annotated[User, Depends(get_current_user)]) -> Lis return ToolRepository().find_agent_tools() -@router.post(f"{TOOLS_PATH}/{{tool_id}}/oauth-callback") -async def tool_auth(tool_id: str, callback: ToolAuthCallback, user: Annotated[User, Depends(get_current_user)], db: Annotated[AsyncSession, Depends(get_db)]): - tool = ToolRepository().find_by_id(tool_id) - if not tool: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tool not found") - oauth_repo = ToolOAuthRepository(db) - state = await oauth_repo.find_state(user.id, tool_id, callback.state) - if not state: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST) +@router.put(f"{TOOLS_PATH}/{{tool_id}}/oauth/{{state_id}}") +async def tool_auth(tool_id: str, state_id: str, callback: ToolAuthCallback, user: Annotated[User, Depends(get_current_user)], db: Annotated[AsyncSession, Depends(get_db)]): + tool = _find_tool(tool_id) + state = await _find_state(user.id, tool_id, state_id, db) config = await AgentToolConfigRepository(db).find_by_ids(state.agent_id, tool_id, include_drafts=True) if not config: # this should not happen since for the state to exist a configuration must exist as well raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR) - await oauth_repo.delete_state(user.id, state.agent_id, tool_id) + await ToolOAuthRepository(db).delete_state(user.id, state.agent_id, tool_id) if not callback.code: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Authentication cancelled") tool.configure(config.agent, user.id, config.config, db) @@ -47,3 +42,25 @@ async def tool_auth(tool_id: str, callback: ToolAuthCallback, user: Annotated[Us except ToolOAuthCallbackError: logger.exception(f"Error during tool oauth callback for tool {tool_id} and user {user.id}") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST) + + +def _find_tool(tool_id: str) -> AgentTool: + tool = ToolRepository().find_by_id(tool_id) + if not tool: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tool not found") + return tool + + +async def _find_state(user_id: int, tool_id: str, state_id: str, db: AsyncSession) -> ToolOAuthState: + oauth_repo = ToolOAuthRepository(db) + state = await oauth_repo.find_state(user_id, tool_id, state_id) + if not state: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + return state + + +@router.delete(f"{TOOLS_PATH}/{{tool_id}}/oauth/{{state_id}}") +async def delete_auth(tool_id: str, state_id: str, user: Annotated[User, Depends(get_current_user)], db: Annotated[AsyncSession, Depends(get_db)]): + _find_tool(tool_id) + state = await _find_state(user.id, tool_id, state_id, db) + await ToolOAuthRepository(db).delete_state(user.id, state.agent_id, tool_id) diff --git a/src/backend/tero/tools/browser/tool.py b/src/backend/tero/tools/browser/tool.py index cc0076a..01c2621 100644 --- a/src/backend/tero/tools/browser/tool.py +++ b/src/backend/tero/tools/browser/tool.py @@ -6,18 +6,16 @@ import re from typing import List, Optional, Any, cast, Annotated - -from langchain_core.tools import BaseTool, StructuredTool, InjectedToolCallId -from langchain_core.runnables import RunnableConfig from langchain_core.callbacks import AsyncCallbackManagerForToolRun from langchain_core.messages import ToolMessage +from langchain_core.runnables import RunnableConfig +from langchain_core.tools import BaseTool, StructuredTool, InjectedToolCallId from langchain_mcp_adapters.client import MultiServerMCPClient from langchain_mcp_adapters.tools import load_mcp_tools from sqlmodel.ext.asyncio.session import AsyncSession from json_schema_to_pydantic import create_model from pydantic import BaseModel - from ...core.env import env from ...files.domain import File, FileStatus, FileMetadata from ...files.repos import FileRepository @@ -46,7 +44,7 @@ class ScreenshotPersistingToolArgs(OriginalModel): def _run(self, *args: Any, **kwargs: Any) -> Any: raise NotImplementedError("Synchronous run not implemented.") - + async def _arun(self, *args: Any, config: RunnableConfig, run_manager: Optional[AsyncCallbackManagerForToolRun] = None, **kwargs: Any) -> Any: result = await self._mcp_tool._arun(*args, config=config, run_manager=run_manager, **kwargs) @@ -103,7 +101,7 @@ async def teardown(self): @asynccontextmanager async def load(self) -> AsyncIterator['BrowserTool']: server_name = "playwright" - client = MultiServerMCPClient({server_name: {"transport": "streamable_http", "url": env.browser_tool_playwright_mcp_url}}) + client = MultiServerMCPClient({server_name: {"transport": "streamable_http", "url": env.browser_tool_playwright_mcp_url }}) async with client.session(server_name) as mcp_session: self._tools = await load_mcp_tools(mcp_session) self._tools = [ScreenshotPersistingTool(cast(StructuredTool, t), self.user_id, self._thread_id, cast(AsyncSession, self._db)) if t.name == "browser_take_screenshot" else t for t in self._tools] @@ -124,4 +122,4 @@ async def clone( async def build_langchain_tools(self) -> List[BaseTool]: if not self._tools: raise RuntimeError("Browser tool has not been set up properly") - return self._tools \ No newline at end of file + return self._tools diff --git a/src/backend/tero/tools/core.py b/src/backend/tero/tools/core.py index 2fc9f64..79dbe88 100644 --- a/src/backend/tero/tools/core.py +++ b/src/backend/tero/tools/core.py @@ -11,9 +11,11 @@ from sqlmodel.ext.asyncio.session import AsyncSession from ..agents.domain import Agent, AgentToolConfig +from ..agents.repos import AgentToolConfigFileRepository, AgentToolConfigFile from ..core.assets import solve_module_path from ..core.domain import CamelCaseModel from ..files.domain import File, FileMetadata +from ..files.repos import FileRepository from ..threads.domain import AgentActionEvent, AgentAction from ..tools.oauth import ToolAuthCallback, ToolOAuthState from ..usage.domain import ToolUsage @@ -42,6 +44,7 @@ def _fix_core_schema_references(ret: dict) -> dict: ret[key] = _fix_core_schema_references(value) return ret + class StatusUpdateCallbackHandler(AsyncCallbackHandler): def __init__(self, tool_id: str, description: Optional[str] = "", response_parser: Optional[Callable[[Any], List[str]]] = None, @@ -83,6 +86,7 @@ async def on_tool_error(self, error: BaseException, **kwargs: Any) -> None: ) ) + class AgentTool(CamelCaseModel, abc.ABC): id: str name: str @@ -93,7 +97,7 @@ class AgentTool(CamelCaseModel, abc.ABC): _config: Optional[dict] = None _db: Optional[AsyncSession] = None _thread_id: Optional[int] = None - + # this method is invoked every time the agent tool is configured or used for a given agent and user id def configure(self, agent: Agent, user_id: int, config: dict, db: AsyncSession, thread_id: Optional[int] = None): self._agent = agent @@ -101,23 +105,23 @@ def configure(self, agent: Agent, user_id: int, config: dict, db: AsyncSession, self._config = config self._db = db self._thread_id = thread_id - + @property def agent(self) -> Agent: return cast(Agent, self._agent) - - @property - def db(self) -> AsyncSession: - return cast(AsyncSession, self._db) - + @property def user_id(self) -> int: return cast(int, self._user_id) - + @property def config(self) -> dict: return cast(dict, self._config) + @property + def db(self) -> AsyncSession: + return cast(AsyncSession, self._db) + # this method is invoked when the tool is configured or the configuration changes async def setup(self, prev_config: Optional[AgentToolConfig]) -> dict: try: @@ -153,6 +157,9 @@ def get_schema_without_files(self, schema: dict) -> dict: def _is_file_property(value: Any) -> bool: if not isinstance(value, dict): return False + ref = value.get("$ref") + if ref and ref.endswith("/File"): + return True items = value.get("items") if not isinstance(items, dict): return False @@ -213,7 +220,32 @@ async def update_file(self, file: File, user: User): async def remove_file(self, file: File): pass + async def _clone_files( + self, + agent_id: int, + cloned_agent_id: int, + tool_id: str, + user_id: int, + db: AsyncSession, + ) -> dict: + tool_file_repo = AgentToolConfigFileRepository(db) + files = await tool_file_repo.find_with_content_by_agent_and_tool( + agent_id, tool_id + ) + file_id_map = {} + + for file in files: + new_file = file.clone(user_id=user_id) + new_file = await FileRepository(db).add(new_file) + await tool_file_repo.add( + AgentToolConfigFile( + agent_id=cloned_agent_id, tool_id=tool_id, file_id=new_file.id + ) + ) + file_id_map[file.id] = new_file.id + return file_id_map + class AgentToolMetadata(CamelCaseModel): tool_usage: Optional[ToolUsage] = None - file: Optional[FileMetadata] = None \ No newline at end of file + file: Optional[FileMetadata] = None diff --git a/src/backend/tero/tools/docs/domain.py b/src/backend/tero/tools/docs/domain.py index 1c65039..9eb690c 100644 --- a/src/backend/tero/tools/docs/domain.py +++ b/src/backend/tero/tools/docs/domain.py @@ -1,5 +1,7 @@ from typing import Any + from sqlmodel import Field + from ...core.domain import CamelCaseModel diff --git a/src/backend/tero/tools/docs/tool.py b/src/backend/tero/tools/docs/tool.py index a105e4f..95d5237 100644 --- a/src/backend/tero/tools/docs/tool.py +++ b/src/backend/tero/tools/docs/tool.py @@ -7,30 +7,29 @@ from enum import Enum import tiktoken -from langchain.indexes import SQLRecordManager, aindex +from langchain_classic.indexes import SQLRecordManager, aindex +from langchain_core.callbacks import AsyncCallbackHandler from langchain_core.callbacks.manager import AsyncCallbackManagerForRetrieverRun, AsyncCallbackManager from langchain_core.documents import Document -from langchain_core.outputs import LLMResult from langchain_core.language_models.chat_models import BaseChatModel from langchain_core.messages import HumanMessage, AIMessage +from langchain_core.outputs import LLMResult from langchain_core.output_parsers import StrOutputParser from langchain_core.prompts import ChatPromptTemplate from langchain_core.runnables import RunnablePassthrough from langchain_core.runnables.config import ensure_config from langchain_core.tools import BaseTool, StructuredTool from langchain_core.vectorstores import VectorStoreRetriever -from langchain_core.callbacks import AsyncCallbackHandler from langchain_postgres import PGVector from langchain_text_splitters import MarkdownTextSplitter, CharacterTextSplitter from langgraph.config import get_stream_writer from pydantic import BaseModel, Field, model_validator -from sqlalchemy.ext.asyncio import AsyncEngine from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncEngine from sqlmodel.ext.asyncio.session import AsyncSession -from ...agents.domain import AgentToolConfig, AgentToolConfigFile -from ...agents.repos import AgentToolConfigFileRepository -from ...ai_models import ai_factory, azure_provider +from ...agents.domain import AgentToolConfig +from ...ai_models import ai_factory from ...ai_models.domain import LlmModel from ...ai_models.repos import AiModelRepository from ...core.assets import solve_asset_path @@ -38,15 +37,16 @@ from ...files.domain import File, FileProcessor from ...files.file_quota import FileQuota, CurrentQuota from ...files.parser import extract_file_text +from ...files.repos import FileRepository +from ...threads.domain import AgentActionEvent, AgentAction from ...usage.domain import Usage, MessageUsage, UsageType from ...usage.repos import UsageRepository from ...users.domain import User -from ...files.repos import FileRepository -from ...threads.domain import AgentActionEvent, AgentAction from ..core import AgentToolWithFiles, load_schema from .domain import DocToolFile, DocToolConfig from .repos import DocToolFileRepository, DocToolConfigRepository + logger = logging.getLogger(__name__) DOCS_TOOL_ID = "docs" ADVANCED_FILE_PROCESSING = "advancedFileProcessing" @@ -346,7 +346,6 @@ async def build_langchain_tools(self) -> List[BaseTool]: coroutine=lambda user_query: docs_tool._run(user_query), )] - async def clone( self, agent_id: int, @@ -363,31 +362,6 @@ async def clone( await self._clone_tool_config(agent_id, cloned_agent_id, db) await self._clone_tool_files(agent_id, cloned_agent_id, file_id_map, db) - async def _clone_files( - self, - agent_id: int, - cloned_agent_id: int, - tool_id: str, - user_id: int, - db: AsyncSession, - ) -> dict: - tool_file_repo = AgentToolConfigFileRepository(db) - files = await tool_file_repo.find_with_content_by_agent_and_tool( - agent_id, tool_id - ) - file_id_map = {} - - for file in files: - new_file = file.clone(user_id=user_id) - new_file = await FileRepository(db).add(new_file) - await tool_file_repo.add( - AgentToolConfigFile( - agent_id=cloned_agent_id, tool_id=tool_id, file_id=new_file.id - ) - ) - file_id_map[file.id] = new_file.id - return file_id_map - async def _clone_vector_store( self, agent_id: int, cloned_agent_id: int, db: AsyncSession ) -> None: diff --git a/src/backend/tero/tools/jira/tool.py b/src/backend/tero/tools/jira/tool.py index c9fd0af..4221fe3 100644 --- a/src/backend/tero/tools/jira/tool.py +++ b/src/backend/tero/tools/jira/tool.py @@ -71,13 +71,12 @@ async def _setup_tool(self, prev_config: Optional[AgentToolConfig]) -> Optional[ async def _save_client_info(self, config: dict): repo = ToolOAuthClientInfoRepository(self.db) - client_info = await repo.find_by_ids(self.user_id, self.agent.id, self.id) + client_info = await repo.find_by_ids(self.agent.id, self.id) client_id = config["clientId"] client_secret = config["clientSecret"] scope = " ".join(config["scope"]) if not client_info: client_info = ToolOAuthClientInfo( - user_id=self.user_id, agent_id=self.agent.id, tool_id=self.id, client_id=client_id, @@ -104,7 +103,7 @@ async def _load_oauth(self) -> AgentToolOauth: authorization_endpoint=AnyHttpUrl(f"{base_url}/authorize"), token_endpoint=AnyHttpUrl(f"{base_url}/oauth/token") ) - client_info = await ToolOAuthClientInfoRepository(self.db).find_by_ids(self.user_id, self.agent.id, self.id) + client_info = await ToolOAuthClientInfoRepository(self.db).find_by_ids(self.agent.id, self.id) # add offline_access scope to be able to refresh tokens return AgentToolOauth(base_url, oauth_metadata, cast(str, cast(ToolOAuthClientInfo, client_info).scope) + " offline_access", self.agent.id, self.id, self.user_id, self.db) @@ -136,7 +135,7 @@ async def auth(self, auth_callback: ToolAuthCallback, state: ToolOAuthState): async def teardown(self): await ToolOAuthRepository(self.db).delete_token(self.user_id, self.agent.id, self.id) - await ToolOAuthClientInfoRepository(self.db).delete(self.user_id, self.agent.id, self.id) + await ToolOAuthClientInfoRepository(self.db).delete(self.agent.id, self.id) await JiraToolConfigRepository(self.db).delete(self.agent.id) async def build_langchain_tools(self) -> list[BaseTool]: diff --git a/src/backend/tero/tools/mcp/tool.py b/src/backend/tero/tools/mcp/tool.py index 9d47c25..68d3cdb 100644 --- a/src/backend/tero/tools/mcp/tool.py +++ b/src/backend/tero/tools/mcp/tool.py @@ -44,7 +44,7 @@ async def _setup_tool(self, prev_config: Optional[AgentToolConfig]) -> Optional[ async def teardown(self): await ToolOAuthRepository(self.db).delete_token(self.user_id, self.agent.id, self.id) - await ToolOAuthClientInfoRepository(self.db).delete(self.user_id, self.agent.id, self.id) + await ToolOAuthClientInfoRepository(self.db).delete(self.agent.id, self.id) @asynccontextmanager async def load(self) -> AsyncIterator['McpTool']: diff --git a/src/backend/tero/tools/oauth.py b/src/backend/tero/tools/oauth.py index c1034cd..57e767d 100644 --- a/src/backend/tero/tools/oauth.py +++ b/src/backend/tero/tools/oauth.py @@ -9,16 +9,28 @@ from fastapi import HTTPException, status import httpx from mcp.client.auth import OAuthClientProvider, TokenStorage, PKCEParameters, OAuthFlowError, OAuthRegistrationError +from mcp.client.auth.utils import ( + build_oauth_authorization_server_metadata_discovery_urls, + build_protected_resource_metadata_discovery_urls, + create_client_registration_request, + create_oauth_metadata_request, + get_client_metadata_scopes, + handle_auth_metadata_response, + handle_protected_resource_response, + handle_registration_response, +) from mcp.shared.auth import OAuthClientMetadata, OAuthToken, OAuthClientInformationFull, OAuthMetadata -from pydantic import AnyHttpUrl, BaseModel, ValidationError +from pydantic import AnyHttpUrl, BaseModel from sqlmodel import SQLModel, Field, col, select, delete, and_ from sqlmodel.ext.asyncio.session import AsyncSession from ..core.env import env from ..core.repos import scalar, EncryptedField + logger = logging.getLogger(__name__) + class ToolOAuthTokenType(str, Enum): BEARER = "bearer" @@ -50,17 +62,16 @@ class ToolOAuthState(SQLModel, table=True): class ToolOAuthClientInfo(SQLModel, table=True): __tablename__ : Any = "tool_oauth_client_info" - user_id: int = Field(primary_key=True) agent_id: int = Field(primary_key=True) tool_id: str = Field(primary_key=True) client_id: str - client_secret: str = EncryptedField() + client_secret: Optional[str] = EncryptedField() scope: Optional[str] = None updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc), index=True) class ToolOAuthRequest(BaseException): - + def __init__(self, auth_url: str, state: str): self.auth_url = auth_url self.state = state @@ -71,7 +82,6 @@ def build_tool_oauth_request_http_exception(e: ToolOAuthRequest) -> HTTPExceptio class ToolAuthCallback(BaseModel): - state: str code: Optional[str] = None @@ -107,7 +117,7 @@ async def find_state(self, user_id: int, tool_id: str, state: str) -> Optional[T where(ToolOAuthState.user_id == user_id, ToolOAuthState.tool_id == tool_id, ToolOAuthState.state == state)) ret = await self._db.exec(stmt) return ret.one_or_none() - + async def save_state(self, state: ToolOAuthState): state.updated_at = datetime.now(timezone.utc) await self._db.merge(state) @@ -123,31 +133,31 @@ async def cleanup(self): token_cutoff = datetime.now(timezone.utc) - timedelta(minutes=env.tool_oauth_token_ttl_minutes) token_stmt = scalar(delete(ToolOAuthToken).where(and_(ToolOAuthToken.updated_at < token_cutoff))) await self._db.exec(token_stmt) - + state_cutoff = datetime.now(timezone.utc) - timedelta(minutes=env.tool_oauth_state_ttl_minutes) state_stmt = scalar(delete(ToolOAuthState).where(and_(ToolOAuthState.updated_at < state_cutoff))) await self._db.exec(state_stmt) - + await self._db.commit() class ToolOAuthClientInfoRepository: def __init__(self, db: AsyncSession): self._db = db - + async def save(self, info: ToolOAuthClientInfo): await self._db.merge(info) await self._db.commit() - async def find_by_ids(self, user_id: int, agent_id: int, tool_id: str) -> Optional[ToolOAuthClientInfo]: + async def find_by_ids(self, agent_id: int, tool_id: str) -> Optional[ToolOAuthClientInfo]: stmt = (select(ToolOAuthClientInfo). - where(ToolOAuthClientInfo.user_id == user_id, ToolOAuthClientInfo.agent_id == agent_id, ToolOAuthClientInfo.tool_id == tool_id)) + where(ToolOAuthClientInfo.agent_id == agent_id, ToolOAuthClientInfo.tool_id == tool_id)) result = await self._db.exec(stmt) return result.one_or_none() - - async def delete(self, user_id: int, agent_id: int, tool_id: str): + + async def delete(self, agent_id: int, tool_id: str): stmt = scalar(delete(ToolOAuthClientInfo). - where(and_(ToolOAuthClientInfo.user_id == user_id, ToolOAuthClientInfo.agent_id == agent_id, ToolOAuthClientInfo.tool_id == tool_id))) + where(and_(ToolOAuthClientInfo.agent_id == agent_id, ToolOAuthClientInfo.tool_id == tool_id))) await self._db.exec(stmt) await self._db.commit() @@ -158,8 +168,8 @@ async def cleanup(self, tool_id: str, ttl_minutes: int): stmt = scalar( delete(ToolOAuthClientInfo) .where(and_( - ToolOAuthClientInfo.tool_id == tool_id if len(tool_id_parts) == 1 else col(ToolOAuthClientInfo.tool_id).like(f"{tool_id_parts[0]}-%"), - ToolOAuthClientInfo.updated_at < cutoff, + ToolOAuthClientInfo.tool_id == tool_id if len(tool_id_parts) == 1 else col(ToolOAuthClientInfo.tool_id).like(f"{tool_id_parts[0]}-%"), + ToolOAuthClientInfo.updated_at < cutoff, ToolOAuthClientInfo.client_id != ""))) await self._db.exec(stmt) await self._db.commit() @@ -189,7 +199,7 @@ async def get_tokens(self) -> Optional[OAuthToken]: async def set_tokens(self, tokens: OAuthToken): await self._oauth_repo.save_token(ToolOAuthToken( - user_id=self._user_id, + user_id=self._user_id, agent_id=self._agent_id, tool_id=self._tool_id, access_token=tokens.access_token, @@ -201,7 +211,7 @@ async def set_tokens(self, tokens: OAuthToken): )) async def get_client_info(self) -> Optional[OAuthClientInformationFull]: - ret = await self._client_info_repo.find_by_ids(self._user_id, self._agent_id, self._tool_id) + ret = await self._client_info_repo.find_by_ids(self._agent_id, self._tool_id) return OAuthClientInformationFull( client_id=ret.client_id, client_secret=ret.client_secret, @@ -209,10 +219,9 @@ async def get_client_info(self) -> Optional[OAuthClientInformationFull]: async def set_client_info(self, client_info: OAuthClientInformationFull): info = ToolOAuthClientInfo( - user_id=self._user_id, agent_id=self._agent_id, tool_id=self._tool_id, - client_id=client_info.client_id, + client_id=cast(str, client_info.client_id), client_secret=cast(str, client_info.client_secret), scope=client_info.scope, updated_at=datetime.now(timezone.utc) @@ -241,26 +250,26 @@ def __init__(self, server_url: str, metadata: Optional[OAuthMetadata], scope: Op client_metadata = OAuthClientMetadata(redirect_uris=[AnyHttpUrl(_build_redirect_uri(tool_id))], scope=scope) super().__init__( server_url, - client_metadata, - AgentToolOAuthStorage(user_id, agent_id, tool_id, db, self), - redirect_handler=self._redirect_handler, + client_metadata, + AgentToolOAuthStorage(user_id, agent_id, tool_id, db, self), + redirect_handler=self._redirect_handler, callback_handler=self._callback_handler ) self.context.oauth_metadata = metadata self.state = "" self.code_verifier = "" - + @property def server_url(self) -> str: return self.context.server_url - # custom redirect handler that saves the state (to restore it in OAuth callback) and requests OAuth authentication flow + # custom redirect handler that saves the state (to restore it in OAuth callback) and requests OAuth authentication flow async def _redirect_handler(self, auth_url: str): tool_state = ToolOAuthState( - user_id=self._user_id, + user_id=self._user_id, agent_id=self._agent_id, - tool_id=self._tool_id, - state=self.state, + tool_id=self._tool_id, + state=self.state, code_verifier=self.code_verifier, token_endpoint=self.context.oauth_metadata.token_endpoint.unicode_string() if self.context.oauth_metadata else None) await self._oauth_repo.save_state(tool_state) @@ -269,23 +278,23 @@ async def _redirect_handler(self, auth_url: str): # this is just to satisfy the callback_handler. It should never be called due to the redirect_handler async def _callback_handler(self) -> tuple[str, str | None]: return "", None - + # part of this logic is the same as async_auth_flow but instead of adding header to a request and doing the complete OAuth flow, # a ToolOAuthRequest is raised when needed async def solve_tokens(self) -> Optional[OAuthToken]: async with self.context.lock: if not self._initialized: await self._initialize() - + # if client_id is empty then it means that the client doesn't support authentication if not self.context.client_info or self.context.client_info.client_id: try: await self.ensure_token() except UnsupportedClientRegistrationException: return None - + return self.context.current_tokens - + # override this method to add a 1 minute buffer to the token expiry time to avoid 401 errors def is_token_valid(self) -> bool: if not self.context.current_tokens or not self.context.current_tokens.access_token: @@ -293,13 +302,13 @@ def is_token_valid(self) -> bool: if self.context.token_expiry_time and self.context.token_expiry_time < time.time() + 60: return False - + return True async def ensure_token(self) -> None: if self.is_token_valid(): return - + if self.context.can_refresh_token(): refresh_request = await self._refresh_token() refresh_response = await self._http_request(refresh_request) @@ -309,11 +318,17 @@ async def ensure_token(self) -> None: await self._discover_oauth_metadata() - registration_request = await self._register_client() - if registration_request: + registration_request = create_client_registration_request( + self.context.oauth_metadata, + self.context.client_metadata, + self.context.get_authorization_base_url(self.context.server_url), + ) + if not self.context.client_info: registration_response = await self._http_request(registration_request) try: - await self._handle_registration_response(registration_response) + client_information = await handle_registration_response(registration_response) + self.context.client_info = client_information + await self.context.storage.set_client_info(client_information) except OAuthRegistrationError as e: # some mcp servers return 404 others may fail with 400 (eg: mcp playwright) when registration is not supported if e.args and e.args[0].startswith("Registration failed: 4"): @@ -333,27 +348,54 @@ async def _http_request(self, request: httpx.Request) -> httpx.Response: return await self._http_client.send(request) async def _discover_oauth_metadata(self) -> None: - # even though the contract of _discover_protected_resource says it expects an httpx.Response, you can actually pass None and it properly handles it - discovery_request = await self._discover_protected_resource(cast(httpx.Response, None)) - discovery_response = await self._http_request(discovery_request) - await self._handle_protected_resource_response(discovery_response) - - discovery_urls = self._get_discovery_urls() - for url in discovery_urls: - oauth_metadata_request = self._create_oauth_metadata_request(url) + prm_discovery_urls = build_protected_resource_metadata_discovery_urls(None, self.context.server_url) + + for url in prm_discovery_urls: + discovery_request = create_oauth_metadata_request(url) + discovery_response = await self._http_request(discovery_request) + + prm = await handle_protected_resource_response(discovery_response) + if prm: + self.context.protected_resource_metadata = prm + self.context.auth_server_url = str(prm.authorization_servers[0]) + break + else: + logger.debug(f"Protected resource metadata discovery failed: {url}") + + asm_discovery_urls = build_oauth_authorization_server_metadata_discovery_urls( + self.context.auth_server_url, self.context.server_url + ) + + for url in asm_discovery_urls: + oauth_metadata_request = create_oauth_metadata_request(url) oauth_metadata_response = await self._http_request(oauth_metadata_request) - if oauth_metadata_response.status_code == 200: - try: - await self._handle_oauth_metadata_response(oauth_metadata_response) - break - except ValidationError: - continue - elif oauth_metadata_response.status_code < 400 or oauth_metadata_response.status_code >= 500: + ok, asm = await handle_auth_metadata_response(oauth_metadata_response) + if not ok: + break + if ok and asm: + self.context.oauth_metadata = asm break + else: + logger.debug(f"OAuth metadata discovery failed: {url}") + + # Add this custom logic so if scope was already provided, do not override it + if not self.context.client_metadata.scope: + self.context.client_metadata.scope = get_client_metadata_scopes( + None, + self.context.protected_resource_metadata, + self.context.oauth_metadata, + ) + + async def _perform_authorization(self) -> httpx.Request: + # same as the one in oauth2.py from mcp library but stores code verifier and state in the class so when redirect is invoked it can store them to later resume the flow + if self.context.client_metadata.redirect_uris is None: + raise OAuthFlowError("No redirect URIs provided for authorization code grant") + if not self.context.redirect_handler: + raise OAuthFlowError("No redirect handler provided for authorization code grant") + if not self.context.callback_handler: + raise OAuthFlowError("No callback handler provided for authorization code grant") - async def _perform_authorization(self) -> tuple[str, str]: - # same as the one in auth.py from mcp library but stores code verifier and state in the class so when redirect is invoked it can store them to later resume the flow if self.context.oauth_metadata and self.context.oauth_metadata.authorization_endpoint: auth_endpoint = str(self.context.oauth_metadata.authorization_endpoint) else: @@ -383,16 +425,15 @@ async def _perform_authorization(self) -> tuple[str, str]: auth_params["scope"] = self.context.client_metadata.scope authorization_url = f"{auth_endpoint}?{urlencode(auth_params)}" - await self._redirect_handler(authorization_url) + await self.context.redirect_handler(authorization_url) + # returning dummy request just to satisfy the return type + return httpx.Request("GET", "https://dummy") # part of this logic is the same as async_auth_flow after the callback is invoked async def callback(self, auth_callback: ToolAuthCallback, state: ToolOAuthState): if not self._initialized: await self._initialize() await self._discover_oauth_metadata() - try: - token_request = await self._exchange_token(cast(str, auth_callback.code), state.code_verifier) - token_response = await self._http_request(token_request) - await self._handle_token_response(token_response) - except Exception as e: - raise ToolOAuthCallbackError() if str(e).startswith("Token exchange failed: 401") else e + token_request = await self._exchange_token_authorization_code(cast(str, auth_callback.code), state.code_verifier) + token_response = await self._http_request(token_request) + await self._handle_token_response(token_response) diff --git a/src/backend/tero/tools/web/tool.py b/src/backend/tero/tools/web/tool.py index cfeca35..91b05fc 100644 --- a/src/backend/tero/tools/web/tool.py +++ b/src/backend/tero/tools/web/tool.py @@ -14,7 +14,6 @@ from pydantic import BaseModel, Field from sqlmodel.ext.asyncio.session import AsyncSession -from ...agents.domain import Agent from ...core.env import env from ...usage.domain import ToolUsage, UsageType from ..core import AgentTool, AgentToolConfig, AgentToolMetadata, load_schema, StatusUpdateCallbackHandler @@ -29,9 +28,11 @@ class WebSearchToolArgs(BaseModel): query: str = Field(description="The query to search for") tool_call_id: Annotated[str, InjectedToolCallId] + def parse_result_search(result: str) -> List[str]: return [f"{e.get('url')}: {e.get('content')[0:200]}" for e in ast.literal_eval(result).get("results")] + class WebSearchLangchainTool(BaseTool): name: str = "web_search" description: str = "Searches the web for the given query" @@ -89,13 +90,16 @@ async def _arun(self, *args: Any, **kwargs: Any) -> ToolMessage: response_metadata=AgentToolMetadata(tool_usage=tool_usage).model_dump() ) + class WebExtractToolArgs(BaseModel): urls: List[str] = Field(description="The URLs to extract text from") tool_call_id: Annotated[str, InjectedToolCallId] + def parse_result_extract(result: Any) -> List[str]: return [f"{e.get('url')}: {e.get('raw_content', e.get('error'))[:200]}" for e in result] if isinstance(result, list) else result + class WebExtractLangchainTool(BaseTool): name: str = "web_extract" description: str = "Extracts text from the given URLs" @@ -159,12 +163,11 @@ async def _arun(self, *args: Any, **kwargs: Any) -> ToolMessage: response_metadata=AgentToolMetadata(tool_usage=tool_usage).model_dump() ) + class WebTool(AgentTool): id: str = WEB_TOOL_ID name: str = "Web Tools" - description: str = ( - "Provides web search and web extraction capabilities." - ) + description: str = "Provides web search and web extraction capabilities." config_schema: dict = load_schema(__file__) async def _setup_tool(self, prev_config: Optional[AgentToolConfig]) -> Optional[dict]: @@ -193,4 +196,4 @@ async def build_langchain_tools(self) -> List[BaseTool]: tools.append(WebSearchLangchainTool()) # Set max characters for web extraction to half of the available input tokens to prevent overflow tools.append(WebExtractLangchainTool(max_extract_length=int((self.agent.model.token_limit) / 2))) - return tools \ No newline at end of file + return tools diff --git a/src/backend/tero/usage/domain.py b/src/backend/tero/usage/domain.py index 71fa666..21fb287 100644 --- a/src/backend/tero/usage/domain.py +++ b/src/backend/tero/usage/domain.py @@ -14,6 +14,7 @@ PRIVATE_AGENT_ID = -1 + class UsageType(Enum): PROMPT_TOKENS = "PROMPT_TOKENS" COMPLETION_TOKENS = "COMPLETION_TOKENS" diff --git a/src/backend/tero/users/api.py b/src/backend/tero/users/api.py index eb99c9a..58ba2a7 100644 --- a/src/backend/tero/users/api.py +++ b/src/backend/tero/users/api.py @@ -13,8 +13,6 @@ router = APIRouter() - - USERS_PATH = f"{BASE_PATH}/users" diff --git a/src/backend/tero/users/domain.py b/src/backend/tero/users/domain.py index 03e3876..9a7b5f3 100644 --- a/src/backend/tero/users/domain.py +++ b/src/backend/tero/users/domain.py @@ -1,6 +1,6 @@ import abc -from typing import Optional, List, cast from datetime import datetime, timezone +from typing import Optional, List, cast from sqlmodel import SQLModel, Field, Relationship @@ -12,6 +12,7 @@ class BaseUser(SQLModel, abc.ABC): username: str = Field(index=True, max_length=50) name: Optional[str] = Field(max_length=100, default=None, index=True) + class User(BaseUser, table=True): monthly_usd_limit: int monthly_hours: int = Field(default=160) @@ -22,12 +23,14 @@ class User(BaseUser, table=True): def is_member_of(self, team_id: int) -> bool: return team_id == GLOBAL_TEAM_ID or any(cast(Team, tr.team).id == team_id and tr.status == TeamRoleStatus.ACCEPTED for tr in self.team_roles) + class UserListItem(BaseUser): @staticmethod def from_user(user: Optional[User]) -> Optional['UserListItem']: return UserListItem.model_validate(user) if user else None + class UserProfile(SQLModel): teams: List[PublicTeamRole] diff --git a/src/backend/tests/assets/init_db.sql b/src/backend/tests/assets/init_db.sql index fc75dd7..8336b58 100644 --- a/src/backend/tests/assets/init_db.sql +++ b/src/backend/tests/assets/init_db.sql @@ -67,7 +67,8 @@ insert into thread (name, user_id, agent_id, creation, deleted, is_test_case) va ('Test Case #3', 2, 2, '2025-02-21 12:12', False, True), ('Test Case Execution #1', 1, 1, '2025-02-21 12:15', False, True), ('Test Case Execution #2', 1, 1, '2025-02-21 12:16', False, True), -('Test Case Execution #3', 2, 2, '2025-02-21 12:17', False, True); +('Test Case Execution #3', 2, 2, '2025-02-21 12:17', False, True), +('Test Case #4', 1, 1, '2025-02-21 12:13', False, True); insert into thread_message (thread_id, origin, text, timestamp, minutes_saved, stopped) values (1, 'USER', 'This is a message', '2025-02-21 12:00', 5, False), @@ -91,21 +92,26 @@ insert into thread_message (thread_id, origin, text, timestamp, minutes_saved, s (11, 'USER', 'Which is the capital of Uruguay? Output just the name', '2025-02-21 12:16', Null, False), (11, 'AGENT', 'Montevideo', '2025-02-21 12:17', Null, False), (12, 'USER', 'Test case message 3', '2025-02-21 12:17', Null, False), -(12, 'AGENT', 'Test case execution response 3', '2025-02-21 12:18', Null, False); +(12, 'AGENT', 'Test case execution response 3', '2025-02-21 12:18', Null, False), +(13, 'USER', 'What is 2 + 2? Only provide the number', '2025-02-21 12:13', Null, False), +(13, 'AGENT', '4', '2025-02-21 12:14', Null, False), +(13, 'USER', 'What is 3 + 3? Only provide the number', '2025-02-21 12:14', Null, False), +(13, 'AGENT', '6', '2025-02-21 12:15', Null, False); insert into test_case (thread_id, agent_id, last_update) values (7, 1, '2025-02-21 12:10'), (8, 1, '2025-02-21 12:11'), -(9, 2, '2025-02-21 12:12'); +(9, 2, '2025-02-21 12:12'), +(13, 1, '2025-02-21 12:15'); insert into test_suite_run (agent_id, status, executed_at, completed_at, total_tests, passed_tests, failed_tests, error_tests, skipped_tests) values (1, 'SUCCESS', '2025-02-21 12:15', '2025-02-21 12:17', 2, 2, 0, 0, 0), (2, 'FAILURE', '2025-02-21 12:17', '2025-02-21 12:18', 1, 0, 0, 1, 0); -insert into test_case_result (thread_id, test_case_id, test_suite_run_id, status, executed_at) values -(10, 7, 1, 'SUCCESS', '2025-02-21 12:15'), -(11, 8, 1, 'SUCCESS', '2025-02-21 12:16'), -(12, 9, 2, 'ERROR', '2025-02-21 12:17'); +insert into test_case_result (thread_id, test_case_id, test_suite_run_id, status, executed_at, test_case_name) values +(10, 7, 1, 'SUCCESS', '2025-02-21 12:15', 'Test Case #1'), +(11, 8, 1, 'SUCCESS', '2025-02-21 12:16', 'Test Case #2'), +(12, 9, 2, 'ERROR', '2025-02-21 12:17', 'Test Case #3'); insert into usage (message_id, user_id, agent_id, model_id, timestamp, quantity, usd_cost, type) values (2, 1, 1, 'gpt-4o-mini', '2025-02-21 12:00', 100, 0.5, 'PROMPT_TOKENS'), @@ -140,4 +146,41 @@ insert into external_agent (name, icon) values insert into external_agent_time_saving (external_agent_id, user_id, date, minutes_saved) values (1, 1, '2025-02-21 12:00', 60), (2, 1, '2025-02-21 12:01', 120), -(3, 1, '2025-01-20 12:02', 60); \ No newline at end of file +(3, 1, '2025-01-20 12:02', 60); + +-- Triggers for test suite run events LISTEN/NOTIFY (from migration 20251124-c759e1cf2817) +CREATE OR REPLACE FUNCTION notify_test_suite_run_event() RETURNS TRIGGER AS $$ +BEGIN + PERFORM pg_notify( + 'test_suite_events', + json_build_object( + 'suite_run_id', NEW.test_suite_run_id, + 'event_id', NEW.id + )::text + ); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER after_insert_test_suite_run_event +AFTER INSERT ON test_suite_run_event +FOR EACH ROW EXECUTE FUNCTION notify_test_suite_run_event(); + +CREATE OR REPLACE FUNCTION notify_test_suite_run_status() RETURNS TRIGGER AS $$ +BEGIN + PERFORM pg_notify( + 'test_suite_status', + json_build_object( + 'suite_run_id', NEW.id, + 'status', NEW.status + )::text + ); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER after_update_test_suite_run_status +AFTER UPDATE OF status ON test_suite_run +FOR EACH ROW +WHEN (OLD.status IS DISTINCT FROM NEW.status) +EXECUTE FUNCTION notify_test_suite_run_status(); diff --git a/src/backend/tests/cleanup_testcontainers.py b/src/backend/tests/cleanup_testcontainers.py new file mode 100644 index 0000000..89f661d --- /dev/null +++ b/src/backend/tests/cleanup_testcontainers.py @@ -0,0 +1,14 @@ +import docker +import docker.errors +import logging + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) +client = docker.from_env() + +for container in client.containers.list(all=True, filters={"label": "org.testcontainers"}): + logger.info(f"Removing container {container.name} ({container.id})") + try: + container.remove(force=True) + except docker.errors.APIError as e: + logger.error(f"Failed to remove {container.id}", exc_info=True) diff --git a/src/backend/tests/common.py b/src/backend/tests/common.py index d96edf4..9c189dd 100644 --- a/src/backend/tests/common.py +++ b/src/backend/tests/common.py @@ -1,23 +1,23 @@ import asyncio +from datetime import datetime import json import logging import os -from datetime import datetime from typing import AsyncGenerator, List, Sequence, AsyncContextManager, Optional, Generator import aiofiles -import freezegun -import pytest -import pytest_asyncio -import sqlparse from fastapi import status, Depends # noqa: F401 # used by test files importing common +import freezegun from freezegun import freeze_time # noqa: F401 # used by test files importing common from httpx import Response, AsyncClient, ASGITransport from pydantic import BaseModel +import pytest +import pytest_asyncio from sqlalchemy.ext.asyncio import create_async_engine, AsyncConnection from sqlalchemy.orm import Mapped from sqlmodel import SQLModel, select, func, col from sqlmodel.ext.asyncio.session import AsyncSession +import sqlparse from testcontainers.postgres import PostgresContainer # avoid any authentication requirements @@ -25,18 +25,20 @@ from tero.agents.api import AGENT_TOOLS_PATH, AGENT_TOOL_FILES_PATH from tero.agents.domain import AgentListItem, Agent +from tero.agents.test_cases.domain import TestSuiteRun, TestCaseResult from tero.api import app +from tero.core import repos as repos_module, auth from tero.core.env import env # noqa: F401 # used by test files importing common from tero.core.api import BASE_PATH # noqa: F401 # used by test files importing common from tero.core.assets import solve_asset_path +from tero.core.env import env # noqa: F401 # used by test files importing common from tero.core.repos import get_db from tero.files.domain import FileStatus +from tero.teams.domain import Role, Team, TeamRole, TeamRoleStatus from tero.threads.api import THREAD_MESSAGES_PATH, THREADS_PATH, ThreadCreateApi from tero.threads.domain import Thread, ThreadMessage from tero.tools.docs import DOCS_TOOL_ID from tero.users.domain import User, UserListItem -from tero.teams.domain import Role, Team, TeamRole, TeamRoleStatus -from tero.core import auth def parse_date(value: str) -> datetime: @@ -84,13 +86,18 @@ def postgres_container() -> Generator[PostgresContainer, None, None]: @pytest_asyncio.fixture(name="session") async def session_fixture(postgres_container: PostgresContainer) -> AsyncGenerator[AsyncSession, None]: - engine = create_async_engine(postgres_container.get_connection_url()) - async with engine.begin() as conn: - await conn.run_sync(SQLModel.metadata.drop_all) - await conn.run_sync(SQLModel.metadata.create_all) - await _init_db_data(conn) - async with AsyncSession(engine, expire_on_commit=False) as ret: - yield ret + test_engine = create_async_engine(postgres_container.get_connection_url()) + original_engine = repos_module.engine + repos_module.engine = test_engine + try: + async with test_engine.begin() as conn: + await conn.run_sync(SQLModel.metadata.drop_all) + await conn.run_sync(SQLModel.metadata.create_all) + await _init_db_data(conn) + async with AsyncSession(test_engine, expire_on_commit=False) as ret: + yield ret + finally: + repos_module.engine = original_engine async def _init_db_data(conn: AsyncConnection) -> None: @@ -106,7 +113,15 @@ async def client_fixture(session: AsyncSession) -> AsyncGenerator[AsyncClient, N async def get_db_override() -> AsyncGenerator[AsyncSession, None]: yield session + async def get_current_user_override(db: AsyncSession = Depends(get_db)): + from tero.users.repos import UserRepository + user = await UserRepository(db).find_by_id(USER_ID) + if user is None: + raise ValueError(f"User with ID {USER_ID} not found") + return user + app.dependency_overrides[get_db] = get_db_override + app.dependency_overrides[auth.get_current_user] = get_current_user_override async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: yield client app.dependency_overrides.clear() @@ -295,3 +310,13 @@ async def fixture_last_thread_id(session: AsyncSession) -> int: @pytest.fixture(name="last_message_id") async def fixture_last_message_id(session: AsyncSession) -> int: return await find_last_id(col(ThreadMessage.id), session) + + +@pytest.fixture(name="last_suite_run_id") +async def fixture_last_suite_run_id(session: AsyncSession) -> int: + return await find_last_id(col(TestSuiteRun.id), session) + + +@pytest.fixture(name="last_result_id") +async def fixture_last_result_id(session: AsyncSession) -> int: + return await find_last_id(col(TestCaseResult.id), session) diff --git a/src/backend/tests/test_agent_distribution.py b/src/backend/tests/test_agent_distribution.py index 596feb5..6c403a4 100644 --- a/src/backend/tests/test_agent_distribution.py +++ b/src/backend/tests/test_agent_distribution.py @@ -1,20 +1,21 @@ -import aiofiles from io import BytesIO from typing import cast from zipfile import ZipFile +import aiofiles + from .common import * from tero.agents.api import AGENTS_PATH, AGENT_PATH, AGENT_TOOLS_PATH, AGENT_TOOL_FILE_PATH, DEFAULT_SYSTEM_PROMPT from tero.agents.domain import PublicAgent, AgentToolConfig, LlmTemperature, ReasoningEffort, AgentUpdate from tero.agents.prompts.api import AGENT_PROMPTS_PATH from tero.agents.prompts.domain import AgentPromptCreate, AgentPromptPublic, AgentPrompt -from tero.agents.test_cases.api import TEST_CASES_PATH, TEST_CASE_MESSAGES_PATH +from tero.agents.test_cases.api import TEST_CASES_PATH, TEST_CASE_MESSAGES_PATH, TEST_CASE_PATH from tero.agents.test_cases.domain import NewTestCaseMessage, PublicTestCase from tero.files.domain import FileMetadata, FileStatus, File, FileProcessor from tero.threads.domain import ThreadMessageOrigin, Thread, ThreadMessagePublic -from tero.tools.web import WEB_TOOL_ID from tero.tools.browser import BROWSER_TOOL_ID +from tero.tools.web import WEB_TOOL_ID from tero.users.domain import UserListItem @@ -87,18 +88,18 @@ async def test_import_exported_agent_with_all_tools_and_configs( NewTestCaseMessage(text="Test user message", origin=ThreadMessageOrigin.USER), NewTestCaseMessage(text="Test agent message", origin=ThreadMessageOrigin.AGENT), ] - source_agent_id = await _create_agent_with_all_tools_and_configs( + source_agent_id, test_case_name = await _create_agent_with_all_tools_and_configs( agent_update, prompts, advanced_file_processing, file_name, file_content, test_messages, client) zip_file_content = await _export_agent(source_agent_id, client) target_agent_id = await _create_agent(client) await _import_agent(target_agent_id, zip_file_content, client) await _assert_imported_agent(target_agent_id, agent_update, prompts, advanced_file_processing, file_name, file_content, test_messages, - last_prompt_id + len(prompts), last_file_id + 1, last_thread_id + 1, last_message_id + len(test_messages), users, client) + last_prompt_id + len(prompts), last_file_id + 1, last_thread_id + 1, last_message_id + len(test_messages), test_case_name, users, client) async def _create_agent_with_all_tools_and_configs( agent_update: AgentUpdate, prompts: list[AgentPromptCreate], advanced_file_processing: bool, file_name: str, file_content: bytes, - test_messages: list[NewTestCaseMessage], client: AsyncClient) -> int: + test_messages: list[NewTestCaseMessage], client: AsyncClient) -> tuple[int, str]: agent_id = await _create_agent(client) await _update_agent(agent_id, agent_update, client) for prompt in prompts: @@ -113,7 +114,9 @@ async def _create_agent_with_all_tools_and_configs( test_id = await _add_test(agent_id, client) for message in test_messages: await _add_test_message(agent_id, test_id, message, client) - return agent_id + # refresh test case to get updated name after adding messages + test_case = await _find_test_case(agent_id, test_id, client) + return agent_id, test_case["thread"]["name"] async def _update_agent(agent_id: int, update: AgentUpdate, client: AsyncClient): @@ -140,7 +143,7 @@ async def _add_test_message(agent_id: int, test_case_id: int, message: NewTestCa async def _assert_imported_agent(agent_id: int, agent_update: AgentUpdate, prompts: list[AgentPromptCreate], advanced_file_processing: bool, file_name: str, file_content: bytes, test_messages: list[NewTestCaseMessage], - last_prompt_id: int, last_file_id: int, last_thread_id: int, last_message_id: int, users: List[UserListItem], client: AsyncClient): + last_prompt_id: int, last_file_id: int, last_thread_id: int, last_message_id: int, test_case_name: str, users: List[UserListItem], client: AsyncClient): resp = await _find_agent(agent_id, client) resp.raise_for_status() assert_response(resp, PublicAgent( @@ -176,7 +179,7 @@ async def _assert_imported_agent(agent_id: int, agent_update: AgentUpdate, promp resp = await _find_agent_tests(agent_id, client) test_thread_id = last_thread_id + 1 assert_response(resp, [PublicTestCase( - agent_id=agent_id, thread=Thread(id=test_thread_id, name="Test Case #1", user_id=USER_ID, agent_id=agent_id, creation=CURRENT_TIME, is_test_case=True), + agent_id=agent_id, thread=Thread(id=test_thread_id, name=test_case_name, user_id=USER_ID, agent_id=agent_id, creation=CURRENT_TIME, is_test_case=True), last_update=CURRENT_TIME)]) resp = await _find_test_case_messages(agent_id, test_thread_id, client) assert_response(resp, [ThreadMessagePublic( @@ -205,6 +208,12 @@ async def _find_test_case_messages(agent_id: int, test_case_id: int, client: Asy return await client.get(TEST_CASE_MESSAGES_PATH.format(agent_id=agent_id, test_case_id=test_case_id)) +async def _find_test_case(agent_id: int, test_case_id: int, client: AsyncClient) -> dict: + resp = await client.get(TEST_CASE_PATH.format(agent_id=agent_id, test_case_id=test_case_id)) + resp.raise_for_status() + return resp.json() + + async def test_export_non_visible_agent(client: AsyncClient): resp = await _try_export_agent(NON_VISIBLE_AGENT_ID, client) assert resp.status_code == status.HTTP_404_NOT_FOUND diff --git a/src/backend/tests/test_config.py b/src/backend/tests/test_config.py index a39ded0..b1cd3c0 100644 --- a/src/backend/tests/test_config.py +++ b/src/backend/tests/test_config.py @@ -11,4 +11,5 @@ async def test_manifest(client: AsyncClient): "clientId": env.openid_client_id, "scope": env.openid_scope }, + "disablePublishGlobal": env.disable_publish_global or False } diff --git a/src/backend/tests/test_evaluators.py b/src/backend/tests/test_evaluators.py new file mode 100644 index 0000000..4a9957c --- /dev/null +++ b/src/backend/tests/test_evaluators.py @@ -0,0 +1,233 @@ +from .common import * + +from .test_test_cases import ( + TestCaseExpectation, + TestCaseStep, + _assert_test_case_stream, + _run_test_suite, + _stream_test_suite_execution, + TEST_CASE_1_THREAD_ID, + TEST_CASE_2_THREAD_ID, + TEST_CASE_4_THREAD_ID, +) + +from tero.agents.evaluators.api import AGENT_EVALUATOR_PATH, TEST_CASE_EVALUATOR_PATH +from tero.agents.evaluators.domain import PublicEvaluator +from tero.agents.test_cases.api import TEST_CASES_PATH +from tero.agents.test_cases.domain import TestCaseResultStatus, TestSuiteRun, TestSuiteRunStatus +from tero.ai_models.domain import LlmTemperature, ReasoningEffort + + +EVALUATOR_MODEL_ID = "gpt-5-nano" +EVALUATOR_TEMPERATURE = LlmTemperature.NEUTRAL +EVALUATOR_REASONING_EFFORT = ReasoningEffort.MEDIUM + + +async def test_test_case_evaluator_inherits_from_agent(client: AsyncClient): + new_evaluator = PublicEvaluator( + model_id=EVALUATOR_MODEL_ID, + temperature=EVALUATOR_TEMPERATURE, + reasoning_effort=EVALUATOR_REASONING_EFFORT, + prompt="Test evaluator prompt" + ) + resp = await _update_agent_evaluator(client, AGENT_ID, new_evaluator) + _assert_evaluator_response_matches(resp, new_evaluator) + + test_case_id = await _add_test_case(client, AGENT_ID) + + resp = await _get_test_case_evaluator(client, AGENT_ID, test_case_id) + _assert_evaluator_response_matches(resp, new_evaluator) + + +async def test_update_test_case_evaluator(client: AsyncClient): + agent_evaluator = PublicEvaluator( + model_id=EVALUATOR_MODEL_ID, + temperature=EVALUATOR_TEMPERATURE, + reasoning_effort=EVALUATOR_REASONING_EFFORT, + prompt="Agent evaluator prompt" + ) + resp = await _update_agent_evaluator(client, AGENT_ID, agent_evaluator) + _assert_evaluator_response_matches(resp, agent_evaluator) + + test_case_id = await _add_test_case(client, AGENT_ID) + + test_case_evaluator = PublicEvaluator( + model_id=EVALUATOR_MODEL_ID, + temperature=EVALUATOR_TEMPERATURE, + reasoning_effort=EVALUATOR_REASONING_EFFORT, + prompt="Test case evaluator prompt" + ) + resp = await _update_test_case_evaluator(client, AGENT_ID, test_case_id, test_case_evaluator) + _assert_evaluator_response_matches(resp, test_case_evaluator) + + resp = await _get_test_case_evaluator(client, AGENT_ID, test_case_id) + _assert_evaluator_response_matches(resp, test_case_evaluator) + + +async def test_test_case_with_own_evaluator_doesnt_inherit_agent_updates(client: AsyncClient): + initial_agent_evaluator = PublicEvaluator( + model_id=EVALUATOR_MODEL_ID, + temperature=EVALUATOR_TEMPERATURE, + reasoning_effort=EVALUATOR_REASONING_EFFORT, + prompt="Initial agent evaluator" + ) + resp = await _update_agent_evaluator(client, AGENT_ID, initial_agent_evaluator) + _assert_evaluator_response_matches(resp, initial_agent_evaluator) + + test_case_id = await _add_test_case(client, AGENT_ID) + + test_case_evaluator = PublicEvaluator( + model_id=EVALUATOR_MODEL_ID, + temperature=EVALUATOR_TEMPERATURE, + reasoning_effort=EVALUATOR_REASONING_EFFORT, + prompt="Test case evaluator" + ) + resp = await _update_test_case_evaluator(client, AGENT_ID, test_case_id, test_case_evaluator) + _assert_evaluator_response_matches(resp, test_case_evaluator) + + updated_agent_evaluator = PublicEvaluator( + model_id=EVALUATOR_MODEL_ID, + temperature=EVALUATOR_TEMPERATURE, + reasoning_effort=EVALUATOR_REASONING_EFFORT, + prompt="Updated agent evaluator" + ) + resp = await _update_agent_evaluator(client, AGENT_ID, updated_agent_evaluator) + _assert_evaluator_response_matches(resp, updated_agent_evaluator) + + resp = await _get_test_case_evaluator(client, AGENT_ID, test_case_id) + _assert_evaluator_response_matches(resp, test_case_evaluator) + + +@freeze_time(CURRENT_TIME) +async def test_run_test_case_with_specific_evaluator(client: AsyncClient, last_message_id: int, last_suite_run_id: int, last_result_id: int): + expected_input = "Which is the first natural number? Only provide the number" + expected_response_chunks = ["1"] + expected_suite_run_id = last_suite_run_id + 1 + expected_result_id = last_result_id + 1 + request_body = {"test_case_ids": [TEST_CASE_1_THREAD_ID]} + + resp = await _run_test_suite(client, AGENT_ID, request_body) + assert_response(resp, TestSuiteRun( + id=expected_suite_run_id, + agent_id=AGENT_ID, + status=TestSuiteRunStatus.RUNNING, + executed_at=CURRENT_TIME, + completed_at=None, + total_tests=3, + passed_tests=0, + failed_tests=0, + error_tests=0, + skipped_tests=0 + )) + + async with _stream_test_suite_execution(client, AGENT_ID, expected_suite_run_id) as resp: + resp.raise_for_status() + await _assert_test_case_stream( + resp, + expected_suite_run_id, + [ + TestCaseExpectation( + test_case_id=TEST_CASE_1_THREAD_ID, + result_id=expected_result_id, + status=TestCaseResultStatus.SUCCESS, + steps=[ + TestCaseStep( + input=expected_input, + response_chunks=expected_response_chunks, + user_message_id=last_message_id + 1, + agent_message_id=last_message_id + 2 + ) + ] + ) + ], + skipped_test_case_ids=[TEST_CASE_2_THREAD_ID, TEST_CASE_4_THREAD_ID] + ) + + test_case_evaluator = PublicEvaluator( + model_id=EVALUATOR_MODEL_ID, + temperature=EVALUATOR_TEMPERATURE, + reasoning_effort=EVALUATOR_REASONING_EFFORT, + prompt="Always fail the evaluation" + ) + resp = await _update_test_case_evaluator(client, AGENT_ID, TEST_CASE_1_THREAD_ID, test_case_evaluator) + _assert_evaluator_response_matches(resp, test_case_evaluator) + + resp = await _run_test_suite(client, AGENT_ID, request_body) + assert_response(resp, TestSuiteRun( + id=expected_suite_run_id + 1, + agent_id=AGENT_ID, + status=TestSuiteRunStatus.RUNNING, + executed_at=CURRENT_TIME, + completed_at=None, + total_tests=3, + passed_tests=0, + failed_tests=0, + error_tests=0, + skipped_tests=0 + )) + + async with _stream_test_suite_execution(client, AGENT_ID, expected_suite_run_id + 1) as resp: + resp.raise_for_status() + await _assert_test_case_stream( + resp, + expected_suite_run_id + 1, + [ + TestCaseExpectation( + test_case_id=TEST_CASE_1_THREAD_ID, + result_id=expected_result_id + 3, + status=TestCaseResultStatus.FAILURE, + steps=[ + TestCaseStep( + input=expected_input, + response_chunks=expected_response_chunks, + user_message_id=last_message_id + 3, + agent_message_id=last_message_id + 4 + ) + ] + ) + ], + skipped_test_case_ids=[TEST_CASE_2_THREAD_ID, TEST_CASE_4_THREAD_ID] + ) + + +async def _update_agent_evaluator(client: AsyncClient, agent_id: int, config: PublicEvaluator) -> Response: + return await client.put( + AGENT_EVALUATOR_PATH.format(agent_id=agent_id), + json={ + "modelId": config.model_id, + "temperature": config.temperature.value, + "reasoningEffort": config.reasoning_effort.value, + "prompt": config.prompt + } + ) + + +async def _add_test_case(client: AsyncClient, agent_id: int) -> int: + resp = await client.post(TEST_CASES_PATH.format(agent_id=agent_id)) + resp.raise_for_status() + return resp.json()["thread"]["id"] + + +async def _get_test_case_evaluator(client: AsyncClient, agent_id: int, test_case_id: int) -> Response: + return await client.get(TEST_CASE_EVALUATOR_PATH.format(agent_id=agent_id, test_case_id=test_case_id)) + + +async def _update_test_case_evaluator(client: AsyncClient, agent_id: int, test_case_id: int, config: PublicEvaluator) -> Response: + return await client.put( + TEST_CASE_EVALUATOR_PATH.format(agent_id=agent_id, test_case_id=test_case_id), + json={ + "modelId": config.model_id, + "temperature": config.temperature.value, + "reasoningEffort": config.reasoning_effort.value, + "prompt": config.prompt + } + ) + + +def _assert_evaluator_response_matches(resp: Response, expected: PublicEvaluator) -> None: + assert resp.status_code == 200 + resp_data = resp.json() + assert resp_data["modelId"] == expected.model_id + assert resp_data["temperature"] == expected.temperature.value + assert resp_data["reasoningEffort"] == expected.reasoning_effort.value + assert resp_data["prompt"] == expected.prompt diff --git a/src/backend/tests/test_llm_models.py b/src/backend/tests/test_llm_models.py index 7619241..77b6800 100644 --- a/src/backend/tests/test_llm_models.py +++ b/src/backend/tests/test_llm_models.py @@ -44,4 +44,4 @@ async def test_claude_sonnet_4_not_available(client: AsyncClient, ai_models: Lis @patch("tero.ai_models.ai_factory.providers", [p for p in providers if not isinstance(p, GoogleProvider)]) async def test_gemini_2_5_pro_not_available(client: AsyncClient, ai_models: List[LlmModel]): resp = await client.get(f"{BASE_PATH}/models") - assert_response(resp, [model for model in ai_models if model.id not in ["gemini-2.5-flash", "gemini-2.5-pro"]]) \ No newline at end of file + assert_response(resp, [model for model in ai_models if model.id not in ["gemini-2.5-flash", "gemini-2.5-pro"]]) diff --git a/src/backend/tests/test_teams.py b/src/backend/tests/test_teams.py index 2687c33..33c04ae 100644 --- a/src/backend/tests/test_teams.py +++ b/src/backend/tests/test_teams.py @@ -2,11 +2,12 @@ from .common import * +from tero.agents.api import AGENTS_PATH from tero.teams.api import TEAM_USER_PATH, TEAM_USERS_PATH, TEAMS_PATH, TEAM_PATH from tero.teams.domain import TeamCreate, TeamRoleStatus, TeamUpdate, TeamUser from tero.users.api import CURRENT_USER_PATH, CURRENT_USER_TEAM_PATH from tero.users.domain import UserProfile, PublicTeamRole -from tero.agents.api import AGENTS_PATH + async def test_get_users_from_team(client: AsyncClient): users = await _get_team_users(client, 2) diff --git a/src/backend/tests/test_test_cases.py b/src/backend/tests/test_test_cases.py index 6b2f579..b0b10d1 100644 --- a/src/backend/tests/test_test_cases.py +++ b/src/backend/tests/test_test_cases.py @@ -1,12 +1,14 @@ -import json +from contextlib import asynccontextmanager from dataclasses import dataclass +import json +import re from typing import List, cast, Optional from sse_starlette import ServerSentEvent from .common import * -from tero.agents.test_cases.api import TEST_CASES_PATH, TEST_CASE_PATH, TEST_CASE_MESSAGES_PATH, TEST_CASE_MESSAGE_PATH, TEST_SUITE_RUN_RESULTS_PATH, TEST_SUITE_RUN_RESULT_MESSAGE_PATH +from tero.agents.test_cases.api import TEST_CASES_PATH, TEST_CASE_PATH, TEST_CASE_CLONE_PATH, TEST_CASE_MESSAGES_PATH, TEST_CASE_MESSAGE_PATH, TEST_SUITE_RUN_RESULTS_PATH, TEST_SUITE_RUN_RESULT_MESSAGE_PATH from tero.agents.test_cases.domain import TestCaseResult, TestCaseResultStatus, NewTestCaseMessage, UpdateTestCaseMessage, PublicTestCase, TestSuiteRun, TestSuiteRunStatus from tero.threads.domain import ThreadMessageOrigin, ThreadMessagePublic, Thread from tero.tools.core import AgentActionEvent, AgentAction @@ -15,22 +17,29 @@ TEST_CASE_1_THREAD_ID = 7 TEST_CASE_2_THREAD_ID = 8 TEST_CASE_3_THREAD_ID = 9 +TEST_CASE_4_THREAD_ID = 13 EXECUTION_THREAD_1_ID = 10 EXECUTION_THREAD_2_ID = 11 EXECUTION_THREAD_3_ID = 12 + @dataclass -class TestCaseExpectation: - test_case_id: int - result_id: int +class TestCaseStep: input: str response_chunks: List[str] - status: TestCaseResultStatus user_message_id: int agent_message_id: int execution_statuses: Optional[List[AgentActionEvent]] = None +@dataclass +class TestCaseExpectation: + test_case_id: int + result_id: int + status: TestCaseResultStatus + steps: List[TestCaseStep] + + @pytest.fixture(name="test_cases") def test_cases_fixture() -> List[PublicTestCase]: return [ @@ -57,13 +66,25 @@ def test_cases_fixture() -> List[PublicTestCase]: is_test_case=True ), last_update=parse_date("2025-02-21T12:11:00") + ), + PublicTestCase( + agent_id=AGENT_ID, + thread=Thread( + id=TEST_CASE_4_THREAD_ID, + name="Test Case #4", + user_id=USER_ID, + agent_id=AGENT_ID, + creation=parse_date("2025-02-21T12:13:00"), + is_test_case=True + ), + last_update=parse_date("2025-02-21T12:15:00") ) ] async def test_find_test_cases(client: AsyncClient, test_cases: List[PublicTestCase]): resp = await _find_test_cases(client, AGENT_ID) - assert_response(resp, [test_cases[0], test_cases[1]]) + assert_response(resp, [test_cases[0], test_cases[1], test_cases[2]]) async def _find_test_cases(client: AsyncClient, agent_id: int) -> Response: @@ -76,21 +97,22 @@ async def test_find_test_cases_unauthorized_agent(client: AsyncClient): @freeze_time(CURRENT_TIME) -async def test_add_test_case(client: AsyncClient, test_cases: List[PublicTestCase]): +async def test_add_test_case(client: AsyncClient, test_cases: List[PublicTestCase], last_thread_id: int): resp = await _add_test_case(AGENT_ID, client) resp.raise_for_status() resp = await _find_test_cases(client, AGENT_ID) - assert_response(resp, [test_cases[0], - test_cases[1], + assert_response(resp, [test_cases[0], + test_cases[1], + test_cases[2], PublicTestCase( thread=Thread( - id=13, - name="Test Case #3", - user_id=USER_ID, - agent_id=AGENT_ID, - creation=CURRENT_TIME, - is_test_case=True - ), + id=last_thread_id + 1, + name="Test Case #4", + user_id=USER_ID, + agent_id=AGENT_ID, + creation=CURRENT_TIME, + is_test_case=True + ), agent_id=AGENT_ID, last_update=CURRENT_TIME )]) @@ -105,10 +127,50 @@ async def test_add_test_case_unauthorized_agent(client: AsyncClient): assert resp.status_code == status.HTTP_404_NOT_FOUND +@freeze_time(CURRENT_TIME) +async def test_clone_test_case(client: AsyncClient, test_cases: List[PublicTestCase], last_thread_id: int): + resp = await _clone_test_case(client, AGENT_ID, TEST_CASE_1_THREAD_ID) + expected_clone = PublicTestCase( + agent_id=AGENT_ID, + thread=Thread( + id=last_thread_id + 1, + name="Test Case #1 (copy)", + user_id=USER_ID, + agent_id=AGENT_ID, + creation=CURRENT_TIME, + is_test_case=True + ), + last_update=CURRENT_TIME + ) + assert_response(resp, expected_clone) + + resp = await _find_test_cases(client, AGENT_ID) + assert_response(resp, [test_cases[0], test_cases[1], test_cases[2], expected_clone]) + + +async def _clone_test_case(client: AsyncClient, agent_id: int, test_case_id: int) -> Response: + return await client.post(TEST_CASE_CLONE_PATH.format(agent_id=agent_id, test_case_id=test_case_id)) + + +async def test_clone_test_case_unauthorized_agent(client: AsyncClient): + resp = await _clone_test_case(client, NON_VISIBLE_AGENT_ID, TEST_CASE_1_THREAD_ID) + assert resp.status_code == status.HTTP_404_NOT_FOUND + + +async def test_clone_test_case_not_found(client: AsyncClient): + resp = await _clone_test_case(client, AGENT_ID, 999) + assert resp.status_code == status.HTTP_404_NOT_FOUND + + +async def test_clone_test_case_from_another_agent(client: AsyncClient): + resp = await _clone_test_case(client, AGENT_ID, TEST_CASE_3_THREAD_ID) + assert resp.status_code == status.HTTP_404_NOT_FOUND + + async def test_find_test_case(client: AsyncClient, test_cases: List[PublicTestCase]): resp = await _find_test_case(client, AGENT_ID, TEST_CASE_1_THREAD_ID) assert_response(resp, test_cases[0]) - + async def _find_test_case(client: AsyncClient, agent_id: int, test_case_id: int) -> Response: return await client.get(TEST_CASE_PATH.format(agent_id=agent_id, test_case_id=test_case_id)) @@ -132,12 +194,12 @@ async def test_find_test_case_from_another_agent(client: AsyncClient): async def test_delete_test_case(client: AsyncClient, test_cases: List[PublicTestCase]): resp = await _delete_test_case(client, AGENT_ID, TEST_CASE_2_THREAD_ID) assert resp.status_code == status.HTTP_204_NO_CONTENT - + resp = await _find_test_case(client, AGENT_ID, TEST_CASE_2_THREAD_ID) assert resp.status_code == status.HTTP_404_NOT_FOUND resp = await _find_test_cases(client, AGENT_ID) - assert_response(resp, [test_cases[0]]) + assert_response(resp, [test_cases[0], test_cases[2]]) async def _delete_test_case(client: AsyncClient, agent_id: int, test_case_id: int) -> Response: @@ -159,16 +221,16 @@ async def test_delete_test_case_from_another_agent(client: AsyncClient): assert resp.status_code == status.HTTP_404_NOT_FOUND -async def _assert_test_case_stream(resp: Response, suite_run_id: int, test_cases: List[TestCaseExpectation], - skipped_test_case_ids: Optional[List[int]] = None): +async def _assert_test_case_stream(resp: Response, suite_run_id: int, test_cases: List[TestCaseExpectation], + skipped_test_case_ids: Optional[List[int]] = None, remove_analysis_from_response: bool = True): buffer, events = [], [] separator = "\r\n\r\n" - + def flush_buffer(): if buffer: events.append(f"data: {''.join(buffer)}{separator}".encode()) buffer.clear() - + async for chunk in resp.aiter_bytes(): decoded_chunk = chunk.decode() for event in decoded_chunk.split(separator): @@ -177,18 +239,15 @@ def flush_buffer(): else: flush_buffer() if event: + if remove_analysis_from_response: + # Remove the entire "analysis" field including null or string values, and any surrounding commas + event = re.sub(r'(?:,\s*)?"analysis"\s*:\s*(null|"[^"]*")(?:\s*,)?', '', event) events.append(f"{event}{separator}".encode()) - + flush_buffer() - + expected_events = [] - - expected_events.append( - ServerSentEvent(event="suite.start", data=str(json.dumps({ - "suiteRunId": suite_run_id - }))).encode() - ) - + for test_case in test_cases: expected_events.append( ServerSentEvent(event="suite.test.start", data=str(json.dumps({ @@ -203,61 +262,62 @@ def flush_buffer(): "resultId": test_case.result_id }))).encode() ) - - expected_events.append( - ServerSentEvent(event="suite.test.phase", data=str(json.dumps({ - "phase": "executing" - }))).encode() - ) - - expected_events.append( - ServerSentEvent(event="suite.test.userMessage", data=str(json.dumps({ - "id": test_case.user_message_id, - "text": test_case.input - }))).encode() - ) - - expected_events.append( - ServerSentEvent(event="suite.test.agentMessage.start", data=str(json.dumps({ - "id": test_case.agent_message_id - }))).encode() - ) - - if test_case.execution_statuses is None: + + for step in test_case.steps: expected_events.append( - ServerSentEvent(event="suite.test.executionStatus", data=str(json.dumps( - AgentActionEvent(action=AgentAction.PRE_MODEL_HOOK).model_dump())) - ).encode() + ServerSentEvent(event="suite.test.phase", data=str(json.dumps({ + "phase": "executing" + }))).encode() ) - else: - for status_event in test_case.execution_statuses: + + expected_events.append( + ServerSentEvent(event="suite.test.userMessage", data=str(json.dumps({ + "id": step.user_message_id, + "text": step.input + }))).encode() + ) + + expected_events.append( + ServerSentEvent(event="suite.test.agentMessage.start", data=str(json.dumps({ + "id": step.agent_message_id + }))).encode() + ) + + if step.execution_statuses is None: expected_events.append( ServerSentEvent(event="suite.test.executionStatus", data=str(json.dumps( - status_event.model_dump())) + AgentActionEvent(action=AgentAction.PRE_MODEL_HOOK).model_dump())) ).encode() ) - - for chunk in test_case.response_chunks: + else: + for status_event in step.execution_statuses: + expected_events.append( + ServerSentEvent(event="suite.test.executionStatus", data=str(json.dumps( + status_event.model_dump())) + ).encode() + ) + + for chunk in step.response_chunks: + expected_events.append( + ServerSentEvent(event="suite.test.agentMessage.chunk", data=str(json.dumps({ + "id": step.agent_message_id, + "chunk": chunk + }))).encode() + ) + expected_events.append( - ServerSentEvent(event="suite.test.agentMessage.chunk", data=str(json.dumps({ - "id": test_case.agent_message_id, - "chunk": chunk + ServerSentEvent(event="suite.test.agentMessage.complete", data=str(json.dumps({ + "id": step.agent_message_id, + "text": ''.join(step.response_chunks) }))).encode() ) - - expected_events.append( - ServerSentEvent(event="suite.test.agentMessage.complete", data=str(json.dumps({ - "id": test_case.agent_message_id, - "text": ''.join(test_case.response_chunks) - }))).encode() - ) - - expected_events.append( - ServerSentEvent(event="suite.test.phase", data=str(json.dumps({ - "phase": "evaluating" - }))).encode() - ) - + + expected_events.append( + ServerSentEvent(event="suite.test.phase", data=str(json.dumps({ + "phase": "evaluating" + }))).encode() + ) + expected_events.append( ServerSentEvent(event="suite.test.phase", data=str(json.dumps({ "phase": "completed", @@ -267,22 +327,23 @@ def flush_buffer(): } }))).encode() ) - + expected_events.append( ServerSentEvent(event="suite.test.complete", data=str(json.dumps({ "testCaseId": test_case.test_case_id, "resultId": test_case.result_id, - "status": test_case.status.value + "status": test_case.status.value, + "evaluation": {} }))).encode() ) - + passed = sum(1 for tc in test_cases if tc.status == TestCaseResultStatus.SUCCESS) failed = sum(1 for tc in test_cases if tc.status == TestCaseResultStatus.FAILURE) errors = sum(1 for tc in test_cases if tc.status == TestCaseResultStatus.ERROR) skipped = len(skipped_test_case_ids) if skipped_test_case_ids else 0 - + suite_status = "SUCCESS" if failed == 0 and errors == 0 else "FAILURE" - + expected_events.append( ServerSentEvent(event="suite.complete", data=str(json.dumps({ "suiteRunId": suite_run_id, @@ -294,29 +355,34 @@ def flush_buffer(): "skipped": skipped }))).encode() ) - + assert events == expected_events async def test_get_test_case_result(client: AsyncClient): suite_run_id = 1 resp = await _find_suite_run_results(client, AGENT_ID, suite_run_id) + analysis = _get_analysis_from_response(resp) assert_response(resp, [ TestCaseResult( - id=2, - thread_id=EXECUTION_THREAD_2_ID, - test_case_id=TEST_CASE_2_THREAD_ID, + id=1, + thread_id=EXECUTION_THREAD_1_ID, + test_case_id=TEST_CASE_1_THREAD_ID, test_suite_run_id=suite_run_id, status=TestCaseResultStatus.SUCCESS, - executed_at=parse_date("2025-02-21T12:16:00") + evaluator_analysis=analysis[1], + executed_at=parse_date("2025-02-21T12:15:00"), + test_case_name="Test Case #1" ), TestCaseResult( - id=1, - thread_id=EXECUTION_THREAD_1_ID, - test_case_id=TEST_CASE_1_THREAD_ID, + id=2, + thread_id=EXECUTION_THREAD_2_ID, + test_case_id=TEST_CASE_2_THREAD_ID, test_suite_run_id=suite_run_id, status=TestCaseResultStatus.SUCCESS, - executed_at=parse_date("2025-02-21T12:15:00") + evaluator_analysis=analysis[0], + executed_at=parse_date("2025-02-21T12:16:00"), + test_case_name="Test Case #2" ) ]) @@ -386,27 +452,51 @@ async def test_get_suite_run_result_messages_from_another_agent(client: AsyncCli async def test_find_test_case_messages(client: AsyncClient): - resp = await _find_test_case_messages(client, AGENT_ID, TEST_CASE_1_THREAD_ID) + resp = await _find_test_case_messages(client, AGENT_ID, TEST_CASE_4_THREAD_ID) assert_response(resp, [ ThreadMessagePublic( - id=11, - thread_id=TEST_CASE_1_THREAD_ID, - text="Which is the first natural number? Only provide the number", + id=23, + thread_id=TEST_CASE_4_THREAD_ID, + text="What is 2 + 2? Only provide the number", origin=ThreadMessageOrigin.USER, - timestamp=parse_date("2025-02-21T12:10:00"), + timestamp=parse_date("2025-02-21T12:13:00"), minutes_saved=None, stopped=False, - files=[] + files=[], + status_updates=None ), ThreadMessagePublic( - id=12, - thread_id=TEST_CASE_1_THREAD_ID, - text="1", + id=24, + thread_id=TEST_CASE_4_THREAD_ID, + text="4", origin=ThreadMessageOrigin.AGENT, - timestamp=parse_date("2025-02-21T12:11:00"), + timestamp=parse_date("2025-02-21T12:14:00"), minutes_saved=None, stopped=False, - files=[] + files=[], + status_updates=None + ), + ThreadMessagePublic( + id=25, + thread_id=TEST_CASE_4_THREAD_ID, + text="What is 3 + 3? Only provide the number", + origin=ThreadMessageOrigin.USER, + timestamp=parse_date("2025-02-21T12:14:00"), + minutes_saved=None, + stopped=False, + files=[], + status_updates=None + ), + ThreadMessagePublic( + id=26, + thread_id=TEST_CASE_4_THREAD_ID, + text="6", + origin=ThreadMessageOrigin.AGENT, + timestamp=parse_date("2025-02-21T12:15:00"), + minutes_saved=None, + stopped=False, + files=[], + status_updates=None ) ]) @@ -426,7 +516,7 @@ async def test_find_test_case_messages_from_another_agent(client: AsyncClient): @freeze_time(CURRENT_TIME) -async def test_add_test_case_message(client: AsyncClient): +async def test_add_test_case_message(client: AsyncClient, last_message_id: int): message_data = NewTestCaseMessage(text="New test message", origin=ThreadMessageOrigin.USER) expected_messages = [ ThreadMessagePublic( @@ -437,7 +527,8 @@ async def test_add_test_case_message(client: AsyncClient): timestamp=parse_date("2025-02-21T12:10:00"), minutes_saved=None, stopped=False, - files=[] + files=[], + status_updates=None ), ThreadMessagePublic( id=12, @@ -447,17 +538,19 @@ async def test_add_test_case_message(client: AsyncClient): timestamp=parse_date("2025-02-21T12:11:00"), minutes_saved=None, stopped=False, - files=[] + files=[], + status_updates=None ), ThreadMessagePublic( - id=23, + id=last_message_id + 1, thread_id=TEST_CASE_1_THREAD_ID, text=message_data.text, origin=message_data.origin, timestamp=CURRENT_TIME, minutes_saved=None, stopped=False, - files=[] + files=[], + status_updates=None ) ] resp = await _add_test_case_message(client, AGENT_ID, TEST_CASE_1_THREAD_ID, message_data) @@ -491,7 +584,8 @@ async def test_update_test_case_message(client: AsyncClient): timestamp=parse_date("2025-02-21T12:10:00"), minutes_saved=None, stopped=False, - files=[] + files=[], + status_updates=None )) @@ -534,6 +628,38 @@ async def test_update_message_from_different_test_case_same_agent(client: AsyncC assert resp.status_code == status.HTTP_404_NOT_FOUND +async def test_generate_test_case_name_after_messages(client: AsyncClient): + create_resp = await _add_test_case(AGENT_ID, client) + create_resp.raise_for_status() + new_test_case = create_resp.json() + new_test_case_id = new_test_case["thread"]["id"] + + user_message_resp = await _add_test_case_message( + client, + AGENT_ID, + new_test_case_id, + NewTestCaseMessage(text="Reset the password for user Alice.", origin=ThreadMessageOrigin.USER), + ) + user_message_resp.raise_for_status() + + test_case_resp = await _find_test_case(client, AGENT_ID, new_test_case_id) + assert test_case_resp.json()["thread"]["name"].startswith("Test Case #") + + agent_message_resp = await _add_test_case_message( + client, + AGENT_ID, + new_test_case_id, + NewTestCaseMessage( + text="The password was reset successfully and an email confirmation was sent.", + origin=ThreadMessageOrigin.AGENT, + ), + ) + agent_message_resp.raise_for_status() + + updated_test_case_resp = await _find_test_case(client, AGENT_ID, new_test_case_id) + assert not updated_test_case_resp.json()["thread"]["name"].startswith("Test Case #") + + async def test_find_test_case_runs(client: AsyncClient): resp = await _find_test_case_runs(client, AGENT_ID) assert_response(resp, [ @@ -562,15 +688,28 @@ async def test_find_test_case_runs_unauthorized_agent(client: AsyncClient): @freeze_time(CURRENT_TIME) -async def test_run_test_suite_with_specific_test_cases(client: AsyncClient, last_thread_id: int, last_message_id: int): +async def test_run_test_suite_with_specific_test_cases(client: AsyncClient, last_thread_id: int, last_message_id: int, last_suite_run_id: int, last_result_id: int): expected_input = "Which is the first natural number? Only provide the number" expected_response_chunks = ["1"] - expected_suite_run_id = 3 - expected_result_id = 4 - + expected_suite_run_id = last_suite_run_id + 1 + expected_result_id = last_result_id + 1 request_body = {"test_case_ids": [TEST_CASE_1_THREAD_ID]} - - async with _run_test_suite(client, AGENT_ID, request_body) as resp: + + resp = await _run_test_suite(client, AGENT_ID, request_body) + assert_response(resp, TestSuiteRun( + id=expected_suite_run_id, + agent_id=AGENT_ID, + status=TestSuiteRunStatus.RUNNING, + executed_at=CURRENT_TIME, + completed_at=None, + total_tests=3, + passed_tests=0, + failed_tests=0, + error_tests=0, + skipped_tests=0 + )) + + async with _stream_test_suite_execution(client, AGENT_ID, expected_suite_run_id) as resp: resp.raise_for_status() await _assert_test_case_stream( resp, @@ -579,48 +718,86 @@ async def test_run_test_suite_with_specific_test_cases(client: AsyncClient, last TestCaseExpectation( test_case_id=TEST_CASE_1_THREAD_ID, result_id=expected_result_id, - input=expected_input, - response_chunks=expected_response_chunks, status=TestCaseResultStatus.SUCCESS, - user_message_id=last_message_id + 1, - agent_message_id=last_message_id + 2 + steps=[ + TestCaseStep( + input=expected_input, + response_chunks=expected_response_chunks, + user_message_id=last_message_id + 1, + agent_message_id=last_message_id + 2 + ) + ] ) ], - skipped_test_case_ids=[TEST_CASE_2_THREAD_ID] + skipped_test_case_ids=[TEST_CASE_2_THREAD_ID, TEST_CASE_4_THREAD_ID] ) - + resp = await _find_suite_run_results(client, AGENT_ID, expected_suite_run_id) + analysis = _get_analysis_from_response(resp) assert_response(resp, [ TestCaseResult( - id=5, + id=expected_result_id, + thread_id=last_thread_id + 1, + test_case_id=TEST_CASE_1_THREAD_ID, + test_suite_run_id=expected_suite_run_id, + status=TestCaseResultStatus.SUCCESS, + evaluator_analysis=analysis[0], + executed_at=CURRENT_TIME, + test_case_name="Test Case #1" + ), + TestCaseResult( + id=expected_result_id + 1, thread_id=None, test_case_id=TEST_CASE_2_THREAD_ID, test_suite_run_id=expected_suite_run_id, status=TestCaseResultStatus.SKIPPED, - executed_at=CURRENT_TIME + evaluator_analysis=analysis[1], + executed_at=CURRENT_TIME, + test_case_name="Test Case #2" ), TestCaseResult( - id=expected_result_id, - thread_id=last_thread_id + 1, - test_case_id=TEST_CASE_1_THREAD_ID, + id=expected_result_id + 2, + thread_id=None, + test_case_id=TEST_CASE_4_THREAD_ID, test_suite_run_id=expected_suite_run_id, - status=TestCaseResultStatus.SUCCESS, - executed_at=CURRENT_TIME + status=TestCaseResultStatus.SKIPPED, + evaluator_analysis=analysis[2], + executed_at=CURRENT_TIME, + test_case_name="Test Case #4" ) ]) -def _run_test_suite(client: AsyncClient, agent_id: int, request_body: dict): - return client.stream("POST", f"{TEST_CASES_PATH}/runs".format(agent_id=agent_id), json=request_body) +async def _run_test_suite(client: AsyncClient, agent_id: int, request_body: dict) -> Response: + return await client.post(f"{TEST_CASES_PATH}/runs".format(agent_id=agent_id), json=request_body) + + +@asynccontextmanager +async def _stream_test_suite_execution(client: AsyncClient, agent_id: int, suite_run_id: int): + async with client.stream("GET", f"{TEST_CASES_PATH}/runs/{suite_run_id}/stream".format(agent_id=agent_id)) as resp: + yield resp @freeze_time(CURRENT_TIME) -async def test_run_test_suite_with_all_test_cases(client: AsyncClient, last_thread_id: int, last_message_id: int): - expected_suite_run_id = 3 - result_id_1 = 4 - result_id_2 = 5 - - async with _run_test_suite(client, AGENT_ID, {}) as resp: +async def test_run_test_suite_with_all_test_cases(client: AsyncClient, last_thread_id: int, last_message_id: int, last_suite_run_id: int, last_result_id: int): + expected_suite_run_id = last_suite_run_id + 1 + expected_result_id = last_result_id + 1 + + resp = await _run_test_suite(client, AGENT_ID, {}) + assert_response(resp, TestSuiteRun( + id=expected_suite_run_id, + agent_id=AGENT_ID, + status=TestSuiteRunStatus.RUNNING, + executed_at=CURRENT_TIME, + completed_at=None, + total_tests=3, + passed_tests=0, + failed_tests=0, + error_tests=0, + skipped_tests=0 + )) + + async with _stream_test_suite_execution(client, AGENT_ID, expected_suite_run_id) as resp: resp.raise_for_status() await _assert_test_case_stream( resp, @@ -628,62 +805,202 @@ async def test_run_test_suite_with_all_test_cases(client: AsyncClient, last_thre [ TestCaseExpectation( test_case_id=TEST_CASE_1_THREAD_ID, - result_id=result_id_1, - input="Which is the first natural number? Only provide the number", - response_chunks=["1"], + result_id=expected_result_id, status=TestCaseResultStatus.SUCCESS, - user_message_id=last_message_id + 1, - agent_message_id=last_message_id + 2, - execution_statuses=[ - AgentActionEvent(action=AgentAction.PRE_MODEL_HOOK) + steps=[ + TestCaseStep( + input="Which is the first natural number? Only provide the number", + response_chunks=["1"], + user_message_id=last_message_id + 1, + agent_message_id=last_message_id + 2, + execution_statuses=[ + AgentActionEvent(action=AgentAction.PRE_MODEL_HOOK) + ] + ) ] ), TestCaseExpectation( test_case_id=TEST_CASE_2_THREAD_ID, - result_id=result_id_2, - input="Which is the capital of Uruguay? Output just the name", - response_chunks=["Monte", "video"], + result_id=expected_result_id + 1, + status=TestCaseResultStatus.SUCCESS, + steps=[ + TestCaseStep( + input="Which is the capital of Uruguay? Output just the name", + response_chunks=["Monte", "video"], + user_message_id=last_message_id + 3, + agent_message_id=last_message_id + 4, + execution_statuses=[ + AgentActionEvent(action=AgentAction.PRE_MODEL_HOOK) + ] + ) + ] + ), + TestCaseExpectation( + test_case_id=TEST_CASE_4_THREAD_ID, + result_id=expected_result_id + 2, status=TestCaseResultStatus.SUCCESS, - user_message_id=last_message_id + 3, - agent_message_id=last_message_id + 4, - execution_statuses=[ - AgentActionEvent(action=AgentAction.PRE_MODEL_HOOK) + steps=[ + TestCaseStep( + input="What is 2 + 2? Only provide the number", + response_chunks=["4"], + user_message_id=last_message_id + 5, + agent_message_id=last_message_id + 6, + execution_statuses=[ + AgentActionEvent(action=AgentAction.PRE_MODEL_HOOK) + ] + ), + TestCaseStep( + input="What is 3 + 3? Only provide the number", + response_chunks=["6"], + user_message_id=last_message_id + 7, + agent_message_id=last_message_id + 8, + execution_statuses=[ + AgentActionEvent(action=AgentAction.PRE_MODEL_HOOK) + ] + ) ] ) ] ) - + resp = await _find_suite_run_results(client, AGENT_ID, expected_suite_run_id) + analysis = _get_analysis_from_response(resp) assert_response(resp, [ TestCaseResult( - id=result_id_2, + id=expected_result_id, + thread_id=last_thread_id + 1, + test_case_id=TEST_CASE_1_THREAD_ID, + test_suite_run_id=expected_suite_run_id, + status=TestCaseResultStatus.SUCCESS, + evaluator_analysis=analysis[0], + executed_at=CURRENT_TIME, + test_case_name="Test Case #1" + ), + TestCaseResult( + id=expected_result_id + 1, thread_id=last_thread_id + 2, test_case_id=TEST_CASE_2_THREAD_ID, test_suite_run_id=expected_suite_run_id, status=TestCaseResultStatus.SUCCESS, - executed_at=CURRENT_TIME + evaluator_analysis=analysis[1], + executed_at=CURRENT_TIME, + test_case_name="Test Case #2" ), TestCaseResult( - id=result_id_1, - thread_id=last_thread_id + 1, + id=expected_result_id + 2, + thread_id=last_thread_id + 3, + test_case_id=TEST_CASE_4_THREAD_ID, + test_suite_run_id=expected_suite_run_id, + status=TestCaseResultStatus.SUCCESS, + evaluator_analysis=analysis[2], + executed_at=CURRENT_TIME, + test_case_name="Test Case #4" + ) + ]) + + +@freeze_time(CURRENT_TIME) +async def test_run_test_suite_with_multi_step_test_case(client: AsyncClient, last_thread_id: int, last_message_id: int, last_suite_run_id: int, last_result_id: int): + expected_suite_run_id = last_suite_run_id + 1 + expected_result_id = last_result_id + 1 + + request_body = {"test_case_ids": [TEST_CASE_4_THREAD_ID]} + + resp = await _run_test_suite(client, AGENT_ID, request_body) + assert_response(resp, TestSuiteRun( + id=expected_suite_run_id, + agent_id=AGENT_ID, + status=TestSuiteRunStatus.RUNNING, + executed_at=CURRENT_TIME, + completed_at=None, + total_tests=3, + passed_tests=0, + failed_tests=0, + error_tests=0, + skipped_tests=0 + )) + + async with _stream_test_suite_execution(client, AGENT_ID, expected_suite_run_id) as resp: + resp.raise_for_status() + await _assert_test_case_stream( + resp, + expected_suite_run_id, + [ + TestCaseExpectation( + test_case_id=TEST_CASE_4_THREAD_ID, + result_id=expected_result_id + 2, + status=TestCaseResultStatus.SUCCESS, + steps=[ + TestCaseStep( + input="What is 2 + 2? Only provide the number", + response_chunks=["4"], + user_message_id=last_message_id + 1, + agent_message_id=last_message_id + 2, + execution_statuses=[ + AgentActionEvent(action=AgentAction.PRE_MODEL_HOOK) + ] + ), + TestCaseStep( + input="What is 3 + 3? Only provide the number", + response_chunks=["6"], + user_message_id=last_message_id + 3, + agent_message_id=last_message_id + 4, + execution_statuses=[ + AgentActionEvent(action=AgentAction.PRE_MODEL_HOOK) + ] + ) + ] + ) + ], + skipped_test_case_ids=[TEST_CASE_1_THREAD_ID, TEST_CASE_2_THREAD_ID] + ) + + resp = await _find_suite_run_results(client, AGENT_ID, expected_suite_run_id) + analysis = _get_analysis_from_response(resp) + assert_response(resp, [ + TestCaseResult( + id=expected_result_id, + thread_id=None, test_case_id=TEST_CASE_1_THREAD_ID, test_suite_run_id=expected_suite_run_id, + status=TestCaseResultStatus.SKIPPED, + evaluator_analysis=analysis[0], + executed_at=CURRENT_TIME, + test_case_name="Test Case #1" + ), + TestCaseResult( + id=expected_result_id + 1, + thread_id=None, + test_case_id=TEST_CASE_2_THREAD_ID, + test_suite_run_id=expected_suite_run_id, + status=TestCaseResultStatus.SKIPPED, + evaluator_analysis=analysis[1], + executed_at=CURRENT_TIME, + test_case_name="Test Case #2" + ), + TestCaseResult( + id=expected_result_id + 2, + thread_id=last_thread_id + 1, + test_case_id=TEST_CASE_4_THREAD_ID, + test_suite_run_id=expected_suite_run_id, status=TestCaseResultStatus.SUCCESS, - executed_at=CURRENT_TIME + evaluator_analysis=analysis[2], + executed_at=CURRENT_TIME, + test_case_name="Test Case #4" ) ]) async def test_run_test_suite_with_invalid_test_case_ids(client: AsyncClient): - resp = await _run_test_suite_sync(client, AGENT_ID, {"test_case_ids": [999, 1000]}) + resp = await _run_test_suite(client, AGENT_ID, {"test_case_ids": [999, 1000]}) assert resp.status_code == status.HTTP_400_BAD_REQUEST -async def _run_test_suite_sync(client: AsyncClient, agent_id: int, request_body: dict) -> Response: - return await client.post(f"{TEST_CASES_PATH}/runs".format(agent_id=agent_id), json=request_body) - - async def test_run_test_suite_unauthorized_agent(client: AsyncClient): request_body = {"test_case_ids": [TEST_CASE_1_THREAD_ID]} - resp = await _run_test_suite_sync(client, NON_VISIBLE_AGENT_ID, request_body) + resp = await _run_test_suite(client, NON_VISIBLE_AGENT_ID, request_body) assert resp.status_code == status.HTTP_404_NOT_FOUND + + +def _get_analysis_from_response(response: Response) -> list[str]: + return [obj.get("evaluatorAnalysis", "") for obj in response.json()] diff --git a/src/backend/tests/test_threads.py b/src/backend/tests/test_threads.py index 8525273..76ad95e 100644 --- a/src/backend/tests/test_threads.py +++ b/src/backend/tests/test_threads.py @@ -1,18 +1,17 @@ from datetime import timezone +import re import threading import time from typing import Any, Callable -import re -from sse_starlette import ServerSentEvent from sqlalchemy import select +from sse_starlette import ServerSentEvent from .common import * -from tero.files.domain import FileMetadata, FileProcessor +from tero.files.domain import FileMetadata, FileProcessor, FileMetadataWithContent from tero.threads.api import THREADS_PATH, THREAD_PATH, THREAD_MESSAGES_PATH, THREAD_MESSAGE_PATH, THREAD_FILE_PATH from tero.threads.domain import ThreadListItem, ThreadMessageOrigin, ThreadMessagePublic -from tero.files.domain import FileMetadataWithContent from tero.tools.core import AgentActionEvent, AgentAction from tero.usage.domain import Usage, UsageType @@ -299,11 +298,12 @@ def _build_thread_messages_response(thread_id: int, message_id: int, parent_mess origin=ThreadMessageOrigin.USER, timestamp=timestamp, parent_id=parent_message_id, + status_updates=None, children=[ThreadMessagePublic(id=message_id + 1, text=response_text, thread_id=thread_id, origin=ThreadMessageOrigin.AGENT, has_positive_feedback=has_positive_feedback, timestamp=timestamp, - parent_id=message_id, minutes_saved=minutes_saved, feedback_text=feedback_text) + parent_id=message_id, minutes_saved=minutes_saved, feedback_text=feedback_text, status_updates=[AgentActionEvent(action=AgentAction.PRE_MODEL_HOOK)]) ]) ] diff --git a/src/backend/tests/test_tools.py b/src/backend/tests/test_tools.py index eaf7118..63cada3 100644 --- a/src/backend/tests/test_tools.py +++ b/src/backend/tests/test_tools.py @@ -10,11 +10,11 @@ from .common import * from tero.agents.api import AGENT_TOOL_FILE_PATH +from tero.tools.browser import BrowserTool, BROWSER_TOOL_ID from tero.tools.docs import DocsTool -from tero.tools.mcp import McpTool from tero.tools.jira import JiraTool +from tero.tools.mcp import McpTool from tero.tools.web import WebTool, WEB_TOOL_ID -from tero.tools.browser import BrowserTool, BROWSER_TOOL_ID from tero.usage.domain import Usage, UsageType diff --git a/src/backend/tests/test_transcript.py b/src/backend/tests/test_transcript.py index aaf16f2..7fb33e4 100644 --- a/src/backend/tests/test_transcript.py +++ b/src/backend/tests/test_transcript.py @@ -3,10 +3,12 @@ from tero.threads.api import THREADS_PATH, AUDIO_FORMAT from tero.threads.domain import ThreadTranscriptionResult + THREAD_ID = 21 TRANSCRIPT_PATH = f"{THREADS_PATH}/{THREAD_ID}/transcriptions" EXPECTED_TRANSCRIPTION = "¿podrías conseguirme los reportes que están en la página 5 de mi pdf, por favor?" + async def test_transcription_endpoint(client: AsyncClient): content = await find_asset_bytes("audio.webm") files = { @@ -18,6 +20,7 @@ async def test_transcription_endpoint(client: AsyncClient): result = ThreadTranscriptionResult(**resp.json()) assert result.transcription.lower().strip() == EXPECTED_TRANSCRIPTION.lower().strip() + async def test_transcription_invalid_file(client: AsyncClient): files = { "file": ("test.txt", b"This is not an audio file", "text/plain") diff --git a/src/backend/tests/test_usage.py b/src/backend/tests/test_usage.py index 0458076..e556169 100644 --- a/src/backend/tests/test_usage.py +++ b/src/backend/tests/test_usage.py @@ -1,14 +1,14 @@ from datetime import timedelta -from httpx import AsyncClient from typing import Callable +from httpx import AsyncClient + from .common import * -_avoid_import_reorder = True -from tero.usage.api import IMPACT_PATH, USAGE_PATH -from tero.usage.domain import AgentImpactItem, UserImpactItem, ImpactSummary, UsageSummary, AgentUsageItem, UserUsageItem, PRIVATE_AGENT_ID from tero.external_agents.domain import PublicExternalAgent from tero.teams.domain import Role, Team, MY_TEAM_ID +from tero.usage.api import IMPACT_PATH, USAGE_PATH +from tero.usage.domain import AgentImpactItem, UserImpactItem, ImpactSummary, UsageSummary, AgentUsageItem, UserUsageItem, PRIVATE_AGENT_ID async def test_user_budget(client: AsyncClient): diff --git a/src/backend/tests/test_users.py b/src/backend/tests/test_users.py index 27db739..426e530 100644 --- a/src/backend/tests/test_users.py +++ b/src/backend/tests/test_users.py @@ -2,9 +2,9 @@ from .common import * +from tero.teams.domain import PublicTeamRole, Role, TeamRoleStatus from tero.users.api import CURRENT_USER_PATH, USERS_PATH from tero.users.domain import UserProfile, UserListItem -from tero.teams.domain import PublicTeamRole, Role, TeamRoleStatus async def test_get_user_profile(client: AsyncClient, teams:list[Team]): @@ -22,4 +22,4 @@ async def test_get_users(client: AsyncClient, users: List[UserListItem]): async def test_get_users_unauthorized(client: AsyncClient, override_user: Callable[[int], None]): override_user(5) resp = await client.get(USERS_PATH) - assert resp.status_code == 401 \ No newline at end of file + assert resp.status_code == 401 diff --git a/src/browser-extension/assets/copilot-title.svg b/src/browser-extension/assets/copilot-title.svg deleted file mode 100644 index e496ed3..0000000 --- a/src/browser-extension/assets/copilot-title.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/src/browser-extension/components/CopilotChat.vue b/src/browser-extension/components/CopilotChat.vue index 6d0ef1f..bfee56a 100644 --- a/src/browser-extension/components/CopilotChat.vue +++ b/src/browser-extension/components/CopilotChat.vue @@ -111,4 +111,4 @@ const adjustMessagesScroll = async () => { "newChatTooltip": "Nuevo Chat" } } - \ No newline at end of file + diff --git a/src/browser-extension/components/CopilotItem.vue b/src/browser-extension/components/CopilotItem.vue new file mode 100644 index 0000000..73db617 --- /dev/null +++ b/src/browser-extension/components/CopilotItem.vue @@ -0,0 +1,19 @@ + + + diff --git a/src/browser-extension/components/CopilotList.vue b/src/browser-extension/components/CopilotList.vue index 0843bd9..abde7d9 100644 --- a/src/browser-extension/components/CopilotList.vue +++ b/src/browser-extension/components/CopilotList.vue @@ -1,11 +1,18 @@ { "en": { - "addTitle": "Add copilot", + "addTitle": "Add", + "refreshHubError": "Error refreshing the Tero instance", + "removeHubError": "Error removing the Tero instance", "removeTitle": "Remove copilot", "removeButton": "Remove", "removeConfirmation": "Are you sure you want to remove the copilot {agentName}?", - "refreshTitle": "Refresh agents", - "refreshError": "Could not refresh agents. Try again and if the problem persists contact support." + "refreshAgentError": "Error refreshing agent", + "standalone": "Standalone", + "noAgents": "No agents available\nClick Add to add a new agent", + "removeHubTitle": "Remove Tero instance", + "removeHubConfirmation": "Are you sure you want to remove the Tero instance {hubName} and all its agents?" }, "es" : { - "addTitle": "Agregar copiloto", + "addTitle": "Agregar", + "refreshHubError": "Error al actualizar la instancia de Tero", + "removeHubError": "Error al eliminar la instancia de Tero", "removeTitle": "Quitar copiloto", "removeButton": "Quitar", - "removeConfirmation": "¿Estás seguro de borrar el copiloto {agentName}?", - "refreshTitle": "Actualizar copilotos", - "refreshError": "No se pudieron actualizar los copilotos. Intenta de nuevo y si el problema persiste contacta a soporte." + "removeConfirmation": "¿Estás seguro de quitar el copiloto {agentName}?", + "refreshAgentError": "Error al actualizar agente", + "standalone": "Independientes", + "noAgents": "No hay agentes disponibles\nHaz clic en Agregar para agregar un nuevo agente", + "removeHubTitle": "Quitar instancia de Tero", + "removeHubConfirmation": "¿Estás seguro de quitar la instancia de Tero {hubName} y todos sus agentes?" } } diff --git a/src/browser-extension/components/HubActionsMenu.vue b/src/browser-extension/components/HubActionsMenu.vue new file mode 100644 index 0000000..3e6a52f --- /dev/null +++ b/src/browser-extension/components/HubActionsMenu.vue @@ -0,0 +1,80 @@ + + + + + +{ + "en": { + "refreshHub": "Refresh", + "removeHub": "Remove", + "refreshAgent": "Refresh", + "removeAgent": "Remove" + }, + "es": { + "refreshHub": "Refrescar", + "removeHub": "Eliminar", + "refreshAgent": "Refrescar", + "removeAgent": "Eliminar" + } +} + diff --git a/src/browser-extension/components/HubSection.vue b/src/browser-extension/components/HubSection.vue new file mode 100644 index 0000000..4a9f627 --- /dev/null +++ b/src/browser-extension/components/HubSection.vue @@ -0,0 +1,66 @@ + + + + + diff --git a/src/browser-extension/components/ModalForm.vue b/src/browser-extension/components/ModalForm.vue index 29c1bc1..c239adb 100644 --- a/src/browser-extension/components/ModalForm.vue +++ b/src/browser-extension/components/ModalForm.vue @@ -24,7 +24,9 @@ const onSave = () => { - + + {{ buttonText }} + diff --git a/src/browser-extension/components/PageOverlay.vue b/src/browser-extension/components/PageOverlay.vue index fb87fe4..692d954 100644 --- a/src/browser-extension/components/PageOverlay.vue +++ b/src/browser-extension/components/PageOverlay.vue @@ -1,6 +1,7 @@ diff --git a/src/browser-extension/entrypoints/background.ts b/src/browser-extension/entrypoints/background.ts index b80cffd..cc51b27 100644 --- a/src/browser-extension/entrypoints/background.ts +++ b/src/browser-extension/entrypoints/background.ts @@ -1,13 +1,13 @@ import { browser } from 'wxt/browser'; import { type Browser } from 'wxt/browser'; -import { Agent, RequestEvent, RequestEventType} from "~/utils/agent"; -import { findAllAgents, findAgentById, removeAllAgents, } from "~/utils/agent-repository"; +import { Agent, RequestEvent, RequestEventType } from "~/utils/agent"; +import { findAllAgents, findAgentById, removeAllAgents } from "~/utils/agent-repository"; import { AgentSession } from "~/utils/agent-session"; -import { findAgentSession, saveAgentSession, removeAgentSession, } from "~/utils/agent-session-repository"; +import { findAgentSession, saveAgentSession, removeAgentSession } from "~/utils/agent-session-repository"; import { AgentSource } from "~/utils/agent"; -import { BrowserMessage, ToggleSidebar, ActiveTabListener, ActivateAgent, AgentActivation, InteractionSummary, } from "~/utils/browser-message"; +import { BrowserMessage, ToggleSidebar, ActiveTabListener, ActivateAgent, AgentActivation, InteractionSummary } from "~/utils/browser-message"; import { HttpServiceError } from "~/utils/http"; -import { isActiveTabListener, setTabListenerActive, removeTabListenerStatus, } from "~/utils/tab-listener-status-repository"; +import { isActiveTabListener, setTabListenerActive, removeTabListenerStatus } from "~/utils/tab-listener-status-repository"; import { removeTabState } from "~/utils/tab-state-repository"; export default defineBackground(() => { @@ -17,7 +17,6 @@ export default defineBackground(() => { browser.runtime.onInstalled.addListener(async () => { await removeAllAgents() createToggleContextMenu(); - console.log("onInstalled", import.meta.env.DEV) if (import.meta.env.DEV) { try { await AgentSource.loadAgentsFromUrl("http://localhost:8000"); @@ -98,14 +97,16 @@ export default defineBackground(() => { const activateAgent = async (tabId: number, agent: Agent, url: string) => { const session = new AgentSession(tabId, agent, url); let success = true; + let errorStatus = undefined; try { await session.activate((msg) => sendToTab(tabId, msg)); await saveAgentSession(session); } catch (e) { // exceptions from http methods are already logged so no need to handle them success = false; + errorStatus = (e as any)?.status; } - sendToTab(tabId, new AgentActivation(agent, success)); + sendToTab(tabId, new AgentActivation(agent, success, errorStatus)); }; browser.webRequest.onBeforeRequest.addListener( diff --git a/src/browser-extension/entrypoints/iframe/App.vue b/src/browser-extension/entrypoints/iframe/App.vue index 57dbc53..0aa25e6 100644 --- a/src/browser-extension/entrypoints/iframe/App.vue +++ b/src/browser-extension/entrypoints/iframe/App.vue @@ -11,6 +11,7 @@ import { findTabState, saveTabState } from '~/utils/tab-state-repository' import { findAgentSession } from '~/utils/agent-session-repository' import { FlowStepError } from '~/utils/flow' import { HttpServiceError } from '~/utils/http' +import { AuthService } from '~/utils/auth' import ToastMessage from '~/components/ToastMessage.vue' import CopilotChat from '~/components/CopilotChat.vue' import CopilotList from '~/components/CopilotList.vue' @@ -180,11 +181,18 @@ const onActivateAgent = async (agentId: string) => { sendToServiceWorker(new ActivateAgent(agentId, tab.url!)) } -const onAgentActivation = (msg: AgentActivation) => { +const onAgentActivation = async (msg: AgentActivation) => { if (displayMode.value === TabDisplayMode.CLOSED) { onToggleSidebar() } if (!msg.success) { + if (msg.errorStatus === 401 && msg.agent.manifest.auth) { + const authService = new AuthService(msg.agent.manifest.auth) + await authService.ensureAuthenticated() + await onActivateAgent(msg.agent.manifest.id!) + return + } + const text = t('activationError', { agentName: msg.agent.manifest.name, contactEmail: msg.agent.manifest.contactEmail }) toast.error({ component: ToastMessage, props: { message: text } }, { icon: IconAlertCircleFilled }) } else { @@ -326,7 +334,7 @@ const sidebarClasses = computed(() => [ "flowStepMissingElement": "I could not find the element '{selector}'. This might be due to recent changes in the page which I am not aware of. Please try again and if the issue persists contact [support](mailto:{contactEmail}?subject=Navigation%20element).", }, "es": { - "activationError": "No se pudo activar el {agentName}. Puedes intentar de nuevo y si el problema persiste contactar al [soporte de {agentName}](mailto:{contactEmail}?subject=Activation%20issue)", + "activationError": "No se pudo activar {agentName}. Puedes intentar de nuevo y si el problema persiste contactar al [soporte de {agentName}](mailto:{contactEmail}?subject=Activation%20issue)", "interactionSummaryError": "No pude procesar informacion generada por la página actual. Esto puede impactar en la información y respuestas que te puedo dar. Si el problema persiste por favor contacta a [soporte](mailto:{contactEmail})?subject=Interaction%20issue", "agentAnswerError": "Ahora no puedo completar tu pedido. Puedes intentar de nuevo y si el problema persiste contactar a [soporte](mailto:{contactEmail}?subject=Question%20issue)", "flowStepMissingElement": "No pude encontrar el elemento '{selector}'. Esto puede ser debido a cambios recientes en la página de los cuales no tengo conocimiento. Por favor intenta de nuevo y si el problema persiste contacta a [soporte](mailto:{contactEmail}?subject=Navigation%20element).", diff --git a/src/browser-extension/entrypoints/iframe/main.ts b/src/browser-extension/entrypoints/iframe/main.ts index 392e332..d30ccc5 100644 --- a/src/browser-extension/entrypoints/iframe/main.ts +++ b/src/browser-extension/entrypoints/iframe/main.ts @@ -38,5 +38,5 @@ app.use(Toast, {}) app.directive('click-outside', clickOutside); app.mount("body") -let elem : HTMLBodyElement = document.getElementsByTagName("body")[0] -elem!.onbeforeunload = () => app.unmount() \ No newline at end of file +let elem: HTMLBodyElement = document.getElementsByTagName("body")[0] +elem!.onbeforeunload = () => app.unmount() diff --git a/src/browser-extension/public/icon/128.png b/src/browser-extension/public/icon/128.png index eaecf1c..fc74754 100644 --- a/src/browser-extension/public/icon/128.png +++ b/src/browser-extension/public/icon/128.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8b9852b87408228e2b8c6c6bae9e3e513e7ea73e01aa488bf4492d746d2e1a68 -size 14600 +oid sha256:6fc8d9b6ac03accb8de2cca9d65d7d593862f18fa6a5340062c969d4d3b82fb2 +size 11653 diff --git a/src/browser-extension/public/icon/16.png b/src/browser-extension/public/icon/16.png index 86acc9a..f363280 100644 --- a/src/browser-extension/public/icon/16.png +++ b/src/browser-extension/public/icon/16.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:05b89cd0887114422c889570842038ffc80dc663a763b2cd64e1b459e4be37e3 -size 1399 +oid sha256:d3d9bb3cd62be8ce6acbc5ca62ffdf7e6065415c9b21553430313c920d26b100 +size 1277 diff --git a/src/browser-extension/public/icon/32.png b/src/browser-extension/public/icon/32.png index 3f61abb..734bafe 100644 --- a/src/browser-extension/public/icon/32.png +++ b/src/browser-extension/public/icon/32.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f63eeaa256473d6e2ff135c133e65dc6db71d9292b29e81582fbe7e97d5d68ad -size 2857 +oid sha256:0d30bf01d870b9efb50bd4dfec465665e8f0af4f650e4b070aefabf5681df601 +size 2405 diff --git a/src/browser-extension/public/icon/48.png b/src/browser-extension/public/icon/48.png index 1afde9d..2347926 100644 --- a/src/browser-extension/public/icon/48.png +++ b/src/browser-extension/public/icon/48.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5292bc5d224108739d6de40b16d08a84aa5712020ef39b216638154aa7d7aeb7 -size 4585 +oid sha256:c2accc8700f8b7581ee362d2d2e6d73da7e758439e13ccbd8edad21c8a3918a4 +size 3841 diff --git a/src/browser-extension/public/icon/96.png b/src/browser-extension/public/icon/96.png index c956180..d777e0e 100644 --- a/src/browser-extension/public/icon/96.png +++ b/src/browser-extension/public/icon/96.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:da1a5fc947a62823211adca366c55eff6ed8d723415b55e87884f02617459130 -size 10825 +oid sha256:703250d00cfb391c128b2ba01be9f6d8333afdd56465e0880d022fd8aabb5ea3 +size 8592 diff --git a/src/browser-extension/utils/agent-repository.ts b/src/browser-extension/utils/agent-repository.ts index b84992b..ab9ad9f 100644 --- a/src/browser-extension/utils/agent-repository.ts +++ b/src/browser-extension/utils/agent-repository.ts @@ -39,3 +39,14 @@ export const removeAllAgents = async (): Promise => { await updateAgents([]) await browser.storage.local.set({ prompts: [] }) } + +export const findAgentsByHubUrl = async (hubUrl: string): Promise => { + const agents = await findAllAgents() + return agents.filter(a => a.url === hubUrl) +} + +export const removeAgentsByHubUrl = async (hubUrl: string): Promise => { + const agents = await findAllAgents() + const filteredAgents = agents.filter(a => a.url !== hubUrl) + await updateAgents(filteredAgents) +} diff --git a/src/browser-extension/utils/agent.ts b/src/browser-extension/utils/agent.ts index 9720d9e..d36c1d0 100644 --- a/src/browser-extension/utils/agent.ts +++ b/src/browser-extension/utils/agent.ts @@ -8,23 +8,21 @@ import { AgentPrompt } from "../../common/src/utils/domain" export abstract class AgentSource { abstract findAgents(authService?: AuthService): Promise; - + static async loadAgentsFromUrl(url: string): Promise { const agents: Agent[] = [] - const manifest = await Agent.findManifest(url) - console.log("Manifest: ", manifest) - + const manifest = await Agent.findManifest(url) + // comparing with agents-hub for backwards compatibility with environments that haven't fully migrated to tero if (manifest.auth && (manifest.auth.clientId === AgentType.TeroAgent || manifest.auth.clientId === "agents-hub")) { const authService = new AuthService(manifest.auth!) - await authService.login() + await authService.ensureAuthenticated() const tero = new TeroServer(url, manifest) agents.push(...await tero.findAgents(authService)) - console.log("loaded agents", agents) } else { agents.push(new StandaloneAgent(url, manifest)) } - + if (agents.length > 0) { for (const agent of agents) { if (await findAgentById(agent.manifest.id)) { @@ -34,7 +32,7 @@ export abstract class AgentSource { await addAgent(agent); } } - + return agents } } @@ -85,13 +83,13 @@ export abstract class Agent { protected async *processStreamResponse(stream: AsyncIterable): AsyncIterable { for await (const part of stream) { if (typeof part === "string") { - yield {message: part}; + yield { message: part }; } else if (part && part instanceof ServerSentEvent) { const event = part.event || 'message' if (event === 'message') { - yield {message: part.data}; + yield { message: part.data }; } else if (event === 'messageId') { - yield {messageId: parseInt(part.data)}; + yield { messageId: parseInt(part.data) }; } else { yield { [`${part.event}`]: JSON.parse(part.data) }; } @@ -121,7 +119,7 @@ export abstract class Agent { } public static fromJsonObject(obj: any): Agent { - return obj.type === AgentType.TeroAgent? TeroAgent.fromJsonObject(obj) : StandaloneAgent.fromJsonObject(obj); + return obj.type === AgentType.TeroAgent ? TeroAgent.fromJsonObject(obj) : StandaloneAgent.fromJsonObject(obj); } public toJSON(): any { @@ -437,7 +435,7 @@ export class TeroAgent extends Agent { } export class TeroServer implements AgentSource { - constructor(private readonly serverUrl: string, private readonly manifest: AgentManifest) {} + constructor(private readonly serverUrl: string, private readonly manifest: AgentManifest) { } async findAgents(authService?: AuthService): Promise { const ret = await fetchJson( @@ -445,7 +443,7 @@ export class TeroServer implements AgentSource { await Agent.buildHttpRequest("GET", undefined, authService) ); return Promise.all(ret.map((a: any) => TeroAgent.fromTero(a, this.manifest, this.serverUrl))); - } + } } export enum AgentType { @@ -454,16 +452,16 @@ export enum AgentType { } export interface AgentManifest { - id: string - name?: string - capabilities?: string[] - welcomeMessage?: string - prompts?: ManifestPrompt[] - onSessionClose?: EndAction - onHttpRequest?: AgentRule[] - pollInteractionPeriodSeconds?: number - auth?: AuthConfig - contactEmail: string + id: string + name?: string + capabilities?: string[] + welcomeMessage?: string + prompts?: ManifestPrompt[] + onSessionClose?: EndAction + onHttpRequest?: AgentRule[] + pollInteractionPeriodSeconds?: number + auth?: AuthConfig + contactEmail: string } export interface ManifestPrompt { @@ -473,60 +471,60 @@ export interface ManifestPrompt { } export interface AgentRule { - condition: AgentRuleCondition - actions: AgentRuleAction[] + condition: AgentRuleCondition + actions: AgentRuleAction[] } export interface AgentRuleCondition { - urlRegex: string - requestMethods?: string[] - resourceTypes?: string[] - event?: string + urlRegex: string + requestMethods?: string[] + resourceTypes?: string[] + event?: string } export interface AgentRuleAction { - activate?: ActivationAction - addHeader?: AddHeaderRuleAction - recordInteraction?: RecordInteractionRuleAction + activate?: ActivationAction + addHeader?: AddHeaderRuleAction + recordInteraction?: RecordInteractionRuleAction } export interface ActivationAction { - httpRequest?: HttpRequestAction + httpRequest?: HttpRequestAction } export interface EndAction { - httpRequest: HttpRequestAction + httpRequest: HttpRequestAction } export interface HttpRequestAction { - url: string - method?: string + url: string + method?: string } export interface AddHeaderRuleAction { - header: string - value: string + header: string + value: string } export interface RecordInteractionRuleAction { - httpRequest?: HttpRequestAction + httpRequest?: HttpRequestAction } export interface AgentSession { - id: string + id: string } export class RequestEvent { - event: RequestEventType; - details: Browser.webRequest.OnCompletedDetails | Browser.webRequest.OnBeforeRequestDetails; + event: RequestEventType; + details: Browser.webRequest.OnCompletedDetails | Browser.webRequest.OnBeforeRequestDetails; - constructor(event: RequestEventType, details: Browser.webRequest.OnCompletedDetails | Browser.webRequest.OnBeforeRequestDetails) { - this.event = event; - this.details = details; - } + constructor(event: RequestEventType, details: Browser.webRequest.OnCompletedDetails | Browser.webRequest.OnBeforeRequestDetails) { + this.event = event; + this.details = details; + } } export enum RequestEventType { - OnBeforeRequest = "onBeforeRequest", - OnCompleted = "onCompleted", + OnBeforeRequest = "onBeforeRequest", + OnCompleted = "onCompleted", } diff --git a/src/browser-extension/utils/auth.ts b/src/browser-extension/utils/auth.ts index 5582076..e283968 100644 --- a/src/browser-extension/utils/auth.ts +++ b/src/browser-extension/utils/auth.ts @@ -45,8 +45,13 @@ class PopupNavigator implements INavigator { class PopupHandler implements IWindow { async navigate(params: NavigateParams): Promise { - let url = await browser.identity.launchWebAuthFlow({ interactive: true, url: params.url }) - return { url } + try { + let url = await browser.identity.launchWebAuthFlow({ interactive: true, url: params.url }) + return { url } + } catch (e: any) { + console.error(`Error launching web auth flow for url ${params.url}`, e) + throw e + } } close(): void { @@ -57,7 +62,7 @@ class PopupHandler implements IWindow { export interface AuthConfig { url: string clientId: string - scope: string + scope: string } export class AuthService { @@ -128,4 +133,13 @@ export class AuthService { await this.userManager.signinPopup() } -} \ No newline at end of file + public async ensureAuthenticated(): Promise { + const user = await this.getUser() + if (!user) { + await this.login() + return (await this.getUser())! + } + return user + } + +} diff --git a/src/browser-extension/utils/browser-message.ts b/src/browser-extension/utils/browser-message.ts index f5d4224..6ec5b15 100644 --- a/src/browser-extension/utils/browser-message.ts +++ b/src/browser-extension/utils/browser-message.ts @@ -87,15 +87,17 @@ export class ActivateAgent extends BrowserMessage { export class AgentActivation extends BrowserMessage { agent: Agent success: boolean + errorStatus?: number - constructor(agent: Agent, success: boolean) { + constructor(agent: Agent, success: boolean, errorStatus?: number) { super("agentActivation") this.agent = agent this.success = success + this.errorStatus = errorStatus } public static fromJsonObject(obj: any): AgentActivation { - return new AgentActivation(Agent.fromJsonObject(obj.agent), obj.success) + return new AgentActivation(Agent.fromJsonObject(obj.agent), obj.success, obj.errorStatus) } } diff --git a/src/browser-extension/utils/http.ts b/src/browser-extension/utils/http.ts index e41f7e0..aa37e23 100644 --- a/src/browser-extension/utils/http.ts +++ b/src/browser-extension/utils/http.ts @@ -12,20 +12,22 @@ const fetchResponse = async (url: string, options?: RequestInit) => { if (ret.headers.get('Content-Type') === 'application/json') { let json = JSON.parse(body) if ('detail' in json) { - throw new HttpServiceError(json.detail) + throw new HttpServiceError(json.detail, ret.status) } } - throw new HttpServiceError() + throw new HttpServiceError(undefined, ret.status) } return ret } export class HttpServiceError extends Error { detail?: string + status?: number - constructor(detail?: string) { + constructor(detail?: string, status?: number) { super() this.detail = detail + this.status = status } } @@ -53,7 +55,7 @@ async function* fetchSSEStream(resp: Response, url: string, options?: RequestIni for (const event of events) { if (event.event === "error") { console.warn(`Problem while reading stream response from ${options?.method ? options.method : 'GET'} ${url}`, event) - throw new HttpServiceError() + throw new HttpServiceError(undefined, resp.status) } if (event.event || event.data) { yield event diff --git a/src/common/src/assets/styles.css b/src/common/src/assets/styles.css index 4c25cfd..fa5a706 100644 --- a/src/common/src/assets/styles.css +++ b/src/common/src/assets/styles.css @@ -32,6 +32,7 @@ --animate-wiggle: wiggle 1s ease-in-out infinite; --animate-glowing: glowing 1s ease-in-out infinite; --animate-magnify-search: magnify-search 1s ease-in-out infinite; + --animate-fade-in: fade-in 0.4s ease-out forwards; --shadow-light: 0px 2px 5px 0px rgba(0,0,0,0.07); --border: 1px solid #ccc; } @@ -89,6 +90,17 @@ html:root, } } +@keyframes fade-in { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + @keyframes border-pulse { 0% { box-shadow: 0 0 0 0 currentColor; diff --git a/src/common/src/components/chat/ChatInput.vue b/src/common/src/components/chat/ChatInput.vue index 40ba2d6..8d5aedc 100644 --- a/src/common/src/components/chat/ChatInput.vue +++ b/src/common/src/components/chat/ChatInput.vue @@ -473,7 +473,7 @@ defineExpose({
diff --git a/src/common/src/components/chat/ChatMessage.vue b/src/common/src/components/chat/ChatMessage.vue index 187c3a9..a70d684 100644 --- a/src/common/src/components/chat/ChatMessage.vue +++ b/src/common/src/components/chat/ChatMessage.vue @@ -42,7 +42,8 @@ export class ChatUiMessage { minutesSaved?: number, feedbackText?: string, hasPositiveFeedback?: boolean, - stopped?: boolean + stopped?: boolean, + statusUpdates?: StatusUpdate[] ) { this.text = text this.files = files @@ -57,6 +58,8 @@ export class ChatUiMessage { this.feedbackText = feedbackText this.hasPositiveFeedback = hasPositiveFeedback this.stopped = stopped + this.statusUpdates = statusUpdates || [] + this.isStatusComplete = (statusUpdates?.length || 0) > 0 } public addChild(child: ChatUiMessage): void { @@ -257,9 +260,9 @@ const handleNextMessage = () => {
diff --git a/src/common/src/components/chat/ChatStatus.vue b/src/common/src/components/chat/ChatStatus.vue index 27bfecd..8887beb 100644 --- a/src/common/src/components/chat/ChatStatus.vue +++ b/src/common/src/components/chat/ChatStatus.vue @@ -1,7 +1,7 @@ -{ + +{ "en": { + "statusProcessing": "Processing", "statusPreModel": "Thinking", "planning": "Planning to run tools", - "statusExecutingTool": "Executing {tool}", - "statusExecutedTool": "Tool {tool} execution finished", + "statusExecutingTool": "Executing {'<'}b>{tool}{'<'}/b>", + "statusExecutedTool": "Tool {'<'}b>{tool}{'<'}/b> execution finished", "documentsRetrieved": "{count} chunks retrieved", - "toolError": "Error in tool {tool}", + "toolError": "Error in tool {'<'}b>{tool}{'<'}/b>", "endMessage": "Thought for {time} seconds", "result": "Result:", "results": "Results {count}:", @@ -115,16 +124,18 @@ const formatStatusAction = (status: StatusUpdate): string => { "analyzed": "Chunks analyzed", "groundingResponse": "Validating response", "groundedResponse": "Response validated", - "withParams": " with params {params}" + "withParams": " with params {'<'}b>{params}{'<'}/b>", + "thoughtProcessMessage": "Thought process" }, "es": { + "statusProcessing": "Procesando", "statusPreModel": "Pensando", "planning": "Planificando ejecutar herramientas", - "statusExecutingTool": "Ejecutando {tool}", - "statusExecutedTool": "Ejecución de herramienta {tool} finalizada", + "statusExecutingTool": "Ejecutando {'<'}b>{tool}{'<'}/b>", + "statusExecutedTool": "Ejecución de herramienta {'<'}b>{tool}{'<'}/b> finalizada", "documentsRetrieved": "{count} secciones recuperados", - "toolError": "Error en la herramienta {tool}", - "endMessage": "Penso durante {time} segundos", + "toolError": "Error en la herramienta {'<'}b>{tool}{'<'}/b>", + "endMessage": "Pensó durante {time} segundos", "result": "Resultado:", "results": "Resultados {count}:", "retrieving": "Recuperando secciones", @@ -134,7 +145,8 @@ const formatStatusAction = (status: StatusUpdate): string => { "groundingResponse": "Validando respuesta", "groundedResponse": "Respuesta validada", "description": "Descripción", - "withParams": " con los siguientes parametros {params}" + "withParams": " con los siguientes parametros {'<'}b>{params}{'<'}/b>", + "thoughtProcessMessage": "Proceso de pensamiento" } -} - +} + diff --git a/src/common/src/components/common/FileInput.vue b/src/common/src/components/common/FileInput.vue index 4e65f1b..116f2aa 100644 --- a/src/common/src/components/common/FileInput.vue +++ b/src/common/src/components/common/FileInput.vue @@ -39,6 +39,14 @@ const formattedFileAccept = computed(() => { return props.allowedExtensions.map((ext) => `.${ext}`).join(',') }) +const allowMultiple = computed(() => { + return props.maxFiles === -1 || props.maxFiles > 1 +}) + +const isMaxFilesReached = computed(() => { + return props.maxFiles !== -1 && props.attachedFiles.length >= props.maxFiles +}) + const isFileExtensionSupported = (filename: string): boolean => { const extension = filename.split('.').pop()?.toLowerCase() || '' return props.allowedExtensions.includes(extension) @@ -154,22 +162,24 @@ defineExpose({ diff --git a/src/frontend/index.html b/src/frontend/index.html index 504d0ab..cdd6bf9 100644 --- a/src/frontend/index.html +++ b/src/frontend/index.html @@ -1,13 +1,16 @@ - - - - - Tero - - -
- - + + + + + + Tero + + + +
+ + + diff --git a/src/frontend/src/assets/styles.css b/src/frontend/src/assets/styles.css index 3d0efb4..1af08d6 100644 --- a/src/frontend/src/assets/styles.css +++ b/src/frontend/src/assets/styles.css @@ -95,6 +95,7 @@ .p-popover:before { @apply !border-b-auxiliar-gray; } + .p-popover { @apply !border-auxiliar-gray; } @@ -151,7 +152,7 @@ @apply border-error; } -.p-autocomplete .p-inputtext:focus + .p-autocomplete-dropdown { +.p-autocomplete .p-inputtext:focus+.p-autocomplete-dropdown { @apply !outline-abstracta !ring-abstracta !border-abstracta !border-l-0; } @@ -170,12 +171,12 @@ /* Override Prime Vue style */ /* Hide last row border */ -.p-datatable .p-datatable-tbody > tr:last-child > td { +.p-datatable .p-datatable-tbody>tr:last-child>td { @apply !border-b-0; } .p-datatable-header-cell, -.p-datatable .p-datatable-tbody > tr > td { +.p-datatable .p-datatable-tbody>tr>td { @apply !border-auxiliar-gray; } @@ -197,6 +198,7 @@ #sidebar { @apply !w-2xs; } + .p-tablist { @apply w-xl overflow-x-auto; } @@ -214,6 +216,7 @@ .grid-wrapper { @apply w-4xl; } + .column-gap { @apply grid-cols-3; } @@ -224,6 +227,7 @@ .grid-wrapper { @apply w-6xl; } + .column-gap { @apply grid-cols-4; } @@ -235,34 +239,46 @@ .formatted-text { @apply leading-snug; } + .formatted-text hr { @apply hidden; } -.formatted-text h1, .formatted-text h2 { + +.formatted-text h1, +.formatted-text h2 { @apply text-xl my-2.5; } -.formatted-text h3, .formatted-text h4 { + +.formatted-text h3, +.formatted-text h4 { @apply my-0.5 font-normal; } + .formatted-text ul, .message ol { @apply mb-2.5; } + .formatted-text p { @apply my-1.5; } + .formatted-text li { @apply ml-4 my-2; } + .formatted-text ul li ul li { @apply list-[circle]; } + .formatted-text strong { @apply font-medium; } + .formatted-text p br { @apply my-2.5; } + .copy-code-btn svg { @apply size-4 mr-1; -} \ No newline at end of file +} diff --git a/src/frontend/src/components/agent/AgentEditorPanel.vue b/src/frontend/src/components/agent/AgentEditorPanel.vue index ad36b64..6caf972 100644 --- a/src/frontend/src/components/agent/AgentEditorPanel.vue +++ b/src/frontend/src/components/agent/AgentEditorPanel.vue @@ -2,46 +2,38 @@ import { onMounted, ref, computed, watch } from 'vue' import { useRoute, onBeforeRouteUpdate, useRouter } from 'vue-router' import { useI18n } from 'vue-i18n' -import { Agent, ApiService, LlmModel, LlmTemperature, ReasoningEffort, LlmModelType, AgentToolConfig, AutomaticAgentField, Team, TestCase, TestCaseResult, TestCaseResultStatus, TestSuiteRun, TestSuiteRunStatus, LlmModelVendor } from '@/services/api' -import { IconPlayerPlay, IconPencil, IconTrash, IconDownload, IconUpload } from '@tabler/icons-vue' -import TestCaseStatus from './TestCaseStatus.vue' -import { AnimationEffect } from '../../../../common/src/utils/animations' +import { Agent, ApiService, LlmModel, AgentToolConfig, AutomaticAgentField, Team, TestCase, TestSuiteRun, GLOBAL_TEAM_ID, Role, TeamRoleStatus, findManifest } from '@/services/api' +import { IconPlayerPlay, IconPencil, IconDownload, IconUpload, IconListDetails } from '@tabler/icons-vue' import { useErrorHandler } from '@/composables/useErrorHandler' import { useAgentStore } from '@/composables/useAgentStore' import { useAgentPromptStore } from '@/composables/useAgentPromptStore' +import { useTestCaseStore } from '@/composables/useTestCaseStore' import { loadUserProfile } from '@/composables/useUserProfile' import { AgentPrompt, UploadedFile } from '../../../../common/src/utils/domain' -import moment from 'moment' -import openAiIcon from '@/assets/images/open-ai.svg' -import googleIcon from '@/assets/images/gemini.svg' -import anthropicIcon from '@/assets/images/anthropic.svg' - +import { AgentTestcaseChatUiMessage } from './AgentTestcaseChatMessage.vue' const props = defineProps<{ selectedThreadId: number - selectedTestCaseId?: number loadingTests?: boolean editingTestcase?: boolean - runningTests?: boolean testCases?: TestCase[] - testCaseResults?: TestCaseResult[] - testSuiteRun?: TestSuiteRun + isComparingResultWithTestSpec?: boolean + testSpecMessages?: AgentTestcaseChatUiMessage[] }>() const emit = defineEmits<{ - (e: 'selectTestCase', testCaseId: number | undefined): void (e: 'showTestCaseEditor', show: boolean): void (e: 'editingTestcase', editing: boolean): void - (e: 'deleteTestCase', testCaseThreadId: number): void (e: 'runTests'): void (e: 'runSingleTest', testCaseId: number): void - (e: 'newTestCase', testCase: TestCase): void (e: 'importAgent' ): void + (e: 'selectExecution', execution: TestSuiteRun): void }>() const { t } = useI18n() const { handleError } = useErrorHandler() const { setCurrentAgent } = useAgentStore() +const { testCasesStore } = useTestCaseStore() const api = new ApiService() const route = useRoute() @@ -54,16 +46,6 @@ const backendAgent = ref() const isSaving = ref(false) const models = ref([]) const toolConfigs = ref([]) -const temperatures = [ - { name: t('preciseTemperature'), value: LlmTemperature.PRECISE }, - { name: t('neutralTemperature'), value: LlmTemperature.NEUTRAL }, - { name: t('creativeTemperature'), value: LlmTemperature.CREATIVE } -] -const reasoningEfforts = [ - { name: t('lowEffort'), value: ReasoningEffort.LOW }, - { name: t('mediumEffort'), value: ReasoningEffort.MEDIUM }, - { name: t('highEffort'), value: ReasoningEffort.HIGH } -] const isGenerating = ref({ name: false, description: false, @@ -78,13 +60,11 @@ const publishPrompts = ref(false) const privatePromptsCount = computed(() => agentsPromptStore.prompts.filter(p => !p.shared).length) const isLoading = ref(true) const teams = ref([]) -const defaultTeams = ref([new Team(0, t('private')), new Team(1, t('global'))]) +const defaultTeams = ref([new Team(0, t('private'))]) const selectedTeam = ref(null) const activeTab = ref('0') -const activeTestCaseMenuId = ref(null) -const deletingTestCaseId = ref(null) -const renamingTestCaseId = ref(null) const showImportAgent = ref(false) +const showPastExecutions = ref(false) const loadAgentData = async (agentIdStr: string) => { const agentId = parseInt(agentIdStr) @@ -104,10 +84,6 @@ const loadAgentData = async (agentIdStr: string) => { } } -const findSelectedModel = (): LlmModel | undefined => { - return models.value.find((m) => m.id === agent.value?.modelId) -} - const loadPromptStarters = async (agentId: number) => { await loadAgentPrompts(agentId) starters.value = agentsPromptStore.prompts.filter(p => p.starter) || [] @@ -116,8 +92,15 @@ const loadPromptStarters = async (agentId: number) => { onMounted(async () => { try { models.value = await api.findModels() + const manifest = await findManifest() const user = await loadUserProfile() + + if (!manifest.disablePublishGlobal || user!.teams.some(t => t.role === Role.TEAM_OWNER && t.id === GLOBAL_TEAM_ID && t.status === TeamRoleStatus.ACCEPTED)) { + defaultTeams.value.push(new Team(GLOBAL_TEAM_ID, t('global'))) + } + teams.value = [...defaultTeams.value, ...user!.teams.filter(t => !defaultTeams.value.some(dt => dt.id === t.id))]; + if (route.params.agentId) { await loadAgentData(route.params.agentId as string) } @@ -270,76 +253,21 @@ const onUpdateToolConfigs = async () => { toolConfigs.value = await api.findAgentToolConfigs(agent.value!.id) } -const selectTestCase = (testCaseId: number) => { - emit('selectTestCase', testCaseId) -} - -const onEditTests = () => { - if (!props.testCases?.length && props.editingTestcase) { - return +const onTestCaseDeleted = () => { + if (testCasesStore.testCases.length === 0 && !props.editingTestcase) { + emit('editingTestcase', true) } - emit('editingTestcase', !props.editingTestcase) -} - -const toggleTestCaseMenu = (testCaseId: number) => { - activeTestCaseMenuId.value = activeTestCaseMenuId.value === testCaseId ? null : testCaseId -} - -const closeTestCaseMenu = () => { - activeTestCaseMenuId.value = null -} - -const onDeleteTestCase = (testCase: TestCase) => { - deletingTestCaseId.value = testCase.thread.id - closeTestCaseMenu() -} - -const onConfirmDeleteTestCase = async () => { - try { - await api.deleteTestCase(agent.value!.id, deletingTestCaseId.value!) - - emit('deleteTestCase', deletingTestCaseId.value!) - deletingTestCaseId.value = null - } catch (error) { - handleError(error) - } -} - -const onRenameTestCase = (testCase: TestCase) => { - renamingTestCaseId.value = testCase.thread.id - closeTestCaseMenu() -} - -const onSaveTestCaseName = async (newName: string) => { - try { - const testCase = props.testCases?.find(tc => tc.thread.id === renamingTestCaseId.value) - const updatedTestCase = await api.updateTestCase(agent.value!.id, renamingTestCaseId.value!, newName) - testCase!.thread.name = updatedTestCase.thread.name - renamingTestCaseId.value = null - } catch (error) { - handleError(error) - } -} - -const onCancelRenameTestCase = () => { - renamingTestCaseId.value = null -} - -const onCancelDeleteTestCase = () => { - deletingTestCaseId.value = null -} - -const findTestCaseResult = (testCaseId: number) => { - return props.testCaseResults?.find((result) => result.testCaseId === testCaseId) } watch(activeTab, (newVal) => { emit('showTestCaseEditor', newVal === '1') + emit('editingTestcase', newVal === '1') }) -const modelType = computed(() => findSelectedModel()?.modelType) const isSelectedPublicTeam = computed(() => selectedTeam.value != null && selectedTeam.value > 0) +const hasPublishableTeams = computed(() => teams.value.some(t => t.id > 0)) + const shareDialogTranslationKey = computed(() => { if (invalidAttrs.value) { return isSelectedPublicTeam.value ? 'shareInvalidAttrs' : 'unshareInvalidAttrs' @@ -360,19 +288,6 @@ const shareDialogTranslationParams = computed(() => ({ team: findTeam(selectedTeam.value!)?.name })) -const onNewTestCase = async () => { - try { - const testCase = await api.addTestCase(agent.value!.id) - emit('newTestCase', testCase) - } catch (e) { - handleError(e) - } -} - -const countResultWithStatus = (status: TestCaseResultStatus) => { - return props.testCaseResults?.filter((result) => result.status === status).length || 0 -} - const exportAgent = async () => { try { const response = await api.exportAgent(agent.value!.id) @@ -398,38 +313,21 @@ const onImportAgent = async (file: UploadedFile) => { } } -// Flattens grouped models: showVendor is true only for the first model of each vendor to render logos once per group. -const flatOptions = computed(() => { - const models = groupedModelsByVendor(); - return models.flatMap((vendorModels) => vendorModels.map((m, i) => ({ ...m, vendor: m.modelVendor, showVendor: i === 0 }))); -}); - -const groupedModelsByVendor = () => { - const map = new Map(); - - for (const model of models.value) { - if (!map.has(model.modelVendor)) { - map.set(model.modelVendor, []); - } - map.get(model.modelVendor)!.push(model); - } - - return Array.from(map.values()); +const onSelectExecution = (execution: TestSuiteRun) => { + emit('selectExecution', execution) + showPastExecutions.value = false } - -const vendorLogos: Record = { - [LlmModelVendor.OPENAI]: openAiIcon, - [LlmModelVendor.GOOGLE]: googleIcon, - [LlmModelVendor.ANTHROPIC]: anthropicIcon -} - -{ + +{ "en": { - "shareTooltip": "Share", - "unshareTooltip": "Unshare", - "editAgentTitle": "Edit {name}", "nameLabel": "Name", "descriptionLabel": "Description", - "modelLabel": "Model", "systemPromptLabel": "Instructions", "saving": "Saving...", "namePlaceholder": "Enter a name for the agent", "descriptionPlaceholder": "What does this agent do?", "systemPromptPlaceholder": "Write the instructions for this agent", - "public": "Public", "private": "Private", "shareConfirmationTitle": "Do you want to make this agent public?", "shareInvalidAttrs": "To share the agent you need to specify {invalidAttrs}.", @@ -734,49 +506,20 @@ const vendorLogos: Record = { "changeTeamConfirmationTitle": "Change the team of this agent from {oldTeam} to {newTeam}?", "changeTeamConfirmationMessage": "When you change the team of an agent only the users in the new team will be able to use it.", "changeTeam": "Change team", - "temperatureLabel": "Temperature", - "reasoningEffortLabel": "Reasoning", - "preciseTemperature": "Precise", - "neutralTemperature": "Neutral", - "creativeTemperature": "Creative", - "lowEffort": "Low", - "mediumEffort": "Medium", - "highEffort": "High", "editAgentTabTitle": "Edit", "testsTabTitle": "Tests", - "noTestCasesTitle": "You don't have test cases for this agent yet", - "noTestCasesDescription": "Create your first test case to validate that the agent meets the expected requirements.", - "editTestsButton": "Edit", - "runTestsButton": "Run all", - "runSingleTestMenuItem": "Run", - "editTestCaseTooltip": "Edit", - "renameTestCaseTooltip": "Rename", - "deleteTestCaseTooltip": "Delete", - "deleteTestCaseConfirmation": "Delete {testCaseName}?", - "newTestCaseButton": "Add", - "lastExecution": "Last execution", - "noTestCasesResults": "No test cases results yet", - "running": "Running...", "exportAgent": "Export", "importAgent": "Import", - "success": "Success", - "failure": "Failed", - "error": "Error running", - "skipped": "Skipped" + "testSpec": "Test case specification" }, "es": { - "shareTooltip": "Compartir", - "unshareTooltip": "Dejar de compartir", - "editAgentTitle": "Editar {name}", "nameLabel": "Nombre", "descriptionLabel": "Descripción", - "modelLabel": "Modelo", "systemPromptLabel": "Instrucciones", "saving": "Guardando...", "namePlaceholder": "Ingresa el nombre del agente", "descriptionPlaceholder": "¿Qué hace este agente?", "systemPromptPlaceholder": "Escribe las instrucciones de este agente", - "public": "Público", "private": "Privado", "shareConfirmationTitle": "¿Quieres hacer este agente público?", "shareConfirmationMessageGlobal": "Cuando haces un agente público, este será visible en la página de inicio de Tero para que todos podamos beneficiarnos de lo que has creado.\n\nAdemás, todas las modificaciones futuras al agente estarán disponibles inmediatamente para el resto de sus usuarios.", @@ -801,37 +544,14 @@ const vendorLogos: Record = { "changeTeamConfirmationTitle": "¿Quieres cambiar el equipo de este agente de {oldTeam} a {newTeam}?", "changeTeamConfirmationMessage": "Cuando cambias el equipo de un agente, solo lo podrán usar los usuarios del nuevo equipo.", "changeTeam": "Cambiar equipo", - "temperatureLabel": "Temperatura", - "reasoningEffortLabel": "Razonamiento", - "preciseTemperature": "Preciso", - "neutralTemperature": "Neutro", - "creativeTemperature": "Creativo", - "lowEffort": "Bajo", - "mediumEffort": "Medio", - "highEffort": "Alto", "editAgentTabTitle": "Editar", "testsTabTitle": "Tests", - "noTestCasesTitle": "Aún no tienes test cases para este agente", - "noTestCasesDescription": "Crea tu primer test case para validar que el agente cumple los requisitos esperados.", - "editTestsButton": "Editar", - "runTestsButton": "Ejecutar todos", - "runSingleTestMenuItem": "Ejecutar", - "editTestCaseTooltip": "Editar", - "renameTestCaseTooltip": "Renombrar", - "deleteTestCaseTooltip": "Eliminar", - "deleteTestCaseConfirmation": "¿Eliminar {testCaseName}?", - "newTestCaseButton": "Agregar", - "lastExecution": "Última ejecución", - "noTestCasesResults": "No hay resultados de ejecución aún", - "running": "Ejecutando...", "exportAgent": "Exportar", "importAgent": "Importar", - "success": "Pasó", - "failure": "Falló", - "error": "Error al ejecutar", - "skipped": "Omitido" + "testSpec": "Especificación del test case" } -} +} + diff --git a/src/frontend/src/components/agent/AgentImportDialog.vue b/src/frontend/src/components/agent/AgentImportDialog.vue index d1df2b6..fce051c 100644 --- a/src/frontend/src/components/agent/AgentImportDialog.vue +++ b/src/frontend/src/components/agent/AgentImportDialog.vue @@ -48,7 +48,7 @@ watch(visible, (_) => {
-
+
diff --git a/src/frontend/src/components/agent/AgentModelSelect.vue b/src/frontend/src/components/agent/AgentModelSelect.vue new file mode 100644 index 0000000..7d56f36 --- /dev/null +++ b/src/frontend/src/components/agent/AgentModelSelect.vue @@ -0,0 +1,198 @@ + + + + + + { + "en": { + "showAllModels": "Show all", + "modelLabel": "Model" + }, + "es": { + "showAllModels": "Mostrar todos", + "modelLabel": "Modelo" + } + } + + diff --git a/src/frontend/src/components/agent/AgentPastExecutionsDialog.vue b/src/frontend/src/components/agent/AgentPastExecutionsDialog.vue new file mode 100644 index 0000000..aaf1765 --- /dev/null +++ b/src/frontend/src/components/agent/AgentPastExecutionsDialog.vue @@ -0,0 +1,151 @@ + + + + + +{ + "en": { + "pastExecutionsTitle": "Past Executions", + "noPastExecutions": "No past executions found", + "deleteConfirmDescription": "Delete execution?" + }, + "es": { + "pastExecutionsTitle": "Ejecuciones Pasadas", + "noPastExecutions": "No se encontraron ejecuciones pasadas", + "deleteConfirmDescription": "¿Eliminar ejecución?" + } +} + diff --git a/src/frontend/src/components/agent/AgentTestcaseChatMessage.vue b/src/frontend/src/components/agent/AgentTestcaseChatMessage.vue index c1d4ee0..748160b 100644 --- a/src/frontend/src/components/agent/AgentTestcaseChatMessage.vue +++ b/src/frontend/src/components/agent/AgentTestcaseChatMessage.vue @@ -21,13 +21,15 @@ export class AgentTestcaseChatUiMessage{ statusUpdates: StatusUpdate[] = [] isStatusComplete: boolean = false - constructor(text: string, isUser: boolean, isPlaceholder: boolean, id?: number){ + constructor(text: string, isUser: boolean, isPlaceholder: boolean, id?: number, statusUpdates?: StatusUpdate[]){ this.uuid = uuidv4() this.text = text this.isUser = isUser this.isPlaceholder = isPlaceholder this.id = id this.isStreaming = false + this.statusUpdates = statusUpdates || [] + this.isStatusComplete = (statusUpdates?.length || 0) > 0 } public addStatusUpdate(statusUpdate: StatusUpdate): void { @@ -99,7 +101,8 @@ const { t } = useI18n() } -{ + +{ "en": { "editMessageButton": "Edit message" }, @@ -107,4 +110,4 @@ const { t } = useI18n() "editMessageButton": "Editar mensaje" } } - \ No newline at end of file + diff --git a/src/frontend/src/components/agent/AgentTestcasePanel.vue b/src/frontend/src/components/agent/AgentTestcasePanel.vue index 6a2c763..722f34c 100644 --- a/src/frontend/src/components/agent/AgentTestcasePanel.vue +++ b/src/frontend/src/components/agent/AgentTestcasePanel.vue @@ -1,58 +1,68 @@ @@ -276,30 +393,37 @@ defineExpose({
-
- {{ testCaseId ? t('noTestCaseResultsDescription') : t('noRunResultsDescription') }} -
-
-
+
@@ -334,20 +458,22 @@ defineExpose({
-
- -
- - {{ t('phaseExecuting') }} - {{ t('phaseEvaluating') }} -
-
- - {{ t('testRunning') }} -
-
- {{ statusDescription }} + class="h-40 min-h-40 flex-shrink-0 border-t-1 border-auxiliar-gray p-4"> +
+ +
+
+ + {{ t('phaseExecuting') }} + {{ t('phaseEvaluating') }} +
+
+ + {{ t('testRunning') }} +
+
+ {{ statusDescription }} +
@@ -356,7 +482,8 @@ defineExpose({ -{ + +{ "en": { "newTestCaseTitle": "Create a new test case", "editTestCaseTitle": "Editing: {testCaseName}", @@ -368,14 +495,19 @@ defineExpose({ "successDescription": "The agent's response matched the expected output. No formatting or content deviations were detected.", "failureDescription": "The agent's response did not match the expected output. Formatting or content deviations were detected.", "errorDescription": "An error occurred while running the test case", - "noRunResultsDescription": "Run the full suite to validate all defined test cases.", - "noTestCaseResultsDescription": "Run the test to compare the agent's response with the expected output.", - "obtainedResultLabel": "Obtained result", "runningTestCase": "Running: {testCaseName}", "testCaseResult": "Test case result: {testCaseName}", "testRunning": "Test is running...", "phaseExecuting": "Executing test case...", - "phaseEvaluating": "Evaluating response..." + "phaseEvaluating": "Evaluating response...", + "compare": "Compare with specification", + "closeCompare": "Close compare", + "compareDisabledReasonTestModified": "Compare is disabled because the test case was modified after the test execution", + "compareDisabledReasonTestDeleted": "Compare is disabled because the test case was deleted", + "success": "Success", + "failure": "Failed", + "error": "Error running", + "skipped": "Skipped" }, "es": { "newTestCaseTitle": "Crea un nuevo test case", @@ -388,13 +520,19 @@ defineExpose({ "successDescription": "La respuesta del agente coincidió con la salida esperada. No se detectaron desvíos de formato o contenido.", "failureDescription": "La respuesta del agente no coincidió con la salida esperada. Se detectaron desvíos de formato o contenido.", "errorDescription": "Ocurrió un error al ejecutar el test case", - "noRunResultsDescription": "Corre la suite completa para validar todos los test cases definidos.", - "noTestCaseResultsDescription": "Ejecuta el test para comparar la respuesta del agente con la esperada.", - "obtainedResultLabel": "Resultado obtenido", "runningTestCase": "Ejecutando: {testCaseName}", "testCaseResult": "Resultado del test case: {testCaseName}", "testRunning": "El test está ejecutándose...", "phaseExecuting": "Ejecutando test case...", - "phaseEvaluating": "Evaluando respuesta..." + "phaseEvaluating": "Evaluando respuesta...", + "compare": "Comparar con la especificación", + "closeCompare": "Cerrar comparación", + "compareDisabledReasonTestModified": "La comparación está deshabilitada porque el test case fue modificado después de la ejecución del test", + "compareDisabledReasonTestDeleted": "La comparación está deshabilitada porque el test case fue eliminado", + "success": "Pasó", + "failure": "Falló", + "error": "Error al ejecutar", + "skipped": "Omitido" } -} +} + diff --git a/src/frontend/src/components/agent/AgentTestcaseResults.vue b/src/frontend/src/components/agent/AgentTestcaseResults.vue new file mode 100644 index 0000000..26f46aa --- /dev/null +++ b/src/frontend/src/components/agent/AgentTestcaseResults.vue @@ -0,0 +1,106 @@ + + + + + +{ + "en": { + "running": "Running...", + "runResultTitle": "Results from {'<'}b>{date}{'<'}/b>", + "stopSuiteRun": "Stop" + }, + "es": { + "running": "Ejecutando...", + "runResultTitle": "Resultados de {'<'}b>{date}{'<'}/b>", + "stopSuiteRun": "Detener" + } +} + diff --git a/src/frontend/src/components/agent/AgentTestcaseResultsSkeleton.vue b/src/frontend/src/components/agent/AgentTestcaseResultsSkeleton.vue new file mode 100644 index 0000000..72c983e --- /dev/null +++ b/src/frontend/src/components/agent/AgentTestcaseResultsSkeleton.vue @@ -0,0 +1,39 @@ + diff --git a/src/frontend/src/components/agent/AgentTestcaseRunStatus.vue b/src/frontend/src/components/agent/AgentTestcaseRunStatus.vue new file mode 100644 index 0000000..1e8768a --- /dev/null +++ b/src/frontend/src/components/agent/AgentTestcaseRunStatus.vue @@ -0,0 +1,51 @@ + + + + + +{ + "en": { + "passed": "Passed", + "failed": "Failed", + "error": "Error", + "skipped": "Skipped" + }, + "es": { + "passed": "Pasó", + "failed": "Falló", + "error": "Error", + "skipped": "Omitido" + } +} + diff --git a/src/frontend/src/components/agent/TestCaseStatus.vue b/src/frontend/src/components/agent/AgentTestcaseStatus.vue similarity index 98% rename from src/frontend/src/components/agent/TestCaseStatus.vue rename to src/frontend/src/components/agent/AgentTestcaseStatus.vue index a4ea1c1..5b2fe34 100644 --- a/src/frontend/src/components/agent/TestCaseStatus.vue +++ b/src/frontend/src/components/agent/AgentTestcaseStatus.vue @@ -67,7 +67,8 @@ const statusConfig = computed(() => {
-{ + +{ "en": { "running": "Running", "success": "Success", @@ -84,5 +85,5 @@ const statusConfig = computed(() => { "pending": "Pendiente", "skipped": "Omitido" } -} - +} + diff --git a/src/frontend/src/components/agent/AgentTestcases.vue b/src/frontend/src/components/agent/AgentTestcases.vue new file mode 100644 index 0000000..831ae44 --- /dev/null +++ b/src/frontend/src/components/agent/AgentTestcases.vue @@ -0,0 +1,238 @@ + + + + + +{ + "en": { + "newTestCaseButton": "Add", + "renameTestCaseTooltip": "Rename", + "cloneTestCaseTooltip": "Clone", + "deleteTestCaseTooltip": "Delete", + "deleteTestCaseConfirmation": "Delete {testCaseName}?", + "runTestCaseMenuItem": "Run", + "runTestsButton": "Run all", + "pastExecutions": "Past Executions", + "noTestCasesTitle": "You don't have test cases for this agent yet", + "noTestCasesDescription": "Create your first test case to validate that the agent meets the expected requirements.", + "configureEvaluator": "Configure agent evaluator", + "configureTestEvaluator": "Configure test evaluator" + }, + "es": { + "newTestCaseButton": "Agregar", + "renameTestCaseTooltip": "Renombrar", + "cloneTestCaseTooltip": "Clonar", + "deleteTestCaseTooltip": "Eliminar", + "deleteTestCaseConfirmation": "¿Eliminar {testCaseName}?", + "runTestCaseMenuItem": "Ejecutar", + "runTestsButton": "Ejecutar todos", + "pastExecutions": "Ejecuciones pasadas", + "noTestCasesTitle": "Aún no tienes test cases para este agente", + "noTestCasesDescription": "Crea tu primer test case para validar que el agente cumple los requisitos esperados.", + "configureEvaluator": "Configurar evaluador del agente", + "configureTestEvaluator": "Configurar evaluador del test" + } +} + diff --git a/src/frontend/src/components/agent/AgentToolConfigEditor.vue b/src/frontend/src/components/agent/AgentToolConfigEditor.vue index d70649d..9ef1f25 100644 --- a/src/frontend/src/components/agent/AgentToolConfigEditor.vue +++ b/src/frontend/src/components/agent/AgentToolConfigEditor.vue @@ -5,7 +5,7 @@ import type { JSONSchema7, JSONSchema7Definition } from 'json-schema' import { useErrorHandler } from '@/composables/useErrorHandler' import Ajv, { type ErrorObject } from 'ajv' import addFormats from 'ajv-formats' -import { AuthenticationWindowCloseError, AuthenticationCancelError, handleOAuthRequestsIn } from '@/services/toolOAuth'; +import { AuthenticationError, handleOAuthRequestsIn } from '@/services/toolOAuth'; import { AgentToolConfig, AgentTool } from '@/services/api' export class EditingToolConfig { @@ -116,11 +116,15 @@ const toolMessage = computed(() => { return ret != toolMessageKey ? ret : null }) -const isFileProperty = (toolProp: JSONSchema7Definition) : boolean => { +const isFileArrayProperty = (toolProp: JSONSchema7Definition) : boolean => { const toolPropSchema = js7(toolProp) return toolPropSchema?.type === 'array' && (js7(toolPropSchema?.items)?.$ref?.endsWith('/File') ?? false) } +const isFileProperty = (toolProp: JSONSchema7Definition) : boolean => { + return js7(toolProp)?.$ref?.endsWith('/File') ?? false +} + const isEnumProperty = (toolProp: JSONSchema7Definition) : boolean => { const toolPropSchema = js7(toolProp)! return toolPropSchema.type === 'array' && js7(toolPropSchema.items)?.enum !== undefined @@ -179,15 +183,23 @@ const saveToolConfig = async () => { // avoid saving tool when requires files and none have been uploaded if (savedConfig.value !== mutableConfig && !(Object.values(toolProperties.value).some(isFileProperty) && !savedConfig.value)) { partialSave = true - ret = await handleOAuthRequestsIn(async () => await api.configureAgentTool(props.toolConfig.agentId, ret), api) + ret = await handleOAuthRequestsIn(async () => { + // resetting this flag since we want to allow retry saving tool config to re open oauth popup, or allow cancelling oauth + // when oauth popup is closed or just ignored + saving.value = true + try { + return await api.configureAgentTool(props.toolConfig.agentId, ret) + } finally { + // here is where we re enable save and cancel buttons in case oauth popup is showed or auth completed + saving.value = false + } + }, api) partialSave = false } emit('update', ret) } catch (error) { - if (error instanceof AuthenticationWindowCloseError) { - validationErrors.value = t('authenticationWindowClosed') - } else if (error instanceof AuthenticationCancelError) { - validationErrors.value = t('authenticationCancelled') + if (error instanceof AuthenticationError) { + validationErrors.value = t(error.errorCode) } else if (error instanceof ValidationErrors) { validationErrors.value = error.message } else if (error instanceof HttpError && error.status === 400) { @@ -259,20 +271,43 @@ class ValidationErrors extends Error {
-
+
- +
- + {{ translateToolPropertyName(toolConfig.tool.id, propName) }}
- +
- +
@@ -308,6 +343,7 @@ class ValidationErrors extends Error { "close": "Close", "authenticationWindowClosed": "The authentication window was closed. Please sign in again to configure this tool.", "authenticationCancelled": "The authentication was cancelled. Please complete the authentication process to configure this tool.", + "authenticationAccessDenied": "The authentication was denied by the MCP server. Please verify that you actually have the permissions necessary to use it.", "missingProperty": "A value is required for '{property}'. Please provide one.", "invalidPropertyFormat": "Provided '{property}' is not a valid {format}. Please, review the value and try again.", "invalidPropertyMinLength": "Provided '{property}' is shorter than {minLength} @:{'character'}. Please, review the value and try again.", @@ -337,6 +373,7 @@ class ValidationErrors extends Error { "close": "Cerrar", "authenticationWindowClosed": "La ventana de autenticación se cerró. Por favor, inicie sesión nuevamente para configurar esta herramienta.", "authenticationCancelled": "La autenticación fue cancelada. Por favor, complete el proceso de autenticación para configurar esta herramienta.", + "authenticationAccessDenied": "La autenticación fue denegada por el servidor MCP. Por favor, verifica que tengas los permisos necesarios para usarlo.", "missingProperty": "Se requiere un valor para '{property}'. Por favor proporciona uno.", "invalidPropertyFormat": "El valor proporcionado para '{property}' no es válido. Por favor, revise el valor y vuelve a intentarlo.", "invalidPropertyMinLength": "El valor proporcionado para '{property}' es más corto que {minLength} @:{'character'}. Por favor, revise el valor y vuelve a intentarlo.", diff --git a/src/frontend/src/components/agent/AgentToolFilesEditor.vue b/src/frontend/src/components/agent/AgentToolFilesEditor.vue index e0ff6a5..2dfa307 100644 --- a/src/frontend/src/components/agent/AgentToolFilesEditor.vue +++ b/src/frontend/src/components/agent/AgentToolFilesEditor.vue @@ -14,6 +14,8 @@ const props = defineProps<{ configuredTool: boolean contactEmail?: string viewMode?: boolean + allowedExtensions?: string[] + maxFiles?: number onBeforeFileUpload: (filesCount: number) => Promise onAfterFileRemove: (filesCount: number) => Promise }>() @@ -21,6 +23,9 @@ const props = defineProps<{ const { t } = useI18n() const api = new ApiService() const { handleError } = useErrorHandler() + +const defaultAllowedExtensions = ['pdf', 'txt', 'md', 'csv', 'xlsx', 'xls'] +const allowedExtensions = computed(() => props.allowedExtensions ?? defaultAllowedExtensions) const attachedFilesError = ref({ title: '', message: '' @@ -188,12 +193,8 @@ const filteredFiles = computed(() => props.viewMode ? toolFiles.value.filter(f =
-
- -
-
- -
+ +
diff --git a/src/frontend/src/components/agent/EvaluatorConfigurationModal.vue b/src/frontend/src/components/agent/EvaluatorConfigurationModal.vue new file mode 100644 index 0000000..4252dd4 --- /dev/null +++ b/src/frontend/src/components/agent/EvaluatorConfigurationModal.vue @@ -0,0 +1,197 @@ + + + + + +{ + "en": { + "evaluator": "evaluator", + "evaluatorTitle": "Agent evaluator", + "testCaseEvaluatorNote": "This evaluator configuration will only be used for this specific test case.", + "modelLabel": "Model", + "instructionsLabel": "Instructions", + "instructionsPlaceholder": "Write the instructions for this evaluator", + "availableVariablesNote": "You can use these variables in your instructions:\n• {'{'}{'{'}inputs{'}'}{'}'} - user message\n• {'{'}{'{'}reference_outputs{'}'}{'}'} - expected response\n• {'{'}{'{'}outputs{'}'}{'}'} - actual agent response", + "cancel": "Cancel", + "confirm": "Confirm" + + }, + "es": { + "evaluator": "evaluador", + "evaluatorTitle": "Evaluador del agente", + "testCaseEvaluatorNote": "Esta configuración del evaluador solo se usará para este caso de prueba específico.", + "modelLabel": "Modelo", + "instructionsLabel": "Instrucciones", + "instructionsPlaceholder": "Escribe las instrucciones para este evaluador", + "availableVariablesNote": "Puedes usar estas variables en tus instrucciones:\n• {'{'}{'{'}inputs{'}'}{'}'} - mensaje del usuario\n• {'{'}{'{'}reference_outputs{'}'}{'}'} - respuesta esperada\n• {'{'}{'{'}outputs{'}'}{'}'} - respuesta actual del agente", + "cancel": "Cancelar", + "confirm": "Confirmar" + + } +} + diff --git a/src/frontend/src/components/agent/LlmModelSettings.vue b/src/frontend/src/components/agent/LlmModelSettings.vue new file mode 100644 index 0000000..795e5e4 --- /dev/null +++ b/src/frontend/src/components/agent/LlmModelSettings.vue @@ -0,0 +1,92 @@ + + + + + +{ + "en": { + "temperatureLabel": "Temperature", + "reasoningEffortLabel": "Reasoning", + "preciseTemperature": "Precise", + "neutralTemperature": "Neutral", + "creativeTemperature": "Creative", + "lowEffort": "Low", + "mediumEffort": "Medium", + "highEffort": "High" + }, + "es": { + "temperatureLabel": "Temperatura", + "reasoningEffortLabel": "Razonamiento", + "preciseTemperature": "Preciso", + "neutralTemperature": "Neutro", + "creativeTemperature": "Creativo", + "lowEffort": "Bajo", + "mediumEffort": "Medio", + "highEffort": "Alto" + } +} + diff --git a/src/frontend/src/components/chat/ChatPanel.vue b/src/frontend/src/components/chat/ChatPanel.vue index 8278894..18615e5 100644 --- a/src/frontend/src/components/chat/ChatPanel.vue +++ b/src/frontend/src/components/chat/ChatPanel.vue @@ -1,7 +1,7 @@ diff --git a/src/frontend/src/components/dashboard/DashboardCardUsers.vue b/src/frontend/src/components/dashboard/DashboardCardUsers.vue index a284b41..b7b0a9f 100644 --- a/src/frontend/src/components/dashboard/DashboardCardUsers.vue +++ b/src/frontend/src/components/dashboard/DashboardCardUsers.vue @@ -35,7 +35,8 @@ const isSearchingUser = ref(false) const searchUser = ref('') const roleNames = computed>(() => ({ [Role.TEAM_OWNER]: t('teamOwner'), - [Role.TEAM_MEMBER]: t('teamMember') + [Role.TEAM_MEMBER]: t('teamMember'), + [Role.TEAM_EDITOR]: t('teamEditor') })) const showAddUserModal = ref(false) @@ -251,6 +252,7 @@ const handleConfirmDeleteUser = async () => { "noUsersFound": "No users found", "teamOwner": "Leader", "teamMember": "Member", + "teamEditor": "Editor", "addUser": "Add", "cancel": "Cancel", "delete": "Remove", @@ -272,6 +274,7 @@ const handleConfirmDeleteUser = async () => { "noUsersFound": "No se encontraron usuarios", "teamOwner": "Líder", "teamMember": "Miembro", + "teamEditor": "Editor", "addUser": "Agregar", "cancel": "Cancelar", "delete": "Remover", diff --git a/src/frontend/src/components/dashboard/DashboardImpactCardTopAgents.vue b/src/frontend/src/components/dashboard/DashboardImpactCardTopAgents.vue index 3891650..716e0b7 100644 --- a/src/frontend/src/components/dashboard/DashboardImpactCardTopAgents.vue +++ b/src/frontend/src/components/dashboard/DashboardImpactCardTopAgents.vue @@ -302,7 +302,8 @@ watch(() => props.teamId, async () => { -{ + +{ "en": { "agentsTitle": "Agents", "loadingMoreAgents": "Loading more agents...", @@ -335,4 +336,5 @@ watch(() => props.teamId, async () => { "startChatButtonLabel": "Usar ahora", "viewDetailsTooltip": "Ver detalles" } -} +} + diff --git a/src/frontend/src/components/discover/DiscoverTabs.vue b/src/frontend/src/components/discover/DiscoverTabs.vue index 25785f6..879aa55 100644 --- a/src/frontend/src/components/discover/DiscoverTabs.vue +++ b/src/frontend/src/components/discover/DiscoverTabs.vue @@ -381,6 +381,9 @@ onMounted(async () => {

{{ isSearching ? t('searchingAgents') : t('noAgentsFound') }}

{{ isSearching ? t('searchingAgentsDescription') : t('noAgentsFoundDescription') }}

+
+

{{ t('noAgentsCreated') }}

+
-{ + + +{ "en": { "userName": "User name", "userNamePlaceholder": "Enter user name", @@ -151,6 +154,7 @@ defineExpose({ "role": "Role", "teamOwner": "Leader", "teamMember": "Member", + "teamEditor": "Editor", "invalidEmail": "Invalid email" }, "es": { @@ -160,6 +164,8 @@ defineExpose({ "role": "Rol", "teamOwner": "Líder", "teamMember": "Miembro", + "teamEditor": "Editor", "invalidEmail": "Email inválido" } -} \ No newline at end of file +} + diff --git a/src/frontend/src/composables/useAgentPromptStore.ts b/src/frontend/src/composables/useAgentPromptStore.ts index acdcfdc..5a95bba 100644 --- a/src/frontend/src/composables/useAgentPromptStore.ts +++ b/src/frontend/src/composables/useAgentPromptStore.ts @@ -37,7 +37,7 @@ export function useAgentPromptStore() { agentsPromptStore.setPrompts(await api.findAgentPrompts(agentId)) } - async function updatePrompt(agentId: number, promptId:number, prompt: AgentPromptUpdate) { + async function updatePrompt(agentId: number, promptId: number, prompt: AgentPromptUpdate) { const updatedPrompt = await api.updateAgentPrompt(agentId, promptId, prompt) agentsPromptStore.updatePrompt(updatedPrompt) } diff --git a/src/frontend/src/composables/useTestCaseStore.ts b/src/frontend/src/composables/useTestCaseStore.ts new file mode 100644 index 0000000..30e5d75 --- /dev/null +++ b/src/frontend/src/composables/useTestCaseStore.ts @@ -0,0 +1,104 @@ +import { reactive } from 'vue' +import { ApiService, TestCase } from '@/services/api' + +const testCasesStore = reactive({ + testCases: [] as TestCase[], + selectedTestCase: undefined as TestCase | undefined, + async setTestCases(testCases: TestCase[]) { + this.testCases = testCases + this.selectedTestCase = testCases.find(tc => tc.thread.id === this.selectedTestCase?.thread.id) + }, + addTestCase(testCase: TestCase) { + this.testCases.push(testCase) + }, + removeTestCase(testCaseThreadId: number) { + this.testCases = this.testCases.filter((tc) => tc.thread.id !== testCaseThreadId) + if (this.selectedTestCase?.thread.id === testCaseThreadId) { + this.selectedTestCase = this.testCases.length > 0 ? this.testCases[0] : undefined + } + }, + updateTestCase(testCaseThreadId: number, updatedTestCase: TestCase) { + const index = this.testCases.findIndex((tc) => tc.thread.id === testCaseThreadId) + if (index !== -1) { + this.testCases[index] = updatedTestCase + if (this.selectedTestCase?.thread.id === testCaseThreadId) { + this.selectedTestCase = updatedTestCase + } + } + }, + setSelectedTestCase(testCase: TestCase | undefined) { + this.setSelectedTestCaseById(testCase?.thread.id) + }, + setSelectedTestCaseById(testCaseId: number | undefined) { + this.selectedTestCase = testCaseId ? this.testCases.find(tc => tc.thread.id === testCaseId) : undefined + }, + clearTestCases() { + this.testCases = [] + this.selectedTestCase = undefined + } +}) + +export function useTestCaseStore() { + const api = new ApiService() + + async function loadTestCases(agentId: number) { + testCasesStore.setTestCases(await api.findTestCases(agentId)) + } + + async function addTestCase(agentId: number) { + const testCase = await api.addTestCase(agentId) + if (testCasesStore.testCases.find(tc => tc.thread.id === testCase.thread.id)) { + testCasesStore.updateTestCase(testCase.thread.id, testCase) + } else { + testCasesStore.addTestCase(testCase) + } + return testCase + } + + async function deleteTestCase(agentId: number, testCaseThreadId: number) { + await api.deleteTestCase(agentId, testCaseThreadId) + testCasesStore.removeTestCase(testCaseThreadId) + } + + async function updateTestCase(agentId: number, testCaseThreadId: number, name: string) { + const updatedTestCase = await api.updateTestCase(agentId, testCaseThreadId, name) + testCasesStore.updateTestCase(testCaseThreadId, updatedTestCase) + } + + async function refreshTestCase(agentId: number, testCaseThreadId: number) { + const testCase = await api.findTestCase(agentId, testCaseThreadId) + testCasesStore.updateTestCase(testCaseThreadId, testCase) + return testCase + } + + async function cloneTestCase(agentId: number, testCaseThreadId: number) { + const clonedTestCase = await api.cloneTestCase(agentId, testCaseThreadId) + testCasesStore.addTestCase(clonedTestCase) + return clonedTestCase + } + + function clearTestCases() { + testCasesStore.clearTestCases() + } + + function setSelectedTestCase(testCase: TestCase | undefined) { + testCasesStore.setSelectedTestCase(testCase) + } + + function setSelectedTestCaseById(testCaseId: number | undefined) { + testCasesStore.setSelectedTestCaseById(testCaseId) + } + + return { + testCasesStore, + loadTestCases, + addTestCase, + deleteTestCase, + updateTestCase, + refreshTestCase, + cloneTestCase, + clearTestCases, + setSelectedTestCase, + setSelectedTestCaseById + } +} diff --git a/src/frontend/src/composables/useTestExecutionStore.ts b/src/frontend/src/composables/useTestExecutionStore.ts new file mode 100644 index 0000000..53cd8aa --- /dev/null +++ b/src/frontend/src/composables/useTestExecutionStore.ts @@ -0,0 +1,291 @@ +import { reactive } from 'vue' +import { ApiService, TestCase, TestCaseResult, TestCaseResultStatus, TestSuiteRun, TestSuiteRunStatus } from '@/services/api' +import type { TestSuiteExecutionStreamEvent } from '@/services/api' + +export interface TestCaseExecutionState { + phase: string; + userMessage?: { id: number; text: string }; + agentMessage?: { id: number; text: string, complete: boolean }; + status?: TestCaseResultStatus; + statusUpdates?: any[]; +} + +const testExecutionStore = reactive({ + selectedSuiteRun: undefined as TestSuiteRun | undefined, + selectedResult: undefined as TestCaseResult | undefined, + testCaseResults: [] as TestCaseResult[], + executionStates: new Map(), + isStoppingSuite: false, + + setSelectedSuiteRun(suiteRun: TestSuiteRun | undefined) { + this.selectedSuiteRun = suiteRun + }, + + setSelectedResult(result: TestCaseResult | undefined) { + this.selectedResult = result + }, + + setTestCaseResults(results: TestCaseResult[]) { + this.testCaseResults = results + this.selectedResult = results.find(r => r.testCaseId === this.selectedResult?.testCaseId) + }, + + setExecutionState(testCaseResultId: number, state: TestCaseExecutionState) { + this.executionStates.set(testCaseResultId, state) + }, + + getExecutionState(testCaseResultId: number): TestCaseExecutionState | undefined { + return this.executionStates.get(testCaseResultId) + }, + + deleteExecutionState(testCaseResultId: number) { + this.executionStates.delete(testCaseResultId) + }, + + clearExecutionStates() { + this.executionStates.clear() + }, + + setTestCaseResultStatus(testCaseId: number, status: TestCaseResultStatus) { + const result = this.testCaseResults.find(tr => tr.testCaseId === testCaseId) + if (!result) { + return + } + + result.status = status + + if (this.selectedResult?.testCaseId === testCaseId) { + this.selectedResult.status = status + } + }, + + clear() { + this.selectedSuiteRun = undefined + this.selectedResult = undefined + this.testCaseResults = [] + this.executionStates.clear() + } +}) + +export function useTestExecutionStore() { + const api = new ApiService() + + async function loadSuiteRunResults(agentId: number, suiteRunId: number) { + const results = await api.findTestSuiteRunResults(agentId, suiteRunId) + testExecutionStore.setTestCaseResults(results) + return results + } + + function processStreamEvent( + event: TestSuiteExecutionStreamEvent, + options?: { + currentTestCaseResultId?: number + agentId?: number + } + ): number | undefined { + let currentTestCaseResultId: number | undefined = options?.currentTestCaseResultId + + switch (event.type) { + case 'suite.test.start': + const testCaseId = event.data.testCaseId; + currentTestCaseResultId = event.data.resultId; + + const testCaseResult = testExecutionStore.testCaseResults.find(tr => tr.testCaseId === testCaseId); + if (testCaseResult) { + testExecutionStore.setTestCaseResultStatus(testCaseId, TestCaseResultStatus.RUNNING); + testCaseResult.id = currentTestCaseResultId; + } + + testExecutionStore.setExecutionState(currentTestCaseResultId, { + phase: 'executing', + statusUpdates: [] + }); + + break; + + case 'suite.test.metadata': + const testResult = testExecutionStore.testCaseResults.find(tr => tr.testCaseId === event.data.testCaseId); + if (testResult) { + testResult.id = event.data.resultId; + if (testExecutionStore.selectedSuiteRun) { + testResult.testSuiteRunId = testExecutionStore.selectedSuiteRun.id; + } + } + break; + + case 'suite.test.phase': + const execState = testExecutionStore.getExecutionState(currentTestCaseResultId!); + if (execState) { + execState.phase = event.data.phase; + if (event.data.phase === 'completed') { + execState.status = event.data.status as TestCaseResultStatus; + } + } + break; + + case 'suite.test.userMessage': + const userExecState = testExecutionStore.getExecutionState(currentTestCaseResultId!); + if (userExecState) { + userExecState.userMessage = { + id: event.data.id, + text: event.data.text + }; + } + break; + + case 'suite.test.agentMessage.start': + const startExecState = testExecutionStore.getExecutionState(currentTestCaseResultId!); + if (startExecState) { + startExecState.agentMessage = { + id: event.data.id, + text: '', + complete: false + }; + } + break; + + case 'suite.test.agentMessage.chunk': + const chunkExecState = testExecutionStore.getExecutionState(currentTestCaseResultId!); + if (chunkExecState && chunkExecState.agentMessage) { + chunkExecState.agentMessage.text += event.data.chunk; + } + break; + + case 'suite.test.agentMessage.complete': + const completeExecState = testExecutionStore.getExecutionState(currentTestCaseResultId!); + if (completeExecState && completeExecState.agentMessage) { + completeExecState.agentMessage.text = event.data.text; + completeExecState.agentMessage.complete = true; + } + break; + + case 'suite.test.executionStatus': + const statusExecState = testExecutionStore.getExecutionState(currentTestCaseResultId!); + if (statusExecState) { + statusExecState.statusUpdates!.push(event.data); + } + break; + + case 'suite.test.error': + const errorExecState = testExecutionStore.getExecutionState(currentTestCaseResultId!); + if (errorExecState) { + errorExecState.phase = 'completed'; + errorExecState.status = TestCaseResultStatus.ERROR; + } + + const errorResult = testExecutionStore.testCaseResults.find(tr => tr.id === currentTestCaseResultId!); + if (errorResult) { + testExecutionStore.setTestCaseResultStatus(errorResult.testCaseId, TestCaseResultStatus.ERROR); + } + break; + + case 'suite.test.complete': + const completedTestCaseId = event.data.testCaseId; + updateTestCaseResult(completedTestCaseId, event.data.status as TestCaseResultStatus, event.data.evaluation?.analysis); + + const completedResult = testExecutionStore.testCaseResults.find(tr => tr.testCaseId === completedTestCaseId); + if (completedResult?.id) { + testExecutionStore.deleteExecutionState(completedResult.id); + } + break; + + case 'suite.error': + testExecutionStore.testCaseResults.forEach(result => { + if (result.status === TestCaseResultStatus.RUNNING || result.status === TestCaseResultStatus.PENDING) { + testExecutionStore.setTestCaseResultStatus(result.testCaseId, TestCaseResultStatus.SKIPPED); + } + }); + + testExecutionStore.clearExecutionStates(); + break; + + case 'suite.complete': + if (testExecutionStore.selectedSuiteRun) { + testExecutionStore.selectedSuiteRun.status = event.data.status as TestSuiteRunStatus + testExecutionStore.selectedSuiteRun.completedAt = new Date() + testExecutionStore.selectedSuiteRun.passedTests = event.data.passed + testExecutionStore.selectedSuiteRun.failedTests = event.data.failed + testExecutionStore.selectedSuiteRun.errorTests = event.data.errors + testExecutionStore.selectedSuiteRun.skippedTests = event.data.skipped + } + break; + } + + return currentTestCaseResultId; + } + + function updateTestCaseResult(testCaseId: number, status: TestCaseResultStatus, evaluatorAnalysis?: string) { + const result = testExecutionStore.testCaseResults.find(tr => tr.testCaseId === testCaseId) + if (result) { + result.status = status + result.evaluatorAnalysis = evaluatorAnalysis + if (testExecutionStore.selectedResult?.testCaseId === testCaseId) { + testExecutionStore.selectedResult = result + } + } + } + + function initializeTestRun(agentId: number, testCases: TestCase[], singleTestCaseId?: number) { + const results = testCases.map(testCase => { + return new TestCaseResult( + testCase.thread.id, + new Date(), + singleTestCaseId ? (testCase.thread.id === singleTestCaseId ? TestCaseResultStatus.PENDING : TestCaseResultStatus.SKIPPED) : TestCaseResultStatus.PENDING, + testCase.thread.name + ) + }) + testExecutionStore.setTestCaseResults(results) + setSelectedResult(singleTestCaseId ? results.find(result => result.testCaseId === singleTestCaseId)! : results[0]); + + testExecutionStore.setSelectedSuiteRun({ + id: 0, + agentId: agentId, + status: TestSuiteRunStatus.RUNNING, + executedAt: new Date(), + totalTests: testCases.length, + passedTests: 0, + failedTests: 0, + errorTests: 0, + skippedTests: 0 + }) + } + + function setSelectedResult(result: TestCaseResult) { + testExecutionStore.setSelectedResult(result) + } + + async function stopSuiteRun() { + const selectedSuiteRun = testExecutionStore.selectedSuiteRun + if (!selectedSuiteRun || testExecutionStore.isStoppingSuite) { + return + } + + testExecutionStore.isStoppingSuite = true + + try { + await api.stopTestSuiteRun(selectedSuiteRun.agentId, selectedSuiteRun.id) + testExecutionStore.testCaseResults.forEach(result => { + if (result.status === TestCaseResultStatus.RUNNING || result.status === TestCaseResultStatus.PENDING) { + testExecutionStore.setTestCaseResultStatus(result.testCaseId, TestCaseResultStatus.SKIPPED) + } + }) + } finally { + testExecutionStore.isStoppingSuite = false + } + } + + function clear() { + testExecutionStore.clear() + } + + return { + testExecutionStore, + loadSuiteRunResults, + processStreamEvent, + updateTestCaseResult, + initializeTestRun, + stopSuiteRun, + setSelectedResult, + clear, + } +} diff --git a/src/frontend/src/pages/AgentEditorPage.vue b/src/frontend/src/pages/AgentEditorPage.vue index 130e3cd..0a29627 100644 --- a/src/frontend/src/pages/AgentEditorPage.vue +++ b/src/frontend/src/pages/AgentEditorPage.vue @@ -2,42 +2,34 @@ import { onMounted, onBeforeUnmount, ref } from 'vue'; import { useRoute, useRouter, onBeforeRouteUpdate } from 'vue-router'; import { useI18n } from 'vue-i18n'; -import { ApiService, Thread, HttpError, TestCase, TestCaseResult, TestCaseResultStatus, TestSuiteRun, TestSuiteRunStatus } from '@/services/api'; +import { ApiService, Thread, HttpError, TestCaseResultStatus, TestSuiteRun, TestSuiteRunStatus } from '@/services/api'; import type { TestSuiteExecutionStreamEvent } from '@/services/api'; import AgentTestcasePanel from '@/components/agent/AgentTestcasePanel.vue'; import { useErrorHandler } from '@/composables/useErrorHandler'; import { useToast } from 'vue-toastification'; import ToastMessage from '@/components/common/ToastMessage.vue'; +import { useTestCaseStore } from '@/composables/useTestCaseStore'; +import { useTestExecutionStore } from '@/composables/useTestExecutionStore'; +import { handleOAuthRequestsIn, AuthenticationError } from '@/services/toolOAuth'; -export interface TestCaseExecutionState { - phase: string; - userMessage?: { id: number; text: string }; - agentMessage?: { id: number; text: string }; - agentChunks: string; - status?: TestCaseResultStatus; - statusUpdates: any[]; -} +export type { TestCaseExecutionState } from '@/composables/useTestExecutionStore'; const { t } = useI18n() const { handleError } = useErrorHandler() const toast = useToast() +const { testCasesStore, loadTestCases: loadTestCasesFromStore, clearTestCases } = useTestCaseStore() +const { testExecutionStore, loadSuiteRunResults, processStreamEvent, initializeTestRun, clear: clearExecutionStore } = useTestExecutionStore() const api = new ApiService(); const route = useRoute(); const router = useRouter(); const agentId = ref(); const threadId = ref(); const showTestCaseEditor = ref(false); -const testCaseId = ref(); -const isEditingTestCase = ref(false); -const testCases = ref([]); -const testCaseResults = ref([]); -const latestSuiteRun = ref(); +const isEditingTestCase = ref(true); const testCasePanel = ref>(); const loadingTests = ref(true); -const runningTests = ref(false); -const executionStates = ref>(new Map()); const testRunStartedByCurrentUser = ref(false); -let pollInterval: number | null = null; +const isComparingResultWithTestSpec = ref(false); const startChat = async () => { try { @@ -48,345 +40,109 @@ const startChat = async () => { router.push('/not-found'); } } -}; +} const handleSelectChat = (chat: Thread) => { threadId.value = chat.id; } -const handleSelectTestCase = (id: number | undefined) => { - testCaseId.value = id; -} - -const handleNewTestCase = (testCase: TestCase) => { - testCaseId.value = testCase.thread.id; - testCases.value.push(testCase); -} - -const loadTestCases = async (id: number) => { +const handleSelectExecution = async (execution: TestSuiteRun) => { + testExecutionStore.clear() + testExecutionStore.setSelectedSuiteRun(execution) try { - testCases.value = await api.findTestCases(id) - const suiteRuns = await api.findTestSuiteRuns(id, 1, 0) - latestSuiteRun.value = suiteRuns.length > 0 ? suiteRuns[0] : undefined - testCaseResults.value = latestSuiteRun.value ? await api.findTestSuiteRunResults(id, latestSuiteRun.value.id) : [] - - if (!testCases.value.length) { - isEditingTestCase.value = true - } - else if (testCaseResults.value.length > 0) { - handleSelectTestCase(testCases.value[0].thread.id) - isEditingTestCase.value = false - } - - if (latestSuiteRun.value && latestSuiteRun.value.status === TestSuiteRunStatus.RUNNING) { - runningTests.value = true - startPolling() - } - - } catch (e) { - handleError(e) - } finally { - loadingTests.value = false - } -} - -const handleDeleteTestCase = (testCaseThreadId: number) => { - const isSelectedTestCase = testCaseThreadId === testCaseId.value - testCases.value = testCases.value.filter(tc => tc.thread.id !== testCaseThreadId) - if (isSelectedTestCase) { - handleSelectTestCase(undefined) - } - if (testCases.value.length === 0 && !isEditingTestCase.value) { - isEditingTestCase.value = true - } -} - -const startPolling = () => { - stopPolling() - - pollInterval = window.setInterval(async () => { - await pollTestSuiteStatus() - }, 1000) -} - -const stopPolling = () => { - if (pollInterval !== null) { - window.clearInterval(pollInterval) - pollInterval = null - } -} - -const pollTestSuiteStatus = async () => { - if (!agentId.value) return - - try { - const suiteRuns = await api.findTestSuiteRuns(agentId.value, 1, 0) - - if (suiteRuns.length === 0) { - stopPolling() - runningTests.value = false - return - } - - latestSuiteRun.value = suiteRuns[0] - - const previousSelectedResult = testCaseId.value - ? testCaseResults.value.find(tr => tr.testCaseId === testCaseId.value) - : undefined - - testCaseResults.value = await api.findTestSuiteRunResults(agentId.value, latestSuiteRun.value!.id) - - const currentSelectedResult = testCaseId.value - ? testCaseResults.value.find(tr => tr.testCaseId === testCaseId.value) - : undefined - - if (testCaseId.value && currentSelectedResult && previousSelectedResult) { - const statusChanged = previousSelectedResult.status !== currentSelectedResult.status - const isNoLongerRunning = currentSelectedResult.status !== TestCaseResultStatus.RUNNING - - if (statusChanged && isNoLongerRunning) { - await testCasePanel.value?.loadTestCaseData() - } - } - - if (latestSuiteRun.value!.status !== TestSuiteRunStatus.RUNNING) { - stopPolling() - runningTests.value = false - testRunStartedByCurrentUser.value = false - } - } catch (e) { - handleError(e) - stopPolling() - runningTests.value = false - testRunStartedByCurrentUser.value = false + const results = await loadSuiteRunResults(agentId.value!, execution.id) + testExecutionStore.setSelectedResult(results[0]) + } catch (error) { + handleError(error) } + isEditingTestCase.value = false } const processSuiteExecutionStream = async ( eventStream: AsyncIterable, - options?: { - onTestStart?: (testCaseId: number) => void - } ) => { - let currentTestCaseId: number | null = null; - let is409Error = false; - + let currentTestCaseResultId: number | undefined = undefined; try { for await (const event of eventStream) { - switch (event.type) { - case 'suite.start': - latestSuiteRun.value = { - id: event.data.suiteRunId, - agentId: agentId.value!, - status: TestSuiteRunStatus.RUNNING, - executedAt: new Date(), - totalTests: testCases.value.length, - passedTests: 0, - failedTests: 0, - errorTests: 0, - skippedTests: 0 - } - break; - - case 'suite.test.start': - currentTestCaseId = event.data.testCaseId; - - const testCaseResult = testCaseResults.value.find(tr => tr.testCaseId === currentTestCaseId); - if (testCaseResult) { - testCaseResult.status = TestCaseResultStatus.RUNNING; - testCaseResult.id = event.data.resultId; - } - - executionStates.value.set(currentTestCaseId, { - phase: 'executing', - agentChunks: '', - statusUpdates: [] - }); - - if (options?.onTestStart) { - options.onTestStart(currentTestCaseId); - } - break; - - case 'suite.test.metadata': - const testResult = testCaseResults.value.find(tr => tr.testCaseId === event.data.testCaseId); - if (testResult) { - testResult.id = event.data.resultId; - if (latestSuiteRun.value) { - testResult.testSuiteRunId = latestSuiteRun.value.id; - } - } - break; - - case 'suite.test.phase': - const execState = currentTestCaseId ? executionStates.value.get(currentTestCaseId) : null; - if (execState) { - execState.phase = event.data.phase; - if (event.data.phase === 'completed') { - execState.status = event.data.status as TestCaseResultStatus; - } - } - break; - - case 'suite.test.userMessage': - const userExecState = currentTestCaseId ? executionStates.value.get(currentTestCaseId) : null; - if (userExecState) { - userExecState.userMessage = { - id: event.data.id, - text: event.data.text - } - } - break; - - case 'suite.test.agentMessage.start': - const startExecState = currentTestCaseId ? executionStates.value.get(currentTestCaseId) : null; - if (startExecState) { - startExecState.agentMessage = { - id: event.data.id, - text: '' - }; - startExecState.agentChunks = ''; - } - break; - - case 'suite.test.agentMessage.chunk': - const chunkExecState = currentTestCaseId ? executionStates.value.get(currentTestCaseId) : null; - if (chunkExecState) { - chunkExecState.agentChunks += event.data.chunk; - } - break; - - case 'suite.test.agentMessage.complete': - const completeExecState = currentTestCaseId ? executionStates.value.get(currentTestCaseId) : null; - if (completeExecState && completeExecState.agentMessage) { - completeExecState.agentMessage.text = event.data.text; - } - break; - - case 'suite.test.executionStatus': - const statusExecState = currentTestCaseId ? executionStates.value.get(currentTestCaseId) : null; - if (statusExecState) { - statusExecState.statusUpdates.push(event.data); - } - break; - - case 'suite.test.error': - if (currentTestCaseId) { - const errorExecState = executionStates.value.get(currentTestCaseId); - if (errorExecState) { - errorExecState.phase = 'completed'; - errorExecState.status = TestCaseResultStatus.ERROR; - } - handleTestCaseCompleted(currentTestCaseId, TestCaseResultStatus.ERROR); - } - break; - - case 'suite.test.complete': - const completedTestCaseId = event.data.testCaseId; - handleTestCaseCompleted(completedTestCaseId, event.data.status as TestCaseResultStatus); - executionStates.value.delete(completedTestCaseId); - break; - - case 'suite.error': - toast.error( - { component: ToastMessage, props: { message: t('suiteExecutionFailed') } }, - { timeout: 5000, icon: false } - ); - - testCaseResults.value.forEach(result => { - if (result.status === TestCaseResultStatus.RUNNING || result.status === TestCaseResultStatus.PENDING) { - result.status = TestCaseResultStatus.SKIPPED; - } - }); - - executionStates.value = new Map(); - break; - - case 'suite.complete': - if (latestSuiteRun.value) { - latestSuiteRun.value.status = event.data.status as TestSuiteRunStatus - latestSuiteRun.value.completedAt = new Date() - latestSuiteRun.value.passedTests = event.data.passed - latestSuiteRun.value.failedTests = event.data.failed - latestSuiteRun.value.errorTests = event.data.errors - latestSuiteRun.value.skippedTests = event.data.skipped - } - break; + const updatedResultId = processStreamEvent(event, { + agentId: agentId.value, + currentTestCaseResultId + }); + currentTestCaseResultId = updatedResultId ?? currentTestCaseResultId; + + if (event.type === 'suite.error') { + toast.error( + { component: ToastMessage, props: { message: t('suiteExecutionFailed') } }, + { timeout: 5000, icon: false } + ); } } } catch (error) { - if (error instanceof HttpError && error.status === 409) { - is409Error = true; - toast.info( - { component: ToastMessage, props: { message: t('suiteAlreadyRunning') } }, - { timeout: 5000, icon: false } - ) - testRunStartedByCurrentUser.value = false - startPolling() - } else { - if (currentTestCaseId) { - const testCaseResult = testCaseResults.value.find(tr => tr.testCaseId === currentTestCaseId); - if (testCaseResult) { - testCaseResult.status = TestCaseResultStatus.ERROR; - } - const execState = executionStates.value.get(currentTestCaseId); - if (execState) { - execState.phase = 'completed'; - execState.status = TestCaseResultStatus.ERROR; - } + if (currentTestCaseResultId) { + const testCaseResult = testExecutionStore.testCaseResults.find(tr => tr.id === currentTestCaseResultId); + if (testCaseResult) { + testCaseResult.status = TestCaseResultStatus.ERROR; } - handleError(error) + testExecutionStore.setExecutionState(currentTestCaseResultId, { + phase: 'completed', + status: TestCaseResultStatus.ERROR + }); } + handleError(error) } finally { - executionStates.value.clear(); - if (!is409Error) { - runningTests.value = false; - testRunStartedByCurrentUser.value = false; - } + testExecutionStore.clearExecutionStates(); + testRunStartedByCurrentUser.value = false; } } -const handleRunTests = async () => { +const runTestSuite = async (testCaseIds?: number[]) => { isEditingTestCase.value = false - runningTests.value = true testRunStartedByCurrentUser.value = true - testCaseResults.value = testCases.value.map(testCase => { - return new TestCaseResult( - testCase.thread.id, - new Date(), - TestCaseResultStatus.PENDING + try { + initializeTestRun(agentId.value!, testCasesStore.testCases, testCaseIds?.[0]) + const suiteRun = await handleOAuthRequestsIn( + () => api.runTestSuite(agentId.value!, testCaseIds), + api ) - }) - - await processSuiteExecutionStream(api.runTestSuiteStream(agentId.value!)) -} - -const handleTestCaseCompleted = (completedTestCaseId: number, status: TestCaseResultStatus) => { - const result = testCaseResults.value.find(tr => tr.testCaseId === completedTestCaseId) - if (result) { - result.status = status + testExecutionStore.setSelectedSuiteRun(suiteRun) + await processSuiteExecutionStream(api.streamTestSuiteUpdates(agentId.value!, suiteRun.id)) + } catch (error) { + if (error instanceof AuthenticationError) { + toast.error( + { component: ToastMessage, props: { message: t('authenticationCancelled') } }, + { timeout: 5000, icon: false } + ) + isEditingTestCase.value = true + testRunStartedByCurrentUser.value = false + return + } + await handleRunError(error) } } -const handleRunSingleTest = async (singleTestCaseId: number) => { - isEditingTestCase.value = false - runningTests.value = true - testRunStartedByCurrentUser.value = true - - testCaseResults.value = testCases.value.map(testCase => { - return new TestCaseResult( - testCase.thread.id, - new Date(), - testCase.thread.id === singleTestCaseId ? TestCaseResultStatus.PENDING : TestCaseResultStatus.SKIPPED - ) - }) - - await processSuiteExecutionStream(api.runTestSuiteStream(agentId.value!, [singleTestCaseId]), { - onTestStart: (testCaseId) => { - handleSelectTestCase(testCaseId) +const handleRunError = async (error: unknown) => { + if (error instanceof HttpError && error.status === 409) { + try { + toast.info( + { component: ToastMessage, props: { message: t('suiteAlreadyRunning') } }, + { timeout: 5000, icon: false } + ) + testRunStartedByCurrentUser.value = false + const suiteRuns = await api.findTestSuiteRuns(agentId.value!, 1, 0) + if (suiteRuns.length && suiteRuns[0].status === TestSuiteRunStatus.RUNNING) { + testExecutionStore.setSelectedSuiteRun(suiteRuns[0]) + const results = await loadSuiteRunResults(agentId.value!, suiteRuns[0].id) + testExecutionStore.setSelectedResult(results[0]) + await processSuiteExecutionStream(api.streamTestSuiteUpdates(agentId.value!, suiteRuns[0].id)) + } + } catch (error) { + handleError(error) } - }) + } else { + handleError(error) + testRunStartedByCurrentUser.value = false + } } onMounted(async () => { @@ -397,10 +153,38 @@ onMounted(async () => { } }); +const loadTestCases = async (id: number) => { + try { + await loadTestCasesFromStore(id) + + if(testCasesStore.testCases.length) { + testCasesStore.setSelectedTestCase(testCasesStore.testCases[0]) + } + + const suiteRuns = await api.findTestSuiteRuns(id, 1, 0) + if (suiteRuns.length && suiteRuns[0].status === TestSuiteRunStatus.RUNNING) { + isEditingTestCase.value = false + testExecutionStore.setSelectedSuiteRun(suiteRuns[0]) + const results = await loadSuiteRunResults(id, suiteRuns[0].id) + testExecutionStore.setSelectedResult(results[0]) + processSuiteExecutionStream(api.streamTestSuiteUpdates(id, suiteRuns[0].id)) + } + } catch (e) { + handleError(e) + } finally { + loadingTests.value = false + } +} + +const onEditingTestCase = (editing: boolean) => { + if(testExecutionStore.selectedSuiteRun?.status == TestSuiteRunStatus.RUNNING) return + isEditingTestCase.value = editing +} + onBeforeRouteUpdate(async (to) => { - stopPolling(); - runningTests.value = false; testRunStartedByCurrentUser.value = false; + clearTestCases(); + clearExecutionStore(); agentId.value = parseInt(to.params.agentId as string); await startChat(); @@ -408,43 +192,42 @@ onBeforeRouteUpdate(async (to) => { await loadTestCases(agentId.value); } }); - -onBeforeUnmount(() => { - stopPolling(); -}); -{ + +{ "en": { "suiteExecutionFailed": "Test suite execution failed", - "suiteAlreadyRunning": "Please wait for the test suite to finish running before starting a new execution" + "suiteAlreadyRunning": "Please wait for the test suite to finish running before starting a new execution", + "authenticationCancelled": "Tool authentication was cancelled. Please authenticate to run tests." }, "es": { "suiteExecutionFailed": "Falló la ejecución de la suite de tests", - "suiteAlreadyRunning": "Espera a que el test suite termine de correr para lanzar una nueva ejecucion" + "suiteAlreadyRunning": "Espera a que el test suite termine de correr para lanzar una nueva ejecucion", + "authenticationCancelled": "La autenticación de la herramienta fue cancelada. Por favor autentícate para ejecutar los tests." } -} +} + diff --git a/src/frontend/src/pages/FilePreviewPage.vue b/src/frontend/src/pages/FilePreviewPage.vue index d7aa9e0..268987a 100644 --- a/src/frontend/src/pages/FilePreviewPage.vue +++ b/src/frontend/src/pages/FilePreviewPage.vue @@ -129,7 +129,7 @@ const findFile = async () : Promise<[File, ProcessedContent]> => { })()]) } else { return await Promise.all([api.downloadAgentToolFile(parsedAgentId.value, toolId!, fileId), (async () => { - const docFile = await api.findAgentDocToolFile(parsedAgentId.value, toolId!, fileId) + const docFile = await api.findAgentToolFile(parsedAgentId.value, toolId!, fileId) return new ProcessedContent(docFile.status, docFile.fileProcessor, docFile.processedContent) })()]) } @@ -179,7 +179,7 @@ const reprocess = async () => { const agentId = parsedAgentId.value await api.configureAgentTool(agentId, new AgentToolConfig(toolId!, {advancedFileProcessing: newProcessor === FileProcessor.ENHANCED})) await api.updateAgentToolFile(agentId, toolId!, fileId, new File([], originalFile.value!.name, { type: originalFile.value!.type })) - let toolFile = await api.findAgentDocToolFile(agentId, toolId!, fileId) + let toolFile = await api.findAgentToolFile(agentId, toolId!, fileId) toolFile = await awaitFileProcessingCompletes(toolFile) processedContent.value = toolFile.processedContent const quotaExceeded = await checkQuotaExceeded(toolFile) @@ -194,7 +194,7 @@ const reprocess = async () => { const awaitFileProcessingCompletes = async (toolFile: DocToolFile) => { while (toolFile.status === FileStatus.PENDING) { - toolFile = await api.findAgentDocToolFile(parsedAgentId.value, toolId!, fileId) + toolFile = await api.findAgentToolFile(parsedAgentId.value, toolId!, fileId) if (toolFile.status === FileStatus.PENDING) { await new Promise((resolve) => setTimeout(resolve, 1000)) } diff --git a/src/frontend/src/pages/ToolAuthPage.vue b/src/frontend/src/pages/ToolAuthPage.vue index 5e30c12..59a4279 100644 --- a/src/frontend/src/pages/ToolAuthPage.vue +++ b/src/frontend/src/pages/ToolAuthPage.vue @@ -7,13 +7,20 @@ const route = useRoute(); const { t } = useI18n(); onBeforeMount(() => { - const { code, state } = route.query; - window.opener.postMessage({ + const { state, error, code } = route.query; + const channel = new BroadcastChannel('oauth_channel'); + try { + channel.postMessage({ type: 'oauth_callback', toolId: route.params.toolId, - code: code, state: state, - }, window.location.origin); + error: error, + code: code, + }); + } finally { + channel.close(); + window.close(); + } }); @@ -23,7 +30,7 @@ onBeforeMount(() => {
- + { "en": { "processingAuthentication": "Processing authentication..." @@ -32,4 +39,4 @@ onBeforeMount(() => { "processingAuthentication": "Procesando autenticación..." } } - \ No newline at end of file + diff --git a/src/frontend/src/services/api.ts b/src/frontend/src/services/api.ts index eadec11..01de60e 100644 --- a/src/frontend/src/services/api.ts +++ b/src/frontend/src/services/api.ts @@ -2,6 +2,7 @@ import auth from './auth' import moment from 'moment' import type { JSONSchema7 } from 'json-schema' import { UploadedFile, FileStatus, AgentPrompt } from '../../../common/src/utils/domain' +import type { StatusUpdate } from '../../../common/src/components/chat/ChatMessage.vue' export class HttpError extends Error { public status: number @@ -29,24 +30,21 @@ export class Manifest { id: string contactEmail: string auth: ManifestAuthConfig + disablePublishGlobal: boolean - constructor(id: string, contactEmail: string, auth: ManifestAuthConfig) { + constructor(id: string, contactEmail: string, auth: ManifestAuthConfig, disablePublishGlobal: boolean) { this.id = id this.contactEmail = contactEmail this.auth = auth + this.disablePublishGlobal = disablePublishGlobal } } export const GLOBAL_TEAM_ID = 1; - export const MY_TEAM_ID = 0; - export const PRIVATE_TEAM_ID = -1; - export const PRIVATE_AGENT_ID = -1; - const PRIVATE_AGENT_ICON_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAAAAXNSR0IArs4c6QAAA+NJREFUaEPtmU2IV2UUxn9PaRFoGBkVmKZ9GFFEpQRWRh+LkDYhCUVBklGR9iW5KVoMtXGIMfowWohQaKjUrlWEWS2KgsQ2ZaGDRSGlWBJl2uk+cAYGnZn/e+feYf4T/wPDfxbvfd/z3POec57zXDHFTVPcf3oAJjuCvQj8LyMQEacB04HTE+Bx4Likf9sG3OoViogzgDnAZcBiYAEQwLfAXuAbYFDSsbaAtAYgIs4F7gZWpPOzTnLyL+AL4G3gfUm/tQGiFQARcU51ZV4C7gFmA/8A+4Ef0slLgIvzWv0KbK/WPCfpcFMQbQHoB9YAZ6bTbwAfA4fSwfOApcBDwBXA38Crkp6ddAARcRuwrXLIV+h74DFg1/B7HhF+UU5qg9gIXFoB9hVaIemjJiAaRSCrzbvAcuBP4BFJW8ZyKCJWARsAJ/xbVZSeaFKdmgKYD+wArgO+Bm6XNHRtRsQREWcBnwHXAl868SX9ON4oNAWwBNgELPRblfR0iSMRMQA8lVfuYUk7S54baU1TAMuA17PCrJPkZO5oEeHkXZ+V6nFJH3R8aJQFPQC9CEDvCtXOn4hwx51XcRsn8TrgQnfW5Dkl+z2QnfvnTGYnsUmeO3Qtq53EEXF1VT0eBPxrEP4zoAPAL4WnXwBclJRi0M5XzXBPVc02S/JvsdUCEBFXVTV/K3B5dtLigwoWmmJ/V1HveyWZdhdZXQAmaOYzHlB2Z0etHfaTPHP03Mk9P/h/86hbiryHclUiIhYBHwIzgPeS9zSmw3Y06fg7wJ3AH8AdkkwzOlpxBCLC995d18+slWRW2ZpFhFnsyznBubRuLtl8PAC8b/EBJU5kFIZeUK39JwxARHg29iTmQX63pN/HAjMswpMPICJuqBL9+WSpdsilcb2kz0cD0TUAIsJJbrq8cpisciLn4GckuXmdYt0E4JpqsH+tqlg3pYxiZ90/PgVWS3L57WoAVyaAW4F96aknN5dgJ7+bVVcDsB7kgcUqxcz01LXdw06/JOtD3QsgS6L50V3Azdk3PC9vkWTOM6J1TQ4MeZcyo0Uul+ojko5OmTI6lqOTGYH7gTezNLrGDzTRc4YDSX3JKsWL2fgelWRu1NHqdGKraZ9Uytr5qeesBX7KAzseNMYCS/FzgT7gRuCgc0eSVb6OVgwgk9OK2pMp3rpEWsA1tW5i03K+MAiDeUWSo1FkdQF4dHyhGjzuA84uOqF8kbmSZcm+0br1SFvVApBRsIh7fQ42JmxtmKXFXcBXdb8b1AaQIPycxdmhT0hNQZgrHZPkrzm1bFwAap0wwYt7ACb4BXfcvheBjq9oghdM+Qj8B4wJmEDEKTttAAAAAElFTkSuQmCC'; - const PRIVATE_AGENT_ICON_BG = '1F1F1F'; export enum LlmModelType { @@ -90,6 +88,20 @@ export enum ReasoningEffort { HIGH = 'HIGH' } +export class Evaluator { + modelId: string + temperature: LlmTemperature + reasoningEffort: ReasoningEffort + prompt: string + + constructor(modelId: string, temperature: LlmTemperature, reasoningEffort: ReasoningEffort, prompt: string) { + this.modelId = modelId + this.temperature = temperature + this.reasoningEffort = reasoningEffort + this.prompt = prompt + } +} + export enum FileProcessor { BASIC = 'BASIC', ENHANCED = 'ENHANCED' @@ -171,7 +183,8 @@ export class TeamRole { export enum Role { TEAM_OWNER = "owner", - TEAM_MEMBER = "member" + TEAM_MEMBER = "member", + TEAM_EDITOR = "editor" } export class Agent { @@ -308,8 +321,9 @@ export class ThreadMessage { hasPositiveFeedback?: boolean files?: UploadedFile[] stopped?: boolean + statusUpdates: StatusUpdate[] = [] - constructor(id: number, text: string, timestamp: Date, origin: ThreadMessageOrigin, children: ThreadMessage[], minutesSaved?: number, feedbackText?: string, hasPositiveFeedback?: boolean, stopped?: boolean) { + constructor(id: number, text: string, timestamp: Date, origin: ThreadMessageOrigin, children: ThreadMessage[], minutesSaved?: number, feedbackText?: string, hasPositiveFeedback?: boolean, stopped?: boolean, statusUpdates?: StatusUpdate[]) { this.id = id this.text = text this.timestamp = timestamp @@ -319,6 +333,7 @@ export class ThreadMessage { this.feedbackText = feedbackText this.hasPositiveFeedback = hasPositiveFeedback this.stopped = stopped + this.statusUpdates = statusUpdates || [] } } @@ -455,8 +470,8 @@ export class NewUser { role: Role; constructor(username: string, role: Role) { - this.username = username; - this.role = role; + this.username = username; + this.role = role; } } @@ -559,13 +574,17 @@ export class TestCaseResult { testSuiteRunId?: number executedAt: Date status: TestCaseResultStatus + testCaseName: string + evaluatorAnalysis?: string - constructor(testCaseId: number, executedAt: Date, status: TestCaseResultStatus, id?: number, testSuiteRunId?: number) { + constructor(testCaseId: number, executedAt: Date, status: TestCaseResultStatus, testCaseName: string, id?: number, testSuiteRunId?: number, evaluatorAnalysis?: string) { this.testCaseId = testCaseId this.executedAt = executedAt this.status = status + this.testCaseName = testCaseName this.testSuiteRunId = testSuiteRunId this.id = id + this.evaluatorAnalysis = evaluatorAnalysis } } @@ -579,7 +598,6 @@ export enum TestCaseResultStatus { } export type TestSuiteExecutionStreamEvent = - | { type: 'suite.start'; data: { suiteRunId: number } } | { type: 'suite.test.start'; data: { testCaseId: number; resultId: number } } | { type: 'suite.test.metadata'; data: { testCaseId: number; resultId: number } } | { type: 'suite.test.phase'; data: { phase: string; status?: string; evaluation?: any } } @@ -589,7 +607,7 @@ export type TestSuiteExecutionStreamEvent = | { type: 'suite.test.agentMessage.complete'; data: { id: number; text: string } } | { type: 'suite.test.executionStatus'; data: any } | { type: 'suite.test.error'; data: { message: string } } - | { type: 'suite.test.complete'; data: { testCaseId: number; resultId: number; status: string } } + | { type: 'suite.test.complete'; data: { testCaseId: number; resultId: number; status: string; evaluation?: any } } | { type: 'suite.complete'; data: { suiteRunId: number; status: string; totalTests: number; passed: number; failed: number; errors: number; skipped: number } } | { type: 'suite.error'; data: {} } @@ -779,7 +797,7 @@ export class ApiService { return await this.fetchJson(`/agents/${agentId}/tools/${toolId}/files`) } - async findAgentDocToolFile(agentId: number, toolId: string, fileId: number): Promise { + async findAgentToolFile(agentId: number, toolId: string, fileId: number): Promise { return await this.fetchJson(`/agents/${agentId}/tools/${toolId}/files/${fileId}`) } @@ -812,6 +830,14 @@ export class ApiService { await this.delete(`/agents/${agentId}/tools/${toolId}/files/${fileId}`) } + async findAgentEvaluator(agentId: number): Promise { + return await this.fetchJson(`/agents/${agentId}/evaluator`) + } + + async saveAgentEvaluator(agentId: number, evaluator: Evaluator): Promise { + return await this.put(`/agents/${agentId}/evaluator`, evaluator) + } + async findAgentPrompts(agentId: number): Promise { return await this.fetchJson(`/agents/${agentId}/prompts`) } @@ -844,6 +870,10 @@ export class ApiService { return await this.put(`/agents/${agentId}/tests/${testCaseId}`, { name }) } + async cloneTestCase(agentId: number, testCaseId: number): Promise { + return await this.post(`/agents/${agentId}/tests/${testCaseId}/clone`) + } + async deleteTestCase(agentId: number, testCaseId: number) { await this.delete(`/agents/${agentId}/tests/${testCaseId}`) } @@ -852,6 +882,14 @@ export class ApiService { return await this.fetchJson(`/agents/${agentId}/tests/${testCaseId}/messages`) } + async findTestCaseEvaluator(agentId: number, testCaseId: number): Promise { + return await this.fetchJson(`/agents/${agentId}/tests/${testCaseId}/evaluator`) + } + + async saveTestCaseEvaluator(agentId: number, testCaseId: number, config: Evaluator): Promise { + return await this.put(`/agents/${agentId}/tests/${testCaseId}/evaluator`, config) + } + async addTestCaseMessage(agentId: number, testCaseId: number, message: TestCaseNewThreadMessage): Promise { return await this.fetchJson(`/agents/${agentId}/tests/${testCaseId}/messages`, 'POST', message) } @@ -860,10 +898,15 @@ export class ApiService { return await this.fetchJson(`/agents/${agentId}/tests/${testCaseId}/messages/${messageId}`, 'PUT', message) } - async *runTestSuiteStream(agentId: number, testCaseIds?: number[]): AsyncIterable { - const url = `/agents/${agentId}/tests/runs`; + async runTestSuite(agentId: number, testCaseIds?: number[]): Promise { const requestBody = testCaseIds ? { test_case_ids: testCaseIds } : {}; - const resp = await this.fetch(url, 'POST', requestBody); + const suiteRun = await this.fetchJson(`/agents/${agentId}/tests/runs`, 'POST', requestBody); + return this.parseTestSuiteRunDates(suiteRun); + } + + async *streamTestSuiteUpdates(agentId: number, suiteRunId: number): AsyncIterable { + const url = `/agents/${agentId}/tests/runs/${suiteRunId}/stream`; + const resp = await this.fetch(url, 'GET'); const contentType = resp.headers.get('content-type') if (contentType?.startsWith('text/event-stream')) { @@ -874,17 +917,21 @@ export class ApiService { } } + async stopTestSuiteRun(agentId: number, suiteRunId: number): Promise { + await this.post(`/agents/${agentId}/tests/runs/${suiteRunId}/stop`) + } + async findTestSuiteRuns(agentId: number, limit: number = 20, offset: number = 0): Promise { const searchParams = new URLSearchParams({ limit: limit.toString(), offset: offset.toString() }); const suiteRuns = await this.fetchJson(`/agents/${agentId}/tests/runs?${searchParams.toString()}`) - return suiteRuns.map((suiteRun: any) => this.parseTestSuiteRunDates(suiteRun)) + return suiteRuns.map((suiteRun: TestSuiteRun) => this.parseTestSuiteRunDates(suiteRun)) } - private parseTestSuiteRunDates(suiteRun: any): TestSuiteRun { + private parseTestSuiteRunDates(suiteRun: TestSuiteRun): TestSuiteRun { return { ...suiteRun, - executedAt: new Date(suiteRun.executedAt), - completedAt: suiteRun.completedAt ? new Date(suiteRun.completedAt) : undefined + executedAt: moment.utc(suiteRun.executedAt).toDate(), + completedAt: suiteRun.completedAt ? moment.utc(suiteRun.completedAt).toDate() : undefined } } @@ -892,6 +939,10 @@ export class ApiService { return await this.fetchJson(`/agents/${agentId}/tests/runs/${suiteRunId}/results`) } + async deleteTestSuiteRun(agentId: number, suiteRunId: number): Promise { + await this.delete(`/agents/${agentId}/tests/runs/${suiteRunId}`) + } + async findTestSuiteRunResultMessages(agentId: number, suiteRunId: number, resultId: number): Promise { return await this.fetchJson(`/agents/${agentId}/tests/runs/${suiteRunId}/results/${resultId}/messages`) } @@ -937,12 +988,12 @@ export class ApiService { } async getImpactSummary(fromDate: Date, toDate: Date, teamId: number): Promise { - const params = this.cleanSearchParams({from_date: fromDate, to_date: toDate, team_id: teamId}) + const params = this.cleanSearchParams({ from_date: fromDate, to_date: toDate, team_id: teamId }) return await this.fetchJson(`/impact/summary?${params}`) } async getImpactTopAgents(privateAgentsName: string, fromDate: Date, toDate: Date, teamId: number, search?: string, limit?: number, offset?: number, userId?: number): Promise { - const params = this.cleanSearchParams({from_date: fromDate, to_date: toDate, team_id: teamId, search: search, limit: limit, offset: offset, user_id: userId}) + const params = this.cleanSearchParams({ from_date: fromDate, to_date: toDate, team_id: teamId, search: search, limit: limit, offset: offset, user_id: userId }) const agents = await this.fetchJson(`/impact/agents?${params}`) return agents.map((agent: AgentImpactItem) => { @@ -960,17 +1011,17 @@ export class ApiService { } async getImpactTopUsers(fromDate: Date, toDate: Date, teamId: number, search?: string, limit?: number, offset?: number, agentId?: number, isExternalAgent?: boolean): Promise { - const params = this.cleanSearchParams({from_date: fromDate, to_date: toDate, team_id: teamId, search: search, limit: limit, offset: offset, agent_id: agentId, is_external_agent: isExternalAgent}) + const params = this.cleanSearchParams({ from_date: fromDate, to_date: toDate, team_id: teamId, search: search, limit: limit, offset: offset, agent_id: agentId, is_external_agent: isExternalAgent }) return await this.fetchJson(`/impact/users?${params}`) } async getUsageSummary(fromDate: Date, toDate: Date, teamId: number): Promise { - const params = this.cleanSearchParams({from_date: fromDate, to_date: toDate, team_id: teamId}) + const params = this.cleanSearchParams({ from_date: fromDate, to_date: toDate, team_id: teamId }) return await this.fetchJson(`/usage/summary?${params}`) } async getUsageTopAgents(privateAgentsName: string, fromDate: Date, toDate: Date, teamId: number, search?: string, limit?: number, offset?: number, userId?: number): Promise { - const params = this.cleanSearchParams({from_date: fromDate, to_date: toDate, team_id: teamId, search: search, limit: limit, offset: offset, user_id: userId}) + const params = this.cleanSearchParams({ from_date: fromDate, to_date: toDate, team_id: teamId, search: search, limit: limit, offset: offset, user_id: userId }) const agents = await this.fetchJson(`/usage/agents?${params}`) return agents.map((agent: AgentUsageItem) => { @@ -988,7 +1039,7 @@ export class ApiService { } async getUsageTopUsers(fromDate: Date, toDate: Date, teamId: number, search?: string, limit?: number, offset?: number, agentId?: number): Promise { - const params = this.cleanSearchParams({from_date: fromDate, to_date: toDate, team_id: teamId, search: search, limit: limit, offset: offset, agent_id: agentId}) + const params = this.cleanSearchParams({ from_date: fromDate, to_date: toDate, team_id: teamId, search: search, limit: limit, offset: offset, agent_id: agentId }) return await this.fetchJson(`/usage/users?${params}`) } @@ -1062,7 +1113,7 @@ export class ApiService { } } - private async *fetchSSEStream(resp: Response, url: string): AsyncIterable> { + private async *fetchSSEStream(resp: Response, url: string): AsyncIterable> { const reader = resp.body!.getReader() let done = false @@ -1095,8 +1146,12 @@ export class ApiService { return data.transcription } - async toolAuth(toolId: string, code: string, state: string): Promise { - await this.post(`/tools/${toolId}/oauth-callback`, { code, state }) + async completeToolAuth(toolId: string, state: string, code: string): Promise { + await this.put(`/tools/${toolId}/oauth/${state}`, { code }) + } + + async deleteToolAuth(toolId: string, state: string): Promise { + await this.delete(`/tools/${toolId}/oauth/${state}`) } async findBudgetUsage(): Promise { @@ -1166,7 +1221,7 @@ export class ApiService { } } -export class ThreadMessagePart{ +export class ThreadMessagePart { userMessage?: { id: number, files: UploadedFile[] } answerText?: string metadata?: { diff --git a/src/frontend/src/services/auth.ts b/src/frontend/src/services/auth.ts index f3ab6c2..0f4e5ef 100644 --- a/src/frontend/src/services/auth.ts +++ b/src/frontend/src/services/auth.ts @@ -45,7 +45,12 @@ class AuthService { } async loginCallback() { - await this.userManager.signinRedirectCallback() + try { + await this.userManager.signinRedirectCallback() + } catch (error) { + console.debug('Login callback failed, attempting token renewal:', error) + return await this.renewToken() + } } async getUser(): Promise { diff --git a/src/frontend/src/services/toolOAuth.ts b/src/frontend/src/services/toolOAuth.ts index 1f7aa7a..d0dc58c 100644 --- a/src/frontend/src/services/toolOAuth.ts +++ b/src/frontend/src/services/toolOAuth.ts @@ -1,6 +1,6 @@ import { HttpError, ApiService } from "@/services/api"; -export const handleOAuthRequestsIn = async (fn: () => Promise, api: ApiService) : Promise => { +export const handleOAuthRequestsIn = async (fn: () => Promise, api: ApiService): Promise => { while (true) { try { return await fn() @@ -15,7 +15,7 @@ export const handleOAuthRequestsIn = async (fn: () => Promise, api: ApiSer } } -const parseOAuthRequest = (e: unknown) : {url: string, state: string} | undefined => { +const parseOAuthRequest = (e: unknown): { url: string, state: string } | undefined => { if (!(e instanceof HttpError && e.status === 401 && e.body)) { return undefined } @@ -40,56 +40,53 @@ class OAuthRequest { } } -export class AuthenticationWindowCloseError extends Error { - constructor() { - super('Authentication window closed'); - } -} +export class AuthenticationError extends Error { + errorCode: string -export class AuthenticationCancelError extends Error { - constructor() { - super('Authentication cancelled'); + constructor(errorCode: string) { + super('Authentication error: ' + errorCode); + this.errorCode = errorCode; } } -const oauthPopupFlow = async (oauthRequest: OAuthRequest, api: ApiService) : Promise => { +const oauthPopupFlow = async (oauthRequest: OAuthRequest, api: ApiService): Promise => { return new Promise((resolve, reject) => { const popupWidth = 600; const popupHeight = 600; const left = window.screenX + (window.outerWidth - popupWidth) / 2; const top = window.screenY + (window.outerHeight - popupHeight) / 2; - const popup = window.open(oauthRequest.url, 'OAuth', `popup,width=${popupWidth},height=${popupHeight},left=${left},top=${top}`); + const popup = window.open(oauthRequest.url, 'oauth', `popup,width=${popupWidth},height=${popupHeight},left=${left},top=${top}`); if (!popup) { reject(new Error('Failed to open popup')); return; } - const checkClosed = setInterval(() => { - if (popup.closed) { - clearInterval(checkClosed); - reject(new AuthenticationWindowCloseError()); - } - }, 1000); + // using broadcast channel instead of just using popup and window opener since some mcp servers (like sentry) + // can set Cross Origin Opener Policy (COOP) which prevents the popup from accessing the opener window + const channel = new BroadcastChannel('oauth_channel'); + const cleanup = () => { + channel.close(); + channel.removeEventListener('message', handleCallback); + }; const handleCallback = async (event: MessageEvent) => { - if (!event.origin.startsWith(window.location.origin)) { - return; - } - const data = event.data; if (data.type === 'oauth_callback' && data.state === oauthRequest.state) { try { - window.removeEventListener('message', handleCallback); - clearInterval(checkClosed); - await api.toolAuth(data.toolId, data.code, data.state); - resolve() + cleanup(); + if (data.error) { + await api.deleteToolAuth(data.toolId, data.state); + reject(new AuthenticationError(data.error == 'access_denied' ? 'authenticationAccessDenied' : 'authenticationUnknownError')); + } else { + await api.completeToolAuth(data.toolId, data.state, data.code); + resolve() + } } catch (error) { if (error instanceof HttpError && error.status === 400) { try { const body = JSON.parse(error.body); if (body.detail && body.detail === "Authentication cancelled") { - reject(new AuthenticationCancelError()); - popup.close(); + reject(new AuthenticationError('authenticationCancelled')); return } } catch (_) { @@ -97,12 +94,9 @@ const oauthPopupFlow = async (oauthRequest: OAuthRequest, api: ApiService) : Pro } reject(error); } - popup.close(); } - }; - window.addEventListener('message', handleCallback); + channel.addEventListener('message', handleCallback); }); } - diff --git a/src/frontend/tsconfig.app.json b/src/frontend/tsconfig.app.json index ae7948a..eeff8f1 100644 --- a/src/frontend/tsconfig.app.json +++ b/src/frontend/tsconfig.app.json @@ -1,20 +1,24 @@ { "extends": "@vue/tsconfig/tsconfig.dom.json", "include": [ - "env.d.ts", - "components.d.ts", - "src/**/*", + "env.d.ts", + "components.d.ts", + "src/**/*", "src/**/*.vue", - "../common/src/**/*", + "../common/src/**/*", "../common/src/**/*.vue" ], - "exclude": ["src/**/__tests__/*"], + "exclude": [ + "src/**/__tests__/*" + ], "compilerOptions": { "composite": true, "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", "baseUrl": ".", "paths": { - "@/*": ["./src/*"] + "@/*": [ + "./src/*" + ] } } } diff --git a/src/frontend/tsconfig.node.json b/src/frontend/tsconfig.node.json index 7dd7285..25f20ff 100644 --- a/src/frontend/tsconfig.node.json +++ b/src/frontend/tsconfig.node.json @@ -12,9 +12,10 @@ "composite": true, "noEmit": true, "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", - "module": "ESNext", "moduleResolution": "Bundler", - "types": ["node"] + "types": [ + "node" + ] } } diff --git a/src/sample.env b/src/sample.env index 12d84e2..7b00b19 100644 --- a/src/sample.env +++ b/src/sample.env @@ -6,12 +6,14 @@ SECRET_ENCRYPTION_KEY="Lbb-6S2PzX-QZ2Fusu1nagZtrZBqKEaypGTb_kGrNg4=" OPENID_URL=http://localhost:8080/realms/tero OPENID_CLIENT_ID=tero OPENID_SCOPE=openid profile -# You can uncomment and set this in case you need to use a different openid url for frontend (this is might be necessary for production deployments) +# You can uncomment and set this in case you need to use a different openid url for frontend (this might be necessary for production deployments) # FRONTEND_OPENID_URL= -# This configuration allows you to only allow to login to the given list of users . +# This configuration allows you to only allow to login to the given list of users. # Specify the users in a comma separated list of usernames, eg: test@test.com,test2@test.com. # This is particularly handy when you use SSO to authenticate users but you want only to give access to some of them (for example in a dev environment). ALLOWED_USERS= +# Disable global team members (non-owners) from publishing agents to global team. Set to true to restrict publishing to global owners only. +DISABLE_PUBLISH_GLOBAL=false # You can uncomment this in case you want to build frontend and try hosting frontend in backend server while running dev environment # FRONTEND_PATH=../frontend/dist/ FRONTEND_URL=http://localhost:5173 @@ -38,9 +40,7 @@ TEMPERATURES=PRECISE:0,NEUTRAL:0.7,CREATIVE:1 INTERNAL_GENERATOR_MODEL=gpt-4o-mini INTERNAL_GENERATOR_TEMPERATURE=0.7 # Model for internal evaluations (e.g., determining if agent tests pass by comparing expected vs actual results). If not set, will use INTERNAL_GENERATOR_MODEL -INTERNAL_EVALUATOR_MODEL=gpt-4o-mini -# Temperature for internal evaluations. If not set, will use INTERNAL_GENERATOR_TEMPERATURE -INTERNAL_EVALUATOR_TEMPERATURE=0.3 +INTERNAL_EVALUATOR_MODEL=gpt-5-nano MONTHLY_USD_LIMIT_DEFAULT=10 # Default model for new agents. If not set, will use INTERNAL_GENERATOR_MODEL AGENT_DEFAULT_MODEL=gpt-5-mini