Skip to content

Commit 9b15171

Browse files
ddecrulleclaude
andcommitted
feat: AI gateway integration via deployment region config
- Add AI usecase (state/thunks/selectors) with initializeStart/initializeSucceed/initializeFailed lifecycle actions - getToken() returns a discriminated result type in the Ai port — no-account (403) vs error cases handled without leaking adapter details into usecases - Gracefully disable AI features on init failure; show a link to the gateway URL when user has no account - Add AccountAiGatewayTab with token/model display and full i18n for all 9 languages Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 5f1e818 commit 9b15171

29 files changed

Lines changed: 837 additions & 18 deletions

web/src/core/adapters/ai/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from "./openWebUi";

web/src/core/adapters/ai/mock.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import type { Ai } from "core/ports/Ai";
2+
3+
export function createAi(params: { webUiUrl: string }): Ai {
4+
const { webUiUrl } = params;
5+
6+
return {
7+
webUiUrl,
8+
apiBase: `${webUiUrl}/api`,
9+
getToken: async () => ({ status: "success" as const, token: "mock-ai-token" }),
10+
listModels: async () => ["llama3.2", "mistral-7b", "codestral"]
11+
};
12+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import type { Ai, GetTokenResult } from "core/ports/Ai";
2+
import { oidcTokenExchange, OidcTokenExchangeError } from "core/tools/oidcTokenExchange";
3+
4+
export function createAi(params: {
5+
webUiUrl: string;
6+
oauthProvider: string;
7+
getOidcAccessToken: () => Promise<string>;
8+
}): Ai {
9+
const { webUiUrl, oauthProvider, getOidcAccessToken } = params;
10+
11+
const apiBase = `${webUiUrl}/api`;
12+
13+
return {
14+
webUiUrl,
15+
apiBase,
16+
getToken: async (): Promise<GetTokenResult> => {
17+
const oidcAccessToken = await getOidcAccessToken();
18+
19+
return oidcTokenExchange({
20+
tokenExchangeEndpoint: `${webUiUrl}/api/v1/auths/oauth/${oauthProvider}/token/exchange`,
21+
oidcAccessToken
22+
})
23+
.then(token => ({ status: "success" as const, token }))
24+
.catch((error: unknown) => {
25+
if (error instanceof OidcTokenExchangeError && error.status === 403) {
26+
return { status: "no-account" as const };
27+
}
28+
return { status: "error" as const };
29+
});
30+
},
31+
listModels: async (token: string) => {
32+
const response = await fetch(`${apiBase}/models`, {
33+
headers: { Authorization: `Bearer ${token}` }
34+
});
35+
36+
if (!response.ok) {
37+
throw new Error(`Failed to list models (${response.status})`);
38+
}
39+
40+
const data = await response.json();
41+
42+
return (data.data as { id: string }[]).map(m => m.id);
43+
}
44+
};
45+
}

web/src/core/adapters/onyxiaApi/ApiTypes.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,11 @@ export type ApiTypes = {
8282
};
8383
};
8484
data?: {
85+
ai?: {
86+
URL: string;
87+
oauthProvider: string;
88+
oidcConfiguration?: Partial<ApiTypes.OidcConfiguration>;
89+
};
8590
S3?: ArrayOrNot<{
8691
URL: string;
8792
pathStyleAccess?: true;

web/src/core/adapters/onyxiaApi/onyxiaApi.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -435,6 +435,17 @@ export function createOnyxiaApi(params: {
435435
apiRegion.vault.oidcConfiguration
436436
)
437437
},
438+
ai:
439+
apiRegion.data?.ai === undefined
440+
? undefined
441+
: {
442+
url: apiRegion.data.ai.URL,
443+
oauthProvider: apiRegion.data.ai.oauthProvider,
444+
oidcParams:
445+
apiTypesOidcConfigurationToOidcParams_Partial(
446+
apiRegion.data.ai.oidcConfiguration
447+
)
448+
},
438449
proxyInjection:
439450
apiRegion.proxyInjection === undefined
440451
? undefined

web/src/core/bootstrap.ts

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type { OnyxiaApi } from "core/ports/OnyxiaApi";
88
import type { SqlOlap } from "core/ports/SqlOlap";
99
import { usecases } from "./usecases";
1010
import type { SecretsManager } from "core/ports/SecretsManager";
11+
import type { Ai } from "core/ports/Ai";
1112
import type { Oidc } from "core/ports/Oidc";
1213
import type { Language } from "core/ports/OnyxiaApi/Language";
1314
import { createDuckDbSqlOlap } from "core/adapters/sqlOlap";
@@ -38,6 +39,7 @@ export type Context = {
3839
onyxiaApi: OnyxiaApi;
3940
secretsManager: SecretsManager;
4041
sqlOlap: SqlOlap;
42+
ai: Ai | undefined;
4143
};
4244

4345
export type Core = GenericCore<typeof usecases, Context>;
@@ -83,7 +85,6 @@ export async function bootstrapCore(
8385
);
8486
} catch (error) {
8587
if (error instanceof AccessError) {
86-
// NOTE: Not initialized yet, it's not a bug.
8788
return undefined;
8889
}
8990
throw error;
@@ -105,7 +106,6 @@ export async function bootstrapCore(
105106
);
106107
} catch (error) {
107108
if (error instanceof AccessError) {
108-
// NOTE: Not initialized yet, it's not a bug.
109109
return undefined;
110110
}
111111
throw error;
@@ -137,7 +137,6 @@ export async function bootstrapCore(
137137

138138
if (isAuthGloballyRequired && !oidc.isUserLoggedIn) {
139139
await oidc.login({ doesCurrentHrefRequiresAuth: true });
140-
// NOTE: Never reached
141140
}
142141

143142
const context: Context = {
@@ -177,7 +176,8 @@ export async function bootstrapCore(
177176
s3_region: s3Profile.paramsOfCreateS3Client.region
178177
};
179178
}
180-
})
179+
}),
180+
ai: undefined
181181
};
182182

183183
const { core, dispatch, getState } = createCore({
@@ -275,6 +275,49 @@ export async function bootstrapCore(
275275
await dispatch(usecases.s3ProfilesManagement.protectedThunks.initialize());
276276
}
277277

278+
init_ai: {
279+
if (!oidc.isUserLoggedIn) {
280+
break init_ai;
281+
}
282+
283+
const deploymentRegion =
284+
usecases.deploymentRegionManagement.selectors.currentDeploymentRegion(
285+
getState()
286+
);
287+
288+
if (deploymentRegion.ai === undefined) {
289+
break init_ai;
290+
}
291+
292+
const [{ createAi }, { createOidc, mergeOidcParams }, { oidcParams }] =
293+
await Promise.all([
294+
import("core/adapters/ai"),
295+
import("core/adapters/oidc"),
296+
onyxiaApi.getAvailableRegionsAndOidcParams()
297+
]);
298+
299+
assert(oidcParams !== undefined);
300+
301+
const oidc_ai = await createOidc({
302+
...mergeOidcParams({
303+
oidcParams,
304+
oidcParams_partial: deploymentRegion.ai.oidcParams
305+
}),
306+
transformBeforeRedirectForKeycloakTheme,
307+
getCurrentLang,
308+
autoLogin: true,
309+
enableDebugLogs: enableOidcDebugLogs
310+
});
311+
312+
context.ai = createAi({
313+
webUiUrl: deploymentRegion.ai.url,
314+
oauthProvider: deploymentRegion.ai.oauthProvider,
315+
getOidcAccessToken: async () => (await oidc_ai.getTokens()).accessToken
316+
});
317+
318+
await dispatch(usecases.ai.protectedThunks.initialize());
319+
}
320+
278321
pluginSystemInitCore({ core, context });
279322

280323
return { core };

web/src/core/ports/Ai.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
export type Ai = {
2+
webUiUrl: string;
3+
apiBase: string;
4+
getToken: () => Promise<GetTokenResult>;
5+
listModels: (token: string) => Promise<string[]>;
6+
};
7+
8+
export type GetTokenResult =
9+
| { status: "success"; token: string }
10+
| { status: "no-account" }
11+
| { status: "error" };

web/src/core/ports/OnyxiaApi/DeploymentRegion.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,13 @@ export type DeploymentRegion = {
4444
oidcParams: OidcParams_Partial;
4545
}
4646
| undefined;
47+
ai:
48+
| {
49+
url: string;
50+
oauthProvider: string;
51+
oidcParams: OidcParams_Partial;
52+
}
53+
| undefined;
4754
proxyInjection:
4855
| {
4956
enabled: boolean | undefined;

web/src/core/ports/OnyxiaApi/XOnyxia.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,14 @@ export type XOnyxiaContext = {
182182
useCertManager: boolean;
183183
certManagerClusterIssuer: string | undefined;
184184
};
185+
ai:
186+
| {
187+
enabled: true;
188+
token: string;
189+
apiBase: string;
190+
model: string;
191+
}
192+
| undefined;
185193
proxyInjection:
186194
| {
187195
enabled: boolean | undefined;

0 commit comments

Comments
 (0)