Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
59a7b06
feat: AI gateway integration via deployment region config
ddecrulle Apr 28, 2026
386656f
following up
ddecrulle May 5, 2026
553843e
improve aiModel
ddecrulle Jun 9, 2026
c57e81c
rebase with correct index
ddecrulle Jun 9, 2026
fe6fea1
wip
ddecrulle Jun 18, 2026
4342f34
fix(ai): disable DPoP for the OpenWebUI token exchange OIDC client
ddecrulle Jun 19, 2026
ce04391
feat(ai): remove the embeddings model selection
ddecrulle Jun 19, 2026
0bc8513
review codes
ddecrulle Jun 25, 2026
0f00457
add env var to enable ai feature (disabled by default)
ddecrulle Jun 25, 2026
9a18c27
add provider with openai as default
ddecrulle Jun 26, 2026
989498b
remove mock and handle provider correctly
ddecrulle Jun 29, 2026
674d985
improve ai x onyxia context et selectors
ddecrulle Jun 29, 2026
bfcd005
Update onyxiaApi.ts
ddecrulle Jun 30, 2026
518a5a7
Update env.ts
ddecrulle Jun 30, 2026
1c8e628
improve initialize
ddecrulle Jun 30, 2026
8f81c0b
use overrideDefaultWith earlier
ddecrulle Jul 1, 2026
40a36ab
improve design
ddecrulle Jul 1, 2026
64c9bc3
overwriteListEnumWith relative path in array items
ddecrulle Jul 6, 2026
46d9e11
improve test
ddecrulle Jul 7, 2026
a97561e
split AccountAiTab in multiples files
ddecrulle Jul 8, 2026
cc4bce8
implement AI provider settings design
ddecrulle Jul 15, 2026
a4d2408
improve code
ddecrulle Jul 16, 2026
9fc2bfd
improve code
ddecrulle Jul 16, 2026
614b024
improve custom ai dialog
ddecrulle Jul 17, 2026
5a02100
improve css
ddecrulle Jul 17, 2026
f25c649
fix: add ai field to mock deployment region after rebase
ddecrulle Aug 24, 2026
0dd7fcb
fix spacing according to figma
ddecrulle Aug 24, 2026
55c0d9e
css and language improvement
ddecrulle Aug 25, 2026
3c2094d
enable AI by default
ddecrulle Aug 26, 2026
b6cd906
Move AI gateway config to web env
ddecrulle Aug 26, 2026
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
14 changes: 14 additions & 0 deletions web/.env
Original file line number Diff line number Diff line change
Expand Up @@ -862,6 +862,20 @@ DISABLE_DISPLAY_ALL_CATALOG=false
# See: https://docs.onyxia.sh/admin-doc/s3-configuration
S3=

# AI gateways displayed in Account > AI. This parameter accepts a JSON5 object or
# array of objects. An empty value means that no managed gateway is configured;
# users can still add custom OpenAI-compatible providers.
# This configuration is exposed to the browser and must not contain secrets.
AI=

# Switch to disable the AI feature (managed gateways and user-added custom
# OpenAI-compatible providers).
#
# The AI feature is enabled by default. Set this parameter to "true" to hide it
# entirely, even when AI gateways are configured.
DISABLE_AI=false


# ==================================================================================
# Private parameters - Not expected to be positioned manually, handled by the helm Chat.
# ==================================================================================
Expand Down
108 changes: 108 additions & 0 deletions web/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Commands

All commands use **Yarn** (not npm).

```bash
yarn dev # Start dev server (processes env YAML first via scripts/unyamlify-env-local.ts)
yarn build # Type-check (tsc) then build for production
yarn test # Run all tests once (Vitest, non-watch)
yarn format # Format all .ts/.tsx/.json/.md files with Prettier
yarn format:check # Check formatting without writing
yarn storybook # Launch Storybook on port 6006
```

**Run a single test file:**

```bash
yarn vitest run src/core/usecases/launcher/decoupledLogic/computeHelmValues.test.ts
```

**Run tests matching a name pattern:**

```bash
yarn vitest run --reporter=verbose -t "pattern"
```

Pre-commit hooks run `eslint --fix` and `prettier --write` via lint-staged.

## Architecture

Onyxia Web is a React SPA — a data science platform portal for launching Kubernetes services (Helm charts), browsing catalogs, managing S3 files, managing Vault secrets, and querying data via DuckDB. It is deployed as static files served by nginx.

### Core principles

- **React is only for rendering.** Business logic is React-agnostic and lives in `src/core/`. The `src/ui/` layer is strictly for React components and hooks.
- **Unidirectional dependencies.** `src/core/` never imports from `src/ui/`, not even for types.
- **Reactive over promise-based.** Thunks update observable state; the UI reacts to state changes. Prefer dispatching actions and reading state over returning values from thunks.
- **Constants outside Redux state.** Values that don't change are not stored in state — they are retrieved from thunks when needed, to avoid unnecessary re-renders.

### `src/core/` — Business logic

Follows a clean-architecture / ports-and-adapters pattern using the `clean-architecture` npm package (a Redux-like store without Redux).

- **`ports/`** — TypeScript interfaces defining contracts for external dependencies (`OnyxiaApi`, `Oidc`, `S3Client`, `SecretsManager`, `SqlOlap`).
- **`adapters/`** — Concrete implementations: `onyxiaApi/` (axios-based HTTP), `oidc/` (oidc-spa), `s3Client/` (AWS SDK v3), `secretManager/` (Vault), `sqlOlap/` (DuckDB WASM). Each adapter has a mock counterpart for dev/testing.
- **`usecases/`** — One folder per feature (20+ total: `catalog`, `launcher`, `serviceManagement`, `fileExplorer`, `secretExplorer`, `dataExplorer`, etc.). Each usecase follows the pattern:
- `state.ts` — state shape + `createUsecaseActions` (slice-like)
- `thunks.ts` — async side effects, accesses adapters via `createUsecaseContextApi`
- `selectors.ts` — memoized state derivations
- `index.ts` — re-exports all three
- **`bootstrap.ts`** — Wires adapters together and creates the core store.
- **`index.ts`** — Exports `useCoreState`, `getCore`, `createReactApi` bindings consumed by `src/ui/`.

**Complex use-cases** (especially `launcher/`) have a `decoupledLogic/` subfolder with pure functions and no framework dependencies — this is where most unit tests live.

### `src/ui/` — React layer

- **`App/`** — Root layout: Header, LeftBar, Main, Footer. `App.tsx` triggers core bootstrap; `Main.tsx` is the route-based page switcher.
- **`pages/`** — One folder per route/page. Each page exports `routeDefs` (via `type-route`'s `defineRoute`) and `routeGroup`. All are merged in `pages/index.ts`.
- **`routes.tsx`** — Router instantiation. Navigation uses `routes.catalog(...).push()` or `session.push()`.
- **`i18n/`** — i18nifty setup. Translation keys are declared at the component level via `declareComponentKeys`, collected into a `ComponentKey` union in `i18n/types.ts`. Nine languages: en, fr, zh-CN, no, fi, nl, it, es, de.
- **`theme/`** — onyxia-ui theme setup (palette, fonts, favicon).
- **`shared/`** — Reusable components (CommandBar, CodeBlock, SettingField, etc.).

### Key patterns

**Consuming core state in React:**

```ts
import { useCoreState, getCore } from "core";
const helmReleases = useCoreState(state => state.serviceManagement.helmReleases);
await getCore().dispatch(usecases.serviceManagement.thunks.initialize());
```

**Styling — tss-react** (not plain CSS modules):

```ts
import { tss } from "tss";
const useStyles = tss.withName({ MyComponent }).create(({ theme }) => ({ ... }));
const { classes, cx } = useStyles();
```

**Absolute imports** — `tsconfig.json` sets `baseUrl: "src"`, so use `import { foo } from "core/usecases/catalog"` (not relative paths).

**Environment variables** — All env vars are centrally parsed and validated in `src/env.ts`. The `index.html` is an EJS template processed by `vite-envs` at build time.

**Authentication** — OIDC init (`oidc-spa`) happens before React renders, in `main.tsx`. Use the `Oidc` port interface, not the adapter directly.

**Plugin system** — `src/pluginSystem.ts` exposes `window.onyxia` after boot and fires an `"onyxiaready"` `CustomEvent`, allowing external JS to interact with core state, routes, theme, and i18n.

**Keycloak theme** — `src/keycloak-theme/` is a Keycloakify login theme that shares env and i18n infrastructure with the main app. Build with `yarn build-keycloak-theme`.

## Key libraries

| Library | Role |
| -------------------- | ------------------------------------------------------------ |
| `onyxia-ui` | In-house design system on top of MUI v6 |
| `type-route` | Strongly-typed client-side router |
| `i18nifty` | Component-level i18n |
| `clean-architecture` | Redux-like store (ports/usecases pattern) |
| `oidc-spa` | OIDC/OAuth2 authentication |
| `keycloakify` | Keycloak login theme from React components |
| `tss-react` | CSS-in-JS bound to onyxia-ui theme |
| `vite-envs` | Env var injection into EJS `index.html` at build time |
| DuckDB WASM | In-browser SQL OLAP queries (`dataExplorer`, `sqlOlapShell`) |
1 change: 1 addition & 0 deletions web/src/core/adapters/ai/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./openWebUi";
67 changes: 67 additions & 0 deletions web/src/core/adapters/ai/openWebUi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import type { Ai, GetTokenResult } from "core/ports/Ai";
import { oidcTokenExchange, OidcTokenExchangeError } from "core/tools/oidcTokenExchange";
import { z } from "zod";

export function createAi(params: {
id: string;
name: string;
provider: Ai["provider"];
description: Ai["description"];
accountCreation: Ai["accountCreation"];
webUiUrl: string;
oauthProvider: string;
getOidcAccessToken: () => Promise<string>;
}): Ai {
const {
id,
name,
provider,
description,
accountCreation,
webUiUrl,
oauthProvider,
getOidcAccessToken
} = params;

const apiBase = `${webUiUrl}/api`;

return {
id,
name,
provider,
description,
accountCreation,
webUiUrl,
apiBase,
getToken: async (): Promise<GetTokenResult> => {
const oidcAccessToken = await getOidcAccessToken();
Comment on lines +36 to +37

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Handle OIDC token retrieval failures inside the adapter

When getOidcAccessToken() rejects, for example because refreshing the gateway-specific OIDC session fails, this await occurs before the exchange's .catch() and therefore escapes getToken() instead of returning { status: "error" }. The initialization-wide try in ai/thunks.ts then dispatches initializationFailed, hiding every other managed and custom provider because one gateway's OIDC client failed.

Useful? React with 👍 / 👎.


return oidcTokenExchange({
tokenExchangeEndpoint: `${webUiUrl}/api/v1/auths/oauth/${oauthProvider}/token/exchange`,
oidcAccessToken
})
.then(token => ({ status: "success" as const, token }))
.catch((error: unknown) => {
if (error instanceof OidcTokenExchangeError && error.status === 403) {
return { status: "no-account" as const };
}
return { status: "error" as const };
});
},
listModels: async (token: string) => {
const response = await fetch(`${apiBase}/models`, {
headers: { Authorization: `Bearer ${token}` }
});

if (!response.ok) {
throw new Error(`Failed to list models (${response.status})`);
}

const { data } = z
.object({ data: z.array(z.object({ id: z.string(), name: z.string() })) })
.parse(await response.json());

return data.map(({ id, name }) => ({ id, name }));
}
};
}
13 changes: 11 additions & 2 deletions web/src/core/adapters/oidc/oidc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@ export async function createOidc<AutoLogin extends boolean>(
getCurrentLang: () => Language;
autoLogin: AutoLogin;
enableDebugLogs: boolean;
/**
* Opt this specific OIDC client instance out of DPoP.
* Use it when the access token has to be handed over to a third party that
* will use it on the user's behalf (e.g. an OpenWebUI token exchange): such
* a party cannot present a DPoP proof, so the token must not be sender-constrained.
*/
disableDPoP?: true;
}
): Promise<AutoLogin extends true ? Oidc.LoggedIn : Oidc> {
const {
Expand All @@ -29,7 +36,8 @@ export async function createOidc<AutoLogin extends boolean>(
extraQueryParams_raw,
idleSessionLifetimeInSeconds,
autoLogin,
enableDebugLogs
enableDebugLogs,
disableDPoP
} = params;

const extraQueryParams_raw_normalized = extraQueryParams_raw
Expand Down Expand Up @@ -99,7 +107,8 @@ export async function createOidc<AutoLogin extends boolean>(
extraTokenParams,
idleSessionLifetimeInSeconds,
debugLogs: enableDebugLogs,
autoLogin
autoLogin,
...(disableDPoP ? { disableDPoP } : {})
});

return oidc;
Expand Down
97 changes: 94 additions & 3 deletions web/src/core/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import type { SqlOlap } from "core/ports/SqlOlap";
import { usecases } from "./usecases";
import type { SecretsManager } from "core/ports/SecretsManager";
import type { Ai } from "core/ports/Ai";
import type { Oidc } from "core/ports/Oidc";
import type { Language } from "core/ports/OnyxiaApi/Language";
import { createDuckDbSqlOlap } from "core/adapters/sqlOlap";
Expand All @@ -16,6 +17,7 @@
import { assert } from "tsafe/assert";
import { fnv1aHashToHex } from "core/tools/fnv1aHashToHex";
import { type S3Config, parseS3ConfigFromEnvValue } from "core/ports/OnyxiaApi/S3Config";
import { parseAiConfigFromEnvValue } from "core/ports/OnyxiaApi/AiConfig";
import { setRootContext } from "./rootContext";

export type ParamsOfBootstrapCore = {
Expand All @@ -31,8 +33,10 @@
isAuthGloballyRequired: boolean;
enableOidcDebugLogs: boolean;
disableDisplayAllCatalog: boolean;
isAiEnabled: boolean;
getIsDarkModeEnabled: () => boolean;
S3_envValue: string;
AI_envValue: string;
};

export type Context = {
Expand All @@ -42,6 +46,7 @@
secretsManager: SecretsManager;
sqlOlap: SqlOlap;
s3Config: S3Config;
ai: Ai[];
};

export type Core = GenericCore<typeof usecases, Context>;
Expand All @@ -53,7 +58,8 @@
onyxiaApiUrl,
transformBeforeRedirectForKeycloakTheme,
getCurrentLang,
enableOidcDebugLogs
enableOidcDebugLogs,
isAiEnabled
} = params;

const isAuthGloballyRequired =
Expand All @@ -65,6 +71,10 @@
envValue: params.S3_envValue
});

const aiConfig = isAiEnabled
? parseAiConfigFromEnvValue({ envValue: params.AI_envValue })
: { entries: [] };

let oidc: Oidc | undefined = undefined;

const onyxiaApi: OnyxiaApi = await (async () => {
Expand Down Expand Up @@ -181,7 +191,6 @@

if (isAuthGloballyRequired && !oidc.isUserLoggedIn) {
await oidc.login({ doesCurrentHrefRequiresAuth: true });
// NOTE: Never reached
}

const context: Context = {
Expand Down Expand Up @@ -222,7 +231,8 @@
};
}
}),
s3Config
s3Config,
ai: []
};

setRootContext(context);
Expand Down Expand Up @@ -339,7 +349,7 @@
await dispatch(usecases.userProfileForm.protectedThunks.initialize());
}

init_s3ProfilesManagement: {

Check warning on line 352 in web/src/core/bootstrap.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor the code to remove this label and the need for it.

See more on https://sonarcloud.io/project/issues?id=InseeFrLab_onyxia&issues=AaA-Kt4qKEkWvzxRyAdK&open=AaA-Kt4qKEkWvzxRyAdK&pullRequest=1072

Check warning on line 352 in web/src/core/bootstrap.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this "init_s3ProfilesManagement" label.

See more on https://sonarcloud.io/project/issues?id=InseeFrLab_onyxia&issues=AaA-Kt4qKEkWvzxRyAdJ&open=AaA-Kt4qKEkWvzxRyAdJ&pullRequest=1072
if (!oidc.isUserLoggedIn) {
break init_s3ProfilesManagement;
}
Expand All @@ -347,6 +357,87 @@
await dispatch(usecases.s3ProfilesManagement.protectedThunks.initialize());
}

init_ai: {

Check warning on line 360 in web/src/core/bootstrap.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor the code to remove this label and the need for it.

See more on https://sonarcloud.io/project/issues?id=InseeFrLab_onyxia&issues=AaAy3cIUk0c36RYjsSbS&open=AaAy3cIUk0c36RYjsSbS&pullRequest=1072

Check warning on line 360 in web/src/core/bootstrap.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this "init_ai" label.

See more on https://sonarcloud.io/project/issues?id=InseeFrLab_onyxia&issues=AaAy3cIUk0c36RYjsSbR&open=AaAy3cIUk0c36RYjsSbR&pullRequest=1072
if (!isAiEnabled) {
break init_ai;
}

if (!oidc.isUserLoggedIn) {
break init_ai;
}

// Wire one Ai adapter per instance-configured gateway into `context.ai` (none
// if the AI env is empty: only custom providers will then be loaded).
configured_ai: {

Check warning on line 371 in web/src/core/bootstrap.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor the code to remove this label and the need for it.

See more on https://sonarcloud.io/project/issues?id=InseeFrLab_onyxia&issues=AaA-O-ALiNaOJDedeMTn&open=AaA-O-ALiNaOJDedeMTn&pullRequest=1072

Check warning on line 371 in web/src/core/bootstrap.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this "configured_ai" label.

See more on https://sonarcloud.io/project/issues?id=InseeFrLab_onyxia&issues=AaA-O-ALiNaOJDedeMTm&open=AaA-O-ALiNaOJDedeMTm&pullRequest=1072
if (aiConfig.entries.length === 0) {
break configured_ai;
}

const [{ createAi }, { createOidc, mergeOidcParams }, { oidcParams }] =
await Promise.all([
import("core/adapters/ai"),
import("core/adapters/oidc"),
onyxiaApi.getAvailableRegionsAndOidcParams()
]);

assert(oidcParams !== undefined);
Comment on lines +376 to +383

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Handle missing global OIDC params here instead of asserting.

bootstrapCore already accepts getAvailableRegionsAndOidcParams() returning undefined earlier in this file (Lines 120-127). If that happens again here, assert(oidcParams !== undefined) turns deploymentRegion.ai into a bootstrap-time crash and prevents custom-provider init from continuing. This branch should just skip region-backed adapters when no base OIDC config exists.

Suggested fix
-            assert(oidcParams !== undefined);
+            if (oidcParams === undefined) {
+                break region_ai;
+            }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const [{ createAi }, { createOidc, mergeOidcParams }, { oidcParams }] =
await Promise.all([
import("core/adapters/ai"),
import("core/adapters/oidc"),
onyxiaApi.getAvailableRegionsAndOidcParams()
]);
assert(oidcParams !== undefined);
const [{ createAi }, { createOidc, mergeOidcParams }, { oidcParams }] =
await Promise.all([
import("core/adapters/ai"),
import("core/adapters/oidc"),
onyxiaApi.getAvailableRegionsAndOidcParams()
]);
if (oidcParams === undefined) {
break region_ai;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/src/core/bootstrap.ts` around lines 301 - 308, `bootstrapCore` should not
crash when `onyxiaApi.getAvailableRegionsAndOidcParams()` returns undefined in
this branch; replace the `assert(oidcParams !== undefined)` in the `Promise.all`
result handling with a guard that skips the region-backed OIDC/Ai adapter setup
when no global OIDC config exists. Use the existing `createAi`, `createOidc`,
and `mergeOidcParams` flow to continue custom-provider initialization only when
`oidcParams` is present, and keep the fallback behavior consistent with the
earlier undefined handling in `bootstrapCore`.


// Providers may share the same OIDC client: oidc-spa identifies a client by
// issuerUri + clientId, so creating it twice would collide. Create each
// distinct client only once.
const getOidcAccessTokenByOidcKey = new Map<string, () => Promise<string>>();

for (const aiConfigEntry of aiConfig.entries) {
const oidcParams_ai = mergeOidcParams({
oidcParams,
oidcParams_partial: aiConfigEntry.oidcParams
});
Comment on lines +391 to +394

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Security: Require a dedicated audience for AI token exchange

When a managed gateway is configured without a complete oidcConfiguration, this merge inherits the Onyxia API issuer, client, and audience; the resulting client then disables DPoP and posts its bearer token to the gateway. A malicious or compromised gateway can replay that token as the victim against /my-lab operations instead of receiving only an AI-scoped assertion. Require a distinct AI client and audience that Onyxia/Vault reject, or broker a one-time audience-bound exchange; never fall back to the primary client.

Useful? React with 👍 / 👎.


const oidcKey = `${oidcParams_ai.issuerUri}\0${oidcParams_ai.clientId}`;

let getOidcAccessToken = getOidcAccessTokenByOidcKey.get(oidcKey);

if (getOidcAccessToken === undefined) {
const oidc_ai = await createOidc({
...oidcParams_ai,
transformBeforeRedirectForKeycloakTheme,
getCurrentLang,
autoLogin: true,
enableDebugLogs: enableOidcDebugLogs,
// The access token is handed over to OpenWebUI's token exchange
// endpoint, which validates it server-side and cannot present a
// DPoP proof. It must therefore be a plain bearer token, never
// sender-constrained, even when DPoP is globally enabled.
disableDPoP: true
});

getOidcAccessToken = async () =>
(await oidc_ai.getTokens()).accessToken;

getOidcAccessTokenByOidcKey.set(oidcKey, getOidcAccessToken);
}

context.ai.push(
createAi({
id: aiConfigEntry.id,
name: aiConfigEntry.name ?? new URL(aiConfigEntry.url).hostname,
provider: aiConfigEntry.provider,
description: aiConfigEntry.description,
accountCreation: aiConfigEntry.accountCreation,
webUiUrl: aiConfigEntry.url,
oauthProvider: aiConfigEntry.oauthProvider,
getOidcAccessToken
})
);
}
}

// Sole initiator of the AI use-case, dispatched only now that any managed
// adapters are wired into `context.ai`. Fire-and-forget so app start isn't
// blocked; consumers await readiness via `ai...waitForInitialization`.
dispatch(usecases.ai.protectedThunks.initialize());
}

pluginSystemInitCore({ core, context });

return { core };
Expand Down
Loading
Loading