Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion appinfo/info.xml
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ Learn more about the Nextcloud Ethical AI Rating [in our blog](https://nextcloud
<website>https://github.com/nextcloud/integration_watsonx</website>
<bugs>https://github.com/nextcloud/integration_watsonx/issues</bugs>
<dependencies>
<nextcloud min-version="30" max-version="32"/>
<nextcloud min-version="30" max-version="33"/>
</dependencies>
<background-jobs>
<job>OCA\Watsonx\Cron\CleanupQuotaDb</job>
Expand Down
48 changes: 34 additions & 14 deletions lib/Service/WatsonxAPIService.php
Original file line number Diff line number Diff line change
Expand Up @@ -138,10 +138,11 @@ public function getModels(string $userId): array {

/**
* @param string $apiKey
* @param string $username
* @return string
* @throws Exception
*/
public function getAccessToken(string $apiKey): string {
public function getAccessToken(string $apiKey, string $username = ''): string {
if ($this->accessTokenMemoryCache !== null) {
$this->logger->debug('Getting watsonx.ai access token from the memory cache');
return $this->accessTokenMemoryCache;
Expand All @@ -156,11 +157,16 @@ public function getAccessToken(string $apiKey): string {

try {
$this->logger->debug('Actually getting IBM access token with a network request');
$params = [
'grant_type' => 'urn:ibm:params:oauth:grant-type:apikey',
'apikey' => $apiKey,
];
$accessTokenResponse = $this->requestIAM('/identity/token', $params, 'POST');
$params = $this->watsonxSettingsService->isUsingIbmCloud()
? [
'grant_type' => 'urn:ibm:params:oauth:grant-type:apikey',
'apikey' => $apiKey,
]
: [
'username' => $username,
'api_key' => $apiKey,
];
$accessTokenResponse = $this->requestIAM($params);
} catch (Exception $e) {
$this->logger->warning('Error retrieving access token (exc): ' . $e->getMessage());
$this->areCredsValid = false;
Expand All @@ -172,8 +178,15 @@ public function getAccessToken(string $apiKey): string {
throw new Exception($accessTokenResponse['error'], Http::STATUS_INTERNAL_SERVER_ERROR);
}

$accessToken = $accessTokenResponse['access_token'];
$cache->set($cacheKey, $accessToken, $accessTokenResponse['expires_in']);
if ($this->watsonxSettingsService->isUsingIbmCloud()) {
$accessToken = $accessTokenResponse['access_token'];
$cache->set($cacheKey, $accessToken, $accessTokenResponse['expires_in']);
} else {
// These tokens do not expire, so use the NC default expiration time
$accessToken = $accessTokenResponse['token'];
$cache->set($cacheKey, $accessToken);
}

$this->accessTokenMemoryCache = $accessToken;
$this->areCredsValid = true;
return $accessToken;
Expand Down Expand Up @@ -645,6 +658,9 @@ public function request(?string $userId, string $endPoint, array $params = [], s
return ['error' => 'An API key is required for watsonx.ai'];
}

// a username is mandatory only for self-hosted instances
$username = $this->watsonxSettingsService->getUserUsername($userId, true);

$serviceUrl = $this->watsonxSettingsService->getServiceUrl();
if ($serviceUrl === '') {
$serviceUrl = Application::WATSONX_API_BASE_URL;
Expand All @@ -653,7 +669,7 @@ public function request(?string $userId, string $endPoint, array $params = [], s
$url = rtrim($serviceUrl, '/') . $endPoint;
$options = [
'headers' => [
'Authorization' => 'Bearer ' . $this->getAccessToken($apiKey),
'Authorization' => 'Bearer ' . $this->getAccessToken($apiKey, $username),
'User-Agent' => Application::USER_AGENT,
],
];
Expand Down Expand Up @@ -728,20 +744,24 @@ public function request(?string $userId, string $endPoint, array $params = [], s

/**
* Make an HTTP request to the IAM Identity Services API
* @param string $endPoint The path to reach
* (or IBM Cloud Pak for Data Platform API for self-hosted clusters)
* @param array $params Query parameters (key/val pairs)
* @param string $method HTTP query method
* @return array decoded request result or error
* @throws Exception
*/
public function requestIAM(string $endPoint, array $params = [], string $method = 'GET'): array {
public function requestIAM(array $params = [], string $method = 'POST'): array {
try {
$serviceUrl = 'https://iam.cloud.ibm.com';
$url = 'https://iam.cloud.ibm.com/identity/token';

if (!$this->watsonxSettingsService->isUsingIbmCloud()) {
$serviceUrl = $this->watsonxSettingsService->getServiceUrl();
$url = rtrim($serviceUrl, '/') . '/icp4d-api/v1/authorize';
}

$url = rtrim($serviceUrl, '/') . $endPoint;
$options = [
'headers' => [
'Content-Type' => 'application/x-www-form-urlencoded',
'Content-Type' => $this->watsonxSettingsService->isUsingIbmCloud() ? 'application/x-www-form-urlencoded' : 'application/json',
'User-Agent' => Application::USER_AGENT,
],
];
Expand Down
73 changes: 70 additions & 3 deletions lib/Service/WatsonxSettingsService.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ class WatsonxSettingsService {
'request_timeout' => 'integer',
'url' => 'string',
'service_name' => 'string',
'username' => 'string',
'api_key' => 'string',
'project_id' => 'string',
'space_id' => 'string',
Expand All @@ -34,6 +35,7 @@ class WatsonxSettingsService {
];

private const USER_CONFIG_TYPES = [
'username' => 'string',
'api_key' => 'string',
'project_id' => 'string',
'space_id' => 'string',
Expand Down Expand Up @@ -61,6 +63,30 @@ public function invalidateAccessTokenCache(): void {
////////////////////////////////////////////
//////////// Getters for settings //////////

/**
* @return string
* @throws Exception
*/
public function getAdminUsername(): string {
return $this->appConfig->getValueString(Application::APP_ID, 'username');
}

/**
* SIC! Does not fall back on the admin api by default
* @param null|string $userId
* @param boolean $fallBackOnAdminValue
* @return string
* @throws Exception
*/
public function getUserUsername(?string $userId, bool $fallBackOnAdminValue = false): string {
$fallBackUsername = $fallBackOnAdminValue ? $this->getAdminUsername() : '';
if ($userId === null) {
return $fallBackUsername;
}
$userUsername = $this->config->getUserValue($userId, Application::APP_ID, 'username');
return $userUsername ?: $fallBackUsername;
}

/**
* @return string
* @throws Exception
Expand Down Expand Up @@ -233,6 +259,7 @@ public function getAdminConfig(): array {
'request_timeout' => $this->getRequestTimeout(),
'url' => $this->getServiceUrl(),
'service_name' => $this->getServiceName(),
'username' => $this->getAdminUsername(),
'api_key' => $this->getAdminApiKey(),
'project_id' => $this->getAdminProjectId(),
'space_id' => $this->getAdminSpaceId(),
Expand All @@ -252,15 +279,15 @@ public function getAdminConfig(): array {

/**
* Get the user config for the settings page
* @return array{api_key: string, project_id: string, space_id: string, is_custom_service: bool}
* @return array{username: string, api_key: string, project_id: string, space_id: string, is_custom_service: bool}
*/
public function getUserConfig(string $userId): array {
$isCustomService = $this->getServiceUrl() !== '' && !str_contains($this->getServiceUrl(), 'cloud.ibm.com');
return [
'username' => $this->getUserUsername($userId),
'api_key' => $this->getUserApiKey($userId),
'project_id' => $this->getUserProjectId($userId),
'space_id' => $this->getUserSpaceId($userId),
'is_custom_service' => $isCustomService,
'is_custom_service' => !$this->isUsingIbmCloud(),
];
}

Expand Down Expand Up @@ -298,6 +325,29 @@ public function setQuotas(array $quotas): void {
$this->appConfig->setValueString(Application::APP_ID, 'quotas', json_encode($quotas, JSON_THROW_ON_ERROR));
}

/**
* @param string $username
* @return void
*/
public function setAdminUsername(string $username): void {
// No need to validate. As long as it's a string, we're happy campers
$this->appConfig->setValueString(Application::APP_ID, 'username', $username, false, true);
$this->invalidateModelsCache();
$this->invalidateAccessTokenCache();
}

/**
* @param string $userId
* @param string $username
* @throws PreConditionNotMetException
*/
public function setUserUsername(string $userId, string $username): void {
// No need to validate. As long as it's a string, we're happy campers
$this->config->setUserValue($userId, Application::APP_ID, 'username', $username);
$this->invalidateModelsCache();
$this->invalidateAccessTokenCache();
}

/**
* @param string $apiKey
* @return void
Expand Down Expand Up @@ -490,6 +540,9 @@ public function setAdminConfig(array $adminConfig): void {
if (isset($adminConfig['service_name'])) {
$this->setServiceName($adminConfig['service_name']);
}
if (isset($adminConfig['username'])) {
$this->setAdminUsername($adminConfig['username']);
}
if (isset($adminConfig['api_key'])) {
$this->setAdminApiKey($adminConfig['api_key']);
}
Expand Down Expand Up @@ -539,6 +592,9 @@ public function setUserConfig(string $userId, array $userConfig): void {
}

// Validation of the input values is done in the individual setters
if (isset($userConfig['username'])) {
$this->setUserUsername($userId, $userConfig['username']);
}
if (isset($userConfig['api_key'])) {
$this->setUserApiKey($userId, $userConfig['api_key']);
}
Expand All @@ -564,4 +620,15 @@ public function setLlmProviderEnabled(bool $enabled): void {
public function setChatEndpointEnabled(bool $enabled): void {
$this->appConfig->setValueString(Application::APP_ID, 'chat_endpoint_enabled', $enabled ? '1' : '0');
}

////////////////////////////////////////////
//////////// Helpers for settings //////////

/**
* @return bool
* @throws Exception
*/
public function isUsingIbmCloud(): bool {
return $this->getServiceUrl() === '' || str_contains($this->getServiceUrl(), 'cloud.ibm.com');
}
}
48 changes: 40 additions & 8 deletions src/components/AdminSettings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
<NcNoteCard type="info">
{{ t('integration_watsonx', 'This should include the domain name and API base path of your watsonx.ai instance. This URL will be accessed by your Nextcloud server.') }}
</NcNoteCard>
<div v-if="state.url !== '' && !state.url.includes('cloud.ibm.com')" class="line">
<div v-if="!isUsingIbmCloud" class="line">
<NcTextField
id="watsonx-service-name"
class="input"
Expand Down Expand Up @@ -88,26 +88,51 @@
<h2>
{{ t('integration_watsonx', 'Authentication') }}
</h2>
<div v-if="!isUsingIbmCloud" class="line">
<NcTextField
id="watsonx-username"
class="input"
:value.sync="state.username"
:label="t('integration_watsonx', 'Username (mandatory)')"
:show-trailing-button="!!state.username"
@update:value="onInput()"
@trailing-button-click="state.username = '' ; onInput()">
<AccountOutlineIcon />
</NcTextField>
<NcButton type="tertiary"
:title="t('integration_watsonx', 'A username is required to authenticate to a self-hosted service (via IBM Cloud Pak for Data)')">
<template #icon>
<HelpCircleOutlineIcon />
</template>
</NcButton>
</div>
<div class="line">
<NcTextField
id="watsonx-api-key"
class="input"
:value.sync="state.api_key"
type="password"
:readonly="readonly"
:label="t('integration_watsonx', 'API key (mandatory with watsonx.ai)')"
:label="t('integration_watsonx', 'API key (mandatory)')"
:show-trailing-button="!!state.api_key"
@update:value="onSensitiveInput(true)"
@trailing-button-click="state.api_key = '' ; onSensitiveInput(true)"
@focus="readonly = false">
<KeyOutlineIcon />
</NcTextField>
</div>
<NcNoteCard v-show="state.url === '' || state.url.includes('cloud.ibm.com')" type="info">
<NcNoteCard v-if="isUsingIbmCloud" type="info">
{{ t('integration_watsonx', 'You can create an API key in your IBM Cloud IAM account settings') }}:
<br>
<a :href="apiKeyUrl" target="_blank" class="external">
{{ apiKeyUrl }}
<a :href="apiKeyDocsUrl" target="_blank" class="external">
{{ apiKeyDocsUrl }}
</a>
</NcNoteCard>
<NcNoteCard v-else type="info">
{{ t('integration_watsonx', 'You can create an API key in your IBM Cloud Pak for Data settings') }}:
<br>
<a :href="customServiceApiKeyDocsUrl" target="_blank" class="external">
{{ customServiceApiKeyDocsUrl }}
</a>
</NcNoteCard>
</div>
Expand Down Expand Up @@ -194,7 +219,7 @@
:no-wrap="true"
input-id="watsonx-model-select"
@input="onModelSelected('text', $event)" />
<a v-if="state.url === '' || state.url.includes('cloud.ibm.com')"
<a v-if="isUsingIbmCloud"
:title="t('integration_watsonx', 'More information about IBM watsonx.ai as a Service')"
href="https://cloud.ibm.com/apidocs/watsonx-ai"
target="_blank">
Expand Down Expand Up @@ -351,6 +376,7 @@
</template>

<script>
import AccountOutlineIcon from 'vue-material-design-icons/AccountOutline.vue'
import CloseIcon from 'vue-material-design-icons/Close.vue'
import EarthIcon from 'vue-material-design-icons/Earth.vue'
import HelpCircleOutlineIcon from 'vue-material-design-icons/HelpCircleOutline.vue'
Expand All @@ -377,6 +403,7 @@ export default {
name: 'AdminSettings',

components: {
AccountOutlineIcon,
KeyOutlineIcon,
CloseIcon,
EarthIcon,
Expand All @@ -399,7 +426,8 @@ export default {
selectedModel: {
text: null,
},
apiKeyUrl: 'https://cloud.ibm.com/docs/account?topic=account-iamtoken_from_apikey',
apiKeyDocsUrl: 'https://cloud.ibm.com/docs/account?topic=account-userapikey',
customServiceApiKeyDocsUrl: 'https://www.ibm.com/docs/en/cloud-paks/cp-data/5.0.x?topic=steps-generating-api-keys',
quotaInfo: null,
llmExtraParamHint: t('integration_watsonx', 'JSON object. Check the API documentation to get the list of all available parameters. For example: {example}', { example: '{"stop":".","temperature":0.7}' }, null, { escape: false, sanitize: false }),
DEFAULT_MODEL_ITEM,
Expand All @@ -415,7 +443,7 @@ export default {
return this.state.url.replace(/\/*$/, '/ml/v1/foundation_model_specs')
},
configured() {
return !!this.state.api_key
return !!this.state.api_key && (this.isUsingIbmCloud || !!this.state.username)
},
formattedModels() {
if (this.models) {
Expand All @@ -429,6 +457,9 @@ export default {
}
return []
},
isUsingIbmCloud() {
return this.state.url === '' || this.state.url.includes('cloud.ibm.com')
},
},

mounted() {
Expand Down Expand Up @@ -552,6 +583,7 @@ export default {
onInput: debounce(async function() {
const values = {
service_name: this.state.service_name,
username: this.state.username,
request_timeout: parseInt(this.state.request_timeout),
chunk_size: parseInt(this.state.chunk_size),
max_tokens: parseInt(this.state.max_tokens),
Expand Down
Loading
Loading