@theia/ai-core 1.76.0-next.6 → 1.76.0

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.
Files changed (128) hide show
  1. package/lib/browser/ai-core-frontend-module.d.ts.map +1 -1
  2. package/lib/browser/ai-core-frontend-module.js +4 -0
  3. package/lib/browser/ai-core-frontend-module.js.map +1 -1
  4. package/lib/browser/discovering-provider-contribution.d.ts +92 -0
  5. package/lib/browser/discovering-provider-contribution.d.ts.map +1 -0
  6. package/lib/browser/discovering-provider-contribution.js +189 -0
  7. package/lib/browser/discovering-provider-contribution.js.map +1 -0
  8. package/lib/browser/discovering-provider-contribution.spec.d.ts +2 -0
  9. package/lib/browser/discovering-provider-contribution.spec.d.ts.map +1 -0
  10. package/lib/browser/discovering-provider-contribution.spec.js +233 -0
  11. package/lib/browser/discovering-provider-contribution.spec.js.map +1 -0
  12. package/lib/browser/favorite-models-service.d.ts +135 -0
  13. package/lib/browser/favorite-models-service.d.ts.map +1 -0
  14. package/lib/browser/favorite-models-service.js +279 -0
  15. package/lib/browser/favorite-models-service.js.map +1 -0
  16. package/lib/browser/favorite-models-service.spec.d.ts +2 -0
  17. package/lib/browser/favorite-models-service.spec.d.ts.map +1 -0
  18. package/lib/browser/favorite-models-service.spec.js +277 -0
  19. package/lib/browser/favorite-models-service.spec.js.map +1 -0
  20. package/lib/browser/frontend-language-model-alias-registry.js +1 -1
  21. package/lib/browser/frontend-language-model-service.d.ts.map +1 -1
  22. package/lib/browser/frontend-language-model-service.js +8 -0
  23. package/lib/browser/frontend-language-model-service.js.map +1 -1
  24. package/lib/browser/frontend-language-model-service.spec.d.ts +2 -0
  25. package/lib/browser/frontend-language-model-service.spec.d.ts.map +1 -0
  26. package/lib/browser/frontend-language-model-service.spec.js +76 -0
  27. package/lib/browser/frontend-language-model-service.spec.js.map +1 -0
  28. package/lib/browser/index.d.ts +3 -0
  29. package/lib/browser/index.d.ts.map +1 -1
  30. package/lib/browser/index.js +3 -0
  31. package/lib/browser/index.js.map +1 -1
  32. package/lib/browser/model-discovery-status-service.d.ts +52 -0
  33. package/lib/browser/model-discovery-status-service.d.ts.map +1 -0
  34. package/lib/browser/model-discovery-status-service.js +110 -0
  35. package/lib/browser/model-discovery-status-service.js.map +1 -0
  36. package/lib/browser/model-discovery-status-service.spec.d.ts +2 -0
  37. package/lib/browser/model-discovery-status-service.spec.d.ts.map +1 -0
  38. package/lib/browser/model-discovery-status-service.spec.js +86 -0
  39. package/lib/browser/model-discovery-status-service.spec.js.map +1 -0
  40. package/lib/common/ai-core-preferences.d.ts +11 -0
  41. package/lib/common/ai-core-preferences.d.ts.map +1 -1
  42. package/lib/common/ai-core-preferences.js +37 -1
  43. package/lib/common/ai-core-preferences.js.map +1 -1
  44. package/lib/common/index.d.ts +2 -0
  45. package/lib/common/index.d.ts.map +1 -1
  46. package/lib/common/index.js +2 -0
  47. package/lib/common/index.js.map +1 -1
  48. package/lib/common/language-model-ordering.spec.d.ts +2 -0
  49. package/lib/common/language-model-ordering.spec.d.ts.map +1 -0
  50. package/lib/common/language-model-ordering.spec.js +72 -0
  51. package/lib/common/language-model-ordering.spec.js.map +1 -0
  52. package/lib/common/language-model-util.d.ts +17 -1
  53. package/lib/common/language-model-util.d.ts.map +1 -1
  54. package/lib/common/language-model-util.js +44 -1
  55. package/lib/common/language-model-util.js.map +1 -1
  56. package/lib/common/language-model.d.ts +21 -0
  57. package/lib/common/language-model.d.ts.map +1 -1
  58. package/lib/common/language-model.js +50 -1
  59. package/lib/common/language-model.js.map +1 -1
  60. package/lib/common/language-model.spec.js +36 -0
  61. package/lib/common/language-model.spec.js.map +1 -1
  62. package/lib/common/model-discovery-status.d.ts +112 -0
  63. package/lib/common/model-discovery-status.d.ts.map +1 -0
  64. package/lib/common/model-discovery-status.js +18 -0
  65. package/lib/common/model-discovery-status.js.map +1 -0
  66. package/lib/common/model-discovery-util.d.ts +46 -0
  67. package/lib/common/model-discovery-util.d.ts.map +1 -0
  68. package/lib/common/model-discovery-util.js +128 -0
  69. package/lib/common/model-discovery-util.js.map +1 -0
  70. package/lib/common/model-discovery-util.spec.d.ts +2 -0
  71. package/lib/common/model-discovery-util.spec.d.ts.map +1 -0
  72. package/lib/common/model-discovery-util.spec.js +95 -0
  73. package/lib/common/model-discovery-util.spec.js.map +1 -0
  74. package/lib/common/prompt-service.js +3 -3
  75. package/lib/common/prompt-service.js.map +1 -1
  76. package/lib/common/prompt-service.spec.js +5 -0
  77. package/lib/common/prompt-service.spec.js.map +1 -1
  78. package/lib/node/ai-core-backend-module.d.ts.map +1 -1
  79. package/lib/node/ai-core-backend-module.js +8 -0
  80. package/lib/node/ai-core-backend-module.js.map +1 -1
  81. package/lib/node/backend-language-model-registry.d.ts.map +1 -1
  82. package/lib/node/backend-language-model-registry.js +1 -0
  83. package/lib/node/backend-language-model-registry.js.map +1 -1
  84. package/lib/node/index.d.ts +2 -0
  85. package/lib/node/index.d.ts.map +1 -1
  86. package/lib/node/index.js +2 -0
  87. package/lib/node/index.js.map +1 -1
  88. package/lib/node/model-discovery-fetcher.d.ts +43 -0
  89. package/lib/node/model-discovery-fetcher.d.ts.map +1 -0
  90. package/lib/node/model-discovery-fetcher.js +85 -0
  91. package/lib/node/model-discovery-fetcher.js.map +1 -0
  92. package/lib/node/model-snapshot-store.d.ts +26 -0
  93. package/lib/node/model-snapshot-store.d.ts.map +1 -0
  94. package/lib/node/model-snapshot-store.js +73 -0
  95. package/lib/node/model-snapshot-store.js.map +1 -0
  96. package/lib/node/test/test-model-discovery-fetcher.d.ts +21 -0
  97. package/lib/node/test/test-model-discovery-fetcher.d.ts.map +1 -0
  98. package/lib/node/test/test-model-discovery-fetcher.js +54 -0
  99. package/lib/node/test/test-model-discovery-fetcher.js.map +1 -0
  100. package/package.json +12 -12
  101. package/src/browser/ai-core-frontend-module.ts +5 -0
  102. package/src/browser/discovering-provider-contribution.spec.ts +304 -0
  103. package/src/browser/discovering-provider-contribution.ts +241 -0
  104. package/src/browser/favorite-models-service.spec.ts +336 -0
  105. package/src/browser/favorite-models-service.ts +297 -0
  106. package/src/browser/frontend-language-model-alias-registry.ts +1 -1
  107. package/src/browser/frontend-language-model-service.spec.ts +86 -0
  108. package/src/browser/frontend-language-model-service.ts +9 -1
  109. package/src/browser/index.ts +3 -0
  110. package/src/browser/model-discovery-status-service.spec.ts +101 -0
  111. package/src/browser/model-discovery-status-service.ts +123 -0
  112. package/src/common/ai-core-preferences.ts +40 -0
  113. package/src/common/index.ts +2 -0
  114. package/src/common/language-model-ordering.spec.ts +83 -0
  115. package/src/common/language-model-util.ts +43 -0
  116. package/src/common/language-model.spec.ts +45 -0
  117. package/src/common/language-model.ts +54 -0
  118. package/src/common/model-discovery-status.ts +132 -0
  119. package/src/common/model-discovery-util.spec.ts +105 -0
  120. package/src/common/model-discovery-util.ts +129 -0
  121. package/src/common/prompt-service.spec.ts +6 -0
  122. package/src/common/prompt-service.ts +3 -3
  123. package/src/node/ai-core-backend-module.ts +8 -0
  124. package/src/node/backend-language-model-registry.ts +1 -0
  125. package/src/node/index.ts +2 -0
  126. package/src/node/model-discovery-fetcher.ts +105 -0
  127. package/src/node/model-snapshot-store.ts +80 -0
  128. package/src/node/test/test-model-discovery-fetcher.ts +62 -0
@@ -41,6 +41,38 @@ export interface ReasoningSupport {
41
41
  readonly supportedLevels: ReadonlyArray<ReasoningLevel>;
42
42
  readonly defaultLevel?: ReasoningLevel;
43
43
  }
44
+ export namespace ReasoningSupport {
45
+ /** Levels ordered by increasing effort; `'auto'` sits outside the scale. */
46
+ const EFFORT_SCALE: ReadonlyArray<ReasoningLevel> = ['off', 'minimal', 'low', 'medium', 'high'];
47
+
48
+ /**
49
+ * Returns `level` when `support` lists it, otherwise the nearest supported level on the effort scale,
50
+ * preferring the higher neighbour (an unsupported `minimal` becomes `low`, not `off`). An unsupported
51
+ * `'auto'` resolves to `defaultLevel`, then to the first supported level. Returns `level` unchanged
52
+ * when nothing suitable is supported.
53
+ */
54
+ export function clampLevel(support: ReasoningSupport, level: ReasoningLevel): ReasoningLevel {
55
+ const supported = support.supportedLevels;
56
+ if (supported.includes(level)) {
57
+ return level;
58
+ }
59
+ if (level === 'auto') {
60
+ return support.defaultLevel && supported.includes(support.defaultLevel) ? support.defaultLevel : supported[0] ?? level;
61
+ }
62
+ const index = EFFORT_SCALE.indexOf(level);
63
+ for (let distance = 1; distance < EFFORT_SCALE.length; distance++) {
64
+ const higher = EFFORT_SCALE[index + distance];
65
+ if (higher && supported.includes(higher)) {
66
+ return higher;
67
+ }
68
+ const lower = EFFORT_SCALE[index - distance];
69
+ if (lower && supported.includes(lower)) {
70
+ return lower;
71
+ }
72
+ }
73
+ return supported.includes('auto') ? 'auto' : level;
74
+ }
75
+ }
44
76
 
45
77
  export type LanguageModelMessage =
46
78
  TextMessage | ThinkingMessage | ToolUseMessage | ToolResultMessage | ServerToolUseMessage | ImageMessage | CompactionMessage;
@@ -319,6 +351,23 @@ export namespace ToolRequest {
319
351
  (!('required' in obj) || (Array.isArray(obj.required) && obj.required.every(prop => typeof prop === 'string')));
320
352
  }
321
353
  }
354
+
355
+ /**
356
+ * Resolves the `headers` attribute of a custom model preference entry. Since preferences are
357
+ * user-authored JSON, entries with a non-string value are dropped. Returns `undefined` when no
358
+ * usable header remains, so that the default request headers are left untouched. The keys are
359
+ * sorted, so equal header maps stringify identically regardless of their order in the preference.
360
+ */
361
+ export function resolveCustomModelHeaders(headers: unknown): Record<string, string> | undefined {
362
+ if (typeof headers !== 'object' || !headers || Array.isArray(headers)) {
363
+ return undefined;
364
+ }
365
+ const resolved = Object.entries(headers)
366
+ .filter((entry): entry is [string, string] => typeof entry[1] === 'string')
367
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
368
+ return resolved.length > 0 ? Object.fromEntries(resolved) : undefined;
369
+ }
370
+
322
371
  // Anthropic requires at least 50,000 tokens, so use one conservative minimum for all compaction settings.
323
372
  export const SERVER_SIDE_COMPACTION_TOKEN_THRESHOLD_MINIMUM = 50_000;
324
373
 
@@ -650,6 +699,11 @@ export interface LanguageModelMetaData {
650
699
  readonly family?: string;
651
700
  readonly maxInputTokens?: number;
652
701
  readonly maxOutputTokens?: number;
702
+ /**
703
+ * Release date (ms since epoch) as reported by the provider, where it reports one. Lets the model
704
+ * lists offer the newest models first instead of ordering them by name.
705
+ */
706
+ readonly released?: number;
653
707
  readonly status: LanguageModelStatus;
654
708
  readonly reasoningSupport?: ReasoningSupport;
655
709
  /**
@@ -0,0 +1,132 @@
1
+ // *****************************************************************************
2
+ // Copyright (C) 2026 EclipseSource GmbH.
3
+ //
4
+ // This program and the accompanying materials are made available under the
5
+ // terms of the Eclipse Public License v. 2.0 which is available at
6
+ // http://www.eclipse.org/legal/epl-2.0.
7
+ //
8
+ // This Source Code may also be made available under the following Secondary
9
+ // Licenses when the conditions for such availability set forth in the Eclipse
10
+ // Public License v. 2.0 are satisfied: GNU General Public License, version 2
11
+ // with the GNU Classpath Exception which is available at
12
+ // https://www.gnu.org/software/classpath/license.html.
13
+ //
14
+ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
15
+ // *****************************************************************************
16
+
17
+ /**
18
+ * Provider-level state of dynamic model discovery.
19
+ *
20
+ * This is distinct from the per-model {@link LanguageModelStatus} (`ready`/`unavailable`):
21
+ * it describes whether a provider's list of models is currently being fetched, has failed,
22
+ * needs credentials or user consent, etc. It gives the UI a place to communicate fetching
23
+ * progress and error state for the whole provider.
24
+ */
25
+ export type ModelDiscoveryState =
26
+ /** No credential (API key / host) is configured, so nothing can be fetched. */
27
+ | 'no-credentials'
28
+ /** A credential was found in the environment and awaits the user's one-time confirmation. */
29
+ | 'awaiting-consent'
30
+ /** Nothing in progress; no fetch has run yet. */
31
+ | 'idle'
32
+ /** A fetch is currently in flight. */
33
+ | 'fetching'
34
+ /** The last fetch (or cache load) succeeded and models are registered. */
35
+ | 'ready'
36
+ /** The last fetch failed. */
37
+ | 'error'
38
+ /** The model list is configured manually, so the provider is not asked what it offers. */
39
+ | 'overridden';
40
+
41
+ /** Where a provider's effective API key comes from. Used to gate env-var keys behind a one-time consent. */
42
+ export type ApiKeySource = 'preference' | 'environment' | 'none';
43
+
44
+ /**
45
+ * A model a provider reported from its list endpoint.
46
+ *
47
+ * Only {@link id} is guaranteed: the endpoints differ widely in what they report about a model.
48
+ * Anthropic gives a display name and a release date, Google additionally a description, and
49
+ * OpenAI nothing but the id.
50
+ */
51
+ export interface DiscoveredModel {
52
+ /** Provider-native model id, without the `<provider>/` prefix. */
53
+ readonly id: string;
54
+ /** Human-readable name reported by the provider, e.g. `Claude Opus 5`. */
55
+ readonly label?: string;
56
+ /** Longer description reported by the provider. */
57
+ readonly description?: string;
58
+ /** Release date (ms since epoch) reported by the provider, used to order dated variants. */
59
+ readonly released?: number;
60
+ /**
61
+ * Set by a provider that knows better than the generic ranking which of its models should be
62
+ * offered in the model pickers without the user asking. GitHub Copilot serves several vendors, so
63
+ * "the five newest" would offer five models of whichever vendor names its models with the highest
64
+ * numbers; it therefore nominates one per vendor itself. Left unset by the other providers.
65
+ */
66
+ readonly featured?: boolean;
67
+ }
68
+
69
+ /**
70
+ * Result of a provider model-discovery attempt.
71
+ *
72
+ * On a successful live fetch, `fromCache` is `false`. When the live fetch fails but a cached
73
+ * snapshot is available, the cached models are returned with `fromCache: true` and `error` set to
74
+ * the failure reason, so the UI can keep the models usable while indicating they may be stale.
75
+ */
76
+ export interface ModelDiscoveryResult {
77
+ /** The discovered models, in the order they should be registered. */
78
+ readonly models: DiscoveredModel[];
79
+ /** Whether the models came from the cached snapshot because the live fetch failed. */
80
+ readonly fromCache: boolean;
81
+ /** Failure reason when the live fetch failed (even if a cached result was returned). */
82
+ readonly error?: string;
83
+ }
84
+
85
+ /**
86
+ * What to do about a discovery state, offered as a button next to the explanation.
87
+ *
88
+ * Most providers need none: what a missing credential wants is the API key setting, which sits on the
89
+ * same page right above. A provider whose credential is established by a command rather than a
90
+ * preference — GitHub Copilot signs in through its CLI — names that command here.
91
+ */
92
+ export interface ModelDiscoveryAction {
93
+ /** Button label, already localized. */
94
+ readonly label: string;
95
+ readonly commandId: string;
96
+ readonly args?: unknown[];
97
+ }
98
+
99
+ /**
100
+ * Snapshot of a provider's model-discovery state, as observed on the frontend.
101
+ */
102
+ export interface ModelDiscoveryStatus {
103
+ /** Provider id, e.g. `anthropic`. Matches the provider node in the Models configuration category. */
104
+ readonly providerId: string;
105
+ /** Human-readable provider label, e.g. `Anthropic`. */
106
+ readonly label: string;
107
+ /**
108
+ * Prefix used for this provider's registered model ids (e.g. `openai` while the provider id is
109
+ * `openAiOfficial`). Defaults to {@link providerId} when omitted.
110
+ */
111
+ readonly modelIdPrefix?: string;
112
+ readonly state: ModelDiscoveryState;
113
+ /**
114
+ * Overrides the badge label the state would otherwise carry, for a provider the generic wording
115
+ * does not fit: what GitHub Copilot lacks when it has no credential is a sign-in, not an API key.
116
+ */
117
+ readonly stateLabel?: string;
118
+ /** Optional human-readable detail (error message, hint). */
119
+ readonly message?: string;
120
+ /** Timestamp (ms since epoch) of the last successful fetch, if any. */
121
+ readonly lastFetch?: number;
122
+ /** Whether the currently registered models came from a cached snapshot rather than a live fetch. */
123
+ readonly fromCache?: boolean;
124
+ /**
125
+ * What the provider reported about the models it discovered, keyed by id in
126
+ * {@link DiscoveredModel.id}. The registry stays the source of truth for which models are
127
+ * registered; this only carries the extra metadata (label, description) for display.
128
+ */
129
+ readonly discovered?: readonly DiscoveredModel[];
130
+ /** What the user can do about the current state, where a command settles it. */
131
+ readonly action?: ModelDiscoveryAction;
132
+ }
@@ -0,0 +1,105 @@
1
+ // *****************************************************************************
2
+ // Copyright (C) 2026 EclipseSource GmbH.
3
+ //
4
+ // This program and the accompanying materials are made available under the
5
+ // terms of the Eclipse Public License v. 2.0 which is available at
6
+ // http://www.eclipse.org/legal/epl-2.0.
7
+ //
8
+ // This Source Code may also be made available under the following Secondary
9
+ // Licenses when the conditions for such availability set forth in the Eclipse
10
+ // Public License v. 2.0 are satisfied: GNU General Public License, version 2
11
+ // with the GNU Classpath Exception which is available at
12
+ // https://www.gnu.org/software/classpath/license.html.
13
+ //
14
+ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
15
+ // *****************************************************************************
16
+
17
+ import { expect } from 'chai';
18
+ import { DiscoveredModels } from './model-discovery-util';
19
+
20
+ describe('DiscoveredModels.undatedId', () => {
21
+
22
+ it('strips a compact date suffix, as Anthropic uses', () => {
23
+ expect(DiscoveredModels.undatedId('claude-opus-5-20260401')).to.equal('claude-opus-5');
24
+ });
25
+
26
+ it('strips a dashed date suffix, as OpenAI uses', () => {
27
+ expect(DiscoveredModels.undatedId('gpt-4o-2024-11-20')).to.equal('gpt-4o');
28
+ });
29
+
30
+ it('strips the year-less snapshot suffix of OpenAI\'s older models', () => {
31
+ expect(DiscoveredModels.undatedId('gpt-4-0613')).to.equal('gpt-4');
32
+ expect(DiscoveredModels.undatedId('gpt-3.5-turbo-0125')).to.equal('gpt-3.5-turbo');
33
+ });
34
+
35
+ it('returns undefined for an id without a date suffix', () => {
36
+ expect(DiscoveredModels.undatedId('claude-opus-5')).to.equal(undefined);
37
+ expect(DiscoveredModels.undatedId('gemini-3.7-flash')).to.equal(undefined);
38
+ // A version suffix is not a date and must not be mistaken for one.
39
+ expect(DiscoveredModels.undatedId('gemini-1.5-pro-002')).to.equal(undefined);
40
+ // Nor is a context size, even though it is four digits like a month and day.
41
+ expect(DiscoveredModels.undatedId('some-model-4096')).to.equal(undefined);
42
+ expect(DiscoveredModels.undatedId('gpt-4-1340')).to.equal(undefined);
43
+ });
44
+ });
45
+
46
+ describe('DiscoveredModels.withUndatedAliases', () => {
47
+
48
+ it('adds the undated alias while keeping the release-pinned ids', () => {
49
+ const result = DiscoveredModels.withUndatedAliases([
50
+ { id: 'claude-opus-5-20260401' },
51
+ { id: 'claude-haiku-4-5-20251001' }
52
+ ]);
53
+ expect(result.map(model => model.id)).to.deep.equal([
54
+ 'claude-opus-5',
55
+ 'claude-haiku-4-5',
56
+ 'claude-opus-5-20260401',
57
+ 'claude-haiku-4-5-20251001'
58
+ ]);
59
+ });
60
+
61
+ it('adds one alias per family and gives it the metadata of the newest release', () => {
62
+ const result = DiscoveredModels.withUndatedAliases([
63
+ { id: 'claude-opus-5-20251120', label: 'Claude Opus 5 (November)', released: Date.parse('2025-11-20') },
64
+ { id: 'claude-opus-5-20260401', label: 'Claude Opus 5', released: Date.parse('2026-04-01') }
65
+ ]);
66
+ expect(result.map(model => model.id)).to.deep.equal([
67
+ 'claude-opus-5',
68
+ 'claude-opus-5-20251120',
69
+ 'claude-opus-5-20260401'
70
+ ]);
71
+ expect(result[0].label).to.equal('Claude Opus 5');
72
+ });
73
+
74
+ it('orders the variants by the reported release date, which the year-less ids cannot express', () => {
75
+ const result = DiscoveredModels.withUndatedAliases([
76
+ // June 2023 sorts after January 2024 by id alone; the reported dates say otherwise.
77
+ { id: 'gpt-4-0613', label: 'older', released: Date.parse('2023-06-13') },
78
+ { id: 'gpt-4-0125', label: 'newer', released: Date.parse('2024-01-25') }
79
+ ]);
80
+ expect(result[0].id).to.equal('gpt-4');
81
+ expect(result[0].label).to.equal('newer');
82
+ });
83
+
84
+ it('falls back to the date in the id when the provider reports none', () => {
85
+ const result = DiscoveredModels.withUndatedAliases([
86
+ { id: 'claude-opus-5-20251120', label: 'older' },
87
+ { id: 'claude-opus-5-20260401', label: 'newer' }
88
+ ]);
89
+ expect(result[0].label).to.equal('newer');
90
+ });
91
+
92
+ it('leaves an alias the provider reported itself untouched', () => {
93
+ const result = DiscoveredModels.withUndatedAliases([
94
+ { id: 'gpt-4o', label: 'GPT-4o' },
95
+ { id: 'gpt-4o-2024-11-20', label: 'GPT-4o (2024-11-20)' }
96
+ ]);
97
+ expect(result.map(model => model.id)).to.deep.equal(['gpt-4o', 'gpt-4o-2024-11-20']);
98
+ expect(result[0].label).to.equal('GPT-4o');
99
+ });
100
+
101
+ it('passes undated models through unchanged', () => {
102
+ const models = [{ id: 'gemini-3.7-flash' }, { id: 'gemini-3.1-pro-preview' }];
103
+ expect(DiscoveredModels.withUndatedAliases(models)).to.deep.equal(models);
104
+ });
105
+ });
@@ -0,0 +1,129 @@
1
+ // *****************************************************************************
2
+ // Copyright (C) 2026 EclipseSource GmbH.
3
+ //
4
+ // This program and the accompanying materials are made available under the
5
+ // terms of the Eclipse Public License v. 2.0 which is available at
6
+ // http://www.eclipse.org/legal/epl-2.0.
7
+ //
8
+ // This Source Code may also be made available under the following Secondary
9
+ // Licenses when the conditions for such availability set forth in the Eclipse
10
+ // Public License v. 2.0 are satisfied: GNU General Public License, version 2
11
+ // with the GNU Classpath Exception which is available at
12
+ // https://www.gnu.org/software/classpath/license.html.
13
+ //
14
+ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
15
+ // *****************************************************************************
16
+
17
+ import { DiscoveredModel } from './model-discovery-status';
18
+
19
+ /**
20
+ * Helpers for the dated model ids that provider list endpoints report. Providers call these from
21
+ * an overridable method of their language-models manager, so a provider (or an adopter) can opt
22
+ * out or refine the behaviour without reimplementing it.
23
+ */
24
+ export namespace DiscoveredModels {
25
+
26
+ /**
27
+ * The date suffix providers append to a release-pinned id: `-20260401` and `-2026-04-01`, plus the
28
+ * `-0613` (month and day only) form OpenAI used for its older snapshots. The month and day are
29
+ * range-checked so that an id merely ending in four digits — a context size, a version — is not
30
+ * mistaken for a release.
31
+ */
32
+ const DATE_SUFFIX = /-(?:(\d{4})-?(0[1-9]|1[0-2])-?(0[1-9]|[12]\d|3[01])|(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01]))$/;
33
+
34
+ /**
35
+ * The undated form of a release-pinned model id, e.g. `claude-opus-5-20260401` → `claude-opus-5`.
36
+ * Returns `undefined` when the id carries no date suffix.
37
+ */
38
+ export function undatedId(id: string): string | undefined {
39
+ const match = DATE_SUFFIX.exec(id);
40
+ return match ? id.substring(0, match.index) : undefined;
41
+ }
42
+
43
+ /**
44
+ * Adds the undated alias for every release-pinned model, keeping the pinned releases themselves.
45
+ *
46
+ * Providers such as Anthropic and OpenAI list one entry per release (`claude-opus-5-20260401`,
47
+ * `claude-opus-5-20251120`, …) while accepting the undated form (`claude-opus-5`) as a
48
+ * request-time alias for the newest of them. The undated id is what the built-in model aliases
49
+ * reference and what does not change from under a configuration when a new release ships, so it
50
+ * is registered even when the provider does not list it. The pinned releases stay registered too,
51
+ * so a specific release can be selected; what keeps them out of the way of the model pickers is
52
+ * that only undated ids are featured (see the favorite models service), not that they are hidden.
53
+ *
54
+ * The alias takes the metadata of the most recent variant. Aliases come first; the reported models
55
+ * keep their relative order after them. An alias the provider reported itself is left untouched.
56
+ */
57
+ export function withUndatedAliases(models: DiscoveredModel[]): DiscoveredModel[] {
58
+ const reported = new Set(models.map(model => model.id));
59
+ const aliases = new Map<string, DiscoveredModel>();
60
+ for (const model of models) {
61
+ const id = undatedId(model.id);
62
+ if (!id || reported.has(id)) {
63
+ continue;
64
+ }
65
+ const previous = aliases.get(id);
66
+ if (!previous || isNewer(model, previous)) {
67
+ aliases.set(id, { ...model, id });
68
+ }
69
+ }
70
+ return [...aliases.values(), ...models];
71
+ }
72
+
73
+ /**
74
+ * Whether the id is a pointer a provider maintains at the current model of a family, e.g.
75
+ * `gemini-flash-latest`. Such an id carries no version to compare, and does not need one: it
76
+ * resolves to whatever is newest by definition.
77
+ */
78
+ export function isLatestPointer(id: string): boolean {
79
+ return id.endsWith('-latest');
80
+ }
81
+
82
+ /**
83
+ * The version numbers in a model id, e.g. `gemini-3.7-flash` → `[3, 7]` and `claude-sonnet-4-5`
84
+ * → `[4, 5]`. A heuristic, and only meaningful between the models of one provider — and one
85
+ * vendor at that: it says nothing about `gpt-5` against `claude-sonnet-4.5`.
86
+ */
87
+ export function versionOf(id: string): number[] {
88
+ return (id.match(/\d+/g) ?? []).map(Number);
89
+ }
90
+
91
+ /**
92
+ * Compares two model ids by the version numbers in them, most recent first. A longer sequence wins
93
+ * a shared prefix, so `gemini-3.7` sorts before `gemini-3`.
94
+ */
95
+ export function compareByVersion(left: string, right: string): number {
96
+ const leftVersion = versionOf(left);
97
+ const rightVersion = versionOf(right);
98
+ for (let index = 0; index < Math.max(leftVersion.length, rightVersion.length); index++) {
99
+ const difference = (rightVersion[index] ?? -1) - (leftVersion[index] ?? -1);
100
+ if (difference !== 0) {
101
+ return difference;
102
+ }
103
+ }
104
+ return 0;
105
+ }
106
+
107
+ /**
108
+ * Whether `candidate` is a more recent variant than `current`. The release date the provider
109
+ * reported is authoritative and always comparable, so it decides whenever both carry one; the date
110
+ * in the id is the fallback, and it only orders ids of the same shape — a year-less `-0613` says
111
+ * nothing about which year it belongs to.
112
+ */
113
+ function isNewer(candidate: DiscoveredModel, current: DiscoveredModel): boolean {
114
+ if (candidate.released !== undefined && current.released !== undefined) {
115
+ return candidate.released > current.released;
116
+ }
117
+ return idDate(candidate.id) > idDate(current.id);
118
+ }
119
+
120
+ /** The date in a release-pinned id as a comparable number, or `0` when there is none. */
121
+ function idDate(id: string): number {
122
+ const match = DATE_SUFFIX.exec(id);
123
+ if (!match) {
124
+ return 0;
125
+ }
126
+ // Either the full `YYYYMMDD` (groups 1-3) or the year-less `MMDD` (groups 4-5).
127
+ return match[1] ? Number(`${match[1]}${match[2]}${match[3]}`) : Number(`${match[4]}${match[5]}`);
128
+ }
129
+ }
@@ -90,6 +90,12 @@ describe('PromptService', () => {
90
90
  expect(prompt?.text).to.equal('Ciao, {{invalid}}!');
91
91
  });
92
92
 
93
+ it('should insert argument values literally without expanding replacement patterns', async () => {
94
+ const value = "echo $$ $' $& $`";
95
+ const prompt = await promptService.getResolvedPromptFragment('1', { name: value });
96
+ expect(prompt?.text).to.equal(`Hello, ${value}!`);
97
+ });
98
+
93
99
  it('should return undefined if the prompt fragment id is not found', async () => {
94
100
  const prompt = await promptService.getResolvedPromptFragment('4');
95
101
  expect(prompt).to.be.undefined;
@@ -897,7 +897,7 @@ export class PromptServiceImpl implements PromptService {
897
897
  let resolvedTemplate = promptFragment.template;
898
898
  const variableAndArgResolutions = await this.resolveVariablesAndArgs(promptFragment.template, args, context);
899
899
  variableAndArgResolutions.replacements.forEach(replacement =>
900
- resolvedTemplate = resolvedTemplate.replace(replacement.placeholder, replacement.value));
900
+ resolvedTemplate = resolvedTemplate.replace(replacement.placeholder, () => replacement.value));
901
901
 
902
902
  // Then resolve function references with already resolved variables and arguments
903
903
  // This allows to resolve function references contained in resolved variables (e.g. prompt fragments)
@@ -920,7 +920,7 @@ export class PromptServiceImpl implements PromptService {
920
920
  };
921
921
  });
922
922
  functionReplacements.forEach(replacement =>
923
- resolvedTemplate = resolvedTemplate.replace(replacement.placeholder, replacement.value));
923
+ resolvedTemplate = resolvedTemplate.replace(replacement.placeholder, () => replacement.value));
924
924
 
925
925
  return {
926
926
  id: systemOrFragmentId,
@@ -945,7 +945,7 @@ export class PromptServiceImpl implements PromptService {
945
945
  const resolutions = await this.resolveVariablesAndArgs(promptFragment.template, args, context, resolveVariable);
946
946
  let resolvedTemplate = promptFragment.template;
947
947
  resolutions.replacements.forEach(replacement =>
948
- resolvedTemplate = resolvedTemplate.replace(replacement.placeholder, replacement.value));
948
+ resolvedTemplate = resolvedTemplate.replace(replacement.placeholder, () => replacement.value));
949
949
 
950
950
  return {
951
951
  id: systemOrFragmentId,
@@ -44,6 +44,8 @@ import {
44
44
  } from '../common';
45
45
  import { BackendLanguageModelRegistryImpl } from './backend-language-model-registry';
46
46
  import { TokenUsageServiceImpl } from './token-usage-service-impl';
47
+ import { ModelSnapshotStore, ModelSnapshotStoreImpl } from './model-snapshot-store';
48
+ import { ModelDiscoveryFetcher, ModelDiscoveryFetcherImpl } from './model-discovery-fetcher';
47
49
  import { AgentSettingsPreferenceSchema } from '../common/agent-preferences';
48
50
  import { bindAICorePreferences } from '../common/ai-core-preferences';
49
51
 
@@ -112,5 +114,11 @@ const aiCoreConnectionModule = ConnectionContainerModule.create(({ bind, bindBac
112
114
  export default new ContainerModule(bind => {
113
115
  bind(PreferenceContribution).toConstantValue({ schema: AgentSettingsPreferenceSchema });
114
116
  bindAICorePreferences(bind);
117
+ // Bound in the root container: the snapshots are per provider, not per frontend connection,
118
+ // and the provider managers resolving them live in connection-scoped child containers.
119
+ bind(ModelSnapshotStoreImpl).toSelf().inSingletonScope();
120
+ bind(ModelSnapshotStore).toService(ModelSnapshotStoreImpl);
121
+ bind(ModelDiscoveryFetcherImpl).toSelf().inSingletonScope();
122
+ bind(ModelDiscoveryFetcher).toService(ModelDiscoveryFetcherImpl);
115
123
  bind(ConnectionContainerModule).toConstantValue(aiCoreConnectionModule);
116
124
  });
@@ -62,6 +62,7 @@ export class BackendLanguageModelRegistryImpl extends DefaultLanguageModelRegist
62
62
  family: model.family,
63
63
  maxInputTokens: model.maxInputTokens,
64
64
  maxOutputTokens: model.maxOutputTokens,
65
+ released: model.released,
65
66
  reasoningSupport: model.reasoningSupport,
66
67
  serverTools: model.serverTools,
67
68
  serverSideCompactionSupport: model.serverSideCompactionSupport,
package/src/node/index.ts CHANGED
@@ -15,3 +15,5 @@
15
15
  // *****************************************************************************
16
16
 
17
17
  export * from './proxy-util';
18
+ export * from './model-snapshot-store';
19
+ export * from './model-discovery-fetcher';
@@ -0,0 +1,105 @@
1
+ // *****************************************************************************
2
+ // Copyright (C) 2026 EclipseSource GmbH.
3
+ //
4
+ // This program and the accompanying materials are made available under the
5
+ // terms of the Eclipse Public License v. 2.0 which is available at
6
+ // http://www.eclipse.org/legal/epl-2.0.
7
+ //
8
+ // This Source Code may also be made available under the following Secondary
9
+ // Licenses when the conditions for such availability set forth in the Eclipse
10
+ // Public License v. 2.0 are satisfied: GNU General Public License, version 2
11
+ // with the GNU Classpath Exception which is available at
12
+ // https://www.gnu.org/software/classpath/license.html.
13
+ //
14
+ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
15
+ // *****************************************************************************
16
+
17
+ import { ILogger } from '@theia/core';
18
+ import { timeout } from '@theia/core/lib/common/promise-util';
19
+ import { inject, injectable, named } from '@theia/core/shared/inversify';
20
+ import { DiscoveredModel, ModelDiscoveryResult } from '../common/model-discovery-status';
21
+ import { ModelSnapshotStore } from './model-snapshot-store';
22
+
23
+ export const ModelDiscoveryFetcher = Symbol('ModelDiscoveryFetcher');
24
+
25
+ /** What a provider contributes to a discovery run; everything around it is the same for all of them. */
26
+ export interface ModelDiscoveryRequest {
27
+ /** Name the provider's snapshot is kept under, e.g. `anthropic-models.json`. */
28
+ readonly snapshotFile: string;
29
+ /** The provider, for the log entry a failed fetch leaves, e.g. `Anthropic`. */
30
+ readonly providerLabel: string;
31
+ /** Asks the provider what it offers. Called again while {@link isRetryable} accepts the failure. */
32
+ listModels(): Promise<DiscoveredModel[]>;
33
+ /**
34
+ * Whether a failure is transient enough to try again. Nothing is retried by default: a provider
35
+ * knows which of its SDK's errors are a connection giving out and which are an answer.
36
+ */
37
+ isRetryable?(error: unknown): boolean;
38
+ }
39
+
40
+ /**
41
+ * Runs a provider's model discovery: asks it what it offers, retries a transient failure, and keeps
42
+ * the answer as a snapshot so the models survive a later failure or an offline start.
43
+ */
44
+ export interface ModelDiscoveryFetcher {
45
+ /**
46
+ * Discovers the models of one provider. Returns the snapshot with `fromCache` set when the fetch
47
+ * failed and there is one, and rejects with the fetch's own error when there is not.
48
+ */
49
+ fetch(request: ModelDiscoveryRequest): Promise<ModelDiscoveryResult>;
50
+ }
51
+
52
+ @injectable()
53
+ export class ModelDiscoveryFetcherImpl implements ModelDiscoveryFetcher {
54
+
55
+ /** Number of attempts (including the first) for a discovery fetch on a retryable error. */
56
+ protected maxAttempts = 3;
57
+ /** Base linear backoff between attempts; multiplied by the number of the attempt that failed. */
58
+ protected retryDelayMs = 500;
59
+
60
+ @inject(ModelSnapshotStore)
61
+ protected readonly snapshotStore: ModelSnapshotStore;
62
+
63
+ @inject(ILogger) @named('ai-core:ModelDiscoveryFetcherImpl')
64
+ protected readonly logger: ILogger;
65
+
66
+ async fetch(request: ModelDiscoveryRequest): Promise<ModelDiscoveryResult> {
67
+ try {
68
+ const models = await this.listWithRetry(request);
69
+ await this.snapshotStore.write(request.snapshotFile, models);
70
+ return { models, fromCache: false };
71
+ } catch (error) {
72
+ const message = error instanceof Error ? error.message : String(error);
73
+ const cached = await this.snapshotStore.read(request.snapshotFile);
74
+ if (cached) {
75
+ this.logger.warn(`${request.providerLabel}: model discovery failed (${message}); using cached snapshot.`);
76
+ return { models: cached, fromCache: true, error: message };
77
+ }
78
+ // A run that never succeeded has no snapshot to fall back on, so record the failure here.
79
+ this.logger.error(`${request.providerLabel}: model discovery failed; no cached snapshot to fall back on.`, error);
80
+ throw error;
81
+ }
82
+ }
83
+
84
+ /** Lists the models, retrying a transient failure with a linear backoff. */
85
+ protected async listWithRetry(request: ModelDiscoveryRequest): Promise<DiscoveredModel[]> {
86
+ let lastError: unknown;
87
+ for (let attempt = 1; attempt <= this.maxAttempts; attempt++) {
88
+ try {
89
+ return await request.listModels();
90
+ } catch (error) {
91
+ lastError = error;
92
+ if (attempt === this.maxAttempts || !request.isRetryable?.(error)) {
93
+ break;
94
+ }
95
+ await this.delay(this.retryDelayMs * attempt);
96
+ }
97
+ }
98
+ throw lastError;
99
+ }
100
+
101
+ /** Waits between two attempts. Overridable so a test does not have to. */
102
+ protected delay(ms: number): Promise<void> {
103
+ return timeout(ms);
104
+ }
105
+ }