@jini-ai/agent-runtime 0.4.7 → 0.4.8

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/CHANGELOG.md CHANGED
@@ -1,3 +1,8 @@
1
+ ## 0.4.8 — 2026-10-13
2
+
3
+ - The root import no longer needs the optional `@jini-ai/integrations` peer: the media-provider catalog loads lazily, only when a default provider URL is needed. A missing peer at that point throws an actionable error with the import failure as its `cause`; explicit `*_BASE_URL` env values never touch it.
4
+ - New `createElevenLabsVoiceClient({ http, clock }, { maxCacheEntries })`: voices listed through an injected `HttpClientPort`, expired entries swept, oldest evicted past the bound, `clearCache()`. `listElevenLabsVoiceOptions` stays as the compatibility wrapper over native fetch.
5
+
1
6
  ## 0.4.7 — 2026-10-13
2
7
 
3
8
  - New `createNativeAgentRuntime({ env }, { state, acpModelProbe, codexDisablePlugins, piGracefulShutdownMs })`: one host-owned runtime state with detection, launch and definitions bound to it, environment policies resolved once, and `dispose({})` that drains in-flight discovery before clearing caches.
@@ -1,5 +1,4 @@
1
1
  import { defaultRuntimeState } from './runtime-state.js';
2
- import { VENDOR_API_BASE_URLS } from '@jini-ai/integrations/media-providers/catalog';
3
2
  import { execFile } from 'node:child_process';
4
3
  import { readFile, stat } from 'node:fs/promises';
5
4
  import { homedir } from 'node:os';
@@ -303,6 +302,21 @@ function runProcess(input) {
303
302
  child.stdin?.end(input.stdin);
304
303
  }) }, { signal: input.signal });
305
304
  }
305
+ /** Load the catalog owner only for a default URL, so importing the runtime never needs this peer.
306
+ * @param required - The vendor whose default endpoint is needed.
307
+ * @returns The catalog-owned base URL; explicit host URLs bypass this lookup.
308
+ * @throws An actionable load error retaining the original import failure as its cause.
309
+ * @complexity O(1) lookup after Node's cached module load.
310
+ */
311
+ async function defaultProviderBaseUrl({ provider }, _optional = {}) {
312
+ try {
313
+ const { VENDOR_API_BASE_URLS } = await import('@jini-ai/integrations/media-providers/catalog');
314
+ return VENDOR_API_BASE_URLS[provider];
315
+ }
316
+ catch (cause) {
317
+ throw new Error('Could not load @jini-ai/integrations/media-providers/catalog. Install the optional peer @jini-ai/integrations or inject ModelDiscoveryDeps.credential to discover models with default provider URLs.', { cause });
318
+ }
319
+ }
306
320
  export const defaultModelDiscoveryDeps = {
307
321
  process: { run: runProcess },
308
322
  fs: {
@@ -326,18 +340,18 @@ export const defaultModelDiscoveryDeps = {
326
340
  // handles. BYOK env is usable only when it identifies this CLI's effective provider.
327
341
  const env = context.env;
328
342
  if (agentId === 'claude' && env.ANTHROPIC_API_KEY)
329
- return { protocol: 'anthropic', baseUrl: env.ANTHROPIC_BASE_URL || VENDOR_API_BASE_URLS.anthropic, apiKey: env.ANTHROPIC_API_KEY };
343
+ return { protocol: 'anthropic', baseUrl: env.ANTHROPIC_BASE_URL || await defaultProviderBaseUrl({ provider: 'anthropic' }), apiKey: env.ANTHROPIC_API_KEY };
330
344
  const selected = context.model || (agentId === 'aider' ? env.AIDER_MODEL : agentId === 'qwen' ? env.QWEN_MODEL || env.OPENAI_MODEL : undefined);
331
345
  if (!selected)
332
346
  return null;
333
347
  if (/^(anthropic\/|claude-)/.test(selected) && env.ANTHROPIC_API_KEY)
334
- return { protocol: 'anthropic', baseUrl: env.ANTHROPIC_BASE_URL || VENDOR_API_BASE_URLS.anthropic, apiKey: env.ANTHROPIC_API_KEY };
348
+ return { protocol: 'anthropic', baseUrl: env.ANTHROPIC_BASE_URL || await defaultProviderBaseUrl({ provider: 'anthropic' }), apiKey: env.ANTHROPIC_API_KEY };
335
349
  if (/^(gemini\/|google\/|gemini-)/.test(selected) && env.GEMINI_API_KEY)
336
- return { protocol: 'google', baseUrl: VENDOR_API_BASE_URLS.google, apiKey: env.GEMINI_API_KEY };
350
+ return { protocol: 'google', baseUrl: await defaultProviderBaseUrl({ provider: 'google' }), apiKey: env.GEMINI_API_KEY };
337
351
  if (/^openrouter\//.test(selected) && env.OPENROUTER_API_KEY)
338
- return { protocol: 'openai', baseUrl: env.OPENROUTER_API_BASE || VENDOR_API_BASE_URLS.openrouter, apiKey: env.OPENROUTER_API_KEY };
352
+ return { protocol: 'openai', baseUrl: env.OPENROUTER_API_BASE || await defaultProviderBaseUrl({ provider: 'openrouter' }), apiKey: env.OPENROUTER_API_KEY };
339
353
  if ((/^(openai\/|gpt-|o\d)/.test(selected) || agentId === 'qwen' && (env.OPENAI_MODEL || env.OPENAI_BASE_URL)) && env.OPENAI_API_KEY)
340
- return { protocol: 'openai', baseUrl: env.OPENAI_BASE_URL || env.OPENAI_API_BASE || VENDOR_API_BASE_URLS.openai, apiKey: env.OPENAI_API_KEY };
354
+ return { protocol: 'openai', baseUrl: env.OPENAI_BASE_URL || env.OPENAI_API_BASE || await defaultProviderBaseUrl({ provider: 'openai' }), apiKey: env.OPENAI_API_KEY };
341
355
  return null;
342
356
  } },
343
357
  acp: createAcpDiscoveryAdapter({ probe: nativeAcpModelProbe }),
@@ -1,3 +1,4 @@
1
+ import { type Clock, type HttpClientPort } from '@jini-ai/core/primitives';
1
2
  export interface ElevenLabsVoiceOption {
2
3
  voiceId: string;
3
4
  name: string;
@@ -17,16 +18,32 @@ export type ElevenLabsCredentialResolver = (requiredArgs: {
17
18
  export declare class ElevenLabsCredentialMissingError extends Error {
18
19
  constructor();
19
20
  }
20
- /**
21
- * Lists the ElevenLabs voice catalog for a workspace, resolving credentials
22
- * via the injected {@link ElevenLabsCredentialResolver} and caching the
23
- * result for {@link ELEVENLABS_VOICE_CACHE_TTL_MS}. Throws
24
- * {@link ElevenLabsCredentialMissingError} when no API key is configured.
25
- */
26
- export declare function listElevenLabsVoiceOptions({ workspaceKey, resolveCredentials }: {
21
+ export interface ElevenLabsVoiceClientOptions {
22
+ /** Maximum cached workspace/credential/page variants; oldest inserted entries are evicted. */
23
+ maxCacheEntries?: number;
24
+ }
25
+ export interface ElevenLabsVoiceListRequired {
27
26
  workspaceKey: string;
28
27
  resolveCredentials: ElevenLabsCredentialResolver;
29
- }, options?: {
28
+ }
29
+ export interface ElevenLabsVoiceListOptions {
30
+ limit?: number;
31
+ }
32
+ /** Owns transport, time and a bounded voice cache for one host lifecycle.
33
+ * Supply the existing policy-enforcing HTTP client; this factory never constructs a raw transport.
34
+ * @throws RangeError for an invalid cache bound; credential/transport/JSON errors propagate from listing.
35
+ * @complexity O(c + v) per lookup, c <= maxCacheEntries and v <= the requested page size.
36
+ */
37
+ export declare function createElevenLabsVoiceClient({ http, clock }: {
38
+ readonly http: HttpClientPort;
39
+ readonly clock: Clock;
40
+ }, { maxCacheEntries }?: ElevenLabsVoiceClientOptions): {
41
+ listVoiceOptions: ({ workspaceKey, resolveCredentials }: ElevenLabsVoiceListRequired, options?: ElevenLabsVoiceListOptions) => Promise<ElevenLabsVoiceOption[]>;
42
+ clearCache: () => void;
43
+ };
44
+ export type ElevenLabsVoiceClient = ReturnType<typeof createElevenLabsVoiceClient>;
45
+ /** Compatibility wrapper for existing callers. New hosts retain an injected client and clear it on shutdown. */
46
+ export declare function listElevenLabsVoiceOptions(required: ElevenLabsVoiceListRequired, options?: {
30
47
  limit?: number;
31
48
  requestInit?: Pick<RequestInit, 'dispatcher'>;
32
49
  }): Promise<ElevenLabsVoiceOption[]>;
@@ -2,7 +2,7 @@
2
2
  * @module providers/elevenlabs
3
3
  *
4
4
  * ElevenLabs voice-catalog listing (`GET /v2/voices`), with a short in-memory
5
- * TTL cache keyed by workspace + base URL + page size + a credential
5
+ * bounded, client-owned TTL cache keyed by workspace + base URL + page size + a credential
6
6
  * fingerprint (never the raw key). Ported from OD's
7
7
  * `integrations/elevenlabs-voices.ts`.
8
8
  *
@@ -16,6 +16,7 @@
16
16
  * ElevenLabs wire API once it has a key.
17
17
  */
18
18
  import { createHash } from 'node:crypto';
19
+ import { createSystemClock } from '@jini-ai/core/primitives';
19
20
  import { FETCH_TIMEOUT_MS, fetchWithTimeout } from '@jini-ai/platform/fetch-with-timeout';
20
21
  const ELEVENLABS_DEFAULT_BASE_URL = 'https://api.elevenlabs.io';
21
22
  const ELEVENLABS_DEFAULT_VOICE_LIMIT = 100;
@@ -28,7 +29,6 @@ export class ElevenLabsCredentialMissingError extends Error {
28
29
  this.name = 'ElevenLabsCredentialMissingError';
29
30
  }
30
31
  }
31
- const voiceOptionsCache = new Map();
32
32
  function isRecord(value) {
33
33
  return value !== null && typeof value === 'object';
34
34
  }
@@ -87,53 +87,90 @@ function cloneVoiceOptions(voices) {
87
87
  ...(voice.labels ? { labels: { ...voice.labels } } : {}),
88
88
  }));
89
89
  }
90
- /**
91
- * Lists the ElevenLabs voice catalog for a workspace, resolving credentials
92
- * via the injected {@link ElevenLabsCredentialResolver} and caching the
93
- * result for {@link ELEVENLABS_VOICE_CACHE_TTL_MS}. Throws
94
- * {@link ElevenLabsCredentialMissingError} when no API key is configured.
90
+ /** Owns transport, time and a bounded voice cache for one host lifecycle.
91
+ * Supply the existing policy-enforcing HTTP client; this factory never constructs a raw transport.
92
+ * @throws RangeError for an invalid cache bound; credential/transport/JSON errors propagate from listing.
93
+ * @complexity O(c + v) per lookup, c <= maxCacheEntries and v <= the requested page size.
95
94
  */
96
- export async function listElevenLabsVoiceOptions({ workspaceKey, resolveCredentials }, options = {}) {
97
- const credentials = await resolveCredentials({ workspaceKey });
98
- if (!credentials.apiKey) {
99
- throw new ElevenLabsCredentialMissingError();
95
+ export function createElevenLabsVoiceClient({ http, clock }, { maxCacheEntries = 64 } = {}) {
96
+ if (!Number.isSafeInteger(maxCacheEntries) || maxCacheEntries < 1) {
97
+ throw new RangeError('maxCacheEntries must be a positive safe integer');
98
+ }
99
+ const cache = new Map();
100
+ function sweepExpired(now) {
101
+ for (const [key, entry] of cache)
102
+ if (entry.expiresAt <= now)
103
+ cache.delete(key);
100
104
  }
101
- const baseUrl = (credentials.baseUrl || ELEVENLABS_DEFAULT_BASE_URL).replace(/\/$/, '');
102
- const pageSize = clampLimit(options.limit);
103
- const cacheKey = voiceCacheKey({
104
- workspaceKey,
105
- baseUrl,
106
- apiKey: credentials.apiKey,
107
- pageSize,
108
- });
109
- const cached = voiceOptionsCache.get(cacheKey);
110
- const now = Date.now();
111
- if (cached && cached.expiresAt > now) {
112
- return cloneVoiceOptions(cached.voices);
105
+ /** Drops retained voices so a host can release them when its lifecycle ends. */
106
+ function clearCache() { cache.clear(); }
107
+ /** Lists and clones credential-scoped catalog values; unsuccessful responses are never cached. */
108
+ async function listVoiceOptions({ workspaceKey, resolveCredentials }, options = {}) {
109
+ const credentials = await resolveCredentials({ workspaceKey });
110
+ if (!credentials.apiKey)
111
+ throw new ElevenLabsCredentialMissingError();
112
+ const baseUrl = (credentials.baseUrl || ELEVENLABS_DEFAULT_BASE_URL).replace(/\/$/, '');
113
+ const pageSize = clampLimit(options.limit);
114
+ const cacheKey = voiceCacheKey({ workspaceKey, baseUrl, apiKey: credentials.apiKey, pageSize });
115
+ sweepExpired(clock.nowMs());
116
+ const cached = cache.get(cacheKey);
117
+ if (cached)
118
+ return cloneVoiceOptions(cached.voices);
119
+ const resp = await http.send({ request: {
120
+ url: `${baseUrl}/v2/voices?page_size=${pageSize}`,
121
+ method: 'GET',
122
+ headers: { 'xi-api-key': credentials.apiKey, accept: 'application/json' },
123
+ idleTimeoutMs: FETCH_TIMEOUT_MS.QUICK,
124
+ totalDeadlineMs: FETCH_TIMEOUT_MS.QUICK,
125
+ maxResponseBytes: 1024 * 1024,
126
+ } });
127
+ if (resp.status < 200 || resp.status >= 300) {
128
+ throw new Error(`elevenlabs voices ${resp.status}: ${resp.bodyText.slice(0, 240)}`);
129
+ }
130
+ if (resp.bodyTruncated)
131
+ throw new Error('elevenlabs voices response was truncated');
132
+ const payload = JSON.parse(resp.bodyText);
133
+ const rawVoices = isRecord(payload) && Array.isArray(payload.voices) ? payload.voices : [];
134
+ const voices = rawVoices.map((voice) => normalizeVoice(voice))
135
+ .filter((voice) => voice !== null);
136
+ const now = clock.nowMs();
137
+ sweepExpired(now);
138
+ // Replacing a key refreshes insertion order; concurrent misses cannot grow past the bound.
139
+ cache.delete(cacheKey);
140
+ if (cache.size >= maxCacheEntries)
141
+ cache.delete(cache.keys().next().value);
142
+ cache.set(cacheKey, { expiresAt: now + ELEVENLABS_VOICE_CACHE_TTL_MS, voices: cloneVoiceOptions(voices) });
143
+ return voices;
113
144
  }
114
- const resp = await fetchWithTimeout(`${baseUrl}/v2/voices?page_size=${pageSize}`, {
115
- ...options.requestInit,
116
- method: 'GET',
117
- headers: {
118
- 'xi-api-key': credentials.apiKey,
119
- accept: 'application/json',
120
- },
121
- }, { timeoutMs: FETCH_TIMEOUT_MS.QUICK });
122
- if (!resp.ok) {
123
- const errText = await resp.text();
124
- throw new Error(`elevenlabs voices ${resp.status}: ${errText.slice(0, 240)}`);
145
+ return { listVoiceOptions, clearCache };
146
+ }
147
+ /** Native defaults stay in this compatibility assembly; injected clients use HttpClientPort directly. */
148
+ function createCompatibilityClient(requestInit = {}) {
149
+ return createElevenLabsVoiceClient({ clock: createSystemClock(), http: {
150
+ async send({ request }) {
151
+ const response = await fetchWithTimeout(request.url, {
152
+ ...requestInit, method: request.method, headers: request.headers,
153
+ }, { timeoutMs: FETCH_TIMEOUT_MS.QUICK });
154
+ // Same reads as before the port: json() on success, text() for the error message.
155
+ return { status: response.status, headers: {},
156
+ bodyText: response.ok ? JSON.stringify(await response.json()) : await response.text() };
157
+ },
158
+ } });
159
+ }
160
+ const compatibilityClient = createCompatibilityClient();
161
+ const dispatcherClients = new WeakMap();
162
+ /** Compatibility wrapper for existing callers. New hosts retain an injected client and clear it on shutdown. */
163
+ export async function listElevenLabsVoiceOptions(required, options = {}) {
164
+ let client = compatibilityClient;
165
+ const dispatcher = options.requestInit?.dispatcher;
166
+ if (dispatcher && typeof dispatcher === 'object') {
167
+ let owned = dispatcherClients.get(dispatcher);
168
+ if (!owned) {
169
+ owned = createCompatibilityClient(options.requestInit);
170
+ dispatcherClients.set(dispatcher, owned);
171
+ }
172
+ client = owned;
125
173
  }
126
- const payload = await resp.json();
127
- const rawVoices = isRecord(payload) && Array.isArray(payload.voices)
128
- ? payload.voices
129
- : [];
130
- const voices = rawVoices
131
- .map((voice) => normalizeVoice(voice))
132
- .filter((voice) => voice !== null);
133
- voiceOptionsCache.set(cacheKey, {
134
- expiresAt: now + ELEVENLABS_VOICE_CACHE_TTL_MS,
135
- voices: cloneVoiceOptions(voices),
136
- });
137
- return voices;
174
+ return client.listVoiceOptions(required, options);
138
175
  }
139
176
  //# sourceMappingURL=elevenlabs.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jini-ai/agent-runtime",
3
- "version": "0.4.7",
3
+ "version": "0.4.8",
4
4
  "description": "Coding-agent runtime adapters: runtime registry, detection, launch, stream parsers, and the ACP/pi-rpc subprocess transport.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -99,10 +99,10 @@
99
99
  "devDependencies": {
100
100
  "@vitest/coverage-v8": "^2.1.9",
101
101
  "@jini-ai/core": "^0.4.0",
102
- "@jini-ai/oauth": "^0.2.2",
103
- "@jini-ai/protocol": "^0.4.4",
102
+ "@jini-ai/integrations": "^0.4.2",
104
103
  "@jini-ai/platform": "^0.5.5",
105
- "@jini-ai/integrations": "^0.4.2"
104
+ "@jini-ai/oauth": "^0.2.2",
105
+ "@jini-ai/protocol": "^0.4.4"
106
106
  },
107
107
  "dependencies": {
108
108
  "@jini-ai/core": "^0.4.4"