dsh-plugin-subscription-oauth 0.17.0 → 0.17.2

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/lib/protocol.d.ts CHANGED
@@ -397,6 +397,15 @@ export interface ModelVariants {
397
397
  fastRung?: string;
398
398
  /** Image-generation model wire id, when the provider reported one. */
399
399
  image?: string;
400
+ /**
401
+ * The route has not read its model listing yet, so "no variant" is not yet an answer.
402
+ *
403
+ * A listing is read once per session and its variants are derived from it, so an early
404
+ * read of this endpoint cannot tell "this model has no fast variant" from "the route has
405
+ * not looked yet". Saying so lets the caller ask again later instead of hiding a control
406
+ * that should be there — and, once a listing has been read, lets it stop asking.
407
+ */
408
+ pending?: boolean;
400
409
  }
401
410
  /** Result of `model-variants`: the variants for one model id. */
402
411
  export type ModelVariantsResult = ModelVariants | undefined;
@@ -176,9 +176,17 @@ export function createArkProvider(options) {
176
176
  removeModel: (id) => controller.removeModel(id),
177
177
  usage: (force) => controller.usage(force),
178
178
  reload: () => controller.reload(),
179
- modelVariants: (id) => {
179
+ modelVariants: async (id) => {
180
+ // The image model is a row of the listing, so the read is awaited before answering:
181
+ // the composer asks once, and "no image model" must mean there is none.
182
+ void id;
183
+ await adapter.hydrate();
180
184
  const image = adapter.imageModelId();
181
- return image === undefined ? undefined : { image };
185
+ return {
186
+ ...image === undefined ? {} : { image },
187
+ // Nothing read yet: the answer above is provisional, and the caller is told so.
188
+ ...adapter.hasListing() ? {} : { pending: true },
189
+ };
182
190
  },
183
191
  };
184
192
  }
@@ -163,6 +163,14 @@ export declare class ArkAdapter extends LlmAdapter {
163
163
  listModels(_provider: string): Promise<readonly LlmModelInfo[]>;
164
164
  /** The image-generation model id, when discovery reported one. */
165
165
  imageModelId(): string | undefined;
166
+ /**
167
+ * Whether this route has read a model listing yet.
168
+ *
169
+ * The image model is a row of the listing, so until one has been read "there is no image
170
+ * model" and "the listing has not arrived" look identical from outside; a caller that asks
171
+ * once needs to tell them apart.
172
+ */
173
+ hasListing(): boolean;
166
174
  resolveModel(_provider: string, model: string): Promise<LlmResolvedModelInfo>;
167
175
  stream(options: GenerateOptions): AsyncIterable<StreamChunk>;
168
176
  /** Translate an auth failure into the message a user can act on. */
@@ -228,6 +228,16 @@ export class ArkAdapter extends LlmAdapter {
228
228
  imageModelId() {
229
229
  return this.imageModel;
230
230
  }
231
+ /**
232
+ * Whether this route has read a model listing yet.
233
+ *
234
+ * The image model is a row of the listing, so until one has been read "there is no image
235
+ * model" and "the listing has not arrived" look identical from outside; a caller that asks
236
+ * once needs to tell them apart.
237
+ */
238
+ hasListing() {
239
+ return this.live !== undefined;
240
+ }
231
241
  async resolveModel(_provider, model) {
232
242
  await this.hydrate();
233
243
  const entry = findEntry(this.catalog(), model);
@@ -88,7 +88,8 @@ export declare function liveModelFacts(row: unknown): LiveModelFacts | undefined
88
88
  * What the endpoint stated wins; the curated table fills only what the listing was
89
89
  * silent about, so a live figure — the window a model actually serves today — is
90
90
  * never overridden by a shipped guess, and a retired figure never survives a live
91
- * read that contradicts it.
91
+ * read that contradicts it. A route whose ids are stable aliases the service re-points
92
+ * per release keeps its own labels instead (see the kimi route's alias labels).
92
93
  *
93
94
  * @param facts - what the listing stated for this id.
94
95
  * @param curated - the curated metadata table.
@@ -163,7 +163,8 @@ export function liveModelFacts(row) {
163
163
  * What the endpoint stated wins; the curated table fills only what the listing was
164
164
  * silent about, so a live figure — the window a model actually serves today — is
165
165
  * never overridden by a shipped guess, and a retired figure never survives a live
166
- * read that contradicts it.
166
+ * read that contradicts it. A route whose ids are stable aliases the service re-points
167
+ * per release keeps its own labels instead (see the kimi route's alias labels).
167
168
  *
168
169
  * @param facts - what the listing stated for this id.
169
170
  * @param curated - the curated metadata table.
@@ -172,9 +172,17 @@ export function createGlmProvider(options) {
172
172
  removeModel: (id) => controller.removeModel(id),
173
173
  usage: (force) => controller.usage(force),
174
174
  reload: () => controller.reload(),
175
- modelVariants: (id) => {
175
+ modelVariants: async (id) => {
176
+ // The image model is a row of the listing, so the read is awaited before answering:
177
+ // the composer asks once, and "no image model" must mean there is none.
178
+ void id;
179
+ await adapter.hydrate();
176
180
  const image = adapter.imageModelId();
177
- return image === undefined ? undefined : { image };
181
+ return {
182
+ ...image === undefined ? {} : { image },
183
+ // Nothing read yet: the answer above is provisional, and the caller is told so.
184
+ ...adapter.hasListing() ? {} : { pending: true },
185
+ };
178
186
  },
179
187
  };
180
188
  }
@@ -200,6 +200,14 @@ export declare class GlmAdapter extends LlmAdapter {
200
200
  private addedEntries;
201
201
  /** The image-generation model id, when discovery reported one. */
202
202
  imageModelId(): string | undefined;
203
+ /**
204
+ * Whether this route has read a model listing yet.
205
+ *
206
+ * The image model is a row of the listing, so until one has been read "there is no image
207
+ * model" and "the listing has not arrived" look identical from outside; a caller that asks
208
+ * once needs to tell them apart.
209
+ */
210
+ hasListing(): boolean;
203
211
  /** The ids the picker must currently omit. */
204
212
  private hidden;
205
213
  /**
@@ -380,6 +380,16 @@ export class GlmAdapter extends LlmAdapter {
380
380
  imageModelId() {
381
381
  return this.imageModel;
382
382
  }
383
+ /**
384
+ * Whether this route has read a model listing yet.
385
+ *
386
+ * The image model is a row of the listing, so until one has been read "there is no image
387
+ * model" and "the listing has not arrived" look identical from outside; a caller that asks
388
+ * once needs to tell them apart.
389
+ */
390
+ hasListing() {
391
+ return this.live !== undefined;
392
+ }
383
393
  /** The ids the picker must currently omit. */
384
394
  async hidden() {
385
395
  if (this.options.hiddenModels === undefined)
@@ -337,6 +337,14 @@ export declare class GrokAdapter extends LlmAdapter {
337
337
  } | undefined;
338
338
  /** The image-generation model id, when discovery reported one. */
339
339
  imageModelId(): string | undefined;
340
+ /**
341
+ * Whether this route has read a model listing yet.
342
+ *
343
+ * A variant is derived from the listing, so until one has been read "this model has no
344
+ * variant" and "the listing has not arrived" look identical from outside; a caller that
345
+ * asks once (the composer's quick-mode button) needs to tell them apart.
346
+ */
347
+ hasListing(): boolean;
340
348
  /** The curated catalog, for the Settings page's model list. */
341
349
  catalogEntries(): readonly CatalogEntry[];
342
350
  /** What this route itself serves, without the ids added by hand. */
@@ -868,6 +868,16 @@ export class GrokAdapter extends LlmAdapter {
868
868
  imageModelId() {
869
869
  return this.imageModel;
870
870
  }
871
+ /**
872
+ * Whether this route has read a model listing yet.
873
+ *
874
+ * A variant is derived from the listing, so until one has been read "this model has no
875
+ * variant" and "the listing has not arrived" look identical from outside; a caller that
876
+ * asks once (the composer's quick-mode button) needs to tell them apart.
877
+ */
878
+ hasListing() {
879
+ return this.live !== undefined;
880
+ }
871
881
  /** The curated catalog, for the Settings page's model list. */
872
882
  catalogEntries() {
873
883
  return this.catalog();
@@ -1193,12 +1203,19 @@ export function createGrokProvider(options) {
1193
1203
  removeModel: (id) => controller.removeModel(id),
1194
1204
  usage: (force) => controller.usage(force),
1195
1205
  reload: () => controller.reload(),
1196
- modelVariants: (id) => {
1206
+ modelVariants: async (id) => {
1207
+ // A variant is known only once the route has read its listing, whose rows name it.
1208
+ // The composer asks exactly once, so the read is awaited here: "no fast variant"
1209
+ // must mean the route really has none, not that its table was still loading.
1210
+ await adapter.hydrate();
1197
1211
  const pair = adapter.fastPairOf(id);
1198
1212
  const image = adapter.imageModelId();
1199
1213
  return {
1200
1214
  ...pair === undefined ? {} : { fast: pair.wireId, fastBase: pair.baseId, fastRung: fastWordOf(pair.wireId) },
1201
1215
  ...image === undefined ? {} : { image },
1216
+ // Nothing read yet: the answer above is provisional, and the caller is told so
1217
+ // rather than being left to read silence as "this model has no variant".
1218
+ ...adapter.hasListing() ? {} : { pending: true },
1202
1219
  };
1203
1220
  },
1204
1221
  };
@@ -156,12 +156,19 @@ export function createKimiProvider(options) {
156
156
  removeModel: (id) => controller.removeModel(id),
157
157
  usage: (force) => controller.usage(force),
158
158
  reload: () => controller.reload(),
159
- modelVariants: (id) => {
159
+ modelVariants: async (id) => {
160
+ // A variant is known only once the route has read its listing, whose row names it.
161
+ // The composer asks exactly once, so the read is awaited here: "no fast variant"
162
+ // must mean the route really has none, not that its table was still loading.
163
+ await adapter.hydrate();
160
164
  const pair = adapter.fastPairOf(id);
161
165
  const image = adapter.imageModelId();
162
166
  return {
163
167
  ...pair === undefined ? {} : { fast: pair.wireId, fastBase: pair.baseId, fastRung: fastWordOf(pair.wireId) },
164
168
  ...image === undefined ? {} : { image },
169
+ // Nothing read yet: the answer above is provisional, and the caller is told so
170
+ // rather than being left to read silence as "this model has no variant".
171
+ ...adapter.hasListing() ? {} : { pending: true },
165
172
  };
166
173
  },
167
174
  };
@@ -161,6 +161,16 @@ export declare class KimiAdapter extends LlmAdapter {
161
161
  * @returns the entries to publish, in picker order.
162
162
  */
163
163
  private adoptListing;
164
+ /**
165
+ * Put this route's stable label on one entry, when the id has one.
166
+ *
167
+ * The listing states what the model *is*; the label states what the *id* is, which only
168
+ * this route knows (see {@link STABLE_MODEL_LABELS}).
169
+ *
170
+ * @param entry - the entry built from the listing.
171
+ * @returns the entry, relabelled when its id has a stable label.
172
+ */
173
+ private labelled;
164
174
  /**
165
175
  * Read the cached listing, once, so the picker works without asking the endpoint.
166
176
  *
@@ -173,6 +183,14 @@ export declare class KimiAdapter extends LlmAdapter {
173
183
  baseId: string;
174
184
  wireId: string;
175
185
  } | undefined;
186
+ /**
187
+ * Whether this route has read a model listing yet.
188
+ *
189
+ * A variant is derived from the listing, so until one has been read "this model has no
190
+ * variant" and "the listing has not arrived" look identical from outside; a caller that
191
+ * asks once (the composer's quick-mode button) needs to tell them apart.
192
+ */
193
+ hasListing(): boolean;
176
194
  /**
177
195
  * Tool schemas in the shape Moonshot accepts: no `$ref`, and under its size
178
196
  * ceiling. A declaration too large to send keeps its arguments, and that is
@@ -71,6 +71,35 @@ const EFFORT_LABELS = {
71
71
  // is "highspeed", which is what the rung is named here.
72
72
  [FAST_RUNG]: { name: 'Highspeed', description: 'Kimi highspeed variant of this model' },
73
73
  };
74
+ /**
75
+ * Stable labels for the ids this route is known for.
76
+ *
77
+ * Labels, not a roster: nothing here decides which models exist or what they serve —
78
+ * membership, windows, ladders and modalities are the endpoint's listing. What it fixes is
79
+ * the one thing the listing cannot answer, because the id does not name it: `kimi-for-coding`
80
+ * is the subscription alias the service re-points at each coding release, so its
81
+ * `display_name` names the release of the moment (`K2.8 Preview` the week K2.8 ships) while
82
+ * the id the user selected stays the same. Labelling that row by release would rename the
83
+ * user's saved selection overnight, so the alias keeps its own name — and the endpoint's
84
+ * release name is deliberately not shown, because the row is the alias, not the version.
85
+ *
86
+ * A model the endpoint adds that this map does not name is published exactly as the
87
+ * endpoint describes it.
88
+ */
89
+ const STABLE_MODEL_LABELS = {
90
+ 'kimi-for-coding': {
91
+ name: 'Kimi for Coding',
92
+ description: 'Subscription alias — the endpoint re-points it at the current coding release',
93
+ },
94
+ 'k3': {
95
+ name: 'Kimi K3',
96
+ description: 'Flagship reasoning model',
97
+ },
98
+ 'k3-256k': {
99
+ name: 'Kimi K3 (256K)',
100
+ description: 'Flagship reasoning model pinned to the 256K-token window',
101
+ },
102
+ };
74
103
  /**
75
104
  * Spellings the endpoint has retired, and the model each one became.
76
105
  *
@@ -185,7 +214,20 @@ export class KimiAdapter extends LlmAdapter {
185
214
  if (id === fact.id || !byId.has(id))
186
215
  byId.set(id, { ...fact, id });
187
216
  }
188
- return [...byId.values()].map((fact) => liveEntry(fact, [], KIMI_CONTEXT_WINDOW, 'Reported by the Kimi Code model list'));
217
+ return [...byId.values()].map((fact) => this.labelled(liveEntry(fact, [], KIMI_CONTEXT_WINDOW, 'Reported by the Kimi Code model list')));
218
+ }
219
+ /**
220
+ * Put this route's stable label on one entry, when the id has one.
221
+ *
222
+ * The listing states what the model *is*; the label states what the *id* is, which only
223
+ * this route knows (see {@link STABLE_MODEL_LABELS}).
224
+ *
225
+ * @param entry - the entry built from the listing.
226
+ * @returns the entry, relabelled when its id has a stable label.
227
+ */
228
+ labelled(entry) {
229
+ const stable = STABLE_MODEL_LABELS[entry.id];
230
+ return stable === undefined ? entry : { ...entry, name: stable.name, description: stable.description };
189
231
  }
190
232
  /**
191
233
  * Read the cached listing, once, so the picker works without asking the endpoint.
@@ -212,6 +254,16 @@ export class KimiAdapter extends LlmAdapter {
212
254
  fastPairOf(id) {
213
255
  return this.fastVariants.pairOf(id);
214
256
  }
257
+ /**
258
+ * Whether this route has read a model listing yet.
259
+ *
260
+ * A variant is derived from the listing, so until one has been read "this model has no
261
+ * variant" and "the listing has not arrived" look identical from outside; a caller that
262
+ * asks once (the composer's quick-mode button) needs to tell them apart.
263
+ */
264
+ hasListing() {
265
+ return this.live !== undefined;
266
+ }
215
267
  /**
216
268
  * Tool schemas in the shape Moonshot accepts: no `$ref`, and under its size
217
269
  * ceiling. A declaration too large to send keeps its arguments, and that is
@@ -465,7 +517,7 @@ export class KimiAdapter extends LlmAdapter {
465
517
  discover(ids) {
466
518
  const known = new Map((this.live ?? []).map((entry) => [entry.id, entry]));
467
519
  this.live = ids.map((id) => known.get(id)
468
- ?? liveEntry({ id }, [], KIMI_CONTEXT_WINDOW, 'Reported by the Kimi Code model list'));
520
+ ?? this.labelled(liveEntry({ id }, [], KIMI_CONTEXT_WINDOW, 'Reported by the Kimi Code model list')));
469
521
  }
470
522
  /** The catalog, for the Settings page's model list. */
471
523
  catalogEntries() {
@@ -181,18 +181,20 @@ export declare class WorkBuddyAdapter extends LlmAdapter {
181
181
  /**
182
182
  * The stored session one access token belongs to.
183
183
  *
184
- * Discovery is handed the account's token rather than the session it came from, and the
185
- * console route is attributed by that session's own fields, so the session is read back
186
- * here. The first account whose stored token matches wins; a token that matches none
187
- * falls back to the route's current session. A read failure is never fatal — the listing
188
- * is still read with the token the caller passed.
189
- *
190
- * @param accessToken - the token discovery was called with.
191
- * @returns the session's fields as a signable token, or undefined when none was readable.
184
+ * The chat signs with the resolved token, but the request still needs the session's
185
+ * attribution fields (`uid`, `enterpriseId`, `domain`). They are read back by matching
186
+ * the resolved token against each stored account; a token that matches none (already
187
+ * rotated away) leaves the caller to sign with the bare token.
192
188
  */
193
189
  private sessionFor;
194
190
  providerRetryPolicy(): undefined;
195
191
  stream(options: GenerateOptions): AsyncIterable<StreamChunk>;
192
+ /**
193
+ * One chat-completions POST, named the way WorkBuddy's own client names a chat: the
194
+ * message, the request, the turn, and the conversation. Without these the gateway
195
+ * counts the request under no client and can cut the stream.
196
+ */
197
+ private post;
196
198
  /** The stored session's fields as the token the request signs with. */
197
199
  private tokenOf;
198
200
  private requestBody;
@@ -614,14 +614,10 @@ export class WorkBuddyAdapter extends LlmAdapter {
614
614
  /**
615
615
  * The stored session one access token belongs to.
616
616
  *
617
- * Discovery is handed the account's token rather than the session it came from, and the
618
- * console route is attributed by that session's own fields, so the session is read back
619
- * here. The first account whose stored token matches wins; a token that matches none
620
- * falls back to the route's current session. A read failure is never fatal — the listing
621
- * is still read with the token the caller passed.
622
- *
623
- * @param accessToken - the token discovery was called with.
624
- * @returns the session's fields as a signable token, or undefined when none was readable.
617
+ * The chat signs with the resolved token, but the request still needs the session's
618
+ * attribution fields (`uid`, `enterpriseId`, `domain`). They are read back by matching
619
+ * the resolved token against each stored account; a token that matches none (already
620
+ * rotated away) leaves the caller to sign with the bare token.
625
621
  */
626
622
  async sessionFor(accessToken) {
627
623
  try {
@@ -630,8 +626,7 @@ export class WorkBuddyAdapter extends LlmAdapter {
630
626
  if (held?.session.accessToken === accessToken)
631
627
  return this.tokenOf(held.session);
632
628
  }
633
- const current = await this.options.tokens.session();
634
- return current === undefined ? undefined : this.tokenOf(current.session);
629
+ return undefined;
635
630
  }
636
631
  catch {
637
632
  return undefined;
@@ -642,10 +637,11 @@ export class WorkBuddyAdapter extends LlmAdapter {
642
637
  }
643
638
  async *stream(options) {
644
639
  diag(`stream entered: model=${options.model} provider=${options.provider}`);
645
- const held = await this.options.tokens.session();
646
- if (held === undefined)
647
- throw new Error('WorkBuddy is not signed in');
648
- const token = this.tokenOf(held.session);
640
+ // Resolve (not just read) the session so a token close to its end is refreshed
641
+ // eagerly, the way WorkBuddy's own app does — a bare session can carry an expired
642
+ // access token into the request, which the gateway refuses with a 403.
643
+ const held = await this.options.tokens.resolve();
644
+ const session = await this.sessionFor(held.accessToken);
649
645
  const watchdog = idleWatchdog(options.signal, this.options.streamIdleTimeoutMs);
650
646
  // Capture the tool-call history so unsafe tool names in past turns can be renamed to
651
647
  // the safe ones the model now sees, keeping the conversation consistent.
@@ -653,28 +649,24 @@ export class WorkBuddyAdapter extends LlmAdapter {
653
649
  // The last usage frame carries the turn's real billed credit; record it so the spend
654
650
  // total is a sum of real figures, not an estimate.
655
651
  const turn = { credit: 0 };
656
- diag(`stream start: model=${options.model} account=${held.account} expiresAt=${held.session.expiresAt} now=${Date.now()}`);
652
+ diag(`stream start: model=${options.model} account=${held.account} expiresAt=${held.expiresAt} now=${Date.now()}`);
657
653
  try {
658
654
  const { body, names } = this.requestBody(options, history);
659
655
  diag(`body: keys=${Object.keys(body).join(',')} messages=${Array.isArray(body.messages) ? body.messages.length : 0} model=${String(body.model)}`);
660
- const response = await this.fetchFn(WORKBUDDY_API, {
661
- method: 'POST',
662
- headers: {
663
- 'Content-Type': 'application/json',
664
- 'Accept': 'text/event-stream',
665
- ...workbuddyHeaders(token),
666
- // Name the chat the way WorkBuddy's own client does: the message, the request,
667
- // the turn, and the conversation. Without these the gateway counts the request
668
- // under no client and can cut the stream.
669
- 'X-Conversation-Message-ID': randomHex(),
670
- 'X-Request-ID': randomHex(),
671
- 'X-Conversation-Request-ID': randomHex(),
672
- 'X-Conversation-ID': randomHex(),
673
- },
674
- body: JSON.stringify(body),
675
- signal: watchdog.signal,
676
- });
677
- diag(`response: status=${response.status} ok=${response.ok} ct=${response.headers.get('content-type') ?? ''}`);
656
+ // Post once with the resolved token. A 401/403 can mean the access token lapsed
657
+ // between resolve() and now: force one refresh and post again, as WorkBuddy's own
658
+ // client does — a 403 with a genuinely dead credential keeps its meaning below.
659
+ let response = await this.post(body, session ?? held, watchdog.signal);
660
+ if (response.status === 401 || response.status === 403) {
661
+ diag(`response: ${response.status} on resolved token; forcing one refresh and retrying`);
662
+ const refreshed = await this.options.tokens.resolve(held.account, true);
663
+ const fresh = await this.sessionFor(refreshed.accessToken);
664
+ response = await this.post(body, fresh ?? refreshed, watchdog.signal);
665
+ diag(`response after refresh: status=${response.status} ok=${response.ok} ct=${response.headers.get('content-type') ?? ''}`);
666
+ }
667
+ else {
668
+ diag(`response: status=${response.status} ok=${response.ok} ct=${response.headers.get('content-type') ?? ''}`);
669
+ }
678
670
  // WorkBuddy refuses a chat mid-stream when the request carries words it filters; a
679
671
  // 403/400 here names why, so surface it instead of dying on an empty stream.
680
672
  if (!response.ok || response.body === null) {
@@ -692,6 +684,27 @@ export class WorkBuddyAdapter extends LlmAdapter {
692
684
  }
693
685
  }
694
686
  }
687
+ /**
688
+ * One chat-completions POST, named the way WorkBuddy's own client names a chat: the
689
+ * message, the request, the turn, and the conversation. Without these the gateway
690
+ * counts the request under no client and can cut the stream.
691
+ */
692
+ post(body, token, signal) {
693
+ return this.fetchFn(WORKBUDDY_API, {
694
+ method: 'POST',
695
+ headers: {
696
+ 'Content-Type': 'application/json',
697
+ 'Accept': 'text/event-stream',
698
+ ...workbuddyHeaders(token),
699
+ 'X-Conversation-Message-ID': randomHex(),
700
+ 'X-Request-ID': randomHex(),
701
+ 'X-Conversation-Request-ID': randomHex(),
702
+ 'X-Conversation-ID': randomHex(),
703
+ },
704
+ body: JSON.stringify(body),
705
+ signal,
706
+ });
707
+ }
695
708
  /** The stored session's fields as the token the request signs with. */
696
709
  tokenOf(session) {
697
710
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-subscription-oauth",
3
- "version": "0.17.0",
3
+ "version": "0.17.2",
4
4
  "description": "Use subscription accounts as DeepSeek Harness LLM providers — Kimi Code (OAuth device flow), Grok / X Premium (OIDC + PKCE), Google Antigravity (Google OAuth + Cloud Code Assist) and GLM / BigModel (ZCode's browser sign-in) — with per-model visibility, drag-to-reorder Auto fallback and plan quotas from the web Settings page",
5
5
  "license": "MIT",
6
6
  "author": "EricFL1998",