portos-ai-toolkit 0.4.0 → 0.5.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portos-ai-toolkit",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Shared AI provider, model, and prompt template patterns for PortOS-style applications",
5
5
  "author": "Adam Eivy <adam@eivy.com> (https://atomantic.com)",
6
6
  "license": "MIT",
@@ -16,6 +16,23 @@
16
16
  "enabled": true,
17
17
  "envVars": {}
18
18
  },
19
+ "claude-code-bedrock": {
20
+ "id": "claude-code-bedrock",
21
+ "name": "Claude Code CLI: Bedrock",
22
+ "type": "cli",
23
+ "command": "claude",
24
+ "args": ["--print"],
25
+ "models": ["us.anthropic.claude-sonnet-4-5-20250929-v1:0", "global.anthropic.claude-opus-4-5-20251101-v1:0"],
26
+ "defaultModel": "global.anthropic.claude-opus-4-5-20251101-v1:0",
27
+ "lightModel": "us.anthropic.claude-sonnet-4-5-20250929-v1:0",
28
+ "mediumModel": "global.anthropic.claude-opus-4-5-20251101-v1:0",
29
+ "heavyModel": "global.anthropic.claude-opus-4-5-20251101-v1:0",
30
+ "timeout": 300000,
31
+ "enabled": false,
32
+ "envVars": {
33
+ "CLAUDE_CODE_USE_BEDROCK": "1"
34
+ }
35
+ },
19
36
  "codex": {
20
37
  "id": "codex",
21
38
  "name": "Codex CLI",
@@ -12,6 +12,7 @@ export interface ProviderService {
12
12
  deleteProvider(id: string): Promise<boolean>;
13
13
  testProvider(id: string): Promise<{ success: boolean; [key: string]: any }>;
14
14
  refreshProviderModels(id: string): Promise<any | null>;
15
+ getSampleProviders(): Promise<any[]>;
15
16
  }
16
17
 
17
18
  export interface RunnerService {
@@ -1,9 +1,13 @@
1
1
  import { readFile, writeFile, mkdir } from 'fs/promises';
2
2
  import { existsSync } from 'fs';
3
- import { join } from 'path';
3
+ import { join, dirname } from 'path';
4
+ import { fileURLToPath } from 'url';
4
5
  import { exec } from 'child_process';
5
6
  import { promisify } from 'util';
6
7
 
8
+ const __dirname = dirname(fileURLToPath(import.meta.url));
9
+ const DEFAULT_SAMPLE_PATH = join(__dirname, '../defaults/providers.sample.json');
10
+
7
11
  const execAsync = promisify(exec);
8
12
 
9
13
  /**
@@ -226,25 +230,33 @@ export function createProviderService(config = {}) {
226
230
  },
227
231
 
228
232
  /**
229
- * Refresh models from API provider
233
+ * Refresh models from provider using provider-specific strategies
230
234
  */
231
235
  async refreshProviderModels(id) {
232
236
  const data = await loadProviders();
233
237
  const provider = data.providers[id];
234
238
 
235
- if (!provider || provider.type !== 'api') {
239
+ if (!provider) {
236
240
  return null;
237
241
  }
238
242
 
239
- const modelsUrl = `${provider.endpoint}/models`;
240
- const response = await fetch(modelsUrl, {
241
- headers: provider.apiKey ? { 'Authorization': `Bearer ${provider.apiKey}` } : {}
242
- }).catch(() => null);
243
+ let models = [];
243
244
 
244
- if (!response?.ok) return null;
245
+ try {
246
+ // Provider-specific refresh strategies
247
+ if (provider.type === 'api') {
248
+ models = await this._refreshAPIProviderModels(provider);
249
+ } else if (provider.type === 'cli') {
250
+ models = await this._refreshCLIProviderModels(provider);
251
+ }
252
+ } catch (error) {
253
+ console.error(`Failed to refresh models for ${provider.name}:`, error.message);
254
+ return null;
255
+ }
245
256
 
246
- const responseData = await response.json().catch(() => ({ data: [] }));
247
- const models = responseData.data?.map(m => m.id) || [];
257
+ if (!models || models.length === 0) {
258
+ return null;
259
+ }
248
260
 
249
261
  const updatedProvider = {
250
262
  ...data.providers[id],
@@ -254,6 +266,155 @@ export function createProviderService(config = {}) {
254
266
  data.providers[id] = updatedProvider;
255
267
  await saveProviders(data);
256
268
  return updatedProvider;
269
+ },
270
+
271
+ /**
272
+ * Refresh models from API providers
273
+ * Supports OpenAI-compatible endpoints (OpenAI, LM Studio, etc.)
274
+ * and Ollama-style endpoints
275
+ */
276
+ async _refreshAPIProviderModels(provider) {
277
+ // Try Ollama format first if endpoint suggests it
278
+ if (provider.endpoint?.includes('ollama') || provider.endpoint?.includes(':11434')) {
279
+ const ollamaUrl = `${provider.endpoint}/api/tags`;
280
+ const response = await fetch(ollamaUrl).catch(() => null);
281
+
282
+ if (response?.ok) {
283
+ const data = await response.json().catch(() => null);
284
+ if (data?.models) {
285
+ return data.models.map(m => m.name || m.model);
286
+ }
287
+ }
288
+ }
289
+
290
+ // Try OpenAI-compatible format (default)
291
+ const modelsUrl = `${provider.endpoint}/models`;
292
+ const headers = {};
293
+
294
+ if (provider.apiKey) {
295
+ headers['Authorization'] = `Bearer ${provider.apiKey}`;
296
+ }
297
+
298
+ const response = await fetch(modelsUrl, { headers }).catch(() => null);
299
+
300
+ if (!response?.ok) {
301
+ throw new Error(`HTTP ${response?.status || 'error'}`);
302
+ }
303
+
304
+ const responseData = await response.json().catch(() => ({ data: [] }));
305
+
306
+ // OpenAI format: { data: [{ id: "model-name" }] }
307
+ if (responseData.data && Array.isArray(responseData.data)) {
308
+ return responseData.data.map(m => m.id);
309
+ }
310
+
311
+ // Alternative format: { models: ["model-name"] }
312
+ if (responseData.models && Array.isArray(responseData.models)) {
313
+ return responseData.models;
314
+ }
315
+
316
+ return [];
317
+ },
318
+
319
+ /**
320
+ * Refresh models from CLI providers using provider-specific APIs
321
+ */
322
+ async _refreshCLIProviderModels(provider) {
323
+ const providerName = provider.name.toLowerCase();
324
+
325
+ // Claude/Anthropic - fetch from Anthropic API
326
+ if (providerName.includes('claude') || provider.command === 'claude') {
327
+ return await this._fetchAnthropicModels(provider);
328
+ }
329
+
330
+ // Gemini - fetch from Google AI API
331
+ if (providerName.includes('gemini') || provider.command === 'gemini') {
332
+ return await this._fetchGeminiModels(provider);
333
+ }
334
+
335
+ // For other CLI providers, we can't refresh models
336
+ throw new Error('Model refresh not supported for this CLI provider');
337
+ },
338
+
339
+ /**
340
+ * Fetch available Claude models from Anthropic API
341
+ */
342
+ async _fetchAnthropicModels(provider) {
343
+ // Check for API key in provider or environment
344
+ const apiKey = provider.apiKey || process.env.ANTHROPIC_API_KEY;
345
+
346
+ if (!apiKey) {
347
+ throw new Error('Anthropic API key required for model refresh');
348
+ }
349
+
350
+ // Known Claude models as of January 2025
351
+ // Anthropic doesn't have a public models list endpoint yet
352
+ return [
353
+ 'claude-opus-4-6',
354
+ 'claude-opus-4',
355
+ 'claude-sonnet-4-6',
356
+ 'claude-sonnet-4',
357
+ 'claude-3-7-sonnet-20250219',
358
+ 'claude-3-5-sonnet-20241022',
359
+ 'claude-3-5-sonnet-20240620',
360
+ 'claude-3-5-haiku-20241022',
361
+ 'claude-3-opus-20240229',
362
+ 'claude-3-sonnet-20240229',
363
+ 'claude-3-haiku-20240307'
364
+ ];
365
+ },
366
+
367
+ /**
368
+ * Fetch available Gemini models from Google AI API
369
+ */
370
+ async _fetchGeminiModels(provider) {
371
+ const apiKey = provider.apiKey || process.env.GOOGLE_API_KEY;
372
+
373
+ if (!apiKey) {
374
+ throw new Error('Google API key required for model refresh');
375
+ }
376
+
377
+ const response = await fetch(
378
+ `https://generativelanguage.googleapis.com/v1beta/models?key=${apiKey}`
379
+ ).catch(() => null);
380
+
381
+ if (!response?.ok) {
382
+ throw new Error(`HTTP ${response?.status || 'error'}`);
383
+ }
384
+
385
+ const data = await response.json().catch(() => ({ models: [] }));
386
+
387
+ // Filter to only generative models
388
+ return (data.models || [])
389
+ .filter(m => m.supportedGenerationMethods?.includes('generateContent'))
390
+ .map(m => m.name.replace('models/', ''));
391
+ },
392
+
393
+ /**
394
+ * Get sample providers not yet in user's configuration.
395
+ * Reads toolkit's built-in defaults and overlays with host app's sample file.
396
+ */
397
+ async getSampleProviders() {
398
+ const data = await loadProviders();
399
+ const existingIds = new Set(Object.keys(data.providers));
400
+
401
+ // Read toolkit's built-in sample
402
+ let sampleProviders = {};
403
+ if (existsSync(DEFAULT_SAMPLE_PATH)) {
404
+ const content = await readFile(DEFAULT_SAMPLE_PATH, 'utf-8');
405
+ const parsed = JSON.parse(content);
406
+ sampleProviders = { ...parsed.providers };
407
+ }
408
+
409
+ // Overlay with host app's sample (takes precedence)
410
+ if (sampleFile && existsSync(sampleFile)) {
411
+ const content = await readFile(sampleFile, 'utf-8');
412
+ const parsed = JSON.parse(content);
413
+ sampleProviders = { ...sampleProviders, ...parsed.providers };
414
+ }
415
+
416
+ // Filter out providers already in user's config
417
+ return Object.values(sampleProviders).filter(p => !existingIds.has(p.id));
257
418
  }
258
419
  };
259
420
  }
@@ -117,4 +117,75 @@ describe('Provider Service', () => {
117
117
  })
118
118
  ).rejects.toThrow('Provider with this ID already exists');
119
119
  });
120
+
121
+ describe('getSampleProviders', () => {
122
+ it('should return sample providers from default sample file', async () => {
123
+ // No providers created yet — all samples should be returned
124
+ const samples = await providerService.getSampleProviders();
125
+ expect(Array.isArray(samples)).toBe(true);
126
+ expect(samples.length).toBeGreaterThan(0);
127
+ // Should include claude-code-bedrock from the default sample
128
+ const bedrock = samples.find(p => p.id === 'claude-code-bedrock');
129
+ expect(bedrock).toBeDefined();
130
+ expect(bedrock.name).toBe('Claude Code CLI: Bedrock');
131
+ });
132
+
133
+ it('should exclude providers already in user config', async () => {
134
+ // Create a provider with an ID that matches a sample
135
+ await providerService.createProvider({
136
+ id: 'claude-code',
137
+ name: 'Claude Code CLI',
138
+ type: 'cli',
139
+ command: 'claude'
140
+ });
141
+
142
+ const samples = await providerService.getSampleProviders();
143
+ const claudeCode = samples.find(p => p.id === 'claude-code');
144
+ expect(claudeCode).toBeUndefined();
145
+ });
146
+
147
+ it('should overlay host app sample over toolkit defaults', async () => {
148
+ // Pre-create providers.json with one existing provider so loadProviders
149
+ // doesn't bootstrap from sampleFile
150
+ const providersPath = join(TEST_DATA_DIR, 'providers-overlay.json');
151
+ await writeFile(providersPath, JSON.stringify({
152
+ activeProvider: 'existing',
153
+ providers: {
154
+ existing: { id: 'existing', name: 'Existing', type: 'cli', command: 'test' }
155
+ }
156
+ }));
157
+
158
+ // Create a host app sample with a unique provider
159
+ const samplePath = join(TEST_DATA_DIR, 'custom-sample.json');
160
+ await writeFile(samplePath, JSON.stringify({
161
+ activeProvider: 'custom-cli',
162
+ providers: {
163
+ 'custom-cli': {
164
+ id: 'custom-cli',
165
+ name: 'Custom CLI',
166
+ type: 'cli',
167
+ command: 'custom',
168
+ args: [],
169
+ models: [],
170
+ timeout: 300000,
171
+ enabled: true
172
+ }
173
+ }
174
+ }));
175
+
176
+ const serviceWithSample = createProviderService({
177
+ dataDir: TEST_DATA_DIR,
178
+ providersFile: 'providers-overlay.json',
179
+ sampleFile: samplePath
180
+ });
181
+
182
+ const samples = await serviceWithSample.getSampleProviders();
183
+ const custom = samples.find(p => p.id === 'custom-cli');
184
+ expect(custom).toBeDefined();
185
+ expect(custom.name).toBe('Custom CLI');
186
+ // 'existing' should NOT appear (already in user's config)
187
+ const existing = samples.find(p => p.id === 'existing');
188
+ expect(existing).toBeUndefined();
189
+ });
190
+ });
120
191
  });
@@ -35,6 +35,12 @@ export function createProvidersRoutes(providerService, options = {}) {
35
35
  res.json(provider);
36
36
  }));
37
37
 
38
+ // GET /providers/samples - Get sample providers not yet in user's config
39
+ router.get('/samples', asyncHandler(async (req, res) => {
40
+ const providers = await providerService.getSampleProviders();
41
+ res.json({ providers });
42
+ }));
43
+
38
44
  // GET /providers/:id - Get provider by ID
39
45
  router.get('/:id', asyncHandler(async (req, res) => {
40
46
  const provider = await providerService.getProviderById(req.params.id);
@@ -188,6 +188,7 @@ export function createRunnerService(config = {}) {
188
188
  await writeFile(join(runDir, 'output.txt'), '');
189
189
 
190
190
  hooks.onRunCreated?.(metadata);
191
+ console.log(`🤖 AI run [${source}]: ${provider.name}/${metadata.model}`);
191
192
 
192
193
  const effectiveTimeout = timeout || provider.timeout;
193
194