@maci0/dsh-google-vertex 0.0.0-stage → 0.12.5

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/auth.js ADDED
@@ -0,0 +1,246 @@
1
+ /**
2
+ * Service-account authentication for Google APIs: read one credentials file,
3
+ * sign a JWT with its private key, trade it for an OAuth access token, and
4
+ * cache that token until shortly before it expires.
5
+ *
6
+ * The harness credential plane stores API keys, while a Vertex deployment
7
+ * typically holds a service-account JSON instead: the file is the credential.
8
+ * That is why this module exists rather than a credential reference: nothing in
9
+ * the harness can turn an RSA key into a bearer token on the request path.
10
+ *
11
+ * Everything here is pure enough to test without a network: the token endpoint
12
+ * transport and the clock are injectable. The scope every request needs and the
13
+ * early-refresh margin are fixed, because nothing has ever needed to vary them:
14
+ * one Vertex route and one margin are what this plugin talks to.
15
+ *
16
+ * @module dsh-google-vertex/auth
17
+ */
18
+ import { createSign } from 'node:crypto';
19
+ import { readFileSync } from 'node:fs';
20
+ import { homedir } from 'node:os';
21
+ import { isAbsolute, join } from 'node:path';
22
+ /** Scope every Vertex AI request needs; the token carries nothing narrower. */
23
+ export const CLOUD_PLATFORM_SCOPE = 'https://www.googleapis.com/auth/cloud-platform';
24
+ /** Token endpoint a service-account file names; this is the public default. */
25
+ const DEFAULT_TOKEN_URI = 'https://oauth2.googleapis.com/token';
26
+ /** Assertion lifetime Google accepts; the token endpoint caps it at one hour. */
27
+ const ASSERTION_LIFETIME_SECONDS = 3600;
28
+ /**
29
+ * Refresh this long before the reported expiry so a request cannot start with a
30
+ * token that dies in flight.
31
+ */
32
+ const DEFAULT_REFRESH_MARGIN_MS = 5 * 60 * 1000;
33
+ /** An auth failure the adapter reports as `AUTH` or `TRANSPORT`. */
34
+ export class VertexAuthError extends Error {
35
+ /** Provider-neutral failure code the adapter forwards. */
36
+ code;
37
+ /**
38
+ * @param code - whether the failure is a credential problem or a transport one.
39
+ * @param message - operator-facing detail.
40
+ */
41
+ constructor(code, message) {
42
+ super(message);
43
+ this.name = 'VertexAuthError';
44
+ this.code = code;
45
+ }
46
+ }
47
+ /**
48
+ * Expand a leading `~` to the process home directory.
49
+ *
50
+ * The path arrives from configuration, which is written by a human the same way
51
+ * a shell is: `~/.secrets/vertex.json` means the home directory, not a literal
52
+ * directory named `~`.
53
+ * @param path - configured path, possibly home-relative.
54
+ * @returns an absolute path.
55
+ */
56
+ export function expandHome(path) {
57
+ if (path === '~')
58
+ return homedir();
59
+ if (path.startsWith('~/'))
60
+ return join(homedir(), path.slice(2));
61
+ return isAbsolute(path) ? path : join(process.cwd(), path);
62
+ }
63
+ /**
64
+ * Parse a service-account document, refusing one this module cannot sign with.
65
+ *
66
+ * `name` is the path the text came from, so the failure tells an operator which
67
+ * file to fix rather than only which field is wrong.
68
+ * @param raw - the file's text.
69
+ * @param name - source path used in failure messages.
70
+ * @returns the parsed account.
71
+ * @throws {Error} when the document is not JSON or lacks a signing key.
72
+ */
73
+ export function parseServiceAccount(raw, name) {
74
+ let parsed;
75
+ try {
76
+ parsed = JSON.parse(raw);
77
+ }
78
+ catch {
79
+ throw new Error(`google-vertex: service account file ${name} is not valid JSON`);
80
+ }
81
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
82
+ throw new Error(`google-vertex: service account file ${name} must contain a JSON object`);
83
+ }
84
+ const account = parsed;
85
+ const clientEmail = account['client_email'];
86
+ const privateKey = account['private_key'];
87
+ if (typeof clientEmail !== 'string' || clientEmail.length === 0) {
88
+ throw new Error(`google-vertex: service account file ${name} has no "client_email"`);
89
+ }
90
+ if (typeof privateKey !== 'string' || !privateKey.includes('BEGIN PRIVATE KEY')) {
91
+ throw new Error(`google-vertex: service account file ${name} has no usable "private_key"`);
92
+ }
93
+ const keyId = account['private_key_id'];
94
+ const projectId = account['project_id'];
95
+ const tokenUri = account['token_uri'];
96
+ return {
97
+ client_email: clientEmail,
98
+ private_key: privateKey,
99
+ ...typeof keyId === 'string' && keyId.length > 0 ? { private_key_id: keyId } : {},
100
+ ...typeof projectId === 'string' && projectId.length > 0 ? { project_id: projectId } : {},
101
+ ...typeof tokenUri === 'string' && tokenUri.length > 0 ? { token_uri: tokenUri } : {},
102
+ };
103
+ }
104
+ /**
105
+ * Read and parse one service-account file.
106
+ *
107
+ * Read synchronously on purpose: this runs once at plugin mount, where a typo'd
108
+ * path must fail loudly and immediately rather than as an opaque transport error
109
+ * on the first message.
110
+ * @param path - configured path, possibly home-relative.
111
+ * @returns the parsed account.
112
+ * @throws {Error} when the file is unreadable or unusable.
113
+ */
114
+ export function loadServiceAccount(path) {
115
+ const resolved = expandHome(path);
116
+ let raw;
117
+ try {
118
+ raw = readFileSync(resolved, 'utf8');
119
+ }
120
+ catch (error) {
121
+ throw new Error(`google-vertex: cannot read service account file ${resolved}`
122
+ + `: ${error instanceof Error ? error.message : String(error)}`);
123
+ }
124
+ return parseServiceAccount(raw, resolved);
125
+ }
126
+ /** base64url of one JSON value, the JWT segment encoding. */
127
+ function base64urlJson(value) {
128
+ return Buffer.from(JSON.stringify(value), 'utf8').toString('base64url');
129
+ }
130
+ /**
131
+ * Sign the JWT-bearer assertion for one service account.
132
+ * @param account - the credentials to sign with.
133
+ * @param nowSeconds - current time from the injected clock.
134
+ * @param scope - OAuth scope the token is requested for.
135
+ * @returns the signed assertion.
136
+ */
137
+ export function signedAssertion(account, nowSeconds, scope) {
138
+ const header = {
139
+ alg: 'RS256',
140
+ typ: 'JWT',
141
+ ...account.private_key_id === undefined ? {} : { kid: account.private_key_id },
142
+ };
143
+ const claims = {
144
+ iss: account.client_email,
145
+ scope,
146
+ aud: account.token_uri ?? DEFAULT_TOKEN_URI,
147
+ iat: nowSeconds,
148
+ exp: nowSeconds + ASSERTION_LIFETIME_SECONDS,
149
+ };
150
+ const signingInput = `${base64urlJson(header)}.${base64urlJson(claims)}`;
151
+ const signer = createSign('RSA-SHA256');
152
+ signer.update(signingInput);
153
+ const signature = signer.sign(account.private_key, 'base64url');
154
+ return `${signingInput}.${signature}`;
155
+ }
156
+ /**
157
+ * Access tokens for one service account, refreshed on demand.
158
+ *
159
+ * Concurrent callers share one mint in flight, so a first turn with parallel
160
+ * requests does not sign one assertion per request.
161
+ */
162
+ export class ServiceAccountTokens {
163
+ #account;
164
+ #fetch;
165
+ #now;
166
+ #cached;
167
+ #pending;
168
+ /**
169
+ * @param account - the parsed service-account credentials.
170
+ * @param options - injectable transport and clock.
171
+ */
172
+ constructor(account, options = {}) {
173
+ this.#account = account;
174
+ this.#fetch = options.fetch ?? ((input, init) => globalThis.fetch(input, init));
175
+ this.#now = options.now ?? (() => Date.now());
176
+ }
177
+ /**
178
+ * A currently valid access token, minting one when the cache is cold or stale.
179
+ * @param signal - cancellation for the first caller's mint; waiters attached
180
+ * to an in-flight mint are cancelled only by that mint, which is the
181
+ * accepted ceiling of sharing one token (a per-caller mint would remove it).
182
+ * @returns the bearer token.
183
+ * @throws {VertexAuthError} `AUTH` for a refused credential, `TRANSPORT` when
184
+ * the token endpoint could not be reached or answered unusably.
185
+ */
186
+ async get(signal) {
187
+ const cached = this.#cached;
188
+ if (cached !== undefined && this.#now() < cached.expiresAt - DEFAULT_REFRESH_MARGIN_MS)
189
+ return cached.value;
190
+ const pending = this.#pending ?? this.#mint(signal);
191
+ this.#pending = pending;
192
+ try {
193
+ return await pending;
194
+ }
195
+ finally {
196
+ if (this.#pending === pending)
197
+ this.#pending = undefined;
198
+ }
199
+ }
200
+ /** Mint one token and cache it. */
201
+ async #mint(signal) {
202
+ const nowSeconds = Math.floor(this.#now() / 1000);
203
+ const assertion = signedAssertion(this.#account, nowSeconds, CLOUD_PLATFORM_SCOPE);
204
+ const tokenUri = this.#account.token_uri ?? DEFAULT_TOKEN_URI;
205
+ let response;
206
+ try {
207
+ response = await this.#fetch(tokenUri, {
208
+ method: 'POST',
209
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
210
+ body: new URLSearchParams({
211
+ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
212
+ assertion,
213
+ }).toString(),
214
+ ...signal === undefined ? {} : { signal },
215
+ });
216
+ }
217
+ catch (error) {
218
+ if (signal?.aborted === true) {
219
+ throw new VertexAuthError('TRANSPORT', 'google-vertex: token request aborted');
220
+ }
221
+ throw new VertexAuthError('TRANSPORT', `google-vertex: token endpoint unreachable: ${error instanceof Error ? error.message : String(error)}`);
222
+ }
223
+ const text = await response.text().catch(() => '');
224
+ if (!response.ok) {
225
+ throw new VertexAuthError(response.status === 408 || response.status === 429 || response.status >= 500 ? 'TRANSPORT' : 'AUTH', `google-vertex: token endpoint failed (HTTP ${response.status})`
226
+ + `${text.length > 0 ? `: ${text.slice(0, 300)}` : ''}`);
227
+ }
228
+ let payload;
229
+ try {
230
+ payload = JSON.parse(text);
231
+ }
232
+ catch {
233
+ throw new VertexAuthError('TRANSPORT', 'google-vertex: token endpoint answered with a non-JSON body');
234
+ }
235
+ const fields = (typeof payload === 'object' && payload !== null ? payload : {});
236
+ const token = fields['access_token'];
237
+ if (typeof token !== 'string' || token.length === 0) {
238
+ throw new VertexAuthError('TRANSPORT', `google-vertex: token endpoint answered without an access_token`);
239
+ }
240
+ const expiresIn = typeof fields['expires_in'] === 'number' && fields['expires_in'] > 0
241
+ ? fields['expires_in']
242
+ : ASSERTION_LIFETIME_SECONDS;
243
+ this.#cached = { value: token, expiresAt: this.#now() + expiresIn * 1000 };
244
+ return token;
245
+ }
246
+ }
package/lib/client.js ADDED
@@ -0,0 +1,206 @@
1
+ /**
2
+ * dsh-google-vertex browser half: the plugin's configuration page on the
3
+ * Plugins page, under the bundle's `google-vertex` row, and the Refresh control
4
+ * that lives on it.
5
+ *
6
+ * The Gemini adapter on the host half discovers its catalog at runtime behind a
7
+ * five-minute cache, so a model Google published a minute ago is invisible to
8
+ * the model picker until that cache expires. This half has exactly one way to
9
+ * reach the host process (a write to the `google-vertex` settings namespace
10
+ * the host half registers), so the button writes the current time there. The
11
+ * host's change hook drops both cached catalogs, and the settings commit also
12
+ * makes the model picker re-read the catalog from the host. No restart, no
13
+ * harness change.
14
+ *
15
+ * The page contract takes `summary` (the one-liner the page prints under the
16
+ * row's module) and `page` (the controls); the page draws the title and the
17
+ * crumb itself, so this half draws neither. `plugins.row.config` is keyed by
18
+ * `<package name>#<row id>`, which is the key below.
19
+ *
20
+ * This file is plain JavaScript on purpose. The client module system serves a
21
+ * package's `exports["./client"]` artifact as a lazy-CJS factory registered on
22
+ * `window.__ModuleLoader__`, and that is the whole format: an out-of-tree
23
+ * plugin can author it directly instead of reproducing the repository's tsdown
24
+ * client preset. `react` is provided by the module system; nothing else is
25
+ * required here.
26
+ */
27
+
28
+ window.__ModuleLoader__.load({
29
+ id: '@maci0/dsh-google-vertex',
30
+
31
+ factory: (require) => {
32
+ var module = { exports: {} }
33
+ var exports = module.exports
34
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
35
+
36
+ const React = require('react')
37
+
38
+ /** Settings namespace shared with the host half. */
39
+ const NAMESPACE = 'google-vertex'
40
+
41
+ /** Locale namespace for this plugin's copy. */
42
+ const LOCALE_NS = 'google-vertex'
43
+
44
+ /** The Plugins-page slot this card occupies. */
45
+ const SLOT = 'plugins.row.config'
46
+
47
+ /**
48
+ * The slot's key: the bundle's package name and the row id its patch
49
+ * declares (`rowConfigKey` in the page's own contract). A deployment that
50
+ * installs this package under another name shows no configure control,
51
+ * which is the whole trace of a mismatch.
52
+ */
53
+ const ENTRY_KEY = '@maci0/dsh-google-vertex#google-vertex'
54
+
55
+ /** Every class is `gv-`-prefixed: the sheet lands in the page's own document. */
56
+ const CSS = [
57
+ '.gv-page{display:flex;flex-direction:column;gap:10px}',
58
+ '.gv-hint{font-size:12px;line-height:1.5;color:var(--dsw-alias-label-tertiary)}',
59
+ '.gv-row{display:flex;align-items:center;flex-wrap:wrap;gap:10px}',
60
+ '.gv-action{appearance:none;font:inherit;font-size:13px;line-height:1.5;padding:5px 14px;cursor:pointer;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-4);border:1px solid var(--dsw-alias-border-l2);border-radius:999px}',
61
+ '.gv-action:disabled{cursor:default;opacity:.5}',
62
+ '.gv-status{font-size:12px;line-height:1.5;color:var(--dsw-alias-label-tertiary)}',
63
+ '.gv-error{font-size:12px;line-height:1.5;color:var(--dsw-alias-label-error)}',
64
+ ].join('')
65
+
66
+ // Appended while the factory materializes: the module system claims the tag
67
+ // for this package and disposes it on unload. Guarded because the node unit
68
+ // tests evaluate this file without a DOM.
69
+ if (typeof document !== 'undefined') {
70
+ const style = document.createElement('style')
71
+ style.textContent = CSS
72
+ document.head.append(style)
73
+ }
74
+
75
+ const en = {
76
+ summary: 'Google-hosted Claude and Gemini models, discovered from Vertex at runtime.',
77
+ refresh: 'Refresh models',
78
+ hint: 'Re-reads the catalog from Vertex, then refreshes the model picker. No restart.',
79
+ lastRefresh: 'Last manual refresh: {at}',
80
+ never: 'No manual refresh yet.',
81
+ refused: 'The Host refused the refresh. Try again.',
82
+ readOnly: 'Settings are not persisted in this deployment, so this control cannot reach the host. Restart dsh web to re-discover.',
83
+ }
84
+
85
+ const zh = {
86
+ summary: '由 Vertex 托管的 Claude 与 Gemini 模型,运行时动态发现。',
87
+ refresh: '刷新模型',
88
+ hint: '从 Vertex 重新读取模型目录,并刷新模型选择器。无需重启。',
89
+ lastRefresh: '上次手动刷新:{at}',
90
+ never: '尚未手动刷新。',
91
+ refused: 'Host 拒绝了刷新,请重试。',
92
+ readOnly: '此部署不持久化设置,该控件无法通知宿主进程。请重启 dsh web 以重新发现。',
93
+ }
94
+
95
+ /**
96
+ * Bind one scope to a React subscription.
97
+ * @param scope - a scope bound to the google-vertex settings namespace.
98
+ * @returns a hook reading that scope's current snapshot.
99
+ */
100
+ function useScope(scope) {
101
+ const subscribe = (listener) => scope.subscribe(listener)
102
+ const getSnapshot = () => scope.getSnapshot()
103
+ return () => React.useSyncExternalStore(subscribe, getSnapshot)
104
+ }
105
+
106
+ /**
107
+ * Read a snapshot's value. A namespace the deployment does not serve reports
108
+ * no value, which the card renders as nothing at all.
109
+ * @param snapshot - the settings scope snapshot.
110
+ * @returns the stored value object, or `undefined` when unreadable.
111
+ */
112
+ function valueOf(snapshot) {
113
+ if (snapshot.status !== 'ready') return undefined
114
+ return snapshot.value !== null && typeof snapshot.value === 'object' ? snapshot.value : {}
115
+ }
116
+
117
+ /**
118
+ * Build the card component over one bound settings scope.
119
+ * @param scope - the scope bound to the google-vertex namespace.
120
+ * @param t - translate function bound to this plugin's locale namespace.
121
+ * @returns the component the page renders in either view.
122
+ */
123
+ function createCard(scope, t) {
124
+ const useSettings = useScope(scope)
125
+
126
+ return function GoogleVertexCard({ view }) {
127
+ const snapshot = useSettings()
128
+ const [error, setError] = React.useState(null)
129
+
130
+ const value = valueOf(snapshot)
131
+ // A namespace this deployment does not serve renders no trace of itself.
132
+ if (value === undefined) return null
133
+ if (view === 'summary') return t('summary')
134
+
135
+ const refresh = () => {
136
+ setError(null)
137
+ new Promise((resolve) => { resolve(scope.set('revalidatedAt', new Date().toISOString())) }).then((accepted) => {
138
+ if (accepted === false) setError(t('refused'))
139
+ }).catch((cause) => {
140
+ setError(cause instanceof Error ? cause.message : String(cause))
141
+ })
142
+ }
143
+
144
+ const stamp = typeof value.revalidatedAt === 'string' && value.revalidatedAt.length > 0
145
+ ? new Date(value.revalidatedAt)
146
+ : undefined
147
+ const last = stamp !== undefined && !Number.isNaN(stamp.getTime())
148
+ ? t('lastRefresh', { at: stamp.toLocaleString() })
149
+ : t('never')
150
+
151
+ return React.createElement(
152
+ 'div',
153
+ { className: 'gv-page' },
154
+ React.createElement('p', { className: 'gv-hint' }, t('hint')),
155
+ React.createElement(
156
+ 'div',
157
+ { className: 'gv-row' },
158
+ React.createElement(
159
+ 'button',
160
+ {
161
+ type: 'button',
162
+ className: 'gv-action',
163
+ disabled: !snapshot.writable,
164
+ onClick: refresh,
165
+ },
166
+ t('refresh'),
167
+ ),
168
+ React.createElement(
169
+ 'span',
170
+ { className: 'gv-status' },
171
+ snapshot.writable ? last : t('readOnly'),
172
+ ),
173
+ ),
174
+ error === null ? null : React.createElement('p', { className: 'gv-error' }, error),
175
+ )
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Mount the card: this plugin's row configuration on the Plugins page.
181
+ * @param ctx - the browser plugin context.
182
+ */
183
+ function apply(ctx) {
184
+ const t = ctx.locale.bind(LOCALE_NS)
185
+ ctx.effect(
186
+ () => ctx.locale.register(LOCALE_NS, { en, zh }),
187
+ 'dsh-google-vertex: locale dictionary',
188
+ )
189
+
190
+ const scope = ctx.configForms.get(NAMESPACE)
191
+ const Card = createCard(scope, t)
192
+
193
+ // The page declares the slot; injecting waits for it to exist, so this
194
+ // registration does not depend on plugin load order.
195
+ ctx.slots.inject(SLOT, () => ctx.slots.register({
196
+ name: SLOT,
197
+ key: ENTRY_KEY,
198
+ locale: LOCALE_NS,
199
+ }, Card))
200
+ }
201
+
202
+ exports.apply = apply
203
+ exports.inject = ['slots', 'configForms', 'locale']
204
+ return module.exports
205
+ },
206
+ })
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Dynamic model discovery for Vertex AI publisher endpoints.
3
+ *
4
+ * Gemini models are fetched from the Vertex Model Garden catalog API
5
+ * (`publishers/google/models`). Anthropic models have no listing endpoint on
6
+ * Vertex, so the adapter serves the catalog from configuration instead.
7
+ *
8
+ * Results are cached for five minutes so the model picker does not
9
+ * make a network call on every open.
10
+ *
11
+ * @module dsh-google-vertex/discovery
12
+ */
13
+ import { DEFAULT_GEMINI_MODELS } from './gemini.js';
14
+ import { endpointOrigin } from './wire.js';
15
+ /** How long a cached model list stays valid, in milliseconds. */
16
+ export const DEFAULT_CACHE_TTL_MS = 5 * 60 * 1000;
17
+ /**
18
+ * Vertex API version for the model list endpoint. `publishers.models.list`
19
+ * exists only in v1beta1; v1 serves `get` alone.
20
+ */
21
+ const API_VERSION = 'v1beta1';
22
+ /** Most catalog pages read before giving up on a misbehaving endpoint. */
23
+ const MAX_PAGES = 10;
24
+ /**
25
+ * Id segments naming a Gemini variant this text route cannot drive: embedding,
26
+ * speech, image output, and the Live API.
27
+ */
28
+ const NON_TEXT_SEGMENTS = new Set(['embedding', 'tts', 'image', 'live', 'audio']);
29
+ /**
30
+ * Extract the model id from a Vertex resource name.
31
+ *
32
+ * A resource name looks like `publishers/google/models/gemini-2.5-pro`: the
33
+ * model id is the last segment. A name without a `/` is returned as-is.
34
+ * @param resourceName - the `name` field from the catalog entry.
35
+ * @returns the bare model id.
36
+ */
37
+ function modelIdFromResource(resourceName) {
38
+ const lastSlash = resourceName.lastIndexOf('/');
39
+ return lastSlash >= 0 ? resourceName.slice(lastSlash + 1) : resourceName;
40
+ }
41
+ /**
42
+ * True for a Gemini id this route can serve: the publisher catalog also lists
43
+ * Imagen, Veo, embedding, and speech models.
44
+ * @param id - bare model id.
45
+ */
46
+ function isGeminiTextModel(id) {
47
+ return id.startsWith('gemini-') && !id.split('-').some(segment => NON_TEXT_SEGMENTS.has(segment));
48
+ }
49
+ /**
50
+ * Build the URL that lists publisher models for one publisher.
51
+ * @param location - configured Vertex region, or `global`.
52
+ * @param publisher - publisher name, e.g. `google`.
53
+ * @returns the absolute list URL.
54
+ */
55
+ function listModelsUrl(location, publisher) {
56
+ const origin = endpointOrigin(location);
57
+ return `${origin}/${API_VERSION}/publishers/${encodeURIComponent(publisher)}/models`;
58
+ }
59
+ /**
60
+ * Fetch one page of publisher models from the Vertex Model Garden catalog.
61
+ * @param url - the list endpoint URL, possibly with a pageToken.
62
+ * @param token - bearer token for authentication.
63
+ * @param fetchFn - transport.
64
+ * @param signal - caller cancellation.
65
+ * @returns the parsed response.
66
+ */
67
+ async function fetchPage(url, token, fetchFn, signal) {
68
+ const response = await fetchFn(url, {
69
+ method: 'GET',
70
+ headers: {
71
+ 'authorization': `Bearer ${token}`,
72
+ 'accept': 'application/json',
73
+ },
74
+ ...signal === undefined ? {} : { signal },
75
+ });
76
+ if (!response.ok) {
77
+ throw new Error(`google-vertex: model list failed (HTTP ${response.status})`);
78
+ }
79
+ const body = await response.json();
80
+ if (typeof body !== 'object' || body === null)
81
+ return {};
82
+ return body;
83
+ }
84
+ /**
85
+ * Fetch the Gemini text models from Vertex's `publishers/google/models` list.
86
+ *
87
+ * Follows pagination and keeps `gemini-` ids minus the variants named in
88
+ * {@link NON_TEXT_SEGMENTS}. A built-in id keeps its built-in name; any other
89
+ * is named by its id.
90
+ * @param location - configured Vertex region, or `global`.
91
+ * @param tokens - token source for bearer authentication.
92
+ * @param fetchFn - transport.
93
+ * @param signal - caller cancellation.
94
+ * @returns model ids and display names, in catalog order.
95
+ */
96
+ export async function fetchGeminiModels(location, tokens, fetchFn, signal) {
97
+ const bearer = await tokens.get(signal);
98
+ const models = [];
99
+ const url = listModelsUrl(location, 'google');
100
+ let pageToken;
101
+ for (let page = 0; page < MAX_PAGES; page++) {
102
+ const pageUrl = pageToken !== undefined ? `${url}?pageToken=${encodeURIComponent(pageToken)}` : url;
103
+ const response = await fetchPage(pageUrl, bearer, fetchFn, signal);
104
+ for (const entry of response.publisherModels ?? []) {
105
+ if (typeof entry.name !== 'string')
106
+ continue;
107
+ const id = modelIdFromResource(entry.name);
108
+ if (!isGeminiTextModel(id))
109
+ continue;
110
+ models.push(DEFAULT_GEMINI_MODELS.find(model => model.id === id) ?? { id, name: `${id} (Vertex)` });
111
+ }
112
+ pageToken = response.nextPageToken;
113
+ if (pageToken === undefined || pageToken.length === 0)
114
+ break;
115
+ }
116
+ return models;
117
+ }
118
+ /**
119
+ * A cached, TTL-bounded model list that falls back to a static default when
120
+ * the remote fetch fails.
121
+ *
122
+ * Only the Gemini adapter fetches: Vertex has no Anthropic model listing
123
+ * endpoint, so that route serves its configured catalog without one and never
124
+ * calls this.
125
+ */
126
+ export class ModelCache {
127
+ #fallback;
128
+ #cached;
129
+ #inflight;
130
+ /**
131
+ * @param fallback - static default returned when the fetch fails or is not
132
+ * attempted.
133
+ */
134
+ constructor(fallback) {
135
+ this.#fallback = fallback;
136
+ }
137
+ /**
138
+ * Return the cached model list, or fetch a fresh one.
139
+ *
140
+ * When a fetch function is provided and the cache is stale, it is called to
141
+ * produce a fresh list. On failure, the fallback is returned. Concurrent
142
+ * callers share one in-flight fetch.
143
+ * @param fetchFn - optional async function that returns a fresh model list.
144
+ * @returns the model list, from cache, fetch, or fallback.
145
+ */
146
+ async get(fetchFn) {
147
+ // Return cached if still valid.
148
+ const cached = this.#cached;
149
+ if (cached !== undefined && Date.now() < cached.expiresAt)
150
+ return cached.models;
151
+ // No fetch function means static-only.
152
+ if (fetchFn === undefined)
153
+ return this.#fallback;
154
+ // Share one in-flight fetch.
155
+ if (this.#inflight !== undefined)
156
+ return this.#inflight;
157
+ const operation = fetchFn().then((models) => {
158
+ if (models.length > 0) {
159
+ if (this.#inflight === operation)
160
+ this.#cached = { models, expiresAt: Date.now() + DEFAULT_CACHE_TTL_MS };
161
+ return models;
162
+ }
163
+ // Empty result: use fallback rather than showing nothing.
164
+ return this.#fallback;
165
+ }).catch(() => {
166
+ // Network failure: serve fallback silently.
167
+ return this.#cached?.models ?? this.#fallback;
168
+ }).finally(() => {
169
+ if (this.#inflight === operation)
170
+ this.#inflight = undefined;
171
+ });
172
+ this.#inflight = operation;
173
+ return operation;
174
+ }
175
+ /** Force the next `get` to re-fetch. */
176
+ invalidate() {
177
+ this.#cached = undefined;
178
+ this.#inflight = undefined;
179
+ }
180
+ }