lemma-sdk 0.2.20 → 0.2.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -108,7 +108,12 @@ const assistantPayload: CreateAssistantInput = {
108
108
  ## Auth Helpers
109
109
 
110
110
  ```ts
111
- import { LemmaClient, buildAuthUrl, resolveSafeRedirectUri } from "lemma-sdk";
111
+ import {
112
+ LemmaClient,
113
+ buildAuthUrl,
114
+ buildFederatedLogoutUrl,
115
+ resolveSafeRedirectUri,
116
+ } from "lemma-sdk";
112
117
 
113
118
  const client = new LemmaClient({
114
119
  apiUrl: "https://api-next.asur.work",
@@ -131,6 +136,14 @@ await client.auth.signOut();
131
136
  const token = await client.auth.getAccessToken();
132
137
  const refreshed = await client.auth.refreshAccessToken();
133
138
  client.auth.redirectToAuth({ mode: "signup", redirectUri: safeRedirect });
139
+
140
+ // Build upstream logout URL (server/client)
141
+ const federatedLogoutUrl = buildFederatedLogoutUrl(client.authUrl, {
142
+ redirectUri: safeRedirect,
143
+ });
144
+
145
+ // Browser: sign out locally, then clear upstream SSO and return to app
146
+ await client.auth.redirectToFederatedLogout({ redirectUri: safeRedirect });
134
147
  ```
135
148
 
136
149
  ### Browser Testing With Injected Token
package/dist/auth.d.ts CHANGED
@@ -28,6 +28,7 @@ export interface AuthState {
28
28
  }
29
29
  export type AuthListener = (state: AuthState) => void;
30
30
  export type AuthRedirectMode = "login" | "signup";
31
+ type AuthQueryParams = Record<string, string | number | boolean | Array<string | number | boolean> | null | undefined>;
31
32
  export interface BuildAuthUrlOptions {
32
33
  /** Optional auth path segment relative to authUrl pathname, e.g. "callback" -> /auth/callback. */
33
34
  path?: string;
@@ -36,7 +37,35 @@ export interface BuildAuthUrlOptions {
36
37
  /** Redirect URI passed to auth service. */
37
38
  redirectUri?: string;
38
39
  /** Additional query parameters appended to auth URL. */
39
- params?: Record<string, string | number | boolean | Array<string | number | boolean> | null | undefined>;
40
+ params?: AuthQueryParams;
41
+ }
42
+ export interface BuildFederatedLogoutUrlOptions {
43
+ /**
44
+ * Optional auth path segment for logout, relative to authUrl pathname.
45
+ * Defaults to "logout" (for example: https://auth.example.com/auth/logout).
46
+ */
47
+ path?: string;
48
+ /**
49
+ * Post-logout redirect URI passed to the auth service.
50
+ */
51
+ redirectUri?: string;
52
+ /**
53
+ * Query parameter name used for redirect URI. Defaults to "redirect_uri".
54
+ */
55
+ redirectParam?: string;
56
+ /** Additional query parameters appended to logout URL. */
57
+ params?: AuthQueryParams;
58
+ }
59
+ export interface RedirectToFederatedLogoutOptions extends Omit<BuildFederatedLogoutUrlOptions, "redirectUri"> {
60
+ /**
61
+ * Post-logout redirect URI. Defaults to current location.
62
+ */
63
+ redirectUri?: string;
64
+ /**
65
+ * Whether to clear the local session before redirecting upstream.
66
+ * Defaults to true.
67
+ */
68
+ localSignOut?: boolean;
40
69
  }
41
70
  export interface ResolveSafeRedirectUriOptions {
42
71
  /** Origin for resolving relative paths. */
@@ -50,6 +79,7 @@ export declare function setTestingToken(token: string): void;
50
79
  export declare function getTestingToken(): string | null;
51
80
  export declare function clearTestingToken(): void;
52
81
  export declare function buildAuthUrl(authUrl: string, options?: BuildAuthUrlOptions): string;
82
+ export declare function buildFederatedLogoutUrl(authUrl: string, options?: BuildFederatedLogoutUrlOptions): string;
53
83
  export declare function resolveSafeRedirectUri(rawValue: string | null | undefined, options: ResolveSafeRedirectUriOptions): string;
54
84
  export declare class AuthManager {
55
85
  private readonly apiUrl;
@@ -72,6 +102,13 @@ export declare class AuthManager {
72
102
  private setState;
73
103
  private assertBrowserContext;
74
104
  private getCookie;
105
+ private getCookieDomainCandidates;
106
+ private expireCookie;
107
+ /**
108
+ * Defensive cleanup for stale SuperTokens frontend marker cookies/storage.
109
+ * This helps recover when signout/session-expiry paths leave local markers behind.
110
+ */
111
+ private clearFrontendSessionMarkers;
75
112
  private clearInjectedToken;
76
113
  private rawSignOutViaBackend;
77
114
  /**
@@ -112,6 +149,10 @@ export declare class AuthManager {
112
149
  * Build auth URL for login/signup/custom auth sub-path.
113
150
  */
114
151
  getAuthUrl(options?: BuildAuthUrlOptions): string;
152
+ /**
153
+ * Build upstream/federated logout URL.
154
+ */
155
+ getFederatedLogoutUrl(options?: BuildFederatedLogoutUrlOptions): string;
115
156
  /**
116
157
  * Redirect to the auth service, passing the current URL as redirect_uri.
117
158
  * After the user authenticates, the auth service should redirect back to
@@ -120,4 +161,11 @@ export declare class AuthManager {
120
161
  redirectToAuth(options?: Omit<BuildAuthUrlOptions, "redirectUri"> & {
121
162
  redirectUri?: string;
122
163
  }): void;
164
+ /**
165
+ * Optional full logout flow:
166
+ * 1. clear local SDK/session cookies
167
+ * 2. redirect to auth service logout endpoint to terminate upstream SSO
168
+ */
169
+ redirectToFederatedLogout(options?: RedirectToFederatedLogoutOptions): Promise<void>;
123
170
  }
171
+ export {};
package/dist/auth.js CHANGED
@@ -18,6 +18,14 @@
18
18
  import Session from "supertokens-web-js/recipe/session";
19
19
  import { ensureCookieSessionSupport } from "./supertokens.js";
20
20
  const DEFAULT_BLOCKED_REDIRECT_PATHS = ["/login", "/signup", "/auth"];
21
+ const SUPERTOKENS_FRONTEND_MARKER_KEYS = [
22
+ "sFrontToken",
23
+ "st-last-access-token-update",
24
+ "sIRTFrontend",
25
+ "sAntiCsrf",
26
+ "st-access-token",
27
+ "st-refresh-token",
28
+ ];
21
29
  const LOCALSTORAGE_TOKEN_KEY = "lemma_token";
22
30
  function readStorageToken() {
23
31
  if (typeof window === "undefined")
@@ -121,6 +129,26 @@ export function buildAuthUrl(authUrl, options = {}) {
121
129
  }
122
130
  return url.toString();
123
131
  }
132
+ export function buildFederatedLogoutUrl(authUrl, options = {}) {
133
+ const url = new URL(authUrl);
134
+ url.pathname = resolveAuthPath(url.pathname, options.path ?? "logout");
135
+ for (const [key, value] of Object.entries(options.params ?? {})) {
136
+ if (value === null || value === undefined)
137
+ continue;
138
+ if (Array.isArray(value)) {
139
+ url.searchParams.delete(key);
140
+ for (const item of value) {
141
+ url.searchParams.append(key, String(item));
142
+ }
143
+ continue;
144
+ }
145
+ url.searchParams.set(key, String(value));
146
+ }
147
+ if (options.redirectUri && options.redirectUri.trim()) {
148
+ url.searchParams.set(options.redirectParam ?? "redirect_uri", options.redirectUri);
149
+ }
150
+ return url.toString();
151
+ }
124
152
  export function resolveSafeRedirectUri(rawValue, options) {
125
153
  const siteOrigin = normalizeOrigin(options.siteOrigin);
126
154
  const blockedPaths = options.blockedPaths ?? DEFAULT_BLOCKED_REDIRECT_PATHS;
@@ -194,6 +222,62 @@ export class AuthManager {
194
222
  const match = document.cookie.match(new RegExp(`(?:^|; )${escaped}=([^;]*)`));
195
223
  return match ? decodeURIComponent(match[1]) : undefined;
196
224
  }
225
+ getCookieDomainCandidates() {
226
+ if (typeof window === "undefined") {
227
+ return [undefined];
228
+ }
229
+ const host = window.location.hostname;
230
+ const isIpv4 = /^\d{1,3}(?:\.\d{1,3}){3}$/.test(host);
231
+ const isIpv6 = host.includes(":");
232
+ if (!host || host === "localhost" || isIpv4 || isIpv6) {
233
+ return [undefined];
234
+ }
235
+ const domains = new Set();
236
+ const parts = host.split(".").filter(Boolean);
237
+ for (let i = 0; i < parts.length - 1; i += 1) {
238
+ const candidate = parts.slice(i).join(".");
239
+ if (!candidate)
240
+ continue;
241
+ domains.add(candidate);
242
+ domains.add(`.${candidate}`);
243
+ }
244
+ return [undefined, ...domains];
245
+ }
246
+ expireCookie(name, domain) {
247
+ if (typeof document === "undefined")
248
+ return;
249
+ const domainPart = domain ? `;domain=${domain}` : "";
250
+ document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;max-age=0;path=/${domainPart};samesite=lax`;
251
+ document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;max-age=0;path=/${domainPart}`;
252
+ }
253
+ /**
254
+ * Defensive cleanup for stale SuperTokens frontend marker cookies/storage.
255
+ * This helps recover when signout/session-expiry paths leave local markers behind.
256
+ */
257
+ clearFrontendSessionMarkers() {
258
+ if (typeof window === "undefined")
259
+ return;
260
+ for (const key of SUPERTOKENS_FRONTEND_MARKER_KEYS) {
261
+ try {
262
+ window.localStorage.removeItem(key);
263
+ }
264
+ catch {
265
+ // ignore storage errors
266
+ }
267
+ try {
268
+ window.sessionStorage.removeItem(key);
269
+ }
270
+ catch {
271
+ // ignore storage errors
272
+ }
273
+ }
274
+ const domains = this.getCookieDomainCandidates();
275
+ for (const key of SUPERTOKENS_FRONTEND_MARKER_KEYS) {
276
+ for (const domain of domains) {
277
+ this.expireCookie(key, domain);
278
+ }
279
+ }
280
+ }
197
281
  clearInjectedToken() {
198
282
  this.injectedToken = null;
199
283
  clearTestingToken();
@@ -302,6 +386,7 @@ export class AuthManager {
302
386
  const response = await fetch(`${this.apiUrl}/users/me`, this.getRequestInit({ method: "GET" }));
303
387
  // Only 401 means not authenticated — 403 means authenticated but forbidden
304
388
  if (response.status === 401) {
389
+ this.clearFrontendSessionMarkers();
305
390
  const next = { status: "unauthenticated", user: null };
306
391
  this.setState(next);
307
392
  return next;
@@ -328,6 +413,7 @@ export class AuthManager {
328
413
  * Does NOT redirect — call redirectToAuth() explicitly if desired.
329
414
  */
330
415
  markUnauthenticated() {
416
+ this.clearFrontendSessionMarkers();
331
417
  this.setState({ status: "unauthenticated", user: null });
332
418
  }
333
419
  /**
@@ -368,6 +454,12 @@ export class AuthManager {
368
454
  getAuthUrl(options = {}) {
369
455
  return buildAuthUrl(this.authUrl, options);
370
456
  }
457
+ /**
458
+ * Build upstream/federated logout URL.
459
+ */
460
+ getFederatedLogoutUrl(options = {}) {
461
+ return buildFederatedLogoutUrl(this.authUrl, options);
462
+ }
371
463
  /**
372
464
  * Redirect to the auth service, passing the current URL as redirect_uri.
373
465
  * After the user authenticates, the auth service should redirect back to
@@ -380,4 +472,21 @@ export class AuthManager {
380
472
  const redirectUri = options.redirectUri ?? window.location.href;
381
473
  window.location.href = this.getAuthUrl({ ...options, redirectUri });
382
474
  }
475
+ /**
476
+ * Optional full logout flow:
477
+ * 1. clear local SDK/session cookies
478
+ * 2. redirect to auth service logout endpoint to terminate upstream SSO
479
+ */
480
+ async redirectToFederatedLogout(options = {}) {
481
+ this.assertBrowserContext();
482
+ const redirectUri = options.redirectUri ?? window.location.href;
483
+ const localSignOut = options.localSignOut ?? true;
484
+ if (localSignOut) {
485
+ await this.signOut();
486
+ }
487
+ window.location.href = this.getFederatedLogoutUrl({
488
+ ...options,
489
+ redirectUri,
490
+ });
491
+ }
383
492
  }
@@ -3,7 +3,7 @@
3
3
  "./browser.js": function (module, exports, require) {
4
4
  "use strict";
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.ApiError = exports.setTestingToken = exports.resolveSafeRedirectUri = exports.getTestingToken = exports.clearTestingToken = exports.buildAuthUrl = exports.AuthManager = exports.LemmaClient = void 0;
6
+ exports.ApiError = exports.setTestingToken = exports.resolveSafeRedirectUri = exports.getTestingToken = exports.clearTestingToken = exports.buildFederatedLogoutUrl = exports.buildAuthUrl = exports.AuthManager = exports.LemmaClient = void 0;
7
7
  /**
8
8
  * Browser bundle entry point.
9
9
  * Exposes LemmaClient as globalThis.LemmaClient.LemmaClient
@@ -19,6 +19,7 @@ Object.defineProperty(exports, "LemmaClient", { enumerable: true, get: function
19
19
  var auth_js_1 = require("./auth.js");
20
20
  Object.defineProperty(exports, "AuthManager", { enumerable: true, get: function () { return auth_js_1.AuthManager; } });
21
21
  Object.defineProperty(exports, "buildAuthUrl", { enumerable: true, get: function () { return auth_js_1.buildAuthUrl; } });
22
+ Object.defineProperty(exports, "buildFederatedLogoutUrl", { enumerable: true, get: function () { return auth_js_1.buildFederatedLogoutUrl; } });
22
23
  Object.defineProperty(exports, "clearTestingToken", { enumerable: true, get: function () { return auth_js_1.clearTestingToken; } });
23
24
  Object.defineProperty(exports, "getTestingToken", { enumerable: true, get: function () { return auth_js_1.getTestingToken; } });
24
25
  Object.defineProperty(exports, "resolveSafeRedirectUri", { enumerable: true, get: function () { return auth_js_1.resolveSafeRedirectUri; } });
@@ -198,10 +199,19 @@ exports.setTestingToken = setTestingToken;
198
199
  exports.getTestingToken = getTestingToken;
199
200
  exports.clearTestingToken = clearTestingToken;
200
201
  exports.buildAuthUrl = buildAuthUrl;
202
+ exports.buildFederatedLogoutUrl = buildFederatedLogoutUrl;
201
203
  exports.resolveSafeRedirectUri = resolveSafeRedirectUri;
202
204
  const session_1 = require("supertokens-web-js/recipe/session");
203
205
  const supertokens_js_1 = require("./supertokens.js");
204
206
  const DEFAULT_BLOCKED_REDIRECT_PATHS = ["/login", "/signup", "/auth"];
207
+ const SUPERTOKENS_FRONTEND_MARKER_KEYS = [
208
+ "sFrontToken",
209
+ "st-last-access-token-update",
210
+ "sIRTFrontend",
211
+ "sAntiCsrf",
212
+ "st-access-token",
213
+ "st-refresh-token",
214
+ ];
205
215
  const LOCALSTORAGE_TOKEN_KEY = "lemma_token";
206
216
  function readStorageToken() {
207
217
  if (typeof window === "undefined")
@@ -305,6 +315,26 @@ function buildAuthUrl(authUrl, options = {}) {
305
315
  }
306
316
  return url.toString();
307
317
  }
318
+ function buildFederatedLogoutUrl(authUrl, options = {}) {
319
+ const url = new URL(authUrl);
320
+ url.pathname = resolveAuthPath(url.pathname, options.path ?? "logout");
321
+ for (const [key, value] of Object.entries(options.params ?? {})) {
322
+ if (value === null || value === undefined)
323
+ continue;
324
+ if (Array.isArray(value)) {
325
+ url.searchParams.delete(key);
326
+ for (const item of value) {
327
+ url.searchParams.append(key, String(item));
328
+ }
329
+ continue;
330
+ }
331
+ url.searchParams.set(key, String(value));
332
+ }
333
+ if (options.redirectUri && options.redirectUri.trim()) {
334
+ url.searchParams.set(options.redirectParam ?? "redirect_uri", options.redirectUri);
335
+ }
336
+ return url.toString();
337
+ }
308
338
  function resolveSafeRedirectUri(rawValue, options) {
309
339
  const siteOrigin = normalizeOrigin(options.siteOrigin);
310
340
  const blockedPaths = options.blockedPaths ?? DEFAULT_BLOCKED_REDIRECT_PATHS;
@@ -375,6 +405,62 @@ class AuthManager {
375
405
  const match = document.cookie.match(new RegExp(`(?:^|; )${escaped}=([^;]*)`));
376
406
  return match ? decodeURIComponent(match[1]) : undefined;
377
407
  }
408
+ getCookieDomainCandidates() {
409
+ if (typeof window === "undefined") {
410
+ return [undefined];
411
+ }
412
+ const host = window.location.hostname;
413
+ const isIpv4 = /^\d{1,3}(?:\.\d{1,3}){3}$/.test(host);
414
+ const isIpv6 = host.includes(":");
415
+ if (!host || host === "localhost" || isIpv4 || isIpv6) {
416
+ return [undefined];
417
+ }
418
+ const domains = new Set();
419
+ const parts = host.split(".").filter(Boolean);
420
+ for (let i = 0; i < parts.length - 1; i += 1) {
421
+ const candidate = parts.slice(i).join(".");
422
+ if (!candidate)
423
+ continue;
424
+ domains.add(candidate);
425
+ domains.add(`.${candidate}`);
426
+ }
427
+ return [undefined, ...domains];
428
+ }
429
+ expireCookie(name, domain) {
430
+ if (typeof document === "undefined")
431
+ return;
432
+ const domainPart = domain ? `;domain=${domain}` : "";
433
+ document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;max-age=0;path=/${domainPart};samesite=lax`;
434
+ document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;max-age=0;path=/${domainPart}`;
435
+ }
436
+ /**
437
+ * Defensive cleanup for stale SuperTokens frontend marker cookies/storage.
438
+ * This helps recover when signout/session-expiry paths leave local markers behind.
439
+ */
440
+ clearFrontendSessionMarkers() {
441
+ if (typeof window === "undefined")
442
+ return;
443
+ for (const key of SUPERTOKENS_FRONTEND_MARKER_KEYS) {
444
+ try {
445
+ window.localStorage.removeItem(key);
446
+ }
447
+ catch {
448
+ // ignore storage errors
449
+ }
450
+ try {
451
+ window.sessionStorage.removeItem(key);
452
+ }
453
+ catch {
454
+ // ignore storage errors
455
+ }
456
+ }
457
+ const domains = this.getCookieDomainCandidates();
458
+ for (const key of SUPERTOKENS_FRONTEND_MARKER_KEYS) {
459
+ for (const domain of domains) {
460
+ this.expireCookie(key, domain);
461
+ }
462
+ }
463
+ }
378
464
  clearInjectedToken() {
379
465
  this.injectedToken = null;
380
466
  clearTestingToken();
@@ -483,6 +569,7 @@ class AuthManager {
483
569
  const response = await fetch(`${this.apiUrl}/users/me`, this.getRequestInit({ method: "GET" }));
484
570
  // Only 401 means not authenticated — 403 means authenticated but forbidden
485
571
  if (response.status === 401) {
572
+ this.clearFrontendSessionMarkers();
486
573
  const next = { status: "unauthenticated", user: null };
487
574
  this.setState(next);
488
575
  return next;
@@ -509,6 +596,7 @@ class AuthManager {
509
596
  * Does NOT redirect — call redirectToAuth() explicitly if desired.
510
597
  */
511
598
  markUnauthenticated() {
599
+ this.clearFrontendSessionMarkers();
512
600
  this.setState({ status: "unauthenticated", user: null });
513
601
  }
514
602
  /**
@@ -549,6 +637,12 @@ class AuthManager {
549
637
  getAuthUrl(options = {}) {
550
638
  return buildAuthUrl(this.authUrl, options);
551
639
  }
640
+ /**
641
+ * Build upstream/federated logout URL.
642
+ */
643
+ getFederatedLogoutUrl(options = {}) {
644
+ return buildFederatedLogoutUrl(this.authUrl, options);
645
+ }
552
646
  /**
553
647
  * Redirect to the auth service, passing the current URL as redirect_uri.
554
648
  * After the user authenticates, the auth service should redirect back to
@@ -561,6 +655,23 @@ class AuthManager {
561
655
  const redirectUri = options.redirectUri ?? window.location.href;
562
656
  window.location.href = this.getAuthUrl({ ...options, redirectUri });
563
657
  }
658
+ /**
659
+ * Optional full logout flow:
660
+ * 1. clear local SDK/session cookies
661
+ * 2. redirect to auth service logout endpoint to terminate upstream SSO
662
+ */
663
+ async redirectToFederatedLogout(options = {}) {
664
+ this.assertBrowserContext();
665
+ const redirectUri = options.redirectUri ?? window.location.href;
666
+ const localSignOut = options.localSignOut ?? true;
667
+ if (localSignOut) {
668
+ await this.signOut();
669
+ }
670
+ window.location.href = this.getFederatedLogoutUrl({
671
+ ...options,
672
+ redirectUri,
673
+ });
674
+ }
564
675
  }
565
676
  exports.AuthManager = AuthManager;
566
677
 
@@ -1538,6 +1649,9 @@ class ConversationsNamespace {
1538
1649
  listByAssistant(assistantId, options = {}) {
1539
1650
  return this.list({ ...options, assistant_id: assistantId });
1540
1651
  }
1652
+ listModels() {
1653
+ return this.http.request("GET", "/models");
1654
+ }
1541
1655
  create(payload) {
1542
1656
  return this.http.request("POST", "/conversations", {
1543
1657
  body: {
package/dist/browser.d.ts CHANGED
@@ -9,5 +9,5 @@
9
9
  * </script>
10
10
  */
11
11
  export { LemmaClient } from "./client.js";
12
- export { AuthManager, buildAuthUrl, clearTestingToken, getTestingToken, resolveSafeRedirectUri, setTestingToken, } from "./auth.js";
12
+ export { AuthManager, buildAuthUrl, buildFederatedLogoutUrl, clearTestingToken, getTestingToken, resolveSafeRedirectUri, setTestingToken, } from "./auth.js";
13
13
  export { ApiError } from "./http.js";
package/dist/browser.js CHANGED
@@ -9,5 +9,5 @@
9
9
  * </script>
10
10
  */
11
11
  export { LemmaClient } from "./client.js";
12
- export { AuthManager, buildAuthUrl, clearTestingToken, getTestingToken, resolveSafeRedirectUri, setTestingToken, } from "./auth.js";
12
+ export { AuthManager, buildAuthUrl, buildFederatedLogoutUrl, clearTestingToken, getTestingToken, resolveSafeRedirectUri, setTestingToken, } from "./auth.js";
13
13
  export { ApiError } from "./http.js";
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  export { LemmaClient } from "./client.js";
2
2
  export type { LemmaConfig } from "./client.js";
3
- export { AuthManager, buildAuthUrl, clearTestingToken, getTestingToken, resolveSafeRedirectUri, setTestingToken, } from "./auth.js";
4
- export type { AuthState, AuthListener, AuthStatus, UserInfo, AuthRedirectMode, BuildAuthUrlOptions, ResolveSafeRedirectUriOptions, } from "./auth.js";
3
+ export { AuthManager, buildAuthUrl, buildFederatedLogoutUrl, clearTestingToken, getTestingToken, resolveSafeRedirectUri, setTestingToken, } from "./auth.js";
4
+ export type { AuthState, AuthListener, AuthStatus, UserInfo, AuthRedirectMode, BuildAuthUrlOptions, BuildFederatedLogoutUrlOptions, RedirectToFederatedLogoutOptions, ResolveSafeRedirectUriOptions, } from "./auth.js";
5
5
  export { ApiError } from "./http.js";
6
6
  export * from "./types.js";
7
7
  export { readSSE, parseSSEJson } from "./streams.js";
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export { LemmaClient } from "./client.js";
2
- export { AuthManager, buildAuthUrl, clearTestingToken, getTestingToken, resolveSafeRedirectUri, setTestingToken, } from "./auth.js";
2
+ export { AuthManager, buildAuthUrl, buildFederatedLogoutUrl, clearTestingToken, getTestingToken, resolveSafeRedirectUri, setTestingToken, } from "./auth.js";
3
3
  export { ApiError } from "./http.js";
4
4
  export * from "./types.js";
5
5
  export { readSSE, parseSSEJson } from "./streams.js";
@@ -1,4 +1,5 @@
1
1
  import type { HttpClient } from "../http.js";
2
+ import type { AvailableModelsListResponse } from "../openapi_client/models/AvailableModelsListResponse.js";
2
3
  import type { AssistantListResponse } from "../openapi_client/models/AssistantListResponse.js";
3
4
  import type { AssistantResponse } from "../openapi_client/models/AssistantResponse.js";
4
5
  import type { ConversationListResponse } from "../openapi_client/models/ConversationListResponse.js";
@@ -41,6 +42,7 @@ export declare class ConversationsNamespace {
41
42
  limit?: number;
42
43
  page_token?: string;
43
44
  }): Promise<ConversationListResponse>;
45
+ listModels(): Promise<AvailableModelsListResponse>;
44
46
  create(payload: CreateConversationRequest): Promise<ConversationResponse>;
45
47
  createForAssistant(assistantId: string, payload?: Omit<CreateConversationRequest, "assistant_id">): Promise<ConversationResponse>;
46
48
  get(conversationId: string, options?: {
@@ -67,6 +67,9 @@ export class ConversationsNamespace {
67
67
  listByAssistant(assistantId, options = {}) {
68
68
  return this.list({ ...options, assistant_id: assistantId });
69
69
  }
70
+ listModels() {
71
+ return this.http.request("GET", "/models");
72
+ }
70
73
  create(payload) {
71
74
  return this.http.request("POST", "/conversations", {
72
75
  body: {
@@ -26,7 +26,9 @@ export type { AssistantListResponse } from './models/AssistantListResponse.js';
26
26
  export type { AssistantResponse } from './models/AssistantResponse.js';
27
27
  export type { AssistantSurfaceListResponse } from './models/AssistantSurfaceListResponse.js';
28
28
  export type { AssistantSurfaceResponse } from './models/AssistantSurfaceResponse.js';
29
+ export type { AvailableModelInfo } from './models/AvailableModelInfo.js';
29
30
  export { AvailableModels } from './models/AvailableModels.js';
31
+ export type { AvailableModelsListResponse } from './models/AvailableModelsListResponse.js';
30
32
  export { BillingInterval } from './models/BillingInterval.js';
31
33
  export type { Body_upload_file_files__resource_type___resource_id__upload_post } from './models/Body_upload_file_files__resource_type___resource_id__upload_post.js';
32
34
  export type { BulkCreateRecordsRequest } from './models/BulkCreateRecordsRequest.js';
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Information about an available model.
3
+ */
4
+ export type AvailableModelInfo = {
5
+ id: string;
6
+ name: string;
7
+ provider_model_name: string;
8
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -4,8 +4,7 @@ export declare enum AvailableModels {
4
4
  GEMINI_FLASH_LITE = "GEMINI_FLASH_LITE",
5
5
  KIMI_K2 = "KIMI_K2",
6
6
  GPT_OSS = "GPT_OSS",
7
- DEEPSEEK_V31 = "DEEPSEEK_V31",
8
- CLAUDE_SONNET_4 = "CLAUDE_SONNET_4",
9
- QWEN3_235B = "QWEN3_235B",
10
- GLM_5 = "GLM_5"
7
+ DEEPSEEK_V32 = "DEEPSEEK_V32",
8
+ GLM_5 = "GLM_5",
9
+ QWEN3_6 = "QWEN3_6"
11
10
  }
@@ -9,8 +9,7 @@ export var AvailableModels;
9
9
  AvailableModels["GEMINI_FLASH_LITE"] = "GEMINI_FLASH_LITE";
10
10
  AvailableModels["KIMI_K2"] = "KIMI_K2";
11
11
  AvailableModels["GPT_OSS"] = "GPT_OSS";
12
- AvailableModels["DEEPSEEK_V31"] = "DEEPSEEK_V31";
13
- AvailableModels["CLAUDE_SONNET_4"] = "CLAUDE_SONNET_4";
14
- AvailableModels["QWEN3_235B"] = "QWEN3_235B";
12
+ AvailableModels["DEEPSEEK_V32"] = "DEEPSEEK_V32";
15
13
  AvailableModels["GLM_5"] = "GLM_5";
14
+ AvailableModels["QWEN3_6"] = "QWEN3_6";
16
15
  })(AvailableModels || (AvailableModels = {}));
@@ -0,0 +1,7 @@
1
+ import type { AvailableModelInfo } from './AvailableModelInfo.js';
2
+ /**
3
+ * Response containing list of available models.
4
+ */
5
+ export type AvailableModelsListResponse = {
6
+ items: Array<AvailableModelInfo>;
7
+ };
@@ -16,4 +16,6 @@ export type FunctionRunResponse = {
16
16
  status: FunctionRunStatus;
17
17
  user_email?: (string | null);
18
18
  user_id: string;
19
+ workspace_process_id?: (string | null);
20
+ workspace_session_id?: (string | null);
19
21
  };
@@ -1,3 +1,4 @@
1
+ import type { AvailableModelsListResponse } from '../models/AvailableModelsListResponse.js';
1
2
  import type { ConversationListResponse } from '../models/ConversationListResponse.js';
2
3
  import type { ConversationMessageListResponse } from '../models/ConversationMessageListResponse.js';
3
4
  import type { ConversationResponse } from '../models/ConversationResponse.js';
@@ -76,4 +77,11 @@ export declare class ConversationsService {
76
77
  * @throws ApiError
77
78
  */
78
79
  static conversationStreamResume(conversationId: string, podId?: (string | null)): CancelablePromise<any>;
80
+ /**
81
+ * List Available Models
82
+ * Get list of all available models in the system.
83
+ * @returns AvailableModelsListResponse Successful Response
84
+ * @throws ApiError
85
+ */
86
+ static conversationModelsList(): CancelablePromise<AvailableModelsListResponse>;
79
87
  }
@@ -185,4 +185,16 @@ export class ConversationsService {
185
185
  },
186
186
  });
187
187
  }
188
+ /**
189
+ * List Available Models
190
+ * Get list of all available models in the system.
191
+ * @returns AvailableModelsListResponse Successful Response
192
+ * @throws ApiError
193
+ */
194
+ static conversationModelsList() {
195
+ return __request(OpenAPI, {
196
+ method: 'GET',
197
+ url: '/models',
198
+ });
199
+ }
188
200
  }
@@ -773,7 +773,15 @@ export function AssistantExperienceView({ controller, title = "Lemma Assistant",
773
773
  const isPinnedToBottomRef = useRef(true);
774
774
  const loadingOlderFromScrollRef = useRef(false);
775
775
  const isConversationBusy = controller.isLoading || controller.isActiveConversationRunning;
776
- const availableModels = useMemo(() => Object.values(AvailableModels), []);
776
+ const availableModels = useMemo(() => {
777
+ const dynamicModels = controller.availableModels
778
+ .map((model) => model.id)
779
+ .filter((model) => model.trim().length > 0);
780
+ return dynamicModels.length > 0
781
+ ? dynamicModels
782
+ : Object.values(AvailableModels);
783
+ }, [controller.availableModels]);
784
+ const availableModelLabels = useMemo(() => new Map(controller.availableModels.map((model) => [model.id, model.name])), [controller.availableModels]);
777
785
  const resizeComposer = useCallback(() => {
778
786
  const textarea = inputRef.current;
779
787
  if (!textarea)
@@ -1027,7 +1035,7 @@ export function AssistantExperienceView({ controller, title = "Lemma Assistant",
1027
1035
  return (_jsxs("div", { className: "lemma-assistant-experience", "data-chrome-style": chromeStyle, "data-status-placement": statusPlacement, "data-radius": radius, "data-show-model-picker": showModelPicker ? "true" : "false", "data-busy": isConversationBusy ? "true" : "false", "data-has-plan": planSummary ? "true" : "false", "data-has-pending-files": controller.pendingFiles.length > 0 ? "true" : "false", "data-show-conversation-list": showConversationList ? "true" : "false", children: [showConversationList ? (_jsxs("aside", { className: "lemma-assistant-experience-sidebar", children: [_jsx("div", { className: "lemma-assistant-experience-sidebar-header", children: _jsxs("div", { className: "lemma-assistant-experience-sidebar-header-row", children: [_jsxs("div", { className: "lemma-assistant-experience-sidebar-copy", children: [_jsx("div", { className: "lemma-assistant-experience-sidebar-title", children: "Conversations" }), _jsxs("div", { className: "lemma-assistant-experience-sidebar-meta", children: [controller.conversations.length, " total"] })] }), showNewConversationButton ? (_jsx("button", { type: "button", onClick: controller.clearMessages, className: "lemma-assistant-experience-sidebar-new", children: "New" })) : null] }) }), _jsx("div", { className: "lemma-assistant-experience-sidebar-items", children: controller.conversations.map((conversation) => {
1028
1036
  const isActive = conversation.id === controller.activeConversationId;
1029
1037
  return (_jsxs("button", { type: "button", onClick: () => controller.selectConversation(conversation.id), className: cx("lemma-assistant-experience-sidebar-item", isActive && "lemma-assistant-experience-sidebar-item-active"), children: [_jsx("div", { className: "lemma-assistant-experience-sidebar-item-title", children: renderConversationLabel({ conversation, isActive }) }), _jsx("div", { className: "lemma-assistant-experience-sidebar-item-status", children: (conversation.status || "waiting").toLowerCase() })] }, conversation.id));
1030
- }) })] })) : null, _jsxs("div", { className: "lemma-assistant-experience-main", children: [_jsxs("div", { className: "lemma-assistant-experience-card", children: [_jsx(AssistantHeader, { className: "lemma-assistant-experience-header", tone: headerTone, title: title, subtitle: subtitle, badge: _jsx("span", { className: "lemma-assistant-experience-header-badge-icon", children: "\u2728" }), controls: showModelPicker || showNewConversationButton ? (_jsxs(_Fragment, { children: [showModelPicker ? (_jsx(AssistantModelPicker, { value: controller.conversationModel, options: availableModels, onChange: (nextModel) => { void handleModelChange(nextModel); }, disabled: isConversationBusy || isUpdatingModel, autoLabel: "Auto", className: "lemma-assistant-experience-model-picker" })) : null, showNewConversationButton ? (_jsx("button", { type: "button", onClick: controller.clearMessages, title: "New conversation", className: "lemma-assistant-experience-new", children: "\u21BA" })) : null] })) : undefined }), _jsxs(AssistantMessageViewport, { className: "lemma-assistant-experience-viewport", ref: messagesContainerRef, onScroll: updatePinnedState, children: [controller.messages.length === 0 && !isConversationBusy ? (emptyState || (_jsx(EmptyState, { onSendMessage: (message) => { void controller.sendMessage(message); }, suggestions: emptyStateSuggestions }))) : null, (controller.isLoadingMessages && controller.messages.length === 0) ? (_jsx("div", { className: "lemma-assistant-experience-loading", children: _jsx("span", { className: "lemma-assistant-experience-loading-text", children: "Loading\u2026" }) })) : null, (controller.isLoadingOlderMessages && controller.messages.length > 0) ? (_jsx("div", { className: "lemma-assistant-experience-loading-older", children: _jsx("span", { className: "lemma-assistant-experience-loading-older-text", children: "Loading older\u2026" }) })) : null, displayMessageRows.map((row, index) => {
1038
+ }) })] })) : null, _jsxs("div", { className: "lemma-assistant-experience-main", children: [_jsxs("div", { className: "lemma-assistant-experience-card", children: [_jsx(AssistantHeader, { className: "lemma-assistant-experience-header", tone: headerTone, title: title, subtitle: subtitle, badge: _jsx("span", { className: "lemma-assistant-experience-header-badge-icon", children: "\u2728" }), controls: showModelPicker || showNewConversationButton ? (_jsxs(_Fragment, { children: [showModelPicker ? (_jsx(AssistantModelPicker, { value: controller.conversationModel, options: availableModels, getOptionLabel: (model) => availableModelLabels.get(model) ?? model, onChange: (nextModel) => { void handleModelChange(nextModel); }, disabled: isConversationBusy || isUpdatingModel, autoLabel: "Auto", className: "lemma-assistant-experience-model-picker" })) : null, showNewConversationButton ? (_jsx("button", { type: "button", onClick: controller.clearMessages, title: "New conversation", className: "lemma-assistant-experience-new", children: "\u21BA" })) : null] })) : undefined }), _jsxs(AssistantMessageViewport, { className: "lemma-assistant-experience-viewport", ref: messagesContainerRef, onScroll: updatePinnedState, children: [controller.messages.length === 0 && !isConversationBusy ? (emptyState || (_jsx(EmptyState, { onSendMessage: (message) => { void controller.sendMessage(message); }, suggestions: emptyStateSuggestions }))) : null, (controller.isLoadingMessages && controller.messages.length === 0) ? (_jsx("div", { className: "lemma-assistant-experience-loading", children: _jsx("span", { className: "lemma-assistant-experience-loading-text", children: "Loading\u2026" }) })) : null, (controller.isLoadingOlderMessages && controller.messages.length > 0) ? (_jsx("div", { className: "lemma-assistant-experience-loading-older", children: _jsx("span", { className: "lemma-assistant-experience-loading-older-text", children: "Loading older\u2026" }) })) : null, displayMessageRows.map((row, index) => {
1031
1039
  const previousRow = index > 0 ? displayMessageRows[index - 1] : null;
1032
1040
  const showAssistantHeader = row.message.role !== "assistant"
1033
1041
  ? false
@@ -1,4 +1,5 @@
1
1
  import type { ReactNode } from "react";
2
+ import type { AvailableModelInfo } from "../../types.js";
2
3
  import type { AssistantRenderableMessage, AssistantToolInvocation } from "../useAssistantController.js";
3
4
  export interface AssistantConversationListItem {
4
5
  id: string;
@@ -11,6 +12,7 @@ export interface AssistantControllerView {
11
12
  messages: AssistantRenderableMessage[];
12
13
  conversations: AssistantConversationListItem[];
13
14
  activeConversationId: string | null;
15
+ availableModels: AvailableModelInfo[];
14
16
  conversationModel: string | null;
15
17
  setConversationModel(model: string | null): Promise<void>;
16
18
  isActiveConversationRunning: boolean;
@@ -1,5 +1,5 @@
1
1
  import type { LemmaClient } from "../client.js";
2
- import type { Conversation, ConversationModel } from "../types.js";
2
+ import type { AvailableModelInfo, Conversation, ConversationModel } from "../types.js";
3
3
  export interface AssistantConversationScope {
4
4
  podId?: string | null;
5
5
  assistantId?: string | null;
@@ -54,6 +54,7 @@ export interface UseAssistantControllerResult {
54
54
  messages: AssistantRenderableMessage[];
55
55
  conversations: Conversation[];
56
56
  activeConversationId: string | null;
57
+ availableModels: AvailableModelInfo[];
57
58
  conversationModel: ConversationModel | null;
58
59
  isActiveConversationRunning: boolean;
59
60
  isLoading: boolean;
@@ -543,6 +543,7 @@ export function useAssistantController({ client, podId, assistantId, organizatio
543
543
  const [messages, setMessages] = useState([]);
544
544
  const [conversations, setConversations] = useState([]);
545
545
  const [activeConversationId, setActiveConversationId] = useState(null);
546
+ const [availableModels, setAvailableModels] = useState([]);
546
547
  const [conversationModel, setConversationModelState] = useState(null);
547
548
  const [isStreaming, setIsStreaming] = useState(false);
548
549
  const [isLoadingConversations, setIsLoadingConversations] = useState(false);
@@ -649,6 +650,15 @@ export function useAssistantController({ client, podId, assistantId, organizatio
649
650
  setIsLoadingConversations(false);
650
651
  }
651
652
  }, [scope, sessionListConversations]);
653
+ const loadAvailableModels = useCallback(async () => {
654
+ try {
655
+ const response = await client.conversations.listModels();
656
+ return response.items ?? [];
657
+ }
658
+ catch {
659
+ return [];
660
+ }
661
+ }, [client]);
652
662
  const loadConversationMessages = useCallback(async (conversationId) => {
653
663
  setIsLoadingMessages(true);
654
664
  try {
@@ -706,6 +716,23 @@ export function useAssistantController({ client, podId, assistantId, organizatio
706
716
  useEffect(() => {
707
717
  conversationsRef.current = conversations;
708
718
  }, [conversations]);
719
+ useEffect(() => {
720
+ if (!enabled) {
721
+ setAvailableModels([]);
722
+ return;
723
+ }
724
+ let cancelled = false;
725
+ void loadAvailableModels()
726
+ .then((models) => {
727
+ if (cancelled)
728
+ return;
729
+ setAvailableModels(models);
730
+ })
731
+ .catch(() => undefined);
732
+ return () => {
733
+ cancelled = true;
734
+ };
735
+ }, [enabled, loadAvailableModels]);
709
736
  useEffect(() => {
710
737
  const conversationId = activeConversationIdRef.current;
711
738
  if (!conversationId) {
@@ -759,6 +786,7 @@ export function useAssistantController({ client, podId, assistantId, organizatio
759
786
  loadingConversationIdRef.current = null;
760
787
  skipInitialLoadConversationIdsRef.current.clear();
761
788
  setActiveConversationId(null);
789
+ setAvailableModels([]);
762
790
  setConversationModelState(null);
763
791
  setConversations([]);
764
792
  setMessages([]);
@@ -842,8 +870,12 @@ export function useAssistantController({ client, podId, assistantId, organizatio
842
870
  const conversationIsRunning = isConversationRunning(activeConversation?.status);
843
871
  if (!hadActiveStream && !conversationIsRunning)
844
872
  return;
873
+ const previousStatus = activeConversation?.status;
845
874
  touchConversation(conversationId, { status: "waiting" });
846
- void sessionStop(conversationId).catch(() => undefined);
875
+ void sessionStop(conversationId).catch((error) => {
876
+ touchConversation(conversationId, { status: previousStatus });
877
+ setLocalError((prev) => prev || (error instanceof Error ? error.message : "Failed to stop conversation"));
878
+ });
847
879
  }, [isStreaming, sessionCancel, sessionIsStreaming, sessionStop, touchConversation]);
848
880
  const selectConversation = useCallback((conversationId) => {
849
881
  if (sessionIsStreaming || isStreaming) {
@@ -1066,6 +1098,7 @@ export function useAssistantController({ client, podId, assistantId, organizatio
1066
1098
  messages,
1067
1099
  conversations,
1068
1100
  activeConversationId,
1101
+ availableModels,
1069
1102
  conversationModel,
1070
1103
  isActiveConversationRunning,
1071
1104
  isLoading,
@@ -21,6 +21,7 @@ function messageTime(message) {
21
21
  function isOptimisticId(messageId) {
22
22
  return messageId.startsWith("optimistic-user-");
23
23
  }
24
+ const OPTIMISTIC_MATCH_WINDOW_MS = 2 * 60 * 1000;
24
25
  function upsertRuntimeMessage(previous, incoming) {
25
26
  const next = [...previous];
26
27
  const directIndex = next.findIndex((message) => message.id === incoming.id);
@@ -31,9 +32,22 @@ function upsertRuntimeMessage(previous, incoming) {
31
32
  if (incoming.role === "user") {
32
33
  const incomingText = messageText(incoming.content);
33
34
  if (incomingText) {
34
- const optimisticIndex = next.findIndex((message) => (message.role === "user"
35
- && isOptimisticId(message.id)
36
- && messageText(message.content) === incomingText));
35
+ const incomingTimestamp = messageTime(incoming);
36
+ let optimisticIndex = -1;
37
+ let bestDistance = Number.POSITIVE_INFINITY;
38
+ next.forEach((message, index) => {
39
+ if (message.role !== "user"
40
+ || !isOptimisticId(message.id)
41
+ || messageText(message.content) !== incomingText) {
42
+ return;
43
+ }
44
+ const distance = Math.abs(messageTime(message) - incomingTimestamp);
45
+ if (distance > OPTIMISTIC_MATCH_WINDOW_MS || distance >= bestDistance) {
46
+ return;
47
+ }
48
+ optimisticIndex = index;
49
+ bestDistance = distance;
50
+ });
37
51
  if (optimisticIndex >= 0) {
38
52
  next[optimisticIndex] = incoming;
39
53
  return next;
@@ -71,7 +85,14 @@ export function useAssistantRuntime({ conversationId = null, sessionMessages = [
71
85
  const normalized = messages
72
86
  .map((message) => toRuntimeMessage(message, conversationId))
73
87
  .filter((message) => !conversationId || message.conversation_id === conversationId);
74
- setRuntimeMessages([...normalized].sort((a, b) => messageTime(a) - messageTime(b)));
88
+ setRuntimeMessages((previous) => {
89
+ const scopedPrevious = previous.filter((message) => !conversationId || message.conversation_id === conversationId);
90
+ // Loads can complete after optimistic appends or stream events. Merge the
91
+ // loaded snapshot into the current runtime state so newer local messages
92
+ // are not temporarily dropped while the server catches up.
93
+ const merged = normalized.reduce((accumulator, message) => upsertRuntimeMessage(accumulator, message), scopedPrevious);
94
+ return [...merged].sort((a, b) => messageTime(a) - messageTime(b));
95
+ });
75
96
  }, [conversationId]);
76
97
  const appendOptimisticUserMessage = useCallback((content, options) => {
77
98
  const trimmed = content.trim();
@@ -393,12 +393,21 @@ export function useAssistantSession(options) {
393
393
  return false;
394
394
  }
395
395
  }
396
+ const previousResumeKey = autoResumedKeyRef.current;
396
397
  autoResumedKeyRef.current = resumeKey;
397
- await resume({
398
- conversationId: id,
399
- onlyIfRunning: true,
400
- });
401
- return true;
398
+ try {
399
+ await resume({
400
+ conversationId: id,
401
+ onlyIfRunning: true,
402
+ });
403
+ return true;
404
+ }
405
+ catch (error) {
406
+ if (autoResumedKeyRef.current === resumeKey) {
407
+ autoResumedKeyRef.current = previousResumeKey;
408
+ }
409
+ throw error;
410
+ }
402
411
  }, [conversationId, isStreaming, refreshConversation, resume]);
403
412
  const stop = useCallback(async (explicitConversationId) => {
404
413
  const id = requireConversationId(explicitConversationId ?? conversationId);
package/dist/types.d.ts CHANGED
@@ -40,7 +40,7 @@ export type CreateAssistantInput = CreateAssistantRequest;
40
40
  export type UpdateAssistantInput = UpdateAssistantRequest;
41
41
  export type Conversation = ConversationResponse;
42
42
  export type ConversationMessage = ConversationMessageResponse;
43
- export type ConversationModel = `${AvailableModels}`;
43
+ export type ConversationModel = `${AvailableModels}` | (string & {});
44
44
  export type Task = TaskResponse;
45
45
  export type TaskMessage = TaskMessageResponse;
46
46
  export type FunctionRun = FunctionRunResponse;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lemma-sdk",
3
- "version": "0.2.20",
3
+ "version": "0.2.22",
4
4
  "description": "Official TypeScript SDK for Lemma pod-scoped APIs",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",